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
+5
-1
@@ -812,7 +812,11 @@ onBeforeUnmount(() => {
|
||||
right: 0;
|
||||
height: 14px;
|
||||
cursor: ns-resize;
|
||||
z-index: 20;
|
||||
/* Must sit above everything inside the Timeline — its .scrub-bar is
|
||||
`position: sticky; z-index: 40`, which (because the Timeline root
|
||||
has no z-index of its own) competes in this same stacking context
|
||||
and would otherwise swallow the bottom half of the grab area. */
|
||||
z-index: 100;
|
||||
touch-action: none;
|
||||
background: transparent;
|
||||
transition: background 120ms var(--ease);
|
||||
|
||||
@@ -492,12 +492,11 @@ function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
|
||||
return { ...cfg, draggable: false, listening: false }
|
||||
}
|
||||
|
||||
function buttonStackForState(
|
||||
function buttonStackForTime(
|
||||
asset: ButtonAsset,
|
||||
state: ButtonState,
|
||||
stateTime: number,
|
||||
): Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> {
|
||||
const out: Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> = []
|
||||
const stateTime = BUTTON_STATE_TIMES[state]
|
||||
const layers = asset.layers.slice().reverse()
|
||||
for (const l of layers) {
|
||||
if (!l.visible) continue
|
||||
@@ -508,13 +507,24 @@ function buttonStackForState(
|
||||
return out
|
||||
}
|
||||
|
||||
function setStageCursor(cursor: string) {
|
||||
const el = stageRef.value?.getStage().container()
|
||||
if (el) el.style.cursor = cursor
|
||||
}
|
||||
|
||||
function onButtonEnter(layer: Layer) {
|
||||
// Pointer cursor whenever a button asset is under the mouse — it's
|
||||
// interactive UX even in edit mode, where it's draggable like any
|
||||
// other layer. In preview mode, also flip the state machine into
|
||||
// `hover` so visual state changes fire.
|
||||
if (!isLayerEffectivelyLocked(layer)) setStageCursor('pointer')
|
||||
if (!stagePreview.value) return
|
||||
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
|
||||
if (cur === 'normal') buttonRt.setState(layer.id, 'hover')
|
||||
}
|
||||
|
||||
function onButtonLeave(layer: Layer) {
|
||||
setStageCursor('')
|
||||
if (!stagePreview.value) return
|
||||
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
|
||||
if (cur === 'hover' || cur === 'pressed') buttonRt.setState(layer.id, 'normal')
|
||||
@@ -543,12 +553,12 @@ function buttonAssetFor(asset: Asset): ButtonAsset | null {
|
||||
return asset.kind === 'button' ? asset : null
|
||||
}
|
||||
|
||||
function buttonStateFor(layer: Layer): ButtonState {
|
||||
function buttonTimeFor(layer: Layer): number {
|
||||
// In edit mode the button is a static asset — always show its normal
|
||||
// state regardless of leftover runtime state (e.g. from a previous
|
||||
// preview session).
|
||||
if (!stagePreview.value) return 'normal'
|
||||
return buttonRt.getEffective(layer.id, layer.buttonDisabled)
|
||||
if (!stagePreview.value) return BUTTON_STATE_TIMES.normal
|
||||
return buttonRt.getEffectiveTime(layer.id, layer.buttonDisabled)
|
||||
}
|
||||
|
||||
const stageConfig = computed(() => ({
|
||||
@@ -2912,7 +2922,7 @@ onBeforeUnmount(() => {
|
||||
:config="buttonHitCatcherConfig(buttonAssetFor(entry.item.asset)!, entry.item.layer)"
|
||||
/>
|
||||
<template
|
||||
v-for="(sub, bli) in buttonStackForState(buttonAssetFor(entry.item.asset)!, buttonStateFor(entry.item.layer))"
|
||||
v-for="(sub, bli) in buttonStackForTime(buttonAssetFor(entry.item.asset)!, buttonTimeFor(entry.item.layer))"
|
||||
:key="`${entry.item.layer.id}:${sub.layer.id}:${bli}`"
|
||||
>
|
||||
<v-rect
|
||||
|
||||
@@ -74,6 +74,19 @@ const sampled = computed<AnimatableProps | null>(() =>
|
||||
selected.value ? sampleLayer(selected.value, store.playhead) : null,
|
||||
)
|
||||
|
||||
/** True when the selected layer is locked directly or reached through
|
||||
* a locked parent group. Suppresses the edit UI — the user must
|
||||
* unlock the layer (or the group) before changing properties. */
|
||||
const selectedIsLocked = computed(() => {
|
||||
const l = selected.value
|
||||
if (!l) return false
|
||||
if (l.locked) return true
|
||||
for (const g of store.scene.groups ?? []) {
|
||||
if (g.locked && g.childIds.includes(l.id)) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const hasKeyframeHere = computed(() => {
|
||||
if (!selected.value) return false
|
||||
return selected.value.keyframes.some((k) => Math.abs(k.time - store.playhead) < 1e-3)
|
||||
@@ -375,7 +388,7 @@ function removeKeyframeHere() {
|
||||
<template>
|
||||
<div class="panel">
|
||||
<h3>Properties</h3>
|
||||
<template v-if="selected && sampled">
|
||||
<template v-if="selected && sampled && !selectedIsLocked">
|
||||
<label>Name<input v-model="selected.name" /></label>
|
||||
|
||||
<div class="kf-row" :class="{ on: hasKeyframeHere }">
|
||||
@@ -645,6 +658,9 @@ function removeKeyframeHere() {
|
||||
{{ t('properties.deleteLayer') }}
|
||||
</button>
|
||||
</template>
|
||||
<p v-else-if="selected && selectedIsLocked" class="hint">
|
||||
{{ t('properties.lockedLayer') }}
|
||||
</p>
|
||||
<p v-else class="hint">{{ t('properties.noLayer') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+33
-10
@@ -544,14 +544,16 @@ const canCreateKf = computed(() => {
|
||||
const id = store.selectedLayerId
|
||||
if (!id) return false
|
||||
const layer = store.activeLayers.find((l) => l.id === id)
|
||||
return !!layer && !layer.locked
|
||||
if (!layer) return false
|
||||
if (layer.locked) return false
|
||||
return !lockedGroupChildIds.value.has(id)
|
||||
})
|
||||
|
||||
function addKeyframeForSelected() {
|
||||
const id = store.selectedLayerId
|
||||
if (!id) return
|
||||
const layer = store.activeLayers.find((l) => l.id === id)
|
||||
if (!layer || layer.locked) return
|
||||
if (!layer || isLayerIdLocked(id)) return
|
||||
const time = snapTime(store.playhead)
|
||||
const sampled = sampleLayer(layer, time)
|
||||
const props: Partial<AnimatableProps> = {
|
||||
@@ -576,7 +578,7 @@ function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const time = snapTime(ratio * duration.value)
|
||||
const layer = store.activeLayers.find((l) => l.id === track.layerId)
|
||||
if (!layer || layer.locked) return
|
||||
if (!layer || isLayerIdLocked(track.layerId)) return
|
||||
const sampled = sampleLayer(layer, time)
|
||||
const props: Partial<AnimatableProps> = {}
|
||||
if (track.kind === 'position') {
|
||||
@@ -617,8 +619,7 @@ function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
|
||||
function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string) {
|
||||
e.stopPropagation()
|
||||
if (kfDrag.value?.moved) return
|
||||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||||
if (layer?.locked) return
|
||||
if (isLayerIdLocked(layerId)) return
|
||||
store.removeKeyframe(layerId, keyframeId)
|
||||
}
|
||||
|
||||
@@ -665,6 +666,17 @@ const lockedGroupChildIds = computed(() => {
|
||||
return s
|
||||
})
|
||||
|
||||
/** Effective lock for a bare layer id — direct flag OR reached by a
|
||||
* locked parent group. Keyframe interactions consult this so edits
|
||||
* are blocked the moment the containing group is locked, without
|
||||
* having to look up the TimelineTrack wrapper. */
|
||||
function isLayerIdLocked(layerId: string): boolean {
|
||||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||||
if (!layer) return false
|
||||
if (layer.locked) return true
|
||||
return lockedGroupChildIds.value.has(layerId)
|
||||
}
|
||||
|
||||
function isTrackEffectivelyLocked(track: TimelineTrack): boolean {
|
||||
if (track.type === 'layer') {
|
||||
return !!track.layer.locked || lockedGroupChildIds.value.has(track.layerId)
|
||||
@@ -901,7 +913,7 @@ function onKeyframePointerDown(
|
||||
) {
|
||||
if (e.button !== 0) return
|
||||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||||
if (layer?.locked) return
|
||||
if (isLayerIdLocked(layerId)) return
|
||||
const kfEl = e.currentTarget as HTMLElement
|
||||
const lane = kfEl.parentElement
|
||||
if (!lane) return
|
||||
@@ -1193,6 +1205,7 @@ function onKeyframeContextMenu(
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (isLayerIdLocked(layerId)) return
|
||||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||||
const kf = kfs.find((k) => k.id === keyframeId)
|
||||
if (!layer || !kf) return
|
||||
@@ -2754,12 +2767,16 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<span
|
||||
class="track-name group-track-name"
|
||||
:class="{ 'drop-into': dropGroupId === track.groupId }"
|
||||
:draggable="!track.locked"
|
||||
@click="toggleGroupCollapsed(track.groupId)"
|
||||
@dblclick="!track.locked ? renameGroupPrompt(track.groupId, track.name) : null"
|
||||
@contextmenu="!track.locked ? onGroupContextMenu($event, track.groupId) : null"
|
||||
@dragstart="!track.locked ? onTrackDragStart($event, track.groupId) : null"
|
||||
@dragend="onTrackDragEnd"
|
||||
@dragover.stop="!track.locked ? onGroupBodyDragOver($event, track.groupId) : null"
|
||||
@drop.stop="!track.locked ? onGroupBodyDrop($event, track.groupId) : null"
|
||||
@dragleave="onGroupBodyDragLeave"
|
||||
>
|
||||
<PhDotsSixVertical :size="12" class="drag-handle" />
|
||||
<component
|
||||
@@ -3606,13 +3623,19 @@ onBeforeUnmount(() => {
|
||||
bottom: -1px;
|
||||
}
|
||||
|
||||
.track.inactive .track-lane > :not(.playhead) {
|
||||
opacity: 0.4;
|
||||
/* Inactive tracks fade as a whole row — same visual vocabulary as
|
||||
the active track, just dimmer. Applying opacity to the row itself
|
||||
keeps keyframes, segments, and the still-frame tail visually in
|
||||
sync (previously we faded each child individually, which multiplied
|
||||
against each child's own opacity and made diamonds read as ghostly
|
||||
while their connecting segments looked nearly normal). */
|
||||
.track.inactive {
|
||||
opacity: 0.5;
|
||||
transition: opacity 140ms var(--ease);
|
||||
}
|
||||
|
||||
.track.inactive:hover .track-lane > :not(.playhead) {
|
||||
opacity: 0.75;
|
||||
.track.inactive:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.track.locked {
|
||||
|
||||
@@ -1,45 +1,171 @@
|
||||
import { reactive } from 'vue'
|
||||
import type { ButtonState } from '@/types/scene'
|
||||
import { BUTTON_STATE_TIMES, type ButtonState } from '@/types/scene'
|
||||
|
||||
/**
|
||||
* Runtime-only per-button-layer state. Keyed by the scene-layer id (the
|
||||
* instance on the main stage). Not persisted; resets on page reload.
|
||||
*
|
||||
* `transitions` animates the visual sample time between state slots
|
||||
* whenever `setState` / `setDisabled` flips the target. Consumers call
|
||||
* `getEffectiveTime` instead of `BUTTON_STATE_TIMES[effectiveState]` to
|
||||
* read the current interpolated time for rendering. `tick` bumps on
|
||||
* every rAF while a transition is in flight so reactive consumers
|
||||
* re-evaluate.
|
||||
*/
|
||||
export const BUTTON_STATE_TRANSITION_MS = 50
|
||||
|
||||
interface Transition {
|
||||
fromTime: number
|
||||
toTime: number
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
interface RuntimeState {
|
||||
state: Map<string, ButtonState>
|
||||
disabled: Map<string, boolean>
|
||||
transitions: Map<string, Transition>
|
||||
tick: number
|
||||
}
|
||||
|
||||
const runtime: RuntimeState = reactive({
|
||||
state: new Map(),
|
||||
disabled: new Map(),
|
||||
transitions: new Map(),
|
||||
tick: 0,
|
||||
})
|
||||
|
||||
let rafId: number | null = null
|
||||
|
||||
function ensureTicker() {
|
||||
if (rafId != null) return
|
||||
const loop = () => {
|
||||
runtime.tick++
|
||||
// Drop finished transitions so the loop can stop when nothing's
|
||||
// in flight anymore. Consumers read tick for reactivity.
|
||||
const now = performance.now()
|
||||
for (const [id, t] of runtime.transitions) {
|
||||
if (now - t.startedAt >= BUTTON_STATE_TRANSITION_MS) {
|
||||
runtime.transitions.delete(id)
|
||||
}
|
||||
}
|
||||
if (runtime.transitions.size > 0) {
|
||||
rafId = requestAnimationFrame(loop)
|
||||
} else {
|
||||
rafId = null
|
||||
}
|
||||
}
|
||||
rafId = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
function computeEffectiveState(
|
||||
layerId: string,
|
||||
persistedDisabled?: boolean,
|
||||
): ButtonState {
|
||||
const override = runtime.disabled.get(layerId)
|
||||
const effectiveDisabled =
|
||||
override !== undefined ? override : !!persistedDisabled
|
||||
if (effectiveDisabled) return 'disabled'
|
||||
return runtime.state.get(layerId) ?? 'normal'
|
||||
}
|
||||
|
||||
function targetTimeFor(state: ButtonState): number {
|
||||
return BUTTON_STATE_TIMES[state]
|
||||
}
|
||||
|
||||
function startTransition(layerId: string, fromTime: number, toTime: number) {
|
||||
if (fromTime === toTime) {
|
||||
runtime.transitions.delete(layerId)
|
||||
return
|
||||
}
|
||||
runtime.transitions.set(layerId, {
|
||||
fromTime,
|
||||
toTime,
|
||||
startedAt: performance.now(),
|
||||
})
|
||||
ensureTicker()
|
||||
}
|
||||
|
||||
export function useButtonRuntime() {
|
||||
return {
|
||||
state: runtime.state,
|
||||
disabled: runtime.disabled,
|
||||
getEffective(layerId: string, persistedDisabled?: boolean): ButtonState {
|
||||
const override = runtime.disabled.get(layerId)
|
||||
const effectiveDisabled =
|
||||
override !== undefined ? override : !!persistedDisabled
|
||||
if (effectiveDisabled) return 'disabled'
|
||||
return runtime.state.get(layerId) ?? 'normal'
|
||||
return computeEffectiveState(layerId, persistedDisabled)
|
||||
},
|
||||
/**
|
||||
* The continuous "state time" used for rendering, lerped through
|
||||
* in-flight transitions. Between states it produces intermediate
|
||||
* times (e.g. 1.5 halfway between normal and hover) so sampleLayer
|
||||
* can interpolate the per-state keyframes smoothly.
|
||||
*/
|
||||
getEffectiveTime(layerId: string, persistedDisabled?: boolean): number {
|
||||
// Touch tick so Vue reactivity refreshes consumers every rAF
|
||||
// while a transition is in flight.
|
||||
void runtime.tick
|
||||
const state = computeEffectiveState(layerId, persistedDisabled)
|
||||
const target = targetTimeFor(state)
|
||||
const t = runtime.transitions.get(layerId)
|
||||
if (!t) return target
|
||||
// If the target changed mid-transition (e.g. hover → pressed),
|
||||
// `setState` replaces the transition's `toTime` before we get
|
||||
// here. In the normal case, toTime === target.
|
||||
const elapsed = performance.now() - t.startedAt
|
||||
const progress = Math.min(1, elapsed / BUTTON_STATE_TRANSITION_MS)
|
||||
if (progress >= 1) {
|
||||
runtime.transitions.delete(layerId)
|
||||
return t.toTime
|
||||
}
|
||||
return t.fromTime + (t.toTime - t.fromTime) * progress
|
||||
},
|
||||
setState(layerId: string, next: ButtonState) {
|
||||
const prev = computeEffectiveState(
|
||||
layerId,
|
||||
runtime.disabled.get(layerId),
|
||||
)
|
||||
if (next === 'normal') runtime.state.delete(layerId)
|
||||
else runtime.state.set(layerId, next)
|
||||
// Start the transition from wherever the current visual sample
|
||||
// is — read via getEffectiveTime so mid-transition flips lerp
|
||||
// from the live position instead of snapping to prev's slot.
|
||||
const existing = runtime.transitions.get(layerId)
|
||||
let fromTime: number
|
||||
if (existing) {
|
||||
const elapsed = performance.now() - existing.startedAt
|
||||
const p = Math.min(1, elapsed / BUTTON_STATE_TRANSITION_MS)
|
||||
fromTime = existing.fromTime + (existing.toTime - existing.fromTime) * p
|
||||
} else {
|
||||
fromTime = targetTimeFor(prev)
|
||||
}
|
||||
startTransition(layerId, fromTime, targetTimeFor(next))
|
||||
},
|
||||
setDisabled(layerId: string, next: boolean) {
|
||||
const prev = computeEffectiveState(
|
||||
layerId,
|
||||
runtime.disabled.get(layerId),
|
||||
)
|
||||
runtime.disabled.set(layerId, next)
|
||||
const target = computeEffectiveState(layerId, next)
|
||||
if (prev === target) return
|
||||
const existing = runtime.transitions.get(layerId)
|
||||
let fromTime: number
|
||||
if (existing) {
|
||||
const elapsed = performance.now() - existing.startedAt
|
||||
const p = Math.min(1, elapsed / BUTTON_STATE_TRANSITION_MS)
|
||||
fromTime = existing.fromTime + (existing.toTime - existing.fromTime) * p
|
||||
} else {
|
||||
fromTime = targetTimeFor(prev)
|
||||
}
|
||||
startTransition(layerId, fromTime, targetTimeFor(target))
|
||||
},
|
||||
clear(layerId: string) {
|
||||
runtime.state.delete(layerId)
|
||||
runtime.disabled.delete(layerId)
|
||||
runtime.transitions.delete(layerId)
|
||||
},
|
||||
reset() {
|
||||
runtime.state.clear()
|
||||
runtime.disabled.clear()
|
||||
runtime.transitions.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -139,6 +139,8 @@ export const en = {
|
||||
removeKeyframe: 'Remove keyframe here',
|
||||
deleteLayer: 'Delete layer',
|
||||
noLayer: 'No layer selected.',
|
||||
lockedLayer:
|
||||
'Selected layer is locked. Unlock the track (or its group) to edit properties.',
|
||||
fill: 'Fill',
|
||||
transparent: 'Transparent',
|
||||
opacity: 'Opacity',
|
||||
|
||||
@@ -1236,6 +1236,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
if (layer) {
|
||||
if (locked) layer.locked = true
|
||||
else delete layer.locked
|
||||
this.deselectIfLocked()
|
||||
return
|
||||
}
|
||||
const clipTrack = this.scene.clipTracks?.find((t) => t.id === trackId)
|
||||
@@ -1254,6 +1255,31 @@ export const useSceneStore = defineStore('scene', {
|
||||
if (group) {
|
||||
if (locked) group.locked = true
|
||||
else delete group.locked
|
||||
this.deselectIfLocked()
|
||||
}
|
||||
},
|
||||
/** When a lock change makes the currently-selected layer inert
|
||||
* (directly locked or reached by a locked parent group), drop
|
||||
* the selection so the properties panel, keyboard shortcuts,
|
||||
* and Konva transformer stop offering edits that would fail
|
||||
* silently against an ignored click. */
|
||||
deselectIfLocked() {
|
||||
const id = this.selectedLayerId
|
||||
if (!id) return
|
||||
const sel = this.activeLayers.find((l) => l.id === id)
|
||||
if (!sel) {
|
||||
this.selectedLayerId = null
|
||||
return
|
||||
}
|
||||
if (sel.locked) {
|
||||
this.selectedLayerId = null
|
||||
return
|
||||
}
|
||||
for (const g of this.scene.groups ?? []) {
|
||||
if (g.locked && g.childIds.includes(id)) {
|
||||
this.selectedLayerId = null
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
setLayerEditorHidden(layerId: string, hidden: boolean) {
|
||||
|
||||
@@ -92,6 +92,20 @@ button {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Force pointer cursor on every enabled <button> even when a scoped
|
||||
component rule (higher specificity than the base `button` selector
|
||||
above) would otherwise suppress it. `!important` is justified here
|
||||
because the pointer-cursor contract is foundational — without it
|
||||
users can't tell interactive from non-interactive, and scoped
|
||||
styles have historically shadowed the base rule when they carried
|
||||
`.foo[data-v-…]` (0,2,0) specificity without declaring a cursor.
|
||||
Disabled buttons still fall through to the `:disabled { cursor:
|
||||
not-allowed }` rule below (higher specificity via the pseudo). */
|
||||
button:not(:disabled):not([aria-disabled='true']),
|
||||
[role='button']:not([aria-disabled='true']):not(:disabled) {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: none;
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.8.3'
|
||||
export const APP_VERSION = '0.8.4'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user