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:
thandClaude Opus 4.7 committed 2026-04-22 04:31:08 +02:00
1 parent 1d84c34c15
commit af5f99380c
12 files changed
+401 -34

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "animatr", "name": "animatr",
"private": true, "private": true,
"version": "0.8.3", "version": "0.8.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+5 -1
View File
@@ -812,7 +812,11 @@ onBeforeUnmount(() => {
right: 0; right: 0;
height: 14px; height: 14px;
cursor: ns-resize; 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; touch-action: none;
background: transparent; background: transparent;
transition: background 120ms var(--ease); transition: background 120ms var(--ease);
+17 -7
View File
@@ -492,12 +492,11 @@ function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
return { ...cfg, draggable: false, listening: false } return { ...cfg, draggable: false, listening: false }
} }
function buttonStackForState( function buttonStackForTime(
asset: ButtonAsset, asset: ButtonAsset,
state: ButtonState, stateTime: number,
): Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> { ): Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> {
const out: 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() const layers = asset.layers.slice().reverse()
for (const l of layers) { for (const l of layers) {
if (!l.visible) continue if (!l.visible) continue
@@ -508,13 +507,24 @@ function buttonStackForState(
return out return out
} }
function setStageCursor(cursor: string) {
const el = stageRef.value?.getStage().container()
if (el) el.style.cursor = cursor
}
function onButtonEnter(layer: Layer) { 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 if (!stagePreview.value) return
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled) const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
if (cur === 'normal') buttonRt.setState(layer.id, 'hover') if (cur === 'normal') buttonRt.setState(layer.id, 'hover')
} }
function onButtonLeave(layer: Layer) { function onButtonLeave(layer: Layer) {
setStageCursor('')
if (!stagePreview.value) return if (!stagePreview.value) return
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled) const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
if (cur === 'hover' || cur === 'pressed') buttonRt.setState(layer.id, 'normal') 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 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 // In edit mode the button is a static asset — always show its normal
// state regardless of leftover runtime state (e.g. from a previous // state regardless of leftover runtime state (e.g. from a previous
// preview session). // preview session).
if (!stagePreview.value) return 'normal' if (!stagePreview.value) return BUTTON_STATE_TIMES.normal
return buttonRt.getEffective(layer.id, layer.buttonDisabled) return buttonRt.getEffectiveTime(layer.id, layer.buttonDisabled)
} }
const stageConfig = computed(() => ({ const stageConfig = computed(() => ({
@@ -2912,7 +2922,7 @@ onBeforeUnmount(() => {
:config="buttonHitCatcherConfig(buttonAssetFor(entry.item.asset)!, entry.item.layer)" :config="buttonHitCatcherConfig(buttonAssetFor(entry.item.asset)!, entry.item.layer)"
/> />
<template <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}`" :key="`${entry.item.layer.id}:${sub.layer.id}:${bli}`"
> >
<v-rect <v-rect
+17 -1
View File
@@ -74,6 +74,19 @@ const sampled = computed<AnimatableProps | null>(() =>
selected.value ? sampleLayer(selected.value, store.playhead) : 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(() => { const hasKeyframeHere = computed(() => {
if (!selected.value) return false if (!selected.value) return false
return selected.value.keyframes.some((k) => Math.abs(k.time - store.playhead) < 1e-3) return selected.value.keyframes.some((k) => Math.abs(k.time - store.playhead) < 1e-3)
@@ -375,7 +388,7 @@ function removeKeyframeHere() {
<template> <template>
<div class="panel"> <div class="panel">
<h3>Properties</h3> <h3>Properties</h3>
<template v-if="selected && sampled"> <template v-if="selected && sampled && !selectedIsLocked">
<label>Name<input v-model="selected.name" /></label> <label>Name<input v-model="selected.name" /></label>
<div class="kf-row" :class="{ on: hasKeyframeHere }"> <div class="kf-row" :class="{ on: hasKeyframeHere }">
@@ -645,6 +658,9 @@ function removeKeyframeHere() {
{{ t('properties.deleteLayer') }} {{ t('properties.deleteLayer') }}
</button> </button>
</template> </template>
<p v-else-if="selected && selectedIsLocked" class="hint">
{{ t('properties.lockedLayer') }}
</p>
<p v-else class="hint">{{ t('properties.noLayer') }}</p> <p v-else class="hint">{{ t('properties.noLayer') }}</p>
</div> </div>
</template> </template>
+33 -10
View File
@@ -544,14 +544,16 @@ const canCreateKf = computed(() => {
const id = store.selectedLayerId const id = store.selectedLayerId
if (!id) return false if (!id) return false
const layer = store.activeLayers.find((l) => l.id === id) 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() { function addKeyframeForSelected() {
const id = store.selectedLayerId const id = store.selectedLayerId
if (!id) return if (!id) return
const layer = store.activeLayers.find((l) => l.id === id) 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 time = snapTime(store.playhead)
const sampled = sampleLayer(layer, time) const sampled = sampleLayer(layer, time)
const props: Partial<AnimatableProps> = { 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 ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const time = snapTime(ratio * duration.value) const time = snapTime(ratio * duration.value)
const layer = store.activeLayers.find((l) => l.id === track.layerId) 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 sampled = sampleLayer(layer, time)
const props: Partial<AnimatableProps> = {} const props: Partial<AnimatableProps> = {}
if (track.kind === 'position') { if (track.kind === 'position') {
@@ -617,8 +619,7 @@ function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string) { function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string) {
e.stopPropagation() e.stopPropagation()
if (kfDrag.value?.moved) return if (kfDrag.value?.moved) return
const layer = store.activeLayers.find((l) => l.id === layerId) if (isLayerIdLocked(layerId)) return
if (layer?.locked) return
store.removeKeyframe(layerId, keyframeId) store.removeKeyframe(layerId, keyframeId)
} }
@@ -665,6 +666,17 @@ const lockedGroupChildIds = computed(() => {
return s 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 { function isTrackEffectivelyLocked(track: TimelineTrack): boolean {
if (track.type === 'layer') { if (track.type === 'layer') {
return !!track.layer.locked || lockedGroupChildIds.value.has(track.layerId) return !!track.layer.locked || lockedGroupChildIds.value.has(track.layerId)
@@ -901,7 +913,7 @@ function onKeyframePointerDown(
) { ) {
if (e.button !== 0) return if (e.button !== 0) return
const layer = store.activeLayers.find((l) => l.id === layerId) const layer = store.activeLayers.find((l) => l.id === layerId)
if (layer?.locked) return if (isLayerIdLocked(layerId)) return
const kfEl = e.currentTarget as HTMLElement const kfEl = e.currentTarget as HTMLElement
const lane = kfEl.parentElement const lane = kfEl.parentElement
if (!lane) return if (!lane) return
@@ -1193,6 +1205,7 @@ function onKeyframeContextMenu(
) { ) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (isLayerIdLocked(layerId)) return
const layer = store.activeLayers.find((l) => l.id === layerId) const layer = store.activeLayers.find((l) => l.id === layerId)
const kf = kfs.find((k) => k.id === keyframeId) const kf = kfs.find((k) => k.id === keyframeId)
if (!layer || !kf) return if (!layer || !kf) return
@@ -2754,12 +2767,16 @@ onBeforeUnmount(() => {
> >
<span <span
class="track-name group-track-name" class="track-name group-track-name"
:class="{ 'drop-into': dropGroupId === track.groupId }"
:draggable="!track.locked" :draggable="!track.locked"
@click="toggleGroupCollapsed(track.groupId)" @click="toggleGroupCollapsed(track.groupId)"
@dblclick="!track.locked ? renameGroupPrompt(track.groupId, track.name) : null" @dblclick="!track.locked ? renameGroupPrompt(track.groupId, track.name) : null"
@contextmenu="!track.locked ? onGroupContextMenu($event, track.groupId) : null" @contextmenu="!track.locked ? onGroupContextMenu($event, track.groupId) : null"
@dragstart="!track.locked ? onTrackDragStart($event, track.groupId) : null" @dragstart="!track.locked ? onTrackDragStart($event, track.groupId) : null"
@dragend="onTrackDragEnd" @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" /> <PhDotsSixVertical :size="12" class="drag-handle" />
<component <component
@@ -3606,13 +3623,19 @@ onBeforeUnmount(() => {
bottom: -1px; bottom: -1px;
} }
.track.inactive .track-lane > :not(.playhead) { /* Inactive tracks fade as a whole row — same visual vocabulary as
opacity: 0.4; 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); transition: opacity 140ms var(--ease);
} }
.track.inactive:hover .track-lane > :not(.playhead) { .track.inactive:hover {
opacity: 0.75; opacity: 0.85;
} }
.track.locked { .track.locked {
+132 -6
View File
@@ -1,45 +1,171 @@
import { reactive } from 'vue' 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 * Runtime-only per-button-layer state. Keyed by the scene-layer id (the
* instance on the main stage). Not persisted; resets on page reload. * 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 { interface RuntimeState {
state: Map<string, ButtonState> state: Map<string, ButtonState>
disabled: Map<string, boolean> disabled: Map<string, boolean>
transitions: Map<string, Transition>
tick: number
} }
const runtime: RuntimeState = reactive({ const runtime: RuntimeState = reactive({
state: new Map(), state: new Map(),
disabled: 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() { export function useButtonRuntime() {
return { return {
state: runtime.state, state: runtime.state,
disabled: runtime.disabled, disabled: runtime.disabled,
getEffective(layerId: string, persistedDisabled?: boolean): ButtonState { getEffective(layerId: string, persistedDisabled?: boolean): ButtonState {
const override = runtime.disabled.get(layerId) return computeEffectiveState(layerId, persistedDisabled)
const effectiveDisabled = },
override !== undefined ? override : !!persistedDisabled /**
if (effectiveDisabled) return 'disabled' * The continuous "state time" used for rendering, lerped through
return runtime.state.get(layerId) ?? 'normal' * 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) { setState(layerId: string, next: ButtonState) {
const prev = computeEffectiveState(
layerId,
runtime.disabled.get(layerId),
)
if (next === 'normal') runtime.state.delete(layerId) if (next === 'normal') runtime.state.delete(layerId)
else runtime.state.set(layerId, next) 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) { setDisabled(layerId: string, next: boolean) {
const prev = computeEffectiveState(
layerId,
runtime.disabled.get(layerId),
)
runtime.disabled.set(layerId, next) 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) { clear(layerId: string) {
runtime.state.delete(layerId) runtime.state.delete(layerId)
runtime.disabled.delete(layerId) runtime.disabled.delete(layerId)
runtime.transitions.delete(layerId)
}, },
reset() { reset() {
runtime.state.clear() runtime.state.clear()
runtime.disabled.clear() runtime.disabled.clear()
runtime.transitions.clear()
}, },
} }
} }
+136
View File
@@ -3232,6 +3232,59 @@ export function registerAllTests(): void {
// The layer itself stays unlocked; effective lock is derived in UI code. // The layer itself stays unlocked; effective lock is derived in UI code.
expect(l.locked).toBe(undefined) 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', () => { 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', () => { suite('button asset — thumbnail rendering', () => {
// Regression: renderButtonThumbnail used to pass images={} and any image // Regression: renderButtonThumbnail used to pass images={} and any image
// layer inside a button produced a fully-transparent PNG, making // layer inside a button produced a fully-transparent PNG, making
+2
View File
@@ -139,6 +139,8 @@ export const en = {
removeKeyframe: 'Remove keyframe here', removeKeyframe: 'Remove keyframe here',
deleteLayer: 'Delete layer', deleteLayer: 'Delete layer',
noLayer: 'No layer selected.', noLayer: 'No layer selected.',
lockedLayer:
'Selected layer is locked. Unlock the track (or its group) to edit properties.',
fill: 'Fill', fill: 'Fill',
transparent: 'Transparent', transparent: 'Transparent',
opacity: 'Opacity', opacity: 'Opacity',
+26
View File
@@ -1236,6 +1236,7 @@ export const useSceneStore = defineStore('scene', {
if (layer) { if (layer) {
if (locked) layer.locked = true if (locked) layer.locked = true
else delete layer.locked else delete layer.locked
this.deselectIfLocked()
return return
} }
const clipTrack = this.scene.clipTracks?.find((t) => t.id === trackId) const clipTrack = this.scene.clipTracks?.find((t) => t.id === trackId)
@@ -1254,6 +1255,31 @@ export const useSceneStore = defineStore('scene', {
if (group) { if (group) {
if (locked) group.locked = true if (locked) group.locked = true
else delete group.locked 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) { setLayerEditorHidden(layerId: string, hidden: boolean) {
+14
View File
@@ -92,6 +92,20 @@ button {
gap: 6px; 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,
button:focus-visible { button:focus-visible {
outline: none; outline: none;
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.8.3' export const APP_VERSION = '0.8.4'
export const APP_NAME = 'RUWIO' export const APP_NAME = 'RUWIO'
+17 -7
View File
@@ -136,7 +136,6 @@ die inline hilfe texte in den settings sind zu klein. das kann man nicht lesen
warum ist eigentlich still frame easing disabled? man muss doch zu einem still-frame hin easen können. verstehe den grund nicht warum ist eigentlich still frame easing disabled? man muss doch zu einem still-frame hin easen können. verstehe den grund nicht
ich würde an dieser stelle mal ein paar regression-tests hinzufügen und dann committen
wenn man den leeren teil des content-views zieht (außerhalb der canvas constraints) muss canvas move auch ohne control key gehen wenn man den leeren teil des content-views zieht (außerhalb der canvas constraints) muss canvas move auch ohne control key gehen
@@ -144,18 +143,29 @@ export dateien heißen noch animatr. wir haben in RUWIO umbenannt
die tooltips sind hässlich. bau ein schöneres custom tooltip system ein. nimm dir zeit und such alle tooltips raus die wir haben. wenn es sinnvoll ist eine funktion besser zu erklären kann ein tooltip auch eine inline hilfe sein. das muss man unter settings -> interface abstellen können die tooltips sind hässlich. bau ein schöneres custom tooltip system ein. nimm dir zeit und such alle tooltips raus die wir haben. wenn es sinnvoll ist eine funktion besser zu erklären kann ein tooltip auch eine inline hilfe sein. das muss man unter settings -> interface abstellen können
buttons müssen einen pointer cursor anzeigen
bei den button timelines zeigen wir '+' icons an auf leeren frames. da wird dann der vorrige hin dupliziert
die track-lanes der nicht aktiven tracks zeigen durchsichtige keyframes. das muss genauso aussehen wie bei den aktiven - nur der ganze track ca. 50% etwas ausgefaded
element auf gruppe ziehen muss auch in der track label spalte gehen
der bereich wo man den timeline-bereich größer ziehen kann ist nur noch 1px. da hatten wir 15px vorher. man trifft diesen anfasser jetzt nicht mehr
kann man bei den button-states 200ms transitions einbauen?
elemente in einer gelockten gruppe kann man bearbeiten. bearbeitungsmöglichkeit muss sofort entzogen werden wenn eine gruppe gesperrt ist
---- ----
buttons müssen einen pointer cursor anzeigen wir brauchen mehrfachselection von tracks (shift-click) und gruppiegen. also rechtsklick -> group
die track-lanes der nicht aktiven traks zeigen durchsichtige keyframes. das muss genauso aussehen wie bei den aktiven - nur etwas ausgefaded
bei den button timelines zeigen wir + icons an auf leeren frames. da wird dann der vorrige hin dupliziert
element auf gruppe ziehen muss auch in der track label spalte gehen
der bereich wo man den timeline-bereich größer ziehen kann ist nur noch 1px. da hatten wir 15px vorher. man trifft diesen anfasser jetzt nicht mehr
ich würde an dieser stelle mal ein paar regression-tests hinzufügen und dann committen