0.8.4: button transitions, locked groups revoke edits, UX polish
Button-state transitions - State flips on stage-preview buttons (normal / hover / pressed / disabled) now ease over 50ms instead of snapping. New `transitions` map + rAF ticker in useButtonRuntime drive a continuous sample time between BUTTON_STATE_TIMES slots; CanvasStage samples each button child at that interpolated time so keyframes at the 4 states blend smoothly. Mid-flight flips (hover → pressed while still in normal → hover) rebase from the current live sample instead of snapping. Locked groups revoke edits - Locking a layer directly or its enclosing group deselects the layer so the Properties panel can't keep editing it. Panel also splits into three branches — editable / locked / no-layer — with a dedicated locked-layer hint. - Timeline keyframe interactions (drag, dblclick-to-remove, context menu, dblclick-to-create on lane, add-kf toolbar) now consult effective lock (direct + parent group) instead of just layer.locked. Editor polish - Inactive tracks fade as a whole row (opacity 0.5) instead of per-child, so keyframes, segments, and still-frame tails stay visually proportional to their active counterparts. - Dragging a layer onto a group's track-name label now drops it INTO the group (was previously only the group-lane area). - Button assets on the stage show a pointer cursor on hover so it's obvious they're interactive. - Global CSS forces `cursor: pointer !important` on every enabled button / role="button" so scoped component styles can no longer accidentally shadow the default. - Timeline resize handle jumped above the scrub-bar (z-index 100) so the full 14px grab zone is hit-testable again. Regression tests - setTrackLocked deselect paths: direct lock, group lock with selected child, group lock with unrelated selection, unlock doesn't re-select. - duplicateKeyframeAt: props + easing + hold + bezier copied, collision refusal, unknown-id guards, separated-track routing. - Button transition runtime: no-transition baseline, mid-flight bounds, mid-transition rebase, no-op setState. - Shared composables tooltips + asset-thumbs: same ref across calls, localStorage round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1d84c34c15
commit
af5f99380c
12 files changed
+401
-34
No files matched your search
@@ -3232,6 +3232,59 @@ export function registerAllTests(): void {
|
||||
// The layer itself stays unlocked; effective lock is derived in UI code.
|
||||
expect(l.locked).toBe(undefined)
|
||||
})
|
||||
|
||||
test('locking a layer directly deselects it', () => {
|
||||
// Regression: locking a layer while it was selected used to leave
|
||||
// the props panel happily editing the now-inert track. The
|
||||
// selection must drop on lock so edits can't leak through.
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.selectLayer(l.id)
|
||||
expect(s.selectedLayerId).toBe(l.id)
|
||||
s.setTrackLocked(l.id, true)
|
||||
expect(s.selectedLayerId).toBe(null)
|
||||
})
|
||||
|
||||
test('locking a group deselects a selected child layer', () => {
|
||||
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.selectLayer(l.id)
|
||||
expect(s.selectedLayerId).toBe(l.id)
|
||||
s.setTrackLocked(g.id, true)
|
||||
expect(s.selectedLayerId).toBe(null)
|
||||
})
|
||||
|
||||
test('locking a group with no selected child leaves selection alone', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#f00' })
|
||||
const b = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#0f0' })
|
||||
const la = s.addLayer(a.id)!
|
||||
const lb = s.addLayer(b.id)!
|
||||
const g = s.addTrackGroup('G')
|
||||
// Only move la into the group; lb is selected and stays top-level.
|
||||
s.moveTrackIntoGroup(la.id, g.id)
|
||||
s.selectLayer(lb.id)
|
||||
s.setTrackLocked(g.id, true)
|
||||
// Locking the group can't touch the selection that lives outside it.
|
||||
expect(s.selectedLayerId).toBe(lb.id)
|
||||
})
|
||||
|
||||
test('unlocking a group does not auto-reselect the previously-selected layer', () => {
|
||||
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.selectLayer(l.id)
|
||||
s.setTrackLocked(g.id, true)
|
||||
// Now unlocking — selection stays null; user re-selects explicitly.
|
||||
s.setTrackLocked(g.id, false)
|
||||
expect(s.selectedLayerId).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
suite('render — crop option', () => {
|
||||
@@ -4269,6 +4322,89 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('button asset — state transition runtime', () => {
|
||||
// Regression: button state changes used to snap instantly between
|
||||
// the normal / hover / pressed / disabled slots. `setState` now
|
||||
// records a transition whose sample time lerps between the
|
||||
// corresponding `BUTTON_STATE_TIMES` slots over
|
||||
// `BUTTON_STATE_TRANSITION_MS`. `getEffectiveTime` is what the
|
||||
// renderer reads; it must return an intermediate time during the
|
||||
// transition and snap to the target once finished.
|
||||
|
||||
async function importRt() {
|
||||
const mod = await import('@/composables/useButtonRuntime')
|
||||
return {
|
||||
rt: mod.useButtonRuntime(),
|
||||
DURATION: mod.BUTTON_STATE_TRANSITION_MS,
|
||||
}
|
||||
}
|
||||
|
||||
test('getEffectiveTime returns the target slot when no transition is running', async () => {
|
||||
const { rt } = await importRt()
|
||||
rt.clear('btn-a')
|
||||
expect(rt.getEffectiveTime('btn-a', false)).toBe(BUTTON_STATE_TIMES.normal)
|
||||
rt.clear('btn-a')
|
||||
// Fast-path: disabled layers report disabled regardless of the
|
||||
// runtime state or transitions.
|
||||
expect(rt.getEffectiveTime('btn-a', true)).toBe(BUTTON_STATE_TIMES.disabled)
|
||||
rt.clear('btn-a')
|
||||
})
|
||||
|
||||
test('setState kicks off a transition; mid-flight time lies between slots', async () => {
|
||||
const { rt, DURATION } = await importRt()
|
||||
rt.clear('btn-b')
|
||||
rt.setState('btn-b', 'hover')
|
||||
// Immediately after setState, time is ~fromTime (normal=1) and
|
||||
// approaching toTime (hover=2).
|
||||
const t0 = rt.getEffectiveTime('btn-b', false)
|
||||
expect(t0).toBeGreaterThan(BUTTON_STATE_TIMES.normal - 0.001)
|
||||
expect(t0).toBeLessThan(BUTTON_STATE_TIMES.hover)
|
||||
// After half the duration the time should be around halfway.
|
||||
await new Promise((r) => setTimeout(r, DURATION / 2))
|
||||
const tMid = rt.getEffectiveTime('btn-b', false)
|
||||
expect(tMid).toBeGreaterThan(BUTTON_STATE_TIMES.normal)
|
||||
expect(tMid).toBeLessThan(BUTTON_STATE_TIMES.hover)
|
||||
// After the full duration (+ slack) the transition resolves to
|
||||
// the target slot exactly.
|
||||
await new Promise((r) => setTimeout(r, DURATION + 40))
|
||||
expect(rt.getEffectiveTime('btn-b', false)).toBe(BUTTON_STATE_TIMES.hover)
|
||||
rt.clear('btn-b')
|
||||
})
|
||||
|
||||
test('mid-transition setState rebases from the live sample time', async () => {
|
||||
// Regression: if hover → pressed flips in-flight from a previous
|
||||
// normal → hover transition, the new transition should start at
|
||||
// the *current* intermediate sample time instead of snapping
|
||||
// back to the old `from` slot. Otherwise a quick hover+press
|
||||
// produces a visible jump.
|
||||
const { rt, DURATION } = await importRt()
|
||||
rt.clear('btn-c')
|
||||
rt.setState('btn-c', 'hover')
|
||||
await new Promise((r) => setTimeout(r, DURATION / 2))
|
||||
// At this point the effective time is roughly halfway between
|
||||
// normal (1) and hover (2) — somewhere around 1.4–1.6.
|
||||
const midway = rt.getEffectiveTime('btn-c', false)
|
||||
rt.setState('btn-c', 'pressed')
|
||||
// The freshly-started transition must begin at or just past the
|
||||
// midway sample, not drop back toward normal (1).
|
||||
const after = rt.getEffectiveTime('btn-c', false)
|
||||
// Allow a small drift forward because `setTimeout` and the
|
||||
// subsequent assignment both burn a few ms — the important
|
||||
// thing is we didn't regress into the [1, midway) interval.
|
||||
expect(after).toBeGreaterThan(midway - 0.01)
|
||||
rt.clear('btn-c')
|
||||
})
|
||||
|
||||
test('no-op setState (target === current) does not start a transition', async () => {
|
||||
const { rt } = await importRt()
|
||||
rt.clear('btn-d')
|
||||
// Currently normal; asking for normal again is a no-op.
|
||||
rt.setState('btn-d', 'normal')
|
||||
expect(rt.getEffectiveTime('btn-d', false)).toBe(BUTTON_STATE_TIMES.normal)
|
||||
rt.clear('btn-d')
|
||||
})
|
||||
})
|
||||
|
||||
suite('button asset — thumbnail rendering', () => {
|
||||
// Regression: renderButtonThumbnail used to pass images={} and any image
|
||||
// layer inside a button produced a fully-transparent PNG, making
|
||||
|
||||
Reference in new issue
Block a user