html2pptx-local-mcp 1.1.19 → 1.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/app/docs/content.js +57 -23
  2. package/cli/dist/commands/edit.d.ts +1 -1
  3. package/cli/dist/commands/edit.js +231 -3
  4. package/cli/dist/index.js +0 -0
  5. package/lib/local-editor-server.js +316 -0
  6. package/lib/local-editor-state.js +45 -0
  7. package/lib/local-slide-editor-launcher.js +19 -18
  8. package/lib/pptx-studio-mcp-core.js +15 -9
  9. package/local-editor-app/app/api/edit-slide/local-health/route.js +16 -0
  10. package/local-editor-app/app/edit-slide/edit-slide-client.jsx +13153 -0
  11. package/local-editor-app/app/edit-slide/page.jsx +13 -0
  12. package/local-editor-app/app/globals.css +4 -0
  13. package/local-editor-app/app/layout.jsx +14 -0
  14. package/local-editor-app/components/studio/edit-property-panel.jsx +1061 -0
  15. package/local-editor-app/lib/edit-panel-value-normalizer.js +97 -0
  16. package/local-editor-app/lib/edit-slide-editor-helpers.js +120 -0
  17. package/local-editor-app/lib/edit-slide-url-security.js +247 -0
  18. package/local-editor-app/next.config.mjs +31 -0
  19. package/local-editor-app/package.json +7 -0
  20. package/mcp/pptx-studio-mcp-server.mjs +1 -1
  21. package/package.json +16 -3
  22. package/public/skills/html2pptx/SKILL.md +635 -0
  23. package/public/skills/html2pptx/references/automation-contract.md +68 -0
  24. package/public/skills/html2pptx/references/input-contract.md +107 -0
  25. package/public/skills/html2pptx/references/japanese-slide-design.md +273 -0
  26. package/public/skills/html2pptx/references/rewrite-patterns.md +218 -0
  27. package/public/skills/icon-generator/SKILL.md +133 -0
  28. package/public/skills/open-slide/SKILL.md +160 -0
  29. package/public/skills/publish-template/SKILL.md +215 -0
  30. package/public/skills/register-template/SKILL.md +142 -0
  31. package/scripts/extract-html2pptx-comments.mjs +172 -0
  32. package/scripts/install-mcp.mjs +58 -13
  33. package/scripts/install-skills.mjs +82 -0
