bak
This commit is contained in:
1 parent
6b6157d786
commit
3ef5fb5a2d
19 files changed
+1244
-94
No files matched your search
+231
-29
@@ -7,8 +7,10 @@ import { sampleLayer } from '@/lib/interpolate'
|
||||
import { loadImage } from '@/lib/fileLoader'
|
||||
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
|
||||
import { t } from '@/i18n'
|
||||
import { useKfSelection } from '@/composables/useKfSelection'
|
||||
|
||||
const store = useSceneStore()
|
||||
const { selectedKfs } = useKfSelection()
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null)
|
||||
const containerSize = ref({ width: 0, height: 0 })
|
||||
@@ -135,6 +137,19 @@ 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) {
|
||||
@@ -145,7 +160,11 @@ const stackItems = computed<StackItem[]>(() => {
|
||||
if (!asset) continue
|
||||
out.push({
|
||||
kind: 'layer',
|
||||
item: { layer, asset, props: sampleLayer(layer, store.playhead) },
|
||||
item: {
|
||||
layer,
|
||||
asset,
|
||||
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
@@ -173,7 +192,11 @@ const stackItems = computed<StackItem[]>(() => {
|
||||
if (!asset) continue
|
||||
out.push({
|
||||
kind: 'layer',
|
||||
item: { layer, asset, props: sampleLayer(layer, store.playhead) },
|
||||
item: {
|
||||
layer,
|
||||
asset,
|
||||
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -208,6 +231,23 @@ const stackItems = computed<StackItem[]>(() => {
|
||||
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) {
|
||||
return {
|
||||
id: layer.id,
|
||||
@@ -219,18 +259,60 @@ function baseNodeConfig(layer: Layer, props: AnimatableProps) {
|
||||
opacity: props.opacity,
|
||||
visible: layer.visible,
|
||||
draggable: true,
|
||||
...shadowCfg(props),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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 rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props: AnimatableProps) {
|
||||
return {
|
||||
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,
|
||||
fill: props.fill ?? asset.fill,
|
||||
fill,
|
||||
}
|
||||
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
|
||||
return cfg
|
||||
}
|
||||
|
||||
function circleConfig(
|
||||
@@ -238,13 +320,17 @@ function circleConfig(
|
||||
asset: Extract<Asset, { kind: 'circle' }>,
|
||||
props: AnimatableProps,
|
||||
) {
|
||||
return {
|
||||
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,
|
||||
fill: props.fill ?? asset.fill,
|
||||
fill,
|
||||
}
|
||||
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
|
||||
return cfg
|
||||
}
|
||||
|
||||
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
|
||||
@@ -287,30 +373,39 @@ function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps
|
||||
visible: layer.visible,
|
||||
draggable: false,
|
||||
listening: false,
|
||||
...shadowCfg(props),
|
||||
}
|
||||
}
|
||||
|
||||
function clipRectConfig(item: RenderedClipLayer) {
|
||||
const asset = item.asset as Extract<Asset, { kind: 'rect' }>
|
||||
return {
|
||||
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,
|
||||
fill: item.props.fill ?? asset.fill,
|
||||
fill,
|
||||
}
|
||||
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
|
||||
return cfg
|
||||
}
|
||||
|
||||
function clipCircleConfig(item: RenderedClipLayer) {
|
||||
const asset = item.asset as Extract<Asset, { kind: 'circle' }>
|
||||
return {
|
||||
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,
|
||||
fill: item.props.fill ?? asset.fill,
|
||||
fill,
|
||||
}
|
||||
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
|
||||
return cfg
|
||||
}
|
||||
|
||||
function clipTextConfig(item: RenderedClipLayer) {
|
||||
@@ -344,6 +439,17 @@ interface PivotInfo {
|
||||
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
|
||||
@@ -351,7 +457,22 @@ const pivotInfo = computed<PivotInfo | null>(() => {
|
||||
if (!layer) return null
|
||||
const asset = assetMap.value.get(layer.assetId)
|
||||
if (!asset) return null
|
||||
return { layerId: layer.id, sampled: sampleLayer(layer, store.playhead) }
|
||||
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(() => {
|
||||
@@ -441,6 +562,31 @@ watch(
|
||||
{ immediate: 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)
|
||||
}
|
||||
|
||||
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||
const target = e.target
|
||||
const tr = transformerRef.value?.getNode()
|
||||
@@ -469,11 +615,21 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||
function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
||||
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(),
|
||||
})
|
||||
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>) {
|
||||
@@ -487,13 +643,24 @@ function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
|
||||
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 (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 {
|
||||
@@ -507,15 +674,19 @@ const rotationHandleConfig = computed(() => {
|
||||
const rotationArmConfig = computed(() => {
|
||||
const info = pivotInfo.value
|
||||
if (!info) return null
|
||||
const arm = ROT_ARM_PX / zoom.value
|
||||
const rad = (info.sampled.rotation * Math.PI) / 180
|
||||
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,
|
||||
info.sampled.x + arm * Math.sin(rad),
|
||||
info.sampled.y - arm * Math.cos(rad),
|
||||
],
|
||||
points: [info.sampled.x, info.sampled.y, endX, endY],
|
||||
stroke: '#6ea8ff',
|
||||
strokeWidth: 1.25 / zoom.value,
|
||||
dash: [3 / zoom.value, 3 / zoom.value],
|
||||
@@ -537,11 +708,33 @@ function onRotationHandleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
|
||||
const dx = node.x() - info.sampled.x
|
||||
const dy = node.y() - info.sampled.y
|
||||
if (dx === 0 && dy === 0) return
|
||||
const rad = Math.atan2(dx, -dy)
|
||||
let deg = (rad * 180) / Math.PI
|
||||
if (deg > 180) deg -= 360
|
||||
if (deg < -180) deg += 360
|
||||
store.upsertKeyframe(info.layerId, store.playhead, { rotation: deg })
|
||||
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
|
||||
@@ -699,25 +892,33 @@ onBeforeUnmount(() => {
|
||||
<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-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>
|
||||
@@ -755,6 +956,7 @@ onBeforeUnmount(() => {
|
||||
v-if="rotationHandleConfig"
|
||||
:config="rotationHandleConfig"
|
||||
@dragmove="onRotationHandleDragMove"
|
||||
@dragend="onRotationHandleDragEnd"
|
||||
>
|
||||
<v-circle :config="rotationKnobConfig" />
|
||||
</v-group>
|
||||
|
||||
Reference in new issue
Block a user