| OLD | NEW |
| (Empty) | |
| 1 // Copyright 2016 The Chromium Authors. All rights reserved. |
| 2 // Use of this source code is governed by a BSD-style license that can be |
| 3 // found in the LICENSE file. |
| 4 |
| 5 'use strict'; |
| 6 |
| 7 // This file provides |assert_selection(sample, tester, expectedText, options)| |
| 8 // assertion to W3C test harness to write editing test cases easier. |
| 9 // |
| 10 // |sample| is an HTML fragment text which is inserted as |innerHTML|. It should |
| 11 // have at least one focus boundary point marker "|" and at most one anchor |
| 12 // boundary point marker "^". |
| 13 // |
| 14 // |tester| is either name with parameter of execCommand or function taking |
| 15 // one parameter |Selection|. |
| 16 // |
| 17 // |expectedText| is an HTML fragment text containing at most one focus marker |
| 18 // and anchor marker. If resulting selection is none, you don't need to have |
| 19 // anchor and focus markers. |
| 20 // |
| 21 // |options| is a string as description, undefined, or a dictionary containing: |
| 22 // description: A description |
| 23 // dumpAs: 'domtree' or 'flattree'. Default is 'domtree'. |
| 24 // removeSampleIfSucceeded: A boolean. Default is true. |
| 25 // |
| 26 // Example: |
| 27 // test(() => { |
| 28 // assert_selection( |
| 29 // '|foo', |
| 30 // (selection) => selection.modify('extent', 'forward, 'character'), |
| 31 // '<a href="http://bar">^f|oo</a>' |
| 32 // }); |
| 33 // |
| 34 // test(() => { |
| 35 // assert_selection( |
| 36 // 'x^y|z', |
| 37 // 'bold', // execCommand name as a test |
| 38 // 'x<b>y</b>z', |
| 39 // 'Insert B tag'); |
| 40 // }); |
| 41 // |
| 42 // test(() => { |
| 43 // assert_selection( |
| 44 // 'x^y|z', |
| 45 // 'createLink http://foo', // execCommand name and parameter |
| 46 // 'x<a href="http://foo/">y</a></b>z', |
| 47 // 'Insert B tag'); |
| 48 // }); |
| 49 // |
| 50 // |
| 51 |
| 52 // TODO(yosin): Please use "clang-format -style=Chromium -i" for formatting |
| 53 // this file. |
| 54 |
| 55 (function() { |
| 56 /** @enum{string} */ |
| 57 const DumpAs = { |
| 58 DOM_TREE: 'domtree', |
| 59 FLAT_TREE: 'flattree', |
| 60 }; |
| 61 |
| 62 /** @const @type {string} */ |
| 63 const kTextArea = 'TEXTAREA'; |
| 64 |
| 65 class Traversal { |
| 66 /** |
| 67 * @param {!Node} node |
| 68 * @return {Node} |
| 69 */ |
| 70 firstChildOf(node) { throw new Error('You should implement firstChildOf'); } |
| 71 |
| 72 /** |
| 73 * @param {!Node} node |
| 74 * @return {!Generator<Node>} |
| 75 */ |
| 76 * childNodesOf(node) { |
| 77 for (let child = this.firstChildOf(node); child !== null; |
| 78 child = this.nextSiblingOf(child)) { |
| 79 yield child; |
| 80 } |
| 81 } |
| 82 |
| 83 /** |
| 84 * @param {!DOMSelection} selection |
| 85 * @return !SampleSelection |
| 86 */ |
| 87 fromDOMSelection(selection) { |
| 88 throw new Error('You should implement fromDOMSelection'); |
| 89 } |
| 90 |
| 91 /** |
| 92 * @param {!Node} node |
| 93 * @return {Node} |
| 94 */ |
| 95 nextSiblingOf(node) { |
| 96 throw new Error('You should implement nextSiblingOf'); |
| 97 } |
| 98 } |
| 99 |
| 100 class DOMTreeTraversal extends Traversal { |
| 101 /** |
| 102 * @override |
| 103 * @param {!Node} node |
| 104 * @return {Node} |
| 105 */ |
| 106 firstChildOf(node) { return node.firstChild; } |
| 107 |
| 108 /** |
| 109 * @param {!DOMSelection} selection |
| 110 * @return !SampleSelection |
| 111 */ |
| 112 fromDOMSelection(selection) { |
| 113 return SampleSelection.fromDOMSelection(selection); |
| 114 } |
| 115 |
| 116 /** |
| 117 * @param {!Node} node |
| 118 * @return {Node} |
| 119 */ |
| 120 nextSiblingOf(node) { return node.nextSibling; } |
| 121 }; |
| 122 |
| 123 class FlatTreeTraversal extends Traversal { |
| 124 /** |
| 125 * @override |
| 126 * @param {!Node} node |
| 127 * @return {Node} |
| 128 */ |
| 129 firstChildOf(node) { return internals.firstChildInFlatTree(node); } |
| 130 |
| 131 /** |
| 132 * @param {!DOMSelection} selection |
| 133 * @return !SampleSelection |
| 134 */ |
| 135 fromDOMSelection(selection) { |
| 136 // TODO(yosin): We should return non-scoped selection rather than |
| 137 // selection |
| 138 // scoped in main tree. |
| 139 return SampleSelection.fromDOMSelection(selection); |
| 140 } |
| 141 |
| 142 /** |
| 143 * @param {!Node} node |
| 144 * @return {Node} |
| 145 */ |
| 146 nextSiblingOf(node) { return internals.nextSiblingInFlatTree(node); } |
| 147 } |
| 148 |
| 149 /** |
| 150 * @param {!Node} node |
| 151 * @return {boolean} |
| 152 */ |
| 153 function isCharacterData(node) { |
| 154 return node.nodeType === Node.TEXT_NODE || |
| 155 node.nodeType === Node.COMMENT_NODE; |
| 156 } |
| 157 |
| 158 /** |
| 159 * @param {!Node} node |
| 160 * @return {boolean} |
| 161 */ |
| 162 function isElement(node) { return node.nodeType === Node.ELEMENT_NODE; } |
| 163 |
| 164 /** |
| 165 * @param {!Node} node |
| 166 * @param {number} offset |
| 167 */ |
| 168 function checkValidNodeAndOffset(node, offset) { |
| 169 if (!node) |
| 170 throw new Error('Node parameter should not be a null.'); |
| 171 if (offset < 0) |
| 172 throw new Error(`Assumes ${offset} >= 0`); |
| 173 if (isElement(node)) { |
| 174 if (offset > node.childNodes.length) |
| 175 throw new Error(`Bad offset ${offset} for ${node}`); |
| 176 return; |
| 177 } |
| 178 if (isCharacterData(node)) { |
| 179 if (offset > node.nodeValue.length) |
| 180 throw new Error(`Bad offset ${offset} for ${node}`); |
| 181 return; |
| 182 } |
| 183 throw new Error(`Invalid node: ${node}`); |
| 184 } |
| 185 |
| 186 class SampleSelection { |
| 187 /** @public */ |
| 188 constructor() { |
| 189 /** @type {?Node} */ |
| 190 this.anchorNode_ = null; |
| 191 /** @type {number} */ |
| 192 this.anchorOffset_ = 0; |
| 193 /** @type {?Node} */ |
| 194 this.focusNode_ = null; |
| 195 /** @type {number} */ |
| 196 this.focusOffset_ = 0; |
| 197 /** @type {HTMLElement} */ |
| 198 this.shadowHost_ = null; |
| 199 } |
| 200 |
| 201 /** |
| 202 * @public |
| 203 * @param {!Node} node |
| 204 * @param {number} offset |
| 205 */ |
| 206 collapse(node, offset) { |
| 207 checkValidNodeAndOffset(node, offset); |
| 208 this.anchorNode_ = this.focusNode_ = node; |
| 209 this.anchorOffset_ = this.focusOffset_ = offset; |
| 210 } |
| 211 |
| 212 /** |
| 213 * @public |
| 214 * @param {!Node} node |
| 215 * @param {number} offset |
| 216 */ |
| 217 extend(node, offset) { |
| 218 checkValidNodeAndOffset(node, offset); |
| 219 this.focusNode_ = node; |
| 220 this.focusOffset_ = offset; |
| 221 } |
| 222 |
| 223 /** @public @return {?Node} */ |
| 224 get anchorNode() { |
| 225 console.assert(!this.isNone, 'Selection should not be a none.'); |
| 226 return this.anchorNode_; |
| 227 } |
| 228 /** @public @return {number} */ |
| 229 get anchorOffset() { |
| 230 console.assert(!this.isNone, 'Selection should not be a none.'); |
| 231 return this.anchorOffset_; |
| 232 } |
| 233 /** @public @return {?Node} */ |
| 234 get focusNode() { |
| 235 console.assert(!this.isNone, 'Selection should not be a none.'); |
| 236 return this.focusNode_; |
| 237 } |
| 238 /** @public @return {number} */ |
| 239 get focusOffset() { |
| 240 console.assert(!this.isNone, 'Selection should not be a none.'); |
| 241 return this.focusOffset_; |
| 242 } |
| 243 |
| 244 /** @public @return {HTMLElement} */ |
| 245 get shadowHost() { return this.shadowHost_; } |
| 246 |
| 247 /** |
| 248 * @public |
| 249 * @return {boolean} |
| 250 */ |
| 251 get isCollapsed() { |
| 252 return this.anchorNode === this.focusNode && |
| 253 this.anchorOffset === this.focusOffset; |
| 254 } |
| 255 |
| 256 /** |
| 257 * @public |
| 258 * @return {boolean} |
| 259 */ |
| 260 get isNone() { return this.anchorNode_ === null; } |
| 261 |
| 262 /** |
| 263 * @public |
| 264 * @param {!Selection} domSelection |
| 265 * @return {!SampleSelection} |
| 266 */ |
| 267 static fromDOMSelection(domSelection) { |
| 268 /** type {!SampleSelection} */ |
| 269 const selection = new SampleSelection(); |
| 270 selection.anchorNode_ = domSelection.anchorNode; |
| 271 selection.anchorOffset_ = domSelection.anchorOffset; |
| 272 selection.focusNode_ = domSelection.focusNode; |
| 273 selection.focusOffset_ = domSelection.focusOffset; |
| 274 |
| 275 if (selection.anchorNode_ === null) |
| 276 return selection; |
| 277 |
| 278 const document = selection.anchorNode_.ownerDocument; |
| 279 selection.shadowHost_ = (() => { |
| 280 if (!document.activeElement) |
| 281 return null; |
| 282 if (document.activeElement.nodeName !== kTextArea) |
| 283 return null; |
| 284 const selectedNode = |
| 285 selection.anchorNode.childNodes[selection.anchorOffset]; |
| 286 if (document.activeElement !== selectedNode) |
| 287 return null; |
| 288 return selectedNode; |
| 289 })(); |
| 290 return selection; |
| 291 } |
| 292 |
| 293 /** @override */ |
| 294 toString() { |
| 295 if (this.isNone) |
| 296 return 'SampleSelection()'; |
| 297 if (this.isCollapsed) |
| 298 return `SampleSelection(${this.focusNode_}@${this.focusOffset_})`; |
| 299 return `SampleSelection(anchor: ${this.anchorNode_}@${this.anchorOffset_ |
| 300 }` + |
| 301 `focus: ${this.focusNode_}@${this.focusOffset_}`; |
| 302 } |
| 303 } |
| 304 |
| 305 // Extracts selection from marker "^" as anchor and "|" as focus from |
| 306 // DOM tree and removes them. |
| 307 class Parser { |
| 308 /** @private */ |
| 309 constructor() { |
| 310 /** @type {?Node} */ |
| 311 this.anchorNode_ = null; |
| 312 /** @type {number} */ |
| 313 this.anchorOffset_ = 0; |
| 314 /** @type {?Node} */ |
| 315 this.focusNode_ = null; |
| 316 /** @type {number} */ |
| 317 this.focusOffset_ = 0; |
| 318 } |
| 319 |
| 320 /** |
| 321 * @public |
| 322 * @return {!SampleSelection} |
| 323 */ |
| 324 get selection() { |
| 325 const selection = new SampleSelection(); |
| 326 if (!this.anchorNode_ && !this.focusNode_) |
| 327 return selection; |
| 328 if (this.anchorNode_ && this.focusNode_) { |
| 329 selection.collapse(this.anchorNode_, this.anchorOffset_); |
| 330 selection.extend(this.focusNode_, this.focusOffset_); |
| 331 return selection; |
| 332 } |
| 333 if (this.focusNode_) { |
| 334 selection.collapse(this.focusNode_, this.focusOffset_); |
| 335 return selection; |
| 336 } |
| 337 throw new Error('There is no focus marker'); |
| 338 } |
| 339 |
| 340 /** |
| 341 * @private |
| 342 * @param {!CharacterData} node |
| 343 * @param {number} nodeIndex |
| 344 */ |
| 345 handleCharacterData(node, nodeIndex) { |
| 346 /** @type {string} */ |
| 347 const text = node.nodeValue; |
| 348 /** @type {number} */ |
| 349 const anchorOffset = text.indexOf('^'); |
| 350 /** @type {number} */ |
| 351 const focusOffset = text.indexOf('|'); |
| 352 /** @type {!Node} */ |
| 353 const parentNode = node.parentNode; |
| 354 node.nodeValue = text.replace('^', '').replace('|', ''); |
| 355 if (node.nodeValue.length == 0) { |
| 356 if (anchorOffset >= 0) |
| 357 this.rememberSelectionAnchor(parentNode, nodeIndex); |
| 358 if (focusOffset >= 0) |
| 359 this.rememberSelectionFocus(parentNode, nodeIndex); |
| 360 node.remove(); |
| 361 return; |
| 362 } |
| 363 if (anchorOffset >= 0 && focusOffset >= 0) { |
| 364 if (anchorOffset > focusOffset) { |
| 365 this.rememberSelectionAnchor(node, anchorOffset - 1); |
| 366 this.rememberSelectionFocus(node, focusOffset); |
| 367 return; |
| 368 } |
| 369 this.rememberSelectionAnchor(node, anchorOffset); |
| 370 this.rememberSelectionFocus(node, focusOffset - 1); |
| 371 return; |
| 372 } |
| 373 if (anchorOffset >= 0) { |
| 374 this.rememberSelectionAnchor(node, anchorOffset); |
| 375 return; |
| 376 } |
| 377 if (focusOffset < 0) |
| 378 return; |
| 379 this.rememberSelectionFocus(node, focusOffset); |
| 380 } |
| 381 |
| 382 /** |
| 383 * @private |
| 384 * @param {!Element} element |
| 385 */ |
| 386 handleElementNode(element) { |
| 387 /** @type {number} */ |
| 388 let childIndex = 0; |
| 389 for (const child of Array.from(element.childNodes)) { |
| 390 this.parseInternal(child, childIndex); |
| 391 if (!child.parentNode) |
| 392 continue; |
| 393 ++childIndex; |
| 394 } |
| 395 } |
| 396 |
| 397 /** |
| 398 * @private |
| 399 * @param {!Node} node |
| 400 * @return {!SampleSelection} |
| 401 */ |
| 402 parse(node) { |
| 403 this.parseInternal(node, 0); |
| 404 return this.selection; |
| 405 } |
| 406 |
| 407 /** |
| 408 * @private |
| 409 * @param {!Node} node |
| 410 * @param {number} nodeIndex |
| 411 */ |
| 412 parseInternal(node, nodeIndex) { |
| 413 if (isElement(node)) |
| 414 return this.handleElementNode(node); |
| 415 if (isCharacterData(node)) |
| 416 return this.handleCharacterData(node, nodeIndex); |
| 417 throw new Error(`Unexpected node ${node}`); |
| 418 } |
| 419 |
| 420 /** |
| 421 * @private |
| 422 * @param {!Node} node |
| 423 * @param {number} offset |
| 424 */ |
| 425 rememberSelectionAnchor(node, offset) { |
| 426 checkValidNodeAndOffset(node, offset); |
| 427 console.assert( |
| 428 this.anchorNode_ === null, 'Anchor marker should be one.', |
| 429 this.anchorNode_, this.anchorOffset_); |
| 430 this.anchorNode_ = node; |
| 431 this.anchorOffset_ = offset; |
| 432 } |
| 433 |
| 434 /** |
| 435 * @private |
| 436 * @param {!Node} node |
| 437 * @param {number} offset |
| 438 */ |
| 439 rememberSelectionFocus(node, offset) { |
| 440 checkValidNodeAndOffset(node, offset); |
| 441 console.assert( |
| 442 this.focusNode_ === null, 'Focus marker should be one.', |
| 443 this.focusNode_, this.focusOffset_); |
| 444 this.focusNode_ = node; |
| 445 this.focusOffset_ = offset; |
| 446 } |
| 447 |
| 448 /** |
| 449 * @public |
| 450 * @param {!Node} node |
| 451 * @return {!SampleSelection} |
| 452 */ |
| 453 static parse(node) { return (new Parser()).parse(node); } |
| 454 } |
| 455 |
| 456 // TODO(yosin): Once we can import JavaScript file from scripts, we should |
| 457 // import "imported/wpt/html/resources/common.js", since |HTML5_VOID_ELEMENTS| |
| 458 // is defined in there. |
| 459 /** |
| 460 * @const @type {!Set<string>} |
| 461 * only void (without end tag) HTML5 elements |
| 462 */ |
| 463 const HTML5_VOID_ELEMENTS = new Set([ |
| 464 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', |
| 465 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr' |
| 466 ]); |
| 467 |
| 468 class Serializer { |
| 469 /** |
| 470 * @public |
| 471 * @param {!SampleSelection} selection |
| 472 * @param {!Traversal} traversal |
| 473 */ |
| 474 constructor(selection, traversal) { |
| 475 /** @type {!SampleSelection} */ |
| 476 this.selection_ = selection; |
| 477 /** @type {!Array<strings>} */ |
| 478 this.strings_ = []; |
| 479 /** @type {!Traversal} */ |
| 480 this.traversal_ = traversal; |
| 481 } |
| 482 |
| 483 /** |
| 484 * @private |
| 485 * @param {string} string |
| 486 */ |
| 487 emit(string) { this.strings_.push(string); } |
| 488 |
| 489 /** |
| 490 * @private |
| 491 * @param {!HTMLElement} parentNode |
| 492 * @param {number} childIndex |
| 493 */ |
| 494 handleSelection(parentNode, childIndex) { |
| 495 if (this.selection_.isNone) |
| 496 return; |
| 497 if (this.selection_.shadowHost) |
| 498 return; |
| 499 if (parentNode === this.selection_.focusNode && |
| 500 childIndex === this.selection_.focusOffset) { |
| 501 this.emit('|'); |
| 502 return; |
| 503 } |
| 504 if (parentNode === this.selection_.anchorNode && |
| 505 childIndex === this.selection_.anchorOffset) { |
| 506 this.emit('^'); |
| 507 } |
| 508 } |
| 509 |
| 510 /** |
| 511 * @private |
| 512 * @param {!CharacterData} node |
| 513 */ |
| 514 handleCharacterData(node) { |
| 515 /** @type {string} */ |
| 516 const text = node.nodeValue; |
| 517 if (this.selection_.isNone) |
| 518 return this.emit(text); |
| 519 /** @type {number} */ |
| 520 const anchorOffset = this.selection_.anchorOffset; |
| 521 /** @type {number} */ |
| 522 const focusOffset = this.selection_.focusOffset; |
| 523 if (node === this.selection_.focusNode && |
| 524 node === this.selection_.anchorNode) { |
| 525 if (anchorOffset === focusOffset) { |
| 526 this.emit(text.substr(0, focusOffset)); |
| 527 this.emit('|'); |
| 528 this.emit(text.substr(focusOffset)); |
| 529 return; |
| 530 } |
| 531 if (anchorOffset < focusOffset) { |
| 532 this.emit(text.substr(0, anchorOffset)); |
| 533 this.emit('^'); |
| 534 this.emit(text.substr(anchorOffset, focusOffset - anchorOffset)); |
| 535 this.emit('|'); |
| 536 this.emit(text.substr(focusOffset)); |
| 537 return; |
| 538 } |
| 539 this.emit(text.substr(0, focusOffset)); |
| 540 this.emit('|'); |
| 541 this.emit(text.substr(focusOffset, anchorOffset - focusOffset)); |
| 542 this.emit('^'); |
| 543 this.emit(text.substr(anchorOffset)); |
| 544 return; |
| 545 } |
| 546 if (node === this.selection_.anchorNode) { |
| 547 this.emit(text.substr(0, anchorOffset)); |
| 548 this.emit('^'); |
| 549 this.emit(text.substr(anchorOffset)); |
| 550 return; |
| 551 } |
| 552 if (node === this.selection_.focusNode) { |
| 553 this.emit(text.substr(0, focusOffset)); |
| 554 this.emit('|'); |
| 555 this.emit(text.substr(focusOffset)); |
| 556 return; |
| 557 } |
| 558 this.emit(text); |
| 559 } |
| 560 |
| 561 /** |
| 562 * @private |
| 563 * @param {!HTMLElement} element |
| 564 */ |
| 565 handleElementNode(element) { |
| 566 /** @type {string} */ |
| 567 const tagName = element.tagName.toLowerCase(); |
| 568 this.emit(`<${tagName}`); |
| 569 Array.from(element.attributes) |
| 570 .sort((attr1, attr2) => attr1.name.localeCompare(attr2.name)) |
| 571 .forEach(attr => { |
| 572 if (attr.value === '') |
| 573 return this.emit(` ${attr.name}`); |
| 574 const value = attr.value.replace(/&/g, '&') |
| 575 .replace(/\u0022/g, '"') |
| 576 .replace(/\u0027/g, '''); |
| 577 this.emit(` ${attr.name}="${value}"`); |
| 578 }); |
| 579 this.emit('>'); |
| 580 if (element.nodeName === kTextArea) |
| 581 return this.handleTextArea(element); |
| 582 if (this.traversal_.firstChildOf(element) === null && |
| 583 HTML5_VOID_ELEMENTS.has(tagName)) { |
| 584 return; |
| 585 } |
| 586 this.serializeChildren(element); |
| 587 this.emit(`</${tagName}>`); |
| 588 } |
| 589 |
| 590 /** |
| 591 * @private |
| 592 * @param {!HTMLTextArea} |
| 593 */ |
| 594 handleTextArea(textArea) { |
| 595 /** @type {string} */ |
| 596 const value = textArea.value; |
| 597 if (this.selection_.shadowHost !== textArea) { |
| 598 this.emit(value); |
| 599 } else { |
| 600 /** @type {number} */ |
| 601 const start = textArea.selectionStart; |
| 602 /** @type {number} */ |
| 603 const end = textArea.selectionEnd; |
| 604 /** @type {boolean} */ |
| 605 const isBackward = |
| 606 start < end && textArea.selectionDirection === 'backward'; |
| 607 const startMarker = isBackward ? '|' : '^'; |
| 608 const endMarker = isBackward ? '^' : '|'; |
| 609 this.emit(value.substr(0, start)); |
| 610 if (start < end) { |
| 611 this.emit(startMarker); |
| 612 this.emit(value.substr(start, end - start)); |
| 613 } |
| 614 this.emit(endMarker); |
| 615 this.emit(value.substr(end)); |
| 616 } |
| 617 this.emit('</textarea>'); |
| 618 } |
| 619 |
| 620 /** |
| 621 * @public |
| 622 * @param {!HTMLDocument} document |
| 623 */ |
| 624 serialize(document) { |
| 625 if (document.body) |
| 626 this.serializeChildren(document.body); |
| 627 else |
| 628 this.serializeInternal(document.documentElement); |
| 629 return this.strings_.join(''); |
| 630 } |
| 631 |
| 632 /** |
| 633 * @private |
| 634 * @param {!HTMLElement} element |
| 635 */ |
| 636 serializeChildren(element) { |
| 637 if (this.traversal_.firstChildOf(element) === null) { |
| 638 this.handleSelection(element, 0); |
| 639 return; |
| 640 } |
| 641 |
| 642 /** @type {number} */ |
| 643 let childIndex = 0; |
| 644 for (let child of this.traversal_.childNodesOf(element)) { |
| 645 this.handleSelection(element, childIndex); |
| 646 this.serializeInternal(child, childIndex); |
| 647 ++childIndex; |
| 648 } |
| 649 this.handleSelection(element, childIndex); |
| 650 } |
| 651 |
| 652 /** |
| 653 * @private |
| 654 * @param {!Node} node |
| 655 */ |
| 656 serializeInternal(node) { |
| 657 if (isElement(node)) |
| 658 return this.handleElementNode(node); |
| 659 if (isCharacterData(node)) |
| 660 return this.handleCharacterData(node); |
| 661 throw new Error(`Unexpected node ${node}`); |
| 662 } |
| 663 } |
| 664 |
| 665 /** |
| 666 * @this {!DOMSelection} |
| 667 * @param {string} html |
| 668 * @param {string=} opt_text |
| 669 */ |
| 670 function setClipboardData(html, opt_text) { |
| 671 assert_not_equals( |
| 672 window.internals, undefined, |
| 673 'This test requests clipboard access from JavaScript.'); |
| 674 function computeTextData() { |
| 675 if (opt_text !== undefined) |
| 676 return opt_text; |
| 677 const element = document.createElement('div'); |
| 678 element.innerHTML = html; |
| 679 return element.textContent; |
| 680 } |
| 681 function copyHandler(event) { |
| 682 const clipboardData = event.clipboardData; |
| 683 clipboardData.setData('text/plain', computeTextData()); |
| 684 clipboardData.setData('text/html', html); |
| 685 event.preventDefault(); |
| 686 } |
| 687 document.addEventListener('copy', copyHandler); |
| 688 document.execCommand('copy'); |
| 689 document.removeEventListener('copy', copyHandler); |
| 690 } |
| 691 |
| 692 class Sample { |
| 693 /** |
| 694 * @public |
| 695 * @param {string} sampleText |
| 696 */ |
| 697 constructor(sampleText) { |
| 698 /** @const @type {!HTMLIFame} */ |
| 699 this.iframe_ = document.createElement('iframe'); |
| 700 if (!document.body) |
| 701 document.body = document.createElement('body'); |
| 702 document.body.appendChild(this.iframe_); |
| 703 /** @const @type {!HTMLDocument} */ |
| 704 this.document_ = this.iframe_.contentDocument; |
| 705 /** @const @type {!Selection} */ |
| 706 this.selection_ = this.iframe_.contentWindow.getSelection(); |
| 707 this.selection_.document = this.document_; |
| 708 this.selection_.document.offsetLeft = this.iframe_.offsetLeft; |
| 709 this.selection_.document.offsetTop = this.iframe_.offsetTop; |
| 710 this.selection_.setClipboardData = setClipboardData; |
| 711 |
| 712 // Set focus to sample IFRAME to make |eventSender| and |
| 713 // |testRunner.execCommand()| to work on sample rather than main frame. |
| 714 this.iframe_.focus(); |
| 715 this.load(sampleText); |
| 716 } |
| 717 |
| 718 /** @return {!HTMLDocument} */ |
| 719 get document() { return this.document_; } |
| 720 |
| 721 /** @return {!Selection} */ |
| 722 get selection() { return this.selection_; } |
| 723 |
| 724 /** |
| 725 * @private |
| 726 * @param {string} sampleText |
| 727 */ |
| 728 load(sampleText) { |
| 729 const anchorMarker = sampleText.indexOf('^'); |
| 730 const focusMarker = sampleText.indexOf('|'); |
| 731 if (focusMarker < 0 && anchorMarker >= 0) { |
| 732 throw new Error( |
| 733 `You should specify caret position in "${sampleText}".`); |
| 734 } |
| 735 if (focusMarker != sampleText.lastIndexOf('|')) { |
| 736 throw new Error( |
| 737 `You should have at least one focus marker "|" in "${sampleText |
| 738 }".`); |
| 739 } |
| 740 if (anchorMarker != sampleText.lastIndexOf('^')) { |
| 741 throw new Error( |
| 742 `You should have at most one anchor marker "^" in "${sampleText |
| 743 }".`); |
| 744 } |
| 745 if (anchorMarker >= 0 && focusMarker >= 0 && |
| 746 (anchorMarker + 1 === focusMarker || |
| 747 anchorMarker - 1 === focusMarker)) { |
| 748 throw new Error( |
| 749 `You should have focus marker and should not have anchor marker if a
nd only if selection is a caret in "${sampleText |
| 750 }".`); |
| 751 } |
| 752 this.document_.body.innerHTML = sampleText; |
| 753 /** @type {!SampleSelection} */ |
| 754 const selection = Parser.parse(this.document_.body); |
| 755 if (selection.isNone) |
| 756 return; |
| 757 if (this.loadSelectionInTextArea(selection)) |
| 758 return; |
| 759 this.selection_.collapse(selection.anchorNode, selection.anchorOffset); |
| 760 this.selection_.extend(selection.focusNode, selection.focusOffset); |
| 761 } |
| 762 |
| 763 /** |
| 764 * @private |
| 765 * @param {!SampleSelection} selection |
| 766 * @return {boolean} Returns true if selection is in TEXTAREA. |
| 767 */ |
| 768 loadSelectionInTextArea(selection) { |
| 769 /** @type {Node} */ |
| 770 const enclosingNode = selection.anchorNode.parentNode; |
| 771 if (selection.focusNode.parentNode !== enclosingNode) |
| 772 return false; |
| 773 if (enclosingNode.nodeName !== kTextArea) |
| 774 return false; |
| 775 if (selection.anchorNode !== selection.focusNode) |
| 776 throw new Error('Selection in TEXTAREA should be in same Text node.'); |
| 777 enclosingNode.focus(); |
| 778 if (selection.anchorOffset < selection.focusOffset) { |
| 779 enclosingNode.setSelectionRange( |
| 780 selection.anchorOffset, selection.focusOffset); |
| 781 return true; |
| 782 } |
| 783 enclosingNode.setSelectionRange( |
| 784 selection.focusOffset, selection.anchorOffset, 'backward'); |
| 785 return true; |
| 786 } |
| 787 |
| 788 /** |
| 789 * @public |
| 790 */ |
| 791 remove() { this.iframe_.remove(); } |
| 792 |
| 793 /** |
| 794 * @public |
| 795 * @param {!Traversal} traversal |
| 796 * @return {string} |
| 797 */ |
| 798 serialize(traversal) { |
| 799 /** @type {!SampleSelection} */ |
| 800 const selection = traversal.fromDOMSelection(this.selection_); |
| 801 /** @type {!Serializer} */ |
| 802 const serializer = new Serializer(selection, traversal); |
| 803 return serializer.serialize(this.document_); |
| 804 } |
| 805 } |
| 806 |
| 807 function assembleDescription() { |
| 808 function getStack() { |
| 809 let stack; |
| 810 try { |
| 811 throw new Error('get line number'); |
| 812 } catch (error) { |
| 813 stack = error.stack.split('\n').slice(1); |
| 814 } |
| 815 return stack |
| 816 } |
| 817 |
| 818 const RE_IN_ASSERT_SELECTION = new RegExp('assert_selection\\.js'); |
| 819 for (const line of getStack()) { |
| 820 const match = RE_IN_ASSERT_SELECTION.exec(line); |
| 821 if (!match) { |
| 822 const RE_LAYOUTTESTS = new RegExp('LayoutTests.*'); |
| 823 return RE_LAYOUTTESTS.exec(line); |
| 824 } |
| 825 } |
| 826 return ''; |
| 827 } |
| 828 |
| 829 /** |
| 830 * @param {string} expectedText |
| 831 */ |
| 832 function checkExpectedText(expectedText) { |
| 833 /** @type {number} */ |
| 834 const anchorOffset = expectedText.indexOf('^'); |
| 835 /** @type {number} */ |
| 836 const focusOffset = expectedText.indexOf('|'); |
| 837 if (anchorOffset != expectedText.lastIndexOf('^')) { |
| 838 throw new Error( |
| 839 `You should have at most one anchor marker "^" in "${expectedText |
| 840 }".`); |
| 841 } |
| 842 if (focusOffset != expectedText.lastIndexOf('|')) { |
| 843 throw new Error( |
| 844 `You should have at most one focus marker "|" in "${expectedText}".`); |
| 845 } |
| 846 if (anchorOffset >= 0 && focusOffset < 0) { |
| 847 throw new Error( |
| 848 `You should have a focus marker "|" in "${expectedText}".`); |
| 849 } |
| 850 if (anchorOffset >= 0 && focusOffset >= 0 && |
| 851 (anchorOffset + 1 === focusOffset || |
| 852 anchorOffset - 1 === focusOffset)) { |
| 853 throw new Error( |
| 854 `You should have focus marker and should not have anchor marker if and
only if selection is a caret in "${expectedText |
| 855 }".`); |
| 856 } |
| 857 } |
| 858 |
| 859 /** |
| 860 * @param {string} str1 |
| 861 * @param {string} str2 |
| 862 * @return {string} |
| 863 */ |
| 864 function commonPrefixOf(str1, str2) { |
| 865 for (let index = 0; index < str1.length; ++index) { |
| 866 if (str1[index] !== str2[index]) |
| 867 return str1.substr(0, index); |
| 868 } |
| 869 return str1; |
| 870 } |
| 871 |
| 872 /** |
| 873 * @param {string} inputText |
| 874 * @param {function(!Selection)|string} |
| 875 * @param {string} expectedText |
| 876 * @param {Object=} opt_options |
| 877 * @return {!Sample} |
| 878 */ |
| 879 function assertSelection(inputText, tester, expectedText, opt_options = {}) { |
| 880 const kDescription = 'description'; |
| 881 const kDumpAs = 'dumpAs'; |
| 882 const kRemoveSampleIfSucceeded = 'removeSampleIfSucceeded'; |
| 883 /** @type {!Object} */ |
| 884 const options = typeof(opt_options) === 'string' ? |
| 885 {description: opt_options} : |
| 886 opt_options; |
| 887 /** @type {string} */ |
| 888 const description = |
| 889 kDescription in options ? options[kDescription] : assembleDescription(); |
| 890 /** @type {boolean} */ |
| 891 const removeSampleIfSucceeded = kRemoveSampleIfSucceeded in options ? |
| 892 !!options[kRemoveSampleIfSucceeded] : |
| 893 true; |
| 894 /** @type {DumpAs} */ |
| 895 const dumpAs = options[kDumpAs] || DumpAs.DOM_TREE; |
| 896 |
| 897 checkExpectedText(expectedText); |
| 898 const sample = new Sample(inputText); |
| 899 if (typeof(tester) === 'function') { |
| 900 tester.call(window, sample.selection); |
| 901 } else if (typeof(tester) === 'string') { |
| 902 const strings = tester.split(/ (.+)/); |
| 903 sample.document.execCommand(strings[0], false, strings[1]); |
| 904 } else { |
| 905 throw new Error(`Invalid tester: ${tester}`); |
| 906 } |
| 907 |
| 908 /** @type {!Traversal} */ |
| 909 const traversal = (() => { |
| 910 switch (dumpAs) { |
| 911 case DumpAs.DOM_TREE: |
| 912 return new DOMTreeTraversal(); |
| 913 case DumpAs.FLAT_TREE: |
| 914 if (!window.internals) |
| 915 throw new Error('This test requires window.internals.'); |
| 916 return new FlatTreeTraversal(); |
| 917 default: |
| 918 throw `${kDumpAs} must be one of ` + |
| 919 `{${Object.values(DumpAs).join(', ')}}` + |
| 920 ` instead of '${dumpAs}'`; |
| 921 } |
| 922 })(); |
| 923 |
| 924 /** @type {string} */ |
| 925 const actualText = sample.serialize(traversal); |
| 926 // We keep sample HTML when assertion is false for ease of debugging test |
| 927 // case. |
| 928 if (actualText === expectedText) { |
| 929 if (removeSampleIfSucceeded) |
| 930 sample.remove(); |
| 931 return sample; |
| 932 } |
| 933 throw new Error( |
| 934 `${description}\n` + |
| 935 `\t expected ${expectedText},\n` + |
| 936 `\t but got ${actualText},\n` + |
| 937 `\t sameupto ${commonPrefixOf(expectedText, actualText)}`); |
| 938 } |
| 939 |
| 940 // Export symbols |
| 941 window.Sample = Sample; |
| 942 window.assert_selection = assertSelection; |
| 943 })(); |
| OLD | NEW |