From 23b7d92ecf5f83004b44b6466f1aaca7aeb83020 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 20 Aug 2026 07:50:31 +0300 Subject: [PATCH] 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 =