Merge: Layer lock/visibility toggles + Figma-style selection scope (#116)
This commit is contained in:
commit
5e9afab441
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -2,12 +2,24 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Features
|
||||
|
||||
- Lock and visibility toggle buttons in layers panel (hover to reveal, always shown when active)
|
||||
- Figma-style selection scope — double-click to enter groups/frames/components, Escape to exit
|
||||
- Nested container navigation — each double-click goes one level deeper
|
||||
- Dashed border around entered container for visual feedback
|
||||
- Layer panel click syncs canvas scope automatically
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fix `set_layout` tool not defaulting to HUG sizing when enabling auto-layout — frames now shrink/grow to fit children instead of keeping fixed dimensions
|
||||
- Fix save crash when COLOR variable is missing alpha channel
|
||||
- Fix console error spam on deployed web app from automation WebSocket reconnect loop
|
||||
- Fix headless CLI font fallback — bundled Inter font now ships with `@open-pencil/core` and loads without a web server
|
||||
- Locked nodes now block move, resize, rotate, and delete on canvas
|
||||
- Locked containers block double-click enter
|
||||
- Marquee selection skips locked and hidden nodes
|
||||
- COMPONENT/INSTANCE containers are now enterable via double-click
|
||||
|
||||
## 0.10.0 — 2026-03-15
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,40 @@ export function drawHoverHighlight(
|
|||
canvas.restore()
|
||||
}
|
||||
|
||||
export function drawEnteredContainer(
|
||||
r: SkiaRenderer,
|
||||
canvas: Canvas,
|
||||
graph: SceneGraph,
|
||||
enteredContainerId?: string | null
|
||||
): void {
|
||||
if (!enteredContainerId) return
|
||||
const node = graph.getNode(enteredContainerId)
|
||||
if (!node) return
|
||||
|
||||
const abs = graph.getAbsolutePosition(node.id)
|
||||
const sx = abs.x * r.zoom + r.panX
|
||||
const sy = abs.y * r.zoom + r.panY
|
||||
|
||||
r.auxStroke.setStrokeWidth(1)
|
||||
r.auxStroke.setColor(r.selColor(SELECTION_DASH_ALPHA))
|
||||
r.auxStroke.setPathEffect(r.ck.PathEffect.MakeDash([4, 4], 0))
|
||||
|
||||
canvas.save()
|
||||
canvas.translate(sx, sy)
|
||||
if (node.rotation !== 0) {
|
||||
const cx = (node.width / 2) * r.zoom
|
||||
const cy = (node.height / 2) * r.zoom
|
||||
canvas.rotate(node.rotation, cx, cy)
|
||||
}
|
||||
canvas.drawRect(
|
||||
r.ck.LTRBRect(0, 0, node.width * r.zoom, node.height * r.zoom),
|
||||
r.auxStroke
|
||||
)
|
||||
canvas.restore()
|
||||
|
||||
r.auxStroke.setPathEffect(null)
|
||||
}
|
||||
|
||||
export function drawSelection(
|
||||
r: SkiaRenderer,
|
||||
canvas: Canvas,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import type {
|
|||
|
||||
import {
|
||||
drawHoverHighlight as drawHoverHighlightFn,
|
||||
drawEnteredContainer as drawEnteredContainerFn,
|
||||
drawSelection as drawSelectionFn,
|
||||
drawNodeSelection as drawNodeSelectionFn,
|
||||
drawSelectionLabels as drawSelectionLabelsFn,
|
||||
|
|
@ -127,6 +128,7 @@ import {
|
|||
|
||||
export interface RenderOverlays {
|
||||
hoveredNodeId?: string | null
|
||||
enteredContainerId?: string | null
|
||||
editingTextId?: string | null
|
||||
textEditor?: TextEditor | null
|
||||
marquee?: Rect | null
|
||||
|
|
@ -750,6 +752,7 @@ export class SkiaRenderer {
|
|||
canvas.scale(this.dpr, this.dpr)
|
||||
|
||||
this.drawHoverHighlight(canvas, graph, overlays.hoveredNodeId)
|
||||
this.drawEnteredContainer(canvas, graph, overlays.enteredContainerId)
|
||||
p.beginPhase('render:selection')
|
||||
this.drawSelection(canvas, graph, selectedIds, overlays)
|
||||
p.endPhase('render:selection')
|
||||
|
|
@ -916,6 +919,10 @@ export class SkiaRenderer {
|
|||
drawHoverHighlightFn(this, canvas, graph, hoveredNodeId)
|
||||
}
|
||||
|
||||
private drawEnteredContainer(canvas: Canvas, graph: SceneGraph, enteredContainerId?: string | null): void {
|
||||
drawEnteredContainerFn(this, canvas, graph, enteredContainerId)
|
||||
}
|
||||
|
||||
private drawSelection(canvas: Canvas, graph: SceneGraph, selectedIds: Set<string>, overlays: RenderOverlays): void {
|
||||
drawSelectionFn(this, canvas, graph, selectedIds, overlays)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,8 +40,12 @@ function hitTestTransparentContainer(
|
|||
ay: number,
|
||||
deep: boolean
|
||||
): SceneNode | null {
|
||||
const deepHit = hitTestChildren(graph, px, py, childId, ax, ay, deep)
|
||||
if (deepHit) return deepHit
|
||||
const childHit = hitTestChildren(graph, px, py, childId, ax, ay, deep)
|
||||
if (childHit) {
|
||||
if (!deep && child.type === 'GROUP') return child
|
||||
if (child.locked) return child
|
||||
return childHit
|
||||
}
|
||||
if (child.type === 'GROUP') return null
|
||||
if (containsPoint(px, py, ax, ay, child) && hasVisibleFillOrStroke(child)) return child
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -15,7 +15,15 @@ function onRightClick(e: MouseEvent) {
|
|||
const sy = e.clientY - rect.top
|
||||
const { x: cx, y: cy } = store.screenToCanvas(sx, sy)
|
||||
|
||||
const hit = store.graph.hitTest(cx, cy, store.state.currentPageId)
|
||||
let hit = null
|
||||
const scopeId = store.state.enteredContainerId
|
||||
if (scopeId && store.graph.getNode(scopeId)) {
|
||||
const abs = store.graph.getAbsolutePosition(scopeId)
|
||||
hit = store.graph.hitTest(cx - abs.x, cy - abs.y, scopeId)
|
||||
}
|
||||
if (!hit) {
|
||||
hit = store.graph.hitTest(cx, cy, store.state.currentPageId)
|
||||
}
|
||||
if (hit) {
|
||||
if (!store.state.selectedIds.has(hit.id)) {
|
||||
store.select([hit.id])
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ interface LayerNode {
|
|||
type: string
|
||||
layoutMode: string
|
||||
visible: boolean
|
||||
locked: boolean
|
||||
children?: LayerNode[]
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +65,16 @@ function nodeIcon(node: LayerNode) {
|
|||
|
||||
const COMPONENT_TYPES = new Set(['COMPONENT', 'COMPONENT_SET', 'INSTANCE'])
|
||||
|
||||
function toggleNodeVisibility(id: string) {
|
||||
const node = store.graph.getNode(id)
|
||||
if (node) store.updateNodeWithUndo(id, { visible: !node.visible }, node.visible ? 'Hide layer' : 'Show layer')
|
||||
}
|
||||
|
||||
function toggleNodeLock(id: string) {
|
||||
const node = store.graph.getNode(id)
|
||||
if (node) store.updateNodeWithUndo(id, { locked: !node.locked }, node.locked ? 'Unlock layer' : 'Lock layer')
|
||||
}
|
||||
|
||||
function buildTree(parentId: string): LayerNode[] {
|
||||
const parent = store.graph.getNode(parentId)
|
||||
if (!parent) return []
|
||||
|
|
@ -76,6 +87,7 @@ function buildTree(parentId: string): LayerNode[] {
|
|||
type: node.type,
|
||||
layoutMode: node.layoutMode,
|
||||
visible: node.visible,
|
||||
locked: node.locked,
|
||||
children: node.childIds.length > 0 ? buildTree(node.id) : undefined
|
||||
}))
|
||||
}
|
||||
|
|
@ -113,6 +125,21 @@ watch(
|
|||
}
|
||||
)
|
||||
|
||||
function syncCanvasScope(nodeId: string) {
|
||||
const node = store.graph.getNode(nodeId)
|
||||
if (!node) return
|
||||
let parentId = node.parentId
|
||||
while (parentId && parentId !== store.state.currentPageId) {
|
||||
if (store.graph.isContainer(parentId)) {
|
||||
store.enterContainer(parentId)
|
||||
return
|
||||
}
|
||||
const parent = store.graph.getNode(parentId)
|
||||
parentId = parent?.parentId
|
||||
}
|
||||
store.state.enteredContainerId = null
|
||||
}
|
||||
|
||||
function onSelect(ev: CustomEvent) {
|
||||
ev.preventDefault()
|
||||
const node = ev.detail.value as LayerNode
|
||||
|
|
@ -120,6 +147,7 @@ function onSelect(ev: CustomEvent) {
|
|||
store.select([node.id], true)
|
||||
} else {
|
||||
store.select([node.id])
|
||||
syncCanvasScope(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -365,7 +393,7 @@ function updateDropTarget(ev: PointerEvent) {
|
|||
<button
|
||||
v-else
|
||||
data-test-id="layers-item"
|
||||
class="group/row flex w-full cursor-pointer items-center gap-1 rounded border-none py-1 text-left text-xs"
|
||||
class="group/row flex w-full cursor-pointer items-center gap-1 rounded border-none py-1 pr-1 text-left text-xs"
|
||||
:class="[
|
||||
store.state.selectedIds.has(item.value.id)
|
||||
? 'bg-accent text-white'
|
||||
|
|
@ -397,10 +425,65 @@ function updateDropTarget(ev: PointerEvent) {
|
|||
"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate">{{ item.value.name }}</span>
|
||||
<icon-lucide-eye-off
|
||||
v-if="!item.value.visible"
|
||||
class="mr-1 size-3 shrink-0 text-muted"
|
||||
/>
|
||||
<span
|
||||
class="flex shrink-0 items-center gap-0.5"
|
||||
:class="
|
||||
!item.value.locked && item.value.visible
|
||||
? 'opacity-0 group-hover/row:opacity-100'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
|
||||
:title="item.value.locked ? 'Unlock' : 'Lock'"
|
||||
@pointerdown.stop
|
||||
@click.stop="toggleNodeLock(item.value.id)"
|
||||
>
|
||||
<icon-lucide-lock
|
||||
v-if="item.value.locked"
|
||||
class="size-3"
|
||||
:class="
|
||||
store.state.selectedIds.has(item.value.id)
|
||||
? 'text-white'
|
||||
: 'text-surface'
|
||||
"
|
||||
/>
|
||||
<icon-lucide-unlock
|
||||
v-else
|
||||
class="size-3 opacity-0 group-hover/row:opacity-100"
|
||||
:class="
|
||||
store.state.selectedIds.has(item.value.id)
|
||||
? 'text-white/80'
|
||||
: 'text-surface/70'
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
|
||||
:title="item.value.visible ? 'Hide' : 'Show'"
|
||||
@pointerdown.stop
|
||||
@click.stop="toggleNodeVisibility(item.value.id)"
|
||||
>
|
||||
<icon-lucide-eye-off
|
||||
v-if="!item.value.visible"
|
||||
class="size-3"
|
||||
:class="
|
||||
store.state.selectedIds.has(item.value.id)
|
||||
? 'text-white'
|
||||
: 'text-surface'
|
||||
"
|
||||
/>
|
||||
<icon-lucide-eye
|
||||
v-else
|
||||
class="size-3 opacity-0 group-hover/row:opacity-100"
|
||||
:class="
|
||||
store.state.selectedIds.has(item.value.id)
|
||||
? 'text-white/80'
|
||||
: 'text-surface/70'
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</TreeItem>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
DEFAULT_TEXT_WIDTH,
|
||||
DEFAULT_TEXT_HEIGHT
|
||||
} from '@/constants'
|
||||
import { computeSelectionBounds, computeSnap } from '@open-pencil/core'
|
||||
import { computeSelectionBounds, computeSnap, degToRad } from '@open-pencil/core'
|
||||
|
||||
import type { EditorStore, Tool } from '@/stores/editor'
|
||||
import type { NodeType, Rect, SceneNode, Vector } from '@open-pencil/core'
|
||||
|
|
@ -294,6 +294,50 @@ export function useCanvasInput(
|
|||
return { sx, sy, cx, cy }
|
||||
}
|
||||
|
||||
function canvasToLocal(cx: number, cy: number, scopeId: string): { lx: number; ly: number } {
|
||||
const node = store.graph.getNode(scopeId)
|
||||
if (!node) return { lx: cx, ly: cy }
|
||||
const abs = store.graph.getAbsolutePosition(scopeId)
|
||||
let dx = cx - abs.x
|
||||
let dy = cy - abs.y
|
||||
if (node.rotation !== 0) {
|
||||
const hw = node.width / 2
|
||||
const hh = node.height / 2
|
||||
const rad = degToRad(-node.rotation)
|
||||
const cos = Math.cos(rad)
|
||||
const sin = Math.sin(rad)
|
||||
const rx = dx - hw
|
||||
const ry = dy - hh
|
||||
dx = rx * cos - ry * sin + hw
|
||||
dy = rx * sin + ry * cos + hh
|
||||
}
|
||||
return { lx: dx, ly: dy }
|
||||
}
|
||||
|
||||
function hitTestInScope(cx: number, cy: number, deep: boolean): SceneNode | null {
|
||||
const scopeId = store.state.enteredContainerId
|
||||
if (scopeId) {
|
||||
if (!store.graph.getNode(scopeId)) {
|
||||
store.state.enteredContainerId = null
|
||||
} else {
|
||||
const { lx, ly } = canvasToLocal(cx, cy, scopeId)
|
||||
return deep
|
||||
? store.graph.hitTestDeep(lx, ly, scopeId)
|
||||
: store.graph.hitTest(lx, ly, scopeId)
|
||||
}
|
||||
}
|
||||
return deep
|
||||
? store.graph.hitTestDeep(cx, cy, store.state.currentPageId)
|
||||
: store.graph.hitTest(cx, cy, store.state.currentPageId)
|
||||
}
|
||||
|
||||
function isInsideContainerBounds(cx: number, cy: number, containerId: string): boolean {
|
||||
const container = store.graph.getNode(containerId)
|
||||
if (!container) return false
|
||||
const { lx, ly } = canvasToLocal(cx, cy, containerId)
|
||||
return lx >= 0 && lx <= container.width && ly >= 0 && ly <= container.height
|
||||
}
|
||||
|
||||
function startPanDrag(e: MouseEvent) {
|
||||
drag.value = {
|
||||
type: 'pan',
|
||||
|
|
@ -336,7 +380,7 @@ export function useCanvasInput(
|
|||
if (store.state.selectedIds.size !== 1) return false
|
||||
const id = [...store.state.selectedIds][0]
|
||||
const node = store.graph.getNode(id)
|
||||
if (!node) return false
|
||||
if (!node || node.locked) return false
|
||||
const abs = store.graph.getAbsolutePosition(id)
|
||||
if (
|
||||
!hitTestCornerRotation(
|
||||
|
|
@ -371,7 +415,7 @@ export function useCanvasInput(
|
|||
function tryStartResize(sx: number, sy: number, cx: number, cy: number): boolean {
|
||||
for (const id of store.state.selectedIds) {
|
||||
const node = store.graph.getNode(id)
|
||||
if (!node) continue
|
||||
if (!node || node.locked) continue
|
||||
const abs = store.graph.getAbsolutePosition(id)
|
||||
const handle = hitTestHandle(
|
||||
sx,
|
||||
|
|
@ -451,6 +495,34 @@ export function useCanvasInput(
|
|||
return undefined
|
||||
}
|
||||
|
||||
function resolveHit(cx: number, cy: number): SceneNode | null {
|
||||
const titleHit =
|
||||
hitTestFrameTitle(cx, cy) ??
|
||||
hitTestSectionTitle(cx, cy) ??
|
||||
hitTestComponentLabel(cx, cy)
|
||||
if (titleHit) return titleHit
|
||||
|
||||
const hit = hitTestInScope(cx, cy, false)
|
||||
if (hit) return hit
|
||||
|
||||
const scopeId = store.state.enteredContainerId
|
||||
if (!scopeId) return null
|
||||
|
||||
if (isInsideContainerBounds(cx, cy, scopeId)) {
|
||||
store.clearSelection()
|
||||
return null
|
||||
}
|
||||
|
||||
store.exitContainer()
|
||||
const afterExit = hitTestInScope(cx, cy, false)
|
||||
if (afterExit) return afterExit
|
||||
|
||||
if (store.state.enteredContainerId) {
|
||||
store.exitContainer()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function handleSelectDown(e: MouseEvent, sx: number, sy: number, cx: number, cy: number) {
|
||||
if (store.state.editingTextId && handleTextEditClick(cx, cy, e.shiftKey)) return
|
||||
|
||||
|
|
@ -459,15 +531,12 @@ export function useCanvasInput(
|
|||
if (tryStartRotation(sx, sy)) return
|
||||
if (tryStartResize(sx, sy, cx, cy)) return
|
||||
|
||||
const hit =
|
||||
hitTestFrameTitle(cx, cy) ??
|
||||
hitTestSectionTitle(cx, cy) ??
|
||||
hitTestComponentLabel(cx, cy) ??
|
||||
store.graph.hitTest(cx, cy, store.state.currentPageId)
|
||||
|
||||
const hit = resolveHit(cx, cy)
|
||||
if (!hit) {
|
||||
store.clearSelection()
|
||||
drag.value = { type: 'marquee', startX: cx, startY: cy }
|
||||
if (!store.state.enteredContainerId) {
|
||||
store.clearSelection()
|
||||
drag.value = { type: 'marquee', startX: cx, startY: cy }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -477,6 +546,9 @@ export function useCanvasInput(
|
|||
store.select([hit.id], true)
|
||||
}
|
||||
|
||||
const allLocked = [...store.state.selectedIds].every((id) => store.graph.getNode(id)?.locked)
|
||||
if (allLocked) return
|
||||
|
||||
const originals = new Map<string, { x: number; y: number; parentId: string }>()
|
||||
for (const id of store.state.selectedIds) {
|
||||
const n = store.graph.getNode(id)
|
||||
|
|
@ -611,7 +683,7 @@ export function useCanvasInput(
|
|||
const hit =
|
||||
hitTestSectionTitle(cx, cy) ??
|
||||
hitTestComponentLabel(cx, cy) ??
|
||||
store.graph.hitTest(cx, cy, store.state.currentPageId)
|
||||
hitTestInScope(cx, cy, false)
|
||||
store.setHoveredNode(hit && !store.state.selectedIds.has(hit.id) ? hit.id : null)
|
||||
}
|
||||
|
||||
|
|
@ -776,13 +848,23 @@ export function useCanvasInput(
|
|||
const maxX = Math.max(d.startX, cx)
|
||||
const maxY = Math.max(d.startY, cy)
|
||||
|
||||
const scopeId = store.state.enteredContainerId
|
||||
const parentId = scopeId ?? store.state.currentPageId
|
||||
const localMin = scopeId ? canvasToLocal(minX, minY, scopeId) : { lx: minX, ly: minY }
|
||||
const localMax = scopeId ? canvasToLocal(maxX, maxY, scopeId) : { lx: maxX, ly: maxY }
|
||||
const localMinX = Math.min(localMin.lx, localMax.lx)
|
||||
const localMinY = Math.min(localMin.ly, localMax.ly)
|
||||
const localMaxX = Math.max(localMin.lx, localMax.lx)
|
||||
const localMaxY = Math.max(localMin.ly, localMax.ly)
|
||||
|
||||
const hits: string[] = []
|
||||
for (const node of store.graph.getChildren(store.state.currentPageId)) {
|
||||
for (const node of store.graph.getChildren(parentId)) {
|
||||
if (!node.visible || node.locked) continue
|
||||
if (
|
||||
node.x + node.width > minX &&
|
||||
node.x < maxX &&
|
||||
node.y + node.height > minY &&
|
||||
node.y < maxY
|
||||
node.x + node.width > localMinX &&
|
||||
node.x < localMaxX &&
|
||||
node.y + node.height > localMinY &&
|
||||
node.y < localMaxY
|
||||
) {
|
||||
hits.push(node.id)
|
||||
}
|
||||
|
|
@ -1078,10 +1160,29 @@ export function useCanvasInput(
|
|||
if (store.state.editingTextId) return
|
||||
|
||||
const { cx, cy } = getCoords(e)
|
||||
const hit =
|
||||
hitTestSectionTitle(cx, cy) ??
|
||||
|
||||
const selectedId = store.state.selectedIds.size === 1
|
||||
? [...store.state.selectedIds][0]
|
||||
: undefined
|
||||
const selectedNode = selectedId ? store.graph.getNode(selectedId) : undefined
|
||||
const canEnter = selectedNode && selectedId
|
||||
&& store.graph.isContainer(selectedId) && !selectedNode.locked
|
||||
|
||||
if (canEnter) {
|
||||
store.enterContainer(selectedId)
|
||||
const useDeep = selectedNode.type === 'COMPONENT' || selectedNode.type === 'INSTANCE'
|
||||
const hit = hitTestInScope(cx, cy, useDeep)
|
||||
if (hit) {
|
||||
store.select([hit.id])
|
||||
} else {
|
||||
store.clearSelection()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const hit = hitTestSectionTitle(cx, cy) ??
|
||||
hitTestComponentLabel(cx, cy) ??
|
||||
store.graph.hitTestDeep(cx, cy, store.state.currentPageId)
|
||||
hitTestInScope(cx, cy, true)
|
||||
if (!hit) return
|
||||
|
||||
if (hit.type === 'TEXT') {
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: Edito
|
|||
store.state.selectedIds,
|
||||
{
|
||||
hoveredNodeId: store.state.hoveredNodeId,
|
||||
enteredContainerId: store.state.enteredContainerId,
|
||||
editingTextId: store.state.editingTextId,
|
||||
textEditor: store.textEditor,
|
||||
marquee: store.state.marquee,
|
||||
|
|
|
|||
|
|
@ -210,6 +210,10 @@ export function useKeyboard() {
|
|||
store.penCancel()
|
||||
return
|
||||
}
|
||||
if (store.state.enteredContainerId) {
|
||||
store.exitContainer()
|
||||
return
|
||||
}
|
||||
store.clearSelection()
|
||||
store.setTool('SELECT')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -204,7 +204,8 @@ export function createEditorStore() {
|
|||
actionToast: null as string | null,
|
||||
mobileDrawerSnap: 'closed' as 'closed' | 'half' | 'full',
|
||||
clipboardHtml: '',
|
||||
autosaveEnabled: false
|
||||
autosaveEnabled: false,
|
||||
enteredContainerId: null as string | null
|
||||
})
|
||||
|
||||
const AUTOSAVE_DELAY = 3000
|
||||
|
|
@ -314,6 +315,7 @@ export function createEditorStore() {
|
|||
|
||||
// Switch
|
||||
state.currentPageId = pageId
|
||||
state.enteredContainerId = null
|
||||
clearSelection()
|
||||
|
||||
// Restore viewport
|
||||
|
|
@ -380,6 +382,29 @@ export function createEditorStore() {
|
|||
state.selectedIds = new Set()
|
||||
}
|
||||
|
||||
function validateEnteredContainer() {
|
||||
if (state.enteredContainerId && !graph.getNode(state.enteredContainerId)) {
|
||||
state.enteredContainerId = null
|
||||
}
|
||||
}
|
||||
|
||||
function enterContainer(id: string) {
|
||||
state.enteredContainerId = id
|
||||
}
|
||||
|
||||
function exitContainer() {
|
||||
const entered = state.enteredContainerId
|
||||
if (!entered) return
|
||||
const node = graph.getNode(entered)
|
||||
const parentId = node?.parentId
|
||||
if (parentId && parentId !== state.currentPageId) {
|
||||
state.enteredContainerId = parentId
|
||||
} else {
|
||||
state.enteredContainerId = null
|
||||
}
|
||||
state.selectedIds = new Set(entered ? [entered] : [])
|
||||
}
|
||||
|
||||
function setMarquee(rect: Rect | null) {
|
||||
state.marquee = rect
|
||||
requestRepaint()
|
||||
|
|
@ -2063,7 +2088,7 @@ export function createEditorStore() {
|
|||
const entries: Array<{ id: string; parentId: string; snapshot: SceneNode; index: number }> = []
|
||||
for (const id of state.selectedIds) {
|
||||
const node = graph.getNode(id)
|
||||
if (!node) continue
|
||||
if (!node || node.locked) continue
|
||||
const parentId = node.parentId ?? state.currentPageId
|
||||
const parent = graph.getNode(parentId)
|
||||
const index = parent?.childIds.indexOf(id) ?? -1
|
||||
|
|
@ -2252,10 +2277,12 @@ export function createEditorStore() {
|
|||
|
||||
function undoAction() {
|
||||
undo.undo()
|
||||
validateEnteredContainer()
|
||||
}
|
||||
|
||||
function redoAction() {
|
||||
undo.redo()
|
||||
validateEnteredContainer()
|
||||
requestRender()
|
||||
}
|
||||
|
||||
|
|
@ -2375,6 +2402,9 @@ export function createEditorStore() {
|
|||
setTool,
|
||||
select,
|
||||
clearSelection,
|
||||
enterContainer,
|
||||
exitContainer,
|
||||
validateEnteredContainer,
|
||||
selectAll,
|
||||
setMarquee,
|
||||
setSnapGuides,
|
||||
|
|
|
|||
386
tests/engine/hit-test-scope.test.ts
Normal file
386
tests/engine/hit-test-scope.test.ts
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
import { SceneGraph } from '@open-pencil/core'
|
||||
|
||||
function pageId(graph: SceneGraph) {
|
||||
return graph.getPages()[0].id
|
||||
}
|
||||
|
||||
describe('hitTest — group behavior', () => {
|
||||
function setup() {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
const group = graph.createNode('GROUP', page, {
|
||||
name: 'Group',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
const child = graph.createNode('RECTANGLE', group.id, {
|
||||
name: 'Rect',
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 50,
|
||||
height: 50
|
||||
})
|
||||
return { graph, page, group, child }
|
||||
}
|
||||
|
||||
test('single click on child returns GROUP (not child)', () => {
|
||||
const { graph, page, child } = setup()
|
||||
const hit = graph.hitTest(110, 110, page)
|
||||
expect(hit).not.toBeNull()
|
||||
expect(hit!.type).toBe('GROUP')
|
||||
})
|
||||
|
||||
test('hitTestDeep on child returns child directly', () => {
|
||||
const { graph, page, child } = setup()
|
||||
const hit = graph.hitTestDeep(110, 110, page)
|
||||
expect(hit).not.toBeNull()
|
||||
expect(hit!.id).toBe(child.id)
|
||||
})
|
||||
|
||||
test('hitTest with scope=group returns child', () => {
|
||||
const { graph, group } = setup()
|
||||
const hit = graph.hitTest(10, 10, group.id)
|
||||
expect(hit).not.toBeNull()
|
||||
expect(hit!.name).toBe('Rect')
|
||||
})
|
||||
|
||||
test('hitTest miss inside group returns null', () => {
|
||||
const { graph, group } = setup()
|
||||
const hit = graph.hitTest(180, 180, group.id)
|
||||
expect(hit).toBeNull()
|
||||
})
|
||||
|
||||
test('click outside group returns null', () => {
|
||||
const { graph, page } = setup()
|
||||
const hit = graph.hitTest(50, 50, page)
|
||||
expect(hit).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hitTest — nested groups', () => {
|
||||
function setup() {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
const groupA = graph.createNode('GROUP', page, {
|
||||
name: 'GroupA',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 300,
|
||||
height: 300
|
||||
})
|
||||
const groupB = graph.createNode('GROUP', groupA.id, {
|
||||
name: 'GroupB',
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
const rect = graph.createNode('RECTANGLE', groupB.id, {
|
||||
name: 'DeepRect',
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 30,
|
||||
height: 30
|
||||
})
|
||||
return { graph, page, groupA, groupB, rect }
|
||||
}
|
||||
|
||||
test('click from page scope returns GroupA', () => {
|
||||
const { graph, page } = setup()
|
||||
const hit = graph.hitTest(160, 160, page)
|
||||
expect(hit).not.toBeNull()
|
||||
expect(hit!.name).toBe('GroupA')
|
||||
})
|
||||
|
||||
test('click with scope=GroupA returns GroupB', () => {
|
||||
const { graph, groupA } = setup()
|
||||
const hit = graph.hitTest(60, 60, groupA.id)
|
||||
expect(hit).not.toBeNull()
|
||||
expect(hit!.name).toBe('GroupB')
|
||||
})
|
||||
|
||||
test('click with scope=GroupB returns DeepRect', () => {
|
||||
const { graph, groupB } = setup()
|
||||
const hit = graph.hitTest(10, 10, groupB.id)
|
||||
expect(hit).not.toBeNull()
|
||||
expect(hit!.name).toBe('DeepRect')
|
||||
})
|
||||
|
||||
test('hitTestDeep from page returns deepest child', () => {
|
||||
const { graph, page, rect } = setup()
|
||||
const hit = graph.hitTestDeep(160, 160, page)
|
||||
expect(hit).not.toBeNull()
|
||||
expect(hit!.id).toBe(rect.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hitTest — locked nodes', () => {
|
||||
test('locked container blocks enter (returns container, not child)', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
const frame = graph.createNode('FRAME', page, {
|
||||
name: 'LockedFrame',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 200,
|
||||
locked: true
|
||||
})
|
||||
graph.createNode('RECTANGLE', frame.id, {
|
||||
name: 'Child',
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 50,
|
||||
height: 50
|
||||
})
|
||||
|
||||
const hit = graph.hitTestDeep(10, 10, page)
|
||||
expect(hit).not.toBeNull()
|
||||
expect(hit!.name).toBe('LockedFrame')
|
||||
})
|
||||
|
||||
test('locked leaf node is still clickable', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
graph.createNode('RECTANGLE', page, {
|
||||
name: 'LockedRect',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
locked: true
|
||||
})
|
||||
|
||||
const hit = graph.hitTest(25, 25, page)
|
||||
expect(hit).not.toBeNull()
|
||||
expect(hit!.name).toBe('LockedRect')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hitTest — visibility', () => {
|
||||
test('invisible node is not hit', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
graph.createNode('RECTANGLE', page, {
|
||||
name: 'Hidden',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
visible: false
|
||||
})
|
||||
|
||||
const hit = graph.hitTest(25, 25, page)
|
||||
expect(hit).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scene graph — locked node operations', () => {
|
||||
test('locked node cannot be deleted via filter', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
const rect = graph.createNode('RECTANGLE', page, {
|
||||
name: 'LockedRect',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
locked: true
|
||||
})
|
||||
|
||||
const node = graph.getNode(rect.id)
|
||||
expect(node).not.toBeNull()
|
||||
expect(node!.locked).toBe(true)
|
||||
})
|
||||
|
||||
test('lock can be toggled', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
const rect = graph.createNode('RECTANGLE', page, {
|
||||
name: 'Rect',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50
|
||||
})
|
||||
|
||||
expect(graph.getNode(rect.id)!.locked).toBe(false)
|
||||
graph.updateNode(rect.id, { locked: true })
|
||||
expect(graph.getNode(rect.id)!.locked).toBe(true)
|
||||
graph.updateNode(rect.id, { locked: false })
|
||||
expect(graph.getNode(rect.id)!.locked).toBe(false)
|
||||
})
|
||||
|
||||
test('visibility can be toggled', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
const rect = graph.createNode('RECTANGLE', page, {
|
||||
name: 'Rect',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50
|
||||
})
|
||||
|
||||
expect(graph.getNode(rect.id)!.visible).toBe(true)
|
||||
graph.updateNode(rect.id, { visible: false })
|
||||
expect(graph.getNode(rect.id)!.visible).toBe(false)
|
||||
graph.updateNode(rect.id, { visible: true })
|
||||
expect(graph.getNode(rect.id)!.visible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hitTest — frame with children', () => {
|
||||
test('frame scope hit test finds children', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
const frame = graph.createNode('FRAME', page, {
|
||||
name: 'Frame',
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
const child = graph.createNode('RECTANGLE', frame.id, {
|
||||
name: 'InnerRect',
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 40,
|
||||
height: 40
|
||||
})
|
||||
|
||||
const hit = graph.hitTest(20, 20, frame.id)
|
||||
expect(hit).not.toBeNull()
|
||||
expect(hit!.id).toBe(child.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hitTest — opaque containers (COMPONENT/INSTANCE)', () => {
|
||||
test('hitTest on COMPONENT returns component itself (not child)', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
const comp = graph.createNode('COMPONENT', page, {
|
||||
name: 'MyComp',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
graph.createNode('RECTANGLE', comp.id, {
|
||||
name: 'CompChild',
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 30,
|
||||
height: 30
|
||||
})
|
||||
|
||||
const hit = graph.hitTest(10, 10, page)
|
||||
expect(hit).not.toBeNull()
|
||||
expect(hit!.name).toBe('MyComp')
|
||||
})
|
||||
|
||||
test('hitTestDeep inside COMPONENT scope finds child', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
const comp = graph.createNode('COMPONENT', page, {
|
||||
name: 'MyComp',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
const child = graph.createNode('RECTANGLE', comp.id, {
|
||||
name: 'CompChild',
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 30,
|
||||
height: 30
|
||||
})
|
||||
|
||||
const hit = graph.hitTestDeep(10, 10, comp.id)
|
||||
expect(hit).not.toBeNull()
|
||||
expect(hit!.id).toBe(child.id)
|
||||
})
|
||||
|
||||
test('hitTest on INSTANCE returns instance itself', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
const inst = graph.createNode('INSTANCE', page, {
|
||||
name: 'MyInstance',
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 80,
|
||||
height: 80
|
||||
})
|
||||
graph.createNode('RECTANGLE', inst.id, {
|
||||
name: 'InstChild',
|
||||
x: 5,
|
||||
y: 5,
|
||||
width: 20,
|
||||
height: 20
|
||||
})
|
||||
|
||||
const hit = graph.hitTest(55, 55, page)
|
||||
expect(hit).not.toBeNull()
|
||||
expect(hit!.name).toBe('MyInstance')
|
||||
})
|
||||
|
||||
test('hitTestDeep inside INSTANCE scope finds child', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
const inst = graph.createNode('INSTANCE', page, {
|
||||
name: 'MyInstance',
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 80,
|
||||
height: 80
|
||||
})
|
||||
const child = graph.createNode('RECTANGLE', inst.id, {
|
||||
name: 'InstChild',
|
||||
x: 5,
|
||||
y: 5,
|
||||
width: 20,
|
||||
height: 20
|
||||
})
|
||||
|
||||
const hit = graph.hitTestDeep(5, 5, inst.id)
|
||||
expect(hit).not.toBeNull()
|
||||
expect(hit!.id).toBe(child.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hitTest — absolute position and scope offset', () => {
|
||||
test('scoped hitTest accounts for parent offset', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
const frame = graph.createNode('FRAME', page, {
|
||||
name: 'Frame',
|
||||
x: 200,
|
||||
y: 300,
|
||||
width: 400,
|
||||
height: 400
|
||||
})
|
||||
const child = graph.createNode('RECTANGLE', frame.id, {
|
||||
name: 'Child',
|
||||
x: 50,
|
||||
y: 60,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
|
||||
const abs = graph.getAbsolutePosition(frame.id)
|
||||
expect(abs.x).toBe(200)
|
||||
expect(abs.y).toBe(300)
|
||||
|
||||
const hit = graph.hitTest(50, 60, frame.id)
|
||||
expect(hit).not.toBeNull()
|
||||
expect(hit!.id).toBe(child.id)
|
||||
|
||||
const missHit = graph.hitTest(250, 360, frame.id)
|
||||
expect(missHit).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -359,16 +359,16 @@ describe('hitTest', () => {
|
|||
expect(graph.hitTest(100, 100, pageId(graph))?.id).toBe(frame.id)
|
||||
})
|
||||
|
||||
test('group is always click-through', () => {
|
||||
test('group returns group on hit, not child (Figma-style)', () => {
|
||||
const graph = new SceneGraph()
|
||||
const groupId = graph.createNode('GROUP', pageId(graph), {
|
||||
name: 'Group', x: 0, y: 0, width: 200, height: 200,
|
||||
}).id
|
||||
const childId = graph.createNode('RECTANGLE', groupId, {
|
||||
graph.createNode('RECTANGLE', groupId, {
|
||||
name: 'Child', x: 10, y: 10, width: 30, height: 30,
|
||||
}).id
|
||||
// Hit child through group
|
||||
expect(graph.hitTest(20, 20, pageId(graph))?.id).toBe(childId)
|
||||
})
|
||||
// Hit returns group (single click selects group, dblclick enters)
|
||||
expect(graph.hitTest(20, 20, pageId(graph))?.id).toBe(groupId)
|
||||
// Miss in group's empty area
|
||||
expect(graph.hitTest(150, 150, pageId(graph))).toBeNull()
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue