diff --git a/package.json b/package.json index 6ee23a3..579adcc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "animatr", "private": true, - "version": "0.8.5", + "version": "0.8.6", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index 3cb9760..fbb6dcd 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -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(() => { 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, + instanceId: string, +) { + const node = e.target + store.setClipInstanceTransform(instanceId, { + x: node.x(), + y: node.y(), + }) +} + +function onClipInstanceTransformEnd( + e: Konva.KonvaEventObject, + 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) { } 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(`#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}` " >