Files
RUWIO/src/components/CanvasStage.vue
T
th 3bc329813c 1.2.1: strokes scale with the layer (incl. inside SVG-asset placements)
- strokeCfg now defaults to strokeScaleEnabled:true so stroked rect /
  circle / text outlines grow proportionally when the layer is scaled
  (Konva default; previously hard-pinned to false).
- polygonConfig and clipPolygonConfig had inline stroke blocks that
  also forced strokeScaleEnabled:false. Flipped both to true so
  freeform / polygon outlines scale alongside the shape.
- SVG-asset placements use a dedicated svgChildConfig wrapper +
  pre-multiplied strokeWidth in svgStackForAsset: each child's stroke
  is bumped by the placement's geometric-mean scale and rendered with
  strokeScaleEnabled:false (Konva resets the ctx to identity before
  stroking) so the on-screen width lands at the right pixel count
  regardless of how Konva propagates parent transforms to nested
  stroke rendering. Konva-mounted tests cover outer-scale 1×, 3× and
  non-uniform 4×/1×.

UI chrome (transformer borders, vertex / tangent handles, hit-shape
outlines, animation-path ghosts, ghost-locked layer indicators) keeps
strokeScaleEnabled:false intentionally — those should stay at constant
pixel width regardless of zoom.
2026-04-25 22:45:01 +02:00

