mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-06 09:24:24 +08:00
refactor: update comments for clarity and consistency across multiple files
This commit is contained in:
@@ -81,7 +81,7 @@ export function NodeCreateMenu({ position, onCreate, onClose }: { position: Posi
|
||||
useNodeRegistryVersion();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const definitions = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false);
|
||||
// 点击菜单外的空白处自动关闭
|
||||
// Close automatically when clicking outside the menu.
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) onClose();
|
||||
|
||||
@@ -27,7 +27,7 @@ type CanvasNodePromptPanelProps = {
|
||||
onStop: (nodeId: string) => void;
|
||||
mentionReferences?: CanvasResourceReference[];
|
||||
onImageSettingsOpenChange?: (open: boolean) => void;
|
||||
modeOverride?: CanvasNodeGenerationMode; // 插件节点用 useBuiltinPanel.mode 指定生成类型
|
||||
modeOverride?: CanvasNodeGenerationMode; // Plugin nodes set their generation type through useBuiltinPanel.mode.
|
||||
};
|
||||
|
||||
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, modeOverride }: CanvasNodePromptPanelProps) {
|
||||
@@ -42,7 +42,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
const isEditingExistingContent = hasTextContent || hasImageContent;
|
||||
const [prompt, setPrompt] = useState(node.metadata?.prompt || "");
|
||||
|
||||
// 仅在切换到其它节点时恢复对应提示词;同一节点生成完成后继续保留当前输入。
|
||||
// Restore prompts only when switching nodes; preserve the current input after generation on the same node.
|
||||
useEffect(() => {
|
||||
setPrompt(node.metadata?.prompt || "");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -133,13 +133,13 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
|
||||
const isGroup = data.type === CanvasNodeType.Group;
|
||||
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
|
||||
// 支持「交互/移动」开关的节点:移动态(默认)内容不吃指针,拖动整块;交互态内容可操作。
|
||||
// forceInteractive(如编辑态)强制可交互;空态(无内容)始终可交互,避免上传/生成按钮点不动。
|
||||
// Nodes with the interaction/move toggle ignore content pointer events in move mode and allow interaction in interactive mode.
|
||||
// forceInteractive states such as editing stay interactive, as do empty nodes so their upload and generation actions remain usable.
|
||||
const supportsInteractionToggle = Boolean(definition?.interactionToggle);
|
||||
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):卡片背景/边框透明,直接融入画布;选中/关联态仍显示描边以便定位
|
||||
// Transparent nodes such as SVGs blend into the canvas while retaining outlines for selected or related states.
|
||||
const transparentBg = Boolean(definition?.transparentBackground);
|
||||
const isActive = isConnectionTarget || isSelected || isFocusRelated;
|
||||
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
|
||||
@@ -449,7 +449,7 @@ function NodeContent(props: NodeContentRendererProps) {
|
||||
const Renderer = nodeContentRenderers[props.node.type as CanvasNodeType];
|
||||
if (Renderer) return <Renderer {...props} />;
|
||||
|
||||
// 插件节点:有注册渲染器则渲染,否则展示缺少插件占位
|
||||
// Render plugin nodes with their registered renderer, or show the missing-plugin placeholder.
|
||||
const definition = getNodeDefinition(props.node.type);
|
||||
if (definition?.Content && props.pluginContext) {
|
||||
const PluginContent = definition.Content;
|
||||
|
||||
@@ -38,7 +38,7 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 打开面板时拉取官方清单(仅在尚未加载过时,避免重复请求)
|
||||
// Fetch the official registry when opening the panel, but only if it has not been loaded yet.
|
||||
useEffect(() => {
|
||||
if (open && official.length === 0 && !loadingOfficial && !officialError) void loadOfficial();
|
||||
}, [open, official.length, loadingOfficial, officialError, loadOfficial]);
|
||||
@@ -82,8 +82,8 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
|
||||
}
|
||||
};
|
||||
|
||||
// 已安装插件的操作区:启用开关 +(非本地)更新/卸载
|
||||
// upgradable=true 时(远程有更高版本),更新按钮高亮为主色以提示升级
|
||||
// Installed plugin actions: enable toggle plus update/uninstall for non-local plugins.
|
||||
// Highlight the update action when a newer remote version is available.
|
||||
const installedControls = (record: InstalledPlugin, upgradable = false) => (
|
||||
<>
|
||||
<Switch size="small" checked={record.enabled} loading={busyId === record.id} onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), t(checked ? "canvas.plugins.enabled" : "canvas.plugins.disabled"))} />
|
||||
@@ -105,8 +105,8 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
|
||||
</>
|
||||
);
|
||||
|
||||
// 图标外挂一个绿点(右上角),用于「有可升级版本」的提示。
|
||||
// boxShadow 画一圈与卡片同色的描边环,让绿点从图标上「浮起」。
|
||||
// Add a green dot at the icon's top-right corner when an update is available.
|
||||
// A card-colored box shadow separates the dot visually from the icon.
|
||||
const withUpgradeDot = (icon: ReactNode) => (
|
||||
<span className="relative inline-flex">
|
||||
{icon}
|
||||
@@ -126,7 +126,7 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
|
||||
</div>
|
||||
);
|
||||
|
||||
// 通用插件行:图标 + 标题(名称 + 版本)+ 描述 + 右侧操作
|
||||
// Shared plugin row: icon, title with name and version, description, and actions.
|
||||
const row = (key: string, icon: ReactNode, name: string, version: string, subtitle: string | undefined, right: ReactNode) => (
|
||||
<div key={key} className="flex items-center gap-3 rounded-xl border px-3 py-2.5" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}>
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-lg text-base" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
|
||||
@@ -169,14 +169,14 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
|
||||
<div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto">
|
||||
{official.map((entry) => {
|
||||
const record = recordById.get(entry.id);
|
||||
// 已安装且远程版本更高 → 显示绿点并高亮升级按钮
|
||||
// Show the update dot and highlight the action when the remote version is newer.
|
||||
const upgradable = Boolean(record && hasUpgrade(record.version, entry.version));
|
||||
const icon = entry.icon || <Puzzle className="size-4" />;
|
||||
return row(
|
||||
entry.id,
|
||||
upgradable ? withUpgradeDot(icon) : icon,
|
||||
entry.name,
|
||||
// 有升级时标题版本展示为「本地 → 远程」,让用户看清升级到哪个版本
|
||||
// Show local and remote versions in the title so the update target is explicit.
|
||||
upgradable && record ? `${record.version} → ${entry.version}` : entry.version,
|
||||
entry.description,
|
||||
record ? (
|
||||
|
||||
@@ -29,14 +29,14 @@ type Token =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "reference"; label: string };
|
||||
|
||||
// 提示词面板专用的 contentEditable 输入框:@ 引用图片时直接内嵌真实缩略图 chip,而不是「图片1」文字。
|
||||
// 序列化时 chip → 引用 label 文本(如「图片1」),保证发给生成的 value 语义与旧 textarea 版一致。
|
||||
// Prompt-panel contentEditable input: @ references embed thumbnail chips instead of plain label text.
|
||||
// Serialization converts chips back to reference labels so the generated value matches the former textarea semantics.
|
||||
export function CanvasPromptChipInput({ value, references, onChange, onSubmit, className, style, placeholder }: Props) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const composingRef = useRef(false);
|
||||
// 记录我们最近一次向父级 emit 的 value。聚焦时若 value 与它一致,说明是本组件输入的回声,
|
||||
// 跳过重建以免打断光标 / IME;若不一致(如发送后父级把 prompt 清空、或从提示词库插入),即使聚焦也要重建。
|
||||
// Track the last value emitted to the parent. An identical focused value is this component's own echo,
|
||||
// so skip rebuilding to preserve the caret and IME. Rebuild external changes even while focused.
|
||||
const lastEmittedRef = useRef(value);
|
||||
const [mention, setMention] = useState<MentionState | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
@@ -44,7 +44,7 @@ export function CanvasPromptChipInput({ value, references, onChange, onSubmit, c
|
||||
|
||||
const activeReferences = useMemo(() => references.filter((item) => item.active), [references]);
|
||||
const referenceByLabel = useMemo(() => new Map(activeReferences.map((item) => [item.label, item])), [activeReferences]);
|
||||
// 长 label 优先匹配,避免「图片1」把「图片10」切坏。
|
||||
// Match longer labels first so a shorter label cannot split a longer one.
|
||||
const activeLabels = useMemo(() => Array.from(new Set(activeReferences.map((item) => item.label))).sort((a, b) => b.length - a.length), [activeReferences]);
|
||||
const tokens = useMemo(() => parseTokens(value, activeLabels), [value, activeLabels]);
|
||||
|
||||
@@ -55,7 +55,7 @@ export function CanvasPromptChipInput({ value, references, onChange, onSubmit, c
|
||||
return activeReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.text || ""}`.toLowerCase().includes(query));
|
||||
}, [mention, activeReferences]);
|
||||
|
||||
// DOM ← value:未聚焦时按 value 重建;聚焦时仅当 value 是外部改动(非本组件回声)才重建。
|
||||
// Rebuild the DOM from value when unfocused, or when a focused value is an external change rather than an emitted echo.
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
@@ -328,7 +328,7 @@ function removeActiveMention() {
|
||||
range.deleteContents();
|
||||
}
|
||||
|
||||
// chip 是 contentEditable="false" 的原子块,光标紧邻它按 Backspace/Delete 时整块删除。
|
||||
// Chips are atomic contentEditable="false" blocks and are removed as a unit with adjacent Backspace/Delete presses.
|
||||
function deleteAdjacentReference(key: string) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount || !selection.isCollapsed) return false;
|
||||
@@ -380,7 +380,7 @@ function caretRect(): DOMRect | null {
|
||||
range.collapse(true);
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (rect.width || rect.height || rect.left || rect.top) return rect;
|
||||
// 空行/空编辑器时 range 无尺寸,退回到编辑器盒子。
|
||||
// Empty lines and editors produce a zero-sized range, so fall back to the editor bounds.
|
||||
const editor = closestEditor(range.startContainer);
|
||||
return editor ? editor.getBoundingClientRect() : null;
|
||||
}
|
||||
@@ -399,7 +399,7 @@ function placeCaretAtEnd(element: HTMLElement) {
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
// 按 active label(已按长度降序)把 value 文本切成「文本片段 + 命中的引用 label」。
|
||||
// Split value into text fragments and matching active labels, which are already sorted by descending length.
|
||||
function parseTokens(value: string, labels: string[]): Token[] {
|
||||
if (!labels.length) return value ? [{ type: "text", value }] : [];
|
||||
const escaped = labels.map(escapeRegExp).join("|");
|
||||
|
||||
@@ -90,7 +90,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
color: showOverlay ? "transparent" : style?.color,
|
||||
caretColor: style?.color || theme.node.text,
|
||||
cursor: "text",
|
||||
// showOverlay 时高亮 div 覆盖在 textarea 上,若不把 textarea 提到上层,原生插入光标(caret)会被盖住看不见
|
||||
// The highlight layer covers the textarea when showOverlay is active, so keep the textarea above it to preserve the native caret.
|
||||
...(showOverlay ? { position: "relative", zIndex: 1, background: "transparent", backgroundColor: "transparent" } : {}),
|
||||
} as CSSProperties;
|
||||
const menu = mention && candidates.length && textareaRef.current ? <MentionMenu textarea={textareaRef.current} caretIndex={mention.start} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null;
|
||||
@@ -220,8 +220,8 @@ function MentionMenu({ textarea, caretIndex, references, activeIndex, theme, onS
|
||||
const menuWidth = 256;
|
||||
const maxMenuHeight = 224;
|
||||
const gap = 6;
|
||||
// 菜单锚定到 @ 所在的光标像素位置(而非 textarea 底边),避免输入框较高时菜单离 @ 太远。
|
||||
// 画布可能被缩放,rect 是缩放后坐标,而镜像测量得到的是布局坐标,需按 scale 换算。
|
||||
// Anchor the menu to the pixel position of the @ caret instead of the textarea edge.
|
||||
// The canvas may be scaled: rect uses scaled coordinates while mirror measurements use layout coordinates, so apply the scale.
|
||||
const scale = textarea.offsetWidth ? rect.width / textarea.offsetWidth : 1;
|
||||
const computed = window.getComputedStyle(textarea);
|
||||
const lineHeight = (parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.4 || 20) * scale;
|
||||
@@ -295,7 +295,7 @@ function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
// 通过镜像 div 复刻 textarea 的排版,测出第 index 个字符处光标相对 textarea 的像素坐标(布局尺度,未含缩放)。
|
||||
// Mirror the textarea layout in a div to measure the caret at index in unscaled layout coordinates.
|
||||
const MIRROR_STYLE_PROPS = ["boxSizing", "width", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", "fontStyle", "fontVariant", "fontWeight", "fontStretch", "fontSize", "lineHeight", "fontFamily", "textAlign", "textIndent", "letterSpacing", "wordSpacing", "tabSize", "textTransform"] as const;
|
||||
|
||||
function getCaretPoint(textarea: HTMLTextAreaElement, index: number) {
|
||||
@@ -323,8 +323,8 @@ function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// 纯 textarea 无法做真正的原子 token,这里在删除时把整个引用 label 当作一个整体一次删掉,
|
||||
// 避免逐字删除把「图片1」删成「图片」。labels 需按长度降序传入以优先匹配更长的 label。
|
||||
// A plain textarea cannot represent atomic tokens, so delete a complete reference label at once.
|
||||
// Labels must be ordered by descending length so longer labels match first.
|
||||
function deleteAdjacentLabel(value: string, caret: number, direction: "backward" | "forward", labels: string[]): { value: string; caret: number } | null {
|
||||
if (direction === "backward") {
|
||||
const before = value.slice(0, caret);
|
||||
@@ -332,12 +332,12 @@ function deleteAdjacentLabel(value: string, caret: number, direction: "backward"
|
||||
if (!label) continue;
|
||||
const match = new RegExp(`(^|\\s)(${escapeRegExp(label)})\\s*$`).exec(before);
|
||||
if (match) {
|
||||
const start = match.index + match[1].length; // label 起始位置(保留前面的分隔空格)
|
||||
const start = match.index + match[1].length; // Label start; preserve the preceding separator space.
|
||||
return { value: value.slice(0, start) + value.slice(caret), caret: start };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 向后删除:光标前须是行首或空白,避免切进其它文字中间
|
||||
// Forward deletion requires the caret to follow a line boundary or whitespace to avoid cutting into other text.
|
||||
if (caret > 0 && !/\s/.test(value[caret - 1])) return null;
|
||||
const after = value.slice(caret);
|
||||
for (const label of labels) {
|
||||
|
||||
@@ -129,7 +129,7 @@ function TabButton({ label, active, theme, onClick }: { label: string; active: b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 画布 Tab —— 列出节点,点击居中放大并选中
|
||||
// Canvas tab: list nodes and center, zoom, and select the clicked node.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NODE_FILTER_VALUES = ["all", CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Text, CanvasNodeType.Audio, CanvasNodeType.Config, CanvasNodeType.Group];
|
||||
@@ -270,7 +270,7 @@ function CheckMark({ checked, theme }: { checked: boolean; theme: CanvasTheme })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布
|
||||
// Assets tab: collapsible type groups, tag filtering, and click-to-insert.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ASSET_GROUPS: { kind: AssetKind; icon: typeof Square }[] = [
|
||||
@@ -438,7 +438,7 @@ function AssetCover({ asset }: { asset: Asset }) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 提示词库 Tab —— 按来源折叠分组,展开时按需加载,点击复制 / 插入文本节点
|
||||
// Prompt library tab: collapsible source groups, lazy loading, and copy or text-node insertion actions.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
|
||||
@@ -506,7 +506,7 @@ function PromptSourceGroup({
|
||||
onView: (prompt: Prompt) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// 展开过一次即缓存,避免收起后重复请求;搜索命中时也需要拿到数据来计数。
|
||||
// Cache a source after its first expansion to avoid repeated requests; search results also need the data for counts.
|
||||
const showResults = open || !!keyword.trim();
|
||||
const query = useQuery({ queryKey: ["side-panel-prompts", sourceId], queryFn: () => fetchSourcePrompts(sourceId), enabled: showResults, staleTime: 1000 * 60 * 60 });
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export function CanvasToolbar({
|
||||
const [panelX, setPanelX] = useState(0);
|
||||
const [extensionsOpen, setExtensionsOpen] = useState(false);
|
||||
const [extPanelX, setExtPanelX] = useState(0);
|
||||
// 扩展(插件)节点,随注册表变化实时更新
|
||||
// Keep extension plugin nodes synchronized with registry changes.
|
||||
useNodeRegistryVersion();
|
||||
const extensionDefs = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false && getNodePluginId(def.type) !== "builtin");
|
||||
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
|
||||
@@ -72,7 +72,7 @@ export function CanvasToolbar({
|
||||
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
|
||||
const tip = hovered ? toolLabel(hovered, t) : "";
|
||||
|
||||
// 点击工具栏(含弹出面板)以外的地方,关闭弹出的扩展节点/画布外观面板
|
||||
// Close extension-node and canvas-appearance popovers when clicking outside the toolbar and its panels.
|
||||
useEffect(() => {
|
||||
if (!extensionsOpen && !appearanceOpen) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
|
||||
@@ -167,7 +167,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// 阻止画布滚动导致页面滚动;但浮层(创建菜单/弹窗等)内允许原生滚动
|
||||
// Prevent canvas scrolling from moving the page while preserving native scrolling inside overlays and dialogs.
|
||||
const preventWheelScroll = (event: WheelEvent) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (target?.closest("[data-canvas-no-zoom],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown")) return;
|
||||
|
||||
@@ -7,8 +7,8 @@ import { registerNodeDefinitions } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import type { CanvasNodeDefinition, CanvasNodeResource } from "@/types/canvas-plugin";
|
||||
|
||||
// 内置节点的可扩展元数据(尺寸/初始 metadata 复用 NODE_SPECS)。
|
||||
// 渲染仍由 canvas-node 内部渲染器负责,故不提供 Content。
|
||||
// Extensible metadata for built-in nodes, reusing NODE_SPECS for size and initial metadata.
|
||||
// Rendering remains in canvas-node's internal renderer, so no Content component is provided.
|
||||
function builtinResource(node: CanvasNodeData): CanvasNodeResource | null {
|
||||
if (node.type === CanvasNodeType.Image && node.metadata?.content) return { kind: "image", url: node.metadata.content };
|
||||
if (node.type === CanvasNodeType.Video && node.metadata?.content) return { kind: "video", url: node.metadata.content };
|
||||
|
||||
Reference in New Issue
Block a user