refactor: extract canvas text prompt composer
This commit is contained in:
@@ -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