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 000000000..560c5b39d Binary files /dev/null and b/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-containment-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-multi-rotation-openpencil-darwin.png b/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-multi-rotation-openpencil-darwin.png new file mode 100644 index 000000000..c5eb01d5a Binary files /dev/null and b/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-multi-rotation-openpencil-darwin.png differ 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 000000000..a1701be82 Binary files /dev/null and b/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-overlap-openpencil-darwin.png differ 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 000000000..fd7a7bfbe Binary files /dev/null and b/tests/e2e/canvas/distance-measurement.spec.ts-snapshots/distance-measurement-openpencil-darwin.png differ diff --git a/tests/engine/render/canvas/measurement.test.ts b/tests/engine/render/canvas/measurement.test.ts new file mode 100644 index 000000000..39f0c9d12 --- /dev/null +++ b/tests/engine/render/canvas/measurement.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test' + +import { computeMeasurementSegments } from '#core/canvas/overlays/measurement' + +const rect = (x: number, y: number, width = 20, height = 20) => ({ 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 + }) + }) +})