fix(canvas): render live guide drag feedback

This commit is contained in:
Danila Poyarkov 2026-08-20 08:23:04 +03:00
parent 581d774d0e
commit 197364923b
9 changed files with 79 additions and 11 deletions

View file

@ -7,9 +7,22 @@ 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 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]
function guideColor(state: 'idle' | 'hovered' | 'selected') {
if (state === 'selected') return SELECTED_GUIDE_COLOR
if (state === 'hovered') return HOVERED_GUIDE_COLOR
return GUIDE_COLOR
}
function guideState(selected: boolean, hovered: boolean): 'idle' | 'hovered' | 'selected' {
if (selected) return 'selected'
if (hovered) return 'hovered'
return 'idle'
}
function drawOwnedGuide(
r: SkiaRenderer,
canvas: Canvas,
@ -18,9 +31,9 @@ function drawOwnedGuide(
axis: 'x' | 'y',
position: number,
preview: boolean,
selected = false
state: 'idle' | 'hovered' | 'selected' = 'idle'
): void {
const color = selected ? SELECTED_GUIDE_COLOR : GUIDE_COLOR
const color = guideColor(state)
r.auxStroke.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a))
const matrix = getWorldMatrix(owner, graph)
const start = Matrix.mapPoint(
@ -51,6 +64,7 @@ export function drawPageGuides(
canvas: Canvas,
graph: SceneGraph,
preview?: RenderOverlays['guidePreview'],
hoveredGuide?: RenderOverlays['hoveredGuide'],
selectedGuide?: RenderOverlays['selectedGuide']
): void {
const page = graph.getNode(r.pageId ?? graph.rootId)
@ -60,8 +74,10 @@ 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) {
if (preview?.source?.ownerId === page.id && preview.source.guideId === guide.id) continue
const selected = selectedGuide?.ownerId === page.id && selectedGuide.guideId === guide.id
const color = selected ? SELECTED_GUIDE_COLOR : GUIDE_COLOR
const hovered = hoveredGuide?.ownerId === page.id && hoveredGuide.guideId === guide.id
const color = guideColor(guideState(selected, hovered))
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
@ -74,6 +90,9 @@ export function drawPageGuides(
const visit = (node: SceneNode) => {
for (const guide of node.guides) {
if (preview?.source?.ownerId === node.id && preview.source.guideId === guide.id) continue
const selected = selectedGuide?.ownerId === node.id && selectedGuide.guideId === guide.id
const hovered = hoveredGuide?.ownerId === node.id && hoveredGuide.guideId === guide.id
drawOwnedGuide(
r,
canvas,
@ -82,7 +101,7 @@ export function drawPageGuides(
guide.axis,
guide.position,
false,
selectedGuide?.ownerId === node.id && selectedGuide.guideId === guide.id
guideState(selected, hovered)
)
}
for (const childId of node.childIds) {
@ -97,6 +116,7 @@ export function drawPageGuides(
if (preview) {
const owner = graph.getNode(preview.ownerId)
if (owner) drawOwnedGuide(r, canvas, owner, graph, preview.axis, preview.position, true)
if (owner)
drawOwnedGuide(r, canvas, owner, graph, preview.axis, preview.position, true, 'selected')
}
}

View file

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

View file

@ -23,6 +23,7 @@ export interface RenderOverlays {
marquee?: Rect | null
snapGuides?: SnapGuide[]
guidePreview?: GuidePreview | null
hoveredGuide?: GuideSelection | null
selectedGuide?: GuideSelection | null
rotationPreview?: { nodeId: string; angle: number } | null
dropTargetId?: string | null

View file

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

View file

@ -7,4 +7,5 @@ export interface GuidePreview {
ownerId: string
axis: 'x' | 'y'
position: number
source?: GuideSelection
}

View file

@ -20,6 +20,13 @@ export function createSelectionOverlayActions(ctx: EditorContext) {
ctx.requestRepaint()
}
function setHoveredGuide(selection: typeof ctx.state.hoveredGuide) {
const current = ctx.state.hoveredGuide
if (current?.ownerId === selection?.ownerId && current?.guideId === selection?.guideId) return
ctx.state.hoveredGuide = selection
ctx.requestRepaint()
}
function setSelectedGuide(selection: typeof ctx.state.selectedGuide) {
ctx.state.selectedGuide = selection
if (selection) ctx.setSelectedIds(new Set())
@ -73,6 +80,7 @@ export function createSelectionOverlayActions(ctx: EditorContext) {
setMarquee,
setSnapGuides,
setGuidePreview,
setHoveredGuide,
setSelectedGuide,
setRotationPreview,
setHoveredNode,

View file

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

View file

@ -54,6 +54,7 @@ export interface EditorViewState {
marquee: Rect | null
snapGuides: SnapGuide[]
guidePreview: GuidePreview | null
hoveredGuide: GuideSelection | null
selectedGuide: GuideSelection | null
rotationPreview: { nodeId: string; angle: number } | null
dropTargetId: string | null

View file

@ -293,10 +293,28 @@ export function useCanvasInput(
return axis === 'x' ? 'ew-resize' : 'ns-resize'
}
function rulerGuideAxis(sx: number, sy: number): 'x' | 'y' | null {
if (sy < RULER_SIZE) return 'y'
if (sx < RULER_SIZE) return 'x'
return null
}
function updateGuideHoverCursor(sx: number, sy: number, cx: number, cy: number) {
const guideHit = guideHitTest(sx, sy)
editor.setHoveredGuide(
guideHit ? { ownerId: guideHit.ownerId, guideId: guideHit.guideId } : null
)
if (guideHit) return guideCursor(guideHit.axis)
const rulerAxis = rulerGuideAxis(sx, sy)
if (rulerAxis) return guideCursor(rulerAxis)
return updateHoverCursor(cx, cy, editor, hitFns, editor.state.measurementMode === 'deep')
}
function startExistingGuideDrag(sx: number, sy: number): boolean {
const hit = guideHitTest(sx, sy)
if (!hit) return false
editor.setSelectedGuide({ ownerId: hit.ownerId, guideId: hit.guideId })
editor.setHoveredGuide(null)
cursorOverride.value = guideCursor(hit.axis)
setDrag({
type: 'guide',
@ -407,10 +425,7 @@ export function useCanvasInput(
if (!drag.value && editor.state.activeTool === 'SELECT') {
const { sx, sy, cx, cy } = coords
const guideHit = guideHitTest(sx, sy)
cursorOverride.value = guideHit
? guideCursor(guideHit.axis)
: updateHoverCursor(cx, cy, editor, hitFns, editor.state.measurementMode === 'deep')
cursorOverride.value = updateGuideHoverCursor(sx, sy, cx, cy)
editor.setAutoLayoutHover(
editor.state.measurementMode === 'off' ? resolveAutoLayoutHover(cx, cy, editor) : null
)
@ -437,7 +452,15 @@ export function useCanvasInput(
const local = owner && owner.type !== 'CANVAS' ? canvasToLocal(cx, cy, owner.id) : null
d.ownerId = target.id
d.position = d.axis === 'x' ? (local?.lx ?? cx) : (local?.ly ?? cy)
editor.setGuidePreview({ ownerId: d.ownerId, axis: d.axis, position: d.position })
editor.setGuidePreview({
ownerId: d.ownerId,
axis: d.axis,
position: d.position,
source:
d.guideId && d.originalOwnerId
? { ownerId: d.originalOwnerId, guideId: d.guideId }
: undefined
})
return
}
@ -495,6 +518,7 @@ export function useCanvasInput(
}
}
editor.setGuidePreview(null)
editor.setHoveredGuide(null)
}
function onMouseUp() {
@ -542,6 +566,7 @@ export function useCanvasInput(
editor.setLayoutInsertIndicator(null)
editor.setDropTarget(null)
editor.setGuidePreview(null)
editor.setHoveredGuide(null)
}
function cancelPointerInteraction() {
@ -568,6 +593,7 @@ export function useCanvasInput(
editor.setMeasurementMode('off')
if (!drag.value) {
editor.setHoveredNode(null)
editor.setHoveredGuide(null)
}
})
useEventListener(