0.8.0: group colour cascade, still-frame past end, project rename, UX polish

Headline:

- Group colour cascades to every non-overridden child (layer or clip
  track). New `colorOverridden` flag on Layer / ClipTrack / ScriptTrack
  / TrackGroup; setTrackColor on a non-group pins the track, clearing
  re-inherits from the parent group. moveTrackIntoGroup also inherits.
  normalizeScene migrates legacy scenes (existing colour ⇒ overridden).
- Keyframes inside a button-asset timeline snap to the 4 state slots
  (normal/hover/pressed/disabled). upsertKeyframe / setKeyframeTime /
  duplicateKeyframe all enforce it; colliding drags refuse the move,
  duplicates walk to the nearest free slot.
- Backspace / Delete on a selected layer removes it (respecting the
  layer's own lock and any enclosing group lock).
- "Still frame" allowed on the last keyframe: the layer stays on stage
  past its final kf. Timeline draws a solid dim tail line from the
  last kf to the end, and single-keyframe tracks now show a full-span
  line so on-stage/off-stage reads at a glance. Height stays 3px.
- Project name moved next to the logo, turns into a rename button on
  hover (pencil fades in). Rename syncs the stored record when the
  project is already persisted.
- Shadow + blur keyframes animate in the preview and exported runtime:
  the runtime's lerpProps used to drop them, so every preview rendered
  shadow=0 between keyframes. RUNTIME_VERSION 11 → 13 (covers the
  button-mask + shadow fixes). Held-last-kf also taught to the runtime.
- Button masks render across every path: edit-mode mask-preview,
  nested buttons on the main stage, sidebar thumbnails, fullscreen
  preview + exports. drawButtonFrame new in render.ts.

Smaller fixes:

- Dragging layer A on the stage with a stale selection on layer B used
  to move B and snap A back. Now the shift-selected branch only runs
  when the dragged layer is itself in the selection.
- Clip-track row was missing its icon — PhFilmStrip was referenced but
  never imported. Added, plus dropped the bespoke `gap` on clip / script
  name rows so their icon+label spacing matches layer rows.
- Clip-art drawer pre-lays-out its 400px grid behind `overflow: hidden`
  so slide-in reveals a finished grid instead of reflowing auto-fill
  columns over 220ms.
- Timeline clip-track dropzone hides until the library has a clip; its
  "drop a clip here" hint stays centred in the visible ruler area.
- Keyframe context menu: check-mark and icons share a single 12px column
  so the ✓ on an easing option lines up with PhPauseCircle / PhCopy /
  PhTrash on the rows below. "Still frame"'s active indicator moved to
  the trailing-check slot.
- Button editor's mask-preview now shows the button's own stage instead
  of leaking through to the main scene. drawButtonFrame drives it.

Regression tests cover all of the above: group-color cascade, pin,
clear-re-inherit, migration, button-kf snap and collision rules,
backspace-deletes-selected-layer (incl. lock guards + editable-target
guard), still-frame-past-end visibility, clipart drawer rigid width,
mask rendering in drawButtonFrame / drawSceneFrame, shadow + blur
interpolation in both canvas and runtime paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-21 21:26:06 +02:00
1 parent ac89f6762d
commit 1e89ea86e0
15 files changed
+849 -74

No files matched your search

+77 -12
View File
@@ -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(() => {
<button class="brand" :title="t('about.openTooltip')" @click="aboutOpen = true">
<img :src="ruwioLogo" alt="RUWIO" class="brand-logo" />
</button>
<button
class="project-name-btn"
:class="{ 'is-untitled': !currentProjectName }"
:title="t('app.renameProject')"
@click="renameProject"
>
<span class="project-name-label">
{{ currentProjectName ?? t('app.untitledProject') }}
</span>
<PhPencilSimpleLine :size="12" class="project-name-pencil" />
</button>
</div>
<div class="transport">
<IconButton
@@ -410,9 +450,6 @@ onBeforeUnmount(() => {
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
</div>
<div class="topbar-right">
<span v-if="currentProjectName" class="project-name" :title="currentProjectName">
{{ currentProjectName }}
</span>
<button class="icon-btn" :title="t('app.recentProjects')" @click="welcomeOpen = true">
<PhFolderOpen :size="16" />
</button>
@@ -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;
}
</style>
+6 -1
View File
@@ -2414,7 +2414,12 @@ function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
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
+11 -3
View File
@@ -49,7 +49,7 @@ function onItemDragStart(e: DragEvent, index: number) {
>
<component :is="clipart.open ? PhCaretLeft : PhCaretRight" :size="14" />
</button>
<div v-if="clipart.open" class="drawer-panel">
<div class="drawer-panel">
<header class="drawer-header">
<PhImagesSquare :size="14" class="title-icon" />
<h3>{{ t('clipart.title') }}</h3>
@@ -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;
+79 -33
View File
@@ -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(() => {
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="seg in segmentsOf(track.kfs)"
v-for="seg in segmentsOf(track.kfs, duration)"
:key="seg.id"
class="kf-seg"
:class="[seg.held ? 'held' : `easing-${seg.easing}`]"
:class="[seg.held ? 'held' : `easing-${seg.easing}`, { tail: seg.tail }]"
:style="segmentStyle(seg)"
/>
<span
@@ -2777,14 +2829,13 @@ onBeforeUnmount(() => {
:disabled="menu.isFirst || menu.isHeld"
@click="applyEasing(opt.value)"
>
<span class="check">
<span class="kf-menu-icon">
<PhCheck
v-if="menu.currentEasing === opt.value && !menu.isHeld"
:size="12"
weight="bold"
/>
</span>
<span class="kf-menu-icon-spacer"></span>
{{ opt.label }}
</button>
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
@@ -2794,24 +2845,23 @@ onBeforeUnmount(() => {
<button
class="kf-menu-item"
:class="{ active: menu.isHeld }"
:disabled="menu.isLast"
:title="menu.isLast ? t('timeline.lastKfHint') : ''"
@click="toggleHold"
>
<span class="check">
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
</span>
<PhPauseCircle :size="12" class="kf-menu-icon" />
{{ t('timeline.stillFrame') }}
<PhCheck
v-if="menu.isHeld"
:size="12"
weight="bold"
class="kf-menu-trailing-check"
/>
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="duplicateFromMenu">
<span class="check"></span>
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
<button class="kf-menu-item danger" @click="deleteFromMenu">
<span class="check"></span>
<PhTrash :size="12" class="kf-menu-icon" />
{{ t('timeline.delete') }}
</button>
@@ -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;
+461
View File
@@ -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
+7 -1
View File
@@ -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',
+25 -3
View File
@@ -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;sk<shadowNumKeys.length;sk++){
var skName=shadowNumKeys[sk];
var sav=a[skName], sbv=b[skName];
if(sav!=null && sbv!=null) out[skName]=lerp(sav,sbv,t);
else if(sbv!=null) out[skName]=sbv;
else if(sav!=null) out[skName]=sav;
}
if(a.shadowColor && b.shadowColor) out.shadowColor=lerpColor(a.shadowColor,b.shadowColor,t);
else if(b.shadowColor) out.shadowColor=b.shadowColor;
else if(a.shadowColor) out.shadowColor=a.shadowColor;
return out;
}
function sample(layer,time){
@@ -489,9 +503,17 @@ const ENGINE_SOURCE = `(function(){
var rot=layer.rotationKeyframes||[];
var total=main.length+pos.length+rot.length;
if(total<=1) return true;
var mn=Infinity, mx=-Infinity;
function scan(arr){ for(var i=0;i<arr.length;i++){ var t=arr[i].time; if(t<mn) mn=t; if(t>mx) mx=t; } }
var mn=Infinity, mx=-Infinity, lastHold=false;
function scan(arr){
for(var i=0;i<arr.length;i++){
var k=arr[i], t=k.time;
if(t<mn) mn=t;
if(t>mx){ 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;
}
+14 -1
View File
@@ -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 }
}
+150 -13
View File
@@ -35,6 +35,28 @@ type AssetInit = Asset extends infer A ? (A extends Asset ? Omit<A, 'id'> : 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<AnimatableProps>) {
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 ?? []) {
+10
View File
@@ -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
}
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.5.6'
export const APP_VERSION = '0.8.0'
export const APP_NAME = 'RUWIO'