mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
feat(plugins): theme variables for html view frames, kept current without reload
- Inject `data-theme` and stable `--openidea-*` colour and font variables (concrete values; the frame has no IDE stylesheet) into html view bodies. - Post theme changes to the loaded frame instead of rebuilding srcdoc, so a theme switch keeps the frame's state; re-send on load to close the race. - Expose `openidea.theme` / `openidea.onThemeChange` to frame scripts. - Move `subscribeTheme` / `themeKey` from the Markdown mermaid module to `src/platform/themeWatch.ts`, shared by both. - Document the variables in plugins/README.md.
This commit is contained in:
@@ -384,6 +384,23 @@ inline scripts and styles and `data:` images, no network, no IDE API — and
|
||||
talks to the plugin only through `openidea.postMessage(text)` /
|
||||
`openidea.onMessage(fn)` (`ViewEvent::Message`, `host::post_view_message`).
|
||||
|
||||
The frame follows the IDE theme without reloading: its root carries
|
||||
`data-theme="light|dark"` and these CSS variables, with concrete colour
|
||||
values (the body already uses the text colour, UI font and size):
|
||||
|
||||
| Variable | Meaning |
|
||||
| ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| `--openidea-bg`, `--openidea-bg-elevated`, `--openidea-bg-hover`, `--openidea-bg-selected`, `--openidea-bg-input` | Surfaces (the frame itself is transparent) |
|
||||
| `--openidea-fg`, `--openidea-fg-strong`, `--openidea-fg-muted` | Text |
|
||||
| `--openidea-border`, `--openidea-focus-ring`, `--openidea-link` | Lines, focus, links |
|
||||
| `--openidea-accent`, `--openidea-fg-on-accent` | Accent and text on it |
|
||||
| `--openidea-danger`, `--openidea-warning`, `--openidea-success`, `--openidea-info` | Status colours |
|
||||
| `--openidea-font-ui`, `--openidea-font-mono`, `--openidea-font-size` | Fonts (web fonts the IDE bundles are not available in the frame) |
|
||||
|
||||
Scripts get the same as `openidea.theme` (`{ dark, vars }`) and
|
||||
`openidea.onThemeChange(fn)`, for drawing on a canvas, say. Variables are only
|
||||
ever added.
|
||||
|
||||
### Settings
|
||||
|
||||
```toml
|
||||
|
||||
@@ -9,7 +9,7 @@ import { defineBuiltinUiPoints } from "../points";
|
||||
import { clearUiContributions, syncUiContributions } from "../projection";
|
||||
import { pluginMenuItems, withPluginSetting } from "../uiPoints";
|
||||
import { matchesPattern } from "../views/customEditor";
|
||||
import { FRAME_CSP, frameDocument } from "../views/frame";
|
||||
import { FRAME_CSP, FRAME_TAG, frameDocument, frameTheme, type FrameTheme } from "../views/frame";
|
||||
import { PluginView, treeNodes, wantedViewport } from "../views/PluginView";
|
||||
import { bumpView } from "../views/viewStore";
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("view helpers", () => {
|
||||
});
|
||||
|
||||
it("confines HTML bodies: CSP without network, a message bridge", () => {
|
||||
const doc = frameDocument("<p>hi</p>", true);
|
||||
const doc = frameDocument("<p>hi</p>", { dark: true, vars: {} });
|
||||
expect(doc).toContain(`content="${FRAME_CSP}"`);
|
||||
expect(FRAME_CSP).toContain("default-src 'none'");
|
||||
expect(FRAME_CSP).not.toMatch(/https?:|connect-src/);
|
||||
@@ -64,6 +64,71 @@ describe("view helpers", () => {
|
||||
expect(doc.indexOf("Content-Security-Policy")).toBeLessThan(doc.indexOf("<p>hi</p>"));
|
||||
});
|
||||
|
||||
it("reads the frame theme from the IDE tokens, resolved and sanitised", () => {
|
||||
const root = document.documentElement;
|
||||
const before = root.dataset.theme;
|
||||
root.style.setProperty("--fg", "#112233");
|
||||
root.style.setProperty("--font-ui", "Inter, sans-serif");
|
||||
root.style.setProperty("--font-mono", "x}</style><script>alert(1)</script>");
|
||||
try {
|
||||
root.dataset.theme = "light";
|
||||
const light = frameTheme();
|
||||
expect(light.dark).toBe(false);
|
||||
expect(light.vars["--openidea-fg"]).toBe("rgb(17, 34, 51)");
|
||||
expect(light.vars["--openidea-font-ui"]).toBe("Inter, sans-serif");
|
||||
expect(light.vars).not.toHaveProperty("--openidea-font-mono");
|
||||
root.dataset.theme = "dark";
|
||||
root.dataset.contrast = "high";
|
||||
expect(frameTheme().dark).toBe(true);
|
||||
} finally {
|
||||
for (const p of ["--fg", "--font-ui", "--font-mono"]) root.style.removeProperty(p);
|
||||
delete root.dataset.contrast;
|
||||
if (before === undefined) delete root.dataset.theme;
|
||||
else root.dataset.theme = before;
|
||||
}
|
||||
});
|
||||
|
||||
it("puts the theme into the frame document without letting a value end the script", () => {
|
||||
const doc = frameDocument("<p>hi</p>", { dark: true, vars: { "--openidea-fg": "</script><b>" } });
|
||||
expect(doc).toContain('<html data-theme="dark">');
|
||||
expect(doc).not.toContain("</script><b>");
|
||||
expect(doc).toContain("\\u003c/script>");
|
||||
});
|
||||
|
||||
it("applies posted themes inside the frame, replacing the previous variables", () => {
|
||||
const doc = frameDocument("", {
|
||||
dark: false,
|
||||
vars: { "--openidea-fg": "rgb(1, 2, 3)", "--openidea-accent": "red" },
|
||||
});
|
||||
const script = /<script>([\s\S]*)<\/script>/.exec(doc)![1]!;
|
||||
const root = document.documentElement;
|
||||
const before = root.dataset.theme;
|
||||
const seen: unknown[] = [];
|
||||
try {
|
||||
new Function(script)();
|
||||
const api = (
|
||||
window as unknown as { openidea: { theme: FrameTheme; onThemeChange(fn: (t: FrameTheme) => void): void } }
|
||||
).openidea;
|
||||
expect(root.style.getPropertyValue("--openidea-fg")).toBe("rgb(1, 2, 3)");
|
||||
expect(api.theme.dark).toBe(false);
|
||||
api.onThemeChange((t) => seen.push(t));
|
||||
// jsdom's postMessage has no `source`, so dispatch the event as the parent would.
|
||||
const send = (t: unknown) =>
|
||||
window.dispatchEvent(new MessageEvent("message", { data: { tag: FRAME_TAG, theme: t }, source: window }));
|
||||
send({ dark: true, vars: { "--openidea-fg": "rgb(9, 9, 9)", "--not-ours": "x" } });
|
||||
expect(root.dataset.theme).toBe("dark");
|
||||
expect(root.style.getPropertyValue("--openidea-fg")).toBe("rgb(9, 9, 9)");
|
||||
expect(root.style.getPropertyValue("--openidea-accent")).toBe("");
|
||||
expect(root.style.getPropertyValue("--not-ours")).toBe("");
|
||||
expect(seen).toHaveLength(1);
|
||||
} finally {
|
||||
for (const p of ["--openidea-fg", "--openidea-accent"]) root.style.removeProperty(p);
|
||||
delete (window as unknown as { openidea?: unknown }).openidea;
|
||||
if (before === undefined) delete root.dataset.theme;
|
||||
else root.dataset.theme = before;
|
||||
}
|
||||
});
|
||||
|
||||
it("matches custom editor file name patterns", () => {
|
||||
expect(matchesPattern("*.csv", "People.CSV")).toBe(true);
|
||||
expect(matchesPattern("*.{csv,tsv}", "a.tsv")).toBe(true);
|
||||
@@ -185,6 +250,30 @@ describe("PluginView", () => {
|
||||
expect(await screen.findByText("Paris")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps an HTML body loaded across theme switches and posts the new theme to it", async () => {
|
||||
const html: View = { toolbar: [], body: { kind: "html", value: "<p>chart</p>" }, status: null };
|
||||
setPluginsBackend(backendWith(() => html, []));
|
||||
const root = document.documentElement;
|
||||
const before = root.dataset.theme;
|
||||
root.dataset.theme = "light";
|
||||
try {
|
||||
const { container } = render(<PluginView pluginId="charts" view="chart" />);
|
||||
await waitFor(() => expect(container.querySelector("iframe")).not.toBeNull());
|
||||
const frame = container.querySelector("iframe")!;
|
||||
const srcDoc = frame.getAttribute("srcdoc");
|
||||
expect(srcDoc).toContain('<html data-theme="light">');
|
||||
const post = vi.spyOn(frame.contentWindow!, "postMessage");
|
||||
root.dataset.theme = "dark";
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledWith({ tag: FRAME_TAG, theme: expect.objectContaining({ dark: true }) }, "*"),
|
||||
);
|
||||
expect(frame.getAttribute("srcdoc")).toBe(srcDoc);
|
||||
} finally {
|
||||
if (before === undefined) delete root.dataset.theme;
|
||||
else root.dataset.theme = before;
|
||||
}
|
||||
});
|
||||
|
||||
it("shows a message when the plugin fails", async () => {
|
||||
const base = createMemoryPluginsBackend([]);
|
||||
setPluginsBackend({ ...base, renderView: () => Promise.reject(new Error("boom")) });
|
||||
|
||||
@@ -6,7 +6,15 @@
|
||||
* (`viewChanged`), from `revision` (a custom editor's document changed), or
|
||||
* from the table scrolling to rows it hasn't got.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from "react";
|
||||
import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import {
|
||||
@@ -22,7 +30,8 @@ import {
|
||||
type MenuItem,
|
||||
} from "@/ui";
|
||||
import { pluginsBackend } from "../backend";
|
||||
import { FRAME_TAG, frameDocument } from "./frame";
|
||||
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
|
||||
import { FRAME_TAG, frameDocument, frameTheme, frameThemeMessage } from "./frame";
|
||||
import { onViewMessage, useViewRevision } from "./viewStore";
|
||||
import styles from "./PluginView.module.css";
|
||||
|
||||
@@ -456,8 +465,15 @@ function HtmlBody({
|
||||
view: string;
|
||||
}) {
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
const dark = typeof document !== "undefined" && document.documentElement.dataset.theme === "dark";
|
||||
const srcDoc = useMemo(() => frameDocument(html, dark), [html, dark]);
|
||||
// The theme at load time is in the document; later changes are posted, so a
|
||||
// theme switch doesn't reload the frame and lose its state.
|
||||
const srcDoc = useMemo(() => frameDocument(html, frameTheme()), [html]);
|
||||
const theme = useSyncExternalStore(subscribeTheme, themeKey, () => "");
|
||||
const postTheme = useCallback(
|
||||
() => frame.current?.contentWindow?.postMessage(frameThemeMessage(frameTheme()), "*"),
|
||||
[],
|
||||
);
|
||||
useEffect(postTheme, [theme, postTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
@@ -476,5 +492,15 @@ function HtmlBody({
|
||||
}, [pluginId, view, send]);
|
||||
|
||||
// No `allow-same-origin`: an opaque origin without access to the IDE.
|
||||
return <iframe ref={frame} className={styles.frame} title={view} sandbox="allow-scripts" srcDoc={srcDoc} />;
|
||||
// `onLoad` re-sends the theme in case it changed between building `srcDoc` and the frame loading.
|
||||
return (
|
||||
<iframe
|
||||
ref={frame}
|
||||
className={styles.frame}
|
||||
title={view}
|
||||
sandbox="allow-scripts"
|
||||
srcDoc={srcDoc}
|
||||
onLoad={postTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
* access) under a CSP that allows only its own inline scripts and styles and
|
||||
* `data:` images — no network. `openidea.postMessage` / `openidea.onMessage`
|
||||
* are its only way to the plugin.
|
||||
*
|
||||
* The IDE theme reaches the frame as `data-theme="light|dark"` on its root and
|
||||
* the `--openidea-*` variables of {@link FRAME_THEME_TOKENS}, kept current
|
||||
* without reloading the frame; `openidea.theme` / `openidea.onThemeChange` give
|
||||
* the same to scripts (canvas drawing, say).
|
||||
*/
|
||||
import { resolveTokenColor, toRgb } from "@/platform/cssColor";
|
||||
|
||||
export const FRAME_CSP =
|
||||
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:";
|
||||
@@ -12,27 +18,110 @@ export const FRAME_CSP =
|
||||
/** Marks frame ↔ workbench messages. */
|
||||
export const FRAME_TAG = "openidea-view";
|
||||
|
||||
const BRIDGE = `
|
||||
/**
|
||||
* The theme variables a frame gets: public name → IDE token. Part of the
|
||||
* plugin API: names are only ever added, so the IDE's own tokens can change.
|
||||
*/
|
||||
export const FRAME_THEME_TOKENS = {
|
||||
"--openidea-bg": "--bg-island",
|
||||
"--openidea-bg-elevated": "--bg-elev",
|
||||
"--openidea-bg-hover": "--bg-hover",
|
||||
"--openidea-bg-selected": "--bg-tree-selected",
|
||||
"--openidea-bg-input": "--bg-input",
|
||||
"--openidea-fg": "--fg",
|
||||
"--openidea-fg-strong": "--fg-strong",
|
||||
"--openidea-fg-muted": "--fg-muted",
|
||||
"--openidea-border": "--border",
|
||||
"--openidea-accent": "--accent",
|
||||
"--openidea-fg-on-accent": "--fg-on-accent",
|
||||
"--openidea-link": "--link",
|
||||
"--openidea-focus-ring": "--focus-ring",
|
||||
"--openidea-danger": "--danger",
|
||||
"--openidea-warning": "--warning",
|
||||
"--openidea-success": "--success",
|
||||
"--openidea-info": "--info",
|
||||
} as const;
|
||||
|
||||
/** Non-colour variables: public name → IDE token. */
|
||||
const FRAME_FONT_TOKENS = {
|
||||
"--openidea-font-ui": "--font-ui",
|
||||
"--openidea-font-mono": "--font-mono",
|
||||
"--openidea-font-size": "--fs-ui",
|
||||
} as const;
|
||||
|
||||
export interface FrameTheme {
|
||||
dark: boolean;
|
||||
/** `--openidea-*` → a concrete value (colours resolved, no `var()`: the frame has no IDE stylesheet). */
|
||||
vars: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Font stacks and sizes only; anything that could leave its declaration is dropped. */
|
||||
const safeValue = (v: string) => v.length <= 400 && !/[<>{};\\]/.test(v);
|
||||
|
||||
/** The IDE theme as a frame sees it, read from the root element's tokens. */
|
||||
export function frameTheme(root: HTMLElement = document.documentElement): FrameTheme {
|
||||
const vars: Record<string, string> = {};
|
||||
for (const [name, token] of Object.entries(FRAME_THEME_TOKENS)) {
|
||||
const c = resolveTokenColor(token, root);
|
||||
if (c) vars[name] = toRgb(c);
|
||||
}
|
||||
if (typeof getComputedStyle !== "undefined") {
|
||||
const css = getComputedStyle(root);
|
||||
for (const [name, token] of Object.entries(FRAME_FONT_TOKENS)) {
|
||||
const v = css.getPropertyValue(token).trim();
|
||||
if (v && safeValue(v)) vars[name] = v;
|
||||
}
|
||||
}
|
||||
return { dark: root.dataset.theme !== "light", vars };
|
||||
}
|
||||
|
||||
/** The message that updates a loaded frame's theme. */
|
||||
export const frameThemeMessage = (theme: FrameTheme) => ({ tag: FRAME_TAG, theme });
|
||||
|
||||
const bridge = (initial: FrameTheme) => `
|
||||
(() => {
|
||||
const listeners = [];
|
||||
const themeListeners = [];
|
||||
const root = document.documentElement;
|
||||
let theme = null;
|
||||
let applied = [];
|
||||
const applyTheme = (t) => {
|
||||
if (!t || typeof t !== "object" || !t.vars || typeof t.vars !== "object") return;
|
||||
const vars = {};
|
||||
for (const [k, v] of Object.entries(t.vars)) if (k.startsWith("--openidea-") && typeof v === "string") vars[k] = v;
|
||||
for (const k of applied) if (!(k in vars)) root.style.removeProperty(k);
|
||||
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
|
||||
applied = Object.keys(vars);
|
||||
root.dataset.theme = t.dark ? "dark" : "light";
|
||||
theme = Object.freeze({ dark: !!t.dark, vars: Object.freeze(vars) });
|
||||
for (const l of themeListeners) { try { l(theme); } catch (err) { console.error(err); } }
|
||||
};
|
||||
applyTheme(${JSON.stringify(initial).replace(/</g, "\\u003c")});
|
||||
window.addEventListener("message", (e) => {
|
||||
if (e.source !== window.parent || !e.data || e.data.tag !== "${FRAME_TAG}") return;
|
||||
if (e.data.theme) { applyTheme(e.data.theme); return; }
|
||||
for (const l of listeners) { try { l(String(e.data.text)); } catch (err) { console.error(err); } }
|
||||
});
|
||||
window.openidea = Object.freeze({
|
||||
postMessage: (text) => window.parent.postMessage({ tag: "${FRAME_TAG}", text: String(text) }, "*"),
|
||||
onMessage: (fn) => { if (typeof fn === "function") listeners.push(fn); },
|
||||
get theme() { return theme; },
|
||||
onThemeChange: (fn) => { if (typeof fn === "function") themeListeners.push(fn); },
|
||||
});
|
||||
})();`;
|
||||
|
||||
/** `html` wrapped with the CSP, the message bridge and base styles. */
|
||||
export function frameDocument(html: string, dark: boolean): string {
|
||||
const scheme = dark ? "dark" : "light";
|
||||
const BASE_STYLE =
|
||||
"html[data-theme=light]{color-scheme:light}html[data-theme=dark]{color-scheme:dark}" +
|
||||
"body{margin:8px;color:var(--openidea-fg);font-family:var(--openidea-font-ui,system-ui,sans-serif);" +
|
||||
"font-size:var(--openidea-font-size,13px)}a{color:var(--openidea-link)}";
|
||||
|
||||
/** `html` wrapped with the CSP, the message bridge, the theme and base styles. */
|
||||
export function frameDocument(html: string, theme: FrameTheme): string {
|
||||
return [
|
||||
'<!doctype html><html><head><meta charset="utf-8">',
|
||||
`<!doctype html><html data-theme="${theme.dark ? "dark" : "light"}"><head><meta charset="utf-8">`,
|
||||
`<meta http-equiv="Content-Security-Policy" content="${FRAME_CSP}">`,
|
||||
`<style>:root{color-scheme:${scheme}}body{margin:8px;font:13px system-ui,sans-serif}</style>`,
|
||||
`<script>${BRIDGE}</script>`,
|
||||
`<style>${BASE_STYLE}</style>`,
|
||||
`<script>${bridge(theme)}</script>`,
|
||||
"</head><body>",
|
||||
html,
|
||||
"</body></html>",
|
||||
|
||||
@@ -25,7 +25,8 @@ import { setShownLine } from "@/editor/api";
|
||||
import { highlightInto } from "@/editor/lsp/markdown";
|
||||
import { FloatingToolbar, selectableText } from "@/ui";
|
||||
import { publishPreviewTop, useLiveMarkdown } from "./live";
|
||||
import { hasMermaid, MERMAID_SELECTOR, renderMermaidIn, subscribeTheme, themeKey } from "./mermaid";
|
||||
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
|
||||
import { hasMermaid, MERMAID_SELECTOR, renderMermaidIn } from "./mermaid";
|
||||
import { modeOf, useMarkdownModes } from "./modes";
|
||||
import { MarkdownLayoutActions } from "./MarkdownLayoutActions";
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderMarkdown } from "./render";
|
||||
import { hasMermaid, renderMermaidIn, resetMermaidForTests, subscribeTheme, themeKey } from "./mermaid";
|
||||
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
|
||||
import { hasMermaid, renderMermaidIn, resetMermaidForTests } from "./mermaid";
|
||||
|
||||
const m = vi.hoisted(() => ({
|
||||
loads: 0,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { fontsLoaded } from "@/platform/fonts";
|
||||
import type { Mermaid } from "mermaid";
|
||||
import { resolveTokenColor, toRgb } from "@/platform/cssColor";
|
||||
import { themeKey } from "@/platform/themeWatch";
|
||||
import { attachPanZoom } from "../panZoom";
|
||||
|
||||
export const MERMAID_SELECTOR = "[data-mermaid]";
|
||||
@@ -29,26 +30,6 @@ function loadMermaid(): Promise<Mermaid> {
|
||||
return loading;
|
||||
}
|
||||
|
||||
/** Theme identity: anything that changes the colours read below. */
|
||||
export function themeKey(): string {
|
||||
const root = document.documentElement;
|
||||
const { theme = "", contrast = "", colorTheme = "", pluginTheme = "" } = root.dataset;
|
||||
const accent = root.style.getPropertyValue("--accent");
|
||||
const font = getComputedStyle(root).getPropertyValue("--font-ui");
|
||||
return `${theme}|${contrast}|${colorTheme}|${pluginTheme}|${accent}|${font}`;
|
||||
}
|
||||
|
||||
/** Calls `cb` when the theme attributes (a plugin theme's included) or the accent on the root change. */
|
||||
export function subscribeTheme(cb: () => void): () => void {
|
||||
if (typeof MutationObserver === "undefined") return () => {};
|
||||
const observer = new MutationObserver(cb);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme", "data-contrast", "data-color-theme", "data-plugin-theme", "style"],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagram colours from the IDE tokens. Mermaid parses only hex and `rgb()`, so
|
||||
* tokens a theme writes as `hsl()`, `color-mix()` or `var()` are resolved to RGB.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Watching the active IDE theme: built-in theme, palette theme, plugin theme
|
||||
* and accent all live as attributes / inline style on the root element.
|
||||
*/
|
||||
|
||||
/** Theme identity: changes whenever the colours the theme tokens resolve to may change. */
|
||||
export function themeKey(): string {
|
||||
const root = document.documentElement;
|
||||
const { theme = "", contrast = "", colorTheme = "", pluginTheme = "" } = root.dataset;
|
||||
const accent = root.style.getPropertyValue("--accent");
|
||||
// The UI font (the custom one, or its fallback once loaded) is part of how a theme renders.
|
||||
const font = getComputedStyle(root).getPropertyValue("--font-ui");
|
||||
return `${theme}|${contrast}|${colorTheme}|${pluginTheme}|${accent}|${font}`;
|
||||
}
|
||||
|
||||
/** Calls `cb` when the theme attributes (a plugin theme's included) or the accent on the root change. */
|
||||
export function subscribeTheme(cb: () => void): () => void {
|
||||
if (typeof MutationObserver === "undefined") return () => {};
|
||||
const observer = new MutationObserver(cb);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme", "data-contrast", "data-color-theme", "data-plugin-theme", "style"],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
Reference in New Issue
Block a user