1.2.0: SVG asset editor + end keyframe type + UX bundle

This release bundles three independently-useful pieces of work that
overlap heavily in shared files (types, store, i18n, App, CanvasStage,
tests), so they ship together.

SVG asset editor
- New asset kind 'svg' (SvgAsset { canvas, layers }), authored from
  inside the existing shape primitives (rect / circle / text / polygon /
  freeform). "+" button under the Images sidebar header; opens a
  sub-editor that swaps the library sidebar for a dedicated layer
  panel, hides the timeline, and uses the same canvas-resize flow
  ("Edit canvas size" via right-click → no Document settings entry
  while in scope). Children carry a single t=0 keyframe each.
- Multi-select rotation in SVG scope only: a temporary group pivot
  appears at the selection centroid (draggable), rotation around it
  shifts each layer's x/y/rotation. Pivot follows the centroid drift
  on Konva-transformer scale/move. Outside SVG, transformer
  rotateEnabled stays off (group-rotate write-back is unreliable
  per prior feedback).
- Authored SVGs are placed as layers in the main scene and rendered
  via drawMaskedGroup at t=0; thumbnails surface in the sidebar via
  renderSvgThumbnail. Drop / click / drag / dbl-click-to-edit work
  via a transparent svg-hit catcher rect routed in the stage handler.
