From d48af791f877bbc2df5085ab65b686e85bd3e5c4 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Wed, 5 Nov 2025 16:26:36 +0100 Subject: [PATCH 01/34] fix: added missing shaders --- src/domRenderer.ts | 123 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/src/domRenderer.ts b/src/domRenderer.ts index e41b0d9..2176689 100644 --- a/src/domRenderer.ts +++ b/src/domRenderer.ts @@ -473,6 +473,129 @@ function updateNodeStyles(node: DOMNode | DOMText) { } break; } + case 'borderTop': { + let borderWidth = effect.props?.width; + let borderColor = effect.props?.color; + if ( + typeof borderWidth === 'number' && + borderWidth !== 0 && + typeof borderColor === 'number' && + borderColor !== 0 + ) { + borderStyle += `border-top: ${borderWidth}px ${colorToRgba(borderColor)};`; + } + break; + } + case 'borderBottom': { + let borderWidth = effect.props?.width; + let borderColor = effect.props?.color; + if ( + typeof borderWidth === 'number' && + borderWidth !== 0 && + typeof borderColor === 'number' && + borderColor !== 0 + ) { + borderStyle += `border-bottom: ${borderWidth}px ${colorToRgba(borderColor)};`; + } + break; + } + case 'radialGradient': { + let stops = effect.props?.stops; + let centerX = effect.props?.centerX ?? 0.5; + let centerY = effect.props?.centerY ?? 0.5; + let radius = effect.props?.radius ?? 1; + + if (Array.isArray(stops) && stops.length >= 2) { + let gradientStops = stops + .map((stop: any) => { + if ( + typeof stop.color === 'number' && + typeof stop.position === 'number' + ) { + return `${colorToRgba(stop.color)} ${stop.position * 100}%`; + } + return null; + }) + .filter(Boolean) + .join(', '); + + if (gradientStops) { + let centerXPercent = centerX * 100; + let centerYPercent = centerY * 100; + let radiusPercent = radius * 100; + + let radialGradient = `radial-gradient(circle ${radiusPercent}% at ${centerXPercent}% ${centerYPercent}%, ${gradientStops})`; + + if (srcImg || gradient) { + // If there's already a background image or gradient, use the radial gradient as a mask + maskStyle += `mask-image: ${radialGradient};`; + } else { + // Use as background if no other background + bgStyle += `background-image: ${radialGradient};`; + bgStyle += `background-repeat: no-repeat;`; + bgStyle += `background-size: 100% 100%;`; + } + } + } + break; + } + case 'linearGradient': { + let stops = effect.props?.stops; + let angle = effect.props?.angle ?? 0; + let startX = effect.props?.startX ?? 0; + let startY = effect.props?.startY ?? 0; + let endX = effect.props?.endX ?? 1; + let endY = effect.props?.endY ?? 1; + + if (Array.isArray(stops) && stops.length >= 2) { + let gradientStops = stops + .map((stop: any) => { + if ( + typeof stop.color === 'number' && + typeof stop.position === 'number' + ) { + return `${colorToRgba(stop.color)} ${stop.position * 100}%`; + } + return null; + }) + .filter(Boolean) + .join(', '); + + if (gradientStops) { + let linearGradient: string; + + if (typeof angle === 'number') { + // Use angle-based gradient + linearGradient = `linear-gradient(${angle}deg, ${gradientStops})`; + } else { + // Use position-based gradient (from point to point) + let startXPercent = startX * 100; + let startYPercent = startY * 100; + let endXPercent = endX * 100; + let endYPercent = endY * 100; + + // Calculate angle from start and end points + let deltaX = endX - startX; + let deltaY = endY - startY; + let angleRad = Math.atan2(deltaY, deltaX); + let angleDeg = (angleRad * 180) / Math.PI + 90; // Convert to CSS angle format + + linearGradient = `linear-gradient(${angleDeg}deg, ${gradientStops})`; + } + + if (srcImg || gradient) { + // If there's already a background image or gradient, use the linear gradient as a mask + maskStyle += `mask-image: ${linearGradient};`; + } else { + // Use as background if no other background + bgStyle += `background-image: ${linearGradient};`; + bgStyle += `background-repeat: no-repeat;`; + bgStyle += `background-size: 100% 100%;`; + } + } + } + break; + } default: console.warn(`Unknown shader effect type: ${effect.type}`); break; From 8429ec3e94b4090447535f2d1e7b0a6b0a0a78ed Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Thu, 6 Nov 2025 20:24:14 +0100 Subject: [PATCH 02/34] fix: dom renderer types --- src/animation.ts | 12 +++++++----- src/domRenderer.ts | 3 +-- src/elementNode.ts | 41 +++++++++++++++++++++++++++-------------- src/lightningInit.ts | 30 ++++++++++++++++++++++++------ 4 files changed, 59 insertions(+), 27 deletions(-) diff --git a/src/animation.ts b/src/animation.ts index b8f812a..6beef80 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -6,7 +6,7 @@ import { type ElementNode, LightningRendererNumberProps, } from './elementNode.js'; -import { type IRendererStage } from './lightningInit.js'; +import { CoreAnimation, renderer } from './lightningInit.js'; /** * Simplified Animation Settings @@ -43,15 +43,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 +173,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/domRenderer.ts b/src/domRenderer.ts index 2176689..1d73b4c 100644 --- a/src/domRenderer.ts +++ b/src/domRenderer.ts @@ -13,11 +13,11 @@ import { IRendererShaderProps, IRendererTextureProps, IRendererTexture, - IRendererMain, IRendererNode, IRendererNodeProps, IRendererTextNode, IRendererTextNodeProps, + IRendererMain, } from './lightningInit.js'; import { EventEmitter } from '@lightningjs/renderer/utils'; @@ -1317,7 +1317,6 @@ function updateRootPosition(this: DOMRendererMain) { export class DOMRendererMain implements IRendererMain { root: DOMNode; canvas: HTMLCanvasElement; - stage: IRendererStage; constructor( diff --git a/src/elementNode.ts b/src/elementNode.ts index 1eea603..aceea5b 100644 --- a/src/elementNode.ts +++ b/src/elementNode.ts @@ -2,7 +2,6 @@ import { IRendererNode, IRendererNodeProps, IRendererShader, - IRendererShaderProps, IRendererTextNode, IRendererTextNodeProps, renderer, @@ -46,6 +45,12 @@ import type { EffectDescUnion, RadialGradientEffectProps, RadialProgressEffectProps, + ShaderController, + ShaderRef, + ITextNode, + ITextNodeProps, + BaseShaderController, + INodeProps, } from '@lightningjs/renderer'; import { assertTruthy } from '@lightningjs/renderer/utils'; import { NodeType } from './nodeTypes.js'; @@ -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 }); } @@ -274,7 +280,9 @@ export interface ElementNode extends RendererNode { */ lng: | Partial - | IRendererNode + | Partial + | Partial + | Partial | (IRendererTextNode & { shader?: any }); /** * A reference to the `ElementNode` instance. Can be an object or a callback function. @@ -702,11 +710,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 +763,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 +1240,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 +1279,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 +1311,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/lightningInit.ts b/src/lightningInit.ts index e62c094..edf9d91 100644 --- a/src/lightningInit.ts +++ b/src/lightningInit.ts @@ -19,11 +19,16 @@ export interface IRendererStage { fontManager: IRendererFontManager; shManager: IRendererShaderManager; animationManager: { - registerAnimation: (anim: any) => void; - unregisterAnimation: (anim: any) => void; + registerAnimation: (anim: CoreAnimation) => void; + unregisterAnimation: (anim: CoreAnimation) => void; }; } +/** Minimal animation interface used by the stage animationManager */ +export type CoreAnimation = Parameters< + lng.Stage['animationManager']['registerAnimation'] +>[0]; + /** Based on {@link lng.CoreShaderManager} */ export interface IRendererShaderManager { registerShaderType: (name: string, shader: any) => void; @@ -31,7 +36,7 @@ export interface IRendererShaderManager { /** Based on {@link lng.CoreShaderNode} */ export interface IRendererShader { - shaderType: IRendererShaderType; + shaderType?: string; props?: IRendererShaderProps; program?: {}; } @@ -93,6 +98,7 @@ export interface IRendererTextNode export interface IRendererMain extends IEventEmitter { stage: IRendererStage; root: IRendererNode; + canvas: HTMLCanvasElement; createTextNode(props: Partial): IRendererTextNode; createNode(props: Partial): IRendererNode; createShader(kind: string, props: IRendererShaderProps): IRendererShader; @@ -105,19 +111,31 @@ export interface IRendererMain extends IEventEmitter { props: Record, name?: string, ): lng.EffectDescUnion; + on(name: string, callback: (target: any, data: any) => void): void; } - -export let renderer: IRendererMain; +// Global renderer instance: can be either the Lightning or DOM implementation +export let renderer: lng.RendererMain | IRendererMain; export const getRenderer = () => renderer; +export function isDomRenderer(r: typeof renderer): r is IRendererMain { + // Heuristic: DOM renderer exposes our minimal stage shape (no txManager) and root.div exists early. + const anyR = r as any; + const hasMinimalStage = + anyR.stage && anyR.stage.renderer && !('txManager' in anyR.stage); + const hasCreateNode = typeof anyR.createNode === 'function'; + const hasDomRootDiv = + !!anyR.root?.div && anyR.stage?.renderer?.mode === 'canvas'; + return hasMinimalStage && hasCreateNode && hasDomRootDiv; +} + export function startLightningRenderer( options: lng.RendererMainSettings, rootId: string | HTMLElement = 'app', ) { renderer = DOM_RENDERING ? new DOMRendererMain(options, rootId) - : (new lng.RendererMain(options, rootId) as any as IRendererMain); + : new lng.RendererMain(options, rootId); return renderer; } From 1cc4cd45fba4e8188b395f7b744649e5d5ced2bb Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Thu, 6 Nov 2025 20:45:32 +0100 Subject: [PATCH 03/34] refactoring: the DOM renderer has been moved to its own folder --- src/animation.ts | 3 +- src/{ => dom-renderer}/domRenderer.ts | 15 ++- src/dom-renderer/domRendererTypes.ts | 109 ++++++++++++++++++++ src/intrinsicTypes.ts | 5 + src/lightningInit.ts | 142 ++++---------------------- 5 files changed, 149 insertions(+), 125 deletions(-) rename src/{ => dom-renderer}/domRenderer.ts (98%) create mode 100644 src/dom-renderer/domRendererTypes.ts diff --git a/src/animation.ts b/src/animation.ts index 6beef80..e1e0954 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -6,7 +6,8 @@ import { type ElementNode, LightningRendererNumberProps, } from './elementNode.js'; -import { CoreAnimation, renderer } from './lightningInit.js'; +import { renderer } from './lightningInit.js'; +import { CoreAnimation } from './intrinsicTypes.js'; /** * Simplified Animation Settings diff --git a/src/domRenderer.ts b/src/dom-renderer/domRenderer.ts similarity index 98% rename from src/domRenderer.ts rename to src/dom-renderer/domRenderer.ts index 1d73b4c..59ac094 100644 --- a/src/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -6,7 +6,7 @@ Experimental DOM renderer import * as lng from '@lightningjs/renderer'; -import { Config } from './config.js'; +import { Config } from '../config.js'; import { IRendererShader, IRendererStage, @@ -18,7 +18,8 @@ import { IRendererTextNode, IRendererTextNodeProps, IRendererMain, -} from './lightningInit.js'; + renderer, +} from '../lightningInit.js'; import { EventEmitter } from '@lightningjs/renderer/utils'; const colorToRgba = (c: number) => @@ -1457,3 +1458,13 @@ export class DOMRendererMain implements IRendererMain { console.log('on', name, callback); } } +export function isDomRenderer(r: typeof renderer): r is IRendererMain { + // Heuristic: DOM renderer exposes our minimal stage shape (no txManager) and root.div exists early. + const anyR = r as any; + const hasMinimalStage = + anyR.stage && anyR.stage.renderer && !('txManager' in anyR.stage); + const hasCreateNode = typeof anyR.createNode === 'function'; + const hasDomRootDiv = + !!anyR.root?.div && anyR.stage?.renderer?.mode === 'canvas'; + return hasMinimalStage && hasCreateNode && hasDomRootDiv; +} diff --git a/src/dom-renderer/domRendererTypes.ts b/src/dom-renderer/domRendererTypes.ts new file mode 100644 index 0000000..2982b48 --- /dev/null +++ b/src/dom-renderer/domRendererTypes.ts @@ -0,0 +1,109 @@ +import * as lng from '@lightningjs/renderer'; +import { CoreAnimation } from '../intrinsicTypes.js'; + +/** 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.CoreShaderNode} */ +export interface IRendererShader { + shaderType?: string; + 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; + canvas: HTMLCanvasElement; + 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; + on(name: string, callback: (target: any, data: any) => void): void; +} 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 edf9d91..4a020f1 100644 --- a/src/lightningInit.ts +++ b/src/lightningInit.ts @@ -1,134 +1,32 @@ import * as lng from '@lightningjs/renderer'; -import { DOMRendererMain } from './domRenderer.js'; +import { DOMRendererMain } from './dom-renderer/domRenderer.js'; import { DOM_RENDERING } from './config.js'; +import type { IRendererMain } from './dom-renderer/domRendererTypes.js'; +export type { + IEventEmitter, + IRendererCoreRenderer, + IRendererFontManager, + IRendererStage, + IRendererShader, + IRendererShaderManager, + IRendererShaderProps, + IRendererShaderType, + IRendererTexture, + IRendererTextureProps, + IRendererNodeShaded, + IRendererNodeProps, + IRendererNode, + IRendererTextNodeProps, + IRendererTextNode, + IRendererMain, +} from './dom-renderer/domRendererTypes.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: CoreAnimation) => void; - unregisterAnimation: (anim: CoreAnimation) => void; - }; -} - -/** Minimal animation interface used by the stage animationManager */ -export type CoreAnimation = Parameters< - lng.Stage['animationManager']['registerAnimation'] ->[0]; - -/** Based on {@link lng.CoreShaderManager} */ -export interface IRendererShaderManager { - registerShaderType: (name: string, shader: any) => void; -} - -/** Based on {@link lng.CoreShaderNode} */ -export interface IRendererShader { - shaderType?: string; - 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; - canvas: HTMLCanvasElement; - 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; - on(name: string, callback: (target: any, data: any) => void): void; -} // Global renderer instance: can be either the Lightning or DOM implementation export let renderer: lng.RendererMain | IRendererMain; export const getRenderer = () => renderer; -export function isDomRenderer(r: typeof renderer): r is IRendererMain { - // Heuristic: DOM renderer exposes our minimal stage shape (no txManager) and root.div exists early. - const anyR = r as any; - const hasMinimalStage = - anyR.stage && anyR.stage.renderer && !('txManager' in anyR.stage); - const hasCreateNode = typeof anyR.createNode === 'function'; - const hasDomRootDiv = - !!anyR.root?.div && anyR.stage?.renderer?.mode === 'canvas'; - return hasMinimalStage && hasCreateNode && hasDomRootDiv; -} - export function startLightningRenderer( options: lng.RendererMainSettings, rootId: string | HTMLElement = 'app', From 7326702861eb219e4e6c2f35bd1291b506c4669f Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Fri, 7 Nov 2025 10:18:55 +0100 Subject: [PATCH 04/34] fix: exports dom-renderer --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) 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 From ec336761773acf63d4ff81f3a44ee9834e57df75 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Fri, 7 Nov 2025 12:06:47 +0100 Subject: [PATCH 05/34] fix: dom renderer event emitter --- src/dom-renderer/domRenderer.ts | 74 ++++++++++++++++++++++++++-- src/dom-renderer/domRendererTypes.ts | 15 ++++-- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 59ac094..4b6107a 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -1319,6 +1319,8 @@ export class DOMRendererMain implements IRendererMain { root: DOMNode; canvas: HTMLCanvasElement; stage: IRendererStage; + private eventListeners: Map void>> = + new Map(); constructor( public settings: lng.RendererMainSettings, @@ -1406,6 +1408,71 @@ export class DOMRendererMain implements IRendererMain { window.addEventListener('resize', updateRootPosition.bind(this)); } + once( + event: Extract, + listener: { [s: string]: (target: any, data: any) => void }[K], + ): void { + const wrappedListener = (target: any, data: any) => { + this.off(event, wrappedListener as any); + 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 as any); + 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)); } @@ -1443,7 +1510,7 @@ export class DOMRendererMain implements IRendererMain { type = lng.TextureType.renderToTexture; break; } - return { type, props }; + return { type, props } as IRendererTexture; } createEffect( @@ -1453,11 +1520,8 @@ export class DOMRendererMain implements IRendererMain { ): lng.EffectDescUnion { return { type, props, name } as any; } - - on(name: string, callback: (target: any, data: any) => void) { - console.log('on', name, callback); - } } + export function isDomRenderer(r: typeof renderer): r is IRendererMain { // Heuristic: DOM renderer exposes our minimal stage shape (no txManager) and root.div exists early. const anyR = r as any; diff --git a/src/dom-renderer/domRendererTypes.ts b/src/dom-renderer/domRendererTypes.ts index 2982b48..df6ee88 100644 --- a/src/dom-renderer/domRendererTypes.ts +++ b/src/dom-renderer/domRendererTypes.ts @@ -37,14 +37,22 @@ export interface IRendererShaderType {} export type IRendererShaderProps = Record; /** Based on {@link lng.Texture} */ -export interface IRendererTexture { +export interface IRendererTexture extends lng.Texture { props: IRendererTextureProps; type: lng.TextureType; } export interface IRendererTextureProps {} -export interface IEventEmitter { - on: (e: string, cb: (...a: any[]) => void) => void; +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 IEventEmitter { @@ -105,5 +113,4 @@ export interface IRendererMain extends IEventEmitter { props: Record, name?: string, ): lng.EffectDescUnion; - on(name: string, callback: (target: any, data: any) => void): void; } From a3d0129b43ed5762d048d48534035bbf16673988 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Fri, 7 Nov 2025 13:34:27 +0100 Subject: [PATCH 06/34] fix: dom renderer props --- src/dom-renderer/domRenderer.ts | 96 ++++++++++++++-------------- src/dom-renderer/domRendererTypes.ts | 22 +++---- src/elementNode.ts | 86 ++++++++++++------------- src/lightningInit.ts | 1 - 4 files changed, 99 insertions(+), 106 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 4b6107a..21f7938 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -8,11 +8,8 @@ import * as lng from '@lightningjs/renderer'; import { Config } from '../config.js'; import { - IRendererShader, IRendererStage, - IRendererShaderProps, IRendererTextureProps, - IRendererTexture, IRendererNode, IRendererNodeProps, IRendererTextNode, @@ -21,6 +18,7 @@ import { renderer, } from '../lightningInit.js'; import { EventEmitter } from '@lightningjs/renderer/utils'; +import { ExtractProps, IRendererShader } from './domRendererTypes.js'; const colorToRgba = (c: number) => `rgba(${(c >> 24) & 0xff},${(c >> 16) & 0xff},${(c >> 8) & 0xff},${(c & 0xff) / 255})`; @@ -426,6 +424,10 @@ function updateNodeStyles(node: DOMNode | DOMText) { bgStyle += `background-size: ${props.textureOptions.resizeMode.type}; background-position: center;`; } else if (srcPos !== null) { bgStyle += `background-position: -${srcPos.x}px -${srcPos.y}px;`; + } else if (props.width && !props.height) { + bgStyle += 'background-size: 100% auto;'; + } else if (props.height && !props.width) { + bgStyle += 'background-size: auto 100%;'; } else { bgStyle += 'background-size: 100% 100%;'; } @@ -460,7 +462,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 ( @@ -469,34 +475,14 @@ 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)};`; - } - break; - } - case 'borderTop': { - let borderWidth = effect.props?.width; - let borderColor = effect.props?.color; - if ( - typeof borderWidth === 'number' && - borderWidth !== 0 && - typeof borderColor === 'number' && - borderColor !== 0 - ) { - borderStyle += `border-top: ${borderWidth}px ${colorToRgba(borderColor)};`; - } - break; - } - case 'borderBottom': { - let borderWidth = effect.props?.width; - let borderColor = effect.props?.color; - if ( - typeof borderWidth === 'number' && - borderWidth !== 0 && - typeof borderColor === 'number' && - borderColor !== 0 - ) { - borderStyle += `border-bottom: ${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; } @@ -728,7 +714,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, @@ -1481,17 +1467,23 @@ 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 { + // Minimal DOM implementation: we don't actually compile shaders, just return a stub matching the expected controller shape. + return { + // Cast to string because ShaderController internally uses the shader key name while our stub keeps it simple. + shaderType: shaderType as string, + props: props as any, + program: {}, + } as unknown as lng.ShaderController; } - createTexture( + createTexture( textureType: keyof lng.TextureMap, props: IRendererTextureProps, - ): IRendererTexture { + ): InstanceType { let type = lng.TextureType.generic; switch (textureType) { case 'SubTexture': @@ -1510,15 +1502,21 @@ export class DOMRendererMain implements IRendererMain { type = lng.TextureType.renderToTexture; break; } - return { type, props } as IRendererTexture; - } - - createEffect( - type: keyof lng.EffectMap, - props: Record, - name?: string, - ): lng.EffectDescUnion { - return { type, props, name } as any; + 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; + }>; } } diff --git a/src/dom-renderer/domRendererTypes.ts b/src/dom-renderer/domRendererTypes.ts index df6ee88..95e31b5 100644 --- a/src/dom-renderer/domRendererTypes.ts +++ b/src/dom-renderer/domRendererTypes.ts @@ -26,11 +26,10 @@ export interface IRendererShaderManager { registerShaderType: (name: string, shader: any) => void; } -/** Based on {@link lng.CoreShaderNode} */ -export interface IRendererShader { +/** Based on {@link lng.WebGlCoreShader} */ +export interface IRendererShader extends Partial { shaderType?: string; props?: IRendererShaderProps; - program?: {}; } /** Based on {@link lng.CoreShaderType} */ export interface IRendererShaderType {} @@ -43,6 +42,10 @@ export interface IRendererTexture extends lng.Texture { } export interface IRendererTextureProps {} +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 }, > { @@ -103,14 +106,7 @@ export interface IRendererMain extends IEventEmitter { canvas: HTMLCanvasElement; 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; + createShader: typeof lng.RendererMain.prototype.createShader; + createTexture: typeof lng.RendererMain.prototype.createTexture; + createEffect: typeof lng.RendererMain.prototype.createEffect; } diff --git a/src/elementNode.ts b/src/elementNode.ts index aceea5b..3cc1408 100644 --- a/src/elementNode.ts +++ b/src/elementNode.ts @@ -1,61 +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 { IRendererShader } from './dom-renderer/domRendererTypes.js'; +import calculateFlex from './flex.js'; +import { ForwardFocusHandler, setActiveElement } from './focusManager.js'; import { - IRendererNode, - IRendererNodeProps, - IRendererShader, - IRendererTextNode, - IRendererTextNodeProps, - renderer, -} from './lightningInit.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 { + IRendererNode, + IRendererNodeProps, + IRendererTextNode, + IRendererTextNodeProps, + 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, - ShaderController, - ShaderRef, - ITextNode, - ITextNodeProps, - BaseShaderController, - INodeProps, -} 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(); diff --git a/src/lightningInit.ts b/src/lightningInit.ts index 4a020f1..e313ce1 100644 --- a/src/lightningInit.ts +++ b/src/lightningInit.ts @@ -7,7 +7,6 @@ export type { IRendererCoreRenderer, IRendererFontManager, IRendererStage, - IRendererShader, IRendererShaderManager, IRendererShaderProps, IRendererShaderType, From d8f203fc3342bd4afa850816ada145f2e2ec8d7b Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Fri, 7 Nov 2025 16:42:38 +0100 Subject: [PATCH 07/34] fix: load dom renderer fonts --- src/dom-renderer/domRenderer.ts | 27 ++++++++++++++++++++++++++- src/lightningInit.ts | 12 ++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 21f7938..deae8d8 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -1360,7 +1360,7 @@ export class DOMRendererMain implements IRendererMain { width: settings.appWidth ?? 1920, height: settings.appHeight ?? 1080, shader: defaultShader, - zIndex: 65534, + zIndex: 0, }), ); this.stage.root = this.root; @@ -1520,6 +1520,31 @@ export class DOMRendererMain implements IRendererMain { } } +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; + + 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: typeof renderer): r is IRendererMain { // Heuristic: DOM renderer exposes our minimal stage shape (no txManager) and root.div exists early. const anyR = r as any; diff --git a/src/lightningInit.ts b/src/lightningInit.ts index e313ce1..f6cd305 100644 --- a/src/lightningInit.ts +++ b/src/lightningInit.ts @@ -1,5 +1,9 @@ import * as lng from '@lightningjs/renderer'; -import { DOMRendererMain } from './dom-renderer/domRenderer.js'; +import { + DOMRendererMain, + isDomRenderer, + loadFontToDom, +} from './dom-renderer/domRenderer.js'; import { DOM_RENDERING } from './config.js'; import type { IRendererMain } from './dom-renderer/domRendererTypes.js'; export type { @@ -58,7 +62,11 @@ export function loadFonts( } // Canvas — Web else if ('fontUrl' in font) { - renderer.stage.fontManager.addFontFace(new lng.WebTrFontFace(font)); + if (isDomRenderer(renderer)) { + loadFontToDom(font); + } else { + renderer.stage.fontManager.addFontFace(new lng.WebTrFontFace(font)); + } } } } From 278ea26fdd0a36500e0e80ab1e51448f66942dfb Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Fri, 7 Nov 2025 16:44:55 +0100 Subject: [PATCH 08/34] fix: dom renderer guard --- src/dom-renderer/domRenderer.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index deae8d8..206ad53 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -1545,13 +1545,8 @@ export function loadFontToDom(font: lng.WebTrFontFaceOptions): void { } } -export function isDomRenderer(r: typeof renderer): r is IRendererMain { - // Heuristic: DOM renderer exposes our minimal stage shape (no txManager) and root.div exists early. - const anyR = r as any; - const hasMinimalStage = - anyR.stage && anyR.stage.renderer && !('txManager' in anyR.stage); - const hasCreateNode = typeof anyR.createNode === 'function'; - const hasDomRootDiv = - !!anyR.root?.div && anyR.stage?.renderer?.mode === 'canvas'; - return hasMinimalStage && hasCreateNode && hasDomRootDiv; +export function isDomRenderer( + r: lng.RendererMain | IRendererMain, +): r is IRendererMain { + return r instanceof DOMRendererMain; } From 360357006e35bcb1b5cbfbd9fe763bc805684aaa Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Fri, 7 Nov 2025 18:14:34 +0100 Subject: [PATCH 09/34] fix: linear gradient --- src/dom-renderer/domRenderer.ts | 45 ++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 206ad53..5189461 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -15,7 +15,6 @@ import { IRendererTextNode, IRendererTextNodeProps, IRendererMain, - renderer, } from '../lightningInit.js'; import { EventEmitter } from '@lightningjs/renderer/utils'; import { ExtractProps, IRendererShader } from './domRendererTypes.js'; @@ -528,11 +527,11 @@ function updateNodeStyles(node: DOMNode | DOMText) { } case 'linearGradient': { let stops = effect.props?.stops; - let angle = effect.props?.angle ?? 0; - let startX = effect.props?.startX ?? 0; - let startY = effect.props?.startY ?? 0; - let endX = effect.props?.endX ?? 1; - let endY = effect.props?.endY ?? 1; + let angle = effect.props?.angle; + let startX = effect.props?.startX; + let startY = effect.props?.startY; + let endX = effect.props?.endX; + let endY = effect.props?.endY; if (Array.isArray(stops) && stops.length >= 2) { let gradientStops = stops @@ -550,24 +549,34 @@ function updateNodeStyles(node: DOMNode | DOMText) { if (gradientStops) { let linearGradient: string; + let hasCustomPoints = + startX != null || + startY != null || + endX != null || + endY != null; if (typeof angle === 'number') { - // Use angle-based gradient linearGradient = `linear-gradient(${angle}deg, ${gradientStops})`; - } else { - // Use position-based gradient (from point to point) - let startXPercent = startX * 100; - let startYPercent = startY * 100; - let endXPercent = endX * 100; - let endYPercent = endY * 100; - - // Calculate angle from start and end points - let deltaX = endX - startX; - let deltaY = endY - startY; + } else if ( + typeof angle === 'string' && + angle.trim().length > 0 + ) { + linearGradient = `linear-gradient(${angle.trim()}, ${gradientStops})`; + } else if (hasCustomPoints) { + let startXVal = startX ?? 0; + let startYVal = startY ?? 0; + let endXVal = endX ?? 1; + let endYVal = endY ?? 1; + + let deltaX = endXVal - startXVal; + let deltaY = endYVal - startYVal; let angleRad = Math.atan2(deltaY, deltaX); let angleDeg = (angleRad * 180) / Math.PI + 90; // Convert to CSS angle format linearGradient = `linear-gradient(${angleDeg}deg, ${gradientStops})`; + } else { + // Default to vertical gradient when no angle or points provided + linearGradient = `linear-gradient(0deg, ${gradientStops})`; } if (srcImg || gradient) { @@ -1360,7 +1369,7 @@ export class DOMRendererMain implements IRendererMain { width: settings.appWidth ?? 1920, height: settings.appHeight ?? 1080, shader: defaultShader, - zIndex: 0, + zIndex: 1, }), ); this.stage.root = this.root; From 6d0ffe38c427677186c50ef4314b393814bfa275 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Fri, 7 Nov 2025 18:32:57 +0100 Subject: [PATCH 10/34] fix: border radius --- src/dom-renderer/domRenderer.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 5189461..e46d388 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -601,20 +601,19 @@ function updateNodeStyles(node: DOMNode | DOMText) { } style += radiusStyle; - bgStyle += radiusStyle; - borderStyle += radiusStyle; if (node.divBg == null) { style += bgStyle; } else { bgStyle += 'position: absolute; inset: 0; z-index: -1;'; - node.divBg.setAttribute('style', bgStyle); + node.divBg.setAttribute('style', bgStyle + radiusStyle); } + if (node.divBorder == null) { style += borderStyle; } else { borderStyle += 'position: absolute; inset: 0; z-index: -1;'; - node.divBorder.setAttribute('style', borderStyle); + node.divBorder.setAttribute('style', borderStyle + radiusStyle); } } From 7dd33304926e19b8d49f415a4c20211220d2307c Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Fri, 7 Nov 2025 20:20:40 +0100 Subject: [PATCH 11/34] feat: coverted background-image in img + added events --- src/dom-renderer/domRenderer.ts | 193 ++++++++++++++++++++++++++------ 1 file changed, 157 insertions(+), 36 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index e46d388..21f03a5 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -385,60 +385,87 @@ function updateNodeStyles(node: DOMNode | DOMText) { let srcImg: string | null = null; let srcPos: null | { x: number; y: number } = 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})`; + rawImgSrc = (props.texture as any).props.texture.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', + 'inset: 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) { - bgStyle += 'background-size: 100% auto;'; + imgStyleParts.push('width: 100%'); + imgStyleParts.push('height: auto'); } else if (props.height && !props.width) { - bgStyle += 'background-size: auto 100%;'; + imgStyleParts.push('width: auto'); + imgStyleParts.push('height: 100%'); } else { - bgStyle += 'background-size: 100% 100%;'; - } - - if (maskStyle !== '') { - bgStyle += maskStyle; + imgStyleParts.push('width: 100%'); + imgStyleParts.push('height: 100%'); + imgStyleParts.push('object-fit: fill'); } - // 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) { + imgStyleParts.push('mix-blend-mode: multiply'); } + + imgStyle = imgStyleParts.join('; ') + ';'; } else if (gradient) { bgStyle += `background-image: ${gradient};`; bgStyle += `background-repeat: no-repeat;`; @@ -600,20 +627,99 @@ function updateNodeStyles(node: DOMNode | DOMText) { } } + if (maskStyle !== '') { + needsBackgroundLayer = true; + } + style += 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; inset: 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.addEventListener('load', () => { + const payload: lng.NodeTextureLoadedPayload = { + type: 'texture', + dimensions: { + width: node.imgEl!.naturalWidth, + height: node.imgEl!.naturalHeight, + }, + }; + node.emit('loaded', payload); + }); + + node.imgEl.addEventListener('error', (e) => { + 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); + } else if (node.imgEl) { + node.imgEl.remove(); + node.imgEl = undefined; + } } else { - bgStyle += 'position: absolute; inset: 0; z-index: -1;'; - node.divBg.setAttribute('style', bgStyle + radiusStyle); + 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 + radiusStyle); + let borderLayerStyle = + 'position: absolute; inset: 0; z-index: -1; pointer-events: none;'; + borderLayerStyle += borderStyle; + node.divBorder.setAttribute('style', borderLayerStyle + radiusStyle); } } @@ -665,7 +771,14 @@ function updateDOMTextSize(node: DOMText): void { if (node.props.height !== size.height) { node.props.height = size.height; updateNodeStyles(node); - node.emit('loaded'); + const payload: lng.NodeTextLoadedPayload = { + type: 'text', + dimensions: { + width: node.props.width, + height: node.props.height, + }, + }; + node.emit('loaded', payload); } break; case 'none': @@ -677,7 +790,14 @@ function updateDOMTextSize(node: DOMText): void { node.props.width = size.width; node.props.height = size.height; updateNodeStyles(node); - node.emit('loaded'); + const payload: lng.NodeTextLoadedPayload = { + type: 'text', + dimensions: { + width: node.props.width, + height: node.props.height, + }, + }; + node.emit('loaded', payload); } break; } @@ -811,6 +931,7 @@ class DOMNode extends EventEmitter implements IRendererNode { div = document.createElement('div'); divBg: HTMLElement | undefined; divBorder: HTMLElement | undefined; + imgEl: HTMLImageElement | undefined; id = ++lastNodeId; From a2f151e33f76bf6e723a4204914c1ea0f0cb7e77 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Fri, 7 Nov 2025 21:09:04 +0100 Subject: [PATCH 12/34] fix: linear gradient --- src/dom-renderer/domRenderer.ts | 116 ++++++++++++++++---------------- 1 file changed, 57 insertions(+), 59 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 21f03a5..80c5d07 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -22,6 +22,39 @@ import { ExtractProps, IRendererShader } from './domRendererTypes.js'; const colorToRgba = (c: number) => `rgba(${(c >> 24) & 0xff},${(c >> 16) & 0xff},${(c >> 8) & 0xff},${(c & 0xff) / 255})`; +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(', '); +} + function applyEasing(easing: string, progress: number): number { switch (easing) { case 'linear': @@ -553,67 +586,32 @@ function updateNodeStyles(node: DOMNode | DOMText) { break; } case 'linearGradient': { - let stops = effect.props?.stops; - let angle = effect.props?.angle; - let startX = effect.props?.startX; - let startY = effect.props?.startY; - let endX = effect.props?.endX; - let endY = effect.props?.endY; - - if (Array.isArray(stops) && stops.length >= 2) { - let gradientStops = stops - .map((stop: any) => { - if ( - typeof stop.color === 'number' && - typeof stop.position === 'number' - ) { - return `${colorToRgba(stop.color)} ${stop.position * 100}%`; - } - return null; - }) - .filter(Boolean) - .join(', '); - + 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) { - let linearGradient: string; - let hasCustomPoints = - startX != null || - startY != null || - endX != null || - endY != null; - - if (typeof angle === 'number') { - linearGradient = `linear-gradient(${angle}deg, ${gradientStops})`; - } else if ( - typeof angle === 'string' && - angle.trim().length > 0 - ) { - linearGradient = `linear-gradient(${angle.trim()}, ${gradientStops})`; - } else if (hasCustomPoints) { - let startXVal = startX ?? 0; - let startYVal = startY ?? 0; - let endXVal = endX ?? 1; - let endYVal = endY ?? 1; - - let deltaX = endXVal - startXVal; - let deltaY = endYVal - startYVal; - let angleRad = Math.atan2(deltaY, deltaX); - let angleDeg = (angleRad * 180) / Math.PI + 90; // Convert to CSS angle format - - linearGradient = `linear-gradient(${angleDeg}deg, ${gradientStops})`; - } else { - // Default to vertical gradient when no angle or points provided - linearGradient = `linear-gradient(0deg, ${gradientStops})`; - } - - if (srcImg || gradient) { - // If there's already a background image or gradient, use the linear gradient as a mask - maskStyle += `mask-image: ${linearGradient};`; + if (colors.length === 1) { + if (srcImg || gradient) { + maskStyle += `mask-image: linear-gradient(${gradientStops});`; + } else { + bgStyle += `background-color: ${colorToRgba(colors[0]!)};`; + } } else { - // Use as background if no other background - bgStyle += `background-image: ${linearGradient};`; - bgStyle += `background-repeat: no-repeat;`; - bgStyle += `background-size: 100% 100%;`; + const angleDeg = (angleRad * 180) / Math.PI; + 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%;`; + } } } } From c984494a46161633ea7d169bd829bad6f24798f2 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Fri, 7 Nov 2025 21:19:26 +0100 Subject: [PATCH 13/34] fix: radial gradient --- src/dom-renderer/domRenderer.ts | 68 ++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 80c5d07..68b3732 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -546,40 +546,46 @@ function updateNodeStyles(node: DOMNode | DOMText) { break; } case 'radialGradient': { - let stops = effect.props?.stops; - let centerX = effect.props?.centerX ?? 0.5; - let centerY = effect.props?.centerY ?? 0.5; - let radius = effect.props?.radius ?? 1; - - if (Array.isArray(stops) && stops.length >= 2) { - let gradientStops = stops - .map((stop: any) => { - if ( - typeof stop.color === 'number' && - typeof stop.position === 'number' - ) { - return `${colorToRgba(stop.color)} ${stop.position * 100}%`; - } - return null; - }) - .filter(Boolean) - .join(', '); + 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) { - let centerXPercent = centerX * 100; - let centerYPercent = centerY * 100; - let radiusPercent = radius * 100; - - let radialGradient = `radial-gradient(circle ${radiusPercent}% at ${centerXPercent}% ${centerYPercent}%, ${gradientStops})`; - - if (srcImg || gradient) { - // If there's already a background image or gradient, use the radial gradient as a mask - maskStyle += `mask-image: ${radialGradient};`; + 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 { - // Use as background if no other background - bgStyle += `background-image: ${radialGradient};`; - bgStyle += `background-repeat: no-repeat;`; - bgStyle += `background-size: 100% 100%;`; + 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) { + sizePart = `${Math.round(width / 2)}px ${Math.round(height / 2)}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%;`; + } } } } From d5973d28eac6fa0cc7add8e7d1fc32c478400b73 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Fri, 7 Nov 2025 21:31:03 +0100 Subject: [PATCH 14/34] fix: angle --- src/dom-renderer/domRenderer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 68b3732..c47a680 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -574,7 +574,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { const pivotY = (pivot[1] ?? 0.5) * 100; let sizePart = ''; if (width > 0 && height > 0) { - sizePart = `${Math.round(width / 2)}px ${Math.round(height / 2)}px`; + sizePart = `${Math.round(width)}px ${Math.round(height)}px`; } else { sizePart = 'closest-side'; } @@ -609,7 +609,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { bgStyle += `background-color: ${colorToRgba(colors[0]!)};`; } } else { - const angleDeg = (angleRad * 180) / Math.PI; + const angleDeg = (angleRad * 60) / Math.PI; const linearGradient = `linear-gradient(${angleDeg.toFixed(2)}deg, ${gradientStops})`; if (srcImg || gradient) { maskStyle += `mask-image: ${linearGradient};`; From f75617c95cab939ec64097bafbde2d3364c43c43 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Fri, 7 Nov 2025 21:34:58 +0100 Subject: [PATCH 15/34] fix: remove image if fails --- src/dom-renderer/domRenderer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index c47a680..e57dd3a 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -674,6 +674,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { }); node.imgEl.addEventListener('error', (e) => { + node.props.src = null; const payload: lng.NodeTextureFailedPayload = { type: 'texture', error: new Error(`Failed to load image: ${rawImgSrc}`), From e15934b52dc778b50cd90268bb941231d2202045 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Sun, 9 Nov 2025 19:52:16 +0100 Subject: [PATCH 16/34] fix: removed cast --- src/dom-renderer/domRenderer.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index e57dd3a..9233269 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -1605,11 +1605,9 @@ export class DOMRendererMain implements IRendererMain { shaderType: ShType, props?: ExtractProps, ): lng.ShaderController { - // Minimal DOM implementation: we don't actually compile shaders, just return a stub matching the expected controller shape. return { - // Cast to string because ShaderController internally uses the shader key name while our stub keeps it simple. - shaderType: shaderType as string, - props: props as any, + shaderType, + props, program: {}, } as unknown as lng.ShaderController; } From b7bf98be8dd2373206c45fb34c389a2dcd71d684 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Mon, 10 Nov 2025 14:15:37 +0100 Subject: [PATCH 17/34] fix: cirle gradient --- src/dom-renderer/domRenderer.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 9233269..8c742ea 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -574,7 +574,11 @@ function updateNodeStyles(node: DOMNode | DOMText) { const pivotY = (pivot[1] ?? 0.5) * 100; let sizePart = ''; if (width > 0 && height > 0) { - sizePart = `${Math.round(width)}px ${Math.round(height)}px`; + if (!isEllipse && width === height) { + sizePart = `${Math.round(width)}px`; + } else { + sizePart = `${Math.round(width)}px ${Math.round(height)}px`; + } } else { sizePart = 'closest-side'; } @@ -867,7 +871,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, @@ -1091,7 +1095,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() { From 6426f42b7504089d0c18991174f95a492bae0e67 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Mon, 10 Nov 2025 16:21:11 +0100 Subject: [PATCH 18/34] fix: fixed linear gradient degree --- src/dom-renderer/domRenderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 8c742ea..ddf83a4 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -613,7 +613,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { bgStyle += `background-color: ${colorToRgba(colors[0]!)};`; } } else { - const angleDeg = (angleRad * 60) / Math.PI; + const angleDeg = 180 * (angleRad / Math.PI - 1); const linearGradient = `linear-gradient(${angleDeg.toFixed(2)}deg, ${gradientStops})`; if (srcImg || gradient) { maskStyle += `mask-image: ${linearGradient};`; From fd457dd3af71168609e94543e158a71439dcb85e Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Tue, 11 Nov 2025 11:56:30 +0100 Subject: [PATCH 19/34] fix: legacy browser compatibility --- src/dom-renderer/domRenderer.ts | 158 +++++++++++++++++++++++++++++++- 1 file changed, 153 insertions(+), 5 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index ddf83a4..33ef321 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -22,6 +22,114 @@ import { ExtractProps, IRendererShader } from './domRendererTypes.js'; const colorToRgba = (c: number) => `rgba(${(c >> 24) & 0xff},${(c >> 16) & 0xff},${(c >> 8) & 0xff},${(c & 0xff) / 255})`; +// Feature detection for legacy Safari (<9) which lacks object-fit / object-position +const supportsObjectFit: boolean = + typeof document !== 'undefined' + ? 'objectFit' in (document.documentElement?.style || {}) + : true; +const supportsObjectPosition: boolean = + typeof document !== 'undefined' + ? 'objectPosition' in (document.documentElement?.style || {}) + : true; +// CSS Masking + blend-mode support detection (standard + webkit prefixes) +const _styleRef: any = + typeof document !== 'undefined' ? document.documentElement?.style || {} : {}; +const supportsStandardMask = 'maskImage' in _styleRef; +const supportsWebkitMask = 'webkitMaskImage' in _styleRef; // legacy Safari +const supportsCssMask = supportsStandardMask || supportsWebkitMask; +const supportsMixBlendMode = 'mixBlendMode' in _styleRef; + +/** + * Compute fallback layout for object-fit / object-position when not supported. + * Only executed after image load (natural dimensions known). + */ +function computeLegacyObjectFit( + node: DOMNode, + img: HTMLImageElement, + resizeMode: ({ type?: string } & Record) | undefined, + clipX: number, + clipY: number, + srcPos: null | { x: number; y: number }, +): void { + if (supportsObjectFit && supportsObjectPosition) return; // No fallback needed + 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; + } + case 'none': + default: { + break; + } + } + + // Positioning (clipX / clipY emulate object-position percentage center default 0.5) + // Negative offsets center the image inside container + let offsetX = (containerW - drawW) * clipX; + let offsetY = (containerH - drawH) * clipY; + + // For subTexture cropping fallback: emulate object-position with translate + if (srcPos) { + // Using transform translate instead of object-position + offsetX = -srcPos.x; + offsetY = -srcPos.y; + } + + // Apply calculated layout styles + 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') { + // explicit none: do not scale + styleParts[1] = `width: ${naturalW}px`; + styleParts[2] = `height: ${naturalH}px`; + } + + // tint fallback still uses mix-blend-mode if relevant + if ( + !supportsObjectFit && + node.props.color !== 0xffffffff && + node.props.color !== 0x00000000 + ) { + styleParts.push('mix-blend-mode: multiply'); + } + + img.setAttribute('style', styleParts.join('; ') + ';'); +} + function buildGradientStops(colors: number[], stops?: number[]): string { if (!Array.isArray(colors) || colors.length === 0) return ''; @@ -463,7 +571,10 @@ function updateNodeStyles(node: DOMNode | DOMText) { const imgStyleParts = [ 'position: absolute', - 'inset: 0', + 'top: 0', + 'left: 0', + 'right: 0', + 'bottom: 0', 'display: block', 'pointer-events: none', ]; @@ -495,7 +606,11 @@ function updateNodeStyles(node: DOMNode | DOMText) { imgStyleParts.push('object-fit: fill'); } if (hasTint) { - imgStyleParts.push('mix-blend-mode: multiply'); + if (supportsMixBlendMode) { + imgStyleParts.push('mix-blend-mode: multiply'); + } else { + imgStyleParts.push('opacity: 0.9'); + } } imgStyle = imgStyleParts.join('; ') + ';'; @@ -636,7 +751,14 @@ function updateNodeStyles(node: DOMNode | DOMText) { } if (maskStyle !== '') { - needsBackgroundLayer = true; + if (!supportsStandardMask && supportsWebkitMask) { + maskStyle = maskStyle.replace(/mask-/g, '-webkit-mask-'); + } else if (!supportsCssMask) { + maskStyle = ''; + } + if (maskStyle !== '') { + needsBackgroundLayer = true; + } } style += radiusStyle; @@ -650,7 +772,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { } let bgLayerStyle = - 'position: absolute; inset: 0; z-index: -1; pointer-events: none; overflow: hidden;'; + 'position: absolute; top:0; left:0; right:0; bottom:0; z-index: -1; pointer-events: none; overflow: hidden;'; if (bgStyle) { bgLayerStyle += bgStyle; } @@ -675,6 +797,18 @@ function updateNodeStyles(node: DOMNode | DOMText) { }, }; node.emit('loaded', payload); + // Apply legacy fallback layout if needed + const resizeMode = (node.props.textureOptions as any)?.resizeMode; + const clipX = (resizeMode as any)?.clipX ?? 0.5; + const clipY = (resizeMode as any)?.clipY ?? 0.5; + computeLegacyObjectFit( + node, + node.imgEl!, + resizeMode, + clipX, + clipY, + srcPos, + ); }); node.imgEl.addEventListener('error', (e) => { @@ -694,6 +828,20 @@ function updateNodeStyles(node: DOMNode | DOMText) { node.divBg.appendChild(node.imgEl); } node.imgEl.setAttribute('style', imgStyle); + // If object-fit unsupported, override with JS fallback after style assignment + if (!supportsObjectFit || !supportsObjectPosition) { + const resizeMode = (node.props.textureOptions as any)?.resizeMode; + const clipX = (resizeMode as any)?.clipX ?? 0.5; + const clipY = (resizeMode as any)?.clipY ?? 0.5; + computeLegacyObjectFit( + node, + node.imgEl, + resizeMode, + clipX, + clipY, + srcPos, + ); + } } else if (node.imgEl) { node.imgEl.remove(); node.imgEl = undefined; @@ -726,7 +874,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { style += borderStyle; } else { let borderLayerStyle = - 'position: absolute; inset: 0; z-index: -1; pointer-events: none;'; + 'position: absolute; top:0; left:0; right:0; bottom:0; z-index: -1; pointer-events: none;'; borderLayerStyle += borderStyle; node.divBorder.setAttribute('style', borderLayerStyle + radiusStyle); } From b0e40f3e7de065f9eb22d472d9a63eef9e496abc Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Tue, 11 Nov 2025 14:19:20 +0100 Subject: [PATCH 20/34] feat: introducind domRenderereEnabled Config --- src/config.ts | 13 +++++++------ src/dom-renderer/domRenderer.ts | 2 +- src/lightningInit.ts | 6 ++++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/config.ts b/src/config.ts index cf981b6..2e35815 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; + domRenderereEnabled?: 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/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 33ef321..8a2fce0 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -1595,7 +1595,7 @@ export class DOMRendererMain implements IRendererMain { new Map(); constructor( - public settings: lng.RendererMainSettings, + public settings: Partial, rawTarget: string | HTMLElement, ) { let target: HTMLElement; diff --git a/src/lightningInit.ts b/src/lightningInit.ts index f6cd305..5a13e3c 100644 --- a/src/lightningInit.ts +++ b/src/lightningInit.ts @@ -4,7 +4,7 @@ import { isDomRenderer, loadFontToDom, } from './dom-renderer/domRenderer.js'; -import { DOM_RENDERING } from './config.js'; +import { Config, DOM_RENDERING } from './config.js'; import type { IRendererMain } from './dom-renderer/domRendererTypes.js'; export type { IEventEmitter, @@ -34,7 +34,9 @@ export function startLightningRenderer( options: lng.RendererMainSettings, rootId: string | HTMLElement = 'app', ) { - renderer = DOM_RENDERING + const enableDomRenderer = DOM_RENDERING && Config.domRenderereEnabled; + + renderer = enableDomRenderer ? new DOMRendererMain(options, rootId) : new lng.RendererMain(options, rootId); return renderer; From 7174fd03d8a922c69b2c7c27ee4866bb4bccce0b Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Tue, 11 Nov 2025 16:42:06 +0100 Subject: [PATCH 21/34] refactor: fix types --- src/dom-renderer/domRenderer.ts | 25 ++++++++++++++++--------- src/dom-renderer/domRendererTypes.ts | 3 ++- src/elementNode.ts | 21 ++++++++++----------- src/lightningInit.ts | 17 ----------------- 4 files changed, 28 insertions(+), 38 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 8a2fce0..9468dab 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -6,18 +6,19 @@ Experimental DOM renderer import * as lng from '@lightningjs/renderer'; +import { EventEmitter } from '@lightningjs/renderer/utils'; import { Config } from '../config.js'; -import { - IRendererStage, - IRendererTextureProps, +import type { + ExtractProps, + IRendererMain, IRendererNode, IRendererNodeProps, + IRendererShader, + IRendererStage, IRendererTextNode, IRendererTextNodeProps, - IRendererMain, -} from '../lightningInit.js'; -import { EventEmitter } from '@lightningjs/renderer/utils'; -import { ExtractProps, IRendererShader } from './domRendererTypes.js'; + IRendererTextureProps, +} from './domRendererTypes.js'; const colorToRgba = (c: number) => `rgba(${(c >> 24) & 0xff},${(c >> 16) & 0xff},${(c >> 8) & 0xff},${(c & 0xff) / 255})`; @@ -1680,12 +1681,18 @@ 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 as any); + this.off(event, wrappedListener); listener(target, data); }; this.on(event, wrappedListener); @@ -1706,7 +1713,7 @@ export class DOMRendererMain implements IRendererMain { ): void { const listeners = this.eventListeners.get(event); if (listeners) { - listeners.delete(listener as any); + listeners.delete(listener); if (listeners.size === 0) { this.eventListeners.delete(event); } diff --git a/src/dom-renderer/domRendererTypes.ts b/src/dom-renderer/domRendererTypes.ts index 95e31b5..6e485ed 100644 --- a/src/dom-renderer/domRendererTypes.ts +++ b/src/dom-renderer/domRendererTypes.ts @@ -1,5 +1,6 @@ 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 { @@ -58,7 +59,7 @@ export interface IEventEmitter< ): void; } -export interface IRendererNodeShaded extends IEventEmitter { +export interface IRendererNodeShaded extends EventEmitter { stage: IRendererStage; id: number; animate: ( diff --git a/src/elementNode.ts b/src/elementNode.ts index 3cc1408..fa66263 100644 --- a/src/elementNode.ts +++ b/src/elementNode.ts @@ -17,7 +17,13 @@ import type { import { assertTruthy } from '@lightningjs/renderer/utils'; import simpleAnimation, { SimpleAnimationSettings } from './animation.js'; import { Config, isDev, SHADERS_ENABLED } from './config.js'; -import { IRendererShader } from './dom-renderer/domRendererTypes.js'; +import { + IRendererNode, + IRendererNodeProps, + IRendererShader, + IRendererTextNode, + IRendererTextNodeProps, +} from './dom-renderer/domRendererTypes.js'; import calculateFlex from './flex.js'; import { ForwardFocusHandler, setActiveElement } from './focusManager.js'; import { @@ -35,13 +41,7 @@ import { TextNode, TextProps, } from './intrinsicTypes.js'; -import { - IRendererNode, - IRendererNodeProps, - IRendererTextNode, - IRendererTextNodeProps, - renderer, -} from './lightningInit.js'; +import { renderer } from './lightningInit.js'; import { NodeType } from './nodeTypes.js'; import States, { type NodeStates } from './states.js'; import { @@ -279,10 +279,9 @@ export interface ElementNode extends RendererNode { * The underlying Lightning Renderer node object. This is where the properties are ultimately set for rendering. */ lng: + | INode + | IRendererNode | Partial - | Partial - | Partial - | Partial | (IRendererTextNode & { shader?: any }); /** * A reference to the `ElementNode` instance. Can be an object or a callback function. diff --git a/src/lightningInit.ts b/src/lightningInit.ts index 5a13e3c..abdeb9c 100644 --- a/src/lightningInit.ts +++ b/src/lightningInit.ts @@ -6,23 +6,6 @@ import { } from './dom-renderer/domRenderer.js'; import { Config, DOM_RENDERING } from './config.js'; import type { IRendererMain } from './dom-renderer/domRendererTypes.js'; -export type { - IEventEmitter, - IRendererCoreRenderer, - IRendererFontManager, - IRendererStage, - IRendererShaderManager, - IRendererShaderProps, - IRendererShaderType, - IRendererTexture, - IRendererTextureProps, - IRendererNodeShaded, - IRendererNodeProps, - IRendererNode, - IRendererTextNodeProps, - IRendererTextNode, - IRendererMain, -} from './dom-renderer/domRendererTypes.js'; export type SdfFontType = 'ssdf' | 'msdf'; // Global renderer instance: can be either the Lightning or DOM implementation From 9a35ec6c63fb8d1897510bae38729eaa23e62757 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Tue, 11 Nov 2025 17:48:28 +0100 Subject: [PATCH 22/34] fix: subtexture size --- src/dom-renderer/domRenderer.ts | 71 +++++++++++++++++++++++++++++++-- src/lightningInit.ts | 3 +- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 9468dab..92c0f53 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -131,6 +131,66 @@ function computeLegacyObjectFit( img.setAttribute('style', styleParts.join('; ') + ';'); } +/** + * Scale and position an element so that a subTexture region (srcX/srcY/srcWidth/srcHeight) + * is displayed at the requested node width/height, rather than its intrinsic region size. + * Works in modern browsers supporting object-position. For legacy fallback we keep existing behavior. + */ +function applySubTextureScaling( + node: DOMNode, + img: HTMLImageElement, + srcPos: { x: number; y: number } | null, +) { + if (!srcPos) return; + const regionW = (node.props as any).srcWidth ?? (srcPos as any).width; + const regionH = (node.props as any).srcHeight ?? (srcPos as any).height; + if (!regionW || !regionH) return; // nothing to scale + const targetW = node.props.width || regionW; + const targetH = node.props.height || regionH; + // If target matches region, default logic suffices. + if (targetW === regionW && targetH === regionH) return; + + const naturalW = img.naturalWidth || regionW; + const naturalH = img.naturalHeight || regionH; + const scaleX = targetW / regionW; + const scaleY = targetH / regionH; + + // Strategy: scale the image element so the selected region maps to target size. + // We don't rely on object-position because we also have to keep mask (if present) in sync. + 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 = -srcPos.x; + const translateY = -srcPos.y; + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`; + + // Adjust mask sizing/position if a mask is used on the background layer. + // (Mask is applied to node.divBg, not the image.) + if (node.divBg) { + const styleEl = node.divBg.style as any; + if ( + styleEl.maskImage || + styleEl.webkitMaskImage || + /mask-image:/.test(node.divBg.getAttribute('style') || '') + ) { + const maskW = Math.round(naturalW * scaleX); + const maskH = Math.round(naturalH * scaleY); + const maskPosX = Math.round(translateX * scaleX); + const maskPosY = Math.round(translateY * scaleY); + + 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`, + ); + } + } +} + function buildGradientStops(colors: number[], stops?: number[]): string { if (!Array.isArray(colors) || colors.length === 0) return ''; @@ -797,8 +857,10 @@ function updateNodeStyles(node: DOMNode | DOMText) { height: node.imgEl!.naturalHeight, }, }; - node.emit('loaded', payload); - // Apply legacy fallback layout if needed + // SubTexture scaling (modern browsers). Executes before legacy fallback so fallback can override if needed. + applySubTextureScaling(node, node.imgEl!, srcPos); + + // Apply legacy fallback layout if needed (older Safari). This may override scaling for unsupported engines. const resizeMode = (node.props.textureOptions as any)?.resizeMode; const clipX = (resizeMode as any)?.clipX ?? 0.5; const clipY = (resizeMode as any)?.clipY ?? 0.5; @@ -810,6 +872,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { clipY, srcPos, ); + node.emit('loaded', payload); }); node.imgEl.addEventListener('error', (e) => { @@ -1837,7 +1900,7 @@ export function loadFontToDom(font: lng.WebTrFontFaceOptions): void { } export function isDomRenderer( - r: lng.RendererMain | IRendererMain, -): r is IRendererMain { + r: lng.RendererMain | DOMRendererMain, +): r is DOMRendererMain { return r instanceof DOMRendererMain; } diff --git a/src/lightningInit.ts b/src/lightningInit.ts index abdeb9c..902b2d8 100644 --- a/src/lightningInit.ts +++ b/src/lightningInit.ts @@ -5,11 +5,10 @@ import { loadFontToDom, } from './dom-renderer/domRenderer.js'; import { Config, DOM_RENDERING } from './config.js'; -import type { IRendererMain } from './dom-renderer/domRendererTypes.js'; export type SdfFontType = 'ssdf' | 'msdf'; // Global renderer instance: can be either the Lightning or DOM implementation -export let renderer: lng.RendererMain | IRendererMain; +export let renderer: lng.RendererMain | DOMRendererMain; export const getRenderer = () => renderer; From 52c91894c31404ef99f5a4d91c9232e8dd62df5b Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Tue, 11 Nov 2025 18:03:46 +0100 Subject: [PATCH 23/34] refactor: moved utils --- src/dom-renderer/domRenderer.ts | 226 +++------------------------ src/dom-renderer/domRendererTypes.ts | 7 +- src/dom-renderer/domRendererUtils.ts | 152 ++++++++++++++++++ 3 files changed, 178 insertions(+), 207 deletions(-) create mode 100644 src/dom-renderer/domRendererUtils.ts diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 92c0f53..e5cc4cf 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -19,210 +19,24 @@ import type { IRendererTextNodeProps, IRendererTextureProps, } from './domRendererTypes.js'; - -const colorToRgba = (c: number) => - `rgba(${(c >> 24) & 0xff},${(c >> 16) & 0xff},${(c >> 8) & 0xff},${(c & 0xff) / 255})`; - -// Feature detection for legacy Safari (<9) which lacks object-fit / object-position -const supportsObjectFit: boolean = - typeof document !== 'undefined' - ? 'objectFit' in (document.documentElement?.style || {}) - : true; -const supportsObjectPosition: boolean = - typeof document !== 'undefined' - ? 'objectPosition' in (document.documentElement?.style || {}) - : true; -// CSS Masking + blend-mode support detection (standard + webkit prefixes) +import { + colorToRgba, + buildGradientStops, + computeLegacyObjectFit, + applySubTextureScaling, + getNodeLineHeight, +} from './domRendererUtils.js'; + +// Feature detection for legacy brousers const _styleRef: any = typeof document !== 'undefined' ? document.documentElement?.style || {} : {}; -const supportsStandardMask = 'maskImage' in _styleRef; -const supportsWebkitMask = 'webkitMaskImage' in _styleRef; // legacy Safari -const supportsCssMask = supportsStandardMask || supportsWebkitMask; -const supportsMixBlendMode = 'mixBlendMode' in _styleRef; - -/** - * Compute fallback layout for object-fit / object-position when not supported. - * Only executed after image load (natural dimensions known). - */ -function computeLegacyObjectFit( - node: DOMNode, - img: HTMLImageElement, - resizeMode: ({ type?: string } & Record) | undefined, - clipX: number, - clipY: number, - srcPos: null | { x: number; y: number }, -): void { - if (supportsObjectFit && supportsObjectPosition) return; // No fallback needed - 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; - } - case 'none': - default: { - break; - } - } - - // Positioning (clipX / clipY emulate object-position percentage center default 0.5) - // Negative offsets center the image inside container - let offsetX = (containerW - drawW) * clipX; - let offsetY = (containerH - drawH) * clipY; - - // For subTexture cropping fallback: emulate object-position with translate - if (srcPos) { - // Using transform translate instead of object-position - offsetX = -srcPos.x; - offsetY = -srcPos.y; - } - - // Apply calculated layout styles - 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') { - // explicit none: do not scale - styleParts[1] = `width: ${naturalW}px`; - styleParts[2] = `height: ${naturalH}px`; - } - - // tint fallback still uses mix-blend-mode if relevant - if ( - !supportsObjectFit && - node.props.color !== 0xffffffff && - node.props.color !== 0x00000000 - ) { - styleParts.push('mix-blend-mode: multiply'); - } - img.setAttribute('style', styleParts.join('; ') + ';'); -} - -/** - * Scale and position an element so that a subTexture region (srcX/srcY/srcWidth/srcHeight) - * is displayed at the requested node width/height, rather than its intrinsic region size. - * Works in modern browsers supporting object-position. For legacy fallback we keep existing behavior. - */ -function applySubTextureScaling( - node: DOMNode, - img: HTMLImageElement, - srcPos: { x: number; y: number } | null, -) { - if (!srcPos) return; - const regionW = (node.props as any).srcWidth ?? (srcPos as any).width; - const regionH = (node.props as any).srcHeight ?? (srcPos as any).height; - if (!regionW || !regionH) return; // nothing to scale - const targetW = node.props.width || regionW; - const targetH = node.props.height || regionH; - // If target matches region, default logic suffices. - if (targetW === regionW && targetH === regionH) return; - - const naturalW = img.naturalWidth || regionW; - const naturalH = img.naturalHeight || regionH; - const scaleX = targetW / regionW; - const scaleY = targetH / regionH; - - // Strategy: scale the image element so the selected region maps to target size. - // We don't rely on object-position because we also have to keep mask (if present) in sync. - 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 = -srcPos.x; - const translateY = -srcPos.y; - img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`; - - // Adjust mask sizing/position if a mask is used on the background layer. - // (Mask is applied to node.divBg, not the image.) - if (node.divBg) { - const styleEl = node.divBg.style as any; - if ( - styleEl.maskImage || - styleEl.webkitMaskImage || - /mask-image:/.test(node.divBg.getAttribute('style') || '') - ) { - const maskW = Math.round(naturalW * scaleX); - const maskH = Math.round(naturalH * scaleY); - const maskPosX = Math.round(translateX * scaleX); - const maskPosY = Math.round(translateY * scaleY); - - 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`, - ); - } - } -} - -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(', '); -} +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) { @@ -452,11 +266,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; @@ -871,6 +681,8 @@ function updateNodeStyles(node: DOMNode | DOMText) { clipX, clipY, srcPos, + supportsObjectFit, + supportsObjectPosition, ); node.emit('loaded', payload); }); @@ -904,6 +716,8 @@ function updateNodeStyles(node: DOMNode | DOMText) { clipX, clipY, srcPos, + supportsObjectFit, + supportsObjectPosition, ); } } else if (node.imgEl) { diff --git a/src/dom-renderer/domRendererTypes.ts b/src/dom-renderer/domRendererTypes.ts index 6e485ed..2da6e77 100644 --- a/src/dom-renderer/domRendererTypes.ts +++ b/src/dom-renderer/domRendererTypes.ts @@ -2,6 +2,11 @@ import * as lng from '@lightningjs/renderer'; import { CoreAnimation } from '../intrinsicTypes.js'; import { EventEmitter } from '@lightningjs/renderer/utils'; +export interface DomNodeLike { + props: any; + divBg?: HTMLElement; +} + /** Based on {@link lng.CoreRenderer} */ export interface IRendererCoreRenderer { mode: 'canvas' | 'webgl' | undefined; @@ -110,4 +115,4 @@ export interface IRendererMain extends IEventEmitter { createShader: typeof lng.RendererMain.prototype.createShader; createTexture: typeof lng.RendererMain.prototype.createTexture; createEffect: typeof lng.RendererMain.prototype.createEffect; -} +} // Minimal shape used by helpers to avoid circular import of DOMNode diff --git a/src/dom-renderer/domRendererUtils.ts b/src/dom-renderer/domRendererUtils.ts new file mode 100644 index 0000000..8392f08 --- /dev/null +++ b/src/dom-renderer/domRendererUtils.ts @@ -0,0 +1,152 @@ +// Utilities extracted from domRenderer.ts for clarity +import { Config } from '../config.js'; +import { DomNodeLike } from './domRendererTypes.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: DomNodeLike, + 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: DomNodeLike, + img: HTMLImageElement, + srcPos: { x: number; y: number } | null, +) { + if (!srcPos) return; + const regionW = (node.props as any).srcWidth ?? (srcPos as any).width; + const regionH = (node.props as any).srcHeight ?? (srcPos as any).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 = -srcPos.x; + const translateY = -srcPos.y; + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`; + if (node.divBg) { + const styleEl = node.divBg.style as any; + if ( + styleEl.maskImage || + styleEl.webkitMaskImage || + /mask-image:/.test(node.divBg.getAttribute('style') || '') + ) { + const maskW = Math.round(naturalW * scaleX); + const maskH = Math.round(naturalH * scaleY); + const maskPosX = Math.round(translateX * scaleX); + const maskPosY = Math.round(translateY * scaleY); + 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`, + ); + } + } +} From afba3d622e9dda0fe1a39b4575ec181e089fe771 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Fri, 14 Nov 2025 17:31:57 +0100 Subject: [PATCH 24/34] fix: clean --- src/dom-renderer/domRendererTypes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dom-renderer/domRendererTypes.ts b/src/dom-renderer/domRendererTypes.ts index 2da6e77..3c19058 100644 --- a/src/dom-renderer/domRendererTypes.ts +++ b/src/dom-renderer/domRendererTypes.ts @@ -107,12 +107,12 @@ export interface IRendererTextNode /** Based on {@link lng.RendererMain} */ export interface IRendererMain extends IEventEmitter { - stage: IRendererStage; 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; -} // Minimal shape used by helpers to avoid circular import of DOMNode +} From caa4bf781d9edb7d6a9317cdf4982ae5211e7093 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Mon, 17 Nov 2025 16:15:00 +0100 Subject: [PATCH 25/34] fix: treeshaking loadFont --- src/lightningInit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lightningInit.ts b/src/lightningInit.ts index 902b2d8..eed2992 100644 --- a/src/lightningInit.ts +++ b/src/lightningInit.ts @@ -46,7 +46,7 @@ export function loadFonts( } // Canvas — Web else if ('fontUrl' in font) { - if (isDomRenderer(renderer)) { + if (DOM_RENDERING && isDomRenderer(renderer)) { loadFontToDom(font); } else { renderer.stage.fontManager.addFontFace(new lng.WebTrFontFace(font)); From d24684dc8320853bdb556ad7cd6d924cba20f0c7 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Mon, 17 Nov 2025 18:28:50 +0100 Subject: [PATCH 26/34] fix: types --- src/dom-renderer/domRenderer.ts | 17 ++++++++++------- src/dom-renderer/domRendererTypes.ts | 12 ------------ src/dom-renderer/domRendererUtils.ts | 16 +++++++++------- 3 files changed, 19 insertions(+), 26 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index e5cc4cf..33099fe 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -17,7 +17,6 @@ import type { IRendererStage, IRendererTextNode, IRendererTextNodeProps, - IRendererTextureProps, } from './domRendererTypes.js'; import { colorToRgba, @@ -396,15 +395,19 @@ 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; - rawImgSrc = (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) { rawImgSrc = props.src; } @@ -962,7 +965,7 @@ 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; @@ -1649,8 +1652,8 @@ export class DOMRendererMain implements IRendererMain { } createTexture( - textureType: keyof lng.TextureMap, - props: IRendererTextureProps, + textureType: Type, + props: ExtractProps, ): InstanceType { let type = lng.TextureType.generic; switch (textureType) { diff --git a/src/dom-renderer/domRendererTypes.ts b/src/dom-renderer/domRendererTypes.ts index 3c19058..d3bbc59 100644 --- a/src/dom-renderer/domRendererTypes.ts +++ b/src/dom-renderer/domRendererTypes.ts @@ -2,11 +2,6 @@ import * as lng from '@lightningjs/renderer'; import { CoreAnimation } from '../intrinsicTypes.js'; import { EventEmitter } from '@lightningjs/renderer/utils'; -export interface DomNodeLike { - props: any; - divBg?: HTMLElement; -} - /** Based on {@link lng.CoreRenderer} */ export interface IRendererCoreRenderer { mode: 'canvas' | 'webgl' | undefined; @@ -41,13 +36,6 @@ export interface IRendererShader extends Partial { export interface IRendererShaderType {} export type IRendererShaderProps = Record; -/** Based on {@link lng.Texture} */ -export interface IRendererTexture extends lng.Texture { - props: IRendererTextureProps; - type: lng.TextureType; -} -export interface IRendererTextureProps {} - export type ExtractProps = Type extends { z$__type__Props: infer Props } ? Props : never; diff --git a/src/dom-renderer/domRendererUtils.ts b/src/dom-renderer/domRendererUtils.ts index 8392f08..44b372c 100644 --- a/src/dom-renderer/domRendererUtils.ts +++ b/src/dom-renderer/domRendererUtils.ts @@ -1,6 +1,7 @@ // Utilities extracted from domRenderer.ts for clarity +import { TextureMap } from '@lightningjs/renderer'; import { Config } from '../config.js'; -import { DomNodeLike } from './domRendererTypes.js'; +import { DOMNode } from './domRenderer.js'; export const colorToRgba = (c: number) => `rgba(${(c >> 24) & 0xff},${(c >> 16) & 0xff},${(c >> 8) & 0xff},${(c & 0xff) / 255})`; @@ -45,7 +46,7 @@ export function getNodeLineHeight(props: { /** Legacy object-fit fall back for unsupported browsers */ export function computeLegacyObjectFit( - node: DomNodeLike, + node: DOMNode, img: HTMLImageElement, resizeMode: ({ type?: string } & Record) | undefined, clipX: number, @@ -106,13 +107,13 @@ export function computeLegacyObjectFit( } export function applySubTextureScaling( - node: DomNodeLike, + node: DOMNode, img: HTMLImageElement, - srcPos: { x: number; y: number } | null, + srcPos: InstanceType['props'] | null, ) { if (!srcPos) return; - const regionW = (node.props as any).srcWidth ?? (srcPos as any).width; - const regionH = (node.props as any).srcHeight ?? (srcPos as any).height; + 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; @@ -129,8 +130,9 @@ export function applySubTextureScaling( const translateX = -srcPos.x; const translateY = -srcPos.y; 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 as any; + const styleEl = node.divBg.style; if ( styleEl.maskImage || styleEl.webkitMaskImage || From 18799727ee27352d1657a5ee9edf03d063ab84a7 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Mon, 17 Nov 2025 19:02:41 +0100 Subject: [PATCH 27/34] fix: subtexture scale --- src/dom-renderer/domRenderer.ts | 25 ++++++------------------- src/dom-renderer/domRendererUtils.ts | 8 ++++---- 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 33099fe..12bc978 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -670,23 +670,6 @@ function updateNodeStyles(node: DOMNode | DOMText) { height: node.imgEl!.naturalHeight, }, }; - // SubTexture scaling (modern browsers). Executes before legacy fallback so fallback can override if needed. - applySubTextureScaling(node, node.imgEl!, srcPos); - - // Apply legacy fallback layout if needed (older Safari). This may override scaling for unsupported engines. - const resizeMode = (node.props.textureOptions as any)?.resizeMode; - const clipX = (resizeMode as any)?.clipX ?? 0.5; - const clipY = (resizeMode as any)?.clipY ?? 0.5; - computeLegacyObjectFit( - node, - node.imgEl!, - resizeMode, - clipX, - clipY, - srcPos, - supportsObjectFit, - supportsObjectPosition, - ); node.emit('loaded', payload); }); @@ -707,8 +690,12 @@ function updateNodeStyles(node: DOMNode | DOMText) { node.divBg.appendChild(node.imgEl); } node.imgEl.setAttribute('style', imgStyle); - // If object-fit unsupported, override with JS fallback after style assignment - if (!supportsObjectFit || !supportsObjectPosition) { + + if (srcPos && node.imgEl.complete) { + applySubTextureScaling(node, node.imgEl, srcPos); + } + // Fallback legacy se necessario e non SubTexture. + if (!srcPos && (!supportsObjectFit || !supportsObjectPosition)) { const resizeMode = (node.props.textureOptions as any)?.resizeMode; const clipX = (resizeMode as any)?.clipX ?? 0.5; const clipY = (resizeMode as any)?.clipY ?? 0.5; diff --git a/src/dom-renderer/domRendererUtils.ts b/src/dom-renderer/domRendererUtils.ts index 44b372c..2c6ad01 100644 --- a/src/dom-renderer/domRendererUtils.ts +++ b/src/dom-renderer/domRendererUtils.ts @@ -127,8 +127,8 @@ export function applySubTextureScaling( img.style.objectFit = 'none'; img.style.objectPosition = '0 0'; img.style.transformOrigin = '0 0'; - const translateX = -srcPos.x; - const translateY = -srcPos.y; + const translateX = -srcPos.x * scaleX; + const translateY = -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) { @@ -140,8 +140,8 @@ export function applySubTextureScaling( ) { const maskW = Math.round(naturalW * scaleX); const maskH = Math.round(naturalH * scaleY); - const maskPosX = Math.round(translateX * scaleX); - const maskPosY = Math.round(translateY * scaleY); + const maskPosX = Math.round(translateX); + const maskPosY = Math.round(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`); From f7b43e5f5da1d4ef42e630d0ee5ff539a3710a90 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Mon, 17 Nov 2025 19:50:18 +0100 Subject: [PATCH 28/34] fix: hide image failed --- src/dom-renderer/domRenderer.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 12bc978..cb0a024 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -673,8 +673,12 @@ function updateNodeStyles(node: DOMNode | DOMText) { node.emit('loaded', payload); }); - node.imgEl.addEventListener('error', (e) => { - node.props.src = null; + 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}`), From 87a38e96332c0b46b58958dbfbbff2d03bc6e2ed Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Mon, 17 Nov 2025 19:55:32 +0100 Subject: [PATCH 29/34] fix: typo --- src/config.ts | 2 +- src/lightningInit.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 2e35815..2db89a5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -40,7 +40,7 @@ export interface Config { animationsEnabled: boolean; animationSettings?: AnimationSettings; debug: boolean; - domRenderereEnabled?: boolean; + domRendererEnabled?: boolean; focusDebug: boolean; focusStateKey: DollarString; fontSettings: Partial; diff --git a/src/lightningInit.ts b/src/lightningInit.ts index eed2992..6eb598e 100644 --- a/src/lightningInit.ts +++ b/src/lightningInit.ts @@ -16,7 +16,7 @@ export function startLightningRenderer( options: lng.RendererMainSettings, rootId: string | HTMLElement = 'app', ) { - const enableDomRenderer = DOM_RENDERING && Config.domRenderereEnabled; + const enableDomRenderer = DOM_RENDERING && Config.domRendererEnabled; renderer = enableDomRenderer ? new DOMRendererMain(options, rootId) From ed6d6f41cb0ed1aa454c2abb0e7d3a9ceddb8c61 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Tue, 18 Nov 2025 12:18:01 +0100 Subject: [PATCH 30/34] fix: subtexture not yet loaded --- src/dom-renderer/domRenderer.ts | 4 +++- src/dom-renderer/domRendererUtils.ts | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index cb0a024..5dccece 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -661,6 +661,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { 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 = { @@ -670,6 +671,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { height: node.imgEl!.naturalHeight, }, }; + applySubTextureScaling(node, node.imgEl!, srcPos); node.emit('loaded', payload); }); @@ -699,7 +701,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { applySubTextureScaling(node, node.imgEl, srcPos); } // Fallback legacy se necessario e non SubTexture. - if (!srcPos && (!supportsObjectFit || !supportsObjectPosition)) { + if (!supportsObjectFit || !supportsObjectPosition) { const resizeMode = (node.props.textureOptions as any)?.resizeMode; const clipX = (resizeMode as any)?.clipX ?? 0.5; const clipY = (resizeMode as any)?.clipY ?? 0.5; diff --git a/src/dom-renderer/domRendererUtils.ts b/src/dom-renderer/domRendererUtils.ts index 2c6ad01..e5a650d 100644 --- a/src/dom-renderer/domRendererUtils.ts +++ b/src/dom-renderer/domRendererUtils.ts @@ -127,8 +127,8 @@ export function applySubTextureScaling( img.style.objectFit = 'none'; img.style.objectPosition = '0 0'; img.style.transformOrigin = '0 0'; - const translateX = -srcPos.x * scaleX; - const translateY = -srcPos.y * scaleY; + 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) { @@ -138,10 +138,11 @@ export function applySubTextureScaling( 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 = Math.round(translateX); - const maskPosY = Math.round(translateY); + 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`); From 6070c80ff0541e63951d912f43e56064961e360b Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Tue, 18 Nov 2025 12:40:01 +0100 Subject: [PATCH 31/34] fix: legacy --- src/dom-renderer/domRenderer.ts | 43 +++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 5dccece..9987da1 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -672,6 +672,20 @@ function updateNodeStyles(node: DOMNode | DOMText) { }, }; applySubTextureScaling(node, node.imgEl!, srcPos); + // Apply legacy fallback layout if needed (older Safari). This may override scaling for unsupported engines. + 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); }); @@ -703,8 +717,8 @@ function updateNodeStyles(node: DOMNode | DOMText) { // Fallback legacy se necessario e non SubTexture. if (!supportsObjectFit || !supportsObjectPosition) { const resizeMode = (node.props.textureOptions as any)?.resizeMode; - const clipX = (resizeMode as any)?.clipX ?? 0.5; - const clipY = (resizeMode as any)?.clipY ?? 0.5; + const clipX = resizeMode?.clipX ?? 0.5; + const clipY = resizeMode?.clipY ?? 0.5; computeLegacyObjectFit( node, node.imgEl, @@ -802,14 +816,6 @@ function updateDOMTextSize(node: DOMText): void { if (node.props.height !== size.height) { node.props.height = size.height; updateNodeStyles(node); - const payload: lng.NodeTextLoadedPayload = { - type: 'text', - dimensions: { - width: node.props.width, - height: node.props.height, - }, - }; - node.emit('loaded', payload); } break; case 'none': @@ -821,17 +827,18 @@ function updateDOMTextSize(node: DOMText): void { node.props.width = size.width; node.props.height = size.height; updateNodeStyles(node); - const payload: lng.NodeTextLoadedPayload = { - type: 'text', - dimensions: { - width: node.props.width, - height: node.props.height, - }, - }; - node.emit('loaded', payload); } break; } + + const payload: lng.NodeTextLoadedPayload = { + type: 'text', + dimensions: { + width: node.props.width, + height: node.props.height, + }, + }; + node.emit('loaded', payload); } function updateDOMTextMeasurements() { From 42caabf06ec70286ba8f7441bb020dd89c62b0ba Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Tue, 18 Nov 2025 14:51:38 +0100 Subject: [PATCH 32/34] fix: added css legacy --- src/dom-renderer/domRenderer.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 9987da1..5a3e903 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -357,6 +357,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { } case 'none': style += `width: max-content;`; + style += `width: -webkit-max-content;`; break; } @@ -672,7 +673,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { }, }; applySubTextureScaling(node, node.imgEl!, srcPos); - // Apply legacy fallback layout if needed (older Safari). This may override scaling for unsupported engines. + const resizeMode = (node.props.textureOptions as any)?.resizeMode; const clipX = resizeMode?.clipX ?? 0.5; const clipY = resizeMode?.clipY ?? 0.5; @@ -714,8 +715,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { if (srcPos && node.imgEl.complete) { applySubTextureScaling(node, node.imgEl, srcPos); } - // Fallback legacy se necessario e non SubTexture. - if (!supportsObjectFit || !supportsObjectPosition) { + if (!srcPos && (!supportsObjectFit || !supportsObjectPosition)) { const resizeMode = (node.props.textureOptions as any)?.resizeMode; const clipX = resizeMode?.clipX ?? 0.5; const clipY = resizeMode?.clipY ?? 0.5; From 65b830a0df09ed78c72adad4473893e2605bd5ee Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Tue, 18 Nov 2025 17:14:17 +0100 Subject: [PATCH 33/34] fix: lagacy fonts.ready --- src/dom-renderer/domRenderer.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index 5a3e903..deddfc8 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -856,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); + } } } From a532e559bd520d2276d399d352a0ac1fbbb3f7de Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Tue, 18 Nov 2025 18:26:31 +0100 Subject: [PATCH 34/34] fix: legacy bowsers without getBoundingClientRect setter --- src/dom-renderer/domRenderer.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/dom-renderer/domRenderer.ts b/src/dom-renderer/domRenderer.ts index deddfc8..96471ef 100644 --- a/src/dom-renderer/domRenderer.ts +++ b/src/dom-renderer/domRenderer.ts @@ -778,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) { @@ -800,7 +800,7 @@ function getElSize(node: DOMNode): Size { } } - return rect; + return { width, height }; } /*