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

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "animatr",
"private": true,
"version": "0.8.0",
"version": "0.8.1",
"type": "module",
"scripts": {
"dev": "vite",
+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'
+4 -1
View File
@@ -132,8 +132,9 @@ der clip-art drawer muss bevor er reingeschoben wird schon so tun als hätte er
die inline hilfe texte in den settings sind zu klein. das kann man nicht lesen
warum ist eigentlich still frame easing disabled? man muss doch zu einem still-frame hin easen können. verstehe den grund nicht
@@ -142,3 +143,5 @@ der clip-art drawer muss bevor er reingeschoben wird schon so tun als hätte er
ich würde an dieser stelle mal ein paar regression-tests hinzufügen und dann committen
wenn man den leeren teil des content-views zieht (außerhalb der canvas constraints) muss canvas move auch ohne control key gehen