Files
RUWIO/src/App.vue
T
thandClaude Opus 4.7 ddea7d348f 0.5.0: RUWIO rebrand, vertex bezier, animation paths, welcome focus
- Rename the app to RUWIO: title, APP_NAME, topbar logo (40px PNG),
  welcome dialog header and about dialog switch from the PhSparkle
  glyph to the real logo. Redundant name text next to the logo is
  removed across the board.
- About dialog: logo spans the full dialog width, the bottom Close
  button is gone (close via header X stays).
- Vertex bezier for polygons: each vertex can be Corner / Curve / Split
  curve via right-click. Curve mirrors tangent handles symmetrically,
  split curve keeps them independent. Tangents are fully animatable
  per keyframe — lerp mirrored across all four render pipelines.
- Stage path overlay: selecting a keyframe draws the tween arc between
  the previous and selected keyframe with draggable per-frame dots that
  curve the path via quadratic bezier control points. Other segments
  on the same track render as dashed ghost lines so the full curve is
  visible. Path dots grow + show grab cursor on hover.
- Vertex box-select: click-drag on empty stage while a polygon is
  selected boxes vertices. Shift-click toggles individual vertices
  into/out of the selection. Multi-vertex drag moves all selected at
  once; right-click on a selected vertex applies Corner/Curve/Split
  to all. Tangent handles only render when exactly one vertex is
  selected.
- Welcome dialog focus: opens with the most recent project focused.
  Enter opens it; Tab cycles only through the dialog's real buttons
  (delete icons pulled out of tab order, scrollable body removed from
  tab order). Export-current removed from welcome (not useful on
  startup).
- Modal focus trap: all modals now keep Tab inside the dialog.
- Fill / stroke transparency toggle restores the last real color when
  flipped back on, with a layer-base / asset fallback chain so there's
  always a visible color to restore to. A watcher keeps the restore
  cache in sync with the sampled value.

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

677 lines
17 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
PhArrowClockwise,
PhArrowCounterClockwise,
PhArrowLeft,
PhExport,
PhFilmStrip,
PhFolderOpen,
PhFloppyDisk,
PhGearSix,
PhPause,
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 { 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 history = useHistory()
function rewind() {
store.setPlayhead(0)
}
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 }))
}
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 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 (e.code !== 'Space') return
if (isEditable(e.target)) return
e.preventDefault()
if (e.shiftKey) {
store.pause()
fullscreenOpen.value = true
return
}
store.togglePlay()
}
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
document.addEventListener('contextmenu', onDocumentContextMenu)
window.addEventListener('resize', onWindowResize)
bootWelcome()
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('contextmenu', onDocumentContextMenu)
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>
</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">
<span v-if="currentProjectName" class="project-name" :title="currentProjectName">
{{ currentProjectName }}
</span>
<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>
</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="() => { currentProjectId = null; currentProjectName = null }"
@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 {
display: inline-flex;
align-items: center;
max-width: 220px;
padding: 4px 10px;
font-size: 11px;
color: var(--text-dim-strong);
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 4px;
}
</style>