From c2103ef722ab2138d440b2e4b930095555771de2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 14 Aug 2026 18:18:55 +0300 Subject: [PATCH] feat(canvas): measure distances between layers (#515) * feat(canvas): measure distances between layers - Show temporary Figma-style guides while Option or Alt is held - Support deep nested targets with Command or Control plus Alt - Measure transformed world bounds and container padding * fix(canvas): anchor diagonal measurement guides - Join diagonal distances at the nearest object corners - Separate horizontal and vertical value badges - Crop visual coverage tightly around the measurement overlay * fix(canvas): cover measurement edge cases - Draw transformed target outlines and center value pills on guides - Suppress conflicting hover overlays during measurement mode - Cover containment, overlap, multi-selection, rotation, and fractional gaps * fix(canvas): suppress conflicting measurement overlays - Hide auto-layout hover visuals while distance measurements render - Assert fractional measurement geometry with numeric tolerance --- CHANGELOG.md | 1 + packages/core/src/canvas/overlays/index.ts | 1 + .../core/src/canvas/overlays/measurement.ts | 245 ++++++++++++++++++ packages/core/src/canvas/renderer.ts | 8 +- packages/core/src/canvas/renderer/methods.ts | 9 + packages/core/src/canvas/renderer/pipeline.ts | 45 +++- packages/core/src/canvas/renderer/types.ts | 3 + packages/core/src/constants.ts | 5 + packages/core/src/editor/create.ts | 3 + .../core/src/editor/selection/overlays.ts | 7 + packages/core/src/editor/state.ts | 1 + packages/core/src/editor/types.ts | 3 +- packages/scene-graph/src/coordinate.ts | 21 ++ packages/vue/src/canvas/useCanvasInput.ts | 90 ++++++- packages/vue/src/shared/input/select/hover.ts | 17 +- .../distance-measurement-edge-cases.spec.ts | 129 +++++++++ ...distance-containment-openpencil-darwin.png | Bin 0 -> 8200 bytes ...tance-multi-rotation-openpencil-darwin.png | Bin 0 -> 15024 bytes .../distance-overlap-openpencil-darwin.png | Bin 0 -> 3611 bytes tests/e2e/canvas/distance-measurement.spec.ts | 102 ++++++++ ...distance-measurement-openpencil-darwin.png | Bin 0 -> 7215 bytes .../engine/render/canvas/measurement.test.ts | 59 +++++ tests/engine/scene-graph/world-bounds.test.ts | 50 ++++ 23 files changed, 772 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/canvas/overlays/measurement.ts create mode 100644 tests/e2e/canvas/distance-measurement-edge-cases.spec.ts create mode 100644 tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-containment-openpencil-darwin.png create mode 100644 tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-multi-rotation-openpencil-darwin.png create mode 100644 tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-overlap-openpencil-darwin.png create mode 100644 tests/e2e/canvas/distance-measurement.spec.ts create mode 100644 tests/e2e/canvas/distance-measurement.spec.ts-snapshots/distance-measurement-openpencil-darwin.png create mode 100644 tests/engine/render/canvas/measurement.test.ts create mode 100644 tests/engine/scene-graph/world-bounds.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 736f11053..1dc9237ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Show Figma-style temporary distance measurements between selected and Option/Alt-hovered layers. (#491) - Add a reproducible Dev Container for web, package, CLI, and non-browser test development. - Add local crash recovery for unsaved and pathless documents, including MCP-created documents. (#487) - Add isolated visual inspection that sends bounded selection renders to the configured Vision model and returns text findings without retaining image data in Design chat history. (#232, #471) diff --git a/packages/core/src/canvas/overlays/index.ts b/packages/core/src/canvas/overlays/index.ts index 857dbedee..886c50847 100644 --- a/packages/core/src/canvas/overlays/index.ts +++ b/packages/core/src/canvas/overlays/index.ts @@ -11,6 +11,7 @@ export { } from './selection' export { drawAutoLayoutHover } from './auto-layout-hover' export { drawFlashes, drawLayoutInsertIndicator, drawMarquee, drawSnapGuides } from './feedback' +export { drawMeasurements } from './measurement' export { drawTextEditOverlay } from './text-edit' export { drawSelectionLabels } from '#core/canvas/labels/selection' export { drawPenOverlay, drawRemoteCursors } from '#core/canvas/pen-overlay' diff --git a/packages/core/src/canvas/overlays/measurement.ts b/packages/core/src/canvas/overlays/measurement.ts new file mode 100644 index 000000000..7074130c3 --- /dev/null +++ b/packages/core/src/canvas/overlays/measurement.ts @@ -0,0 +1,245 @@ +import type { Canvas } from 'canvaskit-wasm' + +import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' +import { getAxisAlignedWorldBounds, getWorldMatrix } from '@open-pencil/scene-graph/coordinate' +import { computeBounds } from '@open-pencil/scene-graph/geometry' +import Matrix from '@open-pencil/scene-graph/matrix' +import type { Rect } from '@open-pencil/scene-graph/primitives' + +import type { SkiaRenderer } from '#core/canvas/renderer' +import { + MEASUREMENT_COLOR, + MEASUREMENT_PILL_HEIGHT, + MEASUREMENT_PILL_PADDING_X, + MEASUREMENT_PILL_RADIUS, + MEASUREMENT_TEXT_BASELINE +} from '#core/constants' + +export interface MeasurementSegment { + axis: 'x' | 'y' + from: number + to: number + cross: number + value: number +} + +function center(start: number, size: number) { + return start + size / 2 +} + +function overlapCenter(startA: number, endA: number, startB: number, endB: number) { + return (Math.max(startA, startB) + Math.min(endA, endB)) / 2 +} + +function horizontalCross(from: Rect, to: Rect) { + const fromBottom = from.y + from.height + const toBottom = to.y + to.height + if (fromBottom < to.y) return fromBottom + if (toBottom < from.y) return from.y + return overlapCenter(from.y, fromBottom, to.y, toBottom) +} + +function verticalCross(from: Rect, to: Rect) { + const fromRight = from.x + from.width + const toRight = to.x + to.width + if (fromRight < to.x) return to.x + if (toRight < from.x) return toRight + return overlapCenter(from.x, fromRight, to.x, toRight) +} + +export function computeMeasurementSegments(from: Rect, to: Rect): MeasurementSegment[] { + const segments: MeasurementSegment[] = [] + const fromRight = from.x + from.width + const toRight = to.x + to.width + const fromBottom = from.y + from.height + const toBottom = to.y + to.height + + const fromInsideTo = + from.x >= to.x && fromRight <= toRight && from.y >= to.y && fromBottom <= toBottom + const toInsideFrom = + to.x >= from.x && toRight <= fromRight && to.y >= from.y && toBottom <= fromBottom + + if (fromInsideTo) { + segments.push( + { + axis: 'x', + from: to.x, + to: from.x, + cross: center(from.y, from.height), + value: from.x - to.x + }, + { + axis: 'x', + from: fromRight, + to: toRight, + cross: center(from.y, from.height), + value: toRight - fromRight + }, + { + axis: 'y', + from: to.y, + to: from.y, + cross: center(from.x, from.width), + value: from.y - to.y + }, + { + axis: 'y', + from: fromBottom, + to: toBottom, + cross: center(from.x, from.width), + value: toBottom - fromBottom + } + ) + return segments.filter((segment) => segment.value > 0) + } + + if (toInsideFrom) { + return computeMeasurementSegments(to, from) + } + + if (fromRight <= to.x) { + segments.push({ + axis: 'x', + from: fromRight, + to: to.x, + cross: horizontalCross(from, to), + value: to.x - fromRight + }) + } else if (toRight <= from.x) { + segments.push({ + axis: 'x', + from: toRight, + to: from.x, + cross: horizontalCross(from, to), + value: from.x - toRight + }) + } + + if (fromBottom <= to.y) { + segments.push({ + axis: 'y', + from: fromBottom, + to: to.y, + cross: verticalCross(from, to), + value: to.y - fromBottom + }) + } else if (toBottom <= from.y) { + segments.push({ + axis: 'y', + from: toBottom, + to: from.y, + cross: verticalCross(from, to), + value: from.y - toBottom + }) + } + + return segments.filter((segment) => segment.value > 0 && Number.isFinite(segment.cross)) +} + +function selectedBounds(graph: SceneGraph, selectedIds: Set): Rect | null { + const nodes: SceneNode[] = [] + for (const id of selectedIds) { + const node = graph.getNode(id) + if (node) nodes.push(node) + } + if (nodes.length === 0) return null + return computeBounds(nodes.map((node) => getAxisAlignedWorldBounds(node, graph))) +} + +function textWidth(r: SkiaRenderer, text: string): number { + const font = r.sizeFont + if (!font) return 0 + const widths = font.getGlyphWidths(font.getGlyphIDs(text)) + let width = 0 + for (const glyphWidth of widths) width += glyphWidth + return width +} + +function drawPill(r: SkiaRenderer, canvas: Canvas, text: string, x: number, y: number) { + const font = r.sizeFont + if (!font) return + const width = textWidth(r, text) + MEASUREMENT_PILL_PADDING_X * 2 + const rect = r.ck.RRectXY( + r.ck.LTRBRect( + x - width / 2, + y - MEASUREMENT_PILL_HEIGHT / 2, + x + width / 2, + y + MEASUREMENT_PILL_HEIGHT / 2 + ), + MEASUREMENT_PILL_RADIUS, + MEASUREMENT_PILL_RADIUS + ) + r.auxFill.setColor(r.ck.Color4f(MEASUREMENT_COLOR.r, MEASUREMENT_COLOR.g, MEASUREMENT_COLOR.b, 1)) + canvas.drawRRect(rect, r.auxFill) + r.auxFill.setColor(r.ck.WHITE) + canvas.drawText( + text, + x - width / 2 + MEASUREMENT_PILL_PADDING_X, + y + MEASUREMENT_TEXT_BASELINE, + r.auxFill, + font + ) +} + +function drawTargetOutline(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph, target: SceneNode) { + const world = getWorldMatrix(target, graph) + const view = Matrix.multiply(Matrix.translated(r.panX, r.panY), Matrix.scaled(r.zoom, r.zoom)) + const points = Matrix.mapPoints(Matrix.multiply(view, world), [ + 0, + 0, + target.width, + 0, + target.width, + target.height, + 0, + target.height + ]) + const path = new r.ck.Path() + path.moveTo(points[0], points[1]) + path.lineTo(points[2], points[3]) + path.lineTo(points[4], points[5]) + path.lineTo(points[6], points[7]) + path.close() + canvas.drawPath(path, r.auxStroke) + path.delete() +} + +export function drawMeasurements( + r: SkiaRenderer, + canvas: Canvas, + graph: SceneGraph, + selectedIds: Set, + targetId?: string | null +): void { + if (!targetId || selectedIds.size === 0 || selectedIds.has(targetId)) return + const target = graph.getNode(targetId) + const from = selectedBounds(graph, selectedIds) + if (!target || !from) return + const to = getAxisAlignedWorldBounds(target, graph) + const segments = computeMeasurementSegments(from, to) + + r.auxStroke.setStrokeWidth(1) + r.auxStroke.setColor( + r.ck.Color4f(MEASUREMENT_COLOR.r, MEASUREMENT_COLOR.g, MEASUREMENT_COLOR.b, 1) + ) + r.auxStroke.setPathEffect(null) + + drawTargetOutline(r, canvas, graph, target) + if (segments.length === 0) return + + for (const segment of segments) { + if (segment.axis === 'x') { + const x1 = segment.from * r.zoom + r.panX + const x2 = segment.to * r.zoom + r.panX + const y = segment.cross * r.zoom + r.panY + canvas.drawLine(x1, y, x2, y, r.auxStroke) + drawPill(r, canvas, String(Math.round(segment.value)), center(x1, x2 - x1), y) + } else { + const x = segment.cross * r.zoom + r.panX + const y1 = segment.from * r.zoom + r.panY + const y2 = segment.to * r.zoom + r.panY + canvas.drawLine(x, y1, x, y2, r.auxStroke) + drawPill(r, canvas, String(Math.round(segment.value)), x, center(y1, y2 - y1)) + } + } +} diff --git a/packages/core/src/canvas/renderer.ts b/packages/core/src/canvas/renderer.ts index 4dcd5be40..1f855905e 100644 --- a/packages/core/src/canvas/renderer.ts +++ b/packages/core/src/canvas/renderer.ts @@ -30,7 +30,7 @@ import { initializeRendererPaints } from './renderer/paints' import * as RenderPipeline from './renderer/pipeline' import * as RendererState from './renderer/state' import * as RenderText from './text' -export type { RenderOverlays, RulerTheme } from './renderer/types' +export type { MeasurementMode, RenderOverlays, RulerTheme } from './renderer/types' import type { Image as CKImage, Path, @@ -199,6 +199,12 @@ export class SkiaRenderer { graph: SceneGraph, hoveredNodeId?: string | null ) => void + declare drawMeasurements: ( + canvas: Canvas, + graph: SceneGraph, + selectedIds: Set, + targetId?: string | null + ) => void declare drawEnteredContainer: ( canvas: Canvas, graph: SceneGraph, diff --git a/packages/core/src/canvas/renderer/methods.ts b/packages/core/src/canvas/renderer/methods.ts index ddd7eaed0..b6d8b2a4e 100644 --- a/packages/core/src/canvas/renderer/methods.ts +++ b/packages/core/src/canvas/renderer/methods.ts @@ -26,6 +26,15 @@ const rendererMethods: ThisType = { Overlays.drawHoverHighlight(this, canvas, graph, hoveredNodeId) }, + drawMeasurements( + canvas: Canvas, + graph: SceneGraph, + selectedIds: Set, + targetId?: string | null + ): void { + Overlays.drawMeasurements(this, canvas, graph, selectedIds, targetId) + }, + drawEnteredContainer( canvas: Canvas, graph: SceneGraph, diff --git a/packages/core/src/canvas/renderer/pipeline.ts b/packages/core/src/canvas/renderer/pipeline.ts index 29b8a039a..4de7d2439 100644 --- a/packages/core/src/canvas/renderer/pipeline.ts +++ b/packages/core/src/canvas/renderer/pipeline.ts @@ -55,6 +55,7 @@ export function renderFromEditorState( state.selectedIds, { hoveredNodeId: state.hoveredNodeId, + measurementMode: state.measurementMode, enteredContainerId: state.enteredContainerId, editingTextId: state.editingTextId, textEditor: textEditor as RenderOverlays['textEditor'], @@ -130,6 +131,36 @@ function measure(fn: () => T): { value: T; duration: number } { return { value, duration: now() - start } } +function measurementVisible(overlays: RenderOverlays): boolean { + return ( + overlays.measurementMode !== undefined && + overlays.measurementMode !== 'off' && + !overlays.editingTextId && + !overlays.nodeEditState && + !overlays.penState + ) +} + +function drawInteractiveOverlays( + r: SkiaRenderer, + canvas: Canvas, + graph: SceneGraph, + selectedIds: Set, + overlays: RenderOverlays +) { + const measuring = measurementVisible(overlays) + const hoveredNodeId = + measuring || overlays.hoveredNodeId === overlays.nodeEditState?.nodeId + ? null + : overlays.hoveredNodeId + r.drawHoverHighlight(canvas, graph, hoveredNodeId) + r.drawEnteredContainer(canvas, graph, overlays.enteredContainerId) + r.profiler.beginPhase('render:selection') + r.drawSelection(canvas, graph, selectedIds, overlays) + if (measuring) r.drawMeasurements(canvas, graph, selectedIds, overlays.hoveredNodeId) + r.profiler.endPhase('render:selection') +} + export function render( r: SkiaRenderer, graph: SceneGraph, @@ -221,21 +252,15 @@ export function render( canvas.save() canvas.scale(r.dpr, r.dpr) - r.drawHoverHighlight( - canvas, - graph, - overlays.hoveredNodeId === overlays.nodeEditState?.nodeId ? null : overlays.hoveredNodeId - ) - r.drawEnteredContainer(canvas, graph, overlays.enteredContainerId) - p.beginPhase('render:selection') - r.drawSelection(canvas, graph, selectedIds, overlays) - p.endPhase('render:selection') + drawInteractiveOverlays(r, canvas, graph, selectedIds, overlays) r.drawFlashes(canvas, graph) drawPageGuides(r, canvas, graph) r.drawSnapGuides(canvas, overlays.snapGuides) r.drawMarquee(canvas, overlays.marquee) r.drawLayoutInsertIndicator(canvas, overlays.layoutInsertIndicator) - r.drawAutoLayoutHover(canvas, graph, overlays.autoLayoutHover) + if (!measurementVisible(overlays)) { + r.drawAutoLayoutHover(canvas, graph, overlays.autoLayoutHover) + } r.drawNodeEditOverlay(canvas, graph, overlays.nodeEditState) r.drawPenOverlay(canvas, overlays.penState) r.drawRemoteCursors(canvas, graph, overlays.remoteCursors) diff --git a/packages/core/src/canvas/renderer/types.ts b/packages/core/src/canvas/renderer/types.ts index 49e7bff4e..a2d94e19a 100644 --- a/packages/core/src/canvas/renderer/types.ts +++ b/packages/core/src/canvas/renderer/types.ts @@ -11,8 +11,11 @@ export interface RulerTheme { label: Color } +export type MeasurementMode = 'off' | 'shallow' | 'deep' + export interface RenderOverlays { hoveredNodeId?: string | null + measurementMode?: MeasurementMode enteredContainerId?: string | null editingTextId?: string | null textEditor?: TextEditor | null diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 97afdd2c7..fb3d4ab68 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -10,6 +10,11 @@ export const DEFAULT_SHADOW_COLOR: Color = { r: 0, g: 0, b: 0, a: 0.25 } export const SELECTION_COLOR = { r: 0.23, g: 0.51, b: 0.96, a: 1 } satisfies Color export const COMPONENT_COLOR = { r: 0.592, g: 0.278, b: 1, a: 1 } satisfies Color export const SNAP_COLOR = { r: 1.0, g: 0.0, b: 0.56, a: 1 } satisfies Color +export const MEASUREMENT_COLOR = { r: 0.949, g: 0.282, b: 0.133, a: 1 } satisfies Color +export const MEASUREMENT_PILL_PADDING_X = 5 +export const MEASUREMENT_PILL_HEIGHT = 18 +export const MEASUREMENT_PILL_RADIUS = 3 +export const MEASUREMENT_TEXT_BASELINE = 4 export const CANVAS_BG_COLOR = { r: 0.96, g: 0.96, b: 0.96, a: 1 } satisfies Color export const CANVAS_BG_COLOR_DARK = { r: 0.173, g: 0.173, b: 0.173, a: 1 } satisfies Color // #2c2c2c, Figma-ish dark canvas diff --git a/packages/core/src/editor/create.ts b/packages/core/src/editor/create.ts index 97a3a67ea..99e0e1649 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -101,6 +101,7 @@ export function createEditor(options?: EditorOptions) { function setSelectedIds(ids: Set) { const previous = [...state.selectedIds] state.selectedIds = ids + if (ids.size === 0) state.measurementMode = 'off' const selected = [...ids] if ( previous.length !== selected.length || @@ -113,6 +114,7 @@ export function createEditor(options?: EditorOptions) { function setActiveTool(tool: EditorState['activeTool']) { const previous = state.activeTool state.activeTool = tool + if (tool !== 'SELECT') state.measurementMode = 'off' if (previous !== tool) emitEditorEvent('tool:changed', tool, previous) } @@ -203,6 +205,7 @@ export function createEditor(options?: EditorOptions) { state.currentPageId = _graph.getPages()[0]?.id ?? _graph.rootId setSelectedIds(new Set()) state.hoveredNodeId = null + state.measurementMode = 'off' pages.clearPageViewports() emitEditorEvent('graph:replaced', _graph) if (previousPageId !== state.currentPageId) { diff --git a/packages/core/src/editor/selection/overlays.ts b/packages/core/src/editor/selection/overlays.ts index c28a7a0f6..b078fe718 100644 --- a/packages/core/src/editor/selection/overlays.ts +++ b/packages/core/src/editor/selection/overlays.ts @@ -25,6 +25,12 @@ export function createSelectionOverlayActions(ctx: EditorContext) { ctx.requestRepaint() } + function setMeasurementMode(mode: typeof ctx.state.measurementMode) { + if (ctx.state.measurementMode === mode) return + ctx.state.measurementMode = mode + ctx.requestRepaint() + } + function setDropTarget(id: string | null) { if (ctx.state.dropTargetId === id) return ctx.state.dropTargetId = id @@ -56,6 +62,7 @@ export function createSelectionOverlayActions(ctx: EditorContext) { setSnapGuides, setRotationPreview, setHoveredNode, + setMeasurementMode, setDropTarget, setLayoutInsertIndicator, setAutoLayoutHover diff --git a/packages/core/src/editor/state.ts b/packages/core/src/editor/state.ts index 6ee8c1b4b..cb1457db6 100644 --- a/packages/core/src/editor/state.ts +++ b/packages/core/src/editor/state.ts @@ -12,6 +12,7 @@ export function createDefaultEditorState(pageId: string): EditorState { dropTargetId: null, layoutInsertIndicator: null, hoveredNodeId: null, + measurementMode: 'off', editingTextId: null, penState: null, penCursorX: null, diff --git a/packages/core/src/editor/types.ts b/packages/core/src/editor/types.ts index 3d39b9cf5..d3d78d0d7 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -12,7 +12,7 @@ import type { SnapGuide } from '@open-pencil/scene-graph/snap' import type { UndoManager } from '@open-pencil/scene-graph/undo' import type { RulerTheme, SkiaRenderer } from '#core/canvas/renderer' -import type { RenderOverlays } from '#core/canvas/renderer/types' +import type { MeasurementMode, RenderOverlays } from '#core/canvas/renderer/types' import type { TextEditor } from '#core/text/editor' import type { FontResolutionEvent, FontResolutionSnapshot } from '#core/text/resolver' @@ -46,6 +46,7 @@ export interface EditorState { direction: 'HORIZONTAL' | 'VERTICAL' } | null hoveredNodeId: string | null + measurementMode: MeasurementMode editingTextId: string | null penState: { vertices: VectorVertex[] diff --git a/packages/scene-graph/src/coordinate.ts b/packages/scene-graph/src/coordinate.ts index 559bda1b4..b9896212c 100644 --- a/packages/scene-graph/src/coordinate.ts +++ b/packages/scene-graph/src/coordinate.ts @@ -22,6 +22,27 @@ export function getWorldMatrix(node: SceneNode, graph: SceneGraph): Mat3 { return matrix } +export function getAxisAlignedWorldBounds(node: SceneNode, graph: SceneGraph) { + const matrix = getWorldMatrix(node, graph) + const points = Matrix.mapPoints(matrix, [ + 0, + 0, + node.width, + 0, + node.width, + node.height, + 0, + node.height + ]) + const xs = [points[0], points[2], points[4], points[6]] + const ys = [points[1], points[3], points[5], points[7]] + const minX = Math.min(...xs) + const maxX = Math.max(...xs) + const minY = Math.min(...ys) + const maxY = Math.max(...ys) + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY } +} + export function getAbsolutePosition(node: SceneNode, graph: SceneGraph): Vector { const matrix = getWorldMatrix(node, graph) const p = Matrix.mapPoints(matrix, [0, 0]) diff --git a/packages/vue/src/canvas/useCanvasInput.ts b/packages/vue/src/canvas/useCanvasInput.ts index 3ce362349..23fe0f75b 100644 --- a/packages/vue/src/canvas/useCanvasInput.ts +++ b/packages/vue/src/canvas/useCanvasInput.ts @@ -1,5 +1,5 @@ import { useEventListener } from '@vueuse/core' -import { ref, type Ref } from 'vue' +import { onScopeDispose, ref, type Ref } from 'vue' import type { Editor } from '@open-pencil/core/editor' import type { SceneNode } from '@open-pencil/scene-graph' @@ -49,6 +49,11 @@ export function useCanvasInput( previous: number } | null>(null) const selectedIdsBeforeClickSequence = ref>(new Set()) + const lastPointer = ref<{ cx: number; cy: number } | null>(null) + const pointerInside = ref(false) + let altHeld = false + let metaHeld = false + let controlHeld = false const spaceHeld = useSpaceHeld() const { recordClick, getClickCount } = createClickCounter() @@ -60,7 +65,54 @@ export function useCanvasInput( hitTestFrameTitle ) + function canMeasure() { + return ( + pointerInside.value && + !drag.value && + editor.state.activeTool === 'SELECT' && + editor.state.selectedIds.size > 0 && + !editor.state.editingTextId && + !editor.state.nodeEditState && + !editor.state.penState + ) + } + + function refreshMeasurement() { + const mode = altHeld && canMeasure() ? (metaHeld || controlHeld ? 'deep' : 'shallow') : 'off' + editor.setMeasurementMode(mode) + const pointer = lastPointer.value + if (!pointer || drag.value || editor.state.activeTool !== 'SELECT' || !pointerInside.value) + return + cursorOverride.value = updateHoverCursor( + pointer.cx, + pointer.cy, + editor, + hitFns, + mode === 'deep' + ) + editor.setAutoLayoutHover( + mode === 'off' ? resolveAutoLayoutHover(pointer.cx, pointer.cy, editor) : null + ) + } + + function updateModifier(code: string, held: boolean) { + if (code === 'AltLeft' || code === 'AltRight') altHeld = held + if (code === 'MetaLeft' || code === 'MetaRight') metaHeld = held + if (code === 'ControlLeft' || code === 'ControlRight') controlHeld = held + if (code.startsWith('Alt') || code.startsWith('Meta') || code.startsWith('Control')) { + refreshMeasurement() + } + } + + function resetMeasurementModifiers() { + altHeld = false + metaHeld = false + controlHeld = false + editor.setMeasurementMode('off') + } + function setDrag(d: DragState) { + editor.setMeasurementMode('off') drag.value = d } @@ -148,6 +200,7 @@ export function useCanvasInput( } function onMouseDown(e: MouseEvent) { + editor.setMeasurementMode('off') const paddingEdit = autoLayoutPaddingEdit.value if (paddingEdit) { commitAutoLayoutPaddingEdit(paddingEdit.value) @@ -175,25 +228,35 @@ export function useCanvasInput( } function onMouseMove(e: MouseEvent) { + pointerInside.value = true + const coords = getCoords(e) + lastPointer.value = { cx: coords.cx, cy: coords.cy } if (onCursorMove) { - const { cx, cy } = getCoords(e) - onCursorMove(cx, cy) + onCursorMove(coords.cx, coords.cy) } if (!drag.value) { - const { cx, cy } = getCoords(e) + const { cx, cy } = coords updatePenHover(cx, cy, editor) } if (!drag.value) { - const { cx, cy } = getCoords(e) + const { cx, cy } = coords updateNodeEditHover(editor, cx, cy) } if (!drag.value && editor.state.activeTool === 'SELECT') { - const { cx, cy } = getCoords(e) - cursorOverride.value = updateHoverCursor(cx, cy, editor, hitFns) - editor.setAutoLayoutHover(resolveAutoLayoutHover(cx, cy, editor)) + const { cx, cy } = coords + cursorOverride.value = updateHoverCursor( + cx, + cy, + editor, + hitFns, + editor.state.measurementMode === 'deep' + ) + editor.setAutoLayoutHover( + editor.state.measurementMode === 'off' ? resolveAutoLayoutHover(cx, cy, editor) : null + ) } if (!drag.value) return @@ -280,13 +343,19 @@ export function useCanvasInput( drag.value = null cursorOverride.value = null + refreshMeasurement() } useEventListener(canvasRef, 'dblclick', onDblClick) useEventListener(canvasRef, 'mousedown', onMouseDown) useEventListener(canvasRef, 'mousemove', onMouseMove) useEventListener(canvasRef, 'mouseup', onMouseUp) + useEventListener(window, 'keydown', (event) => updateModifier(event.code, true)) + useEventListener(window, 'keyup', (event) => updateModifier(event.code, false)) + useEventListener(window, 'blur', resetMeasurementModifiers) useEventListener(canvasRef, 'mouseleave', () => { + pointerInside.value = false + editor.setMeasurementMode('off') if (!drag.value) { editor.setHoveredNode(null) } @@ -295,6 +364,11 @@ export function useCanvasInput( if (drag.value) onMouseUp() }) + const stopToolListener = editor.onEditorEvent('tool:changed', () => { + editor.setMeasurementMode('off') + }) + onScopeDispose(stopToolListener) + setupPanZoom(canvasRef, editor, drag, onMouseDown, onMouseMove, onMouseUp) return { drag, diff --git a/packages/vue/src/shared/input/select/hover.ts b/packages/vue/src/shared/input/select/hover.ts index f8d4026b1..6c6f9beb4 100644 --- a/packages/vue/src/shared/input/select/hover.ts +++ b/packages/vue/src/shared/input/select/hover.ts @@ -45,12 +45,14 @@ function updateHoveredNode( cx: number, cy: number, editor: Editor, - fns: Pick + fns: Pick, + deep: boolean ) { - const hit = - fns.hitTestSectionTitle(cx, cy) ?? - fns.hitTestComponentLabel(cx, cy) ?? - fns.hitTestInScope(cx, cy, false) + const hit = deep + ? fns.hitTestInScope(cx, cy, true) + : (fns.hitTestSectionTitle(cx, cy) ?? + fns.hitTestComponentLabel(cx, cy) ?? + fns.hitTestInScope(cx, cy, false)) const editNodeId = getNodeEditState(editor)?.nodeId editor.setHoveredNode( hit && !editor.state.selectedIds.has(hit.id) && hit.id !== editNodeId ? hit.id : null @@ -61,7 +63,8 @@ export function updateHoverCursor( cx: number, cy: number, editor: Editor, - fns: Pick + fns: Pick, + deep = false ): string | null { if (getNodeEditState(editor)) { editor.setHoveredNode(null) @@ -70,6 +73,6 @@ export function updateHoverCursor( const cursor = getResizeCursorForSelection(cx, cy, editor) ?? getRotationCursorForSelection(cx, cy, editor) - updateHoveredNode(cx, cy, editor, fns) + updateHoveredNode(cx, cy, editor, fns, deep) return cursor } diff --git a/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts b/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts new file mode 100644 index 000000000..526c5dfd2 --- /dev/null +++ b/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts @@ -0,0 +1,129 @@ +import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures' + +const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers') + +async function showMeasurement(targetId: string) { + await editor.page.evaluate((id) => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + store.state.hoveredNodeId = id + store.setMeasurementMode('shallow') + store.requestRepaint() + }, targetId) + await editor.canvas.waitForRender() +} + +test('renders containment, multi-selection, rotation, and overlap edge cases', async () => { + const targets = await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + store.state.zoom = 1 + store.state.panX = 0 + store.state.panY = 0 + const pageId = store.state.currentPageId + const fill = (r: number, g: number, b: number) => [ + { type: 'SOLID' as const, color: { r, g, b, a: 1 }, visible: true, opacity: 1 } + ] + + const container = store.graph.createNode('FRAME', pageId, { + x: 60, + y: 70, + width: 260, + height: 210, + fills: fill(1, 1, 1), + strokes: [ + { + color: { r: 0.7, g: 0.7, b: 0.7, a: 1 }, + weight: 1, + opacity: 1, + visible: true, + align: 'INSIDE', + cap: 'NONE', + join: 'MITER', + dashPattern: [] + } + ] + }) + const child = store.graph.createNode('RECTANGLE', container.id, { + x: 40, + y: 50, + width: 90, + height: 60, + fills: fill(0.3, 0.55, 0.95) + }) + + const first = store.graph.createNode('RECTANGLE', pageId, { + x: 430, + y: 90, + width: 60, + height: 50, + fills: fill(0.3, 0.55, 0.95) + }) + const second = store.graph.createNode('RECTANGLE', pageId, { + x: 500, + y: 160, + width: 60, + height: 50, + fills: fill(0.3, 0.55, 0.95) + }) + const rotated = store.graph.createNode('RECTANGLE', pageId, { + x: 660, + y: 120, + width: 110, + height: 70, + rotation: 28, + fills: fill(0.95, 0.55, 0.2) + }) + + const overlapSelected = store.graph.createNode('RECTANGLE', pageId, { + x: 100, + y: 390, + width: 100, + height: 90, + fills: fill(0.3, 0.55, 0.95) + }) + const overlapTarget = store.graph.createNode('RECTANGLE', pageId, { + x: 160, + y: 430, + width: 110, + height: 80, + fills: fill(0.95, 0.55, 0.2) + }) + + store.select([child.id]) + store.requestRender() + return { + container: container.id, + first: first.id, + second: second.id, + rotated: rotated.id, + overlapSelected: overlapSelected.id, + overlapTarget: overlapTarget.id + } + }) + await editor.canvas.waitForRender() + + await showMeasurement(targets.container) + expect( + await editor.page.screenshot({ clip: { x: 40, y: 40, width: 310, height: 270 } }) + ).toMatchSnapshot('distance-containment.png') + + await editor.page.evaluate(({ first, second }) => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + store.select([first, second]) + }, targets) + await showMeasurement(targets.rotated) + expect( + await editor.page.screenshot({ clip: { x: 390, y: 40, width: 430, height: 260 } }) + ).toMatchSnapshot('distance-multi-rotation.png') + + await editor.page.evaluate( + (id) => window.openPencil?.getStore?.().select([id]), + targets.overlapSelected + ) + await showMeasurement(targets.overlapTarget) + expect( + await editor.page.screenshot({ clip: { x: 70, y: 350, width: 240, height: 200 } }) + ).toMatchSnapshot('distance-overlap.png') +}) diff --git a/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-containment-openpencil-darwin.png b/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-containment-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..560c5b39d7ae5e3acc5080d61d7f731661658958 GIT binary patch literal 8200 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�|2<>-w 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 JF7+nxzX9ThPwD^w literal 0 HcmV?d00001 diff --git a/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-overlap-openpencil-darwin.png b/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-overlap-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..a1701be82db5b9839ec5eaa9efbf1567b48d743e GIT binary patch literal 3611 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 PtN<=q+J0GS;T``A2`n~q literal 0 HcmV?d00001 diff --git a/tests/e2e/canvas/distance-measurement.spec.ts b/tests/e2e/canvas/distance-measurement.spec.ts new file mode 100644 index 000000000..fdec1d74a --- /dev/null +++ b/tests/e2e/canvas/distance-measurement.spec.ts @@ -0,0 +1,102 @@ +import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures' + +const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers') + +test('Option hover shows temporary distances between layers', async () => { + await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + store.state.zoom = 1 + store.state.panX = 0 + store.state.panY = 0 + const pageId = store.state.currentPageId + const selected = store.graph.createNode('RECTANGLE', pageId, { + name: 'Selected', + x: 120, + y: 140, + width: 100, + height: 80, + fills: [ + { type: 'SOLID', color: { r: 0.25, g: 0.52, b: 0.96, a: 1 }, visible: true, opacity: 1 } + ] + }) + store.graph.createNode('RECTANGLE', pageId, { + name: 'Target', + x: 300, + y: 290, + width: 120, + height: 90, + fills: [ + { type: 'SOLID', color: { r: 0.95, g: 0.55, b: 0.2, a: 1 }, visible: true, opacity: 1 } + ] + }) + store.select([selected.id]) + store.requestRender() + }) + await editor.canvas.waitForRender() + + await editor.canvas.hover(340, 330) + await editor.page.keyboard.down('Alt') + await expect + .poll(() => editor.page.evaluate(() => window.openPencil?.getStore?.().state.measurementMode)) + .toBe('shallow') + await editor.canvas.waitForRender() + + expect( + await editor.page.screenshot({ + clip: { x: 80, y: 90, width: 390, height: 340 } + }) + ).toMatchSnapshot('distance-measurement.png') + + await editor.page.keyboard.up('Alt') + await expect + .poll(() => editor.page.evaluate(() => window.openPencil?.getStore?.().state.measurementMode)) + .toBe('off') +}) + +test('deep measurement modifier targets a nested component child', async () => { + const ids = await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + store.state.zoom = 1 + store.state.panX = 0 + store.state.panY = 0 + const pageId = store.state.currentPageId + const selected = store.graph.createNode('RECTANGLE', pageId, { + x: 100, + y: 100, + width: 50, + height: 50 + }) + const component = store.graph.createNode('COMPONENT', pageId, { + x: 250, + y: 100, + width: 200, + height: 160, + fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, visible: true, opacity: 1 }] + }) + const child = store.graph.createNode('RECTANGLE', component.id, { + x: 30, + y: 30, + width: 80, + height: 60 + }) + store.select([selected.id]) + store.requestRender() + return { componentId: component.id, childId: child.id } + }) + await editor.canvas.waitForRender() + await editor.canvas.hover(300, 150) + + await editor.page.keyboard.down('Alt') + await expect + .poll(() => editor.page.evaluate(() => window.openPencil?.getStore?.().state.hoveredNodeId)) + .toBe(ids.componentId) + await editor.page.keyboard.down('Control') + await expect + .poll(() => editor.page.evaluate(() => window.openPencil?.getStore?.().state.hoveredNodeId)) + .toBe(ids.childId) + + await editor.page.keyboard.up('Control') + await editor.page.keyboard.up('Alt') +}) diff --git a/tests/e2e/canvas/distance-measurement.spec.ts-snapshots/distance-measurement-openpencil-darwin.png b/tests/e2e/canvas/distance-measurement.spec.ts-snapshots/distance-measurement-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..fd7a7bfbe2a40030356a8b434c0cf24769a1dee5 GIT binary patch literal 7215 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 ({ x, y, width, height }) + +describe('distance measurement geometry', () => { + test('measures horizontal and vertical gaps between separated bounds', () => { + expect(computeMeasurementSegments(rect(0, 0), rect(30, 40))).toEqual([ + { axis: 'x', from: 20, to: 30, cross: 20, value: 10 }, + { axis: 'y', from: 20, to: 40, cross: 30, value: 20 } + ]) + }) + + test('anchors diagonal guides to the nearest corners', () => { + expect(computeMeasurementSegments(rect(120, 140, 100, 80), rect(300, 290, 120, 90))).toEqual([ + { axis: 'x', from: 220, to: 300, cross: 220, value: 80 }, + { axis: 'y', from: 220, to: 290, cross: 300, value: 70 } + ]) + }) + + test('measures from facing edges in either direction', () => { + expect(computeMeasurementSegments(rect(40, 50), rect(0, 10))).toEqual([ + { axis: 'x', from: 20, to: 40, cross: 50, value: 20 }, + { axis: 'y', from: 30, to: 50, cross: 20, value: 20 } + ]) + }) + + test('only measures the separated axis when bounds overlap', () => { + expect(computeMeasurementSegments(rect(0, 0), rect(30, 10))).toEqual([ + { axis: 'x', from: 20, to: 30, cross: 15, value: 10 } + ]) + }) + + test('measures all four edges for a child inside a container', () => { + expect(computeMeasurementSegments(rect(20, 30, 40, 50), rect(0, 0, 100, 120))).toEqual([ + { axis: 'x', from: 0, to: 20, cross: 55, value: 20 }, + { axis: 'x', from: 60, to: 100, cross: 55, value: 40 }, + { axis: 'y', from: 0, to: 30, cross: 40, value: 30 }, + { axis: 'y', from: 80, to: 120, cross: 40, value: 40 } + ]) + }) + + test('does not measure overlap on either axis', () => { + expect(computeMeasurementSegments(rect(0, 0, 40, 40), rect(20, 20, 40, 40))).toEqual([]) + }) + + test('preserves fractional geometry for display rounding', () => { + const [segment] = computeMeasurementSegments(rect(0.2, 0, 10.1, 10), rect(20.8, 0, 10, 10)) + expect(segment).toMatchObject({ axis: 'x', to: 20.8, cross: 5 }) + expect(segment?.from).toBeCloseTo(10.3) + expect(segment?.value).toBeCloseTo(10.5) + }) + + test('omits zero gaps for touching and identical bounds', () => { + expect(computeMeasurementSegments(rect(0, 0), rect(20, 0))).toEqual([]) + expect(computeMeasurementSegments(rect(0, 0), rect(0, 0))).toEqual([]) + }) +}) diff --git a/tests/engine/scene-graph/world-bounds.test.ts b/tests/engine/scene-graph/world-bounds.test.ts new file mode 100644 index 000000000..e6439b033 --- /dev/null +++ b/tests/engine/scene-graph/world-bounds.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test' + +import { SceneGraph } from '@open-pencil/scene-graph' +import { getAxisAlignedWorldBounds } from '@open-pencil/scene-graph/coordinate' + +describe('axis-aligned world bounds', () => { + test('includes a node own rotation', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const node = graph.createNode('RECTANGLE', page.id, { + x: 100, + y: 100, + width: 100, + height: 50, + rotation: 90 + }) + + expect(getAxisAlignedWorldBounds(node, graph)).toEqual({ + x: 125, + y: 75, + width: 50, + height: 100 + }) + }) + + test('includes transformed ancestors', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const frame = graph.createNode('FRAME', page.id, { + x: 100, + y: 100, + width: 200, + height: 200, + rotation: 90 + }) + const child = graph.createNode('RECTANGLE', frame.id, { + x: 20, + y: 30, + width: 40, + height: 20 + }) + + expect(getAxisAlignedWorldBounds(child, graph)).toEqual({ + x: 250, + y: 120, + width: 20, + height: 40 + }) + }) +})