@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,240 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Undo2,
|
|
3
|
+
Redo2,
|
|
4
|
+
Save,
|
|
5
|
+
Play,
|
|
6
|
+
Download,
|
|
7
|
+
Sparkles,
|
|
8
|
+
Sun,
|
|
9
|
+
Moon,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import { useEditor, useEditorStore } from "@/lib/StoreProvider";
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import type { Deck } from "@/lib/types";
|
|
14
|
+
|
|
15
|
+
interface TopBarProps {
|
|
16
|
+
onSave?: (deck: Deck) => void | Promise<void>;
|
|
17
|
+
onExport?: (deck: Deck) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function TopBar({ onSave: onSaveProp, onExport: onExportProp }: TopBarProps = {}) {
|
|
21
|
+
const store = useEditorStore();
|
|
22
|
+
const title = useEditor((s) => s.deck.title);
|
|
23
|
+
const setTitle = useEditor((s) => s.setTitle);
|
|
24
|
+
const undo = useEditor((s) => s.undo);
|
|
25
|
+
const redo = useEditor((s) => s.redo);
|
|
26
|
+
const play = useEditor((s) => s.play);
|
|
27
|
+
const theme = useEditor((s) => s.theme);
|
|
28
|
+
const toggleTheme = useEditor((s) => s.toggleTheme);
|
|
29
|
+
const [saved, setSaved] = useState<"idle" | "saving" | "saved">("idle");
|
|
30
|
+
|
|
31
|
+
const onSave = async () => {
|
|
32
|
+
setSaved("saving");
|
|
33
|
+
const deck = store.getState().deck;
|
|
34
|
+
try {
|
|
35
|
+
if (onSaveProp) {
|
|
36
|
+
await onSaveProp(deck);
|
|
37
|
+
} else {
|
|
38
|
+
try {
|
|
39
|
+
localStorage.setItem("slidewise-deck", JSON.stringify(deck));
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
setTimeout(() => setSaved("saved"), 320);
|
|
43
|
+
setTimeout(() => setSaved("idle"), 1600);
|
|
44
|
+
} catch {
|
|
45
|
+
setSaved("idle");
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const onExport = () => {
|
|
50
|
+
const deck = store.getState().deck;
|
|
51
|
+
if (onExportProp) {
|
|
52
|
+
onExportProp(deck);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const blob = new Blob([JSON.stringify(deck, null, 2)], {
|
|
56
|
+
type: "application/json",
|
|
57
|
+
});
|
|
58
|
+
const url = URL.createObjectURL(blob);
|
|
59
|
+
const a = document.createElement("a");
|
|
60
|
+
a.href = url;
|
|
61
|
+
a.download = `${(deck.title || "deck").replace(/[^a-z0-9-_]+/gi, "-")}.slidewise.json`;
|
|
62
|
+
a.click();
|
|
63
|
+
URL.revokeObjectURL(url);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
style={{
|
|
69
|
+
height: 56,
|
|
70
|
+
display: "flex",
|
|
71
|
+
alignItems: "center",
|
|
72
|
+
padding: "0 14px",
|
|
73
|
+
gap: 10,
|
|
74
|
+
background: "var(--app-bg)",
|
|
75
|
+
borderBottom: "1px solid var(--border)",
|
|
76
|
+
boxShadow: "var(--topbar-shadow)",
|
|
77
|
+
fontFamily: "Inter, system-ui, sans-serif",
|
|
78
|
+
position: "relative",
|
|
79
|
+
zIndex: 10,
|
|
80
|
+
color: "var(--ink)",
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
<IconBtn onClick={undo} title="Undo">
|
|
84
|
+
<Undo2 size={16} />
|
|
85
|
+
</IconBtn>
|
|
86
|
+
<IconBtn onClick={redo} title="Redo">
|
|
87
|
+
<Redo2 size={16} />
|
|
88
|
+
</IconBtn>
|
|
89
|
+
|
|
90
|
+
<div
|
|
91
|
+
style={{
|
|
92
|
+
flex: 1,
|
|
93
|
+
display: "flex",
|
|
94
|
+
alignItems: "center",
|
|
95
|
+
justifyContent: "center",
|
|
96
|
+
gap: 8,
|
|
97
|
+
minWidth: 0,
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
<span
|
|
101
|
+
style={{
|
|
102
|
+
display: "inline-flex",
|
|
103
|
+
alignItems: "center",
|
|
104
|
+
gap: 4,
|
|
105
|
+
padding: "3px 8px",
|
|
106
|
+
background: "var(--smart-grad)",
|
|
107
|
+
color: "var(--smart-fg)",
|
|
108
|
+
borderRadius: 999,
|
|
109
|
+
fontSize: 11,
|
|
110
|
+
fontWeight: 600,
|
|
111
|
+
letterSpacing: 0.2,
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<Sparkles size={11} />
|
|
115
|
+
Smart
|
|
116
|
+
</span>
|
|
117
|
+
<input
|
|
118
|
+
aria-label="Deck title"
|
|
119
|
+
value={title}
|
|
120
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
121
|
+
style={{
|
|
122
|
+
background: "transparent",
|
|
123
|
+
border: "none",
|
|
124
|
+
fontSize: 14,
|
|
125
|
+
fontWeight: 500,
|
|
126
|
+
color: "var(--ink)",
|
|
127
|
+
textAlign: "center",
|
|
128
|
+
minWidth: 240,
|
|
129
|
+
maxWidth: 520,
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<IconBtn
|
|
135
|
+
onClick={toggleTheme}
|
|
136
|
+
title={theme === "dark" ? "Light mode" : "Dark mode"}
|
|
137
|
+
>
|
|
138
|
+
{theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
|
|
139
|
+
</IconBtn>
|
|
140
|
+
|
|
141
|
+
<button
|
|
142
|
+
onClick={onSave}
|
|
143
|
+
style={chromeBtnStyle()}
|
|
144
|
+
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--hover)")}
|
|
145
|
+
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
|
146
|
+
>
|
|
147
|
+
<Save size={14} />
|
|
148
|
+
{saved === "saving" ? "Saving…" : saved === "saved" ? "Saved" : "Save"}
|
|
149
|
+
</button>
|
|
150
|
+
|
|
151
|
+
<button
|
|
152
|
+
onClick={play}
|
|
153
|
+
style={chromeBtnStyle()}
|
|
154
|
+
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--hover)")}
|
|
155
|
+
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
|
156
|
+
>
|
|
157
|
+
<Play size={14} />
|
|
158
|
+
Play
|
|
159
|
+
</button>
|
|
160
|
+
|
|
161
|
+
<button
|
|
162
|
+
onClick={onExport}
|
|
163
|
+
style={{
|
|
164
|
+
height: 32,
|
|
165
|
+
padding: "0 12px",
|
|
166
|
+
display: "flex",
|
|
167
|
+
alignItems: "center",
|
|
168
|
+
gap: 6,
|
|
169
|
+
background: "var(--primary-bg)",
|
|
170
|
+
border: "1px solid var(--primary-bg)",
|
|
171
|
+
borderRadius: 10,
|
|
172
|
+
cursor: "pointer",
|
|
173
|
+
color: "var(--primary-fg)",
|
|
174
|
+
fontSize: 13,
|
|
175
|
+
fontWeight: 500,
|
|
176
|
+
}}
|
|
177
|
+
onMouseEnter={(e) =>
|
|
178
|
+
(e.currentTarget.style.background = "var(--primary-bg-hover)")
|
|
179
|
+
}
|
|
180
|
+
onMouseLeave={(e) =>
|
|
181
|
+
(e.currentTarget.style.background = "var(--primary-bg)")
|
|
182
|
+
}
|
|
183
|
+
>
|
|
184
|
+
<Download size={14} />
|
|
185
|
+
Export
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function chromeBtnStyle(): React.CSSProperties {
|
|
192
|
+
return {
|
|
193
|
+
height: 32,
|
|
194
|
+
padding: "0 12px",
|
|
195
|
+
display: "flex",
|
|
196
|
+
alignItems: "center",
|
|
197
|
+
gap: 6,
|
|
198
|
+
background: "transparent",
|
|
199
|
+
border: "1px solid var(--border-strong)",
|
|
200
|
+
borderRadius: 10,
|
|
201
|
+
cursor: "pointer",
|
|
202
|
+
color: "var(--ink)",
|
|
203
|
+
fontSize: 13,
|
|
204
|
+
fontWeight: 500,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function IconBtn({
|
|
209
|
+
children,
|
|
210
|
+
onClick,
|
|
211
|
+
title,
|
|
212
|
+
}: {
|
|
213
|
+
children: React.ReactNode;
|
|
214
|
+
onClick: () => void;
|
|
215
|
+
title: string;
|
|
216
|
+
}) {
|
|
217
|
+
return (
|
|
218
|
+
<button
|
|
219
|
+
title={title}
|
|
220
|
+
aria-label={title}
|
|
221
|
+
onClick={onClick}
|
|
222
|
+
style={{
|
|
223
|
+
width: 32,
|
|
224
|
+
height: 32,
|
|
225
|
+
borderRadius: 8,
|
|
226
|
+
border: "none",
|
|
227
|
+
background: "transparent",
|
|
228
|
+
cursor: "pointer",
|
|
229
|
+
display: "flex",
|
|
230
|
+
alignItems: "center",
|
|
231
|
+
justifyContent: "center",
|
|
232
|
+
color: "var(--ink)",
|
|
233
|
+
}}
|
|
234
|
+
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--hover)")}
|
|
235
|
+
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
|
236
|
+
>
|
|
237
|
+
{children}
|
|
238
|
+
</button>
|
|
239
|
+
);
|
|
240
|
+
}
|
package/src/fonts.css
ADDED
package/src/index.css
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export {
|
|
2
|
+
SlidewiseEditor,
|
|
3
|
+
type SlidewiseEditorProps,
|
|
4
|
+
type SlidewiseEditorHandle,
|
|
5
|
+
} from "./SlidewiseEditor";
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
SlidewiseFileEditor,
|
|
9
|
+
type SlidewiseFileEditorProps,
|
|
10
|
+
type SlidewiseFileEditorApi,
|
|
11
|
+
} from "./SlidewiseFileEditor";
|
|
12
|
+
|
|
13
|
+
export { parsePptx, serializeDeck } from "./lib/pptx";
|
|
14
|
+
export type { ParseDiagnostics, ParseResult } from "./lib/pptx/types";
|
|
15
|
+
|
|
16
|
+
export { migrate, CURRENT_DECK_VERSION } from "./lib/schema/migrate";
|
|
17
|
+
|
|
18
|
+
export type {
|
|
19
|
+
Deck,
|
|
20
|
+
Slide,
|
|
21
|
+
SlideElement,
|
|
22
|
+
ElementType,
|
|
23
|
+
EnterAnim,
|
|
24
|
+
BaseElement,
|
|
25
|
+
TextElement,
|
|
26
|
+
ShapeElement,
|
|
27
|
+
ShapeKind,
|
|
28
|
+
ImageElement,
|
|
29
|
+
LineElement,
|
|
30
|
+
TableElement,
|
|
31
|
+
IconElement,
|
|
32
|
+
EmbedElement,
|
|
33
|
+
UnknownElement,
|
|
34
|
+
ElementDraft,
|
|
35
|
+
} from "./lib/types";
|
|
36
|
+
export { SLIDE_W, SLIDE_H } from "./lib/types";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useRef,
|
|
5
|
+
type PropsWithChildren,
|
|
6
|
+
} from "react";
|
|
7
|
+
import { useStore } from "zustand";
|
|
8
|
+
import {
|
|
9
|
+
createEditorStore,
|
|
10
|
+
type EditorState,
|
|
11
|
+
type EditorStore,
|
|
12
|
+
} from "./store";
|
|
13
|
+
import type { Deck } from "./types";
|
|
14
|
+
|
|
15
|
+
const EditorStoreContext = createContext<EditorStore | null>(null);
|
|
16
|
+
|
|
17
|
+
export function EditorStoreProvider({
|
|
18
|
+
initialDeck,
|
|
19
|
+
children,
|
|
20
|
+
}: PropsWithChildren<{ initialDeck: Deck }>) {
|
|
21
|
+
const storeRef = useRef<EditorStore | null>(null);
|
|
22
|
+
if (!storeRef.current) {
|
|
23
|
+
storeRef.current = createEditorStore(initialDeck);
|
|
24
|
+
}
|
|
25
|
+
return (
|
|
26
|
+
<EditorStoreContext.Provider value={storeRef.current}>
|
|
27
|
+
{children}
|
|
28
|
+
</EditorStoreContext.Provider>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useEditorStore(): EditorStore {
|
|
33
|
+
const store = useContext(EditorStoreContext);
|
|
34
|
+
if (!store) {
|
|
35
|
+
throw new Error("useEditor must be used within <EditorStoreProvider>");
|
|
36
|
+
}
|
|
37
|
+
return store;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useEditor<T>(selector: (s: EditorState) => T): T {
|
|
41
|
+
const store = useEditorStore();
|
|
42
|
+
return useStore(store, selector);
|
|
43
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const repoRoot = resolve(here, "..", "..", "..");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Slidewise ships as a library. Hosts mount it inside their own DOM, with
|
|
11
|
+
* their own global stylesheets (Tailwind preflight, normalize.css, app
|
|
12
|
+
* resets). Every rule we ship must therefore live under `.slidewise-editor`
|
|
13
|
+
* — anything at the document root would override host styles when our CSS
|
|
14
|
+
* loads, or get overridden when host CSS loads, depending on order.
|
|
15
|
+
*
|
|
16
|
+
* This test scans the source CSS for top-level selectors and rejects
|
|
17
|
+
* anything that escapes the scope. Allowed at top level: at-rules
|
|
18
|
+
* (`@font-face`, `@import`, `@media`, `@supports`, …) and selectors that
|
|
19
|
+
* begin with `.slidewise-editor`.
|
|
20
|
+
*/
|
|
21
|
+
describe("library CSS scope", () => {
|
|
22
|
+
it("every rule in SlidewiseEditor.css is scoped under .slidewise-editor", () => {
|
|
23
|
+
const css = readFileSync(
|
|
24
|
+
resolve(repoRoot, "src", "SlidewiseEditor.css"),
|
|
25
|
+
"utf8"
|
|
26
|
+
);
|
|
27
|
+
const violations = findUnscopedSelectors(css);
|
|
28
|
+
if (violations.length) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Found ${violations.length} unscoped top-level selector(s) in SlidewiseEditor.css. ` +
|
|
31
|
+
`Every rule must be nested under .slidewise-editor so the lib does not collide ` +
|
|
32
|
+
`with host styles. Offenders:\n ${violations.join("\n ")}`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
expect(violations).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Walk the CSS at brace depth 0, find each rule's selector list, and report
|
|
41
|
+
* any selector that does not start with `.slidewise-editor`. Strips comments
|
|
42
|
+
* and string contents first so braces inside them don't confuse the scan.
|
|
43
|
+
*/
|
|
44
|
+
function findUnscopedSelectors(rawCss: string): string[] {
|
|
45
|
+
const css = stripCommentsAndStrings(rawCss);
|
|
46
|
+
const violations: string[] = [];
|
|
47
|
+
let depth = 0;
|
|
48
|
+
let buf = "";
|
|
49
|
+
|
|
50
|
+
for (let i = 0; i < css.length; i++) {
|
|
51
|
+
const ch = css[i];
|
|
52
|
+
if (ch === "{") {
|
|
53
|
+
if (depth === 0) {
|
|
54
|
+
const selector = buf.trim();
|
|
55
|
+
if (selector && !isAllowedTopLevel(selector)) {
|
|
56
|
+
for (const sel of splitSelectorList(selector)) {
|
|
57
|
+
const s = sel.trim();
|
|
58
|
+
if (!s) continue;
|
|
59
|
+
if (!s.startsWith(".slidewise-editor")) {
|
|
60
|
+
violations.push(s);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
buf = "";
|
|
65
|
+
}
|
|
66
|
+
depth++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (ch === "}") {
|
|
70
|
+
depth = Math.max(0, depth - 1);
|
|
71
|
+
if (depth === 0) buf = "";
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (depth === 0) buf += ch;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return violations;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isAllowedTopLevel(selector: string): boolean {
|
|
81
|
+
// At-rules (@font-face, @media, @supports, @keyframes, @import, …) are
|
|
82
|
+
// global by design and don't override host styles unless they nest
|
|
83
|
+
// unscoped rules — which the recursion catches at depth > 0 if it ever
|
|
84
|
+
// matters. @font-face etc. only register names; they don't paint.
|
|
85
|
+
return selector.startsWith("@");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function splitSelectorList(selector: string): string[] {
|
|
89
|
+
// CSS selectors are separated by commas at the top level (commas inside
|
|
90
|
+
// parens — :is(), :not(), :where() — are not separators). Track paren
|
|
91
|
+
// depth as we split.
|
|
92
|
+
const out: string[] = [];
|
|
93
|
+
let depth = 0;
|
|
94
|
+
let cur = "";
|
|
95
|
+
for (const ch of selector) {
|
|
96
|
+
if (ch === "(") depth++;
|
|
97
|
+
else if (ch === ")") depth = Math.max(0, depth - 1);
|
|
98
|
+
if (ch === "," && depth === 0) {
|
|
99
|
+
out.push(cur);
|
|
100
|
+
cur = "";
|
|
101
|
+
} else {
|
|
102
|
+
cur += ch;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (cur.trim()) out.push(cur);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stripCommentsAndStrings(css: string): string {
|
|
110
|
+
let out = "";
|
|
111
|
+
let i = 0;
|
|
112
|
+
while (i < css.length) {
|
|
113
|
+
if (css[i] === "/" && css[i + 1] === "*") {
|
|
114
|
+
const end = css.indexOf("*/", i + 2);
|
|
115
|
+
i = end < 0 ? css.length : end + 2;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (css[i] === '"' || css[i] === "'") {
|
|
119
|
+
const quote = css[i];
|
|
120
|
+
let j = i + 1;
|
|
121
|
+
while (j < css.length && css[j] !== quote) {
|
|
122
|
+
if (css[j] === "\\") j += 2;
|
|
123
|
+
else j++;
|
|
124
|
+
}
|
|
125
|
+
out += quote + " ".repeat(Math.max(0, j - i - 1)) + quote;
|
|
126
|
+
i = j + 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
out += css[i];
|
|
130
|
+
i++;
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
package/src/lib/fonts.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Deck, TextElement } from "@/lib/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Best-effort web-font loader for typefaces referenced inside a Deck.
|
|
5
|
+
*
|
|
6
|
+
* PPTX files commonly reference typefaces that are NOT installed on the
|
|
7
|
+
* viewer's machine. The cleanest fix would be to extract the embedded font
|
|
8
|
+
* binaries from `ppt/fonts/*.fntdata`, but those use Microsoft's EOT format
|
|
9
|
+
* with MTX compression, which has no practical browser-side decoder.
|
|
10
|
+
*
|
|
11
|
+
* As a pragmatic alternative we ask Google Fonts for every unique typeface
|
|
12
|
+
* name we see — Google's CSS API silently returns 404 for unknown families,
|
|
13
|
+
* so the worst case is the browser's normal font fallback. Most popular
|
|
14
|
+
* typefaces (Coda, Quattrocento Sans, Roboto, Inter, Lato, Montserrat, …)
|
|
15
|
+
* round-trip cleanly this way.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// System / web-safe families we never try to fetch from Google Fonts.
|
|
19
|
+
const SYSTEM_FAMILIES = new Set(
|
|
20
|
+
[
|
|
21
|
+
"inter",
|
|
22
|
+
"system-ui",
|
|
23
|
+
"sans-serif",
|
|
24
|
+
"serif",
|
|
25
|
+
"monospace",
|
|
26
|
+
"arial",
|
|
27
|
+
"helvetica",
|
|
28
|
+
"helvetica neue",
|
|
29
|
+
"times",
|
|
30
|
+
"times new roman",
|
|
31
|
+
"georgia",
|
|
32
|
+
"courier",
|
|
33
|
+
"courier new",
|
|
34
|
+
"verdana",
|
|
35
|
+
"tahoma",
|
|
36
|
+
"trebuchet ms",
|
|
37
|
+
"geist",
|
|
38
|
+
"geist variable",
|
|
39
|
+
"geist mono",
|
|
40
|
+
"geist mono variable",
|
|
41
|
+
].map((s) => s.toLowerCase())
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/** Element IDs we manage in <head> — one per editor host. */
|
|
45
|
+
const STYLESHEET_ID_PREFIX = "slidewise-google-fonts-";
|
|
46
|
+
|
|
47
|
+
export function collectFontFamilies(deck: Deck): string[] {
|
|
48
|
+
const families = new Set<string>();
|
|
49
|
+
for (const slide of deck.slides) {
|
|
50
|
+
for (const el of slide.elements) {
|
|
51
|
+
if (el.type !== "text") continue;
|
|
52
|
+
const t = el as TextElement;
|
|
53
|
+
if (t.fontFamily) families.add(t.fontFamily);
|
|
54
|
+
if (t.runs) {
|
|
55
|
+
for (const r of t.runs) {
|
|
56
|
+
if (r.fontFamily) families.add(r.fontFamily);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...families];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildGoogleFontsHref(families: string[]): string | null {
|
|
65
|
+
const candidates = families
|
|
66
|
+
.map((f) => f.trim())
|
|
67
|
+
.filter((f) => f.length > 0)
|
|
68
|
+
.filter((f) => !SYSTEM_FAMILIES.has(f.toLowerCase()));
|
|
69
|
+
if (!candidates.length) return null;
|
|
70
|
+
// Google's css2 endpoint accepts `family=Name+With+Spaces` repeated.
|
|
71
|
+
const params = candidates
|
|
72
|
+
.map((f) => `family=${encodeURIComponent(f).replace(/%20/g, "+")}`)
|
|
73
|
+
.join("&");
|
|
74
|
+
return `https://fonts.googleapis.com/css2?${params}&display=swap`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Inject a <link rel="stylesheet"> for the given families. Idempotent per
|
|
79
|
+
* `instanceId` — calling again with a different family set replaces the
|
|
80
|
+
* previous link. Returns a disposer.
|
|
81
|
+
*/
|
|
82
|
+
export function ensureGoogleFontsLoaded(
|
|
83
|
+
instanceId: string,
|
|
84
|
+
families: string[]
|
|
85
|
+
): () => void {
|
|
86
|
+
if (typeof document === "undefined") return () => {};
|
|
87
|
+
const id = STYLESHEET_ID_PREFIX + instanceId;
|
|
88
|
+
const existing = document.getElementById(id) as HTMLLinkElement | null;
|
|
89
|
+
const href = buildGoogleFontsHref(families);
|
|
90
|
+
if (!href) {
|
|
91
|
+
if (existing) existing.remove();
|
|
92
|
+
return () => {};
|
|
93
|
+
}
|
|
94
|
+
if (existing && existing.href === href) {
|
|
95
|
+
return () => existing.remove();
|
|
96
|
+
}
|
|
97
|
+
const link = existing ?? document.createElement("link");
|
|
98
|
+
link.id = id;
|
|
99
|
+
link.rel = "stylesheet";
|
|
100
|
+
link.href = href;
|
|
101
|
+
link.crossOrigin = "anonymous";
|
|
102
|
+
if (!existing) document.head.appendChild(link);
|
|
103
|
+
return () => link.remove();
|
|
104
|
+
}
|