update: corrected version
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { SELECTION_RECT_ID, isInsideEditorUi, getEditId } from '../../constants/selectors.js';
|
||||
import { EDIT_BACKGROUND_COLOR, Z_INDEX_EDITOR_OVERLAY, EDIT_BORDER_COLOR } from '../../constants/theme.js';
|
||||
import { MIN_SELECTION_CANDIDATE_SIZE, MAX_SELECTED_ELEMENTS } from '../../constants/layout.js';
|
||||
import { getAppRoot, getOpenModals } from '../../utils/dom-utils.js';
|
||||
import { orderSelection } from '../../state/multi-select-state.js';
|
||||
|
||||
/** Set on `<body>` for the duration of a selection gesture, to keep the browser from painting text over it. */
|
||||
export const NO_TEXT_SELECT_CLASS = 'selection-mode-no-text-select';
|
||||
|
||||
const SELECTION_RECT_STYLES = `
|
||||
#${SELECTION_RECT_ID} {
|
||||
position: fixed;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
background: ${EDIT_BACKGROUND_COLOR};
|
||||
border: 1px solid ${EDIT_BORDER_COLOR};
|
||||
z-index: ${Z_INDEX_EDITOR_OVERLAY};
|
||||
}
|
||||
|
||||
body.${NO_TEXT_SELECT_CLASS}, body.${NO_TEXT_SELECT_CLASS} * {
|
||||
user-select: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
let _rectElement = null;
|
||||
let _stylesInjected = false;
|
||||
|
||||
function ensureStyles() {
|
||||
if (_stylesInjected) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'selection-rect-styles';
|
||||
style.textContent = SELECTION_RECT_STYLES;
|
||||
document.head.appendChild(style);
|
||||
_stylesInjected = true;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Geometry
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Normalises two drag corners into a viewport-space rect.
|
||||
* @returns {{x: number, y: number, w: number, h: number}}
|
||||
*/
|
||||
export function getSelectionRect(x1, y1, x2, y2) {
|
||||
return { x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) };
|
||||
}
|
||||
|
||||
function intersects(bounds, rect) {
|
||||
return bounds.right > rect.x && bounds.left < rect.x + rect.w
|
||||
&& bounds.bottom > rect.y && bounds.top < rect.y + rect.h;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Candidate resolution
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* True when the element is a plausible annotation target: visible, big enough to
|
||||
* aim at, and not part of the editor's own UI.
|
||||
* @param {HTMLElement} element
|
||||
* @param {DOMRect} bounds
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSelectableCandidate(element, bounds) {
|
||||
if (bounds.width < MIN_SELECTION_CANDIDATE_SIZE || bounds.height < MIN_SELECTION_CANDIDATE_SIZE) return false;
|
||||
if (isInsideEditorUi(element)) return false;
|
||||
|
||||
const styles = window.getComputedStyle(element);
|
||||
return styles.display !== 'none' && styles.visibility !== 'hidden';
|
||||
}
|
||||
|
||||
/** Children the band also touches; an empty result means this element is as deep as it goes. */
|
||||
function intersectingChildren(element, rect) {
|
||||
return Array.from(element.children).filter((child) => {
|
||||
const bounds = child.getBoundingClientRect();
|
||||
return isSelectableCandidate(child, bounds) && intersects(bounds, rect);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the band touches, taking the deepest element on each branch so a
|
||||
* container never comes along with the children that made it a hit.
|
||||
* @param {{x: number, y: number, w: number, h: number}} rect
|
||||
* @returns {HTMLElement[]} Document-ordered, with no member nested in another.
|
||||
*/
|
||||
export function collectSelection(rect) {
|
||||
const selected = [];
|
||||
const openModals = getOpenModals();
|
||||
const roots = openModals.length ? openModals : [getAppRoot()];
|
||||
const queue = roots.flatMap((root) => [...root.children]);
|
||||
|
||||
while (queue.length && selected.length < MAX_SELECTED_ELEMENTS) {
|
||||
const element = queue.shift();
|
||||
const bounds = element.getBoundingClientRect();
|
||||
// No overlap prunes the whole subtree: children sit inside their parent's box.
|
||||
if (!isSelectableCandidate(element, bounds) || !intersects(bounds, rect)) continue;
|
||||
|
||||
// An edit id marks the editor's own annotation target, so stop there rather
|
||||
// than splitting a button or heading into the spans it is built from.
|
||||
const children = getEditId(element) ? [] : intersectingChildren(element, rect);
|
||||
if (children.length) {
|
||||
queue.push(...children);
|
||||
} else {
|
||||
selected.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
return orderSelection(selected);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Band rendering
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function showSelectionRect(x1, y1, x2, y2) {
|
||||
if (!_rectElement) {
|
||||
ensureStyles();
|
||||
_rectElement = document.createElement('div');
|
||||
_rectElement.id = SELECTION_RECT_ID;
|
||||
document.body.appendChild(_rectElement);
|
||||
}
|
||||
if (!_rectElement.isConnected) document.body.appendChild(_rectElement);
|
||||
|
||||
const rect = getSelectionRect(x1, y1, x2, y2);
|
||||
_rectElement.style.left = `${rect.x}px`;
|
||||
_rectElement.style.top = `${rect.y}px`;
|
||||
_rectElement.style.width = `${rect.w}px`;
|
||||
_rectElement.style.height = `${rect.h}px`;
|
||||
_rectElement.style.display = 'block';
|
||||
}
|
||||
|
||||
export function hideSelectionRect() {
|
||||
if (_rectElement) _rectElement.style.display = 'none';
|
||||
}
|
||||
|
||||
export function destroySelectionRect() {
|
||||
_rectElement?.remove();
|
||||
_rectElement = null;
|
||||
}
|
||||
Reference in New Issue
Block a user