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:
parent
2710f906a0
commit
c2103ef722
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
245
packages/core/src/canvas/overlays/measurement.ts
Normal file
245
packages/core/src/canvas/overlays/measurement.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export function createDefaultEditorState(pageId: string): EditorState {
|
|||
dropTargetId: null,
|
||||
layoutInsertIndicator: null,
|
||||
hoveredNodeId: null,
|
||||
measurementMode: 'off',
|
||||
editingTextId: null,
|
||||
penState: null,
|
||||
penCursorX: null,
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) ??
|
||||
const hit = deep
|
||||
? fns.hitTestInScope(cx, cy, true)
|
||||
: (fns.hitTestSectionTitle(cx, cy) ??
|
||||
fns.hitTestComponentLabel(cx, cy) ??
|
||||
fns.hitTestInScope(cx, cy, false)
|
||||
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
|
||||
}
|
||||
|
|
|
|||
129
tests/e2e/canvas/distance-measurement-edge-cases.spec.ts
Normal file
129
tests/e2e/canvas/distance-measurement-edge-cases.spec.ts
Normal 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 |
102
tests/e2e/canvas/distance-measurement.spec.ts
Normal file
102
tests/e2e/canvas/distance-measurement.spec.ts
Normal 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 |
59
tests/engine/render/canvas/measurement.test.ts
Normal file
59
tests/engine/render/canvas/measurement.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
50
tests/engine/scene-graph/world-bounds.test.ts
Normal file
50
tests/engine/scene-graph/world-bounds.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue