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
+2294
-405
No files matched your search
@@ -3,7 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<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="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#17171b" />
|
||||||
<title>RUWIO</title>
|
<title>RUWIO</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruwio",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.11.1",
|
"version": "0.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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.
|
||||||
|
})
|
||||||
+86
-65
@@ -24,6 +24,7 @@ import HelpDialog from './components/HelpDialog.vue'
|
|||||||
import CanvasStage from './components/CanvasStage.vue'
|
import CanvasStage from './components/CanvasStage.vue'
|
||||||
import ClipartDrawer from './components/ClipartDrawer.vue'
|
import ClipartDrawer from './components/ClipartDrawer.vue'
|
||||||
import SceneDrawer from './components/SceneDrawer.vue'
|
import SceneDrawer from './components/SceneDrawer.vue'
|
||||||
|
import DocumentDrawer from './components/DocumentDrawer.vue'
|
||||||
import DocumentSettings from './components/DocumentSettings.vue'
|
import DocumentSettings from './components/DocumentSettings.vue'
|
||||||
import ExportDialog from './components/ExportDialog.vue'
|
import ExportDialog from './components/ExportDialog.vue'
|
||||||
import FullscreenPreview from './components/FullscreenPreview.vue'
|
import FullscreenPreview from './components/FullscreenPreview.vue'
|
||||||
@@ -37,7 +38,9 @@ import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './c
|
|||||||
import { t } from './i18n'
|
import { t } from './i18n'
|
||||||
import { useHistory } from './composables/useHistory'
|
import { useHistory } from './composables/useHistory'
|
||||||
import { useAudioRuntime } from './composables/useAudioRuntime'
|
import { useAudioRuntime } from './composables/useAudioRuntime'
|
||||||
|
import { useDocumentDrawer } from './composables/useDocumentDrawer'
|
||||||
import { usePlaybackLoop } from './composables/usePlaybackLoop'
|
import { usePlaybackLoop } from './composables/usePlaybackLoop'
|
||||||
|
import { useProjectName } from './composables/useProjectName'
|
||||||
import {
|
import {
|
||||||
ProjectFileError,
|
ProjectFileError,
|
||||||
downloadProject,
|
downloadProject,
|
||||||
@@ -136,8 +139,8 @@ function onWindowResize() {
|
|||||||
}
|
}
|
||||||
const loadError = ref<string | null>(null)
|
const loadError = ref<string | null>(null)
|
||||||
const savedToast = ref<string | null>(null)
|
const savedToast = ref<string | null>(null)
|
||||||
const currentProjectId = ref<string | null>(null)
|
const { projectName: currentProjectName, projectId: currentProjectId } = useProjectName()
|
||||||
const currentProjectName = ref<string | null>(null)
|
const documentDrawer = useDocumentDrawer()
|
||||||
// Dirty = scene has changed since the last save/load. Deep-watches
|
// Dirty = scene has changed since the last save/load. Deep-watches
|
||||||
// store.scene; save/load paths clear it after the next tick so the
|
// store.scene; save/load paths clear it after the next tick so the
|
||||||
// replacement itself doesn't leave the project flagged as unsaved.
|
// replacement itself doesn't leave the project flagged as unsaved.
|
||||||
@@ -195,32 +198,12 @@ async function saveProject() {
|
|||||||
await markClean()
|
await markClean()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renameProject() {
|
function openProjectNameDrawer() {
|
||||||
const suggested =
|
// Clicking the project name in the topbar toggles the document
|
||||||
currentProjectName.value ??
|
// drawer — edits the name inline alongside the rest of the document
|
||||||
t('app.saveDialog.defaultName', { date: new Date().toLocaleDateString() })
|
// settings. Clicking again closes it, matching the topbar button's
|
||||||
const entered = await promptDialog({
|
// dual role as trigger and dismiss.
|
||||||
title: t('app.renameProjectDialog.title'),
|
documentDrawer.toggle()
|
||||||
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 exportProjectFile() {
|
function exportProjectFile() {
|
||||||
@@ -423,9 +406,11 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div class="root">
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<ClipartDrawer />
|
<ClipartDrawer />
|
||||||
<div class="app" :style="appStyle">
|
<div class="app" :style="appStyle">
|
||||||
|
<DocumentDrawer />
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand-area">
|
<div class="brand-area">
|
||||||
<button class="brand" v-tooltip="t('about.openTooltip')" @click="aboutOpen = true">
|
<button class="brand" v-tooltip="t('about.openTooltip')" @click="aboutOpen = true">
|
||||||
@@ -438,7 +423,7 @@ onBeforeUnmount(() => {
|
|||||||
label: t('app.renameProject'),
|
label: t('app.renameProject'),
|
||||||
help: t('app.renameProjectHelp'),
|
help: t('app.renameProjectHelp'),
|
||||||
}"
|
}"
|
||||||
@click="renameProject"
|
@click="openProjectNameDrawer"
|
||||||
>
|
>
|
||||||
<span class="project-name-label">
|
<span class="project-name-label">
|
||||||
{{ currentProjectName ?? t('app.untitledProject') }}
|
{{ currentProjectName ?? t('app.untitledProject') }}
|
||||||
@@ -447,30 +432,32 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="transport">
|
<div class="transport">
|
||||||
<IconButton
|
<div class="transport-side transport-left">
|
||||||
:icon="PhArrowCounterClockwise"
|
<IconButton
|
||||||
icon-weight="bold"
|
:icon="PhArrowCounterClockwise"
|
||||||
size="md"
|
icon-weight="bold"
|
||||||
:disabled="!history.canUndo.value"
|
size="md"
|
||||||
:title="t('app.undo')"
|
:disabled="!history.canUndo.value"
|
||||||
@click="history.undo"
|
:title="t('app.undo')"
|
||||||
/>
|
@click="history.undo"
|
||||||
<IconButton
|
/>
|
||||||
:icon="PhArrowClockwise"
|
<IconButton
|
||||||
icon-weight="bold"
|
:icon="PhArrowClockwise"
|
||||||
size="md"
|
icon-weight="bold"
|
||||||
:disabled="!history.canRedo.value"
|
size="md"
|
||||||
:title="t('app.redo')"
|
:disabled="!history.canRedo.value"
|
||||||
@click="history.redo"
|
:title="t('app.redo')"
|
||||||
/>
|
@click="history.redo"
|
||||||
<div class="tsep"></div>
|
/>
|
||||||
<IconButton
|
<div class="tsep"></div>
|
||||||
:icon="PhSkipBack"
|
<IconButton
|
||||||
icon-weight="fill"
|
:icon="PhSkipBack"
|
||||||
size="md"
|
icon-weight="fill"
|
||||||
:title="t('app.jumpToStart')"
|
size="md"
|
||||||
@click="rewind"
|
:title="t('app.jumpToStart')"
|
||||||
/>
|
@click="rewind"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<IconButton
|
<IconButton
|
||||||
:icon="store.playing ? PhPause : PhPlay"
|
:icon="store.playing ? PhPause : PhPlay"
|
||||||
icon-weight="fill"
|
icon-weight="fill"
|
||||||
@@ -479,14 +466,16 @@ onBeforeUnmount(() => {
|
|||||||
:title="store.playing ? t('app.pause') : t('app.play')"
|
:title="store.playing ? t('app.pause') : t('app.play')"
|
||||||
@click="store.togglePlay()"
|
@click="store.togglePlay()"
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<div class="transport-side transport-right">
|
||||||
:icon="PhStop"
|
<IconButton
|
||||||
icon-weight="fill"
|
:icon="PhStop"
|
||||||
size="md"
|
icon-weight="fill"
|
||||||
:title="t('app.stop')"
|
size="md"
|
||||||
@click="stop"
|
:title="t('app.stop')"
|
||||||
/>
|
@click="stop"
|
||||||
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
|
/>
|
||||||
|
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
<button class="icon-btn" v-tooltip="t('app.recentProjects')" @click="welcomeOpen = true">
|
<button class="icon-btn" v-tooltip="t('app.recentProjects')" @click="welcomeOpen = true">
|
||||||
@@ -579,14 +568,24 @@ onBeforeUnmount(() => {
|
|||||||
<SceneDrawer />
|
<SceneDrawer />
|
||||||
<TooltipHost />
|
<TooltipHost />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
height: 100vh;
|
flex: 1 1 auto;
|
||||||
width: 100vw;
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,6 +600,7 @@ onBeforeUnmount(() => {
|
|||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
@@ -611,6 +611,8 @@ onBeforeUnmount(() => {
|
|||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
position: relative;
|
||||||
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-area {
|
.brand-area {
|
||||||
@@ -646,10 +648,29 @@ onBeforeUnmount(() => {
|
|||||||
color: var(--accent);
|
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 {
|
.transport {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 4px;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
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 {
|
.tsep {
|
||||||
|
|||||||
@@ -22,7 +22,15 @@ import {
|
|||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
|
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
|
||||||
import { importAudioFile, importImageFile, isAudioFile } from '@/lib/fileLoader'
|
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 type { Asset } from '@/types/scene'
|
||||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||||
import { loadSceneImages, renderButtonThumbnail } from '@/lib/render'
|
import { loadSceneImages, renderButtonThumbnail } from '@/lib/render'
|
||||||
@@ -199,11 +207,105 @@ watchEffect(() => {
|
|||||||
buttonThumbs.value = out
|
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) {
|
function onButtonAssetDragStart(e: DragEvent, assetId: string) {
|
||||||
if (!e.dataTransfer) return
|
if (!e.dataTransfer) return
|
||||||
e.dataTransfer.effectAllowed = 'copy'
|
e.dataTransfer.effectAllowed = 'copy'
|
||||||
e.dataTransfer.setData('application/x-animatr-asset', assetId)
|
e.dataTransfer.setData('application/x-animatr-asset', assetId)
|
||||||
e.dataTransfer.setData('text/plain', 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) {
|
async function renameButtonAssetPrompt(id: string, current: string) {
|
||||||
@@ -266,6 +368,18 @@ function onClipDragStart(e: DragEvent, clipId: string) {
|
|||||||
e.dataTransfer.effectAllowed = 'copy'
|
e.dataTransfer.effectAllowed = 'copy'
|
||||||
e.dataTransfer.setData('application/x-animatr-clip', clipId)
|
e.dataTransfer.setData('application/x-animatr-clip', clipId)
|
||||||
e.dataTransfer.setData('text/plain', 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) {
|
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
|
// Surface the source info so Timeline's ghost preview has something
|
||||||
// to render during dragover (dataTransfer.getData is unreadable then).
|
// to render during dragover (dataTransfer.getData is unreadable then).
|
||||||
setAudioDragSource({ duration: asset.duration, name: asset.name })
|
setAudioDragSource({ duration: asset.duration, name: asset.name })
|
||||||
|
installAudioClipDragImage(e, { name: asset.name })
|
||||||
const el = e.currentTarget as HTMLElement
|
const el = e.currentTarget as HTMLElement
|
||||||
el.addEventListener('dragend', clearAudioDragSource, { once: true })
|
el.addEventListener('dragend', clearAudioDragSource, { once: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<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 { PhArrowClockwise, PhCaretLeft, PhCaretRight, PhFolderOpen, PhImagesSquare, PhMusicNote } from '@phosphor-icons/vue'
|
||||||
import { useClipartStore } from '@/stores/clipart'
|
import { useClipartStore } from '@/stores/clipart'
|
||||||
import { readAudioDuration, readAsDataUrl } from '@/lib/fileLoader'
|
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 { useFullscreen } from '@/composables/useFullscreen'
|
||||||
|
import { dialogState } from '@/components/ui'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
const clipart = useClipartStore()
|
const clipart = useClipartStore()
|
||||||
@@ -23,12 +28,31 @@ function onDrawerLeave() {
|
|||||||
if (isFullscreen.value) clipart.setOpen(false)
|
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(() => {
|
onMounted(() => {
|
||||||
clipart.initialize()
|
clipart.initialize()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clipart.disposeItems()
|
clipart.disposeItems()
|
||||||
|
document.removeEventListener('keydown', onDocKeyDown)
|
||||||
})
|
})
|
||||||
|
|
||||||
function onItemDragStart(e: DragEvent, index: number) {
|
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)
|
// file decodes, so seed with 0 (ghost renders at minimum width)
|
||||||
// and upgrade asynchronously. dragend clears both.
|
// and upgrade asynchronously. dragend clears both.
|
||||||
setAudioDragSource({ duration: 0, name: item.name })
|
setAudioDragSource({ duration: 0, name: item.name })
|
||||||
|
installAudioClipDragImage(e, { name: item.name })
|
||||||
const el = e.currentTarget as HTMLElement
|
const el = e.currentTarget as HTMLElement
|
||||||
el.addEventListener('dragend', clearAudioDragSource, { once: true })
|
el.addEventListener('dragend', clearAudioDragSource, { once: true })
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -55,8 +80,6 @@ function onItemDragStart(e: DragEvent, index: number) {
|
|||||||
/* leave the placeholder width — still a visible hint */
|
/* 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
|
return
|
||||||
}
|
}
|
||||||
// Use the actual image as drag ghost
|
// Use the actual image as drag ghost
|
||||||
@@ -79,7 +102,11 @@ function onItemDragStart(e: DragEvent, index: number) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="clipart-drawer" :class="{ open: clipart.open }" @mouseleave="onDrawerLeave">
|
<aside
|
||||||
|
class="clipart-drawer"
|
||||||
|
:class="{ open: clipart.open, fullscreen: isFullscreen }"
|
||||||
|
@mouseleave="onDrawerLeave"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
class="drawer-tab"
|
class="drawer-tab"
|
||||||
v-tooltip="clipart.open ? t('clipart.close') : t('clipart.open')"
|
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);
|
transition: opacity 140ms var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clipart-drawer:hover .drawer-tab :deep(svg),
|
.clipart-drawer:hover:not(.fullscreen) .drawer-tab :deep(svg),
|
||||||
.clipart-drawer.open .drawer-tab :deep(svg) {
|
.clipart-drawer.open:not(.fullscreen) .drawer-tab :deep(svg) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clipart-drawer:hover .drawer-tab {
|
.clipart-drawer:hover:not(.fullscreen) .drawer-tab {
|
||||||
background: var(--panel-alt);
|
background: var(--panel-alt);
|
||||||
color: var(--text);
|
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 {
|
.clipart-drawer.open .drawer-tab {
|
||||||
flex: 0 0 24px;
|
flex: 0 0 24px;
|
||||||
width: 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">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
PhFilmReel,
|
|
||||||
PhFolder,
|
PhFolder,
|
||||||
PhFrameCorners,
|
|
||||||
PhMoon,
|
PhMoon,
|
||||||
PhSlidersHorizontal,
|
PhSlidersHorizontal,
|
||||||
PhSun,
|
PhSun,
|
||||||
PhX,
|
|
||||||
} from '@phosphor-icons/vue'
|
} from '@phosphor-icons/vue'
|
||||||
import { useSceneStore } from '@/stores/scene'
|
|
||||||
import { useClipartStore } from '@/stores/clipart'
|
import { useClipartStore } from '@/stores/clipart'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
import type { Theme } from '@/composables/useTheme'
|
import type { Theme } from '@/composables/useTheme'
|
||||||
import { useTooltipPref } from '@/composables/useTooltipPref'
|
import { useTooltipPref } from '@/composables/useTooltipPref'
|
||||||
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
|
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
|
||||||
import { showLocked } from '@/composables/useShowLocked'
|
import { showLocked } from '@/composables/useShowLocked'
|
||||||
import { Modal, confirmDialog, promptDialog } from '@/components/ui'
|
import { Modal } from '@/components/ui'
|
||||||
import { setLocale, t, useI18n } from '@/i18n'
|
import { setLocale, t, useI18n } from '@/i18n'
|
||||||
import type { Locale } from '@/i18n'
|
import type { Locale } from '@/i18n'
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean }>()
|
const props = defineProps<{ modelValue: boolean }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
|
||||||
const store = useSceneStore()
|
|
||||||
const clipart = useClipartStore()
|
const clipart = useClipartStore()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const { enabled: tooltipsEnabled } = useTooltipPref()
|
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 }[] = [
|
const tabs: { id: TabId; labelKey: string; icon: typeof PhFolder }[] = [
|
||||||
{ id: 'document', labelKey: 'settings.tab.document', icon: PhFrameCorners },
|
|
||||||
{ id: 'clipart', labelKey: 'settings.tab.clipart', icon: PhFolder },
|
{ id: 'clipart', labelKey: 'settings.tab.clipart', icon: PhFolder },
|
||||||
{ id: 'interface', labelKey: 'settings.tab.interface', icon: PhSlidersHorizontal },
|
{ id: 'interface', labelKey: 'settings.tab.interface', icon: PhSlidersHorizontal },
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeTab = ref<TabId>('document')
|
const activeTab = ref<TabId>('clipart')
|
||||||
|
|
||||||
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 selectedLocale = computed({
|
const selectedLocale = computed({
|
||||||
get: () => locale.value,
|
get: () => locale.value,
|
||||||
set: (value: Locale) => setLocale(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() {
|
function close() {
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
@@ -252,94 +89,7 @@ function applyTheme(t: Theme) {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<section v-if="activeTab === 'document'">
|
<section v-if="activeTab === 'clipart'">
|
||||||
<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'">
|
|
||||||
<h3>{{ t('settings.tab.clipart') }}</h3>
|
<h3>{{ t('settings.tab.clipart') }}</h3>
|
||||||
<div class="clipart-row">
|
<div class="clipart-row">
|
||||||
<div class="clipart-label">{{ t('clipart.folderLabel') }}</div>
|
<div class="clipart-label">{{ t('clipart.folderLabel') }}</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<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 { PhCaretLeft, PhCaretRight, PhCopy, PhFilmSlate, PhPlus, PhTrash } from '@phosphor-icons/vue'
|
||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
import { confirmDialog, dialogState, promptDialog } from '@/components/ui'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { useFullscreen } from '@/composables/useFullscreen'
|
import { useFullscreen } from '@/composables/useFullscreen'
|
||||||
|
|
||||||
@@ -21,6 +21,32 @@ function onDrawerLeave() {
|
|||||||
if (isFullscreen.value) open.value = false
|
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 {
|
interface MenuState {
|
||||||
pageId: string
|
pageId: string
|
||||||
x: number
|
x: number
|
||||||
@@ -100,7 +126,11 @@ watch(menu, (m) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="scene-drawer" :class="{ open }" @mouseleave="onDrawerLeave">
|
<aside
|
||||||
|
class="scene-drawer"
|
||||||
|
:class="{ open, fullscreen: isFullscreen }"
|
||||||
|
@mouseleave="onDrawerLeave"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
class="drawer-tab"
|
class="drawer-tab"
|
||||||
v-tooltip="open ? t('scenes.close') : t('scenes.open')"
|
v-tooltip="open ? t('scenes.close') : t('scenes.open')"
|
||||||
@@ -210,16 +240,34 @@ watch(menu, (m) => {
|
|||||||
transition: opacity 140ms var(--ease);
|
transition: opacity 140ms var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-drawer:hover .drawer-tab :deep(svg),
|
.scene-drawer:hover:not(.fullscreen) .drawer-tab :deep(svg),
|
||||||
.scene-drawer.open .drawer-tab :deep(svg) {
|
.scene-drawer.open:not(.fullscreen) .drawer-tab :deep(svg) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-drawer:hover .drawer-tab {
|
.scene-drawer:hover:not(.fullscreen) .drawer-tab {
|
||||||
background: var(--panel-alt);
|
background: var(--panel-alt);
|
||||||
color: var(--text);
|
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 {
|
.scene-drawer.open .drawer-tab {
|
||||||
flex: 0 0 24px;
|
flex: 0 0 24px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
|
|||||||
+200
-43
@@ -33,6 +33,7 @@ import { useSceneStore } from '@/stores/scene'
|
|||||||
import { useClipartStore } from '@/stores/clipart'
|
import { useClipartStore } from '@/stores/clipart'
|
||||||
import { importAudioFile } from '@/lib/fileLoader'
|
import { importAudioFile } from '@/lib/fileLoader'
|
||||||
import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDragSource'
|
import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDragSource'
|
||||||
|
import { clearClipDragSource, clipDragSource } from '@/composables/useClipDragSource'
|
||||||
import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform'
|
import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform'
|
||||||
import { sampleLayer } from '@/lib/interpolate'
|
import { sampleLayer } from '@/lib/interpolate'
|
||||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||||
@@ -1663,21 +1664,83 @@ function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): numbe
|
|||||||
return ratio * duration.value
|
return ratio * duration.value
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClipLaneDragOver(e: DragEvent, _clipTrackId: string) {
|
interface ClipInstanceGhost {
|
||||||
if (e.dataTransfer?.types.includes('application/x-animatr-clip')) {
|
targetTrackId: string
|
||||||
e.preventDefault()
|
startTime: number
|
||||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
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) {
|
function onClipLaneDrop(e: DragEvent, clipTrackId: string) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
clipInstanceGhost.value = null
|
||||||
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
|
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 el = e.currentTarget as HTMLElement
|
||||||
const time = pixelTimeFromEvent(e, el)
|
const time = pixelTimeFromEvent(e, el)
|
||||||
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
|
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 {
|
interface ClipMoveState {
|
||||||
@@ -1720,7 +1783,7 @@ function onClipBlockPointerMove(e: PointerEvent) {
|
|||||||
if (pps <= 0) return
|
if (pps <= 0) return
|
||||||
const deltaSec = deltaPx / pps
|
const deltaSec = deltaPx / pps
|
||||||
const newStart = Math.max(0, m.initialStartTime + deltaSec)
|
const newStart = Math.max(0, m.initialStartTime + deltaSec)
|
||||||
store.moveClipInstance(m.instanceId, newStart)
|
store.setClipInstancePosition(m.instanceId, newStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClipBlockPointerUp(e: PointerEvent) {
|
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.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
// v1: a single "remove" action. The right-click context menu grows
|
audioClipMenu.value = { clipId: clip.id, x: e.clientX, y: e.clientY }
|
||||||
// later once trim handles and per-clip properties land.
|
}
|
||||||
const ok = await confirmDialog({
|
|
||||||
title: t('timeline.removeAudioClipDialog.title'),
|
function duplicateAudioClipFromMenu() {
|
||||||
message: t('timeline.removeAudioClipDialog.message'),
|
const m = audioClipMenu.value
|
||||||
confirmLabel: t('timeline.removeAudioClipDialog.confirm'),
|
if (!m) return
|
||||||
variant: 'danger',
|
store.duplicateAudioClip(m.clipId)
|
||||||
})
|
audioClipMenu.value = null
|
||||||
if (!ok) return
|
}
|
||||||
store.removeAudioClip(clip.id)
|
|
||||||
|
function deleteAudioClipFromMenu() {
|
||||||
|
const m = audioClipMenu.value
|
||||||
|
if (!m) return
|
||||||
|
store.removeAudioClip(m.clipId)
|
||||||
|
audioClipMenu.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AudioClipMoveState {
|
interface AudioClipMoveState {
|
||||||
@@ -2381,22 +2458,33 @@ function onClipBlockDblClick(ci: ClipInstance) {
|
|||||||
store.enterClip(ci.clipId)
|
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()
|
e.preventDefault()
|
||||||
const clip = clipById.value.get(ci.clipId)
|
e.stopPropagation()
|
||||||
const ok = await confirmDialog({
|
clipInstanceMenu.value = { instanceId: ci.id, x: e.clientX, y: e.clientY }
|
||||||
title: t('timeline.removeInstanceDialog.title'),
|
}
|
||||||
message: clip
|
|
||||||
? t('timeline.removeInstanceDialog.message', {
|
function duplicateClipInstanceFromMenu() {
|
||||||
name: clip.name,
|
const m = clipInstanceMenu.value
|
||||||
time: ci.startTime.toFixed(2),
|
if (!m) return
|
||||||
})
|
store.duplicateClipInstance(m.instanceId)
|
||||||
: t('timeline.removeInstanceDialog.generic'),
|
clipInstanceMenu.value = null
|
||||||
confirmLabel: t('timeline.removeInstanceDialog.confirm'),
|
}
|
||||||
variant: 'danger',
|
|
||||||
})
|
function deleteClipInstanceFromMenu() {
|
||||||
if (!ok) return
|
const m = clipInstanceMenu.value
|
||||||
store.removeClipInstance(ci.id)
|
if (!m) return
|
||||||
|
store.removeClipInstance(m.instanceId)
|
||||||
|
clipInstanceMenu.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackMenu = ref<TrackMenuState | null>(null)
|
const trackMenu = ref<TrackMenuState | null>(null)
|
||||||
@@ -2622,6 +2710,16 @@ function onDocumentPointerDown(e: PointerEvent) {
|
|||||||
} else if (scriptFrameMenu.value) {
|
} else if (scriptFrameMenu.value) {
|
||||||
scriptFrameMenu.value = null
|
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) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
@@ -2629,6 +2727,8 @@ function onKeyDown(e: KeyboardEvent) {
|
|||||||
menu.value = null
|
menu.value = null
|
||||||
trackMenu.value = null
|
trackMenu.value = null
|
||||||
scriptFrameMenu.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]) => {
|
watch(
|
||||||
if (m || tm || sm) {
|
[menu, trackMenu, scriptFrameMenu, clipInstanceMenu, audioClipMenu],
|
||||||
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
([m, tm, sm, cim, acm]) => {
|
||||||
document.addEventListener('keydown', onKeyDown)
|
if (m || tm || sm || cim || acm) {
|
||||||
} else {
|
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
document.addEventListener('keydown', onKeyDown)
|
||||||
document.removeEventListener('keydown', onKeyDown)
|
} else {
|
||||||
}
|
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||||
})
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async function clampMenuIntoViewport(
|
async function clampMenuIntoViewport(
|
||||||
getEl: () => HTMLElement | null,
|
getEl: () => HTMLElement | null,
|
||||||
@@ -2739,6 +2842,16 @@ watch(scriptFrameMenu, (sm) => {
|
|||||||
clampMenuIntoViewport(() => scriptFrameMenuRef.value, sm.x, sm.y)
|
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) {
|
function onWheel(e: WheelEvent) {
|
||||||
if (!scrollEl.value) return
|
if (!scrollEl.value) return
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
@@ -3249,9 +3362,18 @@ onBeforeUnmount(() => {
|
|||||||
class="track-lane clip-lane"
|
class="track-lane clip-lane"
|
||||||
:style="{ width: laneWidth + 'px' }"
|
:style="{ width: laneWidth + 'px' }"
|
||||||
@dragover.stop="onClipLaneDragOver($event, track.clipTrackId)"
|
@dragover.stop="onClipLaneDragOver($event, track.clipTrackId)"
|
||||||
|
@dragleave.stop="onClipLaneDragLeave"
|
||||||
@drop.stop="onClipLaneDrop($event, track.clipTrackId)"
|
@drop.stop="onClipLaneDrop($event, track.clipTrackId)"
|
||||||
>
|
>
|
||||||
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
<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
|
<span
|
||||||
v-for="ci in clipInstancesByTrack[track.clipTrackId] ?? []"
|
v-for="ci in clipInstancesByTrack[track.clipTrackId] ?? []"
|
||||||
:key="ci.id"
|
:key="ci.id"
|
||||||
@@ -3799,6 +3921,40 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<ScriptFrameDialog
|
||||||
v-model="scriptDialogOpen"
|
v-model="scriptDialogOpen"
|
||||||
:frame-id="scriptDialogFrameId"
|
:frame-id="scriptDialogFrameId"
|
||||||
@@ -5041,7 +5197,8 @@ onBeforeUnmount(() => {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-clip-ghost {
|
.audio-clip-ghost,
|
||||||
|
.clip-instance-ghost {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ export { default as DialogsHost } from './DialogsHost.vue'
|
|||||||
export { default as IconButton } from './IconButton.vue'
|
export { default as IconButton } from './IconButton.vue'
|
||||||
export { default as Modal } from './Modal.vue'
|
export { default as Modal } from './Modal.vue'
|
||||||
export { default as Toast } from './Toast.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'
|
export type { ConfirmOptions, PromptOptions } from './dialogs'
|
||||||
@@ -26,3 +26,55 @@ export function setAudioDragSource(src: AudioDragSource): void {
|
|||||||
export function clearAudioDragSource(): void {
|
export function clearAudioDragSource(): void {
|
||||||
audioDragSource.value = null
|
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 PropertiesPanel from '@/components/PropertiesPanel.vue'
|
||||||
import ClipartDrawer from '@/components/ClipartDrawer.vue'
|
import ClipartDrawer from '@/components/ClipartDrawer.vue'
|
||||||
import SceneDrawer from '@/components/SceneDrawer.vue'
|
import SceneDrawer from '@/components/SceneDrawer.vue'
|
||||||
|
import DocumentDrawer from '@/components/DocumentDrawer.vue'
|
||||||
import ActionEditor from '@/components/ActionEditor.vue'
|
import ActionEditor from '@/components/ActionEditor.vue'
|
||||||
import Timeline from '@/components/Timeline.vue'
|
import Timeline from '@/components/Timeline.vue'
|
||||||
import AssetSidebar from '@/components/AssetSidebar.vue'
|
import AssetSidebar from '@/components/AssetSidebar.vue'
|
||||||
@@ -55,7 +56,7 @@ import {
|
|||||||
slicePeaksForClip,
|
slicePeaksForClip,
|
||||||
} from '@/composables/useAudioWaveform'
|
} from '@/composables/useAudioWaveform'
|
||||||
import { vTooltip } from '@/directives/tooltip'
|
import { vTooltip } from '@/directives/tooltip'
|
||||||
import { expect, suite, test } from './testRunner'
|
import { beforeEach, expect, suite, test } from './testRunner'
|
||||||
|
|
||||||
interface MountedHandle {
|
interface MountedHandle {
|
||||||
host: HTMLDivElement
|
host: HTMLDivElement
|
||||||
@@ -2544,6 +2545,101 @@ export function registerAllTests(): void {
|
|||||||
expect(ci.clipTrackId).toBe(t1.id)
|
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)', () => {
|
test('moveTrack reorders the unified stack (layer above clip track)', () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 10, height: 10, fill: '#000' })
|
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)', () => {
|
suite('ui — ActionEditor (jumpToScene)', () => {
|
||||||
test('jumpToScene appears in the type dropdown', async () => {
|
test('jumpToScene appears in the type dropdown', async () => {
|
||||||
const s = makeFreshStore()
|
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', () => {
|
suite('ui — Timeline audio track', () => {
|
||||||
test('audio track renders as an .audio-track lane with one block per clip', async () => {
|
test('audio track renders as an .audio-track lane with one block per clip', async () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
@@ -6244,6 +6718,68 @@ export function registerAllTests(): void {
|
|||||||
expect(s.setAudioClipPosition(clip.id, 0, 'ghost-track')).toBe(false)
|
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', () => {
|
test('normalizeScenePage drops orphan audio clips pointing at a missing track', () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
const asset = addAsset(s)
|
const asset = addAsset(s)
|
||||||
|
|||||||
+38
-2
@@ -23,7 +23,7 @@ export const en = {
|
|||||||
},
|
},
|
||||||
renameProject: 'Rename project',
|
renameProject: 'Rename project',
|
||||||
renameProjectHelp:
|
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: {
|
renameProjectDialog: {
|
||||||
title: 'Rename project',
|
title: 'Rename project',
|
||||||
message: 'Choose a new name for this project.',
|
message: 'Choose a new name for this project.',
|
||||||
@@ -370,6 +370,18 @@ export const en = {
|
|||||||
bgVideoUrlTitle: 'Background video URL',
|
bgVideoUrlTitle: 'Background video URL',
|
||||||
bgVideoUrlMessage: 'Paste the video URL (must be reachable by the browser):',
|
bgVideoUrlMessage: 'Paste the video URL (must be reachable by the browser):',
|
||||||
bgVideoUrlConfirm: 'Load',
|
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',
|
canvas: 'Canvas',
|
||||||
width: 'Width',
|
width: 'Width',
|
||||||
height: 'Height',
|
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.',
|
'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: {
|
scenes: {
|
||||||
title: 'Scenes',
|
title: 'Scenes',
|
||||||
open: 'Open scenes',
|
open: 'Open scenes',
|
||||||
@@ -952,7 +970,7 @@ export const de: DeepPartial<Messages> = {
|
|||||||
},
|
},
|
||||||
renameProject: 'Projekt umbenennen',
|
renameProject: 'Projekt umbenennen',
|
||||||
renameProjectHelp:
|
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: {
|
renameProjectDialog: {
|
||||||
title: 'Projekt umbenennen',
|
title: 'Projekt umbenennen',
|
||||||
message: 'Wähle einen neuen Namen für dieses Projekt.',
|
message: 'Wähle einen neuen Namen für dieses Projekt.',
|
||||||
@@ -1122,6 +1140,18 @@ export const de: DeepPartial<Messages> = {
|
|||||||
bgVideoUrlTitle: 'Hintergrundvideo-URL',
|
bgVideoUrlTitle: 'Hintergrundvideo-URL',
|
||||||
bgVideoUrlMessage: 'Video-URL einfügen. Sie muss im Browser erreichbar sein:',
|
bgVideoUrlMessage: 'Video-URL einfügen. Sie muss im Browser erreichbar sein:',
|
||||||
bgVideoUrlConfirm: 'Laden',
|
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',
|
canvas: 'Canvas',
|
||||||
width: 'Breite',
|
width: 'Breite',
|
||||||
height: 'Höhe',
|
height: 'Höhe',
|
||||||
@@ -1188,6 +1218,12 @@ export const de: DeepPartial<Messages> = {
|
|||||||
settingsHint:
|
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.',
|
'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: {
|
scenes: {
|
||||||
title: 'Szenen',
|
title: 'Szenen',
|
||||||
open: 'Szenen öffnen',
|
open: 'Szenen öffnen',
|
||||||
|
|||||||
+12
@@ -11,3 +11,15 @@ app.use(createPinia())
|
|||||||
app.use(VueKonva)
|
app.use(VueKonva)
|
||||||
app.directive('tooltip', vTooltip)
|
app.directive('tooltip', vTooltip)
|
||||||
app.mount('#app')
|
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
|
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[] = [
|
const BUTTON_STATE_TIME_VALUES: readonly number[] = [
|
||||||
BUTTON_STATE_TIMES.normal,
|
BUTTON_STATE_TIMES.normal,
|
||||||
BUTTON_STATE_TIMES.hover,
|
BUTTON_STATE_TIMES.hover,
|
||||||
@@ -1722,10 +1812,11 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** Move a clip to `(startTime, audioTrackId)` on the active page and
|
/** Move a clip to `(startTime, audioTrackId)` on the active page and
|
||||||
* cascade-push any existing clips on the target track out of the way
|
* cascade-push any existing clips on the target track out of the
|
||||||
* (forward in time) so no overlap survives. Clips that end BEFORE
|
* way so no overlap survives. Clips ahead of the moved block are
|
||||||
* the moved clip's new startTime are left alone. Returns `false`
|
* shoved forward; clips behind it are shoved backward. Hitting
|
||||||
* when the clip or target track is unknown. */
|
* 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 {
|
setAudioClipPosition(id: string, startTime: number, audioTrackId?: string): boolean {
|
||||||
const page = this.activeScene
|
const page = this.activeScene
|
||||||
const clip = page.audioClips.find((c) => c.id === id)
|
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))
|
return Math.max(0, a.duration - (c.trimStart ?? 0) - (c.trimEnd ?? 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
const newStart = Math.max(0, startTime)
|
|
||||||
const clipLen = lenOf(clip)
|
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(
|
||||||
const others = page.audioClips
|
(c) => c.audioTrackId === targetTrackId && c.id !== id,
|
||||||
.filter((c) => c.audioTrackId === targetTrackId && c.id !== id)
|
)
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.startTime - b.startTime)
|
|
||||||
|
|
||||||
let floor = newStart + clipLen
|
const appliedStart = cascadePushAside(
|
||||||
for (const other of others) {
|
others,
|
||||||
const len = lenOf(other)
|
oldCenter,
|
||||||
// Clip entirely before the moved block — no cascade needed.
|
clipLen,
|
||||||
if (other.startTime + len <= newStart) continue
|
lenOf,
|
||||||
// Overlapping or sitting past the moved block but below the
|
startTime,
|
||||||
// rolling floor → push it forward to the floor.
|
)
|
||||||
if (other.startTime < floor) other.startTime = floor
|
|
||||||
floor = other.startTime + len
|
|
||||||
}
|
|
||||||
|
|
||||||
clip.audioTrackId = targetTrackId
|
clip.audioTrackId = targetTrackId
|
||||||
clip.startTime = newStart
|
clip.startTime = appliedStart
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1801,6 +1887,24 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
this.activeScene.audioClips = this.activeScene.audioClips.filter((c) => c.id !== id)
|
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 --------------------------------------------
|
// -------- Button assets --------------------------------------------
|
||||||
|
|
||||||
addButtonAsset(name?: string, width = 200, height = 80): ButtonAsset {
|
addButtonAsset(name?: string, width = 200, height = 80): ButtonAsset {
|
||||||
@@ -2415,6 +2519,45 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
if (track) inst.clipTrackId = clipTrackId
|
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(
|
trimClipInstance(
|
||||||
id: string,
|
id: string,
|
||||||
patch: { startTime?: number; trimStart?: number; trimEnd?: number },
|
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)
|
this.activeScene.clipInstances = this.activeScene.clipInstances.filter((ci) => ci.id !== id)
|
||||||
if (this.selectedClipInstanceId === id) this.selectedClipInstanceId = null
|
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
|
/** Update the per-instance x / y / scaleX / scaleY. Missing fields
|
||||||
* on the patch are left alone; clearing a field (setting it to
|
* on the patch are left alone; clearing a field (setting it to
|
||||||
* the default 0 / 1) prunes it from the stored object so the
|
* 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'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user