- 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>
1653 lines
49 KiB
Vue
1653 lines
49 KiB
Vue
<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>
|