mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +08:00
feat(html-node): enhance HTML node plugin with interactive toolbar and improved editor functionality
This commit is contained in:
@@ -24,7 +24,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: web
|
working-directory: web
|
||||||
run: bun install --frozen-lockfile
|
run: bun install
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
working-directory: web
|
working-directory: web
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
+ [优化] 优化节点插件SDK及HTML节点插件交互。
|
||||||
|
|
||||||
## v0.8.0 - 2026-07-15
|
## v0.8.0 - 2026-07-15
|
||||||
|
|
||||||
+ [新增] 画布节点插件系统:支持通过 URL 动态安装/启用/更新/卸载远程节点插件。
|
+ [新增] 画布节点插件系统:支持通过 URL 动态安装/启用/更新/卸载远程节点插件。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "canvas-plugin-html",
|
"name": "canvas-plugin-html",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Infinite Canvas HTML 节点插件",
|
"description": "Infinite Canvas HTML 节点插件",
|
||||||
|
|||||||
@@ -1,10 +1,68 @@
|
|||||||
// HTML 节点:沙箱 iframe 渲染 HTML,{{input}} 会替换为上游文本节点内容。
|
// HTML 节点:沙箱 iframe 渲染 HTML,{{input}} 会替换为上游文本节点内容。
|
||||||
import { definePlugin, useMemo, useState } from "@infinite-canvas/plugin-sdk";
|
// 交互:预览态 iframe 默认 pointer-events:none —— 鼠标事件穿透到宿主节点体,
|
||||||
|
// 因此点节点任意位置都能拖动,无需在节点上加任何标题栏/按钮。
|
||||||
|
// 「编辑/预览」与「交互」开关都放在节点外的悬浮工具条(toolbar 扩展点),状态存 metadata。
|
||||||
|
import { definePlugin, useMemo, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||||
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||||
|
|
||||||
|
// 源码编辑器行高/字号,行号槽与文本域必须完全一致才能对齐
|
||||||
|
const EDITOR_FONT = 12;
|
||||||
|
const EDITOR_LINE = 20;
|
||||||
|
|
||||||
|
function HtmlEditor({ ctx, value }: { ctx: CanvasNodeContentProps["ctx"]; value: string }) {
|
||||||
|
const gutterRef = useRef<HTMLDivElement>(null);
|
||||||
|
// 行数:按换行统计,至少 1 行;value 变化时重算
|
||||||
|
const lineCount = useMemo(() => Math.max(1, value.split("\n").length), [value]);
|
||||||
|
const [scrollTop, setScrollTop] = useState(0);
|
||||||
|
|
||||||
|
const codeStyle = { fontFamily: "monospace", fontSize: EDITOR_FONT, lineHeight: `${EDITOR_LINE}px`, boxSizing: "border-box" } as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-canvas-no-zoom style={{ height: "100%", width: "100%", display: "flex", overflow: "hidden", borderRadius: 16, background: ctx.theme.node.fill }} onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
{/* 行号槽:跟随文本域滚动,不可单独滚动 */}
|
||||||
|
<div
|
||||||
|
ref={gutterRef}
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
...codeStyle,
|
||||||
|
flex: "0 0 auto",
|
||||||
|
padding: "16px 8px 16px 12px",
|
||||||
|
textAlign: "right",
|
||||||
|
color: ctx.theme.node.placeholder,
|
||||||
|
background: `${ctx.theme.toolbar.panel}66`,
|
||||||
|
borderRight: `1px solid ${ctx.theme.node.stroke}`,
|
||||||
|
overflow: "hidden",
|
||||||
|
userSelect: "none",
|
||||||
|
whiteSpace: "pre",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 用负 margin 让整列跟随 scrollTop 平移,和 textarea 同步 */}
|
||||||
|
<div style={{ transform: `translateY(${-scrollTop}px)` }}>
|
||||||
|
{Array.from({ length: lineCount }, (_, i) => (
|
||||||
|
<div key={i}>{i + 1}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={value}
|
||||||
|
placeholder="<div>Hello, {{input}}</div>"
|
||||||
|
spellCheck={false}
|
||||||
|
wrap="off"
|
||||||
|
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
|
||||||
|
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
style={{ ...codeStyle, flex: "1 1 auto", minWidth: 0, height: "100%", resize: "none", background: "transparent", padding: "16px 16px 16px 12px", outline: "none", border: "none", color: ctx.theme.node.text, whiteSpace: "pre", overflow: "auto" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HtmlContent({ ctx }: CanvasNodeContentProps) {
|
function HtmlContent({ ctx }: CanvasNodeContentProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const value = ctx.node.metadata?.content || "";
|
const value = ctx.node.metadata?.content || "";
|
||||||
|
const editing = Boolean(ctx.node.metadata?.editing);
|
||||||
|
const interactive = Boolean(ctx.node.metadata?.interactive);
|
||||||
const upstreamText = useMemo(
|
const upstreamText = useMemo(
|
||||||
() =>
|
() =>
|
||||||
ctx
|
ctx
|
||||||
@@ -16,23 +74,29 @@ function HtmlContent({ ctx }: CanvasNodeContentProps) {
|
|||||||
);
|
);
|
||||||
const html = value.replace(/\{\{\s*input\s*\}\}/g, upstreamText);
|
const html = value.replace(/\{\{\s*input\s*\}\}/g, upstreamText);
|
||||||
|
|
||||||
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;
|
if (editing) {
|
||||||
|
return <HtmlEditor ctx={ctx} value={value} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
return (
|
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="<div>Hello, {{input}}</div>" 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: 12, outline: "none", border: "none", color: ctx.theme.node.text }} />
|
|
||||||
) : value ? (
|
|
||||||
<iframe title="html-preview" sandbox="allow-scripts allow-forms" style={{ height: "100%", width: "100%", border: 0, borderRadius: 16, background: "#fff" }} srcDoc={html} />
|
|
||||||
) : (
|
|
||||||
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 8, color: ctx.theme.node.placeholder }}>
|
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 8, color: ctx.theme.node.placeholder }}>
|
||||||
<span style={{ fontSize: 26 }}>{"</>"}</span>
|
<span style={{ fontSize: 26 }}>{"</>"}</span>
|
||||||
<span style={{ fontSize: 14 }}>编辑 HTML 源码</span>
|
<span style={{ fontSize: 13 }}>选中节点,点上方工具条的 ✎ 编辑 HTML</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览态:iframe 仅在「节点已选中 + 开启交互」时接收鼠标;否则穿透,保证首次点击能选中节点、弹出工具条。
|
||||||
|
const liveIframe = interactive && ctx.isSelected;
|
||||||
|
return (
|
||||||
|
<div data-canvas-no-zoom={liveIframe || undefined} style={{ position: "relative", height: "100%", width: "100%" }}>
|
||||||
|
<iframe
|
||||||
|
title="html-preview"
|
||||||
|
sandbox="allow-scripts allow-forms"
|
||||||
|
srcDoc={html}
|
||||||
|
style={{ height: "100%", width: "100%", border: 0, borderRadius: 16, background: "#fff", display: "block", pointerEvents: liveIframe ? "auto" : "none" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -40,7 +104,7 @@ function HtmlContent({ ctx }: CanvasNodeContentProps) {
|
|||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
id: "html",
|
id: "html",
|
||||||
name: "HTML 节点",
|
name: "HTML 节点",
|
||||||
version: "1.0.0",
|
version: "1.1.0",
|
||||||
description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本",
|
description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本",
|
||||||
nodes: [
|
nodes: [
|
||||||
{
|
{
|
||||||
@@ -51,7 +115,36 @@ export default definePlugin({
|
|||||||
defaultSize: { width: 420, height: 320 },
|
defaultSize: { width: 420, height: 320 },
|
||||||
defaultMetadata: { content: "" },
|
defaultMetadata: { content: "" },
|
||||||
minimapColor: "#ec4899",
|
minimapColor: "#ec4899",
|
||||||
|
hidePanel: true, // 纯展示型节点:点击/新建不弹出下方生图面板
|
||||||
Content: HtmlContent,
|
Content: HtmlContent,
|
||||||
|
// 所有开关都在节点外的悬浮工具条,不占用节点内部空间
|
||||||
|
toolbar: (ctx) => {
|
||||||
|
const editing = Boolean(ctx.node.metadata?.editing);
|
||||||
|
const interactive = Boolean(ctx.node.metadata?.interactive);
|
||||||
|
const hasContent = Boolean(ctx.node.metadata?.content);
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
id: "html-toggle-edit",
|
||||||
|
title: editing ? "预览渲染结果" : "编辑 HTML 源码",
|
||||||
|
label: editing ? "预览" : "编辑",
|
||||||
|
icon: editing ? "👁" : "✎",
|
||||||
|
active: editing,
|
||||||
|
onClick: () => ctx.updateMetadata({ editing: !editing }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// 预览且有内容时,才提供「交互」开关:开=可点击/滚动页面,关=整块可拖动
|
||||||
|
if (!editing && hasContent) {
|
||||||
|
items.push({
|
||||||
|
id: "html-toggle-interactive",
|
||||||
|
title: interactive ? "锁定预览(整块可拖动)" : "允许与页面交互(点击/滚动)",
|
||||||
|
label: interactive ? "锁定" : "交互",
|
||||||
|
icon: interactive ? "🔒" : "👆",
|
||||||
|
active: interactive,
|
||||||
|
onClick: () => ctx.updateMetadata({ interactive: !interactive }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export type CanvasNodeContext = {
|
|||||||
node: CanvasNodeData;
|
node: CanvasNodeData;
|
||||||
theme: CanvasTheme;
|
theme: CanvasTheme;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
isSelected: boolean; // 该节点当前是否被选中(用于按需启用 iframe 交互等)
|
||||||
updateMetadata: (patch: CanvasNodeMetadata) => void;
|
updateMetadata: (patch: CanvasNodeMetadata) => void;
|
||||||
updateNode: (patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
|
updateNode: (patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
|
||||||
// 图访问
|
// 图访问
|
||||||
@@ -194,6 +195,8 @@ export type CanvasNodeDefinition = {
|
|||||||
minimapColor?: string;
|
minimapColor?: string;
|
||||||
showInCreateMenu?: boolean; // 默认 true
|
showInCreateMenu?: boolean; // 默认 true
|
||||||
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
|
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
|
||||||
|
hidePanel?: boolean; // 为 true 时:点击/新建不弹出下方面板(含内置生图面板),纯展示型节点用
|
||||||
|
autoOpenPanel?: boolean; // 为 true 时:单击节点自动打开自定义 Panel(默认仅内置节点单击自动打开)
|
||||||
keepAspectRatio?: (node: CanvasNodeData) => boolean;
|
keepAspectRatio?: (node: CanvasNodeData) => boolean;
|
||||||
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
|
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
|
||||||
// 渲染
|
// 渲染
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type CanvasNodeProps = {
|
|||||||
batchRecovering?: boolean;
|
batchRecovering?: boolean;
|
||||||
batchMotion?: { x: number; y: number; index: number };
|
batchMotion?: { x: number; y: number; index: number };
|
||||||
onMouseDown: (event: React.MouseEvent, nodeId: string) => void;
|
onMouseDown: (event: React.MouseEvent, nodeId: string) => void;
|
||||||
|
onSelectCapture?: (event: React.MouseEvent, nodeId: string) => void;
|
||||||
onHoverStart: (nodeId: string) => void;
|
onHoverStart: (nodeId: string) => void;
|
||||||
onHoverEnd: (nodeId: string) => void;
|
onHoverEnd: (nodeId: string) => void;
|
||||||
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
|
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
|
||||||
@@ -102,6 +103,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
batchRecovering = false,
|
batchRecovering = false,
|
||||||
batchMotion,
|
batchMotion,
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
|
onSelectCapture,
|
||||||
onHoverStart,
|
onHoverStart,
|
||||||
onHoverEnd,
|
onHoverEnd,
|
||||||
onConnectStart,
|
onConnectStart,
|
||||||
@@ -118,7 +120,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const definition = getNodeDefinition(data.type);
|
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 [isEditingContent, setIsEditingContent] = useState(false);
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
const [titleDraft, setTitleDraft] = useState(data.title || "");
|
const [titleDraft, setTitleDraft] = useState(data.title || "");
|
||||||
@@ -301,6 +303,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
setHovered(false);
|
setHovered(false);
|
||||||
onHoverEnd(data.id);
|
onHoverEnd(data.id);
|
||||||
}}
|
}}
|
||||||
|
onMouseDownCapture={(event) => onSelectCapture?.(event, data.id)}
|
||||||
onContextMenu={(event) => onContextMenu(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()}>
|
<div className="absolute left-3 top-[-28px] z-[65] max-w-[calc(100%-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import type { CanvasNodeData } from "@/types/canvas";
|
|||||||
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
|
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));
|
const storage = createPluginStorage(getNodePluginId(node.type));
|
||||||
return {
|
return {
|
||||||
node,
|
node,
|
||||||
theme,
|
theme,
|
||||||
scale,
|
scale,
|
||||||
|
isSelected,
|
||||||
updateMetadata: (patch) => host.updateMetadata(node.id, patch),
|
updateMetadata: (patch) => host.updateMetadata(node.id, patch),
|
||||||
updateNode: (patch) => host.updateNode(node.id, patch),
|
updateNode: (patch) => host.updateNode(node.id, patch),
|
||||||
getNode: (id) => host.getNode(id),
|
getNode: (id) => host.getNode(id),
|
||||||
|
|||||||
@@ -688,7 +688,10 @@ function InfiniteCanvasPage() {
|
|||||||
}, [collapsingBatchIds, nodes, size.height, size.width, viewport.k, viewport.x, viewport.y]);
|
}, [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 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 infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null;
|
||||||
const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null;
|
const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null;
|
||||||
const maskEditNode = maskEditNodeId ? nodeById.get(maskEditNodeId) || null : null;
|
const maskEditNode = maskEditNodeId ? nodeById.get(maskEditNodeId) || null : null;
|
||||||
@@ -861,7 +864,10 @@ function InfiniteCanvasPage() {
|
|||||||
setSelectedNodeIds(new Set([newNode.id]));
|
setSelectedNodeIds(new Set([newNode.id]));
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
const definition = getNodeDefinition(type);
|
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],
|
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
|
||||||
);
|
);
|
||||||
@@ -1161,30 +1167,42 @@ function InfiniteCanvasPage() {
|
|||||||
[cancelPendingConnectionCreate, screenToCanvas],
|
[cancelPendingConnectionCreate, screenToCanvas],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => {
|
// 仅处理「选中」的纯逻辑,供 body 冒泡拖拽入口与外层 capture 入口共用。
|
||||||
event.stopPropagation();
|
// 返回本次点击后的单选目标 id(多选/取消时为 null),用于同步工具条。
|
||||||
setContextMenu(null);
|
const selectNodeByEvent = useCallback((event: Pick<ReactMouseEvent, "shiftKey" | "metaKey" | "ctrlKey">, nodeId: string) => {
|
||||||
setHoveredNodeId(null);
|
const nextSelected = new Set(selectedNodeIdsRef.current);
|
||||||
setToolbarNodeId(null);
|
|
||||||
setSelectedConnectionId(null);
|
|
||||||
|
|
||||||
const currentSelected = selectedNodeIdsRef.current;
|
|
||||||
const currentNodes = nodesRef.current;
|
|
||||||
const nextSelected = new Set(currentSelected);
|
|
||||||
|
|
||||||
if (event.shiftKey || event.metaKey || event.ctrlKey) {
|
if (event.shiftKey || event.metaKey || event.ctrlKey) {
|
||||||
if (nextSelected.has(nodeId)) {
|
if (nextSelected.has(nodeId)) nextSelected.delete(nodeId);
|
||||||
nextSelected.delete(nodeId);
|
else nextSelected.add(nodeId);
|
||||||
} else {
|
|
||||||
nextSelected.add(nodeId);
|
|
||||||
}
|
|
||||||
} else if (!nextSelected.has(nodeId)) {
|
} else if (!nextSelected.has(nodeId)) {
|
||||||
nextSelected.clear();
|
nextSelected.clear();
|
||||||
nextSelected.add(nodeId);
|
nextSelected.add(nodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedNodeIds(nextSelected);
|
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);
|
const dragIds = new Set(nextSelected);
|
||||||
currentNodes.forEach((node) => {
|
currentNodes.forEach((node) => {
|
||||||
if (!nextSelected.has(node.id)) return;
|
if (!nextSelected.has(node.id)) return;
|
||||||
@@ -1248,8 +1266,12 @@ function InfiniteCanvasPage() {
|
|||||||
dragRef.current.initialSelectedNodes = [];
|
dragRef.current.initialSelectedNodes = [];
|
||||||
if (wasClick && clickedNodeId) {
|
if (wasClick && clickedNodeId) {
|
||||||
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
|
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
|
||||||
|
const clickedDefinition = clickedNode ? getNodeDefinition(clickedNode.type) : undefined;
|
||||||
if (clickedNode?.type === CanvasNodeType.Text) {
|
if (clickedNode?.type === CanvasNodeType.Text) {
|
||||||
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
|
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
|
||||||
|
} else if (clickedDefinition?.hidePanel) {
|
||||||
|
// 纯展示型插件节点:单击只选中,不弹下方面板
|
||||||
|
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
|
||||||
} else if (clickedNode?.type !== CanvasNodeType.Group) {
|
} else if (clickedNode?.type !== CanvasNodeType.Group) {
|
||||||
setDialogNodeId(clickedNodeId);
|
setDialogNodeId(clickedNodeId);
|
||||||
}
|
}
|
||||||
@@ -2659,7 +2681,7 @@ function InfiniteCanvasPage() {
|
|||||||
isConnectionTarget={connectionTargetNodeId === node.id}
|
isConnectionTarget={connectionTargetNodeId === node.id}
|
||||||
isConnecting={Boolean(connectingParams)}
|
isConnecting={Boolean(connectingParams)}
|
||||||
editRequestNonce={editingNodeId === node.id ? editRequestNonce : 0}
|
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}
|
batchCount={batchChildCountById.get(node.id) || 0}
|
||||||
groupChildCount={groupChildCountById.get(node.id) || 0}
|
groupChildCount={groupChildCountById.get(node.id) || 0}
|
||||||
isGroupDropTarget={dropTargetGroupId === node.id}
|
isGroupDropTarget={dropTargetGroupId === node.id}
|
||||||
@@ -2714,6 +2736,7 @@ function InfiniteCanvasPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
onMouseDown={handleNodeMouseDown}
|
onMouseDown={handleNodeMouseDown}
|
||||||
|
onSelectCapture={handleNodeSelectCapture}
|
||||||
onHoverStart={(nodeId) => {
|
onHoverStart={(nodeId) => {
|
||||||
if (nodeDraggingRef.current) return;
|
if (nodeDraggingRef.current) return;
|
||||||
setHoveredNodeId(nodeId);
|
setHoveredNodeId(nodeId);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type CanvasNodeContext = {
|
|||||||
node: CanvasNodeData;
|
node: CanvasNodeData;
|
||||||
theme: CanvasTheme;
|
theme: CanvasTheme;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
isSelected: boolean; // 该节点当前是否被选中(用于按需启用 iframe 交互等)
|
||||||
// 自身数据
|
// 自身数据
|
||||||
updateMetadata: (patch: CanvasNodeMetadata) => void;
|
updateMetadata: (patch: CanvasNodeMetadata) => void;
|
||||||
updateNode: (patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
|
updateNode: (patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
|
||||||
@@ -71,6 +72,8 @@ export type CanvasNodeDefinition = {
|
|||||||
minimapColor?: string;
|
minimapColor?: string;
|
||||||
showInCreateMenu?: boolean; // 默认 true
|
showInCreateMenu?: boolean; // 默认 true
|
||||||
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
|
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
|
||||||
|
hidePanel?: boolean; // 为 true 时:点击/新建不弹出下方面板(含内置生图面板),纯展示型节点用
|
||||||
|
autoOpenPanel?: boolean; // 为 true 时:单击节点自动打开自定义 Panel(默认仅内置节点单击自动打开)
|
||||||
keepAspectRatio?: (node: CanvasNodeData) => boolean;
|
keepAspectRatio?: (node: CanvasNodeData) => boolean;
|
||||||
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
|
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
|
||||||
// 渲染:内置节点由 canvas-node 内部渲染器负责,可不提供 Content
|
// 渲染:内置节点由 canvas-node 内部渲染器负责,可不提供 Content
|
||||||
|
|||||||
Reference in New Issue
Block a user