@textcortex/slidewise 1.0.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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/dist/__vite-browser-external-DYxpcVy9.js +5 -0
  4. package/dist/__vite-browser-external-DYxpcVy9.js.map +1 -0
  5. package/dist/file.svg +1 -0
  6. package/dist/globe.svg +1 -0
  7. package/dist/index.mjs +16697 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/slidewise.css +1 -0
  10. package/dist/types/SlidewiseEditor.d.ts +47 -0
  11. package/dist/types/SlidewiseFileEditor.d.ts +54 -0
  12. package/dist/types/components/editor/BottomToolbar.d.ts +1 -0
  13. package/dist/types/components/editor/Canvas.d.ts +1 -0
  14. package/dist/types/components/editor/Editor.d.ts +8 -0
  15. package/dist/types/components/editor/ElementView.d.ts +6 -0
  16. package/dist/types/components/editor/FloatingToolbar.d.ts +6 -0
  17. package/dist/types/components/editor/GridView.d.ts +1 -0
  18. package/dist/types/components/editor/PlayMode.d.ts +1 -0
  19. package/dist/types/components/editor/SelectionFrame.d.ts +8 -0
  20. package/dist/types/components/editor/SlideRail.d.ts +1 -0
  21. package/dist/types/components/editor/SlideView.d.ts +5 -0
  22. package/dist/types/components/editor/TopBar.d.ts +7 -0
  23. package/dist/types/index.d.ts +7 -0
  24. package/dist/types/lib/StoreProvider.d.ts +8 -0
  25. package/dist/types/lib/fonts.d.ts +9 -0
  26. package/dist/types/lib/pptx/deckToPptx.d.ts +9 -0
  27. package/dist/types/lib/pptx/index.d.ts +3 -0
  28. package/dist/types/lib/pptx/pptxToDeck.d.ts +18 -0
  29. package/dist/types/lib/pptx/types.d.ts +15 -0
  30. package/dist/types/lib/pptx/units.d.ts +25 -0
  31. package/dist/types/lib/schema/migrate.d.ts +25 -0
  32. package/dist/types/lib/seed.d.ts +2 -0
  33. package/dist/types/lib/store.d.ts +55 -0
  34. package/dist/types/lib/types.d.ts +141 -0
  35. package/dist/window.svg +1 -0
  36. package/package.json +86 -0
  37. package/src/App.tsx +261 -0
  38. package/src/SlidewiseEditor.css +146 -0
  39. package/src/SlidewiseEditor.tsx +214 -0
  40. package/src/SlidewiseFileEditor.tsx +242 -0
  41. package/src/components/editor/BottomToolbar.tsx +216 -0
  42. package/src/components/editor/Canvas.tsx +467 -0
  43. package/src/components/editor/Editor.tsx +53 -0
  44. package/src/components/editor/ElementView.tsx +588 -0
  45. package/src/components/editor/FloatingToolbar.tsx +729 -0
  46. package/src/components/editor/GridView.tsx +232 -0
  47. package/src/components/editor/PlayMode.tsx +260 -0
  48. package/src/components/editor/SelectionFrame.tsx +241 -0
  49. package/src/components/editor/SlideRail.tsx +285 -0
  50. package/src/components/editor/SlideView.tsx +55 -0
  51. package/src/components/editor/TopBar.tsx +240 -0
  52. package/src/fonts.css +2 -0
  53. package/src/index.css +13 -0
  54. package/src/index.ts +36 -0
  55. package/src/lib/StoreProvider.tsx +43 -0
  56. package/src/lib/__tests__/css-scope.test.ts +133 -0
  57. package/src/lib/fonts.ts +104 -0
  58. package/src/lib/pptx/__tests__/roundtrip.test.ts +240 -0
  59. package/src/lib/pptx/deckToPptx.ts +300 -0
  60. package/src/lib/pptx/index.ts +3 -0
  61. package/src/lib/pptx/pptxToDeck.ts +1515 -0
  62. package/src/lib/pptx/types.ts +17 -0
  63. package/src/lib/pptx/units.ts +32 -0
  64. package/src/lib/schema/__tests__/migrate.test.ts +70 -0
  65. package/src/lib/schema/migrate.ts +102 -0
  66. package/src/lib/seed.ts +777 -0
  67. package/src/lib/store.ts +384 -0
  68. package/src/lib/types.ts +185 -0
  69. package/src/main.tsx +10 -0
  70. package/src/vite-env.d.ts +3 -0
