export type SelectionActionPoint = { x: number; y: number };
export const SELECTION_MULTI_CLICK_INTERVAL_MS = 500;
export function resolveSelectionActionPosition(options: {
bounds: { left: number; top: number; width: number; height: number };
selectionRect: { right: number; bottom: number } | null;
pointer: SelectionActionPoint | null;
viewport: { width: number; height: number };
}): SelectionActionPoint {
const { bounds, selectionRect, pointer, viewport } = options;
const preferred = pointer ?? {
x: selectionRect?.right ?? bounds.left + bounds.width - 140,
y: selectionRect ? selectionRect.bottom + 4 : bounds.top + 12,
};
return {
x: Math.max(
8,
Math.min(Math.max(bounds.left, preferred.x), bounds.left + bounds.width, viewport.width - 8),
),
y: Math.max(
8,
Math.min(Math.max(bounds.top, preferred.y), bounds.top + bounds.height, viewport.height - 8),
),
};
}
/**
* Opens selection actions after release, leaving multi-clicks time to finish.
* DOM selections call selectionChanged; canvas selections can use their own
* change notification. Each surface still owns reading and acting on its text.
* Interactive popovers pass getActionElement to keep editing their fields from
* replacing the captured source selection.
*/
export function observeSelectionActions({
element,
getActionElement,
onSelection,
onDismiss,
}: {
element: HTMLElement;
getActionElement?: () => HTMLElement | null;
onSelection: (pointer: SelectionActionPoint | null) => void;
onDismiss: (reason: "interaction" | "cancel") => void;
}) {
const document = element.ownerDocument;
const view = document.defaultView!;
let pointerDown = false;
let gestureActive = false;
let dismissed = false;
let pointer: SelectionActionPoint | null = null;
let timer: number | null = null;
let frame: number | null = null;
const isActionTarget = (target: EventTarget | null) =>
target !== null && (getActionElement?.()?.contains(target as Node) ?? false);
const cancelPending = () => {
if (timer !== null) view.clearTimeout(timer);
if (frame !== null) view.cancelAnimationFrame(frame);
timer = frame = null;
};
const cancel = () => {
cancelPending();
pointerDown = gestureActive = false;
pointer = null;
dismissed = true;
};
const dismiss = () => {
cancel();
onDismiss("cancel");
};
const onContextMenu = () => {
cancel();
onDismiss("interaction");
};
const schedule = (delay: number) => {
cancelPending();
timer = view.setTimeout(() => {
timer = null;
frame = view.requestAnimationFrame(() => {
frame = null;
onSelection(pointer);
});
}, delay);
};
const onPointerDown = (event: PointerEvent) => {
if (!event.isPrimary) return;
if (isActionTarget(event.target)) {
cancel();
return;
}
cancelPending();
pointerDown = event.button === 0;
const inside = element.contains(event.target as Node);
gestureActive = false;
dismissed = true;
pointer = null;
if (inside) onDismiss("interaction");
else if (getActionElement?.()) onDismiss("cancel");
};
const onSelectionStart = (event: PointerEvent) => {
if (!event.isPrimary) return;
// A surface may consume a press for a link or terminal mouse reporting.
gestureActive = event.button === 0 && !event.defaultPrevented;
dismissed = !gestureActive;
};
const onPointerUp = (event: PointerEvent) => {
// Preventing pointerdown (for example on a toolbar) can suppress mouseup.
if (event.isPrimary && event.button === 0) pointerDown = false;
};
const onMouseUp = (event: MouseEvent) => {
if (event.button !== 0) return;
pointerDown = false;
if (!gestureActive) return;
gestureActive = false;
pointer = { x: event.clientX, y: event.clientY };
schedule(event.detail >= 2 ? SELECTION_MULTI_CLICK_INTERVAL_MS : 0);
};
const onKeyDown = (event: KeyboardEvent) => {
if (isActionTarget(event.target) && event.key !== "Escape") return;
if (event.key === "Escape") {
dismiss();
} else if (!pointerDown) {
cancelPending();
gestureActive = false;
pointer = null;
dismissed = false;
}
};
const onFocusIn = (event: FocusEvent) => {
if (isActionTarget(event.target)) cancel();
else if (!pointerDown && getActionElement?.()) dismiss();
};
const onScroll = () => {
cancelPending();
// Autoscroll during a drag must not cancel that gesture's eventual release.
if (!pointerDown) {
pointer = null;
dismissed = true;
}
onDismiss("cancel");
};
document.addEventListener("pointerdown", onPointerDown, true);
element.addEventListener("pointerdown", onSelectionStart);
view.addEventListener("pointerup", onPointerUp);
view.addEventListener("mouseup", onMouseUp);
view.addEventListener("pointercancel", dismiss);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("focusin", onFocusIn);
element.addEventListener("contextmenu", onContextMenu, true);
element.addEventListener("scroll", onScroll, true);
view.addEventListener("blur", dismiss);
view.addEventListener("resize", dismiss);
return {
get pending() {
return timer !== null || frame !== null;
},
cancel,
selectionChanged() {
if (isActionTarget(document.activeElement)) return;
if (pointerDown || gestureActive) {
onDismiss("cancel");
} else if (!dismissed && timer === null && frame === null) {
schedule(0);
}
},
dispose() {
cancel();
document.removeEventListener("pointerdown", onPointerDown, true);
element.removeEventListener("pointerdown", onSelectionStart);
view.removeEventListener("pointerup", onPointerUp);
view.removeEventListener("mouseup", onMouseUp);
view.removeEventListener("pointercancel", dismiss);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("focusin", onFocusIn);
element.removeEventListener("contextmenu", onContextMenu, true);
element.removeEventListener("scroll", onScroll, true);
view.removeEventListener("blur", dismiss);
view.removeEventListener("resize", dismiss);
},
};
}