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

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

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

No files matched your search

+116 -1
View File
@@ -22,7 +22,15 @@ import {
import { useSceneStore } from '@/stores/scene'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { importAudioFile, importImageFile, isAudioFile } from '@/lib/fileLoader'
import { clearAudioDragSource, setAudioDragSource } from '@/composables/useAudioDragSource'
import {
clearAudioDragSource,
installAudioClipDragImage,
setAudioDragSource,
} from '@/composables/useAudioDragSource'
import {
clearClipDragSource,
setClipDragSource,
} from '@/composables/useClipDragSource'
import type { Asset } from '@/types/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
import { loadSceneImages, renderButtonThumbnail } from '@/lib/render'
@@ -199,11 +207,105 @@ watchEffect(() => {
buttonThumbs.value = out
})
// Shared ghost-installer: appends the node offscreen, hands it to
// setDragImage for the snapshot frame, then removes it on next tick.
function installGhost(e: DragEvent, node: HTMLElement) {
if (!e.dataTransfer) return
node.style.position = 'fixed'
node.style.top = '-10000px'
node.style.left = '-10000px'
node.style.pointerEvents = 'none'
document.body.appendChild(node)
try {
e.dataTransfer.setDragImage(node, 12, 13)
} catch {
/* ignore */
}
setTimeout(() => node.remove(), 0)
}
// Pill-shaped ghost matching Timeline's `.clip-block` look — accent-
// coloured tint, film-strip icon, clip name. Track colour isn't known
// at drag time, so we fall back to the accent hue.
function buildClipGhost(name: string): HTMLElement {
const ghost = document.createElement('div')
ghost.style.cssText = [
'display: inline-flex',
'align-items: center',
'gap: 6px',
'height: 26px',
'padding: 0 10px',
'background: rgba(110,168,255,0.18)',
'border: 1px solid rgba(110,168,255,0.85)',
'border-radius: 4px',
'color: #a9c7ff',
'font: 500 11px/1.4 system-ui, sans-serif',
'white-space: nowrap',
'box-shadow: 0 2px 6px rgba(0,0,0,0.35)',
].join('; ')
ghost.innerHTML =
'<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" style="flex-shrink:0;opacity:0.9;">' +
'<path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16Zm0 16v32h-32V56Zm-64 0v32h-48V56Zm-64 32H56V56h32ZM40 104h32v48H40Zm48 0h80v48H88Zm96 0h32v48h-32ZM40 168h32v32H40Zm48 0h80v32H88Zm96 32v-32h32v32Z"/>' +
'</svg>'
const label = document.createElement('span')
label.textContent = name
label.style.cssText = 'overflow:hidden;text-overflow:ellipsis;max-width:200px;'
ghost.appendChild(label)
return ghost
}
// Fallback for buttons whose thumbnail hasn't rendered yet: a small
// pill hinting at the stage-shaped target.
function buildButtonGhost(name: string): HTMLElement {
const ghost = document.createElement('div')
ghost.style.cssText = [
'display: inline-flex',
'align-items: center',
'gap: 6px',
'height: 28px',
'padding: 0 14px',
'background: var(--panel, #2a2a34)',
'border: 1px solid rgba(255,255,255,0.25)',
'border-radius: 6px',
'color: #e6edf3',
'font: 500 12px/1.4 system-ui, sans-serif',
'white-space: nowrap',
'box-shadow: 0 2px 6px rgba(0,0,0,0.35)',
].join('; ')
ghost.innerHTML =
'<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" style="flex-shrink:0;opacity:0.9;">' +
'<path d="M169.57 72.61a8 8 0 0 0-5.46 10l20.36 61.05-25.62 17.1a8 8 0 0 0-2.51 10.9l40 64a8 8 0 1 0 13.56-8.48l-35.58-56.93 25.08-16.74a8 8 0 0 0 3.05-9.2L179.6 78.06a8 8 0 0 0-10.03-5.45ZM144 88a40 40 0 1 0-56 36.66v85.36a8 8 0 0 0 16 0V124.66A40 40 0 0 0 144 88Z"/>' +
'</svg>'
const label = document.createElement('span')
label.textContent = name
label.style.cssText = 'overflow:hidden;text-overflow:ellipsis;max-width:200px;'
ghost.appendChild(label)
return ghost
}
function onButtonAssetDragStart(e: DragEvent, assetId: string) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-asset', assetId)
e.dataTransfer.setData('text/plain', assetId)
// Mirror the image-asset behaviour: if the row already shows a
// pre-rendered button thumb, hand that to the drag layer so the
// cursor carries the stage-shaped preview instead of the library
// row. Fall back to a generic button pill when the thumb is still
// decoding or disabled (compact mode).
const target = e.currentTarget as HTMLElement
const thumbImg = target.querySelector('img') as HTMLImageElement | null
if (thumbImg && thumbImg.complete && thumbImg.naturalWidth > 0) {
const r = thumbImg.getBoundingClientRect()
const ox = Math.max(0, e.clientX - r.left)
const oy = Math.max(0, e.clientY - r.top)
e.dataTransfer.setDragImage(thumbImg, ox, oy)
return
}
const asset = store.scene.assets.find((a) => a.id === assetId)
const name = asset?.kind === 'button' ? asset.name : 'Button'
installGhost(e, buildButtonGhost(name))
}
async function renameButtonAssetPrompt(id: string, current: string) {
@@ -266,6 +368,18 @@ function onClipDragStart(e: DragEvent, clipId: string) {
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-clip', clipId)
e.dataTransfer.setData('text/plain', clipId)
const clip = store.project.clips.find((c) => c.id === clipId)
// Mirror the audio flow: stash clip info so Timeline can render a
// hover ghost (dataTransfer.getData is unreadable during dragover).
setClipDragSource({
clipId,
duration: clip?.duration ?? 0,
name: clip?.name ?? 'Clip',
})
const el = e.currentTarget as HTMLElement
el.addEventListener('dragend', clearClipDragSource, { once: true })
installGhost(e, buildClipGhost(clip?.name ?? 'Clip'))
}
async function handleFileSelected(event: Event) {
@@ -365,6 +479,7 @@ function onAudioAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'audi
// Surface the source info so Timeline's ghost preview has something
// to render during dragover (dataTransfer.getData is unreadable then).
setAudioDragSource({ duration: asset.duration, name: asset.name })
installAudioClipDragImage(e, { name: asset.name })
const el = e.currentTarget as HTMLElement
el.addEventListener('dragend', clearAudioDragSource, { once: true })
}
+53 -8
View File
@@ -1,10 +1,15 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { PhArrowClockwise, PhCaretLeft, PhCaretRight, PhFolderOpen, PhImagesSquare, PhMusicNote } from '@phosphor-icons/vue'
import { useClipartStore } from '@/stores/clipart'
import { readAudioDuration, readAsDataUrl } from '@/lib/fileLoader'
import { clearAudioDragSource, setAudioDragSource } from '@/composables/useAudioDragSource'
import {
clearAudioDragSource,
installAudioClipDragImage,
setAudioDragSource,
} from '@/composables/useAudioDragSource'
import { useFullscreen } from '@/composables/useFullscreen'
import { dialogState } from '@/components/ui'
import { t } from '@/i18n'
const clipart = useClipartStore()
@@ -23,12 +28,31 @@ function onDrawerLeave() {
if (isFullscreen.value) clipart.setOpen(false)
}
// ESC closes the drawer when opened via click. Skipped in fullscreen
// (hover-leave handles it) and while a dialog is on top.
function onDocKeyDown(e: KeyboardEvent) {
if (!clipart.open || isFullscreen.value) return
if (e.key !== 'Escape') return
if (dialogState.value) return
e.preventDefault()
clipart.setOpen(false)
}
watch(
() => clipart.open,
(v) => {
if (v) document.addEventListener('keydown', onDocKeyDown)
else document.removeEventListener('keydown', onDocKeyDown)
},
)
onMounted(() => {
clipart.initialize()
})
onBeforeUnmount(() => {
clipart.disposeItems()
document.removeEventListener('keydown', onDocKeyDown)
})
function onItemDragStart(e: DragEvent, index: number) {
@@ -44,6 +68,7 @@ function onItemDragStart(e: DragEvent, index: number) {
// file decodes, so seed with 0 (ghost renders at minimum width)
// and upgrade asynchronously. dragend clears both.
setAudioDragSource({ duration: 0, name: item.name })
installAudioClipDragImage(e, { name: item.name })
const el = e.currentTarget as HTMLElement
el.addEventListener('dragend', clearAudioDragSource, { once: true })
void (async () => {
@@ -55,8 +80,6 @@ function onItemDragStart(e: DragEvent, index: number) {
/* leave the placeholder width — still a visible hint */
}
})()
// No image ghost for audio — browsers render the row itself as the
// default drag preview, which is what we want.
return
}
// Use the actual image as drag ghost
@@ -79,7 +102,11 @@ function onItemDragStart(e: DragEvent, index: number) {
</script>
<template>
<aside class="clipart-drawer" :class="{ open: clipart.open }" @mouseleave="onDrawerLeave">
<aside
class="clipart-drawer"
:class="{ open: clipart.open, fullscreen: isFullscreen }"
@mouseleave="onDrawerLeave"
>
<button
class="drawer-tab"
v-tooltip="clipart.open ? t('clipart.close') : t('clipart.open')"
@@ -192,16 +219,34 @@ function onItemDragStart(e: DragEvent, index: number) {
transition: opacity 140ms var(--ease);
}
.clipart-drawer:hover .drawer-tab :deep(svg),
.clipart-drawer.open .drawer-tab :deep(svg) {
.clipart-drawer:hover:not(.fullscreen) .drawer-tab :deep(svg),
.clipart-drawer.open:not(.fullscreen) .drawer-tab :deep(svg) {
opacity: 1;
}
.clipart-drawer:hover .drawer-tab {
.clipart-drawer:hover:not(.fullscreen) .drawer-tab {
background: var(--panel-alt);
color: var(--text);
}
.clipart-drawer.fullscreen .drawer-tab {
cursor: default;
background: transparent !important;
}
/* Once the fullscreen hover opens the drawer, the tab column has
served its purpose (hover-detect) — reclaim its 24px so the panel
uses the full width. The aside itself still catches mouseleave. */
.clipart-drawer.fullscreen.open .drawer-tab {
display: none;
}
/* Tab column is gone in fullscreen — reclaim its 24 px so the stage
area grows by the same amount instead of leaving a blank strip. */
.clipart-drawer.fullscreen.open {
width: 400px;
}
.clipart-drawer.open .drawer-tab {
flex: 0 0 24px;
width: 24px;
+725
View File
@@ -0,0 +1,725 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { PhFilmReel, PhPlus, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { confirmDialog, promptDialog, dialogState } from '@/components/ui'
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
import { useProjectName } from '@/composables/useProjectName'
import {
parsePresetInput,
presetLabel,
useCanvasPresets,
type CanvasPreset,
} from '@/composables/useCanvasPresets'
import { t } from '@/i18n'
const store = useSceneStore()
const { open } = useDocumentDrawer()
const { projectName, renameProject } = useProjectName()
const { presets, addPreset, removePreset } = useCanvasPresets()
const rootEl = ref<HTMLElement | null>(null)
// The drawer is opened only by the topbar project-name button (no tab,
// no hover). An outside click or ESC dismisses it. A dialog on top
// (preset add/remove, bg-video confirms) owns ESC while it's up.
function onDocPointerDown(e: PointerEvent) {
if (!open.value) return
const root = rootEl.value
if (root && root.contains(e.target as Node)) return
// The project-name button is itself outside the drawer root. Don't
// close on clicks inside it — let its own click handler toggle.
const target = e.target as Element | null
if (target && target.closest('.project-name-btn')) return
open.value = false
}
function onDocKeyDown(e: KeyboardEvent) {
if (!open.value) return
if (e.key !== 'Escape') return
if (dialogState.value) return
e.preventDefault()
open.value = false
}
watch(open, (v) => {
if (v) {
// Defer attaching the pointerdown listener by one tick so the
// very click that opened the drawer (e.g. the topbar button)
// doesn't immediately register as an outside click.
setTimeout(() => {
if (!open.value) return
document.addEventListener('pointerdown', onDocPointerDown, true)
document.addEventListener('keydown', onDocKeyDown)
}, 0)
} else {
document.removeEventListener('pointerdown', onDocPointerDown, true)
document.removeEventListener('keydown', onDocKeyDown)
}
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocPointerDown, true)
document.removeEventListener('keydown', onDocKeyDown)
})
// Local draft of the project name so the inline input doesn't fire
// saveProjectToStore on every keystroke. Commit on blur / Enter; revert
// on Escape.
const nameDraft = ref(projectName.value ?? '')
watch(projectName, (v) => {
nameDraft.value = v ?? ''
})
function commitName() {
const next = nameDraft.value.trim()
if (!next) {
nameDraft.value = projectName.value ?? ''
return
}
if (next === (projectName.value ?? '')) return
void renameProject(next)
}
function onNameKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
;(e.currentTarget as HTMLInputElement).blur()
} else if (e.key === 'Escape') {
nameDraft.value = projectName.value ?? ''
;(e.currentTarget as HTMLInputElement).blur()
}
}
const bgVideoError = ref<string | null>(null)
const bgFileInput = ref<HTMLInputElement | null>(null)
interface ProbedVideo {
src: string
width: number
height: number
duration: number
name: string
}
function probeVideo(src: string, name: string): Promise<ProbedVideo> {
return new Promise((resolve, reject) => {
const v = document.createElement('video')
v.preload = 'metadata'
v.muted = true
v.src = src
v.onloadedmetadata = () => {
resolve({
src,
width: v.videoWidth || 1280,
height: v.videoHeight || 720,
duration: Number.isFinite(v.duration) ? v.duration : 0,
name,
})
}
v.onerror = () => reject(new Error('Could not read video metadata.'))
})
}
async function applyProbedVideo(probe: ProbedVideo) {
const stageMatches =
probe.width === store.scene.canvas.width && probe.height === store.scene.canvas.height
if (!stageMatches) {
const ok = await confirmDialog({
title: t('settings.bgVideoStageSizeTitle'),
message: t('settings.bgVideoStageSizeMessage', {
w: probe.width,
h: probe.height,
cw: store.scene.canvas.width,
ch: store.scene.canvas.height,
}),
confirmLabel: t('settings.bgVideoStageSizeConfirm'),
cancelLabel: t('settings.bgVideoStageSizeCancel'),
})
if (ok) store.setCanvasSize(probe.width, probe.height)
}
if (probe.duration > 0 && probe.duration > store.scene.duration) {
const ok = await confirmDialog({
title: t('settings.bgVideoDurationTitle'),
message: t('settings.bgVideoDurationMessage', {
d: probe.duration.toFixed(2),
cd: store.scene.duration,
}),
confirmLabel: t('settings.bgVideoDurationConfirm'),
cancelLabel: t('settings.bgVideoDurationCancel'),
})
if (ok) store.setDuration(probe.duration)
}
store.setBackgroundVideo({ src: probe.src, muted: true })
}
function pickBgVideoFile() {
bgVideoError.value = null
bgFileInput.value?.click()
}
async function onBgFileSelected(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
try {
const reader = new FileReader()
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
const probe = await probeVideo(dataUrl, file.name)
await applyProbedVideo(probe)
} catch (err) {
bgVideoError.value = err instanceof Error ? err.message : String(err)
}
}
async function pickBgVideoUrl() {
bgVideoError.value = null
const url = await promptDialog({
title: t('settings.bgVideoUrlTitle'),
message: t('settings.bgVideoUrlMessage'),
defaultValue: store.scene.backgroundVideo?.src ?? '',
confirmLabel: t('settings.bgVideoUrlConfirm'),
})
if (!url) return
try {
const probe = await probeVideo(url.trim(), url.trim())
await applyProbedVideo(probe)
} catch (err) {
bgVideoError.value = err instanceof Error ? err.message : String(err)
}
}
function clearBgVideo() {
store.setBackgroundVideo(null)
bgVideoError.value = null
}
// Right-click on the BG video button surfaces URL / clear without
// cluttering the primary click target.
async function onBgVideoContext() {
if (store.scene.backgroundVideo) {
const ok = await confirmDialog({
title: t('settings.bgVideoClear'),
message: bgVideoDisplayName(),
confirmLabel: t('settings.bgVideoClear'),
})
if (ok) clearBgVideo()
return
}
await pickBgVideoUrl()
}
function bgVideoDisplayName(): string {
const v = store.scene.backgroundVideo
if (!v) return ''
if (v.src.startsWith('data:')) return t('settings.bgVideoLocalFile')
try {
const u = new URL(v.src)
return u.pathname.split('/').filter(Boolean).pop() || u.hostname
} catch {
return v.src.length > 48 ? v.src.slice(0, 48) + '…' : v.src
}
}
const width = computed({
get: () => store.scene.canvas.width,
set: (v: number) => store.setCanvasSize(v, store.scene.canvas.height),
})
const height = computed({
get: () => store.scene.canvas.height,
set: (v: number) => store.setCanvasSize(store.scene.canvas.width, v),
})
const background = computed({
get: () => store.scene.canvas.background,
set: (v: string) => store.setCanvasBackground(v),
})
const duration = computed({
get: () => store.scene.duration,
set: (v: number) => store.setDuration(v),
})
const fps = computed({
get: () => store.scene.fps,
set: (v: number) => store.setFps(v),
})
function applyPreset(p: CanvasPreset) {
store.setCanvasSize(p.w, p.h)
}
const presetAddError = ref<string | null>(null)
async function onAddPreset() {
presetAddError.value = null
while (true) {
const input = await promptDialog({
title: t('settings.presetAddTitle'),
message: presetAddError.value
? `${t('settings.presetAddInvalid')} — ${t('settings.presetAddMessage')}`
: t('settings.presetAddMessage'),
placeholder: t('settings.presetAddPlaceholder'),
confirmLabel: t('settings.presetAddConfirm'),
})
if (input === null) {
presetAddError.value = null
return
}
const parsed = parsePresetInput(input)
if (!parsed) {
presetAddError.value = t('settings.presetAddInvalid')
continue
}
addPreset({ label: presetLabel(parsed.w, parsed.h), w: parsed.w, h: parsed.h })
presetAddError.value = null
return
}
}
async function onRemovePreset(p: CanvasPreset) {
const ok = await confirmDialog({
title: t('settings.presetRemoveTitle'),
message: t('settings.presetRemoveMessage', { label: p.label }),
confirmLabel: t('settings.presetRemoveConfirm'),
variant: 'danger',
})
if (ok) removePreset(p)
}
</script>
<template>
<aside
ref="rootEl"
class="document-drawer"
:class="{ open }"
>
<div class="drawer-panel">
<header class="drawer-header">
<label class="name-field">
<span class="name-label">{{ t('documentDrawer.projectName') }}</span>
<input
class="name-input"
type="text"
:value="nameDraft"
:placeholder="t('app.untitledProject')"
@input="nameDraft = ($event.target as HTMLInputElement).value"
@change="commitName"
@blur="commitName"
@keydown="onNameKeyDown"
/>
</label>
</header>
<div class="drawer-body">
<div class="settings-complex">
<div class="fields-row">
<div class="fields-group">
<label class="field">
<span>{{ t('settings.width') }}</span>
<input type="number" min="1" step="1" v-model.number="width" />
</label>
<label class="field">
<span>{{ t('settings.height') }}</span>
<input type="number" min="1" step="1" v-model.number="height" />
</label>
<label class="field">
<span>{{ t('settings.duration') }}</span>
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
</label>
<label class="field">
<span>{{ t('settings.fps') }}</span>
<input type="number" min="1" step="1" v-model.number="fps" />
</label>
</div>
<div class="fields-group">
<label class="field color-field">
<span>{{ t('settings.background') }}</span>
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="background"
@input="background = ($event.target as HTMLInputElement).value"
/>
<span class="hex">{{ background.toUpperCase() }}</span>
</div>
</label>
<label class="field bg-video-field">
<span>{{ t('settings.bgVideoLabel') }}</span>
<button
class="bg-video-btn"
v-tooltip="t('settings.bgVideoPicker')"
@click="pickBgVideoFile"
@contextmenu.prevent="onBgVideoContext"
>
<PhFilmReel :size="12" />
<span v-if="store.scene.backgroundVideo" class="bg-video-name">
{{ bgVideoDisplayName() }}
</span>
<span v-else class="bg-video-empty">
{{ t('settings.bgVideoButton') }}
</span>
<button
v-if="store.scene.backgroundVideo"
class="bg-video-clear"
v-tooltip="t('settings.bgVideoClear')"
@click.stop="clearBgVideo"
>
<PhX :size="10" />
</button>
</button>
</label>
</div>
</div>
<div class="settings-divider"></div>
<div class="presets-row">
<button
v-for="p in presets"
:key="`${p.w}x${p.h}`"
class="preset"
:class="{ active: width === p.w && height === p.h }"
v-tooltip="t('settings.presetRemoveTitle')"
@click="applyPreset(p)"
@contextmenu.prevent="onRemovePreset(p)"
>
{{ p.label }}
</button>
<button
class="preset preset-add"
v-tooltip="t('settings.presetAddTooltip')"
@click="onAddPreset"
>
<PhPlus :size="10" />
</button>
</div>
</div>
<p v-if="bgVideoError" class="error-line">{{ bgVideoError }}</p>
<input
ref="bgFileInput"
type="file"
accept="video/*"
style="display: none"
@change="onBgFileSelected"
/>
</div>
</div>
</aside>
</template>
<style scoped>
/* Slides out from under the topbar. The aside is anchored to the top
edge of `.app` (topbar height = 48px), hidden above via translateY
when closed, and pops into place when opened. Hidden state keeps
pointer-events off so the invisible panel doesn't swallow clicks. */
.document-drawer {
position: absolute;
top: 48px;
left: 0;
right: 0;
height: 196px;
z-index: 40;
background: var(--panel);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-md, 0 6px 18px rgba(0, 0, 0, 0.25));
transform: translateY(calc(-100% - 48px));
transition: transform 220ms var(--ease);
pointer-events: none;
}
.document-drawer.open {
transform: translateY(0);
pointer-events: auto;
}
.drawer-panel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.drawer-header {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
}
.name-field {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 100%;
max-width: 420px;
}
.name-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.name-input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 10px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 13px;
font-weight: 500;
text-align: center;
}
.name-input:focus {
outline: none;
border-color: var(--accent);
}
.drawer-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 8px;
}
/* The settings complex sizes itself to whichever row is wider —
the fields row with its space-between, or the presets row. More
presets widen the complex, which in turn pushes the right-hand
input group (color + video) further right so it stays flush with
the last preset. */
.settings-complex {
display: inline-flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
max-width: 100%;
}
.fields-row {
display: flex;
gap: 24px;
align-items: flex-end;
justify-content: space-between;
}
.fields-group {
display: flex;
gap: 12px;
align-items: flex-end;
flex: 0 0 auto;
}
.settings-divider {
height: 1px;
background: var(--border);
width: 100%;
}
.field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
flex: 0 0 auto;
}
.field > span {
padding-left: 2px;
}
.field input[type='number'] {
width: 78px;
height: 26px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 0 8px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 12px;
line-height: 24px;
text-transform: none;
letter-spacing: 0;
box-sizing: border-box;
}
.color-row {
display: flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 6px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-sizing: border-box;
}
.color-swatch {
width: 22px;
height: 18px;
padding: 0;
border: 1px solid var(--border);
border-radius: 3px;
background: transparent;
cursor: pointer;
flex-shrink: 0;
box-sizing: border-box;
}
.color-swatch::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-swatch::-webkit-color-swatch {
border: none;
border-radius: 3px;
}
.color-swatch::-moz-color-swatch {
border: none;
border-radius: 3px;
}
.hex {
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--text-dim-strong);
text-transform: none;
letter-spacing: 0;
}
.bg-video-field {
min-width: 180px;
}
.bg-video-btn {
display: flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 8px;
box-sizing: border-box;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 11px;
font: inherit;
text-transform: none;
letter-spacing: 0;
cursor: pointer;
min-width: 180px;
max-width: 240px;
}
.bg-video-btn:hover {
border-color: var(--border-strong, var(--accent));
}
.bg-video-name {
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
min-width: 0;
text-align: left;
}
.bg-video-empty {
color: var(--text-dim);
font-style: italic;
flex: 1 1 auto;
text-align: left;
}
.bg-video-clear {
padding: 2px;
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
border-radius: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.bg-video-clear:hover {
color: var(--danger);
background: var(--panel-alt);
}
.presets-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
justify-content: flex-start;
max-width: 100%;
}
.preset {
padding: 3px 10px;
background: var(--panel-alt);
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
font: inherit;
font-size: 10px;
}
.preset:hover:not(:disabled) {
background: var(--panel-hover);
color: var(--text);
}
.preset.active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.preset-add {
padding: 3px 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
}
.preset-add:hover {
color: var(--text);
}
.error-line {
margin: 0;
color: var(--danger);
font-size: 11px;
text-align: center;
}
</style>
+5 -255
View File
@@ -1,29 +1,24 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
PhFilmReel,
PhFolder,
PhFrameCorners,
PhMoon,
PhSlidersHorizontal,
PhSun,
PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useClipartStore } from '@/stores/clipart'
import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme'
import { useTooltipPref } from '@/composables/useTooltipPref'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { showLocked } from '@/composables/useShowLocked'
import { Modal, confirmDialog, promptDialog } from '@/components/ui'
import { Modal } from '@/components/ui'
import { setLocale, t, useI18n } from '@/i18n'
import type { Locale } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const clipart = useClipartStore()
const { theme, setTheme } = useTheme()
const { enabled: tooltipsEnabled } = useTooltipPref()
@@ -40,178 +35,20 @@ watch(
},
)
type TabId = 'document' | 'clipart' | 'interface'
type TabId = 'clipart' | 'interface'
const tabs: { id: TabId; labelKey: string; icon: typeof PhFrameCorners }[] = [
{ id: 'document', labelKey: 'settings.tab.document', icon: PhFrameCorners },
const tabs: { id: TabId; labelKey: string; icon: typeof PhFolder }[] = [
{ id: 'clipart', labelKey: 'settings.tab.clipart', icon: PhFolder },
{ id: 'interface', labelKey: 'settings.tab.interface', icon: PhSlidersHorizontal },
]
const activeTab = ref<TabId>('document')
const bgVideoError = ref<string | null>(null)
const bgFileInput = ref<HTMLInputElement | null>(null)
interface ProbedVideo {
src: string
width: number
height: number
duration: number
name: string
}
function probeVideo(src: string, name: string): Promise<ProbedVideo> {
return new Promise((resolve, reject) => {
const v = document.createElement('video')
v.preload = 'metadata'
v.muted = true
v.src = src
v.onloadedmetadata = () => {
resolve({
src,
width: v.videoWidth || 1280,
height: v.videoHeight || 720,
duration: Number.isFinite(v.duration) ? v.duration : 0,
name,
})
}
v.onerror = () => reject(new Error('Could not read video metadata.'))
})
}
async function applyProbedVideo(probe: ProbedVideo) {
const stageMatches =
probe.width === store.scene.canvas.width && probe.height === store.scene.canvas.height
if (!stageMatches) {
const ok = await confirmDialog({
title: t('settings.bgVideoStageSizeTitle'),
message: t('settings.bgVideoStageSizeMessage', {
w: probe.width,
h: probe.height,
cw: store.scene.canvas.width,
ch: store.scene.canvas.height,
}),
confirmLabel: t('settings.bgVideoStageSizeConfirm'),
cancelLabel: t('settings.bgVideoStageSizeCancel'),
})
if (ok) store.setCanvasSize(probe.width, probe.height)
}
if (probe.duration > 0 && probe.duration > store.scene.duration) {
const ok = await confirmDialog({
title: t('settings.bgVideoDurationTitle'),
message: t('settings.bgVideoDurationMessage', {
d: probe.duration.toFixed(2),
cd: store.scene.duration,
}),
confirmLabel: t('settings.bgVideoDurationConfirm'),
cancelLabel: t('settings.bgVideoDurationCancel'),
})
if (ok) store.setDuration(probe.duration)
}
store.setBackgroundVideo({ src: probe.src, muted: true })
}
async function pickBgVideoFile() {
bgVideoError.value = null
bgFileInput.value?.click()
}
async function onBgFileSelected(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
try {
const reader = new FileReader()
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
const probe = await probeVideo(dataUrl, file.name)
await applyProbedVideo(probe)
} catch (err) {
bgVideoError.value = err instanceof Error ? err.message : String(err)
}
}
async function pickBgVideoUrl() {
bgVideoError.value = null
const url = await promptDialog({
title: t('settings.bgVideoUrlTitle'),
message: t('settings.bgVideoUrlMessage'),
defaultValue: store.scene.backgroundVideo?.src ?? '',
confirmLabel: t('settings.bgVideoUrlConfirm'),
})
if (!url) return
try {
const probe = await probeVideo(url.trim(), url.trim())
await applyProbedVideo(probe)
} catch (err) {
bgVideoError.value = err instanceof Error ? err.message : String(err)
}
}
function clearBgVideo() {
store.setBackgroundVideo(null)
bgVideoError.value = null
}
function bgVideoDisplayName(): string {
const v = store.scene.backgroundVideo
if (!v) return ''
if (v.src.startsWith('data:')) return t('settings.bgVideoLocalFile')
try {
const u = new URL(v.src)
return u.pathname.split('/').filter(Boolean).pop() || u.hostname
} catch {
return v.src.length > 48 ? v.src.slice(0, 48) + '…' : v.src
}
}
const width = computed({
get: () => store.scene.canvas.width,
set: (v: number) => store.setCanvasSize(v, store.scene.canvas.height),
})
const height = computed({
get: () => store.scene.canvas.height,
set: (v: number) => store.setCanvasSize(store.scene.canvas.width, v),
})
const background = computed({
get: () => store.scene.canvas.background,
set: (v: string) => store.setCanvasBackground(v),
})
const duration = computed({
get: () => store.scene.duration,
set: (v: number) => store.setDuration(v),
})
const fps = computed({
get: () => store.scene.fps,
set: (v: number) => store.setFps(v),
})
const activeTab = ref<TabId>('clipart')
const selectedLocale = computed({
get: () => locale.value,
set: (value: Locale) => setLocale(value),
})
const presets: { label: string; w: number; h: number }[] = [
{ label: '16:9 · 1280×720', w: 1280, h: 720 },
{ label: '16:9 · 1920×1080', w: 1920, h: 1080 },
{ label: '1:1 · 1080×1080', w: 1080, h: 1080 },
{ label: '9:16 · 1080×1920', w: 1080, h: 1920 },
{ label: '4:5 · 1080×1350', w: 1080, h: 1350 },
]
function applyPreset(p: { w: number; h: number }) {
store.setCanvasSize(p.w, p.h)
}
function close() {
emit('update:modelValue', false)
}
@@ -252,94 +89,7 @@ function applyTheme(t: Theme) {
</nav>
<div class="content">
<section v-if="activeTab === 'document'">
<h3>{{ t('settings.canvas') }}</h3>
<div class="row">
<label>
{{ t('settings.width') }}
<input type="number" min="1" step="1" v-model.number="width" />
</label>
<label>
{{ t('settings.height') }}
<input type="number" min="1" step="1" v-model.number="height" />
</label>
</div>
<div class="presets">
<button
v-for="p in presets"
:key="p.label"
class="preset"
:class="{ active: width === p.w && height === p.h }"
@click="applyPreset(p)"
>
{{ p.label }}
</button>
</div>
<label class="stacked">
{{ t('settings.background') }}
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="background"
@input="background = ($event.target as HTMLInputElement).value"
/>
<span class="hex">{{ background.toUpperCase() }}</span>
</div>
</label>
<div class="clipart-row">
<div class="clipart-label">{{ t('settings.bgVideoLabel') }}</div>
<div class="clipart-value">
<PhFilmReel :size="13" />
<span v-if="store.scene.backgroundVideo" class="clipart-folder-name">
{{ bgVideoDisplayName() }}
</span>
<span v-else class="clipart-folder-empty">{{ t('settings.bgVideoNone') }}</span>
<button
v-if="store.scene.backgroundVideo"
class="ghost-btn inline-clear"
v-tooltip="t('settings.bgVideoClear')"
@click="clearBgVideo"
>
<PhX :size="12" />
</button>
</div>
</div>
<div class="clipart-actions">
<button class="primary-btn" @click="pickBgVideoFile">
{{ t('settings.bgVideoPickFile') }}
</button>
<button class="ghost-btn" @click="pickBgVideoUrl">
{{ t('settings.bgVideoPickUrl') }}
</button>
</div>
<input
ref="bgFileInput"
type="file"
accept="video/*"
style="display: none"
@change="onBgFileSelected"
/>
<p v-if="bgVideoError" class="error-line">{{ bgVideoError }}</p>
<p class="hint">{{ t('settings.bgVideoHint') }}</p>
<h3 class="sub-heading">{{ t('settings.timing') }}</h3>
<div class="row">
<label>
{{ t('settings.duration') }}
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
</label>
<label>
{{ t('settings.fps') }}
<input type="number" min="1" step="1" v-model.number="fps" />
</label>
</div>
<p class="hint">{{ t('settings.timelineHint') }}</p>
</section>
<section v-else-if="activeTab === 'clipart'">
<section v-if="activeTab === 'clipart'">
<h3>{{ t('settings.tab.clipart') }}</h3>
<div class="clipart-row">
<div class="clipart-label">{{ t('clipart.folderLabel') }}</div>
+54 -6
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { PhCaretLeft, PhCaretRight, PhCopy, PhFilmSlate, PhPlus, PhTrash } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
import { confirmDialog, dialogState, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
import { useFullscreen } from '@/composables/useFullscreen'
@@ -21,6 +21,32 @@ function onDrawerLeave() {
if (isFullscreen.value) open.value = false
}
// ESC closes the drawer when opened via click. Skipped in fullscreen
// (hover-leave already handles it) and while a dialog is on top — the
// dialog owns ESC in that case. If the row context menu is open, ESC
// dismisses that first and the drawer only on the next press.
function onDocKeyDown(e: KeyboardEvent) {
if (!open.value || isFullscreen.value) return
if (e.key !== 'Escape') return
if (dialogState.value) return
if (menu.value) {
e.preventDefault()
menu.value = null
return
}
e.preventDefault()
open.value = false
}
watch(open, (v) => {
if (v) document.addEventListener('keydown', onDocKeyDown)
else document.removeEventListener('keydown', onDocKeyDown)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onDocKeyDown)
})
interface MenuState {
pageId: string
x: number
@@ -100,7 +126,11 @@ watch(menu, (m) => {
</script>
<template>
<aside class="scene-drawer" :class="{ open }" @mouseleave="onDrawerLeave">
<aside
class="scene-drawer"
:class="{ open, fullscreen: isFullscreen }"
@mouseleave="onDrawerLeave"
>
<button
class="drawer-tab"
v-tooltip="open ? t('scenes.close') : t('scenes.open')"
@@ -210,16 +240,34 @@ watch(menu, (m) => {
transition: opacity 140ms var(--ease);
}
.scene-drawer:hover .drawer-tab :deep(svg),
.scene-drawer.open .drawer-tab :deep(svg) {
.scene-drawer:hover:not(.fullscreen) .drawer-tab :deep(svg),
.scene-drawer.open:not(.fullscreen) .drawer-tab :deep(svg) {
opacity: 1;
}
.scene-drawer:hover .drawer-tab {
.scene-drawer:hover:not(.fullscreen) .drawer-tab {
background: var(--panel-alt);
color: var(--text);
}
.scene-drawer.fullscreen .drawer-tab {
cursor: default;
background: transparent !important;
}
/* Once the fullscreen hover opens the drawer, the tab column has
served its purpose (hover-detect) — reclaim its 24px so the panel
uses the full width. The aside itself still catches mouseleave. */
.scene-drawer.fullscreen.open .drawer-tab {
display: none;
}
/* Tab column is gone in fullscreen — reclaim its 24 px so the stage
area grows by the same amount instead of leaving a blank strip. */
.scene-drawer.fullscreen.open {
width: 300px;
}
.scene-drawer.open .drawer-tab {
flex: 0 0 24px;
width: 24px;
+200 -43
View File
@@ -33,6 +33,7 @@ import { useSceneStore } from '@/stores/scene'
import { useClipartStore } from '@/stores/clipart'
import { importAudioFile } from '@/lib/fileLoader'
import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDragSource'
import { clearClipDragSource, clipDragSource } from '@/composables/useClipDragSource'
import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform'
import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
@@ -1663,21 +1664,83 @@ function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): numbe
return ratio * duration.value
}
function onClipLaneDragOver(e: DragEvent, _clipTrackId: string) {
if (e.dataTransfer?.types.includes('application/x-animatr-clip')) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
interface ClipInstanceGhost {
targetTrackId: string
startTime: number
length: number
}
/** Hover-preview for dropping a library clip onto a clip lane. Mirrors
* `audioClipGhost`. Cleared whenever the cursor leaves the lane or
* the drop lands. */
const clipInstanceGhost = ref<ClipInstanceGhost | null>(null)
/** Full-window collision: does [start, start+length] overlap any
* existing clip instance on this track? Stricter than a point-in-clip
* check — catches the case where the drop point is free but the
* dropped tail would land on top of a neighbour. Mirrors
* `audioWindowCollides`. */
function clipWindowCollides(
trackId: string,
start: number,
length: number,
): boolean {
const end = start + length
const siblings = clipInstancesByTrack.value[trackId] ?? []
for (const ci of siblings) {
const cEnd = ci.startTime + visibleDurationOf(ci)
if (cEnd > start && ci.startTime < end) return true
}
return false
}
function onClipLaneDragOver(e: DragEvent, clipTrackId: string) {
const types = e.dataTransfer?.types
if (!types?.includes('application/x-animatr-clip')) return
e.preventDefault()
if (!e.dataTransfer) return
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromEvent(e, el)
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
const src = clipDragSource.value
const length = Math.max(0, src?.duration ?? 0)
const collides =
track?.locked || clipWindowCollides(clipTrackId, time, length)
e.dataTransfer.dropEffect = collides ? 'none' : 'copy'
if (!collides && src) {
clipInstanceGhost.value = {
targetTrackId: clipTrackId,
startTime: time,
length,
}
} else {
clipInstanceGhost.value = null
}
}
function onClipLaneDragLeave() {
clipInstanceGhost.value = null
}
function onClipLaneDrop(e: DragEvent, clipTrackId: string) {
e.preventDefault()
clipInstanceGhost.value = null
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
if (track?.locked) return
if (track?.locked) {
clearClipDragSource()
return
}
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromEvent(e, el)
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
if (clipId) store.addClipInstance(clipId, time, clipTrackId)
// The library payload is authoritative for the window check — the
// composable source may have been cleared by a racing dragend.
const clip = clipId ? store.project.clips.find((c) => c.id === clipId) : null
const length = clip ? Math.max(0, clip.duration) : 0
clearClipDragSource()
if (!clipId) return
if (clipWindowCollides(clipTrackId, time, length)) return
store.addClipInstance(clipId, time, clipTrackId)
}
interface ClipMoveState {
@@ -1720,7 +1783,7 @@ function onClipBlockPointerMove(e: PointerEvent) {
if (pps <= 0) return
const deltaSec = deltaPx / pps
const newStart = Math.max(0, m.initialStartTime + deltaSec)
store.moveClipInstance(m.instanceId, newStart)
store.setClipInstancePosition(m.instanceId, newStart)
}
function onClipBlockPointerUp(e: PointerEvent) {
@@ -1988,19 +2051,33 @@ async function onAudioLaneDrop(e: DragEvent, audioTrackId: string) {
}
}
async function onAudioClipContextMenu(e: MouseEvent, clip: AudioClip) {
interface AudioClipMenuState {
clipId: string
x: number
y: number
}
const audioClipMenu = ref<AudioClipMenuState | null>(null)
const audioClipMenuRef = ref<HTMLDivElement | null>(null)
function onAudioClipContextMenu(e: MouseEvent, clip: AudioClip) {
e.preventDefault()
e.stopPropagation()
// v1: a single "remove" action. The right-click context menu grows
// later once trim handles and per-clip properties land.
const ok = await confirmDialog({
title: t('timeline.removeAudioClipDialog.title'),
message: t('timeline.removeAudioClipDialog.message'),
confirmLabel: t('timeline.removeAudioClipDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeAudioClip(clip.id)
audioClipMenu.value = { clipId: clip.id, x: e.clientX, y: e.clientY }
}
function duplicateAudioClipFromMenu() {
const m = audioClipMenu.value
if (!m) return
store.duplicateAudioClip(m.clipId)
audioClipMenu.value = null
}
function deleteAudioClipFromMenu() {
const m = audioClipMenu.value
if (!m) return
store.removeAudioClip(m.clipId)
audioClipMenu.value = null
}
interface AudioClipMoveState {
@@ -2381,22 +2458,33 @@ function onClipBlockDblClick(ci: ClipInstance) {
store.enterClip(ci.clipId)
}
async function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
interface ClipInstanceMenuState {
instanceId: string
x: number
y: number
}
const clipInstanceMenu = ref<ClipInstanceMenuState | null>(null)
const clipInstanceMenuRef = ref<HTMLDivElement | null>(null)
function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
e.preventDefault()
const clip = clipById.value.get(ci.clipId)
const ok = await confirmDialog({
title: t('timeline.removeInstanceDialog.title'),
message: clip
? t('timeline.removeInstanceDialog.message', {
name: clip.name,
time: ci.startTime.toFixed(2),
})
: t('timeline.removeInstanceDialog.generic'),
confirmLabel: t('timeline.removeInstanceDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeClipInstance(ci.id)
e.stopPropagation()
clipInstanceMenu.value = { instanceId: ci.id, x: e.clientX, y: e.clientY }
}
function duplicateClipInstanceFromMenu() {
const m = clipInstanceMenu.value
if (!m) return
store.duplicateClipInstance(m.instanceId)
clipInstanceMenu.value = null
}
function deleteClipInstanceFromMenu() {
const m = clipInstanceMenu.value
if (!m) return
store.removeClipInstance(m.instanceId)
clipInstanceMenu.value = null
}
const trackMenu = ref<TrackMenuState | null>(null)
@@ -2622,6 +2710,16 @@ function onDocumentPointerDown(e: PointerEvent) {
} else if (scriptFrameMenu.value) {
scriptFrameMenu.value = null
}
if (clipInstanceMenu.value && clipInstanceMenuRef.value && e.target instanceof Node) {
if (!clipInstanceMenuRef.value.contains(e.target)) clipInstanceMenu.value = null
} else if (clipInstanceMenu.value) {
clipInstanceMenu.value = null
}
if (audioClipMenu.value && audioClipMenuRef.value && e.target instanceof Node) {
if (!audioClipMenuRef.value.contains(e.target)) audioClipMenu.value = null
} else if (audioClipMenu.value) {
audioClipMenu.value = null
}
}
function onKeyDown(e: KeyboardEvent) {
@@ -2629,6 +2727,8 @@ function onKeyDown(e: KeyboardEvent) {
menu.value = null
trackMenu.value = null
scriptFrameMenu.value = null
clipInstanceMenu.value = null
audioClipMenu.value = null
}
}
@@ -2694,15 +2794,18 @@ function onDocumentKeyDown(e: KeyboardEvent) {
}
}
watch([menu, trackMenu, scriptFrameMenu], ([m, tm, sm]) => {
if (m || tm || sm) {
document.addEventListener('pointerdown', onDocumentPointerDown, true)
document.addEventListener('keydown', onKeyDown)
} else {
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
document.removeEventListener('keydown', onKeyDown)
}
})
watch(
[menu, trackMenu, scriptFrameMenu, clipInstanceMenu, audioClipMenu],
([m, tm, sm, cim, acm]) => {
if (m || tm || sm || cim || acm) {
document.addEventListener('pointerdown', onDocumentPointerDown, true)
document.addEventListener('keydown', onKeyDown)
} else {
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
document.removeEventListener('keydown', onKeyDown)
}
},
)
async function clampMenuIntoViewport(
getEl: () => HTMLElement | null,
@@ -2739,6 +2842,16 @@ watch(scriptFrameMenu, (sm) => {
clampMenuIntoViewport(() => scriptFrameMenuRef.value, sm.x, sm.y)
})
watch(clipInstanceMenu, (cim) => {
if (!cim) return
clampMenuIntoViewport(() => clipInstanceMenuRef.value, cim.x, cim.y)
})
watch(audioClipMenu, (acm) => {
if (!acm) return
clampMenuIntoViewport(() => audioClipMenuRef.value, acm.x, acm.y)
})
function onWheel(e: WheelEvent) {
if (!scrollEl.value) return
if (e.ctrlKey || e.metaKey) {
@@ -3249,9 +3362,18 @@ onBeforeUnmount(() => {
class="track-lane clip-lane"
:style="{ width: laneWidth + 'px' }"
@dragover.stop="onClipLaneDragOver($event, track.clipTrackId)"
@dragleave.stop="onClipLaneDragLeave"
@drop.stop="onClipLaneDrop($event, track.clipTrackId)"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-if="clipInstanceGhost && clipInstanceGhost.targetTrackId === track.clipTrackId"
class="clip-instance-ghost"
:style="{
left: pxFromTime(clipInstanceGhost.startTime) + 'px',
width: Math.max(6, pxFromTime(clipInstanceGhost.length)) + 'px',
}"
></span>
<span
v-for="ci in clipInstancesByTrack[track.clipTrackId] ?? []"
:key="ci.id"
@@ -3799,6 +3921,40 @@ onBeforeUnmount(() => {
</button>
</div>
<div
v-if="clipInstanceMenu"
ref="clipInstanceMenuRef"
class="kf-menu"
:style="{ left: clipInstanceMenu.x + 'px', top: clipInstanceMenu.y + 'px' }"
>
<button class="kf-menu-item" @click="duplicateClipInstanceFromMenu">
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteClipInstanceFromMenu">
<PhTrash :size="12" class="kf-menu-icon" />
{{ t('timeline.delete') }}
</button>
</div>
<div
v-if="audioClipMenu"
ref="audioClipMenuRef"
class="kf-menu"
:style="{ left: audioClipMenu.x + 'px', top: audioClipMenu.y + 'px' }"
>
<button class="kf-menu-item" @click="duplicateAudioClipFromMenu">
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteAudioClipFromMenu">
<PhTrash :size="12" class="kf-menu-icon" />
{{ t('timeline.delete') }}
</button>
</div>
<ScriptFrameDialog
v-model="scriptDialogOpen"
:frame-id="scriptDialogFrameId"
@@ -5041,7 +5197,8 @@ onBeforeUnmount(() => {
cursor: default;
}
.audio-clip-ghost {
.audio-clip-ghost,
.clip-instance-ghost {
position: absolute;
top: 4px;
bottom: 4px;
+1 -1
View File
@@ -2,5 +2,5 @@ export { default as DialogsHost } from './DialogsHost.vue'
export { default as IconButton } from './IconButton.vue'
export { default as Modal } from './Modal.vue'
export { default as Toast } from './Toast.vue'
export { confirmDialog, promptDialog } from './dialogs'
export { confirmDialog, dialogState, promptDialog } from './dialogs'
export type { ConfirmOptions, PromptOptions } from './dialogs'