@lightningtv/renderer
Version:
Lightning 3 Renderer
523 lines (449 loc) • 14.1 kB
text/typescript
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);
}
}