1.1.3: multi-select on the stage — marquee, group transform, panel

- Click-and-drag on the empty stage starts a layer marquee with
  touch (not enclose) semantics — every unlocked layer whose bbox
  overlaps the drag rect joins the selection. Tiny clicks still
  deselect; shift adds to the existing selection on both clicks
  and marquees. Polygon-vertex lasso stays available when a single
  polygon is the active selection.
- Konva transformer now wraps every selected layer in one shared
  bounding box (polygons fold in for mixed selections; vertex
  handles + the vertex lasso suppress to avoid fighting the box).
  rotateEnabled flips off on multi to skip the ambiguous shared-
  pivot rotation. Drag of any selected node mirrors the delta onto
  every sibling Konva node so the whole group travels together;
  transformend persists each layer's final pose, not just the
  anchor's.
- liveLayerPose became a per-id map (liveLayerPoses) so the
  reactive shape configs don't snap non-focused siblings back to
  their sampled pre-transform pose mid-gesture.
- Plain click on a member of an existing multi-selection no longer
  collapses to single-select before the drag begins.
- Arrow-key nudge + Timeline + keyframe button + Properties panel
  all fan out across the multi-selection: every editable layer
  gets the value, kind-specific panel sections (text, polygon,
  fill, stroke, blur, shadow, mask, button) collapse to leave just
  Transform + Opacity, and the trash button labels with the
  selection count.
- Click on empty stage with a polygon selected now deselects the
  layer too instead of leaving the polygon stuck in the panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-25 08:41:26 +02:00
1 parent 289fa770b0
commit 1f2812c64d
8 files changed
+1168 -157

No files matched your search

