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
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "animatr",
|
||||
"private": true,
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+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'
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
die track-lanes der nicht aktiven traks zeigen durchsichtige keyframes. das muss genauso aussehen wie bei den aktiven - nur etwas ausgefaded
|
||||
wir brauchen mehrfachselection von tracks (shift-click) und gruppiegen. also rechtsklick -> group
|
||||
|
||||
|
||||
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
|
||||
Reference in new issue
Block a user