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:
1 parent
1e89ea86e0
commit
82ee5c98d0
10 files changed
+133
-48
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "animatr",
|
||||
"private": true,
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+59
-4
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.8.0'
|
||||
export const APP_VERSION = '0.8.1'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
@@ -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
|
||||
Reference in new issue
Block a user