electron-text-substitutions
Version:
Substitute text in an input field based on OS X System Preferences
441 lines (361 loc) • 16.7 kB
JavaScript
;
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);
}
}