//=========================================== // THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template //=========================================== // Standalone clickmap overlay. This module is fully independent from the dev // tool (packages/template/src/dev-tool): it has its own DOM root, its own // stylesheet, and its own mount lifecycle, so the dev tool can be changed or // removed without affecting clickmaps. It's opened via a dashboard-minted // token (the CLICKMAP_OVERLAY_TOKEN_UPDATED event / resume flow) — see // ./index.ts for the lazy-loading entry point. import { AnalyticsClickmapResponseBodySchema, type AnalyticsClickmapResponse } from "@hexclave/shared/dist/interface/admin-metrics"; import { CLICKMAP_OVERLAY_RESUME_STORAGE_KEY, CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY, CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, } from "@hexclave/shared/dist/utils/analytics-clickmap-overlay"; import { CLICKMAP_ROOT_ID, DEV_TOOL_ROOT_ID } from "@hexclave/shared/dist/utils/dev-tool"; import { cssEscapeIdent } from "@hexclave/shared/dist/utils/dom"; import { buildElementsChain, parseElementsChain, type ElementsChainSegment } from "@hexclave/shared/dist/utils/elements-chain"; import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; import { stringCompare } from "@hexclave/shared/dist/utils/strings"; import { getGlobalUiInstance, h, hasAppendChild, setGlobalUiInstance, setHtml, type UiGlobalInstance } from "../in-page-ui/dom"; import type { StackClientApp } from "../lib/hexclave-app"; import { hexclaveAppInternalsSymbol } from "../lib/hexclave-app/common"; import { clickmapCSS } from "./clickmap-styles"; type ClickmapPanelResult = { element: HTMLElement, cleanup?: () => void }; // --------------------------------------------------------------------------- // Clickmap panel // --------------------------------------------------------------------------- type ClickmapClickGroup = { selector: string; label: string; count: number; // Clicks on this element that produced no observable effect (is_dead rows). deadCount: number; element: Element | null; rect: DOMRect | null; }; type ClickmapGroupOverlayElement = { marker: HTMLElement; outline: HTMLElement; }; type ClickmapListRowElement = { row: HTMLElement; count: HTMLElement; check: HTMLButtonElement; eye: HTMLButtonElement; label: HTMLElement; dead: HTMLElement; selector: HTMLElement; group: ClickmapClickGroup | null; renderedEyeIcon: string; renderedCheckIcon: string; }; const CLICKMAP_FILTERS_STORAGE_KEY = 'hexclave-clickmap-overlay-filters'; type ClickmapRangeKey = '24h' | '7d' | '30d'; type ClickmapDeviceKey = 'all' | 'mobile' | 'tablet' | 'laptop' | 'desktop' | 'widescreen' | 'tv'; type ClickmapFilters = { range: ClickmapRangeKey, device: ClickmapDeviceKey, urlPattern: string, elementSearch: string, // Reveal dead clicks in the overlay. Off by default: every displayed count // is alive clicks only, dead chips are hidden, and elements whose clicks // were all dead are dropped. Pure client-side filter — the server response // always carries both clicks (total) and dead_clicks per element, so // toggling never refetches. showDead: boolean, }; type ClickmapViewportBucket = { min: number, max: number | null, }; const CLICKMAP_DEFAULT_FILTERS: ClickmapFilters = { range: '7d', device: 'all', urlPattern: '', elementSearch: '', showDead: false, }; const CLICKMAP_RANGE_MS: Record = { '24h': 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000, '30d': 30 * 24 * 60 * 60 * 1000, }; const CLICKMAP_VIEWPORT_BUCKETS: Record, ClickmapViewportBucket> = { mobile: { min: 0, max: 767 }, tablet: { min: 768, max: 1023 }, laptop: { min: 1024, max: 1199 }, desktop: { min: 1200, max: 1439 }, widescreen: { min: 1440, max: 1919 }, tv: { min: 1920, max: null }, }; function getClickmapViewportBucket(device: ClickmapDeviceKey): ClickmapViewportBucket | null { if (device === 'all') return null; return CLICKMAP_VIEWPORT_BUCKETS[device]; } function isClickmapViewportWidthInBucket(width: number, bucket: ClickmapViewportBucket): boolean { return width >= bucket.min && (bucket.max == null || width <= bucket.max); } function getClickmapRecommendedViewportWidth(bucket: ClickmapViewportBucket): number { if (bucket.max == null) return bucket.min; return Math.round((bucket.min + bucket.max) / 2); } function formatClickmapViewportBucket(bucket: ClickmapViewportBucket): string { if (bucket.max == null) return `${bucket.min}px+`; return `${bucket.min}-${bucket.max}px`; } function isClickmapRangeKey(value: unknown): value is ClickmapRangeKey { return value === '24h' || value === '7d' || value === '30d'; } function isClickmapDeviceKey(value: unknown): value is ClickmapDeviceKey { return value === 'all' || value === 'mobile' || value === 'tablet' || value === 'laptop' || value === 'desktop' || value === 'widescreen' || value === 'tv'; } const CLICKMAP_DOM_INDEX_DEBOUNCE_MS = 250; type ServerClickmapSelector = { selector: string; clicks: number; }; type ServerClickmapElement = { elementsChain: string; elementsText: string; tagName: string; href: string | null; clicks: number; deadClicks: number; }; type ServerClickmap = { path: string; // True aggregate click total returned for the active filter (summed across // every matching route), independent of how many elements can be drawn on the // current page's DOM. The overlay can only render elements that exist on the // page you're viewing, but this count reflects the full pattern. totalClicks: number; selectors: ServerClickmapSelector[]; elements: ServerClickmapElement[]; }; function cssEscapeAttrValue(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } function readChainAttr(segment: ElementsChainSegment, attr: string): string { if (!Object.prototype.hasOwnProperty.call(segment.attrs, attr)) return ''; const value = segment.attrs[attr]; return typeof value === 'string' ? value : ''; } // Compact, human-readable counts for tight UI surfaces (markers, chips, // stats): 999 → "999", 1234 → "1.2k", 1_250_000 → "1.3m", 2e9 → "2b". One // decimal place (trailing .0 drops out of the arithmetic), and rounding // cascades into the next unit so 999_950+ reads "1m" rather than "1000k". function formatClickmapCount(value: number): string { let scaled = value; let suffix = ''; for (const nextSuffix of ['k', 'm', 'b']) { // 999.95 is the smallest value that would display as "1000" at one // decimal place, so it already belongs to the next unit up. if (scaled < 999.95) break; scaled /= 1000; suffix = nextSuffix; } if (suffix === '') return String(Math.round(scaled)); return `${Math.round(scaled * 10) / 10}${suffix}`; } function getClickmapHue(count: number, maxCount: number): number { if (maxCount <= 1) return 185; const intensity = Math.min(1, count / maxCount); return 185 - Math.round(intensity * 155); } function getReadableElementLabel(element: Element): string { const ariaLabel = element.getAttribute('aria-label'); if (ariaLabel != null && ariaLabel.trim() !== '') { return ariaLabel.trim().slice(0, 80); } const title = element.getAttribute('title'); if (title != null && title.trim() !== '') { return title.trim().slice(0, 80); } const text = element.textContent.trim().replace(/\s+/g, ' '); if (text !== '') { return text.slice(0, 80); } return element.tagName.toLowerCase(); } function isElementVisibleForClickmap(element: Element): boolean { // Never treat our own UI (or the dev tool's, if it happens to be mounted // alongside) as a clickmap candidate. if (element.closest(`#${cssEscapeIdent(CLICKMAP_ROOT_ID)}, #${cssEscapeIdent(DEV_TOOL_ROOT_ID)}`) != null) { return false; } if (element.closest('[hidden], [aria-hidden="true"], [inert]') != null) { return false; } const rect = element.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) { return false; } const style = window.getComputedStyle(element); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { return false; } return true; } function getElementFromSelector(selector: string): Element | null { try { const elements = Array.from(document.querySelectorAll(selector)); return elements.find(isElementVisibleForClickmap) ?? null; } catch { return null; } } function getSessionStorageString(key: string): string | null { try { const value = sessionStorage.getItem(key); return value == null || value.trim() === '' ? null : value; } catch { return null; } } function removeSessionStorageItem(key: string): void { try { sessionStorage.removeItem(key); } catch { // Storage can be blocked in private or embedded contexts; the toolbar keeps // rendering the actionable error state in that case. } } // Read a string claim out of a JWT payload without verifying the signature. The // clickmap token is self-describing — it carries the `project_id` and `origin` // it was minted for — so the overlay derives both from the token itself instead // of needing them handed over alongside. The server still verifies the token on // every request; this is only used to scope/label the token client-side. function getJwtPayloadClaim(token: string, claim: string): string | null { const tokenParts = token.split('.'); if (tokenParts.length < 2 || tokenParts[1] === '') { return null; } try { const payloadPart = tokenParts[1]; const normalized = payloadPart.replace(/-/g, '+').replace(/_/g, '/'); const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); const payload: unknown = JSON.parse(atob(padded)); if (typeof payload !== 'object' || payload === null) { return null; } const value = Reflect.get(payload, claim); return typeof value === 'string' ? value : null; } catch { return null; } } function getClickmapTokenFromStorage(): string | null { return getSessionStorageString(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY); } function getClickmapOriginFromStorage(): string | null { const token = getClickmapTokenFromStorage(); return token == null ? null : getJwtPayloadClaim(token, 'origin'); } function clearClickmapTokenStorage(): void { removeSessionStorageItem(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY); } function parseServerClickmapResponse(value: unknown, path: string): ServerClickmap { let parsed: AnalyticsClickmapResponse; try { // Validate against the canonical response contract instead of hand-walking // `unknown`. Anything that doesn't match is treated as "no data" so the // overlay stays alive rather than crashing on shape drift. parsed = AnalyticsClickmapResponseBodySchema.validateSync(value); } catch { return { path, totalClicks: 0, selectors: [], elements: [] }; } return { path, // True aggregate across every matching route, independent of what the // current DOM can render. totalClicks: parsed.routes.reduce((sum, route) => sum + route.clicks, 0), selectors: parsed.selectors.map((selector) => ({ selector: selector.selector, clicks: selector.clicks })), elements: parsed.elements.map((element) => ({ elementsChain: element.elements_chain, elementsText: element.elements_text, tagName: element.tag_name, href: element.href, clicks: element.clicks, deadClicks: element.dead_clicks, })), }; } // Heuristic: does this path segment look like an opaque per-entity id (a UUID, // numeric id, Mongo ObjectId, ULID, etc.) rather than a human-readable slug? // Used to auto-wildcard slug routes so a single clickmap pattern aggregates // across every user/team instead of just the one currently in the URL. function isDynamicPathSegment(segment: string): boolean { if (segment === '') return false; let decoded = segment; try { decoded = decodeURIComponent(segment); } catch { // keep the raw segment if it isn't valid percent-encoding } if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(decoded)) return true; // UUID if (/^[0-9a-f]{32}$/i.test(decoded)) return true; // UUID without dashes / md5 if (/^[0-9a-f]{24}$/i.test(decoded)) return true; // Mongo ObjectId if (/^[0-9A-HJKMNP-TV-Z]{26}$/i.test(decoded)) return true; // ULID if (/^\d+$/.test(decoded)) return true; // numeric id return false; } // Turn the current pathname into a clickmap URL pattern by replacing id-like // segments with `*` (PostHog-style wildcards). Stable slugs are preserved so // e.g. `/teams//settings` becomes `/teams/*/settings`. function wildcardizePathname(pathname: string): string { const trailingSlash = pathname.length > 1 && pathname.endsWith('/'); const segments = pathname.split('/').map((segment) => (isDynamicPathSegment(segment) ? '*' : segment)); const joined = segments.join('/'); return trailingSlash ? `${joined}/` : joined; } // Translate a PostHog-style glob (where `*` is the only wildcard) into an // anchored regex source mirroring the backend's SQL LIKE semantics. function globToRegexSource(glob: string): string { return glob .split('*') .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) .join('.*'); } // Does `path` match the active URL pattern? Used to tell the user when the page // they're on isn't covered by the pattern, so the overlay can't be drawn here // even though aggregate data exists. Glob matching mirrors the backend's // anchored `LIKE`. function patternMatchesPath(pattern: string, path: string): boolean { if (pattern === '') return true; try { return new RegExp(`^${globToRegexSource(pattern)}$`).test(path); } catch { return false; } } function createClickmapPanel(app: StackClientApp, onClose: () => void): ClickmapPanelResult { const container = h('div', { className: 'sdt-hm' }); const overlayHighlight = h('div', { className: 'sdt-hm-highlight' }); const overlayRoot = h('div', { className: 'sdt-hm-overlay-root', 'aria-hidden': 'true' }, overlayHighlight); const statsCount = h('div', { className: 'sdt-hm-stat-value' }, '0'); const selectorCount = h('div', { className: 'sdt-hm-stat-value' }, '0'); const viewportValue = h('div', { className: 'sdt-hm-stat-value' }, `${window.innerWidth}x${window.innerHeight}`); const list = h('div', { className: 'sdt-hm-list' }); const empty = h('div', { className: 'sdt-hm-empty' }, 'Paste a clickmap token from the dashboard to load aggregated element clicks for this page.'); const status = h('div', { className: 'sdt-hm-token-status' }); const viewportWarningTitle = h('div', { className: 'sdt-hm-viewport-warning-title' }); const viewportWarningBody = h('div', { className: 'sdt-hm-viewport-warning-body' }); const viewportWarningWidthValue = h('code', { className: 'sdt-hm-viewport-warning-code' }); const viewportWarningHeightValue = h('code', { className: 'sdt-hm-viewport-warning-code' }); const viewportWarningWidthCopy = h('button', { className: 'sdt-hm-copy-btn', type: 'button' }); const viewportWarningHeightCopy = h('button', { className: 'sdt-hm-copy-btn', type: 'button' }); const viewportWarning = h('div', { className: 'sdt-hm-viewport-warning', role: 'status' }, viewportWarningTitle, viewportWarningBody, h('div', { className: 'sdt-hm-viewport-warning-actions' }, h('span', { className: 'sdt-hm-viewport-warning-action' }, h('span', { className: 'sdt-hm-viewport-warning-label' }, 'Width'), viewportWarningWidthValue, viewportWarningWidthCopy, ), h('span', { className: 'sdt-hm-viewport-warning-action' }, h('span', { className: 'sdt-hm-viewport-warning-label' }, 'Height'), viewportWarningHeightValue, viewportWarningHeightCopy, ), ), ); const overlayToggle = h('button', { className: 'sdt-hm-btn sdt-hm-btn-primary' }, 'Hide'); const expandButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Expand clickmap options', 'data-sdt-tip': 'Expand clickmap options' }); const closeButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Close clickmap', 'data-sdt-tip': 'Close clickmap' }); const miniClicks = h('span', { className: 'sdt-hm-toolbar-metric-value' }, '0'); const miniElements = h('span', { className: 'sdt-hm-toolbar-metric-value' }, '0'); function readStoredFilters(): ClickmapFilters { try { const raw = sessionStorage.getItem(CLICKMAP_FILTERS_STORAGE_KEY); if (raw == null) return { ...CLICKMAP_DEFAULT_FILTERS }; const parsed: unknown = JSON.parse(raw); if (parsed == null || typeof parsed !== 'object') return { ...CLICKMAP_DEFAULT_FILTERS }; const obj = parsed as Record; return { range: isClickmapRangeKey(obj.range) ? obj.range : CLICKMAP_DEFAULT_FILTERS.range, device: isClickmapDeviceKey(obj.device) ? obj.device : CLICKMAP_DEFAULT_FILTERS.device, urlPattern: typeof obj.urlPattern === 'string' ? obj.urlPattern : CLICKMAP_DEFAULT_FILTERS.urlPattern, elementSearch: typeof obj.elementSearch === 'string' ? obj.elementSearch : CLICKMAP_DEFAULT_FILTERS.elementSearch, showDead: typeof obj.showDead === 'boolean' ? obj.showDead : CLICKMAP_DEFAULT_FILTERS.showDead, }; } catch { return { ...CLICKMAP_DEFAULT_FILTERS }; } } function persistFilters(next: ClickmapFilters) { try { sessionStorage.setItem(CLICKMAP_FILTERS_STORAGE_KEY, JSON.stringify(next)); } catch { // ignore storage errors } } let currentPath = window.location.pathname; let serverClickmap: ServerClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; let loadingServerClickmap = false; let serverClickmapError: string | null = null; let serverClickmapRequestId = 0; let overlayVisible = true; let expanded = false; let renderFrame = 0; let overlayMode: 'hidden' | 'elements' = 'hidden'; let highlightedGroupSelector: string | null = null; let highlightRenderedSelector: string | null = null; let highlightSettleTimer: number | null = null; // Hovering a count marker tints its outline. The marker button is the only // pointer-interactive part of the overlay (outlines are pointer-events:none // so the page stays usable), so it owns the hover. let hoveredGroupSelector: string | null = null; const mutedGroupSelectors = new Set(); // Datagrid-style row selection, keyed by the same selector ids as muting but // independent of it: it drives the page highlight and scopes the list // header's bulk show/hide actions. The anchor remembers the last plainly // clicked row so shift+click can extend a contiguous range in list order. const selectedGroupSelectors = new Set(); let selectionAnchorSelector: string | null = null; // Snapshot of the groups from the last render, in list order. Range // selection and the header's bulk actions operate on this. let latestGroups: ClickmapClickGroup[] = []; const groupOverlayElements = new Map(); const listRowElements = new Map(); function resetCopyButton(button: HTMLElement, label: string) { button.textContent = label; } function copyClickmapViewportValue(button: HTMLElement, value: string, label: string) { runAsynchronously(async () => { try { await navigator.clipboard.writeText(value); button.textContent = 'Copied'; window.setTimeout(() => resetCopyButton(button, label), 1200); } catch { button.textContent = 'Copy failed'; window.setTimeout(() => resetCopyButton(button, label), 1600); } }); } // DOM-index cache for fast element-chain inference. const domIndex = new Map(); let domIndexDirty = true; let domIndexDebounce = 0; function rebuildDomIndex() { domIndex.clear(); trimTargetCache = new WeakMap(); const all = document.querySelectorAll('*'); for (const el of all) { if (!isElementVisibleForClickmap(el)) continue; const tag = el.tagName.toLowerCase(); const bucket = domIndex.get(tag) ?? []; bucket.push(el); domIndex.set(tag, bucket); } domIndexDirty = false; } // Attribute clicks to the logical control, not the fragment the browser // reported (PostHog's trimElement). A click on a or inside a // button records a span/svg-leaf chain, so the matched element walks up to // the nearest clickable ancestor: a real control (semantic selector) or the // element where cursor:pointer begins (computed pointer while the parent's // isn't — catches div-as-button components with zero hardcoded tags). No // hit within the cap returns the element unchanged. Resolution happens here // at render time, never at capture: stored chains stay raw, so these rules // can evolve and historical clicks regroup for free. const CLICKMAP_TRIM_TARGET_SELECTOR = 'a[href], button, input, select, textarea, summary, label, [role="button"], [role="link"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="tab"], [role="checkbox"], [role="radio"], [role="switch"], [role="option"], [contenteditable="true"]'; const CLICKMAP_TRIM_MAX_HOPS = 10; // getComputedStyle on every hop is too hot for the per-render group loop; // resolutions are cached per matched element and dropped together with the // dom index (same trigger: the page's DOM changed). let trimTargetCache = new WeakMap(); function resolveClickTarget(start: Element): { target: Element, key: string } { const cached = trimTargetCache.get(start); if (cached != null) return cached; let target = start; let current: Element | null = start; for (let hops = 0; current != null && current !== document.body && current !== document.documentElement && hops < CLICKMAP_TRIM_MAX_HOPS; hops++) { if (current.matches(CLICKMAP_TRIM_TARGET_SELECTOR)) { target = current; break; } const parent: Element | null = current.parentElement; if (window.getComputedStyle(current).cursor === 'pointer' && (parent == null || window.getComputedStyle(parent).cursor !== 'pointer')) { target = current; break; } current = parent; } const resolved = { target, key: buildElementsChain(target) }; trimTargetCache.set(start, resolved); return resolved; } function ensureDomIndex() { if (domIndexDirty) rebuildDomIndex(); } function invalidateDomIndex() { domIndexDirty = true; } function scheduleDomIndexInvalidation() { if (domIndexDebounce !== 0) { window.clearTimeout(domIndexDebounce); } domIndexDebounce = window.setTimeout(() => { domIndexDebounce = 0; invalidateDomIndex(); scheduleRender(); }, CLICKMAP_DOM_INDEX_DEBOUNCE_MS); } function isElementChainCandidateUnique(matches: Element[]): Element | null { const visible = matches.filter(isElementVisibleForClickmap); return visible.length === 1 ? visible[0] : null; } function queryUniqueBySelector(selector: string): Element | null { try { const all = Array.from(document.querySelectorAll(selector)); return isElementChainCandidateUnique(all); } catch { return null; } } function elementMatchesSegment(element: Element, segment: ElementsChainSegment, useClasses: boolean): boolean { if (element.tagName.toLowerCase() !== segment.tag) return false; if (useClasses) { for (const cls of segment.classes) { if (!element.classList.contains(cls)) return false; } } return true; } function ancestorMatchesChain(leaf: Element, chain: ElementsChainSegment[], useClasses: boolean, useNthOfType: boolean, useNthChild: boolean): boolean { let cursor: Element | null = leaf; for (let i = 0; i < chain.length; i++) { if (cursor == null) return false; const segment = chain[i]; if (!elementMatchesSegment(cursor, segment, useClasses)) return false; if (useNthOfType && segment.nthOfType != null) { if (computeNthOfType(cursor) !== segment.nthOfType) return false; } if (useNthChild && segment.nthChild != null) { if (computeNthChild(cursor) !== segment.nthChild) return false; } cursor = cursor.parentElement; } return true; } function computeNthOfType(el: Element): number { let n = 1; let sib = el.previousElementSibling; const tag = el.tagName; while (sib != null) { if (sib.tagName === tag) n += 1; sib = sib.previousElementSibling; } return n; } function computeNthChild(el: Element): number { let n = 1; let sib = el.previousElementSibling; while (sib != null) { n += 1; sib = sib.previousElementSibling; } return n; } function inferElementFromChain(chain: ElementsChainSegment[]): Element | null { if (chain.length === 0) return null; const leaf = chain[0]; // 1. Stable attribute selectors on leaf (no tag). const stableAttrOrder: Array<{ attr: string, prefix?: string }> = [ { attr: 'data-hexclave-id' }, { attr: 'data-testid' }, { attr: 'data-test-id' }, { attr: 'name' }, ]; for (const { attr } of stableAttrOrder) { const value = readChainAttr(leaf, attr); if (value === '') continue; const sel = `[${attr}="${cssEscapeAttrValue(value)}"]`; const match: Element | null = queryUniqueBySelector(sel); if (match) return match; } const id = readChainAttr(leaf, 'id'); if (id !== '') { const match: Element | null = queryUniqueBySelector(`#${cssEscapeIdent(id)}`); if (match) return match; } if (leaf.href != null && leaf.href !== '' && leaf.tag === 'a') { const match: Element | null = queryUniqueBySelector(`a[href="${cssEscapeAttrValue(leaf.href)}"]`); if (match) return match; } // 2. Tag + stable attribute on the leaf. const otherStableAttrs = ['aria-label', 'role', 'placeholder', 'title', 'type']; for (const attr of otherStableAttrs) { const value = readChainAttr(leaf, attr); if (value === '') continue; const sel = `${leaf.tag}[${attr}="${cssEscapeAttrValue(value)}"]`; const match: Element | null = queryUniqueBySelector(sel); if (match) return match; } // 3, 4, 5: walk the DOM index by leaf tag, score the chain. ensureDomIndex(); const candidates = domIndex.get(leaf.tag) ?? []; if (candidates.length === 0) return null; // Variant 3: tag.classes across the chain, no nth. const v3: Element[] = []; for (const candidate of candidates) { if (ancestorMatchesChain(candidate, chain, true, false, false)) v3.push(candidate); } const u3 = isElementChainCandidateUnique(v3); if (u3 != null) return u3; // Variant 4: tag.classes + nth-of-type. const v4: Element[] = []; for (const candidate of candidates) { if (ancestorMatchesChain(candidate, chain, true, true, false)) v4.push(candidate); } const u4 = isElementChainCandidateUnique(v4); if (u4 != null) return u4; // Variant 5: tag.classes + nth-child. const v5: Element[] = []; for (const candidate of candidates) { if (ancestorMatchesChain(candidate, chain, true, true, true)) v5.push(candidate); } const u5 = isElementChainCandidateUnique(v5); if (u5 != null) return u5; return null; } setHtml(closeButton, ''); const chevronUpSvg = ''; const chevronDownSvg = ''; const clicksIconSvg = ''; const elementsIconSvg = ''; const eyeIconSvg = ''; const eyeOffIconSvg = ''; // Only swap the chevron when the expanded state actually changes. render() // runs constantly (route poll, scroll, body mutations), and rewriting the // button's SVG on every pass detaches the element under the user's pointer // mid-press, which makes the browser drop the click entirely — the button // appeared to have dead spots wherever the icon sat. let renderedExpandIcon = ''; function syncExpandIcon() { const icon = expanded ? chevronDownSvg : chevronUpSvg; if (renderedExpandIcon === icon) return; renderedExpandIcon = icon; setHtml(expandButton, icon); } syncExpandIcon(); resetCopyButton(viewportWarningWidthCopy, 'Copy width'); resetCopyButton(viewportWarningHeightCopy, 'Copy height'); viewportWarningWidthCopy.addEventListener('click', () => { copyClickmapViewportValue(viewportWarningWidthCopy, viewportWarningWidthValue.textContent, 'Copy width'); }); viewportWarningHeightCopy.addEventListener('click', () => { copyClickmapViewportValue(viewportWarningHeightCopy, viewportWarningHeightValue.textContent, 'Copy height'); }); const stats = h('div', { className: 'sdt-hm-stats' }, h('div', { className: 'sdt-hm-stat' }, h('div', { className: 'sdt-hm-stat-label' }, 'Clicks'), statsCount), h('div', { className: 'sdt-hm-stat' }, h('div', { className: 'sdt-hm-stat-label' }, 'Elements'), selectorCount), h('div', { className: 'sdt-hm-stat' }, h('div', { className: 'sdt-hm-stat-label' }, 'Viewport'), viewportValue), ); let filters: ClickmapFilters = readStoredFilters(); let filterReloadDebounce = 0; // When the user hasn't typed a custom pattern, the URL pattern field mirrors // the current route with id-like segments auto-wildcarded (`/teams/*/settings`) // so the clickmap aggregates across all entities. A stored non-empty pattern // means the user took manual control, so we leave it alone. let urlPatternUserEdited = filters.urlPattern.trim() !== ''; function getEffectiveUrlPattern(): string { if (urlPatternUserEdited) return filters.urlPattern.trim(); return wildcardizePathname(window.location.pathname); } // Reflect the current route into the field while in auto mode. No-op once the // user has typed their own pattern. function syncAutoUrlPattern() { if (urlPatternUserEdited) return; const auto = wildcardizePathname(window.location.pathname); if (urlPatternInput.value !== auto) { urlPatternInput.value = auto; } } function makeFilterSelect(options: Array<[string, string]>, value: string): HTMLSelectElement { const el = h('select', { className: 'sdt-hm-filter-input' }) as HTMLSelectElement; for (const [optValue, label] of options) { const opt = h('option', { value: optValue }, label) as HTMLOptionElement; el.appendChild(opt); } el.value = value; return el; } const rangeSelect = makeFilterSelect([ ['24h', 'Last 24h'], ['7d', 'Last 7 days'], ['30d', 'Last 30 days'], ], filters.range); // Viewport filter as a segmented switcher: equal-weight options with a single // pill that slides to the active mode, instead of a hidden-until-opened native //