refactor: directly store inner state

This commit is contained in:
2026-07-16 14:14:51 +08:00
parent 83b0dd75fb
commit b4be5b14de
8 changed files with 155 additions and 172 deletions
@@ -1,23 +1,17 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getNodeByKey, type NodeKey } from 'lexical';
import { X } from 'lucide-react';
import { useContext } from 'react';
import AttachmentContext from './attachmentContext';
import type { HomeAttachmentDraft } from '../../useHomeDraftStore';
export function AttachmentToken({
attachmentId,
attachment,
nodeKey,
}: {
attachmentId: string;
attachment: HomeAttachmentDraft;
nodeKey: NodeKey;
}) {
const [editor] = useLexicalComposerContext();
const { attachments } = useContext(AttachmentContext);
const attachment = attachments.get(attachmentId);
if (!attachment) {
return null;
}
return (
<span className="mx-0.75 inline-flex min-h-6 max-w-full items-center gap-1 rounded-[7px] border border-(--platform-subpanel-border) bg-(--platform-neutral-bg) px-1.75 align-middle text-[11px] text-(--platform-neutral-text)">
{attachment.file.name}
@@ -1,9 +0,0 @@
import { createContext } from 'react';
import type { HomeAttachmentDraft } from '../../useHomeDraftStore';
const AttachmentContext = createContext({
attachments: new Map<string, HomeAttachmentDraft>(),
});
export default AttachmentContext;
@@ -9,11 +9,12 @@ import {
} from 'lexical';
import type { ReactNode } from 'react';
import type { HomeAttachmentDraft } from '../../useHomeDraftStore';
import { AttachmentToken } from './attachment';
type SerializedAttachmentNode = Spread<
export type SerializedAttachmentNode = Spread<
{
attachmentId: string;
attachment: HomeAttachmentDraft;
type: 'home-attachment';
version: 1;
},
@@ -21,29 +22,29 @@ type SerializedAttachmentNode = Spread<
>;
export class AttachmentNode extends DecoratorNode<ReactNode> {
__attachmentId: string;
__attachment: HomeAttachmentDraft;
static getType() {
return 'home-attachment';
}
static clone(node: AttachmentNode) {
return new AttachmentNode(node.__attachmentId, node.__key);
return new AttachmentNode(node.__attachment, node.__key);
}
static importJSON(serializedNode: SerializedAttachmentNode) {
return $createAttachmentNode(serializedNode.attachmentId);
return $createAttachmentNode(serializedNode.attachment);
}
constructor(attachmentId: string, key?: NodeKey) {
constructor(attachment: HomeAttachmentDraft, key?: NodeKey) {
super(key);
this.__attachmentId = attachmentId;
this.__attachment = attachment;
}
exportJSON(): SerializedAttachmentNode {
return {
...super.exportJSON(),
attachmentId: this.__attachmentId,
attachment: this.__attachment,
type: 'home-attachment',
version: 1,
};
@@ -66,12 +67,14 @@ export class AttachmentNode extends DecoratorNode<ReactNode> {
}
decorate() {
return <AttachmentToken attachmentId={this.__attachmentId} nodeKey={this.__key} />;
return (
<AttachmentToken attachment={this.__attachment} nodeKey={this.__key} />
);
}
}
export function $createAttachmentNode(attachmentId: string) {
return $applyNodeReplacement(new AttachmentNode(attachmentId));
export function $createAttachmentNode(attachment: HomeAttachmentDraft) {
return $applyNodeReplacement(new AttachmentNode(attachment));
}
export function $isAttachmentNode(
@@ -7,39 +7,30 @@ import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { readImage, readText } from '@tauri-apps/plugin-clipboard-manager';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isElementNode,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_EDITOR,
COMMAND_PRIORITY_HIGH,
createCommand,
type LexicalCommand,
type LexicalNode,
PASTE_COMMAND,
} from 'lexical';
import { Upload } from 'lucide-react';
import React, { useEffect, useRef } from 'react';
import type { HomeAttachmentDraft, RichText } from '../../useHomeDraftStore';
import AttachmentContext from './attachmentContext';
import type { HomeAttachmentDraft, Draft } from '../../useHomeDraftStore';
import {
$createAttachmentNode,
$isAttachmentNode,
AttachmentNode,
} from './attachmentNode';
type RichInputAreaProps = {
value: readonly RichText[];
value: Draft;
placeholder: string;
onChange: (value: RichText[]) => void;
onChange: (value: Draft) => void;
children?: React.ReactNode;
};
type AttachmentRegistry = {
current: Map<string, HomeAttachmentDraft>;
};
const INSERT_ATTACHMENTS_COMMAND: LexicalCommand<HomeAttachmentDraft[]> =
createCommand('INSERT_HOME_ATTACHMENTS_COMMAND');
const INSERT_CLIPBOARD_TEXT_COMMAND: LexicalCommand<string> = createCommand(
@@ -113,10 +104,7 @@ function selectEditableEndWhenNeeded() {
function EditorPlugins({
onChange,
attachmentByIdRef,
}: Pick<RichInputAreaProps, 'onChange'> & {
attachmentByIdRef: AttachmentRegistry;
}) {
}: Pick<RichInputAreaProps, 'onChange'>) {
const [editor] = useLexicalComposerContext();
useEffect(
@@ -124,15 +112,12 @@ function EditorPlugins({
editor.registerCommand(
INSERT_ATTACHMENTS_COMMAND,
(nextAttachments) => {
nextAttachments.forEach((attachment) => {
attachmentByIdRef.current.set(attachment.id, attachment);
});
selectEditableEndWhenNeeded();
const currentSelection = $getSelection();
if ($isRangeSelection(currentSelection)) {
currentSelection.insertNodes(
nextAttachments.map((attachment) =>
$createAttachmentNode(attachment.id),
$createAttachmentNode(attachment),
),
);
}
@@ -140,7 +125,7 @@ function EditorPlugins({
},
COMMAND_PRIORITY_EDITOR,
),
[attachmentByIdRef, editor],
[editor],
);
useEffect(
@@ -189,39 +174,12 @@ function EditorPlugins({
return (
<OnChangePlugin
onChange={(editorState) => {
editorState.read(() => {
const parts: RichText[] = [];
collectRichText($getRoot(), parts, attachmentByIdRef.current);
onChange(parts);
});
onChange(editorState);
}}
/>
);
}
function collectRichText(
node: LexicalNode,
parts: RichText[],
attachmentById: ReadonlyMap<string, HomeAttachmentDraft>,
) {
if ($isTextNode(node)) {
parts.push({ type: 'text', text: node.getTextContent() });
return;
}
if ($isAttachmentNode(node)) {
const attachment = attachmentById.get(node.__attachmentId);
if (attachment) {
parts.push({ type: 'attachment', attachment });
}
return;
}
if ($isElementNode(node)) {
node
.getChildren()
.forEach((child) => collectRichText(child, parts, attachmentById));
}
}
export function UploadButton() {
const [editor] = useLexicalComposerContext();
const inputRef = useRef<HTMLInputElement | null>(null);
@@ -252,66 +210,36 @@ export function UploadButton() {
);
}
function richTextToAttachmentMap(parts: readonly RichText[]) {
const attachmentById = new Map<string, HomeAttachmentDraft>();
for (const part of parts) {
if (part.type === 'attachment') {
attachmentById.set(part.attachment.id, part.attachment);
}
}
return attachmentById;
}
export default function RichInputArea(props: RichInputAreaProps) {
const attachmentByIdRef = useRef(richTextToAttachmentMap(props.value));
return (
<AttachmentContext.Provider
value={{
attachments: attachmentByIdRef.current,
<LexicalComposer
initialConfig={{
namespace: 'home-rich-input',
nodes: [AttachmentNode],
editorState: props.value ?? undefined,
onError: (error) => {
throw error;
},
}}
>
<LexicalComposer
initialConfig={{
namespace: 'home-rich-input',
nodes: [AttachmentNode],
editorState: () => {
const paragraph = $createParagraphNode();
for (const part of props.value) {
paragraph.append(
part.type === 'text'
? $createTextNode(part.text)
: $createAttachmentNode(part.attachment.id),
);
}
$getRoot().append(paragraph);
},
onError: (error) => {
throw error;
},
}}
>
<div className="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 text-[13px] text-(--platform-text-muted)">
{props.placeholder}
</span>
}
ErrorBoundary={LexicalErrorBoundary}
/>
{props.children}
</div>
<EditorPlugins
onChange={props.onChange}
attachmentByIdRef={attachmentByIdRef}
<div className="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 text-[13px] text-(--platform-text-muted)">
{props.placeholder}
</span>
}
ErrorBoundary={LexicalErrorBoundary}
/>
</LexicalComposer>
</AttachmentContext.Provider>
{props.children}
</div>
<EditorPlugins onChange={props.onChange} />
</LexicalComposer>
);
}
@@ -1,4 +1,13 @@
import type { HomeAttachmentDraft, RichText } from '../../useHomeDraftStore';
import {
$getRoot,
$isElementNode,
$isLineBreakNode,
$isTextNode,
type LexicalNode,
} from 'lexical';
import type { HomeAttachmentDraft, Draft } from '../../useHomeDraftStore';
import { $isAttachmentNode } from './attachmentNode';
function ordinalSuffix(index: number) {
if (index % 100 >= 11 && index % 100 <= 13) return 'th';
@@ -7,26 +16,71 @@ function ordinalSuffix(index: number) {
'th'
);
}
export function richTextToPrompt(parts: readonly RichText[]) {
let attachmentIndex = 0;
return parts
.map((part) => {
if (part.type === 'text') return part.text;
attachmentIndex += 1;
return `{${attachmentIndex}${ordinalSuffix(attachmentIndex)} attachment: ${part.attachment.file.name}}`;
})
.join('')
.trim();
function collectPromptParts(
node: LexicalNode,
output: string[],
attachmentIndex: { value: number },
) {
if ($isTextNode(node)) {
output.push(node.getTextContent());
return;
}
if ($isLineBreakNode(node)) {
output.push('\n');
return;
}
if ($isAttachmentNode(node)) {
attachmentIndex.value += 1;
output.push(
`{${attachmentIndex.value}${ordinalSuffix(attachmentIndex.value)} attachment: ${node.__attachment.file.name}}`,
);
return;
}
if ($isElementNode(node)) {
const children = node.getChildren();
children.forEach((child, index) => {
if (index > 0 && node.getType() === 'root') {
output.push('\n');
}
collectPromptParts(child, output, attachmentIndex);
});
}
}
export function richTextToAttachments(parts: readonly RichText[]) {
const attachments: HomeAttachmentDraft[] = [];
const seenAttachmentIds = new Set<string>();
for (const part of parts) {
if (part.type !== 'attachment') continue;
if (seenAttachmentIds.has(part.attachment.id)) continue;
seenAttachmentIds.add(part.attachment.id);
attachments.push(part.attachment);
}
return attachments;
export function richTextToPrompt(richText: Draft) {
if (!richText) return '';
return richText.read(() => {
const output: string[] = [];
collectPromptParts($getRoot(), output, { value: 0 });
return output.join('').trim();
});
}
function collectAttachments(
node: LexicalNode,
attachments: HomeAttachmentDraft[],
seenAttachmentIds: Set<string>,
) {
if ($isAttachmentNode(node)) {
if (!seenAttachmentIds.has(node.__attachment.id)) {
seenAttachmentIds.add(node.__attachment.id);
attachments.push(node.__attachment);
}
return;
}
if ($isElementNode(node)) {
for (const child of node.getChildren()) {
collectAttachments(child, attachments, seenAttachmentIds);
}
}
}
export function richTextToAttachments(richText: Draft) {
if (!richText) return [];
return richText.read(() => {
const attachments: HomeAttachmentDraft[] = [];
collectAttachments($getRoot(), attachments, new Set());
return attachments;
});
}
@@ -64,7 +64,7 @@ export default function HomeView({
onProjectOpen,
}: HomeViewProps) {
const homeAgentMode = useLauncherHomeDraftStore((state) => state.mode);
const homeRichText = useLauncherHomeDraftStore((state) => state.richText);
const homeRichText = useLauncherHomeDraftStore((state) => state.draft);
const setHomeAgentMode = useLauncherHomeDraftStore((state) => state.setMode);
const setHomeRichText = useLauncherHomeDraftStore(
(state) => state.setRichText,
@@ -1,3 +1,4 @@
import type { EditorState } from 'lexical';
import { create } from 'zustand';
export type HomeAttachmentDraft = {
@@ -5,15 +6,7 @@ export type HomeAttachmentDraft = {
file: File;
};
export type RichText =
| {
type: 'text';
text: string;
}
| {
type: 'attachment';
attachment: HomeAttachmentDraft;
};
export type Draft = EditorState | null;
export type HomeAgentMode = 'game' | 'art' | 'doc';
@@ -25,24 +18,24 @@ export type HomeDraft = {
type UseHomeDraftStore = {
mode: HomeAgentMode;
richText: RichText[];
draft: Draft;
setMode: (mode: HomeAgentMode) => void;
setRichText: (richText: RichText[]) => void;
setRichText: (richText: Draft) => void;
reset: () => void;
};
const initialHomeDraft: Pick<
UseHomeDraftStore,
'mode' | 'richText'
'mode' | 'draft'
> = {
mode: 'game',
richText: [],
draft: null,
};
// Keep non-serializable File objects available while the Home view is unmounted.
// Keep the immutable Lexical snapshot available while the Home view is unmounted.
export const useLauncherHomeDraftStore = create<UseHomeDraftStore>((set) => ({
...initialHomeDraft,
setMode: (mode) => set({ mode }),
setRichText: (richText) => set({ richText }),
setRichText: (draft) => set({ draft: draft }),
reset: () => set(initialHomeDraft),
}));
@@ -14,6 +14,17 @@ import {
import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
const nativeClipboardMock = vi.hoisted(() => ({
text: '',
}));
vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({
readImage: vi.fn(async () => {
throw new Error('no native clipboard image');
}),
readText: vi.fn(async () => nativeClipboardMock.text),
}));
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import {
createGameCreationAppManifest,
@@ -93,6 +104,7 @@ function mockRoleAgentReply() {
afterEach(() => {
cleanup();
nativeClipboardMock.text = '';
window.history.pushState({}, '', '/');
window.localStorage?.clear();
delete window.__TAURI__;
@@ -1584,6 +1596,12 @@ describe('AI 游戏创作 App 界面边界', () => {
renderLauncherAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '做素材' }));
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '第一行\n第二行\n第三行';
fireEvent.paste(promptInput);
await waitFor(() => {
expect(promptInput.textContent).toContain('第一行\n第二行\n第三行');
});
const fileInput = document.querySelector(
'.launcher-file-input',
) as HTMLInputElement;
@@ -1628,7 +1646,9 @@ describe('AI 游戏创作 App 界面边界', () => {
message: {
role: 'user',
agentId: null,
content: expect.stringContaining('初始意图:art / 做素材'),
content: expect.stringContaining(
'初始意图:art / 做素材\n第一行\n第二行\n第三行',
),
},
});
expect(conversationCalls[0]?.[1]).toMatchObject({