| (() => { |
| class Obfuscator { |
| constructor() { |
| this._scrambledLowercaseLetters = this._scramble(Array(26).fill().map((_, i) => 97 + i)); |
| this._scrambledUppercaseLetters = this._scramble(Array(26).fill().map((_, i) => 65 + i)); |
| this._scrambledNumbers = this._scramble(Array(10).fill().map((_, i) => 48 + i)); |
| this.enabled = false; |
| } |
| |
| _scramble(array) { |
| for (var i = array.length - 1; i > 0; i--) { |
| let j = Math.floor(Math.random() * (i + 1)); |
| let temp = array[i]; |
| array[i] = array[j]; |
| array[j] = temp; |
| } |
| return array; |
| } |
| |
| applyToText(text) { |
| if (!this.enabled || !text) |
| return text; |
| |
| let result = ""; |
| for (let index = 0; index < text.length; index++) { |
| let code = text.charCodeAt(index); |
| let numberIndex = this._scrambedNumberIndexForCode(code); |
| let lowercaseIndex = this._scrambedLowercaseIndexForCode(code); |
| let uppercaseIndex = this._scrambedUppercaseIndexForCode(code); |
| |
| if (numberIndex != null) |
| result += String.fromCharCode(this._scrambledNumbers[numberIndex]); |
| else if (lowercaseIndex != null) |
| result += String.fromCharCode(this._scrambledLowercaseLetters[lowercaseIndex]); |
| else if (uppercaseIndex != null) |
| result += String.fromCharCode(this._scrambledUppercaseLetters[uppercaseIndex]); |
| else |
| result += text.charAt(index); |
| } |
| return result; |
| } |
| |
| applyToFilename(filename) { |
| if (!this.enabled || !filename) |
| return filename; |
| |
| let components = filename.split("."); |
| return components.map((component, index) => { |
| if (index == components.length - 1) |
| return component; |
| |
| return this.applyToText(component); |
| }).join("."); |
| } |
| |
| _scrambedNumberIndexForCode(code) { |
| return 48 <= code && code <= 57 ? code - 48 : null; |
| } |
| |
| _scrambedLowercaseIndexForCode(code) { |
| return 97 <= code && code <= 122 ? code - 97 : null; |
| } |
| |
| _scrambedUppercaseIndexForCode(code) { |
| return 65 <= code && code <= 90 ? code - 65 : null; |
| } |
| |
| static shared() { |
| if (!Obfuscator._sharedInstance) |
| Obfuscator._sharedInstance = new Obfuscator(); |
| return Obfuscator._sharedInstance; |
| } |
| } |
| |
| function elementFromMarkdown(html) { |
| let temporaryDiv = document.createElement("div"); |
| temporaryDiv.innerHTML = html; |
| return temporaryDiv.children[0]; |
| } |
| |
| class GlobalNodeMap { |
| constructor(nodesByGUID) { |
| this._nodesByGUID = nodesByGUID ? nodesByGUID : new Map(); |
| this._guidsByNode = new Map(); |
| this._currentGUID = 0; |
| for (let [guid, node] of this._nodesByGUID) { |
| this._guidsByNode.set(node, guid); |
| this._currentGUID = Math.max(this._currentGUID, guid); |
| } |
| this._currentGUID++; |
| } |
| |
| nodesForGUIDs(guids) { |
| if (!guids.map) |
| guids = Array.from(guids); |
| return guids.map(guid => this.nodeForGUID(guid)); |
| } |
| |
| guidsForNodes(nodes) { |
| if (!nodes.map) |
| nodes = Array.from(nodes); |
| return nodes.map(node => this.guidForNode(node)); |
| } |
| |
| nodeForGUID(guid) { |
| if (!guid) |
| return null; |
| |
| return this._nodesByGUID.get(guid); |
| } |
| |
| guidForNode(node) { |
| if (!node) |
| return 0; |
| |
| if (this.hasGUIDForNode(node)) |
| return this._guidsByNode.get(node); |
| |
| const guid = this._currentGUID; |
| this._guidsByNode.set(node, guid); |
| this._nodesByGUID.set(guid, node); |
| this._currentGUID++; |
| return guid; |
| } |
| |
| hasGUIDForNode(node) { |
| return !!this._guidsByNode.get(node); |
| } |
| |
| nodes() { |
| return Array.from(this._nodesByGUID.values()); |
| } |
| |
| toObject() { |
| let nodesAndGUIDsToProcess = [], guidsToProcess = new Set(); |
| let guidsByNodeIterator = this._guidsByNode.entries(); |
| for (let entry = guidsByNodeIterator.next(); !entry.done; entry = guidsByNodeIterator.next()) { |
| nodesAndGUIDsToProcess.push(entry.value); |
| guidsToProcess.add(entry.value[1]); |
| } |
| |
| let iterator = document.createNodeIterator(document.body, NodeFilter.SHOW_ALL); |
| for (let node = iterator.nextNode(); node; node = iterator.nextNode()) { |
| if (this.hasGUIDForNode(node)) |
| continue; |
| |
| let newGUID = this.guidForNode(node); |
| nodesAndGUIDsToProcess.push([node, newGUID]); |
| guidsToProcess.add(newGUID); |
| } |
| |
| let nodeInfoArray = []; |
| while (nodesAndGUIDsToProcess.length) { |
| let [node, guid] = nodesAndGUIDsToProcess.pop(); |
| let info = {}; |
| info.guid = guid; |
| info.tagName = node.tagName; |
| info.attributes = GlobalNodeMap.nodeAttributesToObject(node); |
| info.type = node.nodeType; |
| info.data = GlobalNodeMap.dataForNode(node); |
| if (node.hasChildNodes()) { |
| info.childGUIDs = this.guidsForNodes(node.childNodes); |
| for (let childGUID of info.childGUIDs) { |
| if (!guidsToProcess.has(childGUID)) |
| nodesAndGUIDsToProcess.push([this.nodeForGUID(childGUID), childGUID]); |
| } |
| } |
| nodeInfoArray.push(info); |
| } |
| |
| return nodeInfoArray; |
| } |
| |
| static fromObject(nodeInfoArray) { |
| let nodesByGUID = new Map(); |
| for (let info of nodeInfoArray) { |
| let node = null; |
| if (info.type == Node.ELEMENT_NODE) |
| node = GlobalNodeMap.elementFromTagName(info.tagName, info.attributes, info.data); |
| |
| if (info.type == Node.TEXT_NODE) |
| node = document.createTextNode(info.data); |
| |
| if (info.type == Node.DOCUMENT_NODE) |
| node = document; |
| |
| console.assert(node); |
| nodesByGUID.set(info.guid, node); |
| } |
| |
| // Then, set child nodes for all nodes that do not appear in the DOM. |
| for (let info of nodeInfoArray.filter(info => !!info.childGUIDs)) { |
| let node = nodesByGUID.get(info.guid); |
| for (let childGUID of info.childGUIDs) |
| node.appendChild(nodesByGUID.get(childGUID)); |
| } |
| |
| return new GlobalNodeMap(nodesByGUID); |
| } |
| |
| static dataForNode(node) { |
| if (node.nodeType === Node.TEXT_NODE) |
| return Obfuscator.shared().applyToText(node.data); |
| |
| if (node.tagName && node.tagName.toLowerCase() === "attachment") { |
| return { |
| type: node.file.type, |
| name: Obfuscator.shared().applyToFilename(node.file.name), |
| lastModified: new Date().getTime() |
| }; |
| } |
| |
| return null; |
| } |
| |
| static elementFromTagName(tagName, attributes, data) { |
| let node = document.createElement(tagName); |
| for (let attributeName in attributes) |
| node.setAttribute(attributeName, attributes[attributeName]); |
| |
| if (tagName.toLowerCase() == "attachment") { |
| node.file = new File([`File named '${data.name}'`], data.name, { |
| type: data.type, |
| lastModified: data.lastModified |
| }); |
| } |
| |
| return node; |
| } |
| |
| // Returns an Object containing attribute name => attribute value |
| static nodeAttributesToObject(node, attributesToExclude=[]) { |
| const excludeAttributesSet = new Set(attributesToExclude); |
| if (!node.attributes) |
| return null; |
| |
| let attributeMap = {}; |
| for (let index = 0; index < node.attributes.length; index++) { |
| const attribute = node.attributes.item(index); |
| const [localName, value] = [attribute.localName, attribute.value]; |
| if (excludeAttributesSet.has(localName)) |
| continue; |
| |
| attributeMap[localName] = value; |
| } |
| |
| return attributeMap; |
| } |
| |
| descriptionHTMLForGUID(guid) { |
| return `<span eh-guid=${guid} class="eh-node">${this.nodeForGUID(guid).nodeName}</span>`; |
| } |
| |
| descriptionHTMLForNode(node) { |
| if (!node) |
| return "(null)"; |
| return `<span eh-guid=${this.guidForNode(node)} class="eh-node">${node.nodeName}</span>`; |
| } |
| } |
| |
| class SelectionState { |
| constructor(nodeMap, startNode, startOffset, endNode, endOffset, anchorNode, anchorOffset, focusNode, focusOffset) { |
| console.assert(nodeMap); |
| this.nodeMap = nodeMap; |
| this.startGUID = nodeMap.guidForNode(startNode); |
| this.startOffset = startOffset; |
| this.endGUID = nodeMap.guidForNode(endNode); |
| this.endOffset = endOffset; |
| this.anchorGUID = nodeMap.guidForNode(anchorNode); |
| this.anchorOffset = anchorOffset; |
| this.focusGUID = nodeMap.guidForNode(focusNode); |
| this.focusOffset = focusOffset; |
| } |
| |
| isEqual(otherSelectionState) { |
| return otherSelectionState |
| && this.startGUID === otherSelectionState.startGUID && this.startOffset === otherSelectionState.startOffset |
| && this.endGUID === otherSelectionState.endGUID && this.endOffset === otherSelectionState.endOffset |
| && this.anchorGUID === otherSelectionState.anchorGUID && this.anchorOffset === otherSelectionState.anchorOffset |
| && this.focusGUID === otherSelectionState.focusGUID && this.focusOffset === otherSelectionState.focusOffset; |
| } |
| |
| applyToSelection(selection) { |
| selection.removeAllRanges(); |
| let range = document.createRange(); |
| range.setStart(this.nodeMap.nodeForGUID(this.startGUID), this.startOffset); |
| range.setEnd(this.nodeMap.nodeForGUID(this.endGUID), this.endOffset); |
| selection.addRange(range); |
| selection.setBaseAndExtent(this.nodeMap.nodeForGUID(this.anchorGUID), this.anchorOffset, this.nodeMap.nodeForGUID(this.focusGUID), this.focusOffset); |
| } |
| |
| static fromSelection(selection, nodeMap) { |
| let [startNode, startOffset, endNode, endOffset] = [null, 0, null, 0]; |
| if (selection.rangeCount) { |
| let selectedRange = selection.getRangeAt(0); |
| startNode = selectedRange.startContainer; |
| startOffset = selectedRange.startOffset; |
| endNode = selectedRange.endContainer; |
| endOffset = selectedRange.endOffset; |
| } |
| return new SelectionState( |
| nodeMap, startNode, startOffset, endNode, endOffset, |
| selection.anchorNode, selection.anchorOffset, selection.focusNode, selection.focusOffset |
| ); |
| } |
| |
| toObject() { |
| return { |
| startGUID: this.startGUID, startOffset: this.startOffset, endGUID: this.endGUID, endOffset: this.endOffset, |
| anchorGUID: this.anchorGUID, anchorOffset: this.anchorOffset, focusGUID: this.focusGUID, focusOffset: this.focusOffset |
| }; |
| } |
| |
| static fromObject(json, nodeMap) { |
| if (!json) |
| return null; |
| |
| return new SelectionState( |
| nodeMap, nodeMap.nodeForGUID(json.startGUID), json.startOffset, nodeMap.nodeForGUID(json.endGUID), json.endOffset, |
| nodeMap.nodeForGUID(json.anchorGUID), json.anchorOffset, nodeMap.nodeForGUID(json.focusGUID), json.focusOffset |
| ); |
| } |
| } |
| |
| class DOMUpdate { |
| constructor(nodeMap) { |
| console.assert(nodeMap); |
| this.nodeMap = nodeMap; |
| } |
| |
| apply() { |
| throw "Expected subclass implementation."; |
| } |
| |
| unapply() { |
| throw "Expected subclass implementation."; |
| } |
| |
| targetNode() { |
| return this.nodeMap.nodeForGUID(this.targetGUID); |
| } |
| |
| detailsElement() { |
| throw "Expected subclass implementation."; |
| } |
| |
| static ofType(type) { |
| if (!DOMUpdate._allTypes) |
| DOMUpdate._allTypes = { ChildListUpdate, CharacterDataUpdate, AttributeUpdate, InputEventUpdate, SelectionUpdate }; |
| return DOMUpdate._allTypes[type]; |
| } |
| |
| static fromRecords(records, nodeMap) { |
| let updates = [] |
| , characterDataUpdates = [] |
| , attributeUpdates = []; |
| |
| for (let record of records) { |
| let target = record.target; |
| switch (record.type) { |
| case "characterData": |
| var update = new CharacterDataUpdate(nodeMap, nodeMap.guidForNode(target), record.oldValue, target.data) |
| updates.push(update); |
| characterDataUpdates.push(update); |
| break; |
| case "childList": |
| var update = new ChildListUpdate(nodeMap, nodeMap.guidForNode(target), nodeMap.guidsForNodes(record.addedNodes), nodeMap.guidsForNodes(record.removedNodes), nodeMap.guidForNode(record.nextSibling)) |
| updates.push(update); |
| break; |
| case "attributes": |
| var update = new AttributeUpdate(nodeMap, nodeMap.guidForNode(target), record.attributeName, record.oldValue, target.getAttribute(record.attributeName)) |
| updates.push(update); |
| attributeUpdates.push(update); |
| break; |
| } |
| } |
| |
| // Adjust all character data updates for the same target. |
| characterDataUpdates.forEach((currentUpdate, index) => { |
| if (index == characterDataUpdates.length - 1) |
| return; |
| |
| for (let nextUpdateIndex = index + 1; nextUpdateIndex < characterDataUpdates.length; nextUpdateIndex++) { |
| let nextUpdate = characterDataUpdates[nextUpdateIndex]; |
| if (currentUpdate.targetGUID === nextUpdate.targetGUID) { |
| currentUpdate.newData = nextUpdate.oldData; |
| break; |
| } |
| } |
| }); |
| |
| // Adjust all attribute updates for the same target and attribute name. |
| attributeUpdates.forEach((currentUpdate, index) => { |
| if (index == attributeUpdates.length - 1) |
| return; |
| |
| for (let nextUpdateIndex = index + 1; nextUpdateIndex < attributeUpdates.length; nextUpdateIndex++) { |
| let nextUpdate = attributeUpdates[nextUpdateIndex]; |
| if (currentUpdate.targetGUID === nextUpdate.targetGUID && currentUpdate.attribute === nextUpdate.attribute) { |
| currentUpdate.newData = nextUpdate.oldData; |
| break; |
| } |
| } |
| }); |
| |
| return updates; |
| } |
| } |
| |
| class ChildListUpdate extends DOMUpdate { |
| constructor(nodeMap, targetGUID, addedGUIDs, removedGUIDs, nextSiblingGUID) { |
| super(nodeMap); |
| this.targetGUID = targetGUID; |
| this.added = addedGUIDs; |
| this.removed = removedGUIDs; |
| this.nextSiblingGUID = nextSiblingGUID == undefined ? null : nextSiblingGUID; |
| console.assert(nodeMap.nodeForGUID(targetGUID)); |
| } |
| |
| apply() { |
| for (let removedNode of this._removedNodes()) |
| removedNode.remove(); |
| |
| let target = this.targetNode(); |
| for (let addedNode of this._addedNodes()) |
| target.insertBefore(addedNode, this._nextSibling()); |
| } |
| |
| unapply() { |
| for (let addedNode of this._addedNodes()) |
| addedNode.remove(); |
| |
| let target = this.targetNode(); |
| for (let removedNode of this._removedNodes()) |
| target.insertBefore(removedNode, this._nextSibling()); |
| } |
| |
| _nextSibling() { |
| if (this.nextSiblingGUID == null) |
| return null; |
| return this.nodeMap.nodeForGUID(this.nextSiblingGUID); |
| } |
| |
| _removedNodes() { |
| return this.nodeMap.nodesForGUIDs(this.removed); |
| } |
| |
| _addedNodes() { |
| return this.nodeMap.nodesForGUIDs(this.added); |
| } |
| |
| toObject() { |
| return { |
| type: "ChildListUpdate", |
| targetGUID: this.targetGUID, |
| addedGUIDs: this.added, |
| removedGUIDs: this.removed, |
| nextSiblingGUID: this.nextSiblingGUID |
| }; |
| } |
| |
| detailsElement() { |
| let nextSibling = this._nextSibling(); |
| let html = |
| `<details> |
| <summary>child list changed</summary> |
| <ul> |
| <li>parent: ${this.nodeMap.descriptionHTMLForGUID(this.targetGUID)}</li> |
| <li>added: [ ${[this._addedNodes().map(node => this.nodeMap.descriptionHTMLForNode(node))]} ]</li> |
| <li>removed: [ ${[this._removedNodes().map(node => this.nodeMap.descriptionHTMLForNode(node))]} ]</li> |
| <li>before sibling: ${nextSibling ? this.nodeMap.descriptionHTMLForNode(nextSibling) : "(null)"}</li> |
| </ul> |
| </details>`; |
| return elementFromMarkdown(html); |
| } |
| |
| static fromObject(json, nodeMap) { |
| return new ChildListUpdate(nodeMap, json.targetGUID, json.addedGUIDs, json.removedGUIDs, json.nextSiblingGUID); |
| } |
| } |
| |
| class CharacterDataUpdate extends DOMUpdate { |
| constructor(nodeMap, targetGUID, oldData, newData) { |
| super(nodeMap); |
| this.targetGUID = targetGUID; |
| this.oldData = oldData; |
| this.newData = newData; |
| console.assert(nodeMap.nodeForGUID(targetGUID)); |
| } |
| |
| apply() { |
| this.targetNode().data = this.newData; |
| } |
| |
| unapply() { |
| this.targetNode().data = this.oldData; |
| } |
| |
| detailsElement() { |
| let html = |
| `<details> |
| <summary>character data changed</summary> |
| <ul> |
| <li>old: ${this.oldData != null ? "'" + this.oldData + "'" : "(null)"}</li> |
| <li>new: ${this.newData != null ? "'" + this.newData + "'" : "(null)"}</li> |
| </ul> |
| </details>`; |
| return elementFromMarkdown(html); |
| } |
| |
| toObject() { |
| return { |
| type: "CharacterDataUpdate", |
| targetGUID: this.targetGUID, |
| oldData: Obfuscator.shared().applyToText(this.oldData), |
| newData: Obfuscator.shared().applyToText(this.newData) |
| }; |
| } |
| |
| static fromObject(json, nodeMap) { |
| return new CharacterDataUpdate(nodeMap, json.targetGUID, json.oldData, json.newData); |
| } |
| } |
| |
| class AttributeUpdate extends DOMUpdate { |
| constructor(nodeMap, targetGUID, attribute, oldValue, newValue) { |
| super(nodeMap); |
| this.targetGUID = targetGUID; |
| this.attribute = attribute; |
| this.oldValue = oldValue; |
| this.newValue = newValue; |
| console.assert(nodeMap.nodeForGUID(targetGUID)); |
| } |
| |
| apply() { |
| if (this.newValue == null) |
| this.targetNode().removeAttribute(this.attribute); |
| else |
| this.targetNode().setAttribute(this.attribute, this.newValue); |
| } |
| |
| unapply() { |
| if (this.oldValue == null) |
| this.targetNode().removeAttribute(this.attribute); |
| else |
| this.targetNode().setAttribute(this.attribute, this.oldValue); |
| } |
| |
| detailsElement() { |
| let html = |
| `<details> |
| <summary>attribute changed</summary> |
| <ul> |
| <li>target: ${this.nodeMap.descriptionHTMLForGUID(this.targetGUID)}</li> |
| <li>attribute: ${this.attribute}</li> |
| <li>old: ${this.oldValue != null ? "'" + this.oldValue + "'" : "(null)"}</li> |
| <li>new: ${this.newValue != null ? "'" + this.newValue + "'" : "(null)"}</li> |
| </ul> |
| </details>`; |
| return elementFromMarkdown(html); |
| } |
| |
| toObject() { |
| return { |
| type: "AttributeUpdate", |
| targetGUID: this.targetGUID, |
| attribute: this.attribute, |
| oldValue: this.oldValue, |
| newValue: this.newValue |
| }; |
| } |
| |
| static fromObject(json, nodeMap) { |
| return new AttributeUpdate(nodeMap, json.targetGUID, json.attribute, json.oldValue, json.newValue); |
| } |
| } |
| |
| class SelectionUpdate extends DOMUpdate { |
| constructor(nodeMap, state) { |
| super(nodeMap); |
| this.state = state; |
| } |
| |
| // SelectionUpdates are not applied/unapplied by the normal means. The selection is applied via |
| // DOMUpdateHistoryContext.applyCurrentSelectionState instead, which considers the updates before and after the |
| // current update index. |
| apply() { } |
| unapply() { } |
| |
| toObject() { |
| return { |
| type: "SelectionUpdate", |
| state: this.state ? this.state.toObject() : null |
| }; |
| } |
| |
| static fromObject(json, nodeMap) { |
| return new SelectionUpdate(nodeMap, SelectionState.fromObject(json.state, nodeMap)); |
| } |
| |
| _rangeDescriptionHTML() { |
| return `(${this.nodeMap.descriptionHTMLForGUID(this.state.startGUID)}:${this.state.startOffset}, |
| ${this.nodeMap.descriptionHTMLForGUID(this.state.endGUID)}:${this.state.endOffset})`; |
| } |
| |
| _anchorDescriptionHTML() { |
| return `${this.nodeMap.descriptionHTMLForGUID(this.state.anchorGUID)}:${this.state.anchorOffset}`; |
| } |
| |
| _focusDescriptionHTML() { |
| return `${this.nodeMap.descriptionHTMLForGUID(this.state.focusGUID)}:${this.state.focusOffset}`; |
| } |
| |
| detailsElement() { |
| let html = |
| `<details> |
| <summary>Selection changed</summary> |
| <ul> |
| <li>range: ${this._rangeDescriptionHTML()}</li> |
| <li>anchor: ${this._anchorDescriptionHTML()}</li> |
| <li>focus: ${this._focusDescriptionHTML()}</li> |
| </ul> |
| </details>`; |
| return elementFromMarkdown(html); |
| } |
| } |
| |
| class InputEventUpdate extends DOMUpdate { |
| constructor(nodeMap, updates, inputType, data, timeStamp) { |
| super(nodeMap); |
| this.updates = updates; |
| this.inputType = inputType; |
| this.data = data; |
| this.timeStamp = timeStamp; |
| } |
| |
| _obfuscatedData() { |
| return this.inputType.indexOf("insert") == 0 ? Obfuscator.shared().applyToText(this.data) : this.data; |
| } |
| |
| apply() { |
| for (let update of this.updates) |
| update.apply(); |
| } |
| |
| unapply() { |
| for (let index = this.updates.length - 1; index >= 0; index--) |
| this.updates[index].unapply(); |
| } |
| |
| toObject() { |
| return { |
| type: "InputEventUpdate", |
| inputType: this.inputType, |
| data: this._obfuscatedData(), |
| timeStamp: this.timeStamp, |
| updates: this.updates.map(update => update.toObject()) |
| }; |
| } |
| |
| static fromObject(json, nodeMap) { |
| let updates = json.updates.map(update => DOMUpdate.ofType(update.type).fromObject(update, nodeMap)); |
| return new InputEventUpdate(nodeMap, updates, json.inputType, json.data, json.timeStamp); |
| } |
| |
| detailsElement() { |
| let html = |
| `<details> |
| <summary>Input (${this.inputType})</summary> |
| <ul> |
| <li>time: ${this.timeStamp}</li> |
| <li>data: ${!this.data ? "(null)" : "'" + this.data + "'"}</li> |
| </ul> |
| </details>`; |
| let topLevelDetails = elementFromMarkdown(html); |
| for (let update of this.updates) |
| topLevelDetails.children[topLevelDetails.childElementCount - 1].appendChild(update.detailsElement()); |
| return topLevelDetails; |
| } |
| } |
| |
| window.EditingHistory = { |
| GlobalNodeMap, |
| SelectionState, |
| DOMUpdate, |
| ChildListUpdate, |
| CharacterDataUpdate, |
| AttributeUpdate, |
| SelectionUpdate, |
| InputEventUpdate, |
| Obfuscator |
| }; |
| })(); |