@@ -0,0 +1,1061 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import {
5
+ AlignCenter,
6
+ AlignJustify,
7
+ AlignLeft,
8
+ AlignRight,
9
+ } from 'lucide-react';
10
+ import {
11
+ getExpandedBorderBoxMetric,
12
+ normalizeEditPanelInputValue,
13
+ normalizeStyleValue,
14
+ } from '../../lib/edit-panel-value-normalizer';
15
+
16
+ const TEXT_LEAF_TAGS = new Set([
17
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
18
+ 'p', 'span', 'a', 'em', 'strong', 'b', 'i',
19
+ 'li', 'td', 'th', 'caption', 'figcaption',
20
+ 'button', 'label', 'small', 'code',
21
+ ]);
22
+
23
+ const TEXT_BLOCK_TAGS = new Set([
24
+ 'div', 'section', 'article', 'blockquote', 'header', 'footer',
25
+ ]);
26
+
27
+ const PHRASING_TEXT_TAGS = new Set([...TEXT_LEAF_TAGS, 'br', 'wbr']);
28
+
29
+ const DEFAULT_LABELS = {
30
+ edit: 'Edit',
31
+ closeEditPanel: 'Close edit panel',
32
+ textContent: 'Text content',
33
+ textRun: 'Text',
34
+ layout: 'Layout',
35
+ xShort: 'X',
36
+ yShort: 'Y',
37
+ typography: 'Typography',
38
+ size: 'Size',
39
+ weight: 'Weight',
40
+ color: 'Color',
41
+ align: 'Align',
42
+ lineHeight: 'Line height',
43
+ widthShort: 'W',
44
+ heightShort: 'H',
45
+ minWidth: 'Min W',
46
+ minHeight: 'Min H',
47
+ box: 'Box',
48
+ appearance: 'Appearance',
49
+ fill: 'Fill',
50
+ spacing: 'Spacing',
51
+ background: 'Background',
52
+ padding: 'Padding',
53
+ margin: 'Margin',
54
+ all: 'All',
55
+ mixed: 'mixed',
56
+ top: 'Top',
57
+ left: 'Left',
58
+ right: 'Right',
59
+ bottom: 'Bottom',
60
+ corners: 'Corners',
61
+ cornerRadius: 'Corner radius',
62
+ opacity: 'Opacity',
63
+ border: 'Border',
64
+ style: 'Style',
65
+ width: 'Width',
66
+ hint: 'Edits apply as inline styles & textContent. Toggle Edit off to commit them back to the HTML editor.',
67
+ className: 'class',
68
+ image: 'Assets',
69
+ replaceImage: 'Replace image',
70
+ replaceVideo: 'Replace video',
71
+ insertImage: 'Insert asset',
72
+ chooseImage: 'Choose asset…',
73
+ applyBackground: 'Use as background',
74
+ dragAssetHint: 'Drag onto the slide to place it',
75
+ uploading: 'Uploading…',
76
+ saveTo: 'Save to',
77
+ assetScopeProject: 'This deck',
78
+ assetScopeGlobal: 'Global',
79
+ savedAssets: 'Saved assets',
80
+ noSavedAssets: 'No saved assets yet.',
81
+ assetNeedsFile: 'Save this file locally first to add assets.',
82
+ };
83
+
84
+ function assetKindFromName(value) {
85
+ const path = String(value || '').split(/[?#]/)[0].toLowerCase();
86
+ if (/\.(mp4|webm|mov|m4v|ogv)$/.test(path)) return 'video';
87
+ if (/\.(png|jpe?g|gif|webp|svg|avif)$/.test(path)) return 'image';
88
+ return 'file';
89
+ }
90
+
91
+ function isTextElement(el) {
92
+ if (!el) return false;
93
+ const tag = el.tagName.toLowerCase();
94
+ if (!el.textContent?.trim()) return false;
95
+ if (TEXT_LEAF_TAGS.has(tag)) return true;
96
+ if (!TEXT_BLOCK_TAGS.has(tag)) return false;
97
+ if (el.classList?.contains('slide') || el.classList?.contains('canvas')) return false;
98
+ const hasDirectText = Array.from(el.childNodes).some((node) => (
99
+ node.nodeType === 3 && node.nodeValue?.trim()
100
+ ));
101
+ if (!hasDirectText) return false;
102
+ return Array.from(el.children).every((child) => (
103
+ PHRASING_TEXT_TAGS.has(child.tagName.toLowerCase())
104
+ ));
105
+ }
106
+
107
+ function collectTextRuns(el) {
108
+ if (!isTextElement(el)) return [];
109
+ const doc = el.ownerDocument;
110
+ const win = doc.defaultView;
111
+ if (!doc.createTreeWalker || !win?.NodeFilter) return [];
112
+
113
+ const walker = doc.createTreeWalker(
114
+ el,
115
+ win.NodeFilter.SHOW_TEXT,
116
+ {
117
+ acceptNode(node) {
118
+ return node.nodeValue?.trim()
119
+ ? win.NodeFilter.FILTER_ACCEPT
120
+ : win.NodeFilter.FILTER_REJECT;
121
+ },
122
+ },
123
+ );
124
+
125
+ const runs = [];
126
+ let node = walker.nextNode();
127
+ while (node) {
128
+ const parent = node.parentElement;
129
+ const parentTag = parent?.tagName?.toLowerCase() || 'text';
130
+ runs.push({
131
+ node,
132
+ label: parent === el ? `Text ${runs.length + 1}` : parentTag,
133
+ value: node.nodeValue || '',
134
+ });
135
+ node = walker.nextNode();
136
+ }
137
+ return runs;
138
+ }
139
+
140
+ function readTextEdit(el) {
141
+ if (!el) return { mode: 'none', value: '', runs: [] };
142
+ if (!isTextElement(el)) return { mode: 'none', value: '', runs: [] };
143
+
144
+ const runs = collectTextRuns(el);
145
+ if (runs.length === 0) return { mode: 'none', value: '', runs: [] };
146
+
147
+ for (const child of el.children) {
148
+ if (child.nodeType === 1) {
149
+ return { mode: 'runs', value: el.textContent || '', runs };
150
+ }
151
+ }
152
+ return { mode: 'plain', value: el.textContent || '', runs };
153
+ }
154
+
155
+ function toHex(color) {
156
+ if (!color) return '';
157
+ if (color.startsWith('#')) return color.length === 4
158
+ ? '#' + color.slice(1).split('').map((c) => c + c).join('')
159
+ : color.slice(0, 7);
160
+ const alpha = color.match(/rgba\([^)]*,\s*([0-9.]+)\s*\)/);
161
+ if (alpha && Number(alpha[1]) === 0) return '';
162
+ const m = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
163
+ if (!m) return '';
164
+ const hex = (n) => Number(n).toString(16).padStart(2, '0');
165
+ return `#${hex(m[1])}${hex(m[2])}${hex(m[3])}`;
166
+ }
167
+
168
+ function stripPx(value) {
169
+ return String(value || '').replace(/(-?\d+(?:\.\d+)?)px\b/g, '$1');
170
+ }
171
+
172
+ function inputValue(value) {
173
+ return value == null ? '' : String(value);
174
+ }
175
+
176
+ function textRows(value) {
177
+ const raw = String(value ?? '');
178
+ const lines = raw.split('\n').length;
179
+ const estimatedWraps = Math.ceil(raw.length / 44);
180
+ return Math.max(2, Math.min(5, lines + estimatedWraps - 1));
181
+ }
182
+
183
+ function readBoxValue(get, prop) {
184
+ return stripPx(get(prop));
185
+ }
186
+
187
+ function readCssNumber(computedStyle, prop) {
188
+ const value = parseFloat(computedStyle?.getPropertyValue(prop));
189
+ return Number.isFinite(value) ? value : null;
190
+ }
191
+
192
+ function readStyles(el) {
193
+ const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
194
+ const get = (prop) => (cs ? cs.getPropertyValue(prop).trim() : '');
195
+ return {
196
+ width: stripPx(get('width')),
197
+ height: stripPx(get('height')),
198
+ minWidth: stripPx(get('min-width')),
199
+ minHeight: stripPx(get('min-height')),
200
+ left: stripPx(get('left')),
201
+ top: stripPx(get('top')),
202
+ fontFamily: get('font-family').split(',')[0]?.replace(/^["']|["']$/g, '') || '',
203
+ fontSize: stripPx(get('font-size')),
204
+ fontWeight: get('font-weight'),
205
+ color: toHex(get('color')),
206
+ backgroundColor: toHex(get('background-color')),
207
+ textAlign: get('text-align'),
208
+ lineHeight: stripPx(get('line-height')),
209
+ paddingTop: readBoxValue(get, 'padding-top'),
210
+ paddingRight: readBoxValue(get, 'padding-right'),
211
+ paddingBottom: readBoxValue(get, 'padding-bottom'),
212
+ paddingLeft: readBoxValue(get, 'padding-left'),
213
+ marginTop: readBoxValue(get, 'margin-top'),
214
+ marginRight: readBoxValue(get, 'margin-right'),
215
+ marginBottom: readBoxValue(get, 'margin-bottom'),
216
+ marginLeft: readBoxValue(get, 'margin-left'),
217
+ borderRadius: stripPx(get('border-top-left-radius')),
218
+ borderTopLeftRadius: readBoxValue(get, 'border-top-left-radius'),
219
+ borderTopRightRadius: readBoxValue(get, 'border-top-right-radius'),
220
+ borderBottomRightRadius: readBoxValue(get, 'border-bottom-right-radius'),
221
+ borderBottomLeftRadius: readBoxValue(get, 'border-bottom-left-radius'),
222
+ opacity: get('opacity'),
223
+ borderStyle: get('border-top-style'),
224
+ borderWidth: stripPx(get('border-top-width')),
225
+ borderColor: toHex(get('border-top-color')),
226
+ };
227
+ }
228
+
229
+ export default function EditPropertyPanel({
230
+ selector,
231
+ element,
232
+ onClose,
233
+ onChange,
234
+ labels = DEFAULT_LABELS,
235
+ assetTools = null,
236
+ }) {
237
+ const ui = { ...DEFAULT_LABELS, ...labels };
238
+ const [styles, setStyles] = useState(() => readStyles(element));
239
+ const [textEdit, setTextEdit] = useState(() => readTextEdit(element));
240
+
241
+ useEffect(() => {
242
+ setStyles(readStyles(element));
243
+ setTextEdit(readTextEdit(element));
244
+ }, [element]);
245
+
246
+ const apply = (key, value) => {
247
+ const input = normalizeEditPanelInputValue(key, value);
248
+ setStyles((prev) => ({ ...prev, [key]: input }));
249
+ const cssValue = normalizeStyleValue(key, input);
250
+ if (['width', 'height', 'minWidth', 'minHeight'].includes(key)) {
251
+ const display = element.ownerDocument.defaultView?.getComputedStyle(element).display;
252
+ if (display === 'inline') element.style.display = 'inline-block';
253
+ }
254
+ if (['left', 'top', 'right', 'bottom'].includes(key)) {
255
+ const position = element.ownerDocument.defaultView?.getComputedStyle(element).position;
256
+ if (position === 'static') element.style.position = 'relative';
257
+ }
258
+ if (key === 'borderStyle') element.style.borderStyle = cssValue;
259
+ else if (key === 'borderWidth') element.style.borderWidth = cssValue;
260
+ else if (key === 'borderColor') element.style.borderColor = cssValue;
261
+ else if (key === 'borderRadius') element.style.borderRadius = cssValue;
262
+ else element.style[key] = cssValue;
263
+ onChange?.();
264
+ };
265
+
266
+ const applyText = (value) => {
267
+ element.textContent = value;
268
+ setTextEdit(readTextEdit(element));
269
+ onChange?.();
270
+ };
271
+
272
+ const applyTextRun = (index, value) => {
273
+ const run = textEdit.runs[index];
274
+ if (!run?.node) return;
275
+ run.node.nodeValue = value;
276
+ setTextEdit(readTextEdit(element));
277
+ onChange?.();
278
+ };
279
+
280
+ const addTextSpan = () => {
281
+ if (!isTextElement(element)) return;
282
+ const doc = element.ownerDocument;
283
+ if (textEdit.mode === 'plain') {
284
+ const currentText = element.textContent || '';
285
+ element.textContent = '';
286
+ if (currentText) element.appendChild(doc.createTextNode(currentText));
287
+ }
288
+ if (element.childNodes.length > 0) element.appendChild(doc.createTextNode(' '));
289
+ const span = doc.createElement('span');
290
+ span.textContent = 'New span';
291
+ element.appendChild(span);
292
+ setTextEdit(readTextEdit(element));
293
+ onChange?.();
294
+ };
295
+
296
+ const styleTextRun = (index) => {
297
+ const run = textEdit.runs[index];
298
+ const parent = run?.node?.parentElement;
299
+ if (!parent) return;
300
+ parent.style.fontWeight = parent.style.fontWeight === '700' ? '' : '700';
301
+ setTextEdit(readTextEdit(element));
302
+ onChange?.();
303
+ };
304
+
305
+ const applyPadding = (side, value) => {
306
+ const key = `padding${side}`;
307
+ const win = element.ownerDocument.defaultView;
308
+ const beforeStyles = win?.getComputedStyle(element);
309
+ const previousPaddingPx = readCssNumber(beforeStyles, `padding-${side.toLowerCase()}`);
310
+ const currentWidthPx = readCssNumber(beforeStyles, 'width');
311
+ const currentHeightPx = readCssNumber(beforeStyles, 'height');
312
+ const input = normalizeEditPanelInputValue(key, value);
313
+ element.style[key] = normalizeStyleValue(key, input);
314
+ const afterStyles = win?.getComputedStyle(element);
315
+ const nextPaddingPx = readCssNumber(afterStyles, `padding-${side.toLowerCase()}`);
316
+ const expandedMetric = getExpandedBorderBoxMetric({
317
+ boxSizing: beforeStyles?.boxSizing,
318
+ side,
319
+ previousPaddingPx,
320
+ nextPaddingPx,
321
+ currentMetricPx: side === 'Left' || side === 'Right' ? currentWidthPx : currentHeightPx,
322
+ });
323
+ const expandedValue = expandedMetric ? String(Number(expandedMetric.value.toFixed(2))) : null;
324
+ if (expandedMetric && expandedValue != null) {
325
+ element.style[expandedMetric.dimension] = `${expandedValue}px`;
326
+ }
327
+
328
+ setStyles((prev) => {
329
+ const nextStyles = { ...prev, [key]: input };
330
+ if (expandedMetric && expandedValue != null) {
331
+ nextStyles[expandedMetric.dimension] = expandedValue;
332
+ }
333
+ return nextStyles;
334
+ });
335
+ onChange?.();
336
+ };
337
+
338
+ const applyMargin = (side, value) => {
339
+ const key = `margin${side}`;
340
+ const input = normalizeEditPanelInputValue(key, value);
341
+ setStyles((prev) => ({ ...prev, [key]: input }));
342
+ element.style[key] = normalizeStyleValue(key, input);
343
+ onChange?.();
344
+ };
345
+
346
+ const applyRadius = (corner, value) => {
347
+ const key = `border${corner}Radius`;
348
+ const input = normalizeEditPanelInputValue(key, value);
349
+ setStyles((prev) => ({ ...prev, [key]: input }));
350
+ element.style[key] = normalizeStyleValue(key, input);
351
+ onChange?.();
352
+ };
353
+
354
+ const hasTextControls = textEdit.mode !== 'none';
355
+ const opacityPercent = String(Math.round(Math.max(0, Math.min(1, Number(styles.opacity) || 1)) * 100));
356
+
357
+ return (
358
+ <aside className="edit-panel edit-panel--lumina">
359
+ <div className="ppt-lumina-panel-actions">
360
+ <span>{selector || element.tagName.toLowerCase()}</span>
361
+ <button type="button" onClick={onClose} aria-label={ui.closeEditPanel}>
362
+ <span aria-hidden="true">x</span>
363
+ </button>
364
+ </div>
365
+
366
+ {assetTools && (
367
+ <ImageAssetSection element={element} ui={ui} assetTools={assetTools} onChange={onChange} />
368
+ )}
369
+
370
+ {hasTextControls && (
371
+ <section className="ppt-lumina-section">
372
+ <div className="ppt-lumina-section__head">
373
+ <h3>Content</h3>
374
+ </div>
375
+ {textEdit.mode === 'plain' ? (
376
+ <textarea
377
+ className="ppt-lumina-textarea"
378
+ value={inputValue(textEdit.value)}
379
+ rows={textRows(textEdit.value)}
380
+ onChange={(e) => applyText(e.target.value)}
381
+ spellCheck={false}
382
+ />
383
+ ) : (
384
+ <div className="ppt-lumina-nested">
385
+ <h4>Multiple text runs</h4>
386
+ {textEdit.runs.map((run, index) => (
387
+ <div className="ppt-lumina-run" key={`${run.label}-${index}`}>
388
+ <textarea
389
+ value={inputValue(run.value)}
390
+ rows={textRows(run.value)}
391
+ onChange={(e) => applyTextRun(index, e.target.value)}
392
+ spellCheck={false}
393
+ />
394
+ <button type="button" onClick={() => styleTextRun(index)}>Edit Styling</button>
395
+ </div>
396
+ ))}
397
+ </div>
398
+ )}
399
+ <button type="button" className="ppt-lumina-add" onClick={addTextSpan}>
400
+ + Add Span
401
+ </button>
402
+ </section>
403
+ )}
404
+
405
+ <section className="ppt-lumina-section">
406
+ <div className="ppt-lumina-section__head">
407
+ <h3>Layout &amp; Position</h3>
408
+ </div>
409
+ <div className="ppt-lumina-metrics">
410
+ <LuminaMetric label={ui.xShort} value={styles.left} onChange={(value) => apply('left', value)} placeholder="auto" />
411
+ <LuminaMetric label={ui.yShort} value={styles.top} onChange={(value) => apply('top', value)} placeholder="auto" />
412
+ <LuminaMetric label={ui.widthShort} value={styles.width} onChange={(value) => apply('width', value)} placeholder="auto" />
413
+ <LuminaMetric label={ui.heightShort} value={styles.height} onChange={(value) => apply('height', value)} placeholder="auto" />
414
+ <LuminaMetric label={ui.minWidth} value={styles.minWidth} onChange={(value) => apply('minWidth', value)} placeholder="0" />
415
+ <LuminaMetric label={ui.minHeight} value={styles.minHeight} onChange={(value) => apply('minHeight', value)} placeholder="0" />
416
+ </div>
417
+ </section>
418
+
419
+ <section className="ppt-lumina-section">
420
+ <div className="ppt-lumina-section__head">
421
+ <h3>Typography</h3>
422
+ </div>
423
+ <label className="ppt-lumina-select">
424
+ <span>Font Family</span>
425
+ <input
426
+ type="text"
427
+ value={inputValue(styles.fontFamily)}
428
+ onChange={(e) => apply('fontFamily', e.target.value)}
429
+ placeholder="Inter"
430
+ />
431
+ </label>
432
+ <div className="ppt-lumina-triple">
433
+ <label className="ppt-lumina-field">
434
+ <span>{ui.size}</span>
435
+ <input
436
+ type="text"
437
+ value={inputValue(styles.fontSize)}
438
+ onChange={(e) => apply('fontSize', e.target.value)}
439
+ placeholder="16"
440
+ />
441
+ </label>
442
+ <label className="ppt-lumina-field">
443
+ <span>{ui.weight}</span>
444
+ <select
445
+ value={inputValue(styles.fontWeight)}
446
+ onChange={(e) => apply('fontWeight', e.target.value)}
447
+ >
448
+ {['100', '200', '300', '400', '500', '600', '700', '800', '900'].map((w) => (
449
+ <option key={w} value={w}>{w}</option>
450
+ ))}
451
+ </select>
452
+ </label>
453
+ <label className="ppt-lumina-field">
454
+ <span>Line</span>
455
+ <input
456
+ type="text"
457
+ value={inputValue(styles.lineHeight)}
458
+ onChange={(e) => apply('lineHeight', e.target.value)}
459
+ placeholder="1.5 / 48"
460
+ />
461
+ </label>
462
+ </div>
463
+ <label className="ppt-lumina-color">
464
+ <input
465
+ className="ppt-lumina-color__picker"
466
+ type="color"
467
+ value={styles.color || '#000000'}
468
+ onChange={(e) => apply('color', e.target.value)}
469
+ />
470
+ <input
471
+ type="text"
472
+ value={inputValue(styles.color)}
473
+ onChange={(e) => apply('color', e.target.value)}
474
+ placeholder="#000000"
475
+ />
476
+ </label>
477
+ <LuminaAlign value={styles.textAlign} onChange={(value) => apply('textAlign', value)} />
478
+ </section>
479
+
480
+ <section className="ppt-lumina-section">
481
+ <div className="ppt-lumina-section__head">
482
+ <h3>Appearance</h3>
483
+ </div>
484
+ <label className="ppt-lumina-color">
485
+ <input
486
+ className="ppt-lumina-color__picker"
487
+ type="color"
488
+ value={styles.backgroundColor || '#ffffff'}
489
+ onChange={(e) => apply('backgroundColor', e.target.value)}
490
+ />
491
+ <input
492
+ type="text"
493
+ value={inputValue(styles.backgroundColor)}
494
+ onChange={(e) => apply('backgroundColor', e.target.value)}
495
+ placeholder="#ffffff"
496
+ />
497
+ <span>Fill</span>
498
+ </label>
499
+ <div className="ppt-lumina-opacity">
500
+ <label className="ppt-lumina-row-field">
501
+ <span>{ui.opacity}</span>
502
+ <input
503
+ type="text"
504
+ value={opacityPercent}
505
+ onChange={(e) => apply('opacity', String((Number(e.target.value) || 0) / 100))}
506
+ placeholder="100"
507
+ />
508
+ </label>
509
+ <input
510
+ type="range"
511
+ min="0"
512
+ max="1"
513
+ step="0.05"
514
+ value={String(Math.max(0, Math.min(1, Number(styles.opacity) || 1)))}
515
+ onChange={(e) => apply('opacity', e.target.value)}
516
+ />
517
+ </div>
518
+ </section>
519
+
520
+ <section className="ppt-lumina-section">
521
+ <div className="ppt-lumina-section__head">
522
+ <h3>Border</h3>
523
+ </div>
524
+ <div className="ppt-lumina-border-grid">
525
+ <label>
526
+ <span>{ui.style}</span>
527
+ <select value={inputValue(styles.borderStyle)} onChange={(e) => apply('borderStyle', e.target.value)}>
528
+ {['none', 'solid', 'dashed', 'dotted'].map((style) => (
529
+ <option key={style} value={style}>{style}</option>
530
+ ))}
531
+ </select>
532
+ </label>
533
+ <label>
534
+ <span>W</span>
535
+ <input value={inputValue(styles.borderWidth)} onChange={(e) => apply('borderWidth', e.target.value)} placeholder="1" />
536
+ </label>
537
+ <label>
538
+ <input
539
+ className="ppt-lumina-color__picker"
540
+ type="color"
541
+ value={styles.borderColor || '#000000'}
542
+ onChange={(e) => apply('borderColor', e.target.value)}
543
+ />
544
+ <input value={inputValue(styles.borderColor)} onChange={(e) => apply('borderColor', e.target.value)} placeholder="#000000" />
545
+ </label>
546
+ </div>
547
+ </section>
548
+
549
+ <section className="ppt-lumina-section">
550
+ <div className="ppt-lumina-section__head">
551
+ <h3>Radius</h3>
552
+ <button type="button" onClick={() => apply('borderRadius', styles.borderTopLeftRadius || '0')}>Link</button>
553
+ </div>
554
+ <div className="ppt-lumina-radius">
555
+ <LuminaCorner label="TL" value={styles.borderTopLeftRadius} onChange={(value) => applyRadius('TopLeft', value)} />
556
+ <LuminaCorner label="TR" value={styles.borderTopRightRadius} onChange={(value) => applyRadius('TopRight', value)} />
557
+ <LuminaCorner label="BR" value={styles.borderBottomRightRadius} onChange={(value) => applyRadius('BottomRight', value)} />
558
+ <LuminaCorner label="BL" value={styles.borderBottomLeftRadius} onChange={(value) => applyRadius('BottomLeft', value)} />
559
+ </div>
560
+ </section>
561
+
562
+ <section className="ppt-lumina-section">
563
+ <div className="ppt-lumina-section__head">
564
+ <h3>Spacing</h3>
565
+ </div>
566
+ <SpacingBoxEditor
567
+ labels={ui}
568
+ values={styles}
569
+ onMarginChange={applyMargin}
570
+ onPaddingChange={applyPadding}
571
+ />
572
+ </section>
573
+ </aside>
574
+ );
575
+ }
576
+
577
+ function ImageAssetSection({ element, ui, assetTools, onChange }) {
578
+ const tagName = element?.tagName?.toLowerCase();
579
+ const isImg = tagName === 'img';
580
+ const isVideo = tagName === 'video';
581
+ const fileInputRef = useRef(null);
582
+ const [assets, setAssets] = useState([]);
583
+ const [busy, setBusy] = useState(false);
584
+ const [error, setError] = useState('');
585
+ const scope = assetTools?.scope === 'global' ? 'global' : 'project';
586
+ const enabled = Boolean(assetTools?.enabled);
587
+
588
+ const loadAssets = useCallback(async () => {
589
+ if (!enabled || typeof assetTools?.listAssets !== 'function') {
590
+ setAssets([]);
591
+ return;
592
+ }
593
+ const list = await assetTools.listAssets(scope);
594
+ setAssets(Array.isArray(list) ? list : []);
595
+ }, [assetTools, enabled, scope]);
596
+
597
+ useEffect(() => {
598
+ loadAssets();
599
+ }, [loadAssets]);
600
+
601
+ const applyAssetSrc = useCallback(
602
+ (srcRel, kindHint) => {
603
+ if (!element || !srcRel) return;
604
+ const kind = kindHint || assetKindFromName(srcRel);
605
+ const preview = assetTools?.resolveAssetUrl?.(srcRel) || srcRel;
606
+ if (isImg && kind === 'image') {
607
+ element.setAttribute('src', preview);
608
+ element.removeAttribute('srcset');
609
+ onChange?.();
610
+ return;
611
+ }
612
+ if (isVideo && kind === 'video') {
613
+ element.setAttribute('src', preview);
614
+ element.setAttribute('controls', '');
615
+ onChange?.();
616
+ }
617
+ },
618
+ [assetTools, element, isImg, isVideo, onChange],
619
+ );
620
+
621
+ const applyAssetBackground = useCallback(
622
+ (srcRel, kindHint) => {
623
+ if (!element || !srcRel) return;
624
+ const kind = kindHint || assetKindFromName(srcRel);
625
+ if (kind !== 'image') return;
626
+ const preview = assetTools?.resolveAssetUrl?.(srcRel) || srcRel;
627
+ element.style.backgroundImage = `url("${preview}")`;
628
+ element.style.backgroundSize = element.style.backgroundSize || 'cover';
629
+ element.style.backgroundPosition = element.style.backgroundPosition || 'center';
630
+ element.style.backgroundRepeat = element.style.backgroundRepeat || 'no-repeat';
631
+ onChange?.();
632
+ },
633
+ [assetTools, element, onChange],
634
+ );
635
+
636
+ const handleFile = useCallback(
637
+ async (file) => {
638
+ if (!file || typeof assetTools?.uploadAsset !== 'function') return;
639
+ setBusy(true);
640
+ setError('');
641
+ try {
642
+ const data = await assetTools.uploadAsset(file, scope);
643
+ applyAssetSrc(data.src, data.kind);
644
+ loadAssets();
645
+ } catch (e) {
646
+ setError(e?.message || 'Upload failed');
647
+ } finally {
648
+ setBusy(false);
649
+ }
650
+ },
651
+ [applyAssetSrc, assetTools, loadAssets, scope],
652
+ );
653
+
654
+ const currentSrc = isImg || isVideo ? element.getAttribute('src') : '';
655
+ const currentKind = isVideo ? 'video' : 'image';
656
+
657
+ return (
658
+ <section className="ppt-lumina-section">
659
+ <div className="ppt-lumina-section__head">
660
+ <h3>{ui.image}</h3>
661
+ </div>
662
+ {!enabled ? (
663
+ <p style={{ margin: 0, fontSize: 11.5, color: '#9a9a9a' }}>{ui.assetNeedsFile}</p>
664
+ ) : (
665
+ <>
666
+ {(isImg || isVideo) && currentSrc ? (
667
+ <div
668
+ style={{
669
+ display: 'flex',
670
+ alignItems: 'center',
671
+ justifyContent: 'center',
672
+ padding: 8,
673
+ marginBottom: 8,
674
+ border: '1px solid #ececec',
675
+ borderRadius: 8,
676
+ background: '#fafafa',
677
+ maxHeight: 120,
678
+ overflow: 'hidden',
679
+ }}
680
+ >
681
+ {currentKind === 'video' ? (
682
+ <video src={currentSrc} muted playsInline style={{ maxWidth: '100%', maxHeight: 104, objectFit: 'contain' }} />
683
+ ) : (
684
+ <img src={currentSrc} alt="" style={{ maxWidth: '100%', maxHeight: 104, objectFit: 'contain' }} />
685
+ )}
686
+ </div>
687
+ ) : null}
688
+
689
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
690
+ <span style={{ fontSize: 11, color: '#9a9a9a' }}>{ui.saveTo}</span>
691
+ {[
692
+ ['project', ui.assetScopeProject],
693
+ ['global', ui.assetScopeGlobal],
694
+ ].map(([value, label]) => (
695
+ <button
696
+ key={value}
697
+ type="button"
698
+ onClick={() => assetTools.setScope?.(value)}
699
+ style={{
700
+ flex: 1,
701
+ height: 26,
702
+ fontSize: 11.5,
703
+ fontWeight: 600,
704
+ cursor: 'pointer',
705
+ borderRadius: 6,
706
+ border: '1px solid ' + (scope === value ? '#0D99FF' : '#e0e0e0'),
707
+ background: scope === value ? 'rgba(13,153,255,0.12)' : '#fff',
708
+ color: scope === value ? '#0D78CC' : '#666',
709
+ }}
710
+ >
711
+ {label}
712
+ </button>
713
+ ))}
714
+ </div>
715
+
716
+ <button type="button" className="ppt-lumina-add" disabled={busy} onClick={() => fileInputRef.current?.click()}>
717
+ {busy ? ui.uploading : isImg ? ui.replaceImage : isVideo ? ui.replaceVideo : ui.insertImage}
718
+ </button>
719
+ <input
720
+ ref={fileInputRef}
721
+ type="file"
722
+ accept="image/*,video/mp4,video/webm,video/quicktime,video/ogg,.m4v"
723
+ hidden
724
+ onChange={(e) => {
725
+ const file = e.target.files?.[0];
726
+ e.target.value = '';
727
+ handleFile(file);
728
+ }}
729
+ />
730
+ {error ? (
731
+ <p style={{ margin: '6px 0 0', fontSize: 11, color: '#d23' }}>{error}</p>
732
+ ) : null}
733
+
734
+ <div style={{ marginTop: 12 }}>
735
+ <h4 style={{ margin: '0 0 6px', fontSize: 11, fontWeight: 600, color: '#9a9a9a' }}>{ui.savedAssets}</h4>
736
+ {assets.length === 0 ? (
737
+ <p style={{ margin: 0, fontSize: 11.5, color: '#b0b0b0' }}>{ui.noSavedAssets}</p>
738
+ ) : (
739
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}>
740
+ {assets.map((asset) => {
741
+ const kind = asset.kind || assetKindFromName(asset.src || asset.name);
742
+ const preview = assetTools.resolveAssetUrl?.(asset.src) || '';
743
+ const payload = JSON.stringify({ src: asset.src, name: asset.name, kind });
744
+ return (
745
+ <div key={asset.src} style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
746
+ <button
747
+ type="button"
748
+ title={`${asset.name} - ${ui.dragAssetHint}`}
749
+ draggable
750
+ onDragStart={(event) => {
751
+ event.dataTransfer.effectAllowed = 'copy';
752
+ event.dataTransfer.setData('application/x-edit-slide-asset', payload);
753
+ event.dataTransfer.setData('text/plain', asset.src);
754
+ }}
755
+ onClick={() => applyAssetSrc(asset.src, kind)}
756
+ style={{
757
+ padding: 0,
758
+ aspectRatio: '1 / 1',
759
+ cursor: 'grab',
760
+ borderRadius: 6,
761
+ border: '1px solid #e6e6e6',
762
+ background: '#fafafa',
763
+ overflow: 'hidden',
764
+ }}
765
+ >
766
+ {kind === 'video' ? (
767
+ <video src={preview} muted playsInline style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
768
+ ) : (
769
+ <img src={preview} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
770
+ )}
771
+ </button>
772
+ <button
773
+ type="button"
774
+ onClick={() => applyAssetBackground(asset.src, kind)}
775
+ disabled={kind !== 'image'}
776
+ title={kind === 'image' ? ui.applyBackground : 'Video cannot be used as a CSS background'}
777
+ style={{
778
+ border: '1px solid #e6e6e6',
779
+ borderRadius: 5,
780
+ background: '#fff',
781
+ color: kind === 'image' ? '#444' : '#aaa',
782
+ cursor: kind === 'image' ? 'pointer' : 'not-allowed',
783
+ fontSize: 9,
784
+ padding: '3px 2px',
785
+ lineHeight: 1.1,
786
+ }}
787
+ >
788
+ {ui.applyBackground}
789
+ </button>
790
+ </div>
791
+ );
792
+ })}
793
+ </div>
794
+ )}
795
+ </div>
796
+ </>
797
+ )}
798
+ </section>
799
+ );
800
+ }
801
+
802
+ function LuminaMetric({ label, value, onChange, placeholder }) {
803
+ return (
804
+ <label className="ppt-lumina-metric">
805
+ <span>{label}</span>
806
+ <input
807
+ type="text"
808
+ value={inputValue(value)}
809
+ onChange={(e) => onChange(e.target.value)}
810
+ placeholder={placeholder}
811
+ />
812
+ </label>
813
+ );
814
+ }
815
+
816
+ function LuminaCorner({ label, value, onChange }) {
817
+ return (
818
+ <label>
819
+ <span>{label}</span>
820
+ <input
821
+ type="text"
822
+ value={inputValue(value)}
823
+ onChange={(e) => onChange(e.target.value)}
824
+ placeholder="0"
825
+ />
826
+ </label>
827
+ );
828
+ }
829
+
830
+ /**
831
+ * Visual, draggable box-model spacing editor (DevTools/Figma style).
832
+ * The outer band is margin, the inner band is padding, the centre is content.
833
+ * Each side shows a numeric chip that you can either type into or drag to
834
+ * scrub the value — dragging a chip toward the centre increases that side.
835
+ */
836
+ function SpacingBoxEditor({ labels, values, onMarginChange, onPaddingChange }) {
837
+ const marginLabel = labels.margin || 'Margin';
838
+ const paddingLabel = labels.padding || 'Padding';
839
+ const sides = [
840
+ { side: 'Top', label: labels.top || 'Top' },
841
+ { side: 'Right', label: labels.right || 'Right' },
842
+ { side: 'Bottom', label: labels.bottom || 'Bottom' },
843
+ { side: 'Left', label: labels.left || 'Left' },
844
+ ];
845
+ return (
846
+ <div className="cv-spacingbox" style={{ userSelect: 'none', WebkitUserSelect: 'none' }}>
847
+ <SpacingRegion
848
+ cornerLabel={marginLabel}
849
+ bandColor="rgba(247, 181, 99, 0.16)"
850
+ edgeColor="#c07d22"
851
+ borderColor="rgba(214, 142, 53, 0.34)"
852
+ prefix="margin"
853
+ sides={sides}
854
+ values={values}
855
+ allowNegative
856
+ onChange={onMarginChange}
857
+ >
858
+ <SpacingRegion
859
+ cornerLabel={paddingLabel}
860
+ bandColor="rgba(120, 190, 120, 0.18)"
861
+ edgeColor="#3a8f3a"
862
+ borderColor="rgba(60, 150, 60, 0.34)"
863
+ prefix="padding"
864
+ sides={sides}
865
+ values={values}
866
+ onChange={onPaddingChange}
867
+ >
868
+ <div
869
+ style={{
870
+ minHeight: 30,
871
+ minWidth: 52,
872
+ display: 'flex',
873
+ alignItems: 'center',
874
+ justifyContent: 'center',
875
+ background: 'rgba(120, 160, 235, 0.14)',
876
+ border: '1px dashed rgba(70, 110, 200, 0.32)',
877
+ borderRadius: 6,
878
+ fontSize: 9,
879
+ letterSpacing: '.06em',
880
+ textTransform: 'uppercase',
881
+ color: '#7f8aa6',
882
+ fontWeight: 600,
883
+ }}
884
+ >
885
+ Content
886
+ </div>
887
+ </SpacingRegion>
888
+ </SpacingRegion>
889
+ </div>
890
+ );
891
+ }
892
+
893
+ function SpacingRegion({
894
+ cornerLabel,
895
+ bandColor,
896
+ edgeColor,
897
+ borderColor,
898
+ prefix,
899
+ sides,
900
+ values,
901
+ onChange,
902
+ allowNegative = false,
903
+ children,
904
+ }) {
905
+ return (
906
+ <div
907
+ style={{
908
+ position: 'relative',
909
+ background: bandColor,
910
+ border: `1px dashed ${borderColor}`,
911
+ borderRadius: 9,
912
+ padding: '24px 34px',
913
+ }}
914
+ >
915
+ <span
916
+ style={{
917
+ position: 'absolute',
918
+ top: 3,
919
+ left: 7,
920
+ fontSize: 9,
921
+ letterSpacing: '.06em',
922
+ textTransform: 'uppercase',
923
+ color: edgeColor,
924
+ fontWeight: 600,
925
+ pointerEvents: 'none',
926
+ }}
927
+ >
928
+ {cornerLabel}
929
+ </span>
930
+ {sides.map(({ side, label }) => (
931
+ <ScrubEdge
932
+ key={side}
933
+ side={side}
934
+ ariaLabel={`${cornerLabel} ${label}`}
935
+ color={edgeColor}
936
+ value={values[`${prefix}${side}`]}
937
+ allowNegative={allowNegative}
938
+ onChange={(value) => onChange(side, value)}
939
+ />
940
+ ))}
941
+ {children}
942
+ </div>
943
+ );
944
+ }
945
+
946
+ const SPACING_EDGE_POS = {
947
+ Top: { top: 2, left: '50%', transform: 'translateX(-50%)', cursor: 'ns-resize' },
948
+ Bottom: { bottom: 2, left: '50%', transform: 'translateX(-50%)', cursor: 'ns-resize' },
949
+ Left: { left: 2, top: '50%', transform: 'translateY(-50%)', cursor: 'ew-resize' },
950
+ Right: { right: 2, top: '50%', transform: 'translateY(-50%)', cursor: 'ew-resize' },
951
+ };
952
+
953
+ /** A numeric chip sitting on one edge: type a value, or drag to scrub it. */
954
+ function ScrubEdge({ side, value, onChange, color, ariaLabel, allowNegative }) {
955
+ const inputRef = useRef(null);
956
+ const drag = useRef(null);
957
+ const axis = side === 'Left' || side === 'Right' ? 'x' : 'y';
958
+ // Drag toward the centre grows the gap: Top/Left follow +delta, Bottom/Right invert.
959
+ const sign = side === 'Top' || side === 'Left' ? 1 : -1;
960
+
961
+ const handlePointerDown = (e) => {
962
+ if (e.button !== 0) return;
963
+ drag.current = {
964
+ origin: axis === 'x' ? e.clientX : e.clientY,
965
+ startVal: parseFloat(value) || 0,
966
+ moved: false,
967
+ };
968
+ try { e.currentTarget.setPointerCapture(e.pointerId); } catch {}
969
+ };
970
+
971
+ const handlePointerMove = (e) => {
972
+ const d = drag.current;
973
+ if (!d) return;
974
+ const delta = (axis === 'x' ? e.clientX : e.clientY) - d.origin;
975
+ if (!d.moved && Math.abs(delta) < 3) return;
976
+ d.moved = true;
977
+ e.preventDefault();
978
+ let next = Math.round(d.startVal + sign * delta);
979
+ if (!allowNegative && next < 0) next = 0;
980
+ onChange(String(next));
981
+ };
982
+
983
+ const handlePointerUp = (e) => {
984
+ const d = drag.current;
985
+ drag.current = null;
986
+ try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
987
+ if (d && !d.moved) {
988
+ inputRef.current?.focus();
989
+ inputRef.current?.select();
990
+ }
991
+ };
992
+
993
+ return (
994
+ <input
995
+ ref={inputRef}
996
+ type="text"
997
+ inputMode="numeric"
998
+ aria-label={ariaLabel}
999
+ title={ariaLabel}
1000
+ value={inputValue(value)}
1001
+ placeholder="0"
1002
+ onChange={(e) => onChange(e.target.value)}
1003
+ onPointerDown={handlePointerDown}
1004
+ onPointerMove={handlePointerMove}
1005
+ onPointerUp={handlePointerUp}
1006
+ onPointerCancel={handlePointerUp}
1007
+ onFocus={(e) => {
1008
+ e.target.style.background = '#fff';
1009
+ e.target.style.boxShadow = `0 0 0 1px ${color}`;
1010
+ }}
1011
+ onBlur={(e) => {
1012
+ e.target.style.background = 'transparent';
1013
+ e.target.style.boxShadow = 'none';
1014
+ }}
1015
+ style={{
1016
+ position: 'absolute',
1017
+ width: 34,
1018
+ textAlign: 'center',
1019
+ border: 'none',
1020
+ background: 'transparent',
1021
+ color,
1022
+ fontSize: 11,
1023
+ fontWeight: 600,
1024
+ fontVariantNumeric: 'tabular-nums',
1025
+ padding: '2px 0',
1026
+ borderRadius: 5,
1027
+ outline: 'none',
1028
+ touchAction: 'none',
1029
+ ...SPACING_EDGE_POS[side],
1030
+ }}
1031
+ />
1032
+ );
1033
+ }
1034
+
1035
+ function LuminaAlign({ value, onChange }) {
1036
+ const current = inputValue(value);
1037
+ const normalizedCurrent = current === 'left' ? 'start' : current;
1038
+ const options = [
1039
+ ['start', 'Left', AlignLeft],
1040
+ ['center', 'Center', AlignCenter],
1041
+ ['right', 'Right', AlignRight],
1042
+ ['justify', 'Justify', AlignJustify],
1043
+ ];
1044
+ return (
1045
+ <div className="ppt-lumina-align" role="group" aria-label="Alignment">
1046
+ {options.map(([optionValue, label, Icon]) => (
1047
+ <button
1048
+ key={optionValue}
1049
+ type="button"
1050
+ className={normalizedCurrent === optionValue ? 'is-active' : ''}
1051
+ onClick={() => onChange(optionValue)}
1052
+ title={optionValue}
1053
+ aria-label={label}
1054
+ aria-pressed={normalizedCurrent === optionValue}
1055
+ >
1056
+ <Icon aria-hidden="true" size={15} strokeWidth={2.2} />
1057
+ </button>
1058
+ ))}
1059
+ </div>
1060
+ );
1061
+ }