mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
feat(analytics): enhance clickmap functionality and UI components
- Introduced auto-copy feature for clickmap token snippets, improving user experience. - Updated CopyField and CopyButton components to support initial copied state. - Refactored PageClient to manage custom origin state and improve clickmap token handling. - Added viewport warning styles and functionality to enhance user guidance in the dev tool. - Cleaned up unused code and improved overall component structure for better maintainability.
This commit is contained in:
parent
508f2ff503
commit
c20f4c2504
@ -21,7 +21,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Spinner,
|
||||
toast,
|
||||
Typography,
|
||||
} from "@/components/ui";
|
||||
import { DesignAnalyticsCard } from "@/components/design-components/analytics-card";
|
||||
@ -317,6 +316,7 @@ function installClickmapTokenForCurrentOrigin(token: AnalyticsClickmapTokenRespo
|
||||
function ClickmapTokenDialog(props: {
|
||||
origin: ClickmapOrigin | null,
|
||||
token: AnalyticsClickmapTokenResponse | null,
|
||||
autoCopied?: boolean,
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void,
|
||||
}) {
|
||||
@ -336,7 +336,7 @@ function ClickmapTokenDialog(props: {
|
||||
<Alert>Creating clickmap token...</Alert>
|
||||
) : (
|
||||
<>
|
||||
<CopyField type="textarea" value={snippet} monospace fixedSize height={124} />
|
||||
<CopyField type="textarea" value={snippet} monospace fixedSize height={124} initialCopied={props.autoCopied} />
|
||||
<Typography type="p" variant="secondary" className="text-sm">
|
||||
The site will use normal client authentication plus this origin-bound clickmap token to fetch aggregate clickmap data.
|
||||
</Typography>
|
||||
@ -370,7 +370,12 @@ export default function PageClient() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [selectedOrigin, setSelectedOrigin] = useState<ClickmapOrigin | null>(null);
|
||||
const [token, setToken] = useState<AnalyticsClickmapTokenResponse | null>(null);
|
||||
const [customOrigin, setCustomOrigin] = useState("http://localhost:8101");
|
||||
const [autoCopied, setAutoCopied] = useState(false);
|
||||
const [customOrigin, setCustomOrigin] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setCustomOrigin(window.location.origin);
|
||||
}, []);
|
||||
|
||||
const origins = useMemo(() => {
|
||||
const byOrigin = new Map<string, ClickmapOrigin>();
|
||||
@ -403,10 +408,11 @@ export default function PageClient() {
|
||||
throw error;
|
||||
}
|
||||
setToken(created);
|
||||
const installedInCurrentTab = installClickmapTokenForCurrentOrigin(created);
|
||||
installClickmapTokenForCurrentOrigin(created);
|
||||
setAutoCopied(false);
|
||||
try {
|
||||
await navigator.clipboard.writeText(createConsoleSnippet(created.token));
|
||||
toast({ title: installedInCurrentTab ? "Clickmap toolbar enabled" : "Snippet copied to clipboard" });
|
||||
setAutoCopied(true);
|
||||
} catch {
|
||||
// Clipboard access can be denied (e.g. lost user-gesture after the
|
||||
// network round-trip); the snippet stays available to copy manually.
|
||||
@ -430,7 +436,17 @@ export default function PageClient() {
|
||||
</Typography>
|
||||
<Input value={customOrigin} onChange={(event) => setCustomOrigin(event.target.value)} placeholder="http://localhost:3000" />
|
||||
</div>
|
||||
<Button onClick={async () => await showClickmap({ id: "localhost", origin: customOrigin })}>
|
||||
<Button
|
||||
disabled={customOrigin.trim() === ""}
|
||||
onClick={async () => {
|
||||
const origin = normalizeOrigin(customOrigin);
|
||||
if (origin == null) {
|
||||
window.alert("Enter a valid HTTP(S) origin, for example http://localhost:3000.");
|
||||
return;
|
||||
}
|
||||
await showClickmap({ id: "localhost", origin });
|
||||
}}
|
||||
>
|
||||
Show clickmap
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
@ -470,6 +486,7 @@ export default function PageClient() {
|
||||
<ClickmapTokenDialog
|
||||
origin={selectedOrigin}
|
||||
token={token}
|
||||
autoCopied={autoCopied}
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
/>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CopyIcon, SparkleIcon } from "@phosphor-icons/react";
|
||||
import { CheckIcon, CopyIcon, SparkleIcon } from "@phosphor-icons/react";
|
||||
import { forwardRefIfNeeded } from "@stackframe/stack-shared/dist/utils/react";
|
||||
import React from "react";
|
||||
import { Button } from "./button";
|
||||
@ -9,9 +9,27 @@ import { useToast } from "./use-toast";
|
||||
|
||||
const CopyButton = forwardRefIfNeeded<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button> & { content: string }
|
||||
>((props, ref) => {
|
||||
React.ComponentProps<typeof Button> & { content: string, initialCopied?: boolean }
|
||||
>(({ content, initialCopied, ...props }, ref) => {
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const resetTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const showCopied = React.useCallback(() => {
|
||||
setCopied(true);
|
||||
if (resetTimeout.current != null) clearTimeout(resetTimeout.current);
|
||||
resetTimeout.current = setTimeout(() => setCopied(false), 2000);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
if (resetTimeout.current != null) clearTimeout(resetTimeout.current);
|
||||
}, []);
|
||||
|
||||
// Reflect a copy that already happened elsewhere (e.g. the snippet was
|
||||
// auto-copied to the clipboard when this field was rendered).
|
||||
React.useEffect(() => {
|
||||
if (initialCopied) showCopied();
|
||||
}, [initialCopied, showCopied]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
@ -22,14 +40,14 @@ const CopyButton = forwardRefIfNeeded<
|
||||
onClick={async (...args) => {
|
||||
await props.onClick?.(...args);
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.content);
|
||||
toast({ description: 'Copied to clipboard!', variant: 'success' });
|
||||
await navigator.clipboard.writeText(content);
|
||||
showCopied();
|
||||
} catch (e) {
|
||||
toast({ description: 'Failed to copy to clipboard', variant: 'destructive' });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CopyIcon />
|
||||
{copied ? <CheckIcon className="text-green-500 dark:text-green-400" weight="bold" /> : <CopyIcon />}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
@ -10,6 +10,7 @@ export function CopyField(props: {
|
||||
helper?: React.ReactNode,
|
||||
monospace?: boolean,
|
||||
fixedSize?: boolean,
|
||||
initialCopied?: boolean,
|
||||
} & ({
|
||||
type: "textarea",
|
||||
height?: number,
|
||||
@ -36,7 +37,7 @@ export function CopyField(props: {
|
||||
resize: props.fixedSize ? "none" : "vertical"
|
||||
}}
|
||||
/>
|
||||
<CopyButton content={props.value} className="absolute right-4 top-2" />
|
||||
<CopyButton content={props.value} initialCopied={props.initialCopied} className="absolute right-4 top-2" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
@ -47,7 +48,7 @@ export function CopyField(props: {
|
||||
fontFamily: props.monospace ? "ui-monospace, monospace" : "inherit",
|
||||
}}
|
||||
/>
|
||||
<CopyButton content={props.value} />
|
||||
<CopyButton content={props.value} initialCopied={props.initialCopied} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -27,7 +27,7 @@ import { clampTriggerPosition, getSnappedTriggerPlacement, resolveTriggerPositio
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TabId = 'overview' | 'clickmaps' | 'customize' | 'ai' | 'dashboard' | 'console' | 'support';
|
||||
type TabId = 'overview' | 'customize' | 'ai' | 'dashboard' | 'console' | 'support';
|
||||
|
||||
type TabResult = { element: HTMLElement, cleanup?: () => void };
|
||||
|
||||
@ -76,9 +76,13 @@ const TABS: { id: TabId; label: string; icon: string }[] = [
|
||||
{ id: 'console', label: 'Console', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>' },
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>' },
|
||||
{ id: 'support', label: 'Support', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>' },
|
||||
{ id: 'clickmaps', label: 'Clickmaps', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 9h.01"/><path d="M15 8h.01"/><path d="M12 15h.01"/><path d="M19 11h.01"/><path d="M5 16h.01"/><path d="M3 3l18 18"/><path d="M14 14l7 7"/><path d="M5 5l5 5"/></svg>' },
|
||||
];
|
||||
|
||||
// Clickmaps is intentionally NOT a dev tool tab. It's a fully independent
|
||||
// feature with its own mount (see createDevTool), opened via a dashboard-minted
|
||||
// token (the CLICKMAP_OVERLAY_TOKEN_UPDATED event / resume flow), never from the
|
||||
// dev tool. The two coexist without affecting each other.
|
||||
|
||||
const DEFAULT_STATE: DevToolState = {
|
||||
isOpen: false,
|
||||
activeTab: 'overview',
|
||||
@ -1811,21 +1815,23 @@ const CLICKMAP_FILTERS_STORAGE_KEY = 'hexclave-clickmap-overlay-filters';
|
||||
|
||||
type ClickmapRangeKey = '24h' | '7d' | '30d';
|
||||
type ClickmapDeviceKey = 'all' | 'mobile' | 'tablet' | 'laptop' | 'desktop' | 'widescreen' | 'tv';
|
||||
type ClickmapUrlPatternMode = 'glob' | 'regex';
|
||||
|
||||
type ClickmapFilters = {
|
||||
range: ClickmapRangeKey,
|
||||
device: ClickmapDeviceKey,
|
||||
urlPattern: string,
|
||||
urlPatternMode: ClickmapUrlPatternMode,
|
||||
elementSearch: string,
|
||||
};
|
||||
|
||||
type ClickmapViewportBucket = {
|
||||
min: number,
|
||||
max: number | null,
|
||||
};
|
||||
|
||||
const CLICKMAP_DEFAULT_FILTERS: ClickmapFilters = {
|
||||
range: '7d',
|
||||
device: 'all',
|
||||
urlPattern: '',
|
||||
urlPatternMode: 'glob',
|
||||
elementSearch: '',
|
||||
};
|
||||
|
||||
@ -1835,15 +1841,40 @@ const CLICKMAP_RANGE_MS: Record<ClickmapRangeKey, number> = {
|
||||
'30d': 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
const CLICKMAP_VIEWPORT_BUCKETS: Record<Exclude<ClickmapDeviceKey, 'all'>, 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';
|
||||
}
|
||||
function isClickmapUrlPatternMode(value: unknown): value is ClickmapUrlPatternMode {
|
||||
return value === 'glob' || value === 'regex';
|
||||
}
|
||||
const CLICKMAP_DOM_INDEX_DEBOUNCE_MS = 250;
|
||||
|
||||
type DevToolServerClickmapSelector = {
|
||||
@ -1978,25 +2009,17 @@ function getJwtPayloadClaim(token: string, claim: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function getClickmapTokenFromStorage(projectId: string): string | null {
|
||||
const token = getSessionStorageString(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY);
|
||||
if (token == null) {
|
||||
return null;
|
||||
}
|
||||
// A token minted for a different project must not apply to this app.
|
||||
const tokenProjectId = getJwtPayloadClaim(token, 'project_id');
|
||||
return tokenProjectId == null || tokenProjectId === projectId ? token : null;
|
||||
function getClickmapTokenFromStorage(): string | null {
|
||||
return getSessionStorageString(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
function getClickmapOriginFromStorage(projectId: string): string | null {
|
||||
const token = getClickmapTokenFromStorage(projectId);
|
||||
function getClickmapOriginFromStorage(): string | null {
|
||||
const token = getClickmapTokenFromStorage();
|
||||
return token == null ? null : getJwtPayloadClaim(token, 'origin');
|
||||
}
|
||||
|
||||
function clearClickmapTokenStorage(projectId: string): void {
|
||||
if (getClickmapTokenFromStorage(projectId) != null) {
|
||||
removeSessionStorageItem(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
function clearClickmapTokenStorage(): void {
|
||||
removeSessionStorageItem(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
function parseServerClickmapResponse(value: unknown, path: string): DevToolServerClickmap {
|
||||
@ -2064,32 +2087,20 @@ function globToRegexSource(glob: string): string {
|
||||
.join('.*');
|
||||
}
|
||||
|
||||
function isValidRegexSource(source: string): boolean {
|
||||
try {
|
||||
new RegExp(source);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 mode mirrors the backend's anchored
|
||||
// `LIKE`; regex mode mirrors ClickHouse `match()` (unanchored RE2).
|
||||
function patternMatchesPath(pattern: string, path: string, mode: ClickmapUrlPatternMode): boolean {
|
||||
// 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 {
|
||||
if (mode === 'regex') {
|
||||
return new RegExp(pattern).test(path);
|
||||
}
|
||||
return new RegExp(`^${globToRegexSource(pattern)}$`).test(path);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabResult {
|
||||
function createClickmapsTab(app: StackClientApp<true>, onClose: () => void): TabResult {
|
||||
const container = h('div', { className: 'sdt-hm' });
|
||||
const overlayRoot = h('div', { className: 'sdt-hm-overlay-root', 'aria-hidden': 'true' });
|
||||
const statsCount = h('div', { className: 'sdt-hm-stat-value' }, '0');
|
||||
@ -2098,9 +2109,31 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
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', title: 'Expand clickmap options' });
|
||||
const backButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Back', title: 'Back' });
|
||||
const closeButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Close clickmap', title: 'Close clickmap' });
|
||||
const miniClicks = h('span', { className: 'sdt-hm-toolbar-metric-value' }, '0');
|
||||
const miniElements = h('span', { className: 'sdt-hm-toolbar-metric-value' }, '0');
|
||||
|
||||
@ -2115,7 +2148,6 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
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,
|
||||
urlPatternMode: isClickmapUrlPatternMode(obj.urlPatternMode) ? obj.urlPatternMode : CLICKMAP_DEFAULT_FILTERS.urlPatternMode,
|
||||
elementSearch: typeof obj.elementSearch === 'string' ? obj.elementSearch : CLICKMAP_DEFAULT_FILTERS.elementSearch,
|
||||
};
|
||||
} catch {
|
||||
@ -2141,6 +2173,23 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
let overlayMode: 'hidden' | 'elements' = 'hidden';
|
||||
const groupOverlayElements = new Map<string, ClickmapGroupOverlayElement>();
|
||||
|
||||
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<string, Element[]>();
|
||||
let domIndexDirty = true;
|
||||
@ -2306,12 +2355,20 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
return null;
|
||||
}
|
||||
|
||||
setHtml(backButton, '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>');
|
||||
setHtml(closeButton, '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>');
|
||||
const chevronUpSvg = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg>';
|
||||
const chevronDownSvg = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>';
|
||||
const clicksIconSvg = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4.1 12 6"/><path d="m5.1 8-2.9-.8"/><path d="m6 12-1.9 2"/><path d="M7.2 2.2 8 5.1"/><path d="M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z"/></svg>';
|
||||
const elementsIconSvg = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>';
|
||||
setHtml(expandButton, chevronUpSvg);
|
||||
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),
|
||||
@ -2328,16 +2385,13 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
let urlPatternUserEdited = filters.urlPattern.trim() !== '';
|
||||
|
||||
function getEffectiveUrlPattern(): string {
|
||||
// Auto route-tracking is a glob concept; in regex mode an empty field means
|
||||
// "this exact page" (the route_path fallback), never an auto-wildcard.
|
||||
if (filters.urlPatternMode === 'regex') return filters.urlPattern.trim();
|
||||
if (urlPatternUserEdited) return filters.urlPattern.trim();
|
||||
return wildcardizePathname(window.location.pathname);
|
||||
}
|
||||
// Reflect the current route into the field while in glob auto mode. No-op in
|
||||
// regex mode or once the user has typed their own pattern.
|
||||
// Reflect the current route into the field while in auto mode. No-op once the
|
||||
// user has typed their own pattern.
|
||||
function syncAutoUrlPattern() {
|
||||
if (filters.urlPatternMode === 'regex' || urlPatternUserEdited) return;
|
||||
if (urlPatternUserEdited) return;
|
||||
const auto = wildcardizePathname(window.location.pathname);
|
||||
if (urlPatternInput.value !== auto) {
|
||||
urlPatternInput.value = auto;
|
||||
@ -2428,12 +2482,14 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
}) as HTMLInputElement;
|
||||
urlPatternInput.value = getEffectiveUrlPattern();
|
||||
// Shown only while the active pattern doesn't cover the current page (see
|
||||
// render); resets the field back to the auto-wildcarded current route.
|
||||
// render); reverts the field back to the auto-wildcarded current route.
|
||||
const urlPatternReset = h('button', {
|
||||
className: 'sdt-hm-filter-reset',
|
||||
type: 'button',
|
||||
title: 'Reset the URL pattern to the current page',
|
||||
}, 'Reset') as HTMLButtonElement;
|
||||
'aria-label': 'Revert the URL pattern to the current page',
|
||||
title: 'Revert the URL pattern to the current page',
|
||||
}) as HTMLButtonElement;
|
||||
setHtml(urlPatternReset, '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/></svg>');
|
||||
|
||||
// Info button + popover explaining the URL pattern syntax. The backend
|
||||
// translates `*` into a SQL LIKE `%`, so `*` is the only wildcard — every
|
||||
@ -2459,37 +2515,14 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
const urlHelpTitle = h('div', { className: 'sdt-hm-url-help-title' });
|
||||
const urlHelpBody = h('div', { className: 'sdt-hm-url-help-body' });
|
||||
const urlHelpRows = h('div', { className: 'sdt-hm-url-help-rows' });
|
||||
// Cheatsheet content tracks the active matching mode so it only ever shows
|
||||
// syntax that actually works.
|
||||
function renderUrlHelp() {
|
||||
if (filters.urlPatternMode === 'regex') {
|
||||
urlHelpTitle.textContent = 'URL pattern · regex';
|
||||
urlHelpBody.replaceChildren(
|
||||
'Matched against the pathname with ClickHouse ',
|
||||
makeCode('match()'),
|
||||
' (RE2 syntax), unanchored — add ',
|
||||
makeCode('^'),
|
||||
' / ',
|
||||
makeCode('$'),
|
||||
' to pin the start and end. No domain, hash, or query string.',
|
||||
);
|
||||
urlHelpRows.replaceChildren(
|
||||
makeUrlHelpRow('^/pricing$', 'Exactly /pricing'),
|
||||
makeUrlHelpRow('^/products/', 'Anything starting with /products/'),
|
||||
makeUrlHelpRow('/(privacy|terms)$', 'Ends in /privacy or /terms'),
|
||||
makeUrlHelpRow('^/teams/[^/]+/members$', 'One id segment in the middle'),
|
||||
makeUrlHelpRow('\\.html$', 'Paths ending in .html'),
|
||||
makeUrlHelpRow('(empty)', 'This exact page only'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
urlHelpTitle.textContent = 'URL pattern · glob';
|
||||
urlHelpBody.replaceChildren(
|
||||
'Limits the clickmap to pages whose path matches. Matched against the pathname only — no domain, hash, or query string. ',
|
||||
makeCode('*'),
|
||||
' is the only wildcard and stands in for any characters (including ',
|
||||
makeCode('/'),
|
||||
'). Everything else is matched literally — switch to regex for full RE2 syntax.',
|
||||
'). Everything else is matched literally.',
|
||||
);
|
||||
urlHelpRows.replaceChildren(
|
||||
makeUrlHelpRow('/pricing', 'That exact page'),
|
||||
@ -2519,58 +2552,7 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
urlPatternHelp.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
// Glob vs. regex matching. The backend already supports both (`url_pattern`
|
||||
// → SQL LIKE, `route_regex` → ClickHouse match()); this toggle picks which
|
||||
// one we send and keeps the local coverage check in sync.
|
||||
const urlModeButtons = new Map<ClickmapUrlPatternMode, HTMLButtonElement>();
|
||||
const urlPatternModeToggle = h('div', {
|
||||
className: 'sdt-hm-mode',
|
||||
role: 'radiogroup',
|
||||
'aria-label': 'URL pattern matching mode',
|
||||
});
|
||||
const urlModeOptions: Array<[ClickmapUrlPatternMode, string, string]> = [
|
||||
['glob', '*', 'Glob matching — * is the only wildcard'],
|
||||
['regex', '.*', 'Regex matching — full RE2 syntax'],
|
||||
];
|
||||
function applyUrlPatternMode() {
|
||||
for (const [mode, btn] of urlModeButtons) {
|
||||
const active = mode === filters.urlPatternMode;
|
||||
btn.setAttribute('aria-checked', String(active));
|
||||
btn.classList.toggle('sdt-hm-mode-btn-active', active);
|
||||
}
|
||||
urlPatternInput.placeholder = filters.urlPatternMode === 'regex' ? '^/products/.*$' : '/products/*';
|
||||
renderUrlHelp();
|
||||
}
|
||||
function setUrlPatternMode(mode: ClickmapUrlPatternMode) {
|
||||
if (filters.urlPatternMode === mode) return;
|
||||
let nextPattern: string;
|
||||
if (mode === 'regex') {
|
||||
// Seed regex mode from the current glob, translated to an equivalent
|
||||
// anchored regex, so the switch leaves a working starting point.
|
||||
const current = getEffectiveUrlPattern();
|
||||
nextPattern = current === '' ? '' : `^${globToRegexSource(current)}$`;
|
||||
urlPatternUserEdited = nextPattern !== '';
|
||||
urlPatternInput.value = nextPattern;
|
||||
} else {
|
||||
// A regex can't be reversed into a glob, so fall back to auto route-tracking.
|
||||
nextPattern = '';
|
||||
urlPatternUserEdited = false;
|
||||
urlPatternInput.value = wildcardizePathname(window.location.pathname);
|
||||
}
|
||||
filters = { ...filters, urlPatternMode: mode, urlPattern: nextPattern };
|
||||
persistFilters(filters);
|
||||
applyUrlPatternMode();
|
||||
scheduleFilterReload();
|
||||
scheduleRender();
|
||||
}
|
||||
for (const [mode, label, title] of urlModeOptions) {
|
||||
const btn = h('button', { className: 'sdt-hm-mode-btn', type: 'button', role: 'radio', title }, label) as HTMLButtonElement;
|
||||
btn.addEventListener('click', () => setUrlPatternMode(mode));
|
||||
urlModeButtons.set(mode, btn);
|
||||
urlPatternModeToggle.appendChild(btn);
|
||||
}
|
||||
applyUrlPatternMode();
|
||||
renderUrlHelp();
|
||||
|
||||
const elementSearchInput = h('input', {
|
||||
className: 'sdt-hm-filter-input',
|
||||
@ -2604,11 +2586,11 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
setHtml(clicksIcon, clicksIconSvg);
|
||||
setHtml(elementsIcon, elementsIconSvg);
|
||||
const toolbar = h('div', { className: 'sdt-hm-toolbar' },
|
||||
backButton,
|
||||
closeButton,
|
||||
h('div', { className: 'sdt-hm-toolbar-title' }, 'Clickmap'),
|
||||
h('div', { className: 'sdt-hm-toolbar-filters' },
|
||||
rangeSelect,
|
||||
h('div', { className: 'sdt-hm-toolbar-url' }, urlPatternInput, urlPatternReset, urlPatternModeToggle, urlPatternInfo, urlPatternHelp),
|
||||
h('div', { className: 'sdt-hm-toolbar-url' }, urlPatternInput, urlPatternReset, urlPatternInfo, urlPatternHelp),
|
||||
),
|
||||
h('div', { className: 'sdt-hm-toolbar-metrics' },
|
||||
h('span', { className: 'sdt-hm-toolbar-metric', title: 'Aggregate clicks' }, miniClicks, clicksIcon),
|
||||
@ -2662,10 +2644,9 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
});
|
||||
urlPatternReset.addEventListener('click', () => {
|
||||
// Hand control back to auto mode and reflect the current route immediately,
|
||||
// so the pattern covers the page the overlay is bound to. In regex mode
|
||||
// there's no auto-wildcard, so clearing the field means "this exact page".
|
||||
// so the pattern covers the page the overlay is bound to.
|
||||
urlPatternUserEdited = false;
|
||||
urlPatternInput.value = filters.urlPatternMode === 'regex' ? '' : wildcardizePathname(window.location.pathname);
|
||||
urlPatternInput.value = wildcardizePathname(window.location.pathname);
|
||||
updateFilters({ urlPattern: '' });
|
||||
});
|
||||
elementSearchInput.addEventListener('input', () => {
|
||||
@ -2678,7 +2659,7 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
// bordered bands that made the expanded panel feel congested.
|
||||
const actions = h('div', { className: 'sdt-hm-actions' }, stats, overlayToggle);
|
||||
const head = h('div', { className: 'sdt-hm-head' }, filterRow, actions);
|
||||
const body = h('div', { className: 'sdt-hm-body' }, status, list);
|
||||
const body = h('div', { className: 'sdt-hm-body' }, status, viewportWarning, list);
|
||||
const details = h('div', { className: 'sdt-hm-details' }, head, body);
|
||||
|
||||
function getGroups(): DevToolClickGroup[] {
|
||||
@ -2848,25 +2829,34 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
const mappedClicks = groups.reduce((sum, group) => sum + group.count, 0);
|
||||
const aggregateClicks = serverClickmap.path === currentPath ? serverClickmap.totalClicks : 0;
|
||||
const viewport = getClickmapViewportSize();
|
||||
const roundedViewportWidth = Math.round(viewport.width);
|
||||
const roundedViewportHeight = Math.round(viewport.height);
|
||||
const selectedViewportBucket = getClickmapViewportBucket(filters.device);
|
||||
const viewportFilterMatches = selectedViewportBucket == null || isClickmapViewportWidthInBucket(roundedViewportWidth, selectedViewportBucket);
|
||||
statsCount.textContent = formatClickmapCount(aggregateClicks);
|
||||
selectorCount.textContent = formatClickmapCount(groups.length);
|
||||
viewportValue.textContent = `${Math.round(viewport.width)}x${Math.round(viewport.height)}`;
|
||||
viewportValue.textContent = `${roundedViewportWidth}x${roundedViewportHeight}`;
|
||||
overlayToggle.textContent = overlayVisible ? 'Hide overlay' : 'Show overlay';
|
||||
viewportWarning.classList.toggle('sdt-hm-viewport-warning-visible', !viewportFilterMatches);
|
||||
if (selectedViewportBucket != null && !viewportFilterMatches) {
|
||||
const recommendedWidth = getClickmapRecommendedViewportWidth(selectedViewportBucket);
|
||||
const recommendedHeight = Math.max(1, roundedViewportHeight);
|
||||
viewportWarningTitle.textContent = 'Viewport filter mismatch';
|
||||
viewportWarningBody.textContent = `This page is ${roundedViewportWidth}px wide, but ${filters.device} is ${formatClickmapViewportBucket(selectedViewportBucket)}. Update the Google DevTools device toolbar before comparing this clickmap.`;
|
||||
viewportWarningWidthValue.textContent = String(recommendedWidth);
|
||||
viewportWarningHeightValue.textContent = String(recommendedHeight);
|
||||
}
|
||||
// A pattern that doesn't cover the current page means the overlay can't draw
|
||||
// here, so offer a one-click reset back to the current route.
|
||||
const effectiveUrlPattern = getEffectiveUrlPattern();
|
||||
const regexInvalid = filters.urlPatternMode === 'regex' && effectiveUrlPattern !== '' && !isValidRegexSource(effectiveUrlPattern);
|
||||
const urlPatternMatchesPath = patternMatchesPath(effectiveUrlPattern, currentPath, filters.urlPatternMode);
|
||||
const urlPatternMatchesPath = patternMatchesPath(effectiveUrlPattern, currentPath);
|
||||
urlPatternReset.classList.toggle('sdt-hm-filter-reset-visible', !urlPatternMatchesPath);
|
||||
urlPatternInput.classList.toggle('sdt-hm-filter-input-error', regexInvalid);
|
||||
const token = getClickmapTokenFromStorage(app.projectId);
|
||||
const tokenOrigin = getClickmapOriginFromStorage(app.projectId);
|
||||
const token = getClickmapTokenFromStorage();
|
||||
const tokenOrigin = getClickmapOriginFromStorage();
|
||||
if (token == null) {
|
||||
status.textContent = serverClickmapError ?? 'No clickmap token in sessionStorage. Paste one from the dashboard to load this page.';
|
||||
} else if (tokenOrigin != null && tokenOrigin !== window.location.origin) {
|
||||
status.textContent = `Token was minted for ${tokenOrigin}, but this page is ${window.location.origin}. Generate a token for this exact origin.`;
|
||||
} else if (regexInvalid) {
|
||||
status.textContent = 'Invalid regular expression — fix the pattern to load the clickmap.';
|
||||
} else if (loadingServerClickmap) {
|
||||
status.textContent = 'Loading aggregate clickmap...';
|
||||
} else if (serverClickmapError != null) {
|
||||
@ -2887,7 +2877,7 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
}
|
||||
status.textContent = message;
|
||||
}
|
||||
status.classList.toggle('sdt-hm-token-status-error', regexInvalid || serverClickmapError != null || (token != null && tokenOrigin != null && tokenOrigin !== window.location.origin));
|
||||
status.classList.toggle('sdt-hm-token-status-error', serverClickmapError != null || (token != null && tokenOrigin != null && tokenOrigin !== window.location.origin));
|
||||
miniClicks.textContent = formatClickmapCount(aggregateClicks);
|
||||
miniElements.textContent = formatClickmapCount(groups.length);
|
||||
container.classList.toggle('sdt-hm-expanded', expanded);
|
||||
@ -2906,7 +2896,7 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
const requestId = serverClickmapRequestId + 1;
|
||||
serverClickmapRequestId = requestId;
|
||||
const isLatestRequest = () => requestId === serverClickmapRequestId;
|
||||
const token = getClickmapTokenFromStorage(app.projectId);
|
||||
const token = getClickmapTokenFromStorage();
|
||||
if (token == null) {
|
||||
serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] };
|
||||
serverClickmapError = null;
|
||||
@ -2914,7 +2904,7 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const tokenOrigin = getClickmapOriginFromStorage(app.projectId);
|
||||
const tokenOrigin = getClickmapOriginFromStorage();
|
||||
if (tokenOrigin != null && tokenOrigin !== window.location.origin) {
|
||||
serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] };
|
||||
serverClickmapError = null;
|
||||
@ -2923,16 +2913,6 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't round-trip a regex we know ClickHouse will reject; surface it locally.
|
||||
const pendingPattern = getEffectiveUrlPattern();
|
||||
if (filters.urlPatternMode === 'regex' && pendingPattern !== '' && !isValidRegexSource(pendingPattern)) {
|
||||
serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] };
|
||||
serverClickmapError = null;
|
||||
loadingServerClickmap = false;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
loadingServerClickmap = true;
|
||||
serverClickmapError = null;
|
||||
render();
|
||||
@ -2948,11 +2928,7 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
until: until.toISOString(),
|
||||
};
|
||||
if (effectiveUrlPattern !== '') {
|
||||
if (filters.urlPatternMode === 'regex') {
|
||||
body.route_regex = effectiveUrlPattern;
|
||||
} else {
|
||||
body.url_pattern = effectiveUrlPattern;
|
||||
}
|
||||
body.url_pattern = effectiveUrlPattern;
|
||||
} else {
|
||||
body.route_path = requestedPath;
|
||||
}
|
||||
@ -2978,7 +2954,7 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
}
|
||||
serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] };
|
||||
if (error instanceof Error && error.message.includes('Clickmap token does not belong to this project')) {
|
||||
clearClickmapTokenStorage(app.projectId);
|
||||
clearClickmapTokenStorage();
|
||||
serverClickmapError = 'The stored clickmap token belongs to another project. Generate a fresh token for this project.';
|
||||
} else {
|
||||
serverClickmapError = error instanceof Error ? error.message : 'Failed to load clickmap data';
|
||||
@ -2995,8 +2971,8 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
// navigates away with a token loaded, drop a sentinel so the dev tool on the
|
||||
// next page can auto-reopen straight back into the clickmap tab.
|
||||
const onBeforeUnloadResume = () => {
|
||||
const token = getClickmapTokenFromStorage(app.projectId);
|
||||
const tokenOrigin = getClickmapOriginFromStorage(app.projectId);
|
||||
const token = getClickmapTokenFromStorage();
|
||||
const tokenOrigin = getClickmapOriginFromStorage();
|
||||
if (token == null || (tokenOrigin != null && tokenOrigin !== window.location.origin)) {
|
||||
return;
|
||||
}
|
||||
@ -3011,7 +2987,7 @@ function createClickmapsTab(app: StackClientApp<true>, onBack: () => void): TabR
|
||||
overlayVisible = !overlayVisible;
|
||||
render();
|
||||
});
|
||||
backButton.addEventListener('click', onBack);
|
||||
closeButton.addEventListener('click', onClose);
|
||||
expandButton.addEventListener('click', () => {
|
||||
expanded = !expanded;
|
||||
render();
|
||||
@ -3511,7 +3487,6 @@ function createPanel(
|
||||
animateNextPanelGeometryChange();
|
||||
}
|
||||
|
||||
panel.classList.toggle('sdt-panel-clickmap', tabId === 'clickmaps');
|
||||
if (tabId === 'dashboard') {
|
||||
panel.classList.add('sdt-panel-fullscreen');
|
||||
panel.style.width = '';
|
||||
@ -3520,11 +3495,6 @@ function createPanel(
|
||||
}
|
||||
|
||||
panel.classList.remove('sdt-panel-fullscreen');
|
||||
if (tabId === 'clickmaps') {
|
||||
panel.style.width = '';
|
||||
panel.style.height = '';
|
||||
return;
|
||||
}
|
||||
panel.style.width = state.get().panelWidth + 'px';
|
||||
panel.style.height = state.get().panelHeight + 'px';
|
||||
}
|
||||
@ -3532,7 +3502,6 @@ function createPanel(
|
||||
const tabs = getTabsForApp(app);
|
||||
const storedActiveTab = state.get().activeTab;
|
||||
const activeTab = tabs.some((tab) => tab.id === storedActiveTab) ? storedActiveTab : DEFAULT_STATE.activeTab;
|
||||
let lastNonClickmapTab: TabId = activeTab === 'clickmaps' ? 'overview' : activeTab;
|
||||
|
||||
applyPanelMode(activeTab);
|
||||
|
||||
@ -3551,9 +3520,6 @@ function createPanel(
|
||||
const trailingControls = h('div', { className: 'sdt-tabbar-actions' }, docsLink, closeBtn);
|
||||
|
||||
const tabBar = createTabBar(tabs, activeTab, (id) => {
|
||||
if (id !== 'clickmaps') {
|
||||
lastNonClickmapTab = id as TabId;
|
||||
}
|
||||
state.update({ activeTab: id as TabId });
|
||||
applyPanelMode(id as TabId, { animate: true });
|
||||
showTab(id as TabId);
|
||||
@ -3579,22 +3545,6 @@ function createPanel(
|
||||
}
|
||||
}
|
||||
|
||||
let clickmapsCleanup: (() => void) | null = null;
|
||||
// The clickmap tab installs an overlay root, a MutationObserver, and
|
||||
// background polling. Unlike other panes it must not be cached-and-hidden:
|
||||
// tear it down (running its cleanup) whenever we leave it, so the overlay and
|
||||
// its work don't linger after the tab is closed.
|
||||
function teardownClickmapsPane() {
|
||||
const pane = mountedPanes.get('clickmaps');
|
||||
if (pane == null) return;
|
||||
if (clickmapsCleanup != null) {
|
||||
clickmapsCleanup();
|
||||
clickmapsCleanup = null;
|
||||
}
|
||||
pane.remove();
|
||||
mountedPanes.delete('clickmaps');
|
||||
}
|
||||
|
||||
function getOrCreatePane(tabId: TabId): HTMLElement {
|
||||
if (mountedPanes.has(tabId)) {
|
||||
return mountedPanes.get(tabId)!;
|
||||
@ -3608,17 +3558,6 @@ function createPanel(
|
||||
mountTab(pane, createOverviewTab(app));
|
||||
break;
|
||||
}
|
||||
case 'clickmaps': {
|
||||
const result = createClickmapsTab(app, () => {
|
||||
state.update({ activeTab: lastNonClickmapTab });
|
||||
applyPanelMode(lastNonClickmapTab, { animate: true });
|
||||
showTab(lastNonClickmapTab);
|
||||
});
|
||||
pane.appendChild(result.element);
|
||||
// Tracked separately from `cleanups` so it can run on tab-switch, not just unmount.
|
||||
clickmapsCleanup = result.cleanup ?? null;
|
||||
break;
|
||||
}
|
||||
case 'customize': {
|
||||
mountTab(pane, createComponentsTab(app));
|
||||
break;
|
||||
@ -3646,9 +3585,6 @@ function createPanel(
|
||||
}
|
||||
|
||||
function showTab(tabId: TabId) {
|
||||
if (tabId !== 'clickmaps') {
|
||||
teardownClickmapsPane();
|
||||
}
|
||||
const pane = getOrCreatePane(tabId);
|
||||
tabBar.setActive(tabId);
|
||||
for (const [, p] of mountedPanes) {
|
||||
@ -3713,7 +3649,6 @@ function createPanel(
|
||||
if (panelAnimationTimeout !== null) {
|
||||
clearTimeout(panelAnimationTimeout);
|
||||
}
|
||||
teardownClickmapsPane();
|
||||
for (const fn of cleanups) fn();
|
||||
},
|
||||
};
|
||||
@ -3763,17 +3698,6 @@ export function createDevTool(app: StackClientApp<true>): () => void {
|
||||
wrapper.appendChild(panel.element);
|
||||
}
|
||||
|
||||
function openClickmapPanel() {
|
||||
state.update({ activeTab: 'clickmaps', isOpen: true });
|
||||
if (panel) {
|
||||
const currentPanel = panel;
|
||||
panel = null;
|
||||
currentPanel.cleanup();
|
||||
currentPanel.element.remove();
|
||||
}
|
||||
openPanel();
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
if (!panel) return;
|
||||
state.update({ isOpen: false });
|
||||
@ -3800,10 +3724,42 @@ export function createDevTool(app: StackClientApp<true>): () => void {
|
||||
const trigger = createTrigger(togglePanel);
|
||||
wrapper.appendChild(trigger.element);
|
||||
|
||||
// Resume the clickmap panel after navigating to a new page. While the overlay
|
||||
// is mounted it drops a sentinel into sessionStorage on unload; if it's
|
||||
// present here, restore the clickmap tab as the active one and reopen the
|
||||
// panel so the user picks up where they left off.
|
||||
// Clickmaps is an independent feature with its own mount, completely separate
|
||||
// from the dev tool panel above: opening or closing one never touches the
|
||||
// other, and both can be on screen at once. It's driven entirely by the
|
||||
// dashboard-minted token (the update event + the navigation resume sentinel),
|
||||
// never by the dev tool trigger.
|
||||
let clickmapMount: { element: HTMLElement, cleanup: () => void } | null = null;
|
||||
|
||||
function openClickmap() {
|
||||
if (clickmapMount) return;
|
||||
const result = createClickmapsTab(app, () => closeClickmap());
|
||||
// Reuse the panel's clickmap-mode chrome (bottom-center positioning, no tab
|
||||
// bar) by replicating the structure those styles target.
|
||||
const element = h('div', { className: 'sdt-panel sdt-panel-clickmap' },
|
||||
h('div', { className: 'sdt-panel-inner' },
|
||||
h('div', { className: 'sdt-content' },
|
||||
h('div', { className: 'sdt-tab-layers' },
|
||||
h('div', { className: 'sdt-tab-pane sdt-tab-pane-active' }, result.element),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
clickmapMount = { element, cleanup: result.cleanup ?? (() => {}) };
|
||||
wrapper.appendChild(element);
|
||||
}
|
||||
|
||||
function closeClickmap() {
|
||||
if (!clickmapMount) return;
|
||||
const closing = clickmapMount;
|
||||
clickmapMount = null;
|
||||
closing.cleanup();
|
||||
closing.element.remove();
|
||||
}
|
||||
|
||||
// Resume the clickmap after navigating to a new page. While the overlay is
|
||||
// mounted it drops a sentinel into sessionStorage on unload; if it's present
|
||||
// here, reopen the clickmap so the user picks up where they left off.
|
||||
let shouldResumeClickmap = false;
|
||||
try {
|
||||
if (sessionStorage.getItem(CLICKMAP_OVERLAY_RESUME_STORAGE_KEY) === '1') {
|
||||
@ -3813,15 +3769,15 @@ export function createDevTool(app: StackClientApp<true>): () => void {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (shouldResumeClickmap) {
|
||||
state.update({ activeTab: 'clickmaps', isOpen: true });
|
||||
}
|
||||
|
||||
if (state.get().isOpen) {
|
||||
openPanel();
|
||||
}
|
||||
if (shouldResumeClickmap) {
|
||||
openClickmap();
|
||||
}
|
||||
|
||||
window.addEventListener(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, openClickmapPanel);
|
||||
window.addEventListener(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, openClickmap);
|
||||
|
||||
const removeRequestListener = app[stackAppInternalsSymbol].addRequestListener((entry: RequestLogEntry) => {
|
||||
const timestamp = Date.now();
|
||||
@ -3850,9 +3806,10 @@ export function createDevTool(app: StackClientApp<true>): () => void {
|
||||
setGlobalDevToolInstance(null);
|
||||
}
|
||||
trigger.cleanup();
|
||||
window.removeEventListener(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, openClickmapPanel);
|
||||
window.removeEventListener(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, openClickmap);
|
||||
removeRequestListener();
|
||||
panel?.cleanup();
|
||||
clickmapMount?.cleanup();
|
||||
if (root.parentNode) {
|
||||
root.parentNode.removeChild(root);
|
||||
}
|
||||
|
||||
@ -3008,26 +3008,26 @@ export const devToolCSS = `
|
||||
|
||||
.stack-devtool .sdt-hm-filter-reset {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-family: var(--sdt-font);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--sdt-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stack-devtool .sdt-hm-filter-reset:hover {
|
||||
background: var(--sdt-bg-hover);
|
||||
color: var(--sdt-accent-hover);
|
||||
}
|
||||
|
||||
.stack-devtool .sdt-hm-filter-reset-visible {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stack-devtool .sdt-hm-filter-input {
|
||||
@ -3149,6 +3149,88 @@ export const devToolCSS = `
|
||||
color: var(--sdt-error);
|
||||
}
|
||||
|
||||
.stack-devtool .sdt-hm-viewport-warning {
|
||||
display: none;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: var(--sdt-radius);
|
||||
border: 1px solid rgba(234, 179, 8, 0.24);
|
||||
background: var(--sdt-warning-muted);
|
||||
color: var(--sdt-text);
|
||||
}
|
||||
|
||||
.stack-devtool .sdt-hm-viewport-warning-visible {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stack-devtool .sdt-hm-viewport-warning-title {
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
color: var(--sdt-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stack-devtool .sdt-hm-viewport-warning-body {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
color: var(--sdt-text-secondary);
|
||||
}
|
||||
|
||||
.stack-devtool .sdt-hm-viewport-warning-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stack-devtool .sdt-hm-viewport-warning-action {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: var(--sdt-radius);
|
||||
border: 1px solid var(--sdt-border-subtle);
|
||||
background: var(--sdt-bg-elevated);
|
||||
padding: 4px 5px 4px 8px;
|
||||
}
|
||||
|
||||
.stack-devtool .sdt-hm-viewport-warning-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--sdt-text-tertiary);
|
||||
}
|
||||
|
||||
.stack-devtool .sdt-hm-viewport-warning-code {
|
||||
font-family: var(--sdt-font-mono);
|
||||
font-size: 11.5px;
|
||||
font-weight: 650;
|
||||
color: var(--sdt-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stack-devtool .sdt-hm-copy-btn {
|
||||
height: 22px;
|
||||
border: 1px solid var(--sdt-border-subtle);
|
||||
border-radius: 999px;
|
||||
background: var(--sdt-bg);
|
||||
color: var(--sdt-text-secondary);
|
||||
padding: 0 8px;
|
||||
font: inherit;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.stack-devtool .sdt-hm-copy-btn:hover {
|
||||
background: var(--sdt-bg-hover);
|
||||
border-color: var(--sdt-border);
|
||||
color: var(--sdt-text);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.stack-devtool .sdt-hm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user