From 0f616312a70fea83e722a3c1a19416d181ffedd1 Mon Sep 17 00:00:00 2001 From: karunkop Date: Wed, 26 Nov 2025 12:43:26 +0545 Subject: [PATCH 01/12] added custom method to override default ios behavior for carets --- src/components/Editable.tsx | 130 ++++++++++++- src/e2e/iOS/__tests__/editable-gap.ts | 205 ++++++++++++++++++++ src/e2e/puppeteer/__tests__/editable-gap.ts | 2 +- src/e2e/puppeteer/test-puppeteer.sh | 4 +- 4 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 src/e2e/iOS/__tests__/editable-gap.ts diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index 2dba1c7a161..95d4586be99 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -525,6 +525,104 @@ const Editable = ({ [value, setCursorOnThought], ) + const handleCursorPosition = useCallback( + (clientX: number, clientY: number, preventDefault: () => void) => { + const doc = document as Document + + // Get the browser-calculated range for the click position + let range: Range | null = null + + const pos = doc.caretPositionFromPoint(clientX, clientY) + if (pos?.offsetNode) { + range = document.createRange() + range.setStart(pos.offsetNode, pos.offset) + range.collapse(true) + } + + // Ensure click is within our editable element + if (!range || !contentRef.current?.contains(range.startContainer)) { + preventDefault() + return true + } + + const node = range.startContainer + + // Only validate text nodes - allow other node types to proceed normally + if (node.nodeType !== Node.TEXT_NODE) { + // Prevent clicks on the editable container itself (empty space) + if (node === contentRef.current) { + preventDefault() + return true + } + return false + } + + const offset = range.startOffset + const len = node.textContent?.length || 0 + + // Cache for character rectangles to avoid redundant calculations + const rectCache = new Map() + + /** Gets the bounding client rectangle for a character at the given offset. */ + const getCharRect = (targetOffset: number): DOMRect | null => { + if (rectCache.has(targetOffset)) { + return rectCache.get(targetOffset)! + } + + try { + const charRange = document.createRange() + charRange.setStart(node, targetOffset) + charRange.setEnd(node, targetOffset + 1) + const rect = charRange.getBoundingClientRect() + rectCache.set(targetOffset, rect) + return rect + } catch (e) { + rectCache.set(targetOffset, null) + return null + } + } + + /** Check if click is within a character's bounding box. */ + const isInsideCharRect = (rect: DOMRect | null): boolean => { + if (!rect) return false + return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom + } + + /** Check if click is vertically aligned with a character (for edge cases). */ + const isVerticallyAligned = (rect: DOMRect | null): boolean => { + if (!rect) return false + return clientY >= rect.top && clientY <= rect.bottom + } + + // 1. Check if clicking directly on a character + // Try character at current offset and the one before it (for right-half clicks) + const offsetsToCheck = [offset, offset - 1].filter(o => o >= 0 && o < len) + + for (const checkOffset of offsetsToCheck) { + const rect = getCharRect(checkOffset) + if (isInsideCharRect(rect)) { + return false // Valid click, allow default behavior + } + } + + // 2. Handle edge cases: clicks before first or after last character + // Allow clicks horizontally beyond text if vertically aligned with text line + if (offset === 0 || offset === len) { + const edgeOffset = offset === 0 ? 0 : len - 1 + const rect = getCharRect(edgeOffset) + + if (isVerticallyAligned(rect)) { + return false // Valid edge click, allow default + } + } + + // 3. Invalid click (padding/void area) - prevent default + preventDefault() + return true + }, + [contentRef], + ) + const onMouseDown = useCallback( (e: React.MouseEvent) => { // If CMD/CTRL is pressed, don't focus the editable. @@ -545,7 +643,13 @@ const Editable = ({ bottomMargin: fontSize * 2, }) - allowDefaultSelection() + // Attempt to handle Safari cursor positioning + // If not handled (or not Safari), fall back to default behavior + const handled = handleCursorPosition(e.clientX, e.clientY, () => e.preventDefault()) + + if (!handled) { + allowDefaultSelection() + } } // There are areas on the outside edge of the thought that will fail to trigger onTouchEnd. // In those cases, it is best to prevent onFocus or onClick, otherwise keyboard is open will be incorrectly activated. @@ -555,9 +659,29 @@ const Editable = ({ e.preventDefault() } }, - [contentRef, editingOrOnCursor, fontSize, allowDefaultSelection, hasMulticursor], + [contentRef, editingOrOnCursor, fontSize, allowDefaultSelection, hasMulticursor, handleCursorPosition], ) + // Manually attach touchstart listener with { passive: false } to allow preventDefault + useEffect(() => { + const editable = contentRef.current + if (!editable) return + + /** Handle iOS touch events to prevent initial cursor jump. */ + const handleTouchStart = (e: TouchEvent) => { + if (editingOrOnCursor && !hasMulticursor && e.touches.length > 0) { + handleCursorPosition(e.touches[0].clientX, e.touches[0].clientY, () => e.preventDefault()) + } + } + + // Add event listener with { passive: false } to allow preventDefault + editable.addEventListener('touchstart', handleTouchStart, { passive: false }) + + return () => { + editable.removeEventListener('touchstart', handleTouchStart) + } + }, [contentRef, editingOrOnCursor, hasMulticursor, handleCursorPosition]) + /** Sets the cursor on the thought on touchend or click. Handles hidden elements, drags, and editing mode. */ const onTap = useCallback( (e: React.MouseEvent | React.TouchEvent) => { @@ -654,7 +778,7 @@ const Editable = ({ // iOS Safari delays event handling in case the DOM is modified during setTimeout inside an event handler, // unless it is given a hint that the element is some sort of form control role='button' - style={style} + style={{ ...style, backgroundColor: 'rgba(255, 0, 0, 0.3)' }} /> ) } diff --git a/src/e2e/iOS/__tests__/editable-gap.ts b/src/e2e/iOS/__tests__/editable-gap.ts new file mode 100644 index 00000000000..9c9261f6545 --- /dev/null +++ b/src/e2e/iOS/__tests__/editable-gap.ts @@ -0,0 +1,205 @@ +/** + * @jest-environment ./src/e2e/webdriverio-environment.js + */ +import { token } from '../../../../styled-system/tokens' +import { DEFAULT_FONT_SIZE } from '../../../constants' +import helpers from '../helpers' + +const { + clickThought, + getEditable, + getEditingText, + getElementRectByScreen, + getSelection, + paste, + tap, + waitForEditable, + waitUntil, +} = helpers() + +// Calculate the clip height from the PandaCSS token to ensure we stay in sync with editable.ts and convert it to pixels +const CLIP_HEIGHT = parseFloat(token('spacing.editableClipBottom')) * DEFAULT_FONT_SIZE + +vi.setConfig({ testTimeout: 30000, hookTimeout: 30000 }) + +/** Helper function to check if caret is positioned in the middle of a thought (not at beginning or end). */ +async function isCaretInMiddle() { + const selection = getSelection() + const focusOffset = await selection.focusOffset + const textContent = await selection.focusNode?.textContent + + if (!textContent || focusOffset === undefined) return false + + // Check if caret is not at the very beginning (0) or end (textContent.length) + // This guards against padding clicks that set caret to edges + return focusOffset > 0 && focusOffset < textContent.length +} + +/** Helper function to check if selection is lost (dead zone detection). */ +const isSelectionLost = async () => { + const focusNode = await getSelection().focusNode + return !focusNode +} + +/** + * Helper function to test clicking/tapping between two thoughts with proper overlap handling. + * Validates that clicking in overlapping areas behaves correctly and tests for dead zones. + * Tests caret positioning to ensure clicks don't set caret to beginning/end of thoughts. + */ +async function testTapBetweenThoughts(thought1: string, thought2: string) { + const el1 = await getEditable(thought1) + const el2 = await getEditable(thought2) + + const rect1 = await getElementRectByScreen(el1) + const rect2 = await getElementRectByScreen(el2) + + if (!rect1 || !rect2) { + throw new Error(`Could not get bounding boxes for "${thought1}" and "${thought2}"`) + } + + // Calculate overlap (expected to be negative due to intentional overlap) + // Account for clipHeight which clips from the bottom of the first thought + const firstThoughtBottom = rect1.y + rect1.height - CLIP_HEIGHT + const secondThoughtTop = rect2.y + + // Note: negative = overlap + const overlapHeight = secondThoughtTop - firstThoughtBottom + + // tap at the middle of the thought horizontally + const tapX = rect1.x + rect1.width / 2 + + // Test 1: Tap in top edge of the overlap zone (should be in first thought) + const tapY1 = firstThoughtBottom + overlapHeight + + await tap(el1, { x: tapX - rect1.x, y: tapY1 - rect1.y }) + await waitUntil(async () => (await getEditingText()) !== undefined) + + expect(await isSelectionLost()).toBe(false) + expect(await getEditingText()).toBe(thought1) + expect(await isCaretInMiddle()).toBe(true) + + // Test 2: Tap just below the overlap zone (should be in second thought) + const tapY2 = secondThoughtTop - overlapHeight + 1 + + await tap(el2, { x: tapX - rect2.x, y: tapY2 - rect2.y }) + await waitUntil(async () => (await getEditingText()) === thought2) + + expect(await isSelectionLost()).toBe(false) + expect(await getEditingText()).toBe(thought2) + expect(await isCaretInMiddle()).toBe(true) + + // Test 3: Tap in the overlap zone (should work on one of the thoughts, not create dead zone) + const tapY3 = firstThoughtBottom + overlapHeight / 2 + + // Tap using el1's coordinate system since we're closer to it + await tap(el1, { x: tapX - rect1.x, y: tapY3 - rect1.y }) + await waitUntil(async () => (await getEditingText()) !== undefined) + + const cursorThought3 = await getEditingText() + + // Selection should not be lost (no dead zone) + expect(await isSelectionLost()).toBe(false) + + // Should be on one of the adjacent thoughts + expect([thought1, thought2]).toContain(cursorThought3) + + if (cursorThought3) { + expect(await isCaretInMiddle()).toBe(true) + } +} + +describe('Dead zone detection (iOS)', () => { + it('tapping between consecutive single-line thoughts should handle overlap correctly', async () => { + const importText = ` + - apples + - banana + - orange + ` + await paste(importText) + + await waitForEditable('apples') + await clickThought('apples') + + await testTapBetweenThoughts('apples', 'banana') + }) + + it('tapping between thoughts with padding should not jump cursor to end', async () => { + const importText = ` + - first + - second + - third + ` + await paste(importText) + + await waitForEditable('first') + await clickThought('first') + + const el = await getEditable('first') + const rect = await getElementRectByScreen(el) + + // Tap in the padding area below the text (should preventDefault and not jump to end) + const belowTextY = rect.y + rect.height - 5 // Just above bottom edge + const middleX = rect.x + rect.width / 2 + + await tap(el, { x: middleX - rect.x, y: belowTextY - rect.y }) + + // Wait a bit for any cursor changes + await waitUntil(async () => (await getEditingText()) !== undefined) + + // The cursor should either stay on the same thought or not move to the end + const editingText = await getEditingText() + + // If we're still on 'first', check that caret didn't jump to the end + if (editingText === 'first') { + const selection = getSelection() + const focusOffset = await selection.focusOffset + const textContent = await selection.focusNode?.textContent + + // If there's a selection, it shouldn't be at the very end (which would indicate a jump) + if (focusOffset !== undefined && textContent) { + console.warn(`Caret position: ${focusOffset}/${textContent.length}`) + // We allow it to be at the end only if the click was intentionally there + // This test is mainly to ensure preventDefault is working + } + } + + expect(await isSelectionLost()).toBe(false) + }) + + it('tapping above text should not jump cursor to beginning', async () => { + const importText = ` + - hello + - world + ` + await paste(importText) + + await waitForEditable('hello') + await clickThought('hello') + + const el = await getEditable('hello') + const rect = await getElementRectByScreen(el) + + // Tap in the padding area above the text + const aboveTextY = rect.y + 2 // Just below top edge + const middleX = rect.x + rect.width / 2 + + await tap(el, { x: middleX - rect.x, y: aboveTextY - rect.y }) + + // Wait a bit for any cursor changes + await waitUntil(async () => (await getEditingText()) !== undefined) + + // The cursor should not have jumped to the beginning + const editingText = await getEditingText() + + if (editingText === 'hello') { + const selection = getSelection() + const focusOffset = await selection.focusOffset + + if (focusOffset !== undefined) { + console.warn(`Caret position after top padding click: ${focusOffset}`) + } + } + + expect(await isSelectionLost()).toBe(false) + }) +}) diff --git a/src/e2e/puppeteer/__tests__/editable-gap.ts b/src/e2e/puppeteer/__tests__/editable-gap.ts index 47db97a129a..87ca3e45680 100644 --- a/src/e2e/puppeteer/__tests__/editable-gap.ts +++ b/src/e2e/puppeteer/__tests__/editable-gap.ts @@ -67,7 +67,7 @@ async function testClickBetweenThoughts(thought1: string, thought2: string) { expect(await isCaretInMiddle()).toBe(true) // Test 2: Click just below the overlap zone (should be in second thought) - await page.mouse.click(clickX, secondThoughtTop - overlapHeight + 1) + await page.mouse.click(clickX, secondThoughtTop - overlapHeight + 9) expect(await isSelectionLost()).toBe(false) expect(await getEditingText()).toBe(thought2) diff --git a/src/e2e/puppeteer/test-puppeteer.sh b/src/e2e/puppeteer/test-puppeteer.sh index 0d8c422018b..5f95c3ac73c 100755 --- a/src/e2e/puppeteer/test-puppeteer.sh +++ b/src/e2e/puppeteer/test-puppeteer.sh @@ -20,8 +20,8 @@ stop_dev_server() { } cleanup() { - stop_dev_server - stop_docker_container + stop_dev_server || true + stop_docker_container || true } # Set up trap to call cleanup function on script exit or if the program crashes From 7366a66cd3608f9de42dd20284d690df4357e9a0 Mon Sep 17 00:00:00 2001 From: karunkop Date: Wed, 26 Nov 2025 12:53:37 +0545 Subject: [PATCH 02/12] fallback to caretRangeFromPoint if doc.caretPositionFromPoint is not available --- src/components/Editable.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index 95d4586be99..bee1c884528 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -531,12 +531,15 @@ const Editable = ({ // Get the browser-calculated range for the click position let range: Range | null = null - - const pos = doc.caretPositionFromPoint(clientX, clientY) - if (pos?.offsetNode) { - range = document.createRange() - range.setStart(pos.offsetNode, pos.offset) - range.collapse(true) + if (doc.caretRangeFromPoint) { + range = doc.caretRangeFromPoint(clientX, clientY) + } else if (doc.caretPositionFromPoint) { + const pos = doc.caretPositionFromPoint(clientX, clientY) + if (pos?.offsetNode) { + range = document.createRange() + range.setStart(pos.offsetNode, pos.offset) + range.collapse(true) + } } // Ensure click is within our editable element From 0e36cdcb04eba4227f9aad9b347a13bb4a831af7 Mon Sep 17 00:00:00 2001 From: karunkop Date: Fri, 28 Nov 2025 22:07:16 +0545 Subject: [PATCH 03/12] manual attachment of touchstart listener --- src/components/Editable.tsx | 95 +++++++++++++------------------------ 1 file changed, 33 insertions(+), 62 deletions(-) diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index bee1c884528..fdb3887dc2d 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -525,11 +525,12 @@ const Editable = ({ [value, setCursorOnThought], ) - const handleCursorPosition = useCallback( - (clientX: number, clientY: number, preventDefault: () => void) => { + /** Determines if the default caret behavior should be prevented. */ + const shouldPreventDefaultCaretBehaviour = useCallback( + (clientX: number, clientY: number, preventDefault: () => void): boolean => { const doc = document as Document - // Get the browser-calculated range for the click position + // Get the browser range for the click position let range: Range | null = null if (doc.caretRangeFromPoint) { range = doc.caretRangeFromPoint(clientX, clientY) @@ -549,40 +550,16 @@ const Editable = ({ } const node = range.startContainer - - // Only validate text nodes - allow other node types to proceed normally - if (node.nodeType !== Node.TEXT_NODE) { - // Prevent clicks on the editable container itself (empty space) - if (node === contentRef.current) { - preventDefault() - return true - } - return false - } - const offset = range.startOffset - const len = node.textContent?.length || 0 + const nodeTextLength = node.textContent?.length || 0 - // Cache for character rectangles to avoid redundant calculations - const rectCache = new Map() - - /** Gets the bounding client rectangle for a character at the given offset. */ + /** Gets the bounding client rect for a character at the given offset. */ const getCharRect = (targetOffset: number): DOMRect | null => { - if (rectCache.has(targetOffset)) { - return rectCache.get(targetOffset)! - } - - try { - const charRange = document.createRange() - charRange.setStart(node, targetOffset) - charRange.setEnd(node, targetOffset + 1) - const rect = charRange.getBoundingClientRect() - rectCache.set(targetOffset, rect) - return rect - } catch (e) { - rectCache.set(targetOffset, null) - return null - } + const charRange = document.createRange() + charRange.setStart(node, targetOffset) + charRange.setEnd(node, targetOffset + 1) + const rect = charRange.getBoundingClientRect() + return rect } /** Check if click is within a character's bounding box. */ @@ -597,29 +574,20 @@ const Editable = ({ return clientY >= rect.top && clientY <= rect.bottom } - // 1. Check if clicking directly on a character + // Check if clicking directly on a character // Try character at current offset and the one before it (for right-half clicks) - const offsetsToCheck = [offset, offset - 1].filter(o => o >= 0 && o < len) - - for (const checkOffset of offsetsToCheck) { - const rect = getCharRect(checkOffset) - if (isInsideCharRect(rect)) { - return false // Valid click, allow default behavior - } - } + const isClickOnCharacter = [offset, offset - 1] + .filter(o => o >= 0 && o < nodeTextLength) + .some(checkOffset => isInsideCharRect(getCharRect(checkOffset))) - // 2. Handle edge cases: clicks before first or after last character // Allow clicks horizontally beyond text if vertically aligned with text line - if (offset === 0 || offset === len) { - const edgeOffset = offset === 0 ? 0 : len - 1 - const rect = getCharRect(edgeOffset) + const isValidEdgeClick = + (offset === 0 || offset === nodeTextLength) && + isVerticallyAligned(getCharRect(offset === 0 ? 0 : nodeTextLength - 1)) - if (isVerticallyAligned(rect)) { - return false // Valid edge click, allow default - } - } + if (isClickOnCharacter || isValidEdgeClick) return false - // 3. Invalid click (padding/void area) - prevent default + // Invalid click (padding/void area), prevent default preventDefault() return true }, @@ -646,11 +614,7 @@ const Editable = ({ bottomMargin: fontSize * 2, }) - // Attempt to handle Safari cursor positioning - // If not handled (or not Safari), fall back to default behavior - const handled = handleCursorPosition(e.clientX, e.clientY, () => e.preventDefault()) - - if (!handled) { + if (!shouldPreventDefaultCaretBehaviour(e.clientX, e.clientY, () => e.preventDefault())) { allowDefaultSelection() } } @@ -662,7 +626,14 @@ const Editable = ({ e.preventDefault() } }, - [contentRef, editingOrOnCursor, fontSize, allowDefaultSelection, hasMulticursor, handleCursorPosition], + [ + contentRef, + editingOrOnCursor, + fontSize, + allowDefaultSelection, + hasMulticursor, + shouldPreventDefaultCaretBehaviour, + ], ) // Manually attach touchstart listener with { passive: false } to allow preventDefault @@ -670,10 +641,10 @@ const Editable = ({ const editable = contentRef.current if (!editable) return - /** Handle iOS touch events to prevent initial cursor jump. */ + /** Handle touch events to prevent initial cursor jump. */ const handleTouchStart = (e: TouchEvent) => { if (editingOrOnCursor && !hasMulticursor && e.touches.length > 0) { - handleCursorPosition(e.touches[0].clientX, e.touches[0].clientY, () => e.preventDefault()) + shouldPreventDefaultCaretBehaviour(e.touches[0].clientX, e.touches[0].clientY, () => e.preventDefault()) } } @@ -683,7 +654,7 @@ const Editable = ({ return () => { editable.removeEventListener('touchstart', handleTouchStart) } - }, [contentRef, editingOrOnCursor, hasMulticursor, handleCursorPosition]) + }, [contentRef, editingOrOnCursor, hasMulticursor, shouldPreventDefaultCaretBehaviour]) /** Sets the cursor on the thought on touchend or click. Handles hidden elements, drags, and editing mode. */ const onTap = useCallback( @@ -781,7 +752,7 @@ const Editable = ({ // iOS Safari delays event handling in case the DOM is modified during setTimeout inside an event handler, // unless it is given a hint that the element is some sort of form control role='button' - style={{ ...style, backgroundColor: 'rgba(255, 0, 0, 0.3)' }} + style={style} /> ) } From 895fb8ebaacd72c4cb599e0cf6c2b79c8c1b41e0 Mon Sep 17 00:00:00 2001 From: karunkop Date: Fri, 28 Nov 2025 22:11:47 +0545 Subject: [PATCH 04/12] removed the clonned ios test for editable gap --- src/e2e/iOS/__tests__/editable-gap.ts | 205 -------------------------- src/e2e/puppeteer/test-puppeteer.sh | 4 +- 2 files changed, 2 insertions(+), 207 deletions(-) delete mode 100644 src/e2e/iOS/__tests__/editable-gap.ts diff --git a/src/e2e/iOS/__tests__/editable-gap.ts b/src/e2e/iOS/__tests__/editable-gap.ts deleted file mode 100644 index 9c9261f6545..00000000000 --- a/src/e2e/iOS/__tests__/editable-gap.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * @jest-environment ./src/e2e/webdriverio-environment.js - */ -import { token } from '../../../../styled-system/tokens' -import { DEFAULT_FONT_SIZE } from '../../../constants' -import helpers from '../helpers' - -const { - clickThought, - getEditable, - getEditingText, - getElementRectByScreen, - getSelection, - paste, - tap, - waitForEditable, - waitUntil, -} = helpers() - -// Calculate the clip height from the PandaCSS token to ensure we stay in sync with editable.ts and convert it to pixels -const CLIP_HEIGHT = parseFloat(token('spacing.editableClipBottom')) * DEFAULT_FONT_SIZE - -vi.setConfig({ testTimeout: 30000, hookTimeout: 30000 }) - -/** Helper function to check if caret is positioned in the middle of a thought (not at beginning or end). */ -async function isCaretInMiddle() { - const selection = getSelection() - const focusOffset = await selection.focusOffset - const textContent = await selection.focusNode?.textContent - - if (!textContent || focusOffset === undefined) return false - - // Check if caret is not at the very beginning (0) or end (textContent.length) - // This guards against padding clicks that set caret to edges - return focusOffset > 0 && focusOffset < textContent.length -} - -/** Helper function to check if selection is lost (dead zone detection). */ -const isSelectionLost = async () => { - const focusNode = await getSelection().focusNode - return !focusNode -} - -/** - * Helper function to test clicking/tapping between two thoughts with proper overlap handling. - * Validates that clicking in overlapping areas behaves correctly and tests for dead zones. - * Tests caret positioning to ensure clicks don't set caret to beginning/end of thoughts. - */ -async function testTapBetweenThoughts(thought1: string, thought2: string) { - const el1 = await getEditable(thought1) - const el2 = await getEditable(thought2) - - const rect1 = await getElementRectByScreen(el1) - const rect2 = await getElementRectByScreen(el2) - - if (!rect1 || !rect2) { - throw new Error(`Could not get bounding boxes for "${thought1}" and "${thought2}"`) - } - - // Calculate overlap (expected to be negative due to intentional overlap) - // Account for clipHeight which clips from the bottom of the first thought - const firstThoughtBottom = rect1.y + rect1.height - CLIP_HEIGHT - const secondThoughtTop = rect2.y - - // Note: negative = overlap - const overlapHeight = secondThoughtTop - firstThoughtBottom - - // tap at the middle of the thought horizontally - const tapX = rect1.x + rect1.width / 2 - - // Test 1: Tap in top edge of the overlap zone (should be in first thought) - const tapY1 = firstThoughtBottom + overlapHeight - - await tap(el1, { x: tapX - rect1.x, y: tapY1 - rect1.y }) - await waitUntil(async () => (await getEditingText()) !== undefined) - - expect(await isSelectionLost()).toBe(false) - expect(await getEditingText()).toBe(thought1) - expect(await isCaretInMiddle()).toBe(true) - - // Test 2: Tap just below the overlap zone (should be in second thought) - const tapY2 = secondThoughtTop - overlapHeight + 1 - - await tap(el2, { x: tapX - rect2.x, y: tapY2 - rect2.y }) - await waitUntil(async () => (await getEditingText()) === thought2) - - expect(await isSelectionLost()).toBe(false) - expect(await getEditingText()).toBe(thought2) - expect(await isCaretInMiddle()).toBe(true) - - // Test 3: Tap in the overlap zone (should work on one of the thoughts, not create dead zone) - const tapY3 = firstThoughtBottom + overlapHeight / 2 - - // Tap using el1's coordinate system since we're closer to it - await tap(el1, { x: tapX - rect1.x, y: tapY3 - rect1.y }) - await waitUntil(async () => (await getEditingText()) !== undefined) - - const cursorThought3 = await getEditingText() - - // Selection should not be lost (no dead zone) - expect(await isSelectionLost()).toBe(false) - - // Should be on one of the adjacent thoughts - expect([thought1, thought2]).toContain(cursorThought3) - - if (cursorThought3) { - expect(await isCaretInMiddle()).toBe(true) - } -} - -describe('Dead zone detection (iOS)', () => { - it('tapping between consecutive single-line thoughts should handle overlap correctly', async () => { - const importText = ` - - apples - - banana - - orange - ` - await paste(importText) - - await waitForEditable('apples') - await clickThought('apples') - - await testTapBetweenThoughts('apples', 'banana') - }) - - it('tapping between thoughts with padding should not jump cursor to end', async () => { - const importText = ` - - first - - second - - third - ` - await paste(importText) - - await waitForEditable('first') - await clickThought('first') - - const el = await getEditable('first') - const rect = await getElementRectByScreen(el) - - // Tap in the padding area below the text (should preventDefault and not jump to end) - const belowTextY = rect.y + rect.height - 5 // Just above bottom edge - const middleX = rect.x + rect.width / 2 - - await tap(el, { x: middleX - rect.x, y: belowTextY - rect.y }) - - // Wait a bit for any cursor changes - await waitUntil(async () => (await getEditingText()) !== undefined) - - // The cursor should either stay on the same thought or not move to the end - const editingText = await getEditingText() - - // If we're still on 'first', check that caret didn't jump to the end - if (editingText === 'first') { - const selection = getSelection() - const focusOffset = await selection.focusOffset - const textContent = await selection.focusNode?.textContent - - // If there's a selection, it shouldn't be at the very end (which would indicate a jump) - if (focusOffset !== undefined && textContent) { - console.warn(`Caret position: ${focusOffset}/${textContent.length}`) - // We allow it to be at the end only if the click was intentionally there - // This test is mainly to ensure preventDefault is working - } - } - - expect(await isSelectionLost()).toBe(false) - }) - - it('tapping above text should not jump cursor to beginning', async () => { - const importText = ` - - hello - - world - ` - await paste(importText) - - await waitForEditable('hello') - await clickThought('hello') - - const el = await getEditable('hello') - const rect = await getElementRectByScreen(el) - - // Tap in the padding area above the text - const aboveTextY = rect.y + 2 // Just below top edge - const middleX = rect.x + rect.width / 2 - - await tap(el, { x: middleX - rect.x, y: aboveTextY - rect.y }) - - // Wait a bit for any cursor changes - await waitUntil(async () => (await getEditingText()) !== undefined) - - // The cursor should not have jumped to the beginning - const editingText = await getEditingText() - - if (editingText === 'hello') { - const selection = getSelection() - const focusOffset = await selection.focusOffset - - if (focusOffset !== undefined) { - console.warn(`Caret position after top padding click: ${focusOffset}`) - } - } - - expect(await isSelectionLost()).toBe(false) - }) -}) diff --git a/src/e2e/puppeteer/test-puppeteer.sh b/src/e2e/puppeteer/test-puppeteer.sh index 5f95c3ac73c..6080249744a 100755 --- a/src/e2e/puppeteer/test-puppeteer.sh +++ b/src/e2e/puppeteer/test-puppeteer.sh @@ -20,8 +20,8 @@ stop_dev_server() { } cleanup() { - stop_dev_server || true - stop_docker_container || true + stop_dev_server + stop_docker_container } # Set up trap to call cleanup function on script exit or if the program crashes From ee423c0dc4b67bbebeed9e2a0f2e731bca37a15e Mon Sep 17 00:00:00 2001 From: karunkop Date: Mon, 1 Dec 2025 10:49:59 +0545 Subject: [PATCH 05/12] early return when document caret APIs are not available in test env --- src/components/Editable.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index fdb3887dc2d..bb1eeca6e31 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -530,6 +530,12 @@ const Editable = ({ (clientX: number, clientY: number, preventDefault: () => void): boolean => { const doc = document as Document + // These APIs are not available in test environments (JSDOM) + // In that case, allow default behavior + if (!doc.caretRangeFromPoint && !doc.caretPositionFromPoint) { + return false + } + // Get the browser range for the click position let range: Range | null = null if (doc.caretRangeFromPoint) { From 64ab2bed833aae708d8279f5fab931f6c74ba8c5 Mon Sep 17 00:00:00 2001 From: karunkop Date: Mon, 15 Dec 2025 22:06:03 +0545 Subject: [PATCH 06/12] added logic for manual placement of caret by calculating nearest node from click coordinates --- panda.config.ts | 1 - src/components/Editable.tsx | 252 +++++++++++++++++--- src/e2e/puppeteer/__tests__/editable-gap.ts | 13 +- src/hooks/useSizeTracking.ts | 2 - src/recipes/editable.ts | 2 - 5 files changed, 221 insertions(+), 49 deletions(-) diff --git a/panda.config.ts b/panda.config.ts index 6b08dcc4999..25c8b68fe29 100644 --- a/panda.config.ts +++ b/panda.config.ts @@ -425,7 +425,6 @@ export default defineConfig({ modalPadding: { value: '8%' }, safeAreaTop: { value: 'env(safe-area-inset-top)' }, safeAreaBottom: { value: 'env(safe-area-inset-bottom)' }, - editableClipBottom: { value: '0.1em' }, }, /* z-index schedule Keep these in one place to make it easier to determine interactions and prevent conflicts. */ diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index bb1eeca6e31..e09d7d6100b 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -525,19 +525,168 @@ const Editable = ({ [value, setCursorOnThought], ) - /** Determines if the default caret behavior should be prevented. */ - const shouldPreventDefaultCaretBehaviour = useCallback( - (clientX: number, clientY: number, preventDefault: () => void): boolean => { + /** + * Safari does glyph-only hit testing, so tapping/clicking on empty space has no caret target. + * Hence, get all text nodes that can receive a caret. + */ + const getTextNodes = useCallback((root: HTMLElement): Text[] => { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + return node.nodeValue?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT + }, + }) + const nodes: Text[] = [] + let n: Text | null + while ((n = walker.nextNode() as Text | null)) nodes.push(n) + return nodes + }, []) + + /** Find the nearest text node by Y distance. */ + const findNearestTextNode = useCallback( + (textNodes: Text[], clientY: number): { node: Text; rect: DOMRect } | null => + textNodes.reduce<{ node: Text; rect: DOMRect } | null>((closest, node) => { + const range = document.createRange() + range.selectNodeContents(node) + const rect = range.getBoundingClientRect() + + const dist = clientY < rect.top ? rect.top - clientY : clientY > rect.bottom ? clientY - rect.bottom : 0 + + if (!closest) return { node, rect } + + const closestDist = + clientY < closest.rect.top + ? closest.rect.top - clientY + : clientY > closest.rect.bottom + ? clientY - closest.rect.bottom + : 0 + + return dist < closestDist ? { node, rect } : closest + }, null), + [], + ) + + /** Get all the lines for the given text node. */ + const getTextNodeLines = useCallback((node: Text): { start: number; end: number; rect: DOMRect }[] => { + const text = node.nodeValue ?? '' + if (!text) return [] + + const range = document.createRange() + let lastRect: DOMRect | null = null + + return Array.from(text, (_, i) => { + range.setStart(node, i) + range.setEnd(node, i + 1) + const rect = range.getBoundingClientRect() + return rect.height ? { i, rect } : null + }) + .filter((item): item is { i: number; rect: DOMRect } => item !== null) + .reduce<{ start: number; end: number; rect: DOMRect }[]>((lines, { i, rect }) => { + if (!lastRect || Math.abs(lastRect.top - rect.top) > rect.height / 2) { + lines.push({ start: i, end: i + 1, rect }) + } else { + lines[lines.length - 1].end = i + 1 + } + lastRect = rect + return lines + }, []) + }, []) + + /** Binary search for the offset of the tap/click within the given text node. */ + const binarySearchOffset = useCallback((node: Text, clientX: number, lo: number, hi: number): number => { + while (lo < hi) { + const mid = (lo + hi) >> 1 + + const r = document.createRange() + r.setStart(node, mid) + r.setEnd(node, Math.min(mid + 1, hi)) + + const rect = r.getBoundingClientRect() + + if (clientX < rect.left) { + hi = mid + } else if (clientX > rect.right) { + lo = mid + 1 + } else { + const center = rect.left + rect.width / 2 + return clientX < center ? mid : mid + 1 + } + } + + return lo + }, []) + + /** Resolve horizontal offset inside the node using binary search. */ + const offsetFromX = useCallback( + (node: Text, clientX: number, clientY: number): number => { + const text = node.nodeValue ?? '' + if (!text) return 0 + + const lines = getTextNodeLines(node) + + // If the text node is a single line, use binary search to find the offset + if (lines.length <= 1) { + return binarySearchOffset(node, clientX, 0, text.length) + } + + // If the text node is a multi-line, pick the closest line with respect to the Y coordinate and then do binary search + const target = lines.reduce<{ line: (typeof lines)[0]; dist: number }>( + (closest, line) => { + const dist = + clientY < line.rect.top + ? line.rect.top - clientY + : clientY > line.rect.bottom + ? clientY - line.rect.bottom + : 0 + return dist < closest.dist ? { line, dist } : closest + }, + { line: lines[0], dist: Infinity }, + ).line + + // Binary search only inside that line + return binarySearchOffset(node, clientX, target.start, target.end) + }, + [getTextNodeLines, binarySearchOffset], + ) + + /** Set the caret at a specific node and offset. */ + const setCaretAtNode = useCallback((node: Text, offset: number) => { + selection.set(node, { offset: Math.min(offset, node.length) }) + }, []) + + /** Calculate cumulative offset from start of editable to the given text node position. */ + const getCumulativeOffset = useCallback( + (editable: HTMLElement, targetNode: Text, nodeOffset: number): number => { + const textNodes = getTextNodes(editable) + const targetIndex = textNodes.findIndex(node => node === targetNode) + const precedingLength = textNodes + .slice(0, targetIndex === -1 ? textNodes.length : targetIndex) + .reduce((acc, node) => acc + (node.nodeValue?.length || 0), 0) + return precedingLength + nodeOffset + }, + [getTextNodes], + ) + + /** + * Detects if a tap is in a void area and returns caret position info. + * Returns null if tap is on a valid character, or caret position info if it's a void area tap. + */ + const detectVoidAreaTap = useCallback( + ( + editable: HTMLElement, + clientX: number, + clientY: number, + ): { node: Text | null; nodeOffset: number; cumulativeOffset: number } | null => { const doc = document as Document // These APIs are not available in test environments (JSDOM) // In that case, allow default behavior if (!doc.caretRangeFromPoint && !doc.caretPositionFromPoint) { - return false + return null } - // Get the browser range for the click position + // Get the browser range for the tap position let range: Range | null = null + if (doc.caretRangeFromPoint) { range = doc.caretRangeFromPoint(clientX, clientY) } else if (doc.caretPositionFromPoint) { @@ -549,55 +698,93 @@ const Editable = ({ } } - // Ensure click is within our editable element - if (!range || !contentRef.current?.contains(range.startContainer)) { - preventDefault() - return true + // Ensure tap is within our editable element + if (!range || !editable.contains(range.startContainer)) { + return { node: null, nodeOffset: 0, cumulativeOffset: 0 } } const node = range.startContainer const offset = range.startOffset const nodeTextLength = node.textContent?.length || 0 - /** Gets the bounding client rect for a character at the given offset. */ + /** Get the bounding rectangle for a character at the given offset. */ const getCharRect = (targetOffset: number): DOMRect | null => { const charRange = document.createRange() charRange.setStart(node, targetOffset) charRange.setEnd(node, targetOffset + 1) - const rect = charRange.getBoundingClientRect() - return rect + return charRange.getBoundingClientRect() } - /** Check if click is within a character's bounding box. */ + /** Check if tap is within a character's bounding box. */ const isInsideCharRect = (rect: DOMRect | null): boolean => { if (!rect) return false return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom } - /** Check if click is vertically aligned with a character (for edge cases). */ - const isVerticallyAligned = (rect: DOMRect | null): boolean => { + /** Check if tap is vertically contained within the character's bounding box. */ + const isVerticallyContained = (rect: DOMRect | null): boolean => { if (!rect) return false return clientY >= rect.top && clientY <= rect.bottom } - // Check if clicking directly on a character - // Try character at current offset and the one before it (for right-half clicks) + // Check by tapping on a character at the current offset and the one before it. const isClickOnCharacter = [offset, offset - 1] .filter(o => o >= 0 && o < nodeTextLength) .some(checkOffset => isInsideCharRect(getCharRect(checkOffset))) - // Allow clicks horizontally beyond text if vertically aligned with text line + // Allow taps horizontally beyond text if vertically aligned with text line const isValidEdgeClick = (offset === 0 || offset === nodeTextLength) && - isVerticallyAligned(getCharRect(offset === 0 ? 0 : nodeTextLength - 1)) + isVerticallyContained(getCharRect(offset === 0 ? 0 : nodeTextLength - 1)) + + // Valid tap on character - not a void area + if (isClickOnCharacter || isValidEdgeClick) return null + + // Invalid tap (padding/void area), calculate the caret position + const textNodes = getTextNodes(editable) + if (textNodes.length === 0) return null - if (isClickOnCharacter || isValidEdgeClick) return false + const nearest = findNearestTextNode(textNodes, clientY) + if (!nearest) return null - // Invalid click (padding/void area), prevent default + const nodeOffset = offsetFromX(nearest.node, clientX, clientY) + const cumulativeOffset = getCumulativeOffset(editable, nearest.node, nodeOffset) + return { node: nearest.node, nodeOffset, cumulativeOffset } + }, + [getTextNodes, findNearestTextNode, offsetFromX, getCumulativeOffset], + ) + + /** + * Handles void area taps by preventing default and setting the caret. + * Returns true if we handled the tap (void area), false if browser should handle it (valid tap on character). + */ + const handleVoidAreaTap = useCallback( + (clientX: number, clientY: number, preventDefault: () => void): boolean => { + const editable = contentRef.current + if (!editable) return false + + const voidAreaInfo = detectVoidAreaTap(editable, clientX, clientY) + if (!voidAreaInfo) return false + + // Void area tap detected - perform side effects preventDefault() + + if (voidAreaInfo.node) { + // Update Redux cursor state + dispatch( + setCursor({ + path, + offset: voidAreaInfo.cumulativeOffset, + }), + ) + + // Set caret manually + setCaretAtNode(voidAreaInfo.node, voidAreaInfo.nodeOffset) + } + return true }, - [contentRef], + [contentRef, detectVoidAreaTap, dispatch, path, setCaretAtNode], ) const onMouseDown = useCallback( @@ -620,7 +807,8 @@ const Editable = ({ bottomMargin: fontSize * 2, }) - if (!shouldPreventDefaultCaretBehaviour(e.clientX, e.clientY, () => e.preventDefault())) { + // If not a void area tap, allow browser's default selection + if (!handleVoidAreaTap(e.clientX, e.clientY, () => e.preventDefault())) { allowDefaultSelection() } } @@ -632,14 +820,7 @@ const Editable = ({ e.preventDefault() } }, - [ - contentRef, - editingOrOnCursor, - fontSize, - allowDefaultSelection, - hasMulticursor, - shouldPreventDefaultCaretBehaviour, - ], + [contentRef, editingOrOnCursor, fontSize, allowDefaultSelection, hasMulticursor, handleVoidAreaTap], ) // Manually attach touchstart listener with { passive: false } to allow preventDefault @@ -647,10 +828,13 @@ const Editable = ({ const editable = contentRef.current if (!editable) return - /** Handle touch events to prevent initial cursor jump. */ + /** Handle touch events to prevent initial cursor jump (Safari-safe). */ const handleTouchStart = (e: TouchEvent) => { if (editingOrOnCursor && !hasMulticursor && e.touches.length > 0) { - shouldPreventDefaultCaretBehaviour(e.touches[0].clientX, e.touches[0].clientY, () => e.preventDefault()) + // If not a void area tap, allow browser's default selection + if (!handleVoidAreaTap(e.touches[0].clientX, e.touches[0].clientY, () => e.preventDefault())) { + allowDefaultSelection() + } } } @@ -660,7 +844,7 @@ const Editable = ({ return () => { editable.removeEventListener('touchstart', handleTouchStart) } - }, [contentRef, editingOrOnCursor, hasMulticursor, shouldPreventDefaultCaretBehaviour]) + }, [contentRef, editingOrOnCursor, hasMulticursor, allowDefaultSelection, handleVoidAreaTap]) /** Sets the cursor on the thought on touchend or click. Handles hidden elements, drags, and editing mode. */ const onTap = useCallback( diff --git a/src/e2e/puppeteer/__tests__/editable-gap.ts b/src/e2e/puppeteer/__tests__/editable-gap.ts index 87ca3e45680..e3bbd12870f 100644 --- a/src/e2e/puppeteer/__tests__/editable-gap.ts +++ b/src/e2e/puppeteer/__tests__/editable-gap.ts @@ -1,6 +1,3 @@ -// import { ElementHandle } from 'puppeteer' -import { token } from '../../../../styled-system/tokens' -import { DEFAULT_FONT_SIZE } from '../../../constants' import clickThought from '../helpers/clickThought' import getEditable from '../helpers/getEditable' import getEditingText from '../helpers/getEditingText' @@ -8,9 +5,6 @@ import getSelection from '../helpers/getSelection' import paste from '../helpers/paste' import { page } from '../setup' -// Calculate the clip height from the PandaCSS token to ensure we stay in sync with editable.ts and convert it to pixels -const CLIP_HEIGHT = parseFloat(token('spacing.editableClipBottom')) * DEFAULT_FONT_SIZE - vi.setConfig({ testTimeout: 20000, hookTimeout: 20000 }) /** Helper function to check if caret is positioned in the middle of a thought (not at beginning or end). */ @@ -49,8 +43,7 @@ async function testClickBetweenThoughts(thought1: string, thought2: string) { } // Calculate overlap (expected to be negative due to intentional overlap) - // Account for clipHeight which clips from the bottom of the first thought - const firstThoughtBottom = rect1.y + rect1.height - CLIP_HEIGHT + const firstThoughtBottom = rect1.y + rect1.height const secondThoughtTop = rect2.y // Note: negative = overlap @@ -60,14 +53,14 @@ async function testClickBetweenThoughts(thought1: string, thought2: string) { const clickX = rect1.x + rect1.width / 2 // Test 1: Click in top edge of the overlap zone (should be in first thought) - await page.mouse.click(clickX, firstThoughtBottom + overlapHeight) + await page.mouse.click(clickX, firstThoughtBottom + overlapHeight - 1) expect(await isSelectionLost()).toBe(false) expect(await getEditingText()).toBe(thought1) expect(await isCaretInMiddle()).toBe(true) // Test 2: Click just below the overlap zone (should be in second thought) - await page.mouse.click(clickX, secondThoughtTop - overlapHeight + 9) + await page.mouse.click(clickX, secondThoughtTop - overlapHeight + 1) expect(await isSelectionLost()).toBe(false) expect(await getEditingText()).toBe(thought2) diff --git a/src/hooks/useSizeTracking.ts b/src/hooks/useSizeTracking.ts index 4e7cfad8f5b..8685b87f66e 100644 --- a/src/hooks/useSizeTracking.ts +++ b/src/hooks/useSizeTracking.ts @@ -50,8 +50,6 @@ const useSizeTracking = () => { key: string }) => { if (height !== null) { - // To create the correct selection behavior, thoughts must be clipped on the top and bottom. Otherwise the top and bottom 1px cause the caret to move to the beginning or end of the editable. But clipPath creates a gap between thoughts. To eliminate this gap, thoughts are rendered with a slight overlap by subtracting a small amount from each thought's measured height, thus affecting their y positions as they are rendered. - // See: clipPath in recipes/editable.ts const lineHeightOverlap = fontSize / 8 const heightClipped = height - lineHeightOverlap diff --git a/src/recipes/editable.ts b/src/recipes/editable.ts index 468c800ac17..96153f8d127 100644 --- a/src/recipes/editable.ts +++ b/src/recipes/editable.ts @@ -15,8 +15,6 @@ const editableRecipe = defineRecipe({ margin: '-0.5px calc(18px - 1em) 0 calc(1em - 18px)', /* create stacking order to position above thought-annotation so that function background color does not mask thought */ position: 'relative', - /* Prevent the selection from being incorrectly set to the beginning of the thought when the top edge is clicked, and the end of the thought when the bottom edge is clicked. Instead, we want the usual behavior of the selection being placed near the click. */ - clipPath: 'inset(0.001px 0 token(spacing.editableClipBottom) 0)', wordBreak: 'break-word' /* breaks urls; backwards-compatible with regular text unlike break-all */, marginTop: { /* TODO: Safari only? */ From e654c6a9c88a6cd447053c8b79bf718a13c3bad9 Mon Sep 17 00:00:00 2001 From: karunkop Date: Tue, 16 Dec 2025 14:58:37 +0545 Subject: [PATCH 07/12] organised the utilities function with proper abstraction --- src/components/Editable.tsx | 246 ++-------------------------- src/e2e/puppeteer/test-puppeteer.sh | 2 +- src/util/caretPositioning.ts | 86 ++++++++++ src/util/textNodeUtils.ts | 90 ++++++++++ src/util/voidAreaDetection.ts | 106 ++++++++++++ 5 files changed, 293 insertions(+), 237 deletions(-) create mode 100644 src/util/caretPositioning.ts create mode 100644 src/util/textNodeUtils.ts create mode 100644 src/util/voidAreaDetection.ts diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index e09d7d6100b..e4df90e80e5 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -56,6 +56,7 @@ import isDocumentEditable from '../util/isDocumentEditable' import strip from '../util/strip' import stripEmptyFormattingTags from '../util/stripEmptyFormattingTags' import trimHtml from '../util/trimHtml' +import detectVoidAreaTap from '../util/voidAreaDetection' import ContentEditable, { ContentEditableEvent } from './ContentEditable' import useEditMode from './Editable/useEditMode' import useOnCopy from './Editable/useOnCopy' @@ -525,235 +526,6 @@ const Editable = ({ [value, setCursorOnThought], ) - /** - * Safari does glyph-only hit testing, so tapping/clicking on empty space has no caret target. - * Hence, get all text nodes that can receive a caret. - */ - const getTextNodes = useCallback((root: HTMLElement): Text[] => { - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { - acceptNode(node) { - return node.nodeValue?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT - }, - }) - const nodes: Text[] = [] - let n: Text | null - while ((n = walker.nextNode() as Text | null)) nodes.push(n) - return nodes - }, []) - - /** Find the nearest text node by Y distance. */ - const findNearestTextNode = useCallback( - (textNodes: Text[], clientY: number): { node: Text; rect: DOMRect } | null => - textNodes.reduce<{ node: Text; rect: DOMRect } | null>((closest, node) => { - const range = document.createRange() - range.selectNodeContents(node) - const rect = range.getBoundingClientRect() - - const dist = clientY < rect.top ? rect.top - clientY : clientY > rect.bottom ? clientY - rect.bottom : 0 - - if (!closest) return { node, rect } - - const closestDist = - clientY < closest.rect.top - ? closest.rect.top - clientY - : clientY > closest.rect.bottom - ? clientY - closest.rect.bottom - : 0 - - return dist < closestDist ? { node, rect } : closest - }, null), - [], - ) - - /** Get all the lines for the given text node. */ - const getTextNodeLines = useCallback((node: Text): { start: number; end: number; rect: DOMRect }[] => { - const text = node.nodeValue ?? '' - if (!text) return [] - - const range = document.createRange() - let lastRect: DOMRect | null = null - - return Array.from(text, (_, i) => { - range.setStart(node, i) - range.setEnd(node, i + 1) - const rect = range.getBoundingClientRect() - return rect.height ? { i, rect } : null - }) - .filter((item): item is { i: number; rect: DOMRect } => item !== null) - .reduce<{ start: number; end: number; rect: DOMRect }[]>((lines, { i, rect }) => { - if (!lastRect || Math.abs(lastRect.top - rect.top) > rect.height / 2) { - lines.push({ start: i, end: i + 1, rect }) - } else { - lines[lines.length - 1].end = i + 1 - } - lastRect = rect - return lines - }, []) - }, []) - - /** Binary search for the offset of the tap/click within the given text node. */ - const binarySearchOffset = useCallback((node: Text, clientX: number, lo: number, hi: number): number => { - while (lo < hi) { - const mid = (lo + hi) >> 1 - - const r = document.createRange() - r.setStart(node, mid) - r.setEnd(node, Math.min(mid + 1, hi)) - - const rect = r.getBoundingClientRect() - - if (clientX < rect.left) { - hi = mid - } else if (clientX > rect.right) { - lo = mid + 1 - } else { - const center = rect.left + rect.width / 2 - return clientX < center ? mid : mid + 1 - } - } - - return lo - }, []) - - /** Resolve horizontal offset inside the node using binary search. */ - const offsetFromX = useCallback( - (node: Text, clientX: number, clientY: number): number => { - const text = node.nodeValue ?? '' - if (!text) return 0 - - const lines = getTextNodeLines(node) - - // If the text node is a single line, use binary search to find the offset - if (lines.length <= 1) { - return binarySearchOffset(node, clientX, 0, text.length) - } - - // If the text node is a multi-line, pick the closest line with respect to the Y coordinate and then do binary search - const target = lines.reduce<{ line: (typeof lines)[0]; dist: number }>( - (closest, line) => { - const dist = - clientY < line.rect.top - ? line.rect.top - clientY - : clientY > line.rect.bottom - ? clientY - line.rect.bottom - : 0 - return dist < closest.dist ? { line, dist } : closest - }, - { line: lines[0], dist: Infinity }, - ).line - - // Binary search only inside that line - return binarySearchOffset(node, clientX, target.start, target.end) - }, - [getTextNodeLines, binarySearchOffset], - ) - - /** Set the caret at a specific node and offset. */ - const setCaretAtNode = useCallback((node: Text, offset: number) => { - selection.set(node, { offset: Math.min(offset, node.length) }) - }, []) - - /** Calculate cumulative offset from start of editable to the given text node position. */ - const getCumulativeOffset = useCallback( - (editable: HTMLElement, targetNode: Text, nodeOffset: number): number => { - const textNodes = getTextNodes(editable) - const targetIndex = textNodes.findIndex(node => node === targetNode) - const precedingLength = textNodes - .slice(0, targetIndex === -1 ? textNodes.length : targetIndex) - .reduce((acc, node) => acc + (node.nodeValue?.length || 0), 0) - return precedingLength + nodeOffset - }, - [getTextNodes], - ) - - /** - * Detects if a tap is in a void area and returns caret position info. - * Returns null if tap is on a valid character, or caret position info if it's a void area tap. - */ - const detectVoidAreaTap = useCallback( - ( - editable: HTMLElement, - clientX: number, - clientY: number, - ): { node: Text | null; nodeOffset: number; cumulativeOffset: number } | null => { - const doc = document as Document - - // These APIs are not available in test environments (JSDOM) - // In that case, allow default behavior - if (!doc.caretRangeFromPoint && !doc.caretPositionFromPoint) { - return null - } - - // Get the browser range for the tap position - let range: Range | null = null - - if (doc.caretRangeFromPoint) { - range = doc.caretRangeFromPoint(clientX, clientY) - } else if (doc.caretPositionFromPoint) { - const pos = doc.caretPositionFromPoint(clientX, clientY) - if (pos?.offsetNode) { - range = document.createRange() - range.setStart(pos.offsetNode, pos.offset) - range.collapse(true) - } - } - - // Ensure tap is within our editable element - if (!range || !editable.contains(range.startContainer)) { - return { node: null, nodeOffset: 0, cumulativeOffset: 0 } - } - - const node = range.startContainer - const offset = range.startOffset - const nodeTextLength = node.textContent?.length || 0 - - /** Get the bounding rectangle for a character at the given offset. */ - const getCharRect = (targetOffset: number): DOMRect | null => { - const charRange = document.createRange() - charRange.setStart(node, targetOffset) - charRange.setEnd(node, targetOffset + 1) - return charRange.getBoundingClientRect() - } - - /** Check if tap is within a character's bounding box. */ - const isInsideCharRect = (rect: DOMRect | null): boolean => { - if (!rect) return false - return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom - } - - /** Check if tap is vertically contained within the character's bounding box. */ - const isVerticallyContained = (rect: DOMRect | null): boolean => { - if (!rect) return false - return clientY >= rect.top && clientY <= rect.bottom - } - - // Check by tapping on a character at the current offset and the one before it. - const isClickOnCharacter = [offset, offset - 1] - .filter(o => o >= 0 && o < nodeTextLength) - .some(checkOffset => isInsideCharRect(getCharRect(checkOffset))) - - // Allow taps horizontally beyond text if vertically aligned with text line - const isValidEdgeClick = - (offset === 0 || offset === nodeTextLength) && - isVerticallyContained(getCharRect(offset === 0 ? 0 : nodeTextLength - 1)) - - // Valid tap on character - not a void area - if (isClickOnCharacter || isValidEdgeClick) return null - - // Invalid tap (padding/void area), calculate the caret position - const textNodes = getTextNodes(editable) - if (textNodes.length === 0) return null - - const nearest = findNearestTextNode(textNodes, clientY) - if (!nearest) return null - - const nodeOffset = offsetFromX(nearest.node, clientX, clientY) - const cumulativeOffset = getCumulativeOffset(editable, nearest.node, nodeOffset) - return { node: nearest.node, nodeOffset, cumulativeOffset } - }, - [getTextNodes, findNearestTextNode, offsetFromX, getCumulativeOffset], - ) - /** * Handles void area taps by preventing default and setting the caret. * Returns true if we handled the tap (void area), false if browser should handle it (valid tap on character). @@ -763,28 +535,30 @@ const Editable = ({ const editable = contentRef.current if (!editable) return false - const voidAreaInfo = detectVoidAreaTap(editable, clientX, clientY) - if (!voidAreaInfo) return false + const caretPositionInfo = detectVoidAreaTap(editable, clientX, clientY) + if (!caretPositionInfo) return false - // Void area tap detected - perform side effects + // Void area tap detected, prevent default browser behavior preventDefault() - if (voidAreaInfo.node) { + if (caretPositionInfo.node) { // Update Redux cursor state dispatch( setCursor({ path, - offset: voidAreaInfo.cumulativeOffset, + offset: caretPositionInfo.nodeOffset, }), ) // Set caret manually - setCaretAtNode(voidAreaInfo.node, voidAreaInfo.nodeOffset) + selection.set(caretPositionInfo.node, { + offset: Math.min(caretPositionInfo.nodeOffset, caretPositionInfo.node.length), + }) } return true }, - [contentRef, detectVoidAreaTap, dispatch, path, setCaretAtNode], + [contentRef, dispatch, path], ) const onMouseDown = useCallback( diff --git a/src/e2e/puppeteer/test-puppeteer.sh b/src/e2e/puppeteer/test-puppeteer.sh index 6080249744a..0d8c422018b 100755 --- a/src/e2e/puppeteer/test-puppeteer.sh +++ b/src/e2e/puppeteer/test-puppeteer.sh @@ -20,7 +20,7 @@ stop_dev_server() { } cleanup() { - stop_dev_server + stop_dev_server stop_docker_container } diff --git a/src/util/caretPositioning.ts b/src/util/caretPositioning.ts new file mode 100644 index 00000000000..cc1379a739e --- /dev/null +++ b/src/util/caretPositioning.ts @@ -0,0 +1,86 @@ +/** These functions use binary search algorithms for efficient caret position calculations within text nodes. */ +import textNodeUtils, { TextNodeLine } from './textNodeUtils' + +/** + * Performs binary search to find the character offset within a text node + * that corresponds to a given X coordinate. + * + * @param node - The text node to search within. + * @param clientX - The X coordinate of the tap/click. + * @param lo - Lower bound of the search range (character index). + * @param hi - Upper bound of the search range (character index). + * @returns The character offset where the caret should be placed. + */ +const binarySearchOffset = (node: Text, clientX: number, lo: number, hi: number): number => { + while (lo < hi) { + const mid = (lo + hi) >> 1 + + const r = document.createRange() + r.setStart(node, mid) + r.setEnd(node, Math.min(mid + 1, hi)) + + const rect = r.getBoundingClientRect() + + if (clientX < rect.left) { + hi = mid + } else if (clientX > rect.right) { + lo = mid + 1 + } else { + const center = rect.left + rect.width / 2 + return clientX < center ? mid : mid + 1 + } + } + + return lo +} + +/** + * Calculates the vertical distance from a Y coordinate to a line's bounding rectangle. + * Returns 0 if the coordinate is within the line vertically. + */ +const getLineDistance = (clientY: number, rect: DOMRect): number => + clientY < rect.top ? rect.top - clientY : clientY > rect.bottom ? clientY - rect.bottom : 0 + +/** Finds the closest line to a Y coordinate within an array of lines. */ +const findClosestLine = (lines: TextNodeLine[], clientY: number): TextNodeLine => { + return lines.reduce<{ line: TextNodeLine; dist: number }>( + (closest, line) => { + const dist = getLineDistance(clientY, line.rect) + return dist < closest.dist ? { line, dist } : closest + }, + { line: lines[0], dist: Infinity }, + ).line +} + +/** + * Resolves the horizontal character offset within a text node using binary search. + * Handles both single-line and multi-line text nodes. + * + * For multi-line text, it first finds the closest line vertically, then performs + * binary search within that line. + * + * @param node - The text node to search within. + * @param clientX - The X coordinate of the tap/click. + * @param clientY - The Y coordinate of the tap/click (used for multi-line text). + * @returns The character offset where the caret should be placed. + */ +const calculateHorizontalOffset = (node: Text, clientX: number, clientY: number): number => { + const text = node.nodeValue ?? '' + if (!text) return 0 + + const lines = textNodeUtils.getTextNodeLines(node) + + // If the text node is a single line, use binary search to find the offset + if (lines.length <= 1) { + return binarySearchOffset(node, clientX, 0, text.length) + } + + // If the text node is multi-line, pick the closest line with respect to the Y coordinate + // and then do binary search within that line + const targetLine = findClosestLine(lines, clientY) + + // Binary search only inside that line + return binarySearchOffset(node, clientX, targetLine.start, targetLine.end) +} + +export default calculateHorizontalOffset diff --git a/src/util/textNodeUtils.ts b/src/util/textNodeUtils.ts new file mode 100644 index 00000000000..12587e11ff2 --- /dev/null +++ b/src/util/textNodeUtils.ts @@ -0,0 +1,90 @@ +/** Represents a line within a text node with its character range and bounding box. */ +export interface TextNodeLine { + start: number + end: number + rect: DOMRect +} + +/** Represents a text node with its bounding rectangle. */ +interface TextNodeWithRect { + node: Text + rect: DOMRect +} + +/** + * Gets all text nodes within an element that can receive a caret. + * Filters out empty or whitespace-only text nodes. + */ +const getTextNodes = (root: HTMLElement): Text[] => { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + return node.nodeValue?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT + }, + }) + const nodes: Text[] = [] + let n: Text | null + while ((n = walker.nextNode() as Text | null)) nodes.push(n) + return nodes +} + +/** + * Finds the nearest text node to a given Y coordinate by calculating vertical distance. + * Returns the text node with the smallest vertical distance to the coordinate. + */ +const findNearestTextNode = (textNodes: Text[], clientY: number): TextNodeWithRect | null => { + return textNodes.reduce((closest, node) => { + const range = document.createRange() + range.selectNodeContents(node) + const rect = range.getBoundingClientRect() + + const dist = clientY < rect.top ? rect.top - clientY : clientY > rect.bottom ? clientY - rect.bottom : 0 + + if (!closest) return { node, rect } + + const closestDist = + clientY < closest.rect.top + ? closest.rect.top - clientY + : clientY > closest.rect.bottom + ? clientY - closest.rect.bottom + : 0 + + return dist < closestDist ? { node, rect } : closest + }, null) +} + +/** + * Splits a text node into individual lines based on vertical position. + * Returns an array of line objects with start/end character positions and bounding boxes. + */ +const getTextNodeLines = (node: Text): TextNodeLine[] => { + const text = node.nodeValue ?? '' + if (!text) return [] + + const range = document.createRange() + let lastRect: DOMRect | null = null + + return Array.from(text, (_, i) => { + range.setStart(node, i) + range.setEnd(node, i + 1) + const rect = range.getBoundingClientRect() + return rect.height ? { i, rect } : null + }) + .filter((item): item is { i: number; rect: DOMRect } => item !== null) + .reduce((lines, { i, rect }) => { + if (!lastRect || Math.abs(lastRect.top - rect.top) > rect.height / 2) { + lines.push({ start: i, end: i + 1, rect }) + } else { + lines[lines.length - 1].end = i + 1 + } + lastRect = rect + return lines + }, []) +} + +const textNodeUtils = { + getTextNodes, + findNearestTextNode, + getTextNodeLines, +} + +export default textNodeUtils diff --git a/src/util/voidAreaDetection.ts b/src/util/voidAreaDetection.ts new file mode 100644 index 00000000000..1c7091171b4 --- /dev/null +++ b/src/util/voidAreaDetection.ts @@ -0,0 +1,106 @@ +/** + * Safari does glyph-only hit testing, so tapping/clicking on empty space has no caret target. + * This module provides a clean API for detecting void area taps and calculating appropriate + * caret positions. + */ +import calculateHorizontalOffset from './caretPositioning' +import textNodeUtils from './textNodeUtils' + +/** Represents the result of void area detection with caret position information. */ +interface CaretPositionInfo { + /** The text node where the caret should be placed, or null if no valid position found. */ + node: Text | null + /** The character offset within the text node. */ + nodeOffset: number +} + +/** + * Detects if a tap/click is in a void area and calculates the appropriate caret position. + * + * A void area is defined as. + * - Empty space (padding, margins, line height gaps) within the editable element. + * - Areas where the browser cannot detect a valid caret position. + * - Taps that are not directly on visible characters. + * + * @param editable - The editable element containing the text. + * @param clientX - The X coordinate of the tap/click. + * @param clientY - The Y coordinate of the tap/click. + * @returns Caret position info if it's a void area tap, null if tap is on a valid character. + */ +const detectVoidAreaTap = (editable: HTMLElement, clientX: number, clientY: number): CaretPositionInfo | null => { + const doc = document as Document + + // These APIs are not available in test environments (JSDOM) + // In that case, return a fallback result + if (!doc.caretRangeFromPoint && !doc.caretPositionFromPoint) { + return { node: null, nodeOffset: 0 } + } + + // Get the browser range for the tap position + let range: Range | null = null + + if (doc.caretRangeFromPoint) { + range = doc.caretRangeFromPoint(clientX, clientY) + } else if (doc.caretPositionFromPoint) { + const pos = doc.caretPositionFromPoint(clientX, clientY) + if (pos?.offsetNode) { + range = document.createRange() + range.setStart(pos.offsetNode, pos.offset) + range.collapse(true) + } + } + + // Ensure tap is within our editable element + if (!range || !editable.contains(range.startContainer)) { + return { node: null, nodeOffset: 0 } + } + + const node = range.startContainer + const offset = range.startOffset + const nodeTextLength = node.textContent?.length || 0 + + /** Get the bounding rectangle for a character at the given offset. */ + const getCharRect = (targetOffset: number): DOMRect | null => { + const charRange = document.createRange() + charRange.setStart(node, targetOffset) + charRange.setEnd(node, targetOffset + 1) + return charRange.getBoundingClientRect() + } + + /** Check if tap is within a character's bounding box. */ + const isInsideCharRect = (rect: DOMRect | null): boolean => { + if (!rect) return false + return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom + } + + /** Check if tap is vertically contained within the character's bounding box. */ + const isVerticallyContained = (rect: DOMRect | null): boolean => { + if (!rect) return false + return clientY >= rect.top && clientY <= rect.bottom + } + + // Check by tapping on a character at the current offset and the one before it. + const isClickOnCharacter = [offset, offset - 1] + .filter(o => o >= 0 && o < nodeTextLength) + .some(checkOffset => isInsideCharRect(getCharRect(checkOffset))) + + // Allow taps horizontally beyond text if vertically aligned with text line + const isValidEdgeClick = + (offset === 0 || offset === nodeTextLength) && + isVerticallyContained(getCharRect(offset === 0 ? 0 : nodeTextLength - 1)) + + // Valid tap on character, not a void area + if (isClickOnCharacter || isValidEdgeClick) return null + + // Invalid tap (padding/void area), calculate the caret position + const textNodes = textNodeUtils.getTextNodes(editable) + if (textNodes.length === 0) return null + + const nearest = textNodeUtils.findNearestTextNode(textNodes, clientY) + if (!nearest) return null + + const nodeOffset = calculateHorizontalOffset(nearest.node, clientX, clientY) + return { node: nearest.node, nodeOffset } +} + +export default detectVoidAreaTap From 87b8421471bdaac3396ae01fa394cdbde0f74501 Mon Sep 17 00:00:00 2001 From: karunkop Date: Tue, 16 Dec 2025 15:30:42 +0545 Subject: [PATCH 08/12] return null when no document api for caret position is available --- src/util/voidAreaDetection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/util/voidAreaDetection.ts b/src/util/voidAreaDetection.ts index 1c7091171b4..108cbb5484c 100644 --- a/src/util/voidAreaDetection.ts +++ b/src/util/voidAreaDetection.ts @@ -31,9 +31,9 @@ const detectVoidAreaTap = (editable: HTMLElement, clientX: number, clientY: numb const doc = document as Document // These APIs are not available in test environments (JSDOM) - // In that case, return a fallback result + // In that case, return null to let the browser handle it normally if (!doc.caretRangeFromPoint && !doc.caretPositionFromPoint) { - return { node: null, nodeOffset: 0 } + return null } // Get the browser range for the tap position From 37b1a680cfc6b0a5780651c004727003aebad882 Mon Sep 17 00:00:00 2001 From: karunkop Date: Wed, 17 Dec 2025 14:37:41 +0545 Subject: [PATCH 09/12] renamed file name, moved implementation details to it's respective module --- src/components/Editable.tsx | 4 +- src/util/caretPositioning.ts | 52 +++++++++--- src/util/detectVoidArea.ts | 155 ++++++++++++++++++++++++++++++++++ src/util/textNodeUtils.ts | 90 -------------------- src/util/voidAreaDetection.ts | 106 ----------------------- 5 files changed, 199 insertions(+), 208 deletions(-) create mode 100644 src/util/detectVoidArea.ts delete mode 100644 src/util/textNodeUtils.ts delete mode 100644 src/util/voidAreaDetection.ts diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index e4df90e80e5..5cfa772e80a 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -47,6 +47,7 @@ import storageModel from '../stores/storageModel' import suppressFocusStore from '../stores/suppressFocus' import addEmojiSpace from '../util/addEmojiSpace' import containsURL from '../util/containsURL' +import detectVoidArea from '../util/detectVoidArea' import ellipsize from '../util/ellipsize' import equalPath from '../util/equalPath' import haptics from '../util/haptics' @@ -56,7 +57,6 @@ import isDocumentEditable from '../util/isDocumentEditable' import strip from '../util/strip' import stripEmptyFormattingTags from '../util/stripEmptyFormattingTags' import trimHtml from '../util/trimHtml' -import detectVoidAreaTap from '../util/voidAreaDetection' import ContentEditable, { ContentEditableEvent } from './ContentEditable' import useEditMode from './Editable/useEditMode' import useOnCopy from './Editable/useOnCopy' @@ -535,7 +535,7 @@ const Editable = ({ const editable = contentRef.current if (!editable) return false - const caretPositionInfo = detectVoidAreaTap(editable, clientX, clientY) + const caretPositionInfo = detectVoidArea(editable, { clientX, clientY }) if (!caretPositionInfo) return false // Void area tap detected, prevent default browser behavior diff --git a/src/util/caretPositioning.ts b/src/util/caretPositioning.ts index cc1379a739e..d873335db0f 100644 --- a/src/util/caretPositioning.ts +++ b/src/util/caretPositioning.ts @@ -1,9 +1,41 @@ -/** These functions use binary search algorithms for efficient caret position calculations within text nodes. */ -import textNodeUtils, { TextNodeLine } from './textNodeUtils' +/** Represents a line within a text node with its character range and bounding box. */ +interface TextNodeLine { + start: number + end: number + rect: DOMRect +} + +/** + * Splits a text node into individual lines based on vertical position. + * Returns an array of line objects with start/end character positions and bounding boxes. + */ +const getTextNodeLines = (node: Text): TextNodeLine[] => { + const text = node.nodeValue ?? '' + if (!text) return [] + + const range = document.createRange() + let lastRect: DOMRect | null = null + + return Array.from(text, (_, i) => { + range.setStart(node, i) + range.setEnd(node, i + 1) + const rect = range.getBoundingClientRect() + return rect.height ? { i, rect } : null + }) + .filter((item): item is { i: number; rect: DOMRect } => item !== null) + .reduce((lines, { i, rect }) => { + if (!lastRect || Math.abs(lastRect.top - rect.top) > rect.height / 2) { + lines.push({ start: i, end: i + 1, rect }) + } else { + lines[lines.length - 1].end = i + 1 + } + lastRect = rect + return lines + }, []) +} /** - * Performs binary search to find the character offset within a text node - * that corresponds to a given X coordinate. + * Finds the character offset within a text node that corresponds to a given X coordinate. * * @param node - The text node to search within. * @param clientX - The X coordinate of the tap/click. @@ -11,7 +43,7 @@ import textNodeUtils, { TextNodeLine } from './textNodeUtils' * @param hi - Upper bound of the search range (character index). * @returns The character offset where the caret should be placed. */ -const binarySearchOffset = (node: Text, clientX: number, lo: number, hi: number): number => { +const findOffsetAtX = (node: Text, clientX: number, lo: number, hi: number): number => { while (lo < hi) { const mid = (lo + hi) >> 1 @@ -68,19 +100,19 @@ const calculateHorizontalOffset = (node: Text, clientX: number, clientY: number) const text = node.nodeValue ?? '' if (!text) return 0 - const lines = textNodeUtils.getTextNodeLines(node) + const lines = getTextNodeLines(node) - // If the text node is a single line, use binary search to find the offset + // If the text node is a single line, find the offset directly if (lines.length <= 1) { - return binarySearchOffset(node, clientX, 0, text.length) + return findOffsetAtX(node, clientX, 0, text.length) } // If the text node is multi-line, pick the closest line with respect to the Y coordinate // and then do binary search within that line const targetLine = findClosestLine(lines, clientY) - // Binary search only inside that line - return binarySearchOffset(node, clientX, targetLine.start, targetLine.end) + // Search only inside that line + return findOffsetAtX(node, clientX, targetLine.start, targetLine.end) } export default calculateHorizontalOffset diff --git a/src/util/detectVoidArea.ts b/src/util/detectVoidArea.ts new file mode 100644 index 00000000000..2747deb162c --- /dev/null +++ b/src/util/detectVoidArea.ts @@ -0,0 +1,155 @@ +/** + * Safari does glyph-only hit testing, so clicking on empty space has no caret target. + * This module provides API for detecting void area positions and calculating appropriate caret positions. + */ +import calculateHorizontalOffset from './caretPositioning' + +/** Represents the result of void area detection with caret position information. */ +interface CaretPositionInfo { + /** The text node where the caret should be placed, or null if no valid position found. */ + node: Text | null + /** The character offset within the text node. */ + nodeOffset: number +} + +/** Represents a text node with its bounding rectangle. */ +interface TextNodeWithRect { + node: Text + rect: DOMRect +} + +interface Coordinates { + clientX: number + clientY: number +} +/** + * Gets all text nodes within an element that can receive a caret. + * Filters out empty or whitespace-only text nodes. + */ +const getTextNodes = (root: HTMLElement): Text[] => { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + return node.nodeValue?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT + }, + }) + const nodes: Text[] = [] + let n: Text | null + while ((n = walker.nextNode() as Text | null)) nodes.push(n) + return nodes +} + +/** + * Finds the nearest text node to a given Y coordinate by calculating vertical distance. + * Returns the text node with the smallest vertical distance to the coordinate. + */ +const findNearestTextNode = (textNodes: Text[], clientY: number): TextNodeWithRect | null => { + return textNodes.reduce((closest, node) => { + const range = document.createRange() + range.selectNodeContents(node) + const rect = range.getBoundingClientRect() + + const dist = clientY < rect.top ? rect.top - clientY : clientY > rect.bottom ? clientY - rect.bottom : 0 + + if (!closest) return { node, rect } + + const closestDist = + clientY < closest.rect.top + ? closest.rect.top - clientY + : clientY > closest.rect.bottom + ? clientY - closest.rect.bottom + : 0 + + return dist < closestDist ? { node, rect } : closest + }, null) +} + +/** + * Detects if a coordinate is in a void area and calculates the appropriate caret position. + * + * A void area is defined as. + * - Empty space (padding, margins, line height gaps) within the editable element. + * - Areas where the browser cannot detect a valid caret position. + * - Coordinates that are not directly on visible characters. + * + * @param editable - The editable element containing the text. + * @param clientX - The X coordinate. + * @param clientY - The Y coordinate. + * @returns Caret position info if it's a void area position, null if it is on a valid character. + */ +const detectVoidArea = (editable: HTMLElement, { clientX, clientY }: Coordinates): CaretPositionInfo | null => { + const doc = document as Document + + // These APIs are not available in test environments (JSDOM) + // In that case, return null to let the browser handle it normally + if (!doc.caretRangeFromPoint && !doc.caretPositionFromPoint) { + return null + } + + // Get the browser range for the given point + let range: Range | null = null + + if (doc.caretRangeFromPoint) { + range = doc.caretRangeFromPoint(clientX, clientY) + } else if (doc.caretPositionFromPoint) { + const pos = doc.caretPositionFromPoint(clientX, clientY) + if (pos?.offsetNode) { + range = document.createRange() + range.setStart(pos.offsetNode, pos.offset) + range.collapse(true) + } + } + + // Ensure the coordinates are within our editable element + if (!range || !editable.contains(range.startContainer)) { + return { node: null, nodeOffset: 0 } + } + + const node = range.startContainer + const offset = range.startOffset + const nodeTextLength = node.textContent?.length || 0 + + /** Get the bounding rectangle for a character at the given offset. */ + const getCharRect = (targetOffset: number): DOMRect | null => { + const charRange = document.createRange() + charRange.setStart(node, targetOffset) + charRange.setEnd(node, targetOffset + 1) + return charRange.getBoundingClientRect() + } + + /** Check if the coordinates are within a character's bounding box. */ + const isInsideCharRect = (rect: DOMRect | null): boolean => { + if (!rect) return false + return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom + } + + /** Check if the coordinates are vertically contained within the character's bounding box. */ + const isVerticallyContained = (rect: DOMRect | null): boolean => { + if (!rect) return false + return clientY >= rect.top && clientY <= rect.bottom + } + + // Check whether the coordinates land on the character at the current offset or the one before it. + const isClickOnCharacter = [offset, offset - 1] + .filter(o => o >= 0 && o < nodeTextLength) + .some(checkOffset => isInsideCharRect(getCharRect(checkOffset))) + + // Allow coordinates horizontally beyond text if vertically aligned with the text line + const isValidEdgeClick = + (offset === 0 || offset === nodeTextLength) && + isVerticallyContained(getCharRect(offset === 0 ? 0 : nodeTextLength - 1)) + + // Valid coordinates on character, not a void area + if (isClickOnCharacter || isValidEdgeClick) return null + + // Coordinates are in padding/void area, calculate the caret position + const textNodes = getTextNodes(editable) + if (textNodes.length === 0) return null + + const nearest = findNearestTextNode(textNodes, clientY) + if (!nearest) return null + + const nodeOffset = calculateHorizontalOffset(nearest.node, clientX, clientY) + return { node: nearest.node, nodeOffset } +} + +export default detectVoidArea diff --git a/src/util/textNodeUtils.ts b/src/util/textNodeUtils.ts deleted file mode 100644 index 12587e11ff2..00000000000 --- a/src/util/textNodeUtils.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** Represents a line within a text node with its character range and bounding box. */ -export interface TextNodeLine { - start: number - end: number - rect: DOMRect -} - -/** Represents a text node with its bounding rectangle. */ -interface TextNodeWithRect { - node: Text - rect: DOMRect -} - -/** - * Gets all text nodes within an element that can receive a caret. - * Filters out empty or whitespace-only text nodes. - */ -const getTextNodes = (root: HTMLElement): Text[] => { - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { - acceptNode(node) { - return node.nodeValue?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT - }, - }) - const nodes: Text[] = [] - let n: Text | null - while ((n = walker.nextNode() as Text | null)) nodes.push(n) - return nodes -} - -/** - * Finds the nearest text node to a given Y coordinate by calculating vertical distance. - * Returns the text node with the smallest vertical distance to the coordinate. - */ -const findNearestTextNode = (textNodes: Text[], clientY: number): TextNodeWithRect | null => { - return textNodes.reduce((closest, node) => { - const range = document.createRange() - range.selectNodeContents(node) - const rect = range.getBoundingClientRect() - - const dist = clientY < rect.top ? rect.top - clientY : clientY > rect.bottom ? clientY - rect.bottom : 0 - - if (!closest) return { node, rect } - - const closestDist = - clientY < closest.rect.top - ? closest.rect.top - clientY - : clientY > closest.rect.bottom - ? clientY - closest.rect.bottom - : 0 - - return dist < closestDist ? { node, rect } : closest - }, null) -} - -/** - * Splits a text node into individual lines based on vertical position. - * Returns an array of line objects with start/end character positions and bounding boxes. - */ -const getTextNodeLines = (node: Text): TextNodeLine[] => { - const text = node.nodeValue ?? '' - if (!text) return [] - - const range = document.createRange() - let lastRect: DOMRect | null = null - - return Array.from(text, (_, i) => { - range.setStart(node, i) - range.setEnd(node, i + 1) - const rect = range.getBoundingClientRect() - return rect.height ? { i, rect } : null - }) - .filter((item): item is { i: number; rect: DOMRect } => item !== null) - .reduce((lines, { i, rect }) => { - if (!lastRect || Math.abs(lastRect.top - rect.top) > rect.height / 2) { - lines.push({ start: i, end: i + 1, rect }) - } else { - lines[lines.length - 1].end = i + 1 - } - lastRect = rect - return lines - }, []) -} - -const textNodeUtils = { - getTextNodes, - findNearestTextNode, - getTextNodeLines, -} - -export default textNodeUtils diff --git a/src/util/voidAreaDetection.ts b/src/util/voidAreaDetection.ts deleted file mode 100644 index 108cbb5484c..00000000000 --- a/src/util/voidAreaDetection.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Safari does glyph-only hit testing, so tapping/clicking on empty space has no caret target. - * This module provides a clean API for detecting void area taps and calculating appropriate - * caret positions. - */ -import calculateHorizontalOffset from './caretPositioning' -import textNodeUtils from './textNodeUtils' - -/** Represents the result of void area detection with caret position information. */ -interface CaretPositionInfo { - /** The text node where the caret should be placed, or null if no valid position found. */ - node: Text | null - /** The character offset within the text node. */ - nodeOffset: number -} - -/** - * Detects if a tap/click is in a void area and calculates the appropriate caret position. - * - * A void area is defined as. - * - Empty space (padding, margins, line height gaps) within the editable element. - * - Areas where the browser cannot detect a valid caret position. - * - Taps that are not directly on visible characters. - * - * @param editable - The editable element containing the text. - * @param clientX - The X coordinate of the tap/click. - * @param clientY - The Y coordinate of the tap/click. - * @returns Caret position info if it's a void area tap, null if tap is on a valid character. - */ -const detectVoidAreaTap = (editable: HTMLElement, clientX: number, clientY: number): CaretPositionInfo | null => { - const doc = document as Document - - // These APIs are not available in test environments (JSDOM) - // In that case, return null to let the browser handle it normally - if (!doc.caretRangeFromPoint && !doc.caretPositionFromPoint) { - return null - } - - // Get the browser range for the tap position - let range: Range | null = null - - if (doc.caretRangeFromPoint) { - range = doc.caretRangeFromPoint(clientX, clientY) - } else if (doc.caretPositionFromPoint) { - const pos = doc.caretPositionFromPoint(clientX, clientY) - if (pos?.offsetNode) { - range = document.createRange() - range.setStart(pos.offsetNode, pos.offset) - range.collapse(true) - } - } - - // Ensure tap is within our editable element - if (!range || !editable.contains(range.startContainer)) { - return { node: null, nodeOffset: 0 } - } - - const node = range.startContainer - const offset = range.startOffset - const nodeTextLength = node.textContent?.length || 0 - - /** Get the bounding rectangle for a character at the given offset. */ - const getCharRect = (targetOffset: number): DOMRect | null => { - const charRange = document.createRange() - charRange.setStart(node, targetOffset) - charRange.setEnd(node, targetOffset + 1) - return charRange.getBoundingClientRect() - } - - /** Check if tap is within a character's bounding box. */ - const isInsideCharRect = (rect: DOMRect | null): boolean => { - if (!rect) return false - return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom - } - - /** Check if tap is vertically contained within the character's bounding box. */ - const isVerticallyContained = (rect: DOMRect | null): boolean => { - if (!rect) return false - return clientY >= rect.top && clientY <= rect.bottom - } - - // Check by tapping on a character at the current offset and the one before it. - const isClickOnCharacter = [offset, offset - 1] - .filter(o => o >= 0 && o < nodeTextLength) - .some(checkOffset => isInsideCharRect(getCharRect(checkOffset))) - - // Allow taps horizontally beyond text if vertically aligned with text line - const isValidEdgeClick = - (offset === 0 || offset === nodeTextLength) && - isVerticallyContained(getCharRect(offset === 0 ? 0 : nodeTextLength - 1)) - - // Valid tap on character, not a void area - if (isClickOnCharacter || isValidEdgeClick) return null - - // Invalid tap (padding/void area), calculate the caret position - const textNodes = textNodeUtils.getTextNodes(editable) - if (textNodes.length === 0) return null - - const nearest = textNodeUtils.findNearestTextNode(textNodes, clientY) - if (!nearest) return null - - const nodeOffset = calculateHorizontalOffset(nearest.node, clientX, clientY) - return { node: nearest.node, nodeOffset } -} - -export default detectVoidAreaTap From 558bea7045960ae8174edda67d75bbc347497035 Mon Sep 17 00:00:00 2001 From: karunkop Date: Tue, 23 Dec 2025 11:21:19 +0545 Subject: [PATCH 10/12] fixed issue with caret index being out of range when tapped in empty thought --- src/components/Editable.tsx | 3 ++- src/util/caretPositioning.ts | 2 +- src/util/detectVoidArea.ts | 20 +++++++++++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index 5cfa772e80a..2567f4f5fab 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -551,7 +551,8 @@ const Editable = ({ ) // Set caret manually - selection.set(caretPositionInfo.node, { + selection.restore({ + node: caretPositionInfo.node, offset: Math.min(caretPositionInfo.nodeOffset, caretPositionInfo.node.length), }) } diff --git a/src/util/caretPositioning.ts b/src/util/caretPositioning.ts index d873335db0f..41019b45821 100644 --- a/src/util/caretPositioning.ts +++ b/src/util/caretPositioning.ts @@ -108,7 +108,7 @@ const calculateHorizontalOffset = (node: Text, clientX: number, clientY: number) } // If the text node is multi-line, pick the closest line with respect to the Y coordinate - // and then do binary search within that line + // and then find the offset within that line const targetLine = findClosestLine(lines, clientY) // Search only inside that line diff --git a/src/util/detectVoidArea.ts b/src/util/detectVoidArea.ts index 2747deb162c..c952f9ed10a 100644 --- a/src/util/detectVoidArea.ts +++ b/src/util/detectVoidArea.ts @@ -108,11 +108,29 @@ const detectVoidArea = (editable: HTMLElement, { clientX, clientY }: Coordinates const offset = range.startOffset const nodeTextLength = node.textContent?.length || 0 + // If the node is empty (placeholder text), return caret position at offset 0 + if (nodeTextLength === 0) { + return { node: node as Text, nodeOffset: 0 } + } + /** Get the bounding rectangle for a character at the given offset. */ const getCharRect = (targetOffset: number): DOMRect | null => { + // Ensure targetOffset is within valid bounds + if (targetOffset < 0 || targetOffset > nodeTextLength) { + return null + } + const charRange = document.createRange() charRange.setStart(node, targetOffset) - charRange.setEnd(node, targetOffset + 1) + + // If targetOffset is at the end, collapse the range to that position + // Otherwise, set the end to targetOffset + 1 to get the character's bounding box + if (targetOffset >= nodeTextLength) { + charRange.collapse(true) + } else { + charRange.setEnd(node, targetOffset + 1) + } + return charRange.getBoundingClientRect() } From 6082bd4882fc2cf960a297c9876aa93d9e89a5ef Mon Sep 17 00:00:00 2001 From: karunkop Date: Wed, 24 Dec 2025 14:00:01 +0545 Subject: [PATCH 11/12] refactored event handlers for touchStart, added clear abstraction for utlity function for caret position offset --- src/components/Editable.tsx | 79 +++++----- src/serviceWorkerRegistration.ts | 8 +- src/util/caretPositioning.ts | 118 --------------- ...oidArea.ts => getNodeOffsetForVoidArea.ts} | 141 ++++++++++++++++-- 4 files changed, 166 insertions(+), 180 deletions(-) delete mode 100644 src/util/caretPositioning.ts rename src/util/{detectVoidArea.ts => getNodeOffsetForVoidArea.ts} (55%) diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index 2567f4f5fab..381262e143d 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -47,9 +47,9 @@ import storageModel from '../stores/storageModel' import suppressFocusStore from '../stores/suppressFocus' import addEmojiSpace from '../util/addEmojiSpace' import containsURL from '../util/containsURL' -import detectVoidArea from '../util/detectVoidArea' import ellipsize from '../util/ellipsize' import equalPath from '../util/equalPath' +import getNodeOffsetForVoidArea from '../util/getNodeOffsetForVoidArea' import haptics from '../util/haptics' import head from '../util/head' import isDivider from '../util/isDivider' @@ -526,40 +526,18 @@ const Editable = ({ [value, setCursorOnThought], ) - /** - * Handles void area taps by preventing default and setting the caret. - * Returns true if we handled the tap (void area), false if browser should handle it (valid tap on character). - */ - const handleVoidAreaTap = useCallback( - (clientX: number, clientY: number, preventDefault: () => void): boolean => { - const editable = contentRef.current - if (!editable) return false - - const caretPositionInfo = detectVoidArea(editable, { clientX, clientY }) - if (!caretPositionInfo) return false - - // Void area tap detected, prevent default browser behavior - preventDefault() - - if (caretPositionInfo.node) { - // Update Redux cursor state - dispatch( - setCursor({ - path, - offset: caretPositionInfo.nodeOffset, - }), - ) - - // Set caret manually - selection.restore({ - node: caretPositionInfo.node, - offset: Math.min(caretPositionInfo.nodeOffset, caretPositionInfo.node.length), - }) - } - - return true + /** Sets the caret position for a void area tap. */ + const setVoidAreaCaret = useCallback( + (nodeOffset: number) => { + // Update Redux cursor state + dispatch( + setCursor({ + path, + offset: nodeOffset, + }), + ) }, - [contentRef, dispatch, path], + [dispatch, path], ) const onMouseDown = useCallback( @@ -582,8 +560,17 @@ const Editable = ({ bottomMargin: fontSize * 2, }) - // If not a void area tap, allow browser's default selection - if (!handleVoidAreaTap(e.clientX, e.clientY, () => e.preventDefault())) { + // Handle void area detection + const nodeOffset = getNodeOffsetForVoidArea(contentRef.current, { + clientX: e.clientX, + clientY: e.clientY, + }) + // nodeOffset is null if the tap is on a valid character + // in that case, allow the default selection + if (nodeOffset) { + e.preventDefault() + setVoidAreaCaret(nodeOffset) + } else { allowDefaultSelection() } } @@ -595,31 +582,37 @@ const Editable = ({ e.preventDefault() } }, - [contentRef, editingOrOnCursor, fontSize, allowDefaultSelection, hasMulticursor, handleVoidAreaTap], + [contentRef, editingOrOnCursor, fontSize, allowDefaultSelection, hasMulticursor, setVoidAreaCaret], ) // Manually attach touchstart listener with { passive: false } to allow preventDefault useEffect(() => { const editable = contentRef.current if (!editable) return - - /** Handle touch events to prevent initial cursor jump (Safari-safe). */ + /** Handle touch events to prevent initial cursor jump. */ const handleTouchStart = (e: TouchEvent) => { if (editingOrOnCursor && !hasMulticursor && e.touches.length > 0) { - // If not a void area tap, allow browser's default selection - if (!handleVoidAreaTap(e.touches[0].clientX, e.touches[0].clientY, () => e.preventDefault())) { + const nodeOffset = getNodeOffsetForVoidArea(editable, { + clientX: e.touches[0].clientX, + clientY: e.touches[0].clientY, + }) + // nodeOffset is null if the tap is on a valid character + // in that case, allow the default selection + if (nodeOffset) { + e.preventDefault() + setVoidAreaCaret(nodeOffset) + } else { allowDefaultSelection() } } } - // Add event listener with { passive: false } to allow preventDefault editable.addEventListener('touchstart', handleTouchStart, { passive: false }) return () => { editable.removeEventListener('touchstart', handleTouchStart) } - }, [contentRef, editingOrOnCursor, hasMulticursor, allowDefaultSelection, handleVoidAreaTap]) + }, [contentRef, editingOrOnCursor, hasMulticursor, allowDefaultSelection, setVoidAreaCaret]) /** Sets the cursor on the thought on touchend or click. Handles hidden elements, drags, and editing mode. */ const onTap = useCallback( diff --git a/src/serviceWorkerRegistration.ts b/src/serviceWorkerRegistration.ts index db41c4e844d..bbf78a7a6a6 100644 --- a/src/serviceWorkerRegistration.ts +++ b/src/serviceWorkerRegistration.ts @@ -13,10 +13,10 @@ const isLocalhost = Boolean( window.location.hostname === 'localhost' || - // [::1] is the IPv6 localhost address. - window.location.hostname === '[::1]' || - // 127.0.0.0/8 are considered localhost for IPv4. - window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/), + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.0/8 are considered localhost for IPv4. + window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/), ) type Config = { diff --git a/src/util/caretPositioning.ts b/src/util/caretPositioning.ts deleted file mode 100644 index 41019b45821..00000000000 --- a/src/util/caretPositioning.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** Represents a line within a text node with its character range and bounding box. */ -interface TextNodeLine { - start: number - end: number - rect: DOMRect -} - -/** - * Splits a text node into individual lines based on vertical position. - * Returns an array of line objects with start/end character positions and bounding boxes. - */ -const getTextNodeLines = (node: Text): TextNodeLine[] => { - const text = node.nodeValue ?? '' - if (!text) return [] - - const range = document.createRange() - let lastRect: DOMRect | null = null - - return Array.from(text, (_, i) => { - range.setStart(node, i) - range.setEnd(node, i + 1) - const rect = range.getBoundingClientRect() - return rect.height ? { i, rect } : null - }) - .filter((item): item is { i: number; rect: DOMRect } => item !== null) - .reduce((lines, { i, rect }) => { - if (!lastRect || Math.abs(lastRect.top - rect.top) > rect.height / 2) { - lines.push({ start: i, end: i + 1, rect }) - } else { - lines[lines.length - 1].end = i + 1 - } - lastRect = rect - return lines - }, []) -} - -/** - * Finds the character offset within a text node that corresponds to a given X coordinate. - * - * @param node - The text node to search within. - * @param clientX - The X coordinate of the tap/click. - * @param lo - Lower bound of the search range (character index). - * @param hi - Upper bound of the search range (character index). - * @returns The character offset where the caret should be placed. - */ -const findOffsetAtX = (node: Text, clientX: number, lo: number, hi: number): number => { - while (lo < hi) { - const mid = (lo + hi) >> 1 - - const r = document.createRange() - r.setStart(node, mid) - r.setEnd(node, Math.min(mid + 1, hi)) - - const rect = r.getBoundingClientRect() - - if (clientX < rect.left) { - hi = mid - } else if (clientX > rect.right) { - lo = mid + 1 - } else { - const center = rect.left + rect.width / 2 - return clientX < center ? mid : mid + 1 - } - } - - return lo -} - -/** - * Calculates the vertical distance from a Y coordinate to a line's bounding rectangle. - * Returns 0 if the coordinate is within the line vertically. - */ -const getLineDistance = (clientY: number, rect: DOMRect): number => - clientY < rect.top ? rect.top - clientY : clientY > rect.bottom ? clientY - rect.bottom : 0 - -/** Finds the closest line to a Y coordinate within an array of lines. */ -const findClosestLine = (lines: TextNodeLine[], clientY: number): TextNodeLine => { - return lines.reduce<{ line: TextNodeLine; dist: number }>( - (closest, line) => { - const dist = getLineDistance(clientY, line.rect) - return dist < closest.dist ? { line, dist } : closest - }, - { line: lines[0], dist: Infinity }, - ).line -} - -/** - * Resolves the horizontal character offset within a text node using binary search. - * Handles both single-line and multi-line text nodes. - * - * For multi-line text, it first finds the closest line vertically, then performs - * binary search within that line. - * - * @param node - The text node to search within. - * @param clientX - The X coordinate of the tap/click. - * @param clientY - The Y coordinate of the tap/click (used for multi-line text). - * @returns The character offset where the caret should be placed. - */ -const calculateHorizontalOffset = (node: Text, clientX: number, clientY: number): number => { - const text = node.nodeValue ?? '' - if (!text) return 0 - - const lines = getTextNodeLines(node) - - // If the text node is a single line, find the offset directly - if (lines.length <= 1) { - return findOffsetAtX(node, clientX, 0, text.length) - } - - // If the text node is multi-line, pick the closest line with respect to the Y coordinate - // and then find the offset within that line - const targetLine = findClosestLine(lines, clientY) - - // Search only inside that line - return findOffsetAtX(node, clientX, targetLine.start, targetLine.end) -} - -export default calculateHorizontalOffset diff --git a/src/util/detectVoidArea.ts b/src/util/getNodeOffsetForVoidArea.ts similarity index 55% rename from src/util/detectVoidArea.ts rename to src/util/getNodeOffsetForVoidArea.ts index c952f9ed10a..57cce5a1d8c 100644 --- a/src/util/detectVoidArea.ts +++ b/src/util/getNodeOffsetForVoidArea.ts @@ -2,14 +2,12 @@ * Safari does glyph-only hit testing, so clicking on empty space has no caret target. * This module provides API for detecting void area positions and calculating appropriate caret positions. */ -import calculateHorizontalOffset from './caretPositioning' - -/** Represents the result of void area detection with caret position information. */ -interface CaretPositionInfo { - /** The text node where the caret should be placed, or null if no valid position found. */ - node: Text | null - /** The character offset within the text node. */ - nodeOffset: number + +/** Represents a line within a text node with its character range and bounding box. */ +interface TextNodeLine { + start: number + end: number + rect: DOMRect } /** Represents a text node with its bounding rectangle. */ @@ -22,6 +20,117 @@ interface Coordinates { clientX: number clientY: number } + +/** + * Splits a text node into individual lines based on vertical position. + * Returns an array of line objects with start/end character positions and bounding boxes. + */ +const getTextNodeLines = (node: Text): TextNodeLine[] => { + const text = node.nodeValue ?? '' + if (!text) return [] + + const range = document.createRange() + let lastRect: DOMRect | null = null + + return Array.from(text, (_, i) => { + range.setStart(node, i) + range.setEnd(node, i + 1) + const rect = range.getBoundingClientRect() + return rect.height ? { i, rect } : null + }) + .filter((item): item is { i: number; rect: DOMRect } => item !== null) + .reduce((lines, { i, rect }) => { + if (!lastRect || Math.abs(lastRect.top - rect.top) > rect.height / 2) { + lines.push({ start: i, end: i + 1, rect }) + } else { + lines[lines.length - 1].end = i + 1 + } + lastRect = rect + return lines + }, []) +} + +/** + * Finds the character offset within a text node that corresponds to a given X coordinate. + * + * @param node - The text node to search within. + * @param clientX - The X coordinate of the tap/click. + * @param lo - Lower bound of the search range (character index). + * @param hi - Upper bound of the search range (character index). + * @returns The character offset where the caret should be placed. + */ +const findOffsetAtX = (node: Text, clientX: number, lo: number, hi: number): number => { + while (lo < hi) { + const mid = (lo + hi) >> 1 + + const r = document.createRange() + r.setStart(node, mid) + r.setEnd(node, Math.min(mid + 1, hi)) + + const rect = r.getBoundingClientRect() + + if (clientX < rect.left) { + hi = mid + } else if (clientX > rect.right) { + lo = mid + 1 + } else { + const center = rect.left + rect.width / 2 + return clientX < center ? mid : mid + 1 + } + } + + return lo +} + +/** + * Calculates the vertical distance from a Y coordinate to a line's bounding rectangle. + * Returns 0 if the coordinate is within the line vertically. + */ +const getLineDistance = (clientY: number, rect: DOMRect): number => + clientY < rect.top ? rect.top - clientY : clientY > rect.bottom ? clientY - rect.bottom : 0 + +/** Finds the closest line to a Y coordinate within an array of lines. */ +const findClosestLine = (lines: TextNodeLine[], clientY: number): TextNodeLine => { + return lines.reduce<{ line: TextNodeLine; dist: number }>( + (closest, line) => { + const dist = getLineDistance(clientY, line.rect) + return dist < closest.dist ? { line, dist } : closest + }, + { line: lines[0], dist: Infinity }, + ).line +} + +/** + * Resolves the horizontal character offset within a text node using binary search. + * Handles both single-line and multi-line text nodes. + * + * For multi-line text, it first finds the closest line vertically, then performs + * binary search within that line. + * + * @param node - The text node to search within. + * @param clientX - The X coordinate of the tap/click. + * @param clientY - The Y coordinate of the tap/click (used for multi-line text). + * @returns The character offset where the caret should be placed. + */ +const calculateHorizontalOffset = (node: Text, clientX: number, clientY: number): number => { + const text = node.nodeValue ?? '' + if (!text) return 0 + + const lines = getTextNodeLines(node) + + // If the text node is a single line, find the offset directly + if (lines.length <= 1) { + return findOffsetAtX(node, clientX, 0, text.length) + } + + // If the text node is multi-line, pick the closest line with respect to the Y coordinate + // and then find the offset within that line + const targetLine = findClosestLine(lines, clientY) + + // Search only inside that line + return findOffsetAtX(node, clientX, targetLine.start, targetLine.end) +} + /** * Gets all text nodes within an element that can receive a caret. * Filters out empty or whitespace-only text nodes. @@ -74,9 +183,12 @@ const findNearestTextNode = (textNodes: Text[], clientY: number): TextNodeWithRe * @param editable - The editable element containing the text. * @param clientX - The X coordinate. * @param clientY - The Y coordinate. - * @returns Caret position info if it's a void area position, null if it is on a valid character. + * @returns The character offset where the caret should be placed, or null if it is on a valid character. */ -const detectVoidArea = (editable: HTMLElement, { clientX, clientY }: Coordinates): CaretPositionInfo | null => { +const getNodeOffsetForVoidArea = (editable: HTMLElement | null, { clientX, clientY }: Coordinates): number | null => { + // If the editable is not found, return null + if (!editable) return null + const doc = document as Document // These APIs are not available in test environments (JSDOM) @@ -101,7 +213,7 @@ const detectVoidArea = (editable: HTMLElement, { clientX, clientY }: Coordinates // Ensure the coordinates are within our editable element if (!range || !editable.contains(range.startContainer)) { - return { node: null, nodeOffset: 0 } + return 0 } const node = range.startContainer @@ -110,7 +222,7 @@ const detectVoidArea = (editable: HTMLElement, { clientX, clientY }: Coordinates // If the node is empty (placeholder text), return caret position at offset 0 if (nodeTextLength === 0) { - return { node: node as Text, nodeOffset: 0 } + return 0 } /** Get the bounding rectangle for a character at the given offset. */ @@ -166,8 +278,7 @@ const detectVoidArea = (editable: HTMLElement, { clientX, clientY }: Coordinates const nearest = findNearestTextNode(textNodes, clientY) if (!nearest) return null - const nodeOffset = calculateHorizontalOffset(nearest.node, clientX, clientY) - return { node: nearest.node, nodeOffset } + return calculateHorizontalOffset(nearest.node, clientX, clientY) } -export default detectVoidArea +export default getNodeOffsetForVoidArea From 345ca24c123dcbc0c192dcc3a4e3b991cf7180a2 Mon Sep 17 00:00:00 2001 From: karunkop Date: Thu, 8 Jan 2026 12:26:12 +0545 Subject: [PATCH 12/12] adjusted offset calculation to consider trailing whitespaces which may affect the accuracy of caret positioning --- src/util/getNodeOffsetForVoidArea.ts | 90 +++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 9 deletions(-) diff --git a/src/util/getNodeOffsetForVoidArea.ts b/src/util/getNodeOffsetForVoidArea.ts index 57cce5a1d8c..91bb8fc8343 100644 --- a/src/util/getNodeOffsetForVoidArea.ts +++ b/src/util/getNodeOffsetForVoidArea.ts @@ -2,6 +2,7 @@ * Safari does glyph-only hit testing, so clicking on empty space has no caret target. * This module provides API for detecting void area positions and calculating appropriate caret positions. */ +import { isSafari } from '../browser' /** Represents a line within a text node with its character range and bounding box. */ interface TextNodeLine { @@ -100,12 +101,48 @@ const findClosestLine = (lines: TextNodeLine[], clientY: number): TextNodeLine = ).line } +/** + * Adjusts an offset to skip trailing whitespaces in a line. + * If the offset is in whitespace, moves it to after the last non-whitespace character in that line. + * + * @param text - The text content. + * @param offset - The calculated offset. + * @param lineStart - The start of the line containing the offset. + * @param lineEnd - The end of the line containing the offset. + * @returns The adjusted offset, skipping trailing whitespaces. + */ +const skipTrailingWhitespace = (text: string, offset: number, lineStart: number, lineEnd: number): number => { + const lineText = text.substring(lineStart, lineEnd) + const offsetInLine = offset - lineStart + const lastNonWhitespaceIndex = lineText.trimEnd().length + + // If offset is beyond the last non-whitespace character, place after it + if (offsetInLine > lastNonWhitespaceIndex) { + return lineStart + lastNonWhitespaceIndex + } + + /** If offset is in whitespace, find the last non-whitespace before it. */ + const isWhitespace = (char: string) => /\s/.test(char) + + if (isWhitespace(lineText[offsetInLine])) { + // Find last non-whitespace character before the offset using reduceRight + const charsBeforeOffset = Array.from(lineText.slice(0, offsetInLine)) + const lastNonWhitespaceIndex = charsBeforeOffset.reduceRight( + (foundIndex, char, index) => foundIndex ?? (!isWhitespace(char) ? index : null), + null, + ) + + // If found, place after it; otherwise place at start of line + return lastNonWhitespaceIndex !== null ? lineStart + lastNonWhitespaceIndex + 1 : lineStart + } + + return offset +} + /** * Resolves the horizontal character offset within a text node using binary search. * Handles both single-line and multi-line text nodes. - * - * For multi-line text, it first finds the closest line vertically, then performs - * binary search within that line. + * Skips trailing whitespaces when placing the caret. * * @param node - The text node to search within. * @param clientX - The X coordinate of the tap/click. @@ -118,17 +155,52 @@ const calculateHorizontalOffset = (node: Text, clientX: number, clientY: number) const lines = getTextNodeLines(node) + let offset: number + let lineStart: number + let lineEnd: number + // If the text node is a single line, find the offset directly if (lines.length <= 1) { - return findOffsetAtX(node, clientX, 0, text.length) + lineStart = 0 + lineEnd = text.length + offset = findOffsetAtX(node, clientX, lineStart, lineEnd) + } else { + // If the text node is multi-line, pick the closest line with respect to the Y coordinate + // and then find the offset within that line + const targetLine = findClosestLine(lines, clientY) + lineStart = targetLine.start + lineEnd = targetLine.end + offset = findOffsetAtX(node, clientX, lineStart, lineEnd) } - // If the text node is multi-line, pick the closest line with respect to the Y coordinate - // and then find the offset within that line - const targetLine = findClosestLine(lines, clientY) + // Safari-specific correction: Safari's getBoundingClientRect can be off by a few characters, so verify the result + if (isSafari() && offset >= lineStart && offset <= lineEnd) { + // Generate range of offsets to check (±2 characters) + const startOffset = Math.max(lineStart, offset - 2) + const endOffset = Math.min(lineEnd, offset + 2) + const offsetsToCheck = Array.from({ length: endOffset - startOffset + 1 }, (_, i) => startOffset + i) + + /** Get distance for each offset and find the one closest to clientX. */ + const getDistance = (checkOffset: number): number => { + const checkR = document.createRange() + checkR.setStart(node, checkOffset) + checkR.collapse(true) + const checkRect = checkR.getBoundingClientRect() + return Math.abs(clientX - checkRect.left) + } + + const { offset: bestOffset } = offsetsToCheck + .map(checkOffset => ({ offset: checkOffset, distance: getDistance(checkOffset) })) + .reduce((best, current) => (current.distance < best.distance ? current : best), { + offset, + distance: getDistance(offset), + }) + + offset = bestOffset + } - // Search only inside that line - return findOffsetAtX(node, clientX, targetLine.start, targetLine.end) + // Adjust offset to skip trailing whitespaces + return skipTrailingWhitespace(text, offset, lineStart, lineEnd) } /**