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:
thandClaude Opus 4.7 committed 2026-04-21 19:19:39 +02:00
1 parent 31604052ca
commit c3764201b8
16 files changed
+2560 -349

No files matched your search

+11
View File
@@ -4,6 +4,7 @@ import {
PhArrowClockwise,
PhArrowCounterClockwise,
PhArrowLeft,
PhCursorClick,
PhExport,
PhFilmStrip,
PhFolderOpen,
@@ -439,6 +440,16 @@ onBeforeUnmount(() => {
{{ store.activeClip?.name ?? '' }}
</span>
</div>
<div v-else-if="store.activeButtonAssetId" class="scope-footer">
<button class="scope-back" @click="store.exitButtonAssetEdit">
<PhArrowLeft :size="12" weight="bold" />
{{ t('app.mainTimeline') }}
</button>
<span class="scope-current" :title="store.activeButtonAsset?.name ?? ''">
<PhCursorClick :size="12" />
{{ store.activeButtonAsset?.name ?? '' }}
</span>
</div>
</aside>
<main class="stage"><CanvasStage /></main>
<aside class="properties"><PropertiesPanel /></aside>
+175 -2
View File
@@ -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;
}
+722 -12
View File
@@ -1,19 +1,36 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type Konva from 'konva'
import { PhCornersIn, PhCrop, PhMaskHappy, PhPencilSimple } from '@phosphor-icons/vue'
import {
PhCircle,
PhCornersIn,
PhCrop,
PhCursorClick,
PhMaskHappy,
PhPencilSimple,
PhPolygon,
PhSquare,
PhTrash,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import { isLayerActiveAt, sampleLayer } from '@/lib/interpolate'
import { importImageFile, loadImage } from '@/lib/fileLoader'
import { useClipartStore } from '@/stores/clipart'
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
import type {
Action,
AnimatableProps,
Asset,
ButtonAsset,
ButtonState,
HitShape,
Keyframe,
Layer,
Point,
} from '@/types/scene'
import { BUTTON_STATE_TIMES } from '@/types/scene'
import { useButtonRuntime } from '@/composables/useButtonRuntime'
import { evalAction } from '@/lib/actions'
import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked'
@@ -34,6 +51,10 @@ const viewX = ref(0)
const viewY = ref(0)
const cropObjects = ref(false)
const maskPreview = ref(false)
// Stage-preview mode: when false (default) buttons behave like normal
// assets — draggable, no hover/click interaction. When true the canvas
// turns into a live preview with buttons responding to input.
const stagePreview = ref(false)
const previewCanvasEl = ref<HTMLCanvasElement | null>(null)
const previewImages = ref<Record<string, HTMLImageElement>>({})
let didInitialFit = false
@@ -48,8 +69,9 @@ function clamp(v: number, lo: number, hi: number) {
function fitToContainer() {
const cw = containerSize.value.width
const ch = containerSize.value.height
const lw = store.scene.canvas.width
const lh = store.scene.canvas.height
const ec = store.activeButtonAsset ? store.activeButtonAsset.canvas : store.scene.canvas
const lw = ec.width
const lh = ec.height
if (!cw || !ch) return
const z = Math.min(cw / lw, ch / lh) * 0.92
zoom.value = z
@@ -60,14 +82,475 @@ function fitToContainer() {
function zoomToOne() {
const cw = containerSize.value.width
const ch = containerSize.value.height
const lw = store.scene.canvas.width
const lh = store.scene.canvas.height
const ec = store.activeButtonAsset ? store.activeButtonAsset.canvas : store.scene.canvas
const lw = ec.width
const lh = ec.height
if (!cw || !ch) return
zoom.value = 1
viewX.value = (cw - lw) / 2
viewY.value = (ch - lh) / 2
}
const effectiveCanvas = computed(() => {
const ba = store.activeButtonAsset
if (ba) return ba.canvas
return store.scene.canvas
})
// -------- Button hit-shape editing --------------------------------
const activeHitShapes = computed(() => store.activeButtonAsset?.hitArea ?? [])
const selectedHitShapeIndex = ref<number | null>(null)
function hitShapeConfig(shape: HitShape, index: number) {
const selected = selectedHitShapeIndex.value === index
const stroke = selected ? '#ffffff' : '#f59e0b'
const strokeWidth = (selected ? 2 : 1.5) / zoom.value
const base = {
id: `hit-shape:${index}`,
stroke,
strokeWidth,
strokeScaleEnabled: false,
dash: [6 / zoom.value, 4 / zoom.value],
fill: 'rgba(245, 158, 11, 0.08)',
draggable: true,
}
if (shape.kind === 'rect') {
return {
...base,
x: shape.x,
y: shape.y,
width: shape.width,
height: shape.height,
}
}
if (shape.kind === 'circle') {
return {
...base,
x: shape.x,
y: shape.y,
radius: shape.radius,
}
}
// Polygon — Konva Line renders using a flat [x,y,x,y,...] array and
// `closed`. We draw it at (0,0); each vertex carries its own position.
const flat: number[] = []
for (const p of shape.points) flat.push(p.x, p.y)
return {
...base,
x: 0,
y: 0,
points: flat,
closed: true,
lineJoin: 'round',
}
}
function onHitShapeDragEnd(e: Konva.KonvaEventObject<DragEvent>, index: number) {
const ba = store.activeButtonAsset
if (!ba) return
const shape = ba.hitArea[index]
if (!shape) return
const node = e.target
if (shape.kind === 'polygon') {
// Whole-polygon drag: node (0,0) moved to (dx, dy); bake that offset
// into every vertex and reset the node back to (0,0).
const dx = node.x()
const dy = node.y()
if (dx === 0 && dy === 0) return
const moved = shape.points.map((p) => ({ x: p.x + dx, y: p.y + dy }))
store.updateButtonHitShape(ba.id, index, { points: moved } as Partial<HitShape>)
node.position({ x: 0, y: 0 })
return
}
store.updateButtonHitShape(ba.id, index, { x: node.x(), y: node.y() })
}
function onHitShapeClick(_e: Konva.KonvaEventObject<MouseEvent>, index: number) {
selectedHitShapeIndex.value = index
}
function onHitRectTransformEnd(e: Konva.KonvaEventObject<Event>, index: number) {
const ba = store.activeButtonAsset
if (!ba) return
const node = e.target
// Konva Transformer applies scale deltas; bake those into width/height
// and reset the node scale so further transforms compose cleanly.
const sx = node.scaleX()
const sy = node.scaleY()
const newW = Math.max(4, Math.round(node.width() * sx))
const newH = Math.max(4, Math.round(node.height() * sy))
node.scaleX(1)
node.scaleY(1)
node.width(newW)
node.height(newH)
store.updateButtonHitShape(ba.id, index, {
x: Math.round(node.x()),
y: Math.round(node.y()),
width: newW,
height: newH,
} as Partial<HitShape>)
}
function onHitCircleTransformEnd(e: Konva.KonvaEventObject<Event>, index: number) {
const ba = store.activeButtonAsset
if (!ba) return
const node = e.target
const sx = node.scaleX()
const sy = node.scaleY()
const shape = ba.hitArea[index]
if (!shape || shape.kind !== 'circle') return
const scaleAvg = (Math.abs(sx) + Math.abs(sy)) / 2
const newR = Math.max(2, Math.round(shape.radius * scaleAvg))
node.scaleX(1)
node.scaleY(1)
store.updateButtonHitShape(ba.id, index, {
x: Math.round(node.x()),
y: Math.round(node.y()),
radius: newR,
} as Partial<HitShape>)
}
function onHitPolygonVertexDrag(
e: Konva.KonvaEventObject<DragEvent>,
shapeIdx: number,
vertexIdx: number,
) {
const ba = store.activeButtonAsset
if (!ba) return
const shape = ba.hitArea[shapeIdx]
if (!shape || shape.kind !== 'polygon') return
const node = e.target
const next = shape.points.map((p, i) =>
i === vertexIdx ? { x: node.x(), y: node.y() } : { ...p },
)
store.updateButtonHitShape(ba.id, shapeIdx, { points: next } as Partial<HitShape>)
}
function addHitRect() {
const ba = store.activeButtonAsset
if (!ba) return
const w = ba.canvas.width
const h = ba.canvas.height
const rw = Math.max(20, Math.round(w * 0.6))
const rh = Math.max(20, Math.round(h * 0.6))
store.addButtonHitShape(ba.id, {
kind: 'rect',
x: Math.round((w - rw) / 2),
y: Math.round((h - rh) / 2),
width: rw,
height: rh,
})
selectedHitShapeIndex.value = (ba.hitArea?.length ?? 1) - 1
}
function addHitCircle() {
const ba = store.activeButtonAsset
if (!ba) return
const w = ba.canvas.width
const h = ba.canvas.height
const r = Math.max(10, Math.round(Math.min(w, h) * 0.3))
store.addButtonHitShape(ba.id, {
kind: 'circle',
x: Math.round(w / 2),
y: Math.round(h / 2),
radius: r,
})
selectedHitShapeIndex.value = (ba.hitArea?.length ?? 1) - 1
}
function addHitPolygon() {
const ba = store.activeButtonAsset
if (!ba) return
const w = ba.canvas.width
const h = ba.canvas.height
const cx = w / 2
const cy = h / 2
const r = Math.max(10, Math.min(w, h) * 0.35)
const sides = 6
const pts: { x: number; y: number }[] = []
for (let i = 0; i < sides; i++) {
const a = (i / sides) * Math.PI * 2 - Math.PI / 2
pts.push({ x: Math.round(cx + Math.cos(a) * r), y: Math.round(cy + Math.sin(a) * r) })
}
store.addButtonHitShape(ba.id, { kind: 'polygon', points: pts })
selectedHitShapeIndex.value = (ba.hitArea?.length ?? 1) - 1
}
function removeSelectedHitShape() {
const ba = store.activeButtonAsset
const idx = selectedHitShapeIndex.value
if (!ba || idx === null) return
store.removeButtonHitShape(ba.id, idx)
selectedHitShapeIndex.value = null
}
const selectedHitShape = computed(() => {
const idx = selectedHitShapeIndex.value
if (idx === null) return null
const list = store.activeButtonAsset?.hitArea
if (!list) return null
return list[idx] ?? null
})
const hitShapeTransformerConfig = computed(() => ({
rotateEnabled: false,
keepRatio: selectedHitShape.value?.kind === 'circle',
anchorSize: 8,
borderStrokeWidth: 0,
anchorStroke: '#f59e0b',
anchorFill: '#ffffff',
ignoreStroke: true,
flipEnabled: false,
}))
const hitTransformerRef = ref<{ getNode(): Konva.Transformer } | null>(null)
// Keep the transformer bound to whichever hit shape is currently selected.
watch(
() => [selectedHitShapeIndex.value, activeHitShapes.value.length] as const,
() => {
nextTick(() => {
const tr = hitTransformerRef.value?.getNode()
if (!tr) return
const idx = selectedHitShapeIndex.value
const shape = selectedHitShape.value
if (idx === null || !shape || shape.kind === 'polygon') {
tr.nodes([])
tr.getLayer()?.batchDraw()
return
}
const stage = tr.getStage()
const node = stage?.findOne(`#hit-shape:${idx}`)
if (node) tr.nodes([node])
else tr.nodes([])
tr.getLayer()?.batchDraw()
})
},
{ flush: 'post' },
)
function hitVertexConfig(shapeIdx: number, vertexIdx: number, pt: { x: number; y: number }) {
const selected = selectedHitShapeIndex.value === shapeIdx
return {
x: pt.x,
y: pt.y,
radius: (selected ? 5 : 3.5) / zoom.value,
fill: '#ffffff',
stroke: '#f59e0b',
strokeWidth: 1 / zoom.value,
strokeScaleEnabled: false,
draggable: selected,
visible: selected,
listening: selected,
name: `hit-vertex:${shapeIdx}:${vertexIdx}`,
}
}
// Clear hit selection whenever the edit context changes.
watch(
() => [store.activeButtonAssetId, store.activeButtonEditMode, store.activeButtonState] as const,
() => {
selectedHitShapeIndex.value = null
},
)
// -------- Button asset canvas-resize handles ---------------------
// Three drag handles (right edge, bottom edge, bottom-right corner) that
// resize the button's internal canvas while in edit mode. Keeps (0,0)
// pinned; content layers inside the button are unaffected.
type CanvasResizeEdge = 'right' | 'bottom' | 'corner'
interface CanvasResizeHandle {
edge: CanvasResizeEdge
x: number
y: number
size: number
}
const canvasResizeHandles = computed<CanvasResizeHandle[]>(() => {
if (!store.activeButtonAssetId) return []
const w = effectiveCanvas.value.width
const h = effectiveCanvas.value.height
const s = 10 / zoom.value
return [
{ edge: 'right', x: w - s / 2, y: h / 2 - s / 2, size: s },
{ edge: 'bottom', x: w / 2 - s / 2, y: h - s / 2, size: s },
{ edge: 'corner', x: w - s / 2, y: h - s / 2, size: s },
]
})
function canvasResizeHandleConfig(h: CanvasResizeHandle) {
return {
x: h.x,
y: h.y,
width: h.size,
height: h.size,
fill: '#ffffff',
stroke: '#f59e0b',
strokeWidth: 1 / zoom.value,
strokeScaleEnabled: false,
draggable: true,
}
}
function onCanvasResizeDrag(e: Konva.KonvaEventObject<DragEvent>, edge: CanvasResizeEdge) {
const ba = store.activeButtonAsset
if (!ba) return
const node = e.target
const s = 10 / zoom.value
let w = ba.canvas.width
let h = ba.canvas.height
if (edge === 'right' || edge === 'corner') {
w = Math.max(10, Math.round(node.x() + s / 2))
}
if (edge === 'bottom' || edge === 'corner') {
h = Math.max(10, Math.round(node.y() + s / 2))
}
store.setButtonAssetCanvas(ba.id, w, h)
}
function onCanvasResizeDragEnd(e: Konva.KonvaEventObject<DragEvent>, edge: CanvasResizeEdge) {
onCanvasResizeDrag(e, edge)
}
// -------- Button instance runtime (main stage) --------------------
const buttonRt = useButtonRuntime()
function buttonLayerGroupConfig(layer: Layer, props: AnimatableProps) {
const locked = isLayerEffectivelyLocked(layer)
return {
id: layer.id,
x: props.x,
y: props.y,
scaleX: props.scaleX,
scaleY: props.scaleY,
rotation: props.rotation,
opacity: props.opacity,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
visible: layer.visible,
listening: !locked,
// In edit mode buttons behave like any other asset: draggable as a
// single group, transformer can attach. Preview mode locks position
// so the button stays put while receiving pointer events.
draggable: !locked && !stagePreview.value,
}
}
/**
* Invisible Konva shape that covers the button's canvas rect but only
* responds to pointer events inside the union of hit shapes. Pointer
* events on this catcher drive hover/press/click.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type KonvaHitContext = any
function buttonHitCatcherConfig(asset: ButtonAsset, layer: Layer) {
const shapes = asset.hitArea
const hitFunc = (ctx: KonvaHitContext, s: Konva.Shape) => {
if (!shapes.length) {
ctx.beginPath()
ctx.rect(0, 0, asset.canvas.width, asset.canvas.height)
ctx.closePath()
ctx.fillStrokeShape(s)
return
}
ctx.beginPath()
for (const h of shapes) {
if (h.kind === 'rect') {
ctx.rect(h.x, h.y, h.width, h.height)
} else if (h.kind === 'circle') {
ctx.moveTo(h.x + h.radius, h.y)
ctx.arc(h.x, h.y, h.radius, 0, Math.PI * 2)
} else if (h.kind === 'polygon' && h.points && h.points.length >= 3) {
ctx.moveTo(h.points[0].x, h.points[0].y)
for (let pi = 1; pi < h.points.length; pi++) ctx.lineTo(h.points[pi].x, h.points[pi].y)
ctx.closePath()
}
}
ctx.closePath()
ctx.fillStrokeShape(s)
}
return {
x: 0,
y: 0,
width: asset.canvas.width,
height: asset.canvas.height,
fill: 'rgba(0,0,0,0.001)',
// Always listen so the button group can be dragged/selected in edit
// mode. In preview mode, disabled buttons stop listening so they
// don't respond to hover/click.
listening: !isLayerEffectivelyLocked(layer) && (!stagePreview.value || !layer.buttonDisabled),
hitFunc,
name: `button-hit:${layer.id}`,
}
}
function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
return { ...cfg, draggable: false, listening: false }
}
function buttonStackForState(
asset: ButtonAsset,
state: ButtonState,
): 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
const a = assetMap.value.get(l.assetId)
if (!a) continue
out.push({ layer: l, asset: a, props: sampleLayer(l, stateTime) })
}
return out
}
function onButtonEnter(layer: Layer) {
if (!stagePreview.value) return
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
if (cur === 'normal') buttonRt.setState(layer.id, 'hover')
}
function onButtonLeave(layer: Layer) {
if (!stagePreview.value) return
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
if (cur === 'hover' || cur === 'pressed') buttonRt.setState(layer.id, 'normal')
}
function onButtonDown(layer: Layer) {
if (!stagePreview.value) return
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
if (cur === 'hover') buttonRt.setState(layer.id, 'pressed')
}
function onButtonUp(layer: Layer) {
if (!stagePreview.value) return
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
if (cur !== 'pressed') return
buttonRt.setState(layer.id, 'hover')
const actions = layer.onClick
if (actions && actions.length) {
const rt = store.actionRuntime()
for (const a of actions) evalAction(a, rt)
}
}
// ButtonAsset lookup helper for template
function buttonAssetFor(asset: Asset): ButtonAsset | null {
return asset.kind === 'button' ? asset : null
}
function buttonStateFor(layer: Layer): ButtonState {
// 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)
}
const stageConfig = computed(() => ({
width: containerSize.value.width,
height: containerSize.value.height,
@@ -81,8 +564,8 @@ const viewConfig = computed(() => ({
}))
const clipGroupConfig = computed(() => {
const w = store.scene.canvas.width
const h = store.scene.canvas.height
const w = effectiveCanvas.value.width
const h = effectiveCanvas.value.height
const crop = cropObjects.value
return {
clipFunc: (ctx: CanvasRenderingContext2D) => {
@@ -93,6 +576,18 @@ const clipGroupConfig = computed(() => {
})
const backgroundConfig = computed(() => {
// In button-asset edit: checkerboard-ish transparent background so the
// user sees where the button's canvas ends.
if (store.activeButtonAssetId) {
return {
x: 0,
y: 0,
width: effectiveCanvas.value.width,
height: effectiveCanvas.value.height,
listening: false,
fill: '#1a1a1f',
}
}
// Always set `fill` — toggling it in/out of the config across scene
// reloads can leave Konva's Rect with a stale attribute. Use a
// transparent fill when a background video is present so the DOM
@@ -178,8 +673,8 @@ watch(
const canvasBorderConfig = computed(() => ({
x: 0,
y: 0,
width: store.scene.canvas.width,
height: store.scene.canvas.height,
width: effectiveCanvas.value.width,
height: effectiveCanvas.value.height,
stroke: '#4a4a56',
strokeWidth: 1 / zoom.value,
listening: false,
@@ -241,11 +736,31 @@ function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps
}
const stackItems = computed<StackItem[]>(() => {
// Button-asset edit: show only the active state's layer stack. In the
// 'hit' cell show the normal state's layers as an onion-skin
// underneath the hit-area gizmos.
if (store.activeButtonAssetId) {
const out: StackItem[] = []
const ba = store.activeButtonAsset
if (!ba) return out
const onionSkin = store.activeButtonEditMode === 'hit'
const layers = ba.layers.slice().reverse()
for (const layer of layers) {
if (!layer.visible) continue
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
const props = applyLivePose(sampleLayer(layer, store.playhead), layer.id)
if (onionSkin) props.opacity = (props.opacity ?? 1) * 0.35
out.push({ kind: 'layer', item: { layer, asset, props } })
}
return out
}
// Clip-edit scope: just render active clip's layers (top of timeline = top of canvas)
if (store.activeClipId) {
const out: StackItem[] = []
const layers = store.activeLayers.slice().reverse()
for (const layer of layers) {
if (!isLayerActiveAt(layer, store.playhead)) continue
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
out.push({
@@ -279,6 +794,7 @@ const stackItems = computed<StackItem[]>(() => {
const id = order[i]
const layer = layerById.get(id)
if (layer) {
if (!isLayerActiveAt(layer, store.playhead)) continue
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
out.push({
@@ -305,6 +821,7 @@ const stackItems = computed<StackItem[]>(() => {
for (let k = clip.layers.length - 1; k >= 0; k--) {
const cl = clip.layers[k]
if (!cl.visible) continue
if (!isLayerActiveAt(cl, localTime)) continue
const ca = assetMap.value.get(cl.assetId)
if (!ca) continue
out.push({
@@ -344,6 +861,9 @@ function isLayerEffectivelyLocked(layer: Layer): boolean {
for (const g of store.scene.groups ?? []) {
if (g.locked && g.childIds.includes(layer.id)) return true
}
// In hit-area edit the user's focus is on hit geometry; layers are onion-
// skinned and must not steal pointer events from the hit-shape handles.
if (store.activeButtonEditMode === 'hit') return true
return false
}
@@ -1689,6 +2209,14 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
}
const id = target.id?.()
if (!id) {
const name = target.name?.() ?? ''
if (name.startsWith('button-hit:')) {
if (!stagePreview.value) {
const layerId = name.slice('button-hit:'.length)
store.selectLayer(store.activeLayers.find((l) => l.id === layerId) ? layerId : null)
}
return
}
store.selectLayer(null)
return
}
@@ -2202,6 +2730,15 @@ watch(maskPreview, async (on) => {
drawPreview()
})
// Re-fit the stage whenever the user enters / exits a button-asset edit
// session — the button has its own canvas and the framing must update.
watch(
() => store.activeButtonAssetId,
() => {
nextTick(() => fitToContainer())
},
)
function zoomBy(factor: number) {
const cw = containerSize.value.width
const ch = containerSize.value.height
@@ -2288,8 +2825,49 @@ onBeforeUnmount(() => {
"
>
<template v-if="entry.kind === 'layer'">
<v-group
v-if="entry.item.asset.kind === 'button' && buttonAssetFor(entry.item.asset)"
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
@mouseenter="onButtonEnter(entry.item.layer)"
@mouseleave="onButtonLeave(entry.item.layer)"
@mousedown="onButtonDown(entry.item.layer)"
@mouseup="onButtonUp(entry.item.layer)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
>
<v-rect
:config="buttonHitCatcherConfig(buttonAssetFor(entry.item.asset)!, entry.item.layer)"
/>
<template
v-for="(sub, bli) in buttonStackForState(buttonAssetFor(entry.item.asset)!, buttonStateFor(entry.item.layer))"
:key="`${entry.item.layer.id}:${sub.layer.id}:${bli}`"
>
<v-rect
v-if="sub.asset.kind === 'rect'"
:config="buttonChildConfig(rectConfig(sub.layer, sub.asset, sub.props))"
/>
<v-circle
v-else-if="sub.asset.kind === 'circle'"
:config="buttonChildConfig(circleConfig(sub.layer, sub.asset, sub.props))"
/>
<v-text
v-else-if="sub.asset.kind === 'text'"
:config="buttonChildConfig(textConfig(sub.layer, sub.asset, sub.props))"
/>
<v-line
v-else-if="sub.asset.kind === 'polygon'"
:config="buttonChildConfig(polygonConfig(sub.layer, sub.asset, sub.props))"
/>
<v-image
v-else-if="sub.asset.kind === 'image' && imageConfig(sub.layer, sub.asset, sub.props)"
:config="buttonChildConfig(imageConfig(sub.layer, sub.asset, sub.props)!)"
/>
</template>
</v-group>
<v-rect
v-if="entry.item.asset.kind === 'rect'"
v-else-if="entry.item.asset.kind === 'rect'"
:config="rectConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@@ -2352,8 +2930,62 @@ onBeforeUnmount(() => {
/>
</template>
</template>
<template
v-if="store.activeButtonAssetId && store.activeButtonEditMode === 'hit'"
>
<template
v-for="(shape, idx) in activeHitShapes"
:key="`hit-${idx}`"
>
<v-rect
v-if="shape.kind === 'rect'"
:config="hitShapeConfig(shape, idx)"
@click="(e: Konva.KonvaEventObject<MouseEvent>) => onHitShapeClick(e, idx)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onHitShapeDragEnd(e, idx)"
@transformend="(e: Konva.KonvaEventObject<Event>) => onHitRectTransformEnd(e, idx)"
/>
<v-circle
v-else-if="shape.kind === 'circle'"
:config="hitShapeConfig(shape, idx)"
@click="(e: Konva.KonvaEventObject<MouseEvent>) => onHitShapeClick(e, idx)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onHitShapeDragEnd(e, idx)"
@transformend="(e: Konva.KonvaEventObject<Event>) => onHitCircleTransformEnd(e, idx)"
/>
<v-line
v-else-if="shape.kind === 'polygon'"
:config="hitShapeConfig(shape, idx)"
@click="(e: Konva.KonvaEventObject<MouseEvent>) => onHitShapeClick(e, idx)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onHitShapeDragEnd(e, idx)"
/>
</template>
<template
v-for="(shape, idx) in activeHitShapes"
:key="`hit-verts-${idx}`"
>
<template v-if="shape.kind === 'polygon'">
<v-circle
v-for="(pt, vi) in shape.points"
:key="`hv-${idx}-${vi}`"
:config="hitVertexConfig(idx, vi, pt)"
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onHitPolygonVertexDrag(e, idx, vi)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onHitPolygonVertexDrag(e, idx, vi)"
/>
</template>
</template>
<v-transformer
ref="hitTransformerRef"
:config="hitShapeTransformerConfig"
/>
</template>
</v-group>
<v-rect :config="canvasBorderConfig" />
<v-rect
v-for="h in canvasResizeHandles"
:key="`canvas-resize-${h.edge}`"
:config="canvasResizeHandleConfig(h)"
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onCanvasResizeDrag(e, h.edge)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onCanvasResizeDragEnd(e, h.edge)"
/>
<v-line v-if="rotationArmConfig" :config="rotationArmConfig" />
<v-group
v-if="pivotHandleConfig"
@@ -2439,6 +3071,33 @@ onBeforeUnmount(() => {
}"
></div>
<div
v-if="store.activeButtonAssetId && store.activeButtonEditMode === 'hit'"
class="hit-tools"
@pointerdown.stop
>
<button class="hit-btn" :title="t('canvas.hit.addRect')" @click="addHitRect">
<PhSquare :size="13" />
<span>rect</span>
</button>
<button class="hit-btn" :title="t('canvas.hit.addCircle')" @click="addHitCircle">
<PhCircle :size="13" />
<span>circle</span>
</button>
<button class="hit-btn" :title="t('canvas.hit.addPolygon')" @click="addHitPolygon">
<PhPolygon :size="13" />
<span>polygon</span>
</button>
<button
class="hit-btn danger"
:disabled="selectedHitShapeIndex === null"
:title="t('canvas.hit.remove')"
@click="removeSelectedHitShape"
>
<PhTrash :size="13" />
</button>
</div>
<div class="zoom-controls" @pointerdown.stop>
<button class="zc-btn" :title="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
<button class="zc-label" :title="t('canvas.fitToView')" @click="fitToContainer">
@@ -2468,9 +3127,17 @@ onBeforeUnmount(() => {
<PhMaskHappy v-if="!maskPreview" :size="13" />
<PhPencilSimple v-else :size="13" />
</button>
<button
class="zc-btn stage-preview-btn"
:class="{ active: stagePreview }"
:title="stagePreview ? t('canvas.stagePreviewOff') : t('canvas.stagePreviewOn')"
@click="stagePreview = !stagePreview"
>
<PhCursorClick :size="13" />
</button>
</div>
<div class="dims">{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}</div>
<div class="dims">{{ effectiveCanvas.width }} × {{ effectiveCanvas.height }}</div>
<div
v-if="vertexMenu"
@@ -2581,6 +3248,49 @@ onBeforeUnmount(() => {
pointer-events: none;
}
.hit-tools {
position: absolute;
top: 10px;
left: 12px;
display: flex;
align-items: center;
gap: 4px;
padding: 3px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
user-select: none;
}
.hit-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 9px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 11px;
cursor: pointer;
}
.hit-btn:hover:not(:disabled) {
background: var(--panel-hover);
}
.hit-btn:disabled {
opacity: 0.4;
cursor: default;
}
.hit-btn.danger:hover:not(:disabled) {
background: var(--danger-soft);
color: var(--danger);
border-color: var(--danger);
}
.zoom-controls {
position: absolute;
bottom: 10px;
+42 -172
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer'
import { t } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
@@ -10,13 +10,9 @@ const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const canvasEl = ref<HTMLCanvasElement | null>(null)
const frameEl = ref<HTMLDivElement | null>(null)
const rootEl = ref<HTMLDivElement | null>(null)
const progressTrackEl = ref<HTMLDivElement | null>(null)
const bgVideoEl = ref<HTMLVideoElement | null>(null)
const viewportSize = ref({ w: 0, h: 0 })
const images = ref<Record<string, HTMLImageElement>>({})
const localTime = ref(0)
const playing = ref(false)
const showControls = ref(false)
@@ -24,6 +20,7 @@ const scrubbing = ref(false)
const HOVER_ZONE_PX = 160
let isClosing = false
let player: PlayerHandle | null = null
const aspect = computed(
() => `${store.scene.canvas.width} / ${store.scene.canvas.height}`,
@@ -32,117 +29,44 @@ const aspect = computed(
const rangeIn = computed(() => store.playbackIn)
const rangeOut = computed(() => store.playbackOut)
let raf = 0
let lastFrame = 0
let ro: ResizeObserver | null = null
function resize() {
if (!canvasEl.value || !frameEl.value) return
const rect = frameEl.value.getBoundingClientRect()
viewportSize.value = { w: rect.width, h: rect.height }
const dpr = window.devicePixelRatio || 1
canvasEl.value.width = Math.max(1, Math.round(rect.width * dpr))
canvasEl.value.height = Math.max(1, Math.round(rect.height * dpr))
draw()
}
function draw() {
const canvas = canvasEl.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
const lw = store.scene.canvas.width
const lh = store.scene.canvas.height
const cw = viewportSize.value.w
const ch = viewportSize.value.h
if (!cw || !ch) return
const scale = Math.min(cw / lw, ch / lh)
const ox = (cw - lw * scale) / 2
const oy = (ch - lh * scale) / 2
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, cw, ch)
ctx.save()
ctx.translate(ox, oy)
ctx.scale(scale, scale)
drawSceneFrame(ctx, store.scene, localTime.value, images.value)
ctx.restore()
}
function loop(now: number) {
if (!props.modelValue) return
if (playing.value) {
const start = rangeIn.value
const end = rangeOut.value
if (end > start) {
const v = bgVideoEl.value
if (v && !v.paused && !v.seeking && !scrubbing.value && v.readyState >= 2) {
// Slave localTime to the video element so drift can't build up and
// cause compensating seeks — those seeks are what makes the video
// stutter visibly.
let vt = v.currentTime
if (vt >= end || vt < start) {
vt = start
v.currentTime = vt
}
localTime.value = vt
} else {
const dt = lastFrame === 0 ? 0 : (now - lastFrame) / 1000
let next = localTime.value + dt
if (next >= end) next = start
else if (next < start) next = start
localTime.value = next
}
}
}
lastFrame = now
draw()
raf = requestAnimationFrame(loop)
}
function startLoop() {
cancelAnimationFrame(raf)
lastFrame = 0
raf = requestAnimationFrame(loop)
}
function stopLoop() {
cancelAnimationFrame(raf)
raf = 0
}
// ---- lifecycle ----
async function startPreview() {
destroyPlayer()
await nextTick()
const el = frameEl.value
if (!el) return
playing.value = true
showControls.value = false
const start = rangeIn.value
const end = rangeOut.value
const head = store.playhead
localTime.value = head < start || head >= end ? start : head
playing.value = true
showControls.value = false
images.value = await loadSceneImages(store.scene)
resize()
startLoop()
// The playing watcher may have fired before the <video> ref was bound;
// make sure the background video is actually started now that the DOM
// is mounted.
const v = bgVideoEl.value
if (v) {
v.currentTime = localTime.value
try {
await v.play()
} catch {
/* ignore autoplay errors */
}
}
await requestBrowserFullscreen()
const initialTime = head < start || head >= end ? start : head
player = mountPlayer(el, store.scene, {
loop: true,
onFrame(time: number, paused: boolean) {
localTime.value = time
playing.value = !paused
},
})
player.seek(initialTime)
requestBrowserFullscreen()
}
function stopPreview() {
stopLoop()
destroyPlayer()
playing.value = false
showControls.value = false
exitBrowserFullscreen()
}
function destroyPlayer() {
if (player) {
player.destroy()
player = null
}
}
async function requestBrowserFullscreen() {
const el = rootEl.value
if (!el) return
@@ -183,6 +107,8 @@ function onRootMouseLeave() {
if (!scrubbing.value) showControls.value = false
}
// ---- transport controls ----
const progressPct = computed(() => {
const start = rangeIn.value
const end = rangeOut.value
@@ -193,20 +119,20 @@ const progressPct = computed(() => {
function seekFromEvent(e: PointerEvent) {
const el = progressTrackEl.value
if (!el) return
if (!el || !player) return
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const start = rangeIn.value
const end = rangeOut.value
localTime.value = start + ratio * (end - start)
draw()
player.seek(start + ratio * (end - start))
}
function onProgressPointerDown(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
scrubbing.value = true
if (player) player.pause()
seekFromEvent(e)
}
@@ -226,12 +152,14 @@ function close() {
}
function toggle() {
playing.value = !playing.value
if (player) player.toggle()
}
function rewind() {
localTime.value = 0
playing.value = true
if (player) {
player.seek(rangeIn.value)
player.play()
}
}
function onKeyDown(e: KeyboardEvent) {
@@ -255,38 +183,6 @@ watch(
},
)
watch(
() => [store.scene.canvas.width, store.scene.canvas.height],
() => {
if (props.modelValue) resize()
},
)
watch(playing, async (p) => {
const v = bgVideoEl.value
if (!v) return
if (p) {
v.currentTime = localTime.value
try {
await v.play()
} catch {
/* ignore */
}
} else {
v.pause()
v.currentTime = localTime.value
}
})
// Keep the video frame aligned with localTime when we are NOT letting the
// video drive time (paused or actively scrubbing). During normal playback
// the RAF loop reads v.currentTime, so we must not seek here.
watch(localTime, (t) => {
const v = bgVideoEl.value
if (!v) return
if (playing.value && !scrubbing.value) return
v.currentTime = t
})
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
document.addEventListener('fullscreenchange', onFullscreenChange)
@@ -296,23 +192,8 @@ onMounted(() => {
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('fullscreenchange', onFullscreenChange)
stopLoop()
destroyPlayer()
exitBrowserFullscreen()
if (ro) ro.disconnect()
ro = null
})
watch(frameEl, (el, _old, onCleanup) => {
if (ro) ro.disconnect()
ro = null
if (el) {
ro = new ResizeObserver(() => resize())
ro.observe(el)
}
onCleanup(() => {
if (ro) ro.disconnect()
ro = null
})
})
const timeLabel = computed(() => {
@@ -333,18 +214,7 @@ const timeLabel = computed(() => {
@mouseleave="onRootMouseLeave"
@dblclick.self="close"
>
<div ref="frameEl" class="fs-frame" :style="{ aspectRatio: aspect }">
<video
v-if="store.scene.backgroundVideo"
ref="bgVideoEl"
class="fs-bg-video"
:src="store.scene.backgroundVideo.src"
muted
playsinline
preload="auto"
></video>
<canvas ref="canvasEl" />
</div>
<div ref="frameEl" class="fs-frame" :style="{ aspectRatio: aspect }"></div>
<div class="fs-controls" @mousedown.stop>
<div
@@ -402,7 +272,7 @@ const timeLabel = computed(() => {
justify-content: center;
}
.fs-bg-video {
.fs-frame :deep(video) {
position: absolute;
inset: 0;
width: 100%;
@@ -412,7 +282,7 @@ const timeLabel = computed(() => {
z-index: 0;
}
.fs-frame canvas {
.fs-frame :deep(canvas) {
display: block;
width: 100%;
height: 100%;
+22 -2
View File
@@ -15,13 +15,14 @@ import {
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import type { AnimatableProps } from '@/types/scene'
import type { Action, AnimatableProps } from '@/types/scene'
import RangeSlider from './RangeSlider.vue'
import ActionEditor from './ActionEditor.vue'
import { t } from '@/i18n'
const SECTION_STORAGE_KEY = 'animatr:propSections'
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask'
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button'
const defaultOpen: Record<SectionKey, boolean> = {
transform: true,
@@ -30,6 +31,7 @@ const defaultOpen: Record<SectionKey, boolean> = {
appearance: true,
shadow: false,
mask: false,
button: true,
}
function readStoredOpen(): Record<SectionKey, boolean> {
@@ -92,6 +94,11 @@ const polygonAsset = computed(() => {
return a && a.kind === 'polygon' ? a : null
})
const buttonAsset = computed(() => {
const a = selectedAsset.value
return a && a.kind === 'button' ? a : null
})
const polygonPointCount = computed<number>({
get: () => polygonAsset.value?.points.length ?? 0,
set: (v: number) => {
@@ -620,6 +627,19 @@ function removeKeyframeHere() {
</div>
</section>
<section v-if="buttonAsset" class="prop-group">
<button class="group-header" @click="toggleSection('button')">
<component :is="sectionOpen.button ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.button') }}</span>
</button>
<div v-if="sectionOpen.button" class="group-body">
<ActionEditor
:actions="selected.onClick ?? []"
@update:actions="(v: Action[]) => store.setLayerOnClick(selected!.id, v)"
/>
</div>
</section>
<button class="danger-btn" @click="store.removeLayer(selected.id)">
<PhTrash :size="13" />
{{ t('properties.deleteLayer') }}
+483 -21
View File
@@ -40,6 +40,7 @@ import type {
Clip,
ClipInstance,
Easing,
HitShape,
Keyframe,
Layer,
TrackGroup,
@@ -97,7 +98,9 @@ type TimelineTrack =
const store = useSceneStore()
const duration = computed(() => store.activeDuration)
const duration = computed(() =>
store.activeButtonAssetId ? 5 : store.activeDuration,
)
const inClipEdit = computed(() => store.activeClipId !== null)
const clipInstancesByTrack = computed<Record<string, ClipInstance[]>>(() => {
const out: Record<string, ClipInstance[]> = {}
@@ -133,8 +136,11 @@ const scriptFramesByTrack = computed<Record<string, typeof store.scene.scriptFra
}
return out
})
const TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth'
const TRACK_NAME_WIDTH_DEFAULT = 140
// Bump the storage key so clients with an old (narrower) stored value pick
// up the new default that matches the assets-sidebar column width (260px),
// which keeps the timeline track-name column visually aligned with it.
const TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth:v2'
const TRACK_NAME_WIDTH_DEFAULT = 260
const TRACK_NAME_WIDTH_MIN = 80
const TRACK_NAME_WIDTH_MAX = 400
@@ -198,8 +204,16 @@ function onNameResizeEnd(e: PointerEvent) {
const MIN_PX_PER_SEC = 2
const MAX_PX_PER_SEC = 2000
// Fixed 100px per state-time unit in button-edit mode. Hoisted here so the
// effectivePxPerSec computed below can reference it.
const BUTTON_STATE_WIDTH = 100
const scrollEl = ref<HTMLDivElement | null>(null)
const viewportWidth = ref(0)
// Tracks horizontal scroll offset of the timeline so we can hide the
// scrub-handle behind the sticky name-spacer when the playhead has
// scrolled off the left edge of the visible ruler area.
const scrollLeft = ref(0)
const pxPerSec = ref(0)
const autoFit = ref(true)
@@ -214,6 +228,9 @@ const fitPxPerSec = computed(() => {
})
const effectivePxPerSec = computed(() => {
// Button-edit uses a fixed 100px per state unit so the kf dots on tracks
// line up with the state labels on the scrubber above.
if (store.activeButtonAssetId) return BUTTON_STATE_WIDTH
if (autoFit.value || pxPerSec.value <= 0) return fitPxPerSec.value
return pxPerSec.value
})
@@ -284,6 +301,15 @@ function pxFromTime(t: number) {
return t * effectivePxPerSec.value
}
// True when the playhead has scrolled off the left side of the visible ruler
// area (so the handle's centre sits behind the sticky name-spacer column).
// Used to drop the handle's z-index below the name-spacer so it doesn't
// render over the toggle-row buttons. When in view, the handle sits above
// the name-spacer so its left half isn't clipped at t=0.
const scrubHandleObscured = computed(
() => scrollLeft.value > pxFromTime(store.playhead),
)
function groupKeyframeTimes(groupId: string): number[] {
const group = groupById.value.get(groupId)
if (!group) return []
@@ -628,6 +654,19 @@ function isTrackEffectivelyLocked(track: TimelineTrack): boolean {
const tracks = computed<TimelineTrack[]>(() => {
const out: TimelineTrack[] = []
// Button-asset edit: show only the active state's layers. 'hit' mode
// has no layer stack — timeline is empty (user edits hit shapes on
// the stage).
if (store.activeButtonAssetId) {
// Hit mode is a separate stage — it owns the timeline, shows hit shapes
// as tracks (rendered below, outside the main track v-for), and layers
// from the 4 content states do not appear here.
if (store.activeButtonEditMode === 'hit') return out
store.activeLayers.forEach((layer, orderIndex) => {
emitLayerTrack(layer, orderIndex, false, out)
})
return out
}
if (store.activeClipId) {
store.activeLayers.forEach((layer, orderIndex) => {
emitLayerTrack(layer, orderIndex, false, out)
@@ -936,6 +975,63 @@ function onKeyframePointerUp(e: PointerEvent) {
}
}
// ---- Script frame dragging ------------------------------------------------
interface SfDrag {
frameId: string
lane: HTMLElement
pointerId: number
startX: number
moved: boolean
initialTime: number
}
const sfDrag = ref<SfDrag | null>(null)
function onScriptFramePointerDown(e: PointerEvent, frameId: string) {
if (e.button !== 0) return
const el = e.currentTarget as HTMLElement
const lane = el.parentElement
if (!lane) return
const frame = store.scene.scriptFrames?.find((f) => f.id === frameId)
if (!frame) return
e.stopPropagation()
el.setPointerCapture(e.pointerId)
sfDrag.value = {
frameId,
lane,
pointerId: e.pointerId,
startX: e.clientX,
moved: false,
initialTime: frame.time,
}
}
function onScriptFramePointerMove(e: PointerEvent) {
const d = sfDrag.value
if (!d || d.pointerId !== e.pointerId) return
if (!d.moved && Math.abs(e.clientX - d.startX) < 3) return
d.moved = true
const rect = d.lane.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const newTime = snapTime(ratio * duration.value)
store.setScriptFrameTime(d.frameId, newTime)
}
function onScriptFramePointerUp(e: PointerEvent) {
const d = sfDrag.value
if (!d || d.pointerId !== e.pointerId) return
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
if (!d.moved) {
openScriptFrame(d.frameId)
}
sfDrag.value = null
}
// ---------------------------------------------------------------------------
interface KfSegment {
id: string
fromTime: number
@@ -999,7 +1095,7 @@ function onKeyframeContextMenu(
) {
e.preventDefault()
e.stopPropagation()
const layer = store.scene.layers.find((l) => l.id === layerId)
const layer = store.activeLayers.find((l) => l.id === layerId)
const kf = kfs.find((k) => k.id === keyframeId)
if (!layer || !kf) return
const sorted = [...kfs].sort((a, b) => a.time - b.time)
@@ -1039,7 +1135,7 @@ function duplicateFromMenu() {
if (!menu.value) return
const newId = store.duplicateKeyframe(menu.value.layerId, menu.value.keyframeId)
if (newId) {
const layer = store.scene.layers.find((l) => l.id === menu.value!.layerId)
const layer = store.activeLayers.find((l) => l.id === menu.value!.layerId)
const all = layer
? [
...layer.keyframes,
@@ -1471,6 +1567,90 @@ function clipBlockStyle(ci: ClipInstance) {
}
}
// -------- Button asset edit bar --------------------------------------
const BUTTON_EDIT_STATES = ['normal', 'hover', 'pressed', 'disabled'] as const
type ButtonContentState = (typeof BUTTON_EDIT_STATES)[number]
function stateLabel(st: ButtonContentState): string {
return st[0].toUpperCase() + st.slice(1)
}
const activeButtonAssetName = computed(() => {
const id = store.activeButtonAssetId
if (!id) return ''
const asset = store.scene.assets.find((a) => a.id === id)
return asset && asset.kind === 'button' ? asset.name : ''
})
// Visual "state timeline" — each state sits at a fixed 100px increment so
// the track-lane kf dots (at pxFromTime(t) = t * 100) line up with the
// state label directly above. Hit has no kf semantics; it sits one slot
// past 'disabled' as a navigation shortcut only.
function stateTickLeft(idx: number): number {
return (idx + 1) * BUTTON_STATE_WIDTH
}
const BUTTON_STATE_TOTAL_WIDTH = BUTTON_STATE_WIDTH * (BUTTON_EDIT_STATES.length + 1)
const currentStateLeft = computed(() => {
const st = store.activeButtonState
if (!st) return 0
const idx = BUTTON_EDIT_STATES.indexOf(st as ButtonContentState)
return idx >= 0 ? stateTickLeft(idx) : 0
})
function updateStateFromPointer(e: PointerEvent) {
const el = e.currentTarget as HTMLElement | null
if (!el) return
const r = el.getBoundingClientRect()
const x = e.clientX - r.left
const idx = Math.max(
0,
Math.min(
BUTTON_EDIT_STATES.length - 1,
Math.round((x - BUTTON_STATE_WIDTH) / BUTTON_STATE_WIDTH),
),
)
store.setActiveButtonState(BUTTON_EDIT_STATES[idx])
}
const activeButtonHitShapes = computed(() => {
const id = store.activeButtonAssetId
if (!id) return []
const asset = store.scene.assets.find((a) => a.id === id)
return asset && asset.kind === 'button' ? asset.hitArea : []
})
function hitShapeLabel(shape: HitShape, idx: number): string {
const n = idx + 1
if (shape.kind === 'rect') return `Rect ${n} · ${Math.round(shape.width)}×${Math.round(shape.height)}`
if (shape.kind === 'circle') return `Circle ${n} · r=${Math.round(shape.radius)}`
return `Polygon ${n} · ${shape.points.length} pts`
}
function onDeleteHitShape(idx: number) {
const id = store.activeButtonAssetId
if (!id) return
store.removeButtonHitShape(id, idx)
}
function onStateScrubPointerDown(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
updateStateFromPointer(e)
}
function onStateScrubPointerMove(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (!el.hasPointerCapture(e.pointerId)) return
updateStateFromPointer(e)
}
function onStateScrubPointerUp(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
}
// Script frame dialog state
const scriptDialogOpen = ref(false)
const scriptDialogFrameId = ref<string | null>(null)
@@ -1851,6 +2031,10 @@ function onWheel(e: WheelEvent) {
let ro: ResizeObserver | null = null
function onScrollCapture() {
if (scrollEl.value) scrollLeft.value = scrollEl.value.scrollLeft
}
onMounted(() => {
if (!scrollEl.value) return
ro = new ResizeObserver((entries) => {
@@ -1858,12 +2042,16 @@ onMounted(() => {
viewportWidth.value = rect.width
})
ro.observe(scrollEl.value)
scrollEl.value.addEventListener('scroll', onScrollCapture, { passive: true })
// Seed with current value.
scrollLeft.value = scrollEl.value.scrollLeft
document.addEventListener('keydown', onDocumentKeyDown)
})
onBeforeUnmount(() => {
ro?.disconnect()
ro = null
if (scrollEl.value) scrollEl.value.removeEventListener('scroll', onScrollCapture)
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
document.removeEventListener('pointerdown', onOutsideMarkerPointerDown, true)
document.removeEventListener('keydown', onKeyDown)
@@ -1875,7 +2063,58 @@ onBeforeUnmount(() => {
<div class="timeline" :style="timelineRootStyle">
<div ref="scrollEl" class="tl-scroll" @wheel="onWheel">
<div class="tl-content" :style="{ width: totalWidth + 'px' }">
<div class="scrub-bar">
<div v-if="store.activeButtonAssetId" class="scrub-bar button-scrub-bar">
<div class="name-spacer">
<div class="button-mode-toggle">
<button
class="mode-btn"
:class="{ active: store.activeButtonEditMode === 'states' }"
:title="t('timeline.buttonModeStates')"
@click="store.setButtonEditMode('states')"
>{{ t('timeline.buttonModeStatesShort') }}</button>
<button
class="mode-btn hit"
:class="{ active: store.activeButtonEditMode === 'hit' }"
:title="t('timeline.buttonModeHit')"
@click="store.setButtonEditMode('hit')"
>{{ t('timeline.buttonModeHitShort') }}</button>
</div>
</div>
<div
v-if="store.activeButtonEditMode === 'states'"
class="scrub-ruler button-state-ruler"
:style="{ width: BUTTON_STATE_TOTAL_WIDTH + 'px' }"
@pointerdown="onStateScrubPointerDown"
@pointermove="onStateScrubPointerMove"
@pointerup="onStateScrubPointerUp"
@pointercancel="onStateScrubPointerUp"
>
<span
v-for="(st, idx) in BUTTON_EDIT_STATES"
:key="st"
class="tick button-state-tick"
:class="{
active: store.activeButtonState === st,
}"
:style="{ left: stateTickLeft(idx) + 'px' }"
>
<span class="tick-line"></span>
<span class="tick-label">{{ stateLabel(st) }}</span>
</span>
<span
class="scrub-handle"
:style="{ left: currentStateLeft + 'px' }"
>
<span class="scrub-handle-head"></span>
<span class="scrub-handle-line"></span>
</span>
</div>
<div v-else class="hit-edit-header">
<span class="hit-edit-title">{{ t('timeline.hitAreaHeader') }}</span>
<span class="hit-edit-hint">{{ t('timeline.hitAreaHint') }}</span>
</div>
</div>
<div v-else class="scrub-bar">
<div class="name-spacer">
<div class="mode-toggle" :title="t('timeline.toggleTimeUnit')">
<button
@@ -2001,7 +2240,11 @@ onBeforeUnmount(() => {
<span class="timeline-marker-flag"></span>
<span class="timeline-marker-label">{{ marker.name }}</span>
</span>
<span class="scrub-handle" :style="{ left: pxFromTime(store.playhead) + 'px' }">
<span
class="scrub-handle"
:class="{ obscured: scrubHandleObscured }"
:style="{ left: pxFromTime(store.playhead) + 'px' }"
>
<span class="scrub-handle-head"></span>
<span class="scrub-handle-line"></span>
</span>
@@ -2344,7 +2587,10 @@ onBeforeUnmount(() => {
class="script-frame"
:style="{ left: pxFromTime(frame.time) + 'px' }"
:title="frame.label ?? t('timeline.scriptFrameTooltip', { n: frame.actions.length })"
@click.stop="openScriptFrame(frame.id)"
@pointerdown="onScriptFramePointerDown($event, frame.id)"
@pointermove="onScriptFramePointerMove"
@pointerup="onScriptFramePointerUp"
@pointercancel="onScriptFramePointerUp"
>
<span class="script-frame-dot"></span>
<span v-if="frame.actions.length > 1" class="script-frame-count">{{ frame.actions.length }}</span>
@@ -2431,9 +2677,40 @@ onBeforeUnmount(() => {
</div>
</div>
</template>
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
<template v-if="store.activeButtonEditMode === 'hit'">
<div
v-for="(shape, idx) in activeButtonHitShapes"
:key="`hit-track-${idx}`"
class="track hit-track"
>
<div class="name-col">
<span class="hit-track-kind">
<PhSquare v-if="shape.kind === 'rect'" :size="11" />
<PhCircle v-else-if="shape.kind === 'circle'" :size="11" />
<PhPolygon v-else :size="11" />
</span>
<span class="hit-track-label">{{ hitShapeLabel(shape, idx) }}</span>
<button
class="hit-track-delete"
:title="t('timeline.hitTrackDelete')"
@pointerdown.stop
@click.stop="onDeleteHitShape(idx)"
>×</button>
</div>
<div class="lane hit-lane"></div>
</div>
<p v-if="!activeButtonHitShapes.length" class="empty">
{{ t('timeline.hitAreaEmpty') }}
</p>
</template>
<p
v-else-if="!tracks.length"
class="empty"
>
{{ store.activeButtonAssetId ? t('timeline.emptyButtonState') : t('timeline.noTracks') }}
</p>
<div
v-if="!inClipEdit"
v-if="!inClipEdit && !store.activeButtonAssetId"
class="track clip-track-dropzone"
:class="{ hot: clipDragOverDropzone }"
@dragenter="onNewTrackDragEnter"
@@ -2542,16 +2819,14 @@ onBeforeUnmount(() => {
<template v-if="trackMenu.kind === 'group'">
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="renameFromMenu">
<span class="check"></span>
<PhArrowsSplit :size="12" class="kf-menu-icon" style="visibility: hidden" />
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
{{ t('timeline.renameGroup') }}
</button>
</template>
<template v-else-if="trackMenu.kind === 'script'">
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="renameFromMenu">
<span class="check"></span>
<PhArrowsSplit :size="12" class="kf-menu-icon" style="visibility: hidden" />
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
{{ t('timeline.renameScriptTrack') }}
</button>
</template>
@@ -2563,17 +2838,19 @@ onBeforeUnmount(() => {
:disabled="trackMenu.locked"
@click="toggleSeparated"
>
<span class="check">
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
</span>
<PhArrowsSplit :size="12" class="kf-menu-icon" />
{{ t('timeline.separate') }}
<PhCheck
v-if="trackMenu.separated"
:size="12"
weight="bold"
class="kf-menu-trailing-check"
/>
</button>
</template>
</template>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteTrack">
<span class="check"></span>
<PhTrash :size="12" class="kf-menu-icon" />
{{
trackMenu.kind === 'layer'
@@ -2599,6 +2876,155 @@ onBeforeUnmount(() => {
position: relative;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.button-scrub-bar {
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
}
.button-scrub-bar .button-name-label {
font-size: 11px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
.button-state-ruler {
cursor: pointer;
}
.button-state-tick .tick-line {
height: 8px;
background: var(--border);
}
.button-state-tick.active .tick-line {
background: var(--accent);
}
.button-state-tick .tick-label {
font-size: 11px;
font-weight: 500;
text-transform: capitalize;
padding-top: 4px;
letter-spacing: 0.01em;
}
.button-state-tick.active .tick-label {
color: var(--text);
}
.button-mode-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg);
margin-left: auto;
}
.button-mode-toggle .mode-btn {
padding: 3px 8px;
min-width: 48px;
font-size: 10px;
font-weight: 500;
color: var(--text-dim);
background: transparent;
border: none;
cursor: pointer;
}
.button-mode-toggle .mode-btn:hover {
background: var(--panel-alt);
color: var(--text);
}
.button-mode-toggle .mode-btn.active {
background: var(--accent-soft);
color: var(--accent);
}
.button-mode-toggle .mode-btn.hit.active {
background: color-mix(in srgb, #f59e0b 24%, transparent);
color: #f59e0b;
}
.button-mode-toggle .mode-btn + .mode-btn {
border-left: 1px solid var(--border);
}
.hit-edit-header {
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
color: var(--text);
font-size: 11px;
flex: 1;
}
.hit-edit-title {
font-weight: 600;
color: #f59e0b;
}
.hit-edit-hint {
color: var(--text-dim);
}
.hit-track .name-col {
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
}
.hit-track-kind {
color: #f59e0b;
display: inline-flex;
align-items: center;
}
.hit-track-label {
font-size: 11px;
color: var(--text);
font-variant-numeric: tabular-nums;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hit-track-delete {
width: 18px;
height: 18px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text-dim);
cursor: pointer;
font-size: 13px;
line-height: 1;
}
.hit-track-delete:hover {
background: var(--panel-alt);
color: #ef4444;
}
.hit-lane {
background: repeating-linear-gradient(
45deg,
transparent 0 8px,
color-mix(in srgb, #f59e0b 8%, transparent) 8px 16px
);
}
.name-col-resize {
@@ -2653,9 +3079,14 @@ onBeforeUnmount(() => {
.scrub-bar {
position: sticky;
top: 0;
z-index: 10;
/* Above sticky track-name columns (z-index 30) so that scrolling rows
pass *under* the scrub-bar + name-spacer instead of sliding over it. */
z-index: 40;
display: flex;
/* 28px ruler content + 3px padding-top reserved for the scrub handle
head to poke above the ruler. Total box height: 31px. */
height: 28px;
padding-top: 3px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
@@ -2665,6 +3096,13 @@ onBeforeUnmount(() => {
left: 0;
z-index: 30;
width: var(--track-name-width);
/* Extend 3px up (into the scrub-bar's padding-top) and 1px down (over
the scrub-bar's border so the handle line's `bottom: -1px` overshoot
can't peek through underneath). Together this makes the toggle row
fully cover the obscured handle. */
height: calc(100% + 4px);
margin-top: -3px;
margin-bottom: -1px;
background: var(--panel);
border-right: 1px solid var(--border);
flex-shrink: 0;
@@ -2936,6 +3374,17 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
align-items: center;
/* Default: above the sticky name-spacer (z-index 30 in the scrub-bar
stacking context) so the handle isn't half-clipped when the playhead
is at t=0 and overlaps the name-spacer column. */
z-index: 41;
}
/* When the playhead has scrolled past the left edge of the ruler (i.e. the
handle centre sits behind the sticky name-spacer), drop below z:30 so
the name-spacer + toggle row covers the handle cleanly. Toggled from
JS via `scrubHandleObscured`. */
.scrub-handle.obscured {
z-index: 20;
}
@@ -2943,8 +3392,10 @@ onBeforeUnmount(() => {
width: 14px;
height: 18px;
background: var(--accent);
margin-top: 2px;
clip-path: polygon(0 0, 100% 0, 100% 55%, 50% 100%, 0 55%);
/* Poke 3px into the scrub-bar's padding-top area so the head sticks
out above the ruler, with slightly rounded top corners (radius 2). */
margin-top: -3px;
clip-path: path('M 2 0 Q 0 0 0 2 L 0 10 L 7 18 L 14 10 L 14 2 Q 14 0 12 0 Z');
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
pointer-events: auto;
cursor: ew-resize;
@@ -3435,6 +3886,8 @@ onBeforeUnmount(() => {
align-items: center;
gap: 3px;
cursor: pointer;
touch-action: none;
user-select: none;
}
.script-frame-dot {
@@ -3775,6 +4228,15 @@ onBeforeUnmount(() => {
justify-content: center;
}
/* Track-menu toggle items (e.g. "Separate Position and Rotation") show their
"active" checkmark on the far right so the left edge stays aligned with
items that only have an icon + label. */
.kf-menu-trailing-check {
margin-left: auto;
color: var(--accent);
flex-shrink: 0;
}
.kf-menu-note {
padding: 2px 10px 4px;
font-size: 10px;
+45
View File
@@ -0,0 +1,45 @@
import { reactive } from 'vue'
import 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.
*/
interface RuntimeState {
state: Map<string, ButtonState>
disabled: Map<string, boolean>
}
const runtime: RuntimeState = reactive({
state: new Map(),
disabled: new Map(),
})
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'
},
setState(layerId: string, next: ButtonState) {
if (next === 'normal') runtime.state.delete(layerId)
else runtime.state.set(layerId, next)
},
setDisabled(layerId: string, next: boolean) {
runtime.disabled.set(layerId, next)
},
clear(layerId: string) {
runtime.state.delete(layerId)
runtime.disabled.delete(layerId)
},
reset() {
runtime.state.clear()
runtime.disabled.clear()
},
}
}
+12 -2
View File
@@ -204,7 +204,11 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<span class="hud-test">{{ currentTest || '…' }}</span>
</div>
<div class="hud-bar">
<div class="hud-bar-fill" :style="{ width: progressPct + '%' }"></div>
<div
class="hud-bar-fill"
:class="{ 'has-failures': failedSoFar > 0 }"
:style="{ width: progressPct + '%' }"
></div>
</div>
</div>
</Transition>
@@ -671,7 +675,13 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width 120ms linear;
transition: width 120ms linear, background 160ms var(--ease);
}
/* Flip the progress bar to red as soon as the first test fails so the
user sees the bad state mid-run instead of only at the end. */
.hud-bar-fill.has-failures {
background: var(--danger);
}
.hud-pop-enter-active,
+412 -113
View File
File diff suppressed because it is too large. Load diff
+48
View File
@@ -67,6 +67,28 @@ export const en = {
polygon: 'Polygon',
freeform: 'Freeform (click to draw, Enter to finish, Esc to cancel)',
freeformActive: 'Drawing… (Enter to finish, Esc to cancel)',
button: 'Button (5-frame asset)',
},
buttons: 'Buttons',
noButtons: 'No buttons yet',
createButtonTooltip: 'Create new button asset',
hitModeDisabled: 'Switch back to button states to add assets.',
newButtonDialog: {
title: 'New button',
nameMessage: 'Button name:',
defaultName: 'Button',
next: 'Next',
sizeMessage: 'Canvas size (width × height):',
confirm: 'Create',
},
renameButtonDialog: {
title: 'Rename button',
confirm: 'Apply',
},
deleteButtonDialog: {
title: 'Delete button?',
message: 'The button asset will be removed. Any layers that reference it will no longer render.',
confirm: 'Delete',
},
newTextDialog: {
title: 'New text object',
@@ -112,6 +134,7 @@ export const en = {
text: 'Text',
polygon: 'Polygon',
appearance: 'Appearance',
button: 'On Click',
},
polygonPoints: 'Points',
polygonPointsHint: 'Changing the point count rewrites all keyframes; new points are inserted at the midpoint of the last edge.',
@@ -163,6 +186,17 @@ export const en = {
scriptTrackName: 'Script track',
addScriptTrackTooltip: 'Create a new script track',
scriptFrameTooltip: 'Script frame — {n} action(s)',
buttonEditLabel: 'Editing button:',
buttonEditExit: 'Exit',
emptyButtonState: 'Empty state — drop shapes or images from the sidebar.',
buttonModeStates: 'Edit button states (normal / hover / pressed / disabled)',
buttonModeStatesShort: 'States',
buttonModeHit: 'Edit the click hit area',
buttonModeHitShort: 'Hit area',
hitAreaHeader: 'Hit area',
hitAreaHint: 'Click the canvas tools to add shapes',
hitAreaEmpty: 'No hit shapes yet — add a rect or circle from the canvas.',
hitTrackDelete: 'Delete hit shape',
addKeyframeTooltip: 'Add keyframe for the selected track at the playhead',
groupChildCount: '{n} tracks',
groupEmpty: 'Drop tracks here',
@@ -170,8 +204,13 @@ export const en = {
title: 'Rename group',
confirm: 'Apply',
},
renameScriptTrackDialog: {
title: 'Rename script track',
confirm: 'Apply',
},
ungroup: 'Ungroup',
renameGroup: 'Rename group',
renameScriptTrack: 'Rename script track',
resizeTrackColumn: 'Drag to resize track name column',
clipTrackName: 'Clip track',
toggleTimeUnit: 'Toggle time unit',
@@ -207,6 +246,7 @@ export const en = {
unlock: 'Unlock',
deleteLayer: 'Delete layer',
deleteClipTrack: 'Delete clip track',
deleteScriptTrack: 'Delete script track',
separate: 'Separate Position and Rotation',
rotation: 'Rotation',
position: 'Position',
@@ -227,9 +267,17 @@ export const en = {
cropOff: 'Show everything',
maskPreviewOn: 'Preview masks (render view)',
maskPreviewOff: 'Back to edit view',
stagePreviewOn: 'Turn stage preview on — buttons become interactive',
stagePreviewOff: 'Turn stage preview off — buttons behave as normal assets',
vertexCorner: 'Corner',
vertexCurve: 'Curve (smooth)',
vertexSplitCurve: 'Split curve',
hit: {
addRect: 'Add rect hit area',
addCircle: 'Add circle hit area',
addPolygon: 'Add polygon hit area',
remove: 'Remove selected hit area',
},
},
settings: {
+207 -8
View File
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '7'
const RUNTIME_VERSION = '10'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -197,9 +197,109 @@ const ENGINE_SOURCE = `(function(){
jumpTo: null,
forcePause: false,
forcePlay: false,
buttonStates: {},
buttonDisabled: {},
warn: function(m){ if(window.console)window.console.warn('[animatr]',m); }
};
(scene.markers||[]).forEach(function(mk){ runtime.markers[mk.name]=mk.time; });
// Populated once per draw(); used for pointer hit-testing.
var buttonInstancesInFrame=[];
function resolveButtonState(layer){
var ov=runtime.buttonDisabled[layer.id];
var dis=ov===undefined?!!layer.buttonDisabled:ov;
if(dis) return 'disabled';
return runtime.buttonStates[layer.id] || 'normal';
}
function setButtonStateRt(layerId, st){
if(st==='normal') delete runtime.buttonStates[layerId];
else runtime.buttonStates[layerId]=st;
}
function pointInPolygon(px, py, pts){
var inside=false, n=pts.length;
for(var i=0,j=n-1;i<n;j=i++){
var xi=pts[i].x, yi=pts[i].y, xj=pts[j].x, yj=pts[j].y;
var intersect=((yi>py)!==(yj>py)) &&
(px<(xj-xi)*(py-yi)/((yj-yi)||1e-12)+xi);
if(intersect) inside=!inside;
}
return inside;
}
function hitTestShapes(x, y, shapes, w, h){
if(!shapes || !shapes.length){
return x>=0 && y>=0 && x<=w && y<=h;
}
for(var i=0;i<shapes.length;i++){
var s=shapes[i];
if(s.kind==='rect'){
if(x>=s.x && x<=s.x+s.width && y>=s.y && y<=s.y+s.height) return true;
} else if(s.kind==='circle'){
var dx=x-s.x, dy=y-s.y;
if(dx*dx+dy*dy <= s.radius*s.radius) return true;
} else if(s.kind==='polygon'){
if(s.points && s.points.length>=3 && pointInPolygon(x,y,s.points)) return true;
}
}
return false;
}
function pointerToCanvasPx(e){
var r=canvas.getBoundingClientRect();
if(!r.width || !r.height) return null;
return {
x:(e.clientX-r.left)*(canvas.width/r.width),
y:(e.clientY-r.top)*(canvas.height/r.height),
};
}
function buttonUnderPointer(dev){
for(var i=buttonInstancesInFrame.length-1;i>=0;i--){
var bi=buttonInstancesInFrame[i];
if(bi.layer.buttonDisabled && runtime.buttonDisabled[bi.layer.id]===undefined) continue;
if(runtime.buttonDisabled[bi.layer.id]===true) continue;
var inv=new DOMMatrix([bi.matrix.a,bi.matrix.b,bi.matrix.c,bi.matrix.d,bi.matrix.e,bi.matrix.f]).invertSelf();
var pt=inv.transformPoint(new DOMPoint(dev.x, dev.y));
if(hitTestShapes(pt.x, pt.y, bi.asset.hitArea, bi.asset.canvas.width, bi.asset.canvas.height)) return bi;
}
return null;
}
function onStageMove(e){
var dev=pointerToCanvasPx(e); if(!dev) return;
var hit=buttonUnderPointer(dev);
for(var i=0;i<buttonInstancesInFrame.length;i++){
var bi=buttonInstancesInFrame[i];
if(resolveButtonState(bi.layer)==='disabled') continue;
var cur=resolveButtonState(bi.layer);
if(hit && hit.layer.id===bi.layer.id){
if(cur==='normal') setButtonStateRt(bi.layer.id,'hover');
} else {
if(cur==='hover' || cur==='pressed') setButtonStateRt(bi.layer.id,'normal');
}
}
}
function onStageDown(e){
var dev=pointerToCanvasPx(e); if(!dev) return;
var hit=buttonUnderPointer(dev);
if(!hit) return;
if(resolveButtonState(hit.layer)==='disabled') return;
setButtonStateRt(hit.layer.id, 'pressed');
}
function onStageUp(e){
var dev=pointerToCanvasPx(e); if(!dev) return;
for(var i=0;i<buttonInstancesInFrame.length;i++){
var bi=buttonInstancesInFrame[i];
if(resolveButtonState(bi.layer)!=='pressed') continue;
var inv=new DOMMatrix([bi.matrix.a,bi.matrix.b,bi.matrix.c,bi.matrix.d,bi.matrix.e,bi.matrix.f]).invertSelf();
var pt=inv.transformPoint(new DOMPoint(dev.x, dev.y));
var inside=hitTestShapes(pt.x, pt.y, bi.asset.hitArea, bi.asset.canvas.width, bi.asset.canvas.height);
setButtonStateRt(bi.layer.id, inside?'hover':'normal');
if(inside){
var acts=bi.layer.onClick||[];
for(var ai=0;ai<acts.length;ai++) evalAction(acts[ai]);
}
}
}
canvas.addEventListener('mousemove', onStageMove);
canvas.addEventListener('mousedown', onStageDown);
canvas.addEventListener('mouseup', onStageUp);
function parseScriptSource(source){
var outActs=[];
var outErrs=[];
@@ -284,11 +384,16 @@ const ENGINE_SOURCE = `(function(){
var currentT=rangeIn;
var paused=false;
var lastNow=0;
var rafId=0;
var onFrame=opts.onFrame||null;
var destroyed=false;
function frame(now){
if(destroyed) return;
if(rangeLen<=0){
draw(currentT);
syncBgVideo(currentT);
requestAnimationFrame(frame);
if(onFrame) onFrame(currentT, paused);
rafId=requestAnimationFrame(frame);
return;
}
if(!lastNow) lastNow=now;
@@ -304,13 +409,14 @@ const ENGINE_SOURCE = `(function(){
currentT=nextT;
fireFrames(prev, currentT);
}
if(runtime.jumpTo!=null){ currentT=runtime.jumpTo; runtime.jumpTo=null; }
if(runtime.forcePause){ paused=true; runtime.forcePause=false; }
if(runtime.forcePlay){ paused=false; runtime.forcePlay=false; }
}
if(runtime.jumpTo!=null){ currentT=runtime.jumpTo; runtime.jumpTo=null; }
if(runtime.forcePause){ paused=true; runtime.forcePause=false; }
if(runtime.forcePlay){ paused=false; runtime.forcePlay=false; lastNow=0; }
draw(currentT);
syncBgVideo(currentT);
requestAnimationFrame(frame);
if(onFrame) onFrame(currentT, paused);
rafId=requestAnimationFrame(frame);
}
function tracePolyPath(tctx,sp,ap,offX,offY,closed){
var n=sp.length;
@@ -342,6 +448,7 @@ const ENGINE_SOURCE = `(function(){
var asset=assetMap[layer.assetId];
if(!asset) return;
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text' && asset.kind!=='polygon') return;
if(!layerActiveAt(layer,time)) return;
var p=sample(layer,time);
ctxm.save();
ctxm.translate(p.x,p.y);
@@ -376,9 +483,22 @@ const ENGINE_SOURCE = `(function(){
}
ctxm.restore();
}
function drawLayer(layer,time){
function layerActiveAt(layer,time){
var main=layer.keyframes||[];
var pos=layer.positionKeyframes||[];
var rot=layer.rotationKeyframes||[];
var total=main.length+pos.length+rot.length;
if(total<=1) return true;
var mn=Infinity, mx=-Infinity;
function scan(arr){ for(var i=0;i<arr.length;i++){ var t=arr[i].time; if(t<mn) mn=t; if(t>mx) mx=t; } }
scan(main); scan(pos); scan(rot);
var EPS=1e-6;
return time>=mn-EPS && time<=mx+EPS;
}
function drawLayer(layer,time,alwaysActive){
var asset=assetMap[layer.assetId];
if(!asset||!layer.visible)return;
if(!alwaysActive&&!layerActiveAt(layer,time))return;
var p=sample(layer,time);
ctx.save();
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
@@ -483,6 +603,23 @@ const ENGINE_SOURCE = `(function(){
var iOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
ctx.drawImage(img,-iOffX,-iOffY,asset.width,asset.height);
}
}else if(asset.kind==='button'){
var bCanvas=asset.canvas||{width:0,height:0};
var bOffX=layer.offsetX!=null?layer.offsetX:bCanvas.width/2;
var bOffY=layer.offsetY!=null?layer.offsetY:bCanvas.height/2;
ctx.translate(-bOffX,-bOffY);
var bm=ctx.getTransform();
buttonInstancesInFrame.push({
layer:layer,
asset:asset,
matrix:{a:bm.a,b:bm.b,c:bm.c,d:bm.d,e:bm.e,f:bm.f}
});
var bState=resolveButtonState(layer);
var stateTime=bState==='hover'?2:(bState==='pressed'?3:(bState==='disabled'?4:1));
var bLayers=asset.layers||[];
for(var bli=bLayers.length-1;bli>=0;bli--){
drawLayer(bLayers[bli], stateTime, true);
}
}
ctx.shadowColor='transparent';
ctx.shadowBlur=0;
@@ -494,6 +631,7 @@ const ENGINE_SOURCE = `(function(){
var lw=scene.canvas.width,lh=scene.canvas.height;
var cw=size.w,ch=size.h;
if(!cw||!ch)return;
buttonInstancesInFrame.length=0;
var s=Math.min(cw/lw,ch/lh);
var ox=(cw-lw*s)/2;
var oy=(ch-lh*s)/2;
@@ -613,8 +751,28 @@ const ENGINE_SOURCE = `(function(){
}
loadImages(scene.assets).then(function(loaded){
imgs=loaded;
requestAnimationFrame(frame);
rafId=requestAnimationFrame(frame);
});
return {
play: function(){ paused=false; lastNow=0; },
pause: function(){ paused=true; },
toggle: function(){ if(paused){ paused=false; lastNow=0; } else paused=true; },
isPlaying: function(){ return !paused; },
seek: function(t){ currentT=Math.max(rangeIn,Math.min(rangeOut,t)); lastNow=0; },
getTime: function(){ return currentT; },
getRangeIn: function(){ return rangeIn; },
getRangeOut: function(){ return rangeOut; },
destroy: function(){
destroyed=true;
cancelAnimationFrame(rafId);
canvas.removeEventListener('mousemove', onStageMove);
canvas.removeEventListener('mousedown', onStageDown);
canvas.removeEventListener('mouseup', onStageUp);
ro.disconnect();
canvas.remove();
if(bgVideo) bgVideo.remove();
}
};
}
var api={ __v: '${RUNTIME_VERSION}', mount: mount };
@@ -647,6 +805,47 @@ const SCENE_BOOT_SOURCE = `(function(){
})();
`
// ---- In-app mount: eval the engine once, return the same player the export uses ----
export interface PlayerHandle {
play(): void
pause(): void
toggle(): void
isPlaying(): boolean
seek(t: number): void
getTime(): number
getRangeIn(): number
getRangeOut(): number
destroy(): void
}
export interface MountOptions {
loop?: boolean
onFrame?: (time: number, paused: boolean) => void
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let cachedApi: any = null
function ensureEngine() {
if (cachedApi) return
// eslint-disable-next-line no-eval
;(0, eval)(ENGINE_SOURCE)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cachedApi = (window as any).AnimatrPlayer
}
export function mountPlayer(
container: HTMLElement,
scene: Scene,
opts?: MountOptions,
): PlayerHandle {
ensureEngine()
return cachedApi.mount(container, JSON.parse(JSON.stringify(scene)), opts)
}
// ---- Export helpers ----
function jsonForExport(scene: Scene): string {
// Data-URL background videos are stripped from the export — local video
// files can't be embedded reasonably. The user needs to host the media
+33
View File
@@ -128,6 +128,39 @@ function sampleKeyframeSet(
return { ...base }
}
/**
* Union range of all keyframe times on a layer (main + position + rotation
* tracks). A layer is considered "alive" only within this range.
*
* Returns null (→ always visible) when the layer has zero or one total
* keyframes — a single keyframe is a static layer with no animation range.
*/
export function layerActiveRange(
layer: Layer,
): { min: number; max: number } | null {
const main = layer.keyframes || []
const pos = layer.positionKeyframes || []
const rot = layer.rotationKeyframes || []
const total = main.length + pos.length + rot.length
if (total <= 1) return null
let min = Infinity
let max = -Infinity
for (const arr of [main, pos, rot]) {
for (const k of arr) {
if (k.time < min) min = k.time
if (k.time > max) max = k.time
}
}
return { min, max }
}
export function isLayerActiveAt(layer: Layer, time: number): boolean {
const range = layerActiveRange(layer)
if (!range) return true
const EPSILON = 1e-6
return time >= range.min - EPSILON && time <= range.max + EPSILON
}
export function sampleLayer(layer: Layer, time: number): AnimatableProps {
const main = sampleKeyframeSet(layer.keyframes, layer.base, time)
if (!layer.separated) return main
+44 -8
View File
@@ -1,5 +1,6 @@
import type { AnimatableProps, Asset, Layer, Scene } from '@/types/scene'
import { sampleLayer } from './interpolate'
import type { AnimatableProps, Asset, ButtonAsset, ButtonState, Layer, Scene } from '@/types/scene'
import { BUTTON_STATE_TIMES } from '@/types/scene'
import { isLayerActiveAt, sampleLayer } from './interpolate'
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
const result: Record<string, HTMLImageElement> = {}
@@ -129,10 +130,13 @@ function drawLayers(
assetMap: Map<string, Asset>,
images: Record<string, HTMLImageElement>,
time: number,
buttonStates?: Map<string, ButtonState>,
alwaysActive?: boolean,
) {
for (let i = layers.length - 1; i >= 0; i--) {
const layer = layers[i]
if (!layer.visible) continue
if (!alwaysActive && !isLayerActiveAt(layer, time)) continue
const asset = assetMap.get(layer.assetId)
if (!asset) continue
const p = sampleLayer(layer, time)
@@ -232,6 +236,12 @@ function drawLayers(
const offY = layer.offsetY ?? asset.height / 2
ctx.drawImage(img, -offX, -offY, asset.width, asset.height)
}
} else if (asset.kind === 'button') {
const offX = layer.offsetX ?? asset.canvas.width / 2
const offY = layer.offsetY ?? asset.canvas.height / 2
ctx.translate(-offX, -offY)
const bs = buttonStates?.get(layer.id) ?? 'normal'
drawLayers(ctx, asset.layers, assetMap, images, BUTTON_STATE_TIMES[bs], undefined, true)
}
clearShadow(ctx)
@@ -254,6 +264,7 @@ function drawMaskShape(
asset.kind !== 'polygon'
)
return
if (!isLayerActiveAt(layer, time)) return
const p = sampleLayer(layer, time)
ctx.save()
ctx.translate(p.x, p.y)
@@ -295,11 +306,12 @@ function drawMaskedGroup(
assetMap: Map<string, Asset>,
images: Record<string, HTMLImageElement>,
time: number,
buttonStates?: Map<string, ButtonState>,
) {
const masks = children.filter((l) => l.isMask)
const content = children.filter((l) => !l.isMask)
if (masks.length === 0) {
drawLayers(ctx, children, assetMap, images, time)
drawLayers(ctx, children, assetMap, images, time, buttonStates)
return
}
@@ -310,12 +322,12 @@ function drawMaskedGroup(
off.height = h
const offCtx = off.getContext('2d')
if (!offCtx) {
drawLayers(ctx, content, assetMap, images, time)
drawLayers(ctx, content, assetMap, images, time, buttonStates)
return
}
const m = ctx.getTransform()
offCtx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f)
drawLayers(offCtx, content, assetMap, images, time)
drawLayers(offCtx, content, assetMap, images, time, buttonStates)
for (const mask of masks) {
offCtx.save()
offCtx.globalCompositeOperation = mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
@@ -333,11 +345,12 @@ export function drawSceneFrame(
scene: Scene,
time: number,
images: Record<string, HTMLImageElement>,
options?: { crop?: boolean },
options?: { crop?: boolean; buttonStates?: Map<string, ButtonState> },
) {
const lw = scene.canvas.width
const lh = scene.canvas.height
const crop = options?.crop !== false
const buttonStates = options?.buttonStates
ctx.save()
if (crop) {
@@ -410,7 +423,7 @@ export function drawSceneFrame(
drawMaskShape(drawCtx, layer, assetMap, time)
drawCtx.restore()
} else {
drawLayers(drawCtx, [layer], assetMap, images, time)
drawLayers(drawCtx, [layer], assetMap, images, time, buttonStates)
}
continue
}
@@ -435,7 +448,7 @@ export function drawSceneFrame(
const cl = layerById.get(group.childIds[k])
if (cl) childLayers.push(cl)
}
drawMaskedGroup(drawCtx, childLayers, assetMap, images, time)
drawMaskedGroup(drawCtx, childLayers, assetMap, images, time, buttonStates)
}
}
@@ -448,3 +461,26 @@ export function drawSceneFrame(
ctx.restore()
}
export function renderButtonThumbnail(
button: ButtonAsset,
scene: Scene,
maxSize = 80,
images: Record<string, HTMLImageElement> = {},
): string | null {
const { width: bw, height: bh } = button.canvas
if (bw <= 0 || bh <= 0 || !button.layers.length) return null
const scale = Math.min(maxSize / bw, maxSize / bh, 1)
const cw = Math.round(bw * scale)
const ch = Math.round(bh * scale)
const canvas = document.createElement('canvas')
canvas.width = cw
canvas.height = ch
const ctx = canvas.getContext('2d')
if (!ctx) return null
ctx.scale(scale, scale)
const assetMap = new Map<string, Asset>()
for (const a of scene.assets) assetMap.set(a.id, a)
drawLayers(ctx, button.layers, assetMap, images, BUTTON_STATE_TIMES.normal, undefined, true)
return canvas.toDataURL('image/png')
}
+189 -6
View File
@@ -4,10 +4,13 @@ import type {
AnimatableProps,
Asset,
BackgroundVideo,
ButtonAsset,
ButtonState,
Clip,
ClipInstance,
ClipTrack,
Easing,
HitShape,
Keyframe,
Layer,
Marker,
@@ -17,7 +20,7 @@ import type {
ScriptTrack,
TrackGroup,
} from '@/types/scene'
import { DEFAULT_BASE_PROPS } from '@/types/scene'
import { BUTTON_STATE_TIMES, DEFAULT_BASE_PROPS } from '@/types/scene'
import { sampleLayer } from '@/lib/interpolate'
import {
buildMarkerMap,
@@ -32,12 +35,24 @@ type AssetInit = Asset extends infer A ? (A extends Asset ? Omit<A, 'id'> : neve
const KEYFRAME_EPSILON = 1e-3
/** The button editor has two orthogonal sub-modes: state editing (4 content
* states as kf-time slots) vs hit-area editing (hit shapes only, with normal
* state as onion-skin). Switching modes keeps each mode's last selection. */
export type ButtonEditMode = 'states' | 'hit'
interface State {
scene: Scene
selectedLayerId: string | null
playhead: number
playing: boolean
activeClipId: string | null
/** When set, the Stage + Timeline are inside a Button-asset edit session. */
activeButtonAssetId: string | null
/** 'states' drives the state scrubber; 'hit' swaps to hit-shape editing. */
activeButtonEditMode: ButtonEditMode | null
/** Which of the 4 content states is currently active. Only meaningful while
* `activeButtonEditMode === 'states'`; preserved across mode toggles. */
activeButtonState: ButtonState | null
drawingMode: 'freeform' | null
drawingAssetId: string | null
drawingLayerId: string | null
@@ -117,6 +132,23 @@ function hasKeys(obj: Partial<AnimatableProps>): boolean {
return false
}
/**
* Scan every container a layer can live in — scene-level, clip layers,
* button-asset state stacks — and return the layer with the matching id.
* Used by mutation helpers that don't know the caller's edit scope.
*/
function findLayerAnywhere(scene: Scene, layerId: string): Layer | null {
for (const l of scene.layers) if (l.id === layerId) return l
for (const clip of scene.clips ?? []) {
for (const l of clip.layers) if (l.id === layerId) return l
}
for (const asset of scene.assets) {
if (asset.kind !== 'button') continue
for (const l of asset.layers) if (l.id === layerId) return l
}
return null
}
function normalizeScene(scene: Scene): Scene {
scene.clips ||= []
scene.clipTracks ||= []
@@ -131,6 +163,7 @@ function normalizeScene(scene: Scene): Scene {
for (const l of scene.layers) known.add(l.id)
for (const t of scene.clipTracks) known.add(t.id)
for (const g of scene.groups) known.add(g.id)
for (const st of scene.scriptTracks) known.add(st.id)
// Cleanup: group childIds should only reference existing layers/clipTracks
const layerAndClipIds = new Set<string>()
@@ -165,6 +198,9 @@ function normalizeScene(scene: Scene): Scene {
for (const g of scene.groups) {
if (!scene.trackOrder.includes(g.id)) scene.trackOrder.push(g.id)
}
for (const st of scene.scriptTracks) {
if (!scene.trackOrder.includes(st.id)) scene.trackOrder.push(st.id)
}
return scene
}
@@ -187,6 +223,9 @@ export const useSceneStore = defineStore('scene', {
playhead: 0,
playing: false,
activeClipId: null,
activeButtonAssetId: null,
activeButtonEditMode: null,
activeButtonState: null,
drawingMode: null,
drawingAssetId: null,
drawingLayerId: null,
@@ -197,7 +236,19 @@ export const useSceneStore = defineStore('scene', {
const clips = state.scene.clips ?? []
return clips.find((c) => c.id === state.activeClipId) ?? null
},
activeButtonAsset(state): ButtonAsset | null {
if (!state.activeButtonAssetId) return null
const asset = state.scene.assets.find((a) => a.id === state.activeButtonAssetId)
return asset && asset.kind === 'button' ? asset : null
},
activeLayers(): Layer[] {
const bAsset = this.activeButtonAsset
if (bAsset) {
// Hit mode shares the same layer list — the canvas switches to
// hit-shape editing but tracks stay on the button's layers so the
// user can still see/select them.
return bAsset.layers
}
const clip = this.activeClip
return clip ? clip.layers : this.scene.layers
},
@@ -257,14 +308,24 @@ export const useSceneStore = defineStore('scene', {
this.scene.assets.push(created)
return created
},
addLayer(assetId: string, name?: string): Layer {
addLayer(assetId: string, name?: string): Layer | null {
// Hit-area edit has no content-layer container — new layers from
// the sidebar/drop path would silently leak into the shared states
// list. Reject here so hit-mode truly owns its own (hit-shape) track
// list and nothing bleeds across.
if (this.activeButtonEditMode === 'hit') return null
const asset = this.scene.assets.find((a) => a.id === assetId)
// Default position = center of the active canvas. In button-asset
// edit that's the button's smaller internal canvas, so the new
// layer lands inside the visible (and unclipped) area.
const canvas = this.activeButtonAsset?.canvas ?? this.scene.canvas
const base: AnimatableProps = {
...DEFAULT_BASE_PROPS,
x: this.scene.canvas.width / 2,
y: this.scene.canvas.height / 2,
x: canvas.width / 2,
y: canvas.height / 2,
}
if (asset && 'fill' in asset) base.fill = asset.fill
const initialTime = this.activeButtonAssetId ? BUTTON_STATE_TIMES.normal : 0
const layer: Layer = {
id: uid(),
name: name ?? asset?.name ?? 'Layer',
@@ -274,7 +335,7 @@ export const useSceneStore = defineStore('scene', {
keyframes: [
{
id: uid(),
time: 0,
time: initialTime,
props: { ...base },
easing: 'linear',
},
@@ -287,6 +348,10 @@ export const useSceneStore = defineStore('scene', {
} else if (asset.kind === 'circle' || asset.kind === 'text' || asset.kind === 'polygon') {
layer.offsetX = 0
layer.offsetY = 0
} else if (asset.kind === 'button') {
// Place the button centered on (x, y) using Konva's offset mechanism.
layer.offsetX = asset.canvas.width / 2
layer.offsetY = asset.canvas.height / 2
}
if (asset.kind === 'polygon') {
layer.keyframes[0].props = {
@@ -296,7 +361,9 @@ export const useSceneStore = defineStore('scene', {
}
}
this.activeLayers.unshift(layer)
if (!this.activeClipId) {
// Only top-level scene layers appear in trackOrder. Clip-edit and
// button-asset-edit layers live inside their own containers.
if (!this.activeClipId && !this.activeButtonAssetId) {
const order = (this.scene.trackOrder ||= [])
if (!order.includes(layer.id)) order.unshift(layer.id)
}
@@ -1136,6 +1203,116 @@ export const useSceneStore = defineStore('scene', {
this.scene.scriptFrames?.sort((a, b) => a.time - b.time)
},
// -------- Button assets --------------------------------------------
addButtonAsset(name?: string, width = 200, height = 80): ButtonAsset {
const asset: ButtonAsset = {
id: uid(),
kind: 'button',
name: name?.trim() || 'Button',
canvas: {
width: Math.max(1, Math.round(width)),
height: Math.max(1, Math.round(height)),
},
layers: [],
hitArea: [],
}
this.scene.assets.push(asset)
return asset
},
setButtonAssetCanvas(assetId: string, width: number, height: number) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'button') return
asset.canvas = {
width: Math.max(1, Math.round(width)),
height: Math.max(1, Math.round(height)),
}
},
enterButtonAssetEdit(assetId: string) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'button') return
this.activeButtonAssetId = assetId
this.activeButtonEditMode = 'states'
this.activeButtonState = 'normal'
this.activeClipId = null
this.selectedLayerId = null
this.playhead = BUTTON_STATE_TIMES.normal
this.playing = false
},
setButtonEditMode(mode: ButtonEditMode) {
if (!this.activeButtonAssetId) return
this.activeButtonEditMode = mode
this.selectedLayerId = null
if (mode === 'states') {
// Landing back in states-mode: keep the last content state (or
// default to normal) and sync the playhead to its kf-time.
const st = this.activeButtonState ?? 'normal'
this.activeButtonState = st
this.playhead = BUTTON_STATE_TIMES[st]
} else {
// Hit mode onion-skins normal, so park the playhead at normal's
// time. No mutations can happen here — layers are inert.
this.playhead = BUTTON_STATE_TIMES.normal
}
},
setActiveButtonState(state: ButtonState) {
if (!this.activeButtonAssetId) return
// Scrubbing a state implies the user is in states-mode.
this.activeButtonEditMode = 'states'
this.activeButtonState = state
// States ARE keyframes — scrubbing moves the playhead to that
// state's fixed time so existing kf mutation paths target it.
this.playhead = BUTTON_STATE_TIMES[state]
this.selectedLayerId = null
},
exitButtonAssetEdit() {
this.activeButtonAssetId = null
this.activeButtonEditMode = null
this.activeButtonState = null
this.selectedLayerId = null
this.playhead = 0
this.playing = false
},
addButtonHitShape(assetId: string, shape: HitShape): HitShape | null {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'button') return null
asset.hitArea.push(shape)
return shape
},
removeButtonHitShape(assetId: string, index: number) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'button') return
if (index < 0 || index >= asset.hitArea.length) return
asset.hitArea.splice(index, 1)
},
updateButtonHitShape(
assetId: string,
index: number,
patch: Partial<HitShape>,
) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'button') return
const current = asset.hitArea[index]
if (!current) return
// Preserve kind — patch can only override numeric fields.
asset.hitArea[index] = { ...current, ...patch } as HitShape
},
// -------- Button layer per-instance (on click / disabled) ----------
setLayerOnClick(layerId: string, actions: Action[]) {
const layer = findLayerAnywhere(this.scene, layerId)
if (!layer) return
if (actions.length) layer.onClick = actions.map((a) => ({ ...a }))
else delete layer.onClick
},
setLayerButtonDisabled(layerId: string, disabled: boolean) {
const layer = findLayerAnywhere(this.scene, layerId)
if (!layer) return
if (disabled) layer.buttonDisabled = true
else delete layer.buttonDisabled
},
setLayerSeparated(layerId: string, separated: boolean) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
@@ -1208,6 +1385,9 @@ export const useSceneStore = defineStore('scene', {
this.playhead = 0
this.playing = false
this.activeClipId = null
this.activeButtonAssetId = null
this.activeButtonEditMode = null
this.activeButtonState = null
},
addClip(name?: string): Clip {
const clip: Clip = {
@@ -1370,6 +1550,9 @@ export const useSceneStore = defineStore('scene', {
const clip = this.scene.clips?.find((c) => c.id === id)
if (!clip) return
this.activeClipId = id
this.activeButtonAssetId = null
this.activeButtonEditMode = null
this.activeButtonState = null
this.selectedLayerId = null
this.playhead = 0
this.playing = false
+47 -2
View File
@@ -43,7 +43,32 @@ export interface Keyframe {
ctrlY?: number
}
export type AssetKind = 'image' | 'rect' | 'circle' | 'text' | 'polygon'
export type AssetKind = 'image' | 'rect' | 'circle' | 'text' | 'polygon' | 'button'
export type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled'
/**
* Inside a button asset, state differences are expressed as keyframes on
* shared layers at these fixed "state times". One layer stack covers all
* four states; a keyframe at `BUTTON_STATE_TIMES.hover` mutates that layer
* in hover, etc. Hit has no content layers — it's geometry only.
*/
export const BUTTON_STATE_TIMES: Record<ButtonState, number> = {
normal: 1,
hover: 2,
pressed: 3,
disabled: 4,
}
/**
* A hit-area is a union of simple shapes in the button's internal canvas
* coordinate space. Constant across the four visual states so the
* interactive boundary doesn't flicker. Polygons are treated as closed.
*/
export type HitShape =
| { kind: 'rect'; x: number; y: number; width: number; height: number }
| { kind: 'circle'; x: number; y: number; radius: number }
| { kind: 'polygon'; points: { x: number; y: number }[] }
export interface ImageAsset {
id: string
@@ -100,7 +125,23 @@ export interface PolygonAsset {
strokeWidth?: number
}
export type Asset = ImageAsset | RectAsset | CircleAsset | TextAsset | PolygonAsset
export interface ButtonAsset {
id: string
kind: 'button'
name: string
/** Button-internal canvas size. User-settable, adjustable in edit mode. */
canvas: { width: number; height: number }
/**
* Shared layer stack. Each layer is present in all four content states;
* per-state mutation is expressed via keyframes at `BUTTON_STATE_TIMES`.
* No keyframes → layer renders identically in every state.
*/
layers: Layer[]
/** Hit geometry in the button's own canvas coord space. Union semantics. */
hitArea: HitShape[]
}
export type Asset = ImageAsset | RectAsset | CircleAsset | TextAsset | PolygonAsset | ButtonAsset
export type MaskMode = 'positive' | 'negative'
@@ -120,6 +161,10 @@ export interface Layer {
locked?: boolean
isMask?: boolean
maskMode?: MaskMode
/** Only meaningful when `assetId` points to a ButtonAsset: fires on click. */
onClick?: Action[]
/** Only meaningful for button layers: initial disabled flag at runtime. */
buttonDisabled?: boolean
}
export interface Clip {
+68 -1
View File
@@ -64,4 +64,71 @@ context menüs ragen unten immernoch aus dem viewport raus
rechts click menu muss überall unterdrückt werden wo wir noch keins haben. das browser context menu darf nirgends angezeigt werden
maskenlayer sind tracks mit einem pfad-objekt (circle, rect, text) alles unter einem masken track wird davon maskiert. allerdings immer nur in einer gruppe. elemente außerhalb der gruppe mit der maske werden normal dargestellt. es kann mehrere masken tracks geben. ein maskentrack kann entweder positiv oder negativ maskieren. ein maskenlayer mus zwei ansichten haben: normale elemente (damit man sie zieht zum bearbeiten) und masking (damit sie maskieren)
maskenlayer sind tracks mit einem pfad-objekt (circle, rect, text) alles unter einem masken track wird davon maskiert. allerdings immer nur in einer gruppe. elemente außerhalb der gruppe mit der maske werden normal dargestellt. es kann mehrere masken tracks geben. ein maskentrack kann entweder positiv oder negativ maskieren. ein maskenlayer mus zwei ansichten haben: normale elemente (damit man sie zieht zum bearbeiten) und masking (damit sie maskieren)
tests tests tests ;)
tasten i und o setzen die brackets. die brackets müssen orange. wenn der playhead auf der bracket ist hat der playhead vorrang beim draggen. heißt wenn die maus über dem playhead ist bewegt man den egal ob da auch eine klammer ist. klammern und playhead müssen weiß werden bei hover (bzw. schwarz in light theme)
bei langer timeline (wenn man hin und her scrollen kann) liegen playhead und needle sowie die klammern über der track-label-spalte. die track labels müssen z-index ganz nach oben - durekt unter sämtlichen popups etc.
kann der animationspfad sich live mit ändern wenn man das objekt bewegt?
wir bauen einen button schritt für schritt:
zuerst nehmen wir das konzept der clips. ein clip kann ja in der timeline frei positioniert werden.
ein button kann das auch, nur das er grundsätzlich jede beliebige länge auf der zeitachse haben kann.
er ist ein loop-objekt. dadurch loopt er seine zeitachse auch durch wenn die haupt zeitliste auf pause steht.
von seinem mouse-hover state hängt ab, wencher zusätzliche track on top sichtbar wird.
der grundloop läuft immer, aber
die states sind verschiedene zeitleisten innerhalb des buttons die umgeschaltet werden
wir machen 2 arten von buttons. damit wir erstmal weiter kommen zuerst das sehr simple konzept des 5 frame buttons.
normal / hover / pressed / disabled / hitarea
die zeitachse sieht bei diesem objekt etwas anders aus als eine normale zeitleiste: es gibt nur diese 5 frames (das kann man dann auch breiter darstellen)
im ersten frame platziert man die grundgrafik und mutiert die in den 4 weiteren
das hitarea ist nur ein rect oder circle auf den die maus reagiert. das bauen wir erstmal so.
layer kann man prinzipiell auch hier so viele haben wie man will, die müssen aber nicht zwingend in allen 4 grafik-frames auch was sichtbares beinhalten.
hast du das verstanden? gibts fragen?
die tracklisten-spalte muss genauso breit wie die assets spalte darüber. .name-spacer in.scrub-bar muss fixed sein und darf sich beim horizintalen scrollen der tracks nicht da wegbewegen
der button der aus der clip-editor ansicht raus führt sieht gut aus
<div data-v-7a7a37b1="" class="scope-footer"><button data-v-7a7a37b1="" class="scope-back"><svg data-v-7a7a37b1="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="12" height="12" fill="currentColor"><g><path d="M228,128a12,12,0,0,1-12,12H69l51.52,51.51a12,12,0,0,1-17,17l-72-72a12,12,0,0,1,0-17l72-72a12,12,0,0,1,17,17L69,116H216A12,12,0,0,1,228,128Z"></path></g></svg> Main timeline</button><span data-v-7a7a37b1="" class="scope-current" title="Clip"><svg data-v-7a7a37b1="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="12" height="12" fill="currentColor"><g><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM40,88h80v80H40Zm96-16V56h32V72Zm-16,0H88V56h32Zm0,112v16H88V184Zm16,0h32v16H136Zm0-16V88h80v80Zm80-96H184V56h32ZM72,56V72H40V56ZM40,184H72v16H40Zm176,16H184V184h32v16Z"></path></g></svg> Clip</span></div>
der button der aus der button ansicht raus führt soll genauso sein, das ist momentan ein kleiner exit button links neben dem state switch
also ich würde stark bevorzugen wenn du den code ließt statt immer den vite dev zu nehmen
ich kann das visuell kontrollieren und dir screenshots von den sachen machen. und der handle ist genau wie vorher
diese spur darf nur erscheinen wenn man überhaupt schon mindestens einen clip hat.
<div data-v-228c0a9d="" class="track clip-track-dropzone"><span data-v-228c0a9d="" class="dropzone-hint">Drop a clip here to add a new track</span></div>
und der text muss immer zentriert stehen bleiben und darf nicht horizontal mit der timeline mitgescrollt werden
im button editor zeigt das preview mit angewendeten masken die hauptbühne an - nicht die button-bühne
ich würde an dieser stelle mal ein paar regression-tests hinzufügen und dann committen