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'
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)

View file

@ -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)

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 { 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?: {

View file

@ -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()

View file

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

View file

@ -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,

View file

@ -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),

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 { 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: {

View file

@ -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<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() {
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()

View file

@ -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 =