mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
web/elements: extract mermaid runtime, modernize <ak-diagram> (#22980)
* web: Clean up diagram behavior. * Add accessor. * Fix import. * Fix theme colors, consistent patternfly colors. * Fix spelling.
This commit is contained in:
Generated
+20
@@ -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",
|
||||
|
||||
+13
-12
@@ -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": [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<UiThemeEnum>) => {
|
||||
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`<ak-empty-state loading></ak-empty-state>`;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -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<this>): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
this.syncDiagramContent();
|
||||
}
|
||||
|
||||
protected renderMermaid(): Promise<SlottedTemplateResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<MermaidConfig> = {
|
||||
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<Mermaid> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user