1.1.3: multi-select on the stage — marquee, group transform, panel
- Click-and-drag on the empty stage starts a layer marquee with touch (not enclose) semantics — every unlocked layer whose bbox overlaps the drag rect joins the selection. Tiny clicks still deselect; shift adds to the existing selection on both clicks and marquees. Polygon-vertex lasso stays available when a single polygon is the active selection. - Konva transformer now wraps every selected layer in one shared bounding box (polygons fold in for mixed selections; vertex handles + the vertex lasso suppress to avoid fighting the box). rotateEnabled flips off on multi to skip the ambiguous shared- pivot rotation. Drag of any selected node mirrors the delta onto every sibling Konva node so the whole group travels together; transformend persists each layer's final pose, not just the anchor's. - liveLayerPose became a per-id map (liveLayerPoses) so the reactive shape configs don't snap non-focused siblings back to their sampled pre-transform pose mid-gesture. - Plain click on a member of an existing multi-selection no longer collapses to single-select before the drag begins. - Arrow-key nudge + Timeline + keyframe button + Properties panel all fan out across the multi-selection: every editable layer gets the value, kind-specific panel sections (text, polygon, fill, stroke, blur, shadow, mask, button) collapse to leave just Transform + Opacity, and the trash button labels with the selection count. - Click on empty stage with a polygon selected now deselects the layer too instead of leaving the polygon stuck in the panel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
289fa770b0
commit
1f2812c64d
8 files changed
+1168
-157
No files matched your search
@@ -4445,6 +4445,22 @@ export function registerAllTests(): void {
|
||||
expect(ci.x).toBe(before - 1)
|
||||
})
|
||||
|
||||
test('ArrowRight nudges every layer in a multi-selection', async () => {
|
||||
const s = makeFreshStore()
|
||||
await clearKeys()
|
||||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const lA = s.addLayer(rect.id)!
|
||||
const lB = s.addLayer(rect.id)!
|
||||
s.selectedLayerIds = [lA.id, lB.id]
|
||||
s.selectedLayerId = lB.id
|
||||
const beforeA = sampleLayer(lA, s.playhead).x
|
||||
const beforeB = sampleLayer(lB, s.playhead).x
|
||||
arrow('ArrowRight')
|
||||
await nextTick()
|
||||
expect(sampleLayer(lA, s.playhead).x).toBe(beforeA + 1)
|
||||
expect(sampleLayer(lB, s.playhead).x).toBe(beforeB + 1)
|
||||
})
|
||||
|
||||
test('nudge is skipped for a locked layer', async () => {
|
||||
const s = makeFreshStore()
|
||||
await clearKeys()
|
||||
@@ -4486,6 +4502,63 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('timeline — add-keyframe button fans across multi-selection', () => {
|
||||
// + keyframe used to write only to the anchor layer. With a
|
||||
// multi-selection the user expects every member to pick up the
|
||||
// frame so grouped motion stays in sync.
|
||||
test('clicking + keyframe with two layers selected gives both one', async () => {
|
||||
const s = makeFreshStore()
|
||||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const lA = s.addLayer(rect.id)!
|
||||
const lB = s.addLayer(rect.id)!
|
||||
s.setPlayhead(1.5)
|
||||
s.selectedLayerIds = [lA.id, lB.id]
|
||||
s.selectedLayerId = lB.id
|
||||
const handle = mountComponent(Timeline, {})
|
||||
try {
|
||||
await nextTick()
|
||||
const btn = handle.host.querySelector('.add-kf-btn') as HTMLButtonElement
|
||||
expect(btn).toBeTruthy()
|
||||
expect(btn.disabled).toBe(false)
|
||||
const beforeA = lA.keyframes.length
|
||||
const beforeB = lB.keyframes.length
|
||||
btn.click()
|
||||
await nextTick()
|
||||
expect(lA.keyframes.length).toBe(beforeA + 1)
|
||||
expect(lB.keyframes.length).toBe(beforeB + 1)
|
||||
// Both land on the (snap-resolved) playhead.
|
||||
expect(lA.keyframes.some((k) => Math.abs(k.time - 1.5) < 1e-3)).toBe(true)
|
||||
expect(lB.keyframes.some((k) => Math.abs(k.time - 1.5) < 1e-3)).toBe(true)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('canCreateKf stays truthy when only some layers in the selection are unlocked', async () => {
|
||||
const s = makeFreshStore()
|
||||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const lA = s.addLayer(rect.id)!
|
||||
const lB = s.addLayer(rect.id)!
|
||||
s.setTrackLocked(lA.id, true)
|
||||
s.selectedLayerIds = [lA.id, lB.id]
|
||||
s.selectedLayerId = lA.id // anchor is the locked one
|
||||
const handle = mountComponent(Timeline, {})
|
||||
try {
|
||||
await nextTick()
|
||||
const btn = handle.host.querySelector('.add-kf-btn') as HTMLButtonElement
|
||||
// Enabled because lB is still unlocked — the handler will
|
||||
// skip the locked lA silently.
|
||||
expect(btn.disabled).toBe(false)
|
||||
btn.click()
|
||||
await nextTick()
|
||||
// Only the unlocked one got a new keyframe.
|
||||
expect(lB.keyframes.length).toBeGreaterThan(0)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
suite('timeline — backspace deletes selected layer', () => {
|
||||
// Regression: Backspace / Delete used to only remove selected
|
||||
// keyframes and markers. The selected layer track had no shortcut
|
||||
@@ -9011,6 +9084,524 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — CanvasStage layer box-select', () => {
|
||||
// Drag on empty stage to marquee-select layers. Touch (not
|
||||
// enclose) semantics: a layer whose bbox overlaps the drag rect
|
||||
// at all is added to the selection. Reuses the same boxSelect
|
||||
// state as the polygon-vertex lasso; handleStageMouseDown
|
||||
// picks the mode per selection context.
|
||||
|
||||
async function mountCanvasWithKonva() {
|
||||
const VueKonva = (await import('vue-konva')).default
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-test-host', '')
|
||||
host.style.width = '800px'
|
||||
host.style.height = '600px'
|
||||
document.body.appendChild(host)
|
||||
const app = createApp({ render() { return h(CanvasStage, {}) } })
|
||||
app.use(VueKonva)
|
||||
app.directive('tooltip', vTooltip)
|
||||
app.mount(host)
|
||||
return {
|
||||
host, app,
|
||||
unmount() { app.unmount(); host.remove() },
|
||||
}
|
||||
}
|
||||
async function getKonvaStage() {
|
||||
const Konva = (await import('konva')).default
|
||||
return Konva.stages[Konva.stages.length - 1]
|
||||
}
|
||||
|
||||
/** Dispatch a synthesised Konva pointerdown → mousemove →
|
||||
* mouseup on the stage at world coords (x0,y0) → (x1,y1).
|
||||
* Fires on the stage itself so the mousedown handler's
|
||||
* `target === stage` branch kicks in. */
|
||||
function simulateBoxDrag(
|
||||
stage: { fire: (type: string, evt: unknown, bubble: boolean) => void; getPointerPosition: () => unknown; width: () => number; height: () => number },
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
) {
|
||||
// pointerToWorld uses stage.getPointerPosition(). We overwrite
|
||||
// it for the duration of the drag so the handler sees the
|
||||
// coords we want regardless of DOM layout.
|
||||
let current = { ...from }
|
||||
const stageObj = stage as unknown as {
|
||||
getPointerPosition: () => { x: number; y: number }
|
||||
}
|
||||
const orig = stageObj.getPointerPosition
|
||||
stageObj.getPointerPosition = () => current
|
||||
const fire = stage.fire.bind(stage)
|
||||
try {
|
||||
fire('mousedown', {
|
||||
target: stage, type: 'mousedown',
|
||||
evt: { button: 0, ctrlKey: false, shiftKey: false },
|
||||
}, true)
|
||||
current = { ...to }
|
||||
fire('mousemove', {
|
||||
target: stage, type: 'mousemove',
|
||||
evt: { button: 0, ctrlKey: false, shiftKey: false },
|
||||
}, true)
|
||||
fire('mouseup', {
|
||||
target: stage, type: 'mouseup',
|
||||
evt: { button: 0, ctrlKey: false, shiftKey: false },
|
||||
}, true)
|
||||
} finally {
|
||||
stageObj.getPointerPosition = orig
|
||||
}
|
||||
}
|
||||
|
||||
/** World → stage-pointer conversion. After mount the auto-fit
|
||||
* resizes zoom/pan; handing the simulator world coords directly
|
||||
* would miss because pointerToWorld divides by zoom. Read the
|
||||
* current view transform from the store and invert. */
|
||||
function worldToStage(s: ReturnType<typeof useSceneStore>, x: number, y: number) {
|
||||
const z = s.editor.canvasZoom || 1
|
||||
return { x: x * z + s.editor.canvasViewX, y: y * z + s.editor.canvasViewY }
|
||||
}
|
||||
|
||||
test('dragging a box across two layers selects both', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||
const a = s.addAsset({ kind: 'rect', name: 'A', width: 40, height: 40, fill: '#f00' })
|
||||
const b = s.addAsset({ kind: 'rect', name: 'B', width: 40, height: 40, fill: '#0f0' })
|
||||
const lA = s.addLayer(a.id)!
|
||||
const lB = s.addLayer(b.id)!
|
||||
// Place A around world (100, 100), B around (300, 100). A box
|
||||
// from (50, 50) to (350, 150) touches both.
|
||||
s.upsertKeyframe(lA.id, 0, { x: 100, y: 100 })
|
||||
s.upsertKeyframe(lB.id, 0, { x: 300, y: 100 })
|
||||
const handle = await mountCanvasWithKonva()
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(60) // let auto-fit settle
|
||||
const stage = await getKonvaStage() as unknown as {
|
||||
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||
getPointerPosition: () => unknown
|
||||
width: () => number
|
||||
height: () => number
|
||||
}
|
||||
simulateBoxDrag(
|
||||
stage,
|
||||
worldToStage(s, 50, 50),
|
||||
worldToStage(s, 350, 150),
|
||||
)
|
||||
await nextTick()
|
||||
expect(s.selectedLayerIds.length).toBe(2)
|
||||
expect(s.selectedLayerIds).toContain(lA.id)
|
||||
expect(s.selectedLayerIds).toContain(lB.id)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('a tiny drag (click) on empty stage deselects instead of multi-selecting', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||
const a = s.addAsset({ kind: 'rect', name: 'A', width: 40, height: 40, fill: '#f00' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.upsertKeyframe(l.id, 0, { x: 200, y: 200 })
|
||||
s.selectLayer(l.id)
|
||||
const handle = await mountCanvasWithKonva()
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(60)
|
||||
const stage = await getKonvaStage() as unknown as {
|
||||
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||
getPointerPosition: () => unknown
|
||||
width: () => number
|
||||
height: () => number
|
||||
}
|
||||
const p = worldToStage(s, 10, 10)
|
||||
simulateBoxDrag(stage, p, p)
|
||||
await nextTick()
|
||||
expect(s.selectedLayerId).toBe(null)
|
||||
expect(s.selectedLayerIds).toEqual([])
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('plain click on a layer already in the multi-selection keeps the selection intact', async () => {
|
||||
// Regression: clicking one of the shapes in a multi-select
|
||||
// used to collapse the selection to that single layer before
|
||||
// Konva's drag machinery could kick in — the would-be group
|
||||
// drag ended up dragging only the clicked shape. Fix:
|
||||
// handleStageMouseDown short-circuits when the clicked layer
|
||||
// is already part of the existing multi-selection, leaving
|
||||
// selectedLayerIds alone so the drag picks up every member.
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||
const lA = s.addLayer(rect.id)!
|
||||
const lB = s.addLayer(rect.id)!
|
||||
s.upsertKeyframe(lA.id, 0, { x: 100, y: 100 })
|
||||
s.upsertKeyframe(lB.id, 0, { x: 300, y: 100 })
|
||||
s.selectedLayerIds = [lA.id, lB.id]
|
||||
s.selectedLayerId = lB.id
|
||||
const handle = await mountCanvasWithKonva()
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(60)
|
||||
const stage = await getKonvaStage() as unknown as {
|
||||
findOne: (sel: string) => unknown
|
||||
}
|
||||
const node = stage.findOne(`#${lA.id}`) as {
|
||||
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||
}
|
||||
node.fire(
|
||||
'mousedown',
|
||||
{
|
||||
target: node,
|
||||
type: 'mousedown',
|
||||
evt: { button: 0, ctrlKey: false, shiftKey: false },
|
||||
},
|
||||
true,
|
||||
)
|
||||
await nextTick()
|
||||
// Both still selected; single-layer replacement was the bug.
|
||||
expect(s.selectedLayerIds.length).toBe(2)
|
||||
expect(s.selectedLayerIds).toContain(lA.id)
|
||||
expect(s.selectedLayerIds).toContain(lB.id)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('shift+click on a second layer adds it to the existing selection', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||
const lA = s.addLayer(rect.id)!
|
||||
const lB = s.addLayer(rect.id)!
|
||||
s.upsertKeyframe(lA.id, 0, { x: 100, y: 100 })
|
||||
s.upsertKeyframe(lB.id, 0, { x: 300, y: 100 })
|
||||
s.selectLayer(lA.id)
|
||||
const handle = await mountCanvasWithKonva()
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(60)
|
||||
const stage = await getKonvaStage() as unknown as {
|
||||
findOne: (sel: string) => unknown
|
||||
}
|
||||
const bNode = stage.findOne(`#${lB.id}`) as {
|
||||
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||
}
|
||||
// Dispatch mousedown directly on B's node with shiftKey set.
|
||||
// The stage mousedown handler receives the event target =
|
||||
// bNode, the shift branch calls toggleLayerSelection, and
|
||||
// both A and B end up selected.
|
||||
bNode.fire(
|
||||
'mousedown',
|
||||
{
|
||||
target: bNode,
|
||||
type: 'mousedown',
|
||||
evt: { button: 0, ctrlKey: false, shiftKey: true },
|
||||
},
|
||||
true,
|
||||
)
|
||||
await nextTick()
|
||||
expect(s.selectedLayerIds.length).toBe(2)
|
||||
expect(s.selectedLayerIds).toContain(lA.id)
|
||||
expect(s.selectedLayerIds).toContain(lB.id)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('shift+click on empty stage keeps the current selection', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||
const lA = s.addLayer(rect.id)!
|
||||
const lB = s.addLayer(rect.id)!
|
||||
s.upsertKeyframe(lA.id, 0, { x: 100, y: 100 })
|
||||
s.upsertKeyframe(lB.id, 0, { x: 300, y: 100 })
|
||||
s.selectedLayerIds = [lA.id, lB.id]
|
||||
s.selectedLayerId = lB.id
|
||||
const handle = await mountCanvasWithKonva()
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(60)
|
||||
const stage = await getKonvaStage() as unknown as {
|
||||
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||
getPointerPosition: () => unknown
|
||||
width: () => number
|
||||
height: () => number
|
||||
}
|
||||
// Shift + tiny drag on empty corner — must leave the
|
||||
// existing multi-selection alone, not wipe it.
|
||||
const p = worldToStage(s, 500, 500)
|
||||
const stageObj = stage as unknown as {
|
||||
getPointerPosition: () => { x: number; y: number }
|
||||
}
|
||||
const orig = stageObj.getPointerPosition
|
||||
stageObj.getPointerPosition = () => p
|
||||
try {
|
||||
stage.fire('mousedown', {
|
||||
target: stage, type: 'mousedown',
|
||||
evt: { button: 0, ctrlKey: false, shiftKey: true },
|
||||
}, true)
|
||||
stage.fire('mouseup', {
|
||||
target: stage, type: 'mouseup',
|
||||
evt: { button: 0, ctrlKey: false, shiftKey: true },
|
||||
}, true)
|
||||
} finally {
|
||||
stageObj.getPointerPosition = orig
|
||||
}
|
||||
await nextTick()
|
||||
expect(s.selectedLayerIds.length).toBe(2)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('transformend on one node in a multi-selection persists every node', async () => {
|
||||
// Multi-select transform writes to every attached layer's
|
||||
// keyframe, not just the anchor that fired the event. Covers
|
||||
// scale — rotation is intentionally OFF for multi-select (see
|
||||
// the rotateEnabled test below) but scale + translation still
|
||||
// work as a group.
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||
const lA = s.addLayer(rect.id)!
|
||||
const lB = s.addLayer(rect.id)!
|
||||
s.upsertKeyframe(lA.id, 0, { x: 100, y: 100 })
|
||||
s.upsertKeyframe(lB.id, 0, { x: 300, y: 100 })
|
||||
s.selectedLayerIds = [lA.id, lB.id]
|
||||
s.selectedLayerId = lB.id
|
||||
const handle = await mountCanvasWithKonva()
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(60)
|
||||
const stage = await getKonvaStage() as unknown as {
|
||||
findOne: (sel: string) => unknown
|
||||
}
|
||||
const nodeA = stage.findOne(`#${lA.id}`) as unknown as {
|
||||
x: (v?: number) => number
|
||||
y: (v?: number) => number
|
||||
scaleX: (v?: number) => number
|
||||
scaleY: (v?: number) => number
|
||||
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||
}
|
||||
const nodeB = stage.findOne(`#${lB.id}`) as unknown as {
|
||||
x: (v?: number) => number
|
||||
y: (v?: number) => number
|
||||
scaleX: (v?: number) => number
|
||||
scaleY: (v?: number) => number
|
||||
}
|
||||
// 2x scale on both nodes — simulate what Konva's transformer
|
||||
// would do when the user drags a corner anchor. Then fire
|
||||
// transformend on one node; handleTransformEnd fans the new
|
||||
// pose out across the entire selection.
|
||||
nodeA.x(120)
|
||||
nodeA.y(120)
|
||||
nodeA.scaleX(2)
|
||||
nodeA.scaleY(2)
|
||||
nodeB.x(340)
|
||||
nodeB.y(120)
|
||||
nodeB.scaleX(2)
|
||||
nodeB.scaleY(2)
|
||||
nodeA.fire(
|
||||
'transformend',
|
||||
{ target: nodeA, type: 'transformend' },
|
||||
true,
|
||||
)
|
||||
await nextTick()
|
||||
const sampA = sampleLayer(lA, s.playhead)
|
||||
const sampB = sampleLayer(lB, s.playhead)
|
||||
expect(sampA.scaleX).toBeCloseTo(2, 4)
|
||||
expect(sampB.scaleX).toBeCloseTo(2, 4)
|
||||
expect(sampA.x).toBeCloseTo(120, 4)
|
||||
expect(sampB.x).toBeCloseTo(340, 4)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('multi-select disables rotateEnabled on the transformer config', async () => {
|
||||
// Rotation on a group pins every node to the transformer
|
||||
// bbox centre, which isn't what users want per-layer — we
|
||||
// turn the rotate handle off whenever more than one node is
|
||||
// wrapped so the gesture is simply unavailable.
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||
const lA = s.addLayer(rect.id)!
|
||||
const lB = s.addLayer(rect.id)!
|
||||
s.upsertKeyframe(lA.id, 0, { x: 100, y: 100 })
|
||||
s.upsertKeyframe(lB.id, 0, { x: 300, y: 100 })
|
||||
s.selectedLayerIds = [lA.id, lB.id]
|
||||
s.selectedLayerId = lB.id
|
||||
const handle = await mountCanvasWithKonva()
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(60)
|
||||
const stage = await getKonvaStage() as unknown as {
|
||||
find: (sel: string) => unknown[]
|
||||
}
|
||||
const transformers = stage.find('Transformer') as Array<{
|
||||
nodes: () => unknown[]
|
||||
rotateEnabled: () => boolean
|
||||
}>
|
||||
const main = transformers.find((tr) => tr.nodes().length > 1)
|
||||
expect(main).toBeTruthy()
|
||||
expect(main!.rotateEnabled()).toBe(false)
|
||||
// Drop back to a single-layer selection → rotate is back.
|
||||
s.selectLayer(lA.id)
|
||||
await nextTick()
|
||||
await tick(20)
|
||||
const refreshed = (stage.find('Transformer') as Array<{
|
||||
nodes: () => unknown[]
|
||||
rotateEnabled: () => boolean
|
||||
}>).find((tr) => tr.nodes().length === 1)
|
||||
expect(refreshed).toBeTruthy()
|
||||
expect(refreshed!.rotateEnabled()).toBe(true)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('rect + polygon multi-selection still gets a shared bounding box', async () => {
|
||||
// Regression: the transformer used to skip every polygon to
|
||||
// keep the vertex-editing flow intact. That meant mixed
|
||||
// selections showed a box around only the rect. Fix: skip
|
||||
// polygons only when they're the SOLE selection.
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||
const poly = s.addAsset({
|
||||
kind: 'polygon', name: 'P',
|
||||
points: [{ x: 0, y: 0 }, { x: 40, y: 0 }, { x: 40, y: 40 }],
|
||||
closed: true,
|
||||
})
|
||||
const lR = s.addLayer(rect.id)!
|
||||
const lP = s.addLayer(poly.id)!
|
||||
s.upsertKeyframe(lR.id, 0, { x: 100, y: 100 })
|
||||
s.upsertKeyframe(lP.id, 0, { x: 300, y: 100 })
|
||||
s.selectedLayerIds = [lR.id, lP.id]
|
||||
s.selectedLayerId = lP.id
|
||||
const handle = await mountCanvasWithKonva()
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(60)
|
||||
const stage = await getKonvaStage() as unknown as {
|
||||
find: (sel: string) => unknown[]
|
||||
}
|
||||
const transformers = stage.find('Transformer') as Array<{
|
||||
nodes: () => Array<{ id: () => string }>
|
||||
}>
|
||||
const main = transformers.find((tr) => tr.nodes().length > 1)
|
||||
expect(main).toBeTruthy()
|
||||
const ids = main!.nodes().map((n) => n.id()).sort()
|
||||
expect(ids).toEqual([lR.id, lP.id].sort())
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('selecting multiple layers attaches the transformer to every node', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||
const lA = s.addLayer(rect.id)!
|
||||
const lB = s.addLayer(rect.id)!
|
||||
s.upsertKeyframe(lA.id, 0, { x: 100, y: 100 })
|
||||
s.upsertKeyframe(lB.id, 0, { x: 300, y: 100 })
|
||||
s.selectedLayerIds = [lA.id, lB.id]
|
||||
s.selectedLayerId = lB.id
|
||||
const handle = await mountCanvasWithKonva()
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(60)
|
||||
const stage = await getKonvaStage() as unknown as {
|
||||
find: (sel: string) => unknown[]
|
||||
}
|
||||
const transformers = stage.find('Transformer') as Array<{
|
||||
nodes: () => Array<{ id: () => string }>
|
||||
}>
|
||||
// The hit-shape transformer (button edit) is always in the
|
||||
// tree — pick whichever Transformer has >1 node attached.
|
||||
const main = transformers.find((tr) => tr.nodes().length > 1)
|
||||
expect(main).toBeTruthy()
|
||||
const attachedIds = main!.nodes().map((n) => n.id()).sort()
|
||||
expect(attachedIds).toEqual([lA.id, lB.id].sort())
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('click on empty stage deselects a currently-selected polygon', async () => {
|
||||
// Regression: with a polygon (or freshly drawn freeform)
|
||||
// selected, the empty-stage click kicked the vertex-lasso
|
||||
// code path. That only cleared vertex indices — the polygon
|
||||
// layer itself stayed selected and the properties panel
|
||||
// kept pointing at it. Fix: a tiny click with no vertex hits
|
||||
// falls through to selectLayer(null) like layer-mode does.
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||
const poly = s.addAsset({
|
||||
kind: 'polygon', name: 'P',
|
||||
points: [{ x: 0, y: 0 }, { x: 40, y: 0 }, { x: 40, y: 40 }],
|
||||
closed: true,
|
||||
})
|
||||
const layer = s.addLayer(poly.id)!
|
||||
s.upsertKeyframe(layer.id, 0, { x: 100, y: 100 })
|
||||
s.selectLayer(layer.id)
|
||||
const handle = await mountCanvasWithKonva()
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(60)
|
||||
const stage = await getKonvaStage() as unknown as {
|
||||
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||
getPointerPosition: () => unknown
|
||||
width: () => number
|
||||
height: () => number
|
||||
}
|
||||
// Click on empty corner (far from polygon + vertices).
|
||||
const p = worldToStage(s, 400, 400)
|
||||
simulateBoxDrag(stage, p, p)
|
||||
await nextTick()
|
||||
expect(s.selectedLayerId).toBe(null)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('box-select skips locked layers', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||
const unlocked = s.addLayer(rect.id)!
|
||||
const locked = s.addLayer(rect.id)!
|
||||
s.upsertKeyframe(unlocked.id, 0, { x: 100, y: 100 })
|
||||
s.upsertKeyframe(locked.id, 0, { x: 300, y: 100 })
|
||||
s.setTrackLocked(locked.id, true)
|
||||
const handle = await mountCanvasWithKonva()
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(60)
|
||||
const stage = await getKonvaStage() as unknown as {
|
||||
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||
getPointerPosition: () => unknown
|
||||
width: () => number
|
||||
height: () => number
|
||||
}
|
||||
simulateBoxDrag(
|
||||
stage,
|
||||
worldToStage(s, 50, 50),
|
||||
worldToStage(s, 350, 150),
|
||||
)
|
||||
await nextTick()
|
||||
// Only the unlocked one made it into the selection.
|
||||
expect(s.selectedLayerIds.length).toBe(1)
|
||||
expect(s.selectedLayerIds[0]).toBe(unlocked.id)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — AssetSidebar audio section', () => {
|
||||
test('lists audio assets with a duration label under the Audio heading', async () => {
|
||||
const s = makeFreshStore()
|
||||
@@ -9278,6 +9869,98 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('properties — multi-select fan-out + section gating', () => {
|
||||
function resetSectionPrefs() {
|
||||
try { localStorage.removeItem('animatr:propSections') } catch { /* noop */ }
|
||||
}
|
||||
|
||||
test('changing X with two layers selected writes to both', async () => {
|
||||
resetSectionPrefs()
|
||||
const s = makeFreshStore()
|
||||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const lA = s.addLayer(rect.id)!
|
||||
const lB = s.addLayer(rect.id)!
|
||||
s.upsertKeyframe(lA.id, 0, { x: 100 })
|
||||
s.upsertKeyframe(lB.id, 0, { x: 300 })
|
||||
s.selectedLayerIds = [lA.id, lB.id]
|
||||
s.selectedLayerId = lB.id // anchor
|
||||
const handle = mountComponent(PropertiesPanel, {})
|
||||
try {
|
||||
await nextTick()
|
||||
const xInput = handle.host.querySelector(
|
||||
'.prop-group input[type="number"]',
|
||||
) as HTMLInputElement
|
||||
expect(xInput).toBeTruthy()
|
||||
// Anchor's x is 300 — that's what the input shows.
|
||||
expect(Number(xInput.value)).toBe(300)
|
||||
xInput.value = '500'
|
||||
xInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
xInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await nextTick()
|
||||
// Both layers' sampled x === 500 (write fanned out).
|
||||
expect(sampleLayer(lA, s.playhead).x).toBe(500)
|
||||
expect(sampleLayer(lB, s.playhead).x).toBe(500)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('multi-select hides kind-specific sections (fill, stroke, polygon, text)', async () => {
|
||||
resetSectionPrefs()
|
||||
const s = makeFreshStore()
|
||||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const text = s.addAsset({
|
||||
kind: 'text', name: 'T', text: 'hi', fontSize: 24, fill: '#000',
|
||||
})
|
||||
const lA = s.addLayer(rect.id)!
|
||||
const lB = s.addLayer(text.id)!
|
||||
s.selectedLayerIds = [lA.id, lB.id]
|
||||
s.selectedLayerId = lB.id
|
||||
const handle = mountComponent(PropertiesPanel, {})
|
||||
try {
|
||||
await nextTick()
|
||||
// Kind-specific labels should NOT show on the multi panel.
|
||||
const labels = Array.from(handle.host.querySelectorAll('label')).map(
|
||||
(el) => el.textContent?.toLowerCase() ?? '',
|
||||
)
|
||||
expect(labels.some((l) => l.includes('text'))).toBe(false)
|
||||
// Fill / stroke labels: hidden too.
|
||||
expect(labels.some((l) => l.startsWith('fill'))).toBe(false)
|
||||
expect(labels.some((l) => l.startsWith('stroke'))).toBe(false)
|
||||
// Multi banner is up.
|
||||
expect(handle.host.querySelector('.multi-banner')).toBeTruthy()
|
||||
// Transform section still renders (X, Y, Scale X, Scale Y, Rotation).
|
||||
const inputs = handle.host.querySelectorAll('.prop-group input[type="number"]')
|
||||
expect(inputs.length).toBeGreaterThan(0)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('multi-select trash button labels with the count and removes every layer', async () => {
|
||||
resetSectionPrefs()
|
||||
const s = makeFreshStore()
|
||||
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const lA = s.addLayer(rect.id)!
|
||||
const lB = s.addLayer(rect.id)!
|
||||
s.selectedLayerIds = [lA.id, lB.id]
|
||||
s.selectedLayerId = lB.id
|
||||
const handle = mountComponent(PropertiesPanel, {})
|
||||
try {
|
||||
await nextTick()
|
||||
const trash = handle.host.querySelector('.danger-btn') as HTMLButtonElement
|
||||
expect(trash).toBeTruthy()
|
||||
expect(trash.textContent).toContain('2')
|
||||
trash.click()
|
||||
await nextTick()
|
||||
expect(s.scene.layers.find((x) => x.id === lA.id)).toBe(undefined)
|
||||
expect(s.scene.layers.find((x) => x.id === lB.id)).toBe(undefined)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
suite('properties — fill/stroke transparent toggle', () => {
|
||||
function findAlphaToggles(host: HTMLElement) {
|
||||
return Array.from(
|
||||
|
||||
Reference in new issue
Block a user