- strokeCfg now defaults to strokeScaleEnabled:true so stroked rect / circle / text outlines grow proportionally when the layer is scaled (Konva default; previously hard-pinned to false). - polygonConfig and clipPolygonConfig had inline stroke blocks that also forced strokeScaleEnabled:false. Flipped both to true so freeform / polygon outlines scale alongside the shape. - SVG-asset placements use a dedicated svgChildConfig wrapper + pre-multiplied strokeWidth in svgStackForAsset: each child's stroke is bumped by the placement's geometric-mean scale and rendered with strokeScaleEnabled:false (Konva resets the ctx to identity before stroking) so the on-screen width lands at the right pixel count regardless of how Konva propagates parent transforms to nested stroke rendering. Konva-mounted tests cover outer-scale 1×, 3× and non-uniform 4×/1×. UI chrome (transformer borders, vertex / tangent handles, hit-shape outlines, animation-path ghosts, ghost-locked layer indicators) keeps strokeScaleEnabled:false intentionally — those should stay at constant pixel width regardless of zoom.
4933 lines
157 KiB
Vue
4933 lines
157 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import type Konva from 'konva'
|
||
import {
|
||
PhCircle,
|
||
PhCornersIn,
|
||
PhCrop,
|
||
PhCrosshair,
|
||
PhCursorClick,
|
||
PhFrameCorners,
|
||
PhMaskHappy,
|
||
PhPencilSimple,
|
||
PhPolygon,
|
||
PhSlidersHorizontal,
|
||
PhSquare,
|
||
PhTrash,
|
||
} from '@phosphor-icons/vue'
|
||
import { useSceneStore } from '@/stores/scene'
|
||
import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from '@/lib/interpolate'
|
||
import { importImageFile, loadImage } from '@/lib/fileLoader'
|
||
import { useClipartStore } from '@/stores/clipart'
|
||
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
|
||
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
|
||
import type {
|
||
Action,
|
||
AnimatableProps,
|
||
Asset,
|
||
ButtonAsset,
|
||
ButtonState,
|
||
ClipInstance,
|
||
HitShape,
|
||
Keyframe,
|
||
Layer,
|
||
Point,
|
||
SvgAsset,
|
||
} from '@/types/scene'
|
||
import { BUTTON_STATE_TIMES } from '@/types/scene'
|
||
import { useButtonRuntime } from '@/composables/useButtonRuntime'
|
||
import { evalAction } from '@/lib/actions'
|
||
import { t } from '@/i18n'
|
||
import { useDeselectAll } from '@/composables/useDeselectAll'
|
||
import { useKfSelection } from '@/composables/useKfSelection'
|
||
import { showLocked } from '@/composables/useShowLocked'
|
||
import { expandedTrackOrder } from '@/lib/sceneOrder'
|
||
import { CLIP_FALLBACK_BOUNDS, computeClipBounds, type Rect } from '@/lib/clipBounds'
|
||
|
||
const store = useSceneStore()
|
||
const clipart = useClipartStore()
|
||
const { selectedKfs } = useKfSelection()
|
||
const deselectAll = useDeselectAll()
|
||
|
||
const container = ref<HTMLDivElement | null>(null)
|
||
const containerSize = ref({ width: 0, height: 0 })
|
||
const stageRef = ref<{ getStage(): Konva.Stage } | null>(null)
|
||
const bgVideoEl = ref<HTMLVideoElement | null>(null)
|
||
const transformerRef = ref<{ getNode(): Konva.Transformer } | null>(null)
|
||
|
||
// Canvas zoom / pan / overlay toggles live on the store so the
|
||
// project file can round-trip the workbench state. Thin
|
||
// writableComputed shims keep the rest of this component's code
|
||
// (which reads/writes `zoom.value`, `cropObjects.value = …`, etc.)
|
||
// unchanged.
|
||
const zoom = computed<number>({
|
||
get: () => store.editor.canvasZoom,
|
||
set: (v) => { store.editor.canvasZoom = v },
|
||
})
|
||
const viewX = computed<number>({
|
||
get: () => store.editor.canvasViewX,
|
||
set: (v) => { store.editor.canvasViewX = v },
|
||
})
|
||
const viewY = computed<number>({
|
||
get: () => store.editor.canvasViewY,
|
||
set: (v) => { store.editor.canvasViewY = v },
|
||
})
|
||
const cropObjects = computed<boolean>({
|
||
get: () => store.editor.cropObjects,
|
||
set: (v) => { store.editor.cropObjects = v },
|
||
})
|
||
const maskPreview = computed<boolean>({
|
||
get: () => store.editor.maskPreview,
|
||
set: (v) => { store.editor.maskPreview = v },
|
||
})
|
||
// Dashed horizontal + vertical lines through the center of the
|
||
// effective canvas — a composition guide the user can toggle on/off
|
||
// from the HUD.
|
||
const centerCrosshair = computed<boolean>({
|
||
get: () => store.editor.centerCrosshair,
|
||
set: (v) => { store.editor.centerCrosshair = v },
|
||
})
|
||
// Stage-preview mode: when false (default) buttons behave like normal
|
||
// assets — draggable, no hover/click interaction. When true the canvas
|
||
// turns into a live preview with buttons responding to input.
|
||
const stagePreview = computed<boolean>({
|
||
get: () => store.editor.stagePreview,
|
||
set: (v) => { store.editor.stagePreview = v },
|
||
})
|
||
const previewCanvasEl = ref<HTMLCanvasElement | null>(null)
|
||
const previewImages = ref<Record<string, HTMLImageElement>>({})
|
||
let didInitialFit = false
|
||
|
||
const ZOOM_MIN = 0.05
|
||
const ZOOM_MAX = 20
|
||
|
||
function clamp(v: number, lo: number, hi: number) {
|
||
return Math.max(lo, Math.min(hi, v))
|
||
}
|
||
|
||
function fitToContainer() {
|
||
const cw = containerSize.value.width
|
||
const ch = containerSize.value.height
|
||
const ec = effectiveCanvas.value
|
||
const lw = ec.width
|
||
const lh = ec.height
|
||
if (!cw || !ch) return
|
||
const z = Math.min(cw / lw, ch / lh) * 0.92
|
||
zoom.value = z
|
||
viewX.value = (cw - lw * z) / 2
|
||
viewY.value = (ch - lh * z) / 2
|
||
}
|
||
|
||
function zoomToOne() {
|
||
const cw = containerSize.value.width
|
||
const ch = containerSize.value.height
|
||
const ec = effectiveCanvas.value
|
||
const lw = ec.width
|
||
const lh = ec.height
|
||
if (!cw || !ch) return
|
||
zoom.value = 1
|
||
viewX.value = (cw - lw) / 2
|
||
viewY.value = (ch - lh) / 2
|
||
}
|
||
|
||
const effectiveCanvas = computed(() => {
|
||
const sa = store.activeSvgAsset
|
||
if (sa) return sa.canvas
|
||
const ba = store.activeButtonAsset
|
||
if (ba) return ba.canvas
|
||
return store.scene.canvas
|
||
})
|
||
|
||
// -------- Button hit-shape editing --------------------------------
|
||
|
||
const activeHitShapes = computed(() => store.activeButtonAsset?.hitArea ?? [])
|
||
const selectedHitShapeIndex = ref<number | null>(null)
|
||
|
||
function hitShapeConfig(shape: HitShape, index: number) {
|
||
const selected = selectedHitShapeIndex.value === index
|
||
const stroke = selected ? '#ffffff' : '#f59e0b'
|
||
const strokeWidth = (selected ? 2 : 1.5) / zoom.value
|
||
const base = {
|
||
id: `hit-shape:${index}`,
|
||
stroke,
|
||
strokeWidth,
|
||
strokeScaleEnabled: false,
|
||
dash: [6 / zoom.value, 4 / zoom.value],
|
||
fill: 'rgba(245, 158, 11, 0.08)',
|
||
draggable: true,
|
||
}
|
||
if (shape.kind === 'rect') {
|
||
return {
|
||
...base,
|
||
x: shape.x,
|
||
y: shape.y,
|
||
width: shape.width,
|
||
height: shape.height,
|
||
}
|
||
}
|
||
if (shape.kind === 'circle') {
|
||
return {
|
||
...base,
|
||
x: shape.x,
|
||
y: shape.y,
|
||
radius: shape.radius,
|
||
}
|
||
}
|
||
// Polygon — Konva Line renders using a flat [x,y,x,y,...] array and
|
||
// `closed`. We draw it at (0,0); each vertex carries its own position.
|
||
const flat: number[] = []
|
||
for (const p of shape.points) flat.push(p.x, p.y)
|
||
return {
|
||
...base,
|
||
x: 0,
|
||
y: 0,
|
||
points: flat,
|
||
closed: true,
|
||
lineJoin: 'round',
|
||
}
|
||
}
|
||
|
||
function onHitShapeDragEnd(e: Konva.KonvaEventObject<DragEvent>, index: number) {
|
||
const ba = store.activeButtonAsset
|
||
if (!ba) return
|
||
const shape = ba.hitArea[index]
|
||
if (!shape) return
|
||
const node = e.target
|
||
if (shape.kind === 'polygon') {
|
||
// Whole-polygon drag: node (0,0) moved to (dx, dy); bake that offset
|
||
// into every vertex and reset the node back to (0,0).
|
||
const dx = node.x()
|
||
const dy = node.y()
|
||
if (dx === 0 && dy === 0) return
|
||
const moved = shape.points.map((p) => ({ x: p.x + dx, y: p.y + dy }))
|
||
store.updateButtonHitShape(ba.id, index, { points: moved } as Partial<HitShape>)
|
||
node.position({ x: 0, y: 0 })
|
||
return
|
||
}
|
||
store.updateButtonHitShape(ba.id, index, { x: node.x(), y: node.y() })
|
||
}
|
||
|
||
function onHitShapeClick(_e: Konva.KonvaEventObject<MouseEvent>, index: number) {
|
||
selectedHitShapeIndex.value = index
|
||
}
|
||
|
||
function onHitRectTransformEnd(e: Konva.KonvaEventObject<Event>, index: number) {
|
||
const ba = store.activeButtonAsset
|
||
if (!ba) return
|
||
const node = e.target
|
||
// Konva Transformer applies scale deltas; bake those into width/height
|
||
// and reset the node scale so further transforms compose cleanly.
|
||
const sx = node.scaleX()
|
||
const sy = node.scaleY()
|
||
const newW = Math.max(4, Math.round(node.width() * sx))
|
||
const newH = Math.max(4, Math.round(node.height() * sy))
|
||
node.scaleX(1)
|
||
node.scaleY(1)
|
||
node.width(newW)
|
||
node.height(newH)
|
||
store.updateButtonHitShape(ba.id, index, {
|
||
x: Math.round(node.x()),
|
||
y: Math.round(node.y()),
|
||
width: newW,
|
||
height: newH,
|
||
} as Partial<HitShape>)
|
||
}
|
||
|
||
function onHitCircleTransformEnd(e: Konva.KonvaEventObject<Event>, index: number) {
|
||
const ba = store.activeButtonAsset
|
||
if (!ba) return
|
||
const node = e.target
|
||
const sx = node.scaleX()
|
||
const sy = node.scaleY()
|
||
const shape = ba.hitArea[index]
|
||
if (!shape || shape.kind !== 'circle') return
|
||
const scaleAvg = (Math.abs(sx) + Math.abs(sy)) / 2
|
||
const newR = Math.max(2, Math.round(shape.radius * scaleAvg))
|
||
node.scaleX(1)
|
||
node.scaleY(1)
|
||
store.updateButtonHitShape(ba.id, index, {
|
||
x: Math.round(node.x()),
|
||
y: Math.round(node.y()),
|
||
radius: newR,
|
||
} as Partial<HitShape>)
|
||
}
|
||
|
||
function onHitPolygonVertexDrag(
|
||
e: Konva.KonvaEventObject<DragEvent>,
|
||
shapeIdx: number,
|
||
vertexIdx: number,
|
||
) {
|
||
const ba = store.activeButtonAsset
|
||
if (!ba) return
|
||
const shape = ba.hitArea[shapeIdx]
|
||
if (!shape || shape.kind !== 'polygon') return
|
||
const node = e.target
|
||
const next = shape.points.map((p, i) =>
|
||
i === vertexIdx ? { x: node.x(), y: node.y() } : { ...p },
|
||
)
|
||
store.updateButtonHitShape(ba.id, shapeIdx, { points: next } as Partial<HitShape>)
|
||
}
|
||
|
||
function addHitRect() {
|
||
const ba = store.activeButtonAsset
|
||
if (!ba) return
|
||
const w = ba.canvas.width
|
||
const h = ba.canvas.height
|
||
const rw = Math.max(20, Math.round(w * 0.6))
|
||
const rh = Math.max(20, Math.round(h * 0.6))
|
||
store.addButtonHitShape(ba.id, {
|
||
kind: 'rect',
|
||
x: Math.round((w - rw) / 2),
|
||
y: Math.round((h - rh) / 2),
|
||
width: rw,
|
||
height: rh,
|
||
})
|
||
selectedHitShapeIndex.value = (ba.hitArea?.length ?? 1) - 1
|
||
}
|
||
|
||
function addHitCircle() {
|
||
const ba = store.activeButtonAsset
|
||
if (!ba) return
|
||
const w = ba.canvas.width
|
||
const h = ba.canvas.height
|
||
const r = Math.max(10, Math.round(Math.min(w, h) * 0.3))
|
||
store.addButtonHitShape(ba.id, {
|
||
kind: 'circle',
|
||
x: Math.round(w / 2),
|
||
y: Math.round(h / 2),
|
||
radius: r,
|
||
})
|
||
selectedHitShapeIndex.value = (ba.hitArea?.length ?? 1) - 1
|
||
}
|
||
|
||
function addHitPolygon() {
|
||
const ba = store.activeButtonAsset
|
||
if (!ba) return
|
||
const w = ba.canvas.width
|
||
const h = ba.canvas.height
|
||
const cx = w / 2
|
||
const cy = h / 2
|
||
const r = Math.max(10, Math.min(w, h) * 0.35)
|
||
const sides = 6
|
||
const pts: { x: number; y: number }[] = []
|
||
for (let i = 0; i < sides; i++) {
|
||
const a = (i / sides) * Math.PI * 2 - Math.PI / 2
|
||
pts.push({ x: Math.round(cx + Math.cos(a) * r), y: Math.round(cy + Math.sin(a) * r) })
|
||
}
|
||
store.addButtonHitShape(ba.id, { kind: 'polygon', points: pts })
|
||
selectedHitShapeIndex.value = (ba.hitArea?.length ?? 1) - 1
|
||
}
|
||
|
||
function removeSelectedHitShape() {
|
||
const ba = store.activeButtonAsset
|
||
const idx = selectedHitShapeIndex.value
|
||
if (!ba || idx === null) return
|
||
store.removeButtonHitShape(ba.id, idx)
|
||
selectedHitShapeIndex.value = null
|
||
}
|
||
|
||
const selectedHitShape = computed(() => {
|
||
const idx = selectedHitShapeIndex.value
|
||
if (idx === null) return null
|
||
const list = store.activeButtonAsset?.hitArea
|
||
if (!list) return null
|
||
return list[idx] ?? null
|
||
})
|
||
|
||
const hitShapeTransformerConfig = computed(() => ({
|
||
rotateEnabled: false,
|
||
keepRatio: selectedHitShape.value?.kind === 'circle',
|
||
anchorSize: 8,
|
||
borderStrokeWidth: 0,
|
||
anchorStroke: '#f59e0b',
|
||
anchorFill: '#ffffff',
|
||
ignoreStroke: true,
|
||
flipEnabled: false,
|
||
}))
|
||
|
||
const hitTransformerRef = ref<{ getNode(): Konva.Transformer } | null>(null)
|
||
|
||
// Keep the transformer bound to whichever hit shape is currently selected.
|
||
watch(
|
||
() => [selectedHitShapeIndex.value, activeHitShapes.value.length] as const,
|
||
() => {
|
||
nextTick(() => {
|
||
const tr = hitTransformerRef.value?.getNode()
|
||
if (!tr) return
|
||
const idx = selectedHitShapeIndex.value
|
||
const shape = selectedHitShape.value
|
||
if (idx === null || !shape || shape.kind === 'polygon') {
|
||
tr.nodes([])
|
||
tr.getLayer()?.batchDraw()
|
||
return
|
||
}
|
||
const stage = tr.getStage()
|
||
const node = stage?.findOne(`#hit-shape:${idx}`)
|
||
if (node) tr.nodes([node])
|
||
else tr.nodes([])
|
||
tr.getLayer()?.batchDraw()
|
||
})
|
||
},
|
||
{ flush: 'post' },
|
||
)
|
||
|
||
function hitVertexConfig(shapeIdx: number, vertexIdx: number, pt: { x: number; y: number }) {
|
||
const selected = selectedHitShapeIndex.value === shapeIdx
|
||
return {
|
||
x: pt.x,
|
||
y: pt.y,
|
||
radius: (selected ? 5 : 3.5) / zoom.value,
|
||
fill: '#ffffff',
|
||
stroke: '#f59e0b',
|
||
strokeWidth: 1 / zoom.value,
|
||
strokeScaleEnabled: false,
|
||
draggable: selected,
|
||
visible: selected,
|
||
listening: selected,
|
||
name: `hit-vertex:${shapeIdx}:${vertexIdx}`,
|
||
}
|
||
}
|
||
|
||
// Clear hit selection whenever the edit context changes.
|
||
watch(
|
||
() => [store.activeButtonAssetId, store.activeButtonEditMode, store.activeButtonState] as const,
|
||
() => {
|
||
selectedHitShapeIndex.value = null
|
||
},
|
||
)
|
||
|
||
// -------- Button asset canvas-resize handles ---------------------
|
||
// Three drag handles (right edge, bottom edge, bottom-right corner) that
|
||
// resize the button's internal canvas while in edit mode. Keeps (0,0)
|
||
// pinned; content layers inside the button are unaffected.
|
||
|
||
type CanvasResizeEdge = 'right' | 'bottom' | 'corner'
|
||
|
||
interface CanvasResizeHandle {
|
||
edge: CanvasResizeEdge
|
||
x: number
|
||
y: number
|
||
size: number
|
||
}
|
||
|
||
const canvasResizeHandles = computed<CanvasResizeHandle[]>(() => {
|
||
// Inline handles are a button-asset only affordance. SVG-asset
|
||
// resize goes through the existing 8-handle stage-resize mode
|
||
// (right-click → "Stage resize") so the canvas stays visually
|
||
// clean instead of looking permanently in-resize.
|
||
if (!store.activeButtonAssetId) return []
|
||
const w = effectiveCanvas.value.width
|
||
const h = effectiveCanvas.value.height
|
||
const s = 10 / zoom.value
|
||
return [
|
||
{ edge: 'right', x: w - s / 2, y: h / 2 - s / 2, size: s },
|
||
{ edge: 'bottom', x: w / 2 - s / 2, y: h - s / 2, size: s },
|
||
{ edge: 'corner', x: w - s / 2, y: h - s / 2, size: s },
|
||
]
|
||
})
|
||
|
||
function canvasResizeHandleConfig(h: CanvasResizeHandle) {
|
||
return {
|
||
x: h.x,
|
||
y: h.y,
|
||
width: h.size,
|
||
height: h.size,
|
||
fill: '#ffffff',
|
||
stroke: '#f59e0b',
|
||
strokeWidth: 1 / zoom.value,
|
||
strokeScaleEnabled: false,
|
||
draggable: true,
|
||
}
|
||
}
|
||
|
||
function onCanvasResizeDrag(e: Konva.KonvaEventObject<DragEvent>, edge: CanvasResizeEdge) {
|
||
const sa = store.activeSvgAsset
|
||
const ba = store.activeButtonAsset
|
||
const target = sa ?? ba
|
||
if (!target) return
|
||
const node = e.target
|
||
const s = 10 / zoom.value
|
||
let w = target.canvas.width
|
||
let h = target.canvas.height
|
||
if (edge === 'right' || edge === 'corner') {
|
||
w = Math.max(10, Math.round(node.x() + s / 2))
|
||
}
|
||
if (edge === 'bottom' || edge === 'corner') {
|
||
h = Math.max(10, Math.round(node.y() + s / 2))
|
||
}
|
||
if (sa) store.setSvgAssetCanvas(sa.id, w, h)
|
||
else if (ba) store.setButtonAssetCanvas(ba.id, w, h)
|
||
}
|
||
|
||
function onCanvasResizeDragEnd(e: Konva.KonvaEventObject<DragEvent>, edge: CanvasResizeEdge) {
|
||
onCanvasResizeDrag(e, edge)
|
||
}
|
||
|
||
// -------- Button instance runtime (main stage) --------------------
|
||
|
||
const buttonRt = useButtonRuntime()
|
||
|
||
function buttonLayerGroupConfig(layer: Layer, props: AnimatableProps) {
|
||
const locked = isLayerEffectivelyLocked(layer)
|
||
return {
|
||
id: layer.id,
|
||
x: props.x,
|
||
y: props.y,
|
||
scaleX: props.scaleX,
|
||
scaleY: props.scaleY,
|
||
rotation: props.rotation,
|
||
opacity: props.opacity,
|
||
offsetX: layer.offsetX ?? 0,
|
||
offsetY: layer.offsetY ?? 0,
|
||
visible: layer.visible,
|
||
listening: !locked,
|
||
// In edit mode buttons behave like any other asset: draggable as a
|
||
// single group, transformer can attach. Preview mode locks position
|
||
// so the button stays put while receiving pointer events.
|
||
draggable: !locked && !stagePreview.value,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Invisible Konva shape that covers the button's canvas rect but only
|
||
* responds to pointer events inside the union of hit shapes. Pointer
|
||
* events on this catcher drive hover/press/click.
|
||
*/
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
type KonvaHitContext = any
|
||
function buttonHitCatcherConfig(asset: ButtonAsset, layer: Layer) {
|
||
const shapes = asset.hitArea
|
||
const hitFunc = (ctx: KonvaHitContext, s: Konva.Shape) => {
|
||
if (!shapes.length) {
|
||
ctx.beginPath()
|
||
ctx.rect(0, 0, asset.canvas.width, asset.canvas.height)
|
||
ctx.closePath()
|
||
ctx.fillStrokeShape(s)
|
||
return
|
||
}
|
||
ctx.beginPath()
|
||
for (const h of shapes) {
|
||
if (h.kind === 'rect') {
|
||
ctx.rect(h.x, h.y, h.width, h.height)
|
||
} else if (h.kind === 'circle') {
|
||
ctx.moveTo(h.x + h.radius, h.y)
|
||
ctx.arc(h.x, h.y, h.radius, 0, Math.PI * 2)
|
||
} else if (h.kind === 'polygon' && h.points && h.points.length >= 3) {
|
||
ctx.moveTo(h.points[0].x, h.points[0].y)
|
||
for (let pi = 1; pi < h.points.length; pi++) ctx.lineTo(h.points[pi].x, h.points[pi].y)
|
||
ctx.closePath()
|
||
}
|
||
}
|
||
ctx.closePath()
|
||
ctx.fillStrokeShape(s)
|
||
}
|
||
return {
|
||
x: 0,
|
||
y: 0,
|
||
width: asset.canvas.width,
|
||
height: asset.canvas.height,
|
||
fill: 'rgba(0,0,0,0.001)',
|
||
// Always listen so the button group can be dragged/selected in edit
|
||
// mode. In preview mode, disabled buttons stop listening so they
|
||
// don't respond to hover/click.
|
||
listening: !isLayerEffectivelyLocked(layer) && (!stagePreview.value || !layer.buttonDisabled),
|
||
hitFunc,
|
||
name: `button-hit:${layer.id}`,
|
||
}
|
||
}
|
||
|
||
function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
|
||
return { ...cfg, draggable: false, listening: false }
|
||
}
|
||
|
||
/** SVG-asset child wrapper. Same listening/drag config as button
|
||
* children, but also pins `strokeScaleEnabled: false`. svgStackForAsset
|
||
* pre-multiplies each child's strokeWidth by the outer placement
|
||
* scale; combined with strokeScaleEnabled:false (Konva resets the
|
||
* ctx transform to identity before stroking), the on-screen stroke
|
||
* width comes out at the right number of pixels regardless of
|
||
* whether Konva propagates the parent group's scale to nested
|
||
* stroke rendering. */
|
||
function svgChildConfig<T extends Record<string, unknown>>(cfg: T): T {
|
||
return { ...cfg, draggable: false, listening: false, strokeScaleEnabled: false }
|
||
}
|
||
|
||
/** Invisible Konva rect that covers the SVG asset's canvas rect.
|
||
* Children are non-listening (buttonChildConfig) so without a hit
|
||
* surface the layer would fall through to the stage and stay
|
||
* unselectable. The catcher routes mousedown to the stage handler
|
||
* via its `svg-hit:${id}` name, mirroring the button-hit pattern. */
|
||
function svgHitCatcherConfig(asset: SvgAsset, layer: Layer) {
|
||
return {
|
||
x: 0,
|
||
y: 0,
|
||
width: asset.canvas.width,
|
||
height: asset.canvas.height,
|
||
fill: 'rgba(0,0,0,0.001)',
|
||
listening: !isLayerEffectivelyLocked(layer),
|
||
name: `svg-hit:${layer.id}`,
|
||
}
|
||
}
|
||
|
||
function buttonStackForTime(
|
||
asset: ButtonAsset,
|
||
stateTime: number,
|
||
): Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> {
|
||
const out: Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> = []
|
||
const layers = asset.layers.slice().reverse()
|
||
for (const l of layers) {
|
||
if (!l.visible) continue
|
||
// Button-state visibility: still-frame kfs render only on their
|
||
// own state, not held into neighbouring states.
|
||
if (!isButtonLayerActiveAt(l, stateTime)) continue
|
||
const a = assetMap.value.get(l.assetId)
|
||
if (!a) continue
|
||
out.push({ layer: l, asset: a, props: sampleLayer(l, stateTime) })
|
||
}
|
||
return out
|
||
}
|
||
|
||
/** Static render stack for an SVG asset placed in the main scene.
|
||
* All child keyframes live at t=0 by construction, so there's no
|
||
* per-state lookup — just sample each visible layer once.
|
||
*
|
||
* `outerScale` is the placement layer's scale factor; child stroke
|
||
* widths get pre-multiplied by it so the rendered outline grows
|
||
* proportionally with the outer scale. Konva's strokeScaleEnabled
|
||
* only honours the *node's own* scale, not the cumulative parent
|
||
* transform — without this pre-multiply, strokes inside a scaled
|
||
* SVG asset would render at constant pixel width even though every
|
||
* shape inside grows. */
|
||
function svgStackForAsset(
|
||
asset: SvgAsset,
|
||
outerScale = 1,
|
||
): Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> {
|
||
const out: Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> = []
|
||
const layers = asset.layers.slice().reverse()
|
||
for (const l of layers) {
|
||
if (!l.visible) continue
|
||
if (l.editorHidden) continue
|
||
const a = assetMap.value.get(l.assetId)
|
||
if (!a) continue
|
||
const props = sampleLayer(l, 0)
|
||
// Resolve effective stroke width from props ?? asset, then scale.
|
||
// strokeCfg looks at props.strokeWidth first; setting it here
|
||
// overrides the asset fallback for this render only.
|
||
const baseSw =
|
||
props.strokeWidth ??
|
||
('strokeWidth' in a ? (a.strokeWidth as number | undefined) : undefined) ??
|
||
0
|
||
if (outerScale !== 1 && baseSw > 0) {
|
||
props.strokeWidth = baseSw * outerScale
|
||
}
|
||
out.push({ layer: l, asset: a, props })
|
||
}
|
||
return out
|
||
}
|
||
|
||
function svgAssetFor(asset: Asset): SvgAsset | null {
|
||
return asset.kind === 'svg' ? asset : null
|
||
}
|
||
|
||
function setStageCursor(cursor: string) {
|
||
const el = stageRef.value?.getStage().container()
|
||
if (el) el.style.cursor = cursor
|
||
}
|
||
|
||
function onButtonEnter(layer: Layer) {
|
||
// Pointer cursor whenever a button asset is under the mouse — it's
|
||
// interactive UX even in edit mode, where it's draggable like any
|
||
// other layer. In preview mode, also flip the state machine into
|
||
// `hover` so visual state changes fire.
|
||
if (!isLayerEffectivelyLocked(layer)) setStageCursor('pointer')
|
||
if (!stagePreview.value) return
|
||
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
|
||
if (cur === 'normal') buttonRt.setState(layer.id, 'hover')
|
||
}
|
||
|
||
function onButtonLeave(layer: Layer) {
|
||
setStageCursor('')
|
||
if (!stagePreview.value) return
|
||
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
|
||
if (cur === 'hover' || cur === 'pressed') buttonRt.setState(layer.id, 'normal')
|
||
}
|
||
|
||
function onButtonDown(layer: Layer) {
|
||
if (!stagePreview.value) return
|
||
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
|
||
if (cur === 'hover') buttonRt.setState(layer.id, 'pressed')
|
||
}
|
||
|
||
function onButtonUp(layer: Layer) {
|
||
if (!stagePreview.value) return
|
||
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
|
||
if (cur !== 'pressed') return
|
||
buttonRt.setState(layer.id, 'hover')
|
||
const actions = layer.onClick
|
||
if (actions && actions.length) {
|
||
const rt = store.actionRuntime()
|
||
for (const a of actions) evalAction(a, rt)
|
||
}
|
||
}
|
||
|
||
// ButtonAsset lookup helper for template
|
||
function buttonAssetFor(asset: Asset): ButtonAsset | null {
|
||
return asset.kind === 'button' ? asset : null
|
||
}
|
||
|
||
function buttonTimeFor(layer: Layer): number {
|
||
// In edit mode the button is a static asset — always show its normal
|
||
// state regardless of leftover runtime state (e.g. from a previous
|
||
// preview session).
|
||
if (!stagePreview.value) return BUTTON_STATE_TIMES.normal
|
||
return buttonRt.getEffectiveTime(layer.id, layer.buttonDisabled)
|
||
}
|
||
|
||
const stageConfig = computed(() => ({
|
||
width: containerSize.value.width,
|
||
height: containerSize.value.height,
|
||
}))
|
||
|
||
const viewConfig = computed(() => ({
|
||
x: viewX.value,
|
||
y: viewY.value,
|
||
scaleX: zoom.value,
|
||
scaleY: zoom.value,
|
||
}))
|
||
|
||
const clipGroupConfig = computed(() => {
|
||
const w = effectiveCanvas.value.width
|
||
const h = effectiveCanvas.value.height
|
||
const crop = cropObjects.value
|
||
return {
|
||
clipFunc: (ctx: CanvasRenderingContext2D) => {
|
||
if (crop) ctx.rect(0, 0, w, h)
|
||
else ctx.rect(-1e6, -1e6, 2e6, 2e6)
|
||
},
|
||
}
|
||
})
|
||
|
||
/** Horizontal + vertical centre-crosshair line configs. Null when
|
||
* the toggle is off. Lines span the effective canvas so the centre
|
||
* is exactly where layer keyframes default to (canvas.width/2,
|
||
* canvas.height/2). Stroke + dash are scaled by `1 / zoom` so they
|
||
* stay 1 screen-pixel thick and the dashes stay the same visual
|
||
* length at any zoom level. `listening: false` keeps the lines out
|
||
* of hit-testing — they're purely a visual guide. */
|
||
const centerCrosshairConfigs = computed(() => {
|
||
if (!centerCrosshair.value) return null
|
||
const w = effectiveCanvas.value.width
|
||
const h = effectiveCanvas.value.height
|
||
const cx = w / 2
|
||
const cy = h / 2
|
||
const z = zoom.value || 1
|
||
const strokeColor = 'rgba(79, 140, 255, 0.7)'
|
||
const strokeWidth = 1 / z
|
||
const dash = [6 / z, 4 / z]
|
||
return {
|
||
horizontal: {
|
||
points: [0, cy, w, cy],
|
||
stroke: strokeColor,
|
||
strokeWidth,
|
||
dash,
|
||
listening: false,
|
||
},
|
||
vertical: {
|
||
points: [cx, 0, cx, h],
|
||
stroke: strokeColor,
|
||
strokeWidth,
|
||
dash,
|
||
listening: false,
|
||
},
|
||
}
|
||
})
|
||
|
||
const backgroundConfig = computed(() => {
|
||
// SVG-asset edit: same dark "outside the document" fill as the
|
||
// button editor so the user sees where the asset's canvas ends.
|
||
// The asset itself composites with alpha; the dark backdrop is
|
||
// visual chrome only.
|
||
if (store.activeSvgAssetId) {
|
||
return {
|
||
x: 0,
|
||
y: 0,
|
||
width: effectiveCanvas.value.width,
|
||
height: effectiveCanvas.value.height,
|
||
listening: false,
|
||
fill: '#1a1a1f',
|
||
}
|
||
}
|
||
// In button-asset edit: checkerboard-ish transparent background so the
|
||
// user sees where the button's canvas ends.
|
||
if (store.activeButtonAssetId) {
|
||
return {
|
||
x: 0,
|
||
y: 0,
|
||
width: effectiveCanvas.value.width,
|
||
height: effectiveCanvas.value.height,
|
||
listening: false,
|
||
fill: '#1a1a1f',
|
||
}
|
||
}
|
||
// Always set `fill` — toggling it in/out of the config across scene
|
||
// reloads can leave Konva's Rect with a stale attribute. Use a
|
||
// transparent fill when a background video is present so the DOM
|
||
// video shows through.
|
||
const fill = store.scene.backgroundVideo
|
||
? 'transparent'
|
||
: store.scene.canvas.background || '#ffffff'
|
||
return {
|
||
x: 0,
|
||
y: 0,
|
||
width: store.scene.canvas.width,
|
||
height: store.scene.canvas.height,
|
||
listening: false,
|
||
fill,
|
||
}
|
||
})
|
||
|
||
const bgVideoStyle = computed(() => {
|
||
const w = store.scene.canvas.width * zoom.value
|
||
const h = store.scene.canvas.height * zoom.value
|
||
return {
|
||
left: `${viewX.value}px`,
|
||
top: `${viewY.value}px`,
|
||
width: `${w}px`,
|
||
height: `${h}px`,
|
||
}
|
||
})
|
||
|
||
// Playhead sync for the background video — only resync current time on bigger
|
||
// deltas so regular playback stays smooth.
|
||
const BG_VIDEO_SYNC_THRESHOLD = 0.12
|
||
watch(
|
||
() => store.playing,
|
||
async (playing) => {
|
||
const v = bgVideoEl.value
|
||
if (!v) return
|
||
if (playing) {
|
||
v.currentTime = store.playhead
|
||
try {
|
||
await v.play()
|
||
} catch {
|
||
/* autoplay might be blocked if the tab has no user gesture yet */
|
||
}
|
||
} else {
|
||
v.pause()
|
||
v.currentTime = store.playhead
|
||
}
|
||
},
|
||
)
|
||
|
||
watch(
|
||
() => store.playhead,
|
||
(t) => {
|
||
const v = bgVideoEl.value
|
||
if (!v) return
|
||
const diff = Math.abs(v.currentTime - t)
|
||
if (!store.playing) {
|
||
v.currentTime = t
|
||
return
|
||
}
|
||
if (diff > BG_VIDEO_SYNC_THRESHOLD) v.currentTime = t
|
||
},
|
||
)
|
||
|
||
// When the video element mounts (or the src changes) do an initial sync.
|
||
watch(
|
||
() => store.scene.backgroundVideo?.src,
|
||
async () => {
|
||
await nextTick()
|
||
const v = bgVideoEl.value
|
||
if (!v) return
|
||
v.currentTime = store.playhead
|
||
if (store.playing) {
|
||
try {
|
||
await v.play()
|
||
} catch {
|
||
/* ignored */
|
||
}
|
||
}
|
||
},
|
||
)
|
||
|
||
const canvasBorderConfig = computed(() => ({
|
||
x: 0,
|
||
y: 0,
|
||
width: effectiveCanvas.value.width,
|
||
height: effectiveCanvas.value.height,
|
||
stroke: '#4a4a56',
|
||
strokeWidth: 1 / zoom.value,
|
||
listening: false,
|
||
}))
|
||
|
||
const zoomPercent = computed(() => Math.round(zoom.value * 100))
|
||
|
||
const assetMap = computed(() => {
|
||
const m = new Map<string, Asset>()
|
||
for (const a of store.scene.assets) m.set(a.id, a)
|
||
return m
|
||
})
|
||
|
||
const imageCache = ref<Record<string, HTMLImageElement>>({})
|
||
|
||
watch(
|
||
() => store.scene.assets.map((a) => (a.kind === 'image' ? `${a.id}:${a.src.length}` : null)),
|
||
() => {
|
||
for (const a of store.scene.assets) {
|
||
if (a.kind !== 'image') continue
|
||
if (imageCache.value[a.id]) continue
|
||
loadImage(a.src)
|
||
.then((img) => {
|
||
imageCache.value = { ...imageCache.value, [a.id]: img }
|
||
})
|
||
.catch((err) => {
|
||
console.error('image load failed', err)
|
||
})
|
||
}
|
||
},
|
||
{ immediate: true, deep: true },
|
||
)
|
||
|
||
interface RenderedLayer {
|
||
layer: Layer
|
||
asset: Asset
|
||
props: AnimatableProps
|
||
}
|
||
|
||
interface RenderedClipLayer extends RenderedLayer {
|
||
instanceId: string
|
||
}
|
||
|
||
interface RenderedClipInstance {
|
||
instance: ClipInstance
|
||
/** Union bbox of every clip layer across its full animation — the
|
||
* stable frame that the transformer wraps. Null when the clip has
|
||
* no measurable layers (template falls back to a placeholder). */
|
||
bounds: Rect | null
|
||
items: RenderedClipLayer[]
|
||
}
|
||
|
||
type StackItem =
|
||
| { kind: 'layer'; item: RenderedLayer }
|
||
| { kind: 'clipInstance'; bundle: RenderedClipInstance }
|
||
|
||
function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps {
|
||
const live = liveLayerPoses.value[layerId]
|
||
if (!live) return props
|
||
return {
|
||
...props,
|
||
x: live.x,
|
||
y: live.y,
|
||
scaleX: live.scaleX,
|
||
scaleY: live.scaleY,
|
||
rotation: live.rotation,
|
||
}
|
||
}
|
||
|
||
const stackItems = computed<StackItem[]>(() => {
|
||
// Button-asset edit: show only the active state's layer stack. In the
|
||
// 'hit' cell show the normal state's layers as an onion-skin
|
||
// underneath the hit-area gizmos. Visibility follows the
|
||
// button-specific rule: a still-frame kf renders only on its own
|
||
// state, not held into subsequent states.
|
||
if (store.activeButtonAssetId) {
|
||
const out: StackItem[] = []
|
||
const ba = store.activeButtonAsset
|
||
if (!ba) return out
|
||
const onionSkin = store.activeButtonEditMode === 'hit'
|
||
const layers = ba.layers.slice().reverse()
|
||
for (const layer of layers) {
|
||
if (!layer.visible) continue
|
||
if (layer.editorHidden) continue
|
||
if (!isButtonLayerActiveAt(layer, store.playhead)) continue
|
||
const asset = assetMap.value.get(layer.assetId)
|
||
if (!asset) continue
|
||
const props = applyLivePose(sampleLayer(layer, store.playhead), layer.id)
|
||
if (onionSkin) props.opacity = (props.opacity ?? 1) * 0.35
|
||
out.push({ kind: 'layer', item: { layer, asset, props } })
|
||
}
|
||
return out
|
||
}
|
||
// Svg-asset edit scope: render the asset's child layers at t=0.
|
||
// No time-axis filtering — every visible layer always shows.
|
||
if (store.activeSvgAssetId) {
|
||
const out: StackItem[] = []
|
||
const sa = store.activeSvgAsset
|
||
if (!sa) return out
|
||
const layers = sa.layers.slice().reverse()
|
||
for (const layer of layers) {
|
||
if (!layer.visible) continue
|
||
if (layer.editorHidden) continue
|
||
const asset = assetMap.value.get(layer.assetId)
|
||
if (!asset) continue
|
||
out.push({
|
||
kind: 'layer',
|
||
item: {
|
||
layer,
|
||
asset,
|
||
props: applyLivePose(sampleLayer(layer, 0), layer.id),
|
||
},
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
// Clip-edit scope: just render active clip's layers (top of timeline = top of canvas)
|
||
if (store.activeClipId) {
|
||
const out: StackItem[] = []
|
||
const layers = store.activeLayers.slice().reverse()
|
||
for (const layer of layers) {
|
||
if (layer.editorHidden) continue
|
||
if (!isLayerActiveAt(layer, store.playhead)) continue
|
||
const asset = assetMap.value.get(layer.assetId)
|
||
if (!asset) continue
|
||
out.push({
|
||
kind: 'layer',
|
||
item: {
|
||
layer,
|
||
asset,
|
||
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
|
||
},
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
const out: StackItem[] = []
|
||
const layerById = new Map<string, Layer>()
|
||
for (const l of store.scene.layers) layerById.set(l.id, l)
|
||
const clipById = new Map<string, NonNullable<typeof store.scene.clips>[number]>()
|
||
for (const c of store.scene.clips ?? []) clipById.set(c.id, c)
|
||
const clipTrackById = new Map<string, NonNullable<typeof store.scene.clipTracks>[number]>()
|
||
for (const t of store.scene.clipTracks ?? []) clipTrackById.set(t.id, t)
|
||
const instancesByTrack = new Map<string, NonNullable<typeof store.scene.clipInstances>>()
|
||
for (const ci of store.scene.clipInstances ?? []) {
|
||
const arr = instancesByTrack.get(ci.clipTrackId) ?? []
|
||
arr.push(ci)
|
||
instancesByTrack.set(ci.clipTrackId, arr)
|
||
}
|
||
|
||
// Tracks whose parent TrackGroup is editor-hidden — same cascade
|
||
// as the Timeline's lockedGroupChildIds logic, just for the eye
|
||
// toggle. A child with its own editorHidden flag unset still
|
||
// disappears if its group carries one.
|
||
const hiddenGroupChildIds = new Set<string>()
|
||
for (const g of store.scene.groups ?? []) {
|
||
if (g.editorHidden) for (const cid of g.childIds) hiddenGroupChildIds.add(cid)
|
||
}
|
||
const expanded = expandedTrackOrder(store.scene)
|
||
const order = expanded.length ? expanded : store.scene.layers.map((l) => l.id)
|
||
// iterate bottom → top: reverse the array
|
||
for (let i = order.length - 1; i >= 0; i--) {
|
||
const id = order[i]
|
||
if (hiddenGroupChildIds.has(id)) continue
|
||
const layer = layerById.get(id)
|
||
if (layer) {
|
||
// Editor-only hide: skip on the Konva stage while the scene
|
||
// file still carries the layer, so export/fullscreen-preview
|
||
// render it normally.
|
||
if (layer.editorHidden) continue
|
||
if (!isLayerActiveAt(layer, store.playhead)) continue
|
||
const asset = assetMap.value.get(layer.assetId)
|
||
if (!asset) continue
|
||
out.push({
|
||
kind: 'layer',
|
||
item: {
|
||
layer,
|
||
asset,
|
||
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
|
||
},
|
||
})
|
||
continue
|
||
}
|
||
// Editor-only hide for an entire clip track — skip every instance
|
||
// on it in the Konva stack so the stage matches the eye-icon
|
||
// state. Exports still see the instances unless the user opts
|
||
// into strip-hidden.
|
||
if (clipTrackById.get(id)?.editorHidden) continue
|
||
const instances = instancesByTrack.get(id) ?? []
|
||
for (const inst of instances) {
|
||
const clip = clipById.get(inst.clipId)
|
||
if (!clip) continue
|
||
const trimStart = inst.trimStart ?? 0
|
||
const trimEnd = inst.trimEnd ?? 0
|
||
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
|
||
const offsetIntoBlock = store.playhead - inst.startTime
|
||
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
|
||
const localTime = trimStart + offsetIntoBlock
|
||
// Bundle the clip's layers under a single clip-instance entry
|
||
// so the template can wrap them in a draggable Konva group that
|
||
// carries the instance's x / y / scale transform.
|
||
const items: RenderedClipLayer[] = []
|
||
for (let k = clip.layers.length - 1; k >= 0; k--) {
|
||
const cl = clip.layers[k]
|
||
if (!cl.visible) continue
|
||
if (!isLayerActiveAt(cl, localTime)) continue
|
||
const ca = assetMap.value.get(cl.assetId)
|
||
if (!ca) continue
|
||
items.push({
|
||
instanceId: inst.id,
|
||
layer: cl,
|
||
asset: ca,
|
||
props: sampleLayer(cl, localTime),
|
||
})
|
||
}
|
||
// Always push the bundle when the instance is in time-range,
|
||
// even if no items are renderable at this playhead: the frame
|
||
// rect still needs to render so the clip can be selected (and
|
||
// the transformer framed) on empty frames.
|
||
out.push({
|
||
kind: 'clipInstance',
|
||
bundle: {
|
||
instance: inst,
|
||
bounds: computeClipBounds(clip, assetMap.value),
|
||
items,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
return out
|
||
})
|
||
|
||
function shadowCfg(props: AnimatableProps): Record<string, unknown> {
|
||
// Konva's default shadowOpacity is 1, so returning {} for the "no
|
||
// shadow" case isn't enough — the shape would reuse whatever values
|
||
// were set on a previous render. Always emit explicit values and let
|
||
// `shadowEnabled` be the single source of truth. The gate matches
|
||
// the canvas/preview path: opacity > 0 AND some blur or distance.
|
||
const opacity = props.shadowOpacity ?? 0
|
||
const blur = props.shadowBlur ?? 0
|
||
const distance = props.shadowDistance ?? 0
|
||
const angle = props.shadowAngle ?? 90
|
||
const rad = (angle * Math.PI) / 180
|
||
return {
|
||
shadowColor: props.shadowColor ?? '#000000',
|
||
shadowBlur: blur,
|
||
shadowOpacity: opacity,
|
||
shadowOffsetX: Math.cos(rad) * distance,
|
||
shadowOffsetY: Math.sin(rad) * distance,
|
||
shadowEnabled: opacity > 0 && (blur > 0 || distance > 0),
|
||
}
|
||
}
|
||
|
||
function isLayerEffectivelyLocked(layer: Layer): boolean {
|
||
if (layer.locked) return true
|
||
for (const g of store.scene.groups ?? []) {
|
||
if (g.locked && g.childIds.includes(layer.id)) return true
|
||
}
|
||
// In hit-area edit the user's focus is on hit geometry; layers are onion-
|
||
// skinned and must not steal pointer events from the hit-shape handles.
|
||
if (store.activeButtonEditMode === 'hit') return true
|
||
return false
|
||
}
|
||
|
||
/** Clip-track counterpart to `isLayerEffectivelyLocked`. Returns true
|
||
* when the ClipTrack's own `locked` is set OR the track sits inside
|
||
* a locked group. Used to disable the ci-group's listening + drag so
|
||
* a locked clip behaves as if it weren't on the stage — the user
|
||
* can't select, drag, or transform its instance. */
|
||
function isClipTrackEffectivelyLocked(trackId: string): boolean {
|
||
const track = store.scene.clipTracks?.find((t) => t.id === trackId)
|
||
if (track?.locked) return true
|
||
for (const g of store.scene.groups ?? []) {
|
||
if (g.locked && g.childIds.includes(trackId)) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
function isLayerGhostLocked(layer: Layer): boolean {
|
||
return isLayerEffectivelyLocked(layer) && !showLocked.value
|
||
}
|
||
|
||
function ghostLockColor(layer: Layer, fallback?: string | null): string {
|
||
if (layer.color) return layer.color
|
||
if (fallback && fallback !== 'transparent') return fallback
|
||
return '#6ea8ff'
|
||
}
|
||
|
||
function baseNodeConfig(layer: Layer, props: AnimatableProps) {
|
||
const maskGhost = layer.isMask
|
||
? {
|
||
opacity: props.opacity * 0.45,
|
||
stroke: layer.maskMode === 'negative' ? '#ff6b6b' : '#6ea8ff',
|
||
strokeWidth: 1.5,
|
||
dash: [6, 4],
|
||
strokeScaleEnabled: false,
|
||
}
|
||
: {}
|
||
// Any locked layer (direct or via a locked group) is inert on the stage.
|
||
// When the timeline also hides locked tracks the per-shape config switches
|
||
// to a thin outline — handled by isLayerGhostLocked() checks per shape.
|
||
const effectivelyLocked = isLayerEffectivelyLocked(layer)
|
||
return {
|
||
id: layer.id,
|
||
x: props.x,
|
||
y: props.y,
|
||
scaleX: props.scaleX,
|
||
scaleY: props.scaleY,
|
||
rotation: props.rotation,
|
||
opacity: props.opacity,
|
||
visible: layer.visible,
|
||
draggable: !effectivelyLocked,
|
||
listening: !effectivelyLocked,
|
||
...shadowCfg(props),
|
||
...maskGhost,
|
||
}
|
||
}
|
||
|
||
type KonvaCtx = {
|
||
beginPath: () => void
|
||
closePath: () => void
|
||
rect: (x: number, y: number, w: number, h: number) => void
|
||
arc: (x: number, y: number, r: number, s: number, e: number, cc?: boolean) => void
|
||
moveTo: (x: number, y: number) => void
|
||
lineTo: (x: number, y: number) => void
|
||
bezierCurveTo: (
|
||
c1x: number,
|
||
c1y: number,
|
||
c2x: number,
|
||
c2y: number,
|
||
x: number,
|
||
y: number,
|
||
) => void
|
||
fillStrokeShape: (shape: unknown) => void
|
||
save: () => void
|
||
restore: () => void
|
||
setAttr: (attr: string, value: unknown) => void
|
||
}
|
||
|
||
type KonvaShape = { width(): number; height(): number; radius(): number; attrs: Record<string, unknown> }
|
||
|
||
function makeRectSceneFunc(blur: number) {
|
||
return (ctx: KonvaCtx, shape: KonvaShape) => {
|
||
ctx.save()
|
||
if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`)
|
||
ctx.beginPath()
|
||
ctx.rect(0, 0, shape.width(), shape.height())
|
||
ctx.closePath()
|
||
ctx.fillStrokeShape(shape)
|
||
ctx.restore()
|
||
}
|
||
}
|
||
|
||
function makeCircleSceneFunc(blur: number) {
|
||
return (ctx: KonvaCtx, shape: KonvaShape) => {
|
||
ctx.save()
|
||
if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`)
|
||
ctx.beginPath()
|
||
ctx.arc(0, 0, shape.radius(), 0, Math.PI * 2, false)
|
||
ctx.closePath()
|
||
ctx.fillStrokeShape(shape)
|
||
ctx.restore()
|
||
}
|
||
}
|
||
|
||
function strokeCfg(
|
||
props: AnimatableProps,
|
||
asset: { stroke?: string; strokeWidth?: number },
|
||
): Record<string, unknown> {
|
||
const strokeWidth = props.strokeWidth ?? asset.strokeWidth ?? 0
|
||
if (strokeWidth <= 0) return { strokeWidth: 0, stroke: undefined }
|
||
const stroke = (props.stroke ?? asset.stroke) || '#000000'
|
||
if (stroke === 'transparent') return { strokeWidth: 0, stroke: undefined }
|
||
// Strokes scale with the layer (Konva default). Set explicitly:
|
||
// Konva's bounding-box / hit / cache paths can default to the
|
||
// shape's previous attribute value when the prop is reactively
|
||
// omitted, so `strokeScaleEnabled: true` lands on the node either
|
||
// way after the first render.
|
||
return { stroke, strokeWidth, strokeScaleEnabled: true }
|
||
}
|
||
|
||
function rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props: AnimatableProps) {
|
||
const fill = props.fill ?? asset.fill
|
||
const blur = props.blur ?? asset.blur ?? 0
|
||
const base = baseNodeConfig(layer, props)
|
||
if (isLayerGhostLocked(layer)) {
|
||
return {
|
||
...base,
|
||
width: asset.width,
|
||
height: asset.height,
|
||
offsetX: layer.offsetX ?? asset.width / 2,
|
||
offsetY: layer.offsetY ?? asset.height / 2,
|
||
fill: undefined,
|
||
stroke: ghostLockColor(layer, fill),
|
||
strokeWidth: 1,
|
||
strokeScaleEnabled: false,
|
||
}
|
||
}
|
||
const cfg: Record<string, unknown> = {
|
||
...base,
|
||
width: asset.width,
|
||
height: asset.height,
|
||
offsetX: layer.offsetX ?? asset.width / 2,
|
||
offsetY: layer.offsetY ?? asset.height / 2,
|
||
...(layer.isMask ? {} : strokeCfg(props, asset)),
|
||
}
|
||
if (fill && fill !== 'transparent') cfg.fill = fill
|
||
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
|
||
return cfg
|
||
}
|
||
|
||
function circleConfig(
|
||
layer: Layer,
|
||
asset: Extract<Asset, { kind: 'circle' }>,
|
||
props: AnimatableProps,
|
||
) {
|
||
const fill = props.fill ?? asset.fill
|
||
const blur = props.blur ?? asset.blur ?? 0
|
||
const base = baseNodeConfig(layer, props)
|
||
if (isLayerGhostLocked(layer)) {
|
||
return {
|
||
...base,
|
||
radius: asset.radius,
|
||
offsetX: layer.offsetX ?? 0,
|
||
offsetY: layer.offsetY ?? 0,
|
||
fill: undefined,
|
||
stroke: ghostLockColor(layer, fill),
|
||
strokeWidth: 1,
|
||
strokeScaleEnabled: false,
|
||
}
|
||
}
|
||
const cfg: Record<string, unknown> = {
|
||
...base,
|
||
radius: asset.radius,
|
||
offsetX: layer.offsetX ?? 0,
|
||
offsetY: layer.offsetY ?? 0,
|
||
...(layer.isMask ? {} : strokeCfg(props, asset)),
|
||
}
|
||
if (fill && fill !== 'transparent') cfg.fill = fill
|
||
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
|
||
return cfg
|
||
}
|
||
|
||
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
|
||
const fill = props.fill ?? asset.fill
|
||
const base = baseNodeConfig(layer, props)
|
||
if (isLayerGhostLocked(layer)) {
|
||
return {
|
||
...base,
|
||
text: asset.text,
|
||
fontSize: asset.fontSize,
|
||
offsetX: layer.offsetX ?? 0,
|
||
offsetY: layer.offsetY ?? 0,
|
||
fill: undefined,
|
||
stroke: ghostLockColor(layer, fill),
|
||
strokeWidth: 1,
|
||
strokeScaleEnabled: false,
|
||
}
|
||
}
|
||
const cfg: Record<string, unknown> = {
|
||
...base,
|
||
text: asset.text,
|
||
fontSize: asset.fontSize,
|
||
offsetX: layer.offsetX ?? 0,
|
||
offsetY: layer.offsetY ?? 0,
|
||
...(layer.isMask ? {} : strokeCfg(props, asset)),
|
||
}
|
||
if (fill && fill !== 'transparent') cfg.fill = fill
|
||
return cfg
|
||
}
|
||
|
||
function traceKonvaPolyPath(
|
||
ctx: KonvaCtx,
|
||
samplePoints: { x: number; y: number }[],
|
||
assetPoints: Point[],
|
||
offX: number,
|
||
offY: number,
|
||
closed: boolean,
|
||
) {
|
||
const n = samplePoints.length
|
||
if (n < 2) return
|
||
ctx.beginPath()
|
||
ctx.moveTo(samplePoints[0].x - offX, samplePoints[0].y - offY)
|
||
const endI = closed ? n : n - 1
|
||
for (let i = 0; i < endI; i++) {
|
||
const aPos = samplePoints[i]
|
||
const bIdx = (i + 1) % n
|
||
const bPos = samplePoints[bIdx]
|
||
const aAsset = assetPoints[i] as Point | undefined
|
||
const bAsset = assetPoints[bIdx] as Point | undefined
|
||
const aHas = aAsset && aAsset.type && aAsset.type !== 'corner'
|
||
const bHas = bAsset && bAsset.type && bAsset.type !== 'corner'
|
||
if (aHas || bHas) {
|
||
const aSp = samplePoints[i] as Point
|
||
const bSp = samplePoints[bIdx] as Point
|
||
const aox = aHas ? aSp.outX ?? aAsset!.outX ?? 0 : 0
|
||
const aoy = aHas ? aSp.outY ?? aAsset!.outY ?? 0 : 0
|
||
const bix = bHas ? bSp.inX ?? bAsset!.inX ?? 0 : 0
|
||
const biy = bHas ? bSp.inY ?? bAsset!.inY ?? 0 : 0
|
||
ctx.bezierCurveTo(
|
||
aPos.x + aox - offX,
|
||
aPos.y + aoy - offY,
|
||
bPos.x + bix - offX,
|
||
bPos.y + biy - offY,
|
||
bPos.x - offX,
|
||
bPos.y - offY,
|
||
)
|
||
} else {
|
||
ctx.lineTo(bPos.x - offX, bPos.y - offY)
|
||
}
|
||
}
|
||
if (closed) ctx.closePath()
|
||
}
|
||
|
||
function polygonConfig(
|
||
layer: Layer,
|
||
asset: Extract<Asset, { kind: 'polygon' }>,
|
||
props: AnimatableProps,
|
||
) {
|
||
const fill = props.fill ?? asset.fill
|
||
const rawStroke = props.stroke ?? asset.stroke
|
||
const strokeWidth = props.strokeWidth ?? asset.strokeWidth ?? 0
|
||
const stroke = strokeWidth > 0 ? (rawStroke || '#000000') : undefined
|
||
const blur = props.blur ?? asset.blur ?? 0
|
||
const offX = layer.offsetX ?? 0
|
||
const offY = layer.offsetY ?? 0
|
||
const pts = props.points ?? asset.points
|
||
const closed = asset.closed !== false
|
||
const flat: number[] = []
|
||
for (const p of pts) {
|
||
flat.push(p.x - offX, p.y - offY)
|
||
}
|
||
const hasCurves = asset.points.some((p) => p.type && p.type !== 'corner')
|
||
const base = baseNodeConfig(layer, props)
|
||
if (isLayerGhostLocked(layer)) {
|
||
const ghostCfg: Record<string, unknown> = {
|
||
...base,
|
||
points: flat,
|
||
closed,
|
||
lineJoin: 'round',
|
||
lineCap: 'round',
|
||
fill: undefined,
|
||
stroke: ghostLockColor(layer, fill),
|
||
strokeWidth: 1,
|
||
strokeScaleEnabled: false,
|
||
}
|
||
if (hasCurves) {
|
||
ghostCfg.sceneFunc = (ctx: KonvaCtx, shape: KonvaShape) => {
|
||
traceKonvaPolyPath(ctx, pts, asset.points, offX, offY, closed)
|
||
ctx.fillStrokeShape(shape)
|
||
}
|
||
}
|
||
return ghostCfg
|
||
}
|
||
const cfg: Record<string, unknown> = {
|
||
...base,
|
||
points: flat,
|
||
closed,
|
||
lineJoin: 'round',
|
||
lineCap: 'round',
|
||
}
|
||
if (closed && fill && fill !== 'transparent') cfg.fill = fill
|
||
if (!layer.isMask && stroke && strokeWidth > 0 && stroke !== 'transparent') {
|
||
cfg.stroke = stroke
|
||
cfg.strokeWidth = strokeWidth
|
||
// Default Konva behaviour: stroke scales with the node's own
|
||
// scale. Previously hard-pinned to false, which left freeform /
|
||
// polygon outlines at constant pixel width when the user scaled
|
||
// the layer. svgChildConfig overrides this back to false for
|
||
// SVG-asset placements (which pre-multiply strokeWidth instead).
|
||
cfg.strokeScaleEnabled = true
|
||
} else {
|
||
cfg.stroke = undefined
|
||
cfg.strokeWidth = 0
|
||
}
|
||
cfg.hitStrokeWidth = Math.max(strokeWidth, 14 / zoom.value)
|
||
if (blur > 0 || hasCurves) {
|
||
cfg.sceneFunc = (ctx: KonvaCtx, shape: KonvaShape) => {
|
||
ctx.save()
|
||
if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`)
|
||
traceKonvaPolyPath(ctx, pts, asset.points, offX, offY, closed)
|
||
ctx.fillStrokeShape(shape)
|
||
ctx.restore()
|
||
}
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
function imageConfig(
|
||
layer: Layer,
|
||
asset: Extract<Asset, { kind: 'image' }>,
|
||
props: AnimatableProps,
|
||
) {
|
||
const img = imageCache.value[asset.id]
|
||
if (!img) return null
|
||
if (isLayerGhostLocked(layer)) {
|
||
// Show a 1px outline of the image's bounding box instead of the pixels.
|
||
return {
|
||
...baseNodeConfig(layer, props),
|
||
width: asset.width,
|
||
height: asset.height,
|
||
offsetX: layer.offsetX ?? asset.width / 2,
|
||
offsetY: layer.offsetY ?? asset.height / 2,
|
||
fill: undefined,
|
||
stroke: ghostLockColor(layer, null),
|
||
strokeWidth: 1,
|
||
strokeScaleEnabled: false,
|
||
sceneFunc: (ctx: KonvaCtx, shape: KonvaShape) => {
|
||
ctx.beginPath()
|
||
ctx.rect(0, 0, asset.width, asset.height)
|
||
ctx.closePath()
|
||
ctx.fillStrokeShape(shape)
|
||
},
|
||
}
|
||
}
|
||
return {
|
||
...baseNodeConfig(layer, props),
|
||
image: img,
|
||
width: asset.width,
|
||
height: asset.height,
|
||
offsetX: layer.offsetX ?? asset.width / 2,
|
||
offsetY: layer.offsetY ?? asset.height / 2,
|
||
}
|
||
}
|
||
|
||
/** Per-clip-instance wrapper group. Carries the instance's static
|
||
* x / y / scale so the user can reposition the packed clip on the
|
||
* stage without touching its inner keyframes. `rotation` is
|
||
* deliberately absent — the user asked for move + scale only.
|
||
* The inner child configs keep `listening: false`, so pointer
|
||
* events funnel to the outer group and drag/transform apply as a
|
||
* unit. */
|
||
function clipInstanceGroupConfig(inst: ClipInstance) {
|
||
// A locked clip track must behave on the stage as if its instance
|
||
// weren't there at all — clicks pass through, drag is off, and the
|
||
// transformer can't latch on. `listening: false` on the group
|
||
// cascades to every descendant (frame rect + inner shapes), so a
|
||
// single flag covers the selection, drag, and transform paths.
|
||
const locked = isClipTrackEffectivelyLocked(inst.clipTrackId)
|
||
return {
|
||
id: `ci-group:${inst.id}`,
|
||
x: inst.x ?? 0,
|
||
y: inst.y ?? 0,
|
||
scaleX: inst.scaleX ?? 1,
|
||
scaleY: inst.scaleY ?? 1,
|
||
rotation: inst.rotation ?? 0,
|
||
draggable: !locked,
|
||
listening: !locked,
|
||
}
|
||
}
|
||
|
||
/** Invisible frame rect sized to the clip's union bbox. Sits as the
|
||
* first child of the ci-group so:
|
||
* - the group's client rect is dominated by this stable rectangle,
|
||
* keeping transformer handles at the clip's outer constraints
|
||
* instead of jittering with animated inner content;
|
||
* - clicks anywhere in the clip's spatial footprint (including
|
||
* empty playhead frames) still hit this rect, bubble to the group
|
||
* and select the instance. `fill: 'black' + opacity: 0` renders
|
||
* nothing but still paints the hit graph, so listening works. */
|
||
function clipFrameConfig(instanceId: string, bounds: Rect | null) {
|
||
const b = bounds ?? CLIP_FALLBACK_BOUNDS
|
||
return {
|
||
id: `ci-frame:${instanceId}`,
|
||
name: 'ci-frame',
|
||
x: b.x,
|
||
y: b.y,
|
||
width: Math.max(1, b.width),
|
||
height: Math.max(1, b.height),
|
||
fill: 'black',
|
||
opacity: 0,
|
||
listening: true,
|
||
}
|
||
}
|
||
|
||
function onClipInstanceDragEnd(
|
||
e: Konva.KonvaEventObject<DragEvent>,
|
||
instanceId: string,
|
||
) {
|
||
const node = e.target
|
||
store.setClipInstanceTransform(instanceId, {
|
||
x: node.x(),
|
||
y: node.y(),
|
||
})
|
||
}
|
||
|
||
function onClipInstanceTransformEnd(
|
||
e: Konva.KonvaEventObject<Event>,
|
||
instanceId: string,
|
||
) {
|
||
const node = e.target
|
||
store.setClipInstanceTransform(instanceId, {
|
||
scaleX: node.scaleX(),
|
||
scaleY: node.scaleY(),
|
||
rotation: node.rotation(),
|
||
x: node.x(),
|
||
y: node.y(),
|
||
})
|
||
}
|
||
|
||
function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps) {
|
||
return {
|
||
id: `ci:${instanceId}:${layer.id}`,
|
||
x: props.x,
|
||
y: props.y,
|
||
scaleX: props.scaleX,
|
||
scaleY: props.scaleY,
|
||
rotation: props.rotation,
|
||
opacity: props.opacity,
|
||
visible: layer.visible,
|
||
draggable: false,
|
||
// Keep clip children hit-testable so a click on the clip's
|
||
// visible shape bubbles up to the parent group's @mousedown
|
||
// selection handler. They don't carry their own handlers, so
|
||
// the event bubbles straight up.
|
||
listening: true,
|
||
...shadowCfg(props),
|
||
}
|
||
}
|
||
|
||
function clipRectConfig(item: RenderedClipLayer) {
|
||
const asset = item.asset as Extract<Asset, { kind: 'rect' }>
|
||
const fill = item.props.fill ?? asset.fill
|
||
const blur = item.props.blur ?? asset.blur ?? 0
|
||
const cfg: Record<string, unknown> = {
|
||
...clipBaseConfig(item.instanceId, item.layer, item.props),
|
||
width: asset.width,
|
||
height: asset.height,
|
||
offsetX: item.layer.offsetX ?? asset.width / 2,
|
||
offsetY: item.layer.offsetY ?? asset.height / 2,
|
||
...strokeCfg(item.props, asset),
|
||
}
|
||
if (fill && fill !== 'transparent') cfg.fill = fill
|
||
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
|
||
return cfg
|
||
}
|
||
|
||
function clipCircleConfig(item: RenderedClipLayer) {
|
||
const asset = item.asset as Extract<Asset, { kind: 'circle' }>
|
||
const fill = item.props.fill ?? asset.fill
|
||
const blur = item.props.blur ?? asset.blur ?? 0
|
||
const cfg: Record<string, unknown> = {
|
||
...clipBaseConfig(item.instanceId, item.layer, item.props),
|
||
radius: asset.radius,
|
||
offsetX: item.layer.offsetX ?? 0,
|
||
offsetY: item.layer.offsetY ?? 0,
|
||
...strokeCfg(item.props, asset),
|
||
}
|
||
if (fill && fill !== 'transparent') cfg.fill = fill
|
||
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
|
||
return cfg
|
||
}
|
||
|
||
function clipTextConfig(item: RenderedClipLayer) {
|
||
const asset = item.asset as Extract<Asset, { kind: 'text' }>
|
||
const fill = item.props.fill ?? asset.fill
|
||
const cfg: Record<string, unknown> = {
|
||
...clipBaseConfig(item.instanceId, item.layer, item.props),
|
||
text: asset.text,
|
||
fontSize: asset.fontSize,
|
||
offsetX: item.layer.offsetX ?? 0,
|
||
offsetY: item.layer.offsetY ?? 0,
|
||
...strokeCfg(item.props, asset),
|
||
}
|
||
if (fill && fill !== 'transparent') cfg.fill = fill
|
||
return cfg
|
||
}
|
||
|
||
function clipPolygonConfig(item: RenderedClipLayer) {
|
||
const asset = item.asset as Extract<Asset, { kind: 'polygon' }>
|
||
const offX = item.layer.offsetX ?? 0
|
||
const offY = item.layer.offsetY ?? 0
|
||
const pts = item.props.points ?? asset.points
|
||
const closed = asset.closed !== false
|
||
const stroke = item.props.stroke ?? asset.stroke
|
||
const strokeWidth = item.props.strokeWidth ?? asset.strokeWidth ?? 0
|
||
const fill = item.props.fill ?? asset.fill
|
||
const flat: number[] = []
|
||
for (const p of pts) {
|
||
flat.push(p.x - offX, p.y - offY)
|
||
}
|
||
const cfg: Record<string, unknown> = {
|
||
...clipBaseConfig(item.instanceId, item.layer, item.props),
|
||
points: flat,
|
||
closed,
|
||
lineJoin: 'round',
|
||
lineCap: 'round',
|
||
}
|
||
if (closed && fill && fill !== 'transparent') cfg.fill = fill
|
||
if (stroke && strokeWidth > 0) {
|
||
cfg.stroke = stroke
|
||
cfg.strokeWidth = strokeWidth
|
||
// Match the regular polygonConfig: strokes scale with the
|
||
// shape's transform so a scaled clip-instance polygon's outline
|
||
// grows proportionally instead of staying at constant pixel
|
||
// width.
|
||
cfg.strokeScaleEnabled = true
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
function clipImageConfig(item: RenderedClipLayer) {
|
||
const asset = item.asset as Extract<Asset, { kind: 'image' }>
|
||
const img = imageCache.value[asset.id]
|
||
if (!img) return null
|
||
return {
|
||
...clipBaseConfig(item.instanceId, item.layer, item.props),
|
||
image: img,
|
||
width: asset.width,
|
||
height: asset.height,
|
||
offsetX: item.layer.offsetX ?? asset.width / 2,
|
||
offsetY: item.layer.offsetY ?? asset.height / 2,
|
||
}
|
||
}
|
||
|
||
interface PivotInfo {
|
||
layerId: string
|
||
sampled: AnimatableProps
|
||
}
|
||
|
||
interface LiveLayerPose {
|
||
x: number
|
||
y: number
|
||
scaleX: number
|
||
scaleY: number
|
||
rotation: number
|
||
}
|
||
|
||
/** Per-layer live pose keyed by layer id. Multiple entries exist
|
||
* during a multi-select drag or transform — Konva mutates each
|
||
* node in place and fires @transform / @dragmove per node, and
|
||
* we record each one so the reactive v-rect / v-line / v-image
|
||
* configs don't snap non-focused nodes back to their sampled
|
||
* pose while Konva is mid-way through rotating the group. */
|
||
const liveLayerPoses = ref<Record<string, LiveLayerPose>>({})
|
||
|
||
function clearLivePoses() {
|
||
liveLayerPoses.value = {}
|
||
}
|
||
|
||
const pivotInfo = computed<PivotInfo | null>(() => {
|
||
const id = store.selectedLayerId
|
||
if (!id) return null
|
||
const layer = store.activeLayers.find((l) => l.id === id)
|
||
if (!layer) return null
|
||
const asset = assetMap.value.get(layer.assetId)
|
||
if (!asset) return null
|
||
const sampled = sampleLayer(layer, store.playhead)
|
||
const live = liveLayerPoses.value[id]
|
||
if (live) {
|
||
return {
|
||
layerId: layer.id,
|
||
sampled: {
|
||
...sampled,
|
||
x: live.x,
|
||
y: live.y,
|
||
scaleX: live.scaleX,
|
||
scaleY: live.scaleY,
|
||
rotation: live.rotation,
|
||
},
|
||
}
|
||
}
|
||
return { layerId: layer.id, sampled }
|
||
})
|
||
|
||
const pivotHandleConfig = computed(() => {
|
||
// Per-layer pivot only makes sense for a single selection. Multi
|
||
// selection has its own temp-pivot block below (SVG scope only).
|
||
if (store.selectedLayerIds.length > 1) return null
|
||
const info = pivotInfo.value
|
||
if (!info) return null
|
||
return {
|
||
x: info.sampled.x,
|
||
y: info.sampled.y,
|
||
draggable: true,
|
||
name: 'pivot-handle',
|
||
}
|
||
})
|
||
|
||
const pivotRingConfig = computed(() => ({
|
||
radius: 8 / zoom.value,
|
||
stroke: '#6ea8ff',
|
||
strokeWidth: 1.5 / zoom.value,
|
||
fill: '#ffffff',
|
||
}))
|
||
|
||
const pivotDotConfig = computed(() => ({
|
||
radius: 2.5 / zoom.value,
|
||
fill: '#6ea8ff',
|
||
listening: false,
|
||
}))
|
||
|
||
interface VertexHandle {
|
||
layerId: string
|
||
index: number
|
||
x: number
|
||
y: number
|
||
}
|
||
|
||
const polygonVertexHandles = computed<VertexHandle[]>(() => {
|
||
// Vertex handles only apply to a SOLO polygon selection — in a
|
||
// mixed multi-selection the shared transformer bounding box owns
|
||
// scale / rotate, and per-vertex handles would visually fight it.
|
||
if (store.selectedLayerIds.length > 1) return []
|
||
const id = store.selectedLayerId
|
||
if (!id) return []
|
||
const layer = store.activeLayers.find((l) => l.id === id)
|
||
if (!layer || layer.locked) return []
|
||
const asset = assetMap.value.get(layer.assetId)
|
||
if (!asset || asset.kind !== 'polygon') return []
|
||
const sampled = sampleLayer(layer, store.playhead)
|
||
const live = liveLayerPoses.value[id]
|
||
const cx = live ? live.x : sampled.x
|
||
const cy = live ? live.y : sampled.y
|
||
const rot = live ? live.rotation : sampled.rotation
|
||
const scX = live ? live.scaleX : sampled.scaleX
|
||
const scY = live ? live.scaleY : sampled.scaleY
|
||
const offX = layer.offsetX ?? 0
|
||
const offY = layer.offsetY ?? 0
|
||
const pts = sampled.points ?? asset.points
|
||
const theta = (rot * Math.PI) / 180
|
||
const c = Math.cos(theta)
|
||
const s = Math.sin(theta)
|
||
return pts.map((p, index) => {
|
||
const lx = (p.x - offX) * scX
|
||
const ly = (p.y - offY) * scY
|
||
return {
|
||
layerId: id,
|
||
index,
|
||
x: cx + lx * c - ly * s,
|
||
y: cy + lx * s + ly * c,
|
||
}
|
||
})
|
||
})
|
||
|
||
function vertexHandleConfig(h: VertexHandle) {
|
||
const selected = selectedVertexIndices.value.has(h.index)
|
||
return {
|
||
x: h.x,
|
||
y: h.y,
|
||
radius: (selected ? 6 : 5) / zoom.value,
|
||
stroke: '#6ea8ff',
|
||
strokeWidth: (selected ? 2 : 1.5) / zoom.value,
|
||
fill: selected ? '#6ea8ff' : '#ffffff',
|
||
draggable: true,
|
||
name: 'vertex-handle',
|
||
}
|
||
}
|
||
|
||
function worldToPolygonLocal(layer: Layer, wx: number, wy: number): { x: number; y: number } {
|
||
const sampled = sampleLayer(layer, store.playhead)
|
||
const dx = wx - sampled.x
|
||
const dy = wy - sampled.y
|
||
const theta = (sampled.rotation * Math.PI) / 180
|
||
const c = Math.cos(theta)
|
||
const s = Math.sin(theta)
|
||
const unrotX = dx * c + dy * s
|
||
const unrotY = -dx * s + dy * c
|
||
const invSX = sampled.scaleX !== 0 ? 1 / sampled.scaleX : 1
|
||
const invSY = sampled.scaleY !== 0 ? 1 / sampled.scaleY : 1
|
||
return {
|
||
x: unrotX * invSX + (layer.offsetX ?? 0),
|
||
y: unrotY * invSY + (layer.offsetY ?? 0),
|
||
}
|
||
}
|
||
|
||
interface VertexDragState {
|
||
layerId: string
|
||
indices: number[]
|
||
dragStartLocal: { x: number; y: number }
|
||
startPositions: Map<number, { x: number; y: number }>
|
||
}
|
||
|
||
const vertexDragState = ref<VertexDragState | null>(null)
|
||
|
||
function onVertexClick(e: Konva.KonvaEventObject<MouseEvent>, index: number) {
|
||
const native = e.evt as MouseEvent
|
||
if (native.shiftKey) {
|
||
const next = new Set(selectedVertexIndices.value)
|
||
if (next.has(index)) next.delete(index)
|
||
else next.add(index)
|
||
selectedVertexIndices.value = next
|
||
} else {
|
||
selectedVertexIndices.value = new Set([index])
|
||
}
|
||
e.cancelBubble = true
|
||
}
|
||
|
||
function onVertexDragStart(_e: Konva.KonvaEventObject<DragEvent>, index: number) {
|
||
const id = store.selectedLayerId
|
||
if (!id) return
|
||
const layer = store.activeLayers.find((l) => l.id === id)
|
||
if (!layer) return
|
||
// If the dragged vertex wasn't in the selection, reduce selection to just that one
|
||
const current = selectedVertexIndices.value
|
||
if (!current.has(index)) {
|
||
selectedVertexIndices.value = new Set([index])
|
||
}
|
||
const indices = Array.from(selectedVertexIndices.value)
|
||
const sampled = sampleLayer(layer, store.playhead)
|
||
const asset = assetMap.value.get(layer.assetId)
|
||
const pts = sampled.points ?? (asset && asset.kind === 'polygon' ? asset.points : [])
|
||
const start = new Map<number, { x: number; y: number }>()
|
||
for (const i of indices) {
|
||
const p = pts[i]
|
||
if (p) start.set(i, { x: p.x, y: p.y })
|
||
}
|
||
const dragStart = start.get(index) ?? { x: 0, y: 0 }
|
||
vertexDragState.value = {
|
||
layerId: id,
|
||
indices,
|
||
dragStartLocal: dragStart,
|
||
startPositions: start,
|
||
}
|
||
}
|
||
|
||
function onVertexDragMove(e: Konva.KonvaEventObject<DragEvent>, index: number) {
|
||
const state = vertexDragState.value
|
||
if (!state) return
|
||
const layer = store.activeLayers.find((l) => l.id === state.layerId)
|
||
if (!layer) return
|
||
const node = e.target
|
||
const currentLocal = worldToPolygonLocal(layer, node.x(), node.y())
|
||
const dx = currentLocal.x - state.dragStartLocal.x
|
||
const dy = currentLocal.y - state.dragStartLocal.y
|
||
const updates: { index: number; x: number; y: number }[] = []
|
||
for (const i of state.indices) {
|
||
const start = state.startPositions.get(i)
|
||
if (!start) continue
|
||
updates.push({ index: i, x: start.x + dx, y: start.y + dy })
|
||
}
|
||
// Keep the dragged node itself in sync with the applied delta (in case Konva's internal node drifts)
|
||
const target = state.startPositions.get(index)
|
||
if (target) {
|
||
updates.push({ index, x: target.x + dx, y: target.y + dy })
|
||
}
|
||
store.setPolygonVertexPositions(state.layerId, updates)
|
||
}
|
||
|
||
function onVertexDragEnd(e: Konva.KonvaEventObject<DragEvent>, index: number) {
|
||
onVertexDragMove(e, index)
|
||
vertexDragState.value = null
|
||
}
|
||
|
||
interface VertexMenuState {
|
||
screenX: number
|
||
screenY: number
|
||
assetId: string
|
||
layerId: string
|
||
indices: number[]
|
||
currentType: 'corner' | 'curve' | 'splitcurve'
|
||
}
|
||
|
||
const vertexMenu = ref<VertexMenuState | null>(null)
|
||
|
||
function onVertexContextMenu(e: Konva.KonvaEventObject<PointerEvent>, index: number) {
|
||
e.evt.preventDefault()
|
||
// Stop the DOM-level contextmenu from bubbling to the
|
||
// .stage-container handler, which would otherwise pop the
|
||
// generic stage menu (Document settings / Stage resize) on top
|
||
// of our vertex menu.
|
||
e.evt.stopPropagation()
|
||
const id = store.selectedLayerId
|
||
if (!id) return
|
||
const layer = store.activeLayers.find((l) => l.id === id)
|
||
if (!layer) return
|
||
const asset = assetMap.value.get(layer.assetId)
|
||
if (!asset || asset.kind !== 'polygon') return
|
||
const rect = container.value?.getBoundingClientRect()
|
||
if (!rect) return
|
||
// If the right-clicked vertex is part of an existing multi-selection,
|
||
// apply the menu action to all selected. Otherwise focus on this vertex only.
|
||
const sel = selectedVertexIndices.value
|
||
const indices = sel.has(index) ? Array.from(sel) : [index]
|
||
if (!sel.has(index)) selectedVertexIndices.value = new Set([index])
|
||
const point = asset.points[index]
|
||
vertexMenu.value = {
|
||
screenX: e.evt.clientX - rect.left,
|
||
screenY: e.evt.clientY - rect.top,
|
||
assetId: asset.id,
|
||
layerId: layer.id,
|
||
indices,
|
||
currentType: point?.type ?? 'corner',
|
||
}
|
||
}
|
||
|
||
function applyVertexType(type: 'corner' | 'curve' | 'splitcurve') {
|
||
const m = vertexMenu.value
|
||
if (!m) return
|
||
store.setPolygonVertexType(m.assetId, m.indices, type)
|
||
vertexMenu.value = null
|
||
}
|
||
|
||
function closeVertexMenu() {
|
||
vertexMenu.value = null
|
||
}
|
||
|
||
interface TangentHandle {
|
||
key: string
|
||
layerId: string
|
||
assetId: string
|
||
vertexIndex: number
|
||
side: 'in' | 'out'
|
||
vertexWx: number
|
||
vertexWy: number
|
||
handleWx: number
|
||
handleWy: number
|
||
}
|
||
|
||
const polygonTangentHandles = computed<TangentHandle[]>(() => {
|
||
const id = store.selectedLayerId
|
||
if (!id) return []
|
||
const layer = store.activeLayers.find((l) => l.id === id)
|
||
if (!layer || layer.locked) return []
|
||
const asset = assetMap.value.get(layer.assetId)
|
||
if (!asset || asset.kind !== 'polygon') return []
|
||
// Only show tangent handles when exactly one vertex is selected
|
||
const vsel = selectedVertexIndices.value
|
||
if (vsel.size !== 1) return []
|
||
const onlyIndex = vsel.values().next().value!
|
||
const sampled = sampleLayer(layer, store.playhead)
|
||
const live = liveLayerPoses.value[id]
|
||
const cx = live ? live.x : sampled.x
|
||
const cy = live ? live.y : sampled.y
|
||
const rot = live ? live.rotation : sampled.rotation
|
||
const scX = live ? live.scaleX : sampled.scaleX
|
||
const scY = live ? live.scaleY : sampled.scaleY
|
||
const offX = layer.offsetX ?? 0
|
||
const offY = layer.offsetY ?? 0
|
||
const pts = sampled.points ?? asset.points
|
||
const theta = (rot * Math.PI) / 180
|
||
const c = Math.cos(theta)
|
||
const s = Math.sin(theta)
|
||
const out: TangentHandle[] = []
|
||
for (let i = 0; i < asset.points.length; i++) {
|
||
if (i !== onlyIndex) continue
|
||
const ap = asset.points[i]
|
||
if (!ap.type || ap.type === 'corner') continue
|
||
const p = pts[i] as Point
|
||
const vx = p.x - offX
|
||
const vy = p.y - offY
|
||
const localVertex = { x: vx * scX, y: vy * scY }
|
||
const vertexWx = cx + localVertex.x * c - localVertex.y * s
|
||
const vertexWy = cy + localVertex.x * s + localVertex.y * c
|
||
for (const side of ['in', 'out'] as const) {
|
||
const dx =
|
||
(side === 'in' ? p.inX ?? ap.inX : p.outX ?? ap.outX) ?? 0
|
||
const dy =
|
||
(side === 'in' ? p.inY ?? ap.inY : p.outY ?? ap.outY) ?? 0
|
||
const tx = (p.x + dx - offX) * scX
|
||
const ty = (p.y + dy - offY) * scY
|
||
const handleWx = cx + tx * c - ty * s
|
||
const handleWy = cy + tx * s + ty * c
|
||
out.push({
|
||
key: `tan-${layer.id}-${i}-${side}`,
|
||
layerId: layer.id,
|
||
assetId: asset.id,
|
||
vertexIndex: i,
|
||
side,
|
||
vertexWx,
|
||
vertexWy,
|
||
handleWx,
|
||
handleWy,
|
||
})
|
||
}
|
||
}
|
||
return out
|
||
})
|
||
|
||
const polygonTangentLines = computed(() =>
|
||
polygonTangentHandles.value.map((h) => ({
|
||
key: `tl-${h.key}`,
|
||
config: {
|
||
points: [h.vertexWx, h.vertexWy, h.handleWx, h.handleWy],
|
||
stroke: '#6ea8ff',
|
||
strokeWidth: 1 / zoom.value,
|
||
strokeScaleEnabled: false,
|
||
listening: false,
|
||
opacity: 0.6,
|
||
},
|
||
})),
|
||
)
|
||
|
||
function tangentHandleConfig(h: TangentHandle) {
|
||
return {
|
||
x: h.handleWx,
|
||
y: h.handleWy,
|
||
radius: 3.5 / zoom.value,
|
||
hitStrokeWidth: 10 / zoom.value,
|
||
fill: '#6ea8ff',
|
||
stroke: '#ffffff',
|
||
strokeWidth: 1 / zoom.value,
|
||
strokeScaleEnabled: false,
|
||
draggable: true,
|
||
name: 'tangent-handle',
|
||
}
|
||
}
|
||
|
||
function onTangentDrag(e: Konva.KonvaEventObject<DragEvent>, h: TangentHandle) {
|
||
const layer = store.activeLayers.find((l) => l.id === h.layerId)
|
||
if (!layer) return
|
||
const asset = assetMap.value.get(h.assetId)
|
||
if (!asset || asset.kind !== 'polygon') return
|
||
const node = e.target
|
||
// Convert node world position to asset-local vertex offset
|
||
const handleLocal = worldToPolygonLocal(layer, node.x(), node.y())
|
||
const sampled = sampleLayer(layer, store.playhead)
|
||
const pts = sampled.points ?? asset.points
|
||
const vp = pts[h.vertexIndex]
|
||
if (!vp) return
|
||
const dx = handleLocal.x - vp.x
|
||
const dy = handleLocal.y - vp.y
|
||
store.setPolygonVertexTangent(h.assetId, h.vertexIndex, h.side, dx, dy)
|
||
}
|
||
|
||
|
||
const selectedVertexIndices = ref<Set<number>>(new Set())
|
||
|
||
const selectedPolygonLayer = computed(() => {
|
||
const id = store.selectedLayerId
|
||
if (!id) return null
|
||
const layer = store.activeLayers.find((l) => l.id === id)
|
||
if (!layer) return null
|
||
const asset = assetMap.value.get(layer.assetId)
|
||
if (!asset || asset.kind !== 'polygon') return null
|
||
return { layer, asset }
|
||
})
|
||
|
||
watch(
|
||
() => store.selectedLayerId,
|
||
() => {
|
||
selectedVertexIndices.value = new Set()
|
||
closeVertexMenu()
|
||
},
|
||
)
|
||
|
||
const boxSelect = ref<{
|
||
startX: number
|
||
startY: number
|
||
curX: number
|
||
curY: number
|
||
/** 'vertex' reuses the existing polygon-vertex lasso; 'layer'
|
||
* intersects every unlocked layer's bbox with the drag rect to
|
||
* build a scene-level multi-selection. */
|
||
mode: 'vertex' | 'layer'
|
||
/** Shift-held at drag start → add the marquee's hits to the
|
||
* existing selection rather than replacing it. A tiny shift
|
||
* click also leaves the current selection untouched. */
|
||
additive: boolean
|
||
} | null>(null)
|
||
|
||
const boxSelectRectPx = computed(() => {
|
||
const b = boxSelect.value
|
||
if (!b) return null
|
||
const x0 = Math.min(b.startX, b.curX)
|
||
const y0 = Math.min(b.startY, b.curY)
|
||
const x1 = Math.max(b.startX, b.curX)
|
||
const y1 = Math.max(b.startY, b.curY)
|
||
// Convert world coords to screen coords for DOM overlay
|
||
const sx0 = x0 * zoom.value + viewX.value
|
||
const sy0 = y0 * zoom.value + viewY.value
|
||
const sx1 = x1 * zoom.value + viewX.value
|
||
const sy1 = y1 * zoom.value + viewY.value
|
||
return {
|
||
left: Math.min(sx0, sx1),
|
||
top: Math.min(sy0, sy1),
|
||
width: Math.abs(sx1 - sx0),
|
||
height: Math.abs(sy1 - sy0),
|
||
}
|
||
})
|
||
|
||
function finishBoxSelect() {
|
||
const b = boxSelect.value
|
||
boxSelect.value = null
|
||
if (!b) return
|
||
const minX = Math.min(b.startX, b.curX)
|
||
const maxX = Math.max(b.startX, b.curX)
|
||
const minY = Math.min(b.startY, b.curY)
|
||
const maxY = Math.max(b.startY, b.curY)
|
||
|
||
if (b.mode === 'vertex') {
|
||
const sel = selectedPolygonLayer.value
|
||
if (!sel) return
|
||
const { layer, asset } = sel
|
||
const sampled = sampleLayer(layer, store.playhead)
|
||
const pts = sampled.points ?? asset.points
|
||
const indices = new Set<number>()
|
||
for (let i = 0; i < pts.length; i++) {
|
||
const world = transformLocalToWorld(layer, sampled, pts[i])
|
||
if (world.x >= minX && world.x <= maxX && world.y >= minY && world.y <= maxY) {
|
||
indices.add(i)
|
||
}
|
||
}
|
||
selectedVertexIndices.value = indices
|
||
// Tiny click with no vertex hits → treat as an empty-stage
|
||
// click and deselect the polygon layer entirely, matching how
|
||
// layer-mode handles the same gesture. Without this a polygon
|
||
// (or freshly-drawn freeform) stays selected forever.
|
||
const dPxX = Math.abs(b.curX - b.startX) * zoom.value
|
||
const dPxY = Math.abs(b.curY - b.startY) * zoom.value
|
||
if (indices.size === 0 && dPxX < 3 && dPxY < 3) {
|
||
deselectAll()
|
||
}
|
||
return
|
||
}
|
||
|
||
// Layer mode — touch to select, not "fully enclosed". Clicks
|
||
// without drag (≤3 screen-px wiggle) fall through to the
|
||
// "click-empty-stage → deselect" behaviour so the handler still
|
||
// covers both interactions from a single pointerdown on the
|
||
// empty stage. Shift on a tiny click is a no-op (keeps the
|
||
// existing selection intact).
|
||
const draggedX = Math.abs(b.curX - b.startX) * zoom.value
|
||
const draggedY = Math.abs(b.curY - b.startY) * zoom.value
|
||
if (draggedX < 3 && draggedY < 3) {
|
||
if (!b.additive) deselectAll()
|
||
return
|
||
}
|
||
const stage = stageRef.value?.getStage()
|
||
if (!stage) {
|
||
if (!b.additive) deselectAll()
|
||
return
|
||
}
|
||
const hits: string[] = []
|
||
for (const layer of store.activeLayers) {
|
||
if (isLayerEffectivelyLocked(layer)) continue
|
||
if (layer.editorHidden) continue
|
||
if (!isLayerActiveAt(layer, store.playhead)) continue
|
||
const node = stage.findOne<Konva.Node>(`#${layer.id}`)
|
||
if (!node) continue
|
||
// getClientRect returns bbox in stage (absolute) coords —
|
||
// convert to world coords by undoing the view zoom/pan so we
|
||
// can intersect against the world-space drag rect.
|
||
const rect = node.getClientRect({ skipTransform: false })
|
||
const z = zoom.value || 1
|
||
const rx0 = (rect.x - viewX.value) / z
|
||
const ry0 = (rect.y - viewY.value) / z
|
||
const rx1 = rx0 + rect.width / z
|
||
const ry1 = ry0 + rect.height / z
|
||
if (rx1 < minX || rx0 > maxX || ry1 < minY || ry0 > maxY) continue
|
||
hits.push(layer.id)
|
||
}
|
||
// Additive: merge hits with the existing selection, keeping the
|
||
// click order so the anchor lands on whatever the user targeted
|
||
// last. Replace mode discards the old selection.
|
||
const merged = b.additive
|
||
? (() => {
|
||
const seen = new Set(store.selectedLayerIds)
|
||
const out = [...store.selectedLayerIds]
|
||
for (const h of hits) {
|
||
if (!seen.has(h)) {
|
||
out.push(h)
|
||
seen.add(h)
|
||
}
|
||
}
|
||
return out
|
||
})()
|
||
: hits
|
||
if (merged.length === 0) {
|
||
deselectAll()
|
||
return
|
||
}
|
||
if (merged.length === 1) {
|
||
store.selectLayer(merged[0])
|
||
return
|
||
}
|
||
store.selectedLayerIds = merged
|
||
store.selectedLayerId = merged[merged.length - 1]
|
||
store.selectedClipInstanceId = null
|
||
}
|
||
|
||
const draggingPivot = ref(false)
|
||
|
||
function onPivotDragStart() {
|
||
draggingPivot.value = true
|
||
}
|
||
|
||
function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
||
draggingPivot.value = false
|
||
const info = pivotInfo.value
|
||
if (!info) return
|
||
const node = e.target
|
||
const dx = node.x() - info.sampled.x
|
||
const dy = node.y() - info.sampled.y
|
||
if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) return
|
||
const theta = (info.sampled.rotation * Math.PI) / 180
|
||
const c = Math.cos(theta)
|
||
const s = Math.sin(theta)
|
||
const unrotDx = dx * c + dy * s
|
||
const unrotDy = -dx * s + dy * c
|
||
const invSX = info.sampled.scaleX !== 0 ? 1 / info.sampled.scaleX : 1
|
||
const invSY = info.sampled.scaleY !== 0 ? 1 / info.sampled.scaleY : 1
|
||
const localDx = unrotDx * invSX
|
||
const localDy = unrotDy * invSY
|
||
store.shiftLayerPivot(info.layerId, dx, dy, localDx, localDy)
|
||
}
|
||
|
||
const transformerConfig = computed(() => {
|
||
// Single selection → rotation is handled by the pivot-anchored
|
||
// custom handle below (the user-set pivot). The Konva transformer
|
||
// anchor stays off so we don't show two rotation knobs.
|
||
// Multi selection → group rotate has been tried and didn't work
|
||
// (write-back per node fights with the bbox-centred transform),
|
||
// so it stays disabled. The user explicitly preferred no
|
||
// rotation in multi-select over a half-broken group rotate.
|
||
return {
|
||
rotateEnabled: false,
|
||
rotationSnaps: [0, 45, 90, 135, 180, -135, -90, -45],
|
||
rotationSnapTolerance: 4,
|
||
enabledAnchors: [
|
||
'top-left',
|
||
'top-center',
|
||
'top-right',
|
||
'middle-right',
|
||
'middle-left',
|
||
'bottom-left',
|
||
'bottom-center',
|
||
'bottom-right',
|
||
],
|
||
anchorSize: 9,
|
||
borderStroke: '#4f8cff',
|
||
anchorStroke: '#4f8cff',
|
||
anchorFill: '#ffffff',
|
||
padding: 2,
|
||
ignoreStroke: true,
|
||
}
|
||
})
|
||
|
||
let attachRetryTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
function attachTransformer(retriesLeft = 3) {
|
||
if (attachRetryTimer) {
|
||
clearTimeout(attachRetryTimer)
|
||
attachRetryTimer = null
|
||
}
|
||
const stage = stageRef.value?.getStage()
|
||
const tr = transformerRef.value?.getNode()
|
||
if (!stage || !tr) return
|
||
|
||
// Clip-instance selection takes precedence — the transformer wraps
|
||
// the group so its move + scale handles update the instance's
|
||
// static x / y / scale.
|
||
const clipInstId = store.selectedClipInstanceId
|
||
if (clipInstId) {
|
||
const groupNode = stage.findOne<Konva.Node>(`#ci-group:${clipInstId}`)
|
||
if (!groupNode && retriesLeft > 0) {
|
||
attachRetryTimer = setTimeout(() => attachTransformer(retriesLeft - 1), 16)
|
||
return
|
||
}
|
||
tr.nodes(groupNode ? [groupNode] : [])
|
||
tr.getLayer()?.batchDraw()
|
||
return
|
||
}
|
||
|
||
// Multi-select: wrap every selected layer node in one transformer
|
||
// so the bounding box + scale / rotate handles span the group.
|
||
// A SOLE polygon selection still bypasses the transformer because
|
||
// a single polygon is edited via vertex handles; mixed selections
|
||
// that happen to include a polygon fold it into the shared bbox.
|
||
const ids = store.selectedLayerIds.length
|
||
? store.selectedLayerIds
|
||
: store.selectedLayerId
|
||
? [store.selectedLayerId]
|
||
: []
|
||
if (!ids.length) {
|
||
tr.nodes([])
|
||
tr.getLayer()?.batchDraw()
|
||
return
|
||
}
|
||
const single = ids.length === 1
|
||
const nodes: Konva.Node[] = []
|
||
let anyMissing = false
|
||
for (const id of ids) {
|
||
const layer = store.activeLayers.find((l) => l.id === id)
|
||
if (!layer) continue
|
||
const asset = assetMap.value.get(layer.assetId)
|
||
if (asset?.kind === 'polygon' && single) continue
|
||
const node = stage.findOne<Konva.Node>(`#${id}`)
|
||
if (!node) {
|
||
anyMissing = true
|
||
continue
|
||
}
|
||
nodes.push(node)
|
||
}
|
||
if (anyMissing && retriesLeft > 0) {
|
||
// Freshly-rendered layers haven't attached yet; retry on the
|
||
// next animation frame before giving up on the ids we missed.
|
||
attachRetryTimer = setTimeout(() => attachTransformer(retriesLeft - 1), 16)
|
||
return
|
||
}
|
||
tr.nodes(nodes)
|
||
tr.getLayer()?.batchDraw()
|
||
}
|
||
|
||
watch(
|
||
() =>
|
||
[
|
||
store.selectedLayerId,
|
||
store.selectedLayerIds,
|
||
store.selectedClipInstanceId,
|
||
stackItems.value,
|
||
store.playhead,
|
||
] as const,
|
||
async () => {
|
||
await nextTick()
|
||
attachTransformer()
|
||
},
|
||
{ immediate: true, deep: true },
|
||
)
|
||
|
||
function updateLivePose(node: Konva.Node, layerId: string) {
|
||
liveLayerPoses.value = {
|
||
...liveLayerPoses.value,
|
||
[layerId]: {
|
||
x: node.x(),
|
||
y: node.y(),
|
||
scaleX: node.scaleX(),
|
||
scaleY: node.scaleY(),
|
||
rotation: node.rotation(),
|
||
},
|
||
}
|
||
}
|
||
|
||
function handleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
|
||
const node = e.target
|
||
const id = node.id()
|
||
const draggedLayer = store.activeLayers.find((l) => l.id === id)
|
||
if (!draggedLayer) return
|
||
updateLivePose(node, id)
|
||
// Multi-select drag: compute the dragged node's delta vs its
|
||
// sampled pose at the current playhead, then mirror it onto every
|
||
// sibling Konva node so the whole selection tracks together. Each
|
||
// sibling's baseline is also its sampled pose (its Konva node
|
||
// hasn't moved yet — only the dragged one has).
|
||
if (
|
||
store.selectedLayerIds.length > 1 &&
|
||
store.selectedLayerIds.includes(id)
|
||
) {
|
||
const stage = stageRef.value?.getStage()
|
||
if (!stage) return
|
||
const baseSamp = sampleLayer(draggedLayer, store.playhead)
|
||
const dx = node.x() - baseSamp.x
|
||
const dy = node.y() - baseSamp.y
|
||
for (const lid of store.selectedLayerIds) {
|
||
if (lid === id) continue
|
||
const other = stage.findOne<Konva.Node>(`#${lid}`)
|
||
if (!other) continue
|
||
const otherLayer = store.activeLayers.find((l) => l.id === lid)
|
||
if (!otherLayer) continue
|
||
const s = sampleLayer(otherLayer, store.playhead)
|
||
other.x(s.x + dx)
|
||
other.y(s.y + dy)
|
||
}
|
||
stage.batchDraw()
|
||
}
|
||
}
|
||
|
||
function handleTransform(e: Konva.KonvaEventObject<Event>) {
|
||
const node = e.target
|
||
const id = node.id()
|
||
if (!store.activeLayers.some((l) => l.id === id)) return
|
||
updateLivePose(node, id)
|
||
}
|
||
|
||
interface PathFrame {
|
||
index: number
|
||
x: number
|
||
y: number
|
||
u: number
|
||
}
|
||
|
||
interface PathSegment {
|
||
flat: number[]
|
||
}
|
||
|
||
interface ActiveSegment {
|
||
keyframeId: string
|
||
frames: PathFrame[]
|
||
}
|
||
|
||
interface PathOverlay {
|
||
layerId: string
|
||
ghostSegments: PathSegment[]
|
||
activeSegments: ActiveSegment[]
|
||
}
|
||
|
||
function sampleSegment(layer: Layer, from: number, to: number, fps: number): number[] {
|
||
const dt = to - from
|
||
if (dt <= 0) return []
|
||
const n = Math.max(2, Math.min(240, Math.round(dt * fps)))
|
||
const out: number[] = []
|
||
for (let i = 0; i <= n; i++) {
|
||
const time = from + (i / n) * dt
|
||
const s = sampleLayer(layer, time)
|
||
out.push(s.x, s.y)
|
||
}
|
||
return out
|
||
}
|
||
|
||
const animationPath = computed<PathOverlay | null>(() => {
|
||
const sel = selectedKfs.value
|
||
if (sel.length === 0) return null
|
||
const first = sel[0]
|
||
if (first.kind === 'rotation') return null
|
||
const origLayer = store.activeLayers.find((l) => l.id === first.layerId)
|
||
if (!origLayer) return null
|
||
const arr = first.kind === 'position' ? origLayer.positionKeyframes : origLayer.keyframes
|
||
if (!arr) return null
|
||
|
||
// While the user drags this layer's object, clone its keyframes so the
|
||
// path reflects the live position without mutating the store. If a kf
|
||
// at the playhead already exists, override its x/y; otherwise splice
|
||
// in a synthetic one so the path gets a live endpoint.
|
||
const live = liveLayerPoses.value[origLayer.id]
|
||
let layer = origLayer
|
||
let workingArr = arr
|
||
if (live) {
|
||
const cloned: Keyframe[] = arr.map((k) => ({ ...k, props: { ...k.props } }))
|
||
const existing = cloned.find((k) => Math.abs(k.time - store.playhead) < 1e-3)
|
||
if (existing) {
|
||
existing.props.x = live.x
|
||
existing.props.y = live.y
|
||
} else {
|
||
cloned.push({
|
||
id: '__live__',
|
||
time: store.playhead,
|
||
props: { x: live.x, y: live.y },
|
||
easing: 'linear',
|
||
})
|
||
}
|
||
cloned.sort((a, b) => a.time - b.time)
|
||
workingArr = cloned
|
||
layer =
|
||
first.kind === 'position'
|
||
? { ...origLayer, positionKeyframes: cloned }
|
||
: { ...origLayer, keyframes: cloned }
|
||
}
|
||
|
||
// Every selected keyframe on this layer+kind gets an interactive
|
||
// active segment (the incoming curve). Other segments stay ghost.
|
||
const selectedIds = new Set<string>()
|
||
for (const s of sel) {
|
||
if (s.layerId !== first.layerId) continue
|
||
if (s.kind !== first.kind) continue
|
||
selectedIds.add(s.keyframeId)
|
||
}
|
||
|
||
const sorted = [...workingArr].sort((a, b) => a.time - b.time)
|
||
const fps = store.scene.fps || 60
|
||
|
||
const ghostSegments: PathSegment[] = []
|
||
const activeSegments: ActiveSegment[] = []
|
||
for (let i = 0; i < sorted.length - 1; i++) {
|
||
const a = sorted[i]
|
||
const b = sorted[i + 1]
|
||
const dt = b.time - a.time
|
||
if (dt <= 0) continue
|
||
if (selectedIds.has(b.id)) {
|
||
const n = Math.max(2, Math.min(240, Math.round(dt * fps)))
|
||
const frames: PathFrame[] = []
|
||
for (let k = 0; k <= n; k++) {
|
||
const u = k / n
|
||
const time = a.time + u * dt
|
||
const s = sampleLayer(layer, time)
|
||
frames.push({ index: k, x: s.x, y: s.y, u })
|
||
}
|
||
activeSegments.push({ keyframeId: b.id, frames })
|
||
} else {
|
||
const flat = sampleSegment(layer, a.time, b.time, fps)
|
||
if (flat.length >= 4) ghostSegments.push({ flat })
|
||
}
|
||
}
|
||
if (!ghostSegments.length && activeSegments.length === 0) return null
|
||
return {
|
||
layerId: layer.id,
|
||
ghostSegments,
|
||
activeSegments,
|
||
}
|
||
})
|
||
|
||
const animationGhostSegments = computed(() => {
|
||
const path = animationPath.value
|
||
if (!path) return []
|
||
return path.ghostSegments.map((seg, i) => ({
|
||
key: `gs-${i}`,
|
||
config: {
|
||
points: seg.flat,
|
||
stroke: '#6ea8ff',
|
||
strokeWidth: 1 / zoom.value,
|
||
strokeScaleEnabled: false,
|
||
listening: false,
|
||
lineCap: 'round',
|
||
lineJoin: 'round',
|
||
opacity: 0.35,
|
||
dash: [4 / zoom.value, 3 / zoom.value],
|
||
},
|
||
}))
|
||
})
|
||
|
||
const animationPathLines = computed(() => {
|
||
const path = animationPath.value
|
||
if (!path) return []
|
||
return path.activeSegments
|
||
.filter((s) => s.frames.length >= 2)
|
||
.map((s) => {
|
||
const flat: number[] = []
|
||
for (const f of s.frames) flat.push(f.x, f.y)
|
||
return {
|
||
key: `apl-${s.keyframeId}`,
|
||
config: {
|
||
points: flat,
|
||
stroke: '#6ea8ff',
|
||
strokeWidth: 1.25 / zoom.value,
|
||
strokeScaleEnabled: false,
|
||
listening: false,
|
||
lineCap: 'round',
|
||
lineJoin: 'round',
|
||
},
|
||
}
|
||
})
|
||
})
|
||
|
||
interface PathDot {
|
||
key: string
|
||
keyframeId: string
|
||
frameIndex: number
|
||
frameTotal: number
|
||
x: number
|
||
y: number
|
||
isEnd: boolean
|
||
}
|
||
|
||
const animationPathDots = computed<PathDot[]>(() => {
|
||
const path = animationPath.value
|
||
if (!path) return []
|
||
const out: PathDot[] = []
|
||
for (const seg of path.activeSegments) {
|
||
const last = seg.frames.length - 1
|
||
for (const f of seg.frames) {
|
||
out.push({
|
||
key: `pf-${seg.keyframeId}-${f.index}`,
|
||
keyframeId: seg.keyframeId,
|
||
frameIndex: f.index,
|
||
frameTotal: last,
|
||
x: f.x,
|
||
y: f.y,
|
||
isEnd: f.index === 0 || f.index === last,
|
||
})
|
||
}
|
||
}
|
||
return out
|
||
})
|
||
|
||
const hoveredPathDot = ref<string | null>(null)
|
||
|
||
function animationDotConfig(d: PathDot) {
|
||
const hoverKey = `${d.keyframeId}:${d.frameIndex}`
|
||
const hovered = !d.isEnd && hoveredPathDot.value === hoverKey
|
||
const baseRadius = d.isEnd ? 4 : 2.5
|
||
const radius = hovered ? 6 : baseRadius
|
||
return {
|
||
x: d.x,
|
||
y: d.y,
|
||
radius: radius / zoom.value,
|
||
hitStrokeWidth: 10 / zoom.value,
|
||
fill: d.isEnd ? '#6ea8ff' : hovered ? '#6ea8ff' : '#ffffff',
|
||
stroke: '#6ea8ff',
|
||
strokeWidth: (hovered ? 1.5 : 1) / zoom.value,
|
||
strokeScaleEnabled: false,
|
||
draggable: !d.isEnd,
|
||
name: 'path-frame-dot',
|
||
}
|
||
}
|
||
|
||
function onPathDotMouseEnter(d: PathDot) {
|
||
if (d.isEnd) return
|
||
hoveredPathDot.value = `${d.keyframeId}:${d.frameIndex}`
|
||
const stage = stageRef.value?.getStage()
|
||
const cont = stage?.container()
|
||
if (cont) cont.style.cursor = 'grab'
|
||
}
|
||
|
||
function onPathDotMouseLeave() {
|
||
hoveredPathDot.value = null
|
||
const stage = stageRef.value?.getStage()
|
||
const cont = stage?.container()
|
||
if (cont) cont.style.cursor = ''
|
||
}
|
||
|
||
function onPathDotDragMove(e: Konva.KonvaEventObject<DragEvent>, d: PathDot) {
|
||
const path = animationPath.value
|
||
if (!path) return
|
||
const node = e.target
|
||
const wx = node.x()
|
||
const wy = node.y()
|
||
if (d.frameTotal <= 0) return
|
||
const uRaw = d.frameIndex / d.frameTotal
|
||
if (uRaw <= 0 || uRaw >= 1) return
|
||
const layer = store.activeLayers.find((l) => l.id === path.layerId)
|
||
if (!layer) return
|
||
const sel = selectedKfs.value.find((s) => s.keyframeId === d.keyframeId)
|
||
const arr = sel?.kind === 'position' ? layer.positionKeyframes : layer.keyframes
|
||
const kf = arr?.find((k) => k.id === d.keyframeId)
|
||
if (!kf) return
|
||
const easing = kf.easing
|
||
const t = applyEasing(uRaw, easing)
|
||
store.setKeyframeControlFromPoint(path.layerId, d.keyframeId, t, wx, wy)
|
||
}
|
||
|
||
function applyEasing(t: number, easing: string): number {
|
||
if (easing === 'easeIn') return t * t
|
||
if (easing === 'easeOut') return 1 - (1 - t) ** 2
|
||
if (easing === 'easeInOut') return t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2
|
||
return t
|
||
}
|
||
|
||
const drawingPreview = ref<{ x: number; y: number } | null>(null)
|
||
|
||
function pointerToWorld(e: Konva.KonvaEventObject<MouseEvent>) {
|
||
const stage = stageRef.value?.getStage()
|
||
if (!stage) return null
|
||
const pos = stage.getPointerPosition()
|
||
if (!pos) return null
|
||
return {
|
||
x: (pos.x - viewX.value) / zoom.value,
|
||
y: (pos.y - viewY.value) / zoom.value,
|
||
}
|
||
}
|
||
|
||
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||
// Marquee on the empty stage. Two flavours share the same
|
||
// drag machinery: vertex-lasso when a polygon is being edited,
|
||
// layer multi-select otherwise. pointerup fires finishBoxSelect
|
||
// which routes by `mode`.
|
||
if (!store.drawingMode && e.target === e.target.getStage()) {
|
||
const native = e.evt as MouseEvent
|
||
if (native.button === 0 && !native.ctrlKey) {
|
||
const wp = pointerToWorld(e)
|
||
if (wp) {
|
||
// Vertex-lasso mode only applies to a SOLO polygon
|
||
// selection — inside a multi-select the box always targets
|
||
// layers so new picks add to the existing group.
|
||
const mode: 'vertex' | 'layer' =
|
||
selectedPolygonLayer.value && store.selectedLayerIds.length <= 1
|
||
? 'vertex'
|
||
: 'layer'
|
||
boxSelect.value = {
|
||
startX: wp.x, startY: wp.y, curX: wp.x, curY: wp.y, mode,
|
||
additive: native.shiftKey,
|
||
}
|
||
if (mode === 'vertex' && !native.shiftKey) {
|
||
selectedVertexIndices.value = new Set()
|
||
}
|
||
closeVertexMenu()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
if (store.drawingMode === 'freeform') {
|
||
const native = e.evt as MouseEvent
|
||
// Middle-click or Ctrl+click belong to the pan gesture, not drawing
|
||
if (native.button === 1 || native.ctrlKey) return
|
||
const wp = pointerToWorld(e)
|
||
if (!wp) return
|
||
const layerId = store.drawingLayerId
|
||
const layer = layerId ? store.activeLayers.find((l) => l.id === layerId) : null
|
||
const local = layer ? worldToPolygonLocal(layer, wp.x, wp.y) : wp
|
||
// Close-on-click near first point (compare in asset-local space)
|
||
const assetId = store.drawingAssetId
|
||
const asset = assetId ? store.scene.assets.find((a) => a.id === assetId) : null
|
||
if (asset && asset.kind === 'polygon' && asset.points.length >= 3) {
|
||
const sampled = layer ? sampleLayer(layer, store.playhead) : null
|
||
const scale = sampled ? Math.max(Math.abs(sampled.scaleX), Math.abs(sampled.scaleY), 0.01) : 1
|
||
const first = asset.points[0]
|
||
const threshold = 10 / zoom.value / scale
|
||
if (Math.hypot(local.x - first.x, local.y - first.y) <= threshold) {
|
||
store.setPolygonClosed(asset.id, true)
|
||
store.finishDrawing()
|
||
return
|
||
}
|
||
}
|
||
store.addDrawingPoint(local.x, local.y)
|
||
return
|
||
}
|
||
const target = e.target
|
||
const tr = transformerRef.value?.getNode()
|
||
if (tr) {
|
||
let p: Konva.Node | null = target
|
||
while (p) {
|
||
if (p === tr) return
|
||
p = p.getParent() ?? null
|
||
}
|
||
}
|
||
if (target.findAncestor?.('.pivot-handle')) return
|
||
if (target.findAncestor?.('.rotation-handle')) return
|
||
// SVG-scope multi-rotation handles. Their inner Konva nodes have
|
||
// no id and a custom name, so without these bails the fall-through
|
||
// below clears the multi-selection on mousedown — making multi-
|
||
// rotation / pivot-drag dissolve the selection they operate on.
|
||
if (target.findAncestor?.('.multi-pivot-handle')) return
|
||
if (target.findAncestor?.('.multi-rotation-handle')) return
|
||
if (target.name?.() === 'multi-pivot-handle') return
|
||
if (target.name?.() === 'multi-rotation-handle') return
|
||
if (target.name?.() === 'vertex-handle' || target.findAncestor?.('.vertex-handle')) return
|
||
if (target.name?.() === 'tangent-handle') return
|
||
if (target.name?.() === 'path-frame-dot') return
|
||
if (vertexMenu.value) vertexMenu.value = null
|
||
if (target === target.getStage()) {
|
||
deselectAll()
|
||
return
|
||
}
|
||
const id = target.id?.()
|
||
if (!id) {
|
||
const name = target.name?.() ?? ''
|
||
if (name.startsWith('button-hit:')) {
|
||
if (!stagePreview.value) {
|
||
const layerId = name.slice('button-hit:'.length)
|
||
store.selectLayer(store.activeLayers.find((l) => l.id === layerId) ? layerId : null)
|
||
}
|
||
return
|
||
}
|
||
if (name.startsWith('svg-hit:')) {
|
||
// Hit-catcher inside an SVG-asset layer's group. Route the
|
||
// mousedown to the wrapping layer the same way a regular
|
||
// shape click would — including shift-toggle for multi.
|
||
const layerId = name.slice('svg-hit:'.length)
|
||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||
if (!layer) {
|
||
store.selectLayer(null)
|
||
return
|
||
}
|
||
if ((e.evt as MouseEvent).shiftKey) {
|
||
store.toggleLayerSelection(layerId)
|
||
} else if (!store.selectedLayerIds.includes(layerId)) {
|
||
store.selectLayer(layerId)
|
||
}
|
||
return
|
||
}
|
||
// Bubbled mousedown from a Konva node we don't recognise as a
|
||
// layer. Use selectLayer(null) — NOT deselectAll — so that any
|
||
// clip-instance selection just set by an inner @mousedown
|
||
// handler (e.g. the ci-group's own listener) survives this
|
||
// bubbled fall-through.
|
||
store.selectLayer(null)
|
||
return
|
||
}
|
||
const match = store.activeLayers.find((l) => l.id === id)
|
||
if (!match) {
|
||
// Same rationale: id-bearing nodes that aren't active layers are
|
||
// typically ci-group / ci-frame / ci-child shapes whose own
|
||
// mousedown handler has already run and set the clip-instance
|
||
// selection. Don't trample that.
|
||
store.selectLayer(null)
|
||
return
|
||
}
|
||
// Shift toggles the clicked layer into the existing selection.
|
||
// Matches the timeline's shift-click-on-track-name behaviour so
|
||
// both surfaces build multi-selections the same way. Anchor moves
|
||
// to the most-recently-clicked id for a predictable Properties
|
||
// panel focus.
|
||
if ((e.evt as MouseEvent).shiftKey) {
|
||
store.toggleLayerSelection(match.id)
|
||
return
|
||
}
|
||
// Pressed on a layer that's already part of the multi-selection
|
||
// → don't collapse to a single-layer select. Konva's drag
|
||
// machinery is already arming on this node, and handleDragMove
|
||
// expects `selectedLayerIds.length > 1` to mirror the delta onto
|
||
// the siblings. Leaving the selection alone keeps the group drag
|
||
// working. Without this, clicking any one of the selected shapes
|
||
// would wipe the others and only drag the clicked one.
|
||
if (
|
||
store.selectedLayerIds.length > 1 &&
|
||
store.selectedLayerIds.includes(match.id)
|
||
) {
|
||
return
|
||
}
|
||
store.selectLayer(match.id)
|
||
}
|
||
|
||
function handleStageMouseMove(e: Konva.KonvaEventObject<MouseEvent>) {
|
||
if (boxSelect.value) {
|
||
const wp = pointerToWorld(e)
|
||
if (wp) boxSelect.value = { ...boxSelect.value, curX: wp.x, curY: wp.y }
|
||
return
|
||
}
|
||
if (store.drawingMode !== 'freeform') {
|
||
drawingPreview.value = null
|
||
return
|
||
}
|
||
const wp = pointerToWorld(e)
|
||
if (!wp) return
|
||
const layerId = store.drawingLayerId
|
||
const layer = layerId ? store.activeLayers.find((l) => l.id === layerId) : null
|
||
drawingPreview.value = layer ? worldToPolygonLocal(layer, wp.x, wp.y) : wp
|
||
}
|
||
|
||
function handleStageMouseUp() {
|
||
if (boxSelect.value) finishBoxSelect()
|
||
}
|
||
|
||
function onContainerDblClick(e: MouseEvent) {
|
||
const rect = container.value?.getBoundingClientRect()
|
||
if (!rect) return
|
||
const wx = (e.clientX - rect.left - viewX.value) / zoom.value
|
||
const wy = (e.clientY - rect.top - viewY.value) / zoom.value
|
||
|
||
// While drawing: finish (optionally close if near first point)
|
||
if (store.drawingMode === 'freeform') {
|
||
const assetId = store.drawingAssetId
|
||
const asset = assetId ? store.scene.assets.find((a) => a.id === assetId) : null
|
||
if (asset && asset.kind === 'polygon' && asset.points.length >= 3) {
|
||
const first = asset.points[0]
|
||
const threshold = 12 / zoom.value
|
||
if (Math.hypot(wx - first.x, wy - first.y) <= threshold) {
|
||
store.setPolygonClosed(asset.id, true)
|
||
}
|
||
}
|
||
store.finishDrawing()
|
||
return
|
||
}
|
||
|
||
// Not drawing: scope-entry shortcut for stage-placed sub-asset
|
||
// layers. Double-click an SVG-asset layer → enter its sub-editor
|
||
// (mirroring the sidebar's double-click behaviour). Other asset
|
||
// kinds fall through to the polygon-specific gestures below.
|
||
const selId = store.selectedLayerId
|
||
if (!selId) return
|
||
const layer = store.activeLayers.find((l) => l.id === selId)
|
||
if (!layer || layer.locked) return
|
||
const asset = assetMap.value.get(layer.assetId)
|
||
if (!asset) return
|
||
if (asset.kind === 'svg') {
|
||
store.enterSvgAssetEdit(asset.id)
|
||
return
|
||
}
|
||
if (asset.kind !== 'polygon') return
|
||
const sampled = sampleLayer(layer, store.playhead)
|
||
const pts = sampled.points ?? asset.points
|
||
if (pts.length < 1) return
|
||
const closed = asset.closed !== false
|
||
const local = worldToPolygonLocal(layer, wx, wy)
|
||
|
||
// Near an endpoint of an open polygon → resume drawing
|
||
if (!closed) {
|
||
const first = pts[0]
|
||
const last = pts[pts.length - 1]
|
||
const threshold = 14 / zoom.value
|
||
const nearFirst = Math.hypot(local.x - first.x, local.y - first.y) <= threshold
|
||
const nearLast =
|
||
pts.length > 1 && Math.hypot(local.x - last.x, local.y - last.y) <= threshold
|
||
if (nearLast || nearFirst) {
|
||
if (nearFirst && !nearLast) store.reversePolygonPoints(asset.id)
|
||
store.startFreeformDrawing()
|
||
return
|
||
}
|
||
}
|
||
|
||
// Click on (or near) an edge → insert a new vertex there
|
||
const edges = closed ? pts.length : pts.length - 1
|
||
if (edges < 1) return
|
||
let bestIdx = 1
|
||
let bestDist = Infinity
|
||
let bestT = 0
|
||
for (let i = 0; i < edges; i++) {
|
||
const a = pts[i]
|
||
const b = pts[(i + 1) % pts.length]
|
||
const dx = b.x - a.x
|
||
const dy = b.y - a.y
|
||
const lenSq = dx * dx + dy * dy
|
||
if (lenSq === 0) continue
|
||
let t = ((local.x - a.x) * dx + (local.y - a.y) * dy) / lenSq
|
||
t = Math.max(0, Math.min(1, t))
|
||
const px = a.x + t * dx
|
||
const py = a.y + t * dy
|
||
const d = Math.hypot(local.x - px, local.y - py)
|
||
if (d < bestDist) {
|
||
bestDist = d
|
||
bestIdx = i + 1
|
||
bestT = t
|
||
}
|
||
}
|
||
const edgeThreshold = 14 / zoom.value
|
||
if (bestDist <= edgeThreshold) {
|
||
store.insertPolygonPointOnEdge(asset.id, bestIdx, bestT)
|
||
}
|
||
}
|
||
|
||
function handleDrawingKey(e: KeyboardEvent) {
|
||
if (store.drawingMode !== 'freeform') return
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault()
|
||
store.cancelDrawing()
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault()
|
||
store.finishDrawing()
|
||
}
|
||
}
|
||
|
||
function transformLocalToWorld(
|
||
layer: Layer,
|
||
sampled: AnimatableProps,
|
||
local: { x: number; y: number },
|
||
): { x: number; y: number } {
|
||
const offX = layer.offsetX ?? 0
|
||
const offY = layer.offsetY ?? 0
|
||
const lx = (local.x - offX) * sampled.scaleX
|
||
const ly = (local.y - offY) * sampled.scaleY
|
||
const theta = (sampled.rotation * Math.PI) / 180
|
||
const c = Math.cos(theta)
|
||
const s = Math.sin(theta)
|
||
return {
|
||
x: sampled.x + lx * c - ly * s,
|
||
y: sampled.y + lx * s + ly * c,
|
||
}
|
||
}
|
||
|
||
const drawingPreviewConfig = computed(() => {
|
||
if (store.drawingMode !== 'freeform') return null
|
||
const layerId = store.drawingLayerId
|
||
if (!layerId) return null
|
||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||
if (!layer) return null
|
||
const asset = assetMap.value.get(layer.assetId)
|
||
if (!asset || asset.kind !== 'polygon') return null
|
||
const sampled = sampleLayer(layer, store.playhead)
|
||
const pts = sampled.points ?? asset.points
|
||
const preview = drawingPreview.value
|
||
const flat: number[] = []
|
||
for (const p of pts) {
|
||
const w = transformLocalToWorld(layer, sampled, p)
|
||
flat.push(w.x, w.y)
|
||
}
|
||
if (preview && pts.length > 0) {
|
||
const w = transformLocalToWorld(layer, sampled, preview)
|
||
flat.push(w.x, w.y)
|
||
}
|
||
if (flat.length < 4) return null
|
||
return {
|
||
points: flat,
|
||
stroke: '#6ea8ff',
|
||
strokeWidth: 1.5 / zoom.value,
|
||
dash: [6 / zoom.value, 4 / zoom.value],
|
||
strokeScaleEnabled: false,
|
||
listening: false,
|
||
}
|
||
})
|
||
|
||
const drawingDotsConfig = computed(() => {
|
||
if (store.drawingMode !== 'freeform') return []
|
||
const layerId = store.drawingLayerId
|
||
if (!layerId) return []
|
||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||
if (!layer) return []
|
||
const asset = assetMap.value.get(layer.assetId)
|
||
if (!asset || asset.kind !== 'polygon') return []
|
||
const sampled = sampleLayer(layer, store.playhead)
|
||
const pts = sampled.points ?? asset.points
|
||
return pts.map((p, i) => {
|
||
const w = transformLocalToWorld(layer, sampled, p)
|
||
return {
|
||
key: `dp-${i}`,
|
||
x: w.x,
|
||
y: w.y,
|
||
radius: 3 / zoom.value,
|
||
fill: '#6ea8ff',
|
||
listening: false,
|
||
}
|
||
})
|
||
})
|
||
|
||
function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
||
const node = e.target
|
||
const id = node.id()
|
||
const layer = store.activeLayers.find((l) => l.id === id)
|
||
if (!layer) return
|
||
const newX = node.x()
|
||
const newY = node.y()
|
||
// Multi-layer drag: persist every selected layer's new position.
|
||
// handleDragMove already mirrored the delta onto each sibling's
|
||
// Konva node, so each node's current x/y is already correct —
|
||
// just upsert from the live Konva state.
|
||
if (
|
||
store.selectedLayerIds.length > 1 &&
|
||
store.selectedLayerIds.includes(id)
|
||
) {
|
||
const stage = stageRef.value?.getStage()
|
||
if (stage) {
|
||
for (const lid of store.selectedLayerIds) {
|
||
const n = stage.findOne<Konva.Node>(`#${lid}`)
|
||
if (!n) continue
|
||
store.upsertKeyframe(lid, store.playhead, { x: n.x(), y: n.y() })
|
||
}
|
||
clearLivePoses()
|
||
return
|
||
}
|
||
}
|
||
// Multi-keyframe shift applies only when the layer being dragged is
|
||
// itself part of the current selection. Otherwise a stale selection on
|
||
// some other layer would steal the drag delta, leaving the dragged
|
||
// layer to snap back while the selected one moved instead.
|
||
const draggedInSelection = selectedKfs.value.some((k) => k.layerId === id)
|
||
if (draggedInSelection && anySelectedKfAtPlayhead()) {
|
||
const samp = sampleLayer(layer, store.playhead)
|
||
const dx = newX - samp.x
|
||
const dy = newY - samp.y
|
||
if (dx !== 0 || dy !== 0) {
|
||
store.shiftKeyframesPosition(selectedKfs.value, dx, dy)
|
||
}
|
||
} else {
|
||
store.upsertKeyframe(id, store.playhead, { x: newX, y: newY })
|
||
selectKfAtPlayhead(layer, layer.separated ? 'position' : 'main')
|
||
}
|
||
clearLivePoses()
|
||
}
|
||
|
||
function anySelectedKfAtPlayhead(): boolean {
|
||
for (const k of selectedKfs.value) {
|
||
const l = store.activeLayers.find((x) => x.id === k.layerId)
|
||
if (!l) continue
|
||
const arr =
|
||
k.kind === 'position'
|
||
? l.positionKeyframes
|
||
: k.kind === 'rotation'
|
||
? l.rotationKeyframes
|
||
: l.keyframes
|
||
const kf = arr?.find((kf) => kf.id === k.keyframeId)
|
||
if (kf && Math.abs(kf.time - store.playhead) < 1e-3) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
function selectKfAtPlayhead(layer: Layer, kind: 'main' | 'position' | 'rotation') {
|
||
const arr =
|
||
kind === 'position'
|
||
? layer.positionKeyframes
|
||
: kind === 'rotation'
|
||
? layer.rotationKeyframes
|
||
: layer.keyframes
|
||
if (!arr) return
|
||
const kf = arr.find((k) => Math.abs(k.time - store.playhead) < 1e-3)
|
||
if (!kf) return
|
||
selectedKfs.value = [{ layerId: layer.id, kind, keyframeId: kf.id }]
|
||
}
|
||
|
||
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
|
||
const node = e.target
|
||
const id = node.id()
|
||
if (!store.activeLayers.some((l) => l.id === id)) return
|
||
// Multi-select transform: each node's @transformend fires
|
||
// separately, but Konva only guarantees the event on a subset
|
||
// (the dragged anchor mostly). To be robust, persist every
|
||
// selected layer's live Konva pose here and clear at the end.
|
||
// Single-select collapses to the same loop with one id.
|
||
const ids =
|
||
store.selectedLayerIds.length > 1 && store.selectedLayerIds.includes(id)
|
||
? store.selectedLayerIds
|
||
: [id]
|
||
const stage = stageRef.value?.getStage()
|
||
for (const lid of ids) {
|
||
const n = lid === id ? node : stage?.findOne<Konva.Node>(`#${lid}`)
|
||
if (!n) continue
|
||
store.upsertKeyframe(lid, store.playhead, {
|
||
x: n.x(),
|
||
y: n.y(),
|
||
scaleX: n.scaleX(),
|
||
scaleY: n.scaleY(),
|
||
rotation: n.rotation(),
|
||
})
|
||
}
|
||
clearLivePoses()
|
||
}
|
||
|
||
const ROT_ARM_PX = 44
|
||
|
||
const rotationHandlePos = ref<{ x: number; y: number } | null>(null)
|
||
|
||
const rotationHandleConfig = computed(() => {
|
||
if (store.selectedLayerIds.length > 1) return null
|
||
const info = pivotInfo.value
|
||
if (!info) return null
|
||
if (draggingPivot.value) return null
|
||
if (rotationHandlePos.value) {
|
||
return {
|
||
x: rotationHandlePos.value.x,
|
||
y: rotationHandlePos.value.y,
|
||
draggable: true,
|
||
name: 'rotation-handle',
|
||
}
|
||
}
|
||
const arm = ROT_ARM_PX / zoom.value
|
||
const rad = (info.sampled.rotation * Math.PI) / 180
|
||
return {
|
||
x: info.sampled.x + arm * Math.sin(rad),
|
||
y: info.sampled.y - arm * Math.cos(rad),
|
||
draggable: true,
|
||
name: 'rotation-handle',
|
||
}
|
||
})
|
||
|
||
const rotationArmConfig = computed(() => {
|
||
if (store.selectedLayerIds.length > 1) return null
|
||
const info = pivotInfo.value
|
||
if (!info) return null
|
||
if (draggingPivot.value) return null
|
||
let endX: number
|
||
let endY: number
|
||
if (rotationHandlePos.value) {
|
||
endX = rotationHandlePos.value.x
|
||
endY = rotationHandlePos.value.y
|
||
} else {
|
||
const arm = ROT_ARM_PX / zoom.value
|
||
const rad = (info.sampled.rotation * Math.PI) / 180
|
||
endX = info.sampled.x + arm * Math.sin(rad)
|
||
endY = info.sampled.y - arm * Math.cos(rad)
|
||
}
|
||
return {
|
||
points: [info.sampled.x, info.sampled.y, endX, endY],
|
||
stroke: '#6ea8ff',
|
||
strokeWidth: 1.25 / zoom.value,
|
||
dash: [3 / zoom.value, 3 / zoom.value],
|
||
listening: false,
|
||
}
|
||
})
|
||
|
||
const rotationKnobConfig = computed(() => ({
|
||
radius: 6 / zoom.value,
|
||
fill: '#ffffff',
|
||
stroke: '#6ea8ff',
|
||
strokeWidth: 2 / zoom.value,
|
||
}))
|
||
|
||
function onRotationHandleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
|
||
const info = pivotInfo.value
|
||
if (!info) return
|
||
const node = e.target
|
||
const dx = node.x() - info.sampled.x
|
||
const dy = node.y() - info.sampled.y
|
||
if (dx === 0 && dy === 0) return
|
||
const raw = (Math.atan2(dx, -dy) * 180) / Math.PI
|
||
// Snap to the 360°-equivalent nearest to the previous rotation so small
|
||
// motions don't flip a full turn across the ±180° boundary.
|
||
const prevLive = liveLayerPoses.value[info.layerId]
|
||
const prev = prevLive?.rotation ?? info.sampled.rotation
|
||
let deg = raw
|
||
while (deg - prev > 180) deg -= 360
|
||
while (deg - prev < -180) deg += 360
|
||
const base = prevLive ?? {
|
||
x: info.sampled.x,
|
||
y: info.sampled.y,
|
||
scaleX: info.sampled.scaleX,
|
||
scaleY: info.sampled.scaleY,
|
||
rotation: info.sampled.rotation,
|
||
}
|
||
liveLayerPoses.value = {
|
||
...liveLayerPoses.value,
|
||
[info.layerId]: { ...base, rotation: deg },
|
||
}
|
||
rotationHandlePos.value = { x: node.x(), y: node.y() }
|
||
}
|
||
|
||
function onRotationHandleDragEnd() {
|
||
const info = pivotInfo.value
|
||
const live = info ? liveLayerPoses.value[info.layerId] : null
|
||
if (info && live) {
|
||
store.upsertKeyframe(info.layerId, store.playhead, { rotation: live.rotation })
|
||
}
|
||
rotationHandlePos.value = null
|
||
clearLivePoses()
|
||
}
|
||
|
||
// -------- SVG-scope multi-select rotation -------------------------
|
||
// In SVG edit mode only: when the user has 2+ layers selected we
|
||
// expose a *temporary* group pivot — defaults to the centroid of the
|
||
// selection but draggable. The rotation handle anchored to it
|
||
// rotates every selected layer around that pivot. The temp pivot is
|
||
// scope-local: dropped on deselect / scope exit, re-init'd next time.
|
||
|
||
interface MultiRotState {
|
||
/** Per-selected-layer pose snapshotted at drag start. We rotate
|
||
* these around the pivot; the live pose is what Konva renders
|
||
* during drag. Persisting in a snapshot keeps the maths stable
|
||
* even when liveLayerPoses changes mid-drag. */
|
||
startPoses: {
|
||
layerId: string
|
||
x: number; y: number
|
||
scaleX: number; scaleY: number
|
||
rotation: number
|
||
}[]
|
||
/** Pivot anchor at drag start. Stays constant for the duration
|
||
* of the rotation gesture. */
|
||
pivotX: number
|
||
pivotY: number
|
||
/** Where the rotation handle started in world coords — used as
|
||
* the zero-angle reference. */
|
||
startHandleX: number
|
||
startHandleY: number
|
||
}
|
||
|
||
/** Active only in SVG scope with a multi-selection. `null` clears
|
||
* the pivot — auto-reseeded by the watcher below when the user
|
||
* multi-selects again. */
|
||
const multiRotPivot = ref<{ x: number; y: number } | null>(null)
|
||
const multiRotDrag = ref<MultiRotState | null>(null)
|
||
const multiRotDelta = ref(0)
|
||
const draggingMultiPivot = ref(false)
|
||
|
||
const isMultiSvg = computed(() =>
|
||
!!store.activeSvgAssetId && store.selectedLayerIds.length >= 2,
|
||
)
|
||
|
||
/** Sample-based positions for every currently-selected layer. Live
|
||
* poses (mid-rotation / mid-transform) are honoured so the centroid
|
||
* follows the rendered position during a drag. Includes scale so
|
||
* the multi-rotation drag preserves it instead of snapping the
|
||
* element to scale 1 mid-rotate. */
|
||
function selectedLayerPoses() {
|
||
const out: {
|
||
layerId: string
|
||
x: number; y: number
|
||
scaleX: number; scaleY: number
|
||
rotation: number
|
||
}[] = []
|
||
for (const id of store.selectedLayerIds) {
|
||
const layer = store.activeLayers.find((l) => l.id === id)
|
||
if (!layer) continue
|
||
const live = liveLayerPoses.value[id]
|
||
const sampled = sampleLayer(layer, store.playhead)
|
||
out.push({
|
||
layerId: id,
|
||
x: live?.x ?? sampled.x,
|
||
y: live?.y ?? sampled.y,
|
||
scaleX: live?.scaleX ?? sampled.scaleX,
|
||
scaleY: live?.scaleY ?? sampled.scaleY,
|
||
rotation: live?.rotation ?? sampled.rotation,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
/** Centroid of the current selection. Used to seed the temp pivot
|
||
* when the user enters multi-select. */
|
||
function selectionCentroid(): { x: number; y: number } | null {
|
||
const poses = selectedLayerPoses()
|
||
if (!poses.length) return null
|
||
let sx = 0, sy = 0
|
||
for (const p of poses) { sx += p.x; sy += p.y }
|
||
return { x: sx / poses.length, y: sy / poses.length }
|
||
}
|
||
|
||
// Re-seed / drop the temp pivot when the selection changes.
|
||
watch(isMultiSvg, (active) => {
|
||
if (active) {
|
||
if (!multiRotPivot.value) multiRotPivot.value = selectionCentroid()
|
||
} else {
|
||
multiRotPivot.value = null
|
||
multiRotDrag.value = null
|
||
multiRotDelta.value = 0
|
||
}
|
||
})
|
||
|
||
// When the selection's centroid drifts (because the user is scaling
|
||
// or moving the multi-selection via the Konva transformer / drag),
|
||
// shift the pivot by the same delta so it stays attached to the
|
||
// selection. Skipped while:
|
||
// - the user is mid-rotation (rotation around a non-centroid pivot
|
||
// naturally moves the centroid; we don't want the pivot to chase
|
||
// its own rotation);
|
||
// - the user is dragging the pivot itself (it's their gesture, not
|
||
// a selection drift).
|
||
const liveSelectionCentroid = computed<{ x: number; y: number } | null>(() => {
|
||
if (!isMultiSvg.value) return null
|
||
return selectionCentroid()
|
||
})
|
||
|
||
watch(liveSelectionCentroid, (newC, oldC) => {
|
||
if (!newC || !oldC) return
|
||
if (!multiRotPivot.value) return
|
||
if (multiRotDrag.value) return
|
||
if (draggingMultiPivot.value) return
|
||
const dx = newC.x - oldC.x
|
||
const dy = newC.y - oldC.y
|
||
if (Math.abs(dx) < 1e-3 && Math.abs(dy) < 1e-3) return
|
||
multiRotPivot.value = {
|
||
x: multiRotPivot.value.x + dx,
|
||
y: multiRotPivot.value.y + dy,
|
||
}
|
||
})
|
||
|
||
const MULTI_ROT_ARM_PX = 56
|
||
|
||
const multiPivotHandleConfig = computed(() => {
|
||
if (!isMultiSvg.value) return null
|
||
const p = multiRotPivot.value
|
||
if (!p) return null
|
||
return { x: p.x, y: p.y, draggable: true, name: 'multi-pivot-handle' }
|
||
})
|
||
|
||
const multiRotArmConfig = computed(() => {
|
||
if (!isMultiSvg.value) return null
|
||
const p = multiRotPivot.value
|
||
if (!p) return null
|
||
if (draggingMultiPivot.value) return null
|
||
const arm = MULTI_ROT_ARM_PX / zoom.value
|
||
const ang = multiRotDelta.value
|
||
const rad = (ang * Math.PI) / 180
|
||
const endX = p.x + arm * Math.sin(rad)
|
||
const endY = p.y - arm * Math.cos(rad)
|
||
return {
|
||
points: [p.x, p.y, endX, endY],
|
||
stroke: '#f59e0b',
|
||
strokeWidth: 1.25 / zoom.value,
|
||
dash: [3 / zoom.value, 3 / zoom.value],
|
||
listening: false,
|
||
}
|
||
})
|
||
|
||
const multiRotHandleConfig = computed(() => {
|
||
if (!isMultiSvg.value) return null
|
||
const p = multiRotPivot.value
|
||
if (!p) return null
|
||
if (draggingMultiPivot.value) return null
|
||
const arm = MULTI_ROT_ARM_PX / zoom.value
|
||
const ang = multiRotDelta.value
|
||
const rad = (ang * Math.PI) / 180
|
||
return {
|
||
x: p.x + arm * Math.sin(rad),
|
||
y: p.y - arm * Math.cos(rad),
|
||
draggable: true,
|
||
name: 'multi-rotation-handle',
|
||
}
|
||
})
|
||
|
||
const multiRotKnobConfig = computed(() => ({
|
||
radius: 6 / zoom.value,
|
||
fill: '#ffffff',
|
||
stroke: '#f59e0b',
|
||
strokeWidth: 2 / zoom.value,
|
||
}))
|
||
|
||
function onMultiPivotDragMove(e: Konva.KonvaEventObject<DragEvent>) {
|
||
draggingMultiPivot.value = true
|
||
multiRotPivot.value = { x: e.target.x(), y: e.target.y() }
|
||
}
|
||
|
||
function onMultiPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
||
draggingMultiPivot.value = false
|
||
multiRotPivot.value = { x: e.target.x(), y: e.target.y() }
|
||
}
|
||
|
||
function onMultiRotHandleDragStart(e: Konva.KonvaEventObject<DragEvent>) {
|
||
const p = multiRotPivot.value
|
||
if (!p) return
|
||
multiRotDrag.value = {
|
||
startPoses: selectedLayerPoses().map((q) => ({ ...q })),
|
||
pivotX: p.x,
|
||
pivotY: p.y,
|
||
startHandleX: e.target.x(),
|
||
startHandleY: e.target.y(),
|
||
}
|
||
multiRotDelta.value = 0
|
||
}
|
||
|
||
function onMultiRotHandleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
|
||
const d = multiRotDrag.value
|
||
if (!d) return
|
||
const ang0 = Math.atan2(d.startHandleX - d.pivotX, -(d.startHandleY - d.pivotY))
|
||
const ang1 = Math.atan2(e.target.x() - d.pivotX, -(e.target.y() - d.pivotY))
|
||
let delta = ((ang1 - ang0) * 180) / Math.PI
|
||
// Wrap to nearest equivalent so a small drag across ±180° doesn't
|
||
// suddenly flip a full turn.
|
||
const prev = multiRotDelta.value
|
||
while (delta - prev > 180) delta -= 360
|
||
while (delta - prev < -180) delta += 360
|
||
multiRotDelta.value = delta
|
||
const rad = (delta * Math.PI) / 180
|
||
const c = Math.cos(rad)
|
||
const s = Math.sin(rad)
|
||
const next: Record<string, LiveLayerPose> = { ...liveLayerPoses.value }
|
||
for (const start of d.startPoses) {
|
||
const dx = start.x - d.pivotX
|
||
const dy = start.y - d.pivotY
|
||
next[start.layerId] = {
|
||
x: d.pivotX + dx * c - dy * s,
|
||
y: d.pivotY + dx * s + dy * c,
|
||
// Carry the layer's own scale through the rotation —
|
||
// rotation around a pivot doesn't change scale, but if we
|
||
// default to 1 here a scaled element snaps to "scale 1"
|
||
// mid-drag and only restores after dragend writes back.
|
||
scaleX: start.scaleX,
|
||
scaleY: start.scaleY,
|
||
rotation: start.rotation + delta,
|
||
}
|
||
}
|
||
liveLayerPoses.value = next
|
||
}
|
||
|
||
function onMultiRotHandleDragEnd() {
|
||
const d = multiRotDrag.value
|
||
if (!d) {
|
||
multiRotDelta.value = 0
|
||
return
|
||
}
|
||
for (const start of d.startPoses) {
|
||
const live = liveLayerPoses.value[start.layerId]
|
||
if (!live) continue
|
||
store.upsertKeyframe(start.layerId, store.playhead, {
|
||
x: live.x,
|
||
y: live.y,
|
||
rotation: live.rotation,
|
||
})
|
||
}
|
||
multiRotDrag.value = null
|
||
multiRotDelta.value = 0
|
||
clearLivePoses()
|
||
}
|
||
|
||
let ro: ResizeObserver | null = null
|
||
|
||
function onWheel(e: WheelEvent) {
|
||
e.preventDefault()
|
||
const rect = container.value?.getBoundingClientRect()
|
||
if (!rect) return
|
||
const sx = e.clientX - rect.left
|
||
const sy = e.clientY - rect.top
|
||
const logX = (sx - viewX.value) / zoom.value
|
||
const logY = (sy - viewY.value) / zoom.value
|
||
const factor = Math.pow(1.0015, -e.deltaY)
|
||
const newZoom = clamp(zoom.value * factor, ZOOM_MIN, ZOOM_MAX)
|
||
zoom.value = newZoom
|
||
viewX.value = sx - logX * newZoom
|
||
viewY.value = sy - logY * newZoom
|
||
}
|
||
|
||
let panning = false
|
||
let panStartX = 0
|
||
let panStartY = 0
|
||
let panStartViewX = 0
|
||
let panStartViewY = 0
|
||
let panPointerId = -1
|
||
|
||
function isInsideCanvasBounds(e: PointerEvent | MouseEvent): boolean {
|
||
const rect = container.value?.getBoundingClientRect()
|
||
if (!rect) return true
|
||
const localX = e.clientX - rect.left
|
||
const localY = e.clientY - rect.top
|
||
const cw = effectiveCanvas.value.width * zoom.value
|
||
const ch = effectiveCanvas.value.height * zoom.value
|
||
return (
|
||
localX >= viewX.value &&
|
||
localX <= viewX.value + cw &&
|
||
localY >= viewY.value &&
|
||
localY <= viewY.value + ch
|
||
)
|
||
}
|
||
|
||
function pointerHitsKonvaShape(e: PointerEvent | MouseEvent): boolean {
|
||
// Transformer handles, vertex dots, and stage shapes can live
|
||
// outside the canvas rectangle (selection box for a shape near the
|
||
// edge, a polygon vertex dragged past the border, …). Asking Konva
|
||
// for an intersection at the pointer lets us tell "user grabbed a
|
||
// handle" from "user clicked the empty background" even when both
|
||
// happen outside the canvas bounds.
|
||
const stage = stageRef.value?.getStage()
|
||
if (!stage) return false
|
||
const rect = stage.container().getBoundingClientRect()
|
||
const hit = stage.getIntersection({
|
||
x: e.clientX - rect.left,
|
||
y: e.clientY - rect.top,
|
||
})
|
||
return !!hit
|
||
}
|
||
|
||
function isPanTrigger(e: PointerEvent | MouseEvent) {
|
||
if (e.button === 1) return true
|
||
if (e.button === 0 && e.ctrlKey) return true
|
||
// Plain left-click on the empty content view (outside the canvas
|
||
// rectangle) pans the viewport too — saves a ctrl-modifier for the
|
||
// common case of re-centering after zoom. Skip panning when the
|
||
// click is actually on a Konva shape: a transformer handle, a
|
||
// polygon vertex, or a selection box that extends past the canvas
|
||
// edge still belongs to its owning shape and must stay draggable.
|
||
if (e.button === 0 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && !isInsideCanvasBounds(e) && !pointerHitsKonvaShape(e)) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
function onContainerPointerDown(e: PointerEvent) {
|
||
if (!isPanTrigger(e)) return
|
||
e.preventDefault()
|
||
panning = true
|
||
panStartX = e.clientX
|
||
panStartY = e.clientY
|
||
panStartViewX = viewX.value
|
||
panStartViewY = viewY.value
|
||
panPointerId = e.pointerId
|
||
container.value?.setPointerCapture(e.pointerId)
|
||
if (container.value) container.value.style.cursor = 'grabbing'
|
||
}
|
||
|
||
function onContainerMouseDownCapture(e: MouseEvent) {
|
||
if (isPanTrigger(e)) {
|
||
e.stopPropagation()
|
||
e.preventDefault()
|
||
}
|
||
}
|
||
|
||
function onContainerPointerMove(e: PointerEvent) {
|
||
if (!panning || e.pointerId !== panPointerId) return
|
||
viewX.value = panStartViewX + (e.clientX - panStartX)
|
||
viewY.value = panStartViewY + (e.clientY - panStartY)
|
||
}
|
||
|
||
function onContainerPointerUp(e: PointerEvent) {
|
||
if (e.pointerId !== panPointerId) return
|
||
panning = false
|
||
container.value?.releasePointerCapture?.(e.pointerId)
|
||
if (container.value) container.value.style.cursor = ''
|
||
}
|
||
|
||
function onContainerDragOver(e: DragEvent) {
|
||
if (!e.dataTransfer) return
|
||
const types = e.dataTransfer.types
|
||
if (
|
||
!types.includes('application/x-animatr-asset') &&
|
||
!types.includes('application/x-animatr-clipart')
|
||
)
|
||
return
|
||
e.preventDefault()
|
||
e.dataTransfer.dropEffect = 'copy'
|
||
}
|
||
|
||
async function importClipartAt(file: File, worldX: number, worldY: number) {
|
||
await importImageFile(store, file, { position: { x: worldX, y: worldY } })
|
||
}
|
||
|
||
function onContainerDrop(e: DragEvent) {
|
||
const rect = container.value?.getBoundingClientRect()
|
||
if (!rect) return
|
||
const sx = e.clientX - rect.left
|
||
const sy = e.clientY - rect.top
|
||
const worldX = (sx - viewX.value) / zoom.value
|
||
const worldY = (sy - viewY.value) / zoom.value
|
||
|
||
const clipartIdx = e.dataTransfer?.getData('application/x-animatr-clipart')
|
||
if (clipartIdx) {
|
||
e.preventDefault()
|
||
const idx = Number.parseInt(clipartIdx, 10)
|
||
const item = Number.isFinite(idx) ? clipart.items[idx] : null
|
||
if (item && item.kind !== 'folder') {
|
||
void importClipartAt(item.file, worldX, worldY)
|
||
}
|
||
return
|
||
}
|
||
|
||
const assetId = e.dataTransfer?.getData('application/x-animatr-asset')
|
||
if (!assetId) return
|
||
e.preventDefault()
|
||
const layer = store.addLayer(assetId)
|
||
if (layer) {
|
||
store.upsertKeyframe(layer.id, 0, { x: worldX, y: worldY })
|
||
}
|
||
}
|
||
|
||
function resizePreviewCanvas() {
|
||
const canvas = previewCanvasEl.value
|
||
if (!canvas) return
|
||
const dpr = window.devicePixelRatio || 1
|
||
const cw = containerSize.value.width
|
||
const ch = containerSize.value.height
|
||
canvas.width = Math.max(1, Math.round(cw * dpr))
|
||
canvas.height = Math.max(1, Math.round(ch * dpr))
|
||
canvas.style.width = `${cw}px`
|
||
canvas.style.height = `${ch}px`
|
||
}
|
||
|
||
function drawPreview() {
|
||
const canvas = previewCanvasEl.value
|
||
if (!canvas) return
|
||
const ctx = canvas.getContext('2d')
|
||
if (!ctx) return
|
||
const dpr = window.devicePixelRatio || 1
|
||
const cw = containerSize.value.width
|
||
const ch = containerSize.value.height
|
||
if (!cw || !ch) return
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||
ctx.clearRect(0, 0, cw, ch)
|
||
ctx.save()
|
||
ctx.translate(viewX.value, viewY.value)
|
||
ctx.scale(zoom.value, zoom.value)
|
||
// In button-edit mode the preview should reflect the *button*'s stage,
|
||
// not the main scene behind it. `store.playhead` is already pinned to
|
||
// BUTTON_STATE_TIMES[activeButtonState] (or to normal in hit mode), so
|
||
// we can pass it straight through.
|
||
if (store.activeButtonAsset) {
|
||
drawButtonFrame(
|
||
ctx,
|
||
store.activeButtonAsset,
|
||
store.scene,
|
||
store.playhead,
|
||
previewImages.value,
|
||
{ background: '#1a1a1f' },
|
||
)
|
||
} else {
|
||
drawSceneFrame(ctx, store.scene, store.playhead, previewImages.value, {
|
||
crop: cropObjects.value,
|
||
})
|
||
}
|
||
ctx.restore()
|
||
}
|
||
|
||
watch(
|
||
[
|
||
() => store.playhead,
|
||
() => store.scene,
|
||
() => containerSize.value,
|
||
zoom,
|
||
viewX,
|
||
viewY,
|
||
cropObjects,
|
||
],
|
||
async () => {
|
||
if (!maskPreview.value) return
|
||
await nextTick()
|
||
resizePreviewCanvas()
|
||
drawPreview()
|
||
},
|
||
{ deep: true },
|
||
)
|
||
|
||
watch(maskPreview, async (on) => {
|
||
if (!on) return
|
||
previewImages.value = await loadSceneImages(store.scene)
|
||
await nextTick()
|
||
resizePreviewCanvas()
|
||
drawPreview()
|
||
})
|
||
|
||
// Re-fit the stage whenever the user enters / exits a button- or
|
||
// svg-asset edit session — sub-assets have their own canvas, and
|
||
// the framing must update so the new canvas lands inside the
|
||
// viewport instead of leaving the user staring at an empty corner.
|
||
watch(
|
||
() => store.activeButtonAssetId,
|
||
() => {
|
||
nextTick(() => fitToContainer())
|
||
},
|
||
)
|
||
watch(
|
||
() => store.activeSvgAssetId,
|
||
() => {
|
||
nextTick(() => fitToContainer())
|
||
},
|
||
)
|
||
|
||
function zoomBy(factor: number) {
|
||
const cw = containerSize.value.width
|
||
const ch = containerSize.value.height
|
||
if (!cw || !ch) return
|
||
const sx = cw / 2
|
||
const sy = ch / 2
|
||
const logX = (sx - viewX.value) / zoom.value
|
||
const logY = (sy - viewY.value) / zoom.value
|
||
const newZoom = clamp(zoom.value * factor, ZOOM_MIN, ZOOM_MAX)
|
||
zoom.value = newZoom
|
||
viewX.value = sx - logX * newZoom
|
||
viewY.value = sy - logY * newZoom
|
||
}
|
||
|
||
// Project-loaded zoom/pan? Treat a non-default editor state as an
|
||
// explicit user choice and skip the first auto-fit so the workbench
|
||
// restores exactly where the user left off.
|
||
if (
|
||
store.editor.canvasZoom !== 1 ||
|
||
store.editor.canvasViewX !== 0 ||
|
||
store.editor.canvasViewY !== 0
|
||
) {
|
||
didInitialFit = true
|
||
}
|
||
|
||
// Last known viewport position of the stage container. Updated by
|
||
// the ResizeObserver below; used to compensate viewX/Y when the
|
||
// container's screen position shifts (e.g. when TAB collapses the
|
||
// asset / properties asides) so the canvas content stays anchored
|
||
// to the same viewport pixel instead of riding the layout shift.
|
||
let lastContainerRect: { left: number; top: number } | null = null
|
||
|
||
onMounted(() => {
|
||
if (!container.value) return
|
||
ro = new ResizeObserver(() => {
|
||
const el = container.value
|
||
if (!el) return
|
||
const r = el.getBoundingClientRect()
|
||
containerSize.value = { width: r.width, height: r.height }
|
||
if (lastContainerRect) {
|
||
const dx = r.left - lastContainerRect.left
|
||
const dy = r.top - lastContainerRect.top
|
||
// viewX/Y are container-local: shift them inversely so the
|
||
// rendered content keeps its viewport position when the
|
||
// container moves under it.
|
||
if (dx !== 0) viewX.value -= dx
|
||
if (dy !== 0) viewY.value -= dy
|
||
}
|
||
lastContainerRect = { left: r.left, top: r.top }
|
||
if (!didInitialFit && r.width > 0 && r.height > 0) {
|
||
fitToContainer()
|
||
didInitialFit = true
|
||
}
|
||
})
|
||
ro.observe(container.value)
|
||
|
||
container.value.addEventListener('wheel', onWheel, { passive: false })
|
||
container.value.addEventListener('mousedown', onContainerMouseDownCapture, { capture: true })
|
||
container.value.addEventListener('pointerdown', onContainerPointerDown)
|
||
container.value.addEventListener('pointermove', onContainerPointerMove)
|
||
container.value.addEventListener('pointerup', onContainerPointerUp)
|
||
container.value.addEventListener('pointercancel', onContainerPointerUp)
|
||
container.value.addEventListener('dragover', onContainerDragOver)
|
||
container.value.addEventListener('drop', onContainerDrop)
|
||
container.value.addEventListener('dblclick', onContainerDblClick)
|
||
window.addEventListener('keydown', handleDrawingKey)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
ro?.disconnect()
|
||
ro = null
|
||
container.value?.removeEventListener('wheel', onWheel)
|
||
container.value?.removeEventListener('mousedown', onContainerMouseDownCapture, { capture: true } as EventListenerOptions)
|
||
container.value?.removeEventListener('pointerdown', onContainerPointerDown)
|
||
container.value?.removeEventListener('pointermove', onContainerPointerMove)
|
||
container.value?.removeEventListener('pointerup', onContainerPointerUp)
|
||
container.value?.removeEventListener('pointercancel', onContainerPointerUp)
|
||
container.value?.removeEventListener('dragover', onContainerDragOver)
|
||
container.value?.removeEventListener('drop', onContainerDrop)
|
||
container.value?.removeEventListener('dblclick', onContainerDblClick)
|
||
window.removeEventListener('keydown', handleDrawingKey)
|
||
document.removeEventListener('pointerdown', onStageMenuPointerDown, true)
|
||
document.removeEventListener('keydown', onStageMenuKeyDown)
|
||
})
|
||
|
||
// --- Stage context menu ---------------------------------------------
|
||
// Right-clicking the stage (outside of a more specific handler like
|
||
// a vertex context menu) opens a small action menu. Currently two
|
||
// items; `stageResize` is a placeholder for the upcoming resize flow.
|
||
|
||
interface StageMenuState {
|
||
x: number
|
||
y: number
|
||
}
|
||
|
||
const stageMenu = ref<StageMenuState | null>(null)
|
||
const stageMenuRef = ref<HTMLDivElement | null>(null)
|
||
const documentDrawer = useDocumentDrawer()
|
||
|
||
function onStageContextMenu(e: MouseEvent) {
|
||
// Don't hijack native right-click on editable elements (e.g. the
|
||
// rare inline inputs the stage overlays render). The App-level
|
||
// `onDocumentContextMenu` already suppresses the browser menu
|
||
// elsewhere, so a non-editable target here is safe to intercept.
|
||
const target = e.target as HTMLElement | null
|
||
if (target && target.closest('input, textarea, [contenteditable="true"]')) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
stageMenu.value = { x: e.clientX, y: e.clientY }
|
||
}
|
||
|
||
function closeStageMenu() {
|
||
stageMenu.value = null
|
||
}
|
||
|
||
function onStageMenuPointerDown(e: PointerEvent) {
|
||
if (!stageMenu.value) return
|
||
const root = stageMenuRef.value
|
||
if (root && root.contains(e.target as Node)) return
|
||
closeStageMenu()
|
||
}
|
||
|
||
function onStageMenuKeyDown(e: KeyboardEvent) {
|
||
if (!stageMenu.value) return
|
||
if (e.key !== 'Escape') return
|
||
e.preventDefault()
|
||
closeStageMenu()
|
||
}
|
||
|
||
watch(stageMenu, (v) => {
|
||
if (v) {
|
||
document.addEventListener('pointerdown', onStageMenuPointerDown, true)
|
||
document.addEventListener('keydown', onStageMenuKeyDown)
|
||
} else {
|
||
document.removeEventListener('pointerdown', onStageMenuPointerDown, true)
|
||
document.removeEventListener('keydown', onStageMenuKeyDown)
|
||
}
|
||
})
|
||
|
||
function onMenuDocumentSettings() {
|
||
closeStageMenu()
|
||
documentDrawer.openDrawer()
|
||
}
|
||
|
||
function onMenuStageResize() {
|
||
closeStageMenu()
|
||
// Resizing without crop obscures the effect of the drag — elements
|
||
// outside the current canvas rect stay painted, so the user can't
|
||
// tell where the new edge actually lands. Force crop on for the
|
||
// duration of the mode; snapshot whatever the user had before so we
|
||
// can restore it on exit.
|
||
prevCropBeforeResize = cropObjects.value
|
||
cropObjects.value = true
|
||
stageResizeMode.value = true
|
||
}
|
||
|
||
// --- Stage resize mode ----------------------------------------------
|
||
// Eight drag handles overlay the stage rectangle. Dragging any handle
|
||
// resizes the canvas; handles that include a west or north component
|
||
// also shift every scene-world coordinate by the same delta so content
|
||
// stays pinned to its visible position while the canvas grows or
|
||
// shrinks around it.
|
||
|
||
type ResizeHandleDir = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw'
|
||
const RESIZE_HANDLES: ResizeHandleDir[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']
|
||
const MIN_CANVAS_SIZE = 20
|
||
|
||
const stageResizeMode = ref(false)
|
||
|
||
interface ResizeDragState {
|
||
dir: ResizeHandleDir
|
||
startClientX: number
|
||
startClientY: number
|
||
startWidth: number
|
||
startHeight: number
|
||
/** Cumulative world-space shift already applied during this drag.
|
||
* Each frame we compute the TARGET total shift and push only the
|
||
* delta relative to this — avoids double-applying as the pointer
|
||
* moves across multiple frames. */
|
||
appliedShiftX: number
|
||
appliedShiftY: number
|
||
pointerId: number
|
||
}
|
||
|
||
const resizeDrag = ref<ResizeDragState | null>(null)
|
||
|
||
// Snapshot of the stage-crop toggle from before resize mode kicked in,
|
||
// so the exit path can restore it. `null` means "not currently in
|
||
// resize mode" (nothing to restore).
|
||
let prevCropBeforeResize: boolean | null = null
|
||
|
||
function exitStageResizeMode() {
|
||
stageResizeMode.value = false
|
||
resizeDrag.value = null
|
||
if (prevCropBeforeResize !== null) {
|
||
cropObjects.value = prevCropBeforeResize
|
||
prevCropBeforeResize = null
|
||
}
|
||
}
|
||
|
||
function onResizeModeKeyDown(e: KeyboardEvent) {
|
||
if (!stageResizeMode.value) return
|
||
if (e.key !== 'Escape') return
|
||
e.preventDefault()
|
||
exitStageResizeMode()
|
||
}
|
||
|
||
watch(stageResizeMode, (v) => {
|
||
if (v) document.addEventListener('keydown', onResizeModeKeyDown)
|
||
else document.removeEventListener('keydown', onResizeModeKeyDown)
|
||
})
|
||
|
||
/** Screen-space rect for the canvas — matches how Konva renders it
|
||
* (view offset + canvas * zoom). Handles are anchored to these
|
||
* positions. Tracks the *active* canvas: SVG-asset / button-asset /
|
||
* main scene, depending on edit scope. */
|
||
const stageScreenRect = computed(() => {
|
||
const z = zoom.value
|
||
const c = effectiveCanvas.value
|
||
return {
|
||
left: viewX.value,
|
||
top: viewY.value,
|
||
width: c.width * z,
|
||
height: c.height * z,
|
||
}
|
||
})
|
||
|
||
/** Apply a canvas-size change to whichever canvas the user is
|
||
* currently editing. SVG-asset edit and button-asset edit both
|
||
* carry their own internal canvas; otherwise it's the main scene. */
|
||
function setActiveCanvasSize(width: number, height: number) {
|
||
const sa = store.activeSvgAsset
|
||
if (sa) {
|
||
store.setSvgAssetCanvas(sa.id, width, height)
|
||
return
|
||
}
|
||
const ba = store.activeButtonAsset
|
||
if (ba) {
|
||
store.setButtonAssetCanvas(ba.id, width, height)
|
||
return
|
||
}
|
||
store.setCanvasSize(width, height)
|
||
}
|
||
|
||
function handleStyle(dir: ResizeHandleDir): Record<string, string> {
|
||
const rect = stageScreenRect.value
|
||
const half = 6 // handle is 12×12, centre the chip on the edge
|
||
const cx = rect.left + rect.width / 2 - half
|
||
const cy = rect.top + rect.height / 2 - half
|
||
const left = rect.left - half
|
||
const right = rect.left + rect.width - half
|
||
const top = rect.top - half
|
||
const bottom = rect.top + rect.height - half
|
||
const pos: Record<ResizeHandleDir, { left: number; top: number }> = {
|
||
nw: { left, top },
|
||
n: { left: cx, top },
|
||
ne: { left: right, top },
|
||
e: { left: right, top: cy },
|
||
se: { left: right, top: bottom },
|
||
s: { left: cx, top: bottom },
|
||
sw: { left, top: bottom },
|
||
w: { left, top: cy },
|
||
}
|
||
return { left: `${pos[dir].left}px`, top: `${pos[dir].top}px` }
|
||
}
|
||
|
||
function resizeTargetsForDelta(
|
||
dir: ResizeHandleDir,
|
||
dxWorld: number,
|
||
dyWorld: number,
|
||
startW: number,
|
||
startH: number,
|
||
): { width: number; height: number; shiftX: number; shiftY: number } {
|
||
let newW = startW
|
||
let newH = startH
|
||
let shiftX = 0
|
||
let shiftY = 0
|
||
if (dir.includes('e')) newW = startW + dxWorld
|
||
if (dir.includes('w')) {
|
||
newW = startW - dxWorld
|
||
shiftX = -dxWorld
|
||
}
|
||
if (dir.includes('s')) newH = startH + dyWorld
|
||
if (dir.includes('n')) {
|
||
newH = startH - dyWorld
|
||
shiftY = -dyWorld
|
||
}
|
||
// Enforce a floor. If resizing would push below MIN_CANVAS_SIZE on
|
||
// a west/north drag, clamp both the size AND the shift so elements
|
||
// don't skate off while the canvas is pinned at the floor.
|
||
if (newW < MIN_CANVAS_SIZE) {
|
||
const clampDx = dxWorld + (MIN_CANVAS_SIZE - newW)
|
||
newW = MIN_CANVAS_SIZE
|
||
if (dir.includes('w')) shiftX = -clampDx
|
||
}
|
||
if (newH < MIN_CANVAS_SIZE) {
|
||
const clampDy = dyWorld + (MIN_CANVAS_SIZE - newH)
|
||
newH = MIN_CANVAS_SIZE
|
||
if (dir.includes('n')) shiftY = -clampDy
|
||
}
|
||
return { width: newW, height: newH, shiftX, shiftY }
|
||
}
|
||
|
||
function onResizeHandlePointerDown(e: PointerEvent, dir: ResizeHandleDir) {
|
||
if (!stageResizeMode.value) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
const el = e.currentTarget as HTMLElement
|
||
el.setPointerCapture(e.pointerId)
|
||
resizeDrag.value = {
|
||
dir,
|
||
startClientX: e.clientX,
|
||
startClientY: e.clientY,
|
||
startWidth: effectiveCanvas.value.width,
|
||
startHeight: effectiveCanvas.value.height,
|
||
appliedShiftX: 0,
|
||
appliedShiftY: 0,
|
||
pointerId: e.pointerId,
|
||
}
|
||
}
|
||
|
||
function onResizeHandlePointerMove(e: PointerEvent) {
|
||
const drag = resizeDrag.value
|
||
if (!drag || drag.pointerId !== e.pointerId) return
|
||
const z = zoom.value || 1
|
||
const dxWorld = (e.clientX - drag.startClientX) / z
|
||
const dyWorld = (e.clientY - drag.startClientY) / z
|
||
const { width, height, shiftX, shiftY } = resizeTargetsForDelta(
|
||
drag.dir,
|
||
dxWorld,
|
||
dyWorld,
|
||
drag.startWidth,
|
||
drag.startHeight,
|
||
)
|
||
setActiveCanvasSize(width, height)
|
||
const deltaShiftX = shiftX - drag.appliedShiftX
|
||
const deltaShiftY = shiftY - drag.appliedShiftY
|
||
// Shift only applies to the main-scene canvas — sub-asset edit
|
||
// scopes have their own coord system and don't need a global
|
||
// shift to keep content visible. North/west drags inside an SVG
|
||
// / button asset just resize without re-anchoring.
|
||
if (
|
||
!store.activeSvgAssetId &&
|
||
!store.activeButtonAssetId &&
|
||
(deltaShiftX !== 0 || deltaShiftY !== 0)
|
||
) {
|
||
store.shiftSceneWorldCoords(deltaShiftX, deltaShiftY)
|
||
drag.appliedShiftX = shiftX
|
||
drag.appliedShiftY = shiftY
|
||
}
|
||
}
|
||
|
||
function onResizeHandlePointerUp(e: PointerEvent) {
|
||
const drag = resizeDrag.value
|
||
if (!drag || drag.pointerId !== e.pointerId) return
|
||
const el = e.currentTarget as HTMLElement
|
||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||
resizeDrag.value = null
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
ref="container"
|
||
class="stage-container"
|
||
:class="{ drawing: store.drawingMode === 'freeform' }"
|
||
@contextmenu="onStageContextMenu"
|
||
>
|
||
<video
|
||
v-if="store.scene.backgroundVideo"
|
||
ref="bgVideoEl"
|
||
class="bg-video"
|
||
:style="bgVideoStyle"
|
||
:src="store.scene.backgroundVideo.src"
|
||
muted
|
||
playsinline
|
||
preload="auto"
|
||
></video>
|
||
<v-stage
|
||
ref="stageRef"
|
||
:config="stageConfig"
|
||
@mousedown="handleStageMouseDown"
|
||
@mousemove="handleStageMouseMove"
|
||
@mouseup="handleStageMouseUp"
|
||
>
|
||
<v-layer>
|
||
<v-group :config="viewConfig">
|
||
<v-group :config="clipGroupConfig">
|
||
<v-rect :config="backgroundConfig" />
|
||
<template
|
||
v-for="(entry, i) in stackItems"
|
||
:key="
|
||
entry.kind === 'layer'
|
||
? `l:${entry.item.layer.id}`
|
||
: `ci:${entry.bundle.instance.id}:${i}`
|
||
"
|
||
>
|
||
<template v-if="entry.kind === 'layer'">
|
||
<v-group
|
||
v-if="entry.item.asset.kind === 'svg' && svgAssetFor(entry.item.asset)"
|
||
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
|
||
@dragmove="handleDragMove"
|
||
@dragend="handleDragEnd"
|
||
@transform="handleTransform"
|
||
@transformend="handleTransformEnd"
|
||
>
|
||
<v-rect
|
||
:config="svgHitCatcherConfig(svgAssetFor(entry.item.asset)!, entry.item.layer)"
|
||
/>
|
||
<template
|
||
v-for="(sub, sli) in svgStackForAsset(
|
||
svgAssetFor(entry.item.asset)!,
|
||
Math.sqrt(
|
||
Math.abs(entry.item.props.scaleX || 1) *
|
||
Math.abs(entry.item.props.scaleY || 1),
|
||
),
|
||
)"
|
||
:key="`${entry.item.layer.id}:${sub.layer.id}:${sli}`"
|
||
>
|
||
<v-rect
|
||
v-if="sub.asset.kind === 'rect'"
|
||
:config="svgChildConfig(rectConfig(sub.layer, sub.asset, sub.props))"
|
||
/>
|
||
<v-circle
|
||
v-else-if="sub.asset.kind === 'circle'"
|
||
:config="svgChildConfig(circleConfig(sub.layer, sub.asset, sub.props))"
|
||
/>
|
||
<v-text
|
||
v-else-if="sub.asset.kind === 'text'"
|
||
:config="svgChildConfig(textConfig(sub.layer, sub.asset, sub.props))"
|
||
/>
|
||
<v-line
|
||
v-else-if="sub.asset.kind === 'polygon'"
|
||
:config="svgChildConfig(polygonConfig(sub.layer, sub.asset, sub.props))"
|
||
/>
|
||
<v-image
|
||
v-else-if="sub.asset.kind === 'image' && imageConfig(sub.layer, sub.asset, sub.props)"
|
||
:config="svgChildConfig(imageConfig(sub.layer, sub.asset, sub.props)!)"
|
||
/>
|
||
</template>
|
||
</v-group>
|
||
<v-group
|
||
v-else-if="entry.item.asset.kind === 'button' && buttonAssetFor(entry.item.asset)"
|
||
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
|
||
@mouseenter="onButtonEnter(entry.item.layer)"
|
||
@mouseleave="onButtonLeave(entry.item.layer)"
|
||
@mousedown="onButtonDown(entry.item.layer)"
|
||
@mouseup="onButtonUp(entry.item.layer)"
|
||
@dragmove="handleDragMove"
|
||
@dragend="handleDragEnd"
|
||
@transform="handleTransform"
|
||
@transformend="handleTransformEnd"
|
||
>
|
||
<v-rect
|
||
:config="buttonHitCatcherConfig(buttonAssetFor(entry.item.asset)!, entry.item.layer)"
|
||
/>
|
||
<template
|
||
v-for="(sub, bli) in buttonStackForTime(buttonAssetFor(entry.item.asset)!, buttonTimeFor(entry.item.layer))"
|
||
:key="`${entry.item.layer.id}:${sub.layer.id}:${bli}`"
|
||
>
|
||
<v-rect
|
||
v-if="sub.asset.kind === 'rect'"
|
||
:config="buttonChildConfig(rectConfig(sub.layer, sub.asset, sub.props))"
|
||
/>
|
||
<v-circle
|
||
v-else-if="sub.asset.kind === 'circle'"
|
||
:config="buttonChildConfig(circleConfig(sub.layer, sub.asset, sub.props))"
|
||
/>
|
||
<v-text
|
||
v-else-if="sub.asset.kind === 'text'"
|
||
:config="buttonChildConfig(textConfig(sub.layer, sub.asset, sub.props))"
|
||
/>
|
||
<v-line
|
||
v-else-if="sub.asset.kind === 'polygon'"
|
||
:config="buttonChildConfig(polygonConfig(sub.layer, sub.asset, sub.props))"
|
||
/>
|
||
<v-image
|
||
v-else-if="sub.asset.kind === 'image' && imageConfig(sub.layer, sub.asset, sub.props)"
|
||
:config="buttonChildConfig(imageConfig(sub.layer, sub.asset, sub.props)!)"
|
||
/>
|
||
</template>
|
||
</v-group>
|
||
<v-rect
|
||
v-else-if="entry.item.asset.kind === 'rect'"
|
||
:config="rectConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||
@dragmove="handleDragMove"
|
||
@dragend="handleDragEnd"
|
||
@transform="handleTransform"
|
||
@transformend="handleTransformEnd"
|
||
/>
|
||
<v-circle
|
||
v-else-if="entry.item.asset.kind === 'circle'"
|
||
:config="circleConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||
@dragmove="handleDragMove"
|
||
@dragend="handleDragEnd"
|
||
@transform="handleTransform"
|
||
@transformend="handleTransformEnd"
|
||
/>
|
||
<v-text
|
||
v-else-if="entry.item.asset.kind === 'text'"
|
||
:config="textConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||
@dragmove="handleDragMove"
|
||
@dragend="handleDragEnd"
|
||
@transform="handleTransform"
|
||
@transformend="handleTransformEnd"
|
||
/>
|
||
<v-line
|
||
v-else-if="entry.item.asset.kind === 'polygon'"
|
||
:config="polygonConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||
@dragmove="handleDragMove"
|
||
@dragend="handleDragEnd"
|
||
@transform="handleTransform"
|
||
@transformend="handleTransformEnd"
|
||
/>
|
||
<v-image
|
||
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||
:config="imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!"
|
||
@dragmove="handleDragMove"
|
||
@dragend="handleDragEnd"
|
||
@transform="handleTransform"
|
||
@transformend="handleTransformEnd"
|
||
/>
|
||
</template>
|
||
<template v-else>
|
||
<v-group
|
||
:config="clipInstanceGroupConfig(entry.bundle.instance)"
|
||
@mousedown="store.selectClipInstance(entry.bundle.instance.id)"
|
||
@dragend="onClipInstanceDragEnd($event, entry.bundle.instance.id)"
|
||
@transformend="onClipInstanceTransformEnd($event, entry.bundle.instance.id)"
|
||
>
|
||
<v-rect
|
||
:config="clipFrameConfig(
|
||
entry.bundle.instance.id,
|
||
entry.bundle.bounds,
|
||
)"
|
||
/>
|
||
<template
|
||
v-for="sub in entry.bundle.items"
|
||
:key="`ci:${entry.bundle.instance.id}:${sub.layer.id}`"
|
||
>
|
||
<v-rect
|
||
v-if="sub.asset.kind === 'rect'"
|
||
:config="clipRectConfig(sub)"
|
||
/>
|
||
<v-circle
|
||
v-else-if="sub.asset.kind === 'circle'"
|
||
:config="clipCircleConfig(sub)"
|
||
/>
|
||
<v-text
|
||
v-else-if="sub.asset.kind === 'text'"
|
||
:config="clipTextConfig(sub)"
|
||
/>
|
||
<v-line
|
||
v-else-if="sub.asset.kind === 'polygon'"
|
||
:config="clipPolygonConfig(sub)"
|
||
/>
|
||
<v-image
|
||
v-else-if="sub.asset.kind === 'image' && clipImageConfig(sub)"
|
||
:config="clipImageConfig(sub)!"
|
||
/>
|
||
</template>
|
||
</v-group>
|
||
</template>
|
||
</template>
|
||
<template
|
||
v-if="store.activeButtonAssetId && store.activeButtonEditMode === 'hit'"
|
||
>
|
||
<template
|
||
v-for="(shape, idx) in activeHitShapes"
|
||
:key="`hit-${idx}`"
|
||
>
|
||
<v-rect
|
||
v-if="shape.kind === 'rect'"
|
||
:config="hitShapeConfig(shape, idx)"
|
||
@click="(e: Konva.KonvaEventObject<MouseEvent>) => onHitShapeClick(e, idx)"
|
||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onHitShapeDragEnd(e, idx)"
|
||
@transformend="(e: Konva.KonvaEventObject<Event>) => onHitRectTransformEnd(e, idx)"
|
||
/>
|
||
<v-circle
|
||
v-else-if="shape.kind === 'circle'"
|
||
:config="hitShapeConfig(shape, idx)"
|
||
@click="(e: Konva.KonvaEventObject<MouseEvent>) => onHitShapeClick(e, idx)"
|
||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onHitShapeDragEnd(e, idx)"
|
||
@transformend="(e: Konva.KonvaEventObject<Event>) => onHitCircleTransformEnd(e, idx)"
|
||
/>
|
||
<v-line
|
||
v-else-if="shape.kind === 'polygon'"
|
||
:config="hitShapeConfig(shape, idx)"
|
||
@click="(e: Konva.KonvaEventObject<MouseEvent>) => onHitShapeClick(e, idx)"
|
||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onHitShapeDragEnd(e, idx)"
|
||
/>
|
||
</template>
|
||
<template
|
||
v-for="(shape, idx) in activeHitShapes"
|
||
:key="`hit-verts-${idx}`"
|
||
>
|
||
<template v-if="shape.kind === 'polygon'">
|
||
<v-circle
|
||
v-for="(pt, vi) in shape.points"
|
||
:key="`hv-${idx}-${vi}`"
|
||
:config="hitVertexConfig(idx, vi, pt)"
|
||
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onHitPolygonVertexDrag(e, idx, vi)"
|
||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onHitPolygonVertexDrag(e, idx, vi)"
|
||
/>
|
||
</template>
|
||
</template>
|
||
<v-transformer
|
||
ref="hitTransformerRef"
|
||
:config="hitShapeTransformerConfig"
|
||
/>
|
||
</template>
|
||
</v-group>
|
||
<v-rect :config="canvasBorderConfig" />
|
||
<template v-if="centerCrosshairConfigs">
|
||
<v-line :config="centerCrosshairConfigs.horizontal" />
|
||
<v-line :config="centerCrosshairConfigs.vertical" />
|
||
</template>
|
||
<v-rect
|
||
v-for="h in canvasResizeHandles"
|
||
:key="`canvas-resize-${h.edge}`"
|
||
:config="canvasResizeHandleConfig(h)"
|
||
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onCanvasResizeDrag(e, h.edge)"
|
||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onCanvasResizeDragEnd(e, h.edge)"
|
||
/>
|
||
<v-line v-if="rotationArmConfig" :config="rotationArmConfig" />
|
||
<v-group
|
||
v-if="pivotHandleConfig"
|
||
:config="pivotHandleConfig"
|
||
@dragstart="onPivotDragStart"
|
||
@dragend="onPivotDragEnd"
|
||
>
|
||
<v-circle :config="pivotRingConfig" />
|
||
<v-circle :config="pivotDotConfig" />
|
||
</v-group>
|
||
<v-group
|
||
v-if="rotationHandleConfig"
|
||
:config="rotationHandleConfig"
|
||
@dragmove="onRotationHandleDragMove"
|
||
@dragend="onRotationHandleDragEnd"
|
||
>
|
||
<v-circle :config="rotationKnobConfig" />
|
||
</v-group>
|
||
|
||
<!-- SVG-scope multi-rotation: temp pivot + rotation arm.
|
||
Active only when isMultiSvg is true; the per-layer
|
||
handles above are gated off in that case. -->
|
||
<v-line v-if="multiRotArmConfig" :config="multiRotArmConfig" />
|
||
<v-group
|
||
v-if="multiPivotHandleConfig"
|
||
:config="multiPivotHandleConfig"
|
||
@dragmove="onMultiPivotDragMove"
|
||
@dragend="onMultiPivotDragEnd"
|
||
>
|
||
<v-circle :config="pivotRingConfig" />
|
||
<v-circle :config="pivotDotConfig" />
|
||
</v-group>
|
||
<v-group
|
||
v-if="multiRotHandleConfig"
|
||
:config="multiRotHandleConfig"
|
||
@dragstart="onMultiRotHandleDragStart"
|
||
@dragmove="onMultiRotHandleDragMove"
|
||
@dragend="onMultiRotHandleDragEnd"
|
||
>
|
||
<v-circle :config="multiRotKnobConfig" />
|
||
</v-group>
|
||
<v-circle
|
||
v-for="h in polygonVertexHandles"
|
||
:key="`vh-${h.layerId}-${h.index}`"
|
||
:config="vertexHandleConfig(h)"
|
||
@click="(e: Konva.KonvaEventObject<MouseEvent>) => onVertexClick(e, h.index)"
|
||
@dragstart="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragStart(e, h.index)"
|
||
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragMove(e, h.index)"
|
||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragEnd(e, h.index)"
|
||
@contextmenu="(e: Konva.KonvaEventObject<PointerEvent>) => onVertexContextMenu(e, h.index)"
|
||
/>
|
||
<v-line
|
||
v-for="tl in polygonTangentLines"
|
||
:key="tl.key"
|
||
:config="tl.config"
|
||
/>
|
||
<v-circle
|
||
v-for="th in polygonTangentHandles"
|
||
:key="th.key"
|
||
:config="tangentHandleConfig(th)"
|
||
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onTangentDrag(e, th)"
|
||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onTangentDrag(e, th)"
|
||
/>
|
||
<v-line
|
||
v-for="g in animationGhostSegments"
|
||
:key="g.key"
|
||
:config="g.config"
|
||
/>
|
||
<v-line
|
||
v-for="line in animationPathLines"
|
||
:key="line.key"
|
||
:config="line.config"
|
||
/>
|
||
<v-circle
|
||
v-for="d in animationPathDots"
|
||
:key="d.key"
|
||
:config="animationDotConfig(d)"
|
||
@mouseenter="onPathDotMouseEnter(d)"
|
||
@mouseleave="onPathDotMouseLeave"
|
||
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
|
||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
|
||
/>
|
||
<v-line v-if="drawingPreviewConfig" :config="drawingPreviewConfig" />
|
||
<v-circle
|
||
v-for="d in drawingDotsConfig"
|
||
:key="d.key"
|
||
:config="d"
|
||
/>
|
||
</v-group>
|
||
<v-transformer ref="transformerRef" :config="transformerConfig" />
|
||
</v-layer>
|
||
</v-stage>
|
||
<canvas
|
||
v-show="maskPreview"
|
||
ref="previewCanvasEl"
|
||
class="mask-preview-canvas"
|
||
></canvas>
|
||
<div
|
||
v-if="boxSelectRectPx"
|
||
class="box-select"
|
||
:style="{
|
||
left: boxSelectRectPx.left + 'px',
|
||
top: boxSelectRectPx.top + 'px',
|
||
width: boxSelectRectPx.width + 'px',
|
||
height: boxSelectRectPx.height + 'px',
|
||
}"
|
||
></div>
|
||
|
||
<div
|
||
v-if="store.activeButtonAssetId && store.activeButtonEditMode === 'hit'"
|
||
class="hit-tools"
|
||
@pointerdown.stop
|
||
>
|
||
<button class="hit-btn" v-tooltip="t('canvas.hit.addRect')" @click="addHitRect">
|
||
<PhSquare :size="13" />
|
||
<span>rect</span>
|
||
</button>
|
||
<button class="hit-btn" v-tooltip="t('canvas.hit.addCircle')" @click="addHitCircle">
|
||
<PhCircle :size="13" />
|
||
<span>circle</span>
|
||
</button>
|
||
<button class="hit-btn" v-tooltip="t('canvas.hit.addPolygon')" @click="addHitPolygon">
|
||
<PhPolygon :size="13" />
|
||
<span>polygon</span>
|
||
</button>
|
||
<button
|
||
class="hit-btn danger"
|
||
:disabled="selectedHitShapeIndex === null"
|
||
v-tooltip="t('canvas.hit.remove')"
|
||
@click="removeSelectedHitShape"
|
||
>
|
||
<PhTrash :size="13" />
|
||
</button>
|
||
</div>
|
||
|
||
<div class="zoom-controls" @pointerdown.stop>
|
||
<button class="zc-btn" v-tooltip="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
|
||
<button class="zc-label" v-tooltip="t('canvas.fitToView')" @click="fitToContainer">
|
||
{{ zoomPercent }}%
|
||
</button>
|
||
<button class="zc-btn" v-tooltip="t('canvas.zoomIn')" @click="zoomBy(1.25)">+</button>
|
||
<div class="zc-sep"></div>
|
||
<button class="zc-btn wide" v-tooltip="t('canvas.zoomOneToOne')" @click="zoomToOne">1:1</button>
|
||
<button class="zc-btn" v-tooltip="t('canvas.fitToView')" @click="fitToContainer">
|
||
<PhCornersIn :size="13" />
|
||
</button>
|
||
<div class="zc-sep"></div>
|
||
<button
|
||
class="zc-btn crop-btn"
|
||
:class="{ active: cropObjects }"
|
||
v-tooltip="cropObjects ? t('canvas.cropOff') : t('canvas.cropOn')"
|
||
@click="cropObjects = !cropObjects"
|
||
>
|
||
<PhCrop :size="13" />
|
||
</button>
|
||
<button
|
||
class="zc-btn"
|
||
:class="{ active: maskPreview }"
|
||
v-tooltip="maskPreview ? t('canvas.maskPreviewOff') : t('canvas.maskPreviewOn')"
|
||
@click="maskPreview = !maskPreview"
|
||
>
|
||
<PhMaskHappy v-if="!maskPreview" :size="13" />
|
||
<PhPencilSimple v-else :size="13" />
|
||
</button>
|
||
<button
|
||
class="zc-btn center-cross-btn"
|
||
:class="{ active: centerCrosshair }"
|
||
v-tooltip="centerCrosshair ? t('canvas.centerCrosshairOff') : t('canvas.centerCrosshairOn')"
|
||
@click="centerCrosshair = !centerCrosshair"
|
||
>
|
||
<PhCrosshair :size="13" />
|
||
</button>
|
||
<button
|
||
class="zc-btn stage-preview-btn"
|
||
:class="{ active: stagePreview }"
|
||
v-tooltip="stagePreview ? t('canvas.stagePreviewOff') : t('canvas.stagePreviewOn')"
|
||
@click="stagePreview = !stagePreview"
|
||
>
|
||
<PhCursorClick :size="13" />
|
||
</button>
|
||
</div>
|
||
|
||
<div class="dims">{{ effectiveCanvas.width }} × {{ effectiveCanvas.height }}</div>
|
||
|
||
<div
|
||
v-if="vertexMenu"
|
||
class="vertex-menu"
|
||
:style="{ left: vertexMenu.screenX + 'px', top: vertexMenu.screenY + 'px' }"
|
||
@mouseleave="closeVertexMenu"
|
||
>
|
||
<button
|
||
class="vertex-menu-item"
|
||
:class="{ active: vertexMenu.currentType === 'corner' }"
|
||
@click="applyVertexType('corner')"
|
||
>
|
||
{{ t('canvas.vertexCorner') }}
|
||
</button>
|
||
<button
|
||
class="vertex-menu-item"
|
||
:class="{ active: vertexMenu.currentType === 'curve' }"
|
||
@click="applyVertexType('curve')"
|
||
>
|
||
{{ t('canvas.vertexCurve') }}
|
||
</button>
|
||
<button
|
||
class="vertex-menu-item"
|
||
:class="{ active: vertexMenu.currentType === 'splitcurve' }"
|
||
@click="applyVertexType('splitcurve')"
|
||
>
|
||
{{ t('canvas.vertexSplitCurve') }}
|
||
</button>
|
||
</div>
|
||
|
||
<template v-if="stageResizeMode">
|
||
<div
|
||
v-for="dir in RESIZE_HANDLES"
|
||
:key="dir"
|
||
class="stage-resize-handle"
|
||
:class="['dir-' + dir]"
|
||
:style="handleStyle(dir)"
|
||
@pointerdown="onResizeHandlePointerDown($event, dir)"
|
||
@pointermove="onResizeHandlePointerMove"
|
||
@pointerup="onResizeHandlePointerUp"
|
||
@pointercancel="onResizeHandlePointerUp"
|
||
></div>
|
||
<div
|
||
class="stage-resize-banner"
|
||
@pointerdown.stop
|
||
@mousedown.stop
|
||
>
|
||
<span class="stage-resize-label">{{ t('canvas.resize.banner') }}</span>
|
||
<span class="stage-resize-hint">{{ t('canvas.resize.hint') }}</span>
|
||
<button
|
||
type="button"
|
||
class="stage-resize-done"
|
||
@pointerdown.stop
|
||
@mousedown.stop
|
||
@click.stop="exitStageResizeMode"
|
||
>
|
||
{{ t('canvas.resize.done') }}
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<div
|
||
v-if="stageMenu"
|
||
ref="stageMenuRef"
|
||
class="stage-menu"
|
||
:style="{ left: stageMenu.x + 'px', top: stageMenu.y + 'px' }"
|
||
@contextmenu.prevent.stop
|
||
>
|
||
<button
|
||
v-if="!store.activeSvgAssetId"
|
||
type="button"
|
||
class="stage-menu-item"
|
||
@pointerdown.stop
|
||
@mousedown.stop
|
||
@click.stop="onMenuDocumentSettings"
|
||
>
|
||
<PhSlidersHorizontal :size="13" class="stage-menu-icon" />
|
||
{{ t('canvas.menu.documentSettings') }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="stage-menu-item"
|
||
@pointerdown.stop
|
||
@mousedown.stop
|
||
@click.stop="onMenuStageResize"
|
||
>
|
||
<PhFrameCorners :size="13" class="stage-menu-icon" />
|
||
{{ store.activeSvgAssetId ? t('canvas.menu.editCanvasSize') : t('canvas.menu.stageResize') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.stage-container {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.bg-video {
|
||
position: absolute;
|
||
object-fit: cover;
|
||
pointer-events: none;
|
||
user-select: none;
|
||
z-index: 0;
|
||
}
|
||
|
||
.stage-container.drawing {
|
||
cursor: crosshair;
|
||
}
|
||
|
||
.vertex-menu {
|
||
position: absolute;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 140px;
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
box-shadow: var(--shadow-md, 0 4px 12px rgba(0, 0, 0, 0.3));
|
||
z-index: 20;
|
||
padding: 4px;
|
||
}
|
||
|
||
.vertex-menu-item {
|
||
text-align: left;
|
||
padding: 6px 10px;
|
||
font-size: 12px;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--text);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.vertex-menu-item:hover {
|
||
background: var(--panel-alt);
|
||
}
|
||
|
||
.vertex-menu-item.active {
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* Stage-level right-click menu. Fixed-positioned so the cursor point
|
||
(clientX/clientY) maps 1:1 regardless of stage scrolling. */
|
||
.stage-menu {
|
||
position: fixed;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 180px;
|
||
background: var(--panel);
|
||
border: 1px solid var(--border-strong, var(--border));
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow-md, 0 8px 24px rgba(0, 0, 0, 0.35));
|
||
z-index: 1000;
|
||
padding: 5px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.stage-menu-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 7px 10px;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text);
|
||
font: inherit;
|
||
font-size: 12px;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
border-radius: var(--radius-sm);
|
||
width: 100%;
|
||
}
|
||
|
||
.stage-menu-item:hover:not(:disabled) {
|
||
background: var(--panel-alt);
|
||
}
|
||
|
||
.stage-menu-icon {
|
||
flex-shrink: 0;
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
/* Resize-mode handles. Eight 12×12 accent-tinted chips anchored to
|
||
the canvas edges/corners. Cursors hint the axis so the user knows
|
||
the drag direction without reading docs. */
|
||
.stage-resize-handle {
|
||
position: absolute;
|
||
width: 12px;
|
||
height: 12px;
|
||
background: var(--accent);
|
||
border: 2px solid #fff;
|
||
border-radius: 3px;
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
|
||
z-index: 15;
|
||
touch-action: none;
|
||
user-select: none;
|
||
}
|
||
|
||
.stage-resize-handle.dir-n,
|
||
.stage-resize-handle.dir-s {
|
||
cursor: ns-resize;
|
||
}
|
||
.stage-resize-handle.dir-e,
|
||
.stage-resize-handle.dir-w {
|
||
cursor: ew-resize;
|
||
}
|
||
.stage-resize-handle.dir-ne,
|
||
.stage-resize-handle.dir-sw {
|
||
cursor: nesw-resize;
|
||
}
|
||
.stage-resize-handle.dir-nw,
|
||
.stage-resize-handle.dir-se {
|
||
cursor: nwse-resize;
|
||
}
|
||
|
||
/* Floating exit affordance. Anchored to the top of the stage area so
|
||
the user has a visible "I'm done" target while the handles are up.
|
||
Esc still works as a shortcut. */
|
||
.stage-resize-banner {
|
||
position: absolute;
|
||
top: 12px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 20;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 6px 8px 6px 14px;
|
||
background: var(--panel);
|
||
border: 1px solid var(--border-strong, var(--border));
|
||
border-radius: 999px;
|
||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.stage-resize-label {
|
||
font-weight: 600;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.stage-resize-hint {
|
||
color: var(--text-dim);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.stage-resize-done {
|
||
padding: 4px 12px;
|
||
background: var(--accent);
|
||
border: 1px solid transparent;
|
||
color: var(--accent-text, #fff);
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.stage-resize-done:hover {
|
||
filter: brightness(1.1);
|
||
}
|
||
|
||
.mask-preview-canvas {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background: var(--bg);
|
||
}
|
||
|
||
.box-select {
|
||
position: absolute;
|
||
pointer-events: none;
|
||
border: 1px dashed var(--accent);
|
||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||
z-index: 15;
|
||
}
|
||
|
||
.dims {
|
||
position: absolute;
|
||
bottom: 10px;
|
||
right: 12px;
|
||
color: var(--text-dim);
|
||
font-size: 11px;
|
||
font-variant-numeric: tabular-nums;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.hit-tools {
|
||
position: absolute;
|
||
top: 10px;
|
||
left: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 3px;
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow-sm);
|
||
user-select: none;
|
||
}
|
||
|
||
.hit-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 9px;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
border-radius: var(--radius-sm);
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.hit-btn:hover:not(:disabled) {
|
||
background: var(--panel-hover);
|
||
}
|
||
|
||
.hit-btn:disabled {
|
||
opacity: 0.4;
|
||
cursor: default;
|
||
}
|
||
|
||
.hit-btn.danger:hover:not(:disabled) {
|
||
background: var(--danger-soft);
|
||
color: var(--danger);
|
||
border-color: var(--danger);
|
||
}
|
||
|
||
.zoom-controls {
|
||
position: absolute;
|
||
bottom: 10px;
|
||
left: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
padding: 3px;
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow-sm);
|
||
user-select: none;
|
||
}
|
||
|
||
.zc-btn {
|
||
width: 26px;
|
||
height: 24px;
|
||
padding: 0;
|
||
justify-content: center;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text-dim);
|
||
font-size: 13px;
|
||
line-height: 1;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.zc-btn:hover:not(:disabled) {
|
||
background: var(--panel-alt);
|
||
color: var(--text);
|
||
}
|
||
|
||
.zc-btn.wide {
|
||
width: auto;
|
||
padding: 0 8px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.zc-btn.active {
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.zc-btn.active:hover:not(:disabled) {
|
||
background: var(--accent-soft-strong);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.zc-label {
|
||
min-width: 54px;
|
||
height: 24px;
|
||
padding: 0 8px;
|
||
justify-content: center;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text-dim-strong);
|
||
font-variant-numeric: tabular-nums;
|
||
font-size: 11px;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.zc-label:hover:not(:disabled) {
|
||
background: var(--panel-alt);
|
||
color: var(--text);
|
||
}
|
||
|
||
.zc-sep {
|
||
width: 1px;
|
||
height: 16px;
|
||
background: var(--border);
|
||
margin: 0 2px;
|
||
}
|
||
</style>
|