Files
RUWIO/src/components/DocumentDrawer.vue
T
2026-04-25 00:08:29 +02:00

803 lines
21 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { PhCheckerboard, 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)
const nameInput = ref<HTMLInputElement | 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)
// Focus + select the project-name field on open — renaming is
// the main reason this drawer exists, so the cursor should land
// there without an extra click.
void nextTick().then(() => {
if (!open.value) return
nameInput.value?.focus()
nameInput.value?.select()
})
} 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),
})
// Transparent toggle: stores 'transparent' sentinel when on, falls back
// to the most recent colour the user picked when turned off. The fallback
// sticks in a local ref so flipping the toggle doesn't lose their custom
// colour each time.
const lastNonTransparent = ref<string>(
store.scene.canvas.background && store.scene.canvas.background !== 'transparent'
? store.scene.canvas.background
: '#ffffff',
)
const bgTransparent = computed({
get: () => store.scene.canvas.background === 'transparent',
set: (on: boolean) => {
if (on) {
if (store.scene.canvas.background !== 'transparent') {
lastNonTransparent.value = store.scene.canvas.background || '#ffffff'
}
store.setCanvasBackground('transparent')
} else {
store.setCanvasBackground(lastNonTransparent.value || '#ffffff')
}
},
})
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
ref="nameInput"
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" :class="{ transparent: bgTransparent }">
<input
type="color"
class="color-swatch"
:value="bgTransparent ? lastNonTransparent : background"
:disabled="bgTransparent"
@input="background = ($event.target as HTMLInputElement).value"
/>
<span class="hex">
{{ bgTransparent ? t('settings.backgroundTransparent') : background.toUpperCase() }}
</span>
<button
type="button"
class="bg-transparent-btn"
:class="{ active: bgTransparent }"
v-tooltip="t('settings.backgroundTransparentTooltip')"
@click="bgTransparent = !bgTransparent"
>
<PhCheckerboard :size="12" />
</button>
</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:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.bg-transparent-btn {
width: 18px;
height: 18px;
padding: 0;
margin-left: auto;
border: 1px solid var(--border);
background: transparent;
color: var(--text-dim);
border-radius: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-sizing: border-box;
}
.bg-transparent-btn:hover {
color: var(--text);
background: var(--panel-alt);
}
.bg-transparent-btn.active {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-soft);
}
.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>