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:
1 parent
ac89f6762d
commit
1e89ea86e0
15 files changed
+849
-74
No files matched your search
+77
-12
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 ?? []) {
|
||||
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.5.6'
|
||||
export const APP_VERSION = '0.8.0'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user