Search code examples
google-apps-scriptgoogle-docs

Log when Google Docs text changes


I am experimenting with the end-to-end latency of Google Docs: how long it takes for one user's edits to show up for other users. To measure this, I need a way to detect when the text in a Google Doc changes. Ideally, each change would be logged to the console.

This used to be possible by watching the raw HTML, but Google Docs switched to using their own Canvas in ~2021. I have also tried some 2022-era tricks to switch it to legacy HTML mode (window._docs_force_html_by_ext = true; installing Grammarly extension) but they no longer seem to work.

Other things I've tried:

  • Google Apps Script: There is no trigger for watching changes to a Google Doc (only a Sheet). The more powerful APIs runs server-side and so wouldn't work for my user-to-user measurements.
  • Accessibility -> Live Edits: This works (edits show up in an HTML side bar), but on a substantial delay. I believe it waits until the other user "stops typing" before showing an edit.

Solution

  • Here is what worked for me:

    1. Using a function like Puppeteer's evaluateOnNewDocument, inject the following code before the page is loaded:
    window._docs_annotate_canvas_by_ext = "<extension ID>";
    

    Here <extension ID> must be the ID of a Chrome extension that is whitelisted to work with Google Docs. You can found a list of valid IDs by searching for i8d= in the code. (Source)

    1. Now Google Docs will use the same Canvas-based renderer, but it will also add SVG elements to the DOM, whose aria-label attribute contains the text. I believe it is "drawing" rects to indicate where snippets of text are. The SVG elements are deep under the docs-editor-container div, near the actual <canvas> element.

    2. To detect text changes, use a MutationObserver:

    const contentRoot = document.querySelector(".kix-rotatingtilemanager-content");
    new MutationObserver(onMutation).observe(
        contentRoot,
        {subtree: true, childList: true}
    );
    

    Empirically, when the text changes, Google Docs will remove an old node and add a new one with the updated aria-label. Text with different inline formats will be split across multiple nodes; header-formatted text doesn't show up at all; and nodes also appear/disappear as text is scrolled into/out of view. This is my best attempt at processing it:

    function onMutation(mutationList) {
      for (const mutation of mutationList) {
        if (mutation.addedNodes.length === 0) continue;
        if (mutation.removedNodes.length === 0) {
          // It may have been scrolled into view - not actually new text.
          continue;
        }
        
        let after = "";
        for (const added of mutation.addedNodes) {
          after += added.ariaLabel ?? "";
        }
    
        let before = "";
        for (const removed of mutation.removedNodes) {
          before += removed.ariaLabel ?? "";
        }
    
        if (after !== before) {
            // Text changed from "before" to "after"...
        }
      }
    }