175 lines
6.3 KiB
TypeScript
175 lines
6.3 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|