import { signal } from '@preact/signals'; import { iife } from './performance-utils'; export let highlightCanvas: HTMLCanvasElement | null = null; export let highlightCtx: CanvasRenderingContext2D | null = null; let animationFrame: number | null = null; type TransitionHighlightState = { kind: 'transition'; transitionTo: { name: string; rects: Array; alpha: number; }; current: { name: string; rects: Array; alpha: number; } | null; }; type HighlightState = | TransitionHighlightState | { kind: 'move-out'; current: { name: string; rects: Array; alpha: number; }; } | { kind: 'idle'; current: { name: string; rects: Array; } | null; }; export const HighlightStore = signal({ kind: 'idle', current: null, }); let currFrame: ReturnType | null = null; let lastFrameTime = 0; const FADE_SPEED = 1.8; const MAX_DELTA = 0.05; const DEFAULT_DELTA = 1 / 60; export const drawHighlights = () => { if (currFrame) { cancelAnimationFrame(currFrame); } currFrame = requestAnimationFrame((timestamp) => { if (!highlightCanvas || !highlightCtx) { return; } const dt = lastFrameTime ? Math.min((timestamp - lastFrameTime) / 1000, MAX_DELTA) : DEFAULT_DELTA; lastFrameTime = timestamp; const step = FADE_SPEED * dt; highlightCtx.clearRect(0, 0, highlightCanvas.width, highlightCanvas.height); const color = 'hsl(271, 76%, 53%)'; const state = HighlightStore.value; const { alpha, current } = iife(() => { switch (state.kind) { case 'transition': { const current = state.current?.alpha && state.current.alpha > 0 ? state.current : state.transitionTo; return { alpha: current ? current.alpha : 0, current, }; } case 'move-out': { return { alpha: state.current?.alpha ?? 0, current: state.current }; } case 'idle': { return { alpha: 1, current: state.current }; } } // exhaustive check state satisfies never; }); current?.rects.forEach((rect) => { if (!highlightCtx) { // typescript cant tell this closure is synchronous/non-escaping return; } highlightCtx.shadowColor = color; highlightCtx.shadowBlur = 6; highlightCtx.strokeStyle = color; highlightCtx.lineWidth = 2; highlightCtx.globalAlpha = alpha; highlightCtx.beginPath(); highlightCtx.rect(rect.left, rect.top, rect.width, rect.height); highlightCtx.stroke(); highlightCtx.shadowBlur = 0; highlightCtx.beginPath(); highlightCtx.rect(rect.left, rect.top, rect.width, rect.height); highlightCtx.stroke(); }); switch (state.kind) { case 'move-out': { if (state.current.alpha === 0) { HighlightStore.value = { kind: 'idle', current: null, }; lastFrameTime = 0; return; } if (state.current.alpha <= 0.01) { state.current.alpha = 0; } state.current.alpha = Math.max(0, state.current.alpha - step); drawHighlights(); return; } case 'transition': { if (state.current && state.current.alpha > 0) { state.current.alpha = Math.max(0, state.current.alpha - step); drawHighlights(); return; } // invariant, state.current.alpha === 0 if (state.transitionTo.alpha === 1) { HighlightStore.value = { kind: 'idle', current: state.transitionTo, }; lastFrameTime = 0; return; } state.transitionTo.alpha = Math.min(state.transitionTo.alpha + step, 1); drawHighlights(); } case 'idle': { // no-op lastFrameTime = 0; return; } } }); }; let handleResizeListener: (() => void) | null = null; export const createHighlightCanvas = (root: HTMLElement) => { highlightCanvas = document.createElement('canvas'); highlightCtx = highlightCanvas.getContext('2d', { alpha: true }); if (!highlightCtx) return null; const dpr = window.devicePixelRatio || 1; const { innerWidth, innerHeight } = window; highlightCanvas.style.width = `${innerWidth}px`; highlightCanvas.style.height = `${innerHeight}px`; highlightCanvas.width = innerWidth * dpr; highlightCanvas.height = innerHeight * dpr; highlightCanvas.style.position = 'fixed'; highlightCanvas.style.left = '0'; highlightCanvas.style.top = '0'; highlightCanvas.style.pointerEvents = 'none'; highlightCanvas.style.zIndex = '2147483600'; highlightCtx.scale(dpr, dpr); root.appendChild(highlightCanvas); if (handleResizeListener) { window.removeEventListener('resize', handleResizeListener); } const handleResize = () => { if (!highlightCanvas || !highlightCtx) return; const dpr = window.devicePixelRatio || 1; const { innerWidth, innerHeight } = window; highlightCanvas.style.width = `${innerWidth}px`; highlightCanvas.style.height = `${innerHeight}px`; highlightCanvas.width = innerWidth * dpr; highlightCanvas.height = innerHeight * dpr; highlightCtx.scale(dpr, dpr); drawHighlights(); }; handleResizeListener = handleResize; window.addEventListener('resize', handleResize); HighlightStore.subscribe(() => { requestAnimationFrame(() => { drawHighlights(); }); }); return cleanup; }; export function cleanup() { if (animationFrame) { cancelAnimationFrame(animationFrame); animationFrame = null; } if (highlightCanvas?.parentNode) { highlightCanvas.parentNode.removeChild(highlightCanvas); } highlightCanvas = null; highlightCtx = null; }