Files
kokkoja-rel/client/js/selection_guard.js
T

79 lines
3.1 KiB
JavaScript
Raw Normal View History

// @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. */
2026-10-11 13:17:22 +02:00
export 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}`);
});
}