Files
RUWIO/src/components/CanvasStage.vue
T
thandClaude Opus 4.7 276d621d20 0.3.0: freeform drawing, stroke properties, polygon edit gestures
- Freeform drawing tool: new sidebar button enters a click-to-draw mode
  that appends points, shows a live ghost preview line, and exits via
  Enter or double-click (near the start point closes the path). Works
  end-to-end through all four render pipelines.
- Polygon authoring gestures: double-click on a path inserts a vertex on
  the nearest edge; double-click on an open polygon's endpoint resumes
  the drawing session from that end, reversing point order when needed.
- Stroke + closed-path properties: all shape types (rect, circle, text,
  polygon) expose stroke color, stroke width, and (for polygons) a
  closed toggle. Fill and stroke each support a "transparent" switch so
  shapes can render as outline-only or fill-only.
- Mask preview no longer inherits the user's stroke — the dashed ghost
  keeps its own outline regardless of stroke-width changes.
- Playhead no longer dims on inactive timeline tracks; only keyframes,
  segments and clip blocks fade.
- Properties panel point-count control for polygons, plus per-keyframe
  points animation that correctly interpolates once a baseline exists.
- Pivot drag hides the rotation handle until release.
- Transformer attach retries on a short timer to avoid races where the
  Konva node hasn't mounted yet when selection changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 09:53:01 +02:00

