From 1c8fe1ee8bbdc881a1d165b65936c4be31f67fac Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 17 May 2026 20:18:14 +0300 Subject: [PATCH] feat(editor): add outline stroke command --- desktop/generated/menu.json | 4 + packages/core/src/canvas/boolean.ts | 103 +++++++++++++++--- packages/core/src/canvas/flatten.ts | 42 ++++++- packages/core/src/canvas/index.ts | 2 +- packages/core/src/editor/bridges/structure.ts | 1 + packages/core/src/editor/structure.ts | 15 ++- packages/core/src/editor/structure/flatten.ts | 41 ++++++- packages/vue/src/editor/commands/registry.ts | 1 + packages/vue/src/editor/commands/selection.ts | 8 ++ packages/vue/src/editor/commands/types.ts | 1 + packages/vue/src/editor/menu-model/canvas.ts | 3 +- .../src/editor/selection-capabilities/use.ts | 11 +- packages/vue/src/i18n/messages.ts | 1 + packages/vue/src/locales/de.json | 3 +- packages/vue/src/locales/es.json | 3 +- packages/vue/src/locales/fr.json | 3 +- packages/vue/src/locales/it.json | 3 +- packages/vue/src/locales/pl.json | 3 +- packages/vue/src/locales/ru.json | 3 +- packages/vue/src/locales/zh-CN.json | 3 +- src/app/shell/menu/schema.ts | 5 + src/app/shell/menu/use.ts | 1 + src/components/CanvasMenu.vue | 4 +- tests/e2e/context-menu/basic.spec.ts | 1 + tests/engine/editor/structure/flatten.test.ts | 102 +++++++++++++++-- .../vue/editor/menu-model/canvas.test.ts | 1 + 26 files changed, 320 insertions(+), 48 deletions(-) diff --git a/desktop/generated/menu.json b/desktop/generated/menu.json index 800e26c42..b5aadf848 100644 --- a/desktop/generated/menu.json +++ b/desktop/generated/menu.json @@ -218,6 +218,10 @@ "id": "selection.outlineText", "label": "Outline text" }, + { + "id": "selection.outlineStroke", + "label": "Outline stroke" + }, { "type": "separator" }, diff --git a/packages/core/src/canvas/boolean.ts b/packages/core/src/canvas/boolean.ts index 94e34e730..7ed44ad62 100644 --- a/packages/core/src/canvas/boolean.ts +++ b/packages/core/src/canvas/boolean.ts @@ -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, @@ -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 diff --git a/packages/core/src/canvas/flatten.ts b/packages/core/src/canvas/flatten.ts index bdc54783d..db36a25dd 100644 --- a/packages/core/src/canvas/flatten.ts +++ b/packages/core/src/canvas/flatten.ts @@ -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 | null { + node: SceneNode +) => ReturnType + +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) + ) +} diff --git a/packages/core/src/canvas/index.ts b/packages/core/src/canvas/index.ts index 91d6d7392..645775d8d 100644 --- a/packages/core/src/canvas/index.ts +++ b/packages/core/src/canvas/index.ts @@ -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' diff --git a/packages/core/src/editor/bridges/structure.ts b/packages/core/src/editor/bridges/structure.ts index 1b64a4a15..762901703 100644 --- a/packages/core/src/editor/bridges/structure.ts +++ b/packages/core/src/editor/bridges/structure.ts @@ -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()) } } diff --git a/packages/core/src/editor/structure.ts b/packages/core/src/editor/structure.ts index 46cfe3b29..27d3a34cf 100644 --- a/packages/core/src/editor/structure.ts +++ b/packages/core/src/editor/structure.ts @@ -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 diff --git a/packages/core/src/editor/structure/flatten.ts b/packages/core/src/editor/structure/flatten.ts index 5b6eaa8c5..1c8f588f1 100644 --- a/packages/core/src/editor/structure/flatten.ts +++ b/packages/core/src/editor/structure/flatten.ts @@ -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 + }) +} diff --git a/packages/vue/src/editor/commands/registry.ts b/packages/vue/src/editor/commands/registry.ts index 5794d202a..e288c58d2 100644 --- a/packages/vue/src/editor/commands/registry.ts +++ b/packages/vue/src/editor/commands/registry.ts @@ -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'] }, diff --git a/packages/vue/src/editor/commands/selection.ts b/packages/vue/src/editor/commands/selection.ts index 96eaa6a17..17537ab50 100644 --- a/packages/vue/src/editor/commands/selection.ts +++ b/packages/vue/src/editor/commands/selection.ts @@ -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() { diff --git a/packages/vue/src/editor/commands/types.ts b/packages/vue/src/editor/commands/types.ts index 98c3a00e5..aa0cd74f3 100644 --- a/packages/vue/src/editor/commands/types.ts +++ b/packages/vue/src/editor/commands/types.ts @@ -27,6 +27,7 @@ export type EditorCommandId = | 'selection.booleanExclude' | 'selection.flatten' | 'selection.outlineText' + | 'selection.outlineStroke' | 'selection.moveToPage' | 'view.zoom100' | 'view.zoomFit' diff --git a/packages/vue/src/editor/menu-model/canvas.ts b/packages/vue/src/editor/menu-model/canvas.ts index 335963a15..96379fc63 100644 --- a/packages/vue/src/editor/menu-model/canvas.ts +++ b/packages/vue/src/editor/menu-model/canvas.ts @@ -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'], diff --git a/packages/vue/src/editor/selection-capabilities/use.ts b/packages/vue/src/editor/selection-capabilities/use.ts index b7a056653..402fabb1e 100644 --- a/packages/vue/src/editor/selection-capabilities/use.ts +++ b/packages/vue/src/editor/selection-capabilities/use.ts @@ -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), diff --git a/packages/vue/src/i18n/messages.ts b/packages/vue/src/i18n/messages.ts index bf1b2aeee..68b4caf5c 100644 --- a/packages/vue/src/i18n/messages.ts +++ b/packages/vue/src/i18n/messages.ts @@ -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', diff --git a/packages/vue/src/locales/de.json b/packages/vue/src/locales/de.json index 4b0434800..e07b988a6 100644 --- a/packages/vue/src/locales/de.json +++ b/packages/vue/src/locales/de.json @@ -75,7 +75,8 @@ "flipHorizontal": "Horizontal spiegeln", "flipVertical": "Vertikal spiegeln", "flattenSelection": "Flatten", - "outlineText": "Outline text" + "outlineText": "Outline text", + "outlineStroke": "Outline stroke" }, "tools": { "move": "Verschieben", diff --git a/packages/vue/src/locales/es.json b/packages/vue/src/locales/es.json index 55c1a308d..2a614456f 100644 --- a/packages/vue/src/locales/es.json +++ b/packages/vue/src/locales/es.json @@ -75,7 +75,8 @@ "flipHorizontal": "Voltear horizontalmente", "flipVertical": "Voltear verticalmente", "flattenSelection": "Flatten", - "outlineText": "Outline text" + "outlineText": "Outline text", + "outlineStroke": "Outline stroke" }, "tools": { "move": "Mover", diff --git a/packages/vue/src/locales/fr.json b/packages/vue/src/locales/fr.json index bb6118719..6eb71a705 100644 --- a/packages/vue/src/locales/fr.json +++ b/packages/vue/src/locales/fr.json @@ -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", diff --git a/packages/vue/src/locales/it.json b/packages/vue/src/locales/it.json index b701d94f1..1e8d8bc21 100644 --- a/packages/vue/src/locales/it.json +++ b/packages/vue/src/locales/it.json @@ -75,7 +75,8 @@ "flipHorizontal": "Rifletti orizzontalmente", "flipVertical": "Rifletti verticalmente", "flattenSelection": "Flatten", - "outlineText": "Outline text" + "outlineText": "Outline text", + "outlineStroke": "Outline stroke" }, "tools": { "move": "Sposta", diff --git a/packages/vue/src/locales/pl.json b/packages/vue/src/locales/pl.json index e7c27f2f1..1bbb275ab 100644 --- a/packages/vue/src/locales/pl.json +++ b/packages/vue/src/locales/pl.json @@ -75,7 +75,8 @@ "flipHorizontal": "Odbij poziomo", "flipVertical": "Odbij pionowo", "flattenSelection": "Flatten", - "outlineText": "Outline text" + "outlineText": "Outline text", + "outlineStroke": "Outline stroke" }, "tools": { "move": "Przesuń", diff --git a/packages/vue/src/locales/ru.json b/packages/vue/src/locales/ru.json index ea254a28c..0df036bf8 100644 --- a/packages/vue/src/locales/ru.json +++ b/packages/vue/src/locales/ru.json @@ -75,7 +75,8 @@ "flipHorizontal": "Отразить по горизонтали", "flipVertical": "Отразить по вертикали", "flattenSelection": "Flatten", - "outlineText": "Outline text" + "outlineText": "Outline text", + "outlineStroke": "Outline stroke" }, "tools": { "move": "Перемещение", diff --git a/packages/vue/src/locales/zh-CN.json b/packages/vue/src/locales/zh-CN.json index 7a896f07a..9e4a4d5ae 100644 --- a/packages/vue/src/locales/zh-CN.json +++ b/packages/vue/src/locales/zh-CN.json @@ -75,7 +75,8 @@ "flipHorizontal": "水平翻转", "flipVertical": "垂直翻转", "flattenSelection": "Flatten", - "outlineText": "Outline text" + "outlineText": "Outline text", + "outlineStroke": "Outline stroke" }, "tools": { "move": "移动", diff --git a/src/app/shell/menu/schema.ts b/src/app/shell/menu/schema.ts index 217e5d2fc..2dc968729 100644 --- a/src/app/shell/menu/schema.ts +++ b/src/app/shell/menu/schema.ts @@ -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', diff --git a/src/app/shell/menu/use.ts b/src/app/shell/menu/use.ts index 78c5cdffc..66f6b7f39 100644 --- a/src/app/shell/menu/use.ts +++ b/src/app/shell/menu/use.ts @@ -31,6 +31,7 @@ const COMMAND_MENU_IDS = new Set([ 'selection.booleanExclude', 'selection.flatten', 'selection.outlineText', + 'selection.outlineStroke', 'selection.bringToFront', 'selection.sendToBack', 'view.zoom100', diff --git a/src/components/CanvasMenu.vue b/src/components/CanvasMenu.vue index bbb335db9..35f1f859b 100644 --- a/src/components/CanvasMenu.vue +++ b/src/components/CanvasMenu.vue @@ -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> function contextCommandTestId(id: EditorCommandId | undefined): string | undefined { diff --git a/tests/e2e/context-menu/basic.spec.ts b/tests/e2e/context-menu/basic.spec.ts index c6f1338d3..a0e107f59 100644 --- a/tests/e2e/context-menu/basic.spec.ts +++ b/tests/e2e/context-menu/basic.spec.ts @@ -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') diff --git a/tests/engine/editor/structure/flatten.test.ts b/tests/engine/editor/structure/flatten.test.ts index ac617785c..e71640559 100644 --- a/tests/engine/editor/structure/flatten.test.ts +++ b/tests/engine/editor/structure/flatten.test.ts @@ -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() diff --git a/tests/engine/vue/editor/menu-model/canvas.test.ts b/tests/engine/vue/editor/menu-model/canvas.test.ts index 1255372dc..1bfc979d6 100644 --- a/tests/engine/vue/editor/menu-model/canvas.test.ts +++ b/tests/engine/vue/editor/menu-model/canvas.test.ts @@ -49,6 +49,7 @@ describe('buildCanvasContextMenu', () => { 'selection.wrapInAutoLayout', 'selection.flatten', 'selection.outlineText', + 'selection.outlineStroke', '---', 'selection.createComponent', '---',