tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 5831bb69857a0be70d94c8d8ebc799215139f350
parent b940f221750f936aebd3b856ccc10c2aba39bc94
Author: Stephanie Y Zhang <stephanie.zhang@microsoft.com>
Date:   Fri,  7 Nov 2025 09:02:31 +0000

Bug 1998603 [wpt PR 55901] - [FormControlRange] Add getClientRects/getBoundingClientRect, a=testonly

Automatic update from web-platform-tests
[FormControlRange] Add getClientRects/getBoundingClientRect

Add geometry methods for FormControlRange that mirror DOM Range behavior
but applied to value space/text for <input>/<textarea>.

Behavior:
- getClientRects(): returns rects for the selected value; collapsed,
disconnected, or display:none returns an empty list.
- getBoundingClientRect(): returns the union of the client rects.
For a caret/collapsed range, it returns a zero-width box with the line
height.

The implementation forces style and layout update, locates the single
inner editor text node, clamps offsets to the current value, and builds
a temporary DOM Range over that node to obtain exact geometry. This
preserves bidirectional ordering, soft wrapping, and other range
dependent layout effects.

Explainer: github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/FormControlRange/explainer.md

Low-Coverage-Reason: COVERAGE_UNDERREPORTED
Bug: 421421332
Change-Id: I2fb217f56f1633f4b174c760c8c78e2190a47047
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7013433
Reviewed-by: Mason Freed <masonf@chromium.org>
Commit-Queue: Ana Sollano Kim <ansollan@microsoft.com>
Reviewed-by: Ana Sollano Kim <ansollan@microsoft.com>
Cr-Commit-Position: refs/heads/main@{#1540993}

--

wpt-commits: 34929bf8385e51ee43917608c9661d1260bd0e1e
wpt-pr: 55901

Diffstat:
Atesting/web-platform/tests/dom/ranges/tentative/FormControlRange-geometry-basic.html | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atesting/web-platform/tests/dom/ranges/tentative/FormControlRange-geometry-complexity-and-visibility.html | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atesting/web-platform/tests/dom/ranges/tentative/FormControlRange-geometry-multiline-and-mutations.html | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 406 insertions(+), 0 deletions(-)

diff --git a/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-geometry-basic.html b/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-geometry-basic.html @@ -0,0 +1,130 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<body></body> +<script> +'use strict'; +const controls = ['input','textarea']; + +function setupControl(control, value) { + document.body.innerHTML = control === 'input' ? '<input type="text" id="test">' + : '<textarea id="test"></textarea>'; + const element = document.getElementById('test'); + element.value = value; + element.focus(); + // Stabilize layout. + element.style.fontFamily = 'monospace'; + element.style.fontSize = '16px'; + element.style.lineHeight = '20px'; + element.style.padding = '0'; + element.style.border = '0'; + element.style.margin = '8px'; + element.style.boxSizing = 'content-box'; + + // Zero scroll offsets so client rects are relative to a known origin. + if ('scrollTop' in element) element.scrollTop = 0; + if ('scrollLeft' in element) element.scrollLeft = 0; + return element; +} +function setupFormControlRange(element, startOffset, endOffset){ + const range = new FormControlRange(); + range.setFormControlRange(element, startOffset, endOffset); + return range; +} + +function assert_rect_inside(inner, outer, msg = '') { + const rounding = 0.5; // sub-pixel fuzz + assert_greater_than_equal(inner.left + rounding, outer.left, msg + 'left inside'); + assert_less_than_equal(inner.right - rounding, outer.right, msg + 'right inside'); +} + +controls.forEach(controlType => { + test(() => { + // Collapsed range (caret): no client rects; bounding rect is a caret box + // (zero width, non-zero height) positioned within the control. + const element = setupControl(controlType, 'Hello'); + const range = setupFormControlRange(element, 2, 2); + const caretRect = range.getBoundingClientRect(); + assert_equals(range.getClientRects().length, 0, 'collapsed: no client rects'); + assert_approx_equals(caretRect.width, 0, 0.05, 'caret width should be 0'); + assert_greater_than(caretRect.height, 0, 'caret height greater than 0'); + assert_rect_inside(caretRect, element.getBoundingClientRect(), 'caret inside '); + }, `Collapsed caret geometry (${controlType})`); + + test(() => { + // Non-collapsed selection: non-zero geometry, all rects contained in control. + const element = setupControl(controlType, 'ABCDE'); + const range = setupFormControlRange(element, 1, 4); + const boundingRect = range.getBoundingClientRect(); + const clientRects = Array.from(range.getClientRects()); + assert_greater_than(boundingRect.width, 0, 'selection width greater than 0'); + assert_greater_than(boundingRect.height, 0, 'selection height greater than 0'); + assert_rect_inside(boundingRect, element.getBoundingClientRect()); + assert_greater_than_equal(clientRects.length, 1); + clientRects.forEach((clientRect, index) => + assert_rect_inside(clientRect, element.getBoundingClientRect(), 'rect[' + index + '] ') + ); + }, `Simple selection geometry (${controlType})`); + + test(() => { + // If the control is removed from the DOM, geometry should be empty. + const element = setupControl(controlType, 'ABCDE'); + const range = setupFormControlRange(element, 0, element.value.length); + assert_greater_than(range.getBoundingClientRect().width,0,'pre removal width greater than 0'); + element.remove(); + const boundingRect = range.getBoundingClientRect(); + assert_approx_equals(boundingRect.width, 0, 0.05, 'width should be 0 after removal'); + assert_equals(range.getClientRects().length, 0); + }, `Geometry empty after control removal (${controlType})`); + + test(() => { + const value = 'ABCDE'; + const element = setupControl(controlType, value); + [0, Math.floor(value.length / 2), value.length].forEach(position => { + const range = setupFormControlRange(element, position, position); + const caretRect = range.getBoundingClientRect(); + assert_equals(range.getClientRects().length, 0, 'collapsed caret has no rects'); + assert_approx_equals(caretRect.width, 0, 0.05, 'caret width should be 0'); + assert_greater_than(caretRect.height, 0, 'caret height greater than 0'); + }); + }, `Collapsed caret at start/middle/end (${controlType})`); + + test(() => { + // Full selection should yield a non-zero bounding box inside the control. + // (For textarea, the string includes a hard newline to exercise multi-line.) + const value = controlType === 'textarea' ? 'First line\nSecond line' : 'ABCDE'; + const element = setupControl(controlType, value); + const range = setupFormControlRange(element, 0, element.value.length); + const boundingRect = range.getBoundingClientRect(); + assert_greater_than(boundingRect.width, 0, 'full width greater than 0'); + assert_greater_than(boundingRect.height, 0, 'full height greater than 0'); + assert_rect_inside(boundingRect, element.getBoundingClientRect(), 'full selection inside'); + }, `Full selection bounding box inside element (${controlType})`); + + test(() => { + // Backwards offsets are auto-collapsed by setFormControlRange; caret geometry applies. + document.body.innerHTML = controlType === 'input' ? '<input type="text" value="Test">' + : '<textarea>Test</textarea>'; + const element = document.body.firstElementChild; const range = new FormControlRange(); + range.setFormControlRange(element, 3, 1); + assert_true(range.collapsed, 'collapsed'); + const caretRect = range.getBoundingClientRect(); + assert_approx_equals(caretRect.width, 0, 0.05, 'caret width should be 0'); + assert_greater_than(caretRect.height, 0, 'caret height greater than 0'); + }, `Backwards offsets collapse (${controlType})`); + + test(() => { + // Elements with display:none have no rendered geometry; ranges report empty rects. + document.body.innerHTML = controlType === 'input' + ? '<input type="text" id="displayNone" style="display:none" value="hidden">' + : '<textarea id="displayNone" style="display:none">hidden</textarea>'; + const element = document.getElementById('displayNone'); + const range = setupFormControlRange(element, 0, element.value.length); + const boundingRect = range.getBoundingClientRect(); + assert_approx_equals(boundingRect.width, 0, 0.05, 'width should be 0 (display:none)'); + assert_approx_equals(boundingRect.height, 0, 0.05, 'height should be 0 (display:none)'); + assert_equals(range.getClientRects().length, 0); + }, `display:none empty geometry (${controlType})`); +}); +</script> +\ No newline at end of file diff --git a/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-geometry-complexity-and-visibility.html b/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-geometry-complexity-and-visibility.html @@ -0,0 +1,93 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<body></body> +<script> +'use strict'; +const controls = ['input','textarea']; + +function setupControl(control, value) { + document.body.innerHTML = control === 'input' + ? '<input type="text" id="test">' + : '<textarea id="test"></textarea>'; + const element = document.getElementById('test'); + element.value = value; + element.focus(); + // Stabilize layout. + element.style.fontFamily = 'monospace'; + element.style.fontSize = '16px'; + element.style.lineHeight = '20px'; + element.style.padding = '0'; + element.style.border = '0'; + element.style.margin = '8px'; + element.style.boxSizing = 'content-box'; + + // Zero scroll offsets so client rects are relative to a known origin. + if ('scrollTop' in element) element.scrollTop = 0; + if ('scrollLeft' in element) element.scrollLeft = 0; + return element; +} +function assert_rect_inside(inner, outer, msg = '') { + const rounding = 0.5; // sub-pixel fuzz. + assert_greater_than_equal(inner.left + rounding, outer.left, msg + 'left inside'); + assert_less_than_equal(inner.right - rounding, outer.right, msg + 'right inside'); +} + +function rect(range, element, label='') { + const r = range.getBoundingClientRect(); + assert_rect_inside(r, element.getBoundingClientRect(), label); + return r; +} +function setupFormControlRange(element, startOffset, endOffset) { + const range = new FormControlRange(); + range.setFormControlRange(element, startOffset, endOffset); + return range; +} + +controls.forEach(controlType => { + // Bidirectional text: a full selection spanning LTR and RTL text + // should produce a non-zero bounding box within the control. + test(() => { + const mixed = 'abc \u0645\u0631\u062D\u0628\u0627 def'; // abc مرحبا def. + const element = setupControl(controlType, mixed); + const range = setupFormControlRange(element, 0, mixed.length); + const boundingRect = rect(range, element, 'Bidirectional text full selection inside: '); + assert_greater_than(boundingRect.width, 0); + assert_greater_than(boundingRect.height, 0); + }, `Bidirectional mixed text full selection geometry (${controlType})`); + + test(() => { + // Surrogate pair handling: geometry should not split a single emoji. + // Check that a full pair has width, and a single code unit slice + // does not produce a larger width than the full pair. + const value = '\ud83c\udf20A\ud83c\udf20'; // 🌠A🌠 in UTF-16. + const element = setupControl(controlType, value); + + const fullRects = Array.from(setupFormControlRange(element, 0, value.length).getClientRects()); + assert_greater_than_equal(fullRects.length, 1, 'full selection has rects'); + + const fullWidth = fullRects[0].width; + const wHalf1 = Array.from(setupFormControlRange(element, 0, 1).getClientRects())[0]?.width ?? 0; + const wHalf2 = Array.from(setupFormControlRange(element, 1, 2).getClientRects())[0]?.width ?? 0; + const wPair = Array.from(setupFormControlRange(element, 0, 2).getClientRects())[0]?.width ?? 0; + + assert_greater_than(wPair, 0, 'pair width greater than 0'); + assert_greater_than_equal(wPair + 0.05, Math.max(wHalf1, wHalf2), 'full pair width greater than or equal to any single code-unit slice'); + assert_greater_than(fullWidth, 0, 'full width greater than 0'); + }, `Surrogate pair width normalization invariants (${controlType})`); + + test(() => { + // visibility:hidden controls still produce a caret box with zero width. + document.body.innerHTML = controlType === 'input' + ? '<input id="test" style="visibility:hidden" value="abc">' + : '<textarea id="test" style="visibility:hidden">abc</textarea>'; + const hidden = document.getElementById('test'); + hidden.focus(); + const range = setupFormControlRange(hidden, 2, 2); + const caretRect = rect(range, hidden, 'hidden caret inside: '); + assert_greater_than(caretRect.height, 0, 'caret height greater than 0'); + assert_approx_equals(caretRect.width, 0, 0.05, 'caret width should be 0'); + }, `visibility:hidden caret geometry (${controlType})`); +}); +</script> +\ No newline at end of file diff --git a/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-geometry-multiline-and-mutations.html b/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-geometry-multiline-and-mutations.html @@ -0,0 +1,180 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<body></body> +<script> +'use strict'; +const controls = ['input','textarea']; + +function setupControl(control, value) { + document.body.innerHTML = control === 'input' ? '<input type="text" id="test">' + : '<textarea id="test"></textarea>'; + const element = document.getElementById('test'); + element.value = value; + element.focus(); + element.setSelectionRange(0, 0); + + // Stabilize layout for cross-platform consistency. + element.style.fontFamily = 'monospace'; + element.style.fontSize = '16px'; + element.style.lineHeight = '20px'; + element.style.padding = '0'; + element.style.border = '0'; + element.style.margin = '8px'; + element.style.boxSizing = 'content-box'; + // Zero scroll offsets so client rects are relative to a known origin. + if ('scrollLeft' in element) element.scrollLeft = 0; + return element; +} +function setupFormControlRange(element, startOffset, endOffset){ + const range = new FormControlRange(); + range.setFormControlRange(element, startOffset, endOffset); + return range; +} + +function assert_rect_inside(inner, outer, msg = '') { + const left_overflow = outer.left > inner.left ? outer.left - inner.left : 0; + const right_overflow = inner.right > outer.right ? inner.right - outer.right : 0; + assert_approx_equals(left_overflow, 0, 0.5, msg + 'left inside'); + assert_approx_equals(right_overflow, 0, 0.5, msg + 'right inside'); +} + +function rect(range, element, label='') { + const r = range.getBoundingClientRect(); + assert_rect_inside(r, element.getBoundingClientRect(), label); + return r; +} + +test(() => { + // Partial selection spanning a hard newline produces non-zero geometry and at least one client rect. + const textareaElement = setupControl('textarea', 'first line\nSECOND LINE\nthird'); + const firstLine = 'first line'; + const range = setupFormControlRange(textareaElement, firstLine.length - 4, firstLine.length + 5); + const boundingRect = rect(range, textareaElement, 'Bounding box inside: '); + assert_greater_than(boundingRect.width, 0); + assert_greater_than(boundingRect.height, 0); + assert_greater_than_equal(range.getClientRects().length, 1); +}, 'Partial hard-newline selection (textarea)'); + +test(() => { + // Selection that spans a blank line should still produce non-zero geometry. + const textareaElement = setupControl('textarea', 'line1\n\nline3'); + const range = setupFormControlRange(textareaElement, 0, textareaElement.value.length); + const boundingRect = rect(range, textareaElement, 'Bounding box inside: '); + assert_greater_than(boundingRect.height, 0); + assert_greater_than(boundingRect.width, 0); +}, 'Selection spanning blank line (textarea)'); + +test(() => { + // Soft-wrapped long logical line yields multiple client rects due to wrapping. + const textareaElement = setupControl('textarea', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 abcdefghijklmnopqrstuvwxyz'); + textareaElement.style.whiteSpace = 'pre-wrap'; + textareaElement.style.width = '160px'; + const range = setupFormControlRange(textareaElement, 0, textareaElement.value.length); + const boundingRect = rect(range, textareaElement, 'Bounding box inside: '); + assert_greater_than_equal(range.getClientRects().length, 2, 'soft wrap multiple rects'); +}, 'Soft-wrapped long single line (textarea)'); + +test(() => { + // Caret before newline and start of next line have non-decreasing y and expected x ordering. + const value = '123456\n789012'; + const textareaElement = setupControl('textarea', value); + const caretBeforeNewlineRect = rect(setupFormControlRange(textareaElement, 6, 6), textareaElement, 'caret before newline inside: '); + const caretNextLineRect = rect(setupFormControlRange(textareaElement, 7, 7), textareaElement, 'caret next line inside: '); + // Allow caret before newline to be aligned or slightly (sub-pixel) to the right. + assert_greater_than_equal(caretBeforeNewlineRect.x + 0.5, caretNextLineRect.x, 'caret before newline at or to the right (epsilon) of next line start'); + assert_less_than_equal(caretBeforeNewlineRect.y, caretNextLineRect.y, 'next line lower or equal y'); +}, 'Collapsed caret across newline parity (textarea)'); + +test(() => { + // Live insert/delete operations adjust selection left/width as expected (grow/shift/shrink). + const inputElement = setupControl('input', 'ABCDE'); + const range = setupFormControlRange(inputElement, 1, 3); + + const leftBeforeInsertion = rect(range, inputElement).left; + inputElement.setRangeText('ZZ', 0, 0); + assert_greater_than(rect(range, inputElement).left, leftBeforeInsertion, 'insert before shifts right'); + + const widthBeforeInteriorInsertion = rect(range, inputElement).width; + inputElement.setRangeText('QQ', range.startOffset, range.startOffset); + assert_greater_than(rect(range, inputElement).width, widthBeforeInteriorInsertion, 'insert inside expands width'); + + const leftBeforeAppend = rect(range, inputElement).left; + inputElement.setRangeText('TT', inputElement.value.length, inputElement.value.length); + assert_approx_equals(rect(range, inputElement).left, leftBeforeAppend, 0.5, 'append does not shift left edge'); + + const leftBeforeDeletion = rect(range, inputElement).left; + inputElement.setRangeText('', 0, 2); + assert_less_than(rect(range, inputElement).left, leftBeforeDeletion, 'delete before shifts left'); + + const widthBeforeOverlapDeletion = rect(range, inputElement).width; + inputElement.setRangeText('', range.startOffset - 1, range.startOffset + 1); + assert_less_than(rect(range, inputElement).width, widthBeforeOverlapDeletion, 'overlap delete shrinks width'); +}, 'Input live insertion/deletion adjustments'); + +test(() => { + // Interior deletion shrinks width; insertion before selection shifts selection right in textarea. + const textareaElement = setupControl('textarea', 'ABCDE'); + const range = setupFormControlRange(textareaElement, 1, 4); + const widthBeforeDeletion = rect(range, textareaElement).width; + textareaElement.setRangeText('', 2, 3); + assert_less_than(rect(range, textareaElement).width, widthBeforeDeletion, 'textarea interior deletion shrinks width'); + const leftBeforeInsertion = rect(range, textareaElement).left; + textareaElement.setRangeText('ZZ', 0, 0); + assert_greater_than(rect(range, textareaElement).left, leftBeforeInsertion, 'textarea insertion before shifts right'); +}, 'Textarea interior deletion & insertion adjustments'); + +controls.forEach(controlType => { + test(() => { + // Inserting text inside selection increases its width. + const element = setupControl(controlType, 'ABCDE'); + const range = setupFormControlRange(element, 1, 4); + const widthBeforeInsertion = rect(range, element).width; + element.setRangeText('ZZ', 2, 2); + assert_greater_than(rect(range, element).width, widthBeforeInsertion, 'insertion inside expands width'); + }, `Insertion inside expands width (${controlType})`); + + test(() => { + // Deleting full selection collapses to a caret. + const element = setupControl(controlType, 'ABCDE'); + const range = setupFormControlRange(element, 1, 4); + assert_greater_than(rect(range, element).width, 0, 'pre width greater than 0'); + element.setRangeText('', 1, 4); + assert_true(range.collapsed, 'collapsed after deletion'); + assert_equals(range.getClientRects().length, 0, 'no client rects'); + assert_approx_equals(rect(range, element).width, 0, 0.05, 'caret width should be 0'); + }, `Deletion collapses geometry (${controlType})`); + + test(() => { + // Shrink near end clamps range end; subsequent insertion at end does not auto-extend. + const element = setupControl(controlType, 'HelloWorld'); + const range = setupFormControlRange(element, 0, element.value.length); + element.setRangeText('', 7, 10); + const endAfterShrink = range.endOffset; + assert_equals(endAfterShrink, element.value.length, 'end clamped after shrink'); + element.setRangeText('XYZ', 7, 7); + assert_equals(range.endOffset, endAfterShrink, 'end remains stable after grow at end (does not auto-extend)'); + }, `Shrink/grow end offset clamping (${controlType})`); + + test(() => { + // Tail deletion clamps end offset while remaining selection geometry stays non-zero. + const element = setupControl(controlType, 'HelloWorld'); + const range = setupFormControlRange(element, 0, element.value.length); + element.setRangeText('', 5, 10); + assert_equals(element.value, 'Hello', 'value shrunk to Hello'); + assert_equals(range.endOffset, element.value.length, 'end offset clamped to new value length'); + const rectBox = rect(range, element); + assert_greater_than(rectBox.width, 0, 'geometry still non-zero after tail deletion'); + }, `Tail deletion clamps end offset (${controlType})`); +}); + +test(() => { + // Deleting selected text collapses to a caret with zero width. + const inputElement = setupControl('input', 'ABCDE'); + const range = setupFormControlRange(inputElement, 1, 4); + inputElement.setRangeText('', 1, 4); + assert_true(range.collapsed, 'collapsed after deletion'); + assert_approx_equals(rect(range, inputElement).width, 0, 0.05, 'width should be 0'); +}, 'Deletion collapses selection (input explicit)'); +</script> +\ No newline at end of file