jvs-ppt-ui 0.0.7 → 0.0.9

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