feat(editor): add outline stroke command

This commit is contained in:
Danila Poyarkov 2026-05-17 20:18:14 +03:00
parent 655f4fcb89
commit 1c8fe1ee8b
26 changed files with 320 additions and 48 deletions

View file

@ -218,6 +218,10 @@
"id": "selection.outlineText",
"label": "Outline text"
},
{
"id": "selection.outlineStroke",
"label": "Outline stroke"
},
{
"type": "separator"
},

View file

@ -4,9 +4,9 @@ import type { SceneGraph, SceneNode } from '#core/scene-graph'
import { getTextOutlineSupport } from '#core/text/outlines'
import { makeArcPath } from './fills'
import { textNodeToOutlinePath } from './text-outlines'
import type { SkiaRenderer } from './renderer'
import { nodeHasRadius } from './shapes'
import { textNodeToOutlinePath } from './text-outlines'
const BOOLEAN_PATH_OP: Record<
NonNullable<SceneNode['booleanOperation']>,
@ -82,6 +82,10 @@ function nodeHasVisibleFill(node: SceneNode): boolean {
return node.fills.some((fill) => fill.visible)
}
export function nodeHasVisibleStroke(node: SceneNode): boolean {
return node.strokes.some((stroke) => stroke.visible && stroke.weight > 0)
}
function addVisibleStrokeOutlines(target: Path, source: Path, node: SceneNode): void {
for (const stroke of node.strokes) {
if (!stroke.visible || stroke.weight <= 0) continue
@ -100,11 +104,36 @@ function canContainFlattenableChildren(node: SceneNode): boolean {
)
}
function appendVisibleChildPaths(
r: SkiaRenderer,
graph: SceneGraph,
parent: SceneNode,
target: Path,
makeChildPath: (child: SceneNode) => Path | null,
failOnMissing: boolean
): boolean | null {
let hasPath = false
for (const childId of parent.childIds) {
const child = graph.getNode(childId)
if (!child || !child.visible) continue
const childPath = makeChildPath(child)
if (!childPath) {
if (failOnMissing) return null
continue
}
childPath.transform(nodePathTransform(r, child))
target.addPath(childPath)
childPath.delete()
hasPath = true
}
return hasPath
}
function containerSourcePath(r: SkiaRenderer, node: SceneNode, graph: SceneGraph): Path | null {
const path = new r.ck.Path()
let hasPath = false
if (nodeHasVisibleFill(node) || node.strokes.some((stroke) => stroke.visible)) {
if (nodeHasVisibleFill(node) || nodeHasVisibleStroke(node)) {
const ownPath = baseShapePath(r, node)
if (ownPath) {
if (nodeHasVisibleFill(node)) path.addPath(ownPath)
@ -114,19 +143,19 @@ function containerSourcePath(r: SkiaRenderer, node: SceneNode, graph: SceneGraph
}
}
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (!child || !child.visible) continue
const childPath = makeBooleanSourcePath(r, child, graph)
if (!childPath) {
path.delete()
return null
}
childPath.transform(nodePathTransform(r, child))
path.addPath(childPath)
childPath.delete()
hasPath = true
const childPaths = appendVisibleChildPaths(
r,
graph,
node,
path,
(child) => makeBooleanSourcePath(r, child, graph),
true
)
if (childPaths === null) {
path.delete()
return null
}
hasPath ||= childPaths
if (!hasPath) {
path.delete()
@ -147,10 +176,54 @@ export function makeBooleanSourcePath(
const path = baseShapePath(r, node)
if (!path) return null
if (node.strokes.some((stroke) => stroke.visible)) addVisibleStrokeOutlines(path, path, node)
if (nodeHasVisibleStroke(node)) addVisibleStrokeOutlines(path, path, node)
return path
}
export function makeStrokeOutlinePath(
r: SkiaRenderer,
node: SceneNode,
graph: SceneGraph
): Path | null {
if (!canMakeBooleanSourcePath(node)) return null
if (canContainFlattenableChildren(node)) {
const path = new r.ck.Path()
let hasPath = false
if (nodeHasVisibleStroke(node)) {
const ownPath = baseShapePath(r, node)
if (ownPath) {
addVisibleStrokeOutlines(path, ownPath, node)
ownPath.delete()
hasPath = true
}
}
hasPath ||=
appendVisibleChildPaths(
r,
graph,
node,
path,
(child) => makeStrokeOutlinePath(r, child, graph),
false
) ?? false
if (hasPath) return path
path.delete()
return null
}
if (!nodeHasVisibleStroke(node)) return null
const path =
node.type === 'BOOLEAN_OPERATION'
? makeBooleanOperationPath(r, node, graph)
: baseShapePath(r, node)
if (!path) return null
if (node.type === 'LINE') return path
const outline = new r.ck.Path()
addVisibleStrokeOutlines(outline, path, node)
path.delete()
return outline
}
function transformedShapePath(r: SkiaRenderer, child: SceneNode, graph: SceneGraph): Path | null {
const path = makeBooleanSourcePath(r, child, graph)
if (!path) return null

View file

@ -2,17 +2,29 @@ import { parseSVGPath } from '#core/io/formats/svg/parse-path'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import { copyFills } from '#core/scene-graph/copy'
import { makeBooleanSourcePath, nodePathTransform } from './boolean'
import { makeBooleanSourcePath, makeStrokeOutlinePath, nodePathTransform } from './boolean'
import type { SkiaRenderer } from './renderer'
export function flattenNodesToVectorProps(
type VectorFlattenProps = Pick<
SceneNode,
'name' | 'x' | 'y' | 'width' | 'height' | 'fills' | 'vectorNetwork'
>
type NodePathFactory = (
renderer: SkiaRenderer,
graph: SceneGraph,
nodes: SceneNode[]
): Pick<SceneNode, 'name' | 'x' | 'y' | 'width' | 'height' | 'fills' | 'vectorNetwork'> | null {
node: SceneNode
) => ReturnType<typeof makeBooleanSourcePath>
function nodesToVectorProps(
renderer: SkiaRenderer,
graph: SceneGraph,
nodes: SceneNode[],
makeNodePath: NodePathFactory
): VectorFlattenProps | null {
const path = new renderer.ck.Path()
for (const node of nodes) {
const nodePath = makeBooleanSourcePath(renderer, node, graph)
const nodePath = makeNodePath(renderer, graph, node)
if (!nodePath) {
path.delete()
return null
@ -42,3 +54,23 @@ export function flattenNodesToVectorProps(
vectorNetwork
}
}
export function flattenNodesToVectorProps(
renderer: SkiaRenderer,
graph: SceneGraph,
nodes: SceneNode[]
): VectorFlattenProps | null {
return nodesToVectorProps(renderer, graph, nodes, (r, g, node) =>
makeBooleanSourcePath(r, node, g)
)
}
export function outlineStrokeNodesToVectorProps(
renderer: SkiaRenderer,
graph: SceneGraph,
nodes: SceneNode[]
): VectorFlattenProps | null {
return nodesToVectorProps(renderer, graph, nodes, (r, g, node) =>
makeStrokeOutlinePath(r, node, g)
)
}

View file

@ -1,3 +1,3 @@
export { canMakeBooleanSourceNode, canMakeBooleanSourcePath } from './boolean'
export { canMakeBooleanSourceNode, canMakeBooleanSourcePath, nodeHasVisibleStroke } from './boolean'
export { SkiaRenderer, type RenderOverlays, type RulerTheme } from './renderer'
export { getAbsolutePositionFull, getAbsoluteRotation, getWorldHandles } from './coordinate'

View file

@ -13,6 +13,7 @@ export function createStructureBridge(structure: StructureActions, selection: Se
structure.booleanOperationSelected(selection.getSelectedNodes(), operation),
flattenSelected: () => structure.flattenSelected(selection.getSelectedNodes()),
outlineTextSelected: () => structure.outlineTextSelected(selection.getSelectedNodes()),
outlineStrokeSelected: () => structure.outlineStrokeSelected(selection.getSelectedNodes()),
ungroupSelected: () => structure.ungroupSelected(selection.getSelectedNode())
}
}

View file

@ -7,7 +7,10 @@ import {
type BooleanOperation
} from './structure/boolean'
import { wrapSelectionInContainer as wrapSelectionInContainerImpl } from './structure/container-wrap'
import { flattenSelected as flattenSelectedImpl } from './structure/flatten'
import {
flattenSelected as flattenSelectedImpl,
outlineStrokeSelected as outlineStrokeSelectedImpl
} from './structure/flatten'
import { ungroupSelected as ungroupImpl } from './structure/group'
import { createStructureReorderActions } from './structure/reorder'
import { createStructureStateActions } from './structure/state'
@ -74,8 +77,13 @@ export function createStructureActions(ctx: EditorContext) {
}
function outlineTextSelected(selectedNodes: SceneNode[]) {
if (selectedNodes.length === 0 || selectedNodes.some((node) => node.type !== 'TEXT')) return null
return flattenSelectedImpl(ctx, selectedNodes, 'Outline text')
if (selectedNodes.length === 0 || selectedNodes.some((node) => node.type !== 'TEXT'))
return null
return flattenSelectedImpl(ctx, selectedNodes, { label: 'Outline text' })
}
function outlineStrokeSelected(selectedNodes: SceneNode[]) {
return outlineStrokeSelectedImpl(ctx, selectedNodes)
}
function moveToPage(pageId: string) {
@ -107,6 +115,7 @@ export function createStructureActions(ctx: EditorContext) {
ungroupSelected,
flattenSelected,
outlineTextSelected,
outlineStrokeSelected,
...stateActions,
moveToPage,
renameNode

View file

@ -1,28 +1,48 @@
import { canMakeBooleanSourceNode } from '#core/canvas/boolean'
import { flattenNodesToVectorProps } from '#core/canvas/flatten'
import { canMakeBooleanSourceNode, nodeHasVisibleStroke } from '#core/canvas/boolean'
import { flattenNodesToVectorProps, outlineStrokeNodesToVectorProps } from '#core/canvas/flatten'
import { restoreSubtree, snapshotSubtree } from '#core/editor/clipboard/subtree-history'
import type { EditorContext } from '#core/editor/types'
import type { SceneNode } from '#core/scene-graph'
import { selectedNodesInSharedParent } from './selection'
export function flattenSelected(ctx: EditorContext, selectedNodes: SceneNode[], label = 'Flatten') {
type VectorPropsFactory = typeof flattenNodesToVectorProps
type FlattenOptions = {
label?: string
canFlattenNode?: (node: SceneNode) => boolean
vectorPropsFactory?: VectorPropsFactory
}
export function flattenSelected(
ctx: EditorContext,
selectedNodes: SceneNode[],
options: FlattenOptions = {}
) {
const label = options.label ?? 'Flatten'
const canFlattenNode =
options.canFlattenNode ?? ((node: SceneNode) => canMakeBooleanSourceNode(node, ctx.graph))
const vectorPropsFactory = options.vectorPropsFactory ?? flattenNodesToVectorProps
const renderer = ctx.getRenderer()
if (!renderer) return null
const selection = selectedNodesInSharedParent(ctx, selectedNodes)
if (!selection) return null
const { topLevel, parentId, parent } = selection
if (topLevel.some((node) => !canMakeBooleanSourceNode(node, ctx.graph))) return null
if (topLevel.some((node) => !canFlattenNode(node))) return null
const childIds = topLevel.map((node) => node.id)
const childSnapshots = childIds.map((id) => ({ id, subtree: snapshotSubtree(ctx.graph, id) }))
const prevSelection = new Set(ctx.state.selectedIds)
const firstIndex = Math.min(...childIds.map((id) => parent.childIds.indexOf(id)))
const vectorProps = flattenNodesToVectorProps(renderer, ctx.graph, topLevel)
const vectorProps = vectorPropsFactory(renderer, ctx.graph, topLevel)
if (!vectorProps) return null
const vector = ctx.graph.createNode('VECTOR', parentId, { ...vectorProps, name: label, strokes: [] })
const vector = ctx.graph.createNode('VECTOR', parentId, {
...vectorProps,
name: label,
strokes: []
})
const vectorSnapshot = structuredClone(vector)
ctx.graph.insertChildAt(vector.id, parentId, firstIndex)
for (const id of childIds) ctx.graph.deleteNode(id)
@ -51,3 +71,12 @@ export function flattenSelected(ctx: EditorContext, selectedNodes: SceneNode[],
return vector.id
}
export function outlineStrokeSelected(ctx: EditorContext, selectedNodes: SceneNode[]) {
return flattenSelected(ctx, selectedNodes, {
label: 'Outline stroke',
canFlattenNode: (node) =>
canMakeBooleanSourceNode(node, ctx.graph) && nodeHasVisibleStroke(node),
vectorPropsFactory: outlineStrokeNodesToVectorProps
})
}

View file

@ -89,6 +89,7 @@ export const EDITOR_COMMAND_METADATA = {
contextTestId: 'context-flatten'
},
'selection.outlineText': { contextTestId: 'context-outline-text' },
'selection.outlineStroke': { contextTestId: 'context-outline-stroke' },
'selection.moveToPage': {},
'view.zoom100': { keybinding: '$mod+Digit0' },
'view.zoomFit': { keybinding: ['$mod+Digit1', 'Shift+Digit1'] },

View file

@ -207,6 +207,14 @@ export function createSelectionCommands({
enabled: capabilities.canOutlineText,
run: () => editor.outlineTextSelected()
},
'selection.outlineStroke': {
id: 'selection.outlineStroke',
get label() {
return t.value.outlineStroke
},
enabled: capabilities.canOutlineStroke,
run: () => editor.outlineStrokeSelected()
},
'selection.moveToPage': {
id: 'selection.moveToPage',
get label() {

View file

@ -27,6 +27,7 @@ export type EditorCommandId =
| 'selection.booleanExclude'
| 'selection.flatten'
| 'selection.outlineText'
| 'selection.outlineStroke'
| 'selection.moveToPage'
| 'view.zoom100'
| 'view.zoomFit'

View file

@ -37,7 +37,8 @@ const CANVAS_MENU_GROUPS = [
'selection.ungroupWhenGroup',
'selection.wrapInAutoLayout',
'selection.flatten',
'selection.outlineText'
'selection.outlineText',
'selection.outlineStroke'
],
['selection.componentAction', 'selection.componentSetAction', 'selection.instanceActions'],
['selection.toggleVisibility', 'selection.toggleLock'],

View file

@ -1,6 +1,6 @@
import { computed } from 'vue'
import { canMakeBooleanSourceNode } from '@open-pencil/core/canvas'
import { canMakeBooleanSourceNode, nodeHasVisibleStroke } from '@open-pencil/core/canvas'
import { useSelectionState } from '#vue/editor/selection-state/use'
import { useSceneComputed } from '#vue/internal/scene-computed/use'
@ -51,6 +51,15 @@ export function useSelectionCapabilities() {
nodes.every((node) => node.type === 'TEXT' && canMakeBooleanSourceNode(node, editor.graph))
)
}),
canOutlineStroke: useSceneComputed(() => {
const nodes = editor.getSelectedNodes()
return (
nodes.length > 0 &&
nodes.every(
(node) => nodeHasVisibleStroke(node) && canMakeBooleanSourceNode(node, editor.graph)
)
)
}),
canGoToMainComponent: computed(() => selection.isInstance.value),
canCreateInstance: computed(() => selectedNode.value?.type === 'COMPONENT'),
canMoveToPage: useSceneComputed(() => hasSelection.value && editor.graph.getPages().length > 1),

View file

@ -100,6 +100,7 @@ export const commandMessages = i18n('commands', {
excludeSelection: 'Exclude selection',
flattenSelection: 'Flatten',
outlineText: 'Outline text',
outlineStroke: 'Outline stroke',
booleanOperations: 'Boolean operations',
flipHorizontal: 'Flip horizontal',
flipVertical: 'Flip vertical',

View file

@ -75,7 +75,8 @@
"flipHorizontal": "Horizontal spiegeln",
"flipVertical": "Vertikal spiegeln",
"flattenSelection": "Flatten",
"outlineText": "Outline text"
"outlineText": "Outline text",
"outlineStroke": "Outline stroke"
},
"tools": {
"move": "Verschieben",

View file

@ -75,7 +75,8 @@
"flipHorizontal": "Voltear horizontalmente",
"flipVertical": "Voltear verticalmente",
"flattenSelection": "Flatten",
"outlineText": "Outline text"
"outlineText": "Outline text",
"outlineStroke": "Outline stroke"
},
"tools": {
"move": "Mover",

View file

@ -75,7 +75,8 @@
"flipHorizontal": "Retourner horizontalement",
"flipVertical": "Retourner verticalement",
"flattenSelection": "Flatten",
"outlineText": "Outline text"
"outlineText": "Outline text",
"outlineStroke": "Outline stroke"
},
"tools": {
"move": "Déplacer",

View file

@ -75,7 +75,8 @@
"flipHorizontal": "Rifletti orizzontalmente",
"flipVertical": "Rifletti verticalmente",
"flattenSelection": "Flatten",
"outlineText": "Outline text"
"outlineText": "Outline text",
"outlineStroke": "Outline stroke"
},
"tools": {
"move": "Sposta",

View file

@ -75,7 +75,8 @@
"flipHorizontal": "Odbij poziomo",
"flipVertical": "Odbij pionowo",
"flattenSelection": "Flatten",
"outlineText": "Outline text"
"outlineText": "Outline text",
"outlineStroke": "Outline stroke"
},
"tools": {
"move": "Przesuń",

View file

@ -75,7 +75,8 @@
"flipHorizontal": "Отразить по горизонтали",
"flipVertical": "Отразить по вертикали",
"flattenSelection": "Flatten",
"outlineText": "Outline text"
"outlineText": "Outline text",
"outlineStroke": "Outline stroke"
},
"tools": {
"move": "Перемещение",

View file

@ -75,7 +75,8 @@
"flipHorizontal": "水平翻转",
"flipVertical": "垂直翻转",
"flattenSelection": "Flatten",
"outlineText": "Outline text"
"outlineText": "Outline text",
"outlineStroke": "Outline stroke"
},
"tools": {
"move": "移动",

View file

@ -179,6 +179,11 @@ export const APP_MENU_SCHEMA = [
label: 'Outline text',
command: 'selection.outlineText'
},
{
id: 'selection.outlineStroke',
label: 'Outline stroke',
command: 'selection.outlineStroke'
},
{ type: 'separator' },
{
id: 'selection.createComponent',

View file

@ -31,6 +31,7 @@ const COMMAND_MENU_IDS = new Set<string>([
'selection.booleanExclude',
'selection.flatten',
'selection.outlineText',
'selection.outlineStroke',
'selection.bringToFront',
'selection.sendToBack',
'view.zoom100',

View file

@ -12,6 +12,7 @@ import IconCombine from '~icons/lucide/combine'
import IconCopyMinus from '~icons/lucide/copy-minus'
import IconCopyX from '~icons/lucide/copy-x'
import IconListCollapse from '~icons/lucide/list-collapse'
import IconSpline from '~icons/lucide/spline'
import IconTypeOutline from '~icons/lucide/type-outline'
import IconSquaresIntersect from '~icons/lucide/squares-intersect'
import {
@ -64,7 +65,8 @@ const booleanCommandIcons = {
'selection.booleanIntersect': IconSquaresIntersect,
'selection.booleanExclude': IconCopyX,
'selection.flatten': IconListCollapse,
'selection.outlineText': IconTypeOutline
'selection.outlineText': IconTypeOutline,
'selection.outlineStroke': IconSpline
} satisfies Partial<Record<EditorCommandId, Component>>
function contextCommandTestId(id: EditorCommandId | undefined): string | undefined {

View file

@ -80,6 +80,7 @@ test('context menu shows expected items', async () => {
await expect(contextItem('context-toggle-lock')).toBeVisible()
await expect(contextItem('context-flatten')).toBeVisible()
await expect(contextItem('context-outline-text')).toBeVisible()
await expect(contextItem('context-outline-stroke')).toBeVisible()
await expect(contextItem('context-boolean-union')).toHaveCount(0)
await editor.page.keyboard.press('Escape')

View file

@ -36,8 +36,18 @@ describe('flattenSelected', () => {
test('converts selected shapes into a vector path', async () => {
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const first = editor.graph.createNode('RECTANGLE', pageId, { x: 10, y: 20, width: 50, height: 40 })
const second = editor.graph.createNode('ELLIPSE', pageId, { x: 40, y: 30, width: 50, height: 40 })
const first = editor.graph.createNode('RECTANGLE', pageId, {
x: 10,
y: 20,
width: 50,
height: 40
})
const second = editor.graph.createNode('ELLIPSE', pageId, {
x: 40,
y: 30,
width: 50,
height: 40
})
editor.select([first.id, second.id])
editor.flattenSelected()
@ -55,6 +65,53 @@ describe('flattenSelected', () => {
surface.delete()
})
test('outlines visible strokes into a vector path', async () => {
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const rect = editor.graph.createNode('RECTANGLE', pageId, {
x: 20,
y: 30,
width: 40,
height: 30,
fills: [TRANSPARENT],
strokes: [{ type: 'SOLID', color: BLACK, weight: 8, opacity: 1, visible: true }]
})
editor.select([rect.id])
editor.outlineStrokeSelected()
const [vectorId] = [...editor.state.selectedIds]
const vector = editor.graph.getNode(vectorId)
expect(vector?.type).toBe('VECTOR')
expect(vector?.name).toBe('Outline stroke')
expect(vector?.x).toBe(16)
expect(vector?.y).toBe(26)
expect(vector?.width).toBe(48)
expect(vector?.height).toBe(38)
expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0)
expect(editor.graph.getNode(rect.id)).toBeUndefined()
surface.delete()
})
test('does not outline fill-only shapes as strokes', async () => {
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const rect = editor.graph.createNode('RECTANGLE', pageId, {
x: 20,
y: 30,
width: 40,
height: 30,
strokes: []
})
editor.select([rect.id])
const result = editor.outlineStrokeSelected()
expect(result).toBeNull()
expect(editor.graph.getNode(rect.id)?.type).toBe('RECTANGLE')
surface.delete()
})
test('outlines loaded text through the shared text outline path', async () => {
await loadInterRegular()
const { editor, surface } = await createEditorWithRenderer()
@ -177,7 +234,10 @@ describe('flattenSelected', () => {
test('does not flatten unsupported text nodes', async () => {
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const text = editor.graph.createNode('TEXT', pageId, { text: 'Nope', fontFamily: 'Definitely Missing Font' })
const text = editor.graph.createNode('TEXT', pageId, {
text: 'Nope',
fontFamily: 'Definitely Missing Font'
})
const rect = editor.graph.createNode('RECTANGLE', pageId)
editor.select([text.id, rect.id])
@ -194,7 +254,16 @@ describe('flattenSelected', () => {
const pageId = editor.state.currentPageId
const rect = editor.graph.createNode('RECTANGLE', pageId)
const image = editor.graph.createNode('RECTANGLE', pageId, {
fills: [{ type: 'IMAGE', imageHash: 'image', imageScaleMode: 'FILL', color: TRANSPARENT, opacity: 1, visible: true }]
fills: [
{
type: 'IMAGE',
imageHash: 'image',
imageScaleMode: 'FILL',
color: TRANSPARENT,
opacity: 1,
visible: true
}
]
})
editor.select([rect.id, image.id])
editor.groupSelected()
@ -210,8 +279,18 @@ describe('flattenSelected', () => {
test('flattens visual descendants from groups', async () => {
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const rect = editor.graph.createNode('RECTANGLE', pageId, { x: 10, y: 20, width: 50, height: 40 })
const ellipse = editor.graph.createNode('ELLIPSE', pageId, { x: 70, y: 20, width: 30, height: 30 })
const rect = editor.graph.createNode('RECTANGLE', pageId, {
x: 10,
y: 20,
width: 50,
height: 40
})
const ellipse = editor.graph.createNode('ELLIPSE', pageId, {
x: 70,
y: 20,
width: 30,
height: 30
})
editor.select([rect.id, ellipse.id])
editor.groupSelected()
const [groupId] = [...editor.state.selectedIds]
@ -235,7 +314,9 @@ describe('flattenSelected', () => {
width: 50,
height: 40,
fills: [],
strokes: [{ type: 'SOLID', color: BLACK, opacity: 1, visible: true, weight: 8, align: 'CENTER' }]
strokes: [
{ type: 'SOLID', color: BLACK, opacity: 1, visible: true, weight: 8, align: 'CENTER' }
]
})
editor.select([rect.id])
@ -263,7 +344,12 @@ describe('flattenSelected', () => {
expect(editor.graph.getNode(pageId)?.childIds).toEqual([before.id, vectorId, after.id])
editor.undo.undo()
expect(editor.graph.getNode(pageId)?.childIds).toEqual([before.id, first.id, second.id, after.id])
expect(editor.graph.getNode(pageId)?.childIds).toEqual([
before.id,
first.id,
second.id,
after.id
])
expect(editor.state.selectedIds).toEqual(new Set([first.id, second.id]))
editor.undo.redo()

View file

@ -49,6 +49,7 @@ describe('buildCanvasContextMenu', () => {
'selection.wrapInAutoLayout',
'selection.flatten',
'selection.outlineText',
'selection.outlineStroke',
'---',
'selection.createComponent',
'---',