AGC 输入区三处观感修复:AI 润色归位、@ 面板筛选条单行、引用 chip 原子化
- 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」的规则不再出现
This commit is contained in:
+19
-1
@@ -21,6 +21,9 @@ export type SerializedResourceReferenceNode = Spread<
|
||||
SerializedLexicalNode
|
||||
>;
|
||||
|
||||
/** 引用节点在纯文本里的唯一占位字符:一个 chip 就当一个字符。 */
|
||||
const OBJECT_REPLACEMENT = '\uFFFC';
|
||||
|
||||
export class ResourceReferenceNode extends DecoratorNode<ReactNode> {
|
||||
__reference: ChatReference;
|
||||
|
||||
@@ -58,8 +61,23 @@ export class ResourceReferenceNode extends DecoratorNode<ReactNode> {
|
||||
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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: <T>(fn: () => T) => T };
|
||||
} {
|
||||
const element = screen.getByLabelText('聊天') as HTMLElement & {
|
||||
__lexicalEditor?: {
|
||||
getEditorState: () => { read: <T>(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(
|
||||
<ResourceReferenceInput
|
||||
value=""
|
||||
references={[]}
|
||||
onChange={vi.fn()}
|
||||
assets={assets}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ResourceReferenceInput
|
||||
value=""
|
||||
references={[]}
|
||||
onChange={onChange}
|
||||
assets={assets}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
/>,
|
||||
);
|
||||
|
||||
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: '当前版本素材' },
|
||||
|
||||
@@ -154,7 +154,7 @@ function resolveItemClassName<TId extends string>({
|
||||
: 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user