feat(markdown-node): improve rendering performance and user interaction by caching parsed Markdown and optimizing DOM updates

This commit is contained in:
HouYunFei
2026-07-16 15:11:48 +08:00
parent 13c508bc34
commit 4beadc6161
4 changed files with 155 additions and 79 deletions
+73 -57
View File
@@ -43,7 +43,7 @@ import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
import { useAgentStore } from "@/stores/use-agent-store";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
import { buildCanvasResourceReferences, buildNodeMentionReferences, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { getNodeDefinition, isBuiltinNodeType as isBuiltinType, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
import { ensurePluginsLoaded } from "@/lib/canvas/plugin-loader";
@@ -102,6 +102,8 @@ type CanvasGenerationRequest = {
const VIDEO_NODE_MAX_WIDTH = 420;
const VIDEO_NODE_MAX_HEIGHT = 420;
// 稳定的空引用数组:避免每次渲染 `... || []` 产生新数组引用而击穿 CanvasNode 的 React.memo
const EMPTY_REFERENCES: CanvasResourceReference[] = [];
const CONNECTION_HANDLE_HIT_RADIUS = 40;
const CONNECTION_NODE_HIT_PADDING = 32;
const NODE_STATUS_IDLE = "idle" as const;
@@ -2701,6 +2703,68 @@ function InfiniteCanvasPage() {
[insertAssistantImage, insertAssistantText, screenToCanvas, size.height, size.width],
);
// --- 传给 CanvasNode 的回调/渲染函数统一 memo 化 ---
// CanvasNode 是 React.memo,但只要这些 prop 每次渲染都是新引用,memo 就失效,
// 导致点击/悬停/移动视角时全部节点跟着重渲染(markdown 尤其明显)。全部 useCallback 后,
// 未变化的节点不再重渲染。依赖里的 map/handler 均已 memo 化,纯交互时保持稳定。
const handleNodeHoverStart = useCallback((nodeId: string) => {
if (nodeDraggingRef.current) return;
setHoveredNodeId(nodeId);
}, []);
const handleNodeHoverEnd = useCallback((nodeId: string) => {
setHoveredNodeId((current) => (current === nodeId ? null : current));
}, []);
const handleNodeViewImage = useCallback((node: CanvasNodeData) => setPreviewNodeId(node.id), []);
const handleNodeRetry = useCallback((node: CanvasNodeData) => void handleRetryNode(node), [handleRetryNode]);
const handleNodeContextMenu = useCallback((event: ReactMouseEvent, nodeId: string) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({ type: "node", x: event.clientX, y: event.clientY, nodeId });
}, []);
const renderNodePanel = useCallback(
(panelNode: CanvasNodeData) =>
getNodeDefinition(panelNode.type)?.Panel ? (
renderPluginPanel(panelNode)
) : panelNode.type === CanvasNodeType.Config ? (
<CanvasConfigComposer value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""} inputs={configInputsById.get(panelNode.id) || []} onChange={(composerContent) => handleConfigNodeChange(panelNode.id, { composerContent })} onClose={() => setDialogNodeId(null)} />
) : (
<CanvasNodePromptPanel
node={panelNode}
isRunning={runningNodeId === panelNode.id}
mentionReferences={mentionReferencesByNodeId.get(panelNode.id) || EMPTY_REFERENCES}
onPromptChange={handleNodePromptChange}
onConfigChange={handleConfigNodeChange}
onGenerate={handleGenerateNode}
onStop={confirmStopGeneration}
modeOverride={getNodeDefinition(panelNode.type)?.useBuiltinPanel?.mode}
onImageSettingsOpenChange={(open) => {
setNodeImageSettingsOpen(open);
if (open) setToolbarNodeId(null);
}}
/>
),
[configInputsById, confirmStopGeneration, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, mentionReferencesByNodeId, renderPluginPanel, runningNodeId],
);
const renderNodeContentPanel = useCallback(
(contentNode: CanvasNodeData) => (
<CanvasConfigNodePanel
node={contentNode}
isRunning={runningNodeId === contentNode.id}
inputSummary={getInputSummary(configInputsById.get(contentNode.id) || [])}
onConfigChange={handleConfigNodeChange}
onComposerToggle={() => setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))}
onStop={confirmStopGeneration}
onGenerate={(nodeId) => {
const target = nodesRef.current.find((item) => item.id === nodeId);
void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "");
}}
/>
),
[configInputsById, confirmStopGeneration, handleConfigNodeChange, handleGenerateNode, runningNodeId],
);
if (!projectLoaded) return <CanvasRefreshShell />;
return (
@@ -2804,73 +2868,25 @@ function InfiniteCanvasPage() {
batchMotion={batchMotionById.get(node.id)}
showImageInfo={showImageInfo}
resourceLabel={resourceReferenceByNodeId.get(node.id)}
mentionReferences={mentionReferencesByNodeId.get(node.id) || []}
mentionReferences={mentionReferencesByNodeId.get(node.id) || EMPTY_REFERENCES}
pluginHost={pluginHost}
registryVersion={nodeRegistryVersion}
renderPanel={(panelNode) =>
getNodeDefinition(panelNode.type)?.Panel ? (
renderPluginPanel(panelNode)
) : panelNode.type === CanvasNodeType.Config ? (
<CanvasConfigComposer
value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""}
inputs={configInputsById.get(panelNode.id) || []}
onChange={(composerContent) => handleConfigNodeChange(panelNode.id, { composerContent })}
onClose={() => setDialogNodeId(null)}
/>
) : (
<CanvasNodePromptPanel
node={panelNode}
isRunning={runningNodeId === panelNode.id}
mentionReferences={mentionReferencesByNodeId.get(panelNode.id) || []}
onPromptChange={handleNodePromptChange}
onConfigChange={handleConfigNodeChange}
onGenerate={handleGenerateNode}
onStop={confirmStopGeneration}
modeOverride={getNodeDefinition(panelNode.type)?.useBuiltinPanel?.mode}
onImageSettingsOpenChange={(open) => {
setNodeImageSettingsOpen(open);
if (open) setToolbarNodeId(null);
}}
/>
)
}
renderNodeContent={(contentNode) => (
<CanvasConfigNodePanel
node={contentNode}
isRunning={runningNodeId === contentNode.id}
inputSummary={getInputSummary(configInputsById.get(contentNode.id) || [])}
onConfigChange={handleConfigNodeChange}
onComposerToggle={() => setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))}
onStop={confirmStopGeneration}
onGenerate={(nodeId) => {
const target = nodesRef.current.find((item) => item.id === nodeId);
void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "");
}}
/>
)}
renderPanel={renderNodePanel}
renderNodeContent={renderNodeContentPanel}
onMouseDown={handleNodeMouseDown}
onSelectCapture={handleNodeSelectCapture}
onHoverStart={(nodeId) => {
if (nodeDraggingRef.current) return;
setHoveredNodeId(nodeId);
}}
onHoverEnd={(nodeId) => {
setHoveredNodeId((current) => (current === nodeId ? null : current));
}}
onHoverStart={handleNodeHoverStart}
onHoverEnd={handleNodeHoverEnd}
onConnectStart={handleConnectStart}
onResize={handleNodeResize}
onContentChange={handleNodeContentChange}
onTitleChange={handleNodeTitleChange}
onToggleBatch={toggleBatchExpanded}
onSetBatchPrimary={setBatchPrimary}
onRetry={(node) => void handleRetryNode(node)}
onRetry={handleNodeRetry}
onGenerateImage={generateImageFromTextNode}
onViewImage={(node) => setPreviewNodeId(node.id)}
onContextMenu={(event, id) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({ type: "node", x: event.clientX, y: event.clientY, nodeId: id });
}}
onViewImage={handleNodeViewImage}
onContextMenu={handleNodeContextMenu}
/>
))}