This commit is contained in:
Achuan-2 2025-12-16 10:56:48 +08:00 committed by GitHub
commit b3003249e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 476 additions and 220 deletions

View file

@ -476,7 +476,7 @@ ${getIconScript(servePath)}
Protyle.flowchartRender(wysElement, "${servePath}stage/protyle");
Protyle.graphvizRender(wysElement, "${servePath}stage/protyle");
Protyle.chartRender(wysElement, "${servePath}stage/protyle");
Protyle.mindmapRender(wysElement, "${servePath}stage/protyle");
Protyle.mindmapRender(wysElement, "${servePath}stage/protyle", {zoom: false, pan: false});
Protyle.abcRender(wysElement, "${servePath}stage/protyle");
Protyle.htmlRender(wysElement);
Protyle.plantumlRender(wysElement, "${servePath}stage/protyle");
@ -775,7 +775,7 @@ ${getIconScript(servePath)}
Protyle.flowchartRender(previewElement, "stage/protyle");
Protyle.graphvizRender(previewElement, "stage/protyle");
Protyle.chartRender(previewElement, "stage/protyle");
Protyle.mindmapRender(previewElement, "stage/protyle");
Protyle.mindmapRender(previewElement, "stage/protyle", {zoom: false, pan: false});
Protyle.abcRender(previewElement, "stage/protyle");
Protyle.htmlRender(previewElement);
Protyle.plantumlRender(previewElement, "stage/protyle");

View file

