1.2.9: global seconds/frames toggle + sane number-input widths/rounding
The Timeline ruler's existing s/fr toggle (store.editor.timelineDisplayMode)
now drives every time display and time input across the editor. Mixing
"frames here, seconds there" was inconsistent — pick the unit once, see
it everywhere.
New composable (src/composables/useTimeFormat.ts):
- formatTime(seconds, opts) → "1.42s" or "42 fr" (short variant drops
trailing zeros for ruler-style ticks).
- timeToInputValue / inputValueToSeconds — paired conversions used at
every <input type="number"> bound to a time. Storage stays in seconds
(renderer + .ruw files unchanged); conversion happens at the UI edge.
- inputStep ('0.01' / '1') and unitLabel ('seconds' / 'frames') for
consistent input attributes / labels.
Surfaces switched over:
- App.vue topbar playhead + duration.
- PropertiesPanel keyframe-here label (`@ 1.50s` → `@ 45 fr`).
- FullscreenPreview time label.
- AssetSidebar clip + audio asset durations.
- Timeline audio clip + clip trim tooltips (route through formatKfTime,
which already honoured the toggle).
- DocumentDrawer scene duration input — step + min flip with the mode,
label gets a (seconds)/(frames) suffix.
- ScriptFrameDialog time input — same flip.
- ActionEditor jumpTo time input — same flip. (jumpToFrame stays as
frames since "frame" is its semantic, not a unit choice.)
- Kaleido offset on ClipInstance — now follows the global toggle too,
was hardcoded frames before. The frames-by-default UX comes from
the user defaulting to 'frames' on the ruler; switching to seconds
shows the same offset in seconds without losing precision.
Number-input audit (cleaning up "ewig viele Nachkommastellen"):
- ScriptFrameDialog time: rounded display to ≤3 decimals at the edge.
- ActionEditor jumpTo time: same.
- PropertiesPanel kaleido pivot X/Y (ClipInstance): rounded to int.
- RangeSlider strokeWidth format: snapped to 0.5 increments.
Number-input widths (capping the panel sprawl):
- PropertiesPanel inputs: max-width 96px + tabular-nums; .row inputs
(side-by-side pivot X/Y, transform X/Y) keep flex-share.
- ActionEditor .params input[type='number']: explicit 96px.
- ScriptFrameDialog .row input[type='number']: explicit 96px.
- DocumentDrawer + ExportDialog: already had explicit widths.
Tests (6 new under "time format — global seconds/frames toggle"):
- useTimeFormat composable roundtrips through both modes at fps=30.
- PropertiesPanel keyframe label flips on mode change.
- DocumentDrawer duration input flips step + value (2s ↔ 60fr).
- ActionEditor jumpTo flips step + value (1s ↔ 24fr at fps=24).
- Kaleido offset display switches between modes for the same stored
value (0.5s ↔ 15fr).
- Kaleido offset write-back: typing "30" in frames mode at fps=30
stores exactly 1.0s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
80e1510b53
commit
7d81a50e74
13 files changed
+373
-46
No files matched your search
@@ -15565,4 +15565,206 @@ export function registerAllTests(): void {
|
||||
expect(layerActiveAt(a, 2)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
suite('time format — global seconds/frames toggle', () => {
|
||||
test('formatTime + timeToInputValue + inputValueToSeconds switch with the mode', async () => {
|
||||
const { useTimeFormat } = await import('@/composables/useTimeFormat')
|
||||
const s = makeFreshStore()
|
||||
s.scene.fps = 30
|
||||
const tf = useTimeFormat()
|
||||
// Seconds mode (default).
|
||||
s.editor.timelineDisplayMode = 'seconds'
|
||||
expect(tf.formatTime(1.5)).toBe('1.50s')
|
||||
expect(tf.formatTime(2, { short: true })).toBe('2s')
|
||||
expect(tf.timeToInputValue(1.4181818)).toBeCloseTo(1.42, 5)
|
||||
expect(tf.inputValueToSeconds(1.42)).toBeCloseTo(1.42, 5)
|
||||
expect(tf.inputStep.value).toBe('0.01')
|
||||
expect(tf.unitLabel.value).toBe('seconds')
|
||||
// Frames mode.
|
||||
s.editor.timelineDisplayMode = 'frames'
|
||||
expect(tf.formatTime(0.5)).toBe('15 fr') // 0.5 * 30 fps
|
||||
expect(tf.timeToInputValue(0.5)).toBe(15)
|
||||
expect(tf.inputValueToSeconds(15)).toBeCloseTo(0.5, 5)
|
||||
expect(tf.inputStep.value).toBe('1')
|
||||
expect(tf.unitLabel.value).toBe('frames')
|
||||
// Roundtrip across the boundary holds at frame-aligned times.
|
||||
const sec = 0.4 // 12 frames @ 30fps
|
||||
const back = tf.inputValueToSeconds(tf.timeToInputValue(sec))
|
||||
expect(back).toBeCloseTo(0.4, 5)
|
||||
})
|
||||
|
||||
test('PropertiesPanel keyframe-here label switches with the global mode', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.fps = 30
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const layer = s.addLayer(a.id)!
|
||||
s.selectedLayerId = layer.id
|
||||
s.selectedLayerIds = [layer.id]
|
||||
s.setPlayhead(1.5)
|
||||
s.editor.timelineDisplayMode = 'seconds'
|
||||
const handle = mountComponent(PropertiesPanel, {})
|
||||
try {
|
||||
await nextTick()
|
||||
const kfLabel = handle.host.querySelector('.kf-time') as HTMLElement | null
|
||||
expect(!!kfLabel).toBe(true)
|
||||
expect((kfLabel!.textContent ?? '').includes('1.50s')).toBe(true)
|
||||
// Flip the mode → label re-renders in frames.
|
||||
s.editor.timelineDisplayMode = 'frames'
|
||||
await nextTick()
|
||||
expect((kfLabel!.textContent ?? '').includes('45 fr')).toBe(true)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('DocumentDrawer duration input flips step + value when the mode changes', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.fps = 30
|
||||
s.scene.duration = 2
|
||||
// The drawer must be open for its template to mount.
|
||||
const drawer = (await import('@/composables/useDocumentDrawer')).useDocumentDrawer()
|
||||
drawer.open.value = true
|
||||
s.editor.timelineDisplayMode = 'seconds'
|
||||
const handle = mountComponent(DocumentDrawer, {})
|
||||
try {
|
||||
await nextTick()
|
||||
// Find the duration input — the only number input that holds 2.
|
||||
const inputs = Array.from(handle.host.querySelectorAll<HTMLInputElement>(
|
||||
'input[type="number"]',
|
||||
))
|
||||
const find = (val: number) => inputs.find((el) => Number(el.value) === val)
|
||||
let input = find(2)!
|
||||
expect(!!input).toBe(true)
|
||||
expect(input.step).toBe('0.01')
|
||||
// Flip global mode → re-finds the input by its new frame value.
|
||||
s.editor.timelineDisplayMode = 'frames'
|
||||
await nextTick()
|
||||
const inputs2 = Array.from(handle.host.querySelectorAll<HTMLInputElement>(
|
||||
'input[type="number"]',
|
||||
))
|
||||
input = inputs2.find((el) => Number(el.value) === 60)! // 2s * 30 fps
|
||||
expect(!!input).toBe(true)
|
||||
expect(input.step).toBe('1')
|
||||
} finally {
|
||||
handle.unmount()
|
||||
drawer.open.value = false
|
||||
}
|
||||
})
|
||||
|
||||
test('ActionEditor jumpTo input flips step + value when the mode changes', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.fps = 24
|
||||
s.editor.timelineDisplayMode = 'seconds'
|
||||
const actions: Action[] = [{ type: 'jumpTo', time: 1 }]
|
||||
const handle = mountComponent(ActionEditor, { actions })
|
||||
try {
|
||||
await nextTick()
|
||||
const input = handle.host.querySelector(
|
||||
'input[type="number"]',
|
||||
) as HTMLInputElement | null
|
||||
expect(!!input).toBe(true)
|
||||
expect(Number(input!.value)).toBe(1)
|
||||
expect(input!.step).toBe('0.01')
|
||||
s.editor.timelineDisplayMode = 'frames'
|
||||
await nextTick()
|
||||
const input2 = handle.host.querySelector(
|
||||
'input[type="number"]',
|
||||
) as HTMLInputElement | null
|
||||
expect(Number(input2!.value)).toBe(24) // 1s * 24 fps
|
||||
expect(input2!.step).toBe('1')
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('PropertiesPanel kaleido offset follows the global mode', async () => {
|
||||
// Regression: the user's complaint that started this work — the
|
||||
// kaleido offset was a hardcoded "frames" field while every
|
||||
// other time display lived in seconds. Now it follows the
|
||||
// ruler toggle like the rest of the surfaces.
|
||||
const s = makeFreshStore()
|
||||
s.scene.fps = 30
|
||||
const clip = s.addClip('K')
|
||||
const track = s.addClipTrack()
|
||||
const inst = s.addClipInstance(clip.id, 0, track.id)!
|
||||
s.setClipInstanceKaleido(inst.id, {
|
||||
kaleido: true,
|
||||
kaleidoCount: 4,
|
||||
kaleidoOffset: 0.5, // 15 frames at 30fps
|
||||
})
|
||||
s.selectClipInstance(inst.id)
|
||||
s.editor.timelineDisplayMode = 'frames'
|
||||
const handle = mountComponent(PropertiesPanel, {})
|
||||
try {
|
||||
await nextTick()
|
||||
// The Kaleido section starts collapsed — open it before
|
||||
// hunting for the offset input.
|
||||
const kHeader = Array.from(
|
||||
handle.host.querySelectorAll<HTMLButtonElement>('.group-header'),
|
||||
).find((b) => (b.textContent ?? '').toLowerCase().includes('kaleido'))
|
||||
expect(!!kHeader).toBe(true)
|
||||
// Open if collapsed.
|
||||
if (!handle.host.querySelector('.group-body input[type="number"]')) {
|
||||
kHeader!.click()
|
||||
await nextTick()
|
||||
}
|
||||
// The offset input is the third number input in the section
|
||||
// (after Repeat + Pivot inputs come AFTER it; offset is right
|
||||
// after Repeat). Filter by step="1" + value matching frames.
|
||||
const inputs = Array.from(
|
||||
handle.host.querySelectorAll<HTMLInputElement>('.group-body input[type="number"]'),
|
||||
)
|
||||
const offsetInput = inputs.find((el) => Number(el.value) === 15)
|
||||
expect(!!offsetInput).toBe(true)
|
||||
expect(offsetInput!.step).toBe('1')
|
||||
// Flip global mode → seconds. Same offset reads as 0.5.
|
||||
s.editor.timelineDisplayMode = 'seconds'
|
||||
await nextTick()
|
||||
const inputs2 = Array.from(
|
||||
handle.host.querySelectorAll<HTMLInputElement>('.group-body input[type="number"]'),
|
||||
)
|
||||
const offsetInput2 = inputs2.find((el) => Number(el.value) === 0.5)
|
||||
expect(!!offsetInput2).toBe(true)
|
||||
expect(offsetInput2!.step).toBe('0.01')
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('PropertiesPanel kaleido offset writes back through the mode', async () => {
|
||||
// Typing "30" into the input in frames mode at 30fps stores
|
||||
// exactly 1.0s; switching to seconds and typing "0.5" stores 0.5.
|
||||
const s = makeFreshStore()
|
||||
s.scene.fps = 30
|
||||
const clip = s.addClip('K')
|
||||
const track = s.addClipTrack()
|
||||
const inst = s.addClipInstance(clip.id, 0, track.id)!
|
||||
s.setClipInstanceKaleido(inst.id, { kaleido: true, kaleidoCount: 4, kaleidoOffset: 0.5 })
|
||||
s.selectClipInstance(inst.id)
|
||||
s.editor.timelineDisplayMode = 'frames'
|
||||
const handle = mountComponent(PropertiesPanel, {})
|
||||
try {
|
||||
await nextTick()
|
||||
const kHeader = Array.from(
|
||||
handle.host.querySelectorAll<HTMLButtonElement>('.group-header'),
|
||||
).find((b) => (b.textContent ?? '').toLowerCase().includes('kaleido'))
|
||||
if (!handle.host.querySelector('.group-body input[type="number"]')) {
|
||||
kHeader!.click()
|
||||
await nextTick()
|
||||
}
|
||||
const inputs = Array.from(
|
||||
handle.host.querySelectorAll<HTMLInputElement>('.group-body input[type="number"]'),
|
||||
)
|
||||
const offsetInput = inputs.find((el) => Number(el.value) === 15)!
|
||||
// Write 30 frames → 1s.
|
||||
offsetInput.value = '30'
|
||||
offsetInput.dispatchEvent(new Event('change'))
|
||||
await nextTick()
|
||||
const stored = s.scene.clipInstances!.find((c) => c.id === inst.id)!
|
||||
expect(stored.kaleidoOffset).toBeCloseTo(1, 5)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user