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