diff --git a/next.config.ts b/next.config.ts index ab7fbf484..1e96de41c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -28,6 +28,7 @@ const contentSecurityPolicy = ` script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' https:; + frame-src 'self' http: https:; frame-ancestors 'self' ${frameAncestors}; `; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css index 0895311fb..96b578475 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css @@ -68,12 +68,18 @@ white-space: nowrap; } +.canvasWrapper { + width: 100%; + min-width: 0; +} + .canvas { position: relative; overflow: hidden; border: 1px solid var(--border-base); border-radius: 8px; background: var(--surface-sunken); + max-width: 100%; } .iframe { diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx index bbf5e784e..493c1327b 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx @@ -1,13 +1,30 @@ 'use client'; import { Column, Grid, Heading, Row, Text } from '@umami/react-zen'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { LoadingPanel } from '@/components/common/LoadingPanel'; import { useResultQuery, useWebsite } from '@/components/hooks'; import { formatLongNumber } from '@/lib/format'; import type { HeatmapMode, HeatmapPoint, HeatmapResult } from '@/queries/sql'; import styles from './Heatmap.module.css'; -const RENDER_WIDTH = 1024; +const MAX_RENDER_WIDTH = 1024; +const IFRAME_SANDBOX = 'allow-same-origin allow-scripts allow-forms allow-popups'; + +function useElementWidth() { + const ref = useRef(null); + const [width, setWidth] = useState(0); + useEffect(() => { + const el = ref.current; + if (!el) return; + const ro = new ResizeObserver(entries => { + const w = entries[0]?.contentRect.width ?? 0; + setWidth(w); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + return [ref, width] as const; +} interface ViewportBucket { width: number; @@ -36,20 +53,26 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, onModeChang const scroll = data?.scroll; return ( - - + + {urlPath ? ( mode === 'scroll' ? ( ) : ( - + ) ) : ( @@ -119,6 +142,27 @@ function PageList({ ); } +function buildIframeSrc( + websiteId: string | null, + domain: string | null, + urlPath: string, +): string | null { + // Self-record: data was captured from this very umami instance, so iframe the + // current origin instead of the website's stored domain (which often points to prod). + if (typeof window !== 'undefined' && websiteId && websiteId === process.env.selfRecord) { + return `${window.location.origin}${urlPath}`; + } + if (!domain) return null; + if (/^https?:\/\//i.test(domain)) return `${domain}${urlPath}`; + const isLocal = /^(localhost|127\.|0\.0\.0\.0|\[::1\])(:|\/|$)/i.test(domain); + const protocol = isLocal + ? typeof window !== 'undefined' + ? window.location.protocol + : 'http:' + : 'https:'; + return `${protocol}//${domain}${urlPath}`; +} + function pickViewport(points: HeatmapPoint[]): ViewportBucket | null { if (!points.length) return null; const buckets = new Map(); @@ -139,10 +183,12 @@ function pickViewport(points: HeatmapPoint[]): ViewportBucket | null { } function HeatmapView({ + websiteId, domain, urlPath, points, }: { + websiteId: string; domain: string | null; urlPath: string; points: HeatmapPoint[]; @@ -161,13 +207,16 @@ function HeatmapView({ [visible], ); + const [containerRef, containerWidth] = useElementWidth(); + if (!viewport || visible.length === 0) { return ; } - const scale = RENDER_WIDTH / viewport.width; + const renderWidth = containerWidth > 0 ? Math.min(containerWidth, MAX_RENDER_WIDTH) : 0; + const scale = renderWidth ? renderWidth / viewport.width : 0; const renderHeight = Math.round(viewport.height * scale); - const iframeSrc = domain ? `https://${domain}${urlPath}` : null; + const iframeSrc = buildIframeSrc(websiteId, domain, urlPath); return ( @@ -186,37 +235,41 @@ function HeatmapView({ )} -
- {showPage && iframeSrc && ( -