@lemonppt/renderer 0.1.8 → 0.2.0

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