picker.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. import type { PickerOptions, PickerSession } from "./types";
  2. /**
  3. * Attribute the ElementRenderer (when devtools is active) sets on each
  4. * rendered element's wrapper. The picker walks up from the hovered node
  5. * to the nearest ancestor bearing this attribute.
  6. */
  7. export const DEVTOOLS_KEY_ATTR = "data-jr-key";
  8. const PICKER_OVERLAY_ID = "__jr_devtools_picker_overlay";
  9. const PICKER_LABEL_ID = "__jr_devtools_picker_label";
  10. const HOVER_OVERLAY_ID = "__jr_devtools_hover_overlay";
  11. const HOVER_LABEL_ID = "__jr_devtools_hover_label";
  12. // ---------------------------------------------------------------------------
  13. // Shared geometry helper
  14. // ---------------------------------------------------------------------------
  15. /**
  16. * Compute a bounding rect for a (possibly `display: contents`) element by
  17. * falling back to the union of its descendants' client rects.
  18. *
  19. * json-render wraps every keyed element in `<span data-jr-key="..." style
  20. * ="display:contents">`, and `display: contents` elements don't generate a
  21. * CSS box — so their own `getBoundingClientRect()` returns `0 × 0` and a
  22. * plain CSS outline paints nothing. We recursively widen to child boxes so
  23. * the overlay tracks the visible extent.
  24. */
  25. function computeBounds(el: Element): DOMRect | null {
  26. const own = el.getBoundingClientRect();
  27. if (own.width > 0 && own.height > 0) return own;
  28. let left = Infinity;
  29. let top = Infinity;
  30. let right = -Infinity;
  31. let bottom = -Infinity;
  32. const walker = document.createTreeWalker(el, NodeFilter.SHOW_ELEMENT);
  33. let node = walker.nextNode() as Element | null;
  34. while (node) {
  35. const r = node.getBoundingClientRect();
  36. if (r.width > 0 && r.height > 0) {
  37. if (r.left < left) left = r.left;
  38. if (r.top < top) top = r.top;
  39. if (r.right > right) right = r.right;
  40. if (r.bottom > bottom) bottom = r.bottom;
  41. }
  42. node = walker.nextNode() as Element | null;
  43. }
  44. if (!Number.isFinite(left) || !Number.isFinite(top)) return null;
  45. return new DOMRect(left, top, right - left, bottom - top);
  46. }
  47. /**
  48. * Position an overlay + label pair over a given rect. Both elements are
  49. * created on demand inside `document.body`; callers pass in the ids so the
  50. * picker and the tree-hover highlight don't collide.
  51. */
  52. function paintOverlay(
  53. rect: DOMRect,
  54. label: string,
  55. ids: { overlay: string; label: string },
  56. ): { overlay: HTMLDivElement; label: HTMLDivElement } {
  57. let ov = document.getElementById(ids.overlay) as HTMLDivElement | null;
  58. if (!ov) {
  59. ov = document.createElement("div");
  60. ov.id = ids.overlay;
  61. document.body.appendChild(ov);
  62. }
  63. ov.style.display = "block";
  64. ov.style.left = `${rect.left}px`;
  65. ov.style.top = `${rect.top}px`;
  66. ov.style.width = `${rect.width}px`;
  67. ov.style.height = `${rect.height}px`;
  68. let lb = document.getElementById(ids.label) as HTMLDivElement | null;
  69. if (!lb) {
  70. lb = document.createElement("div");
  71. lb.id = ids.label;
  72. document.body.appendChild(lb);
  73. }
  74. lb.textContent = label;
  75. lb.style.display = "block";
  76. const labelLeft = Math.max(4, rect.left);
  77. // Prefer the label above the overlay, fall back to below when the element
  78. // is flush against the viewport top.
  79. const labelTop = rect.top >= 24 ? rect.top - 22 : rect.top + rect.height + 4;
  80. lb.style.left = `${labelLeft}px`;
  81. lb.style.top = `${labelTop}px`;
  82. return { overlay: ov, label: lb };
  83. }
  84. function hideOverlayPair(ids: { overlay: string; label: string }): void {
  85. const ov = document.getElementById(ids.overlay);
  86. const lb = document.getElementById(ids.label);
  87. if (ov) ov.style.display = "none";
  88. if (lb) lb.style.display = "none";
  89. }
  90. function removeOverlayPair(ids: { overlay: string; label: string }): void {
  91. const ov = document.getElementById(ids.overlay);
  92. const lb = document.getElementById(ids.label);
  93. if (ov) ov.remove();
  94. if (lb) lb.remove();
  95. }
  96. // ---------------------------------------------------------------------------
  97. // Picker — click-to-pick session started by the toolbar button
  98. // ---------------------------------------------------------------------------
  99. /**
  100. * Start an interactive DOM picker. The user hovers or clicks any element
  101. * in the page; when a click lands on (or inside) a node carrying
  102. * {@link DEVTOOLS_KEY_ATTR}, `onPick` fires with that key.
  103. *
  104. * Returns `null` in environments without a DOM (SSR, tests).
  105. *
  106. * The picker paints a translucent overlay + border over the hovered
  107. * element (Chrome-DevTools style). See {@link computeBounds} for the
  108. * `display: contents` fallback.
  109. */
  110. export function startPicker(options: PickerOptions): PickerSession | null {
  111. if (typeof document === "undefined") return null;
  112. const IDS = { overlay: PICKER_OVERLAY_ID, label: PICKER_LABEL_ID };
  113. let currentHover: Element | null = null;
  114. function paint(el: Element) {
  115. const rect = computeBounds(el);
  116. if (!rect) {
  117. hideOverlayPair(IDS);
  118. return;
  119. }
  120. const key = el.getAttribute(DEVTOOLS_KEY_ATTR) ?? "";
  121. paintOverlay(
  122. rect,
  123. `${key} ${Math.round(rect.width)} × ${Math.round(rect.height)}`,
  124. IDS,
  125. );
  126. }
  127. const onMove = (ev: MouseEvent) => {
  128. const target = ev.target as Element | null;
  129. if (!target || !target.closest) return;
  130. const host = target.closest(`[${DEVTOOLS_KEY_ATTR}]`);
  131. if (host !== currentHover) currentHover = host;
  132. if (host) paint(host);
  133. else hideOverlayPair(IDS);
  134. };
  135. const onClick = (ev: MouseEvent) => {
  136. ev.preventDefault();
  137. ev.stopPropagation();
  138. const target = ev.target as Element | null;
  139. const host = target?.closest?.(`[${DEVTOOLS_KEY_ATTR}]`);
  140. if (host) {
  141. const key = host.getAttribute(DEVTOOLS_KEY_ATTR) ?? "";
  142. stop();
  143. options.onPick(key);
  144. }
  145. };
  146. const onKey = (ev: KeyboardEvent) => {
  147. if (ev.key === "Escape") {
  148. ev.preventDefault();
  149. stop();
  150. options.onCancel?.();
  151. }
  152. };
  153. // Keep the overlay glued to the element while the user scrolls, the
  154. // viewport resizes, or the DOM reflows (e.g. streaming patches arrive
  155. // during pick).
  156. const onReposition = () => {
  157. if (currentHover && currentHover.isConnected) {
  158. paint(currentHover);
  159. } else if (currentHover) {
  160. currentHover = null;
  161. hideOverlayPair(IDS);
  162. }
  163. };
  164. function stop() {
  165. removeOverlayPair(IDS);
  166. currentHover = null;
  167. document.documentElement.removeAttribute("data-jr-devtools-picking");
  168. document.removeEventListener("mousemove", onMove, true);
  169. document.removeEventListener("click", onClick, true);
  170. document.removeEventListener("keydown", onKey, true);
  171. window.removeEventListener("scroll", onReposition, true);
  172. window.removeEventListener("resize", onReposition);
  173. }
  174. document.documentElement.setAttribute("data-jr-devtools-picking", "true");
  175. document.addEventListener("mousemove", onMove, true);
  176. document.addEventListener("click", onClick, true);
  177. document.addEventListener("keydown", onKey, true);
  178. window.addEventListener("scroll", onReposition, true);
  179. window.addEventListener("resize", onReposition);
  180. return { stop };
  181. }
  182. // ---------------------------------------------------------------------------
  183. // Hover highlight — persistent overlay driven by spec-tree hover
  184. // ---------------------------------------------------------------------------
  185. const HOVER_IDS = { overlay: HOVER_OVERLAY_ID, label: HOVER_LABEL_ID };
  186. /**
  187. * Class (not id) used for the *secondary* overlays that paint the extra
  188. * instances of a repeated element. Styled by HOST_CSS with a dashed amber
  189. * outline so they're clearly distinguished from the single primary hover.
  190. */
  191. const HOVER_EXTRA_CLASS = "__jr_devtools_hover_extra";
  192. let hoverKey: string | null = null;
  193. let hoverListenersAttached = false;
  194. let hoverExtraOverlays: HTMLDivElement[] = [];
  195. function ensureExtraOverlay(index: number): HTMLDivElement {
  196. let ov = hoverExtraOverlays[index];
  197. if (!ov) {
  198. ov = document.createElement("div");
  199. ov.className = HOVER_EXTRA_CLASS;
  200. document.body.appendChild(ov);
  201. hoverExtraOverlays[index] = ov;
  202. }
  203. return ov;
  204. }
  205. function hideExtrasFrom(index: number): void {
  206. for (let i = index; i < hoverExtraOverlays.length; i++) {
  207. const ov = hoverExtraOverlays[i];
  208. if (ov) ov.style.display = "none";
  209. }
  210. }
  211. function removeAllExtras(): void {
  212. for (const ov of hoverExtraOverlays) ov.remove();
  213. hoverExtraOverlays = [];
  214. }
  215. function repositionHover() {
  216. if (!hoverKey) return;
  217. const matches = findAllElementsByKey(hoverKey);
  218. const first = matches[0];
  219. if (!first) {
  220. hideOverlayPair(HOVER_IDS);
  221. hideExtrasFrom(0);
  222. return;
  223. }
  224. // Primary overlay + label paints the first match.
  225. const firstRect = computeBounds(first);
  226. if (!firstRect) {
  227. hideOverlayPair(HOVER_IDS);
  228. hideExtrasFrom(0);
  229. return;
  230. }
  231. const labelSuffix =
  232. matches.length > 1 ? ` (${matches.length} instances)` : "";
  233. paintOverlay(
  234. firstRect,
  235. `${hoverKey} ${Math.round(firstRect.width)} × ${Math.round(firstRect.height)}${labelSuffix}`,
  236. HOVER_IDS,
  237. );
  238. // Extra instances (for repeated elements) get their own overlays,
  239. // styled in amber to distinguish from the primary.
  240. for (let i = 1; i < matches.length; i++) {
  241. const el = matches[i];
  242. if (!el) continue;
  243. const rect = computeBounds(el);
  244. const ov = ensureExtraOverlay(i - 1);
  245. if (!rect) {
  246. ov.style.display = "none";
  247. continue;
  248. }
  249. ov.style.display = "block";
  250. ov.style.left = `${rect.left}px`;
  251. ov.style.top = `${rect.top}px`;
  252. ov.style.width = `${rect.width}px`;
  253. ov.style.height = `${rect.height}px`;
  254. }
  255. hideExtrasFrom(Math.max(0, matches.length - 1));
  256. }
  257. function attachHoverListeners() {
  258. if (hoverListenersAttached || typeof window === "undefined") return;
  259. hoverListenersAttached = true;
  260. window.addEventListener("scroll", repositionHover, true);
  261. window.addEventListener("resize", repositionHover);
  262. }
  263. function detachHoverListeners() {
  264. if (!hoverListenersAttached || typeof window === "undefined") return;
  265. hoverListenersAttached = false;
  266. window.removeEventListener("scroll", repositionHover, true);
  267. window.removeEventListener("resize", repositionHover);
  268. }
  269. /**
  270. * Show (or hide) a persistent bounding-box overlay over the element
  271. * identified by `key`. Pass `null` to clear the overlay.
  272. *
  273. * Unlike {@link highlightElement}, this does not fade out — callers are
  274. * expected to clear it explicitly (typically on `mouseleave`). Safe to
  275. * call repeatedly; each call cheaply repositions the single overlay.
  276. */
  277. export function setHoverHighlight(key: string | null): void {
  278. if (typeof document === "undefined") return;
  279. if (!key) {
  280. hoverKey = null;
  281. removeOverlayPair(HOVER_IDS);
  282. removeAllExtras();
  283. detachHoverListeners();
  284. return;
  285. }
  286. hoverKey = key;
  287. repositionHover();
  288. attachHoverListeners();
  289. }
  290. // ---------------------------------------------------------------------------
  291. // Element lookup + brief flash highlight
  292. // ---------------------------------------------------------------------------
  293. /**
  294. * Look up the DOM element carrying `data-jr-key={key}`. Returns `null`
  295. * when no match (spec exists but hasn't rendered, or the element is
  296. * currently hidden by a visibility condition).
  297. *
  298. * Note: when an element appears inside a `repeat`, each repetition
  299. * renders its own DOM instance with the same `data-jr-key`. Use
  300. * {@link findAllElementsByKey} if you need all of them.
  301. */
  302. export function findElementByKey(key: string): Element | null {
  303. if (typeof document === "undefined") return null;
  304. return document.querySelector(`[${DEVTOOLS_KEY_ATTR}="${cssEscape(key)}"]`);
  305. }
  306. /**
  307. * Return every DOM element carrying `data-jr-key={key}`. Intended for
  308. * repeated spec elements (list items, grid cells…) where a single spec
  309. * key renders to N DOM instances.
  310. */
  311. export function findAllElementsByKey(key: string): Element[] {
  312. if (typeof document === "undefined") return [];
  313. const selector = `[${DEVTOOLS_KEY_ATTR}="${cssEscape(key)}"]`;
  314. return Array.from(document.querySelectorAll(selector));
  315. }
  316. /**
  317. * Briefly paint an outline around the DOM element for `key`, so clicking
  318. * a spec-tree row in the panel flashes its position in the page.
  319. *
  320. * The target is often a `display: contents` wrapper, so we build the
  321. * overlay from `computeBounds` (descendants' client rects) instead of
  322. * using a CSS outline on the target directly.
  323. */
  324. export function highlightElement(key: string, durationMs = 1200): void {
  325. if (typeof document === "undefined") return;
  326. const el = findElementByKey(key);
  327. if (!el) return;
  328. const rect = computeBounds(el);
  329. if (!rect) return;
  330. const overlay = document.createElement("div");
  331. overlay.className = "__jr_devtools_highlight";
  332. overlay.style.cssText = `
  333. position: fixed;
  334. left: ${rect.left}px;
  335. top: ${rect.top}px;
  336. width: ${rect.width}px;
  337. height: ${rect.height}px;
  338. outline: 2px solid #60a5fa;
  339. outline-offset: 2px;
  340. pointer-events: none;
  341. z-index: 2147483645;
  342. transition: opacity 0.25s ease-out;
  343. opacity: 1;
  344. `;
  345. document.body.appendChild(overlay);
  346. setTimeout(
  347. () => {
  348. overlay.style.opacity = "0";
  349. },
  350. Math.max(0, durationMs - 250),
  351. );
  352. setTimeout(() => {
  353. overlay.remove();
  354. }, durationMs);
  355. }
  356. /** Minimal CSS.escape fallback for environments without it. */
  357. function cssEscape(value: string): string {
  358. if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
  359. return CSS.escape(value);
  360. }
  361. return value.replace(/["\\]/g, "\\$&");
  362. }