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
This commit is contained in:
Danila Poyarkov 2026-08-14 18:18:55 +03:00 committed by GitHub
parent 2710f906a0
commit c2103ef722
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 772 additions and 27 deletions

View file

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

View file

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

View file

@ -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<string>): 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<string>,
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))
}
}
}

View file

@ -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<string>,
targetId?: string | null
) => void
declare drawEnteredContainer: (
canvas: Canvas,
graph: SceneGraph,

View file

@ -26,6 +26,15 @@ const rendererMethods: ThisType<SkiaRenderer> = {
Overlays.drawHoverHighlight(this, canvas, graph, hoveredNodeId)
},
drawMeasurements(
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>,
targetId?: string | null
): void {
Overlays.drawMeasurements(this, canvas, graph, selectedIds, targetId)
},
drawEnteredContainer(
canvas: Canvas,
graph: SceneGraph,

View file

@ -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<T>(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<string>,
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)

View file

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

View file

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

View file

@ -101,6 +101,7 @@ export function createEditor(options?: EditorOptions) {
function setSelectedIds(ids: Set<string>) {
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) {

View file

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

View file

@ -12,6 +12,7 @@ export function createDefaultEditorState(pageId: string): EditorState {
dropTargetId: null,
layoutInsertIndicator: null,
hoveredNodeId: null,
measurementMode: 'off',
editingTextId: null,
penState: null,
penCursorX: null,

View file

@ -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[]

View file

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

View file

@ -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<ReadonlySet<string>>(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,

View file

@ -45,12 +45,14 @@ function updateHoveredNode(
cx: number,
cy: number,
editor: Editor,
fns: Pick<HitTestFns, 'hitTestInScope' | 'hitTestSectionTitle' | 'hitTestComponentLabel'>
fns: Pick<HitTestFns, 'hitTestInScope' | 'hitTestSectionTitle' | 'hitTestComponentLabel'>,
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<HitTestFns, 'hitTestInScope' | 'hitTestSectionTitle' | 'hitTestComponentLabel'>
fns: Pick<HitTestFns, 'hitTestInScope' | 'hitTestSectionTitle' | 'hitTestComponentLabel'>,
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
}

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

View file

@ -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([])
})
})

View file

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