diff --git a/docs/fork-customizations.md b/docs/fork-customizations.md new file mode 100644 index 00000000..15617f38 --- /dev/null +++ b/docs/fork-customizations.md @@ -0,0 +1,366 @@ +# Fork 定制记录 + +> 本文档记录本 fork(`SsparKluo/OpenCodeUI`,作者 Louis LUO ``)相对于上游 `lehhair/OpenCodeUI` 的全部定制改动,**目的是在未来 merge upstream 时作为参考**:知道改了什么、为什么改、改了哪些文件,从而预判冲突、避免回退、快速重应用。 + +最后核对基准:上游 `main` = `c49fb49f`(v0.6.34),fork 集成分支 `dev` = `c6898e6d`。 + +--- + +## 1. 分支模型 + +| 分支 | 角色 | +| --------------- | -------------------------------------------------------------------- | +| `upstream/main` | 上游 `lehhair/OpenCodeUI` 主干 | +| `main` | **纯上游镜像**,始终与 `upstream/main` 对齐(`merge-base` 相同) | +| `dev` | **fork 集成分支**,所有 Louis 的定制都在这里;下游功能分支从此拉出 | +| `feat/*`、`fix/*`| 主题功能分支,完成后合并回 `dev` | +| `deploy/cloudflare` | Cloudflare 部署相关配置分支(当前 checkout) | + +**合并 upstream 的标准流程**:`git fetch upstream` → 在 `dev` 上 `git merge upstream/main` → 参考**第 3 节**逐个冲突点核对意图 → 解决冲突后跑 `pnpm test`。`main` 分支仅用于 fast-forward 到 `upstream/main`,**不要在 main 上做任何 fork 改动**。 + +--- + +## 2. 定制概览(按主题) + +fork 共有约 70 个非合并提交,归为 12 个主题。下表按「上游冲突风险」排序——风险越高,merge upstream 时越需要重点关注。 + +| # | 主题 | 类型 | 冲突风险 | 主要文件 | +| - | -------------------------- | --------- | -------- | ----------------------------------------------- | +| 1 | 滚动系统重构(v2) | 重写 | 🔴 高 | `useAutoScroll.ts`、`ChatArea.tsx` | +| 2 | 移动端输入框折叠 | 重写 | 🔴 高 | `InputBox.tsx`、`useMobileCollapse.ts` | +| 3 | 侧边栏 / 项目选择器重构 | 重写 | 🔴 高 | `SidePanel.tsx`、`Header.tsx`、`useSessions.ts` | +| 4 | 模型选择器移到输入栏 | UI 迁移 | 🟡 中 | `Header.tsx`、`InputToolbar.tsx`、`ModelSelector.tsx` | +| 5 | per-block 自动展开控制 | 功能+设置 | 🟡 中 | `MessageRenderer.tsx`、各 PartView、`themeStore.ts` | +| 6 | 触摸手势 / iOS Safari | 平台修复 | 🟡 中 | `ChatArea.tsx`、`scrollGesture.ts`、`index.css` | +| 7 | Diff / 代码预览复制按钮 | 功能 | 🟡 中 | `DiffViewer.tsx`、`CodePreview.tsx`、`diffFormat.ts` | +| 8 | API 错误处理 / 鉴权 | 健壮性 | 🟢 低 | `sdk.ts`、`errorHandling.ts`、`ServersSettings.tsx` | +| 9 | aggregateStepFinish 选项 | 功能+设置 | 🟢 低 | `MessageRenderer.tsx`、`ChatSettings.tsx` | +| 10 | 文本选择浮窗 Quote/Copy | 功能 | 🟡 中 | `TextSelectionPopup.tsx`、`popupUtils.ts`、`ChatPane.tsx` | +| 11 | 队列消息输入栏 | 功能 | 🟡 中 | `QueuedMessagesBar.tsx`、`useChatSession.ts`、`ChatPane.tsx` | +| 12 | Cloudflare 部署 | 部署专属 | 🟢 低 | `workers/api-proxy/`、`.github/workflows/` | + +--- + +## 3. 主题详解 + +### 3.1 滚动系统重构(v2 — 输入事件驱动)🔴 + +**目的**:上游的聊天滚动实现存在抖动、跟随不可靠、加载新内容时跳屏等问题。fork 用 `@tanstack/react-virtual` 虚拟化聊天页面,配合自研的 `useAutoScroll` hook 重建整个滚动系统。 + +**架构演进(v1 → v2)**: +- **v1**(`markBoundaryGesture` + 250ms 时间窗):用 opencode 的 `markBoundaryGesture` 手工标记手势边界,搭配 `isAuto`/`markAuto` 给程序滚动打标(2px 容差 + 1500ms TTL token)来区分程序 vs 用户滚动。局限:浏览器滚动事件没有 JS 前驱事件(layout shift / find-in-page / history restore)无法归因。 +- **v2**(输入事件驱动,`2026-07`):彻底移除 `markAuto`/`isAuto`。在 **input 事件层**(wheel/touch/pointerdown/keydown/selectionchange)判断用户意图,`handleScroll` 降级为纯响应(clear `userScrolled` 当返回底部、schedule recover-pin 当漂移)。单一 10px 阈值替代原本 10/60-150/80 分裂阈值。 + +**关键设计**: +- 聊天页面按 `chatPageModel.ts` 分页,**倒序存储、正序渲染**(最旧页面在顶部)。 +- `useAutoScroll` 维护「跟随模式」(`userScrolled` ref),用户向上滚动超出 10px 阈值即脱离跟随,返回底部阈值内自动重新跟随。 +- 滚动跟随 vs 自定义滚动条整合:全局 `overlayScrollbar.ts` 隐藏原生滚动条并渲染 `.os-thumb` 层。`useAutoScroll` 通过自定义事件 `OS_DRAG_START`/`OS_DRAG_END` 感知滚动条拖拽(原生 scrollbar-width:none 使原 pointerdown 启发式失效)。 +- Capsule reasoning、BashRenderer、TaskRenderer 的「智能滚动」统一 60px 阈值(`0c729b0`),仅当距底部 ≤60px 时自动跟随流式内容。 +- selectionchange 仅在**流式活跃时**才触发脱离跟随(`c56183f7`):空闲时选文字复制/引用不打断跟随。 +- 方向感知触摸恢复:`onTouchEnd` 仅在 `touchMaxDownRef > 10`(真实向下拖拽)时尝试恢复跟随,避免轻触/上划回弹误触发。 +- `` 用 `@tanstack/react-virtual` 的 `anchorTo:"end"` + `resizeItem` 覆盖 + `overflowAnchor:"none"`,完全通过该路径控制滚动。 + +**输入事件归因规则**: +- wheel-up 且 target 在 chat root 或 `[data-scrollable]` 内 → 停止跟随(忽略侧栏/输入框/textarea 目标) +- touchstart / pointerdown 在 scrollbar region 或 chat root 包含嵌套 → 停止 +- selectionchange 非空 → 停止(仅流式时生效);清除 → 停止 +- scroll 事件驱动的 tryRecover 仅限于:wheel deltaY>0、ArrowDown/PageDown/End、OS_DRAG_END(滚动条拖到底) + +**涉及文件**: +- `src/features/chat/virtual/useAutoScroll.ts`(+ `.test.tsx`)— 核心实现,**几乎全量重写** +- `src/features/chat/ChatArea.tsx` — 虚拟化容器,托管 scroll-follow +- `src/features/chat/chatPageModel.ts` + `ChatArea.test.ts` — 页面模型 +- `src/features/chat/ChatPane.tsx` +- `src/lib/overlayScrollbar.ts` — 自定义滚动条,派发 OS_DRAG 事件 +- `src/features/message/parts/ReasoningPartView.tsx`、`src/features/message/tools/renderers/TaskRenderer.tsx` — 智能滚动适配 +- `src/features/chat/DESIGN.md` — 滚动跟随设计文档 + +**关键 commits**(按 dev 上的最新版本): +- (v1 基础)`f3b5c6a` feat: overhaul scroll system with useAutoScroll + @tanstack/react-virtual +- (v1 基础)`85cd3c7` fix: reverse chat page order so oldest messages render first +- (v1 基础)`1c5c18c` refactor: replace 250ms scroll gesture window with markBoundaryGesture +- `4a897f25` refactor(chat): input-event-driven scroll-follow redesign(v2 核心) +- `7b00a87` refactor(chat): eliminate stopPendingRef, single-state recovery +- `c56183f7` feat(chat): selection only affects follow state during active streaming +- `ee1dbf3` feat(chat): wheel inside nested scrollable always stops follow +- `db178c8` feat(chat): direction-aware touch recovery + overlayScrollbar events +- `33c887b0` feat(chat): align nested scroll gestures with upstream boundary +- `ba574d0` feat(chat): only expanding disclosure stops follow, collapsing doesn't +- `4b316ee` fix(chat): clearing selection no longer changes follow state +- `354b11c` fix(chat): preserve stop intent for tiny wheel-up via stopPending +- `7cfa63d` fix(reasoning): keep capsule scroll when scrolled up during streaming +- `cdb7e0f` fix(message): stop entry-grow animation on cleanup to avoid stuck height under StrictMode + +> ⚠️ **上游冲突高发区**。上游对 `ChatArea` / 滚动逻辑改动频繁,每次 merge 几乎必然冲突。核对意图时优先保证 `useAutoScroll` 的「跟随/脱离/回弹」语义不被破坏,特别留意 `overflowAnchor:"none"` 和 `anchorTo:"end"` 不被上游覆盖。 + +--- + +### 3.2 移动端输入框折叠 🔴 + +**目的**:移动端输入框展开/收起原本用 auto-collapse,体验割裂。fork 改为**手动折叠 + CSS `grid-rows` 过渡**,并把 textarea 高度测量从直接读 DOM 改为**隐藏 mirror 元素**测量,避免输入时布局抖动和页脚被顶出可视区。 + +**关键设计**: +- `InputBox.tsx` 用 CSS grid 双行轨道(展开行 / 折叠行)做过渡,折叠时**完全回收**空间(不留 gap — `587f580`)。 +- textarea 高度由 `measureTextareaContentHeight.ts` 隐藏 mirror 测量,`useTextareaAutoHeight.ts` 消费;不再在 auto-resize 时重置高度(`e9bff81`)。 +- Mention/SlashCommand 菜单移出 `overflow-hidden` 容器,防止被裁切(`46a079d`)。 + +**涉及文件**: +- `src/features/chat/InputBox.tsx` — **重灾区**,几乎每次 merge 都冲突 +- `src/features/chat/input/useMobileCollapse.ts` +- `src/features/chat/input/useTextareaAutoHeight.ts` +- `src/features/chat/input/measureTextareaContentHeight.ts`(新增) +- `src/features/chat/input/InputActions.tsx`、`InputToolbar.tsx` +- `src/constants/ui.ts` + +**关键 commits**: +- `d89d815` refactor: mobile input collapse bar with CSS grid-rows transition +- `a28cac2` refactor: replace auto-collapse with manual collapse toggle on mobile +- `55c41ca` fix: restructure expanded track grid so collapse fully reclaims space +- `c5b3601` fix: use textarea mirror for stable height measurement +- `92fbf69` fix: use hidden mirror element to measure textarea content height +- `e9bff81` fix: remove unnecessary textarea height reset on auto-resize +- `587f580` fix: remove collapsed input track gap over chat history +- `b2b8776` fix: drop expandedHeight reference +- `46a079d` fix: move MentionMenu/SlashCommandMenu outside overflow-hidden +- `8911b67` fix(chat): make collapsed input dock click-through except capsule +- `a7d6d75` fix(chat): 修复输入框折叠的两个滚动态bug + +--- + +### 3.3 侧边栏 / 项目选择器重构 🔴 + +**目的**:重做侧边栏顶部交互——用「添加项目」按钮替换原项目选择器,并新增**工作区 header location**(会话头部显示/切换所属工作区目录)。同时让 Sidebar 始终挂载,修复折叠/展开过渡丢失的问题。 + +**关键设计**: +- `SidePanel.tsx` 顶部改为「Open Project」按钮 + 工作区列表。 +- 新增 `SessionHeaderLocation.tsx` / `SessionHeaderLocationPicker.tsx` / `sessionHeaderContext.ts`,把工作区目录绑定到会话 header。 +- 新增 `useRecentWorkspaceDirectories.ts`、`useSwitchWorkspaceDirectory.ts`、`recentWorkspaceDirectories.ts`、`draftNewChatSession.ts` 等工具。 +- `Sidebar.tsx` 始终挂载(`7f16c55`),靠 CSS 控制可见性,保证过渡动画。 + +**涉及文件**: +- `src/features/chat/sidebar/SidePanel.tsx`、`FolderRecentList.tsx` +- `src/features/chat/Header.tsx`、`PaneHeader.tsx` +- `src/features/chat/SessionHeaderLocation.tsx`、`SessionHeaderLocationPicker.tsx`、`sessionHeaderContext.ts`(新增) +- `src/features/chat/sidebar/{draftNewChatSession,recentWorkspaceDirectories}.ts`(新增) +- `src/features/chat/{useRecentWorkspaceDirectories,useSwitchWorkspaceDirectory}.ts`(新增) +- `src/features/sessions/SessionList.tsx`、`src/hooks/useSessions.ts` +- `src/features/chat/{Sidebar.tsx,ChatPane.tsx,InputBox.tsx}`、`src/App.tsx` + +**关键 commits**: +- `261e7b4` refactor: replace project selector with add-project button, add workspace header location +- `c7527af` feat: rename 'add project' to 'open project' and always show new chat in sidebar +- `7f16c55` fix: restore sidebar collapse/expand transition by always mounting Sidebar +- `50c7876` fix: clean up merge fallout — 上次 merge upstream 后的清理性修复 + +--- + +### 3.4 模型选择器移到输入栏 🟡 + +**目的**:把模型选择器从顶部 Header 移到输入框工具栏,让用户在输入时即可切换模型,释放 Header 空间。 + +**涉及文件**: +- `src/features/chat/Header.tsx` — 移除 ModelSelector +- `src/features/chat/input/InputToolbar.tsx` — 接入 ModelSelector +- `src/features/chat/ModelSelector.tsx` +- `src/features/chat/ChatPane.tsx` + +**关键 commits**: +- `15f3ed2` feat: move model selector from header to input toolbar + +> 上游若调整 Header 或 InputToolbar 布局会冲突;语义上很简单,冲突时保留「ModelSelector 在 InputToolbar」即可。 + +--- + +### 3.5 per-block 自动展开控制 🟡 + +**目的**:给 reasoning / subtask / tool 等消息块增加**逐块自动展开控制**,并新增「沉浸式未读工具折叠」模式——活跃工具运行期间,未读的工具块按用户偏好折叠,避免刷屏。 + +**关键设计**: +- 新增 `utils/blockCollapseMode.ts`(+ test)定义折叠模式枚举。 +- 设置项写入 `themeStore.ts`,UI 在 `ChatSettings.tsx`。 +- 各 `PartView` 根据 mode + 「是否活跃运行」决定展开/折叠。 + +**涉及文件**: +- `src/features/message/MessageRenderer.tsx` +- `src/features/message/parts/{ReasoningPartView,SubtaskPartView,ToolPartView}.tsx` +- `src/utils/blockCollapseMode.ts`(新增)+ `.test.ts` +- `src/features/settings/components/ChatSettings.tsx` +- `src/store/themeStore.ts` + +**关键 commits**: +- `a589062` feat: per-block auto-expand control with immersive unread tool option +- `1c455ca` fix: respect immersive unread tool collapse mode during active tool run + +--- + +### 3.6 触摸手势 / iOS Safari 🟡 + +**目的**:移动端在 `ChatArea` 上需要同时支持**水平翻页(pager)手势**和**纵向滚动**,但 iOS Safari 默认 `touch-action` 会拦截斜向手势。fork 通过 `touch-pan-y` + 沿 DOM 链向上传播,让 pager 与滚动共存。(曾尝试给 popup 开启垂直 touch pan,最终 revert——iOS 上副作用大于收益。) + +**涉及文件**: +- `src/features/chat/ChatArea.tsx`、`ChatPane.tsx` +- `src/features/chat/scrollGesture.ts`、`useScrollGestureDetector.ts`(+ tests) +- `src/index.css` — `touch-action` 相关规则 + +**关键 commits**: +- `9a33b19` fix: add touch-pan-y to ChatArea to allow horizontal pager gestures on mobile +- `fa3c13c` fix: propagate touch-pan-y up the DOM chain to enable horizontal pager gestures +- `e0e4397`→`41d9ec7`(已 revert)尝试给 iOS Safari popup 开启垂直 touch pan + +--- + +### 3.7 Diff / 代码预览复制按钮 🟡 + +**目的**:给 diff viewer 和 code preview 加复制按钮,提升可用性。`diffFormat.ts` 抽取统一的 diff 文本格式化逻辑。 + +**涉及文件**: +- `src/components/DiffViewer.tsx`(+ `.test.tsx`)、`DiffView.tsx` +- `src/components/CodePreview.tsx`(+ `.test.tsx`) +- `src/components/ContentBlock.tsx`、`SessionChangesPanel.tsx` +- `src/features/message/tools/renderers/DefaultRenderer.tsx` +- `src/utils/diffFormat.ts`(+ `.test.ts`) + +**关键 commits**: +- `69892a4` feat: add copy button to diff viewer and code preview +- `c30ded2` fix: close JSX expression in CopyButton conditional render(构建修复) + +--- + +### 3.8 API 错误处理 / 鉴权 🟢 + +**目的**:上游对 opencode server 错误静默失败,fork 把错误**通过 toast 显式暴露给用户**;并支持**在默认 server 上配置鉴权凭据**(原本只能对自定义 server 配);**允许在存在其他 server 时删除默认 server**(桌面端除外——桌面端的默认 server 既是配置项也是 app 后端)。 + +**关键设计**(默认 server 可删除): +- `serverStore.removeServer` 守卫由 `server.isDefault` 改为 `server.isDefault && (isTauri() || servers.length <= 1)`。 +- 桌面端(`isTauri()`)禁止删除默认 server——避免 `ServiceSettings` 的「启动本地服务」功能静默失效(`setLocalServerRuntimeUrl` 依赖默认 server 存在)。 +- UI 上删除按钮对默认 server 在 `!isTauri() && servers.length > 1` 时显示;编辑按钮仍隐藏(保持作用域最小)。 +- 持久化无改动——`loadFromStorage` 的「空列表重建默认」逻辑是安全的,因为删除默认 server 必导致列表非空。唯一能清空列表的路径(删完所有 server)会触发重建默认,这是合理的 reset 行为。 + +**涉及文件**: +- `src/api/sdk.ts`、`src/utils/errorHandling.ts`(新增) +- `src/contexts/SessionContext.tsx`、`src/hooks/useSessions.ts` +- `src/features/chat/sidebar/{FolderRecentList,SidePanel}.tsx`(错误展示接入点) +- `src/features/settings/components/ServersSettings.tsx`(+ `.test.tsx`) +- `src/store/serverStore.ts`(+ `.test.ts`)— 删除守卫 + +**关键 commits**: +- `ad838c6` fix: surface opencode server errors to users via toast +- `edd8f49` fix: allow configuring auth credentials on the default server +- (pending)feat: allow deleting the default server when others exist (non-desktop) + +--- + +### 3.9 aggregateStepFinish 选项 🟢 + +**目的**:新增设置项控制 step-finish 事件的展示方式(聚合显示),写入 `themeStore`。 + +**涉及文件**: +- `src/features/message/MessageRenderer.tsx`(+ `.test.tsx`) +- `src/features/settings/components/ChatSettings.tsx` +- `src/store/themeStore.ts` + +**关键 commits**: +- `9fc58a6` feat: add aggregateStepFinish option for step-finish display + +--- + +### 3.10 文本选择浮窗 Quote/Copy 🟡 + +**目的**:在聊天面板选中文本时浮出 Quote 和 Copy 按钮,提升引用/复制体验。Quote 将选中文本转为 Markdown 块引用,插入当前面板输入框的光标位置;Copy 将原文写入剪贴板。 + +**关键设计**: +- 全局挂载一次(`App.tsx`),通过 `createPortal` 渲染到 `document.body`。 +- `selectionchange` 仅维护 pending ref,浮窗在 `mouseup/touchend/Shift+Arrow keyup` 才提交,避免拖拽过程中闪烁。 +- 定位依据鼠标/触摸释放点或键盘选择的 selection rect。 +- 自动关闭条件:外部 pointerdown、scroll、resize、Escape 键。 +- `data-pane-id` 添加到 ChatPane 根元素,使跨分屏面板的选择能解析到正确的 textarea。 +- 过滤 `` / ` + + ` + const anchor = container.querySelector('#anchor-text')!.firstChild! + const range = document.createRange() + range.setStart(anchor, 0) + range.setEnd(anchor, anchor.textContent!.length) + + // jsdom doesn't compute layout, so getBoundingClientRect returns 0s. The + // popup relies on a real rect to position itself, so override it for the + // synthetic range. In a real browser this stays untouched. + range.getBoundingClientRect = () => + ({ + top: 100, + left: 200, + right: 300, + bottom: 120, + width: 100, + height: 20, + x: 200, + y: 100, + toJSON: () => ({}), + }) as DOMRect + + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + return { + textarea: container.querySelector('[data-testid="textarea"]')!, + selection, + range, + } +} + +function releasePointer(clientX = 250, clientY = 110) { + fireEvent.mouseUp(document.body, { clientX, clientY }) +} + +async function flush() { + await act(async () => { + await new Promise(r => setTimeout(r, 50)) + }) +} + +describe('TextSelectionPopup', () => { + it('does NOT appear during a selection drag — only after mouseup commits', async () => { + setupSelectionPane('hello there') + + render() + await flush() + + // selectionchange ran mid-test setup; no mouseup yet → popup must be hidden. + expect(document.body.querySelector('[data-text-selection-popup]')).toBeNull() + + await act(async () => { + releasePointer(280, 140) + }) + + expect(document.body.querySelector('[data-text-selection-popup]')).not.toBeNull() + }) + + it('renders Quote and Copy buttons after mouseup over a valid selection', async () => { + setupSelectionPane('hello there') + render() + await flush() + await act(async () => { + releasePointer() + }) + + const popup = document.body.querySelector('[data-text-selection-popup]') + expect(popup).not.toBeNull() + const quoteButton = popup?.querySelector('button[aria-label="Quote"]') + const copyButton = popup?.querySelector('button[aria-label="Copy"]') + expect(quoteButton).not.toBeNull() + expect(copyButton).not.toBeNull() + }) + + it('does not appear for selections outside any data-pane-id container', async () => { + container.innerHTML = ` +
plain outside pane + ${'outside'} +
+ ` + const anchor = container.querySelector('#anchor-text')!.firstChild! + const range = document.createRange() + range.setStart(anchor, 0) + range.setEnd(anchor, anchor.textContent!.length) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + + render() + await flush() + await act(async () => { + releasePointer() + }) + + expect(document.body.querySelector('[data-text-selection-popup]')).toBeNull() + }) + + it('Quote writes a markdown block-quote into the pane textarea and focuses it', async () => { + const { textarea } = setupSelectionPane('how does this work?') + + render() + await flush() + await act(async () => { + releasePointer() + }) + + const quoteButton = document.body.querySelector('button[aria-label="Quote"]') as HTMLButtonElement + expect(quoteButton).not.toBeNull() + + act(() => { + fireEvent.click(quoteButton) + }) + + expect(textarea.value.startsWith('> how does this work?')).toBe(true) + expect(COPY_MOCK).not.toHaveBeenCalled() + }) + + it('Copy writes the selection to the clipboard without touching the textarea', async () => { + const { textarea } = setupSelectionPane('plain copy text') + render() + await flush() + await act(async () => { + releasePointer() + }) + + const copyButton = document.body.querySelector('button[aria-label="Copy"]') as HTMLButtonElement + expect(copyButton).not.toBeNull() + + await act(async () => { + fireEvent.click(copyButton) + }) + + expect(COPY_MOCK).toHaveBeenCalledWith('plain copy text') + expect(textarea.value).toBe('') + }) + + it('hides on Escape after a mouseup-committed selection', async () => { + setupSelectionPane('escape me') + render() + await flush() + await act(async () => { + releasePointer() + }) + expect(document.body.querySelector('[data-text-selection-popup]')).not.toBeNull() + + await act(async () => { + fireEvent.keyDown(document, { key: 'Escape' }) + }) + + expect(document.body.querySelector('[data-text-selection-popup]')).toBeNull() + }) + + it('hides on scroll after a mouseup-committed selection', async () => { + setupSelectionPane('scroll away') + render() + await flush() + await act(async () => { + releasePointer() + }) + expect(document.body.querySelector('[data-text-selection-popup]')).not.toBeNull() + + await act(async () => { + window.dispatchEvent(new Event('scroll')) + }) + expect(document.body.querySelector('[data-text-selection-popup]')).toBeNull() + }) + + it('places the popup near the pointer release point, not the selection start', async () => { + setupSelectionPane('anchor for position math') + render() + await flush() + await act(async () => { + // Release at coordinates far from the synthetic selection rect + // (rect is at top=100/left=200 — pick something the rect-aware path + // would never choose). + releasePointer(900, 600) + }) + + const popup = document.body.querySelector('[data-text-selection-popup]') + expect(popup).not.toBeNull() + // Read the inline style values applied by computePopupAtPointer. + expect(popup!.style.position).toBe('fixed') + expect(popup!.style.top).toMatch(/^\d+(\.\d+)?px$/) + expect(popup!.style.left).toMatch(/^\d+(\.\d+)?px$/) + const leftPx = Number.parseFloat(popup!.style.left) + // Should not coincide with the synthetic rect's left (200). + expect(leftPx).not.toBe(200) + // 900 - popupWidth estimate (200) places the cursor's left edge of the popup. + expect(leftPx).toBeGreaterThan(500) + }) +}) diff --git a/src/features/text-selection-popup/TextSelectionPopup.tsx b/src/features/text-selection-popup/TextSelectionPopup.tsx new file mode 100644 index 00000000..9f10f3f7 --- /dev/null +++ b/src/features/text-selection-popup/TextSelectionPopup.tsx @@ -0,0 +1,272 @@ +/** + * TextSelectionPopup — global floating popup that surfaces "Quote" and "Copy" + * actions when the user highlights text inside a chat pane. + * + * - selectionchange tracks a *pending* selection into a ref. The popup + * never appears during a drag — it only commits on mouseup / touchend / + * keyboard Shift+arrow release so the toolbar never flickers mid-drag. + * - Positions near the pointer release point (mouse / touch), or near the + * selection's bounding rect for keyboard selections. + * - Renders into document.body via createPortal so its fixed position is + * unaffected by ancestor transform / overflow. + * - Auto-hides on: outside pointerdown, scroll, resize, Esc. + */ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { CheckIcon, CopyIcon, QuoteIcon } from '../../components/Icons' +import { copyTextToClipboard } from '../../utils/clipboard' +import { clipboardErrorHandler } from '../../utils/errorHandling' +import { + buildQuotePatch, + computePopupPosition, + findTargetTextarea, + shouldShowPopupForSelection, +} from './popupUtils' + +const POPUP_WIDTH_ESTIMATE = 180 +const POPUP_HEIGHT_ESTIMATE = 30 +const COPIED_FEEDBACK_MS = 1500 + +type PendingSelection = { + text: string + rect: DOMRect + textarea: HTMLTextAreaElement +} + +type PlacedPopup = PendingSelection & { + /** Pointer release point in viewport coordinates; null for keyboard selection. */ + pointer: { x: number; y: number } | null +} + +export function TextSelectionPopup() { + const { t } = useTranslation('chat') + const [placed, setPlaced] = useState(null) + const [copied, setCopied] = useState(false) + const [popupWidth, setPopupWidth] = useState(POPUP_WIDTH_ESTIMATE) + const [popupHeight, setPopupHeight] = useState(POPUP_HEIGHT_ESTIMATE) + const popupRef = useRef(null) + const copiedTimerRef = useRef | null>(null) + /** + * Latest valid selection, kept current via selectionchange. The popup + * never renders from this — only from mouseup/touchend/keyup commits. + */ + const pendingRef = useRef(null) + + // ── selectionchange: maintain pending ref only, never show popup ── + useEffect(() => { + if (typeof document === 'undefined') return + const handler = () => { + const selection = window.getSelection() + if (!selection || !shouldShowPopupForSelection(selection)) { + pendingRef.current = null + return + } + const textarea = findTargetTextarea(selection) + const range = selection.getRangeAt(0) + const rect = range.getBoundingClientRect() + // Zero-by-zero rect = browser hasn't laid out the selection (display:none + // content, or the jsdom test env). The toolbar needs real geometry. + if (!textarea || (rect.width === 0 && rect.height === 0)) { + pendingRef.current = null + return + } + pendingRef.current = { text: selection.toString(), rect, textarea } + } + document.addEventListener('selectionchange', handler) + return () => document.removeEventListener('selectionchange', handler) + }, []) + + // ── pointerdown outside popup: dismiss + clear pending ── + useEffect(() => { + if (typeof document === 'undefined') return + const handler = (e: PointerEvent) => { + const popup = popupRef.current + if (popup && e.target instanceof Node && popup.contains(e.target)) return + setPlaced(null) + pendingRef.current = null + } + document.addEventListener('pointerdown', handler) + return () => document.removeEventListener('pointerdown', handler) + }, []) + + // ── mouseup / touchend: commit pending + position at pointer ── + useEffect(() => { + if (typeof document === 'undefined') return + const commit = (pointer: { x: number; y: number } | null) => { + const pending = pendingRef.current + if (!pending) return + setPlaced({ ...pending, pointer }) + } + const onMouseUp = (e: MouseEvent) => commit({ x: e.clientX, y: e.clientY }) + const onTouchEnd = (e: TouchEvent) => { + const t = e.changedTouches[0] + if (t) commit({ x: t.clientX, y: t.clientY }) + } + document.addEventListener('mouseup', onMouseUp) + document.addEventListener('touchend', onTouchEnd) + return () => { + document.removeEventListener('mouseup', onMouseUp) + document.removeEventListener('touchend', onTouchEnd) + } + }, []) + + // ── keyboard Shift+arrow: commit pending, no pointer coords ── + useEffect(() => { + if (typeof document === 'undefined') return + const SELECTION_KEYS = new Set([ + 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', + 'Home', 'End', 'PageUp', 'PageDown', + ]) + const handler = (e: KeyboardEvent) => { + if (!e.shiftKey || !SELECTION_KEYS.has(e.key)) return + const pending = pendingRef.current + if (!pending) return + setPlaced({ ...pending, pointer: null }) + } + document.addEventListener('keyup', handler) + return () => document.removeEventListener('keyup', handler) + }, []) + + // ── dismiss on scroll / resize / Escape (only while popup is open) ── + useEffect(() => { + if (!placed) return + const hide = () => setPlaced(null) + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setPlaced(null) + window.getSelection()?.removeAllRanges() + } + } + window.addEventListener('scroll', hide, true) + window.addEventListener('resize', hide) + document.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('scroll', hide, true) + window.removeEventListener('resize', hide) + document.removeEventListener('keydown', onKey) + } + }, [placed]) + + // ── measure actual popup size for accurate positioning ── + useLayoutEffect(() => { + if (!placed) return + const popup = popupRef.current + if (!popup) return + const ro = new ResizeObserver(entries => { + for (const entry of entries) { + const { width, height } = entry.contentRect + if (width > 0) setPopupWidth(width) + if (height > 0) setPopupHeight(height) + } + }) + ro.observe(popup) + return () => ro.disconnect() + }, [placed]) + + // ── cleanup copied feedback timer on unmount ── + useEffect(() => { + return () => { + if (copiedTimerRef.current !== null) clearTimeout(copiedTimerRef.current) + } + }, []) + + const handleCopy = useCallback(async () => { + if (!placed) return + try { + await copyTextToClipboard(placed.text) + setCopied(true) + if (copiedTimerRef.current !== null) clearTimeout(copiedTimerRef.current) + copiedTimerRef.current = setTimeout(() => { + setCopied(false) + copiedTimerRef.current = null + }, COPIED_FEEDBACK_MS) + } catch (err) { + clipboardErrorHandler('copy selection', err) + } + }, [placed]) + + const handleQuote = useCallback(() => { + if (!placed) return + const textarea = placed.textarea + const existing = textarea.value + const cursor = typeof textarea.selectionStart === 'number' ? textarea.selectionStart : existing.length + const { newText, newCursor } = buildQuotePatch(existing, cursor, placed.text) + + // Drive the textarea via its native value setter so React sees a real + // input event and syncs the controlled state on the next render. + const valueSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(textarea), 'value')?.set + if (valueSetter) valueSetter.call(textarea, newText) + textarea.dispatchEvent(new Event('input', { bubbles: true })) + + requestAnimationFrame(() => { + textarea.focus() + textarea.setSelectionRange(newCursor, newCursor) + }) + + window.getSelection()?.removeAllRanges() + setPlaced(null) + }, [placed]) + + // Position: use pointer coords when available (mouse/touch), otherwise + // the selection's bounding rect (keyboard). + const position = useMemo(() => { + if (!placed) return null + const anchor = placed.pointer + ? { top: placed.pointer.y, bottom: placed.pointer.y, left: placed.pointer.x } + : { top: placed.rect.top, bottom: placed.rect.bottom, left: placed.rect.left } + const vw = typeof window !== 'undefined' ? window.innerWidth : 1024 + return computePopupPosition(anchor, popupHeight, popupWidth, vw) + }, [placed, popupHeight, popupWidth]) + + if (!placed || !position) return null + + return createPortal( +
e.preventDefault()} + > +
+ + +
, + document.body, + ) +} diff --git a/src/features/text-selection-popup/index.ts b/src/features/text-selection-popup/index.ts new file mode 100644 index 00000000..a3b13cec --- /dev/null +++ b/src/features/text-selection-popup/index.ts @@ -0,0 +1 @@ +export { TextSelectionPopup } from './TextSelectionPopup' diff --git a/src/features/text-selection-popup/popupUtils.test.ts b/src/features/text-selection-popup/popupUtils.test.ts new file mode 100644 index 00000000..b834f857 --- /dev/null +++ b/src/features/text-selection-popup/popupUtils.test.ts @@ -0,0 +1,251 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + buildQuotePatch, + computePopupPosition, + findTargetTextarea, + formatQuote, + shouldShowPopupForSelection, +} from './popupUtils' + +let container: HTMLDivElement + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) +}) + +afterEach(() => { + document.body.removeChild(container) +}) + +/** + * Build a minimal fake `Selection` for `findTargetTextarea` / `shouldShow*`. + * jsdom doesn't implement window.getSelection in a useful way, so we unit-test + * the helpers with stub objects that quack like a Selection. + */ +function makeFakeSelection(opts: { + anchorElement: Element | null + text?: string + isCollapsed?: boolean + rangeCount?: number +}): Partial { + const rangeCount = opts.rangeCount ?? 1 + return { + anchorNode: opts.anchorElement, + isCollapsed: opts.isCollapsed ?? false, + rangeCount, + toString: () => opts.text ?? '', + getRangeAt: () => ({}) as Range, + } +} + +describe('formatQuote', () => { + it('prefixes a single line with "> "', () => { + expect(formatQuote('hello world')).toBe('> hello world') + }) + + it('prefixes each line of a multi-line selection', () => { + expect(formatQuote('line one\nline two\nline three')).toBe('> line one\n> line two\n> line three') + }) + + it('emits a lone ">" for blank lines so Markdown stays a valid blockquote', () => { + expect(formatQuote('first\n\nthird')).toBe('> first\n>\n> third') + }) + + it('returns an empty string for empty input', () => { + expect(formatQuote('')).toBe('') + }) +}) + +describe('buildQuotePatch', () => { + it('quotes into an empty input and leaves the cursor on a fresh line below', () => { + const result = buildQuotePatch('', 0, 'first line\nsecond line') + expect(result.newText).toBe('> first line\n> second line\n\n') + expect(result.newCursor).toBe('> first line\n> second line\n\n'.length) + }) + + it('wraps the quote with blank lines when appending to text that ends with a single newline', () => { + const existing = 'Please look at this:\n' + const result = buildQuotePatch(existing, existing.length, 'quoted block') + expect(result.newText).toBe('Please look at this:\n\n> quoted block\n\n') + expect(result.newCursor).toBe(result.newText.length) + }) + + it('keeps the existing blank line above and still adds one below when text ends with a blank line', () => { + const existing = 'Already has a blank line:\n\n' + const result = buildQuotePatch(existing, existing.length, 'quoted block') + expect(result.newText).toBe('Already has a blank line:\n\n> quoted block\n\n') + }) + + it('quotes in the middle of a non-empty input with blank lines on both sides', () => { + const existing = 'prefix suffix' + const result = buildQuotePatch(existing, 'prefix '.length, 'line one\nline two') + expect(result.newText).toBe('prefix \n\n> line one\n> line two\n\nsuffix') + expect(result.newCursor).toBe('prefix \n\n> line one\n> line two\n\n'.length) + }) + + it('wraps the quote with blank lines even when the cursor is clamped to the end', () => { + const result = buildQuotePatch('abc', 99, 'quoted') + expect(result.newText).toBe('abc\n\n> quoted\n\n') + expect(result.newCursor).toBe('abc\n\n> quoted\n\n'.length) + }) + + it('handles multi-line selections and still leaves a trailing blank line', () => { + const result = buildQuotePatch('hi ', 3, 'foo\nbar') + expect(result.newText).toBe('hi \n\n> foo\n> bar\n\n') + expect(result.newCursor).toBe('hi \n\n> foo\n> bar\n\n'.length) + }) +}) + +describe('findTargetTextarea', () => { + it('returns null when selection is null', () => { + expect(findTargetTextarea(null)).toBeNull() + }) + + it('returns null when the selection is not inside any pane', () => { + container.innerHTML = '
no pane here
' + const anchor = container.querySelector('div') + const sel = makeFakeSelection({ anchorElement: anchor }) + expect(findTargetTextarea(sel as Selection)).toBeNull() + }) + + it('returns the textarea inside the closest data-pane-id ancestor', () => { + container.innerHTML = ` +
+

