1.2.3: track-name shift/ctrl select, polygon level-1/level-2, cross-scope polygon ops

- Track-name click semantics in the Timeline:
  - plain → single-select that layer
  - shift → range-select every parent layer between anchor and click
    (locked + locked-group children are filtered out)
  - ctrl/cmd → toggle the clicked layer in/out of the selection
  Anchor moves to the just-clicked id like a file manager.
- Polygon / freeform selection now lands at "level 1" — the regular
  size + rotation transformer attaches like any other shape. A
  double-click promotes to level 2 (vertex handles + edge-insert
  dbl-click + tangent handles); selecting another layer drops back
  to level 1. Same UX inside the SVG sub-editor.
- New layersUsingAsset(project, assetId) helper walks every layer
  collection in the project (scenes, clips, button assets, svg
  assets). All six polygon-mutation actions (addPolygonPoint,
  removePolygonPoint, setPolygonPointCount, reversePolygonPoints,
  movePolygonVertex, insertPolygonPointOnEdge) now use it, so
  changes to the asset's points propagate to kf.props.points on
  layers in any scope. Fixes "no node added on dblclick of a
  freeform line inside an SVG asset".
- SvgLayerPanel buttons reset padding:0 — the global button reset
  was eating the 22×22 row-button area with box-sizing:border-box
  and clipping the Phosphor icons out of view.
This commit is contained in:
th committed 2026-04-25 23:39:19 +02:00
1 parent c17e2e8a9f
commit 127812d3bf
7 files changed
+581 -17

No files matched your search

