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:
1 parent
6784b76607
commit
5af0cdb3d4
9 files changed
+382
-2
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user