Sent attachments
)} @@ -727,6 +733,7 @@ export function ConversationMessageContent( mentions={props.mentions} mobileActionDisplay={props.mobileActionDisplay ?? "overflow"} onAddToChat={props.onAddToChat} + onSelectProse={props.onSelectProse} onOpenLink={props.onOpenLink} onOpenLocalFileLink={onOpenLocalFileLink} projectId={projectId} diff --git a/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx b/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx index 923e35e3f..fe606a9b6 100644 --- a/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx +++ b/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx @@ -4,6 +4,7 @@ import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { MULTI_CLICK_SELECTION_REPORT_DELAY_MS, + renderedTextSelectionFromRange, SelectableMessageProse, } from "./SelectableMessageProse.js"; @@ -27,8 +28,19 @@ function makeWindowSelection({ text: string; }): Selection { const rect = new DOMRect(10, 20, 30, 8); + const fullText = node.textContent ?? text; + const normalizedText = text.trim(); + const selectedStart = Math.max(0, fullText.indexOf(normalizedText)); + const selectedEnd = Math.min( + fullText.length, + selectedStart + normalizedText.length, + ); const range = { commonAncestorContainer: commonAncestorContainer ?? node, + startContainer: node, + startOffset: selectedStart, + endContainer: node, + endOffset: selectedEnd, getBoundingClientRect: () => rect, getClientRects: () => ({ length: 1, @@ -68,6 +80,37 @@ describe("SelectableMessageProse", () => { ).not.toBeNull(); }); + it("captures one UTF-16 rendered-text selector across nested Markdown nodes", () => { + const root = document.createElement("div"); + root.append("Before "); + const strong = document.createElement("strong"); + strong.textContent = "nested ๐ text"; + root.append(strong, " after"); + document.body.append(root); + const range = document.createRange(); + range.setStart(root.firstChild!, 3); + range.setEnd(strong.firstChild!, "nested ๐".length); + const rect = new DOMRect(10, 20, 90, 18); + vi.spyOn(Range.prototype, "getClientRects").mockReturnValue({ + 0: rect, + length: 1, + item: (index: number) => (index === 0 ? rect : null), + } as unknown as DOMRectList); + vi.spyOn(Range.prototype, "getBoundingClientRect").mockReturnValue(rect); + + expect(renderedTextSelectionFromRange(root, range)).toEqual({ + version: 1, + coordinateSpace: "rendered-text-utf16", + start: 3, + end: "Before nested ๐".length, + exact: "ore nested ๐", + prefix: "Bef", + suffix: " text after", + rects: [{ x: 10, y: 20, width: 90, height: 18 }], + }); + root.remove(); + }); + it("reports a selection only after pointer release", async () => { const onSelect = vi.fn(); const { getByText } = render( diff --git a/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx b/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx index 784293000..32cb691a3 100644 --- a/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx +++ b/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, type ReactNode } from "react"; +import type { experimental_PluginRenderedTextSelection } from "@bb/plugin-sdk"; export interface SelectionAnchorPoint { x: number; @@ -15,6 +16,8 @@ export interface SelectionAnchor { export interface MessageProseSelection { text: string; rect: DOMRect; + /** Present for selections captured inside canonical message prose roots. */ + renderedText?: experimental_PluginRenderedTextSelection; anchorPoint?: SelectionAnchorPoint; anchorSide?: SelectionAnchorSide; sourceSeqEnd?: number; @@ -106,17 +109,126 @@ function isSelectionBoundarySpillWithinNode( ); } +function textOffsetAtBoundary( + root: HTMLElement, + container: Node, + offset: number, +): number | null { + if (container !== root && !root.contains(container)) return null; + const prefix = document.createRange(); + prefix.selectNodeContents(root); + try { + prefix.setEnd(container, offset); + } catch { + return null; + } + return (prefix.cloneContents().textContent ?? "").length; +} + +function slicePrefixWithoutSplittingSurrogate( + text: string, + end: number, +): string { + let start = Math.max(0, end - 32); + const first = text.charCodeAt(start); + if ( + start > 0 && + first >= 0xdc00 && + first <= 0xdfff && + text.charCodeAt(start - 1) >= 0xd800 && + text.charCodeAt(start - 1) <= 0xdbff + ) { + start += 1; + } + return text.slice(start, end); +} + +function sliceSuffixWithoutSplittingSurrogate( + text: string, + start: number, +): string { + let end = Math.min(text.length, start + 32); + const last = text.charCodeAt(end - 1); + if ( + end < text.length && + last >= 0xd800 && + last <= 0xdbff && + text.charCodeAt(end) >= 0xdc00 && + text.charCodeAt(end) <= 0xdfff + ) { + end -= 1; + } + return text.slice(start, end); +} + +export function renderedTextSelectionFromRange( + root: HTMLElement, + range: Range, + geometryRange: Range = range, +): experimental_PluginRenderedTextSelection | null { + const start = textOffsetAtBoundary( + root, + range.startContainer, + range.startOffset, + ); + const end = textOffsetAtBoundary(root, range.endContainer, range.endOffset); + if (start === null || end === null || end <= start) return null; + + const text = root.textContent ?? ""; + const exact = text.slice(start, end); + if (exact.trim().length === 0) return null; + const rects: experimental_PluginRenderedTextSelection["rects"][number][] = []; + const clientRects = geometryRange.getClientRects(); + for (let index = 0; index < clientRects.length; index += 1) { + const rect = clientRects.item(index); + if (rect === null || (rect.width <= 0 && rect.height <= 0)) continue; + rects.push({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }); + } + if (rects.length === 0) { + const rect = geometryRange.getBoundingClientRect(); + if (rect.width > 0 || rect.height > 0) { + rects.push({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }); + } + } + if (rects.length === 0) return null; + + return { + version: 1, + coordinateSpace: "rendered-text-utf16", + start, + end, + exact, + prefix: slicePrefixWithoutSplittingSurrogate(text, start), + suffix: sliceSuffixWithoutSplittingSurrogate(text, end), + rects, + }; +} + function toMessageProseSelection({ anchor, rect, - text, + renderedText, }: { anchor: SelectionAnchor | null; rect: DOMRect | null; - text: string; + renderedText: experimental_PluginRenderedTextSelection | null; }): MessageProseSelection | null { - if (text.length === 0 || rect === null) return null; - const selection: MessageProseSelection = { text, rect }; + if (renderedText === null || rect === null) return null; + const selection: MessageProseSelection = { + text: renderedText.exact, + rect, + renderedText, + }; if (anchor !== null) { selection.anchorPoint = anchor.point; selection.anchorSide = anchor.side; @@ -193,15 +305,31 @@ function readSelectionWithinNode( commonAncestorContainer: range.commonAncestorContainer, }); if (accepted) { - const text = selection.toString().trim(); const rect = firstClientRect(range); - return toMessageProseSelection({ anchor, rect, text }); + const renderedText = renderedTextSelectionFromRange(node, range); + return toMessageProseSelection({ anchor, rect, renderedText }); } - const text = selection.toString().trim(); + const text = selection.toString(); if (isSelectionBoundarySpillWithinNode(node, range, text)) { + const clippedRange = document.createRange(); + if (node.contains(range.startContainer)) { + clippedRange.setStart(range.startContainer, range.startOffset); + } else { + clippedRange.setStart(node, 0); + } + if (node.contains(range.endContainer)) { + clippedRange.setEnd(range.endContainer, range.endOffset); + } else { + clippedRange.setEnd(node, node.childNodes.length); + } const rect = firstClientRect(range); - return toMessageProseSelection({ anchor, rect, text }); + const renderedText = renderedTextSelectionFromRange( + node, + clippedRange, + range, + ); + return toMessageProseSelection({ anchor, rect, renderedText }); } return null; @@ -375,6 +503,7 @@ export function SelectableMessageProse({ // inset. A long-press text selection uses the same touch sequence, so // keep sidebar swipe recognition out of selectable message prose. data-no-sidebar-swipe + data-bb-experimental-message-prose-root="" > {children}