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