1653 lines
49 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type Konva from 'konva'
import { PhCornersIn, PhCrop, PhMaskHappy, PhPencilSimple } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import { loadImage } from '@/lib/fileLoader'
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
import { expandedTrackOrder } from '@/lib/sceneOrder'
const store = useSceneStore()
const { selectedKfs } = useKfSelection()
const container = ref<HTMLDivElement | null>(null)
const containerSize = ref({ width: 0, height: 0 })
const stageRef = ref<{ getStage(): Konva.Stage } | null>(null)
const transformerRef = ref<{ getNode(): Konva.Transformer } | null>(null)
const zoom = ref(1)
const viewX = ref(0)
const viewY = ref(0)
const cropObjects = ref(false)
const maskPreview = ref(false)
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 lw = store.scene.canvas.width
const lh = store.scene.canvas.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 lw = store.scene.canvas.width
const lh = store.scene.canvas.height
if (!cw || !ch) return
zoom.value = 1
viewX.value = (cw - lw) / 2
viewY.value = (ch - lh) / 2
}
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 = store.scene.canvas.width
const h = store.scene.canvas.height
const crop = cropObjects.value
return {
clipFunc: (ctx: CanvasRenderingContext2D) => {
if (crop) ctx.rect(0, 0, w, h)
else ctx.rect(-1e6, -1e6, 2e6, 2e6)
},
}
})
const backgroundConfig = computed(() => ({
x: 0,
y: 0,
width: store.scene.canvas.width,
height: store.scene.canvas.height,
fill: store.scene.canvas.background,
}))
const canvasBorderConfig = computed(() => ({
x: 0,
y: 0,
width: store.scene.canvas.width,
height: store.scene.canvas.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
}
type StackItem =
| { kind: 'layer'; item: RenderedLayer }
| { kind: 'clip'; item: RenderedClipLayer }
function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps {
const live = liveLayerPose.value
if (!live || live.layerId !== layerId) return props
return {
...props,
x: live.x,
y: live.y,
scaleX: live.scaleX,
scaleY: live.scaleY,
rotation: live.rotation,
}
}
const stackItems = computed<StackItem[]>(() => {
// 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) {
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
out.push({
kind: 'layer',
item: {
layer,
asset,
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
},
})
}
return out
}
const out: StackItem[] = []
const layerById = new Map<string, Layer>()
for (const l of store.scene.layers) layerById.set(l.id, l)
const clipById = new Map<string, NonNullable<typeof store.scene.clips>[number]>()
for (const c of store.scene.clips ?? []) clipById.set(c.id, c)
const instancesByTrack = new Map<string, NonNullable<typeof store.scene.clipInstances>>()
for (const ci of store.scene.clipInstances ?? []) {
const arr = instancesByTrack.get(ci.clipTrackId) ?? []
arr.push(ci)
instancesByTrack.set(ci.clipTrackId, arr)
}
const expanded = expandedTrackOrder(store.scene)
const order = expanded.length ? expanded : store.scene.layers.map((l) => l.id)
// iterate bottom → top: reverse the array
for (let i = order.length - 1; i >= 0; i--) {
const id = order[i]
const layer = layerById.get(id)
if (layer) {
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
out.push({
kind: 'layer',
item: {
layer,
asset,
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
},
})
continue
}
const instances = instancesByTrack.get(id) ?? []
for (const inst of instances) {
const clip = clipById.get(inst.clipId)
if (!clip) continue
const trimStart = inst.trimStart ?? 0
const trimEnd = inst.trimEnd ?? 0
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
const offsetIntoBlock = store.playhead - inst.startTime
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
const localTime = trimStart + offsetIntoBlock
// Clip's own layers: reverse too so layer[0] ends up on top
for (let k = clip.layers.length - 1; k >= 0; k--) {
const cl = clip.layers[k]
if (!cl.visible) continue
const ca = assetMap.value.get(cl.assetId)
if (!ca) continue
out.push({
kind: 'clip',
item: {
instanceId: inst.id,
layer: cl,
asset: ca,
props: sampleLayer(cl, localTime),
},
})
}
}
}
return out
})
function shadowCfg(props: AnimatableProps): Record<string, unknown> {
const opacity = props.shadowOpacity ?? 0
if (opacity <= 0) return {}
const blur = props.shadowBlur ?? 0
const distance = props.shadowDistance ?? 0
if (blur <= 0 && distance <= 0) return {}
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,
}
}
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,
}
: {}
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: true,
...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
fillStrokeShape: (shape: unknown) => void
save: () => void
restore: () => void
setAttr: (attr: string, value: unknown) => void
}
type KonvaShape = { width(): number; height(): number; radius(): number; attrs: Record<string, unknown> }
function makeRectSceneFunc(blur: number) {
return (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.save()
if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`)
ctx.beginPath()
ctx.rect(0, 0, shape.width(), shape.height())
ctx.closePath()
ctx.fillStrokeShape(shape)
ctx.restore()
}
}
function makeCircleSceneFunc(blur: number) {
return (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.save()
if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`)
ctx.beginPath()
ctx.arc(0, 0, shape.radius(), 0, Math.PI * 2, false)
ctx.closePath()
ctx.fillStrokeShape(shape)
ctx.restore()
}
}
function strokeCfg(
props: AnimatableProps,
asset: { stroke?: string; strokeWidth?: number },
): Record<string, unknown> {
const strokeWidth = props.strokeWidth ?? asset.strokeWidth ?? 0
if (strokeWidth <= 0) return { strokeWidth: 0, stroke: undefined }
const stroke = (props.stroke ?? asset.stroke) || '#000000'
if (stroke === 'transparent') return { strokeWidth: 0, stroke: undefined }
return { stroke, strokeWidth, strokeScaleEnabled: false }
}
function rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props: AnimatableProps) {
const fill = props.fill ?? asset.fill
const blur = props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
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 cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
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 cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
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 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 cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
points: flat,
closed,
lineJoin: 'round',
lineCap: 'round',
}
if (closed && fill && fill !== 'transparent') cfg.fill = fill
if (!layer.isMask && stroke && strokeWidth > 0 && stroke !== 'transparent') {
cfg.stroke = stroke
cfg.strokeWidth = strokeWidth
cfg.strokeScaleEnabled = false
} else {
cfg.stroke = undefined
cfg.strokeWidth = 0
}
// Widen hit area on edges so double-click to insert a vertex is easy
cfg.hitStrokeWidth = Math.max(strokeWidth, 14 / zoom.value)
if (blur > 0) {
cfg.sceneFunc = (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.save()
ctx.setAttr('filter', `blur(${blur}px)`)
ctx.beginPath()
if (pts.length > 0) {
ctx.moveTo(pts[0].x - offX, pts[0].y - offY)
for (let j = 1; j < pts.length; j++) ctx.lineTo(pts[j].x - offX, pts[j].y - offY)
}
if (closed) ctx.closePath()
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
return {
...baseNodeConfig(layer, props),
image: img,
width: asset.width,
height: asset.height,
offsetX: layer.offsetX ?? asset.width / 2,
offsetY: layer.offsetY ?? asset.height / 2,
}
}
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,
listening: false,
...shadowCfg(props),
}
}
function clipRectConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'rect' }>
const fill = item.props.fill ?? asset.fill
const blur = item.props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props),
width: asset.width,
height: asset.height,
offsetX: item.layer.offsetX ?? asset.width / 2,
offsetY: item.layer.offsetY ?? asset.height / 2,
...strokeCfg(item.props, asset),
}
if (fill && fill !== 'transparent') cfg.fill = fill
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
return cfg
}
function clipCircleConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'circle' }>
const fill = item.props.fill ?? asset.fill
const blur = item.props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props),
radius: asset.radius,
offsetX: item.layer.offsetX ?? 0,
offsetY: item.layer.offsetY ?? 0,
...strokeCfg(item.props, asset),
}
if (fill && fill !== 'transparent') cfg.fill = fill
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
return cfg
}
function clipTextConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'text' }>
const fill = item.props.fill ?? asset.fill
const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props),
text: asset.text,
fontSize: asset.fontSize,
offsetX: item.layer.offsetX ?? 0,
offsetY: item.layer.offsetY ?? 0,
...strokeCfg(item.props, asset),
}
if (fill && fill !== 'transparent') cfg.fill = fill
return cfg
}
function clipPolygonConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'polygon' }>
const offX = item.layer.offsetX ?? 0
const offY = item.layer.offsetY ?? 0
const pts = item.props.points ?? asset.points
const closed = asset.closed !== false
const stroke = item.props.stroke ?? asset.stroke
const strokeWidth = item.props.strokeWidth ?? asset.strokeWidth ?? 0
const fill = item.props.fill ?? asset.fill
const flat: number[] = []
for (const p of pts) {
flat.push(p.x - offX, p.y - offY)
}
const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props),
points: flat,
closed,
lineJoin: 'round',
lineCap: 'round',
}
if (closed && fill && fill !== 'transparent') cfg.fill = fill
if (stroke && strokeWidth > 0) {
cfg.stroke = stroke
cfg.strokeWidth = strokeWidth
cfg.strokeScaleEnabled = false
}
return cfg
}
function clipImageConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'image' }>
const img = imageCache.value[asset.id]
if (!img) return null
return {
...clipBaseConfig(item.instanceId, item.layer, item.props),
image: img,
width: asset.width,
height: asset.height,
offsetX: item.layer.offsetX ?? asset.width / 2,
offsetY: item.layer.offsetY ?? asset.height / 2,
}
}
interface PivotInfo {
layerId: string
sampled: AnimatableProps
}
interface LiveLayerPose {
layerId: string
x: number
y: number
scaleX: number
scaleY: number
rotation: number
}
const liveLayerPose = ref<LiveLayerPose | null>(null)
const pivotInfo = computed<PivotInfo | null>(() => {
const id = store.selectedLayerId
if (!id) return null
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return null
const asset = assetMap.value.get(layer.assetId)
if (!asset) return null
const sampled = sampleLayer(layer, store.playhead)
const live = liveLayerPose.value
if (live && live.layerId === id) {
return {
layerId: layer.id,
sampled: {
...sampled,
x: live.x,
y: live.y,
scaleX: live.scaleX,
scaleY: live.scaleY,
rotation: live.rotation,
},
}
}
return { layerId: layer.id, sampled }
})
const pivotHandleConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
return {
x: info.sampled.x,
y: info.sampled.y,
draggable: true,
name: 'pivot-handle',
}
})
const pivotRingConfig = computed(() => ({
radius: 8 / zoom.value,
stroke: '#6ea8ff',
strokeWidth: 1.5 / zoom.value,
fill: '#ffffff',
}))
const pivotDotConfig = computed(() => ({
radius: 2.5 / zoom.value,
fill: '#6ea8ff',
listening: false,
}))
interface VertexHandle {
layerId: string
index: number
x: number
y: number
}
const polygonVertexHandles = computed<VertexHandle[]>(() => {
const id = store.selectedLayerId
if (!id) return []
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer || layer.locked) return []
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return []
const sampled = sampleLayer(layer, store.playhead)
const live = liveLayerPose.value
const cx = live && live.layerId === id ? live.x : sampled.x
const cy = live && live.layerId === id ? live.y : sampled.y
const rot = live && live.layerId === id ? live.rotation : sampled.rotation
const scX = live && live.layerId === id ? live.scaleX : sampled.scaleX
const scY = live && live.layerId === id ? live.scaleY : sampled.scaleY
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
const pts = sampled.points ?? asset.points
const theta = (rot * Math.PI) / 180
const c = Math.cos(theta)
const s = Math.sin(theta)
return pts.map((p, index) => {
const lx = (p.x - offX) * scX
const ly = (p.y - offY) * scY
return {
layerId: id,
index,
x: cx + lx * c - ly * s,
y: cy + lx * s + ly * c,
}
})
})
function vertexHandleConfig(h: VertexHandle) {
return {
x: h.x,
y: h.y,
radius: 5 / zoom.value,
stroke: '#6ea8ff',
strokeWidth: 1.5 / zoom.value,
fill: '#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),
}
}
function onVertexDragMove(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
const node = e.target
const local = worldToPolygonLocal(layer, node.x(), node.y())
store.movePolygonVertex(id, index, local.x, local.y)
}
function onVertexDragEnd(e: Konva.KonvaEventObject<DragEvent>, index: number) {
onVertexDragMove(e, index)
}
const draggingPivot = ref(false)
function onPivotDragStart() {
draggingPivot.value = true
}
function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
draggingPivot.value = false
const info = pivotInfo.value
if (!info) return
const node = e.target
const dx = node.x() - info.sampled.x
const dy = node.y() - info.sampled.y
if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) return
const theta = (info.sampled.rotation * Math.PI) / 180
const c = Math.cos(theta)
const s = Math.sin(theta)
const unrotDx = dx * c + dy * s
const unrotDy = -dx * s + dy * c
const invSX = info.sampled.scaleX !== 0 ? 1 / info.sampled.scaleX : 1
const invSY = info.sampled.scaleY !== 0 ? 1 / info.sampled.scaleY : 1
const localDx = unrotDx * invSX
const localDy = unrotDy * invSY
store.shiftLayerPivot(info.layerId, dx, dy, localDx, localDy)
}
const transformerConfig = {
rotateEnabled: false,
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
const id = store.selectedLayerId
if (!id) {
tr.nodes([])
tr.getLayer()?.batchDraw()
return
}
// Polygons are edited via vertex handles — no bulk transformer
const layer = store.activeLayers.find((l) => l.id === id)
if (layer) {
const asset = assetMap.value.get(layer.assetId)
if (asset?.kind === 'polygon') {
tr.nodes([])
tr.getLayer()?.batchDraw()
return
}
}
const node = stage.findOne<Konva.Node>(`#${id}`)
if (!node && retriesLeft > 0) {
// The Konva node for a freshly-rendered layer may not be attached yet.
// Retry on the next animation frame a few times before giving up.
attachRetryTimer = setTimeout(() => attachTransformer(retriesLeft - 1), 16)
return
}
tr.nodes(node ? [node] : [])
tr.getLayer()?.batchDraw()
}
watch(
() => [store.selectedLayerId, stackItems.value, store.playhead] as const,
async () => {
await nextTick()
attachTransformer()
},
{ immediate: true, deep: true },
)
function updateLivePose(node: Konva.Node, layerId: string) {
liveLayerPose.value = {
layerId,
x: node.x(),
y: node.y(),
scaleX: node.scaleX(),
scaleY: node.scaleY(),
rotation: node.rotation(),
}
}
function handleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const node = e.target
const id = node.id()
if (!store.activeLayers.some((l) => l.id === id)) return
updateLivePose(node, id)
}
function handleTransform(e: Konva.KonvaEventObject<Event>) {
const node = e.target
const id = node.id()
if (!store.activeLayers.some((l) => l.id === id)) return
updateLivePose(node, id)
}
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>) {
if (store.drawingMode === 'freeform') {
const native = e.evt as MouseEvent
// Middle-click or Ctrl+click belong to the pan gesture, not drawing
if (native.button === 1 || native.ctrlKey) return
const wp = pointerToWorld(e)
if (!wp) return
const layerId = store.drawingLayerId
const layer = layerId ? store.activeLayers.find((l) => l.id === layerId) : null
const local = layer ? worldToPolygonLocal(layer, wp.x, wp.y) : wp
// Close-on-click near first point (compare in asset-local space)
const assetId = store.drawingAssetId
const asset = assetId ? store.scene.assets.find((a) => a.id === assetId) : null
if (asset && asset.kind === 'polygon' && asset.points.length >= 3) {
const sampled = layer ? sampleLayer(layer, store.playhead) : null
const scale = sampled ? Math.max(Math.abs(sampled.scaleX), Math.abs(sampled.scaleY), 0.01) : 1
const first = asset.points[0]
const threshold = 10 / zoom.value / scale
if (Math.hypot(local.x - first.x, local.y - first.y) <= threshold) {
store.setPolygonClosed(asset.id, true)
store.finishDrawing()
return
}
}
store.addDrawingPoint(local.x, local.y)
return
}
const target = e.target
const tr = transformerRef.value?.getNode()
if (tr) {
let p: Konva.Node | null = target
while (p) {
if (p === tr) return
p = p.getParent() ?? null
}
}
if (target.findAncestor?.('.pivot-handle')) return
if (target.findAncestor?.('.rotation-handle')) return
if (target.name?.() === 'vertex-handle' || target.findAncestor?.('.vertex-handle')) return
if (target === target.getStage()) {
store.selectLayer(null)
return
}
const id = target.id?.()
if (!id) {
store.selectLayer(null)
return
}
const match = store.activeLayers.find((l) => l.id === id)
store.selectLayer(match ? match.id : null)
}
function handleStageMouseMove(e: Konva.KonvaEventObject<MouseEvent>) {
if (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 onContainerDblClick(e: MouseEvent) {
const rect = container.value?.getBoundingClientRect()
if (!rect) return
const wx = (e.clientX - rect.left - viewX.value) / zoom.value
const wy = (e.clientY - rect.top - viewY.value) / zoom.value
// While drawing: finish (optionally close if near first point)
if (store.drawingMode === 'freeform') {
const assetId = store.drawingAssetId
const asset = assetId ? store.scene.assets.find((a) => a.id === assetId) : null
if (asset && asset.kind === 'polygon' && asset.points.length >= 3) {
const first = asset.points[0]
const threshold = 12 / zoom.value
if (Math.hypot(wx - first.x, wy - first.y) <= threshold) {
store.setPolygonClosed(asset.id, true)
}
}
store.finishDrawing()
return
}
// Not drawing: polygon-specific gestures on selected polygon
const selId = store.selectedLayerId
if (!selId) return
const layer = store.activeLayers.find((l) => l.id === selId)
if (!layer || layer.locked) return
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return
const sampled = sampleLayer(layer, store.playhead)
const pts = sampled.points ?? asset.points
if (pts.length < 1) return
const closed = asset.closed !== false
const local = worldToPolygonLocal(layer, wx, wy)
// Near an endpoint of an open polygon → resume drawing
if (!closed) {
const first = pts[0]
const last = pts[pts.length - 1]
const threshold = 14 / zoom.value
const nearFirst = Math.hypot(local.x - first.x, local.y - first.y) <= threshold
const nearLast =
pts.length > 1 && Math.hypot(local.x - last.x, local.y - last.y) <= threshold
if (nearLast || nearFirst) {
if (nearFirst && !nearLast) store.reversePolygonPoints(asset.id)
store.startFreeformDrawing()
return
}
}
// Click on (or near) an edge → insert a new vertex there
const edges = closed ? pts.length : pts.length - 1
if (edges < 1) return
let bestIdx = 1
let bestDist = Infinity
let bestT = 0
for (let i = 0; i < edges; i++) {
const a = pts[i]
const b = pts[(i + 1) % pts.length]
const dx = b.x - a.x
const dy = b.y - a.y
const lenSq = dx * dx + dy * dy
if (lenSq === 0) continue
let t = ((local.x - a.x) * dx + (local.y - a.y) * dy) / lenSq
t = Math.max(0, Math.min(1, t))
const px = a.x + t * dx
const py = a.y + t * dy
const d = Math.hypot(local.x - px, local.y - py)
if (d < bestDist) {
bestDist = d
bestIdx = i + 1
bestT = t
}
}
const edgeThreshold = 14 / zoom.value
if (bestDist <= edgeThreshold) {
store.insertPolygonPointOnEdge(asset.id, bestIdx, bestT)
}
}
function handleDrawingKey(e: KeyboardEvent) {
if (store.drawingMode !== 'freeform') return
if (e.key === 'Escape') {
e.preventDefault()
store.cancelDrawing()
} else if (e.key === 'Enter') {
e.preventDefault()
store.finishDrawing()
}
}
function transformLocalToWorld(
layer: Layer,
sampled: AnimatableProps,
local: { x: number; y: number },
): { x: number; y: number } {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
const lx = (local.x - offX) * sampled.scaleX
const ly = (local.y - offY) * sampled.scaleY
const theta = (sampled.rotation * Math.PI) / 180
const c = Math.cos(theta)
const s = Math.sin(theta)
return {
x: sampled.x + lx * c - ly * s,
y: sampled.y + lx * s + ly * c,
}
}
const drawingPreviewConfig = computed(() => {
if (store.drawingMode !== 'freeform') return null
const layerId = store.drawingLayerId
if (!layerId) return null
const layer = store.activeLayers.find((l) => l.id === layerId)
if (!layer) return null
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return null
const sampled = sampleLayer(layer, store.playhead)
const pts = sampled.points ?? asset.points
const preview = drawingPreview.value
const flat: number[] = []
for (const p of pts) {
const w = transformLocalToWorld(layer, sampled, p)
flat.push(w.x, w.y)
}
if (preview && pts.length > 0) {
const w = transformLocalToWorld(layer, sampled, preview)
flat.push(w.x, w.y)
}
if (flat.length < 4) return null
return {
points: flat,
stroke: '#6ea8ff',
strokeWidth: 1.5 / zoom.value,
dash: [6 / zoom.value, 4 / zoom.value],
strokeScaleEnabled: false,
listening: false,
}
})
const drawingDotsConfig = computed(() => {
if (store.drawingMode !== 'freeform') return []
const layerId = store.drawingLayerId
if (!layerId) return []
const layer = store.activeLayers.find((l) => l.id === layerId)
if (!layer) return []
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return []
const sampled = sampleLayer(layer, store.playhead)
const pts = sampled.points ?? asset.points
return pts.map((p, i) => {
const w = transformLocalToWorld(layer, sampled, p)
return {
key: `dp-${i}`,
x: w.x,
y: w.y,
radius: 3 / zoom.value,
fill: '#6ea8ff',
listening: false,
}
})
})
function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
const node = e.target
const id = node.id()
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return
const newX = node.x()
const newY = node.y()
if (selectedKfs.value.length > 0) {
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 })
}
liveLayerPose.value = null
}
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
const node = e.target
const id = node.id()
if (!store.activeLayers.some((l) => l.id === id)) return
store.upsertKeyframe(id, store.playhead, {
x: node.x(),
y: node.y(),
scaleX: node.scaleX(),
scaleY: node.scaleY(),
rotation: node.rotation(),
})
liveLayerPose.value = null
}
const ROT_ARM_PX = 44
const rotationHandlePos = ref<{ x: number; y: number } | null>(null)
const rotationHandleConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
if (draggingPivot.value) return null
if (rotationHandlePos.value) {
return {
x: rotationHandlePos.value.x,
y: rotationHandlePos.value.y,
draggable: true,
name: 'rotation-handle',
}
}
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
return {
x: info.sampled.x + arm * Math.sin(rad),
y: info.sampled.y - arm * Math.cos(rad),
draggable: true,
name: 'rotation-handle',
}
})
const rotationArmConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
if (draggingPivot.value) return null
let endX: number
let endY: number
if (rotationHandlePos.value) {
endX = rotationHandlePos.value.x
endY = rotationHandlePos.value.y
} else {
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
endX = info.sampled.x + arm * Math.sin(rad)
endY = info.sampled.y - arm * Math.cos(rad)
}
return {
points: [info.sampled.x, info.sampled.y, endX, endY],
stroke: '#6ea8ff',
strokeWidth: 1.25 / zoom.value,
dash: [3 / zoom.value, 3 / zoom.value],
listening: false,
}
})
const rotationKnobConfig = computed(() => ({
radius: 6 / zoom.value,
fill: '#ffffff',
stroke: '#6ea8ff',
strokeWidth: 2 / zoom.value,
}))
function onRotationHandleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const info = pivotInfo.value
if (!info) return
const node = e.target
const dx = node.x() - info.sampled.x
const dy = node.y() - info.sampled.y
if (dx === 0 && dy === 0) return
const raw = (Math.atan2(dx, -dy) * 180) / Math.PI
// Snap to the 360°-equivalent nearest to the previous rotation so small
// motions don't flip a full turn across the ±180° boundary.
const prev = liveLayerPose.value?.rotation ?? info.sampled.rotation
let deg = raw
while (deg - prev > 180) deg -= 360
while (deg - prev < -180) deg += 360
const base = liveLayerPose.value ?? {
layerId: info.layerId,
x: info.sampled.x,
y: info.sampled.y,
scaleX: info.sampled.scaleX,
scaleY: info.sampled.scaleY,
rotation: info.sampled.rotation,
}
liveLayerPose.value = { ...base, layerId: info.layerId, rotation: deg }
rotationHandlePos.value = { x: node.x(), y: node.y() }
}
function onRotationHandleDragEnd() {
const info = pivotInfo.value
const live = liveLayerPose.value
if (info && live && live.layerId === info.layerId) {
store.upsertKeyframe(info.layerId, store.playhead, { rotation: live.rotation })
}
rotationHandlePos.value = null
liveLayerPose.value = null
}
let ro: ResizeObserver | null = null
function onWheel(e: WheelEvent) {
e.preventDefault()
const rect = container.value?.getBoundingClientRect()
if (!rect) return
const sx = e.clientX - rect.left
const sy = e.clientY - rect.top
const logX = (sx - viewX.value) / zoom.value
const logY = (sy - viewY.value) / zoom.value
const factor = Math.pow(1.0015, -e.deltaY)
const newZoom = clamp(zoom.value * factor, ZOOM_MIN, ZOOM_MAX)
zoom.value = newZoom
viewX.value = sx - logX * newZoom
viewY.value = sy - logY * newZoom
}
let panning = false
let panStartX = 0
let panStartY = 0
let panStartViewX = 0
let panStartViewY = 0
let panPointerId = -1
function isPanTrigger(e: PointerEvent | MouseEvent) {
if (e.button === 1) return true
if (e.button === 0 && e.ctrlKey) 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
if (!e.dataTransfer.types.includes('application/x-animatr-asset')) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}
function onContainerDrop(e: DragEvent) {
const assetId = e.dataTransfer?.getData('application/x-animatr-asset')
if (!assetId) return
e.preventDefault()
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 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)
drawSceneFrame(ctx, store.scene, store.playhead, previewImages.value)
ctx.restore()
}
watch(
[
() => store.playhead,
() => store.scene,
() => containerSize.value,
zoom,
viewX,
viewY,
],
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()
})
function zoomBy(factor: number) {
const cw = containerSize.value.width
const ch = containerSize.value.height
if (!cw || !ch) return
const sx = cw / 2
const sy = ch / 2
const logX = (sx - viewX.value) / zoom.value
const logY = (sy - viewY.value) / zoom.value
const newZoom = clamp(zoom.value * factor, ZOOM_MIN, ZOOM_MAX)
zoom.value = newZoom
viewX.value = sx - logX * newZoom
viewY.value = sy - logY * newZoom
}
onMounted(() => {
if (!container.value) return
ro = new ResizeObserver((entries) => {
const rect = entries[0].contentRect
containerSize.value = { width: rect.width, height: rect.height }
if (!didInitialFit && rect.width > 0 && rect.height > 0) {
fitToContainer()
didInitialFit = true
}
})
ro.observe(container.value)
container.value.addEventListener('wheel', onWheel, { passive: false })
container.value.addEventListener('mousedown', onContainerMouseDownCapture, { capture: true })
container.value.addEventListener('pointerdown', onContainerPointerDown)
container.value.addEventListener('pointermove', onContainerPointerMove)
container.value.addEventListener('pointerup', onContainerPointerUp)
container.value.addEventListener('pointercancel', onContainerPointerUp)
container.value.addEventListener('dragover', onContainerDragOver)
container.value.addEventListener('drop', onContainerDrop)
container.value.addEventListener('dblclick', onContainerDblClick)
window.addEventListener('keydown', handleDrawingKey)
})
onBeforeUnmount(() => {
ro?.disconnect()
ro = null
container.value?.removeEventListener('wheel', onWheel)
container.value?.removeEventListener('mousedown', onContainerMouseDownCapture, { capture: true } as EventListenerOptions)
container.value?.removeEventListener('pointerdown', onContainerPointerDown)
container.value?.removeEventListener('pointermove', onContainerPointerMove)
container.value?.removeEventListener('pointerup', onContainerPointerUp)
container.value?.removeEventListener('pointercancel', onContainerPointerUp)
container.value?.removeEventListener('dragover', onContainerDragOver)
container.value?.removeEventListener('drop', onContainerDrop)
container.value?.removeEventListener('dblclick', onContainerDblClick)
window.removeEventListener('keydown', handleDrawingKey)
})
</script>
<template>
<div ref="container" class="stage-container" :class="{ drawing: store.drawingMode === 'freeform' }">
<v-stage
ref="stageRef"
:config="stageConfig"
@mousedown="handleStageMouseDown"
@mousemove="handleStageMouseMove"
>
<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.item.instanceId}:${entry.item.layer.id}:${i}`
"
>
<template v-if="entry.kind === 'layer'">
<v-rect
v-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-rect
v-if="entry.item.asset.kind === 'rect'"
:config="clipRectConfig(entry.item)"
/>
<v-circle
v-else-if="entry.item.asset.kind === 'circle'"
:config="clipCircleConfig(entry.item)"
/>
<v-text
v-else-if="entry.item.asset.kind === 'text'"
:config="clipTextConfig(entry.item)"
/>
<v-line
v-else-if="entry.item.asset.kind === 'polygon'"
:config="clipPolygonConfig(entry.item)"
/>
<v-image
v-else-if="entry.item.asset.kind === 'image' && clipImageConfig(entry.item)"
:config="clipImageConfig(entry.item)!"
/>
</template>
</template>
</v-group>
<v-rect :config="canvasBorderConfig" />
<v-line v-if="rotationArmConfig" :config="rotationArmConfig" />
<v-group
v-if="pivotHandleConfig"
:config="pivotHandleConfig"
@dragstart="onPivotDragStart"
@dragend="onPivotDragEnd"
>
<v-circle :config="pivotRingConfig" />
<v-circle :config="pivotDotConfig" />
</v-group>
<v-group
v-if="rotationHandleConfig"
:config="rotationHandleConfig"
@dragmove="onRotationHandleDragMove"
@dragend="onRotationHandleDragEnd"
>
<v-circle :config="rotationKnobConfig" />
</v-group>
<v-circle
v-for="h in polygonVertexHandles"
:key="`vh-${h.layerId}-${h.index}`"
:config="vertexHandleConfig(h)"
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragMove(e, h.index)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragEnd(e, h.index)"
/>
<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 class="zoom-controls" @pointerdown.stop>
<button class="zc-btn" :title="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
<button class="zc-label" :title="t('canvas.fitToView')" @click="fitToContainer">
{{ zoomPercent }}%
</button>
<button class="zc-btn" :title="t('canvas.zoomIn')" @click="zoomBy(1.25)">+</button>
<div class="zc-sep"></div>
<button class="zc-btn wide" :title="t('canvas.zoomOneToOne')" @click="zoomToOne">1:1</button>
<button class="zc-btn" :title="t('canvas.fitToView')" @click="fitToContainer">
<PhCornersIn :size="13" />
</button>
<div class="zc-sep"></div>
<button
class="zc-btn crop-btn"
:class="{ active: cropObjects }"
:title="cropObjects ? t('canvas.cropOff') : t('canvas.cropOn')"
@click="cropObjects = !cropObjects"
>
<PhCrop :size="13" />
</button>
<button
class="zc-btn"
:class="{ active: maskPreview }"
:title="maskPreview ? t('canvas.maskPreviewOff') : t('canvas.maskPreviewOn')"
@click="maskPreview = !maskPreview"
>
<PhMaskHappy v-if="!maskPreview" :size="13" />
<PhPencilSimple v-else :size="13" />
</button>
</div>
<div class="dims">{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}</div>
</div>
</template>
<style scoped>
.stage-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.stage-container.drawing {
cursor: crosshair;
}
.mask-preview-canvas {
position: absolute;
inset: 0;
pointer-events: none;
background: var(--bg);
}
.dims {
position: absolute;
bottom: 10px;
right: 12px;
color: var(--text-dim);
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.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>