@openpresentation/opf-editor 0.26.9 → 0.27.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/README.md +58 -4
- package/dist/canvas-variables.d.ts +15 -0
- package/dist/canvas-variables.js +52 -0
- package/dist/canvas.js +33 -5
- package/dist/index.d.ts +37 -1
- package/dist/index.js +50 -3
- package/dist/presenter.js +63 -26
- package/dist/react.d.ts +34 -0
- package/dist/react.js +120 -0
- package/dist/slide-retention.js +13 -0
- package/dist/styles.css +330 -5
- package/dist/workspace-layouts.d.ts +31 -0
- package/dist/workspace-layouts.js +84 -0
- package/dist/workspace-measure.js +35 -0
- package/dist/workspace-shell.js +96 -0
- package/dist/workspace-source.js +83 -0
- package/dist/workspace-theme.js +142 -0
- package/dist/workspace.d.ts +388 -0
- package/dist/workspace.js +1261 -0
- package/package.json +26 -8
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// CE-06 (promoted from examples/playground.js): text measurement and font notes shared by the workspace parts.
|
|
2
|
+
import { createScriptTextMeasurement } from "@openpresentation/opf-render/fonts";
|
|
3
|
+
import { resolveScriptFonts } from "@openpresentation/opf/composition";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The layout options for composing slide `index` of `deck`: the fonts handle's measurement, made script-aware for this document (Japanese
|
|
7
|
+
* under Aptos measures with Noto Sans JP, where the plain strict measurement would throw "cannot display U+65E5" with every face loaded).
|
|
8
|
+
* A fonts handle without a measurement is passed through.
|
|
9
|
+
*/
|
|
10
|
+
export function layoutOptionsFor(fonts, deck, index, catalogs) {
|
|
11
|
+
if (!fonts?.textMeasurement) return fonts ? { fonts } : {};
|
|
12
|
+
try {
|
|
13
|
+
return { fonts: { textMeasurement: createScriptTextMeasurement(fonts.textMeasurement, resolveScriptFonts(deck, { slideIndex: index, catalogs })) } };
|
|
14
|
+
} catch {
|
|
15
|
+
return { fonts };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Review text for one font resolution the registry recorded. A change inside one family is a style fallback, not a replacement font. */
|
|
20
|
+
export function describeFontChange(change) {
|
|
21
|
+
const sameFamily = (change.sourceFamily ?? change.requestedFamily).toLowerCase() === change.resolvedFamily.toLowerCase();
|
|
22
|
+
if (!sameFamily) return `${change.requestedFamily} → ${change.resolvedFamily} · ${change.compatibility === "metric" ? "Metric substitute" : "Approximate substitute; wrapping may change"} (${change.resolvedWeight}).`;
|
|
23
|
+
const drawn = `${change.resolvedFamily} ${change.resolvedWeight}${change.italic ? " italic" : ""}`;
|
|
24
|
+
return change.requestedWeight === change.resolvedWeight
|
|
25
|
+
? `${change.requestedFamily}: the ${change.italic ? "italic" : "upright"} face asked for is not available, so ${drawn} is drawn; wrapping may change.`
|
|
26
|
+
: `${change.requestedFamily} ${change.requestedWeight}: that weight is not available, so ${drawn} is drawn; wrapping may change.`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Notes (`{ path, message }`) for the font substitutions a fonts handle recorded, one per requested face. */
|
|
30
|
+
export function fontSubstitutionNotes(fonts) {
|
|
31
|
+
const changes = fonts?.substitutions ?? [];
|
|
32
|
+
return changes
|
|
33
|
+
.filter((change, index, all) => all.findIndex((other) => other.requestedFamily === change.requestedFamily && other.requestedWeight === change.requestedWeight && other.italic === change.italic) === index)
|
|
34
|
+
.map((change) => ({ path: change.path ?? "design.fontScheme", message: describeFontChange(change) }));
|
|
35
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// CE-06: the default arrangement of the workspace (`layout: "full"`, `"canvas"` or `"source"`): a bar, the source pane, the slide, the slide
|
|
2
|
+
// row, and a side panel for Review and Design. It only builds DOM and hands the elements to the workspace as named slots; a host that
|
|
3
|
+
// arranges the parts itself passes its own elements as `slots` and none of this runs. Styled by src/styles/workspace.css (tokens only).
|
|
4
|
+
|
|
5
|
+
const make = (doc, tag, attributes = {}, ...children) => {
|
|
6
|
+
const node = doc.createElement(tag);
|
|
7
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
8
|
+
if (value === undefined || value === false) continue;
|
|
9
|
+
if (name === "text") node.textContent = value;
|
|
10
|
+
else node.setAttribute(name, value === true ? "" : value);
|
|
11
|
+
}
|
|
12
|
+
node.append(...children);
|
|
13
|
+
return node;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const VIEW_LABELS = { slide: "Slide", sorter: "Sorter", outline: "Outline", layouts: "Layouts" };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the shell inside `root`. `parts` names what is mounted (`source`, `canvas`, `navigator`, `thumbnails`, `present`, `export`, `find`,
|
|
20
|
+
* `review`, `design`, `layouts`, `sorter`, `outline`, `persistence`, `history`); `views` lists the views in switch order. Returns the slots
|
|
21
|
+
* (one element per mounted part) and the bar's controls.
|
|
22
|
+
*/
|
|
23
|
+
export function buildWorkspaceShell(root, { parts, views, layout, labels = {} }) {
|
|
24
|
+
const doc = root.ownerDocument;
|
|
25
|
+
const text = { slideView: "Slide view", undo: "Undo", redo: "Redo", find: "Find", review: "Review", design: "Design", inspector: "Inspector", ...labels };
|
|
26
|
+
const slots = {};
|
|
27
|
+
const controls = {};
|
|
28
|
+
const slot = (name, tag = "div", attributes = {}) => (slots[name] = make(doc, tag, { "data-opf-slot": name, ...attributes }));
|
|
29
|
+
|
|
30
|
+
// The bar.
|
|
31
|
+
const bar = make(doc, "header", { class: "opf-ws-bar" });
|
|
32
|
+
if (parts.history) {
|
|
33
|
+
controls.undo = make(doc, "button", { type: "button", class: "opf-ws-button", "data-opf-action": "undo", "aria-label": text.undo, title: `${text.undo} · Ctrl/Cmd Z`, text: text.undo });
|
|
34
|
+
controls.redo = make(doc, "button", { type: "button", class: "opf-ws-button", "data-opf-action": "redo", "aria-label": text.redo, title: `${text.redo} · Ctrl/Cmd Shift Z`, text: text.redo });
|
|
35
|
+
bar.append(make(doc, "div", { class: "opf-ws-group" }, controls.undo, controls.redo));
|
|
36
|
+
}
|
|
37
|
+
if (parts.find) {
|
|
38
|
+
controls.find = make(doc, "button", { type: "button", class: "opf-ws-button", "data-opf-action": "find", "aria-expanded": "false", "aria-keyshortcuts": "Control+F Meta+F Control+H", title: `${text.find} · Ctrl/Cmd F`, text: text.find });
|
|
39
|
+
bar.append(controls.find);
|
|
40
|
+
}
|
|
41
|
+
if (views.length > 1) {
|
|
42
|
+
const group = make(doc, "div", { class: "opf-ws-views", role: "group", "aria-label": text.slideView });
|
|
43
|
+
controls.views = {};
|
|
44
|
+
for (const view of views) group.append(controls.views[view] = make(doc, "button", { type: "button", class: "opf-ws-button", "data-opf-view-button": view, "aria-pressed": "false", text: VIEW_LABELS[view] ?? view }));
|
|
45
|
+
bar.append(group);
|
|
46
|
+
}
|
|
47
|
+
bar.append(make(doc, "span", { class: "opf-ws-spacer" }));
|
|
48
|
+
if (parts.persistence) bar.append(slot("autosave", "span", { class: "opf-ws-autosave" }));
|
|
49
|
+
if (parts.export) bar.append(slot("export", "div", { class: "opf-ws-export" }));
|
|
50
|
+
if (parts.present) bar.append(slot("present", "div", { class: "opf-ws-present" }));
|
|
51
|
+
const hasBar = bar.children.length > 1;
|
|
52
|
+
|
|
53
|
+
// The main column: the slide (or another view) over the slide row.
|
|
54
|
+
const stage = make(doc, "div", { class: "opf-ws-stage" });
|
|
55
|
+
const frame = make(doc, "div", { class: "opf-ws-frame" }, slot("canvas", "div", { class: "opf-ws-canvas", "aria-label": "Slide preview" }));
|
|
56
|
+
stage.append(frame);
|
|
57
|
+
for (const view of ["sorter", "outline", "layouts"]) if (parts[view]) stage.append(slot(view, "div", { class: `opf-ws-view opf-ws-${view}`, hidden: true }));
|
|
58
|
+
const main = make(doc, "section", { class: "opf-ws-main", "aria-label": "Slide workspace" });
|
|
59
|
+
if (parts.find) main.append(slot("find", "div", { class: "opf-ws-find", hidden: true }));
|
|
60
|
+
main.append(stage);
|
|
61
|
+
if (parts.navigator || parts.thumbnails) {
|
|
62
|
+
const dock = make(doc, "div", { class: "opf-ws-dock" });
|
|
63
|
+
if (parts.navigator) dock.append(slot("navigator", "div", { class: "opf-ws-navigator" }));
|
|
64
|
+
if (parts.thumbnails) dock.append(slot("thumbnails", "div", { class: "opf-ws-thumbnails" }));
|
|
65
|
+
main.append(dock);
|
|
66
|
+
}
|
|
67
|
+
main.append(slot("status", "p", { class: "opf-ws-status", role: "status" }));
|
|
68
|
+
|
|
69
|
+
const body = make(doc, "div", { class: "opf-ws-body" });
|
|
70
|
+
if (parts.source) body.append(make(doc, "section", { class: "opf-ws-source", "aria-label": "OPF source" }, slot("source", "div", { class: "opf-ws-source-host" })));
|
|
71
|
+
body.append(main);
|
|
72
|
+
|
|
73
|
+
// The side panel: Review and Design as tabs.
|
|
74
|
+
const tabs = ["design", "review"].filter((name) => parts[name]);
|
|
75
|
+
if (tabs.length) {
|
|
76
|
+
controls.tabs = {};
|
|
77
|
+
const list = make(doc, "div", { class: "opf-ws-tabs", role: "tablist", "aria-label": text.inspector });
|
|
78
|
+
const side = make(doc, "aside", { class: "opf-ws-side", "aria-label": text.inspector }, list);
|
|
79
|
+
for (const name of tabs) {
|
|
80
|
+
list.append(controls.tabs[name] = make(doc, "button", { type: "button", role: "tab", class: "opf-ws-tab", id: `opf-ws-tab-${name}`, "aria-selected": "false", "aria-controls": `opf-ws-panel-${name}`, "data-opf-tab": name, text: text[name] }));
|
|
81
|
+
side.append(make(doc, "div", { class: "opf-ws-panel", role: "tabpanel", id: `opf-ws-panel-${name}`, "aria-labelledby": `opf-ws-tab-${name}`, hidden: true }, slot(name, "div", { class: `opf-ws-${name}` })));
|
|
82
|
+
}
|
|
83
|
+
controls.side = side;
|
|
84
|
+
body.append(side);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (parts.persistence) root.append();
|
|
88
|
+
if (hasBar) root.append(bar);
|
|
89
|
+
if (parts.persistence) root.append(slot("banner", "div", { class: "opf-ws-banner", hidden: true }));
|
|
90
|
+
root.append(body);
|
|
91
|
+
root.setAttribute("data-opf-layout", layout);
|
|
92
|
+
controls.frame = frame;
|
|
93
|
+
controls.stage = stage;
|
|
94
|
+
controls.bar = bar;
|
|
95
|
+
return { slots, controls };
|
|
96
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// CE-06 (promoted from openpresentation.org's playground runtime): the source pane's text, read and judged the way a host wants it.
|
|
2
|
+
// `parseSourceText` reads the text of the pane in its format into a document or a message; `reportSource` adds core's verdict. The pane
|
|
3
|
+
// calls these (through `getFindings`) for the text it shows, so the footer of a host and the findings under the code say the same thing.
|
|
4
|
+
import { validate } from "@openpresentation/opf";
|
|
5
|
+
import { fromYaml } from "@openpresentation/opf/yaml";
|
|
6
|
+
import { fromMarkdown } from "@openpresentation/opf/markdown";
|
|
7
|
+
import { findDuplicateKey } from "./exact-source.js";
|
|
8
|
+
|
|
9
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
10
|
+
|
|
11
|
+
/** The checks a source verdict runs by default: the format rules and the catalog reference rules (what the session itself applies to an edit). */
|
|
12
|
+
export const SOURCE_CHECKS = Object.freeze({ only: Object.freeze(["format", "references"]) });
|
|
13
|
+
|
|
14
|
+
/** Line endings as the code editor holds them: LF. */
|
|
15
|
+
export const lf = (text) => text.replace(/\r\n?/g, "\n");
|
|
16
|
+
|
|
17
|
+
/** Read `text` in `format` (`json`, `yaml`, `md`): `{ document, message: "" }`, or `{ message }` when it does not read as a document. */
|
|
18
|
+
export function parseSourceText(format, text, { catalogs } = {}) {
|
|
19
|
+
if (format === "json") {
|
|
20
|
+
try {
|
|
21
|
+
const document = JSON.parse(text);
|
|
22
|
+
if (!isObject(document)) return { message: "An OPF document is a JSON object." };
|
|
23
|
+
// JSON.parse keeps the last of a repeated key and hides the rest; the pane refuses such a document, so this does too.
|
|
24
|
+
const duplicate = findDuplicateKey(text);
|
|
25
|
+
if (duplicate) return { message: `Duplicate key "${duplicate.key}". Remove the repeated key.` };
|
|
26
|
+
return { document, message: "" };
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return { message: error instanceof Error ? error.message : "Not valid JSON" };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const result = format === "yaml" ? fromYaml(text, { catalogs }) : fromMarkdown(text, { catalogs });
|
|
32
|
+
const syntax = result.findings.filter((finding) => !finding.ruleId?.startsWith("opf/"));
|
|
33
|
+
if (syntax.length) return { message: syntax[0].message };
|
|
34
|
+
return { document: result.presentation, message: "" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The verdict on the text of the pane: `{ status, findings, valid }`. `status` is `{ kind: "valid", warnings }`,
|
|
39
|
+
* `{ kind: "invalid", errors, warnings }` or `{ kind: "syntax", format, message }` (the text does not read as a document).
|
|
40
|
+
*/
|
|
41
|
+
export function reportSource(format, text, { catalogs, checks = SOURCE_CHECKS } = {}) {
|
|
42
|
+
const parsed = parseSourceText(format, text, { catalogs });
|
|
43
|
+
if (parsed.document === undefined) return { status: { kind: "syntax", format, message: parsed.message }, findings: [], valid: false };
|
|
44
|
+
const result = validate(parsed.document, { ...checks, ...(catalogs !== undefined ? { catalogs } : {}) });
|
|
45
|
+
const errors = result.findings.filter((finding) => finding.severity === "error").length;
|
|
46
|
+
const warnings = result.findings.filter((finding) => finding.severity === "warning").length;
|
|
47
|
+
return {
|
|
48
|
+
status: result.valid ? { kind: "valid", warnings } : { kind: "invalid", errors, warnings },
|
|
49
|
+
findings: result.findings,
|
|
50
|
+
valid: result.valid,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Copy text to the clipboard; falls back to a hidden textarea and `execCommand` where the Clipboard API is refused. */
|
|
55
|
+
export async function copyText(text) {
|
|
56
|
+
try {
|
|
57
|
+
await navigator.clipboard.writeText(text);
|
|
58
|
+
return;
|
|
59
|
+
} catch {
|
|
60
|
+
const textarea = document.createElement("textarea");
|
|
61
|
+
textarea.value = text;
|
|
62
|
+
textarea.setAttribute("readonly", "");
|
|
63
|
+
textarea.style.position = "fixed";
|
|
64
|
+
textarea.style.left = "-9999px";
|
|
65
|
+
document.body.appendChild(textarea);
|
|
66
|
+
textarea.select();
|
|
67
|
+
const copied = document.execCommand("copy");
|
|
68
|
+
textarea.remove();
|
|
69
|
+
if (!copied) throw new Error("The browser refused the copy.");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Download a source file the pane produced (`{ filename, text, mime }`) as a Blob link. */
|
|
74
|
+
export function downloadSourceFile(file, text = file.text) {
|
|
75
|
+
const url = URL.createObjectURL(new Blob([text], { type: `${file.mime};charset=utf-8` }));
|
|
76
|
+
const link = document.createElement("a");
|
|
77
|
+
link.href = url;
|
|
78
|
+
link.download = file.filename;
|
|
79
|
+
document.body.append(link);
|
|
80
|
+
link.click();
|
|
81
|
+
link.remove();
|
|
82
|
+
globalThis.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
83
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// CE-06: theming of the editor chrome. The chrome reads `--opf-ui-*` CSS custom properties (src/styles/tokens.css). Every component root
|
|
2
|
+
// carries the class `opf-ui` and declares the defaults itself, so a value set on an ancestor never reaches it: a host overrides the
|
|
3
|
+
// tokens on `.opf-ui`. `createThemeController` does that for one editor: it writes a rule, scoped to the editor's root, with enough
|
|
4
|
+
// specificity to win over the package's light, dark and touch rules whichever stylesheet loads last. The rule is built with the CSSOM
|
|
5
|
+
// (`insertRule`, `setProperty`), never from text, so a value cannot close the rule or add another, and an invalid value is dropped.
|
|
6
|
+
|
|
7
|
+
/** The short names of a theme: each maps to one `--opf-ui-*` custom property. The full names are accepted too. */
|
|
8
|
+
export const THEME_TOKENS = Object.freeze({
|
|
9
|
+
background: "--opf-ui-bg",
|
|
10
|
+
surface: "--opf-ui-surface",
|
|
11
|
+
muted: "--opf-ui-muted",
|
|
12
|
+
mutedAlt: "--opf-ui-muted-2",
|
|
13
|
+
text: "--opf-ui-fg",
|
|
14
|
+
textMuted: "--opf-ui-muted-fg",
|
|
15
|
+
accent: "--opf-ui-accent",
|
|
16
|
+
accentText: "--opf-ui-accent-fg",
|
|
17
|
+
accentStrong: "--opf-ui-accent-strong",
|
|
18
|
+
border: "--opf-ui-border",
|
|
19
|
+
borderStrong: "--opf-ui-border-strong",
|
|
20
|
+
focus: "--opf-ui-ring",
|
|
21
|
+
danger: "--opf-ui-danger",
|
|
22
|
+
warning: "--opf-ui-warn",
|
|
23
|
+
success: "--opf-ui-success",
|
|
24
|
+
codeBackground: "--opf-ui-code-bg",
|
|
25
|
+
codeBorder: "--opf-ui-code-border",
|
|
26
|
+
font: "--opf-ui-font",
|
|
27
|
+
mono: "--opf-ui-mono",
|
|
28
|
+
radiusSmall: "--opf-ui-radius-sm",
|
|
29
|
+
radius: "--opf-ui-radius",
|
|
30
|
+
radiusMedium: "--opf-ui-radius-md",
|
|
31
|
+
radiusLarge: "--opf-ui-radius-lg",
|
|
32
|
+
controlHeight: "--opf-ui-control-h",
|
|
33
|
+
shadow: "--opf-ui-shadow-pop",
|
|
34
|
+
motion: "--opf-ui-ease",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const FULL_NAMES = new Set(Object.values(THEME_TOKENS));
|
|
38
|
+
const SCHEMES = new Set(["light", "dark", "auto", "inherit"]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A map of short or full token names to values, as `{ "--opf-ui-name": "value" }`. A name that is neither a short name nor a
|
|
42
|
+
* `--opf-ui-*` token of the contract throws, so a typo does not silently do nothing.
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeThemeTokens(map, label = "theme.tokens") {
|
|
45
|
+
if (map === undefined || map === null) return {};
|
|
46
|
+
if (typeof map !== "object" || Array.isArray(map)) throw new TypeError(`${label} must be an object of token names and values.`);
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [name, value] of Object.entries(map)) {
|
|
49
|
+
const full = THEME_TOKENS[name] ?? (FULL_NAMES.has(name) ? name : undefined);
|
|
50
|
+
if (!full) throw new RangeError(`${label}: "${name}" is not an editor token. Use one of ${Object.keys(THEME_TOKENS).join(", ")} or a full --opf-ui-* name.`);
|
|
51
|
+
if (value === undefined || value === null) continue;
|
|
52
|
+
out[full] = String(value);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The theme as the controller applies it: validated, with defaults. Throws on a bad scheme or token name. */
|
|
58
|
+
export function normalizeTheme(theme) {
|
|
59
|
+
if (theme === undefined || theme === null) return { colorScheme: "inherit", tokens: {}, dark: {} };
|
|
60
|
+
if (typeof theme !== "object" || Array.isArray(theme)) throw new TypeError("theme must be an object.");
|
|
61
|
+
const colorScheme = theme.colorScheme ?? "inherit";
|
|
62
|
+
if (!SCHEMES.has(colorScheme)) throw new RangeError('theme.colorScheme must be "light", "dark", "auto" or "inherit".');
|
|
63
|
+
return { colorScheme, tokens: normalizeThemeTokens(theme.tokens, "theme.tokens"), dark: normalizeThemeTokens(theme.dark, "theme.dark") };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let themeCounter = 0;
|
|
67
|
+
|
|
68
|
+
const apply = (rule, tokens) => { for (const [name, value] of Object.entries(tokens)) rule.style.setProperty(name, value); };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Apply a theme to the editor mounted in `root` (the element the host gave `createOpfEditor`; the chrome inside it, and `root` itself, take the values). `update(theme)` replaces it; `destroy()` removes the
|
|
72
|
+
* rule and the attributes. The scheme: `light` and `dark` force it for this editor, `auto` follows `prefers-color-scheme`, and `inherit` (the
|
|
73
|
+
* default) leaves it to the host page, which marks dark with `.dark` or `[data-theme="dark"]` on the page, as the stylesheet expects.
|
|
74
|
+
*/
|
|
75
|
+
export function createThemeController(root, initial) {
|
|
76
|
+
const doc = root.ownerDocument;
|
|
77
|
+
const win = doc.defaultView;
|
|
78
|
+
const id = `opf-theme-${++themeCounter}`;
|
|
79
|
+
const before = { theme: root.getAttribute("data-opf-theme"), scheme: root.getAttribute("data-theme") };
|
|
80
|
+
const style = doc.createElement("style");
|
|
81
|
+
style.setAttribute("data-opf-theme-style", "");
|
|
82
|
+
root.append(style);
|
|
83
|
+
let media;
|
|
84
|
+
let onMedia;
|
|
85
|
+
const detach = () => { if (media && onMedia) media.removeEventListener?.("change", onMedia); media = undefined; onMedia = undefined; };
|
|
86
|
+
const clearRules = () => { const sheet = style.sheet; while (sheet && sheet.cssRules.length) sheet.deleteRule(0); };
|
|
87
|
+
|
|
88
|
+
function write(theme) {
|
|
89
|
+
clearRules();
|
|
90
|
+
const sheet = style.sheet;
|
|
91
|
+
if (!sheet) return;
|
|
92
|
+
// Three copies of the attribute selector lift the rule over the package's own `.dark .opf-ui`, `[data-theme="dark"] .opf-ui`, forced-light and
|
|
93
|
+
// touch rules, whichever stylesheet loads last. The element itself is in the rule, so the host's own chrome inside it inherits the tokens.
|
|
94
|
+
const scope = `[data-opf-theme="${id}"][data-opf-theme="${id}"][data-opf-theme="${id}"]`;
|
|
95
|
+
const notLight = `${scope}:not([data-theme="light"])`;
|
|
96
|
+
if (Object.keys(theme.tokens).length) {
|
|
97
|
+
sheet.insertRule(`${scope}, ${scope} .opf-ui {}`, sheet.cssRules.length);
|
|
98
|
+
apply(sheet.cssRules[sheet.cssRules.length - 1], theme.tokens);
|
|
99
|
+
}
|
|
100
|
+
if (Object.keys(theme.dark).length) {
|
|
101
|
+
// The same dark scopes tokens.css uses, so a host that marks dark on the page, on an ancestor or on the editor itself is followed.
|
|
102
|
+
const selectors = [];
|
|
103
|
+
for (const target of [notLight, `${notLight} .opf-ui`]) selectors.push(`.dark ${target}`, `[data-theme="dark"] ${target}`);
|
|
104
|
+
selectors.push(`${scope}[data-theme="dark"]`, `${scope}[data-theme="dark"] .opf-ui`, `${scope}.dark`, `${scope}.dark .opf-ui`);
|
|
105
|
+
sheet.insertRule(`${selectors.join(", ")} {}`, sheet.cssRules.length);
|
|
106
|
+
apply(sheet.cssRules[sheet.cssRules.length - 1], theme.dark);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const restoreScheme = () => {
|
|
111
|
+
if (before.scheme === null) root.removeAttribute("data-theme");
|
|
112
|
+
else root.setAttribute("data-theme", before.scheme);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function update(next) {
|
|
116
|
+
const theme = normalizeTheme(next);
|
|
117
|
+
detach();
|
|
118
|
+
root.setAttribute("data-opf-theme", id);
|
|
119
|
+
if (theme.colorScheme === "light" || theme.colorScheme === "dark") root.setAttribute("data-theme", theme.colorScheme);
|
|
120
|
+
else if (theme.colorScheme === "auto" && typeof win?.matchMedia === "function") {
|
|
121
|
+
media = win.matchMedia("(prefers-color-scheme: dark)");
|
|
122
|
+
onMedia = () => root.setAttribute("data-theme", media.matches ? "dark" : "light");
|
|
123
|
+
onMedia();
|
|
124
|
+
media.addEventListener?.("change", onMedia);
|
|
125
|
+
} else restoreScheme();
|
|
126
|
+
write(theme);
|
|
127
|
+
return theme;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let current = update(initial);
|
|
131
|
+
return {
|
|
132
|
+
get theme() { return current; },
|
|
133
|
+
update(next) { current = update(next); return current; },
|
|
134
|
+
destroy() {
|
|
135
|
+
detach();
|
|
136
|
+
style.remove();
|
|
137
|
+
restoreScheme();
|
|
138
|
+
if (before.theme === null) root.removeAttribute("data-opf-theme");
|
|
139
|
+
else root.setAttribute("data-opf-theme", before.theme);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|