feat: 5-frame button assets + timeline/sidebar polish
Button assets
- Re-introduce button-asset type with 4 content states (normal/hover/
pressed/disabled) stored as keyframes at BUTTON_STATE_TIMES, plus a
separate hit-area editor mode.
- useButtonRuntime composable for per-instance state/disabled flags on
the main stage.
- Sidebar library lists button assets with live 80×80 thumbnails;
renderButtonThumbnail now accepts an images map so image-kind layers
inside a button render instead of producing a fully-transparent PNG.
- Compact (no-thumbnail) mode aligns button rows to the library-row
height.
Scope footer
- Button-edit mode now uses the same sidebar scope-footer pattern as
clip-edit (back button to "Main timeline" + active scope chip);
removed the inline exit button from the timeline scrub-bar.
Timeline
- Track-name column default bumped to 260px to match the assets sidebar
above it (storage key bumped to :v2 so stale values reset).
- scrub-bar z-index raised above sticky track-name columns so rows
scroll under it instead of over.
- scrub-handle flips z-index based on horizontal scroll: above
name-spacer when the playhead is in view (no half-clip at t=0),
below when the playhead has scrolled off the left edge (so the
toggle row cleanly covers it).
- name-spacer extends ±3/1px to fully cover the obscured handle.
- scrub-handle head pokes 3px above the ruler with rounded top corners
via clip-path path().
Track context menu
- Added missing i18n keys (renameScriptTrack / deleteScriptTrack /
renameScriptTrackDialog.{title,confirm}).
- Rename items show a visible pencil icon; empty .check slot removed
so items line up flush-left; toggle items place their active ✓ on
the right (kf-menu-trailing-check).
Dev HUD
- Test progress bar flips to --danger as soon as the first failure is
recorded, instead of only at the end of the run.
Regression tests
- renderButtonThumbnail: null on empty button, renders shape layers,
accepts custom images map.
- i18n: renameScriptTrack / deleteScriptTrack / dialog keys resolve.
- scrub-handle-obscured logic (visible at t=0, hidden when playhead is
scrolled off-screen, still visible when playhead is far to the
right of the scroll offset).
- Updated button state-scrubber expectations to match
BUTTON_STATE_TIMES (normal=1, hover=2, pressed=3, disabled=4) and
that hit-mode parks at normal=1 rather than 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
31604052ca
commit
c3764201b8
16 files changed
+2560
-349
No files matched your search
+11
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user