refactor: extract canvas text prompt composer

This commit is contained in:
2026-06-05 18:08:16 +08:00
parent ef05667caa
commit 31046eae58
2 changed files with 190 additions and 115 deletions
+16 -115
View File
@@ -186,6 +186,7 @@ import {
} from "./canvasWorkflowDeserialize"; } from "./canvasWorkflowDeserialize";
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents"; import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
import type { CanvasNodeToolbarAction } from "./canvasComponents"; import type { CanvasNodeToolbarAction } from "./canvasComponents";
import { CanvasTextPromptComposer } from "./CanvasTextPromptComposer";
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels"; import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing"; import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";
@@ -4156,121 +4157,21 @@ function CanvasPage({
onMouseDown={(event) => handleNodeResizeStart(event, "text", textNode.id, textNode.size)} onMouseDown={(event) => handleNodeResizeStart(event, "text", textNode.id, textNode.size)}
/> />
</div> </div>
{textNodeActive && !isCanvasNodeMoving ? (() => { {textNodeActive && !isCanvasNodeMoving ? (
const mentionOptions = buildNodeMentionOptions("text", textNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks); <CanvasTextPromptComposer
const mentionState = textNodeMentionStates[textNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 }; nodeId={textNode.id}
const filteredMentions = mentionState.open prompt={textNode.prompt}
? mentionOptions.filter((o) => !mentionState.query || o.searchText.includes(mentionState.query.toLowerCase())) canGenerate={textNodeCanGenerate}
: []; isGenerating={textNodeGenerating}
mentionOptions={buildNodeMentionOptions("text", textNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
const handlePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { mentionState={textNodeMentionStates[textNode.id]}
const value = e.target.value; onPromptChange={updateTextNodePrompt}
const caret = e.target.selectionStart || 0; onMentionStateChange={setTextNodeMentionStates}
updateTextNodePrompt(textNode.id, value); onCloseMention={closeTextNodeMention}
onInsertMention={insertTextNodeMention}
// Detect @-mention trigger onGenerate={handleGenerateTextNode}
const beforeCaret = value.slice(0, caret); />
const atIdx = beforeCaret.lastIndexOf("@"); ) : null}
if (atIdx >= 0) {
const query = beforeCaret.slice(atIdx + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
return;
}
}
closeTextNodeMention(textNode.id);
};
const handlePromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!mentionState.open || filteredMentions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { ...mentionState, activeIndex: (mentionState.activeIndex + 1) % filteredMentions.length } }));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { ...mentionState, activeIndex: (mentionState.activeIndex - 1 + filteredMentions.length) % filteredMentions.length } }));
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const opt = filteredMentions[mentionState.activeIndex];
if (opt) {
const ta = e.currentTarget;
insertTextNodeMention(textNode.id, opt, ta);
}
} else if (e.key === "Escape") {
e.preventDefault();
closeTextNodeMention(textNode.id);
}
};
const handlePromptSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const ta = e.currentTarget;
const caret = ta.selectionStart || 0;
setTextNodeMentionStates((prev) => {
const cur = prev[textNode.id];
if (!cur?.open) return prev;
return { ...prev, [textNode.id]: { ...cur, caret } };
});
};
return (
<div className="studio-canvas-text-composer">
<div className="studio-canvas-text-composer__input-wrap">
<textarea
value={textNode.prompt}
onMouseDown={(event) => event.stopPropagation()}
onChange={handlePromptChange}
onKeyDown={handlePromptKeyDown}
onSelect={handlePromptSelect}
placeholder="写下你想讲的故事、场景或角色设定。@引用连接的节点"
/>
{mentionState.open ? (
<div className="studio-canvas-mention-panel">
{filteredMentions.length > 0 ? filteredMentions.map((opt, idx) => (
<button
key={opt.token}
type="button"
className={`studio-canvas-mention-item${idx === mentionState.activeIndex ? " is-active" : ""}`}
onMouseDown={(e) => { e.preventDefault(); const ta = e.currentTarget.closest(".studio-canvas-text-composer")?.querySelector("textarea") || null; insertTextNodeMention(textNode.id, opt, ta as HTMLTextAreaElement | null); }}
>
<span className="studio-canvas-mention-thumb">
{opt.kind === "image" && opt.previewUrl ? <img src={opt.previewUrl} alt="" /> : opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}
</span>
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
<span className="studio-canvas-mention-token">{opt.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}>
<span className="studio-canvas-mention-label"></span>
</div>
)}
</div>
) : null}
</div>
<div className="studio-canvas-text-composer__footer">
<button
type="button"
className={`studio-canvas-text-composer__send studio-canvas-generate-button${textNodeCanGenerate && !textNodeGenerating ? " is-ready" : ""}`}
title={textNodeGenerating ? "生成中" : "生成"}
disabled={textNodeGenerating || !textNodeCanGenerate}
aria-busy={textNodeGenerating}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
if (!textNodeGenerating && textNodeCanGenerate) {
void handleGenerateTextNode(textNode.id);
}
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<SendOutlined />
</button>
</div>
</div>
);
})() : null}
</div> </div>
</div> </div>
); );
@@ -0,0 +1,174 @@
import { SendOutlined } from "@ant-design/icons";
import type { CSSProperties, Dispatch, SetStateAction } from "react";
import type { CanvasPromptMentionOption, CanvasPromptMentionState } from "./canvasTypes";
const MENTION_BOUNDARY_RE = /\s|[,。、;:!??(){}[\]<>]/;
const EMPTY_MENTION_STYLE: CSSProperties = { opacity: 0.5, pointerEvents: "none" };
const DEFAULT_MENTION_STATE: CanvasPromptMentionState = {
open: false,
query: "",
start: 0,
caret: 0,
activeIndex: 0,
};
interface CanvasTextPromptComposerProps {
nodeId: string;
prompt: string;
canGenerate: boolean;
isGenerating: boolean;
mentionOptions: CanvasPromptMentionOption[];
mentionState?: CanvasPromptMentionState;
onPromptChange: (nodeId: string, prompt: string) => void;
onMentionStateChange: Dispatch<SetStateAction<Record<string, CanvasPromptMentionState>>>;
onCloseMention: (nodeId: string) => void;
onInsertMention: (
nodeId: string,
option: CanvasPromptMentionOption,
textarea: HTMLTextAreaElement | null,
) => void;
onGenerate: (nodeId: string) => void | Promise<void>;
}
export function CanvasTextPromptComposer({
nodeId,
prompt,
canGenerate,
isGenerating,
mentionOptions,
mentionState = DEFAULT_MENTION_STATE,
onPromptChange,
onMentionStateChange,
onCloseMention,
onInsertMention,
onGenerate,
}: CanvasTextPromptComposerProps) {
const filteredMentions = mentionState.open
? mentionOptions.filter((option) => !mentionState.query || option.searchText.includes(mentionState.query.toLowerCase()))
: [];
const handlePromptChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value;
const caret = event.target.selectionStart || 0;
onPromptChange(nodeId, value);
const beforeCaret = value.slice(0, caret);
const atIndex = beforeCaret.lastIndexOf("@");
if (atIndex >= 0) {
const query = beforeCaret.slice(atIndex + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
onMentionStateChange((prev) => ({
...prev,
[nodeId]: { open: true, query, start: atIndex, caret, activeIndex: 0 },
}));
return;
}
}
onCloseMention(nodeId);
};
const handlePromptKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!mentionState.open || filteredMentions.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
onMentionStateChange((prev) => ({
...prev,
[nodeId]: { ...mentionState, activeIndex: (mentionState.activeIndex + 1) % filteredMentions.length },
}));
} else if (event.key === "ArrowUp") {
event.preventDefault();
onMentionStateChange((prev) => ({
...prev,
[nodeId]: {
...mentionState,
activeIndex: (mentionState.activeIndex - 1 + filteredMentions.length) % filteredMentions.length,
},
}));
} else if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
const option = filteredMentions[mentionState.activeIndex];
if (option) {
onInsertMention(nodeId, option, event.currentTarget);
}
} else if (event.key === "Escape") {
event.preventDefault();
onCloseMention(nodeId);
}
};
const handlePromptSelect = (event: React.SyntheticEvent<HTMLTextAreaElement>) => {
const caret = event.currentTarget.selectionStart || 0;
onMentionStateChange((prev) => {
const current = prev[nodeId];
if (!current?.open) return prev;
return { ...prev, [nodeId]: { ...current, caret } };
});
};
return (
<div className="studio-canvas-text-composer">
<div className="studio-canvas-text-composer__input-wrap">
<textarea
value={prompt}
onMouseDown={(event) => event.stopPropagation()}
onChange={handlePromptChange}
onKeyDown={handlePromptKeyDown}
onSelect={handlePromptSelect}
placeholder="写下你想讲的故事、场景或角色设定。@引用连接的节点"
/>
{mentionState.open ? (
<div className="studio-canvas-mention-panel">
{filteredMentions.length > 0 ? filteredMentions.map((option, index) => (
<button
key={option.token}
type="button"
className={`studio-canvas-mention-item${index === mentionState.activeIndex ? " is-active" : ""}`}
onMouseDown={(event) => {
event.preventDefault();
const textarea = event.currentTarget
.closest(".studio-canvas-text-composer")
?.querySelector("textarea") ?? null;
onInsertMention(nodeId, option, textarea);
}}
>
<span className="studio-canvas-mention-thumb">
{option.kind === "image" && option.previewUrl ? (
<img src={option.previewUrl} alt="" />
) : option.kind === "image" ? "🖼" : option.kind === "video" ? "🎬" : "📝"}
</span>
<span className="studio-canvas-mention-label">{option.nodeTitle}</span>
<span className="studio-canvas-mention-token">{option.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={EMPTY_MENTION_STYLE}>
<span className="studio-canvas-mention-label"></span>
</div>
)}
</div>
) : null}
</div>
<div className="studio-canvas-text-composer__footer">
<button
type="button"
className={`studio-canvas-text-composer__send studio-canvas-generate-button${canGenerate && !isGenerating ? " is-ready" : ""}`}
title={isGenerating ? "生成中" : "生成"}
disabled={isGenerating || !canGenerate}
aria-busy={isGenerating}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isGenerating && canGenerate) {
void onGenerate(nodeId);
}
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<SendOutlined />
</button>
</div>
</div>
);
}