79 lines
3.1 KiB
JavaScript
79 lines
3.1 KiB
JavaScript
// @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<string>} */
|
||
|
|
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}`);
|
||
|
|
});
|
||
|
|
}
|