refactor: extract canvas text prompt composer
This commit is contained in:
@@ -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,122 +4157,22 @@ 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("@");
|
|
||||||
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}
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user