From b1ad6d41e9d22aced13f6f431000db9cf65fff72 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 17 May 2026 20:27:45 +0300 Subject: [PATCH] fix(editor): enable outlining grouped strokes --- packages/core/src/canvas/boolean.ts | 14 +++++++++-- packages/core/src/canvas/index.ts | 7 +++++- packages/core/src/editor/structure/flatten.ts | 4 ++-- .../src/editor/selection-capabilities/use.ts | 6 +++-- tests/engine/editor/structure/flatten.test.ts | 24 +++++++++++++++++++ 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/core/src/canvas/boolean.ts b/packages/core/src/canvas/boolean.ts index 7ed44ad62..353a1dfe1 100644 --- a/packages/core/src/canvas/boolean.ts +++ b/packages/core/src/canvas/boolean.ts @@ -66,7 +66,9 @@ function lineStrokePath(r: SkiaRenderer, node: SceneNode): Path | null { path.moveTo(0, 0) path.lineTo(node.width, node.height) const stroke = node.strokes.find((item) => item.visible) - return path.stroke({ width: stroke?.weight ?? 1 }) + const outline = path.stroke({ width: stroke?.weight ?? 1 }) + path.delete() + return outline } function baseShapePath(r: SkiaRenderer, node: SceneNode): Path | null { @@ -180,6 +182,15 @@ export function makeBooleanSourcePath( return path } +export function hasVisibleStrokeSourceNode(node: SceneNode, graph: SceneGraph): boolean { + if (nodeHasVisibleStroke(node)) return true + if (!canContainFlattenableChildren(node)) return false + return node.childIds.some((childId) => { + const child = graph.getNode(childId) + return child?.visible === true && hasVisibleStrokeSourceNode(child, graph) + }) +} + export function makeStrokeOutlinePath( r: SkiaRenderer, node: SceneNode, @@ -220,7 +231,6 @@ export function makeStrokeOutlinePath( if (node.type === 'LINE') return path const outline = new r.ck.Path() addVisibleStrokeOutlines(outline, path, node) - path.delete() return outline } diff --git a/packages/core/src/canvas/index.ts b/packages/core/src/canvas/index.ts index 645775d8d..9e4fa651c 100644 --- a/packages/core/src/canvas/index.ts +++ b/packages/core/src/canvas/index.ts @@ -1,3 +1,8 @@ -export { canMakeBooleanSourceNode, canMakeBooleanSourcePath, nodeHasVisibleStroke } from './boolean' +export { + canMakeBooleanSourceNode, + canMakeBooleanSourcePath, + hasVisibleStrokeSourceNode, + nodeHasVisibleStroke +} from './boolean' export { SkiaRenderer, type RenderOverlays, type RulerTheme } from './renderer' export { getAbsolutePositionFull, getAbsoluteRotation, getWorldHandles } from './coordinate' diff --git a/packages/core/src/editor/structure/flatten.ts b/packages/core/src/editor/structure/flatten.ts index 1c8f588f1..ee1b67984 100644 --- a/packages/core/src/editor/structure/flatten.ts +++ b/packages/core/src/editor/structure/flatten.ts @@ -1,4 +1,4 @@ -import { canMakeBooleanSourceNode, nodeHasVisibleStroke } from '#core/canvas/boolean' +import { canMakeBooleanSourceNode, hasVisibleStrokeSourceNode } 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' @@ -76,7 +76,7 @@ export function outlineStrokeSelected(ctx: EditorContext, selectedNodes: SceneNo return flattenSelected(ctx, selectedNodes, { label: 'Outline stroke', canFlattenNode: (node) => - canMakeBooleanSourceNode(node, ctx.graph) && nodeHasVisibleStroke(node), + canMakeBooleanSourceNode(node, ctx.graph) && hasVisibleStrokeSourceNode(node, ctx.graph), vectorPropsFactory: outlineStrokeNodesToVectorProps }) } diff --git a/packages/vue/src/editor/selection-capabilities/use.ts b/packages/vue/src/editor/selection-capabilities/use.ts index 402fabb1e..a033a29f8 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, nodeHasVisibleStroke } from '@open-pencil/core/canvas' +import { canMakeBooleanSourceNode, hasVisibleStrokeSourceNode } from '@open-pencil/core/canvas' import { useSelectionState } from '#vue/editor/selection-state/use' import { useSceneComputed } from '#vue/internal/scene-computed/use' @@ -56,7 +56,9 @@ export function useSelectionCapabilities() { return ( nodes.length > 0 && nodes.every( - (node) => nodeHasVisibleStroke(node) && canMakeBooleanSourceNode(node, editor.graph) + (node) => + hasVisibleStrokeSourceNode(node, editor.graph) && + canMakeBooleanSourceNode(node, editor.graph) ) ) }), diff --git a/tests/engine/editor/structure/flatten.test.ts b/tests/engine/editor/structure/flatten.test.ts index 384500f3d..c36de51f6 100644 --- a/tests/engine/editor/structure/flatten.test.ts +++ b/tests/engine/editor/structure/flatten.test.ts @@ -94,6 +94,30 @@ describe('flattenSelected', () => { surface.delete() }) + test('outlines stroked descendants inside groups', 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.groupSelected() + + 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?.vectorNetwork?.vertices.length).toBeGreaterThan(0) + surface.delete() + }) + test('does not outline fill-only shapes as strokes', async () => { const { editor, surface } = await createEditorWithRenderer() const pageId = editor.state.currentPageId