0.8.1: dirty-state + Cmd/Ctrl+S, fullscreen plays to OUT, UX fixes

- Unsaved-changes indicator: a small accent dot appears on the save
  button whenever the scene has changed since the last save/load.
  Save/load/new all mark the project clean on next tick.
- Cmd/Ctrl+S saves the project (reuses the same flow as the button;
  prompts for a name on first save). Tooltip now mentions the
  shortcut.
- Fullscreen preview plays to the OUT marker (or scene.duration when
  no OUT is set) instead of looping at the last keyframe. Dropped
  the runtime's effectiveEnd helper; RUNTIME_VERSION 13 → 14.
- "Still frame" keyframes no longer disable easing. A held kf's
  easing still drives the segment INTO it from the previous kf, so
  you can ease into a pause. Only the first kf disables easing
  (nothing precedes it). Dropped `timeline.holdDisabledHint`.
- Settings dialog: hint text 11px → 13px (and dropped italic) so
  the inline help is readable; section headings 10px → 13px.
- Canvas stage shadow: `shadowCfg` always emits explicit values plus
  a `shadowEnabled` flag instead of returning `{}`. Konva's default
  shadowOpacity is 1, so bailing with `{}` could leave stale shadow
  attrs on the shape. Preview and editor now gate on the same
  condition deterministically.
- Sidebar empty-state copy: "No clips" → "No clips yet" to match
  "No buttons yet".

Regression tests: runtime rangeOut fallback is scene.duration (not
last kf); sidebar empty-states both contain "yet"; the
holdDisabledHint key is gone; saveProject tooltips mention the
shortcut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-21 22:00:35 +02:00
1 parent 1e89ea86e0
commit 82ee5c98d0
10 files changed
+133 -48

No files matched your search

