The Export panel, SDK helpers, app menus, and CLI each kept their own hand-written format lists, so new formats such as PPTX reached some surfaces and not others. Scene Graph now owns the persisted export-setting format ids, Core IO adapters carry literal ids so that list is checked against real adapters, and the panel labels, scale handling, app format types, and CLI format validation/help are derived from the registry. PPTX joins the Export panel as a result.
202 lines
6.2 KiB
Vue
202 lines
6.2 KiB
Vue
<script setup lang="ts">
|
|
import { tryOnScopeDispose, useObjectUrl } from '@vueuse/core'
|
|
import { computed, onActivated, onDeactivated, ref, shallowRef, watch } from 'vue'
|
|
|
|
import { useExport, useI18n, useRetainedActivity } from '@open-pencil/vue'
|
|
|
|
import { useEditorStore } from '@/app/editor/active-store'
|
|
import ExportScaleInput from '@/components/properties/ExportScaleInput.vue'
|
|
import AppButton from '@/components/ui/button/AppButton.vue'
|
|
import IconButton from '@/components/ui/button/IconButton.vue'
|
|
import Tip from '@/components/ui/overlay/Tip.vue'
|
|
import PanelItemRow from '@/components/ui/panel/PanelItemRow.vue'
|
|
import PanelSection from '@/components/ui/panel/PanelSection.vue'
|
|
import AppSelect from '@/components/ui/select/AppSelect.vue'
|
|
import { CHECKERBOARD_BACKGROUND } from '@/theme/paint/checkerboard'
|
|
|
|
const editorStore = useEditorStore()
|
|
const { panels } = useI18n()
|
|
const {
|
|
activeTarget,
|
|
activeName,
|
|
activeSettings,
|
|
targetIds,
|
|
mixed,
|
|
addSetting,
|
|
removeSetting,
|
|
updateScale,
|
|
updateFormat,
|
|
formatSupportsScale,
|
|
formatOptions,
|
|
scales,
|
|
clampExportScale
|
|
} = useExport()
|
|
|
|
const previewBlob = shallowRef<Blob | null>(null)
|
|
const previewURL = useObjectUrl(previewBlob)
|
|
const showPreview = ref(false)
|
|
const exporting = ref(false)
|
|
const retainedActivity = useRetainedActivity()
|
|
let previewVersion = 0
|
|
|
|
function clearPreview() {
|
|
previewVersion++
|
|
previewBlob.value = null
|
|
}
|
|
|
|
onDeactivated(clearPreview)
|
|
tryOnScopeDispose(clearPreview)
|
|
onActivated(() => {
|
|
void updatePreview()
|
|
})
|
|
|
|
const PREVIEW_WIDTH = 480
|
|
|
|
async function doExport() {
|
|
exporting.value = true
|
|
try {
|
|
const requests = []
|
|
// Export exactly the rows shown in the panel (activeSettings) for every target,
|
|
// so a multi-selection exports what the user sees rather than each node's own
|
|
// (possibly hidden / divergent) settings.
|
|
for (const id of targetIds.value) {
|
|
const node = editorStore.graph.getNode(id)
|
|
if (!node) continue
|
|
const target =
|
|
activeTarget.value === 'page'
|
|
? ({ scope: 'page', pageId: id } as const)
|
|
: ({ scope: 'node', nodeId: id } as const)
|
|
for (const setting of activeSettings.value) {
|
|
requests.push({ target, formatId: setting.format, options: { scale: setting.scale } })
|
|
}
|
|
}
|
|
// A single file downloads directly; multiple files bundle into one zip.
|
|
await editorStore.exportTargets(requests)
|
|
} finally {
|
|
exporting.value = false
|
|
}
|
|
}
|
|
|
|
async function updatePreview() {
|
|
const version = ++previewVersion
|
|
if (retainedActivity?.value === false || !showPreview.value) {
|
|
previewBlob.value = null
|
|
return
|
|
}
|
|
|
|
const ids =
|
|
activeTarget.value === 'selection'
|
|
? [...editorStore.state.selectedIds]
|
|
: editorStore.graph.getChildren(editorStore.state.currentPageId).map((n) => n.id)
|
|
|
|
if (ids.length === 0) {
|
|
previewBlob.value = null
|
|
return
|
|
}
|
|
|
|
let maxW = 0
|
|
for (const id of ids) {
|
|
const node = editorStore.getNode(id)
|
|
if (node) maxW = Math.max(maxW, node.width)
|
|
}
|
|
const scale = maxW > 0 ? Math.min(PREVIEW_WIDTH / maxW, 2) : 1
|
|
const data = await editorStore.renderExportImage(ids, scale, 'PNG')
|
|
if (version !== previewVersion) return
|
|
previewBlob.value = data ? new Blob([data], { type: 'image/png' }) : null
|
|
}
|
|
|
|
const previewKey = computed(
|
|
() =>
|
|
`${activeTarget.value}:${editorStore.state.sceneVersion}:${editorStore.state.currentPageId}:${[
|
|
...editorStore.state.selectedIds
|
|
]
|
|
.sort()
|
|
.join(',')}`
|
|
)
|
|
|
|
watch(() => showPreview.value, updatePreview, { flush: 'post' })
|
|
watch(previewKey, updatePreview, { flush: 'post' })
|
|
</script>
|
|
|
|
<template>
|
|
<PanelSection :label="panels.export" :empty="activeSettings.length === 0">
|
|
<template #actions>
|
|
<IconButton :label="panels.addExport" @click="addSetting">
|
|
<icon-lucide-plus class="size-3.5" />
|
|
</IconButton>
|
|
</template>
|
|
<p v-if="mixed" class="text-[11px] text-muted">
|
|
{{ panels.mixed }}
|
|
</p>
|
|
|
|
<PanelItemRow
|
|
v-for="(setting, index) in activeSettings"
|
|
:key="`${targetIds.join(',')}:${index}`"
|
|
data-property="exportSettings"
|
|
:data-index="index"
|
|
>
|
|
<div v-if="formatSupportsScale(setting.format)" class="w-24 shrink-0">
|
|
<ExportScaleInput
|
|
:model-value="setting.scale"
|
|
:presets="scales"
|
|
:clamp="clampExportScale"
|
|
:label="panels.exportScale"
|
|
data-property="export-scale"
|
|
@update:model-value="updateScale(index, $event)"
|
|
/>
|
|
</div>
|
|
<AppSelect
|
|
:model-value="setting.format"
|
|
:options="formatOptions"
|
|
:label="panels.exportFormat"
|
|
:ui="{ trigger: 'w-auto flex-1' }"
|
|
data-property="export-format"
|
|
@update:model-value="updateFormat(index, $event)"
|
|
/>
|
|
<template #rail="{ removeClass }">
|
|
<IconButton
|
|
:label="panels.removeExport"
|
|
:class="[removeClass, 'shrink-0']"
|
|
@click="removeSetting(index)"
|
|
>
|
|
<icon-lucide-minus class="size-3.5" />
|
|
</IconButton>
|
|
</template>
|
|
</PanelItemRow>
|
|
|
|
<AppButton
|
|
v-if="activeSettings.length > 0"
|
|
color="primary"
|
|
variant="solid"
|
|
data-test-id="export-button"
|
|
class="mt-1.5 w-full"
|
|
:disabled="exporting"
|
|
@click="doExport"
|
|
>
|
|
<span class="truncate">{{ panels.export }} {{ activeName }}</span>
|
|
</AppButton>
|
|
|
|
<Tip v-if="activeSettings.length > 0" :label="panels.toggleExportPreview">
|
|
<button
|
|
data-test-id="export-preview-toggle"
|
|
class="mt-1 flex w-full cursor-pointer items-center gap-1 rounded border-none bg-transparent px-0 py-1 text-[11px] text-muted hover:text-surface"
|
|
@click="showPreview = !showPreview"
|
|
>
|
|
<icon-lucide-chevron-down v-if="showPreview" class="size-3" />
|
|
<icon-lucide-chevron-right v-else class="size-3" />
|
|
{{ panels.exportPreview }}
|
|
</button>
|
|
</Tip>
|
|
|
|
<div v-if="showPreview && previewURL" class="mt-1 overflow-hidden rounded border border-border">
|
|
<img :src="previewURL" :class="['block w-full', CHECKERBOARD_BACKGROUND]" />
|
|
</div>
|
|
<div
|
|
v-else-if="showPreview"
|
|
class="mt-1 rounded border border-border px-3 py-2 text-[11px] text-muted"
|
|
>
|
|
{{ panels.exportRenderingPreview }}
|
|
</div>
|
|
</PanelSection>
|
|
</template>
|