From f892cb583cb5b4c718adc7758b573b27d2c62548 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 19 Aug 2026 18:08:48 +0300 Subject: [PATCH 01/12] 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) + }) +}) From f9c0f56ec359cb5a9abf0f683175431bb5e96642 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 19 Aug 2026 18:09:46 +0300 Subject: [PATCH 02/12] chore(vue): keep guide input dispatch lint-clean --- packages/vue/src/canvas/useCanvasInput.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/vue/src/canvas/useCanvasInput.ts b/packages/vue/src/canvas/useCanvasInput.ts index ef00f3d74..e20a49088 100644 --- a/packages/vue/src/canvas/useCanvasInput.ts +++ b/packages/vue/src/canvas/useCanvasInput.ts @@ -208,7 +208,9 @@ export function useCanvasInput( 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 + let axis: 'x' | 'y' | null = null + if (sy < RULER_SIZE) axis = 'y' + else if (sx < RULER_SIZE) axis = 'x' if (!axis) return false const target = guideOwner(cx, cy) const owner = editor.graph.getNode(target.id) @@ -258,6 +260,8 @@ export function useCanvasInput( }) } + // Dispatching the full drag union is intentionally centralized here. + // eslint-disable-next-line complexity function onMouseMove(e: MouseEvent) { if (!isEnabled()) return pointerInside.value = true From adaf6dacb9b3602c0d3b619aa01db5cae389373e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 19 Aug 2026 18:10:36 +0300 Subject: [PATCH 03/12] fix(canvas): keep guide renderer fixtures complete --- packages/core/src/canvas/page-guides.ts | 2 +- tests/engine/render/canvas/page-guides.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/canvas/page-guides.ts b/packages/core/src/canvas/page-guides.ts index db0b8139e..fd134d285 100644 --- a/packages/core/src/canvas/page-guides.ts +++ b/packages/core/src/canvas/page-guides.ts @@ -62,7 +62,7 @@ export function drawPageGuides( } } - for (const childId of page.childIds ?? []) { + for (const childId of page.childIds) { const node = graph.getNode(childId) if (!node) continue if (node.id === page.id || node.guides.length === 0) continue diff --git a/tests/engine/render/canvas/page-guides.test.ts b/tests/engine/render/canvas/page-guides.test.ts index a04e5fbd8..06dcee919 100644 --- a/tests/engine/render/canvas/page-guides.test.ts +++ b/tests/engine/render/canvas/page-guides.test.ts @@ -11,6 +11,7 @@ import { createMockCanvas, createMockRenderer, mockCalls } from './effects/helpe function graphWithGuides(guides: SceneNode['guides']): SceneGraph { const page = { id: 'page', + childIds: [], guides } as SceneNode return { From c141663291b0721967c59833b20d79d8feeed461 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 19 Aug 2026 20:13:57 +0300 Subject: [PATCH 04/12] fix(editor): address guide authoring review - Dispose CanvasKit dash effects and render nested frame guides - Validate imported guide GUIDs and invalidate stale raw guide metadata - Resolve frame owners through nested hit ancestry and require primary-button ruler drags - Share guide preview types through a neutral editor module --- packages/core/src/canvas/page-guides.ts | 17 +++++-- packages/core/src/canvas/renderer/types.ts | 3 +- packages/core/src/editor/guide-preview.ts | 5 ++ packages/core/src/editor/guides.ts | 3 ++ .../core/src/editor/selection/overlays.ts | 3 +- packages/core/src/editor/types.ts | 7 +-- packages/fig/src/node-change/canvas-guides.ts | 17 +++++-- packages/fig/src/source-metadata.ts | 1 + packages/fig/tests/canvas-guides.test.ts | 6 +++ packages/vue/src/canvas/useCanvasInput.ts | 14 ++++-- .../io/fig/roundtrip/source-metadata.test.ts | 4 +- .../engine/render/canvas/page-guides.test.ts | 48 +++++++++++++++++-- 12 files changed, 100 insertions(+), 28 deletions(-) create mode 100644 packages/core/src/editor/guide-preview.ts 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() From 103e4ac1510ec15ff95332fb25baec72929a0a0f Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 19 Aug 2026 20:28:10 +0300 Subject: [PATCH 05/12] test(canvas): fix nested guide renderer coverage --- tests/engine/render/canvas/page-guides.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/engine/render/canvas/page-guides.test.ts b/tests/engine/render/canvas/page-guides.test.ts index bbdd32ae9..39e61f52d 100644 --- a/tests/engine/render/canvas/page-guides.test.ts +++ b/tests/engine/render/canvas/page-guides.test.ts @@ -2,7 +2,8 @@ import { describe, expect, mock, test } from 'bun:test' import type { Canvas } from 'canvaskit-wasm' -import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' +import { SceneGraph } from '@open-pencil/scene-graph' +import type { SceneNode } from '@open-pencil/scene-graph' import { drawPageGuides } from '#core/canvas/page-guides' From 23b7d92ecf5f83004b44b6466f1aaca7aeb83020 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 20 Aug 2026 07:50:31 +0300 Subject: [PATCH 06/12] feat(canvas): support guide selection and editing --- packages/core/src/canvas/page-guides.ts | 23 ++- packages/core/src/canvas/renderer/pipeline.ts | 3 +- packages/core/src/canvas/renderer/types.ts | 3 +- packages/core/src/editor/create.ts | 1 + packages/core/src/editor/guide-preview.ts | 5 + .../core/src/editor/selection/overlays.ts | 7 + packages/core/src/editor/state/view.ts | 2 + packages/core/src/editor/types.ts | 3 +- packages/vue/src/canvas/useCanvasInput.ts | 146 +++++++++++++++++- packages/vue/src/shared/input/types.ts | 8 + 10 files changed, 190 insertions(+), 11 deletions(-) diff --git a/packages/core/src/canvas/page-guides.ts b/packages/core/src/canvas/page-guides.ts index f2a0ab3b4..d30d267b8 100644 --- a/packages/core/src/canvas/page-guides.ts +++ b/packages/core/src/canvas/page-guides.ts @@ -7,6 +7,7 @@ import Matrix from '@open-pencil/scene-graph/matrix' import type { RenderOverlays, SkiaRenderer } from './renderer' const GUIDE_COLOR = { r: 0.85, g: 0.29, b: 0.2, a: 0.78 } +const SELECTED_GUIDE_COLOR = { r: 0.1, g: 0.45, b: 0.95, a: 1 } const GUIDE_DASH = [3, 4] function drawOwnedGuide( @@ -16,8 +17,11 @@ function drawOwnedGuide( graph: SceneGraph, axis: 'x' | 'y', position: number, - preview: boolean + preview: boolean, + selected = false ): void { + const color = selected ? SELECTED_GUIDE_COLOR : GUIDE_COLOR + r.auxStroke.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a)) const matrix = getWorldMatrix(owner, graph) const start = Matrix.mapPoint( matrix, @@ -46,7 +50,8 @@ export function drawPageGuides( r: SkiaRenderer, canvas: Canvas, graph: SceneGraph, - preview?: RenderOverlays['guidePreview'] + preview?: RenderOverlays['guidePreview'], + selectedGuide?: RenderOverlays['selectedGuide'] ): void { const page = graph.getNode(r.pageId ?? graph.rootId) if (!page) return @@ -55,6 +60,9 @@ export function drawPageGuides( r.auxStroke.setColor(r.ck.Color4f(GUIDE_COLOR.r, GUIDE_COLOR.g, GUIDE_COLOR.b, GUIDE_COLOR.a)) for (const guide of page.guides) { + const selected = selectedGuide?.ownerId === page.id && selectedGuide.guideId === guide.id + const color = selected ? SELECTED_GUIDE_COLOR : GUIDE_COLOR + r.auxStroke.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a)) if (guide.axis === 'x') { const x = guide.position * r.zoom + r.panX canvas.drawRect(r.ck.LTRBRect(x, 0, x + 1, r.viewportHeight), r.auxStroke) @@ -66,7 +74,16 @@ export function drawPageGuides( const visit = (node: SceneNode) => { for (const guide of node.guides) { - drawOwnedGuide(r, canvas, node, graph, guide.axis, guide.position, false) + drawOwnedGuide( + r, + canvas, + node, + graph, + guide.axis, + guide.position, + false, + selectedGuide?.ownerId === node.id && selectedGuide.guideId === guide.id + ) } for (const childId of node.childIds) { const child = graph.getNode(childId) diff --git a/packages/core/src/canvas/renderer/pipeline.ts b/packages/core/src/canvas/renderer/pipeline.ts index 1767ab694..bdde1b077 100644 --- a/packages/core/src/canvas/renderer/pipeline.ts +++ b/packages/core/src/canvas/renderer/pipeline.ts @@ -62,6 +62,7 @@ export function renderFromEditorState( marquee: state.marquee, snapGuides: state.snapGuides, guidePreview: state.guidePreview, + selectedGuide: state.selectedGuide, rotationPreview: state.rotationPreview, dropTargetId: state.dropTargetId, layoutInsertIndicator: state.layoutInsertIndicator, @@ -255,7 +256,7 @@ export function render( drawInteractiveOverlays(r, canvas, graph, selectedIds, overlays) r.drawFlashes(canvas, graph) - drawPageGuides(r, canvas, graph, overlays.guidePreview) + drawPageGuides(r, canvas, graph, overlays.guidePreview, overlays.selectedGuide) 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 81effd30c..b048e4eea 100644 --- a/packages/core/src/canvas/renderer/types.ts +++ b/packages/core/src/canvas/renderer/types.ts @@ -2,7 +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 { GuidePreview, GuideSelection } from '#core/editor/guide-preview' import type { TextEditor } from '#core/text/editor' export interface RulerTheme { @@ -23,6 +23,7 @@ export interface RenderOverlays { marquee?: Rect | null snapGuides?: SnapGuide[] guidePreview?: GuidePreview | null + selectedGuide?: GuideSelection | 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 011ceda86..579d71efe 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -210,6 +210,7 @@ export function createEditor(options?: EditorOptions) { state.measurementMode = 'off' state.snapGuides = [] state.guidePreview = null + state.selectedGuide = null state.layoutInsertIndicator = null state.dropTargetId = null pages.clearPageViewports() diff --git a/packages/core/src/editor/guide-preview.ts b/packages/core/src/editor/guide-preview.ts index 38c8ea5ac..e72f5ce44 100644 --- a/packages/core/src/editor/guide-preview.ts +++ b/packages/core/src/editor/guide-preview.ts @@ -1,3 +1,8 @@ +export interface GuideSelection { + ownerId: string + guideId: string +} + export interface GuidePreview { ownerId: string axis: 'x' | 'y' diff --git a/packages/core/src/editor/selection/overlays.ts b/packages/core/src/editor/selection/overlays.ts index 557c7bf0f..c688d708d 100644 --- a/packages/core/src/editor/selection/overlays.ts +++ b/packages/core/src/editor/selection/overlays.ts @@ -20,6 +20,12 @@ export function createSelectionOverlayActions(ctx: EditorContext) { ctx.requestRepaint() } + function setSelectedGuide(selection: typeof ctx.state.selectedGuide) { + ctx.state.selectedGuide = selection + if (selection) ctx.setSelectedIds(new Set()) + ctx.requestRepaint() + } + function setRotationPreview(preview: { nodeId: string; angle: number } | null) { ctx.state.rotationPreview = preview ctx.requestRepaint() @@ -67,6 +73,7 @@ export function createSelectionOverlayActions(ctx: EditorContext) { setMarquee, setSnapGuides, setGuidePreview, + setSelectedGuide, setRotationPreview, setHoveredNode, setMeasurementMode, diff --git a/packages/core/src/editor/state/view.ts b/packages/core/src/editor/state/view.ts index 19afeb738..459b8b77d 100644 --- a/packages/core/src/editor/state/view.ts +++ b/packages/core/src/editor/state/view.ts @@ -8,6 +8,7 @@ export function createDefaultEditorViewState(pageId: string): EditorViewState { marquee: null, snapGuides: [], guidePreview: null, + selectedGuide: null, rotationPreview: null, dropTargetId: null, layoutInsertIndicator: null, @@ -37,6 +38,7 @@ export function copyEditorViewState(source: EditorViewState): EditorViewState { marquee: structuredClone(source.marquee), snapGuides: structuredClone(source.snapGuides), guidePreview: structuredClone(source.guidePreview), + selectedGuide: structuredClone(source.selectedGuide), 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 04a99f99b..237353450 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -14,7 +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 { GuidePreview, GuideSelection } 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' @@ -54,6 +54,7 @@ export interface EditorViewState { marquee: Rect | null snapGuides: SnapGuide[] guidePreview: GuidePreview | null + selectedGuide: GuideSelection | null rotationPreview: { nodeId: string; angle: number } | null dropTargetId: string | null layoutInsertIndicator: { diff --git a/packages/vue/src/canvas/useCanvasInput.ts b/packages/vue/src/canvas/useCanvasInput.ts index 09c7120d8..1334bd7a9 100644 --- a/packages/vue/src/canvas/useCanvasInput.ts +++ b/packages/vue/src/canvas/useCanvasInput.ts @@ -4,6 +4,8 @@ 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' +import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate' +import Matrix from '@open-pencil/scene-graph/matrix' import { handleBendHandleMove, @@ -98,6 +100,16 @@ export function useCanvasInput( ) } + function deleteSelectedGuide(event: KeyboardEvent): boolean { + if (event.code !== 'Delete' && event.code !== 'Backspace') return false + const selected = editor.state.selectedGuide + if (!selected || editor.state.editingTextId) return false + if (!editor.removeGuide(selected.ownerId, selected.guideId)) return false + editor.setSelectedGuide(null) + event.preventDefault() + return true + } + function updateModifier(code: string, held: boolean) { if (!isEnabled()) return if (code === 'AltLeft' || code === 'AltRight') altHeld = held @@ -209,6 +221,95 @@ export function useCanvasInput( return { id: editor.state.currentPageId, position: 0 } } + function guideHitTest(sx: number, sy: number) { + const tolerance = 5 + const page = editor.graph.getNode(editor.state.currentPageId) + if (!page) return null + const hits: Array<{ + ownerId: string + guideId: string + axis: 'x' | 'y' + position: number + distance: number + }> = [] + + const visit = (owner: SceneNode) => { + const matrix = getWorldMatrix(owner, editor.graph) + for (const guide of owner.guides) { + const start = Matrix.mapPoint( + matrix, + guide.axis === 'x' ? { x: guide.position, y: 0 } : { x: 0, y: guide.position } + ) + const end = Matrix.mapPoint( + matrix, + guide.axis === 'x' + ? { x: guide.position, y: owner.height } + : { x: owner.width, y: guide.position } + ) + const x1 = + owner.type === 'CANVAS' && guide.axis === 'y' + ? 0 + : start.x * editor.state.zoom + editor.state.panX + const y1 = + owner.type === 'CANVAS' && guide.axis === 'x' + ? 0 + : start.y * editor.state.zoom + editor.state.panY + const x2 = + owner.type === 'CANVAS' && guide.axis === 'y' + ? (canvasRef.value?.width ?? sx) + : end.x * editor.state.zoom + editor.state.panX + const y2 = + owner.type === 'CANVAS' && guide.axis === 'x' + ? (canvasRef.value?.height ?? sy) + : end.y * editor.state.zoom + editor.state.panY + const dx = x2 - x1 + const dy = y2 - y1 + const lengthSquared = dx * dx + dy * dy + const t = + lengthSquared === 0 + ? 0 + : Math.max(0, Math.min(1, ((sx - x1) * dx + (sy - y1) * dy) / lengthSquared)) + const distance = Math.hypot(sx - (x1 + t * dx), sy - (y1 + t * dy)) + if (distance <= tolerance) { + hits.push({ + ownerId: owner.id, + guideId: guide.id, + axis: guide.axis, + position: guide.position, + distance + }) + } + } + for (const childId of owner.childIds) { + const child = editor.graph.getNode(childId) + if (child) visit(child) + } + } + visit(page) + return hits.sort((a, b) => a.distance - b.distance)[0] ?? null + } + + function startExistingGuideDrag(sx: number, sy: number): boolean { + const hit = guideHitTest(sx, sy) + if (!hit) return false + editor.setSelectedGuide({ ownerId: hit.ownerId, guideId: hit.guideId }) + setDrag({ + type: 'guide', + axis: hit.axis, + ownerId: hit.ownerId, + position: hit.position, + startScreenX: sx, + startScreenY: sy, + currentScreenX: sx, + currentScreenY: sy, + dragStarted: false, + guideId: hit.guideId, + originalOwnerId: hit.ownerId, + originalPosition: hit.position + }) + return true + } + 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 @@ -220,8 +321,17 @@ export function useCanvasInput( 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 }) + setDrag({ + type: 'guide', + axis, + ownerId: target.id, + position, + startScreenX: sx, + startScreenY: sy, + currentScreenX: sx, + currentScreenY: sy, + dragStarted: false + }) return true } @@ -241,10 +351,15 @@ export function useCanvasInput( if (!editor.state.editingTextId) canvasRef.value?.focus() editor.setHoveredNode(null) const { sx, sy, cx, cy } = getCoords(e) + if (e.button === 0 && startExistingGuideDrag(sx, sy)) { + e.preventDefault() + return + } if (e.button === 0 && startGuideDrag(sx, sy, cx, cy)) { e.preventDefault() return } + editor.setSelectedGuide(null) const selectedIdsBeforeMouseDown = new Set(editor.state.selectedIds) const clickCount = recordClick(sx, sy) @@ -310,6 +425,10 @@ export function useCanvasInput( const { sx, sy, cx, cy } = getCoords(e) if (d.type === 'guide') { + d.currentScreenX = sx + d.currentScreenY = sy + if (!d.dragStarted && Math.hypot(sx - d.startScreenX, sy - d.startScreenY) < 3) return + d.dragStarted = true const target = guideOwner(cx, cy) const owner = editor.graph.getNode(target.id) const local = owner && owner.type !== 'CANVAS' ? canvasToLocal(cx, cy, owner.id) : null @@ -359,6 +478,22 @@ export function useCanvasInput( handleMarqueeMove(d, cx, cy) } + function finishGuideDrag(d: Extract) { + if (d.dragStarted) { + if (d.currentScreenX < RULER_SIZE || d.currentScreenY < RULER_SIZE) { + if (d.guideId && d.originalOwnerId) editor.removeGuide(d.originalOwnerId, d.guideId) + } else if (d.guideId && d.originalOwnerId) { + if (d.ownerId === d.originalOwnerId) editor.moveGuide(d.ownerId, d.guideId, d.position) + else editor.transferGuide(d.originalOwnerId, d.ownerId, d.guideId, d.position) + editor.setSelectedGuide({ ownerId: d.ownerId, guideId: d.guideId }) + } else { + const guideId = editor.addGuide(d.ownerId, d.axis, d.position) + if (guideId) editor.setSelectedGuide({ ownerId: d.ownerId, guideId }) + } + } + editor.setGuidePreview(null) + } + function onMouseUp() { if (!isEnabled()) return if (!drag.value) return @@ -367,8 +502,7 @@ export function useCanvasInput( if (handleNodeEditMouseUp(drag, editor)) return if (d.type === 'guide') { - editor.addGuide(d.ownerId, d.axis, d.position) - editor.setGuidePreview(null) + finishGuideDrag(d) } else if (d.type === 'move') handleMoveUp(d, editor) else if (d.type === 'text-select') { drag.value = null @@ -417,7 +551,9 @@ export function useCanvasInput( useEventListener(canvasRef, 'mousedown', onMouseDown) useEventListener(canvasRef, 'mousemove', onMouseMove) useEventListener(canvasRef, 'mouseup', onMouseUp) - useEventListener(window, 'keydown', (event) => updateModifier(event.code, true)) + useEventListener(window, 'keydown', (event) => { + if (!deleteSelectedGuide(event)) updateModifier(event.code, true) + }) useEventListener(window, 'keyup', (event) => updateModifier(event.code, false)) useEventListener(window, 'blur', () => { resetMeasurementModifiers() diff --git a/packages/vue/src/shared/input/types.ts b/packages/vue/src/shared/input/types.ts index f134e09ec..4065cb803 100644 --- a/packages/vue/src/shared/input/types.ts +++ b/packages/vue/src/shared/input/types.ts @@ -133,6 +133,14 @@ export interface DragGuide { axis: 'x' | 'y' ownerId: string position: number + startScreenX: number + startScreenY: number + currentScreenX: number + currentScreenY: number + dragStarted: boolean + guideId?: string + originalOwnerId?: string + originalPosition?: number } export type DragState = From 581d774d0e30c94137d672d055e94bfd10f14c81 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 20 Aug 2026 07:53:33 +0300 Subject: [PATCH 07/12] fix(canvas): show directional cursors for guides --- packages/vue/src/canvas/useCanvasInput.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/vue/src/canvas/useCanvasInput.ts b/packages/vue/src/canvas/useCanvasInput.ts index 1334bd7a9..2ff590894 100644 --- a/packages/vue/src/canvas/useCanvasInput.ts +++ b/packages/vue/src/canvas/useCanvasInput.ts @@ -289,10 +289,15 @@ export function useCanvasInput( return hits.sort((a, b) => a.distance - b.distance)[0] ?? null } + function guideCursor(axis: 'x' | 'y') { + return axis === 'x' ? 'ew-resize' : 'ns-resize' + } + function startExistingGuideDrag(sx: number, sy: number): boolean { const hit = guideHitTest(sx, sy) if (!hit) return false editor.setSelectedGuide({ ownerId: hit.ownerId, guideId: hit.guideId }) + cursorOverride.value = guideCursor(hit.axis) setDrag({ type: 'guide', axis: hit.axis, @@ -401,14 +406,11 @@ export function useCanvasInput( } if (!drag.value && editor.state.activeTool === 'SELECT') { - const { cx, cy } = coords - cursorOverride.value = updateHoverCursor( - cx, - cy, - editor, - hitFns, - editor.state.measurementMode === 'deep' - ) + const { sx, sy, cx, cy } = coords + const guideHit = guideHitTest(sx, sy) + cursorOverride.value = guideHit + ? guideCursor(guideHit.axis) + : updateHoverCursor(cx, cy, editor, hitFns, editor.state.measurementMode === 'deep') editor.setAutoLayoutHover( editor.state.measurementMode === 'off' ? resolveAutoLayoutHover(cx, cy, editor) : null ) @@ -429,6 +431,7 @@ export function useCanvasInput( d.currentScreenY = sy if (!d.dragStarted && Math.hypot(sx - d.startScreenX, sy - d.startScreenY) < 3) return d.dragStarted = true + cursorOverride.value = guideCursor(d.axis) const target = guideOwner(cx, cy) const owner = editor.graph.getNode(target.id) const local = owner && owner.type !== 'CANVAS' ? canvasToLocal(cx, cy, owner.id) : null From 197364923b626a3c7f9ad55853f3b25e256c6318 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 20 Aug 2026 08:23:04 +0300 Subject: [PATCH 08/12] fix(canvas): render live guide drag feedback --- packages/core/src/canvas/page-guides.ts | 30 +++++++++++++--- packages/core/src/canvas/renderer/pipeline.ts | 10 +++++- packages/core/src/canvas/renderer/types.ts | 1 + packages/core/src/editor/create.ts | 1 + packages/core/src/editor/guide-preview.ts | 1 + .../core/src/editor/selection/overlays.ts | 8 +++++ packages/core/src/editor/state/view.ts | 2 ++ packages/core/src/editor/types.ts | 1 + packages/vue/src/canvas/useCanvasInput.ts | 36 ++++++++++++++++--- 9 files changed, 79 insertions(+), 11 deletions(-) diff --git a/packages/core/src/canvas/page-guides.ts b/packages/core/src/canvas/page-guides.ts index d30d267b8..0dfa2de8e 100644 --- a/packages/core/src/canvas/page-guides.ts +++ b/packages/core/src/canvas/page-guides.ts @@ -7,9 +7,22 @@ import Matrix from '@open-pencil/scene-graph/matrix' import type { RenderOverlays, SkiaRenderer } from './renderer' const GUIDE_COLOR = { r: 0.85, g: 0.29, b: 0.2, a: 0.78 } +const HOVERED_GUIDE_COLOR = { r: 0.96, g: 0.4, b: 0.26, a: 1 } const SELECTED_GUIDE_COLOR = { r: 0.1, g: 0.45, b: 0.95, a: 1 } const GUIDE_DASH = [3, 4] +function guideColor(state: 'idle' | 'hovered' | 'selected') { + if (state === 'selected') return SELECTED_GUIDE_COLOR + if (state === 'hovered') return HOVERED_GUIDE_COLOR + return GUIDE_COLOR +} + +function guideState(selected: boolean, hovered: boolean): 'idle' | 'hovered' | 'selected' { + if (selected) return 'selected' + if (hovered) return 'hovered' + return 'idle' +} + function drawOwnedGuide( r: SkiaRenderer, canvas: Canvas, @@ -18,9 +31,9 @@ function drawOwnedGuide( axis: 'x' | 'y', position: number, preview: boolean, - selected = false + state: 'idle' | 'hovered' | 'selected' = 'idle' ): void { - const color = selected ? SELECTED_GUIDE_COLOR : GUIDE_COLOR + const color = guideColor(state) r.auxStroke.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a)) const matrix = getWorldMatrix(owner, graph) const start = Matrix.mapPoint( @@ -51,6 +64,7 @@ export function drawPageGuides( canvas: Canvas, graph: SceneGraph, preview?: RenderOverlays['guidePreview'], + hoveredGuide?: RenderOverlays['hoveredGuide'], selectedGuide?: RenderOverlays['selectedGuide'] ): void { const page = graph.getNode(r.pageId ?? graph.rootId) @@ -60,8 +74,10 @@ export function drawPageGuides( 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 (preview?.source?.ownerId === page.id && preview.source.guideId === guide.id) continue const selected = selectedGuide?.ownerId === page.id && selectedGuide.guideId === guide.id - const color = selected ? SELECTED_GUIDE_COLOR : GUIDE_COLOR + const hovered = hoveredGuide?.ownerId === page.id && hoveredGuide.guideId === guide.id + const color = guideColor(guideState(selected, hovered)) r.auxStroke.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a)) if (guide.axis === 'x') { const x = guide.position * r.zoom + r.panX @@ -74,6 +90,9 @@ export function drawPageGuides( const visit = (node: SceneNode) => { for (const guide of node.guides) { + if (preview?.source?.ownerId === node.id && preview.source.guideId === guide.id) continue + const selected = selectedGuide?.ownerId === node.id && selectedGuide.guideId === guide.id + const hovered = hoveredGuide?.ownerId === node.id && hoveredGuide.guideId === guide.id drawOwnedGuide( r, canvas, @@ -82,7 +101,7 @@ export function drawPageGuides( guide.axis, guide.position, false, - selectedGuide?.ownerId === node.id && selectedGuide.guideId === guide.id + guideState(selected, hovered) ) } for (const childId of node.childIds) { @@ -97,6 +116,7 @@ export function drawPageGuides( if (preview) { const owner = graph.getNode(preview.ownerId) - if (owner) drawOwnedGuide(r, canvas, owner, graph, preview.axis, preview.position, true) + if (owner) + drawOwnedGuide(r, canvas, owner, graph, preview.axis, preview.position, true, 'selected') } } diff --git a/packages/core/src/canvas/renderer/pipeline.ts b/packages/core/src/canvas/renderer/pipeline.ts index bdde1b077..24c4a24aa 100644 --- a/packages/core/src/canvas/renderer/pipeline.ts +++ b/packages/core/src/canvas/renderer/pipeline.ts @@ -62,6 +62,7 @@ export function renderFromEditorState( marquee: state.marquee, snapGuides: state.snapGuides, guidePreview: state.guidePreview, + hoveredGuide: state.hoveredGuide, selectedGuide: state.selectedGuide, rotationPreview: state.rotationPreview, dropTargetId: state.dropTargetId, @@ -256,7 +257,14 @@ export function render( drawInteractiveOverlays(r, canvas, graph, selectedIds, overlays) r.drawFlashes(canvas, graph) - drawPageGuides(r, canvas, graph, overlays.guidePreview, overlays.selectedGuide) + drawPageGuides( + r, + canvas, + graph, + overlays.guidePreview, + overlays.hoveredGuide, + overlays.selectedGuide + ) 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 b048e4eea..58f39c131 100644 --- a/packages/core/src/canvas/renderer/types.ts +++ b/packages/core/src/canvas/renderer/types.ts @@ -23,6 +23,7 @@ export interface RenderOverlays { marquee?: Rect | null snapGuides?: SnapGuide[] guidePreview?: GuidePreview | null + hoveredGuide?: GuideSelection | null selectedGuide?: GuideSelection | null rotationPreview?: { nodeId: string; angle: number } | null dropTargetId?: string | null diff --git a/packages/core/src/editor/create.ts b/packages/core/src/editor/create.ts index 579d71efe..53a1cd54a 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -210,6 +210,7 @@ export function createEditor(options?: EditorOptions) { state.measurementMode = 'off' state.snapGuides = [] state.guidePreview = null + state.hoveredGuide = null state.selectedGuide = null state.layoutInsertIndicator = null state.dropTargetId = null diff --git a/packages/core/src/editor/guide-preview.ts b/packages/core/src/editor/guide-preview.ts index e72f5ce44..0f1c81189 100644 --- a/packages/core/src/editor/guide-preview.ts +++ b/packages/core/src/editor/guide-preview.ts @@ -7,4 +7,5 @@ export interface GuidePreview { ownerId: string axis: 'x' | 'y' position: number + source?: GuideSelection } diff --git a/packages/core/src/editor/selection/overlays.ts b/packages/core/src/editor/selection/overlays.ts index c688d708d..b731e511f 100644 --- a/packages/core/src/editor/selection/overlays.ts +++ b/packages/core/src/editor/selection/overlays.ts @@ -20,6 +20,13 @@ export function createSelectionOverlayActions(ctx: EditorContext) { ctx.requestRepaint() } + function setHoveredGuide(selection: typeof ctx.state.hoveredGuide) { + const current = ctx.state.hoveredGuide + if (current?.ownerId === selection?.ownerId && current?.guideId === selection?.guideId) return + ctx.state.hoveredGuide = selection + ctx.requestRepaint() + } + function setSelectedGuide(selection: typeof ctx.state.selectedGuide) { ctx.state.selectedGuide = selection if (selection) ctx.setSelectedIds(new Set()) @@ -73,6 +80,7 @@ export function createSelectionOverlayActions(ctx: EditorContext) { setMarquee, setSnapGuides, setGuidePreview, + setHoveredGuide, setSelectedGuide, setRotationPreview, setHoveredNode, diff --git a/packages/core/src/editor/state/view.ts b/packages/core/src/editor/state/view.ts index 459b8b77d..336a6f293 100644 --- a/packages/core/src/editor/state/view.ts +++ b/packages/core/src/editor/state/view.ts @@ -8,6 +8,7 @@ export function createDefaultEditorViewState(pageId: string): EditorViewState { marquee: null, snapGuides: [], guidePreview: null, + hoveredGuide: null, selectedGuide: null, rotationPreview: null, dropTargetId: null, @@ -38,6 +39,7 @@ export function copyEditorViewState(source: EditorViewState): EditorViewState { marquee: structuredClone(source.marquee), snapGuides: structuredClone(source.snapGuides), guidePreview: structuredClone(source.guidePreview), + hoveredGuide: structuredClone(source.hoveredGuide), selectedGuide: structuredClone(source.selectedGuide), rotationPreview: structuredClone(source.rotationPreview), layoutInsertIndicator: structuredClone(source.layoutInsertIndicator), diff --git a/packages/core/src/editor/types.ts b/packages/core/src/editor/types.ts index 237353450..3ca077b38 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -54,6 +54,7 @@ export interface EditorViewState { marquee: Rect | null snapGuides: SnapGuide[] guidePreview: GuidePreview | null + hoveredGuide: GuideSelection | null selectedGuide: GuideSelection | null rotationPreview: { nodeId: string; angle: number } | null dropTargetId: string | null diff --git a/packages/vue/src/canvas/useCanvasInput.ts b/packages/vue/src/canvas/useCanvasInput.ts index 2ff590894..7822ca664 100644 --- a/packages/vue/src/canvas/useCanvasInput.ts +++ b/packages/vue/src/canvas/useCanvasInput.ts @@ -293,10 +293,28 @@ export function useCanvasInput( return axis === 'x' ? 'ew-resize' : 'ns-resize' } + function rulerGuideAxis(sx: number, sy: number): 'x' | 'y' | null { + if (sy < RULER_SIZE) return 'y' + if (sx < RULER_SIZE) return 'x' + return null + } + + function updateGuideHoverCursor(sx: number, sy: number, cx: number, cy: number) { + const guideHit = guideHitTest(sx, sy) + editor.setHoveredGuide( + guideHit ? { ownerId: guideHit.ownerId, guideId: guideHit.guideId } : null + ) + if (guideHit) return guideCursor(guideHit.axis) + const rulerAxis = rulerGuideAxis(sx, sy) + if (rulerAxis) return guideCursor(rulerAxis) + return updateHoverCursor(cx, cy, editor, hitFns, editor.state.measurementMode === 'deep') + } + function startExistingGuideDrag(sx: number, sy: number): boolean { const hit = guideHitTest(sx, sy) if (!hit) return false editor.setSelectedGuide({ ownerId: hit.ownerId, guideId: hit.guideId }) + editor.setHoveredGuide(null) cursorOverride.value = guideCursor(hit.axis) setDrag({ type: 'guide', @@ -407,10 +425,7 @@ export function useCanvasInput( if (!drag.value && editor.state.activeTool === 'SELECT') { const { sx, sy, cx, cy } = coords - const guideHit = guideHitTest(sx, sy) - cursorOverride.value = guideHit - ? guideCursor(guideHit.axis) - : updateHoverCursor(cx, cy, editor, hitFns, editor.state.measurementMode === 'deep') + cursorOverride.value = updateGuideHoverCursor(sx, sy, cx, cy) editor.setAutoLayoutHover( editor.state.measurementMode === 'off' ? resolveAutoLayoutHover(cx, cy, editor) : null ) @@ -437,7 +452,15 @@ export function useCanvasInput( 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 }) + editor.setGuidePreview({ + ownerId: d.ownerId, + axis: d.axis, + position: d.position, + source: + d.guideId && d.originalOwnerId + ? { ownerId: d.originalOwnerId, guideId: d.guideId } + : undefined + }) return } @@ -495,6 +518,7 @@ export function useCanvasInput( } } editor.setGuidePreview(null) + editor.setHoveredGuide(null) } function onMouseUp() { @@ -542,6 +566,7 @@ export function useCanvasInput( editor.setLayoutInsertIndicator(null) editor.setDropTarget(null) editor.setGuidePreview(null) + editor.setHoveredGuide(null) } function cancelPointerInteraction() { @@ -568,6 +593,7 @@ export function useCanvasInput( editor.setMeasurementMode('off') if (!drag.value) { editor.setHoveredNode(null) + editor.setHoveredGuide(null) } }) useEventListener( From 27ad48b36520b4ad947122448f122d4119ee4591 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 20 Aug 2026 08:41:16 +0300 Subject: [PATCH 09/12] refactor(canvas): separate guide and overlay pipelines --- packages/core/src/canvas/guides/geometry.ts | 111 +++++++++ packages/core/src/canvas/guides/render.ts | 118 +++++++++ packages/core/src/canvas/guides/types.ts | 21 ++ packages/core/src/canvas/index.ts | 9 + packages/core/src/canvas/page-guides.ts | 122 ---------- .../core/src/canvas/renderer/overlay-pass.ts | 68 ++++++ packages/core/src/canvas/renderer/pipeline.ts | 89 ++----- packages/core/src/canvas/renderer/types.ts | 6 +- packages/core/src/editor/create.ts | 4 +- packages/core/src/editor/guide-preview.ts | 11 - .../core/src/editor/selection/overlays.ts | 14 +- packages/core/src/editor/state/view.ts | 9 +- packages/core/src/editor/types.ts | 6 +- packages/vue/src/canvas/guide-input/use.ts | 176 ++++++++++++++ packages/vue/src/canvas/useCanvasInput.ts | 225 ++---------------- src/app/editor/panes/state.ts | 2 +- .../render/canvas/guides/geometry.test.ts | 56 +++++ .../engine/render/canvas/page-guides.test.ts | 9 +- 18 files changed, 616 insertions(+), 440 deletions(-) create mode 100644 packages/core/src/canvas/guides/geometry.ts create mode 100644 packages/core/src/canvas/guides/render.ts create mode 100644 packages/core/src/canvas/guides/types.ts delete mode 100644 packages/core/src/canvas/page-guides.ts create mode 100644 packages/core/src/canvas/renderer/overlay-pass.ts delete mode 100644 packages/core/src/editor/guide-preview.ts create mode 100644 packages/vue/src/canvas/guide-input/use.ts create mode 100644 tests/engine/render/canvas/guides/geometry.test.ts diff --git a/packages/core/src/canvas/guides/geometry.ts b/packages/core/src/canvas/guides/geometry.ts new file mode 100644 index 000000000..4341580cb --- /dev/null +++ b/packages/core/src/canvas/guides/geometry.ts @@ -0,0 +1,111 @@ +import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' +import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate' +import type { CanvasGuide } from '@open-pencil/scene-graph/guides' +import Matrix from '@open-pencil/scene-graph/matrix' + +export interface GuideViewport { + panX: number + panY: number + zoom: number + width: number + height: number +} + +export interface GuideScreenSegment { + x1: number + y1: number + x2: number + y2: number +} + +export interface GuideHit { + ownerId: string + guideId: string + axis: CanvasGuide['axis'] + position: number + distance: number +} + +export function getGuideScreenSegment( + graph: SceneGraph, + owner: SceneNode, + guide: Pick, + viewport: GuideViewport +): GuideScreenSegment { + if (owner.type === 'CANVAS') { + if (guide.axis === 'x') { + const x = guide.position * viewport.zoom + viewport.panX + return { x1: x, y1: 0, x2: x, y2: viewport.height } + } + const y = guide.position * viewport.zoom + viewport.panY + return { x1: 0, y1: y, x2: viewport.width, y2: y } + } + + const matrix = getWorldMatrix(owner, graph) + const start = Matrix.mapPoint( + matrix, + guide.axis === 'x' ? { x: guide.position, y: 0 } : { x: 0, y: guide.position } + ) + const end = Matrix.mapPoint( + matrix, + guide.axis === 'x' + ? { x: guide.position, y: owner.height } + : { x: owner.width, y: guide.position } + ) + return { + x1: start.x * viewport.zoom + viewport.panX, + y1: start.y * viewport.zoom + viewport.panY, + x2: end.x * viewport.zoom + viewport.panX, + y2: end.y * viewport.zoom + viewport.panY + } +} + +export function distanceToGuideSegment(x: number, y: number, segment: GuideScreenSegment): number { + const dx = segment.x2 - segment.x1 + const dy = segment.y2 - segment.y1 + const lengthSquared = dx * dx + dy * dy + const t = + lengthSquared === 0 + ? 0 + : Math.max(0, Math.min(1, ((x - segment.x1) * dx + (y - segment.y1) * dy) / lengthSquared)) + return Math.hypot(x - (segment.x1 + t * dx), y - (segment.y1 + t * dy)) +} + +export function hitTestGuides( + graph: SceneGraph, + pageId: string, + viewport: GuideViewport, + x: number, + y: number, + tolerance = 5 +): GuideHit | null { + const page = graph.getNode(pageId) + if (!page) return null + let closest: GuideHit | null = null + + const visit = (owner: SceneNode) => { + for (const guide of owner.guides) { + const distance = distanceToGuideSegment( + x, + y, + getGuideScreenSegment(graph, owner, guide, viewport) + ) + if (distance <= tolerance && (!closest || distance < closest.distance)) { + closest = { + ownerId: owner.id, + guideId: guide.id, + axis: guide.axis, + position: guide.position, + distance + } + } + } + for (const childId of owner.childIds) { + const child = graph.getNode(childId) + if (child) visit(child) + } + } + + visit(page) + return closest +} diff --git a/packages/core/src/canvas/guides/render.ts b/packages/core/src/canvas/guides/render.ts new file mode 100644 index 000000000..204181bbb --- /dev/null +++ b/packages/core/src/canvas/guides/render.ts @@ -0,0 +1,118 @@ +import type { Canvas } from 'canvaskit-wasm' + +import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' + +import type { RenderOverlays, SkiaRenderer } from '#core/canvas/renderer' + +import { getGuideScreenSegment } from './geometry' + +const GUIDE_COLOR = { r: 0.85, g: 0.29, b: 0.2, a: 0.78 } +const HOVERED_GUIDE_COLOR = { r: 0.96, g: 0.4, b: 0.26, a: 1 } +const SELECTED_GUIDE_COLOR = { r: 0.1, g: 0.45, b: 0.95, a: 1 } +const GUIDE_DASH = [3, 4] + +type GuideVisualState = 'idle' | 'hovered' | 'selected' + +function guideColor(state: GuideVisualState) { + if (state === 'selected') return SELECTED_GUIDE_COLOR + if (state === 'hovered') return HOVERED_GUIDE_COLOR + return GUIDE_COLOR +} + +function guideState(selected: boolean, hovered: boolean): GuideVisualState { + if (selected) return 'selected' + if (hovered) return 'hovered' + return 'idle' +} + +function drawGuide( + r: SkiaRenderer, + canvas: Canvas, + owner: SceneNode, + graph: SceneGraph, + axis: 'x' | 'y', + position: number, + preview: boolean, + state: GuideVisualState = 'idle' +): void { + const color = guideColor(state) + r.auxStroke.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a)) + const segment = getGuideScreenSegment( + graph, + owner, + { axis, position }, + { + panX: r.panX, + panY: r.panY, + zoom: r.zoom, + width: r.viewportWidth, + height: r.viewportHeight + } + ) + if (owner.type === 'CANVAS') { + if (axis === 'x') { + canvas.drawRect( + r.ck.LTRBRect(segment.x1, segment.y1, segment.x1 + 1, segment.y2), + r.auxStroke + ) + } else { + canvas.drawRect( + r.ck.LTRBRect(segment.x1, segment.y1, segment.x2, segment.y1 + 1), + r.auxStroke + ) + } + } else { + canvas.drawLine(segment.x1, segment.y1, segment.x2, segment.y2, r.auxStroke) + } + + if (!preview || owner.type === 'CANVAS') return + const dash = r.ck.PathEffect.MakeDash(GUIDE_DASH, 0) + r.auxStroke.setPathEffect(dash) + if (axis === 'x') canvas.drawLine(segment.x1, 0, segment.x1, r.viewportHeight, r.auxStroke) + else canvas.drawLine(0, segment.y1, r.viewportWidth, segment.y1, r.auxStroke) + r.auxStroke.setPathEffect(null) + dash.delete() +} + +export function drawGuides( + r: SkiaRenderer, + canvas: Canvas, + graph: SceneGraph, + guides: RenderOverlays['guides'] +): void { + const page = graph.getNode(r.pageId ?? graph.rootId) + if (!page) return + const preview = guides?.preview + const hovered = guides?.hovered + const selected = guides?.selected + + r.auxStroke.setStrokeWidth(1) + const visit = (owner: SceneNode) => { + for (const guide of owner.guides) { + if (preview?.source?.ownerId === owner.id && preview.source.guideId === guide.id) continue + drawGuide( + r, + canvas, + owner, + graph, + guide.axis, + guide.position, + false, + guideState( + selected?.ownerId === owner.id && selected.guideId === guide.id, + hovered?.ownerId === owner.id && hovered.guideId === guide.id + ) + ) + } + for (const childId of owner.childIds) { + const child = graph.getNode(childId) + if (child) visit(child) + } + } + visit(page) + + if (preview) { + const owner = graph.getNode(preview.ownerId) + if (owner) drawGuide(r, canvas, owner, graph, preview.axis, preview.position, true, 'selected') + } +} diff --git a/packages/core/src/canvas/guides/types.ts b/packages/core/src/canvas/guides/types.ts new file mode 100644 index 000000000..a5f1057a5 --- /dev/null +++ b/packages/core/src/canvas/guides/types.ts @@ -0,0 +1,21 @@ +export interface GuideSelection { + ownerId: string + guideId: string +} + +export interface GuidePreview { + ownerId: string + axis: 'x' | 'y' + position: number + source?: GuideSelection +} + +export interface GuideOverlayState { + preview: GuidePreview | null + hovered: GuideSelection | null + selected: GuideSelection | null +} + +export function createGuideOverlayState(): GuideOverlayState { + return { preview: null, hovered: null, selected: null } +} diff --git a/packages/core/src/canvas/index.ts b/packages/core/src/canvas/index.ts index ceb2c0287..90d8dd09e 100644 --- a/packages/core/src/canvas/index.ts +++ b/packages/core/src/canvas/index.ts @@ -4,4 +4,13 @@ export { hasVisibleStrokeSourceNode, nodeHasVisibleStroke } from './boolean' +export { + distanceToGuideSegment, + getGuideScreenSegment, + hitTestGuides, + type GuideHit, + type GuideScreenSegment, + type GuideViewport +} from './guides/geometry' +export type { GuideOverlayState, GuidePreview, GuideSelection } from './guides/types' export { SkiaRenderer, type RenderOverlays, type RulerTheme } from './renderer' diff --git a/packages/core/src/canvas/page-guides.ts b/packages/core/src/canvas/page-guides.ts deleted file mode 100644 index 0dfa2de8e..000000000 --- a/packages/core/src/canvas/page-guides.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { Canvas } from 'canvaskit-wasm' - -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 type { RenderOverlays, SkiaRenderer } from './renderer' - -const GUIDE_COLOR = { r: 0.85, g: 0.29, b: 0.2, a: 0.78 } -const HOVERED_GUIDE_COLOR = { r: 0.96, g: 0.4, b: 0.26, a: 1 } -const SELECTED_GUIDE_COLOR = { r: 0.1, g: 0.45, b: 0.95, a: 1 } -const GUIDE_DASH = [3, 4] - -function guideColor(state: 'idle' | 'hovered' | 'selected') { - if (state === 'selected') return SELECTED_GUIDE_COLOR - if (state === 'hovered') return HOVERED_GUIDE_COLOR - return GUIDE_COLOR -} - -function guideState(selected: boolean, hovered: boolean): 'idle' | 'hovered' | 'selected' { - if (selected) return 'selected' - if (hovered) return 'hovered' - return 'idle' -} - -function drawOwnedGuide( - r: SkiaRenderer, - canvas: Canvas, - owner: SceneNode, - graph: SceneGraph, - axis: 'x' | 'y', - position: number, - preview: boolean, - state: 'idle' | 'hovered' | 'selected' = 'idle' -): void { - const color = guideColor(state) - r.auxStroke.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a)) - 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 - 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( - r: SkiaRenderer, - canvas: Canvas, - graph: SceneGraph, - preview?: RenderOverlays['guidePreview'], - hoveredGuide?: RenderOverlays['hoveredGuide'], - selectedGuide?: RenderOverlays['selectedGuide'] -): void { - const page = graph.getNode(r.pageId ?? graph.rootId) - if (!page) return - - r.auxStroke.setStrokeWidth(1) - 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 (preview?.source?.ownerId === page.id && preview.source.guideId === guide.id) continue - const selected = selectedGuide?.ownerId === page.id && selectedGuide.guideId === guide.id - const hovered = hoveredGuide?.ownerId === page.id && hoveredGuide.guideId === guide.id - const color = guideColor(guideState(selected, hovered)) - r.auxStroke.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a)) - if (guide.axis === 'x') { - const x = guide.position * r.zoom + r.panX - canvas.drawRect(r.ck.LTRBRect(x, 0, x + 1, r.viewportHeight), r.auxStroke) - } else { - const y = guide.position * r.zoom + r.panY - canvas.drawRect(r.ck.LTRBRect(0, y, r.viewportWidth, y + 1), r.auxStroke) - } - } - - const visit = (node: SceneNode) => { - for (const guide of node.guides) { - if (preview?.source?.ownerId === node.id && preview.source.guideId === guide.id) continue - const selected = selectedGuide?.ownerId === node.id && selectedGuide.guideId === guide.id - const hovered = hoveredGuide?.ownerId === node.id && hoveredGuide.guideId === guide.id - drawOwnedGuide( - r, - canvas, - node, - graph, - guide.axis, - guide.position, - false, - guideState(selected, hovered) - ) - } - 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) { - const owner = graph.getNode(preview.ownerId) - if (owner) - drawOwnedGuide(r, canvas, owner, graph, preview.axis, preview.position, true, 'selected') - } -} diff --git a/packages/core/src/canvas/renderer/overlay-pass.ts b/packages/core/src/canvas/renderer/overlay-pass.ts new file mode 100644 index 000000000..4ffb573e6 --- /dev/null +++ b/packages/core/src/canvas/renderer/overlay-pass.ts @@ -0,0 +1,68 @@ +import type { Canvas } from 'canvaskit-wasm' + +import type { SceneGraph } from '@open-pencil/scene-graph' + +import { drawGuides } from '#core/canvas/guides/render' +import type { RenderOverlays, SkiaRenderer } from '#core/canvas/renderer' + +function measurementVisible(overlays: RenderOverlays): boolean { + return ( + overlays.measurementMode !== undefined && + overlays.measurementMode !== 'off' && + !overlays.editingTextId && + !overlays.nodeEditState && + !overlays.penState + ) +} + +export function drawLabelPass(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void { + const profiler = r.profiler + profiler.beginPhase('render:sectionTitles') + r.drawSectionTitles(canvas, graph) + profiler.endPhase('render:sectionTitles') + profiler.beginPhase('render:componentLabels') + r.drawComponentLabels(canvas, graph) + profiler.endPhase('render:componentLabels') +} + +export function drawOverlayPass( + r: SkiaRenderer, + canvas: Canvas, + graph: SceneGraph, + selectedIds: Set, + overlays: RenderOverlays +): void { + const measuring = measurementVisible(overlays) + const hoveredNodeId = + measuring || overlays.hoveredNodeId === overlays.nodeEditState?.nodeId + ? null + : overlays.hoveredNodeId + r.drawHoverHighlight(canvas, graph, hoveredNodeId) + r.drawEnteredContainer(canvas, graph, overlays.enteredContainerId) + r.profiler.beginPhase('render:selection') + r.drawSelection(canvas, graph, selectedIds, overlays) + if (measuring) r.drawMeasurements(canvas, graph, selectedIds, overlays.hoveredNodeId) + r.profiler.endPhase('render:selection') + + r.drawFlashes(canvas, graph) + drawGuides(r, canvas, graph, overlays.guides) + r.drawSnapGuides(canvas, overlays.snapGuides) + r.drawMarquee(canvas, overlays.marquee) + r.drawLayoutInsertIndicator(canvas, overlays.layoutInsertIndicator) + if (!measuring) r.drawAutoLayoutHover(canvas, graph, overlays.autoLayoutHover) + r.drawNodeEditOverlay(canvas, graph, overlays.nodeEditState) + r.drawPenOverlay(canvas, overlays.penState) + r.drawRemoteCursors(canvas, graph, overlays.remoteCursors) +} + +export function drawChromePass( + r: SkiaRenderer, + canvas: Canvas, + graph: SceneGraph, + selectedIds: Set +): void { + r.profiler.beginPhase('render:rulers') + if (r.showRulers) r.drawRulers(canvas, graph, selectedIds) + r.profiler.endPhase('render:rulers') + r.profiler.drawHUD(canvas, r.showRulers) +} diff --git a/packages/core/src/canvas/renderer/pipeline.ts b/packages/core/src/canvas/renderer/pipeline.ts index 24c4a24aa..e5f43c182 100644 --- a/packages/core/src/canvas/renderer/pipeline.ts +++ b/packages/core/src/canvas/renderer/pipeline.ts @@ -3,10 +3,10 @@ import type { Canvas } from 'canvaskit-wasm' import type { SceneGraph } from '@open-pencil/scene-graph' import { computeDescendantVisualBounds } from '@open-pencil/scene-graph/geometry' -import { drawPageGuides } from '#core/canvas/page-guides' import type { RenderOverlays, SkiaRenderer } from '#core/canvas/renderer' import type { EditorState } from '#core/editor/types' +import { drawChromePass, drawLabelPass, drawOverlayPass } from './overlay-pass' import { renderSceneBacking, updateSceneBackingPreviewState } from './retained-backing' export function renderSceneToCanvas( @@ -61,9 +61,7 @@ export function renderFromEditorState( textEditor: textEditor as RenderOverlays['textEditor'], marquee: state.marquee, snapGuides: state.snapGuides, - guidePreview: state.guidePreview, - hoveredGuide: state.hoveredGuide, - selectedGuide: state.selectedGuide, + guides: state.guides, rotationPreview: state.rotationPreview, dropTargetId: state.dropTargetId, layoutInsertIndicator: state.layoutInsertIndicator, @@ -83,7 +81,7 @@ export function renderFromEditorState( ) } -function hasVolatileOverlay(overlays: RenderOverlays): boolean { +function sceneContentDependsOnOverlay(overlays: RenderOverlays): boolean { return ( overlays.dropTargetId != null || overlays.rotationPreview != null || @@ -100,7 +98,7 @@ function scenePictureMissReason( hasPositionPreview: boolean ): string { if (hasPositionPreview) return 'position-preview' - if (hasVolatileOverlay(overlays)) return 'volatile-overlay' + if (sceneContentDependsOnOverlay(overlays)) return 'volatile-overlay' if (!r.scenePicture) return 'missing-picture' if (graph.positionPreviewVersion !== r.scenePicturePositionPreviewVersion) return 'position-preview-version' @@ -114,10 +112,10 @@ function canUseScenePicture( r: SkiaRenderer, graph: SceneGraph, sceneVersion: number, - hasVolatileOverlays: boolean + requiresUncachedSceneRender: boolean ): boolean { return ( - !hasVolatileOverlays && + !requiresUncachedSceneRender && !!r.scenePicture && graph.positionPreviewVersion === r.scenePicturePositionPreviewVersion && sceneVersion === r.scenePictureVersion && @@ -134,36 +132,6 @@ function measure(fn: () => T): { value: T; duration: number } { return { value, duration: now() - start } } -function measurementVisible(overlays: RenderOverlays): boolean { - return ( - overlays.measurementMode !== undefined && - overlays.measurementMode !== 'off' && - !overlays.editingTextId && - !overlays.nodeEditState && - !overlays.penState - ) -} - -function drawInteractiveOverlays( - r: SkiaRenderer, - canvas: Canvas, - graph: SceneGraph, - selectedIds: Set, - overlays: RenderOverlays -) { - const measuring = measurementVisible(overlays) - const hoveredNodeId = - measuring || overlays.hoveredNodeId === overlays.nodeEditState?.nodeId - ? null - : overlays.hoveredNodeId - r.drawHoverHighlight(canvas, graph, hoveredNodeId) - r.drawEnteredContainer(canvas, graph, overlays.enteredContainerId) - r.profiler.beginPhase('render:selection') - r.drawSelection(canvas, graph, selectedIds, overlays) - if (measuring) r.drawMeasurements(canvas, graph, selectedIds, overlays.hoveredNodeId) - r.profiler.endPhase('render:selection') -} - export function render( r: SkiaRenderer, graph: SceneGraph, @@ -199,9 +167,9 @@ export function render( const hasPositionPreview = graph.positionPreviewVersion !== r.scenePicturePositionPreviewVersion && sceneVersion === r.scenePictureVersion - const hasVolatileOverlays = hasPositionPreview || hasVolatileOverlay(overlays) + const requiresUncachedSceneRender = hasPositionPreview || sceneContentDependsOnOverlay(overlays) - const canUsePicture = canUseScenePicture(r, graph, sceneVersion, hasVolatileOverlays) + const canUsePicture = canUseScenePicture(r, graph, sceneVersion, requiresUncachedSceneRender) const cacheMissReason = scenePictureMissReason( r, graph, @@ -217,7 +185,7 @@ export function render( p.beginPhase('render:scene') if ( layer === 'scene' && - !hasVolatileOverlays && + !requiresUncachedSceneRender && renderSceneBacking(r, canvas, graph, sceneVersion) ) { p.setScenePictureMode('hit', 'backing') @@ -232,7 +200,7 @@ export function render( sceneVersion, canUsePicture, cacheMissReason, - hasVolatileOverlays + requiresUncachedSceneRender ) } p.endPhase('render:scene') @@ -244,41 +212,14 @@ export function render( canvas.save() canvas.scale(r.dpr, r.dpr) r.labelCache.update(graph, r.pageId, sceneVersion, graph.positionPreviewVersion) - p.beginPhase('render:sectionTitles') - r.drawSectionTitles(canvas, graph) - p.endPhase('render:sectionTitles') - p.beginPhase('render:componentLabels') - r.drawComponentLabels(canvas, graph) - p.endPhase('render:componentLabels') + drawLabelPass(r, canvas, graph) canvas.restore() canvas.save() canvas.scale(r.dpr, r.dpr) - drawInteractiveOverlays(r, canvas, graph, selectedIds, overlays) - r.drawFlashes(canvas, graph) - drawPageGuides( - r, - canvas, - graph, - overlays.guidePreview, - overlays.hoveredGuide, - overlays.selectedGuide - ) - r.drawSnapGuides(canvas, overlays.snapGuides) - r.drawMarquee(canvas, overlays.marquee) - r.drawLayoutInsertIndicator(canvas, overlays.layoutInsertIndicator) - if (!measurementVisible(overlays)) { - r.drawAutoLayoutHover(canvas, graph, overlays.autoLayoutHover) - } - r.drawNodeEditOverlay(canvas, graph, overlays.nodeEditState) - r.drawPenOverlay(canvas, overlays.penState) - r.drawRemoteCursors(canvas, graph, overlays.remoteCursors) - p.beginPhase('render:rulers') - if (r.showRulers) r.drawRulers(canvas, graph, selectedIds) - p.endPhase('render:rulers') - - p.drawHUD(canvas, r.showRulers) + drawOverlayPass(r, canvas, graph, selectedIds, overlays) + drawChromePass(r, canvas, graph, selectedIds) canvas.restore() } @@ -300,7 +241,7 @@ function renderSceneContent( sceneVersion: number, canUsePicture: boolean, cacheMissReason: string, - hasVolatileOverlays: boolean + requiresUncachedSceneRender: boolean ): void { const p = r.profiler if (canUsePicture) { @@ -312,7 +253,7 @@ function renderSceneContent( p.setScenePictureDrawTime(duration) } p.endPhase('render:drawPicture') - } else if (hasVolatileOverlays) { + } else if (requiresUncachedSceneRender) { p.setScenePictureMode('volatile', cacheMissReason) r._nodeCount = 0 r._culledCount = 0 diff --git a/packages/core/src/canvas/renderer/types.ts b/packages/core/src/canvas/renderer/types.ts index 58f39c131..ddef5b840 100644 --- a/packages/core/src/canvas/renderer/types.ts +++ b/packages/core/src/canvas/renderer/types.ts @@ -2,7 +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, GuideSelection } from '#core/editor/guide-preview' +import type { GuideOverlayState } from '#core/canvas/guides/types' import type { TextEditor } from '#core/text/editor' export interface RulerTheme { @@ -22,9 +22,7 @@ export interface RenderOverlays { textEditor?: TextEditor | null marquee?: Rect | null snapGuides?: SnapGuide[] - guidePreview?: GuidePreview | null - hoveredGuide?: GuideSelection | null - selectedGuide?: GuideSelection | null + guides?: GuideOverlayState 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 53a1cd54a..aa17c2fc5 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -209,9 +209,7 @@ export function createEditor(options?: EditorOptions) { state.hoveredNodeId = null state.measurementMode = 'off' state.snapGuides = [] - state.guidePreview = null - state.hoveredGuide = null - state.selectedGuide = null + state.guides = { preview: null, hovered: null, selected: null } state.layoutInsertIndicator = null state.dropTargetId = null pages.clearPageViewports() diff --git a/packages/core/src/editor/guide-preview.ts b/packages/core/src/editor/guide-preview.ts deleted file mode 100644 index 0f1c81189..000000000 --- a/packages/core/src/editor/guide-preview.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface GuideSelection { - ownerId: string - guideId: string -} - -export interface GuidePreview { - ownerId: string - axis: 'x' | 'y' - position: number - source?: GuideSelection -} diff --git a/packages/core/src/editor/selection/overlays.ts b/packages/core/src/editor/selection/overlays.ts index b731e511f..ae7f68fe6 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 { GuidePreview } from '#core/editor/guide-preview' +import type { GuidePreview } from '#core/canvas/guides/types' import type { EditorContext } from '#core/editor/types' export function createSelectionOverlayActions(ctx: EditorContext) { @@ -16,19 +16,19 @@ export function createSelectionOverlayActions(ctx: EditorContext) { } function setGuidePreview(preview: GuidePreview | null) { - ctx.state.guidePreview = preview + ctx.state.guides.preview = preview ctx.requestRepaint() } - function setHoveredGuide(selection: typeof ctx.state.hoveredGuide) { - const current = ctx.state.hoveredGuide + function setHoveredGuide(selection: typeof ctx.state.guides.hovered) { + const current = ctx.state.guides.hovered if (current?.ownerId === selection?.ownerId && current?.guideId === selection?.guideId) return - ctx.state.hoveredGuide = selection + ctx.state.guides.hovered = selection ctx.requestRepaint() } - function setSelectedGuide(selection: typeof ctx.state.selectedGuide) { - ctx.state.selectedGuide = selection + function setSelectedGuide(selection: typeof ctx.state.guides.selected) { + ctx.state.guides.selected = selection if (selection) ctx.setSelectedIds(new Set()) ctx.requestRepaint() } diff --git a/packages/core/src/editor/state/view.ts b/packages/core/src/editor/state/view.ts index 336a6f293..8f59597f0 100644 --- a/packages/core/src/editor/state/view.ts +++ b/packages/core/src/editor/state/view.ts @@ -1,3 +1,4 @@ +import { createGuideOverlayState } from '#core/canvas/guides/types' import { CANVAS_BG_COLOR } from '#core/constants' import type { EditorState, EditorViewState } from '#core/editor/types' @@ -7,9 +8,7 @@ export function createDefaultEditorViewState(pageId: string): EditorViewState { selectedIds: new Set(), marquee: null, snapGuides: [], - guidePreview: null, - hoveredGuide: null, - selectedGuide: null, + guides: createGuideOverlayState(), rotationPreview: null, dropTargetId: null, layoutInsertIndicator: null, @@ -38,9 +37,7 @@ export function copyEditorViewState(source: EditorViewState): EditorViewState { selectedIds: new Set(source.selectedIds), marquee: structuredClone(source.marquee), snapGuides: structuredClone(source.snapGuides), - guidePreview: structuredClone(source.guidePreview), - hoveredGuide: structuredClone(source.hoveredGuide), - selectedGuide: structuredClone(source.selectedGuide), + guides: structuredClone(source.guides), 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 3ca077b38..962b4097f 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -12,9 +12,9 @@ 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' +import type { GuideOverlayState } from '#core/canvas/guides/types' import type { RulerTheme, SkiaRenderer } from '#core/canvas/renderer' import type { MeasurementMode, RenderOverlays } from '#core/canvas/renderer/types' -import type { GuidePreview, GuideSelection } 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' @@ -53,9 +53,7 @@ export interface EditorViewState { selectedIds: Set marquee: Rect | null snapGuides: SnapGuide[] - guidePreview: GuidePreview | null - hoveredGuide: GuideSelection | null - selectedGuide: GuideSelection | null + guides: GuideOverlayState rotationPreview: { nodeId: string; angle: number } | null dropTargetId: string | null layoutInsertIndicator: { diff --git a/packages/vue/src/canvas/guide-input/use.ts b/packages/vue/src/canvas/guide-input/use.ts new file mode 100644 index 000000000..96c05b14e --- /dev/null +++ b/packages/vue/src/canvas/guide-input/use.ts @@ -0,0 +1,176 @@ +import type { Ref } from 'vue' + +import { hitTestGuides } from '@open-pencil/core/canvas' +import { RULER_SIZE } from '@open-pencil/core/constants' +import type { Editor } from '@open-pencil/core/editor' + +import type { DragGuide, DragState } from '#vue/shared/input/types' + +interface GuideInputOptions { + canvasRef: Ref + editor: Editor + canvasToLocal: (cx: number, cy: number, scopeId: string) => { lx: number; ly: number } + setDrag: (drag: DragState) => void + setCursor: (cursor: string | null) => void +} + +export function createGuideInput({ + canvasRef, + editor, + canvasToLocal, + setDrag, + setCursor +}: GuideInputOptions) { + function viewport() { + const canvas = canvasRef.value + return { + panX: editor.state.panX, + panY: editor.state.panY, + zoom: editor.state.zoom, + width: canvas?.clientWidth ?? 0, + height: canvas?.clientHeight ?? 0 + } + } + + function hitTest(sx: number, sy: number) { + return hitTestGuides(editor.graph, editor.state.currentPageId, viewport(), sx, sy) + } + + function ownerAt(cx: number, cy: number): string { + let node = editor.graph.hitTestDeep(cx, cy, editor.state.currentPageId) + while (node) { + if (node.type === 'FRAME' || node.type === 'COMPONENT') return node.id + node = node.parentId ? (editor.graph.getNode(node.parentId) ?? null) : null + } + return editor.state.currentPageId + } + + function positionFor(ownerId: string, axis: DragGuide['axis'], cx: number, cy: number) { + const owner = editor.graph.getNode(ownerId) + const local = owner && owner.type !== 'CANVAS' ? canvasToLocal(cx, cy, owner.id) : null + return axis === 'x' ? (local?.lx ?? cx) : (local?.ly ?? cy) + } + + function cursor(axis: DragGuide['axis']) { + return axis === 'x' ? 'ew-resize' : 'ns-resize' + } + + function rulerAxis(sx: number, sy: number): DragGuide['axis'] | null { + if (sx < RULER_SIZE && sy < RULER_SIZE) return null + if (sy < RULER_SIZE) return 'y' + if (sx < RULER_SIZE) return 'x' + return null + } + + function updateHover(sx: number, sy: number): string | null { + const hit = hitTest(sx, sy) + editor.setHoveredGuide(hit ? { ownerId: hit.ownerId, guideId: hit.guideId } : null) + if (hit) return cursor(hit.axis) + const axis = rulerAxis(sx, sy) + return axis ? cursor(axis) : null + } + + function tryStartExisting(sx: number, sy: number): boolean { + const hit = hitTest(sx, sy) + if (!hit) return false + editor.setSelectedGuide({ ownerId: hit.ownerId, guideId: hit.guideId }) + editor.setHoveredGuide(null) + setCursor(cursor(hit.axis)) + setDrag({ + type: 'guide', + axis: hit.axis, + ownerId: hit.ownerId, + position: hit.position, + startScreenX: sx, + startScreenY: sy, + currentScreenX: sx, + currentScreenY: sy, + dragStarted: false, + guideId: hit.guideId, + originalOwnerId: hit.ownerId, + originalPosition: hit.position + }) + return true + } + + function tryStartFromRuler(sx: number, sy: number, cx: number, cy: number): boolean { + if (!('showRulers' in editor.state) || editor.state.showRulers !== true) return false + const axis = rulerAxis(sx, sy) + if (!axis) return false + const ownerId = ownerAt(cx, cy) + setDrag({ + type: 'guide', + axis, + ownerId, + position: positionFor(ownerId, axis, cx, cy), + startScreenX: sx, + startScreenY: sy, + currentScreenX: sx, + currentScreenY: sy, + dragStarted: false + }) + return true + } + + function handleMove(drag: DragGuide, sx: number, sy: number, cx: number, cy: number): void { + drag.currentScreenX = sx + drag.currentScreenY = sy + if (!drag.dragStarted && Math.hypot(sx - drag.startScreenX, sy - drag.startScreenY) < 3) return + drag.dragStarted = true + setCursor(cursor(drag.axis)) + drag.ownerId = ownerAt(cx, cy) + drag.position = positionFor(drag.ownerId, drag.axis, cx, cy) + editor.setGuidePreview({ + ownerId: drag.ownerId, + axis: drag.axis, + position: drag.position, + source: + drag.guideId && drag.originalOwnerId + ? { ownerId: drag.originalOwnerId, guideId: drag.guideId } + : undefined + }) + } + + function finish(drag: DragGuide): void { + if (drag.dragStarted) { + if (drag.currentScreenX < RULER_SIZE || drag.currentScreenY < RULER_SIZE) { + if (drag.guideId && drag.originalOwnerId) + editor.removeGuide(drag.originalOwnerId, drag.guideId) + } else if (drag.guideId && drag.originalOwnerId) { + if (drag.ownerId === drag.originalOwnerId) + editor.moveGuide(drag.ownerId, drag.guideId, drag.position) + else editor.transferGuide(drag.originalOwnerId, drag.ownerId, drag.guideId, drag.position) + editor.setSelectedGuide({ ownerId: drag.ownerId, guideId: drag.guideId }) + } else { + const guideId = editor.addGuide(drag.ownerId, drag.axis, drag.position) + if (guideId) editor.setSelectedGuide({ ownerId: drag.ownerId, guideId }) + } + } + clearHoverAndPreview() + } + + function deleteSelected(event: KeyboardEvent): boolean { + if (event.code !== 'Delete' && event.code !== 'Backspace') return false + const selected = editor.state.guides.selected + if (!selected || editor.state.editingTextId) return false + if (!editor.removeGuide(selected.ownerId, selected.guideId)) return false + editor.setSelectedGuide(null) + event.preventDefault() + return true + } + + function clearHoverAndPreview(): void { + editor.setGuidePreview(null) + editor.setHoveredGuide(null) + } + + return { + tryStartExisting, + tryStartFromRuler, + updateHover, + handleMove, + finish, + deleteSelected, + clearHoverAndPreview + } +} diff --git a/packages/vue/src/canvas/useCanvasInput.ts b/packages/vue/src/canvas/useCanvasInput.ts index 7822ca664..e5df8b02c 100644 --- a/packages/vue/src/canvas/useCanvasInput.ts +++ b/packages/vue/src/canvas/useCanvasInput.ts @@ -1,12 +1,10 @@ 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' -import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate' -import Matrix from '@open-pencil/scene-graph/matrix' +import { createGuideInput } from '#vue/canvas/guide-input/use' import { handleBendHandleMove, handleNodeEditMouseUp, @@ -100,16 +98,6 @@ export function useCanvasInput( ) } - function deleteSelectedGuide(event: KeyboardEvent): boolean { - if (event.code !== 'Delete' && event.code !== 'Backspace') return false - const selected = editor.state.selectedGuide - if (!selected || editor.state.editingTextId) return false - if (!editor.removeGuide(selected.ownerId, selected.guideId)) return false - editor.setSelectedGuide(null) - event.preventDefault() - return true - } - function updateModifier(code: string, held: boolean) { if (!isEnabled()) return if (code === 'AltLeft' || code === 'AltRight') altHeld = held @@ -132,6 +120,16 @@ export function useCanvasInput( drag.value = d } + const guideInput = createGuideInput({ + canvasRef, + editor, + canvasToLocal, + setDrag, + setCursor: (cursor) => { + cursorOverride.value = cursor + } + }) + const { handleTextEditClick, onDblClick: onTextDblClick } = createTextEditInput({ editor, getCoords, @@ -210,154 +208,6 @@ export function useCanvasInput( autoLayoutPaddingEdit.value = null } - function guideOwner(cx: number, cy: number): { id: string; position: number } { - 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 guideHitTest(sx: number, sy: number) { - const tolerance = 5 - const page = editor.graph.getNode(editor.state.currentPageId) - if (!page) return null - const hits: Array<{ - ownerId: string - guideId: string - axis: 'x' | 'y' - position: number - distance: number - }> = [] - - const visit = (owner: SceneNode) => { - const matrix = getWorldMatrix(owner, editor.graph) - for (const guide of owner.guides) { - const start = Matrix.mapPoint( - matrix, - guide.axis === 'x' ? { x: guide.position, y: 0 } : { x: 0, y: guide.position } - ) - const end = Matrix.mapPoint( - matrix, - guide.axis === 'x' - ? { x: guide.position, y: owner.height } - : { x: owner.width, y: guide.position } - ) - const x1 = - owner.type === 'CANVAS' && guide.axis === 'y' - ? 0 - : start.x * editor.state.zoom + editor.state.panX - const y1 = - owner.type === 'CANVAS' && guide.axis === 'x' - ? 0 - : start.y * editor.state.zoom + editor.state.panY - const x2 = - owner.type === 'CANVAS' && guide.axis === 'y' - ? (canvasRef.value?.width ?? sx) - : end.x * editor.state.zoom + editor.state.panX - const y2 = - owner.type === 'CANVAS' && guide.axis === 'x' - ? (canvasRef.value?.height ?? sy) - : end.y * editor.state.zoom + editor.state.panY - const dx = x2 - x1 - const dy = y2 - y1 - const lengthSquared = dx * dx + dy * dy - const t = - lengthSquared === 0 - ? 0 - : Math.max(0, Math.min(1, ((sx - x1) * dx + (sy - y1) * dy) / lengthSquared)) - const distance = Math.hypot(sx - (x1 + t * dx), sy - (y1 + t * dy)) - if (distance <= tolerance) { - hits.push({ - ownerId: owner.id, - guideId: guide.id, - axis: guide.axis, - position: guide.position, - distance - }) - } - } - for (const childId of owner.childIds) { - const child = editor.graph.getNode(childId) - if (child) visit(child) - } - } - visit(page) - return hits.sort((a, b) => a.distance - b.distance)[0] ?? null - } - - function guideCursor(axis: 'x' | 'y') { - return axis === 'x' ? 'ew-resize' : 'ns-resize' - } - - function rulerGuideAxis(sx: number, sy: number): 'x' | 'y' | null { - if (sy < RULER_SIZE) return 'y' - if (sx < RULER_SIZE) return 'x' - return null - } - - function updateGuideHoverCursor(sx: number, sy: number, cx: number, cy: number) { - const guideHit = guideHitTest(sx, sy) - editor.setHoveredGuide( - guideHit ? { ownerId: guideHit.ownerId, guideId: guideHit.guideId } : null - ) - if (guideHit) return guideCursor(guideHit.axis) - const rulerAxis = rulerGuideAxis(sx, sy) - if (rulerAxis) return guideCursor(rulerAxis) - return updateHoverCursor(cx, cy, editor, hitFns, editor.state.measurementMode === 'deep') - } - - function startExistingGuideDrag(sx: number, sy: number): boolean { - const hit = guideHitTest(sx, sy) - if (!hit) return false - editor.setSelectedGuide({ ownerId: hit.ownerId, guideId: hit.guideId }) - editor.setHoveredGuide(null) - cursorOverride.value = guideCursor(hit.axis) - setDrag({ - type: 'guide', - axis: hit.axis, - ownerId: hit.ownerId, - position: hit.position, - startScreenX: sx, - startScreenY: sy, - currentScreenX: sx, - currentScreenY: sy, - dragStarted: false, - guideId: hit.guideId, - originalOwnerId: hit.ownerId, - originalPosition: hit.position - }) - return true - } - - 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 - let axis: 'x' | 'y' | null = null - if (sy < RULER_SIZE) axis = 'y' - else if (sx < RULER_SIZE) axis = 'x' - 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) - setDrag({ - type: 'guide', - axis, - ownerId: target.id, - position, - startScreenX: sx, - startScreenY: sy, - currentScreenX: sx, - currentScreenY: sy, - dragStarted: false - }) - return true - } - function onDblClick(e: MouseEvent) { if (startAutoLayoutPaddingEdit(e)) return onTextDblClick(e) @@ -374,11 +224,11 @@ export function useCanvasInput( if (!editor.state.editingTextId) canvasRef.value?.focus() editor.setHoveredNode(null) const { sx, sy, cx, cy } = getCoords(e) - if (e.button === 0 && startExistingGuideDrag(sx, sy)) { + if (e.button === 0 && guideInput.tryStartExisting(sx, sy)) { e.preventDefault() return } - if (e.button === 0 && startGuideDrag(sx, sy, cx, cy)) { + if (e.button === 0 && guideInput.tryStartFromRuler(sx, sy, cx, cy)) { e.preventDefault() return } @@ -425,7 +275,10 @@ export function useCanvasInput( if (!drag.value && editor.state.activeTool === 'SELECT') { const { sx, sy, cx, cy } = coords - cursorOverride.value = updateGuideHoverCursor(sx, sy, cx, cy) + const guideCursor = guideInput.updateHover(sx, sy) + cursorOverride.value = + guideCursor ?? + updateHoverCursor(cx, cy, editor, hitFns, editor.state.measurementMode === 'deep') editor.setAutoLayoutHover( editor.state.measurementMode === 'off' ? resolveAutoLayoutHover(cx, cy, editor) : null ) @@ -442,25 +295,7 @@ export function useCanvasInput( const { sx, sy, cx, cy } = getCoords(e) if (d.type === 'guide') { - d.currentScreenX = sx - d.currentScreenY = sy - if (!d.dragStarted && Math.hypot(sx - d.startScreenX, sy - d.startScreenY) < 3) return - d.dragStarted = true - cursorOverride.value = guideCursor(d.axis) - 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, - source: - d.guideId && d.originalOwnerId - ? { ownerId: d.originalOwnerId, guideId: d.guideId } - : undefined - }) + guideInput.handleMove(d, sx, sy, cx, cy) return } @@ -504,23 +339,6 @@ export function useCanvasInput( handleMarqueeMove(d, cx, cy) } - function finishGuideDrag(d: Extract) { - if (d.dragStarted) { - if (d.currentScreenX < RULER_SIZE || d.currentScreenY < RULER_SIZE) { - if (d.guideId && d.originalOwnerId) editor.removeGuide(d.originalOwnerId, d.guideId) - } else if (d.guideId && d.originalOwnerId) { - if (d.ownerId === d.originalOwnerId) editor.moveGuide(d.ownerId, d.guideId, d.position) - else editor.transferGuide(d.originalOwnerId, d.ownerId, d.guideId, d.position) - editor.setSelectedGuide({ ownerId: d.ownerId, guideId: d.guideId }) - } else { - const guideId = editor.addGuide(d.ownerId, d.axis, d.position) - if (guideId) editor.setSelectedGuide({ ownerId: d.ownerId, guideId }) - } - } - editor.setGuidePreview(null) - editor.setHoveredGuide(null) - } - function onMouseUp() { if (!isEnabled()) return if (!drag.value) return @@ -529,7 +347,7 @@ export function useCanvasInput( if (handleNodeEditMouseUp(drag, editor)) return if (d.type === 'guide') { - finishGuideDrag(d) + guideInput.finish(d) } else if (d.type === 'move') handleMoveUp(d, editor) else if (d.type === 'text-select') { drag.value = null @@ -565,8 +383,7 @@ export function useCanvasInput( editor.setSnapGuides([]) editor.setLayoutInsertIndicator(null) editor.setDropTarget(null) - editor.setGuidePreview(null) - editor.setHoveredGuide(null) + guideInput.clearHoverAndPreview() } function cancelPointerInteraction() { @@ -580,7 +397,7 @@ export function useCanvasInput( useEventListener(canvasRef, 'mousemove', onMouseMove) useEventListener(canvasRef, 'mouseup', onMouseUp) useEventListener(window, 'keydown', (event) => { - if (!deleteSelectedGuide(event)) updateModifier(event.code, true) + if (!guideInput.deleteSelected(event)) updateModifier(event.code, true) }) useEventListener(window, 'keyup', (event) => updateModifier(event.code, false)) useEventListener(window, 'blur', () => { diff --git a/src/app/editor/panes/state.ts b/src/app/editor/panes/state.ts index 23763222a..aa02c2142 100644 --- a/src/app/editor/panes/state.ts +++ b/src/app/editor/panes/state.ts @@ -33,7 +33,7 @@ export function cloneCanvasPaneState(id: string, source: CanvasPaneState): Canva editingTextId: null, marquee: null, snapGuides: [], - guidePreview: null, + guides: { preview: null, hovered: null, selected: null }, rotationPreview: null, dropTargetId: null, layoutInsertIndicator: null, diff --git a/tests/engine/render/canvas/guides/geometry.test.ts b/tests/engine/render/canvas/guides/geometry.test.ts new file mode 100644 index 000000000..9b27a91c3 --- /dev/null +++ b/tests/engine/render/canvas/guides/geometry.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test' + +import { SceneGraph } from '@open-pencil/scene-graph' +import type { SceneNode } from '@open-pencil/scene-graph' + +import { + distanceToGuideSegment, + getGuideScreenSegment, + hitTestGuides +} from '#core/canvas/guides/geometry' + +function pageWithGuide(): { graph: SceneGraph; page: SceneNode } { + const page = { + id: 'page', + type: 'CANVAS', + parentId: null, + childIds: [], + guides: [{ id: 'guide', axis: 'x', position: 20 }] + } as SceneNode + const graph = new SceneGraph() + graph.nodes = new Map([['page', page]]) + graph.rootId = 'page' + return { graph, page } +} + +describe('guide screen geometry', () => { + test('spans page guides across the viewport', () => { + const { graph, page } = pageWithGuide() + expect( + getGuideScreenSegment(graph, page, page.guides[0], { + panX: 10, + panY: 0, + zoom: 2, + width: 300, + height: 200 + }) + ).toEqual({ x1: 50, y1: 0, x2: 50, y2: 200 }) + }) + + test('measures distance to the bounded segment', () => { + expect(distanceToGuideSegment(20, 5, { x1: 10, y1: 0, x2: 10, y2: 20 })).toBe(10) + expect(distanceToGuideSegment(10, 30, { x1: 10, y1: 0, x2: 10, y2: 20 })).toBe(10) + }) + + test('hit tests page guides using screen coordinates', () => { + const { graph } = pageWithGuide() + const hit = hitTestGuides( + graph, + 'page', + { panX: 10, panY: 0, zoom: 2, width: 300, height: 200 }, + 52, + 100 + ) + expect(hit).toMatchObject({ ownerId: 'page', guideId: 'guide', axis: 'x', position: 20 }) + }) +}) diff --git a/tests/engine/render/canvas/page-guides.test.ts b/tests/engine/render/canvas/page-guides.test.ts index 39e61f52d..5e9fb5311 100644 --- a/tests/engine/render/canvas/page-guides.test.ts +++ b/tests/engine/render/canvas/page-guides.test.ts @@ -5,13 +5,14 @@ import type { Canvas } from 'canvaskit-wasm' import { SceneGraph } from '@open-pencil/scene-graph' import type { SceneNode } from '@open-pencil/scene-graph' -import { drawPageGuides } from '#core/canvas/page-guides' +import { drawGuides } from '#core/canvas/guides/render' import { createMockCanvas, createMockRenderer, mockCalls } from './effects/helpers' function graphWithGuides(guides: SceneNode['guides']): SceneGraph { const page = { id: 'page', + type: 'CANVAS', childIds: [], guides } as SceneNode @@ -37,7 +38,7 @@ describe('page guide rendering', () => { { id: 'y', axis: 'y', position: 84 } ]) - drawPageGuides(r, canvas as Canvas, graph) + drawGuides(r, canvas as Canvas, graph) expect(mockCalls(canvas.drawRect)).toHaveLength(2) expect(mockCalls(r.ck.LTRBRect)).toEqual([ @@ -83,7 +84,7 @@ describe('page guide rendering', () => { graph.rootId = 'root' graph.nodes = nodes - drawPageGuides(r, canvas as Canvas, graph) + drawGuides(r, canvas as Canvas, graph) expect(canvas.drawLine).toHaveBeenCalled() }) @@ -93,7 +94,7 @@ describe('page guide rendering', () => { const canvas = createMockCanvas() const graph = graphWithGuides([]) - drawPageGuides(r, canvas as Canvas, graph) + drawGuides(r, canvas as Canvas, graph) expect(canvas.drawRect).not.toHaveBeenCalled() }) From 3497aef8189c43d1f007fd64b2109c8dd54bd517 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 20 Aug 2026 08:47:33 +0300 Subject: [PATCH 10/12] refactor(canvas): normalize guide domain naming --- .../src/canvas/guides/{render.ts => draw.ts} | 0 packages/core/src/canvas/guides/geometry.ts | 47 ----------------- packages/core/src/canvas/guides/hit-test.ts | 51 +++++++++++++++++++ packages/core/src/canvas/index.ts | 3 +- .../core/src/canvas/renderer/overlay-pass.ts | 2 +- .../draw.test.ts} | 4 +- .../render/canvas/guides/geometry.test.ts | 7 +-- 7 files changed, 57 insertions(+), 57 deletions(-) rename packages/core/src/canvas/guides/{render.ts => draw.ts} (100%) create mode 100644 packages/core/src/canvas/guides/hit-test.ts rename tests/engine/render/canvas/{page-guides.test.ts => guides/draw.test.ts} (97%) diff --git a/packages/core/src/canvas/guides/render.ts b/packages/core/src/canvas/guides/draw.ts similarity index 100% rename from packages/core/src/canvas/guides/render.ts rename to packages/core/src/canvas/guides/draw.ts diff --git a/packages/core/src/canvas/guides/geometry.ts b/packages/core/src/canvas/guides/geometry.ts index 4341580cb..9e5d61cd6 100644 --- a/packages/core/src/canvas/guides/geometry.ts +++ b/packages/core/src/canvas/guides/geometry.ts @@ -18,14 +18,6 @@ export interface GuideScreenSegment { y2: number } -export interface GuideHit { - ownerId: string - guideId: string - axis: CanvasGuide['axis'] - position: number - distance: number -} - export function getGuideScreenSegment( graph: SceneGraph, owner: SceneNode, @@ -70,42 +62,3 @@ export function distanceToGuideSegment(x: number, y: number, segment: GuideScree : Math.max(0, Math.min(1, ((x - segment.x1) * dx + (y - segment.y1) * dy) / lengthSquared)) return Math.hypot(x - (segment.x1 + t * dx), y - (segment.y1 + t * dy)) } - -export function hitTestGuides( - graph: SceneGraph, - pageId: string, - viewport: GuideViewport, - x: number, - y: number, - tolerance = 5 -): GuideHit | null { - const page = graph.getNode(pageId) - if (!page) return null - let closest: GuideHit | null = null - - const visit = (owner: SceneNode) => { - for (const guide of owner.guides) { - const distance = distanceToGuideSegment( - x, - y, - getGuideScreenSegment(graph, owner, guide, viewport) - ) - if (distance <= tolerance && (!closest || distance < closest.distance)) { - closest = { - ownerId: owner.id, - guideId: guide.id, - axis: guide.axis, - position: guide.position, - distance - } - } - } - for (const childId of owner.childIds) { - const child = graph.getNode(childId) - if (child) visit(child) - } - } - - visit(page) - return closest -} diff --git a/packages/core/src/canvas/guides/hit-test.ts b/packages/core/src/canvas/guides/hit-test.ts new file mode 100644 index 000000000..7fa63c2be --- /dev/null +++ b/packages/core/src/canvas/guides/hit-test.ts @@ -0,0 +1,51 @@ +import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' +import type { CanvasGuide } from '@open-pencil/scene-graph/guides' + +import { distanceToGuideSegment, getGuideScreenSegment, type GuideViewport } from './geometry' + +export interface GuideHit { + ownerId: string + guideId: string + axis: CanvasGuide['axis'] + position: number + distance: number +} + +export function hitTestGuides( + graph: SceneGraph, + pageId: string, + viewport: GuideViewport, + x: number, + y: number, + tolerance = 5 +): GuideHit | null { + const page = graph.getNode(pageId) + if (!page) return null + let closest: GuideHit | null = null + + const visit = (owner: SceneNode) => { + for (const guide of owner.guides) { + const distance = distanceToGuideSegment( + x, + y, + getGuideScreenSegment(graph, owner, guide, viewport) + ) + if (distance <= tolerance && (!closest || distance < closest.distance)) { + closest = { + ownerId: owner.id, + guideId: guide.id, + axis: guide.axis, + position: guide.position, + distance + } + } + } + for (const childId of owner.childIds) { + const child = graph.getNode(childId) + if (child) visit(child) + } + } + + visit(page) + return closest +} diff --git a/packages/core/src/canvas/index.ts b/packages/core/src/canvas/index.ts index 90d8dd09e..4d474768a 100644 --- a/packages/core/src/canvas/index.ts +++ b/packages/core/src/canvas/index.ts @@ -7,10 +7,9 @@ export { export { distanceToGuideSegment, getGuideScreenSegment, - hitTestGuides, - type GuideHit, type GuideScreenSegment, type GuideViewport } from './guides/geometry' +export { hitTestGuides, type GuideHit } from './guides/hit-test' export type { GuideOverlayState, GuidePreview, GuideSelection } from './guides/types' export { SkiaRenderer, type RenderOverlays, type RulerTheme } from './renderer' diff --git a/packages/core/src/canvas/renderer/overlay-pass.ts b/packages/core/src/canvas/renderer/overlay-pass.ts index 4ffb573e6..063153259 100644 --- a/packages/core/src/canvas/renderer/overlay-pass.ts +++ b/packages/core/src/canvas/renderer/overlay-pass.ts @@ -2,7 +2,7 @@ import type { Canvas } from 'canvaskit-wasm' import type { SceneGraph } from '@open-pencil/scene-graph' -import { drawGuides } from '#core/canvas/guides/render' +import { drawGuides } from '#core/canvas/guides/draw' import type { RenderOverlays, SkiaRenderer } from '#core/canvas/renderer' function measurementVisible(overlays: RenderOverlays): boolean { diff --git a/tests/engine/render/canvas/page-guides.test.ts b/tests/engine/render/canvas/guides/draw.test.ts similarity index 97% rename from tests/engine/render/canvas/page-guides.test.ts rename to tests/engine/render/canvas/guides/draw.test.ts index 5e9fb5311..0e99d608c 100644 --- a/tests/engine/render/canvas/page-guides.test.ts +++ b/tests/engine/render/canvas/guides/draw.test.ts @@ -5,9 +5,9 @@ import type { Canvas } from 'canvaskit-wasm' import { SceneGraph } from '@open-pencil/scene-graph' import type { SceneNode } from '@open-pencil/scene-graph' -import { drawGuides } from '#core/canvas/guides/render' +import { drawGuides } from '#core/canvas/guides/draw' -import { createMockCanvas, createMockRenderer, mockCalls } from './effects/helpers' +import { createMockCanvas, createMockRenderer, mockCalls } from '../effects/helpers' function graphWithGuides(guides: SceneNode['guides']): SceneGraph { const page = { diff --git a/tests/engine/render/canvas/guides/geometry.test.ts b/tests/engine/render/canvas/guides/geometry.test.ts index 9b27a91c3..c9d0bd283 100644 --- a/tests/engine/render/canvas/guides/geometry.test.ts +++ b/tests/engine/render/canvas/guides/geometry.test.ts @@ -3,11 +3,8 @@ import { describe, expect, test } from 'bun:test' import { SceneGraph } from '@open-pencil/scene-graph' import type { SceneNode } from '@open-pencil/scene-graph' -import { - distanceToGuideSegment, - getGuideScreenSegment, - hitTestGuides -} from '#core/canvas/guides/geometry' +import { distanceToGuideSegment, getGuideScreenSegment } from '#core/canvas/guides/geometry' +import { hitTestGuides } from '#core/canvas/guides/hit-test' function pageWithGuide(): { graph: SceneGraph; page: SceneNode } { const page = { From b8d052f5c47424d45319b73d01d0073517a032a4 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 20 Aug 2026 08:53:13 +0300 Subject: [PATCH 11/12] refactor(vue): normalize canvas interaction domains --- .../src/canvas/{guide-input/use.ts => guides/input.ts} | 0 .../src/canvas/{node-edit-input => node-edit}/bend.ts | 0 .../{node-edit-input/use.ts => node-edit/input.ts} | 2 +- packages/vue/src/canvas/{pen-input => pen}/drag.ts | 0 .../vue/src/canvas/{pen-input/use.ts => pen/input.ts} | 2 +- packages/vue/src/canvas/tool-input/index.ts | 1 - .../src/canvas/{tool-input/use.ts => tools/input.ts} | 2 +- .../canvas/{transform-input => transform}/actions.ts | 8 ++++---- .../{transform-input/use.ts => transform/input.ts} | 2 +- .../canvas/{transform-input => transform}/marquee.ts | 0 .../src/canvas/{transform-input => transform}/pan.ts | 0 .../canvas/{transform-input => transform}/rotation.ts | 0 .../{transform-input => transform}/text-selection.ts | 0 packages/vue/src/canvas/useCanvasInput.ts | 10 +++++----- 14 files changed, 13 insertions(+), 14 deletions(-) rename packages/vue/src/canvas/{guide-input/use.ts => guides/input.ts} (100%) rename packages/vue/src/canvas/{node-edit-input => node-edit}/bend.ts (100%) rename packages/vue/src/canvas/{node-edit-input/use.ts => node-edit/input.ts} (98%) rename packages/vue/src/canvas/{pen-input => pen}/drag.ts (100%) rename packages/vue/src/canvas/{pen-input/use.ts => pen/input.ts} (99%) delete mode 100644 packages/vue/src/canvas/tool-input/index.ts rename packages/vue/src/canvas/{tool-input/use.ts => tools/input.ts} (96%) rename packages/vue/src/canvas/{transform-input => transform}/actions.ts (90%) rename packages/vue/src/canvas/{transform-input/use.ts => transform/input.ts} (57%) rename packages/vue/src/canvas/{transform-input => transform}/marquee.ts (100%) rename packages/vue/src/canvas/{transform-input => transform}/pan.ts (100%) rename packages/vue/src/canvas/{transform-input => transform}/rotation.ts (100%) rename packages/vue/src/canvas/{transform-input => transform}/text-selection.ts (100%) diff --git a/packages/vue/src/canvas/guide-input/use.ts b/packages/vue/src/canvas/guides/input.ts similarity index 100% rename from packages/vue/src/canvas/guide-input/use.ts rename to packages/vue/src/canvas/guides/input.ts diff --git a/packages/vue/src/canvas/node-edit-input/bend.ts b/packages/vue/src/canvas/node-edit/bend.ts similarity index 100% rename from packages/vue/src/canvas/node-edit-input/bend.ts rename to packages/vue/src/canvas/node-edit/bend.ts diff --git a/packages/vue/src/canvas/node-edit-input/use.ts b/packages/vue/src/canvas/node-edit/input.ts similarity index 98% rename from packages/vue/src/canvas/node-edit-input/use.ts rename to packages/vue/src/canvas/node-edit/input.ts index 94ab083ba..18ed70538 100644 --- a/packages/vue/src/canvas/node-edit-input/use.ts +++ b/packages/vue/src/canvas/node-edit/input.ts @@ -7,7 +7,7 @@ import { handleBendHandleMove, resolveBendTargetHandle, type CanvasNodeEditMethods -} from '#vue/canvas/node-edit-input/bend' +} from '#vue/canvas/node-edit/bend' import { hitTestEditHandle, isEndpoint, NODE_HIT_THRESHOLD } from '#vue/shared/input/node-edit' import type { DragState } from '#vue/shared/input/types' diff --git a/packages/vue/src/canvas/pen-input/drag.ts b/packages/vue/src/canvas/pen/drag.ts similarity index 100% rename from packages/vue/src/canvas/pen-input/drag.ts rename to packages/vue/src/canvas/pen/drag.ts diff --git a/packages/vue/src/canvas/pen-input/use.ts b/packages/vue/src/canvas/pen/input.ts similarity index 99% rename from packages/vue/src/canvas/pen-input/use.ts rename to packages/vue/src/canvas/pen/input.ts index 3d11900c7..189b2c53f 100644 --- a/packages/vue/src/canvas/pen-input/use.ts +++ b/packages/vue/src/canvas/pen/input.ts @@ -3,7 +3,7 @@ import type { Ref } from 'vue' import { PEN_CLOSE_THRESHOLD } from '@open-pencil/core/constants' import type { Editor } from '@open-pencil/core/editor' -import { createPenDrag, handlePenDragMove } from '#vue/canvas/pen-input/drag' +import { createPenDrag, handlePenDragMove } from '#vue/canvas/pen/drag' import { handlePenNodeEditDown } from '#vue/shared/input/node-edit' import type { DragState } from '#vue/shared/input/types' diff --git a/packages/vue/src/canvas/tool-input/index.ts b/packages/vue/src/canvas/tool-input/index.ts deleted file mode 100644 index b05b352fb..000000000 --- a/packages/vue/src/canvas/tool-input/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { handleToolMouseDown, startPanDrag } from './use' diff --git a/packages/vue/src/canvas/tool-input/use.ts b/packages/vue/src/canvas/tools/input.ts similarity index 96% rename from packages/vue/src/canvas/tool-input/use.ts rename to packages/vue/src/canvas/tools/input.ts index 3d3a81843..2d87b1df4 100644 --- a/packages/vue/src/canvas/tool-input/use.ts +++ b/packages/vue/src/canvas/tools/input.ts @@ -2,7 +2,7 @@ import type { Ref } from 'vue' import type { Editor } from '@open-pencil/core/editor' -import { startPenInput } from '#vue/canvas/pen-input/use' +import { startPenInput } from '#vue/canvas/pen/input' import { startShapeDraw, startTextDraw } from '#vue/shared/input/draw' import { startPanDrag } from '#vue/shared/input/pan' import { handleSelectDown } from '#vue/shared/input/select' diff --git a/packages/vue/src/canvas/transform-input/actions.ts b/packages/vue/src/canvas/transform/actions.ts similarity index 90% rename from packages/vue/src/canvas/transform-input/actions.ts rename to packages/vue/src/canvas/transform/actions.ts index 6d06d9d48..0900d9687 100644 --- a/packages/vue/src/canvas/transform-input/actions.ts +++ b/packages/vue/src/canvas/transform/actions.ts @@ -1,12 +1,12 @@ import type { Editor } from '@open-pencil/core/editor' -import { handleMarqueeMove as handleMarqueeMoveAction } from '#vue/canvas/transform-input/marquee' -import { handlePanMove as handlePanMoveAction } from '#vue/canvas/transform-input/pan' +import { handleMarqueeMove as handleMarqueeMoveAction } from '#vue/canvas/transform/marquee' +import { handlePanMove as handlePanMoveAction } from '#vue/canvas/transform/pan' import { handleRotateMove as handleRotateMoveAction, tryStartRotation as tryStartRotationAction -} from '#vue/canvas/transform-input/rotation' -import { handleTextSelectMove as handleTextSelectMoveAction } from '#vue/canvas/transform-input/text-selection' +} from '#vue/canvas/transform/rotation' +import { handleTextSelectMove as handleTextSelectMoveAction } from '#vue/canvas/transform/text-selection' import type { DragMarquee, DragPan, DragRotate, DragState } from '#vue/shared/input/types' type CanvasToLocal = (cx: number, cy: number, scopeId: string) => { lx: number; ly: number } diff --git a/packages/vue/src/canvas/transform-input/use.ts b/packages/vue/src/canvas/transform/input.ts similarity index 57% rename from packages/vue/src/canvas/transform-input/use.ts rename to packages/vue/src/canvas/transform/input.ts index d824683e4..d2cda6475 100644 --- a/packages/vue/src/canvas/transform-input/use.ts +++ b/packages/vue/src/canvas/transform/input.ts @@ -1 +1 @@ -export { createTransformInputActions as createCanvasTransformInput } from '#vue/canvas/transform-input/actions' +export { createTransformInputActions as createCanvasTransformInput } from '#vue/canvas/transform/actions' diff --git a/packages/vue/src/canvas/transform-input/marquee.ts b/packages/vue/src/canvas/transform/marquee.ts similarity index 100% rename from packages/vue/src/canvas/transform-input/marquee.ts rename to packages/vue/src/canvas/transform/marquee.ts diff --git a/packages/vue/src/canvas/transform-input/pan.ts b/packages/vue/src/canvas/transform/pan.ts similarity index 100% rename from packages/vue/src/canvas/transform-input/pan.ts rename to packages/vue/src/canvas/transform/pan.ts diff --git a/packages/vue/src/canvas/transform-input/rotation.ts b/packages/vue/src/canvas/transform/rotation.ts similarity index 100% rename from packages/vue/src/canvas/transform-input/rotation.ts rename to packages/vue/src/canvas/transform/rotation.ts diff --git a/packages/vue/src/canvas/transform-input/text-selection.ts b/packages/vue/src/canvas/transform/text-selection.ts similarity index 100% rename from packages/vue/src/canvas/transform-input/text-selection.ts rename to packages/vue/src/canvas/transform/text-selection.ts diff --git a/packages/vue/src/canvas/useCanvasInput.ts b/packages/vue/src/canvas/useCanvasInput.ts index e5df8b02c..cb9be0c15 100644 --- a/packages/vue/src/canvas/useCanvasInput.ts +++ b/packages/vue/src/canvas/useCanvasInput.ts @@ -4,17 +4,17 @@ import { onScopeDispose, ref, type Ref } from 'vue' import type { Editor } from '@open-pencil/core/editor' import type { SceneNode } from '@open-pencil/scene-graph' -import { createGuideInput } from '#vue/canvas/guide-input/use' +import { createGuideInput } from '#vue/canvas/guides/input' import { handleBendHandleMove, handleNodeEditMouseUp, updateNodeEditHover -} from '#vue/canvas/node-edit-input/use' -import { handlePenDragMove, updatePenHover } from '#vue/canvas/pen-input/use' +} from '#vue/canvas/node-edit/input' +import { handlePenDragMove, updatePenHover } from '#vue/canvas/pen/input' import { createCanvasPointer } from '#vue/canvas/pointer/use' import { createTextEditInput } from '#vue/canvas/text-edit/input' -import { handleToolMouseDown } from '#vue/canvas/tool-input/use' -import { createCanvasTransformInput } from '#vue/canvas/transform-input/use' +import { handleToolMouseDown } from '#vue/canvas/tools/input' +import { createCanvasTransformInput } from '#vue/canvas/transform/input' import { resolveAutoLayoutHover } from '#vue/shared/input/auto-layout-hover' import { createClickCounter } from '#vue/shared/input/click-count' import { handleDrawMove, handleDrawUp } from '#vue/shared/input/draw' From 5acc2235c628b582109d76cb8410d6da46b27436 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 20 Aug 2026 08:56:33 +0300 Subject: [PATCH 12/12] fix(canvas): polish guide ruler interactions --- CHANGELOG.md | 1 + packages/vue/src/canvas/guides/input.ts | 14 ++++-- tests/engine/vue/input/guides.test.ts | 59 +++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 tests/engine/vue/input/guides.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7693c96c2..b73507823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- Create, select, move, transfer, and delete canvas and frame guides directly from rulers, with undoable edits and `.fig` round-trip fidelity. - Snap vector points, moved layers, and resized edges to nearby geometry, sibling layer bounds, canvas and frame layout guides, and whole-pixel coordinates with visible alignment guides, fractional-coordinate preservation when pixel snapping is off, and persistent geometry, object, and pixel-grid controls in General settings and the Preferences menu. - Run Pi through AI SDK HarnessAgent as a configurable desktop provider with multiple saved model profiles, secure credentials, existing MCP design tools, and per-profile thinking and permission settings. - Open multiple selected design files in separate tabs. diff --git a/packages/vue/src/canvas/guides/input.ts b/packages/vue/src/canvas/guides/input.ts index 96c05b14e..1214cf6b6 100644 --- a/packages/vue/src/canvas/guides/input.ts +++ b/packages/vue/src/canvas/guides/input.ts @@ -63,14 +63,18 @@ export function createGuideInput({ } function updateHover(sx: number, sy: number): string | null { + const axis = rulerAxis(sx, sy) + if (axis) { + editor.setHoveredGuide(null) + return cursor(axis) + } const hit = hitTest(sx, sy) editor.setHoveredGuide(hit ? { ownerId: hit.ownerId, guideId: hit.guideId } : null) - if (hit) return cursor(hit.axis) - const axis = rulerAxis(sx, sy) - return axis ? cursor(axis) : null + return hit ? cursor(hit.axis) : null } function tryStartExisting(sx: number, sy: number): boolean { + if (rulerAxis(sx, sy)) return false const hit = hitTest(sx, sy) if (!hit) return false editor.setSelectedGuide({ ownerId: hit.ownerId, guideId: hit.guideId }) @@ -134,8 +138,10 @@ export function createGuideInput({ function finish(drag: DragGuide): void { if (drag.dragStarted) { if (drag.currentScreenX < RULER_SIZE || drag.currentScreenY < RULER_SIZE) { - if (drag.guideId && drag.originalOwnerId) + if (drag.guideId && drag.originalOwnerId) { editor.removeGuide(drag.originalOwnerId, drag.guideId) + editor.setSelectedGuide(null) + } } else if (drag.guideId && drag.originalOwnerId) { if (drag.ownerId === drag.originalOwnerId) editor.moveGuide(drag.ownerId, drag.guideId, drag.position) diff --git a/tests/engine/vue/input/guides.test.ts b/tests/engine/vue/input/guides.test.ts new file mode 100644 index 000000000..a058a1323 --- /dev/null +++ b/tests/engine/vue/input/guides.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test' + +import { ref } from 'vue' + +import { createEditor } from '@open-pencil/core/editor' + +import { createGuideInput } from '#vue/canvas/guides/input' +import type { DragState } from '#vue/shared/input/types' + +function setup() { + const editor = createEditor() + Object.assign(editor.state, { showRulers: true }) + let drag: DragState | null = null + const input = createGuideInput({ + canvasRef: ref(null), + editor, + canvasToLocal: (cx, cy) => ({ lx: cx, ly: cy }), + setDrag: (next) => { + drag = next + }, + setCursor: () => undefined + }) + return { editor, input, getDrag: () => drag } +} + +describe('guide canvas input', () => { + test('does not create a guide from a ruler click without movement', () => { + const { editor, input, getDrag } = setup() + expect(input.tryStartFromRuler(100, 5, 100, 5)).toBe(true) + const drag = getDrag() + expect(drag?.type).toBe('guide') + if (drag?.type === 'guide') input.finish(drag) + expect(editor.graph.getNode(editor.state.currentPageId)?.guides).toEqual([]) + }) + + test('publishes live preview after the drag threshold and commits on release', () => { + const { editor, input, getDrag } = setup() + input.tryStartFromRuler(100, 5, 100, 5) + const drag = getDrag() + if (drag?.type !== 'guide') throw new Error('Expected guide drag') + + input.handleMove(drag, 100, 40, 100, 40) + expect(editor.state.guides.preview).toMatchObject({ axis: 'y', position: 40 }) + expect(editor.graph.getNode(editor.state.currentPageId)?.guides).toEqual([]) + + input.finish(drag) + expect(editor.graph.getNode(editor.state.currentPageId)?.guides[0]).toMatchObject({ + axis: 'y', + position: 40 + }) + }) + + test('ruler hover takes precedence over an intersecting existing guide', () => { + const { editor, input } = setup() + editor.addGuide(editor.state.currentPageId, 'x', 100) + expect(input.updateHover(100, 5)).toBe('ns-resize') + expect(editor.state.guides.hovered).toBeNull() + }) +})