🏠 Home 

Google Docs - Word Count with Options

adds a word counter with options to Google Docs (NOTE: Unfortunately, this is currently broken. Fortunately, they basically implemented this: in the top menu click Tools > Word count > check "Display word count while typing". This UserScript never worked that well, especially with longer document and Google has changed their code again to make this even more difficult to fix (aggressive lazy-loading pages). I likely won't fix this, but I'm leaving it up for now.)

// ==UserScript==
// @name         Google Docs - Word Count with Options
// @namespace    https://zachhardesty.com
// @author       Zach Hardesty <[email protected]> (https://github.com/zachhardesty7)
// @description  adds a word counter with options to Google Docs (NOTE: Unfortunately, this is currently broken. Fortunately, they basically implemented this: in the top menu click Tools > Word count > check "Display word count while typing". This UserScript never worked that well, especially with longer document and Google has changed their code again to make this even more difficult to fix (aggressive lazy-loading pages). I likely won't fix this, but I'm leaving it up for now.)
// @copyright    2019, Zach Hardesty (https://zachhardesty.com/)
// @license      GPL-3.0-only; http://www.gnu.org/licenses/gpl-3.0.txt
// @version      1.0.1
// @homepageURL  https://github.com/zachhardesty7/tamper-monkey-scripts-collection/raw/master/google-docs-word-count.user.js
// @homepageURL  https://openuserjs.org/scripts/zachhardesty7/Google_Docs_-_Word_Count_(With_Options)
// @supportURL   https://github.com/zachhardesty7/tamper-monkey-scripts-collection/issues
// @match        https://docs.google.com/document/*
// ==/UserScript==
// heavy inspiration from:
// https://greasyfork.org/en/scripts/22057-google-docs-wordcount/code
// https://stackoverflow.com/questions/951021/what-is-the-javascript-version-of-sleep
// strikingly complex (uses DOM bounding boxes) to get currently selected text:
// may implement only necessary functions to save space, library size: (15.4 KB)
// https://github.com/JensPLarsen/ChromeExtension-GoogleDocsUtil
const displayCount = () => {
// words not counted between these when true
const BRACKETS = true
const PARENTHESIS = true
const QUOTES = true
const MISC = true // skips works cited, personal titles
const SELECTED = true // if selected text present, word count only counts it
const display = document.createElement("div")
display.id = "zh-display"
display.setAttribute(
"style",
`
position: fixed;
width: 100%;
left: 0px;
bottom: 0px;
color: rgba(0,0,0,.7);
height: 15px;
background-color: #ededee;
z-index: 100;
font-family: Arial;
font-size: 12px;
padding-top: 5px;
padding-left: 5px;
border-top: 1px solid #d9d9d9;
`
)
document.querySelector("body").append(display)
/**
* update the word count
*/
async function setCount() {
const doc = getGoogleDocument()
let selected = doc.selectedText
console.log("selected", selected)
const pages = document.querySelector(".kix-paginateddocumentplugin")
.children[1].children
let body = ""
for (const page of pages) {
// pages that are unloaded will appear to have no text
// add a marker to the cumulative body to indicate that
// a word count should not be displayed
if (page.textContent === "") body += " ~~ "
body += page.textContent
}
// clean extra spaces
body = body.replace(/\u00A0/g, " ").trim()
// generate regex from settings
// must escape \'s in JS
// in standard regex form:
//   /(“(.(?!“))+”)|(\((.(?!\())+\)|\[(.(?!\[))+\])
//     |Works Cited(\n.*)*|(Unit \d (Primary Source Analysis|Exam: Part \d - #\d+))/g
const regex = []
if (BRACKETS) regex.push("\\[(.(?!\\[))+\\]")
if (PARENTHESIS) regex.push("\\((.(?!\\())+\\)")
if (QUOTES)
regex.push(
"Works Cited(.|\\n.*)*|(Unit \\d (Primary Source Analysis|Exam: Part \\d( - #\\d+)*))"
)
if (MISC) regex.push("(“(.(?!“))+”)")
// apply regex filtering to body
for (const reg of regex) {
selected = selected.replace(new RegExp(reg, "g"), " ")
}
// apply regex filtering to selected text if necessary
let filtered = body
for (const reg of regex) {
filtered = filtered.replace(new RegExp(reg, "g"), " ")
}
// remove extra spaces and line breaks and get counts
const words = filtered
.trim()
.replace(/\u00A0/g, " ")
.replace(/ {2,}/g, " ")
.split(" ")
if (words.includes("~~")) {
// empty or unloaded pages present
document.querySelector(
"#zh-display"
).textContent = `Word Count: (scroll to bottom & remove empty pages) | Pages: ${pages.length}`
} else if (selected.length > 0 && SELECTED) {
selected = selected
.trim()
.replace(/\u00A0/g, " ")
.replace(/ {2,}/g, " ")
console.log("selected", selected)
document.querySelector("#zh-display").textContent = `Word Count: ${
selected.split(" ").length
} of ${words.length} (selected) | Pages: ${pages.length}`
} else {
document.querySelector(
"#zh-display"
).textContent = `Word Count: ${words.length} | Pages: ${pages.length}`
}
}
setInterval(setCount, 1000)
}
// #region - Google Docs Utils
// - - - - - - - - - - - - - - - - - - - -
// General
// - - - - - - - - - - - - - - - - - - - -
const classNames = {
paragraph: ".kix-paragraphrenderer",
line: ".kix-lineview",
selectionOverlay: ".kix-selection-overlay",
wordNode: ".kix-wordhtmlgenerator-word-node",
cursor: ".kix-cursor",
cursorName: ".kix-cursor-name",
cursorCaret: ".kix-cursor-caret",
}
/**
* Google Docs like to add \u200B, \u200C (&zwnj) and non breaking spaces to make sure
* the browser shows the text correct. When getting the text, we would prefer to get
* clean text.
*
* @param {string} text - ?
* @returns {string} clean text
*/
function cleanDocumentText(text) {
let cleanedText = text.replace(/[\u200B\u200C]/g, "")
const nonBreakingSpaces = String.fromCharCode(160)
const regex = new RegExp(nonBreakingSpaces, "g")
cleanedText = cleanedText.replace(regex, " ")
return cleanedText
}
// - - - - - - - - - - - - - - - - - - - -
// Get Google Document
// - - - - - - - - - - - - - - - - - - - -
/**
* Finds all the text and the caret position in the .
*
* @returns {GoogleDoc} google docs document
*/
function getGoogleDocument() {
let caret, caretRect
let caretIndex = 0
let caretLineIndex = 0
let caretLine = 0
const text = []
const nodes = []
let lineCount = 0
let globalIndex = 0
let selectedText = ""
let exportedSelectionRect
const paragraphRenderers = document.querySelectorAll(classNames.paragraph)
if (containsUserCaretDom()) {
caret = getUserCaretDom()
caretRect = caret.getBoundingClientRect()
}
for (const paragraphRenderer of paragraphRenderers) {
const lineViews = paragraphRenderer.querySelectorAll(classNames.line)
for (const lineView of lineViews) {
let lineText = ""
const selectionOverlays = lineView.querySelectorAll(
classNames.selectionOverlay
)
const wordhtmlgeneratorWordNodes = lineView.querySelectorAll(
classNames.wordNode
)
for (const wordhtmlgeneratorWordNode of wordhtmlgeneratorWordNodes) {
const wordhtmlgeneratorWordNodeRect =
wordhtmlgeneratorWordNode.getBoundingClientRect()
if (
caretRect &&
doesRectsOverlap(wordhtmlgeneratorWordNodeRect, caretRect)
) {
const caretXStart =
caretRect.left - wordhtmlgeneratorWordNodeRect.left
const localCaretIndex = getLocalCaretIndex(
caretXStart,
wordhtmlgeneratorWordNode,
lineView
)
caretIndex = globalIndex + localCaretIndex
caretLineIndex = lineText.length + localCaretIndex
caretLine = lineCount
}
const nodeText = cleanDocumentText(
wordhtmlgeneratorWordNode.textContent
)
nodes.push({
index: globalIndex,
line: lineCount,
lineIndex: lineText.length,
node: wordhtmlgeneratorWordNode,
lineElement: lineView,
text: nodeText,
})
for (const selectionOverlay of selectionOverlays) {
const selectionRect = selectionOverlay.getBoundingClientRect()
if (selectionRect) exportedSelectionRect = selectionRect
if (
doesRectsOverlap(
wordhtmlgeneratorWordNodeRect,
selectionOverlay.getBoundingClientRect()
)
) {
const selectionStartIndex = getLocalCaretIndex(
selectionRect.left - wordhtmlgeneratorWordNodeRect.left,
wordhtmlgeneratorWordNode,
lineView
)
const selectionEndIndex = getLocalCaretIndex(
selectionRect.left +
selectionRect.width -
wordhtmlgeneratorWordNodeRect.left,
wordhtmlgeneratorWordNode,
lineView
)
selectedText += nodeText.slice(
selectionStartIndex,
selectionEndIndex
)
}
}
globalIndex += nodeText.length
lineText += nodeText
}
text.push(lineText)
lineCount += 1
}
}
return {
nodes,
text,
selectedText,
caret: {
index: caretIndex,
lineIndex: caretLineIndex,
line: caretLine,
},
selectionRect: exportedSelectionRect,
}
}
// http://stackoverflow.com/questions/306316/determine-if-two-rectangles-overlap-each-other
/**
* @param {DOMRect} RectA - ?
* @param {DOMRect} RectB - ?
* @returns {boolean} overlapping?
*/
function doesRectsOverlap(RectA, RectB) {
return (
RectA.left <= RectB.right &&
RectA.right >= RectB.left &&
RectA.top <= RectB.bottom &&
RectA.bottom >= RectB.top
)
}
// The kix-cursor contain a kix-cursor-name dom, which is only set when it is not the users cursor
/**
* @returns {boolean} does the kix-cursor contain a kix-cursor-name dom
*/
function containsUserCaretDom() {
const carets = document.querySelectorAll(classNames.cursor)
for (const caret of carets) {
const nameDom = caret.querySelectorAll(classNames.cursorName)
const name = nameDom[0].textContent
if (!name) return true
}
return false
}
// The kix-cursor contain a kix-cursor-name dom, which is only set when it is not the users cursor
/**
* @returns {Element} user caret
*/
function getUserCaretDom() {
const carets = document.querySelectorAll(classNames.cursor)
for (const caret of carets) {
const nameDom = caret.querySelectorAll(classNames.cursorName)
const name = nameDom[0].textContent
if (!name) return caret.querySelectorAll(classNames.cursorCaret)[0]
}
throw new Error("Could not find the users cursor")
}
/**
* @param {number} caretX - The x coordinate on where the element the caret is located
* @param {Element} element - The element on which contains the text where in the caret position is
* @param {Element} simulateElement - ?Doing the calculation of the caret position, we need to create a temporary DOM, the DOM will be created as a child to the simulatedElement.
* @returns {number} caret index on the innerText of the element
*/
function getLocalCaretIndex(caretX, element, simulateElement) {
// Creates a span DOM for each letter
const text = cleanDocumentText(element.textContent)
const container = document.createElement("div")
const letterSpans = []
for (const ch of text) {
const textNode = document.createElement("span")
textNode.textContent = ch
textNode.style.cssText = element.style.cssText
// "pre" = if there are multiple white spaces, they will all be rendered. Default behavior is for them to be collapsed
textNode.style.whiteSpace = "pre"
letterSpans.push(textNode)
container.append(textNode)
}
container.style.whiteSpace = "nowrap"
simulateElement.append(container)
// The caret is usually at the edge of the letter, we find the edge we are closest to.
let index = 0
let currentMinimumDistance = -1
const containerRect = container.getBoundingClientRect()
for (const [i, letterSpan] of letterSpans.entries()) {
const rect = letterSpan.getBoundingClientRect()
const left = rect.left - containerRect.left
const right = left + rect.width
if (currentMinimumDistance === -1) {
currentMinimumDistance = Math.abs(caretX - left)
}
const leftDistance = Math.abs(caretX - left)
const rightDistance = Math.abs(caretX - right)
if (leftDistance <= currentMinimumDistance) {
index = i
currentMinimumDistance = leftDistance
}
if (rightDistance <= currentMinimumDistance) {
index = i + 1
currentMinimumDistance = rightDistance
}
}
// Clean up
container.remove()
return index
}
displayCount()