0.8.5: multi-select tracks, group + convert-to-clip

Multi-select
- New `selectedLayerIds: string[]` on the scene store — ordered by
  click, anchor = last entry, always equal to `selectedLayerId`.
- `toggleLayerSelection(id)` shift-click behaviour: adds or removes,
  anchor follows the last click, effectively-locked layers refuse.
- `selectLayer`, `addLayer`, and every selection-reset path keep
  the set in lock-step with the anchor.

Timeline
- Shift-click on a layer row's name toggles that layer's
  membership in the multi-selection. Every selected row highlights
  via `store.selectedLayerIds.includes(track.layerId)`.
- Right-click on a track now preserves an existing multi-selection
  if the clicked track is part of it; lands as a single-select
  otherwise.
- Track context menu adds two entries when ≥1 layer is selected:
  - "Group N selected layers" — calls the new store action and
    drops the group into the topmost selected track's stack slot
    (not at the very top as a naive `addTrackGroup` would).
  - "Convert N selected to clip" — packs the layers into a fresh
    clip with `duration = scene.duration`, deep-clones via
    `structuredClone(toRaw(l))`, drops a single `ClipInstance` on
    a new clipTrack at `startTime = 0`, and positions the
    clipTrack at the topmost selected track's slot.

Fixes
- `removeLayer` no longer unconditionally clears
  `selectedLayerIds` (bulk-replace artifact from the multi-select
  work); the anchor-matched path clears the whole set, the
  non-anchor path only splices out that one id.
- `deselectIfLocked` indentation normalised.

Regression tests
- Multi-select store API: selectLayer sync, toggleLayerSelection
  add/remove/anchor, lock-filter, empty-selection guards.
- `groupSelectedLayers`: happy-path packing, empty-select null
  return, lock-filter, stack-slot pinning.
- `convertSelectedLayersToClip`: packs layers + spawns clipTrack +
  instance (deep-clone integrity pinned via unique per-layer names
  so the cloned-layer lookup is unambiguous), drops the clipTrack
  at the topmost slot, returns null on empty selection, skips
  effectively-locked layers.

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 05:19:09 +02:00
1 parent af5f99380c
commit 53c0410824
7 files changed
+467 -7

No files matched your search

+45 -3
View File
@@ -1877,12 +1877,27 @@ async function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
const trackMenu = ref<TrackMenuState | null>(null)
const trackMenuRef = ref<HTMLDivElement | null>(null)
function onLayerTrackNameClick(e: MouseEvent, track: TimelineLayerTrack) {
if (isTrackEffectivelyLocked(track)) return
if (e.shiftKey && track.isParent) {
store.toggleLayerSelection(track.layerId)
return
}
store.selectLayer(track.layerId)
}
function onTrackContextMenu(e: MouseEvent, layerId: string) {
e.preventDefault()
e.stopPropagation()
const layer = store.activeLayers.find((l) => l.id === layerId)
if (!layer) return
store.selectLayer(layerId)
// If the right-clicked layer isn't already in the multi-selection,
// treat the right-click as a single-select so the menu actions
// operate on what the user actually clicked. Multi-selected ids
// persist so "Group selected" has a real list to work with.
if (!store.selectedLayerIds.includes(layerId)) {
store.selectLayer(layerId)
}
trackMenu.value = {
kind: 'layer',
trackId: layerId,
@@ -1959,6 +1974,16 @@ function toggleSeparated() {
trackMenu.value = null
}
function groupSelectedFromMenu() {
store.groupSelectedLayers()
trackMenu.value = null
}
function convertSelectedToClipFromMenu() {
store.convertSelectedLayersToClip()
trackMenu.value = null
}
function deleteTrack() {
if (!trackMenu.value) return
if (trackMenu.value.kind === 'layer') {
@@ -2424,12 +2449,13 @@ onBeforeUnmount(() => {
<span
class="track-name"
:class="{
selected: track.layerId === store.selectedLayerId && track.isParent,
selected:
track.isParent && store.selectedLayerIds.includes(track.layerId),
parent: track.isParent,
sub: !track.isParent,
}"
:draggable="track.isParent && !isTrackEffectivelyLocked(track)"
@click="!isTrackEffectivelyLocked(track) ? store.selectLayer(track.layerId) : null"
@click="onLayerTrackNameClick($event, track)"
@contextmenu="
track.isParent && !isTrackEffectivelyLocked(track)
? onTrackContextMenu($event, track.layerId)
@@ -2999,6 +3025,22 @@ onBeforeUnmount(() => {
class="kf-menu-trailing-check"
/>
</button>
<button
v-if="store.selectedLayerIds.length >= 2"
class="kf-menu-item"
@click="groupSelectedFromMenu"
>
<PhFolderPlus :size="12" class="kf-menu-icon" />
{{ t('timeline.groupSelected', { n: store.selectedLayerIds.length }) }}
</button>
<button
v-if="store.selectedLayerIds.length >= 1"
class="kf-menu-item"
@click="convertSelectedToClipFromMenu"
>
<PhFilmStrip :size="12" class="kf-menu-icon" />
{{ t('timeline.convertToClip', { n: store.selectedLayerIds.length }) }}
</button>
</template>
</template>
<div class="kf-menu-divider"></div>