185 lines · 6.2 KB · TypeScript
RawDownload
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);    },  };}