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:
thandClaude Opus 4.7 committed 2026-04-26 20:01:54 +02:00
1 parent 80e1510b53
commit 7d81a50e74
13 files changed
+373 -46

No files matched your search

+37 -20
View File
@@ -21,6 +21,9 @@ import RangeSlider from './RangeSlider.vue'
import ActionEditor from './ActionEditor.vue'
import FontPicker from './FontPicker.vue'
import { t } from '@/i18n'
import { useTimeFormat } from '@/composables/useTimeFormat'
const { formatTime, timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
const SECTION_STORAGE_KEY = 'animatr:propSections'
@@ -177,16 +180,18 @@ const selectedClipInstance = computed(() => {
const ciKaleidoOn = computed(() => !!selectedClipInstance.value?.kaleido)
const ciKaleidoCount = computed(() => selectedClipInstance.value?.kaleidoCount ?? 6)
// Offset is stored as seconds (the renderer's unit) but presented to
// the user as frames — that's how animators reason about staggered
// playback. Conversion uses the scene's fps.
const ciKaleidoOffsetFrames = computed(() => {
const sec = selectedClipInstance.value?.kaleidoOffset ?? 0
const fps = Math.max(1, store.scene.fps || 30)
return Math.round(sec * fps)
})
const ciPivotX = computed(() => selectedClipInstance.value?.kaleidoPivotX ?? selectedClipInstance.value?.x ?? 0)
const ciPivotY = computed(() => selectedClipInstance.value?.kaleidoPivotY ?? selectedClipInstance.value?.y ?? 0)
// Offset is stored as seconds (the renderer's unit) but presented in
// whichever unit the user picked globally (Timeline ruler toggle) —
// frames or seconds. Conversion at the UI boundary via useTimeFormat.
const ciKaleidoOffsetInput = computed(() =>
timeToInputValue(selectedClipInstance.value?.kaleidoOffset ?? 0),
)
const ciPivotX = computed(() =>
Math.round(selectedClipInstance.value?.kaleidoPivotX ?? selectedClipInstance.value?.x ?? 0),
)
const ciPivotY = computed(() =>
Math.round(selectedClipInstance.value?.kaleidoPivotY ?? selectedClipInstance.value?.y ?? 0),
)
const ciRotateCopies = computed(() => selectedClipInstance.value?.kaleidoRotateCopies !== false)
function setCiKaleido(patch: Parameters<typeof store.setClipInstanceKaleido>[1]) {
@@ -195,10 +200,8 @@ function setCiKaleido(patch: Parameters<typeof store.setClipInstanceKaleido>[1])
store.setClipInstanceKaleido(inst.id, patch)
}
function setCiKaleidoOffsetFrames(frames: number) {
if (!Number.isFinite(frames)) return
const fps = Math.max(1, store.scene.fps || 30)
setCiKaleido({ kaleidoOffset: Math.round(frames) / fps })
function setCiKaleidoOffset(value: number) {
setCiKaleido({ kaleidoOffset: inputValueToSeconds(value) })
}
const kaleidoOn = computed(() => !!selected.value?.kaleido)
@@ -523,7 +526,7 @@ function deleteSelection() {
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
<span class="kf-label">
{{ hasKeyframeHere ? t('properties.keyframeLabel') : t('properties.interpolated') }}
<span class="kf-time">@ {{ store.playhead.toFixed(2) }}s</span>
<span class="kf-time">@ {{ formatTime(store.playhead) }}</span>
</span>
<button v-if="hasKeyframeHere" class="kf-btn" v-tooltip="t('properties.removeKeyframe')" @click="removeKeyframeHere">
<PhX :size="12" weight="bold" />
@@ -646,7 +649,7 @@ function deleteSelection() {
:min="0"
:max="32"
:step="0.5"
:format="(v: number) => `${v}px`"
:format="(v: number) => `${Math.round(v * 2) / 2}px`"
/>
</label>
<label class="slider-label">
@@ -859,12 +862,12 @@ function deleteSelection() {
/>
</label>
<label v-if="ciKaleidoOn">
{{ t('properties.kaleido.offset') }}
{{ t('properties.kaleido.offset') }} ({{ unitLabel }})
<input
type="number"
step="1"
:value="ciKaleidoOffsetFrames"
@change="setCiKaleidoOffsetFrames(Number(($event.target as HTMLInputElement).value))"
:step="inputStep"
:value="ciKaleidoOffsetInput"
@change="setCiKaleidoOffset(Number(($event.target as HTMLInputElement).value))"
/>
</label>
<div v-if="ciKaleidoOn" class="row">
@@ -946,6 +949,20 @@ input {
letter-spacing: 0;
}
/* Number inputs hold short numeric values. Capping the width keeps
them from stretching to the panel's full ~260px and aligns the
panel visually with the more compact slider controls. Inputs
inside a `.row` (two side-by-side labels) still split that row
evenly because their label parent is `flex: 1 1 0`. */
input[type='number'] {
max-width: 96px;
font-variant-numeric: tabular-nums;
}
.row input[type='number'] {
max-width: none;
}
.row {
display: flex;
gap: 8px;