inside pane a

+ +
+ ` + const anchor = container.querySelector('p') + const sel = makeFakeSelection({ anchorElement: anchor }) + const textarea = findTargetTextarea(sel as Selection) + expect(textarea).toBe(container.querySelector('textarea')) + }) + + it('walks up to a shared pane ancestor when textarea lives in a sibling subtree', () => { + // Real ChatPane DOM: messages and the input live in different subtrees + // under the same data-pane-id wrapper. Selection deep inside a message + // must still resolve to the input textarea in the same pane. + container.innerHTML = ` +
+
+

a thought provoking sentence worth quoting

+
+
+
+ +
+
+
+ ` + const anchor = container.querySelector('#msg') + const sel = makeFakeSelection({ anchorElement: anchor }) + const textarea = findTargetTextarea(sel as Selection) + expect(textarea).toBe(container.querySelector('textarea')) + }) +}) + +describe('shouldShowPopupForSelection', () => { + it('rejects null selections', () => { + expect(shouldShowPopupForSelection(null)).toBe(false) + }) + + it('rejects collapsed selections', () => { + container.innerHTML = ` +
+

just sitting here

+
+ ` + const anchor = container.querySelector('p') + const sel = makeFakeSelection({ anchorElement: anchor, isCollapsed: true, text: 'something' }) + expect(shouldShowPopupForSelection(sel as Selection)).toBe(false) + }) + + it('rejects selections whose text is whitespace only', () => { + container.innerHTML = ` +
+

