diff --git a/package.json b/package.json index 01face065b0..6a5f263946a 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "@github/text-expander-element": "2.9.4", "@lezer/highlight": "1.2.3", "@mcaptcha/vanilla-glue": "0.1.0-rc2", - "@mermaid-js/layout-elk": "0.2.3", "@primer/octicons": "19.36.0", "@replit/codemirror-indentation-markers": "6.5.3", "@replit/codemirror-lang-nix": "6.0.1", @@ -51,7 +50,7 @@ "jquery": "4.0.0", "js-yaml": "5.4.1", "katex": "0.18.7", - "mermaid": "11.17.2", + "mermaid": "12.0.0", "online-3d-viewer": "0.18.0", "pdfobject": "2.3.1", "postcss": "8.5.28", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93cf64f94c6..75247a96791 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,9 +229,6 @@ importers: '@mcaptcha/vanilla-glue': specifier: 0.1.0-rc2 version: 0.1.0-rc2(supports-color@10.2.2) - '@mermaid-js/layout-elk': - specifier: 0.2.3 - version: 0.2.3(mermaid@11.17.2) '@primer/octicons': specifier: 19.36.0 version: 19.36.0 @@ -302,8 +299,8 @@ importers: specifier: 0.18.7 version: 0.18.7 mermaid: - specifier: 11.17.2 - version: 11.17.2 + specifier: 12.0.0 + version: 12.0.0 online-3d-viewer: specifier: 0.18.0 version: 0.18.0 @@ -520,9 +517,21 @@ packages: '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + '@chevrotain/cst-dts-gen@11.1.2': + resolution: {integrity: sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==} + + '@chevrotain/gast@11.1.2': + resolution: {integrity: sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==} + + '@chevrotain/regexp-to-ast@11.1.2': + resolution: {integrity: sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==} + '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@chevrotain/utils@11.1.2': + resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==} + '@citation-js/core@0.7.21': resolution: {integrity: sha512-Vobv2/Yfnn6C6BVO/pvj7madQ7Mfzl83/jAWwixbemGF6ZThhGMz8++FD9hWHyHXDMYuLGa6fK68c2VsolZmTA==} engines: {node: '>=16.0.0'} @@ -1060,13 +1069,9 @@ packages: '@mcaptcha/vanilla-glue@0.1.0-rc2': resolution: {integrity: sha512-LDjn9lrKioJ3zwaQOfql7PXsnxCAHg7b1rPw7G0OxpvVE7xLB/a40SHfIIiocce2VS9TPI4MbcKm5pcuy8fU5g==} - '@mermaid-js/layout-elk@0.2.3': - resolution: {integrity: sha512-m46RVGfvKK1PSMdAFGDz2nksI4jusEkFxvf0lb48bYWmqLQxZpjtpruiw29MbOy9rlWJtuMf9EzG9fAF8CVZbg==} - peerDependencies: - mermaid: ^11.0.2 - - '@mermaid-js/parser@1.2.1': - resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} + '@mermaid-js/parser@2.0.0': + resolution: {integrity: sha512-K8BeapFUfrfxbRAUQAG5oBOCwo1+bNWzaVnPxTCutkfrTBn6T/j91FIhqxWJ32SUeQ9T3iy1zcmPZ5ROZEvDrg==} + engines: {node: '>=22.12.0'} '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} @@ -2047,6 +2052,9 @@ packages: peerDependencies: chart.js: '>=3.2.0' + chevrotain@11.1.2: + resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -2464,8 +2472,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.4.7: - resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==} + dompurify@3.4.15: + resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -2766,9 +2774,6 @@ packages: fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - fastdom@1.0.12: - resolution: {integrity: sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==} - fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} @@ -3399,6 +3404,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} @@ -3469,8 +3477,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - mermaid@11.17.2: - resolution: {integrity: sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==} + mermaid@12.0.0: + resolution: {integrity: sha512-/wQXC9iBxoGV8p3erbvaXs9h77VyLDBH6GdayVjj3hEcSQhFU4N1WUhUppotCEqlIxI2pRMwjwBSwTB1MfZBgQ==} + engines: {node: '>=22.12.0'} micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -4131,9 +4140,6 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - strictdom@1.0.1: - resolution: {integrity: sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -4692,8 +4698,23 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 + '@chevrotain/cst-dts-gen@11.1.2': + dependencies: + '@chevrotain/gast': 11.1.2 + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/gast@11.1.2': + dependencies: + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/regexp-to-ast@11.1.2': {} + '@chevrotain/types@11.1.2': {} + '@chevrotain/utils@11.1.2': {} + '@citation-js/core@0.7.21': dependencies: '@citation-js/date': 0.5.1 @@ -5374,13 +5395,7 @@ snapshots: - supports-color - utf-8-validate - '@mermaid-js/layout-elk@0.2.3(mermaid@11.17.2)': - dependencies: - d3: 7.9.0 - elkjs: 0.9.3 - mermaid: 11.17.2 - - '@mermaid-js/parser@1.2.1': + '@mermaid-js/parser@2.0.0': dependencies: '@chevrotain/types': 11.1.2 @@ -6428,6 +6443,15 @@ snapshots: chart.js: 4.5.1 hammerjs: 2.0.8 + chevrotain@11.1.2: + dependencies: + '@chevrotain/cst-dts-gen': 11.1.2 + '@chevrotain/gast': 11.1.2 + '@chevrotain/regexp-to-ast': 11.1.2 + '@chevrotain/types': 11.1.2 + '@chevrotain/utils': 11.1.2 + lodash-es: 4.17.23 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -6853,7 +6877,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.4.7: + dompurify@3.4.15: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -7289,10 +7313,6 @@ snapshots: fast-uri@3.1.2: {} - fastdom@1.0.12: - dependencies: - strictdom: 1.0.1 - fastest-levenshtein@1.0.16: {} fastq@1.20.1: @@ -7927,6 +7947,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.17.23: {} + lodash-es@4.18.1: {} lodash.truncate@4.4.2: {} @@ -8009,13 +8031,14 @@ snapshots: merge2@1.4.1: {} - mermaid@11.17.2: + mermaid@12.0.0: dependencies: '@braintree/sanitize-url': 7.1.2 '@iconify/utils': 3.1.3 - '@mermaid-js/parser': 1.2.1 + '@mermaid-js/parser': 2.0.0 '@types/d3': 7.4.3 '@upsetjs/venn.js': 2.0.0 + chevrotain: 11.1.2 cytoscape: 3.34.1 cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.1) cytoscape-fcose: 2.2.0(cytoscape@3.34.1) @@ -8023,9 +8046,9 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.23 - dompurify: 3.4.7 + dompurify: 3.4.15 + elkjs: 0.9.3 es-toolkit: 1.47.0 - fastdom: 1.0.12 katex: 0.16.47 khroma: 2.1.0 marked: 16.4.2 @@ -8799,8 +8822,6 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - strictdom@1.0.1: {} - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 diff --git a/web_src/css/markup/content.css b/web_src/css/markup/content.css index c04c8782fa9..63f7a1986fc 100644 --- a/web_src/css/markup/content.css +++ b/web_src/css/markup/content.css @@ -427,7 +427,7 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] { display: block; border: none; width: 100%; - height: var(--height-loading); /* actual height is set in JS after loading */ + height: var(--height-loading); /* actual height is set in JS */ overflow: hidden; color-scheme: normal; /* match the value inside the iframe to allow it to become transparent */ } diff --git a/web_src/css/modules/animations.css b/web_src/css/modules/animations.css index 4b68f486667..7ac269b2dac 100644 --- a/web_src/css/modules/animations.css +++ b/web_src/css/modules/animations.css @@ -55,6 +55,7 @@ form.single-button-form.is-loading .button { .markup pre.is-loading, .editor-loading.is-loading { height: var(--height-loading); + overflow: hidden; } .markup .is-loading > * { diff --git a/web_src/js/markup/mermaid.test.ts b/web_src/js/markup/mermaid.test.ts deleted file mode 100644 index a7467858f51..00000000000 --- a/web_src/js/markup/mermaid.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import {sourceNeedsElk} from './mermaid.ts'; -import {dedent} from '../utils/testhelper.ts'; - -test('MermaidConfigLayoutCheck', () => { - expect(sourceNeedsElk(dedent(` - flowchart TB - elk --> B - `))).toEqual(false); - - expect(sourceNeedsElk(dedent(` - --- - config: - layout : elk - --- - flowchart TB - A --> B - `))).toEqual(true); - - expect(sourceNeedsElk(dedent(` - --- - config: - layout: elk.layered - --- - flowchart TB - A --> B - `))).toEqual(true); - - expect(sourceNeedsElk(` - %%{ init : { "flowchart": { "defaultRenderer": "elk" } } }%% - flowchart TB - A --> B - `)).toEqual(true); - - expect(sourceNeedsElk(dedent(` - --- - config: - layout: 123 - --- - %%{ init : { "class": { "defaultRenderer": "elk.any" } } }%% - flowchart TB - A --> B - `))).toEqual(true); - - expect(sourceNeedsElk(` - %%{init:{ - "layout" : "elk.layered" - }}%% - flowchart TB - A --> B - `)).toEqual(true); - - expect(sourceNeedsElk(` - %%{ initialize: { - 'layout' : 'elk.layered' - }}%% - flowchart TB - A --> B - `)).toEqual(true); -}); diff --git a/web_src/js/markup/mermaid.ts b/web_src/js/markup/mermaid.ts index 10016c4471b..48a2bb21dc3 100644 --- a/web_src/js/markup/mermaid.ts +++ b/web_src/js/markup/mermaid.ts @@ -1,203 +1,98 @@ import {isDarkTheme} from '../utils.ts'; import {displayError} from './common.ts'; -import {createElementFromAttrs, createElementFromHTML, queryElems} from '../utils/dom.ts'; +import {createElementFromAttrs, createElementFromHTML, isPlainClick, queryElems} from '../utils/dom.ts'; import {html, htmlRaw} from '../utils/html.ts'; -import {load as loadYaml} from 'js-yaml'; -import type {MermaidConfig} from 'mermaid'; import {svg} from '../svg.ts'; const {mermaidMaxSourceCharacters} = window.config; -function getIframeCss(): string { - return ` -html, body { height: 100%; } -body { margin: 0; padding: 0; overflow: hidden; } -#mermaid { display: block; margin: 0 auto; } -`; -} - -function isSourceTooLarge(source: string) { - return mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters; -} - -function parseYamlInitConfig(source: string): MermaidConfig | null { - // ref: https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/diagram-api/regexes.ts - const yamlFrontMatterRegex = /^---\s*[\n\r](.*?)[\n\r]---\s*[\n\r]+/s; - const frontmatter = (yamlFrontMatterRegex.exec(source) || [])[1]; - if (!frontmatter) return null; - try { - return (loadYaml(frontmatter) as {config: MermaidConfig})?.config; - } catch { - console.error('invalid or unsupported mermaid init YAML config', frontmatter); - } - return null; -} - -function parseJsonInitConfig(source: string): MermaidConfig | null { - // https://mermaid.js.org/config/directives.html#declaring-directives - // Do as dirty as mermaid does: https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/utils.ts - // It can even accept invalid JSON string like: - // %%{initialize: { 'logLevel': 'fatal', "theme":'dark', 'startOnLoad': true } }%% - const jsonInitConfigRegex = /%%\{\s*(init|initialize)\s*:\s*(.*?)\}%%/s; - const jsonInitText = (jsonInitConfigRegex.exec(source) || [])[2]; - if (!jsonInitText) return null; - try { - const processed = jsonInitText.trim().replace(/'/g, '"'); - return JSON.parse(processed); - } catch { - console.error('invalid or unsupported mermaid init JSON config', jsonInitText); - } - return null; -} - -function configValueIsElk(layoutOrRenderer: string | undefined) { - if (typeof layoutOrRenderer !== 'string') return false; - return layoutOrRenderer === 'elk' || layoutOrRenderer.startsWith('elk.'); -} - -function configContainsElk(config: MermaidConfig | null) { - if (!config) return false; - // Check the layout from the following properties: - // * config.layout - // * 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((diagCfg) => configValueIsElk(diagCfg?.defaultRenderer)); -} - -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(needElkRender: boolean) { - const mermaidPromise = import('mermaid'); - const elkPromise = needElkRender ? import('@mermaid-js/layout-elk') : null; - const results = await Promise.all([mermaidPromise, elkPromise]); - return { - mermaid: results[0].default, - elkLayouts: results[1]?.default, - }; -} - function initMermaidViewController(viewController: Element, dragElement: SVGSVGElement) { - let inited = false, isDragging = false; - let currentScale = 1, initLeft = 0, lastLeft = 0, lastTop = 0, lastPageX = 0, lastPageY = 0; - - const resetView = () => { - currentScale = 1; - lastLeft = initLeft; - lastTop = 0; - dragElement.style.left = `${lastLeft}px`; - dragElement.style.top = `${lastTop}px`; - dragElement.style.position = 'absolute'; - dragElement.style.margin = '0'; - }; - - const initAbsolutePosition = () => { - if (inited) return; - // if we need to drag or zoom, use absolute position and get the current "left" from the "margin: auto" layout. - inited = true; - const container = dragElement.parentElement!; - initLeft = container.getBoundingClientRect().width / 2 - dragElement.getBoundingClientRect().width / 2; - resetView(); + let scale = 1, left = 0, top = 0, lastPageX = 0, lastPageY = 0; + let dragPointerId: number | null = null; + const applyTransform = () => { + dragElement.style.transform = `translate(${left}px, ${top}px) scale(${scale})`; + dragElement.style.touchAction = scale === 1 ? '' : 'none'; // touch scrolls the page until zoomed, then pans the diagram }; for (const el of viewController.querySelectorAll('[data-control-action]')) { el.addEventListener('click', () => { - initAbsolutePosition(); switch (el.getAttribute('data-control-action')) { case 'zoom-in': - currentScale *= 1.2; + scale *= 1.2; break; case 'zoom-out': - currentScale /= 1.2; + scale /= 1.2; break; case 'reset': - resetView(); + scale = 1; + left = top = 0; break; } - dragElement.style.transform = `scale(${currentScale})`; + applyTransform(); }); } - dragElement.addEventListener('mousedown', (e) => { - if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; // only left mouse button can drag - const target = e.target as Element; + dragElement.addEventListener('pointerdown', (e) => { + if (dragPointerId !== null || !isPlainClick(e) || (e.pointerType !== 'mouse' && scale === 1)) return; // don't start the drag if the click is on an interactive element (e.g.: link, button) or text element - if (target.closest('div, p, a, span, button, input, text')) return; - - initAbsolutePosition(); - isDragging = true; + if ((e.target as Element).closest('div, p, a, span, button, input, text')) return; + e.preventDefault(); // prevent text selection while dragging + dragPointerId = e.pointerId; + dragElement.setPointerCapture(e.pointerId); lastPageX = e.pageX; lastPageY = e.pageY; dragElement.style.cursor = 'grabbing'; }); - dragElement.ownerDocument.addEventListener('mousemove', (e) => { - if (!isDragging) return; - lastLeft = e.pageX - lastPageX + lastLeft; - lastTop = e.pageY - lastPageY + lastTop; - dragElement.style.left = `${lastLeft}px`; - dragElement.style.top = `${lastTop}px`; + dragElement.addEventListener('pointermove', (e) => { + if (e.pointerId !== dragPointerId) return; + left += e.pageX - lastPageX; + top += e.pageY - lastPageY; lastPageX = e.pageX; lastPageY = e.pageY; + applyTransform(); }); - dragElement.ownerDocument.addEventListener('mouseup', () => { - if (!isDragging) return; - isDragging = false; + const endDrag = (e: PointerEvent) => { + if (e.pointerId !== dragPointerId) return; + dragPointerId = null; dragElement.style.removeProperty('cursor'); - }); + }; + dragElement.addEventListener('pointerup', endDrag); + dragElement.addEventListener('pointercancel', endDrag); } -let elkLayoutsRegistered = false; - export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise { // .markup code.language-mermaid const mermaidBlocks: Array<{source: string, parentContainer: Element}> = []; const attrMermaidRendered = 'data-markup-mermaid-rendered'; - let needElkRender = false; for (const elCodeBlock of queryElems(elMarkup, 'code.language-mermaid')) { const parentContainer = elCodeBlock.closest('pre.code-block')!; // 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); + const source = elCodeBlock.textContent; + if (mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters) { + displayError(parentContainer, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`)); + continue; + } mermaidBlocks.push({source, parentContainer}); } if (!mermaidBlocks.length) return; - const {mermaid, elkLayouts} = await loadMermaid(needElkRender); - if (elkLayouts && !elkLayoutsRegistered) { - mermaid.registerLayoutLoaders(elkLayouts); - elkLayoutsRegistered = true; - } + const {default: mermaid} = await import('mermaid'); mermaid.initialize({ startOnLoad: false, theme: isDarkTheme() ? 'dark' : 'neutral', // TODO: maybe it should use "darkMode" to adopt more user-specified theme instead of just "dark" or "neutral" + look: 'neo', + themeVariables: {dropShadow: 'none', useGradient: false}, // hide ugly shadows and gradients from neo + themeCSS: '[filter] { filter: none; }', // sequence diagrams hardcode the neo drop shadow as an attribute securityLevel: 'strict', suppressErrorRendering: true, }); - const iframeStyleText = getIframeCss(); - const applyMermaidIframeHeight = (iframe: HTMLIFrameElement, height: number) => { - if (!height) return; - // use a min-height to make sure the buttons won't overlap. - iframe.style.height = `${Math.max(height, 85)}px`; - }; - // 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(parentContainer, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`)); - continue; - } - + for (const {source, parentContainer} of mermaidBlocks) { try { // render the mermaid diagram to svg text, and parse it to a DOM node const {svg: svgText, bindFunctions} = await mermaid.render('mermaid', source, parentContainer); @@ -214,30 +109,18 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise`; - - // 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); - applyMermaidIframeHeight(iframe, iframeHeightFromViewBox); + iframe.classList.add('markup-content-iframe', 'tw-invisible'); + iframe.srcdoc = ''; + const viewBoxHeight = svgNode.viewBox.baseVal?.height; + if (viewBoxHeight) iframe.style.height = `${Math.max(Math.ceil(viewBoxHeight), 85)}px`; // min-height keeps the buttons from overlapping // 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', () => { - // same origin, so we can operate "iframe head/body" and all elements directly - const style = document.createElement('style'); - style.textContent = iframeStyleText; - iframe.contentDocument!.head.append(style); - const iframeBody = iframe.contentDocument!.body; iframeBody.append(svgNode); bindFunctions?.(iframeBody); // follow "mermaid.render" doc, attach event handlers to the svg's container - - // 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) applyMermaidIframeHeight(iframe, iframeBody.clientHeight); - iframe.classList.remove('is-loading'); + iframe.classList.remove('tw-invisible'); initMermaidViewController(viewController, svgNode); }); diff --git a/web_src/js/utils/testhelper.ts b/web_src/js/utils/testhelper.ts index 9efb3aad429..b50e6c08950 100644 --- a/web_src/js/utils/testhelper.ts +++ b/web_src/js/utils/testhelper.ts @@ -12,22 +12,6 @@ export function captureNavigations() { return navigations; } -/** strip common indentation from a string and trim it */ -export function dedent(str: string) { - const match = str.match(/^[ \t]*(?=\S)/gm); - if (!match) return str; - - let minIndent = Infinity; - for (const indent of match) { - minIndent = Math.min(minIndent, indent.length); - } - if (minIndent === 0 || minIndent === Infinity) { - return str; - } - - return str.replace(new RegExp(`^[ \\t]{${minIndent}}`, 'gm'), '').trim(); -} - export function normalizeTestHtml(s: string) { const lines = s.replace(/>\s+\n<').trim().split('\n'); for (let i = 0; i < lines.length; i++) {