diff --git a/animatr_reference/Screenshot from 2026-04-19 15-34-54.png b/animatr_reference/Screenshot from 2026-04-19 15-34-54.png deleted file mode 100644 index 5c54f27..0000000 Binary files a/animatr_reference/Screenshot from 2026-04-19 15-34-54.png and /dev/null differ diff --git a/animatr_reference/Screenshot from 2026-04-19 19-38-06.png b/animatr_reference/Screenshot from 2026-04-19 19-38-06.png deleted file mode 100644 index d38fdfe..0000000 Binary files a/animatr_reference/Screenshot from 2026-04-19 19-38-06.png and /dev/null differ diff --git a/package.json b/package.json index 6bc5b42..528cdf5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "animatr", "private": true, - "version": "0.7.0", + "version": "0.8.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index 5a91c74..76d954e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -11,6 +11,7 @@ import { PhFloppyDisk, PhGearSix, PhPause, + PhPencilSimpleLine, PhPlay, PhSkipBack, PhSparkle, @@ -169,6 +170,34 @@ async function saveProject() { showSavedToast(t('app.savedToast', { name: rec.name })) } +async function renameProject() { + const suggested = + currentProjectName.value ?? + t('app.saveDialog.defaultName', { date: new Date().toLocaleDateString() }) + const entered = await promptDialog({ + title: t('app.renameProjectDialog.title'), + message: t('app.renameProjectDialog.message'), + defaultValue: suggested, + confirmLabel: t('app.renameProjectDialog.confirm'), + }) + if (entered === null) return + const trimmed = entered.trim() + if (!trimmed || trimmed === currentProjectName.value) return + currentProjectName.value = trimmed + // If the project is already persisted in browser storage, sync the + // new name to its record so future loads (and the recents list) use + // it. Unsaved projects just get the in-memory name — the next save + // picks it up. + if (currentProjectId.value) { + const scene = JSON.parse(JSON.stringify(store.scene)) + const rec = await saveProjectToStore(scene, { + id: currentProjectId.value, + name: trimmed, + }) + currentProjectName.value = rec.name + } +} + function exportProjectFile() { const scene = JSON.parse(JSON.stringify(store.scene)) const base = currentProjectName.value ?? 'animatr' @@ -366,6 +395,17 @@ onBeforeUnmount(() => { +
{ {{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s
- - {{ currentProjectName }} - @@ -734,19 +771,47 @@ onBeforeUnmount(() => { background: rgba(110, 168, 255, 0.08); } -.project-name { +.project-name-btn { display: inline-flex; align-items: center; - max-width: 220px; - padding: 4px 10px; - font-size: 11px; - color: var(--text-dim-strong); - background: var(--panel-alt); - border: 1px solid var(--border); + gap: 6px; + max-width: 260px; + padding: 4px 8px; + font: inherit; + font-size: 12px; + color: var(--text); + background: transparent; + border: 1px solid transparent; border-radius: var(--radius-sm); - white-space: nowrap; + cursor: pointer; +} + +.project-name-btn.is-untitled { + color: var(--text-dim); + font-style: italic; +} + +.project-name-btn:hover { + background: var(--panel-alt); + border-color: var(--border); +} + +.project-name-label { overflow: hidden; text-overflow: ellipsis; - margin-right: 4px; + white-space: nowrap; + min-width: 0; +} + +.project-name-pencil { + color: var(--text-dim); + opacity: 0; + transition: opacity 140ms var(--ease); + flex-shrink: 0; +} + +.project-name-btn:hover .project-name-pencil, +.project-name-btn:focus-visible .project-name-pencil { + opacity: 1; } diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index 20871f4..a0d7fd7 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -2414,7 +2414,12 @@ function handleDragEnd(e: Konva.KonvaEventObject) { if (!layer) return const newX = node.x() const newY = node.y() - if (selectedKfs.value.length > 0 && anySelectedKfAtPlayhead()) { + // Multi-keyframe shift applies only when the layer being dragged is + // itself part of the current selection. Otherwise a stale selection on + // some other layer would steal the drag delta, leaving the dragged + // layer to snap back while the selected one moved instead. + const draggedInSelection = selectedKfs.value.some((k) => k.layerId === id) + if (draggedInSelection && anySelectedKfAtPlayhead()) { const samp = sampleLayer(layer, store.playhead) const dx = newX - samp.x const dy = newY - samp.y diff --git a/src/components/ClipartDrawer.vue b/src/components/ClipartDrawer.vue index 8ddc42a..fe58719 100644 --- a/src/components/ClipartDrawer.vue +++ b/src/components/ClipartDrawer.vue @@ -49,7 +49,7 @@ function onItemDragStart(e: DragEvent, index: number) { > -
+

{{ t('clipart.title') }}

@@ -106,6 +106,11 @@ function onItemDragStart(e: DragEvent, index: number) { background: var(--border); border-right: 1px solid var(--border); position: relative; + /* The drawer-panel is always mounted at its full 400px width so the + thumbnail grid's columns don't reflow mid-animation. `overflow:hidden` + clips it to the drawer's animated width so while closed/hovering the + panel is rendered but invisible. */ + overflow: hidden; } .clipart-drawer:hover { @@ -157,8 +162,11 @@ function onItemDragStart(e: DragEvent, index: number) { } .drawer-panel { - flex: 1; - min-width: 0; + /* Rigid 400px width — never flex-shrinks. Combined with the drawer's + `overflow: hidden`, this lets the grid be laid out correctly the + moment it mounts and stay that way regardless of the drawer's + current animated width. */ + flex: 0 0 400px; width: 400px; display: flex; flex-direction: column; diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 896ccd7..f595927 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -8,6 +8,7 @@ import { PhCode, PhCopy, PhDotsSixVertical, + PhFilmStrip, PhFolder, PhFolderPlus, PhLock, @@ -1062,12 +1063,35 @@ interface KfSegment { toTime: number easing: Easing held: boolean + /** True when this segment is the "still-frame tail" extending past + * the last keyframe to the end of the timeline. Rendered dimmer so + * it reads as an after-image rather than an interpolation. */ + tail?: boolean } -function segmentsOf(kfs: Keyframe[]): KfSegment[] { - if (kfs.length < 2) return [] +function segmentsOf(kfs: Keyframe[], duration: number): KfSegment[] { + if (kfs.length === 0) return [] const sorted = [...kfs].sort((a, b) => a.time - b.time) const out: KfSegment[] = [] + + // Single keyframe means the layer is always visible (no animation + // range), so the line should span the entire timeline as a solid + // dim "on-stage" indicator — same treatment as a still-frame tail. + if (sorted.length === 1) { + const only = sorted[0] + if (duration > 0) { + out.push({ + id: `${only.id}>full`, + fromTime: 0, + toTime: duration, + easing: only.easing, + held: false, + tail: true, + }) + } + return out + } + for (let i = 0; i < sorted.length - 1; i++) { const a = sorted[i] const b = sorted[i + 1] @@ -1079,6 +1103,20 @@ function segmentsOf(kfs: Keyframe[]): KfSegment[] { held: !!a.hold, }) } + // Last-keyframe "still frame" tail: when the final keyframe is held, + // extend the on-stage indicator to the end of the timeline. Solid + // and dim — the value isn't interpolating, it's just idling. + const last = sorted[sorted.length - 1] + if (last.hold && duration > last.time) { + out.push({ + id: `${last.id}>tail`, + fromTime: last.time, + toTime: duration, + easing: last.easing, + held: false, + tail: true, + }) + } return out } @@ -1974,6 +2012,20 @@ function onDocumentKeyDown(e: KeyboardEvent) { store.removeMarker(id) return } + // Selected-track deletion. Only layer tracks carry a selection + // today (clip / script / group tracks don't set selectedLayerId). + // Skip the delete when the layer is locked directly or through its + // parent group — Backspace should respect the same locks that block + // mouse interaction. + if (store.selectedLayerId) { + const layerId = store.selectedLayerId + const layer = store.activeLayers.find((l) => l.id === layerId) + if (!layer) return + if (layer.locked || lockedGroupChildIds.value.has(layerId)) return + e.preventDefault() + store.removeLayer(layerId) + return + } } if ( e.key.toLowerCase() === 'm' && @@ -2390,10 +2442,10 @@ onBeforeUnmount(() => { > { :disabled="menu.isFirst || menu.isHeld" @click="applyEasing(opt.value)" > - + - {{ opt.label }}
{{ t('timeline.firstKfHint') }}
@@ -2794,24 +2845,23 @@ onBeforeUnmount(() => {
@@ -3803,6 +3853,21 @@ onBeforeUnmount(() => { opacity: 0.7; } +/* "Tail" = on-stage indicator where the layer is visible but no + interpolation is happening (still-frame past the last keyframe, or + a single-keyframe layer that's always on stage). Solid line at low + opacity — reads as "layer idling on stage" rather than "thin line". + Overrides any preceding easing-* gradient background so the fill is + always a flat accent colour at the track's tint. */ +.kf-seg.tail, +.kf-seg.tail.easing-linear, +.kf-seg.tail.easing-easeIn, +.kf-seg.tail.easing-easeOut, +.kf-seg.tail.easing-easeInOut { + background: var(--track-color, var(--accent)); + opacity: 0.35; +} + .kf { position: absolute; top: 50%; @@ -3871,10 +3936,6 @@ onBeforeUnmount(() => { color: var(--text-dim); } -.clip-track-name { - gap: 6px; -} - .clip-lane { position: relative; } @@ -3896,10 +3957,6 @@ onBeforeUnmount(() => { color: var(--text-dim); } -.script-track-name { - gap: 6px; -} - .script-lane { position: relative; } @@ -4238,18 +4295,7 @@ onBeforeUnmount(() => { background: var(--danger-soft); } -.check { - width: 14px; - height: 12px; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--accent); - flex-shrink: 0; -} - -.kf-menu-icon, -.kf-menu-icon-spacer { +.kf-menu-icon { width: 12px; height: 12px; flex-shrink: 0; diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 4e3ce62..53767d8 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -34,6 +34,7 @@ import Modal from '@/components/ui/Modal.vue' import IconButton from '@/components/ui/IconButton.vue' import Toast from '@/components/ui/Toast.vue' import PropertiesPanel from '@/components/PropertiesPanel.vue' +import ClipartDrawer from '@/components/ClipartDrawer.vue' import { expect, suite, test } from './testRunner' interface MountedHandle { @@ -462,6 +463,57 @@ export function registerAllTests(): void { }) }) + suite('interpolation — shadow properties', () => { + // Regression: shadow keyframes used to render static (or nothing) in + // the preview because the runtime's lerpProps dropped shadow fields. + // The canvas-side sampleLayer must interpolate them correctly; this + // pins the contract both paths share. + + function rectWithShadow() { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'Sh', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + // Seed both endpoints so there's a clear interpolation target. + s.upsertKeyframe(l.id, 0, { + shadowOpacity: 0, + shadowBlur: 0, + shadowDistance: 0, + shadowAngle: 0, + shadowColor: '#000000', + }) + s.upsertKeyframe(l.id, 2, { + shadowOpacity: 1, + shadowBlur: 20, + shadowDistance: 10, + shadowAngle: 180, + shadowColor: '#ffffff', + }) + return l + } + + test('shadowOpacity lerps halfway between keyframes', () => { + const l = rectWithShadow() + const mid = sampleLayer(l, 1) + expect(mid.shadowOpacity).toBe(0.5) + }) + + test('shadowBlur / distance / angle lerp halfway between keyframes', () => { + const l = rectWithShadow() + const mid = sampleLayer(l, 1) + expect(mid.shadowBlur).toBe(10) + expect(mid.shadowDistance).toBe(5) + expect(mid.shadowAngle).toBe(90) + }) + + test('shadowColor is interpolated per channel (not snapped)', () => { + const l = rectWithShadow() + const mid = sampleLayer(l, 1) + // Black→white at t=0.5 lands on mid-gray (same lerpColor the + // fill-color test above pins). + expect(mid.shadowColor).toBe('#808080') + }) + }) + suite('interpolation — layer visibility (kf range)', () => { test('untouched baseline (single kf @0) is always visible', () => { const s = makeFreshStore() @@ -509,6 +561,38 @@ export function registerAllTests(): void { expect(isLayerActiveAt(l, 5)).toBe(true) expect(isLayerActiveAt(l, 6)).toBe(false) }) + + test('last kf held: layer stays visible past its last keyframe', () => { + 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) + }) + + 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. + 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) + }) }) suite('timeline snap', () => { @@ -586,6 +670,21 @@ export function registerAllTests(): void { expect(src).toContain('AnimatrPlayer.mount') expect(src).toContain('BundlR') }) + + test('runtime lerpProps interpolates shadow + blur keyframes', () => { + // Regression: shadow properties (shadowBlur/Opacity/Angle/Distance/ + // Color) and blur used to be dropped by the runtime's lerpProps, + // so an animation that fades a shadow in the editor played flat + // in the preview / exported HTML. The runtime must carry them + // through the per-pair interpolation. + const r = generateRuntimeScript() + expect(r).toContain('shadowBlur') + expect(r).toContain('shadowOpacity') + expect(r).toContain('shadowAngle') + expect(r).toContain('shadowDistance') + expect(r).toContain('shadowColor') + expect(r).toContain('out.blur') + }) }) suite('playback — transport', () => { @@ -1457,6 +1556,109 @@ export function registerAllTests(): void { expect(g.childIds).toEqual([l2.id, l1.id]) }) + test('group color cascades to non-overridden children', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) + const l1 = s.addLayer(a.id)! + const l2 = s.addLayer(a.id)! + const g = s.addTrackGroup('G') + s.moveTrackIntoGroup(l1.id, g.id) + s.moveTrackIntoGroup(l2.id, g.id) + s.setTrackColor(g.id, '#ff0000') + expect(l1.color).toBe('#ff0000') + expect(l2.color).toBe('#ff0000') + // The children inherited — they must NOT be marked as overridden. + expect(l1.colorOverridden).toBe(undefined) + expect(l2.colorOverridden).toBe(undefined) + }) + + test('explicit per-track color pins against later group-color changes', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) + const l1 = s.addLayer(a.id)! + const l2 = s.addLayer(a.id)! + const g = s.addTrackGroup('G') + s.moveTrackIntoGroup(l1.id, g.id) + s.moveTrackIntoGroup(l2.id, g.id) + s.setTrackColor(g.id, '#ff0000') + // User recolours one child AFTER the group — it pins. + s.setTrackColor(l1.id, '#00ff00') + expect(l1.colorOverridden).toBe(true) + // Group colour changes again — pinned child ignores it; other follows. + s.setTrackColor(g.id, '#0000ff') + expect(l1.color).toBe('#00ff00') + expect(l2.color).toBe('#0000ff') + }) + + test('clearing a child-color (null) re-inherits the group color', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + const g = s.addTrackGroup('G') + s.moveTrackIntoGroup(l.id, g.id) + s.setTrackColor(g.id, '#ff0000') + s.setTrackColor(l.id, '#00ff00') + expect(l.color).toBe('#00ff00') + s.setTrackColor(l.id, null) + // Now inheriting again. + expect(l.color).toBe('#ff0000') + expect(l.colorOverridden).toBe(undefined) + }) + + test('clearing a group color clears cascade on non-overridden children', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) + const l1 = s.addLayer(a.id)! + const l2 = s.addLayer(a.id)! + const g = s.addTrackGroup('G') + s.moveTrackIntoGroup(l1.id, g.id) + s.moveTrackIntoGroup(l2.id, g.id) + s.setTrackColor(g.id, '#ff0000') + s.setTrackColor(l2.id, '#00ff00') + s.setTrackColor(g.id, null) + expect(l1.color).toBe(undefined) + // l2 was pinned, keeps its explicit colour. + expect(l2.color).toBe('#00ff00') + }) + + test('moving a track into a coloured group inherits its color', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + const g = s.addTrackGroup('G') + s.setTrackColor(g.id, '#ff0000') + s.moveTrackIntoGroup(l.id, g.id) + expect(l.color).toBe('#ff0000') + expect(l.colorOverridden).toBe(undefined) + }) + + test('moving an overridden track into a coloured group keeps its color', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + s.setTrackColor(l.id, '#00ff00') + const g = s.addTrackGroup('G') + s.setTrackColor(g.id, '#ff0000') + s.moveTrackIntoGroup(l.id, g.id) + expect(l.color).toBe('#00ff00') + }) + + test('loading an older scene treats pre-existing track colors as overridden', () => { + // Migration: scenes saved before colorOverridden shipped must + // preserve their per-track colour choices against future group + // cascades. normalizeScene stamps colorOverridden=true on any + // track that arrives with a colour and no flag. + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + const snap = JSON.parse(JSON.stringify(s.scene)) + snap.layers[0].color = '#123456' + // legacy: no colorOverridden field on the layer + s.loadScene(snap) + const loaded = s.scene.layers.find((x) => x.id === l.id)! + expect(loaded.colorOverridden).toBe(true) + }) + test('normalizeScene (via loadScene) removes orphan childIds and deduplicates', () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'N', width: 10, height: 10, fill: '#666' }) @@ -1524,6 +1726,103 @@ export function registerAllTests(): void { }) }) + suite('timeline — backspace deletes selected layer', () => { + // Regression: Backspace / Delete used to only remove selected + // keyframes and markers. The selected layer track had no shortcut + // — the user had to use the context menu. Timeline's document-level + // keydown now also deletes the currently-selected layer, skipping + // locked layers (direct or via a locked parent group). + + async function clearTimelineSelections() { + // The timeline's marker selection lives in a component-local ref + // that the store reset doesn't touch. Two Escape presses clear + // keyframes first, then markers — matches the handler's priority. + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + await nextTick() + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + await nextTick() + } + + test('Backspace removes the selected layer and clears the selection', async () => { + const s = makeFreshStore() + await clearTimelineSelections() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + s.selectLayer(l.id) + expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })) + await nextTick() + expect(s.scene.layers.some((x) => x.id === l.id)).toBe(false) + expect(s.selectedLayerId).toBe(null) + }) + + test('Delete also removes the selected layer', async () => { + const s = makeFreshStore() + await clearTimelineSelections() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + s.selectLayer(l.id) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' })) + await nextTick() + expect(s.scene.layers.some((x) => x.id === l.id)).toBe(false) + }) + + test('Backspace is a no-op while the layer itself is locked', async () => { + const s = makeFreshStore() + await clearTimelineSelections() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + s.selectLayer(l.id) + s.setTrackLocked(l.id, true) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })) + await nextTick() + expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true) + }) + + test('Backspace is a no-op while the parent group is locked', async () => { + const s = makeFreshStore() + await clearTimelineSelections() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + const g = s.addTrackGroup('G') + s.moveTrackIntoGroup(l.id, g.id) + s.setTrackLocked(g.id, true) + s.selectLayer(l.id) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })) + await nextTick() + expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true) + }) + + test('Backspace while typing in an input does not touch the selected layer', async () => { + const s = makeFreshStore() + await clearTimelineSelections() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + s.selectLayer(l.id) + // Mount an input, focus it, dispatch Backspace FROM the input. + const input = document.createElement('input') + input.setAttribute('data-test-input', '') + document.body.appendChild(input) + input.focus() + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true })) + await nextTick() + expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true) + input.remove() + }) + + test('Backspace with no layer selected is a no-op', async () => { + const s = makeFreshStore() + await clearTimelineSelections() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + // Explicitly deselect + s.selectLayer(null) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })) + await nextTick() + expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true) + }) + }) + suite('version — app metadata', () => { test('APP_VERSION is a semver-like string', () => { expect(typeof APP_VERSION === 'string').toBe(true) @@ -2334,6 +2633,86 @@ export function registerAllTests(): void { }) }) + suite('clipart drawer — grid pre-layout', () => { + // Regression: the drawer's `.drawer-panel` used to be `v-if`-gated on + // clipart.open and given `flex: 1; min-width: 0` — which meant the + // grid mounted at the same moment the drawer started its width + // transition, so the `repeat(auto-fill, minmax(88px, 1fr))` grid + // reflowed continuously from ~1 column at 5px → 4 columns at 424px + // and the thumbs "tumbled" into place. The panel is now always + // mounted at a rigid 400px, and `.clipart-drawer { overflow: hidden }` + // just clips the slide. + + test('.drawer-panel is in the DOM when the drawer is closed', async () => { + const { useClipartStore } = await import('@/stores/clipart') + const s = useClipartStore() + s.setOpen(false) + const handle = mountComponent(ClipartDrawer, {}) + await nextTick() + const panel = handle.host.querySelector('.drawer-panel') + expect(panel).toBeTruthy() + handle.unmount() + }) + + test('.drawer-panel is in the DOM when the drawer is open', async () => { + const { useClipartStore } = await import('@/stores/clipart') + const s = useClipartStore() + s.setOpen(true) + const handle = mountComponent(ClipartDrawer, {}) + await nextTick() + const panel = handle.host.querySelector('.drawer-panel') + expect(panel).toBeTruthy() + handle.unmount() + }) + + test('.drawer-panel stays mounted across open → closed toggle', async () => { + const { useClipartStore } = await import('@/stores/clipart') + const s = useClipartStore() + s.setOpen(true) + const handle = mountComponent(ClipartDrawer, {}) + await nextTick() + expect(handle.host.querySelector('.drawer-panel')).toBeTruthy() + s.setOpen(false) + await nextTick() + expect(handle.host.querySelector('.drawer-panel')).toBeTruthy() + handle.unmount() + }) + + test('.drawer-panel has a rigid 400px width regardless of the drawer state', async () => { + const { useClipartStore } = await import('@/stores/clipart') + const s = useClipartStore() + s.setOpen(false) + const handle = mountComponent(ClipartDrawer, {}) + await nextTick() + const panel = handle.host.querySelector('.drawer-panel') as HTMLElement + expect(panel).toBeTruthy() + const widthClosed = getComputedStyle(panel).width + s.setOpen(true) + await nextTick() + const widthOpen = getComputedStyle(panel).width + expect(widthClosed).toBe('400px') + expect(widthOpen).toBe('400px') + handle.unmount() + }) + + test('.clipart-drawer clips its contents (overflow: hidden)', async () => { + const { useClipartStore } = await import('@/stores/clipart') + const s = useClipartStore() + s.setOpen(false) + const handle = mountComponent(ClipartDrawer, {}) + await nextTick() + const drawer = handle.host.querySelector('.clipart-drawer') as HTMLElement + expect(drawer).toBeTruthy() + const overflowX = getComputedStyle(drawer).overflowX + const overflowY = getComputedStyle(drawer).overflowY + // `overflow: hidden` applies to both axes. We check each because + // browsers normalise the shorthand into the long-hand properties. + expect(overflowX).toBe('hidden') + expect(overflowY).toBe('hidden') + handle.unmount() + }) + }) + suite('polygon — vertex bezier actions', () => { function makeSquare() { const s = makeFreshStore() @@ -3777,6 +4156,88 @@ export function registerAllTests(): void { }) }) + suite('button asset — keyframes snap to state times', () => { + // Regression: keyframes inside a button-asset timeline must only + // exist at the 4 state times (BUTTON_STATE_TIMES = normal=1, + // hover=2, pressed=3, disabled=4). Any code path that places or + // moves a keyframe — upsertKeyframe, setKeyframeTime, + // duplicateKeyframe — snaps the time onto the nearest state. Free + // placement outside the 4 slots is disallowed. + + function setupButton() { + const s = makeFreshStore() + const btn = s.addButtonAsset('B', 200, 80) + s.enterButtonAssetEdit(btn.id) + const a = s.addAsset({ kind: 'rect', name: 'R', width: 200, height: 80, fill: '#fff' }) + const l = s.addLayer(a.id)! + // addLayer already put a keyframe at BUTTON_STATE_TIMES.normal (=1). + return { s, l } + } + + test('upsertKeyframe snaps the requested time to the nearest state', () => { + const { s, l } = setupButton() + // 1.4 is closer to state 1 than to state 2 → should land at 1 + // (merging with the existing one, rather than creating a new off-state kf). + s.upsertKeyframe(l.id, 1.4, { x: 10 }) + expect(l.keyframes.every((k) => BUTTON_STATE_TIMES.normal === k.time || BUTTON_STATE_TIMES.hover === k.time || BUTTON_STATE_TIMES.pressed === k.time || BUTTON_STATE_TIMES.disabled === k.time)).toBe(true) + // 1.6 is closer to state 2 → new keyframe lands at 2 + s.upsertKeyframe(l.id, 1.6, { x: 20 }) + expect(l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-6)).toBeTruthy() + }) + + test('setKeyframeTime snaps drag targets onto state times', () => { + const { s, l } = setupButton() + const kf = l.keyframes[0] + expect(kf.time).toBe(BUTTON_STATE_TIMES.normal) + // Drag "toward" hover but not all the way — 1.8 is closer to 2 → snaps to 2. + s.setKeyframeTime(l.id, kf.id, 1.8) + expect(kf.time).toBe(BUTTON_STATE_TIMES.hover) + // And 2.9 snaps to pressed. + s.setKeyframeTime(l.id, kf.id, 2.9) + expect(kf.time).toBe(BUTTON_STATE_TIMES.pressed) + }) + + test('setKeyframeTime refuses to land on an already-occupied state', () => { + const { s, l } = setupButton() + // Existing kf at state 1. Create a second at state 2. + s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.hover, { x: 99 }) + const kfAtHover = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-6)! + // Drag the hover kf toward normal. State 1 is already occupied — refuse. + s.setKeyframeTime(l.id, kfAtHover.id, 1.1) + expect(kfAtHover.time).toBe(BUTTON_STATE_TIMES.hover) + }) + + test('duplicateKeyframe picks the nearest free state slot', () => { + const { s, l } = setupButton() + // Only state 1 is occupied; duplicate should land on the next + // closest free slot — state 2 (hover). + const sourceId = l.keyframes[0].id + const newId = s.duplicateKeyframe(l.id, sourceId)! + expect(newId).toBeTruthy() + const dup = l.keyframes.find((k) => k.id === newId)! + expect(dup.time).toBe(BUTTON_STATE_TIMES.hover) + }) + + test('duplicateKeyframe returns null when every state slot is taken', () => { + const { s, l } = setupButton() + s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.hover, { x: 2 }) + s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.pressed, { x: 3 }) + s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.disabled, { x: 4 }) + const sourceId = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!.id + const newId = s.duplicateKeyframe(l.id, sourceId) + expect(newId).toBe(null) + }) + + test('on the main timeline, snapping is NOT applied (free placement)', () => { + 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, 1.4, { x: 5 }) + // No button-edit context → the kf lives at the exact requested time. + expect(l.keyframes.some((k) => Math.abs(k.time - 1.4) < 1e-6)).toBe(true) + }) + }) + suite('button asset — mask rendering', () => { // Regression: masks inside buttons used to be ignored by every render // path — the button branch in drawLayers called drawLayers recursively diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 2eda2e1..c6850b7 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -20,6 +20,13 @@ export const en = { confirm: 'Save', defaultName: 'Project {date}', }, + renameProject: 'Rename project', + renameProjectDialog: { + title: 'Rename project', + message: 'Choose a new name for this project.', + confirm: 'Rename', + }, + untitledProject: 'Untitled project', replaceDialog: { title: 'Replace project?', message: @@ -237,7 +244,6 @@ export const en = { easingEaseInOut: 'Ease In-Out', firstKfHint: 'First keyframe — easing has no effect', holdDisabledHint: 'Still frame — easing disabled', - lastKfHint: 'Last keyframe — still frame has no effect', stillFrame: 'Still frame', duplicate: 'Duplicate', delete: 'Delete', diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts index e1213fb..cdb7cea 100644 --- a/src/lib/exportPlayer.ts +++ b/src/lib/exportPlayer.ts @@ -1,6 +1,6 @@ import type { Scene } from '@/types/scene' -const RUNTIME_VERSION = '11' +const RUNTIME_VERSION = '13' const ENGINE_SOURCE = `(function(){ if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return; @@ -70,6 +70,20 @@ const ENGINE_SOURCE = `(function(){ if(a.strokeWidth!=null&&b.strokeWidth!=null) out.strokeWidth=lerp(a.strokeWidth,b.strokeWidth,t); else if(b.strokeWidth!=null) out.strokeWidth=b.strokeWidth; else if(a.strokeWidth!=null) out.strokeWidth=a.strokeWidth; + if(a.blur!=null && b.blur!=null) out.blur=lerp(a.blur,b.blur,t); + else if(b.blur!=null) out.blur=b.blur; + else if(a.blur!=null) out.blur=a.blur; + var shadowNumKeys=['shadowBlur','shadowOpacity','shadowAngle','shadowDistance']; + for(var sk=0;skmx) mx=t; } } + var mn=Infinity, mx=-Infinity, lastHold=false; + function scan(arr){ + for(var i=0;imx){ mx=t; lastHold=!!k.hold; } + else if(t===mx && k.hold){ lastHold=true; } + } + } scan(main); scan(pos); scan(rot); + if(lastHold) mx=Infinity; var EPS=1e-6; return time>=mn-EPS && time<=mx+EPS; } diff --git a/src/lib/interpolate.ts b/src/lib/interpolate.ts index 1b7a3b6..ec9ca20 100644 --- a/src/lib/interpolate.ts +++ b/src/lib/interpolate.ts @@ -134,6 +134,12 @@ function sampleKeyframeSet( * * Returns null (→ always visible) when the layer has zero or one total * keyframes — a single keyframe is a static layer with no animation range. + * + * Special case: when the keyframe with the latest time has `hold: true`, + * the max is extended to Infinity — the layer stays alive past its last + * keyframe, pinned to that keyframe's sampled value. That's how users + * turn the final keyframe of a short anim into a still frame so the + * layer stays on-screen for the remainder of the timeline. */ export function layerActiveRange( layer: Layer, @@ -145,12 +151,19 @@ export function layerActiveRange( if (total <= 1) return null let min = Infinity let max = -Infinity + let lastHold = false for (const arr of [main, pos, rot]) { for (const k of arr) { if (k.time < min) min = k.time - if (k.time > max) max = k.time + if (k.time > max) { + max = k.time + lastHold = !!k.hold + } else if (k.time === max && k.hold) { + lastHold = true + } } } + if (lastHold) max = Infinity return { min, max } } diff --git a/src/stores/scene.ts b/src/stores/scene.ts index b9c48d8..05c4325 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -35,6 +35,28 @@ type AssetInit = Asset extends infer A ? (A extends Asset ? Omit : neve const KEYFRAME_EPSILON = 1e-3 +const BUTTON_STATE_TIME_VALUES: readonly number[] = [ + BUTTON_STATE_TIMES.normal, + BUTTON_STATE_TIMES.hover, + BUTTON_STATE_TIMES.pressed, + BUTTON_STATE_TIMES.disabled, +] + +/** In button-edit mode keyframes may only land on one of the 4 state times. + * Returns the nearest state time to the given raw time. */ +function snapToButtonStateTime(t: number): number { + let best = BUTTON_STATE_TIME_VALUES[0] + let bestDelta = Math.abs(t - best) + for (let i = 1; i < BUTTON_STATE_TIME_VALUES.length; i++) { + const d = Math.abs(t - BUTTON_STATE_TIME_VALUES[i]) + if (d < bestDelta) { + best = BUTTON_STATE_TIME_VALUES[i] + bestDelta = d + } + } + return best +} + /** The button editor has two orthogonal sub-modes: state editing (4 content * states as kf-time slots) vs hit-area editing (hit shapes only, with normal * state as onion-skin). Switching modes keeps each mode's last selection. */ @@ -201,6 +223,23 @@ function normalizeScene(scene: Scene): Scene { for (const st of scene.scriptTracks) { if (!scene.trackOrder.includes(st.id)) scene.trackOrder.push(st.id) } + + // Migration: scenes saved before `colorOverridden` existed must treat + // any track that already carries a `color` as user-set. Without this, + // the first subsequent group-color change would silently overwrite + // pre-existing per-track colors. + for (const l of scene.layers) { + if (l.color != null && l.colorOverridden === undefined) l.colorOverridden = true + } + for (const t of scene.clipTracks) { + if (t.color != null && t.colorOverridden === undefined) t.colorOverridden = true + } + for (const st of scene.scriptTracks) { + if (st.color != null && st.colorOverridden === undefined) st.colorOverridden = true + } + for (const g of scene.groups) { + if (g.color != null && g.colorOverridden === undefined) g.colorOverridden = true + } return scene } @@ -450,6 +489,12 @@ export const useSceneStore = defineStore('scene', { upsertKeyframe(layerId: string, time: number, props: Partial) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return + // Button-edit mode: keyframes only exist at the 4 state times. + // Any upsert call (from lane-click, polygon vertex, shortcut, …) + // is forced onto the nearest state. `upsertInto` already merges + // with an existing keyframe at the same time, so collisions are + // handled automatically. + if (this.activeButtonAssetId) time = snapToButtonStateTime(time) if (!layer.separated) { const snapshot = sampleLayer(layer, time) upsertInto(layer.keyframes, time, props, snapshot) @@ -557,7 +602,20 @@ export const useSceneStore = defineStore('scene', { if (!layer) return const found = findKf(layer, keyframeId) if (!found) return - found.kf.time = Math.max(0, Math.min(this.activeDuration, newTime)) + let t = newTime + if (this.activeButtonAssetId) { + t = snapToButtonStateTime(t) + // Refuse to land on a slot another keyframe already occupies — + // the drag silently holds at its current time. This is a soft + // rejection; the UI will re-issue setKeyframeTime as the user + // continues dragging, so moving PAST a taken slot still works + // once the pointer reaches an empty one. + const collides = found.arr.some( + (k) => k.id !== keyframeId && Math.abs(k.time - t) < KEYFRAME_EPSILON, + ) + if (collides) return + } + found.kf.time = Math.max(0, Math.min(this.activeDuration, t)) sortKfs(found.arr) }, shiftLayerPivot( @@ -593,6 +651,25 @@ export const useSceneStore = defineStore('scene', { const offset = Math.max(3 / fps, 0.1) const duration = this.activeDuration const occupied = (t: number) => arr.some((k) => Math.abs(k.time - t) < KEYFRAME_EPSILON) + // Button-edit mode: snap the duplicate onto the nearest free state + // slot (walking outward from the source keyframe). If all 4 slots + // are taken, refuse. + if (this.activeButtonAssetId) { + const order: number[] = [] + for (const v of BUTTON_STATE_TIME_VALUES) order.push(v) + order.sort((a, b) => Math.abs(a - source.time) - Math.abs(b - source.time)) + const freeSlot = order.find((t) => !occupied(t)) + if (freeSlot === undefined) return null + const newKf: Keyframe = { + id: uid(), + time: freeSlot, + props: { ...source.props }, + easing: source.easing, + } + arr.push(newKf) + sortKfs(arr) + return newKf.id + } let target = source.time + offset while (occupied(target) && target < duration) target += offset if (target > duration) target = duration @@ -613,28 +690,77 @@ export const useSceneStore = defineStore('scene', { return newKf.id }, setTrackColor(trackId: string, color: string | null) { + // Group color changes cascade to every non-overridden direct child + // (layers and clip-tracks — groups are never nested inside groups, + // see normalizeScene). Individual (non-group) color changes always + // mark the track as overridden so subsequent group-color changes + // skip it. Clearing a non-group track's color (color === null) + // drops the override, so the track goes back to inheriting from + // its parent group — if it has one. + const group = this.scene.groups?.find((g) => g.id === trackId) + if (group) { + if (color === null) { + delete group.color + delete group.colorOverridden + } else { + group.color = color + group.colorOverridden = true + } + for (const id of group.childIds) { + const l = this.scene.layers.find((x) => x.id === id) + if (l) { + if (l.colorOverridden) continue + if (color === null) delete l.color + else l.color = color + continue + } + const ct = this.scene.clipTracks?.find((x) => x.id === id) + if (ct) { + if (ct.colorOverridden) continue + if (color === null) delete ct.color + else ct.color = color + } + } + return + } + + const parentGroup = this.scene.groups?.find((g) => g.childIds.includes(trackId)) + const parentColor = parentGroup?.color ?? null + const layer = this.activeLayers.find((l) => l.id === trackId) if (layer) { - if (color === null) delete layer.color - else layer.color = color + if (color === null) { + delete layer.colorOverridden + if (parentColor !== null) layer.color = parentColor + else delete layer.color + } else { + layer.color = color + layer.colorOverridden = true + } return } const clipTrack = this.scene.clipTracks?.find((t) => t.id === trackId) if (clipTrack) { - if (color === null) delete clipTrack.color - else clipTrack.color = color + if (color === null) { + delete clipTrack.colorOverridden + if (parentColor !== null) clipTrack.color = parentColor + else delete clipTrack.color + } else { + clipTrack.color = color + clipTrack.colorOverridden = true + } return } const scriptTrack = this.scene.scriptTracks?.find((t) => t.id === trackId) if (scriptTrack) { - if (color === null) delete scriptTrack.color - else scriptTrack.color = color - return - } - const group = this.scene.groups?.find((g) => g.id === trackId) - if (group) { - if (color === null) delete group.color - else group.color = color + // Script tracks are never inside a group, so no inheritance. + if (color === null) { + delete scriptTrack.color + delete scriptTrack.colorOverridden + } else { + scriptTrack.color = color + scriptTrack.colorOverridden = true + } } }, shiftKeyframesPosition( @@ -1534,6 +1660,17 @@ export const useSceneStore = defineStore('scene', { } const at = atIndex === undefined ? target.childIds.length : Math.max(0, Math.min(atIndex, target.childIds.length)) target.childIds.splice(at, 0, trackId) + + // Inherit the target group's color when the moved track hasn't + // been explicitly recoloured by the user. Matches the cascade + // behaviour of setTrackColor so a track joining a coloured group + // visually lines up with its siblings. + if (target.color != null) { + const l = this.scene.layers.find((x) => x.id === trackId) + if (l && !l.colorOverridden) l.color = target.color + const ct = this.scene.clipTracks?.find((x) => x.id === trackId) + if (ct && !ct.colorOverridden) ct.color = target.color + } }, moveTrackOutOfGroup(trackId: string, toTopLevelIndex?: number) { for (const g of this.scene.groups ?? []) { diff --git a/src/types/scene.ts b/src/types/scene.ts index 733fc39..032c5ec 100644 --- a/src/types/scene.ts +++ b/src/types/scene.ts @@ -158,6 +158,13 @@ export interface Layer { offsetX?: number offsetY?: number color?: string | null + /** + * True when the user explicitly chose this track's color via the UI. + * Group-color cascades skip children whose flag is true so user choices + * stick once made. Children with the flag unset inherit their parent + * group's color. + */ + colorOverridden?: boolean locked?: boolean isMask?: boolean maskMode?: MaskMode @@ -178,6 +185,7 @@ export interface ClipTrack { id: string name?: string color?: string | null + colorOverridden?: boolean locked?: boolean } @@ -187,6 +195,7 @@ export interface TrackGroup { childIds: string[] collapsed?: boolean color?: string | null + colorOverridden?: boolean locked?: boolean } @@ -213,6 +222,7 @@ export interface ScriptTrack { id: string name?: string color?: string | null + colorOverridden?: boolean locked?: boolean } diff --git a/src/version.ts b/src/version.ts index 2ab41fc..185d417 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.5.6' +export const APP_VERSION = '0.8.0' export const APP_NAME = 'RUWIO' diff --git a/todo b/todo index 409d575..e7af558 100644 --- a/todo +++ b/todo @@ -120,14 +120,15 @@ die states sind verschiedene zeitleisten innerhalb des buttons die umgeschaltet im button editor zeigt das preview mit angewendeten masken die hauptbühne an - nicht die button-bühne - - - - die farbe einer gruppe muss automatisch auf alle kinder angewendet werden. es sei denn man färbt einen track HINTERHER anders ein im button dürfen keyframes nur auf den 4 states einrasten +der clip-art drawer muss bevor er reingeschoben wird schon so tun als hätte er die volle breite damit die thumbs nicht hin und her purzteln beim verbreitern. die position von den thimbs muss vor dem slide in feststehen + + irgendwie ist das verschieben von elementen kaputt gegangen. in dem szenario habe ich 2 einfache elemente auf der bühne. wenn ich das eine verschiebe verschiebt sich das andere und das was ich eigenlich verschoben habe snapt zurück, das betrifft das drüber liegende element. bei dem unteren layer gehts. das war mir aber schon vorher mal aufgefallen in einem anderen film + + shadow einstellungen auf keyframes werden in preview und editor nicht gleich behandelt. eine animation des shadows findet im preview nicht statt @@ -139,4 +140,5 @@ im button dürfen keyframes nur auf den 4 states einrasten - ich würde an dieser stelle mal ein paar regression-tests hinzufügen und dann committen \ No newline at end of file + + ich würde an dieser stelle mal ein paar regression-tests hinzufügen und dann committen