+59 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
PhArrowClockwise,
PhArrowCounterClockwise,
@@ -132,12 +132,29 @@ const loadError = ref<string | null>(null)
const savedToast = ref<string | null>(null)
const currentProjectId = ref<string | null>(null)
const currentProjectName = ref<string | null>(null)
// Dirty = scene has changed since the last save/load. Deep-watches
// store.scene; save/load paths clear it after the next tick so the
// replacement itself doesn't leave the project flagged as unsaved.
const isDirty = ref(false)
usePlaybackLoop()
const store = useSceneStore()
const clipart = useClipartStore()
const history = useHistory()
watch(
() => store.scene,
() => {
isDirty.value = true
},
{ deep: true },
)
async function markClean() {
await nextTick()
isDirty.value = false
}
function rewind() {
store.setPlayhead(store.playbackIn)
}
@@ -168,6 +185,7 @@ async function saveProject() {
currentProjectName.value = rec.name
setLastOpenedId(rec.id)
showSavedToast(t('app.savedToast', { name: rec.name }))
await markClean()
}
async function renameProject() {
@@ -225,6 +243,7 @@ async function importFromFile() {
currentProjectId.value = null
currentProjectName.value = file.name.replace(/\.[^.]+$/, '')
loadError.value = null
await markClean()
} catch (err) {
const msg =
err instanceof ProjectFileError ? err.message : (err as Error).message || String(err)
@@ -235,7 +254,7 @@ async function importFromFile() {
}
}
function openFromRecent(project: StoredProject) {
async function openFromRecent(project: StoredProject) {
const cloned = JSON.parse(JSON.stringify(project.scene))
store.loadScene(cloned)
history.resetTo(cloned)
@@ -243,15 +262,17 @@ function openFromRecent(project: StoredProject) {
currentProjectName.value = project.name
setLastOpenedId(project.id)
loadError.value = null
await markClean()
}
function startNewProject() {
async function startNewProject() {
const blank = createBlankScene()
store.loadScene(blank)
history.resetTo(blank)
currentProjectId.value = null
currentProjectName.value = null
loadError.value = null
await markClean()
}
function showSavedToast(msg: string) {
@@ -308,6 +329,11 @@ function onKeyDown(e: KeyboardEvent) {
history.redo()
return
}
if (key === 's' && !e.shiftKey && !e.altKey) {
e.preventDefault()
void saveProject()
return
}
}
if (!isEditable(e.target) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
const key = e.key.toLowerCase()
@@ -453,8 +479,14 @@ onBeforeUnmount(() => {
<button class="icon-btn" :title="t('app.recentProjects')" @click="welcomeOpen = true">
<PhFolderOpen :size="16" />
</button>
<button class="icon-btn" :title="t('app.saveProject')" @click="saveProject">
<button
class="icon-btn save-btn"
:class="{ dirty: isDirty }"
:title="isDirty ? t('app.saveProjectDirty') : t('app.saveProject')"
@click="saveProject"
>
<PhFloppyDisk :size="16" />
<span v-if="isDirty" class="dirty-dot" aria-hidden="true"></span>
</button>
<button class="icon-btn" :title="t('app.documentSettings')" @click="settingsOpen = true">
<PhGearSix :size="16" />
@@ -656,6 +688,29 @@ onBeforeUnmount(() => {
color: var(--text);
}
/* Unsaved-changes indicator on the save button. A small accent dot
pinned to the top-right of the icon, visible at a glance. `position:
relative` on the button lets the dot anchor inside it. */
.save-btn {
position: relative;
}
.save-btn.dirty {
color: var(--text);
}
.dirty-dot {
position: absolute;
top: 4px;
right: 4px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 2px var(--panel);
pointer-events: none;
}
.sidebar {
grid-area: side;
background: var(--panel);
+6 -2
View File
@@ -840,11 +840,14 @@ const stackItems = computed<StackItem[]>(() => {
})
function shadowCfg(props: AnimatableProps): Record<string, unknown> {
// Konva's default shadowOpacity is 1, so returning {} for the "no
// shadow" case isn't enough — the shape would reuse whatever values
// were set on a previous render. Always emit explicit values and let
// `shadowEnabled` be the single source of truth. The gate matches
// the canvas/preview path: opacity > 0 AND some blur or distance.
const opacity = props.shadowOpacity ?? 0
if (opacity <= 0) return {}
const blur = props.shadowBlur ?? 0
const distance = props.shadowDistance ?? 0
if (blur <= 0 && distance <= 0) return {}
const angle = props.shadowAngle ?? 90
const rad = (angle * Math.PI) / 180
return {
@@ -853,6 +856,7 @@ function shadowCfg(props: AnimatableProps): Record<string, unknown> {
shadowOpacity: opacity,
shadowOffsetX: Math.cos(rad) * distance,
shadowOffsetY: Math.sin(rad) * distance,
shadowEnabled: opacity > 0 && (blur > 0 || distance > 0),
}
}
+6 -6
View File
@@ -454,11 +454,11 @@ section {
section h3 {
margin: 0;
font-size: 10px;
font-size: 13px;
text-transform: uppercase;
color: var(--text-dim);
color: var(--text-dim-strong);
letter-spacing: 0.08em;
font-weight: 500;
font-weight: 600;
}
.row {
@@ -558,9 +558,9 @@ input[type='number'] {
}
.hint {
font-size: 11px;
color: var(--text-dim);
font-style: italic;
font-size: 13px;
line-height: 1.45;
color: var(--text-dim-strong);
margin: 0;
}
+3 -4
View File
@@ -2825,13 +2825,13 @@ onBeforeUnmount(() => {
v-for="opt in EASING_OPTIONS"
:key="opt.value"
class="kf-menu-item"
:class="{ active: menu.currentEasing === opt.value && !menu.isHeld }"
:disabled="menu.isFirst || menu.isHeld"
:class="{ active: menu.currentEasing === opt.value }"
:disabled="menu.isFirst"
@click="applyEasing(opt.value)"
>
<span class="kf-menu-icon">
<PhCheck
v-if="menu.currentEasing === opt.value && !menu.isHeld"
v-if="menu.currentEasing === opt.value"
:size="12"
weight="bold"
/>
@@ -2839,7 +2839,6 @@ onBeforeUnmount(() => {
{{ opt.label }}
</button>
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
<div v-else-if="menu.isHeld" class="kf-menu-note">{{ t('timeline.holdDisabledHint') }}</div>
</div>
<div class="kf-menu-divider"></div>
<button
+43
View File
@@ -685,6 +685,20 @@ export function registerAllTests(): void {
expect(r).toContain('shadowColor')
expect(r).toContain('out.blur')
})
test('runtime rangeOut falls back to scene.duration (not last keyframe)', () => {
// Regression: the runtime used to fall back to `effectiveEnd(scene)`
// — the time of the last keyframe — when scene.outTime was unset.
// That made fullscreen preview loop at the last kf, ignoring any
// blank tail before the OUT mark and any still-frame-past-end
// extension. The fallback is now scene.duration, matching the
// editor's `playbackOut` getter.
const r = generateRuntimeScript()
// The old helper must be gone entirely.
expect(r.indexOf('effectiveEnd')).toBe(-1)
// And the current fallback must be scene.duration.
expect(r).toContain('scene.outTime==null?scene.duration:scene.outTime')
})
})
suite('playback — transport', () => {
@@ -4350,6 +4364,35 @@ export function registerAllTests(): void {
expect(title === 'timeline.renameScriptTrackDialog.title').toBe(false)
expect(confirm === 'timeline.renameScriptTrackDialog.confirm').toBe(false)
})
test('sidebar noClips / noButtons empty-states both suggest "yet"', () => {
// Regression: the clip library used to read "No clips" while the
// button library read "No buttons yet". Minor inconsistency that
// the user called out — both should suggest "more coming", not
// "there will never be any".
expect(i18nT('sidebar.noClips')).toContain('yet')
expect(i18nT('sidebar.noButtons')).toContain('yet')
})
test('holdDisabledHint key is gone (held kfs keep easing now)', () => {
// Regression: "Still frame — easing disabled" used to appear in
// the keyframe context menu and the easing buttons were grayed
// out. A held kf's easing still matters for the segment INTO it
// from the previous keyframe, so the restriction was wrong.
// Pin that the note is no longer emitted.
const v = i18nT('timeline.holdDisabledHint')
expect(v === 'timeline.holdDisabledHint').toBe(true)
})
test('app.saveProject tooltip mentions the Ctrl/Cmd+S shortcut', () => {
// Regression: Cmd/Ctrl+S was added as a keyboard save shortcut
// alongside the dirty-state dot. Surface it in the tooltip so
// the shortcut is discoverable; assert both the idle and the
// unsaved-changes variant stay in sync with the handler.
expect(i18nT('app.saveProject')).toContain('Ctrl/Cmd+S')
expect(i18nT('app.saveProjectDirty')).toContain('Ctrl/Cmd+S')
expect(i18nT('app.saveProjectDirty')).toContain('unsaved')
})
})
suite('timeline — scrub-handle obscured logic', () => {
+3 -3
View File
@@ -7,7 +7,8 @@ export const en = {
play: 'Play (Space)',
stop: 'Stop',
recentProjects: 'Recent projects',
saveProject: 'Save project (browser storage)',
saveProject: 'Save project (Ctrl/Cmd+S)',
saveProjectDirty: 'Save project (unsaved changes, Ctrl/Cmd+S)',
documentSettings: 'Document settings',
export: 'Export',
mainTimeline: 'Main timeline',
@@ -66,7 +67,7 @@ export const en = {
clipLayerCount: '{n} layers',
renameTooltip: 'Rename',
deleteTooltip: 'Delete',
noClips: 'No clips',
noClips: 'No clips yet',
shape: {
rect: 'Rect',
circle: 'Circle',
@@ -243,7 +244,6 @@ export const en = {
easingEaseOut: 'Ease Out',
easingEaseInOut: 'Ease In-Out',
firstKfHint: 'First keyframe — easing has no effect',
holdDisabledHint: 'Still frame — easing disabled',
stillFrame: 'Still frame',
duplicate: 'Duplicate',
delete: 'Delete',
+7 -26
View File
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '13'
const RUNTIME_VERSION = '14'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -128,36 +128,17 @@ const ENGINE_SOURCE = `(function(){
})).then(function(){return imgs;});
}
function effectiveEnd(scene){
var max=0;
for(var i=0;i<scene.layers.length;i++){
var kfs=scene.layers[i].keyframes;
for(var j=0;j<kfs.length;j++){ if(kfs[j].time>max) max=kfs[j].time; }
}
var instances=scene.clipInstances||[];
var clips=scene.clips||[];
var clipById={};
for(var ci=0;ci<clips.length;ci++) clipById[clips[ci].id]=clips[ci];
for(var ii=0;ii<instances.length;ii++){
var inst=instances[ii];
var cl=clipById[inst.clipId];
if(!cl) continue;
var trimS=inst.trimStart||0;
var trimE=inst.trimEnd||0;
var end=inst.startTime+Math.max(0,cl.duration-trimS-trimE);
if(end>max) max=end;
}
if(max<=0) return scene.duration;
return Math.min(max,scene.duration);
}
function mount(container,scene,opts){
if(!container)return;
opts=opts||{};
var loop=opts.loop!==false;
// Honour explicit in/out points; fall back to 0 and the animation's own end.
// Honour explicit in/out points. When outTime isn't set, play the
// full timeline (scene.duration) — matches the editor's playbackOut
// getter. Using the last keyframe as a fallback made still-frame
// endings and trailing silence (intentionally empty space before
// the OUT mark) unreachable, so the preview looped early.
var rangeIn=Math.max(0, Math.min(scene.duration, scene.inTime==null?0:scene.inTime));
var autoEnd=effectiveEnd(scene);
var rangeOut=Math.max(rangeIn, Math.min(scene.duration, scene.outTime==null?autoEnd:scene.outTime));
var rangeOut=Math.max(rangeIn, Math.min(scene.duration, scene.outTime==null?scene.duration:scene.outTime));
var sceneEnd=rangeOut;
var cs=getComputedStyle(container);
if(cs.position==='static')container.style.position='relative';
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.8.0'
export const APP_VERSION = '0.8.1'
export const APP_NAME = 'RUWIO'