UNPKG

relay-companion

Version:

Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.

385 lines (349 loc) • 13.8 kB
(function installHoverIntentModule(root, factory) { const api = factory(); if (typeof module === "object" && module && module.exports) module.exports = api; root.RelayHoverIntent = api; })(typeof globalThis !== "undefined" ? globalThis : window, function hoverIntentFactory() { "use strict"; const DEFAULTS = Object.freeze({ scrollQuietMs: 220, postScrollMovePx: 4, seamHeightPx: 38, seamDwellMs: 180, bodyDwellMs: 420, maxDwellDriftPx: 7, fastPointerPxMs: 0.45, fastSettleMs: 80, stablePointerPxMs: 0.12, stableForMs: 90, exitGraceMs: 240, }); function distance(a, b) { if (!a || !b) return Infinity; return Math.hypot(Number(a.x) - Number(b.x), Number(a.y) - Number(b.y)); } function createHoverIntentMachine(options = {}) { const config = { ...DEFAULTS, ...(options.config || {}) }; const clock = options.clock || { now: () => performance.now(), setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: (timer) => clearTimeout(timer), }; const onOpen = typeof options.onOpen === "function" ? options.onOpen : () => {}; const onClose = typeof options.onClose === "function" ? options.onClose : () => {}; let pointer = null; let lastMoveAt = null; let lastSpeed = 0; let suppressedUntil = -Infinity; let needsPostScrollMove = false; let postScrollOrigin = null; let candidate = null; let armTimer = null; let quietTimer = null; let closeTimer = null; let openId = null; function clearTimer(name) { const timer = name === "arm" ? armTimer : name === "quiet" ? quietTimer : closeTimer; if (timer != null) clock.clearTimeout(timer); if (name === "arm") armTimer = null; else if (name === "quiet") quietTimer = null; else closeTimer = null; } function cancelCandidate() { clearTimer("arm"); candidate = null; } function closeOpen(reason) { clearTimer("close"); if (!openId) return; const id = openId; openId = null; onClose(id, reason); } function scheduleArm(input, speed) { cancelCandidate(); const delay = input.zone === "seam" ? config.seamDwellMs : config.bodyDwellMs; const settle = speed > config.fastPointerPxMs ? config.fastSettleMs : 0; candidate = { id: String(input.id), zone: input.zone === "seam" ? "seam" : "body", anchor: { x: input.x, y: input.y }, lastPoint: { x: input.x, y: input.y }, }; const expected = candidate; armTimer = clock.setTimeout(() => { armTimer = null; if (candidate !== expected || clock.now() < suppressedUntil) return; const stableFor = Math.max(0, clock.now() - (lastMoveAt == null ? clock.now() : lastMoveAt)); if (distance(expected.anchor, expected.lastPoint) > config.maxDwellDriftPx) return; if (lastSpeed > config.stablePointerPxMs && stableFor < config.stableForMs) return; candidate = null; clearTimer("close"); if (openId === expected.id) return; if (openId) closeOpen("replaced"); openId = expected.id; onOpen(openId, expected.zone); }, delay + settle); } function pointerMove(input = {}) { const now = clock.now(); const next = { x: Number(input.x) || 0, y: Number(input.y) || 0 }; const elapsed = lastMoveAt == null ? Infinity : Math.max(1, now - lastMoveAt); const moved = pointer ? distance(pointer, next) : 0; const speed = Number.isFinite(elapsed) ? moved / elapsed : 0; pointer = next; lastMoveAt = now; lastSpeed = speed; if (now < suppressedUntil) { postScrollOrigin = next; cancelCandidate(); return false; } if (needsPostScrollMove) { if (!postScrollOrigin) { postScrollOrigin = next; return false; } if (distance(postScrollOrigin, next) < config.postScrollMovePx) return false; needsPostScrollMove = false; } const id = input.id == null ? "" : String(input.id); if (id && openId === id) { clearTimer("close"); cancelCandidate(); return true; } if (!id || input.blocked) { cancelCandidate(); if (openId) scheduleClose("pointer-exit"); return false; } clearTimer("close"); const zone = input.zone === "seam" ? "seam" : "body"; if (!candidate || candidate.id !== id || candidate.zone !== zone) { scheduleArm({ ...input, id, zone }, speed); return false; } candidate.lastPoint = next; if (distance(candidate.anchor, next) > config.maxDwellDriftPx || speed > config.fastPointerPxMs) { scheduleArm({ ...input, id, zone }, speed); } return false; } function scheduleClose(reason = "pointer-exit") { cancelCandidate(); if (!openId || closeTimer != null) return; closeTimer = clock.setTimeout(() => { closeTimer = null; closeOpen(reason); }, config.exitGraceMs); } function pointerLeave() { scheduleClose("pointer-exit"); } function scroll(input = {}) { const now = clock.now(); if (Number.isFinite(input.x) && Number.isFinite(input.y)) pointer = { x: input.x, y: input.y }; postScrollOrigin = pointer ? { ...pointer } : null; needsPostScrollMove = true; suppressedUntil = now + config.scrollQuietMs; cancelCandidate(); clearTimer("quiet"); closeOpen("scroll"); quietTimer = clock.setTimeout(() => { quietTimer = null; }, config.scrollQuietMs); } function reset(reason = "reset") { cancelCandidate(); clearTimer("quiet"); closeOpen(reason); needsPostScrollMove = false; suppressedUntil = -Infinity; } return { pointerMove, pointerLeave, scroll, reset, getState: () => ({ openId, candidateId: candidate && candidate.id, candidateZone: candidate && candidate.zone, suppressed: clock.now() < suppressedUntil, needsPostScrollMove, }), config, }; } function installRelayHoverIntent(options = {}) { const doc = options.document || document; const win = options.window || doc.defaultView || window; const rowSelector = ".th-msg.has-host-actions"; const persistentSelector = ".th-host-actions.persistent"; const coarseQuery = win.matchMedia("(hover: none), (pointer: coarse)"); let coarse = coarseQuery.matches; let focusId = null; let touchId = null; let suppressFocusId = null; function rowForId(id) { return [...doc.querySelectorAll(rowSelector)].find((row) => String(row.dataset.msg || "") === String(id || "")) || null; } function isPersistent(row) { return Boolean(row && row.querySelector(persistentSelector)); } function syncRow(row) { const actions = row && row.querySelector(".th-host-actions"); if (!actions) return; const open = isPersistent(row) || row.classList.contains("host-intent-open") || row.classList.contains("host-focus-open") || row.classList.contains("host-touch-open"); row.setAttribute("aria-expanded", open ? "true" : "false"); actions.setAttribute("aria-hidden", open ? "false" : "true"); actions.inert = !open; const disclosure = row.querySelector("[data-host-disclosure]"); if (disclosure) disclosure.setAttribute("aria-expanded", open ? "true" : "false"); } function closeRowsExcept(id) { for (const row of doc.querySelectorAll(`${rowSelector}.host-intent-open, ${rowSelector}.host-focus-open, ${rowSelector}.host-touch-open`)) { if (String(row.dataset.msg || "") === String(id || "")) continue; row.classList.remove("host-intent-open", "host-focus-open", "host-touch-open"); syncRow(row); } if (focusId && focusId !== id) focusId = null; if (touchId && touchId !== id) touchId = null; } const machine = createHoverIntentMachine({ config: options.config, clock: options.clock, onOpen(id) { const row = rowForId(id); if (!row || isPersistent(row)) return; closeRowsExcept(id); row.classList.add("host-intent-open"); syncRow(row); }, onClose(id) { const row = rowForId(id); if (!row) return; row.classList.remove("host-intent-open"); syncRow(row); }, }); function blockedTarget(target) { return Boolean(target && target.closest && target.closest( "button, a, input, textarea, select, [data-stop=\"1\"], [data-rx-colo]", )); } function pointerMove(event) { if (coarse || event.isPrimary === false || (event.pointerType && event.pointerType !== "mouse" && event.pointerType !== "pen")) return; if (focusId || touchId) return; const target = doc.elementFromPoint(event.clientX, event.clientY); const row = target && target.closest ? target.closest(rowSelector) : null; if (!row || isPersistent(row)) { machine.pointerMove({ x:event.clientX, y:event.clientY }); return; } const rect = row.getBoundingClientRect(); machine.pointerMove({ id: row.dataset.msg, zone: event.clientY >= rect.bottom - machine.config.seamHeightPx ? "seam" : "body", x: event.clientX, y: event.clientY, blocked: blockedTarget(target), }); } function suppressForScroll() { machine.scroll(); } function pinFocus(row) { const id = String(row.dataset.msg || ""); if (!id || isPersistent(row)) return; if (suppressFocusId === id) { suppressFocusId = null; return; } closeRowsExcept(id); machine.reset("keyboard"); focusId = id; row.classList.add("host-focus-open"); syncRow(row); } function onFocusIn(event) { const row = event.target && event.target.closest ? event.target.closest(rowSelector) : null; if (!row || isPersistent(row)) return; if (coarse && event.target.closest("[data-host-disclosure]")) return; pinFocus(row); } function onFocusOut(event) { const row = event.target && event.target.closest ? event.target.closest(rowSelector) : null; if (!row || !row.classList.contains("host-focus-open")) return; win.setTimeout(() => { if (row.contains(doc.activeElement)) return; row.classList.remove("host-focus-open"); if (focusId === row.dataset.msg) focusId = null; syncRow(row); }, 0); } function onKeyDown(event) { if (event.key !== "Escape") return; const row = event.target && event.target.closest ? event.target.closest(rowSelector) : null; if (!row || isPersistent(row)) return; if (!row.classList.contains("host-focus-open") && !row.classList.contains("host-touch-open")) return; event.preventDefault(); event.stopPropagation(); const id = String(row.dataset.msg || ""); suppressFocusId = id; row.classList.remove("host-focus-open", "host-touch-open"); focusId = null; touchId = null; syncRow(row); row.focus({ preventScroll:true }); } function onDisclosureClick(event) { const disclosure = event.target && event.target.closest ? event.target.closest("[data-host-disclosure]") : null; if (!disclosure) return; const row = disclosure.closest(rowSelector); if (!row || isPersistent(row)) return; event.preventDefault(); event.stopPropagation(); const id = String(row.dataset.msg || ""); const opening = !row.classList.contains("host-touch-open"); closeRowsExcept(opening ? id : null); machine.reset("touch-disclosure"); row.classList.toggle("host-touch-open", opening); touchId = opening ? id : null; syncRow(row); } function refresh() { const ids = new Set(); for (const row of doc.querySelectorAll(rowSelector)) { const id = String(row.dataset.msg || ""); ids.add(id); if (isPersistent(row)) row.classList.remove("host-intent-open", "host-focus-open", "host-touch-open"); else { row.classList.toggle("host-focus-open", focusId === id); row.classList.toggle("host-touch-open", touchId === id); row.classList.toggle("host-intent-open", machine.getState().openId === id); } syncRow(row); } if (machine.getState().openId && !ids.has(machine.getState().openId)) machine.reset("row-removed"); if (focusId && !ids.has(focusId)) focusId = null; if (touchId && !ids.has(touchId)) touchId = null; } function onCoarseChange(event) { coarse = event.matches; machine.reset("pointer-capability-change"); refresh(); } doc.addEventListener("pointermove", pointerMove, true); doc.addEventListener("pointerleave", () => machine.pointerLeave(), true); doc.addEventListener("wheel", suppressForScroll, { capture:true, passive:true }); doc.addEventListener("scroll", suppressForScroll, true); doc.addEventListener("focusin", onFocusIn, true); doc.addEventListener("focusout", onFocusOut, true); doc.addEventListener("keydown", onKeyDown, true); doc.addEventListener("click", onDisclosureClick, true); if (coarseQuery.addEventListener) coarseQuery.addEventListener("change", onCoarseChange); else if (coarseQuery.addListener) coarseQuery.addListener(onCoarseChange); refresh(); return { machine, refresh, isCoarse:() => coarse }; } return { DEFAULTS, createHoverIntentMachine, installRelayHoverIntent }; });