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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user