+446
View File
@@ -6030,6 +6030,136 @@ export function registerAllTests(): void {
s.setPolygonClosed(asset.id, true)
expect(asset.closed).toBe(true)
})
test('insertPolygonPointOnEdge updates kf.points for layers in SVG-asset scope', () => {
// Regression: the action filtered scene.layers only, missing
// layers that live inside an SvgAsset.layers (or a button
// asset). Result: the asset.points grew but kf.props.points
// didn't, so the polygon rendered without the new node.
const s = makeFreshStore()
const svg = s.addSvgAsset('Logo', 200, 200)
s.enterSvgAssetEdit(svg.id)
const poly = s.addAsset({
kind: 'polygon', name: 'P',
points: [{ x: 0, y: 0 }, { x: 40, y: 0 }, { x: 40, y: 40 }],
fill: '#abc',
}) as PolygonAsset
const layer = s.addLayer(poly.id)!
// Pre-condition: kf.props.points already mirrors asset.points.
expect(layer.keyframes[0].props.points!.length).toBe(3)
s.insertPolygonPointOnEdge(poly.id, 1, 0.5)
expect(poly.points.length).toBe(4)
// The kf in the SVG-asset's layer must be updated too.
expect(layer.keyframes[0].props.points!.length).toBe(4)
expect(layer.keyframes[0].props.points![1].x).toBe(20)
s.exitSvgAssetEdit()
})
test('addPolygonPoint syncs kf.points for an SVG-asset layer', () => {
const s = makeFreshStore()
const svg = s.addSvgAsset('Logo', 200, 200)
s.enterSvgAssetEdit(svg.id)
const poly = s.addAsset({
kind: 'polygon', name: 'P',
points: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }],
fill: '#abc',
}) as PolygonAsset
const layer = s.addLayer(poly.id)!
s.addPolygonPoint(poly.id, 2, { x: 99, y: 88 })
expect(poly.points.length).toBe(4)
expect(layer.keyframes[0].props.points!.length).toBe(4)
expect(layer.keyframes[0].props.points![2].x).toBe(99)
s.exitSvgAssetEdit()
})
test('removePolygonPoint syncs kf.points for an SVG-asset layer', () => {
const s = makeFreshStore()
const svg = s.addSvgAsset('Logo', 200, 200)
s.enterSvgAssetEdit(svg.id)
const poly = s.addAsset({
kind: 'polygon', name: 'P',
points: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }],
fill: '#abc',
}) as PolygonAsset
const layer = s.addLayer(poly.id)!
s.removePolygonPoint(poly.id, 1)
expect(poly.points.length).toBe(3)
expect(layer.keyframes[0].props.points!.length).toBe(3)
s.exitSvgAssetEdit()
})
test('setPolygonPointCount grows + shrinks across SVG-asset layers', () => {
const s = makeFreshStore()
const svg = s.addSvgAsset('Logo', 200, 200)
s.enterSvgAssetEdit(svg.id)
const poly = s.addAsset({
kind: 'polygon', name: 'P',
points: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }],
fill: '#abc',
}) as PolygonAsset
const layer = s.addLayer(poly.id)!
s.setPolygonPointCount(poly.id, 6)
expect(poly.points.length).toBe(6)
expect(layer.keyframes[0].props.points!.length).toBe(6)
s.setPolygonPointCount(poly.id, 4)
expect(poly.points.length).toBe(4)
expect(layer.keyframes[0].props.points!.length).toBe(4)
s.exitSvgAssetEdit()
})
test('reversePolygonPoints reverses kf.points for SVG-asset layers', () => {
const s = makeFreshStore()
const svg = s.addSvgAsset('Logo', 200, 200)
s.enterSvgAssetEdit(svg.id)
const poly = s.addAsset({
kind: 'polygon', name: 'P',
points: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }],
fill: '#abc',
}) as PolygonAsset
const layer = s.addLayer(poly.id)!
const firstX = poly.points[0].x
s.reversePolygonPoints(poly.id)
const kfPts = layer.keyframes[0].props.points!
expect(kfPts[kfPts.length - 1].x).toBe(firstX)
s.exitSvgAssetEdit()
})
test('movePolygonVertex writes to the SVG-asset layer kf at playhead 0', () => {
const s = makeFreshStore()
const svg = s.addSvgAsset('Logo', 200, 200)
s.enterSvgAssetEdit(svg.id)
const poly = s.addAsset({
kind: 'polygon', name: 'P',
points: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }],
fill: '#abc',
}) as PolygonAsset
const layer = s.addLayer(poly.id)!
// SVG scope pins the playhead to 0; movePolygonVertex writes
// a kf at playhead.
s.movePolygonVertex(layer.id, 0, 77, -55)
const kf = layer.keyframes.find((k) => Math.abs(k.time) < 1e-3)!
expect(kf.props.points![0].x).toBe(77)
expect(kf.props.points![0].y).toBe(-55)
s.exitSvgAssetEdit()
})
test('insertPolygonPointOnEdge syncs kf.points for layers in a button asset', () => {
// Same cross-scope guarantee for ButtonAsset.layers — they
// also use layersUsingAsset under the hood.
const s = makeFreshStore()
const ba = s.addButtonAsset('Btn', 80, 30)
s.enterButtonAssetEdit(ba.id)
const poly = s.addAsset({
kind: 'polygon', name: 'P',
points: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }],
fill: '#abc',
}) as PolygonAsset
const layer = s.addLayer(poly.id)!
s.insertPolygonPointOnEdge(poly.id, 1, 0.5)
expect(poly.points.length).toBe(4)
expect(layer.keyframes[0].props.points!.length).toBe(4)
s.exitButtonAssetEdit()
})
})
suite('freeform drawing — lifecycle', () => {
@@ -8295,6 +8425,123 @@ export function registerAllTests(): void {
})
})
suite('ui — Timeline track-name selection (shift / ctrl)', () => {
// Click semantics on a parent layer's track-name row:
// - plain → single-select that layer
// - shift → range select from anchor to clicked, inclusive
// - ctrl/meta → toggle clicked layer in/out of the selection
test('plain click selects only that layer', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const lA = s.addLayer(a.id)!
const lB = s.addLayer(a.id)!
// Seed a multi-selection so "plain click collapses to one" is observable.
s.selectedLayerIds = [lA.id, lB.id]
s.selectedLayerId = lB.id
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const rows = handle.host.querySelectorAll('.track .track-name') as NodeListOf<HTMLElement>
// Layers render top-down newest-first, so rows[0] is lB, rows[1] is lA.
rows[1].dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await nextTick()
expect(s.selectedLayerIds).toEqual([lA.id])
expect(s.selectedLayerId).toBe(lA.id)
} finally {
handle.unmount()
}
})
test('ctrl-click toggles the clicked layer into / out of the selection', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const lA = s.addLayer(a.id)!
const lB = s.addLayer(a.id)!
s.selectLayer(lA.id)
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const rows = handle.host.querySelectorAll('.track .track-name') as NodeListOf<HTMLElement>
// ctrl+click on lB (rows[0]) → adds lB to selection.
rows[0].dispatchEvent(new MouseEvent('click', {
bubbles: true, cancelable: true, ctrlKey: true,
}))
await nextTick()
expect(s.selectedLayerIds.length).toBe(2)
expect(s.selectedLayerIds.includes(lA.id)).toBe(true)
expect(s.selectedLayerIds.includes(lB.id)).toBe(true)
// ctrl+click on lB again → removes it.
rows[0].dispatchEvent(new MouseEvent('click', {
bubbles: true, cancelable: true, ctrlKey: true,
}))
await nextTick()
expect(s.selectedLayerIds).toEqual([lA.id])
} finally {
handle.unmount()
}
})
test('shift-click range-selects every parent layer between anchor and clicked', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const lA = s.addLayer(a.id)!
const lB = s.addLayer(a.id)!
const lC = s.addLayer(a.id)!
const lD = s.addLayer(a.id)!
// addLayer prepends, so visible order top-down is lD, lC, lB, lA.
// Anchor on lD (top), shift-click on lB → expect [lD, lC, lB].
s.selectLayer(lD.id)
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const rows = handle.host.querySelectorAll('.track .track-name') as NodeListOf<HTMLElement>
// rows index: 0=lD, 1=lC, 2=lB, 3=lA.
rows[2].dispatchEvent(new MouseEvent('click', {
bubbles: true, cancelable: true, shiftKey: true,
}))
await nextTick()
expect(s.selectedLayerIds.length).toBe(3)
// Anchor moves to the just-clicked layer.
expect(s.selectedLayerId).toBe(lB.id)
// The range should include lD, lC, lB (in some order).
const ids = new Set(s.selectedLayerIds)
expect(ids.has(lD.id)).toBe(true)
expect(ids.has(lC.id)).toBe(true)
expect(ids.has(lB.id)).toBe(true)
expect(ids.has(lA.id)).toBe(false)
} finally {
handle.unmount()
}
})
test('shift-click skips locked layers in the range', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const lA = s.addLayer(a.id)!
const lB = s.addLayer(a.id)!
const lC = s.addLayer(a.id)!
// Lock lB so it's in the visual range but unselectable.
s.setTrackLocked(lB.id, true)
// Anchor on lC (top), shift-click on lA (bottom) → range
// would be [lC, lB, lA]; the locked lB drops out.
s.selectLayer(lC.id)
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const rows = handle.host.querySelectorAll('.track .track-name') as NodeListOf<HTMLElement>
rows[2].dispatchEvent(new MouseEvent('click', {
bubbles: true, cancelable: true, shiftKey: true,
}))
await nextTick()
expect(s.selectedLayerIds.includes(lC.id)).toBe(true)
expect(s.selectedLayerIds.includes(lA.id)).toBe(true)
expect(s.selectedLayerIds.includes(lB.id)).toBe(false)
} finally {
handle.unmount()
}
})
})
suite('ui — Timeline rename menu (layer + clip track)', () => {
// Finds the menu item whose label contains the given i18n string.
// Menu items share `.kf-menu-item`, so labels are the only stable
@@ -10632,6 +10879,205 @@ export function registerAllTests(): void {
})
})
suite('ui — CanvasStage polygon vertex-edit promotion', () => {
// Selecting a polygon at level 1 attaches the regular
// transformer (size + rotation). Double-clicking the polygon
// promotes it to vertex-edit mode (level 2): the transformer
// detaches and per-vertex handles render. Selecting a different
// layer drops the polygon back to level-1 next time.
async function mountCanvasWithKonva() {
const VueKonva = (await import('vue-konva')).default
const host = document.createElement('div')
host.setAttribute('data-test-host', '')
host.style.width = '600px'
host.style.height = '400px'
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]
}
test('level-1: polygon selection attaches the transformer (no vertex handles)', async () => {
const s = makeFreshStore()
s.scene.canvas = { width: 400, height: 300, background: '#fff' }
const a = s.addAsset({
kind: 'polygon',
name: 'P',
points: [
{ x: -30, y: -30 }, { x: 30, y: -30 }, { x: 30, y: 30 }, { x: -30, y: 30 },
],
fill: '#22cc88',
})
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage() as unknown as {
find: (sel: string) => Array<{ nodes: () => unknown[] }>
}
const tr = stage.find('Transformer').find((t) => t.nodes().length > 0)
expect(tr).toBeTruthy()
// Vertex handles render with name 'vertex-handle' on the
// Konva nodes; the test host element has no '.vertex-handle'
// DOM class but Konva exposes them via its own naming. We
// assert the absence by counting vertex-named nodes.
const stageT = await getKonvaStage() as unknown as {
find: (sel: string) => unknown[]
}
const verts = stageT.find('.vertex-handle')
expect(verts.length).toBe(0)
} finally {
handle.unmount()
}
})
test('level-2 via dblclick: vertex handles render and the transformer detaches', async () => {
const s = makeFreshStore()
s.scene.canvas = { width: 400, height: 300, background: '#fff' }
const a = s.addAsset({
kind: 'polygon',
name: 'P',
points: [
{ x: -30, y: -30 }, { x: 30, y: -30 }, { x: 30, y: 30 }, { x: -30, y: 30 },
],
fill: '#22cc88',
})
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
// Trigger the container's dblclick handler on the polygon's
// approximate stage centre.
const container = handle.host.querySelector('.stage-container') as HTMLElement
const r = container.getBoundingClientRect()
container.dispatchEvent(
new MouseEvent('dblclick', {
bubbles: true, cancelable: true,
clientX: r.left + r.width / 2,
clientY: r.top + r.height / 2,
}),
)
await nextTick()
await tick(40)
const stage = await getKonvaStage() as unknown as {
find: (sel: string) => unknown[]
}
// Vertex handles now exist: 4 polygon points → 4 vertex
// circles named 'vertex-handle'.
const verts = stage.find('.vertex-handle') as Array<unknown>
expect(verts.length).toBe(4)
// Transformer no longer has the polygon attached.
const trs = stage.find('Transformer') as Array<{ nodes: () => unknown[] }>
const attached = trs.find((t) => t.nodes().length > 0)
expect(attached).toBeFalsy()
} finally {
handle.unmount()
}
})
test('SVG-edit scope follows the same level-1 / level-2 split', async () => {
// Polygon authoring inside an SVG asset uses the same UX as
// the main scene: selection lands at level 1 (transformer
// attaches, no vertex handles); a dbl-click promotes to
// level 2 and surfaces the vertex handles + edge-insert.
const s = makeFreshStore()
const svg = s.addSvgAsset('Logo', 200, 200)
s.enterSvgAssetEdit(svg.id)
const poly = s.addAsset({
kind: 'polygon', name: 'P',
points: [{ x: -40, y: 0 }, { x: 0, y: -30 }, { x: 40, y: 0 }],
fill: '#abc',
}) as Extract<typeof s.scene.assets[number], { kind: 'polygon' }>
const l = s.addLayer(poly.id)!
s.selectLayer(l.id)
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage() as unknown as {
find: (sel: string) => Array<{ nodes?: () => unknown[] }>
}
// Level 1 on selection: no vertex handles yet.
expect(stage.find('.vertex-handle').length).toBe(0)
// First dbl-click → promote, vertex handles light up,
// transformer detaches.
const container = handle.host.querySelector('.stage-container') as HTMLElement
const r = container.getBoundingClientRect()
container.dispatchEvent(
new MouseEvent('dblclick', {
bubbles: true, cancelable: true,
clientX: r.left + r.width / 2, clientY: r.top + r.height / 2,
}),
)
await nextTick()
await tick(40)
expect(stage.find('.vertex-handle').length).toBe(3)
} finally {
handle.unmount()
s.exitSvgAssetEdit()
}
})
test('selecting a different layer drops the polygon back to level 1', async () => {
const s = makeFreshStore()
s.scene.canvas = { width: 400, height: 300, background: '#fff' }
const poly = s.addAsset({
kind: 'polygon', name: 'P',
points: [{ x: -30, y: -30 }, { x: 30, y: -30 }, { x: 30, y: 30 }],
fill: '#22cc88',
})
const lP = s.addLayer(poly.id)!
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 40, height: 40, fill: '#666',
})
const lR = s.addLayer(rect.id)!
s.selectLayer(lP.id)
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const container = handle.host.querySelector('.stage-container') as HTMLElement
const r = container.getBoundingClientRect()
// dblclick → vertex-edit mode for polygon.
container.dispatchEvent(
new MouseEvent('dblclick', {
bubbles: true, cancelable: true,
clientX: r.left + r.width / 2, clientY: r.top + r.height / 2,
}),
)
await nextTick()
await tick(40)
// Switch to the rect: vertex-edit mode should clear.
s.selectLayer(lR.id)
await nextTick()
await tick(40)
const stage = await getKonvaStage() as unknown as {
find: (sel: string) => unknown[]
}
// Switch back to the polygon — should be level-1 again, no
// vertex handles until the user dbl-clicks.
s.selectLayer(lP.id)
await nextTick()
await tick(40)
const verts = stage.find('.vertex-handle')
expect(verts.length).toBe(0)
} finally {
handle.unmount()
}
})
})
suite('ui — AssetSidebar audio section', () => {
test('lists audio assets with a duration label under the Audio heading', async () => {
const s = makeFreshStore()