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
+2558
-347
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
|
||||
v-if="entry.item.asset.kind === '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-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-if="!inClipEdit"
|
||||
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 && !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: {
|
||||
|
||||
+206
-7
@@ -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.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 {
|
||||
|
||||
@@ -65,3 +65,70 @@ context menüs ragen unten immernoch aus dem viewport raus
|
||||
rechts click menu muss überall unterdrückt werden wo wir noch keins haben. das browser context menu darf nirgends angezeigt werden
|
||||
|
||||
maskenlayer sind tracks mit einem pfad-objekt (circle, rect, text) alles unter einem masken track wird davon maskiert. allerdings immer nur in einer gruppe. elemente außerhalb der gruppe mit der maske werden normal dargestellt. es kann mehrere masken tracks geben. ein maskentrack kann entweder positiv oder negativ maskieren. ein maskenlayer mus zwei ansichten haben: normale elemente (damit man sie zieht zum bearbeiten) und masking (damit sie maskieren)
|
||||
|
||||
|
||||
tests tests tests ;)
|
||||
|
||||
tasten i und o setzen die brackets. die brackets müssen orange. wenn der playhead auf der bracket ist hat der playhead vorrang beim draggen. heißt wenn die maus über dem playhead ist bewegt man den egal ob da auch eine klammer ist. klammern und playhead müssen weiß werden bei hover (bzw. schwarz in light theme)
|
||||
|
||||
bei langer timeline (wenn man hin und her scrollen kann) liegen playhead und needle sowie die klammern über der track-label-spalte. die track labels müssen z-index ganz nach oben - durekt unter sämtlichen popups etc.
|
||||
|
||||
kann der animationspfad sich live mit ändern wenn man das objekt bewegt?
|
||||
|
||||
|
||||
|
||||
wir bauen einen button schritt für schritt:
|
||||
zuerst nehmen wir das konzept der clips. ein clip kann ja in der timeline frei positioniert werden.
|
||||
ein button kann das auch, nur das er grundsätzlich jede beliebige länge auf der zeitachse haben kann.
|
||||
er ist ein loop-objekt. dadurch loopt er seine zeitachse auch durch wenn die haupt zeitliste auf pause steht.
|
||||
von seinem mouse-hover state hängt ab, wencher zusätzliche track on top sichtbar wird.
|
||||
der grundloop läuft immer, aber
|
||||
|
||||
die states sind verschiedene zeitleisten innerhalb des buttons die umgeschaltet werden
|
||||
|
||||
|
||||
|
||||
wir machen 2 arten von buttons. damit wir erstmal weiter kommen zuerst das sehr simple konzept des 5 frame buttons.
|
||||
normal / hover / pressed / disabled / hitarea
|
||||
die zeitachse sieht bei diesem objekt etwas anders aus als eine normale zeitleiste: es gibt nur diese 5 frames (das kann man dann auch breiter darstellen)
|
||||
im ersten frame platziert man die grundgrafik und mutiert die in den 4 weiteren
|
||||
das hitarea ist nur ein rect oder circle auf den die maus reagiert. das bauen wir erstmal so.
|
||||
layer kann man prinzipiell auch hier so viele haben wie man will, die müssen aber nicht zwingend in allen 4 grafik-frames auch was sichtbares beinhalten.
|
||||
hast du das verstanden? gibts fragen?
|
||||
|
||||
|
||||
|
||||
die tracklisten-spalte muss genauso breit wie die assets spalte darüber. .name-spacer in.scrub-bar muss fixed sein und darf sich beim horizintalen scrollen der tracks nicht da wegbewegen
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
der button der aus der clip-editor ansicht raus führt sieht gut aus
|
||||
<div data-v-7a7a37b1="" class="scope-footer"><button data-v-7a7a37b1="" class="scope-back"><svg data-v-7a7a37b1="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="12" height="12" fill="currentColor"><g><path d="M228,128a12,12,0,0,1-12,12H69l51.52,51.51a12,12,0,0,1-17,17l-72-72a12,12,0,0,1,0-17l72-72a12,12,0,0,1,17,17L69,116H216A12,12,0,0,1,228,128Z"></path></g></svg> Main timeline</button><span data-v-7a7a37b1="" class="scope-current" title="Clip"><svg data-v-7a7a37b1="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="12" height="12" fill="currentColor"><g><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM40,88h80v80H40Zm96-16V56h32V72Zm-16,0H88V56h32Zm0,112v16H88V184Zm16,0h32v16H136Zm0-16V88h80v80Zm80-96H184V56h32ZM72,56V72H40V56ZM40,184H72v16H40Zm176,16H184V184h32v16Z"></path></g></svg> Clip</span></div>
|
||||
der button der aus der button ansicht raus führt soll genauso sein, das ist momentan ein kleiner exit button links neben dem state switch
|
||||
|
||||
|
||||
also ich würde stark bevorzugen wenn du den code ließt statt immer den vite dev zu nehmen
|
||||
ich kann das visuell kontrollieren und dir screenshots von den sachen machen. und der handle ist genau wie vorher
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
diese spur darf nur erscheinen wenn man überhaupt schon mindestens einen clip hat.
|
||||
<div data-v-228c0a9d="" class="track clip-track-dropzone"><span data-v-228c0a9d="" class="dropzone-hint">Drop a clip here to add a new track</span></div>
|
||||
und der text muss immer zentriert stehen bleiben und darf nicht horizontal mit der timeline mitgescrollt werden
|
||||
|
||||
|
||||
im button editor zeigt das preview mit angewendeten masken die hauptbühne an - nicht die button-bühne
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
ich würde an dieser stelle mal ein paar regression-tests hinzufügen und dann committen
|
||||
Reference in new issue
Block a user