Files
Genarrative/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx
T
k88936 fbfc7e5e62 修复:候选菜单的 key 改用引用身份,同名素材不再撞 key
- 导出 resourceReferences 的 chatReferenceKey(只认稳定身份、不含显示名的身份键),并加注释说明它为什么不能省
- ReferenceMentionOption 由 super(chatReferenceMentionToken(...)) 改为 super(chatReferenceKey(...)):characters/hero.png 与 enemies/hero.png 都展开成 @hero,拿显示 token 当 key 会让 React 撞 key、复用错 DOM,键盘高亮与选中落到另一条候选上
- referenceSourceProviders 用例补一批同名素材:断言两条候选的显示 token 相同、身份键不同
2026-09-22 17:06:08 +08:00

950 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import {
LexicalTypeaheadMenuPlugin,
MenuOption,
type MenuRenderFn,
useBasicTypeaheadTriggerMatch,
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isElementNode,
$isLineBreakNode,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_HIGH,
type EditorState,
KEY_ENTER_COMMAND,
type LexicalNode,
type TextNode,
} from 'lexical';
import { Loader2, RotateCcw, Sparkles } from 'lucide-react';
import {
forwardRef,
type ReactNode,
type Ref,
type RefObject,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import RichTextInput from '../../components/RichTextInput';
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
import {
chatPromptDraftKey,
readChatPromptPolishReminderDisabled,
shouldRemindChatPromptPolish,
writeChatPromptPolishReminderDisabled,
} from './chatPromptPolish';
import { ChatPromptPolishReminder } from './ChatPromptPolishReminder';
import type { ReferenceProvider } from './reference-source/types';
import {
$createResourceReferenceNode,
$isResourceReferenceNode,
ResourceReferenceNode,
} from './ResourceReferenceNode';
import {
buildContentFromTextTokens,
type ChatComposerDraft,
type ChatReference,
chatReferenceDisplayHint,
chatReferenceKey,
chatReferenceMentionToken,
chatReferenceToContentPart,
dedupeChatReferences,
joinMentionText,
} from './resourceReferences';
import { usePromptPolish } from './usePromptPolish';
type ResourceReferenceInputProps = {
onChange?: (draft: ChatComposerDraft) => void;
onEditorStateChange?: (editorState: EditorState) => void;
initialContent?: DirectCodexUserContentPart[];
/**
* 宿主注入的引用来源,**按数组顺序取第一个非空回答**。
*
* 输入区不认识任何引用种类:候选菜单按各 provider 的触发符动态派生,part 的身份解析、
* 改名刷新与正文文本形态也都问它们。没注入就没有这类引用——只注入资源 provider 就只有 `@`。
*/
providers: readonly ReferenceProvider[];
/**
* 项目路径只给输入区自己的润色链路用(润色服务按项目上下文改写提示词)。
* 引用候选与素材选择器都不再从这里拿数据。
*/
projectPath: string;
disabled?: boolean;
placeholder?: string;
ariaLabel: string;
multiline?: boolean;
rows?: number;
/**
* 内置「AI 润色 / 恢复原文」动作是否渲染,默认 `true`。
*
* 聊天输入区用内置那一份(不带资源编辑场景上下文);资源侧快速编辑由宿主自己的
* `ResourcePromptPolishSlot` 承担润色(带场景上下文与长度上限),传 `false`
* 避免同一个面板里出现两个润色入口。
*/
showPolishAction?: boolean;
/**
* 宿主的外部浮层(素材选择器)打开时为 `true`:Enter 让位给下层默认行为,不提交表单。
* 与候选菜单打开时的处置同一口径。
*/
submitSuppressed?: boolean;
/** 输入区操作排(编辑器右侧那一列)里宿主注入的内容,例如素材选择器的 `@` 触发钮。 */
inputActions?: ReactNode;
inputRef?: RefObject<HTMLDivElement | null>;
/** 输入区容器元素:宿主定位自己的浮层时用它当锚点。 */
containerRef?: RefObject<HTMLDivElement | null>;
};
export type ResourceReferenceInputHandle = {
insertReferences: (references: ChatReference[]) => void;
insertText: (text: string) => void;
focus: () => void;
clear: () => void;
replaceText: (text: string) => void;
/** 直接读取 Lexical 当前状态,不在宿主组件复制一份编辑器 state。 */
getDraft: () => ChatComposerDraft;
};
class ReferenceMentionOption extends MenuOption {
reference: ChatReference;
label: string;
hint: string;
constructor(reference: ChatReference) {
// key 必须是引用的唯一身份:显示 token(`@显示名`)不唯一,同名素材会撞 key 并让
// React 复用错 DOM、键盘高亮落到别的候选上。显示 token 只留给 `label`。
super(chatReferenceKey(reference));
this.reference = reference;
this.label = chatReferenceMentionToken(reference);
this.hint = chatReferenceDisplayHint(reference);
}
}
/**
* 编辑器节点 → canonical content part**原样透传**:段落分隔(root 子节点之间补的
* `\n`)、软换行、chip 后的分隔空格都各自成 part,不做空白过滤、不与相邻 part 合并。
* 有效输入只在整条 content 上判定(`hasMeaningfulDirectCodexContent` 与 Rust
* `validate_direct_codex_user_item` 同口径),前端不替用户改写他输入了什么。
*/
function collectDraftParts(
node: LexicalNode,
references: ChatReference[],
content: DirectCodexUserContentPart[],
) {
if ($isTextNode(node)) {
const text = node.getTextContent();
// Lexical 不会留下空 TextNode;这只防「空串 part」落进 app-server 输入。
if (text) {
content.push({ type: 'input_text', text });
}
return;
}
if ($isLineBreakNode(node)) {
content.push({ type: 'input_text', text: '\n' });
return;
}
if ($isResourceReferenceNode(node)) {
references.push(node.__reference);
content.push(chatReferenceToContentPart(node.__reference));
return;
}
if ($isElementNode(node)) {
node.getChildren().forEach((child, index) => {
if (index > 0 && node.getType() === 'root') {
content.push({ type: 'input_text', text: '\n' });
}
collectDraftParts(child, references, content);
});
}
}
type DraftProjection = {
references: ChatReference[];
content: DirectCodexUserContentPart[];
};
/** 仅供编辑器内部派生引用(重建文本草稿时用);对外只暴露 canonical content。 */
function readDraftProjectionFromNodes(): DraftProjection {
const references: ChatReference[] = [];
const content: DirectCodexUserContentPart[] = [];
collectDraftParts($getRoot(), references, content);
return {
references: dedupeChatReferences(references),
content,
};
}
/** 按编辑器自己的口径读 canonical 草稿;只能在 Lexical 读/更新上下文里调用。 */
function readDraftFromNodes(): ChatComposerDraft {
const projection = readDraftProjectionFromNodes();
return { content: projection.content };
}
// The pure projection is exported for submit-time reads and focused tests.
// eslint-disable-next-line react-refresh/only-export-components
export function readResourceReferenceDraft(
editorState: EditorState | null,
): ChatComposerDraft {
if (!editorState) {
return { content: [] };
}
return editorState.read(readDraftFromNodes);
}
/** 第一个认得这个 part 的 provider 给出的引用;都不认得时返回 `null`。 */
function referenceFromPart(
providers: readonly ReferenceProvider[],
part: DirectCodexUserContentPart,
): ChatReference | null {
for (const provider of providers) {
const reference = provider.toReference(part);
if (reference) return reference;
}
return null;
}
/** 第一个认得这个 part 的 provider 给出的正文 token;都不认得(例如文本 part)时返回 `null`。 */
function mentionTokenFromPart(
providers: readonly ReferenceProvider[],
part: DirectCodexUserContentPart,
): string | null {
for (const provider of providers) {
const token = provider.mentionToken(part);
if (token) return token;
}
return null;
}
/**
* 草稿的展示文本:把每个引用 part 按注入的 provider 展开成它的 token,其余文本逐字保留。
*
* 与出站(发给润色服务 / 队列 chip / agent)的口径是同一份 `joinMentionText`
* 所以润色回包还能按同一批 token 原位换回真 part,不需要第二套文本口径。
*/
function promptTextFromContent(
providers: readonly ReferenceProvider[],
content: readonly DirectCodexUserContentPart[],
): string {
return joinMentionText(content, (part) =>
mentionTokenFromPart(providers, part),
);
}
/**
* 润色回写:回包文本里还剩哪个 token,就在那个位置换回真的 part——引用与附件同一套扫描
* 口径(provider 的 `mentionToken`),所以润色改过的那段文字里如果还留着 `@显示名` /
* `$名称` / `@附件名`,chip 原位复活;整体被改写的那些 part 补在末尾,不丢。
*
* TODO: 润色服务整体改写文本、把 `@名称` token 也删掉时,前端无法反推原位置,只能补在末尾。
* 需要“润色精确恢复引用/附件原位置”时,由产品补一个带位置信息的润色协议,不在前端猜字符串。
*/
function applyPolishedTextToRoot(
value: string,
current: DraftProjection,
providers: readonly ReferenceProvider[],
) {
// 候选按 canonical content 原顺序取:引用、Skill、runtime 区域与附件共用一套 token 扫描,
// 与出站给润色服务的文本口径一致,因此回包保留下来的 token 能原位换回真 part。
const candidates = current.content.flatMap((part) => {
const token = mentionTokenFromPart(providers, part);
return token ? [{ token, part }] : [];
});
applyContentToRoot(buildContentFromTextTokens(value, candidates), providers);
}
function applyContentToRoot(
content: readonly DirectCodexUserContentPart[],
providers: readonly ReferenceProvider[],
) {
const root = $getRoot();
root.clear();
let paragraph = $createParagraphNode();
root.append(paragraph);
content.forEach((part) => {
if (part.type === 'input_text') {
const lines = part.text.split('\n');
lines.forEach((line, index) => {
if (line) paragraph.append($createTextNode(line));
if (index < lines.length - 1) {
paragraph = $createParagraphNode();
root.append(paragraph);
}
});
return;
}
const reference = referenceFromPart(providers, part);
if (reference) {
paragraph.append($createResourceReferenceNode(reference));
}
});
}
/**
* 引用身份刷新(资源改名等):每个节点问第一个认得它的 provider 要新引用,
* 拿到不同实例就换成新节点。providers 原样返回表示无需改写。
*/
function $refreshReferenceNodes(providers: readonly ReferenceProvider[]) {
const staleNodes: {
node: ResourceReferenceNode;
reference: ChatReference;
}[] = [];
$getRoot()
.getChildren()
.forEach((block) => {
if (!$isElementNode(block)) return;
block.getChildren().forEach((child) => {
if (!$isResourceReferenceNode(child)) return;
for (const provider of providers) {
const nextReference = provider.refresh(child.__reference);
if (!nextReference) continue;
if (nextReference !== child.__reference) {
staleNodes.push({ node: child, reference: nextReference });
}
return;
}
});
});
return staleNodes;
}
function ResourceReferenceEditor({
onChange,
onEditorStateChange,
initialContent,
providers,
projectPath,
disabled,
multiline,
showPolishAction = true,
submitSuppressed = false,
inputActions,
composerRef,
rootRef,
}: ResourceReferenceInputProps & {
composerRef?: Ref<ResourceReferenceInputHandle>;
rootRef: RefObject<HTMLDivElement | null>;
}) {
const [editor] = useLexicalComposerContext();
const skipInitialDraftChangeRef = useRef(false);
// providers 每次渲染都可能是新数组(宿主按 assets 派生)。回调和命令处理只经 ref 读最新值,
// 避免因为 identity 变化反复重注册 HIGH 优先级命令。
const providersRef = useRef(providers);
providersRef.current = providers;
const submitSuppressedRef = useRef(submitSuppressed);
submitSuppressedRef.current = submitSuppressed;
// Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为
// 打开哪个菜单、宿主的浮层开关变化反复重注册 HIGH 优先级命令。
const mentionMenuOpenRef = useRef(false);
const openMenuTriggersRef = useRef<Set<string>>(new Set());
const handleMenuOpenChange = useCallback((trigger: string, open: boolean) => {
if (open) {
openMenuTriggersRef.current.add(trigger);
} else {
openMenuTriggersRef.current.delete(trigger);
}
mentionMenuOpenRef.current = openMenuTriggersRef.current.size > 0;
}, []);
const insertReferences = useCallback(
(nextReferences: ChatReference[]) => {
if (nextReferences.length === 0) return;
editor.update(() => {
let selection = $getSelection();
// 跨会话恢复草稿后选区可能仍指向已被重建掉的节点,这里统一回落到草稿末尾,
// 避免把引用插到一个已经不存在的位置。
if (
!$isRangeSelection(selection) ||
!selection.anchor.getNode().isAttached()
) {
$getRoot().selectEnd();
selection = $getSelection();
}
if ($isRangeSelection(selection)) {
selection.insertNodes(
nextReferences.flatMap((reference) => [
$createResourceReferenceNode(reference),
$createTextNode(' '),
]),
);
}
});
editor.focus();
},
[editor],
);
const insertText = useCallback(
(text: string) => {
const insert = text.replace(/\s+$/u, '');
if (!insert.trim()) return;
editor.update(() => {
let selection = $getSelection();
if (
!$isRangeSelection(selection) ||
!selection.anchor.getNode().isAttached()
) {
$getRoot().selectEnd();
selection = $getSelection();
}
if ($isRangeSelection(selection)) {
const rootText = $getRoot().getTextContent();
if (rootText && !/\s$/u.test(rootText)) selection.insertText(' ');
selection.insertText(insert);
}
});
editor.focus();
},
[editor],
);
const applyContent = useCallback(
(content: readonly DirectCodexUserContentPart[]) => {
applyContentToRoot(content, providersRef.current);
},
[],
);
useImperativeHandle(
composerRef,
() => ({
insertReferences,
insertText,
focus: () => editor.focus(),
clear: () => {
editor.update(() => {
applyContent([]);
$getRoot().selectEnd();
});
},
replaceText: (text: string) => {
editor.update(() => {
const current = readDraftProjectionFromNodes();
applyPolishedTextToRoot(text, current, providersRef.current);
$getRoot().selectEnd();
});
},
getDraft: () => readResourceReferenceDraft(editor.getEditorState()),
}),
[applyContent, editor, insertReferences, insertText],
);
useEffect(() => {
editor.setEditable(!disabled);
}, [disabled, editor]);
const initialDraftAppliedRef = useRef(false);
useEffect(() => {
if (initialDraftAppliedRef.current || !initialContent) {
return;
}
// provider 的数据可能晚于编辑器挂载到达(例如 manifest 还在加载)。
// 草稿里已经有它认不出、但它迟早能认出来的 part 时先不落:落下就等于把那条引用静默丢掉。
const hasUnresolvedPart = initialContent.some(
(part) =>
part.type !== 'input_text' &&
referenceFromPart(providers, part) === null,
);
const pendingProvider = providers.some(
(provider) => provider.isReady && !provider.isReady(),
);
if (hasUnresolvedPart && pendingProvider) {
return;
}
initialDraftAppliedRef.current = true;
editor.update(() => {
applyContent(initialContent);
skipInitialDraftChangeRef.current = initialContent.length > 0;
$getRoot().selectEnd();
});
}, [applyContent, editor, initialContent, providers]);
// 引用身份变化(资源改名)后刷新已有 chip 的显示名:改写节点会触发 OnChangePlugin
// 把带新显示名的草稿同步回父级,chip 与候选列表都不会残留旧名。
// Lexical 的更新可能排到微任务里提交,这里额外挂一次更新监听兜底。
const refreshReferenceLabels = useCallback(() => {
const hasStaleReferences = editor
.getEditorState()
.read(() => $refreshReferenceNodes(providersRef.current).length > 0);
if (!hasStaleReferences) return;
editor.update(() => {
$refreshReferenceNodes(providersRef.current).forEach(
({ node, reference }) => {
node.replace($createResourceReferenceNode(reference));
},
);
});
}, [editor]);
useEffect(() => {
refreshReferenceLabels();
return editor.registerUpdateListener(() => {
refreshReferenceLabels();
});
// providers 一并当依赖:宿主刚把新清单换进来时立刻刷一次,不必等下一次编辑。
}, [editor, providers, refreshReferenceLabels]);
// Enter 提交只属于单行输入区(聊天 / 项目总控)。多行输入区(资源卡快速编辑提示词)
// 的 Enter 必须留给换行,否则用户没法在提示词里分行。
useEffect(() => {
if (multiline) return undefined;
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (
!event ||
event.shiftKey ||
event.isComposing ||
// 候选菜单 / 宿主的素材选择器开着时 Enter 属于它们:返回 false 把按键让给
// 下层默认行为(LexicalTypeaheadMenuPlugin 在 NORMAL 优先级用 Enter 选候选),
// 而不是在 HIGH 优先级抢先提交表单。
mentionMenuOpenRef.current ||
submitSuppressedRef.current
) {
return false;
}
event.preventDefault();
(event.target as HTMLElement | null)?.closest('form')?.requestSubmit();
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor, multiline]);
// —— C8 AI 润色与发送前提醒 ——
// 润色状态机抽到 `usePromptPolish`(资源侧两处入口共用同一份);这里只剩下
// 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。
const [reminderOpen, setReminderOpen] = useState(false);
const [reminderPolishing, setReminderPolishing] = useState(false);
const [reminderDisabled, setReminderDisabled] = useState(() =>
readChatPromptPolishReminderDisabled(),
);
const acknowledgedDraftKeyRef = useRef<string | null>(null);
// 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。
const liveDraftRef = useRef<ChatComposerDraft>({
content: initialContent ?? [],
});
const [draftText, setDraftText] = useState(() =>
promptTextFromContent(providers, initialContent ?? []),
);
const reminderDisabledRef = useRef(reminderDisabled);
reminderDisabledRef.current = reminderDisabled;
/**
* 我们自己回填进草稿的那一份文本,用于区分「润色回填」与「用户手改」:
* 只有后者该把上一轮往返留下的提示(截断 / 与原文相同)收掉,
* 否则刚显示出来的提示会被自己的回填立刻清掉。
*/
const appliedPromptRef = useRef<string | null>(null);
const applyPromptText = useCallback(
(text: string) => {
appliedPromptRef.current = text;
editor.update(() => {
const current = readDraftProjectionFromNodes();
applyPolishedTextToRoot(text, current, providersRef.current);
$getRoot().selectEnd();
});
},
[editor],
);
const readPromptText = useCallback(
() =>
promptTextFromContent(providersRef.current, liveDraftRef.current.content),
[],
);
const resolvePolishContext = useCallback(
() => projectPath || null,
[projectPath],
);
const promptPolishState = usePromptPolish({
readPrompt: readPromptText,
applyPrompt: applyPromptText,
resolveContext: resolvePolishContext,
});
const {
polishing,
error: polishError,
notice: polishNotice,
originalText: polishedOriginalText,
polish: runPolish,
restoreOriginal: restoreOriginalPrompt,
clearError: clearPolishError,
clearNotice: clearPolishNotice,
reset: resetPromptPolish,
} = promptPolishState;
useEffect(() => {
if (
appliedPromptRef.current !== null &&
draftText === appliedPromptRef.current
) {
return;
}
clearPolishNotice();
}, [clearPolishNotice, draftText]);
const polishPrompt = useCallback(async () => {
await runPolish();
}, [runPolish]);
const closeReminder = useCallback(() => {
setReminderOpen(false);
clearPolishError();
}, [clearPolishError]);
// 用户已在提醒面板里做出选择:记下本轮草稿指纹,再真正提交表单。
// 输入区不在表单里时(例如单独渲染的单元测试)没有可提交的表单事件,仅取消提醒状态。
const submitCurrentDraft = useCallback(() => {
setReminderOpen(false);
acknowledgedDraftKeyRef.current = chatPromptDraftKey(
liveDraftRef.current.content,
);
rootRef.current?.closest('form')?.requestSubmit();
}, [rootRef]);
const useOriginalAndSubmit = useCallback(() => {
clearPolishError();
submitCurrentDraft();
}, [clearPolishError, submitCurrentDraft]);
const polishAndSubmitFromReminder = useCallback(async () => {
if (reminderPolishing) return;
setReminderPolishing(true);
try {
const polished = await runPolish({
failureMessage: 'AI 润色失败,可重试或使用原文提交',
});
// 失败时留在提醒面板里,用户可以选择重试或直接使用原文提交。
if (!polished) return;
submitCurrentDraft();
} finally {
setReminderPolishing(false);
}
}, [reminderPolishing, runPolish, submitCurrentDraft]);
const setPromptPolishReminderDisabled = useCallback((disabled: boolean) => {
writeChatPromptPolishReminderDisabled(disabled);
setReminderDisabled(disabled);
}, []);
// 发送前提醒拦截:在捕获阶段拦下 form 的 submit,阻止 React 的表单提交处理器执行,
// 等用户在独立面板里做出选择后再用 requestSubmit() 真正提交。
useEffect(() => {
const form = rootRef.current?.closest('form');
if (!form) return;
const handleFormSubmit = (event: Event) => {
if (
!shouldRemindChatPromptPolish({
content: liveDraftRef.current.content,
prompt: promptTextFromContent(
providersRef.current,
liveDraftRef.current.content,
),
acknowledgedDraftKey: acknowledgedDraftKeyRef.current,
reminderDisabled: reminderDisabledRef.current,
})
) {
return;
}
event.preventDefault();
event.stopPropagation();
setReminderOpen(true);
};
form.addEventListener('submit', handleFormSubmit, true);
return () => form.removeEventListener('submit', handleFormSubmit, true);
}, [rootRef]);
return (
<>
<div className="resource-reference-input-actions">
{inputActions}
{showPolishAction ? (
<>
<button
type="button"
className="resource-reference-input-polish"
aria-label="AI 润色"
title="AI 润色"
aria-busy={polishing}
disabled={disabled || polishing || draftText.trim() === ''}
onMouseDown={(event) => event.preventDefault()}
onClick={() => void polishPrompt()}
>
{polishing ? (
<Loader2
size={15}
className="animate-spin"
aria-hidden="true"
/>
) : (
<Sparkles size={15} aria-hidden="true" />
)}
</button>
{polishedOriginalText !== null ? (
<button
type="button"
className="resource-reference-input-restore"
aria-label="恢复原文"
title="恢复原文"
disabled={disabled || polishing}
onMouseDown={(event) => event.preventDefault()}
onClick={restoreOriginalPrompt}
>
<RotateCcw size={15} aria-hidden="true" />
</button>
) : null}
</>
) : null}
</div>
{/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */}
{/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */}
{showPolishAction &&
!reminderOpen &&
(polishing || polishError || polishNotice) ? (
<span
className="resource-reference-input-status"
role="status"
aria-live="polite"
>
{polishing ? '润色中…' : (polishError ?? polishNotice)}
</span>
) : null}
{providers.map((provider) =>
provider.trigger ? (
<ProviderMentionMenu
key={provider.trigger}
provider={provider}
rootRef={rootRef}
onOpenChange={handleMenuOpenChange}
/>
) : null,
)}
{reminderOpen ? (
<ChatPromptPolishReminder
busy={reminderPolishing}
error={polishError}
reminderDisabled={reminderDisabled}
onPolishAndSubmit={() => void polishAndSubmitFromReminder()}
onUseOriginalAndSubmit={useOriginalAndSubmit}
onClose={closeReminder}
onReminderDisabledChange={setPromptPolishReminderDisabled}
/>
) : null}
<OnChangePlugin
onChange={(editorState) => {
const nextDraft = readResourceReferenceDraft(editorState);
liveDraftRef.current = nextDraft;
setDraftText(
promptTextFromContent(providersRef.current, nextDraft.content),
);
if (nextDraft.content.length === 0) {
resetPromptPolish();
acknowledgedDraftKeyRef.current = null;
}
if (skipInitialDraftChangeRef.current) {
skipInitialDraftChangeRef.current = false;
return;
}
onEditorStateChange?.(editorState);
onChange?.(nextDraft);
}}
/>
</>
);
}
/**
* 一种引用来源的候选菜单:触发符、候选与插入全按 provider 走。
*
* 输入区不再写死 `@` / `$` 两条链,而是「有几个带触发符的 provider 就有几个菜单」。
*/
function ProviderMentionMenu({
provider,
rootRef,
onOpenChange,
}: {
provider: ReferenceProvider;
rootRef: RefObject<HTMLDivElement | null>;
onOpenChange: (trigger: string, open: boolean) => void;
}) {
const trigger = provider.trigger ?? '';
const [query, setQuery] = useState<string | null>(null);
const triggerFn = useBasicTypeaheadTriggerMatch(trigger, {
minLength: 0,
maxLength: 64,
allowWhitespace: false,
});
const handleQueryChange = useCallback(
(nextQuery: string | null) => {
setQuery(nextQuery);
onOpenChange(trigger, nextQuery !== null);
},
[onOpenChange, trigger],
);
const options = useMemo(() => {
if (query === null || !provider.match) return [];
return provider
.match(query)
.map((reference) => new ReferenceMentionOption(reference));
}, [provider, query]);
const renderMenu: MenuRenderFn<ReferenceMentionOption> = useCallback(
(_anchorElementRef, itemProps) => {
const inputRect = rootRef.current?.getBoundingClientRect();
if (!inputRect || itemProps.options.length === 0) {
return null;
}
const viewportPadding = 12;
const menuWidth = Math.min(
Math.max(280, inputRect.width),
Math.min(420, window.innerWidth - viewportPadding * 2),
);
const left = Math.min(
Math.max(viewportPadding, inputRect.left),
Math.max(
viewportPadding,
window.innerWidth - menuWidth - viewportPadding,
),
);
const availableAbove = Math.max(0, inputRect.top - viewportPadding - 8);
const availableBelow = Math.max(
0,
window.innerHeight - inputRect.bottom - viewportPadding - 8,
);
const openAbove = availableBelow < 160 && availableAbove > availableBelow;
const maxHeight = Math.max(
120,
Math.min(240, openAbove ? availableAbove : availableBelow),
);
const top = openAbove
? Math.max(viewportPadding, inputRect.top - maxHeight - 8)
: inputRect.bottom + 8;
return createPortal(
<div
className="resource-reference-menu"
role="listbox"
aria-label="候选引用"
style={{
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
width: `${menuWidth}px`,
maxHeight: `${maxHeight}px`,
}}
>
{itemProps.options.map((option, index) => (
<button
type="button"
role="option"
key={option.key}
ref={(element) => option.setRefElement(element)}
aria-selected={itemProps.selectedIndex === index}
className={
itemProps.selectedIndex === index ? 'is-active' : undefined
}
onMouseEnter={() => itemProps.setHighlightedIndex(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => itemProps.selectOptionAndCleanUp(option)}
>
<span>{option.label}</span>
<small>{option.hint}</small>
</button>
))}
</div>,
document.body,
);
},
[rootRef],
);
const handleSelectOption = useCallback(
(
option: ReferenceMentionOption,
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
) => {
textNodeContainingQuery?.remove();
const selection = $getSelection();
const node = $createResourceReferenceNode(option.reference);
if ($isRangeSelection(selection)) {
selection.insertNodes([node, $createTextNode(' ')]);
} else {
const paragraph = $createParagraphNode();
paragraph.append(node, $createTextNode(' '));
$getRoot().append(paragraph);
}
closeMenu();
},
[],
);
return (
<LexicalTypeaheadMenuPlugin<ReferenceMentionOption>
options={options}
triggerFn={triggerFn}
onQueryChange={handleQueryChange}
onSelectOption={handleSelectOption}
menuRenderFn={renderMenu}
anchorClassName="resource-reference-menu-anchor"
preselectFirstItem
/>
);
}
export const ResourceReferenceInput = forwardRef<
ResourceReferenceInputHandle,
ResourceReferenceInputProps
>(function ResourceReferenceInput({ containerRef, ...props }, ref) {
const rootRef = useRef<HTMLDivElement | null>(null);
const assignContainer = useCallback(
(element: HTMLDivElement | null) => {
rootRef.current = element;
if (containerRef) containerRef.current = element;
},
[containerRef],
);
return (
<RichTextInput
namespace="agc-resource-reference-input"
nodes={[ResourceReferenceNode]}
containerRef={assignContainer}
containerClassName={`resource-reference-input${props.multiline ? '' : ' is-single-line'}`}
disabled={props.disabled}
contentEditable={
<ContentEditable
aria-label={props.ariaLabel}
className="resource-reference-input-editor"
ref={props.inputRef}
style={
props.multiline && props.rows
? { minHeight: `${Math.max(72, props.rows * 22)}px` }
: undefined
}
/>
}
placeholder={
<span className="resource-reference-input-placeholder">
{props.placeholder}
</span>
}
>
<ResourceReferenceEditor {...props} composerRef={ref} rootRef={rootRef} />
</RichTextInput>
);
});