feat(canvas): support guide selection and editing

This commit is contained in:
Danila Poyarkov 2026-08-20 07:50:31 +03:00
parent 103e4ac151
commit 23b7d92ecf
10 changed files with 190 additions and 11 deletions

View file

@ -7,6 +7,7 @@ import Matrix from '@open-pencil/scene-graph/matrix'
import type { RenderOverlays, SkiaRenderer } from './renderer' import type { RenderOverlays, SkiaRenderer } from './renderer'
const GUIDE_COLOR = { r: 0.85, g: 0.29, b: 0.2, a: 0.78 } 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] const GUIDE_DASH = [3, 4]
function drawOwnedGuide( function drawOwnedGuide(
@ -16,8 +17,11 @@ function drawOwnedGuide(
graph: SceneGraph, graph: SceneGraph,
axis: 'x' | 'y', axis: 'x' | 'y',
position: number, position: number,
preview: boolean preview: boolean,
selected = false
): void { ): 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 matrix = getWorldMatrix(owner, graph)
const start = Matrix.mapPoint( const start = Matrix.mapPoint(
matrix, matrix,
@ -46,7 +50,8 @@ export function drawPageGuides(
r: SkiaRenderer, r: SkiaRenderer,
canvas: Canvas, canvas: Canvas,
graph: SceneGraph, graph: SceneGraph,
preview?: RenderOverlays['guidePreview'] preview?: RenderOverlays['guidePreview'],
selectedGuide?: RenderOverlays['selectedGuide']
): void { ): void {
const page = graph.getNode(r.pageId ?? graph.rootId) const page = graph.getNode(r.pageId ?? graph.rootId)
if (!page) return 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)) r.auxStroke.setColor(r.ck.Color4f(GUIDE_COLOR.r, GUIDE_COLOR.g, GUIDE_COLOR.b, GUIDE_COLOR.a))
for (const guide of page.guides) { 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') { if (guide.axis === 'x') {
const x = guide.position * r.zoom + r.panX const x = guide.position * r.zoom + r.panX
canvas.drawRect(r.ck.LTRBRect(x, 0, x + 1, r.viewportHeight), r.auxStroke) canvas.drawRect(r.ck.LTRBRect(x, 0, x + 1, r.viewportHeight), r.auxStroke)
@ -66,7 +74,16 @@ export function drawPageGuides(
const visit = (node: SceneNode) => { const visit = (node: SceneNode) => {
for (const guide of node.guides) { 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) { for (const childId of node.childIds) {
const child = graph.getNode(childId) const child = graph.getNode(childId)

View file

@ -62,6 +62,7 @@ export function renderFromEditorState(
marquee: state.marquee, marquee: state.marquee,
snapGuides: state.snapGuides, snapGuides: state.snapGuides,
guidePreview: state.guidePreview, guidePreview: state.guidePreview,
selectedGuide: state.selectedGuide,
rotationPreview: state.rotationPreview, rotationPreview: state.rotationPreview,
dropTargetId: state.dropTargetId, dropTargetId: state.dropTargetId,
layoutInsertIndicator: state.layoutInsertIndicator, layoutInsertIndicator: state.layoutInsertIndicator,
@ -255,7 +256,7 @@ export function render(
drawInteractiveOverlays(r, canvas, graph, selectedIds, overlays) drawInteractiveOverlays(r, canvas, graph, selectedIds, overlays)
r.drawFlashes(canvas, graph) r.drawFlashes(canvas, graph)
drawPageGuides(r, canvas, graph, overlays.guidePreview) drawPageGuides(r, canvas, graph, overlays.guidePreview, overlays.selectedGuide)
r.drawSnapGuides(canvas, overlays.snapGuides) r.drawSnapGuides(canvas, overlays.snapGuides)
r.drawMarquee(canvas, overlays.marquee) r.drawMarquee(canvas, overlays.marquee)
r.drawLayoutInsertIndicator(canvas, overlays.layoutInsertIndicator) r.drawLayoutInsertIndicator(canvas, overlays.layoutInsertIndicator)

View file

@ -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 { Color, Rect, Vector } from '@open-pencil/scene-graph/primitives'
import type { SnapGuide } from '@open-pencil/scene-graph/snap' 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' import type { TextEditor } from '#core/text/editor'
export interface RulerTheme { export interface RulerTheme {
@ -23,6 +23,7 @@ export interface RenderOverlays {
marquee?: Rect | null marquee?: Rect | null
snapGuides?: SnapGuide[] snapGuides?: SnapGuide[]
guidePreview?: GuidePreview | null guidePreview?: GuidePreview | null
selectedGuide?: GuideSelection | null
rotationPreview?: { nodeId: string; angle: number } | null rotationPreview?: { nodeId: string; angle: number } | null
dropTargetId?: string | null dropTargetId?: string | null
layoutInsertIndicator?: { layoutInsertIndicator?: {

View file

@ -210,6 +210,7 @@ export function createEditor(options?: EditorOptions) {
state.measurementMode = 'off' state.measurementMode = 'off'
state.snapGuides = [] state.snapGuides = []
state.guidePreview = null state.guidePreview = null
state.selectedGuide = null
state.layoutInsertIndicator = null state.layoutInsertIndicator = null
state.dropTargetId = null state.dropTargetId = null
pages.clearPageViewports() pages.clearPageViewports()

View file

@ -1,3 +1,8 @@
export interface GuideSelection {
ownerId: string
guideId: string
}
export interface GuidePreview { export interface GuidePreview {
ownerId: string ownerId: string
axis: 'x' | 'y' axis: 'x' | 'y'

View file

@ -20,6 +20,12 @@ export function createSelectionOverlayActions(ctx: EditorContext) {
ctx.requestRepaint() 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) { function setRotationPreview(preview: { nodeId: string; angle: number } | null) {
ctx.state.rotationPreview = preview ctx.state.rotationPreview = preview
ctx.requestRepaint() ctx.requestRepaint()
@ -67,6 +73,7 @@ export function createSelectionOverlayActions(ctx: EditorContext) {
setMarquee, setMarquee,
setSnapGuides, setSnapGuides,
setGuidePreview, setGuidePreview,
setSelectedGuide,
setRotationPreview, setRotationPreview,
setHoveredNode, setHoveredNode,
setMeasurementMode, setMeasurementMode,

View file

@ -8,6 +8,7 @@ export function createDefaultEditorViewState(pageId: string): EditorViewState {
marquee: null, marquee: null,
snapGuides: [], snapGuides: [],
guidePreview: null, guidePreview: null,
selectedGuide: null,
rotationPreview: null, rotationPreview: null,
dropTargetId: null, dropTargetId: null,
layoutInsertIndicator: null, layoutInsertIndicator: null,
@ -37,6 +38,7 @@ export function copyEditorViewState(source: EditorViewState): EditorViewState {
marquee: structuredClone(source.marquee), marquee: structuredClone(source.marquee),
snapGuides: structuredClone(source.snapGuides), snapGuides: structuredClone(source.snapGuides),
guidePreview: structuredClone(source.guidePreview), guidePreview: structuredClone(source.guidePreview),
selectedGuide: structuredClone(source.selectedGuide),
rotationPreview: structuredClone(source.rotationPreview), rotationPreview: structuredClone(source.rotationPreview),
layoutInsertIndicator: structuredClone(source.layoutInsertIndicator), layoutInsertIndicator: structuredClone(source.layoutInsertIndicator),
penState: structuredClone(source.penState), penState: structuredClone(source.penState),

View file

@ -14,7 +14,7 @@ import type { UndoManager } from '@open-pencil/scene-graph/undo'
import type { RulerTheme, SkiaRenderer } from '#core/canvas/renderer' import type { RulerTheme, SkiaRenderer } from '#core/canvas/renderer'
import type { MeasurementMode, RenderOverlays } from '#core/canvas/renderer/types' 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 { SnappingPreferences } from '#core/editor/preferences'
import type { TextEditor } from '#core/text/editor' import type { TextEditor } from '#core/text/editor'
import type { FontResolutionEvent, FontResolutionSnapshot } from '#core/text/resolver' import type { FontResolutionEvent, FontResolutionSnapshot } from '#core/text/resolver'
@ -54,6 +54,7 @@ export interface EditorViewState {
marquee: Rect | null marquee: Rect | null
snapGuides: SnapGuide[] snapGuides: SnapGuide[]
guidePreview: GuidePreview | null guidePreview: GuidePreview | null
selectedGuide: GuideSelection | null
rotationPreview: { nodeId: string; angle: number } | null rotationPreview: { nodeId: string; angle: number } | null
dropTargetId: string | null dropTargetId: string | null
layoutInsertIndicator: { layoutInsertIndicator: {

View file

@ -4,6 +4,8 @@ import { onScopeDispose, ref, type Ref } from 'vue'
import { RULER_SIZE } from '@open-pencil/core/constants' import { RULER_SIZE } from '@open-pencil/core/constants'
import type { Editor } from '@open-pencil/core/editor' import type { Editor } from '@open-pencil/core/editor'
import type { SceneNode } from '@open-pencil/scene-graph' 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 { import {
handleBendHandleMove, 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) { function updateModifier(code: string, held: boolean) {
if (!isEnabled()) return if (!isEnabled()) return
if (code === 'AltLeft' || code === 'AltRight') altHeld = held if (code === 'AltLeft' || code === 'AltRight') altHeld = held
@ -209,6 +221,95 @@ export function useCanvasInput(
return { id: editor.state.currentPageId, position: 0 } 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 { function startGuideDrag(sx: number, sy: number, cx: number, cy: number): boolean {
if (!('showRulers' in editor.state) || editor.state.showRulers !== true) return false if (!('showRulers' in editor.state) || editor.state.showRulers !== true) return false
if (sx < RULER_SIZE && sy < RULER_SIZE) 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 owner = editor.graph.getNode(target.id)
const local = owner && owner.type !== 'CANVAS' ? canvasToLocal(cx, cy, owner.id) : null const local = owner && owner.type !== 'CANVAS' ? canvasToLocal(cx, cy, owner.id) : null
const position = axis === 'x' ? (local?.lx ?? cx) : (local?.ly ?? cy) const position = axis === 'x' ? (local?.lx ?? cx) : (local?.ly ?? cy)
editor.setGuidePreview({ ownerId: target.id, axis, position }) setDrag({
setDrag({ type: 'guide', axis, ownerId: target.id, position }) type: 'guide',
axis,
ownerId: target.id,
position,
startScreenX: sx,
startScreenY: sy,
currentScreenX: sx,
currentScreenY: sy,
dragStarted: false
})
return true return true
} }
@ -241,10 +351,15 @@ export function useCanvasInput(
if (!editor.state.editingTextId) canvasRef.value?.focus() if (!editor.state.editingTextId) canvasRef.value?.focus()
editor.setHoveredNode(null) editor.setHoveredNode(null)
const { sx, sy, cx, cy } = getCoords(e) 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)) { if (e.button === 0 && startGuideDrag(sx, sy, cx, cy)) {
e.preventDefault() e.preventDefault()
return return
} }
editor.setSelectedGuide(null)
const selectedIdsBeforeMouseDown = new Set(editor.state.selectedIds) const selectedIdsBeforeMouseDown = new Set(editor.state.selectedIds)
const clickCount = recordClick(sx, sy) const clickCount = recordClick(sx, sy)
@ -310,6 +425,10 @@ export function useCanvasInput(
const { sx, sy, cx, cy } = getCoords(e) const { sx, sy, cx, cy } = getCoords(e)
if (d.type === 'guide') { 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 target = guideOwner(cx, cy)
const owner = editor.graph.getNode(target.id) const owner = editor.graph.getNode(target.id)
const local = owner && owner.type !== 'CANVAS' ? canvasToLocal(cx, cy, owner.id) : null const local = owner && owner.type !== 'CANVAS' ? canvasToLocal(cx, cy, owner.id) : null
@ -359,6 +478,22 @@ export function useCanvasInput(
handleMarqueeMove(d, cx, cy) handleMarqueeMove(d, cx, cy)
} }
function finishGuideDrag(d: Extract<DragState, { type: 'guide' }>) {
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() { function onMouseUp() {
if (!isEnabled()) return if (!isEnabled()) return
if (!drag.value) return if (!drag.value) return
@ -367,8 +502,7 @@ export function useCanvasInput(
if (handleNodeEditMouseUp(drag, editor)) return if (handleNodeEditMouseUp(drag, editor)) return
if (d.type === 'guide') { if (d.type === 'guide') {
editor.addGuide(d.ownerId, d.axis, d.position) finishGuideDrag(d)
editor.setGuidePreview(null)
} else if (d.type === 'move') handleMoveUp(d, editor) } else if (d.type === 'move') handleMoveUp(d, editor)
else if (d.type === 'text-select') { else if (d.type === 'text-select') {
drag.value = null drag.value = null
@ -417,7 +551,9 @@ export function useCanvasInput(
useEventListener(canvasRef, 'mousedown', onMouseDown) useEventListener(canvasRef, 'mousedown', onMouseDown)
useEventListener(canvasRef, 'mousemove', onMouseMove) useEventListener(canvasRef, 'mousemove', onMouseMove)
useEventListener(canvasRef, 'mouseup', onMouseUp) 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, 'keyup', (event) => updateModifier(event.code, false))
useEventListener(window, 'blur', () => { useEventListener(window, 'blur', () => {
resetMeasurementModifiers() resetMeasurementModifiers()

View file

@ -133,6 +133,14 @@ export interface DragGuide {
axis: 'x' | 'y' axis: 'x' | 'y'
ownerId: string ownerId: string
position: number position: number
startScreenX: number
startScreenY: number
currentScreenX: number
currentScreenY: number
dragStarted: boolean
guideId?: string
originalOwnerId?: string
originalPosition?: number
} }
export type DragState = export type DragState =