内置思维导图改为markmap

- 实现编辑内容动态更新,只重新渲染编辑节点
- 导出图片按钮实现导出svg
- 添加重置视图按钮
This commit is contained in:
Tron 2025-11-25 09:37:06 +08:00
parent 702d5fdda3
commit 5abb991e5e
4 changed files with 213 additions and 37 deletions

View file

@ -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 = `<span style="position: absolute;left:0;top:0;width: 1px;">${Constants.ZWSP}</span>`;
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 = `<span style="position: absolute;left:0;top:0;width: 1px;">${Constants.ZWSP}</span><div class="ft__error" style="height:${e.style.height || "420px"}" contenteditable="false">Mindmap render error: <br>${error}</div>`;
}
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
}
});
});
};

View file

@ -11,18 +11,51 @@ export const genIconHTML = (element?: false | HTMLElement, actions = ["edit", "m
return '<div class="protyle-icons"></div>';
}
}
if (actions.length === 3) {
return `<div class="protyle-icons">
<span aria-label="${window.siyuan.languages.refresh}" class="b3-tooltips__nw b3-tooltips protyle-icon protyle-icon--first protyle-action__reload"><svg><use xlink:href="#iconRefresh"></use></svg></span>
<span aria-label="${window.siyuan.languages.edit}" class="b3-tooltips__nw b3-tooltips protyle-icon protyle-action__edit${enable ? "" : " fn__none"}"><svg><use xlink:href="#iconEdit"></use></svg></span>
<span aria-label="${window.siyuan.languages.more}" class="b3-tooltips__nw b3-tooltips protyle-icon protyle-action__menu protyle-icon--last"><svg><use xlink:href="#iconMore"></use></svg></span>
</div>`;
} else {
return `<div class="protyle-icons">
<span aria-label="${window.siyuan.languages.edit}" class="b3-tooltips__nw b3-tooltips protyle-icon protyle-icon--first protyle-action__edit${enable ? "" : " fn__none"}"><svg><use xlink:href="#iconEdit"></use></svg></span>
<span aria-label="${window.siyuan.languages.more}" class="b3-tooltips__nw b3-tooltips protyle-icon protyle-action__menu protyle-icon--last${enable ? "" : " protyle-icon--first"}"><svg><use xlink:href="#iconMore"></use></svg></span>
</div>`;
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 `<span aria-label="${aria}" class="${classList.join(" ")} ${className}${hidden}"><svg><use xlink:href="#${icon}"></use></svg></span>`;
};
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 `<div class="protyle-icons">
${res.join("\n ")}
</div>`;
};
export const genRenderFrame = (renderElement: Element) => {

View file

@ -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);
}
}
}

View file

@ -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]);