diff --git a/js/autoInsertFunctionality.js b/js/autoInsertFunctionality.js new file mode 100644 index 0000000..91a8ae8 --- /dev/null +++ b/js/autoInsertFunctionality.js @@ -0,0 +1,240 @@ +/* global Data */ + +import { + isContentEditable, isTextNode, getNodeWindow, triggerFakeInput, +} from "./pre"; + +/** + * @param {String} character to match + * @param {Number} searchCharIndex (0 or 1) denoting whether to match the + starting (`{`) or the closing (`}`) characters of an auto-insert combo + with the `character` + * @returns {String[]} the auto insert pair if found, else + */ +function searchAutoInsertChars(character, searchCharIndex) { + const arr = Data.charsToAutoInsertUserList, + defaultReturn = ["", ""]; + + for (let i = 0, len = arr.length; i < len; i++) { + if (arr[i][searchCharIndex] === character) { + return arr[i]; + } + } + + return defaultReturn; +} + +// non-breaking space is useful when inserting four concsecutive for tab character +// HTML entities are for HTML nodes, so use \xA0 to insert   +function makeSpaceNonBreakingTextnode(string) { + return string.replace(/ /g, "\xA0"); +} + +function moveForwardForSpaces(range, textNode, position) { + const value = textNode.textContent; + + while (/\s/.test(value[position]) && position < value.length) { + position++; + } + + range.setStart(textNode, position); +} + +function moveBackwardForSpaces(range, textNode, position) { + const value = textNode.textContent; + + while (/\s/.test(value[position - 1]) && position >= 1) { + position--; + } + + range.setEnd(textNode, position); +} + +/** + * for content editable node, when selection is "| abc |" + * this moves the carets so that it becomes " |abc| " + * @param {Range} range + */ +function moveSelectionForSurroundingWhitespace(range) { + if (isTextNode(range.startContainer)) { + moveForwardForSpaces(range, range.startContainer, range.startOffset); + } + if (isTextNode(range.endContainer)) { + moveBackwardForSpaces(range, range.endContainer, range.endOffset); + } +} + +/** + * + * @param {Range} range + * @param {String} textnodeString + * @param {Boolean} isStart + */ +function createNewTextNodeForAutoInsert(range, textnodeString, isStart) { + const textNode = document.createTextNode(textnodeString); + if (isStart) { + range.startContainer.insertBefore(textNode, range.startContainer.childNodes[range.startOffset]); + range.setStart(textNode, 0); + } else { + range.endContainer.insertBefore(textNode, range.endContainer.childNodes[range.endOffset]); + range.setEnd(textNode, 0); + } + + return textNode; +} + +function modifySelection(sel, range) { + sel.removeAllRanges(); + sel.addRange(range); +} + +/** + * this inserts text into textnode at specified position + * however, this resets the node's range object + * pass in a range object if you want to preserve it + * @param {Text} textNode + * @param {String} text + * @param {Number} atPos + * @param {Range} [range] + */ +function insertTextInNode(textNode, text, atPos, range = false) { + const valBefore = textNode.textContent.substr(0, atPos), + valAfter = textNode.textContent.substr(atPos), + // sorry for hardcoding but Object.keys/etc. yielded no positives + // no need to use all props + propsToCopy = ["endContainer", "endOffset", "startContainer", "startOffset"], + oldRange = {}; + + if (range) { for (const prop of propsToCopy) { oldRange[prop] = range[prop]; } } + textNode.textContent = valBefore + text + valAfter; + + if (range) { + // trying to directly assign oldRange back into range results in error + // as properties are read-only + range.setStart(oldRange.startContainer, oldRange.startOffset); + range.setEnd(oldRange.endContainer, oldRange.endOffset); + } +} + +/** + * ONLY call this function when `startAndEndAreSame` + * flag is false in iCC + * @param {Range} range + * @param {String} content + */ +function insertSingleCharacterContentEditable(range, content, isStart = true, increment) { + let textNode, + startPos; + + if (isStart) { + if (!isTextNode(range.startContainer)) { + // range node is an element node when it is empty + textNode = createNewTextNodeForAutoInsert(range, "", true); + startPos = 0; + } else { + textNode = range.startContainer; + startPos = range.startOffset; + } + } else if (!isTextNode(range.endContainer)) { + // range node is an element node when it is empty + textNode = createNewTextNodeForAutoInsert(range, "", false); + startPos = 0; + } else { + textNode = range.endContainer; + // move one step ahead to accommodate the previously inserted character + startPos = range.endOffset + (range.startContainer === range.endContainer); + } + + insertTextInNode(textNode, content, startPos, range); + if (isStart) { + range.setStart(textNode, startPos + increment); + } else { + range.setEnd(textNode, startPos + increment); + } +} + +/** + * + * @param {Element} node the parent node (event.target) + * @param {String} characterStart + * @param {String} [characterEnd] + */ +function insertCharacterContentEditable(node, characterStart, characterEnd) { + const win = getNodeWindow(node), + sel = win.getSelection(), + range = sel.getRangeAt(0), + rangeWasCollapsed = range.collapsed; + let textnodeString, + caretIncrement; + + // process the characters and their positioning + characterStart = makeSpaceNonBreakingTextnode(characterStart); + if (characterEnd) { + characterEnd = makeSpaceNonBreakingTextnode(characterEnd); + textnodeString = characterStart + characterEnd; + caretIncrement = 1; + } else { + textnodeString = characterStart; + caretIncrement = characterStart.length; + } + + const startAndEndAreSame = rangeWasCollapsed + || (!rangeWasCollapsed && !Data.wrapSelectionAutoInsert); + + if (startAndEndAreSame) { + range.deleteContents(); + + insertSingleCharacterContentEditable(range, textnodeString, true, caretIncrement); + } else { + moveSelectionForSurroundingWhitespace(range); + insertSingleCharacterContentEditable(range, characterStart, true, characterStart.length); + if (characterEnd) { + insertSingleCharacterContentEditable(range, characterEnd, false, 0); + } + } + + modifySelection(sel, range); + triggerFakeInput(range.startContainer); + triggerFakeInput(range.endContainer); +} + +/** + * + * @param {Element} node the parent node (event.target) + * @param {String} characterStart + * @param {String} characterEnd + */ +function insertCharacter(node, characterStart, characterEnd) { + if (isContentEditable(node)) { + insertCharacterContentEditable(node, characterStart, characterEnd); + } else { + let text = node.value, + startPos = node.selectionStart, + endPos = node.selectionEnd, + textBefore = text.substring(0, startPos), + textMid = text.substring(startPos, endPos), + textAfter = text.substring(endPos), + // handle trailing spaces + trimmedSelection = textMid.match(/^(\s*)(\S?(?:.|\n|\r)*\S)(\s*)$/) || [ + "", + "", + "", + "", + ]; + + textBefore += trimmedSelection[1]; + textAfter = trimmedSelection[3] + textAfter; + textMid = trimmedSelection[2]; + + textMid = Data.wrapSelectionAutoInsert ? textMid : ""; + startPos = textBefore.length + +!!characterEnd; + endPos = startPos + textMid.length; + + node.value = textBefore + characterStart + textMid + (characterEnd || "") + textAfter; + node.selectionStart = startPos; + node.selectionEnd = endPos; + triggerFakeInput(node); + } +} + +export { insertCharacter, searchAutoInsertChars }; diff --git a/js/detector.js b/js/detector.js index b09f60b..d27aff1 100644 --- a/js/detector.js +++ b/js/detector.js @@ -8,6 +8,8 @@ import { isTextNode, isBlockedSite, PRIMITIVES_EXT_KEY, + getNodeWindow, + triggerFakeInput, } from "./pre"; import { Folder, Snip } from "./snippetClasses"; import { DBget } from "./commonDataHandlers"; @@ -16,6 +18,7 @@ import { updateAllValuesPerWin } from "./protoExtend"; import { getHTML } from "./textmethods"; import { showBlockSiteModal } from "./modalHandlers"; import { getCurrentTimestamp, getFormattedDate } from "./dateFns"; +import { insertCharacter, searchAutoInsertChars } from "./autoInsertFunctionality"; primitiveExtender(); (function () { @@ -67,7 +70,6 @@ primitiveExtender(); /* Helper functions for iframe related work - initiateIframeCheckForSpecialWebpages - - getNodeWindow */ function onIFrameLoad(iframe) { @@ -105,15 +107,6 @@ primitiveExtender(); } } - // in certain web apps, like mailchimp - // node refers to the editor inside iframe - // while `window` refers to top level window - // so selection and other methods do not work - // hence the need to get the `node's window` - function getNodeWindow(node) { - return node.ownerDocument.defaultView; - } - /* Snippet/Placeholder functions */ @@ -302,6 +295,8 @@ primitiveExtender(); function checkPlaceholdersInContentEditableNode() { let pArr = Placeholder.array, currND; + + triggerFakeInput(Placeholder.node); // debugLog(Placeholder); if (pArr && pArr.length > 0) { [currND] = pArr; @@ -324,8 +319,11 @@ primitiveExtender(); // might have been called from keyEventhandler if (Placeholder.isCENode) { checkPlaceholdersInContentEditableNode(); + return; } + triggerFakeInput(node); + // text area logic if (notCheckSelection) { selectedText = getUserSelection(node); @@ -430,166 +428,6 @@ primitiveExtender(); insertSnippetInTextarea(caretPos, caretPos, snip, val, node); } - /* - Auto-Insert Character functions - - searchAutoInsertChars - - insertCharacter - */ - - /** - * @param {String} character to match - * @param {Number} searchCharIndex (0 or 1) denoting whether to match the - starting (`{`) or the closing (`}`) characters of an auto-insert combo - with the `character` - * @returns {String[]} the auto insert pair if found, else - */ - function searchAutoInsertChars(character, searchCharIndex) { - const arr = Data.charsToAutoInsertUserList, - defaultReturn = ["", ""]; - - for (let i = 0, len = arr.length; i < len; i++) { - if (arr[i][searchCharIndex] === character) { - return arr[i]; - } - } - - return defaultReturn; - } - - // auto-insert character functionality - function insertCharacter(node, characterStart, characterEnd) { - if (isContentEditable(node)) { - insertCharacterContentEditable(node, characterStart, characterEnd); - } else { - let text = node.value, - startPos = node.selectionStart, - endPos = node.selectionEnd, - textBefore = text.substring(0, startPos), - textMid = text.substring(startPos, endPos), - textAfter = text.substring(endPos), - // handle trailing spaces - trimmedSelection = textMid.match(/^(\s*)(\S?(?:.|\n|\r)*\S)(\s*)$/) || [ - "", - "", - "", - "", - ]; - - textBefore += trimmedSelection[1]; - textAfter = trimmedSelection[3] + textAfter; - textMid = trimmedSelection[2]; - - textMid = Data.wrapSelectionAutoInsert ? textMid : ""; - startPos = textBefore.length + +!!characterEnd; - endPos = startPos + textMid.length; - - node.value = textBefore + characterStart + textMid + (characterEnd || "") + textAfter; - node.selectionStart = startPos; - node.selectionEnd = endPos; - } - } - - function insertSingleCharacterContentEditable( - rangeNode, - position, - singleCharacter, - isStart, - wasRangeCollapsed, - ) { - let textNode, - positionIncrement = isStart ? 1 : 0; - - if (rangeNode.nodeType !== 3) { - textNode = document.createTextNode(singleCharacter); - rangeNode.insertBefore(textNode, rangeNode.childNodes[position]); - return [positionIncrement, textNode]; - } - - const value = rangeNode.textContent, - len = value.length; - - // do not shift whitespaces if there actually was no selection - if (Data.wrapSelectionAutoInsert && !wasRangeCollapsed) { - if (isStart) { - while (/\s/.test(value[position]) && position < len) { - position++; - } - } else { - // value[position] corresponds to one character out of the current selection - while (/\s/.test(value[position - 1]) && position >= 1) { - position--; - } - } - } - - // HTML entities are for HTML, so use \xA0 to insert   - rangeNode.textContent = value.substring(0, position) - + singleCharacter.replace(/ /g, "\xA0") - + value.substring(position); - - return [position + positionIncrement, rangeNode]; - } - - function insertCharacterContentEditable(node, characterStart, characterEnd) { - let win = getNodeWindow(node), - sel = win.getSelection(), - range = sel.getRangeAt(0), - startNode, - endNode, - startPosition, - endPosition, - newStartNode, - newEndNode, - rangeWasCollapsed = range.collapsed, - singleCharacterReturnValue; - - if (!Data.wrapSelectionAutoInsert) { - range.deleteContents(); - } - - startNode = range.startContainer; - endNode = range.endContainer; - startPosition = range.startOffset; - endPosition = range.endOffset; - - // the rangeNode is a textnode EXCEPT when the node has no text - // eg:https://stackoverflow.com/a/5258024 - singleCharacterReturnValue = insertSingleCharacterContentEditable( - startNode, - startPosition, - characterStart, - true, - rangeWasCollapsed, - ); - [startPosition, newStartNode] = singleCharacterReturnValue; - - // because this method is also used for inserting single tabkey - if (characterEnd) { - // they just inserted a character above - if (startNode === endNode) { - endPosition++; - } - - singleCharacterReturnValue = insertSingleCharacterContentEditable( - endNode, - endPosition, - characterEnd, - false, - rangeWasCollapsed, - ); - [endPosition, newEndNode] = singleCharacterReturnValue; - } else { - startPosition--; - } - - range.setStart(newStartNode, startPosition); - if (characterEnd) { - range.setEnd(newEndNode, endPosition); - } - sel.removeAllRanges(); - sel.addRange(range); - } - // returns user-selected text in content-editable element function getUserSelection(node) { let win, @@ -752,16 +590,13 @@ primitiveExtender(); // with their required value after `=` has been pressed // which has been detected by handleKeyPress function provideDoubleBracketFunctionality(node, win) { - let sel = win.getSelection(), + const sel = win.getSelection(), range = sel.getRangeAt(0), rangeNode = range.startContainer, isCENode = isContentEditable(node), caretPos = isCENode ? range.endOffset : node.selectionEnd, - value = isCENode ? rangeNode.textContent : node.value, - operate = true, - evaluatedValue, - valueToSet, - caretPosToSet, + value = isCENode ? rangeNode.textContent : node.value; + let operate = true, i = caretPos; // check closing brackets are present @@ -779,8 +614,8 @@ primitiveExtender(); } if (operate) { - evaluatedValue = evaluateDoubleBrackets(value, i + 1, caretPos); - [valueToSet, caretPosToSet] = evaluatedValue; + const evaluatedValue = evaluateDoubleBrackets(value, i + 1, caretPos), + [valueToSet, caretPosToSet] = evaluatedValue; if (isCENode) { rangeNode.textContent = valueToSet; @@ -788,9 +623,11 @@ primitiveExtender(); range.setEnd(rangeNode, caretPosToSet); sel.removeAllRanges(); sel.addRange(range); + triggerFakeInput(rangeNode); } else { node.value = valueToSet; node.selectionStart = node.selectionEnd = caretPosToSet; + triggerFakeInput(node); } } } diff --git a/js/pre.js b/js/pre.js index 904af85..9fd9861 100644 --- a/js/pre.js +++ b/js/pre.js @@ -309,6 +309,29 @@ function gTranlateImmune(text) { return `${text}`; } + +/** + * in certain web apps, like mailchimp + * node refers to the editor inside iframe + * while `window` refers to top level window + * so selection and other methods do not work + * hence the need to get the `node's window` + * + * @param {Element} node + */ +function getNodeWindow(node) { + return node.ownerDocument.defaultView; +} + +function triggerFakeInput($elm) { + $elm.dispatchEvent( + new Event("input", { + cancelable: true, + bubbles: true, + }), + ); +} + export { q, qCls, @@ -334,4 +357,6 @@ export { PRIMITIVES_EXT_KEY, appendBlobToLink, gTranlateImmune, + getNodeWindow, + triggerFakeInput, }; diff --git a/manifest.json b/manifest.json index 05b5700..cbda1d1 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "ProKeys", "description": "Save time and effort in emails, etc. with ProKeys! Define snippets, do math in browser, auto complete braces, and much more.", - "version": "3.6.0", + "version": "3.6.1", "author": "Aquila Softworks", "browser_action": { "default_icon": "imgs/r16.png"