diff --git a/package.json b/package.json index d133569..596ed7b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ruwio", "private": true, - "version": "1.1.2", + "version": "1.1.3", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index 2b9e408..5f0db0e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -319,20 +319,31 @@ function nudgeSelection(dx: number, dy: number): boolean { }) return true } - const layerId = store.selectedLayerId - if (!layerId) return false - const layer = store.activeLayers.find((l) => l.id === layerId) - if (!layer) return false - if (layer.locked) return false + // Layer selection: nudge every selected layer. Multi-select + // arrow-nudge mirrors the canvas's multi-drag — the whole group + // travels together. + const layerIds = store.selectedLayerIds.length + ? store.selectedLayerIds + : store.selectedLayerId + ? [store.selectedLayerId] + : [] + if (!layerIds.length) return false + const lockedGroupChildIds = new Set() for (const g of store.scene.groups ?? []) { - if (g.locked && g.childIds.includes(layerId)) return false + if (g.locked) for (const cid of g.childIds) lockedGroupChildIds.add(cid) } - const samp = sampleLayer(layer, store.playhead) - store.upsertKeyframe(layerId, store.playhead, { - x: samp.x + dx, - y: samp.y + dy, - }) - return true + let moved = false + for (const lid of layerIds) { + const layer = store.activeLayers.find((l) => l.id === lid) + if (!layer || layer.locked || lockedGroupChildIds.has(lid)) continue + const samp = sampleLayer(layer, store.playhead) + store.upsertKeyframe(lid, store.playhead, { + x: samp.x + dx, + y: samp.y + dy, + }) + moved = true + } + return moved } function onKeyDown(e: KeyboardEvent) { diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index 3751334..ba5afba 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -816,8 +816,8 @@ type StackItem = | { kind: 'clipInstance'; bundle: RenderedClipInstance } function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps { - const live = liveLayerPose.value - if (!live || live.layerId !== layerId) return props + const live = liveLayerPoses.value[layerId] + if (!live) return props return { ...props, x: live.x, @@ -1544,7 +1544,6 @@ interface PivotInfo { } interface LiveLayerPose { - layerId: string x: number y: number scaleX: number @@ -1552,7 +1551,17 @@ interface LiveLayerPose { rotation: number } -const liveLayerPose = ref(null) +/** Per-layer live pose keyed by layer id. Multiple entries exist + * during a multi-select drag or transform — Konva mutates each + * node in place and fires @transform / @dragmove per node, and + * we record each one so the reactive v-rect / v-line / v-image + * configs don't snap non-focused nodes back to their sampled + * pose while Konva is mid-way through rotating the group. */ +const liveLayerPoses = ref>({}) + +function clearLivePoses() { + liveLayerPoses.value = {} +} const pivotInfo = computed(() => { const id = store.selectedLayerId @@ -1562,8 +1571,8 @@ const pivotInfo = computed(() => { const asset = assetMap.value.get(layer.assetId) if (!asset) return null const sampled = sampleLayer(layer, store.playhead) - const live = liveLayerPose.value - if (live && live.layerId === id) { + const live = liveLayerPoses.value[id] + if (live) { return { layerId: layer.id, sampled: { @@ -1611,6 +1620,10 @@ interface VertexHandle { } const polygonVertexHandles = computed(() => { + // Vertex handles only apply to a SOLO polygon selection — in a + // mixed multi-selection the shared transformer bounding box owns + // scale / rotate, and per-vertex handles would visually fight it. + if (store.selectedLayerIds.length > 1) return [] const id = store.selectedLayerId if (!id) return [] const layer = store.activeLayers.find((l) => l.id === id) @@ -1618,12 +1631,12 @@ const polygonVertexHandles = computed(() => { const asset = assetMap.value.get(layer.assetId) if (!asset || asset.kind !== 'polygon') return [] const sampled = sampleLayer(layer, store.playhead) - const live = liveLayerPose.value - const cx = live && live.layerId === id ? live.x : sampled.x - const cy = live && live.layerId === id ? live.y : sampled.y - const rot = live && live.layerId === id ? live.rotation : sampled.rotation - const scX = live && live.layerId === id ? live.scaleX : sampled.scaleX - const scY = live && live.layerId === id ? live.scaleY : sampled.scaleY + const live = liveLayerPoses.value[id] + const cx = live ? live.x : sampled.x + const cy = live ? live.y : sampled.y + const rot = live ? live.rotation : sampled.rotation + const scX = live ? live.scaleX : sampled.scaleX + const scY = live ? live.scaleY : sampled.scaleY const offX = layer.offsetX ?? 0 const offY = layer.offsetY ?? 0 const pts = sampled.points ?? asset.points @@ -1823,12 +1836,12 @@ const polygonTangentHandles = computed(() => { if (vsel.size !== 1) return [] const onlyIndex = vsel.values().next().value! const sampled = sampleLayer(layer, store.playhead) - const live = liveLayerPose.value - const cx = live && live.layerId === id ? live.x : sampled.x - const cy = live && live.layerId === id ? live.y : sampled.y - const rot = live && live.layerId === id ? live.rotation : sampled.rotation - const scX = live && live.layerId === id ? live.scaleX : sampled.scaleX - const scY = live && live.layerId === id ? live.scaleY : sampled.scaleY + const live = liveLayerPoses.value[id] + const cx = live ? live.x : sampled.x + const cy = live ? live.y : sampled.y + const rot = live ? live.rotation : sampled.rotation + const scX = live ? live.scaleX : sampled.scaleX + const scY = live ? live.scaleY : sampled.scaleY const offX = layer.offsetX ?? 0 const offY = layer.offsetY ?? 0 const pts = sampled.points ?? asset.points @@ -1943,6 +1956,14 @@ const boxSelect = ref<{ startY: number curX: number curY: number + /** 'vertex' reuses the existing polygon-vertex lasso; 'layer' + * intersects every unlocked layer's bbox with the drag rect to + * build a scene-level multi-selection. */ + mode: 'vertex' | 'layer' + /** Shift-held at drag start → add the marquee's hits to the + * existing selection rather than replacing it. A tiny shift + * click also leaves the current selection untouched. */ + additive: boolean } | null>(null) const boxSelectRectPx = computed(() => { @@ -1969,23 +1990,100 @@ function finishBoxSelect() { const b = boxSelect.value boxSelect.value = null if (!b) return - const sel = selectedPolygonLayer.value - if (!sel) return - const { layer, asset } = sel - const sampled = sampleLayer(layer, store.playhead) - const pts = sampled.points ?? asset.points const minX = Math.min(b.startX, b.curX) const maxX = Math.max(b.startX, b.curX) const minY = Math.min(b.startY, b.curY) const maxY = Math.max(b.startY, b.curY) - const indices = new Set() - for (let i = 0; i < pts.length; i++) { - const world = transformLocalToWorld(layer, sampled, pts[i]) - if (world.x >= minX && world.x <= maxX && world.y >= minY && world.y <= maxY) { - indices.add(i) + + if (b.mode === 'vertex') { + const sel = selectedPolygonLayer.value + if (!sel) return + const { layer, asset } = sel + const sampled = sampleLayer(layer, store.playhead) + const pts = sampled.points ?? asset.points + const indices = new Set() + for (let i = 0; i < pts.length; i++) { + const world = transformLocalToWorld(layer, sampled, pts[i]) + if (world.x >= minX && world.x <= maxX && world.y >= minY && world.y <= maxY) { + indices.add(i) + } } + selectedVertexIndices.value = indices + // Tiny click with no vertex hits → treat as an empty-stage + // click and deselect the polygon layer entirely, matching how + // layer-mode handles the same gesture. Without this a polygon + // (or freshly-drawn freeform) stays selected forever. + const dPxX = Math.abs(b.curX - b.startX) * zoom.value + const dPxY = Math.abs(b.curY - b.startY) * zoom.value + if (indices.size === 0 && dPxX < 3 && dPxY < 3) { + store.selectLayer(null) + } + return } - selectedVertexIndices.value = indices + + // Layer mode — touch to select, not "fully enclosed". Clicks + // without drag (≤3 screen-px wiggle) fall through to the + // "click-empty-stage → deselect" behaviour so the handler still + // covers both interactions from a single pointerdown on the + // empty stage. Shift on a tiny click is a no-op (keeps the + // existing selection intact). + const draggedX = Math.abs(b.curX - b.startX) * zoom.value + const draggedY = Math.abs(b.curY - b.startY) * zoom.value + if (draggedX < 3 && draggedY < 3) { + if (!b.additive) store.selectLayer(null) + return + } + const stage = stageRef.value?.getStage() + if (!stage) { + if (!b.additive) store.selectLayer(null) + return + } + const hits: string[] = [] + for (const layer of store.activeLayers) { + if (isLayerEffectivelyLocked(layer)) continue + if (layer.editorHidden) continue + if (!isLayerActiveAt(layer, store.playhead)) continue + const node = stage.findOne(`#${layer.id}`) + if (!node) continue + // getClientRect returns bbox in stage (absolute) coords — + // convert to world coords by undoing the view zoom/pan so we + // can intersect against the world-space drag rect. + const rect = node.getClientRect({ skipTransform: false }) + const z = zoom.value || 1 + const rx0 = (rect.x - viewX.value) / z + const ry0 = (rect.y - viewY.value) / z + const rx1 = rx0 + rect.width / z + const ry1 = ry0 + rect.height / z + if (rx1 < minX || rx0 > maxX || ry1 < minY || ry0 > maxY) continue + hits.push(layer.id) + } + // Additive: merge hits with the existing selection, keeping the + // click order so the anchor lands on whatever the user targeted + // last. Replace mode discards the old selection. + const merged = b.additive + ? (() => { + const seen = new Set(store.selectedLayerIds) + const out = [...store.selectedLayerIds] + for (const h of hits) { + if (!seen.has(h)) { + out.push(h) + seen.add(h) + } + } + return out + })() + : hits + if (merged.length === 0) { + store.selectLayer(null) + return + } + if (merged.length === 1) { + store.selectLayer(merged[0]) + return + } + store.selectedLayerIds = merged + store.selectedLayerId = merged[merged.length - 1] + store.selectedClipInstanceId = null } const draggingPivot = ref(false) @@ -2014,27 +2112,36 @@ function onPivotDragEnd(e: Konva.KonvaEventObject) { store.shiftLayerPivot(info.layerId, dx, dy, localDx, localDy) } -const transformerConfig = { - rotateEnabled: true, - rotationSnaps: [0, 45, 90, 135, 180, -135, -90, -45], - rotationSnapTolerance: 4, - 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, -} +const transformerConfig = computed(() => { + // Rotation on a multi-selection would pin every node to a single + // shared pivot (the transformer bbox centre), which isn't what + // users expect — each layer has its own offset / pivot and should + // only rotate around its own origin. Disable rotation whenever + // more than one node is wrapped so the handle disappears and no + // ambiguous combined rotate can happen. + const multi = store.selectedLayerIds.length > 1 + return { + rotateEnabled: !multi, + rotationSnaps: [0, 45, 90, 135, 180, -135, -90, -45], + rotationSnapTolerance: 4, + 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, + } +}) let attachRetryTimer: ReturnType | null = null @@ -2062,30 +2169,43 @@ function attachTransformer(retriesLeft = 3) { return } - const id = store.selectedLayerId - if (!id) { + // Multi-select: wrap every selected layer node in one transformer + // so the bounding box + scale / rotate handles span the group. + // A SOLE polygon selection still bypasses the transformer because + // a single polygon is edited via vertex handles; mixed selections + // that happen to include a polygon fold it into the shared bbox. + const ids = store.selectedLayerIds.length + ? store.selectedLayerIds + : store.selectedLayerId + ? [store.selectedLayerId] + : [] + if (!ids.length) { tr.nodes([]) tr.getLayer()?.batchDraw() return } - // Polygons are edited via vertex handles — no bulk transformer - const layer = store.activeLayers.find((l) => l.id === id) - if (layer) { + const single = ids.length === 1 + const nodes: Konva.Node[] = [] + let anyMissing = false + for (const id of ids) { + const layer = store.activeLayers.find((l) => l.id === id) + if (!layer) continue const asset = assetMap.value.get(layer.assetId) - if (asset?.kind === 'polygon') { - tr.nodes([]) - tr.getLayer()?.batchDraw() - return + if (asset?.kind === 'polygon' && single) continue + const node = stage.findOne(`#${id}`) + if (!node) { + anyMissing = true + continue } + nodes.push(node) } - const node = stage.findOne(`#${id}`) - if (!node && retriesLeft > 0) { - // The Konva node for a freshly-rendered layer may not be attached yet. - // Retry on the next animation frame a few times before giving up. + if (anyMissing && retriesLeft > 0) { + // Freshly-rendered layers haven't attached yet; retry on the + // next animation frame before giving up on the ids we missed. attachRetryTimer = setTimeout(() => attachTransformer(retriesLeft - 1), 16) return } - tr.nodes(node ? [node] : []) + tr.nodes(nodes) tr.getLayer()?.batchDraw() } @@ -2093,6 +2213,7 @@ watch( () => [ store.selectedLayerId, + store.selectedLayerIds, store.selectedClipInstanceId, stackItems.value, store.playhead, @@ -2105,21 +2226,50 @@ watch( ) 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(), + liveLayerPoses.value = { + ...liveLayerPoses.value, + [layerId]: { + x: node.x(), + y: node.y(), + scaleX: node.scaleX(), + scaleY: node.scaleY(), + rotation: node.rotation(), + }, } } function handleDragMove(e: Konva.KonvaEventObject) { const node = e.target const id = node.id() - if (!store.activeLayers.some((l) => l.id === id)) return + const draggedLayer = store.activeLayers.find((l) => l.id === id) + if (!draggedLayer) return updateLivePose(node, id) + // Multi-select drag: compute the dragged node's delta vs its + // sampled pose at the current playhead, then mirror it onto every + // sibling Konva node so the whole selection tracks together. Each + // sibling's baseline is also its sampled pose (its Konva node + // hasn't moved yet — only the dragged one has). + if ( + store.selectedLayerIds.length > 1 && + store.selectedLayerIds.includes(id) + ) { + const stage = stageRef.value?.getStage() + if (!stage) return + const baseSamp = sampleLayer(draggedLayer, store.playhead) + const dx = node.x() - baseSamp.x + const dy = node.y() - baseSamp.y + for (const lid of store.selectedLayerIds) { + if (lid === id) continue + const other = stage.findOne(`#${lid}`) + if (!other) continue + const otherLayer = store.activeLayers.find((l) => l.id === lid) + if (!otherLayer) continue + const s = sampleLayer(otherLayer, store.playhead) + other.x(s.x + dx) + other.y(s.y + dy) + } + stage.batchDraw() + } } function handleTransform(e: Konva.KonvaEventObject) { @@ -2178,10 +2328,10 @@ const animationPath = computed(() => { // path reflects the live position without mutating the store. If a kf // at the playhead already exists, override its x/y; otherwise splice // in a synthetic one so the path gets a live endpoint. - const live = liveLayerPose.value + const live = liveLayerPoses.value[origLayer.id] let layer = origLayer let workingArr = arr - if (live && live.layerId === origLayer.id) { + if (live) { const cloned: Keyframe[] = arr.map((k) => ({ ...k, props: { ...k.props } })) const existing = cloned.find((k) => Math.abs(k.time - store.playhead) < 1e-3) if (existing) { @@ -2395,19 +2545,29 @@ function pointerToWorld(e: Konva.KonvaEventObject) { } function handleStageMouseDown(e: Konva.KonvaEventObject) { - // Box-select for vertices when a polygon layer is selected and the - // click lands on the empty stage (not a shape/handle). - if ( - !store.drawingMode && - selectedPolygonLayer.value && - e.target === e.target.getStage() - ) { + // Marquee on the empty stage. Two flavours share the same + // drag machinery: vertex-lasso when a polygon is being edited, + // layer multi-select otherwise. pointerup fires finishBoxSelect + // which routes by `mode`. + if (!store.drawingMode && e.target === e.target.getStage()) { const native = e.evt as MouseEvent if (native.button === 0 && !native.ctrlKey) { const wp = pointerToWorld(e) if (wp) { - boxSelect.value = { startX: wp.x, startY: wp.y, curX: wp.x, curY: wp.y } - if (!native.shiftKey) selectedVertexIndices.value = new Set() + // Vertex-lasso mode only applies to a SOLO polygon + // selection — inside a multi-select the box always targets + // layers so new picks add to the existing group. + const mode: 'vertex' | 'layer' = + selectedPolygonLayer.value && store.selectedLayerIds.length <= 1 + ? 'vertex' + : 'layer' + boxSelect.value = { + startX: wp.x, startY: wp.y, curX: wp.x, curY: wp.y, mode, + additive: native.shiftKey, + } + if (mode === 'vertex' && !native.shiftKey) { + selectedVertexIndices.value = new Set() + } closeVertexMenu() return } @@ -2472,7 +2632,33 @@ function handleStageMouseDown(e: Konva.KonvaEventObject) { return } const match = store.activeLayers.find((l) => l.id === id) - store.selectLayer(match ? match.id : null) + if (!match) { + store.selectLayer(null) + return + } + // Shift toggles the clicked layer into the existing selection. + // Matches the timeline's shift-click-on-track-name behaviour so + // both surfaces build multi-selections the same way. Anchor moves + // to the most-recently-clicked id for a predictable Properties + // panel focus. + if ((e.evt as MouseEvent).shiftKey) { + store.toggleLayerSelection(match.id) + return + } + // Pressed on a layer that's already part of the multi-selection + // → don't collapse to a single-layer select. Konva's drag + // machinery is already arming on this node, and handleDragMove + // expects `selectedLayerIds.length > 1` to mirror the delta onto + // the siblings. Leaving the selection alone keeps the group drag + // working. Without this, clicking any one of the selected shapes + // would wipe the others and only drag the clicked one. + if ( + store.selectedLayerIds.length > 1 && + store.selectedLayerIds.includes(match.id) + ) { + return + } + store.selectLayer(match.id) } function handleStageMouseMove(e: Konva.KonvaEventObject) { @@ -2665,6 +2851,25 @@ function handleDragEnd(e: Konva.KonvaEventObject) { if (!layer) return const newX = node.x() const newY = node.y() + // Multi-layer drag: persist every selected layer's new position. + // handleDragMove already mirrored the delta onto each sibling's + // Konva node, so each node's current x/y is already correct — + // just upsert from the live Konva state. + if ( + store.selectedLayerIds.length > 1 && + store.selectedLayerIds.includes(id) + ) { + const stage = stageRef.value?.getStage() + if (stage) { + for (const lid of store.selectedLayerIds) { + const n = stage.findOne(`#${lid}`) + if (!n) continue + store.upsertKeyframe(lid, store.playhead, { x: n.x(), y: n.y() }) + } + clearLivePoses() + return + } + } // Multi-keyframe shift applies only when the layer being dragged is // itself part of the current selection. Otherwise a stale selection on // some other layer would steal the drag delta, leaving the dragged @@ -2681,7 +2886,7 @@ function handleDragEnd(e: Konva.KonvaEventObject) { store.upsertKeyframe(id, store.playhead, { x: newX, y: newY }) selectKfAtPlayhead(layer, layer.separated ? 'position' : 'main') } - liveLayerPose.value = null + clearLivePoses() } function anySelectedKfAtPlayhead(): boolean { @@ -2717,14 +2922,28 @@ function handleTransformEnd(e: Konva.KonvaEventObject) { 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(), - scaleX: node.scaleX(), - scaleY: node.scaleY(), - rotation: node.rotation(), - }) - liveLayerPose.value = null + // Multi-select transform: each node's @transformend fires + // separately, but Konva only guarantees the event on a subset + // (the dragged anchor mostly). To be robust, persist every + // selected layer's live Konva pose here and clear at the end. + // Single-select collapses to the same loop with one id. + const ids = + store.selectedLayerIds.length > 1 && store.selectedLayerIds.includes(id) + ? store.selectedLayerIds + : [id] + const stage = stageRef.value?.getStage() + for (const lid of ids) { + const n = lid === id ? node : stage?.findOne(`#${lid}`) + if (!n) continue + store.upsertKeyframe(lid, store.playhead, { + x: n.x(), + y: n.y(), + scaleX: n.scaleX(), + scaleY: n.scaleY(), + rotation: n.rotation(), + }) + } + clearLivePoses() } const ROT_ARM_PX = 44 @@ -2794,30 +3013,33 @@ function onRotationHandleDragMove(e: Konva.KonvaEventObject) { 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 + const prevLive = liveLayerPoses.value[info.layerId] + const prev = prevLive?.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, + const base = prevLive ?? { 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 } + liveLayerPoses.value = { + ...liveLayerPoses.value, + [info.layerId]: { ...base, 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) { + const live = info ? liveLayerPoses.value[info.layerId] : null + if (info && live) { store.upsertKeyframe(info.layerId, store.playhead, { rotation: live.rotation }) } rotationHandlePos.value = null - liveLayerPose.value = null + clearLivePoses() } let ro: ResizeObserver | null = null diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue index cd9f93d..51af13c 100644 --- a/src/components/PropertiesPanel.vue +++ b/src/components/PropertiesPanel.vue @@ -15,7 +15,7 @@ import { } from '@phosphor-icons/vue' import { useSceneStore } from '@/stores/scene' import { sampleLayer } from '@/lib/interpolate' -import type { Action, AnimatableProps } from '@/types/scene' +import type { Action, AnimatableProps, Layer } from '@/types/scene' import RangeSlider from './RangeSlider.vue' import ActionEditor from './ActionEditor.vue' import { t } from '@/i18n' @@ -74,6 +74,41 @@ const sampled = computed(() => selected.value ? sampleLayer(selected.value, store.playhead) : null, ) +/** True when the user has more than one layer in the selection. + * Drives the multi-edit flow: writers fan out across every + * editable selected layer, kind-specific sections collapse, and + * the shared-common values come from the anchor (selected). */ +const multiSelected = computed(() => store.selectedLayerIds.length > 1) + +const lockedGroupChildIds = computed(() => { + const out = new Set() + for (const g of store.scene.groups ?? []) { + if (g.locked) for (const c of g.childIds) out.add(c) + } + return out +}) + +/** Layers that should receive multi-edit writes — every selection + * member that isn't directly locked nor inside a locked group. + * Falls back to the single anchor for non-multi selections so the + * bind helpers below have a uniform code path. */ +const editableLayers = computed(() => { + const ids = store.selectedLayerIds.length + ? store.selectedLayerIds + : store.selectedLayerId + ? [store.selectedLayerId] + : [] + const out: Layer[] = [] + for (const id of ids) { + const layer = store.activeLayers.find((l) => l.id === id) + if (!layer) continue + if (layer.locked) continue + if (lockedGroupChildIds.value.has(layer.id)) continue + out.push(layer) + } + return out +}) + /** True when the selected layer is locked directly or reached through * a locked parent group. Suppresses the edit UI — the user must * unlock the layer (or the group) before changing properties. */ @@ -361,14 +396,25 @@ const strokeTransparent = computed({ type NumericKey = 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotation' | 'opacity' +/** Patch one key on every editable selected layer, snapshotting + * each layer's other props at the playhead so the new keyframe + * doesn't accidentally collapse to defaults. Used by the bind + * helpers + the shadow / fill / stroke writers below. */ +function writeKeyToSelection(patch: Partial) { + const layers = editableLayers.value + if (!layers.length) return + for (const l of layers) { + store.upsertKeyframe(l.id, store.playhead, patch) + } +} + function bind(key: NumericKey, decimals = 0) { const factor = 10 ** decimals return computed({ + // Reading sticks with the anchor (sampled) so the input shows a + // single concrete value. Writes fan out to every editable layer. get: () => (sampled.value ? Math.round(sampled.value[key] * factor) / factor : 0), - set: (v: number) => { - if (!selected.value) return - store.upsertKeyframe(selected.value.id, store.playhead, { [key]: v }) - }, + set: (v: number) => writeKeyToSelection({ [key]: v }), }) } @@ -380,16 +426,28 @@ const rotation = bind('rotation') const opacity = bind('opacity', 2) function removeKeyframeHere() { - if (!selected.value) return - store.removeKeyframeAt(selected.value.id, store.playhead) + for (const l of editableLayers.value) { + store.removeKeyframeAt(l.id, store.playhead) + } +} + +/** Trash button — removes the selection. Single-select drops one + * layer; multi-select fans out across every editable id. */ +function deleteSelection() { + for (const l of editableLayers.value) { + store.removeLayer(l.id) + } }