diff --git a/apps/html23/src/components/onboarding.tsx b/apps/html23/src/components/onboarding.tsx index f514a5f2..700360ac 100644 --- a/apps/html23/src/components/onboarding.tsx +++ b/apps/html23/src/components/onboarding.tsx @@ -48,8 +48,8 @@ function OnboardDialog() { const [suggestedTutorials, setSuggestedTutorials] = useState | undefined>(undefined) return ( - -
+ +

Welcome to HTML23

@@ -75,6 +75,14 @@ function ExperienceQuestionnaire({ return ( <>

HTML23 simplifies building 3D user interfaces on the web.

+

For the best experience answer the following questions to the best of your knowledge. diff --git a/examples/uikit/src/App.tsx b/examples/uikit/src/App.tsx index 24aadf0e..8eccfc94 100644 --- a/examples/uikit/src/App.tsx +++ b/examples/uikit/src/App.tsx @@ -55,7 +55,7 @@ export default function App() { borderRightWidth={0} borderColor="red" > - + diff --git a/packages/react/src/portal.tsx b/packages/react/src/portal.tsx index d2ea0f83..e4413834 100644 --- a/packages/react/src/portal.tsx +++ b/packages/react/src/portal.tsx @@ -1,8 +1,8 @@ -import { effect } from '@preact/signals-core' +import { Signal, computed, effect } from '@preact/signals-core' import { ReactNode, RefAttributes, RefObject, forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react' import { HalfFloatType, LinearFilter, Scene, WebGLRenderTarget } from 'three' import { Image } from './image.js' -import { InjectState, RootState, createPortal, useFrame, useStore } from '@react-three/fiber' +import { InjectState, RootState, createPortal, useFrame, useStore, useThree } from '@react-three/fiber' import type { DomEvent, EventHandlers } from '@react-three/fiber/dist/declarations/src/core/events.js' import type { ImageProperties } from '@pmndrs/uikit/internals' import type { ComponentInternals } from './ref.js' @@ -21,16 +21,7 @@ export type PortalProperties = { export const Portal: (props: PortalProperties & RefAttributes>) => ReactNode = forwardRef( ({ children, resolution = 1, frames = Infinity, renderPriority = 0, eventPriority = 0, ...props }, ref) => { - // eslint-disable-next-line react-hooks/exhaustive-deps - const fbo = useMemo( - () => - new WebGLRenderTarget(1, 1, { - minFilter: LinearFilter, - magFilter: LinearFilter, - type: HalfFloatType, - }), - [], - ) + const fbo = useMemo(() => new Signal(undefined), []) const imageRef = useRef>(null) const injectState = useMemo( () => ({ @@ -39,28 +30,35 @@ export const Portal: (props: PortalProperties & RefAttributes { if (imageRef.current == null) { return } + const renderTarget = (fbo.value = new WebGLRenderTarget(1, 1, { + minFilter: LinearFilter, + magFilter: LinearFilter, + type: HalfFloatType, + })) const { size } = imageRef.current const unsubscribeSetSize = effect(() => { if (size.value == null) { return } const [width, height] = size.value - fbo.setSize(width, height) + const dpr = store.getState().viewport.dpr + renderTarget.setSize(width * dpr, height * dpr) injectState.size!.width = width injectState.size!.height = height }) return () => { unsubscribeSetSize() - //TODO: portal wont work in strict mode - fbo.dispose() + renderTarget.dispose() } - }, [fbo, injectState]) + }, [fbo, injectState, store]) useImperativeHandle(ref, () => imageRef.current!, []) const vScene = useMemo(() => new Scene(), []) + const texture = useMemo(() => computed(() => fbo.value?.texture), [fbo]) return ( <> {createPortal( @@ -72,7 +70,7 @@ export const Portal: (props: PortalProperties & RefAttributes + ) }, @@ -108,7 +106,7 @@ function ChildrenToFBO({ frames: number renderPriority: number children: ReactNode - fbo: WebGLRenderTarget + fbo: Signal imageRef: RefObject> }) { const store = useStore() @@ -129,12 +127,16 @@ function ChildrenToFBO({ let oldAutoClear let oldXrEnabled useFrame((state) => { + const currentFBO = fbo.peek() + if (currentFBO == null) { + return + } if (frames === Infinity || count < frames) { oldAutoClear = state.gl.autoClear oldXrEnabled = state.gl.xr.enabled state.gl.autoClear = true state.gl.xr.enabled = false - state.gl.setRenderTarget(fbo) + state.gl.setRenderTarget(currentFBO) state.gl.render(state.scene, state.camera) state.gl.setRenderTarget(null) state.gl.autoClear = oldAutoClear diff --git a/packages/uikit/scripts/extract-core-component-properties.ts b/packages/uikit/scripts/extract-core-component-properties.ts index 16b6f043..29407515 100644 --- a/packages/uikit/scripts/extract-core-component-properties.ts +++ b/packages/uikit/scripts/extract-core-component-properties.ts @@ -85,4 +85,7 @@ const result = { ), ), } -writeFileSync(resolve(__dirname, '../src/convert/html/properties.json'), JSON.stringify(result)) +writeFileSync( + resolve(__dirname, '../src/convert/html/generated-property-types.ts'), + `export const generatedPropertyTypes = ${JSON.stringify(result)}`, +) diff --git a/packages/uikit/src/components/svg.ts b/packages/uikit/src/components/svg.ts index 973649ca..6457b191 100644 --- a/packages/uikit/src/components/svg.ts +++ b/packages/uikit/src/components/svg.ts @@ -37,7 +37,7 @@ import { createActivePropertyTransfomers } from '../active.js' import { createHoverPropertyTransformers, setupCursorCleanup } from '../hover.js' import { createInteractionPanel } from '../panel/instanced-panel-mesh.js' import { createResponsivePropertyTransformers } from '../responsive.js' -import { SVGLoader } from 'three/examples/jsm/Addons.js' +import { SVGLoader, SVGResult } from 'three/examples/jsm/Addons.js' import { darkPropertyTransformers } from '../dark.js' import { PanelGroupProperties, computedPanelGroupDependencies, getDefaultPanelMaterialConfig } from '../panel/index.js' import { KeepAspectRatioProperties } from './image.js' @@ -258,6 +258,8 @@ const loader = new SVGLoader() const box3Helper = new Box3() const vectorHelper = new Vector3() +const svgCache = new Map() + async function loadSvg( url: string | undefined, root: RootContext, @@ -271,7 +273,10 @@ async function loadSvg( } const object = new Group() object.matrixAutoUpdate = false - const result = await loader.loadAsync(url) + let result = svgCache.get(url) + if (result == null) { + svgCache.set(url, (result = await loader.loadAsync(url))) + } box3Helper.makeEmpty() for (const path of result.paths) { const shapes = SVGLoader.createShapes(path) diff --git a/packages/uikit/src/convert/html/.gitignore b/packages/uikit/src/convert/html/.gitignore index fb922286..8a94f4bb 100644 --- a/packages/uikit/src/convert/html/.gitignore +++ b/packages/uikit/src/convert/html/.gitignore @@ -1 +1 @@ -properties.json \ No newline at end of file +generated-property-types.ts \ No newline at end of file diff --git a/packages/uikit/src/convert/html/internals.ts b/packages/uikit/src/convert/html/internals.ts index a5c832e6..369911f5 100644 --- a/packages/uikit/src/convert/html/internals.ts +++ b/packages/uikit/src/convert/html/internals.ts @@ -2,11 +2,11 @@ import { parse as parseHTML, Node as ConversionNode, TextNode, HTMLElement } fro import { htmlDefaults } from './defaults.js' import parseInlineCSS, { Declaration, Comment } from 'inline-style-parser' import { tailwindToCSS } from 'tw-to-css' -import generatedPropertyTypes from './properties.json' assert { type: 'json' } +//@ts-ignore +import { generatedPropertyTypes } from './generated-property-types.js' import { ConversionColorMap, ConversionPropertyTypes, - convertProperties as convertCssProperties, convertProperties, convertProperty, isInheritingProperty, @@ -454,17 +454,14 @@ function convertHtmlAttributes( styles = parseInlineCSS(style) } } catch {} + const stylesMap: Record = {} for (const style of styles) { if (style.type === 'comment') { continue } - const key = kebabToCamelCase(style.property) - const value = convertProperty(propertyTypes, key, style.value, colorMap) - if (value == null) { - continue - } - result[key] = value + stylesMap[kebabToCamelCase(style.property)] = style.value } + Object.assign(result, convertProperties(propertyTypes, stylesMap, colorMap, kebabToCamelCase) ?? {}) if (!custom && !('display' in result) && !('flexDirection' in result)) { const key = 'flexDirection' @@ -513,7 +510,7 @@ function convertTailwind( Object.assign(properties, tailwindToJson(withoutConditionals, classes)) - return convertCssProperties(propertyTypes, properties, colorMap) ?? {} + return convertProperties(propertyTypes, properties, colorMap) ?? {} } export * from './properties.js' diff --git a/packages/uikit/src/convert/html/properties.ts b/packages/uikit/src/convert/html/properties.ts index bc6c1418..f34cb1c4 100644 --- a/packages/uikit/src/convert/html/properties.ts +++ b/packages/uikit/src/convert/html/properties.ts @@ -1,5 +1,4 @@ -import { ReadonlySignal, Signal } from '@preact/signals-core' -import { ColorRepresentation } from '../../utils.js' +import { ColorRepresentation, percentageRegex } from '../../utils.js' import { CSSProperties } from 'react' export type ConversionPropertyType = Array> //<- enum @@ -23,10 +22,41 @@ const propertyRenamings = { zIndex: 'zIndexOffset', } -const cssShorthandPropertyTranslation: Record< - string, - (set: (key: string, value: string) => void, property: unknown) => void -> = { +const transformRegex = /(translate|rotate)(X|Y|Z|3d)?\((\s*[^,)]+\s*(?:,\s*[^,)]+\s*)*)\)/g + +const customCssTranslation: Record void, property: unknown) => void> = { + transform: (set, property) => { + if (typeof property != 'string') { + return + } + let result: RegExpExecArray | null + while ((result = transformRegex.exec(property)) != null) { + const [, operation, type, values] = result + let [x, y, z] = values.split(',').map((s) => s.trim()) + + const prefix = `transform${operation[0].toUpperCase()}${operation.slice(1)}` + + if (operation === 'rotate') { + set(`${prefix}Z`, x) + continue + } + + y ??= x + z ??= x + + if (type === 'X' || type === '3d' || type === undefined) { + set(`${prefix}X`, x) + } + + if (type === 'Y' || type === '3d' || type === undefined) { + set(`${prefix}Y`, y) + } + + if (type === 'Z' || type === '3d') { + set(`${prefix}Z`, z) + } + } + }, flex: (set, property) => { //TODO: simplify if (typeof property != 'string') { @@ -99,8 +129,6 @@ export function isInheritingProperty(key: string): boolean { } } -const percentageRegex = /^(-?\d+|\d*\.\d+)\%$/ - const conditionals = ['sm', 'md', 'lg', 'xl', '2xl', 'focus', 'hover', 'active', 'dark'] export function convertProperties( @@ -138,8 +166,8 @@ export function convertProperties( if (convertKey != null) { key = convertKey(key) } - if (key in cssShorthandPropertyTranslation) { - cssShorthandPropertyTranslation[key](set, property) + if (key in customCssTranslation) { + customCssTranslation[key](set, property) continue } if (key === 'positionType' && property === 'fixed') { diff --git a/packages/uikit/src/panel/instanced-panel.ts b/packages/uikit/src/panel/instanced-panel.ts index 8e4112b1..a0f8ddbe 100644 --- a/packages/uikit/src/panel/instanced-panel.ts +++ b/packages/uikit/src/panel/instanced-panel.ts @@ -8,7 +8,7 @@ import { ColorRepresentation, Subscriptions, unsubscribeSubscriptions } from '.. import { MergedProperties } from '../properties/merged.js' import { setupImmediateProperties } from '../properties/immediate.js' import { OrderInfo } from '../order.js' -import { PanelMaterialConfig } from './index.js' +import { PanelMaterialConfig } from './panel-material.js' export type PanelProperties = { borderTopLeftRadius?: number diff --git a/packages/uikit/src/text/utils.ts b/packages/uikit/src/text/utils.ts index 2893a7c3..73c5ac17 100644 --- a/packages/uikit/src/text/utils.ts +++ b/packages/uikit/src/text/utils.ts @@ -1,5 +1,6 @@ import { GlyphLayout, GlyphLayoutProperties } from './layout.js' import { Font, GlyphInfo } from './font.js' +import { percentageRegex } from '../utils.js' export function getGlyphOffsetX( font: Font, @@ -11,8 +12,6 @@ export function getGlyphOffsetX( return (kerning + glyphInfo.xoffset) * fontSize } -const percentageRegex = /^(-?\d+(?:\.\d+)?)%$/ - function lineHeightToAbsolute(lineHeight: GlyphLayoutProperties['lineHeight'], fontSize: number): number { if (typeof lineHeight === 'number') { return lineHeight diff --git a/packages/uikit/src/transform.ts b/packages/uikit/src/transform.ts index 2a75ddbc..6e686cc9 100644 --- a/packages/uikit/src/transform.ts +++ b/packages/uikit/src/transform.ts @@ -1,23 +1,25 @@ import { Signal, computed, effect } from '@preact/signals-core' -import { Euler, Matrix4, Quaternion, Vector3, Vector3Tuple } from 'three' +import { Euler, Matrix4, Quaternion, Vector2Tuple, Vector3, Vector3Tuple } from 'three' import { FlexNodeState } from './flex/node.js' -import { Initializers, alignmentXMap, alignmentYMap } from './utils.js' +import { Initializers, alignmentXMap, alignmentYMap, percentageRegex } from './utils.js' import { MergedProperties } from './properties/merged.js' import { Object3DRef } from './context.js' import { computedProperty } from './properties/index.js' +export type Percentage = `${number}%` + export type TransformProperties = { - transformTranslateX?: number - transformTranslateY?: number + transformTranslateX?: Percentage | number + transformTranslateY?: Percentage | number transformTranslateZ?: number transformRotateX?: number transformRotateY?: number transformRotateZ?: number - transformScaleX?: number - transformScaleY?: number - transformScaleZ?: number + transformScaleX?: Percentage | number + transformScaleY?: Percentage | number + transformScaleZ?: Percentage | number transformOriginX?: keyof typeof alignmentXMap transformOriginY?: keyof typeof alignmentYMap @@ -48,15 +50,15 @@ export function computedTransformMatrix( //O = matrix to transform the origin for matrix T //T = transform matrix (translate, rotate, scale) - const tTX = computedProperty(propertiesSignal, 'transformTranslateX', 0) - const tTY = computedProperty(propertiesSignal, 'transformTranslateY', 0) + const tTX = computedProperty(propertiesSignal, 'transformTranslateX', 0) + const tTY = computedProperty(propertiesSignal, 'transformTranslateY', 0) const tTZ = computedProperty(propertiesSignal, 'transformTranslateZ', 0) const tRX = computedProperty(propertiesSignal, 'transformRotateX', 0) const tRY = computedProperty(propertiesSignal, 'transformRotateY', 0) const tRZ = computedProperty(propertiesSignal, 'transformRotateZ', 0) - const tSX = computedProperty(propertiesSignal, 'transformScaleX', 1) - const tSY = computedProperty(propertiesSignal, 'transformScaleY', 1) - const tSZ = computedProperty(propertiesSignal, 'transformScaleZ', 1) + const tSX = computedProperty(propertiesSignal, 'transformScaleX', 1) + const tSY = computedProperty(propertiesSignal, 'transformScaleY', 1) + const tSZ = computedProperty(propertiesSignal, 'transformScaleZ', 1) const tOX = computedProperty(propertiesSignal, 'transformOriginX', defaultTransformOriginX) const tOY = computedProperty(propertiesSignal, 'transformOriginY', defaultTransformOriginY) @@ -82,8 +84,8 @@ export function computedTransformMatrix( } const r: Vector3Tuple = [tRX.value, tRY.value, tRZ.value] - const t: Vector3Tuple = [tTX.value, -tTY.value, tTZ.value] - const s: Vector3Tuple = [tSX.value, tSY.value, tSZ.value] + const t: Vector3Tuple = [translateToNumber(tTX.value, size, 0), -translateToNumber(tTY.value, size, 1), tTZ.value] + const s: Vector3Tuple = [scaleToNumber(tSX.value), scaleToNumber(tSY.value), scaleToNumber(tSZ.value)] if (t.some((v) => v != 0) || r.some((v) => v != 0) || s.some((v) => v != 1)) { result.multiply( matrixHelper.compose(tHelper.fromArray(t).multiplyScalar(pixelSize), toQuaternion(r), sHelper.fromArray(s)), @@ -98,6 +100,29 @@ export function computedTransformMatrix( }) } +function scaleToNumber(scale: number | Percentage) { + if (typeof scale === 'number') { + return scale + } + const result = percentageRegex.exec(scale) + if (result == null) { + throw new Error(`invalid value "${scale}", expected number of percentage`) + } + return parseFloat(result[1]) / 100 +} + +function translateToNumber(translate: number | Percentage, size: Signal, sizeIndex: number) { + if (typeof translate === 'number') { + return translate + } + const result = percentageRegex.exec(translate) + if (result == null) { + throw new Error(`invalid value "${translate}", expected number of percentage`) + } + const sizeOnAxis = size.value?.[sizeIndex] ?? 0 + return (sizeOnAxis * parseFloat(result[1])) / 100 +} + export function applyTransform( object: Object3DRef, transformMatrix: Signal, diff --git a/packages/uikit/src/utils.ts b/packages/uikit/src/utils.ts index 5555fc7d..818c1fc7 100644 --- a/packages/uikit/src/utils.ts +++ b/packages/uikit/src/utils.ts @@ -4,6 +4,8 @@ import { Inset } from './flex/node.js' import { MergedProperties } from './properties/merged.js' import { computedProperty } from './properties/index.js' +export const percentageRegex = /(-?\d+(?:\.\d+)?)%/ + export type ColorRepresentation = Color | string | number | Vector3Tuple export type Initializers = Array<(subscriptions: Subscriptions) => Subscriptions | (() => void)> diff --git a/packages/uikit/src/vanilla/video.ts b/packages/uikit/src/vanilla/video.ts index bfb0df6b..3e5ffdff 100644 --- a/packages/uikit/src/vanilla/video.ts +++ b/packages/uikit/src/vanilla/video.ts @@ -1,8 +1,8 @@ import { Image } from './image.js' import { VideoTexture } from 'three' -import { AllOptionalProperties, ImageProperties, VideoContainerProperties } from '../index.js' import { Signal, signal } from '@preact/signals-core' -import { updateVideoElement } from '../components/index.js' +import { ImageProperties, VideoContainerProperties, updateVideoElement } from '../components/index.js' +import { AllOptionalProperties } from '../properties/index.js' export class VideoContainer extends Image { public readonly element: HTMLVideoElement