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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user