diff --git a/docs/articles/basics.md b/docs/articles/basics.md index 718ce86..5184381 100644 --- a/docs/articles/basics.md +++ b/docs/articles/basics.md @@ -65,7 +65,7 @@ const TextPage = lazy(() => import('./pages/Text')); // Create the renderer and load fonts const { render } = createRenderer(); -loadFonts(fonts); +await loadFonts(fonts); // Render the app using the HashRouter from SolidRouter render(() => ( diff --git a/docs/articles/migration-2x-to-3.0.md b/docs/articles/migration-2x-to-3.0.md index d1947a8..3ec05b5 100644 --- a/docs/articles/migration-2x-to-3.0.md +++ b/docs/articles/migration-2x-to-3.0.md @@ -122,7 +122,7 @@ Shaders are imported when needed by application. The following example shows how ```typescript const { renderer, render } = createRenderer(); -loadFonts(fonts); +await loadFonts(fonts); // Prepare for RC3 of Renderer import { Rounded, diff --git a/docs/essentials/text.md b/docs/essentials/text.md index e38a31a..9439ba1 100644 --- a/docs/essentials/text.md +++ b/docs/essentials/text.md @@ -74,7 +74,7 @@ Then you'll need to register the custom font in the AppCoreExtensions file: } as const]; // must be called after createRenderer but before render -loadFonts(fonts); +await loadFonts(fonts); ``` From this moment on you'll be able to use the font `ComicSans` anywhere in your App: diff --git a/docs/primitives/router.md b/docs/primitives/router.md index c7e220c..2cdef44 100644 --- a/docs/primitives/router.md +++ b/docs/primitives/router.md @@ -5,7 +5,7 @@ The `HashRouter` primitive is based on the [SolidTV Router](https://github.com/s ### Usage ```jsx -import { HashRouter } from '@solidtv/solid/primitives'; +import { HashRouter } from '@solidtv/solid/primitives/router'; diff --git a/eslint.config.js b/eslint.config.js index 53c4bb4..dcff811 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,7 +22,7 @@ const relaxedTypedRules = { '@typescript-eslint/no-unused-expressions': 'warn', '@typescript-eslint/unbound-method': 'warn', '@typescript-eslint/no-this-alias': 'warn', - '@typescript-eslint/no-empty-object-type': 'warn', + '@typescript-eslint/no-empty-object-type': 'off', '@typescript-eslint/ban-ts-comment': 'warn', '@typescript-eslint/restrict-template-expressions': 'warn', '@typescript-eslint/prefer-promise-reject-errors': 'warn', @@ -40,6 +40,7 @@ export default tseslint.config( parserOptions: { project: true, tsconfigRootDir: import.meta.dirname, + warnOnUnsupportedTypeScriptVersion: false, }, globals: { ...globals.browser, diff --git a/src/core/clickInspector.ts b/src/core/clickInspector.ts index 9c8cc73..835cb34 100644 --- a/src/core/clickInspector.ts +++ b/src/core/clickInspector.ts @@ -1,6 +1,7 @@ import { Config, isDev } from './config.js'; import { isElementNode } from './utils.js'; import type { ElementNode } from './elementNode.js'; +import { IRendererNode } from './dom-renderer/domRendererTypes.js'; let installed = false; @@ -48,7 +49,7 @@ function handleClick(event: MouseEvent) { const el = findDeepestAtPosition(root, event.clientX, event.clientY); event.preventDefault(); event.stopPropagation(); - const lng = el.lng as any; + const lng = el.lng as IRendererNode; const label = el.componentName || el._type; const loc = el.componentLocation ? ` @ ${el.componentLocation}` : ''; console.log( diff --git a/src/core/config.ts b/src/core/config.ts index 39fa9b4..506912f 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -24,10 +24,14 @@ import { export const isDev = !!(import.meta.env && import.meta.env.DEV); /** Whether the DOM renderer is used instead of `@solidtv/renderer` */ -export const DOM_RENDERING = globalThis.SOLIDTV_DOM_RENDERING === true; +export const DOM_RENDERING = + typeof SOLIDTV_DOM_RENDERING !== 'undefined' && + SOLIDTV_DOM_RENDERING === true; /** Whether element shaders are enabled */ -export const SHADERS_ENABLED = globalThis.SOLIDTV_DISABLE_SHADERS !== true; +export const SHADERS_ENABLED = + typeof SOLIDTV_DISABLE_SHADERS === 'undefined' || + SOLIDTV_DISABLE_SHADERS !== true; /** RUNTIME LIGHTNING CONFIGURATION \ diff --git a/src/core/dom-renderer/domRenderer.ts b/src/core/dom-renderer/domRenderer.ts index bcb3d56..9964f6a 100644 --- a/src/core/dom-renderer/domRenderer.ts +++ b/src/core/dom-renderer/domRenderer.ts @@ -235,48 +235,114 @@ function updateNodeParent(node: DOMNode | DOMText) { } } -function updateNodeStyles(node: DOMNode | DOMText) { - const { props } = node; +/** + * Builds the CSS transform string from node props (x, y, rotation, scale, mount). + * Returns an empty string if no transform is needed. + */ +function buildTransformCSS(props: IRendererNodeProps): string { + const transforms: string[] = []; - let style = `position: absolute; z-index: ${props.zIndex}; opacity: ${props.alpha ?? 1};`; + const { x, y } = props; + const hasMountX = props.mountX != null && props.mountX !== 0; + const hasMountY = props.mountY != null && props.mountY !== 0; - if (props.clipping) { - style += `overflow: hidden;`; - } + if (x !== 0) transforms.push(`translateX(${x}px)`); + if (hasMountX) transforms.push(`translateX(${-props.mountX * 100}%)`); - // Transform - { - let transform = ''; + if (y !== 0) transforms.push(`translateY(${y}px)`); + if (hasMountY) transforms.push(`translateY(${-props.mountY * 100}%)`); - const { x, y } = props; + if (props.rotation !== 0) transforms.push(`rotate(${props.rotation}rad)`); - const hasMountX = props.mountX != null && props.mountX !== 0; - const hasMountY = props.mountY != null && props.mountY !== 0; + if (props.scale !== 1 && props.scale != null) { + transforms.push(`scale(${props.scale})`); + } else { + if (props.scaleX !== 1) transforms.push(`scaleX(${props.scaleX})`); + if (props.scaleY !== 1) transforms.push(`scaleY(${props.scaleY})`); + } - if (x !== 0) transform += `translateX(${x}px)`; - if (hasMountX) transform += `translateX(${-props.mountX * 100}%)`; + return transforms.join(' '); +} - if (y !== 0) transform += `translateY(${y}px)`; - if (hasMountY) transform += `translateY(${-props.mountY * 100}%)`; +/** + * Fast path for transform-only updates (x, y, rotation, scale, mount). + * Skips full style rebuild but still re-evaluates viewport bounds for nodes + * with a texture source to drive lazy image load/unload during scroll. + */ +function updateTransformOnly(node: DOMNode | DOMText): void { + const transform = buildTransformCSS(node.props); + const s = node.div.style; + + if (transform.length > 0) { + s.transform = `${transform}`; + } else { + s.transform = ''; + } - if (props.rotation !== 0) transform += `rotate(${props.rotation}rad)`; + updateRenderStateIfNeeded(node); +} - if (props.scale !== 1 && props.scale != null) { - transform += `scale(${props.scale})`; - } else { - if (props.scaleX !== 1) transform += `scaleX(${props.scaleX})`; - if (props.scaleY !== 1) transform += `scaleY(${props.scaleY})`; +/** + * Recomputes the viewport bounds state for a node with a texture source and, + * if changed, triggers lazy image load or unload via updateRenderState. + */ +function updateRenderStateIfNeeded(node: DOMNode | DOMText): void { + if (!(node instanceof DOMNode) || node === node.stage.root) return; + 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; + } +} + +function applyLegacyObjectFit( + node: DOMNode, + img: HTMLImageElement, + srcPos: InstanceType['props'] | null, +): void { + const resizeMode = node.props.textureOptions?.resizeMode; + const clipX = + resizeMode?.type !== 'contain' && resizeMode?.clipX + ? resizeMode?.clipX + : 0.5; + const clipY = + resizeMode?.type !== 'contain' && resizeMode?.clipY + ? resizeMode?.clipY + : 0.5; + computeLegacyObjectFit( + node, + img, + resizeMode, + clipX, + clipY, + srcPos, + supportsObjectFit, + supportsObjectPosition, + ); +} + +function updateNodeStyles(node: DOMNode | DOMText) { + const { props } = node; + + let style = `position: absolute; z-index: ${props.zIndex};`; + + if (props.alpha !== 1) style += `opacity: ${props.alpha};`; + + if (props.clipping) { + style += `overflow: hidden;`; + } + // Transform + { + const transform = buildTransformCSS(props); if (transform.length > 0) { style += `transform: ${transform};`; } - - const pivotX = props.pivotX ?? props.pivot ?? 0.5; - const pivotY = props.pivotY ?? props.pivot ?? 0.5; - if (pivotX !== 0.5 || pivotY !== 0.5) { - style += `transform-origin: ${pivotX * 100}% ${pivotY * 100}%;`; - } } // @@ -438,10 +504,9 @@ function updateNodeStyles(node: DOMNode | DOMText) { let imgStyle = ''; let hasDivBgTint = false; + let hasTint = false; if (rawImgSrc) { - needsBackgroundLayer = true; - - const hasTint = props.color !== 0xffffffff && props.color !== 0x00000000; + hasTint = props.color !== 0xffffffff && props.color !== 0x00000000; if (hasTint) { bgStyle += `background-color: ${colorToRgba(props.color)};`; @@ -467,6 +532,8 @@ function updateNodeStyles(node: DOMNode | DOMText) { 'bottom: 0', 'display: block', 'pointer-events: none', + `opacity: ${node.imageLoading ? 0 : 1}`, + 'transition: opacity 100ms linear', ]; if (props.textureOptions.resizeMode?.type) { @@ -499,7 +566,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { if (supportsMixBlendMode) { imgStyleParts.push('mix-blend-mode: multiply'); } else { - imgStyleParts.push('opacity: 0.9'); + imgStyleParts.push('opacity: 1'); } } @@ -695,6 +762,16 @@ function updateNodeStyles(node: DOMNode | DOMText) { } } + // If there's still no reason to use divBg, check out tint/gradient/subtexture/radius/bgStyle + if (!needsBackgroundLayer && rawImgSrc) { + needsBackgroundLayer = + hasTint || + !!gradient || + srcPos !== null || + radiusStyle !== '' || + bgStyle !== ''; + } + style += radiusStyle; if (needsBackgroundLayer) { @@ -705,14 +782,34 @@ function updateNodeStyles(node: DOMNode | DOMText) { node.div.insertBefore(node.divBg, node.div.firstChild); } + const isSyncSubtextureUpdate = + rawImgSrc != null && + srcPos != null && + !!node.imgEl && + node.imgEl.complete && + node.imgEl.dataset.rawSrc === rawImgSrc; + if (isSyncSubtextureUpdate) { + node.imageLoading = true; + } + let bgLayerStyle = - 'position: absolute; top:0; left:0; right:0; bottom: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 clips the transformed imgEl to prevent sprite bleed-through + // for non-tinted subtextures. NOT applied when hasDivBgTint because + // overflow:hidden + CSS mask-image causes a 1px white border artifact on WebKit. + // Tinted nodes use mask-image for visual clipping, so overflow:hidden is unnecessary. + if (srcPos !== null && !hasDivBgTint) { + bgLayerStyle += 'overflow: hidden;'; + } if (bgStyle) { bgLayerStyle += bgStyle; } if (maskStyle) { bgLayerStyle += maskStyle; } + if (hasDivBgTint && srcPos != null && node.imageLoading) { + bgLayerStyle += 'opacity: 0;'; + } node.divBg.setAttribute('style', bgLayerStyle + radiusStyle); @@ -740,23 +837,22 @@ function updateNodeStyles(node: DOMNode | DOMText) { 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, - ); + if (!node.lazyImageSubTextureProps) { + applyLegacyObjectFit(node, node.imgEl!, null); + } + + // Reveal only after final fit/positioning is applied + if (node.imgEl) { + node.imageLoading = false; + node.imgEl.style.opacity = '1'; + } + node.showBackgroundLayer(); node.emit('loaded', payload); }); node.imgEl.addEventListener('error', () => { + node.imageLoading = false; + node.showBackgroundLayer(); if (node.imgEl) { node.imgEl.removeAttribute('src'); node.imgEl.style.display = 'none'; @@ -782,7 +878,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { node.divBg.appendChild(node.imgEl); } - node.imgEl.setAttribute('style', imgStyle); + node.imgEl.setAttribute('style', imgStyle + radiusStyle); if (hasDivBgTint) { node.imgEl.style.visibility = 'hidden'; @@ -800,6 +896,11 @@ function updateNodeStyles(node: DOMNode | DOMText) { node.imgEl.dataset.rawSrc === rawImgSrc ) { applySubTextureScaling(node, node.imgEl, srcPos); + if (node.imageLoading) { + node.imageLoading = false; + node.imgEl.style.opacity = '1'; + node.showBackgroundLayer(); + } } if ( !srcPos && @@ -807,19 +908,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { (!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, - ); + applyLegacyObjectFit(node, node.imgEl, srcPos); } } else { node.lazyImagePendingSrc = null; @@ -829,6 +918,112 @@ function updateNodeStyles(node: DOMNode | DOMText) { node.imgEl = undefined; } } + } else if (rawImgSrc) { + // Image directly in node.div (without divBg) when there is no tint/gradient + + // Cleanup divBg + if (node.divBg) { + node.divBg.remove(); + node.divBg = undefined; + } + + const isSyncSubtextureUpdate = + srcPos != null && + !!node.imgEl && + node.imgEl.complete && + node.imgEl.dataset.rawSrc === rawImgSrc; + if (isSyncSubtextureUpdate) { + node.imageLoading = true; + } + + if (!node.imgEl) { + node.imgEl = document.createElement('img'); + node.imgEl.alt = ''; + node.imgEl.crossOrigin = 'anonymous'; + 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, + ); + + if (!node.lazyImageSubTextureProps) { + applyLegacyObjectFit(node, node.imgEl!, null); + } + + if (node.imgEl) { + node.imageLoading = false; + node.imgEl.style.opacity = '1'; + } + node.emit('loaded', payload); + }); + + node.imgEl.addEventListener('error', () => { + node.imageLoading = false; + 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.div) { + node.div.appendChild(node.imgEl); + } + + node.imgEl.setAttribute('style', imgStyle + radiusStyle); + + 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 (node.imageLoading) { + node.imageLoading = false; + node.imgEl.style.opacity = '1'; + } + } + if ( + !srcPos && + node.imgEl.complete && + (!supportsObjectFit || !supportsObjectPosition) && + node.imgEl.dataset.rawSrc === rawImgSrc + ) { + applyLegacyObjectFit(node, node.imgEl, srcPos); + } } else { node.lazyImagePendingSrc = null; node.lazyImageSubTextureProps = null; @@ -865,20 +1060,13 @@ function updateNodeStyles(node: DOMNode | DOMText) { } } - node.div.setAttribute('style', compactString(style)); - - 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 newStyle = compactString(style); + if (node._lastStyleStr !== newStyle) { + node._lastStyleStr = newStyle; + node.div.setAttribute('style', newStyle); } + + updateRenderStateIfNeeded(node); } const textNodesToMeasure = new Set(); @@ -1015,12 +1203,16 @@ function scheduleUpdateDOMTextMeasurement(node: DOMText) { const fonts = document.fonts; if (fonts.status === 'loaded') { setTimeout(updateDOMTextMeasurements); - } else if (fonts.ready && typeof fonts.ready.then === 'function') { - fonts.ready.then(updateDOMTextMeasurements); + } else if ( + fonts.ready != null && + typeof fonts.ready.then === 'function' + ) { + void fonts.ready.then(updateDOMTextMeasurements); } else { setTimeout(updateDOMTextMeasurements, 500); } } else { + // Fallback for devices without FontFaceSet.ready() setTimeout(updateDOMTextMeasurements, 500); } } @@ -1136,12 +1328,15 @@ export class DOMNode extends EventEmitter implements IRendererNode { divBg: HTMLElement | undefined; divBorder: HTMLElement | undefined; imgEl: HTMLImageElement | undefined; + imageLoading = false; lazyImagePendingSrc: string | null = null; lazyImageSubTextureProps: | InstanceType['props'] | null = null; boundsDirty = true; children = new Set(); + /** Cached result of the last updateNodeStyles call — avoids redundant setAttribute writes. */ + _lastStyleStr: string = ''; id = ++lastNodeId; @@ -1240,11 +1435,30 @@ export class DOMNode extends EventEmitter implements IRendererNode { } } + showBackgroundLayer() { + if (this.divBg) { + this.divBg.style.opacity = '1'; + } + } + + hideMaskedBackgroundLayer() { + if ( + this.divBg && + (this.divBg.style.maskImage || this.divBg.style.webkitMaskImage) + ) { + this.divBg.style.opacity = '0'; + } + } + applyPendingImageSrc() { if (!this.imgEl) return; const pendingSrc = this.lazyImagePendingSrc; if (!pendingSrc) return; if (this.imgEl.dataset.rawSrc === pendingSrc) return; + // Hide transient frame while source is loading and being fitted. + this.imageLoading = true; + this.imgEl.style.opacity = '0'; + this.hideMaskedBackgroundLayer(); this.imgEl.style.display = ''; this.imgEl.dataset.pendingSrc = pendingSrc; this.imgEl.src = pendingSrc; @@ -1260,7 +1474,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.x = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get y() { return this.props.y; @@ -1270,7 +1484,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.y = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get w() { return this.props.w; @@ -1437,7 +1651,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.scale = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get scaleX() { return this.props.scaleX; @@ -1447,7 +1661,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.scaleX = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get scaleY() { return this.props.scaleY; @@ -1457,7 +1671,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.scaleY = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get mount() { return this.props.mount; @@ -1467,7 +1681,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.mount = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get mountX() { return this.props.mountX; @@ -1477,7 +1691,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.mountX = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get mountY() { return this.props.mountY; @@ -1487,7 +1701,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.mountY = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get pivot() { return this.props.pivot; @@ -1527,7 +1741,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.rotation = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get rtt() { return this.props.rtt; @@ -1818,8 +2032,10 @@ export class DOMRendererMain implements IRendererMain { root: DOMNode; canvas: HTMLCanvasElement; stage: IRendererStage; - private eventListeners: Map void>> = - new Map(); + private eventListeners: Map< + string, + Set<(target: unknown, data: unknown) => void> + > = new Map(); constructor( public settings: DomRendererMainSettings, @@ -1953,12 +2169,12 @@ export class DOMRendererMain implements IRendererMain { ): void; emit( event: Extract, - target: any, + target: unknown, data: Parameters[1], ): void; emit( event: Extract, - targetOrData: any, + targetOrData: unknown, maybeData?: Parameters[1], ): void { const listeners = this.eventListeners.get(event); diff --git a/src/core/elementNode.ts b/src/core/elementNode.ts index e9746f0..3fded45 100644 --- a/src/core/elementNode.ts +++ b/src/core/elementNode.ts @@ -147,7 +147,7 @@ function buildFontTemplate() { _fontFamilyIdx = tpl.length; _fontFamilyWithWeight = `${fs.fontFamily}${fs.fontWeight || ''}`; } - tpl.push([key, (fs as any)[key]]); + tpl.push([key, fs[key]]); } } _fontTemplate = tpl; @@ -155,8 +155,8 @@ function buildFontTemplate() { const parseAndAssignShaderProps = ( prefix: string, - obj: Record, - props: Record = {}, + obj: Record, + props: Record = {}, ) => { if (!obj) return; @@ -701,7 +701,7 @@ export interface ElementNode extends RendererNode, FocusNode { * * @see https://lightning-tv.github.io/solid/#/flow/ondestroy */ - onDestroy?: (this: ElementNode, el: ElementNode) => Promise | void; + onDestroy?: (this: ElementNode, el: ElementNode) => Promise | void; /** * Optional handlers for when the element is rendered—after creation and when switching parents. * @@ -889,7 +889,7 @@ export class ElementNode { (Config.fontWeightAlias && (Config.fontWeightAlias[v as string] as number | string)) ?? v; - (this.lng as any).fontFamily = + (this.lng as ElementNode).fontFamily = `${this.fontFamily || Config.fontSettings?.fontFamily}${weight}`; } @@ -899,7 +899,7 @@ export class ElementNode { set fontFamily(v) { this._fontFamily = v; - (this.lng as any).fontFamily = v; + (this.lng as ElementNode).fontFamily = v; } get fontFamily() { @@ -997,7 +997,7 @@ export class ElementNode { return animationController.start(); } - const result = (this.lng as any).animateProp( + const result = (this.lng as INode).animateProp( name, value, animationSettings || this.animationSettings || {}, @@ -1031,8 +1031,9 @@ export class ElementNode { props: Partial>, animationSettings?: AnimationSettings, ): IAnimationController { - isDev && + if (isDev) { assertTruthy(this.rendered, 'Node must be rendered before animating'); + } return (this.lng as IRendererNode).animate( props, animationSettings || this.animationSettings || {}, @@ -1142,7 +1143,7 @@ export class ElementNode { // If onDestroy returns a promise, wait for it to resolve before destroying // Useful with animations waitUntilStopped method which returns promise if (destroyPromise instanceof Promise) { - destroyPromise.then(() => this._destroy()); + void destroyPromise.then(() => this._destroy()); } else { this._destroy(); } @@ -1281,7 +1282,7 @@ export class ElementNode { * @param val - A value to determine if the element should autofocus. * A truthy value enables autofocus, otherwise disables it. */ - set autofocus(val: any) { + set autofocus(val: boolean | undefined) { this._autofocus = val; // Defer setFocus so children render first (forwardFocus needs them). // The post-mutation focus phase calls setFocus on this element. @@ -1332,7 +1333,7 @@ export class ElementNode { return this._requiresLayout; } - set updateLayoutOn(v: any) { + set updateLayoutOn(_v: unknown) { this.updateLayout(); } @@ -1342,7 +1343,7 @@ export class ElementNode { updateLayout() { if (this.hasChildren) { - isDev && log('Layout: ', this); + if (isDev) log('Layout: ', this); if (this.display === 'flex' && this.flexGrow && this.width === 0) { return; @@ -1372,10 +1373,10 @@ export class ElementNode { } _stateChanged() { - isDev && log('State Changed: ', this, this.states); + if (isDev) log('State Changed: ', this, this.states); if (isDev) { - const div = (this.lng as any)?.div as HTMLElement | undefined; + const div = (this.lng as IRendererNode)?.div; if (div) { if (this.states.length > 0) { div.dataset.states = this.states.join(' '); @@ -1589,7 +1590,7 @@ export class ElementNode { props.shader = Config.convertToShader(node, props.shader); } - isDev && log('Rendering: ', this, props); + if (isDev) log('Rendering: ', this, props); node.lng = renderer.createTextNode( props as Partial & Partial, @@ -1629,7 +1630,7 @@ export class ElementNode { props.shader = Config.convertToShader(node, props.shader); } - isDev && log('Rendering: ', this, props); + if (isDev) log('Rendering: ', this, props); node.lng = renderer.createNode( props as Partial> & Partial, @@ -1640,7 +1641,7 @@ export class ElementNode { for (const child of node.children) { if (isElementNode(child) && isINode(child.lng)) { - child.lng.parent = node.lng as any; + child.lng.parent = node.lng as INode; } } } @@ -1668,7 +1669,7 @@ export class ElementNode { } // L3 Inspector adds div to the lng object - const div: HTMLElement | undefined = (node.lng as any)?.div; + const div: HTMLElement | undefined = (node.lng as IRendererNode)?.div; if (isDev && div) { div.element = node; if (node._states && node._states.length > 0) { @@ -1681,7 +1682,7 @@ export class ElementNode { const numChildren = node.children.length; for (let i = 0; i < numChildren; i++) { const c = node.children[i]; - isDev && assertTruthy(c, 'Child is undefined'); + if (isDev) assertTruthy(c, 'Child is undefined'); // Text elements sneak in from Solid creating tracked nodes if (isElementNode(c)) { c.render(); @@ -1694,7 +1695,7 @@ export class ElementNode { schedulePostMutation(); } - node._autofocus && node.setFocus(); + if (node._autofocus) node.setFocus(); } } diff --git a/src/core/focusManager.ts b/src/core/focusManager.ts index f1407e4..98dddca 100644 --- a/src/core/focusManager.ts +++ b/src/core/focusManager.ts @@ -1,4 +1,5 @@ import { Config, isDev } from './config.js'; +import { IRendererNode } from './dom-renderer/domRendererTypes.js'; export type * from './focusKeyTypes.js'; import { ElementNode } from './elementNode.js'; import type { @@ -8,7 +9,9 @@ import type { } from './focusKeyTypes.js'; import { isFunction } from './utils.js'; -const keyMapEntries: Record = { +type KeyMapEntries = Record; + +const keyMapEntries: KeyMapEntries = { ArrowLeft: 'Left', ArrowRight: 'Right', ArrowUp: 'Up', @@ -24,18 +27,23 @@ const keyHoldMapEntries: Record = { // Enter: 'EnterHold', }; -const flattenKeyMap = (keyMap: any, targetMap: any): void => { +const flattenKeyMap = ( + keyMap: Partial, + targetMap: KeyMapEntries, +): KeyMapEntries => { + const newTargetMap = targetMap; for (const [key, value] of Object.entries(keyMap)) { if (Array.isArray(value)) { value.forEach((v) => { - targetMap[v] = key; + newTargetMap[v] = key; }); } else if (value === null) { - delete targetMap[key]; + delete newTargetMap[key]; } else { - targetMap[value as keyof any] = key; + newTargetMap[value as KeyNameOrKeyCode] = key; } } + return newTargetMap; }; let needFocusDebugStyles = true; @@ -117,7 +125,7 @@ let _pendingHistoryKey: { const getElementLabel = (elm: ElementNode | undefined): string => { if (!elm) return 'None'; // ElementNode exposes _id internally; componentName comes from the Babel devtools plugin - const id = (elm as any).id ?? elm._id; + const id = elm.id ?? elm._id; return id ?? elm.componentName ?? 'Unknown'; }; @@ -173,14 +181,14 @@ export const printFocusHistory = (count: number): void => { key: e.mappedKey ?? e.keyPressed ?? '—', next: getElementLabel(e.next), nextElm: e.next, - nextDiv: (e.next.lng as any).div as HTMLDivElement, + nextDiv: (e.next.lng as IRendererNode).div, })), ); // 2. Expose the most recent element for easy inspection const lastEntry = entries[entries.length - 1]; if (lastEntry) { - const lastElm = (lastEntry.next.lng as any)?.div; + const lastElm = (lastEntry.next.lng as IRendererNode)?.div; if (lastElm) { (window as any).$f = lastElm; } diff --git a/src/primitives/Image.tsx b/src/primitives/Image.tsx index 884ea94..7a6fd99 100644 --- a/src/primitives/Image.tsx +++ b/src/primitives/Image.tsx @@ -1,5 +1,5 @@ import { type Component, createRenderEffect, createSignal } from 'solid-js'; -import { renderer, type NodeProps } from '@solidtv/solid'; +import { ImageTexture, renderer, type NodeProps } from '@solidtv/solid'; import { Config } from '../core/config.js'; export interface ImageProps extends NodeProps { @@ -10,7 +10,7 @@ export interface ImageProps extends NodeProps { } export const Image: Component = (props) => { - const [texture, setTexture] = createSignal(null); + const [texture, setTexture] = createSignal(null); const [src, setSrc] = createSignal(props.placeholder || null); createRenderEffect(() => { diff --git a/src/primitives/Preserve.tsx b/src/primitives/Preserve.tsx index 76b3bcd..1469d3d 100644 --- a/src/primitives/Preserve.tsx +++ b/src/primitives/Preserve.tsx @@ -1,18 +1,23 @@ -import * as s from 'solid-js' -import * as lng from '@solidtv/solid' +import * as s from 'solid-js'; +import * as lng from '@solidtv/solid'; function Preserve(props: lng.NodeProps): s.JSX.Element { - - const view = as any as lng.ElementNode + const view = () as unknown as lng.ElementNode; view.preserve = true; - view.onRender ??= () => {view.hidden = false} - view.onRemove ??= () => {view.hidden = true} + view.onRender ??= () => { + view.hidden = false; + }; + view.onRemove ??= () => { + view.hidden = true; + }; - s.onCleanup(() => {view.destroy()}) + s.onCleanup(() => { + view.destroy(); + }); - return view as any as s.JSX.Element + return view as unknown as s.JSX.Element; } export default Preserve; diff --git a/src/solidOpts.ts b/src/solidOpts.ts index a605e26..dee1402 100644 --- a/src/solidOpts.ts +++ b/src/solidOpts.ts @@ -11,10 +11,10 @@ import { import type { SolidNode, SolidRendererOptions } from './types.js'; Object.defineProperty(ElementNode.prototype, 'preserve', { - get(): boolean | undefined { + get(this: ElementNode): boolean | undefined { return this._queueDelete === 0; }, - set(v: boolean) { + set(this: ElementNode, v: boolean) { this._queueDelete = v ? 0 : undefined; }, }); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 4c5da66..49c08a0 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "types": ["node"] + "types": ["vitest/globals"] }, "include": ["**/*", "../src/**/*"] }