Chromium Code Reviews| 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)| assertion | |
| 8 // 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 // Example: | |
| 22 // test(() => { | |
| 23 // assert_selection( | |
| 24 // '|foo', | |
| 25 // (selection) => selection.modify('extent', 'forward, 'character'), | |
| 26 // '<a href="http://bar">^f|oo</a>' | |
| 27 // }); | |
| 28 // | |
| 29 // test(() => { | |
| 30 // assert_selection( | |
| 31 // 'x^y|z', | |
| 32 // 'bold', // execCommand name as a test | |
| 33 // 'x<b>y</b>z', | |
| 34 // 'Insert B tag'); | |
| 35 // }); | |
| 36 // | |
| 37 // test(() => { | |
| 38 // assert_selection( | |
| 39 // 'x^y|z', | |
| 40 // 'createLink http://foo', // execCommand name and parameter | |
| 41 // 'x<a href="http://foo/">y</a></b>z', | |
| 42 // 'Insert B tag'); | |
| 43 // }); | |
| 44 // | |
| 45 // | |
| 46 | |
| 47 // TODO(yosin): Please use "clang-format -style=Chromium -i" for formatting | |
| 48 // this file. | |
| 49 | |
| 50 (function() { | |
| 51 /** | |
| 52 * @param {!Node} node | |
| 53 * @return {boolean} | |
| 54 */ | |
| 55 function isCharacterData(node) { | |
| 56 return node.nodeType === Node.TEXT_NODE || | |
| 57 node.nodeType === Node.COMMENT_NODE; | |
| 58 } | |
| 59 | |
| 60 /** | |
| 61 * @param {!Node} node | |
| 62 * @return {boolean} | |
| 63 */ | |
| 64 function isElement(node) { | |
| 65 return node.nodeType === Node.ELEMENT_NODE; | |
| 66 } | |
| 67 | |
| 68 /** | |
| 69 * @param {!Node} node | |
| 70 * @param {number} offset | |
| 71 */ | |
| 72 function checkValidNodeAndOffset(node, offset) { | |
| 73 if (!node) | |
| 74 throw new Error('Node parameter should not be a null.'); | |
| 75 if (offset < 0) | |
| 76 throw new Error(`Assumes ${offset} >= 0`); | |
| 77 if (isElement(node)) { | |
| 78 if (offset > node.childNodes.length) | |
| 79 throw new Error(`Bad offset ${offset} for ${node}`); | |
| 80 return; | |
| 81 } | |
| 82 if (isCharacterData(node)) { | |
| 83 if (offset > node.nodeValue.length) | |
| 84 throw new Error(`Bad offset ${offset} for ${node}`); | |
| 85 return; | |
| 86 } | |
| 87 throw new Error(`Invalid node: ${node}`); | |
| 88 } | |
| 89 | |
| 90 class SampleSelection { | |
| 91 /** @public */ | |
| 92 constructor() { | |
| 93 /** @type {?Node} */ | |
| 94 this.anchorNode_ = null; | |
| 95 /** @type {number} */ | |
| 96 this.anchorOffset_ = 0; | |
| 97 /** @type {?Node} */ | |
| 98 this.focusNode_ = null; | |
| 99 /** @type {number} */ | |
| 100 this.focusOffset_ = 0; | |
| 101 } | |
| 102 | |
| 103 /** | |
| 104 * @public | |
| 105 * @param {!Node} node | |
| 106 * @param {number} offset | |
| 107 */ | |
| 108 collapse(node, offset) { | |
| 109 checkValidNodeAndOffset(node, offset); | |
| 110 this.anchorNode_ = this.focusNode_ = node; | |
| 111 this.anchorOffset_ = this.focusOffset_ = offset; | |
| 112 } | |
| 113 | |
| 114 /** | |
| 115 * @public | |
| 116 * @param {!Node} node | |
| 117 * @param {number} offset | |
| 118 */ | |
| 119 extend(node, offset) { | |
| 120 checkValidNodeAndOffset(node, offset); | |
| 121 this.focusNode_ = node; | |
| 122 this.focusOffset_ = offset; | |
| 123 } | |
| 124 | |
| 125 /** @public @return {?Node} */ | |
| 126 get anchorNode() { | |
| 127 console.assert(!this.isNone, 'Selection should not be a none.'); | |
| 128 return this.anchorNode_; | |
| 129 } | |
| 130 /** @public @return {number} */ | |
| 131 get anchorOffset() { | |
| 132 console.assert(!this.isNone, 'Selection should not be a none.'); | |
| 133 return this.anchorOffset_; | |
| 134 } | |
| 135 /** @public @return {?Node} */ | |
| 136 get focusNode() { | |
| 137 console.assert(!this.isNone, 'Selection should not be a none.'); | |
| 138 return this.focusNode_; | |
| 139 } | |
| 140 /** @public @return {number} */ | |
| 141 get focusOffset() { | |
| 142 console.assert(!this.isNone, 'Selection should not be a none.'); | |
| 143 return this.focusOffset_; | |
| 144 } | |
| 145 | |
| 146 /** | |
| 147 * @public | |
| 148 * @return {boolean} | |
| 149 */ | |
| 150 get isCollapsed() { | |
| 151 return this.anchorNode === this.focusNode && | |
| 152 this.anchorOffset === this.focusOffset; | |
| 153 } | |
| 154 | |
| 155 /** | |
| 156 * @public | |
| 157 * @return {boolean} | |
| 158 */ | |
| 159 get isNone() { return this.anchorNode_ === null; } | |
| 160 | |
| 161 /** | |
| 162 * @public | |
| 163 * @param {!Selection} domSeleciton | |
| 164 * @return {!SampleSelection} | |
| 165 */ | |
| 166 static fromDOMSelection(domSelection) { | |
| 167 /** type {!SampleSelection} */ | |
| 168 const selection = new SampleSelection(); | |
| 169 selection.anchorNode_ = domSelection.anchorNode; | |
| 170 selection.anchorOffset_ = domSelection.anchorOffset; | |
| 171 selection.focusNode_ = domSelection.focusNode; | |
| 172 selection.focusOffset_ = domSelection.focusOffset; | |
| 173 return selection; | |
| 174 } | |
| 175 | |
| 176 /** @override */ | |
| 177 toString() { | |
| 178 if (this.isNone) | |
| 179 return 'SampleSelection()'; | |
| 180 if (this.isCollapsed) | |
| 181 return `SampleSelection(${this.focusNode_}@${this.focusOffset_})`; | |
| 182 return `SampleSelection(anchor: ${this.anchorNode_}@${this.anchorOffset_}` + | |
| 183 `focus: ${this.focusNode_}@${this.focusOffset_}`; | |
| 184 } | |
| 185 } | |
| 186 | |
| 187 // Extracts selection from marker "^" as anchor and "|" as focus from | |
| 188 // DOM tree and removes them. | |
| 189 class Parser { | |
| 190 /** @private */ | |
| 191 constructor() { | |
| 192 /** @type {?Node} */ | |
| 193 this.anchorNode_ = null; | |
| 194 /** @type {number} */ | |
| 195 this.anchorOffset_ = 0; | |
| 196 /** @type {?Node} */ | |
| 197 this.focusNode_ = null; | |
| 198 /** @type {number} */ | |
| 199 this.focusOffset_ = 0; | |
| 200 } | |
| 201 | |
| 202 /** | |
| 203 * @public | |
| 204 * @return {!SampleSelection} | |
| 205 */ | |
| 206 get selection() { | |
| 207 const selection = new SampleSelection(); | |
| 208 if (!this.anchorNode_ && !this.focusNode_) | |
| 209 return selection; | |
| 210 if (this.anchorNode_ && this.focusNode_) { | |
| 211 selection.collapse(this.anchorNode_, this.anchorOffset_); | |
| 212 selection.extend(this.focusNode_, this.focusOffset_); | |
| 213 return selection; | |
| 214 } | |
| 215 if (this.focusNode_) { | |
| 216 selection.collapse(this.focusNode_, this.focusOffset_); | |
| 217 return selection; | |
| 218 } | |
| 219 throw new Error('There is no focus marker'); | |
| 220 } | |
| 221 | |
| 222 /** | |
| 223 * @private | |
| 224 * @param {!CharacterData} node | |
| 225 * @param {number} nodeIndex | |
| 226 */ | |
| 227 handleCharacterData(node, nodeIndex) { | |
| 228 /** @type {string} */ | |
| 229 const text = node.nodeValue; | |
| 230 /** @type {number} */ | |
| 231 const anchorOffset = text.indexOf('^'); | |
| 232 /** @type {number} */ | |
| 233 const focusOffset = text.indexOf('|'); | |
| 234 /** @type {!Node} */ | |
| 235 const parentNode = node.parentNode; | |
| 236 node.nodeValue = text.replace('^', '').replace('|', ''); | |
| 237 if (node.nodeValue.length == 0) { | |
| 238 if (anchorOffset >= 0) | |
| 239 this.rememberSelectionAnchor(parentNode, nodeIndex); | |
| 240 if (focusOffset >= 0) | |
| 241 this.rememberSelectionFocus(parentNode, nodeIndex); | |
| 242 return; | |
| 243 } | |
| 244 if (anchorOffset >= 0 && focusOffset >= 0) { | |
| 245 if (anchorOffset > focusOffset) { | |
| 246 this.rememberSelectionAnchor(node, anchorOffset - 1); | |
| 247 this.rememberSelectionFocus(node, focusOffset); | |
| 248 return; | |
| 249 } | |
| 250 this.rememberSelectionAnchor(node, anchorOffset); | |
| 251 this.rememberSelectionFocus(node, focusOffset - 1); | |
| 252 return; | |
| 253 } | |
| 254 if (anchorOffset >= 0) { | |
| 255 this.rememberSelectionAnchor(node, anchorOffset); | |
| 256 return; | |
| 257 } | |
| 258 if (focusOffset < 0) | |
| 259 return; | |
| 260 this.rememberSelectionFocus(node, focusOffset); | |
| 261 } | |
| 262 | |
| 263 /** | |
| 264 * @private | |
| 265 * @param {!Element} element | |
| 266 */ | |
| 267 handleElementNode(element) { | |
| 268 /** @type {number} */ | |
| 269 let childIndex = 0; | |
| 270 for (const child of Array.from(element.childNodes)) { | |
| 271 this.parseInternal(child, childIndex); | |
| 272 if (!child.parentNode) | |
| 273 continue; | |
| 274 ++childIndex; | |
| 275 } | |
| 276 } | |
| 277 | |
| 278 /** | |
| 279 * @private | |
| 280 * @param {!Node} node | |
| 281 * @return {!SampleSelection} | |
| 282 */ | |
| 283 parse(node) { | |
| 284 this.parseInternal(node, 0); | |
| 285 return this.selection; | |
| 286 } | |
| 287 | |
| 288 /** | |
| 289 * @private | |
| 290 * @param {!Node} node | |
| 291 * @param {number} nodeIndex | |
| 292 */ | |
| 293 parseInternal(node, nodeIndex) { | |
| 294 if (isElement(node)) | |
| 295 return this.handleElementNode(node); | |
| 296 if (isCharacterData(node)) | |
| 297 return this.handleCharacterData(node); | |
| 298 throw new Error(`Unexpected node ${node}`); | |
| 299 } | |
| 300 | |
| 301 /** | |
| 302 * @private | |
| 303 * @param {!Node} node | |
| 304 * @param {number} offset | |
| 305 */ | |
| 306 rememberSelectionAnchor(node, offset) { | |
| 307 checkValidNodeAndOffset(node, offset); | |
| 308 console.assert( | |
| 309 this.anchorNode_ === null, 'Anchor marker should be one.', | |
| 310 this.anchorNode_, this.anchorOffset_); | |
| 311 this.anchorNode_ = node; | |
| 312 this.anchorOffset_ = offset; | |
| 313 } | |
| 314 | |
| 315 /** | |
| 316 * @private | |
| 317 * @param {!Node} node | |
| 318 * @param {number} offset | |
| 319 */ | |
| 320 rememberSelectionFocus(node, offset) { | |
| 321 checkValidNodeAndOffset(node, offset); | |
| 322 console.assert( | |
| 323 this.focusNode_ === null, 'Focus marker should be one.', | |
| 324 this.focusNode_, this.focusOffset_); | |
| 325 this.focusNode_ = node; | |
| 326 this.focusOffset_ = offset; | |
| 327 } | |
| 328 | |
| 329 /** | |
| 330 * @public | |
| 331 * @param {!Node} node | |
| 332 * @return {!SampleSelection} | |
| 333 */ | |
| 334 static parse(node) { return (new Parser()).parse(node); } | |
| 335 } | |
| 336 | |
| 337 /** @type {!Set<string>} */ | |
| 338 const END_TAG_OMISSIBLE_NAMES = new Set(['br', 'hr', 'img', 'input', 'wbr']); | |
|
tkent
2016/05/26 08:23:14
Can we refer to HTML5_VOID_ELEMENTS defined in imp
| |
| 339 | |
| 340 class Serializer { | |
| 341 /** | |
| 342 * @public | |
| 343 * @param {!SampleSelection} selection | |
| 344 */ | |
| 345 constructor(selection) { | |
| 346 /** @type {!SampleSelection} */ | |
| 347 this.selection_ = selection; | |
| 348 /** @type {!Array<strings>} */ | |
| 349 this.strings_ = []; | |
| 350 } | |
| 351 | |
| 352 /** | |
| 353 * @private | |
| 354 * @param {string} string | |
| 355 */ | |
| 356 emit(string) { this.strings_.push(string); } | |
| 357 | |
| 358 /** | |
| 359 * @private | |
| 360 * @param {!CharacterData} node | |
| 361 */ | |
| 362 handleCharacterData(node) { | |
| 363 /** @type {string} */ | |
| 364 const text = node.nodeValue; | |
| 365 /** @type {number} */ | |
| 366 const anchorOffset = this.selection_.anchorOffset; | |
| 367 /** @type {number} */ | |
| 368 const focusOffset = this.selection_.focusOffset; | |
| 369 if (node === this.selection_.focusNode && | |
| 370 node === this.selection_.anchorNode) { | |
| 371 if (anchorOffset === focusOffset) { | |
| 372 this.emit(text.substr(0, focusOffset)); | |
| 373 this.emit('|'); | |
| 374 this.emit(text.substr(focusOffset)); | |
| 375 return; | |
| 376 } | |
| 377 if (anchorOffset < focusOffset) { | |
| 378 this.emit(text.substr(0, anchorOffset)); | |
| 379 this.emit('^'); | |
| 380 this.emit(text.substr(anchorOffset, focusOffset - anchorOffset)); | |
| 381 this.emit('|'); | |
| 382 this.emit(text.substr(focusOffset)); | |
| 383 return; | |
| 384 } | |
| 385 this.emit(text.substr(0, focusOffset)); | |
| 386 this.emit('|'); | |
| 387 this.emit(text.substr(focusOffset, anchorOffset - focusOffset)); | |
| 388 this.emit('^'); | |
| 389 this.emit(text.substr(anchorOffset)); | |
| 390 return; | |
| 391 } | |
| 392 if (node === this.selection_.anchorNode) { | |
| 393 this.emit(text.substr(0, anchorOffset)); | |
| 394 this.emit('^'); | |
| 395 this.emit(text.substr(anchorOffset)); | |
| 396 return; | |
| 397 } | |
| 398 if (node === this.selection_.focusNode) { | |
| 399 this.emit(text.substr(0, focusOffset)); | |
| 400 this.emit('|'); | |
| 401 this.emit(text.substr(focusOffset)); | |
| 402 return; | |
| 403 } | |
| 404 this.emit(text); | |
| 405 } | |
| 406 | |
| 407 /** | |
| 408 * @private | |
| 409 * @param {!HTMLElement} element | |
| 410 */ | |
| 411 handleElementNode(element) { | |
| 412 if (element === this.selection_.focusNode && | |
| 413 nodeIndex === this.selection_.focusOffset) { | |
| 414 this.emit('|'); | |
| 415 } else if ( | |
| 416 element === this.selection_.anchorNode && | |
| 417 nodeIndex === this.selection_.anchorOffset) { | |
| 418 this.emit('^'); | |
| 419 } | |
| 420 /** @type {string} */ | |
| 421 const tagName = element.tagName.toLowerCase(); | |
| 422 this.emit(`<${tagName}`); | |
| 423 Array.from(element.attributes) | |
| 424 .sort((attr1, attr2) => attr1.name.localeCompare(attr2.name)) | |
| 425 .forEach(attr => { | |
| 426 if (attr.value === '') | |
| 427 return this.emit(` ${attr.name}`); | |
| 428 const value = attr.value.replace(/&/g, '&') | |
| 429 .replace(/\u0022/g, '"') | |
| 430 .replace(/\u0027/g, '''); | |
| 431 this.emit(` ${attr.name}="${value}"`); | |
| 432 }); | |
| 433 this.emit('>'); | |
| 434 if (element.childNodes.length === 0 && | |
| 435 END_TAG_OMISSIBLE_NAMES.has(tagName)) { | |
| 436 return; | |
| 437 } | |
| 438 /** @type {number} */ | |
| 439 let childIndex = 0; | |
| 440 for (const child of Array.from(element.childNodes)) { | |
| 441 this.serializeInternal(child, childIndex); | |
| 442 ++childIndex; | |
| 443 } | |
| 444 this.emit(`</${tagName}>`); | |
| 445 } | |
| 446 | |
| 447 /** | |
| 448 * @public | |
| 449 * @param {!HTMLElement} element | |
| 450 */ | |
| 451 serialize(element) { | |
| 452 if (this.selection_.isNone) | |
| 453 return node.outerHTML; | |
| 454 this.serializeInternal(element, 0); | |
| 455 return this.strings_.join(''); | |
| 456 } | |
| 457 | |
| 458 /** | |
| 459 * @private | |
| 460 * @param {!Node} node | |
| 461 * @param {number} nodeIndex | |
| 462 */ | |
| 463 serializeInternal(node, nodeIndex) { | |
| 464 if (isElement(node)) | |
| 465 return this.handleElementNode(node); | |
| 466 if (isCharacterData(node)) | |
| 467 return this.handleCharacterData(node); | |
| 468 throw new Error(`Unexpected node ${node}`); | |
| 469 } | |
| 470 } | |
| 471 | |
| 472 class Sample { | |
| 473 /** | |
| 474 * @public | |
| 475 * @param {string} sampleText | |
| 476 */ | |
| 477 constructor(sampleText) { | |
| 478 /** @const @type {!HTMLIFame} */ | |
| 479 this.iframe_ = document.createElement('iframe'); | |
| 480 document.body.appendChild(this.iframe_); | |
| 481 /** @const @type {!HTMLDocument} */ | |
| 482 this.document_ = this.iframe_.contentDocument; | |
| 483 /** @const @type {!Selection} */ | |
| 484 this.selection_ = this.iframe_.contentWindow.getSelection(); | |
| 485 this.load(sampleText); | |
| 486 } | |
| 487 | |
| 488 /** @return {!HTMLDocument} */ | |
| 489 get document() { return this.document_; } | |
| 490 | |
| 491 /** @return {!Selection} */ | |
| 492 get selection() { return this.selection_; } | |
| 493 | |
| 494 /** | |
| 495 * @private | |
| 496 * @param {string} sampleText | |
| 497 */ | |
| 498 load(sampleText) { | |
| 499 const anchorMarker = sampleText.indexOf('^'); | |
| 500 const focusMarker = sampleText.indexOf('|'); | |
| 501 if (focusMarker < 0) { | |
| 502 throw new Error(`You should specify caret position in "${sampleText}".`); | |
| 503 } | |
| 504 if (focusMarker != sampleText.lastIndexOf('|')) { | |
| 505 throw new Error( | |
| 506 `You should have at least one focus marker "|" in "${sampleText}".`); | |
| 507 } | |
| 508 if (anchorMarker != sampleText.lastIndexOf('^')) { | |
| 509 throw new Error( | |
| 510 `You should have at most one anchor marker "^" in "${sampleText}".`); | |
| 511 } | |
| 512 this.document_.body.innerHTML = sampleText; | |
| 513 /** @type {!SampleSelection} */ | |
| 514 const selection = Parser.parse(this.document_.body); | |
| 515 if (selection.isNone) | |
| 516 return; | |
| 517 this.selection_.collapse(selection.anchorNode, selection.anchorOffset); | |
| 518 this.selection_.extend(selection.focusNode, selection.focusOffset); | |
| 519 } | |
| 520 | |
| 521 /** | |
| 522 * @public | |
| 523 */ | |
| 524 remove() { this.iframe_.remove(); } | |
| 525 | |
| 526 /** | |
| 527 * @public | |
| 528 * @return {string} | |
| 529 */ | |
| 530 serialize() { | |
| 531 /** @type {!SampleSelection} */ | |
| 532 const selection = SampleSelection.fromDOMSelection(this.selection_); | |
| 533 /** @type {!Serializer} */ | |
| 534 const serializer = new Serializer(selection); | |
| 535 return serializer.serialize(this.document_.body.firstChild); | |
| 536 } | |
| 537 } | |
| 538 | |
| 539 function assembleDescription() { | |
| 540 const RE_TEST_FUNCTION = | |
| 541 new RegExp('at Object.test \\(.*?([^/]+?):(\\d+):(\\d+)\\)'); | |
| 542 function getStack() { | |
| 543 let stack; | |
| 544 try { | |
| 545 throw new Error('get line number'); | |
| 546 } catch (error) { | |
| 547 stack = error.stack.split('\n').slice(1); | |
| 548 } | |
| 549 return stack | |
| 550 } | |
| 551 for (const line of getStack()) { | |
| 552 const match = RE_TEST_FUNCTION.exec(line); | |
| 553 if (!match) | |
| 554 continue; | |
| 555 return `${match[1]}(${match[2]})`; | |
| 556 } | |
| 557 return ''; | |
| 558 } | |
| 559 | |
| 560 /** | |
| 561 * @param {string} beforeSample | |
| 562 * @param {function(!Selection)|string} | |
| 563 * @param {string} expectedText | |
| 564 * @param {string=} opt_description | |
| 565 */ | |
| 566 function assertSelection( | |
| 567 inputText, tester, expectedText, opt_description = '') { | |
| 568 /** @type {string} */ | |
| 569 const description = | |
| 570 opt_description === '' ? assembleDescription() : opt_description; | |
| 571 const sample = new Sample(inputText); | |
| 572 if (typeof(tester) === 'function') { | |
| 573 tester.call(window, sample.selection); | |
| 574 } else if (typeof(tester) === 'string') { | |
| 575 const strings = tester.split(' '); | |
| 576 sample.document.execCommand(strings[0], false, strings[1]); | |
| 577 } else { | |
| 578 throw new Error(`Invalid tester: ${tester}`); | |
| 579 } | |
| 580 /** @type {string} */ | |
| 581 const actualText = sample.serialize(); | |
| 582 // We keep sample HTML when assertion is false for ease of debugging test | |
| 583 // case. | |
| 584 if (actualText == expectedText) | |
| 585 sample.remove(); | |
| 586 assert_equals(actualText, expectedText, description); | |
| 587 } | |
| 588 | |
| 589 // Export symbols | |
| 590 window.Sample = Sample; | |
| 591 window.assert_selection = assertSelection; | |
| 592 })(); | |
| OLD | NEW |