"use strict"; // TODO: extend `EditorTestUtils` in editing/include/edit-test-utils.mjs const kBackspaceKey = "\uE003"; const kDeleteKey = "\uE017"; const kArrowRight = "\uE014"; const kArrowLeft = "\uE012"; const kShift = "\uE008"; const kMeta = "\uE03d"; const kControl = "\uE009"; const kAlt = "\uE00A"; const kKeyA = "a"; const kImgSrc = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEElEQVR42mNgaGD4D8YwBgAw9AX9Y9zBwwAAAABJRU5ErkJggg=="; let gSelection, gEditor, gBeforeinput, gInput; function initializeTest(aInnerHTML) { function onBeforeinput(event) { // NOTE: Blink makes `getTargetRanges()` return empty range after // propagation, but this test wants to check the result during // propagation. Therefore, we need to cache the result, but will // assert if `getTargetRanges()` returns different ranges after // checking the cached ranges. event.cachedRanges = event.getTargetRanges(); gBeforeinput.push(event); } function onInput(event) { event.cachedRanges = event.getTargetRanges(); gInput.push(event); } if (gEditor !== document.querySelector("div[contenteditable]")) { if (gEditor) { gEditor.isListeningToInputEvents = false; gEditor.removeEventListener("beforeinput", onBeforeinput); gEditor.removeEventListener("input", onInput); } gEditor = document.querySelector("div[contenteditable]"); } gSelection = getSelection(); gBeforeinput = []; gInput = []; if (!gEditor.isListeningToInputEvents) { gEditor.isListeningToInputEvents = true; gEditor.addEventListener("beforeinput", onBeforeinput); gEditor.addEventListener("input", onInput); } setupEditor(aInnerHTML); gBeforeinput = []; gInput = []; } function getArrayOfRangesDescription(arrayOfRanges) { if (arrayOfRanges === null) { return "null"; } if (arrayOfRanges === undefined) { return "undefined"; } if (!Array.isArray(arrayOfRanges)) { return "Unknown Object"; } if (arrayOfRanges.length === 0) { return "[]"; } let result = "["; for (let range of arrayOfRanges) { result += `{${getRangeDescription(range)}},`; } result += "]"; return result; } function getRangeDescription(range) { function getNodeDescription(node) { if (!node) { return "null"; } switch (node.nodeType) { case Node.TEXT_NODE: case Node.COMMENT_NODE: case Node.CDATA_SECTION_NODE: return `${node.nodeName} "${node.data}"`; case Node.ELEMENT_NODE: return `<${node.nodeName.toLowerCase()}>`; default: return `${node.nodeName}`; } } if (range === null) { return "null"; } if (range === undefined) { return "undefined"; } return range.startContainer == range.endContainer && range.startOffset == range.endOffset ? `(${getNodeDescription(range.startContainer)}, ${range.startOffset})` : `(${getNodeDescription(range.startContainer)}, ${ range.startOffset }) - (${getNodeDescription(range.endContainer)}, ${range.endOffset})`; } function sendDeleteKey(modifier) { if (!modifier) { return new test_driver.Actions() .keyDown(kDeleteKey) .keyUp(kDeleteKey) .send(); } return new test_driver.Actions() .keyDown(modifier) .keyDown(kDeleteKey) .keyUp(kDeleteKey) .keyUp(modifier) .send(); } function sendBackspaceKey(modifier) { if (!modifier) { return new test_driver.Actions() .keyDown(kBackspaceKey) .keyUp(kBackspaceKey) .send(); } return new test_driver.Actions() .keyDown(modifier) .keyDown(kBackspaceKey) .keyUp(kBackspaceKey) .keyUp(modifier) .send(); } function sendKeyA() { return new test_driver.Actions() .keyDown(kKeyA) .keyUp(kKeyA) .send(); } function sendArrowLeftKey() { return new test_driver.Actions() .keyDown(kArrowLeft) .keyUp(kArrowLeft) .send(); } function sendArrowRightKey() { return new test_driver.Actions() .keyDown(kArrowRight) .keyUp(kArrowRight) .send(); } function checkGetTargetRangesOfBeforeinputOnDeleteSomething(expectedRanges) { assert_equals( gBeforeinput.length, 1, "One beforeinput event should be fired if the key operation tries to delete something" ); assert_true( Array.isArray(gBeforeinput[0].cachedRanges), "gBeforeinput[0].getTargetRanges() should return an array of StaticRange instances at least during propagation" ); let arrayOfExpectedRanges = Array.isArray(expectedRanges) ? expectedRanges : [expectedRanges]; // Before checking the length of array of ranges, we should check the given // range first because the ranges are more important than whether there are // redundant additional unexpected ranges. for ( let i = 0; i < Math.max(arrayOfExpectedRanges.length, gBeforeinput[0].cachedRanges.length); i++ ) { assert_equals( getRangeDescription(gBeforeinput[0].cachedRanges[i]), getRangeDescription(arrayOfExpectedRanges[i]), `gBeforeinput[0].getTargetRanges()[${i}] should return expected range (inputType is "${gBeforeinput[0].inputType}")` ); } assert_equals( gBeforeinput[0].cachedRanges.length, arrayOfExpectedRanges.length, `getTargetRanges() of beforeinput event should return ${arrayOfExpectedRanges.length} ranges` ); } function checkGetTargetRangesOfInputOnDeleteSomething() { assert_equals( gInput.length, 1, "One input event should be fired if the key operation deletes something" ); // https://github.com/w3c/input-events/issues/113 assert_true( Array.isArray(gInput[0].cachedRanges), "gInput[0].getTargetRanges() should return an array of StaticRange instances at least during propagation" ); assert_equals( gInput[0].cachedRanges.length, 0, "gInput[0].getTargetRanges() should return empty array during propagation" ); } function checkGetTargetRangesOfInputOnDoNothing() { assert_equals( gInput.length, 0, "input event shouldn't be fired when the key operation does not cause modifying the DOM tree" ); } function checkBeforeinputAndInputEventsOnNOOP() { assert_equals( gBeforeinput.length, 0, "beforeinput event shouldn't be fired when the key operation does not cause modifying the DOM tree" ); assert_equals( gInput.length, 0, "input event shouldn't be fired when the key operation does not cause modifying the DOM tree" ); } function checkEditorContentResultAsSubTest( expectedResult, description, options = {} ) { test(() => { if (Array.isArray(expectedResult)) { assert_in_array( options.ignoreWhiteSpaceDifference ? gEditor.innerHTML.replace(/ /g, " ") : gEditor.innerHTML, expectedResult ); } else { assert_equals( options.ignoreWhiteSpaceDifference ? gEditor.innerHTML.replace(/ /g, " ") : gEditor.innerHTML, expectedResult ); } }, `${description} - comparing innerHTML`); } // Similar to `setupDiv` in editing/include/tests.js, this method sets // innerHTML value of gEditor, and sets multiple selection ranges specified // with the markers. // - `[` specifies start boundary in a text node // - `{` specifies start boundary before a node // - `]` specifies end boundary in a text node // - `}` specifies end boundary after a node function setupEditor(innerHTMLWithRangeMarkers) { const startBoundaries = innerHTMLWithRangeMarkers.match(/\{|\[/g) || []; const endBoundaries = innerHTMLWithRangeMarkers.match(/\}|\]/g) || []; if (startBoundaries.length !== endBoundaries.length) { throw "Should match number of open/close markers"; } gEditor.innerHTML = innerHTMLWithRangeMarkers; gEditor.focus(); if (startBoundaries.length === 0) { // Don't remove the range for now since some tests may assume that // setting innerHTML does not remove all selection ranges. return; } function getNextRangeAndDeleteMarker(startNode) { function getNextLeafNode(node) { function inclusiveDeepestFirstChildNode(container) { while (container.firstChild) { container = container.firstChild; } return container; } if (node.hasChildNodes()) { return inclusiveDeepestFirstChildNode(node); } if (node.nextSibling) { return inclusiveDeepestFirstChildNode(node.nextSibling); } let nextSibling = (function nextSiblingOfAncestorElement(child) { for ( let parent = child.parentElement; parent && parent != gEditor; parent = parent.parentElement ) { if (parent.nextSibling) { return parent.nextSibling; } } return null; })(node); if (!nextSibling) { return null; } return inclusiveDeepestFirstChildNode(nextSibling); } function scanMarkerInTextNode(textNode, offset) { return /[\{\[\]\}]/.exec(textNode.data.substr(offset)); } let startMarker = (function scanNextStartMaker( startContainer, startOffset ) { function scanStartMakerInTextNode(textNode, offset) { let scanResult = scanMarkerInTextNode(textNode, offset); if (scanResult === null) { return null; } if (scanResult[0] === "}" || scanResult[0] === "]") { throw "An end marker is found before a start marker"; } return { marker: scanResult[0], container: textNode, offset: scanResult.index + offset }; } if (startContainer.nodeType === Node.TEXT_NODE) { let scanResult = scanStartMakerInTextNode(startContainer, startOffset); if (scanResult !== null) { return scanResult; } } let nextNode = startContainer; while ((nextNode = getNextLeafNode(nextNode))) { if (nextNode.nodeType === Node.TEXT_NODE) { let scanResult = scanStartMakerInTextNode(nextNode, 0); if (scanResult !== null) { return scanResult; } continue; } } return null; })(startNode, 0); if (startMarker === null) { return null; } let endMarker = (function scanNextEndMarker(startContainer, startOffset) { function scanEndMarkerInTextNode(textNode, offset) { let scanResult = scanMarkerInTextNode(textNode, offset); if (scanResult === null) { return null; } if (scanResult[0] === "{" || scanResult[0] === "[") { throw "A start marker is found before an end marker"; } return { marker: scanResult[0], container: textNode, offset: scanResult.index + offset }; } if (startContainer.nodeType === Node.TEXT_NODE) { let scanResult = scanEndMarkerInTextNode(startContainer, startOffset); if (scanResult !== null) { return scanResult; } } let nextNode = startContainer; while ((nextNode = getNextLeafNode(nextNode))) { if (nextNode.nodeType === Node.TEXT_NODE) { let scanResult = scanEndMarkerInTextNode(nextNode, 0); if (scanResult !== null) { return scanResult; } continue; } } return null; })(startMarker.container, startMarker.offset + 1); if (endMarker === null) { throw "Found an open marker, but not found corresponding close marker"; } function indexOfContainer(container, child) { let offset = 0; for (let node = container.firstChild; node; node = node.nextSibling) { if (node == child) { return offset; } offset++; } throw "child must be a child node of container"; } (function deleteFoundMarkers() { function removeNode(node) { let container = node.parentElement; let offset = indexOfContainer(container, node); node.remove(); return { container, offset }; } if (startMarker.container == endMarker.container) { // If the text node becomes empty, remove it and set collapsed range // to the position where there is the text node. if (startMarker.container.length === 2) { if (!/[\[\{][\]\}]/.test(startMarker.container.data)) { throw `Unexpected text node (data: "${startMarker.container.data}")`; } let { container, offset } = removeNode(startMarker.container); startMarker.container = endMarker.container = container; startMarker.offset = endMarker.offset = offset; startMarker.marker = endMarker.marker = ""; return; } startMarker.container.data = `${startMarker.container.data.substring( 0, startMarker.offset )}${startMarker.container.data.substring( startMarker.offset + 1, endMarker.offset )}${startMarker.container.data.substring(endMarker.offset + 1)}`; if (startMarker.offset >= startMarker.container.length) { startMarker.offset = endMarker.offset = startMarker.container.length; return; } endMarker.offset--; // remove the start marker's length if (endMarker.offset > endMarker.container.length) { endMarker.offset = endMarker.container.length; } return; } if (startMarker.container.length === 1) { let { container, offset } = removeNode(startMarker.container); startMarker.container = container; startMarker.offset = offset; startMarker.marker = ""; } else { startMarker.container.data = `${startMarker.container.data.substring( 0, startMarker.offset )}${startMarker.container.data.substring(startMarker.offset + 1)}`; } if (endMarker.container.length === 1) { let { container, offset } = removeNode(endMarker.container); endMarker.container = container; endMarker.offset = offset; endMarker.marker = ""; } else { endMarker.container.data = `${endMarker.container.data.substring( 0, endMarker.offset )}${endMarker.container.data.substring(endMarker.offset + 1)}`; } })(); (function handleNodeSelectMarker() { if (startMarker.marker === "{") { if (startMarker.offset === 0) { // The range start with the text node. let container = startMarker.container.parentElement; startMarker.offset = indexOfContainer( container, startMarker.container ); startMarker.container = container; } else if (startMarker.offset === startMarker.container.data.length) { // The range start after the text node. let container = startMarker.container.parentElement; startMarker.offset = indexOfContainer(container, startMarker.container) + 1; startMarker.container = container; } else { throw 'Start marker "{" is allowed start or end of a text node'; } } if (endMarker.marker === "}") { if (endMarker.offset === 0) { // The range ends before the text node. let container = endMarker.container.parentElement; endMarker.offset = indexOfContainer(container, endMarker.container); endMarker.container = container; } else if (endMarker.offset === endMarker.container.data.length) { // The range ends with the text node. let container = endMarker.container.parentElement; endMarker.offset = indexOfContainer(container, endMarker.container) + 1; endMarker.container = container; } else { throw 'End marker "}" is allowed start or end of a text node'; } } })(); let range = document.createRange(); range.setStart(startMarker.container, startMarker.offset); range.setEnd(endMarker.container, endMarker.offset); return range; } let ranges = []; for ( let range = getNextRangeAndDeleteMarker(gEditor.firstChild); range; range = getNextRangeAndDeleteMarker(range.endContainer) ) { ranges.push(range); } gSelection.removeAllRanges(); for (let range of ranges) { gSelection.addRange(range); } if (gSelection.rangeCount != ranges.length) { throw `Failed to set selection to the given ranges whose length is ${ranges.length}, but only ${gSelection.rangeCount} ranges are added`; } }