init
This commit is contained in:
commit
c6863f2bfc
34 files changed
+6909
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user