+322 -100
View File
@@ -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<LiveLayerPose | null>(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<Record<string, LiveLayerPose>>({})
function clearLivePoses() {
liveLayerPoses.value = {}
}
const pivotInfo = computed<PivotInfo | null>(() => {
const id = store.selectedLayerId
@@ -1562,8 +1571,8 @@ const pivotInfo = computed<PivotInfo | null>(() => {
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<VertexHandle[]>(() => {
// 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<VertexHandle[]>(() => {
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<TangentHandle[]>(() => {
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<number>()
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<number>()
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<Konva.Node>(`#${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<DragEvent>) {
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<typeof setTimeout> | 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<Konva.Node>(`#${id}`)
if (!node) {
anyMissing = true
continue
}
nodes.push(node)
}
const node = stage.findOne<Konva.Node>(`#${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<DragEvent>) {
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<Konva.Node>(`#${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<Event>) {
@@ -2178,10 +2328,10 @@ const animationPath = computed<PathOverlay | null>(() => {
// 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<MouseEvent>) {
}
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
// 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<MouseEvent>) {
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<MouseEvent>) {
@@ -2665,6 +2851,25 @@ function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
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<Konva.Node>(`#${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<DragEvent>) {
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<Event>) {
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<Konva.Node>(`#${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<DragEvent>) {
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
+92 -21
View File
@@ -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<AnimatableProps | null>(() =>
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<string>()
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<Layer[]>(() => {
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<boolean>({
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<AnimatableProps>) {
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)
}
}
</script>
<template>
<div class="panel">
<h3>Properties</h3>
<template v-if="selected && sampled && !selectedIsLocked">
<label>Name<input v-model="selected.name" /></label>
<template v-if="selected && sampled && editableLayers.length > 0">
<div v-if="multiSelected" class="multi-banner">
{{ t('properties.multiSelected', { n: editableLayers.length }) }}
</div>
<label v-if="!multiSelected">Name<input v-model="selected.name" /></label>
<div class="kf-row" :class="{ on: hasKeyframeHere }">
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
@@ -420,7 +478,7 @@ function removeKeyframeHere() {
</div>
</section>
<section v-if="textAsset" class="prop-group">
<section v-if="textAsset && !multiSelected" class="prop-group">
<button class="group-header" @click="toggleSection('text')">
<component :is="sectionOpen.text ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.text') }}</span>
@@ -431,7 +489,7 @@ function removeKeyframeHere() {
</div>
</section>
<section v-if="polygonAsset" class="prop-group">
<section v-if="polygonAsset && !multiSelected" class="prop-group">
<button class="group-header" @click="toggleSection('polygon')">
<component :is="sectionOpen.polygon ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.polygon') }}</span>
@@ -463,7 +521,7 @@ function removeKeyframeHere() {
<span>{{ t('properties.sections.appearance') }}</span>
</button>
<div v-if="sectionOpen.appearance" class="group-body">
<label v-if="canFill" class="color-label">
<label v-if="canFill && !multiSelected" class="color-label">
{{ t('properties.fill') }}
<div class="color-row">
<input
@@ -485,7 +543,7 @@ function removeKeyframeHere() {
</button>
</div>
</label>
<label v-if="canStroke" class="color-label">
<label v-if="canStroke && !multiSelected" class="color-label">
{{ t('properties.stroke') }}
<div class="color-row">
<input
@@ -507,7 +565,7 @@ function removeKeyframeHere() {
</button>
</div>
</label>
<label v-if="canStroke" class="slider-label">
<label v-if="canStroke && !multiSelected" class="slider-label">
{{ t('properties.strokeWidth') }}
<RangeSlider
v-model="strokeWidth"
@@ -521,7 +579,7 @@ function removeKeyframeHere() {
{{ t('properties.opacity') }}
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
</label>
<label v-if="blurAsset" class="slider-label">
<label v-if="blurAsset && !multiSelected" class="slider-label">
{{ t('properties.edgeBlur') }}
<RangeSlider
v-model="blurValue"
@@ -534,7 +592,7 @@ function removeKeyframeHere() {
</div>
</section>
<section class="prop-group">
<section v-if="!multiSelected" class="prop-group">
<button class="group-header" @click="toggleSection('shadow')">
<component :is="sectionOpen.shadow ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.shadow') }}</span>
@@ -589,7 +647,7 @@ function removeKeyframeHere() {
</div>
</section>
<section v-if="canBeMask" class="prop-group">
<section v-if="canBeMask && !multiSelected" class="prop-group">
<button class="group-header" @click="toggleSection('mask')">
<component :is="sectionOpen.mask ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.mask') }}</span>
@@ -640,7 +698,7 @@ function removeKeyframeHere() {
</div>
</section>
<section v-if="buttonAsset" class="prop-group">
<section v-if="buttonAsset && !multiSelected" class="prop-group">
<button class="group-header" @click="toggleSection('button')">
<component :is="sectionOpen.button ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.button') }}</span>
@@ -653,12 +711,16 @@ function removeKeyframeHere() {
</div>
</section>
<button class="danger-btn" @click="store.removeLayer(selected.id)">
<button class="danger-btn" @click="deleteSelection">
<PhTrash :size="13" />
{{ t('properties.deleteLayer') }}
{{
multiSelected
? t('properties.deleteLayers', { n: editableLayers.length })
: t('properties.deleteLayer')
}}
</button>
</template>
<p v-else-if="selected && selectedIsLocked" class="hint">
<p v-else-if="selected && editableLayers.length === 0" class="hint">
{{ t('properties.lockedLayer') }}
</p>
<p v-else class="hint">{{ t('properties.noLayer') }}</p>
@@ -971,4 +1033,13 @@ input {
background: rgba(255, 122, 122, 0.18);
border-color: var(--danger);
}
.multi-banner {
padding: 6px 8px;
font-size: 11px;
background: var(--accent-soft);
color: var(--accent);
border-radius: var(--radius-sm);
border: 1px solid var(--accent);
}
</style>
+44 -22
View File
@@ -617,36 +617,58 @@ function onLaneClick(e: MouseEvent) {
}
const canCreateKf = computed(() => {
const id = store.selectedLayerId
if (!id) return false
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return false
if (layer.locked) return false
return !lockedGroupChildIds.value.has(id)
// Button is available as long as at least one selected layer is
// unlocked — the multi-select handler skips the locked ones per
// id, so a partially-locked selection still writes the usable
// frames.
const ids = store.selectedLayerIds.length
? store.selectedLayerIds
: store.selectedLayerId
? [store.selectedLayerId]
: []
if (!ids.length) return false
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(id)) continue
return true
}
return false
})
function addKeyframeForSelected() {
const id = store.selectedLayerId
if (!id) return
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer || isLayerIdLocked(id)) return
// Fan out across the whole multi-selection so the user can pin a
// frame on every grouped object at once. Each layer gets its own
// sampled snapshot so the keyframe matches the current pose.
// Falls back to the single-selection anchor when no multi is set.
const ids = store.selectedLayerIds.length
? store.selectedLayerIds
: store.selectedLayerId
? [store.selectedLayerId]
: []
if (!ids.length) return
// Snap the playhead itself to the nearest keyframe before adding —
// the current playhead position is NOT a candidate (it's the value
// we're rounding).
const time = snapTime(store.playhead, { ignorePlayhead: true })
const sampled = sampleLayer(layer, time)
const props: Partial<AnimatableProps> = {
scaleX: sampled.scaleX,
scaleY: sampled.scaleY,
opacity: sampled.opacity,
for (const id of ids) {
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer || isLayerIdLocked(id)) continue
const sampled = sampleLayer(layer, time)
const props: Partial<AnimatableProps> = {
scaleX: sampled.scaleX,
scaleY: sampled.scaleY,
opacity: sampled.opacity,
}
if (sampled.fill) props.fill = sampled.fill
if (!layer.separated) {
props.x = sampled.x
props.y = sampled.y
props.rotation = sampled.rotation
}
store.upsertKeyframe(id, time, props)
}
if (sampled.fill) props.fill = sampled.fill
if (!layer.separated) {
props.x = sampled.x
props.y = sampled.y
props.rotation = sampled.rotation
}
store.upsertKeyframe(id, time, props)
}
function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {