@lemonppt/renderer 0.2.0 → 0.2.2

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.
Files changed (72) hide show
  1. package/dist/client/client-render.js +65 -5401
  2. package/dist/client/editor-script.js +3355 -0
  3. package/dist/client/theme-echarts.js +65 -25
  4. package/dist/client-render.d.ts +1 -7
  5. package/dist/client-render.d.ts.map +1 -1
  6. package/dist/client-render.js +1 -11
  7. package/dist/client-render.js.map +1 -1
  8. package/dist/editor-script.d.ts +1 -1
  9. package/dist/editor-script.d.ts.map +1 -1
  10. package/dist/editor-script.js +80 -77
  11. package/dist/editor-script.js.map +1 -1
  12. package/dist/export-pdf.js +1 -1
  13. package/dist/export-pdf.js.map +1 -1
  14. package/dist/export-pptx.d.ts +1132 -2
  15. package/dist/export-pptx.d.ts.map +1 -1
  16. package/dist/export-pptx.js +1208 -19525
  17. package/dist/export-pptx.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/render-editor-data.d.ts +21 -0
  23. package/dist/render-editor-data.d.ts.map +1 -0
  24. package/dist/render-editor-data.js +1068 -0
  25. package/dist/render-editor-data.js.map +1 -0
  26. package/dist/render.d.ts +0 -2
  27. package/dist/render.d.ts.map +1 -1
  28. package/dist/render.js +7 -323
  29. package/dist/render.js.map +1 -1
  30. package/dist/theme01-pptx.d.ts +12 -0
  31. package/dist/theme01-pptx.d.ts.map +1 -0
  32. package/dist/theme01-pptx.js +2954 -0
  33. package/dist/theme01-pptx.js.map +1 -0
  34. package/dist/theme02-pptx.d.ts +5 -0
  35. package/dist/theme02-pptx.d.ts.map +1 -0
  36. package/dist/theme02-pptx.js +1063 -0
  37. package/dist/theme02-pptx.js.map +1 -0
  38. package/dist/theme03-pptx.d.ts +5 -0
  39. package/dist/theme03-pptx.d.ts.map +1 -0
  40. package/dist/theme03-pptx.js +3279 -0
  41. package/dist/theme03-pptx.js.map +1 -0
  42. package/dist/theme04-pptx.d.ts +6 -0
  43. package/dist/theme04-pptx.d.ts.map +1 -0
  44. package/dist/theme04-pptx.js +5496 -0
  45. package/dist/theme04-pptx.js.map +1 -0
  46. package/dist/theme05-pptx.d.ts +5 -0
  47. package/dist/theme05-pptx.d.ts.map +1 -0
  48. package/dist/theme05-pptx.js +3152 -0
  49. package/dist/theme05-pptx.js.map +1 -0
  50. package/dist/theme06-pptx.d.ts +124 -0
  51. package/dist/theme06-pptx.d.ts.map +1 -0
  52. package/dist/theme06-pptx.js +8154 -0
  53. package/dist/theme06-pptx.js.map +1 -0
  54. package/dist/theme07-pptx.d.ts +49 -0
  55. package/dist/theme07-pptx.d.ts.map +1 -0
  56. package/dist/theme07-pptx.js +2579 -0
  57. package/dist/theme07-pptx.js.map +1 -0
  58. package/dist/theme08-pptx.d.ts +19 -0
  59. package/dist/theme08-pptx.d.ts.map +1 -0
  60. package/dist/theme08-pptx.js +1411 -0
  61. package/dist/theme08-pptx.js.map +1 -0
  62. package/dist/theme09-pptx.d.ts +18 -0
  63. package/dist/theme09-pptx.d.ts.map +1 -0
  64. package/dist/theme09-pptx.js +1513 -0
  65. package/dist/theme09-pptx.js.map +1 -0
  66. package/dist/theme10-pptx.d.ts +18 -0
  67. package/dist/theme10-pptx.d.ts.map +1 -0
  68. package/dist/theme10-pptx.js +2889 -0
  69. package/dist/theme10-pptx.js.map +1 -0
  70. package/package.json +7 -6
  71. package/templates/editor.html +580 -0
  72. package/templates/editor.js +222 -0
