UNPKG

electron-text-substitutions

Version:

Substitute text in an input field based on OS X System Preferences

441 lines (361 loc) 16.7 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.preferenceChangedIpcMessage = undefined; exports.default = performTextSubstitution; exports.listenForPreferenceChanges = listenForPreferenceChanges; var _electron = require('electron'); var _electron2 = _interopRequireDefault(_electron); var _lodash = require('lodash'); require('rxjs/add/observable/from'); require('rxjs/add/observable/fromEvent'); require('rxjs/add/operator/mergeMap'); require('rxjs/add/operator/debounceTime'); var _Observable = require('rxjs/Observable'); var _Subscription = require('rxjs/Subscription'); var _preferenceHelpers = require('./preference-helpers'); var _regularExpressions = require('./regular-expressions'); var _keyboardUtils = require('./keyboard-utils'); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } var packageName = 'electron-text-substitutions'; var d = require('debug')(packageName); var registerForPreferenceChangedIpcMessage = packageName + '-register-renderer'; var unregisterForPreferenceChangedIpcMessage = packageName + '-unregister-renderer'; var preferenceChangedIpcMessage = exports.preferenceChangedIpcMessage = packageName + '-preference-changed'; var ipcMain = void 0, ipcRenderer = void 0, systemPreferences = void 0; var replacementItems = null; var registeredWebContents = {}; /** * Adds an `input` event listener to the given element (an <input> or * <textarea>) that will substitute text based on the user's replacements in * `NSUserDefaults`. * * In addition, this method will listen for changes to `NSUserDefaults` and * update accordingly. * * @param {EventTarget} element The DOM node to listen to; should fire the `input` event * @param {Object} preferenceOverrides Used to override text preferences in testing * * @return {Subscription} A `Subscription` that will clean up everything this method did */ function performTextSubstitution(element) { var preferenceOverrides = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; if (!element || !element.addEventListener) throw new Error('Element is null or not an EventTarget'); if (!process || process.type !== 'renderer') throw new Error('Not in an Electron renderer context'); if (process.platform !== 'darwin') throw new Error('Only supported on macOS'); ipcRenderer = ipcRenderer || _electron2.default.ipcRenderer; systemPreferences = systemPreferences || _electron2.default.remote.systemPreferences; if (!systemPreferences || !systemPreferences.getUserDefault) { throw new Error('Electron ' + process.versions.electron + ' is not supported'); } ipcRenderer.send(registerForPreferenceChangedIpcMessage); var unloadListener = function unloadListener() { d('Window unloading, unregister any listeners'); ipcRenderer.send(unregisterForPreferenceChangedIpcMessage); }; window.addEventListener('beforeunload', unloadListener); if (preferenceOverrides) { replacementItems = getReplacementItems(preferenceOverrides); } else if (!replacementItems) { replacementItems = getReplacementItems((0, _preferenceHelpers.readSystemTextPreferences)()); } var currentAttach = addInputListener(element, replacementItems); var preferenceChangedListener = function preferenceChangedListener(serializedItems) { d('User modified text preferences, reattaching listener'); replacementItems = JSON.parse(serializedItems, _regularExpressions.regExpReviver); currentAttach.unsubscribe(); currentAttach = addInputListener(element, replacementItems); }; ipcRenderer.on(preferenceChangedIpcMessage, preferenceChangedListener); return new _Subscription.Subscription(function () { d('Unsubscribing all listeners for ' + element.id); currentAttach.unsubscribe(); ipcRenderer.removeListener(preferenceChangedIpcMessage, preferenceChangedListener); window.removeEventListener('beforeunload', unloadListener); }); } /** * Subscribes to text preference changed notifications and notifies listeners * in renderer processes. This method must be called from the main process, and * should be called before any renderer process calls `performTextSubstitution`. * * @return {Subscription} A `Subscription` that will clean up everything this method did */ function listenForPreferenceChanges() { if (!process || process.type !== 'browser') throw new Error('Not in an Electron browser context'); if (process.platform !== 'darwin') throw new Error('Only supported on macOS'); ipcMain = ipcMain || _electron2.default.ipcMain; systemPreferences = systemPreferences || _electron2.default.systemPreferences; ipcMain.on(registerForPreferenceChangedIpcMessage, function (_ref) { var sender = _ref.sender; var id = sender.getId(); d('Registering webContents ' + id + ' for preference changes'); registeredWebContents[id] = { id: id, sender: sender }; }); ipcMain.on(unregisterForPreferenceChangedIpcMessage, function (_ref2) { var sender = _ref2.sender; d('Unregistering webContents ' + sender.getId()); delete registeredWebContents[sender.getId()]; }); var ret = new _Subscription.Subscription(); ret.add((0, _preferenceHelpers.onPreferenceChanged)(notifyAllListeners)); ret.add(new _Subscription.Subscription(function () { return ipcMain.removeAllListeners(registerForPreferenceChangedIpcMessage); })); ret.add(new _Subscription.Subscription(function () { return ipcMain.removeAllListeners(unregisterForPreferenceChangedIpcMessage); })); return ret; } /** * Sends an IPC message to each `WebContents` that is doing text substitution, * unless it has been destroyed, in which case remove it from our list. */ function notifyAllListeners() { var textPreferences = (0, _preferenceHelpers.readSystemTextPreferences)(); var replacementItems = getReplacementItems(textPreferences); var serializedItems = JSON.stringify(replacementItems, _regularExpressions.regExpReplacer); (0, _lodash.forEach)((0, _lodash.values)(registeredWebContents), function (_ref3) { var id = _ref3.id, sender = _ref3.sender; if (sender.isDestroyed() || sender.isCrashed()) { d('WebContents ' + id + ' is gone, removing it'); delete registeredWebContents[id]; } else { sender.send(preferenceChangedIpcMessage, serializedItems); } }); } /** * @typedef {Object} TextSubstitution * @property {String} replace The text to replace * @property {String} with The replacement text * @property {Bool} on True if this substitution is enabled */ /** * Creates a regular expression for each text substitution entry, in addition * to expressions for smart quotes and dashes (if they're enabled). * * @param {Array<TextSubstitution>} {substitutions An array of text substitution entries * @param {Bool} useSmartQuotes True if smart quotes is on * @param {Bool} useSmartDashes} True if smart dashes is on * @return {Array<ReplacementItem>} An array of replacement items */ function getReplacementItems(_ref4) { var substitutions = _ref4.substitutions, useSmartQuotes = _ref4.useSmartQuotes, useSmartDashes = _ref4.useSmartDashes; d('Smart quotes are ' + (useSmartQuotes ? 'on' : 'off')); d('Smart dashes are ' + (useSmartDashes ? 'on' : 'off')); var additionalReplacements = [].concat(_toConsumableArray(useSmartQuotes ? (0, _regularExpressions.getSmartQuotesRegExp)() : []), _toConsumableArray(useSmartDashes ? (0, _regularExpressions.getSmartDashesRegExp)() : [])); d('Found ' + substitutions.length + ' substitutions in NSUserDictionaryReplacementItems'); // NB: Run each replacement string through our smart quotes & dashes regex, // so that an input event doesn't cause chained substitutions. Also sort // replacements by length, to handle nested substitutions. var userDictionaryReplacements = substitutions.filter(function (substitution) { return substitution.on !== false && substitution.replace !== substitution.with; }).sort(function (a, b) { return b.replace.length - a.replace.length; }).map(function (substitution) { return (0, _regularExpressions.getSubstitutionRegExp)(substitution.replace, (0, _regularExpressions.scrubInputString)(substitution.with, additionalReplacements)); }); return [].concat(_toConsumableArray(userDictionaryReplacements), _toConsumableArray(additionalReplacements)); } /** * Get the string value of Text, Elements, and form elements * * @param {Element} element The element whose text will be retrieved * @return {String} The text value of the element */ function getElementText(element) { if (!element) return ''; if (element.value) return element.value; if (element.textContent.endsWith('\n')) return element.textContent.slice(0, -1); return element.textContent; } /** * Subscribes to the `input` event and performs text substitution. * * @param {EventTarget} element The DOM node to listen to * @param {Array<ReplacementItem>} replacementItems An array of replacement items * @return {Subscription} A `Subscription` that will remove the listener */ function addInputListener(element, replacementItems) { var ignoreEvent = false; var composition = false; var inputListener = function inputListener() { if (composition) { d('composition event is not completed, do not try substitution'); return; } if (ignoreEvent) return; ignoreEvent = true; var _iteratorNormalCompletion = true; var _didIteratorError = false; var _iteratorError = undefined; try { var _loop = function _loop() { var _ref5 = _step.value; var regExp = _ref5.regExp, replacement = _ref5.replacement; // Rather than search the entire input, we're just going to check the word // immediately before the caret (along with its surrounding whitespace). // This is to avoid substitutions after, say, a paste or an undo. var value = getElementText(element); var searchStartIndex = lastIndexOfWhitespace(value, element.selectionEnd); var lastWordBlock = value.substring(searchStartIndex, element.selectionEnd); var match = lastWordBlock.match(regExp); if (match && match.length === 3) { d('Got a match of length ' + match[0].length + ' at index ' + match.index + ': ' + JSON.stringify(match)); if ((0, _lodash.some)(replacementItems, function (item) { return item.match === match[0]; })) { d('The match is a prefix of another replacement item (' + match[0] + '), skip it'); return 'continue'; } var selection = { startIndex: searchStartIndex + match.index, endIndex: searchStartIndex + match.index + match[0].length }; replaceText(element, selection, (0, _regularExpressions.formatReplacement)(match, replacement)); } }; for (var _iterator = replacementItems[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { var _ret = _loop(); if (_ret === 'continue') continue; } } catch (err) { _didIteratorError = true; _iteratorError = err; } finally { try { if (!_iteratorNormalCompletion && _iterator.return) { _iterator.return(); } } finally { if (_didIteratorError) { throw _iteratorError; } } } ignoreEvent = false; }; var keyDownListener = function keyDownListener(e) { if ((0, _keyboardUtils.isUndoRedoEvent)(e) || (0, _keyboardUtils.isBackspaceEvent)(e) || composition) { d('Ignoring keydown event from ' + e.target.value); ignoreEvent = true; } }; var pasteListener = function pasteListener() { ignoreEvent = true; }; var keyUpListener = function keyUpListener() { if (!composition) { ignoreEvent = false; } }; var compositionStartListener = function compositionStartListener() { return composition = true; }; var compositionEndListener = function compositionEndListener() { composition = false; //force validate substitution state after composition completes. //in case of some IME (KR for example) compositon end event won't be triggered unless //final consonant are typed, while char itself can written without final consonant. //This'll makes initial substitution doesn't replace text since it's suppressed then //next substitution try to attempt replace first char which haven't triggered at those moment. //to avoid those, force trigger input validation as soon as composition end event fires ignoreEvent = false; inputListener(); }; element.addEventListener('compositionstart', compositionStartListener, true); element.addEventListener('compositionend', compositionEndListener, true); element.addEventListener('keydown', keyDownListener, true); element.addEventListener('paste', pasteListener, true); element.addEventListener('keyup', keyUpListener, true); element.addEventListener('input', inputListener); d('Added input listener to ' + element.id + ' matching against ' + replacementItems.length + ' replacements'); return new _Subscription.Subscription(function () { element.removeEventListener('compositionstart', compositionStartListener, true); element.removeEventListener('compositionend', compositionEndListener, true); element.removeEventListener('keydown', keyDownListener); element.removeEventListener('paste', pasteListener); element.removeEventListener('keyup', keyUpListener); element.removeEventListener('input', inputListener); d('Removed input listener from ' + element.id); }); } function lastIndexOfWhitespace(value, fromIndex) { var lastIndex = 0; var whitespace = /\s/g; var textToCaret = value.substring(0, fromIndex).trimRight(); while (whitespace.exec(textToCaret) !== null) { lastIndex = whitespace.lastIndex; } return lastIndex; } /** * Performs the actual text replacement using `dispatchEvent`. We use events to * preserve the user's cursor index and make the substitution undoable. * * @param {EventTarget} element The DOM node where text is being substituted * @param {Number} {startIndex Start index of the text to replace * @param {Number} endIndex} End index of the text to replace * @param {String} newText The text being inserted */ function replaceText(element, _ref6, newText) { var startIndex = _ref6.startIndex, endIndex = _ref6.endIndex; setSelectionRange(element, startIndex, endIndex); d('Replacing ' + getElementText(element).substring(startIndex, endIndex) + ' with ' + newText); document.execCommand('insertText', false, newText); } /** * Sets the selection range of a given input element. If the element is not an * `input` or `textarea`, we need to get into the `Range` game. * * @param {type} element The DOM node where text will be selected * @param {type} startIndex Start index of the selection * @param {type} endIndex End index of the selection */ function setSelectionRange(element, startIndex, endIndex) { if (element.value) { element.selectionStart = startIndex; element.selectionEnd = endIndex; } else { var charIndex = 0; var range = document.createRange(); range.setStart(element, 0); range.collapse(true); var nodeStack = [element], node = void 0, foundStart = false, stop = false; while (!stop && (node = nodeStack.pop())) { if (node.nodeType == Node.TEXT_NODE) { var nextCharIndex = charIndex + node.length; if (!foundStart && startIndex >= charIndex && startIndex <= nextCharIndex) { range.setStart(node, startIndex - charIndex); foundStart = true; } if (foundStart && endIndex >= charIndex && endIndex <= nextCharIndex) { range.setEnd(node, endIndex - charIndex); stop = true; } charIndex = nextCharIndex; } else { var i = node.childNodes.length; while (i--) { nodeStack.push(node.childNodes[i]); } } } var selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); } }