抽取通用富文本输入组件

新增可复用的 RichTextInput Lexical 输入外壳。

让首页输入与资源引用输入共享编辑器、回车提交和禁用状态。

保留首页附件与图片粘贴节点能力,并移除 DirectProject 草稿 legacy fallback。

统一聊天草稿 canonical content 状态并更新资源引用测试。
This commit is contained in:
2026-09-15 19:42:37 +08:00
parent 35733f0e33
commit ec286b3480
6 changed files with 197 additions and 144 deletions
+18 -12
View File
@@ -734,15 +734,23 @@ export function App({
);
}
const [chatInput, setChatInput] = useState(() =>
supervisorChatOnly && initialProjectPath
? readSupervisorChatDraft(initialProjectPath)
: '',
);
const [chatReferences, setChatReferences] = useState<ChatReference[]>([]);
const [chatContent, setChatContent] = useState<DirectCodexUserContentPart[]>(
[],
);
const [chatDraft, setChatDraft] = useState<ChatComposerDraft>(() => ({
text:
supervisorChatOnly && initialProjectPath
? readSupervisorChatDraft(initialProjectPath)
: '',
references: [],
content: [],
}));
const chatInput = chatDraft.text;
const chatReferences = chatDraft.references;
const chatContent = chatDraft.content ?? [];
const setChatInput = (text: string) =>
setChatDraft((current) => ({ ...current, text }));
const setChatReferences = (references: ChatReference[]) =>
setChatDraft((current) => ({ ...current, references }));
const setChatContent = (content: DirectCodexUserContentPart[]) =>
setChatDraft((current) => ({ ...current, content }));
const chatComposerRef = useRef<ResourceReferenceInputHandle | null>(null);
const [chatAgentBusy, setChatAgentBusy] = useState(false);
const [directCodexProgress, setDirectCodexProgress] = useState('');
@@ -3937,9 +3945,7 @@ export function App({
}
function handleChatComposerChange(draft: ChatComposerDraft) {
setChatInput(draft.text);
setChatReferences(draft.references);
setChatContent(draft.content ?? []);
setChatDraft(draft);
}
useEffect(() => {
@@ -0,0 +1,102 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import {
COMMAND_PRIORITY_HIGH,
type EditorState,
KEY_ENTER_COMMAND,
type Klass,
type LexicalNode,
} from 'lexical';
import type { ReactElement, ReactNode, Ref } from 'react';
import { useEffect } from 'react';
type RichTextInputProps = {
namespace: string;
nodes: Klass<LexicalNode>[];
initialEditorState?: EditorState | null;
contentEditable?: ReactElement<typeof ContentEditable>;
placeholder?: ReactElement;
containerClassName?: string;
containerRef?: Ref<HTMLDivElement>;
disabled?: boolean;
onChange?: (editorState: EditorState) => void;
onEnter?: () => void;
children?: ReactNode;
};
function SubmitOnEnter({ onEnter }: { onEnter?: () => void }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!onEnter) return undefined;
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (!event || event.shiftKey || event.isComposing) return false;
event.preventDefault();
onEnter();
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor, onEnter]);
return null;
}
function SetEditorEditable({ disabled }: { disabled: boolean }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
editor.setEditable(!disabled);
}, [disabled, editor]);
return null;
}
export default function RichTextInput({
namespace,
nodes,
initialEditorState,
contentEditable = <ContentEditable />,
placeholder,
containerClassName,
containerRef,
disabled = false,
onChange,
onEnter,
children,
}: RichTextInputProps) {
return (
<LexicalComposer
initialConfig={{
namespace,
nodes,
editorState: initialEditorState ?? undefined,
onError: (error) => {
throw error;
},
}}
>
<div
ref={containerRef}
className={containerClassName}
data-disabled={disabled ? 'true' : undefined}
>
<RichTextPlugin
contentEditable={contentEditable}
placeholder={placeholder}
ErrorBoundary={LexicalErrorBoundary}
/>
{children}
<SetEditorEditable disabled={disabled} />
<SubmitOnEnter onEnter={onEnter} />
{onChange ? <OnChangePlugin onChange={onChange} /> : null}
</div>
</LexicalComposer>
);
}
@@ -1,9 +1,6 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import {
LexicalTypeaheadMenuPlugin,
MenuOption,
@@ -58,6 +55,7 @@ import {
type GameIterationVersion,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { resolveTauriInvoke } from '../../app/tauri';
import RichTextInput from '../../components/RichTextInput';
import {
cancelLocalProjectResourcePreviewScope,
createProjectResourcePreviewRequestId,
@@ -175,10 +173,16 @@ function referenceListKey(references: ChatReference[]) {
return chatReferenceListKey(references);
}
function sameDraft(left: ChatComposerDraft, right: ChatComposerDraft) {
function sameDraftValue(left: ChatComposerDraft, right: ChatComposerDraft) {
return (
left.text === right.text &&
referenceListKey(left.references) === referenceListKey(right.references) &&
referenceListKey(left.references) === referenceListKey(right.references)
);
}
function sameCanonicalDraft(left: ChatComposerDraft, right: ChatComposerDraft) {
return (
sameDraftValue(left, right) &&
JSON.stringify(left.content ?? []) === JSON.stringify(right.content ?? [])
);
}
@@ -453,16 +457,14 @@ function ResourceReferenceEditor({
activeVersionId = null,
versions,
disabled,
placeholder,
ariaLabel,
multiline,
rows,
showTriggerButton = true,
showPolishAction = true,
inputRef,
composerRef,
rootRef,
}: ResourceReferenceInputProps & {
composerRef?: Ref<ResourceReferenceInputHandle>;
rootRef: RefObject<HTMLDivElement | null>;
}) {
const [editor] = useLexicalComposerContext();
const lastEmittedDraftRef = useRef<ChatComposerDraft>({
@@ -491,8 +493,6 @@ function ResourceReferenceEditor({
bottom: number;
width: number;
} | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const assetsContentSignature = assetsSignature(assets);
const versionsContentSignature = iterationsSignature(versions);
const assetReferences = useMemo(
@@ -610,7 +610,7 @@ function ResourceReferenceEditor({
text: value,
references,
};
if (sameDraft(lastEmittedDraftRef.current, nextDraft)) {
if (sameDraftValue(lastEmittedDraftRef.current, nextDraft)) {
return;
}
editor.update(() => {
@@ -783,7 +783,7 @@ function ResourceReferenceEditor({
setReminderOpen(false);
acknowledgedDraftKeyRef.current = chatPromptDraftKey(liveDraftRef.current);
rootRef.current?.closest('form')?.requestSubmit();
}, []);
}, [rootRef]);
const useOriginalAndSubmit = useCallback(() => {
clearPolishError();
@@ -831,7 +831,7 @@ function ResourceReferenceEditor({
};
form.addEventListener('submit', handleFormSubmit, true);
return () => form.removeEventListener('submit', handleFormSubmit, true);
}, []);
}, [rootRef]);
// 草稿发出去或被清空后重新开始一轮:清掉润色结果与「本轮已确认」标记。
useEffect(() => {
@@ -906,7 +906,7 @@ function ResourceReferenceEditor({
document.body,
);
},
[],
[rootRef],
);
const pickerReferences = useMemo(() => {
@@ -937,7 +937,7 @@ function ResourceReferenceEditor({
bottom: Math.max(12, window.innerHeight - rect.top + 8),
width,
});
}, []);
}, [rootRef]);
useEffect(() => {
if (!pickerOpen) {
@@ -954,31 +954,7 @@ function ResourceReferenceEditor({
}, [pickerOpen, updatePickerPosition]);
return (
<div
ref={rootRef}
className={`resource-reference-input${multiline ? '' : ' is-single-line'}`}
data-disabled={disabled ? 'true' : undefined}
>
<RichTextPlugin
contentEditable={
<ContentEditable
ref={inputRef}
aria-label={ariaLabel}
className="resource-reference-input-editor"
style={
multiline && rows
? { minHeight: `${Math.max(72, rows * 22)}px` }
: undefined
}
/>
}
placeholder={
<span className="resource-reference-input-placeholder">
{placeholder}
</span>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<>
<div className="resource-reference-input-actions">
{showTriggerButton ? (
<button
@@ -1206,14 +1182,14 @@ function ResourceReferenceEditor({
<OnChangePlugin
onChange={(editorState) => {
const nextDraft = readDraftFromEditorState(editorState);
if (sameDraft(lastEmittedDraftRef.current, nextDraft)) {
if (sameCanonicalDraft(lastEmittedDraftRef.current, nextDraft)) {
return;
}
lastEmittedDraftRef.current = nextDraft;
onChange(nextDraft);
}}
/>
</div>
</>
);
}
@@ -1285,17 +1261,33 @@ export const ResourceReferenceInput = forwardRef<
ResourceReferenceInputHandle,
ResourceReferenceInputProps
>(function ResourceReferenceInput(props, ref) {
const rootRef = useRef<HTMLDivElement | null>(null);
return (
<LexicalComposer
initialConfig={{
namespace: 'agc-resource-reference-input',
nodes: [ResourceReferenceNode],
onError: (error) => {
throw error;
},
}}
<RichTextInput
namespace="agc-resource-reference-input"
nodes={[ResourceReferenceNode]}
containerRef={rootRef}
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} />
</LexicalComposer>
<ResourceReferenceEditor {...props} composerRef={ref} rootRef={rootRef} />
</RichTextInput>
);
});
@@ -115,26 +115,12 @@ export function chatReferenceToContentPart(
}
export function chatComposerDraftToDirectCodexUserItem(
draft: ChatComposerDraft,
draft: ChatComposerDraft & { content: DirectCodexUserContentPart[] },
id: string,
): DirectCodexUserItem {
let content: DirectCodexUserContentPart[];
if (draft.content?.length) {
content = draft.content.filter(
(part) => part.type !== 'input_text' || part.text.trim().length > 0,
);
} else if (draft.references.length > 0) {
content = [
...(draft.text
? [{ type: 'input_text' as const, text: draft.text }]
: []),
...draft.references.map(chatReferenceToContentPart),
];
} else {
content = draft.text
? [{ type: 'input_text' as const, text: draft.text }]
: [];
}
const content = draft.content.filter(
(part) => part.type !== 'input_text' || part.text.trim().length > 0,
);
return {
type: 'message',
role: 'user',
@@ -1,9 +1,5 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { readImage, readText } from '@tauri-apps/plugin-clipboard-manager';
import {
$createParagraphNode,
@@ -13,13 +9,13 @@ import {
COMMAND_PRIORITY_EDITOR,
COMMAND_PRIORITY_HIGH,
createCommand,
KEY_ENTER_COMMAND,
type LexicalCommand,
PASTE_COMMAND,
} from 'lexical';
import { Upload } from 'lucide-react';
import React, { useEffect, useRef } from 'react';
import RichTextInput from '../../../../components/RichTextInput';
import type { Draft, HomeAttachmentDraft } from '../../useHomeDraftStore';
import { $createAttachmentNode, AttachmentNode } from './attachmentNode';
@@ -101,29 +97,9 @@ function selectEditableEndWhenNeeded() {
root.selectEnd();
}
function EditorPlugins({
onChange,
onEnter,
}: Pick<RichInputAreaProps, 'onChange' | 'onEnter'>) {
function EditorPlugins() {
const [editor] = useLexicalComposerContext();
useEffect(
() =>
editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (!event || event.shiftKey || event.isComposing) {
return false;
}
event.preventDefault();
onEnter();
return true;
},
COMMAND_PRIORITY_HIGH,
),
[editor, onEnter],
);
useEffect(
() =>
editor.registerCommand(
@@ -188,13 +164,7 @@ function EditorPlugins({
[editor],
);
return (
<OnChangePlugin
onChange={(editorState) => {
onChange(editorState);
}}
/>
);
return null;
}
export function UploadButton() {
@@ -229,34 +199,27 @@ export function UploadButton() {
export default function RichInputArea(props: RichInputAreaProps) {
return (
<LexicalComposer
initialConfig={{
namespace: 'home-rich-input',
nodes: [AttachmentNode],
editorState: props.value ?? undefined,
onError: (error) => {
throw error;
},
}}
>
<div className="relative grid min-h-9 gap-2">
<RichTextPlugin
contentEditable={
<ContentEditable
aria-label="创作想法"
className="min-h-9 w-full whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 text-[13px] text-(--platform-text-strong) outline-0"
/>
}
placeholder={
<span className="pointer-events-none absolute inset-x-0 top-0 text-[13px] text-(--platform-text-muted)">
{props.placeholder}
</span>
}
ErrorBoundary={LexicalErrorBoundary}
<RichTextInput
namespace="home-rich-input"
nodes={[AttachmentNode]}
initialEditorState={props.value}
onChange={props.onChange}
onEnter={props.onEnter}
containerClassName="relative grid min-h-9 gap-2"
contentEditable={
<ContentEditable
aria-label="创作想法"
className="min-h-9 w-full whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 text-[13px] text-(--platform-text-strong) outline-0"
/>
{props.children}
</div>
<EditorPlugins onChange={props.onChange} onEnter={props.onEnter} />
</LexicalComposer>
}
placeholder={
<span className="pointer-events-none absolute inset-x-0 top-0 text-[13px] text-(--platform-text-muted)">
{props.placeholder}
</span>
}
>
<EditorPlugins />
{props.children}
</RichTextInput>
);
}
@@ -54,6 +54,10 @@ describe('DirectProject user Response item', () => {
source: 'asset-picker',
},
],
content: [
{ type: 'input_text', text: '请使用素材' },
{ type: 'agc_resource_reference', resourceId: 'asset-hero' },
],
};
expect(