@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.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/dist/__vite-browser-external-DYxpcVy9.js +5 -0
- package/dist/__vite-browser-external-DYxpcVy9.js.map +1 -0
- package/dist/file.svg +1 -0
- package/dist/globe.svg +1 -0
- package/dist/index.mjs +16697 -0
- package/dist/index.mjs.map +1 -0
- package/dist/slidewise.css +1 -0
- package/dist/types/SlidewiseEditor.d.ts +47 -0
- package/dist/types/SlidewiseFileEditor.d.ts +54 -0
- package/dist/types/components/editor/BottomToolbar.d.ts +1 -0
- package/dist/types/components/editor/Canvas.d.ts +1 -0
- package/dist/types/components/editor/Editor.d.ts +8 -0
- package/dist/types/components/editor/ElementView.d.ts +6 -0
- package/dist/types/components/editor/FloatingToolbar.d.ts +6 -0
- package/dist/types/components/editor/GridView.d.ts +1 -0
- package/dist/types/components/editor/PlayMode.d.ts +1 -0
- package/dist/types/components/editor/SelectionFrame.d.ts +8 -0
- package/dist/types/components/editor/SlideRail.d.ts +1 -0
- package/dist/types/components/editor/SlideView.d.ts +5 -0
- package/dist/types/components/editor/TopBar.d.ts +7 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/lib/StoreProvider.d.ts +8 -0
- package/dist/types/lib/fonts.d.ts +9 -0
- package/dist/types/lib/pptx/deckToPptx.d.ts +9 -0
- package/dist/types/lib/pptx/index.d.ts +3 -0
- package/dist/types/lib/pptx/pptxToDeck.d.ts +18 -0
- package/dist/types/lib/pptx/types.d.ts +15 -0
- package/dist/types/lib/pptx/units.d.ts +25 -0
- package/dist/types/lib/schema/migrate.d.ts +25 -0
- package/dist/types/lib/seed.d.ts +2 -0
- package/dist/types/lib/store.d.ts +55 -0
- package/dist/types/lib/types.d.ts +141 -0
- package/dist/window.svg +1 -0
- package/package.json +86 -0
- package/src/App.tsx +261 -0
- package/src/SlidewiseEditor.css +146 -0
- package/src/SlidewiseEditor.tsx +214 -0
- package/src/SlidewiseFileEditor.tsx +242 -0
- package/src/components/editor/BottomToolbar.tsx +216 -0
- package/src/components/editor/Canvas.tsx +467 -0
- package/src/components/editor/Editor.tsx +53 -0
- package/src/components/editor/ElementView.tsx +588 -0
- package/src/components/editor/FloatingToolbar.tsx +729 -0
- package/src/components/editor/GridView.tsx +232 -0
- package/src/components/editor/PlayMode.tsx +260 -0
- package/src/components/editor/SelectionFrame.tsx +241 -0
- package/src/components/editor/SlideRail.tsx +285 -0
- package/src/components/editor/SlideView.tsx +55 -0
- package/src/components/editor/TopBar.tsx +240 -0
- package/src/fonts.css +2 -0
- package/src/index.css +13 -0
- package/src/index.ts +36 -0
- package/src/lib/StoreProvider.tsx +43 -0
- package/src/lib/__tests__/css-scope.test.ts +133 -0
- package/src/lib/fonts.ts +104 -0
- package/src/lib/pptx/__tests__/roundtrip.test.ts +240 -0
- package/src/lib/pptx/deckToPptx.ts +300 -0
- package/src/lib/pptx/index.ts +3 -0
- package/src/lib/pptx/pptxToDeck.ts +1515 -0
- package/src/lib/pptx/types.ts +17 -0
- package/src/lib/pptx/units.ts +32 -0
- package/src/lib/schema/__tests__/migrate.test.ts +70 -0
- package/src/lib/schema/migrate.ts +102 -0
- package/src/lib/seed.ts +777 -0
- package/src/lib/store.ts +384 -0
- package/src/lib/types.ts +185 -0
- package/src/main.tsx +10 -0
- 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, "&")
|
|
209
|
+
.replace(/</g, "<")
|
|
210
|
+
.replace(/>/g, ">")
|
|
211
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|