From 2477dab79bebafff91f17b5cfab3ffbf5c2502df Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Fri, 26 Jun 2026 17:53:49 +0530 Subject: [PATCH 1/3] Use official Markmap renderer --- desktop-app/prepare.js | 15 + desktop-app/resources/js/preview-worker.js | 7 +- desktop-app/resources/js/script.js | 540 +++++++++++++++------ desktop-app/resources/styles.css | 11 + preview-worker.js | 7 +- script.js | 173 +++++-- styles.css | 11 + 7 files changed, 581 insertions(+), 183 deletions(-) diff --git a/desktop-app/prepare.js b/desktop-app/prepare.js index 79df5b08..be83290b 100644 --- a/desktop-app/prepare.js +++ b/desktop-app/prepare.js @@ -249,6 +249,21 @@ async function prepareOfflineDependencies() { dest: path.join(LIBS_DIR, "mermaid.min.js"), hash: null }, + { + url: "https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js", + dest: path.join(LIBS_DIR, "d3.min.js"), + hash: null + }, + { + url: "https://cdn.jsdelivr.net/npm/markmap-lib@0.18.12/dist/browser/index.iife.js", + dest: path.join(LIBS_DIR, "markmap-lib.iife.js"), + hash: null + }, + { + url: "https://cdn.jsdelivr.net/npm/markmap-view@0.18.12/dist/browser/index.js", + dest: path.join(LIBS_DIR, "markmap-view.js"), + hash: null + }, { url: "https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js", dest: path.join(LIBS_DIR, "tex-mml-chtml.min.js"), diff --git a/desktop-app/resources/js/preview-worker.js b/desktop-app/resources/js/preview-worker.js index 8455b0a7..fead7c1b 100644 --- a/desktop-app/resources/js/preview-worker.js +++ b/desktop-app/resources/js/preview-worker.js @@ -366,14 +366,17 @@ function configureMarked() { const krokiLanguages = { 'vega-lite': ['vegalite', 'Vega-Lite'], vegalite: ['vegalite', 'Vega-Lite'], - wavedrom: ['wavedrom', 'WaveDrom'], - markmap: ['markmap', 'Markmap'] + wavedrom: ['wavedrom', 'WaveDrom'] }; if (krokiLanguages[language]) { const [engine, label] = krokiLanguages[language]; const uniqueId = `${engine}-diagram-worker-${krokiIdCounter++}`; return renderDiagramShell(engine, 'kroki-container', 'kroki-diagram', uniqueId, code, label); } + if (language === 'markmap') { + const uniqueId = `markmap-diagram-worker-${krokiIdCounter++}`; + return renderDiagramShell('markmap', 'markmap-container', 'markmap-diagram', uniqueId, code, 'Markmap'); + } if (language === "math") { return `
$$\n${code}\n$$
\n`; diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 6ca6546b..83a24d32 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -40,28 +40,50 @@ document.addEventListener("DOMContentLoaded", async function () { // PERF-002: Lazy script loader for optional heavy libraries const _loadedScripts = new Set(); + const _loadingScripts = new Map(); function loadScript(url) { if (_loadedScripts.has(url)) return Promise.resolve(); - return new Promise(function(resolve, reject) { + if (_loadingScripts.has(url)) return _loadingScripts.get(url); + const loadPromise = new Promise(function(resolve, reject) { const script = document.createElement('script'); script.src = url; - script.onload = function() { _loadedScripts.add(url); resolve(); }; - script.onerror = function() { reject(new Error('Failed to load: ' + url)); }; + script.onload = function() { + _loadedScripts.add(url); + _loadingScripts.delete(url); + resolve(); + }; + script.onerror = function() { + _loadingScripts.delete(url); + reject(new Error('Failed to load: ' + url)); + }; document.head.appendChild(script); }); + _loadingScripts.set(url, loadPromise); + return loadPromise; } const _loadedStyles = new Set(); + const _loadingStyles = new Map(); function loadStyle(url) { if (_loadedStyles.has(url)) return Promise.resolve(); - return new Promise(function(resolve, reject) { + if (_loadingStyles.has(url)) return _loadingStyles.get(url); + const loadPromise = new Promise(function(resolve, reject) { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = url; - link.onload = function() { _loadedStyles.add(url); resolve(); }; - link.onerror = function() { reject(new Error('Failed to load style: ' + url)); }; + link.onload = function() { + _loadedStyles.add(url); + _loadingStyles.delete(url); + resolve(); + }; + link.onerror = function() { + _loadingStyles.delete(url); + reject(new Error('Failed to load style: ' + url)); + }; document.head.appendChild(link); }); + _loadingStyles.set(url, loadPromise); + return loadPromise; } // CDN URLs for lazy-loaded libraries @@ -79,7 +101,10 @@ document.addEventListener("DOMContentLoaded", async function () { topojson: 'https://cdnjs.cloudflare.com/ajax/libs/topojson/3.0.2/topojson.min.js', three: 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js', stlLoader: 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/STLLoader.js', - orbitControls: 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js' + orbitControls: 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js', + d3: 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js', + markmapLib: 'https://cdn.jsdelivr.net/npm/markmap-lib@0.18.12/dist/browser/index.iife.js', + markmapView: 'https://cdn.jsdelivr.net/npm/markmap-view@0.18.12/dist/browser/index.js' }; // Resolve local paths for desktop (Neutralinojs) offline support @@ -98,6 +123,9 @@ document.addEventListener("DOMContentLoaded", async function () { CDN.three = '/libs/three.min.js'; CDN.stlLoader = '/libs/STLLoader.js'; CDN.orbitControls = '/libs/OrbitControls.js'; + CDN.d3 = '/libs/d3.min.js'; + CDN.markmapLib = '/libs/markmap-lib.iife.js'; + CDN.markmapView = '/libs/markmap-view.js'; } // Active WebGL / Three.js 3D STL renderers Map for memory cleanup @@ -193,6 +221,9 @@ document.addEventListener("DOMContentLoaded", async function () { const previewWorkerRequests = new Map(); const previewSegmentHtmlCache = new Map(); let previewSegmentCacheTabId = null; + let mathJaxLoadPromise = null; + let mathJaxTypesetPromise = Promise.resolve(); + let mathJaxTypesetRunId = 0; const markdownEditor = document.getElementById("markdown-editor"); const markdownPreview = document.getElementById("markdown-preview"); @@ -466,6 +497,8 @@ document.addEventListener("DOMContentLoaded", async function () { const renderer = new marked.Renderer(); const BLOCK_MATH_MARKER_PATTERN = /^\$\$/m; const BLOCK_MATH_PATTERN = /^\$\$[ \t]*\n?([\s\S]*?)\n?\$\$[ \t]*(?:\n|$)/; + const RAW_MATH_TEXT_PATTERN = /\$\$|\$[^$]|\\\(|\\\[/; + const MATHJAX_TEXT_TARGET_SELECTOR = 'p, li, td, th, dd, dt, blockquote, figcaption, h1, h2, h3, h4, h5, h6, .math-block'; const DEFINITION_LIST_ITEM_PATTERN = /^:[ \t]+(.*)$/; const SUPERSCRIPT_PATTERN = /^\^(?!\s)([^^\n]*?\S)\^(?!\^)/; const SUBSCRIPT_PATTERN = /^~(?!~)(?!\s)([^~\n]*?\S)~(?!~)/; @@ -1091,8 +1124,7 @@ document.addEventListener("DOMContentLoaded", async function () { const krokiLanguages = { 'vega-lite': 'vegalite', vegalite: 'vegalite', - wavedrom: 'wavedrom', - markmap: 'markmap' + wavedrom: 'wavedrom' }; if (krokiLanguages[language]) { const engine = krokiLanguages[language]; @@ -1100,6 +1132,11 @@ document.addEventListener("DOMContentLoaded", async function () { return renderDiagramShell(engine, 'kroki-container', 'kroki-diagram', uniqueId, code); } + if (language === 'markmap') { + const uniqueId = 'markmap-diagram-' + Math.random().toString(36).substr(2, 9); + return renderDiagramShell('markmap', 'markmap-container', 'markmap-diagram', uniqueId, code); + } + if (language === 'math') { return `
$$\n${code}\n$$
\n`; } @@ -1352,7 +1389,7 @@ document.addEventListener("DOMContentLoaded", async function () { graphviz: { label: 'Graphviz', krokiType: 'graphviz' }, vegalite: { label: 'Vega-Lite', krokiType: 'vegalite' }, wavedrom: { label: 'WaveDrom', krokiType: 'wavedrom' }, - markmap: { label: 'Markmap', krokiType: 'markmap' } + markmap: { label: 'Markmap', local: true } }); const DIAGRAM_REQUEST_TIMEOUT = 15000; const DIAGRAM_REQUEST_RETRIES = 2; @@ -1496,7 +1533,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (!adapter) throw new Error(`Unsupported diagram engine: ${engine}`); if (engine === 'markmap') { - return renderMarkmapSvg(source); + return renderMarkmapPreviewSvg(source); } const rendererSource = normalizeDiagramSourceForRenderer(engine, source); @@ -1540,44 +1577,113 @@ document.addEventListener("DOMContentLoaded", async function () { .replace(/"/g, '"'); } - function renderMarkmapSvg(source) { - const nodes = []; - String(source || '').split(/\r?\n/).forEach(line => { - const heading = /^(#{1,6})\s+(.+)$/.exec(line); - const list = /^(\s*)[-*+]\s+(.+)$/.exec(line); - if (!heading && !list) return; - const level = heading ? heading[1].length : Math.floor(list[1].replace(/\t/g, ' ').length / 2) + 2; - const label = (heading ? heading[2] : list[2]).replace(/[`*_~]/g, '').trim(); - if (!label) return; - let parent = -1; - for (let index = nodes.length - 1; index >= 0; index -= 1) { - if (nodes[index].level < level) { - parent = index; - break; - } - } - nodes.push({ level, label, parent }); - }); + let markmapLibraryPromise = null; - if (!nodes.length) nodes.push({ level: 1, label: 'Mind map', parent: -1 }); - const minLevel = Math.min(...nodes.map(node => node.level)); - const horizontalGap = 190; - const verticalGap = 64; - const padding = 32; - nodes.forEach((node, index) => { - node.x = padding + (node.level - minLevel) * horizontalGap; - node.y = padding + index * verticalGap; - node.width = Math.min(Math.max(node.label.length * 7.2 + 28, 96), 176); - node.height = 36; - }); - const width = Math.max(...nodes.map(node => node.x + node.width)) + padding; - const height = nodes[nodes.length - 1].y + nodes[nodes.length - 1].height + padding; - const edges = nodes.filter(node => node.parent >= 0).map(node => { - const parent = nodes[node.parent]; - return ``; - }).join(''); - const boxes = nodes.map((node, index) => `${escapeDiagramXml(node.label)}`).join(''); - return `${edges}${boxes}`; + function ensureMarkmapReady() { + if (window.markmap && window.markmap.Transformer && window.markmap.Markmap) { + return Promise.resolve(window.markmap); + } + if (!markmapLibraryPromise) { + markmapLibraryPromise = loadDiagramLibrary(CDN.d3) + .then(() => loadDiagramLibrary(CDN.markmapLib)) + .then(() => loadDiagramLibrary(CDN.markmapView)) + .then(() => { + if (!window.markmap || !window.markmap.Transformer || !window.markmap.Markmap) { + throw new Error('Official Markmap API did not initialize.'); + } + return window.markmap; + }) + .catch(error => { + markmapLibraryPromise = null; + throw error; + }); + } + return markmapLibraryPromise; + } + + function getMarkmapNodeCount(node) { + if (!node) return 0; + return 1 + (node.children || []).reduce((count, child) => count + getMarkmapNodeCount(child), 0); + } + + function getMarkmapDepth(node) { + if (!node || !node.children || node.children.length === 0) return 1; + return 1 + Math.max(...node.children.map(getMarkmapDepth)); + } + + function getMarkmapHeight(root, compact) { + const nodeCount = getMarkmapNodeCount(root); + const depth = getMarkmapDepth(root); + const baseHeight = compact ? 220 : 340; + const estimated = baseHeight + nodeCount * (compact ? 14 : 20) + depth * 24; + const min = compact ? 260 : 360; + const max = compact ? 520 : 900; + return Math.max(min, Math.min(max, estimated)); + } + + async function buildMarkmap(source) { + const markmap = await ensureMarkmapReady(); + const transformer = new markmap.Transformer(); + const transformed = transformer.transform(String(source || '')); + const assets = transformer.getUsedAssets(transformed.features || {}); + if (assets.styles && markmap.loadCSS) { + markmap.loadCSS(assets.styles); + } + if (assets.scripts && markmap.loadJS) { + await markmap.loadJS(assets.scripts, { + getMarkmap: () => markmap + }); + } + const jsonOptions = transformed.frontmatter && typeof transformed.frontmatter.markmap === 'object' + ? transformed.frontmatter.markmap + : undefined; + const options = markmap.deriveOptions + ? markmap.deriveOptions(jsonOptions || {}) + : jsonOptions; + return { markmap, root: transformed.root, options }; + } + + async function renderMarkmapIntoElement(node, source, compact) { + const { markmap, root, options } = await buildMarkmap(source); + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const height = getMarkmapHeight(root, compact); + const measuredWidth = node.getBoundingClientRect ? node.getBoundingClientRect().width : 0; + const width = compact ? 960 : Math.max(640, Math.min(1200, Math.round(measuredWidth || 960))); + svg.setAttribute('width', String(width)); + svg.setAttribute('height', String(height)); + svg.setAttribute('role', 'img'); + svg.setAttribute('aria-label', 'Markmap preview'); + node.style.setProperty('--markmap-height', `${height}px`); + svg.style.setProperty('--markmap-height', `${height}px`); + svg.style.width = '100%'; + svg.style.height = `${height}px`; + svg.style.minHeight = `${height}px`; + svg.style.maxHeight = compact ? '100%' : 'min(70vh, 900px)'; + node.replaceChildren(svg); + const instance = markmap.Markmap.create(svg, options, root); + await new Promise(resolve => requestAnimationFrame(resolve)); + if (instance && typeof instance.fit === 'function') { + instance.fit(); + } + normalizeDiagramSvg(node, 'markmap', source); + return svg; + } + + async function renderMarkmapPreviewSvg(source) { + const wrapper = document.createElement('div'); + wrapper.style.position = 'fixed'; + wrapper.style.left = '-10000px'; + wrapper.style.top = '0'; + wrapper.style.width = '960px'; + wrapper.style.height = '520px'; + wrapper.style.pointerEvents = 'none'; + document.body.appendChild(wrapper); + try { + const svg = await renderMarkmapIntoElement(wrapper, source, true); + return new XMLSerializer().serializeToString(svg); + } finally { + wrapper.remove(); + } } function importDiagramSvg(node, svgText, engine, source) { @@ -1710,20 +1816,27 @@ document.addEventListener("DOMContentLoaded", async function () { } async function renderRemoteDiagramNode(node, engine, context) { - const container = node.closest('[data-diagram-engine], .plantuml-container, .d2-container, .graphviz-container, .kroki-container'); + const container = node.closest('[data-diagram-engine], .plantuml-container, .d2-container, .graphviz-container, .kroki-container, .markmap-container'); const originalCode = node.getAttribute('data-original-code'); if (!container || !originalCode) return; const source = decodeURIComponent(originalCode); setDiagramRenderState(container, 'loading', `Rendering ${REMOTE_DIAGRAM_ENGINES[engine].label}…`); try { + if (engine === 'markmap') { + await renderMarkmapIntoElement(node, source, false); + if ((context && context.renderId !== previewRenderGeneration) || !document.body.contains(node)) return; + setDiagramRenderState(container, 'ready'); + mountDiagramViewer(container, engine); + return; + } const svgText = await renderDiagramThroughAdapter(engine, source); - if (context.renderId !== previewRenderGeneration || !document.body.contains(node)) return; + if ((context && context.renderId !== previewRenderGeneration) || !document.body.contains(node)) return; importDiagramSvg(node, svgText, engine, source); setDiagramRenderState(container, 'ready'); mountDiagramViewer(container, engine); } catch (error) { - if (context.renderId !== previewRenderGeneration || !document.body.contains(node)) return; + if ((context && context.renderId !== previewRenderGeneration) || !document.body.contains(node)) return; console.error('Diagram rendering failed', { engine, status: error.status || null, @@ -1736,6 +1849,92 @@ document.addEventListener("DOMContentLoaded", async function () { } } + function typesetMathJaxTargets(mathTargets, context) { + const runId = ++mathJaxTypesetRunId; + const mathJaxReady = MathJax.startup && MathJax.startup.promise + ? MathJax.startup.promise + : Promise.resolve(); + + mathJaxTypesetPromise = mathJaxTypesetPromise.catch(function() { + // Continue with the newest render after a previous MathJax run fails. + }).then(function() { + return mathJaxReady; + }).then(function() { + if (runId !== mathJaxTypesetRunId) return null; + if (context.renderId !== previewRenderGeneration) return; + if (typeof MathJax.typesetPromise !== 'function') { + throw new Error('MathJax typesetPromise API is unavailable.'); + } + const connectedTargets = mathTargets.filter(function(target) { + return target && target.isConnected; + }); + if (connectedTargets.length === 0) return null; + return MathJax.typesetPromise(connectedTargets); + }).then(function() { + if (runId !== mathJaxTypesetRunId) return; + if (context.renderId !== previewRenderGeneration) return; + queryPreviewRoots(mathTargets, 'mjx-container[tabindex="0"]').forEach(function(mjx) { + mjx.removeAttribute('tabindex'); + }); + }).catch(function(err) { + console.warn('MathJax typesetting failed:', err); + }); + + return mathJaxTypesetPromise; + } + + function configureMathJax() { + if (window.MathJax && typeof MathJax.typesetPromise === 'function') return; + if (window.MathJax && MathJax.loader && MathJax.tex) return; + window.MathJax = { + loader: { load: ['[tex]/boldsymbol'] }, + startup: { + typeset: false + }, + options: { + a11y: { inTabOrder: false } + }, + tex: { + inlineMath: [['$', '$'], ['\\(', '\\)']], + displayMath: [['$$', '$$'], ['\\[', '\\]']], + processEscapes: true, + packages: { '[+]': ['ams', 'boldsymbol'] } + } + }; + } + + function ensureMathJaxReady() { + if (window.MathJax && typeof MathJax.typesetPromise === 'function') { + return MathJax.startup && MathJax.startup.promise + ? MathJax.startup.promise + : Promise.resolve(); + } + + configureMathJax(); + if (!mathJaxLoadPromise) { + mathJaxLoadPromise = loadScript(CDN.mathjax) + .then(function() { + return MathJax.startup && MathJax.startup.promise + ? MathJax.startup.promise + : Promise.resolve(); + }) + .catch(function(error) { + mathJaxLoadPromise = null; + throw error; + }); + } + return mathJaxLoadPromise; + } + + function clearMathJaxPreviewState(container) { + if (!window.MathJax || typeof MathJax.typesetClear !== 'function' || !container) return; + try { + MathJax.typesetClear([container]); + } catch (error) { + console.warn('MathJax cleanup failed:', error); + } + } + // PERF-012: Inlined default template to eliminate network request, FOUC, and layout shifts const defaultMarkdownTemplate = document.getElementById('default-markdown'); let templateText = ''; @@ -2676,6 +2875,8 @@ document.addEventListener("DOMContentLoaded", async function () { const shouldRestoreScroll = previewHasCommittedRender && !previewContainsSkeleton(); const scrollSnapshot = shouldRestoreScroll ? capturePreviewScroll() : null; + mathJaxTypesetRunId += 1; + clearMathJaxPreviewState(markdownPreview); const patchResult = patchPreviewDom(markdownPreview, sanitizedHtml, { reusePreviewBlocks: context.previewEngineMode === 'segmented' && !context.force, }); @@ -2729,6 +2930,38 @@ document.addEventListener("DOMContentLoaded", async function () { return matches; } + function hasRawMathText(node) { + return Boolean(node && RAW_MATH_TEXT_PATTERN.test(node.textContent || '')); + } + + function addMathJaxTarget(targets, candidate) { + if (!candidate || !hasRawMathText(candidate)) return; + if (targets.some(function(target) { return target.contains(candidate); })) return; + for (let index = targets.length - 1; index >= 0; index -= 1) { + if (candidate.contains(targets[index])) { + targets.splice(index, 1); + } + } + targets.push(candidate); + } + + function getMathJaxTypesetTargets(roots) { + const targets = []; + roots.forEach(function(root) { + if (!root || root.nodeType !== Node.ELEMENT_NODE) return; + if (root.matches && root.matches(MATHJAX_TEXT_TARGET_SELECTOR)) { + addMathJaxTarget(targets, root); + } + root.querySelectorAll(MATHJAX_TEXT_TARGET_SELECTOR).forEach(function(candidate) { + addMathJaxTarget(targets, candidate); + }); + if (targets.length === 0 && hasRawMathText(root)) { + addMathJaxTarget(targets, root); + } + }); + return targets; + } + function markPreviewRootsReady(roots) { queryPreviewRoots(roots, '.mermaid-container.is-loading').forEach(function(container) { setDiagramRenderState(container, 'ready'); @@ -2738,6 +2971,91 @@ document.addEventListener("DOMContentLoaded", async function () { }); } + function getDiagramNodeSource(node) { + const originalCode = node.getAttribute('data-original-code'); + if (originalCode) { + return decodeURIComponent(originalCode); + } + return node.textContent || ''; + } + + function escapeDiagramSource(code) { + return code + .replace(/&/g, "&") + .replace(//g, ">"); + } + + function restoreDiagramNodeSource(node) { + node.innerHTML = escapeDiagramSource(getDiagramNodeSource(node)); + node.removeAttribute('data-processed'); + } + + function withMutedMermaidConsole(callback) { + const originalError = console.error; + const originalWarn = console.warn; + const shouldMute = function(args) { + return args.some(function(arg) { + const text = arg && arg.message ? arg.message : String(arg); + return / attribute d: Expected number|NaN/.test(text); + }); + }; + + console.error = function() { + if (shouldMute(Array.from(arguments))) return; + originalError.apply(console, arguments); + }; + console.warn = function() { + if (shouldMute(Array.from(arguments))) return; + originalWarn.apply(console, arguments); + }; + + return Promise.resolve() + .then(callback) + .finally(function() { + console.error = originalError; + console.warn = originalWarn; + }); + } + + async function renderMermaidNode(node, context) { + if (!node || (context && context.renderId !== previewRenderGeneration)) return false; + const container = node.closest('.mermaid-container'); + const code = getDiagramNodeSource(node); + const renderId = `${node.id || 'mermaid-diagram'}-render-${Math.random().toString(36).slice(2)}`; + + try { + setDiagramRenderState(container, 'loading', 'Rendering Mermaid…'); + restoreDiagramNodeSource(node); + const result = await withMutedMermaidConsole(function() { + return mermaid.render(renderId, code); + }); + if (context && context.renderId !== previewRenderGeneration) return false; + node.innerHTML = result.svg; + if (typeof result.bindFunctions === 'function') { + result.bindFunctions(node); + } + setDiagramRenderState(container, 'ready'); + return true; + } catch (error) { + if (context && context.renderId !== previewRenderGeneration) return false; + setDiagramRenderState(container, 'error', 'Mermaid could not be rendered. Check the diagram syntax and retry.', () => { + renderMermaidNode(node, context); + }); + return false; + } + } + + async function renderMermaidNodeList(nodes, context) { + const results = []; + for (const node of nodes) { + if (context && context.renderId !== previewRenderGeneration) return results; + results.push(await renderMermaidNode(node, context)); + } + addMermaidToolbars(); + return results; + } + function parseAbcHeaders(abcString) { const titleMatch = /^T:\s*(.*)$/m.exec(abcString); const composerMatch = /^C:\s*(.*)$/m.exec(abcString); @@ -3296,22 +3614,21 @@ document.addEventListener("DOMContentLoaded", async function () { try { const mermaidNodes = queryPreviewRoots(roots, '.mermaid'); if (mermaidNodes.length > 0) { - const renderMermaidFallback = function() { - mermaidNodes.forEach(node => renderRemoteDiagramNode(node, 'mermaid', context)); - }; const renderMermaidNodes = function() { if (context.renderId !== previewRenderGeneration) return; initMermaid(false); - Promise.resolve(mermaid.init(undefined, mermaidNodes)) - .then(() => { - if (context.renderId !== previewRenderGeneration) return; - markPreviewRootsReady(roots); - addMermaidToolbars(); - }) + renderMermaidNodeList(mermaidNodes, context) .catch((e) => { if (context.renderId !== previewRenderGeneration) return; console.warn("Mermaid rendering failed:", e); - renderMermaidFallback(); + mermaidNodes.forEach(function(node) { + setDiagramRenderState( + node.closest('.mermaid-container'), + 'error', + 'Mermaid could not be rendered. Check the diagram syntax and retry.', + () => renderMermaidNode(node, context) + ); + }); }); }; if (typeof mermaid === 'undefined') { @@ -3321,7 +3638,14 @@ document.addEventListener("DOMContentLoaded", async function () { renderMermaidNodes(); }).catch(function(e) { console.warn('Failed to load mermaid:', e); - renderMermaidFallback(); + mermaidNodes.forEach(function(node) { + setDiagramRenderState( + node.closest('.mermaid-container'), + 'error', + 'Mermaid renderer is unavailable. Check your connection and retry.', + () => renderMermaidNode(node, context) + ); + }); }); } else { renderMermaidNodes(); @@ -3538,6 +3862,7 @@ document.addEventListener("DOMContentLoaded", async function () { ['.plantuml-diagram', 'plantuml'], ['.d2-diagram', 'd2'], ['.graphviz-diagram', 'graphviz'], + ['.markmap-diagram', 'markmap'], ['.kroki-diagram', null] ]; const renderAdapterTargets = function() { @@ -3566,52 +3891,14 @@ document.addEventListener("DOMContentLoaded", async function () { const hasMath = /\$\$|\$[^$]|\\\(|\\\[/.test(rawVal || '') || /```math\b/.test(rawVal || ''); if (hasMath) { - const typesetTargets = roots.filter(function(root) { - return root && root.nodeType === Node.ELEMENT_NODE && /\$\$|\$[^$]|\\\(|\\\[/.test(root.textContent || ''); + const mathTargets = getMathJaxTypesetTargets(roots); + if (mathTargets.length === 0) return; + ensureMathJaxReady().then(function() { + if (context.renderId !== previewRenderGeneration) return; + typesetMathJaxTargets(mathTargets, context); + }).catch(function(e) { + console.warn('Failed to load MathJax:', e); }); - const mathTargets = typesetTargets.length ? typesetTargets : roots; - if (window.MathJax) { - try { - MathJax.typesetPromise(mathTargets).then(function() { - if (context.renderId !== previewRenderGeneration) return; - queryPreviewRoots(mathTargets, 'mjx-container[tabindex="0"]').forEach(function(mjx) { - mjx.removeAttribute('tabindex'); - }); - }).catch(function(err) { - console.warn('MathJax typesetting failed:', err); - }); - } catch (e) { - console.warn("MathJax rendering failed:", e); - } - } else { - window.MathJax = { - loader: { load: ['[tex]/ams', '[tex]/boldsymbol'] }, - options: { - a11y: { inTabOrder: false } - }, - tex: { - inlineMath: [['$', '$'], ['\\(', '\\)']], - displayMath: [['$$', '$$'], ['\\[', '\\]']], - processEscapes: true, - packages: { '[+]': ['ams', 'boldsymbol'] } - } - }; - loadScript(CDN.mathjax).then(function() { - if (context.renderId !== previewRenderGeneration) return; - try { - MathJax.typesetPromise(mathTargets).then(function() { - if (context.renderId !== previewRenderGeneration) return; - queryPreviewRoots(mathTargets, 'mjx-container[tabindex="0"]').forEach(function(mjx) { - mjx.removeAttribute('tabindex'); - }); - }).catch(function(err) { - console.warn('MathJax typesetting failed:', err); - }); - } catch (e) { - console.warn('MathJax rendering failed:', e); - } - }).catch(function(e) { console.warn('Failed to load MathJax:', e); }); - } } updateDocumentStats(); @@ -6663,15 +6950,11 @@ document.addEventListener("DOMContentLoaded", async function () { const previewDiv = document.createElement('div'); previewDiv.className = 'diagram-card-preview'; - const isMermaidSpecial = (t.id === 'mermaid-sequence' || t.id === 'mermaid-er'); - const titleColor = isMermaidSpecial ? '#ff4081' : 'var(--text-color)'; - const titleWeight = isMermaidSpecial ? 'bold' : 'normal'; const catClass = t.category.toLowerCase().replace(/\s+/g, ''); previewDiv.innerHTML = ` -
-
${t.title}
-
+
+
${t.svg}
@@ -8990,34 +9273,19 @@ document.addEventListener("DOMContentLoaded", async function () { // Clear existing rendered Mermaid SVGs and re-render with new theme mermaidNodes.forEach(function(node) { // Restore original diagram code to prevent parsing already-rendered SVG as source - const originalCode = node.getAttribute('data-original-code'); - if (originalCode) { - const decodedCode = decodeURIComponent(originalCode); - const escapedCode = decodedCode - .replace(/&/g, "&") - .replace(//g, ">"); - node.innerHTML = escapedCode; - } - node.removeAttribute('data-processed'); + restoreDiagramNodeSource(node); const container = node.closest('.mermaid-container'); if (container) { - container.classList.add('is-loading'); + setDiagramRenderState(container, 'loading', 'Rendering Mermaid…'); const oldToolbar = container.querySelector('.mermaid-toolbar'); if (oldToolbar) oldToolbar.remove(); } }); - Promise.resolve(mermaid.init(undefined, mermaidNodes)) - .then(function() { - markdownPreview.querySelectorAll('.mermaid-container.is-loading').forEach(function(c) { - c.classList.remove('is-loading'); - }); - addMermaidToolbars(); - }) + renderMermaidNodeList(Array.from(mermaidNodes), null) .catch(function(e) { console.warn('Mermaid theme re-render failed:', e); markdownPreview.querySelectorAll('.mermaid-container.is-loading').forEach(function(c) { - c.classList.remove('is-loading'); + setDiagramRenderState(c, 'ready'); }); }); } @@ -11613,7 +11881,9 @@ document.addEventListener("DOMContentLoaded", async function () { onClick: button => downloadMermaidSvg(container, button) } ]; - actions.forEach(action => toolbar.appendChild(createDiagramToolbarButton(action))); + actions + .filter(action => !(engine === 'abc' && action.title === 'Open diagram viewer')) + .forEach(action => toolbar.appendChild(createDiagramToolbarButton(action))); container.appendChild(toolbar); } @@ -13685,9 +13955,7 @@ document.addEventListener("DOMContentLoaded", async function () { // Register Service Worker for offline capabilities if ('serviceWorker' in navigator) { window.addEventListener('load', function() { - navigator.serviceWorker.register('sw.js').then(function(registration) { - console.log('ServiceWorker registration successful with scope: ', registration.scope); - }, function(err) { + navigator.serviceWorker.register('sw.js').catch(function(err) { console.log('ServiceWorker registration failed: ', err); }); }); diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index d0c8fa05..ceb8ba87 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -4443,6 +4443,17 @@ html[data-theme="dark"] .d2-diagram > svg:not([data-diagram-native-dark="true"]) object-fit: contain; } +.markmap-diagram > svg { + width: 100% !important; + height: var(--markmap-height, 420px) !important; + min-height: var(--markmap-height, 360px); + max-height: min(70vh, 900px) !important; +} + +.markmap-diagram .markmap-foreign { + color: var(--text-color); +} + .diagram-toolbar { position: absolute; top: 8px; diff --git a/preview-worker.js b/preview-worker.js index 8455b0a7..fead7c1b 100644 --- a/preview-worker.js +++ b/preview-worker.js @@ -366,14 +366,17 @@ function configureMarked() { const krokiLanguages = { 'vega-lite': ['vegalite', 'Vega-Lite'], vegalite: ['vegalite', 'Vega-Lite'], - wavedrom: ['wavedrom', 'WaveDrom'], - markmap: ['markmap', 'Markmap'] + wavedrom: ['wavedrom', 'WaveDrom'] }; if (krokiLanguages[language]) { const [engine, label] = krokiLanguages[language]; const uniqueId = `${engine}-diagram-worker-${krokiIdCounter++}`; return renderDiagramShell(engine, 'kroki-container', 'kroki-diagram', uniqueId, code, label); } + if (language === 'markmap') { + const uniqueId = `markmap-diagram-worker-${krokiIdCounter++}`; + return renderDiagramShell('markmap', 'markmap-container', 'markmap-diagram', uniqueId, code, 'Markmap'); + } if (language === "math") { return `
$$\n${code}\n$$
\n`; diff --git a/script.js b/script.js index 446d0958..83a24d32 100644 --- a/script.js +++ b/script.js @@ -101,7 +101,10 @@ document.addEventListener("DOMContentLoaded", async function () { topojson: 'https://cdnjs.cloudflare.com/ajax/libs/topojson/3.0.2/topojson.min.js', three: 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js', stlLoader: 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/STLLoader.js', - orbitControls: 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js' + orbitControls: 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js', + d3: 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js', + markmapLib: 'https://cdn.jsdelivr.net/npm/markmap-lib@0.18.12/dist/browser/index.iife.js', + markmapView: 'https://cdn.jsdelivr.net/npm/markmap-view@0.18.12/dist/browser/index.js' }; // Resolve local paths for desktop (Neutralinojs) offline support @@ -120,6 +123,9 @@ document.addEventListener("DOMContentLoaded", async function () { CDN.three = '/libs/three.min.js'; CDN.stlLoader = '/libs/STLLoader.js'; CDN.orbitControls = '/libs/OrbitControls.js'; + CDN.d3 = '/libs/d3.min.js'; + CDN.markmapLib = '/libs/markmap-lib.iife.js'; + CDN.markmapView = '/libs/markmap-view.js'; } // Active WebGL / Three.js 3D STL renderers Map for memory cleanup @@ -1118,8 +1124,7 @@ document.addEventListener("DOMContentLoaded", async function () { const krokiLanguages = { 'vega-lite': 'vegalite', vegalite: 'vegalite', - wavedrom: 'wavedrom', - markmap: 'markmap' + wavedrom: 'wavedrom' }; if (krokiLanguages[language]) { const engine = krokiLanguages[language]; @@ -1127,6 +1132,11 @@ document.addEventListener("DOMContentLoaded", async function () { return renderDiagramShell(engine, 'kroki-container', 'kroki-diagram', uniqueId, code); } + if (language === 'markmap') { + const uniqueId = 'markmap-diagram-' + Math.random().toString(36).substr(2, 9); + return renderDiagramShell('markmap', 'markmap-container', 'markmap-diagram', uniqueId, code); + } + if (language === 'math') { return `
$$\n${code}\n$$
\n`; } @@ -1379,7 +1389,7 @@ document.addEventListener("DOMContentLoaded", async function () { graphviz: { label: 'Graphviz', krokiType: 'graphviz' }, vegalite: { label: 'Vega-Lite', krokiType: 'vegalite' }, wavedrom: { label: 'WaveDrom', krokiType: 'wavedrom' }, - markmap: { label: 'Markmap', krokiType: 'markmap' } + markmap: { label: 'Markmap', local: true } }); const DIAGRAM_REQUEST_TIMEOUT = 15000; const DIAGRAM_REQUEST_RETRIES = 2; @@ -1523,7 +1533,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (!adapter) throw new Error(`Unsupported diagram engine: ${engine}`); if (engine === 'markmap') { - return renderMarkmapSvg(source); + return renderMarkmapPreviewSvg(source); } const rendererSource = normalizeDiagramSourceForRenderer(engine, source); @@ -1567,44 +1577,113 @@ document.addEventListener("DOMContentLoaded", async function () { .replace(/"/g, '"'); } - function renderMarkmapSvg(source) { - const nodes = []; - String(source || '').split(/\r?\n/).forEach(line => { - const heading = /^(#{1,6})\s+(.+)$/.exec(line); - const list = /^(\s*)[-*+]\s+(.+)$/.exec(line); - if (!heading && !list) return; - const level = heading ? heading[1].length : Math.floor(list[1].replace(/\t/g, ' ').length / 2) + 2; - const label = (heading ? heading[2] : list[2]).replace(/[`*_~]/g, '').trim(); - if (!label) return; - let parent = -1; - for (let index = nodes.length - 1; index >= 0; index -= 1) { - if (nodes[index].level < level) { - parent = index; - break; - } - } - nodes.push({ level, label, parent }); - }); + let markmapLibraryPromise = null; - if (!nodes.length) nodes.push({ level: 1, label: 'Mind map', parent: -1 }); - const minLevel = Math.min(...nodes.map(node => node.level)); - const horizontalGap = 190; - const verticalGap = 64; - const padding = 32; - nodes.forEach((node, index) => { - node.x = padding + (node.level - minLevel) * horizontalGap; - node.y = padding + index * verticalGap; - node.width = Math.min(Math.max(node.label.length * 7.2 + 28, 96), 176); - node.height = 36; - }); - const width = Math.max(...nodes.map(node => node.x + node.width)) + padding; - const height = nodes[nodes.length - 1].y + nodes[nodes.length - 1].height + padding; - const edges = nodes.filter(node => node.parent >= 0).map(node => { - const parent = nodes[node.parent]; - return ``; - }).join(''); - const boxes = nodes.map((node, index) => `${escapeDiagramXml(node.label)}`).join(''); - return `${edges}${boxes}`; + function ensureMarkmapReady() { + if (window.markmap && window.markmap.Transformer && window.markmap.Markmap) { + return Promise.resolve(window.markmap); + } + if (!markmapLibraryPromise) { + markmapLibraryPromise = loadDiagramLibrary(CDN.d3) + .then(() => loadDiagramLibrary(CDN.markmapLib)) + .then(() => loadDiagramLibrary(CDN.markmapView)) + .then(() => { + if (!window.markmap || !window.markmap.Transformer || !window.markmap.Markmap) { + throw new Error('Official Markmap API did not initialize.'); + } + return window.markmap; + }) + .catch(error => { + markmapLibraryPromise = null; + throw error; + }); + } + return markmapLibraryPromise; + } + + function getMarkmapNodeCount(node) { + if (!node) return 0; + return 1 + (node.children || []).reduce((count, child) => count + getMarkmapNodeCount(child), 0); + } + + function getMarkmapDepth(node) { + if (!node || !node.children || node.children.length === 0) return 1; + return 1 + Math.max(...node.children.map(getMarkmapDepth)); + } + + function getMarkmapHeight(root, compact) { + const nodeCount = getMarkmapNodeCount(root); + const depth = getMarkmapDepth(root); + const baseHeight = compact ? 220 : 340; + const estimated = baseHeight + nodeCount * (compact ? 14 : 20) + depth * 24; + const min = compact ? 260 : 360; + const max = compact ? 520 : 900; + return Math.max(min, Math.min(max, estimated)); + } + + async function buildMarkmap(source) { + const markmap = await ensureMarkmapReady(); + const transformer = new markmap.Transformer(); + const transformed = transformer.transform(String(source || '')); + const assets = transformer.getUsedAssets(transformed.features || {}); + if (assets.styles && markmap.loadCSS) { + markmap.loadCSS(assets.styles); + } + if (assets.scripts && markmap.loadJS) { + await markmap.loadJS(assets.scripts, { + getMarkmap: () => markmap + }); + } + const jsonOptions = transformed.frontmatter && typeof transformed.frontmatter.markmap === 'object' + ? transformed.frontmatter.markmap + : undefined; + const options = markmap.deriveOptions + ? markmap.deriveOptions(jsonOptions || {}) + : jsonOptions; + return { markmap, root: transformed.root, options }; + } + + async function renderMarkmapIntoElement(node, source, compact) { + const { markmap, root, options } = await buildMarkmap(source); + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const height = getMarkmapHeight(root, compact); + const measuredWidth = node.getBoundingClientRect ? node.getBoundingClientRect().width : 0; + const width = compact ? 960 : Math.max(640, Math.min(1200, Math.round(measuredWidth || 960))); + svg.setAttribute('width', String(width)); + svg.setAttribute('height', String(height)); + svg.setAttribute('role', 'img'); + svg.setAttribute('aria-label', 'Markmap preview'); + node.style.setProperty('--markmap-height', `${height}px`); + svg.style.setProperty('--markmap-height', `${height}px`); + svg.style.width = '100%'; + svg.style.height = `${height}px`; + svg.style.minHeight = `${height}px`; + svg.style.maxHeight = compact ? '100%' : 'min(70vh, 900px)'; + node.replaceChildren(svg); + const instance = markmap.Markmap.create(svg, options, root); + await new Promise(resolve => requestAnimationFrame(resolve)); + if (instance && typeof instance.fit === 'function') { + instance.fit(); + } + normalizeDiagramSvg(node, 'markmap', source); + return svg; + } + + async function renderMarkmapPreviewSvg(source) { + const wrapper = document.createElement('div'); + wrapper.style.position = 'fixed'; + wrapper.style.left = '-10000px'; + wrapper.style.top = '0'; + wrapper.style.width = '960px'; + wrapper.style.height = '520px'; + wrapper.style.pointerEvents = 'none'; + document.body.appendChild(wrapper); + try { + const svg = await renderMarkmapIntoElement(wrapper, source, true); + return new XMLSerializer().serializeToString(svg); + } finally { + wrapper.remove(); + } } function importDiagramSvg(node, svgText, engine, source) { @@ -1737,13 +1816,20 @@ document.addEventListener("DOMContentLoaded", async function () { } async function renderRemoteDiagramNode(node, engine, context) { - const container = node.closest('[data-diagram-engine], .plantuml-container, .d2-container, .graphviz-container, .kroki-container'); + const container = node.closest('[data-diagram-engine], .plantuml-container, .d2-container, .graphviz-container, .kroki-container, .markmap-container'); const originalCode = node.getAttribute('data-original-code'); if (!container || !originalCode) return; const source = decodeURIComponent(originalCode); setDiagramRenderState(container, 'loading', `Rendering ${REMOTE_DIAGRAM_ENGINES[engine].label}…`); try { + if (engine === 'markmap') { + await renderMarkmapIntoElement(node, source, false); + if ((context && context.renderId !== previewRenderGeneration) || !document.body.contains(node)) return; + setDiagramRenderState(container, 'ready'); + mountDiagramViewer(container, engine); + return; + } const svgText = await renderDiagramThroughAdapter(engine, source); if ((context && context.renderId !== previewRenderGeneration) || !document.body.contains(node)) return; importDiagramSvg(node, svgText, engine, source); @@ -3776,6 +3862,7 @@ document.addEventListener("DOMContentLoaded", async function () { ['.plantuml-diagram', 'plantuml'], ['.d2-diagram', 'd2'], ['.graphviz-diagram', 'graphviz'], + ['.markmap-diagram', 'markmap'], ['.kroki-diagram', null] ]; const renderAdapterTargets = function() { diff --git a/styles.css b/styles.css index d0c8fa05..ceb8ba87 100644 --- a/styles.css +++ b/styles.css @@ -4443,6 +4443,17 @@ html[data-theme="dark"] .d2-diagram > svg:not([data-diagram-native-dark="true"]) object-fit: contain; } +.markmap-diagram > svg { + width: 100% !important; + height: var(--markmap-height, 420px) !important; + min-height: var(--markmap-height, 360px); + max-height: min(70vh, 900px) !important; +} + +.markmap-diagram .markmap-foreign { + color: var(--text-color); +} + .diagram-toolbar { position: absolute; top: 8px; From d8f9014ccec679a6825dee9ce8e3aa7e321431a3 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Fri, 26 Jun 2026 18:06:01 +0530 Subject: [PATCH 2/3] Center official Markmap diagrams --- desktop-app/resources/js/script.js | 39 ++++++++++++++++++++---------- desktop-app/resources/styles.css | 2 +- script.js | 39 ++++++++++++++++++++---------- styles.css | 2 +- 4 files changed, 54 insertions(+), 28 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 83a24d32..4c4bb914 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -1611,14 +1611,20 @@ document.addEventListener("DOMContentLoaded", async function () { return 1 + Math.max(...node.children.map(getMarkmapDepth)); } - function getMarkmapHeight(root, compact) { + function getMarkmapViewportSize(node, root, compact) { const nodeCount = getMarkmapNodeCount(root); const depth = getMarkmapDepth(root); - const baseHeight = compact ? 220 : 340; - const estimated = baseHeight + nodeCount * (compact ? 14 : 20) + depth * 24; - const min = compact ? 260 : 360; - const max = compact ? 520 : 900; - return Math.max(min, Math.min(max, estimated)); + const viewer = node.closest ? node.closest('.diagram-viewer') : null; + const measuredWidth = Math.max( + node.getBoundingClientRect ? node.getBoundingClientRect().width : 0, + viewer && viewer.getBoundingClientRect ? viewer.getBoundingClientRect().width : 0 + ); + const width = compact ? 960 : Math.max(1, Math.min(1400, Math.round(measuredWidth || 960))); + const estimatedHeight = width * 0.56 + nodeCount * (compact ? 4 : 6) + depth * 12; + const minHeight = compact ? 360 : 480; + const maxHeight = compact ? 560 : 760; + const height = Math.max(minHeight, Math.min(maxHeight, Math.round(estimatedHeight))); + return { width, height }; } async function buildMarkmap(source) { @@ -1646,9 +1652,7 @@ document.addEventListener("DOMContentLoaded", async function () { async function renderMarkmapIntoElement(node, source, compact) { const { markmap, root, options } = await buildMarkmap(source); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - const height = getMarkmapHeight(root, compact); - const measuredWidth = node.getBoundingClientRect ? node.getBoundingClientRect().width : 0; - const width = compact ? 960 : Math.max(640, Math.min(1200, Math.round(measuredWidth || 960))); + const { width, height } = getMarkmapViewportSize(node, root, compact); svg.setAttribute('width', String(width)); svg.setAttribute('height', String(height)); svg.setAttribute('role', 'img'); @@ -1661,10 +1665,19 @@ document.addEventListener("DOMContentLoaded", async function () { svg.style.maxHeight = compact ? '100%' : 'min(70vh, 900px)'; node.replaceChildren(svg); const instance = markmap.Markmap.create(svg, options, root); - await new Promise(resolve => requestAnimationFrame(resolve)); - if (instance && typeof instance.fit === 'function') { - instance.fit(); - } + const fitMarkmap = () => { + if (instance && typeof instance.fit === 'function' && document.body.contains(svg)) { + instance.fit(); + } + }; + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + fitMarkmap(); + setTimeout(fitMarkmap, 120); + setTimeout(fitMarkmap, 500); + svg.querySelectorAll('image, img').forEach(image => { + image.addEventListener('load', fitMarkmap, { once: true }); + image.addEventListener('error', fitMarkmap, { once: true }); + }); normalizeDiagramSvg(node, 'markmap', source); return svg; } diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index ceb8ba87..7afe5fb0 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -4447,7 +4447,7 @@ html[data-theme="dark"] .d2-diagram > svg:not([data-diagram-native-dark="true"]) width: 100% !important; height: var(--markmap-height, 420px) !important; min-height: var(--markmap-height, 360px); - max-height: min(70vh, 900px) !important; + max-height: none !important; } .markmap-diagram .markmap-foreign { diff --git a/script.js b/script.js index 83a24d32..4c4bb914 100644 --- a/script.js +++ b/script.js @@ -1611,14 +1611,20 @@ document.addEventListener("DOMContentLoaded", async function () { return 1 + Math.max(...node.children.map(getMarkmapDepth)); } - function getMarkmapHeight(root, compact) { + function getMarkmapViewportSize(node, root, compact) { const nodeCount = getMarkmapNodeCount(root); const depth = getMarkmapDepth(root); - const baseHeight = compact ? 220 : 340; - const estimated = baseHeight + nodeCount * (compact ? 14 : 20) + depth * 24; - const min = compact ? 260 : 360; - const max = compact ? 520 : 900; - return Math.max(min, Math.min(max, estimated)); + const viewer = node.closest ? node.closest('.diagram-viewer') : null; + const measuredWidth = Math.max( + node.getBoundingClientRect ? node.getBoundingClientRect().width : 0, + viewer && viewer.getBoundingClientRect ? viewer.getBoundingClientRect().width : 0 + ); + const width = compact ? 960 : Math.max(1, Math.min(1400, Math.round(measuredWidth || 960))); + const estimatedHeight = width * 0.56 + nodeCount * (compact ? 4 : 6) + depth * 12; + const minHeight = compact ? 360 : 480; + const maxHeight = compact ? 560 : 760; + const height = Math.max(minHeight, Math.min(maxHeight, Math.round(estimatedHeight))); + return { width, height }; } async function buildMarkmap(source) { @@ -1646,9 +1652,7 @@ document.addEventListener("DOMContentLoaded", async function () { async function renderMarkmapIntoElement(node, source, compact) { const { markmap, root, options } = await buildMarkmap(source); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - const height = getMarkmapHeight(root, compact); - const measuredWidth = node.getBoundingClientRect ? node.getBoundingClientRect().width : 0; - const width = compact ? 960 : Math.max(640, Math.min(1200, Math.round(measuredWidth || 960))); + const { width, height } = getMarkmapViewportSize(node, root, compact); svg.setAttribute('width', String(width)); svg.setAttribute('height', String(height)); svg.setAttribute('role', 'img'); @@ -1661,10 +1665,19 @@ document.addEventListener("DOMContentLoaded", async function () { svg.style.maxHeight = compact ? '100%' : 'min(70vh, 900px)'; node.replaceChildren(svg); const instance = markmap.Markmap.create(svg, options, root); - await new Promise(resolve => requestAnimationFrame(resolve)); - if (instance && typeof instance.fit === 'function') { - instance.fit(); - } + const fitMarkmap = () => { + if (instance && typeof instance.fit === 'function' && document.body.contains(svg)) { + instance.fit(); + } + }; + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + fitMarkmap(); + setTimeout(fitMarkmap, 120); + setTimeout(fitMarkmap, 500); + svg.querySelectorAll('image, img').forEach(image => { + image.addEventListener('load', fitMarkmap, { once: true }); + image.addEventListener('error', fitMarkmap, { once: true }); + }); normalizeDiagramSvg(node, 'markmap', source); return svg; } diff --git a/styles.css b/styles.css index ceb8ba87..7afe5fb0 100644 --- a/styles.css +++ b/styles.css @@ -4447,7 +4447,7 @@ html[data-theme="dark"] .d2-diagram > svg:not([data-diagram-native-dark="true"]) width: 100% !important; height: var(--markmap-height, 420px) !important; min-height: var(--markmap-height, 360px); - max-height: min(70vh, 900px) !important; + max-height: none !important; } .markmap-diagram .markmap-foreign { From ee50f659c4706792c36054a1ea8d2e1f5b2c8e47 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Fri, 26 Jun 2026 18:14:39 +0530 Subject: [PATCH 3/3] Preserve nested Markmap code fences --- desktop-app/resources/js/preview-worker.js | 69 +++++++++++++++++++++- desktop-app/resources/js/script.js | 69 +++++++++++++++++++++- preview-worker.js | 69 +++++++++++++++++++++- script.js | 69 +++++++++++++++++++++- 4 files changed, 272 insertions(+), 4 deletions(-) diff --git a/desktop-app/resources/js/preview-worker.js b/desktop-app/resources/js/preview-worker.js index fead7c1b..01f57686 100644 --- a/desktop-app/resources/js/preview-worker.js +++ b/desktop-app/resources/js/preview-worker.js @@ -403,6 +403,72 @@ function configureMarked() { return `${text}`; }; + function normalizeMarkmapFences(markdown) { + const lines = String(markdown || '').split(/\r?\n/); + const output = []; + let index = 0; + + while (index < lines.length) { + const opening = lines[index].match(/^([ \t]{0,3})(`{3,}|~{3,})([ \t]*)(.*)$/); + const info = opening ? opening[4].trim() : ''; + if (!opening || !/^markmap(?:\s|$)/i.test(info)) { + output.push(lines[index]); + index += 1; + continue; + } + + const indent = opening[1]; + const fence = opening[2]; + const marker = fence[0]; + const content = []; + let nestedFence = null; + let maxInnerFenceLength = fence.length; + let closeIndex = -1; + + for (let scan = index + 1; scan < lines.length; scan += 1) { + const line = lines[scan]; + const fenceMatch = line.match(/^[ \t]{0,3}(`{3,}|~{3,})([ \t]*.*)$/); + if (fenceMatch) { + const currentFence = fenceMatch[1]; + const currentMarker = currentFence[0]; + const tail = fenceMatch[2].trim(); + if (currentMarker === marker) { + maxInnerFenceLength = Math.max(maxInnerFenceLength, currentFence.length); + } + + if (nestedFence) { + if (currentMarker === nestedFence.marker && currentFence.length >= nestedFence.length && tail === '') { + nestedFence = null; + } + } else if (currentMarker === marker && currentFence.length >= fence.length && tail === '') { + closeIndex = scan; + break; + } else if (tail !== '') { + nestedFence = { + marker: currentMarker, + length: currentFence.length + }; + } + } + content.push(line); + } + + if (closeIndex === -1) { + output.push(lines[index]); + index += 1; + continue; + } + + const normalizedFence = marker.repeat(maxInnerFenceLength + 1); + output.push(`${indent}${normalizedFence}${opening[3]}${opening[4]}`); + output.push(...content); + output.push(`${indent}${normalizedFence}`); + index = closeIndex + 1; + } + + return output.join('\n'); + } + marked.use({ extensions: [ blockMathExtension, @@ -415,7 +481,8 @@ function configureMarked() { preprocess(markdown) { if (suppressFootnotePreprocess) return markdown; resetExtendedMarkdownState(); - const protectedMarkdown = markdown.replace(/\\\$/g, "$"); + const normalizedMarkdown = normalizeMarkmapFences(markdown); + const protectedMarkdown = normalizedMarkdown.replace(/\\\$/g, "$"); return applyFootnotes(extractFootnoteDefinitions(protectedMarkdown)); }, }, diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 4c4bb914..e0ec6f37 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -1162,6 +1162,72 @@ document.addEventListener("DOMContentLoaded", async function () { return `${text}`; }; + function normalizeMarkmapFences(markdown) { + const lines = String(markdown || '').split(/\r?\n/); + const output = []; + let index = 0; + + while (index < lines.length) { + const opening = lines[index].match(/^([ \t]{0,3})(`{3,}|~{3,})([ \t]*)(.*)$/); + const info = opening ? opening[4].trim() : ''; + if (!opening || !/^markmap(?:\s|$)/i.test(info)) { + output.push(lines[index]); + index += 1; + continue; + } + + const indent = opening[1]; + const fence = opening[2]; + const marker = fence[0]; + const content = []; + let nestedFence = null; + let maxInnerFenceLength = fence.length; + let closeIndex = -1; + + for (let scan = index + 1; scan < lines.length; scan += 1) { + const line = lines[scan]; + const fenceMatch = line.match(/^[ \t]{0,3}(`{3,}|~{3,})([ \t]*.*)$/); + if (fenceMatch) { + const currentFence = fenceMatch[1]; + const currentMarker = currentFence[0]; + const tail = fenceMatch[2].trim(); + if (currentMarker === marker) { + maxInnerFenceLength = Math.max(maxInnerFenceLength, currentFence.length); + } + + if (nestedFence) { + if (currentMarker === nestedFence.marker && currentFence.length >= nestedFence.length && tail === '') { + nestedFence = null; + } + } else if (currentMarker === marker && currentFence.length >= fence.length && tail === '') { + closeIndex = scan; + break; + } else if (tail !== '') { + nestedFence = { + marker: currentMarker, + length: currentFence.length + }; + } + } + content.push(line); + } + + if (closeIndex === -1) { + output.push(lines[index]); + index += 1; + continue; + } + + const normalizedFence = marker.repeat(maxInnerFenceLength + 1); + output.push(`${indent}${normalizedFence}${opening[3]}${opening[4]}`); + output.push(...content); + output.push(`${indent}${normalizedFence}`); + index = closeIndex + 1; + } + + return output.join('\n'); + } + marked.use({ extensions: [ blockMathExtension, @@ -1178,7 +1244,8 @@ document.addEventListener("DOMContentLoaded", async function () { resetExtendedMarkdownState(); // ✅ Replace escaped dollar signs before marked.js strips the backslash. // This prevents MathJax from treating lone $ as a math delimiter. - const protectedMarkdown = markdown.replace(/\\\$/g, '$'); + const normalizedMarkdown = normalizeMarkmapFences(markdown); + const protectedMarkdown = normalizedMarkdown.replace(/\\\$/g, '$'); return applyFootnotes(extractFootnoteDefinitions(protectedMarkdown)); }, }, diff --git a/preview-worker.js b/preview-worker.js index fead7c1b..01f57686 100644 --- a/preview-worker.js +++ b/preview-worker.js @@ -403,6 +403,72 @@ function configureMarked() { return `${text}`; }; + function normalizeMarkmapFences(markdown) { + const lines = String(markdown || '').split(/\r?\n/); + const output = []; + let index = 0; + + while (index < lines.length) { + const opening = lines[index].match(/^([ \t]{0,3})(`{3,}|~{3,})([ \t]*)(.*)$/); + const info = opening ? opening[4].trim() : ''; + if (!opening || !/^markmap(?:\s|$)/i.test(info)) { + output.push(lines[index]); + index += 1; + continue; + } + + const indent = opening[1]; + const fence = opening[2]; + const marker = fence[0]; + const content = []; + let nestedFence = null; + let maxInnerFenceLength = fence.length; + let closeIndex = -1; + + for (let scan = index + 1; scan < lines.length; scan += 1) { + const line = lines[scan]; + const fenceMatch = line.match(/^[ \t]{0,3}(`{3,}|~{3,})([ \t]*.*)$/); + if (fenceMatch) { + const currentFence = fenceMatch[1]; + const currentMarker = currentFence[0]; + const tail = fenceMatch[2].trim(); + if (currentMarker === marker) { + maxInnerFenceLength = Math.max(maxInnerFenceLength, currentFence.length); + } + + if (nestedFence) { + if (currentMarker === nestedFence.marker && currentFence.length >= nestedFence.length && tail === '') { + nestedFence = null; + } + } else if (currentMarker === marker && currentFence.length >= fence.length && tail === '') { + closeIndex = scan; + break; + } else if (tail !== '') { + nestedFence = { + marker: currentMarker, + length: currentFence.length + }; + } + } + content.push(line); + } + + if (closeIndex === -1) { + output.push(lines[index]); + index += 1; + continue; + } + + const normalizedFence = marker.repeat(maxInnerFenceLength + 1); + output.push(`${indent}${normalizedFence}${opening[3]}${opening[4]}`); + output.push(...content); + output.push(`${indent}${normalizedFence}`); + index = closeIndex + 1; + } + + return output.join('\n'); + } + marked.use({ extensions: [ blockMathExtension, @@ -415,7 +481,8 @@ function configureMarked() { preprocess(markdown) { if (suppressFootnotePreprocess) return markdown; resetExtendedMarkdownState(); - const protectedMarkdown = markdown.replace(/\\\$/g, "$"); + const normalizedMarkdown = normalizeMarkmapFences(markdown); + const protectedMarkdown = normalizedMarkdown.replace(/\\\$/g, "$"); return applyFootnotes(extractFootnoteDefinitions(protectedMarkdown)); }, }, diff --git a/script.js b/script.js index 4c4bb914..e0ec6f37 100644 --- a/script.js +++ b/script.js @@ -1162,6 +1162,72 @@ document.addEventListener("DOMContentLoaded", async function () { return `${text}`; }; + function normalizeMarkmapFences(markdown) { + const lines = String(markdown || '').split(/\r?\n/); + const output = []; + let index = 0; + + while (index < lines.length) { + const opening = lines[index].match(/^([ \t]{0,3})(`{3,}|~{3,})([ \t]*)(.*)$/); + const info = opening ? opening[4].trim() : ''; + if (!opening || !/^markmap(?:\s|$)/i.test(info)) { + output.push(lines[index]); + index += 1; + continue; + } + + const indent = opening[1]; + const fence = opening[2]; + const marker = fence[0]; + const content = []; + let nestedFence = null; + let maxInnerFenceLength = fence.length; + let closeIndex = -1; + + for (let scan = index + 1; scan < lines.length; scan += 1) { + const line = lines[scan]; + const fenceMatch = line.match(/^[ \t]{0,3}(`{3,}|~{3,})([ \t]*.*)$/); + if (fenceMatch) { + const currentFence = fenceMatch[1]; + const currentMarker = currentFence[0]; + const tail = fenceMatch[2].trim(); + if (currentMarker === marker) { + maxInnerFenceLength = Math.max(maxInnerFenceLength, currentFence.length); + } + + if (nestedFence) { + if (currentMarker === nestedFence.marker && currentFence.length >= nestedFence.length && tail === '') { + nestedFence = null; + } + } else if (currentMarker === marker && currentFence.length >= fence.length && tail === '') { + closeIndex = scan; + break; + } else if (tail !== '') { + nestedFence = { + marker: currentMarker, + length: currentFence.length + }; + } + } + content.push(line); + } + + if (closeIndex === -1) { + output.push(lines[index]); + index += 1; + continue; + } + + const normalizedFence = marker.repeat(maxInnerFenceLength + 1); + output.push(`${indent}${normalizedFence}${opening[3]}${opening[4]}`); + output.push(...content); + output.push(`${indent}${normalizedFence}`); + index = closeIndex + 1; + } + + return output.join('\n'); + } + marked.use({ extensions: [ blockMathExtension, @@ -1178,7 +1244,8 @@ document.addEventListener("DOMContentLoaded", async function () { resetExtendedMarkdownState(); // ✅ Replace escaped dollar signs before marked.js strips the backslash. // This prevents MathJax from treating lone $ as a math delimiter. - const protectedMarkdown = markdown.replace(/\\\$/g, '$'); + const normalizedMarkdown = normalizeMarkmapFences(markdown); + const protectedMarkdown = normalizedMarkdown.replace(/\\\$/g, '$'); return applyFootnotes(extractFootnoteDefinitions(protectedMarkdown)); }, },