browser_caret_rect.js (18342B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 function getCaretRect(docAcc, id) { 8 const acc = findAccessibleChildByID(docAcc, id, [nsIAccessibleText]); 9 const caretX = {}; 10 const caretY = {}; 11 const caretW = {}; 12 const caretH = {}; 13 acc.getCaretRect(caretX, caretY, caretW, caretH); 14 15 const caretBounds = [caretX.value, caretY.value, caretW.value, caretH.value]; 16 17 info(`Caret bounds: ${caretBounds}`); 18 19 return caretBounds; 20 } 21 22 function getCollapsedRangeExtents(acc, offset) { 23 const rangeX = {}; 24 const rangeY = {}; 25 const rangeW = {}; 26 const rangeH = {}; 27 acc.getRangeExtents( 28 offset, 29 offset, 30 rangeX, 31 rangeY, 32 rangeW, 33 rangeH, 34 COORDTYPE_SCREEN_RELATIVE 35 ); 36 37 const rangeBounds = [rangeX.value, rangeY.value, rangeW.value, rangeH.value]; 38 39 info(`Range ${offset}-${offset} bounds: ${rangeBounds}`); 40 41 return rangeBounds; 42 } 43 44 async function fetchCollapsedRangeBounds(docAcc, acc) { 45 const state = {}; 46 acc.getState(state, {}); 47 if (state.value & nsIAccessibleStates.STATE_FOCUSABLE) { 48 // This pre-scrolls the accessible into view as a focus would do. 49 let focused = waitForEvent(EVENT_FOCUS, acc); 50 acc.takeFocus(); 51 await focused; 52 53 // We need to blur the accessible so the caret does not interfere with 54 // the bounds fetch. 55 focused = waitForEvent(EVENT_FOCUS, docAcc); 56 docAcc.takeFocus(); 57 await focused; 58 } 59 60 acc.QueryInterface(nsIAccessibleHyperText); 61 62 // If this is a 0 length text accessible, we need to ensure that 63 // characterCount is at least 1. 64 const characterCount = acc.characterCount ? acc.characterCount : 1; 65 const bounds = []; 66 for (let offset = 0; offset < characterCount; offset++) { 67 let linkIndex = acc.getLinkIndexAtOffset(offset); 68 if (linkIndex != -1) { 69 const link = acc 70 .getLinkAt(linkIndex) 71 .QueryInterface(nsIAccessibleText) 72 .QueryInterface(nsIAccessible); 73 bounds.push(...(await fetchCollapsedRangeBounds(docAcc, link))); 74 } else { 75 let rect = getCollapsedRangeExtents(acc, offset); 76 bounds.push(rect); 77 } 78 } 79 80 return bounds; 81 } 82 83 function testCaretRect( 84 docAcc, 85 id, 86 offset, 87 fetchedBounds, 88 atLineEnd = false, 89 isVertical = false 90 ) { 91 const acc = findAccessibleChildByID(docAcc, id, [nsIAccessibleText]); 92 is(acc.caretOffset, offset, `Caret at offset ${offset}`); 93 const atEnd = offset == acc.characterCount; 94 const empty = offset == 0 && atEnd; 95 let queryOffset = atEnd && !empty ? offset - 1 : offset; 96 const atEndInNewLine = atEnd && acc.getCharacterAtOffset(queryOffset) == "\n"; 97 98 const [rangeX, rangeY, , rangeH] = 99 fetchedBounds.length > queryOffset 100 ? fetchedBounds[queryOffset] 101 : [0, 0, 0, 0]; 102 103 const [caretX, caretY, caretW, caretH] = getCaretRect(docAcc, id); 104 105 if (!empty) { 106 // In case of an empty input `getRangeExtents()` will return the full accessible bounds. 107 let [currRangeX, currRangeY, currRangeW] = getCollapsedRangeExtents( 108 acc, 109 acc.caretOffset 110 ); 111 Assert.deepEqual( 112 [caretX, caretY, caretW], 113 [currRangeX, currRangeY, currRangeW], 114 "Collapsed range extents at caret position should be identical caret rect" 115 ); 116 } 117 118 if (atEndInNewLine) { 119 Assert.lessOrEqual(caretX, rangeX, "Caret x before range x"); 120 } else if (atEnd || atLineEnd) { 121 Assert.greater(caretX, rangeX, "Caret x after last range x"); 122 } else { 123 // Caret width changes depending on device pixel ratio. In RTL 124 // text that would change the x where the caret is drawn by a pixel or two. 125 isWithin(caretX, rangeX, 3, "Caret x similar to range x"); 126 } 127 128 if (isVertical && atEnd) { 129 Assert.greaterOrEqual(caretY, rangeY, "Caret y below range y"); 130 } else if (atEndInNewLine) { 131 Assert.greater(caretY, rangeY, "Caret y below range y"); 132 } else if (atLineEnd) { 133 Assert.less(caretY, rangeY, "Caret y above start line range."); 134 } else { 135 isWithin(caretY, rangeY, 3, "Caret y similar to range y"); 136 } 137 138 ok(caretW, "Caret width is greater than 0"); 139 140 if (!empty) { 141 // Depending on glyph, the range can be taller. 142 isWithin(caretH, rangeH, 2, "Caret height similar to range height"); 143 } 144 } 145 146 function getAccBounds(acc) { 147 const x = {}; 148 const y = {}; 149 const w = {}; 150 const h = {}; 151 acc.getBounds(x, y, w, h); 152 return [x.value, y.value, w.value, h.value]; 153 } 154 155 /** 156 * Test the caret rect in content documents. 157 */ 158 addAccessibleTask( 159 ` 160 <input id="input" value="ab"> 161 <input id="emptyInput"> 162 `, 163 async function (browser, docAcc) { 164 async function runTests() { 165 const input = findAccessibleChildByID(docAcc, "input", [ 166 nsIAccessibleText, 167 ]); 168 169 let fetchedBounds = await fetchCollapsedRangeBounds(docAcc, input); 170 171 info("Focusing input"); 172 let caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, input); 173 input.takeFocus(); 174 await caretMoved; 175 testCaretRect(docAcc, "input", 0, fetchedBounds); 176 info("Setting caretOffset to 1"); 177 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, input); 178 input.caretOffset = 1; 179 await caretMoved; 180 testCaretRect(docAcc, "input", 1, fetchedBounds); 181 info("Setting caretOffset to 2"); 182 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, input); 183 input.caretOffset = 2; 184 await caretMoved; 185 testCaretRect(docAcc, "input", 2, fetchedBounds); 186 info("Resetting caretOffset to 0"); 187 input.caretOffset = 0; 188 189 const emptyInput = findAccessibleChildByID(docAcc, "emptyInput", [ 190 nsIAccessibleText, 191 ]); 192 193 fetchedBounds = await fetchCollapsedRangeBounds(docAcc, emptyInput); 194 195 info("Focusing emptyInput"); 196 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, emptyInput); 197 emptyInput.takeFocus(); 198 await caretMoved; 199 testCaretRect(docAcc, "emptyInput", 0, fetchedBounds); 200 } 201 202 await runTests(); 203 204 // Check that the caret rect is correct when the title bar is shown. 205 if (LINUX || Services.env.get("MOZ_HEADLESS")) { 206 // Disabling tabs in title bar doesn't change the bounds on Linux or in 207 // headless mode. 208 info("Skipping title bar tests"); 209 return; 210 } 211 const [, origDocY] = getAccBounds(docAcc); 212 info("Showing title bar"); 213 let titleBarChanged = BrowserTestUtils.waitForMutationCondition( 214 document.documentElement, 215 { attributes: true, attributeFilter: ["customtitlebar"] }, 216 () => !document.documentElement.hasAttribute("customtitlebar") 217 ); 218 await SpecialPowers.pushPrefEnv({ 219 set: [["browser.tabs.inTitlebar", false]], 220 }); 221 await titleBarChanged; 222 const [, newDocY] = getAccBounds(docAcc); 223 Assert.greater( 224 newDocY, 225 origDocY, 226 "Doc has larger y after title bar change" 227 ); 228 await runTests(); 229 await SpecialPowers.popPrefEnv(); 230 }, 231 { chrome: true, topLevel: true } 232 ); 233 234 /** 235 * Test the caret rect in multiline content. 236 */ 237 addAccessibleTask( 238 `<style> 239 @font-face { 240 font-family: Ahem; 241 src: url(${CURRENT_CONTENT_DIR}e10s/fonts/Ahem.sjs); 242 } 243 textarea { 244 font: 10px/10px Ahem; 245 width: 30px; 246 height: 80px; 247 } 248 </style> 249 <textarea id="textarea">1234 250 56789 251 </textarea> 252 `, 253 async function testMultiline(browser, docAcc) { 254 async function moveCaret(key, keyopts = {}) { 255 let caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, "textarea"); 256 if (key) { 257 EventUtils.synthesizeKey(key, keyopts); 258 } else { 259 // If no key is provided, just focus the textarea. 260 findAccessibleChildByID(docAcc, "textarea").takeFocus(); 261 } 262 263 let evt = await caretMoved; 264 evt.QueryInterface(nsIAccessibleCaretMoveEvent); 265 return [evt.caretOffset, evt.isAtEndOfLine]; 266 } 267 268 const textarea = findAccessibleChildByID(docAcc, "textarea", [ 269 nsIAccessibleText, 270 ]); 271 let fetchedBounds = await fetchCollapsedRangeBounds(docAcc, textarea); 272 273 info("Focusing textarea"); 274 let [offset, isAtLineEnd] = await moveCaret(); 275 is(offset, 0, "Caret at offset 0"); 276 is(isAtLineEnd, false, "Caret not at end of line"); 277 testCaretRect(docAcc, "textarea", offset, fetchedBounds, isAtLineEnd); 278 279 info("Moving caret right"); 280 [offset, isAtLineEnd] = await moveCaret("KEY_ArrowRight"); 281 is(offset, 1, "Caret at offset 1"); 282 is(isAtLineEnd, false, "Caret not at end of line"); 283 testCaretRect(docAcc, "textarea", offset, fetchedBounds, isAtLineEnd); 284 285 info("Moving caret right again"); 286 [offset, isAtLineEnd] = await moveCaret("KEY_ArrowRight"); 287 is(offset, 2, "Caret at offset 2"); 288 is(isAtLineEnd, false, "Caret not at end of line"); 289 testCaretRect(docAcc, "textarea", offset, fetchedBounds, isAtLineEnd); 290 291 info("Moving caret right again again"); 292 [offset, isAtLineEnd] = await moveCaret("KEY_ArrowRight"); 293 is(offset, 3, "Caret at offset 3"); 294 is(isAtLineEnd, true, "Caret at end of line"); 295 testCaretRect(docAcc, "textarea", offset, fetchedBounds, isAtLineEnd); 296 297 info("Moving caret right stays at same offset, but on new line"); 298 [offset, isAtLineEnd] = await moveCaret("KEY_ArrowRight"); 299 is(offset, 3, "Caret at offset 3"); 300 is(isAtLineEnd, false, "Caret not at end of line"); 301 testCaretRect(docAcc, "textarea", offset, fetchedBounds, isAtLineEnd); 302 303 info("Moving caret right in second line"); 304 [offset, isAtLineEnd] = await moveCaret("KEY_ArrowRight"); 305 is(offset, 4, "Caret at offset 4"); 306 is(isAtLineEnd, false, "Caret not at end of line"); 307 testCaretRect(docAcc, "textarea", offset, fetchedBounds, isAtLineEnd); 308 309 info("Move caret right to new line"); 310 [offset, isAtLineEnd] = await moveCaret("KEY_ArrowRight"); 311 is(offset, 5, "Caret at offset 5"); 312 is(isAtLineEnd, false, "Caret not at end of line"); 313 testCaretRect(docAcc, "textarea", offset, fetchedBounds, isAtLineEnd); 314 315 info("Move caret to end of previous line"); 316 [offset, isAtLineEnd] = await moveCaret("KEY_ArrowLeft"); 317 is(offset, 4, "Caret at offset 4"); 318 is(isAtLineEnd, false, "Caret at end line break"); 319 testCaretRect(docAcc, "textarea", offset, fetchedBounds, isAtLineEnd); 320 321 info("Move caret to end of text"); 322 if (AppConstants.platform == "macosx") { 323 [offset, isAtLineEnd] = await moveCaret("KEY_PageDown", { 324 altKey: true, 325 }); 326 } else { 327 [offset, isAtLineEnd] = await moveCaret("KEY_End", { 328 ctrlKey: true, 329 }); 330 } 331 is(offset, 11, "Caret at offset 11"); 332 is(isAtLineEnd, false, "Caret at end line break"); 333 testCaretRect(docAcc, "textarea", offset, fetchedBounds, isAtLineEnd); 334 } 335 ); 336 337 function todoIsWithin(expected, got, within, msg) { 338 if (Math.abs(got - expected) <= within) { 339 todo(true, `${msg} - Got ${got}`); 340 } else { 341 todo( 342 false, 343 `${msg} - Got ${got}, expected ${expected} with error of ${within}` 344 ); 345 } 346 } 347 348 /** 349 * Test the caret rect and collapsed range in bidi text 350 */ 351 addAccessibleTask( 352 ` 353 <input id="input1" value="hello שלום world"> 354 <input id="input2" dir="rtl" value="שלום hello עולם"> 355 <textarea id="textarea" dir="rtl">שלום 356 עולם</textarea> 357 `, 358 async function testRTL(browser, docAcc) { 359 const input1 = findAccessibleChildByID(docAcc, "input1", [ 360 nsIAccessibleText, 361 ]); 362 363 let fetchedBounds = await fetchCollapsedRangeBounds(docAcc, input1); 364 365 info("Focusing input1"); 366 let caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, input1); 367 input1.takeFocus(); 368 await caretMoved; 369 testCaretRect(docAcc, "input1", 0, fetchedBounds); 370 371 info("Setting caretOffset to 1"); 372 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, input1); 373 input1.caretOffset = 1; 374 await caretMoved; 375 testCaretRect(docAcc, "input1", 1, fetchedBounds); 376 377 info("Setting caretOffset to 6 (first in embedded RTL)"); 378 // Retrieving rangeX before caret goes there 379 let [rangeX] = getCollapsedRangeExtents(input1, 6); 380 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, input1); 381 input1.caretOffset = 6; 382 await caretMoved; 383 let [caretX] = getCaretRect(docAcc, "input1"); 384 todoIsWithin(caretX, rangeX, 2, "Caret x similar to range x"); 385 386 info("Setting caretOffset to 7 (in embedded RTL)"); 387 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, input1); 388 input1.caretOffset = 7; 389 await caretMoved; 390 testCaretRect(docAcc, "input1", 7, fetchedBounds); 391 392 info("Resetting caretOffset to 0"); 393 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, input1); 394 input1.caretOffset = 0; 395 await caretMoved; 396 397 const input2 = findAccessibleChildByID(docAcc, "input2", [ 398 nsIAccessibleText, 399 ]); 400 401 fetchedBounds = await fetchCollapsedRangeBounds(docAcc, input2); 402 403 info("Focusing input2"); 404 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, input2); 405 input2.takeFocus(); 406 await caretMoved; 407 testCaretRect(docAcc, "input2", 0, fetchedBounds); 408 409 info("Setting caretOffset to 1"); 410 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, input2); 411 input2.caretOffset = 1; 412 await caretMoved; 413 testCaretRect(docAcc, "input2", 1, fetchedBounds); 414 415 info("Setting caretOffset to 5 (first in embedded LTR)"); 416 // Retrieving rangeX before caret goes there 417 [rangeX] = getCollapsedRangeExtents(input2, 5); 418 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, input2); 419 input2.caretOffset = 5; 420 await caretMoved; 421 [caretX] = getCaretRect(docAcc, "input1"); 422 todoIsWithin(caretX, rangeX, 2, "Caret x similar to range x"); 423 424 info("Setting caretOffset to 7 (in embedded LTR)"); 425 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, input2); 426 input2.caretOffset = 7; 427 await caretMoved; 428 testCaretRect(docAcc, "input2", 7, fetchedBounds); 429 430 const textarea = findAccessibleChildByID(docAcc, "textarea", [ 431 nsIAccessibleText, 432 ]); 433 434 fetchedBounds = await fetchCollapsedRangeBounds(docAcc, textarea); 435 436 info("Focusing textarea"); 437 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, textarea); 438 textarea.takeFocus(); 439 await caretMoved; 440 testCaretRect(docAcc, "textarea", 0, fetchedBounds); 441 442 info("Setting caretOffset to 1"); 443 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, textarea); 444 textarea.caretOffset = 1; 445 await caretMoved; 446 testCaretRect(docAcc, "textarea", 1, fetchedBounds); 447 448 info("Setting caretOffset to 4 (before newline)"); 449 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, textarea); 450 textarea.caretOffset = 4; 451 await caretMoved; 452 testCaretRect(docAcc, "textarea", 4, fetchedBounds); 453 454 info("Setting caretOffset to 5 (after newline)"); 455 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, textarea); 456 textarea.caretOffset = 5; 457 await caretMoved; 458 testCaretRect(docAcc, "textarea", 5, fetchedBounds); 459 }, 460 { chrome: true, topLevel: true } 461 ); 462 463 /** 464 * Test the caret rect in vertical text. 465 */ 466 addAccessibleTask( 467 ` 468 <input id="input" value="ab" style="writing-mode: vertical-lr;"> 469 <input id="emptyInput" style="writing-mode: vertical-lr;"> 470 `, 471 async function testVerticalInputs(browser, docAcc) { 472 const input = findAccessibleChildByID(docAcc, "input", [nsIAccessibleText]); 473 let fetchedBounds = await fetchCollapsedRangeBounds(docAcc, input); 474 475 info("Focusing input"); 476 let caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, input); 477 input.takeFocus(); 478 await caretMoved; 479 testCaretRect(docAcc, "input", 0, fetchedBounds, false, true); 480 info("Setting caretOffset to 1"); 481 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, input); 482 input.caretOffset = 1; 483 await caretMoved; 484 testCaretRect(docAcc, "input", 1, fetchedBounds, false, true); 485 info("Setting caretOffset to 2"); 486 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, input); 487 input.caretOffset = 2; 488 await caretMoved; 489 testCaretRect(docAcc, "input", 2, fetchedBounds, false, true); 490 info("Resetting caretOffset to 0"); 491 input.caretOffset = 0; 492 493 const emptyInput = findAccessibleChildByID(docAcc, "emptyInput", [ 494 nsIAccessibleText, 495 ]); 496 fetchedBounds = await fetchCollapsedRangeBounds(docAcc, emptyInput); 497 498 info("Focusing emptyInput"); 499 caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED, emptyInput); 500 emptyInput.takeFocus(); 501 await caretMoved; 502 testCaretRect(docAcc, "emptyInput", 0, fetchedBounds, false, true); 503 } 504 ); 505 506 /** 507 * Test contenteditable caret 508 */ 509 addAccessibleTask( 510 ` 511 <div contenteditable="true" id="input" role="textbox">one <strong>two</strong> <i>three</i></div> 512 `, 513 async function testRichTextEdit(browser, docAcc) { 514 const input = findAccessibleChildByID(docAcc, "input", [nsIAccessibleText]); 515 let fetchedBounds = await fetchCollapsedRangeBounds(docAcc, input); 516 517 let focused = waitForEvent(EVENT_FOCUS, input); 518 input.takeFocus(); 519 info("Focusing input"); 520 await focused; 521 testCaretRect(docAcc, "input", 0, fetchedBounds); 522 523 for (let offset = 1; offset < fetchedBounds.length - 1; offset++) { 524 info("Pressing ArrowRight to move caret to index " + offset); 525 let caretMoved = waitForEvent(EVENT_TEXT_CARET_MOVED); 526 EventUtils.synthesizeKey("KEY_ArrowRight"); 527 528 let evt = (await caretMoved).QueryInterface(nsIAccessibleCaretMoveEvent); 529 530 let acc = evt.accessible.QueryInterface(nsIAccessibleHyperText); 531 let caretOffset = evt.caretOffset; 532 let linkIndex = acc.getLinkIndexAtOffset(evt.caretOffset); 533 if (linkIndex != -1) { 534 acc = acc 535 .getLinkAt(linkIndex) 536 .QueryInterface(nsIAccessibleText) 537 .QueryInterface(nsIAccessible); 538 caretOffset = 0; 539 } 540 541 const [rangeX, rangeY, rangeW, rangeH] = 542 fetchedBounds.length > offset ? fetchedBounds[offset] : [0, 0, 0, 0]; 543 const [caretX, caretY, caretW, caretH] = getCollapsedRangeExtents( 544 acc, 545 caretOffset 546 ); 547 548 info( 549 `${offset}: Caret rect: ${caretX}, ${caretY}, ${caretW}, ${caretH}, Prefetched rect: ${rangeX}, ${rangeY}, ${rangeW}, ${rangeH}` 550 ); 551 552 isWithin(rangeX, caretX, 2, "Caret x similar to range x"); 553 isWithin(rangeY, caretY, 2, "Caret y similar to range y"); 554 isWithin(rangeW, caretW, 2, "Caret width similar to range width"); 555 } 556 } 557 );