To get the most out of this add-on, you will also want to follow the directions below to customize the look of Google Docs:
1) In a Google Doc, go to Tools -> Document outline (this shows the “NavPane”)
2) Go to View -> Print Layout (this will get rid of lots of white space between pages)
3) In the Google Doc Settings, turn on Offline Editing (scripts won’t work without internet, but you will still be able to access and edit your files)
4) Install the Debate Template Google Add-On (if you haven't done so yet)
5) Install Tampermonkey for Chrome
6) In Chrome, go the following URL: chrome://extensions/
7) Find the Tampermonkey extension and pick Details
8) Enable the Allow User Scripts options
9) Visit a Google Doc that you want to use for Debate.
10) Next to the URL bar, select the puzzle icon and pick TamperMonkey and choose the Create New Script option
11) Paste the code below into the TamperMonkey window. Save the code and close out of TamperMonkey. Reload your Google Docs window and collapsible navigation should work!
// ==UserScript==
// @name Google Docs Collapsible Outline & Hotkeys
// @namespace http://tampermonkey.net/
// @version 4.1
// @description Adds collapsible expand/collapse arrows to Google Docs sidebars, and enables F-key macro hotkeys for the Debate Template Add-on.
// @author You
// @match https://docs.google.com/document/d/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
/* =================================================================================
SECTION 1: DEBATE TEMPLATE HOTKEYS ENGINE
================================================================================= */
const HOTKEYS = {
// F2 is omitted as 'Paste' is not in the Extension menu (Use standard Ctrl/Cmd+V)
'F3': 'Condense',
'F4': 'Pocket',
'F5': 'Hat',
'F6': 'Block',
'F7': 'Tag',
'F8': 'Cite',
'F9': 'Underline',
'F10': 'Emphasis',
'F11': 'Highlight',
'F12': 'Clear'
};
let isMacroRunning = false;
let hotkeysBoundWindow = false;
// Simulates an exact physical mouse event to bypass Google's listener checks
function simulateMouseEvent(element, eventName, coordX, coordY) {
element.dispatchEvent(new MouseEvent(eventName, {
view: window,
bubbles: true,
cancelable: true,
clientX: coordX,
clientY: coordY,
button: 0
}));
}
// Automates a full click lifecycle on a specific element
function execClick(element) {
if (!element) return;
const box = element.getBoundingClientRect();
const coordX = box.left + box.width / 2;
const coordY = box.top + box.height / 2;
simulateMouseEvent(element, "mousedown", coordX, coordY);
simulateMouseEvent(element, "mouseup", coordX, coordY);
simulateMouseEvent(element, "click", coordX, coordY);
}
// Navigates the Google Docs menu asynchronously
async function triggerAddonMenu(addonName, menuItemName) {
// 1. Open Extensions Menu
const extensionsMenuBtn = document.querySelector("#docs-extensions-menu");
if (!extensionsMenuBtn) return;
execClick(extensionsMenuBtn);
await new Promise(r => setTimeout(r, 50)); // Render delay
// 2. Find Add-on Name in the dynamically generated menu
let menus = Array.from(document.querySelectorAll('.goog-menuitem'));
let addonItem = menus.find(m => m.textContent.trim().startsWith(addonName));
if (!addonItem) {
execClick(document.body); // Close menu fallback
return;
}
execClick(addonItem);
await new Promise(r => setTimeout(r, 150)); // Submenu fetch/render delay
// 3. Find Action in the sub-menu
menus = Array.from(document.querySelectorAll('.goog-menuitem'));
let actionItem = menus.find(m => {
const text = m.textContent.trim();
return text === menuItemName || text.startsWith(menuItemName);
});
if (actionItem) {
execClick(actionItem);
} else {
execClick(document.body); // Close if not found
}
// Return focus to the document so typing can continue seamlessly
const iframe = document.querySelector('.docs-texteventtarget-iframe');
if (iframe) iframe.focus();
}
// Master handler for keystrokes
async function handleHotkey(e) {
if (e.key in HOTKEYS) {
// Block default browser behavior (e.g. F5 refresh, F12 dev tools)
e.preventDefault();
e.stopPropagation();
if (isMacroRunning) return; // Prevent spamming
isMacroRunning = true;
try {
await triggerAddonMenu('Debate Template', HOTKEYS[e.key]);
} finally {
isMacroRunning = false;
}
}
}
// Attaches listeners safely to both the main window and Google's hidden typing frame
function bindHotkeys() {
if (!hotkeysBoundWindow) {
window.addEventListener('keydown', handleHotkey, true);
hotkeysBoundWindow = true;
}
const iframe = document.querySelector('.docs-texteventtarget-iframe');
if (iframe && iframe.contentDocument) {
if (!iframe.contentDocument.__hotkeysBound) {
iframe.contentDocument.addEventListener('keydown', handleHotkey, true);
iframe.contentDocument.__hotkeysBound = true;
}
}
}
// Injects visual hotkey labels into the dynamic Google Docs menus
function updateMenuHotkeys() {
// Find any menu items we haven't processed yet
const menuItems = document.querySelectorAll('.goog-menuitem:not([data-hotkey-injected])');
if (menuItems.length === 0) return;
// Invert our HOTKEYS map to easily lookup by Action Name
const actionToKey = {};
for (const [key, action] of Object.entries(HOTKEYS)) {
actionToKey[action] = key;
}
menuItems.forEach(item => {
const contentDiv = item.querySelector('.goog-menuitem-content');
const textNode = contentDiv || item;
// Clean the text to ensure exact matches
let text = textNode.textContent || '';
text = text.replace(/\s+/g, ' ').trim();
const matchedAction = Object.keys(actionToKey).find(action => text === action || text.startsWith(action));
if (matchedAction) {
const hotkeyText = actionToKey[matchedAction];
// Construct the shortcut label using Google's native styling classes
const accelSpan = document.createElement('span');
accelSpan.className = 'goog-menuitem-accel';
accelSpan.setAttribute('aria-label', 'shortcut ' + hotkeyText);
accelSpan.style.userSelect = 'none';
// Fallback inline styling in case Google's core CSS classes were altered
accelSpan.style.float = 'right';
accelSpan.style.paddingLeft = '16px';
accelSpan.style.color = '#5f6368';
accelSpan.textContent = hotkeyText;
if (contentDiv) {
contentDiv.appendChild(accelSpan);
} else {
item.appendChild(accelSpan);
}
}
// Mark as injected so we never double-inject on the same element
item.setAttribute('data-hotkey-injected', 'true');
});
}
/* =================================================================================
SECTION 2: COLLAPSIBLE DOCUMENT OUTLINE / TABS ENGINE
================================================================================= */
const collapsedHeaders = new Set();
let isInitialized = false;
const css = `
.custom-outline-toggle {
position: absolute;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
font-size: 10px;
color: #5f6368;
padding: 4px;
z-index: 9999;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background-color: transparent;
transition: background-color 0.1s, color 0.1s;
font-family: Arial, sans-serif;
}
.custom-outline-toggle:hover {
background-color: rgba(0,0,0,0.1);
color: #202124;
}
.outline-item-hidden {
display: none !important;
}
`;
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function cleanText(text) {
if (!text) return '';
return text.replace(/\s+/g, ' ').trim();
}
function getHeaderKey(item) {
const clone = item.cloneNode(true);
const customToggle = clone.querySelector('.custom-outline-toggle');
if (customToggle) customToggle.remove();
clone.querySelectorAll('svg, button, [role="button"]').forEach(el => el.remove());
return cleanText(clone.textContent);
}
function getLevels(items, outerRows) {
return items.map((item, index) => {
const outerRow = outerRows[index];
const isHidden = outerRow.classList.contains('outline-item-hidden') ||
outerRow.closest('.outline-item-hidden') !== null;
if (isHidden && item.hasAttribute('data-custom-level')) {
return parseFloat(item.getAttribute('data-custom-level'));
}
let curr = item;
while (curr.children.length > 0) {
let nextChild = null;
for (let child of curr.children) {
if (child.classList.contains('custom-outline-toggle')) continue;
if (child.textContent.trim().length > 0) {
nextChild = child;
break;
}
}
if (nextChild && nextChild !== curr) {
curr = nextChild;
} else {
break;
}
}
const rect = curr.getBoundingClientRect();
const cssStyle = window.getComputedStyle(curr);
const paddingLeft = parseFloat(cssStyle.paddingLeft) || 0;
const leftPos = rect.left + paddingLeft;
item.setAttribute('data-custom-level', leftPos);
return leftPos;
});
}
const eventsToKill = ['mousedown', 'mouseup', 'pointerdown', 'pointerup'];
eventsToKill.forEach(eventName => {
window.addEventListener(eventName, (e) => {
if (e.target && e.target.closest && e.target.closest('.custom-outline-toggle')) {
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
}
}, true);
});
window.addEventListener('click', (e) => {
if (e.target && e.target.closest) {
const toggle = e.target.closest('.custom-outline-toggle');
if (toggle) {
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
toggle.dispatchEvent(new CustomEvent('custom-toggle-action'));
}
}
}, true);
function updateOutline() {
let candidates = Array.from(document.querySelectorAll(
'[role="treeitem"], [role="tab"], [role="button"], ' +
'[class*="navigation-item"], [class*="sidebar-item"], ' +
'[class*="tab-label"], [class*="tree-item"], [class*="outline-item"], ' +
'.goog-tree-row, .docs-navigation-item'
));
candidates = candidates.filter(item => {
const isHiddenByUs = item.classList.contains('outline-item-hidden') ||
item.closest('.outline-item-hidden') !== null;
const rect = item.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
if (!isHiddenByUs) return false;
}
if (rect.left >= window.innerWidth / 2 || rect.width >= 450) return false;
const text = cleanText(item.textContent);
if (text.length === 0 || text === '+') return false;
return true;
});
let items = candidates.filter(itemA => {
const hasDescendant = candidates.some(itemB => {
return itemB !== itemA && itemA.contains(itemB);
});
return !hasDescendant;
});
if (items.length === 0) return false;
let outerRows = items.map(inner => {
let curr = inner;
while (curr) {
if (candidates.includes(curr) && curr !== inner) {
return curr;
}
curr = curr.parentElement;
}
return inner;
});
if (!isInitialized) {
console.log('✅ Google Docs Collapsible Outline & Hotkeys Script: Active!');
isInitialized = true;
}
const levels = getLevels(items, outerRows);
items.forEach((item, index) => {
if (window.getComputedStyle(item).position === 'static') {
item.style.position = 'relative';
}
const currentLevel = levels[index];
const nextLevel = index + 1 < items.length ? levels[index + 1] : 0;
const hasChildren = nextLevel > currentLevel + 2;
const headerText = getHeaderKey(item);
let toggleBtn = item.querySelector('.custom-outline-toggle');
if (hasChildren) {
const shouldBeExpanded = !collapsedHeaders.has(headerText);
if (!toggleBtn) {
toggleBtn = document.createElement('div');
toggleBtn.className = 'custom-outline-toggle';
const itemLeft = item.getBoundingClientRect().left;
const arrowLeft = Math.max(2, currentLevel - itemLeft - 16);
toggleBtn.style.left = arrowLeft + 'px';
toggleBtn.addEventListener('custom-toggle-action', () => {
const willExpand = !toggleBtn.classList.contains('expanded');
if (willExpand) {
toggleBtn.classList.add('expanded');
toggleBtn.textContent = '▼';
collapsedHeaders.delete(headerText);
} else {
toggleBtn.classList.remove('expanded');
toggleBtn.textContent = '▶';
collapsedHeaders.add(headerText);
}
applyVisibility(items, levels, outerRows);
});
item.appendChild(toggleBtn);
}
if (shouldBeExpanded) {
toggleBtn.classList.add('expanded');
toggleBtn.textContent = '▼';
} else {
toggleBtn.classList.remove('expanded');
toggleBtn.textContent = '▶';
}
} else if (toggleBtn) {
toggleBtn.remove();
}
});
applyVisibility(items, levels, outerRows);
return true;
}
function applyVisibility(items, levels, outerRows) {
let collapsedLevelThreshold = Infinity;
for (let i = 0; i < items.length; i++) {
const outerRow = outerRows[i];
const level = levels[i];
if (level <= collapsedLevelThreshold + 2) {
collapsedLevelThreshold = Infinity;
}
const shouldHide = level > collapsedLevelThreshold + 2;
if (shouldHide) {
outerRow.classList.add('outline-item-hidden');
let walker = outerRow;
while (walker && walker !== document.body) {
const parent = walker.parentElement;
if (parent) {
const childItems = Array.from(parent.querySelectorAll(
'[role="treeitem"], [role="tab"], [class*="navigation-item"], [class*="sidebar-item"]'
));
const allChildrenHidden = childItems.every(child => {
const matchedItemIndex = items.indexOf(child);
if (matchedItemIndex !== -1) {
const childOuter = outerRows[matchedItemIndex];
return childOuter.classList.contains('outline-item-hidden');
}
return true;
});
if (allChildrenHidden && childItems.length > 0) {
parent.classList.add('outline-item-hidden');
walker = parent;
} else {
break;
}
} else {
break;
}
}
} else {
outerRow.classList.remove('outline-item-hidden');
let walker = outerRow;
while (walker && walker !== document.body) {
const parent = walker.parentElement;
if (parent && parent.classList.contains('outline-item-hidden')) {
parent.classList.remove('outline-item-hidden');
walker = parent;
} else {
break;
}
}
}
if (level <= collapsedLevelThreshold + 2) {
const toggleBtn = items[i].querySelector('.custom-outline-toggle');
if (toggleBtn && !toggleBtn.classList.contains('expanded')) {
collapsedLevelThreshold = level;
}
}
}
}
/* =================================================================================
SECTION 3: GLOBAL INITIALIZATION
================================================================================= */
let debounceTimer;
function setupObserver() {
if (!document.body) {
setTimeout(setupObserver, 100);
return;
}
const observer = new MutationObserver(() => {
// Check for menus instantly (bypassing the 300ms debounce) for snappy UI rendering
if (document.querySelector('.goog-menuitem:not([data-hotkey-injected])')) {
updateMenuHotkeys();
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
updateOutline(); // Render Outline Arrows
bindHotkeys(); // Attach hotkeys to any new active iframes
}, 300);
});
observer.observe(document.body, { childList: true, subtree: true });
// Initial manual trigger
updateOutline();
bindHotkeys();
}
setupObserver();
})();