mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
feat(html-node): enhance HTML node plugin with interactive toolbar and improved editor functionality
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "canvas-plugin-html",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas HTML 节点插件",
|
||||
|
||||
@@ -1,10 +1,68 @@
|
||||
// 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";
|
||||
|
||||
// 源码编辑器行高/字号,行号槽与文本域必须完全一致才能对齐
|
||||
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) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const value = ctx.node.metadata?.content || "";
|
||||
const editing = Boolean(ctx.node.metadata?.editing);
|
||||
const interactive = Boolean(ctx.node.metadata?.interactive);
|
||||
const upstreamText = useMemo(
|
||||
() =>
|
||||
ctx
|
||||
@@ -16,23 +74,29 @@ function HtmlContent({ ctx }: CanvasNodeContentProps) {
|
||||
);
|
||||
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 (
|
||||
<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: 13 }}>选中节点,点上方工具条的 ✎ 编辑 HTML</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 预览态:iframe 仅在「节点已选中 + 开启交互」时接收鼠标;否则穿透,保证首次点击能选中节点、弹出工具条。
|
||||
const liveIframe = interactive && ctx.isSelected;
|
||||
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 }}>
|
||||
<span style={{ fontSize: 26 }}>{"</>"}</span>
|
||||
<span style={{ fontSize: 14 }}>编辑 HTML 源码</span>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +104,7 @@ function HtmlContent({ ctx }: CanvasNodeContentProps) {
|
||||
export default definePlugin({
|
||||
id: "html",
|
||||
name: "HTML 节点",
|
||||
version: "1.0.0",
|
||||
version: "1.1.0",
|
||||
description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本",
|
||||
nodes: [
|
||||
{
|
||||
@@ -51,7 +115,36 @@ export default definePlugin({
|
||||
defaultSize: { width: 420, height: 320 },
|
||||
defaultMetadata: { content: "" },
|
||||
minimapColor: "#ec4899",
|
||||
hidePanel: true, // 纯展示型节点:点击/新建不弹出下方生图面板
|
||||
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;
|
||||
theme: CanvasTheme;
|
||||
scale: number;
|
||||
isSelected: boolean; // 该节点当前是否被选中(用于按需启用 iframe 交互等)
|
||||
updateMetadata: (patch: CanvasNodeMetadata) => void;
|
||||
updateNode: (patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
|
||||
// 图访问
|
||||
@@ -194,6 +195,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;
|
||||
// 渲染
|
||||
|
||||
Reference in New Issue
Block a user