diff --git a/packages/scan/src/index.ts b/packages/scan/src/index.ts index f69723ba..d6cb0d0a 100644 --- a/packages/scan/src/index.ts +++ b/packages/scan/src/index.ts @@ -1,5 +1,6 @@ -import { init } from './install-hook'; // Initialize RDT hook +import { init } from "./install-hook"; // Initialize RDT hook init(); -export * from './core/index'; +export * from "./core/index"; +export * from "./new-outlines/outline-renderer"; diff --git a/packages/scan/src/new-outlines/canvas.ts b/packages/scan/src/new-outlines/canvas.ts index d4f936a2..fa6678f9 100644 --- a/packages/scan/src/new-outlines/canvas.ts +++ b/packages/scan/src/new-outlines/canvas.ts @@ -1,8 +1,8 @@ -import type { ActiveOutline, OutlineData } from './types'; +import type { ActiveOutline, OutlineData } from "./types"; export const OUTLINE_ARRAY_SIZE = 7; export const MONO_FONT = - 'Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace'; + "Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace"; export const INTERPOLATION_SPEED = 0.1; export const lerp = (start: number, end: number) => { @@ -11,10 +11,10 @@ export const lerp = (start: number, end: number) => { export const MAX_PARTS_LENGTH = 4; export const MAX_LABEL_LENGTH = 40; -export const TOTAL_FRAMES = 45; +export const TOTAL_DURATION_MS = 750; -export const primaryColor = '115,97,230'; -export const secondaryColor = '128,128,128'; +export const primaryColor = "115,97,230"; +export const secondaryColor = "128,128,128"; export const getLabelText = (outlines: ActiveOutline[]): string => { const nameByCount = new Map(); @@ -37,15 +37,15 @@ export const getLabelText = (outlines: ActiveOutline[]): string => { ([countA], [countB]) => countB - countA, ); const partsLength = partsEntries.length; - let labelText = ''; + let labelText = ""; for (let i = 0; i < partsLength; i++) { const [count, names] = partsEntries[i]; - let part = `${names.slice(0, MAX_PARTS_LENGTH).join(', ')} ×${count}`; + let part = `${names.slice(0, MAX_PARTS_LENGTH).join(", ")} ×${count}`; if (part.length > MAX_LABEL_LENGTH) { part = `${part.slice(0, MAX_LABEL_LENGTH)}…`; } if (i !== partsLength - 1) { - part += ', '; + part += ", "; } labelText += part; } @@ -69,6 +69,7 @@ export const updateOutlines = ( activeOutlines: Map, outlines: OutlineData[], ) => { + const now = performance.now(); for (const { id, name, count, x, y, width, height, didCommit } of outlines) { const outline: ActiveOutline = { id, @@ -78,7 +79,7 @@ export const updateOutlines = ( y, width, height, - frame: 0, + startTime: now, targetX: x, targetY: y, targetWidth: width, @@ -90,7 +91,7 @@ export const updateOutlines = ( const existingOutline = activeOutlines.get(key); if (existingOutline) { existingOutline.count++; - existingOutline.frame = 0; + existingOutline.startTime = now; existingOutline.targetX = x; existingOutline.targetY = y; existingOutline.targetWidth = width; @@ -119,7 +120,7 @@ export const initCanvas = ( canvas: HTMLCanvasElement | OffscreenCanvas, dpr: number, ) => { - const ctx = canvas.getContext('2d', { alpha: true }) as + const ctx = canvas.getContext("2d", { alpha: true }) as | CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; if (ctx) { @@ -134,6 +135,7 @@ export const drawCanvas = ( dpr: number, activeOutlines: Map, ) => { + const now = performance.now(); ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); const groupedOutlinesMap = new Map(); @@ -158,7 +160,7 @@ export const drawCanvas = ( targetY, targetWidth, targetHeight, - frame, + startTime, } = outline; if (targetX !== x) { outline.x = lerp(x, targetX); @@ -184,8 +186,8 @@ export const drawCanvas = ( groupedOutlinesMap.set(labelKey, [outline]); } - const alpha = 1 - frame / TOTAL_FRAMES; - outline.frame++; + const timeElapsedMs = now - startTime; + const alpha = Math.max(0, 1 - timeElapsedMs / TOTAL_DURATION_MS); const rect = rectMap.get(rectKey) || { x, @@ -227,12 +229,14 @@ export const drawCanvas = ( } >(); - ctx.textRendering = 'optimizeSpeed'; + ctx.textRendering = "optimizeSpeed"; for (const outlines of groupedOutlinesMap.values()) { const first = outlines[0]; - const { x, y, frame } = first; - const alpha = 1 - frame / TOTAL_FRAMES; + const { x, y, startTime } = first; + + const timeElapsedMs = now - startTime; + const alpha = Math.max(0, 1 - timeElapsedMs / TOTAL_DURATION_MS); const text = getLabelText(outlines); const { width } = ctx.measureText(text); const height = 11; @@ -252,7 +256,7 @@ export const drawCanvas = ( labelY = 0; } - if (frame > TOTAL_FRAMES) { + if (timeElapsedMs > TOTAL_DURATION_MS) { for (const outline of outlines) { activeOutlines.delete(String(outline.id)); } diff --git a/packages/scan/src/new-outlines/index.ts b/packages/scan/src/new-outlines/index.ts index 5d1aa18f..18318cc7 100644 --- a/packages/scan/src/new-outlines/index.ts +++ b/packages/scan/src/new-outlines/index.ts @@ -5,30 +5,20 @@ import { getFiberId, getNearestHostFibers, isCompositeFiber, -} from 'bippy'; -import { ReactScanInternals, Store, ignoredProps } from '~core/index'; -import { createInstrumentation } from '~core/instrumentation'; -import { readLocalStorage, removeLocalStorage } from '~web/utils/helpers'; -import { log, logIntro } from '~web/utils/log'; -import { inspectorUpdateSignal } from '~web/views/inspector/states'; +} from "bippy"; +import { ReactScanInternals, Store, ignoredProps } from "~core/index"; +import { createInstrumentation } from "~core/instrumentation"; +import { readLocalStorage, removeLocalStorage } from "~web/utils/helpers"; +import { log, logIntro } from "~web/utils/log"; +import { inspectorUpdateSignal } from "~web/views/inspector/states"; +import type { BlueprintOutline, OutlineData } from "./types"; import { - OUTLINE_ARRAY_SIZE, - drawCanvas, - initCanvas, - updateOutlines, - updateScroll, -} from './canvas'; -import type { ActiveOutline, BlueprintOutline, OutlineData } from './types'; - -// The worker code will be replaced at build time -const workerCode = '__WORKER_CODE__'; - -let worker: Worker | null = null; -let canvas: HTMLCanvasElement | null = null; -let ctx: CanvasRenderingContext2D | null = null; -let dpr = 1; -let animationFrameId: number | null = null; -const activeOutlines = new Map(); + CanvasOutlineRenderer, + OutlineRenderer, + WorkerOutlineRenderer, +} from "./outline-renderer"; + +let outlineRenderer: OutlineRenderer | null = null; const blueprintMap = new Map(); const blueprintMapKeys = new Set(); @@ -36,7 +26,7 @@ const blueprintMapKeys = new Set(); export const outlineFiber = (fiber: Fiber) => { if (!isCompositeFiber(fiber)) return; const name = - typeof fiber.type === 'string' ? fiber.type : getDisplayName(fiber); + typeof fiber.type === "string" ? fiber.type : getDisplayName(fiber); if (!name) return; const blueprint = blueprintMap.get(fiber); const nearestFibers = getNearestHostFibers(fiber); @@ -133,9 +123,6 @@ export const getBatchedRectMap = async function* ( } }; -const SupportedArrayBuffer = - typeof SharedArrayBuffer !== 'undefined' ? SharedArrayBuffer : ArrayBuffer; - export const flushOutlines = async () => { const elements: Element[] = []; @@ -186,11 +173,6 @@ export const flushOutlines = async () => { } if (blueprints.length > 0) { - const arrayBuffer = new SupportedArrayBuffer( - blueprints.length * OUTLINE_ARRAY_SIZE * 4, - ); - const sharedView = new Float32Array(arrayBuffer); - const blueprintNames = new Array(blueprints.length); let outlineData: OutlineData[] | undefined; for (let i = 0, len = blueprints.length; i < len; i++) { @@ -199,42 +181,21 @@ export const flushOutlines = async () => { const { x, y, width, height } = blueprintRects[i]; const { count, name, didCommit } = blueprint; - if (worker) { - const scaledIndex = i * OUTLINE_ARRAY_SIZE; - sharedView[scaledIndex] = id; - sharedView[scaledIndex + 1] = count; - sharedView[scaledIndex + 2] = x; - sharedView[scaledIndex + 3] = y; - sharedView[scaledIndex + 4] = width; - sharedView[scaledIndex + 5] = height; - sharedView[scaledIndex + 6] = didCommit; - blueprintNames[i] = name; - } else { - outlineData ||= new Array(blueprints.length); - outlineData[i] = { - id, - name, - count, - x, - y, - width, - height, - didCommit: didCommit as 0 | 1, - }; - } + outlineData ||= new Array(blueprints.length); + outlineData[i] = { + id, + name, + count, + x, + y, + width, + height, + didCommit: didCommit as 0 | 1, + }; } - if (worker) { - worker.postMessage({ - type: 'draw-outlines', - data: arrayBuffer, - names: blueprintNames, - }); - } else if (canvas && ctx && outlineData) { - updateOutlines(activeOutlines, outlineData); - if (!animationFrameId) { - animationFrameId = requestAnimationFrame(draw); - } + if (outlineData) { + outlineRenderer?.renderOutlines(outlineData); } } } @@ -245,22 +206,10 @@ export const flushOutlines = async () => { } }; -const draw = () => { - if (!ctx || !canvas) return; - - const shouldContinue = drawCanvas(ctx, canvas, dpr, activeOutlines); - - if (shouldContinue) { - animationFrameId = requestAnimationFrame(draw); - } else { - animationFrameId = null; - } -}; - const CANVAS_HTML_STR = ``; const IS_OFFSCREEN_CANVAS_WORKER_SUPPORTED = - typeof OffscreenCanvas !== 'undefined' && typeof Worker !== 'undefined'; + typeof OffscreenCanvas !== "undefined" && typeof Worker !== "undefined"; const getDpr = () => { return Math.min(window.devicePixelRatio || 1, 2); @@ -268,114 +217,57 @@ const getDpr = () => { export const getCanvasEl = () => { cleanup(); - const host = document.createElement('div'); - host.setAttribute('data-react-scan', 'true'); - const shadowRoot = host.attachShadow({ mode: 'open' }); + const host = document.createElement("div"); + host.setAttribute("data-react-scan", "true"); + const shadowRoot = host.attachShadow({ mode: "open" }); shadowRoot.innerHTML = CANVAS_HTML_STR; const canvasEl = shadowRoot.firstChild as HTMLCanvasElement; if (!canvasEl) return null; - dpr = getDpr(); - canvas = canvasEl; - - const { innerWidth, innerHeight } = window; - canvasEl.style.width = `${innerWidth}px`; - canvasEl.style.height = `${innerHeight}px`; - const width = innerWidth * dpr; - const height = innerHeight * dpr; - canvasEl.width = width; - canvasEl.height = height; + const dpr = getDpr(); + const size = { width: window.innerWidth, height: window.innerHeight }; if (IS_OFFSCREEN_CANVAS_WORKER_SUPPORTED) { try { - const useExtensionWorker = readLocalStorage('use-extension-worker'); - removeLocalStorage('use-extension-worker'); + const useExtensionWorker = readLocalStorage( + "use-extension-worker", + ); + removeLocalStorage("use-extension-worker"); if (useExtensionWorker) { - worker = new Worker( - URL.createObjectURL( - new Blob([workerCode], { type: 'application/javascript' }), - ), - ); - - const offscreenCanvas = canvasEl.transferControlToOffscreen(); - worker?.postMessage( - { - type: 'init', - canvas: offscreenCanvas, - width: canvasEl.width, - height: canvasEl.height, - dpr, - }, - [offscreenCanvas], - ); + outlineRenderer = new WorkerOutlineRenderer(canvasEl, size, dpr); } } catch (e) { // biome-ignore lint/suspicious/noConsole: Intended debug output - console.warn('Failed to initialize OffscreenCanvas worker:', e); + console.warn("Failed to initialize OffscreenCanvas worker:", e); } } - if (!worker) { - ctx = initCanvas(canvasEl, dpr) as CanvasRenderingContext2D; + if (!outlineRenderer) { + outlineRenderer = new CanvasOutlineRenderer(canvasEl, size, dpr); } - let isResizeScheduled = false; - window.addEventListener('resize', () => { - if (!isResizeScheduled) { - isResizeScheduled = true; - setTimeout(() => { - const width = window.innerWidth; - const height = window.innerHeight; - dpr = getDpr(); - canvasEl.style.width = `${width}px`; - canvasEl.style.height = `${height}px`; - if (worker) { - worker.postMessage({ - type: 'resize', - width, - height, - dpr, - }); - } else { - canvasEl.width = width * dpr; - canvasEl.height = height * dpr; - if (ctx) { - ctx.resetTransform(); - ctx.scale(dpr, dpr); - } - draw(); - } - isResizeScheduled = false; - }); - } + window.addEventListener("resize", () => { + const width = window.innerWidth; + const height = window.innerHeight; + outlineRenderer?.resize({ width, height }); }); let prevScrollX = window.scrollX; let prevScrollY = window.scrollY; let isScrollScheduled = false; - window.addEventListener('scroll', () => { + window.addEventListener("scroll", () => { + const { scrollX, scrollY } = window; + const deltaX = scrollX - prevScrollX; + const deltaY = scrollY - prevScrollY; + prevScrollX = scrollX; + prevScrollY = scrollY; if (!isScrollScheduled) { isScrollScheduled = true; setTimeout(() => { - const { scrollX, scrollY } = window; - const deltaX = scrollX - prevScrollX; - const deltaY = scrollY - prevScrollY; - prevScrollX = scrollX; - prevScrollY = scrollY; - if (worker) { - worker.postMessage({ - type: 'scroll', - deltaX, - deltaY, - }); - } else { - requestAnimationFrame(() => { - updateScroll(activeOutlines, deltaX, deltaY); - }); - } + outlineRenderer?.scroll(deltaX, deltaY); isScrollScheduled = false; }, 16 * 2); } @@ -403,7 +295,7 @@ export const stop = () => { }; export const cleanup = () => { - const host = document.querySelector('[data-react-scan]'); + const host = document.querySelector("[data-react-scan]"); if (host) { host.remove(); } @@ -431,7 +323,7 @@ export const isValidFiber = (fiber: Fiber) => { export const initReactScanInstrumentation = () => { if (hasStopped()) return; // todo: don't hardcode string getting weird ref error in iife when using process.env - const instrumentation = createInstrumentation('react-scan-devtools-0.1.0', { + const instrumentation = createInstrumentation("react-scan-devtools-0.1.0", { onCommitStart: () => { ReactScanInternals.options.value.onCommitStart?.(); }, @@ -456,8 +348,8 @@ export const initReactScanInstrumentation = () => { const isOverlayPaused = ReactScanInternals.instrumentation?.isPaused.value; const isInspectorInactive = - Store.inspectState.value.kind === 'inspect-off' || - Store.inspectState.value.kind === 'uninitialized'; + Store.inspectState.value.kind === "inspect-off" || + Store.inspectState.value.kind === "uninitialized"; const shouldFullyAbort = isOverlayPaused && isInspectorInactive; if (shouldFullyAbort) { @@ -471,7 +363,7 @@ export const initReactScanInstrumentation = () => { log(renders); } - if (Store.inspectState.value.kind === 'focused') { + if (Store.inspectState.value.kind === "focused") { inspectorUpdateSignal.value = Date.now(); } diff --git a/packages/scan/src/new-outlines/offscreen-canvas.worker.ts b/packages/scan/src/new-outlines/offscreen-canvas.worker.ts index 47ec93e0..4bcc5f4b 100644 --- a/packages/scan/src/new-outlines/offscreen-canvas.worker.ts +++ b/packages/scan/src/new-outlines/offscreen-canvas.worker.ts @@ -1,5 +1,5 @@ -import { OUTLINE_ARRAY_SIZE, drawCanvas, initCanvas } from './canvas'; -import type { ActiveOutline } from './types'; +import { OUTLINE_ARRAY_SIZE, drawCanvas, initCanvas } from "./canvas"; +import type { ActiveOutline } from "./types"; let canvas: OffscreenCanvas | null = null; let ctx: OffscreenCanvasRenderingContext2D | null = null; @@ -23,7 +23,7 @@ const draw = () => { self.onmessage = (event) => { const { type } = event.data; - if (type === 'init') { + if (type === "init") { canvas = event.data.canvas; dpr = event.data.dpr; @@ -36,7 +36,7 @@ self.onmessage = (event) => { if (!canvas || !ctx) return; - if (type === 'resize') { + if (type === "resize") { dpr = event.data.dpr; canvas.width = event.data.width * dpr; canvas.height = event.data.height * dpr; @@ -47,8 +47,9 @@ self.onmessage = (event) => { return; } - if (type === 'draw-outlines') { + if (type === "draw-outlines") { const { data, names } = event.data; + const now = performance.now(); const sharedView = new Float32Array(data); for (let i = 0; i < sharedView.length; i += OUTLINE_ARRAY_SIZE) { @@ -66,7 +67,7 @@ self.onmessage = (event) => { y, width, height, - frame: 0, + startTime: now, targetX: x, targetY: y, targetWidth: width, @@ -78,7 +79,7 @@ self.onmessage = (event) => { const existingOutline = activeOutlines.get(key); if (existingOutline) { existingOutline.count++; - existingOutline.frame = 0; + existingOutline.startTime = now; existingOutline.targetX = x; existingOutline.targetY = y; existingOutline.targetWidth = width; @@ -96,7 +97,7 @@ self.onmessage = (event) => { return; } - if (type === 'scroll') { + if (type === "scroll") { const { deltaX, deltaY } = event.data; for (const outline of activeOutlines.values()) { const newX = outline.x - deltaX; diff --git a/packages/scan/src/new-outlines/outline-renderer.ts b/packages/scan/src/new-outlines/outline-renderer.ts new file mode 100644 index 00000000..2892647f --- /dev/null +++ b/packages/scan/src/new-outlines/outline-renderer.ts @@ -0,0 +1,222 @@ +import { Size } from "~web/widget/types"; +import { drawCanvas, updateScroll } from "./canvas"; +import { ActiveOutline, OutlineData } from "./types"; + +export interface OutlineRenderer { + renderOutlines(outlines: OutlineData[]): void; + resize(size: Size): void; + scroll(deltaX: number, deltaY: number): void; + dispose(): void; +} + +export class CanvasOutlineRenderer implements OutlineRenderer { + private activeOutlines: Map = new Map(); + private animationFrameId: ReturnType | null = + null; + private ctx: CanvasRenderingContext2D | null; + private isResizeScheduled = false; + + constructor( + private canvas: HTMLCanvasElement, + private size: Size, + private dpr: number, + ) { + this.ctx = canvas.getContext("2d", { alpha: true }); + this.setCanvasSize(size); + } + + scroll(deltaX: number, deltaY: number): void { + updateScroll(this.activeOutlines, deltaX, deltaY); + } + + resize(size: Size): void { + this.size = size; + if (this.isResizeScheduled) return; + this.isResizeScheduled = true; + setTimeout(() => { + this.setCanvasSize(this.size); + this.draw(); + this.isResizeScheduled = false; + }); + } + + renderOutlines(outlines: OutlineData[]): void { + this.updateOutlines(outlines); + if (!this.animationFrameId) { + this.animationFrameId = requestAnimationFrame(this.draw); + } + } + + dispose(): void {} + + private setCanvasSize(size: Size) { + this.canvas.style.width = `${size.width}px`; + this.canvas.style.height = `${size.height}px`; + this.canvas.width = size.width * this.dpr; + this.canvas.height = size.height * this.dpr; + if (this.ctx) { + this.ctx.resetTransform(); + this.ctx.scale(this.dpr, this.dpr); + } + } + + private updateOutlines(outlines: OutlineData[]) { + const now = performance.now(); + for (const { + id, + name, + count, + x, + y, + width, + height, + didCommit, + } of outlines) { + const outline: ActiveOutline = { + id, + name, + count, + x, + y, + width, + height, + startTime: now, + targetX: x, + targetY: y, + targetWidth: width, + targetHeight: height, + didCommit, + }; + const key = String(outline.id); + + const existingOutline = this.activeOutlines.get(key); + if (existingOutline) { + existingOutline.count++; + existingOutline.startTime = now; + existingOutline.targetX = x; + existingOutline.targetY = y; + existingOutline.targetWidth = width; + existingOutline.targetHeight = height; + existingOutline.didCommit = didCommit; + } else { + this.activeOutlines.set(key, outline); + } + } + } + + private draw = () => { + if (!this.ctx || !this.canvas) { + return; + } + + const shouldContinue = drawCanvas( + this.ctx, + this.canvas, + this.dpr, + this.activeOutlines, + ); + + if (shouldContinue) { + this.animationFrameId = requestAnimationFrame(this.draw); + } else { + this.animationFrameId = null; + } + }; +} + +// The worker code will be replaced at build time +const workerCode = "__WORKER_CODE__"; +const OUTLINE_ARRAY_SIZE = 7; +const SupportedArrayBuffer = + typeof SharedArrayBuffer !== "undefined" ? SharedArrayBuffer : ArrayBuffer; + +export class WorkerOutlineRenderer implements OutlineRenderer { + private worker: Worker; + private isResizeScheduled = false; + + constructor( + private canvasEl: HTMLCanvasElement, + private size: Size, + private dpr: number, + ) { + this.worker = new Worker( + URL.createObjectURL( + new Blob([workerCode], { type: "application/javascript" }), + ), + ); + + this.setCanvasSize(size); + + const offscreenCanvas = canvasEl.transferControlToOffscreen(); + this.worker.postMessage( + { + type: "init", + canvas: offscreenCanvas, + width: size.width * dpr, + height: size.height * dpr, + dpr, + }, + [offscreenCanvas], + ); + } + + dispose(): void { + this.worker.terminate(); + } + + private setCanvasSize(size: Size) { + this.canvasEl.style.width = `${size.width}px`; + this.canvasEl.style.height = `${size.height}px`; + } + + renderOutlines(outlines: OutlineData[]): void { + const arrayBuffer = new SupportedArrayBuffer( + outlines.length * OUTLINE_ARRAY_SIZE * 4, + ); + const sharedView = new Float32Array(arrayBuffer); + const outlineNames = new Array(outlines.length); + + outlines.forEach((outline, i) => { + const { id, name, count, x, y, width, height, didCommit } = outline; + const scaledIndex = i * OUTLINE_ARRAY_SIZE; + sharedView[scaledIndex] = id; + sharedView[scaledIndex + 1] = count; + sharedView[scaledIndex + 2] = x; + sharedView[scaledIndex + 3] = y; + sharedView[scaledIndex + 4] = width; + sharedView[scaledIndex + 5] = height; + sharedView[scaledIndex + 6] = didCommit; + outlineNames[i] = name; + }); + + this.worker.postMessage({ + type: "draw-outlines", + data: arrayBuffer, + names: outlineNames, + }); + } + + resize(size: Size): void { + this.size = size; + if (this.isResizeScheduled) return; + this.isResizeScheduled = true; + setTimeout(() => { + this.setCanvasSize(this.size); + this.worker.postMessage({ + type: "resize", + width: this.size.width, + height: this.size.height, + dpr: this.dpr, + }); + this.isResizeScheduled = false; + }); + } + + scroll(deltaX: number, deltaY: number): void { + this.worker.postMessage({ + type: "scroll", + deltaX, + deltaY, + }); + } +} diff --git a/packages/scan/src/new-outlines/types.ts b/packages/scan/src/new-outlines/types.ts index b6f79387..7a62a7aa 100644 --- a/packages/scan/src/new-outlines/types.ts +++ b/packages/scan/src/new-outlines/types.ts @@ -52,7 +52,7 @@ export interface ActiveOutline { targetY: number; targetWidth: number; targetHeight: number; - frame: number; + startTime: number; didCommit: 1 | 0; }