Created
April 21, 2026 19:16
-
-
Save tilacog/53ca58d63f5e9c1b059fe5c5c3838cae to your computer and use it in GitHub Desktop.
Notion Read-Only Mode
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Notion Read-Only Mode | |
| // @namespace https://notion.so/ | |
| // @version 1.0.0 | |
| // @description Adds a toggleable "Read Only Mode" button next to Share that blocks all edits while keeping text selection/highlighting intact. | |
| // @author you | |
| // @match https://www.notion.so/* | |
| // @match https://notion.so/* | |
| // @run-at document-idle | |
| // @grant none | |
| // @noframes | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // --------------------------------------------------------------------------- | |
| // State | |
| // --------------------------------------------------------------------------- | |
| // `readOnly` is the single source of truth for whether edits are blocked. | |
| // We deliberately keep it in module-local scope (no window pollution). | |
| // `mutationObserver` watches the DOM so we can re-inject our button whenever | |
| // Notion's SPA re-renders the topbar (e.g. when navigating between pages). | |
| let readOnly = false; | |
| let mutationObserver = null; | |
| const BUTTON_ID = 'tm-notion-read-only-toggle'; | |
| const HTML_STATE_CLASS = 'tm-notion-readonly'; // applied to <html> when active | |
| // --------------------------------------------------------------------------- | |
| // Locating Notion's Share button | |
| // --------------------------------------------------------------------------- | |
| // Notion's Share button is a `div[role="button"]` whose visible text is | |
| // exactly "Share", sitting inside the `.notion-topbar` action cluster. | |
| // Its class names contain hashed fragments that change between releases, so | |
| // we don't rely on them. We instead do a text-content scan scoped to the | |
| // topbar, with a cheap specific-selector fast-path for older layouts. | |
| function findShareButton() { | |
| const specific = document.querySelector('.notion-topbar-share-menu'); | |
| if (specific) return specific; | |
| const topbar = document.querySelector('.notion-topbar') || document.body; | |
| const candidates = topbar.querySelectorAll('[role="button"]'); | |
| for (const el of candidates) { | |
| const text = (el.innerText || el.textContent || '').trim(); | |
| if (text === 'Share') return el; | |
| } | |
| return null; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Event-blocking logic | |
| // --------------------------------------------------------------------------- | |
| // We treat the Notion editor as "anything inside a contenteditable=true | |
| // subtree" — block content, page titles, rename popovers — *except* comment | |
| // surfaces, which we want to remain fully writable so you can annotate | |
| // without leaving read-only mode. | |
| // | |
| // Notion renders every comment UI (side panel, inline thread popover, reply | |
| // composer) under an ancestor whose class name contains `comment` or | |
| // `discussion`. We use a case-insensitive attribute-substring match so we | |
| // catch every variant (`notion-comments-panel`, `notion-discussion`, | |
| // `...CommentInput...`, etc.) without pinning to Notion's hashed classes. | |
| const COMMENT_ANCESTOR_SELECTOR = | |
| '[class*="comment" i], [class*="discussion" i]'; | |
| function isCommentSurface(el) { | |
| return !!(el && el.closest && el.closest(COMMENT_ANCESTOR_SELECTOR)); | |
| } | |
| function inEditableSurface(target) { | |
| if (!target || !target.closest) return false; | |
| const editable = target.closest('[contenteditable="true"]'); | |
| if (!editable) return false; | |
| // Comment editors are contenteditable too — let them through. | |
| if (isCommentSurface(editable)) return false; | |
| return true; | |
| } | |
| // Keys that are always safe: pure navigation, escape, and function keys | |
| // (F5 reload, F12 devtools, etc. shouldn't be collateral damage). | |
| const ALWAYS_ALLOWED_KEYS = new Set([ | |
| 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', | |
| 'PageUp', 'PageDown', 'Home', 'End', 'Escape', | |
| 'F1','F2','F3','F4','F5','F6','F7','F8','F9','F10','F11','F12', | |
| ]); | |
| // Single-letter keys that are safe when combined *only* with Ctrl/Cmd | |
| // (no Shift, no Alt). Anything else gets blocked when focus is in the editor: | |
| // c - copy | |
| // a - select all | |
| // f - browser find-in-page | |
| // p - Notion quick find (navigation, not modification) | |
| const SAFE_MOD_KEYS = new Set(['c', 'a', 'f', 'p']); | |
| function shouldAllowKeydown(e) { | |
| if (ALWAYS_ALLOWED_KEYS.has(e.key)) return true; | |
| const isMod = e.ctrlKey || e.metaKey; | |
| if (isMod && !e.altKey && !e.shiftKey && SAFE_MOD_KEYS.has(e.key.toLowerCase())) { | |
| return true; | |
| } | |
| // Outside the editor, let everything through. This keeps the sidebar, | |
| // topbar buttons, modals, and browser-level shortcuts fully functional. | |
| if (!inEditableSurface(e.target)) return true; | |
| // Inside the editor with no safe key — block it. | |
| return false; | |
| } | |
| function onKeydown(e) { | |
| if (!readOnly) return; | |
| if (shouldAllowKeydown(e)) return; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| e.stopImmediatePropagation(); | |
| } | |
| // `beforeinput` is the heavyweight block. Its `inputType` covers every | |
| // contenteditable mutation: insertText, insertFromPaste, insertFromDrop, | |
| // deleteContentBackward/Forward/ByWord/ByLine, historyUndo, historyRedo, | |
| // formatBold, insertLineBreak, etc. Preventing it cancels the mutation | |
| // without disturbing selection or focus. | |
| function onBeforeInput(e) { | |
| if (!readOnly) return; | |
| if (!inEditableSurface(e.target)) return; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| e.stopImmediatePropagation(); | |
| } | |
| // Paste/cut/drop still get their own blockers even though beforeinput | |
| // usually catches them. This is defense in depth: some browsers and some | |
| // code paths (e.g. programmatic cut via execCommand) short-circuit | |
| // beforeinput, and we'd rather be boring-and-correct than clever. | |
| function onClipboardOrDrop(e) { | |
| if (!readOnly) return; | |
| if (!inEditableSurface(e.target)) return; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| e.stopImmediatePropagation(); | |
| } | |
| // Preventing `dragover` in read-only mode stops external files (or even | |
| // drags from inside the page) from being droppable anywhere. `dragstart` | |
| // is blocked on editable surfaces so Notion's own block-drag handles | |
| // don't let you rearrange blocks. | |
| function onDragOver(e) { | |
| if (!readOnly) return; | |
| e.preventDefault(); | |
| } | |
| function onDragStart(e) { | |
| if (!readOnly) return; | |
| if (!inEditableSurface(e.target)) return; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| } | |
| // All listeners are registered with `capture: true` so they fire during the | |
| // capture phase — before Notion's own handlers on inner elements. This is | |
| // what lets us preempt the editor instead of racing it. | |
| function installListeners() { | |
| window.addEventListener('keydown', onKeydown, true); | |
| window.addEventListener('keypress', onKeydown, true); // legacy safety net | |
| window.addEventListener('beforeinput', onBeforeInput, true); | |
| window.addEventListener('paste', onClipboardOrDrop, true); | |
| window.addEventListener('cut', onClipboardOrDrop, true); | |
| window.addEventListener('drop', onClipboardOrDrop, true); | |
| window.addEventListener('dragover', onDragOver, true); | |
| window.addEventListener('dragstart', onDragStart, true); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Toggle + visual state | |
| // --------------------------------------------------------------------------- | |
| function setReadOnly(value, button) { | |
| readOnly = !!value; | |
| if (button) { | |
| button.setAttribute('aria-pressed', String(readOnly)); | |
| const label = button.querySelector('.tm-ro-label'); | |
| if (label) label.textContent = readOnly ? 'Editing Locked' : 'Read Only Mode'; | |
| // Tinted red-ish background when active; otherwise clear overrides so | |
| // the button falls back to whatever Notion's classes give it. | |
| if (readOnly) { | |
| button.style.background = 'rgba(235, 87, 87, 0.15)'; | |
| button.style.color = 'rgb(235, 87, 87)'; | |
| } else { | |
| button.style.background = ''; | |
| button.style.color = ''; | |
| } | |
| } | |
| document.documentElement.classList.toggle(HTML_STATE_CLASS, readOnly); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Button construction + injection | |
| // --------------------------------------------------------------------------- | |
| // We lift Notion's own classes and inline styles onto our button so that | |
| // typography, padding, hover states, and dark-mode adaption all come for | |
| // free. The one thing we add is a small right margin so it doesn't collide | |
| // with Share. | |
| function createButton(shareButton) { | |
| const btn = document.createElement('div'); | |
| btn.id = BUTTON_ID; | |
| btn.setAttribute('role', 'button'); | |
| btn.setAttribute('tabindex', '0'); | |
| btn.setAttribute('aria-pressed', 'false'); | |
| btn.setAttribute('aria-label', 'Toggle read-only mode'); | |
| if (shareButton.className) btn.className = shareButton.className; | |
| if (shareButton.style && shareButton.style.cssText) { | |
| btn.style.cssText = shareButton.style.cssText; | |
| } | |
| btn.style.marginRight = '6px'; | |
| const label = document.createElement('div'); | |
| label.className = 'tm-ro-label'; | |
| label.textContent = 'Read Only Mode'; | |
| label.style.whiteSpace = 'nowrap'; | |
| btn.appendChild(label); | |
| // Click and keyboard activation. We stop propagation so Notion doesn't | |
| // interpret Enter/Space on our button as an editor input. | |
| const activate = (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setReadOnly(!readOnly, btn); | |
| }; | |
| btn.addEventListener('click', activate); | |
| btn.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' || e.key === ' ') activate(e); | |
| }, true); | |
| return btn; | |
| } | |
| function injectButton() { | |
| if (document.getElementById(BUTTON_ID)) return true; | |
| const share = findShareButton(); | |
| if (!share || !share.parentNode) return false; | |
| const btn = createButton(share); | |
| share.parentNode.insertBefore(btn, share); | |
| setReadOnly(readOnly, btn); // reflect current state on the fresh node | |
| return true; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // SPA resilience | |
| // --------------------------------------------------------------------------- | |
| // Notion tears down and rebuilds the topbar across navigations. The observer | |
| // re-injects our button on any subtree mutation where our node has gone | |
| // missing. The check is O(1) (getElementById) so firing often is fine. | |
| function startObserver() { | |
| if (mutationObserver) mutationObserver.disconnect(); | |
| mutationObserver = new MutationObserver(() => { | |
| if (!document.getElementById(BUTTON_ID)) injectButton(); | |
| }); | |
| mutationObserver.observe(document.body, { childList: true, subtree: true }); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Bootstrap | |
| // --------------------------------------------------------------------------- | |
| function init() { | |
| installListeners(); | |
| injectButton(); | |
| startObserver(); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init, { once: true }); | |
| } else { | |
| init(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment