Files
RUWIO/src/components/ExportDialog.vue
T
thandClaude Opus 4.7 a2c8e5998a 0.8.2: pan outside canvas + finish RUWIO rename of exports
- Canvas stage: plain left-click in the empty content area (outside
  the canvas rectangle) now starts a pan. Ctrl+left-click still pans
  anywhere, middle-click unchanged. Only pure left-click qualifies
  (any modifier leaves the old routing intact).
- Export rebrand Animatr → RUWIO for everything user-visible:
  - Default filenames: projekt.animatr.json → projekt.ruwio.json;
    animatr-runtime.js → ruwio-runtime.js; App's export fallback
    base animatr → ruwio.
  - Generated comment headers in runtime/scene/bundled scripts.
  - Console warn prefix in the runtime and the scene store.
  - Example HTML title, heading, and embed-usage snippet.
  - parseProject error message.
  - ExportDialog copy.
- RUNTIME_VERSION 14 → 15 so cached runtimes reload.
- On-disk magic stays `animatr-project` and window.AnimatrPlayer /
  __animatrPending / localStorage & IndexedDB keys are untouched so
  old project files and previously-exported HTML keep working.

Regression tests: legacy magic still parses; parseProject error
mentions RUWIO and not Animatr; runtime/scene/bundled scripts and
example HTML all carry the new brand; console prefix is [ruwio].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:17:10 +02:00

407 lines
9.2 KiB
Vue

<script setup lang="ts">
import { computed, ref } from 'vue'
import {
PhCode,
PhDownloadSimple,
PhEngine,
PhFileHtml,
PhFilmSlate,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadFromServer } from '@/lib/serverExport'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
interface ProgressState {
progress: number
controller: AbortController
label: string
error: string | null
}
const progress = ref<ProgressState | null>(null)
const videoSupported = pickMimeType() !== null
function sceneClone() {
return JSON.parse(JSON.stringify(store.scene))
}
function exportScene() {
downloadSceneExport(sceneClone())
emit('update:modelValue', false)
}
function exportRuntime() {
downloadRuntime()
emit('update:modelValue', false)
}
function exportHtml() {
downloadExampleHtml(sceneClone())
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(sceneClone(), {
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 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(sceneClone(), {
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">
<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>
<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;
}
.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>