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

+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 ?? []) {