From f892cb583cb5b4c718adc7758b573b27d2c62548 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 19 Aug 2026 18:08:48 +0300 Subject: [PATCH] feat(editor): add ruler guide authoring foundation - Preserve page and frame guide GUIDs through .fig conversion - Add undoable guide add, move, transfer, and remove actions - Start page/frame guide previews from CanvasKit rulers - Render warm guide previews with frame-scoped solid lines and dotted continuation --- packages/core/src/canvas/page-guides.ts | 65 ++++++++++-- packages/core/src/canvas/renderer/pipeline.ts | 3 +- packages/core/src/canvas/renderer/types.ts | 1 + packages/core/src/editor/create.ts | 6 ++ packages/core/src/editor/guides.ts | 98 +++++++++++++++++++ packages/core/src/editor/index.ts | 1 + .../core/src/editor/selection/overlays.ts | 8 +- packages/core/src/editor/state/view.ts | 2 + packages/core/src/editor/types.ts | 9 ++ packages/fig/src/node-change/canvas-guides.ts | 28 +++++- packages/fig/src/node-change/convert.ts | 2 + packages/fig/src/node-change/export-node.ts | 2 + packages/fig/tests/canvas-guides.test.ts | 25 +++++ packages/scene-graph/src/guides.ts | 4 + packages/vue/src/canvas/useCanvasInput.ts | 42 +++++++- packages/vue/src/shared/input/types.ts | 8 ++ src/app/editor/panes/state.ts | 1 + tests/engine/editor/guides.test.ts | 56 +++++++++++ 18 files changed, 348 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/editor/guides.ts create mode 100644 packages/fig/tests/canvas-guides.test.ts create mode 100644 tests/engine/editor/guides.test.ts diff --git a/packages/core/src/canvas/page-guides.ts b/packages/core/src/canvas/page-guides.ts index 11aad8c75..db0b8139e 100644 --- a/packages/core/src/canvas/page-guides.ts +++ b/packages/core/src/canvas/page-guides.ts @@ -1,17 +1,56 @@ import type { Canvas } from 'canvaskit-wasm' -import type { SceneGraph } from '@open-pencil/scene-graph' +import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' +import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate' +import Matrix from '@open-pencil/scene-graph/matrix' -import { SELECTION_COLOR } from '#core/constants' +import type { RenderOverlays, SkiaRenderer } from './renderer' -import type { SkiaRenderer } from './renderer' +const GUIDE_COLOR = { r: 0.85, g: 0.29, b: 0.2, a: 0.78 } +const GUIDE_DASH = [3, 4] -export function drawPageGuides(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void { +function drawOwnedGuide( + r: SkiaRenderer, + canvas: Canvas, + owner: SceneNode, + graph: SceneGraph, + axis: 'x' | 'y', + position: number, + preview: boolean +): void { + const matrix = getWorldMatrix(owner, graph) + const start = Matrix.mapPoint( + matrix, + axis === 'x' ? { x: position, y: 0 } : { x: 0, y: position } + ) + const end = Matrix.mapPoint( + matrix, + axis === 'x' ? { x: position, y: owner.height } : { x: owner.width, y: position } + ) + const sx1 = start.x * r.zoom + r.panX + const sy1 = start.y * r.zoom + r.panY + const sx2 = end.x * r.zoom + r.panX + const sy2 = end.y * r.zoom + r.panY + canvas.drawLine(sx1, sy1, sx2, sy2, r.auxStroke) + + if (!preview || owner.type === 'CANVAS') return + r.auxStroke.setPathEffect(r.ck.PathEffect.MakeDash(GUIDE_DASH, 0)) + 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) +} + +export function drawPageGuides( + r: SkiaRenderer, + canvas: Canvas, + graph: SceneGraph, + preview?: RenderOverlays['guidePreview'] +): void { const page = graph.getNode(r.pageId ?? graph.rootId) - if (!page || page.guides.length === 0) return + if (!page) return r.auxStroke.setStrokeWidth(1) - r.auxStroke.setColor(r.ck.Color4f(SELECTION_COLOR.r, SELECTION_COLOR.g, SELECTION_COLOR.b, 0.65)) + r.auxStroke.setColor(r.ck.Color4f(GUIDE_COLOR.r, GUIDE_COLOR.g, GUIDE_COLOR.b, GUIDE_COLOR.a)) for (const guide of page.guides) { if (guide.axis === 'x') { @@ -22,4 +61,18 @@ export function drawPageGuides(r: SkiaRenderer, canvas: Canvas, graph: SceneGrap canvas.drawRect(r.ck.LTRBRect(0, y, r.viewportWidth, y + 1), r.auxStroke) } } + + for (const childId of page.childIds ?? []) { + const node = graph.getNode(childId) + if (!node) continue + if (node.id === page.id || node.guides.length === 0) continue + for (const guide of node.guides) { + drawOwnedGuide(r, canvas, node, graph, guide.axis, guide.position, false) + } + } + + if (preview) { + const owner = graph.getNode(preview.ownerId) + if (owner) drawOwnedGuide(r, canvas, owner, graph, preview.axis, preview.position, true) + } } diff --git a/packages/core/src/canvas/renderer/pipeline.ts b/packages/core/src/canvas/renderer/pipeline.ts index 4de7d2439..1767ab694 100644 --- a/packages/core/src/canvas/renderer/pipeline.ts +++ b/packages/core/src/canvas/renderer/pipeline.ts @@ -61,6 +61,7 @@ export function renderFromEditorState( textEditor: textEditor as RenderOverlays['textEditor'], marquee: state.marquee, snapGuides: state.snapGuides, + guidePreview: state.guidePreview, rotationPreview: state.rotationPreview, dropTargetId: state.dropTargetId, layoutInsertIndicator: state.layoutInsertIndicator, @@ -254,7 +255,7 @@ export function render( drawInteractiveOverlays(r, canvas, graph, selectedIds, overlays) r.drawFlashes(canvas, graph) - drawPageGuides(r, canvas, graph) + drawPageGuides(r, canvas, graph, overlays.guidePreview) r.drawSnapGuides(canvas, overlays.snapGuides) r.drawMarquee(canvas, overlays.marquee) r.drawLayoutInsertIndicator(canvas, overlays.layoutInsertIndicator) diff --git a/packages/core/src/canvas/renderer/types.ts b/packages/core/src/canvas/renderer/types.ts index a2d94e19a..53eeff6ae 100644 --- a/packages/core/src/canvas/renderer/types.ts +++ b/packages/core/src/canvas/renderer/types.ts @@ -21,6 +21,7 @@ export interface RenderOverlays { textEditor?: TextEditor | null marquee?: Rect | null snapGuides?: SnapGuide[] + guidePreview?: { ownerId: string; axis: 'x' | 'y'; position: number } | null rotationPreview?: { nodeId: string; angle: number } | null dropTargetId?: string | null layoutInsertIndicator?: { diff --git a/packages/core/src/editor/create.ts b/packages/core/src/editor/create.ts index a2d71bb9f..011ceda86 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -24,6 +24,7 @@ import { createComponentSyncScheduler } from './component-sync' import { createComponentActions } from './components' import { createGraphEventSubscription } from './graph-events' import { createGraphReadActions } from './graph-reads' +import { createGuideActions } from './guides' import { createLayoutRunner } from './layout-runner' import { createNodeActions } from './nodes' import { createPageActions } from './pages' @@ -163,6 +164,7 @@ export function createEditor(options?: EditorOptions) { const viewport = createViewportActions(ctx) const selection = createSelectionActions(ctx) const pages = createPageActions(ctx) + const guides = createGuideActions(ctx) const shapes = createShapeActions(ctx) const structure = createStructureActions(ctx) const components = createComponentActions(ctx) @@ -207,6 +209,7 @@ export function createEditor(options?: EditorOptions) { state.hoveredNodeId = null state.measurementMode = 'off' state.snapGuides = [] + state.guidePreview = null state.layoutInsertIndicator = null state.dropTargetId = null pages.clearPageViewports() @@ -252,6 +255,9 @@ export function createEditor(options?: EditorOptions) { // Pages ...pages, + // Canvas and frame guides + ...guides, + // Shapes & tools ...shapes, diff --git a/packages/core/src/editor/guides.ts b/packages/core/src/editor/guides.ts new file mode 100644 index 000000000..3b1ad1717 --- /dev/null +++ b/packages/core/src/editor/guides.ts @@ -0,0 +1,98 @@ +import type { CanvasGuide } from '@open-pencil/scene-graph/guides' + +import type { EditorContext } from './types' + +function owner(ctx: EditorContext, ownerId: string) { + const node = ctx.graph.getNode(ownerId) + return node?.type === 'CANVAS' || node?.type === 'FRAME' || node?.type === 'COMPONENT' + ? node + : null +} + +function replaceGuides(ctx: EditorContext, ownerId: string, guides: CanvasGuide[]): void { + ctx.graph.updateNode(ownerId, { guides: structuredClone(guides) }) + ctx.emitEditorEvent('guides:changed', ownerId, structuredClone(guides)) + ctx.requestRender() +} + +function newGuideId(): string { + return `guide:${crypto.randomUUID()}` +} + +export function createGuideActions(ctx: EditorContext) { + function addGuide(ownerId: string, axis: CanvasGuide['axis'], position: number): string | null { + const node = owner(ctx, ownerId) + if (!node || !Number.isFinite(position)) return null + const guide: CanvasGuide = { id: newGuideId(), axis, position } + const before = structuredClone(node.guides) + const after = [...before, guide] + replaceGuides(ctx, ownerId, after) + ctx.undo.push({ + label: 'Add guide', + forward: () => replaceGuides(ctx, ownerId, after), + inverse: () => replaceGuides(ctx, ownerId, before) + }) + return guide.id + } + + function moveGuide(ownerId: string, guideId: string, position: number): boolean { + const node = owner(ctx, ownerId) + if (!node || !Number.isFinite(position)) return false + const index = node.guides.findIndex((guide) => guide.id === guideId) + if (index === -1 || node.guides[index].position === position) return false + const before = structuredClone(node.guides) + const after = structuredClone(node.guides) + after[index].position = position + replaceGuides(ctx, ownerId, after) + ctx.undo.push({ + label: 'Move guide', + forward: () => replaceGuides(ctx, ownerId, after), + inverse: () => replaceGuides(ctx, ownerId, before) + }) + return true + } + + function removeGuide(ownerId: string, guideId: string): boolean { + const node = owner(ctx, ownerId) + if (!node) return false + const before = structuredClone(node.guides) + const after = before.filter((guide) => guide.id !== guideId) + if (after.length === before.length) return false + replaceGuides(ctx, ownerId, after) + ctx.undo.push({ + label: 'Remove guide', + forward: () => replaceGuides(ctx, ownerId, after), + inverse: () => replaceGuides(ctx, ownerId, before) + }) + return true + } + + function transferGuide( + fromOwnerId: string, + toOwnerId: string, + guideId: string, + position: number + ): boolean { + const from = owner(ctx, fromOwnerId) + const to = owner(ctx, toOwnerId) + const guide = from?.guides.find((candidate) => candidate.id === guideId) + if (!from || !to || !guide || !Number.isFinite(position)) return false + const fromBefore = structuredClone(from.guides) + const toBefore = structuredClone(to.guides) + const fromAfter = fromBefore.filter((candidate) => candidate.id !== guideId) + const toAfter = [...toBefore, { ...guide, position }] + const apply = (fromGuides: CanvasGuide[], toGuides: CanvasGuide[]) => { + replaceGuides(ctx, fromOwnerId, fromGuides) + replaceGuides(ctx, toOwnerId, toGuides) + } + apply(fromAfter, toAfter) + ctx.undo.push({ + label: 'Move guide to frame', + forward: () => apply(fromAfter, toAfter), + inverse: () => apply(fromBefore, toBefore) + }) + return true + } + + return { addGuide, moveGuide, removeGuide, transferGuide } +} diff --git a/packages/core/src/editor/index.ts b/packages/core/src/editor/index.ts index caf0065d7..32c006322 100644 --- a/packages/core/src/editor/index.ts +++ b/packages/core/src/editor/index.ts @@ -15,6 +15,7 @@ export { export { createDefaultEditorState, createEditor } from './create' export type { Editor } from './create' export { reapplyInstanceComponentProperties } from './components/properties' +export { createGuideActions } from './guides' export { createTextActions } from './text' export { opacityFromBuffer } from './nodes' export { EDITOR_TOOLS, TOOL_SHORTCUTS } from './tool-registry' diff --git a/packages/core/src/editor/selection/overlays.ts b/packages/core/src/editor/selection/overlays.ts index b078fe718..082dd5b97 100644 --- a/packages/core/src/editor/selection/overlays.ts +++ b/packages/core/src/editor/selection/overlays.ts @@ -1,7 +1,7 @@ import type { Rect } from '@open-pencil/scene-graph/primitives' import type { SnapGuide } from '@open-pencil/scene-graph/snap' -import type { EditorContext } from '#core/editor/types' +import type { EditorContext, GuidePreview } from '#core/editor/types' export function createSelectionOverlayActions(ctx: EditorContext) { function setMarquee(rect: Rect | null) { @@ -14,6 +14,11 @@ export function createSelectionOverlayActions(ctx: EditorContext) { ctx.requestRepaint() } + function setGuidePreview(preview: GuidePreview | null) { + ctx.state.guidePreview = preview + ctx.requestRepaint() + } + function setRotationPreview(preview: { nodeId: string; angle: number } | null) { ctx.state.rotationPreview = preview ctx.requestRepaint() @@ -60,6 +65,7 @@ export function createSelectionOverlayActions(ctx: EditorContext) { return { setMarquee, setSnapGuides, + setGuidePreview, setRotationPreview, setHoveredNode, setMeasurementMode, diff --git a/packages/core/src/editor/state/view.ts b/packages/core/src/editor/state/view.ts index 34c043f7d..19afeb738 100644 --- a/packages/core/src/editor/state/view.ts +++ b/packages/core/src/editor/state/view.ts @@ -7,6 +7,7 @@ export function createDefaultEditorViewState(pageId: string): EditorViewState { selectedIds: new Set(), marquee: null, snapGuides: [], + guidePreview: null, rotationPreview: null, dropTargetId: null, layoutInsertIndicator: null, @@ -35,6 +36,7 @@ export function copyEditorViewState(source: EditorViewState): EditorViewState { selectedIds: new Set(source.selectedIds), marquee: structuredClone(source.marquee), snapGuides: structuredClone(source.snapGuides), + guidePreview: structuredClone(source.guidePreview), rotationPreview: structuredClone(source.rotationPreview), layoutInsertIndicator: structuredClone(source.layoutInsertIndicator), penState: structuredClone(source.penState), diff --git a/packages/core/src/editor/types.ts b/packages/core/src/editor/types.ts index 8a0266c4d..d032176df 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -7,6 +7,7 @@ import type { VectorSegment, VectorVertex } from '@open-pencil/scene-graph' +import type { CanvasGuide } from '@open-pencil/scene-graph/guides' import type { Color, Rect, Vector } from '@open-pencil/scene-graph/primitives' import type { SnapGuide } from '@open-pencil/scene-graph/snap' import type { UndoManager } from '@open-pencil/scene-graph/undo' @@ -46,11 +47,18 @@ export interface EditorSharedState { loading: boolean } +export interface GuidePreview { + ownerId: string + axis: 'x' | 'y' + position: number +} + export interface EditorViewState { currentPageId: string selectedIds: Set marquee: Rect | null snapGuides: SnapGuide[] + guidePreview: GuidePreview | null rotationPreview: { nodeId: string; angle: number } | null dropTargetId: string | null layoutInsertIndicator: { @@ -114,6 +122,7 @@ export interface EditorEvents extends SceneGraphEvents { 'selection:changed': (selectedIds: string[], previousIds: string[]) => void 'tool:changed': (tool: Tool, previousTool: Tool) => void 'page:changed': (pageId: string, previousPageId: string) => void + 'guides:changed': (ownerId: string, guides: readonly CanvasGuide[]) => void 'clipboard:images-missing': (resolution: ClipboardImageResolution) => void 'font:resolution-changed': (event: FontResolutionEvent, snapshot: FontResolutionSnapshot) => void 'viewport:changed': ( diff --git a/packages/fig/src/node-change/canvas-guides.ts b/packages/fig/src/node-change/canvas-guides.ts index fcb0707d0..eb7aa7de5 100644 --- a/packages/fig/src/node-change/canvas-guides.ts +++ b/packages/fig/src/node-change/canvas-guides.ts @@ -1,19 +1,38 @@ import type { CanvasGuide } from '@open-pencil/scene-graph/guides' +import type { GUID } from '@open-pencil/scene-graph/primitives' interface FigmaCanvasGuide { axis?: string offset?: number + guid?: GUID +} + +function guideId(guid: GUID | undefined, index: number): string { + return guid ? `fig-guide:${guid.sessionID}:${guid.localID}` : `guide:${index}` } export function importCanvasGuides(value: unknown): CanvasGuide[] { if (!Array.isArray(value)) return [] const guides: CanvasGuide[] = [] - for (const raw of value) { + for (const [index, raw] of value.entries()) { if (!raw || typeof raw !== 'object') continue const guide = raw as FigmaCanvasGuide if (typeof guide.offset !== 'number' || !Number.isFinite(guide.offset)) continue - if (guide.axis === 'X') guides.push({ axis: 'x', position: guide.offset }) - else if (guide.axis === 'Y') guides.push({ axis: 'y', position: guide.offset }) + if (guide.axis === 'X') { + guides.push({ + id: guideId(guide.guid, index), + axis: 'x', + position: guide.offset, + figGuid: guide.guid + }) + } else if (guide.axis === 'Y') { + guides.push({ + id: guideId(guide.guid, index), + axis: 'y', + position: guide.offset, + figGuid: guide.guid + }) + } } return guides } @@ -21,6 +40,7 @@ export function importCanvasGuides(value: unknown): CanvasGuide[] { export function exportCanvasGuides(guides: readonly CanvasGuide[]): FigmaCanvasGuide[] { return guides.map((guide) => ({ axis: guide.axis === 'x' ? 'X' : 'Y', - offset: guide.position + offset: guide.position, + ...(guide.figGuid ? { guid: guide.figGuid } : {}) })) } diff --git a/packages/fig/src/node-change/convert.ts b/packages/fig/src/node-change/convert.ts index cb00def55..9a5e1049a 100644 --- a/packages/fig/src/node-change/convert.ts +++ b/packages/fig/src/node-change/convert.ts @@ -7,6 +7,7 @@ import { import { parseVariantName } from '@open-pencil/scene-graph/variant-name' /* eslint-disable max-lines -- kiwi↔scene conversion helpers are tightly coupled */ +import { importCanvasGuides } from './canvas-guides' import { convertFigmaDerivedTextGlyphs } from './derived-text-glyphs' import { convertFontFeatures } from './font/features' import { convertFontVariations } from './font/variations' @@ -640,6 +641,7 @@ export function nodeChangeToProps( ), effects: convertEffects(nc.effects), layoutGrids: convertLayoutGrids(nc.layoutGrids), + guides: importCanvasGuides(nc.guides), fillStyleId: styleRefId(nc.styleIdForFill), strokeStyleId: styleRefId(nc.styleIdForStrokeFill), textStyleId: styleRefId(nc.styleIdForText), diff --git a/packages/fig/src/node-change/export-node.ts b/packages/fig/src/node-change/export-node.ts index 1596b9547..f92598749 100644 --- a/packages/fig/src/node-change/export-node.ts +++ b/packages/fig/src/node-change/export-node.ts @@ -12,6 +12,7 @@ import type { Color, GUID, Matrix, Vector } from '@open-pencil/scene-graph/primi import { effectiveFigmaRawNodeFields, effectiveFigmaSourcePayload } from '../source-metadata' /* eslint-disable max-lines */ import { bytesToHex } from './bytes' +import { exportCanvasGuides } from './canvas-guides' import { applyExportSettingsPluginData, applyLibrarySourcePluginData, @@ -810,6 +811,7 @@ function applySharedStyleProps(node: SceneNode, nc: KiwiNodeChange): void { if (node.effectStyleId) nc.styleIdForEffect = { guid: stringToGuid(node.effectStyleId) } if (node.gridStyleId) nc.styleIdForGrid = { guid: stringToGuid(node.gridStyleId) } if (node.layoutGrids.length > 0) nc.layoutGrids = structuredClone(node.layoutGrids) + if (node.guides.length > 0) nc.guides = exportCanvasGuides(node.guides) } function applyNodeVisualProps( diff --git a/packages/fig/tests/canvas-guides.test.ts b/packages/fig/tests/canvas-guides.test.ts new file mode 100644 index 000000000..cf1253f2c --- /dev/null +++ b/packages/fig/tests/canvas-guides.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test' + +import { exportCanvasGuides, importCanvasGuides } from '@open-pencil/fig/node-change' + +const guid = { sessionID: 123, localID: 456 } + +describe('Figma canvas guide conversion', () => { + test('imports axis, owner-local offset, and binary GUID', () => { + expect(importCanvasGuides([{ axis: 'X', offset: 42, guid }])).toEqual([ + { id: 'fig-guide:123:456', axis: 'x', position: 42, figGuid: guid } + ]) + }) + + test('exports preserved GUID and allocates no format-specific fallback', () => { + expect( + exportCanvasGuides([ + { id: 'fig-guide:123:456', axis: 'y', position: 84, figGuid: guid }, + { id: 'guide:new', axis: 'x', position: 12 } + ]) + ).toEqual([ + { axis: 'Y', offset: 84, guid }, + { axis: 'X', offset: 12 } + ]) + }) +}) diff --git a/packages/scene-graph/src/guides.ts b/packages/scene-graph/src/guides.ts index 672883758..91d2b4fd2 100644 --- a/packages/scene-graph/src/guides.ts +++ b/packages/scene-graph/src/guides.ts @@ -1,4 +1,8 @@ +import type { GUID } from './primitives' + export interface CanvasGuide { + id: string axis: 'x' | 'y' position: number + figGuid?: GUID } diff --git a/packages/vue/src/canvas/useCanvasInput.ts b/packages/vue/src/canvas/useCanvasInput.ts index d94085424..ef00f3d74 100644 --- a/packages/vue/src/canvas/useCanvasInput.ts +++ b/packages/vue/src/canvas/useCanvasInput.ts @@ -1,6 +1,7 @@ import { useEventListener } from '@vueuse/core' import { onScopeDispose, ref, type Ref } from 'vue' +import { RULER_SIZE } from '@open-pencil/core/constants' import type { Editor } from '@open-pencil/core/editor' import type { SceneNode } from '@open-pencil/scene-graph' @@ -197,6 +198,27 @@ export function useCanvasInput( autoLayoutPaddingEdit.value = null } + 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 } + } + + function startGuideDrag(sx: number, sy: number, cx: number, cy: number): boolean { + if (!('showRulers' in editor.state) || editor.state.showRulers !== true) return false + if (sx < RULER_SIZE && sy < RULER_SIZE) return false + const axis = sy < RULER_SIZE ? 'y' : sx < RULER_SIZE ? 'x' : null + if (!axis) return false + const target = guideOwner(cx, cy) + const owner = editor.graph.getNode(target.id) + const local = owner && owner.type !== 'CANVAS' ? canvasToLocal(cx, cy, owner.id) : null + const position = axis === 'x' ? (local?.lx ?? cx) : (local?.ly ?? cy) + editor.setGuidePreview({ ownerId: target.id, axis, position }) + setDrag({ type: 'guide', axis, ownerId: target.id, position }) + return true + } + function onDblClick(e: MouseEvent) { if (startAutoLayoutPaddingEdit(e)) return onTextDblClick(e) @@ -213,6 +235,10 @@ 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)) { + e.preventDefault() + return + } const selectedIdsBeforeMouseDown = new Set(editor.state.selectedIds) const clickCount = recordClick(sx, sy) @@ -275,6 +301,16 @@ export function useCanvasInput( const { sx, sy, cx, cy } = getCoords(e) + if (d.type === 'guide') { + const target = guideOwner(cx, cy) + const owner = editor.graph.getNode(target.id) + const local = owner && owner.type !== 'CANVAS' ? canvasToLocal(cx, cy, owner.id) : null + d.ownerId = target.id + d.position = d.axis === 'x' ? (local?.lx ?? cx) : (local?.ly ?? cy) + editor.setGuidePreview({ ownerId: d.ownerId, axis: d.axis, position: d.position }) + return + } + if (d.type === 'rotate') { handleRotateMove(d, cx, cy, e.shiftKey) return @@ -322,7 +358,10 @@ export function useCanvasInput( if (handleNodeEditMouseUp(drag, editor)) return - if (d.type === 'move') handleMoveUp(d, editor) + if (d.type === 'guide') { + editor.addGuide(d.ownerId, d.axis, d.position) + editor.setGuidePreview(null) + } else if (d.type === 'move') handleMoveUp(d, editor) else if (d.type === 'text-select') { drag.value = null return @@ -357,6 +396,7 @@ export function useCanvasInput( editor.setSnapGuides([]) editor.setLayoutInsertIndicator(null) editor.setDropTarget(null) + editor.setGuidePreview(null) } function cancelPointerInteraction() { diff --git a/packages/vue/src/shared/input/types.ts b/packages/vue/src/shared/input/types.ts index c1f4f01d0..f134e09ec 100644 --- a/packages/vue/src/shared/input/types.ts +++ b/packages/vue/src/shared/input/types.ts @@ -128,6 +128,13 @@ export interface DragBendHandle { targetTangentField: 'tangentStart' | 'tangentEnd' | null } +export interface DragGuide { + type: 'guide' + axis: 'x' | 'y' + ownerId: string + position: number +} + export type DragState = | DragDraw | DragMove @@ -140,6 +147,7 @@ export type DragState = | DragEditNode | DragEditHandle | DragBendHandle + | DragGuide export const TOOL_TO_NODE: Partial> = { FRAME: 'FRAME', diff --git a/src/app/editor/panes/state.ts b/src/app/editor/panes/state.ts index bd21f92da..23763222a 100644 --- a/src/app/editor/panes/state.ts +++ b/src/app/editor/panes/state.ts @@ -33,6 +33,7 @@ export function cloneCanvasPaneState(id: string, source: CanvasPaneState): Canva editingTextId: null, marquee: null, snapGuides: [], + guidePreview: null, rotationPreview: null, dropTargetId: null, layoutInsertIndicator: null, diff --git a/tests/engine/editor/guides.test.ts b/tests/engine/editor/guides.test.ts new file mode 100644 index 000000000..625c1373e --- /dev/null +++ b/tests/engine/editor/guides.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test' + +import { createEditor } from '@open-pencil/core/editor' + +function setup() { + const editor = createEditor() + const pageId = editor.state.currentPageId + const frame = editor.graph.createNode('FRAME', pageId, { + x: 100, + y: 100, + width: 300, + height: 200 + }) + return { editor, pageId, frameId: frame.id } +} + +describe('guide editor actions', () => { + test('adds, moves, removes, and undoes a page guide', () => { + const { editor, pageId } = setup() + const id = editor.addGuide(pageId, 'x', 42) + expect(id).not.toBeNull() + expect(editor.graph.getNode(pageId)?.guides).toEqual([{ id, axis: 'x', position: 42 }]) + + expect(editor.moveGuide(pageId, id ?? '', 84)).toBe(true) + expect(editor.graph.getNode(pageId)?.guides[0]?.position).toBe(84) + editor.undoAction() + expect(editor.graph.getNode(pageId)?.guides[0]?.position).toBe(42) + + expect(editor.removeGuide(pageId, id ?? '')).toBe(true) + expect(editor.graph.getNode(pageId)?.guides).toEqual([]) + editor.undoAction() + expect(editor.graph.getNode(pageId)?.guides[0]?.position).toBe(42) + }) + + test('transfers a guide between page and frame in one undo step', () => { + const { editor, pageId, frameId } = setup() + const id = editor.addGuide(pageId, 'y', 120) + editor.undo.clear() + + expect(editor.transferGuide(pageId, frameId, id ?? '', 20)).toBe(true) + expect(editor.graph.getNode(pageId)?.guides).toEqual([]) + expect(editor.graph.getNode(frameId)?.guides).toEqual([{ id, axis: 'y', position: 20 }]) + + editor.undoAction() + expect(editor.graph.getNode(pageId)?.guides).toEqual([{ id, axis: 'y', position: 120 }]) + expect(editor.graph.getNode(frameId)?.guides).toEqual([]) + }) + + test('rejects unsupported owners and no-op movement', () => { + const { editor, pageId } = setup() + const rect = editor.graph.createNode('RECTANGLE', pageId) + expect(editor.addGuide(rect.id, 'x', 10)).toBeNull() + const id = editor.addGuide(pageId, 'x', 10) + expect(editor.moveGuide(pageId, id ?? '', 10)).toBe(false) + }) +})