0.18.1: editor-hide cascades to groups + rename for layers & clip tracks

Track groups get the same editor-hide affordance as layer and clip
tracks: new eye button on the group header, setGroupEditorHidden
action (clears selection pointing inside the group), and a cascade
through the Konva stackItems + render.ts so every child track in a
hidden group vanishes in the editor.

Track renaming via right-click context menu now covers layers and
clip tracks too — setLayerName / setClipTrackName actions, new i18n
strings, and end-to-end UI tests driving the menu → promptDialog →
rename flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-24 14:55:22 +02:00
1 parent 6784b76607
commit 5af0cdb3d4
9 files changed
+382 -2

No files matched your search

+260
View File
@@ -6159,6 +6159,56 @@ export function registerAllTests(): void {
})
})
suite('scene store — track group editor hide', () => {
test('setGroupEditorHidden toggles the flag on + off', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup(undefined, l.id)
expect(g.editorHidden).toBe(undefined)
s.setGroupEditorHidden(g.id, true)
expect(g.editorHidden).toBe(true)
s.setGroupEditorHidden(g.id, false)
expect('editorHidden' in g).toBe(false)
})
test('hiding a group clears selection of a child layer', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const groupId = s.groupSelectedLayers('G1')!
expect(groupId).toBeTruthy()
// groupSelectedLayers clears selection after consuming it, so
// re-select the layer now that it sits inside the group.
s.selectLayer(l.id)
expect(s.selectedLayerId).toBe(l.id)
s.setGroupEditorHidden(groupId, true)
expect(s.selectedLayerId).toBe(null)
expect(s.selectedLayerIds).toEqual([])
})
test('hiding a group clears clip-instance selection when the instance track is inside', () => {
const s = makeFreshStore()
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
// Wrap the clip track into a fresh group by poking childIds
// directly — there's no public addClipTrackToGroup action, the
// real app nests tracks through drag-drop.
const g = s.addTrackGroup('G2', track.id)
g.childIds.push(track.id)
const order = s.scene.trackOrder!
const ti = order.indexOf(track.id)
if (ti >= 0) order.splice(ti, 1)
s.selectClipInstance(ci.id)
expect(s.selectedClipInstanceId).toBe(ci.id)
s.setGroupEditorHidden(g.id, true)
expect(s.selectedClipInstanceId).toBe(null)
})
})
suite('ui — Timeline clip-track eye button', () => {
test('clicking the eye toggles editorHidden on the clip track', async () => {
const s = makeFreshStore()
@@ -6183,6 +6233,178 @@ export function registerAllTests(): void {
})
})
suite('scene store — track rename (layer + clip track)', () => {
test('setLayerName applies a trimmed non-empty name', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setLayerName(l.id, ' Hero ')
expect(l.name).toBe('Hero')
})
test('setLayerName ignores empty / whitespace-only input', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const original = l.name
s.setLayerName(l.id, '')
expect(l.name).toBe(original)
s.setLayerName(l.id, ' ')
expect(l.name).toBe(original)
})
test('setClipTrackName sets + clears the optional name field', () => {
const s = makeFreshStore()
s.addClip('CInst')
const track = s.scene.clipTracks![0]
s.setClipTrackName(track.id, 'Intro clips')
expect(track.name).toBe('Intro clips')
// Empty clears back to undefined — default label is shown via i18n.
s.setClipTrackName(track.id, '')
expect(track.name).toBe(undefined)
})
})
suite('ui — Timeline rename menu (layer + clip track)', () => {
// Finds the menu item whose label contains the given i18n string.
// Menu items share `.kf-menu-item`, so labels are the only stable
// handle that distinguishes rename from lock / delete / etc.
function findMenuItem(menu: HTMLElement, label: string): HTMLButtonElement | null {
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
return items.find((b) => b.textContent?.includes(label)) ?? null
}
test('right-click on a layer row → Rename layer → prompt → setLayerName', async () => {
const { dialogState, resolveDialog } = await import(
'@/components/ui/dialogs'
)
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Rect', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const row = handle.host.querySelector('.track .track-name') as HTMLElement
expect(row).toBeTruthy()
row.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true, cancelable: true, clientX: 10, clientY: 10,
}),
)
await nextTick()
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
expect(menu).toBeTruthy()
const renameBtn = findMenuItem(menu, i18nT('timeline.renameLayer'))
expect(renameBtn).toBeTruthy()
// renameFromMenu is async but runs synchronously up to the
// `await promptDialog(...)`; promptDialog's new Promise cb
// runs in the same tick and writes dialogState.value before
// returning. So the prompt state is observable immediately
// after the click.
renameBtn!.click()
expect(dialogState.value?.kind).toBe('prompt')
resolveDialog('Hero-from-menu')
// The awaited promptDialog now resolves on a microtask — tick
// so the continuation (store.setLayerName) runs.
await nextTick()
expect(l.name).toBe('Hero-from-menu')
} finally {
handle.unmount()
}
})
test('Rename layer prompt cancelled → layer name stays the same', async () => {
// Dismissing the prompt must not clobber the existing name.
const { dialogState, cancelDialog } = await import(
'@/components/ui/dialogs'
)
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Rect', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const original = l.name
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const row = handle.host.querySelector('.track .track-name') as HTMLElement
row.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true, cancelable: true, clientX: 10, clientY: 10,
}),
)
await nextTick()
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
findMenuItem(menu, i18nT('timeline.renameLayer'))!.click()
expect(dialogState.value?.kind).toBe('prompt')
cancelDialog()
await nextTick()
expect(l.name).toBe(original)
} finally {
handle.unmount()
}
})
test('right-click on a clip-track row → Rename clip track → prompt → setClipTrackName', async () => {
const { dialogState, resolveDialog } = await import(
'@/components/ui/dialogs'
)
const s = makeFreshStore()
s.addClip('CInst')
const track = s.scene.clipTracks![0]
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const row = handle.host.querySelector(
'.track.clip-track .track-name',
) as HTMLElement
expect(row).toBeTruthy()
row.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true, cancelable: true, clientX: 10, clientY: 10,
}),
)
await nextTick()
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
expect(menu).toBeTruthy()
const renameBtn = findMenuItem(menu, i18nT('timeline.renameClipTrack'))
expect(renameBtn).toBeTruthy()
renameBtn!.click()
expect(dialogState.value?.kind).toBe('prompt')
resolveDialog('Intro clips')
await nextTick()
expect(track.name).toBe('Intro clips')
} finally {
handle.unmount()
}
})
})
suite('ui — Timeline group eye button', () => {
test('clicking the eye toggles editorHidden on the track group', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const groupId = s.groupSelectedLayers('G1')!
const group = s.scene.groups!.find((g) => g.id === groupId)!
const handle = mountComponent(Timeline, {})
await nextTick()
const groupTrack = handle.host.querySelector('.track.group-track') as HTMLElement
expect(groupTrack).toBeTruthy()
const eye = groupTrack.querySelector('.track-name .eye-btn') as HTMLButtonElement
expect(eye).toBeTruthy()
expect(eye.classList.contains('hidden')).toBe(false)
eye.click()
await nextTick()
expect(group.editorHidden).toBe(true)
const eye2 = groupTrack.querySelector('.track-name .eye-btn') as HTMLButtonElement
expect(eye2.classList.contains('hidden')).toBe(true)
eye2.click()
await nextTick()
expect('editorHidden' in group).toBe(false)
handle.unmount()
})
})
suite('ui — Timeline audio-clip context menu', () => {
test('right-click on an audio-clip-block opens a menu with Duplicate + Delete', async () => {
const s = makeFreshStore()
@@ -7345,6 +7567,32 @@ export function registerAllTests(): void {
}
})
test('editorHidden group cascades — children vanish from the Konva stack', async () => {
// Regression guard for the cascade: even if the child track's
// own flag is unset, hiding the parent group must drop its
// layers and clip instances from the editor.
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00',
})
const layer = s.addLayer(rect.id)!
s.selectLayer(layer.id)
const groupId = s.groupSelectedLayers('G')!
expect(groupId).toBeTruthy()
s.setGroupEditorHidden(groupId, true)
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage()
// The child layer's Konva node carries the layer id directly.
expect(stage.findOne(`#${layer.id}`)).toBeFalsy()
} finally {
handle.unmount()
}
})
test('editorHidden clip track drops its instances from the Konva stack', async () => {
// Matches the layer editor-hide semantics: the track stays in
// the scene file (so exports still see it) but no ci-group or
@@ -10353,6 +10601,18 @@ export function registerAllTests(): void {
expect(confirm === 'timeline.renameScriptTrackDialog.confirm').toBe(false)
})
test('renameLayer + renameClipTrack are translated', () => {
expect(i18nT('timeline.renameLayer') === 'timeline.renameLayer').toBe(false)
expect(i18nT('timeline.renameClipTrack') === 'timeline.renameClipTrack').toBe(false)
})
test('renameLayerDialog / renameClipTrackDialog titles + confirms are translated', () => {
expect(i18nT('timeline.renameLayerDialog.title') === 'timeline.renameLayerDialog.title').toBe(false)
expect(i18nT('timeline.renameLayerDialog.confirm') === 'timeline.renameLayerDialog.confirm').toBe(false)
expect(i18nT('timeline.renameClipTrackDialog.title') === 'timeline.renameClipTrackDialog.title').toBe(false)
expect(i18nT('timeline.renameClipTrackDialog.confirm') === 'timeline.renameClipTrackDialog.confirm').toBe(false)
})
test('sidebar noClips / noButtons empty-states both suggest "yet"', () => {
// Regression: the clip library used to read "No clips" while the
// button library read "No buttons yet". Minor inconsistency that