feat: 5-frame button assets + timeline/sidebar polish
Button assets
- Re-introduce button-asset type with 4 content states (normal/hover/
pressed/disabled) stored as keyframes at BUTTON_STATE_TIMES, plus a
separate hit-area editor mode.
- useButtonRuntime composable for per-instance state/disabled flags on
the main stage.
- Sidebar library lists button assets with live 80×80 thumbnails;
renderButtonThumbnail now accepts an images map so image-kind layers
inside a button render instead of producing a fully-transparent PNG.
- Compact (no-thumbnail) mode aligns button rows to the library-row
height.
Scope footer
- Button-edit mode now uses the same sidebar scope-footer pattern as
clip-edit (back button to "Main timeline" + active scope chip);
removed the inline exit button from the timeline scrub-bar.
Timeline
- Track-name column default bumped to 260px to match the assets sidebar
above it (storage key bumped to :v2 so stale values reset).
- scrub-bar z-index raised above sticky track-name columns so rows
scroll under it instead of over.
- scrub-handle flips z-index based on horizontal scroll: above
name-spacer when the playhead is in view (no half-clip at t=0),
below when the playhead has scrolled off the left edge (so the
toggle row cleanly covers it).
- name-spacer extends ±3/1px to fully cover the obscured handle.
- scrub-handle head pokes 3px above the ruler with rounded top corners
via clip-path path().
Track context menu
- Added missing i18n keys (renameScriptTrack / deleteScriptTrack /
renameScriptTrackDialog.{title,confirm}).
- Rename items show a visible pencil icon; empty .check slot removed
so items line up flush-left; toggle items place their active ✓ on
the right (kf-menu-trailing-check).
Dev HUD
- Test progress bar flips to --danger as soon as the first failure is
recorded, instead of only at the end of the run.
Regression tests
- renderButtonThumbnail: null on empty button, renders shape layers,
accepts custom images map.
- i18n: renameScriptTrack / deleteScriptTrack / dialog keys resolve.
- scrub-handle-obscured logic (visible at t=0, hidden when playhead is
scrolled off-screen, still visible when playhead is far to the
right of the scroll offset).
- Updated button state-scrubber expectations to match
BUTTON_STATE_TIMES (normal=1, hover=2, pressed=3, disabled=4) and
that hit-mode parks at normal=1 rather than 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
31604052ca
commit
c3764201b8
16 files changed
+2560
-349
No files matched your search
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref, watch, watchEffect } from 'vue'
|
||||
import {
|
||||
PhCircle,
|
||||
PhCursorClick,
|
||||
PhFilmStrip,
|
||||
PhImage,
|
||||
PhImageSquare,
|
||||
@@ -19,6 +20,7 @@ import { useSceneStore } from '@/stores/scene'
|
||||
import { importImageFile } from '@/lib/fileLoader'
|
||||
import type { Asset } from '@/types/scene'
|
||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||
import { loadSceneImages, renderButtonThumbnail } from '@/lib/render'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const store = useSceneStore()
|
||||
@@ -106,7 +108,98 @@ async function addSampleText() {
|
||||
store.addLayer(asset.id)
|
||||
}
|
||||
|
||||
async function addButton() {
|
||||
const nameInput = await promptDialog({
|
||||
title: t('sidebar.newButtonDialog.title'),
|
||||
message: t('sidebar.newButtonDialog.nameMessage'),
|
||||
defaultValue: t('sidebar.newButtonDialog.defaultName'),
|
||||
confirmLabel: t('sidebar.newButtonDialog.next'),
|
||||
})
|
||||
if (nameInput === null) return
|
||||
const sizeInput = await promptDialog({
|
||||
title: t('sidebar.newButtonDialog.title'),
|
||||
message: t('sidebar.newButtonDialog.sizeMessage'),
|
||||
defaultValue: '200 × 80',
|
||||
confirmLabel: t('sidebar.newButtonDialog.confirm'),
|
||||
})
|
||||
if (sizeInput === null) return
|
||||
const m = sizeInput.match(/(\d+)\s*[×x*]\s*(\d+)/)
|
||||
const w = m ? parseInt(m[1], 10) : 200
|
||||
const h = m ? parseInt(m[2], 10) : 80
|
||||
const asset = store.addButtonAsset(nameInput || 'Button', w, h)
|
||||
store.enterButtonAssetEdit(asset.id)
|
||||
}
|
||||
|
||||
const clips = computed(() => store.scene.clips ?? [])
|
||||
const buttonAssets = computed(() =>
|
||||
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'button' }> => a.kind === 'button'),
|
||||
)
|
||||
|
||||
const buttonThumbs = ref<Record<string, string | null>>({})
|
||||
const sceneImageCache = ref<Record<string, HTMLImageElement>>({})
|
||||
|
||||
// Keep scene image cache in sync with the current scene's image assets so
|
||||
// button thumbnails that reference images actually render them instead of
|
||||
// producing a transparent canvas.
|
||||
watchEffect(async (onCleanup) => {
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
// Track changes to image assets: any add/remove/src change triggers reload.
|
||||
const imgSrcs = store.scene.assets
|
||||
.filter((a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image')
|
||||
.map((a) => `${a.id}:${a.src}`)
|
||||
.join('|')
|
||||
void imgSrcs
|
||||
const imgs = await loadSceneImages(store.scene)
|
||||
if (!cancelled) sceneImageCache.value = imgs
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (!showThumbs.value) { buttonThumbs.value = {}; return }
|
||||
const out: Record<string, string | null> = {}
|
||||
for (const ba of buttonAssets.value) {
|
||||
// Access layers deeply so Vue tracks changes inside the button asset
|
||||
for (const l of ba.layers) {
|
||||
void l.keyframes.length
|
||||
void l.visible
|
||||
}
|
||||
out[ba.id] = renderButtonThumbnail(ba, store.scene, 80, sceneImageCache.value)
|
||||
}
|
||||
buttonThumbs.value = out
|
||||
})
|
||||
|
||||
function onButtonAssetDragStart(e: DragEvent, assetId: string) {
|
||||
if (!e.dataTransfer) return
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
e.dataTransfer.setData('application/x-animatr-asset', assetId)
|
||||
e.dataTransfer.setData('text/plain', assetId)
|
||||
}
|
||||
|
||||
async function renameButtonAssetPrompt(id: string, current: string) {
|
||||
const input = await promptDialog({
|
||||
title: t('sidebar.renameButtonDialog.title'),
|
||||
defaultValue: current,
|
||||
confirmLabel: t('sidebar.renameButtonDialog.confirm'),
|
||||
})
|
||||
if (input === null) return
|
||||
const asset = store.scene.assets.find((a) => a.id === id)
|
||||
if (asset && asset.kind === 'button') asset.name = input.trim() || asset.name
|
||||
}
|
||||
|
||||
async function deleteButtonAsset(id: string) {
|
||||
const ok = await confirmDialog({
|
||||
title: t('sidebar.deleteButtonDialog.title'),
|
||||
message: t('sidebar.deleteButtonDialog.message'),
|
||||
confirmLabel: t('sidebar.deleteButtonDialog.confirm'),
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
// Remove the asset; any layers referencing it will render as nothing.
|
||||
store.scene.assets = store.scene.assets.filter((a) => a.id !== id)
|
||||
if (store.activeButtonAssetId === id) store.exitButtonAssetEdit()
|
||||
}
|
||||
|
||||
async function addClip() {
|
||||
const input = await promptDialog({
|
||||
@@ -174,6 +267,7 @@ const assetIconMap: Record<Asset['kind'], typeof PhSquare> = {
|
||||
image: PhImage,
|
||||
text: PhTextT,
|
||||
polygon: PhPolygon,
|
||||
button: PhCursorClick,
|
||||
}
|
||||
|
||||
const imageAssets = computed(() =>
|
||||
@@ -214,7 +308,11 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div
|
||||
class="panel"
|
||||
:class="{ 'hit-disabled': store.activeButtonEditMode === 'hit' }"
|
||||
:title="store.activeButtonEditMode === 'hit' ? t('sidebar.hitModeDisabled') : undefined"
|
||||
>
|
||||
<h3>Assets</h3>
|
||||
|
||||
<button class="upload-btn" @click="triggerUpload">
|
||||
@@ -243,6 +341,9 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
|
||||
>
|
||||
<PhPencilSimpleLine :size="16" />
|
||||
</button>
|
||||
<button class="shape-btn" :title="t('sidebar.shape.button')" @click="addButton">
|
||||
<PhCursorClick :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -336,6 +437,61 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
|
||||
</li>
|
||||
<li v-if="!clips.length" class="empty">{{ t('sidebar.noClips') }}</li>
|
||||
</ul>
|
||||
|
||||
<div class="lib-header clips-header">
|
||||
<h4>{{ t('sidebar.buttons') }}</h4>
|
||||
<button class="thumb-toggle" :title="t('sidebar.createButtonTooltip')" @click="addButton">
|
||||
<PhPlus :size="12" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
<ul class="clips" :class="{ compact: !showThumbs }">
|
||||
<li
|
||||
v-for="ba in buttonAssets"
|
||||
:key="ba.id"
|
||||
class="clip button-asset"
|
||||
:class="{ editing: store.activeButtonAssetId === ba.id }"
|
||||
draggable="true"
|
||||
@dragstart="onButtonAssetDragStart($event, ba.id)"
|
||||
@dblclick="store.enterButtonAssetEdit(ba.id)"
|
||||
>
|
||||
<template v-if="showThumbs && buttonThumbs[ba.id]">
|
||||
<div class="thumb">
|
||||
<img :src="buttonThumbs[ba.id]!" :alt="ba.name" draggable="false" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="showThumbs">
|
||||
<div class="clip-icon">
|
||||
<PhCursorClick :size="16" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<PhCursorClick :size="14" class="asset-icon" />
|
||||
</template>
|
||||
<div class="clip-meta">
|
||||
<span class="clip-name" :title="ba.name">{{ ba.name }}</span>
|
||||
<span v-if="showThumbs" class="clip-sub">
|
||||
{{ ba.canvas.width }} × {{ ba.canvas.height }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="clip-actions">
|
||||
<button
|
||||
class="clip-btn"
|
||||
:title="t('sidebar.renameTooltip')"
|
||||
@click.stop="renameButtonAssetPrompt(ba.id, ba.name)"
|
||||
>
|
||||
<PhPencilSimple :size="11" />
|
||||
</button>
|
||||
<button
|
||||
class="clip-btn danger"
|
||||
:title="t('sidebar.deleteTooltip')"
|
||||
@click.stop="deleteButtonAsset(ba.id)"
|
||||
>
|
||||
<PhTrash :size="11" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="!buttonAssets.length" class="empty">{{ t('sidebar.noButtons') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -347,6 +503,12 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel.hit-disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
filter: saturate(0.5);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 2px;
|
||||
font-size: 13px;
|
||||
@@ -574,6 +736,17 @@ h4 {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* In compact (no-thumbnail) mode, buttons match library entry height
|
||||
exactly by dropping the 1px border (same as .assets li). */
|
||||
.clips.compact .clip {
|
||||
border: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.clips.compact .clip.editing {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.clip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user