From 676bd524ee3a3ac981574d30c916c0a85e38b02e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 8 Aug 2026 15:10:16 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=BB=91=E5=8A=A8=E6=9D=A1=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E8=B0=83=E6=95=B4=20(#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 示例来自微信: ![shotmd-1786091231.jpg](/attachments/b59bae77-00ca-4669-8a54-a9786ae53c93) before: - firefox: ![shotmd-1786091298.jpg](/attachments/80bbd46e-551d-4b44-abe4-d3d0ecaa34dd) - edge: ![shotmd-1786091062.jpg](/attachments/7918c523-1b5b-4439-926d-591e0029c5b9) after: - firefox: ![shotmd-1786110119.jpg](/attachments/31b22537-236b-4782-bc47-28ac5ecb0f9f) ![shotmd-1786110060.jpg](/attachments/b48d5343-fd07-451d-943a-55000600e248) - edge: ![shotmd-1786110050.jpg](/attachments/cfd0d8e4-7d52-45f2-bab8-1b573b3a43b0) - 使用 Lexical + OverlayScrollbars 实现统一可控的滑动条样式, 取代原来的textarea Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/149 Co-authored-by: 王德宇 Co-committed-by: 王德宇 --- .../appSurface/project-development.suite.ts | 51 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...前端测试】React组件测试准则-2026-06-26.md | 2 + package-lock.json | 29 + package.json | 2 + .../common/AutoGrowTextArea.test-utils.ts | 47 ++ .../common/AutoGrowTextArea.test.tsx | 403 +++++--------- src/components/common/AutoGrowTextArea.tsx | 505 ++++++++++++++---- .../EditorAgentConversationPanelView.test.tsx | 116 ++-- .../EditorAgentConversationPanelView.tsx | 17 +- .../EditorAgentDraftTextarea.test.tsx | 106 ++-- .../EditorAgentDraftTextarea.tsx | 31 +- .../useConversationAttachments.ts | 3 +- ...CanvasBasicGenerationComposerView.test.tsx | 9 +- ...ImageCanvasBasicGenerationComposerView.tsx | 33 +- ...CanvasCharacterAnimationPanelView.test.tsx | 50 +- ...ImageCanvasCharacterAnimationPanelView.tsx | 21 +- ...asCharacterGenerationComposerView.test.tsx | 28 +- ...eCanvasCharacterGenerationComposerView.tsx | 23 +- .../ImageCanvasCropExpandPanelView.tsx | 17 +- ...mageCanvasEditGenerationModalView.test.tsx | 5 +- .../ImageCanvasEditGenerationModalView.tsx | 2 +- ...CanvasEditorGenerationIntegration.test.tsx | 245 +++++---- .../ImageCanvasEditorView.test.tsx | 32 +- ...ImageCanvasGenerationComposerView.test.tsx | 77 ++- .../ImageCanvasGenerationComposerView.tsx | 60 +-- ...eCanvasGenerationImageOptionsView.test.tsx | 1 + .../ImageCanvasGenerationImageOptionsView.tsx | 5 +- ...CanvasIconSpritesheetComposerView.test.tsx | 22 +- ...ImageCanvasIconSpritesheetComposerView.tsx | 21 +- ...PublicationMaterialsDemoPanelView.test.tsx | 4 +- ...anvasPublicationMaterialsDemoPanelView.tsx | 23 +- .../ImageCanvasQuickEditPanelView.test.tsx | 9 +- .../ImageCanvasQuickEditPanelView.tsx | 2 +- ...mageCanvasSpecGenerationPanelView.test.tsx | 30 +- .../ImageCanvasSpecGenerationPanelView.tsx | 37 +- .../ImageCanvasTextAreaStyles.test.ts | 55 -- src/index.css | 181 +++++-- src/main.tsx | 1 + 39 files changed, 1343 insertions(+), 964 deletions(-) create mode 100644 src/components/common/AutoGrowTextArea.test-utils.ts delete mode 100644 src/components/image-editor/ImageCanvasTextAreaStyles.test.ts diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 7919a671d..628a40d29 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -6858,23 +6858,29 @@ export function registerProjectSupervisorSurfaceTests() { await driver.submit('生成同 revision 首次验证'); await driver.emitValidation(1, 1000); - await waitFor(() => { - expect( - driver.invoke.mock.calls.filter( - ([command]) => command === 'start_local_game_preview', - ), - ).toHaveLength(1); - expect(driver.readAuthorization()).toBeNull(); - }); + await waitFor( + () => { + expect( + driver.invoke.mock.calls.filter( + ([command]) => command === 'start_local_game_preview', + ), + ).toHaveLength(1); + expect(driver.readAuthorization()).toBeNull(); + }, + { timeout: 3000 }, + ); await driver.submit('同一 Run 新授权后重新验证同一 revision'); - await waitFor(() => { - expect(driver.readAuthorization()).toMatchObject({ - afterRevision: 1, - afterValidatedAt: 1000, - authorizationId: expect.any(String), - }); - }); + await waitFor( + () => { + expect(driver.readAuthorization()).toMatchObject({ + afterRevision: 1, + afterValidatedAt: 1000, + authorizationId: expect.any(String), + }); + }, + { timeout: 3000 }, + ); await driver.emitValidation(1, 2000); expect( await screen.findByLabelText('游戏运行', {}, { timeout: 3000 }), @@ -7149,11 +7155,16 @@ export function registerProjectSupervisorSurfaceTests() { }); expect(screen.queryByText('等待新的运行事件')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '运行详情' })); - expect( - within(screen.getByLabelText('最近运行活动')).getByText( - /新受理 Run 正在执行/, - ), - ).not.toBeNull(); + await waitFor( + () => { + expect( + within(screen.getByLabelText('最近运行活动')).getByText( + /新受理 Run 正在执行/, + ), + ).not.toBeNull(); + }, + { timeout: 2500 }, + ); harness.setProjectRevision(4); await act(async () => { harness.emitAgentRuntime( diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 2df540c68..74eb79be7 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -102,7 +102,7 @@ - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。 - 资源表保存资源和素材级元数据,其中 `editor_project_resource.asset_kind` 是资源默认类型;图层位置、层级、分组选中所需 ID、groupId 和可空 `assetKindOverride` 保存在画布布局。legacy 布局 JSON 是混合数组:普通图层按 `layerId/resourceId/assetKindOverride` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存;结构化模式下 `asset_kind_override` 必须是 `editor_canvas_layer` 的 typed 列,不得塞回 `item_json`。普通图层的新保存不再把资源默认 `assetKind` 或 `generationInputs` 写入布局;刷新时从 `editor_project_resource` 恢复资源默认类型,再与图层覆盖合并为 `effectiveAssetKind`,不得把合并结果反写为新的资源或覆盖。旧布局中的 `assetKind` 只用于迁移:与资源默认类型相同则归一为无 override,不同则迁移为图层 override;资源尚无默认值且允许补齐时先补资源默认值,不制造新的资源行。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口只接收提交前临时状态解析出的 `objectKey` 或资源 ID;Data URL、Blob URL 和 signed URL 不进入请求体,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务;手动重拆图集保留独立 `taskId` 用于存储隔离和日志排障,通过私有 provenance 从服务端生成账号素材的 source resource、asset object 或 Object Key 取得可信来源任务,并把它写入 `groupTaskId`,不信任客户端可提交的 resource `taskId/assetKind`;跨项目复用后仍可通过稳定媒体引用找回来源。没有可信来源的新拆分显式归到自身任务,不走历史资源链回溯。每个手动切片同时写入 `groupTaskExpectedAssetCount`,全部切片落库后写独立 cohort 完成事实;后台 read model 只让同一根任务的一个已完成拆分批次并入原图集父项,用户后来删除单片不会让批次脱组,部分失败批次和后续重复拆分批次按各自真实任务分页,避免残缺批次抢占根任务、单组无限增长或素材丢失。历史行在项目资源仍存在时兼容回溯,删除项目资源前只固化直接受影响行的真实来源字段,有界展示 ID 不反写数据库。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 -- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。图片生成、快速编辑、重绘、修改图片、视频、音频、角色、图标、UI / 自定义规范、角色动画和宣发素材描述统一直接使用公共 `AutoGrowTextArea`,不经 `PlatformTextField` 的 textarea 分支;`AutoGrowTextArea` 负责尺寸、溢出及统一的 disabled 光标 / 透明度反馈,画布内生成面板统一由 `image-canvas-editor__generation-prompt` 承载边框、背景、padding、圆角和 focus chrome,独立修改图片弹窗继续使用 `image-canvas-editor__generate-prompt`,用途专属类只追加最小高度等差异,不得新建第三套 textarea chrome。Enter 继续输入换行而不提交表单。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀;图标拆分素材按全连通域视觉顺序自动命名为 `素材 N`。 +- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。图片生成、快速编辑、重绘、修改图片、视频、音频、角色、图标、UI / 自定义规范、角色动画和宣发素材描述统一直接使用公共 `AutoGrowTextArea`,不经 `PlatformTextField` 的 textarea 分支;`AutoGrowTextArea` 负责尺寸、溢出及统一的 disabled 光标 / 透明度反馈,画布内生成面板统一由 `image-canvas-editor__generation-prompt` 承载边框、背景、padding、圆角和 focus chrome,独立修改图片弹窗继续使用 `image-canvas-editor__generate-prompt`,用途专属类只追加最小高度等差异,不得新建第三套 textarea chrome。两套 chrome 均通过 `--auto-grow-scrollbar-radius` 与内部滚动条共用圆角;WebKit 轨道按该圆角上下内缩,滑块在透明横向 gutter 内保持浏览器原生长度比例,拉至顶部或底部不得越过输入框边界。Enter 继续输入换行而不提交表单。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀;图标拆分素材按全连通域视觉顺序自动命名为 `素材 N`。 - 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和工具结果回填由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 - Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 diff --git a/docs/technical/【前端测试】React组件测试准则-2026-06-26.md b/docs/technical/【前端测试】React组件测试准则-2026-06-26.md index 6b32b7ce5..eca1179c6 100644 --- a/docs/technical/【前端测试】React组件测试准则-2026-06-26.md +++ b/docs/technical/【前端测试】React组件测试准则-2026-06-26.md @@ -38,6 +38,8 @@ - 当测试是为防止历史回归,应在测试名或邻近注释中说明防的是什么行为,而不是记录实现步骤。 - 同一用例既要验证定时器调度参数,又要断言确定的中间帧或中间状态时,必须 mock 定时器回调或使用可控假时钟;不得让真实墙上时间在异步交互期间推进被断言的状态,否则本地通过的用例会在较慢 CI 中偶发失败。 - 测试 React effect 中注册的事件监听时,触发事件前先用可观测的 listener 调用确认注册已完成;异步请求已开始应用有界 `waitFor` 断言确认,不要用无界手工 Promise 等待一次性信号。解除挂起请求时将 Promise 收尾纳入异步 `act`,确保后续 React 更新在断言前已冲刷。 +- 公共 `AutoGrowTextArea` 使用 Lexical `contenteditable`,业务测试通过 `setPlainTextEditorValue` 写入文本、通过 `getPlainTextEditorHost` 断言外层样式;占位文案是独立渲染节点,不读取原生 `placeholder` 属性,也不对编辑根触发 textarea 专属的 `change` 事件。 +- 异步请求失败时,错误状态与调用方的草稿 / 附件恢复可能分属连续两次 React 更新;用例必须对最终恢复结果使用有界 `waitFor`,不能把错误文案刚出现的中间帧当成恢复已经完成。 ## 试点调整 diff --git a/package-lock.json b/package-lock.json index 0b00be7e4..ea348739a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,8 @@ "lexical": "^0.47.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "overlayscrollbars": "2.16.0", + "overlayscrollbars-react": "0.5.6", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -15895,6 +15897,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/overlayscrollbars": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.16.0.tgz", + "integrity": "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==", + "license": "MIT" + }, + "node_modules/overlayscrollbars-react": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz", + "integrity": "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==", + "license": "MIT", + "peerDependencies": { + "overlayscrollbars": "^2.0.0", + "react": ">=16.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -31502,6 +31520,17 @@ "wcwidth": "^1.0.1" } }, + "overlayscrollbars": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.16.0.tgz", + "integrity": "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==" + }, + "overlayscrollbars-react": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz", + "integrity": "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==", + "requires": {} + }, "p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", diff --git a/package.json b/package.json index b26e54986..71533ac35 100644 --- a/package.json +++ b/package.json @@ -191,6 +191,8 @@ "lexical": "^0.47.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "overlayscrollbars": "2.16.0", + "overlayscrollbars-react": "0.5.6", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/src/components/common/AutoGrowTextArea.test-utils.ts b/src/components/common/AutoGrowTextArea.test-utils.ts new file mode 100644 index 000000000..0351fd607 --- /dev/null +++ b/src/components/common/AutoGrowTextArea.test-utils.ts @@ -0,0 +1,47 @@ +import { act } from '@testing-library/react'; +import { + $createLineBreakNode, + $createParagraphNode, + $createTextNode, + $getRoot, + getNearestEditorFromDOMNode, +} from 'lexical'; + +export function setPlainTextEditorValue( + contentEditable: HTMLElement, + value: string, +) { + const editor = getNearestEditorFromDOMNode(contentEditable); + if (!editor) { + throw new Error('Expected a Lexical editor root'); + } + + act(() => { + editor.update( + () => { + const paragraph = $createParagraphNode(); + value.split('\n').forEach((line, index, lines) => { + if (line) { + paragraph.append($createTextNode(line)); + } + if (index < lines.length - 1) { + paragraph.append($createLineBreakNode()); + } + }); + $getRoot().clear().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + }); +} + +export function getPlainTextEditorHost(contentEditable: HTMLElement) { + const host = contentEditable.closest( + '.auto-grow-text-area', + ); + if (!host) { + throw new Error('Expected an auto-grow plain-text editor host'); + } + return host; +} diff --git a/src/components/common/AutoGrowTextArea.test.tsx b/src/components/common/AutoGrowTextArea.test.tsx index 04caaa941..30fe8dc85 100644 --- a/src/components/common/AutoGrowTextArea.test.tsx +++ b/src/components/common/AutoGrowTextArea.test.tsx @@ -1,288 +1,175 @@ /* @vitest-environment jsdom */ -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AutoGrowTextArea } from './AutoGrowTextArea'; -afterEach(() => { - vi.unstubAllGlobals(); +beforeEach(() => { + Range.prototype.getBoundingClientRect = vi.fn(() => new DOMRect()); + Range.prototype.getClientRects = vi.fn(() => [] as unknown as DOMRectList); + vi.stubGlobal( + 'ResizeObserver', + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.stubGlobal( + 'ClipboardEvent', + class ClipboardEvent extends Event { + clipboardData: DataTransfer; + + constructor( + type: string, + init: EventInit & { clipboardData: DataTransfer }, + ) { + super(type, init); + this.clipboardData = init.clipboardData; + } + }, + ); }); +function pastePlainText(editor: HTMLElement, value: string) { + const clipboardData = { + files: [], + getData: (type: string) => (type === 'text/plain' ? value : ''), + items: [], + types: ['text/plain'], + } as unknown as DataTransfer; + fireEvent( + editor, + new ClipboardEvent('paste', { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); +} + +function ControlledEditor({ + initialValue = '', + maxLength, +}: { + initialValue?: string; + maxLength?: number; +}) { + const [value, setValue] = useState(initialValue); + return ( + <> + + {value} + + + ); +} + describe('AutoGrowTextArea', () => { - it('uses native field sizing when the browser supports it', () => { - const supports = vi.fn().mockReturnValue(true); - const resizeObserver = vi.fn(); - const onChange = vi.fn(); - vi.stubGlobal('CSS', { supports }); - vi.stubGlobal('ResizeObserver', resizeObserver); + it('keeps React state authoritative and preserves exact line breaks', async () => { + const user = userEvent.setup(); + render(); - render( - , + const editor = screen.getByRole('textbox', { name: '受控编辑器' }); + expect(screen.getByLabelText('React 文本状态').textContent).toBe( + '第一行\n第二行', ); - const input = screen.getByLabelText( - '自动增长输入框', - ) as HTMLTextAreaElement; - expect(supports).toHaveBeenCalledWith('field-sizing', 'content'); - expect(resizeObserver).not.toHaveBeenCalled(); - expect(input.getAttribute('rows')).toBe('1'); - expect(input.className).toContain('auto-grow-text-area'); - expect(input.className).toContain('[field-sizing:content]'); - expect(input.className).toContain('min-h-10'); - expect(input.className).toContain('max-h-32'); - expect(input.className).toContain('overflow-y-auto'); - expect(input.className).toContain('disabled:cursor-not-allowed'); - expect(input.className).toContain('disabled:opacity-60'); - expect(input.style.height).toBe(''); - - fireEvent.change(input, { target: { value: '新内容' } }); - expect(onChange).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: '外部更新' })); + expect(screen.getByLabelText('React 文本状态').textContent).toBe( + '外部\n更新', + ); }); - it('falls back to measured sizing, preserves css limits and remeasures width changes', () => { - let resizeObserverCallback!: ResizeObserverCallback; - const observe = vi.fn(); - const disconnect = vi.fn(); - vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) }); - vi.stubGlobal( - 'ResizeObserver', - vi.fn().mockImplementation((callback: ResizeObserverCallback) => { - resizeObserverCallback = callback; - return { - observe, - unobserve: vi.fn(), - disconnect, - }; - }), + it('publishes edits through onValueChange and enforces maxLength', async () => { + const user = userEvent.setup(); + render(); + + const editor = screen.getByRole('textbox', { name: '受控编辑器' }); + await user.click(editor); + pastePlainText(editor, '12345'); + + await waitFor(() => + expect(screen.getByLabelText('React 文本状态').textContent).toBe('1234'), + ); + expect(editor.textContent).toBe('1234'); + }); + + it('supports undo and redo while keeping React state synchronized', async () => { + const user = userEvent.setup(); + render(); + + const editor = screen.getByRole('textbox', { name: '受控编辑器' }); + await user.click(editor); + pastePlainText(editor, '草稿'); + await waitFor(() => + expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿'), ); - const { rerender, unmount } = render( + await user.keyboard('{Control>}z{/Control}'); + expect(screen.getByLabelText('React 文本状态').textContent).toBe(''); + + await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}'); + expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿'); + }); + + it('exposes read-only and disabled accessibility without form controls', () => { + const { container } = render( , ); - const input = screen.getByLabelText( - '自动增长输入框', - ) as HTMLTextAreaElement; - expect(observe).toHaveBeenCalledWith(input); - let contentHeight = 92; - Object.defineProperty(input, 'offsetHeight', { - configurable: true, - get: () => { - const styleHeight = Number.parseFloat(input.style.height); - return Number.isNaN(styleHeight) ? 82 : styleHeight; - }, - }); - Object.defineProperty(input, 'clientHeight', { - configurable: true, - get: () => input.offsetHeight - 2, - }); - Object.defineProperty(input, 'scrollHeight', { - configurable: true, - get: () => contentHeight, - }); - rerender( + const editor = screen.getByRole('textbox', { name: '只读编辑器' }); + expect(editor.getAttribute('aria-disabled')).toBe('true'); + expect(editor.getAttribute('aria-readonly')).toBe('true'); + expect(editor.getAttribute('tabindex')).toBe('-1'); + expect(container.querySelector('textarea')).toBeNull(); + expect(container.querySelector('form')).toBeNull(); + }); + + it('uses a distinct internal viewport for overflow content', async () => { + const { container } = render( `第 ${index + 1} 行`).join( + '\n', + )} + onValueChange={vi.fn()} />, ); - expect(input.style.height).toBe('94px'); - expect(input.style.overflowY).toBe('hidden'); - contentHeight = 180; - act(() => { - resizeObserverCallback( - [ - { - target: input, - contentRect: { width: 180 }, - } as unknown as ResizeObserverEntry, - ], - {} as ResizeObserver, + const viewport = await waitFor(() => { + const element = container.querySelector( + '[data-overlayscrollbars-viewport]', ); + expect(element).not.toBeNull(); + return element!; }); - expect(input.style.height).toBe('128px'); - expect(input.style.overflowY).toBe('auto'); - - contentHeight = 30; - rerender( - , + const content = container.querySelector( + '[data-overlayscrollbars-content]', ); - expect(input.style.height).toBe('80px'); - expect(input.style.overflowY).toBe('hidden'); - unmount(); - expect(disconnect).toHaveBeenCalledTimes(1); + expect(viewport).not.toBe(content); + expect( + viewport.contains( + screen.getByRole('textbox', { name: '滚动编辑器' }), + ), + ).toBe(true); }); - it('remeasures uncontrolled input changes and forwards the consumer input handler', () => { - const onInput = vi.fn(); - vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) }); - vi.stubGlobal( - 'ResizeObserver', - vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - })), - ); - - render( - , - ); - const input = screen.getByLabelText( - '非受控自动增长输入框', - ) as HTMLTextAreaElement; - let contentHeight = 90; - Object.defineProperty(input, 'offsetHeight', { - configurable: true, - get: () => { - const styleHeight = Number.parseFloat(input.style.height); - return Number.isNaN(styleHeight) ? 42 : styleHeight; - }, - }); - Object.defineProperty(input, 'clientHeight', { - configurable: true, - get: () => input.offsetHeight - 2, - }); - Object.defineProperty(input, 'scrollHeight', { - configurable: true, - get: () => contentHeight, - }); - - fireEvent.input(input, { - target: { value: '第一行\n第二行\n第三行\n第四行' }, - }); - expect(input.style.height).toBe('92px'); - expect(input.style.overflowY).toBe('hidden'); - - contentHeight = 180; - fireEvent.input(input, { - target: { value: '很多行内容'.repeat(30) }, - }); - expect(input.style.height).toBe('128px'); - expect(input.style.overflowY).toBe('auto'); - - contentHeight = 20; - fireEvent.input(input, { target: { value: '短内容' } }); - expect(input.style.height).toBe('40px'); - expect(input.style.overflowY).toBe('hidden'); - expect(onInput).toHaveBeenCalledTimes(3); - }); - - it('measures content-box height without double-counting padding or borders', () => { - vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) }); - vi.stubGlobal( - 'ResizeObserver', - vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - })), - ); - - const { rerender } = render( - , - ); - const input = screen.getByLabelText( - '内容盒输入框', - ) as HTMLTextAreaElement; - let contentHeight = 120; - Object.defineProperty(input, 'offsetHeight', { - configurable: true, - get: () => 122, - }); - Object.defineProperty(input, 'clientHeight', { - configurable: true, - get: () => 120, - }); - Object.defineProperty(input, 'scrollHeight', { - configurable: true, - get: () => contentHeight, - }); - - rerender( - , - ); - expect(input.style.height).toBe('104px'); - expect(input.style.overflowY).toBe('hidden'); - - contentHeight = 160; - rerender( - , - ); - expect(input.style.height).toBe('128px'); - expect(input.style.overflowY).toBe('auto'); - }); }); diff --git a/src/components/common/AutoGrowTextArea.tsx b/src/components/common/AutoGrowTextArea.tsx index 282c7c549..d478fc733 100644 --- a/src/components/common/AutoGrowTextArea.tsx +++ b/src/components/common/AutoGrowTextArea.tsx @@ -1,130 +1,443 @@ +import 'overlayscrollbars/styles/overlayscrollbars.css'; + +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 { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; +import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin'; +import { mergeRegister } from '@lexical/utils'; import { - type InputEventHandler, - type TextareaHTMLAttributes, + $createLineBreakNode, + $createParagraphNode, + $createTextNode, + $getRoot, + CLEAR_HISTORY_COMMAND, + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + COMPOSITION_END_COMMAND, + COMPOSITION_START_COMMAND, + INSERT_LINE_BREAK_COMMAND, + KEY_ENTER_COMMAND, + type LexicalEditor, + PASTE_COMMAND, +} from 'lexical'; +import type { PartialOptions } from 'overlayscrollbars'; +import { useOverlayScrollbars } from 'overlayscrollbars-react'; +import { + type CSSProperties, + useCallback, + useEffect, useLayoutEffect, + useMemo, useRef, } from 'react'; -const DEFAULT_MIN_HEIGHT_PX = 40; -const DEFAULT_MAX_HEIGHT_PX = 128; +const CONTROLLED_VALUE_TAG = 'auto-grow-controlled-value'; +const MAX_LENGTH_TAG = 'auto-grow-max-length'; -function supportsNativeFieldSizing() { - return ( - typeof CSS !== 'undefined' && - typeof CSS.supports === 'function' && - CSS.supports('field-sizing', 'content') - ); +const OVERLAY_SCROLLBAR_OPTIONS = { + overflow: { + x: 'hidden', + y: 'scroll', + }, + scrollbars: { + autoHide: 'never', + clickScroll: false, + dragScroll: true, + // 由 OverlayScrollbars 统一绘制,避免 Edge / Firefox 原生滚动条差异。 + theme: 'os-theme-auto-grow-text-area', + visibility: 'auto', + }, +} satisfies PartialOptions; + +export type AutoGrowTextAreaProps = { + value: string; + onValueChange: (value: string) => void; + className?: string; + style?: CSSProperties; + placeholder?: string; + disabled?: boolean; + readOnly?: boolean; + maxLength?: number; + onPaste?: (event: ClipboardEvent) => void; + onSubmitRequest?: () => void; + 'aria-label': string; + 'aria-describedby'?: string; + 'aria-invalid'?: boolean | 'false' | 'true'; +}; + +function replaceEditorText(value: string, selectEnd: boolean) { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const lines = value.split('\n'); + + lines.forEach((line, index) => { + if (line) { + paragraph.append($createTextNode(line)); + } + if (index < lines.length - 1) { + paragraph.append($createLineBreakNode()); + } + }); + + root.clear().append(paragraph); + if (selectEnd) { + paragraph.selectEnd(); + } } -function parseComputedPixelValue(value: string, fallback: number) { - const parsedValue = Number.parseFloat(value); - return Number.isFinite(parsedValue) ? parsedValue : fallback; +function trimToUtf16Length(value: string, maxLength: number) { + if (value.length <= maxLength) { + return value; + } + + let trimmed = value.slice(0, maxLength); + const finalCodeUnit = trimmed.charCodeAt(trimmed.length - 1); + if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) { + trimmed = trimmed.slice(0, -1); + } + return trimmed; } -function resizeFallbackTextArea(textarea: HTMLTextAreaElement) { - textarea.style.height = 'auto'; - - const computedStyle = window.getComputedStyle(textarea); - const minHeight = parseComputedPixelValue( - computedStyle.minHeight, - DEFAULT_MIN_HEIGHT_PX, - ); - const maxHeight = Math.max( - minHeight, - parseComputedPixelValue(computedStyle.maxHeight, DEFAULT_MAX_HEIGHT_PX), - ); - const contentHeight = textarea.scrollHeight; - const borderHeight = Math.max( - 0, - textarea.offsetHeight - textarea.clientHeight, - ); - const isBorderBox = computedStyle.boxSizing === 'border-box'; - const paddingHeight = - parseComputedPixelValue(computedStyle.paddingTop, 0) + - parseComputedPixelValue(computedStyle.paddingBottom, 0); - const measuredHeight = isBorderBox - ? contentHeight + borderHeight - : Math.max(0, contentHeight - paddingHeight); - const nextHeight = Math.min( - Math.max(measuredHeight, minHeight), - maxHeight, - ); - - textarea.style.height = `${nextHeight}px`; - textarea.style.overflowY = measuredHeight > maxHeight ? 'auto' : 'hidden'; +function readEditorText(editor: LexicalEditor) { + return editor.getEditorState().read(() => $getRoot().getTextContent()); } -export function AutoGrowTextArea({ - className, - onInput, - rows = 1, +function readPixelValue(value: string, fallback: number) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function ControlledPlainTextPlugin({ value, - ...textareaProps -}: TextareaHTMLAttributes) { - const textareaRef = useRef(null); + onValueChange, + disabled, + readOnly, + maxLength, + onPaste, + onSubmitRequest, + onEditorUpdate, +}: Pick< + AutoGrowTextAreaProps, + | 'value' + | 'onValueChange' + | 'disabled' + | 'readOnly' + | 'maxLength' + | 'onPaste' + | 'onSubmitRequest' +> & { + onEditorUpdate: () => void; +}) { + const [editor] = useLexicalComposerContext(); + const controlledValueRef = useRef(value); + const onValueChangeRef = useRef(onValueChange); + const isComposingRef = useRef(false); - useLayoutEffect(() => { - const textarea = textareaRef.current; - if (!textarea || supportsNativeFieldSizing()) { + controlledValueRef.current = value; + onValueChangeRef.current = onValueChange; + + useEffect(() => { + editor.setEditable(!disabled && !readOnly); + }, [disabled, editor, readOnly]); + + useEffect(() => { + const currentText = readEditorText(editor); + if (currentText === value) { return; } - resizeFallbackTextArea(textarea); - }, [value]); - useLayoutEffect(() => { - const textarea = textareaRef.current; - if (!textarea || supportsNativeFieldSizing()) { + controlledValueRef.current = value; + editor.update(() => replaceEditorText(value, true), { + discrete: true, + tag: CONTROLLED_VALUE_TAG, + }); + editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined); + }, [editor, value]); + + useEffect(() => { + let active = true; + + const publishCurrentValue = () => { + if (!active || isComposingRef.current || editor.isComposing()) { + return; + } + + const editorText = readEditorText(editor); + const nextValue = + typeof maxLength === 'number' + ? trimToUtf16Length(editorText, Math.max(0, maxLength)) + : editorText; + + if (nextValue !== editorText) { + editor.update(() => replaceEditorText(nextValue, true), { + discrete: true, + tag: MAX_LENGTH_TAG, + }); + } + + if (nextValue !== controlledValueRef.current) { + controlledValueRef.current = nextValue; + onValueChangeRef.current(nextValue); + } + }; + + return mergeRegister( + editor.registerUpdateListener(({ tags }) => { + onEditorUpdate(); + if ( + tags.has(CONTROLLED_VALUE_TAG) || + tags.has(MAX_LENGTH_TAG) + ) { + return; + } + publishCurrentValue(); + }), + editor.registerCommand( + COMPOSITION_START_COMMAND, + () => { + isComposingRef.current = true; + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + COMPOSITION_END_COMMAND, + () => { + isComposingRef.current = false; + queueMicrotask(publishCurrentValue); + return false; + }, + COMMAND_PRIORITY_LOW, + ), + () => { + active = false; + }, + ); + }, [editor, maxLength, onEditorUpdate]); + + useEffect( + () => + mergeRegister( + editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (!event) { + return false; + } + if ( + event.isComposing || + event.keyCode === 229 || + !onSubmitRequest || + event.shiftKey + ) { + return false; + } + + event.preventDefault(); + onSubmitRequest(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ), + editor.registerCommand( + PASTE_COMMAND, + (event) => { + if (!onPaste || !('clipboardData' in event)) { + return false; + } + + onPaste(event as ClipboardEvent); + return event.defaultPrevented; + }, + COMMAND_PRIORITY_HIGH, + ), + ), + [editor, onPaste, onSubmitRequest], + ); + + useEffect(() => { + if (onSubmitRequest) { return undefined; } - let previousWidth = textarea.getBoundingClientRect().width; - const resizeWhenWidthChanges = (nextWidth: number) => { - if (Math.abs(nextWidth - previousWidth) < 0.5) { - return; - } - previousWidth = nextWidth; - resizeFallbackTextArea(textarea); - }; - - if (typeof ResizeObserver !== 'undefined') { - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (entry) { - resizeWhenWidthChanges(entry.contentRect.width); + return editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (!event || event.isComposing || event.keyCode === 229) { + return false; } - }); - observer.observe(textarea); - return () => observer.disconnect(); - } + event.preventDefault(); + return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false); + }, + COMMAND_PRIORITY_HIGH, + ); + }, [editor, onSubmitRequest]); - const handleWindowResize = () => { - resizeWhenWidthChanges(textarea.getBoundingClientRect().width); - }; - window.addEventListener('resize', handleWindowResize); - return () => window.removeEventListener('resize', handleWindowResize); - }, []); + return null; +} +export function AutoGrowTextArea({ + value, + onValueChange, + className, + style, + placeholder, + disabled = false, + readOnly = false, + maxLength, + onPaste, + onSubmitRequest, + 'aria-label': ariaLabel, + 'aria-describedby': ariaDescribedBy, + 'aria-invalid': ariaInvalid, +}: AutoGrowTextAreaProps) { + const initialValueRef = useRef(value); + const hostRef = useRef(null); + const viewportRef = useRef(null); + const contentRef = useRef(null); + const contentEditableRef = useRef(null); + const [initializeOverlayScrollbars, overlayScrollbarsInstance] = + useOverlayScrollbars({ options: OVERLAY_SCROLLBAR_OPTIONS }); + const initialConfig = useMemo( + () => ({ + editable: !disabled && !readOnly, + editorState: () => replaceEditorText(initialValueRef.current, false), + namespace: 'AutoGrowTextArea', + onError(error: Error) { + throw error; + }, + }), + // LexicalComposer intentionally reads its initial configuration once. + [disabled, readOnly], + ); const resolvedClassName = [ - 'auto-grow-text-area max-h-32 min-h-10 resize-none overflow-x-hidden overflow-y-auto overscroll-contain [field-sizing:content] disabled:cursor-not-allowed disabled:opacity-60', + 'auto-grow-text-area', className, ] .filter(Boolean) .join(' '); - const handleInput: InputEventHandler = (event) => { - if (!supportsNativeFieldSizing()) { - resizeFallbackTextArea(event.currentTarget); + + const resizeHost = useCallback(() => { + const host = hostRef.current; + const contentEditable = contentEditableRef.current; + if (!host || !contentEditable) { + return; } - onInput?.(event); - }; + + const style = window.getComputedStyle(host); + const minHeight = readPixelValue(style.minHeight, 40); + const maxHeight = Math.max( + minHeight, + readPixelValue(style.maxHeight, 128), + ); + const borderHeight = + readPixelValue(style.borderTopWidth, 0) + + readPixelValue(style.borderBottomWidth, 0); + const nextHeight = Math.min( + Math.max(contentEditable.scrollHeight + borderHeight, minHeight), + maxHeight, + ); + + host.style.height = `${nextHeight}px`; + overlayScrollbarsInstance()?.update(true); + }, [overlayScrollbarsInstance]); + + const scheduleResize = useCallback(() => { + resizeHost(); + }, [resizeHost]); + + useLayoutEffect(() => { + resizeHost(); + const contentEditable = contentEditableRef.current; + if (!contentEditable) { + return undefined; + } + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', resizeHost); + return () => window.removeEventListener('resize', resizeHost); + } + + const observer = new ResizeObserver(resizeHost); + observer.observe(contentEditable); + return () => observer.disconnect(); + }, [resizeHost]); + + useLayoutEffect(() => { + scheduleResize(); + }, [scheduleResize, value]); + + useEffect(() => { + const host = hostRef.current; + const viewport = viewportRef.current; + const content = contentRef.current; + if (!host || !viewport || !content) { + return; + } + + initializeOverlayScrollbars({ + target: host, + elements: { viewport, content }, + }); + resizeHost(); + }, [initializeOverlayScrollbars, resizeHost]); return ( -