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:
l0ng-ai
2026-11-04 02:41:46 +08:00
parent fd1f465aeb
commit fa003016ef
8 changed files with 265 additions and 36 deletions
+17
View File
@@ -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
+91 -2
View File
@@ -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")) });
+31 -5
View File
@@ -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}
/>
);
}
+96 -7
View File
@@ -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,
+1 -20
View File
@@ -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.
+25
View File
@@ -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();
}