diff --git a/packages/core/src/canvas/page-guides.ts b/packages/core/src/canvas/page-guides.ts index fd134d285..f2a0ab3b4 100644 --- a/packages/core/src/canvas/page-guides.ts +++ b/packages/core/src/canvas/page-guides.ts @@ -34,10 +34,12 @@ function drawOwnedGuide( canvas.drawLine(sx1, sy1, sx2, sy2, r.auxStroke) if (!preview || owner.type === 'CANVAS') return - r.auxStroke.setPathEffect(r.ck.PathEffect.MakeDash(GUIDE_DASH, 0)) + const dash = r.ck.PathEffect.MakeDash(GUIDE_DASH, 0) + r.auxStroke.setPathEffect(dash) if (axis === 'x') canvas.drawLine(sx1, 0, sx1, r.viewportHeight, r.auxStroke) else canvas.drawLine(0, sy1, r.viewportWidth, sy1, r.auxStroke) r.auxStroke.setPathEffect(null) + dash.delete() } export function drawPageGuides( @@ -62,13 +64,18 @@ export function drawPageGuides( } } - for (const childId of page.childIds) { - const node = graph.getNode(childId) - if (!node) continue - if (node.id === page.id || node.guides.length === 0) continue + const visit = (node: SceneNode) => { for (const guide of node.guides) { drawOwnedGuide(r, canvas, node, graph, guide.axis, guide.position, false) } + for (const childId of node.childIds) { + const child = graph.getNode(childId) + if (child) visit(child) + } + } + for (const childId of page.childIds) { + const node = graph.getNode(childId) + if (node) visit(node) } if (preview) { diff --git a/packages/core/src/canvas/renderer/types.ts b/packages/core/src/canvas/renderer/types.ts index 53eeff6ae..81effd30c 100644 --- a/packages/core/src/canvas/renderer/types.ts +++ b/packages/core/src/canvas/renderer/types.ts @@ -2,6 +2,7 @@ import type { VectorRegion, VectorVertex } from '@open-pencil/scene-graph' import type { Color, Rect, Vector } from '@open-pencil/scene-graph/primitives' import type { SnapGuide } from '@open-pencil/scene-graph/snap' +import type { GuidePreview } from '#core/editor/guide-preview' import type { TextEditor } from '#core/text/editor' export interface RulerTheme { @@ -21,7 +22,7 @@ export interface RenderOverlays { textEditor?: TextEditor | null marquee?: Rect | null snapGuides?: SnapGuide[] - guidePreview?: { ownerId: string; axis: 'x' | 'y'; position: number } | null + guidePreview?: GuidePreview | null rotationPreview?: { nodeId: string; angle: number } | null dropTargetId?: string | null layoutInsertIndicator?: { diff --git a/packages/core/src/editor/guide-preview.ts b/packages/core/src/editor/guide-preview.ts new file mode 100644 index 000000000..38c8ea5ac --- /dev/null +++ b/packages/core/src/editor/guide-preview.ts @@ -0,0 +1,5 @@ +export interface GuidePreview { + ownerId: string + axis: 'x' | 'y' + position: number +} diff --git a/packages/core/src/editor/guides.ts b/packages/core/src/editor/guides.ts index 3b1ad1717..10f91ab7e 100644 --- a/packages/core/src/editor/guides.ts +++ b/packages/core/src/editor/guides.ts @@ -10,7 +10,10 @@ function owner(ctx: EditorContext, ownerId: string) { } function replaceGuides(ctx: EditorContext, ownerId: string, guides: CanvasGuide[]): void { + const node = ctx.graph.getNode(ownerId) + if (!node) return ctx.graph.updateNode(ownerId, { guides: structuredClone(guides) }) + node.source.editedFields = [...new Set([...node.source.editedFields, 'guides'])] ctx.emitEditorEvent('guides:changed', ownerId, structuredClone(guides)) ctx.requestRender() } diff --git a/packages/core/src/editor/selection/overlays.ts b/packages/core/src/editor/selection/overlays.ts index 082dd5b97..557c7bf0f 100644 --- a/packages/core/src/editor/selection/overlays.ts +++ b/packages/core/src/editor/selection/overlays.ts @@ -1,7 +1,8 @@ import type { Rect } from '@open-pencil/scene-graph/primitives' import type { SnapGuide } from '@open-pencil/scene-graph/snap' -import type { EditorContext, GuidePreview } from '#core/editor/types' +import type { GuidePreview } from '#core/editor/guide-preview' +import type { EditorContext } from '#core/editor/types' export function createSelectionOverlayActions(ctx: EditorContext) { function setMarquee(rect: Rect | null) { diff --git a/packages/core/src/editor/types.ts b/packages/core/src/editor/types.ts index d032176df..04a99f99b 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -14,6 +14,7 @@ import type { UndoManager } from '@open-pencil/scene-graph/undo' import type { RulerTheme, SkiaRenderer } from '#core/canvas/renderer' import type { MeasurementMode, RenderOverlays } from '#core/canvas/renderer/types' +import type { GuidePreview } from '#core/editor/guide-preview' import type { SnappingPreferences } from '#core/editor/preferences' import type { TextEditor } from '#core/text/editor' import type { FontResolutionEvent, FontResolutionSnapshot } from '#core/text/resolver' @@ -47,12 +48,6 @@ export interface EditorSharedState { loading: boolean } -export interface GuidePreview { - ownerId: string - axis: 'x' | 'y' - position: number -} - export interface EditorViewState { currentPageId: string selectedIds: Set diff --git a/packages/fig/src/node-change/canvas-guides.ts b/packages/fig/src/node-change/canvas-guides.ts index eb7aa7de5..52fe4fd58 100644 --- a/packages/fig/src/node-change/canvas-guides.ts +++ b/packages/fig/src/node-change/canvas-guides.ts @@ -4,7 +4,13 @@ import type { GUID } from '@open-pencil/scene-graph/primitives' interface FigmaCanvasGuide { axis?: string offset?: number - guid?: GUID + guid?: unknown +} + +function isGuid(value: unknown): value is GUID { + if (!value || typeof value !== 'object') return false + const candidate = value as { sessionID?: unknown; localID?: unknown } + return Number.isFinite(candidate.sessionID) && Number.isFinite(candidate.localID) } function guideId(guid: GUID | undefined, index: number): string { @@ -18,19 +24,20 @@ export function importCanvasGuides(value: unknown): CanvasGuide[] { if (!raw || typeof raw !== 'object') continue const guide = raw as FigmaCanvasGuide if (typeof guide.offset !== 'number' || !Number.isFinite(guide.offset)) continue + const figGuid = isGuid(guide.guid) ? guide.guid : undefined if (guide.axis === 'X') { guides.push({ - id: guideId(guide.guid, index), + id: guideId(figGuid, index), axis: 'x', position: guide.offset, - figGuid: guide.guid + ...(figGuid ? { figGuid } : {}) }) } else if (guide.axis === 'Y') { guides.push({ - id: guideId(guide.guid, index), + id: guideId(figGuid, index), axis: 'y', position: guide.offset, - figGuid: guide.guid + ...(figGuid ? { figGuid } : {}) }) } } diff --git a/packages/fig/src/source-metadata.ts b/packages/fig/src/source-metadata.ts index 3b7b73d89..d73b0d52d 100644 --- a/packages/fig/src/source-metadata.ts +++ b/packages/fig/src/source-metadata.ts @@ -26,6 +26,7 @@ const EDITED_RAW_FIELDS: Partial> = { strokes: ['strokePaints'], effects: ['effects'], layoutGrids: ['layoutGrids'], + guides: ['guides'], exportSettings: ['exportSettings'], cornerRadius: ['cornerRadius'], independentCorners: ['rectangleCornerRadiiIndependent'], diff --git a/packages/fig/tests/canvas-guides.test.ts b/packages/fig/tests/canvas-guides.test.ts index cf1253f2c..ae8342995 100644 --- a/packages/fig/tests/canvas-guides.test.ts +++ b/packages/fig/tests/canvas-guides.test.ts @@ -11,6 +11,12 @@ describe('Figma canvas guide conversion', () => { ]) }) + test('rejects malformed Figma guide GUIDs', () => { + expect( + importCanvasGuides([{ axis: 'X', offset: 10, guid: { sessionID: 'bad', localID: null } }]) + ).toEqual([{ id: 'guide:0', axis: 'x', position: 10 }]) + }) + test('exports preserved GUID and allocates no format-specific fallback', () => { expect( exportCanvasGuides([ diff --git a/packages/vue/src/canvas/useCanvasInput.ts b/packages/vue/src/canvas/useCanvasInput.ts index e20a49088..09c7120d8 100644 --- a/packages/vue/src/canvas/useCanvasInput.ts +++ b/packages/vue/src/canvas/useCanvasInput.ts @@ -199,10 +199,14 @@ export function useCanvasInput( } function guideOwner(cx: number, cy: number): { id: string; position: number } { - const hit = editor.graph.hitTestDeep(cx, cy, editor.state.currentPageId) - const owner = hit && ['FRAME', 'COMPONENT'].includes(hit.type) ? hit : null - if (!owner) return { id: editor.state.currentPageId, position: 0 } - return { id: owner.id, position: 0 } + let node = editor.graph.hitTestDeep(cx, cy, editor.state.currentPageId) + while (node) { + if (node.type === 'FRAME' || node.type === 'COMPONENT') { + return { id: node.id, position: 0 } + } + node = node.parentId ? (editor.graph.getNode(node.parentId) ?? null) : null + } + return { id: editor.state.currentPageId, position: 0 } } function startGuideDrag(sx: number, sy: number, cx: number, cy: number): boolean { @@ -237,7 +241,7 @@ export function useCanvasInput( if (!editor.state.editingTextId) canvasRef.value?.focus() editor.setHoveredNode(null) const { sx, sy, cx, cy } = getCoords(e) - if (startGuideDrag(sx, sy, cx, cy)) { + if (e.button === 0 && startGuideDrag(sx, sy, cx, cy)) { e.preventDefault() return } diff --git a/tests/engine/io/fig/roundtrip/source-metadata.test.ts b/tests/engine/io/fig/roundtrip/source-metadata.test.ts index 127e2f6e3..8a2c937d9 100644 --- a/tests/engine/io/fig/roundtrip/source-metadata.test.ts +++ b/tests/engine/io/fig/roundtrip/source-metadata.test.ts @@ -44,8 +44,8 @@ describe('fig roundtrip source metadata', () => { } ] page.guides = [ - { axis: 'x', position: 42 }, - { axis: 'y', position: 84 } + { id: 'x', axis: 'x', position: 42 }, + { id: 'y', axis: 'y', position: 84 } ] page.source.fig.rawNodeFields.strokeJoin = 'BEVEL' page.source.fig.rawNodeFields.strokeWeight = 0 diff --git a/tests/engine/render/canvas/page-guides.test.ts b/tests/engine/render/canvas/page-guides.test.ts index 06dcee919..bbdd32ae9 100644 --- a/tests/engine/render/canvas/page-guides.test.ts +++ b/tests/engine/render/canvas/page-guides.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'bun:test' +import { describe, expect, mock, test } from 'bun:test' import type { Canvas } from 'canvaskit-wasm' @@ -32,8 +32,8 @@ describe('page guide rendering', () => { }) const canvas = createMockCanvas() const graph = graphWithGuides([ - { axis: 'x', position: 42 }, - { axis: 'y', position: 84 } + { id: 'x', axis: 'x', position: 42 }, + { id: 'y', axis: 'y', position: 84 } ]) drawPageGuides(r, canvas as Canvas, graph) @@ -45,6 +45,48 @@ describe('page guide rendering', () => { ]) }) + test('renders nested frame guides', () => { + const r = createMockRenderer({ pageId: 'page', zoom: 1, panX: 0, panY: 0 }) + const canvas = createMockCanvas() + canvas.drawLine = mock(() => undefined) + const nested = { + id: 'nested', + type: 'FRAME', + parentId: 'frame', + childIds: [], + x: 20, + y: 30, + width: 100, + height: 80, + rotation: 0, + flipX: false, + flipY: false, + guides: [{ id: 'nested-guide', axis: 'x', position: 10 }] + } as SceneNode + const frame = { + ...nested, + id: 'frame', + parentId: 'page', + childIds: ['nested'], + x: 100, + y: 100, + guides: [] + } as SceneNode + const page = { id: 'page', parentId: null, childIds: ['frame'], guides: [] } as SceneNode + const nodes = new Map([ + ['page', page], + ['frame', frame], + ['nested', nested] + ]) + const graph = new SceneGraph() + graph.rootId = 'root' + graph.nodes = nodes + + drawPageGuides(r, canvas as Canvas, graph) + + expect(canvas.drawLine).toHaveBeenCalled() + }) + test('ignores pages without guides', () => { const r = createMockRenderer({ pageId: 'page' }) const canvas = createMockCanvas()