0.12.0: document drawer redesign, PWA install, clip/audio context menus + push-cascade
- Document drawer slides out from under the topbar (no tab, no hover peek); opens via project-name button, closes on outside-click or ESC. Layout: inputs row + divider + preset row, presets user-managed via popup and persisted in localStorage. - PWA manifest + minimal service worker so Chrome installs RUWIO as a standalone app — suppresses the native fullscreen exit overlay. - Clips and audio clips get right-click context menus (Duplicate / Delete) via new `duplicateClipInstance` / `duplicateAudioClip` store actions. - Clip moves cascade non-overlap in both directions; `setAudioClipPosition` now pushes backward as well as forward. New `setClipInstancePosition` gives clip instances the same behaviour. - Library drags carry timeline-shaped ghost previews (audio pill, clip pill, button thumb) instead of the library row; clip lane renders a dashed drop-target ghost and rejects window-overlap drops. - Scene / Clipart drawer tabs disappear in fullscreen; the 24 px they occupied is reclaimed for the stage. Play button sits on the exact horizontal center via a 3-column grid in the transport cluster. - Tests: context menu + push-cascade + duplicate action coverage for both clip kinds; DocumentDrawer suite rewritten for the slide-out model. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fa66ba69bb
commit
354dd8a4f0
25 files changed
+2294
-405
No files matched your search
+537
-1
@@ -42,6 +42,7 @@ import Toast from '@/components/ui/Toast.vue'
|
||||
import PropertiesPanel from '@/components/PropertiesPanel.vue'
|
||||
import ClipartDrawer from '@/components/ClipartDrawer.vue'
|
||||
import SceneDrawer from '@/components/SceneDrawer.vue'
|
||||
import DocumentDrawer from '@/components/DocumentDrawer.vue'
|
||||
import ActionEditor from '@/components/ActionEditor.vue'
|
||||
import Timeline from '@/components/Timeline.vue'
|
||||
import AssetSidebar from '@/components/AssetSidebar.vue'
|
||||
@@ -55,7 +56,7 @@ import {
|
||||
slicePeaksForClip,
|
||||
} from '@/composables/useAudioWaveform'
|
||||
import { vTooltip } from '@/directives/tooltip'
|
||||
import { expect, suite, test } from './testRunner'
|
||||
import { beforeEach, expect, suite, test } from './testRunner'
|
||||
|
||||
interface MountedHandle {
|
||||
host: HTMLDivElement
|
||||
@@ -2544,6 +2545,101 @@ export function registerAllTests(): void {
|
||||
expect(ci.clipTrackId).toBe(t1.id)
|
||||
})
|
||||
|
||||
test('setClipInstancePosition pushes neighbours forward when the moved clip collides on the right', () => {
|
||||
const s = makeFreshStore()
|
||||
const c = s.addClip('P')
|
||||
c.duration = 2
|
||||
const t = s.scene.clipTracks![0]
|
||||
const a = s.addClipInstance(c.id, 0, t.id)! // 0..2
|
||||
const b = s.addClipInstance(c.id, 3, t.id)! // 3..5
|
||||
const cc = s.addClipInstance(c.id, 6, t.id)! // 6..8
|
||||
// Move `a` forward onto `b`. Expect `b` to shunt to 2, `cc` stays.
|
||||
expect(s.setClipInstancePosition(a.id, 2)).toBe(true)
|
||||
expect(a.startTime).toBe(2)
|
||||
expect(b.startTime).toBe(4)
|
||||
expect(cc.startTime).toBe(6)
|
||||
})
|
||||
|
||||
test('setClipInstancePosition pushes neighbours backward when the moved clip collides on the left', () => {
|
||||
const s = makeFreshStore()
|
||||
const c = s.addClip('P')
|
||||
c.duration = 2
|
||||
const t = s.scene.clipTracks![0]
|
||||
const a = s.addClipInstance(c.id, 0, t.id)! // 0..2
|
||||
const b = s.addClipInstance(c.id, 3, t.id)! // 3..5
|
||||
const cc = s.addClipInstance(c.id, 6, t.id)! // 6..8
|
||||
// Move `cc` backward onto `b`. Expect `b` to shunt left to 2 (abut
|
||||
// `a` end) and `a` shifts to 0 (already there).
|
||||
expect(s.setClipInstancePosition(cc.id, 4)).toBe(true)
|
||||
expect(cc.startTime).toBe(4)
|
||||
expect(b.startTime).toBe(2)
|
||||
expect(a.startTime).toBe(0)
|
||||
})
|
||||
|
||||
test('setClipInstancePosition clamps moved clip forward when backward cascade hits 0', () => {
|
||||
// Pack the lane: three 2s clips abutting from 0. Drag the right-
|
||||
// most left to 1 — there's no room for the chain at 0, so the
|
||||
// moved clip should land at the first fitting slot (4) instead.
|
||||
const s = makeFreshStore()
|
||||
const c = s.addClip('Pack')
|
||||
c.duration = 2
|
||||
const t = s.scene.clipTracks![0]
|
||||
const a = s.addClipInstance(c.id, 0, t.id)! // 0..2
|
||||
const b = s.addClipInstance(c.id, 2, t.id)! // 2..4
|
||||
const cc = s.addClipInstance(c.id, 4, t.id)! // 4..6
|
||||
expect(s.setClipInstancePosition(cc.id, 1)).toBe(true)
|
||||
expect(cc.startTime).toBe(4)
|
||||
expect(a.startTime).toBe(0)
|
||||
expect(b.startTime).toBe(2)
|
||||
})
|
||||
|
||||
test('setClipInstancePosition returns false for unknown instance or unknown track', () => {
|
||||
const s = makeFreshStore()
|
||||
expect(s.setClipInstancePosition('no-such-instance', 0)).toBe(false)
|
||||
const c = s.addClip('X')
|
||||
const t = s.scene.clipTracks![0]
|
||||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||||
expect(s.setClipInstancePosition(ci.id, 0, 'ghost-track')).toBe(false)
|
||||
})
|
||||
|
||||
test('duplicateClipInstance clones onto the same track, offset by visible duration', () => {
|
||||
const s = makeFreshStore()
|
||||
const c = s.addClip('D')
|
||||
c.duration = 3
|
||||
const t = s.scene.clipTracks![0]
|
||||
const ci = s.addClipInstance(c.id, 1, t.id)!
|
||||
s.setClipInstanceTransform(ci.id, { x: 42, scaleX: 2 })
|
||||
const newId = s.duplicateClipInstance(ci.id)!
|
||||
expect(typeof newId).toBe('string')
|
||||
expect(newId === ci.id).toBe(false)
|
||||
const copy = s.activeScene.clipInstances.find((x) => x.id === newId)!
|
||||
expect(copy.clipId).toBe(c.id)
|
||||
expect(copy.clipTrackId).toBe(t.id)
|
||||
expect(copy.startTime).toBe(1 + 3) // original.startTime + visible length
|
||||
expect(copy.x).toBe(42)
|
||||
expect(copy.scaleX).toBe(2)
|
||||
})
|
||||
|
||||
test('duplicateClipInstance honours trim when computing the offset', () => {
|
||||
const s = makeFreshStore()
|
||||
const c = s.addClip('D')
|
||||
c.duration = 4
|
||||
const t = s.scene.clipTracks![0]
|
||||
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||||
s.trimClipInstance(ci.id, { trimStart: 0.5, trimEnd: 0.5 })
|
||||
const newId = s.duplicateClipInstance(ci.id)!
|
||||
const copy = s.activeScene.clipInstances.find((x) => x.id === newId)!
|
||||
// visible length = 4 - 0.5 - 0.5 = 3 → copy lands at 3
|
||||
expect(copy.startTime).toBe(3)
|
||||
expect(copy.trimStart).toBe(0.5)
|
||||
expect(copy.trimEnd).toBe(0.5)
|
||||
})
|
||||
|
||||
test('duplicateClipInstance returns null for unknown id', () => {
|
||||
const s = makeFreshStore()
|
||||
expect(s.duplicateClipInstance('nope')).toBe(null)
|
||||
})
|
||||
|
||||
test('moveTrack reorders the unified stack (layer above clip track)', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 10, height: 10, fill: '#000' })
|
||||
@@ -4271,6 +4367,243 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — DocumentDrawer', () => {
|
||||
// The document-settings drawer slides down from under the topbar
|
||||
// when the project-name button is clicked — no local tab button,
|
||||
// no hover-to-peek. Opening is driven by the shared
|
||||
// useDocumentDrawer() composable; outside-click and ESC close it.
|
||||
// Document / canvas / bg-video / timing settings live here; the
|
||||
// project name is edited inline in the header.
|
||||
|
||||
beforeEach(async () => {
|
||||
// The drawer's open state + project name are module-scoped
|
||||
// (shared composables). Reset both so tests don't leak state.
|
||||
const { useDocumentDrawer } = await import(
|
||||
'@/composables/useDocumentDrawer'
|
||||
)
|
||||
const { useProjectName } = await import('@/composables/useProjectName')
|
||||
useDocumentDrawer().closeDrawer()
|
||||
useProjectName().projectName.value = null
|
||||
useProjectName().projectId.value = null
|
||||
})
|
||||
|
||||
test('renders the drawer panel starting closed, no tab button', async () => {
|
||||
makeFreshStore()
|
||||
const handle = mountComponent(DocumentDrawer, {})
|
||||
await nextTick()
|
||||
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
|
||||
expect(drawer).toBeTruthy()
|
||||
expect(drawer.classList.contains('open')).toBe(false)
|
||||
// The panel is always mounted (hidden via transform when closed).
|
||||
expect(handle.host.querySelector('.drawer-panel')).toBeTruthy()
|
||||
// The old tab trigger is gone — opening is composable-driven.
|
||||
expect(handle.host.querySelector('.drawer-tab')).toBe(null)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('toggle() via the composable flips .open on and off', async () => {
|
||||
makeFreshStore()
|
||||
const handle = mountComponent(DocumentDrawer, {})
|
||||
await nextTick()
|
||||
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
|
||||
const { useDocumentDrawer } = await import(
|
||||
'@/composables/useDocumentDrawer'
|
||||
)
|
||||
const dd = useDocumentDrawer()
|
||||
expect(drawer.classList.contains('open')).toBe(false)
|
||||
dd.toggle()
|
||||
await nextTick()
|
||||
expect(drawer.classList.contains('open')).toBe(true)
|
||||
dd.toggle()
|
||||
await nextTick()
|
||||
expect(drawer.classList.contains('open')).toBe(false)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('opening externally via useDocumentDrawer().openDrawer() flips the class', async () => {
|
||||
// Regression: the topbar project-name button opens the drawer
|
||||
// through the shared composable, so all triggers (topbar click,
|
||||
// programmatic openDrawer) must land on the same state.
|
||||
makeFreshStore()
|
||||
const handle = mountComponent(DocumentDrawer, {})
|
||||
await nextTick()
|
||||
const { useDocumentDrawer } = await import(
|
||||
'@/composables/useDocumentDrawer'
|
||||
)
|
||||
useDocumentDrawer().openDrawer()
|
||||
await nextTick()
|
||||
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
|
||||
expect(drawer.classList.contains('open')).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('panel exposes the canvas width / height fields bound to the store', async () => {
|
||||
const s = makeFreshStore()
|
||||
const handle = mountComponent(DocumentDrawer, {})
|
||||
await nextTick()
|
||||
const { useDocumentDrawer } = await import(
|
||||
'@/composables/useDocumentDrawer'
|
||||
)
|
||||
useDocumentDrawer().openDrawer()
|
||||
await nextTick()
|
||||
const inputs = handle.host.querySelectorAll(
|
||||
'.drawer-panel input[type="number"]',
|
||||
) as NodeListOf<HTMLInputElement>
|
||||
// Expected order: width, height, duration, fps.
|
||||
expect(inputs.length).toBe(4)
|
||||
expect(Number(inputs[0].value)).toBe(s.scene.canvas.width)
|
||||
expect(Number(inputs[1].value)).toBe(s.scene.canvas.height)
|
||||
// Edit width → store updates.
|
||||
inputs[0].value = '640'
|
||||
inputs[0].dispatchEvent(new Event('input', { bubbles: true }))
|
||||
inputs[0].dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(s.scene.canvas.width).toBe(640)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('preset buttons set canvas dimensions and reflect the active preset', async () => {
|
||||
const s = makeFreshStore()
|
||||
const handle = mountComponent(DocumentDrawer, {})
|
||||
await nextTick()
|
||||
const { useDocumentDrawer } = await import(
|
||||
'@/composables/useDocumentDrawer'
|
||||
)
|
||||
useDocumentDrawer().openDrawer()
|
||||
await nextTick()
|
||||
const presets = Array.from(
|
||||
handle.host.querySelectorAll('.preset'),
|
||||
) as HTMLButtonElement[]
|
||||
const fullHd = presets.find((b) => b.textContent?.includes('1920×1080'))
|
||||
expect(fullHd).toBeTruthy()
|
||||
fullHd!.click()
|
||||
await nextTick()
|
||||
expect(s.scene.canvas.width).toBe(1920)
|
||||
expect(s.scene.canvas.height).toBe(1080)
|
||||
expect(fullHd!.classList.contains('active')).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('mounting the drawer alone does not open it', async () => {
|
||||
// Regression: there is no local trigger inside the drawer itself
|
||||
// (no tab, no hover-peek). The only way in is via the composable,
|
||||
// so a freshly-mounted drawer must stay closed.
|
||||
makeFreshStore()
|
||||
const handle = mountComponent(DocumentDrawer, {})
|
||||
await nextTick()
|
||||
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
|
||||
expect(drawer.classList.contains('open')).toBe(false)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('duration + fps fields are bound to the store', async () => {
|
||||
const s = makeFreshStore()
|
||||
const handle = mountComponent(DocumentDrawer, {})
|
||||
await nextTick()
|
||||
const { useDocumentDrawer } = await import(
|
||||
'@/composables/useDocumentDrawer'
|
||||
)
|
||||
useDocumentDrawer().openDrawer()
|
||||
await nextTick()
|
||||
const inputs = handle.host.querySelectorAll(
|
||||
'.drawer-panel input[type="number"]',
|
||||
) as NodeListOf<HTMLInputElement>
|
||||
// Order is [width, height, duration, fps].
|
||||
const duration = inputs[2]
|
||||
const fps = inputs[3]
|
||||
expect(Number(duration.value)).toBe(s.scene.duration)
|
||||
expect(Number(fps.value)).toBe(s.scene.fps)
|
||||
duration.value = '12.5'
|
||||
duration.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
duration.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(s.scene.duration).toBeCloseTo(12.5, 5)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('project name input reflects the shared useProjectName ref', async () => {
|
||||
// The drawer mirrors the topbar's name label — editing lives in
|
||||
// the drawer and the topbar just displays. The input defaults to
|
||||
// empty (with a placeholder) while the name is null; as soon as
|
||||
// the source ref gets a value, the input follows via the watcher.
|
||||
makeFreshStore()
|
||||
const { useProjectName } = await import('@/composables/useProjectName')
|
||||
const { projectName } = useProjectName()
|
||||
const handle = mountComponent(DocumentDrawer, {})
|
||||
await nextTick()
|
||||
const input = handle.host.querySelector('.name-input') as HTMLInputElement
|
||||
expect(input).toBeTruthy()
|
||||
expect(input.value).toBe('')
|
||||
projectName.value = 'My Project'
|
||||
await nextTick()
|
||||
expect(input.value).toBe('My Project')
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('committing a name via blur writes it to useProjectName.projectName', async () => {
|
||||
makeFreshStore()
|
||||
const { useProjectName } = await import('@/composables/useProjectName')
|
||||
const { projectName } = useProjectName()
|
||||
projectName.value = 'Old Name'
|
||||
const handle = mountComponent(DocumentDrawer, {})
|
||||
await nextTick()
|
||||
const input = handle.host.querySelector('.name-input') as HTMLInputElement
|
||||
input.value = ' Fresh Name '
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
await nextTick()
|
||||
// Trimmed. No surrounding whitespace leaks into the stored name.
|
||||
expect(projectName.value).toBe('Fresh Name')
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('Enter commits the name; Escape reverts to the stored value', async () => {
|
||||
makeFreshStore()
|
||||
const { useProjectName } = await import('@/composables/useProjectName')
|
||||
const { projectName } = useProjectName()
|
||||
projectName.value = 'Stored'
|
||||
const handle = mountComponent(DocumentDrawer, {})
|
||||
await nextTick()
|
||||
const input = handle.host.querySelector('.name-input') as HTMLInputElement
|
||||
|
||||
// Enter → commit.
|
||||
input.value = 'Pressed Enter'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
// The handler blurs the input synchronously; blur fires commit.
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(projectName.value).toBe('Pressed Enter')
|
||||
|
||||
// Escape → revert the draft, no store change.
|
||||
input.value = 'Accidentally typed'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(projectName.value).toBe('Pressed Enter')
|
||||
expect(input.value).toBe('Pressed Enter')
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('empty / whitespace-only name is rejected; stored value snaps back', async () => {
|
||||
makeFreshStore()
|
||||
const { useProjectName } = await import('@/composables/useProjectName')
|
||||
const { projectName } = useProjectName()
|
||||
projectName.value = 'Keep Me'
|
||||
const handle = mountComponent(DocumentDrawer, {})
|
||||
await nextTick()
|
||||
const input = handle.host.querySelector('.name-input') as HTMLInputElement
|
||||
input.value = ' '
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(projectName.value).toBe('Keep Me')
|
||||
expect(input.value).toBe('Keep Me')
|
||||
handle.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — ActionEditor (jumpToScene)', () => {
|
||||
test('jumpToScene appears in the type dropdown', async () => {
|
||||
const s = makeFreshStore()
|
||||
@@ -4496,6 +4829,147 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — Timeline clip-instance context menu', () => {
|
||||
test('right-click on a clip-block opens a menu with Duplicate + Delete', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
const clip = s.addClip('CInst')
|
||||
clip.duration = 2
|
||||
const track = s.scene.clipTracks![0]
|
||||
s.addClipInstance(clip.id, 0, track.id)
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const block = handle.host.querySelector('.clip-block') as HTMLElement
|
||||
expect(block).toBeTruthy()
|
||||
block.dispatchEvent(
|
||||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 30, clientY: 40 }),
|
||||
)
|
||||
await nextTick()
|
||||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||||
const menu = menus[menus.length - 1]
|
||||
expect(menu).toBeTruthy()
|
||||
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[items.length - 1].classList.contains('danger')).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('menu Duplicate clones the clip instance on the same track', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
const clip = s.addClip('CInst')
|
||||
clip.duration = 2
|
||||
const track = s.scene.clipTracks![0]
|
||||
const ci = s.addClipInstance(clip.id, 1, track.id)!
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const block = handle.host.querySelector('.clip-block') as HTMLElement
|
||||
block.dispatchEvent(
|
||||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
|
||||
)
|
||||
await nextTick()
|
||||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||||
const dup = menus[menus.length - 1].querySelector('.kf-menu-item') as HTMLButtonElement
|
||||
dup.click()
|
||||
await nextTick()
|
||||
const siblings = s.activeScene.clipInstances.filter((x) => x.clipTrackId === track.id)
|
||||
expect(siblings).toHaveLength(2)
|
||||
const clone = siblings.find((x) => x.id !== ci.id)!
|
||||
expect(clone.clipId).toBe(clip.id)
|
||||
expect(clone.startTime).toBe(1 + 2)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('menu Delete removes the clip instance', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
const clip = s.addClip('CInst')
|
||||
clip.duration = 2
|
||||
const track = s.scene.clipTracks![0]
|
||||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const block = handle.host.querySelector('.clip-block') as HTMLElement
|
||||
block.dispatchEvent(
|
||||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
|
||||
)
|
||||
await nextTick()
|
||||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||||
const del = menus[menus.length - 1].querySelector('.kf-menu-item.danger') as HTMLButtonElement
|
||||
del.click()
|
||||
await nextTick()
|
||||
expect(s.activeScene.clipInstances.find((x) => x.id === ci.id)).toBe(undefined)
|
||||
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()
|
||||
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 2 })
|
||||
const track = s.addAudioTrack('FX')
|
||||
s.addAudioClip(asset.id, 0, track.id)
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const block = handle.host.querySelector('.audio-clip-block') as HTMLElement
|
||||
expect(block).toBeTruthy()
|
||||
block.dispatchEvent(
|
||||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 30, clientY: 40 }),
|
||||
)
|
||||
await nextTick()
|
||||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||||
const menu = menus[menus.length - 1]
|
||||
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[items.length - 1].classList.contains('danger')).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('menu Duplicate clones the audio clip on the same track', async () => {
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 2 })
|
||||
const track = s.addAudioTrack('FX')
|
||||
const clip = s.addAudioClip(asset.id, 1, track.id)!
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const block = handle.host.querySelector('.audio-clip-block') as HTMLElement
|
||||
block.dispatchEvent(
|
||||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
|
||||
)
|
||||
await nextTick()
|
||||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||||
const dup = menus[menus.length - 1].querySelector('.kf-menu-item') as HTMLButtonElement
|
||||
dup.click()
|
||||
await nextTick()
|
||||
const siblings = s.activeScene.audioClips.filter((c) => c.audioTrackId === track.id)
|
||||
expect(siblings).toHaveLength(2)
|
||||
const clone = siblings.find((c) => c.id !== clip.id)!
|
||||
expect(clone.audioAssetId).toBe(asset.id)
|
||||
expect(clone.startTime).toBe(1 + 2)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('menu Delete removes the audio clip', async () => {
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 2 })
|
||||
const track = s.addAudioTrack('FX')
|
||||
const clip = s.addAudioClip(asset.id, 0, track.id)!
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const block = handle.host.querySelector('.audio-clip-block') as HTMLElement
|
||||
block.dispatchEvent(
|
||||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
|
||||
)
|
||||
await nextTick()
|
||||
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
|
||||
const del = menus[menus.length - 1].querySelector('.kf-menu-item.danger') as HTMLButtonElement
|
||||
del.click()
|
||||
await nextTick()
|
||||
expect(s.activeScene.audioClips.find((c) => c.id === clip.id)).toBe(undefined)
|
||||
handle.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — Timeline audio track', () => {
|
||||
test('audio track renders as an .audio-track lane with one block per clip', async () => {
|
||||
const s = makeFreshStore()
|
||||
@@ -6244,6 +6718,68 @@ export function registerAllTests(): void {
|
||||
expect(s.setAudioClipPosition(clip.id, 0, 'ghost-track')).toBe(false)
|
||||
})
|
||||
|
||||
test('setAudioClipPosition pushes neighbours backward when moved clip collides on the left', () => {
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||||
const track = s.addAudioTrack()
|
||||
const early = s.addAudioClip(asset.id, 0, track.id)! // 0..2
|
||||
const mid = s.addAudioClip(asset.id, 3, track.id)! // 3..5
|
||||
const late = s.addAudioClip(asset.id, 6, track.id)! // 6..8
|
||||
// Drag `late` back onto `mid`. `mid` must shunt left to 2 (abuts
|
||||
// `early` end), `early` sits already at 0.
|
||||
expect(s.setAudioClipPosition(late.id, 4)).toBe(true)
|
||||
expect(late.startTime).toBe(4)
|
||||
expect(mid.startTime).toBe(2)
|
||||
expect(early.startTime).toBe(0)
|
||||
})
|
||||
|
||||
test('setAudioClipPosition clamps moved clip forward when backward cascade hits 0', () => {
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||||
const track = s.addAudioTrack()
|
||||
const a = s.addAudioClip(asset.id, 0, track.id)! // 0..2
|
||||
const b = s.addAudioClip(asset.id, 2, track.id)! // 2..4
|
||||
const cc = s.addAudioClip(asset.id, 4, track.id)! // 4..6
|
||||
// Nothing fits to the left of 4 — drag to 1 clamps cc forward to 4.
|
||||
expect(s.setAudioClipPosition(cc.id, 1)).toBe(true)
|
||||
expect(cc.startTime).toBe(4)
|
||||
expect(a.startTime).toBe(0)
|
||||
expect(b.startTime).toBe(2)
|
||||
})
|
||||
|
||||
test('duplicateAudioClip clones onto the same track, offset by visible duration', () => {
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||||
const track = s.addAudioTrack()
|
||||
const clip = s.addAudioClip(asset.id, 1, track.id)!
|
||||
const newId = s.duplicateAudioClip(clip.id)!
|
||||
expect(typeof newId).toBe('string')
|
||||
expect(newId === clip.id).toBe(false)
|
||||
const copy = s.activeScene.audioClips.find((c) => c.id === newId)!
|
||||
expect(copy.audioAssetId).toBe(asset.id)
|
||||
expect(copy.audioTrackId).toBe(track.id)
|
||||
expect(copy.startTime).toBe(1 + 2)
|
||||
})
|
||||
|
||||
test('duplicateAudioClip honours trim when computing the offset', () => {
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 4 })
|
||||
const track = s.addAudioTrack()
|
||||
const clip = s.addAudioClip(asset.id, 0, track.id)!
|
||||
s.trimAudioClip(clip.id, { trimStart: 0.5, trimEnd: 0.5 })
|
||||
const newId = s.duplicateAudioClip(clip.id)!
|
||||
const copy = s.activeScene.audioClips.find((c) => c.id === newId)!
|
||||
// visible length = 4 - 0.5 - 0.5 = 3
|
||||
expect(copy.startTime).toBe(3)
|
||||
expect(copy.trimStart).toBe(0.5)
|
||||
expect(copy.trimEnd).toBe(0.5)
|
||||
})
|
||||
|
||||
test('duplicateAudioClip returns null for unknown id', () => {
|
||||
const s = makeFreshStore()
|
||||
expect(s.duplicateAudioClip('nope')).toBe(null)
|
||||
})
|
||||
|
||||
test('normalizeScenePage drops orphan audio clips pointing at a missing track', () => {
|
||||
const s = makeFreshStore()
|
||||
const asset = addAsset(s)
|
||||
|
||||
Reference in new issue
Block a user