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
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
Reference in new issue
Block a user