0.12.0: document drawer redesign, PWA install, clip/audio context menus + push-cascade

- Document drawer slides out from under the topbar (no tab, no hover
  peek); opens via project-name button, closes on outside-click or ESC.
  Layout: inputs row + divider + preset row, presets user-managed via
  popup and persisted in localStorage.
- PWA manifest + minimal service worker so Chrome installs RUWIO as a
  standalone app — suppresses the native fullscreen exit overlay.
- Clips and audio clips get right-click context menus (Duplicate /
  Delete) via new `duplicateClipInstance` / `duplicateAudioClip` store
  actions.
- Clip moves cascade non-overlap in both directions; `setAudioClipPosition`
  now pushes backward as well as forward. New `setClipInstancePosition`
  gives clip instances the same behaviour.
- Library drags carry timeline-shaped ghost previews (audio pill, clip
  pill, button thumb) instead of the library row; clip lane renders a
  dashed drop-target ghost and rejects window-overlap drops.
- Scene / Clipart drawer tabs disappear in fullscreen; the 24 px they
  occupied is reclaimed for the stage. Play button sits on the exact
  horizontal center via a 3-column grid in the transport cluster.
- Tests: context menu + push-cascade + duplicate action coverage for
  both clip kinds; DocumentDrawer suite rewritten for the slide-out
  model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-24 07:41:26 +02:00
1 parent fa66ba69bb
commit 354dd8a4f0
25 files changed
+2256 -367

No files matched your search

