1export type SelectionActionPoint = { x: number; y: number };
2
3export const SELECTION_MULTI_CLICK_INTERVAL_MS = 500;
4
5export function resolveSelectionActionPosition(options: {
6 bounds: { left: number; top: number; width: number; height: number };
7 selectionRect: { right: number; bottom: number } | null;
8 pointer: SelectionActionPoint | null;
9 viewport: { width: number; height: number };
10}): SelectionActionPoint {
11 const { bounds, selectionRect, pointer, viewport } = options;
12 const preferred = pointer ?? {
13 x: selectionRect?.right ?? bounds.left + bounds.width - 140,
14 y: selectionRect ? selectionRect.bottom + 4 : bounds.top + 12,
15 };
16 return {
17 x: Math.max(
18 8,
19 Math.min(Math.max(bounds.left, preferred.x), bounds.left + bounds.width, viewport.width - 8),
20 ),
21 y: Math.max(
22 8,
23 Math.min(Math.max(bounds.top, preferred.y), bounds.top + bounds.height, viewport.height - 8),
24 ),
25 };
26}
27
28/**
29 * Opens selection actions after release, leaving multi-clicks time to finish.
30 * DOM selections call selectionChanged; canvas selections can use their own
31 * change notification. Each surface still owns reading and acting on its text.
32 * Interactive popovers pass getActionElement to keep editing their fields from
33 * replacing the captured source selection.
34 */
35export function observeSelectionActions({
36 element,
37 getActionElement,
38 onSelection,
39 onDismiss,
40}: {
41 element: HTMLElement;
42 getActionElement?: () => HTMLElement | null;
43 onSelection: (pointer: SelectionActionPoint | null) => void;
44 onDismiss: (reason: "interaction" | "cancel") => void;
45}) {
46 const document = element.ownerDocument;
47 const view = document.defaultView!;
48 let pointerDown = false;
49 let gestureActive = false;
50 let dismissed = false;
51 let pointer: SelectionActionPoint | null = null;
52 let timer: number | null = null;
53 let frame: number | null = null;
54
55 const isActionTarget = (target: EventTarget | null) =>
56 target !== null && (getActionElement?.()?.contains(target as Node) ?? false);
57
58 const cancelPending = () => {
59 if (timer !== null) view.clearTimeout(timer);
60 if (frame !== null) view.cancelAnimationFrame(frame);
61 timer = frame = null;
62 };
63 const cancel = () => {
64 cancelPending();
65 pointerDown = gestureActive = false;
66 pointer = null;
67 dismissed = true;
68 };
69 const dismiss = () => {
70 cancel();
71 onDismiss("cancel");
72 };
73 const onContextMenu = () => {
74 cancel();
75 onDismiss("interaction");
76 };
77 const schedule = (delay: number) => {
78 cancelPending();
79 timer = view.setTimeout(() => {
80 timer = null;
81 frame = view.requestAnimationFrame(() => {
82 frame = null;
83 onSelection(pointer);
84 });
85 }, delay);
86 };
87 const onPointerDown = (event: PointerEvent) => {
88 if (!event.isPrimary) return;
89 if (isActionTarget(event.target)) {
90 cancel();
91 return;
92 }
93 cancelPending();
94 pointerDown = event.button === 0;
95 const inside = element.contains(event.target as Node);
96 gestureActive = false;
97 dismissed = true;
98 pointer = null;
99 if (inside) onDismiss("interaction");
100 else if (getActionElement?.()) onDismiss("cancel");
101 };
102 const onSelectionStart = (event: PointerEvent) => {
103 if (!event.isPrimary) return;
104 const target = event.target as Element | null;
105 // Buttons can preserve an existing text selection while opening a menu.
106 // Their release must not reopen selection actions beside the button.
107 if (target?.closest("button, [role=button]")) return;
108 // A surface may consume a press for a link or terminal mouse reporting.
109 gestureActive = event.button === 0 && !event.defaultPrevented;
110 dismissed = !gestureActive;
111 };
112 const onPointerUp = (event: PointerEvent) => {
113 // Preventing pointerdown (for example on a toolbar) can suppress mouseup.
114 if (event.isPrimary && event.button === 0) pointerDown = false;
115 };
116 const onMouseUp = (event: MouseEvent) => {
117 if (event.button !== 0) return;
118 pointerDown = false;
119 if (!gestureActive) return;
120 gestureActive = false;
121 pointer = { x: event.clientX, y: event.clientY };
122 schedule(event.detail >= 2 ? SELECTION_MULTI_CLICK_INTERVAL_MS : 0);
123 };
124 const onKeyDown = (event: KeyboardEvent) => {
125 if (isActionTarget(event.target) && event.key !== "Escape") return;
126 if (event.key === "Escape") {
127 dismiss();
128 } else if (!pointerDown) {
129 cancelPending();
130 gestureActive = false;
131 pointer = null;
132 dismissed = false;
133 }
134 };
135 const onFocusIn = (event: FocusEvent) => {
136 if (isActionTarget(event.target)) cancel();
137 else if (!pointerDown && getActionElement?.()) dismiss();
138 };
139 const onScroll = () => {
140 cancelPending();
141 // Autoscroll during a drag must not cancel that gesture's eventual release.
142 if (!pointerDown) {
143 pointer = null;
144 dismissed = true;
145 }
146 onDismiss("cancel");
147 };
148
149 document.addEventListener("pointerdown", onPointerDown, true);
150 element.addEventListener("pointerdown", onSelectionStart);
151 view.addEventListener("pointerup", onPointerUp);
152 view.addEventListener("mouseup", onMouseUp);
153 view.addEventListener("pointercancel", dismiss);
154 document.addEventListener("keydown", onKeyDown);
155 document.addEventListener("focusin", onFocusIn);
156 element.addEventListener("contextmenu", onContextMenu, true);
157 element.addEventListener("scroll", onScroll, true);
158 view.addEventListener("blur", dismiss);
159 view.addEventListener("resize", dismiss);
160
161 return {
162 get pending() {
163 return timer !== null || frame !== null;
164 },
165 cancel,
166 selectionChanged() {
167 if (isActionTarget(document.activeElement)) return;
168 if (pointerDown || gestureActive) {
169 onDismiss("cancel");
170 } else if (!dismissed && timer === null && frame === null) {
171 schedule(0);
172 }
173 },
174 dispose() {
175 cancel();
176 document.removeEventListener("pointerdown", onPointerDown, true);
177 element.removeEventListener("pointerdown", onSelectionStart);
178 view.removeEventListener("pointerup", onPointerUp);
179 view.removeEventListener("mouseup", onMouseUp);
180 view.removeEventListener("pointercancel", dismiss);
181 document.removeEventListener("keydown", onKeyDown);
182 document.removeEventListener("focusin", onFocusIn);
183 element.removeEventListener("contextmenu", onContextMenu, true);
184 element.removeEventListener("scroll", onScroll, true);
185 view.removeEventListener("blur", dismiss);
186 view.removeEventListener("resize", dismiss);
187 },
188 };
189}