content

+
+ ` + const anchor = container.querySelector('p') + const sel = makeFakeSelection({ anchorElement: anchor, text: ' ' }) + expect(shouldShowPopupForSelection(sel as Selection)).toBe(false) + }) + + it('rejects selections inside an input/textarea', () => { + container.innerHTML = ` +
+ +
+ ` + const textarea = container.querySelector('textarea')! + const sel = makeFakeSelection({ anchorElement: textarea, text: 'user draft' }) + expect(shouldShowPopupForSelection(sel as Selection)).toBe(false) + }) + + it('rejects selections inside elements marked with data-no-selection-popup', () => { + container.innerHTML = ` +
+
opt-out
+
+ ` + const anchor = container.querySelector('span') + const sel = makeFakeSelection({ anchorElement: anchor, text: 'opt-out' }) + expect(shouldShowPopupForSelection(sel as Selection)).toBe(false) + }) + + it('accepts ordinary text inside a pane', () => { + container.innerHTML = ` +
+

an interesting fact

+
+ ` + const anchor = container.querySelector('p') + const sel = makeFakeSelection({ anchorElement: anchor, text: 'an interesting fact' }) + expect(shouldShowPopupForSelection(sel as Selection)).toBe(true) + }) +}) + +describe('computePopupPosition', () => { + // The function takes a simple anchor {top, bottom, left} that works for + // both pointer-release coords (top==bottom==y) and selection rects. + function anchor(top: number, bottom: number, left: number) { + return { top, bottom, left } + } + + it('places above when there is room', () => { + const result = computePopupPosition(anchor(200, 220, 50), 40, 200, 1024, 8, 800) + expect(result.placement).toBe('above') + expect(result.top).toBe(152) // 200 - 40 - 8 + expect(result.left).toBe(50) + }) + + it('places below when above would clip', () => { + const result = computePopupPosition(anchor(20, 30, 50), 40, 200, 1024, 8, 800) + expect(result.placement).toBe('below') + expect(result.top).toBe(38) // 30 + 8 + }) + + it('clamps to the right edge of the viewport', () => { + const result = computePopupPosition(anchor(200, 220, 900), 40, 200, 1024, 8, 800) + expect(result.left).toBeLessThanOrEqual(1024 - 200 - 8) + }) + + it('clamps to the left edge of the viewport', () => { + const result = computePopupPosition(anchor(200, 220, -100), 40, 200, 1024, 8, 800) + expect(result.left).toBe(8) + }) + + it('falls back to above (clamped) when both directions are tight', () => { + const result = computePopupPosition(anchor(2, 4, 0), 40, 200, 1024, 8, 50) + expect(result.placement).toBe('above') + expect(result.top).toBeGreaterThanOrEqual(8) + }) + + it('works with a zero-height anchor (pointer release point)', () => { + const result = computePopupPosition(anchor(200, 200, 100), 30, 180, 1024, 8, 800) + expect(result.placement).toBe('above') + expect(result.top).toBe(162) // 200 - 30 - 8 + expect(result.left).toBe(100) + }) +}) diff --git a/src/features/text-selection-popup/popupUtils.ts b/src/features/text-selection-popup/popupUtils.ts new file mode 100644 index 00000000..42e71ef6 --- /dev/null +++ b/src/features/text-selection-popup/popupUtils.ts @@ -0,0 +1,155 @@ +/** + * Pure helpers for the text-selection floating popup. Kept dependency-free so + * they can be unit-tested without a DOM or React runtime. + */ + +/** + * Format a selection as a Markdown blockquote prefix. Each line gets a `> ` + * (or a single `>` for blank lines), matching the convention used by GitHub, + * Slack and other Markdown dialects. + */ +export function formatQuote(text: string): string { + if (text.length === 0) return '' + return text + .split('\n') + .map(line => (line.length > 0 ? `> ${line}` : '>')) + .join('\n') +} + +/** + * Count the newlines needed before the quote so it lands on its own blank + * line in the rendered Markdown. + * - empty `before` → 0 (quote at the very start, nothing above it) + * - ends with `\n\n`+ → 0 (blank line already exists) + * - ends with `\n` → 1 (add one more for a blank line) + * - ends with non-newline → 2 (full blank line) + */ +function leadNewlinesFor(before: string): string { + if (before.length === 0) return '' + if (/\n\s*\n\s*$/.test(before)) return '' + if (/\n\s*$/.test(before)) return '\n' + return '\n\n' +} + +/** + * Newlines needed after the quote so it is always separated from what follows + * by a blank line — including when nothing follows, so the user can type a + * reply below the blockquote instead of appending to its last line. + * - empty `after` → 2 (blank line, lands cursor on a fresh line) + * - starts with `\n\n`+ → 0 (blank line already exists) + * - starts with `\n` → 1 (add one more for a blank line) + * - starts with non-newline → 2 (full blank line) + */ +function trailingNewlinesFor(after: string): string { + if (after.length === 0) return '\n\n' + if (/^\s*\n\s*\n/.test(after)) return '' + if (/^\s*\n/.test(after)) return '\n' + return '\n\n' +} + +/** + * Build a text patch that inserts a quoted version of `selected` into + * `existing` at `cursor`, returning the next text + the cursor position just + * past the trailing separator (so the user can keep typing a reply below the + * blockquote). + */ +export function buildQuotePatch( + existing: string, + cursor: number, + selected: string, +): { newText: string; newCursor: number } { + const safeCursor = Math.max(0, Math.min(cursor, existing.length)) + const before = existing.slice(0, safeCursor) + const after = existing.slice(safeCursor) + const quoted = formatQuote(selected) + + const lead = leadNewlinesFor(before) + const trail = trailingNewlinesFor(after) + + const newText = `${before}${lead}${quoted}${trail}${after}` + const newCursor = before.length + lead.length + quoted.length + trail.length + + return { newText, newCursor } +} + +/** + * Locate the target `