4933 lines
157 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,
PhCrosshair,
PhCursorClick,
PhFrameCorners,
PhMaskHappy,
PhPencilSimple,
PhPolygon,
PhSlidersHorizontal,
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 { useDocumentDrawer } from '@/composables/useDocumentDrawer'
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
import type {
Action,
AnimatableProps,
Asset,
ButtonAsset,
ButtonState,
ClipInstance,
HitShape,
Keyframe,
Layer,
Point,
SvgAsset,
} 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 { useDeselectAll } from '@/composables/useDeselectAll'
import { useKfSelection } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked'
import { expandedTrackOrder } from '@/lib/sceneOrder'
import { CLIP_FALLBACK_BOUNDS, computeClipBounds, type Rect } from '@/lib/clipBounds'
const store = useSceneStore()
const clipart = useClipartStore()
const { selectedKfs } = useKfSelection()
const deselectAll = useDeselectAll()
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)
// Canvas zoom / pan / overlay toggles live on the store so the
// project file can round-trip the workbench state. Thin
// writableComputed shims keep the rest of this component's code
// (which reads/writes `zoom.value`, `cropObjects.value = …`, etc.)
// unchanged.
const zoom = computed<number>({
get: () => store.editor.canvasZoom,
set: (v) => { store.editor.canvasZoom = v },
})
const viewX = computed<number>({
get: () => store.editor.canvasViewX,
set: (v) => { store.editor.canvasViewX = v },
})
const viewY = computed<number>({
get: () => store.editor.canvasViewY,
set: (v) => { store.editor.canvasViewY = v },
})
const cropObjects = computed<boolean>({
get: () => store.editor.cropObjects,
set: (v) => { store.editor.cropObjects = v },
})
const maskPreview = computed<boolean>({
get: () => store.editor.maskPreview,
set: (v) => { store.editor.maskPreview = v },
})
// Dashed horizontal + vertical lines through the center of the
// effective canvas — a composition guide the user can toggle on/off
// from the HUD.
const centerCrosshair = computed<boolean>({
get: () => store.editor.centerCrosshair,
set: (v) => { store.editor.centerCrosshair = v },
})
// 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 = computed<boolean>({
get: () => store.editor.stagePreview,
set: (v) => { store.editor.stagePreview = v },
})
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 = effectiveCanvas.value
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 = effectiveCanvas.value
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 sa = store.activeSvgAsset
if (sa) return sa.canvas
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[]>(() => {
// Inline handles are a button-asset only affordance. SVG-asset
// resize goes through the existing 8-handle stage-resize mode
// (right-click → "Stage resize") so the canvas stays visually
// clean instead of looking permanently in-resize.
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 sa = store.activeSvgAsset
const ba = store.activeButtonAsset
const target = sa ?? ba
if (!target) return
const node = e.target
const s = 10 / zoom.value
let w = target.canvas.width
let h = target.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))
}
if (sa) store.setSvgAssetCanvas(sa.id, w, h)
else if (ba) 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 }
}
/** SVG-asset child wrapper. Same listening/drag config as button
* children, but also pins `strokeScaleEnabled: false`. svgStackForAsset
* pre-multiplies each child's strokeWidth by the outer placement
* scale; combined with strokeScaleEnabled:false (Konva resets the
* ctx transform to identity before stroking), the on-screen stroke
* width comes out at the right number of pixels regardless of
* whether Konva propagates the parent group's scale to nested
* stroke rendering. */
function svgChildConfig<T extends Record<string, unknown>>(cfg: T): T {
return { ...cfg, draggable: false, listening: false, strokeScaleEnabled: false }
}
/** Invisible Konva rect that covers the SVG asset's canvas rect.
* Children are non-listening (buttonChildConfig) so without a hit
* surface the layer would fall through to the stage and stay
* unselectable. The catcher routes mousedown to the stage handler
* via its `svg-hit:${id}` name, mirroring the button-hit pattern. */
function svgHitCatcherConfig(asset: SvgAsset, layer: Layer) {
return {
x: 0,
y: 0,
width: asset.canvas.width,
height: asset.canvas.height,
fill: 'rgba(0,0,0,0.001)',
listening: !isLayerEffectivelyLocked(layer),
name: `svg-hit:${layer.id}`,
}
}
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
}
/** Static render stack for an SVG asset placed in the main scene.
* All child keyframes live at t=0 by construction, so there's no
* per-state lookup — just sample each visible layer once.
*
* `outerScale` is the placement layer's scale factor; child stroke
* widths get pre-multiplied by it so the rendered outline grows
* proportionally with the outer scale. Konva's strokeScaleEnabled
* only honours the *node's own* scale, not the cumulative parent
* transform — without this pre-multiply, strokes inside a scaled
* SVG asset would render at constant pixel width even though every
* shape inside grows. */
function svgStackForAsset(
asset: SvgAsset,
outerScale = 1,
): 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
if (l.editorHidden) continue
const a = assetMap.value.get(l.assetId)
if (!a) continue
const props = sampleLayer(l, 0)
// Resolve effective stroke width from props ?? asset, then scale.
// strokeCfg looks at props.strokeWidth first; setting it here
// overrides the asset fallback for this render only.
const baseSw =
props.strokeWidth ??
('strokeWidth' in a ? (a.strokeWidth as number | undefined) : undefined) ??
0
if (outerScale !== 1 && baseSw > 0) {
props.strokeWidth = baseSw * outerScale
}
out.push({ layer: l, asset: a, props })
}
return out
}
function svgAssetFor(asset: Asset): SvgAsset | null {
return asset.kind === 'svg' ? asset : null
}
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)
},
}
})
/** Horizontal + vertical centre-crosshair line configs. Null when
* the toggle is off. Lines span the effective canvas so the centre
* is exactly where layer keyframes default to (canvas.width/2,
* canvas.height/2). Stroke + dash are scaled by `1 / zoom` so they
* stay 1 screen-pixel thick and the dashes stay the same visual
* length at any zoom level. `listening: false` keeps the lines out
* of hit-testing — they're purely a visual guide. */
const centerCrosshairConfigs = computed(() => {
if (!centerCrosshair.value) return null
const w = effectiveCanvas.value.width
const h = effectiveCanvas.value.height
const cx = w / 2
const cy = h / 2
const z = zoom.value || 1
const strokeColor = 'rgba(79, 140, 255, 0.7)'
const strokeWidth = 1 / z
const dash = [6 / z, 4 / z]
return {
horizontal: {
points: [0, cy, w, cy],
stroke: strokeColor,
strokeWidth,
dash,
listening: false,
},
vertical: {
points: [cx, 0, cx, h],
stroke: strokeColor,
strokeWidth,
dash,
listening: false,
},
}
})
const backgroundConfig = computed(() => {
// SVG-asset edit: same dark "outside the document" fill as the
// button editor so the user sees where the asset's canvas ends.
// The asset itself composites with alpha; the dark backdrop is
// visual chrome only.
if (store.activeSvgAssetId) {
return {
x: 0,
y: 0,
width: effectiveCanvas.value.width,
height: effectiveCanvas.value.height,
listening: false,
fill: '#1a1a1f',
}
}
// 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
/** Union bbox of every clip layer across its full animation — the
* stable frame that the transformer wraps. Null when the clip has
* no measurable layers (template falls back to a placeholder). */
bounds: Rect | null
items: RenderedClipLayer[]
}
type StackItem =
| { kind: 'layer'; item: RenderedLayer }
| { kind: 'clipInstance'; bundle: RenderedClipInstance }
function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps {
const live = liveLayerPoses.value[layerId]
if (!live) 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
}
// Svg-asset edit scope: render the asset's child layers at t=0.
// No time-axis filtering — every visible layer always shows.
if (store.activeSvgAssetId) {
const out: StackItem[] = []
const sa = store.activeSvgAsset
if (!sa) return out
const layers = sa.layers.slice().reverse()
for (const layer of layers) {
if (!layer.visible) continue
if (layer.editorHidden) continue
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
out.push({
kind: 'layer',
item: {
layer,
asset,
props: applyLivePose(sampleLayer(layer, 0), layer.id),
},
})
}
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 clipTrackById = new Map<string, NonNullable<typeof store.scene.clipTracks>[number]>()
for (const t of store.scene.clipTracks ?? []) clipTrackById.set(t.id, t)
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)
}
// Tracks whose parent TrackGroup is editor-hidden — same cascade
// as the Timeline's lockedGroupChildIds logic, just for the eye
// toggle. A child with its own editorHidden flag unset still
// disappears if its group carries one.
const hiddenGroupChildIds = new Set<string>()
for (const g of store.scene.groups ?? []) {
if (g.editorHidden) for (const cid of g.childIds) hiddenGroupChildIds.add(cid)
}
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]
if (hiddenGroupChildIds.has(id)) continue
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
}
// Editor-only hide for an entire clip track — skip every instance
// on it in the Konva stack so the stage matches the eye-icon
// state. Exports still see the instances unless the user opts
// into strip-hidden.
if (clipTrackById.get(id)?.editorHidden) 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),
})
}
// Always push the bundle when the instance is in time-range,
// even if no items are renderable at this playhead: the frame
// rect still needs to render so the clip can be selected (and
// the transformer framed) on empty frames.
out.push({
kind: 'clipInstance',
bundle: {
instance: inst,
bounds: computeClipBounds(clip, assetMap.value),
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
}
/** Clip-track counterpart to `isLayerEffectivelyLocked`. Returns true
* when the ClipTrack's own `locked` is set OR the track sits inside
* a locked group. Used to disable the ci-group's listening + drag so
* a locked clip behaves as if it weren't on the stage — the user
* can't select, drag, or transform its instance. */
function isClipTrackEffectivelyLocked(trackId: string): boolean {
const track = store.scene.clipTracks?.find((t) => t.id === trackId)
if (track?.locked) return true
for (const g of store.scene.groups ?? []) {
if (g.locked && g.childIds.includes(trackId)) 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 }
// Strokes scale with the layer (Konva default). Set explicitly:
// Konva's bounding-box / hit / cache paths can default to the
// shape's previous attribute value when the prop is reactively
// omitted, so `strokeScaleEnabled: true` lands on the node either
// way after the first render.
return { stroke, strokeWidth, strokeScaleEnabled: true }
}
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
// Default Konva behaviour: stroke scales with the node's own
// scale. Previously hard-pinned to false, which left freeform /
// polygon outlines at constant pixel width when the user scaled
// the layer. svgChildConfig overrides this back to false for
// SVG-asset placements (which pre-multiply strokeWidth instead).
cfg.strokeScaleEnabled = true
} 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) {
// A locked clip track must behave on the stage as if its instance
// weren't there at all — clicks pass through, drag is off, and the
// transformer can't latch on. `listening: false` on the group
// cascades to every descendant (frame rect + inner shapes), so a
// single flag covers the selection, drag, and transform paths.
const locked = isClipTrackEffectivelyLocked(inst.clipTrackId)
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: !locked,
listening: !locked,
}
}
/** Invisible frame rect sized to the clip's union bbox. Sits as the
* first child of the ci-group so:
* - the group's client rect is dominated by this stable rectangle,
* keeping transformer handles at the clip's outer constraints
* instead of jittering with animated inner content;
* - clicks anywhere in the clip's spatial footprint (including
* empty playhead frames) still hit this rect, bubble to the group
* and select the instance. `fill: 'black' + opacity: 0` renders
* nothing but still paints the hit graph, so listening works. */
function clipFrameConfig(instanceId: string, bounds: Rect | null) {
const b = bounds ?? CLIP_FALLBACK_BOUNDS
return {
id: `ci-frame:${instanceId}`,
name: 'ci-frame',
x: b.x,
y: b.y,
width: Math.max(1, b.width),
height: Math.max(1, b.height),
fill: 'black',
opacity: 0,
listening: 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
// Match the regular polygonConfig: strokes scale with the
// shape's transform so a scaled clip-instance polygon's outline
// grows proportionally instead of staying at constant pixel
// width.
cfg.strokeScaleEnabled = true
}
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 {
x: number
y: number
scaleX: number
scaleY: number
rotation: number
}
/** Per-layer live pose keyed by layer id. Multiple entries exist
* during a multi-select drag or transform — Konva mutates each
* node in place and fires @transform / @dragmove per node, and
* we record each one so the reactive v-rect / v-line / v-image
* configs don't snap non-focused nodes back to their sampled
* pose while Konva is mid-way through rotating the group. */
const liveLayerPoses = ref<Record<string, LiveLayerPose>>({})
function clearLivePoses() {
liveLayerPoses.value = {}
}
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 = liveLayerPoses.value[id]
if (live) {
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(() => {
// Per-layer pivot only makes sense for a single selection. Multi
// selection has its own temp-pivot block below (SVG scope only).
if (store.selectedLayerIds.length > 1) return null
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[]>(() => {
// Vertex handles only apply to a SOLO polygon selection — in a
// mixed multi-selection the shared transformer bounding box owns
// scale / rotate, and per-vertex handles would visually fight it.
if (store.selectedLayerIds.length > 1) return []
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 = liveLayerPoses.value[id]
const cx = live ? live.x : sampled.x
const cy = live ? live.y : sampled.y
const rot = live ? live.rotation : sampled.rotation
const scX = live ? live.scaleX : sampled.scaleX
const scY = live ? 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()
// Stop the DOM-level contextmenu from bubbling to the
// .stage-container handler, which would otherwise pop the
// generic stage menu (Document settings / Stage resize) on top
// of our vertex menu.
e.evt.stopPropagation()
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 = liveLayerPoses.value[id]
const cx = live ? live.x : sampled.x
const cy = live ? live.y : sampled.y
const rot = live ? live.rotation : sampled.rotation
const scX = live ? live.scaleX : sampled.scaleX
const scY = live ? 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
/** 'vertex' reuses the existing polygon-vertex lasso; 'layer'
* intersects every unlocked layer's bbox with the drag rect to
* build a scene-level multi-selection. */
mode: 'vertex' | 'layer'
/** Shift-held at drag start → add the marquee's hits to the
* existing selection rather than replacing it. A tiny shift
* click also leaves the current selection untouched. */
additive: boolean
} | 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 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)
if (b.mode === 'vertex') {
const sel = selectedPolygonLayer.value
if (!sel) return
const { layer, asset } = sel
const sampled = sampleLayer(layer, store.playhead)
const pts = sampled.points ?? asset.points
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
// Tiny click with no vertex hits → treat as an empty-stage
// click and deselect the polygon layer entirely, matching how
// layer-mode handles the same gesture. Without this a polygon
// (or freshly-drawn freeform) stays selected forever.
const dPxX = Math.abs(b.curX - b.startX) * zoom.value
const dPxY = Math.abs(b.curY - b.startY) * zoom.value
if (indices.size === 0 && dPxX < 3 && dPxY < 3) {
deselectAll()
}
return
}
// Layer mode — touch to select, not "fully enclosed". Clicks
// without drag (≤3 screen-px wiggle) fall through to the
// "click-empty-stage → deselect" behaviour so the handler still
// covers both interactions from a single pointerdown on the
// empty stage. Shift on a tiny click is a no-op (keeps the
// existing selection intact).
const draggedX = Math.abs(b.curX - b.startX) * zoom.value
const draggedY = Math.abs(b.curY - b.startY) * zoom.value
if (draggedX < 3 && draggedY < 3) {
if (!b.additive) deselectAll()
return
}
const stage = stageRef.value?.getStage()
if (!stage) {
if (!b.additive) deselectAll()
return
}
const hits: string[] = []
for (const layer of store.activeLayers) {
if (isLayerEffectivelyLocked(layer)) continue
if (layer.editorHidden) continue
if (!isLayerActiveAt(layer, store.playhead)) continue
const node = stage.findOne<Konva.Node>(`#${layer.id}`)
if (!node) continue
// getClientRect returns bbox in stage (absolute) coords —
// convert to world coords by undoing the view zoom/pan so we
// can intersect against the world-space drag rect.
const rect = node.getClientRect({ skipTransform: false })
const z = zoom.value || 1
const rx0 = (rect.x - viewX.value) / z
const ry0 = (rect.y - viewY.value) / z
const rx1 = rx0 + rect.width / z
const ry1 = ry0 + rect.height / z
if (rx1 < minX || rx0 > maxX || ry1 < minY || ry0 > maxY) continue
hits.push(layer.id)
}
// Additive: merge hits with the existing selection, keeping the
// click order so the anchor lands on whatever the user targeted
// last. Replace mode discards the old selection.
const merged = b.additive
? (() => {
const seen = new Set(store.selectedLayerIds)
const out = [...store.selectedLayerIds]
for (const h of hits) {
if (!seen.has(h)) {
out.push(h)
seen.add(h)
}
}
return out
})()
: hits
if (merged.length === 0) {
deselectAll()
return
}
if (merged.length === 1) {
store.selectLayer(merged[0])
return
}
store.selectedLayerIds = merged
store.selectedLayerId = merged[merged.length - 1]
store.selectedClipInstanceId = null
}
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 = computed(() => {
// Single selection → rotation is handled by the pivot-anchored
// custom handle below (the user-set pivot). The Konva transformer
// anchor stays off so we don't show two rotation knobs.
// Multi selection → group rotate has been tried and didn't work
// (write-back per node fights with the bbox-centred transform),
// so it stays disabled. The user explicitly preferred no
// rotation in multi-select over a half-broken group rotate.
return {
rotateEnabled: false,
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
}
// Multi-select: wrap every selected layer node in one transformer
// so the bounding box + scale / rotate handles span the group.
// A SOLE polygon selection still bypasses the transformer because
// a single polygon is edited via vertex handles; mixed selections
// that happen to include a polygon fold it into the shared bbox.
const ids = store.selectedLayerIds.length
? store.selectedLayerIds
: store.selectedLayerId
? [store.selectedLayerId]
: []
if (!ids.length) {
tr.nodes([])
tr.getLayer()?.batchDraw()
return
}
const single = ids.length === 1
const nodes: Konva.Node[] = []
let anyMissing = false
for (const id of ids) {
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) continue
const asset = assetMap.value.get(layer.assetId)
if (asset?.kind === 'polygon' && single) continue
const node = stage.findOne<Konva.Node>(`#${id}`)
if (!node) {
anyMissing = true
continue
}
nodes.push(node)
}
if (anyMissing && retriesLeft > 0) {
// Freshly-rendered layers haven't attached yet; retry on the
// next animation frame before giving up on the ids we missed.
attachRetryTimer = setTimeout(() => attachTransformer(retriesLeft - 1), 16)
return
}
tr.nodes(nodes)
tr.getLayer()?.batchDraw()
}
watch(
() =>
[
store.selectedLayerId,
store.selectedLayerIds,
store.selectedClipInstanceId,
stackItems.value,
store.playhead,
] as const,
async () => {
await nextTick()
attachTransformer()
},
{ immediate: true, deep: true },
)
function updateLivePose(node: Konva.Node, layerId: string) {
liveLayerPoses.value = {
...liveLayerPoses.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()
const draggedLayer = store.activeLayers.find((l) => l.id === id)
if (!draggedLayer) return
updateLivePose(node, id)
// Multi-select drag: compute the dragged node's delta vs its
// sampled pose at the current playhead, then mirror it onto every
// sibling Konva node so the whole selection tracks together. Each
// sibling's baseline is also its sampled pose (its Konva node
// hasn't moved yet — only the dragged one has).
if (
store.selectedLayerIds.length > 1 &&
store.selectedLayerIds.includes(id)
) {
const stage = stageRef.value?.getStage()
if (!stage) return
const baseSamp = sampleLayer(draggedLayer, store.playhead)
const dx = node.x() - baseSamp.x
const dy = node.y() - baseSamp.y
for (const lid of store.selectedLayerIds) {
if (lid === id) continue
const other = stage.findOne<Konva.Node>(`#${lid}`)
if (!other) continue
const otherLayer = store.activeLayers.find((l) => l.id === lid)
if (!otherLayer) continue
const s = sampleLayer(otherLayer, store.playhead)
other.x(s.x + dx)
other.y(s.y + dy)
}
stage.batchDraw()
}
}
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 = liveLayerPoses.value[origLayer.id]
let layer = origLayer
let workingArr = arr
if (live) {
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>) {
// Marquee on the empty stage. Two flavours share the same
// drag machinery: vertex-lasso when a polygon is being edited,
// layer multi-select otherwise. pointerup fires finishBoxSelect
// which routes by `mode`.
if (!store.drawingMode && e.target === e.target.getStage()) {
const native = e.evt as MouseEvent
if (native.button === 0 && !native.ctrlKey) {
const wp = pointerToWorld(e)
if (wp) {
// Vertex-lasso mode only applies to a SOLO polygon
// selection — inside a multi-select the box always targets
// layers so new picks add to the existing group.
const mode: 'vertex' | 'layer' =
selectedPolygonLayer.value && store.selectedLayerIds.length <= 1
? 'vertex'
: 'layer'
boxSelect.value = {
startX: wp.x, startY: wp.y, curX: wp.x, curY: wp.y, mode,
additive: native.shiftKey,
}
if (mode === 'vertex' && !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
// SVG-scope multi-rotation handles. Their inner Konva nodes have
// no id and a custom name, so without these bails the fall-through
// below clears the multi-selection on mousedown — making multi-
// rotation / pivot-drag dissolve the selection they operate on.
if (target.findAncestor?.('.multi-pivot-handle')) return
if (target.findAncestor?.('.multi-rotation-handle')) return
if (target.name?.() === 'multi-pivot-handle') return
if (target.name?.() === 'multi-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()) {
deselectAll()
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
}
if (name.startsWith('svg-hit:')) {
// Hit-catcher inside an SVG-asset layer's group. Route the
// mousedown to the wrapping layer the same way a regular
// shape click would — including shift-toggle for multi.
const layerId = name.slice('svg-hit:'.length)
const layer = store.activeLayers.find((l) => l.id === layerId)
if (!layer) {
store.selectLayer(null)
return
}
if ((e.evt as MouseEvent).shiftKey) {
store.toggleLayerSelection(layerId)
} else if (!store.selectedLayerIds.includes(layerId)) {
store.selectLayer(layerId)
}
return
}
// Bubbled mousedown from a Konva node we don't recognise as a
// layer. Use selectLayer(null) — NOT deselectAll — so that any
// clip-instance selection just set by an inner @mousedown
// handler (e.g. the ci-group's own listener) survives this
// bubbled fall-through.
store.selectLayer(null)
return
}
const match = store.activeLayers.find((l) => l.id === id)
if (!match) {
// Same rationale: id-bearing nodes that aren't active layers are
// typically ci-group / ci-frame / ci-child shapes whose own
// mousedown handler has already run and set the clip-instance
// selection. Don't trample that.
store.selectLayer(null)
return
}
// Shift toggles the clicked layer into the existing selection.
// Matches the timeline's shift-click-on-track-name behaviour so
// both surfaces build multi-selections the same way. Anchor moves
// to the most-recently-clicked id for a predictable Properties
// panel focus.
if ((e.evt as MouseEvent).shiftKey) {
store.toggleLayerSelection(match.id)
return
}
// Pressed on a layer that's already part of the multi-selection
// → don't collapse to a single-layer select. Konva's drag
// machinery is already arming on this node, and handleDragMove
// expects `selectedLayerIds.length > 1` to mirror the delta onto
// the siblings. Leaving the selection alone keeps the group drag
// working. Without this, clicking any one of the selected shapes
// would wipe the others and only drag the clicked one.
if (
store.selectedLayerIds.length > 1 &&
store.selectedLayerIds.includes(match.id)
) {
return
}
store.selectLayer(match.id)
}
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: scope-entry shortcut for stage-placed sub-asset
// layers. Double-click an SVG-asset layer → enter its sub-editor
// (mirroring the sidebar's double-click behaviour). Other asset
// kinds fall through to the polygon-specific gestures below.
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) return
if (asset.kind === 'svg') {
store.enterSvgAssetEdit(asset.id)
return
}
if (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-layer drag: persist every selected layer's new position.
// handleDragMove already mirrored the delta onto each sibling's
// Konva node, so each node's current x/y is already correct —
// just upsert from the live Konva state.
if (
store.selectedLayerIds.length > 1 &&
store.selectedLayerIds.includes(id)
) {
const stage = stageRef.value?.getStage()
if (stage) {
for (const lid of store.selectedLayerIds) {
const n = stage.findOne<Konva.Node>(`#${lid}`)
if (!n) continue
store.upsertKeyframe(lid, store.playhead, { x: n.x(), y: n.y() })
}
clearLivePoses()
return
}
}
// 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')
}
clearLivePoses()
}
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
// Multi-select transform: each node's @transformend fires
// separately, but Konva only guarantees the event on a subset
// (the dragged anchor mostly). To be robust, persist every
// selected layer's live Konva pose here and clear at the end.
// Single-select collapses to the same loop with one id.
const ids =
store.selectedLayerIds.length > 1 && store.selectedLayerIds.includes(id)
? store.selectedLayerIds
: [id]
const stage = stageRef.value?.getStage()
for (const lid of ids) {
const n = lid === id ? node : stage?.findOne<Konva.Node>(`#${lid}`)
if (!n) continue
store.upsertKeyframe(lid, store.playhead, {
x: n.x(),
y: n.y(),
scaleX: n.scaleX(),
scaleY: n.scaleY(),
rotation: n.rotation(),
})
}
clearLivePoses()
}
const ROT_ARM_PX = 44
const rotationHandlePos = ref<{ x: number; y: number } | null>(null)
const rotationHandleConfig = computed(() => {
if (store.selectedLayerIds.length > 1) return null
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(() => {
if (store.selectedLayerIds.length > 1) return null
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 prevLive = liveLayerPoses.value[info.layerId]
const prev = prevLive?.rotation ?? info.sampled.rotation
let deg = raw
while (deg - prev > 180) deg -= 360
while (deg - prev < -180) deg += 360
const base = prevLive ?? {
x: info.sampled.x,
y: info.sampled.y,
scaleX: info.sampled.scaleX,
scaleY: info.sampled.scaleY,
rotation: info.sampled.rotation,
}
liveLayerPoses.value = {
...liveLayerPoses.value,
[info.layerId]: { ...base, rotation: deg },
}
rotationHandlePos.value = { x: node.x(), y: node.y() }
}
function onRotationHandleDragEnd() {
const info = pivotInfo.value
const live = info ? liveLayerPoses.value[info.layerId] : null
if (info && live) {
store.upsertKeyframe(info.layerId, store.playhead, { rotation: live.rotation })
}
rotationHandlePos.value = null
clearLivePoses()
}
// -------- SVG-scope multi-select rotation -------------------------
// In SVG edit mode only: when the user has 2+ layers selected we
// expose a *temporary* group pivot — defaults to the centroid of the
// selection but draggable. The rotation handle anchored to it
// rotates every selected layer around that pivot. The temp pivot is
// scope-local: dropped on deselect / scope exit, re-init'd next time.
interface MultiRotState {
/** Per-selected-layer pose snapshotted at drag start. We rotate
* these around the pivot; the live pose is what Konva renders
* during drag. Persisting in a snapshot keeps the maths stable
* even when liveLayerPoses changes mid-drag. */
startPoses: {
layerId: string
x: number; y: number
scaleX: number; scaleY: number
rotation: number
}[]
/** Pivot anchor at drag start. Stays constant for the duration
* of the rotation gesture. */
pivotX: number
pivotY: number
/** Where the rotation handle started in world coords — used as
* the zero-angle reference. */
startHandleX: number
startHandleY: number
}
/** Active only in SVG scope with a multi-selection. `null` clears
* the pivot — auto-reseeded by the watcher below when the user
* multi-selects again. */
const multiRotPivot = ref<{ x: number; y: number } | null>(null)
const multiRotDrag = ref<MultiRotState | null>(null)
const multiRotDelta = ref(0)
const draggingMultiPivot = ref(false)
const isMultiSvg = computed(() =>
!!store.activeSvgAssetId && store.selectedLayerIds.length >= 2,
)
/** Sample-based positions for every currently-selected layer. Live
* poses (mid-rotation / mid-transform) are honoured so the centroid
* follows the rendered position during a drag. Includes scale so
* the multi-rotation drag preserves it instead of snapping the
* element to scale 1 mid-rotate. */
function selectedLayerPoses() {
const out: {
layerId: string
x: number; y: number
scaleX: number; scaleY: number
rotation: number
}[] = []
for (const id of store.selectedLayerIds) {
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) continue
const live = liveLayerPoses.value[id]
const sampled = sampleLayer(layer, store.playhead)
out.push({
layerId: id,
x: live?.x ?? sampled.x,
y: live?.y ?? sampled.y,
scaleX: live?.scaleX ?? sampled.scaleX,
scaleY: live?.scaleY ?? sampled.scaleY,
rotation: live?.rotation ?? sampled.rotation,
})
}
return out
}
/** Centroid of the current selection. Used to seed the temp pivot
* when the user enters multi-select. */
function selectionCentroid(): { x: number; y: number } | null {
const poses = selectedLayerPoses()
if (!poses.length) return null
let sx = 0, sy = 0
for (const p of poses) { sx += p.x; sy += p.y }
return { x: sx / poses.length, y: sy / poses.length }
}
// Re-seed / drop the temp pivot when the selection changes.
watch(isMultiSvg, (active) => {
if (active) {
if (!multiRotPivot.value) multiRotPivot.value = selectionCentroid()
} else {
multiRotPivot.value = null
multiRotDrag.value = null
multiRotDelta.value = 0
}
})
// When the selection's centroid drifts (because the user is scaling
// or moving the multi-selection via the Konva transformer / drag),
// shift the pivot by the same delta so it stays attached to the
// selection. Skipped while:
// - the user is mid-rotation (rotation around a non-centroid pivot
// naturally moves the centroid; we don't want the pivot to chase
// its own rotation);
// - the user is dragging the pivot itself (it's their gesture, not
// a selection drift).
const liveSelectionCentroid = computed<{ x: number; y: number } | null>(() => {
if (!isMultiSvg.value) return null
return selectionCentroid()
})
watch(liveSelectionCentroid, (newC, oldC) => {
if (!newC || !oldC) return
if (!multiRotPivot.value) return
if (multiRotDrag.value) return
if (draggingMultiPivot.value) return
const dx = newC.x - oldC.x
const dy = newC.y - oldC.y
if (Math.abs(dx) < 1e-3 && Math.abs(dy) < 1e-3) return
multiRotPivot.value = {
x: multiRotPivot.value.x + dx,
y: multiRotPivot.value.y + dy,
}
})
const MULTI_ROT_ARM_PX = 56
const multiPivotHandleConfig = computed(() => {
if (!isMultiSvg.value) return null
const p = multiRotPivot.value
if (!p) return null
return { x: p.x, y: p.y, draggable: true, name: 'multi-pivot-handle' }
})
const multiRotArmConfig = computed(() => {
if (!isMultiSvg.value) return null
const p = multiRotPivot.value
if (!p) return null
if (draggingMultiPivot.value) return null
const arm = MULTI_ROT_ARM_PX / zoom.value
const ang = multiRotDelta.value
const rad = (ang * Math.PI) / 180
const endX = p.x + arm * Math.sin(rad)
const endY = p.y - arm * Math.cos(rad)
return {
points: [p.x, p.y, endX, endY],
stroke: '#f59e0b',
strokeWidth: 1.25 / zoom.value,
dash: [3 / zoom.value, 3 / zoom.value],
listening: false,
}
})
const multiRotHandleConfig = computed(() => {
if (!isMultiSvg.value) return null
const p = multiRotPivot.value
if (!p) return null
if (draggingMultiPivot.value) return null
const arm = MULTI_ROT_ARM_PX / zoom.value
const ang = multiRotDelta.value
const rad = (ang * Math.PI) / 180
return {
x: p.x + arm * Math.sin(rad),
y: p.y - arm * Math.cos(rad),
draggable: true,
name: 'multi-rotation-handle',
}
})
const multiRotKnobConfig = computed(() => ({
radius: 6 / zoom.value,
fill: '#ffffff',
stroke: '#f59e0b',
strokeWidth: 2 / zoom.value,
}))
function onMultiPivotDragMove(e: Konva.KonvaEventObject<DragEvent>) {
draggingMultiPivot.value = true
multiRotPivot.value = { x: e.target.x(), y: e.target.y() }
}
function onMultiPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
draggingMultiPivot.value = false
multiRotPivot.value = { x: e.target.x(), y: e.target.y() }
}
function onMultiRotHandleDragStart(e: Konva.KonvaEventObject<DragEvent>) {
const p = multiRotPivot.value
if (!p) return
multiRotDrag.value = {
startPoses: selectedLayerPoses().map((q) => ({ ...q })),
pivotX: p.x,
pivotY: p.y,
startHandleX: e.target.x(),
startHandleY: e.target.y(),
}
multiRotDelta.value = 0
}
function onMultiRotHandleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const d = multiRotDrag.value
if (!d) return
const ang0 = Math.atan2(d.startHandleX - d.pivotX, -(d.startHandleY - d.pivotY))
const ang1 = Math.atan2(e.target.x() - d.pivotX, -(e.target.y() - d.pivotY))
let delta = ((ang1 - ang0) * 180) / Math.PI
// Wrap to nearest equivalent so a small drag across ±180° doesn't
// suddenly flip a full turn.
const prev = multiRotDelta.value
while (delta - prev > 180) delta -= 360
while (delta - prev < -180) delta += 360
multiRotDelta.value = delta
const rad = (delta * Math.PI) / 180
const c = Math.cos(rad)
const s = Math.sin(rad)
const next: Record<string, LiveLayerPose> = { ...liveLayerPoses.value }
for (const start of d.startPoses) {
const dx = start.x - d.pivotX
const dy = start.y - d.pivotY
next[start.layerId] = {
x: d.pivotX + dx * c - dy * s,
y: d.pivotY + dx * s + dy * c,
// Carry the layer's own scale through the rotation —
// rotation around a pivot doesn't change scale, but if we
// default to 1 here a scaled element snaps to "scale 1"
// mid-drag and only restores after dragend writes back.
scaleX: start.scaleX,
scaleY: start.scaleY,
rotation: start.rotation + delta,
}
}
liveLayerPoses.value = next
}
function onMultiRotHandleDragEnd() {
const d = multiRotDrag.value
if (!d) {
multiRotDelta.value = 0
return
}
for (const start of d.startPoses) {
const live = liveLayerPoses.value[start.layerId]
if (!live) continue
store.upsertKeyframe(start.layerId, store.playhead, {
x: live.x,
y: live.y,
rotation: live.rotation,
})
}
multiRotDrag.value = null
multiRotDelta.value = 0
clearLivePoses()
}
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 && item.kind !== 'folder') {
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- or
// svg-asset edit session — sub-assets have their own canvas, and
// the framing must update so the new canvas lands inside the
// viewport instead of leaving the user staring at an empty corner.
watch(
() => store.activeButtonAssetId,
() => {
nextTick(() => fitToContainer())
},
)
watch(
() => store.activeSvgAssetId,
() => {
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
}
// Project-loaded zoom/pan? Treat a non-default editor state as an
// explicit user choice and skip the first auto-fit so the workbench
// restores exactly where the user left off.
if (
store.editor.canvasZoom !== 1 ||
store.editor.canvasViewX !== 0 ||
store.editor.canvasViewY !== 0
) {
didInitialFit = true
}
// Last known viewport position of the stage container. Updated by
// the ResizeObserver below; used to compensate viewX/Y when the
// container's screen position shifts (e.g. when TAB collapses the
// asset / properties asides) so the canvas content stays anchored
// to the same viewport pixel instead of riding the layout shift.
let lastContainerRect: { left: number; top: number } | null = null
onMounted(() => {
if (!container.value) return
ro = new ResizeObserver(() => {
const el = container.value
if (!el) return
const r = el.getBoundingClientRect()
containerSize.value = { width: r.width, height: r.height }
if (lastContainerRect) {
const dx = r.left - lastContainerRect.left
const dy = r.top - lastContainerRect.top
// viewX/Y are container-local: shift them inversely so the
// rendered content keeps its viewport position when the
// container moves under it.
if (dx !== 0) viewX.value -= dx
if (dy !== 0) viewY.value -= dy
}
lastContainerRect = { left: r.left, top: r.top }
if (!didInitialFit && r.width > 0 && r.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)
document.removeEventListener('pointerdown', onStageMenuPointerDown, true)
document.removeEventListener('keydown', onStageMenuKeyDown)
})
// --- Stage context menu ---------------------------------------------
// Right-clicking the stage (outside of a more specific handler like
// a vertex context menu) opens a small action menu. Currently two
// items; `stageResize` is a placeholder for the upcoming resize flow.
interface StageMenuState {
x: number
y: number
}
const stageMenu = ref<StageMenuState | null>(null)
const stageMenuRef = ref<HTMLDivElement | null>(null)
const documentDrawer = useDocumentDrawer()
function onStageContextMenu(e: MouseEvent) {
// Don't hijack native right-click on editable elements (e.g. the
// rare inline inputs the stage overlays render). The App-level
// `onDocumentContextMenu` already suppresses the browser menu
// elsewhere, so a non-editable target here is safe to intercept.
const target = e.target as HTMLElement | null
if (target && target.closest('input, textarea, [contenteditable="true"]')) return
e.preventDefault()
e.stopPropagation()
stageMenu.value = { x: e.clientX, y: e.clientY }
}
function closeStageMenu() {
stageMenu.value = null
}
function onStageMenuPointerDown(e: PointerEvent) {
if (!stageMenu.value) return
const root = stageMenuRef.value
if (root && root.contains(e.target as Node)) return
closeStageMenu()
}
function onStageMenuKeyDown(e: KeyboardEvent) {
if (!stageMenu.value) return
if (e.key !== 'Escape') return
e.preventDefault()
closeStageMenu()
}
watch(stageMenu, (v) => {
if (v) {
document.addEventListener('pointerdown', onStageMenuPointerDown, true)
document.addEventListener('keydown', onStageMenuKeyDown)
} else {
document.removeEventListener('pointerdown', onStageMenuPointerDown, true)
document.removeEventListener('keydown', onStageMenuKeyDown)
}
})
function onMenuDocumentSettings() {
closeStageMenu()
documentDrawer.openDrawer()
}
function onMenuStageResize() {
closeStageMenu()
// Resizing without crop obscures the effect of the drag — elements
// outside the current canvas rect stay painted, so the user can't
// tell where the new edge actually lands. Force crop on for the
// duration of the mode; snapshot whatever the user had before so we
// can restore it on exit.
prevCropBeforeResize = cropObjects.value
cropObjects.value = true
stageResizeMode.value = true
}
// --- Stage resize mode ----------------------------------------------
// Eight drag handles overlay the stage rectangle. Dragging any handle
// resizes the canvas; handles that include a west or north component
// also shift every scene-world coordinate by the same delta so content
// stays pinned to its visible position while the canvas grows or
// shrinks around it.
type ResizeHandleDir = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw'
const RESIZE_HANDLES: ResizeHandleDir[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']
const MIN_CANVAS_SIZE = 20
const stageResizeMode = ref(false)
interface ResizeDragState {
dir: ResizeHandleDir
startClientX: number
startClientY: number
startWidth: number
startHeight: number
/** Cumulative world-space shift already applied during this drag.
* Each frame we compute the TARGET total shift and push only the
* delta relative to this — avoids double-applying as the pointer
* moves across multiple frames. */
appliedShiftX: number
appliedShiftY: number
pointerId: number
}
const resizeDrag = ref<ResizeDragState | null>(null)
// Snapshot of the stage-crop toggle from before resize mode kicked in,
// so the exit path can restore it. `null` means "not currently in
// resize mode" (nothing to restore).
let prevCropBeforeResize: boolean | null = null
function exitStageResizeMode() {
stageResizeMode.value = false
resizeDrag.value = null
if (prevCropBeforeResize !== null) {
cropObjects.value = prevCropBeforeResize
prevCropBeforeResize = null
}
}
function onResizeModeKeyDown(e: KeyboardEvent) {
if (!stageResizeMode.value) return
if (e.key !== 'Escape') return
e.preventDefault()
exitStageResizeMode()
}
watch(stageResizeMode, (v) => {
if (v) document.addEventListener('keydown', onResizeModeKeyDown)
else document.removeEventListener('keydown', onResizeModeKeyDown)
})
/** Screen-space rect for the canvas — matches how Konva renders it
* (view offset + canvas * zoom). Handles are anchored to these
* positions. Tracks the *active* canvas: SVG-asset / button-asset /
* main scene, depending on edit scope. */
const stageScreenRect = computed(() => {
const z = zoom.value
const c = effectiveCanvas.value
return {
left: viewX.value,
top: viewY.value,
width: c.width * z,
height: c.height * z,
}
})
/** Apply a canvas-size change to whichever canvas the user is
* currently editing. SVG-asset edit and button-asset edit both
* carry their own internal canvas; otherwise it's the main scene. */
function setActiveCanvasSize(width: number, height: number) {
const sa = store.activeSvgAsset
if (sa) {
store.setSvgAssetCanvas(sa.id, width, height)
return
}
const ba = store.activeButtonAsset
if (ba) {
store.setButtonAssetCanvas(ba.id, width, height)
return
}
store.setCanvasSize(width, height)
}
function handleStyle(dir: ResizeHandleDir): Record<string, string> {
const rect = stageScreenRect.value
const half = 6 // handle is 12×12, centre the chip on the edge
const cx = rect.left + rect.width / 2 - half
const cy = rect.top + rect.height / 2 - half
const left = rect.left - half
const right = rect.left + rect.width - half
const top = rect.top - half
const bottom = rect.top + rect.height - half
const pos: Record<ResizeHandleDir, { left: number; top: number }> = {
nw: { left, top },
n: { left: cx, top },
ne: { left: right, top },
e: { left: right, top: cy },
se: { left: right, top: bottom },
s: { left: cx, top: bottom },
sw: { left, top: bottom },
w: { left, top: cy },
}
return { left: `${pos[dir].left}px`, top: `${pos[dir].top}px` }
}
function resizeTargetsForDelta(
dir: ResizeHandleDir,
dxWorld: number,
dyWorld: number,
startW: number,
startH: number,
): { width: number; height: number; shiftX: number; shiftY: number } {
let newW = startW
let newH = startH
let shiftX = 0
let shiftY = 0
if (dir.includes('e')) newW = startW + dxWorld
if (dir.includes('w')) {
newW = startW - dxWorld
shiftX = -dxWorld
}
if (dir.includes('s')) newH = startH + dyWorld
if (dir.includes('n')) {
newH = startH - dyWorld
shiftY = -dyWorld
}
// Enforce a floor. If resizing would push below MIN_CANVAS_SIZE on
// a west/north drag, clamp both the size AND the shift so elements
// don't skate off while the canvas is pinned at the floor.
if (newW < MIN_CANVAS_SIZE) {
const clampDx = dxWorld + (MIN_CANVAS_SIZE - newW)
newW = MIN_CANVAS_SIZE
if (dir.includes('w')) shiftX = -clampDx
}
if (newH < MIN_CANVAS_SIZE) {
const clampDy = dyWorld + (MIN_CANVAS_SIZE - newH)
newH = MIN_CANVAS_SIZE
if (dir.includes('n')) shiftY = -clampDy
}
return { width: newW, height: newH, shiftX, shiftY }
}
function onResizeHandlePointerDown(e: PointerEvent, dir: ResizeHandleDir) {
if (!stageResizeMode.value) return
e.preventDefault()
e.stopPropagation()
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
resizeDrag.value = {
dir,
startClientX: e.clientX,
startClientY: e.clientY,
startWidth: effectiveCanvas.value.width,
startHeight: effectiveCanvas.value.height,
appliedShiftX: 0,
appliedShiftY: 0,
pointerId: e.pointerId,
}
}
function onResizeHandlePointerMove(e: PointerEvent) {
const drag = resizeDrag.value
if (!drag || drag.pointerId !== e.pointerId) return
const z = zoom.value || 1
const dxWorld = (e.clientX - drag.startClientX) / z
const dyWorld = (e.clientY - drag.startClientY) / z
const { width, height, shiftX, shiftY } = resizeTargetsForDelta(
drag.dir,
dxWorld,
dyWorld,
drag.startWidth,
drag.startHeight,
)
setActiveCanvasSize(width, height)
const deltaShiftX = shiftX - drag.appliedShiftX
const deltaShiftY = shiftY - drag.appliedShiftY
// Shift only applies to the main-scene canvas — sub-asset edit
// scopes have their own coord system and don't need a global
// shift to keep content visible. North/west drags inside an SVG
// / button asset just resize without re-anchoring.
if (
!store.activeSvgAssetId &&
!store.activeButtonAssetId &&
(deltaShiftX !== 0 || deltaShiftY !== 0)
) {
store.shiftSceneWorldCoords(deltaShiftX, deltaShiftY)
drag.appliedShiftX = shiftX
drag.appliedShiftY = shiftY
}
}
function onResizeHandlePointerUp(e: PointerEvent) {
const drag = resizeDrag.value
if (!drag || drag.pointerId !== e.pointerId) return
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
resizeDrag.value = null
}
</script>
<template>
<div
ref="container"
class="stage-container"
:class="{ drawing: store.drawingMode === 'freeform' }"
@contextmenu="onStageContextMenu"
>
<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 === 'svg' && svgAssetFor(entry.item.asset)"
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
>
<v-rect
:config="svgHitCatcherConfig(svgAssetFor(entry.item.asset)!, entry.item.layer)"
/>
<template
v-for="(sub, sli) in svgStackForAsset(
svgAssetFor(entry.item.asset)!,
Math.sqrt(
Math.abs(entry.item.props.scaleX || 1) *
Math.abs(entry.item.props.scaleY || 1),
),
)"
:key="`${entry.item.layer.id}:${sub.layer.id}:${sli}`"
>
<v-rect
v-if="sub.asset.kind === 'rect'"
:config="svgChildConfig(rectConfig(sub.layer, sub.asset, sub.props))"
/>
<v-circle
v-else-if="sub.asset.kind === 'circle'"
:config="svgChildConfig(circleConfig(sub.layer, sub.asset, sub.props))"
/>
<v-text
v-else-if="sub.asset.kind === 'text'"
:config="svgChildConfig(textConfig(sub.layer, sub.asset, sub.props))"
/>
<v-line
v-else-if="sub.asset.kind === 'polygon'"
:config="svgChildConfig(polygonConfig(sub.layer, sub.asset, sub.props))"
/>
<v-image
v-else-if="sub.asset.kind === 'image' && imageConfig(sub.layer, sub.asset, sub.props)"
:config="svgChildConfig(imageConfig(sub.layer, sub.asset, sub.props)!)"
/>
</template>
</v-group>
<v-group
v-else-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)"
>
<v-rect
:config="clipFrameConfig(
entry.bundle.instance.id,
entry.bundle.bounds,
)"
/>
<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" />
<template v-if="centerCrosshairConfigs">
<v-line :config="centerCrosshairConfigs.horizontal" />
<v-line :config="centerCrosshairConfigs.vertical" />
</template>
<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>
<!-- SVG-scope multi-rotation: temp pivot + rotation arm.
Active only when isMultiSvg is true; the per-layer
handles above are gated off in that case. -->
<v-line v-if="multiRotArmConfig" :config="multiRotArmConfig" />
<v-group
v-if="multiPivotHandleConfig"
:config="multiPivotHandleConfig"
@dragmove="onMultiPivotDragMove"
@dragend="onMultiPivotDragEnd"
>
<v-circle :config="pivotRingConfig" />
<v-circle :config="pivotDotConfig" />
</v-group>
<v-group
v-if="multiRotHandleConfig"
:config="multiRotHandleConfig"
@dragstart="onMultiRotHandleDragStart"
@dragmove="onMultiRotHandleDragMove"
@dragend="onMultiRotHandleDragEnd"
>
<v-circle :config="multiRotKnobConfig" />
</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 center-cross-btn"
:class="{ active: centerCrosshair }"
v-tooltip="centerCrosshair ? t('canvas.centerCrosshairOff') : t('canvas.centerCrosshairOn')"
@click="centerCrosshair = !centerCrosshair"
>
<PhCrosshair :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>
<template v-if="stageResizeMode">
<div
v-for="dir in RESIZE_HANDLES"
:key="dir"
class="stage-resize-handle"
:class="['dir-' + dir]"
:style="handleStyle(dir)"
@pointerdown="onResizeHandlePointerDown($event, dir)"
@pointermove="onResizeHandlePointerMove"
@pointerup="onResizeHandlePointerUp"
@pointercancel="onResizeHandlePointerUp"
></div>
<div
class="stage-resize-banner"
@pointerdown.stop
@mousedown.stop
>
<span class="stage-resize-label">{{ t('canvas.resize.banner') }}</span>
<span class="stage-resize-hint">{{ t('canvas.resize.hint') }}</span>
<button
type="button"
class="stage-resize-done"
@pointerdown.stop
@mousedown.stop
@click.stop="exitStageResizeMode"
>
{{ t('canvas.resize.done') }}
</button>
</div>
</template>
<div
v-if="stageMenu"
ref="stageMenuRef"
class="stage-menu"
:style="{ left: stageMenu.x + 'px', top: stageMenu.y + 'px' }"
@contextmenu.prevent.stop
>
<button
v-if="!store.activeSvgAssetId"
type="button"
class="stage-menu-item"
@pointerdown.stop
@mousedown.stop
@click.stop="onMenuDocumentSettings"
>
<PhSlidersHorizontal :size="13" class="stage-menu-icon" />
{{ t('canvas.menu.documentSettings') }}
</button>
<button
type="button"
class="stage-menu-item"
@pointerdown.stop
@mousedown.stop
@click.stop="onMenuStageResize"
>
<PhFrameCorners :size="13" class="stage-menu-icon" />
{{ store.activeSvgAssetId ? t('canvas.menu.editCanvasSize') : t('canvas.menu.stageResize') }}
</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);
}
/* Stage-level right-click menu. Fixed-positioned so the cursor point
(clientX/clientY) maps 1:1 regardless of stage scrolling. */
.stage-menu {
position: fixed;
display: flex;
flex-direction: column;
min-width: 180px;
background: var(--panel);
border: 1px solid var(--border-strong, var(--border));
border-radius: var(--radius);
box-shadow: var(--shadow-md, 0 8px 24px rgba(0, 0, 0, 0.35));
z-index: 1000;
padding: 5px;
font-size: 12px;
}
.stage-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
background: transparent;
border: none;
color: var(--text);
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
border-radius: var(--radius-sm);
width: 100%;
}
.stage-menu-item:hover:not(:disabled) {
background: var(--panel-alt);
}
.stage-menu-icon {
flex-shrink: 0;
color: var(--text-dim);
}
/* Resize-mode handles. Eight 12×12 accent-tinted chips anchored to
the canvas edges/corners. Cursors hint the axis so the user knows
the drag direction without reading docs. */
.stage-resize-handle {
position: absolute;
width: 12px;
height: 12px;
background: var(--accent);
border: 2px solid #fff;
border-radius: 3px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
z-index: 15;
touch-action: none;
user-select: none;
}
.stage-resize-handle.dir-n,
.stage-resize-handle.dir-s {
cursor: ns-resize;
}
.stage-resize-handle.dir-e,
.stage-resize-handle.dir-w {
cursor: ew-resize;
}
.stage-resize-handle.dir-ne,
.stage-resize-handle.dir-sw {
cursor: nesw-resize;
}
.stage-resize-handle.dir-nw,
.stage-resize-handle.dir-se {
cursor: nwse-resize;
}
/* Floating exit affordance. Anchored to the top of the stage area so
the user has a visible "I'm done" target while the handles are up.
Esc still works as a shortcut. */
.stage-resize-banner {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 20;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 8px 6px 14px;
background: var(--panel);
border: 1px solid var(--border-strong, var(--border));
border-radius: 999px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
font-size: 12px;
color: var(--text);
pointer-events: auto;
}
.stage-resize-label {
font-weight: 600;
color: var(--accent);
}
.stage-resize-hint {
color: var(--text-dim);
font-size: 11px;
}
.stage-resize-done {
padding: 4px 12px;
background: var(--accent);
border: 1px solid transparent;
color: var(--accent-text, #fff);
border-radius: 999px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
}
.stage-resize-done:hover {
filter: brightness(1.1);
}
.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>