google-docs-utils
Version:
Utilities for interaction with Google Docs.
2,101 lines (1,796 loc) • 45.8 kB
JavaScript
/**
* @license MIT
* @see https://github.com/Amaimersion/google-docs-utils
*/
;
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;