- Freeform default stroke is red (#e23b3b) so the open path is
  visible immediately. addDrawingPoint resolves the layer through
  activeLayers so freeform draws correctly inside SVG and clip
  scopes too.

End keyframe type
- Keyframe.kind: 'normal' | 'end'. End kfs carry no props, mark the
  visibility cap. layerActiveRange / isLayerActiveAt / sampleKeyframeSet
  ignore them for interpolation and use them only as the visible-from
  / visible-until bounds.
- One-time migration in normalizeProject synthesizes an end kf at the
  time of the last normal kf for layers that relied on the legacy
  "last kf = disappears" semantics; layers using hold:true to extend
  visibility past the end skip the migration. Idempotent.
- Timeline UI: new "+end" toolbar button (multi-select fan-out, toggle),
  "Convert to end" / "Back to normal" entries in the kf context menu
  (easing / still-frame / duplicate hidden for end kfs), end kfs
  render as a flat axis-aligned square (vs. the diamond-rotated normal
  kf). Render filter drops end kfs that collide with a normal kf at
  the same time so legacy migrated projects don't read as four-point
  stars.

UX polish
- TAB-collapse stage anchoring: the canvas content stays put when the
  asset / properties asides toggle (CanvasStage's ResizeObserver
  compensates viewX/Y by the container's bounding-rect drift).
- Topbar clipboard button copies the current frame to the system
  clipboard as a PNG via navigator.clipboard.write([ClipboardItem]).
- chromeClick predicate adds .timeline to the editing-panels bail so
  the synthetic click after a Timeline box-select drag no longer
  wipes the just-committed kf selection.
- Right-click on a polygon vertex stops propagation so the vertex
  context menu wins instead of the generic stage menu opening on top.
This commit is contained in:
th committed 2026-04-25 21:45:58 +02:00
1 parent 492f25f629
commit d03fbe4ecb
15 files changed
+2529 -222

No files matched your search

+691 -55
View File
@@ -850,74 +850,150 @@ export function registerAllTests(): void {
expect(isLayerActiveAt(l, 99)).toBe(true)
})
test('single kf at any time is always visible (static layer)', () => {
test('single kf at t>0: layer appears at the kf time and stays visible forever', () => {
// New model: the kf time is the visibility start, not just a
// property snapshot. A static-looking 1-kf layer placed at
// t=3 is invisible before that — matches the kf-as-cue
// semantic the rest of the system uses (no implicit "always
// visible" rule).
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
l.keyframes[0].time = 3
expect(isLayerActiveAt(l, 0)).toBe(true)
expect(isLayerActiveAt(l, 0)).toBe(false)
expect(isLayerActiveAt(l, 2.99)).toBe(false)
expect(isLayerActiveAt(l, 3)).toBe(true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 99)).toBe(true)
})
test('multi-kf: visible only in [firstKf, lastKf]', () => {
test('multi-kf without end kf: visible from first kf forward (no auto end)', () => {
// New model: the last NORMAL kf no longer terminates visibility.
// Without an explicit end kf the layer stays on stage to scene
// end. Authors who want the old "disappear at last kf" behaviour
// place an explicit end kf there (see the migration test below).
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
s.upsertKeyframe(l.id, 5, { scaleX: 3 })
// kf@0 is still there; firstKf = 0, lastKf = 5
expect(isLayerActiveAt(l, -0.1)).toBe(false)
expect(isLayerActiveAt(l, 0)).toBe(true)
expect(isLayerActiveAt(l, 3)).toBe(true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 6)).toBe(true)
expect(isLayerActiveAt(l, 99)).toBe(true)
})
test('explicit end kf cuts visibility at its time', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
s.upsertKeyframe(l.id, 5, { scaleX: 3 })
s.setLayerEnd(l.id, 5)
expect(isLayerActiveAt(l, 4.99)).toBe(true)
expect(isLayerActiveAt(l, 5)).toBe(false)
expect(isLayerActiveAt(l, 6)).toBe(false)
})
test('clearLayerEnd restores "visible to scene end"', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
s.setLayerEnd(l.id, 4)
expect(isLayerActiveAt(l, 5)).toBe(false)
s.clearLayerEnd(l.id)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 99)).toBe(true)
})
test('dragging first kf forward hides layer before it', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
// Drag the first kf from t=0 to t=2.
const first = l.keyframes.find((k) => k.time === 0)!
first.time = 2
expect(isLayerActiveAt(l, 1)).toBe(false)
expect(isLayerActiveAt(l, 2)).toBe(true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 6)).toBe(false)
// No end kf → still visible past last normal kf.
expect(isLayerActiveAt(l, 6)).toBe(true)
})
test('last kf held: layer stays visible past its last keyframe', () => {
test('end kf carries no props (no influence on interpolation)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 0, { scaleX: 1 })
s.upsertKeyframe(l.id, 4, { scaleX: 5 })
s.setLayerEnd(l.id, 4)
// Sampling at t=2 must still interpolate halfway between the
// two normal kfs, ignoring the end kf at t=4.
expect(sampleLayer(l, 2).scaleX).toBe(3)
})
test('migration: legacy multi-kf layers gain an end kf at last-kf time', () => {
// Old projects relied on "last kf = disappear". On load
// (normalizeProject) we synthesize an end kf at the time of
// the last normal kf so the visual stays identical.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
// Mark the t=5 kf as hold. It's the last keyframe — used to be a
// no-op; now it means "stay on stage past the end of the anim".
const last = l.keyframes.find((k) => k.time === 5)!
s.setKeyframeHold(l.id, last.id, true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 6)).toBe(true)
expect(isLayerActiveAt(l, 99)).toBe(true)
// sampleLayer already pins to last.props for time >= last.time —
// confirm the value is the last keyframe's, not interpolating out.
expect(sampleLayer(l, 10).scaleX).toBe(2)
// Strip any pre-existing end kfs to simulate a pre-migration
// project, then round-trip through loadProject.
l.keyframes = l.keyframes.filter((k) => k.kind !== 'end')
const snapshot = JSON.parse(JSON.stringify(s.projectForSave))
const s2 = makeFreshStore()
s2.loadProject(snapshot)
const restored = s2.scene.layers.find((x) => x.id === l.id)!
const endKf = restored.keyframes.find((k) => k.kind === 'end')
expect(endKf).toBeTruthy()
expect(endKf!.time).toBe(5)
})
test('non-last held kf does NOT extend visibility past end', () => {
// Hold on a middle keyframe only changes how it interpolates TO
// the following kf; it should not keep the layer alive past the
// final keyframe when that final kf itself isn't held.
test('migration is idempotent (already-migrated projects stay clean)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
s.upsertKeyframe(l.id, 5, { scaleX: 3 })
const middle = l.keyframes.find((k) => k.time === 2)!
s.setKeyframeHold(l.id, middle.id, true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 6)).toBe(false)
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
s.setLayerEnd(l.id, 7)
// Round-trip twice — the existing end kf at t=7 must be
// preserved (NOT replaced with an end kf at t=5).
const snap1 = JSON.parse(JSON.stringify(s.projectForSave))
const s2 = makeFreshStore()
s2.loadProject(snap1)
const snap2 = JSON.parse(JSON.stringify(s2.projectForSave))
const s3 = makeFreshStore()
s3.loadProject(snap2)
const restored = s3.scene.layers.find((x) => x.id === l.id)!
const ends = restored.keyframes.filter((k) => k.kind === 'end')
expect(ends.length).toBe(1)
expect(ends[0].time).toBe(7)
})
test('migration skips layers whose last kf had hold:true (visible to scene end)', () => {
// Legacy hold:true on the last kf was the way to keep a layer
// visible past the animation. The new "no end kf = visible
// forever" rule does the same job, so the migration must NOT
// add an end kf for these layers.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
const last = l.keyframes.find((k) => k.time === 5)!
s.setKeyframeHold(l.id, last.id, true)
l.keyframes = l.keyframes.filter((k) => k.kind !== 'end')
const snap = JSON.parse(JSON.stringify(s.projectForSave))
const s2 = makeFreshStore()
s2.loadProject(snap)
const restored = s2.scene.layers.find((x) => x.id === l.id)!
expect(restored.keyframes.find((k) => k.kind === 'end')).toBeFalsy()
// Layer stays alive forever (= visible to scene end).
expect(isLayerActiveAt(restored, 99)).toBe(true)
})
})
@@ -4598,6 +4674,16 @@ export function registerAllTests(): void {
expect(recentsBtn).toBeTruthy()
})
test('topbar has a copy-still-to-clipboard button', () => {
// Sits next to the recent-projects button. Renders the still-
// image export through the Clipboard API on click — the button
// itself just needs to be present and labelled correctly.
const btn = document.querySelector(
'button[aria-label="Copy current frame"]',
)
expect(btn).toBeTruthy()
})
test('canvas has crop toggle button', () => {
const cropBtn = document.querySelector('.crop-btn')
expect(cropBtn).toBeTruthy()
@@ -4837,6 +4923,289 @@ export function registerAllTests(): void {
})
})
suite('timeline — end keyframe (set / toggle / render)', () => {
test('+end button sets an end kf on each selected layer at the playhead', async () => {
const s = makeFreshStore()
s.setDuration(10)
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(4)
s.selectedLayerIds = [lA.id, lB.id]
s.selectedLayerId = lB.id
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const btn = handle.host.querySelector('.set-end-btn') as HTMLButtonElement
expect(btn).toBeTruthy()
btn.click()
await nextTick()
const endA = lA.keyframes.find((k) => k.kind === 'end')
const endB = lB.keyframes.find((k) => k.kind === 'end')
expect(endA).toBeTruthy()
expect(endB).toBeTruthy()
expect(endA!.time).toBe(4)
expect(endB!.time).toBe(4)
} finally {
handle.unmount()
}
})
test('+end clicked again at the same playhead removes the end kfs (toggle)', async () => {
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.setPlayhead(3)
s.selectedLayerId = l.id
s.selectedLayerIds = [l.id]
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const btn = handle.host.querySelector('.set-end-btn') as HTMLButtonElement
btn.click()
await nextTick()
expect(l.keyframes.find((k) => k.kind === 'end')).toBeTruthy()
// Same playhead, second click — toggle off.
btn.click()
await nextTick()
expect(l.keyframes.find((k) => k.kind === 'end')).toBeFalsy()
} finally {
handle.unmount()
}
})
test('+end at a new playhead moves the existing end kf instead of duplicating it', async () => {
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.selectedLayerId = l.id
s.selectedLayerIds = [l.id]
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const btn = handle.host.querySelector('.set-end-btn') as HTMLButtonElement
s.setPlayhead(2)
btn.click()
await nextTick()
s.setPlayhead(6)
btn.click()
await nextTick()
const ends = l.keyframes.filter((k) => k.kind === 'end')
expect(ends.length).toBe(1)
expect(ends[0].time).toBe(6)
} finally {
handle.unmount()
}
})
test('+end button is hidden in SVG sub-editor scope', async () => {
const s = makeFreshStore()
const svg = s.addSvgAsset('Logo', 60, 60)
s.enterSvgAssetEdit(svg.id)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.selectedLayerId = l.id
s.selectedLayerIds = [l.id]
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const btn = handle.host.querySelector('.set-end-btn')
expect(btn).toBeNull()
} finally {
handle.unmount()
s.exitSvgAssetEdit()
}
})
test('+end button is hidden in button-asset edit scope', async () => {
const s = makeFreshStore()
const ba = s.addButtonAsset('Btn', 80, 30)
s.enterButtonAssetEdit(ba.id)
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const btn = handle.host.querySelector('.set-end-btn')
expect(btn).toBeNull()
} finally {
handle.unmount()
s.exitButtonAssetEdit()
}
})
test('end kf renders with the .kf-end class on the timeline lane', async () => {
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
s.setLayerEnd(l.id, 7)
s.selectedLayerId = l.id
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const endNode = handle.host.querySelector('.track-lane .kf.kf-end') as HTMLElement
expect(endNode).toBeTruthy()
// The end kf should sit at its own time, not on the lane edge.
const lane = handle.host.querySelector('.track-lane') as HTMLElement
const laneRect = lane.getBoundingClientRect()
const endRect = endNode.getBoundingClientRect()
// end kf at t=7 of 10s scene should land roughly 70% across.
const ratio = (endRect.left + endRect.width / 2 - laneRect.left) / laneRect.width
expect(ratio > 0.5 && ratio < 0.95).toBe(true)
} finally {
handle.unmount()
}
})
test('visibility tail extends to scene end when no end kf is set', async () => {
// Behaviour shift: with the new model, layers without an end
// kf stay visible past their last normal kf. The on-stage tail
// must therefore render and extend across the rest of the
// timeline, not stop at the last kf.
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.upsertKeyframe(l.id, 4, { scaleX: 2 })
s.selectedLayerId = l.id
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const tail = handle.host.querySelector('.track-lane .kf-seg.tail') as HTMLElement
expect(tail).toBeTruthy()
} finally {
handle.unmount()
}
})
test('right-click context menu on a normal kf shows the "Convert to end" entry', async () => {
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.upsertKeyframe(l.id, 4, { scaleX: 2 })
s.selectedLayerId = l.id
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const kfs = handle.host.querySelectorAll('.track-lane .kf')
expect(kfs.length).toBeGreaterThan(0)
kfs[kfs.length - 1].dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 50, clientY: 50 }),
)
await nextTick()
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
expect(menu).toBeTruthy()
const items = Array.from(menu.querySelectorAll('.kf-menu-item')).map(
(el) => el.textContent?.trim() ?? '',
)
expect(items.some((t) => t.includes(i18nT('timeline.endKfMakeEnd')))).toBe(true)
} finally {
handle.unmount()
}
})
test('clicking "Convert to end" turns the kf into an end kf and drops its props', async () => {
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.upsertKeyframe(l.id, 4, { scaleX: 2 })
s.selectedLayerId = l.id
const target = l.keyframes.find((k) => Math.abs(k.time - 4) < 1e-3)!
const handle = mountComponent(Timeline, {})
try {
await nextTick()
// Open the context menu by querySelector → the kf at t=4 has
// a stable data-kf-id so we can pick it directly.
const node = handle.host.querySelector(
`.track-lane .kf[data-kf-id="${target.id}"]`,
) as HTMLElement
expect(node).toBeTruthy()
node.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 50, clientY: 50 }),
)
await nextTick()
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
const convertBtn = Array.from(menu.querySelectorAll('.kf-menu-item')).find(
(el) => el.textContent?.includes(i18nT('timeline.endKfMakeEnd')),
) as HTMLButtonElement | undefined
expect(convertBtn).toBeTruthy()
convertBtn!.click()
await nextTick()
const updated = l.keyframes.find((k) => k.id === target.id)!
expect(updated.kind).toBe('end')
// End kf must carry no props.
expect(Object.keys(updated.props).length).toBe(0)
} finally {
handle.unmount()
}
})
test('end kf colliding with a normal kf at the same time is hidden from the lane', async () => {
// Migration on legacy projects places an end kf at the time
// of the last normal kf. Rendering both diamond + square at
// the same x reads as a four-point star — filter the end kf
// out of the v-for; visibility still cuts via the data layer.
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
s.setLayerEnd(l.id, 5)
s.selectedLayerId = l.id
const handle = mountComponent(Timeline, {})
try {
await nextTick()
// Two kfs in the data, one rendered (the diamond).
expect(l.keyframes.filter((k) => Math.abs(k.time - 5) < 1e-3).length).toBe(2)
const lane = handle.host.querySelector('.track-lane') as HTMLElement
expect(lane).toBeTruthy()
const ends = lane.querySelectorAll('.kf.kf-end')
expect(ends.length).toBe(0)
// Visibility still cuts: the layer must be invisible past t=5.
const { isLayerActiveAt } = await import('@/lib/interpolate')
expect(isLayerActiveAt(l, 6)).toBe(false)
} finally {
handle.unmount()
}
})
test('right-click on an end kf shows "Back to normal" but no easing/duplicate', async () => {
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.setLayerEnd(l.id, 6)
s.selectedLayerId = l.id
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const endNode = handle.host.querySelector('.track-lane .kf.kf-end') as HTMLElement
expect(endNode).toBeTruthy()
endNode.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 50, clientY: 50 }),
)
await nextTick()
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
expect(menu).toBeTruthy()
const items = Array.from(menu.querySelectorAll('.kf-menu-item')).map(
(el) => el.textContent?.trim() ?? '',
)
// The convert label flips to "Back to normal".
expect(items.some((t) => t.includes(i18nT('timeline.endKfBackToNormal')))).toBe(true)
// Easing block + still-frame + duplicate are hidden for end kfs.
expect(menu.querySelector('.kf-menu-header')).toBeNull()
expect(items.some((t) => t.includes(i18nT('timeline.stillFrame')))).toBe(false)
expect(items.some((t) => t.includes(i18nT('timeline.duplicate')))).toBe(false)
} 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
@@ -6264,12 +6633,14 @@ export function registerAllTests(): void {
}
})
test('clicks on empty timeline tracks → deselect, on timeline elements → keep', async () => {
// Empty timeline space (gaps below tracks, the .tracks
// background) should clear the selection. Timeline elements
// that own their own selection semantics (.kf, .clip-block,
// .track-name) must NOT trigger deselect — letting them would
// immediately undo the click that just selected something.
test('clicks anywhere inside the timeline → keep selection', async () => {
// Regression: after a kf box-select drag, the synthetic click
// event still fires on the document and used to fall through
// to the global deselect, wiping the just-committed
// selection. Timeline owns its own selection model — bare
// empty-tracks clicks call deselectAll() from inside the
// component — so the chrome handler must bail on the whole
// .timeline subtree.
const { shouldChromeClickDeselect } = await import('@/lib/chromeClick')
const tl = document.createElement('div')
tl.className = 'timeline'
@@ -6285,9 +6656,11 @@ export function registerAllTests(): void {
tl.appendChild(tracks)
document.body.appendChild(tl)
try {
// Background within the timeline → deselect.
expect(shouldChromeClickDeselect(tracks)).toBe(true)
// Selection-managing children → keep.
// Every subtree inside the timeline keeps the selection —
// including the .tracks background (synthetic-click target
// after a box-select drag).
expect(shouldChromeClickDeselect(tl)).toBe(false)
expect(shouldChromeClickDeselect(tracks)).toBe(false)
expect(shouldChromeClickDeselect(kf)).toBe(false)
expect(shouldChromeClickDeselect(trackName)).toBe(false)
expect(shouldChromeClickDeselect(trackLane)).toBe(false)
@@ -6326,6 +6699,31 @@ export function registerAllTests(): void {
expect(shouldChromeClickDeselect(null)).toBe(false)
})
test('synthetic click after a Timeline box-select must NOT wipe kf selection', async () => {
// Regression: after a successful kf box-select the browser
// fires a synthetic `click` on the .tracks background. If
// the global chrome-deselect handler treated that as inert
// chrome, deselectAll() would run AFTER the box-select had
// populated selectedKfs and erase it. The fix lives in the
// shouldChromeClickDeselect predicate — anything inside
// `.timeline` must bail, regardless of which inner element
// received the click.
const { shouldChromeClickDeselect } = await import('@/lib/chromeClick')
const tl = document.createElement('div')
tl.className = 'timeline'
const tracks = document.createElement('div')
tracks.className = 'tracks'
tl.appendChild(tracks)
document.body.appendChild(tl)
try {
// Simulating "click target = .tracks background after a
// drag" — predicate returns false → deselectAll never fires.
expect(shouldChromeClickDeselect(tracks)).toBe(false)
} finally {
tl.remove()
}
})
test('useDeselectAll clears layers, clip instances and keyframes', async () => {
// Single helper used from every "empty click" surface — chrome
// (App.vue), empty stage (CanvasStage), empty tracks (Timeline)
@@ -9929,11 +10327,12 @@ export function registerAllTests(): void {
}
})
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.
test('transformer rotateEnabled stays off in every selection state', async () => {
// Single selection delegates rotation to the custom pivot
// handle. Multi selection has no rotation at all — group
// rotate via Konva's built-in didn't work cleanly (write-back
// per node fights the bbox-centred transform), so it's
// explicitly disabled.
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' })
@@ -9950,23 +10349,20 @@ export function registerAllTests(): void {
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.
type Tr = { nodes: () => unknown[]; rotateEnabled: () => boolean }
const multiTr = (stage.find('Transformer') as Tr[]).find(
(tr) => tr.nodes().length > 1,
)
expect(multiTr).toBeTruthy()
expect(multiTr!.rotateEnabled()).toBe(false)
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)
const singleTr = (stage.find('Transformer') as Tr[]).find(
(tr) => tr.nodes().length === 1,
)
expect(singleTr).toBeTruthy()
expect(singleTr!.rotateEnabled()).toBe(false)
} finally {
handle.unmount()
}
@@ -13379,4 +13775,244 @@ export function registerAllTests(): void {
expect(obscured(200, 0)).toBe(true)
})
})
suite('svg asset — store + edit mode', () => {
test('addSvgAsset inherits the current scene canvas', () => {
const s = makeFreshStore()
s.scene.canvas.width = 480
s.scene.canvas.height = 270
const asset = s.addSvgAsset('Logo')
expect(asset.kind).toBe('svg')
expect(asset.name).toBe('Logo')
expect(asset.canvas.width).toBe(480)
expect(asset.canvas.height).toBe(270)
expect(asset.layers).toEqual([])
})
test('enterSvgAssetEdit flips scope and clears competing scopes', () => {
const s = makeFreshStore()
const asset = s.addSvgAsset('Logo')
s.activeClipId = 'fake-clip'
s.activeButtonAssetId = 'fake-btn'
s.enterSvgAssetEdit(asset.id)
expect(s.activeSvgAssetId).toBe(asset.id)
expect(s.activeClipId).toBeNull()
expect(s.activeButtonAssetId).toBeNull()
expect(s.playhead).toBe(0)
expect(s.activeSvgAsset?.id).toBe(asset.id)
})
test('addLayer in svg scope appends to asset.layers, not the scene', () => {
const s = makeFreshStore()
const before = s.scene.layers.length
const asset = s.addSvgAsset('Logo')
s.enterSvgAssetEdit(asset.id)
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff',
})
const layer = s.addLayer(rect.id)!
expect(layer).toBeTruthy()
expect(asset.layers.length).toBe(1)
expect(asset.layers[0].id).toBe(layer.id)
// The main scene's layer list and trackOrder must be untouched.
expect(s.scene.layers.length).toBe(before)
expect((s.scene.trackOrder ?? []).includes(layer.id)).toBe(false)
})
test('upsertKeyframe in svg scope collapses onto t=0', () => {
const s = makeFreshStore()
const asset = s.addSvgAsset('Logo')
s.enterSvgAssetEdit(asset.id)
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff',
})
const layer = s.addLayer(rect.id)!
// Try to write at t=2; expected to merge into the existing
// single keyframe at t=0 instead of inserting a second one.
s.upsertKeyframe(layer.id, 2, { x: 99 })
expect(layer.keyframes.length).toBe(1)
expect(layer.keyframes[0].time).toBe(0)
expect(layer.keyframes[0].props.x).toBe(99)
})
test('exitSvgAssetEdit returns to the main timeline scope', () => {
const s = makeFreshStore()
const asset = s.addSvgAsset('Logo')
s.enterSvgAssetEdit(asset.id)
s.exitSvgAssetEdit()
expect(s.activeSvgAssetId).toBeNull()
expect(s.selectedLayerId).toBeNull()
expect(s.activeLayers).toBe(s.scene.layers)
})
test('enterSvgAssetEdit strips multi-keyframe layers down to t=0', () => {
// Pasted / migrated layers might carry multiple keyframes. The
// sub-editor would silently hide them behind the static stage,
// so the entry sanitises down to a single t=0 entry per layer.
const s = makeFreshStore()
const asset = s.addSvgAsset('Logo')
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff',
})
// Author the layer through the normal scope, then hand-edit
// its keyframes to simulate a migrated multi-kf layer before
// re-entering the scope.
s.enterSvgAssetEdit(asset.id)
const layer = s.addLayer(rect.id)!
s.exitSvgAssetEdit()
layer.keyframes.push({ id: 'extra', time: 5, props: { x: 100 }, easing: 'linear' })
s.enterSvgAssetEdit(asset.id)
expect(asset.layers[0].keyframes.length).toBe(1)
expect(asset.layers[0].keyframes[0].time).toBe(0)
})
})
suite('svg asset — sidebar + scope wiring', () => {
test('+ button under Images creates an SVG and enters edit mode', async () => {
// Stub the prompt so the test runs unattended. The button's
// click handler awaits promptDialog; we resolve immediately
// and tick the awaited continuation.
const { dialogState, resolveDialog } = await import(
'@/components/ui/dialogs'
)
const s = makeFreshStore()
const handle = mountComponent(AssetSidebar, {})
try {
await nextTick()
const beforeAssets = s.scene.assets.length
const btn = handle.host.querySelector(
`button[aria-label="${i18nT('sidebar.createSvgTooltip')}"]`,
) as HTMLButtonElement
expect(btn).toBeTruthy()
btn.click()
expect(dialogState.value?.kind).toBe('prompt')
resolveDialog('Logo')
// Allow the awaited handler chain to settle: addSvgAsset +
// enterSvgAssetEdit both run on the next microtask.
await nextTick()
await nextTick()
expect(s.scene.assets.length).toBe(beforeAssets + 1)
const created = s.scene.assets[s.scene.assets.length - 1]
expect(created.kind).toBe('svg')
expect(s.activeSvgAssetId).toBe(created.id)
} finally {
handle.unmount()
// Reset scope so subsequent suites don't inherit the SVG mode.
s.exitSvgAssetEdit()
}
})
})
suite('svg asset — main-scene render', () => {
test('drawSceneFrame composites SVG child shapes into the main frame', async () => {
// End-to-end: an SVG asset with one bright child rect, placed
// as a layer in the scene, should leave non-transparent pixels
// in the rendered frame.
const { drawSceneFrame, loadSceneImages } = await import('@/lib/render')
const s = makeFreshStore()
s.setDuration(1)
s.scene.canvas.width = 60
s.scene.canvas.height = 60
s.scene.canvas.background = 'transparent'
const svg = s.addSvgAsset('Logo', 60, 60)
// Author one child rect via the sub-editor scope.
s.enterSvgAssetEdit(svg.id)
const rect = s.addAsset({
kind: 'rect', name: 'Square', width: 40, height: 40, fill: '#22cc88',
})
s.addLayer(rect.id)
s.exitSvgAssetEdit()
// Place the SVG asset as a top-level scene layer.
s.addLayer(svg.id)
const canvas = document.createElement('canvas')
canvas.width = 60; canvas.height = 60
const ctx = canvas.getContext('2d')!
const imgs = await loadSceneImages(s.scene)
drawSceneFrame(ctx, s.scene, 0, imgs)
// Center pixel should carry the green fill bleed-through.
const px = ctx.getImageData(30, 30, 1, 1).data
expect(px[0] + px[1] + px[2] > 0).toBe(true)
expect(px[3] > 0).toBe(true)
})
test('SVG asset round-trips through projectForSave + loadProject', () => {
const s = makeFreshStore()
const svg = s.addSvgAsset('Logo', 100, 80)
s.enterSvgAssetEdit(svg.id)
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff',
})
s.addLayer(rect.id)
s.exitSvgAssetEdit()
const snapshot = JSON.parse(JSON.stringify(s.projectForSave))
const s2 = makeFreshStore()
s2.loadProject(snapshot)
const restored = s2.scene.assets.find((a) => a.kind === 'svg')
expect(restored).toBeTruthy()
expect(restored!.kind).toBe('svg')
// Asset list survived; nested layers carried through.
const restoredAsset = restored as Extract<typeof restored, { kind: 'svg' }>
expect(restoredAsset.canvas.width).toBe(100)
expect(restoredAsset.canvas.height).toBe(80)
expect(restoredAsset.layers.length).toBe(1)
})
})
suite('svg asset — thumbnail rendering', () => {
test('renderSvgThumbnail returns a data URL for non-empty assets', async () => {
const { renderSvgThumbnail } = await import('@/lib/render')
const s = makeFreshStore()
const svg = s.addSvgAsset('Logo', 80, 80)
s.enterSvgAssetEdit(svg.id)
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 60, height: 60, fill: '#22cc88',
})
s.addLayer(rect.id)
s.exitSvgAssetEdit()
const url = renderSvgThumbnail(svg, s.scene, 64)
expect(url).toBeTruthy()
expect(url!.startsWith('data:image/png;base64,')).toBe(true)
})
test('renderSvgThumbnail returns null for empty SVGs', async () => {
const { renderSvgThumbnail } = await import('@/lib/render')
const s = makeFreshStore()
const svg = s.addSvgAsset('Empty', 80, 80)
expect(renderSvgThumbnail(svg, s.scene, 64)).toBeNull()
})
test('AssetSidebar renders an <img> thumb for an SVG asset with content', async () => {
// Regression for the inline placeholder: with showThumbs on
// and an SVG that has child layers, the row should render a
// real <img> (data-URL) instead of the icon fallback.
const s = makeFreshStore()
const svg = s.addSvgAsset('Logo', 80, 80)
s.enterSvgAssetEdit(svg.id)
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 60, height: 60, fill: '#22cc88',
})
s.addLayer(rect.id)
s.exitSvgAssetEdit()
// Force showThumbs on so the sidebar takes the thumbnail branch.
const { useAssetThumbsPref } = await import('@/composables/useAssetThumbsPref')
const { enabled } = useAssetThumbsPref()
const prior = enabled.value
enabled.value = true
const handle = mountComponent(AssetSidebar, {})
try {
// Two ticks: one for the watchEffect, one for the DOM
// render to settle after svgThumbs[id] populates.
await nextTick()
await nextTick()
const row = handle.host.querySelector('.assets li.svg-asset') as HTMLElement
expect(row).toBeTruthy()
const img = row.querySelector('.thumb img') as HTMLImageElement | null
expect(img).toBeTruthy()
expect(img!.src.startsWith('data:image/png;base64,')).toBe(true)
} finally {
handle.unmount()
enabled.value = prior
}
})
})
}