UNPKG

google-docs-utils

Version:
2,101 lines (1,796 loc) 45.8 kB
/** * @license MIT * @see https://github.com/Amaimersion/google-docs-utils */ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); /** * Selectors to find element in the page. * * Use array of strings, not just single string value. * It is means there can be multiple selectors for single * element, in descending order of priority. * For example, if selector № 1 returned some result, then * that result will be used, otherwise selector № 2 will * be used to try to get valid result, etc. * If there only one value, then use array with one element. */ const docsEditorContainer = [ '#docs-editor-container' ]; const docsEditor = [ '#docs-editor', ...docsEditorContainer ]; const textEventTarget = [ 'iframe.docs-texteventtarget-iframe', '.docs-texteventtarget-iframe' ]; const kixPage = [ '.kix-page', '.docs-page' ]; const kixLine = [ '.kix-lineview', '.kix-paragraphrenderer' ]; const kixLineText = [ '.kix-lineview-text-block' ]; const kixWordNone = [ '.kix-wordhtmlgenerator-word-node' ]; const kixSelectionOverlay = [ '.kix-selection-overlay' ]; const kixCursor = [ '.kix-cursor' ]; const kixActiveCursor = [ '.docs-text-ui-cursor-blink' ]; const kixCursorCaret = [ '.kix-cursor-caret' ]; /** * Gets HTML element using `querySelector`. * * @param {string[]} selectors * Array of possible selectors. * If selector results to some element, * then that element will be returned, * otherwise next selector will be used. * @param {document | HTMLElement} root * A root in which the element will be searched. * Defaults to `document`. * * @returns {HTMLElement | null} * HTML element if finded, `null` otherwise. * * @throws * Throws an error if `root == null`. */ function querySelector( selectors, root = document ) { if (root == null) { throw new Error('Passed root element does not exists'); } let value = null; for (const selector of selectors) { value = root.querySelector(selector); if (value) { break; } } return value; } /** * Gets all HTML elements using `querySelectorAll`. * * @param {string[]} selectors * Array of possible selectors. * If selector results to some elements, * then these elements will be returned, * otherwise next selector will be used. * @param {document | HTMLElement} root * A root in which elements will be searched. * Defaults to `document`. * * @returns {HTMLElement[]} * HTML elements if finded, otherwise empty array. * * @throws * Throws an error if `root == null`. */ function querySelectorAll( selectors, root = document ) { if (root == null) { throw new Error('Passed root element does not exists'); } let value = null; for (const selector of selectors) { value = root.querySelectorAll(selector); if (value.length > 0) { break; } } if (value) { return Array.from(value); } return []; } /** * @returns * Current active editor element. * NOTE: it contains only editor element itself, * not bar and other elements. */ function getEditorElement() { return querySelector(docsEditor); } /** * Joins text using separator. */ /** * @param {HTMLElement} element */ function isIframe(element) { return (element.nodeName.toLowerCase() === 'iframe'); } /** * NOTE: during execution temp element will be added * in the DOM. That element will be invisible to user, * and that element will be removed in the end of execution. * * @param {string} char * Single character is expected. * You can pass more than one character, * but result will be not so accurate, because, * for example, different characters may have different width. * @param {string} css * Using that CSS `char` was rendered. * It is important to provide exactly CSS * that was used for rendering, because * different CSS may lead to different rect. * * @returns {DOMRectReadOnly} * Rect of rendered character. */ function getCharRect(char, css) { const element = document.createElement('span'); element.textContent = char; element.style.cssText = css; // sequences of white spaces should be preserved element.style.whiteSpace = 'pre'; // don't display this element this element element.style.position = 'absolute'; element.style.top = '-100px'; // need to render this element in order to get valid rect document.body.appendChild(element); const rect = element.getBoundingClientRect(); element.remove(); return rect; } /** * @param {DOMRectReadOnly} a * @param {DOMRectReadOnly} b * * @returns {boolean} * Two rects overlaps each other. * * @see https://stackoverflow.com/a/306332/8445442 */ function isRectsOverlap(a, b) { return ( (a.left <= b.right) && (a.right >= b.left) && (a.top <= b.bottom) && (a.bottom >= b.top) ); } /** * Similar to RegExp `\w`, but also supports non-ASCII characters * * WARNING: * it will not work for Chinese, Japanese, Arabic, Hebrew and most * other scripts which doesn't have upper and lower latters. * * @param {string} char * Char to check. */ function charIsWordChar(char) { // ASCII, numbers, underscores and other symbols if (char.match(/[\w]/)) { return true; } // https://stackoverflow.com/a/32567789/8445442 if (char.toLowerCase() !== char.toUpperCase()) { return true; } return false; } /** * Runs a method when page is fully loaded. * * @param {Function} method * Method to run. */ function runOnPageLoaded(method) { // inherit `this` context. const mthd = () => { method(); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mthd); } else { mthd(); } } /** * Converts selectors (`selectors.js`) to list of class names. * * @param {string[]} selectors * Selectors variable from `selectors.js` file. * * @returns * Class names one by one, without dot. * See example for more. * * @example * ([ * '.test', '.test2.iframe', '#nide.hide', * 'div.div1.div2', '#tag' * ]) => [ * 'test', 'test2', 'iframe', 'hide', * 'div1', 'div2' * ] */ function selectorsToClassList(selectors) { const result = []; for (let selector of selectors) { if (!selector.startsWith('.')) { selector = selector.slice( selector.indexOf('.') ); } selector = selector.slice(1); const classNames = selector.split('.'); for (const className of classNames) { if (className) { result.push(className); } } } return result; } /** * - Google Docs uses special target to handle * text events. So, for example, you cannot send * text event just to current document. You * should use special target for that. * * @returns {HTMLElement | Document} * A target that can be used to send text events * and listens for text events (in particular, keyboard events). */ function getTextEventTarget() { /** * @type {HTMLElement & HTMLIFrameElement} */ const element = querySelector(textEventTarget); if (isIframe(element)) { return element.contentDocument; } return element; } /** * @returns * All rendered pages of editor. */ function getPagesElements() { const editor = getEditorElement(); return querySelectorAll(kixPage, editor); } /** * @returns {HTMLElement[]} * All rendered pages of editor. */ function getLinesElements() { const pages = getPagesElements(); let result = []; for (const page of pages) { const lines = querySelectorAll(kixLine, page); result = [ ...result, ...lines ]; } return result; } /** * @returns {HTMLElement[]} * Text elements of each line. * Every text element contains all word elements * (there can be multiple word elements for one text element). */ function getLinesTextElements() { const lines = getLinesElements(); const result = []; for (const line of lines) { const textElement = querySelector(kixLineText, line); result.push(textElement); } return result; } /** * Clears a text extracted from element * using `textContent` property. * * - you may want to use this function because * Google Docs adds special symbols (ZWNJ, NBSP, etc.) * to display text correctly across all browsers. * - no sense to use this function for `innertText`. * * @param {string} textContent */ function clearTextContent(textContent) { textContent = removeZWNJ(textContent); textContent = removeNBSP(textContent); return textContent; } /** * Removes all ZWNJ characters. * * - https://en.wikipedia.org/wiki/Zero-width_non-joiner * * @param {string} value */ function removeZWNJ(value) { return value.replace(/\u200C/g, ''); } /** * Removes all NBSP characters. * * - https://en.wikipedia.org/wiki/Non-breaking_space * * @param {string} value */ function removeNBSP(value) { return value.replace(/\u00A0/g, ''); } /** * @returns * Text of every editor line. * If line is empty, then zero length string * will be returned for that line. */ function getLinesText() { const lines = getLinesTextElements(); const result = []; for (const line of lines) { // difference between `textContent` and `innerText` is matters! let value = line.textContent; value = clearTextContent(value); value = clearLineText(value); result.push(value); } return result; } /** * @param {string} value */ function clearLineText(value) { return value.trim(); } /** * @param {number} lineIndex * @param {number} startIndex * @param {number} endIndex * * @returns * Text of specific line. */ function getLineText( lineIndex, startIndex = undefined, endIndex = undefined ) { const linesText = getLinesText(); if (lineIndex >= linesText.length) { return null; } const text = linesText[lineIndex]; if (startIndex == null) { startIndex = 0; } if (endIndex == null) { endIndex = text.length; } return text.substring(startIndex, endIndex); } /** * @returns {Array<HTMLElement[]>} * Each element is a line, each of elements * of that line is a word node of that line. * These word nodes contains actual text of line. * * NOTE: * if text of line contains various formatting (font, bold, etc.), * then it will be splitted into several word nodes. * For example, "some [Arial font] text [Roboto font]" will be * splitted into two nodes, "some text [Arial font]" will be * represented as one node and "another [Arial font, normal] * text [Arial font, bold]" will be splitted into two nodes. */ function getWordElements() { const lines = getLinesElements(); const result = []; for (const line of lines) { const nodes = querySelectorAll(kixWordNone, line); result.push(nodes); } return result; } /** * Google Docs creates separate element to display * selection. It is no actual selection of text, it is * just an element with some style that emulates selection. * * Because of this, for example, you cannot just remove * selection overlay element from DOM in order to remove selection, * because Google Docs will restore selection at next user selection. * * @returns {HTMLElement[]} * Selection overlay element for each line. * If there are no selection for that line, * then `null` will be used. */ function getSelectionOverlayElements() { const lines = getLinesElements(); const result = []; for (const line of lines) { const element = querySelector(kixSelectionOverlay, line); result.push(element); } return result; } /** * @returns {Array<null | Array<object | null>>} * Selection data for every rendered line. * `[]` - represents line, `[][]` - represents all * selected word nodes. * `[]` - element will be `null` if that line doesn't * contains selection at all, otherwise it will be array. * `[][]` - it is all selected word nodes (see `getWordElements()` * documentation for more). If word node not selected (i.e., selection * don't overlaps that node), then value will be `null`, otherwise * it will be an object that describes selection of that word node. * * @throws * Throws an error if unable to get information * about current selection for at least one line. */ function getSelection() { const selectionElements = getSelectionOverlayElements(); const wordElements = getWordElements(); if (selectionElements.length !== wordElements.length) { throw new Error( 'Unable to map selection elements and word elements' ); } const count = Math.min( selectionElements.length, wordElements.length ); const result = []; const emptyValue = null; for (let i = 0; i !== count; i++) { const selectionElement = selectionElements[i]; if (!selectionElement) { result.push(emptyValue); continue; } const line = wordElements[i]; const lineSelection = []; for (const wordElement of line) { if (!wordElement) { lineSelection.push(emptyValue); continue; } const originalText = clearTextContent(wordElement.textContent); const textCSS = wordElement.style.cssText; const wordRect = wordElement.getBoundingClientRect(); const selectionRect = selectionElement.getBoundingClientRect(); const selectionIndexes = calculateSelectionIndexes( originalText, textCSS, wordRect, selectionRect ); const notSelected = (!selectionIndexes); if (notSelected) { lineSelection.push(emptyValue); continue; } const selectedText = originalText.substring( selectionIndexes.start, selectionIndexes.end ); lineSelection.push({ text: originalText, selectedText: selectedText, selectionStart: selectionIndexes.start, selectionEnd: selectionIndexes.end, textRect: wordRect, selectionRect: selectionRect, textElement: wordElement, selectionElement: selectionElement }); } result.push(lineSelection); } return result; } /** * Calculates text selection indexes based on * DOM rect of text element and selection element. * * @param {string} text * Original text. * @param {string} textCSS * CSS of rendered original text. * @param {DOMRectReadOnly} textRect * DOM rect of text element. * @param {DOMRectReadOnly} selectionRect * DOM rect of selection element. * * @returns * Indexes of current text selection. * They can be used, for example, for `substring()`. * `null` will be returned if nothing is selected. */ function calculateSelectionIndexes( text, textCSS, textRect, selectionRect ) { let virtualCaretLeft = textRect.left; let selected = false; let selectionStart = 0; let selectionEnd = text.length; for (let i = 0; i !== text.length; i++) { const isOverlap = ( (selectionRect.left <= virtualCaretLeft) && (virtualCaretLeft < selectionRect.right) ); if (isOverlap) { if (!selected) { selectionStart = i; selected = true; } } else { if (selected) { selectionEnd = i; break; } } const char = text[i]; const charRect = getCharRect(char, textCSS); virtualCaretLeft += charRect.width; } const selectionIndexes = { start: selectionStart, end: selectionEnd, }; return (selected ? selectionIndexes : null); } /** * @returns {HTMLElement} * User cursor. */ function getCursorElement() { const editor = getEditorElement(); return querySelector(kixCursor, editor); } /** * @returns {HTMLElement} * User active blinked cursor. */ function getActiveCursorElement() { const editor = getEditorElement(); return querySelector(kixActiveCursor, editor); } /** * @returns {HTMLElement | null} * Caret of user active cursor. */ function getCaretElement() { const activeCursor = getCursorElement(); if (!activeCursor) { return null; } return querySelector(kixCursorCaret, activeCursor); } /** * @returns * Information about caret. * `null` if unable to get information. */ function getCaret() { const caretElement = getCaretElement(); if (!caretElement) { return null; } const wordElements = getWordElements(); if (!wordElements.length) { return null; } const caretRect = caretElement.getBoundingClientRect(); const result = { element: null, wordElement: null, lineIndex: null, positionIndexRelativeToWord: null }; let resultFound = false; for (let lineIndex = 0; lineIndex !== wordElements.length; lineIndex++) { const line = wordElements[lineIndex]; for (let wordIndex = 0; wordIndex !== line.length; wordIndex++) { const wordElement = line[wordIndex]; const wordRect = wordElement.getBoundingClientRect(); const isOverlap = isRectsOverlap(caretRect, wordRect); if (!isOverlap) { continue; } result.element = caretElement; result.wordElement = wordElement; result.lineIndex = lineIndex; result.positionIndexRelativeToWord = calculatePositionIndex( wordRect, caretRect, wordElement.textContent, wordElement.style.cssText ); resultFound = true; break; } if (resultFound) { break; } } return result; } /** * @param {DOMRectReadOnly} wordRect * @param {DOMRectReadOnly} caretRect * @param {string} text * "Dirty" `textContent` is expected. * In case of "Dirty" empty spaces will be * handled correctly. * @param {string} textCSS * * @returns * On what position caret is placed on a line. * For example, `1` means caret is placed before * second character of line text. */ function calculatePositionIndex(wordRect, caretRect, text, textCSS) { let virtualCaretLeft = wordRect.left - caretRect.width; let localIndex = 0; for (const char of text) { const charRect = getCharRect(char, textCSS); // we should ignore special invisible // characters like ZWNJ or NBSP if (charRect.width === 0) { continue; } virtualCaretLeft += charRect.width; if (virtualCaretLeft >= caretRect.left) { break; } localIndex += 1; } return localIndex; } /** * @returns * A word on which caret is currently located. */ function getCaretWord() { const caret = getCaret(); if (!caret) { return null; } const caretText = clearTextContent(caret.wordElement.textContent); const result = { word: '', text: caretText, indexStart: caret.positionIndexRelativeToWord, indexEnd: caret.positionIndexRelativeToWord }; // not strict `>=`, because we may shift // by one to the left in further if (caret.positionIndexRelativeToWord > caretText.length) { return result; } const indexStart = getBoundaryIndex( caret.positionIndexRelativeToWord, caretText, true ); const indexEnd = getBoundaryIndex( caret.positionIndexRelativeToWord, caretText, false ); result.indexStart = indexStart; result.indexEnd = indexEnd; result.word = caretText.substring(indexStart, indexEnd); return result; } /** * @param {number} startIndex * From where to start search a word boundary. * @param {string} text * Full text. * @param {bool} toLeft * `true` for left direction, * `false` for right direction. * * @returns {number} * Index of word boundary that can be used for `substring()`. * * @example * ``` * const text = 'one two three'; * const start = getBoundaryIndex(5, text, true) // => 4; * const end = getBoundaryIndex(5, text, false) // => 7; * * text.substring(start, end); // => 'two' * ``` * * @example * ``` * const text = 'one two three'; * const start = getBoundaryIndex(3, text, true) // => 0; * const end = getBoundaryIndex(3, text, false) // => 3; * * text.substring(start, end); // => 'one' * ``` * * @example * ``` * const text = 'one two three'; // notice extra space * const start = getBoundaryIndex(4, text, true) // => 4; * const end = getBoundaryIndex(4, text, false) // => 4; * * text.substring(start, end); // => '' * ``` * * @example * ``` * const text = ' one two three'; // notice extra spaces * const start = getBoundaryIndex(1, text, true) // => 1; * const end = getBoundaryIndex(1, text, false) // => 1; * * text.substring(start, end); // => 'one' * ``` */ function getBoundaryIndex(startIndex, text, toLeft) { let isEnd = undefined; let move = undefined; let undoMove = undefined; if (toLeft) { isEnd = (index) => (index <= 0); move = (index) => (index - 1); undoMove = (index) => (index + 1); } else { isEnd = (index) => (index >= text.length); move = (index) => (index + 1); undoMove = (index) => (index - 1); } let boundaryIndex = startIndex; let character = text[boundaryIndex]; // in case if we at the end of word, // let's shift to the left by one in order // next `while` algorithm handle that case correctly if ( toLeft && charIsOutOfWord(character) && !isEnd(boundaryIndex) ) { boundaryIndex = move(boundaryIndex); character = text[boundaryIndex]; // there is no word boundary after shift by one, // we should initial start index without move if (charIsOutOfWord(character)) { return startIndex; } } while ( !charIsOutOfWord(character) && !isEnd(boundaryIndex) ) { boundaryIndex = move(boundaryIndex); character = text[boundaryIndex]; } // if previous `while` ended because of `charIsOutOfWord`, // then now we have boundary index for invalid character. // It is expected result for `toLeft = false` because in that // case we want exclude such character from `substring()`, // but in case of `toLeft = true` we don't want include invalid // word boundary character in `substring()`. if ( toLeft && !isEnd(boundaryIndex) ) { boundaryIndex = undoMove(boundaryIndex); } return boundaryIndex; } /** * @param {string} character * * @returns * Character is outside of word boundary. */ function charIsOutOfWord(character) { if (character == null) { return true; } return !charIsWordChar(character); } /** * This module can be used to imitate physical keyboard press events. * * - use `keypress` for letter characters, * - use `keydown` for special keys (ArrowLeft, Delete, etc.). * * It is important to provide valid `target`, because Google Docs * uses special target for text events, not default `document`. * * Use this for help - https://keycode.info */ /** * Creates keyboard event. * * @param {'keypress' | 'keydown' | 'keyup'} name * Name of event. * @param {Document | HTMLElement} target * Target of event. * @param {string} key * Name of key. * @param {string | null} code * Code of `key`. Specify `null` for autodetect. * Autodetect works correctly only for letters. * @param {number | null} keyCode * "Numerical code identifying the unmodified value of the pressed key". * Specify `null` for autodetect. * @param {KeyboardEventInit} eventOptions * Custom options to add/overwrite event options. */ function createKeyboardEvent( name, target, key, code, keyCode, eventOptions ) { if (code == null) { code = 'Key' + key.toUpperCase(); } if (keyCode == null) { // `codePointAt`, not `charCodeAt`, because of // eslint-disable-next-line max-len // https://github.com/Amaimersion/google-docs-utils/issues/8#issuecomment-824117587 keyCode = key.codePointAt(0); } return new KeyboardEvent( name, { repeat: false, isComposing: false, bubbles: true, cancelable: true, ctrlKey: false, shiftKey: false, altKey: false, metaKey: false, target: target, currentTarget: target, key: key, code: code, // it is important to also specify // these deprecated properties keyCode: keyCode, charCode: keyCode, which: keyCode, ...eventOptions } ); } /** * @param {Document | HTMLElement} target * @param {string} key * @param {string | null} code * @param {number | null} keyCode * @param {KeyboardEventInit} eventOptions */ function keypress( target, key, code = null, keyCode = null, eventOptions = {} ) { const event = createKeyboardEvent( 'keypress', target, key, code, keyCode, eventOptions ); target.dispatchEvent(event); } /** * @param {Document | HTMLElement} target * @param {string} key * @param {string | null} code * @param {number | null} keyCode * @param {KeyboardEventInit} eventOptions */ function keydown( target, key, code = null, keyCode = null, eventOptions = {} ) { const event = createKeyboardEvent( 'keydown', target, key, code, keyCode, eventOptions ); target.dispatchEvent(event); } //#region Base /** * Imitates physical press on single character. */ function Character(char, { ctrlKey = false, shiftKey = false } = {}) { // Google Docs handles `keydown` event in case of // "ctrl" or "shift" modificators, otherwise `keypress` // event should be used for normal characters if (ctrlKey || shiftKey) { keydown( getTextEventTarget(), char, null, null, { ctrlKey, shiftKey } ); } else { keypress( getTextEventTarget(), char ); } } /** * Imitates physical press on "Backspace". * * @param {boolean} ctrlKey */ function Backspace({ ctrlKey = false } = {}) { keydown( getTextEventTarget(), 'Backspace', 'Backspace', 8, { ctrlKey } ); } /** * Imitates physical press on "Tab". */ function Tab() { keydown( getTextEventTarget(), 'Tab', 'Tab', 9 ); } /** * Imitates physical press on "Enter". */ function Enter() { keydown( getTextEventTarget(), 'Enter', 'Enter', 13 ); } /** * Imitates physical press on space character. */ function Space() { keypress( getTextEventTarget(), '\u0020', 'Space', 32 ); } /** * Imitates physical press on "End" button. */ function End({ ctrlKey = false, shiftKey = false } = {}) { keydown( getTextEventTarget(), 'End', 'End', 35, { ctrlKey, shiftKey } ); } /** * Imitates physical press on "Home" button. */ function Home({ ctrlKey = false, shiftKey = false } = {}) { keydown( getTextEventTarget(), 'Home', 'Home', 36, { ctrlKey, shiftKey } ); } /** * Imitates physical press on left arrow. */ function ArrowLeft({ ctrlKey = false, shiftKey = false } = {}) { keydown( getTextEventTarget(), 'ArrowLeft', 'ArrowLeft', 37, { ctrlKey, shiftKey } ); } /** * Imitates physical press on up arrow. */ function ArrowUp({ ctrlKey = false, shiftKey = false } = {}) { keydown( getTextEventTarget(), 'ArrowUp', 'ArrowUp', 38, { ctrlKey, shiftKey } ); } /** * Imitates physical press on right arrow. */ function ArrowRight({ ctrlKey = false, shiftKey = false } = {}) { keydown( getTextEventTarget(), 'ArrowRight', 'ArrowRight', 39, { ctrlKey, shiftKey } ); } /** * Imitates physical press on down arrow. */ function ArrowDown({ ctrlKey = false, shiftKey = false } = {}) { keydown( getTextEventTarget(), 'ArrowDown', 'ArrowDown', 40, { ctrlKey, shiftKey } ); } /** * Imitates physical press on "Delete" ("Del"). */ function Delete({ ctrlKey = false } = {}) { keydown( getTextEventTarget(), 'Delete', 'Delete', 46, { ctrlKey } ); } //#endregion //#region Dependence /** * Imitates physical press on "Undo" button. */ function Undo() { Character('z', { ctrlKey: true }); } /** * Imitates physical press on "Redo" button. */ function Redo() { Character('y', { ctrlKey: true }); } /** * Imitates physical press on "Print" button * (print dialog, not print of character). */ function PrintDialog() { Character('p', { ctrlKey: true }); } /** * Imitates physical press on "Bold" button. */ function Bold() { Character('b', { ctrlKey: true }); } /** * Imitates physical press on "Italic" button. */ function Italic() { Character('i', { ctrlKey: true }); } /** * Imitates physical press on "Underline" button. */ function Underline() { Character('u', { ctrlKey: true }); } //#endregion var pressOn = /*#__PURE__*/Object.freeze({ __proto__: null, Character: Character, Backspace: Backspace, Tab: Tab, Enter: Enter, Space: Space, End: End, Home: Home, ArrowLeft: ArrowLeft, ArrowUp: ArrowUp, ArrowRight: ArrowRight, ArrowDown: ArrowDown, Delete: Delete, Undo: Undo, Redo: Redo, PrintDialog: PrintDialog, Bold: Bold, Italic: Italic, Underline: Underline }); /** * Types text at current caret position. * * - imitates physical typing * * @param {string} text * Text to type. */ function typeText(text) { type(text); } /** * Types text at current caret position. * * - imitates key press char by char, * can take a long time for long text. * * @param {string} text */ function type(text) { for (const char of text) { Character(char); } } /** * @returns {boolean} * Text selection is exists (at least one line). */ function isTextSelected() { const selectionElements = getSelectionOverlayElements(); const isSelected = selectionElements.some((i) => !!i); return isSelected; } /** * @returns {boolean} * Document is focused and active. * It is means that cursor is blinked. */ function isDocumentActive() { const activeCursor = getActiveCursorElement(); const documentIsActive = !!activeCursor; return documentIsActive; } /** * Focuses on current document. * * "Focus" means that document is active and available for editing: * cursor is blinking or selection active. * * @returns {boolean} * `true` if there was any actions to perform a focus, * `false` if document already was active and nothing was performed. */ function focusDocument() { if (isDocumentActive()) { return false; } // character that is acceptable by Google Docs should be used. // For example, `\u0000` is not acceptable and will be not typed. // Use something from this plane: // https://www.compart.com/en/unicode/plane/U+0000 const randomCharToCreateFocus = '\u003F'; const textSelected = isTextSelected(); Character(randomCharToCreateFocus); // if selection existed, then at the moment we removed it. // lets restore it, otherwise we will delete typed character if (textSelected) { Undo(); } else { Backspace(); } return true; } /** * Moves cursor to character that is placed to the left * of current cursor position. If that character placed * on previous line, then previous line will be used */ function PrevCharacter() { ArrowLeft(); } /** * Moves cursor to character that is placed to the right * of current cursor position. If that character placed * on next line, then next line will be used */ function NextCharacter() { ArrowRight(); } /** * Moves cursor to the previous line and tries to keep * cursor position. If there is no previous line, then moves * cursor to the start of current paragraph */ function PrevLine() { ArrowUp(); } /** * Moves cursor to the next line and tries to keep * cursor position. If there is no next line, then moves * cursor to the end of current paragraph */ function NextLine() { ArrowDown(); } /** * Moves cursor to: * - if it is start of current line, then to * the end of previous word on previous line * - else if it is start of current word, then to * the start of previous word * - else moves to the start of current word */ function PrevWord() { ArrowLeft({ ctrlKey: true }); } /** * Moves cursor to: * - if it is end of current line, then to * the start of next word on next line * - else if it is end of current word, then to * the end of next word * - else moves to the end of current word */ function NextWord() { ArrowRight({ ctrlKey: true }); } /** * Moves cursor to: * - if it is start of current paragraph, then to * the start of previous paragraph * - else moves to the start of current paragraph */ function PrevParagraph() { ArrowUp({ ctrlKey: true }); } /** * Moves cursor to: * - if it is end of current paragraph, then to * the end of next paragraph * - else moves to the end of current paragraph */ function NextParagraph() { ArrowDown({ ctrlKey: true }); } /** * Moves cursor to the start of current line. */ function LineStart() { // focus is needed in order to behave properly focusDocument(); Home(); } /** * Moves cursor to the end of current line. */ function LineEnd() { // focus is needed in order to behave properly focusDocument(); End(); } /** * Moves cursor to the start of document. */ function DocumentStart() { Home({ ctrlKey: true }); } /** * Moves cursor to the end of document. */ function DocumentEnd() { End({ ctrlKey: true }); } var moveCursorTo = /*#__PURE__*/Object.freeze({ __proto__: null, PrevCharacter: PrevCharacter, NextCharacter: NextCharacter, PrevLine: PrevLine, NextLine: NextLine, PrevWord: PrevWord, NextWord: NextWord, PrevParagraph: PrevParagraph, NextParagraph: NextParagraph, LineStart: LineStart, LineEnd: LineEnd, DocumentStart: DocumentStart, DocumentEnd: DocumentEnd }); /** * Removes: * - if prev word is present, then it will be removed * - else content from current line will be divided with prev line */ function PrevWord$1() { Backspace({ ctrlKey: true }); } /** * Removes: * - if next word is present, then it will be removed * - else content from current line will be divided with next line */ function NextWord$1() { Delete({ ctrlKey: true }); } /** * Removes active selection. * * @returns {boolean} * `true` - selection was removed, * `false` - nothing to remove (nothing is selected) */ function Selection() { if (!isTextSelected()) { return false; } // "Delete" should be used, not "Backspace". Delete(); return true; } var remove = /*#__PURE__*/Object.freeze({ __proto__: null, PrevWord: PrevWord$1, NextWord: NextWord$1, Selection: Selection }); /** * Selects text of entire document. */ function All() { Character('a', { ctrlKey: true }); } /** * Selects a character that is placed to the left of * current cursor position. Following logic will be used, * with priority of actions from top to bottom: * - if at least one character already selected with reverse selection * (opposite direction), then lastly selected character will be deselected * - if at least one character already selected, then next one will * be selected. If that next character located on previous line, * than that previous line will be used * - if nothing selected, then first character will be selected */ function PrevCharacter$1() { ArrowLeft({ shiftKey: true }); } /** * Selects a character that is placed to the right of * current cursor position. Following logic will be used, * with priority of actions from top to bottom: * - if at least one character already selected with reverse selection * (opposite direction), then lastly selected character will be deselected * - if at least one character already selected, then next one will * be selected. If that next character located on next line, * than that next line will be used * - if nothing selected, then first character will be selected */ function NextCharacter$1() { ArrowRight({ shiftKey: true }); } /** * Same as `PrevCharacter`, but performs an action with word. */ function PrevWord$2() { ArrowLeft({ shiftKey: true, ctrlKey: true }); } /** * Same as `NextCharacter`, but performs an action with word. */ function NextWord$2() { ArrowRight({ shiftKey: true, ctrlKey: true }); } /** * Selects N number of characters to the left where N * is a max length of line. */ function PrevLine$1() { // requires focus to behave correctly focusDocument(); ArrowUp({ shiftKey: true }); } /** * Same as `PrevLine`, but uses right direction. */ function NextLine$1() { // requires focus to behave correctly focusDocument(); ArrowDown({ shiftKey: true }); } /** * Selects a paragraph that is placed to the left of * current cursor position. Following logic will be used, * with priority of actions from top to bottom: * - if it is start of current paragraph, then previous * paragraph will be selected * - else text between current paragraph start and current * cursor position will be selected */ function PrevParagraph$1() { ArrowUp({ shiftKey: true, ctrlKey: true }); } /** * Selects a paragraph that is placed to the right of * current cursor position. Following logic will be used, * with priority of actions from top to bottom: * - if it is end of current paragraph, then next * paragraph will be NOT selected * - else text between current paragraph end and current * cursor position will be selected */ function NextParagraph$1() { ArrowDown({ shiftKey: true, ctrlKey: true }); } /** * Selects a text between current cursor position and * current line start. */ function TextBetweenCursorAndLineStart() { // requires focus to behave correctly focusDocument(); Home({ shiftKey: true }); } /** * Same as `TextBetweenCursorAndLineStart`, but interacts * with current line end. */ function TextBetweenCursorAndLineEnd() { // requires focus to behave correctly focusDocument(); End({ shiftKey: true }); } /** * Same as `TextBetweenCursorAndLineStart`, but interacts * with document start. */ function TextBetweenCursorAndDocumentStart() { Home({ shiftKey: true, ctrlKey: true }); } /** * Same as `TextBetweenCursorAndLineStart`, but interacts * with document end. */ function TextBetweenCursorAndDocumentEnd() { End({ shiftKey: true, ctrlKey: true }); } var select = /*#__PURE__*/Object.freeze({ __proto__: null, All: All, PrevCharacter: PrevCharacter$1, NextCharacter: NextCharacter$1, PrevWord: PrevWord$2, NextWord: NextWord$2, PrevLine: PrevLine$1, NextLine: NextLine$1, PrevParagraph: PrevParagraph$1, NextParagraph: NextParagraph$1, TextBetweenCursorAndLineStart: TextBetweenCursorAndLineStart, TextBetweenCursorAndLineEnd: TextBetweenCursorAndLineEnd, TextBetweenCursorAndDocumentStart: TextBetweenCursorAndDocumentStart, TextBetweenCursorAndDocumentEnd: TextBetweenCursorAndDocumentEnd }); /** * Type of Google Docs event. */ const EVENT_TYPE = { selectionChange: 'selectionchange' }; /** * Google Docs event listeners. * * Structure: * - key: event type * - value: all registered listeners for that event * * @type {{[key: string]: Function[]}} */ const EVENT_LISTENERS = {}; //#region Precalculated values const KIX_SELECTION_OVERLAY_CLASS_LIST = selectorsToClassList( kixSelectionOverlay ); //#endregion /** * Runs on script inject. */ function main() { runOnPageLoaded(bindObserver); } /** * Creates mutation observer and starts observing Google Docs container. * The container element should be created at that stage. */ function bindObserver() { const docsEditorContainer$1 = querySelector(docsEditorContainer); if (docsEditorContainer$1 == null) { throw new Error('Unable to observe missing docsEditorContainer'); } const observer = new MutationObserver(mutationCallback); observer.observe( docsEditorContainer$1, { subtree: true, childList: true, attributes: false, characterData: false } ); } /** * Callback which will be called on every Google Docs mutation. */ function mutationCallback(mutationList) { let selectionChangeEvent = false; // TODO: refactoring of that entire loop if there will be more events for (const mutation of mutationList) { for (const addedNode of mutation.addedNodes) { const addedNodeClassList = Array.from( addedNode.classList || [] ); selectionChangeEvent = ( selectionChangeEvent || KIX_SELECTION_OVERLAY_CLASS_LIST.some( (value) => addedNodeClassList.includes(value) ) ); } for (const removedNode of mutation.removedNodes) { const removedNodeClassList = Array.from( removedNode.classList || [] ); selectionChangeEvent = ( selectionChangeEvent || KIX_SELECTION_OVERLAY_CLASS_LIST.some( (value) => removedNodeClassList.includes(value) ) ); } } if (selectionChangeEvent) { callEventListener(EVENT_TYPE.selectionChange); } } /** * Adds listener for specific event. * * There can be many listeners for single event. * Order of calling is same as order of adding. * * @param {string} type * Type of event. Use `EVENT_TYPE`. * @param {(event: object) => any} listener * Callback that will be called. * Information about event will be passed as argument. */ function addEventListener(type, listener) { if (!EVENT_LISTENERS[type]) { EVENT_LISTENERS[type] = []; } EVENT_LISTENERS[type].push(listener); } /** * Calls all registered event listeners for specific event. * * @param {string} type * Type of event. Use `EVENT_TYPE`. */ function callEventListener(type) { const listeners = EVENT_LISTENERS[type]; if (!listeners) { return; } for (const listener of listeners) { try { listener({ type: type }); } catch (error) { console.error(error); } } } main(); exports.addEventListener = addEventListener; exports.clearTextContent = clearTextContent; exports.focusDocument = focusDocument; exports.getActiveCursorElement = getActiveCursorElement; exports.getCaret = getCaret; exports.getCaretElement = getCaretElement; exports.getCaretWord = getCaretWord; exports.getCursorElement = getCursorElement; exports.getEditorElement = getEditorElement; exports.getLineText = getLineText; exports.getLinesElements = getLinesElements; exports.getLinesText = getLinesText; exports.getLinesTextElements = getLinesTextElements; exports.getPagesElements = getPagesElements; exports.getSelection = getSelection; exports.getSelectionOverlayElements = getSelectionOverlayElements; exports.getTextEventTarget = getTextEventTarget; exports.getWordElements = getWordElements; exports.isDocumentActive = isDocumentActive; exports.isTextSelected = isTextSelected; exports.moveCursorTo = moveCursorTo; exports.pressOn = pressOn; exports.remove = remove; exports.select = select; exports.typeText = typeText;