feat(html-node): enhance HTML node plugin with interactive toolbar and improved editor functionality

This commit is contained in:
HouYunFei
2026-07-15 17:51:39 +08:00
parent 7ac2ab351d
commit 3f623ea70c
9 changed files with 171 additions and 43 deletions
+4 -1
View File
@@ -41,6 +41,7 @@ type CanvasNodeProps = {
batchRecovering?: boolean;
batchMotion?: { x: number; y: number; index: number };
onMouseDown: (event: React.MouseEvent, nodeId: string) => void;
onSelectCapture?: (event: React.MouseEvent, nodeId: string) => void;
onHoverStart: (nodeId: string) => void;
onHoverEnd: (nodeId: string) => void;
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
@@ -102,6 +103,7 @@ export const CanvasNode = React.memo(function CanvasNode({
batchRecovering = false,
batchMotion,
onMouseDown,
onSelectCapture,
onHoverStart,
onHoverEnd,
onConnectStart,
@@ -118,7 +120,7 @@ export const CanvasNode = React.memo(function CanvasNode({
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [hovered, setHovered] = useState(false);
const definition = getNodeDefinition(data.type);
const pluginContext = useMemo<CanvasNodeContext | null>(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale) : null), [pluginHost, data, theme, scale]);
const pluginContext = useMemo<CanvasNodeContext | null>(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale, isSelected) : null), [pluginHost, data, theme, scale, isSelected]);
const [isEditingContent, setIsEditingContent] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(data.title || "");
@@ -301,6 +303,7 @@ export const CanvasNode = React.memo(function CanvasNode({
setHovered(false);
onHoverEnd(data.id);
}}
onMouseDownCapture={(event) => onSelectCapture?.(event, data.id)}
onContextMenu={(event) => onContextMenu(event, data.id)}
>
<div className="absolute left-3 top-[-28px] z-[65] max-w-[calc(100%-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
+2 -1
View File
@@ -5,12 +5,13 @@ import type { CanvasNodeData } from "@/types/canvas";
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
// 把宿主能力 + 节点 + 主题/缩放,组装成注入给插件节点的上下文
export function buildNodeContext(host: CanvasPluginHost, node: CanvasNodeData, theme: CanvasTheme, scale: number): CanvasNodeContext {
export function buildNodeContext(host: CanvasPluginHost, node: CanvasNodeData, theme: CanvasTheme, scale: number, isSelected = false): CanvasNodeContext {
const storage = createPluginStorage(getNodePluginId(node.type));
return {
node,
theme,
scale,
isSelected,
updateMetadata: (patch) => host.updateMetadata(node.id, patch),
updateNode: (patch) => host.updateNode(node.id, patch),
getNode: (id) => host.getNode(id),
+44 -21
View File
@@ -688,7 +688,10 @@ function InfiniteCanvasPage() {
}, [collapsingBatchIds, nodes, size.height, size.width, viewport.k, viewport.x, viewport.y]);
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
const toolbarNode = toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null;
// 工具条跟随「单选节点」:点击/新建/框选/键盘选中任一节点都会显示,不再仅靠精确点中触发。
// 多选时不显示;拖拽中由下方 isNodeDragging 守卫隐藏。
const singleSelectedNodeId = selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null;
const toolbarNode = (toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null) || (singleSelectedNodeId ? nodeById.get(singleSelectedNodeId) || null : null);
const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null;
const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null;
const maskEditNode = maskEditNodeId ? nodeById.get(maskEditNodeId) || null : null;
@@ -861,7 +864,10 @@ function InfiniteCanvasPage() {
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
const definition = getNodeDefinition(type);
if (definition?.Panel || (isBuiltinType(type) && type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group)) setDialogNodeId(newNode.id);
// 纯展示型插件节点(hidePanel)不弹面板;插件自定义 Panel 需显式 autoOpenPanel 才在新建时打开;
// 内置的图片/视频/配置类节点保持原有「新建即打开生图面板」行为。
const wantsPanel = definition?.hidePanel ? false : definition?.Panel ? Boolean(definition.autoOpenPanel) : isBuiltinType(type) && type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group;
if (wantsPanel) setDialogNodeId(newNode.id);
},
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
);
@@ -1161,30 +1167,42 @@ function InfiniteCanvasPage() {
[cancelPendingConnectionCreate, screenToCanvas],
);
const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => {
event.stopPropagation();
setContextMenu(null);
setHoveredNodeId(null);
setToolbarNodeId(null);
setSelectedConnectionId(null);
const currentSelected = selectedNodeIdsRef.current;
const currentNodes = nodesRef.current;
const nextSelected = new Set(currentSelected);
// 仅处理「选中」的纯逻辑,供 body 冒泡拖拽入口与外层 capture 入口共用。
// 返回本次点击后的单选目标 id(多选/取消时为 null),用于同步工具条。
const selectNodeByEvent = useCallback((event: Pick<ReactMouseEvent, "shiftKey" | "metaKey" | "ctrlKey">, nodeId: string) => {
const nextSelected = new Set(selectedNodeIdsRef.current);
if (event.shiftKey || event.metaKey || event.ctrlKey) {
if (nextSelected.has(nodeId)) {
nextSelected.delete(nodeId);
} else {
nextSelected.add(nodeId);
}
if (nextSelected.has(nodeId)) nextSelected.delete(nodeId);
else nextSelected.add(nodeId);
} else if (!nextSelected.has(nodeId)) {
nextSelected.clear();
nextSelected.add(nodeId);
}
setSelectedNodeIds(nextSelected);
setToolbarNodeId(nextSelected.size === 1 && nextSelected.has(nodeId) ? nodeId : null);
const soloId = nextSelected.size === 1 && nextSelected.has(nodeId) ? nodeId : null;
setToolbarNodeId(soloId);
return { nextSelected, soloId };
}, []);
// capture 阶段选中:点击节点内部任意元素(含吞掉 mousedown 的 textarea/iframe)都能选中并弹出工具条。
// 只做选中,不启动拖拽 —— 拖拽仍由 body 的 onMouseDown(冒泡)负责,故编辑器内选词不会拖动节点。
// capture 必先于同一次事件的 body 冒泡触发,故把算好的选中集暂存,供紧随其后的拖拽入口复用,避免二次选中(shift 反选被抵消)。
const pendingSelectionRef = useRef<Set<string> | null>(null);
const handleNodeSelectCapture = useCallback((event: ReactMouseEvent, nodeId: string) => {
if (event.button !== 0) return;
setContextMenu(null);
setHoveredNodeId(null);
setSelectedConnectionId(null);
const { nextSelected } = selectNodeByEvent(event, nodeId);
pendingSelectionRef.current = nextSelected;
}, [selectNodeByEvent]);
const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => {
event.stopPropagation();
// 选中已由 capture 阶段完成;这里只负责建立拖拽。若因故没走 capture,则兜底再选一次。
const currentNodes = nodesRef.current;
const nextSelected = pendingSelectionRef.current ?? selectNodeByEvent(event, nodeId).nextSelected;
pendingSelectionRef.current = null;
const dragIds = new Set(nextSelected);
currentNodes.forEach((node) => {
if (!nextSelected.has(node.id)) return;
@@ -1248,8 +1266,12 @@ function InfiniteCanvasPage() {
dragRef.current.initialSelectedNodes = [];
if (wasClick && clickedNodeId) {
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
const clickedDefinition = clickedNode ? getNodeDefinition(clickedNode.type) : undefined;
if (clickedNode?.type === CanvasNodeType.Text) {
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
} else if (clickedDefinition?.hidePanel) {
// 纯展示型插件节点:单击只选中,不弹下方面板
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
} else if (clickedNode?.type !== CanvasNodeType.Group) {
setDialogNodeId(clickedNodeId);
}
@@ -2659,7 +2681,7 @@ function InfiniteCanvasPage() {
isConnectionTarget={connectionTargetNodeId === node.id}
isConnecting={Boolean(connectingParams)}
editRequestNonce={editingNodeId === node.id ? editRequestNonce : 0}
showPanel={dialogNodeId === node.id && !selectionBox}
showPanel={dialogNodeId === node.id && !selectionBox && !getNodeDefinition(node.type)?.hidePanel}
batchCount={batchChildCountById.get(node.id) || 0}
groupChildCount={groupChildCountById.get(node.id) || 0}
isGroupDropTarget={dropTargetGroupId === node.id}
@@ -2714,6 +2736,7 @@ function InfiniteCanvasPage() {
/>
)}
onMouseDown={handleNodeMouseDown}
onSelectCapture={handleNodeSelectCapture}
onHoverStart={(nodeId) => {
if (nodeDraggingRef.current) return;
setHoveredNodeId(nodeId);
+3
View File
@@ -24,6 +24,7 @@ export type CanvasNodeContext = {
node: CanvasNodeData;
theme: CanvasTheme;
scale: number;
isSelected: boolean; // 该节点当前是否被选中(用于按需启用 iframe 交互等)
// 自身数据
updateMetadata: (patch: CanvasNodeMetadata) => void;
updateNode: (patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
@@ -71,6 +72,8 @@ export type CanvasNodeDefinition = {
minimapColor?: string;
showInCreateMenu?: boolean; // 默认 true
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
hidePanel?: boolean; // 为 true 时:点击/新建不弹出下方面板(含内置生图面板),纯展示型节点用
autoOpenPanel?: boolean; // 为 true 时:单击节点自动打开自定义 Panel(默认仅内置节点单击自动打开)
keepAspectRatio?: (node: CanvasNodeData) => boolean;
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
// 渲染:内置节点由 canvas-node 内部渲染器负责,可不提供 Content