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:
1 parent
fa66ba69bb
commit
354dd8a4f0
25 files changed
+2256
-367
No files matched your search
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.11.1'
|
||||
export const APP_VERSION = '0.12.0'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user