feat(web): toast anchoring and model-selection rejection banner
The toast sits 120px from the viewport top and centers over its anchor — the composer card, so it centers on the chat column rather than the window; a rejected model selection (e.g. picking a text-only model while the session holds images) announces through the same banner while the in-menu strip with Retry stays the catalog-load surface. The attachment rail consumes every wheel tick with a vertical component: a diagonal pan keeps its horizontal intent and nothing scrolls the conversation behind the composer.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md
|
||||
README.md: 7e67a0064f5611ac814a9d489db0d4cc471309e1
|
||||
README.zh.md: bf44f6a42db0e1a2c06e1f42133918150b123c2e
|
||||
README.md: 9fab9c23b958606030b1e87fcbfa45130c980947
|
||||
README.zh.md: 668dba11154538f52a9a87692020868c1b8a63d5
|
||||
|
||||
@@ -6,7 +6,7 @@ Pure React attachment atoms (zero cordis): the composer draft-image rail (`Attac
|
||||
|
||||
## Attachment rail
|
||||
|
||||
`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling (instant under `prefers-reduced-motion: reduce`), and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and rail size changes (a ResizeObserver on the rail element, so sidebar and panel resizes count, not only window resizes). A vertical wheel pans the rail horizontally through a non-passive listener that consumes the event — the same tick never also scrolls the conversation — with LINE/PAGE deltas normalized to pixels and per-tick travel clamped to 60px, while trackpad horizontal pans keep native scrolling. A newly added item is revealed at the rail's end; removal keeps the scroll position, and a rail that mounts over an already-populated draft keeps its start position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist.
|
||||
`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling (instant under `prefers-reduced-motion: reduce`), and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and rail size changes (a ResizeObserver on the rail element, so sidebar and panel resizes count, not only window resizes). The rail scrolls horizontally only: a non-passive listener consumes every wheel tick with a vertical component — nothing scrolls the conversation behind the composer — converting a pure vertical wheel to a horizontal step (LINE/PAGE deltas normalized to pixels, per-tick travel clamped to 60px) and keeping a diagonal pan's horizontal intent, while purely horizontal pans stay native. A newly added item is revealed at the rail's end; removal keeps the scroll position, and a rail that mounts over an already-populated draft keeps its start position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist.
|
||||
|
||||
## Message images and the lightbox
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 附件栏
|
||||
|
||||
`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动(`prefers-reduced-motion: reduce` 下瞬时完成),箭头的显隐在滚动、条目数量变化和栏自身尺寸变化时依据滚动几何重算(rail 元素上的 ResizeObserver,因此侧栏、面板的宽度变化也计入,不只是窗口尺寸变化)。纵向滚轮经非 passive 监听器转为横向平移并独占消费该事件,同一次滚动不会同时滚动会话记录;LINE/PAGE 单位的增量先归一化为像素,单次行程钳制在 60px 内,触控板的横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位,带着已有草稿重新挂载的栏保持起始位置。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。
|
||||
`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动(`prefers-reduced-motion: reduce` 下瞬时完成),箭头的显隐在滚动、条目数量变化和栏自身尺寸变化时依据滚动几何重算(rail 元素上的 ResizeObserver,因此侧栏、面板的宽度变化也计入,不只是窗口尺寸变化)。附件栏只允许横向滚动:非 passive 监听器消费所有带纵向分量的滚轮事件——不会滚动输入框背后的会话记录——纯纵向滚轮转为横向步进(LINE/PAGE 单位先归一化为像素,单次行程钳制在 60px 内),对角平移保留其横向分量,纯横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位,带着已有草稿重新挂载的栏保持起始位置。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。
|
||||
|
||||
## 消息图片与灯箱
|
||||
|
||||
|
||||
@@ -109,20 +109,25 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
|
||||
observer.observe(el)
|
||||
disconnect = () => { observer.disconnect() }
|
||||
}
|
||||
// A vertical wheel pans the rail horizontally and is consumed: without
|
||||
// preventDefault the same tick would also scroll the conversation behind
|
||||
// the composer. React's root wheel listener is passive, so the exclusive
|
||||
// conversion needs this manually attached non-passive listener. LINE and
|
||||
// PAGE deltas (Firefox notch wheels) are normalized to pixels before the
|
||||
// per-tick clamp that keeps a fast wheel followable.
|
||||
// The rail scrolls horizontally ONLY: any wheel tick with a vertical
|
||||
// component is consumed — without preventDefault it would also scroll the
|
||||
// conversation behind the composer, and React's root wheel listener is
|
||||
// passive, so the exclusion needs this manually attached non-passive
|
||||
// listener. A diagonal trackpad pan keeps its horizontal intent; a pure
|
||||
// vertical wheel converts to a horizontal step, with LINE and PAGE deltas
|
||||
// (Firefox notch wheels) normalized to pixels before the per-tick clamp
|
||||
// that keeps a fast wheel followable. A purely horizontal pan stays
|
||||
// native.
|
||||
const onWheel = (event: globalThis.WheelEvent): void => {
|
||||
if (event.deltaX !== 0 || event.deltaY === 0) return
|
||||
if (event.deltaY === 0) return
|
||||
const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
||||
? WHEEL_LINE_PX
|
||||
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1
|
||||
event.preventDefault()
|
||||
el.scrollBy({
|
||||
left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
|
||||
left: event.deltaX !== 0
|
||||
? event.deltaX * scale
|
||||
: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
|
||||
behavior: 'auto',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,10 +128,14 @@ describe('AttachmentRail', () => {
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 32, behavior: 'auto' })
|
||||
fireEvent.wheel(rail, { deltaY: -1, deltaMode: WheelEvent.DOM_DELTA_PAGE })
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' })
|
||||
// A trackpad pan (deltaX) and a zero-delta wheel keep native behavior.
|
||||
expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })).toBe(true)
|
||||
// A diagonal pan is consumed too — nothing vertical may escape the rail —
|
||||
// and keeps its horizontal intent.
|
||||
expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })).toBe(false)
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 12, behavior: 'auto' })
|
||||
// A purely horizontal pan and a zero-delta wheel keep native behavior.
|
||||
expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 0 })).toBe(true)
|
||||
fireEvent.wheel(rail, { deltaY: 0 })
|
||||
expect(scrollBy).toHaveBeenCalledTimes(5)
|
||||
expect(scrollBy).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('pages instantly under a reduced-motion preference, smoothly otherwise', () => {
|
||||
|
||||
Reference in New Issue
Block a user