FormControlRange-geometry-multiline-and-mutations.html (9283B)
1 <!DOCTYPE html> 2 <meta charset="utf-8"> 3 <script src="/resources/testharness.js"></script> 4 <script src="/resources/testharnessreport.js"></script> 5 <body></body> 6 <script> 7 'use strict'; 8 const controls = ['input','textarea']; 9 10 function setupControl(control, value) { 11 document.body.innerHTML = control === 'input' ? '<input type="text" id="test">' 12 : '<textarea id="test"></textarea>'; 13 const element = document.getElementById('test'); 14 element.value = value; 15 element.focus(); 16 element.setSelectionRange(0, 0); 17 18 // Stabilize layout for cross-platform consistency. 19 element.style.fontFamily = 'monospace'; 20 element.style.fontSize = '16px'; 21 element.style.lineHeight = '20px'; 22 element.style.padding = '0'; 23 element.style.border = '0'; 24 element.style.margin = '8px'; 25 element.style.boxSizing = 'content-box'; 26 // Zero scroll offsets so client rects are relative to a known origin. 27 if ('scrollLeft' in element) element.scrollLeft = 0; 28 return element; 29 } 30 function setupFormControlRange(element, startOffset, endOffset){ 31 const range = new FormControlRange(); 32 range.setFormControlRange(element, startOffset, endOffset); 33 return range; 34 } 35 36 function assert_rect_inside(inner, outer, msg = '') { 37 const left_overflow = outer.left > inner.left ? outer.left - inner.left : 0; 38 const right_overflow = inner.right > outer.right ? inner.right - outer.right : 0; 39 assert_approx_equals(left_overflow, 0, 0.5, msg + 'left inside'); 40 assert_approx_equals(right_overflow, 0, 0.5, msg + 'right inside'); 41 } 42 43 function rect(range, element, label='') { 44 const r = range.getBoundingClientRect(); 45 assert_rect_inside(r, element.getBoundingClientRect(), label); 46 return r; 47 } 48 49 test(() => { 50 // Partial selection spanning a hard newline produces non-zero geometry and at least one client rect. 51 const textareaElement = setupControl('textarea', 'first line\nSECOND LINE\nthird'); 52 const firstLine = 'first line'; 53 const range = setupFormControlRange(textareaElement, firstLine.length - 4, firstLine.length + 5); 54 const boundingRect = rect(range, textareaElement, 'Bounding box inside: '); 55 assert_greater_than(boundingRect.width, 0); 56 assert_greater_than(boundingRect.height, 0); 57 assert_greater_than_equal(range.getClientRects().length, 1); 58 }, 'Partial hard-newline selection (textarea)'); 59 60 test(() => { 61 // Selection that spans a blank line should still produce non-zero geometry. 62 const textareaElement = setupControl('textarea', 'line1\n\nline3'); 63 const range = setupFormControlRange(textareaElement, 0, textareaElement.value.length); 64 const boundingRect = rect(range, textareaElement, 'Bounding box inside: '); 65 assert_greater_than(boundingRect.height, 0); 66 assert_greater_than(boundingRect.width, 0); 67 }, 'Selection spanning blank line (textarea)'); 68 69 test(() => { 70 // Soft-wrapped long logical line yields multiple client rects due to wrapping. 71 const textareaElement = setupControl('textarea', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 abcdefghijklmnopqrstuvwxyz'); 72 textareaElement.style.whiteSpace = 'pre-wrap'; 73 textareaElement.style.width = '160px'; 74 const range = setupFormControlRange(textareaElement, 0, textareaElement.value.length); 75 const boundingRect = rect(range, textareaElement, 'Bounding box inside: '); 76 assert_greater_than_equal(range.getClientRects().length, 2, 'soft wrap multiple rects'); 77 }, 'Soft-wrapped long single line (textarea)'); 78 79 test(() => { 80 // Caret before newline and start of next line have non-decreasing y and expected x ordering. 81 const value = '123456\n789012'; 82 const textareaElement = setupControl('textarea', value); 83 const caretBeforeNewlineRect = rect(setupFormControlRange(textareaElement, 6, 6), textareaElement, 'caret before newline inside: '); 84 const caretNextLineRect = rect(setupFormControlRange(textareaElement, 7, 7), textareaElement, 'caret next line inside: '); 85 // Allow caret before newline to be aligned or slightly (sub-pixel) to the right. 86 assert_greater_than_equal(caretBeforeNewlineRect.x + 0.5, caretNextLineRect.x, 'caret before newline at or to the right (epsilon) of next line start'); 87 assert_less_than_equal(caretBeforeNewlineRect.y, caretNextLineRect.y, 'next line lower or equal y'); 88 }, 'Collapsed caret across newline parity (textarea)'); 89 90 test(() => { 91 // Live insert/delete operations adjust selection left/width as expected (grow/shift/shrink). 92 const inputElement = setupControl('input', 'ABCDE'); 93 const range = setupFormControlRange(inputElement, 1, 3); 94 95 const leftBeforeInsertion = rect(range, inputElement).left; 96 inputElement.setRangeText('ZZ', 0, 0); 97 assert_greater_than(rect(range, inputElement).left, leftBeforeInsertion, 'insert before shifts right'); 98 99 const widthBeforeInteriorInsertion = rect(range, inputElement).width; 100 inputElement.setRangeText('QQ', range.startOffset, range.startOffset); 101 assert_greater_than(rect(range, inputElement).width, widthBeforeInteriorInsertion, 'insert inside expands width'); 102 103 const leftBeforeAppend = rect(range, inputElement).left; 104 inputElement.setRangeText('TT', inputElement.value.length, inputElement.value.length); 105 assert_approx_equals(rect(range, inputElement).left, leftBeforeAppend, 0.5, 'append does not shift left edge'); 106 107 const leftBeforeDeletion = rect(range, inputElement).left; 108 inputElement.setRangeText('', 0, 2); 109 assert_less_than(rect(range, inputElement).left, leftBeforeDeletion, 'delete before shifts left'); 110 111 const widthBeforeOverlapDeletion = rect(range, inputElement).width; 112 inputElement.setRangeText('', range.startOffset - 1, range.startOffset + 1); 113 assert_less_than(rect(range, inputElement).width, widthBeforeOverlapDeletion, 'overlap delete shrinks width'); 114 }, 'Input live insertion/deletion adjustments'); 115 116 test(() => { 117 // Interior deletion shrinks width; insertion before selection shifts selection right in textarea. 118 const textareaElement = setupControl('textarea', 'ABCDE'); 119 const range = setupFormControlRange(textareaElement, 1, 4); 120 const widthBeforeDeletion = rect(range, textareaElement).width; 121 textareaElement.setRangeText('', 2, 3); 122 assert_less_than(rect(range, textareaElement).width, widthBeforeDeletion, 'textarea interior deletion shrinks width'); 123 const leftBeforeInsertion = rect(range, textareaElement).left; 124 textareaElement.setRangeText('ZZ', 0, 0); 125 assert_greater_than(rect(range, textareaElement).left, leftBeforeInsertion, 'textarea insertion before shifts right'); 126 }, 'Textarea interior deletion & insertion adjustments'); 127 128 controls.forEach(controlType => { 129 test(() => { 130 // Inserting text inside selection increases its width. 131 const element = setupControl(controlType, 'ABCDE'); 132 const range = setupFormControlRange(element, 1, 4); 133 const widthBeforeInsertion = rect(range, element).width; 134 element.setRangeText('ZZ', 2, 2); 135 assert_greater_than(rect(range, element).width, widthBeforeInsertion, 'insertion inside expands width'); 136 }, `Insertion inside expands width (${controlType})`); 137 138 test(() => { 139 // Deleting full selection collapses to a caret. 140 const element = setupControl(controlType, 'ABCDE'); 141 const range = setupFormControlRange(element, 1, 4); 142 assert_greater_than(rect(range, element).width, 0, 'pre width greater than 0'); 143 element.setRangeText('', 1, 4); 144 assert_true(range.collapsed, 'collapsed after deletion'); 145 assert_equals(range.getClientRects().length, 0, 'no client rects'); 146 assert_approx_equals(rect(range, element).width, 0, 0.05, 'caret width should be 0'); 147 }, `Deletion collapses geometry (${controlType})`); 148 149 test(() => { 150 // Shrink near end clamps range end; subsequent insertion at end does not auto-extend. 151 const element = setupControl(controlType, 'HelloWorld'); 152 const range = setupFormControlRange(element, 0, element.value.length); 153 element.setRangeText('', 7, 10); 154 const endAfterShrink = range.endOffset; 155 assert_equals(endAfterShrink, element.value.length, 'end clamped after shrink'); 156 element.setRangeText('XYZ', 7, 7); 157 assert_equals(range.endOffset, endAfterShrink, 'end remains stable after grow at end (does not auto-extend)'); 158 }, `Shrink/grow end offset clamping (${controlType})`); 159 160 test(() => { 161 // Tail deletion clamps end offset while remaining selection geometry stays non-zero. 162 const element = setupControl(controlType, 'HelloWorld'); 163 const range = setupFormControlRange(element, 0, element.value.length); 164 element.setRangeText('', 5, 10); 165 assert_equals(element.value, 'Hello', 'value shrunk to Hello'); 166 assert_equals(range.endOffset, element.value.length, 'end offset clamped to new value length'); 167 const rectBox = rect(range, element); 168 assert_greater_than(rectBox.width, 0, 'geometry still non-zero after tail deletion'); 169 }, `Tail deletion clamps end offset (${controlType})`); 170 }); 171 172 test(() => { 173 // Deleting selected text collapses to a caret with zero width. 174 const inputElement = setupControl('input', 'ABCDE'); 175 const range = setupFormControlRange(inputElement, 1, 4); 176 inputElement.setRangeText('', 1, 4); 177 assert_true(range.collapsed, 'collapsed after deletion'); 178 assert_approx_equals(rect(range, inputElement).width, 0, 0.05, 'width should be 0'); 179 }, 'Deletion collapses selection (input explicit)'); 180 </script>