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:
1 parent
492f25f629
commit
d03fbe4ecb
15 files changed
+2529
-222
No files matched your search
+691
-55
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user