refactor: directly store inner state
This commit is contained in:
@@ -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}
|
||||
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
import type { HomeAttachmentDraft } from '../../useHomeDraftStore';
|
||||
|
||||
const AttachmentContext = createContext({
|
||||
attachments: new Map<string, HomeAttachmentDraft>(),
|
||||
});
|
||||
|
||||
export default AttachmentContext;
|
||||
+14
-11
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+75
-21
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user