@ -3,7 +3,7 @@ import {Constants} from "../../constants";
import { hasClosestByClassName } from "../util/hasClosest";
import { genIconHTML } from "./util";
export const mindmapRender = (element: Element, cdn = Constants.PROTYLE_CDN) => {
export const mindmapRender = (element: Element, cdn = Constants.PROTYLE_CDN, markmapOptions: { zoom?: boolean; pan?: boolean } = {}) => {
let mindmapElements: Element[] = [];
if (element.getAttribute("data-subtype") === "mindmap") {
// 编辑器内代码块编辑渲染
@ -14,7 +14,11 @@ export const mindmapRender = (element: Element, cdn = Constants.PROTYLE_CDN) =>
if (mindmapElements.length === 0) {
return;
}
addScript(`${cdn}/js/echarts/echarts.min.js?v=0.0.0`, "protyleEchartsScript").then(() => {
// load d3 first, then markmap-lib, then markmap-view (in order)
addScript(`${cdn}/js/d3/d3.min.js?v6.7.0`, "protyleD3Script")
.then(() => addScript(`${cdn}/js/markmap/markmap-lib.min.js?v0.14.4`, "protyleMarkmapLibScript"))
.then(() => addScript(`${cdn}/js/markmap/markmap-view.min.js?v0.14.4`, "protyleMarkmapScript"))
.then(() => {
const wysiswgElement = hasClosestByClassName(element, "protyle-wysiwyg", true);
let width: number = undefined;
if (wysiswgElement && wysiswgElement.clientWidth > 0 && mindmapElements[0].firstElementChild.clientWidth === 0 && wysiswgElement.firstElementChild) {
@ -25,67 +29,143 @@ 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) {
renderElement.innerHTML = `<span style="position: absolute;left:0;top:0;width: 1px;">${Constants.ZWSP}</span><div style="height:${e.style.height || "420px"}" contenteditable="false"></div>`;
} else {
renderElement.lastElementChild.classList.remove("ft__error");
}
window.echarts.init(renderElement.lastElementChild, window.siyuan.config.appearance.mode === 1 ? "dark" : undefined, {
width,
}).setOption({
series: [
{
data: [JSON.parse(Lute.EChartsMindmapStr(Lute.UnEscapeHTMLStr(e.getAttribute("data-content"))))],
initialTreeDepth: -1,
itemStyle: {
borderWidth: 0,
color: "#4285f4",
},
label: {
backgroundColor: "#f6f8fa",
borderColor: "#d1d5da",
borderRadius: 6,
borderWidth: 0.5,
color: "#586069",
lineHeight: 20,
offset: [-5, 0],
padding: [0, 5],
position: "insideRight",
},
lineStyle: {
color: "#d1d5da",
width: 1,
},
roam: true,
symbol: (value: number, params: { data?: { children?: string } }) => {
if (params?.data?.children) {
return "circle";
} else {
return "path://";
// Convert stored content to markdown using Lute (prefer existing instance), then transform/render with markmap
const raw = Lute.UnEscapeHTMLStr(e.getAttribute("data-content"));
let md: string = raw;
// prefer protyle's lute instance if available
if ((window as any).protyle && (window as any).protyle.lute && typeof (window as any).protyle.lute.BlockDOM2Md === "function") {
md = (window as any).protyle.lute.BlockDOM2Md(raw);
} else if (typeof Lute === "function" && typeof Lute.New === "function") {
try {
const luteInst = Lute.New();
if (luteInst && typeof luteInst.BlockDOM2Md === "function") {
md = luteInst.BlockDOM2Md(raw);
} else if (luteInst && typeof luteInst.BlockDOM2HTML === "function") {
md = luteInst.BlockDOM2HTML(raw);
}
} catch (e) {
// fallback to raw
md = raw;
}
}
// 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 (transform -> getUsedAssets -> load assets -> create)
let rootData: any = null;
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 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;
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 */ }
}
if (scripts && loadJS) {
try { loadJS(scripts, { getMarkmap: () => (window as any).markmap || mm }); } catch (err) { /* ignore */ }
}
}
} else {
// 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") {
try {
const tx = transformFn(md) || {};
rootData = tx.root || tx || null;
} catch (err) {
rootData = null;
}
}
}
}
// container for svg
const container = renderElement.lastElementChild as HTMLElement;
// clear existing content and append an svg for markmap
container.innerHTML = "";
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
// use width if calculated earlier
if (typeof width === "number" && width > 0) {
svg.setAttribute("width", String(width));
} else {
svg.setAttribute("width", "100%");
}
svg.setAttribute("height", "100%");
container.appendChild(svg);
// prefer Markmap.create if available
const MarkmapCtor = (mm && (mm.Markmap || mm.default || mm)) || (window as any).Markmap;
// default options, allow overriding via markmapOptions (e.g. in export we can pass zoom/pan false)
const options = Object.assign({
duration: 0, // 🔥 禁用动画设为0
}, markmapOptions || {});
// create and store markmap + transformer on the element so callers can update instead of re-creating
if (MarkmapCtor && typeof MarkmapCtor.create === "function") {
if (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");
}
},
type: "tree",
},
],
tooltip: {
trigger: "item",
triggerOn: "mousemove",
},
backgroundColor: "transparent",
});
} catch (error) {
window.echarts.dispose(renderElement.lastElementChild);
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}" data-position="4north" class="ariaLabel protyle-icon protyle-icon--first protyle-action__reload"><svg><use xlink:href="#iconRefresh"></use></svg></span>
<span aria-label="${window.siyuan.languages.edit}" data-position="4north" class="ariaLabel protyle-icon protyle-action__edit${enable ? "" : " fn__none"}"><svg><use xlink:href="#iconEdit"></use></svg></span>
<span aria-label="${window.siyuan.languages.more}" data-position="4north" class="ariaLabel 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}" data-position="4north" class="ariaLabel 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}" data-position="4north" class="ariaLabel 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 = ["ariaLabel", "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}" data-position="4north" 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,12 +1127,33 @@ export class Toolbar {
}
});
} else {
// 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 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();
});
textElement.addEventListener("keydown", (event: KeyboardEvent) => {
@ -1158,6 +1239,16 @@ export class Toolbar {
} else {
renderElement.setAttribute("data-content", Lute.EscapeHTMLStr(textElement.value));
renderElement.removeAttribute("data-render");
// 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 = "";
@ -1165,6 +1256,15 @@ export class Toolbar {
processRender(renderElement);
}
}
} else {
if (types.includes("NodeBlockQueryEmbed")) {
blockRender(protyle, renderElement);
(renderElement as HTMLElement).style.height = "";
} else {
processRender(renderElement);
}
}
}
// 光标定位
if (getSelection().rangeCount === 0 ||

View file

@ -99,6 +99,16 @@ import {img3115} from "../../boot/compatibleVersion";
import { globalClickHideMenu } from "../../boot/globalEvent/click";
import { hideTooltip } from "../../dialog/tooltip";
import { openGalleryItemMenu } from "../render/av/gallery/util";
import { clearSelect } from "../util/clearSelect";
import { chartRender } from "../render/chartRender";
import {openEmojiPanel, unicode2Emoji} from "../../emoji";
import {openLink} from "../../editor/openLink";
import {mathRender} from "../render/mathRender";
import {editAssetItem} from "../render/av/asset";
import {img3115} from "../../boot/compatibleVersion";
import {globalClickHideMenu} from "../../boot/globalEvent/click";
import {hideTooltip} from "../../dialog/tooltip";
import {openGalleryItemMenu} from "../render/av/gallery/util";
import {clearSelect} from "../util/clear";
import {chartRender} from "../render/chartRender";
import {updateCalloutType} from "./callout";
@ -2927,6 +2937,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]);

2
app/stage/protyle/js/d3/d3.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long