From 5abb991e5ec1bfd86bd2207c1995b10e1050a862 Mon Sep 17 00:00:00 2001 From: Tron Date: Tue, 25 Nov 2025 09:37:06 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=E5=86=85=E7=BD=AE=E6=80=9D=E7=BB=B4?= =?UTF-8?q?=E5=AF=BC=E5=9B=BE=E6=94=B9=E4=B8=BAmarkmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现编辑内容动态更新,只重新渲染编辑节点 - 导出图片按钮实现导出svg - 添加重置视图按钮 --- app/src/protyle/render/mindmapRender.ts | 61 +++++++++---- app/src/protyle/render/util.ts | 55 ++++++++--- app/src/protyle/toolbar/index.ts | 116 ++++++++++++++++++++++-- app/src/protyle/wysiwyg/index.ts | 18 ++++ 4 files changed, 213 insertions(+), 37 deletions(-) diff --git a/app/src/protyle/render/mindmapRender.ts b/app/src/protyle/render/mindmapRender.ts index eefb3a70a..5734fc526 100644 --- a/app/src/protyle/render/mindmapRender.ts +++ b/app/src/protyle/render/mindmapRender.ts @@ -29,13 +29,15 @@ export const mindmapRender = (element: Element, cdn = Constants.PROTYLE_CDN) => return; } if (!e.firstElementChild.classList.contains("protyle-icons")) { - e.insertAdjacentHTML("afterbegin", genIconHTML(wysiswgElement)); + // Add home icon for mindmap (reset view), edit and more + e.insertAdjacentHTML("afterbegin", genIconHTML(wysiswgElement, ["home", "edit", "more"])); } const renderElement = e.firstElementChild.nextElementSibling as HTMLElement; if (!e.getAttribute("data-content")) { renderElement.innerHTML = `${Constants.ZWSP}`; return; } + let transformer: any = null; try { // create or reuse container for markmap if (!renderElement.lastElementChild || renderElement.childElementCount === 1) { @@ -67,27 +69,24 @@ export const mindmapRender = (element: Element, cdn = Constants.PROTYLE_CDN) => // Try to obtain markmap entry from loaded bundles (single unified reference) const mm: any = (window as any).markmap || (window as any).Markmap || null; - // Prefer the Transformer API when available (example usage: transform -> getUsedAssets -> load assets -> create) - let data: any = null; + // Prefer the Transformer API when available (transform -> getUsedAssets -> load assets -> create) let rootData: any = null; - try { - if (mm && typeof mm.Transformer === "function") { - const transformer = new mm.Transformer(); + if (mm) { + if (typeof mm.Transformer === "function") { + transformer = new mm.Transformer(); // transform markdown -> { root, features } const tx = transformer.transform(md) || {}; rootData = tx.root || null; - // select asset getter: prefer getUsedAssets (only assets used), fallback to getAssets + // select asset getter: prefer getUsedAssets then getAssets const assetsGetter = typeof transformer.getUsedAssets === "function" ? "getUsedAssets" : (typeof transformer.getAssets === "function" ? "getAssets" : null); if (assetsGetter) { const assets = (transformer as any)[assetsGetter](tx.features || {}); const styles = assets && assets.styles; const scripts = assets && assets.scripts; - // Use markmap provided loaders when available const loadCSS = typeof mm.loadCSS === "function" ? mm.loadCSS : null; const loadJS = typeof mm.loadJS === "function" ? mm.loadJS : null; - if (styles && loadCSS) { try { loadCSS(styles); } catch (err) { /* ignore */ } } @@ -96,16 +95,17 @@ export const mindmapRender = (element: Element, cdn = Constants.PROTYLE_CDN) => } } } else { - // fallback: try older transform functions - const transformFn = mm && (mm.transform || (window as any).markmap && (window as any).markmap.transform); + // fallback: try older transform functions (may return root-like object) + const transformFn = mm.transform || (window as any).markmap && (window as any).markmap.transform; if (typeof transformFn === "function") { - data = transformFn(md); + try { + const tx = transformFn(md) || {}; + rootData = tx.root || tx || null; + } catch (err) { + rootData = null; + } } } - } catch (e) { - // leave data/rootData null and let downstream handle it - data = null; - rootData = null; } // container for svg @@ -127,10 +127,15 @@ export const mindmapRender = (element: Element, cdn = Constants.PROTYLE_CDN) => const options = { duration: 0, // 🔥 禁用动画,设为0 }; + // create and store markmap + transformer on the element so callers can update instead of re-creating if (MarkmapCtor && typeof MarkmapCtor.create === "function") { if (rootData) { - // When Transformer was used we have a `root` structure - MarkmapCtor.create(svg, options, rootData); + const markmapInstance = MarkmapCtor.create(svg, options, rootData); + const mmEntry: any = (e as any).__markmap || {}; + mmEntry.transformer = transformer || mmEntry.transformer || null; + mmEntry.markmap = markmapInstance; + mmEntry.options = options; + (e as any).__markmap = mmEntry; } } else { throw new Error("Markmap not available"); @@ -140,6 +145,26 @@ export const mindmapRender = (element: Element, cdn = Constants.PROTYLE_CDN) => renderElement.innerHTML = `${Constants.ZWSP}
Mindmap render error:
${error}
`; } e.setAttribute("data-render", "true"); + // expose a small helper to update content (callable by toolbar) + try { + const mmEntry: any = (e as any).__markmap; + if (mmEntry && mmEntry.transformer && mmEntry.markmap) { + (e as any).__markmap.updateContent = (newMarkdown: string) => { + try { + const tx2 = mmEntry.transformer.transform(newMarkdown) || {}; + const root2 = tx2.root || null; + if (mmEntry.markmap && typeof mmEntry.markmap.setData === "function") { + mmEntry.markmap.setData(root2, mmEntry.options); + } + } catch (e) { + // ignore update errors + console.error("markmap updateContent error", e); + } + }; + } + } catch (e) { + // ignore + } }); }); }; diff --git a/app/src/protyle/render/util.ts b/app/src/protyle/render/util.ts index dddccc36e..51f91702e 100644 --- a/app/src/protyle/render/util.ts +++ b/app/src/protyle/render/util.ts @@ -11,18 +11,51 @@ export const genIconHTML = (element?: false | HTMLElement, actions = ["edit", "m return '
'; } } - if (actions.length === 3) { - return `
- - - -
`; - } else { - return `
- - -
`; + const mapActionToHTML = (action: string, isFirst: boolean, isLast: boolean) => { + const classList = ["b3-tooltips__nw", "b3-tooltips", "protyle-icon"]; + if (isFirst) classList.push("protyle-icon--first"); + if (isLast) classList.push("protyle-icon--last"); + let aria = ""; + let className = ""; + let icon = ""; + switch (action) { + case "reload": + case "refresh": + aria = window.siyuan.languages.refresh; + className = "protyle-action__reload"; + icon = "iconRefresh"; + break; + case "home": + case "fit": + aria = window.siyuan.languages.reset; + className = "protyle-action__home"; + icon = "#iconHistory"; + break; + case "edit": + aria = window.siyuan.languages.edit; + className = "protyle-action__edit"; + icon = "iconEdit"; + break; + case "more": + default: + aria = window.siyuan.languages.more; + className = "protyle-action__menu"; + icon = "iconMore"; + break; + } + // Only the edit button honors read-only enable + const hidden = (action === "edit" && !enable) ? " fn__none" : ""; + return ``; + }; + const res: string[] = []; + for (let i = 0; i < actions.length; i++) { + const isFirst = i === 0; + const isLast = i === actions.length - 1; + res.push(mapActionToHTML(actions[i], isFirst, isLast)); } + return `
+ ${res.join("\n ")} +
`; }; export const genRenderFrame = (renderElement: Element) => { diff --git a/app/src/protyle/toolbar/index.ts b/app/src/protyle/toolbar/index.ts index 7998617e6..284c98ce8 100644 --- a/app/src/protyle/toolbar/index.ts +++ b/app/src/protyle/toolbar/index.ts @@ -987,6 +987,17 @@ export class Toolbar { break; case "refresh": btnElement.classList.toggle("block__icon--active"); + // If this is a mindmap, call markmap instance fit() to reset view + if (renderElement.getAttribute("data-subtype") === "mindmap") { + const mmEntry: any = (renderElement as any).__markmap || (nodeElement as any).__markmap || null; + if (mmEntry && mmEntry.markmap && typeof mmEntry.markmap.fit === "function") { + try { + mmEntry.markmap.fit(); + } catch (e) { + console.error("markmap fit error", e); + } + } + } break; case "before": insertEmptyBlock(protyle, "beforebegin", id); @@ -1017,6 +1028,55 @@ export class Toolbar { }); return; } + // mindmap: try to export SVG directly using the rendered SVG + if (renderElement.getAttribute("data-subtype") === "mindmap") { + try { + // find rendered svg inside the renderElement + const svgElement = renderElement.querySelector("svg.markmap") as SVGSVGElement; + if (!svgElement) { + throw new Error("SVG not found"); + } + const clonedSvg = svgElement.cloneNode(true) as SVGSVGElement; + const bbox = svgElement.getBBox(); + clonedSvg.setAttribute("viewBox", `${bbox.x-20} ${bbox.y-20} ${bbox.width+40} ${bbox.height+40}`); + clonedSvg.setAttribute("width", String(bbox.width+40)); + clonedSvg.setAttribute("height", String(bbox.height+40)); + clonedSvg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + clonedSvg.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink"); + + // inline styles + const styles = Array.from(document.styleSheets) + .filter(sheet => { + try { + return sheet.cssRules; + } catch (e) { + return false; + } + }) + .reduce((acc, sheet) => { + return acc + Array.from((sheet as CSSStyleSheet).cssRules).map(rule => rule.cssText).join("\n"); + }, ""); + + const styleElement = document.createElementNS("http://www.w3.org/2000/svg", "style"); + styleElement.textContent = styles; + clonedSvg.insertBefore(styleElement, clonedSvg.firstChild); + + const serializer = new XMLSerializer(); + const svgString = serializer.serializeToString(clonedSvg); + const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" }); + const formData = new FormData(); + formData.append("file", blob); + formData.append("type", "image/svg+xml"); + fetchPost("/api/export/exportAsFile", formData, (response) => { + openByMobile(response.data.file); + hideMessage(msgId); + }); + return; + } catch (err) { + console.error("mindmap export error", err); + // fall through to html-to-image fallback + } + } setTimeout(() => { addScript("/stage/protyle/js/html-to-image.min.js?v=1.11.13", "protyleHtml2image").then(() => { (renderElement as HTMLHtmlElement).style.display = "inline-block"; @@ -1067,11 +1127,32 @@ export class Toolbar { } }); } else { - renderElement.setAttribute("data-content", Lute.EscapeHTMLStr(textElement.value)); - renderElement.removeAttribute("data-render"); + // mindmap: try incremental update via stored markmap instance + const isMindmap = renderElement.getAttribute("data-subtype") === "mindmap"; + const mmEntry: any = (renderElement as any).__markmap || (nodeElement as any).__markmap || null; + if (isMindmap && mmEntry && mmEntry.transformer && mmEntry.markmap && this.subElement.querySelector('[data-type="refresh"]').classList.contains("block__icon--active")) { + try { + // update markmap in-place + const txu = mmEntry.transformer.transform(textElement.value) || {}; + const rootu = txu.root || null; + mmEntry.markmap.setData(rootu, mmEntry.options); + renderElement.setAttribute("data-content", Lute.EscapeHTMLStr(textElement.value)); + } catch (err) { + // fallback to re-render + renderElement.setAttribute("data-content", Lute.EscapeHTMLStr(textElement.value)); + renderElement.removeAttribute("data-render"); + } + } else { + renderElement.setAttribute("data-content", Lute.EscapeHTMLStr(textElement.value)); + renderElement.removeAttribute("data-render"); + } } - if (!types.includes("NodeBlockQueryEmbed") || !types.includes("NodeHTMLBlock") || !isInlineMemo) { - processRender(renderElement); + // If mindmap was updated via markmap.setData above, no need to run full processRender. + const didIncrementalUpdate = renderElement.getAttribute("data-subtype") === "mindmap" && ((renderElement as any).__markmap && (renderElement as any).__markmap.markmap) && this.subElement.querySelector('[data-type="refresh"]').classList.contains("block__icon--active"); + if (!didIncrementalUpdate) { + if (!types.includes("NodeBlockQueryEmbed") || !types.includes("NodeHTMLBlock") || !isInlineMemo) { + processRender(renderElement); + } } event.stopPropagation(); }); @@ -1158,11 +1239,30 @@ export class Toolbar { } else { renderElement.setAttribute("data-content", Lute.EscapeHTMLStr(textElement.value)); renderElement.removeAttribute("data-render"); - if (types.includes("NodeBlockQueryEmbed")) { - blockRender(protyle, renderElement); - (renderElement as HTMLElement).style.height = ""; + // If this is a mindmap and we have a stored markmap instance, prefer incremental update + const isMindmap = renderElement.getAttribute("data-subtype") === "mindmap"; + const mmEntry: any = (renderElement as any).__markmap || (nodeElement as any).__markmap || null; + if (isMindmap && mmEntry && mmEntry.transformer && mmEntry.markmap) { + try { + const txu = mmEntry.transformer.transform(textElement.value) || {}; + const rootu = txu.root || null; + mmEntry.markmap.setData(rootu, mmEntry.options); + } catch (err) { + // fallback to full render + if (types.includes("NodeBlockQueryEmbed")) { + blockRender(protyle, renderElement); + (renderElement as HTMLElement).style.height = ""; + } else { + processRender(renderElement); + } + } } else { - processRender(renderElement); + if (types.includes("NodeBlockQueryEmbed")) { + blockRender(protyle, renderElement); + (renderElement as HTMLElement).style.height = ""; + } else { + processRender(renderElement); + } } } diff --git a/app/src/protyle/wysiwyg/index.ts b/app/src/protyle/wysiwyg/index.ts index c13b55185..e31ba33b7 100644 --- a/app/src/protyle/wysiwyg/index.ts +++ b/app/src/protyle/wysiwyg/index.ts @@ -2886,6 +2886,24 @@ export class WYSIWYG { return; } + const fitElement = hasClosestByClassName(event.target, "protyle-action__home"); + if (fitElement) { + const blockElement = hasClosestBlock(fitElement); + if (blockElement && blockElement.getAttribute("data-subtype") === "mindmap") { + try { + const mmEntry: any = (blockElement as any).__markmap || null; + if (mmEntry && mmEntry.markmap && typeof mmEntry.markmap.fit === "function") { + mmEntry.markmap.fit(); + } + } catch (e) { + console.error("markmap fit error", e); + } + } + event.stopPropagation(); + event.preventDefault(); + return; + } + const languageElement = hasClosestByClassName(event.target, "protyle-action__language"); if (languageElement && !protyle.disabled && !ctrlIsPressed) { protyle.toolbar.showCodeLanguage(protyle, [languageElement]);