// @ts-check /** * No selection of text that the stylesheet declares unselectable. * * Safari on iOS and iPadOS sometimes selected the save status at the * toolbar's right end when a stroke began with an Apple Pencil on Andy's * iPad (2026-10-10), no hand on the glass: WebKit may take a Pencil drag * for a selection gesture, and the selection snaps to the nearest text. * The prefixed -webkit-user-select: none made it rarer, not gone, so * WebKit's gesture path does not fully respect the property. Two * guards, both inert where the browser already does the right thing: * a selection that is about to start in unselectable text is refused * (selectstart), and one that WebKit makes there anyway is cleared the * moment it appears (selectionchange). Each is logged, in the console * once per place and in the record every time, as evidence of which * path the browser took. Selections inside fields * (the invitation, a text block being typed) are left alone: a field's * own selection reaches document.getSelection() in some browsers as a * range on the field's parent, which may be unselectable. */ import { log, trace } from './log.js'; /** * @param {Node | null | undefined} node * @returns {boolean} Whether the stylesheet forbids selecting it. */ function unselectable(node) { const element = node instanceof Element ? node : node?.parentElement; if (!element) return false; const style = getComputedStyle(element); const value = style.getPropertyValue('-webkit-user-select') || style.getPropertyValue('user-select'); return value === 'none'; } /** * @param {Node | null | undefined} node * @returns {string} The element, for the log. */ function describe(node) { const element = node instanceof Element ? node : node?.parentElement; if (!element) return 'nothing'; const name = element.tagName.toLowerCase(); if (element.id) return `${name}#${element.id}`; return element.className ? `${name}.${element.className}` : name; } /** Whether the selection belongs to a focused text field. */ function inField() { const active = document.activeElement; return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement || (active instanceof HTMLElement && active.isContentEditable); } export function guardSelection() { /** @type {Set} */ const logged = new Set(); /** @param {string} what */ function once(what) { if (logged.has(what)) return trace(what); logged.add(what); log(what); } document.addEventListener('selectstart', (event) => { if (inField() || !(event.target instanceof Node) || !unselectable(event.target)) return; event.preventDefault(); once(`Refused a selection starting in unselectable ${describe(event.target)}`); }); document.addEventListener('selectionchange', () => { const selection = document.getSelection(); if (!selection || selection.isCollapsed || inField()) return; if (!unselectable(selection.anchorNode) && !unselectable(selection.focusNode)) return; const where = describe(selection.anchorNode); selection.removeAllRanges(); once(`Cleared a selection the browser made in unselectable ${where}`); }); }