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

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ruwio", "name": "ruwio",
"private": true, "private": true,
"version": "0.18.0", "version": "0.18.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+9
View File
@@ -821,11 +821,20 @@ const stackItems = computed<StackItem[]>(() => {
instancesByTrack.set(ci.clipTrackId, arr) instancesByTrack.set(ci.clipTrackId, arr)
} }
// Tracks whose parent TrackGroup is editor-hidden — same cascade
// as the Timeline's lockedGroupChildIds logic, just for the eye
// toggle. A child with its own editorHidden flag unset still
// disappears if its group carries one.
const hiddenGroupChildIds = new Set<string>()
for (const g of store.scene.groups ?? []) {
if (g.editorHidden) for (const cid of g.childIds) hiddenGroupChildIds.add(cid)
}
const expanded = expandedTrackOrder(store.scene) const expanded = expandedTrackOrder(store.scene)
const order = expanded.length ? expanded : store.scene.layers.map((l) => l.id) const order = expanded.length ? expanded : store.scene.layers.map((l) => l.id)
// iterate bottom → top: reverse the array // iterate bottom → top: reverse the array
for (let i = order.length - 1; i >= 0; i--) { for (let i = order.length - 1; i >= 0; i--) {
const id = order[i] const id = order[i]
if (hiddenGroupChildIds.has(id)) continue
const layer = layerById.get(id) const layer = layerById.get(id)
if (layer) { if (layer) {
// Editor-only hide: skip on the Konva stage while the scene // Editor-only hide: skip on the Konva stage while the scene
+53
View File
@@ -89,6 +89,7 @@ interface TimelineGroupTrack {
collapsed: boolean collapsed: boolean
color: string | null color: string | null
locked: boolean locked: boolean
editorHidden: boolean
orderIndex: number orderIndex: number
childCount: number childCount: number
} }
@@ -808,6 +809,7 @@ const tracks = computed<TimelineTrack[]>(() => {
collapsed: !!grp.collapsed, collapsed: !!grp.collapsed,
color: grp.color ?? null, color: grp.color ?? null,
locked: !!grp.locked, locked: !!grp.locked,
editorHidden: !!grp.editorHidden,
orderIndex, orderIndex,
childCount: grp.childIds.length, childCount: grp.childIds.length,
}) })
@@ -2741,6 +2743,36 @@ function duplicateTrackFromMenu() {
async function renameFromMenu() { async function renameFromMenu() {
if (!trackMenu.value) return if (!trackMenu.value) return
if (trackMenu.value.kind === 'layer') {
const layer = store.activeLayers.find((l) => l.id === trackMenu.value!.trackId)
if (!layer) return
const id = trackMenu.value.trackId
const current = layer.name
trackMenu.value = null
const next = await promptDialog({
title: t('timeline.renameLayerDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameLayerDialog.confirm'),
})
if (next === null) return
store.setLayerName(id, next)
return
}
if (trackMenu.value.kind === 'clip') {
const track = store.scene.clipTracks?.find((t) => t.id === trackMenu.value!.trackId)
if (!track) return
const id = trackMenu.value.trackId
const current = track.name ?? t('timeline.clipTrackName')
trackMenu.value = null
const next = await promptDialog({
title: t('timeline.renameClipTrackDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameClipTrackDialog.confirm'),
})
if (next === null) return
store.setClipTrackName(id, next)
return
}
if (trackMenu.value.kind === 'group') { if (trackMenu.value.kind === 'group') {
const group = groupById.value.get(trackMenu.value.trackId) const group = groupById.value.get(trackMenu.value.trackId)
if (!group) return if (!group) return
@@ -3778,6 +3810,16 @@ onBeforeUnmount(() => {
<PhFolder :size="12" class="name-badge" /> <PhFolder :size="12" class="name-badge" />
<span class="track-label">{{ track.name }}</span> <span class="track-label">{{ track.name }}</span>
<span class="group-count">{{ t('timeline.groupChildCount', { n: track.childCount }) }}</span> <span class="group-count">{{ t('timeline.groupChildCount', { n: track.childCount }) }}</span>
<button
class="eye-btn"
:class="{ hidden: !!track.editorHidden }"
v-tooltip="track.editorHidden ? t('timeline.showInEditor') : t('timeline.hideInEditor')"
@click.stop="store.setGroupEditorHidden(track.groupId, !track.editorHidden)"
@mousedown.stop
@dragstart.prevent.stop
>
<component :is="track.editorHidden ? PhEyeSlash : PhEye" :size="12" />
</button>
<button <button
class="lock-btn" class="lock-btn"
:class="{ locked: track.locked }" :class="{ locked: track.locked }"
@@ -3973,6 +4015,13 @@ onBeforeUnmount(() => {
{{ t('timeline.renameScriptTrack') }} {{ t('timeline.renameScriptTrack') }}
</button> </button>
</template> </template>
<template v-else-if="trackMenu.kind === 'clip'">
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="renameFromMenu">
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
{{ t('timeline.renameClipTrack') }}
</button>
</template>
<template v-else-if="trackMenu.kind === 'audio'"> <template v-else-if="trackMenu.kind === 'audio'">
<div class="kf-menu-divider"></div> <div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="renameFromMenu"> <button class="kf-menu-item" @click="renameFromMenu">
@@ -3993,6 +4042,10 @@ onBeforeUnmount(() => {
<template v-else> <template v-else>
<template v-if="trackMenu.kind === 'layer'"> <template v-if="trackMenu.kind === 'layer'">
<div class="kf-menu-divider"></div> <div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="renameFromMenu">
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
{{ t('timeline.renameLayer') }}
</button>
<button <button
class="kf-menu-item" class="kf-menu-item"
:disabled="trackMenu.locked" :disabled="trackMenu.locked"
+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', () => { suite('ui — Timeline clip-track eye button', () => {
test('clicking the eye toggles editorHidden on the clip track', async () => { test('clicking the eye toggles editorHidden on the clip track', async () => {
const s = makeFreshStore() 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', () => { suite('ui — Timeline audio-clip context menu', () => {
test('right-click on an audio-clip-block opens a menu with Duplicate + Delete', async () => { test('right-click on an audio-clip-block opens a menu with Duplicate + Delete', async () => {
const s = makeFreshStore() 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 () => { test('editorHidden clip track drops its instances from the Konva stack', async () => {
// Matches the layer editor-hide semantics: the track stays in // Matches the layer editor-hide semantics: the track stays in
// the scene file (so exports still see it) but no ci-group or // 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) 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"', () => { test('sidebar noClips / noButtons empty-states both suggest "yet"', () => {
// Regression: the clip library used to read "No clips" while the // Regression: the clip library used to read "No clips" while the
// button library read "No buttons yet". Minor inconsistency that // button library read "No buttons yet". Minor inconsistency that
+10
View File
@@ -237,6 +237,14 @@ export const en = {
title: 'Rename audio track', title: 'Rename audio track',
confirm: 'Apply', confirm: 'Apply',
}, },
renameLayerDialog: {
title: 'Rename layer',
confirm: 'Apply',
},
renameClipTrackDialog: {
title: 'Rename clip track',
confirm: 'Apply',
},
removeAudioClipDialog: { removeAudioClipDialog: {
title: 'Remove audio clip?', title: 'Remove audio clip?',
message: 'Remove this audio clip from the timeline.', message: 'Remove this audio clip from the timeline.',
@@ -248,6 +256,8 @@ export const en = {
audioMuted: 'Muted — click to unmute', audioMuted: 'Muted — click to unmute',
muteAudioTrack: 'Mute track', muteAudioTrack: 'Mute track',
renameAudioTrack: 'Rename audio track', renameAudioTrack: 'Rename audio track',
renameLayer: 'Rename layer',
renameClipTrack: 'Rename clip track',
ungroup: 'Ungroup', ungroup: 'Ungroup',
groupSelected: 'Group {n} selected layers', groupSelected: 'Group {n} selected layers',
convertToClip: 'Convert {n} selected to clip', convertToClip: 'Convert {n} selected to clip',
+4
View File
@@ -487,6 +487,10 @@ export function drawSceneFrame(
} }
const group = groupById.get(id) const group = groupById.get(id)
if (group) { if (group) {
// Editor-only hide cascades to every child track. Match the
// Konva stage's stackItems so mask-preview and thumbnails
// agree with the canvas.
if (group.editorHidden) continue
const childLayers: Layer[] = [] const childLayers: Layer[] = []
for (let k = group.childIds.length - 1; k >= 0; k--) { for (let k = group.childIds.length - 1; k >= 0; k--) {
const cl = layerById.get(group.childIds[k]) const cl = layerById.get(group.childIds[k])
+39
View File
@@ -1829,6 +1829,35 @@ export const useSceneStore = defineStore('scene', {
} }
} }
}, },
setGroupEditorHidden(groupId: string, hidden: boolean) {
const group = this.scene.groups?.find((g) => g.id === groupId)
if (!group) return
if (hidden) group.editorHidden = true
else delete group.editorHidden
// Editor-hide cascades to every child track; drop any selection
// that now points inside the group so the transformer, keyboard
// shortcuts and Properties panel stop offering edits that won't
// render.
if (!hidden) return
const childIds = new Set(group.childIds)
if (this.selectedLayerId && childIds.has(this.selectedLayerId)) {
this.selectedLayerId = null
this.selectedLayerIds = []
} else if (this.selectedLayerIds.length) {
this.selectedLayerIds = this.selectedLayerIds.filter(
(id) => !childIds.has(id),
)
if (!this.selectedLayerIds.length) this.selectedLayerId = null
}
if (this.selectedClipInstanceId) {
const sel = this.scene.clipInstances?.find(
(ci) => ci.id === this.selectedClipInstanceId,
)
if (sel && childIds.has(sel.clipTrackId)) {
this.selectedClipInstanceId = null
}
}
},
// -------- Markers -------------------------------------------------- // -------- Markers --------------------------------------------------
@@ -1892,6 +1921,16 @@ export const useSceneStore = defineStore('scene', {
const track = this.scene.scriptTracks?.find((t) => t.id === id) const track = this.scene.scriptTracks?.find((t) => t.id === id)
if (track) track.name = name.trim() || undefined if (track) track.name = name.trim() || undefined
}, },
setLayerName(id: string, name: string) {
const layer = this.activeLayers.find((l) => l.id === id)
if (!layer) return
const n = name.trim()
if (n) layer.name = n
},
setClipTrackName(id: string, name: string) {
const track = this.scene.clipTracks?.find((t) => t.id === id)
if (track) track.name = name.trim() || undefined
},
addScriptFrame( addScriptFrame(
trackId: string, trackId: string,
time: number, time: number,
+5
View File
@@ -234,6 +234,11 @@ export interface TrackGroup {
color?: string | null color?: string | null
colorOverridden?: boolean colorOverridden?: boolean
locked?: boolean locked?: boolean
/** Editor-only hide that cascades to every child track. Same
* semantics as `Layer.editorHidden` / `ClipTrack.editorHidden` —
* in-editor render paths skip the children, exports still see
* them unless the user opts into strip-hidden. */
editorHidden?: boolean
} }
export interface ClipInstance { export interface ClipInstance {
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.18.0' export const APP_VERSION = '0.18.1'
export const APP_NAME = 'RUWIO' export const APP_NAME = 'RUWIO'