Skip to content

Instantly share code, notes, and snippets.

@tilacog
Created April 21, 2026 19:16
Show Gist options
  • Select an option

  • Save tilacog/53ca58d63f5e9c1b059fe5c5c3838cae to your computer and use it in GitHub Desktop.

Select an option

Save tilacog/53ca58d63f5e9c1b059fe5c5c3838cae to your computer and use it in GitHub Desktop.
Notion Read-Only Mode
// ==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