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

+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) {