Skip to content
Source
Sign inSign up

selectionActions.ts

204 lines6.7 KB

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