diff --git a/src/animation.ts b/src/animation.ts index b8f812a..e1e0954 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -6,7 +6,8 @@ import { type ElementNode, LightningRendererNumberProps, } from './elementNode.js'; -import { type IRendererStage } from './lightningInit.js'; +import { renderer } from './lightningInit.js'; +import { CoreAnimation } from './intrinsicTypes.js'; /** * Simplified Animation Settings @@ -43,15 +44,15 @@ interface SimpleAnimationNodeConfig { export class SimpleAnimation { private nodeConfigs: SimpleAnimationNodeConfig[] = []; private isRegistered = false; - private stage: IRendererStage | undefined; + private stage: typeof renderer.stage | undefined; - register(stage: IRendererStage) { + register(stage: typeof renderer.stage) { if (this.isRegistered) { return; } this.isRegistered = true; this.stage = stage; - stage.animationManager.registerAnimation(this); + stage.animationManager.registerAnimation(this as unknown as CoreAnimation); } /** @@ -173,7 +174,9 @@ export class SimpleAnimation { this.nodeConfigs.splice(i, 1); } if (this.nodeConfigs.length === 0) { - this.stage?.animationManager.unregisterAnimation(this); + this.stage?.animationManager.unregisterAnimation( + this as unknown as CoreAnimation, + ); this.isRegistered = false; } } diff --git a/src/config.ts b/src/config.ts index cf981b6..2db89a5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -37,17 +37,18 @@ export const SHADERS_ENABLED = !!( before running any Lightning modules to ensure consistent behavior across the application. */ export interface Config { + animationsEnabled: boolean; + animationSettings?: AnimationSettings; debug: boolean; + domRendererEnabled?: boolean; focusDebug: boolean; - keyDebug: boolean; - simpleAnimationsEnabled?: boolean; - animationSettings?: AnimationSettings; - animationsEnabled: boolean; + focusStateKey: DollarString; fontSettings: Partial; + keyDebug: boolean; + lockStyles?: boolean; rendererOptions?: Partial; setActiveElement: (elm: ElementNode) => void; - focusStateKey: DollarString; - lockStyles?: boolean; + simpleAnimationsEnabled?: boolean; throttleInput?: number; } diff --git a/src/domRenderer.ts b/src/dom-renderer/domRenderer.ts similarity index 64% rename from src/domRenderer.ts rename to src/dom-renderer/domRenderer.ts index e41b0d9..96471ef 100644 --- a/src/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -6,23 +6,36 @@ Experimental DOM renderer import * as lng from '@lightningjs/renderer'; -import { Config } from './config.js'; -import { - IRendererShader, - IRendererStage, - IRendererShaderProps, - IRendererTextureProps, - IRendererTexture, +import { EventEmitter } from '@lightningjs/renderer/utils'; +import { Config } from '../config.js'; +import type { + ExtractProps, IRendererMain, IRendererNode, IRendererNodeProps, + IRendererShader, + IRendererStage, IRendererTextNode, IRendererTextNodeProps, -} from './lightningInit.js'; -import { EventEmitter } from '@lightningjs/renderer/utils'; - -const colorToRgba = (c: number) => - `rgba(${(c >> 24) & 0xff},${(c >> 16) & 0xff},${(c >> 8) & 0xff},${(c & 0xff) / 255})`; +} from './domRendererTypes.js'; +import { + colorToRgba, + buildGradientStops, + computeLegacyObjectFit, + applySubTextureScaling, + getNodeLineHeight, +} from './domRendererUtils.js'; + +// Feature detection for legacy brousers +const _styleRef: any = + typeof document !== 'undefined' ? document.documentElement?.style || {} : {}; + +const supportsObjectFit: boolean = 'objectFit' in _styleRef; +const supportsObjectPosition: boolean = 'objectPosition' in _styleRef; +const supportsMixBlendMode: boolean = 'mixBlendMode' in _styleRef; +const supportsStandardMask: boolean = 'maskImage' in _styleRef; +const supportsWebkitMask: boolean = 'webkitMaskImage' in _styleRef; +const supportsCssMask: boolean = supportsStandardMask || supportsWebkitMask; function applyEasing(easing: string, progress: number): number { switch (easing) { @@ -252,11 +265,7 @@ function updateNodeParent(node: DOMNode | DOMText) { } } -function getNodeLineHeight(props: IRendererTextNodeProps): number { - return ( - props.lineHeight ?? Config.fontSettings.lineHeight ?? 1.2 * props.fontSize - ); -} +// getNodeLineHeight moved to domRendererUtils.ts function updateNodeStyles(node: DOMNode | DOMText) { let { props } = node; @@ -348,6 +357,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { } case 'none': style += `width: max-content;`; + style += `width: -webkit-max-content;`; break; } @@ -386,57 +396,99 @@ function updateNodeStyles(node: DOMNode | DOMText) { : vGradient || hGradient; let srcImg: string | null = null; - let srcPos: null | { x: number; y: number } = null; + let srcPos: null | InstanceType['props'] = + null; + let rawImgSrc: string | null = null; if ( props.texture != null && props.texture.type === lng.TextureType.subTexture ) { - srcPos = (props.texture as any).props; - srcImg = `url(${(props.texture as any).props.texture.props.src})`; + const texture = props.texture as InstanceType< + lng.TextureMap['SubTexture'] + >; + srcPos = texture.props; + rawImgSrc = (texture.props.texture as any).props.src; } else if (props.src) { - srcImg = `url(${props.src})`; + rawImgSrc = props.src; + } + + if (rawImgSrc) { + srcImg = `url(${rawImgSrc})`; } let bgStyle = ''; let borderStyle = ''; let radiusStyle = ''; let maskStyle = ''; - - if (srcImg) { - if (props.color !== 0xffffffff && props.color !== 0x00000000) { - // use image as a mask - bgStyle += `background-color: ${colorToRgba(props.color)}; background-blend-mode: multiply;`; - maskStyle += `mask-image: ${srcImg};`; - if (srcPos !== null) { - maskStyle += `mask-position: -${srcPos.x}px -${srcPos.y}px;`; - } else { - maskStyle += `mask-size: 100% 100%;`; + let needsBackgroundLayer = false; + let imgStyle = ''; + + if (rawImgSrc) { + needsBackgroundLayer = true; + + const hasTint = props.color !== 0xffffffff && props.color !== 0x00000000; + + if (hasTint) { + bgStyle += `background-color: ${colorToRgba(props.color)};`; + if (srcImg) { + maskStyle += `mask-image: ${srcImg};`; + if (srcPos !== null) { + maskStyle += `mask-position: -${srcPos.x}px -${srcPos.y}px;`; + } else { + maskStyle += `mask-size: 100% 100%;`; + } } } else if (gradient) { - // use gradient as a mask + // use gradient as a mask when no tint is applied maskStyle += `mask-image: ${gradient};`; } - bgStyle += `background-image: ${srcImg};`; - bgStyle += `background-repeat: no-repeat;`; + const imgStyleParts = [ + 'position: absolute', + 'top: 0', + 'left: 0', + 'right: 0', + 'bottom: 0', + 'display: block', + 'pointer-events: none', + ]; if (props.textureOptions.resizeMode?.type) { - bgStyle += `background-size: ${props.textureOptions.resizeMode.type}; background-position: center;`; + const resizeMode = props.textureOptions.resizeMode; + imgStyleParts.push('width: 100%'); + imgStyleParts.push('height: 100%'); + imgStyleParts.push(`object-fit: ${resizeMode.type}`); + + // Handle clipX and clipY for object-position + const clipX = (resizeMode as any).clipX ?? 0.5; + const clipY = (resizeMode as any).clipY ?? 0.5; + imgStyleParts.push(`object-position: ${clipX * 100}% ${clipY * 100}%`); } else if (srcPos !== null) { - bgStyle += `background-position: -${srcPos.x}px -${srcPos.y}px;`; + imgStyleParts.push('width: auto'); + imgStyleParts.push('height: auto'); + imgStyleParts.push('object-fit: none'); + imgStyleParts.push(`object-position: -${srcPos.x}px -${srcPos.y}px`); + } else if (props.width && !props.height) { + imgStyleParts.push('width: 100%'); + imgStyleParts.push('height: auto'); + } else if (props.height && !props.width) { + imgStyleParts.push('width: auto'); + imgStyleParts.push('height: 100%'); } else { - bgStyle += 'background-size: 100% 100%;'; + imgStyleParts.push('width: 100%'); + imgStyleParts.push('height: 100%'); + imgStyleParts.push('object-fit: fill'); } - - if (maskStyle !== '') { - bgStyle += maskStyle; - } - // separate layers are needed for the mask - if (maskStyle !== '' && node.divBg == null) { - node.div.appendChild((node.divBg = document.createElement('div'))); - node.div.appendChild((node.divBorder = document.createElement('div'))); + if (hasTint) { + if (supportsMixBlendMode) { + imgStyleParts.push('mix-blend-mode: multiply'); + } else { + imgStyleParts.push('opacity: 0.9'); + } } + + imgStyle = imgStyleParts.join('; ') + ';'; } else if (gradient) { bgStyle += `background-image: ${gradient};`; bgStyle += `background-repeat: no-repeat;`; @@ -459,7 +511,11 @@ function updateNodeStyles(node: DOMNode | DOMText) { } break; } - case 'border': { + case 'border': + case 'borderTop': + case 'borderBottom': + case 'borderLeft': + case 'borderRight': { let borderWidth = effect.props?.width; let borderColor = effect.props?.color; if ( @@ -468,8 +524,96 @@ function updateNodeStyles(node: DOMNode | DOMText) { typeof borderColor === 'number' && borderColor !== 0 ) { - // css border impacts the element's box size when box-shadow doesn't - borderStyle += `box-shadow: inset 0px 0px 0px ${borderWidth}px ${colorToRgba(borderColor)};`; + const rgbaColor = colorToRgba(borderColor); + if (effect.type === 'border') { + // Avoid affecting layout sizing while applying uniform borders + borderStyle += `box-shadow: inset 0px 0px 0px ${borderWidth}px ${rgbaColor};`; + } else { + const side = effect.type.slice('border'.length).toLowerCase(); + borderStyle += `border-${side}: ${borderWidth}px solid ${rgbaColor};`; + } + } + break; + } + case 'radialGradient': { + const rg = effect.props as + | Partial + | undefined; + const colors = Array.isArray(rg?.colors) ? rg!.colors! : []; + const stops = Array.isArray(rg?.stops) ? rg!.stops! : undefined; + const pivot = Array.isArray(rg?.pivot) ? rg!.pivot! : [0.5, 0.5]; + const width = + typeof rg?.width === 'number' ? rg!.width! : props.width || 0; + const height = + typeof rg?.height === 'number' ? rg!.height! : width; + + if (colors.length > 0) { + const gradientStops = buildGradientStops(colors, stops); + if (gradientStops) { + if (colors.length === 1) { + // Single color -> solid fill + if (srcImg || gradient) { + maskStyle += `mask-image: linear-gradient(${gradientStops});`; + } else { + bgStyle += `background-color: ${colorToRgba(colors[0]!)};`; + } + } else { + const isEllipse = + width > 0 && height > 0 && width !== height; + const pivotX = (pivot[0] ?? 0.5) * 100; + const pivotY = (pivot[1] ?? 0.5) * 100; + let sizePart = ''; + if (width > 0 && height > 0) { + if (!isEllipse && width === height) { + sizePart = `${Math.round(width)}px`; + } else { + sizePart = `${Math.round(width)}px ${Math.round(height)}px`; + } + } else { + sizePart = 'closest-side'; + } + const radialGradient = `radial-gradient(${isEllipse ? 'ellipse' : 'circle'} ${sizePart} at ${pivotX.toFixed(2)}% ${pivotY.toFixed(2)}%, ${gradientStops})`; + if (srcImg || gradient) { + maskStyle += `mask-image: ${radialGradient};`; + } else { + bgStyle += `background-image: ${radialGradient};`; + bgStyle += `background-repeat: no-repeat;`; + bgStyle += `background-size: 100% 100%;`; + } + } + } + } + break; + } + case 'linearGradient': { + const lg = effect.props as + | Partial + | undefined; + const colors = Array.isArray(lg?.colors) ? lg!.colors! : []; + const stops = Array.isArray(lg?.stops) ? lg!.stops! : undefined; + const angleRad = typeof lg?.angle === 'number' ? lg!.angle! : 0; // radians + + if (colors.length > 0) { + const gradientStops = buildGradientStops(colors, stops); + if (gradientStops) { + if (colors.length === 1) { + if (srcImg || gradient) { + maskStyle += `mask-image: linear-gradient(${gradientStops});`; + } else { + bgStyle += `background-color: ${colorToRgba(colors[0]!)};`; + } + } else { + const angleDeg = 180 * (angleRad / Math.PI - 1); + const linearGradient = `linear-gradient(${angleDeg.toFixed(2)}deg, ${gradientStops})`; + if (srcImg || gradient) { + maskStyle += `mask-image: ${linearGradient};`; + } else { + bgStyle += `background-image: ${linearGradient};`; + bgStyle += `background-repeat: no-repeat;`; + bgStyle += `background-size: 100% 100%;`; + } + } + } } break; } @@ -481,21 +625,146 @@ function updateNodeStyles(node: DOMNode | DOMText) { } } + if (maskStyle !== '') { + if (!supportsStandardMask && supportsWebkitMask) { + maskStyle = maskStyle.replace(/mask-/g, '-webkit-mask-'); + } else if (!supportsCssMask) { + maskStyle = ''; + } + if (maskStyle !== '') { + needsBackgroundLayer = true; + } + } + style += radiusStyle; - bgStyle += radiusStyle; - borderStyle += radiusStyle; - if (node.divBg == null) { - style += bgStyle; + if (needsBackgroundLayer) { + if (node.divBg == null) { + node.divBg = document.createElement('div'); + node.div.insertBefore(node.divBg, node.div.firstChild); + } else if (node.divBg.parentElement !== node.div) { + node.div.insertBefore(node.divBg, node.div.firstChild); + } + + let bgLayerStyle = + 'position: absolute; top:0; left:0; right:0; bottom:0; z-index: -1; pointer-events: none; overflow: hidden;'; + if (bgStyle) { + bgLayerStyle += bgStyle; + } + if (maskStyle) { + bgLayerStyle += maskStyle; + } + + node.divBg.setAttribute('style', bgLayerStyle + radiusStyle); + + if (rawImgSrc) { + if (!node.imgEl) { + node.imgEl = document.createElement('img'); + node.imgEl.alt = ''; + node.imgEl.setAttribute('aria-hidden', 'true'); + node.imgEl.setAttribute('loading', 'lazy'); + + node.imgEl.addEventListener('load', () => { + const payload: lng.NodeTextureLoadedPayload = { + type: 'texture', + dimensions: { + width: node.imgEl!.naturalWidth, + height: node.imgEl!.naturalHeight, + }, + }; + applySubTextureScaling(node, node.imgEl!, srcPos); + + const resizeMode = (node.props.textureOptions as any)?.resizeMode; + const clipX = resizeMode?.clipX ?? 0.5; + const clipY = resizeMode?.clipY ?? 0.5; + computeLegacyObjectFit( + node, + node.imgEl!, + resizeMode, + clipX, + clipY, + srcPos, + supportsObjectFit, + supportsObjectPosition, + ); + node.emit('loaded', payload); + }); + + node.imgEl.addEventListener('error', () => { + if (node.imgEl) { + node.imgEl.src = ''; + node.imgEl.style.display = 'none'; + } + + const payload: lng.NodeTextureFailedPayload = { + type: 'texture', + error: new Error(`Failed to load image: ${rawImgSrc}`), + }; + node.emit('failed', payload); + }); + } + if (node.imgEl.dataset.rawSrc !== rawImgSrc) { + node.imgEl.src = rawImgSrc; + node.imgEl.dataset.rawSrc = rawImgSrc; + } + if (node.imgEl.parentElement !== node.divBg) { + node.divBg.appendChild(node.imgEl); + } + node.imgEl.setAttribute('style', imgStyle); + + if (srcPos && node.imgEl.complete) { + applySubTextureScaling(node, node.imgEl, srcPos); + } + if (!srcPos && (!supportsObjectFit || !supportsObjectPosition)) { + const resizeMode = (node.props.textureOptions as any)?.resizeMode; + const clipX = resizeMode?.clipX ?? 0.5; + const clipY = resizeMode?.clipY ?? 0.5; + computeLegacyObjectFit( + node, + node.imgEl, + resizeMode, + clipX, + clipY, + srcPos, + supportsObjectFit, + supportsObjectPosition, + ); + } + } else if (node.imgEl) { + node.imgEl.remove(); + node.imgEl = undefined; + } } else { - bgStyle += 'position: absolute; inset: 0; z-index: -1;'; - node.divBg.setAttribute('style', bgStyle); + if (node.imgEl) { + node.imgEl.remove(); + node.imgEl = undefined; + } + if (node.divBg) { + node.divBg.remove(); + node.divBg = undefined; + } + style += bgStyle; + } + + const needsSeparateBorderLayer = needsBackgroundLayer && maskStyle !== ''; + + if (needsSeparateBorderLayer) { + if (node.divBorder == null) { + node.divBorder = document.createElement('div'); + node.div.appendChild(node.divBorder); + } + } else if (node.divBorder) { + node.divBorder.remove(); + node.divBorder = undefined; } + if (node.divBorder == null) { style += borderStyle; } else { - borderStyle += 'position: absolute; inset: 0; z-index: -1;'; - node.divBorder.setAttribute('style', borderStyle); + let borderLayerStyle = + 'position: absolute; top:0; left:0; right:0; bottom:0; z-index: -1; pointer-events: none;'; + borderLayerStyle += borderStyle; + node.divBorder.setAttribute('style', borderLayerStyle + radiusStyle); } } @@ -509,19 +778,19 @@ const textNodesToMeasure = new Set(); type Size = { width: number; height: number }; function getElSize(node: DOMNode): Size { - let rect = node.div.getBoundingClientRect(); + const rawRect = node.div.getBoundingClientRect(); - let dpr = Config.rendererOptions?.deviceLogicalPixelRatio ?? 1; - rect.height /= dpr; - rect.width /= dpr; + const dpr = Config.rendererOptions?.deviceLogicalPixelRatio ?? 1; + let width = rawRect.width / dpr; + let height = rawRect.height / dpr; for (;;) { if (node.props.scale != null && node.props.scale !== 1) { - rect.height /= node.props.scale; - rect.width /= node.props.scale; + width /= node.props.scale; + height /= node.props.scale; } else { - rect.height /= node.props.scaleY; - rect.width /= node.props.scaleX; + width /= node.props.scaleX; + height /= node.props.scaleY; } if (node.parent instanceof DOMNode) { @@ -531,7 +800,7 @@ function getElSize(node: DOMNode): Size { } } - return rect; + return { width, height }; } /* @@ -547,7 +816,6 @@ function updateDOMTextSize(node: DOMText): void { if (node.props.height !== size.height) { node.props.height = size.height; updateNodeStyles(node); - node.emit('loaded'); } break; case 'none': @@ -559,10 +827,18 @@ function updateDOMTextSize(node: DOMText): void { node.props.width = size.width; node.props.height = size.height; updateNodeStyles(node); - node.emit('loaded'); } break; } + + const payload: lng.NodeTextLoadedPayload = { + type: 'text', + dimensions: { + width: node.props.width, + height: node.props.height, + }, + }; + node.emit('loaded', payload); } function updateDOMTextMeasurements() { @@ -580,10 +856,15 @@ function scheduleUpdateDOMTextMeasurement(node: DOMText) { } if (textNodesToMeasure.size === 0) { + const fonts = document.fonts; if (document.fonts.status === 'loaded') { setTimeout(updateDOMTextMeasurements); } else { - document.fonts.ready.then(updateDOMTextMeasurements); + if (fonts && fonts.ready && typeof fonts.ready.then === 'function') { + fonts.ready.then(updateDOMTextMeasurements); + } else { + setTimeout(updateDOMTextMeasurements); + } } } @@ -604,7 +885,7 @@ function updateNodeData(node: DOMNode | DOMText) { function resolveNodeDefaults( props: Partial, ): IRendererNodeProps { - const color = props.color ?? 0xffffffff; + const color = props.color ?? 0x00000000; return { x: props.x ?? 0, @@ -624,7 +905,7 @@ function resolveNodeDefaults( colorBr: props.colorBr ?? props.colorBottom ?? props.colorRight ?? color, colorTl: props.colorTl ?? props.colorTop ?? props.colorLeft ?? color, colorTr: props.colorTr ?? props.colorTop ?? props.colorRight ?? color, - zIndex: props.zIndex ?? 0, + zIndex: Math.ceil(props.zIndex ?? 0), zIndexLocked: props.zIndexLocked ?? 0, parent: props.parent ?? null, texture: props.texture ?? null, @@ -689,10 +970,11 @@ const defaultShader: IRendererShader = { let lastNodeId = 0; -class DOMNode extends EventEmitter implements IRendererNode { +export class DOMNode extends EventEmitter implements IRendererNode { div = document.createElement('div'); divBg: HTMLElement | undefined; divBorder: HTMLElement | undefined; + imgEl: HTMLImageElement | undefined; id = ++lastNodeId; @@ -847,7 +1129,7 @@ class DOMNode extends EventEmitter implements IRendererNode { return this.props.zIndex; } set zIndex(v) { - this.props.zIndex = v; + this.props.zIndex = Math.ceil(v); updateNodeStyles(this); } get texture() { @@ -1194,11 +1476,12 @@ function updateRootPosition(this: DOMRendererMain) { export class DOMRendererMain implements IRendererMain { root: DOMNode; canvas: HTMLCanvasElement; - stage: IRendererStage; + private eventListeners: Map void>> = + new Map(); constructor( - public settings: lng.RendererMainSettings, + public settings: Partial, rawTarget: string | HTMLElement, ) { let target: HTMLElement; @@ -1249,7 +1532,7 @@ export class DOMRendererMain implements IRendererMain { width: settings.appWidth ?? 1920, height: settings.appHeight ?? 1080, shader: defaultShader, - zIndex: 65534, + zIndex: 1, }), ); this.stage.root = this.root; @@ -1283,6 +1566,77 @@ export class DOMRendererMain implements IRendererMain { window.addEventListener('resize', updateRootPosition.bind(this)); } + removeAllListeners(): void { + if (this.eventListeners.size === 0) return; + this.eventListeners.forEach((listeners) => listeners.clear()); + this.eventListeners.clear(); + } + + once( + event: Extract, + listener: { [s: string]: (target: any, data: any) => void }[K], + ): void { + const wrappedListener = (target: any, data: any) => { + this.off(event, wrappedListener); + listener(target, data); + }; + this.on(event, wrappedListener); + } + + on(name: string, callback: (target: any, data: any) => void) { + let listeners = this.eventListeners.get(name); + if (!listeners) { + listeners = new Set(); + this.eventListeners.set(name, listeners); + } + listeners.add(callback); + } + + off( + event: Extract, + listener: { [s: string]: (target: any, data: any) => void }[K], + ): void { + const listeners = this.eventListeners.get(event); + if (listeners) { + listeners.delete(listener); + if (listeners.size === 0) { + this.eventListeners.delete(event); + } + } + } + + emit( + event: Extract, + data: Parameters[1], + ): void; + emit( + event: Extract, + target: any, + data: Parameters[1], + ): void; + emit( + event: Extract, + targetOrData: any, + maybeData?: Parameters[1], + ): void { + const listeners = this.eventListeners.get(event); + if (!listeners || listeners.size === 0) { + return; + } + + const hasExplicitTarget = arguments.length === 3; + const target = hasExplicitTarget ? targetOrData : this.root; + const data = hasExplicitTarget ? maybeData : targetOrData; + + for (const listener of Array.from(listeners)) { + try { + listener(target, data); + } catch (error) { + console.error(`Error in listener for event "${event}"`, error); + } + } + } + createNode(props: Partial): IRendererNode { return new DOMNode(this.stage, resolveNodeDefaults(props)); } @@ -1291,17 +1645,21 @@ export class DOMRendererMain implements IRendererMain { return new DOMText(this.stage, resolveTextNodeDefaults(props)); } - createShader( - shaderType: string, - props?: IRendererShaderProps, - ): IRendererShader { - return { shaderType, props, program: {} }; + createShader( + shaderType: ShType, + props?: ExtractProps, + ): lng.ShaderController { + return { + shaderType, + props, + program: {}, + } as unknown as lng.ShaderController; } - createTexture( - textureType: keyof lng.TextureMap, - props: IRendererTextureProps, - ): IRendererTexture { + createTexture( + textureType: Type, + props: ExtractProps, + ): InstanceType { let type = lng.TextureType.generic; switch (textureType) { case 'SubTexture': @@ -1320,18 +1678,51 @@ export class DOMRendererMain implements IRendererMain { type = lng.TextureType.renderToTexture; break; } - return { type, props }; + return { type, props } as InstanceType; + } + + createEffect< + Type extends keyof lng.EffectMap, + Name extends string | undefined = undefined, + >( + type: Type, + props: ExtractProps, + name?: Name, + ): lng.EffectDesc<{ name: Name; type: Type }> { + return { type, props, name: name as Name } as unknown as lng.EffectDesc<{ + name: Name; + type: Type; + }>; } +} - createEffect( - type: keyof lng.EffectMap, - props: Record, - name?: string, - ): lng.EffectDescUnion { - return { type, props, name } as any; - } +export function loadFontToDom(font: lng.WebTrFontFaceOptions): void { + const fontFaceDescriptors: FontFaceDescriptors | undefined = font.descriptors + ? { + ...font.descriptors, + weight: + typeof font.descriptors.weight === 'number' + ? String(font.descriptors.weight) + : font.descriptors.weight, + } + : undefined; - on(name: string, callback: (target: any, data: any) => void) { - console.log('on', name, callback); + const fontFace = new FontFace( + font.fontFamily, + `url(${font.fontUrl})`, + fontFaceDescriptors, + ); + + if (typeof document !== 'undefined' && 'fonts' in document) { + const fontSet = document.fonts as FontFaceSet & { + add?: (font: FontFace) => FontFaceSet; + }; + fontSet.add?.(fontFace); } } + +export function isDomRenderer( + r: lng.RendererMain | DOMRendererMain, +): r is DOMRendererMain { + return r instanceof DOMRendererMain; +} diff --git a/src/dom-renderer/domRendererTypes.ts b/src/dom-renderer/domRendererTypes.ts new file mode 100644 index 0000000..d3bbc59 --- /dev/null +++ b/src/dom-renderer/domRendererTypes.ts @@ -0,0 +1,106 @@ +import * as lng from '@lightningjs/renderer'; +import { CoreAnimation } from '../intrinsicTypes.js'; +import { EventEmitter } from '@lightningjs/renderer/utils'; + +/** Based on {@link lng.CoreRenderer} */ +export interface IRendererCoreRenderer { + mode: 'canvas' | 'webgl' | undefined; +} +/** Based on {@link lng.TrFontManager} */ +export interface IRendererFontManager { + addFontFace: (...a: any[]) => void; +} +/** Based on {@link lng.Stage} */ +export interface IRendererStage { + root: IRendererNode; + renderer: IRendererCoreRenderer; + fontManager: IRendererFontManager; + shManager: IRendererShaderManager; + animationManager: { + registerAnimation: (anim: CoreAnimation) => void; + unregisterAnimation: (anim: CoreAnimation) => void; + }; +} + +/** Based on {@link lng.CoreShaderManager} */ +export interface IRendererShaderManager { + registerShaderType: (name: string, shader: any) => void; +} + +/** Based on {@link lng.WebGlCoreShader} */ +export interface IRendererShader extends Partial { + shaderType?: string; + props?: IRendererShaderProps; +} +/** Based on {@link lng.CoreShaderType} */ +export interface IRendererShaderType {} +export type IRendererShaderProps = Record; + +export type ExtractProps = Type extends { z$__type__Props: infer Props } + ? Props + : never; + +export interface IEventEmitter< + T extends object = { [s: string]: (target: any, data: any) => void }, +> { + on(event: Extract, listener: T[K]): void; + once(event: Extract, listener: T[K]): void; + off(event: Extract, listener: T[K]): void; + emit( + event: Extract, + data: Parameters[1], + ): void; +} + +export interface IRendererNodeShaded extends EventEmitter { + stage: IRendererStage; + id: number; + animate: ( + props: Partial>, + settings: Partial, + ) => lng.IAnimationController; + get absX(): number; + get absY(): number; +} + +/** Based on {@link lng.INodeProps} */ +export interface IRendererNodeProps + extends Omit { + shader: IRendererShader | null; + parent: IRendererNode | null; +} + +/** Based on {@link lng.INode} */ +export interface IRendererNode extends IRendererNodeShaded, IRendererNodeProps { + div?: HTMLElement; + props: IRendererNodeProps; + renderState: lng.CoreNodeRenderState; +} + +/** Based on {@link lng.ITextNodeProps} */ +export interface IRendererTextNodeProps + extends Omit { + shader: IRendererShader | null; + parent: IRendererNode | null; +} + +/** Based on {@link lng.ITextNode} */ +export interface IRendererTextNode + extends IRendererNodeShaded, + IRendererTextNodeProps { + div?: HTMLElement; + props: IRendererTextNodeProps; + renderState: lng.CoreNodeRenderState; +} + +/** Based on {@link lng.RendererMain} */ +export interface IRendererMain extends IEventEmitter { + root: IRendererNode; + stage: IRendererStage; + canvas: HTMLCanvasElement; + createTextNode(props: Partial): IRendererTextNode; + createNode(props: Partial): IRendererNode; + createShader: typeof lng.RendererMain.prototype.createShader; + createTexture: typeof lng.RendererMain.prototype.createTexture; + createEffect: typeof lng.RendererMain.prototype.createEffect; +} diff --git a/src/dom-renderer/domRendererUtils.ts b/src/dom-renderer/domRendererUtils.ts new file mode 100644 index 0000000..e5a650d --- /dev/null +++ b/src/dom-renderer/domRendererUtils.ts @@ -0,0 +1,155 @@ +// Utilities extracted from domRenderer.ts for clarity +import { TextureMap } from '@lightningjs/renderer'; +import { Config } from '../config.js'; +import { DOMNode } from './domRenderer.js'; + +export const colorToRgba = (c: number) => + `rgba(${(c >> 24) & 0xff},${(c >> 16) & 0xff},${(c >> 8) & 0xff},${(c & 0xff) / 255})`; + +export function buildGradientStops(colors: number[], stops?: number[]): string { + if (!Array.isArray(colors) || colors.length === 0) return ''; + const positions: number[] = []; + if (Array.isArray(stops) && stops.length === colors.length) { + for (let v of stops) { + if (typeof v !== 'number' || !isFinite(v)) { + positions.push(0); + continue; + } + let pct = v <= 1 ? v * 100 : v; + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + positions.push(pct); + } + } else { + const lastIndex = colors.length - 1; + for (let i = 0; i < colors.length; i++) { + positions.push(lastIndex === 0 ? 0 : (i / lastIndex) * 100); + } + } + if (positions.length !== colors.length) { + while (positions.length < colors.length) + positions.push(positions.length === 0 ? 0 : 100); + } + return colors + .map((color, idx) => `${colorToRgba(color)} ${positions[idx]!.toFixed(2)}%`) + .join(', '); +} + +export function getNodeLineHeight(props: { + lineHeight?: number; + fontSize: number; +}): number { + return ( + props.lineHeight ?? Config.fontSettings.lineHeight ?? 1.2 * props.fontSize + ); +} + +/** Legacy object-fit fall back for unsupported browsers */ +export function computeLegacyObjectFit( + node: DOMNode, + img: HTMLImageElement, + resizeMode: ({ type?: string } & Record) | undefined, + clipX: number, + clipY: number, + srcPos: null | { x: number; y: number }, + supportsObjectFit: boolean, + supportsObjectPosition: boolean, +) { + if (supportsObjectFit && supportsObjectPosition) return; + const containerW = node.props.width || img.naturalWidth; + const containerH = node.props.height || img.naturalHeight; + const naturalW = img.naturalWidth || 1; + const naturalH = img.naturalHeight || 1; + let fitType = resizeMode?.type || (srcPos ? 'none' : 'fill'); + let drawW = naturalW; + let drawH = naturalH; + switch (fitType) { + case 'cover': { + const scale = Math.max(containerW / naturalW, containerH / naturalH); + drawW = naturalW * scale; + drawH = naturalH * scale; + break; + } + case 'contain': { + const scale = Math.min(containerW / naturalW, containerH / naturalH); + drawW = naturalW * scale; + drawH = naturalH * scale; + break; + } + case 'fill': { + drawW = containerW; + drawH = containerH; + break; + } + } + let offsetX = (containerW - drawW) * clipX; + let offsetY = (containerH - drawH) * clipY; + if (srcPos) { + offsetX = -srcPos.x; + offsetY = -srcPos.y; + } + const styleParts = [ + 'position: absolute', + `width: ${Math.round(drawW)}px`, + `height: ${Math.round(drawH)}px`, + `left: ${Math.round(offsetX)}px`, + `top: ${Math.round(offsetY)}px`, + 'display: block', + 'pointer-events: none', + ]; + img.style.removeProperty('object-fit'); + img.style.removeProperty('object-position'); + if (resizeMode?.type === 'none') { + styleParts[1] = `width: ${naturalW}px`; + styleParts[2] = `height: ${naturalH}px`; + } + img.setAttribute('style', styleParts.join('; ') + ';'); +} + +export function applySubTextureScaling( + node: DOMNode, + img: HTMLImageElement, + srcPos: InstanceType['props'] | null, +) { + if (!srcPos) return; + const regionW = node.props.srcWidth ?? srcPos.width; + const regionH = node.props.srcHeight ?? srcPos.height; + if (!regionW || !regionH) return; + const targetW = node.props.width || regionW; + const targetH = node.props.height || regionH; + if (targetW === regionW && targetH === regionH) return; + const naturalW = img.naturalWidth || regionW; + const naturalH = img.naturalHeight || regionH; + const scaleX = targetW / regionW; + const scaleY = targetH / regionH; + img.style.width = naturalW + 'px'; + img.style.height = naturalH + 'px'; + img.style.objectFit = 'none'; + img.style.objectPosition = '0 0'; + img.style.transformOrigin = '0 0'; + const translateX = Math.round(-srcPos.x * scaleX); + const translateY = Math.round(-srcPos.y * scaleY); + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`; + img.style.setProperty('-webkit-transform', img.style.transform); + if (node.divBg) { + const styleEl = node.divBg.style; + if ( + styleEl.maskImage || + styleEl.webkitMaskImage || + /mask-image:/.test(node.divBg.getAttribute('style') || '') + ) { + img.style.display = 'none'; + const maskW = Math.round(naturalW * scaleX); + const maskH = Math.round(naturalH * scaleY); + const maskPosX = translateX; + const maskPosY = translateY; + styleEl.setProperty?.('mask-size', `${maskW}px ${maskH}px`); + styleEl.setProperty?.('mask-position', `${maskPosX}px ${maskPosY}px`); + styleEl.setProperty?.('-webkit-mask-size', `${maskW}px ${maskH}px`); + styleEl.setProperty?.( + '-webkit-mask-position', + `${maskPosX}px ${maskPosY}px`, + ); + } + } +} diff --git a/src/elementNode.ts b/src/elementNode.ts index 1eea603..fa66263 100644 --- a/src/elementNode.ts +++ b/src/elementNode.ts @@ -1,56 +1,61 @@ +import type { + BaseShaderController, + EffectDescUnion, + IAnimationController, + INode, + INodeAnimateProps, + INodeProps, + ITextNode, + ITextNodeProps, + LinearGradientEffectProps, + RadialGradientEffectProps, + RadialProgressEffectProps, + RendererMain, + ShaderController, + ShaderRef, +} from '@lightningjs/renderer'; +import { assertTruthy } from '@lightningjs/renderer/utils'; +import simpleAnimation, { SimpleAnimationSettings } from './animation.js'; +import { Config, isDev, SHADERS_ENABLED } from './config.js'; import { IRendererNode, IRendererNodeProps, IRendererShader, - IRendererShaderProps, IRendererTextNode, IRendererTextNodeProps, - renderer, -} from './lightningInit.js'; +} from './dom-renderer/domRendererTypes.js'; +import calculateFlex from './flex.js'; +import { ForwardFocusHandler, setActiveElement } from './focusManager.js'; import { + type AnimationEventHandler, + type AnimationEvents, + type AnimationSettings, type BorderRadius, type BorderStyle, - type StyleEffects, - type AnimationSettings, type ElementText, + type OnEvent, + type StyleEffects, type Styles, - type AnimationEvents, - type AnimationEventHandler, AddColorString, - TextProps, - TextNode, - type OnEvent, NewOmit, + TextNode, + TextProps, } from './intrinsicTypes.js'; +import { renderer } from './lightningInit.js'; +import { NodeType } from './nodeTypes.js'; import States, { type NodeStates } from './states.js'; -import calculateFlex from './flex.js'; import { - log, isArray, - isNumber, - isFunc, - keyExists, - isINode, isElementNode, isElementText, - logRenderTree, + isFunc, isFunction, + isINode, + isNumber, + keyExists, + log, + logRenderTree, } from './utils.js'; -import { Config, isDev, SHADERS_ENABLED } from './config.js'; -import type { - RendererMain, - INode, - INodeAnimateProps, - LinearGradientEffectProps, - IAnimationController, - EffectDescUnion, - RadialGradientEffectProps, - RadialProgressEffectProps, -} from '@lightningjs/renderer'; -import { assertTruthy } from '@lightningjs/renderer/utils'; -import { NodeType } from './nodeTypes.js'; -import { ForwardFocusHandler, setActiveElement } from './focusManager.js'; -import simpleAnimation, { SimpleAnimationSettings } from './animation.js'; let layoutRunQueued = false; const layoutQueue = new Set(); @@ -76,7 +81,7 @@ function addToLayoutQueue(node: ElementNode) { function convertEffectsToShader( node: ElementNode, styleEffects: StyleEffects, -): IRendererShader { +): IRendererShader | ShaderController<'DynamicShader'> { const effects: EffectDescUnion[] = []; for (let type in styleEffects) { const props = styleEffects[type as keyof StyleEffects]; @@ -89,6 +94,7 @@ function convertEffectsToShader( effects.push(renderer.createEffect(type as any, props, type)); } } + return renderer.createShader('DynamicShader', { effects }); } @@ -273,8 +279,9 @@ export interface ElementNode extends RendererNode { * The underlying Lightning Renderer node object. This is where the properties are ultimately set for rendering. */ lng: - | Partial + | INode | IRendererNode + | Partial | (IRendererTextNode & { shader?: any }); /** * A reference to the `ElementNode` instance. Can be an object or a callback function. @@ -702,11 +709,9 @@ export class ElementNode extends Object { return undefined; } - set shader( - shaderProps: IRendererShader | [kind: string, props: IRendererShaderProps], - ) { + set shader(shaderProps: ShaderRef | typeof renderer.createShader) { this.lng.shader = isArray(shaderProps) - ? renderer.createShader(...shaderProps) + ? renderer.createShader(shaderProps[0], shaderProps[1]) : shaderProps; } @@ -757,7 +762,8 @@ export class ElementNode extends Object { } } - (this.lng[name as keyof IRendererNode] as number | string) = value; + (this.lng[name as keyof (IRendererNode | INode)] as number | string) = + value; } animate( @@ -1233,8 +1239,9 @@ export class ElementNode extends Object { } isDev && log('Rendering: ', this, props); + node.lng = renderer.createTextNode( - props as unknown as IRendererTextNodeProps, + props as Partial & Partial, ); if (parent.requiresLayout()) { if (!props.width || !props.height) { @@ -1271,7 +1278,11 @@ export class ElementNode extends Object { } isDev && log('Rendering: ', this, props); - node.lng = renderer.createNode(props as IRendererNodeProps); + + node.lng = renderer.createNode( + props as Partial> & + Partial, + ); if (node._hasRenderedChildren) { node._hasRenderedChildren = false; @@ -1299,14 +1310,15 @@ export class ElementNode extends Object { if (node.onEvent) { for (const [name, handler] of Object.entries(node.onEvent)) { - node.lng.on(name, (_inode, data) => handler.call(node, node, data)); + if (typeof node.lng.on === 'function') { + node.lng.on(name, (_inode, data) => handler.call(node, node, data)); + } } } // L3 Inspector adds div to the lng object - if (node.lng?.div) { - node.lng.div.element = node; - } + const div: HTMLElement | undefined = (node.lng as any)?.div; + if (div) div.element = node; if (node._type === NodeType.Element) { // only element nodes will have children that need rendering diff --git a/src/index.ts b/src/index.ts index 0307185..1ea07c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ export * from './utils.js'; export * from './intrinsicTypes.js'; export * from './focusKeyTypes.js'; export * from './config.js'; +export * from './dom-renderer/domRenderer.js'; +export type * from './dom-renderer/domRendererTypes.js'; export type * from '@lightningjs/renderer'; export { type AnimationSettings } from './intrinsicTypes.js'; // hopefully fix up webpack error diff --git a/src/intrinsicTypes.ts b/src/intrinsicTypes.ts index d2be891..6b54571 100644 --- a/src/intrinsicTypes.ts +++ b/src/intrinsicTypes.ts @@ -14,6 +14,7 @@ import { } from '@lightningjs/renderer'; import { ElementNode, type RendererNode } from './elementNode.js'; import { NodeStates } from './states.js'; +import * as lng from '@lightningjs/renderer'; export type AnimationSettings = Partial; @@ -207,3 +208,7 @@ type EventHandler = ( export type OnEvent = Partial<{ [K in NodeEvents]: EventHandler; }>; + +export type CoreAnimation = Parameters< + lng.Stage['animationManager']['registerAnimation'] +>[0]; diff --git a/src/lightningInit.ts b/src/lightningInit.ts index e62c094..6eb598e 100644 --- a/src/lightningInit.ts +++ b/src/lightningInit.ts @@ -1,113 +1,14 @@ import * as lng from '@lightningjs/renderer'; -import { DOMRendererMain } from './domRenderer.js'; -import { DOM_RENDERING } from './config.js'; +import { + DOMRendererMain, + isDomRenderer, + loadFontToDom, +} from './dom-renderer/domRenderer.js'; +import { Config, DOM_RENDERING } from './config.js'; export type SdfFontType = 'ssdf' | 'msdf'; - -/** Based on {@link lng.CoreRenderer} */ -export interface IRendererCoreRenderer { - mode: 'canvas' | 'webgl' | undefined; -} -/** Based on {@link lng.TrFontManager} */ -export interface IRendererFontManager { - addFontFace: (...a: any[]) => void; -} -/** Based on {@link lng.Stage} */ -export interface IRendererStage { - root: IRendererNode; - renderer: IRendererCoreRenderer; - fontManager: IRendererFontManager; - shManager: IRendererShaderManager; - animationManager: { - registerAnimation: (anim: any) => void; - unregisterAnimation: (anim: any) => void; - }; -} - -/** Based on {@link lng.CoreShaderManager} */ -export interface IRendererShaderManager { - registerShaderType: (name: string, shader: any) => void; -} - -/** Based on {@link lng.CoreShaderNode} */ -export interface IRendererShader { - shaderType: IRendererShaderType; - props?: IRendererShaderProps; - program?: {}; -} -/** Based on {@link lng.CoreShaderType} */ -export interface IRendererShaderType {} -export type IRendererShaderProps = Record; - -/** Based on {@link lng.Texture} */ -export interface IRendererTexture { - props: IRendererTextureProps; - type: lng.TextureType; -} -export interface IRendererTextureProps {} - -export interface IEventEmitter { - on: (e: string, cb: (...a: any[]) => void) => void; -} - -export interface IRendererNodeShaded extends IEventEmitter { - stage: IRendererStage; - id: number; - animate: ( - props: Partial>, - settings: Partial, - ) => lng.IAnimationController; - get absX(): number; - get absY(): number; -} - -/** Based on {@link lng.INodeProps} */ -export interface IRendererNodeProps - extends Omit { - shader: IRendererShader | null; - parent: IRendererNode | null; -} -/** Based on {@link lng.INode} */ -export interface IRendererNode extends IRendererNodeShaded, IRendererNodeProps { - div?: HTMLElement; - props: IRendererNodeProps; - renderState: lng.CoreNodeRenderState; -} - -/** Based on {@link lng.ITextNodeProps} */ -export interface IRendererTextNodeProps - extends Omit { - shader: IRendererShader | null; - parent: IRendererNode | null; -} -/** Based on {@link lng.ITextNode} */ -export interface IRendererTextNode - extends IRendererNodeShaded, - IRendererTextNodeProps { - div?: HTMLElement; - props: IRendererTextNodeProps; - renderState: lng.CoreNodeRenderState; -} - -/** Based on {@link lng.RendererMain} */ -export interface IRendererMain extends IEventEmitter { - stage: IRendererStage; - root: IRendererNode; - createTextNode(props: Partial): IRendererTextNode; - createNode(props: Partial): IRendererNode; - createShader(kind: string, props: IRendererShaderProps): IRendererShader; - createTexture( - kind: keyof lng.TextureMap, - props: IRendererTextureProps, - ): IRendererTexture; - createEffect( - kind: keyof lng.EffectMap, - props: Record, - name?: string, - ): lng.EffectDescUnion; -} - -export let renderer: IRendererMain; +// Global renderer instance: can be either the Lightning or DOM implementation +export let renderer: lng.RendererMain | DOMRendererMain; export const getRenderer = () => renderer; @@ -115,9 +16,11 @@ export function startLightningRenderer( options: lng.RendererMainSettings, rootId: string | HTMLElement = 'app', ) { - renderer = DOM_RENDERING + const enableDomRenderer = DOM_RENDERING && Config.domRendererEnabled; + + renderer = enableDomRenderer ? new DOMRendererMain(options, rootId) - : (new lng.RendererMain(options, rootId) as any as IRendererMain); + : new lng.RendererMain(options, rootId); return renderer; } @@ -143,7 +46,11 @@ export function loadFonts( } // Canvas — Web else if ('fontUrl' in font) { - renderer.stage.fontManager.addFontFace(new lng.WebTrFontFace(font)); + if (DOM_RENDERING && isDomRenderer(renderer)) { + loadFontToDom(font); + } else { + renderer.stage.fontManager.addFontFace(new lng.WebTrFontFace(font)); + } } } }