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

+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;