抽取通用富文本输入组件
新增可复用的 RichTextInput Lexical 输入外壳。 让首页输入与资源引用输入共享编辑器、回车提交和禁用状态。 保留首页附件与图片粘贴节点能力,并移除 DirectProject 草稿 legacy fallback。 统一聊天草稿 canonical content 状态并更新资源引用测试。
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
+45
-53
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user