@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,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
@@ -0,0 +1,2 @@
1
+ @import "@fontsource-variable/geist/index.css";
2
+ @import "@fontsource-variable/geist-mono/index.css";
package/src/index.css ADDED
@@ -0,0 +1,13 @@
1
+ @import "tailwindcss";
2
+ @import "./SlidewiseEditor.css";
3
+
4
+ html,
5
+ body {
6
+ height: 100%;
7
+ margin: 0;
8
+ background: #ffffff;
9
+ }
10
+
11
+ #root {
12
+ height: 100%;
13
+ }
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
+ }
@@ -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
+ }