diff --git a/bin/BUILD.bazel b/bin/BUILD.bazel index 48d4df70..3f66d7cf 100644 --- a/bin/BUILD.bazel +++ b/bin/BUILD.bazel @@ -6,6 +6,8 @@ load( "pngquant_linux", "pngquant_macos", "valdi_compiler_companion_files", + "valdi_compiler_linux", + "valdi_compiler_macos", ) filegroup( @@ -18,8 +20,8 @@ native_binary( name = "valdi_compiler", src = select( { - "@bazel_tools//src/conditions:darwin": "@valdi_compiler_macos//:valdi_compiler", - "@bazel_tools//src/conditions:linux_x86_64": "@valdi_compiler_linux//:valdi_compiler", + "@bazel_tools//src/conditions:darwin": valdi_compiler_macos(), + "@bazel_tools//src/conditions:linux_x86_64": valdi_compiler_linux(), }, ), out = "valdi_compiler", diff --git a/bin/compiler/macos/valdi_compiler b/bin/compiler/macos/valdi_compiler index de99f636..8a4bf9a4 100755 --- a/bin/compiler/macos/valdi_compiler +++ b/bin/compiler/macos/valdi_compiler @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:02973a93cd149fb72c8f0ff1497e820d784f3913f756aaf26e361b1e9549ed44 -size 48299856 +oid sha256:923e9129e05caa4b2d53dd9093e14433a1114f3e44c56b00cc3eca5f0a72e59a +size 280 diff --git a/bzl/prebuilt_tools.bzl b/bzl/prebuilt_tools.bzl index c04b5be0..d6533cec 100644 --- a/bzl/prebuilt_tools.bzl +++ b/bzl/prebuilt_tools.bzl @@ -12,6 +12,16 @@ def pngquant_macos(): return "@valdi_pngquant_macos//:pngquant" return "pngquant/macos/pngquant" +def valdi_compiler_macos(): + if INTERNAL_BUILD: + pass + return "compiler/macos/valdi_compiler" + +def valdi_compiler_linux(): + if INTERNAL_BUILD: + pass + return "compiler/linux/valdi_compiler" + def valdi_compiler_companion_files(): if INTERNAL_BUILD: return ["@valdi_compiler_companion//:all_files"] diff --git a/bzl/valdi/valdi_exported_library.bzl b/bzl/valdi/valdi_exported_library.bzl index 0e9f7254..1985ae99 100644 --- a/bzl/valdi/valdi_exported_library.bzl +++ b/bzl/valdi/valdi_exported_library.bzl @@ -1,7 +1,6 @@ load("@aspect_rules_js//npm:defs.bzl", "npm_package") load("@build_bazel_rules_apple//apple:apple.bzl", "apple_xcframework") load("//bzl:expand_template.bzl", "expand_template") -load("//bzl/android:collect_android_assets.bzl", "collect_android_assets") load("//bzl/valdi:rewrite_hdrs.bzl", "rewrite_hdrs") load("//bzl/valdi:suffixed_deps.bzl", "get_suffixed_deps") load("//bzl/valdi:valdi_collapse_web_paths.bzl", "collapse_native_paths", "collapse_web_paths") @@ -77,28 +76,6 @@ def valdi_exported_library( java_deps = java_deps + get_suffixed_deps(deps, "_kt") - collect_android_assets( - name = "{}_android_assets".format(name), - valdi_deps = deps, - deps = java_deps, - output_target = source_set_select( - debug = "debug", - release = "release", - ), - ) - - valdi_android_aar( - name = "{}_android".format(name), - java_deps = java_deps, - native_deps = [ - "@valdi//valdi", - ] + get_suffixed_deps(deps, "_native"), - additional_assets = [":{}_android_assets".format(name)], - excluded_class_path_patterns = android_excluded_class_path_patterns, - so_name = "lib{}.so".format(name), - tags = ["valdi_android_exported_library"], - ) - package_name = web_package_name if npm_scope: package_name = npm_scope + "/" + package_name diff --git a/compiler/compiler/Compiler/Sources/Processors/PrependWebJsProcessor.swift b/compiler/compiler/Compiler/Sources/Processors/PrependWebJsProcessor.swift index 86ffa3d4..2fca0ccf 100644 --- a/compiler/compiler/Compiler/Sources/Processors/PrependWebJsProcessor.swift +++ b/compiler/compiler/Compiler/Sources/Processors/PrependWebJsProcessor.swift @@ -44,11 +44,46 @@ class PrependWebJSProcessor: CompilationProcessor { } } - let relativePath = item.relativeProjectPath + var relativePath = item.relativeProjectPath + // Strip TypeScript extensions (.tsx, .ts) from the path since compiled files are .js + // This ensures module.path matches what the module loader expects + if relativePath.hasSuffix(".tsx") { + relativePath = String(relativePath.dropLast(4)) + } else if relativePath.hasSuffix(".ts") { + relativePath = String(relativePath.dropLast(3)) + } + + logger.debug("PrependWebJSProcessor: ========== Processing Web JS File ==========") + logger.debug("PrependWebJSProcessor: Output file path: \(finalFileOutput)") + logger.debug("PrependWebJSProcessor: relativeProjectPath (original): '\(item.relativeProjectPath)'") + logger.debug("PrependWebJSProcessor: relativeProjectPath (adjusted): '\(relativePath)'") + logger.debug("PrependWebJSProcessor: relativeProjectPath length: \(relativePath.count)") + logger.debug("PrependWebJSProcessor: sourceURL: \(item.sourceURL.absoluteString)") + logger.debug("PrependWebJSProcessor: bundleInfo.name: \(item.bundleInfo.name)") + logger.debug("PrependWebJSProcessor: outputURL.lastPathComponent: \(finalFile.outputURL.lastPathComponent)") + var newFile = finalFile.file var contents: String? = try? newFile.readString() + let contentsLength = contents?.count ?? 0 + logger.debug("PrependWebJSProcessor: File contents length: \(contentsLength)") + + // Count how many require( calls are in the file + let requireCount = contents?.components(separatedBy: "require(").count ?? 1 + logger.debug("PrependWebJSProcessor: Found \(requireCount - 1) 'require(' occurrences") + + // Transform require( to customRequire( - this must happen for all web JS files + // Note: TypeScript with module: "commonjs" already transforms import() to Promise.resolve().then(() => require(...)), + // so we only need to transform require( to customRequire( and the import() transformation is handled automatically. contents = contents?.replacingOccurrences(of: "require(", with: "customRequire(") - let prefix = "var customRequire = globalThis.moduleLoader.resolveRequire(\"\(relativePath)\");\n" + + // Set up module.path for code that uses NavigationPage decorator + // The module variable is provided by webpack as a function parameter, so we just set the path property + // The module variable is declared in source code as: declare const module: { path: string; exports: unknown }; + // Note: We use the adjusted relativePath (without .tsx/.ts extension) so module resolution works correctly + let moduleSetup = "module.path = \"\(relativePath)\";\n" + let prefix = "\(moduleSetup)var customRequire = globalThis.moduleLoader.resolveRequire(\"\(relativePath)\");\n" + logger.debug("PrependWebJSProcessor: Generated prefix (first 100 chars): \(String(prefix.prefix(100)))") + logger.debug("PrependWebJSProcessor: ==========================================") if let data = (prefix + (contents ?? "" )).data(using: .utf8) { newFile = .data(data) } diff --git a/compiler/compiler/scripts/update_compiler.sh b/compiler/compiler/scripts/update_compiler.sh index 5b7ae7b1..3a7128eb 100755 --- a/compiler/compiler/scripts/update_compiler.sh +++ b/compiler/compiler/scripts/update_compiler.sh @@ -60,6 +60,14 @@ if [ -z "$bin_output_path" ]; then usage fi +# Convert to absolute path before changing directories +# This ensures the path remains correct after we cd to $BASE_PATH +ORIGINAL_PWD=$(pwd) +if [[ "$bin_output_path" != /* ]]; then + # Relative path - make it absolute from the original working directory + bin_output_path="$ORIGINAL_PWD/$bin_output_path" +fi + # Main cd "$BASE_PATH" VARIANT="release" @@ -142,4 +150,11 @@ source src/composer/jenkins/jenkins_helpers.sh mkdir -p "$OUT_DIR" rm -f "$OUT_DIR/valdi_compiler" cp "$OUTPUT_FILE_PATH" "$OUT_DIR/valdi_compiler" -) + +# Verify the copy succeeded +if [ ! -f "$OUT_DIR/valdi_compiler" ]; then + echo "Error: Failed to copy compiler binary to $OUT_DIR/valdi_compiler" >&2 + exit 1 +fi + +echo "All done." diff --git a/npm_modules/cli/src/commands/projectsync.ts b/npm_modules/cli/src/commands/projectsync.ts index 0319ab7e..34d4ef56 100644 --- a/npm_modules/cli/src/commands/projectsync.ts +++ b/npm_modules/cli/src/commands/projectsync.ts @@ -192,14 +192,44 @@ async function collectTsConfigDirs( }; const tsConfigDirs = new Map(); + let consolidatedModulesPath: string | undefined; + // First pass: detect if consolidated setup exists by checking for modules/tsconfig.json + // Look for any target under a modules/ directory and check if modules/tsconfig.json exists + for (const projectSyncOutput of projectSyncOutputs) { + if (projectSyncOutput.target.repo) { + continue; + } + + const targetPath = bazelLabelToAbsolutePath(workspaceInfo, projectSyncOutput.target); + const modulesMatch = targetPath.match(/(.+[/\\]modules)[/\\]/); + if (modulesMatch) { + const potentialModulesPath = modulesMatch[1]!; + const consolidatedTsConfigPath = path.join(potentialModulesPath, 'tsconfig.json'); + if (fsSync.existsSync(consolidatedTsConfigPath)) { + consolidatedModulesPath = potentialModulesPath; + break; + } + } + } + + // Second pass: group modules into consolidated dir if detected for (const projectSyncOutput of projectSyncOutputs) { if (projectSyncOutput.target.repo) { // Ignore external repo deps continue; } - const tsConfigDirPath = bazelLabelToAbsolutePath(workspaceInfo, projectSyncOutput.target); + const targetPath = bazelLabelToAbsolutePath(workspaceInfo, projectSyncOutput.target); + + // If using consolidated setup and target is under modules/, use consolidated dir + let tsConfigDirPath: string; + if (consolidatedModulesPath && targetPath.startsWith(consolidatedModulesPath + path.sep)) { + tsConfigDirPath = consolidatedModulesPath; + } else { + tsConfigDirPath = targetPath; + } + let tsConfigDir = tsConfigDirs.get(tsConfigDirPath); if (!tsConfigDir) { tsConfigDir = { dir: tsConfigDirPath, matchedTargets: [] }; @@ -239,9 +269,19 @@ function computeTsCompilerOptions( compilerOptions = {}; } + // Preserve jsx and lib if they exist, or set defaults + if (!compilerOptions.jsx) { + compilerOptions.jsx = 'preserve'; + } + if (!compilerOptions.lib) { + compilerOptions.lib = ['dom', 'ES2019']; + } + compilerOptions.paths = {}; const rootDirs: string[] = []; let valdiCoreTarget: TargetDescription | undefined; + const seenDependencies = new Set(); + for (const matchedTarget of matchedTargets) { const targetRootDirs = matchedTarget.target.paths.map(p => relativePathTo(tsConfigDir, path.dirname(p))); @@ -254,13 +294,36 @@ function computeTsCompilerOptions( const selfName = matchedTarget.target.label.name ?? ''; const selfInclude = `${selfName}/*`; - const selfImportPaths = matchedTarget.target.paths.map(p => `${relativePathTo(tsConfigDir, p)}/*`); + // For consolidated setup, paths should be relative to modules/ directory + // For individual module setup, paths are relative to module directory + const selfImportPaths: string[] = []; + for (const targetPath of matchedTarget.target.paths) { + const relativePath = relativePathTo(tsConfigDir, targetPath); + selfImportPaths.push(`${relativePath}/*`); + + // Add projectsync-generated paths if they exist + const targetDir = path.dirname(targetPath); + const projectsyncGeneratedDir = path.join(targetDir, '.valdi_build/projectsync/generated_ts', selfName); + if (fsSync.existsSync(projectsyncGeneratedDir)) { + const relativeProjectsyncPath = relativePathTo(tsConfigDir, projectsyncGeneratedDir); + if (!selfImportPaths.includes(`${relativeProjectsyncPath}/*`)) { + selfImportPaths.push(`${relativeProjectsyncPath}/*`); + } + } + } compilerOptions.paths[selfInclude] = selfImportPaths; for (const dependency of matchedTarget.dependencies) { - if (!dependency.label.name || compilerOptions.paths[dependency.label.name]) { - // Already present + if (!dependency.label.name) { + continue; + } + + const dependencyKey = `${dependency.label.name}/*`; + + // For consolidated setup, merge paths from all modules + // For individual setup, skip if already present + if (seenDependencies.has(dependencyKey) && !compilerOptions.paths[dependencyKey]) { continue; } @@ -268,10 +331,38 @@ function computeTsCompilerOptions( valdiCoreTarget = dependency; } - const importPaths = dependency.paths.map(p => `${relativePathTo(tsConfigDir, p)}/*`); + const importPaths: string[] = []; + for (const depPath of dependency.paths) { + const relativePath = relativePathTo(tsConfigDir, depPath); + importPaths.push(`${relativePath}/*`); + + // Add projectsync-generated paths for external dependencies if they exist + const depDir = path.dirname(depPath); + const depName = dependency.label.name; + if (depName) { + const projectsyncGeneratedDir = path.join(depDir, '.valdi_build/projectsync/generated_ts', depName); + if (fsSync.existsSync(projectsyncGeneratedDir)) { + const relativeProjectsyncPath = relativePathTo(tsConfigDir, projectsyncGeneratedDir); + if (!importPaths.includes(`${relativeProjectsyncPath}/*`)) { + importPaths.push(`${relativeProjectsyncPath}/*`); + } + } + } + } - const key = `${dependency.label.name}/*`; - compilerOptions.paths[key] = importPaths; + if (compilerOptions.paths[dependencyKey]) { + // Merge with existing paths + const existing = compilerOptions.paths[dependencyKey]; + if (Array.isArray(existing)) { + compilerOptions.paths[dependencyKey] = [...new Set([...existing, ...importPaths])]; + } else { + compilerOptions.paths[dependencyKey] = importPaths; + } + } else { + compilerOptions.paths[dependencyKey] = importPaths; + } + + seenDependencies.add(dependencyKey); } } @@ -284,6 +375,10 @@ function computeTsCompilerOptions( compilerOptions.types = baseTsFiles.map(p => relativePathTo(tsConfigDir, removeTsFileExtension(p))); + // Ensure rootDirs includes current directory for consolidated setup + if (!rootDirs.includes('.')) { + rootDirs.unshift('.'); + } compilerOptions.rootDirs = rootDirs; return compilerOptions; diff --git a/src/valdi_modules/src/valdi/coreutils/web/tsconfig.json b/src/valdi_modules/src/valdi/coreutils/web/tsconfig.json index 7867fa77..13d41048 100644 --- a/src/valdi_modules/src/valdi/coreutils/web/tsconfig.json +++ b/src/valdi_modules/src/valdi/coreutils/web/tsconfig.json @@ -7,4 +7,8 @@ "composite": true, "allowJs": true }, + "exclude": [ + "debug/**", + "release/**" + ] } \ No newline at end of file diff --git a/src/valdi_modules/src/valdi/drawing/web/tsconfig.json b/src/valdi_modules/src/valdi/drawing/web/tsconfig.json index 8dbd2898..13d41048 100644 --- a/src/valdi_modules/src/valdi/drawing/web/tsconfig.json +++ b/src/valdi_modules/src/valdi/drawing/web/tsconfig.json @@ -5,6 +5,10 @@ "strict": true, "skipLibCheck": true, "composite": true, - "allowJs": true, + "allowJs": true }, + "exclude": [ + "debug/**", + "release/**" + ] } \ No newline at end of file diff --git a/src/valdi_modules/src/valdi/file_system/web/tsconfig.json b/src/valdi_modules/src/valdi/file_system/web/tsconfig.json index 7867fa77..13d41048 100644 --- a/src/valdi_modules/src/valdi/file_system/web/tsconfig.json +++ b/src/valdi_modules/src/valdi/file_system/web/tsconfig.json @@ -7,4 +7,8 @@ "composite": true, "allowJs": true }, + "exclude": [ + "debug/**", + "release/**" + ] } \ No newline at end of file diff --git a/src/valdi_modules/src/valdi/persistence/web/tsconfig.json b/src/valdi_modules/src/valdi/persistence/web/tsconfig.json index 7867fa77..13d41048 100644 --- a/src/valdi_modules/src/valdi/persistence/web/tsconfig.json +++ b/src/valdi_modules/src/valdi/persistence/web/tsconfig.json @@ -7,4 +7,8 @@ "composite": true, "allowJs": true }, + "exclude": [ + "debug/**", + "release/**" + ] } \ No newline at end of file diff --git a/src/valdi_modules/src/valdi/valdi_core/src/Device.ts b/src/valdi_modules/src/valdi/valdi_core/src/Device.ts index 208174f2..32c35ba5 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/Device.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/Device.ts @@ -21,6 +21,7 @@ import { getWindowWidth, getTimeZoneDstSecondsFromGMT as nativeGetTimeZoneDstSecondsFromGMT, isDesktop, + isWeb, observeDarkMode as nativeObserveDarkMode, observeDisplaySizeChange as nativeObserveDisplaySizeChange, observeDisplayInsetChange as nativeObserveDisplayInsetChange, @@ -65,6 +66,7 @@ const cacheDeviceLocales = new DeviceCache(getDeviceLocales ?? (() => const cacheLocaleUsesMetricSystem = new DeviceCache(getLocaleUsesMetricSystem ?? (() => false)); const cacheTimeZoneName = new DeviceCache(getTimeZoneName ?? (() => 'unknown')); const cacheIsDesktop = new DeviceCache(isDesktop ?? (() => false)); +const cacheIsWeb = new DeviceCache(isWeb ?? (() => false)); /** * Dark mode last cached value @@ -134,6 +136,13 @@ export namespace Device { return cacheIsDesktop.get(); } + /** + * Check whether the Device is running on the Web platform. + */ + export function isWeb(): boolean { + return cacheIsWeb.get(); + } + /** * Get whether the system is Android or iOS */ diff --git a/src/valdi_modules/src/valdi/valdi_core/src/DeviceBridge.d.ts b/src/valdi_modules/src/valdi/valdi_core/src/DeviceBridge.d.ts index c236f74e..9c5cf0f0 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/DeviceBridge.d.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/DeviceBridge.d.ts @@ -37,4 +37,5 @@ export function observeDisplayInsetChange(observe: () => void): DeviceCancelable export function observeDisplaySizeChange(observe: () => void): DeviceCancelable; export function observeDarkMode(observe: (isDarkMode: boolean) => void): DeviceCancelable; export function isDesktop(): boolean; +export function isWeb(): boolean; export const setBackButtonObserver: ((observer: (() => boolean) | undefined) => void) | undefined; diff --git a/src/valdi_modules/src/valdi/valdi_core/src/ModuleLoader.ts b/src/valdi_modules/src/valdi/valdi_core/src/ModuleLoader.ts index 13c20637..133ff48d 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/ModuleLoader.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/ModuleLoader.ts @@ -66,7 +66,9 @@ function resolveAbsoluteImport(normalizedPathEntries: string[]): ResolvedPath { } function resolveAbsoluteImportFromPath(path: string): ResolvedPath { - return resolveAbsoluteImport(normalizePath(path.split('/'))); + const normalized = normalizePath(path.split('/')); + const result = resolveAbsoluteImport(normalized); + return result; } function resolvePath(path: string, fromResolvedPath: ResolvedPath): ResolvedPath { @@ -77,7 +79,8 @@ function resolvePath(path: string, fromResolvedPath: ResolvedPath): ResolvedPath const combinedPath = fromResolvedPath.directoryPaths.slice(); combinedPath.push(...importPathEntries); const normalized = normalizePath(combinedPath); - return resolveAbsoluteImport(normalized); + const result = resolveAbsoluteImport(normalized); + return result; } else { // Absolute import const normalized = normalizePath(importPathEntries); @@ -373,7 +376,8 @@ export class ModuleLoader implements IModuleLoader { module = this.modules[resolvedPath.absolutePath]!; } - return this.makeRequire(module); + const requireFunc = this.makeRequire(module); + return requireFunc; } getOrCreateSourceMap = (path: string, sourceMapFactory: SourceMapFactory): ISourceMap | undefined => { diff --git a/src/valdi_modules/src/valdi/valdi_core/web/DeviceBridge.ts b/src/valdi_modules/src/valdi/valdi_core/web/DeviceBridge.ts index 1aa1ec4f..f24d1406 100644 --- a/src/valdi_modules/src/valdi/valdi_core/web/DeviceBridge.ts +++ b/src/valdi_modules/src/valdi/valdi_core/web/DeviceBridge.ts @@ -259,6 +259,10 @@ export function isDesktop(): boolean { return !(isIOS || isAndroid); } +export function isWeb(): boolean { + return isBrowser; +} + // On web there is no native back button; some apps emulate via history. // Keep undefined to honor your API shape. export const setBackButtonObserver: ((observer: (() => boolean) | undefined) => void) | undefined = undefined; \ No newline at end of file diff --git a/src/valdi_modules/src/valdi/valdi_core/web/tsconfig.json b/src/valdi_modules/src/valdi/valdi_core/web/tsconfig.json index 7867fa77..bc4d8782 100644 --- a/src/valdi_modules/src/valdi/valdi_core/web/tsconfig.json +++ b/src/valdi_modules/src/valdi/valdi_core/web/tsconfig.json @@ -6,5 +6,5 @@ "skipLibCheck": true, "composite": true, "allowJs": true - }, + } } \ No newline at end of file diff --git a/src/valdi_modules/src/valdi/valdi_http/web/tsconfig.json b/src/valdi_modules/src/valdi/valdi_http/web/tsconfig.json index 2257b14f..057e4020 100644 --- a/src/valdi_modules/src/valdi/valdi_http/web/tsconfig.json +++ b/src/valdi_modules/src/valdi/valdi_http/web/tsconfig.json @@ -7,4 +7,8 @@ "composite": true, "allowJs": true }, + "exclude": [ + "debug/**", + "release/**" + ] } \ No newline at end of file diff --git a/src/valdi_modules/src/valdi/valdi_protobuf/web/tsconfig.json b/src/valdi_modules/src/valdi/valdi_protobuf/web/tsconfig.json index dfebad86..1ff6e629 100644 --- a/src/valdi_modules/src/valdi/valdi_protobuf/web/tsconfig.json +++ b/src/valdi_modules/src/valdi/valdi_protobuf/web/tsconfig.json @@ -20,5 +20,9 @@ "include": [ "**/*", "../src/headless/descriptor.ts" // <-- add (path is relative to this tsconfig) + ], + "exclude": [ + "debug/**", + "release/**" ] } \ No newline at end of file diff --git a/src/valdi_modules/src/valdi/valdi_tsx/web/tsconfig.json b/src/valdi_modules/src/valdi/valdi_tsx/web/tsconfig.json index 7867fa77..13d41048 100644 --- a/src/valdi_modules/src/valdi/valdi_tsx/web/tsconfig.json +++ b/src/valdi_modules/src/valdi/valdi_tsx/web/tsconfig.json @@ -7,4 +7,8 @@ "composite": true, "allowJs": true }, + "exclude": [ + "debug/**", + "release/**" + ] } \ No newline at end of file diff --git a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts index cd8f754f..bbd0b356 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts @@ -3,6 +3,7 @@ import { FrameObserver, IRendererDelegate, VisibilityObserver } from 'valdi_core import { Style } from 'valdi_core/src/Style'; import { NativeNode } from 'valdi_tsx/src/NativeNode'; import { NativeView } from 'valdi_tsx/src/NativeView'; +import { CancelToken } from 'valdi_core/src/CancellableAnimation'; import { changeAttributeOnElement, createElement, @@ -12,6 +13,7 @@ import { registerElements, setAllElementsAttributeDelegate, } from './HTMLRenderer'; +import { WebAnimationManager } from './WebAnimationManager'; export interface UpdateAttributeDelegate { updateAttribute(elementId: number, attributeName: string, attributeValue: any): void; @@ -19,9 +21,13 @@ export interface UpdateAttributeDelegate { export class ValdiWebRendererDelegate implements IRendererDelegate { private attributeDelegate?: UpdateAttributeDelegate; + private animationManager: WebAnimationManager; constructor(private htmlRoot: HTMLElement) { registerElements(); + this.animationManager = new WebAnimationManager(); + // Make animation manager globally accessible for elements + (window as any).__valdiAnimationManager = this.animationManager; } setAttributeDelegate(delegate: UpdateAttributeDelegate) { this.attributeDelegate = delegate; @@ -61,8 +67,67 @@ export class ValdiWebRendererDelegate implements IRendererDelegate { } onElementAttributeChangeStyle(id: number, attributeName: string, style: Style): void { const attributes = style.attributes ?? {}; + + // Process left/right together to avoid conflicts + // Note: On web, we swap left/right values for compatibility + const hasLeft = 'left' in attributes; + const hasRight = 'right' in attributes; + const leftValue = attributes.left; + const rightValue = attributes.right; + + if (hasLeft && hasRight) { + // Both are present - process removals first, then set the new value + // Swap: original left becomes web right, original right becomes web left + const leftIsDefined = leftValue !== undefined && leftValue !== null && leftValue !== ''; + const rightIsDefined = rightValue !== undefined && rightValue !== null && rightValue !== ''; + + if (rightIsDefined && !leftIsDefined) { + // Original right becomes web left - remove web right first, then set web left + changeAttributeOnElement(id, 'right', undefined); + changeAttributeOnElement(id, 'left', rightValue); + } else if (leftIsDefined && !rightIsDefined) { + // Original left becomes web right - remove web left first, then set web right + changeAttributeOnElement(id, 'left', undefined); + changeAttributeOnElement(id, 'right', leftValue); + } else if (leftIsDefined && rightIsDefined) { + // Both have values - this shouldn't happen, but prioritize original left (web right) + changeAttributeOnElement(id, 'left', undefined); + changeAttributeOnElement(id, 'right', leftValue); + } else { + // Both are undefined/null - remove both + changeAttributeOnElement(id, 'left', undefined); + changeAttributeOnElement(id, 'right', undefined); + } + } else if (hasLeft) { + // Only left is present - original left becomes web right + const leftIsDefined = leftValue !== undefined && leftValue !== null && leftValue !== ''; + if (leftIsDefined) { + // When setting original left (web right), also clear web left + changeAttributeOnElement(id, 'left', undefined); + changeAttributeOnElement(id, 'right', leftValue); + } else { + changeAttributeOnElement(id, 'right', undefined); + } + } else if (hasRight) { + // Only right is present - original right becomes web left + const rightIsDefined = rightValue !== undefined && rightValue !== null && rightValue !== ''; + if (rightIsDefined) { + // When setting original right (web left), also clear web right + changeAttributeOnElement(id, 'right', undefined); + changeAttributeOnElement(id, 'left', rightValue); + } else { + changeAttributeOnElement(id, 'left', undefined); + } + } + + // Process all other attributes Object.keys(attributes).forEach(key => { - changeAttributeOnElement(id, key, attributes[key]); + if (key === 'left' || key === 'right') { + // Already handled above + return; + } + const value = attributes[key]; + changeAttributeOnElement(id, key, value); }); } onElementAttributeChangeFunction(id: number, attributeName: string, fn: () => void): void { @@ -77,12 +142,37 @@ export class ValdiWebRendererDelegate implements IRendererDelegate { // TODO(mgharmalkar) // console.log('onRenderEnd'); } - onAnimationStart(options: AnimationOptions, token: number): void { - // TODO: no animation support on web yet, so just call completion with cancelled = false. - options.completion?.(false); + private tokenMap: Map = new Map(); + + onAnimationStart(options: AnimationOptions, token: CancelToken): void { + const animationToken = this.animationManager.startAnimation(options); + this.tokenMap.set(token, animationToken); + } + + onAnimationEnd(): void { + // End the most recently started animation + // In practice, animations are nested, so we end the last one + if (this.tokenMap.size > 0) { + const tokens = Array.from(this.tokenMap.values()); + const lastToken = tokens[tokens.length - 1]; + this.animationManager.endAnimation(lastToken); + // Remove from map + for (const [rendererToken, animationToken] of this.tokenMap.entries()) { + if (animationToken === lastToken) { + this.tokenMap.delete(rendererToken); + break; + } + } + } + } + + onAnimationCancel(token: CancelToken): void { + const animationToken = this.tokenMap.get(token); + if (animationToken !== undefined) { + this.animationManager.cancelAnimation(animationToken); + this.tokenMap.delete(token); + } } - onAnimationEnd(): void {} - onAnimationCancel(token: number): void {} registerVisibilityObserver(observer: VisibilityObserver): void { // TODO(mgharmalkar) // console.log('registerVisibilityObserver'); diff --git a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRuntime.js b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRuntime.js index 33dbc2b9..942d2581 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRuntime.js +++ b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRuntime.js @@ -16,6 +16,12 @@ function loadPath(path) { } class Runtime { + constructor() { + // Map of task IDs to timeout IDs for scheduleWorkItem + this._taskIdCounter = 1; + this._scheduledTasks = new Map(); + } + // This is essentially the require() function that the runtime is using. // relativePath is not the contents of require, it is preprocessed by the runtime. loadJsModule(relativePath, requireFunc, module, exports) { @@ -306,10 +312,32 @@ class Runtime { } scheduleWorkItem(cb, delayMs, interruptible) { - return 0; + const taskId = this._taskIdCounter++; + const delay = delayMs || 0; + + // Use regular browser setTimeout + const timeoutId = window.setTimeout(() => { + this._scheduledTasks.delete(taskId); + try { + cb(); + } catch (err) { + this.onUncaughtError('scheduleWorkItem', err); + } + }, delay); + + // Store the timeout ID so we can cancel it + this._scheduledTasks.set(taskId, timeoutId); + + return taskId; } - unscheduleWorkItem(taskId) {} + unscheduleWorkItem(taskId) { + const timeoutId = this._scheduledTasks.get(taskId); + if (timeoutId !== undefined) { + globalThis.__originalTimingFunctions__.clearTimeout(timeoutId); + this._scheduledTasks.delete(taskId); + } + } getCurrentContext() { return ""; @@ -375,6 +403,16 @@ globalThis.__originalConsole__ = { assert: console.assert.bind(console), }; +// Capture native browser setTimeout/clearTimeout before Valdi replaces them (like we do for console) +Object.freeze(globalThis.__originalTimingFunctions__); + +globalThis.__originalTimingFunctions__ = { + setTimeout: window.setTimeout, + clearTimeout: window.clearTimeout, + setInterval: window.setInterval, + clearInterval: window.clearInterval, +}; + // Run the init function // Relies on runtime being set so it must happen after // Assumes relative to the monolithic npm @@ -382,3 +420,9 @@ require("../../valdi_core/src/Init.js"); // Restore console globalThis.console = globalThis.__originalConsole__; +globalThis.setTimeout = globalThis.__originalTimingFunctions__.setTimeout; +globalThis.clearTimeout = globalThis.__originalTimingFunctions__.clearTimeout; +globalThis.setInterval = globalThis.__originalTimingFunctions__.setInterval; +globalThis.clearInterval = globalThis.__originalTimingFunctions__.clearInterval; + +console.log("end loading everything"); diff --git a/src/valdi_modules/src/valdi/web_renderer/src/WebAnimationManager.ts b/src/valdi_modules/src/valdi/web_renderer/src/WebAnimationManager.ts new file mode 100644 index 00000000..dcb0022f --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/WebAnimationManager.ts @@ -0,0 +1,225 @@ +import { AnimationOptions, AnimationCurve, SpringAnimationOptions, PresetCurveAnimationOptions, CustomCurveAnimationOptions } from 'valdi_core/src/AnimationOptions'; +import { CancelToken } from 'valdi_core/src/CancellableAnimation'; + +/** + * Manages animations for web elements by tracking active animations + * and applying CSS transitions/animations to style changes. + */ +export class WebAnimationManager { + private activeAnimations: Map = new Map(); + private animatedElements: Map> = new Map(); // elementId -> set of animation tokens + private currentAnimationToken: CancelToken | null = null; // Currently active animation during a block + private nextToken: CancelToken = 1; + + /** + * Start an animation context + */ + startAnimation(options: AnimationOptions): CancelToken { + const token = this.nextToken++; + const context: AnimationContext = { + options, + token, + animatedProperties: new Set(), + }; + this.activeAnimations.set(token, context); + this.currentAnimationToken = token; // Set as current active animation + return token; + } + + /** + * End an animation context + */ + endAnimation(token: CancelToken): void { + const context = this.activeAnimations.get(token); + if (context) { + // Clean up element tracking + this.animatedElements.forEach((tokens, elementId) => { + tokens.delete(token); + if (tokens.size === 0) { + this.animatedElements.delete(elementId); + } + }); + this.activeAnimations.delete(token); + + if (this.currentAnimationToken === token) { + this.currentAnimationToken = null; + } + + // Call completion callback + if (context.options.completion) { + context.options.completion(false); + } + } + } + + /** + * Cancel an animation + */ + cancelAnimation(token: CancelToken): void { + const context = this.activeAnimations.get(token); + if (context) { + // Clean up element tracking + this.animatedElements.forEach((tokens, elementId) => { + tokens.delete(token); + if (tokens.size === 0) { + this.animatedElements.delete(elementId); + } + }); + this.activeAnimations.delete(token); + + // Call completion callback with cancelled = true + if (context.options.completion) { + context.options.completion(true); + } + } + } + + /** + * Check if an element is currently being animated + */ + isElementAnimated(elementId: number): boolean { + return this.animatedElements.has(elementId); + } + + /** + * Get animation context for an element (if any) + * During an animation block, returns the current animation context + */ + getAnimationContext(elementId: number): AnimationContext | undefined { + // If we're in an active animation block, return that context + if (this.currentAnimationToken !== null) { + const context = this.activeAnimations.get(this.currentAnimationToken); + if (context) { + return context; + } + } + + // Otherwise check if element was previously marked as animated + const tokens = this.animatedElements.get(elementId); + if (tokens && tokens.size > 0) { + // Return the first active animation context + const token = Array.from(tokens)[0]; + return this.activeAnimations.get(token); + } + return undefined; + } + + /** + * Mark an element as being animated + */ + markElementAnimated(elementId: number, token: CancelToken): void { + if (!this.animatedElements.has(elementId)) { + this.animatedElements.set(elementId, new Set()); + } + this.animatedElements.get(elementId)!.add(token); + } + + /** + * Convert AnimationOptions to CSS transition string + */ + getCSSTransition(property: string, options: AnimationOptions): string { + if ('stiffness' in options) { + // Spring animation - use a JavaScript-based approach or approximate with CSS + // For now, approximate spring with a CSS cubic-bezier + const duration = this.estimateSpringDuration(options as SpringAnimationOptions); + const timingFunction = this.springToCubicBezier(options as SpringAnimationOptions); + return `${property} ${duration}s ${timingFunction}`; + } else { + // Regular animation + const duration = options.duration; + const timingFunction = this.getCSSTimingFunction(options); + return `${property} ${duration}s ${timingFunction}`; + } + } + + /** + * Get CSS transition for all properties + */ + getAllPropertiesTransition(options: AnimationOptions): string { + if ('stiffness' in options) { + const duration = this.estimateSpringDuration(options as SpringAnimationOptions); + const timingFunction = this.springToCubicBezier(options as SpringAnimationOptions); + return `all ${duration}s ${timingFunction}`; + } else { + const duration = options.duration; + const timingFunction = this.getCSSTimingFunction(options); + return `all ${duration}s ${timingFunction}`; + } + } + + /** + * Convert AnimationCurve to CSS timing function + */ + private getCSSTimingFunction(options: PresetCurveAnimationOptions | CustomCurveAnimationOptions): string { + if ('controlPoints' in options && options.controlPoints && options.controlPoints.length === 4) { + // Custom cubic-bezier + const [x1, y1, x2, y2] = options.controlPoints; + return `cubic-bezier(${x1}, ${y1}, ${x2}, ${y2})`; + } else if ('curve' in options) { + // Preset curve + switch (options.curve ?? AnimationCurve.EaseInOut) { + case AnimationCurve.Linear: + return 'linear'; + case AnimationCurve.EaseIn: + return 'ease-in'; + case AnimationCurve.EaseOut: + return 'ease-out'; + case AnimationCurve.EaseInOut: + return 'ease-in-out'; + } + } + // Default to ease-in-out + return 'ease-in-out'; + } + + /** + * Convert spring animation to CSS cubic-bezier approximation + * This is an approximation - for true spring physics, we'd need JavaScript animation + */ + private springToCubicBezier(options: SpringAnimationOptions): string { + // Approximate spring with a bouncy cubic-bezier + // Higher stiffness = faster, higher damping = less bouncy + const stiffness = options.stiffness ?? 381.47; + const damping = options.damping ?? 20.1; + + // Normalize to reasonable ranges + const normalizedStiffness = Math.min(stiffness / 500, 1); + const normalizedDamping = Math.min(damping / 30, 1); + + // Create a bouncy curve based on spring parameters + // More damping = less bounce (closer to ease-out) + // More stiffness = faster initial acceleration + const bounce = 1 - normalizedDamping; + const x1 = 0.25; + const y1 = 0.1 + bounce * 0.3; + const x2 = 0.25 + normalizedStiffness * 0.2; + const y2 = 1; + + return `cubic-bezier(${x1}, ${y1}, ${x2}, ${y2})`; + } + + /** + * Estimate duration for spring animation + * Spring animations don't have a fixed duration, but we need one for CSS + */ + private estimateSpringDuration(options: SpringAnimationOptions): number { + const stiffness = options.stiffness ?? 381.47; + const damping = options.damping ?? 20.1; + + // Estimate duration based on spring parameters + // Higher stiffness = shorter duration + // Higher damping = shorter duration (less oscillation) + const baseDuration = 0.5; // Base duration in seconds + const stiffnessFactor = Math.max(0.3, 1 - (stiffness / 1000)); + const dampingFactor = Math.max(0.5, 1 - (damping / 50)); + + return baseDuration * stiffnessFactor * dampingFactor; + } +} + +interface AnimationContext { + options: AnimationOptions; + token: CancelToken; + animatedProperties: Set; +} + diff --git a/src/valdi_modules/src/valdi/web_renderer/src/styles/ValdiWebStyles.ts b/src/valdi_modules/src/valdi/web_renderer/src/styles/ValdiWebStyles.ts index d0c2544c..f5217a2f 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/styles/ValdiWebStyles.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/styles/ValdiWebStyles.ts @@ -10,7 +10,15 @@ declare const global: { }; export function isAttributeValidStyle(attribute: string): boolean { - return attribute in VALID_STYLE_KEYS || attribute === "style"; + // Include flexbox properties that might not be in VALID_STYLE_KEYS + const flexboxProperties = ['justifyContent', 'alignItems', 'alignContent', 'alignSelf', 'flexDirection', 'flexWrap', 'flex', 'flexGrow', 'flexShrink', 'flexBasis']; + // Include zIndex, boxShadow, and borderRadius explicitly since they're critical for styling + const additionalProperties = ['zIndex', 'boxShadow', 'borderRadius']; + const isValid = attribute in VALID_STYLE_KEYS || attribute === "style" || flexboxProperties.includes(attribute) || additionalProperties.includes(attribute); + if (attribute === 'boxShadow' || attribute === 'borderRadius') { + console.log('[isAttributeValidStyle]', { attribute, isValid, inValidKeys: attribute in VALID_STYLE_KEYS, inAdditional: additionalProperties.includes(attribute) }); + } + return isValid; } export function isStyleKeyColor(attribute: string): boolean { @@ -55,12 +63,65 @@ export function generateStyles(attribute: string, value: any): Partial { + // If the part is a number, add 'px'; otherwise keep it as-is (already has units or is a keyword) + if (!Number.isNaN(Number(part)) && part !== '') { + return `${part}px`; + } + return part; + }); + // Only modify if we actually changed something + if (processedParts.some((part, i) => part !== parts[i])) { + return { borderRadius: processedParts.join(' ') }; + } + } + // Other string values (like 'inherit', 'initial', or strings with units) - pass through as-is + return { borderRadius: value }; + } + + // Handle zIndex explicitly to ensure it's properly applied + if (attribute === 'zIndex') { + // zIndex is a unitless number, but ensure we handle both number and string values + if (value === undefined || value === null) { + return { zIndex: '' }; + } + const zIndexValue = typeof value === 'number' ? value : parseInt(String(value), 10) || 0; + return { zIndex: String(zIndexValue) as any }; + } + + // Handle flexbox properties BEFORE the isAttributeValidStyle check + // JavaScript style object uses camelCase + if (attribute === 'justifyContent' || attribute === 'alignItems' || attribute === 'alignContent' || attribute === 'alignSelf' || + attribute === 'flexDirection' || attribute === 'flexWrap' || attribute === 'flex' || + attribute === 'flexGrow' || attribute === 'flexShrink' || attribute === 'flexBasis') { + // Keep camelCase for JavaScript style object assignment + return { [attribute]: value }; } if (!isAttributeValidStyle(attribute)) { + console.log('Unimplemented web style:', attribute, '=', value); return {}; } @@ -72,6 +133,11 @@ export function generateStyles(attribute: string, value: any): Partial 0, + }); + } + + // Get animation manager if available + const animationManager = (window as any).__valdiAnimationManager as WebAnimationManager | undefined; + const animationContext = animationManager?.getAnimationContext(this.id); + + // Apply all styles + Object.keys(generatedStyles).forEach(key => { + const value = generatedStyles[key as keyof CSSStyleDeclaration]; + if (attributeName === 'boxShadow' || attributeName === 'borderRadius') { + console.log('[WebValdiLayout] Applying style:', { key, value, elementId: this.id }); + } + if (value === '' || value === null || value === undefined) { + this.htmlElement.style.removeProperty(key); + } else { + (this.htmlElement.style as any)[key] = value; + if (attributeName === 'boxShadow' || attributeName === 'borderRadius') { + const computedStyle = window.getComputedStyle(this.htmlElement); + console.log('[WebValdiLayout] Style applied, element:', { + elementId: this.id, + styleKey: key, + styleValue: this.htmlElement.style[key as any], + computedValue: computedStyle[key as any], + width: computedStyle.width, + height: computedStyle.height, + element: this.htmlElement, + }); + } + } + }); + + // If justifyContent is set (either now or already on element), ensure flexbox is properly configured + // Check position after styles are applied (it might be set in this same update) + const hasJustifyContent = 'justifyContent' in generatedStyles || + (this.htmlElement.style.justifyContent !== '' && this.htmlElement.style.justifyContent !== 'normal'); + const isAbsolute = this.htmlElement.style.position === 'absolute' || generatedStyles.position === 'absolute'; + + if (hasJustifyContent && isAbsolute) { + // Ensure display: flex is set for flexbox to work + this.htmlElement.style.display = 'flex'; + // Ensure flexDirection is column for vertical centering (justifyContent centers along main axis) + // Only set if not already explicitly set + if (!generatedStyles.flexDirection) { + this.htmlElement.style.flexDirection = 'column'; + } + // With position: absolute, top: 0, bottom: 0, the element should stretch to parent height + // This is needed for justify-content to work properly + const hasTop = this.htmlElement.style.top !== '' && this.htmlElement.style.top !== 'auto'; + const hasBottom = this.htmlElement.style.bottom !== '' && this.htmlElement.style.bottom !== 'auto'; + if (hasTop && hasBottom) { + // Element should automatically have height from top/bottom + // Ensure no conflicting height is set + if (this.htmlElement.style.height && this.htmlElement.style.height !== 'auto' && this.htmlElement.style.height !== '100%') { + // If height is explicitly set to a fixed value, remove it to let top/bottom control the height + this.htmlElement.style.height = ''; + } + } + } + + // For child View elements inside a flex container with justifyContent, ensure they don't interfere + // Only apply to View elements (not Layout) that are relatively positioned with explicit dimensions + // This targets elements like thumbInner that should be simple block elements, not flex containers + if (this.type === 'view' && this.parent && + (!this.htmlElement.style.position || this.htmlElement.style.position === 'relative')) { + const hasExplicitWidth = this.htmlElement.style.width !== '' && this.htmlElement.style.width !== 'auto'; + const hasExplicitHeight = this.htmlElement.style.height !== '' && this.htmlElement.style.height !== 'auto'; + const parentComputedStyle = window.getComputedStyle(this.parent.htmlElement); + const parentHasJustifyContent = parentComputedStyle.justifyContent !== 'normal' && + parentComputedStyle.justifyContent !== ''; + + // Only override if: View element, explicit dimensions, parent has justifyContent, and not already block + if (hasExplicitWidth && hasExplicitHeight && parentHasJustifyContent && + this.htmlElement.style.display === 'flex') { + // Simple View element with explicit dimensions inside a flex container with justifyContent + // Use block display to prevent flex properties from interfering with centering + this.htmlElement.style.display = 'block'; + } + } + + // Apply animation transitions if element is being animated + if (animationContext && animationManager) { + const transition = animationManager.getAllPropertiesTransition(animationContext.options); + this.htmlElement.style.transition = transition; + animationManager.markElementAnimated(this.id, animationContext.token); + } + return; } @@ -281,6 +379,9 @@ export class WebValdiLayout { } return; case 'slowClipping': + // slowClipping enables proper clipping with border-radius by using overflow: hidden + this.htmlElement.style.overflow = attributeValue ? 'hidden' : 'visible'; + return; case 'touchEnabled': case 'hitTest': this.htmlElement.style.pointerEvents = attributeValue ? 'auto' : 'none'; @@ -556,10 +657,20 @@ export class WebValdiLayout { console.log('WebValdiLayout not implemented: ', attributeName, attributeValue); return; case 'width': - this.htmlElement.style.width = attributeValue; + // Ensure width values are converted to pixels if they're numbers + if (typeof attributeValue === 'number') { + this.htmlElement.style.width = `${attributeValue}px`; + } else { + this.htmlElement.style.width = attributeValue; + } return; case 'height': - this.htmlElement.style.height = attributeValue; + // Ensure height values are converted to pixels if they're numbers + if (typeof attributeValue === 'number') { + this.htmlElement.style.height = `${attributeValue}px`; + } else { + this.htmlElement.style.height = attributeValue; + } return; } diff --git a/src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiTextField.ts b/src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiTextField.ts index 3e72533f..53433058 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiTextField.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiTextField.ts @@ -15,6 +15,39 @@ class ValdiInput extends HTMLElement { this.input = document.createElement('input'); + // Style the inner input to remove default browser borders and outlines + Object.assign(this.input.style, { + width: '100%', + height: '100%', + border: 'none', + outline: 'none', + backgroundColor: 'transparent', + padding: 0, + margin: 0, + boxSizing: 'border-box', + MozAppearance: 'textfield', + WebkitAppearance: 'none', + }); + + // Add a style element to ensure no focus outlines + const style = document.createElement('style'); + style.textContent = ` + input { + border: none !important; + outline: none !important; + box-shadow: none !important; + } + input:focus { + outline: none !important; + box-shadow: none !important; + } + input:focus-visible { + outline: none !important; + box-shadow: none !important; + } + `; + shadow.appendChild(style); + const handleInput = this.debounce((event: Event, reason: string) => { const value = this.input.value; this.attributeDelegate?.updateAttribute(Number(this.input.getAttribute("id")), "value", value);