diff --git a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachment.tsx b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachment.tsx index 41bffc9a0..ce549712e 100644 --- a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachment.tsx +++ b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachment.tsx @@ -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 ( {attachment.file.name} diff --git a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachmentContext.tsx b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachmentContext.tsx deleted file mode 100644 index 04c3330a1..000000000 --- a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachmentContext.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createContext } from 'react'; - -import type { HomeAttachmentDraft } from '../../useHomeDraftStore'; - -const AttachmentContext = createContext({ - attachments: new Map(), -}); - -export default AttachmentContext; diff --git a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachmentNode.tsx b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachmentNode.tsx index 4baf62b90..a0ccb24bd 100644 --- a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachmentNode.tsx +++ b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachmentNode.tsx @@ -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 { - __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 { } decorate() { - return ; + return ( + + ); } } -export function $createAttachmentNode(attachmentId: string) { - return $applyNodeReplacement(new AttachmentNode(attachmentId)); +export function $createAttachmentNode(attachment: HomeAttachmentDraft) { + return $applyNodeReplacement(new AttachmentNode(attachment)); } export function $isAttachmentNode( diff --git a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx index fef5bc5c5..5d2bb59cd 100644 --- a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx @@ -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; -}; const INSERT_ATTACHMENTS_COMMAND: LexicalCommand = createCommand('INSERT_HOME_ATTACHMENTS_COMMAND'); const INSERT_CLIPBOARD_TEXT_COMMAND: LexicalCommand = createCommand( @@ -113,10 +104,7 @@ function selectEditableEndWhenNeeded() { function EditorPlugins({ onChange, - attachmentByIdRef, -}: Pick & { - attachmentByIdRef: AttachmentRegistry; -}) { +}: Pick) { 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 ( { - editorState.read(() => { - const parts: RichText[] = []; - collectRichText($getRoot(), parts, attachmentByIdRef.current); - onChange(parts); - }); + onChange(editorState); }} /> ); } -function collectRichText( - node: LexicalNode, - parts: RichText[], - attachmentById: ReadonlyMap, -) { - 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(null); @@ -252,66 +210,36 @@ export function UploadButton() { ); } -function richTextToAttachmentMap(parts: readonly RichText[]) { - const attachmentById = new Map(); - 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 ( - { + throw error; + }, }} > - { - 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; - }, - }} - > -
- - } - placeholder={ - - {props.placeholder} - - } - ErrorBoundary={LexicalErrorBoundary} - /> - {props.children} -
- + + } + placeholder={ + + {props.placeholder} + + } + ErrorBoundary={LexicalErrorBoundary} /> -
-
+ {props.children} + + + ); } diff --git a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/richTextToPrompt.tsx b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/richTextToPrompt.tsx index 387ef90db..deb92826d 100644 --- a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/richTextToPrompt.tsx +++ b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/richTextToPrompt.tsx @@ -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(); - 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, +) { + 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; + }); } diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index 8b77e2097..024b41dce 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -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, diff --git a/apps/ai-game-creator-shell/src/view/home/useHomeDraftStore.ts b/apps/ai-game-creator-shell/src/view/home/useHomeDraftStore.ts index d816df036..280be8269 100644 --- a/apps/ai-game-creator-shell/src/view/home/useHomeDraftStore.ts +++ b/apps/ai-game-creator-shell/src/view/home/useHomeDraftStore.ts @@ -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((set) => ({ ...initialHomeDraft, setMode: (mode) => set({ mode }), - setRichText: (richText) => set({ richText }), + setRichText: (draft) => set({ draft: draft }), reset: () => set(initialHomeDraft), })); diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 5c688c8e0..c316040c1 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -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({