- New .ruw file format: a STORE-only zip wrapping project.json + an assets/ folder. packProject extracts every data-URL asset (images, audio, background video) into its own file and rewrites the in-JSON src fields to relative paths; unpackProject rehydrates back to data URLs on read. Zero dependencies — we ship a minimal in-house zip codec. - ExportDialog surfaces the new format as a top-row "RUWIO project (.ruw)" option that round-trips through the welcome-dialog file picker. Legacy .json imports still work via byte-sniffing the PK header in readAnyProjectFile. Dead App.vue `exportProjectFile` helper removed. - Frame-based exports (GIF, APNG, APNG preview) take an optional targetFps override so a 60fps project doesn't balloon the file — APNG / GIF size scales ~linearly with frame count. The ExportDialog grows a second input next to target size; defaults inherit the scene's fps and reset on each dialog open. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
948 lines
24 KiB
Vue
948 lines
24 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import {
|
||
PhCode,
|
||
PhDownloadSimple,
|
||
PhEngine,
|
||
PhFileArchive,
|
||
PhFileHtml,
|
||
PhFileImage,
|
||
PhFilmSlate,
|
||
PhGif,
|
||
} from '@phosphor-icons/vue'
|
||
import { useSceneStore } from '@/stores/scene'
|
||
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
|
||
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
|
||
import { downloadSceneAsGif } from '@/lib/gifExport'
|
||
import { downloadSceneAsApng, downloadSceneAsApngPreview } from '@/lib/apngExport'
|
||
import { downloadFromServer } from '@/lib/serverExport'
|
||
import { downloadRuwFile } from '@/lib/ruwFile'
|
||
import { useProjectName } from '@/composables/useProjectName'
|
||
import type { ButtonAsset, Layer } from '@/types/scene'
|
||
import { Modal } from '@/components/ui'
|
||
import { t } from '@/i18n'
|
||
|
||
const props = defineProps<{ modelValue: boolean }>()
|
||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||
|
||
const store = useSceneStore()
|
||
const { projectName } = useProjectName()
|
||
|
||
interface ProgressState {
|
||
progress: number
|
||
controller: AbortController
|
||
label: string
|
||
error: string | null
|
||
}
|
||
const progress = ref<ProgressState | null>(null)
|
||
|
||
const videoSupported = pickMimeType() !== null
|
||
|
||
// Opt-in: strip layers that are hidden in the editor (`editorHidden`)
|
||
// from the exported scene. Default off — editor-hide is a workflow
|
||
// aid and shouldn't silently drop content on export unless the user
|
||
// explicitly asks for it. Applies to every format below.
|
||
const skipHidden = ref(false)
|
||
|
||
// Target output size for frame-based formats (GIF, APNG, APNG
|
||
// preview). The user enters a width; the height is derived from the
|
||
// scene's aspect ratio so the export never squashes the content.
|
||
const targetWidth = ref(Math.max(1, Math.round(store.scene.canvas.width)))
|
||
|
||
const targetHeight = computed(() => {
|
||
const sw = store.scene.canvas.width
|
||
const sh = store.scene.canvas.height
|
||
if (sw <= 0 || sh <= 0) return targetWidth.value
|
||
return Math.max(1, Math.round((targetWidth.value * sh) / sw))
|
||
})
|
||
|
||
const targetSizeChanged = computed(
|
||
() =>
|
||
targetWidth.value !== Math.round(store.scene.canvas.width) ||
|
||
targetHeight.value !== Math.round(store.scene.canvas.height),
|
||
)
|
||
|
||
function resetTargetSize() {
|
||
targetWidth.value = Math.max(1, Math.round(store.scene.canvas.width))
|
||
}
|
||
|
||
// Target capture frame rate. Scene default is often 60 for smooth
|
||
// playback in-editor, but 60fps APNG / GIF bloats the file for no
|
||
// perceptual gain — let the user dial it down per export without
|
||
// touching the project's own fps.
|
||
const targetFps = ref(Math.max(1, Math.round(store.scene.fps || 30)))
|
||
|
||
const targetFpsChanged = computed(
|
||
() => targetFps.value !== Math.max(1, Math.round(store.scene.fps || 30)),
|
||
)
|
||
|
||
function resetTargetFps() {
|
||
targetFps.value = Math.max(1, Math.round(store.scene.fps || 30))
|
||
}
|
||
|
||
// Every time the dialog re-opens, inherit the CURRENT document
|
||
// canvas size + fps. Without this, a user who resized the stage or
|
||
// switched project fps after first opening the dialog would keep
|
||
// seeing the stale values.
|
||
watch(
|
||
() => props.modelValue,
|
||
(open) => {
|
||
if (open) {
|
||
resetTargetSize()
|
||
resetTargetFps()
|
||
}
|
||
},
|
||
)
|
||
|
||
/** Shared render options for GIF / APNG / APNG-preview. Only sends
|
||
* the overrides that actually differ from the scene so same-size
|
||
* / same-fps exports keep the old no-override code paths. */
|
||
function frameExportOpts(controller: AbortController, onProg: (p: number) => void) {
|
||
const base: {
|
||
signal: AbortSignal
|
||
onProgress: (p: number) => void
|
||
targetWidth?: number
|
||
targetHeight?: number
|
||
targetFps?: number
|
||
} = { signal: controller.signal, onProgress: onProg }
|
||
if (targetSizeChanged.value) {
|
||
base.targetWidth = targetWidth.value
|
||
base.targetHeight = targetHeight.value
|
||
}
|
||
if (targetFpsChanged.value) {
|
||
base.targetFps = targetFps.value
|
||
}
|
||
return base
|
||
}
|
||
|
||
const hasHiddenLayers = computed(() => {
|
||
if (store.scene.layers.some((l) => l.editorHidden)) return true
|
||
if (store.scene.clipTracks?.some((t) => t.editorHidden)) return true
|
||
for (const clip of store.scene.clips ?? []) {
|
||
if (clip.layers.some((l) => l.editorHidden)) return true
|
||
}
|
||
for (const asset of store.scene.assets) {
|
||
if (asset.kind === 'button' && asset.layers.some((l) => l.editorHidden)) return true
|
||
}
|
||
return false
|
||
})
|
||
|
||
/** Strip every instance that belongs to an editor-hidden clip track,
|
||
* then drop the track itself. Shared between project + single-scene
|
||
* clones so both payload shapes stay in sync. */
|
||
function stripHiddenClipTracks(page: {
|
||
clipTracks?: { id: string; editorHidden?: boolean }[]
|
||
clipInstances?: { clipTrackId: string }[]
|
||
}) {
|
||
if (!page.clipTracks?.length) return
|
||
const hiddenIds = new Set(
|
||
page.clipTracks.filter((t) => t.editorHidden).map((t) => t.id),
|
||
)
|
||
if (!hiddenIds.size) return
|
||
if (page.clipInstances) {
|
||
page.clipInstances = page.clipInstances.filter(
|
||
(ci) => !hiddenIds.has(ci.clipTrackId),
|
||
)
|
||
}
|
||
page.clipTracks = page.clipTracks.filter((t) => !hiddenIds.has(t.id))
|
||
}
|
||
|
||
function projectClone() {
|
||
// Project-shaped payload for standalone static exports — the
|
||
// embedded runtime handles multi-scene playback natively.
|
||
const project = JSON.parse(JSON.stringify(store.project))
|
||
if (!skipHidden.value) return project
|
||
for (const page of project.scenes) {
|
||
page.layers = page.layers.filter((l: Layer) => !l.editorHidden)
|
||
stripHiddenClipTracks(page)
|
||
}
|
||
if (Array.isArray(project.clips)) {
|
||
for (const clip of project.clips) {
|
||
clip.layers = clip.layers.filter((l: Layer) => !l.editorHidden)
|
||
}
|
||
}
|
||
if (Array.isArray(project.assets)) {
|
||
for (const asset of project.assets as ButtonAsset[]) {
|
||
if (asset && asset.kind === 'button' && Array.isArray(asset.layers)) {
|
||
asset.layers = asset.layers.filter((l: Layer) => !l.editorHidden)
|
||
}
|
||
}
|
||
}
|
||
return project
|
||
}
|
||
|
||
function activeSceneClone() {
|
||
// Scene-shaped payload for linear render paths (webm/mp4 server).
|
||
// Video is a single timeline — we only render the currently active
|
||
// page. A user who wants a different scene rendered needs to switch
|
||
// it in the drawer first.
|
||
const scene = JSON.parse(JSON.stringify(store.scene))
|
||
if (!skipHidden.value) return scene
|
||
scene.layers = scene.layers.filter((l: Layer) => !l.editorHidden)
|
||
stripHiddenClipTracks(scene)
|
||
if (Array.isArray(scene.clips)) {
|
||
for (const clip of scene.clips) {
|
||
clip.layers = clip.layers.filter((l: Layer) => !l.editorHidden)
|
||
}
|
||
}
|
||
if (Array.isArray(scene.assets)) {
|
||
for (const asset of scene.assets as ButtonAsset[]) {
|
||
if (asset && asset.kind === 'button' && Array.isArray(asset.layers)) {
|
||
asset.layers = asset.layers.filter((l: Layer) => !l.editorHidden)
|
||
}
|
||
}
|
||
}
|
||
return scene
|
||
}
|
||
|
||
function exportScene() {
|
||
downloadSceneExport(projectClone())
|
||
emit('update:modelValue', false)
|
||
}
|
||
|
||
function exportRuntime() {
|
||
downloadRuntime()
|
||
emit('update:modelValue', false)
|
||
}
|
||
|
||
function exportRuwBundle() {
|
||
// Re-import target: .ruw is the round-trippable format the welcome
|
||
// dialog + file-picker accept. Pull the project via projectForSave
|
||
// so the editor-session UI state (zoom, toggles, …) rides along.
|
||
const project = JSON.parse(JSON.stringify(store.projectForSave))
|
||
const base = (projectName.value ?? 'ruwio').toString()
|
||
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'ruwio'
|
||
downloadRuwFile(project, `${safe}.ruw`)
|
||
emit('update:modelValue', false)
|
||
}
|
||
|
||
function exportHtml() {
|
||
downloadExampleHtml(projectClone())
|
||
emit('update:modelValue', false)
|
||
}
|
||
|
||
async function exportWebm() {
|
||
if (progress.value) return
|
||
const controller = new AbortController()
|
||
progress.value = {
|
||
progress: 0,
|
||
controller,
|
||
label: t('export.labelWebm'),
|
||
error: null,
|
||
}
|
||
store.pause()
|
||
try {
|
||
await downloadSceneAsVideo(activeSceneClone(), {
|
||
signal: controller.signal,
|
||
onProgress: (p) => {
|
||
if (progress.value) progress.value.progress = p
|
||
},
|
||
})
|
||
progress.value = null
|
||
emit('update:modelValue', false)
|
||
} catch (err) {
|
||
if ((err as Error).name === 'AbortError') {
|
||
progress.value = null
|
||
return
|
||
}
|
||
if (progress.value) progress.value.error = (err as Error).message || String(err)
|
||
}
|
||
}
|
||
|
||
async function exportGif() {
|
||
if (progress.value) return
|
||
const controller = new AbortController()
|
||
progress.value = {
|
||
progress: 0,
|
||
controller,
|
||
label: t('export.labelGif'),
|
||
error: null,
|
||
}
|
||
store.pause()
|
||
try {
|
||
await downloadSceneAsGif(
|
||
activeSceneClone(),
|
||
frameExportOpts(controller, (p) => {
|
||
if (progress.value) progress.value.progress = p
|
||
}),
|
||
)
|
||
progress.value = null
|
||
emit('update:modelValue', false)
|
||
} catch (err) {
|
||
if ((err as Error).name === 'AbortError') {
|
||
progress.value = null
|
||
return
|
||
}
|
||
if (progress.value) progress.value.error = (err as Error).message || String(err)
|
||
}
|
||
}
|
||
|
||
async function exportApng() {
|
||
if (progress.value) return
|
||
const controller = new AbortController()
|
||
progress.value = {
|
||
progress: 0,
|
||
controller,
|
||
label: t('export.labelApng'),
|
||
error: null,
|
||
}
|
||
store.pause()
|
||
try {
|
||
await downloadSceneAsApng(
|
||
activeSceneClone(),
|
||
frameExportOpts(controller, (p) => {
|
||
if (progress.value) progress.value.progress = p
|
||
}),
|
||
)
|
||
progress.value = null
|
||
emit('update:modelValue', false)
|
||
} catch (err) {
|
||
if ((err as Error).name === 'AbortError') {
|
||
progress.value = null
|
||
return
|
||
}
|
||
if (progress.value) progress.value.error = (err as Error).message || String(err)
|
||
}
|
||
}
|
||
|
||
async function exportApngPreview() {
|
||
if (progress.value) return
|
||
const controller = new AbortController()
|
||
progress.value = {
|
||
progress: 0,
|
||
controller,
|
||
label: t('export.labelApngPreview'),
|
||
error: null,
|
||
}
|
||
store.pause()
|
||
try {
|
||
await downloadSceneAsApngPreview(
|
||
activeSceneClone(),
|
||
frameExportOpts(controller, (p) => {
|
||
if (progress.value) progress.value.progress = p
|
||
}),
|
||
)
|
||
progress.value = null
|
||
emit('update:modelValue', false)
|
||
} catch (err) {
|
||
if ((err as Error).name === 'AbortError') {
|
||
progress.value = null
|
||
return
|
||
}
|
||
if (progress.value) progress.value.error = (err as Error).message || String(err)
|
||
}
|
||
}
|
||
|
||
async function exportMp4Server() {
|
||
if (progress.value) return
|
||
const controller = new AbortController()
|
||
progress.value = {
|
||
progress: 0,
|
||
controller,
|
||
label: t('export.labelMp4'),
|
||
error: null,
|
||
}
|
||
store.pause()
|
||
try {
|
||
await downloadFromServer(activeSceneClone(), {
|
||
signal: controller.signal,
|
||
onProgress: (p) => {
|
||
if (progress.value) progress.value.progress = p
|
||
},
|
||
})
|
||
progress.value = null
|
||
emit('update:modelValue', false)
|
||
} catch (err) {
|
||
if ((err as Error).name === 'AbortError') {
|
||
progress.value = null
|
||
return
|
||
}
|
||
if (progress.value) progress.value.error = (err as Error).message || String(err)
|
||
}
|
||
}
|
||
|
||
function cancel() {
|
||
progress.value?.controller.abort()
|
||
}
|
||
|
||
function dismissError() {
|
||
progress.value = null
|
||
}
|
||
|
||
const isLocked = computed(() => !!progress.value && !progress.value.error)
|
||
|
||
function onUpdate(v: boolean) {
|
||
if (!v && isLocked.value) return
|
||
emit('update:modelValue', v)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<Modal
|
||
:model-value="modelValue"
|
||
:title="t('export.title')"
|
||
size="md"
|
||
:dismissable="!isLocked"
|
||
:close-on-backdrop="!isLocked"
|
||
:close-on-esc="!isLocked"
|
||
@update:model-value="onUpdate"
|
||
>
|
||
<div v-if="!progress" class="options">
|
||
<label
|
||
class="export-toggle"
|
||
:class="{ disabled: !hasHiddenLayers }"
|
||
v-tooltip="hasHiddenLayers ? undefined : t('export.skipHiddenNone')"
|
||
>
|
||
<span class="switch" :class="{ on: skipHidden && hasHiddenLayers }">
|
||
<input
|
||
type="checkbox"
|
||
v-model="skipHidden"
|
||
:disabled="!hasHiddenLayers"
|
||
/>
|
||
</span>
|
||
<div class="export-toggle-text">
|
||
<div class="export-toggle-label">{{ t('export.skipHidden') }}</div>
|
||
<div class="export-toggle-desc">{{ t('export.skipHiddenDesc') }}</div>
|
||
</div>
|
||
</label>
|
||
|
||
<button class="opt" @click="exportRuwBundle">
|
||
<div class="opt-icon"><PhFileArchive :size="20" /></div>
|
||
<div class="opt-text">
|
||
<div class="opt-title">
|
||
{{ t('export.ruwTitle') }}
|
||
<span class="badge accent">{{ t('export.ruwBadge') }}</span>
|
||
</div>
|
||
<div class="opt-desc">{{ t('export.ruwDesc') }}</div>
|
||
</div>
|
||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||
</button>
|
||
|
||
<button class="opt" @click="exportRuntime">
|
||
<div class="opt-icon"><PhEngine :size="20" /></div>
|
||
<div class="opt-text">
|
||
<div class="opt-title">
|
||
ruwio-runtime.js
|
||
<span class="badge">{{ t('export.runtimeBadge') }}</span>
|
||
</div>
|
||
<div class="opt-desc">{{ t('export.runtimeDesc') }}</div>
|
||
</div>
|
||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||
</button>
|
||
|
||
<button class="opt" @click="exportScene">
|
||
<div class="opt-icon"><PhCode :size="20" /></div>
|
||
<div class="opt-text">
|
||
<div class="opt-title">
|
||
animation.js
|
||
<span class="badge">{{ t('export.sceneBadge') }}</span>
|
||
</div>
|
||
<div class="opt-desc">{{ t('export.sceneDesc') }}</div>
|
||
</div>
|
||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||
</button>
|
||
|
||
<button class="opt" @click="exportHtml">
|
||
<div class="opt-icon"><PhFileHtml :size="20" /></div>
|
||
<div class="opt-text">
|
||
<div class="opt-title">{{ t('export.exampleTitle') }}</div>
|
||
<div class="opt-desc">{{ t('export.exampleDesc') }}</div>
|
||
</div>
|
||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||
</button>
|
||
|
||
<button class="opt" @click="exportMp4Server">
|
||
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
|
||
<div class="opt-text">
|
||
<div class="opt-title">
|
||
MP4 Video
|
||
<span class="badge accent">{{ t('export.mp4Badge') }}</span>
|
||
</div>
|
||
<div class="opt-desc">{{ t('export.mp4Desc') }}</div>
|
||
</div>
|
||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||
</button>
|
||
|
||
<button class="opt" :disabled="!videoSupported" @click="exportWebm">
|
||
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
|
||
<div class="opt-text">
|
||
<div class="opt-title">
|
||
WebM Video
|
||
<span class="badge">{{ t('export.webmBadge') }}</span>
|
||
</div>
|
||
<div class="opt-desc">
|
||
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }}
|
||
</div>
|
||
</div>
|
||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||
</button>
|
||
|
||
<div class="target-size">
|
||
<div class="target-size-label">{{ t('export.targetSize') }}</div>
|
||
<label class="target-size-input">
|
||
<span class="target-size-tag">W</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
step="1"
|
||
:value="targetWidth"
|
||
@input="targetWidth = Math.max(1, Math.round(Number(($event.target as HTMLInputElement).value) || 1))"
|
||
/>
|
||
</label>
|
||
<span class="target-size-times">×</span>
|
||
<div class="target-size-derived">
|
||
{{ targetHeight }}
|
||
<span class="target-size-tag">H</span>
|
||
</div>
|
||
<button
|
||
v-if="targetSizeChanged"
|
||
class="target-size-reset"
|
||
v-tooltip="t('export.targetSizeReset')"
|
||
@click="resetTargetSize"
|
||
>
|
||
{{ t('export.targetSizeResetLabel') }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="target-size">
|
||
<div class="target-size-label">{{ t('export.targetFps') }}</div>
|
||
<label class="target-size-input">
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
step="1"
|
||
:value="targetFps"
|
||
@input="targetFps = Math.max(1, Math.round(Number(($event.target as HTMLInputElement).value) || 1))"
|
||
/>
|
||
<span class="target-size-tag">fps</span>
|
||
</label>
|
||
<span class="target-size-hint">{{ t('export.targetFpsHint') }}</span>
|
||
<button
|
||
v-if="targetFpsChanged"
|
||
class="target-size-reset"
|
||
v-tooltip="t('export.targetFpsReset')"
|
||
@click="resetTargetFps"
|
||
>
|
||
{{ t('export.targetSizeResetLabel') }}
|
||
</button>
|
||
</div>
|
||
|
||
<button class="opt" @click="exportGif">
|
||
<div class="opt-icon"><PhGif :size="20" /></div>
|
||
<div class="opt-text">
|
||
<div class="opt-title">
|
||
Animated GIF
|
||
<span class="badge">{{ t('export.gifBadge') }}</span>
|
||
</div>
|
||
<div class="opt-desc">{{ t('export.gifDesc') }}</div>
|
||
</div>
|
||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||
</button>
|
||
|
||
<button class="opt" @click="exportApng">
|
||
<div class="opt-icon"><PhFileImage :size="20" /></div>
|
||
<div class="opt-text">
|
||
<div class="opt-title">
|
||
Animated PNG
|
||
<span class="badge">{{ t('export.apngBadge') }}</span>
|
||
</div>
|
||
<div class="opt-desc">{{ t('export.apngDesc') }}</div>
|
||
</div>
|
||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||
</button>
|
||
|
||
<button class="opt" @click="exportApngPreview">
|
||
<div class="opt-icon"><PhFileHtml :size="20" /></div>
|
||
<div class="opt-text">
|
||
<div class="opt-title">
|
||
{{ t('export.apngPreviewTitle') }}
|
||
<span class="badge">{{ t('export.apngPreviewBadge') }}</span>
|
||
</div>
|
||
<div class="opt-desc">{{ t('export.apngPreviewDesc') }}</div>
|
||
</div>
|
||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||
</button>
|
||
</div>
|
||
|
||
<div v-else-if="!progress.error" class="rendering">
|
||
<div class="r-label">
|
||
<PhFilmSlate :size="14" />
|
||
<span class="r-text">{{ progress.label }}</span>
|
||
<span class="r-percent">{{ Math.round(progress.progress * 100) }}%</span>
|
||
</div>
|
||
<div class="r-bar">
|
||
<div class="r-bar-fill" :style="{ width: progress.progress * 100 + '%' }"></div>
|
||
</div>
|
||
<div class="r-actions">
|
||
<button class="secondary" @click="cancel">{{ t('export.cancel') }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="error-view">
|
||
<div class="error-title">{{ t('export.failed') }}</div>
|
||
<div class="error-msg">{{ progress.error }}</div>
|
||
<div class="error-actions">
|
||
<button class="primary" @click="dismissError">{{ t('export.back') }}</button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.options {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
padding: 12px;
|
||
}
|
||
|
||
.export-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 12px;
|
||
margin-bottom: 6px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: var(--panel-alt);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
}
|
||
|
||
.export-toggle.disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.export-toggle-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.export-toggle-label {
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: var(--text);
|
||
}
|
||
|
||
.export-toggle-desc {
|
||
font-size: 11px;
|
||
color: var(--text-dim);
|
||
line-height: 1.35;
|
||
}
|
||
|
||
/* iOS-style switch. The <input> is a transparent overlay that still
|
||
handles keyboard focus and click targeting; the visual is drawn by
|
||
the span + ::after pill. */
|
||
.switch {
|
||
position: relative;
|
||
width: 32px;
|
||
height: 18px;
|
||
flex-shrink: 0;
|
||
background: var(--border);
|
||
border-radius: 999px;
|
||
transition: background 140ms var(--ease);
|
||
}
|
||
|
||
.switch.on {
|
||
background: var(--accent);
|
||
}
|
||
|
||
.switch input {
|
||
position: absolute;
|
||
inset: 0;
|
||
margin: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
opacity: 0;
|
||
cursor: inherit;
|
||
}
|
||
|
||
.switch::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
width: 14px;
|
||
height: 14px;
|
||
background: #fff;
|
||
border-radius: 50%;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||
transition: transform 140ms var(--ease);
|
||
}
|
||
|
||
.switch.on::after {
|
||
transform: translateX(14px);
|
||
}
|
||
|
||
.target-size {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
background: var(--panel-alt);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
color: var(--text);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.target-size-label {
|
||
flex-shrink: 0;
|
||
font-weight: 500;
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
.target-size-input,
|
||
.target-size-derived {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 6px;
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
min-width: 82px;
|
||
}
|
||
|
||
.target-size-input input {
|
||
background: transparent;
|
||
border: 0;
|
||
color: var(--text);
|
||
font: inherit;
|
||
font-variant-numeric: tabular-nums;
|
||
width: 56px;
|
||
text-align: right;
|
||
padding: 0;
|
||
outline: none;
|
||
}
|
||
|
||
.target-size-input input:focus {
|
||
outline: none;
|
||
}
|
||
|
||
.target-size-tag {
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.03em;
|
||
}
|
||
|
||
.target-size-times {
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
.target-size-derived {
|
||
color: var(--text-dim);
|
||
font-variant-numeric: tabular-nums;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.target-size-hint {
|
||
margin-left: 4px;
|
||
color: var(--text-dim);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.target-size-reset {
|
||
margin-left: auto;
|
||
padding: 4px 8px;
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
color: var(--text-dim);
|
||
font-size: 11px;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.target-size-reset:hover {
|
||
border-color: var(--border-strong);
|
||
color: var(--text);
|
||
}
|
||
|
||
.opt {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
background: var(--panel-alt);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
color: var(--text);
|
||
text-align: left;
|
||
transition: background 140ms var(--ease), border-color 140ms var(--ease),
|
||
transform 140ms var(--ease);
|
||
}
|
||
|
||
.opt:hover:not(:disabled) {
|
||
background: var(--panel-hover);
|
||
border-color: var(--border-strong);
|
||
}
|
||
|
||
.opt:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.opt-icon {
|
||
width: 40px;
|
||
height: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--bg);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--accent);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.opt-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
|
||
.opt-title {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.opt-desc {
|
||
font-size: 11px;
|
||
color: var(--text-dim);
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.opt-arrow {
|
||
color: var(--text-dim);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.opt:hover:not(:disabled) .opt-arrow {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.badge {
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
padding: 2px 6px;
|
||
background: var(--bg);
|
||
color: var(--text-dim);
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.badge.accent {
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.rendering {
|
||
padding: 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.r-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: var(--text-dim-strong);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.r-text {
|
||
flex: 1;
|
||
}
|
||
|
||
.r-percent {
|
||
color: var(--accent);
|
||
font-variant-numeric: tabular-nums;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.r-bar {
|
||
height: 6px;
|
||
background: var(--border);
|
||
border-radius: 999px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.r-bar-fill {
|
||
height: 100%;
|
||
background: var(--accent);
|
||
transition: width 140ms linear;
|
||
}
|
||
|
||
.r-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
padding-top: 4px;
|
||
}
|
||
|
||
.r-actions button {
|
||
padding: 6px 14px;
|
||
border: 1px solid var(--border);
|
||
background: var(--panel-alt);
|
||
color: var(--text);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.r-actions button:hover:not(:disabled) {
|
||
background: var(--panel-hover);
|
||
}
|
||
|
||
.error-view {
|
||
padding: 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.error-title {
|
||
color: var(--danger);
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.error-msg {
|
||
font-size: 12px;
|
||
color: var(--text-dim-strong);
|
||
background: var(--danger-soft);
|
||
padding: 8px 10px;
|
||
border-radius: var(--radius-sm);
|
||
word-break: break-word;
|
||
user-select: text;
|
||
-webkit-user-select: text;
|
||
}
|
||
|
||
.error-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.error-actions .primary {
|
||
padding: 6px 14px;
|
||
background: var(--accent-soft);
|
||
border: 1px solid var(--accent);
|
||
color: var(--accent);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.error-actions .primary:hover:not(:disabled) {
|
||
background: var(--accent-soft-strong);
|
||
}
|
||
</style>
|