Files
RUWIO/src/components/CanvasStage.vue
T
2026-04-22 18:32:18 +02:00

3549 lines
109 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type Konva from 'konva'
import {
PhCircle,
PhCornersIn,
PhCrop,
PhCursorClick,
PhMaskHappy,
PhPencilSimple,
PhPolygon,
PhSquare,
PhTrash,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from '@/lib/interpolate'
import { importImageFile, loadImage } from '@/lib/fileLoader'
import { useClipartStore } from '@/stores/clipart'
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
import type {
Action,
AnimatableProps,
Asset,
ButtonAsset,
ButtonState,
ClipInstance,
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'
import { expandedTrackOrder } from '@/lib/sceneOrder'
const store = useSceneStore()
const clipart = useClipartStore()
const { selectedKfs } = useKfSelection()
const container = ref<HTMLDivElement | null>(null)
const containerSize = ref({ width: 0, height: 0 })
const stageRef = ref<{ getStage(): Konva.Stage } | null>(null)
const bgVideoEl = ref<HTMLVideoElement | null>(null)
const transformerRef = ref<{ getNode(): Konva.Transformer } | null>(null)
const zoom = ref(1)
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
const ZOOM_MIN = 0.05
const ZOOM_MAX = 20
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v))
}
function fitToContainer() {
const cw = containerSize.value.width
const ch = containerSize.value.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
viewX.value = (cw - lw * z) / 2
viewY.value = (ch - lh * z) / 2
}
function zoomToOne() {
const cw = containerSize.value.width
const ch = containerSize.value.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 buttonStackForTime(
asset: ButtonAsset,
stateTime: number,
): Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> {
const out: Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> = []
const layers = asset.layers.slice().reverse()
for (const l of layers) {
if (!l.visible) continue
// Button-state visibility: still-frame kfs render only on their
// own state, not held into neighbouring states.
if (!isButtonLayerActiveAt(l, stateTime)) continue
const a = assetMap.value.get(l.assetId)
if (!a) continue
out.push({ layer: l, asset: a, props: sampleLayer(l, stateTime) })
}
return out
}
function setStageCursor(cursor: string) {
const el = stageRef.value?.getStage().container()
if (el) el.style.cursor = cursor
}
function onButtonEnter(layer: Layer) {
// Pointer cursor whenever a button asset is under the mouse — it's
// interactive UX even in edit mode, where it's draggable like any
// other layer. In preview mode, also flip the state machine into
// `hover` so visual state changes fire.
if (!isLayerEffectivelyLocked(layer)) setStageCursor('pointer')
if (!stagePreview.value) return
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
if (cur === 'normal') buttonRt.setState(layer.id, 'hover')
}
function onButtonLeave(layer: Layer) {
setStageCursor('')
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 buttonTimeFor(layer: Layer): number {
// 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 BUTTON_STATE_TIMES.normal
return buttonRt.getEffectiveTime(layer.id, layer.buttonDisabled)
}
const stageConfig = computed(() => ({
width: containerSize.value.width,
height: containerSize.value.height,
}))
const viewConfig = computed(() => ({
x: viewX.value,
y: viewY.value,
scaleX: zoom.value,
scaleY: zoom.value,
}))
const clipGroupConfig = computed(() => {
const w = effectiveCanvas.value.width
const h = effectiveCanvas.value.height
const crop = cropObjects.value
return {
clipFunc: (ctx: CanvasRenderingContext2D) => {
if (crop) ctx.rect(0, 0, w, h)
else ctx.rect(-1e6, -1e6, 2e6, 2e6)
},
}
})
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
// video shows through.
const fill = store.scene.backgroundVideo
? 'transparent'
: store.scene.canvas.background || '#ffffff'
return {
x: 0,
y: 0,
width: store.scene.canvas.width,
height: store.scene.canvas.height,
listening: false,
fill,
}
})
const bgVideoStyle = computed(() => {
const w = store.scene.canvas.width * zoom.value
const h = store.scene.canvas.height * zoom.value
return {
left: `${viewX.value}px`,
top: `${viewY.value}px`,
width: `${w}px`,
height: `${h}px`,
}
})
// Playhead sync for the background video — only resync current time on bigger
// deltas so regular playback stays smooth.
const BG_VIDEO_SYNC_THRESHOLD = 0.12
watch(
() => store.playing,
async (playing) => {
const v = bgVideoEl.value
if (!v) return
if (playing) {
v.currentTime = store.playhead
try {
await v.play()
} catch {
/* autoplay might be blocked if the tab has no user gesture yet */
}
} else {
v.pause()
v.currentTime = store.playhead
}
},
)
watch(
() => store.playhead,
(t) => {
const v = bgVideoEl.value
if (!v) return
const diff = Math.abs(v.currentTime - t)
if (!store.playing) {
v.currentTime = t
return
}
if (diff > BG_VIDEO_SYNC_THRESHOLD) v.currentTime = t
},
)
// When the video element mounts (or the src changes) do an initial sync.
watch(
() => store.scene.backgroundVideo?.src,
async () => {
await nextTick()
const v = bgVideoEl.value
if (!v) return
v.currentTime = store.playhead
if (store.playing) {
try {
await v.play()
} catch {
/* ignored */
}
}
},
)
const canvasBorderConfig = computed(() => ({
x: 0,
y: 0,
width: effectiveCanvas.value.width,
height: effectiveCanvas.value.height,
stroke: '#4a4a56',
strokeWidth: 1 / zoom.value,
listening: false,
}))
const zoomPercent = computed(() => Math.round(zoom.value * 100))
const assetMap = computed(() => {
const m = new Map<string, Asset>()
for (const a of store.scene.assets) m.set(a.id, a)
return m
})
const imageCache = ref<Record<string, HTMLImageElement>>({})
watch(
() => store.scene.assets.map((a) => (a.kind === 'image' ? `${a.id}:${a.src.length}` : null)),
() => {
for (const a of store.scene.assets) {
if (a.kind !== 'image') continue
if (imageCache.value[a.id]) continue
loadImage(a.src)
.then((img) => {
imageCache.value = { ...imageCache.value, [a.id]: img }
})
.catch((err) => {
console.error('image load failed', err)
})
}
},
{ immediate: true, deep: true },
)
interface RenderedLayer {
layer: Layer
asset: Asset
props: AnimatableProps
}
interface RenderedClipLayer extends RenderedLayer {
instanceId: string
}
interface RenderedClipInstance {
instance: ClipInstance
items: RenderedClipLayer[]
}
type StackItem =
| { kind: 'layer'; item: RenderedLayer }
| { kind: 'clipInstance'; bundle: RenderedClipInstance }
function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps {
const live = liveLayerPose.value
if (!live || live.layerId !== layerId) return props
return {
...props,
x: live.x,
y: live.y,
scaleX: live.scaleX,
scaleY: live.scaleY,
rotation: live.rotation,
}
}
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. Visibility follows the
// button-specific rule: a still-frame kf renders only on its own
// state, not held into subsequent states.
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
if (layer.editorHidden) continue
if (!isButtonLayerActiveAt(layer, store.playhead)) 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 (layer.editorHidden) continue
if (!isLayerActiveAt(layer, store.playhead)) continue
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
out.push({
kind: 'layer',
item: {
layer,
asset,
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
},
})
}
return out
}
const out: StackItem[] = []
const layerById = new Map<string, Layer>()
for (const l of store.scene.layers) layerById.set(l.id, l)
const clipById = new Map<string, NonNullable<typeof store.scene.clips>[number]>()
for (const c of store.scene.clips ?? []) clipById.set(c.id, c)
const instancesByTrack = new Map<string, NonNullable<typeof store.scene.clipInstances>>()
for (const ci of store.scene.clipInstances ?? []) {
const arr = instancesByTrack.get(ci.clipTrackId) ?? []
arr.push(ci)
instancesByTrack.set(ci.clipTrackId, arr)
}
const expanded = expandedTrackOrder(store.scene)
const order = expanded.length ? expanded : store.scene.layers.map((l) => l.id)
// iterate bottom → top: reverse the array
for (let i = order.length - 1; i >= 0; i--) {
const id = order[i]
const layer = layerById.get(id)
if (layer) {
// Editor-only hide: skip on the Konva stage while the scene
// file still carries the layer, so export/fullscreen-preview
// render it normally.
if (layer.editorHidden) continue
if (!isLayerActiveAt(layer, store.playhead)) continue
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
out.push({
kind: 'layer',
item: {
layer,
asset,
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
},
})
continue
}
const instances = instancesByTrack.get(id) ?? []
for (const inst of instances) {
const clip = clipById.get(inst.clipId)
if (!clip) continue
const trimStart = inst.trimStart ?? 0
const trimEnd = inst.trimEnd ?? 0
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
const offsetIntoBlock = store.playhead - inst.startTime
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
const localTime = trimStart + offsetIntoBlock
// Bundle the clip's layers under a single clip-instance entry
// so the template can wrap them in a draggable Konva group that
// carries the instance's x / y / scale transform.
const items: RenderedClipLayer[] = []
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
items.push({
instanceId: inst.id,
layer: cl,
asset: ca,
props: sampleLayer(cl, localTime),
})
}
if (items.length) {
out.push({ kind: 'clipInstance', bundle: { instance: inst, items } })
}
}
}
return out
})
function shadowCfg(props: AnimatableProps): Record<string, unknown> {
// Konva's default shadowOpacity is 1, so returning {} for the "no
// shadow" case isn't enough — the shape would reuse whatever values
// were set on a previous render. Always emit explicit values and let
// `shadowEnabled` be the single source of truth. The gate matches
// the canvas/preview path: opacity > 0 AND some blur or distance.
const opacity = props.shadowOpacity ?? 0
const blur = props.shadowBlur ?? 0
const distance = props.shadowDistance ?? 0
const angle = props.shadowAngle ?? 90
const rad = (angle * Math.PI) / 180
return {
shadowColor: props.shadowColor ?? '#000000',
shadowBlur: blur,
shadowOpacity: opacity,
shadowOffsetX: Math.cos(rad) * distance,
shadowOffsetY: Math.sin(rad) * distance,
shadowEnabled: opacity > 0 && (blur > 0 || distance > 0),
}
}
function isLayerEffectivelyLocked(layer: Layer): boolean {
if (layer.locked) return true
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
}
function isLayerGhostLocked(layer: Layer): boolean {
return isLayerEffectivelyLocked(layer) && !showLocked.value
}
function ghostLockColor(layer: Layer, fallback?: string | null): string {
if (layer.color) return layer.color
if (fallback && fallback !== 'transparent') return fallback
return '#6ea8ff'
}
function baseNodeConfig(layer: Layer, props: AnimatableProps) {
const maskGhost = layer.isMask
? {
opacity: props.opacity * 0.45,
stroke: layer.maskMode === 'negative' ? '#ff6b6b' : '#6ea8ff',
strokeWidth: 1.5,
dash: [6, 4],
strokeScaleEnabled: false,
}
: {}
// Any locked layer (direct or via a locked group) is inert on the stage.
// When the timeline also hides locked tracks the per-shape config switches
// to a thin outline — handled by isLayerGhostLocked() checks per shape.
const effectivelyLocked = isLayerEffectivelyLocked(layer)
return {
id: layer.id,
x: props.x,
y: props.y,
scaleX: props.scaleX,
scaleY: props.scaleY,
rotation: props.rotation,
opacity: props.opacity,
visible: layer.visible,
draggable: !effectivelyLocked,
listening: !effectivelyLocked,
...shadowCfg(props),
...maskGhost,
}
}
type KonvaCtx = {
beginPath: () => void
closePath: () => void
rect: (x: number, y: number, w: number, h: number) => void
arc: (x: number, y: number, r: number, s: number, e: number, cc?: boolean) => void
moveTo: (x: number, y: number) => void
lineTo: (x: number, y: number) => void
bezierCurveTo: (
c1x: number,
c1y: number,
c2x: number,
c2y: number,
x: number,
y: number,
) => void
fillStrokeShape: (shape: unknown) => void
save: () => void
restore: () => void
setAttr: (attr: string, value: unknown) => void
}
type KonvaShape = { width(): number; height(): number; radius(): number; attrs: Record<string, unknown> }
function makeRectSceneFunc(blur: number) {
return (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.save()
if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`)
ctx.beginPath()
ctx.rect(0, 0, shape.width(), shape.height())
ctx.closePath()
ctx.fillStrokeShape(shape)
ctx.restore()
}
}
function makeCircleSceneFunc(blur: number) {
return (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.save()
if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`)
ctx.beginPath()
ctx.arc(0, 0, shape.radius(), 0, Math.PI * 2, false)
ctx.closePath()
ctx.fillStrokeShape(shape)
ctx.restore()
}
}
function strokeCfg(
props: AnimatableProps,
asset: { stroke?: string; strokeWidth?: number },
): Record<string, unknown> {
const strokeWidth = props.strokeWidth ?? asset.strokeWidth ?? 0
if (strokeWidth <= 0) return { strokeWidth: 0, stroke: undefined }
const stroke = (props.stroke ?? asset.stroke) || '#000000'
if (stroke === 'transparent') return { strokeWidth: 0, stroke: undefined }
return { stroke, strokeWidth, strokeScaleEnabled: false }
}
function rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props: AnimatableProps) {
const fill = props.fill ?? asset.fill
const blur = props.blur ?? asset.blur ?? 0
const base = baseNodeConfig(layer, props)
if (isLayerGhostLocked(layer)) {
return {
...base,
width: asset.width,
height: asset.height,
offsetX: layer.offsetX ?? asset.width / 2,
offsetY: layer.offsetY ?? asset.height / 2,
fill: undefined,
stroke: ghostLockColor(layer, fill),
strokeWidth: 1,
strokeScaleEnabled: false,
}
}
const cfg: Record<string, unknown> = {
...base,
width: asset.width,
height: asset.height,
offsetX: layer.offsetX ?? asset.width / 2,
offsetY: layer.offsetY ?? asset.height / 2,
...(layer.isMask ? {} : strokeCfg(props, asset)),
}
if (fill && fill !== 'transparent') cfg.fill = fill
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
return cfg
}
function circleConfig(
layer: Layer,
asset: Extract<Asset, { kind: 'circle' }>,
props: AnimatableProps,
) {
const fill = props.fill ?? asset.fill
const blur = props.blur ?? asset.blur ?? 0
const base = baseNodeConfig(layer, props)
if (isLayerGhostLocked(layer)) {
return {
...base,
radius: asset.radius,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
fill: undefined,
stroke: ghostLockColor(layer, fill),
strokeWidth: 1,
strokeScaleEnabled: false,
}
}
const cfg: Record<string, unknown> = {
...base,
radius: asset.radius,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
...(layer.isMask ? {} : strokeCfg(props, asset)),
}
if (fill && fill !== 'transparent') cfg.fill = fill
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
return cfg
}
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
const fill = props.fill ?? asset.fill
const base = baseNodeConfig(layer, props)
if (isLayerGhostLocked(layer)) {
return {
...base,
text: asset.text,
fontSize: asset.fontSize,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
fill: undefined,
stroke: ghostLockColor(layer, fill),
strokeWidth: 1,
strokeScaleEnabled: false,
}
}
const cfg: Record<string, unknown> = {
...base,
text: asset.text,
fontSize: asset.fontSize,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
...(layer.isMask ? {} : strokeCfg(props, asset)),
}
if (fill && fill !== 'transparent') cfg.fill = fill
return cfg
}
function traceKonvaPolyPath(
ctx: KonvaCtx,
samplePoints: { x: number; y: number }[],
assetPoints: Point[],
offX: number,
offY: number,
closed: boolean,
) {
const n = samplePoints.length
if (n < 2) return
ctx.beginPath()
ctx.moveTo(samplePoints[0].x - offX, samplePoints[0].y - offY)
const endI = closed ? n : n - 1
for (let i = 0; i < endI; i++) {
const aPos = samplePoints[i]
const bIdx = (i + 1) % n
const bPos = samplePoints[bIdx]
const aAsset = assetPoints[i] as Point | undefined
const bAsset = assetPoints[bIdx] as Point | undefined
const aHas = aAsset && aAsset.type && aAsset.type !== 'corner'
const bHas = bAsset && bAsset.type && bAsset.type !== 'corner'
if (aHas || bHas) {
const aSp = samplePoints[i] as Point
const bSp = samplePoints[bIdx] as Point
const aox = aHas ? aSp.outX ?? aAsset!.outX ?? 0 : 0
const aoy = aHas ? aSp.outY ?? aAsset!.outY ?? 0 : 0
const bix = bHas ? bSp.inX ?? bAsset!.inX ?? 0 : 0
const biy = bHas ? bSp.inY ?? bAsset!.inY ?? 0 : 0
ctx.bezierCurveTo(
aPos.x + aox - offX,
aPos.y + aoy - offY,
bPos.x + bix - offX,
bPos.y + biy - offY,
bPos.x - offX,
bPos.y - offY,
)
} else {
ctx.lineTo(bPos.x - offX, bPos.y - offY)
}
}
if (closed) ctx.closePath()
}
function polygonConfig(
layer: Layer,
asset: Extract<Asset, { kind: 'polygon' }>,
props: AnimatableProps,
) {
const fill = props.fill ?? asset.fill
const rawStroke = props.stroke ?? asset.stroke
const strokeWidth = props.strokeWidth ?? asset.strokeWidth ?? 0
const stroke = strokeWidth > 0 ? (rawStroke || '#000000') : undefined
const blur = props.blur ?? asset.blur ?? 0
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
const pts = props.points ?? asset.points
const closed = asset.closed !== false
const flat: number[] = []
for (const p of pts) {
flat.push(p.x - offX, p.y - offY)
}
const hasCurves = asset.points.some((p) => p.type && p.type !== 'corner')
const base = baseNodeConfig(layer, props)
if (isLayerGhostLocked(layer)) {
const ghostCfg: Record<string, unknown> = {
...base,
points: flat,
closed,
lineJoin: 'round',
lineCap: 'round',
fill: undefined,
stroke: ghostLockColor(layer, fill),
strokeWidth: 1,
strokeScaleEnabled: false,
}
if (hasCurves) {
ghostCfg.sceneFunc = (ctx: KonvaCtx, shape: KonvaShape) => {
traceKonvaPolyPath(ctx, pts, asset.points, offX, offY, closed)
ctx.fillStrokeShape(shape)
}
}
return ghostCfg
}
const cfg: Record<string, unknown> = {
...base,
points: flat,
closed,
lineJoin: 'round',
lineCap: 'round',
}
if (closed && fill && fill !== 'transparent') cfg.fill = fill
if (!layer.isMask && stroke && strokeWidth > 0 && stroke !== 'transparent') {
cfg.stroke = stroke
cfg.strokeWidth = strokeWidth
cfg.strokeScaleEnabled = false
} else {
cfg.stroke = undefined
cfg.strokeWidth = 0
}
cfg.hitStrokeWidth = Math.max(strokeWidth, 14 / zoom.value)
if (blur > 0 || hasCurves) {
cfg.sceneFunc = (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.save()
if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`)
traceKonvaPolyPath(ctx, pts, asset.points, offX, offY, closed)
ctx.fillStrokeShape(shape)
ctx.restore()
}
}
return cfg
}
function imageConfig(
layer: Layer,
asset: Extract<Asset, { kind: 'image' }>,
props: AnimatableProps,
) {
const img = imageCache.value[asset.id]
if (!img) return null
if (isLayerGhostLocked(layer)) {
// Show a 1px outline of the image's bounding box instead of the pixels.
return {
...baseNodeConfig(layer, props),
width: asset.width,
height: asset.height,
offsetX: layer.offsetX ?? asset.width / 2,
offsetY: layer.offsetY ?? asset.height / 2,
fill: undefined,
stroke: ghostLockColor(layer, null),
strokeWidth: 1,
strokeScaleEnabled: false,
sceneFunc: (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.beginPath()
ctx.rect(0, 0, asset.width, asset.height)
ctx.closePath()
ctx.fillStrokeShape(shape)
},
}
}
return {
...baseNodeConfig(layer, props),
image: img,
width: asset.width,
height: asset.height,
offsetX: layer.offsetX ?? asset.width / 2,
offsetY: layer.offsetY ?? asset.height / 2,
}
}
/** Per-clip-instance wrapper group. Carries the instance's static
* x / y / scale so the user can reposition the packed clip on the
* stage without touching its inner keyframes. `rotation` is
* deliberately absent — the user asked for move + scale only.
* The inner child configs keep `listening: false`, so pointer
* events funnel to the outer group and drag/transform apply as a
* unit. */
function clipInstanceGroupConfig(inst: ClipInstance) {
return {
id: `ci-group:${inst.id}`,
x: inst.x ?? 0,
y: inst.y ?? 0,
scaleX: inst.scaleX ?? 1,
scaleY: inst.scaleY ?? 1,
rotation: inst.rotation ?? 0,
draggable: true,
}
}
function onClipInstanceDragEnd(
e: Konva.KonvaEventObject<DragEvent>,
instanceId: string,
) {
const node = e.target
store.setClipInstanceTransform(instanceId, {
x: node.x(),
y: node.y(),
})
}
function onClipInstanceTransformEnd(
e: Konva.KonvaEventObject<Event>,
instanceId: string,
) {
const node = e.target
store.setClipInstanceTransform(instanceId, {
scaleX: node.scaleX(),
scaleY: node.scaleY(),
rotation: node.rotation(),
x: node.x(),
y: node.y(),
})
}
function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps) {
return {
id: `ci:${instanceId}:${layer.id}`,
x: props.x,
y: props.y,
scaleX: props.scaleX,
scaleY: props.scaleY,
rotation: props.rotation,
opacity: props.opacity,
visible: layer.visible,
draggable: false,
// Keep clip children hit-testable so a click on the clip's
// visible shape bubbles up to the parent group's @mousedown
// selection handler. They don't carry their own handlers, so
// the event bubbles straight up.
listening: true,
...shadowCfg(props),
}
}
function clipRectConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'rect' }>
const fill = item.props.fill ?? asset.fill
const blur = item.props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props),
width: asset.width,
height: asset.height,
offsetX: item.layer.offsetX ?? asset.width / 2,
offsetY: item.layer.offsetY ?? asset.height / 2,
...strokeCfg(item.props, asset),
}
if (fill && fill !== 'transparent') cfg.fill = fill
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
return cfg
}
function clipCircleConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'circle' }>
const fill = item.props.fill ?? asset.fill
const blur = item.props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props),
radius: asset.radius,
offsetX: item.layer.offsetX ?? 0,
offsetY: item.layer.offsetY ?? 0,
...strokeCfg(item.props, asset),
}
if (fill && fill !== 'transparent') cfg.fill = fill
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
return cfg
}
function clipTextConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'text' }>
const fill = item.props.fill ?? asset.fill
const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props),
text: asset.text,
fontSize: asset.fontSize,
offsetX: item.layer.offsetX ?? 0,
offsetY: item.layer.offsetY ?? 0,
...strokeCfg(item.props, asset),
}
if (fill && fill !== 'transparent') cfg.fill = fill
return cfg
}
function clipPolygonConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'polygon' }>
const offX = item.layer.offsetX ?? 0
const offY = item.layer.offsetY ?? 0
const pts = item.props.points ?? asset.points
const closed = asset.closed !== false
const stroke = item.props.stroke ?? asset.stroke
const strokeWidth = item.props.strokeWidth ?? asset.strokeWidth ?? 0
const fill = item.props.fill ?? asset.fill
const flat: number[] = []
for (const p of pts) {
flat.push(p.x - offX, p.y - offY)
}
const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props),
points: flat,
closed,
lineJoin: 'round',
lineCap: 'round',
}
if (closed && fill && fill !== 'transparent') cfg.fill = fill
if (stroke && strokeWidth > 0) {
cfg.stroke = stroke
cfg.strokeWidth = strokeWidth
cfg.strokeScaleEnabled = false
}
return cfg
}
function clipImageConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'image' }>
const img = imageCache.value[asset.id]
if (!img) return null
return {
...clipBaseConfig(item.instanceId, item.layer, item.props),
image: img,
width: asset.width,
height: asset.height,
offsetX: item.layer.offsetX ?? asset.width / 2,
offsetY: item.layer.offsetY ?? asset.height / 2,
}
}
interface PivotInfo {
layerId: string
sampled: AnimatableProps
}
interface LiveLayerPose {
layerId: string
x: number
y: number
scaleX: number
scaleY: number
rotation: number
}
const liveLayerPose = ref<LiveLayerPose | null>(null)
const pivotInfo = computed<PivotInfo | null>(() => {
const id = store.selectedLayerId
if (!id) return null
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return null
const asset = assetMap.value.get(layer.assetId)
if (!asset) return null
const sampled = sampleLayer(layer, store.playhead)
const live = liveLayerPose.value
if (live && live.layerId === id) {
return {
layerId: layer.id,
sampled: {
...sampled,
x: live.x,
y: live.y,
scaleX: live.scaleX,
scaleY: live.scaleY,
rotation: live.rotation,
},
}
}
return { layerId: layer.id, sampled }
})
const pivotHandleConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
return {
x: info.sampled.x,
y: info.sampled.y,
draggable: true,
name: 'pivot-handle',
}
})
const pivotRingConfig = computed(() => ({
radius: 8 / zoom.value,
stroke: '#6ea8ff',
strokeWidth: 1.5 / zoom.value,
fill: '#ffffff',
}))
const pivotDotConfig = computed(() => ({
radius: 2.5 / zoom.value,
fill: '#6ea8ff',
listening: false,
}))
interface VertexHandle {
layerId: string
index: number
x: number
y: number
}
const polygonVertexHandles = computed<VertexHandle[]>(() => {
const id = store.selectedLayerId
if (!id) return []
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer || layer.locked) return []
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return []
const sampled = sampleLayer(layer, store.playhead)
const live = liveLayerPose.value
const cx = live && live.layerId === id ? live.x : sampled.x
const cy = live && live.layerId === id ? live.y : sampled.y
const rot = live && live.layerId === id ? live.rotation : sampled.rotation
const scX = live && live.layerId === id ? live.scaleX : sampled.scaleX
const scY = live && live.layerId === id ? live.scaleY : sampled.scaleY
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
const pts = sampled.points ?? asset.points
const theta = (rot * Math.PI) / 180
const c = Math.cos(theta)
const s = Math.sin(theta)
return pts.map((p, index) => {
const lx = (p.x - offX) * scX
const ly = (p.y - offY) * scY
return {
layerId: id,
index,
x: cx + lx * c - ly * s,
y: cy + lx * s + ly * c,
}
})
})
function vertexHandleConfig(h: VertexHandle) {
const selected = selectedVertexIndices.value.has(h.index)
return {
x: h.x,
y: h.y,
radius: (selected ? 6 : 5) / zoom.value,
stroke: '#6ea8ff',
strokeWidth: (selected ? 2 : 1.5) / zoom.value,
fill: selected ? '#6ea8ff' : '#ffffff',
draggable: true,
name: 'vertex-handle',
}
}
function worldToPolygonLocal(layer: Layer, wx: number, wy: number): { x: number; y: number } {
const sampled = sampleLayer(layer, store.playhead)
const dx = wx - sampled.x
const dy = wy - sampled.y
const theta = (sampled.rotation * Math.PI) / 180
const c = Math.cos(theta)
const s = Math.sin(theta)
const unrotX = dx * c + dy * s
const unrotY = -dx * s + dy * c
const invSX = sampled.scaleX !== 0 ? 1 / sampled.scaleX : 1
const invSY = sampled.scaleY !== 0 ? 1 / sampled.scaleY : 1
return {
x: unrotX * invSX + (layer.offsetX ?? 0),
y: unrotY * invSY + (layer.offsetY ?? 0),
}
}
interface VertexDragState {
layerId: string
indices: number[]
dragStartLocal: { x: number; y: number }
startPositions: Map<number, { x: number; y: number }>
}
const vertexDragState = ref<VertexDragState | null>(null)
function onVertexClick(e: Konva.KonvaEventObject<MouseEvent>, index: number) {
const native = e.evt as MouseEvent
if (native.shiftKey) {
const next = new Set(selectedVertexIndices.value)
if (next.has(index)) next.delete(index)
else next.add(index)
selectedVertexIndices.value = next
} else {
selectedVertexIndices.value = new Set([index])
}
e.cancelBubble = true
}
function onVertexDragStart(_e: Konva.KonvaEventObject<DragEvent>, index: number) {
const id = store.selectedLayerId
if (!id) return
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return
// If the dragged vertex wasn't in the selection, reduce selection to just that one
const current = selectedVertexIndices.value
if (!current.has(index)) {
selectedVertexIndices.value = new Set([index])
}
const indices = Array.from(selectedVertexIndices.value)
const sampled = sampleLayer(layer, store.playhead)
const asset = assetMap.value.get(layer.assetId)
const pts = sampled.points ?? (asset && asset.kind === 'polygon' ? asset.points : [])
const start = new Map<number, { x: number; y: number }>()
for (const i of indices) {
const p = pts[i]
if (p) start.set(i, { x: p.x, y: p.y })
}
const dragStart = start.get(index) ?? { x: 0, y: 0 }
vertexDragState.value = {
layerId: id,
indices,
dragStartLocal: dragStart,
startPositions: start,
}
}
function onVertexDragMove(e: Konva.KonvaEventObject<DragEvent>, index: number) {
const state = vertexDragState.value
if (!state) return
const layer = store.activeLayers.find((l) => l.id === state.layerId)
if (!layer) return
const node = e.target
const currentLocal = worldToPolygonLocal(layer, node.x(), node.y())
const dx = currentLocal.x - state.dragStartLocal.x
const dy = currentLocal.y - state.dragStartLocal.y
const updates: { index: number; x: number; y: number }[] = []
for (const i of state.indices) {
const start = state.startPositions.get(i)
if (!start) continue
updates.push({ index: i, x: start.x + dx, y: start.y + dy })
}
// Keep the dragged node itself in sync with the applied delta (in case Konva's internal node drifts)
const target = state.startPositions.get(index)
if (target) {
updates.push({ index, x: target.x + dx, y: target.y + dy })
}
store.setPolygonVertexPositions(state.layerId, updates)
}
function onVertexDragEnd(e: Konva.KonvaEventObject<DragEvent>, index: number) {
onVertexDragMove(e, index)
vertexDragState.value = null
}
interface VertexMenuState {
screenX: number
screenY: number
assetId: string
layerId: string
indices: number[]
currentType: 'corner' | 'curve' | 'splitcurve'
}
const vertexMenu = ref<VertexMenuState | null>(null)
function onVertexContextMenu(e: Konva.KonvaEventObject<PointerEvent>, index: number) {
e.evt.preventDefault()
const id = store.selectedLayerId
if (!id) return
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return
const rect = container.value?.getBoundingClientRect()
if (!rect) return
// If the right-clicked vertex is part of an existing multi-selection,
// apply the menu action to all selected. Otherwise focus on this vertex only.
const sel = selectedVertexIndices.value
const indices = sel.has(index) ? Array.from(sel) : [index]
if (!sel.has(index)) selectedVertexIndices.value = new Set([index])
const point = asset.points[index]
vertexMenu.value = {
screenX: e.evt.clientX - rect.left,
screenY: e.evt.clientY - rect.top,
assetId: asset.id,
layerId: layer.id,
indices,
currentType: point?.type ?? 'corner',
}
}
function applyVertexType(type: 'corner' | 'curve' | 'splitcurve') {
const m = vertexMenu.value
if (!m) return
store.setPolygonVertexType(m.assetId, m.indices, type)
vertexMenu.value = null
}
function closeVertexMenu() {
vertexMenu.value = null
}
interface TangentHandle {
key: string
layerId: string
assetId: string
vertexIndex: number
side: 'in' | 'out'
vertexWx: number
vertexWy: number
handleWx: number
handleWy: number
}
const polygonTangentHandles = computed<TangentHandle[]>(() => {
const id = store.selectedLayerId
if (!id) return []
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer || layer.locked) return []
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return []
// Only show tangent handles when exactly one vertex is selected
const vsel = selectedVertexIndices.value
if (vsel.size !== 1) return []
const onlyIndex = vsel.values().next().value!
const sampled = sampleLayer(layer, store.playhead)
const live = liveLayerPose.value
const cx = live && live.layerId === id ? live.x : sampled.x
const cy = live && live.layerId === id ? live.y : sampled.y
const rot = live && live.layerId === id ? live.rotation : sampled.rotation
const scX = live && live.layerId === id ? live.scaleX : sampled.scaleX
const scY = live && live.layerId === id ? live.scaleY : sampled.scaleY
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
const pts = sampled.points ?? asset.points
const theta = (rot * Math.PI) / 180
const c = Math.cos(theta)
const s = Math.sin(theta)
const out: TangentHandle[] = []
for (let i = 0; i < asset.points.length; i++) {
if (i !== onlyIndex) continue
const ap = asset.points[i]
if (!ap.type || ap.type === 'corner') continue
const p = pts[i] as Point
const vx = p.x - offX
const vy = p.y - offY
const localVertex = { x: vx * scX, y: vy * scY }
const vertexWx = cx + localVertex.x * c - localVertex.y * s
const vertexWy = cy + localVertex.x * s + localVertex.y * c
for (const side of ['in', 'out'] as const) {
const dx =
(side === 'in' ? p.inX ?? ap.inX : p.outX ?? ap.outX) ?? 0
const dy =
(side === 'in' ? p.inY ?? ap.inY : p.outY ?? ap.outY) ?? 0
const tx = (p.x + dx - offX) * scX
const ty = (p.y + dy - offY) * scY
const handleWx = cx + tx * c - ty * s
const handleWy = cy + tx * s + ty * c
out.push({
key: `tan-${layer.id}-${i}-${side}`,
layerId: layer.id,
assetId: asset.id,
vertexIndex: i,
side,
vertexWx,
vertexWy,
handleWx,
handleWy,
})
}
}
return out
})
const polygonTangentLines = computed(() =>
polygonTangentHandles.value.map((h) => ({
key: `tl-${h.key}`,
config: {
points: [h.vertexWx, h.vertexWy, h.handleWx, h.handleWy],
stroke: '#6ea8ff',
strokeWidth: 1 / zoom.value,
strokeScaleEnabled: false,
listening: false,
opacity: 0.6,
},
})),
)
function tangentHandleConfig(h: TangentHandle) {
return {
x: h.handleWx,
y: h.handleWy,
radius: 3.5 / zoom.value,
hitStrokeWidth: 10 / zoom.value,
fill: '#6ea8ff',
stroke: '#ffffff',
strokeWidth: 1 / zoom.value,
strokeScaleEnabled: false,
draggable: true,
name: 'tangent-handle',
}
}
function onTangentDrag(e: Konva.KonvaEventObject<DragEvent>, h: TangentHandle) {
const layer = store.activeLayers.find((l) => l.id === h.layerId)
if (!layer) return
const asset = assetMap.value.get(h.assetId)
if (!asset || asset.kind !== 'polygon') return
const node = e.target
// Convert node world position to asset-local vertex offset
const handleLocal = worldToPolygonLocal(layer, node.x(), node.y())
const sampled = sampleLayer(layer, store.playhead)
const pts = sampled.points ?? asset.points
const vp = pts[h.vertexIndex]
if (!vp) return
const dx = handleLocal.x - vp.x
const dy = handleLocal.y - vp.y
store.setPolygonVertexTangent(h.assetId, h.vertexIndex, h.side, dx, dy)
}
const selectedVertexIndices = ref<Set<number>>(new Set())
const selectedPolygonLayer = computed(() => {
const id = store.selectedLayerId
if (!id) return null
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return null
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return null
return { layer, asset }
})
watch(
() => store.selectedLayerId,
() => {
selectedVertexIndices.value = new Set()
closeVertexMenu()
},
)
const boxSelect = ref<{
startX: number
startY: number
curX: number
curY: number
} | null>(null)
const boxSelectRectPx = computed(() => {
const b = boxSelect.value
if (!b) return null
const x0 = Math.min(b.startX, b.curX)
const y0 = Math.min(b.startY, b.curY)
const x1 = Math.max(b.startX, b.curX)
const y1 = Math.max(b.startY, b.curY)
// Convert world coords to screen coords for DOM overlay
const sx0 = x0 * zoom.value + viewX.value
const sy0 = y0 * zoom.value + viewY.value
const sx1 = x1 * zoom.value + viewX.value
const sy1 = y1 * zoom.value + viewY.value
return {
left: Math.min(sx0, sx1),
top: Math.min(sy0, sy1),
width: Math.abs(sx1 - sx0),
height: Math.abs(sy1 - sy0),
}
})
function finishBoxSelect() {
const b = boxSelect.value
boxSelect.value = null
if (!b) return
const sel = selectedPolygonLayer.value
if (!sel) return
const { layer, asset } = sel
const sampled = sampleLayer(layer, store.playhead)
const pts = sampled.points ?? asset.points
const minX = Math.min(b.startX, b.curX)
const maxX = Math.max(b.startX, b.curX)
const minY = Math.min(b.startY, b.curY)
const maxY = Math.max(b.startY, b.curY)
const indices = new Set<number>()
for (let i = 0; i < pts.length; i++) {
const world = transformLocalToWorld(layer, sampled, pts[i])
if (world.x >= minX && world.x <= maxX && world.y >= minY && world.y <= maxY) {
indices.add(i)
}
}
selectedVertexIndices.value = indices
}
const draggingPivot = ref(false)
function onPivotDragStart() {
draggingPivot.value = true
}
function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
draggingPivot.value = false
const info = pivotInfo.value
if (!info) return
const node = e.target
const dx = node.x() - info.sampled.x
const dy = node.y() - info.sampled.y
if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) return
const theta = (info.sampled.rotation * Math.PI) / 180
const c = Math.cos(theta)
const s = Math.sin(theta)
const unrotDx = dx * c + dy * s
const unrotDy = -dx * s + dy * c
const invSX = info.sampled.scaleX !== 0 ? 1 / info.sampled.scaleX : 1
const invSY = info.sampled.scaleY !== 0 ? 1 / info.sampled.scaleY : 1
const localDx = unrotDx * invSX
const localDy = unrotDy * invSY
store.shiftLayerPivot(info.layerId, dx, dy, localDx, localDy)
}
const transformerConfig = {
rotateEnabled: true,
rotationSnaps: [0, 45, 90, 135, 180, -135, -90, -45],
rotationSnapTolerance: 4,
enabledAnchors: [
'top-left',
'top-center',
'top-right',
'middle-right',
'middle-left',
'bottom-left',
'bottom-center',
'bottom-right',
],
anchorSize: 9,
borderStroke: '#4f8cff',
anchorStroke: '#4f8cff',
anchorFill: '#ffffff',
padding: 2,
ignoreStroke: true,
}
let attachRetryTimer: ReturnType<typeof setTimeout> | null = null
function attachTransformer(retriesLeft = 3) {
if (attachRetryTimer) {
clearTimeout(attachRetryTimer)
attachRetryTimer = null
}
const stage = stageRef.value?.getStage()
const tr = transformerRef.value?.getNode()
if (!stage || !tr) return
// Clip-instance selection takes precedence — the transformer wraps
// the group so its move + scale handles update the instance's
// static x / y / scale.
const clipInstId = store.selectedClipInstanceId
if (clipInstId) {
const groupNode = stage.findOne<Konva.Node>(`#ci-group:${clipInstId}`)
if (!groupNode && retriesLeft > 0) {
attachRetryTimer = setTimeout(() => attachTransformer(retriesLeft - 1), 16)
return
}
tr.nodes(groupNode ? [groupNode] : [])
tr.getLayer()?.batchDraw()
return
}
const id = store.selectedLayerId
if (!id) {
tr.nodes([])
tr.getLayer()?.batchDraw()
return
}
// Polygons are edited via vertex handles — no bulk transformer
const layer = store.activeLayers.find((l) => l.id === id)
if (layer) {
const asset = assetMap.value.get(layer.assetId)
if (asset?.kind === 'polygon') {
tr.nodes([])
tr.getLayer()?.batchDraw()
return
}
}
const node = stage.findOne<Konva.Node>(`#${id}`)
if (!node && retriesLeft > 0) {
// The Konva node for a freshly-rendered layer may not be attached yet.
// Retry on the next animation frame a few times before giving up.
attachRetryTimer = setTimeout(() => attachTransformer(retriesLeft - 1), 16)
return
}
tr.nodes(node ? [node] : [])
tr.getLayer()?.batchDraw()
}
watch(
() =>
[
store.selectedLayerId,
store.selectedClipInstanceId,
stackItems.value,
store.playhead,
] as const,
async () => {
await nextTick()
attachTransformer()
},
{ immediate: true, deep: true },
)
function updateLivePose(node: Konva.Node, layerId: string) {
liveLayerPose.value = {
layerId,
x: node.x(),
y: node.y(),
scaleX: node.scaleX(),
scaleY: node.scaleY(),
rotation: node.rotation(),
}
}
function handleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const node = e.target
const id = node.id()
if (!store.activeLayers.some((l) => l.id === id)) return
updateLivePose(node, id)
}
function handleTransform(e: Konva.KonvaEventObject<Event>) {
const node = e.target
const id = node.id()
if (!store.activeLayers.some((l) => l.id === id)) return
updateLivePose(node, id)
}
interface PathFrame {
index: number
x: number
y: number
u: number
}
interface PathSegment {
flat: number[]
}
interface ActiveSegment {
keyframeId: string
frames: PathFrame[]
}
interface PathOverlay {
layerId: string
ghostSegments: PathSegment[]
activeSegments: ActiveSegment[]
}
function sampleSegment(layer: Layer, from: number, to: number, fps: number): number[] {
const dt = to - from
if (dt <= 0) return []
const n = Math.max(2, Math.min(240, Math.round(dt * fps)))
const out: number[] = []
for (let i = 0; i <= n; i++) {
const time = from + (i / n) * dt
const s = sampleLayer(layer, time)
out.push(s.x, s.y)
}
return out
}
const animationPath = computed<PathOverlay | null>(() => {
const sel = selectedKfs.value
if (sel.length === 0) return null
const first = sel[0]
if (first.kind === 'rotation') return null
const origLayer = store.activeLayers.find((l) => l.id === first.layerId)
if (!origLayer) return null
const arr = first.kind === 'position' ? origLayer.positionKeyframes : origLayer.keyframes
if (!arr) return null
// While the user drags this layer's object, clone its keyframes so the
// path reflects the live position without mutating the store. If a kf
// at the playhead already exists, override its x/y; otherwise splice
// in a synthetic one so the path gets a live endpoint.
const live = liveLayerPose.value
let layer = origLayer
let workingArr = arr
if (live && live.layerId === origLayer.id) {
const cloned: Keyframe[] = arr.map((k) => ({ ...k, props: { ...k.props } }))
const existing = cloned.find((k) => Math.abs(k.time - store.playhead) < 1e-3)
if (existing) {
existing.props.x = live.x
existing.props.y = live.y
} else {
cloned.push({
id: '__live__',
time: store.playhead,
props: { x: live.x, y: live.y },
easing: 'linear',
})
}
cloned.sort((a, b) => a.time - b.time)
workingArr = cloned
layer =
first.kind === 'position'
? { ...origLayer, positionKeyframes: cloned }
: { ...origLayer, keyframes: cloned }
}
// Every selected keyframe on this layer+kind gets an interactive
// active segment (the incoming curve). Other segments stay ghost.
const selectedIds = new Set<string>()
for (const s of sel) {
if (s.layerId !== first.layerId) continue
if (s.kind !== first.kind) continue
selectedIds.add(s.keyframeId)
}
const sorted = [...workingArr].sort((a, b) => a.time - b.time)
const fps = store.scene.fps || 60
const ghostSegments: PathSegment[] = []
const activeSegments: ActiveSegment[] = []
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i]
const b = sorted[i + 1]
const dt = b.time - a.time
if (dt <= 0) continue
if (selectedIds.has(b.id)) {
const n = Math.max(2, Math.min(240, Math.round(dt * fps)))
const frames: PathFrame[] = []
for (let k = 0; k <= n; k++) {
const u = k / n
const time = a.time + u * dt
const s = sampleLayer(layer, time)
frames.push({ index: k, x: s.x, y: s.y, u })
}
activeSegments.push({ keyframeId: b.id, frames })
} else {
const flat = sampleSegment(layer, a.time, b.time, fps)
if (flat.length >= 4) ghostSegments.push({ flat })
}
}
if (!ghostSegments.length && activeSegments.length === 0) return null
return {
layerId: layer.id,
ghostSegments,
activeSegments,
}
})
const animationGhostSegments = computed(() => {
const path = animationPath.value
if (!path) return []
return path.ghostSegments.map((seg, i) => ({
key: `gs-${i}`,
config: {
points: seg.flat,
stroke: '#6ea8ff',
strokeWidth: 1 / zoom.value,
strokeScaleEnabled: false,
listening: false,
lineCap: 'round',
lineJoin: 'round',
opacity: 0.35,
dash: [4 / zoom.value, 3 / zoom.value],
},
}))
})
const animationPathLines = computed(() => {
const path = animationPath.value
if (!path) return []
return path.activeSegments
.filter((s) => s.frames.length >= 2)
.map((s) => {
const flat: number[] = []
for (const f of s.frames) flat.push(f.x, f.y)
return {
key: `apl-${s.keyframeId}`,
config: {
points: flat,
stroke: '#6ea8ff',
strokeWidth: 1.25 / zoom.value,
strokeScaleEnabled: false,
listening: false,
lineCap: 'round',
lineJoin: 'round',
},
}
})
})
interface PathDot {
key: string
keyframeId: string
frameIndex: number
frameTotal: number
x: number
y: number
isEnd: boolean
}
const animationPathDots = computed<PathDot[]>(() => {
const path = animationPath.value
if (!path) return []
const out: PathDot[] = []
for (const seg of path.activeSegments) {
const last = seg.frames.length - 1
for (const f of seg.frames) {
out.push({
key: `pf-${seg.keyframeId}-${f.index}`,
keyframeId: seg.keyframeId,
frameIndex: f.index,
frameTotal: last,
x: f.x,
y: f.y,
isEnd: f.index === 0 || f.index === last,
})
}
}
return out
})
const hoveredPathDot = ref<string | null>(null)
function animationDotConfig(d: PathDot) {
const hoverKey = `${d.keyframeId}:${d.frameIndex}`
const hovered = !d.isEnd && hoveredPathDot.value === hoverKey
const baseRadius = d.isEnd ? 4 : 2.5
const radius = hovered ? 6 : baseRadius
return {
x: d.x,
y: d.y,
radius: radius / zoom.value,
hitStrokeWidth: 10 / zoom.value,
fill: d.isEnd ? '#6ea8ff' : hovered ? '#6ea8ff' : '#ffffff',
stroke: '#6ea8ff',
strokeWidth: (hovered ? 1.5 : 1) / zoom.value,
strokeScaleEnabled: false,
draggable: !d.isEnd,
name: 'path-frame-dot',
}
}
function onPathDotMouseEnter(d: PathDot) {
if (d.isEnd) return
hoveredPathDot.value = `${d.keyframeId}:${d.frameIndex}`
const stage = stageRef.value?.getStage()
const cont = stage?.container()
if (cont) cont.style.cursor = 'grab'
}
function onPathDotMouseLeave() {
hoveredPathDot.value = null
const stage = stageRef.value?.getStage()
const cont = stage?.container()
if (cont) cont.style.cursor = ''
}
function onPathDotDragMove(e: Konva.KonvaEventObject<DragEvent>, d: PathDot) {
const path = animationPath.value
if (!path) return
const node = e.target
const wx = node.x()
const wy = node.y()
if (d.frameTotal <= 0) return
const uRaw = d.frameIndex / d.frameTotal
if (uRaw <= 0 || uRaw >= 1) return
const layer = store.activeLayers.find((l) => l.id === path.layerId)
if (!layer) return
const sel = selectedKfs.value.find((s) => s.keyframeId === d.keyframeId)
const arr = sel?.kind === 'position' ? layer.positionKeyframes : layer.keyframes
const kf = arr?.find((k) => k.id === d.keyframeId)
if (!kf) return
const easing = kf.easing
const t = applyEasing(uRaw, easing)
store.setKeyframeControlFromPoint(path.layerId, d.keyframeId, t, wx, wy)
}
function applyEasing(t: number, easing: string): number {
if (easing === 'easeIn') return t * t
if (easing === 'easeOut') return 1 - (1 - t) ** 2
if (easing === 'easeInOut') return t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2
return t
}
const drawingPreview = ref<{ x: number; y: number } | null>(null)
function pointerToWorld(e: Konva.KonvaEventObject<MouseEvent>) {
const stage = stageRef.value?.getStage()
if (!stage) return null
const pos = stage.getPointerPosition()
if (!pos) return null
return {
x: (pos.x - viewX.value) / zoom.value,
y: (pos.y - viewY.value) / zoom.value,
}
}
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
// Box-select for vertices when a polygon layer is selected and the
// click lands on the empty stage (not a shape/handle).
if (
!store.drawingMode &&
selectedPolygonLayer.value &&
e.target === e.target.getStage()
) {
const native = e.evt as MouseEvent
if (native.button === 0 && !native.ctrlKey) {
const wp = pointerToWorld(e)
if (wp) {
boxSelect.value = { startX: wp.x, startY: wp.y, curX: wp.x, curY: wp.y }
if (!native.shiftKey) selectedVertexIndices.value = new Set()
closeVertexMenu()
return
}
}
}
if (store.drawingMode === 'freeform') {
const native = e.evt as MouseEvent
// Middle-click or Ctrl+click belong to the pan gesture, not drawing
if (native.button === 1 || native.ctrlKey) return
const wp = pointerToWorld(e)
if (!wp) return
const layerId = store.drawingLayerId
const layer = layerId ? store.activeLayers.find((l) => l.id === layerId) : null
const local = layer ? worldToPolygonLocal(layer, wp.x, wp.y) : wp
// Close-on-click near first point (compare in asset-local space)
const assetId = store.drawingAssetId
const asset = assetId ? store.scene.assets.find((a) => a.id === assetId) : null
if (asset && asset.kind === 'polygon' && asset.points.length >= 3) {
const sampled = layer ? sampleLayer(layer, store.playhead) : null
const scale = sampled ? Math.max(Math.abs(sampled.scaleX), Math.abs(sampled.scaleY), 0.01) : 1
const first = asset.points[0]
const threshold = 10 / zoom.value / scale
if (Math.hypot(local.x - first.x, local.y - first.y) <= threshold) {
store.setPolygonClosed(asset.id, true)
store.finishDrawing()
return
}
}
store.addDrawingPoint(local.x, local.y)
return
}
const target = e.target
const tr = transformerRef.value?.getNode()
if (tr) {
let p: Konva.Node | null = target
while (p) {
if (p === tr) return
p = p.getParent() ?? null
}
}
if (target.findAncestor?.('.pivot-handle')) return
if (target.findAncestor?.('.rotation-handle')) return
if (target.name?.() === 'vertex-handle' || target.findAncestor?.('.vertex-handle')) return
if (target.name?.() === 'tangent-handle') return
if (target.name?.() === 'path-frame-dot') return
if (vertexMenu.value) vertexMenu.value = null
if (target === target.getStage()) {
store.selectLayer(null)
return
}
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
}
const match = store.activeLayers.find((l) => l.id === id)
store.selectLayer(match ? match.id : null)
}
function handleStageMouseMove(e: Konva.KonvaEventObject<MouseEvent>) {
if (boxSelect.value) {
const wp = pointerToWorld(e)
if (wp) boxSelect.value = { ...boxSelect.value, curX: wp.x, curY: wp.y }
return
}
if (store.drawingMode !== 'freeform') {
drawingPreview.value = null
return
}
const wp = pointerToWorld(e)
if (!wp) return
const layerId = store.drawingLayerId
const layer = layerId ? store.activeLayers.find((l) => l.id === layerId) : null
drawingPreview.value = layer ? worldToPolygonLocal(layer, wp.x, wp.y) : wp
}
function handleStageMouseUp() {
if (boxSelect.value) finishBoxSelect()
}
function onContainerDblClick(e: MouseEvent) {
const rect = container.value?.getBoundingClientRect()
if (!rect) return
const wx = (e.clientX - rect.left - viewX.value) / zoom.value
const wy = (e.clientY - rect.top - viewY.value) / zoom.value
// While drawing: finish (optionally close if near first point)
if (store.drawingMode === 'freeform') {
const assetId = store.drawingAssetId
const asset = assetId ? store.scene.assets.find((a) => a.id === assetId) : null
if (asset && asset.kind === 'polygon' && asset.points.length >= 3) {
const first = asset.points[0]
const threshold = 12 / zoom.value
if (Math.hypot(wx - first.x, wy - first.y) <= threshold) {
store.setPolygonClosed(asset.id, true)
}
}
store.finishDrawing()
return
}
// Not drawing: polygon-specific gestures on selected polygon
const selId = store.selectedLayerId
if (!selId) return
const layer = store.activeLayers.find((l) => l.id === selId)
if (!layer || layer.locked) return
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return
const sampled = sampleLayer(layer, store.playhead)
const pts = sampled.points ?? asset.points
if (pts.length < 1) return
const closed = asset.closed !== false
const local = worldToPolygonLocal(layer, wx, wy)
// Near an endpoint of an open polygon → resume drawing
if (!closed) {
const first = pts[0]
const last = pts[pts.length - 1]
const threshold = 14 / zoom.value
const nearFirst = Math.hypot(local.x - first.x, local.y - first.y) <= threshold
const nearLast =
pts.length > 1 && Math.hypot(local.x - last.x, local.y - last.y) <= threshold
if (nearLast || nearFirst) {
if (nearFirst && !nearLast) store.reversePolygonPoints(asset.id)
store.startFreeformDrawing()
return
}
}
// Click on (or near) an edge → insert a new vertex there
const edges = closed ? pts.length : pts.length - 1
if (edges < 1) return
let bestIdx = 1
let bestDist = Infinity
let bestT = 0
for (let i = 0; i < edges; i++) {
const a = pts[i]
const b = pts[(i + 1) % pts.length]
const dx = b.x - a.x
const dy = b.y - a.y
const lenSq = dx * dx + dy * dy
if (lenSq === 0) continue
let t = ((local.x - a.x) * dx + (local.y - a.y) * dy) / lenSq
t = Math.max(0, Math.min(1, t))
const px = a.x + t * dx
const py = a.y + t * dy
const d = Math.hypot(local.x - px, local.y - py)
if (d < bestDist) {
bestDist = d
bestIdx = i + 1
bestT = t
}
}
const edgeThreshold = 14 / zoom.value
if (bestDist <= edgeThreshold) {
store.insertPolygonPointOnEdge(asset.id, bestIdx, bestT)
}
}
function handleDrawingKey(e: KeyboardEvent) {
if (store.drawingMode !== 'freeform') return
if (e.key === 'Escape') {
e.preventDefault()
store.cancelDrawing()
} else if (e.key === 'Enter') {
e.preventDefault()
store.finishDrawing()
}
}
function transformLocalToWorld(
layer: Layer,
sampled: AnimatableProps,
local: { x: number; y: number },
): { x: number; y: number } {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
const lx = (local.x - offX) * sampled.scaleX
const ly = (local.y - offY) * sampled.scaleY
const theta = (sampled.rotation * Math.PI) / 180
const c = Math.cos(theta)
const s = Math.sin(theta)
return {
x: sampled.x + lx * c - ly * s,
y: sampled.y + lx * s + ly * c,
}
}
const drawingPreviewConfig = computed(() => {
if (store.drawingMode !== 'freeform') return null
const layerId = store.drawingLayerId
if (!layerId) return null
const layer = store.activeLayers.find((l) => l.id === layerId)
if (!layer) return null
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return null
const sampled = sampleLayer(layer, store.playhead)
const pts = sampled.points ?? asset.points
const preview = drawingPreview.value
const flat: number[] = []
for (const p of pts) {
const w = transformLocalToWorld(layer, sampled, p)
flat.push(w.x, w.y)
}
if (preview && pts.length > 0) {
const w = transformLocalToWorld(layer, sampled, preview)
flat.push(w.x, w.y)
}
if (flat.length < 4) return null
return {
points: flat,
stroke: '#6ea8ff',
strokeWidth: 1.5 / zoom.value,
dash: [6 / zoom.value, 4 / zoom.value],
strokeScaleEnabled: false,
listening: false,
}
})
const drawingDotsConfig = computed(() => {
if (store.drawingMode !== 'freeform') return []
const layerId = store.drawingLayerId
if (!layerId) return []
const layer = store.activeLayers.find((l) => l.id === layerId)
if (!layer) return []
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return []
const sampled = sampleLayer(layer, store.playhead)
const pts = sampled.points ?? asset.points
return pts.map((p, i) => {
const w = transformLocalToWorld(layer, sampled, p)
return {
key: `dp-${i}`,
x: w.x,
y: w.y,
radius: 3 / zoom.value,
fill: '#6ea8ff',
listening: false,
}
})
})
function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
const node = e.target
const id = node.id()
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return
const newX = node.x()
const newY = node.y()
// Multi-keyframe shift applies only when the layer being dragged is
// itself part of the current selection. Otherwise a stale selection on
// some other layer would steal the drag delta, leaving the dragged
// layer to snap back while the selected one moved instead.
const draggedInSelection = selectedKfs.value.some((k) => k.layerId === id)
if (draggedInSelection && anySelectedKfAtPlayhead()) {
const samp = sampleLayer(layer, store.playhead)
const dx = newX - samp.x
const dy = newY - samp.y
if (dx !== 0 || dy !== 0) {
store.shiftKeyframesPosition(selectedKfs.value, dx, dy)
}
} else {
store.upsertKeyframe(id, store.playhead, { x: newX, y: newY })
selectKfAtPlayhead(layer, layer.separated ? 'position' : 'main')
}
liveLayerPose.value = null
}
function anySelectedKfAtPlayhead(): boolean {
for (const k of selectedKfs.value) {
const l = store.activeLayers.find((x) => x.id === k.layerId)
if (!l) continue
const arr =
k.kind === 'position'
? l.positionKeyframes
: k.kind === 'rotation'
? l.rotationKeyframes
: l.keyframes
const kf = arr?.find((kf) => kf.id === k.keyframeId)
if (kf && Math.abs(kf.time - store.playhead) < 1e-3) return true
}
return false
}
function selectKfAtPlayhead(layer: Layer, kind: 'main' | 'position' | 'rotation') {
const arr =
kind === 'position'
? layer.positionKeyframes
: kind === 'rotation'
? layer.rotationKeyframes
: layer.keyframes
if (!arr) return
const kf = arr.find((k) => Math.abs(k.time - store.playhead) < 1e-3)
if (!kf) return
selectedKfs.value = [{ layerId: layer.id, kind, keyframeId: kf.id }]
}
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
const node = e.target
const id = node.id()
if (!store.activeLayers.some((l) => l.id === id)) return
store.upsertKeyframe(id, store.playhead, {
x: node.x(),
y: node.y(),
scaleX: node.scaleX(),
scaleY: node.scaleY(),
rotation: node.rotation(),
})
liveLayerPose.value = null
}
const ROT_ARM_PX = 44
const rotationHandlePos = ref<{ x: number; y: number } | null>(null)
const rotationHandleConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
if (draggingPivot.value) return null
if (rotationHandlePos.value) {
return {
x: rotationHandlePos.value.x,
y: rotationHandlePos.value.y,
draggable: true,
name: 'rotation-handle',
}
}
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
return {
x: info.sampled.x + arm * Math.sin(rad),
y: info.sampled.y - arm * Math.cos(rad),
draggable: true,
name: 'rotation-handle',
}
})
const rotationArmConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
if (draggingPivot.value) return null
let endX: number
let endY: number
if (rotationHandlePos.value) {
endX = rotationHandlePos.value.x
endY = rotationHandlePos.value.y
} else {
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
endX = info.sampled.x + arm * Math.sin(rad)
endY = info.sampled.y - arm * Math.cos(rad)
}
return {
points: [info.sampled.x, info.sampled.y, endX, endY],
stroke: '#6ea8ff',
strokeWidth: 1.25 / zoom.value,
dash: [3 / zoom.value, 3 / zoom.value],
listening: false,
}
})
const rotationKnobConfig = computed(() => ({
radius: 6 / zoom.value,
fill: '#ffffff',
stroke: '#6ea8ff',
strokeWidth: 2 / zoom.value,
}))
function onRotationHandleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const info = pivotInfo.value
if (!info) return
const node = e.target
const dx = node.x() - info.sampled.x
const dy = node.y() - info.sampled.y
if (dx === 0 && dy === 0) return
const raw = (Math.atan2(dx, -dy) * 180) / Math.PI
// Snap to the 360°-equivalent nearest to the previous rotation so small
// motions don't flip a full turn across the ±180° boundary.
const prev = liveLayerPose.value?.rotation ?? info.sampled.rotation
let deg = raw
while (deg - prev > 180) deg -= 360
while (deg - prev < -180) deg += 360
const base = liveLayerPose.value ?? {
layerId: info.layerId,
x: info.sampled.x,
y: info.sampled.y,
scaleX: info.sampled.scaleX,
scaleY: info.sampled.scaleY,
rotation: info.sampled.rotation,
}
liveLayerPose.value = { ...base, layerId: info.layerId, rotation: deg }
rotationHandlePos.value = { x: node.x(), y: node.y() }
}
function onRotationHandleDragEnd() {
const info = pivotInfo.value
const live = liveLayerPose.value
if (info && live && live.layerId === info.layerId) {
store.upsertKeyframe(info.layerId, store.playhead, { rotation: live.rotation })
}
rotationHandlePos.value = null
liveLayerPose.value = null
}
let ro: ResizeObserver | null = null
function onWheel(e: WheelEvent) {
e.preventDefault()
const rect = container.value?.getBoundingClientRect()
if (!rect) return
const sx = e.clientX - rect.left
const sy = e.clientY - rect.top
const logX = (sx - viewX.value) / zoom.value
const logY = (sy - viewY.value) / zoom.value
const factor = Math.pow(1.0015, -e.deltaY)
const newZoom = clamp(zoom.value * factor, ZOOM_MIN, ZOOM_MAX)
zoom.value = newZoom
viewX.value = sx - logX * newZoom
viewY.value = sy - logY * newZoom
}
let panning = false
let panStartX = 0
let panStartY = 0
let panStartViewX = 0
let panStartViewY = 0
let panPointerId = -1
function isInsideCanvasBounds(e: PointerEvent | MouseEvent): boolean {
const rect = container.value?.getBoundingClientRect()
if (!rect) return true
const localX = e.clientX - rect.left
const localY = e.clientY - rect.top
const cw = effectiveCanvas.value.width * zoom.value
const ch = effectiveCanvas.value.height * zoom.value
return (
localX >= viewX.value &&
localX <= viewX.value + cw &&
localY >= viewY.value &&
localY <= viewY.value + ch
)
}
function pointerHitsKonvaShape(e: PointerEvent | MouseEvent): boolean {
// Transformer handles, vertex dots, and stage shapes can live
// outside the canvas rectangle (selection box for a shape near the
// edge, a polygon vertex dragged past the border, …). Asking Konva
// for an intersection at the pointer lets us tell "user grabbed a
// handle" from "user clicked the empty background" even when both
// happen outside the canvas bounds.
const stage = stageRef.value?.getStage()
if (!stage) return false
const rect = stage.container().getBoundingClientRect()
const hit = stage.getIntersection({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
})
return !!hit
}
function isPanTrigger(e: PointerEvent | MouseEvent) {
if (e.button === 1) return true
if (e.button === 0 && e.ctrlKey) return true
// Plain left-click on the empty content view (outside the canvas
// rectangle) pans the viewport too — saves a ctrl-modifier for the
// common case of re-centering after zoom. Skip panning when the
// click is actually on a Konva shape: a transformer handle, a
// polygon vertex, or a selection box that extends past the canvas
// edge still belongs to its owning shape and must stay draggable.
if (e.button === 0 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && !isInsideCanvasBounds(e) && !pointerHitsKonvaShape(e)) {
return true
}
return false
}
function onContainerPointerDown(e: PointerEvent) {
if (!isPanTrigger(e)) return
e.preventDefault()
panning = true
panStartX = e.clientX
panStartY = e.clientY
panStartViewX = viewX.value
panStartViewY = viewY.value
panPointerId = e.pointerId
container.value?.setPointerCapture(e.pointerId)
if (container.value) container.value.style.cursor = 'grabbing'
}
function onContainerMouseDownCapture(e: MouseEvent) {
if (isPanTrigger(e)) {
e.stopPropagation()
e.preventDefault()
}
}
function onContainerPointerMove(e: PointerEvent) {
if (!panning || e.pointerId !== panPointerId) return
viewX.value = panStartViewX + (e.clientX - panStartX)
viewY.value = panStartViewY + (e.clientY - panStartY)
}
function onContainerPointerUp(e: PointerEvent) {
if (e.pointerId !== panPointerId) return
panning = false
container.value?.releasePointerCapture?.(e.pointerId)
if (container.value) container.value.style.cursor = ''
}
function onContainerDragOver(e: DragEvent) {
if (!e.dataTransfer) return
const types = e.dataTransfer.types
if (
!types.includes('application/x-animatr-asset') &&
!types.includes('application/x-animatr-clipart')
)
return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}
async function importClipartAt(file: File, worldX: number, worldY: number) {
await importImageFile(store, file, { position: { x: worldX, y: worldY } })
}
function onContainerDrop(e: DragEvent) {
const rect = container.value?.getBoundingClientRect()
if (!rect) return
const sx = e.clientX - rect.left
const sy = e.clientY - rect.top
const worldX = (sx - viewX.value) / zoom.value
const worldY = (sy - viewY.value) / zoom.value
const clipartIdx = e.dataTransfer?.getData('application/x-animatr-clipart')
if (clipartIdx) {
e.preventDefault()
const idx = Number.parseInt(clipartIdx, 10)
const item = Number.isFinite(idx) ? clipart.items[idx] : null
if (item) {
void importClipartAt(item.file, worldX, worldY)
}
return
}
const assetId = e.dataTransfer?.getData('application/x-animatr-asset')
if (!assetId) return
e.preventDefault()
const layer = store.addLayer(assetId)
if (layer) {
store.upsertKeyframe(layer.id, 0, { x: worldX, y: worldY })
}
}
function resizePreviewCanvas() {
const canvas = previewCanvasEl.value
if (!canvas) return
const dpr = window.devicePixelRatio || 1
const cw = containerSize.value.width
const ch = containerSize.value.height
canvas.width = Math.max(1, Math.round(cw * dpr))
canvas.height = Math.max(1, Math.round(ch * dpr))
canvas.style.width = `${cw}px`
canvas.style.height = `${ch}px`
}
function drawPreview() {
const canvas = previewCanvasEl.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
const cw = containerSize.value.width
const ch = containerSize.value.height
if (!cw || !ch) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, cw, ch)
ctx.save()
ctx.translate(viewX.value, viewY.value)
ctx.scale(zoom.value, zoom.value)
// In button-edit mode the preview should reflect the *button*'s stage,
// not the main scene behind it. `store.playhead` is already pinned to
// BUTTON_STATE_TIMES[activeButtonState] (or to normal in hit mode), so
// we can pass it straight through.
if (store.activeButtonAsset) {
drawButtonFrame(
ctx,
store.activeButtonAsset,
store.scene,
store.playhead,
previewImages.value,
{ background: '#1a1a1f' },
)
} else {
drawSceneFrame(ctx, store.scene, store.playhead, previewImages.value, {
crop: cropObjects.value,
})
}
ctx.restore()
}
watch(
[
() => store.playhead,
() => store.scene,
() => containerSize.value,
zoom,
viewX,
viewY,
cropObjects,
],
async () => {
if (!maskPreview.value) return
await nextTick()
resizePreviewCanvas()
drawPreview()
},
{ deep: true },
)
watch(maskPreview, async (on) => {
if (!on) return
previewImages.value = await loadSceneImages(store.scene)
await nextTick()
resizePreviewCanvas()
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
if (!cw || !ch) return
const sx = cw / 2
const sy = ch / 2
const logX = (sx - viewX.value) / zoom.value
const logY = (sy - viewY.value) / zoom.value
const newZoom = clamp(zoom.value * factor, ZOOM_MIN, ZOOM_MAX)
zoom.value = newZoom
viewX.value = sx - logX * newZoom
viewY.value = sy - logY * newZoom
}
onMounted(() => {
if (!container.value) return
ro = new ResizeObserver((entries) => {
const rect = entries[0].contentRect
containerSize.value = { width: rect.width, height: rect.height }
if (!didInitialFit && rect.width > 0 && rect.height > 0) {
fitToContainer()
didInitialFit = true
}
})
ro.observe(container.value)
container.value.addEventListener('wheel', onWheel, { passive: false })
container.value.addEventListener('mousedown', onContainerMouseDownCapture, { capture: true })
container.value.addEventListener('pointerdown', onContainerPointerDown)
container.value.addEventListener('pointermove', onContainerPointerMove)
container.value.addEventListener('pointerup', onContainerPointerUp)
container.value.addEventListener('pointercancel', onContainerPointerUp)
container.value.addEventListener('dragover', onContainerDragOver)
container.value.addEventListener('drop', onContainerDrop)
container.value.addEventListener('dblclick', onContainerDblClick)
window.addEventListener('keydown', handleDrawingKey)
})
onBeforeUnmount(() => {
ro?.disconnect()
ro = null
container.value?.removeEventListener('wheel', onWheel)
container.value?.removeEventListener('mousedown', onContainerMouseDownCapture, { capture: true } as EventListenerOptions)
container.value?.removeEventListener('pointerdown', onContainerPointerDown)
container.value?.removeEventListener('pointermove', onContainerPointerMove)
container.value?.removeEventListener('pointerup', onContainerPointerUp)
container.value?.removeEventListener('pointercancel', onContainerPointerUp)
container.value?.removeEventListener('dragover', onContainerDragOver)
container.value?.removeEventListener('drop', onContainerDrop)
container.value?.removeEventListener('dblclick', onContainerDblClick)
window.removeEventListener('keydown', handleDrawingKey)
})
</script>
<template>
<div ref="container" class="stage-container" :class="{ drawing: store.drawingMode === 'freeform' }">
<video
v-if="store.scene.backgroundVideo"
ref="bgVideoEl"
class="bg-video"
:style="bgVideoStyle"
:src="store.scene.backgroundVideo.src"
muted
playsinline
preload="auto"
></video>
<v-stage
ref="stageRef"
:config="stageConfig"
@mousedown="handleStageMouseDown"
@mousemove="handleStageMouseMove"
@mouseup="handleStageMouseUp"
>
<v-layer>
<v-group :config="viewConfig">
<v-group :config="clipGroupConfig">
<v-rect :config="backgroundConfig" />
<template
v-for="(entry, i) in stackItems"
:key="
entry.kind === 'layer'
? `l:${entry.item.layer.id}`
: `ci:${entry.bundle.instance.id}:${i}`
"
>
<template v-if="entry.kind === 'layer'">
<v-group
v-if="entry.item.asset.kind === 'button' && buttonAssetFor(entry.item.asset)"
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
@mouseenter="onButtonEnter(entry.item.layer)"
@mouseleave="onButtonLeave(entry.item.layer)"
@mousedown="onButtonDown(entry.item.layer)"
@mouseup="onButtonUp(entry.item.layer)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
>
<v-rect
:config="buttonHitCatcherConfig(buttonAssetFor(entry.item.asset)!, entry.item.layer)"
/>
<template
v-for="(sub, bli) in buttonStackForTime(buttonAssetFor(entry.item.asset)!, buttonTimeFor(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"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
<v-circle
v-else-if="entry.item.asset.kind === 'circle'"
:config="circleConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
<v-text
v-else-if="entry.item.asset.kind === 'text'"
:config="textConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
<v-line
v-else-if="entry.item.asset.kind === 'polygon'"
:config="polygonConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
<v-image
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
</template>
<template v-else>
<v-group
:config="clipInstanceGroupConfig(entry.bundle.instance)"
@mousedown="store.selectClipInstance(entry.bundle.instance.id)"
@dragend="onClipInstanceDragEnd($event, entry.bundle.instance.id)"
@transformend="onClipInstanceTransformEnd($event, entry.bundle.instance.id)"
>
<template
v-for="sub in entry.bundle.items"
:key="`ci:${entry.bundle.instance.id}:${sub.layer.id}`"
>
<v-rect
v-if="sub.asset.kind === 'rect'"
:config="clipRectConfig(sub)"
/>
<v-circle
v-else-if="sub.asset.kind === 'circle'"
:config="clipCircleConfig(sub)"
/>
<v-text
v-else-if="sub.asset.kind === 'text'"
:config="clipTextConfig(sub)"
/>
<v-line
v-else-if="sub.asset.kind === 'polygon'"
:config="clipPolygonConfig(sub)"
/>
<v-image
v-else-if="sub.asset.kind === 'image' && clipImageConfig(sub)"
:config="clipImageConfig(sub)!"
/>
</template>
</v-group>
</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"
:config="pivotHandleConfig"
@dragstart="onPivotDragStart"
@dragend="onPivotDragEnd"
>
<v-circle :config="pivotRingConfig" />
<v-circle :config="pivotDotConfig" />
</v-group>
<v-group
v-if="rotationHandleConfig"
:config="rotationHandleConfig"
@dragmove="onRotationHandleDragMove"
@dragend="onRotationHandleDragEnd"
>
<v-circle :config="rotationKnobConfig" />
</v-group>
<v-circle
v-for="h in polygonVertexHandles"
:key="`vh-${h.layerId}-${h.index}`"
:config="vertexHandleConfig(h)"
@click="(e: Konva.KonvaEventObject<MouseEvent>) => onVertexClick(e, h.index)"
@dragstart="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragStart(e, h.index)"
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragMove(e, h.index)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragEnd(e, h.index)"
@contextmenu="(e: Konva.KonvaEventObject<PointerEvent>) => onVertexContextMenu(e, h.index)"
/>
<v-line
v-for="tl in polygonTangentLines"
:key="tl.key"
:config="tl.config"
/>
<v-circle
v-for="th in polygonTangentHandles"
:key="th.key"
:config="tangentHandleConfig(th)"
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onTangentDrag(e, th)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onTangentDrag(e, th)"
/>
<v-line
v-for="g in animationGhostSegments"
:key="g.key"
:config="g.config"
/>
<v-line
v-for="line in animationPathLines"
:key="line.key"
:config="line.config"
/>
<v-circle
v-for="d in animationPathDots"
:key="d.key"
:config="animationDotConfig(d)"
@mouseenter="onPathDotMouseEnter(d)"
@mouseleave="onPathDotMouseLeave"
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
/>
<v-line v-if="drawingPreviewConfig" :config="drawingPreviewConfig" />
<v-circle
v-for="d in drawingDotsConfig"
:key="d.key"
:config="d"
/>
</v-group>
<v-transformer ref="transformerRef" :config="transformerConfig" />
</v-layer>
</v-stage>
<canvas
v-show="maskPreview"
ref="previewCanvasEl"
class="mask-preview-canvas"
></canvas>
<div
v-if="boxSelectRectPx"
class="box-select"
:style="{
left: boxSelectRectPx.left + 'px',
top: boxSelectRectPx.top + 'px',
width: boxSelectRectPx.width + 'px',
height: boxSelectRectPx.height + 'px',
}"
></div>
<div
v-if="store.activeButtonAssetId && store.activeButtonEditMode === 'hit'"
class="hit-tools"
@pointerdown.stop
>
<button class="hit-btn" v-tooltip="t('canvas.hit.addRect')" @click="addHitRect">
<PhSquare :size="13" />
<span>rect</span>
</button>
<button class="hit-btn" v-tooltip="t('canvas.hit.addCircle')" @click="addHitCircle">
<PhCircle :size="13" />
<span>circle</span>
</button>
<button class="hit-btn" v-tooltip="t('canvas.hit.addPolygon')" @click="addHitPolygon">
<PhPolygon :size="13" />
<span>polygon</span>
</button>
<button
class="hit-btn danger"
:disabled="selectedHitShapeIndex === null"
v-tooltip="t('canvas.hit.remove')"
@click="removeSelectedHitShape"
>
<PhTrash :size="13" />
</button>
</div>
<div class="zoom-controls" @pointerdown.stop>
<button class="zc-btn" v-tooltip="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
<button class="zc-label" v-tooltip="t('canvas.fitToView')" @click="fitToContainer">
{{ zoomPercent }}%
</button>
<button class="zc-btn" v-tooltip="t('canvas.zoomIn')" @click="zoomBy(1.25)">+</button>
<div class="zc-sep"></div>
<button class="zc-btn wide" v-tooltip="t('canvas.zoomOneToOne')" @click="zoomToOne">1:1</button>
<button class="zc-btn" v-tooltip="t('canvas.fitToView')" @click="fitToContainer">
<PhCornersIn :size="13" />
</button>
<div class="zc-sep"></div>
<button
class="zc-btn crop-btn"
:class="{ active: cropObjects }"
v-tooltip="cropObjects ? t('canvas.cropOff') : t('canvas.cropOn')"
@click="cropObjects = !cropObjects"
>
<PhCrop :size="13" />
</button>
<button
class="zc-btn"
:class="{ active: maskPreview }"
v-tooltip="maskPreview ? t('canvas.maskPreviewOff') : t('canvas.maskPreviewOn')"
@click="maskPreview = !maskPreview"
>
<PhMaskHappy v-if="!maskPreview" :size="13" />
<PhPencilSimple v-else :size="13" />
</button>
<button
class="zc-btn stage-preview-btn"
:class="{ active: stagePreview }"
v-tooltip="stagePreview ? t('canvas.stagePreviewOff') : t('canvas.stagePreviewOn')"
@click="stagePreview = !stagePreview"
>
<PhCursorClick :size="13" />
</button>
</div>
<div class="dims">{{ effectiveCanvas.width }} × {{ effectiveCanvas.height }}</div>
<div
v-if="vertexMenu"
class="vertex-menu"
:style="{ left: vertexMenu.screenX + 'px', top: vertexMenu.screenY + 'px' }"
@mouseleave="closeVertexMenu"
>
<button
class="vertex-menu-item"
:class="{ active: vertexMenu.currentType === 'corner' }"
@click="applyVertexType('corner')"
>
{{ t('canvas.vertexCorner') }}
</button>
<button
class="vertex-menu-item"
:class="{ active: vertexMenu.currentType === 'curve' }"
@click="applyVertexType('curve')"
>
{{ t('canvas.vertexCurve') }}
</button>
<button
class="vertex-menu-item"
:class="{ active: vertexMenu.currentType === 'splitcurve' }"
@click="applyVertexType('splitcurve')"
>
{{ t('canvas.vertexSplitCurve') }}
</button>
</div>
</div>
</template>
<style scoped>
.stage-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.bg-video {
position: absolute;
object-fit: cover;
pointer-events: none;
user-select: none;
z-index: 0;
}
.stage-container.drawing {
cursor: crosshair;
}
.vertex-menu {
position: absolute;
display: flex;
flex-direction: column;
min-width: 140px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md, 0 4px 12px rgba(0, 0, 0, 0.3));
z-index: 20;
padding: 4px;
}
.vertex-menu-item {
text-align: left;
padding: 6px 10px;
font-size: 12px;
border: none;
background: transparent;
color: var(--text);
border-radius: var(--radius-sm);
cursor: pointer;
}
.vertex-menu-item:hover {
background: var(--panel-alt);
}
.vertex-menu-item.active {
background: var(--accent-soft);
color: var(--accent);
}
.mask-preview-canvas {
position: absolute;
inset: 0;
pointer-events: none;
background: var(--bg);
}
.box-select {
position: absolute;
pointer-events: none;
border: 1px dashed var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
z-index: 15;
}
.dims {
position: absolute;
bottom: 10px;
right: 12px;
color: var(--text-dim);
font-size: 11px;
font-variant-numeric: tabular-nums;
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;
left: 12px;
display: flex;
align-items: center;
gap: 2px;
padding: 3px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
user-select: none;
}
.zc-btn {
width: 26px;
height: 24px;
padding: 0;
justify-content: center;
background: transparent;
border: none;
color: var(--text-dim);
font-size: 13px;
line-height: 1;
border-radius: var(--radius-sm);
}
.zc-btn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.zc-btn.wide {
width: auto;
padding: 0 8px;
font-size: 11px;
font-weight: 500;
}
.zc-btn.active {
background: var(--accent-soft);
color: var(--accent);
}
.zc-btn.active:hover:not(:disabled) {
background: var(--accent-soft-strong);
color: var(--accent);
}
.zc-label {
min-width: 54px;
height: 24px;
padding: 0 8px;
justify-content: center;
background: transparent;
border: none;
color: var(--text-dim-strong);
font-variant-numeric: tabular-nums;
font-size: 11px;
border-radius: var(--radius-sm);
}
.zc-label:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.zc-sep {
width: 1px;
height: 16px;
background: var(--border);
margin: 0 2px;
}
</style>