From 51a0ec647ea61aeeeea2960aac76721f5a2e626f Mon Sep 17 00:00:00 2001 From: Louis LUO Date: Wed, 22 Jul 2026 18:18:33 +0800 Subject: [PATCH] feat(text-selection-popup): Quote + Copy actions on chat text selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface a floating popup with Quote and Copy buttons when the user selects text inside a chat pane. Quote turns the selection into a Markdown blockquote and inserts it into the pane's input box at the cursor; Copy writes the raw selection to the clipboard. Architecture: - Mounted once in App, renders into document.body via createPortal - selectionchange tracks a pending ref; popup only commits on mouseup/touchend/Shift+arrow keyup — never flickers mid-drag - Positions near the pointer release point (mouse/touch) or the selection rect (keyboard) - Auto-dismisses on outside pointerdown, scroll, resize, Esc - data-pane-id added to ChatPane root so message selections resolve to the correct pane's textarea across split panes Filters out selections inside form controls, contenteditable, and [data-no-selection-popup] opt-out zones. Quote spacing follows Slack/Notion conventions (always at least one blank line around the block). 607 tests pass (34 in this feature). 0 lint warnings. --- src/App.tsx | 2 + src/components/Icons.tsx | 2 + src/features/chat/ChatPane.tsx | 1 + .../TextSelectionPopup.test.tsx | 226 +++++++++++++++ .../TextSelectionPopup.tsx | 272 ++++++++++++++++++ src/features/text-selection-popup/index.ts | 1 + .../text-selection-popup/popupUtils.test.ts | 252 ++++++++++++++++ .../text-selection-popup/popupUtils.ts | 147 ++++++++++ src/locales/en/chat.json | 7 + src/locales/zh-CN/chat.json | 7 + 10 files changed, 917 insertions(+) create mode 100644 src/features/text-selection-popup/TextSelectionPopup.test.tsx create mode 100644 src/features/text-selection-popup/TextSelectionPopup.tsx create mode 100644 src/features/text-selection-popup/index.ts create mode 100644 src/features/text-selection-popup/popupUtils.test.ts create mode 100644 src/features/text-selection-popup/popupUtils.ts diff --git a/src/App.tsx b/src/App.tsx index 7ce65ebd..224edad7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { invoke } from '@tauri-apps/api/core' import { Sidebar } from './features/chat' import { ChatPane } from './features/chat/ChatPane' import { SplitContainer } from './features/chat/SplitContainer' +import { TextSelectionPopup } from './features/text-selection-popup' import type { CommandItem } from './components/CommandPalette' import { ToastContainer } from './components/ToastContainer' import { RightPanel } from './components/RightPanel' @@ -985,6 +986,7 @@ function App() { )} + diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 2c77061d..c9c9e134 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -14,6 +14,7 @@ import { Check, Send, Plus, + Quote, GraduationCap, Settings, Sun, @@ -123,6 +124,7 @@ export const KeyboardIcon = wrap(Keyboard) export const CheckIcon = wrap(Check) export const SendIcon = wrap(Send) export const PlusIcon = wrap(Plus) +export const QuoteIcon = wrap(Quote) export const TeachIcon = wrap(GraduationCap) export const SettingsIcon = wrap(Settings) export const SunIcon = wrap(Sun) diff --git a/src/features/chat/ChatPane.tsx b/src/features/chat/ChatPane.tsx index 8ca49902..5959d89f 100644 --- a/src/features/chat/ChatPane.tsx +++ b/src/features/chat/ChatPane.tsx @@ -980,6 +980,7 @@ export const ChatPane = memo(function ChatPane({
({ + copyTextToClipboard: vi.fn(async () => undefined), +})) + +vi.mock('../../utils/errorHandling', () => ({ + clipboardErrorHandler: vi.fn(), +})) + +import { copyTextToClipboard } from '../../utils/clipboard' + +const COPY_MOCK = copyTextToClipboard as unknown as ReturnType + +let container: HTMLDivElement + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + COPY_MOCK.mockClear() +}) + +afterEach(() => { + document.body.removeChild(container) +}) + +function setupSelectionPane(selectionText: string) { + container.innerHTML = ` +
+
${selectionText}
+ +
+ ` + 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..c83deeab --- /dev/null +++ b/src/features/text-selection-popup/popupUtils.test.ts @@ -0,0 +1,252 @@ +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 places the cursor at the end of the quote', () => { + 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'.length) + }) + + it('quotes at the end of a non-empty input that already ends with a newline', () => { + const existing = 'Please look at this:\n' + const result = buildQuotePatch(existing, existing.length, 'quoted block') + // Single trailing newline → add one more so the quote sits on a blank line. + expect(result.newText).toBe('Please look at this:\n\n> quoted block') + expect(result.newCursor).toBe(result.newText.length) + }) + + it('does not double-add a separator when the input already 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') + }) + + it('quotes in the middle of a non-empty input while preserving surrounding text', () => { + 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'.length) + }) + + it('clamps an out-of-range cursor to the text length', () => { + const result = buildQuotePatch('abc', 99, 'quoted') + expect(result.newText).toBe('abc\n\n> quoted') + expect(result.newCursor).toBe('abc\n\n> quoted'.length) + }) + + it('handles multi-line selections that already include internal newlines', () => { + const result = buildQuotePatch('hi ', 3, 'foo\nbar') + expect(result.newText).toBe('hi \n\n> foo\n> bar') + expect(result.newCursor).toBe('hi \n\n> foo\n> bar'.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..95f036ee --- /dev/null +++ b/src/features/text-selection-popup/popupUtils.ts @@ -0,0 +1,147 @@ +/** + * 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 (handled separately) + * - 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' +} + +/** Symmetric trailing-newline counterpart to {@link leadNewlinesFor}. */ +function trailingNewlinesFor(after: string): string { + if (after.length === 0) return '' + 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 right + * after the insertion (so the user can continue typing). + */ +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 beforeChunk = `${before}${leadNewlinesFor(before)}` + const middleSep = before.length === 0 ? '\n\n' : '' // empty input → cursor lands on fresh line + const afterChunk = `${middleSep}${trailingNewlinesFor(after)}${after}` + + const newText = `${beforeChunk}${quoted}${afterChunk}` + const newCursor = beforeChunk.length + quoted.length + + return { newText, newCursor } +} + +/** + * Locate the target `