diff --git a/web/package-lock.json b/web/package-lock.json index 5ce5d55e2f..3c30a7932e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -37,6 +37,7 @@ "@lit/reactive-element": "^2.1.2", "@lit/task": "^1.0.3", "@mdx-js/mdx": "^3.1.1", + "@mermaid-js/layout-elk": "^0.2.1", "@mrmarble/djangoql-completion": "^0.8.3", "@open-wc/lit-helpers": "^0.7.0", "@openlayers-elements/core": "^0.4.0", @@ -2037,6 +2038,19 @@ "react": ">=16" } }, + "node_modules/@mermaid-js/layout-elk": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/layout-elk/-/layout-elk-0.2.1.tgz", + "integrity": "sha512-MX9jwhMyd5zDcFsYcl3duDUkKhjVRUCGEQrdCeNV5hCIR6+3FuDDbRbFmvVbAu15K1+juzsYGG+K8MDvCY1Amg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "elkjs": "^0.9.3" + }, + "peerDependencies": { + "mermaid": "^11.0.2" + } + }, "node_modules/@mermaid-js/parser": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", @@ -9387,6 +9401,12 @@ "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", "license": "ISC" }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", diff --git a/web/package.json b/web/package.json index 39563a776b..b873955a52 100644 --- a/web/package.json +++ b/web/package.json @@ -112,6 +112,7 @@ "@lit/reactive-element": "^2.1.2", "@lit/task": "^1.0.3", "@mdx-js/mdx": "^3.1.1", + "@mermaid-js/layout-elk": "^0.2.1", "@mrmarble/djangoql-completion": "^0.8.3", "@open-wc/lit-helpers": "^0.7.0", "@openlayers-elements/core": "^0.4.0", @@ -212,14 +213,10 @@ ], "wireit": { "build": { - "#comment": [ - "`npm run build` and `npm run watch` are the most common ", - "commands you should be using when working on the front end", - "The files and output spec here expect you to use `npm run build --watch` ", - "instead of `npm run watch`. The former is more comprehensive, but ", - "the latter is faster." - ], "command": "${NODE_RUNNER} scripts/build-web.mjs", + "dependencies": [ + "build-locales" + ], "files": [ "src/**/*.{css,jpg,png,ts,js,json}", "!src/**/*.stories.ts", @@ -239,8 +236,12 @@ "./dist/poly-*.js.map", "./dist/styles/**" ], - "dependencies": [ - "build-locales" + "#comment": [ + "`npm run build` and `npm run watch` are the most common ", + "commands you should be using when working on the front end", + "The files and output spec here expect you to use `npm run build --watch` ", + "instead of `npm run watch`. The former is more comprehensive, but ", + "the latter is faster." ], "env": { "NODE_RUNNER": { @@ -255,9 +256,6 @@ "build-locales" ] }, - "locales:repair": { - "command": "prettier --write ./src/locale-codes.ts" - }, "lint:components": { "command": "lit-analyzer src" }, @@ -270,6 +268,9 @@ "lit-analyse": { "command": "lit-analyzer src" }, + "locales:repair": { + "command": "prettier --write ./src/locale-codes.ts" + }, "precommit": { "command": "prettier --write .", "dependencies": [ diff --git a/web/src/admin/flows/FlowDiagram.ts b/web/src/admin/flows/FlowDiagram.ts index f2e93d7650..dcd08329a7 100644 --- a/web/src/admin/flows/FlowDiagram.ts +++ b/web/src/admin/flows/FlowDiagram.ts @@ -2,28 +2,29 @@ import "#elements/EmptyState"; import { aki } from "#common/api/client"; -import { Diagram } from "#elements/Diagram"; +import { Diagram } from "#elements/Diagram/ak-diagram"; import { FlowsApi } from "@goauthentik/api"; +import { observes } from "@patternfly/pfe-core/decorators/observes.js"; + import { customElement, property } from "lit/decorators.js"; @customElement("ak-flow-diagram") export class FlowDiagram extends Diagram { - @property() - flowSlug?: string; + @property({ type: String, useDefault: true }) + public flowSlug: string | null = null; - refreshHandler = (): void => { - this.diagram = undefined; + @observes("flowSlug") + protected refresh(): void { aki(FlowsApi) .flowsInstancesDiagramRetrieve({ slug: this.flowSlug || "", }) .then((data) => { this.diagram = data.diagram; - this.requestUpdate(); }); - }; + } } declare global { diff --git a/web/src/admin/sources/oauth/OAuthSourceDiagram.ts b/web/src/admin/sources/oauth/OAuthSourceDiagram.ts index a1151ffa61..ec5008999a 100644 --- a/web/src/admin/sources/oauth/OAuthSourceDiagram.ts +++ b/web/src/admin/sources/oauth/OAuthSourceDiagram.ts @@ -1,4 +1,4 @@ -import { Diagram } from "#elements/Diagram"; +import { Diagram } from "#elements/Diagram/ak-diagram"; import { UserMatchingModeToLabel } from "#admin/sources/oauth/utils"; @@ -9,22 +9,28 @@ import { customElement, property } from "lit/decorators.js"; @customElement("ak-source-oauth-diagram") export class OAuthSourceDiagram extends Diagram { - @property({ attribute: false }) - source?: OAuthSource; + @property({ attribute: false, useDefault: true }) + public source: OAuthSource | null = null; - refreshHandler = (): void => { + protected override syncDiagramContent = (): void => { if (!this.source) return; - const graph = ["graph LR"]; - graph.push(`source[${msg(str`OAuth Source ${this.source.name}`)}]`); - graph.push( - `source --> flow_manager["${UserMatchingModeToLabel(this.source.userMatchingMode || UserMatchingModeEnum.Identifier)}"]`, - ); + + const graph = [ + "graph LR", + `source[${msg(str`OAuth Source ${this.source.name}`)}]`, + `source --> flow_manager["${UserMatchingModeToLabel( + this.source.userMatchingMode || UserMatchingModeEnum.Identifier, + )}"]`, + ]; + if (this.source.enrollmentFlow) { graph.push("flow_manager --> flow_enroll[Enrollment flow]"); } + if (this.source.authenticationFlow) { graph.push("flow_manager --> flow_auth[Authentication flow]"); } + this.diagram = graph.join("\n"); }; } diff --git a/web/src/common/theme.ts b/web/src/common/theme.ts index 6bc5717d79..6966c50111 100644 --- a/web/src/common/theme.ts +++ b/web/src/common/theme.ts @@ -261,26 +261,29 @@ declare global { * @param hint The color scheme hint to use. * @param doc The document to apply the theme to. */ -export const applyDocumentTheme = ((currentUITheme = resolveUITheme(), doc = document): void => { +export const applyDocumentTheme = (( + currentUITheme = resolveUITheme(), + ownerDocument = document, +): void => { console.debug(`authentik/theme (document): want to switch to ${currentUITheme} theme`); - const { themeChoice } = doc.documentElement.dataset; + const { themeChoice } = ownerDocument.documentElement.dataset; if (themeChoice && themeChoice !== "auto") { console.debug( `authentik/theme (document): skipping theme application due to explicit choice (${themeChoice})`, ); - doc.dispatchEvent(new ThemeChangeEvent(themeChoice)); + ownerDocument.dispatchEvent(new ThemeChangeEvent(themeChoice)); return; } - doc.documentElement.dataset.theme = currentUITheme; + ownerDocument.documentElement.dataset.theme = currentUITheme; console.debug(`authentik/theme (document): switching to ${currentUITheme} theme`); - doc.dispatchEvent(new ThemeChangeEvent(currentUITheme)); + ownerDocument.dispatchEvent(new ThemeChangeEvent(currentUITheme)); }) satisfies UIThemeListener; /** diff --git a/web/src/elements/Diagram.ts b/web/src/elements/Diagram.ts deleted file mode 100644 index 12b4fa686e..0000000000 --- a/web/src/elements/Diagram.ts +++ /dev/null @@ -1,103 +0,0 @@ -import "#elements/EmptyState"; - -import { EVENT_REFRESH } from "#common/constants"; -import { DOM_PURIFY_STRICT } from "#common/purify"; -import { ThemeChangeEvent } from "#common/theme"; - -import { AKElement } from "#elements/Base"; - -import { UiThemeEnum } from "@goauthentik/api"; - -import mermaid, { MermaidConfig } from "mermaid"; - -import { css, CSSResult, html, TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { until } from "lit/directives/until.js"; - -@customElement("ak-diagram") -export class Diagram extends AKElement { - @property({ attribute: false }) - diagram?: string; - - refreshHandler = (): void => { - if (!this.textContent) return; - this.diagram = this.textContent; - }; - - handlerBound = false; - - static styles: CSSResult[] = [ - css` - :host { - display: flex; - justify-content: center; - } - `, - ]; - - config: MermaidConfig; - - constructor() { - super(); - this.config = { - // The type definition for this says number - // but the example use strings - // and numbers don't work - logLevel: "fatal", - startOnLoad: false, - flowchart: { - curve: "linear", - }, - htmlLabels: false, - securityLevel: "strict", - dompurifyConfig: DOM_PURIFY_STRICT, - }; - mermaid.initialize(this.config); - } - - firstUpdated(): void { - if (this.handlerBound) return; - window.addEventListener(EVENT_REFRESH, this.refreshHandler); - this.addEventListener(ThemeChangeEvent.eventName, ((ev: CustomEvent) => { - if (ev.detail === UiThemeEnum.Dark) { - this.config.theme = "dark"; - } else { - this.config.theme = "default"; - } - mermaid.initialize(this.config); - }) as EventListener); - this.handlerBound = true; - this.refreshHandler(); - } - - disconnectedCallback(): void { - super.disconnectedCallback(); - window.removeEventListener(EVENT_REFRESH, this.refreshHandler); - } - - render(): TemplateResult { - this.querySelectorAll("*").forEach((el) => { - try { - el.remove(); - } catch { - console.debug(`authentik/diagram: failed to remove element ${el}`); - } - }); - if (!this.diagram) { - return html``; - } - return html`${until( - mermaid.render("graph", this.diagram).then((r) => { - r.bindFunctions?.(this.shadowRoot as unknown as Element); - return unsafeHTML(r.svg); - }), - )}`; - } -} - -declare global { - interface HTMLElementTagNameMap { - "ak-diagram": Diagram; - } -} diff --git a/web/src/elements/Diagram/ak-diagram.css b/web/src/elements/Diagram/ak-diagram.css new file mode 100644 index 0000000000..f21bc9f9bb --- /dev/null +++ b/web/src/elements/Diagram/ak-diagram.css @@ -0,0 +1,4 @@ +:host { + display: flex; + justify-content: center; +} diff --git a/web/src/elements/Diagram/ak-diagram.ts b/web/src/elements/Diagram/ak-diagram.ts new file mode 100644 index 0000000000..a0ab870a79 --- /dev/null +++ b/web/src/elements/Diagram/ak-diagram.ts @@ -0,0 +1,88 @@ +import "#elements/EmptyState"; + +import { AKRefreshEvent } from "#common/events"; + +import { AKElement } from "#elements/Base"; +import { listen } from "#elements/decorators/listen"; +import Styles from "#elements/Diagram/ak-diagram.css"; +import { EmptyState } from "#elements/EmptyState"; +import MermaidStyles from "#elements/mermaid/mermaid.css"; +import { loadMermaid } from "#elements/mermaid/utils"; +import { SlottedTemplateResult } from "#elements/types"; + +import { CSSResult, PropertyValues } from "lit"; +import { guard } from "lit-html/directives/guard.js"; +import { customElement, property } from "lit/decorators.js"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { until } from "lit/directives/until.js"; + +@customElement("ak-diagram") +export class Diagram extends AKElement { + static styles: CSSResult[] = [MermaidStyles, Styles]; + + #diagram = ""; + @property({ attribute: false, useDefault: true }) + public get diagram(): string { + return this.#diagram || this.textContent.trim() || ""; + } + + public set diagram(value: string) { + const previous = this.#diagram; + this.#diagram = value.trim(); + + this.requestUpdate("diagram", previous); + } + + @listen(AKRefreshEvent, { + target: window, + }) + protected syncDiagramContent = (): void => { + if (!this.textContent) return; + this.diagram = this.textContent; + }; + + loadingPlaceholder: EmptyState; + + constructor() { + super(); + this.loadingPlaceholder = new EmptyState(); + this.loadingPlaceholder.loading = true; + } + + protected firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + this.syncDiagramContent(); + } + + protected renderMermaid(): Promise { + return loadMermaid(this.activeTheme).then((mermaid) => { + if (!this.diagram) { + return null; + } + + return mermaid.render(`mermaid-svg-${this.localName}`, this.diagram).then((result) => { + result.bindFunctions?.(this.renderRoot as HTMLElement); + + return unsafeHTML(result.svg); + }); + }); + } + + protected override render(): SlottedTemplateResult { + const { diagram, loadingPlaceholder, activeTheme } = this; + + return guard([diagram, activeTheme], () => { + if (!diagram) { + return loadingPlaceholder; + } + + return until(this.renderMermaid(), loadingPlaceholder); + }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-diagram": Diagram; + } +} diff --git a/web/src/elements/ak-mdx/ak-mdx.tsx b/web/src/elements/ak-mdx/ak-mdx.tsx index 94d100d833..cbc50c2dcc 100644 --- a/web/src/elements/ak-mdx/ak-mdx.tsx +++ b/web/src/elements/ak-mdx/ak-mdx.tsx @@ -11,12 +11,12 @@ import { remarkHeadings } from "#elements/ak-mdx/remark/remark-headings"; import { remarkLists } from "#elements/ak-mdx/remark/remark-lists"; import Styles from "#elements/ak-mdx/styles.css"; import { AKElement } from "#elements/Base"; +import MermaidStyles from "#elements/mermaid/mermaid.css"; +import { loadMermaid } from "#elements/mermaid/utils"; import { DistDirectoryName, StaticDirectoryName } from "#paths"; import OneDark from "#styles/atom/one-dark.css"; -import { UiThemeEnum } from "@goauthentik/api"; - import { compile as compileMDX, run as runMDX } from "@mdx-js/mdx"; import apacheGrammar from "highlight.js/lib/languages/apache"; import diffGrammar from "highlight.js/lib/languages/diff"; @@ -77,6 +77,7 @@ export class AKMDX extends AKElement { PFTable, PFContent, OneDark, + MermaidStyles, Styles, ]; @@ -113,6 +114,8 @@ export class AKMDX extends AKElement { mdxModule.content, ); + const { activeTheme } = this; + const mdx = await compileMDX(normalized, { outputFormat: "function-body", remarkPlugins: [ @@ -132,7 +135,8 @@ export class AKMDX extends AKElement { rehypeMermaid, { prefix: "mermaid-svg-", - colorScheme: this.activeTheme === UiThemeEnum.Dark ? "dark" : "light", + colorScheme: activeTheme, + mermaidConfig: await loadMermaid(activeTheme), } satisfies RehypeMermaidOptions, ], ], diff --git a/web/src/elements/ak-mdx/styles.css b/web/src/elements/ak-mdx/styles.css index 025074820d..7db3238078 100644 --- a/web/src/elements/ak-mdx/styles.css +++ b/web/src/elements/ak-mdx/styles.css @@ -59,21 +59,6 @@ pre:has(.hljs) { padding: var(--pf-global--spacer--md); } -svg[id^="mermaid-svg-"] { - .rect { - fill: var( - --ak-mermaid-box-background-color, - var(--pf-global--BackgroundColor--light-300) - ) !important; - } - - .messageText { - stroke-width: 4; - fill: var(--ak-mermaid-message-text) !important; - paint-order: stroke; - } -} - ak-alert + :is(h2, p) { padding-top: var(--pf-global--spacer--md); } @@ -81,19 +66,7 @@ ak-alert + :is(h2, p) { /* #region Dark Theme */ :host([theme="dark"]) { - --ak-mermaid-message-text: var(--ak-dark-foreground); - --ak-mermaid-box-background-color: var(--ak-dark-background-lighter); --ak-table-stripe-background: var(--pf-global--BackgroundColor--dark-200); - - svg[id^="mermaid-svg-"] { - line[class^="messageLine"] { - /* - Mermaid's support for dynamic palette changes leaves a lot to be desired. - This is a workaround to keep content readable while not breaking the rest of the theme. - */ - filter: invert(1) !important; - } - } } /* #endregion */ diff --git a/web/src/elements/mermaid/mermaid.css b/web/src/elements/mermaid/mermaid.css new file mode 100644 index 0000000000..4e66674c8a --- /dev/null +++ b/web/src/elements/mermaid/mermaid.css @@ -0,0 +1,59 @@ +/* svg[id^="mermaid-svg-"] { */ +/* &.flowchart { + .edgeLabel .label { + padding: 4px 10px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.55); + color: var(--ak-foreground, #fff); + } + } */ + +.flowchart { + foreignObject:has(.edgeLabel) { + display: flex; + align-items: center; + justify-content: center; + overflow: visible; + } + + .edgeLabel, + .edgeLabel .labelBkg { + background-color: transparent; + display: flex !important; + } + + .edgeLabel > span, + .labelBkg > span.edgeLabel { + margin-inline: auto; + max-width: max-content; + padding: 3px 12px; + border-radius: 6px; + white-space: nowrap; + } + + .edgeLabel { + background-color: var(--pf-global--palette--gold-200) !important; + border: 1px solid var(--pf-global--palette--gold-500) !important; + } +} + +svg[id^="mermaid-svg-"] { + & > .rect { + fill: color-mix(var(--pf-global--palette--gold-100), transparent 95%); + stroke: var(--pf-global--palette--gold-100); + stroke-width: 1; + } + + .messageText { + fill: var(--pf-global--Color--100) !important; + } + + .messageLine0 { + stroke: var(--pf-global--palette--purple-300) !important; + } + + [id$="-arrowhead"] path { + fill: var(--pf-global--palette--purple-300) !important; + stroke: var(--pf-global--palette--purple-300) !important; + } +} diff --git a/web/src/elements/mermaid/theme.ts b/web/src/elements/mermaid/theme.ts new file mode 100644 index 0000000000..e2db5ae298 --- /dev/null +++ b/web/src/elements/mermaid/theme.ts @@ -0,0 +1,164 @@ +import type { MermaidConfig } from "mermaid"; + +/** + * Resolves PatternFly CSS custom properties into concrete hex colors and maps + * them onto Mermaid's `themeVariables` keyset. + * + * @remarks + * + * Colors are parsed through a 1x1 canvas so that any valid CSS color form + * (named, rgb/rgba, hsl, or a `var()` chain) collapses to a hex string Mermaid + * can consume. Fully transparent values resolve to `"transparent"`. + * + * PatternFly 4 handles light/dark theming at the token level, so a single token + * set resolves correctly under either theme — no per-theme branching needed. + */ +export class MermaidThemeAdapter { + canvas = new OffscreenCanvas(1, 1); + ctx = this.canvas.getContext("2d"); + + constructor(protected computedStyle: CSSStyleDeclaration) {} + + /** + * Resolve a CSS custom property to a hex color string. + * + * @param cssProperty The CSS custom property name to read. + * @param fallback Color used when the property is unset or empty. + * + * @returns A hex color code string, or `"transparent"` for fully transparent values. + */ + public readHexColorVariable = (cssProperty: string, fallback = "#ff0000"): string => { + if (!this.ctx) { + throw new Error("Could not create canvas context for color parsing"); + } + + this.ctx.clearRect(0, 0, 1, 1); + this.ctx.fillStyle = this.computedStyle.getPropertyValue(cssProperty).trim() || fallback; + this.ctx.fillRect(0, 0, 1, 1); + + const [r, g, b, a] = this.ctx.getImageData(0, 0, 1, 1).data; + + if (a === 0) { + return "transparent"; + } + + // eslint-disable-next-line no-bitwise + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; + }; + + /** + * Read a surface color, substituting an opaque fallback when the token + * resolves to `transparent`. Node fills must never be see-through. + */ + protected readSurface = (cssProperty: string, fallback: string): string => { + const value = this.readHexColorVariable(cssProperty, fallback); + + return value === "transparent" ? fallback : value; + }; + + /** + * Map PatternFly tokens onto Mermaid's `themeVariables`. + * + * @remarks + * + * Requires `theme: "base"` in the Mermaid config — other built-in themes + * ignore most of these overrides. + */ + public toThemeVariables(darkMode?: boolean): MermaidConfig["themeVariables"] { + const { readHexColorVariable: read, readSurface } = this; + + const surface = readSurface("--pf-global--palette--purple-50", "#ffffff"); + const surfaceAlt = readSurface("--pf-global--palette--blue-50", surface); + const surfaceDark = readSurface("--pf-global--palette--black-200", surfaceAlt); + + const textBase = read("--pf-global--palette--purple-700"); + const textSecondary = read( + darkMode ? "--pf-global--palette--gold-100" : "--pf-global--palette--gold-400", + ); + const border = read( + darkMode ? "--pf-global--palette--purple-300" : "--pf-global--palette--purple-700", + ); + + const primaryBorder = read("--pf-global--palette--purple-400"); + const primaryAccent = read("--pf-global--palette--purple-100"); + const primaryAccentText = read("--pf-global--palette--purple-700"); + + const success = read("--pf-global--success-color--100"); + const danger = read("--pf-global--danger-color--100"); + const warning = read("--pf-global--warning-color--100"); + const info = read("--pf-global--info-color--100"); + + return { + // Base / canvas + background: surface, + mainBkg: surface, + fontFamily: "var(--ak-font-family-sans-serif)", + + // Primary node + primaryColor: surface, + primaryBorderColor: primaryBorder, + primaryTextColor: textBase, + + // Secondary node + secondaryColor: surfaceAlt, + secondaryBorderColor: border, + secondaryTextColor: textBase, + + // Tertiary node + tertiaryColor: surfaceDark, + tertiaryBorderColor: border, + tertiaryTextColor: textBase, + + // Edges / lines / labels + lineColor: textSecondary, + edgeLabelBackground: surface, + titleColor: textBase, + + // Generic node fallbacks + nodeBorder: border, + nodeTextColor: textBase, + + // Clusters / subgraphs + clusterBkg: surfaceDark, + clusterBorder: border, + + // Notes + noteBkgColor: warning, + noteTextColor: textBase, + noteBorderColor: border, + + // Brand accents (classDef / linkStyle) + primaryColorAccent: primaryAccent, + primaryTextColorAccent: primaryAccentText, + + // Status (state / git / quadrant diagrams) + successColor: success, + errorColor: danger, + warningColor: warning, + infoColor: info, + + // Sequence / state actors + actorBkg: surface, + actorBorder: border, + actorTextColor: textBase, + labelBoxBkgColor: surface, + labelTextColor: textBase, + }; + } + + /** + * Semantic accent colors for emitting `linkStyle` / `classDef` directives + * into diagram source (e.g. coloring policy pass/fail edges). + */ + public toAccents() { + const { readHexColorVariable: read } = this; + + return { + success: read("--pf-global--success-color--100"), + danger: read("--pf-global--danger-color--100"), + warning: read("--pf-global--warning-color--100"), + info: read("--pf-global--info-color--100"), + primary: read("--pf-global--palette--purple-100"), + }; + } +} diff --git a/web/src/elements/mermaid/utils.ts b/web/src/elements/mermaid/utils.ts new file mode 100644 index 0000000000..a6192cb826 --- /dev/null +++ b/web/src/elements/mermaid/utils.ts @@ -0,0 +1,76 @@ +import MermaidStyles from "./mermaid.css"; + +import { DOM_PURIFY_STRICT } from "#common/purify"; +import { ResolvedUITheme } from "#common/theme"; + +import { MermaidThemeAdapter } from "#elements/mermaid/theme"; + +import elkLayouts from "@mermaid-js/layout-elk"; +import type { Mermaid, MermaidConfig } from "mermaid"; + +export const DefaultMermaidConfig: Readonly = { + logLevel: "fatal", + startOnLoad: false, + htmlLabels: true, + fontFamily: "var(--ak-font-family-sans-serif)", + layout: "elk", + flowchart: { + curve: "linear", + + nodeSpacing: 25, + rankSpacing: 25, + wrappingWidth: 500, + }, + theme: "base", + securityLevel: "strict", + dompurifyConfig: DOM_PURIFY_STRICT, +}; + +let lastActiveTheme: ResolvedUITheme | null = null; +let mermaid: Mermaid | null = null; + +/** + * Load the Mermaid library and initialize it with the appropriate theme based + * on the provided UI theme. + * + * @remarks + * + * Mermaid is only loaded once and cached for subsequent calls. Note that + * Mermaid is a singleton and does not support multiple instances with different + * configurations. Re-initialization occurs only when the active theme changes. + * + * @param uiTheme The resolved UI theme to derive Mermaid colors from. + * @returns The initialized Mermaid singleton. + */ +export async function loadMermaid(uiTheme: ResolvedUITheme): Promise { + if (!mermaid) { + const mermaidModule = await import("mermaid"); + mermaid = mermaidModule.default; + mermaid.registerLayoutLoaders(elkLayouts); + } + + if (uiTheme && uiTheme === lastActiveTheme) { + return mermaid; + } + + await new Promise((resolve) => requestAnimationFrame(resolve)); + + const computedStyle = getComputedStyle(document.documentElement); + const darkMode = uiTheme === "dark"; + + const themeAdapter = new MermaidThemeAdapter(computedStyle); + const themeVariables = themeAdapter.toThemeVariables(darkMode); + + mermaid.initialize({ + ...DefaultMermaidConfig, + themeVariables: { + ...themeVariables, + }, + darkMode, + themeCSS: String(MermaidStyles), + }); + + lastActiveTheme = uiTheme; + + return mermaid; +}