| 1 | export type SelectionActionPoint = { x: number; y: number }; |
| 2 | |
| 3 | export const SELECTION_MULTI_CLICK_INTERVAL_MS = 500; |
| 4 | |
| 5 | export 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 | */ |
| 35 | export 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 | } |