mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
feat(markdown-node): improve rendering performance and user interaction by caching parsed Markdown and optimizing DOM updates
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "canvas-plugin-markdown",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas Markdown 节点插件",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Markdown 节点:编辑与渲染 Markdown。marked 从 CDN 按需加载,不打进插件体积。
|
||||
// 移动/交互:走宿主统一的「交互 ⇄ 移动」开关(interactionToggle)——默认「移动」态整块可拖,
|
||||
// 切「交互」态可滚动/选择;编辑态强制可交互。
|
||||
// 防闪烁:解析结果按源码模块级缓存,且只在 HTML 真正变化时写入 DOM——
|
||||
// 画布任何重渲染都不会重新解析或重载 Markdown 里的图片。
|
||||
// styles.css 由 esbuild 以 text 方式打进 bundle,通过 plugin.css 自动注入。
|
||||
import { definePlugin, useEffect, useMemo, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
import css from "./styles.css";
|
||||
@@ -15,44 +19,76 @@ function loadMarked(): Promise<Marked> {
|
||||
return markedPromise;
|
||||
}
|
||||
|
||||
function MarkdownContent({ ctx }: CanvasNodeContentProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
// marked 就绪状态:已缓存则首帧即为 true,避免重渲染/重挂载时闪成空白
|
||||
const [markedReady, setMarkedReady] = useState(Boolean(marked));
|
||||
const value = ctx.node.metadata?.content || "";
|
||||
const PLACEHOLDER = "*选中节点,点上方工具条的 ✎ 编辑 Markdown*";
|
||||
|
||||
// 解析结果按源码缓存(模块级):同一段 Markdown 只解析一次,重复渲染/重挂载都命中缓存,
|
||||
// 返回的是同一个字符串引用,配合下方「值不变不写 DOM」彻底避免图片被重新请求。
|
||||
const htmlCache = new Map<string, string>();
|
||||
function renderMarkdown(source: string): string {
|
||||
if (!marked) return "";
|
||||
const key = source || PLACEHOLDER;
|
||||
let out = htmlCache.get(key);
|
||||
if (out === undefined) {
|
||||
out = marked.parse(key);
|
||||
htmlCache.set(key, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 预览态:用 ref 手动写 innerHTML,且仅在 html 真正变化时写入。
|
||||
// 这样宿主的任何重渲染(点击、移动视角、选中态变化等)都不会触碰已渲染的 DOM,图片不会重新加载。
|
||||
function MarkdownPreview({ ctx }: CanvasNodeContentProps) {
|
||||
const [, force] = useState(0);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const lastHtml = useRef<string | null>(null);
|
||||
|
||||
// marked 未就绪时按需加载,加载完触发一次重渲染填充内容
|
||||
useEffect(() => {
|
||||
if (marked) return;
|
||||
let alive = true;
|
||||
loadMarked().then(() => alive && setMarkedReady(true));
|
||||
loadMarked().then(() => alive && force((n) => n + 1));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// marked 就绪后同步计算 HTML(按 value 记忆),不再用异步 setState 填充,消除闪烁
|
||||
const html = useMemo(() => (marked ? marked.parse(value || "*双击右上角按钮编辑 Markdown*") : ""), [value, markedReady]);
|
||||
const source = (ctx.node.metadata?.content as string | undefined) || "";
|
||||
const html = renderMarkdown(source);
|
||||
|
||||
const toggle = { position: "absolute", right: 8, top: 8, zIndex: 20, width: 32, height: 32, display: "grid", placeItems: "center", borderRadius: 8, border: `1px solid ${ctx.theme.node.stroke}`, background: `${ctx.theme.toolbar.panel}dd`, color: ctx.theme.node.text, cursor: "pointer" } as const;
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || lastHtml.current === html) return; // 内容没变:不动 DOM,已加载的图片保持原样
|
||||
el.innerHTML = html;
|
||||
lastHtml.current = html;
|
||||
}, [html]);
|
||||
|
||||
return <div ref={ref} className="cnv-md" data-canvas-no-zoom onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", color: ctx.theme.node.text }} />;
|
||||
}
|
||||
|
||||
function MarkdownEditor({ ctx }: CanvasNodeContentProps) {
|
||||
const value = (ctx.node.metadata?.content as string | undefined) || "";
|
||||
return (
|
||||
<div data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<button type="button" style={toggle} onClick={() => setEditing((v) => !v)} title={editing ? "预览" : "编辑"}>
|
||||
{editing ? "👁" : "✎"}
|
||||
</button>
|
||||
{editing ? (
|
||||
<textarea autoFocus value={value} placeholder="# 输入 Markdown" onChange={(e) => ctx.updateMetadata({ content: e.target.value })} onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", resize: "none", background: "transparent", padding: 16, fontFamily: "monospace", fontSize: 14, outline: "none", border: "none", color: ctx.theme.node.text }} />
|
||||
) : (
|
||||
<div className="cnv-md" onWheel={(e) => e.stopPropagation()} style={{ color: ctx.theme.node.text }} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="# 输入 Markdown"
|
||||
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
style={{ height: "100%", width: "100%", resize: "none", background: ctx.theme.node.fill, borderRadius: 16, boxSizing: "border-box", padding: 16, fontFamily: "monospace", fontSize: 14, outline: "none", border: "none", color: ctx.theme.node.text }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownContent({ ctx }: CanvasNodeContentProps) {
|
||||
return ctx.node.metadata?.editing ? <MarkdownEditor ctx={ctx} /> : <MarkdownPreview ctx={ctx} />;
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "markdown",
|
||||
name: "Markdown 节点",
|
||||
version: "1.0.0",
|
||||
version: "1.1.0",
|
||||
description: "在画布中编辑与渲染 Markdown",
|
||||
css,
|
||||
nodes: [
|
||||
@@ -64,8 +100,26 @@ export default definePlugin({
|
||||
defaultSize: { width: 360, height: 300 },
|
||||
defaultMetadata: { content: "" },
|
||||
minimapColor: "#6366f1",
|
||||
hidePanel: true, // 纯展示/编辑节点:不弹出下方生图面板
|
||||
// 宿主统一提供「交互 ⇄ 移动」开关;编辑态强制可交互并隐藏该开关
|
||||
interactionToggle: true,
|
||||
forceInteractive: (node) => Boolean(node.metadata?.editing),
|
||||
resource: (node) => ({ kind: "text", text: node.metadata?.content }),
|
||||
Content: MarkdownContent,
|
||||
// 仅保留「编辑/预览」开关(状态存 metadata.editing);交互/移动 由宿主自动注入
|
||||
toolbar: (ctx) => {
|
||||
const editing = Boolean(ctx.node.metadata?.editing);
|
||||
return [
|
||||
{
|
||||
id: "md-toggle-edit",
|
||||
title: editing ? "预览渲染结果" : "编辑 Markdown 源码",
|
||||
label: editing ? "预览" : "编辑",
|
||||
icon: editing ? "👁" : "✎",
|
||||
active: editing,
|
||||
onClick: () => ctx.updateMetadata({ editing: !editing }),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user