diff --git a/.vscode/settings.json b/.vscode/settings.json index 63f5d1f..9d7ff0f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,14 @@ { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.codeActionsOnSave": [ - "source.formatDocument", - "source.fixAll.eslint" - ], - "[typescript]": { - "editor.defaultFormatter": "rvest.vs-code-prettier-eslint" + "typescript.tsdk": "node_modules/typescript/lib", + "[ignore]": { + "editor.defaultFormatter": "foxundermoon.shell-format" }, + "[properties]": { + "editor.defaultFormatter": "foxundermoon.shell-format" + }, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } } diff --git a/package.json b/package.json index aa57beb..2044c7d 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ }, "devDependencies": { "@eslint/js": "^9.16.0", - "@lightningjs/renderer": "3.0.0-beta21", + "@lightningjs/renderer": "3.0.0-beta22", "@solid-devtools/debugger": "^0.28.1", "@solidjs/testing-library": "^0.8.10", "@typescript-eslint/eslint-plugin": "^8.17.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3468043..27615b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,8 +34,8 @@ importers: specifier: ^9.16.0 version: 9.16.0 '@lightningjs/renderer': - specifier: 3.0.0-beta21 - version: 3.0.0-beta21 + specifier: 3.0.0-beta22 + version: 3.0.0-beta22 '@solid-devtools/debugger': specifier: ^0.28.1 version: 0.28.1(solid-js@1.9.3) @@ -441,8 +441,8 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@lightningjs/renderer@3.0.0-beta21': - resolution: {integrity: sha512-FzSTjQJv+87wi1n29yD+RBfQiJdownrZQloqHsv7UcuoSD7WXIZXFdMPBgbXZPW9Zv5OovONa7qNbTQ7INJBjw==} + '@lightningjs/renderer@3.0.0-beta22': + resolution: {integrity: sha512-4aBBSRJ+gczGQ0vAXYAGJpcrPh1WocWajuaHy2FO2RKmrPwOp8CRAR6s3g1coR9x0lbphBZEI/hSzu1zhGXiPQ==} engines: {node: '>= 18.0.0', npm: '>= 10.0.0', pnpm: '>= 10.17.0'} '@nodelib/fs.scandir@2.1.5': @@ -2929,7 +2929,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 - '@lightningjs/renderer@3.0.0-beta21': {} + '@lightningjs/renderer@3.0.0-beta22': {} '@nodelib/fs.scandir@2.1.5': dependencies: diff --git a/src/core/animation.ts b/src/core/animation.ts index f3e880f..38dcfb4 100644 --- a/src/core/animation.ts +++ b/src/core/animation.ts @@ -6,9 +6,10 @@ import { type ElementNode, LightningRendererNumberProps, } from './elementNode.js'; -import { type IRendererStage } from './lightningInit.js'; import { TimingFunction } from '@lightningjs/renderer'; import { isFunc } from './utils.js'; +import { IRendererStage } from './dom-renderer/domRendererTypes.js'; +import { CoreAnimation } from './intrinsicTypes.js'; /** * Simplified Animation Settings @@ -53,7 +54,7 @@ export class SimpleAnimation { } this.isRegistered = true; this.stage = stage; - stage.animationManager.registerAnimation(this); + stage.animationManager.registerAnimation(this as unknown as CoreAnimation); } /** @@ -174,7 +175,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/core/domRenderer.ts b/src/core/dom-renderer/domRenderer.ts similarity index 56% rename from src/core/domRenderer.ts rename to src/core/dom-renderer/domRenderer.ts index 4367e8b..84e0d08 100644 --- a/src/core/domRenderer.ts +++ b/src/core/dom-renderer/domRenderer.ts @@ -5,72 +5,45 @@ Experimental DOM renderer */ import * as lng from '@lightningjs/renderer'; -import { EventEmitter } from '@lightningjs/renderer/utils'; -import { Config } from './config.js'; -import { - IRendererShader, - IRendererStage, - IRendererShaderProps, - IRendererTextureProps, - IRendererTexture, +import { EventEmitter } from '@lightningjs/renderer/utils'; +import { Config } from '../config.js'; +import type { + ExtractProps, IRendererMain, IRendererNode, IRendererNodeProps, + IRendererShader, + IRendererShaderProps, + IRendererStage, IRendererTextNode, IRendererTextNodeProps, -} from './lightningInit.js'; -import { isFunc } from './utils.js'; - -const colorToRgba = (c: number) => - `rgba(${(c >> 24) & 0xff},${(c >> 16) & 0xff},${(c >> 8) & 0xff},${(c & 0xff) / 255})`; - -function applyEasing( - easing: string | lng.TimingFunction, - progress: number, -): number { - if (isFunc(easing)) { - return easing(progress); - } - - switch (easing) { - case 'linear': - default: - return progress; - case 'ease-in': - return progress * progress; - case 'ease-out': - return progress * (2 - progress); - case 'ease-in-out': - return progress < 0.5 - ? 2 * progress * progress - : -1 + (4 - 2 * progress) * progress; - } -} - -function interpolate(start: number, end: number, t: number): number { - return start + (end - start) * t; -} - -function interpolateColor(start: number, end: number, t: number): number { - return ( - (interpolate((start >> 24) & 0xff, (end >> 24) & 0xff, t) << 24) | - (interpolate((start >> 16) & 0xff, (end >> 16) & 0xff, t) << 16) | - (interpolate((start >> 8) & 0xff, (end >> 8) & 0xff, t) << 8) | - interpolate(start & 0xff, end & 0xff, t) - ); -} - -function interpolateProp( - name: string, - start: number, - end: number, - t: number, -): number { - return name.startsWith('color') - ? interpolateColor(start, end, t) - : interpolate(start, end, t); -} +} from './domRendererTypes.js'; +import { + colorToRgba, + buildGradientStops, + computeLegacyObjectFit, + applySubTextureScaling, + getNodeLineHeight, + applyEasing, + interpolateProp, + isRenderStateInBounds, + nodeHasTextureSource, + computeRenderStateForNode, + compactString, +} from './domRendererUtils.js'; +import { FontLoadOptions } from '../intrinsicTypes.js'; + +// Feature detection for legacy brousers +const _styleRef: any = + typeof document !== 'undefined' ? document.documentElement?.style || {} : {}; + +const supportsObjectFit: boolean = 'objectFit' in _styleRef; +const supportsObjectPosition: boolean = 'objectPosition' in _styleRef; +const supportsMixBlendMode: boolean = 'mixBlendMode' in _styleRef; +const supportsStandardMask: boolean = 'maskImage' in _styleRef; +const supportsWebkitMask: boolean = 'webkitMaskImage' in _styleRef; +const supportsCssMask: boolean = supportsStandardMask || supportsWebkitMask; /* Animations @@ -117,6 +90,8 @@ function updateAnimations(time: number) { // Animation complete else { Object.assign(task.node.props, task.propsEnd); + task.node.boundsDirty = true; + task.node.markChildrenBoundsDirty(); updateNodeStyles(task.node); task.stop(); @@ -251,17 +226,12 @@ function animate( let elMap = new WeakMap(); function updateNodeParent(node: DOMNode | DOMText) { - if (node.parent != null) { - elMap.get(node.parent as DOMNode)!.appendChild(node.div); + const parent = node.props.parent; + if (parent instanceof DOMNode) { + elMap.get(parent)!.appendChild(node.div); } } -function getNodeLineHeight(props: IRendererTextNodeProps): number { - return ( - props.lineHeight ?? Config.fontSettings.lineHeight ?? 1.2 * props.fontSize - ); -} - function updateNodeStyles(node: DOMNode | DOMText) { let { props } = node; @@ -324,6 +294,9 @@ function updateNodeStyles(node: DOMNode | DOMText) { if (textProps.fontWeight !== 'normal') { style += `font-weight: ${textProps.fontWeight};`; } + if (textProps.fontStretch && textProps.fontStretch !== 'normal') { + style += `font-stretch: ${textProps.fontStretch};`; + } if (textProps.lineHeight != null) { style += `line-height: ${textProps.lineHeight}px;`; } @@ -337,21 +310,45 @@ function updateNodeStyles(node: DOMNode | DOMText) { let maxLines = textProps.maxLines || Infinity; switch (textProps.contain) { case 'width': - style += `width: ${props.w}px; overflow: hidden;`; + if (textProps.maxWidth && textProps.maxWidth > 0) { + style += `width: ${textProps.maxWidth}px;`; + } else { + style += `width: 100%;`; + } + style += `overflow: hidden;`; break; case 'both': { let lineHeight = getNodeLineHeight(textProps); - maxLines = Math.min(maxLines, Math.floor(props.h / lineHeight)); - maxLines = Math.max(1, maxLines); - let height = maxLines * lineHeight; - style += `width: ${props.w}px; height: ${height}px; overflow: hidden;`; + const widthConstraint = + textProps.maxWidth && textProps.maxWidth > 0 + ? `${textProps.maxWidth}px` + : `100%`; + const heightConstraint = + textProps.maxHeight && textProps.maxHeight > 0 + ? textProps.maxHeight + : props.h; + + let height = heightConstraint || 0; + if (height > 0) { + const maxLinesByHeight = Math.max(1, Math.floor(height / lineHeight)); + maxLines = Math.min(maxLines, maxLinesByHeight); + height = Math.max(lineHeight, maxLines * lineHeight); + } else { + maxLines = Number.isFinite(maxLines) ? Math.max(1, maxLines) : 1; + height = maxLines * lineHeight; + } + + style += `width: ${widthConstraint}; height: ${height}px; overflow: hidden;`; break; } case 'none': + style += `width: -webkit-max-content;`; style += `width: max-content;`; break; } + style += `white-space: pre-wrap;`; + if (maxLines !== Infinity) { // https://stackoverflow.com/a/13924997 style += `display: -webkit-box; @@ -363,12 +360,10 @@ function updateNodeStyles(node: DOMNode | DOMText) { // if (node.overflowSuffix) style += `overflow-suffix: ${node.overflowSuffix};` // if (node.verticalAlign) style += `vertical-align: ${node.verticalAlign};` - - scheduleUpdateDOMTextMeasurement(node); } // else { - if (props.w !== 0) style += `width: ${props.w}px;`; + if (props.w !== 0) style += `width: ${props.w < 0 ? 0 : props.w}px;`; if (props.h !== 0) style += `height: ${props.h}px;`; let vGradient = @@ -387,57 +382,101 @@ function updateNodeStyles(node: DOMNode | DOMText) { : vGradient || hGradient; let srcImg: string | null = null; - let srcPos: null | { x: number; y: number } = null; + let srcPos: null | InstanceType['props'] = + null; + let rawImgSrc: string | null = null; if ( props.texture != null && props.texture.type === lng.TextureType.subTexture ) { - srcPos = (props.texture as any).props; - srcImg = `url(${(props.texture as any).props.texture.props.src})`; + const texture = props.texture as InstanceType< + lng.TextureMap['SubTexture'] + >; + srcPos = texture.props; + rawImgSrc = (texture.props.texture as any).props.src; } else if (props.src) { - srcImg = `url(${props.src})`; + rawImgSrc = props.src; + } + + if (rawImgSrc) { + srcImg = `url(${rawImgSrc})`; } let bgStyle = ''; let borderStyle = ''; let radiusStyle = ''; let maskStyle = ''; - - if (srcImg) { - if (props.color !== 0xffffffff && props.color !== 0x00000000) { - // use image as a mask - bgStyle += `background-color: ${colorToRgba(props.color)}; background-blend-mode: multiply;`; - maskStyle += `mask-image: ${srcImg};`; - if (srcPos !== null) { - maskStyle += `mask-position: -${srcPos.x}px -${srcPos.y}px;`; - } else { - maskStyle += `mask-size: 100% 100%;`; + let needsBackgroundLayer = false; + let imgStyle = ''; + let hasDivBgTint = false; + + 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%;`; + } + hasDivBgTint = true; } } else if (gradient) { - // use gradient as a mask + // use gradient as a mask when no tint is applied maskStyle += `mask-image: ${gradient};`; } - bgStyle += `background-image: ${srcImg};`; - bgStyle += `background-repeat: no-repeat;`; + const imgStyleParts = [ + 'position: absolute', + 'top: 0', + 'left: 0', + 'right: 0', + 'bottom: 0', + 'display: block', + 'pointer-events: none', + ]; if (props.textureOptions.resizeMode?.type) { - bgStyle += `background-size: ${props.textureOptions.resizeMode.type}; background-position: center;`; + const resizeMode = props.textureOptions.resizeMode; + imgStyleParts.push('width: 100%'); + imgStyleParts.push('height: 100%'); + imgStyleParts.push(`object-fit: ${resizeMode.type}`); + + // Handle clipX and clipY for object-position + const clipX = (resizeMode as any).clipX ?? 0.5; + const clipY = (resizeMode as any).clipY ?? 0.5; + imgStyleParts.push(`object-position: ${clipX * 100}% ${clipY * 100}%`); } else if (srcPos !== null) { - bgStyle += `background-position: -${srcPos.x}px -${srcPos.y}px;`; + imgStyleParts.push('width: auto'); + imgStyleParts.push('height: auto'); + imgStyleParts.push('object-fit: none'); + imgStyleParts.push(`object-position: -${srcPos.x}px -${srcPos.y}px`); + } else if (props.w && !props.h) { + imgStyleParts.push('width: 100%'); + imgStyleParts.push('height: auto'); + } else if (props.h && !props.w) { + 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) { + if (supportsMixBlendMode) { + imgStyleParts.push('mix-blend-mode: multiply'); + } else { + imgStyleParts.push('opacity: 0.9'); + } } + + imgStyle = imgStyleParts.join('; ') + ';'; } else if (gradient) { bgStyle += `background-image: ${gradient};`; bgStyle += `background-repeat: no-repeat;`; @@ -462,10 +501,10 @@ function updateNodeStyles(node: DOMNode | DOMText) { typeof borderColor === 'number' && borderColor !== 0 ) { + const rgbaColor = colorToRgba(borderColor); // Handle inset borders by making gap negative let gap = borderInset ? -(borderWidth + borderGap) : borderGap; - - borderStyle += `outline: ${borderWidth}px solid ${colorToRgba(borderColor)};`; + borderStyle += `outline: ${borderWidth}px solid ${rgbaColor};`; borderStyle += `outline-offset: ${gap}px;`; } // Rounded @@ -476,47 +515,220 @@ function updateNodeStyles(node: DOMNode | DOMText) { } } + if (maskStyle !== '') { + if (!supportsStandardMask && supportsWebkitMask) { + maskStyle = maskStyle.replace(/mask-/g, '-webkit-mask-'); + } else if (!supportsCssMask) { + maskStyle = ''; + } + if (maskStyle !== '') { + needsBackgroundLayer = true; + } + } + style += radiusStyle; - bgStyle += radiusStyle; - borderStyle += radiusStyle; - if (node.divBg == null) { - style += bgStyle; + if (needsBackgroundLayer) { + if (node.divBg == null) { + node.divBg = document.createElement('div'); + node.div.insertBefore(node.divBg, node.div.firstChild); + } else if (node.divBg.parentElement !== node.div) { + node.div.insertBefore(node.divBg, node.div.firstChild); + } + + let bgLayerStyle = + 'position: absolute; top:0; left:0; right:0; bottom:0; z-index: -1; pointer-events: none; overflow: hidden;'; + if (bgStyle) { + bgLayerStyle += bgStyle; + } + if (maskStyle) { + bgLayerStyle += maskStyle; + } + + node.divBg.setAttribute('style', bgLayerStyle + radiusStyle); + + if (rawImgSrc) { + if (!node.imgEl) { + node.imgEl = document.createElement('img'); + node.imgEl.alt = ''; + node.imgEl.setAttribute('aria-hidden', 'true'); + node.imgEl.setAttribute('loading', 'lazy'); + node.imgEl.removeAttribute('src'); + + node.imgEl.addEventListener('load', () => { + const payload: lng.NodeTextureLoadedPayload = { + type: 'texture', + dimensions: { + w: node.imgEl!.naturalWidth, + h: node.imgEl!.naturalHeight, + }, + }; + node.imgEl!.style.display = ''; + applySubTextureScaling( + node, + node.imgEl!, + node.lazyImageSubTextureProps, + ); + + 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, + node.lazyImageSubTextureProps, + supportsObjectFit, + supportsObjectPosition, + ); + node.emit('loaded', payload); + }); + + node.imgEl.addEventListener('error', () => { + if (node.imgEl) { + node.imgEl.removeAttribute('src'); + node.imgEl.style.display = 'none'; + node.imgEl.removeAttribute('data-rawSrc'); + } + + const failedSrc = + node.imgEl?.dataset.pendingSrc || node.lazyImagePendingSrc || ''; + + const payload: lng.NodeTextureFailedPayload = { + type: 'texture', + error: new Error(`Failed to load image: ${failedSrc}`), + }; + node.emit('failed', payload); + }); + } + + node.lazyImagePendingSrc = rawImgSrc; + node.lazyImageSubTextureProps = srcPos; + node.imgEl.dataset.pendingSrc = rawImgSrc; + + if (node.imgEl.parentElement !== node.divBg) { + node.divBg.appendChild(node.imgEl); + } + + node.imgEl.setAttribute('style', imgStyle); + + if (hasDivBgTint) { + node.imgEl.style.visibility = 'hidden'; + } + + if (isRenderStateInBounds(node.renderState)) { + node.applyPendingImageSrc(); + } else if (!node.imgEl.dataset.rawSrc) { + node.imgEl.removeAttribute('src'); + } + + if ( + srcPos && + node.imgEl.complete && + node.imgEl.dataset.rawSrc === rawImgSrc + ) { + applySubTextureScaling(node, node.imgEl, srcPos); + } + if ( + !srcPos && + node.imgEl.complete && + (!supportsObjectFit || !supportsObjectPosition) && + node.imgEl.dataset.rawSrc === rawImgSrc + ) { + const resizeMode = (node.props.textureOptions as any)?.resizeMode; + const clipX = resizeMode?.clipX ?? 0.5; + const clipY = resizeMode?.clipY ?? 0.5; + computeLegacyObjectFit( + node, + node.imgEl, + resizeMode, + clipX, + clipY, + srcPos, + supportsObjectFit, + supportsObjectPosition, + ); + } + } else { + node.lazyImagePendingSrc = null; + node.lazyImageSubTextureProps = null; + if (node.imgEl) { + node.imgEl.remove(); + node.imgEl = undefined; + } + } } else { - bgStyle += 'position: absolute; inset: 0; z-index: -1;'; - node.divBg.setAttribute('style', bgStyle); + node.lazyImagePendingSrc = null; + node.lazyImageSubTextureProps = null; + if (node.imgEl) { + node.imgEl.remove(); + node.imgEl = undefined; + } + if (node.divBg) { + node.divBg.remove(); + node.divBg = undefined; + } + style += bgStyle; + } + + const needsSeparateBorderLayer = needsBackgroundLayer && maskStyle !== ''; + + if (needsSeparateBorderLayer) { + if (node.divBorder == null) { + node.divBorder = document.createElement('div'); + node.div.appendChild(node.divBorder); + } + } else if (node.divBorder) { + node.divBorder.remove(); + node.divBorder = undefined; } + if (node.divBorder == null) { style += borderStyle; } else { - borderStyle += 'position: absolute; inset: 0; z-index: -1;'; - node.divBorder.setAttribute('style', borderStyle); + let borderLayerStyle = + 'position: absolute; top:0; left:0; right:0; bottom:0; z-index: -1; pointer-events: none;'; + borderLayerStyle += borderStyle; + node.divBorder.setAttribute('style', borderLayerStyle + radiusStyle); } } - node.div.setAttribute('style', style); -} + node.div.setAttribute('style', compactString(style)); -const fontFamiliesToLoad = new Set(); + if (node instanceof DOMNode && node !== node.stage.root) { + const hasTextureSrc = nodeHasTextureSource(node); + if (hasTextureSrc && node.boundsDirty) { + const next = computeRenderStateForNode(node); + if (next != null) { + node.updateRenderState(next); + } + node.boundsDirty = false; + } else if (!hasTextureSrc) { + node.boundsDirty = false; + } + } +} 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) { @@ -526,7 +738,7 @@ function getElSize(node: DOMNode): Size { } } - return rect; + return { width, height }; } /* @@ -542,7 +754,6 @@ function updateDOMTextSize(node: DOMText): void { if (node.props.h !== size.height) { node.props.h = size.height; updateNodeStyles(node); - node.emit('loaded'); } break; case 'none': @@ -551,10 +762,21 @@ function updateDOMTextSize(node: DOMText): void { node.props.w = size.width; node.props.h = size.height; updateNodeStyles(node); - node.emit('loaded'); } break; } + + if (!node.loaded) { + const payload: lng.NodeTextLoadedPayload = { + type: 'text', + dimensions: { + w: node.props.w, + h: node.props.h, + }, + }; + node.emit('loaded', payload); + node.loaded = true; + } } function updateDOMTextMeasurements() { @@ -566,16 +788,17 @@ function scheduleUpdateDOMTextMeasurement(node: DOMText) { /* Make sure the font is loaded before measuring */ - if (node.fontFamily && !fontFamiliesToLoad.has(node.fontFamily)) { - fontFamiliesToLoad.add(node.fontFamily); - document.fonts.load(`16px ${node.fontFamily}`); - } 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, 500); + } } } @@ -583,12 +806,13 @@ function scheduleUpdateDOMTextMeasurement(node: DOMText) { } function updateNodeData(node: DOMNode | DOMText) { - for (let key in node.data) { - let keyValue: unknown = node.data[key]; + const data = node.data; + for (let key in data) { + let keyValue: unknown = data[key]; if (keyValue === undefined) { node.div.removeAttribute('data-' + key); } else { - node.div.setAttribute('data-' + key, String(keyValue)); + node.div.dataset[key] = String(keyValue); } } } @@ -596,7 +820,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, @@ -677,15 +901,31 @@ const defaultShader: IRendererShader = { let lastNodeId = 0; -class DOMNode extends EventEmitter implements IRendererNode { +const CoreNodeRenderStateMap = new Map([ + [0, 'init'], + [2, 'outOfBounds'], + [4, 'inBounds'], + [8, 'inViewport'], +]); + +export class DOMNode extends EventEmitter implements IRendererNode { div = document.createElement('div'); divBg: HTMLElement | undefined; divBorder: HTMLElement | undefined; + imgEl: HTMLImageElement | undefined; + lazyImagePendingSrc: string | null = null; + lazyImageSubTextureProps: + | InstanceType['props'] + | null = null; + boundsDirty = true; + children = new Set(); id = ++lastNodeId; renderState: lng.CoreNodeRenderState = 0 /* Init */; + preventCleanup = true; + constructor( public stage: IRendererStage, public props: IRendererNodeProps, @@ -697,6 +937,11 @@ class DOMNode extends EventEmitter implements IRendererNode { this.div.setAttribute('data-id', String(this.id)); elMap.set(this, this.div); + const parent = this.props.parent; + if (parent instanceof DOMNode) { + parent.children.add(this); + } + updateNodeParent(this); updateNodeStyles(this); updateNodeData(this); @@ -704,6 +949,10 @@ class DOMNode extends EventEmitter implements IRendererNode { destroy(): void { elMap.delete(this); + const parent = this.props.parent; + if (parent instanceof DOMNode) { + parent.children.delete(this); + } this.div.parentNode!.removeChild(this.div); } @@ -711,52 +960,132 @@ class DOMNode extends EventEmitter implements IRendererNode { return this.props.parent; } set parent(value: IRendererNode | null) { + if (this.props.parent === value) return; + + const prevParent = this.props.parent; + if (prevParent instanceof DOMNode) { + prevParent.children.delete(this); + prevParent.markChildrenBoundsDirty(); + } + this.props.parent = value; + + if (value instanceof DOMNode) { + value.children.add(this); + value.markChildrenBoundsDirty(); + } + + this.boundsDirty = true; + this.markChildrenBoundsDirty(); updateNodeParent(this); } + public markChildrenBoundsDirty() { + for (const child of this.children) { + child.boundsDirty = true; + + if (child !== child.stage.root) { + if (nodeHasTextureSource(child)) { + const nextState = computeRenderStateForNode(child); + if (nextState != null) { + child.updateRenderState(nextState); + } + } + child.boundsDirty = false; + } + + child.markChildrenBoundsDirty(); + } + } + animate = animate; + updateRenderState(renderState: lng.CoreNodeRenderState) { + if (renderState === this.renderState) return; + const previous = this.renderState; + this.renderState = renderState; + const event = CoreNodeRenderStateMap.get(renderState); + if (isRenderStateInBounds(renderState)) { + this.applyPendingImageSrc(); + } + if (event && event !== 'init') { + this.emit(event, { previous, current: renderState }); + } + if (this.imgEl) { + this.imgEl.dataset.state = event; + } + } + + applyPendingImageSrc() { + if (!this.imgEl) return; + const pendingSrc = this.lazyImagePendingSrc; + if (!pendingSrc) return; + if (this.imgEl.dataset.rawSrc === pendingSrc) return; + this.imgEl.style.display = ''; + this.imgEl.dataset.pendingSrc = pendingSrc; + this.imgEl.src = pendingSrc; + this.imgEl.dataset.rawSrc = pendingSrc; + this.imgEl.dataset.pendingSrc = ''; + } + get x() { return this.props.x; } set x(v) { + if (this.props.x === v) return; this.props.x = v; + this.boundsDirty = true; + this.markChildrenBoundsDirty(); updateNodeStyles(this); } get y() { return this.props.y; } set y(v) { + if (this.props.y === v) return; this.props.y = v; + this.boundsDirty = true; + this.markChildrenBoundsDirty(); updateNodeStyles(this); } get w() { return this.props.w; } set w(v) { + if (this.props.w === v) return; this.props.w = v; + this.boundsDirty = true; + this.markChildrenBoundsDirty(); updateNodeStyles(this); } get h() { return this.props.h; } set h(v) { + if (this.props.h === v) return; this.props.h = v; + this.boundsDirty = true; + this.markChildrenBoundsDirty(); updateNodeStyles(this); } get width() { return this.props.w; } set width(v) { + if (this.props.w === v) return; this.props.w = v; + this.boundsDirty = true; + this.markChildrenBoundsDirty(); updateNodeStyles(this); } get height() { return this.props.h; } set height(v) { + if (this.props.h === v) return; this.props.h = v; + this.boundsDirty = true; + this.markChildrenBoundsDirty(); updateNodeStyles(this); } get alpha() { @@ -847,14 +1176,17 @@ class DOMNode extends EventEmitter implements IRendererNode { return this.props.zIndex; } set zIndex(v) { - this.props.zIndex = v; + if (this.props.zIndex === v) return; + this.props.zIndex = Math.ceil(v); updateNodeStyles(this); } get texture() { return this.props.texture; } set texture(v) { + if (this.props.texture === v) return; this.props.texture = v; + this.boundsDirty = true; updateNodeStyles(this); } get textureOptions(): IRendererNode['textureOptions'] { @@ -868,13 +1200,16 @@ class DOMNode extends EventEmitter implements IRendererNode { return this.props.src; } set src(v) { + if (this.props.src === v) return; this.props.src = v; + this.boundsDirty = true; updateNodeStyles(this); } get scale() { return this.props.scale ?? 1; } set scale(v) { + if (this.props.scale === v) return; this.props.scale = v; updateNodeStyles(this); } @@ -955,6 +1290,7 @@ class DOMNode extends EventEmitter implements IRendererNode { this.props.shader = v; updateNodeStyles(this); } + get data(): IRendererNode['data'] { return this.props.data; } @@ -999,29 +1335,45 @@ class DOMNode extends EventEmitter implements IRendererNode { } set boundsMargin(value: number | [number, number, number, number] | null) { this.props.boundsMargin = value; + this.boundsDirty = true; + this.markChildrenBoundsDirty(); } get absX(): number { - return this.x + -this.width * this.mountX + (this.parent?.absX ?? 0); + const parent = this.props.parent; + return ( + this.x + + -this.w * this.mountX + + (parent instanceof DOMNode ? parent.absX : 0) + ); } get absY(): number { - return this.y + -this.height * this.mountY + (this.parent?.absY ?? 0); + const parent = this.props.parent; + return ( + this.y + + -this.h * this.mountY + + (parent instanceof DOMNode ? parent.absY : 0) + ); } } class DOMText extends DOMNode { + public loaded = false; + constructor( stage: IRendererStage, public override props: IRendererTextNodeProps, ) { super(stage, props); this.div.innerText = props.text; + scheduleUpdateDOMTextMeasurement(this); } get text() { return this.props.text; } set text(v) { + if (this.props.text === v) return; this.props.text = v; this.div.innerText = v; scheduleUpdateDOMTextMeasurement(this); @@ -1030,29 +1382,46 @@ class DOMText extends DOMNode { return this.props.fontFamily; } set fontFamily(v) { + if (this.props.fontFamily === v) return; this.props.fontFamily = v; updateNodeStyles(this); + scheduleUpdateDOMTextMeasurement(this); } get fontSize() { return this.props.fontSize; } set fontSize(v) { + if (this.props.fontSize === v) return; this.props.fontSize = v; updateNodeStyles(this); + scheduleUpdateDOMTextMeasurement(this); } get fontStyle() { return this.props.fontStyle; } set fontStyle(v) { + if (this.props.fontStyle === v) return; this.props.fontStyle = v; updateNodeStyles(this); + scheduleUpdateDOMTextMeasurement(this); } get fontWeight() { return this.props.fontWeight; } set fontWeight(v) { + if (this.props.fontWeight === v) return; this.props.fontWeight = v; updateNodeStyles(this); + scheduleUpdateDOMTextMeasurement(this); + } + get fontStretch() { + return this.props.fontStretch; + } + set fontStretch(v) { + if (this.props.fontStretch === v) return; + this.props.fontStretch = v; + updateNodeStyles(this); + scheduleUpdateDOMTextMeasurement(this); } get forceLoad() { return this.props.forceLoad; @@ -1064,34 +1433,43 @@ class DOMText extends DOMNode { return this.props.lineHeight; } set lineHeight(v) { + if (this.props.lineHeight === v) return; this.props.lineHeight = v; updateNodeStyles(this); + scheduleUpdateDOMTextMeasurement(this); } get maxWidth() { return this.props.maxWidth; } set maxWidth(v) { + if (this.props.maxWidth === v) return; this.props.maxWidth = v; updateNodeStyles(this); + scheduleUpdateDOMTextMeasurement(this); } get maxHeight() { return this.props.maxHeight; } set maxHeight(v) { + if (this.props.maxHeight === v) return; this.props.maxHeight = v; updateNodeStyles(this); + scheduleUpdateDOMTextMeasurement(this); } get letterSpacing() { return this.props.letterSpacing; } set letterSpacing(v) { + if (this.props.letterSpacing === v) return; this.props.letterSpacing = v; updateNodeStyles(this); + scheduleUpdateDOMTextMeasurement(this); } get textAlign() { return this.props.textAlign; } set textAlign(v) { + if (this.props.textAlign === v) return; this.props.textAlign = v; updateNodeStyles(this); } @@ -1099,6 +1477,7 @@ class DOMText extends DOMNode { return this.props.overflowSuffix; } set overflowSuffix(v) { + if (this.props.overflowSuffix === v) return; this.props.overflowSuffix = v; updateNodeStyles(this); } @@ -1106,15 +1485,19 @@ class DOMText extends DOMNode { return this.props.maxLines; } set maxLines(v) { + if (this.props.maxLines === v) return; this.props.maxLines = v; updateNodeStyles(this); + scheduleUpdateDOMTextMeasurement(this); } get contain() { return this.props.contain; } set contain(v) { + if (this.props.contain === v) return; this.props.contain = v; updateNodeStyles(this); + scheduleUpdateDOMTextMeasurement(this); } get verticalAlign() { return this.props.verticalAlign; @@ -1171,11 +1554,12 @@ function updateRootPosition(this: DOMRendererMain) { export class DOMRendererMain implements IRendererMain { root: DOMNode; canvas: HTMLCanvasElement; - stage: IRendererStage; + private eventListeners: Map void>> = + new Map(); constructor( - public settings: lng.RendererMainSettings, + public settings: Partial, rawTarget: string | HTMLElement, ) { let target: HTMLElement; @@ -1203,16 +1587,20 @@ export class DOMRendererMain implements IRendererMain { root: null!, renderer: { mode: 'canvas', + boundsMargin: settings.boundsMargin, }, - loadFont: async () => {}, shManager: { registerShaderType() {}, }, animationManager: { - registerAnimation() {}, - unregisterAnimation() {}, + registerAnimation(anim) { + console.log('registerAnimation', anim); + }, + unregisterAnimation(anim) { + console.log('unregisterAnimation', anim); + }, }, - cleanup() {}, + loadFont: async () => {}, }; this.root = new DOMNode( @@ -1221,7 +1609,7 @@ export class DOMRendererMain implements IRendererMain { w: settings.appWidth ?? 1920, h: settings.appHeight ?? 1080, shader: defaultShader, - zIndex: 65534, + zIndex: 1, }), ); this.stage.root = this.root; @@ -1255,6 +1643,77 @@ export class DOMRendererMain implements IRendererMain { window.addEventListener('resize', updateRootPosition.bind(this)); } + removeAllListeners(): void { + if (this.eventListeners.size === 0) return; + this.eventListeners.forEach((listeners) => listeners.clear()); + this.eventListeners.clear(); + } + + once( + event: Extract, + listener: { [s: string]: (target: any, data: any) => void }[K], + ): void { + const wrappedListener = (target: any, data: any) => { + this.off(event, wrappedListener); + listener(target, data); + }; + this.on(event, wrappedListener); + } + + on(name: string, callback: (target: any, data: any) => void) { + let listeners = this.eventListeners.get(name); + if (!listeners) { + listeners = new Set(); + this.eventListeners.set(name, listeners); + } + listeners.add(callback); + } + + off( + event: Extract, + listener: { [s: string]: (target: any, data: any) => void }[K], + ): void { + const listeners = this.eventListeners.get(event); + if (listeners) { + listeners.delete(listener); + if (listeners.size === 0) { + this.eventListeners.delete(event); + } + } + } + + emit( + event: Extract, + data: Parameters[1], + ): void; + emit( + event: Extract, + target: any, + data: Parameters[1], + ): void; + emit( + event: Extract, + targetOrData: any, + maybeData?: Parameters[1], + ): void { + const listeners = this.eventListeners.get(event); + if (!listeners || listeners.size === 0) { + return; + } + + const hasExplicitTarget = arguments.length === 3; + const target = hasExplicitTarget ? targetOrData : this.root; + const data = hasExplicitTarget ? maybeData : targetOrData; + + for (const listener of Array.from(listeners)) { + try { + listener(target, data); + } catch (error) { + console.error(`Error in listener for event "${event}"`, error); + } + } + } + createNode(props: Partial): IRendererNode { return new DOMNode(this.stage, resolveNodeDefaults(props)); } @@ -1263,17 +1722,32 @@ export class DOMRendererMain implements IRendererMain { return new DOMText(this.stage, resolveTextNodeDefaults(props)); } + /** TODO: restore this */ + // createShader( + // shType: ShType, + // props?: OptionalShaderProps, + // ): InstanceType { + // return { shaderType: shType, props, program: {} } as InstanceType< + // lng.ShaderMap[ShType] + // >; + // } + createShader( - shaderType: string, - props?: IRendererShaderProps, - ): IRendererShader { - return { shaderType, props, program: {} }; + ...args: Parameters + ): ReturnType { + const [shaderType, props] = args; + return { + // @ts-ignore + shaderType, + props, + program: {}, + }; } - createTexture( - textureType: keyof lng.TextureMap, - props: IRendererTextureProps, - ): IRendererTexture { + createTexture( + textureType: Type, + props: ExtractProps, + ): InstanceType { let type = lng.TextureType.generic; switch (textureType) { case 'SubTexture': @@ -1292,10 +1766,29 @@ export class DOMRendererMain implements IRendererMain { type = lng.TextureType.renderToTexture; break; } - return { type, props }; + return { type, props } as InstanceType; } +} - on(name: string, callback: (target: any, data: any) => void) { - console.log('on', name, callback); +export function loadFontToDom(font: FontLoadOptions): void { + // fontFamily: string; + // metrics?: FontMetrics; + // fontUrl?: string; + // atlasUrl?: string; + // atlasDataUrl?: string; + + const fontFace = new FontFace(font.fontFamily, `url(${font.fontUrl})`); + + if (typeof document !== 'undefined' && 'fonts' in document) { + const fontSet = document.fonts as FontFaceSet & { + add?: (font: FontFace) => FontFaceSet; + }; + fontSet.add?.(fontFace); } } + +export function isDomRenderer( + r: lng.RendererMain | DOMRendererMain, +): r is DOMRendererMain { + return r instanceof DOMRendererMain; +} diff --git a/src/core/dom-renderer/domRendererTypes.ts b/src/core/dom-renderer/domRendererTypes.ts new file mode 100644 index 0000000..04fbc76 --- /dev/null +++ b/src/core/dom-renderer/domRendererTypes.ts @@ -0,0 +1,125 @@ +import * as lng from '@lightningjs/renderer'; +import { CoreAnimation } from '../intrinsicTypes.js'; +import { EventEmitter } from '@lightningjs/renderer/utils'; +import { + ShaderBorderPrefixedProps, + ShaderHolePunchProps, + ShaderLinearGradientProps, + ShaderRadialGradientProps, + ShaderRoundedProps, + ShaderShadowPrefixedProps, +} from '../shaders.js'; + +/** Based on {@link lng.CoreRenderer} */ +export interface IRendererCoreRenderer { + mode: 'canvas' | 'webgl' | undefined; + boundsMargin?: number | [number, number, number, number]; +} +/** Based on {@link lng.TrFontManager} */ +export interface IRendererFontManager { + addFontFace: (...a: any[]) => void; +} +/** Based on {@link lng.Stage} */ +export interface IRendererStage { + root: IRendererNode; + renderer: IRendererCoreRenderer; + shManager: IRendererShaderManager; + animationManager: { + registerAnimation: (anim: CoreAnimation) => void; + unregisterAnimation: (anim: CoreAnimation) => void; + }; + loadFont: lng.Stage['loadFont']; +} + +/** Based on {@link lng.CoreShaderManager} */ +export interface IRendererShaderManager { + registerShaderType: (name: string, shader: any) => void; +} + +/** Based on {@link lng.CoreShaderType} */ +export interface IRendererShaderType {} + +export type IRendererShaderProps = Partial & + Partial & + Partial & + Partial & + Partial & + Partial; + +/** Based on {@link lng.CoreShaderNode} */ +export interface IRendererShader extends Partial { + shaderType: IRendererShaderType; + props?: IRendererShaderProps; + program?: {}; +} + +export type ExtractProps = Type extends { z$__type__Props: infer Props } + ? Props + : never; + +export interface IEventEmitter< + T extends object = { [s: string]: (target: any, data: any) => void }, +> { + on(event: Extract, listener: T[K]): void; + once(event: Extract, listener: T[K]): void; + off(event: Extract, listener: T[K]): void; + emit( + event: Extract, + data: Parameters[1], + ): void; +} + +export interface IRendererNodeShaded extends EventEmitter { + stage: IRendererStage; + id: number; + animate: ( + props: Partial>, + settings: Partial, + ) => lng.IAnimationController; + get absX(): number; + get absY(): number; +} + +/** Based on {@link lng.INodeProps} */ +export interface IRendererNodeProps + extends Omit { + shader: IRendererShader | null; + parent: IRendererNode | null; +} + +/** Based on {@link lng.CoreNode} */ +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; + fontWeight?: string; + fontStretch?: string; +} + +/** Based on {@link lng.ITextNode} */ +export interface IRendererTextNode + extends IRendererNodeShaded, + IRendererTextNodeProps { + div?: HTMLElement; + props: IRendererTextNodeProps; + renderState: lng.CoreNodeRenderState; +} + +/** Based on {@link lng.RendererMain} */ +export interface IRendererMain extends IEventEmitter { + root: IRendererNode; + stage: IRendererStage; + canvas: HTMLCanvasElement; + createTextNode(props: Partial): IRendererTextNode; + createNode(props: Partial): IRendererNode; + createShader: typeof lng.RendererMain.prototype.createShader; + createTexture: typeof lng.RendererMain.prototype.createTexture; + //createEffect: typeof lng.RendererMain.prototype.createEffect; +} diff --git a/src/core/dom-renderer/domRendererUtils.ts b/src/core/dom-renderer/domRendererUtils.ts new file mode 100644 index 0000000..ff33267 --- /dev/null +++ b/src/core/dom-renderer/domRendererUtils.ts @@ -0,0 +1,291 @@ +// Utilities extracted from domRenderer.ts for clarity +import * as lng from '@lightningjs/renderer'; +import { Config } from '../config.js'; +import { DOMNode } from './domRenderer.js'; +import { isFunc } from '../utils.js'; + +// #region Color & Gradient Utils + +export const colorToRgba = (c: number) => + `rgba(${(c >> 24) & 0xff},${(c >> 16) & 0xff},${(c >> 8) & 0xff},${(c & 0xff) / 255})`; + +export function buildGradientStops(colors: number[], stops?: number[]): string { + if (!Array.isArray(colors) || colors.length === 0) return ''; + const positions: number[] = []; + if (Array.isArray(stops) && stops.length === colors.length) { + for (let v of stops) { + if (typeof v !== 'number' || !isFinite(v)) { + positions.push(0); + continue; + } + let pct = v <= 1 ? v * 100 : v; + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + positions.push(pct); + } + } else { + const lastIndex = colors.length - 1; + for (let i = 0; i < colors.length; i++) { + positions.push(lastIndex === 0 ? 0 : (i / lastIndex) * 100); + } + } + if (positions.length !== colors.length) { + while (positions.length < colors.length) + positions.push(positions.length === 0 ? 0 : 100); + } + return colors + .map((color, idx) => `${colorToRgba(color)} ${positions[idx]!.toFixed(2)}%`) + .join(', '); +} + +export function getNodeLineHeight(props: { + lineHeight?: number; + fontSize: number; +}): number { + return ( + props.lineHeight ?? Config.fontSettings.lineHeight ?? 1.2 * props.fontSize + ); +} + +/** Legacy object-fit fall back for unsupported browsers */ +export function computeLegacyObjectFit( + node: DOMNode, + img: HTMLImageElement, + resizeMode: ({ type?: string } & Record) | undefined, + clipX: number, + clipY: number, + srcPos: null | { x: number; y: number }, + supportsObjectFit: boolean, + supportsObjectPosition: boolean, +) { + if (supportsObjectFit && supportsObjectPosition) return; + const containerW = node.props.w || img.naturalWidth; + const containerH = node.props.h || img.naturalHeight; + const naturalW = img.naturalWidth || 1; + const naturalH = img.naturalHeight || 1; + let fitType = resizeMode?.type || (srcPos ? 'none' : 'fill'); + let drawW = naturalW; + let drawH = naturalH; + switch (fitType) { + case 'cover': { + const scale = Math.max(containerW / naturalW, containerH / naturalH); + drawW = naturalW * scale; + drawH = naturalH * scale; + break; + } + case 'contain': { + const scale = Math.min(containerW / naturalW, containerH / naturalH); + drawW = naturalW * scale; + drawH = naturalH * scale; + break; + } + case 'fill': { + drawW = containerW; + drawH = containerH; + break; + } + } + let offsetX = (containerW - drawW) * clipX; + let offsetY = (containerH - drawH) * clipY; + if (srcPos) { + offsetX = -srcPos.x; + offsetY = -srcPos.y; + } + const styleParts = [ + 'position: absolute', + `width: ${Math.round(drawW)}px`, + `height: ${Math.round(drawH)}px`, + `left: ${Math.round(offsetX)}px`, + `top: ${Math.round(offsetY)}px`, + 'display: block', + 'pointer-events: none', + ]; + img.style.removeProperty('object-fit'); + img.style.removeProperty('object-position'); + if (resizeMode?.type === 'none') { + styleParts[1] = `width: ${naturalW}px`; + styleParts[2] = `height: ${naturalH}px`; + } + img.setAttribute('style', styleParts.join('; ') + ';'); +} + +export function applySubTextureScaling( + node: DOMNode, + img: HTMLImageElement, + srcPos: InstanceType['props'] | null, +) { + if (!srcPos) return; + const regionW = node.props.srcWidth ?? srcPos.w; + const regionH = node.props.srcHeight ?? srcPos.h; + if (!regionW || !regionH) return; + const targetW = node.props.w || regionW; + const targetH = node.props.h || regionH; + if (targetW === regionW && targetH === regionH) return; + const naturalW = img.naturalWidth || regionW; + const naturalH = img.naturalHeight || regionH; + const scaleX = targetW / regionW; + const scaleY = targetH / regionH; + img.style.width = naturalW + 'px'; + img.style.height = naturalH + 'px'; + img.style.objectFit = 'none'; + img.style.objectPosition = '0 0'; + img.style.transformOrigin = '0 0'; + const translateX = Math.round(-srcPos.x * scaleX); + const translateY = Math.round(-srcPos.y * scaleY); + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`; + img.style.setProperty('-webkit-transform', img.style.transform); + if (node.divBg) { + const styleEl = node.divBg.style; + if ( + styleEl.maskImage || + styleEl.webkitMaskImage || + /mask-image:/.test(node.divBg.getAttribute('style') || '') + ) { + img.style.display = 'none'; + const maskW = Math.round(naturalW * scaleX); + const maskH = Math.round(naturalH * scaleY); + const maskPosX = translateX; + const maskPosY = translateY; + styleEl.setProperty?.('mask-size', `${maskW}px ${maskH}px`); + styleEl.setProperty?.('mask-position', `${maskPosX}px ${maskPosY}px`); + styleEl.setProperty?.('-webkit-mask-size', `${maskW}px ${maskH}px`); + styleEl.setProperty?.( + '-webkit-mask-position', + `${maskPosX}px ${maskPosY}px`, + ); + } + } +} +export function applyEasing( + easing: string | lng.TimingFunction, + progress: number, +): number { + if (isFunc(easing)) { + return easing(progress); + } + + switch (easing) { + case 'linear': + default: + return progress; + case 'ease-in': + return progress * progress; + case 'ease-out': + return progress * (2 - progress); + case 'ease-in-out': + return progress < 0.5 + ? 2 * progress * progress + : -1 + (4 - 2 * progress) * progress; + } +} +function interpolate(start: number, end: number, t: number): number { + return start + (end - start) * t; +} + +function interpolateColor(start: number, end: number, t: number): number { + return ( + (interpolate((start >> 24) & 0xff, (end >> 24) & 0xff, t) << 24) | + (interpolate((start >> 16) & 0xff, (end >> 16) & 0xff, t) << 16) | + (interpolate((start >> 8) & 0xff, (end >> 8) & 0xff, t) << 8) | + interpolate(start & 0xff, end & 0xff, t) + ); +} + +export function interpolateProp( + name: string, + start: number, + end: number, + t: number, +): number { + return name.startsWith('color') + ? interpolateColor(start, end, t) + : interpolate(start, end, t); +} + +export function compactString(input: string): string { + return input.replace(/\s*\n\s*/g, ' '); +} + +// #region Renderer State Utils + +export function isRenderStateInBounds(state: lng.CoreNodeRenderState): boolean { + return state === 4 || state === 8; +} + +export function nodeHasTextureSource(node: DOMNode): boolean { + const textureType = node.props.texture?.type; + return ( + !!node.props.src || + textureType === lng.TextureType.image || + textureType === lng.TextureType.subTexture + ); +} + +export function normalizeBoundsMargin( + margin: number | [number, number, number, number] | null | undefined, +): [number, number, number, number] { + if (margin == null) return [0, 0, 0, 0]; + if (typeof margin === 'number') { + return [margin, margin, margin, margin]; + } + if (Array.isArray(margin) && margin.length === 4) { + return [margin[0] ?? 0, margin[1] ?? 0, margin[2] ?? 0, margin[3] ?? 0]; + } + return [0, 0, 0, 0]; +} + +export function computeRenderStateForNode( + node: DOMNode, +): lng.CoreNodeRenderState | null { + const stageRoot = node.stage.root as DOMNode | undefined; + if (!stageRoot || stageRoot === node) return null; + + const rootWidth = stageRoot.props.w ?? 0; + const rootHeight = stageRoot.props.h ?? 0; + if (rootWidth <= 0 || rootHeight <= 0) return 4; + + const rootLeft = stageRoot.absX; + const rootTop = stageRoot.absY; + const rootRight = rootLeft + rootWidth; + const rootBottom = rootTop + rootHeight; + + const [marginTop, marginRight, marginBottom, marginLeft] = + normalizeBoundsMargin( + node.props.boundsMargin ?? node.stage.renderer.boundsMargin, + ); + + const width = node.props.w ?? 0; + const height = node.props.h ?? 0; + + const left = node.absX; + const top = node.absY; + const right = left + width; + const bottom = top + height; + + const expandedLeft = rootLeft - marginLeft; + const expandedTop = rootTop - marginTop; + const expandedRight = rootRight + marginRight; + const expandedBottom = rootBottom + marginBottom; + + const intersectsBounds = + right >= expandedLeft && + left <= expandedRight && + bottom >= expandedTop && + top <= expandedBottom; + + if (!intersectsBounds) { + return 2; + } + + const intersectsViewport = + right >= rootLeft && + left <= rootRight && + bottom >= rootTop && + top <= rootBottom; + + if (intersectsViewport) { + return 8; + } + + return 4; +} diff --git a/src/core/elementNode.ts b/src/core/elementNode.ts index 1207fb0..f9efdd5 100644 --- a/src/core/elementNode.ts +++ b/src/core/elementNode.ts @@ -1,12 +1,4 @@ -import { - IRendererNode, - IRendererNodeProps, - IRendererShader, - IRendererShaderProps, - IRendererTextNode, - IRendererTextNodeProps, - renderer, -} from './lightningInit.js'; +import { renderer } from './lightningInit.js'; import { type BorderRadius, type BorderStyle, @@ -46,6 +38,8 @@ import type { RadialGradientProps, ShadowProps, CoreShaderNode, + ITextNodeProps, + INodeProps, } from '@lightningjs/renderer'; import { assertTruthy } from '@lightningjs/renderer/utils'; import { NodeType } from './nodeTypes.js'; @@ -55,6 +49,14 @@ import { FocusNode, } from './focusManager.js'; import simpleAnimation, { SimpleAnimationSettings } from './animation.js'; +import { + IRendererNode, + IRendererNodeProps, + IRendererShader, + IRendererShaderProps, + IRendererTextNode, + IRendererTextNodeProps, +} from './dom-renderer/domRendererTypes.js'; let layoutRunQueued = false; const layoutQueue = new Set(); @@ -99,8 +101,7 @@ export function convertToShader( let type = 'rounded'; if (v.border) type += 'WithBorder'; if (v.shadow) type += 'WithShadow'; - - return renderer.createShader(type, v as IRendererShaderProps); + return renderer.createShader(type, v); } function getPropertyAlias(name: string) { @@ -175,6 +176,7 @@ const LightningRendererNonAnimatingProps = [ 'texture', 'textureOptions', 'verticalAlign', + 'wordBreak', 'wordWrap', ]; @@ -277,8 +279,9 @@ export interface ElementNode extends RendererNode, FocusNode { * The underlying Lightning Renderer node object. This is where the properties are ultimately set for rendering. */ lng: - | Partial + | INode | IRendererNode + | Partial | (IRendererTextNode & { shader?: any }); /** * A reference to the `ElementNode` instance. Can be an object or a callback function. @@ -634,7 +637,7 @@ export class ElementNode extends Object { set effects(v: StyleEffects) { if (!SHADERS_ENABLED) return; let target = this.lng.shader || {}; - if (this.lng.shader?.program) { + if (this.lng.shader?.props) { target = this.lng.shader.props; } if (v.rounded) target.radius = v.rounded.radius; @@ -826,7 +829,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( @@ -1246,7 +1250,11 @@ export class ElementNode extends Object { if (Config.fontSettings) { for (const key in Config.fontSettings) { if (textProps[key] === undefined) { - textProps[key] = Config.fontSettings[key]; + let value = Config.fontSettings[key]; + if (key === 'fontFamily' && textProps['fontWeight'] === undefined) { + value = `${value}${Config.fontSettings.fontWeight || ''}`; + } + textProps[key] = value; } } } @@ -1282,8 +1290,7 @@ export class ElementNode extends Object { textProps.lineHeight || textProps.fontSize) as number; } - - textProps.w = textProps.h = undefined; + // textProps.w = textProps.h = 0; } // Can you put effects on Text nodes? Need to confirm... @@ -1292,9 +1299,11 @@ export class ElementNode extends Object { } isDev && log('Rendering: ', this, props); + node.lng = renderer.createTextNode( - props as unknown as IRendererTextNodeProps, - ); + props as Partial & Partial, + ) as IRendererTextNode; + if (parent.requiresLayout()) { if (!textProps.maxWidth || !textProps.maxHeight) { node._layoutOnLoad(); @@ -1330,7 +1339,10 @@ 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; @@ -1358,14 +1370,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 @@ -1434,7 +1447,7 @@ export function shaderAccessor | number>( this._effects[key] = value; let animationSettings: AnimationSettings | undefined; - if (this.lng.shader?.program) { + if (this.lng.shader?.props) { target = this.lng.shader.props; const transitionKey = key === 'rounded' ? 'borderRadius' : key; if ( diff --git a/src/core/intrinsicTypes.ts b/src/core/intrinsicTypes.ts index f6ba4ac..6dea666 100644 --- a/src/core/intrinsicTypes.ts +++ b/src/core/intrinsicTypes.ts @@ -47,6 +47,16 @@ export interface Effects { export type StyleEffects = Effects; +export type CoreAnimation = Parameters< + lngr.Stage['animationManager']['registerAnimation'] +>[0]; + +export type FontLoadOptions = Parameters[1] & { + type?: 'ssdf' | 'msdf'; +}; + +export type CoreShaderManager = lngr.Stage['shManager']; + export type NewOmit = { [P in keyof T as Exclude]: T[P]; }; diff --git a/src/core/lightningInit.ts b/src/core/lightningInit.ts index a140af4..ef64a0b 100644 --- a/src/core/lightningInit.ts +++ b/src/core/lightningInit.ts @@ -1,123 +1,15 @@ import * as lng from '@lightningjs/renderer'; -import { DOMRendererMain } from './domRenderer.js'; -import { DOM_RENDERING } from './config.js'; import { - ShaderBorderPrefixedProps, - ShaderHolePunchProps, - ShaderLinearGradientProps, - ShaderRadialGradientProps, - ShaderRoundedProps, - ShaderShadowPrefixedProps, -} from './shaders.js'; + DOMRendererMain, + isDomRenderer, + loadFontToDom, +} from './dom-renderer/domRenderer.js'; +import { Config, DOM_RENDERING } from './config.js'; +import { FontLoadOptions } from './intrinsicTypes.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; - shManager: IRendererShaderManager; - animationManager: { - registerAnimation: (anim: any) => void; - unregisterAnimation: (anim: any) => void; - }; - loadFont(kind: string, props: any): Promise; - cleanup(full: boolean): void; -} - -/** Based on {@link lng.CoreShaderManager} */ -export interface IRendererShaderManager { - registerShaderType: (name: string, shader: any) => void; -} - -/** Based on {@link lng.CoreShaderNode} */ -export interface IRendererShader { - shaderType: IRendererShaderType; - props?: IRendererShaderProps; - program?: {}; -} -/** Based on {@link lng.CoreShaderType} */ -export interface IRendererShaderType {} -export type IRendererShaderProps = Partial & - Partial & - Partial & - Partial & - Partial & - Partial; - -/** 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' | 'parent'> { - 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; - fontWeight?: string; -} -/** Based on {@link lng.ITextNode} */ -export interface IRendererTextNode - extends IRendererNodeShaded, - IRendererTextNodeProps { - div?: HTMLElement; - props: IRendererTextNodeProps; - renderState: lng.CoreNodeRenderState; -} - -/** Based on {@link lng.RendererMain} */ -export interface IRendererMain extends IEventEmitter { - stage: IRendererStage; - root: IRendererNode; - createTextNode(props: Partial): IRendererTextNode; - createNode(props: Partial): IRendererNode; - createShader(kind: string, props: IRendererShaderProps): IRendererShader; - createTexture( - kind: keyof lng.TextureMap, - props: IRendererTextureProps, - ): IRendererTexture; -} - -export let renderer: IRendererMain; +// Global renderer instance: can be either the Lightning or DOM implementation +export let renderer: lng.RendererMain | DOMRendererMain; export const getRenderer = () => renderer; @@ -125,12 +17,15 @@ export function startLightningRenderer( options: lng.RendererMainSettings, rootId: string | HTMLElement = 'app', ) { - renderer = DOM_RENDERING + const enableDomRenderer = DOM_RENDERING && Config.domRendererEnabled; + + renderer = enableDomRenderer ? new DOMRendererMain(options, rootId) - : (new lng.RendererMain(options, rootId) as any as IRendererMain); + : new lng.RendererMain(options, rootId); return renderer; } -export function loadFonts(fonts: any[]) { + +export function loadFonts(fonts: FontLoadOptions[]) { for (const font of fonts) { // WebGL — SDF if ( @@ -141,8 +36,12 @@ export function loadFonts(fonts: any[]) { renderer.stage.loadFont('sdf', font); } // Canvas — Web - else if ('fontUrl' in font && renderer.stage.renderer.mode !== 'webgl') { - renderer.stage.loadFont('canvas', font); + else if ('fontUrl' in font) { + if (DOM_RENDERING && isDomRenderer(renderer)) { + loadFontToDom(font); + } else { + renderer.stage.loadFont('canvas', font); + } } } } diff --git a/src/core/shaders.ts b/src/core/shaders.ts index 373cfbc..c0e6f87 100644 --- a/src/core/shaders.ts +++ b/src/core/shaders.ts @@ -2,25 +2,25 @@ import * as lngr from '@lightningjs/renderer'; import * as lngr_shaders from '@lightningjs/renderer/webgl/shaders'; import type { - RoundedProps as ShaderRoundedProps, - ShadowProps as ShaderShadowProps, HolePunchProps as ShaderHolePunchProps, - RadialGradientProps as ShaderRadialGradientProps, LinearGradientProps as ShaderLinearGradientProps, + RadialGradientProps as ShaderRadialGradientProps, + RoundedProps as ShaderRoundedProps, + ShadowProps as ShaderShadowProps, } from '@lightningjs/renderer'; +import { type WebGlShaderType as WebGlShader } from '@lightningjs/renderer/webgl'; export { - ShaderRoundedProps, - ShaderShadowProps, ShaderHolePunchProps, - ShaderRadialGradientProps, ShaderLinearGradientProps, + ShaderRadialGradientProps, + ShaderRoundedProps, + ShaderShadowProps, }; - -import { type WebGlShaderType as WebGlShader } from '@lightningjs/renderer/webgl'; export { WebGlShader }; -import { type IRendererShaderManager } from './lightningInit.js'; import { DOM_RENDERING, SHADERS_ENABLED } from './config.js'; +import type { CoreShaderManager } from './intrinsicTypes.js'; +import { IRendererShaderManager } from './dom-renderer/domRendererTypes.js'; export type Vec4 = [x: number, y: number, z: number, w: number]; @@ -119,109 +119,18 @@ function toValidVec4(value: unknown): Vec4 { return [0, 0, 0, 0]; } -const roundedWithBorderProps: lngr.ShaderProps = { - radius: { - default: [0, 0, 0, 0], - resolve(value) { - return toValidVec4(value); - }, - }, - 'border-align': 0, - 'top-left': { - default: 0, - set(value, props) { - (props.radius as Vec4)[0] = value; - }, - get(props) { - return (props.radius as Vec4)[0]; - }, - }, - 'top-right': { - default: 0, - set(value, props) { - (props.radius as Vec4)[1] = value; - }, - get(props) { - return (props.radius as Vec4)[1]; - }, - }, - 'bottom-right': { - default: 0, - set(value, props) { - (props.radius as Vec4)[2] = value; - }, - get(props) { - return (props.radius as Vec4)[2]; - }, - }, - 'bottom-left': { - default: 0, - set(value, props) { - (props.radius as Vec4)[3] = value; - }, - get(props) { - return (props.radius as Vec4)[3]; - }, - }, - 'border-w': { - default: [0, 0, 0, 0], - resolve(value) { - return toValidVec4(value); - }, - }, - 'border-color': 0xffffffff, - 'border-gap': 0, - 'border-top': { - default: 0, - set(value, props) { - (props['border-w'] as Vec4)[0] = value; - }, - get(props) { - return (props['border-w'] as Vec4)[0]; - }, - }, - 'border-right': { - default: 0, - set(value, props) { - (props['border-w'] as Vec4)[1] = value; - }, - get(props) { - return (props['border-w'] as Vec4)[1]; - }, - }, - 'border-bottom': { - default: 0, - set(value, props) { - (props['border-w'] as Vec4)[2] = value; - }, - get(props) { - return (props['border-w'] as Vec4)[2]; - }, - }, - 'border-left': { - default: 0, - set(value, props) { - (props['border-w'] as Vec4)[3] = value; - }, - get(props) { - return (props['border-w'] as Vec4)[3]; - }, - }, - 'border-inset': true, -}; - export function registerDefaultShaderRounded( shManager: IRendererShaderManager, ) { if (SHADERS_ENABLED && !DOM_RENDERING) shManager.registerShaderType('rounded', defaultShaderRounded); } -export function registerDefaultShaderShadow(shManager: IRendererShaderManager) { +export function registerDefaultShaderShadow(shManager: CoreShaderManager) { if (SHADERS_ENABLED && !DOM_RENDERING) shManager.registerShaderType('shadow', defaultShaderShadow); } export function registerDefaultShaderRoundedWithBorder( - shManager: IRendererShaderManager, + shManager: CoreShaderManager, ) { if (SHADERS_ENABLED && !DOM_RENDERING) shManager.registerShaderType( @@ -230,7 +139,7 @@ export function registerDefaultShaderRoundedWithBorder( ); } export function registerDefaultShaderRoundedWithShadow( - shManager: IRendererShaderManager, + shManager: CoreShaderManager, ) { if (SHADERS_ENABLED && !DOM_RENDERING) shManager.registerShaderType( @@ -239,7 +148,7 @@ export function registerDefaultShaderRoundedWithShadow( ); } export function registerDefaultShaderRoundedWithBorderAndShadow( - shManager: IRendererShaderManager, + shManager: CoreShaderManager, ) { if (SHADERS_ENABLED && !DOM_RENDERING) shManager.registerShaderType( @@ -247,26 +156,24 @@ export function registerDefaultShaderRoundedWithBorderAndShadow( defaultShaderRoundedWithBorderAndShadow, ); } -export function registerDefaultShaderHolePunch( - shManager: IRendererShaderManager, -) { +export function registerDefaultShaderHolePunch(shManager: CoreShaderManager) { if (SHADERS_ENABLED && !DOM_RENDERING) shManager.registerShaderType('holePunch', defaultShaderHolePunch); } export function registerDefaultShaderRadialGradient( - shManager: IRendererShaderManager, + shManager: CoreShaderManager, ) { if (SHADERS_ENABLED && !DOM_RENDERING) shManager.registerShaderType('radialGradient', defaultShaderRadialGradient); } export function registerDefaultShaderLinearGradient( - shManager: IRendererShaderManager, + shManager: CoreShaderManager, ) { if (SHADERS_ENABLED && !DOM_RENDERING) shManager.registerShaderType('linearGradient', defaultShaderLinearGradient); } -export function registerDefaultShaders(shManager: IRendererShaderManager) { +export function registerDefaultShaders(shManager: CoreShaderManager) { if (SHADERS_ENABLED && !DOM_RENDERING) { registerDefaultShaderRounded(shManager); registerDefaultShaderShadow(shManager);