|
|
@@ -6,13 +6,13 @@ Status: implemented
|
|
|
|
|
|
## 问题
|
|
|
|
|
|
-composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx)):`<textarea>` 持有取值、选区与光标,但它自己的字形以 `color: transparent` 渲染;用户看到的每一个字符都由其下的 `[data-input-backdrop]` 层绘制,该层同时承载 claim token 高亮、chip 与提示影子文本。这一拆分正是 chip 与高亮得以存在的前提——textarea 无法为自身文本的某个区间单独设置样式。草稿框的高度上限为 14 行,因此超过上限之后总得有东西滚动。
|
|
|
+composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx)):`<textarea>` 持有值、选区与光标,但它自己的字形以 `color: transparent` 渲染;用户看到的每一个字符都由其下的 `[data-input-backdrop]` 层绘制,该层同时承载 claim token 高亮、chip 与提示影子文本。这一拆分正是 chip 与高亮得以存在的前提——textarea 无法为自身文本的某个区间单独设置样式。草稿框的高度上限为 14 行,因此超过上限之后总得有东西滚动。
|
|
|
|
|
|
两层各自持有一个滚动偏移,会分两个阶段失效,本次改动是其中的第二个。
|
|
|
|
|
|
第一个阶段是静态的。backdrop 为 `position: absolute; inset: 0; overflow: hidden`——只裁剪、不滚动——浏览器也不会把它的偏移与 textarea 关联起来,于是超过上限之后光标在动而文字冻结在第 1 行。未达上限时这一点不可见,因为两层都停在 0;该缺陷与高度上限同龄,藏在所有截图所捕获的那个静止状态背后。当时的修复是用一个 `scroll` 监听把 textarea 的 `scrollTop` 镜像到 backdrop 上,这让两层在**静止时**保持一致。
|
|
|
|
|
|
-第二个阶段正是随后被用户报告的现象:从长草稿的顶部快速滑到底部时,光标像带着惯性一样从自己的文字里往上飞出去,过一会儿又落回原位。原因就是那个镜像。滚轮手势在合成线程上滚动 textarea,不经过主线程;而驱动赋值的 `scroll` 事件在其后才派发,因此中间这些帧里光标位于新偏移、每一个字形却仍位于旧偏移。在同一套几何的独立环境上实测:把偏移改变 200px 并在本任务结束之前读取光标与其字形之间的距离,chromium 分离 203px、firefox 202px、WebKit 203px,一两帧之后才收敛到固定的行盒常量(3/2/3)。每个引擎、每次手势都如此,且滑动越快分离越远。
|
|
|
+第二个阶段正是随后被用户报告的现象:从长草稿的顶部快速滑动时,光标像带着惯性一样从自己的文字里往上飞出去,过一会儿又落回原位。原因就是那个镜像。滚轮手势在合成线程上滚动 textarea,不经过主线程;而驱动赋值的 `scroll` 事件在其后才派发,因此中间这些帧里光标位于新偏移、每一个字形却仍位于旧偏移。在同一套几何的独立环境上实测:把偏移改变 200px 并在本任务结束之前读取光标与其字形之间的距离,chromium 分离 203px、firefox 202px、WebKit 203px,一两帧之后才收敛到固定的行盒常量(3/2/3)。每个引擎、每次手势都如此,且滑动越快分离越远。
|
|
|
|
|
|
任何监听都无法消除这个间隙,因为这个间隙正是监听的定义:它在自己所响应的那件事之后才运行。凡是用 JavaScript 维持两个盒子相等的做法,都会落后于那个不经询问就搬动其中一个盒子的合成器一帧。
|
|
|
|
|
|
@@ -52,7 +52,7 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl
|
|
|
|
|
|
**去掉 backdrop,直接给 textarea 自己的文本上样式。** 这会消除分层,也一并消除这一整类失步。被否决是因为它不可实现:textarea 只渲染一段统一的文本,claim token 高亮、chip 与提示影子文本——backdrop 存在的理由——无从表达。为修滚动而失去它们,是拿一个有界缺陷换一次功能删除。
|
|
|
|
|
|
-**用 `contenteditable` div 渲染草稿。** 一个元素、一个偏移、区间可上样式。因代价与缺陷严重不成比例被否决:`contenteditable` 会把输入法组合、撤销/重做、选区语义与粘贴规范化重新压回我们身上,而这些目前都由 textarea 加输入状态机处理,且状态机已经持有一份假定 textarea 取值语义的撤销日志。
|
|
|
+**用 `contenteditable` div 渲染草稿。** 一个元素、一个偏移、区间可上样式。因代价与缺陷严重不成比例被否决:`contenteditable` 会把输入法组合、撤销/重做、选区语义与粘贴规范化重新压回我们身上,而这些目前都由 textarea 加输入状态机处理,且状态机已经持有一份假定 textarea 值语义的撤销日志。
|
|
|
|
|
|
**给三层都加 `scrollbar-gutter: stable`。** 在 textarea 还是滚动者时试过并已回退:WebKit 只对 `overflow-y: auto` 生效、不对 `overflow: hidden` 生效,那 8px 的差距原样留着,却让每个 chromium 用户无条件损失 8px 文本列宽。现在已无意义——三层共享同一包含块,没有什么需要预留。
|
|
|
|
|
|
@@ -63,9 +63,9 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl
|
|
|
- 光标不可能离开自己的字形。浏览器滚动的是同一个盒子,因此「textarea 把某一行放在哪」与「backdrop 把这一行画在哪」之间的距离在任何偏移下都是一个固定的行盒常量,手势进行中也不例外。场景测试度量的正是这个数。
|
|
|
- 滚动条从 textarea 移到了滚动容器上——视觉位置相同,只是外移了一层。`.card` 的 l2 token 绑定仍会继承下去。
|
|
|
- chip、claim token 高亮与文本引用标记在滚动时仍与其字形对齐,因为它们定位在 backdrop 内部、随之移动。除去掉哨兵之外,装饰扫描没有变化。
|
|
|
-- 在 Firefox 与 WebKit 上,点进一个草稿超过上限的 composer 现在还会把会话记录滚动到底部:光标的 scroll-into-view 会越过 composer 的滚动容器一路走到会话记录的滚动容器,而一个比自身盒子矮的 textarea 从不会引发这一步。chromium 不会。已实测,并试过 `overscroll-behavior: contain` 与 `contain: paint`,两者都拦不住这次上行——没有任何 CSS 能终止 scroll-into-view 的接力。接受:它滚向底部,而 composer 本来就在底部,而其替代方案是在每个引擎上都出现光标与文字明显分离。
|
|
|
+- 在 Firefox 与 WebKit 上,点进一个草稿超过上限的 composer 现在还会把会话 transcript(文本记录)滚动到底部:光标的 scroll-into-view 会越过 composer 的滚动容器一路走到 transcript 的滚动容器,而一个比自身盒子矮的 textarea 从不会引发这一步。chromium 不会。已实测,并试过 `overscroll-behavior: contain` 与 `contain: paint`,两者都拦不住这次上行——没有任何 CSS 能终止 scroll-into-view 的接力。接受:它滚向底部,而 composer 本来就在底部,而其替代方案是在每个引擎上都出现光标与文字明显分离。
|
|
|
- 翻页与拖拽选区的行为未变,二者均做了新旧对照实测。`PageDown`/`PageUp` 本来就不会移动 textarea 的插入点——chromium 是滚动一页并保持 `selectionStart` 不变,新旧几何皆然,区别只在于滚的是哪个盒子。拖拽选区越过下边缘仍会自动滚动,且落点一致(chromium 628/628、firefox 625/620、WebKit 170/170——WebKit 自动滚动较慢,但改动前后一样慢)。
|
|
|
-- composer 自己发起的 `focus()` 全部加了 `preventScroll`——解锁/切会话的 effect,以及工具栏按钮上那个保持焦点的 mousedown——因此一次没有任何手势要求的聚焦,不会再通过更高的 textarea 的回视链把 transcript 挪走。抑制这条链之后,光标就回到了我们手上,而这在一条路径上确实要紧:composer 的 DOM 跨会话复用,因此切到更长的草稿时旧偏移会留着,而换值会把光标放到新草稿的末尾。三引擎实测,这会让光标落在停在 0 的盒子下方 940px 处;于是该 effect 会在自己的滚动容器里把它带回来,落点 625/628——正是旧几何靠浏览器达到的 628。mousedown 那条不需要回视:光标没动过,而下一次敲键会拿到浏览器原生的回视。另一个只负责回视的 effect 会处理渲染后才到达的非空草稿:`ConversationSession` 在自己的 mount effect 中注入持久化草稿,而该 effect 在本组件的 effect 之后运行,否则第一次回视会量到空镜像,且不会再为随后出现的草稿重跑。第二个 effect 从不聚焦,因此发送后清空或发送失败后恢复这类普通的空/非空转换不会从其他控件夺走焦点。
|
|
|
+- composer 自己发起的 `focus()` 全部加了 `preventScroll`——解锁/切会话的 effect,以及工具栏按钮上那个保持焦点的 mousedown——因此一次没有任何手势要求的聚焦,不会再通过更高的 textarea 的回视链把 transcript 挪走。抑制这条链之后,光标就回到了我们手上,而这在一条路径上确实要紧:composer 的 DOM 跨会话复用,因此切到更长的草稿时旧偏移会留着,而换值会把光标放到新草稿的末尾。三引擎实测,这会让光标落在停在 0 的盒子下方 940px 处;于是该 effect 会在自己的滚动容器里把它带回来,落点 625/628——正是旧几何靠浏览器达到的 628。mousedown 那条不需要回视:光标没动过,而下一次敲键会拿到浏览器原生的回视。另一个只负责回视的 effect 会处理渲染后才到达的非空草稿:`ConversationSession` 在自己的 mount effect 中注入持久化草稿,而该 effect 在本组件的 effect 之后运行,因此如果没有这个独立 effect,第一次回视会量到空镜像,且不会再为随后出现的草稿重跑。第二个 effect 从不聚焦,因此发送后清空或发送失败后恢复这类普通的空/非空转换不会从其他控件夺走焦点。
|
|
|
- 撤销/重做可以在不恢复光标、也不回视的情况下改动草稿:状态机重放上一版草稿,DOM 选区停在浏览器钳位后的位置。这早于本次改动且未被改动——在此点名,是因为另外两处恢复都会回视,会让这处遗漏看起来像有意为之;真被报告时 helper 就在旁边。
|
|
|
- 任何新增在 backdrop 旁边的层都属于滚动容器**内部**,并且必须与草稿等高,否则就会重新引入这一缺陷。这是 composer 长期存在的风险点:两层拆分对 chip 与高亮是承重的,因此耦合必须来自结构,而不是靠维护。
|
|
|
|
|
|
@@ -73,7 +73,7 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl
|
|
|
|
|
|
[input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) 中的单元用例断言 jsdom 能看见的部分:同一个滚动盒同时包含 textarea 与 backdrop,backdrop 的文本现在就是草稿本身、不多不少,且渲染后才到达的持久化草稿会回视其光标,同时不从其他控件夺走焦点。jsdom 对任何元素都报告 `scrollHeight === clientHeight` 且从不滚动,因此几何属于浏览器场景;滚轮接力用例改为桩接滚动容器的度量,而非 textarea 的。
|
|
|
|
|
|
-[composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) 在 chromium 中针对构建产物度量其余部分:全新工作区空会话的 composer 中一份 40 行草稿,零模型调用。每个度量都在光标自己的坐标系里读取——即 textarea 把第 n 行放在哪,含其自身偏移——再与 backdrop 同一行文本上的 DOM Range 相比,因为这个差值正是用户看到的东西。决定性的用例改变偏移,并**在本任务结束之前**重新读取该差值,也就是在任何 `scroll` 监听可能运行之前:单一滚动容器下为 0,镜像方案下则是整个增量。空洞性保护先断言草稿确实超过了带上限的盒子;其余用例分别覆盖高度上限、三层同一折行宽度、滚轮手势、以换行结尾的草稿,以及过去由 textarea 自身滚动承担的光标回视路径——滚离光标后输入,必须把滚动容器带回光标处。
|
|
|
+[composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) 在 chromium 中针对构建产物度量其余部分:全新工作区的空白 composer 中一份 40 行草稿,零模型调用。每个度量都在光标自己的坐标系里读取——即 textarea 把第 n 行放在哪,含其自身偏移——再与 backdrop 同一行文本上的 DOM Range 相比,因为这个差值正是用户看到的东西。决定性的用例改变偏移,并**在本任务结束之前**重新读取该差值,也就是在任何 `scroll` 监听可能运行之前:单一滚动容器下为 0,镜像方案下则是整个增量。空洞性保护先断言草稿确实超过了带上限的盒子;其余用例分别覆盖高度上限、三层同一折行宽度、滚轮手势、以换行结尾的草稿,以及过去由 textarea 自身滚动承担的光标回视路径——滚离光标后输入,必须把滚动容器带回光标处。
|
|
|
|
|
|
另有一个用例端到端覆盖粘贴路径:短草稿、光标停在末尾,然后派发一个携带真实剪贴板数据的 `paste` 事件——与 Cmd-V 送达的是同一个事件,走同一个处理器——再检查偏移与所粘内容的最后一行。它等待的是偏移而不是「草稿是否溢出」,因为恢复发生在状态机提交草稿之后的下一帧;没有这次回视的构建会卡在这个等待上失败。
|
|
|
|