+3
View File
@@ -3,7 +3,10 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/pwa-icon-192.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#17171b" />
<title>RUWIO</title>
</head>
<body>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "0.11.1",
"version": "0.12.0",
"type": "module",
"scripts": {
"dev": "vite",
+32
View File
@@ -0,0 +1,32 @@
{
"name": "RUWIO",
"short_name": "RUWIO",
"description": "A timeline-based animation editor that runs in your browser.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],
"orientation": "any",
"background_color": "#17171b",
"theme_color": "#17171b",
"icons": [
{
"src": "/pwa-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa-icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

+19
View File
@@ -0,0 +1,19 @@
// Minimal service worker. Present so Chrome considers the site
// installable as a PWA; installed instances run in standalone display
// mode, which removes the top-edge "exit fullscreen" indicator that
// regular-browser tabs get when entering the Fullscreen API.
// No caching logic — assets are served straight from the network so
// a new build is picked up without a version bump dance.
self.addEventListener('install', (event) => {
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
self.addEventListener('fetch', () => {
// Fall through to network. Required handler for installability; we
// deliberately do nothing else so dev/prod behave identically.
})
+54 -33
View File
@@ -24,6 +24,7 @@ import HelpDialog from './components/HelpDialog.vue'
import CanvasStage from './components/CanvasStage.vue'
import ClipartDrawer from './components/ClipartDrawer.vue'
import SceneDrawer from './components/SceneDrawer.vue'
import DocumentDrawer from './components/DocumentDrawer.vue'
import DocumentSettings from './components/DocumentSettings.vue'
import ExportDialog from './components/ExportDialog.vue'
import FullscreenPreview from './components/FullscreenPreview.vue'
@@ -37,7 +38,9 @@ import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './c
import { t } from './i18n'
import { useHistory } from './composables/useHistory'
import { useAudioRuntime } from './composables/useAudioRuntime'
import { useDocumentDrawer } from './composables/useDocumentDrawer'
import { usePlaybackLoop } from './composables/usePlaybackLoop'
import { useProjectName } from './composables/useProjectName'
import {
ProjectFileError,
downloadProject,
@@ -136,8 +139,8 @@ function onWindowResize() {
}
const loadError = ref<string | null>(null)
const savedToast = ref<string | null>(null)
const currentProjectId = ref<string | null>(null)
const currentProjectName = ref<string | null>(null)
const { projectName: currentProjectName, projectId: currentProjectId } = useProjectName()
const documentDrawer = useDocumentDrawer()
// 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.
@@ -195,32 +198,12 @@ async function saveProject() {
await markClean()
}
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 project = JSON.parse(JSON.stringify(store.project))
const rec = await saveProjectToStore(project, {
id: currentProjectId.value,
name: trimmed,
})
currentProjectName.value = rec.name
}
function openProjectNameDrawer() {
// Clicking the project name in the topbar toggles the document
// drawer — edits the name inline alongside the rest of the document
// settings. Clicking again closes it, matching the topbar button's
// dual role as trigger and dismiss.
documentDrawer.toggle()
}
function exportProjectFile() {
@@ -423,9 +406,11 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="root">
<div class="shell">
<ClipartDrawer />
<div class="app" :style="appStyle">
<DocumentDrawer />
<header class="topbar">
<div class="brand-area">
<button class="brand" v-tooltip="t('about.openTooltip')" @click="aboutOpen = true">
@@ -438,7 +423,7 @@ onBeforeUnmount(() => {
label: t('app.renameProject'),
help: t('app.renameProjectHelp'),
}"
@click="renameProject"
@click="openProjectNameDrawer"
>
<span class="project-name-label">
{{ currentProjectName ?? t('app.untitledProject') }}
@@ -447,6 +432,7 @@ onBeforeUnmount(() => {
</button>
</div>
<div class="transport">
<div class="transport-side transport-left">
<IconButton
:icon="PhArrowCounterClockwise"
icon-weight="bold"
@@ -471,6 +457,7 @@ onBeforeUnmount(() => {
:title="t('app.jumpToStart')"
@click="rewind"
/>
</div>
<IconButton
:icon="store.playing ? PhPause : PhPlay"
icon-weight="fill"
@@ -479,6 +466,7 @@ onBeforeUnmount(() => {
:title="store.playing ? t('app.pause') : t('app.play')"
@click="store.togglePlay()"
/>
<div class="transport-side transport-right">
<IconButton
:icon="PhStop"
icon-weight="fill"
@@ -488,6 +476,7 @@ onBeforeUnmount(() => {
/>
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
</div>
</div>
<div class="topbar-right">
<button class="icon-btn" v-tooltip="t('app.recentProjects')" @click="welcomeOpen = true">
<PhFolderOpen :size="16" />
@@ -579,14 +568,24 @@ onBeforeUnmount(() => {
<SceneDrawer />
<TooltipHost />
</div>
</div>
</template>
<style scoped>
.root {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
}
.shell {
display: flex;
align-items: stretch;
height: 100vh;
width: 100vw;
flex: 1 1 auto;
min-height: 0;
width: 100%;
overflow: hidden;
}
@@ -601,6 +600,7 @@ onBeforeUnmount(() => {
flex: 1 1 0;
min-width: 0;
height: 100%;
position: relative;
}
.topbar {
@@ -611,6 +611,8 @@ onBeforeUnmount(() => {
background: var(--panel);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm);
position: relative;
z-index: 50;
}
.brand-area {
@@ -646,10 +648,29 @@ onBeforeUnmount(() => {
color: var(--accent);
}
/* The play button sits in its own auto-width column; the two side
groups get equal 1fr space on either side. That way the play icon
is pinned to the topbar's horizontal center regardless of how much
content each side has. */
.transport {
display: flex;
gap: 4px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
column-gap: 4px;
}
.transport-side {
display: flex;
align-items: center;
gap: 4px;
}
.transport-left {
justify-content: flex-end;
}
.transport-right {
justify-content: flex-start;
}
.tsep {
+116 -1
View File
@@ -22,7 +22,15 @@ import {
import { useSceneStore } from '@/stores/scene'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { importAudioFile, importImageFile, isAudioFile } from '@/lib/fileLoader'
import { clearAudioDragSource, setAudioDragSource } from '@/composables/useAudioDragSource'
import {
clearAudioDragSource,
installAudioClipDragImage,
setAudioDragSource,
} from '@/composables/useAudioDragSource'
import {
clearClipDragSource,
setClipDragSource,
} from '@/composables/useClipDragSource'
import type { Asset } from '@/types/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
import { loadSceneImages, renderButtonThumbnail } from '@/lib/render'
@@ -199,11 +207,105 @@ watchEffect(() => {
buttonThumbs.value = out
})
// Shared ghost-installer: appends the node offscreen, hands it to
// setDragImage for the snapshot frame, then removes it on next tick.
function installGhost(e: DragEvent, node: HTMLElement) {
if (!e.dataTransfer) return
node.style.position = 'fixed'
node.style.top = '-10000px'
node.style.left = '-10000px'
node.style.pointerEvents = 'none'
document.body.appendChild(node)
try {
e.dataTransfer.setDragImage(node, 12, 13)
} catch {
/* ignore */
}
setTimeout(() => node.remove(), 0)
}
// Pill-shaped ghost matching Timeline's `.clip-block` look — accent-
// coloured tint, film-strip icon, clip name. Track colour isn't known
// at drag time, so we fall back to the accent hue.
function buildClipGhost(name: string): HTMLElement {
const ghost = document.createElement('div')
ghost.style.cssText = [
'display: inline-flex',
'align-items: center',
'gap: 6px',
'height: 26px',
'padding: 0 10px',
'background: rgba(110,168,255,0.18)',
'border: 1px solid rgba(110,168,255,0.85)',
'border-radius: 4px',
'color: #a9c7ff',
'font: 500 11px/1.4 system-ui, sans-serif',
'white-space: nowrap',
'box-shadow: 0 2px 6px rgba(0,0,0,0.35)',
].join('; ')
ghost.innerHTML =
'<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" style="flex-shrink:0;opacity:0.9;">' +
'<path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16Zm0 16v32h-32V56Zm-64 0v32h-48V56Zm-64 32H56V56h32ZM40 104h32v48H40Zm48 0h80v48H88Zm96 0h32v48h-32ZM40 168h32v32H40Zm48 0h80v32H88Zm96 32v-32h32v32Z"/>' +
'</svg>'
const label = document.createElement('span')
label.textContent = name
label.style.cssText = 'overflow:hidden;text-overflow:ellipsis;max-width:200px;'
ghost.appendChild(label)
return ghost
}
// Fallback for buttons whose thumbnail hasn't rendered yet: a small
// pill hinting at the stage-shaped target.
function buildButtonGhost(name: string): HTMLElement {
const ghost = document.createElement('div')
ghost.style.cssText = [
'display: inline-flex',
'align-items: center',
'gap: 6px',
'height: 28px',
'padding: 0 14px',
'background: var(--panel, #2a2a34)',
'border: 1px solid rgba(255,255,255,0.25)',
'border-radius: 6px',
'color: #e6edf3',
'font: 500 12px/1.4 system-ui, sans-serif',
'white-space: nowrap',
'box-shadow: 0 2px 6px rgba(0,0,0,0.35)',
].join('; ')
ghost.innerHTML =
'<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" style="flex-shrink:0;opacity:0.9;">' +
'<path d="M169.57 72.61a8 8 0 0 0-5.46 10l20.36 61.05-25.62 17.1a8 8 0 0 0-2.51 10.9l40 64a8 8 0 1 0 13.56-8.48l-35.58-56.93 25.08-16.74a8 8 0 0 0 3.05-9.2L179.6 78.06a8 8 0 0 0-10.03-5.45ZM144 88a40 40 0 1 0-56 36.66v85.36a8 8 0 0 0 16 0V124.66A40 40 0 0 0 144 88Z"/>' +
'</svg>'
const label = document.createElement('span')
label.textContent = name
label.style.cssText = 'overflow:hidden;text-overflow:ellipsis;max-width:200px;'
ghost.appendChild(label)
return ghost
}
function onButtonAssetDragStart(e: DragEvent, assetId: string) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-asset', assetId)
e.dataTransfer.setData('text/plain', assetId)
// Mirror the image-asset behaviour: if the row already shows a
// pre-rendered button thumb, hand that to the drag layer so the
// cursor carries the stage-shaped preview instead of the library
// row. Fall back to a generic button pill when the thumb is still
// decoding or disabled (compact mode).
const target = e.currentTarget as HTMLElement
const thumbImg = target.querySelector('img') as HTMLImageElement | null
if (thumbImg && thumbImg.complete && thumbImg.naturalWidth > 0) {
const r = thumbImg.getBoundingClientRect()
const ox = Math.max(0, e.clientX - r.left)
const oy = Math.max(0, e.clientY - r.top)
e.dataTransfer.setDragImage(thumbImg, ox, oy)
return
}
const asset = store.scene.assets.find((a) => a.id === assetId)
const name = asset?.kind === 'button' ? asset.name : 'Button'
installGhost(e, buildButtonGhost(name))
}
async function renameButtonAssetPrompt(id: string, current: string) {
@@ -266,6 +368,18 @@ function onClipDragStart(e: DragEvent, clipId: string) {
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-clip', clipId)
e.dataTransfer.setData('text/plain', clipId)
const clip = store.project.clips.find((c) => c.id === clipId)
// Mirror the audio flow: stash clip info so Timeline can render a
// hover ghost (dataTransfer.getData is unreadable during dragover).
setClipDragSource({
clipId,
duration: clip?.duration ?? 0,
name: clip?.name ?? 'Clip',
})
const el = e.currentTarget as HTMLElement
el.addEventListener('dragend', clearClipDragSource, { once: true })
installGhost(e, buildClipGhost(clip?.name ?? 'Clip'))
}
async function handleFileSelected(event: Event) {
@@ -365,6 +479,7 @@ function onAudioAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'audi
// Surface the source info so Timeline's ghost preview has something
// to render during dragover (dataTransfer.getData is unreadable then).
setAudioDragSource({ duration: asset.duration, name: asset.name })
installAudioClipDragImage(e, { name: asset.name })
const el = e.currentTarget as HTMLElement
el.addEventListener('dragend', clearAudioDragSource, { once: true })
}
+53 -8
View File
@@ -1,10 +1,15 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { PhArrowClockwise, PhCaretLeft, PhCaretRight, PhFolderOpen, PhImagesSquare, PhMusicNote } from '@phosphor-icons/vue'
import { useClipartStore } from '@/stores/clipart'
import { readAudioDuration, readAsDataUrl } from '@/lib/fileLoader'
import { clearAudioDragSource, setAudioDragSource } from '@/composables/useAudioDragSource'
import {
clearAudioDragSource,
installAudioClipDragImage,
setAudioDragSource,
} from '@/composables/useAudioDragSource'
import { useFullscreen } from '@/composables/useFullscreen'
import { dialogState } from '@/components/ui'
import { t } from '@/i18n'
const clipart = useClipartStore()
@@ -23,12 +28,31 @@ function onDrawerLeave() {
if (isFullscreen.value) clipart.setOpen(false)
}
// ESC closes the drawer when opened via click. Skipped in fullscreen
// (hover-leave handles it) and while a dialog is on top.
function onDocKeyDown(e: KeyboardEvent) {
if (!clipart.open || isFullscreen.value) return
if (e.key !== 'Escape') return
if (dialogState.value) return
e.preventDefault()
clipart.setOpen(false)
}
watch(
() => clipart.open,
(v) => {
if (v) document.addEventListener('keydown', onDocKeyDown)
else document.removeEventListener('keydown', onDocKeyDown)
},
)
onMounted(() => {
clipart.initialize()
})
onBeforeUnmount(() => {
clipart.disposeItems()
document.removeEventListener('keydown', onDocKeyDown)
})
function onItemDragStart(e: DragEvent, index: number) {
@@ -44,6 +68,7 @@ function onItemDragStart(e: DragEvent, index: number) {
// file decodes, so seed with 0 (ghost renders at minimum width)
// and upgrade asynchronously. dragend clears both.
setAudioDragSource({ duration: 0, name: item.name })
installAudioClipDragImage(e, { name: item.name })
const el = e.currentTarget as HTMLElement
el.addEventListener('dragend', clearAudioDragSource, { once: true })
void (async () => {
@@ -55,8 +80,6 @@ function onItemDragStart(e: DragEvent, index: number) {
/* leave the placeholder width — still a visible hint */
}
})()
// No image ghost for audio — browsers render the row itself as the
// default drag preview, which is what we want.
return
}
// Use the actual image as drag ghost
@@ -79,7 +102,11 @@ function onItemDragStart(e: DragEvent, index: number) {
</script>
<template>
<aside class="clipart-drawer" :class="{ open: clipart.open }" @mouseleave="onDrawerLeave">
<aside
class="clipart-drawer"
:class="{ open: clipart.open, fullscreen: isFullscreen }"
@mouseleave="onDrawerLeave"
>
<button
class="drawer-tab"
v-tooltip="clipart.open ? t('clipart.close') : t('clipart.open')"
@@ -192,16 +219,34 @@ function onItemDragStart(e: DragEvent, index: number) {
transition: opacity 140ms var(--ease);
}
.clipart-drawer:hover .drawer-tab :deep(svg),
.clipart-drawer.open .drawer-tab :deep(svg) {
.clipart-drawer:hover:not(.fullscreen) .drawer-tab :deep(svg),
.clipart-drawer.open:not(.fullscreen) .drawer-tab :deep(svg) {
opacity: 1;
}
.clipart-drawer:hover .drawer-tab {
.clipart-drawer:hover:not(.fullscreen) .drawer-tab {
background: var(--panel-alt);
color: var(--text);
}
.clipart-drawer.fullscreen .drawer-tab {
cursor: default;
background: transparent !important;
}
/* Once the fullscreen hover opens the drawer, the tab column has
served its purpose (hover-detect) — reclaim its 24px so the panel
uses the full width. The aside itself still catches mouseleave. */
.clipart-drawer.fullscreen.open .drawer-tab {
display: none;
}
/* Tab column is gone in fullscreen — reclaim its 24 px so the stage
area grows by the same amount instead of leaving a blank strip. */
.clipart-drawer.fullscreen.open {
width: 400px;
}
.clipart-drawer.open .drawer-tab {
flex: 0 0 24px;
width: 24px;
+725
View File
@@ -0,0 +1,725 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { PhFilmReel, PhPlus, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { confirmDialog, promptDialog, dialogState } from '@/components/ui'
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
import { useProjectName } from '@/composables/useProjectName'
import {
parsePresetInput,
presetLabel,
useCanvasPresets,
type CanvasPreset,
} from '@/composables/useCanvasPresets'
import { t } from '@/i18n'
const store = useSceneStore()
const { open } = useDocumentDrawer()
const { projectName, renameProject } = useProjectName()
const { presets, addPreset, removePreset } = useCanvasPresets()
const rootEl = ref<HTMLElement | null>(null)
// The drawer is opened only by the topbar project-name button (no tab,
// no hover). An outside click or ESC dismisses it. A dialog on top
// (preset add/remove, bg-video confirms) owns ESC while it's up.
function onDocPointerDown(e: PointerEvent) {
if (!open.value) return
const root = rootEl.value
if (root && root.contains(e.target as Node)) return
// The project-name button is itself outside the drawer root. Don't
// close on clicks inside it — let its own click handler toggle.
const target = e.target as Element | null
if (target && target.closest('.project-name-btn')) return
open.value = false
}
function onDocKeyDown(e: KeyboardEvent) {
if (!open.value) return
if (e.key !== 'Escape') return
if (dialogState.value) return
e.preventDefault()
open.value = false
}
watch(open, (v) => {
if (v) {
// Defer attaching the pointerdown listener by one tick so the
// very click that opened the drawer (e.g. the topbar button)
// doesn't immediately register as an outside click.
setTimeout(() => {
if (!open.value) return
document.addEventListener('pointerdown', onDocPointerDown, true)
document.addEventListener('keydown', onDocKeyDown)
}, 0)
} else {
document.removeEventListener('pointerdown', onDocPointerDown, true)
document.removeEventListener('keydown', onDocKeyDown)
}
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocPointerDown, true)
document.removeEventListener('keydown', onDocKeyDown)
})
// Local draft of the project name so the inline input doesn't fire
// saveProjectToStore on every keystroke. Commit on blur / Enter; revert
// on Escape.
const nameDraft = ref(projectName.value ?? '')
watch(projectName, (v) => {
nameDraft.value = v ?? ''
})
function commitName() {
const next = nameDraft.value.trim()
if (!next) {
nameDraft.value = projectName.value ?? ''
return
}
if (next === (projectName.value ?? '')) return
void renameProject(next)
}
function onNameKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
;(e.currentTarget as HTMLInputElement).blur()
} else if (e.key === 'Escape') {
nameDraft.value = projectName.value ?? ''
;(e.currentTarget as HTMLInputElement).blur()
}
}
const bgVideoError = ref<string | null>(null)
const bgFileInput = ref<HTMLInputElement | null>(null)
interface ProbedVideo {
src: string
width: number
height: number
duration: number
name: string
}
function probeVideo(src: string, name: string): Promise<ProbedVideo> {
return new Promise((resolve, reject) => {
const v = document.createElement('video')
v.preload = 'metadata'
v.muted = true
v.src = src
v.onloadedmetadata = () => {
resolve({
src,
width: v.videoWidth || 1280,
height: v.videoHeight || 720,
duration: Number.isFinite(v.duration) ? v.duration : 0,
name,
})
}
v.onerror = () => reject(new Error('Could not read video metadata.'))
})
}
async function applyProbedVideo(probe: ProbedVideo) {
const stageMatches =
probe.width === store.scene.canvas.width && probe.height === store.scene.canvas.height
if (!stageMatches) {
const ok = await confirmDialog({
title: t('settings.bgVideoStageSizeTitle'),
message: t('settings.bgVideoStageSizeMessage', {
w: probe.width,
h: probe.height,
cw: store.scene.canvas.width,
ch: store.scene.canvas.height,
}),
confirmLabel: t('settings.bgVideoStageSizeConfirm'),
cancelLabel: t('settings.bgVideoStageSizeCancel'),
})
if (ok) store.setCanvasSize(probe.width, probe.height)
}
if (probe.duration > 0 && probe.duration > store.scene.duration) {
const ok = await confirmDialog({
title: t('settings.bgVideoDurationTitle'),
message: t('settings.bgVideoDurationMessage', {
d: probe.duration.toFixed(2),
cd: store.scene.duration,
}),
confirmLabel: t('settings.bgVideoDurationConfirm'),
cancelLabel: t('settings.bgVideoDurationCancel'),
})
if (ok) store.setDuration(probe.duration)
}
store.setBackgroundVideo({ src: probe.src, muted: true })
}
function pickBgVideoFile() {
bgVideoError.value = null
bgFileInput.value?.click()
}
async function onBgFileSelected(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
try {
const reader = new FileReader()
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
const probe = await probeVideo(dataUrl, file.name)
await applyProbedVideo(probe)
} catch (err) {
bgVideoError.value = err instanceof Error ? err.message : String(err)
}
}
async function pickBgVideoUrl() {
bgVideoError.value = null
const url = await promptDialog({
title: t('settings.bgVideoUrlTitle'),
message: t('settings.bgVideoUrlMessage'),
defaultValue: store.scene.backgroundVideo?.src ?? '',
confirmLabel: t('settings.bgVideoUrlConfirm'),
})
if (!url) return
try {
const probe = await probeVideo(url.trim(), url.trim())
await applyProbedVideo(probe)
} catch (err) {
bgVideoError.value = err instanceof Error ? err.message : String(err)
}
}
function clearBgVideo() {
store.setBackgroundVideo(null)
bgVideoError.value = null
}
// Right-click on the BG video button surfaces URL / clear without
// cluttering the primary click target.
async function onBgVideoContext() {
if (store.scene.backgroundVideo) {
const ok = await confirmDialog({
title: t('settings.bgVideoClear'),
message: bgVideoDisplayName(),
confirmLabel: t('settings.bgVideoClear'),
})
if (ok) clearBgVideo()
return
}
await pickBgVideoUrl()
}
function bgVideoDisplayName(): string {
const v = store.scene.backgroundVideo
if (!v) return ''
if (v.src.startsWith('data:')) return t('settings.bgVideoLocalFile')
try {
const u = new URL(v.src)
return u.pathname.split('/').filter(Boolean).pop() || u.hostname
} catch {
return v.src.length > 48 ? v.src.slice(0, 48) + '…' : v.src
}
}
const width = computed({
get: () => store.scene.canvas.width,
set: (v: number) => store.setCanvasSize(v, store.scene.canvas.height),
})
const height = computed({
get: () => store.scene.canvas.height,
set: (v: number) => store.setCanvasSize(store.scene.canvas.width, v),
})
const background = computed({
get: () => store.scene.canvas.background,
set: (v: string) => store.setCanvasBackground(v),
})
const duration = computed({
get: () => store.scene.duration,
set: (v: number) => store.setDuration(v),
})
const fps = computed({
get: () => store.scene.fps,
set: (v: number) => store.setFps(v),
})
function applyPreset(p: CanvasPreset) {
store.setCanvasSize(p.w, p.h)
}
const presetAddError = ref<string | null>(null)
async function onAddPreset() {
presetAddError.value = null
while (true) {
const input = await promptDialog({
title: t('settings.presetAddTitle'),
message: presetAddError.value
? `${t('settings.presetAddInvalid')} — ${t('settings.presetAddMessage')}`
: t('settings.presetAddMessage'),
placeholder: t('settings.presetAddPlaceholder'),
confirmLabel: t('settings.presetAddConfirm'),
})
if (input === null) {
presetAddError.value = null
return
}
const parsed = parsePresetInput(input)
if (!parsed) {
presetAddError.value = t('settings.presetAddInvalid')
continue
}
addPreset({ label: presetLabel(parsed.w, parsed.h), w: parsed.w, h: parsed.h })
presetAddError.value = null
return
}
}
async function onRemovePreset(p: CanvasPreset) {
const ok = await confirmDialog({
title: t('settings.presetRemoveTitle'),
message: t('settings.presetRemoveMessage', { label: p.label }),
confirmLabel: t('settings.presetRemoveConfirm'),
variant: 'danger',
})
if (ok) removePreset(p)
}
</script>
<template>
<aside
ref="rootEl"
class="document-drawer"
:class="{ open }"
>
<div class="drawer-panel">
<header class="drawer-header">
<label class="name-field">
<span class="name-label">{{ t('documentDrawer.projectName') }}</span>
<input
class="name-input"
type="text"
:value="nameDraft"
:placeholder="t('app.untitledProject')"
@input="nameDraft = ($event.target as HTMLInputElement).value"
@change="commitName"
@blur="commitName"
@keydown="onNameKeyDown"
/>
</label>
</header>
<div class="drawer-body">
<div class="settings-complex">
<div class="fields-row">
<div class="fields-group">
<label class="field">
<span>{{ t('settings.width') }}</span>
<input type="number" min="1" step="1" v-model.number="width" />
</label>
<label class="field">
<span>{{ t('settings.height') }}</span>
<input type="number" min="1" step="1" v-model.number="height" />
</label>
<label class="field">
<span>{{ t('settings.duration') }}</span>
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
</label>
<label class="field">
<span>{{ t('settings.fps') }}</span>
<input type="number" min="1" step="1" v-model.number="fps" />
</label>
</div>
<div class="fields-group">
<label class="field color-field">
<span>{{ t('settings.background') }}</span>
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="background"
@input="background = ($event.target as HTMLInputElement).value"
/>
<span class="hex">{{ background.toUpperCase() }}</span>
</div>
</label>
<label class="field bg-video-field">
<span>{{ t('settings.bgVideoLabel') }}</span>
<button
class="bg-video-btn"
v-tooltip="t('settings.bgVideoPicker')"
@click="pickBgVideoFile"
@contextmenu.prevent="onBgVideoContext"
>
<PhFilmReel :size="12" />
<span v-if="store.scene.backgroundVideo" class="bg-video-name">
{{ bgVideoDisplayName() }}
</span>
<span v-else class="bg-video-empty">
{{ t('settings.bgVideoButton') }}
</span>
<button
v-if="store.scene.backgroundVideo"
class="bg-video-clear"
v-tooltip="t('settings.bgVideoClear')"
@click.stop="clearBgVideo"
>
<PhX :size="10" />
</button>
</button>
</label>
</div>
</div>
<div class="settings-divider"></div>
<div class="presets-row">
<button
v-for="p in presets"
:key="`${p.w}x${p.h}`"
class="preset"
:class="{ active: width === p.w && height === p.h }"
v-tooltip="t('settings.presetRemoveTitle')"
@click="applyPreset(p)"
@contextmenu.prevent="onRemovePreset(p)"
>
{{ p.label }}
</button>
<button
class="preset preset-add"
v-tooltip="t('settings.presetAddTooltip')"
@click="onAddPreset"
>
<PhPlus :size="10" />
</button>
</div>
</div>
<p v-if="bgVideoError" class="error-line">{{ bgVideoError }}</p>
<input
ref="bgFileInput"
type="file"
accept="video/*"
style="display: none"
@change="onBgFileSelected"
/>
</div>
</div>
</aside>
</template>
<style scoped>
/* Slides out from under the topbar. The aside is anchored to the top
edge of `.app` (topbar height = 48px), hidden above via translateY
when closed, and pops into place when opened. Hidden state keeps
pointer-events off so the invisible panel doesn't swallow clicks. */
.document-drawer {
position: absolute;
top: 48px;
left: 0;
right: 0;
height: 196px;
z-index: 40;
background: var(--panel);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-md, 0 6px 18px rgba(0, 0, 0, 0.25));
transform: translateY(calc(-100% - 48px));
transition: transform 220ms var(--ease);
pointer-events: none;
}
.document-drawer.open {
transform: translateY(0);
pointer-events: auto;
}
.drawer-panel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.drawer-header {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
}
.name-field {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 100%;
max-width: 420px;
}
.name-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.name-input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 10px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 13px;
font-weight: 500;
text-align: center;
}
.name-input:focus {
outline: none;
border-color: var(--accent);
}
.drawer-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 8px;
}
/* The settings complex sizes itself to whichever row is wider —
the fields row with its space-between, or the presets row. More
presets widen the complex, which in turn pushes the right-hand
input group (color + video) further right so it stays flush with
the last preset. */
.settings-complex {
display: inline-flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
max-width: 100%;
}
.fields-row {
display: flex;
gap: 24px;
align-items: flex-end;
justify-content: space-between;
}
.fields-group {
display: flex;
gap: 12px;
align-items: flex-end;
flex: 0 0 auto;
}
.settings-divider {
height: 1px;
background: var(--border);
width: 100%;
}
.field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
flex: 0 0 auto;
}
.field > span {
padding-left: 2px;
}
.field input[type='number'] {
width: 78px;
height: 26px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 0 8px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 12px;
line-height: 24px;
text-transform: none;
letter-spacing: 0;
box-sizing: border-box;
}
.color-row {
display: flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 6px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-sizing: border-box;
}
.color-swatch {
width: 22px;
height: 18px;
padding: 0;
border: 1px solid var(--border);
border-radius: 3px;
background: transparent;
cursor: pointer;
flex-shrink: 0;
box-sizing: border-box;
}
.color-swatch::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-swatch::-webkit-color-swatch {
border: none;
border-radius: 3px;
}
.color-swatch::-moz-color-swatch {
border: none;
border-radius: 3px;
}
.hex {
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--text-dim-strong);
text-transform: none;
letter-spacing: 0;
}
.bg-video-field {
min-width: 180px;
}
.bg-video-btn {
display: flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 8px;
box-sizing: border-box;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 11px;
font: inherit;
text-transform: none;
letter-spacing: 0;
cursor: pointer;
min-width: 180px;
max-width: 240px;
}
.bg-video-btn:hover {
border-color: var(--border-strong, var(--accent));
}
.bg-video-name {
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
min-width: 0;
text-align: left;
}
.bg-video-empty {
color: var(--text-dim);
font-style: italic;
flex: 1 1 auto;
text-align: left;
}
.bg-video-clear {
padding: 2px;
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
border-radius: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.bg-video-clear:hover {
color: var(--danger);
background: var(--panel-alt);
}
.presets-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
justify-content: flex-start;
max-width: 100%;
}
.preset {
padding: 3px 10px;
background: var(--panel-alt);
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
font: inherit;
font-size: 10px;
}
.preset:hover:not(:disabled) {
background: var(--panel-hover);
color: var(--text);
}
.preset.active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.preset-add {
padding: 3px 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
}
.preset-add:hover {
color: var(--text);
}
.error-line {
margin: 0;
color: var(--danger);
font-size: 11px;
text-align: center;
}
</style>
+5 -255
View File
@@ -1,29 +1,24 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
PhFilmReel,
PhFolder,
PhFrameCorners,
PhMoon,
PhSlidersHorizontal,
PhSun,
PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useClipartStore } from '@/stores/clipart'
import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme'
import { useTooltipPref } from '@/composables/useTooltipPref'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { showLocked } from '@/composables/useShowLocked'
import { Modal, confirmDialog, promptDialog } from '@/components/ui'
import { Modal } from '@/components/ui'
import { setLocale, t, useI18n } from '@/i18n'
import type { Locale } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const clipart = useClipartStore()
const { theme, setTheme } = useTheme()
const { enabled: tooltipsEnabled } = useTooltipPref()
@@ -40,178 +35,20 @@ watch(
},
)
type TabId = 'document' | 'clipart' | 'interface'
type TabId = 'clipart' | 'interface'
const tabs: { id: TabId; labelKey: string; icon: typeof PhFrameCorners }[] = [
{ id: 'document', labelKey: 'settings.tab.document', icon: PhFrameCorners },
const tabs: { id: TabId; labelKey: string; icon: typeof PhFolder }[] = [
{ id: 'clipart', labelKey: 'settings.tab.clipart', icon: PhFolder },
{ id: 'interface', labelKey: 'settings.tab.interface', icon: PhSlidersHorizontal },
]
const activeTab = ref<TabId>('document')
const bgVideoError = ref<string | null>(null)
const bgFileInput = ref<HTMLInputElement | null>(null)
interface ProbedVideo {
src: string
width: number
height: number
duration: number
name: string
}
function probeVideo(src: string, name: string): Promise<ProbedVideo> {
return new Promise((resolve, reject) => {
const v = document.createElement('video')
v.preload = 'metadata'
v.muted = true
v.src = src
v.onloadedmetadata = () => {
resolve({
src,
width: v.videoWidth || 1280,
height: v.videoHeight || 720,
duration: Number.isFinite(v.duration) ? v.duration : 0,
name,
})
}
v.onerror = () => reject(new Error('Could not read video metadata.'))
})
}
async function applyProbedVideo(probe: ProbedVideo) {
const stageMatches =
probe.width === store.scene.canvas.width && probe.height === store.scene.canvas.height
if (!stageMatches) {
const ok = await confirmDialog({
title: t('settings.bgVideoStageSizeTitle'),
message: t('settings.bgVideoStageSizeMessage', {
w: probe.width,
h: probe.height,
cw: store.scene.canvas.width,
ch: store.scene.canvas.height,
}),
confirmLabel: t('settings.bgVideoStageSizeConfirm'),
cancelLabel: t('settings.bgVideoStageSizeCancel'),
})
if (ok) store.setCanvasSize(probe.width, probe.height)
}
if (probe.duration > 0 && probe.duration > store.scene.duration) {
const ok = await confirmDialog({
title: t('settings.bgVideoDurationTitle'),
message: t('settings.bgVideoDurationMessage', {
d: probe.duration.toFixed(2),
cd: store.scene.duration,
}),
confirmLabel: t('settings.bgVideoDurationConfirm'),
cancelLabel: t('settings.bgVideoDurationCancel'),
})
if (ok) store.setDuration(probe.duration)
}
store.setBackgroundVideo({ src: probe.src, muted: true })
}
async function pickBgVideoFile() {
bgVideoError.value = null
bgFileInput.value?.click()
}
async function onBgFileSelected(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
try {
const reader = new FileReader()
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
const probe = await probeVideo(dataUrl, file.name)
await applyProbedVideo(probe)
} catch (err) {
bgVideoError.value = err instanceof Error ? err.message : String(err)
}
}
async function pickBgVideoUrl() {
bgVideoError.value = null
const url = await promptDialog({
title: t('settings.bgVideoUrlTitle'),
message: t('settings.bgVideoUrlMessage'),
defaultValue: store.scene.backgroundVideo?.src ?? '',
confirmLabel: t('settings.bgVideoUrlConfirm'),
})
if (!url) return
try {
const probe = await probeVideo(url.trim(), url.trim())
await applyProbedVideo(probe)
} catch (err) {
bgVideoError.value = err instanceof Error ? err.message : String(err)
}
}
function clearBgVideo() {
store.setBackgroundVideo(null)
bgVideoError.value = null
}
function bgVideoDisplayName(): string {
const v = store.scene.backgroundVideo
if (!v) return ''
if (v.src.startsWith('data:')) return t('settings.bgVideoLocalFile')
try {
const u = new URL(v.src)
return u.pathname.split('/').filter(Boolean).pop() || u.hostname
} catch {
return v.src.length > 48 ? v.src.slice(0, 48) + '…' : v.src
}
}
const width = computed({
get: () => store.scene.canvas.width,
set: (v: number) => store.setCanvasSize(v, store.scene.canvas.height),
})
const height = computed({
get: () => store.scene.canvas.height,
set: (v: number) => store.setCanvasSize(store.scene.canvas.width, v),
})
const background = computed({
get: () => store.scene.canvas.background,
set: (v: string) => store.setCanvasBackground(v),
})
const duration = computed({
get: () => store.scene.duration,
set: (v: number) => store.setDuration(v),
})
const fps = computed({
get: () => store.scene.fps,
set: (v: number) => store.setFps(v),
})
const activeTab = ref<TabId>('clipart')
const selectedLocale = computed({
get: () => locale.value,
set: (value: Locale) => setLocale(value),
})
const presets: { label: string; w: number; h: number }[] = [
{ label: '16:9 · 1280×720', w: 1280, h: 720 },
{ label: '16:9 · 1920×1080', w: 1920, h: 1080 },
{ label: '1:1 · 1080×1080', w: 1080, h: 1080 },
{ label: '9:16 · 1080×1920', w: 1080, h: 1920 },
{ label: '4:5 · 1080×1350', w: 1080, h: 1350 },
]
function applyPreset(p: { w: number; h: number }) {
store.setCanvasSize(p.w, p.h)
}
function close() {
emit('update:modelValue', false)
}
@@ -252,94 +89,7 @@ function applyTheme(t: Theme) {
</nav>
<div class="content">
<section v-if="activeTab === 'document'">
<h3>{{ t('settings.canvas') }}</h3>
<div class="row">
<label>
{{ t('settings.width') }}
<input type="number" min="1" step="1" v-model.number="width" />
</label>
<label>
{{ t('settings.height') }}
<input type="number" min="1" step="1" v-model.number="height" />
</label>
</div>
<div class="presets">
<button
v-for="p in presets"
:key="p.label"
class="preset"
:class="{ active: width === p.w && height === p.h }"
@click="applyPreset(p)"
>
{{ p.label }}
</button>
</div>
<label class="stacked">
{{ t('settings.background') }}
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="background"
@input="background = ($event.target as HTMLInputElement).value"
/>
<span class="hex">{{ background.toUpperCase() }}</span>
</div>
</label>
<div class="clipart-row">
<div class="clipart-label">{{ t('settings.bgVideoLabel') }}</div>
<div class="clipart-value">
<PhFilmReel :size="13" />
<span v-if="store.scene.backgroundVideo" class="clipart-folder-name">
{{ bgVideoDisplayName() }}
</span>
<span v-else class="clipart-folder-empty">{{ t('settings.bgVideoNone') }}</span>
<button
v-if="store.scene.backgroundVideo"
class="ghost-btn inline-clear"
v-tooltip="t('settings.bgVideoClear')"
@click="clearBgVideo"
>
<PhX :size="12" />
</button>
</div>
</div>
<div class="clipart-actions">
<button class="primary-btn" @click="pickBgVideoFile">
{{ t('settings.bgVideoPickFile') }}
</button>
<button class="ghost-btn" @click="pickBgVideoUrl">
{{ t('settings.bgVideoPickUrl') }}
</button>
</div>
<input
ref="bgFileInput"
type="file"
accept="video/*"
style="display: none"
@change="onBgFileSelected"
/>
<p v-if="bgVideoError" class="error-line">{{ bgVideoError }}</p>
<p class="hint">{{ t('settings.bgVideoHint') }}</p>
<h3 class="sub-heading">{{ t('settings.timing') }}</h3>
<div class="row">
<label>
{{ t('settings.duration') }}
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
</label>
<label>
{{ t('settings.fps') }}
<input type="number" min="1" step="1" v-model.number="fps" />
</label>
</div>
<p class="hint">{{ t('settings.timelineHint') }}</p>
</section>
<section v-else-if="activeTab === 'clipart'">
<section v-if="activeTab === 'clipart'">
<h3>{{ t('settings.tab.clipart') }}</h3>
<div class="clipart-row">
<div class="clipart-label">{{ t('clipart.folderLabel') }}</div>
+54 -6
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { PhCaretLeft, PhCaretRight, PhCopy, PhFilmSlate, PhPlus, PhTrash } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
import { confirmDialog, dialogState, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
import { useFullscreen } from '@/composables/useFullscreen'
@@ -21,6 +21,32 @@ function onDrawerLeave() {
if (isFullscreen.value) open.value = false
}
// ESC closes the drawer when opened via click. Skipped in fullscreen
// (hover-leave already handles it) and while a dialog is on top — the
// dialog owns ESC in that case. If the row context menu is open, ESC
// dismisses that first and the drawer only on the next press.
function onDocKeyDown(e: KeyboardEvent) {
if (!open.value || isFullscreen.value) return
if (e.key !== 'Escape') return
if (dialogState.value) return
if (menu.value) {
e.preventDefault()
menu.value = null
return
}
e.preventDefault()
open.value = false
}
watch(open, (v) => {
if (v) document.addEventListener('keydown', onDocKeyDown)
else document.removeEventListener('keydown', onDocKeyDown)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onDocKeyDown)
})
interface MenuState {
pageId: string
x: number
@@ -100,7 +126,11 @@ watch(menu, (m) => {
</script>
<template>
<aside class="scene-drawer" :class="{ open }" @mouseleave="onDrawerLeave">
<aside
class="scene-drawer"
:class="{ open, fullscreen: isFullscreen }"
@mouseleave="onDrawerLeave"
>
<button
class="drawer-tab"
v-tooltip="open ? t('scenes.close') : t('scenes.open')"
@@ -210,16 +240,34 @@ watch(menu, (m) => {
transition: opacity 140ms var(--ease);
}
.scene-drawer:hover .drawer-tab :deep(svg),
.scene-drawer.open .drawer-tab :deep(svg) {
.scene-drawer:hover:not(.fullscreen) .drawer-tab :deep(svg),
.scene-drawer.open:not(.fullscreen) .drawer-tab :deep(svg) {
opacity: 1;
}
.scene-drawer:hover .drawer-tab {
.scene-drawer:hover:not(.fullscreen) .drawer-tab {
background: var(--panel-alt);
color: var(--text);
}
.scene-drawer.fullscreen .drawer-tab {
cursor: default;
background: transparent !important;
}
/* Once the fullscreen hover opens the drawer, the tab column has
served its purpose (hover-detect) — reclaim its 24px so the panel
uses the full width. The aside itself still catches mouseleave. */
.scene-drawer.fullscreen.open .drawer-tab {
display: none;
}
/* Tab column is gone in fullscreen — reclaim its 24 px so the stage
area grows by the same amount instead of leaving a blank strip. */
.scene-drawer.fullscreen.open {
width: 300px;
}
.scene-drawer.open .drawer-tab {
flex: 0 0 24px;
width: 24px;
+194 -37
View File
@@ -33,6 +33,7 @@ import { useSceneStore } from '@/stores/scene'
import { useClipartStore } from '@/stores/clipart'
import { importAudioFile } from '@/lib/fileLoader'
import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDragSource'
import { clearClipDragSource, clipDragSource } from '@/composables/useClipDragSource'
import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform'
import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
@@ -1663,21 +1664,83 @@ function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): numbe
return ratio * duration.value
}
function onClipLaneDragOver(e: DragEvent, _clipTrackId: string) {
if (e.dataTransfer?.types.includes('application/x-animatr-clip')) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
interface ClipInstanceGhost {
targetTrackId: string
startTime: number
length: number
}
/** Hover-preview for dropping a library clip onto a clip lane. Mirrors
* `audioClipGhost`. Cleared whenever the cursor leaves the lane or
* the drop lands. */
const clipInstanceGhost = ref<ClipInstanceGhost | null>(null)
/** Full-window collision: does [start, start+length] overlap any
* existing clip instance on this track? Stricter than a point-in-clip
* check — catches the case where the drop point is free but the
* dropped tail would land on top of a neighbour. Mirrors
* `audioWindowCollides`. */
function clipWindowCollides(
trackId: string,
start: number,
length: number,
): boolean {
const end = start + length
const siblings = clipInstancesByTrack.value[trackId] ?? []
for (const ci of siblings) {
const cEnd = ci.startTime + visibleDurationOf(ci)
if (cEnd > start && ci.startTime < end) return true
}
return false
}
function onClipLaneDragOver(e: DragEvent, clipTrackId: string) {
const types = e.dataTransfer?.types
if (!types?.includes('application/x-animatr-clip')) return
e.preventDefault()
if (!e.dataTransfer) return
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromEvent(e, el)
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
const src = clipDragSource.value
const length = Math.max(0, src?.duration ?? 0)
const collides =
track?.locked || clipWindowCollides(clipTrackId, time, length)
e.dataTransfer.dropEffect = collides ? 'none' : 'copy'
if (!collides && src) {
clipInstanceGhost.value = {
targetTrackId: clipTrackId,
startTime: time,
length,
}
} else {
clipInstanceGhost.value = null
}
}
function onClipLaneDragLeave() {
clipInstanceGhost.value = null
}
function onClipLaneDrop(e: DragEvent, clipTrackId: string) {
e.preventDefault()
clipInstanceGhost.value = null
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
if (track?.locked) return
if (track?.locked) {
clearClipDragSource()
return
}
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromEvent(e, el)
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
if (clipId) store.addClipInstance(clipId, time, clipTrackId)
// The library payload is authoritative for the window check — the
// composable source may have been cleared by a racing dragend.
const clip = clipId ? store.project.clips.find((c) => c.id === clipId) : null
const length = clip ? Math.max(0, clip.duration) : 0
clearClipDragSource()
if (!clipId) return
if (clipWindowCollides(clipTrackId, time, length)) return
store.addClipInstance(clipId, time, clipTrackId)
}
interface ClipMoveState {
@@ -1720,7 +1783,7 @@ function onClipBlockPointerMove(e: PointerEvent) {
if (pps <= 0) return
const deltaSec = deltaPx / pps
const newStart = Math.max(0, m.initialStartTime + deltaSec)
store.moveClipInstance(m.instanceId, newStart)
store.setClipInstancePosition(m.instanceId, newStart)
}
function onClipBlockPointerUp(e: PointerEvent) {
@@ -1988,19 +2051,33 @@ async function onAudioLaneDrop(e: DragEvent, audioTrackId: string) {
}
}
async function onAudioClipContextMenu(e: MouseEvent, clip: AudioClip) {
interface AudioClipMenuState {
clipId: string
x: number
y: number
}
const audioClipMenu = ref<AudioClipMenuState | null>(null)
const audioClipMenuRef = ref<HTMLDivElement | null>(null)
function onAudioClipContextMenu(e: MouseEvent, clip: AudioClip) {
e.preventDefault()
e.stopPropagation()
// v1: a single "remove" action. The right-click context menu grows
// later once trim handles and per-clip properties land.
const ok = await confirmDialog({
title: t('timeline.removeAudioClipDialog.title'),
message: t('timeline.removeAudioClipDialog.message'),
confirmLabel: t('timeline.removeAudioClipDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeAudioClip(clip.id)
audioClipMenu.value = { clipId: clip.id, x: e.clientX, y: e.clientY }
}
function duplicateAudioClipFromMenu() {
const m = audioClipMenu.value
if (!m) return
store.duplicateAudioClip(m.clipId)
audioClipMenu.value = null
}
function deleteAudioClipFromMenu() {
const m = audioClipMenu.value
if (!m) return
store.removeAudioClip(m.clipId)
audioClipMenu.value = null
}
interface AudioClipMoveState {
@@ -2381,22 +2458,33 @@ function onClipBlockDblClick(ci: ClipInstance) {
store.enterClip(ci.clipId)
}
async function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
interface ClipInstanceMenuState {
instanceId: string
x: number
y: number
}
const clipInstanceMenu = ref<ClipInstanceMenuState | null>(null)
const clipInstanceMenuRef = ref<HTMLDivElement | null>(null)
function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
e.preventDefault()
const clip = clipById.value.get(ci.clipId)
const ok = await confirmDialog({
title: t('timeline.removeInstanceDialog.title'),
message: clip
? t('timeline.removeInstanceDialog.message', {
name: clip.name,
time: ci.startTime.toFixed(2),
})
: t('timeline.removeInstanceDialog.generic'),
confirmLabel: t('timeline.removeInstanceDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeClipInstance(ci.id)
e.stopPropagation()
clipInstanceMenu.value = { instanceId: ci.id, x: e.clientX, y: e.clientY }
}
function duplicateClipInstanceFromMenu() {
const m = clipInstanceMenu.value
if (!m) return
store.duplicateClipInstance(m.instanceId)
clipInstanceMenu.value = null
}
function deleteClipInstanceFromMenu() {
const m = clipInstanceMenu.value
if (!m) return
store.removeClipInstance(m.instanceId)
clipInstanceMenu.value = null
}
const trackMenu = ref<TrackMenuState | null>(null)
@@ -2622,6 +2710,16 @@ function onDocumentPointerDown(e: PointerEvent) {
} else if (scriptFrameMenu.value) {
scriptFrameMenu.value = null
}
if (clipInstanceMenu.value && clipInstanceMenuRef.value && e.target instanceof Node) {
if (!clipInstanceMenuRef.value.contains(e.target)) clipInstanceMenu.value = null
} else if (clipInstanceMenu.value) {
clipInstanceMenu.value = null
}
if (audioClipMenu.value && audioClipMenuRef.value && e.target instanceof Node) {
if (!audioClipMenuRef.value.contains(e.target)) audioClipMenu.value = null
} else if (audioClipMenu.value) {
audioClipMenu.value = null
}
}
function onKeyDown(e: KeyboardEvent) {
@@ -2629,6 +2727,8 @@ function onKeyDown(e: KeyboardEvent) {
menu.value = null
trackMenu.value = null
scriptFrameMenu.value = null
clipInstanceMenu.value = null
audioClipMenu.value = null
}
}
@@ -2694,15 +2794,18 @@ function onDocumentKeyDown(e: KeyboardEvent) {
}
}
watch([menu, trackMenu, scriptFrameMenu], ([m, tm, sm]) => {
if (m || tm || sm) {
watch(
[menu, trackMenu, scriptFrameMenu, clipInstanceMenu, audioClipMenu],
([m, tm, sm, cim, acm]) => {
if (m || tm || sm || cim || acm) {
document.addEventListener('pointerdown', onDocumentPointerDown, true)
document.addEventListener('keydown', onKeyDown)
} else {
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
document.removeEventListener('keydown', onKeyDown)
}
})
},
)
async function clampMenuIntoViewport(
getEl: () => HTMLElement | null,
@@ -2739,6 +2842,16 @@ watch(scriptFrameMenu, (sm) => {
clampMenuIntoViewport(() => scriptFrameMenuRef.value, sm.x, sm.y)
})
watch(clipInstanceMenu, (cim) => {
if (!cim) return
clampMenuIntoViewport(() => clipInstanceMenuRef.value, cim.x, cim.y)
})
watch(audioClipMenu, (acm) => {
if (!acm) return
clampMenuIntoViewport(() => audioClipMenuRef.value, acm.x, acm.y)
})
function onWheel(e: WheelEvent) {
if (!scrollEl.value) return
if (e.ctrlKey || e.metaKey) {
@@ -3249,9 +3362,18 @@ onBeforeUnmount(() => {
class="track-lane clip-lane"
:style="{ width: laneWidth + 'px' }"
@dragover.stop="onClipLaneDragOver($event, track.clipTrackId)"
@dragleave.stop="onClipLaneDragLeave"
@drop.stop="onClipLaneDrop($event, track.clipTrackId)"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-if="clipInstanceGhost && clipInstanceGhost.targetTrackId === track.clipTrackId"
class="clip-instance-ghost"
:style="{
left: pxFromTime(clipInstanceGhost.startTime) + 'px',
width: Math.max(6, pxFromTime(clipInstanceGhost.length)) + 'px',
}"
></span>
<span
v-for="ci in clipInstancesByTrack[track.clipTrackId] ?? []"
:key="ci.id"
@@ -3799,6 +3921,40 @@ onBeforeUnmount(() => {
</button>
</div>
<div
v-if="clipInstanceMenu"
ref="clipInstanceMenuRef"
class="kf-menu"
:style="{ left: clipInstanceMenu.x + 'px', top: clipInstanceMenu.y + 'px' }"
>
<button class="kf-menu-item" @click="duplicateClipInstanceFromMenu">
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteClipInstanceFromMenu">
<PhTrash :size="12" class="kf-menu-icon" />
{{ t('timeline.delete') }}
</button>
</div>
<div
v-if="audioClipMenu"
ref="audioClipMenuRef"
class="kf-menu"
:style="{ left: audioClipMenu.x + 'px', top: audioClipMenu.y + 'px' }"
>
<button class="kf-menu-item" @click="duplicateAudioClipFromMenu">
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteAudioClipFromMenu">
<PhTrash :size="12" class="kf-menu-icon" />
{{ t('timeline.delete') }}
</button>
</div>
<ScriptFrameDialog
v-model="scriptDialogOpen"
:frame-id="scriptDialogFrameId"
@@ -5041,7 +5197,8 @@ onBeforeUnmount(() => {
cursor: default;
}
.audio-clip-ghost {
.audio-clip-ghost,
.clip-instance-ghost {
position: absolute;
top: 4px;
bottom: 4px;
+1 -1
View File
@@ -2,5 +2,5 @@ export { default as DialogsHost } from './DialogsHost.vue'
export { default as IconButton } from './IconButton.vue'
export { default as Modal } from './Modal.vue'
export { default as Toast } from './Toast.vue'
export { confirmDialog, promptDialog } from './dialogs'
export { confirmDialog, dialogState, promptDialog } from './dialogs'
export type { ConfirmOptions, PromptOptions } from './dialogs'
+52
View File
@@ -26,3 +26,55 @@ export function setAudioDragSource(src: AudioDragSource): void {
export function clearAudioDragSource(): void {
audioDragSource.value = null
}
/**
* Build a styled DOM node that mimics Timeline's `.audio-clip-block`
* look (rounded pill, cyan gradient, music-note + label) and install
* it as the drag image. The element is appended offscreen, handed to
* `setDragImage`, and removed on the next tick — the browser snapshots
* it during that frame.
*/
export function installAudioClipDragImage(
e: DragEvent,
opts: { name: string },
): void {
if (!e.dataTransfer) return
const ghost = document.createElement('div')
ghost.style.cssText = [
'position: fixed',
'top: -10000px',
'left: -10000px',
'display: inline-flex',
'align-items: center',
'gap: 6px',
'height: 26px',
'padding: 0 10px',
'background: linear-gradient(to bottom, rgba(14,165,233,0.45), rgba(14,165,233,0.20))',
'border: 1px solid rgba(14,165,233,0.75)',
'border-radius: 4px',
'color: #e6edf3',
'font: 500 11px/1.4 system-ui, sans-serif',
'white-space: nowrap',
'box-shadow: 0 2px 6px rgba(0,0,0,0.35)',
'pointer-events: none',
].join('; ')
// Inline SVG music note so the ghost is self-contained — no icon
// font dependency during the one-frame snapshot.
ghost.innerHTML =
'<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" style="flex-shrink:0;opacity:0.9;">' +
'<path d="M13 2v8.05A2.5 2.5 0 1 0 14 12V4.2l-6 1.2V11.05A2.5 2.5 0 1 0 9 13V3l4-1z"/>' +
'</svg>'
const label = document.createElement('span')
label.textContent = opts.name
label.style.cssText = 'overflow:hidden;text-overflow:ellipsis;max-width:200px;'
ghost.appendChild(label)
document.body.appendChild(ghost)
try {
e.dataTransfer.setDragImage(ghost, 12, 13)
} catch {
/* ignore */
}
setTimeout(() => ghost.remove(), 0)
}
+97
View File
@@ -0,0 +1,97 @@
import { ref, watch } from 'vue'
export interface CanvasPreset {
label: string
w: number
h: number
}
const STORAGE_KEY = 'ruwio:canvasPresets'
const DEFAULT_PRESETS: CanvasPreset[] = [
{ label: '16:9 · 1280×720', w: 1280, h: 720 },
{ label: '16:9 · 1920×1080', w: 1920, h: 1080 },
{ label: '1:1 · 1080×1080', w: 1080, h: 1080 },
{ label: '9:16 · 1080×1920', w: 1080, h: 1920 },
{ label: '4:5 · 1080×1350', w: 1080, h: 1350 },
]
function isPreset(p: unknown): p is CanvasPreset {
if (typeof p !== 'object' || p === null) return false
const r = p as Record<string, unknown>
return (
typeof r.label === 'string' &&
typeof r.w === 'number' &&
typeof r.h === 'number' &&
r.w > 0 &&
r.h > 0
)
}
function readInitial(): CanvasPreset[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return [...DEFAULT_PRESETS]
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return [...DEFAULT_PRESETS]
const valid = parsed.filter(isPreset)
return valid.length > 0 ? valid : [...DEFAULT_PRESETS]
} catch {
return [...DEFAULT_PRESETS]
}
}
const presets = ref<CanvasPreset[]>(readInitial())
watch(
presets,
(v) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(v))
} catch {
/* storage disabled — stay in-memory */
}
},
{ deep: true },
)
function gcd(a: number, b: number): number {
a = Math.abs(a)
b = Math.abs(b)
while (b !== 0) {
const t = b
b = a % b
a = t
}
return a || 1
}
export function presetLabel(w: number, h: number): string {
const g = gcd(w, h)
return `${w / g}:${h / g} · ${w}×${h}`
}
export function parsePresetInput(input: string): { w: number; h: number } | null {
const m = input.trim().match(/^(\d+)\s*[x×*]\s*(\d+)$/i)
if (!m) return null
const w = Number(m[1])
const h = Number(m[2])
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null
return { w, h }
}
export function useCanvasPresets() {
return {
presets,
addPreset(p: CanvasPreset) {
const exists = presets.value.some((x) => x.w === p.w && x.h === p.h)
if (exists) return
presets.value = [...presets.value, p]
},
removePreset(target: CanvasPreset) {
presets.value = presets.value.filter(
(p) => !(p.w === target.w && p.h === target.h),
)
},
}
}
+29
View File
@@ -0,0 +1,29 @@
import { ref } from 'vue'
/**
* Shared reactive state describing the clip the user is dragging out
* of the clip library. Read by Timeline's clip lane to render a ghost
* preview at the hover position — mirrors `useAudioDragSource`.
*
* The browser hides `dataTransfer.getData()` during `dragover`, so we
* stash the source info on dragstart and clear it on dragend/drop.
*/
export interface ClipDragSource {
/** Matches the `Clip` id in the project library so Timeline can
* check collisions against its visible duration. */
clipId: string
/** Seconds — the library clip's full duration (no trim applied yet). */
duration: number
/** Display label; currently unused by the ghost but handy for tooling. */
name: string
}
export const clipDragSource = ref<ClipDragSource | null>(null)
export function setClipDragSource(src: ClipDragSource): void {
clipDragSource.value = src
}
export function clearClipDragSource(): void {
clipDragSource.value = null
}
+20
View File
@@ -0,0 +1,20 @@
import { ref } from 'vue'
// Shared open state so the topbar project-name button, the drawer's
// own tab, and any future trigger all flip the same flag.
const open = ref(false)
export function useDocumentDrawer() {
return {
open,
toggle() {
open.value = !open.value
},
openDrawer() {
open.value = true
},
closeDrawer() {
open.value = false
},
}
}
+31
View File
@@ -0,0 +1,31 @@
import { ref, toRaw } from 'vue'
import { saveProjectToStore } from '@/lib/projectStore'
import { useSceneStore } from '@/stores/scene'
// Module-scoped so topbar, document drawer, save/load flow and
// welcome dialog all see the same name/id without prop plumbing.
const projectName = ref<string | null>(null)
const projectId = ref<string | null>(null)
export function useProjectName() {
return { projectName, projectId, renameProject }
}
// Commit a new name. Trims, no-ops on empty or unchanged input. If
// the project is already persisted in IDB, the record is rewritten
// so recents + next load use the new name. Unsaved projects just get
// the in-memory rename and the next save picks it up.
async function renameProject(next: string): Promise<void> {
const trimmed = next.trim()
if (!trimmed || trimmed === projectName.value) return
projectName.value = trimmed
if (projectId.value) {
const store = useSceneStore()
const project = JSON.parse(JSON.stringify(toRaw(store.project)))
const rec = await saveProjectToStore(project, {
id: projectId.value,
name: trimmed,
})
projectName.value = rec.name
}
}
+537 -1
View File
@@ -42,6 +42,7 @@ import Toast from '@/components/ui/Toast.vue'
import PropertiesPanel from '@/components/PropertiesPanel.vue'
import ClipartDrawer from '@/components/ClipartDrawer.vue'
import SceneDrawer from '@/components/SceneDrawer.vue'
import DocumentDrawer from '@/components/DocumentDrawer.vue'
import ActionEditor from '@/components/ActionEditor.vue'
import Timeline from '@/components/Timeline.vue'
import AssetSidebar from '@/components/AssetSidebar.vue'
@@ -55,7 +56,7 @@ import {
slicePeaksForClip,
} from '@/composables/useAudioWaveform'
import { vTooltip } from '@/directives/tooltip'
import { expect, suite, test } from './testRunner'
import { beforeEach, expect, suite, test } from './testRunner'
interface MountedHandle {
host: HTMLDivElement
@@ -2544,6 +2545,101 @@ export function registerAllTests(): void {
expect(ci.clipTrackId).toBe(t1.id)
})
test('setClipInstancePosition pushes neighbours forward when the moved clip collides on the right', () => {
const s = makeFreshStore()
const c = s.addClip('P')
c.duration = 2
const t = s.scene.clipTracks![0]
const a = s.addClipInstance(c.id, 0, t.id)! // 0..2
const b = s.addClipInstance(c.id, 3, t.id)! // 3..5
const cc = s.addClipInstance(c.id, 6, t.id)! // 6..8
// Move `a` forward onto `b`. Expect `b` to shunt to 2, `cc` stays.
expect(s.setClipInstancePosition(a.id, 2)).toBe(true)
expect(a.startTime).toBe(2)
expect(b.startTime).toBe(4)
expect(cc.startTime).toBe(6)
})
test('setClipInstancePosition pushes neighbours backward when the moved clip collides on the left', () => {
const s = makeFreshStore()
const c = s.addClip('P')
c.duration = 2
const t = s.scene.clipTracks![0]
const a = s.addClipInstance(c.id, 0, t.id)! // 0..2
const b = s.addClipInstance(c.id, 3, t.id)! // 3..5
const cc = s.addClipInstance(c.id, 6, t.id)! // 6..8
// Move `cc` backward onto `b`. Expect `b` to shunt left to 2 (abut
// `a` end) and `a` shifts to 0 (already there).
expect(s.setClipInstancePosition(cc.id, 4)).toBe(true)
expect(cc.startTime).toBe(4)
expect(b.startTime).toBe(2)
expect(a.startTime).toBe(0)
})
test('setClipInstancePosition clamps moved clip forward when backward cascade hits 0', () => {
// Pack the lane: three 2s clips abutting from 0. Drag the right-
// most left to 1 — there's no room for the chain at 0, so the
// moved clip should land at the first fitting slot (4) instead.
const s = makeFreshStore()
const c = s.addClip('Pack')
c.duration = 2
const t = s.scene.clipTracks![0]
const a = s.addClipInstance(c.id, 0, t.id)! // 0..2
const b = s.addClipInstance(c.id, 2, t.id)! // 2..4
const cc = s.addClipInstance(c.id, 4, t.id)! // 4..6
expect(s.setClipInstancePosition(cc.id, 1)).toBe(true)
expect(cc.startTime).toBe(4)
expect(a.startTime).toBe(0)
expect(b.startTime).toBe(2)
})
test('setClipInstancePosition returns false for unknown instance or unknown track', () => {
const s = makeFreshStore()
expect(s.setClipInstancePosition('no-such-instance', 0)).toBe(false)
const c = s.addClip('X')
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
expect(s.setClipInstancePosition(ci.id, 0, 'ghost-track')).toBe(false)
})
test('duplicateClipInstance clones onto the same track, offset by visible duration', () => {
const s = makeFreshStore()
const c = s.addClip('D')
c.duration = 3
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 1, t.id)!
s.setClipInstanceTransform(ci.id, { x: 42, scaleX: 2 })
const newId = s.duplicateClipInstance(ci.id)!
expect(typeof newId).toBe('string')
expect(newId === ci.id).toBe(false)
const copy = s.activeScene.clipInstances.find((x) => x.id === newId)!
expect(copy.clipId).toBe(c.id)
expect(copy.clipTrackId).toBe(t.id)
expect(copy.startTime).toBe(1 + 3) // original.startTime + visible length
expect(copy.x).toBe(42)
expect(copy.scaleX).toBe(2)
})
test('duplicateClipInstance honours trim when computing the offset', () => {
const s = makeFreshStore()
const c = s.addClip('D')
c.duration = 4
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
s.trimClipInstance(ci.id, { trimStart: 0.5, trimEnd: 0.5 })
const newId = s.duplicateClipInstance(ci.id)!
const copy = s.activeScene.clipInstances.find((x) => x.id === newId)!
// visible length = 4 - 0.5 - 0.5 = 3 → copy lands at 3
expect(copy.startTime).toBe(3)
expect(copy.trimStart).toBe(0.5)
expect(copy.trimEnd).toBe(0.5)
})
test('duplicateClipInstance returns null for unknown id', () => {
const s = makeFreshStore()
expect(s.duplicateClipInstance('nope')).toBe(null)
})
test('moveTrack reorders the unified stack (layer above clip track)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 10, height: 10, fill: '#000' })
@@ -4271,6 +4367,243 @@ export function registerAllTests(): void {
})
})
suite('ui — DocumentDrawer', () => {
// The document-settings drawer slides down from under the topbar
// when the project-name button is clicked — no local tab button,
// no hover-to-peek. Opening is driven by the shared
// useDocumentDrawer() composable; outside-click and ESC close it.
// Document / canvas / bg-video / timing settings live here; the
// project name is edited inline in the header.
beforeEach(async () => {
// The drawer's open state + project name are module-scoped
// (shared composables). Reset both so tests don't leak state.
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
const { useProjectName } = await import('@/composables/useProjectName')
useDocumentDrawer().closeDrawer()
useProjectName().projectName.value = null
useProjectName().projectId.value = null
})
test('renders the drawer panel starting closed, no tab button', async () => {
makeFreshStore()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
expect(drawer).toBeTruthy()
expect(drawer.classList.contains('open')).toBe(false)
// The panel is always mounted (hidden via transform when closed).
expect(handle.host.querySelector('.drawer-panel')).toBeTruthy()
// The old tab trigger is gone — opening is composable-driven.
expect(handle.host.querySelector('.drawer-tab')).toBe(null)
handle.unmount()
})
test('toggle() via the composable flips .open on and off', async () => {
makeFreshStore()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
const dd = useDocumentDrawer()
expect(drawer.classList.contains('open')).toBe(false)
dd.toggle()
await nextTick()
expect(drawer.classList.contains('open')).toBe(true)
dd.toggle()
await nextTick()
expect(drawer.classList.contains('open')).toBe(false)
handle.unmount()
})
test('opening externally via useDocumentDrawer().openDrawer() flips the class', async () => {
// Regression: the topbar project-name button opens the drawer
// through the shared composable, so all triggers (topbar click,
// programmatic openDrawer) must land on the same state.
makeFreshStore()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
useDocumentDrawer().openDrawer()
await nextTick()
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
expect(drawer.classList.contains('open')).toBe(true)
handle.unmount()
})
test('panel exposes the canvas width / height fields bound to the store', async () => {
const s = makeFreshStore()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
useDocumentDrawer().openDrawer()
await nextTick()
const inputs = handle.host.querySelectorAll(
'.drawer-panel input[type="number"]',
) as NodeListOf<HTMLInputElement>
// Expected order: width, height, duration, fps.
expect(inputs.length).toBe(4)
expect(Number(inputs[0].value)).toBe(s.scene.canvas.width)
expect(Number(inputs[1].value)).toBe(s.scene.canvas.height)
// Edit width → store updates.
inputs[0].value = '640'
inputs[0].dispatchEvent(new Event('input', { bubbles: true }))
inputs[0].dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
expect(s.scene.canvas.width).toBe(640)
handle.unmount()
})
test('preset buttons set canvas dimensions and reflect the active preset', async () => {
const s = makeFreshStore()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
useDocumentDrawer().openDrawer()
await nextTick()
const presets = Array.from(
handle.host.querySelectorAll('.preset'),
) as HTMLButtonElement[]
const fullHd = presets.find((b) => b.textContent?.includes('1920×1080'))
expect(fullHd).toBeTruthy()
fullHd!.click()
await nextTick()
expect(s.scene.canvas.width).toBe(1920)
expect(s.scene.canvas.height).toBe(1080)
expect(fullHd!.classList.contains('active')).toBe(true)
handle.unmount()
})
test('mounting the drawer alone does not open it', async () => {
// Regression: there is no local trigger inside the drawer itself
// (no tab, no hover-peek). The only way in is via the composable,
// so a freshly-mounted drawer must stay closed.
makeFreshStore()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const drawer = handle.host.querySelector('.document-drawer') as HTMLElement
expect(drawer.classList.contains('open')).toBe(false)
handle.unmount()
})
test('duration + fps fields are bound to the store', async () => {
const s = makeFreshStore()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
useDocumentDrawer().openDrawer()
await nextTick()
const inputs = handle.host.querySelectorAll(
'.drawer-panel input[type="number"]',
) as NodeListOf<HTMLInputElement>
// Order is [width, height, duration, fps].
const duration = inputs[2]
const fps = inputs[3]
expect(Number(duration.value)).toBe(s.scene.duration)
expect(Number(fps.value)).toBe(s.scene.fps)
duration.value = '12.5'
duration.dispatchEvent(new Event('input', { bubbles: true }))
duration.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
expect(s.scene.duration).toBeCloseTo(12.5, 5)
handle.unmount()
})
test('project name input reflects the shared useProjectName ref', async () => {
// The drawer mirrors the topbar's name label — editing lives in
// the drawer and the topbar just displays. The input defaults to
// empty (with a placeholder) while the name is null; as soon as
// the source ref gets a value, the input follows via the watcher.
makeFreshStore()
const { useProjectName } = await import('@/composables/useProjectName')
const { projectName } = useProjectName()
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const input = handle.host.querySelector('.name-input') as HTMLInputElement
expect(input).toBeTruthy()
expect(input.value).toBe('')
projectName.value = 'My Project'
await nextTick()
expect(input.value).toBe('My Project')
handle.unmount()
})
test('committing a name via blur writes it to useProjectName.projectName', async () => {
makeFreshStore()
const { useProjectName } = await import('@/composables/useProjectName')
const { projectName } = useProjectName()
projectName.value = 'Old Name'
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const input = handle.host.querySelector('.name-input') as HTMLInputElement
input.value = ' Fresh Name '
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
await nextTick()
// Trimmed. No surrounding whitespace leaks into the stored name.
expect(projectName.value).toBe('Fresh Name')
handle.unmount()
})
test('Enter commits the name; Escape reverts to the stored value', async () => {
makeFreshStore()
const { useProjectName } = await import('@/composables/useProjectName')
const { projectName } = useProjectName()
projectName.value = 'Stored'
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const input = handle.host.querySelector('.name-input') as HTMLInputElement
// Enter → commit.
input.value = 'Pressed Enter'
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
// The handler blurs the input synchronously; blur fires commit.
input.dispatchEvent(new Event('blur', { bubbles: true }))
await nextTick()
expect(projectName.value).toBe('Pressed Enter')
// Escape → revert the draft, no store change.
input.value = 'Accidentally typed'
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
await nextTick()
expect(projectName.value).toBe('Pressed Enter')
expect(input.value).toBe('Pressed Enter')
handle.unmount()
})
test('empty / whitespace-only name is rejected; stored value snaps back', async () => {
makeFreshStore()
const { useProjectName } = await import('@/composables/useProjectName')
const { projectName } = useProjectName()
projectName.value = 'Keep Me'
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const input = handle.host.querySelector('.name-input') as HTMLInputElement
input.value = ' '
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
await nextTick()
expect(projectName.value).toBe('Keep Me')
expect(input.value).toBe('Keep Me')
handle.unmount()
})
})
suite('ui — ActionEditor (jumpToScene)', () => {
test('jumpToScene appears in the type dropdown', async () => {
const s = makeFreshStore()
@@ -4496,6 +4829,147 @@ export function registerAllTests(): void {
})
})
suite('ui — Timeline clip-instance context menu', () => {
test('right-click on a clip-block opens a menu with Duplicate + Delete', async () => {
const s = makeFreshStore()
s.setDuration(10)
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
s.addClipInstance(clip.id, 0, track.id)
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
expect(block).toBeTruthy()
block.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 30, clientY: 40 }),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const menu = menus[menus.length - 1]
expect(menu).toBeTruthy()
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
expect(items).toHaveLength(2)
expect(items[items.length - 1].classList.contains('danger')).toBe(true)
handle.unmount()
})
test('menu Duplicate clones the clip instance on the same track', async () => {
const s = makeFreshStore()
s.setDuration(10)
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 1, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
block.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const dup = menus[menus.length - 1].querySelector('.kf-menu-item') as HTMLButtonElement
dup.click()
await nextTick()
const siblings = s.activeScene.clipInstances.filter((x) => x.clipTrackId === track.id)
expect(siblings).toHaveLength(2)
const clone = siblings.find((x) => x.id !== ci.id)!
expect(clone.clipId).toBe(clip.id)
expect(clone.startTime).toBe(1 + 2)
handle.unmount()
})
test('menu Delete removes the clip instance', async () => {
const s = makeFreshStore()
s.setDuration(10)
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
block.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const del = menus[menus.length - 1].querySelector('.kf-menu-item.danger') as HTMLButtonElement
del.click()
await nextTick()
expect(s.activeScene.clipInstances.find((x) => x.id === ci.id)).toBe(undefined)
handle.unmount()
})
})
suite('ui — Timeline audio-clip context menu', () => {
test('right-click on an audio-clip-block opens a menu with Duplicate + Delete', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack('FX')
s.addAudioClip(asset.id, 0, track.id)
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.audio-clip-block') as HTMLElement
expect(block).toBeTruthy()
block.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 30, clientY: 40 }),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const menu = menus[menus.length - 1]
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
expect(items).toHaveLength(2)
expect(items[items.length - 1].classList.contains('danger')).toBe(true)
handle.unmount()
})
test('menu Duplicate clones the audio clip on the same track', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack('FX')
const clip = s.addAudioClip(asset.id, 1, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.audio-clip-block') as HTMLElement
block.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const dup = menus[menus.length - 1].querySelector('.kf-menu-item') as HTMLButtonElement
dup.click()
await nextTick()
const siblings = s.activeScene.audioClips.filter((c) => c.audioTrackId === track.id)
expect(siblings).toHaveLength(2)
const clone = siblings.find((c) => c.id !== clip.id)!
expect(clone.audioAssetId).toBe(asset.id)
expect(clone.startTime).toBe(1 + 2)
handle.unmount()
})
test('menu Delete removes the audio clip', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack('FX')
const clip = s.addAudioClip(asset.id, 0, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.audio-clip-block') as HTMLElement
block.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const del = menus[menus.length - 1].querySelector('.kf-menu-item.danger') as HTMLButtonElement
del.click()
await nextTick()
expect(s.activeScene.audioClips.find((c) => c.id === clip.id)).toBe(undefined)
handle.unmount()
})
})
suite('ui — Timeline audio track', () => {
test('audio track renders as an .audio-track lane with one block per clip', async () => {
const s = makeFreshStore()
@@ -6244,6 +6718,68 @@ export function registerAllTests(): void {
expect(s.setAudioClipPosition(clip.id, 0, 'ghost-track')).toBe(false)
})
test('setAudioClipPosition pushes neighbours backward when moved clip collides on the left', () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack()
const early = s.addAudioClip(asset.id, 0, track.id)! // 0..2
const mid = s.addAudioClip(asset.id, 3, track.id)! // 3..5
const late = s.addAudioClip(asset.id, 6, track.id)! // 6..8
// Drag `late` back onto `mid`. `mid` must shunt left to 2 (abuts
// `early` end), `early` sits already at 0.
expect(s.setAudioClipPosition(late.id, 4)).toBe(true)
expect(late.startTime).toBe(4)
expect(mid.startTime).toBe(2)
expect(early.startTime).toBe(0)
})
test('setAudioClipPosition clamps moved clip forward when backward cascade hits 0', () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack()
const a = s.addAudioClip(asset.id, 0, track.id)! // 0..2
const b = s.addAudioClip(asset.id, 2, track.id)! // 2..4
const cc = s.addAudioClip(asset.id, 4, track.id)! // 4..6
// Nothing fits to the left of 4 — drag to 1 clamps cc forward to 4.
expect(s.setAudioClipPosition(cc.id, 1)).toBe(true)
expect(cc.startTime).toBe(4)
expect(a.startTime).toBe(0)
expect(b.startTime).toBe(2)
})
test('duplicateAudioClip clones onto the same track, offset by visible duration', () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack()
const clip = s.addAudioClip(asset.id, 1, track.id)!
const newId = s.duplicateAudioClip(clip.id)!
expect(typeof newId).toBe('string')
expect(newId === clip.id).toBe(false)
const copy = s.activeScene.audioClips.find((c) => c.id === newId)!
expect(copy.audioAssetId).toBe(asset.id)
expect(copy.audioTrackId).toBe(track.id)
expect(copy.startTime).toBe(1 + 2)
})
test('duplicateAudioClip honours trim when computing the offset', () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 4 })
const track = s.addAudioTrack()
const clip = s.addAudioClip(asset.id, 0, track.id)!
s.trimAudioClip(clip.id, { trimStart: 0.5, trimEnd: 0.5 })
const newId = s.duplicateAudioClip(clip.id)!
const copy = s.activeScene.audioClips.find((c) => c.id === newId)!
// visible length = 4 - 0.5 - 0.5 = 3
expect(copy.startTime).toBe(3)
expect(copy.trimStart).toBe(0.5)
expect(copy.trimEnd).toBe(0.5)
})
test('duplicateAudioClip returns null for unknown id', () => {
const s = makeFreshStore()
expect(s.duplicateAudioClip('nope')).toBe(null)
})
test('normalizeScenePage drops orphan audio clips pointing at a missing track', () => {
const s = makeFreshStore()
const asset = addAsset(s)
+38 -2
View File
@@ -23,7 +23,7 @@ export const en = {
},
renameProject: 'Rename project',
renameProjectHelp:
'Click to rename. Saved projects update in place; new names apply at the next save.',
'Opens the document drawer where you can rename the project. Saved projects update in place; unsaved names apply at the next save.',
renameProjectDialog: {
title: 'Rename project',
message: 'Choose a new name for this project.',
@@ -370,6 +370,18 @@ export const en = {
bgVideoUrlTitle: 'Background video URL',
bgVideoUrlMessage: 'Paste the video URL (must be reachable by the browser):',
bgVideoUrlConfirm: 'Load',
bgVideoButton: 'Background video',
bgVideoPicker: 'Click to choose a video · right-click for URL / clear',
presetsLabel: 'Presets',
presetAddTitle: 'Add preset',
presetAddMessage: 'Enter dimensions as WxH (e.g. 1920x1080).',
presetAddPlaceholder: '1920x1080',
presetAddConfirm: 'Add',
presetAddInvalid: 'Please enter dimensions like 1920x1080.',
presetAddTooltip: 'Add preset',
presetRemoveTitle: 'Remove preset?',
presetRemoveMessage: 'Remove "{label}" from your presets?',
presetRemoveConfirm: 'Remove',
canvas: 'Canvas',
width: 'Width',
height: 'Height',
@@ -438,6 +450,12 @@ export const en = {
'Point to a local folder with your images and audio files. The app remembers the folder; permissions may be re-asked after browser restart.',
},
documentDrawer: {
open: 'Open document settings',
close: 'Close document settings',
projectName: 'Project',
},
scenes: {
title: 'Scenes',
open: 'Open scenes',
@@ -952,7 +970,7 @@ export const de: DeepPartial<Messages> = {
},
renameProject: 'Projekt umbenennen',
renameProjectHelp:
'Klicken, um den Namen zu ändern. Gespeicherte Projekte werden direkt aktualisiert. Neue Namen gelten beim nächsten Speichern.',
'Öffnet die Dokument-Schublade, in der das Projekt umbenannt werden kann. Gespeicherte Projekte werden direkt aktualisiert, ungespeicherte Namen gelten beim nächsten Speichern.',
renameProjectDialog: {
title: 'Projekt umbenennen',
message: 'Wähle einen neuen Namen für dieses Projekt.',
@@ -1122,6 +1140,18 @@ export const de: DeepPartial<Messages> = {
bgVideoUrlTitle: 'Hintergrundvideo-URL',
bgVideoUrlMessage: 'Video-URL einfügen. Sie muss im Browser erreichbar sein:',
bgVideoUrlConfirm: 'Laden',
bgVideoButton: 'Hintergrundvideo',
bgVideoPicker: 'Klick für Videoauswahl · Rechtsklick für URL / Entfernen',
presetsLabel: 'Presets',
presetAddTitle: 'Preset hinzufügen',
presetAddMessage: 'Maße als BxH eingeben (z. B. 1920x1080).',
presetAddPlaceholder: '1920x1080',
presetAddConfirm: 'Hinzufügen',
presetAddInvalid: 'Bitte Maße wie 1920x1080 eingeben.',
presetAddTooltip: 'Preset hinzufügen',
presetRemoveTitle: 'Preset entfernen?',
presetRemoveMessage: '„{label}" aus den Presets entfernen?',
presetRemoveConfirm: 'Entfernen',
canvas: 'Canvas',
width: 'Breite',
height: 'Höhe',
@@ -1188,6 +1218,12 @@ export const de: DeepPartial<Messages> = {
settingsHint:
'Verweise auf einen lokalen Ordner mit Bildern und Audio. Die App merkt sich den Ordner. Nach einem Browser-Neustart kann die Berechtigung erneut abgefragt werden.',
},
documentDrawer: {
open: 'Dokument-Einstellungen öffnen',
close: 'Dokument-Einstellungen schließen',
projectName: 'Projekt',
},
scenes: {
title: 'Szenen',
open: 'Szenen öffnen',
+12
View File
@@ -11,3 +11,15 @@ app.use(createPinia())
app.use(VueKonva)
app.directive('tooltip', vTooltip)
app.mount('#app')
// Service worker exists solely to make the app installable as a PWA.
// In Chrome/Edge, an installed PWA runs in standalone display mode,
// which removes the top-edge "exit fullscreen" indicator that tabs
// show when the Fullscreen API is active.
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
/* registration failed — app still works without PWA install */
})
})
}
+182 -21
View File
@@ -42,6 +42,96 @@ type AssetInit = Asset extends infer A ? (A extends Asset ? Omit<A, 'id'> : neve
const KEYFRAME_EPSILON = 1e-3
/** Shared cascading push-aside helper used by the audio-clip and clip-
* instance move paths. Items on the same track ahead of the moved
* block get shoved forward to its new tail; items behind get shoved
* backward to its new head. If the backward chain would go below 0
* the whole block is clamped forward to the first fitting slot so
* nothing overlaps and nothing crosses the scene origin.
*
* The helper mutates `others` in place (updates `startTime` on each
* item) and returns the effective newStart actually applied — which
* the caller should apply to the moved item itself. */
interface CascadeItem {
id: string
startTime: number
}
function cascadePushAside<T extends CascadeItem>(
others: T[],
movedCenterOld: number,
movedLen: number,
lenOf: (item: T) => number,
desiredStart: number,
): number {
const newEnd = () => newStart + movedLen
// Use the moved clip's *old* center to decide which side each neighbor
// belongs to. Doing the split on the pre-move position prevents the
// partition from flipping partway through a drag when the user's
// cursor crosses another clip.
const leftSide: T[] = []
const rightSide: T[] = []
for (const o of others) {
const center = o.startTime + lenOf(o) / 2
if (center < movedCenterOld) leftSide.push(o)
else rightSide.push(o)
}
let newStart = Math.max(0, desiredStart)
// Backward cascade: walk left-side clips from rightmost → leftmost,
// pushing each one left to stay clear of the running ceiling. If any
// would land below 0 we shift the whole moved block right by that
// deficit so the stack on the left sits flush with the scene origin.
leftSide.sort((a, b) => b.startTime - a.startTime)
const computeLeft = (): { newStarts: Map<string, number>; deficit: number } => {
const map = new Map<string, number>()
let ceiling = newStart
let maxDeficit = 0
for (const o of leftSide) {
const len = lenOf(o)
if (o.startTime + len <= ceiling) {
// Already clear — this one stays where it is
ceiling = o.startTime
map.set(o.id, o.startTime)
} else {
const next = ceiling - len
if (next < 0) maxDeficit = Math.max(maxDeficit, -next)
ceiling = next
map.set(o.id, next)
}
}
return { newStarts: map, deficit: maxDeficit }
}
let left = computeLeft()
if (left.deficit > 0) {
newStart += left.deficit
left = computeLeft()
}
for (const o of leftSide) {
const s = left.newStarts.get(o.id)
if (s !== undefined) o.startTime = Math.max(0, s)
}
// Forward cascade: walk right-side clips leftmost → rightmost,
// pushing each one forward to stay past the running floor.
rightSide.sort((a, b) => a.startTime - b.startTime)
let floor = newEnd()
for (const o of rightSide) {
const len = lenOf(o)
if (o.startTime >= floor) {
floor = o.startTime + len
} else {
o.startTime = floor
floor = o.startTime + len
}
}
return newStart
}
const BUTTON_STATE_TIME_VALUES: readonly number[] = [
BUTTON_STATE_TIMES.normal,
BUTTON_STATE_TIMES.hover,
@@ -1722,10 +1812,11 @@ export const useSceneStore = defineStore('scene', {
},
/** Move a clip to `(startTime, audioTrackId)` on the active page and
* cascade-push any existing clips on the target track out of the way
* (forward in time) so no overlap survives. Clips that end BEFORE
* the moved clip's new startTime are left alone. Returns `false`
* when the clip or target track is unknown. */
* cascade-push any existing clips on the target track out of the
* way so no overlap survives. Clips ahead of the moved block are
* shoved forward; clips behind it are shoved backward. Hitting
* the scene origin clamps the moved clip forward. Returns `false`
* only when the clip or target track is unknown. */
setAudioClipPosition(id: string, startTime: number, audioTrackId?: string): boolean {
const page = this.activeScene
const clip = page.audioClips.find((c) => c.id === id)
@@ -1740,28 +1831,23 @@ export const useSceneStore = defineStore('scene', {
return Math.max(0, a.duration - (c.trimStart ?? 0) - (c.trimEnd ?? 0))
}
const newStart = Math.max(0, startTime)
const clipLen = lenOf(clip)
const oldCenter = clip.startTime + clipLen / 2
// Anything already on the target track, minus the one we're moving.
const others = page.audioClips
.filter((c) => c.audioTrackId === targetTrackId && c.id !== id)
.slice()
.sort((a, b) => a.startTime - b.startTime)
const others = page.audioClips.filter(
(c) => c.audioTrackId === targetTrackId && c.id !== id,
)
let floor = newStart + clipLen
for (const other of others) {
const len = lenOf(other)
// Clip entirely before the moved block — no cascade needed.
if (other.startTime + len <= newStart) continue
// Overlapping or sitting past the moved block but below the
// rolling floor → push it forward to the floor.
if (other.startTime < floor) other.startTime = floor
floor = other.startTime + len
}
const appliedStart = cascadePushAside(
others,
oldCenter,
clipLen,
lenOf,
startTime,
)
clip.audioTrackId = targetTrackId
clip.startTime = newStart
clip.startTime = appliedStart
return true
},
@@ -1801,6 +1887,24 @@ export const useSceneStore = defineStore('scene', {
this.activeScene.audioClips = this.activeScene.audioClips.filter((c) => c.id !== id)
},
/** Clone an audio clip onto the same track, placed directly after
* the original using its visible (trim-adjusted) duration. The
* copy carries trim settings and sample/loop offsets forward. */
duplicateAudioClip(id: string): string | null {
const src = this.activeScene.audioClips.find((c) => c.id === id)
if (!src) return null
const asset = this.project.assets.find((a) => a.id === src.audioAssetId)
const base = asset && asset.kind === 'audio' ? asset.duration : 1
const visible = Math.max(0.05, base - (src.trimStart ?? 0) - (src.trimEnd ?? 0))
const copy: AudioClip = {
...structuredClone(toRaw(src)),
id: uid(),
startTime: src.startTime + visible,
}
this.activeScene.audioClips.push(copy)
return copy.id
},
// -------- Button assets --------------------------------------------
addButtonAsset(name?: string, width = 200, height = 80): ButtonAsset {
@@ -2415,6 +2519,45 @@ export const useSceneStore = defineStore('scene', {
if (track) inst.clipTrackId = clipTrackId
}
},
/** Move a clip instance, cascading non-overlap across its track —
* mirrors `setAudioClipPosition`. Preferred over `moveClipInstance`
* for user-initiated drags so instances can't land on top of each
* other. Returns `false` only when the instance or target track
* is unknown. */
setClipInstancePosition(id: string, startTime: number, clipTrackId?: string): boolean {
const page = this.activeScene
const inst = page.clipInstances.find((ci) => ci.id === id)
if (!inst) return false
const targetTrackId = clipTrackId ?? inst.clipTrackId
const targetTrack = page.clipTracks.find((t) => t.id === targetTrackId)
if (!targetTrack) return false
const lenOf = (ci: ClipInstance): number => {
const clip = this.project.clips.find((c) => c.id === ci.clipId)
if (!clip) return 0
return Math.max(0, clip.duration - (ci.trimStart ?? 0) - (ci.trimEnd ?? 0))
}
const instLen = lenOf(inst)
const oldCenter = inst.startTime + instLen / 2
const others = page.clipInstances.filter(
(ci) => ci.clipTrackId === targetTrackId && ci.id !== id,
)
const appliedStart = cascadePushAside(
others,
oldCenter,
instLen,
lenOf,
startTime,
)
inst.clipTrackId = targetTrackId
inst.startTime = appliedStart
return true
},
trimClipInstance(
id: string,
patch: { startTime?: number; trimStart?: number; trimEnd?: number },
@@ -2448,6 +2591,24 @@ export const useSceneStore = defineStore('scene', {
this.activeScene.clipInstances = this.activeScene.clipInstances.filter((ci) => ci.id !== id)
if (this.selectedClipInstanceId === id) this.selectedClipInstanceId = null
},
/** Clone a clip instance onto the same clip track, placed directly
* after the original using its visible (trim-adjusted) duration.
* Per-instance transform + trim settings are carried forward. */
duplicateClipInstance(id: string): string | null {
const src = this.activeScene.clipInstances.find((ci) => ci.id === id)
if (!src) return null
const clip = this.project.clips.find((c) => c.id === src.clipId)
const base = clip?.duration ?? 1
const visible = Math.max(0.05, base - (src.trimStart ?? 0) - (src.trimEnd ?? 0))
const copy: ClipInstance = {
...structuredClone(toRaw(src)),
id: uid(),
startTime: src.startTime + visible,
}
this.activeScene.clipInstances.push(copy)
return copy.id
},
/** Update the per-instance x / y / scaleX / scaleY. Missing fields
* on the patch are left alone; clearing a field (setting it to
* the default 0 / 1) prunes it from the stored object so the
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.11.1'
export const APP_VERSION = '0.12.0'
export const APP_NAME = 'RUWIO'