diff --git a/web_src/js/markup/codecopy.ts b/web_src/js/markup/codecopy.ts index a6e07a3cf6..870ba7728c 100644 --- a/web_src/js/markup/codecopy.ts +++ b/web_src/js/markup/codecopy.ts @@ -1,10 +1,13 @@ import {svg} from '../svg.ts'; import {queryElems} from '../utils/dom.ts'; -export function makeCodeCopyButton(): HTMLButtonElement { +export function makeCodeCopyButton(attrs: Record = {}): HTMLButtonElement { const button = document.createElement('button'); button.classList.add('code-copy', 'ui', 'button'); button.innerHTML = svg('octicon-copy'); + for (const [key, value] of Object.entries(attrs)) { + button.setAttribute(key, value); + } return button; } diff --git a/web_src/js/markup/mermaid.test.ts b/web_src/js/markup/mermaid.test.ts index 19c3963658..a7467858f5 100644 --- a/web_src/js/markup/mermaid.test.ts +++ b/web_src/js/markup/mermaid.test.ts @@ -1,37 +1,37 @@ -import {sourcesContainElk} from './mermaid.ts'; +import {sourceNeedsElk} from './mermaid.ts'; import {dedent} from '../utils/testhelper.ts'; -test('sourcesContainElk', () => { - expect(sourcesContainElk([dedent(` +test('MermaidConfigLayoutCheck', () => { + expect(sourceNeedsElk(dedent(` flowchart TB elk --> B - `)])).toEqual(false); + `))).toEqual(false); - expect(sourcesContainElk([dedent(` + expect(sourceNeedsElk(dedent(` --- config: layout : elk --- flowchart TB A --> B - `)])).toEqual(true); + `))).toEqual(true); - expect(sourcesContainElk([dedent(` + expect(sourceNeedsElk(dedent(` --- config: layout: elk.layered --- flowchart TB A --> B - `)])).toEqual(true); + `))).toEqual(true); - expect(sourcesContainElk([` + expect(sourceNeedsElk(` %%{ init : { "flowchart": { "defaultRenderer": "elk" } } }%% flowchart TB A --> B - `])).toEqual(true); + `)).toEqual(true); - expect(sourcesContainElk([` + expect(sourceNeedsElk(dedent(` --- config: layout: 123 @@ -39,21 +39,21 @@ test('sourcesContainElk', () => { %%{ init : { "class": { "defaultRenderer": "elk.any" } } }%% flowchart TB A --> B - `])).toEqual(true); + `))).toEqual(true); - expect(sourcesContainElk([` + expect(sourceNeedsElk(` %%{init:{ "layout" : "elk.layered" }}%% flowchart TB A --> B - `])).toEqual(true); + `)).toEqual(true); - expect(sourcesContainElk([` + expect(sourceNeedsElk(` %%{ initialize: { 'layout' : 'elk.layered' }}%% flowchart TB A --> B - `])).toEqual(true); + `)).toEqual(true); }); diff --git a/web_src/js/markup/mermaid.ts b/web_src/js/markup/mermaid.ts index 0314a6177c..5d37c81b8f 100644 --- a/web_src/js/markup/mermaid.ts +++ b/web_src/js/markup/mermaid.ts @@ -1,7 +1,7 @@ -import {isDarkTheme} from '../utils.ts'; +import {isDarkTheme, parseDom} from '../utils.ts'; import {makeCodeCopyButton} from './codecopy.ts'; import {displayError} from './common.ts'; -import {queryElems} from '../utils/dom.ts'; +import {createElementFromAttrs, queryElems} from '../utils/dom.ts'; import {html, htmlRaw} from '../utils/html.ts'; import {load as loadYaml} from 'js-yaml'; import type {MermaidConfig} from 'mermaid'; @@ -58,29 +58,18 @@ function configContainsElk(config: MermaidConfig | null) { // * config.{any-diagram-config}.defaultRenderer // Although only a few diagram types like "flowchart" support "defaultRenderer", // as long as there is no side effect, here do a general check for all properties of "config", for ease of maintenance - return configValueIsElk(config.layout) || Object.values(config).some((value) => configValueIsElk(value?.defaultRenderer)); + return configValueIsElk(config.layout) || Object.values(config).some((diagCfg) => configValueIsElk(diagCfg?.defaultRenderer)); } -/** detect whether mermaid sources contain elk layout configuration */ -export function sourcesContainElk(sources: Array) { - for (const source of sources) { - if (isSourceTooLarge(source)) continue; - - const yamlConfig = parseYamlInitConfig(source); - if (configContainsElk(yamlConfig)) return true; - - const jsonConfig = parseJsonInitConfig(source); - if (configContainsElk(jsonConfig)) return true; - } - - return false; +export function sourceNeedsElk(source: string) { + if (isSourceTooLarge(source)) return false; + const configYaml = parseYamlInitConfig(source), configJson = parseJsonInitConfig(source); + return configContainsElk(configYaml) || configContainsElk(configJson); } -async function loadMermaid(sources: Array) { +async function loadMermaid(needElkRender: boolean) { const mermaidPromise = import(/* webpackChunkName: "mermaid" */'mermaid'); - const elkPromise = sourcesContainElk(sources) ? - import(/* webpackChunkName: "mermaid-layout-elk" */'@mermaid-js/layout-elk') : null; - + const elkPromise = needElkRender ? import(/* webpackChunkName: "mermaid-layout-elk" */'@mermaid-js/layout-elk') : null; const results = await Promise.all([mermaidPromise, elkPromise]); return { mermaid: results[0].default, @@ -92,86 +81,74 @@ let elkLayoutsRegistered = false; export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise { // .markup code.language-mermaid - const els = Array.from(queryElems(elMarkup, 'code.language-mermaid')); - if (!els.length) return; - const sources = Array.from(els, (el) => el.textContent ?? ''); - const {mermaid, elkLayouts} = await loadMermaid(sources); + const mermaidBlocks: Array<{source: string, parentContainer: HTMLElement}> = []; + const attrMermaidRendered = 'data-markup-mermaid-rendered'; + let needElkRender = false; + for (const elCodeBlock of queryElems(elMarkup, 'code.language-mermaid')) { + const parentContainer = elCodeBlock.closest('pre')!; // it must exist, if no, there must be a bug + if (parentContainer.hasAttribute(attrMermaidRendered)) continue; + parentContainer.setAttribute(attrMermaidRendered, 'true'); + const source = elCodeBlock.textContent ?? ''; + needElkRender = needElkRender || sourceNeedsElk(source); + mermaidBlocks.push({source, parentContainer}); + } + if (!mermaidBlocks.length) return; + + const {mermaid, elkLayouts} = await loadMermaid(needElkRender); if (elkLayouts && !elkLayoutsRegistered) { mermaid.registerLayoutLoaders(elkLayouts); elkLayoutsRegistered = true; } mermaid.initialize({ startOnLoad: false, - theme: isDarkTheme() ? 'dark' : 'neutral', + theme: isDarkTheme() ? 'dark' : 'neutral', // TODO: maybe it should use "darkMode" to adopt more user-specified theme instead of just "dark" or "neutral" securityLevel: 'strict', suppressErrorRendering: true, }); - await Promise.all(els.map(async (el, index) => { - const source = sources[index]; - const pre = el.closest('pre'); - - if (!pre || pre.hasAttribute('data-render-done')) { - return; - } - + // mermaid is a globally shared instance, its document also says "Multiple calls to this function will be enqueued to run serially." + // so here we just simply render the mermaid blocks one by one, no need to do "Promise.all" concurrently + for (const block of mermaidBlocks) { + const {source, parentContainer} = block; if (isSourceTooLarge(source)) { - displayError(pre, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`)); - return; + displayError(parentContainer, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`)); + continue; } try { - await mermaid.parse(source); - } catch (err) { - displayError(pre, err); - return; - } - - try { - // can't use bindFunctions here because we can't cross the iframe boundary. This - // means js-based interactions won't work but they aren't intended to work either - const {svg} = await mermaid.render('mermaid', source); + // render the mermaid diagram to svg text, and parse it to a DOM node + const {svg: svgText, bindFunctions} = await mermaid.render('mermaid', source, parentContainer); + const svgDoc = parseDom(svgText, 'image/svg+xml'); + const svgNode = (svgDoc.documentElement as unknown) as SVGSVGElement; + // create an iframe to sandbox the svg with styles, and set correct height by reading svg's viewBox height const iframe = document.createElement('iframe'); - iframe.classList.add('markup-content-iframe', 'tw-invisible'); - iframe.srcdoc = html`${htmlRaw(svg)}`; + iframe.classList.add('markup-content-iframe', 'is-loading'); + iframe.srcdoc = html``; - const mermaidBlock = document.createElement('div'); - mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden'); - mermaidBlock.append(iframe); - - const btn = makeCodeCopyButton(); - btn.setAttribute('data-clipboard-text', source); - mermaidBlock.append(btn); - - const updateIframeHeight = () => { - const body = iframe.contentWindow?.document?.body; - if (body) { - iframe.style.height = `${body.clientHeight}px`; - } - }; + // although the "viewBox" is optional, mermaid's output should always have a correct viewBox with width and height + const iframeHeightFromViewBox = Math.ceil(svgNode.viewBox?.baseVal?.height ?? 0); + if (iframeHeightFromViewBox) iframe.style.height = `${iframeHeightFromViewBox}px`; + // the iframe will be fully reloaded if its DOM context is changed (e.g.: moved in the DOM tree). + // to avoid unnecessary reloading, we should insert the iframe to its final position only once. iframe.addEventListener('load', () => { - pre.replaceWith(mermaidBlock); - mermaidBlock.classList.remove('tw-hidden'); - updateIframeHeight(); - setTimeout(() => { // avoid flash of iframe background - mermaidBlock.classList.remove('is-loading'); - iframe.classList.remove('tw-invisible'); - }, 0); + // same origin, so we can operate "iframe body" and all elements directly + const iframeBody = iframe.contentDocument!.body; + iframeBody.append(svgNode); + bindFunctions?.(iframeBody); // follow "mermaid.render" doc, attach event handlers to the svg's container - // update height when element's visibility state changes, for example when the diagram is inside - // a
+ block and the
block becomes visible upon user interaction, it - // would initially set a incorrect height and the correct height is set during this callback. - (new IntersectionObserver(() => { - updateIframeHeight(); - }, {root: document.documentElement})).observe(iframe); + // according to mermaid, the viewBox height should always exist, here just a fallback for unknown cases. + // and keep in mind: clientHeight can be 0 if the element is hidden (display: none). + if (!iframeHeightFromViewBox && iframeBody.clientHeight) iframe.style.height = `${iframeBody.clientHeight}px`; + iframe.classList.remove('is-loading'); }); - document.body.append(mermaidBlock); + const container = createElementFromAttrs('div', {class: 'mermaid-block'}, iframe, makeCodeCopyButton({'data-clipboard-text': source})); + parentContainer.replaceWith(container); } catch (err) { - displayError(pre, err); + displayError(parentContainer, err); } - })); + } }