jvs-ppt-ui 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1005 +1,1040 @@
1
- <script setup lang="ts">
2
- import { ref, computed, watch, onMounted, nextTick, useSlots, getCurrentInstance } from 'vue'
3
- import jvsPickerColorV3 from 'jvs-picker-color-v3'
4
- import { saveAs } from 'file-saver'
5
- import Header from './Header.vue'
6
- import Toolbar from './Toolbar.vue'
7
- import SidebarLeft from './SidebarLeft.vue'
8
- import CanvasArea from './CanvasArea.vue'
9
- import SidebarRight from './SidebarRight.vue'
10
- import PresentationMode from './PresentationMode.vue'
11
- import ToastNotification from './ToastNotification.vue'
12
- import FormulaDialog from './FormulaDialog.vue'
13
- import DslInputDialog from './DslInputDialog.vue'
14
- import AIPptDialog from './AIPptDialog.vue'
15
- import UnsplashImageDialog from './UnsplashImageDialog.vue'
16
-
17
- // 自动检测并兼容单组件 import { PptEditor } 局部引用的场景(若宿主未调用 app.use(JvsPptUi) 则静默自动装配)
18
- // Element Plus 组件已由构建期按需解析(unplugin-vue-components),无需运行时全局注册
19
- const currentApp = getCurrentInstance()?.appContext?.app
20
- if (currentApp) {
21
- const registeredComponents = currentApp._context?.components || {}
22
- if (!registeredComponents['jvs-picker-color-v3']) {
23
- currentApp.use(jvsPickerColorV3)
24
- }
25
- }
26
-
27
- import { useSlides } from '../composables/useSlides'
28
- import type { Slide, SlideMaster, DeckData, PptEditorProps, PptEditorApi, SaveOptions, LoadSourceOptions, LoadSourceResult } from '../types'
29
-
30
- const props = withDefaults(defineProps<PptEditorProps>(), {
31
- modelValue: null,
32
- readonly: false,
33
- showHeader: true,
34
- showSidebarLeft: true,
35
- showSidebarRight: true,
36
- enableMcp: false,
37
- enableAi: undefined,
38
- aiApiUrl: '',
39
- aiHeaders: () => ({}),
40
- enableMaterial: undefined,
41
- materialApiUrl: '',
42
- materialListApiUrl: '',
43
- materialTreeApiUrl: '',
44
- materialPageApiUrl: '',
45
- materialTypeApiUrl: '',
46
- materialSaveApiUrl: '',
47
- materialHeaders: () => ({}),
48
- config: () => ({})
49
- })
50
-
51
- const emit = defineEmits<{
52
- (e: 'update:modelValue', value: DeckData | Slide[]): void
53
- (e: 'change', payload: { slides: Slide[]; activeIndex: number; title: string }): void
54
- (e: 'save', payload: any): void
55
- (e: 'load', payload: LoadSourceResult): void
56
- (e: 'export', payload: { format: string; title: string; data?: any }): void
57
- (e: 'slideChange', payload: { index: number; slide: Slide | null }): void
58
- (e: 'ready', api: PptEditorApi): void
59
- }>()
60
-
61
- const slidesState = useSlides()
62
- const {
63
- slides,
64
- activeIndex,
65
- activeSlide,
66
- presentationTitle,
67
- slideMasters,
68
- themeClrScheme,
69
- slideRatio,
70
- slideHeight,
71
- logicalWidth,
72
- logicalHeight,
73
- formulaDialogVisible,
74
- dslInputDialogVisible,
75
- aiPptDialogVisible,
76
- importSlides,
77
- exportSlides,
78
- currentEditingFormulaEl,
79
- addElement,
80
- notify,
81
- activeTab,
82
- undo,
83
- redo,
84
- canUndo,
85
- canRedo,
86
- addSlide,
87
- deleteSlide,
88
- selectSlide,
89
- startPresentation,
90
- stopPresentation,
91
- zoomIn,
92
- zoomOut,
93
- resetZoom,
94
- setAiConfig,
95
- isAiBeautifyEnabled,
96
- setMaterialConfig,
97
- isMaterialEnabled,
98
- saveCurrentSlideToMaterial,
99
- isCanvasDragging,
100
- pauseHistory,
101
- setCustomUploadHandler,
102
- getCustomUploadHandler,
103
- uploadMediaResourcesInSlides
104
- } = slidesState
105
-
106
- // 标志位:防止外部 v-model 变化与内部 watch 产生互相覆盖的回环
107
- let isInternalUpdating = false
108
- let lastEmittedModelValue: any = null
109
-
110
- // 监听外部传入的 AI 相关配置(优先 props,次选 config 对象)
111
- watch(
112
- [
113
- () => props.aiApiUrl,
114
- () => props.config?.aiApiUrl,
115
- () => props.enableAi,
116
- () => props.config?.enableAi,
117
- () => props.aiHeaders,
118
- () => props.config?.aiHeaders
119
- ],
120
- () => {
121
- const url = props.aiApiUrl || props.config?.aiApiUrl || ''
122
- const headers = { ...(props.config?.aiHeaders || {}), ...(props.aiHeaders || {}) }
123
- // 若显式传入 enableAi,则以其为准;若传了 url 则开启;未指定时保留 undefined 触发默认策略
124
- const rawEnabled = props.enableAi !== undefined ? props.enableAi : props.config?.enableAi
125
- const enabled = rawEnabled !== undefined ? rawEnabled : (url ? true : undefined)
126
- setAiConfig({ apiUrl: url, headers, enable: enabled })
127
- },
128
- { immediate: true, deep: true }
129
- )
130
-
131
- // 监听外部传入的素材库相关配置(优先 props,次选 config 对象)
132
- watch(
133
- [
134
- () => props.materialApiUrl,
135
- () => props.config?.materialApiUrl,
136
- () => props.materialListApiUrl,
137
- () => props.config?.materialListApiUrl,
138
- () => props.materialTreeApiUrl,
139
- () => props.config?.materialTreeApiUrl,
140
- () => props.materialPageApiUrl,
141
- () => props.config?.materialPageApiUrl,
142
- () => props.materialTypeApiUrl,
143
- () => props.config?.materialTypeApiUrl,
144
- () => props.materialSaveApiUrl,
145
- () => props.config?.materialSaveApiUrl,
146
- () => props.enableMaterial,
147
- () => props.config?.enableMaterial,
148
- () => props.materialHeaders,
149
- () => props.config?.materialHeaders
150
- ],
151
- () => {
152
- const listUrl = props.materialListApiUrl || props.config?.materialListApiUrl || props.materialApiUrl || props.config?.materialApiUrl || ''
153
- const treeUrl = props.materialTreeApiUrl || props.config?.materialTreeApiUrl || listUrl || ''
154
- const pageUrl = props.materialPageApiUrl || props.config?.materialPageApiUrl || ''
155
- const typeUrl = props.materialTypeApiUrl || props.config?.materialTypeApiUrl || ''
156
- const saveUrl = props.materialSaveApiUrl || props.config?.materialSaveApiUrl || ''
157
- const headers = { ...(props.config?.materialHeaders || {}), ...(props.materialHeaders || {}) }
158
- // 只要配置了获取内容的 API,且未显式关闭,就展示并激活素材功能
159
- const rawEnabled = props.enableMaterial !== undefined ? props.enableMaterial : props.config?.enableMaterial
160
- const enabled = rawEnabled !== undefined ? rawEnabled : ((treeUrl || pageUrl || listUrl) ? true : undefined)
161
- setMaterialConfig({
162
- listApiUrl: listUrl,
163
- apiUrl: listUrl,
164
- treeApiUrl: treeUrl,
165
- pageApiUrl: pageUrl,
166
- typeApiUrl: typeUrl,
167
- saveApiUrl: saveUrl,
168
- headers,
169
- enable: enabled
170
- })
171
- },
172
- { immediate: true, deep: true }
173
- )
174
-
175
- // 监听外部传入的自定义资源上传函数(优先 props,次选 config 对象)
176
- watch(
177
- [
178
- () => props.customUpload,
179
- () => props.uploadHandler,
180
- () => props.config?.customUpload,
181
- () => props.config?.uploadHandler
182
- ],
183
- () => {
184
- const handler = props.customUpload || props.uploadHandler || props.config?.customUpload || props.config?.uploadHandler || null
185
- setCustomUploadHandler(handler)
186
- },
187
- { immediate: true }
188
- )
189
-
190
- // 初始化标题
191
- if (props.config?.title) {
192
- presentationTitle.value = props.config.title
193
- }
194
-
195
- // 获取当前完整 Deck 工程数据
196
- function getDeck(): DeckData {
197
- return {
198
- title: presentationTitle.value || '未命名演示文稿',
199
- slides: JSON.parse(JSON.stringify(slides.value || [])),
200
- slideMasters: JSON.parse(JSON.stringify(slideMasters.value || [])),
201
- themeClrScheme: themeClrScheme.value,
202
- slideRatio: slideRatio.value,
203
- slideHeight: slideHeight.value,
204
- logicalWidth: logicalWidth.value,
205
- logicalHeight: logicalHeight.value
206
- }
207
- }
208
-
209
- // 获取幻灯片列表
210
- function getSlides(): Slide[] {
211
- return JSON.parse(JSON.stringify(slides.value || []))
212
- }
213
-
214
- // 设置/替换当前幻灯片列表
215
- function setSlides(newSlides: Slide[]) {
216
- if (!newSlides || !Array.isArray(newSlides)) return
217
- isInternalUpdating = true
218
- importSlides(newSlides)
219
- nextTick(() => {
220
- isInternalUpdating = false
221
- })
222
- }
223
-
224
- // 加载完整 Deck 工程数据
225
- function loadDeck(deck: DeckData | Slide[]) {
226
- if (!deck) return
227
- isInternalUpdating = true
228
- if (Array.isArray(deck)) {
229
- importSlides(deck)
230
- } else {
231
- if (deck.title) {
232
- presentationTitle.value = deck.title
233
- }
234
- if (deck.slideMasters && Array.isArray(deck.slideMasters)) {
235
- slideMasters.value = deck.slideMasters
236
- }
237
- if (deck.slides && Array.isArray(deck.slides)) {
238
- importSlides(deck.slides)
239
- }
240
- }
241
- nextTick(() => {
242
- isInternalUpdating = false
243
- })
244
- }
245
-
246
- const headerRef = ref<any>(null)
247
-
248
- /**
249
- * 直接获取 JSON 格式的 Blob 对象
250
- */
251
- function getSaveJsonBlob(): Blob {
252
- const deck = getDeck()
253
- return new Blob([JSON.stringify(deck, null, 2)], { type: 'application/json' })
254
- }
255
-
256
- /**
257
- * 直接获取带文件名的 JSON File 对象
258
- */
259
- function getSaveJsonFile(filename?: string): File {
260
- const blob = getSaveJsonBlob()
261
- const name = `${filename || presentationTitle.value || 'presentation'}.json`
262
- return new File([blob], name, { type: 'application/json' })
263
- }
264
-
265
- /**
266
- * 异步生成并获取可编辑的 PPTX 二进制 Blob 对象
267
- */
268
- async function getSavePptxBlob(): Promise<Blob | null> {
269
- if (headerRef.value?.generatePptxBlob) {
270
- return await headerRef.value.generatePptxBlob()
271
- }
272
- return null
273
- }
274
-
275
- /**
276
- * 异步生成并获取带文件名的 PPTX File 对象
277
- */
278
- async function getSavePptxFile(filename?: string): Promise<File | null> {
279
- const blob = await getSavePptxBlob()
280
- if (!blob) return null
281
- const name = `${filename || presentationTitle.value || 'presentation'}.pptx`
282
- return new File([blob], name, {
283
- type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
284
- })
285
- }
286
-
287
- /**
288
- * 核心保存 API:供外部使用方直接调用获取保存内容
289
- * @param options 保存配置
290
- * @returns 对应格式的内容 (DeckData | Blob | File)
291
- */
292
- async function save(options?: SaveOptions): Promise<any> {
293
- const format = options?.format || 'json'
294
- const customName = options?.filename || presentationTitle.value || 'presentation'
295
- const currentDeck = getDeck()
296
-
297
- let result: any = null
298
-
299
- if (format === 'json' || format === 'deck') {
300
- result = currentDeck
301
- if (options?.autoDownload) {
302
- const blob = getSaveJsonBlob()
303
- saveAs(blob, `${customName}.json`)
304
- }
305
- } else if (format === 'json-blob') {
306
- result = getSaveJsonBlob()
307
- if (options?.autoDownload) {
308
- saveAs(result, `${customName}.json`)
309
- }
310
- } else if (format === 'json-file') {
311
- result = getSaveJsonFile(customName)
312
- if (options?.autoDownload) {
313
- saveAs(result, result.name)
314
- }
315
- } else if (format === 'pptx' || format === 'pptx-blob') {
316
- const blob = await getSavePptxBlob()
317
- result = blob
318
- if (blob && options?.autoDownload) {
319
- saveAs(blob, `${customName}.pptx`)
320
- }
321
- } else if (format === 'pptx-file') {
322
- const file = await getSavePptxFile(customName)
323
- result = file
324
- if (file && options?.autoDownload) {
325
- saveAs(file, file.name)
326
- }
327
- } else if (format === 'images-zip') {
328
- if (headerRef.value?.generateImagesZipBlob) {
329
- const zipBlob = await headerRef.value.generateImagesZipBlob()
330
- result = zipBlob
331
- if (zipBlob && options?.autoDownload) {
332
- saveAs(zipBlob, `${customName}.zip`)
333
- }
334
- }
335
- } else {
336
- result = currentDeck
337
- }
338
-
339
- // 抛出保存事件,携带最终产物与工程元数据
340
- emit('save', {
341
- format,
342
- data: result,
343
- deck: currentDeck
344
- })
345
-
346
- return result
347
- }
348
-
349
- // 导出相关方法
350
- async function exportPptx() {
351
- if (headerRef.value?.downloadAsPptx) {
352
- await headerRef.value.downloadAsPptx()
353
- } else {
354
- exportJson()
355
- }
356
- emit('export', { format: 'pptx', title: presentationTitle.value })
357
- }
358
-
359
- function exportJson() {
360
- exportSlides()
361
- emit('export', { format: 'json', title: presentationTitle.value })
362
- }
363
-
364
- async function exportImages() {
365
- if (headerRef.value?.downloadAllSlidesAsZip) {
366
- await headerRef.value.downloadAllSlidesAsZip()
367
- }
368
- emit('export', { format: 'images', title: presentationTitle.value })
369
- }
370
-
371
- /**
372
- * 通用多源数据与文件加载/渲染方法
373
- * 支持传入 File 对象、Blob 二进制流、ArrayBuffer、JSON 字符串/对象、XML 字符串
374
- * 自动探测类型并一键渲染进编辑器画布
375
- */
376
- async function loadFile(source: File | Blob | ArrayBuffer | string | any, options?: LoadSourceOptions): Promise<LoadSourceResult> {
377
- if (!source) {
378
- throw new Error('加载失败:未提供任何有效数据源 (source is null or undefined)')
379
- }
380
-
381
- let detectedType: string = options?.type || 'auto'
382
- let customTitle = options?.title || ''
383
-
384
- // 1. 探测 File 类型与获取标题
385
- if (typeof File !== 'undefined' && source instanceof File) {
386
- if (!customTitle) {
387
- customTitle = source.name.replace(/\.[^.]+$/, '')
388
- }
389
- if (detectedType === 'auto') {
390
- const ext = (source.name.split('.').pop() || '').toLowerCase()
391
- if (ext === 'pptx') detectedType = 'pptx'
392
- else if (ext === 'json') detectedType = 'json'
393
- else if (ext === 'xml') detectedType = 'xml'
394
- else if (source.type.includes('presentation') || source.type.includes('powerpoint')) detectedType = 'pptx'
395
- else if (source.type.includes('json')) detectedType = 'json'
396
- else if (source.type.includes('xml')) detectedType = 'xml'
397
- }
398
- }
399
-
400
- // 2. 探测 Blob 类型
401
- if (typeof Blob !== 'undefined' && source instanceof Blob && detectedType === 'auto') {
402
- if (source.type.includes('presentation') || source.type.includes('powerpoint')) {
403
- detectedType = 'pptx'
404
- } else if (source.type.includes('json')) {
405
- detectedType = 'json'
406
- } else if (source.type.includes('xml')) {
407
- detectedType = 'xml'
408
- } else {
409
- try {
410
- const headBuffer = await source.slice(0, 4).arrayBuffer()
411
- const headBytes = new Uint8Array(headBuffer)
412
- if (headBytes[0] === 0x50 && headBytes[1] === 0x4B) {
413
- detectedType = 'pptx'
414
- } else {
415
- const textSample = (await source.slice(0, 200).text()).trim()
416
- if (textSample.startsWith('{') || textSample.startsWith('[')) {
417
- detectedType = 'json'
418
- } else if (textSample.startsWith('<')) {
419
- detectedType = 'xml'
420
- }
421
- }
422
- } catch (e) {
423
- console.warn('探测 Blob 头部签名失败:', e)
424
- }
425
- }
426
- }
427
-
428
- // 3. 探测 ArrayBuffer 类型
429
- if (source instanceof ArrayBuffer && detectedType === 'auto') {
430
- const headBytes = new Uint8Array(source.slice(0, 4))
431
- if (headBytes[0] === 0x50 && headBytes[1] === 0x4B) {
432
- detectedType = 'pptx'
433
- } else {
434
- detectedType = 'pptx'
435
- }
436
- }
437
-
438
- // 4. 探测纯字符串类型 (支持 JSON 文本、XML 文本,或 URL 文件地址)
439
- if (typeof source === 'string') {
440
- const trimmed = source.trim()
441
- const isUrl = /^(https?:\/\/|blob:|data:|\/|\.\/|\.\.\/)/i.test(trimmed) || /\.(pptx|json|xml)(\?.*)?$/i.test(trimmed)
442
-
443
- if (isUrl && !trimmed.startsWith('{') && !trimmed.startsWith('[') && !trimmed.startsWith('<')) {
444
- const urlPath = trimmed.split('?')[0].split('#')[0]
445
- const fileNameFromUrl = urlPath.substring(urlPath.lastIndexOf('/') + 1)
446
- if (!customTitle && fileNameFromUrl) {
447
- try {
448
- customTitle = decodeURIComponent(fileNameFromUrl.replace(/\.[^.]+$/, ''))
449
- } catch (e) {
450
- customTitle = fileNameFromUrl.replace(/\.[^.]+$/, '')
451
- }
452
- }
453
-
454
- if (detectedType === 'auto' && fileNameFromUrl) {
455
- const ext = (fileNameFromUrl.split('.').pop() || '').toLowerCase()
456
- if (ext === 'pptx') detectedType = 'pptx'
457
- else if (ext === 'json') detectedType = 'json'
458
- else if (ext === 'xml') detectedType = 'xml'
459
- }
460
-
461
- const res = await fetch(trimmed, options?.fetchOptions)
462
- if (!res.ok) {
463
- throw new Error(`加载文件地址失败 (${res.status} ${res.statusText}): ${trimmed}`)
464
- }
465
-
466
- const contentType = res.headers.get('content-type') || ''
467
- if (detectedType === 'auto') {
468
- if (contentType.includes('presentation') || contentType.includes('powerpoint')) detectedType = 'pptx'
469
- else if (contentType.includes('json')) detectedType = 'json'
470
- else if (contentType.includes('xml')) detectedType = 'xml'
471
- }
472
-
473
- const downloadedBlob = await res.blob()
474
- return await loadFile(downloadedBlob, {
475
- ...options,
476
- type: detectedType !== 'auto' ? detectedType as any : undefined,
477
- title: customTitle || options?.title
478
- })
479
- }
480
-
481
- if (detectedType === 'auto') {
482
- if (trimmed.startsWith('<')) {
483
- detectedType = 'xml'
484
- } else if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
485
- detectedType = 'json'
486
- }
487
- }
488
- }
489
-
490
- // 5. 探测普通 JS 对象
491
- if (typeof source === 'object' && !(source instanceof Blob) && !(source instanceof ArrayBuffer) && detectedType === 'auto') {
492
- detectedType = 'json'
493
- }
494
-
495
- // 兜底识别
496
- if (detectedType === 'auto') {
497
- detectedType = typeof source === 'string' ? 'json' : 'pptx'
498
- }
499
-
500
- if (customTitle) {
501
- presentationTitle.value = customTitle
502
- }
503
-
504
- // 分发执行解析渲染
505
- if (detectedType === 'pptx') {
506
- let buffer: ArrayBuffer
507
- if (source instanceof ArrayBuffer) {
508
- buffer = source
509
- } else if (typeof Blob !== 'undefined' && source instanceof Blob) {
510
- buffer = await source.arrayBuffer()
511
- } else {
512
- throw new Error('PPTX 格式需要 File / Blob / ArrayBuffer 数据源')
513
- }
514
-
515
- if (!headerRef.value?.importPptxArrayBuffer) {
516
- throw new Error('PPTX 解析器模块尚未就绪')
517
- }
518
-
519
- const success = await headerRef.value.importPptxArrayBuffer(buffer, customTitle)
520
- if (!success) {
521
- throw new Error('PPTX 解析导入失败')
522
- }
523
-
524
- const result: LoadSourceResult = {
525
- success: true,
526
- title: presentationTitle.value,
527
- slidesCount: slides.value?.length || 0,
528
- type: 'pptx'
529
- }
530
- emit('load', result)
531
- return result
532
- } else if (detectedType === 'json' || detectedType === 'deck') {
533
- let data: any
534
- if (typeof Blob !== 'undefined' && source instanceof Blob) {
535
- const text = await source.text()
536
- data = JSON.parse(text)
537
- } else if (typeof source === 'string') {
538
- data = JSON.parse(source)
539
- } else {
540
- data = source
541
- }
542
-
543
- if (uploadMediaResourcesInSlides) {
544
- await uploadMediaResourcesInSlides(data)
545
- }
546
-
547
- if (Array.isArray(data)) {
548
- setSlides(data)
549
- } else if (data && typeof data === 'object' && Array.isArray(data.slides)) {
550
- loadDeck(data)
551
- } else {
552
- throw new Error('无效的 JSON 幻灯片数据格式,缺少 slides 数组')
553
- }
554
-
555
- if (options?.notify !== false) {
556
- notify('已成功导入幻灯片工程')
557
- }
558
-
559
- const result: LoadSourceResult = {
560
- success: true,
561
- title: presentationTitle.value,
562
- slidesCount: slides.value?.length || 0,
563
- type: 'json'
564
- }
565
- emit('load', result)
566
- return result
567
- } else if (detectedType === 'xml') {
568
- let xmlText = ''
569
- if (typeof Blob !== 'undefined' && source instanceof Blob) {
570
- xmlText = await source.text()
571
- } else if (typeof source === 'string') {
572
- xmlText = source
573
- } else {
574
- throw new Error('XML 格式需要字符串或文本 Blob 数据源')
575
- }
576
-
577
- const parser = new DOMParser()
578
- const doc = parser.parseFromString(xmlText, 'text/xml')
579
- const parserError = doc.querySelector('parsererror')
580
- if (parserError) {
581
- throw new Error(`XML 语法解析错误: ${parserError.textContent}`)
582
- }
583
-
584
- const parsedSlides: Slide[] = []
585
- const deckNode = doc.querySelector('deck') || doc.querySelector('presentation')
586
- if (deckNode && deckNode.getAttribute('title')) {
587
- presentationTitle.value = deckNode.getAttribute('title') || presentationTitle.value
588
- }
589
-
590
- const slideNodes = doc.querySelectorAll('slide')
591
- if (slideNodes && slideNodes.length > 0) {
592
- slideNodes.forEach((sNode, sIdx) => {
593
- const title = sNode.querySelector('title')?.textContent || `幻灯片 ${sIdx + 1}`
594
- const subtitle = sNode.querySelector('subtitle')?.textContent || ''
595
- const background = sNode.getAttribute('background') || sNode.querySelector('background')?.textContent || '#ffffff'
596
- const elements: any[] = []
597
-
598
- const elemNodes = sNode.querySelectorAll('element, shape, text, item')
599
- elemNodes.forEach((eNode, eIdx) => {
600
- const type = eNode.getAttribute('type') || eNode.tagName.toLowerCase()
601
- const content = eNode.textContent || ''
602
- const left = eNode.getAttribute('left') || `${50 + eIdx * 20}px`
603
- const top = eNode.getAttribute('top') || `${100 + eIdx * 40}px`
604
- const width = eNode.getAttribute('width') || '400px'
605
- const height = eNode.getAttribute('height') || '100px'
606
-
607
- elements.push({
608
- id: `el_xml_${Date.now()}_${eIdx}`,
609
- type: type === 'shape' ? 'shape' : 'text',
610
- content: content || '文本内容',
611
- style: {
612
- left,
613
- top,
614
- width,
615
- height,
616
- color: eNode.getAttribute('color') || '#000000',
617
- fontSize: eNode.getAttribute('fontSize') || '20px'
618
- }
619
- })
620
- })
621
-
622
- parsedSlides.push({
623
- id: `slide_xml_${Date.now()}_${sIdx}`,
624
- title,
625
- subtitle,
626
- background,
627
- bgType: 'color',
628
- elements,
629
- notes: sNode.querySelector('notes')?.textContent || '',
630
- transition: 'none'
631
- } as any)
632
- })
633
- } else {
634
- const pSld = doc.querySelector('p\\:sld, sld')
635
- if (pSld) {
636
- const elements: any[] = []
637
- const spList = doc.querySelectorAll('p\\:sp, sp')
638
- spList.forEach((sp, spIdx) => {
639
- const tNodes = sp.querySelectorAll('a\\:t, t')
640
- let textVal = ''
641
- tNodes.forEach(t => { textVal += t.textContent + ' ' })
642
- textVal = textVal.trim()
643
-
644
- const off = sp.querySelector('a\\:off, off')
645
- const ext = sp.querySelector('a\\:ext, ext')
646
- const xEmu = off ? parseInt(off.getAttribute('x') || '0', 10) : 0
647
- const yEmu = off ? parseInt(off.getAttribute('y') || '0', 10) : 0
648
- const cxEmu = ext ? parseInt(ext.getAttribute('cx') || '0', 10) : 0
649
- const cyEmu = ext ? parseInt(ext.getAttribute('cy') || '0', 10) : 0
650
-
651
- const left = `${Math.round(xEmu / 9525)}px`
652
- const top = `${Math.round(yEmu / 9525)}px`
653
- const width = cxEmu ? `${Math.round(cxEmu / 9525)}px` : '300px'
654
- const height = cyEmu ? `${Math.round(cyEmu / 9525)}px` : '80px'
655
-
656
- elements.push({
657
- id: `el_xml_${Date.now()}_${spIdx}`,
658
- type: 'text',
659
- content: textVal || '形状元素',
660
- style: {
661
- left,
662
- top,
663
- width,
664
- height,
665
- color: '#1e293b',
666
- fontSize: '18px'
667
- }
668
- })
669
- })
670
-
671
- parsedSlides.push({
672
- id: `slide_xml_${Date.now()}`,
673
- title: customTitle || 'XML 导入幻灯片',
674
- subtitle: '',
675
- background: '#ffffff',
676
- bgType: 'color',
677
- elements,
678
- notes: '',
679
- transition: 'none'
680
- } as any)
681
- } else {
682
- throw new Error('未识别的 XML 幻灯片格式,请提供标准 deck/slide 或 p:sld 格式')
683
- }
684
- }
685
-
686
- if (parsedSlides.length === 0) {
687
- throw new Error('XML 解析成功,但未提取到有效幻灯片页面')
688
- }
689
-
690
- setSlides(parsedSlides)
691
- if (options?.notify !== false) {
692
- notify(`已成功从 XML 渲染 ${parsedSlides.length} 页幻灯片`)
693
- }
694
-
695
- const result: LoadSourceResult = {
696
- success: true,
697
- title: presentationTitle.value,
698
- slidesCount: parsedSlides.length,
699
- type: 'xml'
700
- }
701
- emit('load', result)
702
- return result
703
- } else {
704
- throw new Error(`暂不支持的数据格式类型: ${detectedType}`)
705
- }
706
- }
707
-
708
- // 监听外部传入的 modelValue
709
- watch(
710
- () => props.modelValue,
711
- (val) => {
712
- if (isInternalUpdating) return
713
- if (!val) return
714
- if (val === lastEmittedModelValue) return
715
- loadDeck(val as any)
716
- },
717
- { immediate: true, deep: true }
718
- )
719
-
720
- // 监听内部 slides 变化,向外部同步(拖拽/变形交互期间冻结,避免高频触发几十毫秒的深拷贝序列化)
721
- watch(
722
- () => slides.value,
723
- (newSlides) => {
724
- if (isInternalUpdating) return
725
- if (isCanvasDragging?.value || pauseHistory?.value) return
726
- isInternalUpdating = true
727
- const currentDeck = getDeck()
728
- lastEmittedModelValue = currentDeck
729
- emit('update:modelValue', currentDeck)
730
- emit('change', {
731
- slides: newSlides,
732
- activeIndex: activeIndex.value,
733
- title: presentationTitle.value
734
- })
735
- nextTick(() => {
736
- isInternalUpdating = false
737
- })
738
- },
739
- { deep: true }
740
- )
741
-
742
- // 拖拽/变形结束后,统一补偿同步一次最新的 Deck 状态给外部
743
- watch([() => isCanvasDragging?.value, () => pauseHistory?.value], ([isDragging, isPaused], [prevDragging, prevPaused]) => {
744
- if ((prevDragging && !isDragging) || (prevPaused && !isPaused)) {
745
- if (!isDragging && !isPaused) {
746
- if (isInternalUpdating) return
747
- isInternalUpdating = true
748
- const currentDeck = getDeck()
749
- lastEmittedModelValue = currentDeck
750
- emit('update:modelValue', currentDeck)
751
- emit('change', {
752
- slides: slides.value,
753
- activeIndex: activeIndex.value,
754
- title: presentationTitle.value
755
- })
756
- nextTick(() => {
757
- isInternalUpdating = false
758
- })
759
- }
760
- }
761
- })
762
-
763
- // 监听当前选中的幻灯片切换
764
- watch(
765
- () => activeIndex.value,
766
- (idx) => {
767
- emit('slideChange', {
768
- index: idx,
769
- slide: activeSlide.value || null
770
- })
771
- }
772
- )
773
-
774
- // 公式编辑确认
775
- function handleFormulaConfirm(latex: string) {
776
- if (currentEditingFormulaEl.value) {
777
- currentEditingFormulaEl.value.latex = latex
778
- currentEditingFormulaEl.value.content = latex
779
- notify('已更新公式')
780
- currentEditingFormulaEl.value = null
781
- } else {
782
- addElement('text', latex, latex)
783
- }
784
- }
785
-
786
- // 组装对外暴露的编程式 API 对象
787
- const editorApi: PptEditorApi = {
788
- save,
789
- getSaveData: () => getDeck(),
790
- getSaveJsonBlob,
791
- getSaveJsonFile,
792
- getSavePptxBlob,
793
- getSavePptxFile,
794
- getSlides,
795
- getDeck,
796
- setSlides,
797
- loadDeck,
798
- loadFile,
799
- importFile: loadFile,
800
- getActiveSlide: () => activeSlide.value || null,
801
- getActiveIndex: () => activeIndex.value,
802
- selectSlide: (index: number) => selectSlide(index),
803
- addSlide: () => addSlide(),
804
- deleteSlide: (index?: number) => {
805
- const targetIdx = typeof index === 'number' ? index : activeIndex.value
806
- deleteSlide(targetIdx)
807
- },
808
- exportPptx,
809
- exportJson,
810
- exportImages,
811
- undo,
812
- redo,
813
- canUndo,
814
- canRedo,
815
- startPresentation,
816
- stopPresentation,
817
- notify,
818
- zoomIn,
819
- zoomOut,
820
- resetZoom,
821
- setAiConfig,
822
- isAiBeautifyEnabled,
823
- setMaterialConfig,
824
- isMaterialEnabled,
825
- saveMaterial: (meta) => saveCurrentSlideToMaterial(meta),
826
- setCustomUploadHandler,
827
- getCustomUploadHandler,
828
- slidesComposable: slidesState
829
- }
830
-
831
- // 获取当前插槽实例
832
- const slots = useSlots()
833
-
834
- // 顶部工具栏插槽作用域上下文数据
835
- const headerSlotContext = computed(() => ({
836
- api: editorApi,
837
- deck: getDeck(),
838
- slides: slidesState.slides.value,
839
- activeSlide: slidesState.activeSlide.value || null,
840
- activeIndex: slidesState.activeIndex.value,
841
- title: presentationTitle.value,
842
- setTitle: (newTitle: string) => {
843
- presentationTitle.value = newTitle
844
- },
845
- save,
846
- exportPptx,
847
- exportJson,
848
- exportImages,
849
- undo,
850
- redo,
851
- canUndo: canUndo.value,
852
- canRedo: canRedo.value,
853
- zoomIn,
854
- zoomOut,
855
- resetZoom,
856
- zoomRatio: slidesState.zoomRatio.value,
857
- isMasterEditingView: slidesState.isMasterEditingView.value
858
- }))
859
-
860
- // 对外通过 ref 暴露所有方法
861
- defineExpose(editorApi)
862
-
863
- onMounted(async () => {
864
- // 触发 ready 事件
865
- emit('ready', editorApi)
866
-
867
- // 若启用 MCP 自动拉取
868
- if (props.enableMcp) {
869
- const params = new URLSearchParams(window.location.search)
870
- const deckId = params.get('deckId')
871
- const deckPath = params.get('deck')
872
- if (deckId || deckPath) {
873
- try {
874
- const query = deckId
875
- ? `deckId=${encodeURIComponent(deckId)}`
876
- : `path=${encodeURIComponent(deckPath || '')}`
877
- const response = await fetch(`/__mcp_deck?${query}`)
878
- if (!response.ok) {
879
- throw new Error(await response.text())
880
- }
881
- const deck = await response.json()
882
- loadDeck(deck)
883
- notify('已从 MCP 打开可编辑稿')
884
- } catch (error: any) {
885
- notify(`打开 MCP 稿件失败:${error?.message || error}`)
886
- }
887
- }
888
- }
889
- })
890
- </script>
891
-
892
- <template>
893
- <div class="app-container jvs-ppt-editor-root" :class="{ 'is-readonly': readonly }">
894
- <!-- Main top Navigation bar (支持完整 header 插槽替换或精细区域插槽定制) -->
895
- <slot name="header" v-bind="headerSlotContext">
896
- <Header ref="headerRef" v-show="showHeader" :slot-props="headerSlotContext">
897
- <!-- 左侧前置 (Logo、返回按钮等) -->
898
- <template v-if="slots['header-left-prefix']" #left-prefix="sp">
899
- <slot name="header-left-prefix" v-bind="sp"></slot>
900
- </template>
901
-
902
- <!-- 左侧主体(若外部传了 header-left,则覆盖默认汉堡菜单和标题) -->
903
- <template v-if="slots['header-left']" #left="sp">
904
- <slot name="header-left" v-bind="sp"></slot>
905
- </template>
906
-
907
- <!-- 左侧后置 (文档副标题、状态标签等) -->
908
- <template v-if="slots['header-left-suffix']" #left-suffix="sp">
909
- <slot name="header-left-suffix" v-bind="sp"></slot>
910
- </template>
911
-
912
- <!-- 中间工具栏主体(若外部传了 toolbar 或 header-center,则覆盖默认 Toolbar) -->
913
- <template v-if="slots['toolbar'] || slots['header-center']" #center="sp">
914
- <slot name="toolbar" v-bind="sp">
915
- <slot name="header-center" v-bind="sp"></slot>
916
- </slot>
917
- </template>
918
-
919
- <!-- 工具栏前置 (插入到撤销按钮前) -->
920
- <template v-if="slots['toolbar-prefix']" #toolbar-prefix="sp">
921
- <slot name="toolbar-prefix" v-bind="sp"></slot>
922
- </template>
923
-
924
- <!-- 工具栏后置 (插入到一键美化按钮后) -->
925
- <template v-if="slots['toolbar-suffix']" #toolbar-suffix="sp">
926
- <slot name="toolbar-suffix" v-bind="sp"></slot>
927
- </template>
928
-
929
- <!-- 右侧前置 (缩放/放映前) -->
930
- <template v-if="slots['header-right-prefix']" #right-prefix="sp">
931
- <slot name="header-right-prefix" v-bind="sp"></slot>
932
- </template>
933
-
934
- <!-- 右侧主体 (保存、导出、分享等自定义按钮) -->
935
- <template v-if="slots['header-right']" #right="sp">
936
- <slot name="header-right" v-bind="sp"></slot>
937
- </template>
938
-
939
- <!-- 右侧后置 (最右侧,放映按钮后) -->
940
- <template v-if="slots['header-right-suffix']" #right-suffix="sp">
941
- <slot name="header-right-suffix" v-bind="sp"></slot>
942
- </template>
943
- </Header>
944
- </slot>
945
-
946
- <!-- Main Workspace consisting of SidebarLeft, Center Canvas, and SidebarRight -->
947
- <main class="app-workspace" :class="{ 'hide-right-sidebar': !activeTab || !showSidebarRight }">
948
- <!-- 左侧幻灯片列表 -->
949
- <SidebarLeft v-if="showSidebarLeft" class="sidebar-left" />
950
-
951
- <!-- 中间主画布区 -->
952
- <CanvasArea class="canvas-area" />
953
-
954
- <!-- 右侧属性配置栏 -->
955
- <SidebarRight v-if="showSidebarRight && !readonly" />
956
- </main>
957
-
958
- <!-- Fullscreen slide deck display panel -->
959
- <PresentationMode />
960
-
961
- <!-- Action feedback notifications banner -->
962
- <ToastNotification />
963
-
964
- <!-- Global LaTeX Formula Editor Dialog -->
965
- <FormulaDialog
966
- v-if="!readonly"
967
- v-model:visible="formulaDialogVisible"
968
- :initialLatex="currentEditingFormulaEl?.latex || ''"
969
- @confirm="handleFormulaConfirm"
970
- />
971
-
972
- <!-- Global DSL JSON Input Dialog -->
973
- <DslInputDialog
974
- v-if="!readonly"
975
- v-model:visible="dslInputDialogVisible"
976
- @confirm="importSlides"
977
- />
978
-
979
- <!-- Global AI PPT Streaming Dialog -->
980
- <AIPptDialog
981
- v-if="!readonly"
982
- v-model:visible="aiPptDialogVisible"
983
- />
984
-
985
- <!-- Global Unsplash AI Image Search & Insertion Dialog -->
986
- <UnsplashImageDialog v-if="!readonly" />
987
- </div>
988
- </template>
989
-
990
- <style scoped>
991
- .jvs-ppt-editor-root {
992
- width: 100%;
993
- height: 100%;
994
- position: relative;
995
- overflow: hidden;
996
- display: flex;
997
- flex-direction: column;
998
- }
999
-
1000
- /* 只读模式下的禁用样式微调 */
1001
- .jvs-ppt-editor-root.is-readonly :deep(.action-btn-disabled) {
1002
- pointer-events: none;
1003
- opacity: 0.6;
1004
- }
1005
- </style>
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, useSlots, getCurrentInstance, defineAsyncComponent, type Ref } from 'vue'
3
+ import jvsPickerColorV3 from 'jvs-picker-color-v3'
4
+ import '../assets/iconfont/iconfont.js'
5
+ import { saveAs } from 'file-saver'
6
+ import Header from './Header.vue'
7
+ import Toolbar from './Toolbar.vue'
8
+ import SidebarLeft from './SidebarLeft.vue'
9
+ import CanvasArea from './CanvasArea.vue'
10
+ import SidebarRight from './SidebarRight.vue'
11
+ import ToastNotification from './ToastNotification.vue'
12
+ // 大弹层/放映视图异步化:不进首屏主包,首次打开才加载
13
+ const PresentationMode = defineAsyncComponent(() => import('./PresentationMode.vue'))
14
+ const FormulaDialog = defineAsyncComponent(() => import('./FormulaDialog.vue'))
15
+ const DslInputDialog = defineAsyncComponent(() => import('./DslInputDialog.vue'))
16
+ const AIPptDialog = defineAsyncComponent(() => import('./AIPptDialog.vue'))
17
+ const UnsplashImageDialog = defineAsyncComponent(() => import('./UnsplashImageDialog.vue'))
18
+
19
+ // 自动检测并兼容单组件 import { PptEditor } 局部引用的场景(若宿主未调用 app.use(JvsPptUi) 则静默自动装配)
20
+ // Element Plus 组件已由构建期按需解析(unplugin-vue-components),无需运行时全局注册
21
+ const currentApp = getCurrentInstance()?.appContext?.app
22
+ if (currentApp) {
23
+ const registeredComponents = currentApp._context?.components || {}
24
+ if (!registeredComponents['jvs-picker-color-v3']) {
25
+ currentApp.use(jvsPickerColorV3)
26
+ }
27
+ }
28
+
29
+ import { useSlides } from '../composables/useSlides'
30
+ import type { Slide, SlideMaster, DeckData, PptEditorProps, PptEditorApi, SaveOptions, LoadSourceOptions, LoadSourceResult } from '../types'
31
+
32
+ const props = withDefaults(defineProps<PptEditorProps>(), {
33
+ modelValue: null,
34
+ readonly: false,
35
+ showHeader: true,
36
+ showSidebarLeft: true,
37
+ showSidebarRight: true,
38
+ enableMcp: false,
39
+ enableAi: undefined,
40
+ aiApiUrl: '',
41
+ aiHeaders: () => ({}),
42
+ enableMaterial: undefined,
43
+ materialApiUrl: '',
44
+ materialListApiUrl: '',
45
+ materialTreeApiUrl: '',
46
+ materialPageApiUrl: '',
47
+ materialTypeApiUrl: '',
48
+ materialSaveApiUrl: '',
49
+ materialHeaders: () => ({}),
50
+ config: () => ({})
51
+ })
52
+
53
+ const emit = defineEmits<{
54
+ (e: 'update:modelValue', value: DeckData | Slide[]): void
55
+ (e: 'change', payload: { slides: Slide[]; activeIndex: number; title: string }): void
56
+ (e: 'save', payload: any): void
57
+ (e: 'load', payload: LoadSourceResult): void
58
+ (e: 'export', payload: { format: string; title: string; data?: any }): void
59
+ (e: 'slideChange', payload: { index: number; slide: Slide | null }): void
60
+ (e: 'ready', api: PptEditorApi): void
61
+ }>()
62
+
63
+ const slidesState = useSlides()
64
+ const {
65
+ slides,
66
+ activeIndex,
67
+ activeSlide,
68
+ presentationTitle,
69
+ slideMasters,
70
+ themeClrScheme,
71
+ slideRatio,
72
+ slideHeight,
73
+ logicalWidth,
74
+ logicalHeight,
75
+ formulaDialogVisible,
76
+ dslInputDialogVisible,
77
+ aiPptDialogVisible,
78
+ unsplashDialogVisible,
79
+ isPresenting,
80
+ importSlides,
81
+ exportSlides,
82
+ currentEditingFormulaEl,
83
+ addElement,
84
+ notify,
85
+ activeTab,
86
+ undo,
87
+ redo,
88
+ canUndo,
89
+ canRedo,
90
+ addSlide,
91
+ deleteSlide,
92
+ selectSlide,
93
+ startPresentation,
94
+ stopPresentation,
95
+ zoomIn,
96
+ zoomOut,
97
+ resetZoom,
98
+ setAiConfig,
99
+ isAiBeautifyEnabled,
100
+ setMaterialConfig,
101
+ isMaterialEnabled,
102
+ saveCurrentSlideToMaterial,
103
+ isCanvasDragging,
104
+ pauseHistory,
105
+ setCustomUploadHandler,
106
+ getCustomUploadHandler,
107
+ uploadMediaResourcesInSlides
108
+ } = slidesState
109
+
110
+ // 弹层挂载闩锁:首次 visible 后保持挂载(异步组件只加载一次,关闭动画不被 v-if 掐断)
111
+ function latchMount(source: Ref<boolean>) {
112
+ const opened = ref(false)
113
+ watch(source, v => { if (v) opened.value = true })
114
+ return computed(() => source.value || opened.value)
115
+ }
116
+ const showFormulaDialog = latchMount(formulaDialogVisible)
117
+ const showDslInputDialog = latchMount(dslInputDialogVisible)
118
+ const showAiPptDialog = latchMount(aiPptDialogVisible)
119
+ const showUnsplashDialog = latchMount(unsplashDialogVisible)
120
+
121
+ // 标志位:防止外部 v-model 变化与内部 watch 产生互相覆盖的回环
122
+ let isInternalUpdating = false
123
+ let lastEmittedModelValue: any = null
124
+
125
+ // 监听外部传入的 AI 相关配置(优先 props,次选 config 对象)
126
+ watch(
127
+ [
128
+ () => props.aiApiUrl,
129
+ () => props.config?.aiApiUrl,
130
+ () => props.enableAi,
131
+ () => props.config?.enableAi,
132
+ () => props.aiHeaders,
133
+ () => props.config?.aiHeaders
134
+ ],
135
+ () => {
136
+ const url = props.aiApiUrl || props.config?.aiApiUrl || ''
137
+ const headers = { ...(props.config?.aiHeaders || {}), ...(props.aiHeaders || {}) }
138
+ // 若显式传入 enableAi,则以其为准;若传了 url 则开启;未指定时保留 undefined 触发默认策略
139
+ const rawEnabled = props.enableAi !== undefined ? props.enableAi : props.config?.enableAi
140
+ const enabled = rawEnabled !== undefined ? rawEnabled : (url ? true : undefined)
141
+ setAiConfig({ apiUrl: url, headers, enable: enabled })
142
+ },
143
+ { immediate: true, deep: true }
144
+ )
145
+
146
+ // 监听外部传入的素材库相关配置(优先 props,次选 config 对象)
147
+ watch(
148
+ [
149
+ () => props.materialApiUrl,
150
+ () => props.config?.materialApiUrl,
151
+ () => props.materialListApiUrl,
152
+ () => props.config?.materialListApiUrl,
153
+ () => props.materialTreeApiUrl,
154
+ () => props.config?.materialTreeApiUrl,
155
+ () => props.materialPageApiUrl,
156
+ () => props.config?.materialPageApiUrl,
157
+ () => props.materialTypeApiUrl,
158
+ () => props.config?.materialTypeApiUrl,
159
+ () => props.materialSaveApiUrl,
160
+ () => props.config?.materialSaveApiUrl,
161
+ () => props.enableMaterial,
162
+ () => props.config?.enableMaterial,
163
+ () => props.materialHeaders,
164
+ () => props.config?.materialHeaders
165
+ ],
166
+ () => {
167
+ const listUrl = props.materialListApiUrl || props.config?.materialListApiUrl || props.materialApiUrl || props.config?.materialApiUrl || ''
168
+ const treeUrl = props.materialTreeApiUrl || props.config?.materialTreeApiUrl || listUrl || ''
169
+ const pageUrl = props.materialPageApiUrl || props.config?.materialPageApiUrl || ''
170
+ const typeUrl = props.materialTypeApiUrl || props.config?.materialTypeApiUrl || ''
171
+ const saveUrl = props.materialSaveApiUrl || props.config?.materialSaveApiUrl || ''
172
+ const headers = { ...(props.config?.materialHeaders || {}), ...(props.materialHeaders || {}) }
173
+ // 只要配置了获取内容的 API,且未显式关闭,就展示并激活素材功能
174
+ const rawEnabled = props.enableMaterial !== undefined ? props.enableMaterial : props.config?.enableMaterial
175
+ const enabled = rawEnabled !== undefined ? rawEnabled : ((treeUrl || pageUrl || listUrl) ? true : undefined)
176
+ setMaterialConfig({
177
+ listApiUrl: listUrl,
178
+ apiUrl: listUrl,
179
+ treeApiUrl: treeUrl,
180
+ pageApiUrl: pageUrl,
181
+ typeApiUrl: typeUrl,
182
+ saveApiUrl: saveUrl,
183
+ headers,
184
+ enable: enabled
185
+ })
186
+ },
187
+ { immediate: true, deep: true }
188
+ )
189
+
190
+ // 监听外部传入的自定义资源上传函数(优先 props,次选 config 对象)
191
+ watch(
192
+ [
193
+ () => props.customUpload,
194
+ () => props.uploadHandler,
195
+ () => props.config?.customUpload,
196
+ () => props.config?.uploadHandler
197
+ ],
198
+ () => {
199
+ const handler = props.customUpload || props.uploadHandler || props.config?.customUpload || props.config?.uploadHandler || null
200
+ setCustomUploadHandler(handler)
201
+ },
202
+ { immediate: true }
203
+ )
204
+
205
+ // 初始化标题
206
+ if (props.config?.title) {
207
+ presentationTitle.value = props.config.title
208
+ }
209
+
210
+ // 获取当前完整 Deck 工程数据
211
+ function getDeck(): DeckData {
212
+ return {
213
+ title: presentationTitle.value || '未命名演示文稿',
214
+ slides: JSON.parse(JSON.stringify(slides.value || [])),
215
+ slideMasters: JSON.parse(JSON.stringify(slideMasters.value || [])),
216
+ themeClrScheme: themeClrScheme.value,
217
+ slideRatio: slideRatio.value,
218
+ slideHeight: slideHeight.value,
219
+ logicalWidth: logicalWidth.value,
220
+ logicalHeight: logicalHeight.value
221
+ }
222
+ }
223
+
224
+ // 获取幻灯片列表
225
+ function getSlides(): Slide[] {
226
+ return JSON.parse(JSON.stringify(slides.value || []))
227
+ }
228
+
229
+ // 设置/替换当前幻灯片列表
230
+ function setSlides(newSlides: Slide[]) {
231
+ if (!newSlides || !Array.isArray(newSlides)) return
232
+ isInternalUpdating = true
233
+ importSlides(newSlides)
234
+ nextTick(() => {
235
+ isInternalUpdating = false
236
+ })
237
+ }
238
+
239
+ // 加载完整 Deck 工程数据
240
+ function loadDeck(deck: DeckData | Slide[]) {
241
+ if (!deck) return
242
+ isInternalUpdating = true
243
+ if (Array.isArray(deck)) {
244
+ importSlides(deck)
245
+ } else {
246
+ if (deck.title) {
247
+ presentationTitle.value = deck.title
248
+ }
249
+ if (deck.slideMasters && Array.isArray(deck.slideMasters)) {
250
+ slideMasters.value = deck.slideMasters
251
+ }
252
+ if (deck.slides && Array.isArray(deck.slides)) {
253
+ importSlides(deck.slides)
254
+ }
255
+ }
256
+ nextTick(() => {
257
+ isInternalUpdating = false
258
+ })
259
+ }
260
+
261
+ const headerRef = ref<any>(null)
262
+
263
+ /**
264
+ * 直接获取 JSON 格式的 Blob 对象
265
+ */
266
+ function getSaveJsonBlob(): Blob {
267
+ const deck = getDeck()
268
+ return new Blob([JSON.stringify(deck, null, 2)], { type: 'application/json' })
269
+ }
270
+
271
+ /**
272
+ * 直接获取带文件名的 JSON File 对象
273
+ */
274
+ function getSaveJsonFile(filename?: string): File {
275
+ const blob = getSaveJsonBlob()
276
+ const name = `${filename || presentationTitle.value || 'presentation'}.json`
277
+ return new File([blob], name, { type: 'application/json' })
278
+ }
279
+
280
+ /**
281
+ * 异步生成并获取可编辑的 PPTX 二进制 Blob 对象
282
+ */
283
+ async function getSavePptxBlob(): Promise<Blob | null> {
284
+ if (headerRef.value?.generatePptxBlob) {
285
+ return await headerRef.value.generatePptxBlob()
286
+ }
287
+ return null
288
+ }
289
+
290
+ /**
291
+ * 异步生成并获取带文件名的 PPTX File 对象
292
+ */
293
+ async function getSavePptxFile(filename?: string): Promise<File | null> {
294
+ const blob = await getSavePptxBlob()
295
+ if (!blob) return null
296
+ const name = `${filename || presentationTitle.value || 'presentation'}.pptx`
297
+ return new File([blob], name, {
298
+ type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
299
+ })
300
+ }
301
+
302
+ /**
303
+ * 核心保存 API:供外部使用方直接调用获取保存内容
304
+ * @param options 保存配置
305
+ * @returns 对应格式的内容 (DeckData | Blob | File)
306
+ */
307
+ async function save(options?: SaveOptions): Promise<any> {
308
+ const format = options?.format || 'json'
309
+ const customName = options?.filename || presentationTitle.value || 'presentation'
310
+ const currentDeck = getDeck()
311
+
312
+ let result: any = null
313
+
314
+ if (format === 'json' || format === 'deck') {
315
+ result = currentDeck
316
+ if (options?.autoDownload) {
317
+ const blob = getSaveJsonBlob()
318
+ saveAs(blob, `${customName}.json`)
319
+ }
320
+ } else if (format === 'json-blob') {
321
+ result = getSaveJsonBlob()
322
+ if (options?.autoDownload) {
323
+ saveAs(result, `${customName}.json`)
324
+ }
325
+ } else if (format === 'json-file') {
326
+ result = getSaveJsonFile(customName)
327
+ if (options?.autoDownload) {
328
+ saveAs(result, result.name)
329
+ }
330
+ } else if (format === 'pptx' || format === 'pptx-blob') {
331
+ const blob = await getSavePptxBlob()
332
+ result = blob
333
+ if (blob && options?.autoDownload) {
334
+ saveAs(blob, `${customName}.pptx`)
335
+ }
336
+ } else if (format === 'pptx-file') {
337
+ const file = await getSavePptxFile(customName)
338
+ result = file
339
+ if (file && options?.autoDownload) {
340
+ saveAs(file, file.name)
341
+ }
342
+ } else if (format === 'images-zip') {
343
+ if (headerRef.value?.generateImagesZipBlob) {
344
+ const zipBlob = await headerRef.value.generateImagesZipBlob()
345
+ result = zipBlob
346
+ if (zipBlob && options?.autoDownload) {
347
+ saveAs(zipBlob, `${customName}.zip`)
348
+ }
349
+ }
350
+ } else {
351
+ result = currentDeck
352
+ }
353
+
354
+ // 抛出保存事件,携带最终产物与工程元数据
355
+ emit('save', {
356
+ format,
357
+ data: result,
358
+ deck: currentDeck
359
+ })
360
+
361
+ return result
362
+ }
363
+
364
+ // 导出相关方法
365
+ async function exportPptx() {
366
+ if (headerRef.value?.downloadAsPptx) {
367
+ await headerRef.value.downloadAsPptx()
368
+ } else {
369
+ exportJson()
370
+ }
371
+ emit('export', { format: 'pptx', title: presentationTitle.value })
372
+ }
373
+
374
+ function exportJson() {
375
+ exportSlides()
376
+ emit('export', { format: 'json', title: presentationTitle.value })
377
+ }
378
+
379
+ async function exportImages() {
380
+ if (headerRef.value?.downloadAllSlidesAsZip) {
381
+ await headerRef.value.downloadAllSlidesAsZip()
382
+ }
383
+ emit('export', { format: 'images', title: presentationTitle.value })
384
+ }
385
+
386
+ /**
387
+ * 通用多源数据与文件加载/渲染方法
388
+ * 支持传入 File 对象、Blob 二进制流、ArrayBuffer、JSON 字符串/对象、XML 字符串
389
+ * 自动探测类型并一键渲染进编辑器画布
390
+ */
391
+ async function loadFile(source: File | Blob | ArrayBuffer | string | any, options?: LoadSourceOptions): Promise<LoadSourceResult> {
392
+ if (!source) {
393
+ throw new Error('加载失败:未提供任何有效数据源 (source is null or undefined)')
394
+ }
395
+
396
+ let detectedType: string = options?.type || 'auto'
397
+ let customTitle = options?.title || ''
398
+
399
+ // 1. 探测 File 类型与获取标题
400
+ if (typeof File !== 'undefined' && source instanceof File) {
401
+ if (!customTitle) {
402
+ customTitle = source.name.replace(/\.[^.]+$/, '')
403
+ }
404
+ if (detectedType === 'auto') {
405
+ const ext = (source.name.split('.').pop() || '').toLowerCase()
406
+ if (ext === 'pptx') detectedType = 'pptx'
407
+ else if (ext === 'json') detectedType = 'json'
408
+ else if (ext === 'xml') detectedType = 'xml'
409
+ else if (source.type.includes('presentation') || source.type.includes('powerpoint')) detectedType = 'pptx'
410
+ else if (source.type.includes('json')) detectedType = 'json'
411
+ else if (source.type.includes('xml')) detectedType = 'xml'
412
+ }
413
+ }
414
+
415
+ // 2. 探测 Blob 类型
416
+ if (typeof Blob !== 'undefined' && source instanceof Blob && detectedType === 'auto') {
417
+ if (source.type.includes('presentation') || source.type.includes('powerpoint')) {
418
+ detectedType = 'pptx'
419
+ } else if (source.type.includes('json')) {
420
+ detectedType = 'json'
421
+ } else if (source.type.includes('xml')) {
422
+ detectedType = 'xml'
423
+ } else {
424
+ try {
425
+ const headBuffer = await source.slice(0, 4).arrayBuffer()
426
+ const headBytes = new Uint8Array(headBuffer)
427
+ if (headBytes[0] === 0x50 && headBytes[1] === 0x4B) {
428
+ detectedType = 'pptx'
429
+ } else {
430
+ const textSample = (await source.slice(0, 200).text()).trim()
431
+ if (textSample.startsWith('{') || textSample.startsWith('[')) {
432
+ detectedType = 'json'
433
+ } else if (textSample.startsWith('<')) {
434
+ detectedType = 'xml'
435
+ }
436
+ }
437
+ } catch (e) {
438
+ console.warn('探测 Blob 头部签名失败:', e)
439
+ }
440
+ }
441
+ }
442
+
443
+ // 3. 探测 ArrayBuffer 类型
444
+ if (source instanceof ArrayBuffer && detectedType === 'auto') {
445
+ const headBytes = new Uint8Array(source.slice(0, 4))
446
+ if (headBytes[0] === 0x50 && headBytes[1] === 0x4B) {
447
+ detectedType = 'pptx'
448
+ } else {
449
+ detectedType = 'pptx'
450
+ }
451
+ }
452
+
453
+ // 4. 探测纯字符串类型 (支持 JSON 文本、XML 文本,或 URL 文件地址)
454
+ if (typeof source === 'string') {
455
+ const trimmed = source.trim()
456
+ const isUrl = /^(https?:\/\/|blob:|data:|\/|\.\/|\.\.\/)/i.test(trimmed) || /\.(pptx|json|xml)(\?.*)?$/i.test(trimmed)
457
+
458
+ if (isUrl && !trimmed.startsWith('{') && !trimmed.startsWith('[') && !trimmed.startsWith('<')) {
459
+ const urlPath = trimmed.split('?')[0].split('#')[0]
460
+ const fileNameFromUrl = urlPath.substring(urlPath.lastIndexOf('/') + 1)
461
+ if (!customTitle && fileNameFromUrl) {
462
+ try {
463
+ customTitle = decodeURIComponent(fileNameFromUrl.replace(/\.[^.]+$/, ''))
464
+ } catch (e) {
465
+ customTitle = fileNameFromUrl.replace(/\.[^.]+$/, '')
466
+ }
467
+ }
468
+
469
+ if (detectedType === 'auto' && fileNameFromUrl) {
470
+ const ext = (fileNameFromUrl.split('.').pop() || '').toLowerCase()
471
+ if (ext === 'pptx') detectedType = 'pptx'
472
+ else if (ext === 'json') detectedType = 'json'
473
+ else if (ext === 'xml') detectedType = 'xml'
474
+ }
475
+
476
+ const res = await fetch(trimmed, options?.fetchOptions)
477
+ if (!res.ok) {
478
+ throw new Error(`加载文件地址失败 (${res.status} ${res.statusText}): ${trimmed}`)
479
+ }
480
+
481
+ const contentType = res.headers.get('content-type') || ''
482
+ if (detectedType === 'auto') {
483
+ if (contentType.includes('presentation') || contentType.includes('powerpoint')) detectedType = 'pptx'
484
+ else if (contentType.includes('json')) detectedType = 'json'
485
+ else if (contentType.includes('xml')) detectedType = 'xml'
486
+ }
487
+
488
+ const downloadedBlob = await res.blob()
489
+ return await loadFile(downloadedBlob, {
490
+ ...options,
491
+ type: detectedType !== 'auto' ? detectedType as any : undefined,
492
+ title: customTitle || options?.title
493
+ })
494
+ }
495
+
496
+ if (detectedType === 'auto') {
497
+ if (trimmed.startsWith('<')) {
498
+ detectedType = 'xml'
499
+ } else if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
500
+ detectedType = 'json'
501
+ }
502
+ }
503
+ }
504
+
505
+ // 5. 探测普通 JS 对象
506
+ if (typeof source === 'object' && !(source instanceof Blob) && !(source instanceof ArrayBuffer) && detectedType === 'auto') {
507
+ detectedType = 'json'
508
+ }
509
+
510
+ // 兜底识别
511
+ if (detectedType === 'auto') {
512
+ detectedType = typeof source === 'string' ? 'json' : 'pptx'
513
+ }
514
+
515
+ if (customTitle) {
516
+ presentationTitle.value = customTitle
517
+ }
518
+
519
+ // 分发执行解析渲染
520
+ if (detectedType === 'pptx') {
521
+ let buffer: ArrayBuffer
522
+ if (source instanceof ArrayBuffer) {
523
+ buffer = source
524
+ } else if (typeof Blob !== 'undefined' && source instanceof Blob) {
525
+ buffer = await source.arrayBuffer()
526
+ } else {
527
+ throw new Error('PPTX 格式需要 File / Blob / ArrayBuffer 数据源')
528
+ }
529
+
530
+ if (!headerRef.value?.importPptxArrayBuffer) {
531
+ throw new Error('PPTX 解析器模块尚未就绪')
532
+ }
533
+
534
+ const success = await headerRef.value.importPptxArrayBuffer(buffer, customTitle)
535
+ if (!success) {
536
+ throw new Error('PPTX 解析导入失败')
537
+ }
538
+
539
+ const result: LoadSourceResult = {
540
+ success: true,
541
+ title: presentationTitle.value,
542
+ slidesCount: slides.value?.length || 0,
543
+ type: 'pptx'
544
+ }
545
+ emit('load', result)
546
+ return result
547
+ } else if (detectedType === 'json' || detectedType === 'deck') {
548
+ let data: any
549
+ if (typeof Blob !== 'undefined' && source instanceof Blob) {
550
+ const text = await source.text()
551
+ data = JSON.parse(text)
552
+ } else if (typeof source === 'string') {
553
+ data = JSON.parse(source)
554
+ } else {
555
+ data = source
556
+ }
557
+
558
+ if (uploadMediaResourcesInSlides) {
559
+ await uploadMediaResourcesInSlides(data)
560
+ }
561
+
562
+ if (Array.isArray(data)) {
563
+ setSlides(data)
564
+ } else if (data && typeof data === 'object' && Array.isArray(data.slides)) {
565
+ loadDeck(data)
566
+ } else {
567
+ throw new Error('无效的 JSON 幻灯片数据格式,缺少 slides 数组')
568
+ }
569
+
570
+ if (options?.notify !== false) {
571
+ notify('已成功导入幻灯片工程')
572
+ }
573
+
574
+ const result: LoadSourceResult = {
575
+ success: true,
576
+ title: presentationTitle.value,
577
+ slidesCount: slides.value?.length || 0,
578
+ type: 'json'
579
+ }
580
+ emit('load', result)
581
+ return result
582
+ } else if (detectedType === 'xml') {
583
+ let xmlText = ''
584
+ if (typeof Blob !== 'undefined' && source instanceof Blob) {
585
+ xmlText = await source.text()
586
+ } else if (typeof source === 'string') {
587
+ xmlText = source
588
+ } else {
589
+ throw new Error('XML 格式需要字符串或文本 Blob 数据源')
590
+ }
591
+
592
+ const parser = new DOMParser()
593
+ const doc = parser.parseFromString(xmlText, 'text/xml')
594
+ const parserError = doc.querySelector('parsererror')
595
+ if (parserError) {
596
+ throw new Error(`XML 语法解析错误: ${parserError.textContent}`)
597
+ }
598
+
599
+ const parsedSlides: Slide[] = []
600
+ const deckNode = doc.querySelector('deck') || doc.querySelector('presentation')
601
+ if (deckNode && deckNode.getAttribute('title')) {
602
+ presentationTitle.value = deckNode.getAttribute('title') || presentationTitle.value
603
+ }
604
+
605
+ const slideNodes = doc.querySelectorAll('slide')
606
+ if (slideNodes && slideNodes.length > 0) {
607
+ slideNodes.forEach((sNode, sIdx) => {
608
+ const title = sNode.querySelector('title')?.textContent || `幻灯片 ${sIdx + 1}`
609
+ const subtitle = sNode.querySelector('subtitle')?.textContent || ''
610
+ const background = sNode.getAttribute('background') || sNode.querySelector('background')?.textContent || '#ffffff'
611
+ const elements: any[] = []
612
+
613
+ const elemNodes = sNode.querySelectorAll('element, shape, text, item')
614
+ elemNodes.forEach((eNode, eIdx) => {
615
+ const type = eNode.getAttribute('type') || eNode.tagName.toLowerCase()
616
+ const content = eNode.textContent || ''
617
+ const left = eNode.getAttribute('left') || `${50 + eIdx * 20}px`
618
+ const top = eNode.getAttribute('top') || `${100 + eIdx * 40}px`
619
+ const width = eNode.getAttribute('width') || '400px'
620
+ const height = eNode.getAttribute('height') || '100px'
621
+
622
+ elements.push({
623
+ id: `el_xml_${Date.now()}_${eIdx}`,
624
+ type: type === 'shape' ? 'shape' : 'text',
625
+ content: content || '文本内容',
626
+ style: {
627
+ left,
628
+ top,
629
+ width,
630
+ height,
631
+ color: eNode.getAttribute('color') || '#000000',
632
+ fontSize: eNode.getAttribute('fontSize') || '20px'
633
+ }
634
+ })
635
+ })
636
+
637
+ parsedSlides.push({
638
+ id: `slide_xml_${Date.now()}_${sIdx}`,
639
+ title,
640
+ subtitle,
641
+ background,
642
+ bgType: 'color',
643
+ elements,
644
+ notes: sNode.querySelector('notes')?.textContent || '',
645
+ transition: 'none'
646
+ } as any)
647
+ })
648
+ } else {
649
+ const pSld = doc.querySelector('p\\:sld, sld')
650
+ if (pSld) {
651
+ const elements: any[] = []
652
+ const spList = doc.querySelectorAll('p\\:sp, sp')
653
+ spList.forEach((sp, spIdx) => {
654
+ const tNodes = sp.querySelectorAll('a\\:t, t')
655
+ let textVal = ''
656
+ tNodes.forEach(t => { textVal += t.textContent + ' ' })
657
+ textVal = textVal.trim()
658
+
659
+ const off = sp.querySelector('a\\:off, off')
660
+ const ext = sp.querySelector('a\\:ext, ext')
661
+ const xEmu = off ? parseInt(off.getAttribute('x') || '0', 10) : 0
662
+ const yEmu = off ? parseInt(off.getAttribute('y') || '0', 10) : 0
663
+ const cxEmu = ext ? parseInt(ext.getAttribute('cx') || '0', 10) : 0
664
+ const cyEmu = ext ? parseInt(ext.getAttribute('cy') || '0', 10) : 0
665
+
666
+ const left = `${Math.round(xEmu / 9525)}px`
667
+ const top = `${Math.round(yEmu / 9525)}px`
668
+ const width = cxEmu ? `${Math.round(cxEmu / 9525)}px` : '300px'
669
+ const height = cyEmu ? `${Math.round(cyEmu / 9525)}px` : '80px'
670
+
671
+ elements.push({
672
+ id: `el_xml_${Date.now()}_${spIdx}`,
673
+ type: 'text',
674
+ content: textVal || '形状元素',
675
+ style: {
676
+ left,
677
+ top,
678
+ width,
679
+ height,
680
+ color: '#1e293b',
681
+ fontSize: '18px'
682
+ }
683
+ })
684
+ })
685
+
686
+ parsedSlides.push({
687
+ id: `slide_xml_${Date.now()}`,
688
+ title: customTitle || 'XML 导入幻灯片',
689
+ subtitle: '',
690
+ background: '#ffffff',
691
+ bgType: 'color',
692
+ elements,
693
+ notes: '',
694
+ transition: 'none'
695
+ } as any)
696
+ } else {
697
+ throw new Error('未识别的 XML 幻灯片格式,请提供标准 deck/slide 或 p:sld 格式')
698
+ }
699
+ }
700
+
701
+ if (parsedSlides.length === 0) {
702
+ throw new Error('XML 解析成功,但未提取到有效幻灯片页面')
703
+ }
704
+
705
+ setSlides(parsedSlides)
706
+ if (options?.notify !== false) {
707
+ notify(`已成功从 XML 渲染 ${parsedSlides.length} 页幻灯片`)
708
+ }
709
+
710
+ const result: LoadSourceResult = {
711
+ success: true,
712
+ title: presentationTitle.value,
713
+ slidesCount: parsedSlides.length,
714
+ type: 'xml'
715
+ }
716
+ emit('load', result)
717
+ return result
718
+ } else {
719
+ throw new Error(`暂不支持的数据格式类型: ${detectedType}`)
720
+ }
721
+ }
722
+
723
+ // 监听外部传入的 modelValue
724
+ watch(
725
+ () => props.modelValue,
726
+ (val) => {
727
+ if (isInternalUpdating) return
728
+ if (!val) return
729
+ if (val === lastEmittedModelValue) return
730
+ loadDeck(val as any)
731
+ },
732
+ { immediate: true, deep: true }
733
+ )
734
+
735
+ // 对外 deck 同步防抖:打字/连续微编辑不再每次都整册 getDeck 深拷贝+emit,
736
+ // 300ms 静默后统一发一次;拖拽落沿/翻页/卸载时立即冲刷,宿主侧最终一致
737
+ let emitDeckTimer: ReturnType<typeof setTimeout> | null = null
738
+ let emitDeckPending = false
739
+
740
+ function emitDeckNow() {
741
+ if (emitDeckTimer) {
742
+ clearTimeout(emitDeckTimer)
743
+ emitDeckTimer = null
744
+ }
745
+ if (!emitDeckPending) return
746
+ if (isInternalUpdating) return // 保留 pending,下次调度/冲刷再试
747
+ emitDeckPending = false
748
+ isInternalUpdating = true
749
+ const currentDeck = getDeck()
750
+ lastEmittedModelValue = currentDeck
751
+ emit('update:modelValue', currentDeck)
752
+ emit('change', {
753
+ slides: slides.value,
754
+ activeIndex: activeIndex.value,
755
+ title: presentationTitle.value
756
+ })
757
+ nextTick(() => {
758
+ isInternalUpdating = false
759
+ })
760
+ }
761
+
762
+ function scheduleEmitDeck() {
763
+ if (isInternalUpdating) return
764
+ emitDeckPending = true
765
+ if (emitDeckTimer) clearTimeout(emitDeckTimer)
766
+ emitDeckTimer = setTimeout(() => {
767
+ emitDeckTimer = null
768
+ emitDeckNow()
769
+ }, 300)
770
+ }
771
+
772
+ // 监听内部 slides 变化,向外部同步(拖拽/变形交互期间冻结,避免高频触发几十毫秒的深拷贝序列化)
773
+ watch(
774
+ () => slides.value,
775
+ () => {
776
+ if (isInternalUpdating) return
777
+ if (isCanvasDragging?.value || pauseHistory?.value) return
778
+ scheduleEmitDeck()
779
+ },
780
+ { deep: true }
781
+ )
782
+
783
+ // 拖拽/变形结束后,立即冲刷防抖中的对外同步
784
+ watch([() => isCanvasDragging?.value, () => pauseHistory?.value], ([isDragging, isPaused], [prevDragging, prevPaused]) => {
785
+ if ((prevDragging && !isDragging) || (prevPaused && !isPaused)) {
786
+ if (!isDragging && !isPaused) {
787
+ emitDeckNow()
788
+ }
789
+ }
790
+ })
791
+
792
+ // 监听当前选中的幻灯片切换(翻页前冲刷防抖中的对外同步,宿主拿到的 deck 与页码一致)
793
+ watch(
794
+ () => activeIndex.value,
795
+ (idx) => {
796
+ emitDeckNow()
797
+ emit('slideChange', {
798
+ index: idx,
799
+ slide: activeSlide.value || null
800
+ })
801
+ }
802
+ )
803
+
804
+ // 公式编辑确认
805
+ function handleFormulaConfirm(latex: string) {
806
+ if (currentEditingFormulaEl.value) {
807
+ currentEditingFormulaEl.value.latex = latex
808
+ currentEditingFormulaEl.value.content = latex
809
+ notify('已更新公式')
810
+ currentEditingFormulaEl.value = null
811
+ } else {
812
+ addElement('text', latex, latex)
813
+ }
814
+ }
815
+
816
+ // 组装对外暴露的编程式 API 对象
817
+ // 卸载前冲刷防抖中的对外同步,避免最后 300ms 内的编辑丢失
818
+ onBeforeUnmount(() => {
819
+ emitDeckNow()
820
+ })
821
+
822
+ const editorApi: PptEditorApi = {
823
+ save,
824
+ getSaveData: () => getDeck(),
825
+ getSaveJsonBlob,
826
+ getSaveJsonFile,
827
+ getSavePptxBlob,
828
+ getSavePptxFile,
829
+ getSlides,
830
+ getDeck,
831
+ setSlides,
832
+ loadDeck,
833
+ loadFile,
834
+ importFile: loadFile,
835
+ getActiveSlide: () => activeSlide.value || null,
836
+ getActiveIndex: () => activeIndex.value,
837
+ selectSlide: (index: number) => selectSlide(index),
838
+ addSlide: () => addSlide(),
839
+ deleteSlide: (index?: number) => {
840
+ const targetIdx = typeof index === 'number' ? index : activeIndex.value
841
+ deleteSlide(targetIdx)
842
+ },
843
+ exportPptx,
844
+ exportJson,
845
+ exportImages,
846
+ undo,
847
+ redo,
848
+ canUndo,
849
+ canRedo,
850
+ startPresentation,
851
+ stopPresentation,
852
+ notify,
853
+ zoomIn,
854
+ zoomOut,
855
+ resetZoom,
856
+ setAiConfig,
857
+ isAiBeautifyEnabled,
858
+ setMaterialConfig,
859
+ isMaterialEnabled,
860
+ saveMaterial: (meta) => saveCurrentSlideToMaterial(meta),
861
+ setCustomUploadHandler,
862
+ getCustomUploadHandler,
863
+ slidesComposable: slidesState
864
+ }
865
+
866
+ // 获取当前插槽实例
867
+ const slots = useSlots()
868
+
869
+ // 顶部工具栏插槽作用域上下文数据
870
+ const headerSlotContext = computed(() => ({
871
+ api: editorApi,
872
+ deck: getDeck(),
873
+ slides: slidesState.slides.value,
874
+ activeSlide: slidesState.activeSlide.value || null,
875
+ activeIndex: slidesState.activeIndex.value,
876
+ title: presentationTitle.value,
877
+ setTitle: (newTitle: string) => {
878
+ presentationTitle.value = newTitle
879
+ },
880
+ save,
881
+ exportPptx,
882
+ exportJson,
883
+ exportImages,
884
+ undo,
885
+ redo,
886
+ canUndo: canUndo.value,
887
+ canRedo: canRedo.value,
888
+ zoomIn,
889
+ zoomOut,
890
+ resetZoom,
891
+ zoomRatio: slidesState.zoomRatio.value,
892
+ isMasterEditingView: slidesState.isMasterEditingView.value
893
+ }))
894
+
895
+ // 对外通过 ref 暴露所有方法
896
+ defineExpose(editorApi)
897
+
898
+ onMounted(async () => {
899
+ // 触发 ready 事件
900
+ emit('ready', editorApi)
901
+
902
+ // 若启用 MCP 自动拉取
903
+ if (props.enableMcp) {
904
+ const params = new URLSearchParams(window.location.search)
905
+ const deckId = params.get('deckId')
906
+ const deckPath = params.get('deck')
907
+ if (deckId || deckPath) {
908
+ try {
909
+ const query = deckId
910
+ ? `deckId=${encodeURIComponent(deckId)}`
911
+ : `path=${encodeURIComponent(deckPath || '')}`
912
+ const response = await fetch(`/__mcp_deck?${query}`)
913
+ if (!response.ok) {
914
+ throw new Error(await response.text())
915
+ }
916
+ const deck = await response.json()
917
+ loadDeck(deck)
918
+ notify('已从 MCP 打开可编辑稿')
919
+ } catch (error: any) {
920
+ notify(`打开 MCP 稿件失败:${error?.message || error}`)
921
+ }
922
+ }
923
+ }
924
+ })
925
+ </script>
926
+
927
+ <template>
928
+ <div class="app-container jvs-ppt-editor-root" :class="{ 'is-readonly': readonly }">
929
+ <!-- Main top Navigation bar (支持完整 header 插槽替换或精细区域插槽定制) -->
930
+ <slot name="header" v-bind="headerSlotContext">
931
+ <Header ref="headerRef" v-show="showHeader" :slot-props="headerSlotContext">
932
+ <!-- 左侧前置 (Logo、返回按钮等) -->
933
+ <template v-if="slots['header-left-prefix']" #left-prefix="sp">
934
+ <slot name="header-left-prefix" v-bind="sp"></slot>
935
+ </template>
936
+
937
+ <!-- 左侧主体(若外部传了 header-left,则覆盖默认汉堡菜单和标题) -->
938
+ <template v-if="slots['header-left']" #left="sp">
939
+ <slot name="header-left" v-bind="sp"></slot>
940
+ </template>
941
+
942
+ <!-- 左侧后置 (文档副标题、状态标签等) -->
943
+ <template v-if="slots['header-left-suffix']" #left-suffix="sp">
944
+ <slot name="header-left-suffix" v-bind="sp"></slot>
945
+ </template>
946
+
947
+ <!-- 中间工具栏主体(若外部传了 toolbar 或 header-center,则覆盖默认 Toolbar) -->
948
+ <template v-if="slots['toolbar'] || slots['header-center']" #center="sp">
949
+ <slot name="toolbar" v-bind="sp">
950
+ <slot name="header-center" v-bind="sp"></slot>
951
+ </slot>
952
+ </template>
953
+
954
+ <!-- 工具栏前置 (插入到撤销按钮前) -->
955
+ <template v-if="slots['toolbar-prefix']" #toolbar-prefix="sp">
956
+ <slot name="toolbar-prefix" v-bind="sp"></slot>
957
+ </template>
958
+
959
+ <!-- 工具栏后置 (插入到一键美化按钮后) -->
960
+ <template v-if="slots['toolbar-suffix']" #toolbar-suffix="sp">
961
+ <slot name="toolbar-suffix" v-bind="sp"></slot>
962
+ </template>
963
+
964
+ <!-- 右侧前置 (缩放/放映前) -->
965
+ <template v-if="slots['header-right-prefix']" #right-prefix="sp">
966
+ <slot name="header-right-prefix" v-bind="sp"></slot>
967
+ </template>
968
+
969
+ <!-- 右侧主体 (保存、导出、分享等自定义按钮) -->
970
+ <template v-if="slots['header-right']" #right="sp">
971
+ <slot name="header-right" v-bind="sp"></slot>
972
+ </template>
973
+
974
+ <!-- 右侧后置 (最右侧,放映按钮后) -->
975
+ <template v-if="slots['header-right-suffix']" #right-suffix="sp">
976
+ <slot name="header-right-suffix" v-bind="sp"></slot>
977
+ </template>
978
+ </Header>
979
+ </slot>
980
+
981
+ <!-- Main Workspace consisting of SidebarLeft, Center Canvas, and SidebarRight -->
982
+ <main class="app-workspace" :class="{ 'hide-right-sidebar': !activeTab || !showSidebarRight }">
983
+ <!-- 左侧幻灯片列表 -->
984
+ <SidebarLeft v-if="showSidebarLeft" class="sidebar-left" />
985
+
986
+ <!-- 中间主画布区 -->
987
+ <CanvasArea class="canvas-area" />
988
+
989
+ <!-- 右侧属性配置栏 -->
990
+ <SidebarRight v-if="showSidebarRight && !readonly" />
991
+ </main>
992
+
993
+ <!-- Fullscreen slide deck display panel -->
994
+ <PresentationMode v-if="isPresenting" />
995
+
996
+ <!-- Action feedback notifications banner -->
997
+ <ToastNotification />
998
+
999
+ <!-- Global LaTeX Formula Editor Dialog -->
1000
+ <FormulaDialog
1001
+ v-if="!readonly && showFormulaDialog"
1002
+ v-model:visible="formulaDialogVisible"
1003
+ :initialLatex="currentEditingFormulaEl?.latex || ''"
1004
+ @confirm="handleFormulaConfirm"
1005
+ />
1006
+
1007
+ <!-- Global DSL JSON Input Dialog -->
1008
+ <DslInputDialog
1009
+ v-if="!readonly && showDslInputDialog"
1010
+ v-model:visible="dslInputDialogVisible"
1011
+ @confirm="importSlides"
1012
+ />
1013
+
1014
+ <!-- Global AI PPT Streaming Dialog -->
1015
+ <AIPptDialog
1016
+ v-if="!readonly && showAiPptDialog"
1017
+ v-model:visible="aiPptDialogVisible"
1018
+ />
1019
+
1020
+ <!-- Global Unsplash AI Image Search & Insertion Dialog -->
1021
+ <UnsplashImageDialog v-if="!readonly && showUnsplashDialog" />
1022
+ </div>
1023
+ </template>
1024
+
1025
+ <style scoped>
1026
+ .jvs-ppt-editor-root {
1027
+ width: 100%;
1028
+ height: 100%;
1029
+ position: relative;
1030
+ overflow: hidden;
1031
+ display: flex;
1032
+ flex-direction: column;
1033
+ }
1034
+
1035
+ /* 只读模式下的禁用样式微调 */
1036
+ .jvs-ppt-editor-root.is-readonly :deep(.action-btn-disabled) {
1037
+ pointer-events: none;
1038
+ opacity: 0.6;
1039
+ }
1040
+ </style>