feat(canvas): support guide selection and editing
This commit is contained in:
parent
103e4ac151
commit
23b7d92ecf
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
export interface GuideSelection {
|
||||
ownerId: string
|
||||
guideId: string
|
||||
}
|
||||
|
||||
export interface GuidePreview {
|
||||
ownerId: string
|
||||
axis: 'x' | 'y'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Reference in a new issue