This commit is contained in:
th committed 2026-04-19 17:53:36 +02:00
commit c6863f2bfc
34 files changed
+6909

No files matched your search

+581
View File
@@ -0,0 +1,581 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type Konva from 'konva'
import { PhCornersIn } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import { loadImage } from '@/lib/fileLoader'
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
const store = useSceneStore()
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)
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 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
}
const rendered = computed<RenderedLayer[]>(() =>
store.scene.layers
.map((layer) => {
const asset = assetMap.value.get(layer.assetId)
if (!asset) return null
return {
layer,
asset,
props: sampleLayer(layer, store.playhead),
}
})
.filter((x): x is RenderedLayer => x !== null),
)
function baseNodeConfig(layer: Layer, props: AnimatableProps) {
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,
}
}
function rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props: AnimatableProps) {
return {
...baseNodeConfig(layer, props),
width: asset.width,
height: asset.height,
offsetX: layer.offsetX ?? asset.width / 2,
offsetY: layer.offsetY ?? asset.height / 2,
fill: props.fill ?? asset.fill,
}
}
function circleConfig(
layer: Layer,
asset: Extract<Asset, { kind: 'circle' }>,
props: AnimatableProps,
) {
return {
...baseNodeConfig(layer, props),
radius: asset.radius,
fill: props.fill ?? asset.fill,
}
}
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
return {
...baseNodeConfig(layer, props),
text: asset.text,
fontSize: asset.fontSize,
fill: props.fill ?? asset.fill,
}
}
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,
}
}
interface PivotInfo {
layerId: string
sampled: AnimatableProps
}
const pivotInfo = computed<PivotInfo | null>(() => {
const id = store.selectedLayerId
if (!id) return null
const layer = store.scene.layers.find((l) => l.id === id)
if (!layer) return null
const asset = assetMap.value.get(layer.assetId)
if (!asset || (asset.kind !== 'rect' && asset.kind !== 'image')) return null
return { layerId: layer.id, sampled: sampleLayer(layer, store.playhead) }
})
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,
}))
function onPivotDragEnd(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 (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) return
const theta = (info.sampled.rotation * Math.PI) / 180
const c = Math.cos(theta)
const s = Math.sin(theta)
const unrotDx = dx * c + dy * s
const unrotDy = -dx * s + dy * c
const invSX = info.sampled.scaleX !== 0 ? 1 / info.sampled.scaleX : 1
const invSY = info.sampled.scaleY !== 0 ? 1 / info.sampled.scaleY : 1
const localDx = unrotDx * invSX
const localDy = unrotDy * invSY
store.shiftLayerPivot(info.layerId, dx, dy, localDx, localDy)
}
const transformerConfig = {
rotateEnabled: true,
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,
}
function attachTransformer() {
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
}
const node = stage.findOne<Konva.Node>(`#${id}`)
tr.nodes(node ? [node] : [])
tr.getLayer()?.batchDraw()
}
watch(
() => [store.selectedLayerId, rendered.value.length, store.playhead] as const,
async () => {
await nextTick()
attachTransformer()
},
{ immediate: true },
)
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
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 === target.getStage()) {
store.selectLayer(null)
return
}
const id = target.id?.()
if (!id) {
store.selectLayer(null)
return
}
const match = store.scene.layers.find((l) => l.id === id)
store.selectLayer(match ? match.id : null)
}
function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
const node = e.target
const id = node.id()
if (!store.scene.layers.some((l) => l.id === id)) return
store.upsertKeyframe(id, store.playhead, {
x: node.x(),
y: node.y(),
})
}
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
const node = e.target
const id = node.id()
if (!store.scene.layers.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(),
})
}
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 onContainerPointerDown(e: PointerEvent) {
if (e.button !== 1) 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 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 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('pointerdown', onContainerPointerDown)
container.value.addEventListener('pointermove', onContainerPointerMove)
container.value.addEventListener('pointerup', onContainerPointerUp)
container.value.addEventListener('pointercancel', onContainerPointerUp)
})
onBeforeUnmount(() => {
ro?.disconnect()
ro = null
container.value?.removeEventListener('wheel', onWheel)
container.value?.removeEventListener('pointerdown', onContainerPointerDown)
container.value?.removeEventListener('pointermove', onContainerPointerMove)
container.value?.removeEventListener('pointerup', onContainerPointerUp)
container.value?.removeEventListener('pointercancel', onContainerPointerUp)
})
</script>
<template>
<div ref="container" class="stage-container">
<v-stage ref="stageRef" :config="stageConfig" @mousedown="handleStageMouseDown">
<v-layer>
<v-group :config="viewConfig">
<v-rect :config="backgroundConfig" />
<template v-for="item in rendered" :key="item.layer.id">
<v-rect
v-if="item.asset.kind === 'rect'"
:config="rectConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-circle
v-else-if="item.asset.kind === 'circle'"
:config="circleConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-text
v-else-if="item.asset.kind === 'text'"
:config="textConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-image
v-else-if="item.asset.kind === 'image' && imageConfig(item.layer, item.asset, item.props)"
:config="imageConfig(item.layer, item.asset, item.props)!"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
</template>
<v-rect :config="canvasBorderConfig" />
<v-group
v-if="pivotHandleConfig"
:config="pivotHandleConfig"
@dragend="onPivotDragEnd"
>
<v-circle :config="pivotRingConfig" />
<v-circle :config="pivotDotConfig" />
</v-group>
</v-group>
<v-transformer ref="transformerRef" :config="transformerConfig" />
</v-layer>
</v-stage>
<div class="zoom-controls" @pointerdown.stop>
<button class="zc-btn" title="Zoom out" @click="zoomBy(1 / 1.25)">−</button>
<button class="zc-label" title="Fit to view" @click="fitToContainer">
{{ zoomPercent }}%
</button>
<button class="zc-btn" title="Zoom in" @click="zoomBy(1.25)">+</button>
<div class="zc-sep"></div>
<button class="zc-btn wide" title="Zoom 1:1" @click="zoomToOne">1:1</button>
<button class="zc-btn" title="An Viewport anpassen" @click="fitToContainer">
<PhCornersIn :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;
}
.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-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>