| author | |
| committer | |
| log | 522ebe65a542ace6f22d9ad95f95ef9ade7e87f1 |
| tree | 56d8f25ec0589555618bd55672c0a9de0d277f94 |
| parent | 6382268323f86e01b2d40206bcf33beccb3fb64a |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Code <noreply@anthropic.com>3 files changed, 105 insertions(+), 1 deletions(-)
apps/web/src/components/chat/ChatComposer.tsx+35-1| ... | ... | @@ -206,6 +206,7 @@ import { |
| 206 | 206 | resetComposerScrollGesture, |
| 207 | 207 | suppressActiveComposerScrollGesture, |
| 208 | 208 | } from "./composerScrollGesture"; |
| 209 | import { selectionHoldsComposerOpen } from "./composerSelectionHold"; | |
| 209 | 210 | import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; |
| 210 | 211 | |
| 211 | 212 | function ComposerVideoThumbnail({ file }: { file: File }) { |
| ... | ... | @@ -4293,9 +4294,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4293 | 4294 | ) { |
| 4294 | 4295 | return; |
| 4295 | 4296 | } |
| 4297 | if ( | |
| 4298 | !isMobileViewport && | |
| 4299 | selectionHoldsComposerOpen(window.getSelection(), getTimelineScrollableNode()) | |
| 4300 | ) { | |
| 4301 | // The check runs again once the selection clears. | |
| 4302 | return; | |
| 4303 | } | |
| 4296 | 4304 | setIsComposerFocused(false); |
| 4297 | 4305 | }); |
| 4298 | }, [isMobileViewport]); | |
| 4306 | }, [getTimelineScrollableNode, isMobileViewport]); | |
| 4307 | ||
| 4308 | // A held collapse settles when the selection goes away, whether the user | |
| 4309 | // clicked elsewhere, pressed Escape, or used the selection toolbar. | |
| 4310 | useEffect(() => { | |
| 4311 | if (isMobileViewport || !isComposerFocused) return; | |
| 4312 | let wasHolding = false; | |
| 4313 | const handleSelectionChange = () => { | |
| 4314 | const holding = selectionHoldsComposerOpen( | |
| 4315 | window.getSelection(), | |
| 4316 | getTimelineScrollableNode(), | |
| 4317 | ); | |
| 4318 | if (wasHolding && !holding) { | |
| 4319 | scheduleComposerCollapseCheck(); | |
| 4320 | } | |
| 4321 | wasHolding = holding; | |
| 4322 | }; | |
| 4323 | document.addEventListener("selectionchange", handleSelectionChange); | |
| 4324 | return () => { | |
| 4325 | document.removeEventListener("selectionchange", handleSelectionChange); | |
| 4326 | }; | |
| 4327 | }, [ | |
| 4328 | getTimelineScrollableNode, | |
| 4329 | isComposerFocused, | |
| 4330 | isMobileViewport, | |
| 4331 | scheduleComposerCollapseCheck, | |
| 4332 | ]); | |
| 4299 | 4333 | |
| 4300 | 4334 | useEffect(() => { |
| 4301 | 4335 | if (isMobileViewport || !isComposerFocused) return; |
apps/web/src/components/chat/composerSelectionHold.test.ts created+55| ... | ... | @@ -0,0 +1,55 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { selectionHoldsComposerOpen } from "./composerSelectionHold"; | |
| 4 | ||
| 5 | function node(parent: Node | null = null) { | |
| 6 | const self = { | |
| 7 | parent, | |
| 8 | contains(other: Node | null): boolean { | |
| 9 | let cursor: { parent: Node | null } | null = other as unknown as { parent: Node | null }; | |
| 10 | while (cursor) { | |
| 11 | if (cursor === (self as unknown)) return true; | |
| 12 | cursor = cursor.parent as { parent: Node | null } | null; | |
| 13 | } | |
| 14 | return false; | |
| 15 | }, | |
| 16 | }; | |
| 17 | return self as unknown as Node; | |
| 18 | } | |
| 19 | ||
| 20 | function selection(anchor: Node, collapsed = false) { | |
| 21 | return { | |
| 22 | isCollapsed: collapsed, | |
| 23 | rangeCount: 1, | |
| 24 | getRangeAt: () => ({ commonAncestorContainer: anchor }) as Range, | |
| 25 | }; | |
| 26 | } | |
| 27 | ||
| 28 | describe("selectionHoldsComposerOpen", () => { | |
| 29 | const timeline = node(); | |
| 30 | const message = node(timeline); | |
| 31 | const elsewhere = node(); | |
| 32 | ||
| 33 | it("holds for a range inside the timeline", () => { | |
| 34 | expect(selectionHoldsComposerOpen(selection(message), timeline)).toBe(true); | |
| 35 | }); | |
| 36 | ||
| 37 | it("ignores a caret with nothing selected", () => { | |
| 38 | expect(selectionHoldsComposerOpen(selection(message, true), timeline)).toBe(false); | |
| 39 | }); | |
| 40 | ||
| 41 | it("ignores selections outside the timeline", () => { | |
| 42 | expect(selectionHoldsComposerOpen(selection(elsewhere), timeline)).toBe(false); | |
| 43 | }); | |
| 44 | ||
| 45 | it("ignores an empty or missing selection", () => { | |
| 46 | expect(selectionHoldsComposerOpen(null, timeline)).toBe(false); | |
| 47 | expect( | |
| 48 | selectionHoldsComposerOpen( | |
| 49 | { isCollapsed: false, rangeCount: 0, getRangeAt: () => ({}) as Range }, | |
| 50 | timeline, | |
| 51 | ), | |
| 52 | ).toBe(false); | |
| 53 | expect(selectionHoldsComposerOpen(selection(message), null)).toBe(false); | |
| 54 | }); | |
| 55 | }); |
apps/web/src/components/chat/composerSelectionHold.ts created+15| ... | ... | @@ -0,0 +1,15 @@ |
| 1 | /** | |
| 2 | * Whether a live text selection inside the timeline should hold the composer | |
| 3 | * open. A drag-select in the conversation blurs the composer, and letting it | |
| 4 | * rest mid-gesture reflows the timeline, which dismisses the selection | |
| 5 | * toolbar the user was about to use. | |
| 6 | */ | |
| 7 | export function selectionHoldsComposerOpen( | |
| 8 | selection: Pick<Selection, "isCollapsed" | "rangeCount" | "getRangeAt"> | null, | |
| 9 | timeline: Node | null, | |
| 10 | ): boolean { | |
| 11 | if (!timeline || !selection || selection.isCollapsed || selection.rangeCount === 0) { | |
| 12 | return false; | |
| 13 | } | |
| 14 | return timeline.contains(selection.getRangeAt(0).commonAncestorContainer); | |
| 15 | } |