From cb76aadb480495dbf5e5d09dd6cfe2b12781bce6 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 19:05:57 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E8=BE=93=E5=85=A5=E5=8C=BA=E4=B8=89?= =?UTF-8?q?=E5=A4=84=E8=A7=82=E6=84=9F=E4=BF=AE=E5=A4=8D=EF=BC=9AAI=20?= =?UTF-8?q?=E6=B6=A6=E8=89=B2=E5=BD=92=E4=BD=8D=E3=80=81@=20=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E7=AD=9B=E9=80=89=E6=9D=A1=E5=8D=95=E8=A1=8C=E3=80=81?= =?UTF-8?q?=E5=BC=95=E7=94=A8=20chip=20=E5=8E=9F=E5=AD=90=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - styles.css:把 direct-codex composer 下广播式的 `.project-supervisor-composer button` 规则 收窄到 `.project-supervisor-submit-button`(含 svg 那条与 media query 里那条), 并给 `.resource-reference-input-actions` 补 `grid-column: 1 / -1` 让输入区操作排独占一行 - styles.css:`.resource-reference-input-actions` / `.resource-reference-input-at` 撤掉绝对定位, 操作排回到网格文档流,与输入框右下角对齐,不再浮在文本区中间 - styles.css:`.resource-reference-input-polish` 与 `.resource-reference-input-restore` 并入 `@` 按钮同一条尺寸规则(28px 方钮、8px 圆角、同 hover/disabled 观感) - styles.css:输入区状态行(润色中/润色失败)改为与应用排同一行,不再单独占一行顶高 22.8px - styles.css:direct-codex 输入框 padding 收为 `12px 15px 6px`,窄屏 `10px 12px 6px` - styles.css:`.resource-reference-chip` 的 `×` 删除按钮不再被 broadcast 规则摘出 chip - PlatformSegmentedTabs.tsx:分段页签 item 加 `whitespace-nowrap`,中文不再逐字竖排 - packages/shared/styles.css:补 `.scrollbar-hide` 实现(该类名一直被 `layout="scroll"` 引用但仓库未定义) - ResourceReferenceNode.tsx:`getTextContent()` 改为单个 U+FFFC 占位符并显式实现 `getTextContentSize()`,引用成为真正的原子内联节点(1 个引用 = 1 个字符) - tests/resourceReferenceInput.test.tsx:新增「1 个引用 = 1 个字符」与「chip 自带删除、一次删净」两条断言 - tests/appSurface/project-development.suite.ts:把 composer 按钮样式断言收窄到提交按钮, 并新增一条反向断言,钉住「广播到 composer 下所有 button」的规则不再出现 --- .../ResourceReferenceNode.tsx | 20 +++- apps/ai-game-creator-shell/src/styles.css | 97 +++++++++++------- .../appSurface/project-development.suite.ts | 7 +- .../tests/resourceReferenceInput.test.tsx | 98 ++++++++++++++++++- .../src/components/PlatformSegmentedTabs.tsx | 2 +- packages/shared/src/components/styles.css | 11 +++ 6 files changed, 195 insertions(+), 40 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceNode.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceNode.tsx index dbb76a248..a536ec791 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceNode.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceNode.tsx @@ -21,6 +21,9 @@ export type SerializedResourceReferenceNode = Spread< SerializedLexicalNode >; +/** 引用节点在纯文本里的唯一占位字符:一个 chip 就当一个字符。 */ +const OBJECT_REPLACEMENT = '\uFFFC'; + export class ResourceReferenceNode extends DecoratorNode { __reference: ChatReference; @@ -58,8 +61,23 @@ export class ResourceReferenceNode extends DecoratorNode { return false; } + /** + * 引用是原子内联节点:纯文本表示只占 **1 个字符**(U+FFFC OBJECT REPLACEMENT + * CHARACTER,Unicode 里专门用来占位「一个不可拆分的对象」)。 + * + * 这决定了两件事: + * - 字数统计与光标偏移都按 1 算,`getTextContentSize()` 不必再跟着显示名长度走; + * - 复制成纯文本时一个 chip 也只会留下一个占位符,而不是把 `@显示名` 混进正文。 + * + * 完整引用数据不走这里:草稿文本由 `ResourceReferenceInput` 的 `collectDraftParts` + * 用 `@${reference.label}` 拼装,结构化引用直接取 `__reference`,两者都不依赖本方法。 + */ getTextContent() { - return `@${this.__reference.label}`; + return OBJECT_REPLACEMENT; + } + + getTextContentSize() { + return OBJECT_REPLACEMENT.length; } isInline() { diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 7a6f4bfe2..96a2aa988 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3006,7 +3006,11 @@ textarea { line-height: 1.5; } -.project-supervisor-composer button { +/* 只发给提交按钮:这里原来是 `.project-supervisor-composer button`,会把 `position`、 + `width`、`border-radius` 广播到输入区里的每一个按钮上——`.project-supervisor-composer + .is-direct-codex .project-supervisor-composer button` 那条绝对定位规则就是这样把 + 「AI 润色」从输入区网格里摘出去、浮到文本区右侧的。 */ +.project-supervisor-submit-button { display: inline-flex; align-items: center; justify-content: center; @@ -3020,7 +3024,7 @@ textarea { color: #fff; } -.project-supervisor-composer button:disabled { +.project-supervisor-submit-button:disabled { border-color: #cfd6e1; background: #e8ecf2; color: #8b95a5; @@ -3308,7 +3312,7 @@ textarea { grid-template-columns: 1fr; } - .project-supervisor-composer button { + .project-supervisor-submit-button { width: 100%; } @@ -4473,40 +4477,24 @@ h2 { color: #8a94a6; } +/* 输入区操作排是文本区下面的独立一行(`.resource-reference-input` 是行式网格, + 第一行放编辑器)。`@` / AI 润色 / 恢复原文三个 28px 方钮共用一套外观,网格既有 + 的 `align-items: end` 已经把它们钉在行底,和下面那一排主操作对齐。 + 注意不要再给这一排加 `min-height`:网格行高由编辑器撑开,行高一旦被顶起来, + 编辑器就会跟着变高。 */ .resource-reference-input-actions { display: flex; align-items: end; gap: 6px; } -.resource-reference-input-at { - display: grid; - width: 28px; - height: 28px; - padding: 0; - border: 1px solid #cfd6df; - border-radius: 8px; - background: #f8fafc; - color: #475569; - place-items: center; - cursor: pointer; -} - -.resource-reference-input-at:hover:not(:disabled) { - border-color: #94a3b8; - color: #0f172a; -} - -.resource-reference-input-at:disabled { - cursor: not-allowed; - opacity: 0.5; -} - +.resource-reference-input-at, .resource-reference-input-polish, .resource-reference-input-restore { display: grid; width: 28px; height: 28px; + flex: 0 0 28px; padding: 0; border: 1px solid #cfd6df; border-radius: 8px; @@ -4516,20 +4504,24 @@ h2 { cursor: pointer; } +.resource-reference-input-at:hover:not(:disabled), .resource-reference-input-polish:hover:not(:disabled), .resource-reference-input-restore:hover:not(:disabled) { border-color: #94a3b8; color: #0f172a; } +.resource-reference-input-at:disabled, .resource-reference-input-polish:disabled, .resource-reference-input-restore:disabled { cursor: not-allowed; opacity: 0.5; } +/* 润色中 / 润色失败:整行独占,既不会压住上面的编辑器,也不会被操作排挤成两行。 */ .resource-reference-input-status { grid-column: 1 / -1; + margin-top: 6px; color: #b45309; font-size: 12px; line-height: 1.4; @@ -8461,7 +8453,7 @@ iframe.preview-frame { .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer - button { + .project-supervisor-submit-button { width: auto; height: auto; min-height: 72px; @@ -8560,10 +8552,13 @@ iframe.preview-frame { pointer-events: auto; } +/* 只发给提交按钮。这条原来是 `.project-supervisor-composer button` 的通用规则, + 会把 `position: absolute; right: 10px; bottom: 10px` 广播给 composer 里的每一个 + 按钮——输入区里的「AI 润色」就是这样被摘出网格、浮到文本区右侧的。 */ .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer - button { + .project-supervisor-submit-button { position: absolute; right: 10px; bottom: 10px; @@ -8584,7 +8579,7 @@ iframe.preview-frame { .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer - button + .project-supervisor-submit-button svg { width: 14px; height: 14px; @@ -9074,6 +9069,15 @@ iframe.preview-frame { flex: 0 0 auto; } + /* 窄屏:输入框左右留白收紧,操作排(`@` / AI 润色 / 恢复原文)保持单行不换行, + 编辑器靠 `minmax(0, 1fr)` 吸收宽度,不会挤出横向滚动条。 */ + .game-workbench-chat + .project-supervisor-surface.is-direct-codex + .project-supervisor-composer + .resource-reference-input { + padding: 10px 12px 6px; + } + .game-workbench-chat .project-supervisor-surface, .game-workbench-chat .project-supervisor-conversation { height: auto; @@ -10005,8 +10009,7 @@ iframe.preview-frame { .resource-reference-input { display: grid; width: 100%; - min-height: 88px; - padding: 12px 46px 10px 15px; + padding: 12px 15px 6px; border-color: var(--platform-surface-border); border-radius: 14px; background: var(--platform-input-fill); @@ -10035,16 +10038,38 @@ iframe.preview-frame { line-height: 1.6; } +/* 陶泥儿输入区的操作排(`@` / AI 润色 / 恢复原文)回到文档流:`grid-column: 1 / -1` + 让它独占编辑器下面的一行,`align-items: end` + `justify-content: flex-end` 把它 + 贴到输入框右下角。之前这里用 `position: absolute; right: 48px; bottom: 10px` + 把整排从网格里摘出来浮在输入框中间,AI 润色因此看起来压在文本区里、和下面那排 + 主操作(`.project-supervisor-composer-controls` 的 `@` / 快速 / 发送)脱节。 + 不设 `min-height`:这一行的高度由 28px 方钮自己撑开,行高一旦被顶起来会连带把 + 编辑器的 `min-height: 96px` 改掉。 */ .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer .resource-reference-input-actions { - position: absolute; - right: 48px; - bottom: 10px; + grid-column: 1 / -1; + position: static; display: flex; - align-items: center; - height: 30px; + align-items: end; + justify-content: flex-end; + min-width: 0; + height: auto; +} + +/* 「润色中… / 润色失败」和应用排同一行:状态文字靠左占剩余宽度,操作排靠右。 + 单独占一行会把输入框整体顶高 22.8px,把操作排和下面那排主操作推开一个状态行的 + 距离;共行则状态出现/消失都不改变输入框高度。 */ +.game-workbench-chat + .project-supervisor-surface.is-direct-codex + .project-supervisor-composer + .resource-reference-input-status { + grid-column: 1 / -1; + grid-row: 2; + align-self: center; + min-width: 0; + margin-top: 0; } .game-workbench-chat 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 fc7539bb3..7927779a8 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 @@ -4045,8 +4045,13 @@ export function registerProjectWorkbenchFoundationTests() { expect(styles).toMatch( /\.game-workbench-chat \.project-runtime-pending-command\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 136px/s, ); + // 提交按钮这条只发给 `.project-supervisor-submit-button`:以前是 composer 下所有 + // `button`,会把绝对定位广播到输入区里的「AI 润色」上,把它浮到文本区中间。 expect(styles).toMatch( - /\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s+button\s*\{[^}]*min-height:\s*72px[^}]*white-space:\s*nowrap/s, + /\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s+\.project-supervisor-submit-button\s*\{[^}]*min-height:\s*72px[^}]*white-space:\s*nowrap/s, + ); + expect(styles).not.toMatch( + /\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s+button\s*\{/s, ); expect(styles).toMatch( /\.game-run-surface\s*\{[^}]*grid-template-rows:\s*minmax\(300px, 1fr\) auto[^}]*grid-row:\s*2 \/ -1[^}]*height:\s*100%/s, diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 9bb477de5..81dba7fa1 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { $getRoot } from 'lexical'; import { StrictMode } from 'react'; import { afterEach, describe, expect, test, vi } from 'vitest'; @@ -18,6 +19,7 @@ import { type ChatReference, currentIterationVersionAssets, dispatchResourceReferenceInsert, + resolveActiveIterationVersion, RESOURCE_REFERENCE_FILTERS, RESOURCE_REFERENCE_INSERT_EVENT, RESOURCE_REFERENCE_SCOPES, @@ -25,7 +27,6 @@ import { resourceReferenceFromAsset, resourceReferenceMatchesCategoryFilter, resourceReferenceMatchesQuery, - resolveActiveIterationVersion, } from '../src/features/project-workspace/resourceReferences'; function asset( @@ -67,6 +68,29 @@ async function settleComposer() { }); } +/** + * 编辑器文本模型里的字符数。断言「一个引用 = 一个字符」用它而不是 DOM 文本: + * DOM 里 chip 仍要显示 `@显示名` 给用户看,两者本来就不该相等。 + */ +function composerEditor(): { + getEditorState: () => { read: (fn: () => T) => T }; +} { + const element = screen.getByLabelText('聊天') as HTMLElement & { + __lexicalEditor?: { + getEditorState: () => { read: (fn: () => T) => T }; + }; + }; + const editor = element.__lexicalEditor; + if (!editor) throw new Error('lexical editor instance not found'); + return editor; +} + +function editorTextSize() { + return composerEditor() + .getEditorState() + .read(() => $getRoot().getTextContentSize()); +} + const assets = [ asset('hero', 'character', 'image/png', 'assets/hero.png'), asset('enemy', 'character', 'image/png', 'assets/enemy.png'), @@ -304,6 +328,78 @@ describe('ResourceReferenceInput', () => { }); }); + test('counts one inserted reference as a single character in the editor text model', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: '插入素材引用' })); + await user.click(screen.getByRole('option', { name: /hero/u })); + await user.click(screen.getByRole('button', { name: '插入引用' })); + await settleComposer(); + + expect( + document.querySelector('[data-resource-reference-id="hero"]'), + ).not.toBeNull(); + // 引用是原子节点:编辑器文本模型里只占 1 个字符,加上插入时补的那个分隔空格 + // 一共 2 个。显示名 `hero` 有 4 个字符(`@hero` 是 5),如果引用按显示名算, + // 这里会是 5 或 6,所以这条断言正好把「1 个引用 = 1 个字符」钉住。 + // 提交用的结构化引用另走 `references`,不受这个占位字符影响。 + expect(editorTextSize()).toBe(2); + }); + + test('keeps the whole reference after one delete and drops its data in one step', async () => { + const user = userEvent.setup(); + const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: '插入素材引用' })); + await user.click(screen.getByRole('option', { name: /hero/u })); + await user.click(screen.getByRole('button', { name: '插入引用' })); + await settleComposer(); + + // chip 仍把 `@显示名` 显示给用户看;删除按钮挂在 chip 自己身上 + // (`移除引用 hero` 是 chip 内部的按钮,不是脱离 chip 的独立按钮)。 + const chip = document.querySelector('[data-resource-reference-id="hero"]'); + expect(chip).not.toBeNull(); + expect(chip?.textContent).toBe('@hero'); + const deleteButton = screen.getByRole('button', { name: '移除引用 hero' }); + expect(chip?.contains(deleteButton)).toBe(true); + + // 提交用的结构化引用完整保留,末尾那个分隔空格提交前会被 trim 掉。 + expect(onChange.mock.calls.at(-1)?.[0].text).toBe('@hero'); + expect(onChange.mock.calls.at(-1)?.[0].references[0]?.resourceId).toBe( + 'hero', + ); + + // 引用节点是原子的:一次操作删掉整个 chip,不存在"删一半"的中间态。 + await user.click(deleteButton); + await settleComposer(); + + expect( + document.querySelector('[data-resource-reference-id="hero"]'), + ).toBeNull(); + expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0); + expect(onChange.mock.calls.at(-1)?.[0].text).toBe(''); + }); + test('exposes the current-version and all-canvas scopes as the only two tabs', () => { expect(RESOURCE_REFERENCE_SCOPES).toEqual([ { id: 'current-version', label: '当前版本素材' }, diff --git a/packages/shared/src/components/PlatformSegmentedTabs.tsx b/packages/shared/src/components/PlatformSegmentedTabs.tsx index a3f4c4318..34852d475 100644 --- a/packages/shared/src/components/PlatformSegmentedTabs.tsx +++ b/packages/shared/src/components/PlatformSegmentedTabs.tsx @@ -154,7 +154,7 @@ function resolveItemClassName({ : itemClassName; return [ - 'min-w-0 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)]', + 'min-w-0 whitespace-nowrap transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)]', PLATFORM_SEGMENTED_TABS_ITEM_SIZE_CLASS[size], active ? PLATFORM_SEGMENTED_TABS_TONE_CLASS[tone].active diff --git a/packages/shared/src/components/styles.css b/packages/shared/src/components/styles.css index c57dd87cc..a3c7bcc59 100644 --- a/packages/shared/src/components/styles.css +++ b/packages/shared/src/components/styles.css @@ -508,6 +508,17 @@ padding-right: 0.1rem; } +/* 横向滚动条按需隐藏:分段页签的 `layout="scroll"` 与标签排都靠它保持外观干净, + 滚动能力本身不隐藏(内容仍可横向滚动,只是不画滚动条)。 */ +.scrollbar-hide { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + .platform-category-chip { display: inline-flex; min-height: 2.25rem;