@@ -0,0 +1,53 @@
1
+ import { useEditor } from "@/lib/StoreProvider";
2
+ import type { Deck } from "@/lib/types";
3
+ import { TopBar } from "./TopBar";
4
+ import { SlideRail } from "./SlideRail";
5
+ import { Canvas } from "./Canvas";
6
+ import { BottomToolbar } from "./BottomToolbar";
7
+ import { PlayMode } from "./PlayMode";
8
+ import { GridView } from "./GridView";
9
+
10
+ interface EditorProps {
11
+ showTopBar?: boolean;
12
+ onSave?: (deck: Deck) => void | Promise<void>;
13
+ onExport?: (deck: Deck) => void;
14
+ }
15
+
16
+ export function Editor({ showTopBar = true, onSave, onExport }: EditorProps = {}) {
17
+ const playing = useEditor((s) => s.playing);
18
+ const view = useEditor((s) => s.view);
19
+ const theme = useEditor((s) => s.theme);
20
+
21
+ return (
22
+ <div
23
+ className={`slidewise-editor theme-${theme}`}
24
+ style={{
25
+ width: "100%",
26
+ height: "100%",
27
+ display: "flex",
28
+ flexDirection: "column",
29
+ background: "var(--app-bg)",
30
+ color: "var(--ink)",
31
+ overflow: "hidden",
32
+ }}
33
+ >
34
+ {showTopBar && <TopBar onSave={onSave} onExport={onExport} />}
35
+ <div
36
+ style={{
37
+ flex: 1,
38
+ display: "flex",
39
+ overflow: "hidden",
40
+ position: "relative",
41
+ }}
42
+ >
43
+ <SlideRail />
44
+ <div style={{ flex: 1, display: "flex", position: "relative" }}>
45
+ <Canvas />
46
+ <BottomToolbar />
47
+ </div>
48
+ </div>
49
+ {view === "grid" && <GridView />}
50
+ {playing && <PlayMode />}
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,588 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type {
3
+ SlideElement,
4
+ TextElement,
5
+ TextRun,
6
+ ShapeElement,
7
+ ImageElement,
8
+ LineElement,
9
+ TableElement,
10
+ IconElement,
11
+ EmbedElement,
12
+ UnknownElement,
13
+ } from "@/lib/types";
14
+
15
+ export function ElementView({
16
+ el,
17
+ editing,
18
+ onTextCommit,
19
+ }: {
20
+ el: SlideElement;
21
+ editing?: boolean;
22
+ onTextCommit?: (text: string, runs?: TextRun[]) => void;
23
+ }) {
24
+ switch (el.type) {
25
+ case "text":
26
+ return <TextView el={el} editing={editing} onCommit={onTextCommit} />;
27
+ case "shape":
28
+ return <ShapeView el={el} />;
29
+ case "image":
30
+ return <ImageView el={el} />;
31
+ case "line":
32
+ return <LineView el={el} />;
33
+ case "table":
34
+ return <TableView el={el} />;
35
+ case "icon":
36
+ return <IconView el={el} />;
37
+ case "embed":
38
+ return <EmbedView el={el} />;
39
+ case "unknown":
40
+ return <UnknownView el={el} />;
41
+ }
42
+ }
43
+
44
+ function TextView({
45
+ el,
46
+ editing,
47
+ onCommit,
48
+ }: {
49
+ el: TextElement;
50
+ editing?: boolean;
51
+ onCommit?: (text: string, runs?: TextRun[]) => void;
52
+ }) {
53
+ // Outer wrapper handles vertical alignment via flex; the inner block carries
54
+ // the typographic flow so inline <span> runs lay out correctly. Putting flex
55
+ // on the same node as the spans turns each span into a block-level flex
56
+ // item — that broke multi-color text layout in v1.
57
+ const outer: React.CSSProperties = {
58
+ width: "100%",
59
+ height: "100%",
60
+ display: "flex",
61
+ flexDirection: "column",
62
+ justifyContent:
63
+ el.vAlign === "top"
64
+ ? "flex-start"
65
+ : el.vAlign === "middle"
66
+ ? "center"
67
+ : "flex-end",
68
+ cursor: editing ? "text" : "inherit",
69
+ };
70
+ const inner: React.CSSProperties = {
71
+ width: "100%",
72
+ color: el.color,
73
+ fontFamily: el.fontFamily,
74
+ fontSize: el.fontSize,
75
+ fontWeight: el.fontWeight,
76
+ fontStyle: el.italic ? "italic" : "normal",
77
+ textDecoration: [el.underline && "underline", el.strike && "line-through"]
78
+ .filter(Boolean)
79
+ .join(" "),
80
+ textAlign: el.align,
81
+ lineHeight: el.lineHeight,
82
+ letterSpacing: el.letterSpacing,
83
+ whiteSpace: "pre-wrap",
84
+ wordBreak: "break-word",
85
+ outline: "none",
86
+ };
87
+
88
+ if (editing) {
89
+ return (
90
+ <div style={outer}>
91
+ <EditableText
92
+ style={inner}
93
+ initialText={el.text}
94
+ initialRuns={el.runs}
95
+ onCommit={(t, r) => onCommit?.(t, r)}
96
+ />
97
+ </div>
98
+ );
99
+ }
100
+
101
+ if (el.runs && el.runs.length) {
102
+ return (
103
+ <div style={outer}>
104
+ <div style={inner}>
105
+ {el.runs.map((r, i) => (
106
+ <span key={i} style={runCssStyle(r)}>
107
+ {r.text}
108
+ </span>
109
+ ))}
110
+ </div>
111
+ </div>
112
+ );
113
+ }
114
+
115
+ return (
116
+ <div style={outer}>
117
+ <div style={inner}>{el.text}</div>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ function runCssStyle(r: TextRun): React.CSSProperties {
123
+ const s: React.CSSProperties = {};
124
+ if (r.fontFamily) s.fontFamily = r.fontFamily;
125
+ if (r.fontSize) s.fontSize = r.fontSize;
126
+ if (r.fontWeight) s.fontWeight = r.fontWeight;
127
+ if (r.color) s.color = r.color;
128
+ if (r.italic) s.fontStyle = "italic";
129
+ if (r.letterSpacing != null) s.letterSpacing = r.letterSpacing;
130
+ const decoration = [r.underline && "underline", r.strike && "line-through"]
131
+ .filter(Boolean)
132
+ .join(" ");
133
+ if (decoration) s.textDecoration = decoration;
134
+ return s;
135
+ }
136
+
137
+ function EditableText({
138
+ style,
139
+ initialText,
140
+ initialRuns,
141
+ onCommit,
142
+ }: {
143
+ style: React.CSSProperties;
144
+ initialText: string;
145
+ initialRuns?: TextRun[];
146
+ onCommit: (text: string, runs?: TextRun[]) => void;
147
+ }) {
148
+ const ref = useRef<HTMLDivElement>(null);
149
+ const initialRunsRef = useRef(initialRuns);
150
+
151
+ useEffect(() => {
152
+ const node = ref.current;
153
+ if (!node) return;
154
+ if (initialRunsRef.current && initialRunsRef.current.length) {
155
+ node.innerHTML = runsToHtml(initialRunsRef.current);
156
+ } else {
157
+ node.innerText = initialText;
158
+ }
159
+ node.focus();
160
+ const range = document.createRange();
161
+ range.selectNodeContents(node);
162
+ range.collapse(false);
163
+ const sel = window.getSelection();
164
+ sel?.removeAllRanges();
165
+ sel?.addRange(range);
166
+ // eslint-disable-next-line react-hooks/exhaustive-deps
167
+ }, []);
168
+
169
+ const commit = () => {
170
+ const node = ref.current;
171
+ if (!node) return;
172
+ const hadRuns = !!initialRunsRef.current?.length;
173
+ if (!hadRuns) {
174
+ onCommit(node.innerText, undefined);
175
+ return;
176
+ }
177
+ const { text, runs } = extractRunsFromDom(node);
178
+ // If extraction collapsed everything to one style, drop runs to keep the
179
+ // store representation clean.
180
+ const isHomogeneous =
181
+ runs.length <= 1 ||
182
+ runs.every((r) => sameStyle(r, runs[0]));
183
+ onCommit(text, isHomogeneous ? undefined : runs);
184
+ };
185
+
186
+ return (
187
+ <div
188
+ ref={ref}
189
+ style={style}
190
+ contentEditable
191
+ suppressContentEditableWarning
192
+ onMouseDown={(e) => e.stopPropagation()}
193
+ onClick={(e) => e.stopPropagation()}
194
+ onDoubleClick={(e) => e.stopPropagation()}
195
+ onBlur={commit}
196
+ onKeyDown={(e) => {
197
+ if (e.key === "Escape") {
198
+ (e.target as HTMLDivElement).blur();
199
+ }
200
+ e.stopPropagation();
201
+ }}
202
+ />
203
+ );
204
+ }
205
+
206
+ function escapeHtml(s: string): string {
207
+ return s
208
+ .replace(/&/g, "&amp;")
209
+ .replace(/</g, "&lt;")
210
+ .replace(/>/g, "&gt;")
211
+ .replace(/"/g, "&quot;");
212
+ }
213
+
214
+ function runsToHtml(runs: TextRun[]): string {
215
+ return runs
216
+ .map((r) => {
217
+ const props: string[] = [];
218
+ if (r.color) props.push(`color: ${r.color}`);
219
+ if (r.fontFamily) props.push(`font-family: ${r.fontFamily}`);
220
+ if (r.fontSize) props.push(`font-size: ${r.fontSize}px`);
221
+ if (r.fontWeight) props.push(`font-weight: ${r.fontWeight}`);
222
+ if (r.italic) props.push(`font-style: italic`);
223
+ if (r.letterSpacing != null) props.push(`letter-spacing: ${r.letterSpacing}px`);
224
+ const decoration = [r.underline && "underline", r.strike && "line-through"]
225
+ .filter(Boolean)
226
+ .join(" ");
227
+ if (decoration) props.push(`text-decoration: ${decoration}`);
228
+ const styleAttr = props.join("; ");
229
+ const html = escapeHtml(r.text).replace(/\n/g, "<br>");
230
+ return `<span data-slidewise-run="1" style="${styleAttr}">${html}</span>`;
231
+ })
232
+ .join("");
233
+ }
234
+
235
+ function styleToRun(el: HTMLElement, text: string): TextRun {
236
+ // Read explicit inline style only (not computed) so we don't capture
237
+ // inherited defaults like the body color.
238
+ const s = el.style;
239
+ const r: TextRun = { text };
240
+ if (s.color) r.color = s.color;
241
+ if (s.fontFamily) r.fontFamily = s.fontFamily.replace(/^["']|["']$/g, "");
242
+ if (s.fontSize) {
243
+ const px = parseFloat(s.fontSize);
244
+ if (Number.isFinite(px)) r.fontSize = px;
245
+ }
246
+ if (s.fontWeight) {
247
+ const w = parseInt(s.fontWeight, 10);
248
+ if (Number.isFinite(w)) r.fontWeight = w;
249
+ }
250
+ if (s.fontStyle === "italic") r.italic = true;
251
+ if (s.letterSpacing) {
252
+ const ls = parseFloat(s.letterSpacing);
253
+ if (Number.isFinite(ls)) r.letterSpacing = ls;
254
+ }
255
+ const td = s.textDecoration || s.textDecorationLine;
256
+ if (td?.includes("underline")) r.underline = true;
257
+ if (td?.includes("line-through")) r.strike = true;
258
+ return r;
259
+ }
260
+
261
+ function extractRunsFromDom(root: HTMLElement): { text: string; runs: TextRun[] } {
262
+ const runs: TextRun[] = [];
263
+ const text: string[] = [];
264
+
265
+ const walk = (node: Node, parentStyle: HTMLElement | null) => {
266
+ if (node.nodeType === Node.TEXT_NODE) {
267
+ const t = node.textContent ?? "";
268
+ if (!t) return;
269
+ runs.push(parentStyle ? styleToRun(parentStyle, t) : { text: t });
270
+ text.push(t);
271
+ return;
272
+ }
273
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
274
+ const el = node as HTMLElement;
275
+ if (el.tagName === "BR") {
276
+ // Append "\n" to the most recent run so it stays in-style.
277
+ if (runs.length) runs[runs.length - 1].text += "\n";
278
+ else runs.push({ text: "\n" });
279
+ text.push("\n");
280
+ return;
281
+ }
282
+ if (el.tagName === "DIV" || el.tagName === "P") {
283
+ // Browser may wrap new lines in <div>/<p>. Treat as line breaks between
284
+ // children: insert a "\n" before the children of every block past the
285
+ // first one.
286
+ if (runs.length || text.length) {
287
+ if (runs.length) runs[runs.length - 1].text += "\n";
288
+ else runs.push({ text: "\n" });
289
+ text.push("\n");
290
+ }
291
+ el.childNodes.forEach((c) => walk(c, el));
292
+ return;
293
+ }
294
+ // SPAN or any other inline wrapper: pass its style to children.
295
+ el.childNodes.forEach((c) => walk(c, el));
296
+ };
297
+
298
+ root.childNodes.forEach((c) => walk(c, null));
299
+ return { text: text.join(""), runs };
300
+ }
301
+
302
+ function sameStyle(a: TextRun, b: TextRun): boolean {
303
+ return (
304
+ a.color === b.color &&
305
+ a.fontFamily === b.fontFamily &&
306
+ a.fontSize === b.fontSize &&
307
+ a.fontWeight === b.fontWeight &&
308
+ a.italic === b.italic &&
309
+ a.underline === b.underline &&
310
+ a.strike === b.strike &&
311
+ a.letterSpacing === b.letterSpacing
312
+ );
313
+ }
314
+
315
+ function ShapeView({ el }: { el: ShapeElement }) {
316
+ const stroke = el.stroke ?? "transparent";
317
+ const sw = el.strokeWidth ?? 0;
318
+ if (el.shape === "rect" || el.shape === "rounded") {
319
+ return (
320
+ <div
321
+ style={{
322
+ width: "100%",
323
+ height: "100%",
324
+ background: el.fill,
325
+ borderRadius: el.shape === "rounded" ? (el.radius ?? 16) : 0,
326
+ border: sw ? `${sw}px solid ${stroke}` : undefined,
327
+ }}
328
+ />
329
+ );
330
+ }
331
+ if (el.shape === "circle") {
332
+ return (
333
+ <div
334
+ style={{
335
+ width: "100%",
336
+ height: "100%",
337
+ background: el.fill,
338
+ borderRadius: "50%",
339
+ border: sw ? `${sw}px solid ${stroke}` : undefined,
340
+ }}
341
+ />
342
+ );
343
+ }
344
+ return (
345
+ <svg viewBox="0 0 100 100" preserveAspectRatio="none" width="100%" height="100%">
346
+ {el.shape === "triangle" && (
347
+ <polygon
348
+ points="50,3 97,97 3,97"
349
+ fill={el.fill}
350
+ stroke={stroke}
351
+ strokeWidth={sw}
352
+ vectorEffect="non-scaling-stroke"
353
+ />
354
+ )}
355
+ {el.shape === "diamond" && (
356
+ <polygon
357
+ points="50,3 97,50 50,97 3,50"
358
+ fill={el.fill}
359
+ stroke={stroke}
360
+ strokeWidth={sw}
361
+ vectorEffect="non-scaling-stroke"
362
+ />
363
+ )}
364
+ {el.shape === "star" && (
365
+ <polygon
366
+ points="50,5 61,38 96,38 67,59 78,93 50,72 22,93 33,59 4,38 39,38"
367
+ fill={el.fill}
368
+ stroke={stroke}
369
+ strokeWidth={sw}
370
+ vectorEffect="non-scaling-stroke"
371
+ />
372
+ )}
373
+ </svg>
374
+ );
375
+ }
376
+
377
+ function ImageView({ el }: { el: ImageElement }) {
378
+ // When the source PPTX defined a crop (<a:srcRect>), render via
379
+ // background-image so we can apply background-size/position to mimic
380
+ // PowerPoint's "crop then fill" behaviour. Otherwise fall back to <img>
381
+ // with object-fit, which keeps a:alt text usable.
382
+ if (el.crop) {
383
+ const { l, r, t, b } = el.crop;
384
+ const remW = Math.max(0.0001, 1 - l - r);
385
+ const remH = Math.max(0.0001, 1 - t - b);
386
+ // Scale the source so its visible (post-crop) area exactly fills the box,
387
+ // then offset so the cropped corner sits at (0,0).
388
+ const sizeX = 100 / remW;
389
+ const sizeY = 100 / remH;
390
+ const posX = remW > 0 ? (l / (l + r || 1)) * 100 : 0;
391
+ const posY = remH > 0 ? (t / (t + b || 1)) * 100 : 0;
392
+ return (
393
+ <div
394
+ role="img"
395
+ aria-label={el.alt ?? ""}
396
+ style={{
397
+ width: "100%",
398
+ height: "100%",
399
+ overflow: "hidden",
400
+ borderRadius: el.radius ?? 0,
401
+ backgroundImage: `url(${el.src})`,
402
+ backgroundSize: `${sizeX}% ${sizeY}%`,
403
+ backgroundPosition: `${posX}% ${posY}%`,
404
+ backgroundRepeat: "no-repeat",
405
+ }}
406
+ />
407
+ );
408
+ }
409
+ return (
410
+ <div
411
+ style={{
412
+ width: "100%",
413
+ height: "100%",
414
+ overflow: "hidden",
415
+ borderRadius: el.radius ?? 0,
416
+ }}
417
+ >
418
+ <img
419
+ src={el.src}
420
+ alt={el.alt ?? ""}
421
+ draggable={false}
422
+ style={{
423
+ width: "100%",
424
+ height: "100%",
425
+ objectFit: el.fit,
426
+ display: "block",
427
+ userSelect: "none",
428
+ }}
429
+ />
430
+ </div>
431
+ );
432
+ }
433
+
434
+ function LineView({ el }: { el: LineElement }) {
435
+ // A LineElement renders a segment from one corner of its bounding box to
436
+ // the opposite corner — supports horizontal, vertical, and diagonal lines.
437
+ // Negative w/h come from PPTX flipH/flipV: invert the start/end so the
438
+ // visual direction matches the source.
439
+ const aw = Math.abs(el.w) || 1;
440
+ const ah = Math.abs(el.h) || 1;
441
+ const x1 = el.w < 0 ? aw : 0;
442
+ const y1 = el.h < 0 ? ah : 0;
443
+ const x2 = el.w < 0 ? 0 : aw;
444
+ const y2 = el.h < 0 ? 0 : ah;
445
+ return (
446
+ <svg
447
+ viewBox={`0 0 ${aw} ${ah}`}
448
+ preserveAspectRatio="none"
449
+ width="100%"
450
+ height="100%"
451
+ style={{ overflow: "visible" }}
452
+ >
453
+ <line
454
+ x1={x1}
455
+ y1={y1}
456
+ x2={x2}
457
+ y2={y2}
458
+ stroke={el.stroke}
459
+ strokeWidth={el.strokeWidth}
460
+ strokeDasharray={el.dashed ? "12 8" : undefined}
461
+ strokeLinecap="round"
462
+ vectorEffect="non-scaling-stroke"
463
+ />
464
+ {el.arrow && (
465
+ <polygon
466
+ points={`${x2},${y2} ${x2 - 18},${y2 - 9} ${x2 - 18},${y2 + 9}`}
467
+ fill={el.stroke}
468
+ />
469
+ )}
470
+ </svg>
471
+ );
472
+ }
473
+
474
+ function TableView({ el }: { el: TableElement }) {
475
+ const cols = el.rows[0]?.length ?? 1;
476
+ // PPTX-faithful: contiguous cells, no inter-cell gap, no rounded corners.
477
+ // Earlier "card grid" styling drifted too far from the source look.
478
+ return (
479
+ <div
480
+ style={{
481
+ display: "grid",
482
+ gridTemplateColumns: `repeat(${cols}, 1fr)`,
483
+ gridAutoRows: "1fr",
484
+ width: "100%",
485
+ height: "100%",
486
+ gap: 0,
487
+ background: "transparent",
488
+ }}
489
+ >
490
+ {el.rows.flatMap((row, ri) =>
491
+ row.map((cell, ci) => (
492
+ <div
493
+ key={`${ri}-${ci}`}
494
+ style={{
495
+ background: ri === 0 ? el.headerFill : el.rowFill,
496
+ color: el.textColor,
497
+ fontSize: el.fontSize,
498
+ padding: "12px 16px",
499
+ display: "flex",
500
+ alignItems: "center",
501
+ fontWeight: ri === 0 ? 600 : 400,
502
+ boxSizing: "border-box",
503
+ minWidth: 0,
504
+ minHeight: 0,
505
+ overflow: "hidden",
506
+ wordBreak: "break-word",
507
+ }}
508
+ >
509
+ {cell}
510
+ </div>
511
+ ))
512
+ )}
513
+ </div>
514
+ );
515
+ }
516
+
517
+ function IconView({ el }: { el: IconElement }) {
518
+ return (
519
+ <div
520
+ style={{
521
+ width: "100%",
522
+ height: "100%",
523
+ display: "flex",
524
+ alignItems: "center",
525
+ justifyContent: "center",
526
+ color: el.color,
527
+ fontSize: Math.min(el.w, el.h) * 0.7,
528
+ }}
529
+ >
530
+ {el.icon}
531
+ </div>
532
+ );
533
+ }
534
+
535
+ function UnknownView({ el }: { el: UnknownElement }) {
536
+ return (
537
+ <div
538
+ style={{
539
+ width: "100%",
540
+ height: "100%",
541
+ background:
542
+ "repeating-linear-gradient(45deg, rgba(15,19,48,0.04) 0 8px, transparent 8px 16px)",
543
+ border: "1px dashed var(--border-strong)",
544
+ borderRadius: 8,
545
+ display: "flex",
546
+ alignItems: "center",
547
+ justifyContent: "center",
548
+ flexDirection: "column",
549
+ gap: 4,
550
+ color: "var(--ink-muted)",
551
+ fontFamily: "Inter, system-ui, sans-serif",
552
+ fontSize: 12,
553
+ padding: 12,
554
+ textAlign: "center",
555
+ }}
556
+ >
557
+ <div style={{ fontWeight: 600 }}>{el.label ?? "Imported content"}</div>
558
+ <div style={{ opacity: 0.7 }}>{el.ooxmlTag}</div>
559
+ </div>
560
+ );
561
+ }
562
+
563
+ function EmbedView({ el }: { el: EmbedElement }) {
564
+ return (
565
+ <div
566
+ style={{
567
+ width: "100%",
568
+ height: "100%",
569
+ background: "#0E1330",
570
+ color: "#fff",
571
+ borderRadius: 12,
572
+ display: "flex",
573
+ alignItems: "center",
574
+ justifyContent: "center",
575
+ flexDirection: "column",
576
+ padding: 16,
577
+ gap: 8,
578
+ fontFamily: "Inter",
579
+ }}
580
+ >
581
+ <div style={{ fontSize: 14, opacity: 0.6 }}>Embed</div>
582
+ <div style={{ fontSize: 18, fontWeight: 600 }}>{el.label}</div>
583
+ <div style={{ fontSize: 12, opacity: 0.5, wordBreak: "break-all" }}>
584
+ {el.url}
585
+ </div>
586
+ </div>
587
+ );
588
+ }