Initial commit

This commit is contained in:
Andres Santana
2026-09-25 09:37:15 -04:00
commit 324464e9de
115 changed files with 22403 additions and 0 deletions
@@ -0,0 +1,71 @@
export const HOVER_OUTLINE_CLASS = 'inline-editor-hover-outline';
export const LOCKED_OUTLINE_CLASS = 'inline-editor-locked-outline';
/** @param {HTMLElement|HTMLElement[]|null} value @returns {HTMLElement[]} */
function toArray(value) {
if (!value) return [];
return Array.isArray(value) ? value.filter(Boolean) : [value];
}
/** @param {HTMLElement[]} elements @param {string} className */
function addClassTo(elements, className) {
for (const element of elements) element.classList.add(className);
}
/** @param {HTMLElement[]} elements @param {string} className */
function removeClassFrom(elements, className) {
for (const element of elements) element.classList.remove(className);
}
/** @type {HTMLElement[]} */
let _hoveredElements = [];
/** @type {HTMLElement[]} */
let _lockedElements = [];
let _holdCount = 0;
/**
* Dashed outline for the transient hover. Elements already carrying a locked
* outline are skipped so they aren't outlined twice.
* @param {HTMLElement|HTMLElement[]} elements
*/
export function showHoverOutline(elements) {
const targets = toArray(elements).filter(element => !_lockedElements.includes(element));
if (!targets.length) return;
removeClassFrom(_hoveredElements, HOVER_OUTLINE_CLASS);
_hoveredElements = targets;
addClassTo(_hoveredElements, HOVER_OUTLINE_CLASS);
}
/** Hides the hover outline (no-op while held). */
export function hideHoverOutline() {
if (_holdCount > 0) return;
removeClassFrom(_hoveredElements, HOVER_OUTLINE_CLASS);
_hoveredElements = [];
}
export function holdHoverOutline() { _holdCount++; }
export function releaseHoverOutline() {
_holdCount = Math.max(0, _holdCount - 1);
if (_holdCount === 0) hideHoverOutline();
}
/**
* Solid outline for a committed selection — a drag preview, a shift+click
* group, or the element(s) an open annotation panel targets.
* @param {HTMLElement|HTMLElement[]} elements
*/
export function lockHoverOutline(elements) {
const targets = toArray(elements);
if (!targets.length) {
unlockHoverOutline();
return;
}
removeClassFrom(_lockedElements, LOCKED_OUTLINE_CLASS);
_lockedElements = targets;
addClassTo(_lockedElements, LOCKED_OUTLINE_CLASS);
}
export function unlockHoverOutline() {
removeClassFrom(_lockedElements, LOCKED_OUTLINE_CLASS);
_lockedElements = [];
}
@@ -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;
}
@@ -0,0 +1,73 @@
import { getElementType } from '../../utils/dom-utils.js';
import { TYPE_TOOLTIP_CURSOR_OFFSET, TOOLTIP_VIEWPORT_MARGIN } from '../../constants/layout.js';
import { ELEMENT_TYPE_TRANSLATIONS } from '../../constants/selectors.js';
import { showHoverOutline, hideHoverOutline } from './hover-outline.js';
import { highlightMarkerForElement, unhighlightAllMarkers } from '../annotation-panel/annotation-markers.js';
import { getEditorTranslations } from '../../state/annotation-state.js';
const ELEMENT_TYPE_TOOLTIP_CLASS = 'element-type-tooltip';
let typeTooltipElement = null;
let displayedType = null;
let cachedWidth = 0;
let cachedHeight = 0;
function createTypeTooltip() {
if (typeTooltipElement) return;
typeTooltipElement = document.createElement('div');
typeTooltipElement.className = ELEMENT_TYPE_TOOLTIP_CLASS;
document.body.appendChild(typeTooltipElement);
}
/**
* Positions the tooltip's top-left corner near `(clientX, clientY)`, clamped
* per axis so it never crosses within `TOOLTIP_VIEWPORT_MARGIN` of the
* viewport edge. The cursor is free to keep moving over the parked tooltip —
* it is `pointer-events: none`, so hit-testing resolves straight through it.
* @param {number} clientX
* @param {number} clientY
*/
function positionAt(clientX, clientY) {
const maxLeft = Math.max(TOOLTIP_VIEWPORT_MARGIN, window.innerWidth - cachedWidth - TOOLTIP_VIEWPORT_MARGIN);
const maxTop = Math.max(TOOLTIP_VIEWPORT_MARGIN, window.innerHeight - cachedHeight - TOOLTIP_VIEWPORT_MARGIN);
const left = Math.min(Math.max(clientX + TYPE_TOOLTIP_CURSOR_OFFSET, TOOLTIP_VIEWPORT_MARGIN), maxLeft);
const top = Math.min(Math.max(clientY + TYPE_TOOLTIP_CURSOR_OFFSET, TOOLTIP_VIEWPORT_MARGIN), maxTop);
typeTooltipElement.style.transform = `translate3d(${left}px, ${top}px, 0)`;
}
/**
* Shows the type tooltip near the cursor, labelling `element`'s logical type.
* @param {HTMLElement} element
* @param {number} clientX
* @param {number} clientY
*/
export function showTypeTooltip(element, clientX, clientY) {
const type = getElementType(element);
if (!typeTooltipElement) createTypeTooltip();
if (!typeTooltipElement.isConnected) document.body.appendChild(typeTooltipElement);
if (type !== displayedType) {
displayedType = type;
const translations = getEditorTranslations();
const translationKey = ELEMENT_TYPE_TRANSLATIONS[type]?.key;
typeTooltipElement.textContent = (translationKey && translations[translationKey]) || type;
cachedWidth = typeTooltipElement.offsetWidth;
cachedHeight = typeTooltipElement.offsetHeight;
}
typeTooltipElement.classList.add('active');
positionAt(clientX, clientY);
showHoverOutline(element);
unhighlightAllMarkers();
highlightMarkerForElement(element);
}
/** Hides the type tooltip and its paired hover outline. */
export function hideTypeTooltip() {
typeTooltipElement?.classList.remove('active');
displayedType = null;
hideHoverOutline();
unhighlightAllMarkers();
}