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
+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 ?? []) {
|
||||
|
||||
Reference in new issue
Block a user