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.
- package/app/docs/content.js +57 -23
- package/cli/dist/commands/edit.d.ts +1 -1
- package/cli/dist/commands/edit.js +231 -3
- package/cli/dist/index.js +0 -0
- package/lib/local-editor-server.js +316 -0
- package/lib/local-editor-state.js +45 -0
- package/lib/local-slide-editor-launcher.js +19 -18
- package/lib/pptx-studio-mcp-core.js +15 -9
- package/local-editor-app/app/api/edit-slide/local-health/route.js +16 -0
- package/local-editor-app/app/edit-slide/edit-slide-client.jsx +13153 -0
- package/local-editor-app/app/edit-slide/page.jsx +13 -0
- package/local-editor-app/app/globals.css +4 -0
- package/local-editor-app/app/layout.jsx +14 -0
- package/local-editor-app/components/studio/edit-property-panel.jsx +1061 -0
- package/local-editor-app/lib/edit-panel-value-normalizer.js +97 -0
- package/local-editor-app/lib/edit-slide-editor-helpers.js +120 -0
- package/local-editor-app/lib/edit-slide-url-security.js +247 -0
- package/local-editor-app/next.config.mjs +31 -0
- package/local-editor-app/package.json +7 -0
- package/mcp/pptx-studio-mcp-server.mjs +1 -1
- package/package.json +16 -3
- package/public/skills/html2pptx/SKILL.md +635 -0
- package/public/skills/html2pptx/references/automation-contract.md +68 -0
- package/public/skills/html2pptx/references/input-contract.md +107 -0
- package/public/skills/html2pptx/references/japanese-slide-design.md +273 -0
- package/public/skills/html2pptx/references/rewrite-patterns.md +218 -0
- package/public/skills/icon-generator/SKILL.md +133 -0
- package/public/skills/open-slide/SKILL.md +160 -0
- package/public/skills/publish-template/SKILL.md +215 -0
- package/public/skills/register-template/SKILL.md +142 -0
- package/scripts/extract-html2pptx-comments.mjs +172 -0
- package/scripts/install-mcp.mjs +58 -13
- 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 & 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
|
+
}
|