@@ -0,0 +1,3355 @@
1
+ (function () {
2
+ // 用 jQuery 为所有带 data-scrollbar 的容器添加自定义滚动条样式类
3
+ if (typeof jQuery !== 'undefined') {
4
+ jQuery(function ($) {
5
+ $('[data-scrollbar]').addClass('lp-custom-scrollbar');
6
+ });
7
+ }
8
+
9
+ const MIGRATION_KEY = 'lemonppt:editor:migration:showInsight:v1';
10
+ const MAX_HISTORY = 50;
11
+
12
+ let goal = window.__lemonPPT_goal;
13
+ if (!goal) return;
14
+
15
+ function getStorageKey() {
16
+ return 'lemonppt:editor:v2:' + (goal.theme || 'theme01') + ':' + (goal.randomSeed || goal.title || 'default');
17
+ }
18
+
19
+ // 优先从 localStorage 恢复
20
+ let needsDomRebuild = false;
21
+ try {
22
+ const saved = localStorage.getItem(getStorageKey());
23
+ if (saved) {
24
+ const parsed = JSON.parse(saved);
25
+ if (parsed && parsed.slides) {
26
+ // 一次性迁移:将图表页的重点强调开关重置为默认开启
27
+ if (!localStorage.getItem(MIGRATION_KEY)) {
28
+ parsed.slides.forEach((slide) => {
29
+ if (slide.role === 'chart') {
30
+ slide.props = slide.props || {};
31
+ slide.props.showInsight = true;
32
+ }
33
+ });
34
+ localStorage.setItem(MIGRATION_KEY, '1');
35
+ }
36
+ // 若结构数量或幻灯片顺序发生变化,需要重建 DOM;仅内容变化则同步文本即可
37
+ const orderChanged = parsed.slides.length !== goal.slides.length ||
38
+ parsed.slides.some((slide, i) => slide.layout !== goal.slides[i].layout);
39
+ goal = parsed;
40
+ window.__lemonPPT_goal = goal;
41
+ if (orderChanged) {
42
+ needsDomRebuild = true;
43
+ } else {
44
+ syncDomFromGoal();
45
+ }
46
+ }
47
+ }
48
+ } catch (err) {
49
+ console.warn('自动恢复失败', err);
50
+ }
51
+
52
+ // 初始化外观模式(同一主题下的浅色/深色切换,通过 data-theme / data-appearance 切换 CSS 变量)
53
+ syncAppearanceFromGoal();
54
+
55
+ const TRANSITIONS = [
56
+ { key: 'none', label: '无动画' },
57
+ { key: 'slide', label: '横滑' },
58
+ { key: 'fade', label: '淡入淡出' },
59
+ { key: 'scale', label: '缩放' },
60
+ { key: 'cut-in', label: '切入' },
61
+ { key: 'cube', label: '立方体' },
62
+ { key: 'flip', label: '翻页' },
63
+ { key: 'cover', label: '覆盖' },
64
+ { key: 'wipe', label: '擦除' },
65
+ ];
66
+
67
+ const history = [deepClone(goal)];
68
+ let historyIndex = 0;
69
+
70
+ function deepClone(obj) {
71
+ return JSON.parse(JSON.stringify(obj));
72
+ }
73
+
74
+ function recordHistory() {
75
+ // 截断 redo 分支
76
+ if (historyIndex < history.length - 1) {
77
+ history.splice(historyIndex + 1);
78
+ }
79
+ history.push(deepClone(goal));
80
+ if (history.length > MAX_HISTORY) {
81
+ history.shift();
82
+ } else {
83
+ historyIndex++;
84
+ }
85
+ updateUndoRedoButtons();
86
+ }
87
+
88
+ function undo() {
89
+ if (historyIndex <= 0) return;
90
+ historyIndex--;
91
+ restoreGoal(history[historyIndex]);
92
+ }
93
+
94
+ function redo() {
95
+ if (historyIndex >= history.length - 1) return;
96
+ historyIndex++;
97
+ restoreGoal(history[historyIndex]);
98
+ }
99
+
100
+ function restoreGoal(newGoal) {
101
+ const oldSlides = goal && goal.slides ? goal.slides : [];
102
+ goal = newGoal;
103
+ window.__lemonPPT_goal = goal;
104
+ Object.assign(window.__lemonPPT_goal, newGoal);
105
+ const orderChanged = oldSlides.length !== goal.slides.length ||
106
+ oldSlides.some((slide, i) => slide.layout !== goal.slides[i].layout);
107
+ if (orderChanged) {
108
+ current = 0;
109
+ selectedSlideIdx = 0;
110
+ rebuildSlidesAndThumbnails();
111
+ if (typeof selectSlide === 'function') selectSlide(current);
112
+ } else {
113
+ syncDomFromGoal();
114
+ }
115
+ syncAppearanceFromGoal();
116
+ autoSave();
117
+ updateUndoRedoButtons();
118
+ }
119
+
120
+ let autoSaveTimer = null;
121
+ function autoSave() {
122
+ if (autoSaveTimer) {
123
+ clearTimeout(autoSaveTimer);
124
+ }
125
+ autoSaveTimer = setTimeout(() => {
126
+ autoSaveTimer = null;
127
+ try {
128
+ localStorage.setItem(getStorageKey(), JSON.stringify(goal));
129
+ } catch (err) {
130
+ console.warn('自动保存失败', err);
131
+ }
132
+ }, 400);
133
+ }
134
+
135
+ // 获取可编辑元素;兼容 Text 节点、Shadow DOM 等场景
136
+ function getEditingElement(target) {
137
+ if (!target) return null;
138
+ if (target.nodeType === Node.TEXT_NODE) {
139
+ target = target.parentElement;
140
+ }
141
+ if (!target || typeof target.closest !== 'function') return null;
142
+ return target.closest('[data-lp-editable="true"], [contenteditable="true"], input, textarea, select');
143
+ }
144
+
145
+ function isEditingTarget(target) {
146
+ return !!getEditingElement(target);
147
+ }
148
+
149
+ function propMatchesChangedPath(prop, changedPath) {
150
+ if (!changedPath) return true;
151
+ return prop === changedPath || prop.startsWith(changedPath + '.') || changedPath.startsWith(prop + '.');
152
+ }
153
+
154
+ function syncDomFromGoal(changedPath) {
155
+ // 文本:如果指定了 changedPath,只同步受影响的路径,避免覆盖 React 渲染的特殊结构(如标题高亮)
156
+ document.querySelectorAll('[data-lp-editable="true"]').forEach((el) => {
157
+ const slideIdx = Number(el.getAttribute('data-lp-slide-idx'));
158
+ const prop = el.getAttribute('data-lp-prop');
159
+ if (Number.isNaN(slideIdx) || !prop) return;
160
+ if (!propMatchesChangedPath(prop, changedPath)) return;
161
+ const slide = goal.slides[slideIdx];
162
+ if (!slide) return;
163
+ const value = getProp(slide.props, prop);
164
+ el.textContent = value == null ? '' : String(value);
165
+ });
166
+
167
+ // 图片
168
+ document.querySelectorAll('[data-lp-editable-image="true"]').forEach((img) => {
169
+ const slideIdx = Number(img.getAttribute('data-lp-slide-idx'));
170
+ const prop = img.getAttribute('data-lp-prop') || 'image';
171
+ if (Number.isNaN(slideIdx)) return;
172
+ if (!propMatchesChangedPath(prop, changedPath)) return;
173
+ const slide = goal.slides[slideIdx];
174
+ if (!slide) return;
175
+ const value = getProp(slide.props, prop);
176
+ if (value) {
177
+ if (img.tagName === 'IMG') {
178
+ img.setAttribute('src', String(value));
179
+ } else {
180
+ // 占位容器需要替换为真实图片
181
+ const newImg = document.createElement('img');
182
+ newImg.className = img.className.replace(/\blp-editable-image-placeholder\b/g, '').trim();
183
+ newImg.src = String(value);
184
+ newImg.alt = img.getAttribute('alt') || '';
185
+ newImg.setAttribute('data-lp-editable-image', 'true');
186
+ newImg.setAttribute('data-lp-slide-idx', String(slideIdx));
187
+ newImg.setAttribute('data-lp-prop', prop);
188
+ img.parentNode.replaceChild(newImg, img);
189
+ }
190
+ }
191
+ // 无图片时保留占位容器,确保仍可点击上传
192
+ });
193
+ }
194
+
195
+ function getProp(obj, path) {
196
+ const keys = path.split('.');
197
+ let target = obj;
198
+ for (const k of keys) {
199
+ if (target == null || typeof target !== 'object') return undefined;
200
+ target = target[k];
201
+ }
202
+ return target;
203
+ }
204
+
205
+ function setProp(obj, path, value) {
206
+ const keys = path.split('.');
207
+ let target = obj;
208
+ for (let i = 0; i < keys.length - 1; i++) {
209
+ const k = keys[i];
210
+ if (!(k in target)) target[k] = [];
211
+ target = target[k];
212
+ }
213
+ target[keys[keys.length - 1]] = value;
214
+ }
215
+
216
+ // 点击图片固定区域直接上传本地图片
217
+ function handleImageUpload(target, slideIdx, prop) {
218
+ const slide = goal.slides[slideIdx];
219
+ if (!slide) return;
220
+
221
+ const input = document.createElement('input');
222
+ input.type = 'file';
223
+ input.accept = 'image/*';
224
+ input.style.display = 'none';
225
+ document.body.appendChild(input);
226
+
227
+ input.addEventListener('change', () => {
228
+ const file = input.files?.[0];
229
+ if (!file) {
230
+ input.remove();
231
+ return;
232
+ }
233
+ const reader = new FileReader();
234
+ reader.onload = (e) => {
235
+ const dataUrl = e.target?.result;
236
+ if (typeof dataUrl !== 'string') {
237
+ input.remove();
238
+ return;
239
+ }
240
+ recordHistory();
241
+ setProp(slide.props, prop, dataUrl);
242
+ autoSave();
243
+ renderCurrentSlideToRoot();
244
+ input.remove();
245
+ };
246
+ reader.readAsDataURL(file);
247
+ });
248
+
249
+ input.addEventListener('cancel', () => input.remove());
250
+ input.click();
251
+ }
252
+
253
+ function escapeHtml(str) {
254
+ return String(str)
255
+ .replace(/&/g, '&amp;')
256
+ .replace(/</g, '&lt;')
257
+ .replace(/>/g, '&gt;')
258
+ .replace(/"/g, '&quot;')
259
+ .replace(/'/g, '&#39;');
260
+ }
261
+
262
+ // 初始化 contenteditable 属性;使用 MutationObserver 监听 React 重新渲染后新增的编辑元素
263
+ function initEditableElements(container) {
264
+ const scope = container || document;
265
+ scope.querySelectorAll('[data-lp-editable="true"]').forEach((el) => {
266
+ if (el.getAttribute('contenteditable') !== 'true') {
267
+ el.setAttribute('contenteditable', 'true');
268
+ }
269
+ });
270
+ }
271
+ initEditableElements();
272
+
273
+ const editableObserver = new MutationObserver((mutations) => {
274
+ mutations.forEach((mutation) => {
275
+ mutation.addedNodes.forEach((node) => {
276
+ if (node.nodeType === Node.ELEMENT_NODE) {
277
+ initEditableElements(node);
278
+ }
279
+ });
280
+ });
281
+ });
282
+ editableObserver.observe(document.body, { childList: true, subtree: true });
283
+
284
+ // 使用事件委托处理文本编辑,支持 React 局部渲染后自动生效
285
+ document.addEventListener('focus', (e) => {
286
+ const el = e.target.closest('[data-lp-editable="true"]');
287
+ if (!el) return;
288
+ recordHistory();
289
+ }, true);
290
+
291
+ document.addEventListener('blur', (e) => {
292
+ const el = e.target.closest('[data-lp-editable="true"]');
293
+ if (!el) return;
294
+ const slideIdx = Number(el.getAttribute('data-lp-slide-idx'));
295
+ const prop = el.getAttribute('data-lp-prop');
296
+ if (Number.isNaN(slideIdx) || !prop) return;
297
+
298
+ const slide = goal.slides[slideIdx];
299
+ if (!slide) return;
300
+
301
+ const rawValue = el.textContent || '';
302
+ const fieldType = el.getAttribute('data-lp-editable-type');
303
+ const value = fieldType === 'number' ? Number(rawValue) : rawValue;
304
+ setProp(slide.props, prop, value);
305
+ autoSave();
306
+ // 图表数据字段在画布上直接编辑后,需要重新渲染幻灯片以更新图表
307
+ if (el.getAttribute('data-lp-chart-data') === 'true' && typeof window.__lemonPPT_initECharts === 'function') {
308
+ refreshCurrentSlide();
309
+ }
310
+ }, true);
311
+
312
+ document.addEventListener('keydown', (e) => {
313
+ const el = getEditingElement(e.target) || getEditingElement(document.activeElement);
314
+ if (!el) return;
315
+ if (e.key === 'Enter') {
316
+ e.preventDefault();
317
+ el.blur();
318
+ }
319
+ });
320
+
321
+ // 图片换图:点击固定区域直接唤起文件选择器上传
322
+ document.addEventListener('click', (e) => {
323
+ const target = e.target.closest('[data-lp-editable-image="true"]');
324
+ if (!target) return;
325
+ const slideIdx = Number(target.getAttribute('data-lp-slide-idx'));
326
+ const prop = target.getAttribute('data-lp-prop') || 'image';
327
+ if (Number.isNaN(slideIdx)) return;
328
+ handleImageUpload(target, slideIdx, prop);
329
+ });
330
+
331
+ const exportToggle = document.getElementById('lp-export-toggle');
332
+ const exportMenu = document.getElementById('lp-export-menu');
333
+ const exportWrapper = document.getElementById('lp-editor-export');
334
+ if (exportToggle && exportMenu) {
335
+ exportToggle.addEventListener('click', (e) => {
336
+ e.stopPropagation();
337
+ const hidden = exportMenu.hasAttribute('hidden');
338
+ if (hidden) {
339
+ exportMenu.removeAttribute('hidden');
340
+ exportToggle.setAttribute('aria-expanded', 'true');
341
+ } else {
342
+ exportMenu.setAttribute('hidden', '');
343
+ exportToggle.setAttribute('aria-expanded', 'false');
344
+ }
345
+ });
346
+ document.addEventListener('click', (e) => {
347
+ if (!exportWrapper || exportWrapper.contains(e.target)) return;
348
+ exportMenu.setAttribute('hidden', '');
349
+ exportToggle.setAttribute('aria-expanded', 'false');
350
+ });
351
+ }
352
+
353
+ const exportHtmlBtn = document.getElementById('lp-export-html');
354
+ if (exportHtmlBtn) {
355
+ exportHtmlBtn.addEventListener('click', () => {
356
+ const html = '<!DOCTYPE html>\\n' + document.documentElement.outerHTML;
357
+ const blob = new Blob([html], { type: 'text/html' });
358
+ const url = URL.createObjectURL(blob);
359
+ const a = document.createElement('a');
360
+ a.href = url;
361
+ a.download = 'presentation.html';
362
+ a.click();
363
+ URL.revokeObjectURL(url);
364
+ if (exportMenu) exportMenu.setAttribute('hidden', '');
365
+ });
366
+ }
367
+
368
+ const exportPptxBtn = document.getElementById('lp-export-pptx');
369
+ if (exportPptxBtn) {
370
+ const pptxLabel = exportPptxBtn.querySelector('.lp-editor-export-label');
371
+ exportPptxBtn.addEventListener('click', async () => {
372
+ if (window.location.protocol === 'file:') {
373
+ alert('静态文件模式下无法直接导出 PPTX。\\n\\n请通过本地服务器访问后导出:\\n pnpm dev\\n或运行:\\n node scripts/export-pptx.mjs <goal.json> out.pptx');
374
+ if (exportMenu) exportMenu.setAttribute('hidden', '');
375
+ return;
376
+ }
377
+ if (pptxLabel) pptxLabel.textContent = '导出中...';
378
+ exportPptxBtn.disabled = true;
379
+ try {
380
+ const res = await fetch('/api/export/pptx', {
381
+ method: 'POST',
382
+ headers: { 'Content-Type': 'application/json' },
383
+ body: JSON.stringify(goal),
384
+ });
385
+ if (!res.ok) throw new Error('导出失败: ' + res.status);
386
+ const blob = await res.blob();
387
+ const url = URL.createObjectURL(blob);
388
+ const a = document.createElement('a');
389
+ a.href = url;
390
+ a.download = 'presentation.pptx';
391
+ a.click();
392
+ URL.revokeObjectURL(url);
393
+ } catch (err) {
394
+ alert(err instanceof Error ? err.message : String(err));
395
+ } finally {
396
+ if (pptxLabel) pptxLabel.textContent = 'PPTX';
397
+ exportPptxBtn.disabled = false;
398
+ if (exportMenu) exportMenu.setAttribute('hidden', '');
399
+ }
400
+ });
401
+ }
402
+
403
+ const exportPdfBtn = document.getElementById('lp-export-pdf');
404
+ if (exportPdfBtn) {
405
+ const pdfLabel = exportPdfBtn.querySelector('.lp-editor-export-label');
406
+ exportPdfBtn.addEventListener('click', async () => {
407
+ if (window.location.protocol === 'file:') {
408
+ alert('静态文件模式下无法直接导出 PDF。\\n\\n请通过本地服务器访问后导出:\\n pnpm dev\\n或运行:\\n node scripts/export-pdf.mjs <goal.json> out.pdf');
409
+ if (exportMenu) exportMenu.setAttribute('hidden', '');
410
+ return;
411
+ }
412
+ if (pdfLabel) pdfLabel.textContent = '导出中...';
413
+ exportPdfBtn.disabled = true;
414
+ try {
415
+ const res = await fetch('/api/export/pdf', {
416
+ method: 'POST',
417
+ headers: { 'Content-Type': 'application/json' },
418
+ body: JSON.stringify(goal),
419
+ });
420
+ if (!res.ok) throw new Error('导出失败: ' + res.status);
421
+ const blob = await res.blob();
422
+ const url = URL.createObjectURL(blob);
423
+ const a = document.createElement('a');
424
+ a.href = url;
425
+ a.download = 'presentation.pdf';
426
+ a.click();
427
+ URL.revokeObjectURL(url);
428
+ } catch (err) {
429
+ alert(err instanceof Error ? err.message : String(err));
430
+ } finally {
431
+ if (pdfLabel) pdfLabel.textContent = 'PDF';
432
+ exportPdfBtn.disabled = false;
433
+ if (exportMenu) exportMenu.setAttribute('hidden', '');
434
+ }
435
+ });
436
+ }
437
+
438
+ // 撤销 / 重做
439
+ const undoBtn = document.getElementById('lp-undo');
440
+ const redoBtn = document.getElementById('lp-redo');
441
+
442
+ function updateUndoRedoButtons() {
443
+ if (undoBtn) undoBtn.disabled = historyIndex <= 0;
444
+ if (redoBtn) redoBtn.disabled = historyIndex >= history.length - 1;
445
+ }
446
+
447
+ if (undoBtn) undoBtn.addEventListener('click', undo);
448
+ if (redoBtn) redoBtn.addEventListener('click', redo);
449
+
450
+ const saveDeckBtn = document.getElementById('lp-save-deck');
451
+ if (saveDeckBtn) {
452
+ saveDeckBtn.addEventListener('click', () => {
453
+ try {
454
+ localStorage.setItem(getStorageKey(), JSON.stringify(goal));
455
+ const original = saveDeckBtn.textContent;
456
+ saveDeckBtn.textContent = '已保存';
457
+ saveDeckBtn.disabled = true;
458
+ setTimeout(() => {
459
+ saveDeckBtn.textContent = original;
460
+ saveDeckBtn.disabled = false;
461
+ }, 1200);
462
+ } catch (err) {
463
+ console.warn('手动保存失败', err);
464
+ alert('保存失败,请检查浏览器存储权限。');
465
+ }
466
+ });
467
+ }
468
+
469
+ function syncAppearanceFromGoal() {
470
+ const t = goal.theme || 'theme01';
471
+ if (t === 'theme04' || t === 'theme05' || t === 'theme06' || t === 'theme07' || t === 'theme08' || t === 'theme09' || t === 'theme10') {
472
+ const scheme = goal.colorScheme || (t === 'theme05' ? 'coral' : t === 'theme06' ? 'volt' : t === 'theme07' ? 'cold-white' : t === 'theme08' ? 'obsidian-gold' : t === 'theme09' ? 'ink-editorial' : t === 'theme10' ? 'gold-index' : 'green');
473
+ const rawAppearance = goal.appearance || (t === 'theme07' ? 'light' : t === 'theme08' || t === 'theme09' || t === 'theme10' ? 'primary' : 'dark');
474
+ /* theme08 / theme09 归一化:light→muted / dark→primary */
475
+ const appearance = (t === 'theme08' || t === 'theme09') ? ((rawAppearance === 'light' || rawAppearance === 'muted') ? 'muted' : 'primary') : rawAppearance;
476
+ document.documentElement.setAttribute('data-theme', scheme);
477
+ document.documentElement.setAttribute('data-appearance', appearance);
478
+ } else if (t === 'theme03') {
479
+ const scheme = goal.colorScheme || 'scheme-a';
480
+ const appearance = goal.appearance || 'dark';
481
+ document.documentElement.setAttribute('data-theme', scheme);
482
+ document.documentElement.setAttribute('data-appearance', appearance);
483
+ } else {
484
+ const scheme = goal.colorScheme || (t === 'theme02' ? 'scheme-a' : 'light');
485
+ document.documentElement.setAttribute('data-theme', scheme);
486
+ document.documentElement.removeAttribute('data-appearance');
487
+ }
488
+
489
+ document.querySelectorAll('.lp-appearance-btn').forEach((btn) => {
490
+ const appearanceValue = btn.getAttribute('data-appearance');
491
+ const themeValue = btn.getAttribute('data-theme');
492
+ if (!appearanceValue && !themeValue) return;
493
+
494
+ let active = false;
495
+ if (t === 'theme04' || t === 'theme05' || t === 'theme06' || t === 'theme07' || t === 'theme08' || t === 'theme09' || t === 'theme10') {
496
+ if (themeValue) {
497
+ active = themeValue === (goal.colorScheme || (t === 'theme05' ? 'coral' : t === 'theme06' ? 'volt' : t === 'theme07' ? 'cold-white' : t === 'theme08' ? 'obsidian-gold' : t === 'theme09' ? 'ink-editorial' : t === 'theme10' ? 'gold-index' : 'green'));
498
+ } else if (appearanceValue) {
499
+ const rawGoal = goal.appearance || (t === 'theme07' ? 'light' : t === 'theme08' || t === 'theme09' || t === 'theme10' ? 'primary' : 'dark');
500
+ const normGoal = (t === 'theme08' || t === 'theme09') ? ((rawGoal === 'light' || rawGoal === 'muted') ? 'muted' : 'primary') : rawGoal;
501
+ const normBtn = (t === 'theme08' || t === 'theme09') ? ((appearanceValue === 'light' || appearanceValue === 'muted') ? 'muted' : 'primary') : appearanceValue;
502
+ active = normBtn === normGoal;
503
+ }
504
+ } else if (t === 'theme03') {
505
+ active = appearanceValue === (goal.appearance || 'dark');
506
+ } else {
507
+ active = appearanceValue === (goal.colorScheme || (t === 'theme02' ? 'scheme-a' : 'light'));
508
+ }
509
+ btn.classList.toggle('lp-appearance-active', active);
510
+ btn.setAttribute('aria-pressed', String(active));
511
+ });
512
+ }
513
+
514
+ function applyAppearanceClientSide(newMode) {
515
+ const t = goal.theme || 'theme01';
516
+
517
+ if (t === 'theme04' || t === 'theme05' || t === 'theme06' || t === 'theme07' || t === 'theme08' || t === 'theme09' || t === 'theme10') {
518
+ /* theme08 / theme09 归一化:light→muted / dark→primary */
519
+ const normMode = (t === 'theme08' || t === 'theme09') ? ((newMode === 'light' || newMode === 'muted') ? 'muted' : 'primary') : newMode;
520
+ const curNorm = (t === 'theme08' || t === 'theme09') ? ((goal.appearance === 'light' || goal.appearance === 'muted') ? 'muted' : 'primary') : goal.appearance;
521
+ if (normMode === curNorm) return;
522
+ recordHistory();
523
+ goal.appearance = newMode;
524
+ autoSave();
525
+ document.documentElement.setAttribute('data-appearance', newMode);
526
+ } else if (t === 'theme03') {
527
+ if (newMode === goal.appearance) return;
528
+ recordHistory();
529
+ goal.appearance = newMode;
530
+ autoSave();
531
+ document.documentElement.setAttribute('data-appearance', newMode);
532
+ } else {
533
+ if (newMode === goal.colorScheme) return;
534
+ recordHistory();
535
+ goal.colorScheme = newMode;
536
+ autoSave();
537
+ document.documentElement.setAttribute('data-theme', newMode);
538
+ }
539
+
540
+ syncAppearanceFromGoal();
541
+
542
+ // 切换外观后 CSS 变量值变化,释放全部实例并重新初始化当前 active slide 的 ECharts。
543
+ if (typeof window.__lemonPPT_disposeECharts === 'function') {
544
+ window.__lemonPPT_disposeECharts();
545
+ }
546
+ initActiveSlideECharts();
547
+ }
548
+
549
+ function applyToneClientSide(newTone) {
550
+ const t = goal.theme || 'theme01';
551
+ if (t !== 'theme04' && t !== 'theme05' && t !== 'theme06') return;
552
+ if (newTone === goal.colorScheme) return;
553
+ recordHistory();
554
+ goal.colorScheme = newTone;
555
+ autoSave();
556
+ document.documentElement.setAttribute('data-theme', newTone);
557
+ syncAppearanceFromGoal();
558
+ if (typeof window.__lemonPPT_disposeECharts === 'function') {
559
+ window.__lemonPPT_disposeECharts();
560
+ }
561
+ initActiveSlideECharts();
562
+ }
563
+
564
+ // 外观模式切换(浅色 / 深色 / 主题 04 色调)
565
+ document.querySelectorAll('.lp-appearance-btn').forEach((btn) => {
566
+ btn.addEventListener('click', () => {
567
+ const appearance = btn.getAttribute('data-appearance');
568
+ const tone = btn.getAttribute('data-theme');
569
+ if (tone) applyToneClientSide(tone);
570
+ if (appearance) applyAppearanceClientSide(appearance);
571
+ });
572
+ });
573
+
574
+ // 添加幻灯片
575
+ function createDefaultSlide(layoutId, role) {
576
+ const slide = { layout: layoutId, role, props: { title: '新幻灯片' } };
577
+ switch (role) {
578
+ case 'cover': {
579
+ if (layoutId === 'theme02_cover_v1') {
580
+ slide.props = {
581
+ kicker: '霓虹主题演示',
582
+ title: 'Neon Pitch',
583
+ subtitle: '深色背景 + 双配色方案 + 科技感版式',
584
+ date: '2026.07'
585
+ };
586
+ } else {
587
+ slide.props = { title: '封面标题', subtitle: '副标题' };
588
+ }
589
+ break;
590
+ }
591
+ case 'closing': {
592
+ if (layoutId === 'theme01_conclusion_v1') {
593
+ slide.props = {
594
+ title: '核心结论',
595
+ subtitle: 'CONCLUSION',
596
+ points: [
597
+ { item: 'AI 演示生成已迈入实用阶段' },
598
+ { item: '主题系统与布局引擎是护城河' },
599
+ { item: '开放生态将加速行业落地' }
600
+ ]
601
+ };
602
+ } else if (layoutId === 'theme02_closing_v1') {
603
+ slide.props = {
604
+ kicker: '感谢观看',
605
+ title: "Let's Build Neon",
606
+ subtitle: '用深色霓虹风格点亮下一场演示',
607
+ cta: '开始使用',
608
+ contact: '柠檬团队',
609
+ email: 'hello@lemonppt.dev',
610
+ link: 'lemonppt.dev'
611
+ };
612
+ } else {
613
+ slide.props = {
614
+ kicker: '感谢观看',
615
+ title: '期待与您合作',
616
+ subtitle: '有任何问题,欢迎随时交流',
617
+ cta: '联系我们',
618
+ contact: '柠檬团队',
619
+ email: 'hello@lemonppt.dev',
620
+ link: 'lemonppt.dev'
621
+ };
622
+ }
623
+ break;
624
+ }
625
+ case 'quote': {
626
+ if (layoutId === 'theme02_quote_v1') {
627
+ slide.props = {
628
+ quote: '设计的本质是解决问题,而好的设计是让复杂变得简单。',
629
+ author: '柠檬团队',
630
+ role: '产品理念'
631
+ };
632
+ } else {
633
+ slide.props = {
634
+ quote: '简洁是终极的复杂。',
635
+ author: '莱昂纳多·达·芬奇'
636
+ };
637
+ }
638
+ break;
639
+ }
640
+ case 'content': {
641
+ if (layoutId === 'theme01_risk_v1') {
642
+ slide.props = {
643
+ kicker: '风险研判',
644
+ title: '风险标题',
645
+ items: [
646
+ { risk: '风险 1', impact: '高', response: '应对策略' },
647
+ { risk: '风险 2', impact: '中', response: '应对策略' }
648
+ ]
649
+ };
650
+ } else if (layoutId === 'theme01_outlook_v1') {
651
+ slide.props = {
652
+ kicker: '投资展望',
653
+ title: '展望标题',
654
+ items: [
655
+ { title: '趋势一', trend: '趋势判断', action: '行动建议' },
656
+ { title: '趋势二', trend: '趋势判断', action: '行动建议' }
657
+ ]
658
+ };
659
+ } else if (layoutId === 'theme01_region_v1') {
660
+ slide.props = {
661
+ kicker: '地区分布',
662
+ title: '市场分布',
663
+ regions: [
664
+ { name: '地区一', value: '35%', change: '+2%', note: '说明文字' },
665
+ { name: '地区二', value: '25%', change: '+1%', note: '说明文字' }
666
+ ]
667
+ };
668
+ } else if (layoutId === 'theme01_case_study') {
669
+ slide.props = {
670
+ kicker: '典型案例',
671
+ title: '柠檬科技',
672
+ subtitle: 'FROM CHALLENGER TO FRONTRUNNER',
673
+ intro: '柠檬科技成立于 2022 年,专注 AI 演示文稿生成。凭借自研布局引擎与主题系统,在 18 个月内完成从 0 到 1 的跨越。',
674
+ rounds: [
675
+ { date: '2022·06', round: '天使轮', valuation: '估值 ¥1 亿', amount: '¥1,000万' },
676
+ { date: '2023·03', round: 'Pre-A', valuation: '估值 ¥5 亿', amount: '¥5,000万' },
677
+ { date: '2024·01', round: 'A 轮', valuation: '估值 ¥20 亿', amount: '¥1.5亿' }
678
+ ],
679
+ quote: 'AI 不是要替代人做 PPT,而是把创意从格式中解放出来。',
680
+ quoteAuthor: '李雷,联合创始人兼 CEO',
681
+ footnote: '数据来源:公司公开披露与媒体整理。'
682
+ };
683
+ } else if (layoutId === 'theme01_spotlight_grid') {
684
+ slide.props = {
685
+ kicker: '主题聚焦',
686
+ title: '钱,最终砸向了这三件事',
687
+ subtitle: '资金集中流向模型能力、应用场景与基础设施',
688
+ columns: [
689
+ { tag: '模型能力', title: '通用大模型', description: '头部厂商持续加码基座模型,规模效应显现。' },
690
+ { tag: '应用场景', title: '垂直应用', description: '办公、设计、编程等场景率先跑出商业化路径。' },
691
+ { tag: '基础设施', title: 'AI 基础设施', description: '算力、数据与工具链成为支撑上层创新的关键底座。' }
692
+ ]
693
+ };
694
+ } else if (layoutId === 'theme02_chapter_v1') {
695
+ slide.props = {
696
+ kicker: '章节',
697
+ number: '01',
698
+ title: '核心发现',
699
+ subtitle: '从数据到洞察的关键转折'
700
+ };
701
+ } else if (layoutId === 'theme02_content_v1') {
702
+ slide.props = {
703
+ kicker: '要点总结',
704
+ title: '内容标题',
705
+ subtitle: '用霓虹 bullet 列表突出关键信息',
706
+ bullets: [
707
+ '全栈 AI 演示生成,从大纲到成稿一键完成',
708
+ '双配色方案自由切换,适配不同品牌调性',
709
+ 'SVG 图表 + 洞察面板,数据表达更聚焦'
710
+ ]
711
+ };
712
+ } else {
713
+ slide.props = { title: '内容标题', points: ['新要点'] };
714
+ }
715
+ break;
716
+ }
717
+ case 'chart': {
718
+ if (layoutId === 'theme01_chart_treemap') {
719
+ slide.props = {
720
+ title: '图表标题',
721
+ kicker: '矩形树图',
722
+ unit: '',
723
+ data: [
724
+ { name: '分类 A', value: 400, children: [
725
+ { name: 'A-1', value: 200 },
726
+ { name: 'A-2', value: 150 },
727
+ { name: 'A-3', value: 50 }
728
+ ]},
729
+ { name: '分类 B', value: 300, children: [
730
+ { name: 'B-1', value: 180 },
731
+ { name: 'B-2', value: 120 }
732
+ ]}
733
+ ]
734
+ };
735
+ } else if (layoutId === 'theme01_chart_sankey') {
736
+ slide.props = {
737
+ title: '图表标题',
738
+ kicker: '桑基图',
739
+ data: [
740
+ { source: '访问', target: '点击', value: 5000 },
741
+ { source: '点击', target: '咨询', value: 3500 },
742
+ { source: '咨询', target: '订单', value: 2200 },
743
+ { source: '访问', target: '跳出', value: 1500 },
744
+ { source: '点击', target: '离开', value: 1000 },
745
+ { source: '咨询', target: '流失', value: 1300 }
746
+ ]
747
+ };
748
+ } else if (layoutId === 'theme01_chart_sunburst') {
749
+ slide.props = {
750
+ title: '图表标题',
751
+ kicker: '旭日图',
752
+ data: [
753
+ { name: '线上', value: 600, children: [
754
+ { name: '搜索', value: 300 },
755
+ { name: '社交', value: 200 },
756
+ { name: '直访', value: 100 }
757
+ ]},
758
+ { name: '线下', value: 400, children: [
759
+ { name: '门店', value: 250 },
760
+ { name: '代理', value: 150 }
761
+ ]}
762
+ ]
763
+ };
764
+ } else if (layoutId === 'theme01_chart_gauge') {
765
+ slide.props = {
766
+ title: '图表标题',
767
+ kicker: '仪表盘',
768
+ value: 78,
769
+ min: 0,
770
+ max: 100,
771
+ unit: '%'
772
+ };
773
+ } else if (layoutId === 'theme01_chart_heatmap') {
774
+ slide.props = {
775
+ title: '图表标题',
776
+ kicker: '热力图',
777
+ xAxis: ['A', 'B', 'C', 'D'],
778
+ yAxis: ['W1', 'W2', 'W3'],
779
+ data: [
780
+ ['A', 'W1', 12], ['B', 'W1', 45], ['C', 'W1', 78], ['D', 'W1', 23],
781
+ ['A', 'W2', 67], ['B', 'W2', 89], ['C', 'W2', 34], ['D', 'W2', 56],
782
+ ['A', 'W3', 91], ['B', 'W3', 15], ['C', 'W3', 66], ['D', 'W3', 40]
783
+ ]
784
+ };
785
+ } else if (layoutId === 'theme01_chart_funnel') {
786
+ slide.props = {
787
+ title: '图表标题',
788
+ kicker: '漏斗图',
789
+ data: [
790
+ { name: '访问', value: 10000 },
791
+ { name: '意向', value: 6500 },
792
+ { name: '询价', value: 4200 },
793
+ { name: '成交', value: 2800 }
794
+ ]
795
+ };
796
+ } else if (layoutId === 'theme01_chart_radar') {
797
+ slide.props = {
798
+ title: '图表标题',
799
+ kicker: '雷达图',
800
+ indicators: [
801
+ { name: '性能', max: 100 },
802
+ { name: '稳定性', max: 100 },
803
+ { name: '易用性', max: 100 },
804
+ { name: '扩展性', max: 100 },
805
+ { name: '安全性', max: 100 }
806
+ ],
807
+ data: [
808
+ { name: '当前', value: [85, 90, 78, 88, 82] },
809
+ { name: '目标', value: [95, 95, 90, 92, 95] }
810
+ ]
811
+ };
812
+ } else if (layoutId === 'theme01_chart_graph') {
813
+ slide.props = {
814
+ title: '图表标题',
815
+ kicker: '关系图',
816
+ categories: [
817
+ { name: '核心' },
818
+ { name: '产品' },
819
+ { name: '技术' }
820
+ ],
821
+ nodes: [
822
+ { name: '平台', value: 80, category: 0 },
823
+ { name: '用户端', value: 50, category: 1 },
824
+ { name: '管理端', value: 45, category: 1 },
825
+ { name: '数据中台', value: 60, category: 2 },
826
+ { name: 'AI 引擎', value: 70, category: 2 },
827
+ { name: '渲染器', value: 55, category: 2 },
828
+ { name: '模板库', value: 40, category: 1 }
829
+ ],
830
+ links: [
831
+ { source: '平台', target: '用户端', value: 5 },
832
+ { source: '平台', target: '管理端', value: 5 },
833
+ { source: '平台', target: '数据中台', value: 8 },
834
+ { source: '数据中台', target: 'AI 引擎', value: 6 },
835
+ { source: 'AI 引擎', target: '渲染器', value: 4 },
836
+ { source: '管理端', target: '模板库', value: 3 },
837
+ { source: '用户端', target: '渲染器', value: 4 }
838
+ ]
839
+ };
840
+ } else if (layoutId === 'theme01_chart_bar3d') {
841
+ slide.props = {
842
+ title: '图表标题',
843
+ kicker: '3D 柱状图',
844
+ labels: ['Q1', 'Q2', 'Q3', 'Q4'],
845
+ data: [120, 200, 150, 80]
846
+ };
847
+ } else if (layoutId === 'theme01_chart_wordcloud') {
848
+ slide.props = {
849
+ title: '图表标题',
850
+ kicker: '词云',
851
+ words: [
852
+ { name: 'AI', value: 100 },
853
+ { name: 'PPT', value: 90 },
854
+ { name: '数据可视化', value: 85 },
855
+ { name: '自动化', value: 80 },
856
+ { name: '大屏', value: 75 },
857
+ { name: '主题', value: 70 },
858
+ { name: '模板', value: 65 },
859
+ { name: '智能生成', value: 60 }
860
+ ]
861
+ };
862
+ } else if (layoutId === 'theme01_chart_donut') {
863
+ slide.props = {
864
+ title: '赛道分布',
865
+ kicker: '环形图',
866
+ subtitle: '融资额占比',
867
+ total: { value: '970 亿美元', label: '全年合计' },
868
+ segments: [
869
+ { label: '通用大模型', labelEn: 'General LLM', value: '420', percent: '43.3%', color: 'var(--lp-blue)' },
870
+ { label: '垂直应用', labelEn: 'Vertical Apps', value: '245', percent: '25.3%', color: 'var(--lp-green)' },
871
+ { label: 'AI 基础设施', labelEn: 'AI Infra', value: '198', percent: '20.4%', color: 'var(--lp-amber)' },
872
+ { label: 'AI 芯片', labelEn: 'AI Chips', value: '97', percent: '10.0%', color: 'var(--lp-red)' },
873
+ { label: '其他', labelEn: 'Others', value: '10', percent: '1.0%', color: 'var(--lp-violet)' }
874
+ ]
875
+ };
876
+ } else if (layoutId === 'theme02_chart_funnel') {
877
+ slide.props = {
878
+ title: '资金集中度',
879
+ kicker: '霓虹漏斗',
880
+ subtitle: '资本如何向头部收敛',
881
+ data: [
882
+ { name: '全市场', value: 970 },
883
+ { name: '头部 10 家', value: 232 },
884
+ { name: 'Top 3', value: 181 },
885
+ { name: '单笔最大', value: 66 }
886
+ ],
887
+ showInsight: true,
888
+ insight: {
889
+ value: '24%',
890
+ label: '头部 10 家 = 全市场近 1/4',
891
+ description: '少数独角兽反复获得巨额追加投资,头部格局已然确立。'
892
+ },
893
+ footnote: '数据来源:lemonPPT 内部统计 · 2026'
894
+ };
895
+ } else if (layoutId === 'theme02_chart_donut') {
896
+ slide.props = {
897
+ title: '赛道分布',
898
+ kicker: '霓虹环形',
899
+ subtitle: '融资额占比',
900
+ total: { value: '970 亿美元', label: '全年合计' },
901
+ segments: [
902
+ { label: '通用大模型', labelEn: 'General LLM', value: '420', percent: '43.3%', color: 'var(--lp-accent)' },
903
+ { label: '垂直应用', labelEn: 'Vertical Apps', value: '245', percent: '25.3%', color: 'var(--lp-accent-cool)' },
904
+ { label: 'AI 基础设施', labelEn: 'AI Infra', value: '198', percent: '20.4%', color: 'var(--lp-accent-2)' },
905
+ { label: 'AI 芯片', labelEn: 'AI Chips', value: '97', percent: '10.0%', color: 'var(--lp-violet)' },
906
+ { label: '其他', labelEn: 'Others', value: '10', percent: '1.0%', color: 'var(--lp-cyan)' }
907
+ ],
908
+ footnote: '数据来源:lemonPPT 内部统计 · 2026',
909
+ showInsight: true,
910
+ insight: {
911
+ value: '43.3%',
912
+ label: '通用大模型占比最高',
913
+ description: '通用大模型与垂直应用合计贡献近七成融资额,赛道头部效应明显。'
914
+ }
915
+ };
916
+ } else if (layoutId === 'theme02_chart_heatmap') {
917
+ slide.props = {
918
+ title: '用户活跃度热力',
919
+ kicker: '霓虹热力',
920
+ xAxis: ['周一', '周二', '周三', '周四', '周五'],
921
+ yAxis: ['00:00', '06:00', '12:00', '18:00'],
922
+ data: [
923
+ ['周一', '00:00', 120], ['周一', '06:00', 80], ['周一', '12:00', 320], ['周一', '18:00', 280],
924
+ ['周二', '00:00', 90], ['周二', '06:00', 60], ['周二', '12:00', 350], ['周二', '18:00', 300],
925
+ ['周三', '00:00', 100], ['周三', '06:00', 70], ['周三', '12:00', 380], ['周三', '18:00', 340],
926
+ ['周四', '00:00', 110], ['周四', '06:00', 75], ['周四', '12:00', 360], ['周四', '18:00', 310],
927
+ ['周五', '00:00', 130], ['周五', '06:00', 85], ['周五', '12:00', 400], ['周五', '18:00', 360]
928
+ ],
929
+ showInsight: true,
930
+ insight: {
931
+ value: '400',
932
+ label: '周五 12:00 活跃度峰值',
933
+ description: '工作日午间是用户活跃高峰,适合推送关键内容与运营活动。'
934
+ }
935
+ };
936
+ } else if (layoutId === 'theme02_chart_radar') {
937
+ slide.props = {
938
+ title: '能力雷达对比',
939
+ kicker: '霓虹雷达',
940
+ indicators: [
941
+ { name: '性能', max: 100 },
942
+ { name: '稳定性', max: 100 },
943
+ { name: '易用性', max: 100 },
944
+ { name: '扩展性', max: 100 },
945
+ { name: '安全性', max: 100 }
946
+ ],
947
+ data: [
948
+ { name: '当前', value: [85, 90, 78, 88, 82] },
949
+ { name: '目标', value: [95, 95, 90, 92, 95] }
950
+ ],
951
+ showInsight: true,
952
+ insight: {
953
+ value: '84.6',
954
+ label: '当前综合能力均分',
955
+ description: '性能与稳定性表现突出,易用性仍有提升空间,建议优先优化交互流程。'
956
+ }
957
+ };
958
+ } else if (layoutId === 'theme02_chart_gauge') {
959
+ slide.props = {
960
+ title: '年度目标达成率',
961
+ kicker: '霓虹仪表',
962
+ value: 78,
963
+ min: 0,
964
+ max: 100,
965
+ unit: '%',
966
+ showInsight: true,
967
+ insight: {
968
+ value: '+22%',
969
+ label: '较上月提升',
970
+ description: '年度目标完成率已接近 80%,按当前增速预计下季度可超额达成。'
971
+ }
972
+ };
973
+ } else if (layoutId === 'theme02_chart_v1') {
974
+ slide.props = {
975
+ title: '季度营收增长',
976
+ kicker: '霓虹图表',
977
+ subtitle: '全年四个季度持续上扬',
978
+ type: 'bar',
979
+ labels: ['Q1', 'Q2', 'Q3', 'Q4'],
980
+ data: [1800, 2450, 3200, 4150],
981
+ unit: '万元',
982
+ showInsight: true,
983
+ insight: {
984
+ headline: '11,600',
985
+ subheadline: 'FULL-YEAR TOTAL',
986
+ items: [
987
+ { label: 'Q4 环比', value: '+30%' },
988
+ { label: '平均季度', value: '2,900' }
989
+ ],
990
+ badge: { text: 'Q4 增长最快', tone: 'accent' }
991
+ }
992
+ };
993
+ } else {
994
+ slide.props = { title: '图表标题', labels: ['Q1', 'Q2', 'Q3', 'Q4'], data: [20, 45, 70, 95] };
995
+ }
996
+ break;
997
+ }
998
+ case 'bento': {
999
+ if (layoutId === 'theme02_bento_v1') {
1000
+ slide.props = {
1001
+ kicker: 'Bento',
1002
+ title: '模块化数据展示',
1003
+ subtitle: '用不规则网格突出关键数字',
1004
+ items: [
1005
+ { label: '年度营收', value: '1.2', unit: '亿元', size: 'large' },
1006
+ { label: '用户增长', value: '+320', unit: '%', size: 'medium' },
1007
+ { label: 'NPS', value: '72', unit: '分', size: 'small' },
1008
+ { label: '付费客户', value: '1,200', unit: '+', size: 'small' },
1009
+ { label: '模板数', value: '150', unit: '+', size: 'small' }
1010
+ ]
1011
+ };
1012
+ } else {
1013
+ slide.props = {
1014
+ kicker: 'Bento',
1015
+ title: '模块化展示',
1016
+ items: [
1017
+ { title: '核心指标', description: '关键数据一览', span: 'large' },
1018
+ { title: '用户增长', description: '月活跃用户持续提升', span: 'medium' },
1019
+ { title: '产品特性', description: '核心能力', span: 'small' },
1020
+ { title: '技术架构', description: '稳定可靠', span: 'small' }
1021
+ ]
1022
+ };
1023
+ }
1024
+ break;
1025
+ }
1026
+ case 'gallery': {
1027
+ if (layoutId === 'theme01_filmstrip_v1') {
1028
+ slide.props = {
1029
+ kicker: '影像长卷',
1030
+ title: '故事线',
1031
+ images: [
1032
+ { url: '', caption: '阶段一' },
1033
+ { url: '', caption: '阶段二' },
1034
+ { url: '', caption: '阶段三' },
1035
+ { url: '', caption: '阶段四' }
1036
+ ]
1037
+ };
1038
+ } else if (layoutId === 'theme02_gallery_v1') {
1039
+ slide.props = {
1040
+ kicker: '图集',
1041
+ title: '精选图片',
1042
+ subtitle: '用霓虹边框网格展示产品场景',
1043
+ images: [
1044
+ { url: '', caption: '图片说明 1' },
1045
+ { url: '', caption: '图片说明 2' },
1046
+ { url: '', caption: '图片说明 3' },
1047
+ { url: '', caption: '图片说明 4' }
1048
+ ]
1049
+ };
1050
+ } else {
1051
+ slide.props = {
1052
+ kicker: '图集',
1053
+ title: '精选图片',
1054
+ images: [
1055
+ { url: '', caption: '图片说明 1' },
1056
+ { url: '', caption: '图片说明 2' },
1057
+ { url: '', caption: '图片说明 3' },
1058
+ { url: '', caption: '图片说明 4' }
1059
+ ]
1060
+ };
1061
+ }
1062
+ break;
1063
+ }
1064
+ case 'table': {
1065
+ if (layoutId === 'theme01_table_data') {
1066
+ slide.props = {
1067
+ kicker: '数据表',
1068
+ title: '对比一览',
1069
+ columns: [
1070
+ { key: 'rank', label: '排名', align: 'left' },
1071
+ { key: 'company', label: '公司', align: 'left' },
1072
+ { key: 'track', label: '赛道', align: 'left' },
1073
+ { key: 'amount', label: '融资额', align: 'right' }
1074
+ ],
1075
+ rows: [
1076
+ { rank: '1', company: '柠檬科技', track: 'SaaS', amount: '¥5,000万' },
1077
+ { rank: '2', company: '青云数据', track: '云原生', amount: '¥3,200万' },
1078
+ { rank: '3', company: '红星智能', track: 'AI 硬件', amount: '¥2,800万' },
1079
+ { rank: '4', company: '蓝海资本', track: '金融科技', amount: '¥1,500万' }
1080
+ ],
1081
+ highlightRow: 0
1082
+ };
1083
+ } else if (layoutId === 'theme02_table_v1') {
1084
+ slide.props = {
1085
+ kicker: '数据表',
1086
+ title: '方案对比一览',
1087
+ subtitle: '多维度评估,辅助决策',
1088
+ headers: ['维度', '传统方式', '柠檬 PPT'],
1089
+ rows: [
1090
+ ['制作时间', '2-3 天', '10 分钟'],
1091
+ ['风格一致性', '依赖设计师', '主题系统保证'],
1092
+ ['数据更新', '手动修改', '联动刷新'],
1093
+ ['协作效率', '版本混乱', '实时协作'],
1094
+ ['导出格式', '单一', 'PPTX / PDF / 图片']
1095
+ ],
1096
+ highlightFirstColumn: true
1097
+ };
1098
+ } else {
1099
+ slide.props = {
1100
+ kicker: '数据表',
1101
+ title: '对比一览',
1102
+ headers: ['维度', '方案 A', '方案 B', '方案 C'],
1103
+ rows: [
1104
+ ['成本', '低', '中', '高'],
1105
+ ['性能', '中', '高', '高'],
1106
+ ['易用性', '高', '中', '低'],
1107
+ ['扩展性', '中', '高', '高']
1108
+ ],
1109
+ highlightFirstColumn: true
1110
+ };
1111
+ }
1112
+ break;
1113
+ }
1114
+ case 'tags': {
1115
+ if (layoutId === 'theme02_tags_v1') {
1116
+ slide.props = {
1117
+ kicker: '标签墙',
1118
+ title: '关键词云',
1119
+ subtitle: '一眼看懂用户最关心的能力',
1120
+ tags: [
1121
+ { label: 'AI', value: 98, tone: 'accent' },
1122
+ { label: '自动化', value: 85, tone: 'positive' },
1123
+ { label: '可视化', value: 72 },
1124
+ { label: '协作', value: 60 },
1125
+ { label: '云端', value: 55 },
1126
+ { label: '效率', value: 80, tone: 'positive' },
1127
+ { label: '设计', value: 45 },
1128
+ { label: '品牌', value: 38 },
1129
+ { label: 'PPTX', value: 65, tone: 'accent' },
1130
+ { label: '智能生成', value: 70 }
1131
+ ]
1132
+ };
1133
+ } else {
1134
+ slide.props = {
1135
+ kicker: '标签墙',
1136
+ title: '关键词云',
1137
+ tags: [
1138
+ { label: 'AI', value: 98, tone: 'accent' },
1139
+ { label: '自动化', value: 85, tone: 'positive' },
1140
+ { label: '可视化', value: 72 },
1141
+ { label: '协作', value: 60 },
1142
+ { label: '云端', value: 55 },
1143
+ { label: '效率', value: 80, tone: 'positive' },
1144
+ { label: '设计', value: 45 },
1145
+ { label: '品牌', value: 38 },
1146
+ { label: 'PPTX', value: 65, tone: 'accent' },
1147
+ { label: '智能生成', value: 70 }
1148
+ ]
1149
+ };
1150
+ }
1151
+ break;
1152
+ }
1153
+ case 'process': {
1154
+ if (layoutId === 'theme02_process_v1') {
1155
+ slide.props = {
1156
+ kicker: '实施路径',
1157
+ title: '四步上线',
1158
+ subtitle: '从需求到交付的标准化流程',
1159
+ steps: [
1160
+ { title: '需求梳理', description: '明确受众、目标与核心信息。' },
1161
+ { title: 'AI 生成', description: '输入主题,自动生成大纲与版式。' },
1162
+ { title: '视觉调优', description: '切换配色、替换图片、微调文案。' },
1163
+ { title: '导出交付', description: '一键导出 PPTX / PDF / 图片。' }
1164
+ ]
1165
+ };
1166
+ } else {
1167
+ slide.props = { title: '流程标题', steps: ['新步骤'] };
1168
+ }
1169
+ break;
1170
+ }
1171
+ case 'feature': {
1172
+ if (layoutId === 'theme02_feature_v1') {
1173
+ slide.props = {
1174
+ kicker: '产品特性',
1175
+ title: '为什么选择柠檬 PPT',
1176
+ subtitle: '从生成到交付,每个环节都更高效',
1177
+ features: [
1178
+ { title: 'AI 一键生成', description: '基于大模型自动理解需求,生成完整大纲与页面。' },
1179
+ { title: '主题系统', description: '深色霓虹、浅色玻璃等多种风格,全局一键切换。' },
1180
+ { title: '数据可视化', description: '内置 SVG 图表与洞察面板,数据表达更聚焦。' }
1181
+ ]
1182
+ };
1183
+ } else {
1184
+ slide.props = { title: '特性标题', features: [{ title: '新特性', description: '' }] };
1185
+ }
1186
+ break;
1187
+ }
1188
+ case 'stats': {
1189
+ if (layoutId === 'theme02_metrics_v1') {
1190
+ slide.props = {
1191
+ kicker: '数据墙',
1192
+ title: '核心指标一览',
1193
+ subtitle: '用霓虹卡片呈现关键业务数据',
1194
+ stats: [
1195
+ { label: '年度营收', value: '1.2', unit: '亿元', tone: 'accent' },
1196
+ { label: '用户增长', value: '+320', unit: '%', tone: 'cool' },
1197
+ { label: '客户满意度', value: '98', unit: '%', tone: 'accent2' },
1198
+ { label: '团队规模', value: '86', unit: '人', tone: 'default' }
1199
+ ]
1200
+ };
1201
+ } else if (layoutId === 'theme02_stats_v1') {
1202
+ slide.props = {
1203
+ kicker: '核心指标',
1204
+ title: '增长数据一览',
1205
+ subtitle: '用霓虹数字卡片突出关键成果',
1206
+ stats: [
1207
+ { label: '注册用户', value: '120K', unit: '+' },
1208
+ { label: '月活跃用户', value: '45K', unit: '+' },
1209
+ { label: '付费转化率', value: '8.5', unit: '%' },
1210
+ { label: '客户续费率', value: '92', unit: '%' },
1211
+ { label: '模板使用量', value: '2.4M', unit: '+' },
1212
+ { label: 'NPS 评分', value: '72', unit: '' }
1213
+ ]
1214
+ };
1215
+ } else {
1216
+ slide.props = { title: '数据标题', stats: [{ label: '指标', value: 0, unit: '', change: '' }] };
1217
+ }
1218
+ break;
1219
+ }
1220
+ case 'metric': {
1221
+ if (layoutId === 'theme01_metric_big') {
1222
+ slide.props = {
1223
+ kicker: '核心指标',
1224
+ title: '年度融资总额',
1225
+ subtitle: '全年大额融资事件汇总',
1226
+ value: '970',
1227
+ unit: '亿美元',
1228
+ context: '创历史新高,占全美风险投资近三分之一',
1229
+ metrics: [
1230
+ { value: '97', label: '事件笔数', accent: false },
1231
+ { value: '≈10 亿', label: '平均单笔', accent: false },
1232
+ { value: '+41%', label: 'Q4 环比', accent: true },
1233
+ { value: 'Q2-Q3', label: '高峰区间', accent: false }
1234
+ ]
1235
+ };
1236
+ } else if (layoutId === 'theme01_metric_triptych') {
1237
+ slide.props = {
1238
+ kicker: '指标总览',
1239
+ title: '关键数据三视图',
1240
+ subtitle: '规模、增速与质量的综合视角',
1241
+ panels: [
1242
+ { index: '01', title: '融资总额', value: '970 亿', subtitle: '全年累计规模创新高', chartType: 'bar', chartData: [120, 180, 320, 350] },
1243
+ { index: '02', title: '事件笔数', value: '97 笔', subtitle: '大额交易活跃度提升', chartType: 'line', chartData: [18, 22, 28, 29] },
1244
+ { index: '03', title: '平均单笔', value: '≈10 亿', subtitle: '单笔金额持续走高', chartType: 'area', chartData: [6.7, 8.2, 11.4, 12.1] }
1245
+ ]
1246
+ };
1247
+ } else if (layoutId === 'theme02_metric_big') {
1248
+ slide.props = {
1249
+ kicker: '核心指标',
1250
+ title: '年度融资总额',
1251
+ subtitle: '全年大额融资事件汇总',
1252
+ value: '970',
1253
+ unit: '亿美元',
1254
+ context: '创历史新高,占全美风险投资近三分之一',
1255
+ metrics: [
1256
+ { value: '97', label: '事件笔数', accent: false },
1257
+ { value: '≈10 亿', label: '平均单笔', accent: false },
1258
+ { value: '+41%', label: 'Q4 环比', accent: true },
1259
+ { value: 'Q2-Q3', label: '高峰区间', accent: false }
1260
+ ]
1261
+ };
1262
+ } else if (layoutId === 'theme02_progress_v1') {
1263
+ slide.props = {
1264
+ kicker: '达成度',
1265
+ title: '年度目标完成进度',
1266
+ subtitle: '关键 OKR 当前完成度',
1267
+ items: [
1268
+ { label: 'ARR 营收目标', value: 8400, max: 12000, unit: '万元' },
1269
+ { label: '企业客户签约', value: 320, max: 500, unit: '家' },
1270
+ { label: '产品 NPS', value: 72, max: 100, unit: '分' },
1271
+ { label: '团队规模', value: 86, max: 120, unit: '人' }
1272
+ ]
1273
+ };
1274
+ } else if (layoutId === 'theme02_delta_v1') {
1275
+ slide.props = {
1276
+ kicker: '今昔对照',
1277
+ title: '关键指标变化',
1278
+ subtitle: '去年同期 vs 当前表现',
1279
+ items: [
1280
+ { label: 'ARR 营收', previous: 4200, current: 8400, unit: '万元' },
1281
+ { label: '企业客户', previous: 120, current: 320, unit: '家' },
1282
+ { label: '产品 NPS', previous: 58, current: 72, unit: '分' },
1283
+ { label: '团队规模', previous: 45, current: 86, unit: '人' }
1284
+ ],
1285
+ footnote: '数据来源:公司财务与运营系统 · 2026'
1286
+ };
1287
+ } else {
1288
+ slide.props = { title: '指标标题', metrics: [{ label: '指标', value: '0', unit: '', change: '' }] };
1289
+ }
1290
+ break;
1291
+ }
1292
+ case 'comparison': {
1293
+ if (layoutId === 'theme01_diptych_contrast') {
1294
+ slide.props = {
1295
+ kicker: '双联对比',
1296
+ title: '模型能力 vs 应用落地',
1297
+ left: { label: '模型能力', labelEn: 'FOUNDATION MODEL', imageUrl: '' },
1298
+ right: { label: '应用落地', labelEn: 'APPLICATION', imageUrl: '' },
1299
+ centerCard: {
1300
+ title: '核心差异',
1301
+ comparisons: [
1302
+ { leftValue: '66%', leftLabel: '资本占比', rightValue: '25%', rightLabel: '资本占比' },
1303
+ { leftValue: '420 亿', leftLabel: '融资额', rightValue: '245 亿', rightLabel: '融资额' },
1304
+ { leftValue: '头部集中', leftLabel: '竞争格局', rightValue: '长尾分散', rightLabel: '竞争格局' }
1305
+ ],
1306
+ conclusion: '底层模型仍是资金主战场,但垂直应用的变现路径更清晰。'
1307
+ }
1308
+ };
1309
+ } else if (layoutId === 'theme02_comparison_v1') {
1310
+ slide.props = {
1311
+ kicker: '方案对比',
1312
+ title: '传统方式 vs 柠檬 PPT',
1313
+ subtitle: '从小时级到分钟级的效率跃迁',
1314
+ leftTitle: '传统 PPT 制作',
1315
+ rightTitle: '柠檬 PPT 生成',
1316
+ leftItems: ['手动排版耗时久', '风格难以统一', '协作版本混乱', '数据图表更新慢'],
1317
+ rightItems: ['AI 一键生成版式', '主题系统全局一致', '云端实时协作', '数据联动自动刷新']
1318
+ };
1319
+ } else {
1320
+ slide.props = { title: '对比标题', left: '左侧', right: '右侧' };
1321
+ }
1322
+ break;
1323
+ }
1324
+ case 'team': {
1325
+ if (layoutId === 'theme02_team_v1') {
1326
+ slide.props = {
1327
+ kicker: '核心团队',
1328
+ title: '我们是谁',
1329
+ subtitle: '一支相信技术与设计能改变演示的团队',
1330
+ members: [
1331
+ { name: '李雷', role: 'CEO', bio: '连续创业者,前头部 SaaS 产品负责人。' },
1332
+ { name: '韩梅梅', role: 'CTO', bio: '全栈工程师,专注 AI 与渲染引擎。' },
1333
+ { name: '林涛', role: '设计总监', bio: '十年品牌设计经验,深耕视觉系统。' },
1334
+ { name: '吉姆', role: '增长负责人', bio: '数据驱动增长,擅长 B2B 规模化获客。' }
1335
+ ]
1336
+ };
1337
+ } else {
1338
+ slide.props = { title: '团队', members: [{ name: '新成员', role: '', bio: '', imageUrl: '' }] };
1339
+ }
1340
+ break;
1341
+ }
1342
+ case 'timeline': {
1343
+ if (layoutId === 'theme02_timeline_v1') {
1344
+ slide.props = {
1345
+ kicker: '里程碑',
1346
+ title: '发展历程',
1347
+ subtitle: '从想法到产品的关键节点',
1348
+ milestones: [
1349
+ { date: '2022.06', title: '项目启动', description: '柠檬 PPT 立项,聚焦 AI 演示生成。' },
1350
+ { date: '2023.03', title: '产品上线', description: '首个版本发布,支持一键生成完整大纲。' },
1351
+ { date: '2024.01', title: '规模增长', description: '累计用户突破 10 万,企业客户签约。' },
1352
+ { date: '2025.09', title: '生态开放', description: '开放主题与模板市场,共建创作者生态。' }
1353
+ ]
1354
+ };
1355
+ } else {
1356
+ slide.props = { title: '时间线', events: [{ date: '', title: '新里程碑', description: '' }] };
1357
+ }
1358
+ break;
1359
+ }
1360
+ case 'faq': {
1361
+ if (layoutId === 'theme02_faq_v1') {
1362
+ slide.props = {
1363
+ kicker: '常见问题',
1364
+ title: '你可能想知道的',
1365
+ subtitle: '快速了解柠檬 PPT 的核心能力与使用方式',
1366
+ items: [
1367
+ { q: '柠檬 PPT 适合什么场景?', a: '融资路演、产品发布、季度汇报、培训课件等需要高质量演示的场合。' },
1368
+ { q: '生成后能否继续编辑?', a: '可以。编辑器支持直接修改文字、替换图片、调整配色与版式。' },
1369
+ { q: '导出格式有哪些?', a: '支持 PPTX、PDF 和高清图片导出,满足不同分发需求。' }
1370
+ ]
1371
+ };
1372
+ } else {
1373
+ slide.props = { title: '常见问题', items: [{ q: '问题', a: '回答' }] };
1374
+ }
1375
+ break;
1376
+ }
1377
+ case 'partners': {
1378
+ if (layoutId === 'theme02_partners_v1') {
1379
+ slide.props = {
1380
+ kicker: '合作伙伴',
1381
+ title: '他们都在使用',
1382
+ subtitle: '与领先企业共同推动演示方式升级',
1383
+ partners: [
1384
+ { name: '云智科技', logoUrl: '' },
1385
+ { name: '未来资本', logoUrl: '' },
1386
+ { name: '星辰数据', logoUrl: '' },
1387
+ { name: '蓝海传媒', logoUrl: '' },
1388
+ { name: '红石咨询', logoUrl: '' },
1389
+ { name: '极光设计', logoUrl: '' },
1390
+ { name: '万象集团', logoUrl: '' },
1391
+ { name: '银河创投', logoUrl: '' }
1392
+ ]
1393
+ };
1394
+ } else {
1395
+ slide.props = { title: '合作伙伴', partners: [{ name: '', logoUrl: '' }] };
1396
+ }
1397
+ break;
1398
+ }
1399
+ case 'roadmap': {
1400
+ if (layoutId === 'theme02_roadmap_v1') {
1401
+ slide.props = {
1402
+ kicker: '产品路线',
1403
+ title: '未来 12 个月规划',
1404
+ subtitle: '从基础能力到生态开放的清晰节奏',
1405
+ phases: [
1406
+ { phase: 'Q1 基础能力', items: ['编辑器内核重构', '主题系统 2.0', 'PPTX 导出优化'] },
1407
+ { phase: 'Q2 智能升级', items: ['AI 多轮对话生成', '自动配图与数据联动', '协作评论'] },
1408
+ { phase: 'Q3 企业场景', items: ['品牌资产中心', '权限与版本管理', '私有部署'] },
1409
+ { phase: 'Q4 生态开放', items: ['模板市场', '开发者 SDK', '开放主题协议'] }
1410
+ ]
1411
+ };
1412
+ } else {
1413
+ slide.props = { title: '路线图', phases: [{ phase: '阶段', items: ['里程碑'] }] };
1414
+ }
1415
+ break;
1416
+ }
1417
+ case 'pricing': {
1418
+ if (layoutId === 'theme02_pricing_v1') {
1419
+ slide.props = {
1420
+ kicker: '定价方案',
1421
+ title: '选择适合你的方案',
1422
+ subtitle: '从个人创作者到企业团队,灵活扩展',
1423
+ tiers: [
1424
+ {
1425
+ name: '免费版',
1426
+ price: '¥0',
1427
+ period: '/ 月',
1428
+ features: ['每月 10 次生成', '3 种基础主题', 'PNG 导出'],
1429
+ cta: '开始使用',
1430
+ highlight: false
1431
+ },
1432
+ {
1433
+ name: '专业版',
1434
+ price: '¥99',
1435
+ period: '/ 月',
1436
+ features: ['无限次生成', '全部主题与版式', 'PPTX / PDF 导出', '优先客服'],
1437
+ cta: '立即升级',
1438
+ highlight: true
1439
+ },
1440
+ {
1441
+ name: '团队版',
1442
+ price: '¥399',
1443
+ period: '/ 月',
1444
+ features: ['5 人协作', '品牌资产中心', 'API 接入', '专属客户成功'],
1445
+ cta: '联系销售',
1446
+ highlight: false
1447
+ }
1448
+ ]
1449
+ };
1450
+ } else {
1451
+ slide.props = { title: '定价', tiers: [{ name: '方案', price: '', period: '', features: [''], cta: '' }] };
1452
+ }
1453
+ break;
1454
+ }
1455
+ case 'image': {
1456
+ if (layoutId === 'theme02_image_v1') {
1457
+ slide.props = {
1458
+ kicker: '视觉呈现',
1459
+ title: '一张图讲清产品价值',
1460
+ subtitle: '上传高清大图,用霓虹渐变叠加打造科技感封面。',
1461
+ image: '',
1462
+ caption: '图注:产品界面示意图'
1463
+ };
1464
+ } else {
1465
+ slide.props = { title: '图片标题', subtitle: '副标题' };
1466
+ }
1467
+ break;
1468
+ }
1469
+ case 'swot': {
1470
+ if (layoutId === 'theme02_swot_v1') {
1471
+ slide.props = {
1472
+ kicker: '战略分析',
1473
+ title: 'SWOT 分析',
1474
+ subtitle: '看清内外部环境,制定下一步策略',
1475
+ strength: 'AI 生成能力领先,主题系统可扩展,社区活跃度高。',
1476
+ weakness: '品牌知名度仍在建立,部分高级版式依赖人工设计。',
1477
+ opportunity: '企业数字化汇报需求增长,AI 工具接受度提升。',
1478
+ threat: '大厂同类产品布局加速,用户审美要求持续提高。'
1479
+ };
1480
+ } else {
1481
+ slide.props = { title: 'SWOT 分析', strength: '优势', weakness: '劣势', opportunity: '机会', threat: '威胁' };
1482
+ }
1483
+ break;
1484
+ }
1485
+ case 'pest': {
1486
+ if (layoutId === 'theme02_pest_v1') {
1487
+ slide.props = {
1488
+ kicker: '宏观分析',
1489
+ title: 'PEST 分析',
1490
+ subtitle: '从政策、经济、社会、技术四维度洞察环境',
1491
+ political: 'AI 内容生成监管框架逐步完善,合规化成为产品准入门槛。',
1492
+ economic: '企业降本增效需求强烈,数字化工具预算占比持续提升。',
1493
+ social: '远程协作常态化,对轻量化、可分享演示内容的需求激增。',
1494
+ technological: '大模型多模态能力快速进步,文本到幻灯片的生成质量显著提高。'
1495
+ };
1496
+ } else {
1497
+ slide.props = { title: 'PEST 分析', political: '政治环境', economic: '经济环境', social: '社会环境', technological: '技术环境' };
1498
+ }
1499
+ break;
1500
+ }
1501
+ case 'testimonial': {
1502
+ if (layoutId === 'theme02_testimonial_v1') {
1503
+ slide.props = {
1504
+ kicker: '客户评价',
1505
+ quote: '柠檬 PPT 帮助我们把汇报制作时间从 2 天缩短到 2 小时,团队可以把更多精力放在业务思考上。',
1506
+ author: '陈晓明',
1507
+ role: '产品总监',
1508
+ company: '未来科技有限公司',
1509
+ avatarUrl: ''
1510
+ };
1511
+ } else {
1512
+ slide.props = { kicker: '客户评价', quote: '产品帮助我们把汇报效率提升了 3 倍。', author: '张三', role: '产品经理', company: '示例科技' };
1513
+ }
1514
+ break;
1515
+ }
1516
+ case 'tableOfContents': {
1517
+ if (layoutId === 'theme02_table_of_contents_v1') {
1518
+ slide.props = {
1519
+ title: '目录',
1520
+ subtitle: 'CONTENTS',
1521
+ items: [
1522
+ { title: '市场背景与机会', page: '02' },
1523
+ { title: '产品方案与优势', page: '04' },
1524
+ { title: '商业模式与增长', page: '06' },
1525
+ { title: '团队与融资计划', page: '08' }
1526
+ ]
1527
+ };
1528
+ } else {
1529
+ slide.props = { title: '目录', items: ['目录项'] };
1530
+ }
1531
+ break;
1532
+ }
1533
+ }
1534
+ return slide;
1535
+ }
1536
+
1537
+ const addSlideBtn = document.getElementById('lp-add-slide');
1538
+ const addSlideModal = document.getElementById('lp-add-slide-modal');
1539
+ const addSlideConfirm = document.getElementById('lp-add-slide-confirm');
1540
+ let selectedLayoutId = null;
1541
+ let selectedRole = null;
1542
+
1543
+ function closeAddSlideModal() {
1544
+ if (addSlideModal) addSlideModal.setAttribute('hidden', '');
1545
+ addSlideModal?.querySelectorAll('.lp-add-slide-option').forEach((el) => el.classList.remove('selected'));
1546
+ selectedLayoutId = null;
1547
+ selectedRole = null;
1548
+ if (addSlideConfirm) addSlideConfirm.disabled = true;
1549
+ }
1550
+
1551
+ function openAddSlideModal() {
1552
+ if (addSlideModal) addSlideModal.removeAttribute('hidden');
1553
+ }
1554
+
1555
+ function confirmAddSlide() {
1556
+ if (!selectedLayoutId) return;
1557
+ const newSlide = createDefaultSlide(selectedLayoutId, selectedRole || selectedLayoutId.split('_')[0]);
1558
+ recordHistory();
1559
+ goal.slides.push(newSlide);
1560
+ selectedSlideIdx = goal.slides.length - 1;
1561
+ // current 会在 rebuildSlidesAndThumbnails 中同步,避免在 DOM 重建前访问越界
1562
+ autoSave();
1563
+ closeAddSlideModal();
1564
+ reloadEditor();
1565
+ }
1566
+
1567
+ if (addSlideBtn) {
1568
+ addSlideBtn.addEventListener('click', openAddSlideModal);
1569
+ }
1570
+
1571
+ if (addSlideModal) {
1572
+ addSlideModal.addEventListener('click', (e) => {
1573
+ const target = e.target;
1574
+ if (target.closest('[data-close-modal]')) {
1575
+ closeAddSlideModal();
1576
+ return;
1577
+ }
1578
+ const option = target.closest('.lp-add-slide-option');
1579
+ if (option) {
1580
+ addSlideModal.querySelectorAll('.lp-add-slide-option').forEach((el) => el.classList.remove('selected'));
1581
+ option.classList.add('selected');
1582
+ selectedLayoutId = option.getAttribute('data-layout');
1583
+ selectedRole = option.getAttribute('data-role');
1584
+ if (addSlideConfirm) addSlideConfirm.disabled = false;
1585
+ }
1586
+ });
1587
+ }
1588
+
1589
+ if (addSlideConfirm) {
1590
+ addSlideConfirm.addEventListener('click', confirmAddSlide);
1591
+ }
1592
+
1593
+ updateUndoRedoButtons();
1594
+
1595
+ // 翻页脚本
1596
+ let slides = Array.from(document.querySelectorAll('.lp-slide-wrapper'));
1597
+ let thumbnails = Array.from(document.querySelectorAll('.lp-thumbnail'));
1598
+ const prevBtn = document.getElementById('lp-prev');
1599
+ const nextBtn = document.getElementById('lp-next');
1600
+ const currentLabel = document.getElementById('lp-current');
1601
+ const deck = document.querySelector('.lp-deck');
1602
+ let current = 0;
1603
+ let isTransitioning = false;
1604
+ let cleanupTimer = null;
1605
+ let dragSrcIndex = null;
1606
+ let dragDropIndicator = null;
1607
+
1608
+ function refreshSlidesAndThumbnails() {
1609
+ slides = Array.from(document.querySelectorAll('.lp-slide-wrapper'));
1610
+ thumbnails = Array.from(document.querySelectorAll('.lp-thumbnail'));
1611
+ }
1612
+
1613
+ function updateEditorUI() {
1614
+ thumbnails.forEach((thumb, index) => {
1615
+ thumb.classList.toggle('active', index === current);
1616
+ });
1617
+ const activeThumb = thumbnails[current];
1618
+ if (activeThumb) activeThumb.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1619
+ const labelEl = document.getElementById('lp-current');
1620
+ if (labelEl) labelEl.textContent = String(current + 1);
1621
+ if (prevBtn) prevBtn.disabled = current === 0 || isTransitioning;
1622
+ if (nextBtn) nextBtn.disabled = current === slides.length - 1 || isTransitioning;
1623
+ }
1624
+
1625
+ function resetTransitionState() {
1626
+ slides.forEach((slide) => slide.classList.remove('enter', 'leave'));
1627
+ if (deck) {
1628
+ deck.setAttribute('data-lp-transition', 'none');
1629
+ deck.removeAttribute('data-lp-direction');
1630
+ }
1631
+ isTransitioning = false;
1632
+ updateClasses();
1633
+ }
1634
+
1635
+ function updateClasses() {
1636
+ updateEditorUI();
1637
+ }
1638
+
1639
+ function goTo(index) {
1640
+ if (index === current || index < 0 || index >= slides.length || isTransitioning) {
1641
+ return;
1642
+ }
1643
+ const leaving = slides[current];
1644
+ const entering = slides[index];
1645
+ const transition = entering.dataset.lpTransition || 'none';
1646
+ const direction = index > current ? 'next' : 'prev';
1647
+
1648
+ if (transition === 'none') {
1649
+ current = index;
1650
+ if (deck) {
1651
+ deck.setAttribute('data-lp-transition', 'none');
1652
+ deck.removeAttribute('data-lp-direction');
1653
+ }
1654
+ leaving.classList.remove('active');
1655
+ entering.classList.add('active');
1656
+ updateClasses();
1657
+ if (typeof selectSlide === 'function') selectSlide(current);
1658
+ // 切页后释放离开 slide 的图表并仅初始化新 active slide 的 ECharts。
1659
+ requestAnimationFrame(() => {
1660
+ disposeEChartsInWrapper(leaving);
1661
+ initActiveSlideECharts();
1662
+ });
1663
+ return;
1664
+ }
1665
+
1666
+ isTransitioning = true;
1667
+ current = index;
1668
+ updateEditorUI();
1669
+
1670
+ if (deck) {
1671
+ deck.setAttribute('data-lp-transition', transition);
1672
+ deck.setAttribute('data-lp-direction', direction);
1673
+ }
1674
+
1675
+ leaving.classList.add('leave');
1676
+ entering.classList.add('enter');
1677
+ // 先禁用 transition,让 leave/enter 的初始状态直接落位,
1678
+ // 避免从上一状态的 translateX 插值;随后恢复 transition 再切换 active。
1679
+ leaving.style.transition = 'none';
1680
+ entering.style.transition = 'none';
1681
+ void entering.offsetWidth;
1682
+ leaving.style.transition = '';
1683
+ entering.style.transition = '';
1684
+ leaving.classList.remove('active');
1685
+ entering.classList.add('active');
1686
+
1687
+ function finish() {
1688
+ entering.removeEventListener('transitionend', finish);
1689
+ clearTimeout(cleanupTimer);
1690
+ resetTransitionState();
1691
+ // 动画结束后释放离开 slide 的图表,并仅对当前 active slide 的 ECharts 进行 resize/初始化
1692
+ requestAnimationFrame(() => {
1693
+ disposeEChartsInWrapper(leaving);
1694
+ initActiveSlideECharts();
1695
+ });
1696
+ }
1697
+ entering.addEventListener('transitionend', finish);
1698
+ cleanupTimer = setTimeout(finish, 600);
1699
+
1700
+ if (typeof selectSlide === 'function') selectSlide(current);
1701
+ }
1702
+
1703
+ if (prevBtn) prevBtn.addEventListener('click', () => goTo(current - 1));
1704
+ if (nextBtn) nextBtn.addEventListener('click', () => goTo(current + 1));
1705
+ const deleteConfirmToast = document.getElementById('lp-delete-confirm-toast');
1706
+ const deleteConfirmText = document.getElementById('lp-delete-confirm-text');
1707
+ const deleteConfirmCancel = document.getElementById('lp-delete-confirm-cancel');
1708
+ const deleteConfirmConfirm = document.getElementById('lp-delete-confirm-confirm');
1709
+ let pendingDeleteIndex = null;
1710
+
1711
+ function showDeleteConfirm(index, triggerEl) {
1712
+ if (goal.slides.length <= 1) {
1713
+ alert('至少保留一页幻灯片。');
1714
+ return;
1715
+ }
1716
+ pendingDeleteIndex = index;
1717
+ if (deleteConfirmText) {
1718
+ deleteConfirmText.textContent = '确定删除第 ' + (index + 1) + ' 页幻灯片?';
1719
+ }
1720
+ if (deleteConfirmToast) {
1721
+ deleteConfirmToast.removeAttribute('hidden');
1722
+ positionDeleteConfirmToast(triggerEl);
1723
+ }
1724
+ }
1725
+
1726
+ function positionDeleteConfirmToast(triggerEl) {
1727
+ if (!deleteConfirmToast || !triggerEl) return;
1728
+ const rect = triggerEl.getBoundingClientRect();
1729
+ const toastRect = deleteConfirmToast.getBoundingClientRect();
1730
+ const viewportWidth = window.innerWidth;
1731
+ const viewportHeight = window.innerHeight;
1732
+ const gap = 8;
1733
+ let top = rect.bottom + gap;
1734
+ let left = rect.left + rect.width / 2 - toastRect.width / 2;
1735
+ if (left < gap) left = gap;
1736
+ if (left + toastRect.width > viewportWidth - gap) {
1737
+ left = viewportWidth - toastRect.width - gap;
1738
+ }
1739
+ if (top + toastRect.height > viewportHeight - gap) {
1740
+ top = rect.top - toastRect.height - gap;
1741
+ }
1742
+ deleteConfirmToast.style.top = top + 'px';
1743
+ deleteConfirmToast.style.left = left + 'px';
1744
+ }
1745
+
1746
+ function hideDeleteConfirm() {
1747
+ pendingDeleteIndex = null;
1748
+ if (deleteConfirmToast) {
1749
+ deleteConfirmToast.setAttribute('hidden', '');
1750
+ deleteConfirmToast.style.top = '';
1751
+ deleteConfirmToast.style.left = '';
1752
+ }
1753
+ }
1754
+
1755
+ function confirmDelete() {
1756
+ if (pendingDeleteIndex == null) return;
1757
+ const index = pendingDeleteIndex;
1758
+ hideDeleteConfirm();
1759
+ recordHistory();
1760
+ goal.slides.splice(index, 1);
1761
+ selectedSlideIdx = Math.min(index, goal.slides.length - 1);
1762
+ // current 会在 rebuildSlidesAndThumbnails 中同步
1763
+ autoSave();
1764
+ reloadEditor();
1765
+ }
1766
+
1767
+ if (deleteConfirmCancel) {
1768
+ deleteConfirmCancel.addEventListener('click', hideDeleteConfirm);
1769
+ }
1770
+ if (deleteConfirmConfirm) {
1771
+ deleteConfirmConfirm.addEventListener('click', confirmDelete);
1772
+ }
1773
+ if (deleteConfirmToast) {
1774
+ deleteConfirmToast.addEventListener('click', (e) => {
1775
+ if (e.target === deleteConfirmToast) {
1776
+ hideDeleteConfirm();
1777
+ }
1778
+ });
1779
+ }
1780
+
1781
+ function attachThumbnailListeners() {
1782
+ thumbnails.forEach((thumb) => {
1783
+ thumb.addEventListener('click', (e) => {
1784
+ const deleteBtn = e.target.closest && e.target.closest('.lp-thumbnail-delete');
1785
+ if (deleteBtn) {
1786
+ e.stopPropagation();
1787
+ const index = Number(deleteBtn.getAttribute('data-index'));
1788
+ showDeleteConfirm(index, deleteBtn);
1789
+ return;
1790
+ }
1791
+ goTo(Number(thumb.dataset.index));
1792
+ });
1793
+
1794
+ // 缩略图改为 div 后,补充键盘选中支持
1795
+ thumb.addEventListener('keydown', (e) => {
1796
+ if (e.key === 'Enter' || e.key === ' ') {
1797
+ e.preventDefault();
1798
+ goTo(Number(thumb.dataset.index));
1799
+ }
1800
+ });
1801
+ });
1802
+ }
1803
+ attachThumbnailListeners();
1804
+
1805
+ // 缩略图虚拟滚动:用 IntersectionObserver 卸载视口外 slide 渲染 DOM,仅保留占位框架与文字信息。
1806
+ const thumbnailRenderCache = {};
1807
+ let thumbnailObserver = null;
1808
+
1809
+ function getThumbnailCacheKey(thumb) {
1810
+ return 'thumb-' + thumb.dataset.index;
1811
+ }
1812
+
1813
+ function mountThumbnailRender(thumb) {
1814
+ const render = thumb.querySelector('.lp-thumbnail-render');
1815
+ if (!render) return;
1816
+ if (thumb.dataset.lpThumbMounted === 'true') return;
1817
+ const key = getThumbnailCacheKey(thumb);
1818
+ const html = thumbnailRenderCache[key];
1819
+ if (html != null) {
1820
+ render.innerHTML = html;
1821
+ }
1822
+ thumb.dataset.lpThumbMounted = 'true';
1823
+ }
1824
+
1825
+ function unmountThumbnailRender(thumb) {
1826
+ const render = thumb.querySelector('.lp-thumbnail-render');
1827
+ if (!render) return;
1828
+ if (thumb.dataset.lpThumbMounted === 'false') return;
1829
+ const key = getThumbnailCacheKey(thumb);
1830
+ thumbnailRenderCache[key] = render.innerHTML;
1831
+ render.innerHTML = '';
1832
+ thumb.dataset.lpThumbMounted = 'false';
1833
+ }
1834
+
1835
+ function initThumbnailVirtualScroll() {
1836
+ const container = document.querySelector('.lp-editor-left-panel');
1837
+ if (!container || !('IntersectionObserver' in window)) return;
1838
+
1839
+ if (thumbnailObserver) {
1840
+ thumbnailObserver.disconnect();
1841
+ thumbnailObserver = null;
1842
+ }
1843
+
1844
+ // 默认所有缩略图处于挂载状态
1845
+ thumbnails.forEach((thumb) => {
1846
+ thumb.dataset.lpThumbMounted = 'true';
1847
+ });
1848
+
1849
+ thumbnailObserver = new IntersectionObserver((entries) => {
1850
+ entries.forEach((entry) => {
1851
+ const thumb = entry.target;
1852
+ const index = Number(thumb.dataset.index);
1853
+ // 当前激活页始终保留渲染,避免切换时白屏
1854
+ if (index === current || entry.isIntersecting) {
1855
+ mountThumbnailRender(thumb);
1856
+ } else {
1857
+ unmountThumbnailRender(thumb);
1858
+ }
1859
+ });
1860
+ }, {
1861
+ root: container,
1862
+ rootMargin: '200px 0px 200px 0px',
1863
+ threshold: 0,
1864
+ });
1865
+
1866
+ thumbnails.forEach((thumb) => thumbnailObserver.observe(thumb));
1867
+ }
1868
+
1869
+ function disconnectThumbnailVirtualScroll() {
1870
+ if (thumbnailObserver) {
1871
+ thumbnailObserver.disconnect();
1872
+ thumbnailObserver = null;
1873
+ }
1874
+ }
1875
+ initThumbnailVirtualScroll();
1876
+
1877
+ function attachDragAndDropListeners() {
1878
+ const container = document.querySelector('.lp-editor-thumbnails');
1879
+ if (!container) return;
1880
+
1881
+ function getOrCreateDropIndicator() {
1882
+ if (dragDropIndicator && dragDropIndicator.parentElement) return dragDropIndicator;
1883
+ dragDropIndicator = document.createElement('div');
1884
+ dragDropIndicator.className = 'lp-thumbnail-drop-indicator';
1885
+ container.appendChild(dragDropIndicator);
1886
+ return dragDropIndicator;
1887
+ }
1888
+
1889
+ function clearDragState() {
1890
+ dragSrcIndex = null;
1891
+ thumbnails.forEach((t) => t.classList.remove('dragging', 'drag-over'));
1892
+ if (dragDropIndicator) dragDropIndicator.classList.remove('visible');
1893
+ }
1894
+
1895
+ function positionDropIndicator(target, before) {
1896
+ const indicator = getOrCreateDropIndicator();
1897
+ if (!indicator || !target) return;
1898
+ const rect = target.getBoundingClientRect();
1899
+ const containerRect = container.getBoundingClientRect();
1900
+ const top = before ? rect.top - containerRect.top - 1 : rect.bottom - containerRect.top - 1;
1901
+ indicator.style.top = top + 'px';
1902
+ indicator.classList.add('visible');
1903
+ }
1904
+
1905
+ container.addEventListener('dragstart', (e) => {
1906
+ const thumb = e.target && e.target.closest && e.target.closest('.lp-thumbnail');
1907
+ if (!thumb) return;
1908
+ // 点击删除按钮时不应触发拖拽
1909
+ const deleteBtn = e.target.closest && e.target.closest('.lp-thumbnail-delete');
1910
+ if (deleteBtn) {
1911
+ e.preventDefault();
1912
+ return;
1913
+ }
1914
+ if (isTransitioning || goal.slides.length <= 1) {
1915
+ e.preventDefault();
1916
+ return;
1917
+ }
1918
+ const src = Number(thumb.dataset.index);
1919
+ if (Number.isNaN(src)) return;
1920
+ dragSrcIndex = src;
1921
+ hideDeleteConfirm();
1922
+ thumb.classList.add('dragging');
1923
+ if (e.dataTransfer) {
1924
+ e.dataTransfer.effectAllowed = 'move';
1925
+ e.dataTransfer.setData('text/plain', String(src));
1926
+ try {
1927
+ e.dataTransfer.setDragImage(thumb, 16, 16);
1928
+ } catch (_) {}
1929
+ }
1930
+ });
1931
+
1932
+ container.addEventListener('dragover', (e) => {
1933
+ if (dragSrcIndex == null) return;
1934
+ e.preventDefault();
1935
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
1936
+ const targetThumb = e.target && e.target.closest && e.target.closest('.lp-thumbnail');
1937
+ const indicator = getOrCreateDropIndicator();
1938
+ if (!targetThumb) {
1939
+ const lastThumb = container.querySelector('.lp-thumbnail:last-child');
1940
+ if (lastThumb && dragSrcIndex !== goal.slides.length - 1) {
1941
+ positionDropIndicator(lastThumb, false);
1942
+ } else {
1943
+ indicator.classList.remove('visible');
1944
+ }
1945
+ thumbnails.forEach((t) => t.classList.remove('drag-over'));
1946
+ return;
1947
+ }
1948
+ const targetIndex = Number(targetThumb.dataset.index);
1949
+ if (Number.isNaN(targetIndex)) return;
1950
+ const rect = targetThumb.getBoundingClientRect();
1951
+ const before = e.clientY < rect.top + rect.height / 2;
1952
+ if (targetIndex === dragSrcIndex) {
1953
+ indicator.classList.remove('visible');
1954
+ targetThumb.classList.remove('drag-over');
1955
+ return;
1956
+ }
1957
+ thumbnails.forEach((t) => t.classList.remove('drag-over'));
1958
+ targetThumb.classList.add('drag-over');
1959
+ positionDropIndicator(targetThumb, before);
1960
+ });
1961
+
1962
+ container.addEventListener('dragleave', (e) => {
1963
+ if (dragSrcIndex == null) return;
1964
+ if (!container.contains(e.relatedTarget)) {
1965
+ if (dragDropIndicator) dragDropIndicator.classList.remove('visible');
1966
+ thumbnails.forEach((t) => t.classList.remove('drag-over'));
1967
+ }
1968
+ });
1969
+
1970
+ container.addEventListener('drop', (e) => {
1971
+ if (dragSrcIndex == null) return;
1972
+ e.preventDefault();
1973
+ const src = dragSrcIndex;
1974
+ const targetThumb = e.target && e.target.closest && e.target.closest('.lp-thumbnail');
1975
+ let targetIndex = 0;
1976
+ let insertBefore = true;
1977
+ if (targetThumb) {
1978
+ targetIndex = Number(targetThumb.dataset.index);
1979
+ const rect = targetThumb.getBoundingClientRect();
1980
+ insertBefore = e.clientY < rect.top + rect.height / 2;
1981
+ } else {
1982
+ targetIndex = goal.slides.length - 1;
1983
+ insertBefore = false;
1984
+ }
1985
+ if (Number.isNaN(targetIndex) || src === targetIndex) {
1986
+ clearDragState();
1987
+ return;
1988
+ }
1989
+ let insertAt = targetIndex;
1990
+ if (insertBefore) {
1991
+ if (src < targetIndex) insertAt = targetIndex - 1;
1992
+ } else {
1993
+ if (src > targetIndex) insertAt = targetIndex + 1;
1994
+ }
1995
+ const activeSlide = goal.slides[current];
1996
+ const movingSlide = goal.slides[src];
1997
+ const newSlides = goal.slides.filter((_, i) => i !== src);
1998
+ insertAt = Math.max(0, Math.min(insertAt, newSlides.length));
1999
+ newSlides.splice(insertAt, 0, movingSlide);
2000
+ goal.slides = newSlides;
2001
+ recordHistory();
2002
+ selectedSlideIdx = goal.slides.indexOf(activeSlide);
2003
+ current = selectedSlideIdx;
2004
+ autoSave();
2005
+ rebuildSlidesAndThumbnails();
2006
+ if (typeof selectSlide === 'function') selectSlide(current);
2007
+ clearDragState();
2008
+ });
2009
+
2010
+ container.addEventListener('dragend', () => {
2011
+ clearDragState();
2012
+ });
2013
+ }
2014
+ attachDragAndDropListeners();
2015
+
2016
+ function deleteSlide(index) {
2017
+ if (goal.slides.length <= 1) {
2018
+ alert('至少保留一页幻灯片。');
2019
+ return;
2020
+ }
2021
+ const deleteBtn = document.querySelector('.lp-thumbnail-delete[data-index="' + index + '"]');
2022
+ showDeleteConfirm(index, deleteBtn);
2023
+ }
2024
+
2025
+ document.addEventListener('keydown', (e) => {
2026
+ if (isEditingTarget(e.target) || isEditingTarget(document.activeElement)) return;
2027
+ const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
2028
+ const mod = isMac ? e.metaKey : e.ctrlKey;
2029
+
2030
+ // 关闭删除确认气泡
2031
+ if (e.key === 'Escape' && pendingDeleteIndex != null) {
2032
+ e.preventDefault();
2033
+ hideDeleteConfirm();
2034
+ return;
2035
+ }
2036
+
2037
+ // 撤销 / 重做
2038
+ if (mod && e.key.toLowerCase() === 'z') {
2039
+ e.preventDefault();
2040
+ if (e.shiftKey) redo();
2041
+ else undo();
2042
+ return;
2043
+ }
2044
+ if (mod && e.key.toLowerCase() === 'y') {
2045
+ e.preventDefault();
2046
+ redo();
2047
+ return;
2048
+ }
2049
+
2050
+ // 翻页导航
2051
+ if (isTransitioning || slides.length === 0) return;
2052
+ if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') {
2053
+ e.preventDefault();
2054
+ goTo(current + 1);
2055
+ }
2056
+ if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
2057
+ e.preventDefault();
2058
+ goTo(current - 1);
2059
+ }
2060
+ if (e.key === 'Home') {
2061
+ e.preventDefault();
2062
+ goTo(0);
2063
+ }
2064
+ if (e.key === 'End') {
2065
+ e.preventDefault();
2066
+ goTo(slides.length - 1);
2067
+ }
2068
+ });
2069
+
2070
+ updateClasses();
2071
+
2072
+ // 缩放控制
2073
+ const stage = document.querySelector('.lp-editor-stage');
2074
+ const scaler = document.querySelector('.lp-editor-stage-scaler');
2075
+ const editorRoot = document.querySelector('.lp-editor-root');
2076
+ const zoomSlider = document.getElementById('lp-zoom-slider');
2077
+ const zoomValue = document.getElementById('lp-zoom-value');
2078
+ const zoomOutBtn = document.getElementById('lp-zoom-out');
2079
+ const zoomInBtn = document.getElementById('lp-zoom-in');
2080
+ const zoomFitBtn = document.getElementById('lp-zoom-fit');
2081
+ let userZoom = null;
2082
+
2083
+ const SLIDE_WIDTH = 1280;
2084
+ const SLIDE_HEIGHT = 720;
2085
+
2086
+ function fitScale() {
2087
+ if (!stage || !scaler) return 1;
2088
+ return Math.min(stage.clientWidth / SLIDE_WIDTH, stage.clientHeight / SLIDE_HEIGHT) * 0.92;
2089
+ }
2090
+
2091
+ function updateScale() {
2092
+ if (!stage || !scaler) return;
2093
+ if (editorRoot && editorRoot.classList.contains('lp-editor-presentation-mode')) {
2094
+ const scale = Math.min(window.innerWidth / SLIDE_WIDTH, window.innerHeight / SLIDE_HEIGHT);
2095
+ scaler.style.setProperty('--lp-presentation-scale', String(Math.max(scale, 0.35)));
2096
+ scaler.style.transform = '';
2097
+ return;
2098
+ }
2099
+ const scale = userZoom == null ? fitScale() : userZoom;
2100
+ scaler.style.transform = 'scale(' + Math.max(scale, 0.35) + ')';
2101
+ scaler.style.removeProperty('--lp-presentation-scale');
2102
+ if (zoomValue) zoomValue.textContent = Math.round(scale * 100) + '%';
2103
+ if (zoomSlider && userZoom != null) zoomSlider.value = String(Math.round(scale * 100));
2104
+ }
2105
+
2106
+ if (zoomSlider) {
2107
+ zoomSlider.addEventListener('input', () => {
2108
+ userZoom = Number(zoomSlider.value) / 100;
2109
+ updateScale();
2110
+ });
2111
+ }
2112
+ if (zoomOutBtn) {
2113
+ zoomOutBtn.addEventListener('click', () => {
2114
+ userZoom = (userZoom == null ? fitScale() : userZoom) - 0.1;
2115
+ if (userZoom < 0.35) userZoom = 0.35;
2116
+ updateScale();
2117
+ });
2118
+ }
2119
+ if (zoomInBtn) {
2120
+ zoomInBtn.addEventListener('click', () => {
2121
+ userZoom = (userZoom == null ? fitScale() : userZoom) + 0.1;
2122
+ if (userZoom > 1.5) userZoom = 1.5;
2123
+ updateScale();
2124
+ });
2125
+ }
2126
+ if (zoomFitBtn) {
2127
+ zoomFitBtn.addEventListener('click', () => {
2128
+ userZoom = null;
2129
+ updateScale();
2130
+ });
2131
+ }
2132
+ window.addEventListener('resize', updateScale);
2133
+ updateScale();
2134
+
2135
+ // 播放:进入全屏演示模式,使用当前编辑状态直接播放
2136
+ const playBtn = document.getElementById('lp-play');
2137
+ let presentationExitBtn = null;
2138
+ let presentationKeyHandler = null;
2139
+
2140
+ let isExitingPresentation = false;
2141
+
2142
+ function exitPresentationMode() {
2143
+ if (!editorRoot || isExitingPresentation) return;
2144
+ isExitingPresentation = true;
2145
+ editorRoot.classList.remove('lp-editor-presentation-mode');
2146
+ if (presentationExitBtn && presentationExitBtn.parentNode) {
2147
+ presentationExitBtn.parentNode.removeChild(presentationExitBtn);
2148
+ presentationExitBtn = null;
2149
+ }
2150
+ if (presentationKeyHandler) {
2151
+ document.removeEventListener('keydown', presentationKeyHandler);
2152
+ presentationKeyHandler = null;
2153
+ }
2154
+ const done = () => {
2155
+ isExitingPresentation = false;
2156
+ updateScale();
2157
+ };
2158
+ if (document.fullscreenElement && document.exitFullscreen) {
2159
+ document.exitFullscreen().catch(() => {}).finally(done);
2160
+ } else {
2161
+ done();
2162
+ }
2163
+ }
2164
+
2165
+ async function enterPresentationMode() {
2166
+ if (!editorRoot) return;
2167
+ editorRoot.classList.add('lp-editor-presentation-mode');
2168
+ updateScale();
2169
+ try {
2170
+ const target = editorRoot.requestFullscreen ? editorRoot : document.documentElement;
2171
+ if (target.requestFullscreen) {
2172
+ await target.requestFullscreen();
2173
+ }
2174
+ } catch (err) {
2175
+ console.warn('进入全屏失败,将以窗口演示模式播放', err);
2176
+ }
2177
+ if (!presentationExitBtn) {
2178
+ presentationExitBtn = document.createElement('button');
2179
+ presentationExitBtn.className = 'lp-editor-presentation-exit';
2180
+ presentationExitBtn.textContent = '退出演示 (Esc)';
2181
+ presentationExitBtn.type = 'button';
2182
+ presentationExitBtn.addEventListener('click', exitPresentationMode);
2183
+ document.body.appendChild(presentationExitBtn);
2184
+ }
2185
+ if (!presentationKeyHandler) {
2186
+ presentationKeyHandler = (e) => {
2187
+ if (e.key === 'Escape') {
2188
+ e.preventDefault();
2189
+ exitPresentationMode();
2190
+ }
2191
+ };
2192
+ document.addEventListener('keydown', presentationKeyHandler);
2193
+ }
2194
+ }
2195
+
2196
+ document.addEventListener('fullscreenchange', () => {
2197
+ if (!document.fullscreenElement && editorRoot && editorRoot.classList.contains('lp-editor-presentation-mode')) {
2198
+ exitPresentationMode();
2199
+ }
2200
+ });
2201
+
2202
+ if (playBtn) {
2203
+ playBtn.addEventListener('click', enterPresentationMode);
2204
+ }
2205
+
2206
+ // 内容结构变更后重新渲染当前编辑器(仅用于数组增删等模板内容调整)
2207
+ function saveCurrentForReload() {
2208
+ try {
2209
+ localStorage.setItem('lemonppt:editor:currentSlide', String(selectedSlideIdx));
2210
+ } catch (e) {}
2211
+ }
2212
+
2213
+ function getSlideLabel(slide) {
2214
+ const props = slide.props || {};
2215
+ const candidates = [
2216
+ props.title,
2217
+ props.name,
2218
+ props.kicker,
2219
+ props.quote,
2220
+ props.heading,
2221
+ props.items && Array.isArray(props.items) ? props.items[0] : undefined,
2222
+ props.metric,
2223
+ props.value,
2224
+ ];
2225
+ for (const c of candidates) {
2226
+ if (c !== undefined && c !== null && String(c).trim()) return String(c).trim();
2227
+ }
2228
+ return slide.layout;
2229
+ }
2230
+
2231
+ // 静态文件模式下,增删幻灯片后不再使用 document.write 重写整页(会中断 CSS/资源加载),
2232
+ // 而是直接在现有 DOM 中重建 slide 容器和缩略图,并复用 React root 重新渲染内容。
2233
+ function rebuildSlidesAndThumbnails() {
2234
+ if (!deck) return;
2235
+ if (typeof window.__lemonPPT_renderSlideHtml !== 'function') return;
2236
+
2237
+ // DOM 重建前将 current 同步到合法范围,避免后续 goTo/updateClasses 越界
2238
+ current = Math.max(0, Math.min(selectedSlideIdx, goal.slides.length - 1));
2239
+
2240
+ const firstWrapper = slides[0];
2241
+ const width = firstWrapper ? parseInt(firstWrapper.style.width, 10) || 1280 : 1280;
2242
+ const height = firstWrapper ? parseInt(firstWrapper.style.height, 10) || 720 : 720;
2243
+ const theme = goal.theme || 'theme01';
2244
+
2245
+ // 重建 deck 中的 slide 容器
2246
+ const wrappersHtml = goal.slides.map((slide, index) => {
2247
+ const slideHtml = window.__lemonPPT_renderSlideHtml(slide, { slideIdx: index, editable: true, theme });
2248
+ const stateClass = index === current ? 'active' : (index < current ? 'prev' : '');
2249
+ const transition = String((slide.props && slide.props.transition) || 'none');
2250
+ return '<div class="lp-slide-wrapper ' + stateClass + '" data-slide-index="' + index + '" data-layout="' + slide.layout + '" data-lp-transition="' + transition + '" style="width:' + width + 'px;height:' + height + 'px;box-sizing:border-box;">' + slideHtml + '</div>';
2251
+ }).join('');
2252
+ deck.innerHTML = wrappersHtml;
2253
+
2254
+ // 重建左侧缩略图
2255
+ const thumbnailsContainer = document.querySelector('.lp-editor-thumbnails');
2256
+ if (thumbnailsContainer) {
2257
+ const thumbInnerWidth = 156;
2258
+ const scale = thumbInnerWidth / width;
2259
+ const showDragHandle = goal.slides.length > 1;
2260
+ const dragHandleHtml = showDragHandle
2261
+ ? '<span class="lp-thumbnail-drag-handle" data-lp-action="drag-handle" aria-hidden="true">' +
2262
+ '<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor" aria-hidden="true">' +
2263
+ '<circle cx="2.5" cy="2.5" r="1.5"/>' +
2264
+ '<circle cx="9.5" cy="2.5" r="1.5"/>' +
2265
+ '<circle cx="2.5" cy="8" r="1.5"/>' +
2266
+ '<circle cx="9.5" cy="8" r="1.5"/>' +
2267
+ '<circle cx="2.5" cy="13.5" r="1.5"/>' +
2268
+ '<circle cx="9.5" cy="13.5" r="1.5"/>' +
2269
+ '</svg>' +
2270
+ '</span>'
2271
+ : '';
2272
+ const buttonsHtml = goal.slides.map((slide, index) => {
2273
+ const label = getSlideLabel(slide);
2274
+ const activeClass = index === current ? 'active' : '';
2275
+ const slideHtml = window.__lemonPPT_renderSlideHtml(slide, { slideIdx: index, editable: true, theme });
2276
+ const draggableAttr = goal.slides.length > 1 ? 'draggable="true"' : '';
2277
+ return '<div class="lp-thumbnail ' + activeClass + '" data-index="' + index + '" role="button" tabindex="0" ' + draggableAttr + ' aria-label="幻灯片 ' + (index + 1) + ',拖动可调整顺序">' +
2278
+ dragHandleHtml +
2279
+ '<div class="lp-thumbnail-render">' +
2280
+ '<div class="lp-thumbnail-scaler" style="width:' + width + 'px;height:' + height + 'px;transform:scale(' + scale + ');transform-origin:top left;">' + slideHtml + '</div>' +
2281
+ '</div>' +
2282
+ '<div class="lp-thumbnail-scrim"></div>' +
2283
+ '<div class="lp-thumbnail-content">' +
2284
+ '<div class="lp-thumbnail-index">' + (index + 1) + ' / ' + goal.slides.length + '</div>' +
2285
+ '<div class="lp-thumbnail-title">' + escapeHtml(label) + '</div>' +
2286
+ '<div class="lp-thumbnail-layout">' + escapeHtml(slide.layout) + '</div>' +
2287
+ '</div>' +
2288
+ '<span class="lp-thumbnail-delete" data-lp-action="delete-slide" data-index="' + index + '" title="删除幻灯片" aria-label="删除幻灯片">×</span>' +
2289
+ '</div>';
2290
+ }).join('');
2291
+ thumbnailsContainer.innerHTML = buttonsHtml;
2292
+ // 缩略图 DOM 重建后清空虚拟滚动缓存,避免旧 HTML 与新索引错位
2293
+ Object.keys(thumbnailRenderCache).forEach((key) => {
2294
+ delete thumbnailRenderCache[key];
2295
+ });
2296
+ }
2297
+
2298
+ // 同步页码计数器
2299
+ const pageCounter = document.querySelector('.lp-editor-page-counter');
2300
+ if (pageCounter) {
2301
+ pageCounter.innerHTML = '<span id="lp-current">' + (current + 1) + '</span> / ' + goal.slides.length;
2302
+ }
2303
+
2304
+ refreshSlidesAndThumbnails();
2305
+ attachThumbnailListeners();
2306
+ updateClasses();
2307
+ renderAllSlidesToRoot();
2308
+ initThumbnailVirtualScroll();
2309
+ }
2310
+
2311
+ function refreshCurrentSlide() {
2312
+ if (typeof window.__lemonPPT_renderSlideToRoot === 'function') {
2313
+ renderCurrentSlideToRoot();
2314
+ } else {
2315
+ // 兜底:旧版静态 HTML 替换
2316
+ const wrapper = document.querySelector('.lp-slide-wrapper.active');
2317
+ if (!wrapper) return;
2318
+ const slide = goal.slides[selectedSlideIdx];
2319
+ if (!slide) return;
2320
+ if (typeof window.__lemonPPT_disposeECharts === 'function') {
2321
+ window.__lemonPPT_disposeECharts();
2322
+ }
2323
+ const html = window.__lemonPPT_renderSlideHtml(slide, { slideIdx: selectedSlideIdx, editable: true, theme: goal.theme });
2324
+ wrapper.innerHTML = html;
2325
+ initActiveSlideECharts();
2326
+ }
2327
+ }
2328
+
2329
+ async function reloadEditor() {
2330
+ saveCurrentForReload();
2331
+
2332
+ // 优先尝试服务器端重新渲染(开发服务器模式)
2333
+ try {
2334
+ const res = await fetch('/api/render-editor', {
2335
+ method: 'POST',
2336
+ headers: { 'Content-Type': 'application/json' },
2337
+ body: JSON.stringify(goal),
2338
+ });
2339
+ if (!res.ok) throw new Error('重新渲染失败: ' + res.status);
2340
+ window.location.reload();
2341
+ return;
2342
+ } catch (err) {
2343
+ // 静态文件模式下,直接在当前 DOM 中重建 slide 容器和缩略图,
2344
+ // 避免 document.write 中断 CSS/资源加载导致幻灯片内容无法渲染。
2345
+ if (typeof window.__lemonPPT_disposeECharts === 'function') {
2346
+ window.__lemonPPT_disposeECharts();
2347
+ }
2348
+ rebuildSlidesAndThumbnails();
2349
+ initActiveSlideECharts();
2350
+ }
2351
+ }
2352
+
2353
+ // 元素选中与右侧属性面板
2354
+ let selectedEl = null;
2355
+ let selectedSlideIdx = 0;
2356
+ const propertyContent = document.getElementById('lp-property-content');
2357
+ const layoutSchemas = window.__lemonPPT_layoutSchemas || {};
2358
+
2359
+ const FIELD_LABELS = {
2360
+ kicker: '标签',
2361
+ title: '标题',
2362
+ subtitle: '副标题',
2363
+ date: '日期',
2364
+ quote: '引用',
2365
+ author: '作者',
2366
+ role: '职位',
2367
+ company: '公司',
2368
+ value: '数值',
2369
+ unit: '单位',
2370
+ label: '指标名',
2371
+ description: '说明',
2372
+ change: '变化',
2373
+ imageUrl: '图片',
2374
+ url: '图片',
2375
+ logoUrl: 'Logo',
2376
+ avatarUrl: '头像',
2377
+ caption: '说明',
2378
+ q: '问题',
2379
+ a: '回答',
2380
+ question: '问题',
2381
+ answer: '回答',
2382
+ name: '名称',
2383
+ price: '价格',
2384
+ period: '周期',
2385
+ cta: '按钮文案',
2386
+ type: '图表类型',
2387
+ status: '状态',
2388
+ bio: '简介',
2389
+ items: '目录项',
2390
+ points: '要点',
2391
+ leftPoints: '左侧要点',
2392
+ rightPoints: '右侧要点',
2393
+ features: '特性',
2394
+ steps: '步骤',
2395
+ stats: '统计数据',
2396
+ members: '成员',
2397
+ milestones: '里程碑',
2398
+ phases: '阶段',
2399
+ plans: '方案',
2400
+ tiers: '套餐',
2401
+ images: '图片',
2402
+ partners: '合作伙伴',
2403
+ datasets: '数据集',
2404
+ metrics: '指标',
2405
+ cards: '卡片',
2406
+ highlighted: '高亮',
2407
+ columns: '主题列',
2408
+ panels: '指标面板',
2409
+ left: '左侧内容',
2410
+ right: '右侧内容',
2411
+ centerCard: '中央结论卡',
2412
+ comparisons: '对比项',
2413
+ conclusion: '结论',
2414
+ chartType: '图表类型',
2415
+ chartData: '图表数据',
2416
+ footnote: '脚注',
2417
+ source: '来源',
2418
+ number: '序号',
2419
+ context: '上下文',
2420
+ showInsight: '重点强调',
2421
+ insight: '洞察面板',
2422
+ previous: '原数值',
2423
+ current: '现数值',
2424
+ max: '最大值',
2425
+ tone: '色调',
2426
+ percent: '占比',
2427
+ color: '颜色',
2428
+ rank: '排名',
2429
+ company: '公司',
2430
+ track: '赛道',
2431
+ amount: '金额',
2432
+ highlightRow: '高亮行',
2433
+ highlightFirstColumn: '高亮首列',
2434
+ leftTitle: '左侧标题',
2435
+ rightTitle: '右侧标题',
2436
+ leftValue: '左侧数值',
2437
+ rightValue: '右侧数值',
2438
+ leftLabel: '左侧标签',
2439
+ rightLabel: '右侧标签',
2440
+ size: '尺寸',
2441
+ icon: '图标',
2442
+ page: '页码',
2443
+ image: '图片',
2444
+ avatar: '头像',
2445
+ logo: 'Logo',
2446
+ };
2447
+
2448
+ const LAYOUT_LABELS = {
2449
+ cover_v1: '封面',
2450
+ cover_v2: '封面(居中)',
2451
+ cover_v3: '封面(全图)',
2452
+ table_of_contents_v1: '目录',
2453
+ table_of_contents_v2: '目录(卡片)',
2454
+ content_v1: '内容列表',
2455
+ content_v2: '内容对比',
2456
+ content_v3: '内容段落',
2457
+ content_v4: '内容卡片',
2458
+ chart_v1: '图表',
2459
+ chart_v2: '多系列图表',
2460
+ process_v1: '流程步骤',
2461
+ process_v2: '流程时间线',
2462
+ process_v3: '流程卡片',
2463
+ stats_v1: '数据指标',
2464
+ stats_v2: '数据指标(卡片)',
2465
+ metric_v1: '核心指标',
2466
+ metric_v2: '核心指标(大数字)',
2467
+ metric_v3: '双指标对比',
2468
+ feature_v1: '特性列表',
2469
+ feature_v2: '特性网格',
2470
+ feature_v3: '特性卡片',
2471
+ team_v1: '团队介绍',
2472
+ team_v2: '团队卡片',
2473
+ timeline_v1: '时间线',
2474
+ timeline_v2: '时间线(横向)',
2475
+ timeline_v3: '时间线(里程碑)',
2476
+ roadmap_v1: '路线图',
2477
+ faq_v1: '问答',
2478
+ partners_v1: '合作伙伴',
2479
+ gallery_v1: '图片集',
2480
+ gallery_v2: '图片集(网格)',
2481
+ pricing_v1: '定价',
2482
+ pricing_v2: '定价方案',
2483
+ closing_v2: '结束页',
2484
+ quote_v1: '引用页',
2485
+ testimonial_v1: '推荐语',
2486
+ metric_big: '大数字页',
2487
+ metric_triptych: '三指标总览',
2488
+ chart_donut: '环形图拆解',
2489
+ chart_funnel: '漏斗图',
2490
+ table_data: '数据表格',
2491
+ case_study: '案例详情',
2492
+ spotlight_grid: '主题聚焦网格',
2493
+ diptych_contrast: '双联对比',
2494
+ conclusion_v1: '结论页',
2495
+ table_v1: '表格',
2496
+ ranking_v1: '排行榜',
2497
+ region_v1: '地区分布',
2498
+ risk_v1: '风险研判',
2499
+ outlook_v1: '投资展望',
2500
+ bento_v1: 'Bento 网格',
2501
+ gallery_v1: '图片集',
2502
+ gantt_v1: '甘特图',
2503
+ quadrant_v1: '四象限',
2504
+ scorecard_v1: '评分卡',
2505
+ tags_v1: '标签云',
2506
+ filmstrip_v1: '胶片条',
2507
+ swot_v1: 'SWOT 分析',
2508
+ pest_v1: 'PEST 分析',
2509
+ appendix_v1: '附录',
2510
+ image_v1: '图片页',
2511
+ process_v1: '流程步骤',
2512
+ faq_v1: '问答',
2513
+ partners_v1: '合作伙伴',
2514
+ pricing_v1: '定价',
2515
+ team_v1: '团队介绍',
2516
+ team_v2: '团队卡片',
2517
+ timeline_v1: '时间线',
2518
+ roadmap_v1: '路线图',
2519
+ chapter_v1: '章节页',
2520
+ chapter_v2: '章节页(全图)',
2521
+ chapter_v3: '章节页(卡片)',
2522
+ cover_v3: '封面(全图)',
2523
+ cover_v4: '封面(杂志)',
2524
+ chart_bar3d: '3D 柱状图',
2525
+ chart_gauge: '仪表盘',
2526
+ chart_graph: '关系图',
2527
+ chart_heatmap: '热力图',
2528
+ chart_radar: '雷达图',
2529
+ chart_sankey: '桑基图',
2530
+ chart_sunburst: '旭日图',
2531
+ chart_treemap: '矩形树图',
2532
+ chart_wordcloud: '词云',
2533
+ content_v4: '内容卡片',
2534
+ metrics_v1: '指标墙',
2535
+ delta_v1: '今昔对照',
2536
+ comparison_v1: '对比分析',
2537
+ progress_v1: '进度条',
2538
+ number_showcase_v1: '数字秀',
2539
+ testimonial_v1: '推荐语',
2540
+ closing_v1: '结束页',
2541
+ };
2542
+
2543
+ function getLayoutLabel(layoutId) {
2544
+ if (LAYOUT_LABELS[layoutId]) return LAYOUT_LABELS[layoutId];
2545
+ const shortId = String(layoutId).replace(/^theme\d+_/, '');
2546
+ return LAYOUT_LABELS[shortId] || layoutId;
2547
+ }
2548
+
2549
+ function resolveLayoutSchema(slide) {
2550
+ if (layoutSchemas[slide.layout]) return layoutSchemas[slide.layout];
2551
+ const theme = goal.theme || 'theme01';
2552
+ const shortId = String(slide.layout).replace(/^theme\d+_/, '');
2553
+ return layoutSchemas[theme + '_' + shortId] || null;
2554
+ }
2555
+
2556
+ function getFieldLabel(path) {
2557
+ const key = String(path).split('.').pop() || '';
2558
+ return FIELD_LABELS[key] || key;
2559
+ }
2560
+
2561
+ function inferFieldType(path, value) {
2562
+ const key = String(path).split('.').pop() || '';
2563
+ if (/image|url|logo|avatar/i.test(key) && typeof value === 'string') return 'image';
2564
+ if (typeof value === 'boolean') return 'boolean';
2565
+ if (typeof value === 'number') return 'number';
2566
+ if (key === 'type' && ['bar', 'line', 'pie'].includes(String(value))) return 'select';
2567
+ if (key === 'status') return 'select';
2568
+ if (['description', 'bio', 'quote', 'answer', 'a', 'subtitle'].includes(key)) return 'textarea';
2569
+ return 'text';
2570
+ }
2571
+
2572
+ function getSelectOptions(path) {
2573
+ const key = String(path).split('.').pop() || '';
2574
+ if (key === 'type') return [{ value: 'bar', label: '柱状' }, { value: 'line', label: '折线' }, { value: 'pie', label: '饼图' }];
2575
+ if (key === 'status') return [{ value: '已完成', label: '已完成' }, { value: '进行中', label: '进行中' }, { value: '规划中', label: '规划中' }];
2576
+ return [];
2577
+ }
2578
+
2579
+ function clearSelection() {
2580
+ if (selectedEl) {
2581
+ selectedEl.classList.remove('lp-selected');
2582
+ selectedEl = null;
2583
+ }
2584
+ selectedSlideIdx = current;
2585
+ renderSlidePanel();
2586
+ }
2587
+
2588
+ function setField(path, value) {
2589
+ const slide = goal.slides[selectedSlideIdx];
2590
+ if (!slide) return;
2591
+ recordHistory();
2592
+ setProp(slide.props, path, value);
2593
+ // 打开依赖开关时,如果被控字段为空且配有默认值,则自动填充测试数据
2594
+ if (value === true) {
2595
+ const schema = resolveLayoutSchema(slide);
2596
+ if (schema && schema.fields) {
2597
+ schema.fields.forEach((field) => {
2598
+ if (field.visibleWhen && field.visibleWhen.key === path && field.visibleWhen.value === true && field.defaultValue !== undefined) {
2599
+ const current = getProp(slide.props, field.key);
2600
+ const isEmpty = current == null || (typeof current === 'object' && Object.keys(current).length === 0);
2601
+ if (isEmpty) {
2602
+ setProp(slide.props, field.key, deepClone(field.defaultValue));
2603
+ }
2604
+ }
2605
+ });
2606
+ }
2607
+ }
2608
+ syncDomFromGoal(path);
2609
+ autoSave();
2610
+ // 如果该字段在画布上没有对应的直接编辑元素(如 SVG 图表数据),刷新整页幻灯片。
2611
+ // 对于标记为 data-lp-chart-data 的图表数据字段,即使画布上可直接编辑,也需要
2612
+ // 重新渲染图表以同步数据变化。
2613
+ if (!hasEditableElementForPath(path) || hasChartDataElementForPath(path)) {
2614
+ refreshCurrentSlide();
2615
+ }
2616
+ }
2617
+
2618
+ function hasEditableElementForPath(path) {
2619
+ const selector = '[data-lp-editable="true"][data-lp-slide-idx="' + selectedSlideIdx + '"][data-lp-prop="' + path + '"]';
2620
+ return document.querySelector(selector) !== null;
2621
+ }
2622
+
2623
+ function hasChartDataElementForPath(path) {
2624
+ const selector = '[data-lp-editable="true"][data-lp-chart-data="true"][data-lp-slide-idx="' + selectedSlideIdx + '"][data-lp-prop="' + path + '"]';
2625
+ return document.querySelector(selector) !== null;
2626
+ }
2627
+
2628
+ function createEl(tag, className, parent) {
2629
+ const el = document.createElement(tag);
2630
+ if (className) el.className = className;
2631
+ if (parent) parent.appendChild(el);
2632
+ return el;
2633
+ }
2634
+
2635
+ function createTextField(label, value, onChange) {
2636
+ const wrap = createEl('div', 'lp-property-field');
2637
+ createEl('label', 'lp-property-label', wrap).textContent = label;
2638
+ const input = createEl('input', 'lp-property-input', wrap);
2639
+ input.type = 'text';
2640
+ input.value = value == null ? '' : String(value);
2641
+ input.addEventListener('input', () => onChange(input.value));
2642
+ return wrap;
2643
+ }
2644
+
2645
+ function createTextareaField(label, value, onChange) {
2646
+ const wrap = createEl('div', 'lp-property-field');
2647
+ createEl('label', 'lp-property-label', wrap).textContent = label;
2648
+ const textarea = createEl('textarea', 'lp-property-textarea', wrap);
2649
+ textarea.value = value == null ? '' : String(value);
2650
+ textarea.addEventListener('input', () => onChange(textarea.value));
2651
+ return wrap;
2652
+ }
2653
+
2654
+ function createNumberField(label, value, onChange) {
2655
+ const wrap = createEl('div', 'lp-property-field');
2656
+ createEl('label', 'lp-property-label', wrap).textContent = label;
2657
+ const input = createEl('input', 'lp-property-input', wrap);
2658
+ input.type = 'number';
2659
+ input.value = value == null ? '' : String(value);
2660
+ input.addEventListener('input', () => onChange(Number(input.value)));
2661
+ return wrap;
2662
+ }
2663
+
2664
+ function renderSliderTicks(ruler, min, max) {
2665
+ const scale = createEl('div', 'lp-property-slider-scale', ruler);
2666
+ const span = max - min;
2667
+ if (span <= 0) return scale;
2668
+ // 刻度步长:范围小时每个整数一个刻度,范围大时均匀取 6~10 个
2669
+ const desiredTicks = 8;
2670
+ const step = span <= desiredTicks ? 1 : Math.max(1, Math.round(span / desiredTicks));
2671
+ for (let i = min; i <= max; i += step) {
2672
+ const tick = createEl('div', 'lp-property-slider-tick', scale);
2673
+ tick.style.left = ((i - min) / span) * 100 + '%';
2674
+ const label = createEl('div', 'lp-property-slider-tick-label', tick);
2675
+ label.textContent = String(i);
2676
+ tick.addEventListener('click', () => {
2677
+ const input = ruler.querySelector('input[type="range"]');
2678
+ if (input) {
2679
+ input.value = String(i);
2680
+ input.dispatchEvent(new Event('input', { bubbles: true }));
2681
+ input.dispatchEvent(new Event('change', { bubbles: true }));
2682
+ }
2683
+ });
2684
+ }
2685
+ return scale;
2686
+ }
2687
+
2688
+ function createSliderField(field, value, onChange) {
2689
+ const wrap = createEl('div', 'lp-property-slider-field');
2690
+ const header = createEl('div', 'lp-property-section-header', wrap);
2691
+ createEl('div', 'lp-property-section-title', header).textContent = field.label;
2692
+ const countBadge = createEl('span', 'lp-property-slider-value', header);
2693
+ const initialValue = value == null ? (field.min || 0) : value;
2694
+ countBadge.textContent = String(initialValue);
2695
+
2696
+ const min = typeof field.min === 'number' ? field.min : 0;
2697
+ const max = typeof field.max === 'number' ? field.max : 100;
2698
+
2699
+ const sliderWrap = createEl('div', 'lp-property-slider-wrap', wrap);
2700
+ const ruler = createEl('div', 'lp-property-slider-ruler', sliderWrap);
2701
+ const range = document.createElement('input');
2702
+ range.type = 'range';
2703
+ range.className = 'lp-property-slider';
2704
+ range.min = String(min);
2705
+ range.max = String(max);
2706
+ range.value = String(initialValue);
2707
+ ruler.appendChild(range);
2708
+ renderSliderTicks(ruler, min, max);
2709
+
2710
+ range.addEventListener('input', () => {
2711
+ const num = Number(range.value);
2712
+ countBadge.textContent = String(num);
2713
+ onChange(num);
2714
+ });
2715
+
2716
+ return wrap;
2717
+ }
2718
+
2719
+ function createToggleField(label, value, onChange) {
2720
+ const wrap = createEl('div', 'lp-property-field');
2721
+ const labelEl = createEl('label', 'lp-property-toggle', wrap);
2722
+ const text = createEl('span', '', labelEl);
2723
+ text.textContent = label;
2724
+ const input = createEl('input', '', labelEl);
2725
+ input.type = 'checkbox';
2726
+ input.checked = !!value;
2727
+ const track = createEl('div', 'lp-property-toggle-track', labelEl);
2728
+ createEl('div', 'lp-property-toggle-thumb', track);
2729
+ input.addEventListener('change', () => onChange(input.checked));
2730
+ return wrap;
2731
+ }
2732
+
2733
+ function createSelectField(label, value, options, onChange) {
2734
+ const wrap = createEl('div', 'lp-property-field');
2735
+ createEl('label', 'lp-property-label', wrap).textContent = label;
2736
+ const segmented = createEl('div', 'lp-property-segmented', wrap);
2737
+ options.forEach((opt) => {
2738
+ const btn = createEl('button', '', segmented);
2739
+ btn.textContent = opt.label;
2740
+ btn.type = 'button';
2741
+ if (opt.value === value) btn.classList.add('active');
2742
+ btn.addEventListener('click', () => {
2743
+ Array.from(segmented.children).forEach((b) => b.classList.remove('active'));
2744
+ btn.classList.add('active');
2745
+ onChange(opt.value);
2746
+ });
2747
+ });
2748
+ return wrap;
2749
+ }
2750
+
2751
+ function createImageHintField(label) {
2752
+ const wrap = createEl('div', 'lp-property-field');
2753
+ createEl('label', 'lp-property-label', wrap).textContent = label;
2754
+ const hint = createEl('div', 'lp-property-help', wrap);
2755
+ hint.textContent = '请点击画布中的图片区域直接上传';
2756
+ return wrap;
2757
+ }
2758
+
2759
+ function createColorField(label, value, onChange) {
2760
+ const wrap = createEl('div', 'lp-property-field');
2761
+ createEl('label', 'lp-property-label', wrap).textContent = label;
2762
+ const row = createEl('div', '', wrap);
2763
+ row.style.display = 'flex';
2764
+ row.style.alignItems = 'center';
2765
+ row.style.gap = '8px';
2766
+ const input = createEl('input', 'lp-property-input', row);
2767
+ input.type = 'text';
2768
+ input.value = value == null ? '' : String(value);
2769
+ input.style.flex = '1';
2770
+ input.addEventListener('input', () => onChange(input.value));
2771
+ const picker = createEl('input', '', row);
2772
+ picker.type = 'color';
2773
+ picker.value = value || '#000000';
2774
+ picker.style.width = '32px';
2775
+ picker.style.height = '32px';
2776
+ picker.style.border = 'none';
2777
+ picker.style.background = 'none';
2778
+ picker.style.cursor = 'pointer';
2779
+ picker.addEventListener('input', () => {
2780
+ input.value = picker.value;
2781
+ onChange(picker.value);
2782
+ });
2783
+ return wrap;
2784
+ }
2785
+
2786
+ function createFieldControl(path, value) {
2787
+ const type = inferFieldType(path, value);
2788
+ const label = getFieldLabel(path);
2789
+ if (type === 'image') return createImageHintField(label);
2790
+ if (type === 'textarea') return createTextareaField(label, value, (v) => setField(path, v || undefined));
2791
+ if (type === 'number') return createNumberField(label, value, (v) => setField(path, v));
2792
+ if (type === 'boolean') return createToggleField(label, value, (v) => setField(path, v));
2793
+ if (type === 'select') return createSelectField(label, value, getSelectOptions(path), (v) => setField(path, v));
2794
+ return createTextField(label, value, (v) => setField(path, v || undefined));
2795
+ }
2796
+
2797
+ function createSchemaFieldControl(field, value, path, skipCanvasEditable) {
2798
+ const label = field.label || field.key;
2799
+ if (skipCanvasEditable && (field.type === 'text' || field.type === 'textarea' || field.type === 'image')) {
2800
+ return null;
2801
+ }
2802
+ if (field.type === 'array') {
2803
+ const wrap = createEl('div', 'lp-property-nested-array');
2804
+ createSchemaArraySection(field, Array.isArray(value) ? value : [], wrap, path, skipCanvasEditable);
2805
+ return wrap;
2806
+ }
2807
+ if (field.type === 'image') return createImageHintField(label);
2808
+ if (field.type === 'textarea') return createTextareaField(label, value, (v) => setField(path, v || undefined));
2809
+ if (field.type === 'number') return createNumberField(label, value, (v) => setField(path, Number(v)));
2810
+ if (field.type === 'slider') {
2811
+ const effectiveValue = value === undefined && field.defaultValue !== undefined ? field.defaultValue : value;
2812
+ let sliderField = field;
2813
+ if (field.key === 'highlightRow') {
2814
+ const slideProps = goal.slides[selectedSlideIdx]?.props;
2815
+ const rows = Array.isArray(slideProps?.rows) ? slideProps.rows : [];
2816
+ sliderField = { ...field, max: Math.min(field.max ?? 11, Math.max(0, rows.length - 1)) };
2817
+ }
2818
+ return createSliderField(sliderField, effectiveValue, (v) => setField(path, v));
2819
+ }
2820
+ if (field.type === 'boolean') {
2821
+ const effectiveValue = value === undefined && field.defaultValue !== undefined ? field.defaultValue : value;
2822
+ return createToggleField(label, effectiveValue, (v) => {
2823
+ setField(path, v);
2824
+ renderSlidePanel();
2825
+ });
2826
+ }
2827
+ if (field.type === 'select') return createSelectField(label, value, field.options || [], (v) => setField(path, v));
2828
+ if (field.type === 'color') return createColorField(label, value, (v) => setField(path, v));
2829
+ return createTextField(label, value, (v) => setField(path, v || undefined));
2830
+ }
2831
+
2832
+ function getSchemaEmptyItem(itemSchema) {
2833
+ if (!itemSchema || !itemSchema.length) return '示例项';
2834
+ // itemSchema 只有单一占位字段时,表示数组元素是原始值(字符串/数字等)
2835
+ if (itemSchema.length === 1 && itemSchema[0].key === 'item') {
2836
+ const field = itemSchema[0];
2837
+ if (field.defaultValue !== undefined) return JSON.parse(JSON.stringify(field.defaultValue));
2838
+ if (field.type === 'boolean') return false;
2839
+ if (field.type === 'number') return 100;
2840
+ if (field.type === 'textarea') return '示例内容';
2841
+ return '示例项';
2842
+ }
2843
+ const item = {};
2844
+ itemSchema.forEach((field) => {
2845
+ if (field.defaultValue !== undefined) {
2846
+ item[field.key] = JSON.parse(JSON.stringify(field.defaultValue));
2847
+ } else if (field.type === 'boolean') {
2848
+ item[field.key] = false;
2849
+ } else if (field.type === 'number') {
2850
+ item[field.key] = 100;
2851
+ } else if (field.type === 'textarea') {
2852
+ item[field.key] = '示例内容';
2853
+ } else if (field.type === 'array') {
2854
+ const count = typeof field.minItems === 'number' ? Math.max(1, field.minItems) : 1;
2855
+ const empty = [];
2856
+ for (let i = 0; i < count; i++) {
2857
+ empty.push(getSchemaEmptyItem(field.itemSchema));
2858
+ }
2859
+ item[field.key] = empty;
2860
+ } else if (field.type === 'image') {
2861
+ item[field.key] = '';
2862
+ } else if (field.type === 'text') {
2863
+ item[field.key] = '';
2864
+ } else if (field.type === 'select') {
2865
+ const firstOption = Array.isArray(field.options) && field.options.length > 0 ? field.options[0] : undefined;
2866
+ item[field.key] = firstOption && typeof firstOption === 'object' ? firstOption.value : '';
2867
+ } else {
2868
+ item[field.key] = '示例文字';
2869
+ }
2870
+ });
2871
+ return item;
2872
+ }
2873
+
2874
+ function createSchemaArraySection(field, array, parent, basePath, skipCanvasEditable) {
2875
+ const path = basePath || field.key;
2876
+ const section = createEl('div', 'lp-property-section', parent);
2877
+
2878
+ // 条目数由组件样式决定:minItems / maxItems 来自版式 Schema
2879
+ const minItems = typeof field.minItems === 'number' ? field.minItems : 1;
2880
+ // 当 Schema 未声明 maxItems 时,使用一个默认上限(而不是当前数组长度),
2881
+ // 避免用户减少条目后 slider 的 max 同步缩小,导致无法恢复或继续增加。
2882
+ const maxItems = typeof field.maxItems === 'number' ? field.maxItems : Math.max(array.length, minItems, 6);
2883
+ const currentCount = Math.max(minItems, Math.min(maxItems, array.length));
2884
+
2885
+ // 标题与数值放在同一行
2886
+ const header = createEl('div', 'lp-property-section-header', section);
2887
+ createEl('div', 'lp-property-section-title', header).textContent = field.label;
2888
+ const countBadge = createEl('span', 'lp-property-slider-value', header);
2889
+ countBadge.textContent = String(currentCount);
2890
+
2891
+ // 数量滑块(标尺样式)
2892
+ const sliderWrap = createEl('div', 'lp-property-slider-wrap', section);
2893
+ const ruler = createEl('div', 'lp-property-slider-ruler', sliderWrap);
2894
+ const range = document.createElement('input');
2895
+ range.type = 'range';
2896
+ range.className = 'lp-property-slider';
2897
+ range.min = String(minItems);
2898
+ range.max = String(maxItems);
2899
+ range.value = String(currentCount);
2900
+ ruler.appendChild(range);
2901
+ renderSliderTicks(ruler, minItems, maxItems);
2902
+
2903
+ function setArrayLength(newLength) {
2904
+ const slide = goal.slides[selectedSlideIdx];
2905
+ if (!slide) return;
2906
+ let arr = getProp(slide.props, path);
2907
+ if (!Array.isArray(arr)) {
2908
+ arr = [];
2909
+ setProp(slide.props, path, arr);
2910
+ }
2911
+ if (newLength < arr.length) {
2912
+ arr.length = newLength;
2913
+ } else if (newLength > arr.length) {
2914
+ while (arr.length < newLength) {
2915
+ arr.push(getSchemaEmptyItem(field.itemSchema));
2916
+ }
2917
+ }
2918
+ }
2919
+
2920
+ function updateSliderFill() {
2921
+ const min = Number(range.min);
2922
+ const max = Number(range.max);
2923
+ const pct = max === min ? 0 : ((Number(range.value) - min) / (max - min)) * 100;
2924
+ range.style.background = 'linear-gradient(90deg, rgba(91,155,213,1) 0%, rgba(139,92,246,1) ' + pct + '%, rgba(255,255,255,0.14) ' + pct + '%, rgba(255,255,255,0.14) 100%)';
2925
+ }
2926
+ updateSliderFill();
2927
+
2928
+ range.addEventListener('input', () => {
2929
+ const newLength = Number(range.value);
2930
+ countBadge.textContent = String(newLength);
2931
+ setArrayLength(newLength);
2932
+ updateSliderFill();
2933
+ refreshCurrentSlide();
2934
+ });
2935
+
2936
+ range.addEventListener('change', () => {
2937
+ recordHistory();
2938
+ autoSave();
2939
+ renderSlidePanel();
2940
+ });
2941
+
2942
+ // 点击标尺快速跳转
2943
+ ruler.addEventListener('click', (e) => {
2944
+ if (e.target === range || range.contains(e.target)) return;
2945
+ const rect = ruler.getBoundingClientRect();
2946
+ const padding = 9;
2947
+ const availableWidth = rect.width - padding * 2;
2948
+ const x = e.clientX - rect.left - padding;
2949
+ const ratio = Math.max(0, Math.min(1, x / availableWidth));
2950
+ const newLength = Math.round(minItems + ratio * (maxItems - minItems));
2951
+ if (newLength !== Number(range.value)) {
2952
+ range.value = String(newLength);
2953
+ countBadge.textContent = String(newLength);
2954
+ setArrayLength(newLength);
2955
+ updateSliderFill();
2956
+ refreshCurrentSlide();
2957
+ recordHistory();
2958
+ autoSave();
2959
+ renderSlidePanel();
2960
+ }
2961
+ });
2962
+
2963
+ // 显示当前条目的可编辑字段(简单文字字段仍在画布上编辑,此处跳过)
2964
+ const isPrimitiveItem = field.itemSchema && field.itemSchema.length === 1 && field.itemSchema[0].key === 'item';
2965
+ const allSubFieldsAreCanvasEditable = skipCanvasEditable && (() => {
2966
+ if (!field.itemSchema) return true;
2967
+ if (isPrimitiveItem) {
2968
+ return ['text', 'textarea', 'image'].includes(field.itemSchema[0].type);
2969
+ }
2970
+ return field.itemSchema.every((subField) => ['text', 'textarea', 'image'].includes(subField.type));
2971
+ })();
2972
+
2973
+ if (!allSubFieldsAreCanvasEditable) {
2974
+ const list = createEl('div', 'lp-property-array', section);
2975
+ array.forEach((item, index) => {
2976
+ const itemWrap = createEl('div', 'lp-property-array-item', list);
2977
+
2978
+ const itemHeader = createEl('div', 'lp-property-array-item-header', itemWrap);
2979
+ const itemIndexLabel = createEl('span', '', itemHeader);
2980
+ itemIndexLabel.textContent = '#' + (index + 1);
2981
+ const deleteBtn = createEl('button', 'lp-property-btn-danger', itemHeader);
2982
+ deleteBtn.type = 'button';
2983
+ deleteBtn.title = '删除该项';
2984
+ deleteBtn.textContent = '×';
2985
+ deleteBtn.disabled = array.length <= minItems;
2986
+ deleteBtn.addEventListener('click', (e) => {
2987
+ e.stopPropagation();
2988
+ e.preventDefault();
2989
+ const slide = goal.slides[selectedSlideIdx];
2990
+ if (!slide) return;
2991
+ const arr = getProp(slide.props, path);
2992
+ if (!Array.isArray(arr)) return;
2993
+ arr.splice(index, 1);
2994
+ refreshCurrentSlide();
2995
+ recordHistory();
2996
+ autoSave();
2997
+ renderSlidePanel();
2998
+ });
2999
+
3000
+ if (isPrimitiveItem) {
3001
+ const subField = field.itemSchema[0];
3002
+ const itemPath = path + '.' + index;
3003
+ const itemValue = item;
3004
+ const control = createSchemaFieldControl(subField, itemValue, itemPath, skipCanvasEditable);
3005
+ if (control) itemWrap.appendChild(control);
3006
+ } else if (field.itemSchema) {
3007
+ field.itemSchema.forEach((subField) => {
3008
+ if (skipCanvasEditable && (subField.type === 'text' || subField.type === 'textarea' || subField.type === 'image')) return;
3009
+ const itemPath = path + '.' + index + '.' + subField.key;
3010
+ const itemValue = typeof item === 'object' && item !== null ? item[subField.key] : undefined;
3011
+ const control = createSchemaFieldControl(subField, itemValue, itemPath, skipCanvasEditable);
3012
+ if (control) itemWrap.appendChild(control);
3013
+ });
3014
+ } else if (typeof item === 'string') {
3015
+ if (skipCanvasEditable) return;
3016
+ const textarea = createEl('textarea', 'lp-property-textarea', itemWrap);
3017
+ textarea.value = item;
3018
+ textarea.addEventListener('input', () => {
3019
+ setField(path + '.' + index, textarea.value);
3020
+ });
3021
+ }
3022
+ });
3023
+ }
3024
+ }
3025
+
3026
+ function findSchemaField(schema, path) {
3027
+ if (!schema || !schema.fields || !path) return null;
3028
+ const parts = path.split('.');
3029
+ let fields = schema.fields;
3030
+ let field = null;
3031
+ for (let i = 0; i < parts.length; i++) {
3032
+ const key = parts[i];
3033
+ if (!fields) return null;
3034
+ if (/^\d+$/.test(key)) {
3035
+ // 数组索引,进入下一层 itemSchema
3036
+ if (field && field.itemSchema) {
3037
+ fields = field.itemSchema;
3038
+ }
3039
+ continue;
3040
+ }
3041
+ field = fields.find((f) => f.key === key) || null;
3042
+ if (!field) return null;
3043
+ if (i < parts.length - 1) {
3044
+ fields = field.type === 'array' ? field.itemSchema : (field.type === 'object' ? field.itemSchema : null);
3045
+ }
3046
+ }
3047
+ return field;
3048
+ }
3049
+
3050
+ function renderSchemaFields(schema, props, parent, basePath, skipCanvasEditable) {
3051
+ if (!schema || !schema.fields) return;
3052
+ schema.fields.forEach((field) => {
3053
+ // 简单文字字段直接在画布上编辑,不在右侧边栏重复渲染
3054
+ if (field.inlineEditable) return;
3055
+ // 非图表组件中,可在画布直接编辑的文字、图片字段不再在右侧边栏重复提供
3056
+ if (skipCanvasEditable && (field.type === 'text' || field.type === 'textarea' || field.type === 'image')) return;
3057
+ // 依赖开关的字段,开关关闭时收起;未设置时按开关默认值判断
3058
+ if (field.visibleWhen) {
3059
+ const controller = findSchemaField(schema, field.visibleWhen.key);
3060
+ const rawValue = getProp(props, field.visibleWhen.key);
3061
+ const effectiveValue = rawValue === undefined && controller?.defaultValue !== undefined ? controller.defaultValue : rawValue;
3062
+ if (effectiveValue !== field.visibleWhen.value) return;
3063
+ }
3064
+ const path = basePath ? basePath + '.' + field.key : field.key;
3065
+ const value = getProp(props, path);
3066
+ if (field.type === 'array') {
3067
+ createSchemaArraySection(field, Array.isArray(value) ? value : [], parent, path, skipCanvasEditable);
3068
+ } else if (field.type === 'object') {
3069
+ const section = createEl('div', 'lp-property-section', parent);
3070
+ createEl('div', 'lp-property-section-title', section).textContent = field.label;
3071
+ if (field.itemSchema) {
3072
+ renderSchemaFields({ fields: field.itemSchema }, props, section, path, skipCanvasEditable);
3073
+ }
3074
+ } else if (field.type === 'slider') {
3075
+ const section = createEl('div', 'lp-property-section', parent);
3076
+ const effectiveValue = value === undefined && field.defaultValue !== undefined ? field.defaultValue : value;
3077
+ let sliderField = field;
3078
+ if (field.key === 'highlightRow') {
3079
+ const rows = Array.isArray(props?.rows) ? props.rows : [];
3080
+ sliderField = { ...field, max: Math.min(field.max ?? 11, Math.max(0, rows.length - 1)) };
3081
+ }
3082
+ section.appendChild(createSliderField(sliderField, effectiveValue, (v) => setField(path, v)));
3083
+ } else {
3084
+ const control = createSchemaFieldControl(field, value, path, skipCanvasEditable);
3085
+ if (control) parent.appendChild(control);
3086
+ }
3087
+ });
3088
+ }
3089
+
3090
+ function renderSlideFields(props, parent, skipCanvasEditable) {
3091
+ Object.keys(props).forEach((key) => {
3092
+ if (key === '_style') return;
3093
+ const value = props[key];
3094
+ if (Array.isArray(value)) {
3095
+ // 无 Schema 时仅提供简单文本编辑,不显示序号刻度
3096
+ const section = createEl('div', 'lp-property-section', parent);
3097
+ createEl('div', 'lp-property-section-title', section).textContent = getFieldLabel(key) + ' (' + value.length + ')';
3098
+ const list = createEl('div', 'lp-property-array', section);
3099
+ value.forEach((item, index) => {
3100
+ const itemWrap = createEl('div', 'lp-property-array-item', list);
3101
+ if (typeof item === 'string') {
3102
+ if (skipCanvasEditable) return;
3103
+ const textarea = createEl('textarea', 'lp-property-textarea', itemWrap);
3104
+ textarea.value = item;
3105
+ textarea.addEventListener('input', () => {
3106
+ setField(key + '.' + index, textarea.value);
3107
+ });
3108
+ } else if (typeof item === 'object' && item !== null) {
3109
+ Object.keys(item).forEach((subKey) => {
3110
+ const type = inferFieldType(key + '.' + index + '.' + subKey, item[subKey]);
3111
+ if (skipCanvasEditable && (type === 'text' || type === 'textarea' || type === 'image')) return;
3112
+ itemWrap.appendChild(createFieldControl(key + '.' + index + '.' + subKey, item[subKey]));
3113
+ });
3114
+ }
3115
+ if (!itemWrap.hasChildNodes()) {
3116
+ itemWrap.remove();
3117
+ }
3118
+ });
3119
+ if (!list.hasChildNodes()) {
3120
+ list.remove();
3121
+ }
3122
+ } else if (value !== null && typeof value === 'object') {
3123
+ // 嵌套对象直接展开(目前较少)
3124
+ const section = createEl('div', 'lp-property-section', parent);
3125
+ createEl('div', 'lp-property-section-title', section).textContent = getFieldLabel(key);
3126
+ Object.keys(value).forEach((subKey) => {
3127
+ const type = inferFieldType(key + '.' + subKey, value[subKey]);
3128
+ if (skipCanvasEditable && (type === 'text' || type === 'textarea' || type === 'image')) return;
3129
+ section.appendChild(createFieldControl(key + '.' + subKey, value[subKey]));
3130
+ });
3131
+ } else {
3132
+ const type = inferFieldType(key, value);
3133
+ if (skipCanvasEditable && (type === 'text' || type === 'textarea' || type === 'image')) return;
3134
+ parent.appendChild(createFieldControl(key, value));
3135
+ }
3136
+ });
3137
+ }
3138
+
3139
+ function renderSlidePanel() {
3140
+ if (!propertyContent) return;
3141
+ propertyContent.innerHTML = '';
3142
+ const slide = goal.slides[selectedSlideIdx];
3143
+ if (!slide) return;
3144
+
3145
+ const info = createEl('div', 'lp-property-section', propertyContent);
3146
+ createEl('div', 'lp-property-section-title', info).textContent = '幻灯片 ' + (selectedSlideIdx + 1);
3147
+ const layoutLabel = createEl('div', 'lp-property-help', info);
3148
+ layoutLabel.textContent = '版式:' + getLayoutLabel(slide.layout);
3149
+
3150
+ const transitionSection = createEl('div', 'lp-property-section', propertyContent);
3151
+ createEl('div', 'lp-property-section-title', transitionSection).textContent = '切换动画';
3152
+ const transitionSelect = document.createElement('select');
3153
+ transitionSelect.className = 'lp-editor-select';
3154
+ transitionSelect.style.width = '100%';
3155
+ const currentTransition = slide.props.transition || 'none';
3156
+ TRANSITIONS.forEach((t) => {
3157
+ const opt = document.createElement('option');
3158
+ opt.value = t.key;
3159
+ opt.textContent = t.label;
3160
+ if (t.key === currentTransition) opt.selected = true;
3161
+ transitionSelect.appendChild(opt);
3162
+ });
3163
+ transitionSelect.addEventListener('change', () => {
3164
+ slide.props.transition = transitionSelect.value;
3165
+ const wrapper = document.querySelector('.lp-slide-wrapper[data-slide-index="' + selectedSlideIdx + '"]');
3166
+ if (wrapper) wrapper.setAttribute('data-lp-transition', slide.props.transition);
3167
+ recordHistory();
3168
+ saveState();
3169
+ });
3170
+ transitionSection.appendChild(transitionSelect);
3171
+
3172
+ if (selectedEl && slide.role === 'chart') {
3173
+ const prop = selectedEl.getAttribute('data-lp-prop');
3174
+ const schema = resolveLayoutSchema(slide);
3175
+ const schemaField = schema ? findSchemaField(schema, prop) : null;
3176
+ // 简单文字字段已在画布上直接编辑,不在右侧边栏重复显示
3177
+ if (!schemaField || !schemaField.inlineEditable) {
3178
+ const quickSection = createEl('div', 'lp-property-section', propertyContent);
3179
+ createEl('div', 'lp-property-section-title', quickSection).textContent = '当前选中';
3180
+ const fieldValue = getProp(slide.props, prop);
3181
+ quickSection.appendChild(createFieldControl(prop, fieldValue));
3182
+ }
3183
+ }
3184
+
3185
+ const fieldsSection = createEl('div', 'lp-property-section', propertyContent);
3186
+ createEl('div', 'lp-property-section-title', fieldsSection).textContent = '内容属性';
3187
+ const schema = resolveLayoutSchema(slide);
3188
+ const skipCanvasEditable = slide.role !== 'chart';
3189
+ if (schema && schema.fields) {
3190
+ renderSchemaFields(schema, slide.props, fieldsSection, undefined, skipCanvasEditable);
3191
+ } else {
3192
+ renderSlideFields(slide.props, fieldsSection, skipCanvasEditable);
3193
+ }
3194
+ }
3195
+
3196
+ function selectEl(el) {
3197
+ if (selectedEl === el) return;
3198
+ if (selectedEl) selectedEl.classList.remove('lp-selected');
3199
+ selectedEl = el;
3200
+ selectedEl.classList.add('lp-selected');
3201
+ selectedSlideIdx = Number(el.getAttribute('data-lp-slide-idx')) || current;
3202
+ renderSlidePanel();
3203
+ // 滚动到当前选中字段
3204
+ const path = el.getAttribute('data-lp-prop');
3205
+ if (path && propertyContent) {
3206
+ const label = propertyContent.querySelector('.lp-property-label');
3207
+ // 简单高亮:暂时不做自动滚动,避免复杂度
3208
+ }
3209
+ }
3210
+
3211
+ function renderSlideToRootByIndex(index, wrapper) {
3212
+ if (typeof window.__lemonPPT_renderSlideToRoot !== 'function') return;
3213
+ const slide = goal.slides[index];
3214
+ if (!slide || !wrapper) {
3215
+ return;
3216
+ }
3217
+ window.__lemonPPT_renderSlideToRoot(wrapper, slide, { slideIdx: index, editable: true, theme: goal.theme });
3218
+ initEditableElements(wrapper);
3219
+ }
3220
+
3221
+ function initActiveSlideECharts() {
3222
+ const activeWrapper = document.querySelector('.lp-slide-wrapper.active');
3223
+ if (activeWrapper && typeof window.__lemonPPT_initECharts === 'function') {
3224
+ window.__lemonPPT_initECharts(goal.theme || 'theme01', activeWrapper);
3225
+ }
3226
+ }
3227
+
3228
+ function disposeEChartsInWrapper(wrapper) {
3229
+ if (!wrapper) return;
3230
+ wrapper.querySelectorAll('[data-lp-echart-type]').forEach((container) => {
3231
+ const inst = container.__lpEChartInstance;
3232
+ if (inst && !inst.isDisposed()) {
3233
+ inst.dispose();
3234
+ }
3235
+ container.__lpEChartInstance = undefined;
3236
+ });
3237
+ }
3238
+
3239
+ function renderCurrentSlideToRoot() {
3240
+ const wrapper = document.querySelector('.lp-slide-wrapper.active');
3241
+ if (!wrapper) return;
3242
+ renderSlideToRootByIndex(selectedSlideIdx, wrapper);
3243
+ // React 18 createRoot().render() 是异步的,延迟到下一帧再初始化 ECharts,
3244
+ // 确保占位容器已经挂载到 DOM 并有可测量的尺寸。
3245
+ requestAnimationFrame(() => {
3246
+ initActiveSlideECharts();
3247
+ });
3248
+ }
3249
+
3250
+ // 预创建所有 slide 的 React root 并渲染,使切页和非 active slide 更新都无需重新构建 DOM
3251
+ function renderAllSlidesToRoot() {
3252
+ if (typeof window.__lemonPPT_renderSlideToRoot !== 'function') {
3253
+ return;
3254
+ }
3255
+ const wrappers = document.querySelectorAll('.lp-slide-wrapper');
3256
+ wrappers.forEach((wrapper, index) => {
3257
+ renderSlideToRootByIndex(index, wrapper);
3258
+ });
3259
+ // 编辑器场景下只初始化当前 active slide 的 ECharts,避免 75+ 页同时渲染导致卡顿。
3260
+ requestAnimationFrame(() => {
3261
+ initActiveSlideECharts();
3262
+ });
3263
+ }
3264
+
3265
+ function selectSlide(index) {
3266
+ if (selectedEl) {
3267
+ selectedEl.classList.remove('lp-selected');
3268
+ selectedEl = null;
3269
+ }
3270
+ selectedSlideIdx = index;
3271
+ renderSlidePanel();
3272
+ }
3273
+
3274
+ document.addEventListener('focusin', (e) => {
3275
+ const el = e.target.closest && e.target.closest('[data-lp-editable="true"], [data-lp-editable-image="true"]');
3276
+ if (el) selectEl(el);
3277
+ });
3278
+
3279
+ document.addEventListener('click', (e) => {
3280
+ const editableEl = e.target.closest && e.target.closest('[data-lp-editable="true"], [data-lp-editable-image="true"]');
3281
+ if (editableEl) {
3282
+ selectEl(editableEl);
3283
+ return;
3284
+ }
3285
+ const wrapper = e.target.closest && e.target.closest('.lp-slide-wrapper');
3286
+ if (wrapper) {
3287
+ selectSlide(Number(wrapper.getAttribute('data-slide-index')));
3288
+ return;
3289
+ }
3290
+ if (e.target.closest && !e.target.closest('.lp-editor-right-panel')) {
3291
+ clearSelection();
3292
+ }
3293
+ });
3294
+
3295
+ // 恢复上次停留的幻灯片(结构变更后重载用)
3296
+ const savedCurrent = localStorage.getItem('lemonppt:editor:currentSlide');
3297
+ if (savedCurrent) {
3298
+ const savedIndex = Number(savedCurrent);
3299
+ if (!Number.isNaN(savedIndex) && savedIndex >= 0 && savedIndex < slides.length) {
3300
+ goTo(savedIndex);
3301
+ }
3302
+ localStorage.removeItem('lemonppt:editor:currentSlide');
3303
+ }
3304
+
3305
+ // 默认选中当前幻灯片
3306
+ selectSlide(current);
3307
+
3308
+ // 若 localStorage 中幻灯片数量与当前 DOM 不匹配,重建 DOM 后再渲染
3309
+ if (needsDomRebuild) {
3310
+ rebuildSlidesAndThumbnails();
3311
+ } else {
3312
+ // 预创建并渲染所有 slide 的 React root,使切页与后续更新都走 React reconcile
3313
+ renderAllSlidesToRoot();
3314
+ }
3315
+
3316
+ // 暴露给 editor.js:无刷新切换主题时重置内部状态
3317
+ window.__lemonPPT_applyTheme = function(newGoal, newSlidesMarkup) {
3318
+ try {
3319
+ const oldKey = 'lemonppt:editor:v2:' + (goal.theme || 'theme01') + ':' + (goal.randomSeed || goal.title || 'default');
3320
+ localStorage.setItem(oldKey, JSON.stringify(goal));
3321
+ } catch (err) {
3322
+ console.warn('切换主题前保存失败', err);
3323
+ }
3324
+ goal = newGoal;
3325
+ window.__lemonPPT_goal = goal;
3326
+
3327
+ // 尝试恢复当前主题的本地编辑状态
3328
+ try {
3329
+ const newKey = 'lemonppt:editor:v2:' + (goal.theme || 'theme01') + ':' + (goal.randomSeed || goal.title || 'default');
3330
+ const saved = localStorage.getItem(newKey);
3331
+ if (saved) {
3332
+ const parsed = JSON.parse(saved);
3333
+ if (parsed && Array.isArray(parsed.slides)) {
3334
+ goal = parsed;
3335
+ window.__lemonPPT_goal = goal;
3336
+ }
3337
+ }
3338
+ } catch (err) {
3339
+ console.warn('切换主题后恢复失败', err);
3340
+ }
3341
+
3342
+ const slidesContainer = document.getElementById('lp-slides');
3343
+ if (slidesContainer) {
3344
+ slidesContainer.innerHTML = newSlidesMarkup;
3345
+ }
3346
+ current = 0;
3347
+ selectedSlideIdx = 0;
3348
+ rebuildSlidesAndThumbnails();
3349
+ selectSlide(current);
3350
+ syncAppearanceFromGoal();
3351
+ autoSave();
3352
+ updateUndoRedoButtons();
3353
+ };
3354
+ })();
3355
+