diff --git a/.changeset/highlight-updates.md b/.changeset/highlight-updates.md index 1d78947..073d5bd 100644 --- a/.changeset/highlight-updates.md +++ b/.changeset/highlight-updates.md @@ -5,6 +5,6 @@ Add fine-grained reactive update highlighter with frequency color progression: - Toggle via `Alt+Shift+H` shortcut or the highlight icon in the dock toolbar, with state persisted in local storage. -- Flashes fine-grained DOM mutations in real time, visualizing surgical reactivity where only updating text nodes and SVG icons flash without coarse component bounding boxes. +- Outlines each DOM change as it happens. A text change gets a box around the changed text only, one per line it covers. Other changes get a box around the element. - Frequency-based color progression within an 800ms window: Light Blue (1), Purple (2-3), Pink (4-7), and Red (8+). -- Pixel-perfect ghost overlay using deep DOM node cloning in a dedicated portal layer, preserving typography, line-height, text transforms, and SVG stroke/fill colors. +- The boxes are drawn on one canvas in a shadow root, so they add nothing to the page and page styles cannot reach them. They follow the page when it scrolls. One frame draws at most 200 of them, and the fade turns off when the user prefers reduced motion. diff --git a/.changeset/keyed-panel-rows.md b/.changeset/keyed-panel-rows.md new file mode 100644 index 0000000..a68aac8 --- /dev/null +++ b/.changeset/keyed-panel-rows.md @@ -0,0 +1,5 @@ +--- +'@solidjs/start-devtools': patch +--- + +The Ownership tree rows and the Reactivity graph nodes and edges are now matched by id between refreshes. Before, every refresh rebuilt all of them, so an app that writes a signal every frame kept the page at about 15 to 20 frames per second while one of these panels was open. diff --git a/examples/demo/src/App.tsx b/examples/demo/src/App.tsx index fb9b421..7b9fa4e 100644 --- a/examples/demo/src/App.tsx +++ b/examples/demo/src/App.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, For, Loading, Show } from 'solid-js'; +import { createEffect, createMemo, createSignal, For, Loading, onSettled, Show } from 'solid-js'; import { createOrder, SEED_ORDERS, type Order, type OrderStatus } from './orders.js'; import { reportCall } from './report-call.js'; import './styles.css'; @@ -24,6 +24,41 @@ function Boom(): never { throw new Error('The demo threw this on purpose.'); } +/** + * Updates on every animation frame while it runs: a counter for a text + * change, and a dot whose style changes for an element change. Useful to + * watch the update highlight while scrolling or resizing. + */ +function Ticker() { + const [running, setRunning] = createSignal(true, { name: 'ticker-running' }); + const [frame, setFrame] = createSignal(0, { name: 'ticker-frame' }); + + onSettled(() => { + let id = requestAnimationFrame(function tick() { + if (running()) setFrame((value) => value + 1); + id = requestAnimationFrame(tick); + }); + return () => cancelAnimationFrame(id); + }); + + return ( +
+ Frames + {frame()} +
+
+
+ +
+ ); +} + export default function App() { const [orders, setOrders] = createSignal(SEED_ORDERS, { name: 'orders' }); const [query, setQuery] = createSignal('', { name: 'query' }); @@ -143,6 +178,7 @@ export default function App() { server function through an async memo +
diff --git a/examples/demo/src/styles.css b/examples/demo/src/styles.css index 9c42817..917150b 100644 --- a/examples/demo/src/styles.css +++ b/examples/demo/src/styles.css @@ -125,6 +125,28 @@ button.link { font-size: 1.25rem; } +.ticker-track { + position: relative; + height: 0.5rem; + margin: 0.25rem 0; + border-radius: 999px; + background: var(--border); +} + +.ticker-dot { + position: absolute; + top: 0; + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + background: var(--accent); +} + +.ticker-toggle { + align-self: flex-start; + padding: 0; +} + .stat-note { color: var(--muted); font-size: 0.6875rem; diff --git a/src/dev-toolbar/highlight-updates/canvas.test.ts b/src/dev-toolbar/highlight-updates/canvas.test.ts new file mode 100644 index 0000000..979e6e8 --- /dev/null +++ b/src/dev-toolbar/highlight-updates/canvas.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; +import { boxAlpha, drawBoxes, type FlashBox } from './canvas.js'; +import { FADE_OUT_DURATION_MS } from './tracker.js'; + +function box(start: number): FlashBox { + return { + left: 10, + top: 20, + width: 80, + height: 16, + color: '#4483ec', + background: 'rgba(68, 131, 236, 0.22)', + start, + }; +} + +function mockContext() { + return { + clearRect: vi.fn(), + beginPath: vi.fn(), + roundRect: vi.fn(), + fill: vi.fn(), + stroke: vi.fn(), + globalAlpha: 1, + lineWidth: 1, + fillStyle: '', + strokeStyle: '', + }; +} + +describe('boxAlpha', () => { + it('holds, then fades to 0 at the end of the duration', () => { + const b = box(0); + expect(boxAlpha(b, 0, true)).toBe(1); + expect(boxAlpha(b, FADE_OUT_DURATION_MS * 0.1, true)).toBe(1); + const middle = boxAlpha(b, FADE_OUT_DURATION_MS * 0.6, true); + expect(middle).toBeGreaterThan(0); + expect(middle).toBeLessThan(1); + expect(boxAlpha(b, FADE_OUT_DURATION_MS, true)).toBe(0); + }); + + it('stays opaque until the end without the fade', () => { + const b = box(0); + expect(boxAlpha(b, FADE_OUT_DURATION_MS * 0.9, false)).toBe(1); + expect(boxAlpha(b, FADE_OUT_DURATION_MS, false)).toBe(0); + }); +}); + +describe('drawBoxes', () => { + it('draws the boxes still showing and drops the rest', () => { + const ctx = mockContext(); + const fresh = box(1000); + const old = box(1000 - FADE_OUT_DURATION_MS); + + const showing = drawBoxes( + ctx as unknown as CanvasRenderingContext2D, + 800, + 600, + [fresh, old], + 1000, + true, + ); + + expect(showing).toEqual([fresh]); + expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 800, 600); + expect(ctx.roundRect).toHaveBeenCalledTimes(1); + // The border sits inside the box. + expect(ctx.roundRect).toHaveBeenCalledWith(10.75, 20.75, 78.5, 14.5, 3); + expect(ctx.strokeStyle).toBe('#4483ec'); + expect(ctx.globalAlpha).toBe(1); + }); +}); diff --git a/src/dev-toolbar/highlight-updates/canvas.ts b/src/dev-toolbar/highlight-updates/canvas.ts new file mode 100644 index 0000000..d3f7d0c --- /dev/null +++ b/src/dev-toolbar/highlight-updates/canvas.ts @@ -0,0 +1,64 @@ +import type { UpdateRect } from './rects.js'; +import { FADE_OUT_DURATION_MS } from './tracker.js'; + +export interface FlashBox extends UpdateRect { + /** The border color, as `#rrggbb`. */ + color: string; + /** The fill color. */ + background: string; + /** When the box appeared, in `performance.now()` time. */ + start: number; +} + +/** The part of the fade a box stays fully opaque, so a short update is still seen. */ +const HOLD = 0.15; +const BORDER_WIDTH = 1.5; +const RADIUS = 3; + +/** How opaque a box is at `now`: 1 during the hold, then down to 0 at the end. */ +export function boxAlpha(box: FlashBox, now: number, fade: boolean): number { + const progress = (now - box.start) / FADE_OUT_DURATION_MS; + if (progress >= 1) return 0; + if (!fade || progress <= HOLD) return 1; + return 1 - (progress - HOLD) / (1 - HOLD); +} + +/** + * Clears the canvas and draws every box that has not run out. The context is + * a parameter, so the same code can draw on an OffscreenCanvas in a worker. + * Returns the boxes still showing, for the next frame. + */ +export function drawBoxes( + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, + width: number, + height: number, + boxes: FlashBox[], + now: number, + fade: boolean, +): FlashBox[] { + ctx.clearRect(0, 0, width, height); + const showing: FlashBox[] = []; + for (const box of boxes) { + const alpha = boxAlpha(box, now, fade); + if (alpha <= 0) continue; + showing.push(box); + // The border is drawn inside the box, as `box-sizing: border-box` would. + const inset = BORDER_WIDTH / 2; + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.roundRect( + box.left + inset, + box.top + inset, + Math.max(0, box.width - BORDER_WIDTH), + Math.max(0, box.height - BORDER_WIDTH), + RADIUS, + ); + ctx.fillStyle = box.background; + ctx.fill(); + ctx.lineWidth = BORDER_WIDTH; + ctx.strokeStyle = box.color; + ctx.stroke(); + } + ctx.globalAlpha = 1; + return showing; +} diff --git a/src/dev-toolbar/highlight-updates/clone.test.ts b/src/dev-toolbar/highlight-updates/clone.test.ts deleted file mode 100644 index 4dfaa47..0000000 --- a/src/dev-toolbar/highlight-updates/clone.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createGhostClone } from './clone.js'; -import { getFlashColors } from './tracker.js'; - -function createMockStyle(): any { - const store: Record = {}; - const styleObj = { - setProperty(name: string, value: string) { - store[name] = value; - const camel = name.replace(/-([a-z])/g, (_, g) => g.toUpperCase()); - store[camel] = value; - (styleObj as any)[camel] = value; - }, - }; - return new Proxy(styleObj, { - get(target, prop: string) { - if (prop in target) return (target as any)[prop]; - return store[prop]; - }, - set(_target, prop: string, value: string) { - store[prop] = value; - const kebab = prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); - store[kebab] = value; - return true; - }, - }); -} - -class MockNode { - public nodeType: number; - public textContent: string; - public parentNode: MockNode | null = null; - public parentElement: MockElement | null = null; - public childNodes: MockNode[] = []; - - constructor(nodeType: number, textContent = '') { - this.nodeType = nodeType; - this.textContent = textContent; - } - - public appendChild(child: MockNode) { - child.parentNode = this; - if (this instanceof MockElement) { - child.parentElement = this; - } - this.childNodes.push(child); - } - - public replaceChild(newChild: MockNode, oldChild: MockNode) { - const index = this.childNodes.indexOf(oldChild); - if (index !== -1) { - newChild.parentNode = this; - if (this instanceof MockElement) { - newChild.parentElement = this; - } - this.childNodes[index] = newChild; - } - } - - public contains(target: MockNode): boolean { - if (target === this) return true; - for (const child of this.childNodes) { - if (child.contains(target)) return true; - } - return false; - } -} - -class MockElement extends MockNode { - public tagName: string; - public style = createMockStyle(); - public attributes: Record = {}; - public isConnected = true; - - constructor(tagName: string) { - super(1, ''); - this.tagName = tagName.toUpperCase(); - } - - public setAttribute(name: string, value: string) { - this.attributes[name] = value; - } - - public getAttribute(name: string): string | null { - return this.attributes[name] ?? null; - } - - public removeAttribute(name: string) { - delete this.attributes[name]; - } - - public getBoundingClientRect() { - return { - left: 10, - top: 20, - width: 100, - height: 30, - bottom: 50, - right: 110, - x: 10, - y: 20, - toJSON: () => {}, - }; - } - - public cloneNode(deep = true): MockElement { - const clone = new (this.constructor as any)(this.tagName); - clone.attributes = { ...this.attributes }; - Object.assign(clone.style, this.style); - if (deep) { - for (const child of this.childNodes) { - const childClone = (child as any).cloneNode ? (child as any).cloneNode(true) : child; - clone.appendChild(childClone); - } - } - return clone; - } - - public querySelectorAll(selector: string): MockElement[] { - const results: MockElement[] = []; - const walk = (node: MockNode) => { - for (const child of node.childNodes) { - if (child instanceof MockElement) { - if (selector === '*' || (selector === '[id]' && child.getAttribute('id'))) { - results.push(child); - } else if (selector.toLowerCase() === child.tagName.toLowerCase()) { - results.push(child); - } - walk(child); - } - } - }; - walk(this); - return results; - } - - public querySelector(selector: string): MockElement | null { - return this.querySelectorAll(selector)[0] ?? null; - } - - public closest(selector: string): MockElement | null { - if (selector.toLowerCase() === this.tagName.toLowerCase()) return this; - return this.parentElement ? this.parentElement.closest(selector) : null; - } -} - -class MockSVGElement extends MockElement { - public ownerSVGElement: MockSVGElement | null = null; -} - -class MockTextNode extends MockNode { - constructor(text: string) { - super(3, text); - } - - public cloneNode(): MockTextNode { - return new MockTextNode(this.textContent); - } -} - -describe('createGhostClone', () => { - const originalDocument = globalThis.document; - const originalWindow = globalThis.window; - const originalSVGElement = (globalThis as any).SVGElement; - const originalHTMLElement = (globalThis as any).HTMLElement; - - beforeEach(() => { - (globalThis as any).HTMLElement = MockElement; - (globalThis as any).SVGElement = MockSVGElement; - (globalThis as any).NodeFilter = { SHOW_ALL: 1 }; - - const mockDoc: any = { - createElement: (tag: string) => new MockElement(tag), - createTreeWalker: (root: MockNode) => { - const all: MockNode[] = []; - const collect = (n: MockNode) => { - all.push(n); - for (const c of n.childNodes) collect(c); - }; - collect(root); - let idx = 0; - return { - currentNode: all[0], - nextNode: () => { - idx++; - return all[idx] ?? null; - }, - }; - }, - }; - - const mockWin: any = { - getComputedStyle: (_el: any) => ({ - fontFamily: 'ui-monospace, monospace', - fontSize: '14px', - fontWeight: '500', - fontStyle: 'normal', - lineHeight: '20px', - letterSpacing: '0px', - textTransform: 'none', - textAlign: 'left', - textIndent: '0px', - whiteSpace: 'nowrap', - wordBreak: 'normal', - tabSize: '2', - boxSizing: 'border-box', - paddingTop: '4px', - paddingRight: '8px', - paddingBottom: '4px', - paddingLeft: '8px', - borderRadius: '6px', - display: 'block', - stroke: 'none', - fill: 'none', - strokeWidth: '0px', - }), - }; - - globalThis.document = mockDoc; - globalThis.window = mockWin; - }); - - afterEach(() => { - globalThis.document = originalDocument; - globalThis.window = originalWindow; - (globalThis as any).SVGElement = originalSVGElement; - (globalThis as any).HTMLElement = originalHTMLElement; - }); - - it('returns null when element has 0 width or height', () => { - const el = new MockElement('div'); - vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ - left: 0, - top: 0, - width: 0, - height: 0, - bottom: 0, - right: 0, - x: 0, - y: 0, - toJSON: () => {}, - }); - - expect(createGhostClone(el as unknown as Node, 1)).toBeNull(); - }); - - it('gives SVGs text-like ghost treatment with stroke/fill/color flashing', () => { - const svg = new MockSVGElement('svg'); - svg.setAttribute('stroke', 'currentColor'); - const path = new MockSVGElement('path'); - path.ownerSVGElement = svg; - svg.appendChild(path); - - const colors = getFlashColors(1); - const clone = createGhostClone(svg as unknown as Node, 1); - expect(clone).not.toBeNull(); - expect(clone).toBeInstanceOf(MockSVGElement); - - const svgClone = clone as unknown as MockSVGElement; - expect(svgClone.style.background).toBe('transparent'); - expect(svgClone.style.outline).toBe('none'); - expect(svgClone.style.color).toBe(colors.color); - expect(svgClone.style.stroke).toBe(colors.color); - }); - - it('finds SVG root and flashes icon when an inner path updates', () => { - const svg = new MockSVGElement('svg'); - const path = new MockSVGElement('path'); - path.setAttribute('fill', 'currentColor'); - path.ownerSVGElement = svg; - svg.appendChild(path); - - const colors = getFlashColors(2); - const clone = createGhostClone(path as unknown as Node, 2); - expect(clone).not.toBeNull(); - expect(clone).toBeInstanceOf(MockSVGElement); - expect((clone as any).tagName.toLowerCase()).toBe('svg'); - - const svgClone = clone as unknown as MockSVGElement; - expect(svgClone.style.color).toBe(colors.color); - const pathClone = svgClone.querySelector('path'); - expect(pathClone?.style.fill).toBe(colors.color); - }); - - it('strips ids, names, frame sources and autoplay from the clone', () => { - const div = new MockElement('div'); - div.setAttribute('id', 'panel'); - const radio = new MockElement('input'); - radio.setAttribute('name', 'choice'); - const frame = new MockElement('iframe'); - frame.setAttribute('src', 'https://example.com'); - const video = new MockElement('video'); - video.setAttribute('autoplay', ''); - div.appendChild(radio); - div.appendChild(frame); - div.appendChild(video); - - const clone = createGhostClone(div as unknown as Node, 1) as unknown as MockElement; - expect(clone.getAttribute('id')).toBeNull(); - expect(clone.querySelector('input')?.getAttribute('name')).toBeNull(); - expect(clone.querySelector('iframe')?.getAttribute('src')).toBe('about:blank'); - expect(clone.querySelector('video')?.getAttribute('autoplay')).toBeNull(); - - expect(div.getAttribute('id')).toBe('panel'); - expect(radio.getAttribute('name')).toBe('choice'); - }); - - it('colors text inside div clones with the same update color and keeps box outline/tint', () => { - const div = new MockElement('div'); - const span = new MockElement('span'); - span.textContent = '// entry-server.tsx'; - div.appendChild(span); - - const colors = getFlashColors(1); - const clone = createGhostClone(div as unknown as Node, 1); - expect(clone).not.toBeNull(); - expect(clone).toBeInstanceOf(MockElement); - - const htmlClone = clone as unknown as MockElement; - expect(htmlClone.style.backgroundColor).toBe(colors.background); - expect(htmlClone.style.outline).toBe(`1.5px solid ${colors.color}`); - expect(htmlClone.style.color).toBe(colors.color); - expect(htmlClone.style.fontFamily).toBe('ui-monospace, monospace'); - - const spanClone = htmlClone.querySelector('span'); - expect(spanClone).not.toBeNull(); - expect(spanClone?.style.color).toBe(colors.color); - }); - - it('preserves text layout while highlighting only target text for text nodes', () => { - const p = new MockElement('p'); - p.appendChild(new MockTextNode('Prefix ')); - const target = new MockTextNode('Counter 42'); - p.appendChild(target); - p.appendChild(new MockTextNode(' Suffix')); - - const colors = getFlashColors(1); - const clone = createGhostClone(target as unknown as Node, 1); - expect(clone).not.toBeNull(); - - const htmlClone = clone as unknown as MockElement; - expect(htmlClone.style.background).toBe('transparent'); - expect(htmlClone.style.outline).toBe('none'); - expect(htmlClone.style.fontFamily).toBe('ui-monospace, monospace'); - - const spans = htmlClone.querySelectorAll('span'); - const highlighted = spans.find((s) => s.style.color === colors.color); - expect(highlighted).toBeDefined(); - expect(highlighted?.textContent).toBe('Counter 42'); - expect(highlighted?.style.color).toBe(colors.color); - }); -}); diff --git a/src/dev-toolbar/highlight-updates/clone.ts b/src/dev-toolbar/highlight-updates/clone.ts deleted file mode 100644 index f2be9a2..0000000 --- a/src/dev-toolbar/highlight-updates/clone.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { getFlashColors, FADE_OUT_DURATION_MS } from './tracker.js'; - -function getNodePath(root: Node, target: Node): number[] { - const path: number[] = []; - let current: Node | null = target; - while (current && current !== root) { - const parent: Node | null = current.parentNode; - if (!parent) break; - const index = Array.prototype.indexOf.call(parent.childNodes, current); - path.unshift(index); - current = parent; - } - return path; -} - -function getNodeByPath(root: Node, path: number[]): Node | null { - let current: Node = root; - for (const index of path) { - if (!current.childNodes || !current.childNodes[index]) return null; - current = current.childNodes[index]; - } - return current; -} - -function findSvgRoot(element: Element): SVGElement | null { - if (typeof SVGElement !== 'undefined' && element instanceof SVGElement) { - if (element.tagName.toLowerCase() === 'svg') { - return element; - } - return element.ownerSVGElement ?? (element.closest ? element.closest('svg') : null); - } - if (element.tagName.toLowerCase() === 'svg') { - return element as unknown as SVGElement; - } - return element.closest ? (element.closest('svg') as SVGElement | null) : null; -} - -function applySvgColors(cloneSvg: SVGElement, sourceSvg: SVGElement, color: string): void { - const win = - sourceSvg.ownerDocument?.defaultView ?? (typeof window !== 'undefined' ? window : null); - - const cloneElements: SVGElement[] = [ - cloneSvg, - ...Array.from(cloneSvg.querySelectorAll('*')), - ]; - const sourceElements: SVGElement[] = [ - sourceSvg, - ...Array.from(sourceSvg.querySelectorAll('*')), - ]; - - for (let i = 0; i < cloneElements.length; i++) { - const cloneEl = cloneElements[i]; - const sourceEl = sourceElements[i]; - if (!cloneEl || !sourceEl) continue; - - cloneEl.style.color = color; - - if (win) { - const computed = win.getComputedStyle(sourceEl); - const stroke = computed.stroke; - const fill = computed.fill; - const strokeWidth = computed.strokeWidth; - - const attrStroke = sourceEl.getAttribute('stroke'); - const attrFill = sourceEl.getAttribute('fill'); - - const hasVisibleStroke = - (stroke && stroke !== 'none' && stroke !== 'transparent' && strokeWidth !== '0px') || - (attrStroke && attrStroke !== 'none'); - - if (hasVisibleStroke) { - cloneEl.style.setProperty('stroke', color, 'important'); - } - - const hasVisibleFill = - (fill && fill !== 'none' && fill !== 'transparent') || (attrFill && attrFill !== 'none'); - - if (hasVisibleFill) { - cloneEl.style.setProperty('fill', color, 'important'); - } - } else { - cloneEl.style.setProperty('stroke', color, 'important'); - cloneEl.style.setProperty('fill', color, 'important'); - } - } -} - -/** - * Strips what would make the clone act like the original once it is in the - * document: duplicate ids, radio groups that would take the checked state from - * the original, and frames and media that would load or play a second time. - */ -function detachClone(clone: Element): void { - for (const element of [clone, ...Array.from(clone.querySelectorAll('*'))]) { - element.removeAttribute('id'); - element.removeAttribute('name'); - const tag = element.tagName.toLowerCase(); - if (tag === 'iframe') { - element.removeAttribute('srcdoc'); - element.setAttribute('src', 'about:blank'); - } else if (tag === 'video' || tag === 'audio') { - element.removeAttribute('autoplay'); - } - } -} - -/** - * Creates an exact deep clone of the element that contains the updating node, - * preserving all typography, line-height, text-transform, and layout metrics. - * - * - Text updates: Non-target elements/text are hidden with visibility: hidden - * so wrapping and layout match 100%, while only the updating text flashes. - * - SVG updates: Cloned with transparent background, flashing stroke/fill in update color. - * - Div / container updates: Flashes background tint and outline, while inner text - * and icons align pixel-perfectly and flash in the exact same update color. - */ -export function createGhostClone(node: Node, count: number): HTMLElement | SVGElement | null { - if (typeof document === 'undefined') return null; - - const isText = node.nodeType === 3; /* Node.TEXT_NODE */ - const hostElement = isText ? node.parentElement : (node as Element); - if (!hostElement || !(hostElement instanceof HTMLElement || hostElement instanceof SVGElement)) { - return null; - } - - const svgRoot = findSvgRoot(hostElement); - const colors = getFlashColors(count); - - if (svgRoot) { - const rect = svgRoot.getBoundingClientRect(); - if (rect.width < 1 || rect.height < 1) return null; - - const clone = svgRoot.cloneNode(true) as SVGElement; - - detachClone(clone); - - clone.setAttribute('data-solid-flash-clone', ''); - clone.style.position = 'fixed'; - clone.style.left = `${rect.left}px`; - clone.style.top = `${rect.top}px`; - clone.style.width = `${rect.width}px`; - clone.style.height = `${rect.height}px`; - clone.style.margin = '0'; - clone.style.pointerEvents = 'none'; - clone.style.userSelect = 'none'; - clone.style.zIndex = '999998'; - clone.style.transform = 'none'; - clone.style.animation = `solid-flash-fade ${FADE_OUT_DURATION_MS}ms cubic-bezier(0, 0, 0.2, 1) forwards`; - - clone.style.background = 'transparent'; - clone.style.backgroundColor = 'transparent'; - clone.style.outline = 'none'; - clone.style.border = 'none'; - clone.style.boxShadow = 'none'; - - applySvgColors(clone, svgRoot, colors.color); - - return clone; - } - - const rect = hostElement.getBoundingClientRect(); - if (rect.width < 1 || rect.height < 1) return null; - - const clone = hostElement.cloneNode(true) as HTMLElement; - - detachClone(clone); - - clone.setAttribute('data-solid-flash-clone', ''); - clone.style.position = 'fixed'; - clone.style.left = `${rect.left}px`; - clone.style.top = `${rect.top}px`; - clone.style.width = `${rect.width}px`; - clone.style.height = `${rect.height}px`; - clone.style.margin = '0'; - clone.style.pointerEvents = 'none'; - clone.style.userSelect = 'none'; - clone.style.zIndex = '999998'; - clone.style.animation = `solid-flash-fade ${FADE_OUT_DURATION_MS}ms cubic-bezier(0, 0, 0.2, 1) forwards`; - - const win = - hostElement.ownerDocument?.defaultView ?? (typeof window !== 'undefined' ? window : null); - - if (win) { - const computed = win.getComputedStyle(hostElement); - - clone.style.setProperty('font-family', computed.fontFamily, 'important'); - clone.style.setProperty('font-size', computed.fontSize, 'important'); - clone.style.setProperty('font-weight', computed.fontWeight, 'important'); - clone.style.setProperty('font-style', computed.fontStyle, 'important'); - clone.style.setProperty('line-height', computed.lineHeight, 'important'); - clone.style.setProperty('letter-spacing', computed.letterSpacing, 'important'); - clone.style.setProperty('text-transform', computed.textTransform, 'important'); - clone.style.setProperty('text-align', computed.textAlign, 'important'); - clone.style.setProperty('text-indent', computed.textIndent, 'important'); - clone.style.setProperty('white-space', computed.whiteSpace, 'important'); - clone.style.setProperty('word-break', computed.wordBreak, 'important'); - clone.style.setProperty('tab-size', computed.tabSize, 'important'); - - clone.style.setProperty('box-sizing', 'border-box', 'important'); - clone.style.setProperty('padding-top', computed.paddingTop, 'important'); - clone.style.setProperty('padding-right', computed.paddingRight, 'important'); - clone.style.setProperty('padding-bottom', computed.paddingBottom, 'important'); - clone.style.setProperty('padding-left', computed.paddingLeft, 'important'); - clone.style.setProperty('border-radius', computed.borderRadius, 'important'); - clone.style.setProperty('transform', 'none', 'important'); - - if (computed.display === 'flex' || computed.display === 'inline-flex') { - clone.style.display = computed.display; - clone.style.flexDirection = computed.flexDirection; - clone.style.alignItems = computed.alignItems; - clone.style.justifyContent = computed.justifyContent; - clone.style.gap = computed.gap; - } else if (computed.display === 'grid' || computed.display === 'inline-grid') { - clone.style.display = computed.display; - clone.style.gridTemplateColumns = computed.gridTemplateColumns; - clone.style.gridTemplateRows = computed.gridTemplateRows; - clone.style.gap = computed.gap; - } - } - - if (isText) { - clone.style.background = 'transparent'; - clone.style.backgroundColor = 'transparent'; - clone.style.backgroundImage = 'none'; - clone.style.borderColor = 'transparent'; - clone.style.boxShadow = 'none'; - clone.style.outline = 'none'; - - const path = getNodePath(hostElement, node); - const targetInClone = getNodeByPath(clone, path); - - if (targetInClone) { - const walker = document.createTreeWalker(clone, NodeFilter.SHOW_ALL); - const toHide: (HTMLElement | SVGElement)[] = []; - const toWrap: Node[] = []; - - let current: Node | null = walker.currentNode; - while (current) { - if (current === targetInClone) { - // Keep target visible - } else if ( - current !== clone && - !current.contains(targetInClone) && - !targetInClone.contains(current) - ) { - if (current instanceof HTMLElement || current instanceof SVGElement) { - toHide.push(current); - } else if (current.nodeType === 3) { - toWrap.push(current); - } - } - current = walker.nextNode(); - } - - for (const el of toHide) { - el.style.visibility = 'hidden'; - } - - for (const textNode of toWrap) { - const hiddenSpan = document.createElement('span'); - hiddenSpan.textContent = textNode.textContent; - hiddenSpan.style.visibility = 'hidden'; - textNode.parentNode?.replaceChild(hiddenSpan, textNode); - } - - const highlightSpan = document.createElement('span'); - highlightSpan.textContent = targetInClone.textContent; - highlightSpan.style.setProperty('color', colors.color, 'important'); - highlightSpan.style.visibility = 'visible'; - targetInClone.parentNode?.replaceChild(highlightSpan, targetInClone); - } else { - clone.style.setProperty('color', colors.color, 'important'); - } - } else { - clone.style.backgroundColor = colors.background; - clone.style.borderColor = colors.color; - clone.style.outline = `1.5px solid ${colors.color}`; - clone.style.setProperty('color', colors.color, 'important'); - - const cloneChildren = clone.querySelectorAll('*'); - const sourceChildren = hostElement.querySelectorAll('*'); - - for (let i = 0; i < cloneChildren.length; i++) { - const childClone = cloneChildren[i]; - const childSource = sourceChildren[i]; - - if (childClone instanceof HTMLElement) { - childClone.style.setProperty('color', colors.color, 'important'); - - if (win && childSource instanceof HTMLElement) { - const childComputed = win.getComputedStyle(childSource); - childClone.style.setProperty('font-family', childComputed.fontFamily, 'important'); - childClone.style.setProperty('font-size', childComputed.fontSize, 'important'); - childClone.style.setProperty('font-weight', childComputed.fontWeight, 'important'); - childClone.style.setProperty('line-height', childComputed.lineHeight, 'important'); - childClone.style.setProperty('letter-spacing', childComputed.letterSpacing, 'important'); - childClone.style.setProperty('text-transform', childComputed.textTransform, 'important'); - childClone.style.setProperty('white-space', childComputed.whiteSpace, 'important'); - } - } else if (childClone instanceof SVGElement && childSource instanceof SVGElement) { - applySvgColors(childClone, childSource, colors.color); - } - } - } - - return clone; -} diff --git a/src/dev-toolbar/highlight-updates/overlay.css b/src/dev-toolbar/highlight-updates/overlay.css deleted file mode 100644 index 41cee1d..0000000 --- a/src/dev-toolbar/highlight-updates/overlay.css +++ /dev/null @@ -1,24 +0,0 @@ -[data-solid-highlight-overlay] { - position: fixed; - inset: 0; - pointer-events: none; - z-index: 999998; - overflow: hidden; -} - -[data-solid-flash-clone] { - will-change: opacity; - animation: solid-flash-fade 400ms cubic-bezier(0, 0, 0.2, 1) forwards; -} - -@keyframes solid-flash-fade { - 0% { - opacity: 1; - } - 15% { - opacity: 1; - } - 100% { - opacity: 0; - } -} diff --git a/src/dev-toolbar/highlight-updates/overlay.tsx b/src/dev-toolbar/highlight-updates/overlay.tsx index 74837e1..6c0a2b3 100644 --- a/src/dev-toolbar/highlight-updates/overlay.tsx +++ b/src/dev-toolbar/highlight-updates/overlay.tsx @@ -1,67 +1,130 @@ -import { JSX, Portal } from '@solidjs/web'; -import { createEffect, Show } from 'solid-js'; -import { createGhostClone } from './clone.js'; -import { FADE_OUT_DURATION_MS, UpdateHighlightTracker } from './tracker.js'; -import './overlay.css'; +import { createEffect } from 'solid-js'; +import { drawBoxes, type FlashBox } from './canvas.js'; +import { measureUpdate } from './rects.js'; +import { getFlashColors, UpdateHighlightTracker } from './tracker.js'; export interface HighlightUpdatesOverlayProps { active: boolean; } -export function HighlightUpdatesOverlay(props: HighlightUpdatesOverlayProps): JSX.Element { - let containerRef: HTMLDivElement | undefined; - const tracker = new UpdateHighlightTracker(); - const activeClones = new Map(); - +/** + * Outlines each updated node on one canvas above the page. The canvas sits in + * a shadow root, so page styles cannot reach it, and drawing adds no elements + * the page could see. + */ +export function HighlightUpdatesOverlay(props: HighlightUpdatesOverlayProps): null { createEffect( () => props.active, (active) => { if (!active || typeof window === 'undefined') return; - const unsubscribe = tracker.subscribe((event) => { - if (!containerRef) return; + const host = document.createElement('div'); + host.setAttribute('data-solid-highlight-overlay', ''); + host.setAttribute('aria-hidden', 'true'); + host.style.cssText = + 'all:initial;position:fixed;inset:0;pointer-events:none;z-index:999998;display:block'; + const canvas = document.createElement('canvas'); + canvas.style.cssText = 'display:block;width:100%;height:100%'; + host.attachShadow({ mode: 'open' }).append(canvas); + document.body.append(host); + const ctx = canvas.getContext('2d'); + if (!ctx) { + host.remove(); + return; + } - const clone = createGhostClone(event.node, event.count); - if (!clone) return; + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + // The boxes of each node. A node that updates again replaces its boxes. + const boxes = new Map(); + let frame: number | null = null; - // Replace any existing active clone for this node - const previous = activeClones.get(event.node); - if (previous) { - previous.remove(); + function resize() { + const dpr = window.devicePixelRatio || 1; + const width = Math.round(window.innerWidth * dpr); + const height = Math.round(window.innerHeight * dpr); + if (canvas.width === width && canvas.height === height) return; + canvas.width = width; + canvas.height = height; + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0); + // Setting the size clears the canvas, so draw now rather than show an + // empty frame. The pending frame is dropped first, or each resize would + // start one more draw loop. + if (frame !== null) cancelAnimationFrame(frame); + draw(); + } + + function draw() { + frame = null; + const all: FlashBox[] = []; + for (const list of boxes.values()) all.push(...list); + const showing = new Set( + drawBoxes( + ctx!, + window.innerWidth, + window.innerHeight, + all, + performance.now(), + !reducedMotion.matches, + ), + ); + for (const [node, list] of boxes) { + if (!showing.has(list[0]!)) boxes.delete(node); } - activeClones.set(event.node, clone); + if (boxes.size > 0) schedule(); + } - containerRef.appendChild(clone); + function schedule() { + frame ??= requestAnimationFrame(draw); + } - const onEnd = () => { - if (activeClones.get(event.node) === clone) { - activeClones.delete(event.node); + // The boxes are in viewport coordinates, so they move against a scroll of the window. + let scrollX = window.scrollX; + let scrollY = window.scrollY; + function onScroll() { + const dx = window.scrollX - scrollX; + const dy = window.scrollY - scrollY; + scrollX = window.scrollX; + scrollY = window.scrollY; + if (dx === 0 && dy === 0) return; + for (const list of boxes.values()) { + for (const box of list) { + box.left -= dx; + box.top -= dy; } - clone.remove(); - }; + } + schedule(); + } - clone.addEventListener('animationend', onEnd, { once: true }); - setTimeout(onEnd, FADE_OUT_DURATION_MS + 50); + const tracker = new UpdateHighlightTracker(); + const unsubscribe = tracker.subscribe((event) => { + const rects = measureUpdate(event.node); + if (rects.length === 0) return; + const colors = getFlashColors(event.count); + const start = performance.now(); + const list: FlashBox[] = []; + for (const rect of rects) { + list.push({ ...rect, color: colors.color, background: colors.background, start }); + } + boxes.set(event.node, list); + schedule(); }); + resize(); + window.addEventListener('resize', resize); + window.addEventListener('scroll', onScroll, { passive: true }); tracker.start(); return () => { unsubscribe(); tracker.stop(); - activeClones.clear(); - if (containerRef) { - containerRef.innerHTML = ''; - } + window.removeEventListener('resize', resize); + window.removeEventListener('scroll', onScroll); + if (frame !== null) cancelAnimationFrame(frame); + boxes.clear(); + host.remove(); }; }, ); - return ( - - -