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