mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 06:54:06 +08:00
feat(svg-node): enhance SVG node with transparent background rendering and improved editing experience
This commit is contained in:
@@ -16,7 +16,7 @@ const nodeModules = join(root, "node_modules");
|
||||
// 与插件 src 里 definePlugin({version}) 保持一致,避免清单与产物版本脱节。
|
||||
const OFFICIAL = [
|
||||
{ id: "markdown", dir: "markdown", name: "Markdown 节点", description: "在画布中编辑与渲染 Markdown", icon: "📝" },
|
||||
{ id: "svg", dir: "svg", name: "SVG 节点", description: "编辑与渲染 SVG,可接收上游文本节点的 SVG 源码", icon: "🔷" },
|
||||
{ id: "svg", dir: "svg", name: "SVG 节点", description: "透明背景渲染 SVG,可接收上游文本节点的 SVG 源码", icon: "🔷" },
|
||||
{ id: "html", dir: "html", name: "HTML 节点", description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本", icon: "🌐" },
|
||||
{ id: "panorama", dir: "panorama", name: "3D 全景节点", description: "查看 360° 等距柱状全景图,可从上游图片节点取图", icon: "🧭" },
|
||||
{ id: "sticky-note", dir: "sticky-note", name: "便利贴节点", description: "可自选颜色、双击编辑、拖动即可移动的便利贴", icon: "📌" },
|
||||
|
||||
@@ -275,6 +275,8 @@ export type CanvasNodeDefinition = {
|
||||
showInCreateMenu?: boolean; // 默认 true
|
||||
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
|
||||
hidePanel?: boolean; // 为 true 时:点击/新建不弹出下方面板(含内置生图面板),纯展示型节点用
|
||||
// 为 true 时:节点卡片背景与边框透明,内容直接融入画布(如 SVG/矢量图);选中时仍显示选中描边
|
||||
transparentBackground?: boolean;
|
||||
autoOpenPanel?: boolean; // 为 true 时:单击节点自动打开自定义 Panel(默认仅内置节点单击自动打开)
|
||||
// 复用宿主内置生成面板;与自定义 Panel 二选一(同时提供时优先 Panel)
|
||||
useBuiltinPanel?: CanvasBuiltinPanelConfig;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "canvas-plugin-svg",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas SVG 节点插件",
|
||||
|
||||
@@ -1,34 +1,75 @@
|
||||
// SVG 节点:编辑与渲染 SVG,无自身内容时可取上游文本节点里的 SVG 源码。
|
||||
import { definePlugin, useEffect, useState } from "@infinite-canvas/plugin-sdk";
|
||||
// SVG 节点:编辑与渲染 SVG,透明背景直接融入画布;无自身内容时可取上游文本节点里的 SVG 源码。
|
||||
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
const DEFAULT_SVG = '<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="40" fill="#6366f1"/></svg>';
|
||||
|
||||
function SvgContent({ ctx }: CanvasNodeContentProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const stored = ctx.node.metadata?.content;
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const stored = ctx.node.metadata?.content as string | undefined;
|
||||
const upstream = ctx
|
||||
.getUpstream()
|
||||
.map((node) => node.metadata?.content)
|
||||
.find((text) => text?.trim().startsWith("<svg"));
|
||||
.find((text): text is string => typeof text === "string" && text.trim().startsWith("<svg"));
|
||||
const value = stored ?? "";
|
||||
const svg = value.trim() || upstream || DEFAULT_SVG;
|
||||
const svg = value.trim() || upstream || "";
|
||||
|
||||
// 无自身内容但有上游 SVG:自动采用上游源码
|
||||
useEffect(() => {
|
||||
if (stored === undefined && upstream) ctx.updateMetadata({ content: upstream });
|
||||
}, [ctx, stored, upstream]);
|
||||
|
||||
// 点击节点外部时退出编辑。用 pointerdown 的 capture 阶段:宿主画布在 pointerdown 上
|
||||
// preventDefault 会抑制 mousedown,capture 又能先于宿主 stopPropagation 触发。
|
||||
useEffect(() => {
|
||||
if (!editing) return;
|
||||
const onDocDown = (e: PointerEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setEditing(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", onDocDown, true);
|
||||
return () => document.removeEventListener("pointerdown", onDocDown, true);
|
||||
}, [editing]);
|
||||
|
||||
// 交互控件上按下时阻止冒泡,避免误触发节点拖动/双击编辑
|
||||
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
|
||||
|
||||
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;
|
||||
|
||||
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 ? "预览" : "编辑源码"}>
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-canvas-no-zoom
|
||||
style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column", cursor: editing ? "text" : "move" }}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
<button type="button" style={toggle} onMouseDown={stop} onClick={() => setEditing((v) => !v)} title={editing ? "预览" : "编辑源码"}>
|
||||
{editing ? "👁" : "✎"}
|
||||
</button>
|
||||
{editing ? (
|
||||
<textarea autoFocus value={value} placeholder={DEFAULT_SVG} 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 }} />
|
||||
<textarea
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="粘贴 SVG 源码,如 <svg …>…</svg>"
|
||||
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
onMouseDown={stop}
|
||||
onPointerDown={stop}
|
||||
onWheel={stop}
|
||||
style={{ height: "100%", width: "100%", resize: "none", background: ctx.theme.node.fill, borderRadius: 16, padding: 16, boxSizing: "border-box", fontFamily: "monospace", fontSize: 12, outline: "none", border: `1px solid ${ctx.theme.node.stroke}`, color: ctx.theme.node.text }}
|
||||
/>
|
||||
) : svg ? (
|
||||
// pointerEvents:none 让整块可拖动移动,双击/拖拽都命中外层节点
|
||||
<div style={{ height: "100%", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 12, boxSizing: "border-box", pointerEvents: "none" }} dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
) : (
|
||||
<div style={{ height: "100%", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 16 }} dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
<div style={{ height: "100%", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 16, boxSizing: "border-box", color: ctx.theme.node.placeholder, fontSize: 13, textAlign: "center", pointerEvents: "none" }}>双击编辑,粘贴 SVG 源码</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -37,8 +78,8 @@ function SvgContent({ ctx }: CanvasNodeContentProps) {
|
||||
export default definePlugin({
|
||||
id: "svg",
|
||||
name: "SVG 节点",
|
||||
version: "1.0.0",
|
||||
description: "编辑与渲染 SVG,可接收上游文本节点的 SVG 源码",
|
||||
version: "1.1.0",
|
||||
description: "透明背景渲染 SVG 矢量图,可接收上游文本节点的 SVG 源码",
|
||||
nodes: [
|
||||
{
|
||||
type: "svg:vector",
|
||||
@@ -48,6 +89,8 @@ export default definePlugin({
|
||||
defaultSize: { width: 320, height: 320 },
|
||||
defaultMetadata: {},
|
||||
minimapColor: "#14b8a6",
|
||||
// 背景/边框透明,矢量图直接融入画布
|
||||
transparentBackground: true,
|
||||
Content: SvgContent,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -135,6 +135,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
const forceInteractive = supportsInteractionToggle ? Boolean(definition?.forceInteractive?.(data)) : false;
|
||||
const contentInteractive = !supportsInteractionToggle || forceInteractive || !data.metadata?.content ? true : Boolean(data.metadata?.interactive);
|
||||
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
|
||||
// 透明背景节点(如 SVG):卡片背景/边框透明,直接融入画布;选中/关联态仍显示描边以便定位
|
||||
const transparentBg = Boolean(definition?.transparentBackground);
|
||||
const isActive = isConnectionTarget || isSelected || isFocusRelated;
|
||||
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -348,8 +350,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
<div
|
||||
className="relative h-full w-full overflow-visible rounded-3xl border-2"
|
||||
style={{
|
||||
background: isGroup ? `${theme.toolbar.panel}66` : hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||
borderColor: isGroup ? (isGroupDropTarget || isActive ? selectionBlue : theme.node.stroke) : hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : theme.node.stroke,
|
||||
background: isGroup ? `${theme.toolbar.panel}66` : hasImageContent || hasVideoContent || transparentBg ? "transparent" : theme.node.fill,
|
||||
borderColor: isGroup ? (isGroupDropTarget || isActive ? selectionBlue : theme.node.stroke) : hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : transparentBg ? "transparent" : theme.node.stroke,
|
||||
borderStyle: isGroup ? "dashed" : "solid",
|
||||
boxShadow: isGroupDropTarget ? `0 0 0 2px ${selectionBlue}66, inset 0 0 0 999px ${selectionBlue}10` : isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined,
|
||||
}}
|
||||
@@ -378,7 +380,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`}
|
||||
style={
|
||||
{
|
||||
background: isGroup ? "transparent" : hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||
background: isGroup ? "transparent" : hasImageContent || hasVideoContent || transparentBg ? "transparent" : theme.node.fill,
|
||||
pointerEvents: contentInteractive ? undefined : "none",
|
||||
"--batch-from-x": `${batchMotion?.x || 0}px`,
|
||||
"--batch-from-y": `${batchMotion?.y || 0}px`,
|
||||
|
||||
@@ -109,6 +109,7 @@ export type CanvasNodeDefinition = {
|
||||
showInCreateMenu?: boolean; // 默认 true
|
||||
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
|
||||
hidePanel?: boolean; // 为 true 时:点击/新建不弹出下方面板(含内置生图面板),纯展示型节点用
|
||||
transparentBackground?: boolean; // 为 true 时:节点卡片背景与边框透明,内容直接融入画布(如 SVG/矢量图)
|
||||
autoOpenPanel?: boolean; // 为 true 时:单击节点自动打开自定义 Panel(默认仅内置节点单击自动打开)
|
||||
useBuiltinPanel?: CanvasBuiltinPanelConfig; // 复用宿主内置生成面板(与自定义 Panel 二选一)
|
||||
// 为 true 时:宿主自动提供「交互 ⇄ 移动」工具条开关,并按 metadata.interactive 控制内容层指针事件
|
||||
|
||||
Reference in New Issue
Block a user