Files
RUWIO/src/App.vue
T
thandClaude Opus 4.7 1e89ea86e0 0.8.0: group colour cascade, still-frame past end, project rename, UX polish
Headline:

- Group colour cascades to every non-overridden child (layer or clip
  track). New `colorOverridden` flag on Layer / ClipTrack / ScriptTrack
  / TrackGroup; setTrackColor on a non-group pins the track, clearing
  re-inherits from the parent group. moveTrackIntoGroup also inherits.
  normalizeScene migrates legacy scenes (existing colour ⇒ overridden).
- Keyframes inside a button-asset timeline snap to the 4 state slots
  (normal/hover/pressed/disabled). upsertKeyframe / setKeyframeTime /
  duplicateKeyframe all enforce it; colliding drags refuse the move,
  duplicates walk to the nearest free slot.
- Backspace / Delete on a selected layer removes it (respecting the
  layer's own lock and any enclosing group lock).
- "Still frame" allowed on the last keyframe: the layer stays on stage
  past its final kf. Timeline draws a solid dim tail line from the
  last kf to the end, and single-keyframe tracks now show a full-span
  line so on-stage/off-stage reads at a glance. Height stays 3px.
- Project name moved next to the logo, turns into a rename button on
  hover (pencil fades in). Rename syncs the stored record when the
  project is already persisted.
- Shadow + blur keyframes animate in the preview and exported runtime:
  the runtime's lerpProps used to drop them, so every preview rendered
  shadow=0 between keyframes. RUNTIME_VERSION 11 → 13 (covers the
  button-mask + shadow fixes). Held-last-kf also taught to the runtime.
- Button masks render across every path: edit-mode mask-preview,
  nested buttons on the main stage, sidebar thumbnails, fullscreen
  preview + exports. drawButtonFrame new in render.ts.

Smaller fixes:

- Dragging layer A on the stage with a stale selection on layer B used
  to move B and snap A back. Now the shift-selected branch only runs
  when the dragged layer is itself in the selection.
- Clip-track row was missing its icon — PhFilmStrip was referenced but
  never imported. Added, plus dropped the bespoke `gap` on clip / script
  name rows so their icon+label spacing matches layer rows.
- Clip-art drawer pre-lays-out its 400px grid behind `overflow: hidden`
  so slide-in reveals a finished grid instead of reflowing auto-fill
  columns over 220ms.
- Timeline clip-track dropzone hides until the library has a clip; its
  "drop a clip here" hint stays centred in the visible ruler area.
- Keyframe context menu: check-mark and icons share a single 12px column
  so the ✓ on an easing option lines up with PhPauseCircle / PhCopy /
  PhTrash on the rows below. "Still frame"'s active indicator moved to
  the trailing-check slot.
- Button editor's mask-preview now shows the button's own stage instead
  of leaking through to the main scene. drawButtonFrame drives it.

Regression tests cover all of the above: group-color cascade, pin,
clear-re-inherit, migration, button-kf snap and collision rules,
backspace-deletes-selected-layer (incl. lock guards + editable-target
guard), still-frame-past-end visibility, clipart drawer rigid width,
mask rendering in drawButtonFrame / drawSceneFrame, shadow + blur
interpolation in both canvas and runtime paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:26:06 +02:00

818 lines
21 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
PhArrowClockwise,
PhArrowCounterClockwise,
PhArrowLeft,
PhCursorClick,
PhExport,
PhFilmStrip,
PhFolderOpen,
PhFloppyDisk,
PhGearSix,
PhPause,
PhPencilSimpleLine,
PhPlay,
PhSkipBack,
PhSparkle,
PhStop,
} from '@phosphor-icons/vue'
import AboutDialog from './components/AboutDialog.vue'
import AssetSidebar from './components/AssetSidebar.vue'
import CanvasStage from './components/CanvasStage.vue'
import ClipartDrawer from './components/ClipartDrawer.vue'
import DocumentSettings from './components/DocumentSettings.vue'
import ExportDialog from './components/ExportDialog.vue'
import FullscreenPreview from './components/FullscreenPreview.vue'
import PropertiesPanel from './components/PropertiesPanel.vue'
import Timeline from './components/Timeline.vue'
import WelcomeDialog from './components/WelcomeDialog.vue'
import TestOverlay from './devtools/TestOverlay.vue'
import ruwioLogo from './assets/logo.png'
import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
import { t } from './i18n'
import { useHistory } from './composables/useHistory'
import { usePlaybackLoop } from './composables/usePlaybackLoop'
import {
ProjectFileError,
downloadProject,
pickProjectFile,
readProjectFile,
} from './lib/projectFile'
import {
getLastOpenedId,
listRecentProjects,
saveProjectToStore,
setLastOpenedId,
type StoredProject,
} from './lib/projectStore'
import { importImageFile } from './lib/fileLoader'
import { useClipartStore } from './stores/clipart'
import { createBlankScene, useSceneStore } from './stores/scene'
const settingsOpen = ref(false)
const exportOpen = ref(false)
const testsOpen = ref(false)
const welcomeOpen = ref(false)
const fullscreenOpen = ref(false)
const aboutOpen = ref(false)
const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
const DEFAULT_TIMELINE_HEIGHT = 220
const MIN_TIMELINE_HEIGHT = 120
const MAX_TIMELINE_HEIGHT_MARGIN = 160
function readStoredTimelineHeight(): number {
try {
const raw = localStorage.getItem(TIMELINE_HEIGHT_KEY)
if (raw === null) return DEFAULT_TIMELINE_HEIGHT
const n = Number.parseInt(raw, 10)
if (Number.isFinite(n) && n >= MIN_TIMELINE_HEIGHT) return n
} catch {
/* ignore */
}
return DEFAULT_TIMELINE_HEIGHT
}
const timelineHeight = ref(readStoredTimelineHeight())
function maxTimelineHeight(): number {
return Math.max(MIN_TIMELINE_HEIGHT, window.innerHeight - MAX_TIMELINE_HEIGHT_MARGIN)
}
const appStyle = computed(() => ({
'--timeline-height': `${timelineHeight.value}px`,
}))
let resizeStartY = 0
let resizeStartHeight = 0
let resizePointerId = -1
const resizing = ref(false)
function onTimelineResizeStart(e: PointerEvent) {
if (e.button !== 0) return
e.preventDefault()
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
resizePointerId = e.pointerId
resizeStartY = e.clientY
resizeStartHeight = timelineHeight.value
resizing.value = true
document.body.style.userSelect = 'none'
}
function onTimelineResizeMove(e: PointerEvent) {
if (!resizing.value || e.pointerId !== resizePointerId) return
const dy = resizeStartY - e.clientY
const next = Math.max(
MIN_TIMELINE_HEIGHT,
Math.min(maxTimelineHeight(), resizeStartHeight + dy),
)
timelineHeight.value = next
}
function onTimelineResizeEnd(e: PointerEvent) {
if (!resizing.value || e.pointerId !== resizePointerId) return
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
resizePointerId = -1
resizing.value = false
document.body.style.userSelect = ''
try {
localStorage.setItem(TIMELINE_HEIGHT_KEY, String(timelineHeight.value))
} catch {
/* ignore */
}
}
function onWindowResize() {
const max = maxTimelineHeight()
if (timelineHeight.value > max) timelineHeight.value = max
}
const loadError = ref<string | null>(null)
const savedToast = ref<string | null>(null)
const currentProjectId = ref<string | null>(null)
const currentProjectName = ref<string | null>(null)
usePlaybackLoop()
const store = useSceneStore()
const clipart = useClipartStore()
const history = useHistory()
function rewind() {
store.setPlayhead(store.playbackIn)
}
function stop() {
store.pause()
}
async function saveProject() {
const scene = JSON.parse(JSON.stringify(store.scene))
let name = currentProjectName.value
if (!name) {
const suggested = t('app.saveDialog.defaultName', { date: new Date().toLocaleDateString() })
const entered = await promptDialog({
title: t('app.saveDialog.title'),
message: t('app.saveDialog.message'),
defaultValue: suggested,
confirmLabel: t('app.saveDialog.confirm'),
})
if (entered === null) return
name = entered.trim() || suggested
}
const rec = await saveProjectToStore(scene, {
id: currentProjectId.value ?? undefined,
name,
})
currentProjectId.value = rec.id
currentProjectName.value = rec.name
setLastOpenedId(rec.id)
showSavedToast(t('app.savedToast', { name: rec.name }))
}
async function renameProject() {
const suggested =
currentProjectName.value ??
t('app.saveDialog.defaultName', { date: new Date().toLocaleDateString() })
const entered = await promptDialog({
title: t('app.renameProjectDialog.title'),
message: t('app.renameProjectDialog.message'),
defaultValue: suggested,
confirmLabel: t('app.renameProjectDialog.confirm'),
})
if (entered === null) return
const trimmed = entered.trim()
if (!trimmed || trimmed === currentProjectName.value) return
currentProjectName.value = trimmed
// If the project is already persisted in browser storage, sync the
// new name to its record so future loads (and the recents list) use
// it. Unsaved projects just get the in-memory name — the next save
// picks it up.
if (currentProjectId.value) {
const scene = JSON.parse(JSON.stringify(store.scene))
const rec = await saveProjectToStore(scene, {
id: currentProjectId.value,
name: trimmed,
})
currentProjectName.value = rec.name
}
}
function exportProjectFile() {
const scene = JSON.parse(JSON.stringify(store.scene))
const base = currentProjectName.value ?? 'animatr'
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'animatr'
downloadProject(scene, `${safe}.json`)
}
async function importFromFile() {
const file = await pickProjectFile()
if (!file) return
const hasContent = store.scene.layers.length > 0 || store.scene.assets.length > 0
if (hasContent) {
const ok = await confirmDialog({
title: t('app.replaceDialog.title'),
message: t('app.replaceDialog.message'),
confirmLabel: t('app.replaceDialog.confirm'),
variant: 'danger',
})
if (!ok) return
}
try {
const scene = await readProjectFile(file)
store.loadScene(scene)
history.resetTo(scene)
currentProjectId.value = null
currentProjectName.value = file.name.replace(/\.[^.]+$/, '')
loadError.value = null
} catch (err) {
const msg =
err instanceof ProjectFileError ? err.message : (err as Error).message || String(err)
loadError.value = msg
setTimeout(() => {
loadError.value = null
}, 6000)
}
}
function openFromRecent(project: StoredProject) {
const cloned = JSON.parse(JSON.stringify(project.scene))
store.loadScene(cloned)
history.resetTo(cloned)
currentProjectId.value = project.id
currentProjectName.value = project.name
setLastOpenedId(project.id)
loadError.value = null
}
function startNewProject() {
const blank = createBlankScene()
store.loadScene(blank)
history.resetTo(blank)
currentProjectId.value = null
currentProjectName.value = null
loadError.value = null
}
function showSavedToast(msg: string) {
savedToast.value = msg
setTimeout(() => {
if (savedToast.value === msg) savedToast.value = null
}, 2500)
}
async function bootWelcome() {
const recents = await listRecentProjects()
if (recents.length > 0) {
welcomeOpen.value = true
const lastId = getLastOpenedId()
if (lastId) {
const last = recents.find((p) => p.id === lastId)
if (last) {
currentProjectName.value = last.name
}
}
}
}
function isEditable(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false
const tag = el.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
if (el.isContentEditable) return true
return false
}
function onDocumentContextMenu(e: MouseEvent) {
// Allow native context menu only on editable inputs (for paste, etc.).
if (isEditable(e.target)) return
e.preventDefault()
}
function onKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
e.preventDefault()
testsOpen.value = true
return
}
const mod = e.ctrlKey || e.metaKey
if (mod && !isEditable(e.target)) {
const key = e.key.toLowerCase()
if (key === 'z' && !e.shiftKey) {
e.preventDefault()
history.undo()
return
}
if ((key === 'z' && e.shiftKey) || key === 'y') {
e.preventDefault()
history.redo()
return
}
}
if (!isEditable(e.target) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
const key = e.key.toLowerCase()
if (key === 'i') {
e.preventDefault()
store.setPlaybackRange(Math.min(store.playhead, store.playbackOut), null)
return
}
if (key === 'o') {
e.preventDefault()
store.setPlaybackRange(null, Math.max(store.playhead, store.playbackIn))
return
}
if (key === 'r') {
e.preventDefault()
rewind()
return
}
}
if (e.code !== 'Space') return
if (isEditable(e.target)) return
e.preventDefault()
if (e.shiftKey) {
store.pause()
fullscreenOpen.value = true
return
}
store.togglePlay()
}
function isTextInputTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
const tag = target.tagName
if (tag === 'INPUT') {
const t = (target as HTMLInputElement).type
// Only block typed text inputs — color / range / checkbox shouldn't swallow paste
return ['text', 'search', 'email', 'number', 'tel', 'url', 'password'].includes(t)
}
if (tag === 'TEXTAREA') return true
return false
}
async function onPaste(e: ClipboardEvent) {
if (isTextInputTarget(e.target)) return
const data = e.clipboardData
if (!data) return
const items = Array.from(data.items)
const imageItems = items.filter((it) => it.kind === 'file' && it.type.startsWith('image/'))
if (imageItems.length === 0) return
e.preventDefault()
for (const it of imageItems) {
const file = it.getAsFile()
if (!file) continue
try {
await importImageFile(store, file)
} catch {
/* ignore invalid paste payloads */
}
}
}
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
document.addEventListener('contextmenu', onDocumentContextMenu)
document.addEventListener('paste', onPaste)
window.addEventListener('resize', onWindowResize)
void clipart.initialize()
bootWelcome()
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('contextmenu', onDocumentContextMenu)
document.removeEventListener('paste', onPaste)
window.removeEventListener('resize', onWindowResize)
})
</script>
<template>
<div class="shell">
<ClipartDrawer />
<div class="app" :style="appStyle">
<header class="topbar">
<div class="brand-area">
<button class="brand" :title="t('about.openTooltip')" @click="aboutOpen = true">
<img :src="ruwioLogo" alt="RUWIO" class="brand-logo" />
</button>
<button
class="project-name-btn"
:class="{ 'is-untitled': !currentProjectName }"
:title="t('app.renameProject')"
@click="renameProject"
>
<span class="project-name-label">
{{ currentProjectName ?? t('app.untitledProject') }}
</span>
<PhPencilSimpleLine :size="12" class="project-name-pencil" />
</button>
</div>
<div class="transport">
<IconButton
:icon="PhArrowCounterClockwise"
icon-weight="bold"
size="md"
:disabled="!history.canUndo.value"
:title="t('app.undo')"
@click="history.undo"
/>
<IconButton
:icon="PhArrowClockwise"
icon-weight="bold"
size="md"
:disabled="!history.canRedo.value"
:title="t('app.redo')"
@click="history.redo"
/>
<div class="tsep"></div>
<IconButton
:icon="PhSkipBack"
icon-weight="fill"
size="md"
:title="t('app.jumpToStart')"
@click="rewind"
/>
<IconButton
:icon="store.playing ? PhPause : PhPlay"
icon-weight="fill"
size="md"
variant="primary"
:title="store.playing ? t('app.pause') : t('app.play')"
@click="store.togglePlay()"
/>
<IconButton
:icon="PhStop"
icon-weight="fill"
size="md"
:title="t('app.stop')"
@click="stop"
/>
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
</div>
<div class="topbar-right">
<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">
<PhFloppyDisk :size="16" />
</button>
<button class="icon-btn" :title="t('app.documentSettings')" @click="settingsOpen = true">
<PhGearSix :size="16" />
</button>
<button class="export-btn" :title="t('app.export')" @click="exportOpen = true">
<PhExport :size="14" weight="bold" />
{{ t('app.export') }}
</button>
</div>
</header>
<aside class="sidebar">
<div class="sidebar-scroll"><AssetSidebar /></div>
<div v-if="store.activeClipId" class="scope-footer">
<button class="scope-back" @click="store.exitClip">
<PhArrowLeft :size="12" weight="bold" />
{{ t('app.mainTimeline') }}
</button>
<span class="scope-current" :title="store.activeClip?.name ?? ''">
<PhFilmStrip :size="12" />
{{ store.activeClip?.name ?? '' }}
</span>
</div>
<div v-else-if="store.activeButtonAssetId" class="scope-footer">
<button class="scope-back" @click="store.exitButtonAssetEdit">
<PhArrowLeft :size="12" weight="bold" />
{{ t('app.mainTimeline') }}
</button>
<span class="scope-current" :title="store.activeButtonAsset?.name ?? ''">
<PhCursorClick :size="12" />
{{ store.activeButtonAsset?.name ?? '' }}
</span>
</div>
</aside>
<main class="stage"><CanvasStage /></main>
<aside class="properties"><PropertiesPanel /></aside>
<footer class="timeline">
<div
class="tl-resize"
:class="{ resizing }"
:title="t('app.dragResize')"
@pointerdown="onTimelineResizeStart"
@pointermove="onTimelineResizeMove"
@pointerup="onTimelineResizeEnd"
@pointercancel="onTimelineResizeEnd"
></div>
<Timeline />
</footer>
<DocumentSettings v-model="settingsOpen" />
<ExportDialog v-model="exportOpen" />
<TestOverlay v-model="testsOpen" />
<FullscreenPreview v-model="fullscreenOpen" />
<AboutDialog v-model="aboutOpen" />
<DialogsHost />
<WelcomeDialog
v-model="welcomeOpen"
@open="openFromRecent"
@new="startNewProject"
@import="importFromFile"
/>
<Toast :visible="!!loadError" variant="error">
<strong>{{ t('app.loadFailed') }}</strong>
<span>{{ loadError }}</span>
</Toast>
<Toast :visible="!!savedToast" variant="info" :message="savedToast ?? ''" />
</div>
</div>
</template>
<style scoped>
.shell {
display: flex;
align-items: stretch;
height: 100vh;
width: 100vw;
overflow: hidden;
}
.app {
display: grid;
grid-template-columns: 260px 1fr 300px;
grid-template-rows: 48px 1fr var(--timeline-height, 220px);
grid-template-areas:
'top top top'
'side stage props'
'time time time';
flex: 1 1 0;
min-width: 0;
height: 100%;
}
.topbar {
grid-area: top;
display: flex;
align-items: center;
padding: 0 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
.brand-area {
flex: 1 1 0;
display: flex;
gap: 10px;
align-items: center;
min-width: 0;
}
.brand {
font-weight: 600;
font-size: 14px;
letter-spacing: -0.01em;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
margin-left: -8px;
background: transparent;
border: 1px solid transparent;
color: var(--text);
border-radius: var(--radius-sm);
cursor: pointer;
}
.brand:hover {
background: var(--panel-alt);
border-color: var(--border);
}
.brand-icon {
color: var(--accent);
}
.transport {
display: flex;
gap: 4px;
align-items: center;
}
.tsep {
width: 1px;
height: 18px;
background: var(--border);
margin: 0 4px;
}
.time {
margin-left: 10px;
font-variant-numeric: tabular-nums;
color: var(--text-dim);
font-size: 11px;
min-width: 92px;
text-align: center;
}
.topbar-right {
flex: 1 1 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.brand-logo {
height: 40px;
width: auto;
object-fit: contain;
display: block;
user-select: none;
-webkit-user-drag: none;
}
.export-btn {
padding: 6px 12px;
border: 1px solid var(--accent);
color: var(--accent);
background: var(--accent-soft);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 500;
}
.export-btn:hover:not(:disabled) {
background: var(--accent-soft-strong);
}
.icon-btn {
width: 30px;
height: 30px;
padding: 0;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
}
.icon-btn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.sidebar {
grid-area: side;
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.sidebar-scroll {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
.scope-footer {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid var(--border-strong);
background: var(--accent-soft);
color: var(--accent);
font-size: 11px;
min-width: 0;
}
.scope-back {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-dim-strong);
font-size: 11px;
flex-shrink: 0;
}
.scope-back:hover {
background: var(--panel-alt);
color: var(--text);
border-color: var(--border-strong);
}
.scope-current {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
.stage {
grid-area: stage;
background: var(--bg);
overflow: hidden;
min-width: 0;
min-height: 0;
}
.properties {
grid-area: props;
background: var(--panel);
border-left: 1px solid var(--border);
overflow: auto;
}
.timeline {
grid-area: time;
background: var(--panel);
border-top: 1px solid var(--border);
overflow: hidden;
position: relative;
}
.tl-resize {
position: absolute;
top: -7px;
left: 0;
right: 0;
height: 14px;
cursor: ns-resize;
z-index: 20;
touch-action: none;
background: transparent;
transition: background 120ms var(--ease);
}
.tl-resize::after {
content: '';
position: absolute;
left: 0;
right: 0;
top: 7px;
height: 1px;
background: transparent;
transition: background 120ms var(--ease);
}
.tl-resize:hover::after,
.tl-resize.resizing::after {
background: var(--accent);
}
.tl-resize.resizing {
background: rgba(110, 168, 255, 0.08);
}
.project-name-btn {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 260px;
padding: 4px 8px;
font: inherit;
font-size: 12px;
color: var(--text);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
}
.project-name-btn.is-untitled {
color: var(--text-dim);
font-style: italic;
}
.project-name-btn:hover {
background: var(--panel-alt);
border-color: var(--border);
}
.project-name-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.project-name-pencil {
color: var(--text-dim);
opacity: 0;
transition: opacity 140ms var(--ease);
flex-shrink: 0;
}
.project-name-btn:hover .project-name-pencil,
.project-name-btn:focus-visible .project-name-pencil {
opacity: 1;
}
</style>