cursor.ts (10371B)
1 /** 2 * The dot cursor and its pill, framework-free. Ported from daemon-sec's 3 * client/src/components/daemon/cursor.tsx so the sibling sites behave 4 * the same way. 5 * 6 * A small disc trails the pointer and opens into a ring over anything 7 * clickable. Over something with one clear action it becomes a pill 8 * that says what a click will do: 9 * - an element with `data-cursor-label` (optional `data-cursor-icon`: 10 * view | play | pdf | download | read) 11 * - a video marked `data-pill`: Play / Pause (a click on the video body 12 * toggles it; the native control strip keeps its own clicks) 13 * - an image marked `data-zoomable`: View image / View GIF 14 * - a link to a PDF: Open PDF, or Download PDF if it downloads 15 * - a link the site counts as an article (`isArticle`): Read 16 * 17 * Fine pointers only; it never mounts on touch or under reduced motion, 18 * so those visitors keep the native cursor. The trailing loop stops as 19 * soon as the dot catches up, so an idle page costs nothing. 20 * 21 * Usage: 22 * mountCursor({ isArticle: (href) => href.startsWith("/sheets/") }); 23 */ 24 25 export interface CursorOptions { 26 /** Which internal links read as articles and get the "Read" pill. */ 27 isArticle?: (href: string) => boolean; 28 /** Finer control: the pill text for a link, or null for none. Takes 29 * precedence over `isArticle`. */ 30 linkLabel?: (href: string, link: HTMLAnchorElement) => string | null; 31 /** Ancestors that are dark grounds, so the cursor re-tints over them. */ 32 darkSelector?: string; 33 /** Finer control than `darkSelector`, for sites that nest light panels 34 * inside dark bands. */ 35 isDark?: (target: HTMLElement) => boolean; 36 /** Override the reduced-motion gate. */ 37 enabled?: boolean; 38 } 39 40 type Mode = "view" | "play" | "read" | "pdf" | "label" | null; 41 type IconName = "view" | "play" | "pause" | "pdf" | "download" | "read"; 42 43 /* lucide path data (ISC/MIT), so the pill needs no icon font. */ 44 const ICONS: Record<IconName, string> = { 45 view: '<path d="M15 3h6v6"/><path d="m21 3-7 7"/><path d="m3 21 7-7"/><path d="M9 21H3v-6"/>', 46 play: '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>', 47 pause: '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>', 48 pdf: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>', 49 download: '<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>', 50 read: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>', 51 }; 52 53 const EASE = 0.28; 54 const PILL_EASE = 0.16; 55 const SETTLED = 0.1; 56 const CONTROLS_STRIP = 48; 57 const PDF = /\.pdf($|[?#])/i; 58 const INTERACTIVE = 59 "a, button, input, textarea, select, summary, label, [role='button'], [role='switch'], [role='tab']"; 60 61 /* Only ever fixed markup from ICONS: an unknown name falls back to the 62 arrow rather than reaching the DOM. */ 63 function svg(name: IconName) { 64 const body = ICONS[name] ?? ICONS.read; 65 return `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`; 66 } 67 68 export function mountCursor(options: CursorOptions = {}): () => void { 69 /* A site-level motion switch (`<html data-motion="reduce">`) counts the 70 same as the OS setting. */ 71 const enabled = 72 options.enabled ?? 73 (window.matchMedia("(pointer: fine)").matches && 74 !window.matchMedia("(prefers-reduced-motion: reduce)").matches && 75 document.documentElement.dataset.motion !== "reduce"); 76 if (!enabled) return () => {}; 77 const isArticle = options.isArticle ?? (() => false); 78 const linkLabel = 79 options.linkLabel ?? ((href: string) => (isArticle(href) ? "Read" : null)); 80 const darkSelector = options.darkSelector ?? "[data-ground='dark']"; 81 const isDark = options.isDark ?? ((t: HTMLElement) => Boolean(t.closest(darkSelector))); 82 83 const el = document.createElement("div"); 84 el.className = "dmn-cursor"; 85 el.setAttribute("aria-hidden", "true"); 86 el.innerHTML = '<span class="dmn-cursor-pill"><span class="dmn-cursor-icon"></span><span class="dmn-cursor-label"></span></span>'; 87 const iconEl = el.querySelector<HTMLElement>(".dmn-cursor-icon")!; 88 const labelEl = el.querySelector<HTMLElement>(".dmn-cursor-label")!; 89 document.body.appendChild(el); 90 const root = document.documentElement; 91 root.classList.add("has-dmn-cursor"); 92 93 let x = window.innerWidth / 2; 94 let y = window.innerHeight / 2; 95 let tx = x; 96 let ty = y; 97 let raf = 0; 98 let seen = false; 99 let mode: Mode = null; 100 let mediaEl: HTMLElement | null = null; 101 102 const place = () => { 103 el.style.transform = `translate(${x}px, ${y}px) translate(-50%, -50%)`; 104 }; 105 const step = () => { 106 const k = mode ? PILL_EASE : EASE; 107 x += (tx - x) * k; 108 y += (ty - y) * k; 109 if (Math.abs(tx - x) < SETTLED && Math.abs(ty - y) < SETTLED) { 110 x = tx; 111 y = ty; 112 place(); 113 raf = 0; 114 return; 115 } 116 place(); 117 raf = requestAnimationFrame(step); 118 }; 119 const wake = () => { 120 if (!raf) raf = requestAnimationFrame(step); 121 }; 122 123 const under = (target: HTMLElement): { mode: Mode; el: HTMLElement } | null => { 124 const labelled = target.closest<HTMLElement>("[data-cursor-label]"); 125 if (labelled) return { mode: "label", el: labelled }; 126 const video = target.closest<HTMLVideoElement>("video[data-pill]"); 127 if (video) return { mode: "play", el: video }; 128 const img = 129 target.closest<HTMLImageElement>("img[data-zoomable]") ?? 130 target.closest("button")?.querySelector<HTMLImageElement>("img[data-zoomable]") ?? 131 null; 132 if (img) return { mode: "view", el: img }; 133 const link = target.closest<HTMLAnchorElement>("a[href]"); 134 if (link) { 135 const href = link.getAttribute("href") ?? ""; 136 if (PDF.test(href)) return { mode: "pdf", el: link }; 137 if (linkLabel(href, link)) return { mode: "read", el: link }; 138 } 139 return null; 140 }; 141 142 const labelFor = (m: Mode, t: HTMLElement): { icon: IconName; text: string } => { 143 if (m === "label") { 144 return { icon: (t.dataset.cursorIcon as IconName) || "read", text: t.dataset.cursorLabel ?? "" }; 145 } 146 if (m === "play") { 147 const paused = (t as HTMLVideoElement).paused; 148 return { icon: paused ? "play" : "pause", text: paused ? "Play" : "Pause" }; 149 } 150 if (m === "pdf") { 151 return t.hasAttribute("download") 152 ? { icon: "download", text: "Download PDF" } 153 : { icon: "pdf", text: "Open PDF" }; 154 } 155 if (m === "read") { 156 const a = t as HTMLAnchorElement; 157 return { icon: "read", text: linkLabel(a.getAttribute("href") ?? "", a) ?? "Read" }; 158 } 159 const src = (t as HTMLImageElement).currentSrc || (t as HTMLImageElement).src || ""; 160 return { icon: "view", text: /\.gif($|\?)/i.test(src) ? "View GIF" : "View image" }; 161 }; 162 163 const syncLabel = () => { 164 if (!mode || !mediaEl) return; 165 const { icon, text } = labelFor(mode, mediaEl); 166 iconEl.innerHTML = svg(icon); 167 labelEl.textContent = text; 168 }; 169 170 const setMode = (next: Mode, target: HTMLElement | null) => { 171 if (mediaEl && mediaEl !== target) { 172 mediaEl.removeEventListener("play", syncLabel); 173 mediaEl.removeEventListener("pause", syncLabel); 174 } 175 mode = next; 176 mediaEl = target; 177 if (mode && mediaEl) { 178 el.dataset.mode = mode; 179 if (mode === "play") { 180 mediaEl.addEventListener("play", syncLabel); 181 mediaEl.addEventListener("pause", syncLabel); 182 } 183 syncLabel(); 184 } else { 185 delete el.dataset.mode; 186 } 187 }; 188 189 /* A viewer or modal on screen hides the pill. Sites often keep their 190 search dialog in the DOM while closed, so only a visible one counts. */ 191 const dialogOpen = () => 192 Array.from(document.querySelectorAll<HTMLElement>("dialog[open], [role='dialog'][aria-modal='true']")).some( 193 (d) => (d.checkVisibility ? d.checkVisibility() : d.offsetParent !== null), 194 ); 195 196 const applyTarget = (target: HTMLElement | null) => { 197 if (!target?.closest) return; 198 el.classList.toggle("hot", Boolean(target.closest(INTERACTIVE))); 199 el.classList.toggle("on-dark", isDark(target)); 200 const media = dialogOpen() ? null : under(target); 201 setMode(media?.mode ?? null, media?.el ?? null); 202 }; 203 204 const onMove = (e: MouseEvent) => { 205 tx = e.clientX; 206 ty = e.clientY; 207 seen = true; 208 wake(); 209 }; 210 const onOver = (e: MouseEvent) => applyTarget(e.target as HTMLElement | null); 211 const onLeave = () => (el.style.opacity = "0"); 212 const onEnter = () => (el.style.opacity = "1"); 213 214 /* Content can move under a pointer that has not; look again while the 215 page scrolls, once per frame. */ 216 let scrollRaf = 0; 217 const onScroll = () => { 218 if (!seen || scrollRaf) return; 219 scrollRaf = requestAnimationFrame(() => { 220 scrollRaf = 0; 221 applyTarget(document.elementFromPoint(tx, ty) as HTMLElement | null); 222 }); 223 }; 224 225 const onClick = (e: MouseEvent) => { 226 const video = (e.target as HTMLElement | null)?.closest?.<HTMLVideoElement>("video[data-pill]"); 227 if (!video) return; 228 const r = video.getBoundingClientRect(); 229 if (e.clientY > r.bottom - CONTROLS_STRIP) return; 230 e.preventDefault(); 231 if (video.paused) void video.play().catch(() => {}); 232 else video.pause(); 233 }; 234 235 window.addEventListener("mousemove", onMove, { passive: true }); 236 window.addEventListener("mouseover", onOver, { passive: true }); 237 document.documentElement.addEventListener("mouseleave", onLeave); 238 document.documentElement.addEventListener("mouseenter", onEnter); 239 window.addEventListener("scroll", onScroll, { passive: true }); 240 window.addEventListener("click", onClick); 241 place(); 242 243 return () => { 244 if (raf) cancelAnimationFrame(raf); 245 if (scrollRaf) cancelAnimationFrame(scrollRaf); 246 setMode(null, null); 247 window.removeEventListener("mousemove", onMove); 248 window.removeEventListener("mouseover", onOver); 249 document.documentElement.removeEventListener("mouseleave", onLeave); 250 document.documentElement.removeEventListener("mouseenter", onEnter); 251 window.removeEventListener("scroll", onScroll); 252 window.removeEventListener("click", onClick); 253 root.classList.remove("has-dmn-cursor"); 254 el.remove(); 255 }; 256 }