# Files changed · PR \#9499 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/9499/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(web\): keep the composer open while selecting timeline text](https://git.cafe/pingdotgg/t3code/pull/9499.md)

Version: 2

Head commit: 4557997a01c6af375598d8b97c293045a1f8b5fb

Base commit: 08346fbf4ff44eedabc513af9df049a3bbefa9d8

Comparison base: 08346fbf4ff44eedabc513af9df049a3bbefa9d8

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/src/components/chat/ChatComposer.tsx

Status: modified; +35; −1

```diff
@@ -206,6 +206,7 @@
   resetComposerScrollGesture,
   suppressActiveComposerScrollGesture,
 } from "./composerScrollGesture";
+import { selectionHoldsComposerOpen } from "./composerSelectionHold";
 import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
 
 function ComposerVideoThumbnail({ file }: { file: File }) {
@@ -4293,13 +4294,46 @@
       ) {
         return;
       }
+      if (
+        !isMobileViewport &&
+        selectionHoldsComposerOpen(window.getSelection(), getTimelineScrollableNode())
+      ) {
+        // The check runs again once the selection clears.
+        return;
+      }
       setIsComposerFocused(false);
     });
-  }, [isMobileViewport]);
+  }, [getTimelineScrollableNode, isMobileViewport]);
 
+  // A held collapse settles when the selection goes away, whether the user
+  // clicked elsewhere, pressed Escape, or used the selection toolbar.
   useEffect(() => {
     if (isMobileViewport || !isComposerFocused) return;
+    let wasHolding = false;
+    const handleSelectionChange = () => {
+      const holding = selectionHoldsComposerOpen(
+        window.getSelection(),
+        getTimelineScrollableNode(),
+      );
+      if (wasHolding && !holding) {
+        scheduleComposerCollapseCheck();
+      }
+      wasHolding = holding;
+    };
+    document.addEventListener("selectionchange", handleSelectionChange);
+    return () => {
+      document.removeEventListener("selectionchange", handleSelectionChange);
+    };
+  }, [
+    getTimelineScrollableNode,
+    isComposerFocused,
+    isMobileViewport,
+    scheduleComposerCollapseCheck,
+  ]);
 
+  useEffect(() => {
+    if (isMobileViewport || !isComposerFocused) return;
+
     const isInsideDesktopComposerFocusScope = (target: EventTarget | null) =>
       Boolean(
         target instanceof Node &&
```

## apps/web/src/components/chat/composerSelectionHold.test.ts

Status: added; +55; −0

```diff
@@ -1,0 +1,55 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { selectionHoldsComposerOpen } from "./composerSelectionHold";
+
+function node(parent: Node | null = null) {
+  const self = {
+    parent,
+    contains(other: Node | null): boolean {
+      let cursor: { parent: Node | null } | null = other as unknown as { parent: Node | null };
+      while (cursor) {
+        if (cursor === (self as unknown)) return true;
+        cursor = cursor.parent as { parent: Node | null } | null;
+      }
+      return false;
+    },
+  };
+  return self as unknown as Node;
+}
+
+function selection(anchor: Node, collapsed = false) {
+  return {
+    isCollapsed: collapsed,
+    rangeCount: 1,
+    getRangeAt: () => ({ commonAncestorContainer: anchor }) as Range,
+  };
+}
+
+describe("selectionHoldsComposerOpen", () => {
+  const timeline = node();
+  const message = node(timeline);
+  const elsewhere = node();
+
+  it("holds for a range inside the timeline", () => {
+    expect(selectionHoldsComposerOpen(selection(message), timeline)).toBe(true);
+  });
+
+  it("ignores a caret with nothing selected", () => {
+    expect(selectionHoldsComposerOpen(selection(message, true), timeline)).toBe(false);
+  });
+
+  it("ignores selections outside the timeline", () => {
+    expect(selectionHoldsComposerOpen(selection(elsewhere), timeline)).toBe(false);
+  });
+
+  it("ignores an empty or missing selection", () => {
+    expect(selectionHoldsComposerOpen(null, timeline)).toBe(false);
+    expect(
+      selectionHoldsComposerOpen(
+        { isCollapsed: false, rangeCount: 0, getRangeAt: () => ({}) as Range },
+        timeline,
+      ),
+    ).toBe(false);
+    expect(selectionHoldsComposerOpen(selection(message), null)).toBe(false);
+  });
+});
```

## apps/web/src/components/chat/composerSelectionHold.ts

Status: added; +15; −0

```diff
@@ -1,0 +1,15 @@
+/**
+ * Whether a live text selection inside the timeline should hold the composer
+ * open. A drag-select in the conversation blurs the composer, and letting it
+ * rest mid-gesture reflows the timeline, which dismisses the selection
+ * toolbar the user was about to use.
+ */
+export function selectionHoldsComposerOpen(
+  selection: Pick<Selection, "isCollapsed" | "rangeCount" | "getRangeAt"> | null,
+  timeline: Node | null,
+): boolean {
+  if (!timeline || !selection || selection.isCollapsed || selection.rangeCount === 0) {
+    return false;
+  }
+  return timeline.contains(selection.getRangeAt(0).commonAncestorContainer);
+}
```
