diff --git a/CHANGELOG.md b/CHANGELOG.md index 535654c75..84806724e 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/core/src/canvas/guides/draw.ts b/packages/core/src/canvas/guides/draw.ts new file mode 100644 index 000000000..204181bbb --- /dev/null +++ b/packages/core/src/canvas/guides/draw.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/geometry.ts b/packages/core/src/canvas/guides/geometry.ts new file mode 100644 index 000000000..9e5d61cd6 --- /dev/null +++ b/packages/core/src/canvas/guides/geometry.ts @@ -0,0 +1,64 @@ +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 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)) +} 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/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..4d474768a 100644 --- a/packages/core/src/canvas/index.ts +++ b/packages/core/src/canvas/index.ts @@ -4,4 +4,12 @@ export { hasVisibleStrokeSourceNode, nodeHasVisibleStroke } from './boolean' +export { + distanceToGuideSegment, + getGuideScreenSegment, + 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/page-guides.ts b/packages/core/src/canvas/page-guides.ts deleted file mode 100644 index 11aad8c75..000000000 --- a/packages/core/src/canvas/page-guides.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Canvas } from 'canvaskit-wasm' - -import type { SceneGraph } from '@open-pencil/scene-graph' - -import { SELECTION_COLOR } from '#core/constants' - -import type { SkiaRenderer } from './renderer' - -export function drawPageGuides(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void { - const page = graph.getNode(r.pageId ?? graph.rootId) - if (!page || page.guides.length === 0) return - - r.auxStroke.setStrokeWidth(1) - r.auxStroke.setColor(r.ck.Color4f(SELECTION_COLOR.r, SELECTION_COLOR.g, SELECTION_COLOR.b, 0.65)) - - for (const guide of page.guides) { - 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) - } - } -} 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..063153259 --- /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/draw' +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 4de7d2439..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,6 +61,7 @@ export function renderFromEditorState( textEditor: textEditor as RenderOverlays['textEditor'], marquee: state.marquee, snapGuides: state.snapGuides, + guides: state.guides, rotationPreview: state.rotationPreview, dropTargetId: state.dropTargetId, layoutInsertIndicator: state.layoutInsertIndicator, @@ -80,7 +81,7 @@ export function renderFromEditorState( ) } -function hasVolatileOverlay(overlays: RenderOverlays): boolean { +function sceneContentDependsOnOverlay(overlays: RenderOverlays): boolean { return ( overlays.dropTargetId != null || overlays.rotationPreview != null || @@ -97,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' @@ -111,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 && @@ -131,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, @@ -196,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, @@ -214,7 +185,7 @@ export function render( p.beginPhase('render:scene') if ( layer === 'scene' && - !hasVolatileOverlays && + !requiresUncachedSceneRender && renderSceneBacking(r, canvas, graph, sceneVersion) ) { p.setScenePictureMode('hit', 'backing') @@ -229,7 +200,7 @@ export function render( sceneVersion, canUsePicture, cacheMissReason, - hasVolatileOverlays + requiresUncachedSceneRender ) } p.endPhase('render:scene') @@ -241,34 +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) - 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() } @@ -290,7 +241,7 @@ function renderSceneContent( sceneVersion: number, canUsePicture: boolean, cacheMissReason: string, - hasVolatileOverlays: boolean + requiresUncachedSceneRender: boolean ): void { const p = r.profiler if (canUsePicture) { @@ -302,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 a2d94e19a..ddef5b840 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 { GuideOverlayState } from '#core/canvas/guides/types' import type { TextEditor } from '#core/text/editor' export interface RulerTheme { @@ -21,6 +22,7 @@ export interface RenderOverlays { textEditor?: TextEditor | null marquee?: Rect | null snapGuides?: SnapGuide[] + 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 a2d71bb9f..aa17c2fc5 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.guides = { preview: null, hovered: null, selected: 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..10f91ab7e --- /dev/null +++ b/packages/core/src/editor/guides.ts @@ -0,0 +1,101 @@ +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 { + 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() +} + +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..ae7f68fe6 100644 --- a/packages/core/src/editor/selection/overlays.ts +++ b/packages/core/src/editor/selection/overlays.ts @@ -1,6 +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/canvas/guides/types' import type { EditorContext } from '#core/editor/types' export function createSelectionOverlayActions(ctx: EditorContext) { @@ -14,6 +15,24 @@ export function createSelectionOverlayActions(ctx: EditorContext) { ctx.requestRepaint() } + function setGuidePreview(preview: GuidePreview | null) { + ctx.state.guides.preview = preview + ctx.requestRepaint() + } + + 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.guides.hovered = selection + ctx.requestRepaint() + } + + function setSelectedGuide(selection: typeof ctx.state.guides.selected) { + ctx.state.guides.selected = selection + if (selection) ctx.setSelectedIds(new Set()) + ctx.requestRepaint() + } + function setRotationPreview(preview: { nodeId: string; angle: number } | null) { ctx.state.rotationPreview = preview ctx.requestRepaint() @@ -60,6 +79,9 @@ export function createSelectionOverlayActions(ctx: EditorContext) { return { setMarquee, setSnapGuides, + setGuidePreview, + setHoveredGuide, + setSelectedGuide, setRotationPreview, setHoveredNode, setMeasurementMode, diff --git a/packages/core/src/editor/state/view.ts b/packages/core/src/editor/state/view.ts index 34c043f7d..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,6 +8,7 @@ export function createDefaultEditorViewState(pageId: string): EditorViewState { selectedIds: new Set(), marquee: null, snapGuides: [], + guides: createGuideOverlayState(), rotationPreview: null, dropTargetId: null, layoutInsertIndicator: null, @@ -35,6 +37,7 @@ export function copyEditorViewState(source: EditorViewState): EditorViewState { selectedIds: new Set(source.selectedIds), marquee: structuredClone(source.marquee), snapGuides: structuredClone(source.snapGuides), + 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 8a0266c4d..962b4097f 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -7,10 +7,12 @@ 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' +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 { SnappingPreferences } from '#core/editor/preferences' @@ -51,6 +53,7 @@ export interface EditorViewState { selectedIds: Set marquee: Rect | null snapGuides: SnapGuide[] + guides: GuideOverlayState rotationPreview: { nodeId: string; angle: number } | null dropTargetId: string | null layoutInsertIndicator: { @@ -114,6 +117,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..52fe4fd58 100644 --- a/packages/fig/src/node-change/canvas-guides.ts +++ b/packages/fig/src/node-change/canvas-guides.ts @@ -1,19 +1,45 @@ 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?: 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 { + 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 }) + const figGuid = isGuid(guide.guid) ? guide.guid : undefined + if (guide.axis === 'X') { + guides.push({ + id: guideId(figGuid, index), + axis: 'x', + position: guide.offset, + ...(figGuid ? { figGuid } : {}) + }) + } else if (guide.axis === 'Y') { + guides.push({ + id: guideId(figGuid, index), + axis: 'y', + position: guide.offset, + ...(figGuid ? { figGuid } : {}) + }) + } } return guides } @@ -21,6 +47,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 f1b1fa3c6..61acad84b 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, @@ -839,6 +840,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/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 new file mode 100644 index 000000000..ae8342995 --- /dev/null +++ b/packages/fig/tests/canvas-guides.test.ts @@ -0,0 +1,31 @@ +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('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([ + { 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/guides/input.ts b/packages/vue/src/canvas/guides/input.ts new file mode 100644 index 000000000..1214cf6b6 --- /dev/null +++ b/packages/vue/src/canvas/guides/input.ts @@ -0,0 +1,182 @@ +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 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) + 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 }) + 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) + editor.setSelectedGuide(null) + } + } 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/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 d94085424..cb9be0c15 100644 --- a/packages/vue/src/canvas/useCanvasInput.ts +++ b/packages/vue/src/canvas/useCanvasInput.ts @@ -4,16 +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/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' @@ -119,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, @@ -213,6 +224,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 && guideInput.tryStartExisting(sx, sy)) { + e.preventDefault() + return + } + if (e.button === 0 && guideInput.tryStartFromRuler(sx, sy, cx, cy)) { + e.preventDefault() + return + } + editor.setSelectedGuide(null) const selectedIdsBeforeMouseDown = new Set(editor.state.selectedIds) const clickCount = recordClick(sx, sy) @@ -232,6 +252,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 @@ -252,14 +274,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 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 ) @@ -275,6 +294,11 @@ export function useCanvasInput( const { sx, sy, cx, cy } = getCoords(e) + if (d.type === 'guide') { + guideInput.handleMove(d, sx, sy, cx, cy) + return + } + if (d.type === 'rotate') { handleRotateMove(d, cx, cy, e.shiftKey) return @@ -322,7 +346,9 @@ export function useCanvasInput( if (handleNodeEditMouseUp(drag, editor)) return - if (d.type === 'move') handleMoveUp(d, editor) + if (d.type === 'guide') { + guideInput.finish(d) + } else if (d.type === 'move') handleMoveUp(d, editor) else if (d.type === 'text-select') { drag.value = null return @@ -357,6 +383,7 @@ export function useCanvasInput( editor.setSnapGuides([]) editor.setLayoutInsertIndicator(null) editor.setDropTarget(null) + guideInput.clearHoverAndPreview() } function cancelPointerInteraction() { @@ -369,7 +396,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 (!guideInput.deleteSelected(event)) updateModifier(event.code, true) + }) useEventListener(window, 'keyup', (event) => updateModifier(event.code, false)) useEventListener(window, 'blur', () => { resetMeasurementModifiers() @@ -381,6 +410,7 @@ export function useCanvasInput( editor.setMeasurementMode('off') if (!drag.value) { editor.setHoveredNode(null) + editor.setHoveredGuide(null) } }) useEventListener( diff --git a/packages/vue/src/shared/input/types.ts b/packages/vue/src/shared/input/types.ts index c1f4f01d0..4065cb803 100644 --- a/packages/vue/src/shared/input/types.ts +++ b/packages/vue/src/shared/input/types.ts @@ -128,6 +128,21 @@ export interface DragBendHandle { targetTangentField: 'tangentStart' | 'tangentEnd' | null } +export interface DragGuide { + type: 'guide' + 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 = | DragDraw | DragMove @@ -140,6 +155,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..aa02c2142 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: [], + guides: { preview: null, hovered: null, selected: 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) + }) +}) 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/guides/draw.test.ts b/tests/engine/render/canvas/guides/draw.test.ts new file mode 100644 index 000000000..0e99d608c --- /dev/null +++ b/tests/engine/render/canvas/guides/draw.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, mock, test } from 'bun:test' + +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/draw' + +import { createMockCanvas, createMockRenderer, mockCalls } from '../effects/helpers' + +function graphWithGuides(guides: SceneNode['guides']): SceneGraph { + const page = { + id: 'page', + type: 'CANVAS', + childIds: [], + guides + } as SceneNode + return { + rootId: 'root', + getNode: (id: string) => (id === 'page' ? page : null) + } as SceneGraph +} + +describe('page guide rendering', () => { + test('renders imported Figma page guides in screen space', () => { + const r = createMockRenderer({ + pageId: 'page', + panX: 10, + panY: 20, + zoom: 2, + viewportWidth: 300, + viewportHeight: 200 + }) + const canvas = createMockCanvas() + const graph = graphWithGuides([ + { id: 'x', axis: 'x', position: 42 }, + { id: 'y', axis: 'y', position: 84 } + ]) + + drawGuides(r, canvas as Canvas, graph) + + expect(mockCalls(canvas.drawRect)).toHaveLength(2) + expect(mockCalls(r.ck.LTRBRect)).toEqual([ + [94, 0, 95, 200], + [0, 188, 300, 189] + ]) + }) + + 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 + + drawGuides(r, canvas as Canvas, graph) + + expect(canvas.drawLine).toHaveBeenCalled() + }) + + test('ignores pages without guides', () => { + const r = createMockRenderer({ pageId: 'page' }) + const canvas = createMockCanvas() + const graph = graphWithGuides([]) + + drawGuides(r, canvas as Canvas, graph) + + expect(canvas.drawRect).not.toHaveBeenCalled() + }) +}) 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..c9d0bd283 --- /dev/null +++ b/tests/engine/render/canvas/guides/geometry.test.ts @@ -0,0 +1,53 @@ +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 } from '#core/canvas/guides/geometry' +import { hitTestGuides } from '#core/canvas/guides/hit-test' + +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 deleted file mode 100644 index a04e5fbd8..000000000 --- a/tests/engine/render/canvas/page-guides.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, test } from 'bun:test' - -import type { Canvas } from 'canvaskit-wasm' - -import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' - -import { drawPageGuides } from '#core/canvas/page-guides' - -import { createMockCanvas, createMockRenderer, mockCalls } from './effects/helpers' - -function graphWithGuides(guides: SceneNode['guides']): SceneGraph { - const page = { - id: 'page', - guides - } as SceneNode - return { - rootId: 'root', - getNode: (id: string) => (id === 'page' ? page : null) - } as SceneGraph -} - -describe('page guide rendering', () => { - test('renders imported Figma page guides in screen space', () => { - const r = createMockRenderer({ - pageId: 'page', - panX: 10, - panY: 20, - zoom: 2, - viewportWidth: 300, - viewportHeight: 200 - }) - const canvas = createMockCanvas() - const graph = graphWithGuides([ - { axis: 'x', position: 42 }, - { axis: 'y', position: 84 } - ]) - - drawPageGuides(r, canvas as Canvas, graph) - - expect(mockCalls(canvas.drawRect)).toHaveLength(2) - expect(mockCalls(r.ck.LTRBRect)).toEqual([ - [94, 0, 95, 200], - [0, 188, 300, 189] - ]) - }) - - test('ignores pages without guides', () => { - const r = createMockRenderer({ pageId: 'page' }) - const canvas = createMockCanvas() - const graph = graphWithGuides([]) - - drawPageGuides(r, canvas as Canvas, graph) - - expect(canvas.drawRect).not.toHaveBeenCalled() - }) -}) 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() + }) +})