0.8.6: clip instances get stage transforms (x/y/scale/rotation)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-22 06:13:09 +02:00
1 parent 53c0410824
commit d60684edd1
7 files changed
+512 -41

No files matched your search

+127 -34
View File
@@ -23,6 +23,7 @@ import type {
Asset,
ButtonAsset,
ButtonState,
ClipInstance,
HitShape,
Keyframe,
Layer,
@@ -728,9 +729,14 @@ interface RenderedClipLayer extends RenderedLayer {
instanceId: string
}
interface RenderedClipInstance {
instance: ClipInstance
items: RenderedClipLayer[]
}
type StackItem =
| { kind: 'layer'; item: RenderedLayer }
| { kind: 'clip'; item: RenderedClipLayer }
| { kind: 'clipInstance'; bundle: RenderedClipInstance }
function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps {
const live = liveLayerPose.value
@@ -833,23 +839,26 @@ const stackItems = computed<StackItem[]>(() => {
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
// Bundle the clip's layers under a single clip-instance entry
// so the template can wrap them in a draggable Konva group that
// carries the instance's x / y / scale transform.
const items: RenderedClipLayer[] = []
for (let k = clip.layers.length - 1; k >= 0; k--) {
const cl = clip.layers[k]
if (!cl.visible) continue
if (!isLayerActiveAt(cl, localTime)) 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),
},
items.push({
instanceId: inst.id,
layer: cl,
asset: ca,
props: sampleLayer(cl, localTime),
})
}
if (items.length) {
out.push({ kind: 'clipInstance', bundle: { instance: inst, items } })
}
}
}
return out
@@ -1224,6 +1233,50 @@ function imageConfig(
}
}
/** Per-clip-instance wrapper group. Carries the instance's static
* x / y / scale so the user can reposition the packed clip on the
* stage without touching its inner keyframes. `rotation` is
* deliberately absent — the user asked for move + scale only.
* The inner child configs keep `listening: false`, so pointer
* events funnel to the outer group and drag/transform apply as a
* unit. */
function clipInstanceGroupConfig(inst: ClipInstance) {
return {
id: `ci-group:${inst.id}`,
x: inst.x ?? 0,
y: inst.y ?? 0,
scaleX: inst.scaleX ?? 1,
scaleY: inst.scaleY ?? 1,
rotation: inst.rotation ?? 0,
draggable: true,
}
}
function onClipInstanceDragEnd(
e: Konva.KonvaEventObject<DragEvent>,
instanceId: string,
) {
const node = e.target
store.setClipInstanceTransform(instanceId, {
x: node.x(),
y: node.y(),
})
}
function onClipInstanceTransformEnd(
e: Konva.KonvaEventObject<Event>,
instanceId: string,
) {
const node = e.target
store.setClipInstanceTransform(instanceId, {
scaleX: node.scaleX(),
scaleY: node.scaleY(),
rotation: node.rotation(),
x: node.x(),
y: node.y(),
})
}
function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps) {
return {
id: `ci:${instanceId}:${layer.id}`,
@@ -1235,7 +1288,11 @@ function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps
opacity: props.opacity,
visible: layer.visible,
draggable: false,
listening: false,
// Keep clip children hit-testable so a click on the clip's
// visible shape bubbles up to the parent group's @mousedown
// selection handler. They don't carry their own handlers, so
// the event bubbles straight up.
listening: true,
...shadowCfg(props),
}
}
@@ -1808,7 +1865,9 @@ function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
}
const transformerConfig = {
rotateEnabled: false,
rotateEnabled: true,
rotationSnaps: [0, 45, 90, 135, 180, -135, -90, -45],
rotationSnapTolerance: 4,
enabledAnchors: [
'top-left',
'top-center',
@@ -1837,6 +1896,22 @@ function attachTransformer(retriesLeft = 3) {
const stage = stageRef.value?.getStage()
const tr = transformerRef.value?.getNode()
if (!stage || !tr) return
// Clip-instance selection takes precedence — the transformer wraps
// the group so its move + scale handles update the instance's
// static x / y / scale.
const clipInstId = store.selectedClipInstanceId
if (clipInstId) {
const groupNode = stage.findOne<Konva.Node>(`#ci-group:${clipInstId}`)
if (!groupNode && retriesLeft > 0) {
attachRetryTimer = setTimeout(() => attachTransformer(retriesLeft - 1), 16)
return
}
tr.nodes(groupNode ? [groupNode] : [])
tr.getLayer()?.batchDraw()
return
}
const id = store.selectedLayerId
if (!id) {
tr.nodes([])
@@ -1865,7 +1940,13 @@ function attachTransformer(retriesLeft = 3) {
}
watch(
() => [store.selectedLayerId, stackItems.value, store.playhead] as const,
() =>
[
store.selectedLayerId,
store.selectedClipInstanceId,
stackItems.value,
store.playhead,
] as const,
async () => {
await nextTick()
attachTransformer()
@@ -2902,7 +2983,7 @@ onBeforeUnmount(() => {
:key="
entry.kind === 'layer'
? `l:${entry.item.layer.id}`
: `ci:${entry.item.instanceId}:${entry.item.layer.id}:${i}`
: `ci:${entry.bundle.instance.id}:${i}`
"
>
<template v-if="entry.kind === 'layer'">
@@ -2989,26 +3070,38 @@ onBeforeUnmount(() => {
/>
</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)!"
/>
<v-group
:config="clipInstanceGroupConfig(entry.bundle.instance)"
@mousedown="store.selectClipInstance(entry.bundle.instance.id)"
@dragend="onClipInstanceDragEnd($event, entry.bundle.instance.id)"
@transformend="onClipInstanceTransformEnd($event, entry.bundle.instance.id)"
>
<template
v-for="sub in entry.bundle.items"
:key="`ci:${entry.bundle.instance.id}:${sub.layer.id}`"
>
<v-rect
v-if="sub.asset.kind === 'rect'"
:config="clipRectConfig(sub)"
/>
<v-circle
v-else-if="sub.asset.kind === 'circle'"
:config="clipCircleConfig(sub)"
/>
<v-text
v-else-if="sub.asset.kind === 'text'"
:config="clipTextConfig(sub)"
/>
<v-line
v-else-if="sub.asset.kind === 'polygon'"
:config="clipPolygonConfig(sub)"
/>
<v-image
v-else-if="sub.asset.kind === 'image' && clipImageConfig(sub)"
:config="clipImageConfig(sub)!"
/>
</template>
</v-group>
</template>
</template>
<template