refactor: update comments for clarity and consistency across multiple files

This commit is contained in:
HouYunFei
2026-08-05 14:17:03 +08:00
parent 0f6809251a
commit 5a7116f924
43 changed files with 218 additions and 223 deletions
@@ -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
+4 -4
View File
@@ -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 });
+2 -2
View File
@@ -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 };