UNPKG

@lightningtv/renderer

Version:
523 lines (449 loc) 14.1 kB
import { CoreNode, type CoreNodeAnimateProps, type CoreNodeProps, } from '../core/CoreNode.js'; import { type RendererMainSettings } from './Renderer.js'; import type { AnimationSettings } from '../core/animations/CoreAnimation.js'; import type { IAnimationController } from '../common/IAnimationController.js'; import { isProductionEnvironment } from '../utils.js'; import type { CoreTextNode, CoreTextNodeProps } from '../core/CoreTextNode.js'; /** * Inspector * * The inspector is a tool that allows you to inspect the state of the renderer * and the nodes that are being rendered. It is a tool that is used for debugging * and development purposes. * * The inspector will generate a DOM tree that mirrors the state of the renderer */ /** * stylePropertyMap is a map of renderer properties that are mapped to CSS properties * * It can either return a string or an object with a prop and value property. Once a * property is found in the map, the value is set on the style of the div element. * Erik H made me do it. */ interface StyleResponse { prop: string; value: string; } const stylePropertyMap: { [key: string]: ( value: string | number | boolean, ) => string | StyleResponse | null; } = { alpha: (v) => { if (v === 1) { return null; } return { prop: 'opacity', value: `${v}` }; }, x: (x) => { return { prop: 'left', value: `${x}px` }; }, y: (y) => { return { prop: 'top', value: `${y}px` }; }, width: (w) => { if (w === 0) { return null; } return { prop: 'width', value: `${w}px` }; }, height: (h) => { if (h === 0) { return null; } return { prop: 'height', value: `${h}px` }; }, fontSize: (fs) => { if (fs === 0) { return null; } return { prop: 'font-size', value: `${fs}px` }; }, lineHeight: (lh) => { if (lh === 0) { return null; } return { prop: 'line-height', value: `${lh}px` }; }, zIndex: () => 'z-index', fontFamily: () => 'font-family', fontStyle: () => 'font-style', fontWeight: () => 'font-weight', fontStretch: () => 'font-stretch', letterSpacing: () => 'letter-spacing', textAlign: () => 'text-align', overflowSuffix: () => 'overflow-suffix', maxLines: () => 'max-lines', contain: () => 'contain', verticalAlign: () => 'vertical-align', clipping: (v) => { if (v === false) { return null; } return { prop: 'overflow', value: v ? 'hidden' : 'visible' }; }, rotation: (v) => { if (v === 0) { return null; } return { prop: 'transform', value: `rotate(${v}rad)` }; }, scale: (v) => { if (v === 1) { return null; } return { prop: 'transform', value: `scale(${v})` }; }, scaleX: (v) => { if (v === 1) { return null; } return { prop: 'transform', value: `scaleX(${v})` }; }, scaleY: (v) => { if (v === 1) { return null; } return { prop: 'transform', value: `scaleY(${v})` }; }, color: (v) => { if (v === 0) { return null; } return { prop: 'color', value: convertColorToRgba(v as number) }; }, }; const convertColorToRgba = (color: number) => { const a = (color & 0xff) / 255; const b = (color >> 8) & 0xff; const g = (color >> 16) & 0xff; const r = (color >> 24) & 0xff; return `rgba(${r},${g},${b},${a})`; }; const domPropertyMap: { [key: string]: string } = { id: 'test-id', }; const gradientColorPropertyMap = [ 'colorTop', 'colorBottom', 'colorLeft', 'colorRight', 'colorTl', 'colorTr', 'colorBl', 'colorBr', ]; const knownProperties = new Set<string>([ ...Object.keys(stylePropertyMap), ...Object.keys(domPropertyMap), // ...gradientColorPropertyMap, 'src', 'parent', 'data', ]); export class Inspector { private root: HTMLElement | null = null; private canvas: HTMLCanvasElement | null = null; private height = 1080; private width = 1920; private scaleX = 1; private scaleY = 1; constructor(canvas: HTMLCanvasElement, settings: RendererMainSettings) { if (isProductionEnvironment()) return; if (!settings) { throw new Error('settings is required'); } // calc dimensions based on the devicePixelRatio this.height = Math.ceil( settings.appHeight ?? 1080 / (settings.deviceLogicalPixelRatio ?? 1), ); this.width = Math.ceil( settings.appWidth ?? 1920 / (settings.deviceLogicalPixelRatio ?? 1), ); this.scaleX = settings.deviceLogicalPixelRatio ?? 1; this.scaleY = settings.deviceLogicalPixelRatio ?? 1; this.canvas = canvas; this.root = document.createElement('div'); this.setRootPosition(); document.body.appendChild(this.root); //listen for changes on canvas const mutationObserver = new MutationObserver( this.setRootPosition.bind(this), ); mutationObserver.observe(canvas, { attributes: true, childList: false, subtree: false, }); // Create a ResizeObserver to watch for changes in the element's size const resizeObserver = new ResizeObserver(this.setRootPosition.bind(this)); resizeObserver.observe(canvas); //listen for changes on window window.addEventListener('resize', this.setRootPosition.bind(this)); console.warn('Inspector is enabled, this will impact performance'); } setRootPosition() { if (this.root === null || this.canvas === null) { return; } // get the world position of the canvas object, so we can match the inspector to it const rect = this.canvas.getBoundingClientRect(); const top = document.documentElement.scrollTop + rect.top; const left = document.documentElement.scrollLeft + rect.left; this.root.id = 'root'; this.root.style.left = `${left}px`; this.root.style.top = `${top}px`; this.root.style.width = `${this.width}px`; this.root.style.height = `${this.height}px`; this.root.style.position = 'absolute'; this.root.style.transformOrigin = '0 0 0'; this.root.style.transform = `scale(${this.scaleX}, ${this.scaleY})`; this.root.style.overflow = 'hidden'; this.root.style.zIndex = '65534'; } createDiv( id: number, properties: CoreNodeProps | CoreTextNodeProps, ): HTMLElement { const div = document.createElement('div'); div.style.position = 'absolute'; div.id = id.toString(); // set initial properties for (const key in properties) { this.updateNodeProperty( div, // really typescript? really? key as keyof CoreNodeProps, properties[key as keyof CoreNodeProps], properties, ); } return div; } createNode(node: CoreNode): CoreNode { const div = this.createDiv(node.id, node.props); (div as HTMLElement & { node: CoreNode }).node = node; (node as CoreNode & { div: HTMLElement }).div = div; node.on('inViewport', () => div.setAttribute('state', 'inViewport')); node.on('inBounds', () => div.setAttribute('state', 'inBounds')); node.on('outOfBounds', () => div.setAttribute('state', 'outOfBounds')); // Monitor only relevant properties by trapping with selective assignment return this.createProxy(node, div); } createTextNode(node: CoreNode): CoreTextNode { const div = this.createDiv(node.id, node.props); (div as HTMLElement & { node: CoreNode }).node = node; (node as CoreNode & { div: HTMLElement }).div = div; return this.createProxy(node, div) as CoreTextNode; } createProxy( node: CoreNode | CoreTextNode, div: HTMLElement, ): CoreNode | CoreTextNode { // Define traps for each property in knownProperties knownProperties.forEach((property) => { let originalProp = Object.getOwnPropertyDescriptor(node, property); if (originalProp === undefined) { // Search the prototype chain for the property descriptor const proto = Object.getPrototypeOf(node) as CoreNode | CoreTextNode; originalProp = Object.getOwnPropertyDescriptor(proto, property); } if (originalProp === undefined) { return; } Object.defineProperty(node, property, { get() { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return originalProp?.get?.call(node); }, set: (value) => { originalProp?.set?.call(node, value); this.updateNodeProperty( div, property as keyof CoreNodeProps | keyof CoreTextNodeProps, value, node.props, ); }, configurable: true, enumerable: true, }); }); const originalDestroy = node.destroy; Object.defineProperty(node, 'destroy', { value: () => { this.destroyNode(node.id); originalDestroy.call(node); }, }); const originalAnimate = node.animate; Object.defineProperty(node, 'animate', { value: ( props: CoreNodeAnimateProps, settings: AnimationSettings, ): IAnimationController => { const animationController = originalAnimate.call(node, props, settings); const originalStart = animationController.start.bind(animationController); animationController.start = () => { this.animateNode(div, props, settings); return originalStart(); }; return animationController; }, }); return node; } destroyNode(id: number) { const div = document.getElementById(id.toString()); div?.remove(); } updateNodeProperty( div: HTMLElement, property: keyof CoreNodeProps | keyof CoreTextNodeProps, // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any, props: CoreNodeProps | CoreTextNodeProps, ) { if (this.root === null || value === undefined || value === null) { return; } /** * Special case for parent property */ if (property === 'parent') { const parentId: number = value.id; // only way to detect if the parent is the root node // if you are reading this and have a better way, please let me know if (parentId === 1) { this.root.appendChild(div); return; } const parent = document.getElementById(parentId.toString()); parent?.appendChild(div); return; } // special case for text if (property === 'text') { div.innerHTML = String(value); // hide text because we can't render SDF fonts // it would look weird and obstruct the WebGL rendering div.style.visibility = 'hidden'; return; } // special case for images // we're not setting any CSS properties to avoid images getting loaded twice // as the renderer will handle the loading of the image. Setting it to `data-src` if (property === 'src' && value) { div.setAttribute(`data-src`, String(value)); return; } // special case for color gradients (normal colors are handled by the stylePropertyMap) // FIXME the renderer seems to return the same number for all colors // if (gradientColorPropertyMap.includes(property as string)) { // const color = convertColorToRgba(value as number); // div.setAttribute(`data-${property}`, color); // return; // } if (property === 'rtt' && value) { div.setAttribute('data-rtt', String(value)); return; } // CSS mappable attribute if (stylePropertyMap[property]) { const mappedStyleResponse = stylePropertyMap[property]?.(value); if (mappedStyleResponse === null) { return; } if (typeof mappedStyleResponse === 'string') { div.style.setProperty(mappedStyleResponse, String(value)); return; } if (typeof mappedStyleResponse === 'object') { let value = mappedStyleResponse.value; if (property === 'x') { const mount = props.mountX; const width = props.width; if (mount) { value = `${parseInt(value) - width * mount}px`; } } else if (property === 'y') { const mount = props.mountY; const height = props.height; if (mount) { value = `${parseInt(value) - height * mount}px`; } } div.style.setProperty(mappedStyleResponse.prop, value); } return; } // DOM properties if (domPropertyMap[property]) { const domProperty = domPropertyMap[property]; if (!domProperty) { return; } div.setAttribute(String(domProperty), String(value)); return; } // custom data properties if (property === 'data') { for (const key in value) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const keyValue: unknown = value[key]; if (keyValue === undefined) { div.removeAttribute(`data-${key}`); } else { div.setAttribute(`data-${key}`, String(keyValue)); } } return; } } // simple animation handler animateNode( div: HTMLElement, props: CoreNodeAnimateProps, settings: AnimationSettings, ) { const { duration = 1000, delay = 0, // easing = 'linear', // repeat = 0, // loop = false, // stopMethod = false, } = settings; const { x, y, width, height, alpha = 1, rotation = 0, scale = 1, color, mountX, mountY, } = props; // ignoring loops and repeats for now, as that might be a bit too much for the inspector function animate() { setTimeout(() => { div.style.top = `${y - height * mountY}px`; div.style.left = `${x - width * mountX}px`; div.style.width = `${width}px`; div.style.height = `${height}px`; div.style.opacity = `${alpha}`; div.style.rotate = `${rotation}rad`; div.style.scale = `${scale}`; div.style.color = convertColorToRgba(color); }, duration); } setTimeout(animate, delay); } }