| 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,6 +206,7 @@ import { |
| 206 | resetComposerScrollGesture, | 206 | resetComposerScrollGesture, |
| 207 | suppressActiveComposerScrollGesture, | 207 | suppressActiveComposerScrollGesture, |
| 208 | } from "./composerScrollGesture"; | 208 | } from "./composerScrollGesture"; |
| 209 | import { selectionHoldsComposerOpen } from "./composerSelectionHold"; | ||
| 209 | import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; | 210 | import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; |
| 210 | 211 | ||
| 211 | function ComposerVideoThumbnail({ file }: { file: File }) { | 212 | function ComposerVideoThumbnail({ file }: { file: File }) { |
| ... | @@ -4293,9 +4294,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4293,9 +4294,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4293 | ) { | 4294 | ) { |
| 4294 | return; | 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 | setIsComposerFocused(false); | 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 | useEffect(() => { | 4334 | useEffect(() => { |
| 4301 | if (isMobileViewport || !isComposerFocused) return; | 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 | } | ||