From 5a01d899cd7db075f662cb2d859744d59324e73f Mon Sep 17 00:00:00 2001 From: Simon Corsin Date: Thu, 23 Jul 2026 12:21:15 -0500 Subject: [PATCH 01/10] Add hierarchical color palettes and theme-aware assets --- .../src/valdi/valdi_core/src/Asset.ts | 16 + .../valdi/valdi_core/src/ValdiRuntime.d.ts | 6 +- .../valdi_tsx/src/NativeTemplateElements.d.ts | 5 + valdi/src/valdi/android/NativeBridge.cpp | 7 +- valdi/src/valdi/ios/SCValdiRuntime.mm | 2 +- .../runtime/Attributes/AssetAttributes.cpp | 13 +- .../runtime/Attributes/AttributeHandler.cpp | 1 + .../valdi/runtime/Attributes/AttributeIds.cpp | 1 + .../valdi/runtime/Attributes/AttributeIds.hpp | 1 + .../AttributesBindingContextImpl.cpp | 52 ++-- .../AttributesBindingContextImpl.hpp | 6 +- .../runtime/Attributes/AttributesManager.cpp | 22 +- .../runtime/Attributes/AttributesManager.hpp | 9 +- .../Attributes/DefaultAttributeProcessors.cpp | 256 +++++++++++++--- .../Attributes/DefaultAttributeProcessors.hpp | 18 +- .../runtime/Attributes/DefaultAttributes.cpp | 1 + .../runtime/Attributes/ValueConverters.cpp | 19 +- .../runtime/Attributes/ValueConverters.hpp | 4 +- .../runtime/Attributes/ViewNodeAttribute.cpp | 12 +- .../runtime/Attributes/ViewNodeAttribute.hpp | 3 + .../Attributes/ViewNodeAttributesApplier.cpp | 31 +- .../Attributes/ViewNodeAttributesApplier.hpp | 3 + .../runtime/Context/ViewManagerContext.cpp | 4 +- .../runtime/Context/ViewManagerContext.hpp | 4 +- valdi/src/valdi/runtime/Context/ViewNode.cpp | 116 +++++++- valdi/src/valdi/runtime/Context/ViewNode.hpp | 19 +- .../valdi/runtime/Context/ViewNodeTree.cpp | 5 + .../runtime/JavaScript/JavaScriptRuntime.cpp | 57 +++- .../runtime/JavaScript/JavaScriptRuntime.hpp | 8 +- .../AttributedTextNativeModuleFactory.cpp | 10 +- .../AttributedTextNativeModuleFactory.hpp | 8 +- .../runtime/Rendering/ViewNodeRenderer.cpp | 6 +- .../runtime/Resources/DirectionalAsset.cpp | 11 +- .../runtime/Resources/DirectionalAsset.hpp | 2 +- .../Resources/PlatformSpecificAsset.cpp | 34 ++- .../Resources/PlatformSpecificAsset.hpp | 2 +- .../valdi/runtime/Resources/ThemableAsset.cpp | 75 +++++ .../valdi/runtime/Resources/ThemableAsset.hpp | 53 ++++ valdi/src/valdi/runtime/Runtime.cpp | 29 +- valdi/src/valdi/runtime/Runtime.hpp | 10 +- valdi/src/valdi/runtime/RuntimeManager.cpp | 84 ++---- valdi/src/valdi/runtime/RuntimeManager.hpp | 8 +- valdi/test/benchmark/ViewNode_benchmark.cpp | 2 +- valdi/test/integration/Runtime_tests.cpp | 281 ++++++++++++++++-- valdi/test/runtime/AttributeParser_tests.cpp | 4 +- .../runtime/AttributeProcessors_tests.cpp | 229 +++++++------- .../runtime/ColorPaletteManager_tests.cpp | 89 ++++++ valdi/test/utils/ViewNodeTestsUtils.cpp | 13 +- .../test/src/ColorPaletteOverrideTest.tsx | 49 +++ .../modules/test/src/ColorPaletteTest.tsx | 41 ++- .../modules/test/src/ThemableAsset.tsx | 53 ++++ .../cpp/Attributes/AttributeUtils.cpp | 29 +- .../cpp/Attributes/AttributeUtils.hpp | 2 + .../cpp/Attributes/ColorPalette.cpp | 66 +++- .../cpp/Attributes/ColorPalette.hpp | 44 ++- .../src/valdi_core/cpp/Resources/Asset.cpp | 11 +- .../src/valdi_core/cpp/Resources/Asset.hpp | 25 +- 57 files changed, 1550 insertions(+), 421 deletions(-) create mode 100644 valdi/src/valdi/runtime/Resources/ThemableAsset.cpp create mode 100644 valdi/src/valdi/runtime/Resources/ThemableAsset.hpp create mode 100644 valdi/test/runtime/ColorPaletteManager_tests.cpp create mode 100644 valdi/testdata/resources/modules/test/src/ColorPaletteOverrideTest.tsx create mode 100644 valdi/testdata/resources/modules/test/src/ThemableAsset.tsx diff --git a/src/valdi_modules/src/valdi/valdi_core/src/Asset.ts b/src/valdi_modules/src/valdi/valdi_core/src/Asset.ts index 32e27005e..aa0b5564c 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/Asset.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/Asset.ts @@ -73,6 +73,10 @@ export type PlatformAssetOverrides = { android?: string | Asset; }; +export type ThemableAssetMap = { + [colorPaletteName: string]: string | Asset; +}; + /** * Make a platform specific Asset from a default asset `defaultAsset` and * iOS and/or Android assets in `platformAssetOverrides`. The iOS asset will be @@ -97,6 +101,18 @@ export function makePlatformSpecificAsset( return runtime.makePlatformSpecificAsset(defaultAsset, platformAssetOverrides); } +/** + * Make a themable Asset from an object keyed by color palette name. + * The asset matching the resolved color palette of the element will be + * rendered. If no asset matches the resolved color palette, nothing is rendered. + * @param assetsByColorPalette an object containing asset overrides keyed by color palette name + * @returns an Asset that can be used as a src attribute, and will use the + * asset matching the resolved color palette. + */ +export function makeThemableAsset(assetsByColorPalette: ThemableAssetMap): Asset { + return runtime.makeThemableAsset(assetsByColorPalette); +} + /** * Callback called whenever an asset has finished loading. */ diff --git a/src/valdi_modules/src/valdi/valdi_core/src/ValdiRuntime.d.ts b/src/valdi_modules/src/valdi/valdi_core/src/ValdiRuntime.d.ts index f9b26acfc..40acd0823 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/ValdiRuntime.d.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/ValdiRuntime.d.ts @@ -2,7 +2,7 @@ import { RuntimeBase } from 'coreutils/src/RuntimeBase'; import { ElementFrame } from 'valdi_tsx/src/Geometry'; import { NativeNode } from 'valdi_tsx/src/NativeNode'; import { NativeView } from 'valdi_tsx/src/NativeView'; -import { Asset, PlatformAssetOverrides } from './Asset'; +import { Asset, PlatformAssetOverrides, ThemableAssetMap } from './Asset'; import { ElementId } from './IRenderedElement'; import { IRootComponentsManager } from './IRootComponentsManager'; import { RenderRequest } from './RenderRequest'; @@ -163,6 +163,7 @@ export interface ValdiRuntime extends RuntimeBase { makeAssetFromBytes(bytes: ArrayBuffer | Uint8Array): Asset; makeDirectionalAsset(ltrAsset: string | Asset, rtlAsset: string | Asset): Asset; makePlatformSpecificAsset(defaultAsset: string | Asset, platformAssetOverrides: PlatformAssetOverrides): Asset; + makeThemableAsset(assetsByColorPalette: ThemableAssetMap): Asset; getAssets(catalogPath: string): AssetEntry[]; addAssetLoadObserver( asset: string | Asset, @@ -173,7 +174,8 @@ export interface ValdiRuntime extends RuntimeBase { ): () => void; getLoadedAssetMetadata(loadedAsset: LoadedAsset): LoadedAssetMetadata | undefined; - setColorPalette(colorPalette: ColorPalette): void; + configureColorPalette(name: string, colorPalette: ColorPalette): void; + setActiveColorPalette(name: string): void; outputLog(type: number, content: string): void; diff --git a/src/valdi_modules/src/valdi/valdi_tsx/src/NativeTemplateElements.d.ts b/src/valdi_modules/src/valdi/valdi_tsx/src/NativeTemplateElements.d.ts index 1a77455fe..7d4ae7da4 100644 --- a/src/valdi_modules/src/valdi/valdi_tsx/src/NativeTemplateElements.d.ts +++ b/src/valdi_modules/src/valdi/valdi_tsx/src/NativeTemplateElements.d.ts @@ -75,6 +75,11 @@ interface LayoutAttributes { */ lazyLayout?: boolean; + /** + * Overrides the configured color palette for this element and descendants. + */ + colorPaletteName?: string; + /** * @experimental This feature is experimental and may change in future releases. * diff --git a/valdi/src/valdi/android/NativeBridge.cpp b/valdi/src/valdi/android/NativeBridge.cpp index 784c3d8b0..1504f3a3f 100644 --- a/valdi/src/valdi/android/NativeBridge.cpp +++ b/valdi/src/valdi/android/NativeBridge.cpp @@ -1865,9 +1865,10 @@ jlong ValdiAndroid::NativeBridge::createViewFactory( // NOLINT auto attributes = viewManagerContext->getAttributesManager().getAttributesForClass(cppViewClassName); if (hasBindAttributes == JNI_TRUE) { - Valdi::AttributesBindingContextImpl bindingContext(viewManagerContext->getAttributesManager().getAttributeIds(), - viewManagerContext->getAttributesManager().getColorPalette(), - runtimeManagerWrapper->getRuntimeManager().getLogger()); + Valdi::AttributesBindingContextImpl bindingContext( + viewManagerContext->getAttributesManager().getAttributeIds(), + viewManagerContext->getAttributesManager().getColorPaletteManager(), + runtimeManagerWrapper->getRuntimeManager().getLogger()); auto wrapper = Valdi::makeShared(androidViewManager, bindingContext); auto ptr = reinterpret_cast(Valdi::unsafeBridgeCast(wrapper.get())); diff --git a/valdi/src/valdi/ios/SCValdiRuntime.mm b/valdi/src/valdi/ios/SCValdiRuntime.mm index cd7234394..4609fcd05 100644 --- a/valdi/src/valdi/ios/SCValdiRuntime.mm +++ b/valdi/src/valdi/ios/SCValdiRuntime.mm @@ -454,7 +454,7 @@ - (void)setPerformHapticFeedbackFunctionBlock:(void (^)(NSString *))block auto attributes = attributesManager.getAttributesForClass(viewClassName); if (attributesBinder) { - Valdi::AttributesBindingContextImpl bindingContext(attributesManager.getAttributeIds(), attributesManager.getColorPalette(), _runtime->getLogger()); + Valdi::AttributesBindingContextImpl bindingContext(attributesManager.getAttributeIds(), attributesManager.getColorPaletteManager(), _runtime->getLogger()); SCValdiAttributesBinder *wrapper = [[SCValdiAttributesBinder alloc] initWithNativeAttributesBindingContext:(SCValdiAttributesBinderNative *)&bindingContext fontManager:_fontManager]; diff --git a/valdi/src/valdi/runtime/Attributes/AssetAttributes.cpp b/valdi/src/valdi/runtime/Attributes/AssetAttributes.cpp index ef6ba28d1..d62915edc 100644 --- a/valdi/src/valdi/runtime/Attributes/AssetAttributes.cpp +++ b/valdi/src/valdi/runtime/Attributes/AssetAttributes.cpp @@ -46,7 +46,10 @@ class AssetMeasureDelegate : public DefaultMeasureDelegate { return Size(); } - asset = asset->withDirection(isRightToLeft); + asset = asset->withConfiguration(AssetConfiguration(nullptr, std::nullopt, isRightToLeft)); + if (asset == nullptr) { + return Size(); + } double maxWidth = widthMode == MeasureMode::MeasureModeUnspecified ? -1 : static_cast(width); double maxHeight = heightMode == MeasureMode::MeasureModeUnspecified ? -1 : static_cast(height); @@ -89,8 +92,8 @@ class AssetSrcAttributeHandlerDelegate : public AttributeHandlerDelegate { if (!result) { return result.moveError(); } - asset = result.value()->withPlatform(viewNode.getPlatformType()); - asset = asset->withDirection(viewNode.isRightToLeft()); + asset = result.value()->withConfiguration(AssetConfiguration( + viewNode.getResolvedColorPalette(), viewNode.getPlatformType(), viewNode.isRightToLeft())); } setAsset(viewTransactionScope, viewNode, view, asset, callback, associatedData, flipOnRtl); @@ -233,8 +236,12 @@ void AssetAttributes::bind(AttributeHandlerById& attributes, Ref(_assetOutputType), true); + auto& srcOnLoadHandler = attributes[_attributeIds.getIdForName(srcOnLoad)]; + srcOnLoadHandler.setShouldReevaluateOnColorPaletteChange(true); + auto& srcHandler = attributes[srcAttributeId]; srcHandler.appendPostprocessor(postprocessAsset); + srcHandler.setShouldReevaluateOnColorPaletteChange(true); if (_assetOutputType != snap::valdi_core::AssetOutputType::Lottie) { auto& filterHandler = attributes[filterAttributeId]; diff --git a/valdi/src/valdi/runtime/Attributes/AttributeHandler.cpp b/valdi/src/valdi/runtime/Attributes/AttributeHandler.cpp index 4ec24f264..06bf10704 100644 --- a/valdi/src/valdi/runtime/Attributes/AttributeHandler.cpp +++ b/valdi/src/valdi/runtime/Attributes/AttributeHandler.cpp @@ -221,6 +221,7 @@ AttributeHandler AttributeHandler::withDelegate(const Ref preprocessString(const Value& value) { return ValueConverter::toString(value).map(); } +static Result postprocessColor(ViewNode& viewNode, const Value& value) { + if (!value.isString()) { + return value; + } + + const auto& colorPalette = viewNode.getResolvedColorPalette(); + if (colorPalette == nullptr) { + return Error("ViewNode has no resolved ColorPalette"); + } + return ValueConverter::toColor(*colorPalette, value); +} + AttributesBindingContextImpl::AttributesBindingContextImpl(AttributeIds& attributeIds, - const Ref& colorPalette, + const Ref& colorPaletteManager, ILogger& logger) - : _attributeIds(attributeIds), _colorPalette(colorPalette), _logger(logger) {} + : _attributeIds(attributeIds), _colorPaletteManager(colorPaletteManager), _logger(logger) {} AttributesBindingContextImpl::~AttributesBindingContextImpl() = default; @@ -113,17 +125,19 @@ AttributeId AttributesBindingContextImpl::bindTextAttribute(const StringBox& att bool invalidateLayoutOnChange, const Ref& delegate) { auto& registeredHandler = registerHandler(attribute, invalidateLayoutOnChange, delegate); - registeredHandler.appendPreprocessor( - [colorPalette = _colorPalette, logger = &_logger](const Value& value) -> Result { - if (value.isString()) { - return value; - } - // strict parsing for non production build - auto strict = !snap::kIsAppstoreBuild; - return TextAttributeValueParser::parse(*colorPalette, value, *logger, strict); - }, - false); - registeredHandler.setEnablePreprocessorCache(true); + registeredHandler.appendPostprocessor([logger = &_logger](ViewNode& viewNode, const Value& value) -> Result { + if (value.isString()) { + return value; + } + // strict parsing for non production build + auto strict = !snap::kIsAppstoreBuild; + const auto& colorPalette = viewNode.getResolvedColorPalette(); + if (colorPalette == nullptr) { + return Error("ViewNode has no resolved ColorPalette"); + } + return TextAttributeValueParser::parse(*colorPalette, value, *logger, strict); + }); + registeredHandler.setShouldReevaluateOnColorPaletteChange(true); return registeredHandler.getId(); } @@ -261,16 +275,8 @@ const AttributeHandlerById& AttributesBindingContextImpl::getHandlers() const { } void AttributesBindingContextImpl::registerColorPreprocessor(AttributeHandler& handler) { - handler.appendPreprocessor( - [colorPalette = _colorPalette](const Value& value) -> Result { - auto color = ValueConverter::toColor(*colorPalette, value); - if (!color) { - return color.moveError(); - } - - return Value(color.value().value); - }, - false); + handler.appendPreprocessor(&ValueConverter::toColorValue, false); + handler.appendPostprocessor(&postprocessColor); handler.setShouldReevaluateOnColorPaletteChange(true); } diff --git a/valdi/src/valdi/runtime/Attributes/AttributesBindingContextImpl.hpp b/valdi/src/valdi/runtime/Attributes/AttributesBindingContextImpl.hpp index 4cb4c38c5..78a5d3474 100644 --- a/valdi/src/valdi/runtime/Attributes/AttributesBindingContextImpl.hpp +++ b/valdi/src/valdi/runtime/Attributes/AttributesBindingContextImpl.hpp @@ -21,7 +21,9 @@ class ILogger; class AttributesBindingContextImpl : public AttributesBindingContext { public: - AttributesBindingContextImpl(AttributeIds& attributeIds, const Ref& colorPalette, ILogger& logger); + AttributesBindingContextImpl(AttributeIds& attributeIds, + const Ref& colorPaletteManager, + ILogger& logger); ~AttributesBindingContextImpl() override; void registerPreprocessor(const Valdi::StringBox& attribute, @@ -83,7 +85,7 @@ class AttributesBindingContextImpl : public AttributesBindingContext { private: AttributeIds& _attributeIds; - Ref _colorPalette; + Ref _colorPaletteManager; ILogger& _logger; AttributeHandlerById _handlers; Ref _defaultDelegate; diff --git a/valdi/src/valdi/runtime/Attributes/AttributesManager.cpp b/valdi/src/valdi/runtime/Attributes/AttributesManager.cpp index cd0f57ee6..d972e7572 100644 --- a/valdi/src/valdi/runtime/Attributes/AttributesManager.cpp +++ b/valdi/src/valdi/runtime/Attributes/AttributesManager.cpp @@ -20,21 +20,22 @@ namespace Valdi { AttributesManager::AttributesManager(IViewManager& viewManager, AttributeIds& attributeIds, - const Ref& colorPalette, + const Ref& colorPaletteManager, ILogger& logger, std::shared_ptr yogaConfig) : _viewManager(viewManager), _attributeIds(attributeIds), _logger(logger), _yogaConfig(std::move(yogaConfig)), - _colorPalette(colorPalette) {} + _colorPaletteManager(colorPaletteManager) {} void AttributesManager::registerPreprocessor(AttributeId attributeId, - Result (*preprocessor)(const Ref&, const Value&)) { - registerPreprocessor(attributeId, - [colorPalette = _colorPalette, preprocessor](const Value& value) -> Result { - return preprocessor(colorPalette, value); - }); + Result (*preprocessor)(const Ref&, + const Value&)) { + registerPreprocessor( + attributeId, [colorPaletteManager = _colorPaletteManager, preprocessor](const Value& value) -> Result { + return preprocessor(colorPaletteManager, value); + }); } void AttributesManager::registerPreprocessor(AttributeId attributeId, @@ -88,7 +89,6 @@ SharedBoundAttributes AttributesManager::lockFreeGetAttributesForClass(const Str scrollAttributesBound); _boundAttributesByClass[className] = boundAttributes; - return boundAttributes; } @@ -129,7 +129,7 @@ void AttributesManager::populateAttributeHandlerById(AttributeHandlerById& attri } if (!isLayoutClass) { - AttributesBindingContextImpl binder(_attributeIds, _colorPalette, _logger); + AttributesBindingContextImpl binder(_attributeIds, _colorPaletteManager, _logger); _viewManager.bindAttributes(className, binder); if (binder.getDefaultDelegate() != nullptr) { @@ -196,8 +196,8 @@ AttributeIds& AttributesManager::getAttributeIds() const { return _attributeIds; } -const Ref& AttributesManager::getColorPalette() const { - return _colorPalette; +const Ref& AttributesManager::getColorPaletteManager() const { + return _colorPaletteManager; } const StringBox& AttributesManager::getLayoutPlaceholderClassName() { diff --git a/valdi/src/valdi/runtime/Attributes/AttributesManager.hpp b/valdi/src/valdi/runtime/Attributes/AttributesManager.hpp index 8b61f6d3d..d1b925854 100644 --- a/valdi/src/valdi/runtime/Attributes/AttributesManager.hpp +++ b/valdi/src/valdi/runtime/Attributes/AttributesManager.hpp @@ -30,25 +30,24 @@ class AttributesManager { public: AttributesManager(IViewManager& viewManager, AttributeIds& attributeIds, - const Ref& colorPalette, + const Ref& colorPaletteManager, ILogger& logger, std::shared_ptr yogaConfig); void registerPreprocessor(AttributeId attributeId, const AttributePreprocessor& preprocessor); void registerPreprocessor(AttributeId attributeId, - Result (*preprocessor)(const Ref&, const Value&)); + Result (*preprocessor)(const Ref&, const Value&)); void registerPostprocessor(AttributeId attributeId, Result (*postprocessor)(ViewNode& viewNode, const Value& value)); SharedBoundAttributes getAttributesForClass(const StringBox& className) noexcept; FlatMap getAllBoundAttributes() const; - IViewManager& getViewManager() const; ILogger& getLogger() const; YGConfig* getYogaConfig() const; AttributeIds& getAttributeIds() const; - const Ref& getColorPalette() const; + const Ref& getColorPaletteManager() const; static const StringBox& getLayoutPlaceholderClassName(); static const StringBox& getDeferredClassName(); @@ -61,7 +60,7 @@ class AttributesManager { FlatMap _boundAttributesByClass; FlatMap> _preprocessors; FlatMap> _postprocessors; - Ref _colorPalette; + Ref _colorPaletteManager; mutable std::mutex _mutex; SharedBoundAttributes lockFreeGetAttributesForClass(const StringBox& className) noexcept; diff --git a/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.cpp b/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.cpp index ff1827e69..ec22f05e6 100644 --- a/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.cpp +++ b/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.cpp @@ -15,6 +15,7 @@ #include "valdi/runtime/Context/ViewNode.hpp" #include +#include namespace Valdi { @@ -23,7 +24,7 @@ static Error parseError(AttributeParser& parser, std::string_view name) { return parser.getError(); } -Result preprocessBorder(const Ref& colorPalette, const Value& in) { +Result preprocessBorder(const Value& in) { auto stringBox = in.toStringBox(); AttributeParser parser(stringBox.toStringView()); @@ -44,7 +45,7 @@ Result preprocessBorder(const Ref& colorPalette, const Valu return parseError(parser, "border style"); } - auto color = parser.parseColor(*colorPalette); + auto color = parser.parseColorValue(); if (!color) { return parseError(parser, "border color"); } @@ -55,7 +56,7 @@ Result preprocessBorder(const Ref& colorPalette, const Valu return parser.getError(); } - border = ValueArray::make({Value(borderWidth.value().value), Value(color.value().value)}); + border = ValueArray::make({Value(borderWidth.value().value), color.value()}); } else { border = ValueArray::make({Value(borderWidth.value().value)}); @@ -64,7 +65,7 @@ Result preprocessBorder(const Ref& colorPalette, const Valu return Value(border); } -Result preprocessBoxShadow(const Ref& colorPalette, const Value& in) { +Result preprocessBoxShadow(const Value& in) { auto stringBox = in.toStringBox(); static StringBox none = STRING_LITERAL("none"); if (stringBox == none) { @@ -90,7 +91,7 @@ Result preprocessBoxShadow(const Ref& colorPalette, const V return parseError(parser, "boxShadow blur"); } - auto color = parser.parseColor(*colorPalette); + auto color = parser.parseColorValue(); if (!color) { return parseError(parser, "boxShadow color"); } @@ -102,12 +103,12 @@ Result preprocessBoxShadow(const Ref& colorPalette, const V Value(hOffset.value().value), Value(vOffset.value().value), Value(blur.value().value), - Value(color.value().value)}); + color.value()}); return Value(boxShadow); } -Result preprocessTextShadow(const Ref& colorPalette, const Value& in) { +Result preprocessTextShadow(const Value& in) { auto stringBox = in.toStringBox(); static StringBox none = STRING_LITERAL("none"); if (stringBox == none) { @@ -116,7 +117,7 @@ Result preprocessTextShadow(const Ref& colorPalette, const AttributeParser parser(stringBox.toStringView()); - auto color = parser.parseColor(*colorPalette); + auto color = parser.parseColorValue(); if (!color) { return parseError(parser, "Failed to parse text shadow color: "); } @@ -142,11 +143,8 @@ Result preprocessTextShadow(const Ref& colorPalette, const return parser.getError(); } - const auto textShadow = ValueArray::make({Value(color.value().value), - Value(radius.value()), - Value(opacity.value()), - Value(hOffset.value()), - Value(vOffset.value())}); + const auto textShadow = ValueArray::make( + {color.value(), Value(radius.value()), Value(opacity.value()), Value(hOffset.value()), Value(vOffset.value())}); return Value(textShadow); } @@ -173,7 +171,7 @@ static LinearGradientAngle angleRadToAngleEnum(double angleRad) { return static_cast(angleEnum); } -Result preprocessGradient(const Ref& colorPalette, const Value& in) { +Result preprocessGradient(const Value& in) { auto stringBox = in.toStringBox(); Ref colorArray; @@ -211,12 +209,12 @@ Result preprocessGradient(const Ref& colorPalette, const Va if (shouldParseColorComponents) { while (!parser.isAtEnd()) { parser.tryParseWhitespaces(); - auto color = parser.parseColor(*colorPalette); + auto color = parser.parseColorValue(); if (!color) { return parseError(parser, "gradient color"); } - colors.emplace(color.value().value); + colors.emplace(color.value()); parser.tryParseWhitespaces(); @@ -254,12 +252,12 @@ Result preprocessGradient(const Ref& colorPalette, const Va } else { parser.tryParseWhitespaces(); - auto singleColor = parser.parseColor(*colorPalette); + auto singleColor = parser.parseColorValue(); if (!singleColor) { return parser.getError(); } - colorArray = ValueArray::make({Value(singleColor.value().value)}); + colorArray = ValueArray::make({singleColor.value()}); locationArray = ValueArray::make(0); } @@ -274,7 +272,7 @@ Result preprocessGradient(const Ref& colorPalette, const Va return Value(gradient); } -Result preprocessBorderRadius(const Ref& /*colorPalette*/, const Value& in) { +Result preprocessBorderRadius(const Value& in) { auto borderRadius = ValueConverter::toBorderValues(in); if (!borderRadius) { return borderRadius.moveError(); @@ -283,8 +281,97 @@ Result preprocessBorderRadius(const Ref& /*colorPalette*/, return Value(borderRadius.value()); } +static Result resolveColorValue(const ColorPalette& colorPalette, const Value& in) { + return ValueConverter::toColor(colorPalette, in); +} + +static Result resolveColorValue(ViewNode& viewNode, const Value& in) { + if (!in.isString()) { + return in; + } + + const auto& colorPalette = viewNode.getResolvedColorPalette(); + if (colorPalette == nullptr) { + return Error("ViewNode has no resolved ColorPalette"); + } + return resolveColorValue(*colorPalette, in); +} + +static Result resolveColorAtIndex(ViewNode& viewNode, const Value& in, size_t colorIndex) { + if (!in.isArray()) { + return in; + } + + const auto* array = in.getArray(); + if (array->size() <= colorIndex || (*array)[colorIndex].isUndefined()) { + return in; + } + + auto resolvedColor = resolveColorValue(viewNode, (*array)[colorIndex]); + if (!resolvedColor) { + return resolvedColor.moveError(); + } + + auto out = array->clone(); + out->emplace(colorIndex, resolvedColor.moveValue()); + return Value(out); +} + +static Result> resolveColorAtIndexInArray(ViewNode& viewNode, const Value& in, size_t colorIndex) { + const auto* array = in.getArray(); + if (array == nullptr) { + return Error("Invalid array value"); + } + + auto out = array->clone(); + if (out->size() > colorIndex && !(*out)[colorIndex].isUndefined()) { + auto resolvedColor = resolveColorValue(viewNode, (*out)[colorIndex]); + if (!resolvedColor) { + return resolvedColor.moveError(); + } + out->emplace(colorIndex, resolvedColor.moveValue()); + } + + return out; +} + +Result postprocessBorder(ViewNode& viewNode, const Value& in) { + constexpr size_t kBorderColorIndex = 1; + return resolveColorAtIndex(viewNode, in, kBorderColorIndex); +} + +static Result postprocessBoxShadow(bool isRightToLeft, Ref boxShadow) { + if (boxShadow->size() != 5) { + return Error("Invalid boxShadow value"); + } + + if (!isRightToLeft) { + return Value(boxShadow); + } + + constexpr size_t kHOffsetIndex = 1; + + auto hOffset = (*boxShadow)[kHOffsetIndex].toDouble(); + if (hOffset != 0.0) { + boxShadow->emplace(kHOffsetIndex, Value(hOffset * -1)); + } + + return Value(boxShadow); +} + Result postprocessBoxShadow(ViewNode& viewNode, const Value& in) { - return postprocessBoxShadow(viewNode.isRightToLeft(), in); + constexpr size_t kBoxShadowColorIndex = 4; + auto resolvedBoxShadow = resolveColorAtIndexInArray(viewNode, in, kBoxShadowColorIndex); + if (!resolvedBoxShadow) { + return resolvedBoxShadow.moveError(); + } + + return postprocessBoxShadow(viewNode.isRightToLeft(), resolvedBoxShadow.moveValue()); +} + +Result postprocessTextShadow(ViewNode& viewNode, const Value& in) { + constexpr size_t kTextShadowColorIndex = 0; + return resolveColorAtIndex(viewNode, in, kTextShadowColorIndex); } Result postprocessBoxShadow(bool isRightToLeft, const Value& in) { @@ -315,14 +402,106 @@ Result postprocessBoxShadow(bool isRightToLeft, const Value& in) { constexpr size_t kAngleIndex = 2; -Result makeBackgroundWithAngle(const ValueArray* background, LinearGradientAngle angle) { - auto newBackground = background->clone(); - newBackground->emplace(kAngleIndex, Value(angle)); - return Value(newBackground); +static std::optional flippedGradientAngle(LinearGradientAngle angle) { + switch (angle) { + case LinearGradientAngleTopBottom: + case LinearGradientAngleBottomTop: + return std::nullopt; + case LinearGradientAngleTopRightBottomLeft: + return LinearGradientAngleTopLeftBottomRight; + case LinearGradientAngleRightLeft: + return LinearGradientAngleLeftRight; + case LinearGradientAngleBottomRightTopLeft: + return LinearGradientAngleBottomLeftTopRight; + case LinearGradientAngleBottomLeftTopRight: + return LinearGradientAngleBottomRightTopLeft; + case LinearGradientAngleLeftRight: + return LinearGradientAngleRightLeft; + case LinearGradientAngleTopLeftBottomRight: + return LinearGradientAngleTopRightBottomLeft; + } + + return std::nullopt; +} + +static Result postprocessGradient(bool isRightToLeft, Ref background) { + if (background->size() != 4) { + return Error("Invalid background value"); + } + + if (!isRightToLeft) { + return Value(background); + } + + auto angle = static_cast((*background)[kAngleIndex].toInt()); + auto flippedAngle = flippedGradientAngle(angle); + if (flippedAngle) { + background->emplace(kAngleIndex, Value(flippedAngle.value())); + } + + return Value(background); } Result postprocessGradient(ViewNode& viewNode, const Value& in) { - return postprocessGradient(viewNode.isRightToLeft(), in); + if (!in.isArray()) { + return in; + } + + const auto* background = in.getArray(); + if (background->size() != 4) { + return Error("Invalid background value"); + } + + constexpr size_t kColorsIndex = 0; + const auto* colors = (*background)[kColorsIndex].getArray(); + if (colors == nullptr) { + return Error("Invalid background colors value"); + } + + auto resolvedColors = colors->clone(); + for (size_t i = 0; i < colors->size(); ++i) { + auto resolvedColor = resolveColorValue(viewNode, (*colors)[i]); + if (!resolvedColor) { + return resolvedColor.moveError(); + } + resolvedColors->emplace(i, resolvedColor.moveValue()); + } + + auto resolvedBackground = background->clone(); + resolvedBackground->emplace(kColorsIndex, Value(resolvedColors)); + + return postprocessGradient(viewNode.isRightToLeft(), std::move(resolvedBackground)); +} + +Result postprocessGradient(bool isRightToLeft, const ColorPalette& colorPalette, const Value& in) { + if (!in.isArray()) { + return in; + } + + const auto* background = in.getArray(); + if (background->size() != 4) { + return Error("Invalid background value"); + } + + constexpr size_t kColorsIndex = 0; + const auto* colors = (*background)[kColorsIndex].getArray(); + if (colors == nullptr) { + return Error("Invalid background colors value"); + } + + auto resolvedColors = colors->clone(); + for (size_t i = 0; i < colors->size(); ++i) { + auto resolvedColor = resolveColorValue(colorPalette, (*colors)[i]); + if (!resolvedColor) { + return resolvedColor.moveError(); + } + resolvedColors->emplace(i, resolvedColor.moveValue()); + } + + auto resolvedBackground = background->clone(); + resolvedBackground->emplace(kColorsIndex, Value(resolvedColors)); + + return postprocessGradient(isRightToLeft, std::move(resolvedBackground)); } Result postprocessGradient(bool isRightToLeft, const Value& in) { @@ -336,27 +515,14 @@ Result postprocessGradient(bool isRightToLeft, const Value& in) { } auto angle = static_cast((*background)[kAngleIndex].toInt()); - - switch (angle) { - case LinearGradientAngleTopBottom: - return in; - case LinearGradientAngleTopRightBottomLeft: - return makeBackgroundWithAngle(background, LinearGradientAngleTopLeftBottomRight); - case LinearGradientAngleRightLeft: - return makeBackgroundWithAngle(background, LinearGradientAngleLeftRight); - case LinearGradientAngleBottomRightTopLeft: - return makeBackgroundWithAngle(background, LinearGradientAngleBottomLeftTopRight); - case LinearGradientAngleBottomTop: - return in; - case LinearGradientAngleBottomLeftTopRight: - return makeBackgroundWithAngle(background, LinearGradientAngleBottomRightTopLeft); - case LinearGradientAngleLeftRight: - return makeBackgroundWithAngle(background, LinearGradientAngleRightLeft); - case LinearGradientAngleTopLeftBottomRight: - return makeBackgroundWithAngle(background, LinearGradientAngleTopRightBottomLeft); + auto flippedAngle = flippedGradientAngle(angle); + if (!flippedAngle) { + return in; } - return in; + auto flippedBackground = background->clone(); + flippedBackground->emplace(kAngleIndex, Value(flippedAngle.value())); + return Value(flippedBackground); } Result postprocessBorderRadius(ViewNode& viewNode, const Value& in) { @@ -403,7 +569,9 @@ void registerDefaultProcessors(AttributesManager& attributesManager) { attributesManager.registerPreprocessor(textGradientAttributeId, &preprocessGradient); attributesManager.registerPreprocessor(maskImageAttributeId, &preprocessGradient); + attributesManager.registerPostprocessor(borderAttributeId, &postprocessBorder); attributesManager.registerPostprocessor(boxShadowAttributeId, &postprocessBoxShadow); + attributesManager.registerPostprocessor(textShadowAttributeId, &postprocessTextShadow); attributesManager.registerPostprocessor(backgroundAttributeId, &postprocessGradient); attributesManager.registerPostprocessor(borderRadiusAttributeId, &postprocessBorderRadius); attributesManager.registerPostprocessor(textGradientAttributeId, &postprocessGradient); diff --git a/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.hpp b/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.hpp index 819c55eb3..db66fe069 100644 --- a/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.hpp +++ b/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.hpp @@ -12,15 +12,23 @@ namespace Valdi { -Result preprocessBorder(const Ref& colorPalette, const Value& in); -Result preprocessBoxShadow(const Ref& colorPalette, const Value& in); -Result preprocessTextShadow(const Ref& colorPalette, const Value& in); -Result preprocessBorderRadius(const Ref& /*colorPalette*/, const Value& in); -Result preprocessGradient(const Ref& colorPalette, const Value& in); +class ColorPalette; +Result preprocessBorder(const Value& in); +Result preprocessBoxShadow(const Value& in); +Result preprocessTextShadow(const Value& in); +Result preprocessBorderRadius(const Value& in); +Result preprocessGradient(const Value& in); + +Result postprocessBorder(ViewNode& viewNode, const Value& in); +Result postprocessBoxShadow(ViewNode& viewNode, const Value& in); +Result postprocessTextShadow(ViewNode& viewNode, const Value& in); +Result postprocessGradient(ViewNode& viewNode, const Value& in); +Result postprocessBorderRadius(ViewNode& viewNode, const Value& in); Result postprocessBoxShadow(bool isRightToLeft, const Value& in); Result postprocessBorderRadius(bool isRightToLeft, const Value& in); Result postprocessGradient(bool isRightToLeft, const Value& in); +Result postprocessGradient(bool isRightToLeft, const ColorPalette& colorPalette, const Value& in); void registerDefaultProcessors(AttributesManager& attributesManager); diff --git a/valdi/src/valdi/runtime/Attributes/DefaultAttributes.cpp b/valdi/src/valdi/runtime/Attributes/DefaultAttributes.cpp index 1c7ad13c2..6c5971ef0 100644 --- a/valdi/src/valdi/runtime/Attributes/DefaultAttributes.cpp +++ b/valdi/src/valdi/runtime/Attributes/DefaultAttributes.cpp @@ -46,6 +46,7 @@ void DefaultAttributes::bind(AttributeHandlerById& attributes) { binder.bindViewNodeFloat("estimatedWidth", &ViewNode::setEstimatedWidth); binder.bindViewNodeFloat("estimatedHeight", &ViewNode::setEstimatedHeight); + binder.bindViewNodeString("colorPaletteName", &ViewNode::setColorPaletteName); binder.bind( "limitToViewport", diff --git a/valdi/src/valdi/runtime/Attributes/ValueConverters.cpp b/valdi/src/valdi/runtime/Attributes/ValueConverters.cpp index 7cf62db15..a76360793 100644 --- a/valdi/src/valdi/runtime/Attributes/ValueConverters.cpp +++ b/valdi/src/valdi/runtime/Attributes/ValueConverters.cpp @@ -64,13 +64,26 @@ std::optional ensureParserAtEnd(AttributeParser& parser, std::optional&& r return std::move(result); } -Result ValueConverter::toColor(const ColorPalette& colorPalette, const Value& value) { +Result ValueConverter::toColor(const ColorPalette& colorPalette, const Value& value) { + if (value.isString()) { + auto colorName = value.toStringBox(); + auto color = colorPalette.getColorForName(colorName); + if (!color) { + return Error(STRING_FORMAT("Invalid color name '{}'", colorName)); + } + return Value(color.value().value); + } + + return value; +} + +Result ValueConverter::toColorValue(const Value& value) { if (value.isNumber()) { - return Color(value.toInt()); + return Value(value.toLong()); } else if (value.isString()) { auto strBox = value.toStringBox(); AttributeParser parser(strBox.toStringView()); - auto color = ensureParserAtEnd(parser, parser.parseColor(colorPalette)); + auto color = ensureParserAtEnd(parser, parser.parseColorValue()); if (!color) { return parser.getError(); } diff --git a/valdi/src/valdi/runtime/Attributes/ValueConverters.hpp b/valdi/src/valdi/runtime/Attributes/ValueConverters.hpp index e053a0a1f..518dcc1e5 100644 --- a/valdi/src/valdi/runtime/Attributes/ValueConverters.hpp +++ b/valdi/src/valdi/runtime/Attributes/ValueConverters.hpp @@ -24,7 +24,9 @@ struct ValueConverter { [[nodiscard]] static Result toInt(const Value& value); - [[nodiscard]] static Result toColor(const ColorPalette& colorPalette, const Value& value); + [[nodiscard]] static Result toColor(const ColorPalette& colorPalette, const Value& value); + + [[nodiscard]] static Result toColorValue(const Value& value); [[nodiscard]] static Result toDouble(const Value& value); diff --git a/valdi/src/valdi/runtime/Attributes/ViewNodeAttribute.cpp b/valdi/src/valdi/runtime/Attributes/ViewNodeAttribute.cpp index b7506d71c..dc8fd09dd 100644 --- a/valdi/src/valdi/runtime/Attributes/ViewNodeAttribute.cpp +++ b/valdi/src/valdi/runtime/Attributes/ViewNodeAttribute.cpp @@ -21,7 +21,8 @@ ViewNodeAttribute::ViewNodeAttribute(const AttributeHandler* handler) : _handler(handler), _handlerNeedsView(handler->requiresView()), _handlerCanAffectLayout(handler->shouldInvalidateLayoutOnChange()), - _handlerIsCompositePart(handler->isCompositePart()) {} + _handlerIsCompositePart(handler->isCompositePart()), + _handlerShouldReevaluateOnColorChange(handler->shouldReevaluateOnColorPaletteChange()) {} ViewNodeAttribute::~ViewNodeAttribute() { if (_hasSingleAttribute) { @@ -327,6 +328,10 @@ bool ViewNodeAttribute::isCompositePart() const { return _handlerIsCompositePart; } +bool ViewNodeAttribute::shouldReevaluateOnColorChange() const { + return _handlerShouldReevaluateOnColorChange; +} + const CompositeAttribute* ViewNodeAttribute::getCompositeAttribute() const { return _handler->getCompositeAttribute().get(); } @@ -432,6 +437,10 @@ void ViewNodeAttribute::markDirty() { } } +void ViewNodeAttribute::markAppliedValueDirty() { + _appliedValueDirty = true; +} + Ref ViewNodeAttribute::copy() { auto copy = makeShared(_handler); @@ -452,6 +461,7 @@ void ViewNodeAttribute::setHandler(const AttributeHandler* handler) { _handlerNeedsView = handler->requiresView(); _handlerCanAffectLayout = handler->shouldInvalidateLayoutOnChange(); _handlerIsCompositePart = handler->isCompositePart(); + _handlerShouldReevaluateOnColorChange = handler->shouldReevaluateOnColorPaletteChange(); } } // namespace Valdi diff --git a/valdi/src/valdi/runtime/Attributes/ViewNodeAttribute.hpp b/valdi/src/valdi/runtime/Attributes/ViewNodeAttribute.hpp index 9855cbcb0..00a14ece2 100644 --- a/valdi/src/valdi/runtime/Attributes/ViewNodeAttribute.hpp +++ b/valdi/src/valdi/runtime/Attributes/ViewNodeAttribute.hpp @@ -41,6 +41,7 @@ class ViewNodeAttribute : public SimpleRefCountable { const Ref& animator); void markDirty(); + void markAppliedValueDirty(); /** Prepare this attribute for an animation. @@ -85,6 +86,7 @@ class ViewNodeAttribute : public SimpleRefCountable { Returns whether this represents a composite attribute part. */ bool isCompositePart() const; + bool shouldReevaluateOnColorChange() const; /** Whether this attribute can affect the layout calculation. @@ -137,6 +139,7 @@ class ViewNodeAttribute : public SimpleRefCountable { bool _handlerNeedsView = false; bool _handlerCanAffectLayout = false; bool _handlerIsCompositePart = false; + bool _handlerShouldReevaluateOnColorChange = false; bool _appliedValueDirty = false; bool _hasAppliedValue = false; diff --git a/valdi/src/valdi/runtime/Attributes/ViewNodeAttributesApplier.cpp b/valdi/src/valdi/runtime/Attributes/ViewNodeAttributesApplier.cpp index 047351c6e..f34ce5cd9 100644 --- a/valdi/src/valdi/runtime/Attributes/ViewNodeAttributesApplier.cpp +++ b/valdi/src/valdi/runtime/Attributes/ViewNodeAttributesApplier.cpp @@ -107,6 +107,10 @@ void ViewNodeAttributesApplier::reapplyAttribute(ViewTransactionScope& viewTrans } } +void ViewNodeAttributesApplier::invalidateColorAttributes() { + _colorAttributesInvalidated = true; +} + bool ViewNodeAttributesApplier::removeAllAttributesForOwner(ViewTransactionScope& viewTransactionScope, const AttributeOwner* owner, const Ref& animator) { @@ -265,6 +269,11 @@ void ViewNodeAttributesApplier::flush(ViewTransactionScope& viewTransactionScope return; } + if (_colorAttributesInvalidated) { + _colorAttributesInvalidated = false; + updateInvalidatedColorAttributes(viewTransactionScope); + } + while (!_dirtyCompositeAttributes.empty()) { auto dirtyCompositeAttribute = *_dirtyCompositeAttributes.begin(); _dirtyCompositeAttributes.erase(_dirtyCompositeAttributes.begin()); @@ -274,7 +283,25 @@ void ViewNodeAttributesApplier::flush(ViewTransactionScope& viewTransactionScope } bool ViewNodeAttributesApplier::needsFlush() const { - return !_dirtyCompositeAttributes.empty(); + return _colorAttributesInvalidated || !_dirtyCompositeAttributes.empty(); +} + +void ViewNodeAttributesApplier::updateInvalidatedColorAttributes(ViewTransactionScope& viewTransactionScope) { + for (const auto& it : _attributes) { + if (!it.second->shouldReevaluateOnColorChange()) { + continue; + } + + auto id = it.first; + auto attribute = it.second; + attribute->markAppliedValueDirty(); + + if (attribute->isCompositePart()) { + _dirtyCompositeAttributes[attribute->getCompositeAttribute()->getAttributeId()] = nullptr; + } else { + updateAttribute(viewTransactionScope, id, *attribute, nullptr, /* justAddedView */ false); + } + } } void ViewNodeAttributesApplier::updateCompositeAttribute(ViewTransactionScope& viewTransactionScope, @@ -453,6 +480,7 @@ void ViewNodeAttributesApplier::setBoundAttributes(Ref boundAtt if (_boundAttributes == nullptr) { // Clear state so flush() / emplaceAttribute() are never called with null _boundAttributes. _dirtyCompositeAttributes.clear(); + _colorAttributesInvalidated = false; _attributes.clear(); } else if (VALDI_UNLIKELY(hadAttributes)) { updateAttributeHandlers(); @@ -493,6 +521,7 @@ void ViewNodeAttributesApplier::updateAttributeHandlers() { void ViewNodeAttributesApplier::destroy() { _viewNode = nullptr; _dirtyCompositeAttributes.clear(); + _colorAttributesInvalidated = false; _attributes.clear(); } diff --git a/valdi/src/valdi/runtime/Attributes/ViewNodeAttributesApplier.hpp b/valdi/src/valdi/runtime/Attributes/ViewNodeAttributesApplier.hpp index e5253e28c..bef2f7070 100644 --- a/valdi/src/valdi/runtime/Attributes/ViewNodeAttributesApplier.hpp +++ b/valdi/src/valdi/runtime/Attributes/ViewNodeAttributesApplier.hpp @@ -53,6 +53,7 @@ class ViewNodeAttributesApplier : public AttributesApplier, public AttributeOwne const Ref& animator) override; void reapplyAttribute(ViewTransactionScope& viewTransactionScope, AttributeId id) override; + void invalidateColorAttributes(); void flush(ViewTransactionScope& viewTransactionScope) override; bool needsFlush() const override; @@ -101,6 +102,7 @@ class ViewNodeAttributesApplier : public AttributesApplier, public AttributeOwne FlatMap> _dirtyCompositeAttributes; bool _hasView = false; + bool _colorAttributesInvalidated = false; void updateCompositeAttribute(ViewTransactionScope& viewTransactionScope, AttributeId compositeId, @@ -129,6 +131,7 @@ class ViewNodeAttributesApplier : public AttributesApplier, public AttributeOwne StringBox getAttributeName(AttributeId id) const; void updateAttributeHandlers(); + void updateInvalidatedColorAttributes(ViewTransactionScope& viewTransactionScope); const AttributeHandler* getAttributeHandler(AttributeId id) const; }; diff --git a/valdi/src/valdi/runtime/Context/ViewManagerContext.cpp b/valdi/src/valdi/runtime/Context/ViewManagerContext.cpp index abb5d61ab..550a6be75 100644 --- a/valdi/src/valdi/runtime/Context/ViewManagerContext.cpp +++ b/valdi/src/valdi/runtime/Context/ViewManagerContext.cpp @@ -15,13 +15,13 @@ namespace Valdi { ViewManagerContext::ViewManagerContext(IViewManager& viewManager, AttributeIds& attributeIds, - const Ref& colorPalette, + const Ref& colorPaletteManager, const Shared& yogaConfig, bool enablePreloading, const Ref& mainThreadManager, ILogger& logger) : _viewManager(viewManager), - _attributesManager(viewManager, attributeIds, colorPalette, logger, yogaConfig), + _attributesManager(viewManager, attributeIds, colorPaletteManager, logger, yogaConfig), _mainThreadManager(mainThreadManager) { Valdi::registerDefaultProcessors(_attributesManager); diff --git a/valdi/src/valdi/runtime/Context/ViewManagerContext.hpp b/valdi/src/valdi/runtime/Context/ViewManagerContext.hpp index 37eef1a73..1f4dd3606 100644 --- a/valdi/src/valdi/runtime/Context/ViewManagerContext.hpp +++ b/valdi/src/valdi/runtime/Context/ViewManagerContext.hpp @@ -20,7 +20,7 @@ class GlobalViewFactories; class DispatchQueue; class ViewPreloader; class MainThreadManager; -class ColorPalette; +class ColorPaletteManager; using ViewPoolsStats = FlatMap; @@ -28,7 +28,7 @@ class ViewManagerContext : public SimpleRefCountable { public: ViewManagerContext(IViewManager& viewManager, AttributeIds& attributeIds, - const Ref& colorPalette, + const Ref& colorPaletteManager, const Shared& yogaConfig, bool enablePreloading, const Ref& mainThreadManager, diff --git a/valdi/src/valdi/runtime/Context/ViewNode.cpp b/valdi/src/valdi/runtime/Context/ViewNode.cpp index 2635e75b1..7834698be 100644 --- a/valdi/src/valdi/runtime/Context/ViewNode.cpp +++ b/valdi/src/valdi/runtime/Context/ViewNode.cpp @@ -158,12 +158,17 @@ constexpr size_t kHasChildWithAccessibilityId = 26; constexpr size_t kCanAlwaysScrollHorizontal = 27; constexpr size_t kCanAlwaysScrollVertical = 28; constexpr size_t kAccessibilityTreeNeedsUpdate = 29; +constexpr size_t kHasOveriddenColorPalette = 30; -ViewNode::ViewNode(YGConfig* yogaConfig, AttributeIds& attributeIds, ILogger& logger) +ViewNode::ViewNode(YGConfig* yogaConfig, + AttributeIds& attributeIds, + const Ref& colorPalette, + ILogger& logger) : _yogaNode(yogaConfig != nullptr ? Yoga::createNode(yogaConfig) : nullptr), _attributeIds(attributeIds), _logger(logger), - _attributesApplier(this) { + _attributesApplier(this), + _colorPalette(colorPalette) { if (_yogaNode != nullptr) { setupYogaNode(_yogaNode, this); } @@ -195,6 +200,49 @@ ViewNodeTree* ViewNode::getViewNodeTree() const { return _viewNodeTree; } +void ViewNode::setColorPaletteName(ViewTransactionScope& viewTransactionScope, const StringBox& colorPaletteName) { + if (colorPaletteName.isEmpty() && !hasOveriddenColorPalette()) { + return; + } + + Ref colorPalette; + if (colorPaletteName.isEmpty()) { + colorPalette = getParentResolvedColorPalette(); + setHasOveriddenColorPalette(false); + } else { + SC_ASSERT(_viewNodeTree != nullptr, "Cannot resolve color palette without a ViewNodeTree"); + colorPalette = + _viewNodeTree->getViewManagerContext()->getAttributesManager().getColorPaletteManager()->getColorPalette( + colorPaletteName); + setHasOveriddenColorPalette(true); + } + + if (!setResolvedColorPalette(colorPalette)) { + return; + } + + invalidateColorAttributes(viewTransactionScope, false); + propagateInheritedColorPalette(viewTransactionScope, _colorPalette); +} + +void ViewNode::setInheritedColorPalette(ViewTransactionScope& viewTransactionScope, + const Ref& colorPalette) { + if (hasOveriddenColorPalette()) { + return; + } + + if (!setResolvedColorPalette(colorPalette)) { + return; + } + + invalidateColorAttributes(viewTransactionScope, true); + propagateInheritedColorPalette(viewTransactionScope, colorPalette); +} + +const Ref& ViewNode::getResolvedColorPalette() const { + return _colorPalette; +} + const Ref& ViewNode::getView() const { return _view; } @@ -1535,6 +1583,9 @@ void ViewNode::insertChildAt(ViewTransactionScope& viewTransactionScope, const R if (getChildCount() > kMaxChildrenBeforeIndexing && _childrenIndexer == nullptr) { _childrenIndexer = std::make_unique(this); } + if (_colorPalette != nullptr) { + child->setInheritedColorPalette(viewTransactionScope, _colorPalette); + } setCalculatedViewportHasChildNeedsUpdate(); @@ -1602,7 +1653,7 @@ Size ViewNode::onMeasure(float width, MeasureMode widthMode, float height, Measu Ref ViewNode::makePlaceholderViewNode(ViewTransactionScope& viewTransactionScope, const Ref& placeholderView) { - auto viewNode = Valdi::makeShared(nullptr, _attributeIds, _logger); + auto viewNode = Valdi::makeShared(nullptr, _attributeIds, _colorPalette, _logger); viewNode->setViewNodeTree(_viewNodeTree); viewNode->setViewFactory(viewTransactionScope, _viewFactory); viewNode->_emittingViewNode = strongSmallRef(this); @@ -3025,6 +3076,65 @@ void ViewNode::reapplyAttributesRecursive(ViewTransactionScope& viewTransactionS } } +bool ViewNode::hasOveriddenColorPalette() const { + return _flags[kHasOveriddenColorPalette]; +} + +void ViewNode::setHasOveriddenColorPalette(bool hasOveriddenColorPalette) { + _flags[kHasOveriddenColorPalette] = hasOveriddenColorPalette; +} + +bool ViewNode::setResolvedColorPalette(const Ref& colorPalette) { + if (_colorPalette == colorPalette) { + return false; + } + + _colorPalette = colorPalette; + return true; +} + +Ref ViewNode::getParentResolvedColorPalette() const { + auto parent = getParent(); + if (parent != nullptr) { + return parent->getResolvedColorPalette(); + } + + if (_viewNodeTree == nullptr) { + return nullptr; + } + + const auto& viewManagerContext = _viewNodeTree->getViewManagerContext(); + if (viewManagerContext == nullptr) { + return nullptr; + } + + return viewManagerContext->getAttributesManager().getColorPaletteManager()->getActiveColorPalette(); +} + +void ViewNode::invalidateColorAttributes(ViewTransactionScope& viewTransactionScope, bool shouldApply) { + _attributesApplier.invalidateColorAttributes(); + if (shouldApply && _colorPalette != nullptr) { + _attributesApplier.flush(viewTransactionScope); + } +} + +void ViewNode::propagateInheritedColorPalette(ViewTransactionScope& viewTransactionScope, + const Ref& colorPalette) { + for (auto* child : *this) { + child->setInheritedColorPalette(viewTransactionScope, colorPalette); + } +} + +void ViewNode::onColorPaletteMutated(ViewTransactionScope& viewTransactionScope, const ColorPalette& colorPalette) { + if (_colorPalette != nullptr && _colorPalette.get() == &colorPalette) { + invalidateColorAttributes(viewTransactionScope, true); + } + + for (auto* child : *this) { + child->onColorPaletteMutated(viewTransactionScope, colorPalette); + } +} + void ViewNode::notifyAttributeFailed(AttributeId attributeId, const Error& error) { _attributesApplier.onApplyAttributeFailed(attributeId, error); } diff --git a/valdi/src/valdi/runtime/Context/ViewNode.hpp b/valdi/src/valdi/runtime/Context/ViewNode.hpp index 1423cbae3..3bc3ce0f8 100644 --- a/valdi/src/valdi/runtime/Context/ViewNode.hpp +++ b/valdi/src/valdi/runtime/Context/ViewNode.hpp @@ -59,6 +59,7 @@ class BoundAttributes; class AttributeOwner; class ViewNodesFrameObserver; class Metrics; +class ColorPalette; class ViewNode; class ViewNodeIterator { @@ -151,7 +152,7 @@ enum SimplifiedScrollDirection { class ViewNode : public SharedPtrRefCountable { public: - ViewNode(YGConfig* yogaConfig, AttributeIds& attributeIds, ILogger& logger); + ViewNode(YGConfig* yogaConfig, AttributeIds& attributeIds, const Ref& colorPalette, ILogger& logger); ~ViewNode() override; @@ -322,6 +323,11 @@ class ViewNode : public SharedPtrRefCountable { void reapplyAttributesRecursive(ViewTransactionScope& viewTransactionScope, const std::vector& attributes, bool invalidateMeasure); + void onColorPaletteMutated(ViewTransactionScope& viewTransactionScope, const ColorPalette& colorPalette); + + void setColorPaletteName(ViewTransactionScope& viewTransactionScope, const StringBox& colorPaletteName); + void setInheritedColorPalette(ViewTransactionScope& viewTransactionScope, const Ref& colorPalette); + const Ref& getResolvedColorPalette() const; void notifyAttributeFailed(AttributeId attributeId, const Error& error); @@ -653,13 +659,14 @@ class ViewNode : public SharedPtrRefCountable { float _stickyCachedParentH = 0.0f; float _stickyCachedChildH = 0.0f; - std::bitset<30> _flags; + std::bitset<31> _flags; ViewNodeTree* _viewNodeTree = nullptr; Ref _view; Ref _viewFactory; Ref _assetHandler; + Ref _colorPalette; Ref _onViewCreatedCallback; Ref _onViewDestroyedCallback; @@ -759,6 +766,14 @@ class ViewNode : public SharedPtrRefCountable { const Ref& resolveAnimator(const Ref& parentAnimator) const; + bool hasOveriddenColorPalette() const; + void setHasOveriddenColorPalette(bool hasOveriddenColorPalette); + bool setResolvedColorPalette(const Ref& colorPalette); + Ref getParentResolvedColorPalette() const; + void invalidateColorAttributes(ViewTransactionScope& viewTransactionScope, bool shouldApply); + void propagateInheritedColorPalette(ViewTransactionScope& viewTransactionScope, + const Ref& colorPalette); + ViewNodeScrollState& getOrCreateScrollState(); ViewNodeAccessibilityState& getOrCreateAccessibilityState(); diff --git a/valdi/src/valdi/runtime/Context/ViewNodeTree.cpp b/valdi/src/valdi/runtime/Context/ViewNodeTree.cpp index 86d087ef4..98bcfa63e 100644 --- a/valdi/src/valdi/runtime/Context/ViewNodeTree.cpp +++ b/valdi/src/valdi/runtime/Context/ViewNodeTree.cpp @@ -553,6 +553,11 @@ void ViewNodeTree::setRootViewNode(Ref rootViewNode, bool useDefaultVi if (_rootViewNode != nullptr) { _rootViewNode->removeFromParent(viewTransactionScope); + if (_viewManagerContext != nullptr) { + _rootViewNode->setInheritedColorPalette( + viewTransactionScope, + _viewManagerContext->getAttributesManager().getColorPaletteManager()->getActiveColorPalette()); + } if (useDefaultViewFactory) { SC_ASSERT_NOTNULL(_viewManager); _rootViewNode->setViewFactory(viewTransactionScope, diff --git a/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.cpp b/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.cpp index 0b1acfebc..dc707e1cc 100644 --- a/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.cpp +++ b/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.cpp @@ -28,6 +28,7 @@ #include "valdi/runtime/JavaScript/ValueFunctionWithJSValue.hpp" #include "valdi/runtime/Resources/DirectionalAsset.hpp" #include "valdi/runtime/Resources/PlatformSpecificAsset.hpp" +#include "valdi/runtime/Resources/ThemableAsset.hpp" #include "valdi/runtime/ValdiRuntimeTweaks.hpp" #include "valdi_core/JSRuntimeNativeObjectsManager.hpp" #include "valdi_core/cpp/Constants.hpp" @@ -1813,6 +1814,31 @@ JSValueRef JavaScriptRuntime::runtimeMakePlatformSpecificAsset(JSFunctionNativeC return makeWrappedObject(callContext.getContext(), asset, callContext.getExceptionTracker(), false); } +JSValueRef JavaScriptRuntime::runtimeMakeThemableAsset(JSFunctionNativeCallContext& callContext) { + auto assetsByColorPaletteValue = callContext.getParameterAsValue(0); + CHECK_CALL_CONTEXT(callContext); + + auto assetsByColorPaletteMap = assetsByColorPaletteValue.getMapRef(); + if (assetsByColorPaletteMap == nullptr || assetsByColorPaletteMap->empty()) { + return callContext.throwError(Error("Invalid themable assets object specified")); + } + + FlatMap> assetsByColorPalette; + for (const auto& assetByColorPalette : *assetsByColorPaletteMap) { + auto asset = AssetResolver::resolve(_resourceManager, assetByColorPalette.second); + if (asset == nullptr) { + return callContext.throwError( + Error("Themable assets can only be created from URL or Valdi assets")); + } + + assetsByColorPalette[assetByColorPalette.first] = asset; + } + + auto asset = makeShared(std::move(assetsByColorPalette)); + + return makeWrappedObject(callContext.getContext(), asset, callContext.getExceptionTracker(), false); +} + JSValueRef JavaScriptRuntime::runtimeGetLoadedAssetMetadata(JSFunctionNativeCallContext& callContext) { auto loadedAsset = castOrNull(callContext.getParameterAsWrappedObject(0)); CHECK_CALL_CONTEXT(callContext); @@ -1937,16 +1963,35 @@ JSValueRef JavaScriptRuntime::runtimeGetAssets(JSFunctionNativeCallContext& call return assetsArray; } -JSValueRef JavaScriptRuntime::runtimeSetColorPalette(JSFunctionNativeCallContext& callContext) { +JSValueRef JavaScriptRuntime::runtimeConfigureColorPalette(JSFunctionNativeCallContext& callContext) { + if (!_isWorker) { + auto name = callContext.getParameterAsString(0); + CHECK_CALL_CONTEXT(callContext); + auto colorPaletteMap = callContext.getParameterAsValue(1); + CHECK_CALL_CONTEXT(callContext); + + dispatchOnMainThread([weakSelf = weakRef(this), name, colorPaletteMap = std::move(colorPaletteMap)]() { + auto self = weakSelf.lock(); + if (self != nullptr) { + if (auto listener = self->getListener()) { + listener->configureColorPalette(name, colorPaletteMap); + } + } + }); + } + return callContext.getContext().newUndefined(); +} + +JSValueRef JavaScriptRuntime::runtimeSetActiveColorPalette(JSFunctionNativeCallContext& callContext) { if (!_isWorker) { - auto colorPaletteMap = callContext.getParameterAsValue(0); + auto name = callContext.getParameterAsString(0); CHECK_CALL_CONTEXT(callContext); - dispatchOnMainThread([weakSelf = weakRef(this), colorPaletteMap = std::move(colorPaletteMap)]() { + dispatchOnMainThread([weakSelf = weakRef(this), name]() { auto self = weakSelf.lock(); if (self != nullptr) { if (auto listener = self->getListener()) { - listener->updateColorPalette(colorPaletteMap); + listener->setActiveColorPalette(name); } } }); @@ -2425,8 +2470,10 @@ void JavaScriptRuntime::buildContext(Valdi::IJavaScriptContext& context, JS_BIND(context, exceptionTracker, runtimeObject, "makeDirectionalAsset", runtimeMakeDirectionalAsset); JS_BIND(context, exceptionTracker, runtimeObject, "makePlatformSpecificAsset", runtimeMakePlatformSpecificAsset); + JS_BIND(context, exceptionTracker, runtimeObject, "makeThemableAsset", runtimeMakeThemableAsset); JS_BIND(context, exceptionTracker, runtimeObject, "getLoadedAssetMetadata", runtimeGetLoadedAssetMetadata); - JS_BIND(context, exceptionTracker, runtimeObject, "setColorPalette", runtimeSetColorPalette); + JS_BIND(context, exceptionTracker, runtimeObject, "configureColorPalette", runtimeConfigureColorPalette); + JS_BIND(context, exceptionTracker, runtimeObject, "setActiveColorPalette", runtimeSetActiveColorPalette); JS_BIND(context, exceptionTracker, runtimeObject, "onMainThreadIdle", runtimeOnMainThreadIdle); JS_BIND(context, exceptionTracker, runtimeObject, "createWorker", runtimeCreateWorker); diff --git a/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.hpp b/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.hpp index 470f366be..9d8f5d7fd 100644 --- a/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.hpp +++ b/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.hpp @@ -100,7 +100,9 @@ class IJavaScriptRuntimeListener { bool createIfNeeded, Function&)>&& function) = 0; - virtual void updateColorPalette(const Value& colorPaletteMap) = 0; + virtual void configureColorPalette(const StringBox& name, const Value& colorPaletteMap) = 0; + + virtual void setActiveColorPalette(const StringBox& name) = 0; virtual void onUncaughtJsError(const StringBox& moduleName, const Error& error) = 0; @@ -477,8 +479,10 @@ class JavaScriptRuntime : public JavaScriptTaskScheduler, JSValueRef runtimeGetAssets(JSFunctionNativeCallContext& callContext); JSValueRef runtimeMakeDirectionalAsset(JSFunctionNativeCallContext& callContext); JSValueRef runtimeMakePlatformSpecificAsset(JSFunctionNativeCallContext& callContext); + JSValueRef runtimeMakeThemableAsset(JSFunctionNativeCallContext& callContext); JSValueRef runtimeGetLoadedAssetMetadata(JSFunctionNativeCallContext& callContext); - JSValueRef runtimeSetColorPalette(JSFunctionNativeCallContext& callContext); + JSValueRef runtimeConfigureColorPalette(JSFunctionNativeCallContext& callContext); + JSValueRef runtimeSetActiveColorPalette(JSFunctionNativeCallContext& callContext); JSValueRef runtimeTakeElementSnapshot(JSFunctionNativeCallContext& callContext); JSValueRef runtimeGetNativeNodeForElementId(JSFunctionNativeCallContext& callContext); diff --git a/valdi/src/valdi/runtime/JavaScript/Modules/AttributedTextNativeModuleFactory.cpp b/valdi/src/valdi/runtime/JavaScript/Modules/AttributedTextNativeModuleFactory.cpp index d3cf9fa28..938b82cc5 100644 --- a/valdi/src/valdi/runtime/JavaScript/Modules/AttributedTextNativeModuleFactory.cpp +++ b/valdi/src/valdi/runtime/JavaScript/Modules/AttributedTextNativeModuleFactory.cpp @@ -5,9 +5,9 @@ namespace Valdi { -AttributedTextNativeModuleFactory::AttributedTextNativeModuleFactory(const Ref& colorPalette, - ILogger& logger) - : _colorPalette(colorPalette), _logger(logger) {} +AttributedTextNativeModuleFactory::AttributedTextNativeModuleFactory( + const Ref& colorPaletteManager, ILogger& logger) + : _colorPaletteManager(colorPaletteManager), _logger(logger) {} AttributedTextNativeModuleFactory::~AttributedTextNativeModuleFactory() = default; @@ -25,7 +25,7 @@ Value AttributedTextNativeModuleFactory::loadModule() { Value AttributedTextNativeModuleFactory::makeNativeAttributedText(const ValueFunctionCallContext& callContext) const { auto value = callContext.getParameter(0); - auto result = TextAttributeValueParser::parse(*_colorPalette, value, _logger, true); + auto result = TextAttributeValueParser::parse(*_colorPaletteManager->getActiveColorPalette(), value, _logger, true); if (!result) { callContext.getExceptionTracker().onError(result.moveError()); return Value::undefined(); @@ -34,4 +34,4 @@ Value AttributedTextNativeModuleFactory::makeNativeAttributedText(const ValueFun return result.value(); } -} // namespace Valdi \ No newline at end of file +} // namespace Valdi diff --git a/valdi/src/valdi/runtime/JavaScript/Modules/AttributedTextNativeModuleFactory.hpp b/valdi/src/valdi/runtime/JavaScript/Modules/AttributedTextNativeModuleFactory.hpp index adacef7f5..115a5df93 100644 --- a/valdi/src/valdi/runtime/JavaScript/Modules/AttributedTextNativeModuleFactory.hpp +++ b/valdi/src/valdi/runtime/JavaScript/Modules/AttributedTextNativeModuleFactory.hpp @@ -5,23 +5,23 @@ namespace Valdi { -class ColorPalette; +class ColorPaletteManager; class ILogger; class ValueFunctionCallContext; class AttributedTextNativeModuleFactory : public Valdi::SharedPtrRefCountable, public snap::valdi_core::ModuleFactory { public: - AttributedTextNativeModuleFactory(const Ref& colorPalette, ILogger& logger); + AttributedTextNativeModuleFactory(const Ref& colorPaletteManager, ILogger& logger); ~AttributedTextNativeModuleFactory() override; StringBox getModulePath() override; Value loadModule() override; private: - Ref _colorPalette; + Ref _colorPaletteManager; ILogger& _logger; Value makeNativeAttributedText(const ValueFunctionCallContext& callContext) const; }; -} // namespace Valdi \ No newline at end of file +} // namespace Valdi diff --git a/valdi/src/valdi/runtime/Rendering/ViewNodeRenderer.cpp b/valdi/src/valdi/runtime/Rendering/ViewNodeRenderer.cpp index 1314a68e7..33009a7a7 100644 --- a/valdi/src/valdi/runtime/Rendering/ViewNodeRenderer.cpp +++ b/valdi/src/valdi/runtime/Rendering/ViewNodeRenderer.cpp @@ -54,8 +54,10 @@ void ViewNodeRenderer::visit(RenderRequestEntries::CreateElement& entry) { return; } - auto viewNode = - Valdi::makeShared(_attributesManager.getYogaConfig(), _attributesManager.getAttributeIds(), _logger); + auto viewNode = Valdi::makeShared(_attributesManager.getYogaConfig(), + _attributesManager.getAttributeIds(), + _attributesManager.getColorPaletteManager()->getActiveColorPalette(), + _logger); viewNode->setViewFactory(_viewTransactionScope, _viewNodeTree.getOrCreateViewFactory(entry.getViewClassName())); viewNode->setRawId(entry.getElementId()); diff --git a/valdi/src/valdi/runtime/Resources/DirectionalAsset.cpp b/valdi/src/valdi/runtime/Resources/DirectionalAsset.cpp index 7f4ccb77c..cc92a12df 100644 --- a/valdi/src/valdi/runtime/Resources/DirectionalAsset.cpp +++ b/valdi/src/valdi/runtime/Resources/DirectionalAsset.cpp @@ -29,8 +29,15 @@ double DirectionalAsset::getHeight() const { return _ltrAsset->getHeight(); } -Ref DirectionalAsset::withDirection(bool rightToLeft) { - return rightToLeft ? _rtlAsset : _ltrAsset; +Ref DirectionalAsset::withConfiguration(const AssetConfiguration& configuration) { + if (!configuration.rightToLeft.has_value()) { + return strongSmallRef(this); + } + + auto asset = configuration.rightToLeft.value() ? _rtlAsset : _ltrAsset; + auto remainingConfiguration = configuration; + remainingConfiguration.rightToLeft = std::nullopt; + return asset->withConfiguration(remainingConfiguration); } void DirectionalAsset::addLoadObserver(const std::shared_ptr& observer, diff --git a/valdi/src/valdi/runtime/Resources/DirectionalAsset.hpp b/valdi/src/valdi/runtime/Resources/DirectionalAsset.hpp index 714b46435..18d022c4b 100644 --- a/valdi/src/valdi/runtime/Resources/DirectionalAsset.hpp +++ b/valdi/src/valdi/runtime/Resources/DirectionalAsset.hpp @@ -28,7 +28,7 @@ class DirectionalAsset : public Asset { double getWidth() const final; double getHeight() const final; - Ref withDirection(bool rightToLeft) final; + Ref withConfiguration(const AssetConfiguration& configuration) final; void addLoadObserver(const std::shared_ptr& observer, snap::valdi_core::AssetOutputType outputType, diff --git a/valdi/src/valdi/runtime/Resources/PlatformSpecificAsset.cpp b/valdi/src/valdi/runtime/Resources/PlatformSpecificAsset.cpp index cdbc80ea2..00fb7ed1f 100644 --- a/valdi/src/valdi/runtime/Resources/PlatformSpecificAsset.cpp +++ b/valdi/src/valdi/runtime/Resources/PlatformSpecificAsset.cpp @@ -36,24 +36,38 @@ double PlatformSpecificAsset::getHeight() const { return _defaultAsset->getHeight(); } -Ref PlatformSpecificAsset::withPlatform(PlatformType platformType) { - switch (platformType) { +Ref PlatformSpecificAsset::withConfiguration(const AssetConfiguration& configuration) { + if (!configuration.platformType.has_value()) { + return strongSmallRef(this); + } + + Ref asset; + switch (configuration.platformType.value()) { case PlatformTypeAndroid: if (_androidAsset == nullptr) { - return _defaultAsset; + asset = _defaultAsset; + } else { + asset = _androidAsset; } - return _androidAsset; + break; case PlatformTypeIOS: case PlatformTypeMacOS: case PlatformTypeWeb: case PlatformTypeLinux: if (_iOSAsset == nullptr) { - return _defaultAsset; + asset = _defaultAsset; + } else { + asset = _iOSAsset; } - return _iOSAsset; + break; default: - return _defaultAsset; + asset = _defaultAsset; + break; } + + auto remainingConfiguration = configuration; + remainingConfiguration.platformType = std::nullopt; + return asset->withConfiguration(remainingConfiguration); } void PlatformSpecificAsset::addLoadObserver(const std::shared_ptr& observer, @@ -64,7 +78,7 @@ void PlatformSpecificAsset::addLoadObserver(const std::shared_ptr withPlatform(PlatformType platformType) final; + Ref withConfiguration(const AssetConfiguration& configuration) final; void addLoadObserver(const std::shared_ptr& observer, snap::valdi_core::AssetOutputType outputType, diff --git a/valdi/src/valdi/runtime/Resources/ThemableAsset.cpp b/valdi/src/valdi/runtime/Resources/ThemableAsset.cpp new file mode 100644 index 000000000..e5ee716eb --- /dev/null +++ b/valdi/src/valdi/runtime/Resources/ThemableAsset.cpp @@ -0,0 +1,75 @@ +// +// ThemableAsset.cpp +// valdi +// + +#include "valdi/runtime/Resources/ThemableAsset.hpp" +#include "valdi_core/cpp/Attributes/ColorPalette.hpp" + +namespace Valdi { + +ThemableAsset::ThemableAsset(FlatMap> assetsByColorPalette) + : _assetsByColorPalette(std::move(assetsByColorPalette)) { + if (!_assetsByColorPalette.empty()) { + _representativeAsset = _assetsByColorPalette.begin()->second; + } +} + +ThemableAsset::~ThemableAsset() = default; + +bool ThemableAsset::canBeMeasured() const { + return _representativeAsset != nullptr && _representativeAsset->canBeMeasured(); +} + +StringBox ThemableAsset::getIdentifier() { + return _representativeAsset != nullptr ? _representativeAsset->getIdentifier() : StringBox(); +} + +double ThemableAsset::getWidth() const { + return _representativeAsset != nullptr ? _representativeAsset->getWidth() : 0.0; +} + +double ThemableAsset::getHeight() const { + return _representativeAsset != nullptr ? _representativeAsset->getHeight() : 0.0; +} + +Ref ThemableAsset::withConfiguration(const AssetConfiguration& configuration) { + if (configuration.colorPalette == nullptr) { + return strongSmallRef(this); + } + + const auto& it = _assetsByColorPalette.find(configuration.colorPalette->getName()); + if (it == _assetsByColorPalette.end()) { + return nullptr; + } + + auto remainingConfiguration = configuration; + remainingConfiguration.colorPalette = nullptr; + return it->second->withConfiguration(remainingConfiguration); +} + +void ThemableAsset::addLoadObserver(const std::shared_ptr& /*observer*/, + snap::valdi_core::AssetOutputType /*outputType*/, + int32_t /*preferredWidth*/, + int32_t /*preferredHeight*/, + const Valdi::Value& /*associatedData*/) { + // No op. ThemableAsset should be resolved through withConfiguration before rendering. +} + +void ThemableAsset::removeLoadObserver( + const std::shared_ptr& /*observer*/) { + // No op. ThemableAsset should be resolved through withConfiguration before rendering. +} + +void ThemableAsset::updateLoadObserverPreferredSize( + const std::shared_ptr& /*observer*/, + int32_t /*preferredWidth*/, + int32_t /*preferredHeight*/) { + // No op. ThemableAsset should be resolved through withConfiguration before rendering. +} + +std::optional ThemableAsset::getResolvedLocation() const { + return _representativeAsset != nullptr ? _representativeAsset->getResolvedLocation() : std::nullopt; +} + +} // namespace Valdi diff --git a/valdi/src/valdi/runtime/Resources/ThemableAsset.hpp b/valdi/src/valdi/runtime/Resources/ThemableAsset.hpp new file mode 100644 index 000000000..20511419c --- /dev/null +++ b/valdi/src/valdi/runtime/Resources/ThemableAsset.hpp @@ -0,0 +1,53 @@ +// +// ThemableAsset.hpp +// valdi +// + +#pragma once + +#include "valdi_core/cpp/Resources/Asset.hpp" +#include "valdi_core/cpp/Utils/FlatMap.hpp" +#include "valdi_core/cpp/Utils/StringBox.hpp" + +namespace Valdi { + +class ColorPalette; + +/** + A ThemableAsset holds assets keyed by color palette name and resolves to the + asset matching the view node's resolved color palette. + */ +class ThemableAsset : public Asset { +public: + explicit ThemableAsset(FlatMap> assetsByColorPalette); + ~ThemableAsset() override; + + bool canBeMeasured() const final; + + StringBox getIdentifier() final; + + double getWidth() const final; + double getHeight() const final; + + Ref withConfiguration(const AssetConfiguration& configuration) final; + + void addLoadObserver(const std::shared_ptr& observer, + snap::valdi_core::AssetOutputType outputType, + int32_t preferredWidth, + int32_t preferredHeight, + const Valdi::Value& associatedData) final; + + void removeLoadObserver(const std::shared_ptr& observer) final; + + void updateLoadObserverPreferredSize(const std::shared_ptr& observer, + int32_t preferredWidth, + int32_t preferredHeight) final; + + std::optional getResolvedLocation() const final; + +private: + FlatMap> _assetsByColorPalette; + Ref _representativeAsset; +}; + +} // namespace Valdi diff --git a/valdi/src/valdi/runtime/Runtime.cpp b/valdi/src/valdi/runtime/Runtime.cpp index abccb8446..4b243aa08 100644 --- a/valdi/src/valdi/runtime/Runtime.cpp +++ b/valdi/src/valdi/runtime/Runtime.cpp @@ -123,7 +123,7 @@ Runtime::Runtime(AttributeIds& attributeIds, const Shared& resourceLoader, const Ref& assetLoaderManager, const Holder>& requestManager, - const Ref& colorPalette, + const Ref& colorPaletteManager, const Ref& diskCache, const std::shared_ptr& yogaConfig, const Shared& runtimeMessageHandler, @@ -146,7 +146,7 @@ Runtime::Runtime(AttributeIds& attributeIds, _contextManager(logger, this), _viewNodeManager(*mainThreadManager, *logger), _mainThreadManager(mainThreadManager), - _colorPalette(colorPalette), + _colorPaletteManager(colorPaletteManager), _diskCache(diskCache), _userSession(userSession), _requestManager(requestManager), @@ -213,7 +213,8 @@ void Runtime::postInit() { _diskCache, _workerQueue, _userSession, _keychain, *_logger, disablePersistentStoreEncryption())); } registerNativeModuleFactory(makeShared().toShared()); - registerNativeModuleFactory(makeShared(_colorPalette, *_logger).toShared()); + registerNativeModuleFactory( + makeShared(_colorPaletteManager, *_logger).toShared()); registerJavaScriptModuleFactory(makeShared(*_resourceManager, _workerQueue, *_logger)); registerJavaScriptModuleFactory(makeShared()); @@ -781,30 +782,24 @@ void Runtime::registerJavaScriptModuleFactory(const Ref _javaScriptRuntime->registerJavaScriptModuleFactory(moduleFactory); } -void Runtime::updateColorPalette(const Value& colorPaletteMap) { +void Runtime::configureColorPalette(const StringBox& name, const Value& colorPaletteMap) { if (colorPaletteMap.isMap()) { FlatMap colors; for (const auto& it : *colorPaletteMap.getMap()) { - auto colorResult = ValueConverter::toColor(*_colorPalette, it.second); - if (!colorResult) { - VALDI_ERROR(*_logger, "Failed to parse color '{}': {}", it.first, colorResult.error()); + auto colorValue = ValueConverter::toColorValue(it.second); + if (!colorValue) { + VALDI_ERROR(*_logger, "Failed to parse color '{}': {}", it.first, colorValue.error()); continue; } - - colors[it.first] = colorResult.value(); + colors[it.first] = Color(colorValue.value().toLong()); } - _colorPalette->updateColors(colors); + _colorPaletteManager->configureColorPalette(name, colors); } } -Value Runtime::getColorPalette() { - auto valueMap = Valdi::makeShared(); - for (const auto& [name, color] : _colorPalette->getColors()) { - (*valueMap)[name] = Valdi::Value(color.value); - } - - return Valdi::Value(std::move(valueMap)); +void Runtime::setActiveColorPalette(const StringBox& name) { + _colorPaletteManager->setActiveColorPalette(name); } void Runtime::onUncaughtJsError(const StringBox& moduleName, const Error& error) { diff --git a/valdi/src/valdi/runtime/Runtime.hpp b/valdi/src/valdi/runtime/Runtime.hpp index 6e79df373..d70efc309 100644 --- a/valdi/src/valdi/runtime/Runtime.hpp +++ b/valdi/src/valdi/runtime/Runtime.hpp @@ -66,7 +66,7 @@ class RenderViewNodeRequest; using SharedRuntime = Ref; class ViewManagerContext; -class ColorPalette; +class ColorPaletteManager; class AssetLoaderManager; class ITweakValueProvider; class JavaScriptANRDetector; @@ -89,7 +89,7 @@ class Runtime final : public IDebuggerServiceListener, IJavaScriptRuntimeListene const Shared& resourceLoader, const Ref& assetLoaderManager, const Holder>& requestManager, - const Ref& colorPalette, + const Ref& colorPaletteManager, const Ref& diskCache, const std::shared_ptr& yogaConfig, const Shared& runtimeMessageHandler, @@ -266,8 +266,8 @@ class Runtime final : public IDebuggerServiceListener, IJavaScriptRuntimeListene void emitInitMetrics(); - void updateColorPalette(const Value& colorPaletteMap) override; - Value getColorPalette(); + void configureColorPalette(const StringBox& name, const Value& colorPaletteMap) override; + void setActiveColorPalette(const StringBox& name) override; const Ref& getDiskCache() const; @@ -314,7 +314,7 @@ class Runtime final : public IDebuggerServiceListener, IJavaScriptRuntimeListene ContextManager _contextManager; ViewNodeTreeManager _viewNodeManager; Ref _mainThreadManager; - Ref _colorPalette; + Ref _colorPaletteManager; Ref _diskCache; SharedAtomicObject _userSession; const Holder> _requestManager; diff --git a/valdi/src/valdi/runtime/RuntimeManager.cpp b/valdi/src/valdi/runtime/RuntimeManager.cpp index 714d20784..d06638a49 100644 --- a/valdi/src/valdi/runtime/RuntimeManager.cpp +++ b/valdi/src/valdi/runtime/RuntimeManager.cpp @@ -127,7 +127,7 @@ RuntimeManager::RuntimeManager(const Ref& mainThreadDispa _diskCache(diskCache), _keychain(std::move(keychain)), _runtimeMessageHandler(runtimeMessageHandler), - _colorPalette(makeShared()), + _colorPaletteManager(makeShared()), _platformType(platformType), _jsThreadQoS(jsThreadQoS), _debuggerServiceEnabled(_debuggerService != nullptr) { @@ -138,7 +138,7 @@ RuntimeManager::RuntimeManager(const Ref& mainThreadDispa _anrDetector->setListener(makeShared(runtimeMessageHandler)); } - _colorPalette->setListener(this); + _colorPaletteManager->setListener(this); } RuntimeManager::~RuntimeManager() { @@ -185,7 +185,7 @@ SharedRuntime RuntimeManager::createRuntime(const Shared& resou resourceLoader, _assetLoaderManager, _requestManager, - _colorPalette, + _colorPaletteManager, _diskCache, _yogaConfig, _runtimeMessageHandler, @@ -260,7 +260,7 @@ Ref RuntimeManager::createViewManagerContext( auto viewManagerContext = makeShared( viewManager, _attributeIds, - _colorPalette, + _colorPaletteManager, _yogaConfig, enablePreloading, mainThreadManagerOverride != nullptr ? mainThreadManagerOverride : _mainThreadManager, @@ -459,60 +459,28 @@ void RuntimeManager::setApplicationId(const StringBox& applicationId) { } } -void RuntimeManager::onColorPaletteUpdated(const ColorPalette& /*colorPalette*/) { - FlatSet attributesToReapply; - - // Clear the cache for all color attributes - for (const auto& viewManagerContext : _viewManagerContexts) { - for (const auto& it : viewManagerContext->getAttributesManager().getAllBoundAttributes()) { - for (const auto& handlerIt : it.second->getHandlers()) { - auto* handler = it.second->getAttributeHandlerForId(handlerIt.first); - - if (handler->shouldReevaluateOnColorPaletteChange()) { - handler->clearPreprocessorCache(); - attributesToReapply.insert(handlerIt.first); - } - } - } - } - - // Reapply all the color attributes - auto allAttributes = makeShared>(); - allAttributes->insert(allAttributes->end(), attributesToReapply.begin(), attributesToReapply.end()); - +void RuntimeManager::onColorPaletteManagerUpdated(const ColorPaletteManager& colorPaletteManager, + const ColorPalette& colorPalette, + bool activeColorPaletteChanged) { #if VALDI_DEBUG_TREE_UPDATES - std::string applyTrigger = "apply_attributes"; - if (!_viewManagerContexts.empty() && !attributesToReapply.empty()) { - const auto& attributeIds = _viewManagerContexts.front()->getAttributesManager().getAttributeIds(); - std::string names; - size_t count = 0; - constexpr size_t kMaxNames = 12; - constexpr size_t kMaxLen = 80; - for (AttributeId id : attributesToReapply) { - if (count >= kMaxNames || (count > 0 && names.size() >= kMaxLen)) { - names += ",..."; - break; - } - if (count != 0) { - names += ","; - } - names += attributeIds.getNameForId(id).slowToString(); - ++count; - } - if (!names.empty()) { - applyTrigger += ":"; - applyTrigger += names; - } - } + std::string applyTrigger = "apply_color_attributes"; for (const auto& runtime : getAllRuntimes()) { for (const auto& tree : runtime->getViewNodeTreeManager().getAllRootViewNodeTrees()) { tree->scheduleExclusiveUpdate( - [treePtr = tree.get(), allAttributes]() { + [treePtr = tree.get(), + colorPaletteRef = activeColorPaletteChanged ? colorPaletteManager.getActiveColorPalette() : nullptr, + colorPalettePtr = &colorPalette, + activeColorPaletteChanged]() { auto rootViewNode = treePtr->getRootViewNode(); if (rootViewNode != nullptr) { - rootViewNode->reapplyAttributesRecursive( - treePtr->getCurrentViewTransactionScope(), *allAttributes, false); + if (activeColorPaletteChanged) { + rootViewNode->setInheritedColorPalette(treePtr->getCurrentViewTransactionScope(), + colorPaletteRef); + } else { + rootViewNode->onColorPaletteMutated(treePtr->getCurrentViewTransactionScope(), + *colorPalettePtr); + } } }, Valdi::DispatchFunction(), @@ -523,11 +491,19 @@ void RuntimeManager::onColorPaletteUpdated(const ColorPalette& /*colorPalette*/) for (const auto& runtime : getAllRuntimes()) { for (const auto& tree : runtime->getViewNodeTreeManager().getAllRootViewNodeTrees()) { tree->scheduleExclusiveUpdate( - [treePtr = tree.get(), allAttributes]() { + [treePtr = tree.get(), + colorPaletteRef = activeColorPaletteChanged ? colorPaletteManager.getActiveColorPalette() : nullptr, + colorPalettePtr = &colorPalette, + activeColorPaletteChanged]() { auto rootViewNode = treePtr->getRootViewNode(); if (rootViewNode != nullptr) { - rootViewNode->reapplyAttributesRecursive( - treePtr->getCurrentViewTransactionScope(), *allAttributes, false); + if (activeColorPaletteChanged) { + rootViewNode->setInheritedColorPalette(treePtr->getCurrentViewTransactionScope(), + colorPaletteRef); + } else { + rootViewNode->onColorPaletteMutated(treePtr->getCurrentViewTransactionScope(), + *colorPalettePtr); + } } }, Valdi::DispatchFunction()); diff --git a/valdi/src/valdi/runtime/RuntimeManager.hpp b/valdi/src/valdi/runtime/RuntimeManager.hpp index 861ffc6eb..d7d7c3a07 100644 --- a/valdi/src/valdi/runtime/RuntimeManager.hpp +++ b/valdi/src/valdi/runtime/RuntimeManager.hpp @@ -61,7 +61,7 @@ class IRuntimeManagerListener : public SimpleRefCountable { virtual void onRuntimeCreated(Runtime& runtime) = 0; }; -class RuntimeManager : public ValdiObject, protected ColorPaletteListener { +class RuntimeManager : public ValdiObject, protected ColorPaletteManagerListener { public: RuntimeManager(const Ref& mainThreadDispatcher, IJavaScriptBridge* jsBridge, @@ -163,7 +163,9 @@ class RuntimeManager : public ValdiObject, protected ColorPaletteListener { VALDI_CLASS_HEADER(RuntimeManager) protected: - void onColorPaletteUpdated(const ColorPalette& colorPalette) override; + void onColorPaletteManagerUpdated(const ColorPaletteManager& colorPaletteManager, + const ColorPalette& colorPalette, + bool activeColorPaletteChanged) override; private: std::shared_ptr _initStopWatch; @@ -194,7 +196,7 @@ class RuntimeManager : public ValdiObject, protected ColorPaletteListener { std::vector _registeredTypeConverters; std::vector> _listeners; - Ref _colorPalette; + Ref _colorPaletteManager; Ref _attributionResolver; Holder> _userSession; Ref _runtimeTweaks; diff --git a/valdi/test/benchmark/ViewNode_benchmark.cpp b/valdi/test/benchmark/ViewNode_benchmark.cpp index 5ef298978..b29a542e2 100644 --- a/valdi/test/benchmark/ViewNode_benchmark.cpp +++ b/valdi/test/benchmark/ViewNode_benchmark.cpp @@ -23,7 +23,7 @@ struct Dependencies { Dependencies() : mainQueue(), mainThreadManager(mainQueue), viewManager(), attributeIds() { viewManagerContext = makeShared(viewManager, attributeIds, - makeShared(), + makeShared(), Yoga::createConfig(0), true, mainThreadManager, diff --git a/valdi/test/integration/Runtime_tests.cpp b/valdi/test/integration/Runtime_tests.cpp index 6fbb33735..0507cfde0 100644 --- a/valdi/test/integration/Runtime_tests.cpp +++ b/valdi/test/integration/Runtime_tests.cpp @@ -5319,6 +5319,106 @@ TEST_P(RuntimeFixture, supportsPlatformSpecificAsset) { ASSERT_EQ(iOSAssetUrl, asset->getIdentifier()); } +TEST_P(RuntimeFixture, supportsThemableAsset) { + auto lightAssetUrl = STRING_LITERAL("file://light.png"); + auto darkAssetUrl = STRING_LITERAL("file://dark.png"); + wrapper.diskCache->store(Path(URL(lightAssetUrl).getPath()), BytesView()).ensureSuccess(); + wrapper.diskCache->store(Path(URL(darkAssetUrl).getPath()), BytesView()).ensureSuccess(); + + auto viewModel = Value() + .setMapValue("lightAsset", Value(lightAssetUrl)) + .setMapValue("darkAsset", Value(darkAssetUrl)) + .setMapValue("includeDarkAsset", Value(true)); + + auto tree = + wrapper.createViewNodeTreeAndContext(STRING_LITERAL("ThemableAsset@test/src/ThemableAsset"), viewModel, Value()); + + tree->setLayoutSpecs(Size(1.0f, 1.0f), LayoutDirectionLTR); + + wrapper.waitUntilAllUpdatesCompleted(); + + auto rootNode = tree->getRootViewNode(); + ASSERT_TRUE(rootNode != nullptr); + + auto rootAsset = getSrcAssetFromNode(*rootNode->getChildAt(0)); + ASSERT_TRUE(rootAsset != nullptr); + ASSERT_EQ(lightAssetUrl, rootAsset->getIdentifier()); + + auto overriddenAsset = getSrcAssetFromNode(*rootNode->getChildAt(1)->getChildAt(0)); + ASSERT_TRUE(overriddenAsset != nullptr); + ASSERT_EQ(darkAssetUrl, overriddenAsset->getIdentifier()); + + auto nestedAsset = getSrcAssetFromNode(*rootNode->getChildAt(2)); + ASSERT_TRUE(nestedAsset != nullptr); + ASSERT_EQ(lightAssetUrl, nestedAsset->getIdentifier()); +} + +TEST_P(RuntimeFixture, canSwitchActiveColorPaletteForThemableAsset) { + auto lightAssetUrl = STRING_LITERAL("file://light.png"); + auto darkAssetUrl = STRING_LITERAL("file://dark.png"); + wrapper.diskCache->store(Path(URL(lightAssetUrl).getPath()), BytesView()).ensureSuccess(); + wrapper.diskCache->store(Path(URL(darkAssetUrl).getPath()), BytesView()).ensureSuccess(); + + auto viewModel = Value() + .setMapValue("lightAsset", Value(lightAssetUrl)) + .setMapValue("darkAsset", Value(darkAssetUrl)) + .setMapValue("includeDarkAsset", Value(true)); + + auto tree = + wrapper.createViewNodeTreeAndContext(STRING_LITERAL("ThemableAsset@test/src/ThemableAsset"), viewModel, Value()); + + tree->setLayoutSpecs(Size(1.0f, 1.0f), LayoutDirectionLTR); + + wrapper.waitUntilAllUpdatesCompleted(); + + wrapper.runtime->getJavaScriptRuntime()->callComponentFunction(tree->getContext(), + STRING_LITERAL("setDarkColorPalette")); + + wrapper.flushQueues(); + + auto rootNode = tree->getRootViewNode(); + ASSERT_TRUE(rootNode != nullptr); + + auto asset = getSrcAssetFromNode(*rootNode->getChildAt(0)); + ASSERT_TRUE(asset != nullptr); + ASSERT_EQ(darkAssetUrl, asset->getIdentifier()); + + auto nestedAsset = getSrcAssetFromNode(*rootNode->getChildAt(2)); + ASSERT_TRUE(nestedAsset != nullptr); + ASSERT_EQ(darkAssetUrl, nestedAsset->getIdentifier()); +} + +TEST_P(RuntimeFixture, missingThemableAssetPaletteClearsAsset) { + auto lightAssetUrl = STRING_LITERAL("file://light.png"); + auto darkAssetUrl = STRING_LITERAL("file://dark.png"); + wrapper.diskCache->store(Path(URL(lightAssetUrl).getPath()), BytesView()).ensureSuccess(); + wrapper.diskCache->store(Path(URL(darkAssetUrl).getPath()), BytesView()).ensureSuccess(); + + auto viewModel = Value() + .setMapValue("lightAsset", Value(lightAssetUrl)) + .setMapValue("darkAsset", Value(darkAssetUrl)) + .setMapValue("includeDarkAsset", Value(false)); + + auto tree = + wrapper.createViewNodeTreeAndContext(STRING_LITERAL("ThemableAsset@test/src/ThemableAsset"), viewModel, Value()); + + tree->setLayoutSpecs(Size(1.0f, 1.0f), LayoutDirectionLTR); + + wrapper.waitUntilAllUpdatesCompleted(); + + wrapper.runtime->getJavaScriptRuntime()->callComponentFunction(tree->getContext(), + STRING_LITERAL("setDarkColorPalette")); + + wrapper.flushQueues(); + + auto rootNode = tree->getRootViewNode(); + ASSERT_TRUE(rootNode != nullptr); + + ASSERT_EQ(nullptr, getSrcAssetFromNode(*rootNode->getChildAt(0))); + ASSERT_EQ(nullptr, getSrcAssetFromNode(*rootNode->getChildAt(1)->getChildAt(0))); + ASSERT_EQ(nullptr, getSrcAssetFromNode(*rootNode->getChildAt(2))); +} + TEST_P(RuntimeFixture, canHotReloadAsset) { auto assets = registerAssets(wrapper); @@ -7549,6 +7649,19 @@ TEST_P(RuntimeFixture, supportsNotifyWithUncaughtErrorHandler) { ASSERT_TRUE(result.isError()); } +static DummyView makeColorPaletteTestView(int64_t borderColor, int64_t backgroundColor) { + return DummyView("SCValdiView") + .addAttribute("border", Value(ValueArray::make({Value(1.0), Value(borderColor)}))) + .addChild(DummyView("SCValdiView") + .addAttribute("background", + Value(ValueArray::make({ + Value(ValueArray::make({Value(backgroundColor)})), + Value(ValueArray::make({})), + Value(static_cast(0)), + Value(false), + })))); +} + TEST_P(RuntimeFixture, supportsCustomColorPalette) { auto tree = wrapper.createViewNodeTreeAndContext(STRING_LITERAL("ColorPaletteTest@test/src/ColorPaletteTest"), Value(makeShared()), @@ -7556,20 +7669,10 @@ TEST_P(RuntimeFixture, supportsCustomColorPalette) { wrapper.waitUntilAllUpdatesCompleted(); - ASSERT_EQ(DummyView("SCValdiView") - .addAttribute("border", Value(ValueArray::make({Value(1.0), Value(65535)}))) - .addChild(DummyView("SCValdiView") - .addAttribute("background", - Value(ValueArray::make({ - Value(ValueArray::make({Value(8388863)})), - Value(ValueArray::make({})), - Value(static_cast(0)), - Value(false), - })))), - getRootView(tree)); + ASSERT_EQ(makeColorPaletteTestView(65535, 8388863), getRootView(tree)); } -TEST_P(RuntimeFixture, canUpdateCustomColorPalette) { +TEST_P(RuntimeFixture, canSwitchActiveCustomColorPalette) { auto tree = wrapper.createViewNodeTreeAndContext(STRING_LITERAL("ColorPaletteTest@test/src/ColorPaletteTest"), Value(makeShared()), Value::undefined()); @@ -7577,22 +7680,150 @@ TEST_P(RuntimeFixture, canUpdateCustomColorPalette) { wrapper.waitUntilAllUpdatesCompleted(); wrapper.runtime->getJavaScriptRuntime()->callComponentFunction(tree->getContext(), - STRING_LITERAL("updateColorPalette")); + STRING_LITERAL("setDarkColorPalette")); wrapper.flushQueues(); - ASSERT_EQ( - DummyView("SCValdiView") - .addAttribute("border", Value(ValueArray::make({Value(1.0), Value(static_cast(4278190335))}))) - .addChild(DummyView("SCValdiView") - .addAttribute("background", - Value(ValueArray::make({ - Value(ValueArray::make({Value(static_cast(4294902015))})), - Value(ValueArray::make({})), - Value(static_cast(0)), - Value(false), - })))), - getRootView(tree)); + ASSERT_EQ(makeColorPaletteTestView(4278190335, 4294902015), getRootView(tree)); +} + +TEST_P(RuntimeFixture, doesNotReapplyCustomColorPaletteWhenInactivePaletteChanges) { + auto tree = wrapper.createViewNodeTreeAndContext(STRING_LITERAL("ColorPaletteTest@test/src/ColorPaletteTest"), + Value(makeShared()), + Value::undefined()); + + wrapper.waitUntilAllUpdatesCompleted(); + + wrapper.runtime->getJavaScriptRuntime()->callComponentFunction(tree->getContext(), + STRING_LITERAL("updateDarkColorPalette")); + + wrapper.flushQueues(); + + ASSERT_EQ(makeColorPaletteTestView(65535, 8388863), getRootView(tree)); +} + +TEST_P(RuntimeFixture, reappliesCustomColorPaletteWhenActivePaletteChanges) { + auto tree = wrapper.createViewNodeTreeAndContext(STRING_LITERAL("ColorPaletteTest@test/src/ColorPaletteTest"), + Value(makeShared()), + Value::undefined()); + + wrapper.waitUntilAllUpdatesCompleted(); + + wrapper.runtime->getJavaScriptRuntime()->callComponentFunction(tree->getContext(), + STRING_LITERAL("updateLightColorPalette")); + + wrapper.flushQueues(); + + ASSERT_EQ(makeColorPaletteTestView(4278190335, 4294902015), getRootView(tree)); +} + +static DummyView makeColorPaletteOverrideTestView(int64_t rootBackgroundColor, + int64_t overrideBackgroundColor, + int64_t overrideChildBackgroundColor, + int64_t siblingBackgroundColor) { + auto makeBackground = [](int64_t color) { + return Value(ValueArray::make({ + Value(ValueArray::make({Value(color)})), + Value(ValueArray::make({})), + Value(static_cast(0)), + Value(false), + })); + }; + + return DummyView("SCValdiView") + .addAttribute("background", makeBackground(rootBackgroundColor)) + .addChild( + DummyView("SCValdiView") + .addAttribute("background", makeBackground(overrideBackgroundColor)) + .addChild( + DummyView("SCValdiView").addAttribute("background", makeBackground(overrideChildBackgroundColor)))) + .addChild(DummyView("SCValdiView").addAttribute("background", makeBackground(siblingBackgroundColor))); +} + +TEST_P(RuntimeFixture, supportsPerViewNodeColorPaletteOverride) { + auto tree = wrapper.createViewNodeTreeAndContext( + STRING_LITERAL("ColorPaletteOverrideTest@test/src/ColorPaletteOverrideTest"), + Value(makeShared()), + Value::undefined()); + + wrapper.waitUntilAllUpdatesCompleted(); + + ASSERT_EQ(makeColorPaletteOverrideTestView(65535, 4278190335, 4294902015, 8388863), getRootView(tree)); +} + +TEST_P(RuntimeFixture, switchingActiveColorPaletteDoesNotChangeOverriddenSubtree) { + auto tree = wrapper.createViewNodeTreeAndContext( + STRING_LITERAL("ColorPaletteOverrideTest@test/src/ColorPaletteOverrideTest"), + Value(makeShared()), + Value::undefined()); + + wrapper.waitUntilAllUpdatesCompleted(); + + wrapper.runtime->getJavaScriptRuntime()->callComponentFunction(tree->getContext(), + STRING_LITERAL("setDarkActiveColorPalette")); + + wrapper.flushQueues(); + + ASSERT_EQ(makeColorPaletteOverrideTestView(4278190335, 4278190335, 4294902015, 4294902015), getRootView(tree)); +} + +TEST_P(RuntimeFixture, clearingRootColorPaletteOverrideFallsBackToActivePalette) { + auto tree = wrapper.createViewNodeTreeAndContext( + STRING_LITERAL("ColorPaletteOverrideTest@test/src/ColorPaletteOverrideTest"), + Value(makeShared()), + Value::undefined()); + + wrapper.waitUntilAllUpdatesCompleted(); + + auto root = tree->getRootViewNode(); + ASSERT_NE(nullptr, root); + ASSERT_EQ(STRING_LITERAL("light"), root->getResolvedColorPalette()->getName()); + + tree->scheduleExclusiveUpdate([&]() { + root->setColorPaletteName(tree->getCurrentViewTransactionScope(), STRING_LITERAL("dark")); + }); + wrapper.flushQueues(); + ASSERT_EQ(STRING_LITERAL("dark"), root->getResolvedColorPalette()->getName()); + + tree->scheduleExclusiveUpdate([&]() { + root->setColorPaletteName(tree->getCurrentViewTransactionScope(), StringBox()); + }); + wrapper.flushQueues(); + + ASSERT_NE(nullptr, root->getResolvedColorPalette()); + ASSERT_EQ(STRING_LITERAL("light"), root->getResolvedColorPalette()->getName()); +} + +TEST_P(RuntimeFixture, mutatingOverriddenColorPaletteUpdatesOverriddenSubtree) { + auto tree = wrapper.createViewNodeTreeAndContext( + STRING_LITERAL("ColorPaletteOverrideTest@test/src/ColorPaletteOverrideTest"), + Value(makeShared()), + Value::undefined()); + + wrapper.waitUntilAllUpdatesCompleted(); + + wrapper.runtime->getJavaScriptRuntime()->callComponentFunction(tree->getContext(), + STRING_LITERAL("updateDarkColorPalette")); + + wrapper.flushQueues(); + + ASSERT_EQ(makeColorPaletteOverrideTestView(65535, 255, 4294967295, 8388863), getRootView(tree)); +} + +TEST_P(RuntimeFixture, mutatingUnusedColorPaletteDoesNotChangeRenderedAttributes) { + auto tree = wrapper.createViewNodeTreeAndContext( + STRING_LITERAL("ColorPaletteOverrideTest@test/src/ColorPaletteOverrideTest"), + Value(makeShared()), + Value::undefined()); + + wrapper.waitUntilAllUpdatesCompleted(); + + wrapper.runtime->getJavaScriptRuntime()->callComponentFunction(tree->getContext(), + STRING_LITERAL("updateUnusedColorPalette")); + + wrapper.flushQueues(); + + ASSERT_EQ(makeColorPaletteOverrideTestView(65535, 4278190335, 4294902015, 8388863), getRootView(tree)); } static Result postprocessArrayValueToLength(ViewNode& viewNode, const Value& in) { diff --git a/valdi/test/runtime/AttributeParser_tests.cpp b/valdi/test/runtime/AttributeParser_tests.cpp index 8103801d3..82fd4edb5 100644 --- a/valdi/test/runtime/AttributeParser_tests.cpp +++ b/valdi/test/runtime/AttributeParser_tests.cpp @@ -15,7 +15,7 @@ using namespace Valdi; namespace ValdiTest { TEST(AttributeParser, canParseColor) { - ColorPalette colorPalette; + ColorPalette colorPalette(STRING_LITERAL("test")); AttributeParser parser("red rgba(0, 255, 0, 1.0) #0000ff #0000ff10 #f5a rgba(50, 25, 100, 0.5) rgba(25%, 10%, 5%, " "0.1) rgba(255, 255, 255, 5%)"); @@ -86,7 +86,7 @@ TEST(AttributeParser, canParseColor) { } TEST(AttributeParser, failsToParseColorOnColorPaletteMismatch) { - ColorPalette colorPalette; + ColorPalette colorPalette(STRING_LITERAL("test")); AttributeParser parser("reddish"); diff --git a/valdi/test/runtime/AttributeProcessors_tests.cpp b/valdi/test/runtime/AttributeProcessors_tests.cpp index 929b763e0..bc6da57b1 100644 --- a/valdi/test/runtime/AttributeProcessors_tests.cpp +++ b/valdi/test/runtime/AttributeProcessors_tests.cpp @@ -1,89 +1,115 @@ #include "valdi/runtime/Attributes/DefaultAttributeProcessors.hpp" #include "valdi/runtime/Attributes/ValueConverters.hpp" -#include "valdi_core/cpp/Attributes/AttributeUtils.hpp" +#include "valdi_core/cpp/Attributes/ColorPalette.hpp" #include "valdi_core/cpp/Utils/StringCache.hpp" #include "valdi_core/cpp/Utils/ValueArray.hpp" #include "gtest/gtest.h" #include +#include using namespace Valdi; namespace ValdiTest { -static auto kColorPalette = makeShared(); - -static Value makeGradientValue(std::vector colors, std::vector locations, int32_t angle, bool radial) { +template +static Value makeGradientValueImpl(const ColorsT& colors, + std::initializer_list locations, + int32_t angle, + bool radial) { auto outColors = ValueArray::make(colors.size()); auto outLocations = ValueArray::make(locations.size()); - for (size_t i = 0; i < colors.size(); i++) { - outColors->emplace(i, Value(colors[i].value)); + size_t i = 0; + for (const auto& color : colors) { + outColors->emplace(i++, Value(color)); } - for (size_t i = 0; i < locations.size(); i++) { - outLocations->emplace(i, Value(locations[i])); + + i = 0; + for (auto location : locations) { + outLocations->emplace(i++, Value(location)); } return Value(ValueArray::make({Value(outColors), Value(outLocations), Value(angle), Value(radial)})); } +static Value makeGradientValue(std::initializer_list colors, + std::initializer_list locations, + int32_t angle, + bool radial) { + return makeGradientValueImpl(colors, locations, angle, radial); +} + +static Value makeGradientValue(std::initializer_list colors, + std::initializer_list locations, + int32_t angle, + bool radial) { + return makeGradientValueImpl(colors, locations, angle, radial); +} + +static Ref makeTestColorPalette() { + auto colorPalette = makeShared(STRING_LITERAL("default")); + colorPalette->updateColors({ + {STRING_LITERAL("primary"), Color(0x11223344)}, + {STRING_LITERAL("secondary"), Color(0x55667788)}, + }); + return colorPalette; +} + TEST(AttributeProcessor, canParseSimpleBackground) { - auto result = preprocessGradient(kColorPalette, Value(STRING_LITERAL("red"))); + auto colorPalette = makeTestColorPalette(); - ASSERT_TRUE(result.success()) << result.description(); + auto result = preprocessGradient(Value(STRING_LITERAL("red"))); - ASSERT_EQ(makeGradientValue({Color::rgba(255, 0, 0, 1.0)}, {}, 0, false), result.value()); + ASSERT_TRUE(result.success()) << result.description(); - auto ltrResult = postprocessGradient(false, result.value()); - ASSERT_TRUE(ltrResult) << ltrResult.description(); - ASSERT_EQ(result.value(), ltrResult.value()); + ASSERT_EQ(makeGradientValue({STRING_LITERAL("red")}, {}, 0, false), result.value()); - auto rtlResult = postprocessGradient(true, result.value()); - ASSERT_TRUE(rtlResult) << rtlResult.description(); - ASSERT_EQ(result.value(), rtlResult.value()); + auto postprocessed = postprocessGradient(false, *colorPalette, result.value()); + ASSERT_TRUE(postprocessed) << postprocessed.description(); + ASSERT_EQ(makeGradientValue({0xFF0000FF}, {}, 0, false), postprocessed.value()); } TEST(AttributeProcessor, failsOnTrailingInvalidKeyword) { - auto result = preprocessGradient(kColorPalette, Value(STRING_LITERAL("red wtf"))); + auto result = preprocessGradient(Value(STRING_LITERAL("red wtf"))); ASSERT_FALSE(result.success()) << result.description(); } TEST(AttributeProcessor, canParseSimpleLinearGradient) { - auto result = preprocessGradient(kColorPalette, Value(STRING_LITERAL("linear-gradient(blue, white, red)"))); + auto colorPalette = makeTestColorPalette(); + + auto result = preprocessGradient(Value(STRING_LITERAL("linear-gradient(blue, white, red)"))); ASSERT_TRUE(result.success()) << result.description(); ASSERT_EQ(makeGradientValue( { - Color::rgba(0, 0, 255, 1.0), - Color::rgba(255, 255, 255, 1.0), - Color::rgba(255, 0, 0, 1.0), + STRING_LITERAL("blue"), + STRING_LITERAL("white"), + STRING_LITERAL("red"), }, {}, 0, false), result.value()); - auto ltrResult = postprocessGradient(false, result.value()); - ASSERT_TRUE(ltrResult) << ltrResult.description(); - ASSERT_EQ(result.value(), ltrResult.value()); - - auto rtlResult = postprocessGradient(true, result.value()); - ASSERT_TRUE(rtlResult) << rtlResult.description(); - ASSERT_EQ(result.value(), rtlResult.value()); + auto postprocessed = postprocessGradient(false, *colorPalette, result.value()); + ASSERT_TRUE(postprocessed) << postprocessed.description(); + ASSERT_EQ(makeGradientValue({0x0000FFFF, 0xFFFFFFFF, 0xFF0000FF}, {}, 0, false), postprocessed.value()); } TEST(AttributeProcessor, canParseLinearGradientWithLocations) { - auto result = - preprocessGradient(kColorPalette, Value(STRING_LITERAL("linear-gradient(blue 0, white 0.25, red 0.75)"))); + auto colorPalette = makeTestColorPalette(); + + auto result = preprocessGradient(Value(STRING_LITERAL("linear-gradient(blue 0, white 0.25, red 0.75)"))); ASSERT_TRUE(result.success()) << result.description(); ASSERT_EQ(makeGradientValue( { - Color::rgba(0, 0, 255, 1.0), - Color::rgba(255, 255, 255, 1.0), - Color::rgba(255, 0, 0, 1.0), + STRING_LITERAL("blue"), + STRING_LITERAL("white"), + STRING_LITERAL("red"), }, { 0.0, @@ -94,125 +120,99 @@ TEST(AttributeProcessor, canParseLinearGradientWithLocations) { false), result.value()); - auto ltrResult = postprocessGradient(false, result.value()); - ASSERT_TRUE(ltrResult) << ltrResult.description(); - ASSERT_EQ(result.value(), ltrResult.value()); - - auto rtlResult = postprocessGradient(true, result.value()); - ASSERT_TRUE(rtlResult) << rtlResult.description(); - ASSERT_EQ(result.value(), rtlResult.value()); + auto postprocessed = postprocessGradient(false, *colorPalette, result.value()); + ASSERT_TRUE(postprocessed) << postprocessed.description(); + ASSERT_EQ(makeGradientValue({0x0000FFFF, 0xFFFFFFFF, 0xFF0000FF}, {0.0, 0.25, 0.75}, 0, false), + postprocessed.value()); } TEST(AttributeProcessor, failsWhenLinearGradientWithLocationsIsNotBalanced) { - auto result = preprocessGradient(kColorPalette, Value(STRING_LITERAL("linear-gradient(blue 0, white 0.25, red)"))); + auto result = preprocessGradient(Value(STRING_LITERAL("linear-gradient(blue 0, white 0.25, red)"))); ASSERT_FALSE(result.success()) << result.description(); } TEST(AttributeProcessor, canParseAngleInLinearGradient) { - auto result = preprocessGradient(kColorPalette, Value(STRING_LITERAL("linear-gradient(45deg, blue, white, red)"))); + auto colorPalette = makeTestColorPalette(); + + auto result = preprocessGradient(Value(STRING_LITERAL("linear-gradient(45deg, blue, white, red)"))); ASSERT_TRUE(result.success()) << result.description(); ASSERT_EQ(makeGradientValue( { - Color::rgba(0, 0, 255, 1.0), - Color::rgba(255, 255, 255, 1.0), - Color::rgba(255, 0, 0, 1.0), + STRING_LITERAL("blue"), + STRING_LITERAL("white"), + STRING_LITERAL("red"), }, {}, 1, false), result.value()); - auto ltrResult = postprocessGradient(false, result.value()); - ASSERT_TRUE(ltrResult) << ltrResult.description(); - ASSERT_EQ(result.value(), ltrResult.value()); - - auto rtlResult = postprocessGradient(true, result.value()); - ASSERT_TRUE(rtlResult) << rtlResult.description(); - ASSERT_EQ(makeGradientValue( - { - Color::rgba(0, 0, 255, 1.0), - Color::rgba(255, 255, 255, 1.0), - Color::rgba(255, 0, 0, 1.0), - }, - {}, - 7, - false), - rtlResult.value()); + auto postprocessed = postprocessGradient(true, *colorPalette, result.value()); + ASSERT_TRUE(postprocessed) << postprocessed.description(); + ASSERT_EQ(makeGradientValue({0x0000FFFF, 0xFFFFFFFF, 0xFF0000FF}, {}, 7, false), postprocessed.value()); } TEST(AttributeProcessor, canParseAngleAsRadInLinearGradient) { - auto result = preprocessGradient(kColorPalette, Value(STRING_LITERAL("linear-gradient(1.6rad, blue, white, red)"))); + auto colorPalette = makeTestColorPalette(); + + auto result = preprocessGradient(Value(STRING_LITERAL("linear-gradient(1.6rad, blue, white, red)"))); ASSERT_TRUE(result.success()) << result.description(); ASSERT_EQ(makeGradientValue( { - Color::rgba(0, 0, 255, 1.0), - Color::rgba(255, 255, 255, 1.0), - Color::rgba(255, 0, 0, 1.0), + STRING_LITERAL("blue"), + STRING_LITERAL("white"), + STRING_LITERAL("red"), }, {}, 2, false), result.value()); - auto ltrResult = postprocessGradient(false, result.value()); - ASSERT_TRUE(ltrResult) << ltrResult.description(); - ASSERT_EQ(result.value(), ltrResult.value()); - - auto rtlResult = postprocessGradient(true, result.value()); - ASSERT_TRUE(rtlResult) << rtlResult.description(); - ASSERT_EQ(makeGradientValue( - { - Color::rgba(0, 0, 255, 1.0), - Color::rgba(255, 255, 255, 1.0), - Color::rgba(255, 0, 0, 1.0), - }, - {}, - 6, - false), - rtlResult.value()); + auto postprocessed = postprocessGradient(true, *colorPalette, result.value()); + ASSERT_TRUE(postprocessed) << postprocessed.description(); + ASSERT_EQ(makeGradientValue({0x0000FFFF, 0xFFFFFFFF, 0xFF0000FF}, {}, 6, false), postprocessed.value()); } TEST(AttributeProcessor, canParseSimpleRadialGradient) { - auto result = preprocessGradient(kColorPalette, Value(STRING_LITERAL("radial-gradient(blue, white, red)"))); + auto colorPalette = makeTestColorPalette(); + + auto result = preprocessGradient(Value(STRING_LITERAL("radial-gradient(blue, white, red)"))); ASSERT_TRUE(result.success()) << result.description(); ASSERT_EQ(makeGradientValue( { - Color::rgba(0, 0, 255, 1.0), - Color::rgba(255, 255, 255, 1.0), - Color::rgba(255, 0, 0, 1.0), + STRING_LITERAL("blue"), + STRING_LITERAL("white"), + STRING_LITERAL("red"), }, {}, 0, true), result.value()); - auto ltrResult = postprocessGradient(false, result.value()); - ASSERT_TRUE(ltrResult) << ltrResult.description(); - ASSERT_EQ(result.value(), ltrResult.value()); - - auto rtlResult = postprocessGradient(true, result.value()); - ASSERT_TRUE(rtlResult) << rtlResult.description(); - ASSERT_EQ(result.value(), rtlResult.value()); + auto postprocessed = postprocessGradient(false, *colorPalette, result.value()); + ASSERT_TRUE(postprocessed) << postprocessed.description(); + ASSERT_EQ(makeGradientValue({0x0000FFFF, 0xFFFFFFFF, 0xFF0000FF}, {}, 0, true), postprocessed.value()); } TEST(AttributeProcessor, canParseRadialGradientWithLocations) { - auto result = - preprocessGradient(kColorPalette, Value(STRING_LITERAL("radial-gradient(blue 0, white 0.25, red 0.75)"))); + auto colorPalette = makeTestColorPalette(); + + auto result = preprocessGradient(Value(STRING_LITERAL("radial-gradient(blue 0, white 0.25, red 0.75)"))); ASSERT_TRUE(result.success()) << result.description(); ASSERT_EQ(makeGradientValue( { - Color::rgba(0, 0, 255, 1.0), - Color::rgba(255, 255, 255, 1.0), - Color::rgba(255, 0, 0, 1.0), + STRING_LITERAL("blue"), + STRING_LITERAL("white"), + STRING_LITERAL("red"), }, { 0.0, @@ -223,24 +223,20 @@ TEST(AttributeProcessor, canParseRadialGradientWithLocations) { true), result.value()); - auto ltrResult = postprocessGradient(false, result.value()); - ASSERT_TRUE(ltrResult) << ltrResult.description(); - ASSERT_EQ(result.value(), ltrResult.value()); - - auto rtlResult = postprocessGradient(true, result.value()); - ASSERT_TRUE(rtlResult) << rtlResult.description(); - ASSERT_EQ(result.value(), rtlResult.value()); + auto postprocessed = postprocessGradient(false, *colorPalette, result.value()); + ASSERT_TRUE(postprocessed) << postprocessed.description(); + ASSERT_EQ(makeGradientValue({0x0000FFFF, 0xFFFFFFFF, 0xFF0000FF}, {0.0, 0.25, 0.75}, 0, true), + postprocessed.value()); } TEST(AttributeProcessor, failsWhenRadialGradientWithLocationsIsNotBalanced) { - auto result = preprocessGradient(kColorPalette, Value(STRING_LITERAL("radial-gradient(blue 0, white 0.25, red)"))); + auto result = preprocessGradient(Value(STRING_LITERAL("radial-gradient(blue 0, white 0.25, red)"))); ASSERT_FALSE(result.success()) << result.description(); } TEST(AttributeProcessor, failsWhenRadialGradientHasAngle) { - auto result = - preprocessGradient(kColorPalette, Value(STRING_LITERAL("radial-gradient(45deg, blue 0, white 0.25, red)"))); + auto result = preprocessGradient(Value(STRING_LITERAL("radial-gradient(45deg, blue 0, white 0.25, red)"))); ASSERT_FALSE(result.success()) << result.description(); } @@ -317,7 +313,7 @@ TEST(AttributeProcessor, failsParseBorderRadiusWithInvalidValues) { } TEST(AttributeProcessor, flipsHorizontalBordersOnRTL) { - auto result = preprocessBorderRadius(nullptr, Value(STRING_LITERAL("42 12 100 1337"))); + auto result = preprocessBorderRadius(Value(STRING_LITERAL("42 12 100 1337"))); ASSERT_TRUE(result) << result.description(); auto borderRadius = result.value().getTypedRef(); @@ -348,4 +344,19 @@ TEST(AttributeProcessor, flipsHorizontalBordersOnRTL) { ASSERT_EQ(borderRadius->getBottomRight(), rtlBorderRadius->getBottomLeft()); } +TEST(AttributeProcessor, postprocessGradientResolvesColors) { + auto colorPalette = makeTestColorPalette(); + + auto result = preprocessGradient(Value(STRING_LITERAL("linear-gradient(45deg, primary, secondary)"))); + ASSERT_TRUE(result.success()) << result.description(); + + ASSERT_EQ(makeGradientValue({STRING_LITERAL("primary"), STRING_LITERAL("secondary")}, {}, 1, false), + result.value()); + + auto postprocessed = postprocessGradient(false, *colorPalette, result.value()); + ASSERT_TRUE(postprocessed.success()) << postprocessed.description(); + + ASSERT_EQ(makeGradientValue({0x11223344, 0x55667788}, {}, 1, false), postprocessed.value()); +} + } // namespace ValdiTest diff --git a/valdi/test/runtime/ColorPaletteManager_tests.cpp b/valdi/test/runtime/ColorPaletteManager_tests.cpp new file mode 100644 index 000000000..5c2ef1b0d --- /dev/null +++ b/valdi/test/runtime/ColorPaletteManager_tests.cpp @@ -0,0 +1,89 @@ +#include "valdi_core/cpp/Attributes/ColorPalette.hpp" +#include "valdi_core/cpp/Utils/StringCache.hpp" +#include "gtest/gtest.h" + +using namespace Valdi; + +namespace ValdiTest { + +class TestColorPaletteManagerListener : public ColorPaletteManagerListener { +public: + void onColorPaletteManagerUpdated(const ColorPaletteManager& colorPaletteManager, + const ColorPalette& colorPalette, + bool activeColorPaletteChanged) override { + updateCount++; + lastActiveColorPaletteName = colorPaletteManager.getActiveColorPalette()->getName(); + lastUpdatedColorPaletteName = colorPalette.getName(); + lastActiveColorPaletteChanged = activeColorPaletteChanged; + } + + int updateCount = 0; + StringBox lastActiveColorPaletteName; + StringBox lastUpdatedColorPaletteName; + bool lastActiveColorPaletteChanged = false; +}; + +TEST(ColorPaletteManager, defaultsToDefaultPaletteWithCssColors) { + ColorPaletteManager manager; + + ASSERT_EQ(STRING_LITERAL("default"), manager.getActiveColorPalette()->getName()); + ASSERT_EQ(Color::rgba(255, 0, 0, 1.0), + manager.getActiveColorPalette()->getColorForName(STRING_LITERAL("red")).value()); +} + +TEST(ColorPaletteManager, notifiesWhenConfiguringInactivePaletteWithChangedValuesOnly) { + ColorPaletteManager manager; + TestColorPaletteManagerListener listener; + manager.setListener(&listener); + + manager.configureColorPalette(STRING_LITERAL("dark"), {{STRING_LITERAL("background"), Color::rgba(0, 0, 0, 1.0)}}); + manager.configureColorPalette(STRING_LITERAL("dark"), {{STRING_LITERAL("background"), Color::rgba(0, 0, 0, 1.0)}}); + + ASSERT_EQ(1, listener.updateCount); + ASSERT_EQ(STRING_LITERAL("default"), manager.getActiveColorPalette()->getName()); + ASSERT_EQ(STRING_LITERAL("dark"), listener.lastUpdatedColorPaletteName); + ASSERT_FALSE(listener.lastActiveColorPaletteChanged); +} + +TEST(ColorPaletteManager, notifiesWhenConfiguringActivePaletteWithChangedValuesOnly) { + ColorPaletteManager manager; + TestColorPaletteManagerListener listener; + manager.setListener(&listener); + + manager.configureColorPalette(STRING_LITERAL("default"), + {{STRING_LITERAL("background"), Color::rgba(255, 255, 255, 1.0)}}); + manager.configureColorPalette(STRING_LITERAL("default"), + {{STRING_LITERAL("background"), Color::rgba(255, 255, 255, 1.0)}}); + + ASSERT_EQ(1, listener.updateCount); + ASSERT_EQ(STRING_LITERAL("default"), listener.lastUpdatedColorPaletteName); + ASSERT_FALSE(listener.lastActiveColorPaletteChanged); + ASSERT_EQ(Color::rgba(255, 255, 255, 1.0), + manager.getActiveColorPalette()->getColorForName(STRING_LITERAL("background")).value()); +} + +TEST(ColorPaletteManager, notifiesWhenActivePaletteChangesOnly) { + ColorPaletteManager manager; + TestColorPaletteManagerListener listener; + manager.setListener(&listener); + + manager.setActiveColorPalette(STRING_LITERAL("dark")); + manager.setActiveColorPalette(STRING_LITERAL("dark")); + + ASSERT_EQ(1, listener.updateCount); + ASSERT_EQ(STRING_LITERAL("dark"), listener.lastActiveColorPaletteName); + ASSERT_EQ(STRING_LITERAL("dark"), listener.lastUpdatedColorPaletteName); + ASSERT_TRUE(listener.lastActiveColorPaletteChanged); +} + +TEST(ColorPaletteManager, createsDefaultInitializedPaletteWhenActivatingUnknownName) { + ColorPaletteManager manager; + + manager.setActiveColorPalette(STRING_LITERAL("dark")); + + ASSERT_EQ(STRING_LITERAL("dark"), manager.getActiveColorPalette()->getName()); + ASSERT_EQ(Color::rgba(255, 0, 0, 1.0), + manager.getActiveColorPalette()->getColorForName(STRING_LITERAL("red")).value()); +} + +} // namespace ValdiTest diff --git a/valdi/test/utils/ViewNodeTestsUtils.cpp b/valdi/test/utils/ViewNodeTestsUtils.cpp index ebbddd6e9..e0c57d78a 100644 --- a/valdi/test/utils/ViewNodeTestsUtils.cpp +++ b/valdi/test/utils/ViewNodeTestsUtils.cpp @@ -13,8 +13,11 @@ static Ref makeMainThreadManager(const Ref()), _mainThreadManager(makeMainThreadManager(_mainQueue->createMainThreadDispatcher())), - _attributesManager( - _viewManager, _attributeIds, makeShared(), ConsoleLogger::getLogger(), Yoga::createConfig(0)), + _attributesManager(_viewManager, + _attributeIds, + makeShared(), + ConsoleLogger::getLogger(), + Yoga::createConfig(0)), _viewTransactionScope(makeShared(&_viewManager, nullptr, false)) { _mainThreadManager->markCurrentThreadIsMainThread(); @@ -49,8 +52,10 @@ ViewTransactionScope& ViewNodeTestsDependencies::getViewTransactionScope() { } Ref ViewNodeTestsDependencies::createNode(const char* viewClassName) { - auto viewNode = Valdi::makeShared( - _attributesManager.getYogaConfig(), _attributesManager.getAttributeIds(), _attributesManager.getLogger()); + auto viewNode = Valdi::makeShared(_attributesManager.getYogaConfig(), + _attributesManager.getAttributeIds(), + _attributesManager.getColorPaletteManager()->getActiveColorPalette(), + _attributesManager.getLogger()); viewNode->setViewNodeTree(_tree.get()); viewNode->setViewFactory(*_viewTransactionScope, _viewFactories->getViewFactory(STRING_LITERAL(viewClassName))); diff --git a/valdi/testdata/resources/modules/test/src/ColorPaletteOverrideTest.tsx b/valdi/testdata/resources/modules/test/src/ColorPaletteOverrideTest.tsx new file mode 100644 index 000000000..740d3ea27 --- /dev/null +++ b/valdi/testdata/resources/modules/test/src/ColorPaletteOverrideTest.tsx @@ -0,0 +1,49 @@ +import { Component } from 'valdi_core/src/Component'; +import { ValdiRuntime } from 'valdi_core/src/ValdiRuntime'; + +declare const runtime: ValdiRuntime; + +export class ColorPaletteOverrideTest extends Component { + onCreate() { + runtime.configureColorPalette('light', { + background: 'rgba(0, 0, 255, 1)', + foreground: 'rgba(0, 128, 0, 1)', + }); + runtime.configureColorPalette('dark', { + background: 'rgba(255, 0, 0, 1)', + foreground: 'rgba(255, 255, 0, 1)', + }); + runtime.configureColorPalette('unused', { + background: 'rgba(0, 0, 0, 1)', + foreground: 'rgba(255, 255, 255, 1)', + }); + runtime.setActiveColorPalette('light'); + } + + onRender() { + + + + + + ; + } + + setDarkActiveColorPalette() { + runtime.setActiveColorPalette('dark'); + } + + updateDarkColorPalette() { + runtime.configureColorPalette('dark', { + background: 'rgba(0, 0, 0, 1)', + foreground: 'rgba(255, 255, 255, 1)', + }); + } + + updateUnusedColorPalette() { + runtime.configureColorPalette('unused', { + background: 'rgba(255, 255, 255, 1)', + foreground: 'rgba(0, 0, 0, 1)', + }); + } +} diff --git a/valdi/testdata/resources/modules/test/src/ColorPaletteTest.tsx b/valdi/testdata/resources/modules/test/src/ColorPaletteTest.tsx index 801d7bff2..0fc707d33 100644 --- a/valdi/testdata/resources/modules/test/src/ColorPaletteTest.tsx +++ b/valdi/testdata/resources/modules/test/src/ColorPaletteTest.tsx @@ -1,27 +1,42 @@ -import { Component } from "valdi_core/src/Component"; -import { ValdiRuntime } from "valdi_core/src/ValdiRuntime"; +import { Component } from 'valdi_core/src/Component'; +import { ValdiRuntime } from 'valdi_core/src/ValdiRuntime'; declare const runtime: ValdiRuntime; export class ColorPaletteTest extends Component { - onCreate() { - runtime.setColorPalette({ - background: 'blue', - foreground: 'green' + runtime.configureColorPalette('light', { + background: 'rgba(0, 0, 255, 1)', + foreground: 'rgba(0, 128, 0, 1)', + }); + runtime.configureColorPalette('dark', { + background: 'rgba(255, 0, 0, 1)', + foreground: 'rgba(255, 255, 0, 1)', }); + runtime.setActiveColorPalette('light'); } onRender() { - - + + ; + } + + setDarkColorPalette() { + runtime.setActiveColorPalette('dark'); + } + + updateDarkColorPalette() { + runtime.configureColorPalette('dark', { + background: 'rgba(0, 0, 0, 1)', + foreground: 'rgba(255, 255, 255, 1)', + }); } - updateColorPalette() { - runtime.setColorPalette({ - background: 'red', - foreground: 'yellow' + updateLightColorPalette() { + runtime.configureColorPalette('light', { + background: 'rgba(255, 0, 0, 1)', + foreground: 'rgba(255, 255, 0, 1)', }); } -} \ No newline at end of file +} diff --git a/valdi/testdata/resources/modules/test/src/ThemableAsset.tsx b/valdi/testdata/resources/modules/test/src/ThemableAsset.tsx new file mode 100644 index 000000000..e9adc0f5f --- /dev/null +++ b/valdi/testdata/resources/modules/test/src/ThemableAsset.tsx @@ -0,0 +1,53 @@ +import { Asset, ThemableAssetMap, makeDirectionalAsset, makeThemableAsset } from 'valdi_core/src/Asset'; +import { Component } from 'valdi_core/src/Component'; +import { ValdiRuntime } from 'valdi_core/src/ValdiRuntime'; + +declare const runtime: ValdiRuntime; + +interface ViewModel { + lightAsset: string | Asset; + darkAsset: string | Asset; + includeDarkAsset: boolean; +} + +export class ThemableAsset extends Component { + private asset?: Asset; + private nestedAsset?: Asset; + + onCreate() { + runtime.configureColorPalette('light', { + background: 'rgba(0, 0, 255, 1)', + }); + runtime.configureColorPalette('dark', { + background: 'rgba(255, 0, 0, 1)', + }); + runtime.setActiveColorPalette('light'); + } + + onViewModelUpdate(): void { + const assetsByColorPalette: ThemableAssetMap = { + light: this.viewModel.lightAsset, + }; + + if (this.viewModel.includeDarkAsset) { + assetsByColorPalette.dark = this.viewModel.darkAsset; + } + + this.asset = makeThemableAsset(assetsByColorPalette); + this.nestedAsset = makeDirectionalAsset(this.asset, this.viewModel.lightAsset); + } + + onRender() { + + + + + + + ; + } + + setDarkColorPalette() { + runtime.setActiveColorPalette('dark'); + } +} diff --git a/valdi_core/src/valdi_core/cpp/Attributes/AttributeUtils.cpp b/valdi_core/src/valdi_core/cpp/Attributes/AttributeUtils.cpp index 903944dd5..49e37b554 100644 --- a/valdi_core/src/valdi_core/cpp/Attributes/AttributeUtils.cpp +++ b/valdi_core/src/valdi_core/cpp/Attributes/AttributeUtils.cpp @@ -47,6 +47,24 @@ std::optional AttributeParser::parseColorComponent(bool isAlpha) { } std::optional AttributeParser::parseColor(const ColorPalette& colorPalette) { + auto colorValue = parseColorValue(); + if (!colorValue) { + return std::nullopt; + } + + if (colorValue->isNumber()) { + return Color(colorValue->toLong()); + } + + auto colorName = colorValue->toStringBox(); + auto color = colorPalette.getColorForName(colorName); + if (!color) { + setErrorAtCurrentPosition(Error(STRING_FORMAT("Invalid color name '{}'", colorName))); + } + return color; +} + +std::optional AttributeParser::parseColorValue() { tryParseWhitespaces(); if (tryParse("rgba(")) { @@ -87,7 +105,7 @@ std::optional AttributeParser::parseColor(const ColorPalette& colorPalett return std::nullopt; } - return Color(r.value(), g.value(), b.value(), a.value()); + return Value(Color(r.value(), g.value(), b.value(), a.value()).value); } else { if (tryParse('#')) { auto currentPosition = position(); @@ -111,19 +129,14 @@ std::optional AttributeParser::parseColor(const ColorPalette& colorPalett color = 0xFF | color << 8; } - return Color(color); + return Value(color); } else { - // Skip until the end of non-whitespace and then check if this is a color keyword tryParseWhitespaces(); auto keyword = parseIdentifier(); if (!keyword || keyword.value().empty()) { return std::nullopt; } - auto color = colorPalette.getColorForName(StringCache::getGlobal().makeString(keyword.value())); - if (!color) { - setErrorAtCurrentPosition(Error(STRING_FORMAT("Invalid color name '{}'", keyword.value()))); - } - return color; + return Value(StringCache::getGlobal().makeString(keyword.value())); } } } diff --git a/valdi_core/src/valdi_core/cpp/Attributes/AttributeUtils.hpp b/valdi_core/src/valdi_core/cpp/Attributes/AttributeUtils.hpp index 703f17197..b297f3bc0 100644 --- a/valdi_core/src/valdi_core/cpp/Attributes/AttributeUtils.hpp +++ b/valdi_core/src/valdi_core/cpp/Attributes/AttributeUtils.hpp @@ -12,6 +12,7 @@ #include "valdi_core/cpp/Utils/Result.hpp" #include "valdi_core/cpp/Utils/StringBox.hpp" #include "valdi_core/cpp/Utils/TextParser.hpp" +#include "valdi_core/cpp/Utils/Value.hpp" #include namespace Valdi { @@ -48,6 +49,7 @@ class AttributeParser : public TextParser { explicit AttributeParser(std::string_view str); std::optional parseColor(const ColorPalette& colorPalette); + std::optional parseColorValue(); std::optional parseAngle(); std::optional parseDimension(); diff --git a/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.cpp b/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.cpp index 2f16a4543..d6964e8e5 100644 --- a/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.cpp +++ b/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.cpp @@ -176,7 +176,7 @@ std::vector> getDefaultColors() { }; } -ColorPalette::ColorPalette() { +ColorPalette::ColorPalette(const StringBox& name) : _name(name) { for (const auto& it : getDefaultColors()) { setColorForName(StringCache::getGlobal().makeString(it.first), it.second); } @@ -184,7 +184,11 @@ ColorPalette::ColorPalette() { ColorPalette::~ColorPalette() = default; -void ColorPalette::updateColors(const FlatMap& colors) { +const StringBox& ColorPalette::getName() const { + return _name; +} + +bool ColorPalette::updateColors(const FlatMap& colors) { auto changed = false; for (const auto& it : colors) { @@ -193,11 +197,7 @@ void ColorPalette::updateColors(const FlatMap& colors) { } } - if (changed) { - if (_listener != nullptr) { - _listener->onColorPaletteUpdated(*this); - } - } + return changed; } bool ColorPalette::setColorForName(const StringBox& name, Color color) { @@ -226,8 +226,58 @@ const FlatMap& ColorPalette::getColors() const { return _colorByName; } -void ColorPalette::setListener(ColorPaletteListener* listener) { +ColorPaletteManager::ColorPaletteManager() : _activeColorPalette(makeShared(STRING_LITERAL("default"))) { + _colorPaletteByName[_activeColorPalette->getName()] = _activeColorPalette; +} + +ColorPaletteManager::~ColorPaletteManager() = default; + +const Ref& ColorPaletteManager::getActiveColorPalette() const { + return _activeColorPalette; +} + +const Ref& ColorPaletteManager::getColorPalette(const StringBox& name) { + return getOrCreateColorPalette(name); +} + +const FlatMap>& ColorPaletteManager::getColorPalettes() const { + return _colorPaletteByName; +} + +void ColorPaletteManager::configureColorPalette(const StringBox& name, const FlatMap& colors) { + const auto& colorPalette = getOrCreateColorPalette(name); + if (colorPalette->updateColors(colors)) { + notifyListener(*colorPalette, false); + } +} + +void ColorPaletteManager::setActiveColorPalette(const StringBox& name) { + if (name == _activeColorPalette->getName()) { + return; + } + + _activeColorPalette = getOrCreateColorPalette(name); + notifyListener(*_activeColorPalette, true); +} + +void ColorPaletteManager::setListener(ColorPaletteManagerListener* listener) { _listener = listener; } +const Ref& ColorPaletteManager::getOrCreateColorPalette(const StringBox& name) { + auto it = _colorPaletteByName.find(name); + if (it != _colorPaletteByName.end()) { + return it->second; + } + + _colorPaletteByName[name] = makeShared(name); + return _colorPaletteByName[name]; +} + +void ColorPaletteManager::notifyListener(const ColorPalette& colorPalette, bool activeColorPaletteChanged) { + if (_listener != nullptr) { + _listener->onColorPaletteManagerUpdated(*this, colorPalette, activeColorPaletteChanged); + } +} + } // namespace Valdi diff --git a/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.hpp b/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.hpp index 36da85477..9535bd3bd 100644 --- a/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.hpp +++ b/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.hpp @@ -63,29 +63,55 @@ std::ostream& operator<<(std::ostream& os, const Color& value); class ColorPalette; -class ColorPaletteListener { +class ColorPaletteManager; + +class ColorPaletteManagerListener { public: - virtual ~ColorPaletteListener() = default; - virtual void onColorPaletteUpdated(const ColorPalette& colorPalette) = 0; + virtual ~ColorPaletteManagerListener() = default; + virtual void onColorPaletteManagerUpdated(const ColorPaletteManager& colorPaletteManager, + const ColorPalette& colorPalette, + bool activeColorPaletteChanged) = 0; }; -class ColorPalette : public SharedPtrRefCountable { +class ColorPalette : public SimpleRefCountable { public: - ColorPalette(); + explicit ColorPalette(const StringBox& name); ~ColorPalette() override; + const StringBox& getName() const; std::optional getColorForName(const StringBox& name) const; const FlatMap& getColors() const; - void updateColors(const FlatMap& colors); - - void setListener(ColorPaletteListener* listener); + bool updateColors(const FlatMap& colors); private: + StringBox _name; FlatMap _colorByName; - ColorPaletteListener* _listener = nullptr; bool setColorForName(const StringBox& name, Color color); }; +class ColorPaletteManager : public SharedPtrRefCountable { +public: + ColorPaletteManager(); + ~ColorPaletteManager() override; + + const Ref& getActiveColorPalette() const; + const Ref& getColorPalette(const StringBox& name); + const FlatMap>& getColorPalettes() const; + + void configureColorPalette(const StringBox& name, const FlatMap& colors); + void setActiveColorPalette(const StringBox& name); + + void setListener(ColorPaletteManagerListener* listener); + +private: + FlatMap> _colorPaletteByName; + Ref _activeColorPalette; + ColorPaletteManagerListener* _listener = nullptr; + + const Ref& getOrCreateColorPalette(const StringBox& name); + void notifyListener(const ColorPalette& colorPalette, bool activeColorPaletteChanged); +}; + } // namespace Valdi diff --git a/valdi_core/src/valdi_core/cpp/Resources/Asset.cpp b/valdi_core/src/valdi_core/cpp/Resources/Asset.cpp index 8f7e57e36..87ecbe166 100644 --- a/valdi_core/src/valdi_core/cpp/Resources/Asset.cpp +++ b/valdi_core/src/valdi_core/cpp/Resources/Asset.cpp @@ -13,6 +13,11 @@ Asset::Asset() = default; Asset::~Asset() = default; +AssetConfiguration::AssetConfiguration(Ref colorPalette, + std::optional platformType, + std::optional rightToLeft) + : colorPalette(std::move(colorPalette)), platformType(platformType), rightToLeft(rightToLeft) {} + bool Asset::needResolve() const { return !getResolvedLocation().has_value(); } @@ -44,11 +49,7 @@ StringBox Asset::getResolvedURL() const { return resolvedLocation.value().getUrl(); } -Ref Asset::withDirection(bool /*rightToLeft*/) { - return strongSmallRef(this); -} - -Ref Asset::withPlatform(PlatformType /*platformType*/) { +Ref Asset::withConfiguration(const AssetConfiguration& /*configuration*/) { return strongSmallRef(this); } diff --git a/valdi_core/src/valdi_core/cpp/Resources/Asset.hpp b/valdi_core/src/valdi_core/cpp/Resources/Asset.hpp index 0e15b8566..25b00e41b 100644 --- a/valdi_core/src/valdi_core/cpp/Resources/Asset.hpp +++ b/valdi_core/src/valdi_core/cpp/Resources/Asset.hpp @@ -8,12 +8,24 @@ #pragma once #include "valdi_core/Asset.hpp" +#include "valdi_core/cpp/Attributes/ColorPalette.hpp" #include "valdi_core/cpp/Context/PlatformType.hpp" #include "valdi_core/cpp/Resources/AssetLocation.hpp" #include "valdi_core/cpp/Utils/ValdiObject.hpp" +#include namespace Valdi { +struct AssetConfiguration { + AssetConfiguration(Ref colorPalette, + std::optional platformType, + std::optional rightToLeft); + + Ref colorPalette; + std::optional platformType; + std::optional rightToLeft; +}; + class Asset : public ValdiObject, public snap::valdi_core::Asset { public: Asset(); @@ -26,18 +38,7 @@ class Asset : public ValdiObject, public snap::valdi_core::Asset { virtual bool canBeMeasured() const = 0; - /** - Return an LTR or RTL representation of the asset. - If the asset is not directional, this will return "this". - */ - virtual Ref withDirection(bool rightToLeft); - - /** - Return a platform specific asset depending on - the platform it will be rendered upon. - If the asset is not platform specific, this will return "this". - */ - virtual Ref withPlatform(PlatformType platformType); + virtual Ref withConfiguration(const AssetConfiguration& configuration); virtual double getWidth() const = 0; virtual double getHeight() const = 0; From 0b12164a05c754d2fc452c120ad2636c9a376b0a Mon Sep 17 00:00:00 2001 From: Simon Corsin Date: Thu, 23 Jul 2026 13:31:40 -0500 Subject: [PATCH 02/10] Add native API versioning and compatibility guards --- bzl/valdi/BUILD.bazel | 21 +- bzl/valdi/common.bzl | 1 + bzl/valdi/empty_android_manifest.xml | 2 + bzl/valdi/valdi_android_application.bzl | 8 +- bzl/valdi/valdi_android_resource_deps.bzl | 19 + bzl/valdi/valdi_application.bzl | 5 + bzl/valdi/valdi_compilation_metadata.bzl | 31 + bzl/valdi/valdi_compiled.bzl | 11 +- bzl/valdi/valdi_config.yaml.tpl | 1 + bzl/valdi/valdi_exported_library.bzl | 13 +- bzl/valdi/valdi_ios_application.bzl | 2 + bzl/valdi/valdi_macos_application.bzl | 2 + bzl/valdi/valdi_projectsync.bzl | 3 + bzl/valdi/valdi_run_compiler.bzl | 7 + bzl/valdi/valdi_test.bzl | 12 +- compiler/companion/src/AST.ts | 31 +- compiler/companion/src/CompilerCompanion.ts | 4 +- .../companion/src/VersioningValidator.spec.ts | 854 ++++++++++++++++++ compiler/companion/src/VersioningValidator.ts | 481 ++++++++++ compiler/companion/src/Workspace.ts | 23 + compiler/companion/src/WorkspaceStore.spec.ts | 42 + compiler/companion/src/WorkspaceStore.ts | 19 +- .../src/cache/CachingWorkspaceFactory.ts | 13 +- .../src/native/NativeCompiler.spec.ts | 32 +- compiler/companion/src/protocol.ts | 4 +- .../Sources/Config/ValdiProjectConfig.swift | 16 +- .../Generation/Cpp/CppFunctionGenerator.swift | 3 +- .../ExportedFunctionGenerator.swift | 1 + .../GeneratedTypesDiagnostics.swift | 326 ++++++- .../ObjC/ObjCFunctionGenerator.swift | 3 +- .../Parser/Models/ValdiRawDocument.swift | 6 +- .../Sources/Pipeline/CompilationItem.swift | 2 +- .../ApplyTypeScriptAnnotationsProcessor.swift | 31 +- .../Processors/DiagnosticsProcessor.swift | 10 +- .../DumpCompilationMetadataProcessor.swift | 28 +- .../Processors/GenerateModelsProcessor.swift | 109 ++- .../GenerateViewClassesProcessor.swift | 17 +- .../GeneratedTypesVerificationProcessor.swift | 2 +- .../NativeCodeGenerationManager.swift | 44 +- .../TypeScriptAnnotationsManager.swift | 4 +- .../Template/CompilationMetadata.swift | 37 + .../TypeScript/CompanionExecutable.swift | 5 +- .../TypeScript/TypeScriptAnnotation.swift | 34 +- .../TypeScript/TypeScriptCommentedFile.swift | 16 + .../TypeScript/TypeScriptCompiler.swift | 6 +- .../TypeScriptCompilerCompanionDriver.swift | 6 +- .../TypeScriptNativeTypeExporter.swift | 96 +- .../TypeScriptNativeTypeResolver.swift | 1 - .../Utils/GeneratedSourceFilename.swift | 1 + .../Sources/ValdiCompilerRunner.swift | 23 +- .../ViewModels/ExportedEnumGenerator.swift | 1 + .../NativeApiMetadataTests.swift | 148 +++ .../CompilerTests/ValdiAnnotationTests.swift | 45 + .../valdi_core/src/CompilerIntrinsics.ts | 20 + .../valdi/valdi_core/src/ValdiRuntime.d.ts | 2 + .../test/CompilerIntrinsics.spec.ts | 18 + .../runtime/JavaScript/JavaScriptRuntime.cpp | 7 + .../runtime/Resources/ResourceManager.cpp | 35 + .../runtime/Resources/ResourceManager.hpp | 5 + valdi/test/integration/Runtime_tests.cpp | 10 + 60 files changed, 2573 insertions(+), 186 deletions(-) create mode 100644 bzl/valdi/empty_android_manifest.xml create mode 100644 bzl/valdi/valdi_android_resource_deps.bzl create mode 100644 bzl/valdi/valdi_compilation_metadata.bzl create mode 100644 compiler/companion/src/VersioningValidator.spec.ts create mode 100644 compiler/companion/src/VersioningValidator.ts create mode 100644 compiler/companion/src/WorkspaceStore.spec.ts create mode 100644 compiler/compiler/Compiler/Tests/CompilerTests/NativeApiMetadataTests.swift create mode 100644 src/valdi_modules/src/valdi/valdi_core/test/CompilerIntrinsics.spec.ts diff --git a/bzl/valdi/BUILD.bazel b/bzl/valdi/BUILD.bazel index 16aaf65b5..492922d19 100644 --- a/bzl/valdi/BUILD.bazel +++ b/bzl/valdi/BUILD.bazel @@ -1,4 +1,4 @@ -load("@bazel_skylib//rules:common_settings.bzl", "bool_flag", "string_flag") +load("@bazel_skylib//rules:common_settings.bzl", "bool_flag", "int_flag", "string_flag") load("@valdi//bzl/conditions:selects.bzl", "selects") load("@valdi//bzl/valdi:valdi_toolchain.bzl", "valdi_toolchain") load("@valdi//bzl/valdi:valdi_toolchain_binary.bzl", "valdi_toolchain_binary") @@ -13,6 +13,7 @@ exports_files([ "empty.swift", "empty.kt", "empty.js", + "empty_android_manifest.xml", "Empty.bundle/Info.plist", "Info.plist", "package.json.tmpl", @@ -49,6 +50,24 @@ string_flag( visibility = ["//visibility:public"], ) +int_flag( + name = "native_api_min_version", + build_setting_default = -1, + visibility = ["//visibility:public"], +) + +filegroup( + name = "empty_api_version_file", + srcs = [], + visibility = ["//visibility:public"], +) + +label_flag( + name = "api_version_file", + build_setting_default = ":empty_api_version_file", + visibility = ["//visibility:public"], +) + string_flag( name = "assets_mode", build_setting_default = "bundle", diff --git a/bzl/valdi/common.bzl b/bzl/valdi/common.bzl index 56e89a52d..140e1bfa0 100644 --- a/bzl/valdi/common.bzl +++ b/bzl/valdi/common.bzl @@ -73,6 +73,7 @@ BUILD_DIR = ".valdi_build/compile" TYPESCRIPT_OUTPUT_DIR = paths.join(BUILD_DIR, "typescript/output") TYPESCRIPT_GENERATED_TS_DIR = paths.join(BUILD_DIR, "generated_ts") TYPESCRIPT_DUMPED_SYMBOLS_DIR = paths.join(BUILD_DIR, "typescript/dumped_symbols") +COMPILATION_METADATA_FILENAME = "compilation-metadata.json" def base_relative_dir(platform, output_target, relative_dir): """Helper function for constructing paths relative to the _BASE_DIR. diff --git a/bzl/valdi/empty_android_manifest.xml b/bzl/valdi/empty_android_manifest.xml new file mode 100644 index 000000000..471856872 --- /dev/null +++ b/bzl/valdi/empty_android_manifest.xml @@ -0,0 +1,2 @@ + + diff --git a/bzl/valdi/valdi_android_application.bzl b/bzl/valdi/valdi_android_application.bzl index e9f24dbed..112b2c432 100644 --- a/bzl/valdi/valdi_android_application.bzl +++ b/bzl/valdi/valdi_android_application.bzl @@ -7,6 +7,7 @@ load( "generate_valdi_android_application_icons", _valdi_android_application_icons = "valdi_android_application_icons", ) +load("//bzl/valdi:valdi_android_resource_deps.bzl", "valdi_android_resource_deps") load("//bzl/valdi/source_set:utils.bzl", "source_set_select") def valdi_android_application_icons(src, round_src = None): @@ -34,10 +35,15 @@ def valdi_android_application( round_icon_name = None, activity_theme_name = None, deps = [], + resources = [], native_deps = []): src_target = "{}_src".format(name) src_activity_target = "{}_activitygen".format(name) aar_target = "{}_aar".format(name) + resource_deps = valdi_android_resource_deps( + name = "{}_resources".format(name), + resources = resources, + ) generated_app_icons = generate_valdi_android_application_icons( name, @@ -123,5 +129,5 @@ def valdi_android_application( deps = [ ":{}".format(src_target), ":{}_import".format(aar_target), - ], + ] + resource_deps, ) diff --git a/bzl/valdi/valdi_android_resource_deps.bzl b/bzl/valdi/valdi_android_resource_deps.bzl new file mode 100644 index 000000000..1390606e7 --- /dev/null +++ b/bzl/valdi/valdi_android_resource_deps.bzl @@ -0,0 +1,19 @@ +"""Helpers for packaging cross-platform Valdi resources.""" + +load("@rules_android//rules:rules.bzl", "android_library") + +def valdi_android_resource_deps(name, resources): + """Creates an Android asset dependency for cross-platform resources.""" + if not resources: + return [] + + android_library( + name = name, + assets = resources, + # Keep these resources independent of the application's assets_dir. + # The empty root packages source resources relative to their owning + # Bazel package and generated resources relative to their output root. + assets_dir = "", + manifest = "@valdi//bzl/valdi:empty_android_manifest.xml", + ) + return [":{}".format(name)] diff --git a/bzl/valdi/valdi_application.bzl b/bzl/valdi/valdi_application.bzl index 8268bf815..cdd696b22 100644 --- a/bzl/valdi/valdi_application.bzl +++ b/bzl/valdi/valdi_application.bzl @@ -58,8 +58,10 @@ def valdi_application( desktop_window_width = 600, desktop_window_height = 800, desktop_window_resizable = True, + resources = [], version = None, deps = []): + resources = resources + [Label("//bzl/valdi:api_version_file")] resolved_ios_bundle_id = ios_bundle_id if ios_bundle_id else "com.snap.valdi.{}".format(name) resolved_android_package = android_package if android_package else "com.snap.valdi.{}".format(name) resolved_app_icons = icons if icons != None else app_icons @@ -91,6 +93,7 @@ def valdi_application( minimum_os_version = ios_minimum_os_version, provisioning_profile = ios_provisioning_profile, app_icons = ios_app_icons, + resources = resources, version = version, deps = get_suffixed_deps(deps, "_objc"), ) @@ -109,6 +112,7 @@ def valdi_application( round_icon_name = android_round_app_icon_name, activity_theme_name = android_activity_theme_name, deps = get_suffixed_deps(deps, "_kt"), + resources = resources, native_deps = get_suffixed_deps(deps, "_native"), ) @@ -121,6 +125,7 @@ def valdi_application( window_height = desktop_window_height, window_resizable = desktop_window_resizable, app_icons = macos_app_icons, + resources = resources, deps = get_suffixed_deps(deps, "_native"), ) diff --git a/bzl/valdi/valdi_compilation_metadata.bzl b/bzl/valdi/valdi_compilation_metadata.bzl new file mode 100644 index 000000000..96faad946 --- /dev/null +++ b/bzl/valdi/valdi_compilation_metadata.bzl @@ -0,0 +1,31 @@ +"""Collects compilation metadata from a transitive graph of Valdi modules.""" + +load(":common.bzl", "COMPILATION_METADATA_FILENAME") +load(":valdi_compiled.bzl", "ValdiModuleInfo") + +def _valdi_compilation_metadata_impl(ctx): + intermediates = depset( + transitive = [ + dep[ValdiModuleInfo].intermediates + for dep in ctx.attr.deps + ], + ) + metadata = [ + file + for file in intermediates.to_list() + if file.basename == COMPILATION_METADATA_FILENAME + ] + return [DefaultInfo(files = depset(metadata))] + +valdi_compilation_metadata = rule( + implementation = _valdi_compilation_metadata_impl, + doc = "Returns compilation-metadata.json artifacts for the transitive closure of Valdi module dependencies.", + attrs = { + "deps": attr.label_list( + mandatory = True, + cfg = "exec", + providers = [ValdiModuleInfo], + doc = "Valdi modules whose transitive compilation metadata should be collected.", + ), + }, +) diff --git a/bzl/valdi/valdi_compiled.bzl b/bzl/valdi/valdi_compiled.bzl index aa27b9525..a804419d3 100644 --- a/bzl/valdi/valdi_compiled.bzl +++ b/bzl/valdi/valdi_compiled.bzl @@ -12,6 +12,7 @@ load( "common.bzl", "ANDROID_RESOURCE_VARIANT_DIRECTORIES", "BUILD_DIR", + "COMPILATION_METADATA_FILENAME", "IOS_API_NAME_SUFFIX", "IOS_DEFAULT_MODULE_NAME_PREFIX", "IOS_OUTPUT_BASE", @@ -374,6 +375,9 @@ valdi_compiled = rule( allow_single_file = True, default = "valdi_config.yaml.tpl", ), + "_native_api_min_version": attr.label( + default = "@valdi//bzl/valdi:native_api_min_version", + ), }, ) @@ -1438,7 +1442,7 @@ def _get_ios_image_resources_paths(module_name, resources_basenames): return [debug_images, release_images] def _get_dumped_compilation_metadata(module_name): - return paths.join(TYPESCRIPT_DUMPED_SYMBOLS_DIR, module_name, "compilation-metadata.json") + return paths.join(TYPESCRIPT_DUMPED_SYMBOLS_DIR, module_name, COMPILATION_METADATA_FILENAME) def _get_dependency_data_path(module_name): return base_relative_dir("ios", "metadata", "dependencyData.json") @@ -1897,7 +1901,7 @@ def _extract_dts_files(srcs): return [f for f in srcs if f.basename.endswith(".d.ts")] def _extract_dumped_compilation_metadata(files): - return [f for f in files if f.basename.endswith("compilation-metadata.json")] + return [f for f in files if f.basename == COMPILATION_METADATA_FILENAME] def _extract_valdi_module_android(output_target, module_name, outputs): debug_path, release_path = _get_android_valdi_module_paths(module_name) @@ -2319,5 +2323,8 @@ valdi_hotreload = rule( allow_single_file = True, default = "valdi_config.yaml.tpl", ), + "_native_api_min_version": attr.label( + default = "@valdi//bzl/valdi:native_api_min_version", + ), }, ) diff --git a/bzl/valdi/valdi_config.yaml.tpl b/bzl/valdi/valdi_config.yaml.tpl index 7f127c8b4..c491c010f 100644 --- a/bzl/valdi/valdi_config.yaml.tpl +++ b/bzl/valdi/valdi_config.yaml.tpl @@ -58,3 +58,4 @@ node_modules_target: {NODE_MODULES_TARGET} node_modules_workspace: {NODE_MODULES_WORKSPACE} external_modules_target: {EXTERNAL_MODULES_TARGET} external_modules_workspace: {EXTERNAL_MODULES_WORKSPACE} +{NATIVE_API_MIN_VERSION_CONFIG} diff --git a/bzl/valdi/valdi_exported_library.bzl b/bzl/valdi/valdi_exported_library.bzl index 30f8dfda1..d8c4a10aa 100644 --- a/bzl/valdi/valdi_exported_library.bzl +++ b/bzl/valdi/valdi_exported_library.bzl @@ -4,6 +4,7 @@ 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_android_resource_deps.bzl", "valdi_android_resource_deps") load("//bzl/valdi:valdi_collapse_web_paths.bzl", "collapse_native_paths", "collapse_web_paths", "generate_native_module_map", "generate_register_native_modules") load("//bzl/valdi:valdi_protodecl_to_js.bzl", "collapse_protodecl_paths", "protodecl_to_js_dir") load("//bzl/valdi/source_set:utils.bzl", "source_set_select") @@ -37,7 +38,8 @@ def valdi_exported_library( web_package_name = None, npm_scope = "", npm_version = "1.0.0", - web_exclude_jsx_global_declaration = False): + web_exclude_jsx_global_declaration = False, + resources = []): """Exports Valdi modules as platform-specific libraries (xcframework, aar, npm). Args: @@ -48,9 +50,15 @@ def valdi_exported_library( Only needed for multi-module exports with cross-module Swift imports. ValdiCoreSwift is always included automatically. """ + resources = resources + [Label("//bzl/valdi:api_version_file")] if not web_package_name: web_package_name = "{}_npm".format(name) + android_resource_deps = valdi_android_resource_deps( + name = "{}_resources_android".format(name), + resources = resources, + ) + ios_public_hdrs_name = "{}_ios_hdrs".format(name) rewrite_hdrs( name = ios_public_hdrs_name, @@ -113,6 +121,7 @@ done | sed '/^import ValdiCoreSwift$$/d' > $@ apple_xcframework( name = "{}_ios".format(name), bundle_name = ios_bundle_name, + data = resources, deps = xcframework_deps, infoplists = [ "@valdi//bzl/valdi:Info.plist", @@ -135,7 +144,7 @@ done | sed '/^import ValdiCoreSwift$$/d' > $@ public_hdrs = [":{}".format(ios_public_hdrs_name)], ) - java_deps = java_deps + get_suffixed_deps(deps, "_kt") + java_deps = java_deps + get_suffixed_deps(deps, "_kt") + android_resource_deps collect_android_assets( name = "{}_android_assets".format(name), diff --git a/bzl/valdi/valdi_ios_application.bzl b/bzl/valdi/valdi_ios_application.bzl index e0a352524..f21e7813e 100644 --- a/bzl/valdi/valdi_ios_application.bzl +++ b/bzl/valdi/valdi_ios_application.bzl @@ -28,6 +28,7 @@ def valdi_ios_application( minimum_os_version = None, provisioning_profile = None, app_icons = None, + resources = [], version = None, deps = []): main_target = "{}_maingen".format(name) @@ -96,6 +97,7 @@ def valdi_ios_application( minimum_os_version = minimum_os_version, provisioning_profile = provisioning_profile, app_icons = generate_valdi_ios_application_icons(name, app_icons), + resources = resources, version = resolved_version, tags = ["valdi_ios_application"], visibility = ["//visibility:public"], diff --git a/bzl/valdi/valdi_macos_application.bzl b/bzl/valdi/valdi_macos_application.bzl index 3211ff098..56a2b26ce 100644 --- a/bzl/valdi/valdi_macos_application.bzl +++ b/bzl/valdi/valdi_macos_application.bzl @@ -18,6 +18,7 @@ def valdi_macos_application( window_height, window_resizable, app_icons = None, + resources = [], deps = []): main_target = "{}_maingen".format(name) plist_target = "{}_plist".format(name) @@ -61,6 +62,7 @@ def valdi_macos_application( deps = [":{}".format(src_target)], minimum_os_version = "15.0", app_icons = generate_valdi_macos_application_icons(name, app_icons), + resources = resources, tags = ["valdi_macos_application"], visibility = ["//visibility:public"], ) diff --git a/bzl/valdi/valdi_projectsync.bzl b/bzl/valdi/valdi_projectsync.bzl index 685b7f8b6..0fa0f9abb 100644 --- a/bzl/valdi/valdi_projectsync.bzl +++ b/bzl/valdi/valdi_projectsync.bzl @@ -230,5 +230,8 @@ valdi_projectsync = rule( allow_single_file = True, default = "valdi_config.yaml.tpl", ), + "_native_api_min_version": attr.label( + default = "@valdi//bzl/valdi:native_api_min_version", + ), }, ) diff --git a/bzl/valdi/valdi_run_compiler.bzl b/bzl/valdi/valdi_run_compiler.bzl index 0d65c65f2..b33e6f0d1 100644 --- a/bzl/valdi/valdi_run_compiler.bzl +++ b/bzl/valdi/valdi_run_compiler.bzl @@ -2,6 +2,7 @@ load( "common.bzl", "NODE_MODULES_BASE", ) +load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") load(":valdi_toolchain_type.bzl", "VALDI_TOOLCHAIN_TYPE") # TODO: modify the compiler so that we don't need to pass in the config file and instead can just pass in all of these options as arguments @@ -18,6 +19,11 @@ def generate_config(ctx): companion_path = toolchain.companion.files.to_list()[0].path minify_config_path = toolchain.minify_config.files.to_list()[0].path compiler_toolbox_path = toolchain.compiler_toolbox.files.to_list()[0].path + native_api_min_version = ctx.attr._native_api_min_version[BuildSettingInfo].value + + native_api_min_version_config = "" + if native_api_min_version >= 0: + native_api_min_version_config = "native_api_min_version: {}".format(native_api_min_version) ctx.actions.expand_template( output = out, @@ -29,6 +35,7 @@ def generate_config(ctx): "{MINIFY_CONFIG_PATH}": "$PWD/" + minify_config_path, "{COMPILER_TOOLBOX_PATH}": "$PWD/" + compiler_toolbox_path, "{NODE_MODULES_DIR}": NODE_MODULES_BASE, + "{NATIVE_API_MIN_VERSION_CONFIG}": native_api_min_version_config, }, ) return out diff --git a/bzl/valdi/valdi_test.bzl b/bzl/valdi/valdi_test.bzl index 49dd64955..188557614 100644 --- a/bzl/valdi/valdi_test.bzl +++ b/bzl/valdi/valdi_test.bzl @@ -32,7 +32,8 @@ def _valdi_test_impl(ctx): # Always include standalone module runfiles valdimodules = _collect_target_runfiles(ctx.attr.target) valdimodules += _collect_target_runfiles(ctx.attr._valdi_standalone) - module_paths = ["--module_path {}".format(f.short_path) for f in valdimodules] + runtime_files = valdimodules + ctx.files._api_version_file + module_paths = ["--module_path {}".format(f.short_path) for f in runtime_files] test_script = ctx.actions.declare_file("test_wrapper.sh") if has_tests: @@ -64,10 +65,10 @@ def _valdi_test_impl(ctx): ) runfiles = ctx.runfiles( - files = [standalone_binary] + valdimodules, + files = [standalone_binary] + runtime_files, ) - return [DefaultInfo(executable = test_script, files = depset([standalone_binary] + valdimodules), runfiles = runfiles)] + return [DefaultInfo(executable = test_script, files = depset([standalone_binary] + runtime_files), runfiles = runfiles)] valdi_test = rule( implementation = _valdi_test_impl, @@ -99,6 +100,11 @@ valdi_test = rule( default = Label("@valdi//bzl/valdi:code_coverage_enabled"), doc = "Enable code coverage collection during tests", ), + "_api_version_file": attr.label( + default = Label("//bzl/valdi:api_version_file"), + allow_files = True, + doc = "Optional repository API-version resource exposed to the standalone test runtime", + ), "_valdi_standalone": attr.label( default = Label("@valdi//src/valdi_modules/src/valdi/valdi_standalone"), doc = "The Valdi Standalone target to be added to all Valdi targets", diff --git a/compiler/companion/src/AST.ts b/compiler/companion/src/AST.ts index 8c1ccc394..6200a1eb2 100644 --- a/compiler/companion/src/AST.ts +++ b/compiler/companion/src/AST.ts @@ -455,6 +455,22 @@ export interface DumpedRootNode { exportedTypeAlias?: string; } +const GENERATE_OR_EXPORT_ANNOTATION_REGEX = /@Generate|@Export/; +const NATIVE_ANNOTATION_REGEX = /@Native/; +const EXPORT_MODULE_ANNOTATION_REGEX = /@ExportModule/; + +export function hasGenerateOrExportAnnotation(comments: string): boolean { + return GENERATE_OR_EXPORT_ANNOTATION_REGEX.test(comments); +} + +export function hasNativeExportAnnotation(comments: string): boolean { + return hasGenerateOrExportAnnotation(comments) || NATIVE_ANNOTATION_REGEX.test(comments); +} + +export function hasExportModuleAnnotation(comments: string): boolean { + return EXPORT_MODULE_ANNOTATION_REGEX.test(comments); +} + function isExportedSymbolOrHasAnnotation(nodeToDump: NodeToDump, shouldDumpAllExportedSymbols: boolean): boolean { if (shouldDumpAllExportedSymbols && isNodeExported(nodeToDump.node)) { return true; @@ -465,19 +481,11 @@ function isExportedSymbolOrHasAnnotation(nodeToDump: NodeToDump, shouldDumpAllEx return false; } const comments = nodeToDump.leadingComments.text; - const hasMatch = !!comments.match(/@Generate|@Export|@Component|@ViewModel|@Context|@Native/g); + const hasMatch = hasNativeExportAnnotation(comments) || !!comments.match(/@Component|@ViewModel|@Context/g); return hasMatch; } -function hasExportModuleAnnotation(nodeToDump: NodeToDump): boolean { - if (!nodeToDump.leadingComments) { - return false; - } - - return !!nodeToDump.leadingComments.text.match(/@ExportModule/g); -} - function shouldDumpNodeMember(nodeToDump: NodeToDump, memberNode: ts.TypeElement | ts.ClassElement): boolean { const nodeComments = nodeToDump.leadingComments?.text; if (!nodeComments) { @@ -488,7 +496,7 @@ function shouldDumpNodeMember(nodeToDump: NodeToDump, memberNode: ts.TypeElement return false; } else if (nodeComments.match(/@Component/)) { return !!(getNodeComments(memberNode)?.text ?? '').match(/@Action|@ConstructorOmitted/g); - } else if (nodeComments.match(/@Generate|@Export/)) { + } else if (hasGenerateOrExportAnnotation(nodeComments)) { return true; } else { return false; @@ -602,7 +610,8 @@ export function dumpRootNodes(sourceFile: ts.SourceFile, astReferences: AST.Type // Need to dump all exported symbols for .vue user scripts, or for modules // annotated with @ExportModule const shouldDumpAllExportedSymbols = - !!sourceFile.fileName.match(/\.vue\.ts(x)?$/g) || hasExportModuleAnnotation(rootNodes[0]); + !!sourceFile.fileName.match(/\.vue\.ts(x)?$/g) || + hasExportModuleAnnotation(rootNodes[0].leadingComments?.text ?? ''); for (const rootNode of rootNodes) { if (isExportedSymbolOrHasAnnotation(rootNode, shouldDumpAllExportedSymbols)) { nodesToDump.push(rootNode); diff --git a/compiler/companion/src/CompilerCompanion.ts b/compiler/companion/src/CompilerCompanion.ts index adaf4e4e1..9469f9392 100644 --- a/compiler/companion/src/CompilerCompanion.ts +++ b/compiler/companion/src/CompilerCompanion.ts @@ -102,7 +102,7 @@ export class CompilerCompanion extends CompanionServiceBase { }); this.addEndpoint(Command.createWorkspace, async (body) => { - const workspaceId = this.workspaceStore.createWorkspace().workspaceId; + const workspaceId = this.workspaceStore.createWorkspace(body.nativeApiMinVersion).workspaceId; return { workspaceId }; }); @@ -270,7 +270,7 @@ export class CompilerCompanion extends CompanionServiceBase { this.addEndpoint(Command.compileNative, async (body) => { let workspaceId: number; if (!body.workspaceId && body.registerInputFiles) { - workspaceId = this.workspaceStore.createWorkspace().workspaceId; + workspaceId = this.workspaceStore.createWorkspace(undefined).workspaceId; } else { workspaceId = body.workspaceId; } diff --git a/compiler/companion/src/VersioningValidator.spec.ts b/compiler/companion/src/VersioningValidator.spec.ts new file mode 100644 index 000000000..c0d37ede7 --- /dev/null +++ b/compiler/companion/src/VersioningValidator.spec.ts @@ -0,0 +1,854 @@ +import 'ts-jest'; +import * as ts from 'typescript'; +import { Workspace } from './Workspace'; + +function createWorkspaceWithFile(contents: string, nativeApiMinVersion: number | undefined): Workspace { + const workspace = new Workspace( + '/', + false, + undefined, + { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.CommonJS, + lib: ['lib.es2015.d.ts'], + strict: true, + }, + nativeApiMinVersion, + ); + + workspace.registerInMemoryFile('/file.ts', contents); + workspace.addSourceFileAtPath('/file.ts'); + return workspace; +} + +function getDiagnosticTexts(contents: string, nativeApiMinVersion?: number): string[] { + const workspace = createWorkspaceWithFile(contents, nativeApiMinVersion); + const diagnostics = workspace.getDiagnosticsSync('/file.ts').diagnostics; + workspace.destroy(); + return diagnostics.map((diagnostic) => diagnostic.text); +} + +describe('VersioningValidator', () => { + it('allows versioned properties inside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + title: string; + // @Version(43) + subtitle?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(43)) { + model.subtitle; + } + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('rejects versioned properties inside an insufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + title: string; + // @Version(43) + subtitle?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(42)) { + model.subtitle; + } + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('rejects versioned properties outside a version guard', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + title: string; + // @Version(43) + subtitle?: string; + } + + function render(model: MyModel) { + model.subtitle; + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('rejects placeholder-versioned properties outside a version guard', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + title: string; + // @Version(__PLACEHOLDER__) + subtitle?: string; + } + + function render(model: MyModel) { + model.subtitle; + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(__PLACEHOLDER__) or an enclosing isVersionAtLeast(__PLACEHOLDER__) block", + ]); + }); + + it('allows placeholder-versioned properties inside a placeholder version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare const __PLACEHOLDER__: number; + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(__PLACEHOLDER__) + subtitle?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(__PLACEHOLDER__)) { + model.subtitle; + } + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows placeholder-versioned properties inside a max safe integer version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(__PLACEHOLDER__) + subtitle?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(9007199254740991)) { + model.subtitle; + } + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('applies the highest nested version guard to child blocks only', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(42) + subtitle?: string; + // @Version(43) + detail?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(42)) { + model.subtitle; + if (isVersionAtLeast(43)) { + model.detail; + } + model.detail; + } + } + `); + + expect(diagnostics).toEqual(["Property 'detail' requires @Version(43) or an enclosing isVersionAtLeast(43) block"]); + }); + + it('does not apply the then branch version guard to the else branch', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(42) + subtitle?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(42)) { + model.subtitle; + } else { + model.subtitle; + } + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(42) or an enclosing isVersionAtLeast(42) block", + ]); + }); + + it('allows versioned properties in the right side and body of a version-guarded && condition', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(42) + subtitle?: string; + } + + function render(model: MyModel): string | undefined { + if (isVersionAtLeast(42) && model.subtitle) { + return model.subtitle; + } + return undefined; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('keeps && condition guards order-sensitive for short-circuit evaluation', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(42) + subtitle?: string; + } + + function render(model: MyModel): string | undefined { + if (model.subtitle && isVersionAtLeast(42)) { + return model.subtitle; + } + return undefined; + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(42) or an enclosing isVersionAtLeast(42) block", + ]); + }); + + it('rejects versioned properties in && conditions guarded by an insufficient version', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(43) + subtitle?: string; + } + + function render(model: MyModel): string | undefined { + if (isVersionAtLeast(42) && model.subtitle) { + return model.subtitle; + } + return undefined; + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('combines parenthesized and nested && version guards', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(42) + subtitle?: string; + // @Version(43) + detail?: string; + } + + declare function isReady(): boolean; + + function render(model: MyModel): string | undefined { + if ((isReady() && isVersionAtLeast(42)) && (isVersionAtLeast(43) && model.detail)) { + model.subtitle; + return model.detail; + } + return undefined; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows versioned properties inside a sufficiently versioned function body', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + // @Version(42) + subtitle?: string; + } + + // @Version(42) + function render(model: MyModel) { + model.subtitle; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('rejects versioned properties inside an insufficiently versioned function body', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + // @Version(43) + subtitle?: string; + } + + // @Version(42) + function render(model: MyModel) { + model.subtitle; + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('allows nested lambdas created inside a far outer version guard to use versioned properties', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + declare function run(callback: () => string | undefined): string | undefined; + + interface MyModel { + // @Version(42) + subtitle?: string; + } + + function render(model: MyModel): string | undefined { + if (isVersionAtLeast(42)) { + const renderLater = () => { + const renderNested = () => { + return model.subtitle; + }; + return renderNested(); + }; + + return run(() => renderLater()); + } + + return undefined; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows nested lambdas created inside a far outer && version guard to use versioned properties', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + declare function run(callback: () => string | undefined): string | undefined; + + interface MyModel { + // @Version(42) + subtitle?: string; + } + + declare function isReady(): boolean; + + function render(model: MyModel): string | undefined { + if (isReady() && isVersionAtLeast(42)) { + return run(() => { + const renderNested = () => model.subtitle; + return renderNested(); + }); + } + + return undefined; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows calls to versioned functions inside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + // @Version(42) + function renderLabelNew() {} + + function render() { + if (isVersionAtLeast(42)) { + renderLabelNew(); + } + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('rejects calls to versioned functions outside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + function renderLabelNew() {} + + function render() { + renderLabelNew(); + } + `); + + expect(diagnostics).toEqual(['Function call requires @Version(42) or an enclosing isVersionAtLeast(42) block']); + }); + + it('rejects calls to versioned functions inside an insufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + // @Version(43) + function renderLabelNew() {} + + function render() { + if (isVersionAtLeast(42)) { + renderLabelNew(); + } + } + `); + + expect(diagnostics).toEqual(['Function call requires @Version(43) or an enclosing isVersionAtLeast(43) block']); + }); + + it('allows calls to versioned methods inside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + class Renderer { + // @Version(42) + renderLabelNew() {} + } + + function render(renderer: Renderer) { + if (isVersionAtLeast(42)) { + renderer.renderLabelNew(); + } + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('rejects calls to versioned methods outside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + class Renderer { + // @Version(42) + renderLabelNew() {} + } + + function render(renderer: Renderer) { + renderer.renderLabelNew(); + } + `); + + expect(diagnostics).toEqual(['Function call requires @Version(42) or an enclosing isVersionAtLeast(42) block']); + }); + + it('rejects calls to placeholder-versioned functions outside a version guard', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(__PLACEHOLDER__) + function renderLabelNew() {} + + function render() { + renderLabelNew(); + } + `); + + expect(diagnostics).toEqual([ + 'Function call requires @Version(__PLACEHOLDER__) or an enclosing isVersionAtLeast(__PLACEHOLDER__) block', + ]); + }); + + it('allows calls to placeholder-versioned functions inside a placeholder version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare const __PLACEHOLDER__: number; + declare function isVersionAtLeast(version: number): boolean; + + // @Version(__PLACEHOLDER__) + function renderLabelNew() {} + + function render() { + if (isVersionAtLeast(__PLACEHOLDER__)) { + renderLabelNew(); + } + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('requires declarations exposing versioned types to be versioned', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + function render(model: NewModel) {} + `); + + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(42) on the containing declaration"]); + }); + + it('requires declarations exposing placeholder-versioned types to be placeholder-versioned', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(__PLACEHOLDER__) + interface NewModel { + title: string; + } + + function render(model: NewModel) {} + `); + + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(__PLACEHOLDER__) on the containing declaration"]); + }); + + it('allows placeholder-versioned declarations exposing placeholder-versioned types', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(__PLACEHOLDER__) + interface NewModel { + title: string; + } + + // @Version(__PLACEHOLDER__) + function render(model: NewModel): NewModel { + return model; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('rejects versioned declarations that expose newer types', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(43) + interface NewModel { + title: string; + } + + // @Version(42) + function render(model: NewModel) {} + `); + + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(43) on the containing declaration"]); + }); + + it('allows declarations exposing older versioned types', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + // @Version(43) + function render(model: NewModel): NewModel { + return model; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('requires return types exposing versioned types to be versioned', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + function makeModel(): NewModel { + return { title: 'title' }; + } + `); + + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(42) on the containing declaration"]); + }); + + it('requires interfaces extending versioned types to be versioned', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + interface ExtendedModel extends NewModel { + subtitle: string; + } + `); + + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(42) on the containing declaration"]); + }); + + it('allows interfaces extending older versioned types', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + // @Version(43) + interface ExtendedModel extends NewModel { + subtitle: string; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('requires interface methods exposing versioned types to be versioned', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + interface Renderer { + render(model: NewModel): void; + } + `); + + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(42) on the containing declaration"]); + }); + + it('allows versioned interface methods exposing compatible versioned types', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + interface Renderer { + // @Version(42) + render(model: NewModel): void; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows versioned properties exposing compatible versioned types', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + interface Renderer { + // @Version(42) + model: NewModel; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('validates isVersionAtLeast rejects non-literal arguments', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + const version = 42; + if (isVersionAtLeast(version)) { + } + `); + + expect(diagnostics).toEqual(['isVersionAtLeast expects exactly one numeric literal or __PLACEHOLDER__ argument']); + }); + + it('validates isVersionAtLeast rejects missing arguments', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(...versions: number[]): boolean; + + if (isVersionAtLeast()) { + } + `); + + expect(diagnostics).toEqual(['isVersionAtLeast expects exactly one numeric literal or __PLACEHOLDER__ argument']); + }); + + it('validates isVersionAtLeast rejects extra arguments', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(...versions: number[]): boolean; + + if (isVersionAtLeast(42, 43)) { + } + `); + + expect(diagnostics).toEqual(['isVersionAtLeast expects exactly one numeric literal or __PLACEHOLDER__ argument']); + }); + + it('preserves unchecked native contracts when the workspace minimum is disabled', () => { + const diagnostics = getDiagnosticTexts(` + // @ExportModel + export interface NativeModel { + futureValue?: string; + } + + function render(model: NativeModel) { + model.futureValue; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows implicitly versioned native members at the workspace minimum', () => { + const diagnostics = getDiagnosticTexts( + ` + // @ExportModel + export interface NativeModel { + value: string; + } + + function render(model: NativeModel) { + model.value; + } + `, + 2, + ); + + expect(diagnostics).toEqual([]); + }); + + it('lets an explicit member version override native-contract inheritance', () => { + const diagnostics = getDiagnosticTexts( + ` + // @ExportModel + export interface NativeModel { + // @Version(1) + existingValue: string; + // @Version(3) + futureValue?: string; + } + + function render(model: NativeModel) { + model.existingValue; + model.futureValue; + } + `, + 2, + ); + + expect(diagnostics).toEqual([ + "Property 'futureValue' requires @Version(3) or an enclosing isVersionAtLeast(3) block", + ]); + }); + + it('lets a placeholder member version override native-contract inheritance', () => { + const diagnostics = getDiagnosticTexts( + ` + // @ExportModel + export interface NativeModel { + // @Version(__PLACEHOLDER__) + futureValue?: string; + } + + function render(model: NativeModel) { + model.futureValue; + } + `, + 2, + ); + + expect(diagnostics).toEqual([ + "Property 'futureValue' requires @Version(__PLACEHOLDER__) or an enclosing isVersionAtLeast(__PLACEHOLDER__) block", + ]); + }); + + it('allows native members above the workspace minimum inside a sufficient guard', () => { + const diagnostics = getDiagnosticTexts( + ` + declare function isVersionAtLeast(version: number): boolean; + + // @NativeInterface + export interface NativeModel { + // @Version(3) + futureValue?: string; + } + + function render(model: NativeModel) { + if (isVersionAtLeast(3)) { + model.futureValue; + } + } + `, + 2, + ); + + expect(diagnostics).toEqual([]); + }); + + it('applies explicit versions to members inherited from native contracts', () => { + const diagnostics = getDiagnosticTexts( + ` + // @ExportProxy + export interface NativeBase { + // @Version(3) + futureValue?: string; + } + + export interface NativeDerived extends NativeBase {} + + function render(model: NativeDerived) { + model.futureValue; + } + `, + 2, + ); + + expect(diagnostics).toEqual([ + "Property 'futureValue' requires @Version(3) or an enclosing isVersionAtLeast(3) block", + ]); + }); + + it('applies the workspace minimum to exports in an ExportModule file', () => { + const diagnostics = getDiagnosticTexts( + ` + /** @ExportModule */ + export function existingFunction(): void {} + + // @Version(3) + export function futureFunction(): void {} + + function render() { + existingFunction(); + futureFunction(); + } + `, + 2, + ); + + expect(diagnostics).toEqual(['Function call requires @Version(3) or an enclosing isVersionAtLeast(3) block']); + }); + + it('does not implicitly version ordinary TypeScript declarations', () => { + const diagnostics = getDiagnosticTexts( + ` + interface Model { + value: string; + } + + function makeModel(): Model { + return { value: 'value' }; + } + + function render() { + makeModel().value; + } + `, + 2, + ); + + expect(diagnostics).toEqual([]); + }); +}); diff --git a/compiler/companion/src/VersioningValidator.ts b/compiler/companion/src/VersioningValidator.ts new file mode 100644 index 000000000..018a2423a --- /dev/null +++ b/compiler/companion/src/VersioningValidator.ts @@ -0,0 +1,481 @@ +import * as ts from 'typescript'; +import { hasExportModuleAnnotation, hasNativeExportAnnotation } from './AST'; +import { Diagnostic } from './protocol'; +import { getNodeComments, isNodeExported } from './TSUtils'; + +const PLACEHOLDER_VERSION = Number.MAX_SAFE_INTEGER; +const PLACEHOLDER_VERSION_TEXT = '__PLACEHOLDER__'; +const VERSION_ANNOTATION_REGEX = /@Version\s*\(\s*(\d+|__PLACEHOLDER__)\s*\)/; +const VERSION_INTRINSIC_NAME = 'isVersionAtLeast'; + +export class VersioningValidator { + private readonly versionCache = new WeakMap(); + private readonly nativeContractCache = new WeakMap(); + private readonly exportModuleCache = new WeakMap(); + private readonly diagnostics: Diagnostic[] = []; + + constructor( + private readonly sourceFile: ts.SourceFile, + private readonly typeChecker: ts.TypeChecker, + private readonly makeDiagnostic: (sourceFile: ts.SourceFile, node: ts.Node, text: string) => Diagnostic, + private readonly nativeApiMinVersion: number | undefined, + ) {} + + validate(): Diagnostic[] { + this.visit(this.sourceFile, this.nativeApiMinVersion); + return this.diagnostics; + } + + private getVersion(node: ts.Node | undefined): number | undefined { + if (!node) { + return undefined; + } + + if (this.versionCache.has(node)) { + return this.versionCache.get(node); + } + + let version = this.parseVersion(node); + if (version === undefined && ts.isVariableDeclaration(node) && ts.isVariableStatement(node.parent.parent)) { + version = this.parseVersion(node.parent.parent); + } + if ( + version === undefined && + this.nativeApiMinVersion !== undefined && + this.isImplicitlyVersionedNativeDeclaration(node) + ) { + version = this.nativeApiMinVersion; + } + + this.versionCache.set(node, version); + return version; + } + + private isImplicitlyVersionedNativeDeclaration(node: ts.Node): boolean { + if (this.nativeContractCache.has(node)) { + return this.nativeContractCache.get(node) ?? false; + } + + const annotationNode = this.getAnnotationNode(node); + let isNativeContract = hasNativeExportAnnotation(getNodeComments(annotationNode)?.text ?? ''); + + if (!isNativeContract) { + const containingContract = this.getContainingContractDeclaration(annotationNode); + if (containingContract) { + isNativeContract = this.isImplicitlyVersionedNativeDeclaration(containingContract); + } else if (this.sourceFileHasExportModuleAnnotation(annotationNode.getSourceFile())) { + const topLevelDeclaration = this.getTopLevelDeclaration(annotationNode); + isNativeContract = topLevelDeclaration !== undefined && isNodeExported(topLevelDeclaration); + } + } + + this.nativeContractCache.set(node, isNativeContract); + return isNativeContract; + } + + private sourceFileHasExportModuleAnnotation(sourceFile: ts.SourceFile): boolean { + const cached = this.exportModuleCache.get(sourceFile); + if (cached !== undefined) { + return cached; + } + + const hasAnnotation = sourceFile.statements.some((statement) => + hasExportModuleAnnotation(getNodeComments(statement)?.text ?? ''), + ); + this.exportModuleCache.set(sourceFile, hasAnnotation); + return hasAnnotation; + } + + private getAnnotationNode(node: ts.Node): ts.Node { + if (ts.isVariableDeclaration(node) && ts.isVariableStatement(node.parent.parent)) { + return node.parent.parent; + } + + return node; + } + + private getContainingContractDeclaration( + node: ts.Node, + ): ts.ClassDeclaration | ts.InterfaceDeclaration | ts.EnumDeclaration | undefined { + const parent = node.parent; + if ( + parent && + (ts.isClassDeclaration(parent) || ts.isInterfaceDeclaration(parent) || ts.isEnumDeclaration(parent)) + ) { + return parent; + } + + return undefined; + } + + private getTopLevelDeclaration(node: ts.Node): ts.Statement | undefined { + let current = node; + while (current.parent && !ts.isSourceFile(current.parent)) { + current = current.parent; + } + + return ts.isStatement(current) ? current : undefined; + } + + private parseVersion(node: ts.Node): number | undefined { + const comments = getNodeComments(node); + if (!comments) { + return undefined; + } + + const match = comments.text.match(VERSION_ANNOTATION_REGEX); + if (!match) { + return undefined; + } + + if (match[1] === PLACEHOLDER_VERSION_TEXT) { + return PLACEHOLDER_VERSION; + } + + return Number(match[1]); + } + + private formatVersion(version: number): string { + if (version === PLACEHOLDER_VERSION) { + return PLACEHOLDER_VERSION_TEXT; + } + + return String(version); + } + + private getVersionFromSymbol(symbol: ts.Symbol | undefined): number | undefined { + if (!symbol) { + return undefined; + } + + const declarations = symbol.getDeclarations(); + if (!declarations) { + return undefined; + } + + for (const declaration of declarations) { + const version = this.getVersion(declaration); + if (version !== undefined) { + return version; + } + } + + return undefined; + } + + private isVersionSatisfied(currentVersion: number | undefined, requiredVersion: number): boolean { + return currentVersion !== undefined && currentVersion >= requiredVersion; + } + + private validateVersionedUse( + node: ts.Node, + currentVersion: number | undefined, + requiredVersion: number, + label: string, + ) { + if (this.isVersionSatisfied(currentVersion, requiredVersion)) { + return; + } + + this.diagnostics.push( + this.makeDiagnostic( + this.sourceFile, + node, + `${label} requires @Version(${this.formatVersion( + requiredVersion, + )}) or an enclosing isVersionAtLeast(${this.formatVersion(requiredVersion)}) block`, + ), + ); + } + + private visit(node: ts.Node, currentVersion: number | undefined): void { + if (this.isVersionIntrinsicCall(node) && ts.isCallExpression(node)) { + this.validateVersionIntrinsicCall(node); + } + + if (ts.isIfStatement(node)) { + this.visitIfStatement(node, currentVersion); + return; + } + + if (this.isFunctionLikeDeclaration(node)) { + this.visitFunctionLikeDeclaration(node, currentVersion); + return; + } + + if (ts.isInterfaceDeclaration(node) || ts.isClassDeclaration(node)) { + this.validateContainerDeclaration(node); + } + + if (ts.isPropertyAccessExpression(node) && !this.isCalleePropertyAccess(node)) { + this.validatePropertyAccess(node, currentVersion); + } + + if (ts.isCallExpression(node)) { + this.validateCallExpression(node, currentVersion); + } + + ts.forEachChild(node, (child) => this.visit(child, currentVersion)); + } + + private visitIfStatement(node: ts.IfStatement, currentVersion: number | undefined): void { + const conditionVersion = this.visitVersionCondition(node.expression, currentVersion); + + const thenVersion = this.mergeVersions(currentVersion, conditionVersion); + this.visit(node.thenStatement, thenVersion); + + if (node.elseStatement) { + this.visit(node.elseStatement, currentVersion); + } + } + + private visitVersionCondition(node: ts.Expression, currentVersion: number | undefined): number | undefined { + if (ts.isParenthesizedExpression(node)) { + return this.visitVersionCondition(node.expression, currentVersion); + } + + if (this.isVersionIntrinsicCall(node) && ts.isCallExpression(node)) { + this.visit(node, currentVersion); + return this.getVersionIntrinsicArgument(node); + } + + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) { + const leftVersion = this.visitVersionCondition(node.left, currentVersion); + const rightVersion = this.visitVersionCondition(node.right, this.mergeVersions(currentVersion, leftVersion)); + return this.mergeVersions(leftVersion, rightVersion); + } + + this.visit(node, currentVersion); + return undefined; + } + + private mergeVersions(left: number | undefined, right: number | undefined): number | undefined { + if (left === undefined) { + return right; + } + + if (right === undefined) { + return left; + } + + return Math.max(left, right); + } + + private visitFunctionLikeDeclaration(node: ts.FunctionLikeDeclaration, currentVersion: number | undefined): void { + const declaredVersion = this.getDeclarationVersion(node); + const effectiveDeclarationVersion = this.mergeVersions(this.nativeApiMinVersion, declaredVersion); + this.validateSignature(node, effectiveDeclarationVersion); + + if (node.body) { + const bodyVersion = + this.nativeApiMinVersion === undefined + ? declaredVersion ?? currentVersion + : this.mergeVersions(currentVersion, declaredVersion); + this.visit(node.body, bodyVersion); + } + } + + private getDeclarationVersion(node: ts.Node | undefined): number | undefined { + if (!node) { + return undefined; + } + + if (ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) { + return this.getVersion(node); + } + + if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node)) { + return this.getVersion(node); + } + + if ((ts.isFunctionExpression(node) || ts.isArrowFunction(node)) && ts.isVariableDeclaration(node.parent)) { + return this.getVersion(node.parent); + } + + return this.getVersion(node); + } + + private isFunctionLikeDeclaration(node: ts.Node): node is ts.FunctionLikeDeclaration { + return ( + ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) + ); + } + + private validateContainerDeclaration(node: ts.InterfaceDeclaration | ts.ClassDeclaration): void { + const containerVersion = this.mergeVersions(this.nativeApiMinVersion, this.getVersion(node)); + + if (node.heritageClauses) { + for (const heritageClause of node.heritageClauses) { + for (const type of heritageClause.types) { + this.validateTypeNode(type, containerVersion, type); + } + } + } + + for (const member of node.members) { + if (this.isFunctionLikeDeclaration(member)) { + continue; + } + + const declaredMemberVersion = this.getVersion(member); + const memberVersion = + this.nativeApiMinVersion === undefined + ? declaredMemberVersion ?? containerVersion + : this.mergeVersions(containerVersion, declaredMemberVersion); + if (this.isSignatureMember(member)) { + this.validateSignature(member, memberVersion); + continue; + } + + if (ts.isPropertySignature(member) || ts.isPropertyDeclaration(member)) { + this.validateTypeNode(member.type, memberVersion, member.type ?? member); + } + } + } + + private isSignatureMember(node: ts.Node): node is ts.SignatureDeclaration { + return ( + ts.isMethodSignature(node) || + ts.isCallSignatureDeclaration(node) || + ts.isConstructSignatureDeclaration(node) || + ts.isIndexSignatureDeclaration(node) + ); + } + + private validateSignature(node: ts.SignatureDeclaration, declarationVersion: number | undefined): void { + for (const parameter of node.parameters) { + this.validateTypeNode(parameter.type, declarationVersion, parameter.type ?? parameter); + } + + this.validateTypeNode(node.type, declarationVersion, node.type ?? node); + } + + private validateTypeNode( + typeNode: ts.TypeNode | undefined, + declarationVersion: number | undefined, + diagnosticNode: ts.Node, + ): void { + if (!typeNode) { + return; + } + + const requiredVersion = this.getRequiredVersionForTypeNode(typeNode); + if (requiredVersion !== undefined && !this.isVersionSatisfied(declarationVersion, requiredVersion)) { + this.diagnostics.push( + this.makeDiagnostic( + this.sourceFile, + diagnosticNode, + `Type '${typeNode.getText(this.sourceFile)}' requires @Version(${this.formatVersion( + requiredVersion, + )}) on the containing declaration`, + ), + ); + } + } + + private getRequiredVersionForTypeNode(typeNode: ts.TypeNode): number | undefined { + let requiredVersion: number | undefined; + + const recordVersion = (version: number | undefined) => { + if (version === undefined) { + return; + } + requiredVersion = Math.max(requiredVersion ?? version, version); + }; + + const visitType = (node: ts.Node) => { + if (ts.isTypeReferenceNode(node)) { + recordVersion(this.getVersionFromSymbol(this.typeChecker.getSymbolAtLocation(node.typeName))); + } else if (ts.isExpressionWithTypeArguments(node)) { + recordVersion(this.getVersionFromSymbol(this.typeChecker.getSymbolAtLocation(node.expression))); + } else if (ts.isTypeQueryNode(node)) { + recordVersion(this.getVersionFromSymbol(this.typeChecker.getSymbolAtLocation(node.exprName))); + } + + ts.forEachChild(node, visitType); + }; + + visitType(typeNode); + return requiredVersion; + } + + private validatePropertyAccess(node: ts.PropertyAccessExpression, currentVersion: number | undefined): void { + const requiredVersion = this.getVersionFromSymbol(this.typeChecker.getSymbolAtLocation(node.name)); + if (requiredVersion !== undefined) { + this.validateVersionedUse(node.name, currentVersion, requiredVersion, `Property '${node.name.text}'`); + } + } + + private validateCallExpression(node: ts.CallExpression, currentVersion: number | undefined): void { + if (this.isVersionIntrinsicCall(node)) { + return; + } + + const requiredVersion = this.getRequiredVersionForCall(node); + if (requiredVersion !== undefined) { + this.validateVersionedUse(node.expression, currentVersion, requiredVersion, 'Function call'); + } + } + + private isCalleePropertyAccess(node: ts.PropertyAccessExpression): boolean { + return ts.isCallExpression(node.parent) && node.parent.expression === node; + } + + private getRequiredVersionForCall(node: ts.CallExpression): number | undefined { + let requiredVersion = this.getVersionFromSymbol(this.typeChecker.getSymbolAtLocation(node.expression)); + + const signature = this.typeChecker.getResolvedSignature(node); + if (!signature) { + return requiredVersion; + } + + const declarationVersion = this.getDeclarationVersion(signature.declaration); + if (declarationVersion !== undefined) { + requiredVersion = Math.max(requiredVersion ?? declarationVersion, declarationVersion); + } + + return requiredVersion; + } + + private isVersionIntrinsicCall(node: ts.Node): boolean { + return ( + ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === VERSION_INTRINSIC_NAME + ); + } + + private getVersionIntrinsicArgument(node: ts.Expression): number | undefined { + if (!this.isVersionIntrinsicCall(node) || !ts.isCallExpression(node)) { + return undefined; + } + + if (node.arguments.length !== 1) { + return undefined; + } + + const argument = node.arguments[0]; + if (ts.isNumericLiteral(argument)) { + return Number(argument.text); + } + if (ts.isIdentifier(argument) && argument.text === PLACEHOLDER_VERSION_TEXT) { + return PLACEHOLDER_VERSION; + } + return undefined; + } + + private validateVersionIntrinsicCall(node: ts.CallExpression): void { + if (this.getVersionIntrinsicArgument(node) === undefined) { + this.diagnostics.push( + this.makeDiagnostic( + this.sourceFile, + node, + 'isVersionAtLeast expects exactly one numeric literal or __PLACEHOLDER__ argument', + ), + ); + } + } +} diff --git a/compiler/companion/src/Workspace.ts b/compiler/companion/src/Workspace.ts index a59d07154..cdf28038c 100644 --- a/compiler/companion/src/Workspace.ts +++ b/compiler/companion/src/Workspace.ts @@ -28,6 +28,7 @@ import { IWorkspace, OpenFileImportPath, OpenFileResult } from './IWorkspace'; import * as _path from 'path'; import { ImportPathResolver } from './utils/ImportPathResolver'; import { debounce } from 'lodash'; +import { VersioningValidator } from './VersioningValidator'; export interface OpenedFile { sourceFile: ts.SourceFile; @@ -120,6 +121,7 @@ export class Workspace implements IWorkspace { shouldDebounceOpenFile: boolean, readonly logger: ILogger | undefined, readonly compilerOptions: ts.CompilerOptions | undefined, + readonly nativeApiMinVersion: number | undefined, ) { this.workspaceRoot = workspaceRoot; const project = new Project(workspaceRoot, compilerOptions, logger ? new ProjectListener(logger) : undefined); @@ -373,6 +375,18 @@ export class Workspace implements IWorkspace { return success; } + private validateVersioning(openedFile: OpenedFile, output: Diagnostic[]): boolean { + const validator = new VersioningValidator( + openedFile.sourceFile, + openedFile.workspaceProject.typeChecker, + (sourceFile, node, text) => this.makeDiagnostic(sourceFile, node, text), + this.nativeApiMinVersion, + ); + const diagnostics = validator.validate(); + output.push(...diagnostics); + return diagnostics.length === 0; + } + async getDiagnostics(fileName: string): Promise { return this.getDiagnosticsSync(fileName); } @@ -409,6 +423,15 @@ export class Workspace implements IWorkspace { timeTakenMs: sw.elapsedMilliseconds, }; } + + if (!this.validateVersioning(openedFile, diagnostics)) { + return { + diagnostics, + fileContent: openedFile.sourceFile.text, + hasError: true, + timeTakenMs: sw.elapsedMilliseconds, + }; + } } return { diff --git a/compiler/companion/src/WorkspaceStore.spec.ts b/compiler/companion/src/WorkspaceStore.spec.ts new file mode 100644 index 000000000..bb08d67ba --- /dev/null +++ b/compiler/companion/src/WorkspaceStore.spec.ts @@ -0,0 +1,42 @@ +import { getCompilationCacheVersion } from './cache/CachingWorkspaceFactory'; +import { ILogger } from './logger/ILogger'; +import { WorkspaceStore } from './WorkspaceStore'; + +const logger: ILogger = { + debug: undefined, + info: undefined, + warn: undefined, + error: undefined, +}; + +describe('WorkspaceStore', () => { + it('keeps the native API minimum scoped to each workspace', () => { + const store = new WorkspaceStore(logger, undefined, false); + + const unchecked = store.createWorkspace(undefined); + const baselineZero = store.createWorkspace(0); + const higherBaseline = store.createWorkspace(7); + + expect(store.getUncachedWorkspace(unchecked.workspaceId).nativeApiMinVersion).toBeUndefined(); + expect(store.getUncachedWorkspace(baselineZero.workspaceId).nativeApiMinVersion).toBe(0); + expect(store.getUncachedWorkspace(higherBaseline.workspaceId).nativeApiMinVersion).toBe(7); + + store.destroyAllWorkspaces(); + }); + + it('rejects invalid native API minimums', () => { + const store = new WorkspaceStore(logger, undefined, false); + + expect(() => store.createWorkspace(-1)).toThrow('nativeApiMinVersion must be an integer between 0 and 2147483647'); + expect(() => store.createWorkspace(1.5)).toThrow('nativeApiMinVersion must be an integer between 0 and 2147483647'); + expect(() => store.createWorkspace(2147483648)).toThrow( + 'nativeApiMinVersion must be an integer between 0 and 2147483647', + ); + }); + + it('versions compilation caches by native API minimum', () => { + expect(getCompilationCacheVersion(undefined)).toBe('3'); + expect(getCompilationCacheVersion(0)).toBe('3/native-api-min-version-0'); + expect(getCompilationCacheVersion(7)).toBe('3/native-api-min-version-7'); + }); +}); diff --git a/compiler/companion/src/WorkspaceStore.ts b/compiler/companion/src/WorkspaceStore.ts index ddaf3944e..b0b2c90b7 100644 --- a/compiler/companion/src/WorkspaceStore.ts +++ b/compiler/companion/src/WorkspaceStore.ts @@ -23,11 +23,24 @@ export class WorkspaceStore { readonly shouldDebounceOpenFile: boolean, ) {} - createWorkspace(): CreateWorkspaceResult { - const uncachedWorkspace = new Workspace('/', this.shouldDebounceOpenFile, this.logger, undefined); + createWorkspace(nativeApiMinVersion: number | undefined): CreateWorkspaceResult { + if ( + nativeApiMinVersion !== undefined && + (!Number.isInteger(nativeApiMinVersion) || nativeApiMinVersion < 0 || nativeApiMinVersion > 2147483647) + ) { + throw new Error('nativeApiMinVersion must be an integer between 0 and 2147483647'); + } + + const uncachedWorkspace = new Workspace( + '/', + this.shouldDebounceOpenFile, + this.logger, + undefined, + nativeApiMinVersion, + ); let workspace: IWorkspace; if (this.cacheDir) { - workspace = createCachingWorkspace(this.cacheDir, uncachedWorkspace, this.logger); + workspace = createCachingWorkspace(this.cacheDir, uncachedWorkspace, this.logger, nativeApiMinVersion); } else { workspace = uncachedWorkspace; } diff --git a/compiler/companion/src/cache/CachingWorkspaceFactory.ts b/compiler/companion/src/cache/CachingWorkspaceFactory.ts index 9c6b29a79..88243080e 100644 --- a/compiler/companion/src/cache/CachingWorkspaceFactory.ts +++ b/compiler/companion/src/cache/CachingWorkspaceFactory.ts @@ -7,17 +7,26 @@ import { ILogger } from '../logger/ILogger'; /** * Should be incremented every time the workspace implementation changes. */ -const CACHE_VERSION = '2'; +const CACHE_VERSION = '3'; + +export function getCompilationCacheVersion(nativeApiMinVersion: number | undefined): string { + if (nativeApiMinVersion === undefined) { + return CACHE_VERSION; + } + + return `${CACHE_VERSION}/native-api-min-version-${nativeApiMinVersion}`; +} export function createCachingWorkspace( cacheDir: string, sourceWorkspace: IWorkspace, logger: ILogger | undefined, + nativeApiMinVersion: number | undefined, ): IWorkspace { const dbPath = path.resolve(cacheDir, 'compilecache.db'); const compilationCache = new SQLiteCompilationCache( dbPath, - CACHE_VERSION, + getCompilationCacheVersion(nativeApiMinVersion), { getCurrentTimestamp() { return Date.now(); diff --git a/compiler/companion/src/native/NativeCompiler.spec.ts b/compiler/companion/src/native/NativeCompiler.spec.ts index 1585e49fa..0bf64825e 100644 --- a/compiler/companion/src/native/NativeCompiler.spec.ts +++ b/compiler/companion/src/native/NativeCompiler.spec.ts @@ -19,11 +19,17 @@ function compileAsIRString( setupWorkspace: ((workpace: Workspace) => void) | undefined, filterIR: (ir: NativeCompilerIR.Base) => boolean, ): string { - const workspace = new Workspace('/', false, undefined, { - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.CommonJS, - lib: ['lib.es2015.d.ts'], - }); + const workspace = new Workspace( + '/', + false, + undefined, + { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.CommonJS, + lib: ['lib.es2015.d.ts'], + }, + undefined, + ); const filePath = 'file.ts'; workspace.registerInMemoryFile(filePath, text); @@ -43,11 +49,17 @@ function compileAsC( selectFunction: string | undefined, setupWorkspace: ((workpace: Workspace) => void) | undefined, ): string { - const workspace = new Workspace('/', false, undefined, { - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.CommonJS, - lib: ['lib.es2015.d.ts'], - }); + const workspace = new Workspace( + '/', + false, + undefined, + { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.CommonJS, + lib: ['lib.es2015.d.ts'], + }, + undefined, + ); const filePath = 'file.ts'; workspace.registerInMemoryFile(filePath, text); diff --git a/compiler/companion/src/protocol.ts b/compiler/companion/src/protocol.ts index cba183562..a66a0d0ed 100644 --- a/compiler/companion/src/protocol.ts +++ b/compiler/companion/src/protocol.ts @@ -61,7 +61,9 @@ export interface BatchMinifyJSRequestBody { options: string; } -export interface CreateWorkspaceRequestBody {} +export interface CreateWorkspaceRequestBody { + nativeApiMinVersion?: number; +} export interface DestroyWorkspaceRequestBody { workspaceId: number; diff --git a/compiler/compiler/Compiler/Sources/Config/ValdiProjectConfig.swift b/compiler/compiler/Compiler/Sources/Config/ValdiProjectConfig.swift index 614f62eb0..48645c161 100644 --- a/compiler/compiler/Compiler/Sources/Config/ValdiProjectConfig.swift +++ b/compiler/compiler/Compiler/Sources/Config/ValdiProjectConfig.swift @@ -96,6 +96,7 @@ struct ValdiProjectConfig { let nodeModulesWorkspace: String? let externalModulesTarget: String? let externalModulesWorkspace: String? + let nativeApiMinVersion: Int? private static func parseOutputConfig(inputConfig: Yams.Node.Mapping?, ignoredFiles: [NSRegularExpression]?, @@ -317,6 +318,18 @@ struct ValdiProjectConfig { let nodeModulesWorkspace = config["node_modules_workspace"]?.string let externalModulesTarget = config["external_modules_target"]?.string let externalModulesWorkspace = config["external_modules_workspace"]?.string + let nativeApiMinVersion: Int? + if let configuredNativeApiMinVersion = config["native_api_min_version"] { + guard let parsedNativeApiMinVersion = configuredNativeApiMinVersion.int else { + throw CompilerError("native_api_min_version must be an integer") + } + guard parsedNativeApiMinVersion >= 0 && parsedNativeApiMinVersion <= Int(Int32.max) else { + throw CompilerError("native_api_min_version must be between 0 and \(Int32.max)") + } + nativeApiMinVersion = parsedNativeApiMinVersion + } else { + nativeApiMinVersion = nil + } var projectConfig = ValdiProjectConfig(configDirectoryUrl: configDirectoryUrl, projectName: projectName, @@ -357,7 +370,8 @@ struct ValdiProjectConfig { nodeModulesTarget: nodeModulesTarget, nodeModulesWorkspace: nodeModulesWorkspace, externalModulesTarget: externalModulesTarget, - externalModulesWorkspace: externalModulesWorkspace) + externalModulesWorkspace: externalModulesWorkspace, + nativeApiMinVersion: nativeApiMinVersion) projectConfig.shouldEmitDiagnostics = args.emitDiagnostics projectConfig.shouldDebugCompilerCompanion = args.debugCompanion diff --git a/compiler/compiler/Compiler/Sources/Generation/Cpp/CppFunctionGenerator.swift b/compiler/compiler/Compiler/Sources/Generation/Cpp/CppFunctionGenerator.swift index 0413c1894..99d1ebc3e 100644 --- a/compiler/compiler/Compiler/Sources/Generation/Cpp/CppFunctionGenerator.swift +++ b/compiler/compiler/Compiler/Sources/Generation/Cpp/CppFunctionGenerator.swift @@ -56,7 +56,8 @@ final class CppFunctionGenerator { type: .function(parameters: exportedFunction.parameters, returnType: exportedFunction.returnType, isSingleCall: false, shouldCallOnWorkerThread: false, allowSyncCall: exportedFunction.allowSyncCall), comments: exportedFunction.comments, omitConstructor: nil, - injectableParams: .empty) + injectableParams: .empty, + declaredVersion: nil) let nameAllocator = PropertyNameAllocator.forCpp() diff --git a/compiler/compiler/Compiler/Sources/Generation/ExportedFunctionGenerator.swift b/compiler/compiler/Compiler/Sources/Generation/ExportedFunctionGenerator.swift index 6f0ef2b8b..3be15b8c4 100644 --- a/compiler/compiler/Compiler/Sources/Generation/ExportedFunctionGenerator.swift +++ b/compiler/compiler/Compiler/Sources/Generation/ExportedFunctionGenerator.swift @@ -17,6 +17,7 @@ struct ExportedFunction { let returnType: ValdiModelPropertyType let allowSyncCall: Bool let comments: String? + let declaredVersion: String? } final class ExportedFunctionGenerator: NativeSourceGenerator { diff --git a/compiler/compiler/Compiler/Sources/Generation/GeneratedTypesDiagnostics.swift b/compiler/compiler/Compiler/Sources/Generation/GeneratedTypesDiagnostics.swift index b8c7f61c2..0b5099bb3 100644 --- a/compiler/compiler/Compiler/Sources/Generation/GeneratedTypesDiagnostics.swift +++ b/compiler/compiler/Compiler/Sources/Generation/GeneratedTypesDiagnostics.swift @@ -7,6 +7,29 @@ import Foundation +func nativeApiDeclaredVersion(annotations: [ValdiTypeScriptAnnotation]) -> String? { + return annotations.first(where: { $0.name == ValdiAnnotationType.version.rawValue })?.positionalPayload +} + +func nativeApiEffectiveMemberVersion(declared: String?, container: String?) -> String? { + guard let declared else { + return container + } + guard let container else { + return declared + } + if declared == "__PLACEHOLDER__" || container == "__PLACEHOLDER__" { + return "__PLACEHOLDER__" + } + guard let declaredVersion = Int(declared) else { + return declared + } + guard let containerVersion = Int(container) else { + return container + } + return String(max(declaredVersion, containerVersion)) +} + ////////// // This file defines set of easily-encodable structs describing the classes, // interfaces, enums etc. generated by the compiler. @@ -29,6 +52,7 @@ enum GeneratedTypeDescription: Encodable { case interface(GeneratedNativeInterfaceDescription) case `enum`(GeneratedEnumDescription) case function(GeneratedFunctionDescription) + case module(GeneratedNativeInterfaceDescription) case viewClass(GeneratedViewClassDescription) enum CodingKeys: CodingKey { @@ -36,6 +60,7 @@ enum GeneratedTypeDescription: Encodable { case interface case `enum` case function + case module case viewClass } @@ -50,6 +75,8 @@ enum GeneratedTypeDescription: Encodable { try container.encode(value, forKey: .enum) case let .function(value): try container.encode(value, forKey: .function) + case let .module(value): + try container.encode(value, forKey: .module) case let .viewClass(value): try container.encode(value, forKey: .viewClass) } @@ -78,6 +105,8 @@ enum GeneratedTypeDescription: Encodable { return this.iosTypeName case let .function(this): return this.containingIosTypeName + case let .module(this): + return this.iosTypeName case let .viewClass(this): return this.iosTypeName } @@ -93,6 +122,8 @@ enum GeneratedTypeDescription: Encodable { return this.androidTypeName case let .function(this): return this.containingAndroidTypeName + case let .module(this): + return this.androidTypeName case let .viewClass(this): return this.androidClassName } @@ -106,9 +137,10 @@ enum GeneratedTypeDescription: Encodable { return this.cppTypeName case let .enum(this): return this.cppTypeName - case .function: - // TODO: not supported yet - return nil + case let .function(this): + return this.containingCppTypeName + case let .module(this): + return this.cppTypeName case .viewClass: return nil } @@ -129,10 +161,25 @@ enum GeneratedTypeDescription: Encodable { return this.iosTypeName ?? this.androidTypeName ?? "" case let .function(this): return this.functionName + case let .module(this): + return this.iosTypeName ?? this.androidTypeName ?? "" case let .viewClass(this): return this.iosTypeName ?? this.androidClassName ?? "" } } + + var isNativeApi: Bool { + switch self { + case let .class(description), + let .interface(description), + let .module(description): + return description.isNativeApi + case .enum, .function: + return true + case .viewClass: + return false + } + } } struct GeneratedNativeClassDescription: Encodable { @@ -140,26 +187,84 @@ struct GeneratedNativeClassDescription: Encodable { let androidTypeName: String? let cppTypeName: String? let properties: [PropertyDescription] + let declaredVersion: String? + let effectiveVersion: String? + let isNativeApi: Bool - init(model: ValdiModel) { + enum CodingKeys: CodingKey { + case iosTypeName + case androidTypeName + case cppTypeName + case properties + case declaredVersion + case effectiveVersion + } + + init(model: ValdiModel, baseline: String?) { iosTypeName = model.iosType?.name androidTypeName = model.androidClassName cppTypeName = model.cppType?.declaration.fullTypeName - properties = model.properties.map(PropertyDescription.init) + declaredVersion = model.declaredVersion + isNativeApi = model.isNativeApi + let effectiveVersion = model.declaredVersion ?? (model.isNativeApi ? baseline : nil) + self.effectiveVersion = effectiveVersion + properties = model.properties.map { property in + if model.isNativeApi { + return PropertyDescription( + prop: property, + declaredVersion: property.declaredVersion, + containerVersion: effectiveVersion + ) + } else { + return PropertyDescription(prop: property) + } + } + } + + init(nativeClass: TypeScriptNativeClass, + annotations: [ValdiTypeScriptAnnotation], + baseline: String?) { + iosTypeName = nativeClass.iosType?.name + androidTypeName = nativeClass.androidClass + cppTypeName = nativeClass.cppType?.declaration.fullTypeName + properties = [] + declaredVersion = nativeApiDeclaredVersion(annotations: annotations) + effectiveVersion = declaredVersion ?? baseline + isNativeApi = true + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(iosTypeName, forKey: .iosTypeName) + try container.encodeIfPresent(androidTypeName, forKey: .androidTypeName) + try container.encodeIfPresent(cppTypeName, forKey: .cppTypeName) + try container.encode(properties, forKey: .properties) + try container.encode(declaredVersion, forKey: .declaredVersion) + try container.encode(effectiveVersion, forKey: .effectiveVersion) } } -struct GeneratedNativeInterfaceDescription: Encodable { - let iosTypeName: String? - let androidTypeName: String? - let cppTypeName: String? - let properties: [PropertyDescription] +typealias GeneratedNativeInterfaceDescription = GeneratedNativeClassDescription - init(model: ValdiModel) { - iosTypeName = model.iosType?.name - androidTypeName = model.androidClassName - cppTypeName = model.cppType?.declaration.fullTypeName - properties = model.properties.map(PropertyDescription.init) +struct GeneratedEnumCaseDescription: Encodable { + let name: String + let value: T + let declaredVersion: String? + let effectiveVersion: String? + + enum CodingKeys: CodingKey { + case name + case value + case declaredVersion + case effectiveVersion + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(value, forKey: .value) + try container.encode(declaredVersion, forKey: .declaredVersion) + try container.encode(effectiveVersion, forKey: .effectiveVersion) } } @@ -168,41 +273,124 @@ struct GeneratedEnumDescription: Encodable { let androidTypeName: String? let cppTypeName: String? - let intCases: [EnumCase]? - let stringCases: [EnumCase]? + let intCases: [GeneratedEnumCaseDescription]? + let stringCases: [GeneratedEnumCaseDescription]? + let declaredVersion: String? + let effectiveVersion: String? - static func from(exportedEnum: ExportedEnum) -> GeneratedEnumDescription { - let intCases: [EnumCase]? - let stringCases: [EnumCase]? + enum CodingKeys: CodingKey { + case iosTypeName + case androidTypeName + case cppTypeName + case intCases + case stringCases + case declaredVersion + case effectiveVersion + } + + static func from(exportedEnum: ExportedEnum, baseline: String?) -> GeneratedEnumDescription { + let declaredVersion = exportedEnum.declaredVersion + let effectiveVersion = declaredVersion ?? baseline + + let intCases: [GeneratedEnumCaseDescription]? + let stringCases: [GeneratedEnumCaseDescription]? switch exportedEnum.cases { case let .enum(cases): - intCases = cases + intCases = cases.map { enumCase in + return GeneratedEnumCaseDescription( + name: enumCase.name, + value: enumCase.value, + declaredVersion: enumCase.declaredVersion, + effectiveVersion: nativeApiEffectiveMemberVersion( + declared: enumCase.declaredVersion, + container: effectiveVersion + ) + ) + } stringCases = nil case let .stringEnum(cases): intCases = nil - stringCases = cases + stringCases = cases.map { enumCase in + return GeneratedEnumCaseDescription( + name: enumCase.name, + value: enumCase.value, + declaredVersion: enumCase.declaredVersion, + effectiveVersion: nativeApiEffectiveMemberVersion( + declared: enumCase.declaredVersion, + container: effectiveVersion + ) + ) + } } - return GeneratedEnumDescription(iosTypeName: exportedEnum.iosType?.name, - androidTypeName: exportedEnum.androidTypeName, - cppTypeName: exportedEnum.cppType?.declaration.fullTypeName, - intCases: intCases, - stringCases: stringCases) + + return GeneratedEnumDescription( + iosTypeName: exportedEnum.iosType?.name, + androidTypeName: exportedEnum.androidTypeName, + cppTypeName: exportedEnum.cppType?.declaration.fullTypeName, + intCases: intCases, + stringCases: stringCases, + declaredVersion: declaredVersion, + effectiveVersion: effectiveVersion + ) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(iosTypeName, forKey: .iosTypeName) + try container.encodeIfPresent(androidTypeName, forKey: .androidTypeName) + try container.encodeIfPresent(cppTypeName, forKey: .cppTypeName) + try container.encodeIfPresent(intCases, forKey: .intCases) + try container.encodeIfPresent(stringCases, forKey: .stringCases) + try container.encode(declaredVersion, forKey: .declaredVersion) + try container.encode(effectiveVersion, forKey: .effectiveVersion) } } struct GeneratedFunctionDescription: Encodable { let containingIosTypeName: String? let containingAndroidTypeName: String? + let containingCppTypeName: String? let functionName: String let parameters: [PropertyDescription] let returnType: PropertyTypeDescription + let declaredVersion: String? + let effectiveVersion: String? + + enum CodingKeys: CodingKey { + case containingIosTypeName + case containingAndroidTypeName + case containingCppTypeName + case functionName + case parameters + case returnType + case declaredVersion + case effectiveVersion + } - static func from(exportedFunction: ExportedFunction) -> GeneratedFunctionDescription { - return GeneratedFunctionDescription(containingIosTypeName: exportedFunction.containingIosType?.name, - containingAndroidTypeName: exportedFunction.containingAndroidTypeName, - functionName: exportedFunction.functionName, - parameters: exportedFunction.parameters.map(PropertyDescription.init), - returnType: PropertyTypeDescription(propType: exportedFunction.returnType)) + static func from(exportedFunction: ExportedFunction, baseline: String?) -> GeneratedFunctionDescription { + let declaredVersion = exportedFunction.declaredVersion + return GeneratedFunctionDescription( + containingIosTypeName: exportedFunction.containingIosType?.name, + containingAndroidTypeName: exportedFunction.containingAndroidTypeName, + containingCppTypeName: exportedFunction.containingCppType?.declaration.fullTypeName, + functionName: exportedFunction.functionName, + parameters: exportedFunction.parameters.map(PropertyDescription.init), + returnType: PropertyTypeDescription(propType: exportedFunction.returnType), + declaredVersion: declaredVersion, + effectiveVersion: declaredVersion ?? baseline + ) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(containingIosTypeName, forKey: .containingIosTypeName) + try container.encodeIfPresent(containingAndroidTypeName, forKey: .containingAndroidTypeName) + try container.encodeIfPresent(containingCppTypeName, forKey: .containingCppTypeName) + try container.encode(functionName, forKey: .functionName) + try container.encode(parameters, forKey: .parameters) + try container.encode(returnType, forKey: .returnType) + try container.encode(declaredVersion, forKey: .declaredVersion) + try container.encode(effectiveVersion, forKey: .effectiveVersion) } } @@ -246,6 +434,7 @@ class PropertyTypeDescription: Encodable { struct CustomTypeMetadata: Encodable { let iosTypeName: String? let androidClassName: String? + let cppTypeName: String? } private(set) var typeParameterMetadata: TypeParameterMetadata? @@ -269,8 +458,12 @@ class PropertyTypeDescription: Encodable { genericTypeMetadata = GenericTypeMetadata(types: [description]) case .bytes: typeStr = "bytes" - case .map: + case let .map(keyType, valueType): typeStr = "map" + genericTypeMetadata = GenericTypeMetadata(types: [ + PropertyTypeDescription(propType: keyType), + PropertyTypeDescription(propType: valueType), + ]) case .any: typeStr = "any" case .void: @@ -279,27 +472,38 @@ class PropertyTypeDescription: Encodable { typeStr = "function" let parameters = parameters.map(PropertyDescription.init) let returnType = PropertyTypeDescription(propType: returnType) - functionTypeMetadata = FunctionTypeMetadata(parameters: parameters, returnType: returnType) + functionTypeMetadata = FunctionTypeMetadata(parameters: parameters, + returnType: returnType) case let .object(nodeClassMapping): typeStr = "object" - customTypeMetadata = CustomTypeMetadata(iosTypeName: nodeClassMapping.iosType?.name, androidClassName: nodeClassMapping.androidClassName) + customTypeMetadata = CustomTypeMetadata(nodeClassMapping: nodeClassMapping) case let .genericTypeParameter(name): typeStr = "genericTypeParameter" typeParameterMetadata = .init(typeParameterName: name) - case let .genericObject(nodeClassMapping, _): + case let .genericObject(nodeClassMapping, typeArguments): typeStr = "genericObject" - customTypeMetadata = CustomTypeMetadata(iosTypeName: nodeClassMapping.iosType?.name, androidClassName: nodeClassMapping.androidClassName) + customTypeMetadata = CustomTypeMetadata(nodeClassMapping: nodeClassMapping) + genericTypeMetadata = GenericTypeMetadata(types: typeArguments.map(PropertyTypeDescription.init)) case let .enum(nodeClassMapping): typeStr = "enum" - customTypeMetadata = CustomTypeMetadata(iosTypeName: nodeClassMapping.iosType?.name, androidClassName: nodeClassMapping.androidClassName) + customTypeMetadata = CustomTypeMetadata(nodeClassMapping: nodeClassMapping) case .promise(let typeArgument): typeStr = "promise" genericTypeMetadata = GenericTypeMetadata(types: [PropertyTypeDescription(propType: typeArgument)]) case let .nullable(innerType): - let innerDescription = PropertyTypeDescription(propType: innerType) - typeStr = "\(innerDescription.typeStr)?" + typeStr = "nullable" + genericTypeMetadata = GenericTypeMetadata(types: [PropertyTypeDescription(propType: innerType)]) } } + +} + +private extension PropertyTypeDescription.CustomTypeMetadata { + init(nodeClassMapping: ValdiNodeClassMapping) { + iosTypeName = nodeClassMapping.iosType?.name + androidClassName = nodeClassMapping.androidClassName + cppTypeName = nodeClassMapping.cppType?.declaration.fullTypeName + } } struct PropertyDescription: Encodable { @@ -307,14 +511,54 @@ struct PropertyDescription: Encodable { let isOptional: Bool let type: PropertyTypeDescription + let declaredVersion: String? + let effectiveVersion: String? + private let emitsVersionMetadata: Bool // not for interfaces let omitConstructor: OmitConstructorParams? + enum CodingKeys: CodingKey { + case name + case isOptional + case type + case declaredVersion + case effectiveVersion + case omitConstructor + } + init(prop: ValdiModelProperty) { name = prop.name isOptional = prop.type.isOptional type = PropertyTypeDescription(propType: prop.type.unwrappingOptional) omitConstructor = prop.omitConstructor + declaredVersion = nil + effectiveVersion = nil + emitsVersionMetadata = false + } + + init(prop: ValdiModelProperty, declaredVersion: String?, containerVersion: String?) { + name = prop.name + isOptional = prop.type.isOptional + type = PropertyTypeDescription(propType: prop.type.unwrappingOptional) + omitConstructor = prop.omitConstructor + self.declaredVersion = declaredVersion + effectiveVersion = nativeApiEffectiveMemberVersion( + declared: declaredVersion, + container: containerVersion + ) + emitsVersionMetadata = true + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(isOptional, forKey: .isOptional) + try container.encode(type, forKey: .type) + if emitsVersionMetadata { + try container.encode(declaredVersion, forKey: .declaredVersion) + try container.encode(effectiveVersion, forKey: .effectiveVersion) + } + try container.encodeIfPresent(omitConstructor, forKey: .omitConstructor) } } diff --git a/compiler/compiler/Compiler/Sources/Generation/ObjC/ObjCFunctionGenerator.swift b/compiler/compiler/Compiler/Sources/Generation/ObjC/ObjCFunctionGenerator.swift index fbabbe4b3..0e9885e91 100644 --- a/compiler/compiler/Compiler/Sources/Generation/ObjC/ObjCFunctionGenerator.swift +++ b/compiler/compiler/Compiler/Sources/Generation/ObjC/ObjCFunctionGenerator.swift @@ -93,7 +93,8 @@ final class ObjCFunctionGenerator { type: .function(parameters: exportedFunction.parameters, returnType: exportedFunction.returnType, isSingleCall: false, shouldCallOnWorkerThread: false, allowSyncCall: exportedFunction.allowSyncCall), comments: nil, omitConstructor: nil, - injectableParams: .empty)) + injectableParams: .empty, + declaredVersion: nil)) let objectDescriptor = try classGenerator.writeObjectDescriptorGetter(resolvedProperties: [objcProperty], objcSelectors: [nil], typeParameters: nil, diff --git a/compiler/compiler/Compiler/Sources/Parser/Models/ValdiRawDocument.swift b/compiler/compiler/Compiler/Sources/Parser/Models/ValdiRawDocument.swift index 1cd7ba952..d53e2fb42 100644 --- a/compiler/compiler/Compiler/Sources/Parser/Models/ValdiRawDocument.swift +++ b/compiler/compiler/Compiler/Sources/Parser/Models/ValdiRawDocument.swift @@ -189,6 +189,7 @@ struct ValdiModelProperty { let comments: String? let omitConstructor: OmitConstructorParams? let injectableParams: InjectableParams + let declaredVersion: String? } struct ValdiTypeParameter { @@ -206,6 +207,8 @@ struct ValdiModel { var usePublicFields = false var comments: String? var properties = [ValdiModelProperty]() + var declaredVersion: String? + var isNativeApi = false } struct ExportedModule { @@ -221,6 +224,7 @@ struct EnumCase { let name: String let value: T let comments: String? + let declaredVersion: String? } extension EnumCase: Encodable where T: Encodable { @@ -549,4 +553,4 @@ struct ValdiNodeClassMapping { // TODO: rename to ValdiTypeMapping? struct ValdiClassMapping { var nodeMappingByClass = [String: ValdiNodeClassMapping]() -} \ No newline at end of file +} diff --git a/compiler/compiler/Compiler/Sources/Pipeline/CompilationItem.swift b/compiler/compiler/Compiler/Sources/Pipeline/CompilationItem.swift index 0c53d5664..c259547b9 100644 --- a/compiler/compiler/Compiler/Sources/Pipeline/CompilationItem.swift +++ b/compiler/compiler/Compiler/Sources/Pipeline/CompilationItem.swift @@ -81,7 +81,7 @@ struct CompilationItem { /** Diagnostics structure describing an exported type (class, interface, enum, function, view class). */ - case generatedTypeDescription(GeneratedTypeDescription) + case generatedTypeDescription(GeneratedTypeDescription, src: TypeScriptItemSrc) /** A diagnostics file to be emitted when we finish processing diff --git a/compiler/compiler/Compiler/Sources/Processors/ApplyTypeScriptAnnotationsProcessor.swift b/compiler/compiler/Compiler/Sources/Processors/ApplyTypeScriptAnnotationsProcessor.swift index ba13e6154..276694881 100644 --- a/compiler/compiler/Compiler/Sources/Processors/ApplyTypeScriptAnnotationsProcessor.swift +++ b/compiler/compiler/Compiler/Sources/Processors/ApplyTypeScriptAnnotationsProcessor.swift @@ -137,9 +137,33 @@ final class ApplyTypeScriptAnnotationsProcessor: CompilationProcessor { case .nativeTypeConverter: try nativeCodeGenerationManager.addNativeTypeConverter(commentedFile: commentedFile, annotation: annotation, sourceURL: sourceURL, annotatedSymbol: annotatedSymbol, compilationItem: compilationItem, linesIndexer: linesIndexer) case .nativeClass: - try nativeCodeGenerationManager.registerNativeClass(commentedFile: commentedFile, annotation: annotation, symbol: symbol, shouldGenerateIOS: compilationItem.shouldOutputToIOS, shouldGenerateAndroid: compilationItem.shouldOutputToAndroid, kind: .class, bundleInfo: compilationItem.bundleInfo, isGenerated: false) + let nativeClass = try nativeCodeGenerationManager.registerNativeClass(commentedFile: commentedFile, annotation: annotation, symbol: symbol, shouldGenerateIOS: compilationItem.shouldOutputToIOS, shouldGenerateAndroid: compilationItem.shouldOutputToAndroid, kind: .class, bundleInfo: compilationItem.bundleInfo, isGenerated: false) + let description = nativeCodeGenerationManager.nativeTypeDescription( + annotatedSymbol: annotatedSymbol, + nativeClass: nativeClass, + compilationItem: compilationItem + ) + out.append(item: compilationItem.with( + newKind: .generatedTypeDescription( + description, + src: commentedFile.src + ), + newPlatform: .none + )) case .nativeInterface: - try nativeCodeGenerationManager.registerNativeClass(commentedFile: commentedFile, annotation: annotation, symbol: symbol, shouldGenerateIOS: compilationItem.shouldOutputToIOS, shouldGenerateAndroid: compilationItem.shouldOutputToAndroid, kind: .interface, bundleInfo: compilationItem.bundleInfo, isGenerated: false) + let nativeClass = try nativeCodeGenerationManager.registerNativeClass(commentedFile: commentedFile, annotation: annotation, symbol: symbol, shouldGenerateIOS: compilationItem.shouldOutputToIOS, shouldGenerateAndroid: compilationItem.shouldOutputToAndroid, kind: .interface, bundleInfo: compilationItem.bundleInfo, isGenerated: false) + let description = nativeCodeGenerationManager.nativeTypeDescription( + annotatedSymbol: annotatedSymbol, + nativeClass: nativeClass, + compilationItem: compilationItem + ) + out.append(item: compilationItem.with( + newKind: .generatedTypeDescription( + description, + src: commentedFile.src + ), + newPlatform: .none + )) case .component: if isTSorTSX { guard let symbolName = symbol.text.nonEmpty else { @@ -220,6 +244,9 @@ final class ApplyTypeScriptAnnotationsProcessor: CompilationProcessor { case .untypedMap: // UntypedMap annotations get processed inside TypeScriptNativeTypeExporter break + case .version: + // Version annotations are consumed by the companion's version validator. + break } // Replace the CompilationItem with possibly-updated document diff --git a/compiler/compiler/Compiler/Sources/Processors/DiagnosticsProcessor.swift b/compiler/compiler/Compiler/Sources/Processors/DiagnosticsProcessor.swift index c2aae44ca..f123ebf43 100644 --- a/compiler/compiler/Compiler/Sources/Processors/DiagnosticsProcessor.swift +++ b/compiler/compiler/Compiler/Sources/Processors/DiagnosticsProcessor.swift @@ -30,16 +30,16 @@ class DiagnosticsProcessor: CompilationProcessor { return items } - return items.select { (item) -> GeneratedTypeDescription? in - if case let .generatedTypeDescription(generatedTypeDescription) = item.kind { - return generatedTypeDescription + return items.select { (item) -> (GeneratedTypeDescription, TypeScriptItemSrc)? in + if case let .generatedTypeDescription(generatedTypeDescription, src) = item.kind { + return (generatedTypeDescription, src) } return nil }.groupBy { selectedItem -> String in - selectedItem.item.relativeProjectPath + selectedItem.data.1.compilationPath }.transformEachConcurrently { groupedItems -> CompilationItem in let relativeSourceFilePath = groupedItems.key - let descriptions = groupedItems.items.map { $0.data }.sorted { $0.valueToSortBy < $1.valueToSortBy } + let descriptions = groupedItems.items.map { $0.data.0 }.sorted { $0.valueToSortBy < $1.valueToSortBy } let summary = GeneratedTypesSummary(sourceFilePath: relativeSourceFilePath, generatedTypes: descriptions) let anyItem = groupedItems.items[0] diff --git a/compiler/compiler/Compiler/Sources/Processors/DumpCompilationMetadataProcessor.swift b/compiler/compiler/Compiler/Sources/Processors/DumpCompilationMetadataProcessor.swift index 968c67925..92f8cad56 100644 --- a/compiler/compiler/Compiler/Sources/Processors/DumpCompilationMetadataProcessor.swift +++ b/compiler/compiler/Compiler/Sources/Processors/DumpCompilationMetadataProcessor.swift @@ -1,7 +1,6 @@ import Foundation class DumpCompilationMetadataProcessor: CompilationProcessor { - let projectConfig: ValdiProjectConfig let compilerConfig: CompilerConfig let projectClassMappingManager: ProjectClassMappingManager @@ -21,9 +20,28 @@ class DumpCompilationMetadataProcessor: CompilationProcessor { self.typeScriptNativeTypeResolver = typeScriptNativeTypeResolver } + private func generatedTypes(items: CompilationItems) -> [GeneratedTypesSummary] { + return items.select { item -> (GeneratedTypeDescription, TypeScriptItemSrc)? in + if case let .generatedTypeDescription(description, src) = item.kind, + description.isNativeApi { + return (description, src) + } + return nil + }.groupBy { selectedItem in + selectedItem.data.1.compilationPath.removing(suffixes: FileExtensions.typescriptFileExtensionsDotted) + }.selectedItems.map { groupedItems in + let descriptions = groupedItems.items.map(\.data.0) + return GeneratedTypesSummary( + sourceFilePath: groupedItems.key, + generatedTypes: descriptions.sorted { $0.valueToSortBy < $1.valueToSortBy } + ) + }.sorted { $0.sourceFilePath < $1.sourceFilePath } + } + func process(items: CompilationItems) throws -> CompilationItems { let resolvedMappings = projectClassMappingManager.copyProjectClassMapping().copyMappings() - let nativeTypes = typeScriptNativeTypeResolver.serialize() + let nativeTypes = typeScriptNativeTypeResolver.serialize() + let generatedTypes = generatedTypes(items: items) let encoder = JSONEncoder() encoder.outputFormatting = .sortedKeys @@ -48,7 +66,11 @@ class DumpCompilationMetadataProcessor: CompilationProcessor { let transformed = try selectedItems.transformEach { selectedItem in let item = selectedItem.item let (resolvedMappingsFromModule, resolvedTypeResolverForModule) = selectedItem.data - let compilationMetadata = CompilationMetadata(classMappings: resolvedMappingsFromModule, nativeTypes: resolvedTypeResolverForModule) + let nativeApiMinVersion = projectConfig.nativeApiMinVersion.map(String.init) + let compilationMetadata = CompilationMetadata(classMappings: resolvedMappingsFromModule, + nativeTypes: resolvedTypeResolverForModule, + nativeApiMinVersion: nativeApiMinVersion, + generatedTypes: generatedTypes) let encoded = try encoder.encode(compilationMetadata) let file = File.data(encoded) diff --git a/compiler/compiler/Compiler/Sources/Processors/GenerateModelsProcessor.swift b/compiler/compiler/Compiler/Sources/Processors/GenerateModelsProcessor.swift index 41f9af80e..4f86553ec 100644 --- a/compiler/compiler/Compiler/Sources/Processors/GenerateModelsProcessor.swift +++ b/compiler/compiler/Compiler/Sources/Processors/GenerateModelsProcessor.swift @@ -18,10 +18,12 @@ final class GenerateModelsProcessor: CompilationProcessor { private let logger: ILogger private let compilerConfig: CompilerConfig + private let generateNativeSources: Bool - init(logger: ILogger, compilerConfig: CompilerConfig) { + init(logger: ILogger, compilerConfig: CompilerConfig, generateNativeSources: Bool) { self.logger = logger self.compilerConfig = compilerConfig + self.generateNativeSources = generateNativeSources } var description: String { @@ -61,65 +63,73 @@ final class GenerateModelsProcessor: CompilationProcessor { let classMapping: ResolvedClassMapping } - private func typeDescription(for exportedType: ExportedType) -> GeneratedTypeDescription? { + private func typeDescription(for exportedType: ExportedType, baseline: String?) -> GeneratedTypeDescription { switch exportedType { case let .valdiModel(model): if model.exportAsInterface { - return .interface(GeneratedNativeInterfaceDescription(model: model)) + return .interface(GeneratedNativeInterfaceDescription(model: model, baseline: baseline)) } else { - return .class(GeneratedNativeClassDescription(model: model)) + return .class(GeneratedNativeClassDescription(model: model, baseline: baseline)) } case let .enum(exportedEnum): - return .enum(GeneratedEnumDescription.from(exportedEnum: exportedEnum)) + return .enum(GeneratedEnumDescription.from(exportedEnum: exportedEnum, baseline: baseline)) case let .function(exportedFunction): - return .function(GeneratedFunctionDescription.from(exportedFunction: exportedFunction)) - case .module: - return nil + return .function(GeneratedFunctionDescription.from(exportedFunction: exportedFunction, baseline: baseline)) + case let .module(exportedModule): + return .module(GeneratedNativeInterfaceDescription(model: exportedModule.model, baseline: baseline)) } } private func generate(selectedItem: SelectedItem<[IntermediateItem]>) -> [CompilationItem] { var out = [CompilationItem]() for item in selectedItem.data { - switch item.exportedType { - case .valdiModel(let valdiModel): - out += doGenerate(item: selectedItem.item, - intermediateItem: item, - iosType: valdiModel.iosType, - androidClassName: valdiModel.androidClassName, - cppType: valdiModel.cppType, - generationType: "model", - generator: ValdiModelGenerator(model: valdiModel)) - case .enum(let exportedEnum): - out += doGenerate(item: selectedItem.item, - intermediateItem: item, - iosType: exportedEnum.iosType, - androidClassName: exportedEnum.androidTypeName, - cppType: exportedEnum.cppType, - generationType: "enum", - generator: ExportedEnumGenerator(exportedEnum: exportedEnum)) - case .function(let exportedFunc): - out += doGenerate(item: selectedItem.item, - intermediateItem: item, - iosType: exportedFunc.containingIosType, - androidClassName: exportedFunc.containingAndroidTypeName, - cppType: exportedFunc.containingCppType, - generationType: "function", - generator: ExportedFunctionGenerator(exportedFunction: exportedFunc, modulePath: selectedItem.item.relativeBundleURL.deletingPathExtension().absoluteString)) - case .module(let exportedModule): - out += doGenerate(item: selectedItem.item, - intermediateItem: item, - iosType: exportedModule.model.iosType, - androidClassName: exportedModule.model.androidClassName, - cppType: exportedModule.model.cppType, - generationType: "module", - generator: ExportedModuleGenerator(bundleInfo: selectedItem.item.bundleInfo, exportedModule: exportedModule)) + if generateNativeSources { + switch item.exportedType { + case .valdiModel(let valdiModel): + out += doGenerate(item: selectedItem.item, + intermediateItem: item, + iosType: valdiModel.iosType, + androidClassName: valdiModel.androidClassName, + cppType: valdiModel.cppType, + generationType: "model", + generator: ValdiModelGenerator(model: valdiModel)) + case .enum(let exportedEnum): + out += doGenerate(item: selectedItem.item, + intermediateItem: item, + iosType: exportedEnum.iosType, + androidClassName: exportedEnum.androidTypeName, + cppType: exportedEnum.cppType, + generationType: "enum", + generator: ExportedEnumGenerator(exportedEnum: exportedEnum)) + case .function(let exportedFunc): + out += doGenerate(item: selectedItem.item, + intermediateItem: item, + iosType: exportedFunc.containingIosType, + androidClassName: exportedFunc.containingAndroidTypeName, + cppType: exportedFunc.containingCppType, + generationType: "function", + generator: ExportedFunctionGenerator(exportedFunction: exportedFunc, modulePath: selectedItem.item.relativeBundleURL.deletingPathExtension().absoluteString)) + case .module(let exportedModule): + out += doGenerate(item: selectedItem.item, + intermediateItem: item, + iosType: exportedModule.model.iosType, + androidClassName: exportedModule.model.androidClassName, + cppType: exportedModule.model.cppType, + generationType: "module", + generator: ExportedModuleGenerator(bundleInfo: selectedItem.item.bundleInfo, exportedModule: exportedModule)) + } } - if let description = typeDescription(for: item.exportedType) { - let newItem = selectedItem.item.with(newKind: .generatedTypeDescription(description), newPlatform: .none) - out.append(newItem) - } + let baseline = selectedItem.item.bundleInfo.projectConfig.nativeApiMinVersion.map(String.init) + let description = typeDescription(for: item.exportedType, baseline: baseline) + let newItem = selectedItem.item.with( + newKind: .generatedTypeDescription( + description, + src: item.sourceFilename.src + ), + newPlatform: .none + ) + out.append(newItem) } if case .document = selectedItem.item.kind { @@ -148,7 +158,14 @@ final class GenerateModelsProcessor: CompilationProcessor { } if case .document(let result) = item.kind, let viewModel = result.originalDocument.viewModel { - let generatedSourceFilename = GeneratedSourceFilename(filename: result.componentPath.fileName, symbolName: result.componentPath.exportedMember) + let generatedSourceFilename = GeneratedSourceFilename( + filename: result.componentPath.fileName, + symbolName: result.componentPath.exportedMember, + src: TypeScriptItemSrc( + compilationPath: item.relativeProjectPath, + sourceURL: item.sourceURL + ) + ) var out = [IntermediateItem]() out.append(IntermediateItem(sourceFilename: generatedSourceFilename, exportedType: .valdiModel(viewModel), diff --git a/compiler/compiler/Compiler/Sources/Processors/GenerateViewClassesProcessor.swift b/compiler/compiler/Compiler/Sources/Processors/GenerateViewClassesProcessor.swift index 1cc3ba86b..f7575c636 100644 --- a/compiler/compiler/Compiler/Sources/Processors/GenerateViewClassesProcessor.swift +++ b/compiler/compiler/Compiler/Sources/Processors/GenerateViewClassesProcessor.swift @@ -32,7 +32,14 @@ class GenerateViewClassesProcessor: CompilationProcessor { logger.debug("Not generating view class for \(item.sourceURL.path) because the root node doesn't have a custom class.") return [] } - let generatedSourceFilename = GeneratedSourceFilename(filename: item.relativeProjectPath, symbolName: compilationResult.componentPath.exportedMember) + let generatedSourceFilename = GeneratedSourceFilename( + filename: item.relativeProjectPath, + symbolName: compilationResult.componentPath.exportedMember, + src: TypeScriptItemSrc( + compilationPath: item.relativeProjectPath, + sourceURL: item.sourceURL + ) + ) let iosType = classMapping.iosType let androidClassName = classMapping.androidClassName @@ -55,7 +62,13 @@ class GenerateViewClassesProcessor: CompilationProcessor { } let nativeSourceItems = result.nativeSources.map { item.with(newKind: .nativeSource($0.source), newPlatform: $0.platform) } let typeDescription = GeneratedTypeDescription.viewClass(result.description) - let descriptionItem = item.with(newKind: .generatedTypeDescription(typeDescription), newPlatform: .none) + let descriptionItem = item.with( + newKind: .generatedTypeDescription( + typeDescription, + src: generatedSourceFilename.src + ), + newPlatform: .none + ) return nativeSourceItems + [descriptionItem] } catch let error { logger.error("Failed to generate View class for \(item.sourceURL.path): \(error.legibleLocalizedDescription)") diff --git a/compiler/compiler/Compiler/Sources/Processors/GeneratedTypesVerificationProcessor.swift b/compiler/compiler/Compiler/Sources/Processors/GeneratedTypesVerificationProcessor.swift index 0f4ef8c70..925e7a07b 100644 --- a/compiler/compiler/Compiler/Sources/Processors/GeneratedTypesVerificationProcessor.swift +++ b/compiler/compiler/Compiler/Sources/Processors/GeneratedTypesVerificationProcessor.swift @@ -23,7 +23,7 @@ class GeneratedTypesVerificationProcessor: CompilationProcessor { func process(items: CompilationItems) throws -> CompilationItems { try items.select { item -> GeneratedTypeDescription? in - guard case let .generatedTypeDescription(generatedTypeDescription) = item.kind else { + guard case let .generatedTypeDescription(generatedTypeDescription, _) = item.kind else { return nil } return generatedTypeDescription diff --git a/compiler/compiler/Compiler/Sources/Processors/NativeCodeGenerationManager.swift b/compiler/compiler/Compiler/Sources/Processors/NativeCodeGenerationManager.swift index a444b372d..ad8271f38 100644 --- a/compiler/compiler/Compiler/Sources/Processors/NativeCodeGenerationManager.swift +++ b/compiler/compiler/Compiler/Sources/Processors/NativeCodeGenerationManager.swift @@ -236,6 +236,20 @@ class NativeCodeGenerationManager { nativeTypeResolver.registerTypeConverter(src: commentedFile.src, emittingBundleName: compilationItem.bundleInfo.name, tsTypeName: symbol.text, fromTypePath: typeReferenceFromType.fileName, tsFromTypeName: typeReferenceFromType.name, toTypePath: typeReferenceToType.fileName, tsToTypeName: typeReferenceToType.name) } + func nativeTypeDescription(annotatedSymbol: TypeScriptAnnotatedSymbol, + nativeClass: TypeScriptNativeClass, + compilationItem: CompilationItem) -> GeneratedTypeDescription { + let baseline = compilationItem.bundleInfo.projectConfig.nativeApiMinVersion.map(String.init) + let description = GeneratedNativeClassDescription( + nativeClass: nativeClass, + annotations: annotatedSymbol.annotations, + baseline: baseline + ) + return nativeClass.kind == .interface + ? .interface(description) + : .class(description) + } + func addViewModelSymbol(sourceURL: URL, symbol: String) { viewModelSymbolNameBySourceURL.data { $0[sourceURL] = symbol } } @@ -516,7 +530,15 @@ class NativeCodeGenerationManager { let resolvedClassMapping = ResolvedClassMapping(localClassMapping: classMapping, projectClassMapping: ProjectClassMapping(allowMappingOverride: false), currentBundle: nativeModuleToGenerate.compilationItem.bundleInfo) let item = nativeModuleToGenerate.compilationItem.with( - newKind: .exportedType(.module(exportedModule), resolvedClassMapping, GeneratedSourceFilename(filename: nativeModuleToGenerate.compilationItem.relativeProjectPath, symbolName: nativeModuleToGenerate.tsTypeName)) + newKind: .exportedType( + .module(exportedModule), + resolvedClassMapping, + GeneratedSourceFilename( + filename: nativeModuleToGenerate.compilationItem.relativeProjectPath, + symbolName: nativeModuleToGenerate.tsTypeName, + src: nativeModuleToGenerate.commentedFile.src + ) + ) ) items.append(item: item) @@ -536,7 +558,11 @@ class NativeCodeGenerationManager { return nativeTypeExported.export().then { (nativeTypeToExport, classMapping) -> Void in let resolvedClassMapping = ResolvedClassMapping(localClassMapping: classMapping, projectClassMapping: ProjectClassMapping(allowMappingOverride: false), currentBundle: nativeClassToGenerate.compilationItem.bundleInfo) - let generatedSourceFilename = GeneratedSourceFilename(filename: nativeClassToGenerate.compilationItem.relativeProjectPath, symbolName: nativeClassToGenerate.nativeClass.tsTypeName) + let generatedSourceFilename = GeneratedSourceFilename( + filename: nativeClassToGenerate.compilationItem.relativeProjectPath, + symbolName: nativeClassToGenerate.nativeClass.tsTypeName, + src: nativeClassToGenerate.commentedFile.src + ) switch nativeTypeToExport { case .valdiModel(let model): @@ -618,7 +644,15 @@ class NativeCodeGenerationManager { let resolvedClassMapping = ResolvedClassMapping(localClassMapping: classMapping, projectClassMapping: ProjectClassMapping(allowMappingOverride: false), currentBundle: nativeFuncToGenerate.compilationItem.bundleInfo) let item = nativeFuncToGenerate.compilationItem.with( - newKind: .exportedType(.function(exportedFunction), resolvedClassMapping, GeneratedSourceFilename(filename: nativeFuncToGenerate.compilationItem.relativeProjectPath, symbolName: nativeFuncToGenerate.tsTypeName)) + newKind: .exportedType( + .function(exportedFunction), + resolvedClassMapping, + GeneratedSourceFilename( + filename: nativeFuncToGenerate.compilationItem.relativeProjectPath, + symbolName: nativeFuncToGenerate.tsTypeName, + src: nativeFuncToGenerate.commentedFile.src + ) + ) ) items.append(item: item) @@ -626,7 +660,7 @@ class NativeCodeGenerationManager { let exporterError = CompilerError(type: "NativeFuncExporter error", message: "Failed to export native function '\(nativeFuncToGenerate.dumpedSymbol.text)': \(error.legibleLocalizedDescription)", range: nativeFuncToGenerate.annotation.range, inDocument: nativeFuncToGenerate.commentedFile.fileContent) return Promise(error: exporterError) } - } + } private func makeNativeClassToGenerate(commentedFile: TypeScriptCommentedFile, annotation: ValdiTypeScriptAnnotation, @@ -736,4 +770,4 @@ class NativeCodeGenerationManager { return nativeFunc } -} \ No newline at end of file +} diff --git a/compiler/compiler/Compiler/Sources/Processors/TypeScriptAnnotationsManager.swift b/compiler/compiler/Compiler/Sources/Processors/TypeScriptAnnotationsManager.swift index f3f93928a..abf8fb956 100644 --- a/compiler/compiler/Compiler/Sources/Processors/TypeScriptAnnotationsManager.swift +++ b/compiler/compiler/Compiler/Sources/Processors/TypeScriptAnnotationsManager.swift @@ -42,6 +42,7 @@ enum ValdiAnnotationType: String, CaseIterable { case allowSyncCall = "AllowSyncCall" case untypedMap = "UntypedMap" case untyped = "Untyped" + case version = "Version" /// Returns true if this annotation type can have ios/android parameters that indicate native exports. /// Used for detecting native exports in Vue files and validating annotation parameters. @@ -71,7 +72,8 @@ enum ValdiAnnotationType: String, CaseIterable { .workerThread, .allowSyncCall, .untypedMap, - .untyped: + .untyped, + .version: return false } } diff --git a/compiler/compiler/Compiler/Sources/Template/CompilationMetadata.swift b/compiler/compiler/Compiler/Sources/Template/CompilationMetadata.swift index 022e0effc..7cfea1003 100644 --- a/compiler/compiler/Compiler/Sources/Template/CompilationMetadata.swift +++ b/compiler/compiler/Compiler/Sources/Template/CompilationMetadata.swift @@ -3,8 +3,45 @@ import Foundation struct CompilationMetadata: Codable { + let nativeApiMinVersion: String? let classMappings: [String: ValdiClass] let nativeTypes: SerializedTypeScriptNativeTypeResolver + let generatedTypes: [GeneratedTypesSummary] + init(classMappings: [String: ValdiClass], + nativeTypes: SerializedTypeScriptNativeTypeResolver, + nativeApiMinVersion: String?, + generatedTypes: [GeneratedTypesSummary]) { + self.nativeApiMinVersion = nativeApiMinVersion + self.classMappings = classMappings + self.nativeTypes = nativeTypes + self.generatedTypes = generatedTypes + } + + enum CodingKeys: CodingKey { + case nativeApiMinVersion + case classMappings + case nativeTypes + case generatedTypes + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + nativeApiMinVersion = try container.decodeIfPresent(String.self, forKey: .nativeApiMinVersion) + classMappings = try container.decode([String: ValdiClass].self, forKey: .classMappings) + nativeTypes = try container.decode(SerializedTypeScriptNativeTypeResolver.self, forKey: .nativeTypes) + // Compilation metadata is decoded only to restore class mappings and native type resolver + // entries from dependencies. Generated type descriptions are an output-only API snapshot, + // so decoding intentionally does not round-trip them. + generatedTypes = [] + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(nativeApiMinVersion, forKey: .nativeApiMinVersion) + try container.encode(classMappings, forKey: .classMappings) + try container.encode(nativeTypes, forKey: .nativeTypes) + try container.encode(generatedTypes, forKey: .generatedTypes) + } } diff --git a/compiler/compiler/Compiler/Sources/TypeScript/CompanionExecutable.swift b/compiler/compiler/Compiler/Sources/TypeScript/CompanionExecutable.swift index b8b6a3461..3276e73ab 100644 --- a/compiler/compiler/Compiler/Sources/TypeScript/CompanionExecutable.swift +++ b/compiler/compiler/Compiler/Sources/TypeScript/CompanionExecutable.swift @@ -52,6 +52,7 @@ private struct CreateWorkspace: Command { static let type: CommandType = .createWorkspace struct Request: RequestBody { + let nativeApiMinVersion: Int? } struct Response: ResponseBody { @@ -594,8 +595,8 @@ class CompanionExecutable { return processRequest(BatchMinifyJS.self, request).then { return $0.results } } - func createWorkspace() -> Promise { - let request = CreateWorkspace.Request() + func createWorkspace(nativeApiMinVersion: Int?) -> Promise { + let request = CreateWorkspace.Request(nativeApiMinVersion: nativeApiMinVersion) return processRequest(CreateWorkspace.self, request).then { response in response.workspaceId } } diff --git a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptAnnotation.swift b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptAnnotation.swift index 2626f170c..69026af70 100644 --- a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptAnnotation.swift +++ b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptAnnotation.swift @@ -23,16 +23,21 @@ struct ValdiTypeScriptAnnotation { let name: String let range: NSRange let parameters: [String: String]? + let positionalPayload: String? let content: String - init(name: String, parameters: [String: String]?, range: NSRange, content: String) { + init(name: String, parameters: [String: String]?, positionalPayload: String?, range: NSRange, content: String) { self.name = name self.parameters = parameters + self.positionalPayload = positionalPayload self.range = range self.content = content } - private static let annotationRegex = try! NSRegularExpression(pattern: "@([A-z-]+) *(?:\\((?:\\{(.*?)\\})\\))?", options: [.dotMatchesLineSeparators]) + private static let annotationRegex = try! NSRegularExpression( + pattern: "@([A-z-]+) *(?:\\((?:\\{(.*?)\\}|\\s*(\\d+|__PLACEHOLDER__)\\s*)\\))?", + options: [.dotMatchesLineSeparators] + ) static func extractAnnotations(comments: TS.AST.Comments, fileContent: String) throws -> [ValdiTypeScriptAnnotation] { let joinedComments = comments.text @@ -76,7 +81,30 @@ struct ValdiTypeScriptAnnotation { parameters = foundParameters } - annotations.append(ValdiTypeScriptAnnotation(name: annotationName, parameters: parameters, range: totalRange, content: annotationContent)) + let positionalPayloadRange = match.range(at: 3) + let positionalPayload: String? + if positionalPayloadRange.location != NSNotFound && annotationName != ValdiAnnotationType.version.rawValue { + let rangeInFile = NSRange( + location: commentsRange.location + positionalPayloadRange.location, + length: positionalPayloadRange.length + ) + try throwAnnotationError( + message: "Only @Version supports a positional annotation payload", + range: rangeInFile, + inDocument: fileContent + ) + } + if positionalPayloadRange.location != NSNotFound { + positionalPayload = nsString.substring(with: positionalPayloadRange) + } else { + positionalPayload = nil + } + + annotations.append(ValdiTypeScriptAnnotation(name: annotationName, + parameters: parameters, + positionalPayload: positionalPayload, + range: totalRange, + content: annotationContent)) } return annotations diff --git a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCommentedFile.swift b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCommentedFile.swift index 6d9a030c3..0218d6bf3 100644 --- a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCommentedFile.swift +++ b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCommentedFile.swift @@ -60,6 +60,22 @@ class TypeScriptCommentedFile { annotatedSymbol.addMemberAnnotations(memberAnnotations, atIndex: idx) } } + + if let enumDeclaration = annotatedSymbol.symbol.enum { + for (idx, member) in enumDeclaration.members.enumerated() { + guard let memberComments = member.leadingComments else { + continue + } + let memberAnnotations = try ValdiTypeScriptAnnotation.extractAnnotations( + comments: memberComments, + fileContent: fileContent + ) + guard !memberAnnotations.isEmpty else { + continue + } + annotatedSymbol.addMemberAnnotations(memberAnnotations, atIndex: idx) + } + } } return annotatedSymbol diff --git a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompiler.swift b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompiler.swift index 4999524ea..4319a8b52 100644 --- a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompiler.swift +++ b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompiler.swift @@ -63,7 +63,11 @@ final class TypeScriptCompiler { emitDebug: Bool) { self.logger = logger self.companion = companion - self.driver = TypeScriptCompilerCompanionDriver(logger: logger, companion: companion) + self.driver = TypeScriptCompilerCompanionDriver( + logger: logger, + companion: companion, + nativeApiMinVersion: projectConfig.nativeApiMinVersion + ) self.projectConfig = projectConfig self.fileManager = fileManager self.emitDebug = emitDebug diff --git a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompilerCompanionDriver.swift b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompilerCompanionDriver.swift index 0ef32dbad..5f5c2614f 100644 --- a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompilerCompanionDriver.swift +++ b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompilerCompanionDriver.swift @@ -11,13 +11,15 @@ class TypeScriptCompilerCompanionDriver: TypeScriptCompilerDriver { private let logger: ILogger private let companion: CompanionExecutable + private let nativeApiMinVersion: Int? private var workspaceId: Int? private var lock = DispatchSemaphore.newLock() private var currentCreateWorkspacePromise: Promise? - init(logger: ILogger, companion: CompanionExecutable) { + init(logger: ILogger, companion: CompanionExecutable, nativeApiMinVersion: Int?) { self.logger = logger self.companion = companion + self.nativeApiMinVersion = nativeApiMinVersion } func destroyWorkspace() -> Promise { @@ -51,7 +53,7 @@ class TypeScriptCompilerCompanionDriver: TypeScriptCompilerDriver { lock.lock { if self.workspaceId == nil && self.currentCreateWorkspacePromise == nil { logger.trace("Creating TypeScript Workspace project") - self.currentCreateWorkspacePromise = companion.createWorkspace() + self.currentCreateWorkspacePromise = companion.createWorkspace(nativeApiMinVersion: nativeApiMinVersion) needsOnWorkspaceCreatedCallback = true } diff --git a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeExporter.swift b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeExporter.swift index 2e8c0d130..f1067d641 100644 --- a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeExporter.swift +++ b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeExporter.swift @@ -27,6 +27,7 @@ private struct EnumMember { let value: Value let comments: String? + let declaredVersion: String? } private class DocumentIndexMatcher { @@ -151,7 +152,13 @@ final class TypeScriptNativeTypeExporter { } if let function = type.function { - let functionParameters = try function.parameters.map { try parsePropertyOrParameter(propertyLikeDeclaration: $0, references: references) } + let functionParameters = try function.parameters.map { + try parsePropertyOrParameter( + propertyLikeDeclaration: $0, + references: references, + declaredVersion: nil + ) + } let returnValue = try resolveType(type: function.returnValue, references: references) var isSingleCall = false @@ -171,6 +178,8 @@ final class TypeScriptNativeTypeExporter { shouldCallOnWorkerThread = true } else if annotation == .allowSyncCall { allowSyncCall = true + } else if annotation == .version { + // Version annotations are validation metadata and do not affect native code generation. } else { try self.throwAnnotationError(comments: type.leadingComments!, message: "Function only support the @SingleCall, @WorkerThread and @AllowSyncCall annotations") } @@ -347,7 +356,8 @@ final class TypeScriptNativeTypeExporter { type: TS.AST.TSType, isOptional: Bool, leadingComments: TS.AST.Comments?, - references: [TS.AST.TypeReference]) throws -> ValdiModelProperty { + references: [TS.AST.TypeReference], + declaredVersion: String?) throws -> ValdiModelProperty { var parsedType = try resolveType(type: type, references: references) if isOptional { parsedType = .nullable(parsedType) @@ -385,15 +395,19 @@ final class TypeScriptNativeTypeExporter { type: parsedType, comments: resolvedPropertyMetadata?.comments, omitConstructor: resolvedPropertyMetadata?.omitConstructor, - injectableParams: resolvedPropertyMetadata?.injectableParams ?? .empty) + injectableParams: resolvedPropertyMetadata?.injectableParams ?? .empty, + declaredVersion: declaredVersion) } - private func parsePropertyOrParameter(propertyLikeDeclaration: TS.AST.PropertyLikeDeclaration, references: [TS.AST.TypeReference]) throws -> ValdiModelProperty { + private func parsePropertyOrParameter(propertyLikeDeclaration: TS.AST.PropertyLikeDeclaration, + references: [TS.AST.TypeReference], + declaredVersion: String?) throws -> ValdiModelProperty { return try parsePropertyLike(name: propertyLikeDeclaration.name, type: propertyLikeDeclaration.type, isOptional: propertyLikeDeclaration.isOptional, leadingComments: propertyLikeDeclaration.leadingComments, - references: references) + references: references, + declaredVersion: declaredVersion) } func export() -> Promise<(ExportedType, ValdiClassMapping)> { @@ -404,20 +418,22 @@ final class TypeScriptNativeTypeExporter { } } - private func parseEnumMember(enumMember: TS.AST.EnumMember, sequence: EnumMemberSequence) throws -> EnumMember { + private func parseEnumMember(enumMember: TS.AST.EnumMember, + sequence: EnumMemberSequence, + declaredVersion: String?) throws -> EnumMember { if let numberValue = enumMember.numberValue { guard let enumValue = Int(numberValue) else { throw CompilerError("Could not parse number '\(numberValue)' in enum member \(enumMember.name) ") } sequence.setNext(enumValue + 1) - return EnumMember(name: enumMember.name, value: .number(enumValue), comments: enumMember.leadingComments?.text) + return EnumMember(name: enumMember.name, value: .number(enumValue), comments: enumMember.leadingComments?.text, declaredVersion: declaredVersion) } if let stringValue = enumMember.stringValue { - return EnumMember(name: enumMember.name, value: .string(stringValue), comments: enumMember.leadingComments?.text) + return EnumMember(name: enumMember.name, value: .string(stringValue), comments: enumMember.leadingComments?.text, declaredVersion: declaredVersion) } - return EnumMember(name: enumMember.name, value: .number(sequence.assign()), comments: enumMember.leadingComments?.text) + return EnumMember(name: enumMember.name, value: .number(sequence.assign()), comments: enumMember.leadingComments?.text, declaredVersion: declaredVersion) } func exportFunction() -> Promise<(ExportedFunction, ValdiClassMapping)> { @@ -429,7 +445,13 @@ final class TypeScriptNativeTypeExporter { let allowSyncCall = annotatedSymbol.annotations.contains(where: { $0.name == ValdiAnnotationType.allowSyncCall.rawValue }) do { - let parameters = try dumpedFunction.type.parameters.map { try self.parsePropertyOrParameter(propertyLikeDeclaration: $0, references: self.commentedFile.references) } + let parameters = try dumpedFunction.type.parameters.map { + try self.parsePropertyOrParameter( + propertyLikeDeclaration: $0, + references: self.commentedFile.references, + declaredVersion: nil + ) + } let returnType = try self.resolveType(type: dumpedFunction.type.returnValue, references: self.commentedFile.references) let exportedFunction = ExportedFunction(containingIosType: self.iosType, containingAndroidTypeName: self.androidClass, @@ -438,7 +460,8 @@ final class TypeScriptNativeTypeExporter { parameters: parameters, returnType: returnType, allowSyncCall: allowSyncCall, - comments: comments) + comments: comments, + declaredVersion: nativeApiDeclaredVersion(annotations: annotatedSymbol.annotations)) let classMapping = ValdiClassMapping() return Promise(data: (exportedFunction, classMapping)) } catch { @@ -449,13 +472,17 @@ final class TypeScriptNativeTypeExporter { func exportModule() -> Promise<(ExportedModule, ValdiClassMapping)> { do { var model = ValdiModel() + model.tsType = dumpedSymbol.text model.exportAsInterface = true model.cppType = cppType model.iosType = iosType model.androidClassName = androidClass + model.declaredVersion = nativeApiDeclaredVersion(annotations: annotatedSymbol.annotations) + model.isNativeApi = true - for annotatedSymbol in commentedFile.annotatedSymbols where annotatedSymbol.symbol.modifiers?.contains("export") == true { - if let function = annotatedSymbol.symbol.function { + for exportedSymbol in commentedFile.annotatedSymbols where exportedSymbol.symbol.modifiers?.contains("export") == true { + let declaredVersion = nativeApiDeclaredVersion(annotations: exportedSymbol.annotations) + if let function = exportedSymbol.symbol.function { let wrappedType = TS.AST.TSType(name: function.name, leadingComments: nil, function: function.type, @@ -468,11 +495,19 @@ final class TypeScriptNativeTypeExporter { type: wrappedType, isOptional: false, leadingComments: dumpedSymbol.leadingComments, - references: self.commentedFile.references)) + references: self.commentedFile.references, + declaredVersion: declaredVersion)) } - if let variable = annotatedSymbol.symbol.variable { - model.properties.append(try parsePropertyLike(name: variable.name, type: variable.type, isOptional: false, leadingComments: variable.leadingComments, references: self.commentedFile.references)) + if let variable = exportedSymbol.symbol.variable { + model.properties.append(try parsePropertyLike( + name: variable.name, + type: variable.type, + isOptional: false, + leadingComments: variable.leadingComments, + references: self.commentedFile.references, + declaredVersion: declaredVersion + )) } } @@ -494,7 +529,15 @@ final class TypeScriptNativeTypeExporter { do { let enumSequence = EnumMemberSequence() - let enumMembers = try dumpedEnum.members.map { try self.parseEnumMember(enumMember: $0, sequence: enumSequence) } + let enumMembers = try dumpedEnum.members.enumerated().map { index, enumMember in + try self.parseEnumMember( + enumMember: enumMember, + sequence: enumSequence, + declaredVersion: nativeApiDeclaredVersion( + annotations: annotatedSymbol.memberAnnotations[index] ?? [] + ) + ) + } let classMapping = ValdiClassMapping() @@ -504,7 +547,7 @@ final class TypeScriptNativeTypeExporter { TypeScriptAnnotatedSymbol.cleanCommentString($0) } - return EnumCase(name: member.name, value: value, comments: comments) + return EnumCase(name: member.name, value: value, comments: comments, declaredVersion: member.declaredVersion) } let numberCases: [EnumCase] = enumMembers.compactMap { member in guard case let .number(value) = member.value else { return nil } @@ -512,7 +555,7 @@ final class TypeScriptNativeTypeExporter { TypeScriptAnnotatedSymbol.cleanCommentString($0) } - return EnumCase(name: member.name, value: value, comments: comments) + return EnumCase(name: member.name, value: value, comments: comments, declaredVersion: member.declaredVersion) } let valid = stringCases.isEmpty != numberCases.isEmpty @@ -531,7 +574,8 @@ final class TypeScriptNativeTypeExporter { androidTypeName: self.androidClass, cppType: self.cppType, cases: enumCases, - comments: comments) + comments: comments, + declaredVersion: nativeApiDeclaredVersion(annotations: annotatedSymbol.annotations)) return Promise(data: (.enum(exportedEnum), classMapping)) } catch { @@ -548,9 +592,15 @@ final class TypeScriptNativeTypeExporter { do { var properties: [ValdiModelProperty] = [] - for member in dumpedInterface.members { + for (index, member) in dumpedInterface.members.enumerated() { do { - let property = try self.parsePropertyOrParameter(propertyLikeDeclaration: member, references: self.commentedFile.references) + let property = try self.parsePropertyOrParameter( + propertyLikeDeclaration: member, + references: self.commentedFile.references, + declaredVersion: nativeApiDeclaredVersion( + annotations: annotatedSymbol.memberAnnotations[index] ?? [] + ) + ) properties.append(property) } catch let error { throw CompilerError("Failed to parse property \(member.name): \(error.legibleLocalizedDescription)") @@ -565,6 +615,8 @@ final class TypeScriptNativeTypeExporter { model.properties = properties model.comments = comments model.typeParameters = dumpedInterface.typeParameters?.map { ValdiTypeParameter(name: $0.name) } + model.declaredVersion = nativeApiDeclaredVersion(annotations: annotatedSymbol.annotations) + model.isNativeApi = true // Check for usePublicFields parameter in ExportModel annotation if let exportModelAnnotation = self.annotatedSymbol.annotations.first(where: { $0.name == "ExportModel" }) { diff --git a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeResolver.swift b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeResolver.swift index b5e3884c4..2c555072c 100644 --- a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeResolver.swift +++ b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeResolver.swift @@ -54,7 +54,6 @@ final class TypeScriptNativeTypeResolver { private let lock = DispatchSemaphore.newLock() private var typesByPath = [String: [String: TypeScriptNativeType]]() - init(rootURL: URL) { self.rootURL = rootURL } diff --git a/compiler/compiler/Compiler/Sources/Utils/GeneratedSourceFilename.swift b/compiler/compiler/Compiler/Sources/Utils/GeneratedSourceFilename.swift index 9c2f2185d..3000e5e8b 100644 --- a/compiler/compiler/Compiler/Sources/Utils/GeneratedSourceFilename.swift +++ b/compiler/compiler/Compiler/Sources/Utils/GeneratedSourceFilename.swift @@ -10,4 +10,5 @@ import Foundation struct GeneratedSourceFilename { let filename: String let symbolName: String + let src: TypeScriptItemSrc } diff --git a/compiler/compiler/Compiler/Sources/ValdiCompilerRunner.swift b/compiler/compiler/Compiler/Sources/ValdiCompilerRunner.swift index 11cd49f9c..d1cf3f877 100644 --- a/compiler/compiler/Compiler/Sources/ValdiCompilerRunner.swift +++ b/compiler/compiler/Compiler/Sources/ValdiCompilerRunner.swift @@ -423,11 +423,6 @@ class ValdiCompilerRunner { typeScriptCompilerManager: typeScriptCompilerManager, typeScriptAnnotationsManager: typeScriptAnnotationsManager, nativeCodeGenerationManager: nativeCodeGenerationManager)) - builder.append(processor: DumpCompilationMetadataProcessor(projectConfig: configs.projectConfig, - compilerConfig: configs.compilerConfig, - projectClassMappingManager: projectClassMappingManager, - typeScriptCompilationManager: typeScriptCompilerManager, - typeScriptNativeTypeResolver: nativeCodeGenerationManager.nativeTypeResolver)) if !codeGenOnly { builder.append(processor: CompileTypeScriptProcessor(typeScriptCompilerManager: typeScriptCompilerManager, compilerConfig: configs.compilerConfig)) @@ -465,12 +460,28 @@ class ValdiCompilerRunner { if !configs.compilerConfig.generateTSResFiles { if !hotReloadingEnabled && !regenerateValdiModulesBuildFilesOnly { builder.append(postprocessor: GenerateViewClassesProcessor(logger: logger, compilerConfig: configs.compilerConfig)) - builder.append(postprocessor: GenerateModelsProcessor(logger: logger, compilerConfig: configs.compilerConfig)) + builder.append(postprocessor: GenerateModelsProcessor(logger: logger, + compilerConfig: configs.compilerConfig, + generateNativeSources: true)) + builder.append(postprocessor: DumpCompilationMetadataProcessor(projectConfig: configs.projectConfig, + compilerConfig: configs.compilerConfig, + projectClassMappingManager: projectClassMappingManager, + typeScriptCompilationManager: typeScriptCompilerManager, + typeScriptNativeTypeResolver: nativeCodeGenerationManager.nativeTypeResolver)) // GenerateDependencyInjectionDataProcessor must run BEFORE CombineNativeSourcesProcessor // so that Factory classes are included in the combined output for single_file_codegen modules builder.append(postprocessor: GenerateDependencyInjectionDataProcessor(logger: logger, onlyFocusProcessingForModules: configs.compilerConfig.onlyFocusProcessingForModules)) builder.append(postprocessor: CombineNativeSourcesProcessor(logger: logger, compilerConfig: configs.compilerConfig, projectConfig: configs.projectConfig, bundleManager: bundleManager)) builder.append(postprocessor: GeneratedTypesVerificationProcessor(logger: logger, projectConfig: configs.projectConfig)) + } else { + builder.append(postprocessor: GenerateModelsProcessor(logger: logger, + compilerConfig: configs.compilerConfig, + generateNativeSources: false)) + builder.append(postprocessor: DumpCompilationMetadataProcessor(projectConfig: configs.projectConfig, + compilerConfig: configs.compilerConfig, + projectClassMappingManager: projectClassMappingManager, + typeScriptCompilationManager: typeScriptCompilerManager, + typeScriptNativeTypeResolver: nativeCodeGenerationManager.nativeTypeResolver)) } if !codeGenOnly && !regenerateValdiModulesBuildFilesOnly { diff --git a/compiler/compiler/Compiler/Sources/ViewModels/ExportedEnumGenerator.swift b/compiler/compiler/Compiler/Sources/ViewModels/ExportedEnumGenerator.swift index 5b16ed9bc..ad558d863 100644 --- a/compiler/compiler/Compiler/Sources/ViewModels/ExportedEnumGenerator.swift +++ b/compiler/compiler/Compiler/Sources/ViewModels/ExportedEnumGenerator.swift @@ -18,6 +18,7 @@ struct ExportedEnum { } let cases: Cases let comments: String? + let declaredVersion: String? } final class ExportedEnumGenerator: NativeSourceGenerator { diff --git a/compiler/compiler/Compiler/Tests/CompilerTests/NativeApiMetadataTests.swift b/compiler/compiler/Compiler/Tests/CompilerTests/NativeApiMetadataTests.swift new file mode 100644 index 000000000..6836def04 --- /dev/null +++ b/compiler/compiler/Compiler/Tests/CompilerTests/NativeApiMetadataTests.swift @@ -0,0 +1,148 @@ +import Foundation +import XCTest +@testable import Compiler + +final class NativeApiMetadataTests: XCTestCase { + private func apiDescription() -> GeneratedTypeDescription { + return .function( + GeneratedFunctionDescription( + containingIosTypeName: nil, + containingAndroidTypeName: nil, + containingCppTypeName: nil, + functionName: "exportedFunction", + parameters: [], + returnType: PropertyTypeDescription(propType: .void), + declaredVersion: nil, + effectiveVersion: "0" + ) + ) + } + + func testVersionInheritance() { + XCTAssertEqual(nativeApiEffectiveMemberVersion(declared: nil, container: "3"), "3") + XCTAssertEqual(nativeApiEffectiveMemberVersion(declared: "2", container: "3"), "3") + XCTAssertEqual(nativeApiEffectiveMemberVersion(declared: "7", container: "3"), "7") + XCTAssertEqual(nativeApiEffectiveMemberVersion(declared: "__PLACEHOLDER__", container: "3"), "__PLACEHOLDER__") + XCTAssertEqual(nativeApiEffectiveMemberVersion(declared: "future", container: "3"), "future") + XCTAssertEqual(nativeApiEffectiveMemberVersion(declared: "2", container: "future"), "future") + } + + func testVersionsAlwaysEncodeAsStringsOrNull() throws { + let data = try JSONEncoder().encode(apiDescription()) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let function = try XCTUnwrap(object["function"] as? [String: Any]) + + XCTAssertTrue(function["declaredVersion"] is NSNull) + XCTAssertEqual(function["effectiveVersion"] as? String, "0") + } + + func testGeneratedModelDescriptionUsesExportedVersionMetadata() throws { + var model = ValdiModel() + model.declaredVersion = "2" + model.isNativeApi = true + model.properties = [ + ValdiModelProperty( + name: "value", + type: .string, + comments: nil, + omitConstructor: nil, + injectableParams: .empty, + declaredVersion: "5" + ), + ] + + let description = GeneratedNativeClassDescription(model: model, baseline: "0") + XCTAssertTrue(description.isNativeApi) + XCTAssertEqual(description.declaredVersion, "2") + XCTAssertEqual(description.effectiveVersion, "2") + XCTAssertEqual(description.properties[0].declaredVersion, "5") + XCTAssertEqual(description.properties[0].effectiveVersion, "5") + + var ordinaryModel = ValdiModel() + ordinaryModel.properties = [ + ValdiModelProperty( + name: "value", + type: .string, + comments: nil, + omitConstructor: nil, + injectableParams: .empty, + declaredVersion: nil + ), + ] + + let ordinaryDescription = GeneratedNativeClassDescription(model: ordinaryModel, baseline: "9") + XCTAssertFalse(ordinaryDescription.isNativeApi) + XCTAssertNil(ordinaryDescription.effectiveVersion) + } + + func testCompilationMetadataEncodingAndLegacyDecoding() throws { + let metadata = CompilationMetadata( + classMappings: [:], + nativeTypes: SerializedTypeScriptNativeTypeResolver(entries: []), + nativeApiMinVersion: "0", + generatedTypes: [ + GeneratedTypesSummary( + sourceFilePath: "module/src/Api", + generatedTypes: [apiDescription()] + ), + ] + ) + let encoded = try JSONEncoder().encode(metadata) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + + XCTAssertEqual(object["nativeApiMinVersion"] as? String, "0") + let generatedTypes = try XCTUnwrap(object["generatedTypes"] as? [[String: Any]]) + XCTAssertEqual(generatedTypes.count, 1) + XCTAssertEqual(generatedTypes[0]["sourceFilePath"] as? String, "module/src/Api") + + let decoded = try JSONDecoder().decode(CompilationMetadata.self, from: encoded) + XCTAssertTrue(decoded.generatedTypes.isEmpty) + + let unversionedMetadata = CompilationMetadata( + classMappings: [:], + nativeTypes: SerializedTypeScriptNativeTypeResolver(entries: []), + nativeApiMinVersion: nil, + generatedTypes: [] + ) + let unversionedData = try JSONEncoder().encode(unversionedMetadata) + let unversionedObject = try XCTUnwrap(JSONSerialization.jsonObject(with: unversionedData) as? [String: Any]) + XCTAssertTrue(unversionedObject["nativeApiMinVersion"] is NSNull) + + let legacy = Data(#"{"classMappings":{},"nativeTypes":{"entries":[]}}"#.utf8) + let decodedLegacy = try JSONDecoder().decode(CompilationMetadata.self, from: legacy) + + XCTAssertNil(decodedLegacy.nativeApiMinVersion) + XCTAssertTrue(decodedLegacy.generatedTypes.isEmpty) + } + + func testWireTypeDescriptionIsRecursive() throws { + let callback = ValdiModelPropertyType.function( + parameters: [ + ValdiModelProperty( + name: "values", + type: .array(elementType: .map(keyType: .string, valueType: .nullable(.long))), + comments: "not metadata", + omitConstructor: nil, + injectableParams: .empty, + declaredVersion: nil + ), + ], + returnType: .promise(typeArgument: .genericTypeParameter(name: "T")), + isSingleCall: true, + shouldCallOnWorkerThread: true, + allowSyncCall: true + ) + let data = try JSONEncoder().encode(PropertyTypeDescription(propType: callback)) + let json = String(decoding: data, as: UTF8.self) + + XCTAssertTrue(json.contains(#""typeStr":"function""#)) + XCTAssertTrue(json.contains(#""typeStr":"array""#)) + XCTAssertTrue(json.contains(#""typeStr":"map""#)) + XCTAssertTrue(json.contains(#""typeStr":"nullable""#)) + XCTAssertTrue(json.contains(#""typeStr":"promise""#)) + XCTAssertTrue(json.contains(#""typeParameterName":"T""#)) + XCTAssertFalse(json.contains("declaredVersion")) + XCTAssertFalse(json.contains("not metadata")) + XCTAssertFalse(json.contains("sourcePosition")) + } +} diff --git a/compiler/compiler/Compiler/Tests/CompilerTests/ValdiAnnotationTests.swift b/compiler/compiler/Compiler/Tests/CompilerTests/ValdiAnnotationTests.swift index 3a73fe24d..c8c4402fe 100644 --- a/compiler/compiler/Compiler/Tests/CompilerTests/ValdiAnnotationTests.swift +++ b/compiler/compiler/Compiler/Tests/CompilerTests/ValdiAnnotationTests.swift @@ -94,6 +94,51 @@ final class ValdiAnnotationTests: XCTestCase { XCTAssertEqual(result.first?.parameters, ["ios": "blah"]) } + func testVersionAnnotationWithNumericPayload() throws { + let content = """ +/** + * Native model docs. + * @Version( 42 ) + * @ExportModel + */ +""" + let annotations = try extractAnnotations(content) + + XCTAssertEqual(annotations.map(\.name), ["Version", "ExportModel"]) + XCTAssertEqual(annotations.first?.content, "@Version( 42 )") + XCTAssertEqual(annotations.first?.positionalPayload, "42") + XCTAssertEqual(nativeApiDeclaredVersion(annotations: annotations), "42") + XCTAssertEqual( + TypeScriptAnnotatedSymbol.mergedCommentsWithoutAnnotations( + fullComments: content, + annotations: annotations + ), + "Native model docs." + ) + } + + func testVersionAnnotationWithPlaceholderPayload() throws { + let content = """ +/** + * @Version(__PLACEHOLDER__) + * @NativeClass + */ +""" + let annotations = try extractAnnotations(content) + + XCTAssertEqual(annotations.map(\.name), ["Version", "NativeClass"]) + XCTAssertEqual(annotations.first?.content, "@Version(__PLACEHOLDER__)") + XCTAssertEqual(annotations.first?.positionalPayload, "__PLACEHOLDER__") + XCTAssertEqual(nativeApiDeclaredVersion(annotations: annotations), "__PLACEHOLDER__") + XCTAssertEqual( + TypeScriptAnnotatedSymbol.mergedCommentsWithoutAnnotations( + fullComments: content, + annotations: annotations + ), + "" + ) + } + func testBadCases() throws { var content: String = "" diff --git a/src/valdi_modules/src/valdi/valdi_core/src/CompilerIntrinsics.ts b/src/valdi_modules/src/valdi/valdi_core/src/CompilerIntrinsics.ts index 44dfbb4ed..b7f84fb16 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/CompilerIntrinsics.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/CompilerIntrinsics.ts @@ -1,4 +1,15 @@ import { AnyRenderFunction } from 'valdi_core/src/AnyRenderFunction'; +import { ValdiRuntime } from './ValdiRuntime'; + +declare global { + /** Placeholder for a native API version allocated automatically when a change merges. */ + const __PLACEHOLDER__: number; +} + +declare const runtime: ValdiRuntime; +const PLACEHOLDER_VERSION = Number.MAX_SAFE_INTEGER; + +(globalThis as typeof globalThis & { __PLACEHOLDER__: number }).__PLACEHOLDER__ = PLACEHOLDER_VERSION; type GetTypeOfChildren = TViewModel extends { children: any } ? TViewModel['children'] : never; @@ -17,3 +28,12 @@ export declare function $slot(value: T | undefined) * @param value the named slots object that should be passed as the view model "children" property. */ export declare function $namedSlots(value: GetTypeOfChildren): GetTypeOfChildren; + +/** + * Compiler intrinsic to guard access to APIs annotated with @Version. + * The compiler recognizes numeric literals and __PLACEHOLDER__, and treats the + * guarded block as safe for declarations introduced at that version or lower. + */ +export function isVersionAtLeast(version: number): boolean { + return version === PLACEHOLDER_VERSION || runtime.apiVersion >= version; +} diff --git a/src/valdi_modules/src/valdi/valdi_core/src/ValdiRuntime.d.ts b/src/valdi_modules/src/valdi/valdi_core/src/ValdiRuntime.d.ts index f9b26acfc..9e5bc69fd 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/ValdiRuntime.d.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/ValdiRuntime.d.ts @@ -87,6 +87,8 @@ export interface LoadedAssetSkCodecMetadata { export type LoadedAssetMetadata = LoadedAssetImageMetadata | LoadedAssetLottieMetadata | LoadedAssetSkCodecMetadata; export interface ValdiRuntime extends RuntimeBase { + apiVersion: number; + postMessage(contextId: string, command: string, params: any): void; getFrameForElementId( contextId: string, diff --git a/src/valdi_modules/src/valdi/valdi_core/test/CompilerIntrinsics.spec.ts b/src/valdi_modules/src/valdi/valdi_core/test/CompilerIntrinsics.spec.ts new file mode 100644 index 000000000..bff69bae8 --- /dev/null +++ b/src/valdi_modules/src/valdi/valdi_core/test/CompilerIntrinsics.spec.ts @@ -0,0 +1,18 @@ +import 'jasmine/src/jasmine'; +import { isVersionAtLeast } from '../src/CompilerIntrinsics'; +import { ValdiRuntime } from '../src/ValdiRuntime'; + +declare const runtime: ValdiRuntime; + +describe('CompilerIntrinsics', () => { + it('enables placeholder-versioned API guards during development', () => { + expect(isVersionAtLeast(__PLACEHOLDER__)).toBeTrue(); + }); + + it('continues comparing concrete versions with the runtime API version', () => { + const currentVersion = runtime.apiVersion; + + expect(isVersionAtLeast(0)).toBe(currentVersion >= 0); + expect(isVersionAtLeast(2147483647)).toBe(currentVersion >= 2147483647); + }); +}); diff --git a/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.cpp b/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.cpp index 0b1acfebc..2afa39ddf 100644 --- a/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.cpp +++ b/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.cpp @@ -2341,6 +2341,13 @@ void JavaScriptRuntime::buildContext(Valdi::IJavaScriptContext& context, return; } + auto jsApiVersion = context.newNumber(_resourceManager.getApiVersion()); + + context.setObjectProperty(runtimeObject.get(), "apiVersion", jsApiVersion.get(), exceptionTracker); + if (!exceptionTracker) { + return; + } + auto jsEnableDebugger = context.newBool(_enableDebugger); context.setObjectProperty(runtimeObject.get(), "isDebugEnabled", jsEnableDebugger.get(), exceptionTracker); diff --git a/valdi/src/valdi/runtime/Resources/ResourceManager.cpp b/valdi/src/valdi/runtime/Resources/ResourceManager.cpp index 031ee2feb..ad31bf450 100644 --- a/valdi/src/valdi/runtime/Resources/ResourceManager.cpp +++ b/valdi/src/valdi/runtime/Resources/ResourceManager.cpp @@ -36,13 +36,17 @@ #include "valdi_core/cpp/Interfaces/ILogger.hpp" #include "valdi_core/cpp/Utils/FlatSet.hpp" #include "valdi_core/cpp/Utils/Parser.hpp" +#include "valdi_core/cpp/Utils/TextParser.hpp" #include "valdi_core/cpp/Utils/Trace.hpp" #include "valdi_core/cpp/Utils/ValueMap.hpp" #include "valdi_core/cpp/Utils/ValueUtils.hpp" #include +#include #include #include +#include +#include namespace Valdi { @@ -77,6 +81,37 @@ static StringBox resolveSourceMapFilePath(const StringBox& modulePath) { return modulePath.append(".map.json"); } +static int32_t parseApiVersion(const BytesView& bytes, ILogger& logger) { + TextParser parser(std::string_view(reinterpret_cast(bytes.data()), bytes.size())); + + parser.tryParseWhitespaces(); + auto version = parser.parseUInt(); + parser.tryParseWhitespaces(); + + if (!version.has_value() || !parser.isAtEnd() || version.value() > std::numeric_limits::max()) { + VALDI_WARN(logger, "Invalid valdi_api_version resource content. Expected a non-negative integer."); + return 0; + } + + return static_cast(version.value()); +} + +int32_t ResourceManager::getApiVersion() { + std::lock_guard guard(_mutex); + if (_apiVersion.has_value()) { + return _apiVersion.value(); + } + + auto content = _resourceLoader->loadModuleContent(STRING_LITERAL("valdi_api_version")); + if (!content) { + _apiVersion = 0; + return _apiVersion.value(); + } + + _apiVersion = parseApiVersion(content.value(), _logger); + return _apiVersion.value(); +} + Result> ResourceManager::getArchiveForModule(const StringBox& modulePath, bool useMmap, const Path& mmapCacheDir, diff --git a/valdi/src/valdi/runtime/Resources/ResourceManager.hpp b/valdi/src/valdi/runtime/Resources/ResourceManager.hpp index 937cfba9c..826204563 100644 --- a/valdi/src/valdi/runtime/Resources/ResourceManager.hpp +++ b/valdi/src/valdi/runtime/Resources/ResourceManager.hpp @@ -8,7 +8,9 @@ #pragma once +#include #include +#include #include #include "valdi/runtime/Resources/Bundle.hpp" @@ -107,6 +109,8 @@ class ResourceManager : public SimpleRefCountable { void warmUpBundles(const std::vector& modulePaths); + int32_t getApiVersion(); + bool enableAccessibility() const; bool enableDeferredGC() const; bool isLazyModulePreloadingEnabled() const; @@ -132,6 +136,7 @@ class ResourceManager : public SimpleRefCountable { bool _enableTSN = true; bool _inlineAssetsEnabled = true; bool _hotReloaderEnabled; + std::optional _apiVersion; std::atomic_bool _lazyModulePreloadingEnabled = true; Path _mmapCacheDirectory; diff --git a/valdi/test/integration/Runtime_tests.cpp b/valdi/test/integration/Runtime_tests.cpp index 6fbb33735..1022f52d7 100644 --- a/valdi/test/integration/Runtime_tests.cpp +++ b/valdi/test/integration/Runtime_tests.cpp @@ -260,6 +260,16 @@ static Result callFunctionSync(RuntimeWrapper& wrapper, return callFunctionSync(wrapper, nullptr, moduleName, functionName, std::move(params)); } +TEST_P(RuntimeFixture, exposesDefaultApiVersion) { + std::string evalBody = "return runtime.apiVersion;"; + + auto evalResult = wrapper.runtime->getJavaScriptRuntime()->evaluateScript( + makeShared(evalBody)->toBytesView(), STRING_LITERAL("eval.js")); + + ASSERT_TRUE(evalResult) << evalResult.description(); + ASSERT_EQ(0, evalResult.value().toInt()); +} + TEST_P(RuntimeFixture, canLoadSimpleViewTree) { auto tree = wrapper.createViewNodeTreeAndContext("test", "BasicViewTree"); From b34177d2c9751d5028f164721783c5e66094190e Mon Sep 17 00:00:00 2001 From: Simon Corsin Date: Tue, 28 Jul 2026 10:55:06 -0500 Subject: [PATCH 03/10] fix: harden hierarchical color palette handling --- .../Attributes/DefaultAttributeProcessors.cpp | 4 ++ valdi/src/valdi/runtime/Context/ViewNode.cpp | 15 +++--- valdi/src/valdi/runtime/RuntimeManager.cpp | 2 + valdi/test/integration/Runtime_tests.cpp | 19 +++++++ .../runtime/AttributeProcessors_tests.cpp | 14 +++++ .../runtime/ColorPaletteManager_tests.cpp | 24 +++++++++ valdi/test/runtime/ViewNode_tests.cpp | 38 +++++++++++++ .../cpp/Attributes/ColorPalette.cpp | 53 +++++++++++++------ .../cpp/Attributes/ColorPalette.hpp | 10 ++-- 9 files changed, 153 insertions(+), 26 deletions(-) diff --git a/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.cpp b/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.cpp index ec22f05e6..953457c07 100644 --- a/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.cpp +++ b/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.cpp @@ -360,6 +360,10 @@ static Result postprocessBoxShadow(bool isRightToLeft, Ref bo } Result postprocessBoxShadow(ViewNode& viewNode, const Value& in) { + if (!in.isArray()) { + return in; + } + constexpr size_t kBoxShadowColorIndex = 4; auto resolvedBoxShadow = resolveColorAtIndexInArray(viewNode, in, kBoxShadowColorIndex); if (!resolvedBoxShadow) { diff --git a/valdi/src/valdi/runtime/Context/ViewNode.cpp b/valdi/src/valdi/runtime/Context/ViewNode.cpp index 7834698be..9fdb7c360 100644 --- a/valdi/src/valdi/runtime/Context/ViewNode.cpp +++ b/valdi/src/valdi/runtime/Context/ViewNode.cpp @@ -210,10 +210,13 @@ void ViewNode::setColorPaletteName(ViewTransactionScope& viewTransactionScope, c colorPalette = getParentResolvedColorPalette(); setHasOveriddenColorPalette(false); } else { - SC_ASSERT(_viewNodeTree != nullptr, "Cannot resolve color palette without a ViewNodeTree"); - colorPalette = - _viewNodeTree->getViewManagerContext()->getAttributesManager().getColorPaletteManager()->getColorPalette( - colorPaletteName); + if (_viewNodeTree != nullptr) { + const auto& viewManagerContext = _viewNodeTree->getViewManagerContext(); + if (viewManagerContext != nullptr) { + colorPalette = + viewManagerContext->getAttributesManager().getColorPaletteManager()->getColorPalette(colorPaletteName); + } + } setHasOveriddenColorPalette(true); } @@ -1583,9 +1586,7 @@ void ViewNode::insertChildAt(ViewTransactionScope& viewTransactionScope, const R if (getChildCount() > kMaxChildrenBeforeIndexing && _childrenIndexer == nullptr) { _childrenIndexer = std::make_unique(this); } - if (_colorPalette != nullptr) { - child->setInheritedColorPalette(viewTransactionScope, _colorPalette); - } + child->setInheritedColorPalette(viewTransactionScope, _colorPalette); setCalculatedViewportHasChildNeedsUpdate(); diff --git a/valdi/src/valdi/runtime/RuntimeManager.cpp b/valdi/src/valdi/runtime/RuntimeManager.cpp index d06638a49..662673ecc 100644 --- a/valdi/src/valdi/runtime/RuntimeManager.cpp +++ b/valdi/src/valdi/runtime/RuntimeManager.cpp @@ -150,6 +150,8 @@ void RuntimeManager::postInit() { } void RuntimeManager::fullTeardown() { + _colorPaletteManager->setListener(nullptr); + if (_anrDetector != nullptr) { _anrDetector->stop(); _anrDetector = nullptr; diff --git a/valdi/test/integration/Runtime_tests.cpp b/valdi/test/integration/Runtime_tests.cpp index 0507cfde0..a44fa533a 100644 --- a/valdi/test/integration/Runtime_tests.cpp +++ b/valdi/test/integration/Runtime_tests.cpp @@ -7672,6 +7672,25 @@ TEST_P(RuntimeFixture, supportsCustomColorPalette) { ASSERT_EQ(makeColorPaletteTestView(65535, 8388863), getRootView(tree)); } +TEST_P(RuntimeFixture, colorPaletteManagerRemainsUsableAfterRuntimeManagerTeardown) { + Ref colorPaletteManager; + { + RuntimeWrapper temporaryWrapper(getJsBridge(), getTSNMode()); + colorPaletteManager = temporaryWrapper.standaloneRuntime->getViewManagerContext() + ->getAttributesManager() + .getColorPaletteManager(); + temporaryWrapper.teardown(); + } + + colorPaletteManager->configureColorPalette(STRING_LITERAL("dark"), + {{STRING_LITERAL("background"), Color::rgba(255, 0, 0, 1.0)}}); + colorPaletteManager->setActiveColorPalette(STRING_LITERAL("dark")); + + ASSERT_EQ(STRING_LITERAL("dark"), colorPaletteManager->getActiveColorPalette()->getName()); + ASSERT_EQ(Color::rgba(255, 0, 0, 1.0), + colorPaletteManager->getActiveColorPalette()->getColorForName(STRING_LITERAL("background")).value()); +} + TEST_P(RuntimeFixture, canSwitchActiveCustomColorPalette) { auto tree = wrapper.createViewNodeTreeAndContext(STRING_LITERAL("ColorPaletteTest@test/src/ColorPaletteTest"), Value(makeShared()), diff --git a/valdi/test/runtime/AttributeProcessors_tests.cpp b/valdi/test/runtime/AttributeProcessors_tests.cpp index bc6da57b1..c26986c8b 100644 --- a/valdi/test/runtime/AttributeProcessors_tests.cpp +++ b/valdi/test/runtime/AttributeProcessors_tests.cpp @@ -1,3 +1,4 @@ +#include "ViewNodeTestsUtils.hpp" #include "valdi/runtime/Attributes/DefaultAttributeProcessors.hpp" #include "valdi/runtime/Attributes/ValueConverters.hpp" #include "valdi_core/cpp/Attributes/ColorPalette.hpp" @@ -55,6 +56,19 @@ static Ref makeTestColorPalette() { return colorPalette; } +TEST(AttributeProcessor, boxShadowNoneClearsShadow) { + ViewNodeTestsDependencies dependencies; + auto viewNode = dependencies.createView(); + + auto preprocessed = preprocessBoxShadow(Value(STRING_LITERAL("none"))); + ASSERT_TRUE(preprocessed) << preprocessed.description(); + ASSERT_TRUE(preprocessed.value().isUndefined()); + + auto postprocessed = postprocessBoxShadow(*viewNode, preprocessed.value()); + ASSERT_TRUE(postprocessed) << postprocessed.description(); + ASSERT_TRUE(postprocessed.value().isUndefined()); +} + TEST(AttributeProcessor, canParseSimpleBackground) { auto colorPalette = makeTestColorPalette(); diff --git a/valdi/test/runtime/ColorPaletteManager_tests.cpp b/valdi/test/runtime/ColorPaletteManager_tests.cpp index 5c2ef1b0d..4b9544eb6 100644 --- a/valdi/test/runtime/ColorPaletteManager_tests.cpp +++ b/valdi/test/runtime/ColorPaletteManager_tests.cpp @@ -76,6 +76,18 @@ TEST(ColorPaletteManager, notifiesWhenActivePaletteChangesOnly) { ASSERT_TRUE(listener.lastActiveColorPaletteChanged); } +TEST(ColorPaletteManager, doesNotNotifyAfterListenerIsCleared) { + ColorPaletteManager manager; + TestColorPaletteManagerListener listener; + manager.setListener(&listener); + manager.setListener(nullptr); + + manager.configureColorPalette(STRING_LITERAL("dark"), {{STRING_LITERAL("background"), Color::rgba(0, 0, 0, 1.0)}}); + manager.setActiveColorPalette(STRING_LITERAL("dark")); + + ASSERT_EQ(0, listener.updateCount); +} + TEST(ColorPaletteManager, createsDefaultInitializedPaletteWhenActivatingUnknownName) { ColorPaletteManager manager; @@ -86,4 +98,16 @@ TEST(ColorPaletteManager, createsDefaultInitializedPaletteWhenActivatingUnknownN manager.getActiveColorPalette()->getColorForName(STRING_LITERAL("red")).value()); } +TEST(ColorPaletteManager, returnsIndependentPaletteSnapshots) { + ColorPaletteManager manager; + + auto palettes = manager.getColorPalettes(); + auto darkPalette = manager.getColorPalette(STRING_LITERAL("dark")); + + ASSERT_EQ(palettes.end(), palettes.find(STRING_LITERAL("dark"))); + + auto updatedPalettes = manager.getColorPalettes(); + ASSERT_EQ(darkPalette, updatedPalettes.at(STRING_LITERAL("dark"))); +} + } // namespace ValdiTest diff --git a/valdi/test/runtime/ViewNode_tests.cpp b/valdi/test/runtime/ViewNode_tests.cpp index f4f12bd85..af80e0cd4 100644 --- a/valdi/test/runtime/ViewNode_tests.cpp +++ b/valdi/test/runtime/ViewNode_tests.cpp @@ -17,6 +17,44 @@ void assertAllFlagsAreUpToDate(ViewNode* viewNode) { } } +TEST(ViewNode, settingColorPaletteOnDetachedNodeClearsResolvedPalette) { + ViewNodeTestsDependencies utils; + auto viewNode = utils.createView(); + viewNode->setViewNodeTree(nullptr); + + viewNode->setColorPaletteName(utils.getViewTransactionScope(), STRING_LITERAL("dark")); + + ASSERT_EQ(nullptr, viewNode->getResolvedColorPalette()); +} + +TEST(ViewNode, settingColorPaletteWithoutViewManagerContextClearsResolvedPalette) { + ViewNodeTestsDependencies utils; + auto viewNode = utils.createView(); + + ASSERT_EQ(nullptr, viewNode->getViewNodeTree()->getViewManagerContext()); + + viewNode->setColorPaletteName(utils.getViewTransactionScope(), STRING_LITERAL("dark")); + + ASSERT_EQ(nullptr, viewNode->getResolvedColorPalette()); +} + +TEST(ViewNode, reparentingToNodeWithoutPaletteClearsInheritedPalette) { + ViewNodeTestsDependencies utils; + auto oldParent = utils.createLayout(); + auto newParent = utils.createLayout(); + auto child = utils.createLayout(); + + oldParent->appendChild(utils.getViewTransactionScope(), child); + ASSERT_NE(nullptr, child->getResolvedColorPalette()); + + newParent->setInheritedColorPalette(utils.getViewTransactionScope(), nullptr); + ASSERT_EQ(nullptr, newParent->getResolvedColorPalette()); + + newParent->appendChild(utils.getViewTransactionScope(), child); + + ASSERT_EQ(nullptr, child->getResolvedColorPalette()); +} + TEST(ViewNode, canInsertChildren) { ViewNodeTestsDependencies utils; diff --git a/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.cpp b/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.cpp index d6964e8e5..20bbba2ea 100644 --- a/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.cpp +++ b/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.cpp @@ -232,51 +232,74 @@ ColorPaletteManager::ColorPaletteManager() : _activeColorPalette(makeShared& ColorPaletteManager::getActiveColorPalette() const { +Ref ColorPaletteManager::getActiveColorPalette() const { + std::lock_guard guard(_mutex); return _activeColorPalette; } -const Ref& ColorPaletteManager::getColorPalette(const StringBox& name) { +Ref ColorPaletteManager::getColorPalette(const StringBox& name) { + std::lock_guard guard(_mutex); return getOrCreateColorPalette(name); } -const FlatMap>& ColorPaletteManager::getColorPalettes() const { +FlatMap> ColorPaletteManager::getColorPalettes() const { + std::lock_guard guard(_mutex); return _colorPaletteByName; } void ColorPaletteManager::configureColorPalette(const StringBox& name, const FlatMap& colors) { - const auto& colorPalette = getOrCreateColorPalette(name); - if (colorPalette->updateColors(colors)) { - notifyListener(*colorPalette, false); + Ref colorPalette; + { + std::lock_guard guard(_mutex); + colorPalette = getOrCreateColorPalette(name); + if (!colorPalette->updateColors(colors)) { + return; + } } + + notifyListener(*colorPalette, false); } void ColorPaletteManager::setActiveColorPalette(const StringBox& name) { - if (name == _activeColorPalette->getName()) { - return; + Ref colorPalette; + { + std::lock_guard guard(_mutex); + if (name == _activeColorPalette->getName()) { + return; + } + + colorPalette = getOrCreateColorPalette(name); + _activeColorPalette = colorPalette; } - _activeColorPalette = getOrCreateColorPalette(name); - notifyListener(*_activeColorPalette, true); + notifyListener(*colorPalette, true); } void ColorPaletteManager::setListener(ColorPaletteManagerListener* listener) { + std::lock_guard guard(_mutex); _listener = listener; } -const Ref& ColorPaletteManager::getOrCreateColorPalette(const StringBox& name) { +Ref ColorPaletteManager::getOrCreateColorPalette(const StringBox& name) { auto it = _colorPaletteByName.find(name); if (it != _colorPaletteByName.end()) { return it->second; } - _colorPaletteByName[name] = makeShared(name); - return _colorPaletteByName[name]; + auto colorPalette = makeShared(name); + _colorPaletteByName[name] = colorPalette; + return colorPalette; } void ColorPaletteManager::notifyListener(const ColorPalette& colorPalette, bool activeColorPaletteChanged) { - if (_listener != nullptr) { - _listener->onColorPaletteManagerUpdated(*this, colorPalette, activeColorPaletteChanged); + ColorPaletteManagerListener* listener; + { + std::lock_guard guard(_mutex); + listener = _listener; + } + + if (listener != nullptr) { + listener->onColorPaletteManagerUpdated(*this, colorPalette, activeColorPaletteChanged); } } diff --git a/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.hpp b/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.hpp index 9535bd3bd..b61cc6bd2 100644 --- a/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.hpp +++ b/valdi_core/src/valdi_core/cpp/Attributes/ColorPalette.hpp @@ -8,6 +8,7 @@ #pragma once #include "valdi_core/cpp/Utils/FlatMap.hpp" +#include "valdi_core/cpp/Utils/Mutex.hpp" #include "valdi_core/cpp/Utils/Shared.hpp" #include "valdi_core/cpp/Utils/StringBox.hpp" @@ -96,9 +97,9 @@ class ColorPaletteManager : public SharedPtrRefCountable { ColorPaletteManager(); ~ColorPaletteManager() override; - const Ref& getActiveColorPalette() const; - const Ref& getColorPalette(const StringBox& name); - const FlatMap>& getColorPalettes() const; + Ref getActiveColorPalette() const; + Ref getColorPalette(const StringBox& name); + FlatMap> getColorPalettes() const; void configureColorPalette(const StringBox& name, const FlatMap& colors); void setActiveColorPalette(const StringBox& name); @@ -106,11 +107,12 @@ class ColorPaletteManager : public SharedPtrRefCountable { void setListener(ColorPaletteManagerListener* listener); private: + mutable Mutex _mutex; FlatMap> _colorPaletteByName; Ref _activeColorPalette; ColorPaletteManagerListener* _listener = nullptr; - const Ref& getOrCreateColorPalette(const StringBox& name); + Ref getOrCreateColorPalette(const StringBox& name); void notifyListener(const ColorPalette& colorPalette, bool activeColorPaletteChanged); }; From bb46bd54daf24f2748939c8dfb4cc807ddb3847c Mon Sep 17 00:00:00 2001 From: Simon Corsin Date: Thu, 6 Aug 2026 14:19:41 -0700 Subject: [PATCH 04/10] fix: expose runtime color palette manager --- valdi/src/valdi/runtime/Runtime.cpp | 4 ++++ valdi/src/valdi/runtime/Runtime.hpp | 1 + 2 files changed, 5 insertions(+) diff --git a/valdi/src/valdi/runtime/Runtime.cpp b/valdi/src/valdi/runtime/Runtime.cpp index 4b243aa08..bd1b5078e 100644 --- a/valdi/src/valdi/runtime/Runtime.cpp +++ b/valdi/src/valdi/runtime/Runtime.cpp @@ -782,6 +782,10 @@ void Runtime::registerJavaScriptModuleFactory(const Ref _javaScriptRuntime->registerJavaScriptModuleFactory(moduleFactory); } +const Ref& Runtime::getColorPaletteManager() const { + return _colorPaletteManager; +} + void Runtime::configureColorPalette(const StringBox& name, const Value& colorPaletteMap) { if (colorPaletteMap.isMap()) { FlatMap colors; diff --git a/valdi/src/valdi/runtime/Runtime.hpp b/valdi/src/valdi/runtime/Runtime.hpp index d70efc309..150d0ca2a 100644 --- a/valdi/src/valdi/runtime/Runtime.hpp +++ b/valdi/src/valdi/runtime/Runtime.hpp @@ -266,6 +266,7 @@ class Runtime final : public IDebuggerServiceListener, IJavaScriptRuntimeListene void emitInitMetrics(); + const Ref& getColorPaletteManager() const; void configureColorPalette(const StringBox& name, const Value& colorPaletteMap) override; void setActiveColorPalette(const StringBox& name) override; From fe9a48f3b495a53dc1840db5120515eae6e5848e Mon Sep 17 00:00:00 2001 From: Simon Corsin Date: Fri, 7 Aug 2026 09:46:49 -0700 Subject: [PATCH 05/10] Strengthen native API version compatibility validation --- .../companion/src/VersioningValidator.spec.ts | 1194 ++++++++++++++++- compiler/companion/src/VersioningValidator.ts | 332 ++++- 2 files changed, 1429 insertions(+), 97 deletions(-) diff --git a/compiler/companion/src/VersioningValidator.spec.ts b/compiler/companion/src/VersioningValidator.spec.ts index c0d37ede7..b1eaab33f 100644 --- a/compiler/companion/src/VersioningValidator.spec.ts +++ b/compiler/companion/src/VersioningValidator.spec.ts @@ -2,7 +2,11 @@ import 'ts-jest'; import * as ts from 'typescript'; import { Workspace } from './Workspace'; -function createWorkspaceWithFile(contents: string, nativeApiMinVersion: number | undefined): Workspace { +function createWorkspaceWithFile( + contents: string, + nativeApiMinVersion: number | undefined, + fileName: string, +): Workspace { const workspace = new Workspace( '/', false, @@ -12,75 +16,610 @@ function createWorkspaceWithFile(contents: string, nativeApiMinVersion: number | module: ts.ModuleKind.CommonJS, lib: ['lib.es2015.d.ts'], strict: true, + jsx: ts.JsxEmit.Preserve, }, nativeApiMinVersion, ); + workspace.registerInMemoryFile(fileName, contents); + workspace.addSourceFileAtPath(fileName); + return workspace; +} + +function getDiagnosticTexts(contents: string, nativeApiMinVersion?: number): string[] { + const workspace = createWorkspaceWithFile(contents, nativeApiMinVersion, '/file.ts'); + const diagnostics = workspace.getDiagnosticsSync('/file.ts').diagnostics; + workspace.destroy(); + return diagnostics.map((diagnostic) => diagnostic.text); +} + +function getJsxDiagnosticTexts(contents: string): string[] { + const workspace = createWorkspaceWithFile(contents, undefined, '/file.tsx'); + const diagnostics = workspace.getDiagnosticsSync('/file.tsx').diagnostics; + workspace.destroy(); + return diagnostics.map((diagnostic) => diagnostic.text); +} + +function getDiagnosticTextsWithImportedFile( + contents: string, + importedContents: string, + nativeApiMinVersion: number | undefined, +): string[] { + const workspace = createWorkspaceWithFile(importedContents, nativeApiMinVersion, '/models.ts'); workspace.registerInMemoryFile('/file.ts', contents); workspace.addSourceFileAtPath('/file.ts'); - return workspace; + const diagnostics = workspace.getDiagnosticsSync('/file.ts').diagnostics; + workspace.destroy(); + return diagnostics.map((diagnostic) => diagnostic.text); } -function getDiagnosticTexts(contents: string, nativeApiMinVersion?: number): string[] { - const workspace = createWorkspaceWithFile(contents, nativeApiMinVersion); - const diagnostics = workspace.getDiagnosticsSync('/file.ts').diagnostics; - workspace.destroy(); - return diagnostics.map((diagnostic) => diagnostic.text); -} +describe('VersioningValidator', () => { + it('allows versioned properties inside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + title: string; + // @Version(43) + subtitle?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(43)) { + model.subtitle; + } + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('rejects versioned properties inside an insufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + title: string; + // @Version(43) + subtitle?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(42)) { + model.subtitle; + } + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('rejects versioned properties outside a version guard', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + title: string; + // @Version(43) + subtitle?: string; + } + + function render(model: MyModel) { + model.subtitle; + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('rejects destructuring versioned properties outside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + // @Version(43) + subtitle?: string; + } + + declare const model: MyModel; + const { subtitle } = model; + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('rejects renamed destructured properties with default values', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + // @Version(43) + subtitle?: string; + } + + declare const model: MyModel; + const { subtitle: label = 'Default' } = model; + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('rejects nested destructured properties requiring a newer version', () => { + const diagnostics = getDiagnosticTexts(` + interface NestedModel { + // @Version(43) + subtitle?: string; + } + + interface MyModel { + nested: NestedModel; + } + + declare const model: MyModel; + const { nested: { subtitle } } = model; + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('rejects destructured properties referenced through computed literal names', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + // @Version(43) + subtitle?: string; + } + + declare const model: MyModel; + const { ['subtitle']: label } = model; + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('allows destructuring versioned properties inside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(43) + subtitle?: string; + } + + declare const model: MyModel; + if (isVersionAtLeast(43)) { + const { subtitle: label = 'Default' } = model; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('rejects destructuring versioned properties inside an insufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(43) + subtitle?: string; + } + + declare const model: MyModel; + if (isVersionAtLeast(42)) { + const { subtitle } = model; + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('rejects destructuring versioned properties in unversioned function parameters', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + // @Version(43) + subtitle?: string; + } + + function render({ subtitle }: MyModel): void {} + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('allows destructured parameters to inherit their containing function version', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + // @Version(43) + subtitle?: string; + } + + // @Version(43) + function render({ subtitle }: MyModel): void {} + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows destructured method parameters to inherit their containing class version', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + // @Version(43) + subtitle?: string; + } + + // @Version(43) + class Renderer { + render({ subtitle }: MyModel): void {} + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows rest destructuring because it does not require newer properties to exist', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + // @Version(43) + subtitle?: string; + } + + declare const model: MyModel; + const { ...remaining } = model; + `); + + expect(diagnostics).toEqual([]); + }); + + it('rejects versioned properties in destructuring assignments', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + // @Version(43) + subtitle?: string; + } + + declare const model: MyModel; + let subtitle: string | undefined; + ({ subtitle } = model); + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('rejects renamed versioned properties with defaults in destructuring assignments', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + // @Version(43) + subtitle?: string; + } + + declare const model: MyModel; + let label: string; + ({ subtitle: label = 'Default' } = model); + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('rejects nested versioned properties in destructuring assignments', () => { + const diagnostics = getDiagnosticTexts(` + interface NestedModel { + // @Version(43) + subtitle?: string; + } + + interface MyModel { + nested: NestedModel; + } + + declare const model: MyModel; + let label: string | undefined; + ({ nested: { subtitle: label } } = model); + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('allows destructuring assignments inside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(43) + subtitle?: string; + } + + declare const model: MyModel; + let label: string | undefined; + if (isVersionAtLeast(43)) { + ({ subtitle: label } = model); + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('does not treat ordinary object literals as destructuring assignments', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + // @Version(43) + subtitle?: string; + } + + const localModel: MyModel = { subtitle: 'Hello' }; + `); + + expect(diagnostics).toEqual([]); + }); + + it('requires interface members to inherit their containing version at use sites', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(43) + interface Renderer { + title: string; + draw(): void; + } + + declare const renderer: Renderer; + renderer.title; + renderer.draw(); + `); + + expect(diagnostics).toEqual([ + "Property 'title' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + 'Function call requires @Version(43) or an enclosing isVersionAtLeast(43) block', + ]); + }); + + it('requires class members to inherit their containing version at use sites', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(43) + class Renderer { + title = 'title'; + draw() {} + } + + declare const renderer: Renderer; + renderer.title; + renderer.draw(); + `); + + expect(diagnostics).toEqual([ + "Property 'title' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + 'Function call requires @Version(43) or an enclosing isVersionAtLeast(43) block', + ]); + }); + + it('allows inherited container members inside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + // @Version(43) + interface Renderer { + title: string; + draw(): void; + } + + declare const renderer: Renderer; + if (isVersionAtLeast(43)) { + renderer.title; + renderer.draw(); + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('requires the highest version across merged member declarations', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface Renderer { + // @Version(42) + title: string; + } + + interface Renderer { + // @Version(43) + title: string; + } + + declare const renderer: Renderer; + if (isVersionAtLeast(42)) { + renderer.title; + } + `); + + expect(diagnostics).toEqual(["Property 'title' requires @Version(43) or an enclosing isVersionAtLeast(43) block"]); + }); + + it('rejects placeholder-versioned properties outside a version guard', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + title: string; + // @Version(__PLACEHOLDER__) + subtitle?: string; + } + + function render(model: MyModel) { + model.subtitle; + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(__PLACEHOLDER__) or an enclosing isVersionAtLeast(__PLACEHOLDER__) block", + ]); + }); + + it('allows placeholder-versioned properties inside a placeholder version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare const __PLACEHOLDER__: number; + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(__PLACEHOLDER__) + subtitle?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(__PLACEHOLDER__)) { + model.subtitle; + } + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows placeholder-versioned properties inside a max safe integer version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(__PLACEHOLDER__) + subtitle?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(9007199254740991)) { + model.subtitle; + } + } + `); + + expect(diagnostics).toEqual([]); + }); -describe('VersioningValidator', () => { - it('allows versioned properties inside a sufficient version guard', () => { + it('applies the highest nested version guard to child blocks only', () => { const diagnostics = getDiagnosticTexts(` declare function isVersionAtLeast(version: number): boolean; interface MyModel { - title: string; - // @Version(43) + // @Version(42) subtitle?: string; + // @Version(43) + detail?: string; } function render(model: MyModel) { - if (isVersionAtLeast(43)) { + if (isVersionAtLeast(42)) { model.subtitle; + if (isVersionAtLeast(43)) { + model.detail; + } + model.detail; } } `); - expect(diagnostics).toEqual([]); + expect(diagnostics).toEqual(["Property 'detail' requires @Version(43) or an enclosing isVersionAtLeast(43) block"]); }); - it('rejects versioned properties inside an insufficient version guard', () => { + it('does not apply the then branch version guard to the else branch', () => { const diagnostics = getDiagnosticTexts(` declare function isVersionAtLeast(version: number): boolean; interface MyModel { - title: string; - // @Version(43) + // @Version(42) subtitle?: string; } function render(model: MyModel) { if (isVersionAtLeast(42)) { model.subtitle; + } else { + model.subtitle; } } `); expect(diagnostics).toEqual([ - "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + "Property 'subtitle' requires @Version(42) or an enclosing isVersionAtLeast(42) block", ]); }); - it('rejects versioned properties outside a version guard', () => { + it('allows versioned properties in the right side and body of a version-guarded && condition', () => { const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(42) + subtitle?: string; + } + + function render(model: MyModel): string | undefined { + if (isVersionAtLeast(42) && model.subtitle) { + return model.subtitle; + } + return undefined; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows standalone short-circuit expressions after a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(42) + subtitle?: string; + } + + function render(model: MyModel) { + const subtitle = isVersionAtLeast(42) && model.subtitle; + return isVersionAtLeast(42) && model.subtitle; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('keeps standalone short-circuit version guards order-sensitive', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(42) + subtitle?: string; + } + + function render(model: MyModel) { + return model.subtitle && isVersionAtLeast(42); + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(42) or an enclosing isVersionAtLeast(42) block", + ]); + }); + + it('rejects standalone short-circuit expressions with an insufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + interface MyModel { - title: string; // @Version(43) subtitle?: string; } function render(model: MyModel) { - model.subtitle; + return isVersionAtLeast(42) && model.subtitle; } `); @@ -89,91 +628,128 @@ describe('VersioningValidator', () => { ]); }); - it('rejects placeholder-versioned properties outside a version guard', () => { + it('applies short-circuit version guards inside JSX expressions', () => { + const diagnostics = getJsxDiagnosticTexts(` + declare namespace JSX { + interface Element {} + interface IntrinsicElements { + view: {}; + } + } + + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(42) + subtitle?: string; + } + + declare const model: MyModel; + {isVersionAtLeast(42) && model.subtitle}; + `); + + expect(diagnostics).toEqual([]); + }); + + it('applies version guards to the true branch of ternary expressions', () => { const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + interface MyModel { - title: string; - // @Version(__PLACEHOLDER__) + // @Version(42) subtitle?: string; } function render(model: MyModel) { - model.subtitle; + return isVersionAtLeast(42) ? model.subtitle : undefined; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('does not apply ternary version guards to the unguarded branch', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(42) + subtitle?: string; + } + + function render(model: MyModel) { + return isVersionAtLeast(42) ? undefined : model.subtitle; } `); expect(diagnostics).toEqual([ - "Property 'subtitle' requires @Version(__PLACEHOLDER__) or an enclosing isVersionAtLeast(__PLACEHOLDER__) block", + "Property 'subtitle' requires @Version(42) or an enclosing isVersionAtLeast(42) block", ]); }); - it('allows placeholder-versioned properties inside a placeholder version guard', () => { + it('applies negated version guards to the false branch of ternary expressions', () => { const diagnostics = getDiagnosticTexts(` - declare const __PLACEHOLDER__: number; declare function isVersionAtLeast(version: number): boolean; interface MyModel { - // @Version(__PLACEHOLDER__) + // @Version(42) subtitle?: string; } function render(model: MyModel) { - if (isVersionAtLeast(__PLACEHOLDER__)) { - model.subtitle; - } + return !isVersionAtLeast(42) ? undefined : model.subtitle; } `); expect(diagnostics).toEqual([]); }); - it('allows placeholder-versioned properties inside a max safe integer version guard', () => { + it('applies version guards after negated early returns', () => { const diagnostics = getDiagnosticTexts(` declare function isVersionAtLeast(version: number): boolean; interface MyModel { - // @Version(__PLACEHOLDER__) + // @Version(42) subtitle?: string; } function render(model: MyModel) { - if (isVersionAtLeast(9007199254740991)) { - model.subtitle; + if (!isVersionAtLeast(42)) { + return; } + + model.subtitle; } `); expect(diagnostics).toEqual([]); }); - it('applies the highest nested version guard to child blocks only', () => { + it('applies version guards after early throws', () => { const diagnostics = getDiagnosticTexts(` declare function isVersionAtLeast(version: number): boolean; interface MyModel { // @Version(42) subtitle?: string; - // @Version(43) - detail?: string; } function render(model: MyModel) { - if (isVersionAtLeast(42)) { - model.subtitle; - if (isVersionAtLeast(43)) { - model.detail; - } - model.detail; + if (!isVersionAtLeast(42)) { + throw new Error('Unsupported native API'); } + + model.subtitle; } `); - expect(diagnostics).toEqual(["Property 'detail' requires @Version(43) or an enclosing isVersionAtLeast(43) block"]); + expect(diagnostics).toEqual([]); }); - it('does not apply the then branch version guard to the else branch', () => { + it('does not apply early-return guards when the guarded branch can continue', () => { const diagnostics = getDiagnosticTexts(` declare function isVersionAtLeast(version: number): boolean; + declare function shouldReturn(): boolean; interface MyModel { // @Version(42) @@ -181,11 +757,13 @@ describe('VersioningValidator', () => { } function render(model: MyModel) { - if (isVersionAtLeast(42)) { - model.subtitle; - } else { - model.subtitle; + if (!isVersionAtLeast(42)) { + if (shouldReturn()) { + return; + } } + + model.subtitle; } `); @@ -194,7 +772,7 @@ describe('VersioningValidator', () => { ]); }); - it('allows versioned properties in the right side and body of a version-guarded && condition', () => { + it('applies version guards to the right side of negated short-circuit alternatives', () => { const diagnostics = getDiagnosticTexts(` declare function isVersionAtLeast(version: number): boolean; @@ -203,11 +781,8 @@ describe('VersioningValidator', () => { subtitle?: string; } - function render(model: MyModel): string | undefined { - if (isVersionAtLeast(42) && model.subtitle) { - return model.subtitle; - } - return undefined; + function render(model: MyModel) { + return !isVersionAtLeast(42) || model.subtitle; } `); @@ -455,6 +1030,90 @@ describe('VersioningValidator', () => { expect(diagnostics).toEqual(['Function call requires @Version(42) or an enclosing isVersionAtLeast(42) block']); }); + it('rejects construction of versioned classes outside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts( + ` + // @Version(42) + class NewRenderer {} + + function render(): void { + new NewRenderer(); + } + `, + 1, + ); + + expect(diagnostics).toEqual(['Constructor call requires @Version(42) or an enclosing isVersionAtLeast(42) block']); + }); + + it('allows construction of versioned classes inside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts( + ` + declare function isVersionAtLeast(version: number): boolean; + + // @Version(42) + class NewRenderer {} + + function render(): void { + if (isVersionAtLeast(42)) { + new NewRenderer(); + } + } + `, + 1, + ); + + expect(diagnostics).toEqual([]); + }); + + it('rejects JSX usage of versioned components outside a sufficient version guard', () => { + const diagnostics = getJsxDiagnosticTexts(` + declare namespace JSX { + interface Element {} + interface ElementClass { + render(): Element; + } + } + + // @Version(42) + class NewComponent { + render(): JSX.Element { + return {}; + } + } + + ; + `); + + expect(diagnostics).toEqual([ + "Component 'NewComponent' requires @Version(42) or an enclosing isVersionAtLeast(42) block", + ]); + }); + + it('allows JSX usage of versioned components inside a sufficient version guard', () => { + const diagnostics = getJsxDiagnosticTexts(` + declare namespace JSX { + interface Element {} + interface ElementClass { + render(): Element; + } + } + + declare function isVersionAtLeast(version: number): boolean; + + // @Version(42) + class NewComponent { + render(): JSX.Element { + return {}; + } + } + + isVersionAtLeast(42) && ; + `); + + expect(diagnostics).toEqual([]); + }); + it('rejects calls to placeholder-versioned functions outside a version guard', () => { const diagnostics = getDiagnosticTexts(` // @Version(__PLACEHOLDER__) @@ -483,22 +1142,98 @@ describe('VersioningValidator', () => { renderLabelNew(); } } - `); + `); + + expect(diagnostics).toEqual([]); + }); + + it('requires declarations exposing versioned types to be versioned', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + function render(model: NewModel) {} + `); + + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(42) on the containing declaration"]); + }); + + it('requires declarations exposing imported versioned types to be versioned', () => { + const diagnostics = getDiagnosticTextsWithImportedFile( + ` + import type { NewModel as ImportedModel } from './models'; + + function render(model: ImportedModel): void {} + `, + ` + // @Version(42) + export interface NewModel { + value: string; + } + `, + 1, + ); + + expect(diagnostics).toEqual(["Type 'ImportedModel' requires @Version(42) on the containing declaration"]); + }); + + it('requires constructor parameters exposing versioned types to be versioned', () => { + const diagnostics = getDiagnosticTexts( + ` + // @Version(42) + interface NewModel { + value: string; + } + + class ExistingRenderer { + constructor(model: NewModel) {} + } + `, + 1, + ); - expect(diagnostics).toEqual([]); + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(42) on the containing declaration"]); }); - it('requires declarations exposing versioned types to be versioned', () => { - const diagnostics = getDiagnosticTexts(` + it('allows constructor parameters to inherit their containing class version', () => { + const diagnostics = getDiagnosticTexts( + ` // @Version(42) interface NewModel { - title: string; + value: string; } - function render(model: NewModel) {} - `); + // @Version(42) + class NewRenderer { + constructor(model: NewModel) {} + } + `, + 1, + ); - expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(42) on the containing declaration"]); + expect(diagnostics).toEqual([]); + }); + + it('allows sufficiently versioned declarations exposing imported versioned types', () => { + const diagnostics = getDiagnosticTextsWithImportedFile( + ` + import type { NewModel as ImportedModel } from './models'; + + // @Version(42) + function render(model: ImportedModel): void {} + `, + ` + // @Version(42) + export interface NewModel { + value: string; + } + `, + 1, + ); + + expect(diagnostics).toEqual([]); }); it('requires declarations exposing placeholder-versioned types to be placeholder-versioned', () => { @@ -575,7 +1310,7 @@ describe('VersioningValidator', () => { expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(42) on the containing declaration"]); }); - it('requires interfaces extending versioned types to be versioned', () => { + it('allows interfaces to extend versioned types because interface heritage is erased', () => { const diagnostics = getDiagnosticTexts(` // @Version(42) interface NewModel { @@ -587,7 +1322,7 @@ describe('VersioningValidator', () => { } `); - expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(42) on the containing declaration"]); + expect(diagnostics).toEqual([]); }); it('allows interfaces extending older versioned types', () => { @@ -606,6 +1341,32 @@ describe('VersioningValidator', () => { expect(diagnostics).toEqual([]); }); + it('allows classes to implement versioned interfaces because implements clauses are erased', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface Renderer { + render(): void; + } + + class LocalRenderer implements Renderer { + render(): void {} + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('requires classes extending versioned classes to be versioned', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + class BaseRenderer {} + + class LocalRenderer extends BaseRenderer {} + `); + + expect(diagnostics).toEqual(["Type 'BaseRenderer' requires @Version(42) on the containing declaration"]); + }); + it('requires interface methods exposing versioned types to be versioned', () => { const diagnostics = getDiagnosticTexts(` // @Version(42) @@ -637,6 +1398,232 @@ describe('VersioningValidator', () => { expect(diagnostics).toEqual([]); }); + it('allows class methods to inherit their containing version for signatures and bodies', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + value: string; + } + + // @Version(42) + class Renderer { + render(model: NewModel): NewModel { + return model; + } + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows class field initializers to inherit their containing version', () => { + const diagnostics = getDiagnosticTexts( + ` + // @Version(42) + interface NewModel { + value: string; + } + + declare const model: NewModel; + + // @Version(42) + class Renderer { + value = model.value; + } + `, + 1, + ); + + expect(diagnostics).toEqual([]); + }); + + it('allows class arrow-function properties and nested callbacks to inherit their containing version', () => { + const diagnostics = getDiagnosticTexts( + ` + // @Version(42) + interface NewModel { + value: string; + } + + // @Version(42) + class Renderer { + render = (model: NewModel): string => { + const readValue = () => model.value; + return readValue(); + }; + } + `, + 1, + ); + + expect(diagnostics).toEqual([]); + }); + + it('allows nested callback signatures to inherit their containing class version', () => { + const diagnostics = getDiagnosticTexts( + ` + // @Version(42) + interface NewModel { + value: string; + } + + // @Version(42) + class Renderer { + render(): void { + const callback = (model: NewModel): void => {}; + } + } + `, + 1, + ); + + expect(diagnostics).toEqual([]); + }); + + it('allows nested callback signatures inside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts( + ` + declare function isVersionAtLeast(version: number): boolean; + + // @Version(42) + interface NewModel { + value: string; + } + + if (isVersionAtLeast(42)) { + const callback = (model: NewModel): void => {}; + } + `, + 1, + ); + + expect(diagnostics).toEqual([]); + }); + + it('still rejects class field initializers requiring a newer version than their containing class', () => { + const diagnostics = getDiagnosticTexts( + ` + // @Version(43) + interface NewModel { + value: string; + } + + declare const model: NewModel; + + // @Version(42) + class Renderer { + value = model.value; + } + `, + 1, + ); + + expect(diagnostics).toEqual(["Property 'value' requires @Version(43) or an enclosing isVersionAtLeast(43) block"]); + }); + + it('does not apply a class version to eagerly evaluated static field initializers', () => { + const diagnostics = getDiagnosticTexts( + ` + interface Model { + // @Version(42) + value: string; + } + + declare const model: Model; + + // @Version(42) + class Renderer { + static value = model.value; + } + `, + 1, + ); + + expect(diagnostics).toEqual(["Property 'value' requires @Version(42) or an enclosing isVersionAtLeast(42) block"]); + }); + + it('allows eagerly evaluated static fields to use sufficiently guarded APIs', () => { + const diagnostics = getDiagnosticTexts( + ` + declare function isVersionAtLeast(version: number): boolean; + + interface Model { + // @Version(42) + value: string; + } + + declare const model: Model; + + // @Version(42) + class Renderer { + static value = isVersionAtLeast(42) ? model.value : undefined; + } + `, + 1, + ); + + expect(diagnostics).toEqual([]); + }); + + it('does not apply a class version to eagerly evaluated static blocks', () => { + const diagnostics = getDiagnosticTexts( + ` + interface Model { + // @Version(42) + value: string; + } + + declare const model: Model; + + // @Version(42) + class Renderer { + static { + model.value; + } + } + `, + 1, + ); + + expect(diagnostics).toEqual(["Property 'value' requires @Version(42) or an enclosing isVersionAtLeast(42) block"]); + }); + + it('allows class accessors to inherit their containing version for signatures and bodies', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + value: string; + } + + // @Version(42) + class Renderer { + get model(): NewModel { + return { value: 'value' }; + } + + set model(value: NewModel) {} + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('requires a newer explicit class method version despite its containing version', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(43) + interface NewModel { + value: string; + } + + // @Version(42) + class Renderer { + render(model: NewModel): void {} + } + `); + + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(43) on the containing declaration"]); + }); + it('allows versioned properties exposing compatible versioned types', () => { const diagnostics = getDiagnosticTexts(` // @Version(42) @@ -653,6 +1640,61 @@ describe('VersioningValidator', () => { expect(diagnostics).toEqual([]); }); + it('allows existing interface properties to refer to newer versioned types', () => { + const diagnostics = getDiagnosticTexts( + ` + // @Version(42) + interface NewModel { + value: string; + } + + interface ExistingOptions { + model?: NewModel; + } + `, + 1, + ); + + expect(diagnostics).toEqual([]); + }); + + it('allows unversioned class fields to refer to newer versioned types', () => { + const diagnostics = getDiagnosticTexts( + ` + // @Version(42) + interface NewModel { + value: string; + } + + class ExistingController { + private model?: NewModel; + } + `, + 1, + ); + + expect(diagnostics).toEqual([]); + }); + + it('rejects explicitly versioned properties exposing newer types', () => { + const diagnostics = getDiagnosticTexts( + ` + // @Version(43) + interface NewModel { + value: string; + } + + interface ExistingOptions { + // @Version(42) + model?: NewModel; + } + `, + 1, + ); + + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(43) on the containing declaration"]); + }); + it('validates isVersionAtLeast rejects non-literal arguments', () => { const diagnostics = getDiagnosticTexts(` declare function isVersionAtLeast(version: number): boolean; @@ -744,6 +1786,24 @@ describe('VersioningValidator', () => { ]); }); + it('does not let the workspace minimum mask a higher native-container version', () => { + const diagnostics = getDiagnosticTexts( + ` + // @ExportModel + // @Version(5) + export interface NativeModel { + value: string; + } + + declare const model: NativeModel; + model.value; + `, + 2, + ); + + expect(diagnostics).toEqual(["Property 'value' requires @Version(5) or an enclosing isVersionAtLeast(5) block"]); + }); + it('lets a placeholder member version override native-contract inheritance', () => { const diagnostics = getDiagnosticTexts( ` diff --git a/compiler/companion/src/VersioningValidator.ts b/compiler/companion/src/VersioningValidator.ts index 018a2423a..d1260b172 100644 --- a/compiler/companion/src/VersioningValidator.ts +++ b/compiler/companion/src/VersioningValidator.ts @@ -148,19 +148,21 @@ export class VersioningValidator { return undefined; } + if ((symbol.flags & ts.SymbolFlags.Alias) !== 0) { + symbol = this.typeChecker.getAliasedSymbol(symbol); + } + const declarations = symbol.getDeclarations(); if (!declarations) { return undefined; } + let requiredVersion: number | undefined; for (const declaration of declarations) { - const version = this.getVersion(declaration); - if (version !== undefined) { - return version; - } + requiredVersion = this.mergeVersions(requiredVersion, this.getDeclarationVersion(declaration)); } - return undefined; + return requiredVersion; } private isVersionSatisfied(currentVersion: number | undefined, requiredVersion: number): boolean { @@ -198,6 +200,25 @@ export class VersioningValidator { return; } + if ( + ts.isBinaryExpression(node) && + (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + node.operatorToken.kind === ts.SyntaxKind.BarBarToken) + ) { + this.visitVersionCondition(node, currentVersion); + return; + } + + if (ts.isConditionalExpression(node)) { + this.visitConditionalExpression(node, currentVersion); + return; + } + + if (ts.isSourceFile(node) || ts.isBlock(node)) { + this.visitStatements(node.statements, currentVersion); + return; + } + if (this.isFunctionLikeDeclaration(node)) { this.visitFunctionLikeDeclaration(node, currentVersion); return; @@ -205,16 +226,49 @@ export class VersioningValidator { if (ts.isInterfaceDeclaration(node) || ts.isClassDeclaration(node)) { this.validateContainerDeclaration(node); + const containerVersion = this.mergeVersions(currentVersion, this.getDeclarationVersion(node)); + ts.forEachChild(node, (child) => { + const isEagerStaticMember = + ts.isClassDeclaration(node) && + (ts.isClassStaticBlockDeclaration(child) || + (ts.isPropertyDeclaration(child) && (ts.getCombinedModifierFlags(child) & ts.ModifierFlags.Static) !== 0)); + this.visit(child, isEagerStaticMember ? currentVersion : containerVersion); + }); + return; } if (ts.isPropertyAccessExpression(node) && !this.isCalleePropertyAccess(node)) { this.validatePropertyAccess(node, currentVersion); } + if (ts.isBindingElement(node) && ts.isObjectBindingPattern(node.parent)) { + this.validateObjectBindingElement(node, currentVersion); + } + + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isObjectLiteralExpression(node.left) + ) { + this.validateObjectDestructuringAssignment( + node.left, + this.typeChecker.getTypeAtLocation(node.right), + currentVersion, + ); + } + if (ts.isCallExpression(node)) { this.validateCallExpression(node, currentVersion); } + if (ts.isNewExpression(node)) { + this.validateNewExpression(node, currentVersion); + } + + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + this.validateJsxComponent(node, currentVersion); + } + ts.forEachChild(node, (child) => this.visit(child, currentVersion)); } @@ -225,8 +279,65 @@ export class VersioningValidator { this.visit(node.thenStatement, thenVersion); if (node.elseStatement) { - this.visit(node.elseStatement, currentVersion); + const elseVersion = this.mergeVersions(currentVersion, this.getVersionWhenConditionIsFalse(node.expression)); + this.visit(node.elseStatement, elseVersion); + } + } + + private visitConditionalExpression(node: ts.ConditionalExpression, currentVersion: number | undefined): void { + const conditionVersion = this.visitVersionCondition(node.condition, currentVersion); + const thenVersion = this.mergeVersions(currentVersion, conditionVersion); + const elseVersion = this.mergeVersions(currentVersion, this.getVersionWhenConditionIsFalse(node.condition)); + + this.visit(node.whenTrue, thenVersion); + this.visit(node.whenFalse, elseVersion); + } + + private visitStatements(statements: ts.NodeArray, currentVersion: number | undefined): void { + let statementVersion = currentVersion; + for (const statement of statements) { + this.visit(statement, statementVersion); + statementVersion = this.getVersionAfterStatement(statement, statementVersion); + } + } + + private getVersionAfterStatement(statement: ts.Statement, currentVersion: number | undefined): number | undefined { + if (!ts.isIfStatement(statement)) { + return currentVersion; + } + + const thenTerminates = this.statementAlwaysTerminates(statement.thenStatement); + const elseTerminates = + statement.elseStatement !== undefined && this.statementAlwaysTerminates(statement.elseStatement); + + if (thenTerminates && !elseTerminates) { + return this.mergeVersions(currentVersion, this.getVersionWhenConditionIsFalse(statement.expression)); } + + if (elseTerminates && !thenTerminates) { + return this.mergeVersions(currentVersion, this.getVersionWhenConditionIsTrue(statement.expression)); + } + + return currentVersion; + } + + private statementAlwaysTerminates(statement: ts.Statement): boolean { + if (ts.isReturnStatement(statement) || ts.isThrowStatement(statement)) { + return true; + } + + if (ts.isBlock(statement)) { + return statement.statements.some((child) => this.statementAlwaysTerminates(child)); + } + + if (ts.isIfStatement(statement) && statement.elseStatement) { + return ( + this.statementAlwaysTerminates(statement.thenStatement) && + this.statementAlwaysTerminates(statement.elseStatement) + ); + } + + return false; } private visitVersionCondition(node: ts.Expression, currentVersion: number | undefined): number | undefined { @@ -234,6 +345,11 @@ export class VersioningValidator { return this.visitVersionCondition(node.expression, currentVersion); } + if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) { + this.visitVersionCondition(node.operand, currentVersion); + return this.getVersionWhenConditionIsFalse(node.operand); + } + if (this.isVersionIntrinsicCall(node) && ts.isCallExpression(node)) { this.visit(node, currentVersion); return this.getVersionIntrinsicArgument(node); @@ -245,10 +361,67 @@ export class VersioningValidator { return this.mergeVersions(leftVersion, rightVersion); } + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.BarBarToken) { + const leftVersion = this.visitVersionCondition(node.left, currentVersion); + const rightVersion = this.visitVersionCondition( + node.right, + this.mergeVersions(currentVersion, this.getVersionWhenConditionIsFalse(node.left)), + ); + return leftVersion === undefined || rightVersion === undefined ? undefined : Math.min(leftVersion, rightVersion); + } + this.visit(node, currentVersion); return undefined; } + private getVersionWhenConditionIsTrue(node: ts.Expression): number | undefined { + if (ts.isParenthesizedExpression(node)) { + return this.getVersionWhenConditionIsTrue(node.expression); + } + + if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) { + return this.getVersionWhenConditionIsFalse(node.operand); + } + + if (this.isVersionIntrinsicCall(node)) { + return this.getVersionIntrinsicArgument(node); + } + + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) { + return this.mergeVersions( + this.getVersionWhenConditionIsTrue(node.left), + this.getVersionWhenConditionIsTrue(node.right), + ); + } + + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.BarBarToken) { + const leftVersion = this.getVersionWhenConditionIsTrue(node.left); + const rightVersion = this.getVersionWhenConditionIsTrue(node.right); + return leftVersion === undefined || rightVersion === undefined ? undefined : Math.min(leftVersion, rightVersion); + } + + return undefined; + } + + private getVersionWhenConditionIsFalse(node: ts.Expression): number | undefined { + if (ts.isParenthesizedExpression(node)) { + return this.getVersionWhenConditionIsFalse(node.expression); + } + + if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) { + return this.getVersionWhenConditionIsTrue(node.operand); + } + + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.BarBarToken) { + return this.mergeVersions( + this.getVersionWhenConditionIsFalse(node.left), + this.getVersionWhenConditionIsFalse(node.right), + ); + } + + return undefined; + } + private mergeVersions(left: number | undefined, right: number | undefined): number | undefined { if (left === undefined) { return right; @@ -263,9 +436,15 @@ export class VersioningValidator { private visitFunctionLikeDeclaration(node: ts.FunctionLikeDeclaration, currentVersion: number | undefined): void { const declaredVersion = this.getDeclarationVersion(node); - const effectiveDeclarationVersion = this.mergeVersions(this.nativeApiMinVersion, declaredVersion); + const effectiveDeclarationVersion = this.mergeVersions(currentVersion, declaredVersion); this.validateSignature(node, effectiveDeclarationVersion); + for (const parameter of node.parameters) { + if (ts.isObjectBindingPattern(parameter.name) || ts.isArrayBindingPattern(parameter.name)) { + this.visit(parameter.name, effectiveDeclarationVersion); + } + } + if (node.body) { const bodyVersion = this.nativeApiMinVersion === undefined @@ -280,25 +459,23 @@ export class VersioningValidator { return undefined; } - if (ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) { - return this.getVersion(node); - } - - if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node)) { - return this.getVersion(node); - } - - if ((ts.isFunctionExpression(node) || ts.isArrowFunction(node)) && ts.isVariableDeclaration(node.parent)) { - return this.getVersion(node.parent); + if ( + (ts.isFunctionExpression(node) || ts.isArrowFunction(node)) && + (ts.isVariableDeclaration(node.parent) || ts.isPropertyDeclaration(node.parent)) + ) { + node = node.parent; } - return this.getVersion(node); + const declaredVersion = this.getVersion(node); + const containingDeclaration = this.getContainingContractDeclaration(this.getAnnotationNode(node)); + return this.mergeVersions(declaredVersion, this.getVersion(containingDeclaration)); } private isFunctionLikeDeclaration(node: ts.Node): node is ts.FunctionLikeDeclaration { return ( ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || + ts.isConstructorDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node) || ts.isFunctionExpression(node) || @@ -309,8 +486,12 @@ export class VersioningValidator { private validateContainerDeclaration(node: ts.InterfaceDeclaration | ts.ClassDeclaration): void { const containerVersion = this.mergeVersions(this.nativeApiMinVersion, this.getVersion(node)); - if (node.heritageClauses) { + if (ts.isClassDeclaration(node) && node.heritageClauses) { for (const heritageClause of node.heritageClauses) { + if (heritageClause.token !== ts.SyntaxKind.ExtendsKeyword) { + continue; + } + for (const type of heritageClause.types) { this.validateTypeNode(type, containerVersion, type); } @@ -322,17 +503,16 @@ export class VersioningValidator { continue; } - const declaredMemberVersion = this.getVersion(member); - const memberVersion = - this.nativeApiMinVersion === undefined - ? declaredMemberVersion ?? containerVersion - : this.mergeVersions(containerVersion, declaredMemberVersion); + const memberVersion = this.mergeVersions(containerVersion, this.getDeclarationVersion(member)); if (this.isSignatureMember(member)) { this.validateSignature(member, memberVersion); continue; } - if (ts.isPropertySignature(member) || ts.isPropertyDeclaration(member)) { + if ( + (ts.isPropertySignature(member) || ts.isPropertyDeclaration(member)) && + this.parseVersion(member) !== undefined + ) { this.validateTypeNode(member.type, memberVersion, member.type ?? member); } } @@ -411,26 +591,118 @@ export class VersioningValidator { } } + private validateObjectBindingElement(node: ts.BindingElement, currentVersion: number | undefined): void { + if (node.dotDotDotToken) { + return; + } + + const propertyName = node.propertyName ?? node.name; + const propertyNameText = this.getDestructuredPropertyName(propertyName); + if (propertyNameText === undefined) { + return; + } + + const sourceType = this.typeChecker.getTypeAtLocation(node.parent); + const symbol = this.typeChecker.getPropertyOfType(sourceType, propertyNameText); + const requiredVersion = this.getVersionFromSymbol(symbol); + if (requiredVersion !== undefined) { + this.validateVersionedUse(propertyName, currentVersion, requiredVersion, `Property '${propertyNameText}'`); + } + } + + private validateObjectDestructuringAssignment( + pattern: ts.ObjectLiteralExpression, + sourceType: ts.Type, + currentVersion: number | undefined, + ): void { + for (const element of pattern.properties) { + if (!ts.isShorthandPropertyAssignment(element) && !ts.isPropertyAssignment(element)) { + continue; + } + + const propertyNameText = this.getDestructuredPropertyName(element.name); + if (propertyNameText === undefined) { + continue; + } + + const symbol = this.typeChecker.getPropertyOfType(sourceType, propertyNameText); + const requiredVersion = this.getVersionFromSymbol(symbol); + if (requiredVersion !== undefined) { + this.validateVersionedUse(element.name, currentVersion, requiredVersion, `Property '${propertyNameText}'`); + } + + if (ts.isPropertyAssignment(element) && ts.isObjectLiteralExpression(element.initializer) && symbol) { + const propertyType = this.typeChecker.getTypeOfSymbolAtLocation(symbol, element.name); + this.validateObjectDestructuringAssignment(element.initializer, propertyType, currentVersion); + } + } + } + + private getDestructuredPropertyName(node: ts.PropertyName | ts.BindingName): string | undefined { + if (ts.isIdentifier(node) || ts.isStringLiteralLike(node) || ts.isNumericLiteral(node)) { + return node.text; + } + + if (!ts.isComputedPropertyName(node)) { + return undefined; + } + + const computedType = this.typeChecker.getTypeAtLocation(node.expression); + if (computedType.isStringLiteral()) { + return computedType.value; + } + if (computedType.isNumberLiteral()) { + return String(computedType.value); + } + + return undefined; + } + private validateCallExpression(node: ts.CallExpression, currentVersion: number | undefined): void { if (this.isVersionIntrinsicCall(node)) { return; } - const requiredVersion = this.getRequiredVersionForCall(node); + const requiredVersion = this.getRequiredVersionForInvocation(node); if (requiredVersion !== undefined) { this.validateVersionedUse(node.expression, currentVersion, requiredVersion, 'Function call'); } } + private validateNewExpression(node: ts.NewExpression, currentVersion: number | undefined): void { + const requiredVersion = this.getRequiredVersionForInvocation(node); + if (requiredVersion !== undefined) { + this.validateVersionedUse(node.expression, currentVersion, requiredVersion, 'Constructor call'); + } + } + + private validateJsxComponent(node: ts.JsxOpeningLikeElement, currentVersion: number | undefined): void { + const requiredVersion = this.getRequiredVersionForInvocation(node); + if (requiredVersion !== undefined) { + this.validateVersionedUse( + node.tagName, + currentVersion, + requiredVersion, + `Component '${node.tagName.getText(this.sourceFile)}'`, + ); + } + } + private isCalleePropertyAccess(node: ts.PropertyAccessExpression): boolean { - return ts.isCallExpression(node.parent) && node.parent.expression === node; + return ( + ((ts.isCallExpression(node.parent) || ts.isNewExpression(node.parent)) && node.parent.expression === node) || + ((ts.isJsxOpeningElement(node.parent) || ts.isJsxSelfClosingElement(node.parent)) && node.parent.tagName === node) + ); } - private getRequiredVersionForCall(node: ts.CallExpression): number | undefined { - let requiredVersion = this.getVersionFromSymbol(this.typeChecker.getSymbolAtLocation(node.expression)); + private getRequiredVersionForInvocation( + node: ts.CallExpression | ts.NewExpression | ts.JsxOpeningLikeElement, + ): number | undefined { + const expression = ts.isJsxOpeningLikeElement(node) ? node.tagName : node.expression; + let requiredVersion = this.getVersionFromSymbol(this.typeChecker.getSymbolAtLocation(expression)); const signature = this.typeChecker.getResolvedSignature(node); - if (!signature) { + if (!signature?.declaration || signature.declaration.pos < 0) { return requiredVersion; } From 2847fbed11e965701b7f857ed529e4fb8e14d60d Mon Sep 17 00:00:00 2001 From: Simon Corsin Date: Fri, 14 Aug 2026 12:49:18 -0500 Subject: [PATCH 06/10] Fix native API version guard and export-module detection --- compiler/companion/src/AST.spec.ts | 38 +++++++++++++++++++ .../companion/src/VersioningValidator.spec.ts | 20 ++++++++++ compiler/companion/src/VersioningValidator.ts | 11 ++---- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/compiler/companion/src/AST.spec.ts b/compiler/companion/src/AST.spec.ts index 68f7bd093..080681823 100644 --- a/compiler/companion/src/AST.spec.ts +++ b/compiler/companion/src/AST.spec.ts @@ -1048,6 +1048,44 @@ describe('AST', () => { }); }); + it('only treats ExportModule as file-wide when it annotates the first root node', () => { + const interfaceNames = (source: string): string[] => { + const result = compile(source); + const dumpedNodes = dumpRootNodes(result.sourceFile, { + typeChecker: result.typeChecker, + references: [], + }); + + return dumpedNodes.flatMap((node) => (node.interface ? [node.interface.name] : [])); + }; + + expect( + interfaceNames(` + /** @ExportModule */ + export interface Annotated { + value: string; + } + + export interface Plain { + value: string; + } + `), + ).toEqual(['Annotated', 'Plain']); + + expect( + interfaceNames(` + export interface Plain { + value: string; + } + + /** @ExportModule */ + export interface Annotated { + value: string; + } + `), + ).toEqual(['Annotated']); + }); + it('can dump interface with complex type references', () => { const result = compile( ` diff --git a/compiler/companion/src/VersioningValidator.spec.ts b/compiler/companion/src/VersioningValidator.spec.ts index b1eaab33f..6e1de7524 100644 --- a/compiler/companion/src/VersioningValidator.spec.ts +++ b/compiler/companion/src/VersioningValidator.spec.ts @@ -893,6 +893,26 @@ describe('VersioningValidator', () => { ]); }); + it('preserves stronger surrounding version guards inside explicitly versioned function bodies', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(43) + subtitle?: string; + } + + if (isVersionAtLeast(43)) { + // @Version(42) + function render(model: MyModel) { + model.subtitle; + } + } + `); + + expect(diagnostics).toEqual([]); + }); + it('allows nested lambdas created inside a far outer version guard to use versioned properties', () => { const diagnostics = getDiagnosticTexts(` declare function isVersionAtLeast(version: number): boolean; diff --git a/compiler/companion/src/VersioningValidator.ts b/compiler/companion/src/VersioningValidator.ts index d1260b172..658a0ee8d 100644 --- a/compiler/companion/src/VersioningValidator.ts +++ b/compiler/companion/src/VersioningValidator.ts @@ -79,9 +79,9 @@ export class VersioningValidator { return cached; } - const hasAnnotation = sourceFile.statements.some((statement) => - hasExportModuleAnnotation(getNodeComments(statement)?.text ?? ''), - ); + const firstStatement = sourceFile.statements[0]; + const hasAnnotation = + firstStatement !== undefined && hasExportModuleAnnotation(getNodeComments(firstStatement)?.text ?? ''); this.exportModuleCache.set(sourceFile, hasAnnotation); return hasAnnotation; } @@ -446,10 +446,7 @@ export class VersioningValidator { } if (node.body) { - const bodyVersion = - this.nativeApiMinVersion === undefined - ? declaredVersion ?? currentVersion - : this.mergeVersions(currentVersion, declaredVersion); + const bodyVersion = this.mergeVersions(currentVersion, declaredVersion); this.visit(node.body, bodyVersion); } } From 048dd4ce879ce78ee2adcd4957b1ae667f0a75b4 Mon Sep 17 00:00:00 2001 From: Simon Corsin Date: Fri, 14 Aug 2026 12:57:43 -0500 Subject: [PATCH 07/10] Add Valdi Web renderer v2 baseline --- .../src/valdi/web_renderer/AGENTS.md | 133 + .../src/valdi/web_renderer/BUILD.bazel | 4 +- .../src/LayoutObserverController.ts | 524 ++ .../web_renderer/src/ValdiWebRenderer.ts | 102 +- .../src/ValdiWebRendererDelegate.ts | 188 +- .../valdi/web_renderer/src/ValdiWebRuntime.ts | 428 +- .../valdi/web_renderer/src/ValdiWebWorker.ts | 57 + .../src/VisibilityObserverController.ts | 193 + .../web_renderer/src/WebPolyglotRuntime.ts | 3 +- .../valdi/web_renderer/src/WebRendererRoot.ts | 27 + .../web_renderer/src/WebViewClassRegistry.ts | 5 +- .../src/animations/AnimationController.ts | 287 + .../animations/AnimationTimingFunctions.ts | 71 + .../web_renderer/src/animations/Animator.ts | 132 + .../animations/AnimatorCommitPreparation.ts | 6 + .../src/animations/KeyAnimation.ts | 34 + .../src/animations/LayoutAnimation.ts | 438 ++ .../src/attributes/AttributeAnimation.ts | 83 + .../src/attributes/AttributeApplierHelpers.ts | 181 + .../src/attributes/AttributeOwner.ts | 24 + .../src/attributes/AttributesApplier.ts | 614 +++ .../src/attributes/AttributesBinder.ts | 308 ++ .../src/attributes/BorderRadiusAttribute.ts | 355 ++ .../web_renderer/src/core/ElementClass.ts | 168 + .../valdi/web_renderer/src/core/Palette.ts | 54 + .../valdi/web_renderer/src/core/ViewNode.ts | 914 ++++ .../web_renderer/src/core/ViewNodeTree.ts | 494 ++ .../src/debug/WebDebuggerBridge.ts | 133 + .../src/elements/BlurElementClass.ts | 85 + .../src/elements/CanvasImageRenderer.ts | 203 + .../src/elements/CustomViewElementClass.ts | 131 + .../src/elements/DatePickerElementClass.ts | 14 + .../src/elements/ElementClassRegistry.ts | 70 + .../src/elements/ElementClassSupport.ts | 204 + .../web_renderer/src/elements/ImageElement.ts | 442 ++ .../src/elements/ImageElementClass.ts | 318 ++ .../src/elements/LabelElementClass.ts | 277 + .../src/elements/LayoutElementClass.ts | 650 +++ .../src/elements/ScrollElementClass.ts | 574 ++ .../src/elements/ShapeElementClass.ts | 196 + .../src/elements/SpinnerElementClass.ts | 148 + .../TextAnimationGroupElementClass.ts | 60 + .../src/elements/TextFieldElementClass.ts | 402 ++ .../src/elements/TextViewElementClass.ts | 593 +++ .../src/elements/VideoElementClass.ts | 122 + .../src/elements/ViewElementAttributes.ts | 889 ++++ .../src/elements/ViewElementClass.ts | 79 + .../src/elements/ViewElementState.ts | 36 + .../src/elements/WebViewElementClass.ts | 14 + .../web_renderer/src/styles/scrollbar.ts | 26 + .../src/tracing/ChromeDevToolsTracing.ts | 73 + .../src/tracing/PerformanceTimelineTracing.ts | 44 + .../src/tracing/ValdiWebTracing.ts | 87 + .../web_renderer/src/utils/IndexedRecord.ts | 71 + .../src/utils/TextAnimationController.ts | 710 +++ .../src/utils/TextAnimationRegistry.ts | 107 + .../src/utils/TextAnimationTypes.ts | 60 + .../web_renderer/src/utils/assetSource.ts | 93 + .../valdi/web_renderer/src/utils/cssColor.ts | 118 + .../web_renderer/src/utils/cssFunction.ts | 2 + .../web_renderer/src/utils/cssScanner.ts | 266 + .../web_renderer/src/utils/geometricPath.ts | 98 + .../src/utils/imageFilterOperations.ts | 107 + .../web_renderer/src/utils/imageSource.ts | 122 + .../src/utils/parseAttributedText.ts | 557 +- .../valdi/web_renderer/src/utils/textStyle.ts | 37 + .../valdi/web_renderer/test/Animator.spec.ts | 93 + .../test/AttributeApplierHelpers.spec.ts | 14 + .../test/AttributesBinder.spec.ts | 231 + .../test/CanvasImageRenderer.spec.ts | 14 + .../test/ChromeDevToolsTracing.spec.ts | 54 + .../test/ElementClassSupport.spec.ts | 25 + .../web_renderer/test/IndexedRecord.spec.ts | 101 + .../test/LayoutObserverController.spec.ts | 240 + .../web_renderer/test/ObserverTestUtils.ts | 194 + .../test/PerformanceTimelineTracing.spec.ts | 84 + .../test/TextAnimationController.spec.ts | 536 ++ .../web_renderer/test/ValdiWebTracing.spec.ts | 115 + .../web_renderer/test/ValdiWebWorker.spec.ts | 83 + .../test/VisibilityObserverController.spec.ts | 111 + .../web_renderer/test/WebRendererCore.spec.ts | 4601 +++++++++++++++++ .../web_renderer/test/WebRendererRoot.spec.ts | 69 + .../web_renderer/test/assetSource.spec.ts | 17 + .../valdi/web_renderer/test/cssColor.spec.ts | 10 + .../web_renderer/test/cssFunction.spec.ts | 19 + .../web_renderer/test/cssScanner.spec.ts | 39 + .../web_renderer/test/geometricPath.spec.ts | 41 + .../test/imageFilterOperations.spec.ts | 33 + .../web_renderer/test/imageSource.spec.ts | 34 + .../test/parseAttributedText.spec.ts | 138 + 90 files changed, 20558 insertions(+), 383 deletions(-) create mode 100644 src/valdi_modules/src/valdi/web_renderer/AGENTS.md create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/LayoutObserverController.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/ValdiWebWorker.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/VisibilityObserverController.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/WebRendererRoot.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/animations/AnimationController.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/animations/AnimationTimingFunctions.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/animations/Animator.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/animations/AnimatorCommitPreparation.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/animations/KeyAnimation.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/animations/LayoutAnimation.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributeAnimation.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributeApplierHelpers.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributeOwner.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributesApplier.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributesBinder.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/attributes/BorderRadiusAttribute.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/core/ElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/core/Palette.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/core/ViewNode.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/core/ViewNodeTree.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/debug/WebDebuggerBridge.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/BlurElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/CanvasImageRenderer.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/CustomViewElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/DatePickerElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/ElementClassRegistry.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/ElementClassSupport.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/ImageElement.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/ImageElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/LabelElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/LayoutElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/ScrollElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/ShapeElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/SpinnerElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/TextAnimationGroupElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/TextFieldElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/TextViewElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/VideoElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/ViewElementAttributes.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/ViewElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/ViewElementState.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/WebViewElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/styles/scrollbar.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/tracing/ChromeDevToolsTracing.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/tracing/PerformanceTimelineTracing.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/tracing/ValdiWebTracing.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/utils/IndexedRecord.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/utils/TextAnimationController.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/utils/TextAnimationRegistry.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/utils/TextAnimationTypes.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/utils/assetSource.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/utils/cssColor.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/utils/cssFunction.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/utils/cssScanner.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/utils/geometricPath.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/utils/imageFilterOperations.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/utils/imageSource.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/utils/textStyle.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/Animator.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/AttributeApplierHelpers.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/AttributesBinder.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/CanvasImageRenderer.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/ChromeDevToolsTracing.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/ElementClassSupport.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/IndexedRecord.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/LayoutObserverController.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/ObserverTestUtils.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/PerformanceTimelineTracing.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/TextAnimationController.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/ValdiWebTracing.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/ValdiWebWorker.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/VisibilityObserverController.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/WebRendererCore.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/WebRendererRoot.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/assetSource.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/cssColor.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/cssFunction.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/cssScanner.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/geometricPath.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/imageFilterOperations.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/imageSource.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/parseAttributedText.spec.ts diff --git a/src/valdi_modules/src/valdi/web_renderer/AGENTS.md b/src/valdi_modules/src/valdi/web_renderer/AGENTS.md new file mode 100644 index 000000000..e678b9fed --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/AGENTS.md @@ -0,0 +1,133 @@ +# AGENTS.md - Valdi Web Renderer Notes + +This file applies to `/src/valdi_modules/src/valdi/web_renderer`. + +The web renderer is being rebuilt around a native-like renderer core. Do not treat older web renderer behavior as sacred unless the user explicitly asks for compatibility. The current priority is a clean, fast, C++-inspired architecture that can be completed incrementally. + +## Directory Layout + +- `src/core/` + - Renderer primitives: `ViewNode`, `ViewNodeTree`, `ElementClass`, and `Palette`. + - `ViewNode` owns the DOM element, parent/children links, applied attributes, dirty flags, palette state, lifecycle state, and the minimal applier context. + - `ViewNodeTree` owns node lookup, root ownership, render batching, scheduled flushes, and palette change integration. +- `src/elements/` + - One file per concrete `ElementClass` subclass. + - `ElementClassRegistry.ts` is the central registry mapping Valdi view class aliases to singleton element classes. + - `ElementClassSupport.ts` contains shared DOM helpers for element classes. +- `src/attributes/` + - `AttributesApplier.ts` is the stateful attribute owner/resolution/dirty flushing class. + - `AttributeApplierHelpers.ts` contains typed parser/factory helpers such as number, boolean, CSS length, enum, color, and direct style appliers. +- `src/utils/` + - Shared low-level utilities such as `IndexedRecord`. + +Keep these boundaries intact. + +## Element Classes + +- Each built-in view type should have its own `ElementClass` subclass file in `src/elements/`. +- Do not add generic catch-all classes such as `BasicElementClass` or `SimpleDivElementClass`. +- Do not use large switch statements on `viewClass` or `attributeName`. Use the registry for view class lookup and the per-class `elementAttributes` record for attribute lookup. +- Do not introduce temporary creation-info objects. +- `ElementClass.createElement()` owns template caching and cloning. Subclasses should implement `protected onCreateElement()` to build one template element, and the base class clones it for each node. +- The registry should construct and wire the concrete element class singletons. Dependent classes should receive class instances they need, for example label/text classes can receive view/textfield classes and reuse their appliers. +- Unknown or incomplete element behavior should be represented in the specific element class with a clear TODO, not hidden in a generic fallback class. + +## Attribute Architecture + +- `AttributesApplier` owns stored attribute values, style owner values, dirty attribute names, and dirty composites. +- Attribute appliers should be typed on the HTMLElement subtype they operate on and should receive the element directly. Avoid passing `ViewNode` into appliers. +- `AttributeApplierContext` should expose only what appliers truly need. Keep additions narrow and justify each one. +- Every `AttributeApplier` and `CompositeAttribute` must provide `reset`. Reset is not optional; it is called when the resolved value becomes `undefined` or `null`. +- Attribute appliers must not branch behavior based on the attribute name. If two attributes need different reset/apply behavior, use two appliers. +- Missing attributes should warn with node id, element class name, attribute name, and provided value. +- Failed applies should throw locally, be caught by `AttributesApplier`, and log node id, element class name, attribute name, provided value, and error message. Do not let applier failures escape a flush. +- Do not add blind catches. If a promise or callback can fail, log enough context to debug it. +- Avoid hard casts. Prefer typed helpers, narrow element interfaces, and DOM APIs that TypeScript can type-check. + +## Attribute Values And Priority + +- Direct attributes outrank style attributes. +- `undefined` and `null` remove that owner value and allow lower-priority owners to win again. +- `false` is a real boolean value and must not be treated as removal. +- Style objects are treated as immutable by identity. +- Attribute conflicts are rare. Optimize the common case: + - store the single owner/value inline in `StoredAttribute`; + - allocate owner collections only on real conflicts; + - lazily allocate optional state such as cleanup callbacks and dirty composites. +- Store the resolved `ElementAttribute` lookup inside `StoredAttribute` so each attribute name is resolved once. + +## Dirty Update Pass + +- The tree has one root node. Do not scan all nodes to flush normal updates. +- `ViewNodeTree.flush()` starts at the root and calls the root-to-children update pass. +- Dirty state must propagate upward. There should never be a dirty child with a clean attached ancestor. +- It is valid for detached nodes/subtrees to become dirty, but the dirty state must propagate once that subtree is rooted or moved under an attached root. +- `markNeedsUpdate()` should be a no-op when the node already has an update flag. +- Use a bitfield for update flags so checking whether any update is needed is cheap. +- `setAttribute()` should only mark an update when the resolved attribute value actually changes. +- During render batches, flush at the outermost `endRender()`. Outside render batches, schedule one microtask flush. +- If a flush dirties more work, the tree should continue flushing until the requested work is complete. + +## Palette Handling + +- Palette state is managed by `ColorPaletteManager` in `src/core/Palette.ts`. Do not add a second global palette state path. +- `ViewNodeTree` owns the palette manager instance for a renderer tree and listens for palette changes. +- `colorPaletteName` should apply by calling `AttributeApplierContext.setColorPalette()`. +- Palette resolution happens during the root-to-children update pass. Pass the starting active palette from the root; do not recompute active palette ad hoc inside nodes. +- A node inherits its parent palette unless it has an override. Moving a node must mark palette dirty so descendants recompute during the update pass. +- Palette mutation and active palette changes may traverse/reapply from the root. This is intentionally O(n) because palette changes are infrequent. +- Color-dependent attributes and composites must be marked dirty when palette resolution changes or a forced palette reapply occurs. + +## Composite Attributes + +- A composite is color-dependent if any part is color-dependent. Store this on `composite.colorDependent`. +- Composite parts should mark the composite dirty, and the composite should apply once during flush. +- Keep composites small and purposeful. Use them when attribute values depend on each other, for example transform parts. + +## Performance Expectations + +- Optimize for the common path in rendering and attribute updates. +- Avoid temporary objects in hot paths. Examples: + - no creation-info object for element creation; + - no repeated default style object construction for each node; use per-element-class template cloning; + - no owner-value allocation for the single-owner attribute case; + - lazy allocation for optional state. +- Prefer plain records or `IndexedRecord` for small hot mutable key/value sets. Use `Map` only when it is actually the right fit. +- Use `IndexedRecord` for dirty name sets and other mutable keyed collections that need efficient `set`, `remove`, `clear`, `keys`, `empty`, and `pop`. +- Do not repeat expensive lookups during flush. Cache per-attribute metadata in stored attribute state. +- Do not add global node scans for normal updates. Root traversal is the update mechanism. +- Keep APIs tight. Public methods should be what other classes actually need; ViewNode internals should stay private. + +## Code Style Preferences + +- Keep the public renderer/delegate API stable unless the user explicitly asks to change it. +- Rename operations should be complete: folder names, imports, dynamic `customRequire()` paths, test names, and local variables should agree. +- Use precise names. Avoid confusing pairs like `AttributeApplier` and `AttributeAppliers.ts`; prefer names that explain role, such as `AttributeApplierHelpers.ts`. +- Module constants should use `UPPER_SNAKE_CASE`. +- Do not use TypeScript default parameters. Put defaults inside the function body or pass explicit values at call sites. +- Avoid generic error-message parameters passed into helper methods. Prefer specific helper names such as `getNodeOrThrow`. +- Do not add DOM data attributes such as `data-valdi-node-id` unless there is a concrete runtime need. +- If an old view implementation has been replaced by element classes, remove it instead of keeping duplicate paths. +- Do not preserve dead files just because they once had behavior. If only a tiny helper survives, move the helper to an appropriate home and delete the old file. +- Use `interface`, not type-alias object patterns. + +## Testing + +- Run the focused target after web renderer changes: + +```bash +bazel test //src/valdi_modules/src/valdi/web_renderer:test +``` + +- Unit tests should cover: + - style/direct priority and fallback; + - boolean `false` as a real value; + - dirty update coalescing; + - detached dirty subtree propagation when attached; + - root-only flushing, not all-node scanning; + - palette inheritance, mutation, active palette swap, and subtree overrides; + - composite coalescing; + - missing attribute warnings; + - applier failure logging context; + - `ViewNodeTree` create/move/root/destroy behavior. +- When visual or integration behavior matters, use the Valdi integration test system and compare against the provided baseline output rather than relying only on unit tests. diff --git a/src/valdi_modules/src/valdi/web_renderer/BUILD.bazel b/src/valdi_modules/src/valdi/web_renderer/BUILD.bazel index 732845525..d84e94e0d 100644 --- a/src/valdi_modules/src/valdi/web_renderer/BUILD.bazel +++ b/src/valdi_modules/src/valdi/web_renderer/BUILD.bazel @@ -32,9 +32,9 @@ valdi_module( ]), visibility = ["//visibility:public"], deps = [ - "@valdi//src/valdi_modules/src/valdi/jasmine", + "@valdi//src/valdi_modules/src/valdi/coreutils", "@valdi//src/valdi_modules/src/valdi/valdi_core", - "@valdi//src/valdi_modules/src/valdi/valdi_navigation", "@valdi//src/valdi_modules/src/valdi/valdi_tsx", ], + test_deps = ["@valdi//src/valdi_modules/src/valdi/jasmine"], ) diff --git a/src/valdi_modules/src/valdi/web_renderer/src/LayoutObserverController.ts b/src/valdi_modules/src/valdi/web_renderer/src/LayoutObserverController.ts new file mode 100644 index 000000000..896c0bbba --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/LayoutObserverController.ts @@ -0,0 +1,524 @@ +import type { ElementFrame } from 'valdi_tsx/src/Geometry'; +import type { ElementLayoutObserver } from './core/ElementClass'; + +type OnLayoutCallback = (frame: ElementFrame) => void; +type PostLayoutScheduler = (callback: () => void) => void; +type RenderCompleteScheduler = (callback: () => void) => void; + +interface LayoutObserverEntry { + readonly attributeName: string; + readonly layoutElement: LayoutElement; + readonly observer: ElementLayoutObserver; + hasSize: boolean; + height: number; + preparedCommit: boolean; + width: number; +} + +interface LayoutElement { + readonly id: number; + readonly viewClass: string; + element: HTMLElement; + attached: boolean; + observed: boolean; + observers?: Record; + observerCount: number; + sizeObserverCount: number; + measureObserver?: LayoutObserverEntry; + onLayout?: OnLayoutCallback; + lastFrame?: ElementFrame; + forceOnLayout: boolean; +} + +export function measureElementFrame(element: HTMLElement): ElementFrame { + return measureElementFrameFromRect(element, element.getBoundingClientRect()); +} + +function measureElementFrameFromRect(element: HTMLElement, rect: DOMRect): ElementFrame { + const offsetParent = element.offsetParent as HTMLElement | null; + + let x: number; + let y: number; + if (offsetParent) { + const parentRect = offsetParent.getBoundingClientRect(); + const style = getComputedStyle(offsetParent); + x = rect.left - parentRect.left + offsetParent.scrollLeft - (parseFloat(style.borderLeftWidth) || 0); + y = rect.top - parentRect.top + offsetParent.scrollTop - (parseFloat(style.borderTopWidth) || 0); + } else { + x = rect.left; + y = rect.top; + } + + return { x, y, width: rect.width, height: rect.height }; +} + +function framesEqual(left: ElementFrame, right: ElementFrame): boolean { + return left.x === right.x && left.y === right.y && left.width === right.width && left.height === right.height; +} + +export class LayoutObserverController { + private readonly elementsById = new Map(); + private readonly elementIdsByHtmlElement = new WeakMap(); + private readonly elementIdsWithMeasureObserver = new Set(); + private readonly onBrowserResize = () => this.scheduleRefresh(); + private readonly flushScheduledRefresh = () => { + if (!this.refreshScheduled) { + return; + } + this.refreshScheduled = false; + this.performUpdates(); + }; + private readonly flushPostLayoutCallbacks = () => { + this.postLayoutFlushScheduled = false; + if (this.destroyed) { + return; + } + const callbacks = this.pendingPostLayoutCallbacks; + if (!callbacks) { + return; + } + this.pendingPostLayoutCallbacks = undefined; + this.isFlushingPostLayoutCallbacks = true; + try { + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](); + } + } finally { + this.isFlushingPostLayoutCallbacks = false; + } + this.schedulePostLayoutCallbacks(); + }; + private resizeObserver?: ResizeObserver; + private pendingPostLayoutCallbacks?: Array<() => void>; + private observersToCommit?: LayoutObserverEntry[]; + private postLayoutScheduler?: PostLayoutScheduler; + private renderCompleteScheduler?: RenderCompleteScheduler; + private postLayoutDeferralDepth = 0; + private postLayoutFlushScheduled = false; + private isFlushingPostLayoutCallbacks = false; + private refreshScheduled = false; + private destroyed = false; + private listeningForBrowserResize = false; + + constructor(private readonly onLayoutPassCommitted: () => void) { + if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + window.addEventListener('resize', this.onBrowserResize); + this.listeningForBrowserResize = true; + } + } + + setRenderCompleteScheduler(schedule: RenderCompleteScheduler): void { + this.renderCompleteScheduler = schedule; + } + + setPostLayoutScheduler(scheduler: PostLayoutScheduler | undefined): void { + this.postLayoutScheduler = scheduler; + this.schedulePostLayoutCallbacks(); + } + + beginUpdate(): void { + this.postLayoutDeferralDepth++; + } + + endUpdate(): void { + if (this.postLayoutDeferralDepth === 0) { + throw new Error('Unbalanced LayoutObserverController.endUpdate()'); + } + this.postLayoutDeferralDepth--; + this.schedulePostLayoutCallbacks(); + } + + enqueuePostLayoutCallback(callback: () => void): void { + (this.pendingPostLayoutCallbacks ??= []).push(callback); + if (!this.isFlushingPostLayoutCallbacks) { + this.schedulePostLayoutCallbacks(); + } + } + + setLayoutObserver( + id: number, + viewClass: string, + element: HTMLElement, + attached: boolean, + attributeName: string, + observer: ElementLayoutObserver | undefined, + ): void { + const existingElement = this.elementsById.get(id); + const existingEntry = existingElement?.observers?.[attributeName]; + if (!observer) { + if (existingElement && existingEntry) { + this.removeLayoutObserver(existingElement, existingEntry); + this.removeElementIfEmpty(existingElement); + } + return; + } + if (observer.onMeasure && attributeName !== 'onMeasure') { + throw new Error(`Only the 'onMeasure' attribute can define ElementLayoutObserver.onMeasure`); + } + + const layoutElement = existingElement ?? this.createLayoutElement(id, viewClass, element, attached); + if (layoutElement.element !== element) { + this.replaceElement(layoutElement, element); + } + layoutElement.attached = attached; + if (existingEntry) { + this.removeLayoutObserver(layoutElement, existingEntry); + } + + const entry: LayoutObserverEntry = { + attributeName, + layoutElement, + observer, + hasSize: false, + height: 0, + preparedCommit: false, + width: 0, + }; + const observers = layoutElement.observers ?? (layoutElement.observers = Object.create(null)); + observers[attributeName] = entry; + layoutElement.observerCount++; + if (observer.onSizeChanged) { + layoutElement.sizeObserverCount++; + } + if (observer.onMeasure) { + layoutElement.measureObserver = entry; + this.elementIdsWithMeasureObserver.add(id); + } + this.updateResizeObservation(layoutElement); + this.scheduleRefresh(); + } + + getLayoutObserver(id: number, attributeName: string): ElementLayoutObserver | undefined { + return this.elementsById.get(id)?.observers?.[attributeName]?.observer; + } + + setOnLayoutCallback( + id: number, + viewClass: string, + element: HTMLElement, + attached: boolean, + callback: OnLayoutCallback | undefined, + ): void { + const existingElement = this.elementsById.get(id); + if (!callback) { + if (existingElement) { + existingElement.onLayout = undefined; + existingElement.lastFrame = undefined; + existingElement.forceOnLayout = false; + this.removeElementIfEmpty(existingElement); + } + return; + } + + const layoutElement = existingElement ?? this.createLayoutElement(id, viewClass, element, attached); + if (layoutElement.element !== element) { + this.replaceElement(layoutElement, element); + } + layoutElement.attached = attached; + layoutElement.onLayout = callback; + layoutElement.lastFrame = undefined; + layoutElement.forceOnLayout = true; + this.updateResizeObservation(layoutElement); + this.scheduleRefresh(); + } + + setElementAttached(id: number, attached: boolean): void { + const layoutElement = this.elementsById.get(id); + if (!layoutElement || layoutElement.attached === attached) { + return; + } + layoutElement.attached = attached; + this.updateResizeObservation(layoutElement); + if (attached) { + this.scheduleRefresh(); + } + } + + destroyElement(id: number): void { + const layoutElement = this.elementsById.get(id); + if (!layoutElement) { + return; + } + if (layoutElement.observed) { + this.resizeObserver?.unobserve(layoutElement.element); + } + this.elementIdsWithMeasureObserver.delete(id); + this.elementsById.delete(id); + } + + scheduleRefresh(): void { + if (this.destroyed || this.refreshScheduled || this.elementsById.size === 0) { + return; + } + this.refreshScheduled = true; + this.enqueuePostLayoutCallback(this.flushScheduledRefresh); + } + + drainScheduledRefresh(): void { + if (!this.refreshScheduled || this.destroyed) { + return; + } + this.refreshScheduled = false; + this.performUpdates(); + } + + destroy(): void { + if (this.destroyed) { + return; + } + this.destroyed = true; + if (this.listeningForBrowserResize) { + window.removeEventListener('resize', this.onBrowserResize); + this.listeningForBrowserResize = false; + } + this.resizeObserver?.disconnect(); + this.resizeObserver = undefined; + this.elementsById.clear(); + this.elementIdsWithMeasureObserver.clear(); + this.pendingPostLayoutCallbacks = undefined; + this.observersToCommit = undefined; + this.postLayoutFlushScheduled = false; + this.isFlushingPostLayoutCallbacks = false; + this.refreshScheduled = false; + } + + private createLayoutElement(id: number, viewClass: string, element: HTMLElement, attached: boolean): LayoutElement { + const layoutElement: LayoutElement = { + id, + viewClass, + element, + attached, + observed: false, + observerCount: 0, + sizeObserverCount: 0, + forceOnLayout: false, + }; + this.elementsById.set(id, layoutElement); + this.elementIdsByHtmlElement.set(element, id); + return layoutElement; + } + + private replaceElement(layoutElement: LayoutElement, element: HTMLElement): void { + if (layoutElement.observed) { + this.resizeObserver?.unobserve(layoutElement.element); + layoutElement.observed = false; + } + layoutElement.element = element; + this.elementIdsByHtmlElement.set(element, layoutElement.id); + } + + private removeLayoutObserver(layoutElement: LayoutElement, entry: LayoutObserverEntry): void { + delete layoutElement.observers![entry.attributeName]; + layoutElement.observerCount--; + if (entry.observer.onSizeChanged) { + layoutElement.sizeObserverCount--; + } + if (layoutElement.measureObserver === entry) { + layoutElement.measureObserver = undefined; + this.elementIdsWithMeasureObserver.delete(layoutElement.id); + } + } + + private removeElementIfEmpty(layoutElement: LayoutElement): void { + if (layoutElement.observerCount !== 0 || layoutElement.onLayout) { + this.updateResizeObservation(layoutElement); + return; + } + if (layoutElement.observed) { + this.resizeObserver?.unobserve(layoutElement.element); + } + this.elementsById.delete(layoutElement.id); + } + + private updateResizeObservation(layoutElement: LayoutElement): void { + const shouldObserve = layoutElement.attached && (layoutElement.observerCount !== 0 || !!layoutElement.onLayout); + if (layoutElement.observed === shouldObserve) { + return; + } + if (shouldObserve) { + this.ensureResizeObserver(); + this.resizeObserver?.observe(layoutElement.element); + } else { + this.resizeObserver?.unobserve(layoutElement.element); + } + layoutElement.observed = shouldObserve; + } + + private ensureResizeObserver(): void { + if (this.resizeObserver || typeof ResizeObserver === 'undefined') { + return; + } + this.resizeObserver = new ResizeObserver(entries => { + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const elementId = this.elementIdsByHtmlElement.get(entry.target); + const layoutElement = elementId === undefined ? undefined : this.elementsById.get(elementId); + if (layoutElement?.element === entry.target && layoutElement.attached) { + this.scheduleRefresh(); + return; + } + } + }); + } + + private schedulePostLayoutCallbacks(): void { + if ( + !this.pendingPostLayoutCallbacks || + this.postLayoutFlushScheduled || + this.postLayoutDeferralDepth !== 0 || + this.destroyed + ) { + return; + } + this.postLayoutFlushScheduled = true; + if (this.postLayoutScheduler) { + this.postLayoutScheduler(this.flushPostLayoutCallbacks); + } else { + Promise.resolve().then(this.flushPostLayoutCallbacks); + } + } + + private performUpdates(): void { + this.observersToCommit = undefined; + + // 1. Run the special onMeasure hooks so their DOM reads and pending commits are prepared first. + this.measureOnMeasureObservers(); + + // 2. Measure every attached element once, notify changed size observers, and collect pending work. + const elementsToNotify = this.measureElementLayouts(); + + // 3. Apply observer DOM writes only after every measurement in this pass has completed. + this.commitLayoutObservers(); + + // 4. Deliver public onLayout callbacks after internal observer commits have finished. + this.notifyOnLayoutCallbacks(elementsToNotify); + + // 5. Refresh downstream layout consumers, such as visibility observation, using committed geometry. + this.onLayoutPassCommitted(); + } + + private measureOnMeasureObservers(): void { + for (const elementId of this.elementIdsWithMeasureObserver) { + const entry = this.elementsById.get(elementId)?.measureObserver; + if (!entry || !entry.layoutElement.attached) { + continue; + } + entry.preparedCommit = false; + try { + entry.observer.onMeasure!(entry.layoutElement.element); + entry.preparedCommit = !!entry.observer.onCommit; + } catch (error) { + this.logObserverError('measure', entry, error); + } + } + } + + private measureElementLayouts(): LayoutElement[] | undefined { + let elementsToNotify: LayoutElement[] | undefined; + for (const layoutElement of this.elementsById.values()) { + if (!layoutElement.attached) { + continue; + } + const shouldMeasureSize = layoutElement.sizeObserverCount !== 0; + const shouldMeasureFrame = !!layoutElement.onLayout; + const rect = shouldMeasureSize || shouldMeasureFrame ? layoutElement.element.getBoundingClientRect() : undefined; + this.measureElementLayoutObservers(layoutElement, rect); + if (rect && shouldMeasureFrame) { + const frame = measureElementFrameFromRect(layoutElement.element, rect); + const frameChanged = !layoutElement.lastFrame || !framesEqual(layoutElement.lastFrame, frame); + if (layoutElement.forceOnLayout || frameChanged) { + layoutElement.forceOnLayout = false; + layoutElement.lastFrame = frame; + (elementsToNotify ??= []).push(layoutElement); + } + } + } + return elementsToNotify; + } + + private measureElementLayoutObservers(layoutElement: LayoutElement, rect: DOMRect | undefined): void { + const observers = layoutElement.observers; + if (!observers) { + return; + } + for (const attributeName in observers) { + const entry = observers[attributeName]; + if (!entry) { + continue; + } + let shouldCommit = entry.preparedCommit; + entry.preparedCommit = false; + if ( + rect && + entry.observer.onSizeChanged && + (!entry.hasSize || entry.width !== rect.width || entry.height !== rect.height) + ) { + try { + entry.observer.onSizeChanged(rect.width, rect.height); + entry.width = rect.width; + entry.height = rect.height; + entry.hasSize = true; + shouldCommit = shouldCommit || !!entry.observer.onCommit; + } catch (error) { + this.logObserverError('notify size change', entry, error); + } + } + if (shouldCommit) { + (this.observersToCommit ??= []).push(entry); + } + } + } + + private commitLayoutObservers(): void { + const observersToCommit = this.observersToCommit; + this.observersToCommit = undefined; + if (!observersToCommit) { + return; + } + for (let i = 0; i < observersToCommit.length; i++) { + const entry = observersToCommit[i]; + if (entry.layoutElement.observers?.[entry.attributeName] !== entry) { + continue; + } + try { + entry.observer.onCommit!(entry.layoutElement.element); + } catch (error) { + this.logObserverError('commit', entry, error); + } + } + } + + private notifyOnLayoutCallbacks(elementsToNotify: LayoutElement[] | undefined): void { + if (!elementsToNotify) { + return; + } + const notify = () => { + for (let i = 0; i < elementsToNotify.length; i++) { + const layoutElement = elementsToNotify[i]; + const callback = layoutElement.onLayout; + const frame = layoutElement.lastFrame; + if (!callback || !frame) { + continue; + } + try { + callback(frame); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Valdi web renderer failed to call 'onLayout' on node ${layoutElement.id}: ${message}`); + } + } + }; + if (this.renderCompleteScheduler) { + this.renderCompleteScheduler(notify); + } else { + notify(); + } + } + + private logObserverError(phase: string, entry: LayoutObserverEntry, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Valdi web renderer failed to ${phase} layout observer '${entry.attributeName}' on node ${entry.layoutElement.id} (${entry.layoutElement.viewClass}): ${message}`, + ); + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRenderer.ts b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRenderer.ts index d9616fdb8..6e439089d 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRenderer.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRenderer.ts @@ -1,25 +1,101 @@ -import { Renderer } from 'valdi_core/src/Renderer'; -import { UpdateAttributeDelegate, ValdiWebRendererDelegate } from './ValdiWebRendererDelegate'; +import type { RequireFunc } from 'valdi_core/src/IModuleLoader'; +import type { ComponentConstructor, IComponent } from 'valdi_core/src/IComponent'; +import type { ComponentPrototype } from 'valdi_core/src/ComponentPrototype'; +import type { Renderer as RendererType } from 'valdi_core/src/Renderer'; +import type { AttributeUpdatedExternallyDelegate } from './core/ElementClass'; +import { getValdiRuntime } from 'valdi_core/src/ValdiRuntimeProvider'; +import { isValdiWebTracingEnabled } from './tracing/ValdiWebTracing'; -declare const require: (id: string) => any; +declare const require: RequireFunc; -// Bootstrap the runtime (sets up globals, moduleLoader, etc.) -require('./ValdiWebRuntime'); +getValdiRuntime(); -export class ValdiWebRenderer extends Renderer implements UpdateAttributeDelegate { +// Collapsed web packages generate browser worker factories at this path. +// Keep this host-only: worker entries import ValdiWebRuntime, and loading the +// factories from there would make worker chunks discover worker entries again. +try { + require('../../_web_worker_factories'); +} catch {} + +declare const moduleLoader: any; + +const customRequire = moduleLoader.resolveRequire('web_renderer/src/ValdiWebRenderer.ts'); + +const { Renderer } = customRequire('valdi_core/src/Renderer') as { Renderer: typeof RendererType }; +const rendererDelegate = customRequire('./ValdiWebRendererDelegate') as typeof import('./ValdiWebRendererDelegate'); +const rendererCore = customRequire('./core/ViewNodeTree') as typeof import('./core/ViewNodeTree'); +const paletteCore = customRequire('./core/Palette') as typeof import('./core/Palette'); +const debuggerCore = customRequire('./debug/WebDebuggerBridge') as typeof import('./debug/WebDebuggerBridge'); +const rootCore = customRequire('./WebRendererRoot') as typeof import('./WebRendererRoot'); +const ValdiWebRendererDelegate = rendererDelegate.ValdiWebRendererDelegate; +const ViewNodeTree = rendererCore.ViewNodeTree; +const COLOR_PALETTE_MANAGER = paletteCore.COLOR_PALETTE_MANAGER; +const WebDebuggerBridge = debuggerCore.WebDebuggerBridge; +const createIsolatedWebRendererRoot = rootCore.createIsolatedWebRendererRoot; + +let CONTEXT_ID_SEQUENCE = 0; + +function makeContextId(contextIdentifierPrefix: string | undefined): string { + const contextIdSuffix = (++CONTEXT_ID_SEQUENCE).toString(); + return contextIdentifierPrefix === undefined ? contextIdSuffix : `${contextIdentifierPrefix}-${contextIdSuffix}`; +} + +export class ValdiWebRenderer extends Renderer implements AttributeUpdatedExternallyDelegate { delegate: InstanceType; + private readonly debuggerBridge: InstanceType; - constructor(htmlRoot: HTMLElement | ShadowRoot) { - const delegate = new ValdiWebRendererDelegate(htmlRoot); - super('valdi-web-renderer', ['view', 'label', 'layout', 'scroll', 'image', 'textfield', 'textview', 'spinner', 'custom-view', 'video', 'shape'], delegate); - delegate.setAttributeDelegate(this); + constructor(htmlRoot: HTMLElement | ShadowRoot, contextIdentifierPrefix?: string) { + const isolatedRoot = createIsolatedWebRendererRoot(htmlRoot); + const viewNodeTree = new ViewNodeTree(COLOR_PALETTE_MANAGER); + const delegate = new ValdiWebRendererDelegate(isolatedRoot, viewNodeTree); + viewNodeTree.setPostLayoutScheduler((callback: () => void) => delegate.onNextLayoutComplete(callback)); + super( + makeContextId(contextIdentifierPrefix), + [ + 'view', + 'label', + 'layout', + 'scroll', + 'image', + 'animatedimage', + 'textfield', + 'textview', + 'spinner', + 'custom-view', + 'video', + 'shape', + 'blur', + 'webview', + ], + delegate, + undefined, + isValdiWebTracingEnabled(), + ); + delegate.setAttributeUpdatedExternallyDelegate(this); this.delegate = delegate; + this.debuggerBridge = new WebDebuggerBridge(isolatedRoot, viewNodeTree); } - updateAttribute(elementId: number, attributeName: string, attributeValue: any) { + + onAttributeUpdatedExternally(elementId: number, attributeName: string, attributeValue: unknown): void { super.attributeUpdatedExternally(elementId, attributeName, attributeValue); } - destroy() { - this.delegate.onDestroyed(); + setComponentContext(context: any): void { + super.setComponentContext(context); + super.setViewModelProperty('context', context); + } + + override onDestroy(): void { + this.debuggerBridge.destroy(); + super.onDestroy(); + } + + renderRootComponent, ViewModel = any, Context = any>( + ctr: ComponentConstructor, + prototype: ComponentPrototype, + viewModel: ViewModel, + context: Context, + ): void { + super.renderRootComponent(ctr, prototype, viewModel, context); } } 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 50e59f968..623d6368d 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts @@ -1,156 +1,136 @@ import { AnimationOptions } from 'valdi_core/src/AnimationOptions'; -import { FrameObserver, IRendererDelegate, VisibilityObserver } from 'valdi_core/src/IRendererDelegate'; +import { IRendererDelegate, VisibilityObserver } from 'valdi_core/src/IRendererDelegate'; import { Style } from 'valdi_core/src/Style'; +import { ElementFrame } from 'valdi_tsx/src/Geometry'; import { NativeNode } from 'valdi_tsx/src/NativeNode'; import { NativeView } from 'valdi_tsx/src/NativeView'; -import { - changeAttributeOnElement, - createElement, - createNodesRef, - destroyElement, - makeElementRoot, - moveElement, - NodesRef, - registerElements, - setAllElementsAttributeDelegate, -} from './HTMLRenderer'; - -export interface UpdateAttributeDelegate { - updateAttribute(elementId: number, attributeName: string, attributeValue: any): void; -} +import type { AttributeUpdatedExternallyDelegate } from './core/ElementClass'; +import { ViewNodeTree } from './core/ViewNodeTree'; export class ValdiWebRendererDelegate implements IRendererDelegate { - private attributeDelegate?: UpdateAttributeDelegate; - private frameObserver?: FrameObserver; - private resizeObserver?: ResizeObserver; - private elementIdByHtmlElement = new WeakMap(); - // Owned per delegate (i.e. per renderer/page) so element ids can't collide - // with another page's (github.com/Snapchat/Valdi#115). - private nodesRef: NodesRef = createNodesRef(); + private attributeUpdatedExternallyDelegate?: AttributeUpdatedExternallyDelegate; - constructor(private htmlRoot: HTMLElement | ShadowRoot) { - registerElements(); + constructor( + private htmlRoot: HTMLElement | ShadowRoot, + private readonly viewNodeTree: ViewNodeTree, + ) {} + + setRenderCompleteScheduler(schedule: (callback: () => void) => void): void { + this.viewNodeTree.setRenderCompleteScheduler(schedule); } - setAttributeDelegate(delegate: UpdateAttributeDelegate) { - this.attributeDelegate = delegate; - setAllElementsAttributeDelegate(this.nodesRef, this.attributeDelegate); + setAttributeUpdatedExternallyDelegate(delegate: AttributeUpdatedExternallyDelegate): void { + this.attributeUpdatedExternallyDelegate = delegate; } onElementBecameRoot(id: number): void { - makeElementRoot(this.nodesRef, id, this.htmlRoot); + this.viewNodeTree.makeElementRoot(id, this.htmlRoot); + this.viewNodeTree.scheduleVisibilityRefresh(false); } onElementMoved(id: number, parentId: number, parentIndex: number): void { - moveElement(this.nodesRef, id, parentId, parentIndex); + this.viewNodeTree.moveElement(id, parentId, parentIndex); + this.viewNodeTree.scheduleVisibilityRefresh(false); } onElementCreated(id: number, viewClass: string): void { - createElement(this.nodesRef, id, viewClass, this.attributeDelegate); - const element = this.nodesRef.get(id); - if (element?.htmlElement) { - this.elementIdByHtmlElement.set(element.htmlElement, id); - this.resizeObserver?.observe(element.htmlElement); - } + this.viewNodeTree.createElement(id, viewClass, this.attributeUpdatedExternallyDelegate); } onElementDestroyed(id: number): void { - const element = this.nodesRef.get(id); - if (element?.htmlElement) { - this.resizeObserver?.unobserve(element.htmlElement); - } - destroyElement(this.nodesRef, id); + this.viewNodeTree.destroyElement(id); } + onElementDestroyedFromParent(id: number): void {} onElementAttributeChangeAny(id: number, attributeName: string, attributeValue: any): void { - changeAttributeOnElement(this.nodesRef, id, attributeName, attributeValue); + this.viewNodeTree.setAttributeOnElement(id, attributeName, attributeValue); } onElementAttributeChangeNumber(id: number, attributeName: string, attributeValue: number): void { - changeAttributeOnElement(this.nodesRef, id, attributeName, attributeValue); + this.viewNodeTree.setAttributeOnElement(id, attributeName, attributeValue); } onElementAttributeChangeString(id: number, attributeName: string, attributeValue: string): void { - changeAttributeOnElement(this.nodesRef, id, attributeName, attributeValue); + this.viewNodeTree.setAttributeOnElement(id, attributeName, attributeValue); } onElementAttributeChangeTrue(id: number, attributeName: string): void { - changeAttributeOnElement(this.nodesRef, id, attributeName, undefined); + this.viewNodeTree.setAttributeOnElement(id, attributeName, true); } onElementAttributeChangeFalse(id: number, attributeName: string): void { - changeAttributeOnElement(this.nodesRef, id, attributeName, undefined); + this.viewNodeTree.setAttributeOnElement(id, attributeName, false); } onElementAttributeChangeUndefined(id: number, attributeName: string): void { - changeAttributeOnElement(this.nodesRef, id, attributeName, undefined); + this.viewNodeTree.setAttributeOnElement(id, attributeName, undefined); } onElementAttributeChangeStyle(id: number, attributeName: string, style: Style): void { - const attributes = style.attributes ?? {}; - Object.keys(attributes).forEach(key => { - changeAttributeOnElement(this.nodesRef, id, key, attributes[key]); - }); + this.viewNodeTree.setStyleAttributeOnElement(id, attributeName, style); } onElementAttributeChangeFunction(id: number, attributeName: string, fn: () => void): void { - changeAttributeOnElement(this.nodesRef, id, attributeName, fn); + this.viewNodeTree.setAttributeOnElement(id, attributeName, fn); + } + onNextLayoutComplete(callback: () => void): void { + requestAnimationFrame(() => { + this.drainScheduledLayoutObservers(false); + requestAnimationFrame(() => { + this.drainScheduledLayoutObservers(false); + callback(); + }); + }); + } + onNextDraw(callback: (hookTimeMs: number) => void): void { + requestAnimationFrame(hookTimeMs => callback(hookTimeMs)); } - onNextLayoutComplete(callback: () => void): void {} - onNextDraw(callback: (hookTimeMs: number) => void): void {} onRenderStart(): void { - // TODO(mgharmalkar) - // console.log('onRenderStart'); + this.viewNodeTree.beginRender(); } onRenderEnd(): void { - // TODO(mgharmalkar) - // console.log('onRenderEnd'); + this.viewNodeTree.endRender(); + this.viewNodeTree.scheduleVisibilityRefresh(false); } onAnimationStart(options: AnimationOptions, token: number): void { - // TODO: no animation support on web yet, so just call completion with cancelled = false. - options.completion?.(false); + this.viewNodeTree.beginAnimation(options, token); } - onAnimationEnd(): void {} - onAnimationCancel(token: number): void {} - registerVisibilityObserver(observer: VisibilityObserver): void { - // TODO(mgharmalkar) - // console.log('registerVisibilityObserver'); + onAnimationEnd(): void { + this.viewNodeTree.endAnimation(); } - registerFrameObserver(observer: FrameObserver): void { - this.frameObserver = observer; - - this.resizeObserver = new ResizeObserver((entries) => { - if (!this.frameObserver) return; - - const updates: number[] = []; - for (const entry of entries) { - const elementId = this.elementIdByHtmlElement.get(entry.target); - if (elementId === undefined) continue; - - const htmlElement = entry.target as HTMLElement; - const rect = htmlElement.getBoundingClientRect(); - const offsetParent = htmlElement.offsetParent as HTMLElement | null; - - let x: number; - let y: number; - if (offsetParent) { - const parentRect = offsetParent.getBoundingClientRect(); - const cs = getComputedStyle(offsetParent); - x = rect.left - parentRect.left + offsetParent.scrollLeft - (parseFloat(cs.borderLeftWidth) || 0); - y = rect.top - parentRect.top + offsetParent.scrollTop - (parseFloat(cs.borderTopWidth) || 0); - } else { - x = rect.left; - y = rect.top; - } - - updates.push(elementId, x, y, rect.width, rect.height); - } - - if (updates.length > 0) { - this.frameObserver(new Float64Array(updates)); - } - }); + onAnimationCancel(token: number): void { + this.viewNodeTree.cancelAnimation(token); + } + registerVisibilityObserver(observer: VisibilityObserver): void { + this.viewNodeTree.registerVisibilityObserver(observer); } getNativeView(id: number, callback: (instance: NativeView | undefined) => void): void {} getNativeNode(id: number): NativeNode | undefined { - throw new Error('Method not implemented.'); + return this.viewNodeTree.getNode(id)?.htmlElement as unknown as NativeNode | undefined; + } + getCachedElementFrame(id: number): ElementFrame | undefined { + return this.viewNodeTree.getElementFrame(id); + } + getElementFrame(id: number, callback: (instance: ElementFrame | undefined) => void): void { + callback(this.viewNodeTree.getElementFrame(id)); + } + takeElementSnapshot(id: number, callback: (snapshotBase64: string | undefined) => void): void { + const element = this.viewNodeTree.getNode(id)?.htmlElement; + const takeSnapshot = ( + globalThis as unknown as { + __valdiTakeElementSnapshot?: (element: HTMLElement) => Promise; + } + ).__valdiTakeElementSnapshot; + if (!element || !takeSnapshot) { + callback(undefined); + return; + } + + takeSnapshot(element) + .then(snapshot => callback(snapshot)) + .catch(error => { + console.error('Failed to capture Valdi web element snapshot', error); + callback(undefined); + }); } - getElementFrame(id: number, callback: (instance: any) => void): void {} - takeElementSnapshot(id: number, callback: (snapshotBase64: string | undefined) => void): void {} onUncaughtError(message: string, error: Error): void { console.error(message, error); } onDestroyed(): void { - this.frameObserver = undefined; - this.resizeObserver?.disconnect(); + this.viewNodeTree.destroy(); + } + + private drainScheduledLayoutObservers(forceVisibility: boolean): void { + this.viewNodeTree.drainScheduledLayoutObserverRefresh(); + this.viewNodeTree.drainScheduledVisibilityRefresh(forceVisibility); } } diff --git a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRuntime.ts b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRuntime.ts index 9498372ce..fc950c8e7 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRuntime.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRuntime.ts @@ -1,86 +1,207 @@ -// Declare webpack require.context +import type { ColorPalette, ColorPaletteManager } from './core/Palette'; +import { + beginValdiWebTrace, + endValdiWebTrace, + instantValdiWebTrace, + makeValdiWebTraceProxy, +} from './tracing/ValdiWebTracing'; +import { createValdiWebWorker } from './ValdiWebWorker'; + declare const require: { (id: string): any; - // 'mode' param enables webpack 'weak' context (lookup-only, no bundling) - context(directory: string, useSubdirectories: boolean, regExp: RegExp, mode?: string): any; }; -// Declare global for Node-like environment -declare const global: any; - -// Declare global timing functions -declare global { - var __originalTimingFunctions__: { - setTimeout: typeof setTimeout; - clearTimeout: typeof clearTimeout; - setInterval: typeof setInterval; - clearInterval: typeof clearInterval; - }; -} - const path = require('path-browserify'); +let cachedColorPaletteManager: ColorPaletteManager | undefined; +let cachedRuntimeCustomRequire: ((moduleId: string) => any) | undefined; +let cachedResolveAssetSourceUrl: ((source: unknown) => string | undefined) | undefined; +let cachedBase64FromByteArray: ((bytes: Uint8Array) => string) | undefined; +let cachedDetectImageMimeType: ((bytes: Uint8Array) => string) | undefined; + +const valdiGlobalThis = globalThis as any; -// Valdi runtime assumes global instead of globalThis -(globalThis as any).global = globalThis; +// globalThis is the canonical web global. Keep `global` as a compatibility +// alias for older generated code and third-party modules that still read the +// Node spelling. +valdiGlobalThis.global = valdiGlobalThis; // To make tests happy -(globalThis as any).describe = function(name: string, func: Function) {}; +valdiGlobalThis.describe = function (name: string, func: Function) {}; -// Eager context removed — modules are now resolved lazily via: +// Eager JS module context removed. Compiled modules now resolve lazily through: // 1. __valdiBootstrapModules (statically imported essentials) -// 2. moduleLoader factories (registerModule'd native modules) +// 2. moduleLoader factories (registered native module shims) +// 3. generated navigation and worker registries. + +function getRuntimeCustomRequire(): (moduleId: string) => any { + if (cachedRuntimeCustomRequire) { + return cachedRuntimeCustomRequire; + } + + const moduleLoader = valdiGlobalThis.moduleLoader; + if (!moduleLoader) { + throw new Error('Valdi moduleLoader is not available before runtime initialization'); + } + + const customRequire = moduleLoader.resolveRequire('web_renderer/src/ValdiWebRuntime.ts'); + cachedRuntimeCustomRequire = customRequire; + return customRequire; +} + +function getColorPaletteManager(): ColorPaletteManager { + if (cachedColorPaletteManager) { + return cachedColorPaletteManager; + } + + const customRequire = getRuntimeCustomRequire(); + cachedColorPaletteManager = customRequire('./core/Palette').COLOR_PALETTE_MANAGER as ColorPaletteManager; + return cachedColorPaletteManager; +} + +function stringToUtf8Bytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +function utf8BytesToString(value: Uint8Array): string { + return new TextDecoder().decode(value); +} + +function unwrapWebpackDefault(resourceModule: unknown): unknown { + const source = resourceModule as { default?: unknown }; + return source && typeof source === 'object' && 'default' in source ? source.default : resourceModule; +} + +function resourceModuleToByteArray(resourceModule: unknown): Uint8Array | undefined { + const value = unwrapWebpackDefault(resourceModule); + if (value instanceof Uint8Array) { + return value; + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value); + } + return undefined; +} + +// Runtime bootstrap executes before moduleLoader exists, so dependencies are loaded lazily. +function resolveRuntimeAssetSourceUrl(source: unknown): string | undefined { + if (!cachedResolveAssetSourceUrl) { + cachedResolveAssetSourceUrl = getRuntimeCustomRequire()('./utils/assetSource').resolveAssetSourceUrl as ( + source: unknown, + ) => string | undefined; + } + return cachedResolveAssetSourceUrl(source); +} + +function runtimeBytesToBase64(bytes: Uint8Array): string { + if (!cachedBase64FromByteArray) { + cachedBase64FromByteArray = getRuntimeCustomRequire()('coreutils/src/Base64').Base64.fromByteArray as ( + bytes: Uint8Array, + ) => string; + } + return cachedBase64FromByteArray(bytes); +} + +function runtimeImageMimeType(bytes: Uint8Array): string { + if (!cachedDetectImageMimeType) { + cachedDetectImageMimeType = getRuntimeCustomRequire()('./utils/imageSource').detectImageMimeType as ( + bytes: Uint8Array, + ) => string; + } + return cachedDetectImageMimeType(bytes); +} + +function resourceModuleToString(resourceModule: unknown): string { + const resolved = resolveRuntimeAssetSourceUrl(resourceModule); + if (resolved) { + return resolved; + } + const bytes = resourceModuleToByteArray(resourceModule); + if (bytes) { + return utf8BytesToString(bytes); + } + const value = unwrapWebpackDefault(resourceModule); + return typeof value === 'string' ? value : JSON.stringify(value); +} + +function resourceModuleToBytes(resourceModule: unknown): Uint8Array { + return resourceModuleToByteArray(resourceModule) ?? stringToUtf8Bytes(resourceModuleToString(resourceModule)); +} + +function getRegisteredModuleEntry(module: string, pathStr: string): unknown | undefined { + const registry = valdiGlobalThis.__valdiModuleEntryRegistry; + const factory = registry?.[module]?.[pathStr]; + if (factory) { + return factory(); + } + + const context = valdiGlobalThis.__valdiModuleEntryContext; + if (context) { + const filePath = './' + module + '/' + pathStr; + return context(filePath); + } + + return undefined; +} class Runtime { componentPaths = new Map(); isDebugEnabled = true; - // ConsoleLogTransformer guards use runtime.isLoggingEnabled + // ConsoleLogTransformer guards use isLoggingEnabled. isLoggingEnabled = true; - buildType = "debug"; + buildType = 'debug'; + apiVersion = 0; // Map of task IDs to timeout IDs for scheduleWorkItem private _taskIdCounter = 1; private _scheduledTasks = new Map(); // jsEvaluator for the ModuleLoader. Called when a compiled module's lazy - // Proxy is first accessed. Resolves via bootstrap modules (Init.js deps) - // then moduleLoader factories (native module shims registered at startup). - // Most modules are loaded by webpack's own require — loadJsModule only - // handles modules requested via valdiRequire / moduleLoader.load(). + // Proxy is first accessed. Resolves via bootstrap modules (Init.js deps), + // moduleLoader factories (native module shims registered at startup), and + // generated registries for dynamic module categories. loadJsModule(relativePath: string, requireFunc: any, module: any, exports: any) { relativePath = path.normalize(relativePath); module.path = relativePath; // 1. Bootstrap modules — statically imported so webpack always includes // them, but lazily evaluated (the cache stores factories, not exports) - // so evaluation order respects Init.js's global setup (e.g. PostInit - // needs global.Long which Init.js installs partway through). - const bootstrap = (globalThis as any).__valdiBootstrapModules; + // so evaluation order respects Init.js's globalThis setup (e.g. PostInit + // needs Long which Init.js installs partway through). + const bootstrap = valdiGlobalThis.__valdiBootstrapModules; if (bootstrap?.[relativePath]) { module.exports = bootstrap[relativePath](); return; } // 2. moduleLoader factories — native modules registered via shims or setup.ts - const ml = (global as any).moduleLoader; + const ml = valdiGlobalThis.moduleLoader; if (ml?.hasModuleFactory?.(relativePath)) { module.exports = ml.load(relativePath, true); return; } - // 3. Dynamic-module registries — build-time generated maps for modules + // 3. Generated module registry — explicit webpack-visible map for compiled + // Valdi modules still reached through customRequire/moduleLoader.load(). + const modules = valdiGlobalThis.__valdiModuleRegistry; + if (modules?.[relativePath]) { + module.exports = modules[relativePath](); + return; + } + + // 4. Dynamic-module registries — build-time generated maps for modules // that are targets of dynamic require(variable) calls. Each registry // covers one category: NavigationPage components, worker entry points. - const navPages = (globalThis as any).__valdiNavigationPages; + const navPages = valdiGlobalThis.__valdiNavigationPages; if (navPages?.[relativePath]) { module.exports = navPages[relativePath](); return; } - const workers = (globalThis as any).__valdiWorkerModules; + const workers = valdiGlobalThis.__valdiWorkerModules; if (workers?.[relativePath]) { module.exports = workers[relativePath](); return; } - if ((globalThis as any).runtime?.isLoggingEnabled) { + if (valdiGlobalThis.runtime?.isLoggingEnabled) { console.warn(`[ValdiWebRuntime] Module not found: ${relativePath}`); } } @@ -98,7 +219,7 @@ class Runtime { const symbolName = componentName.substring(0, atIdx); const filePath = componentName.substring(atIdx + 1); - const pages = (globalThis as any).__valdiNavigationPages; + const pages = valdiGlobalThis.__valdiNavigationPages; if (pages?.[filePath]) { try { const mod = pages[filePath](); @@ -112,7 +233,7 @@ class Runtime { } // Fallback: try moduleLoader (for native module components) - const ml = (global as any).moduleLoader; + const ml = valdiGlobalThis.moduleLoader; if (ml?.hasModuleFactory?.(filePath)) { const mod = ml.load(filePath, true); if (mod && mod[symbolName]) { @@ -122,17 +243,21 @@ class Runtime { } } - if ((globalThis as any).runtime?.isLoggingEnabled) { - console.error("could not find", componentName); + if (valdiGlobalThis.runtime?.isLoggingEnabled) { + console.error('could not find', componentName); } } - setColorPalette(palette: any) { - (global as any).currentPalette = palette; + configureColorPalette(name: string, palette: ColorPalette) { + getColorPaletteManager().configureColorPalette(name, palette); + } + + getColorPalette(name?: string) { + return getColorPaletteManager().getColorPalette(name); } - getColorPalette() { - return (global as any).currentPalette; + setActiveColorPalette(name: string) { + getColorPaletteManager().setActiveColorPalette(name); } getCurrentPlatform() { @@ -146,7 +271,7 @@ class Runtime { createContext(manager: any) { // console.log("createContext", manager); - return "contextId"; + return 'contextId'; } setLayoutSpecs(contextId: string, width: number, height: number, rtl: boolean) { @@ -164,37 +289,38 @@ class Runtime { // // Resolution tiers (first hit wins): // 1. __valdiImageRegistry[catalogPath] — build-time map emitted by - // collapse_web_paths, populated when consumers `require` the - // module's _image_registry.js. + // collapse_web_paths. // 2. __valdiImageContext — back-compat path for consumers that wire // a webpack require.context directly (pre-PR4 behavior). getAssets(catalogPath: string) { - const registry = (globalThis as any).__valdiImageRegistry?.[catalogPath]; + const registry = valdiGlobalThis.__valdiImageRegistry?.[catalogPath]; if (registry) { return Object.entries(registry).map(([k, v]: [string, any]) => ({ path: k, src: v?.default ?? v, })); } - const imgCtx = (globalThis as any).__valdiImageContext; - if (!imgCtx) { - return []; + + const imgCtx = valdiGlobalThis.__valdiImageContext; + if (imgCtx) { + const prefix = `./${catalogPath}/`; + const allKeys = imgCtx.keys(); + const filteredImages = allKeys.filter((key: string) => key.startsWith(prefix)); + return filteredImages.map((key: string) => ({ + path: path.basename(key).split('.').slice(0, -1).join('.'), + src: imgCtx(key).default || imgCtx(key), + })); } - const prefix = `./${catalogPath}/`; - const allKeys = imgCtx.keys(); - const filteredImages = allKeys.filter((key: string) => key.startsWith(prefix)); - return filteredImages.map((key: string) => ({ - path: path.basename(key).split('.').slice(0, -1).join('.'), - src: imgCtx(key).default || imgCtx(key), - })); + + return []; } makeAssetFromUrl(url: string) { return { path: url, - src: url, width: 100, height: 100, + src: url, }; } @@ -239,16 +365,19 @@ class Runtime { } createWorker(url: string) { - return { - postMessage(data: any) {}, - setOnMessage(f: Function) {}, - terminate() {}, - }; + return createValdiWebWorker(url); } destroyContext(contextId: string) {} - measureContext(contextId: string, maxWidth: number, widthMode: number, maxHeight: number, heightMode: number, rtl: boolean): [number, number] { + measureContext( + contextId: string, + maxWidth: number, + widthMode: number, + maxHeight: number, + heightMode: number, + rtl: boolean, + ): [number, number] { return [0, 0]; } @@ -288,23 +417,32 @@ class Runtime { if (completion) completion(); } - // Stubbed — was using jsonContext (removed). Localized strings now use - // _strings_preload.js generated by collapse_web_paths instead. getModuleEntry(module: string, pathStr: string, asString: boolean) { - return '{}'; + const resourceModule = getRegisteredModuleEntry(module, pathStr); + if (resourceModule === undefined) { + throw new Error(`Valdi module entry not found: ${module}/${pathStr}`); + } + return asString ? resourceModuleToString(resourceModule) : resourceModuleToBytes(resourceModule); } getModuleJsPaths(module: string) { - return [""]; + return ['']; + } + + beginTrace(tag: string) { + beginValdiWebTrace(tag); + } + + endTrace() { + endValdiWebTrace(); } - trace(tag: string, callback: Function) { - return callback(); + instantTrace(tag: string, args?: readonly unknown[]) { + instantValdiWebTrace(tag, args); } makeTraceProxy(tag: string, callback: Function) { - // Return callback directly to avoid adding stack frames - return callback; + return makeValdiWebTraceProxy(tag, callback); } startTraceRecording() { @@ -312,7 +450,10 @@ class Runtime { } stopTraceRecording(id: number) { - return []; + return { + traceData: new Uint8Array(), + traceEventCount: 0, + }; } callOnMainThread(method: Function, parameters: any) { @@ -320,54 +461,50 @@ class Runtime { } onMainThreadIdle(cb: Function) { - requestIdleCallback(() => { - cb(); - }); + if (typeof globalThis.requestIdleCallback === 'function') { + globalThis.requestIdleCallback(() => { + cb(); + }); + } else { + globalThis.setTimeout(() => { + cb(); + }, 0); + } } - makeAssetFromBytes(bytes: ArrayBuffer) { + makeAssetFromBytes(bytes: ArrayBuffer | Uint8Array) { const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); - let binary = ''; - for (let i = 0; i < view.length; i++) { - binary += String.fromCharCode(view[i]); - } - const src = 'data:image/png;base64,' + btoa(binary); return { - path: "", - src, + path: '', width: 100, height: 100, + src: `data:${runtimeImageMimeType(view)};base64,${runtimeBytesToBase64(view)}`, }; } makeDirectionalAsset(ltrAsset: any, rtlAsset: any) { - const isRtl = typeof document !== 'undefined' && document.dir === 'rtl'; - const asset = isRtl ? rtlAsset : ltrAsset; - if (typeof asset === 'string') { - return { path: asset, src: asset, width: 100, height: 100 }; - } return { - path: asset?.path ?? "", - src: asset?.src ?? asset?.path ?? "", - width: asset?.width ?? 100, - height: asset?.height ?? 100, + path: '', + width: 100, + height: 100, }; } makePlatformSpecificAsset(defaultAsset: any, platformAssetOverrides: any) { - const asset = defaultAsset; - if (typeof asset === 'string') { - return { path: asset, src: asset, width: 100, height: 100 }; - } return { - path: asset?.path ?? "", - src: asset?.src ?? asset?.path ?? "", - width: asset?.width ?? 100, - height: asset?.height ?? 100, + path: '', + width: 100, + height: 100, }; } - addAssetLoadObserver(asset: any, onLoad: Function, outputType: any, preferredWidth?: number, preferredHeight?: number) { + addAssetLoadObserver( + asset: any, + onLoad: Function, + outputType: any, + preferredWidth?: number, + preferredHeight?: number, + ) { return () => {}; } @@ -378,11 +515,7 @@ class Runtime { scheduleWorkItem(cb: Function, delayMs: number, interruptible: boolean) { const taskId = this._taskIdCounter++; const delay = delayMs || 0; - const timing = (globalThis as any).__originalTimingFunctions__; - // Use the same native setTimeout/clearTimeout pair so cancellation always works - // (window.setTimeout may be monkey-patched by Zone.js or others, returning IDs - // that native clearTimeout would not recognize) - const timeoutId = timing.setTimeout(() => { + const timeoutId = globalThis.setTimeout(() => { this._scheduledTasks.delete(taskId); try { cb(); @@ -397,13 +530,13 @@ class Runtime { unscheduleWorkItem(taskId: number) { const timeoutId = this._scheduledTasks.get(taskId); if (timeoutId !== undefined) { - (globalThis as any).__originalTimingFunctions__.clearTimeout(timeoutId); + globalThis.clearTimeout(timeoutId); this._scheduledTasks.delete(taskId); } } getCurrentContext() { - return ""; + return ''; } saveCurrentContext() { @@ -413,7 +546,9 @@ class Runtime { restoreCurrentContext(contextId: number) {} onUncaughtError(message: string, error: any) { - if ((globalThis as any).runtime?.isLoggingEnabled) console.log("uncaught error", message, error); + if (valdiGlobalThis.runtime?.isLoggingEnabled) { + console.log('uncaught error', message, error); + } } setUncaughtExceptionHandler(cb: Function) {} @@ -444,68 +579,59 @@ class Runtime { submitDebugMessage(level: string, message: string) { // Unused, should go through console.log } -}; +} -const globalAny = globalThis as any; -globalAny.runtime = new Runtime(); - -// Capture original console before Init overwrites it -globalAny.__originalConsole__ = { - log: console.log.bind(console), - warn: console.warn.bind(console), - error: console.error.bind(console), - info: console.info.bind(console), - debug: console.debug.bind(console), - dir: console.dir.bind(console), - trace: console.trace.bind(console), - assert: console.assert.bind(console), -}; -Object.freeze(globalAny.__originalConsole__); +valdiGlobalThis.runtime = new Runtime(); -/** Log to the real browser console even when Init has replaced global.console (e.g. during startup). */ -globalAny.__valdiLogToConsole__ = function (...args: unknown[]) { - const c = globalAny.__originalConsole__ || globalAny.console; - if (c?.log) c.log.apply(c, args); -}; +// Collapsed web packages generate these files for explicit webpack-visible +// runtime lookup. Non-collapsed test environments may omit them. +try { + require('../../_image_registry'); +} catch (error) {} -// Capture native browser setTimeout/clearTimeout before Valdi replaces them (like we do for console) -// Bind to window so they can be called without a receiver (e.g. timing.setTimeout(...)) without -// throwing "Illegal invocation" on native functions that require window as `this`. -(globalThis as any).__originalTimingFunctions__ = { - setTimeout: window.setTimeout.bind(window), - clearTimeout: window.clearTimeout.bind(window), - setInterval: window.setInterval.bind(window), - clearInterval: window.clearInterval.bind(window), -}; -Object.freeze((globalThis as any).__originalTimingFunctions__); +try { + require('./_module_registry'); +} catch (error) {} + +try { + require('./_module_entry_registry'); +} catch (error) {} // Bootstrap modules Init.js needs via loadJsModule. Statically required // so webpack includes them, but wrapped in factories so evaluation is -// deferred until first access. Order matters: PostInit references the -// global `Long` that Init.js installs after loading the Long module, so -// PostInit MUST NOT evaluate at bundle-load time. +// deferred until first access. Order matters: PostInit references the `Long` +// that Init.js installs after loading the Long module, so PostInit MUST NOT +// evaluate at bundle-load time. const _bootstrapModules: Record unknown> = { 'valdi_core/src/ModuleLoader': () => require('valdi_core/src/ModuleLoader'), 'valdi_core/src/Long': () => require('valdi_core/src/Long'), 'valdi_core/src/tslib': () => require('valdi_core/src/tslib'), 'valdi_core/src/PostInit': () => require('valdi_core/src/PostInit'), - // PostInit overwrites global.TextEncoder/TextDecoder when UnicodeNative registers. + 'valdi_core/src/Console': () => require('valdi_core/src/Console'), + 'valdi_core/src/PromisePolyfill': () => require('valdi_core/src/PromisePolyfill'), + 'valdi_core/src/TsnHelper': () => require('valdi_core/src/TsnHelper'), + // PostInit overwrites globalThis.TextEncoder/TextDecoder when UnicodeNative registers. // It loads TextCoding via moduleLoader.load() — must be resolvable here. 'coreutils/src/unicode/TextCoding': () => require('coreutils/src/unicode/TextCoding'), }; -(globalThis as any).__valdiBootstrapModules = _bootstrapModules; +valdiGlobalThis.__valdiBootstrapModules = _bootstrapModules; // Init.js creates moduleLoader and runs PostInit (which gates browser-specific // globals internally). Uses standard module path — webpack resolves via // resolve.modules config. -const initModule = require("valdi_core/src/Init"); +require('valdi_core/src/Init'); + +// Collapsed web packages generate native-module registration next to the +// runtime. Loading it here keeps registration internal to runtime bootstrap so +// host apps can import Valdi components/renderers without explicit setup. +require('../../RegisterNativeModules'); // Patch moduleLoader.onHotReload to handle undefined paths gracefully. // Webpack's module objects don't have .path, so modules that call // onHotReload(module, module.path, callback) would fail on web. -if (globalAny.moduleLoader) { - const originalOnHotReload = globalAny.moduleLoader.onHotReload.bind(globalAny.moduleLoader); - globalAny.moduleLoader.onHotReload = function(module: any, modulePath: string, callback: () => void) { +if (valdiGlobalThis.moduleLoader) { + const originalOnHotReload = valdiGlobalThis.moduleLoader.onHotReload.bind(valdiGlobalThis.moduleLoader); + valdiGlobalThis.moduleLoader.onHotReload = function (module: any, modulePath: string, callback: () => void) { if (!modulePath) { return () => {}; } @@ -514,7 +640,7 @@ if (globalAny.moduleLoader) { } // Console/timing restoration removed — PostInit now gates console and -// timing overwrites internally (isBrowser check), so they're never +// timing overwrites internally (isWeb check), so they're never // overwritten on web in the first place. export {}; diff --git a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebWorker.ts b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebWorker.ts new file mode 100644 index 000000000..b465d00cf --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebWorker.ts @@ -0,0 +1,57 @@ +import type { + NativeMessageEvent, + NativeMessagePort, + NativeWorker, + OnMessageFunc, +} from 'valdi_core/src/ValdiRuntime'; + +export type ValdiWebWorkerFactory = () => Worker; + +const WORKER_FACTORIES = new Map(); + +function getLogicalModulePath(modulePath: string): string { + const queryIndex = modulePath.indexOf('?'); + const logicalModulePath = queryIndex < 0 ? modulePath : modulePath.substring(0, queryIndex); + if (!logicalModulePath) { + throw new Error('Valdi web worker module path must not be empty'); + } + return logicalModulePath; +} + +export function registerValdiWebWorker(modulePath: string, factory: ValdiWebWorkerFactory): void { + const logicalModulePath = getLogicalModulePath(modulePath); + const existingFactory = WORKER_FACTORIES.get(logicalModulePath); + + if (existingFactory !== undefined && existingFactory !== factory) { + throw new Error(`Valdi web worker "${logicalModulePath}" is already registered`); + } + + WORKER_FACTORIES.set(logicalModulePath, factory); +} + +export function createValdiWebWorker(modulePath: string): NativeWorker { + const logicalModulePath = getLogicalModulePath(modulePath); + const factory = WORKER_FACTORIES.get(logicalModulePath); + if (factory === undefined) { + throw new Error(`Valdi web worker is not registered: ${logicalModulePath}`); + } + + const worker = factory(); + return { + postMessage(data: T, transfer?: readonly NativeMessagePort[]): void { + if (transfer === undefined) { + worker.postMessage(data); + } else { + worker.postMessage(data, transfer as unknown as Transferable[]); + } + }, + setOnMessage(func: OnMessageFunc): void { + worker.onmessage = event => { + func(event as unknown as NativeMessageEvent); + }; + }, + terminate(): void { + worker.terminate(); + }, + }; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/VisibilityObserverController.ts b/src/valdi_modules/src/valdi/web_renderer/src/VisibilityObserverController.ts new file mode 100644 index 000000000..2e7b35b72 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/VisibilityObserverController.ts @@ -0,0 +1,193 @@ +import type { VisibilityObserver } from 'valdi_core/src/IRendererDelegate'; + +interface Viewport { + x: number; + y: number; + width: number; + height: number; +} + +type ViewportTuple = [number, number, number, number]; +interface ObservedElement { + element: HTMLElement; + isVisible: boolean; + lastViewport?: ViewportTuple; +} + +const EMPTY_ELEMENT_IDS: number[] = []; + +export class VisibilityObserverController { + private observer?: VisibilityObserver; + private htmlRoot?: HTMLElement | ShadowRoot; + private readonly observedElementsById = new Map(); + private readonly elementIdsByHtmlElement = new WeakMap(); + private intersectionObserver?: IntersectionObserver; + + setRoot(htmlRoot: HTMLElement | ShadowRoot): void { + if (this.htmlRoot === htmlRoot) { + return; + } + this.htmlRoot = htmlRoot; + this.recreateIntersectionObserver(); + this.scheduleRefresh(false); + } + + registerObserver(observer: VisibilityObserver): void { + this.observer = observer; + this.ensureIntersectionObserver(); + this.scheduleRefresh(false); + } + + observeElement(id: number, element: HTMLElement): void { + const observedElement = this.observedElementsById.get(id); + if (observedElement?.element === element) { + return; + } + this.ensureIntersectionObserver(); + if (observedElement) { + this.intersectionObserver?.unobserve(observedElement.element); + this.elementIdsByHtmlElement.delete(observedElement.element); + } + this.observedElementsById.set(id, { + element, + isVisible: false, + }); + this.elementIdsByHtmlElement.set(element, id); + if (this.intersectionObserver) { + this.intersectionObserver.observe(element); + } else { + this.scheduleRefresh(false); + } + } + + unobserveElement(id: number): void { + this.destroyElement(id); + } + + destroyElement(id: number): void { + const observedElement = this.observedElementsById.get(id); + if (!observedElement) { + return; + } + this.intersectionObserver?.unobserve(observedElement.element); + this.elementIdsByHtmlElement.delete(observedElement.element); + this.observedElementsById.delete(id); + } + + scheduleRefresh(_force: boolean): void { + this.processPendingIntersectionEntries(); + } + + drainScheduledRefresh(_force: boolean): void { + this.processPendingIntersectionEntries(); + } + + destroy(): void { + this.intersectionObserver?.disconnect(); + this.intersectionObserver = undefined; + this.observer = undefined; + this.htmlRoot = undefined; + this.observedElementsById.clear(); + } + + private ensureIntersectionObserver(): void { + if (this.intersectionObserver || !this.observer || !this.htmlRoot) { + return; + } + const root = + typeof ShadowRoot !== 'undefined' && this.htmlRoot instanceof ShadowRoot ? null : (this.htmlRoot as HTMLElement); + this.intersectionObserver = new IntersectionObserver(entries => this.processIntersectionEntries(entries), { + root, + threshold: [0, 1], + }); + for (const observedElement of this.observedElementsById.values()) { + this.intersectionObserver.observe(observedElement.element); + } + } + + private recreateIntersectionObserver(): void { + this.intersectionObserver?.disconnect(); + this.intersectionObserver = undefined; + this.ensureIntersectionObserver(); + } + + private processPendingIntersectionEntries(): void { + const entries = this.intersectionObserver?.takeRecords(); + if (entries?.length) { + this.processIntersectionEntries(entries); + } + } + + private processIntersectionEntries(entries: IntersectionObserverEntry[]): void { + const observer = this.observer; + if (!observer || !this.htmlRoot) { + return; + } + + let appearingElements: number[] | undefined; + let disappearingElements: number[] | undefined; + let viewportUpdates: number[] | undefined; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const elementId = this.elementIdsByHtmlElement.get(entry.target); + if (elementId === undefined) { + continue; + } + const observedElement = this.observedElementsById.get(elementId); + if (!observedElement || observedElement.element !== entry.target) { + continue; + } + const viewport = this.visibleViewportForIntersectionEntry(entry); + const wasVisible = observedElement.isVisible; + const isVisible = !!viewport && viewport.width > 0 && viewport.height > 0; + if (isVisible) { + observedElement.isVisible = true; + if (!wasVisible) { + (appearingElements ??= []).push(elementId); + } + if (!this.hasSameViewport(observedElement, viewport)) { + observedElement.lastViewport = [viewport.x, viewport.y, viewport.width, viewport.height]; + (viewportUpdates ??= []).push(elementId, viewport.x, viewport.y, viewport.width, viewport.height); + } + } else if (wasVisible) { + observedElement.isVisible = false; + observedElement.lastViewport = undefined; + (disappearingElements ??= []).push(elementId); + } + } + + if (appearingElements || disappearingElements || viewportUpdates) { + observer( + appearingElements ?? EMPTY_ELEMENT_IDS, + disappearingElements ?? EMPTY_ELEMENT_IDS, + viewportUpdates ?? EMPTY_ELEMENT_IDS, + performance.now(), + ); + } + } + + private hasSameViewport(observedElement: ObservedElement, viewport: Viewport): boolean { + const lastViewport = observedElement.lastViewport; + return ( + !!lastViewport && + lastViewport[0] === viewport.x && + lastViewport[1] === viewport.y && + lastViewport[2] === viewport.width && + lastViewport[3] === viewport.height + ); + } + + private visibleViewportForIntersectionEntry(entry: IntersectionObserverEntry): Viewport | undefined { + const intersectionRect = entry.intersectionRect; + if (!entry.isIntersecting || intersectionRect.width <= 0 || intersectionRect.height <= 0) { + return undefined; + } + const elementRect = entry.boundingClientRect; + return { + x: intersectionRect.left - elementRect.left, + y: intersectionRect.top - elementRect.top, + width: intersectionRect.width, + height: intersectionRect.height, + }; + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/WebPolyglotRuntime.ts b/src/valdi_modules/src/valdi/web_renderer/src/WebPolyglotRuntime.ts index 6020f6e70..e5452ba3d 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/WebPolyglotRuntime.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/WebPolyglotRuntime.ts @@ -29,7 +29,8 @@ export function tryRegisterWebPolyglotViewClass( try { registerWebPolyglotViewClassOrThrow(fallbackModulePath, className, factory); return true; - } catch (_e) { + } catch (error) { + console.warn(`Valdi web renderer failed to register web polyglot view class '${className}'`, error); return false; } } diff --git a/src/valdi_modules/src/valdi/web_renderer/src/WebRendererRoot.ts b/src/valdi_modules/src/valdi/web_renderer/src/WebRendererRoot.ts new file mode 100644 index 000000000..2fd865a14 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/WebRendererRoot.ts @@ -0,0 +1,27 @@ +const ISOLATED_ROOT_STYLES: Record = { + all: 'initial', + color: 'black', + display: 'block', + height: '100%', + width: '100%', +}; + +const ISOLATED_ELEMENT_STYLES = '* { box-sizing: border-box; }'; + +function getOrCreateShadowRoot(htmlRoot: HTMLElement | ShadowRoot): ShadowRoot { + if (typeof ShadowRoot !== 'undefined' && htmlRoot instanceof ShadowRoot) { + return htmlRoot; + } + const host = htmlRoot as HTMLElement; + return host.shadowRoot ?? host.attachShadow({ mode: 'open' }); +} + +export function createIsolatedWebRendererRoot(htmlRoot: HTMLElement | ShadowRoot): HTMLElement { + const shadowRoot = getOrCreateShadowRoot(htmlRoot); + const style = document.createElement('style'); + style.textContent = ISOLATED_ELEMENT_STYLES; + const root = document.createElement('div'); + Object.assign(root.style, ISOLATED_ROOT_STYLES); + shadowRoot.replaceChildren(style, root); + return root; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/WebViewClassRegistry.ts b/src/valdi_modules/src/valdi/web_renderer/src/WebViewClassRegistry.ts index 6603ca19c..6fea38b92 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/WebViewClassRegistry.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/WebViewClassRegistry.ts @@ -6,7 +6,10 @@ * registry and pending callbacks are stored on globalThis so all chunks share one registry. */ -export type WebViewClassAttributeHandler = { changeAttribute: (name: string, value: unknown) => void }; +export interface WebViewClassAttributeHandler { + changeAttribute(name: string, value: unknown): void; + destroy?(): void; +} export type WebViewClassFactory = (container: HTMLElement) => WebViewClassAttributeHandler | void; export type WebViewClassRegistry = Map; export type WebViewClassRegistrationCallback = (reg: WebViewClassRegistry) => void; diff --git a/src/valdi_modules/src/valdi/web_renderer/src/animations/AnimationController.ts b/src/valdi_modules/src/valdi/web_renderer/src/animations/AnimationController.ts new file mode 100644 index 000000000..c5909bd4d --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/animations/AnimationController.ts @@ -0,0 +1,287 @@ +import type { AnimationOptions } from 'valdi_core/src/AnimationOptions'; +import { makeAnimationTimingFunction } from './AnimationTimingFunctions'; +import type { AnimationTimingFunction } from './AnimationTimingFunctions'; +import type { Animator } from './Animator'; +import type { KeyAnimation } from './KeyAnimation'; + +interface SpringState { + value: number; + velocity: number; + lastTimestamp: number; +} + +interface RunningAnimation { + keyAnimation: KeyAnimation; + transaction: RunningAnimationTransaction; + springState?: SpringState; +} + +interface RunningAnimationTransaction { + animator: Animator; + animations: Set; + startTimestamp: number; + timingFunction?: AnimationTimingFunction; +} + +const VELOCITY_THRESHOLD_MULTIPLIER = 1000 / 16; + +export class AnimationController { + private readonly transactionsByToken = new Map(); + private frameRequest: number | undefined; + private destroyed = false; + + commit(animator: Animator): void { + if (this.destroyed) { + animator.complete(true); + return; + } + + const options = animator.options; + let timing: AnimationTimingFunction | undefined; + if (isSpringOptions(options)) { + validateSpringOptions(options.stiffness, options.damping); + timing = undefined; + } else { + timing = makeAnimationTimingFunction(options); + } + this.cancelTransaction(animator.token); + const animations = animator.takeAnimations(); + const timestamp = now(); + const transaction: RunningAnimationTransaction = { + animator, + animations: new Set(), + startTimestamp: timestamp, + timingFunction: timing, + }; + this.transactionsByToken.set(animator.token, transaction); + + for (const keyAnimation of animations) { + const animation: RunningAnimation = { + keyAnimation, + transaction, + springState: isSpringOptions(animator.options) + ? { value: 0, velocity: 0, lastTimestamp: timestamp } + : undefined, + }; + if (keyAnimation.finished) { + continue; + } + transaction.animations.add(animation); + if (!this.applyProgress(animation, 0)) { + keyAnimation.cancel(); + transaction.animations.delete(animation); + } + } + + if (transaction.animations.size === 0) { + this.finishTransaction(transaction, false); + return; + } + this.scheduleFrameIfNeeded(); + } + + cancelTransaction(token: number): void { + const transaction = this.transactionsByToken.get(token); + if (!transaction) { + return; + } + const animations = Array.from(transaction.animations); + for (const animation of animations) { + if (!animation.keyAnimation.finished) { + this.completeAnimation(animation); + } + transaction.animations.delete(animation); + } + this.finishTransaction(transaction, true); + this.cancelFrameIfIdle(); + } + + destroy(): void { + if (this.destroyed) { + return; + } + this.destroyed = true; + if (this.frameRequest !== undefined) { + cancelAnimationFrame(this.frameRequest); + this.frameRequest = undefined; + } + const transactions = Array.from(this.transactionsByToken.values()); + this.transactionsByToken.clear(); + for (const transaction of transactions) { + for (const animation of transaction.animations) { + animation.keyAnimation.cancel(); + } + transaction.animations.clear(); + transaction.animator.complete(true); + } + } + + private runFrame = (timestamp: number): void => { + this.frameRequest = undefined; + if (this.destroyed) { + return; + } + + for (const transaction of Array.from(this.transactionsByToken.values())) { + for (const animation of Array.from(transaction.animations)) { + if (animation.keyAnimation.finished) { + transaction.animations.delete(animation); + continue; + } + const sample = sampleAnimation(animation, timestamp); + if (sample.finished) { + this.completeAnimation(animation); + transaction.animations.delete(animation); + } else if (!this.applyProgress(animation, sample.progress)) { + animation.keyAnimation.cancel(); + transaction.animations.delete(animation); + } + } + if (transaction.animations.size === 0) { + this.finishTransaction(transaction, false); + } + } + + this.scheduleFrameIfNeeded(); + }; + + private applyProgress(animation: RunningAnimation, progress: number): boolean { + try { + return animation.keyAnimation.applyProgress(progress); + } catch (error) { + console.error('Valdi web renderer animation failed', error); + return false; + } + } + + private completeAnimation(animation: RunningAnimation): void { + try { + animation.keyAnimation.complete(); + } catch (error) { + console.error('Valdi web renderer final animation application failed', error); + } + } + + private finishTransaction(transaction: RunningAnimationTransaction, wasCancelled: boolean): void { + if (this.transactionsByToken.get(transaction.animator.token) !== transaction) { + return; + } + this.transactionsByToken.delete(transaction.animator.token); + transaction.animator.complete(wasCancelled); + } + + private scheduleFrameIfNeeded(): void { + if (this.frameRequest !== undefined || this.transactionsByToken.size === 0 || this.destroyed) { + return; + } + this.frameRequest = requestAnimationFrame(this.runFrame); + } + + private cancelFrameIfIdle(): void { + if (this.transactionsByToken.size !== 0 || this.frameRequest === undefined) { + return; + } + cancelAnimationFrame(this.frameRequest); + this.frameRequest = undefined; + } +} + +interface AnimationSample { + progress: number; + finished: boolean; +} + +function sampleAnimation(animation: RunningAnimation, timestamp: number): AnimationSample { + const options = animation.transaction.animator.options; + if (isSpringOptions(options)) { + return sampleSpring(animation, timestamp, options.stiffness, options.damping); + } + const linearProgress = + options.duration <= 0 + ? 1 + : Math.max(0, Math.min(1, (timestamp - animation.transaction.startTimestamp) / (options.duration * 1000))); + return { + progress: animation.transaction.timingFunction!(linearProgress), + finished: linearProgress >= 1, + }; +} + +function sampleSpring( + animation: RunningAnimation, + timestamp: number, + stiffness: number, + damping: number, +): AnimationSample { + const state = animation.springState!; + const deltaSeconds = Math.max(0, timestamp - state.lastTimestamp) / 1000; + state.lastTimestamp = timestamp; + if (deltaSeconds > 0) { + updateSpringState(state, deltaSeconds, stiffness, damping); + } + const threshold = Math.max(Math.abs(animation.keyAnimation.minimumVisibleChange), Number.EPSILON); + const finished = + Math.abs(state.value - 1) < threshold && Math.abs(state.velocity) < threshold * VELOCITY_THRESHOLD_MULTIPLIER; + if (finished) { + state.value = 1; + state.velocity = 0; + } + return { progress: state.value, finished }; +} + +function updateSpringState(state: SpringState, deltaSeconds: number, stiffness: number, damping: number): void { + const frequency = Math.sqrt(stiffness); + const dampingRatio = damping / (2 * frequency); + const displacement = state.value - 1; + const velocity = state.velocity; + let nextDisplacement: number; + let nextVelocity: number; + + if (dampingRatio > 1) { + const root = frequency * Math.sqrt(dampingRatio * dampingRatio - 1); + const gammaPlus = -dampingRatio * frequency + root; + const gammaMinus = -dampingRatio * frequency - root; + const coefficientA = displacement - (gammaMinus * displacement - velocity) / (gammaMinus - gammaPlus); + const coefficientB = (gammaMinus * displacement - velocity) / (gammaMinus - gammaPlus); + const minusTerm = Math.exp(gammaMinus * deltaSeconds); + const plusTerm = Math.exp(gammaPlus * deltaSeconds); + nextDisplacement = coefficientA * minusTerm + coefficientB * plusTerm; + nextVelocity = coefficientA * gammaMinus * minusTerm + coefficientB * gammaPlus * plusTerm; + } else if (Math.abs(dampingRatio - 1) < Number.EPSILON) { + const coefficientA = displacement; + const coefficientB = velocity + frequency * displacement; + const exponential = Math.exp(-frequency * deltaSeconds); + nextDisplacement = (coefficientA + coefficientB * deltaSeconds) * exponential; + nextVelocity = nextDisplacement * -frequency + coefficientB * exponential; + } else { + const dampedFrequency = frequency * Math.sqrt(1 - dampingRatio * dampingRatio); + const cosCoefficient = displacement; + const sinCoefficient = (dampingRatio * frequency * displacement + velocity) / dampedFrequency; + const exponential = Math.exp(-dampingRatio * frequency * deltaSeconds); + const cosine = Math.cos(dampedFrequency * deltaSeconds); + const sine = Math.sin(dampedFrequency * deltaSeconds); + nextDisplacement = exponential * (cosCoefficient * cosine + sinCoefficient * sine); + nextVelocity = + nextDisplacement * -frequency * dampingRatio + + exponential * (-dampedFrequency * cosCoefficient * sine + dampedFrequency * sinCoefficient * cosine); + } + + state.value = nextDisplacement + 1; + state.velocity = nextVelocity; +} + +function isSpringOptions(options: AnimationOptions): options is Extract { + return 'stiffness' in options; +} + +function validateSpringOptions(stiffness: number, damping: number): void { + if (!Number.isFinite(stiffness) || stiffness <= 0) { + throw new Error('Animation spring stiffness must be a finite value greater than zero'); + } + if (!Number.isFinite(damping) || damping <= 0) { + throw new Error('Animation spring damping must be a finite value greater than zero'); + } +} + +function now(): number { + return typeof performance === 'undefined' ? Date.now() : performance.now(); +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/animations/AnimationTimingFunctions.ts b/src/valdi_modules/src/valdi/web_renderer/src/animations/AnimationTimingFunctions.ts new file mode 100644 index 000000000..81433bfdc --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/animations/AnimationTimingFunctions.ts @@ -0,0 +1,71 @@ +import { AnimationCurve } from 'valdi_core/src/AnimationOptions'; +import type { AnimationOptions } from 'valdi_core/src/AnimationOptions'; + +export type AnimationTimingFunction = (progress: number) => number; + +const LINEAR_TIMING_FUNCTION: AnimationTimingFunction = progress => progress; +const EASE_IN_TIMING_FUNCTION: AnimationTimingFunction = makeCubicBezierTimingFunction(0.42, 0, 1, 1); +const EASE_OUT_TIMING_FUNCTION: AnimationTimingFunction = makeCubicBezierTimingFunction(0, 0, 0.58, 1); +const EASE_IN_OUT_TIMING_FUNCTION: AnimationTimingFunction = makeCubicBezierTimingFunction(0.42, 0, 0.58, 1); + +export function makeAnimationTimingFunction( + options: Exclude, +): AnimationTimingFunction { + if (!Number.isFinite(options.duration) || options.duration < 0) { + throw new Error('Animation duration must be a finite value greater than or equal to zero'); + } + if ('controlPoints' in options) { + const points = options.controlPoints; + if ( + points.length !== 4 || + points.some(point => !Number.isFinite(point)) || + points[0] < 0 || + points[0] > 1 || + points[2] < 0 || + points[2] > 1 + ) { + throw new Error('Animation controlPoints must contain four finite values with x coordinates between 0 and 1'); + } + return makeCubicBezierTimingFunction(points[0], points[1], points[2], points[3]); + } + switch (options.curve ?? AnimationCurve.EaseInOut) { + case AnimationCurve.Linear: + return LINEAR_TIMING_FUNCTION; + case AnimationCurve.EaseIn: + return EASE_IN_TIMING_FUNCTION; + case AnimationCurve.EaseOut: + return EASE_OUT_TIMING_FUNCTION; + case AnimationCurve.EaseInOut: + return EASE_IN_OUT_TIMING_FUNCTION; + } +} + +function makeCubicBezierTimingFunction( + x1: number, + y1: number, + x2: number, + y2: number, +): AnimationTimingFunction { + const sample = (t: number, a1: number, a2: number) => { + const inverse = 1 - t; + return 3 * inverse * inverse * t * a1 + 3 * inverse * t * t * a2 + t * t * t; + }; + return progress => { + let low = 0; + let high = 1; + let t = progress; + for (let index = 0; index < 12; index++) { + const x = sample(t, x1, x2); + if (Math.abs(x - progress) < 0.000001) { + break; + } + if (x < progress) { + low = t; + } else { + high = t; + } + t = (low + high) / 2; + } + return sample(t, y1, y2); + }; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/animations/Animator.ts b/src/valdi_modules/src/valdi/web_renderer/src/animations/Animator.ts new file mode 100644 index 000000000..010205224 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/animations/Animator.ts @@ -0,0 +1,132 @@ +import type { AnimationOptions } from 'valdi_core/src/AnimationOptions'; +import type { AnimatorCommitPreparation } from './AnimatorCommitPreparation'; +import { KeyAnimation } from './KeyAnimation'; + +export interface AnimatorDelegate { + animatorWillApplyLayoutMutation(animator: Animator): void; +} + +export class Animator { + private readonly animationsByOwner = new Map>(); + private commitPreparations?: Map; + private hasLayoutMutation = false; + private sealed = false; + private preparedForCommit = false; + private completed = false; + + constructor( + readonly options: AnimationOptions, + readonly token: number, + private readonly delegate: AnimatorDelegate, + ) {} + + willApplyLayoutMutation(): void { + if (this.hasLayoutMutation) { + return; + } + this.hasLayoutMutation = true; + this.delegate.animatorWillApplyLayoutMutation(this); + } + + getCommitPreparation(key: string): AnimatorCommitPreparation | undefined { + return this.commitPreparations?.get(key); + } + + addCommitPreparation(key: string, preparation: AnimatorCommitPreparation): void { + if (this.sealed || this.preparedForCommit) { + throw new Error('Cannot add a commit preparation to a committed animator'); + } + const preparations = (this.commitPreparations ??= new Map()); + if (preparations.has(key)) { + throw new Error(`Animator already has a commit preparation for '${key}'`); + } + preparations.set(key, preparation); + } + + prepareForCommit(): void { + if (this.preparedForCommit) { + throw new Error('Animator was already prepared for commit'); + } + this.preparedForCommit = true; + const preparations = this.commitPreparations; + this.commitPreparations = undefined; + if (!preparations) { + return; + } + preparations.forEach(preparation => { + try { + preparation.prepareForCommit(this); + } catch (error) { + preparation.cancel(); + console.error('Valdi web renderer animation commit preparation failed', error); + } + }); + } + + addAnimation(owner: object, key: string, animation: KeyAnimation): void { + if (this.sealed) { + throw new Error('Cannot add an animation to a committed animator'); + } + let animations = this.animationsByOwner.get(owner); + if (!animations) { + animations = new Map(); + this.animationsByOwner.set(owner, animations); + } + const previous = animations.get(key); + animations.set(key, animation); + if (previous && previous !== animation) { + previous.cancel(); + } + } + + get empty(): boolean { + for (const animations of this.animationsByOwner.values()) { + for (const animation of animations.values()) { + if (!animation.finished) { + return false; + } + } + } + return true; + } + + takeAnimations(): KeyAnimation[] { + if (this.sealed) { + throw new Error('Animator was already committed'); + } + this.sealed = true; + const result: KeyAnimation[] = []; + this.animationsByOwner.forEach(animations => + animations.forEach(animation => { + if (!animation.finished) { + result.push(animation); + } + }), + ); + this.animationsByOwner.clear(); + return result; + } + + complete(wasCancelled: boolean): void { + if (this.completed) { + return; + } + this.completed = true; + this.cancelCommitPreparations(); + const completion = this.options.completion; + if (!completion) { + return; + } + try { + completion(wasCancelled); + } catch (error) { + console.error('Valdi web renderer animation completion failed', error); + } + } + + private cancelCommitPreparations(): void { + const preparations = this.commitPreparations; + this.commitPreparations = undefined; + preparations?.forEach(preparation => preparation.cancel()); + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/animations/AnimatorCommitPreparation.ts b/src/valdi_modules/src/valdi/web_renderer/src/animations/AnimatorCommitPreparation.ts new file mode 100644 index 000000000..a7f5b42cb --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/animations/AnimatorCommitPreparation.ts @@ -0,0 +1,6 @@ +import type { Animator } from './Animator'; + +export interface AnimatorCommitPreparation { + prepareForCommit(animator: Animator): void; + cancel(): void; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/animations/KeyAnimation.ts b/src/valdi_modules/src/valdi/web_renderer/src/animations/KeyAnimation.ts new file mode 100644 index 000000000..54b382e28 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/animations/KeyAnimation.ts @@ -0,0 +1,34 @@ +export abstract class KeyAnimation { + private finishedValue = false; + + constructor(readonly minimumVisibleChange: number) {} + + get finished(): boolean { + return this.finishedValue; + } + + abstract applyProgress(progress: number): boolean; + + abstract applyFinalValue(): void; + + cancel(): void { + if (this.finishedValue) { + return; + } + this.finishedValue = true; + this.didFinish(); + } + + complete(): void { + if (this.finishedValue) { + return; + } + try { + this.applyFinalValue(); + } finally { + this.cancel(); + } + } + + protected didFinish(): void {} +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/animations/LayoutAnimation.ts b/src/valdi_modules/src/valdi/web_renderer/src/animations/LayoutAnimation.ts new file mode 100644 index 000000000..89ee77e82 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/animations/LayoutAnimation.ts @@ -0,0 +1,438 @@ +import { MIN_VISIBLE_CHANGE_PIXEL } from '../attributes/AttributesBinder'; +import type { ViewNode } from '../core/ViewNode'; +import type { ViewNodeTree } from '../core/ViewNodeTree'; +import type { LayoutAnimationSizeApplier } from '../core/ElementClass'; +import type { Animator } from './Animator'; +import type { AnimatorCommitPreparation } from './AnimatorCommitPreparation'; +import { KeyAnimation } from './KeyAnimation'; + +export interface LayoutFrame { + x: number; + y: number; + width: number; + height: number; +} + +interface LayoutSnapshotEntry { + node: ViewNode; + parent: ViewNode | undefined; + frame: LayoutFrame; + animationsEnabled: boolean; +} + +export interface LayoutSnapshot { + readonly entries: LayoutSnapshotEntry[]; + readonly entriesByNode: Map; +} + +interface TranslationProjection { + x: number; + y: number; +} + +interface LayoutAnimationRecord { + node: ViewNode | undefined; + readonly startFrame: LayoutFrame; + readonly endFrame: LayoutFrame; + currentFrame: LayoutFrame; + parent: LayoutAnimationRecord | undefined; + readonly originalTranslate: string; + readonly sizeApplier: LayoutAnimationSizeApplier | undefined; + projection: TranslationProjection; +} + +const LAYOUT_ANIMATION_KEY = 'layout'; +const MINIMUM_SCALE = 0.0001; +const FRAME_EPSILON = 0.01; +const IDENTITY_PROJECTION: TranslationProjection = { x: 0, y: 0 }; +const SUPPORTS_INDEPENDENT_TRANSFORMS = + typeof CSS === 'undefined' || + typeof CSS.supports !== 'function' || + (CSS.supports('translate', '1px 1px') && CSS.supports('scale', '1 1')); + +export class LayoutAnimationPass implements AnimatorCommitPreparation { + constructor( + private tree: ViewNodeTree | undefined, + private initialSnapshot: LayoutSnapshot | undefined, + ) {} + + prepareForCommit(animator: Animator): void { + const tree = this.tree; + const initialSnapshot = this.initialSnapshot; + this.tree = undefined; + this.initialSnapshot = undefined; + if (!tree || !initialSnapshot) { + return; + } + const animation = new LayoutAnimation(initialSnapshot, tree.captureLayoutAnimationSnapshot(), tree); + if (animation.empty) { + animation.cancel(); + return; + } + tree.setActiveLayoutAnimation(animation); + animator.addAnimation(tree, LAYOUT_ANIMATION_KEY, animation); + } + + cancel(): void { + this.tree = undefined; + this.initialSnapshot = undefined; + } +} + +export class LayoutAnimation extends KeyAnimation { + private records: LayoutAnimationRecord[]; + private readonly recordsByNode = new Map(); + private destroying = false; + + constructor( + initialSnapshot: LayoutSnapshot, + finalSnapshot: LayoutSnapshot, + private tree: ViewNodeTree | undefined, + ) { + super(MIN_VISIBLE_CHANGE_PIXEL); + this.records = SUPPORTS_INDEPENDENT_TRANSFORMS + ? makeAnimationRecords(initialSnapshot, finalSnapshot, this.recordsByNode) + : []; + for (const record of this.records) { + record.node!.setLayoutAnimation(this); + } + } + + get empty(): boolean { + return this.records.length === 0; + } + + getFrame(node: ViewNode, current: boolean): LayoutFrame | undefined { + const record = this.recordsByNode.get(node); + return record ? (current ? record.currentFrame : record.endFrame) : undefined; + } + + cancelAnimationsWithChangedFrames(): void { + let removedAnimation = false; + for (const record of this.records) { + const node = record.node; + if (!node || !framesDiffer(record.endFrame, measureLogicalFrame(node.htmlElement))) { + continue; + } + restoreProjection(record); + this.recordsByNode.delete(node); + node.clearLayoutAnimation(this); + record.node = undefined; + removedAnimation = true; + } + if (this.recordsByNode.size === 0) { + if (removedAnimation) { + this.cancel(); + } + return; + } + const parentChanged = this.resolveActiveParents(); + if (removedAnimation || parentChanged) { + this.applyCurrentFrames(); + } + } + + completeNode(node: ViewNode): void { + this.removeNode(node, true); + } + + retireNode(node: ViewNode): void { + this.removeNode(node, false); + } + + destroy(): void { + if (this.finished) { + return; + } + this.destroying = true; + for (const record of this.records) { + const node = record.node; + if (node) { + node.clearLayoutAnimation(this); + this.recordsByNode.delete(node); + record.node = undefined; + } + } + this.records.length = 0; + this.cancel(); + } + + override applyProgress(progress: number): boolean { + for (const record of this.records) { + const node = record.node; + if (!node) { + continue; + } + const start = record.startFrame; + const end = record.endFrame; + const desired = record.currentFrame; + desired.x = interpolate(start.x, end.x, progress); + desired.y = interpolate(start.y, end.y, progress); + desired.width = record.sizeApplier ? interpolate(start.width, end.width, progress) : end.width; + desired.height = record.sizeApplier ? interpolate(start.height, end.height, progress) : end.height; + } + this.applyCurrentFrames(); + return true; + } + + override applyFinalValue(): void { + this.clearAllProjections(); + } + + protected override didFinish(): void { + if (!this.destroying) { + this.clearAllProjections(); + } + const tree = this.tree; + this.tree = undefined; + tree?.layoutAnimationDidFinish(this); + } + + private applyCurrentFrames(): void { + for (const record of this.records) { + const node = record.node; + if (!node) { + continue; + } + const end = record.endFrame; + const desired = record.currentFrame; + const parentProjection = record.parent?.projection ?? IDENTITY_PROJECTION; + const residualTranslateX = desired.x - end.x - parentProjection.x; + const residualTranslateY = desired.y - end.y - parentProjection.y; + const scaleX = record.sizeApplier ? Math.max(MINIMUM_SCALE, desired.width / end.width) : 1; + const scaleY = record.sizeApplier ? Math.max(MINIMUM_SCALE, desired.height / end.height) : 1; + const correction = record.sizeApplier?.apply(scaleX, scaleY) ?? IDENTITY_PROJECTION; + + const style = node.htmlElement.style; + style.setProperty('translate', `${residualTranslateX + correction.x}px ${residualTranslateY + correction.y}px`); + record.projection.x = desired.x - end.x; + record.projection.y = desired.y - end.y; + } + } + + private resolveActiveParents(): boolean { + let changed = false; + for (const record of this.records) { + const node = record.node; + if (!node) { + continue; + } + let parentNode = node.getParent(); + let parent: LayoutAnimationRecord | undefined; + while (parentNode) { + parent = this.recordsByNode.get(parentNode); + if (parent) { + break; + } + parentNode = parentNode.getParent(); + } + if (record.parent !== parent) { + record.parent = parent; + changed = true; + } + } + return changed; + } + + private removeNode(node: ViewNode, clearProjection: boolean): void { + const record = this.recordsByNode.get(node); + if (!record) { + return; + } + if (clearProjection) { + restoreProjection(record); + } + this.recordsByNode.delete(node); + node.clearLayoutAnimation(this); + record.node = undefined; + if (this.recordsByNode.size === 0) { + this.cancel(); + } + } + + private clearAllProjections(): void { + for (const record of this.records) { + const node = record.node; + if (!node) { + continue; + } + restoreProjection(record); + node.clearLayoutAnimation(this); + this.recordsByNode.delete(node); + record.node = undefined; + } + this.records.length = 0; + } +} + +export function captureLayoutSnapshot(root: ViewNode | null): LayoutSnapshot { + const entries: LayoutSnapshotEntry[] = []; + const entriesByNode = new Map(); + if (!root || root.isDestroyed() || root.isPendingRemoval()) { + return { entries, entriesByNode }; + } + + const rootOffset = measureLogicalOffset(root.htmlElement); + captureLayoutSnapshotNode(root, undefined, true, rootOffset.x, rootOffset.y, entries, entriesByNode); + return { entries, entriesByNode }; +} + +function captureLayoutSnapshotNode( + node: ViewNode, + parent: ViewNode | undefined, + parentAnimationsEnabled: boolean, + x: number, + y: number, + entries: LayoutSnapshotEntry[], + entriesByNode: Map, +): void { + const element = node.htmlElement; + const frame: LayoutFrame = { x, y, width: element.offsetWidth, height: element.offsetHeight }; + const animationsEnabled = parentAnimationsEnabled && node.isAnimationEnabled(); + let capturedParent = parent; + if (frame.width > 0 && frame.height > 0) { + const entry: LayoutSnapshotEntry = { node, parent, frame, animationsEnabled }; + entries.push(entry); + entriesByNode.set(node, entry); + capturedParent = node; + } + const children = node.getChildrenSnapshot(); + if (children.length === 0) { + return; + } + const childOriginX = x; + const childOriginY = y; + for (const child of children) { + if (child.isDestroyed() || child.isPendingRemoval()) { + continue; + } + const childElement = child.htmlElement; + captureLayoutSnapshotNode( + child, + capturedParent, + animationsEnabled, + childOriginX + childElement.offsetLeft, + childOriginY + childElement.offsetTop, + entries, + entriesByNode, + ); + } +} + +function makeAnimationRecords( + initialSnapshot: LayoutSnapshot, + finalSnapshot: LayoutSnapshot, + recordsByNode: Map, +): LayoutAnimationRecord[] { + const records: LayoutAnimationRecord[] = []; + const includedParentByNode = new Map(); + const hasTranslatingAncestorByNode = new Map(); + const blockedByNode = new Map(); + + for (const finalEntry of finalSnapshot.entries) { + if (!finalEntry.animationsEnabled) { + blockedByNode.set(finalEntry.node, true); + continue; + } + const initialEntry = initialSnapshot.entriesByNode.get(finalEntry.node); + if (!initialEntry) { + continue; + } + const parentRecord = finalEntry.parent ? includedParentByNode.get(finalEntry.parent) : undefined; + const parentHasTranslation = finalEntry.parent + ? hasTranslatingAncestorByNode.get(finalEntry.parent) === true + : false; + const parentBlocked = finalEntry.parent ? blockedByNode.get(finalEntry.parent) === true : false; + const changesSize = + differs(initialEntry.frame.width, finalEntry.frame.width) || + differs(initialEntry.frame.height, finalEntry.frame.height); + const changesPosition = + differs(initialEntry.frame.x, finalEntry.frame.x) || differs(initialEntry.frame.y, finalEntry.frame.y); + const sizeApplier = changesSize + ? finalEntry.node.makeLayoutAnimationSizeApplier(finalEntry.frame.width, finalEntry.frame.height) + : undefined; + const needsProjection = changesPosition || parentHasTranslation || sizeApplier !== undefined; + const blocked = parentBlocked || (needsProjection && hasIndependentTranslation(finalEntry.node.htmlElement)); + blockedByNode.set(finalEntry.node, blocked); + hasTranslatingAncestorByNode.set(finalEntry.node, parentHasTranslation || changesPosition); + if (!needsProjection || blocked) { + sizeApplier?.reset(); + includedParentByNode.set(finalEntry.node, parentRecord); + continue; + } + + const style = finalEntry.node.htmlElement.style; + const record: LayoutAnimationRecord = { + node: finalEntry.node, + startFrame: initialEntry.frame, + endFrame: finalEntry.frame, + currentFrame: { ...finalEntry.frame }, + parent: parentRecord, + originalTranslate: style.getPropertyValue('translate'), + sizeApplier, + projection: { ...IDENTITY_PROJECTION }, + }; + records.push(record); + recordsByNode.set(finalEntry.node, record); + includedParentByNode.set(finalEntry.node, record); + } + return records; +} + +function measureLogicalFrame(element: HTMLElement): LayoutFrame { + const offset = measureLogicalOffset(element); + return { x: offset.x, y: offset.y, width: element.offsetWidth, height: element.offsetHeight }; +} + +function measureLogicalOffset(element: HTMLElement): { x: number; y: number } { + let x = 0; + let y = 0; + let current: HTMLElement | null = element; + while (current) { + x += current.offsetLeft; + y += current.offsetTop; + current = current.offsetParent as HTMLElement | null; + } + return { x, y }; +} + +function hasIndependentTranslation(element: HTMLElement): boolean { + const style = typeof getComputedStyle === 'function' ? getComputedStyle(element) : element.style; + const translate = style.getPropertyValue('translate').trim(); + return !isIdentityTranslate(translate); +} + +function isIdentityTranslate(value: string): boolean { + return value === '' || value === 'none' || value === '0px' || value === '0px 0px'; +} + +function restoreProjection(record: LayoutAnimationRecord): void { + const element = record.node?.htmlElement; + if (!element) { + return; + } + restoreStyleProperty(element.style, 'translate', record.originalTranslate); + record.sizeApplier?.reset(); +} + +function restoreStyleProperty(style: CSSStyleDeclaration, name: string, value: string): void { + if (value) { + style.setProperty(name, value); + } else { + style.removeProperty(name); + } +} + +function interpolate(from: number, to: number, progress: number): number { + return from + (to - from) * progress; +} + +function framesDiffer(from: LayoutFrame, to: LayoutFrame): boolean { + return ( + differs(from.x, to.x) || differs(from.y, to.y) || differs(from.width, to.width) || differs(from.height, to.height) + ); +} + +function differs(from: number, to: number): boolean { + return Math.abs(from - to) > FRAME_EPSILON; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributeAnimation.ts b/src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributeAnimation.ts new file mode 100644 index 000000000..5fe5a1735 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributeAnimation.ts @@ -0,0 +1,83 @@ +import { KeyAnimation } from '../animations/KeyAnimation'; +import type { AttributeApplier, AttributeApplierContext, CompositeAttribute } from '../core/ElementClass'; + +export type AnimationInterpolator = (progress: number) => unknown; + +export class AttributeAnimation extends KeyAnimation { + private value: unknown; + + constructor( + readonly startValue: unknown, + readonly endValue: unknown, + minimumVisibleChange: number, + private readonly interpolate: AnimationInterpolator, + private readonly definition: AttributeApplier | CompositeAttribute, + private readonly key: string, + private readonly element: HTMLElement, + private readonly context: AttributeApplierContext, + private readonly finishCallback: (animation: AttributeAnimation) => void, + ) { + super(minimumVisibleChange); + this.value = startValue; + } + + get currentValue(): unknown { + return this.value; + } + + override applyProgress(progress: number): boolean { + const value = this.interpolate(progress); + try { + this.applyValue(value); + } catch (error) { + this.logApplyError(value, error); + return false; + } + this.value = value; + return true; + } + + override applyFinalValue(): void { + try { + if (this.endValue === undefined || this.endValue === null) { + this.definition.reset(this.element, this.key, this.context); + } else { + this.applyValue(this.endValue); + } + this.value = this.endValue; + } catch (error) { + this.logApplyError(this.endValue, error); + } + } + + protected override didFinish(): void { + this.finishCallback(this); + } + + private applyValue(value: unknown): void { + if ('parts' in this.definition) { + this.definition.apply(this.element, value as ReadonlyArray, this.key, this.context); + } else { + this.definition.apply(this.element, value, this.key, this.context); + } + } + + private logApplyError(value: unknown, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Valdi web renderer failed to apply animated attribute '${this.key}' on node ${this.context.id} with value ${stringifyValue(value)}: ${message}`, + ); + } +} + +function stringifyValue(value: unknown): string { + if (typeof value === 'function') { + return '[function]'; + } + try { + const stringified = JSON.stringify(value); + return stringified === undefined ? String(value) : stringified; + } catch (_error) { + return String(value); + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributeApplierHelpers.ts b/src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributeApplierHelpers.ts new file mode 100644 index 000000000..6396bedc1 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributeApplierHelpers.ts @@ -0,0 +1,181 @@ +import { parseCssFunction } from '../utils/cssFunction'; +import { consumeCssNumber, isAsciiAlphaCode, isAsciiDigitCode, isCssWhitespaceCode } from '../utils/cssScanner'; + +const CHAR_HASH = 35; +const CHAR_PERCENT = 37; +const CHAR_DOT = 46; +const CHAR_MINUS = 45; +const CHAR_UNDERSCORE = 95; + +export type StyleStringName = { + [K in keyof CSSStyleDeclaration]: CSSStyleDeclaration[K] extends string ? K : never; +}[keyof CSSStyleDeclaration] & + string; + +function isNullOrUndefined(value: unknown): value is null | undefined { + return value === null || value === undefined; +} + +function blocksUnitlessNumberPrefix(code: number): boolean { + return ( + isAsciiAlphaCode(code) || + isAsciiDigitCode(code) || + code === CHAR_UNDERSCORE || + code === CHAR_DOT || + code === CHAR_PERCENT || + code === CHAR_HASH || + code === CHAR_MINUS + ); +} + +function blocksUnitlessNumberSuffix(code: number): boolean { + return isAsciiAlphaCode(code) || isAsciiDigitCode(code) || code === CHAR_DOT || code === CHAR_PERCENT; +} + +export function parseNumber(value: unknown, attributeName: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`Expected '${attributeName}' to be a finite number`); + } + return value; +} + +export function parseBoolean(value: unknown, attributeName: string): boolean { + if (typeof value !== 'boolean') { + throw new Error(`Expected '${attributeName}' to be a boolean`); + } + return value; +} + +export function parseString(value: unknown, attributeName: string): string { + if (typeof value !== 'string') { + throw new Error(`Expected '${attributeName}' to be a string`); + } + return value; +} + +function isRepeatFunctionCount(value: string, numberStart: number): boolean { + let index = numberStart - 1; + while (index >= 0 && isCssWhitespaceCode(value.charCodeAt(index))) { + index--; + } + if (index < 6 || value.charCodeAt(index) !== 40) { + return false; + } + return value.slice(index - 6, index) === 'repeat'; +} + +function shouldAppendPxToCssNumber( + value: string, + numberStart: number, + numberEnd: number, + skipRepeatCount: boolean, +): boolean { + if (numberStart > 0 && blocksUnitlessNumberPrefix(value.charCodeAt(numberStart - 1))) { + return false; + } + if (numberEnd < value.length && blocksUnitlessNumberSuffix(value.charCodeAt(numberEnd))) { + return false; + } + return !skipRepeatCount || !isRepeatFunctionCount(value, numberStart); +} + +function appendPxToUnitlessCssNumbers(value: string, skipRepeatCount: boolean): string { + let output = ''; + let copiedUntil = 0; + let index = 0; + while (index < value.length) { + const end = consumeCssNumber(value, index); + if (end < 0) { + index++; + continue; + } + + if (shouldAppendPxToCssNumber(value, index, end, skipRepeatCount)) { + output += value.slice(copiedUntil, end); + output += 'px'; + copiedUntil = end; + } + index = end; + } + + return output ? output + value.slice(copiedUntil) : value; +} + +export function parseCssLength(value: unknown, attributeName: string): string { + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error(`Expected '${attributeName}' to be a finite CSS length`); + } + return `${value}px`; + } + if (typeof value === 'string') { + return appendPxToUnitlessCssNumbers(value, false); + } + throw new Error(`Expected '${attributeName}' to be a number or string CSS length`); +} + +export function parseCssTrackList(value: unknown, attributeName: string): string { + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error(`Expected '${attributeName}' to be a finite CSS track size`); + } + return `${value}px`; + } + if (typeof value === 'string') { + return appendPxToUnitlessCssNumbers(value, true); + } + throw new Error(`Expected '${attributeName}' to be a number or string CSS track list`); +} + +export function parseOptionalCssLength(value: unknown, attributeName: string): string | undefined { + return isNullOrUndefined(value) ? undefined : parseCssLength(value, attributeName); +} + +const LINEAR_GRADIENT_PREFIX = 'linear-gradient('; +const VALDI_GRADIENT_ANGLE_DEGREES = [180, 225, 270, 315, 0, 45, 90, 135]; + +function valdiGradientAngleToCssDegrees(angle: number, unit: string): number { + const angleRad = unit === 'rad' ? angle : (angle * Math.PI) / 180; + const valdiAngleIndex = Math.max(0, Math.min(7, Math.floor(angleRad / (Math.PI / 4)))); + return VALDI_GRADIENT_ANGLE_DEGREES[valdiAngleIndex]; +} + +function parseValdiGradientAngle(value: string): { angle: number; unit: 'deg' | 'rad' } | undefined { + let unit: 'deg' | 'rad'; + let numberEnd: number; + if (value.endsWith('deg')) { + unit = 'deg'; + numberEnd = value.length - 3; + } else if (value.endsWith('rad')) { + unit = 'rad'; + numberEnd = value.length - 3; + } else { + return undefined; + } + + const numberText = value.slice(0, numberEnd); + if (numberText.length === 0) { + return undefined; + } + const consumed = consumeCssNumber(numberText, 0); + if (consumed !== numberText.length) { + return undefined; + } + + const angle = Number(numberText); + return Number.isFinite(angle) ? { angle, unit } : undefined; +} + +export function resolveValdiGradientAngles(value: string): string { + const parsed = parseCssFunction(value); + if (!parsed || parsed.name !== 'linear-gradient' || parsed.parameters.length === 0) { + return value; + } + const angle = parseValdiGradientAngle(parsed.parameters[0]); + if (!angle) { + return value; + } + + const cssAngle = valdiGradientAngleToCssDegrees(angle.angle, angle.unit); + return `${LINEAR_GRADIENT_PREFIX}${[`${cssAngle}deg`, ...parsed.parameters.slice(1)].join(', ')})`; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributeOwner.ts b/src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributeOwner.ts new file mode 100644 index 000000000..21ef44282 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributeOwner.ts @@ -0,0 +1,24 @@ +export interface AttributeOwner { + readonly priority: number; + readonly source: string; +} + +const APPEARANCE_OWNER_PRIORITY = 1000; +const NATIVE_OVERRIDE_OWNER_PRIORITY = 0; + +let appearanceAttributeOwner: AttributeOwner | undefined; +let nativeOverrideAttributeOwner: AttributeOwner | undefined; + +export function getAppearanceAttributeOwner(): AttributeOwner { + return (appearanceAttributeOwner ??= { + priority: APPEARANCE_OWNER_PRIORITY, + source: 'appearance', + }); +} + +export function getNativeOverrideAttributeOwner(): AttributeOwner { + return (nativeOverrideAttributeOwner ??= { + priority: NATIVE_OVERRIDE_OWNER_PRIORITY, + source: 'nativeOverride', + }); +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributesApplier.ts b/src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributesApplier.ts new file mode 100644 index 000000000..7ce396da9 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributesApplier.ts @@ -0,0 +1,614 @@ +import type { Style } from 'valdi_core/src/Style'; +import type { Animator } from '../animations/Animator'; +import type { + AnyElementClass, + AttributeApplier, + AttributeApplierContext, + CompositeAttribute, + ElementAttribute, +} from '../core/ElementClass'; +import { IndexedRecord } from '../utils/IndexedRecord'; +import { AttributeAnimation } from './AttributeAnimation'; +import type { AttributeOwner } from './AttributeOwner'; +import { MIN_VISIBLE_CHANGE_PIXEL } from './AttributesBinder'; + +type StyleLike = { + attributes?: Record; +}; + +export enum AttributeSetResult { + Unchanged, + Changed, + ChangedAndInvalidatesLayout, +} + +type OwnerValue = { + owner: AttributeOwner; + value: unknown; +}; + +const DIRECT_OWNER: AttributeOwner = { priority: 2, source: 'direct' }; +const STYLE_OWNER: AttributeOwner = { priority: 4, source: 'style' }; +const COMPOSITE_OWNER: AttributeOwner = { priority: 2, source: 'composite' }; + +class StoredAttribute { + constructor( + readonly elementAttribute: ElementAttribute | undefined, + readonly invalidatesLayout: boolean, + ) { + this.lastAppliedValue = undefined; + } + + private singleOwner?: AttributeOwner; + private singleValue: unknown = undefined; + private values?: OwnerValue[]; + animation: AttributeAnimation | undefined; + lastAppliedValue: unknown; + + getResolvedValue(): unknown { + if (this.values) { + const resolved = this.resolveValueFromCollection(); + return resolved ? resolved.value : undefined; + } + return this.singleOwner ? this.singleValue : undefined; + } + + setValue(owner: AttributeOwner, value: unknown): boolean { + const oldResolvedValue = this.getResolvedValue(); + if (this.values) { + const existing = this.values.find(entry => entry.owner === owner); + if (existing) { + if (existing.value === value) { + return false; + } + existing.value = value; + } else { + this.values.push({ owner, value }); + } + } else if (this.singleOwner) { + if (this.singleOwner === owner) { + if (this.singleValue === value) { + return false; + } + this.singleValue = value; + } else { + this.values = [ + { owner: this.singleOwner!, value: this.singleValue }, + { owner, value }, + ]; + this.clearSingleValue(); + } + } else { + this.singleOwner = owner; + this.singleValue = value; + } + return oldResolvedValue !== this.getResolvedValue(); + } + + removeValue(owner: AttributeOwner): boolean { + const oldResolvedValue = this.getResolvedValue(); + if (this.values) { + const index = this.values.findIndex(entry => entry.owner === owner); + if (index < 0) { + return false; + } + this.values.splice(index, 1); + if (this.values.length === 1) { + const single = this.values[0]; + this.singleOwner = single.owner; + this.singleValue = single.value; + this.values = undefined; + } + } else if (this.singleOwner === owner) { + this.clearSingleValue(); + } else { + return false; + } + return oldResolvedValue !== this.getResolvedValue(); + } + + empty(): boolean { + return !this.singleOwner && (!this.values || this.values.length === 0); + } + + private clearSingleValue(): void { + this.singleOwner = undefined; + this.singleValue = undefined; + } + + private resolveValueFromCollection(): OwnerValue | undefined { + if (!this.values || this.values.length === 0) { + return undefined; + } + let best = this.values[0]; + for (let i = 1; i < this.values.length; i++) { + const candidate = this.values[i]; + if (candidate.owner.priority < best.owner.priority) { + best = candidate; + } + } + return best; + } +} + +export class AttributesApplier { + private readonly attributes = new IndexedRecord(); + private readonly dirtyAttributes = new IndexedRecord(); + private dirtyComposites?: IndexedRecord; + private currentStyle?: StyleLike; + private currentStyleAttributeNames?: string[]; + + constructor( + private readonly id: number, + private readonly elementClass: AnyElementClass, + ) {} + + setAttribute(attributeName: string, value: unknown): AttributeSetResult { + const actualAttributeName = attributeName.startsWith('$') ? attributeName.substring(1) : attributeName; + if (actualAttributeName === 'style') { + return this.setStyle(value as Style | undefined); + } + + const changedAttribute = this.updateAttributeForOwner(actualAttributeName, DIRECT_OWNER, value); + if (!changedAttribute) { + return AttributeSetResult.Unchanged; + } + this.markAttributeDirty(actualAttributeName); + if (changedAttribute.invalidatesLayout) { + return AttributeSetResult.ChangedAndInvalidatesLayout; + } + return AttributeSetResult.Changed; + } + + setAttributeForOwner(attributeName: string, owner: AttributeOwner, value: unknown): AttributeSetResult { + const changedAttribute = this.updateAttributeForOwner(attributeName, owner, value); + if (!changedAttribute) { + return AttributeSetResult.Unchanged; + } + this.markAttributeDirty(attributeName); + if (changedAttribute.invalidatesLayout) { + return AttributeSetResult.ChangedAndInvalidatesLayout; + } + return AttributeSetResult.Changed; + } + + flush(element: HTMLElement, context: AttributeApplierContext, animator: Animator | undefined): void { + while (!this.dirtyAttributes.empty) { + const attributeName = this.dirtyAttributes.pop(); + if (attributeName === undefined) { + continue; + } + const attribute = this.attributes.get(attributeName); + if (!attribute) { + this.logMissingStoredAttribute(attributeName); + continue; + } + const elementAttribute = attribute.elementAttribute; + if (elementAttribute?.composite) { + this.markCompositeDirty(elementAttribute.composite!); + } else { + const resolvedAnimator = animator && context.isAnimationEnabled() ? animator : undefined; + this.applyAttribute(attributeName, attribute, element, context, resolvedAnimator); + } + } + + const dirtyComposites = this.dirtyComposites; + if (dirtyComposites) { + while (!dirtyComposites.empty) { + const composite = dirtyComposites.pop(); + if (composite === undefined) { + continue; + } + const resolvedAnimator = animator && context.isAnimationEnabled() ? animator : undefined; + this.updateCompositeAttribute(composite, element, context, resolvedAnimator); + } + } + } + + getAttribute(attributeName: string): unknown { + return this.attributes.get(attributeName)?.getResolvedValue(); + } + + getDebugAttributes(): Record { + const debugAttributes: Record = {}; + const attributeNames = this.attributes.keys; + for (let i = 0; i < attributeNames.length; i++) { + const attributeName = attributeNames[i]; + const attribute = this.attributes.get(attributeName); + if (attribute?.elementAttribute?.composite && !attribute.elementAttribute.isCompositePart) { + continue; + } + const value = attribute?.getResolvedValue(); + if (value !== undefined) { + debugAttributes[attributeName] = toDebugValue(value, 0, new Set()); + } + } + return debugAttributes; + } + + cancelAnimations(): void { + for (const attributeName of this.attributes.keys) { + this.attributes.get(attributeName)?.animation?.cancel(); + } + } + + completeAnimations(): void { + for (const attributeName of this.attributes.keys) { + this.attributes.get(attributeName)?.animation?.complete(); + } + } + + markColorDependentAttributesDirty(): boolean { + let shouldFlush = false; + const attributeNames = this.attributes.keys; + for (let i = 0; i < attributeNames.length; i++) { + const attributeName = attributeNames[i]; + const attribute = this.attributes.get(attributeName); + if (!attribute || attribute.empty()) { + continue; + } + const elementAttribute = attribute.elementAttribute; + const composite = elementAttribute?.composite; + if (elementAttribute?.isCompositePart && composite?.colorDependent) { + this.markCompositeDirty(composite); + shouldFlush = true; + } else if (elementAttribute?.applier?.colorDependent) { + this.markAttributeDirty(attributeName); + shouldFlush = true; + } + } + return shouldFlush; + } + + private setStyle(value: Style | undefined | null): AttributeSetResult { + if (value === undefined || value === null) { + return this.clearCurrentStyle(); + } + const style = value as StyleLike; + const attributes = style.attributes; + if (!attributes || typeof attributes !== 'object') { + return this.clearCurrentStyle(); + } + if (this.currentStyle === style) { + return AttributeSetResult.Unchanged; + } + + let result = this.clearCurrentStyle(); + const attributeNames = Object.keys(attributes); + this.currentStyle = style; + this.currentStyleAttributeNames = attributeNames; + for (let i = 0; i < attributeNames.length; i++) { + const attributeName = attributeNames[i]; + const changedAttribute = this.updateAttributeForOwner(attributeName, STYLE_OWNER, attributes[attributeName]); + if (changedAttribute) { + this.markAttributeDirty(attributeName); + if (changedAttribute.invalidatesLayout) { + result = AttributeSetResult.ChangedAndInvalidatesLayout; + } else if (result === AttributeSetResult.Unchanged) { + result = AttributeSetResult.Changed; + } + } + } + return result; + } + + private clearCurrentStyle(): AttributeSetResult { + const attributeNames = this.currentStyleAttributeNames; + this.currentStyle = undefined; + this.currentStyleAttributeNames = undefined; + if (!attributeNames) { + return AttributeSetResult.Unchanged; + } + let result = AttributeSetResult.Unchanged; + for (let i = 0; i < attributeNames.length; i++) { + const attributeName = attributeNames[i]; + const changedAttribute = this.removeAttributeForOwner(attributeName, STYLE_OWNER); + if (changedAttribute) { + this.markAttributeDirty(attributeName); + if (changedAttribute.invalidatesLayout) { + result = AttributeSetResult.ChangedAndInvalidatesLayout; + } else if (result === AttributeSetResult.Unchanged) { + result = AttributeSetResult.Changed; + } + } + } + return result; + } + + private updateAttributeForOwner( + attributeName: string, + owner: AttributeOwner, + value: unknown, + ): StoredAttribute | undefined { + if (value === undefined || value === null) { + return this.removeAttributeForOwner(attributeName, owner); + } + let attribute = this.attributes.get(attributeName); + if (!attribute) { + attribute = this.createStoredAttribute(attributeName); + this.attributes.set(attributeName, attribute); + } + return attribute.setValue(owner, value) ? attribute : undefined; + } + + private removeAttributeForOwner(attributeName: string, owner: AttributeOwner): StoredAttribute | undefined { + const attribute = this.attributes.get(attributeName); + if (!attribute) { + return undefined; + } + return attribute.removeValue(owner) ? attribute : undefined; + } + + private createStoredAttribute(attributeName: string): StoredAttribute { + const elementAttribute = this.elementClass.elementAttributes[attributeName]; + if (!elementAttribute) { + return new StoredAttribute(undefined, !!this.elementClass.unknownAttributeApplier?.layoutDependent); + } + return new StoredAttribute( + elementAttribute, + !!(elementAttribute.applier?.layoutDependent || elementAttribute.composite?.layoutDependent), + ); + } + + private applyAttribute( + attributeName: string, + attribute: StoredAttribute, + element: HTMLElement, + context: AttributeApplierContext, + animator: Animator | undefined, + ): void { + const applier = attribute.elementAttribute?.applier; + const value = attribute.getResolvedValue(); + if (animator && attribute.invalidatesLayout) { + animator.willApplyLayoutMutation(); + } + if (!applier) { + const unknownApplier = this.elementClass.unknownAttributeApplier; + if (unknownApplier) { + try { + if (value === undefined || value === null) { + unknownApplier.reset(element, attributeName, context); + } else { + unknownApplier.apply(element, value, attributeName, context); + } + } catch (error) { + this.logApplyError(attributeName, value, error); + } + return; + } + this.logMissingAttribute(attributeName, value); + return; + } + if (applier.makeAnimationInterpolator) { + if (animator) { + if (this.startAnimation(applier, attribute, attributeName, value, element, context, animator)) { + attribute.lastAppliedValue = value; + return; + } + } else { + this.cancelAnimationIfNeeded(attribute); + } + } + try { + if (value === undefined || value === null) { + applier.reset(element, attributeName, context); + } else { + applier.apply(element, value, attributeName, context); + } + } catch (error) { + this.logApplyError(attributeName, value, error); + return; + } + attribute.lastAppliedValue = value; + } + + private updateCompositeAttribute( + composite: CompositeAttribute, + element: HTMLElement, + context: AttributeApplierContext, + animator: Animator | undefined, + ): void { + const values: unknown[] = []; + let hasValue = false; + for (const part of composite.parts) { + const value = this.getAttribute(part.name); + if ((value === undefined || value === null) && !part.optional) { + this.logApplyError(composite.name, value, new Error(`Composite attribute is missing '${part.name}'`)); + return; + } + if (value !== undefined && value !== null) { + hasValue = true; + } + try { + values.push( + value === undefined || value === null || !part.parse ? value : part.parse(element, value, part.name, context), + ); + } catch (error) { + this.logApplyError(part.name, value, error); + return; + } + } + + let attribute = this.attributes.get(composite.name); + if (!attribute) { + attribute = this.createStoredAttribute(composite.name); + this.attributes.set(composite.name, attribute); + } + if (hasValue) { + attribute.setValue(COMPOSITE_OWNER, values); + } else { + attribute.removeValue(COMPOSITE_OWNER); + } + this.applyComposite(composite, attribute, element, context, animator); + } + + private applyComposite( + composite: CompositeAttribute, + attribute: StoredAttribute, + element: HTMLElement, + context: AttributeApplierContext, + animator: Animator | undefined, + ): void { + const value = attribute.getResolvedValue(); + if (composite.makeAnimationInterpolator) { + if (animator) { + if (this.startAnimation(composite, attribute, composite.name, value, element, context, animator)) { + attribute.lastAppliedValue = value; + return; + } + } else { + this.cancelAnimationIfNeeded(attribute); + } + } + try { + if (animator && attribute.invalidatesLayout) { + animator.willApplyLayoutMutation(); + } + if (value === undefined || value === null) { + composite.reset(element, composite.name, context); + } else { + composite.apply(element, value as ReadonlyArray, composite.name, context); + } + } catch (error) { + this.logApplyError(composite.name, value, error); + return; + } + attribute.lastAppliedValue = value; + } + + private startAnimation( + definition: AttributeApplier | CompositeAttribute, + attribute: StoredAttribute, + key: string, + value: unknown, + element: HTMLElement, + context: AttributeApplierContext, + animator: Animator, + ): boolean { + const previousAnimation = attribute.animation; + const startValue = previousAnimation + ? animator.options.beginFromCurrentState + ? previousAnimation.currentValue + : previousAnimation.endValue + : attribute.lastAppliedValue; + try { + const interpolator = definition.makeAnimationInterpolator!(element, startValue, value, context); + if (!interpolator) { + this.cancelAnimationIfNeeded(attribute); + return false; + } + this.cancelAnimationIfNeeded(attribute); + const animation = new AttributeAnimation( + startValue, + value, + definition.animationMinimumVisibleChange ?? MIN_VISIBLE_CHANGE_PIXEL, + interpolator, + definition, + key, + element, + context, + finishedAnimation => { + if (attribute.animation === finishedAnimation) { + attribute.animation = undefined; + } + }, + ); + attribute.animation = animation; + animator.addAnimation(this, key, animation); + return true; + } catch (error) { + this.logApplyError(key, value, error); + return true; + } + } + + private cancelAnimationIfNeeded(attribute: StoredAttribute): void { + attribute.animation?.cancel(); + } + + private markAttributeDirty(attributeName: string): void { + this.dirtyAttributes.set(attributeName, attributeName); + } + + private markCompositeDirty(composite: CompositeAttribute): void { + if (!this.dirtyComposites) { + this.dirtyComposites = new IndexedRecord(); + } + this.dirtyComposites.set(composite.name, composite); + } + + private logMissingAttribute(attributeName: string, value: unknown): void { + console.warn( + `Valdi web renderer has no applier for attribute '${attributeName}' on node ${this.id} (${this.elementClass.className}) with value ${stringifyValue(value)}`, + ); + } + + private logMissingStoredAttribute(attributeName: string): void { + console.error( + `Valdi web renderer marked attribute '${attributeName}' dirty on node ${this.id} (${this.elementClass.className}) but no stored attribute exists`, + ); + } + + private logApplyError(attributeName: string, value: unknown, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Valdi web renderer failed to apply attribute '${attributeName}' on node ${this.id} (${this.elementClass.className}) with value ${stringifyValue(value)}: ${message}`, + ); + } +} + +function stringifyValue(value: unknown): string { + if (typeof value === 'function') { + return '[function]'; + } + try { + const stringified = JSON.stringify(value); + return stringified === undefined ? String(value) : stringified; + } catch (_error) { + return String(value); + } +} + +function toDebugValue(value: unknown, depth: number, seen: Set): unknown { + if ( + value === undefined || + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value; + } + if (typeof value === 'function') { + return '[function]'; + } + if (typeof value !== 'object') { + return String(value); + } + if (value instanceof Uint8Array) { + return `[Uint8Array ${value.byteLength}]`; + } + if (seen.has(value)) { + return '[circular]'; + } + if (depth >= 4) { + return `[${value.constructor?.name || 'object'}]`; + } + + seen.add(value); + if (Array.isArray(value)) { + return value.map(item => toDebugValue(item, depth + 1, seen)); + } + + const debugValue: Record = {}; + const entries = Object.entries(value); + const entryCount = Math.min(entries.length, 80); + for (let i = 0; i < entryCount; i++) { + const [key, entryValue] = entries[i]; + debugValue[key] = toDebugValue(entryValue, depth + 1, seen); + } + if (entries.length > entryCount) { + debugValue.__truncated__ = `${entries.length - entryCount} more fields`; + } + return debugValue; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributesBinder.ts b/src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributesBinder.ts new file mode 100644 index 000000000..95500e0e0 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/attributes/AttributesBinder.ts @@ -0,0 +1,308 @@ +import { AttributeApplier, AttributeApplierContext } from '../core/ElementClass'; +import { + parseBoolean, + parseCssLength, + parseCssTrackList, + parseNumber, + parseString, + resolveValdiGradientAngles, + StyleStringName, +} from './AttributeApplierHelpers'; + +export const MIN_VISIBLE_CHANGE_ALPHA = 0.0039; +export const MIN_VISIBLE_CHANGE_COLOR = 0.0039; +export const MIN_VISIBLE_CHANGE_PIXEL = 0.00016; + +type AttributeApply = ( + element: TElement, + value: TValue, + context: AttributeApplierContext, + attributeName: string, +) => void; + +type AttributeReset = ( + element: TElement, + context: AttributeApplierContext, + attributeName: string, +) => void; + +const SUPPORTS_COLOR_MIX = + typeof CSS === 'undefined' || + (typeof CSS.supports === 'function' && CSS.supports('color', 'color-mix(in srgb, black 50%, white 50%)')); + +function resolveColorAnimationEndpoint( + value: unknown, + resetColor: string, + attributeName: string, + context: AttributeApplierContext, +): string { + return value === undefined || value === null ? resetColor : context.resolveColor(parseString(value, attributeName)); +} + +export class AttributesBinder { + readonly attributeAppliers: Record> = {}; + + bindAttribute(name: string, applier: AttributeApplier): void { + this.attributeAppliers[name] = applier; + } + + bindNoOpAttribute(name: string, layoutDependent?: boolean): void { + this.bindAttribute(name, { + layoutDependent, + apply() {}, + reset() {}, + }); + } + + bindDirectAttribute(name: string, domAttributeName: string, layoutDependent?: boolean): void { + this.bindAttribute(name, { + layoutDependent, + apply(element, value) { + element.setAttribute(domAttributeName, String(value)); + }, + reset(element) { + element.removeAttribute(domAttributeName); + }, + }); + } + + bindAriaBooleanAttribute(name: string, ariaAttributeName: string, layoutDependent?: boolean): void { + this.bindAttribute(name, { + layoutDependent, + apply(element, value) { + element.setAttribute(ariaAttributeName, String(!!value)); + }, + reset(element) { + element.removeAttribute(ariaAttributeName); + }, + }); + } + + bindNumberAttribute( + name: string, + apply: AttributeApply, + reset: AttributeReset, + layoutDependent?: boolean, + ): void { + this.bindAttribute(name, { + layoutDependent, + apply(element, value, attributeName, context) { + apply(element, parseNumber(value, attributeName), context, attributeName); + }, + reset(element, attributeName, context) { + reset(element, context, attributeName); + }, + }); + } + + bindAnimatableNumberAttribute( + name: string, + resetValue: number, + minimumVisibleChange: number, + apply: AttributeApply, + reset: AttributeReset, + ): void { + const endpoint = (value: unknown): number => + value === undefined || value === null ? resetValue : parseNumber(value, name); + this.bindAttribute(name, { + animationMinimumVisibleChange: minimumVisibleChange, + makeAnimationInterpolator(_element, from, to, _context) { + const start = endpoint(from); + const end = endpoint(to); + return progress => start + (end - start) * progress; + }, + apply(element, value, attributeName, context) { + apply(element, parseNumber(value, attributeName), context, attributeName); + }, + reset(element, attributeName, context) { + reset(element, context, attributeName); + }, + }); + } + + bindBooleanAttribute( + name: string, + apply: AttributeApply, + reset: AttributeReset, + layoutDependent?: boolean, + ): void { + this.bindAttribute(name, { + layoutDependent, + apply(element, value, attributeName, context) { + apply(element, parseBoolean(value, attributeName), context, attributeName); + }, + reset(element, attributeName, context) { + reset(element, context, attributeName); + }, + }); + } + + bindStringAttribute( + name: string, + apply: AttributeApply, + reset: AttributeReset, + layoutDependent?: boolean, + ): void { + this.bindAttribute(name, { + layoutDependent, + apply(element, value, attributeName, context) { + apply(element, parseString(value, attributeName), context, attributeName); + }, + reset(element, attributeName, context) { + reset(element, context, attributeName); + }, + }); + } + + bindFunctionAttribute( + name: string, + apply: AttributeApply, + reset: AttributeReset, + layoutDependent?: boolean, + ): void { + this.bindAttribute(name, { + layoutDependent, + apply(element, value, attributeName, context) { + if (typeof value !== 'function') { + throw new Error(`Expected '${attributeName}' to be a function`); + } + apply(element, value, context, attributeName); + }, + reset(element, attributeName, context) { + reset(element, context, attributeName); + }, + }); + } + + bindEnumAttribute( + name: string, + allowedValues: ReadonlyArray, + apply: AttributeApply, + reset: AttributeReset, + layoutDependent?: boolean, + ): void { + const allowed = new Set(allowedValues); + this.bindAttribute(name, { + layoutDependent, + apply(element, value, attributeName, context) { + const parsed = parseString(value, attributeName); + if (!allowed.has(parsed)) { + throw new Error(`Invalid '${attributeName}' value '${parsed}'`); + } + apply(element, parsed as TValue, context, attributeName); + }, + reset(element, attributeName, context) { + reset(element, context, attributeName); + }, + }); + } + + bindCssLengthStyleAttribute(name: string, styleName: StyleStringName, layoutDependent?: boolean): void { + this.bindAttribute(name, { + layoutDependent, + apply(element, value, attributeName) { + element.style[styleName] = parseCssLength(value, attributeName); + }, + reset(element) { + element.style[styleName] = ''; + }, + }); + } + + bindCssTrackListStyleAttribute(name: string, styleName: StyleStringName, layoutDependent?: boolean): void { + this.bindAttribute(name, { + layoutDependent, + apply(element, value, attributeName) { + element.style[styleName] = parseCssTrackList(value, attributeName); + }, + reset(element) { + element.style[styleName] = ''; + }, + }); + } + + bindStyleValueAttribute(name: string, styleName: StyleStringName, layoutDependent?: boolean): void { + this.bindAttribute(name, { + layoutDependent, + apply(element, value) { + element.style[styleName] = String(value); + }, + reset(element) { + element.style[styleName] = ''; + }, + }); + } + + bindColorAttribute( + name: string, + apply: AttributeApply, + reset: AttributeReset, + layoutDependent?: boolean, + ): void { + this.bindAttribute(name, { + colorDependent: true, + layoutDependent, + apply(element, value, attributeName, context) { + apply(element, context.resolveColor(parseString(value, attributeName)), context, attributeName); + }, + reset(element, attributeName, context) { + reset(element, context, attributeName); + }, + }); + } + + bindAnimatableColorAttribute( + name: string, + resetColor: string, + minimumVisibleChange: number, + apply: AttributeApply, + reset: AttributeReset, + ): void { + const applier: AttributeApplier = { + colorDependent: true, + apply(element, value, attributeName, context) { + apply(element, context.resolveColor(parseString(value, attributeName)), context, attributeName); + }, + reset(element, attributeName, context) { + reset(element, context, attributeName); + }, + }; + if (SUPPORTS_COLOR_MIX) { + applier.animationMinimumVisibleChange = minimumVisibleChange; + applier.makeAnimationInterpolator = (_element, from, to, context) => { + const start = resolveColorAnimationEndpoint(from, resetColor, name, context); + const end = resolveColorAnimationEndpoint(to, resetColor, name, context); + return progress => { + const clamped = Math.max(0, Math.min(1, progress)); + if (clamped <= 0) { + return start; + } + if (clamped >= 1) { + return end; + } + const endWeight = clamped * 100; + return `color-mix(in srgb, ${start} ${100 - endWeight}%, ${end} ${endWeight}%)`; + }; + }; + } + this.bindAttribute(name, applier); + } + + bindColorStyleAttribute( + name: string, + styleName: StyleStringName, + resetValue: string, + layoutDependent?: boolean, + ): void { + this.bindColorAttribute( + name, + (element, value) => { + element.style[styleName] = resolveValdiGradientAngles(value); + }, + element => { + element.style[styleName] = resetValue; + }, + layoutDependent, + ); + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/attributes/BorderRadiusAttribute.ts b/src/valdi_modules/src/valdi/web_renderer/src/attributes/BorderRadiusAttribute.ts new file mode 100644 index 000000000..37bd6ffac --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/attributes/BorderRadiusAttribute.ts @@ -0,0 +1,355 @@ +import type { AnimationInterpolator } from './AttributeAnimation'; +import { parseCssLength } from './AttributeApplierHelpers'; +import { MIN_VISIBLE_CHANGE_PIXEL } from './AttributesBinder'; +import type { AttributeApplier, AttributeApplierContext, ElementLayoutObserver } from '../core/ElementClass'; +import { getViewPresentationState, type ViewPresentationState } from '../elements/ViewElementState'; +import { readCssNumber, readWhitespaceSeparatedToken } from '../utils/cssScanner'; + +interface BorderRadiusCorner { + readonly pixels: number; + readonly percent: number; +} + +interface BorderRadiusCorners { + readonly topLeft: BorderRadiusCorner; + readonly topRight: BorderRadiusCorner; + readonly bottomRight: BorderRadiusCorner; + readonly bottomLeft: BorderRadiusCorner; +} + +interface ParsedBorderRadius { + readonly corners: BorderRadiusCorners | undefined; + readonly css: string; + readonly usesPercent: boolean; +} + +class AnimatedBorderRadius { + constructor( + readonly corners: BorderRadiusCorners, + readonly usesPercent: boolean, + ) {} +} + +const ZERO_CORNER: BorderRadiusCorner = { pixels: 0, percent: 0 }; +const ZERO_BORDER_RADIUS = new AnimatedBorderRadius( + { + topLeft: ZERO_CORNER, + topRight: ZERO_CORNER, + bottomRight: ZERO_CORNER, + bottomLeft: ZERO_CORNER, + }, + false, +); + +function formatNumber(value: number): string { + return String(Object.is(value, -0) ? 0 : value); +} + +function formatPixels(value: number): string { + return `${formatNumber(value)}px`; +} + +function formatPercent(value: number): string { + return `${formatNumber(value)}%`; +} + +function parseBorderRadiusToken( + token: string, +): { corner: BorderRadiusCorner; css: string; usesPercent: boolean } | undefined { + const number = readCssNumber(token, 0); + if (!number || !Number.isFinite(number.value)) { + return undefined; + } + const unit = token.slice(number.nextIndex); + if (unit === '%') { + return { + corner: { pixels: 0, percent: number.value }, + css: formatPercent(number.value), + usesPercent: true, + }; + } + if (unit === 'px' || unit === 'pt') { + return { + corner: { pixels: number.value, percent: 0 }, + css: formatPixels(number.value), + usesPercent: false, + }; + } + return undefined; +} + +function expandCorners(corners: BorderRadiusCorner[]): BorderRadiusCorners { + if (corners.length === 1) { + return { + topLeft: corners[0], + topRight: corners[0], + bottomRight: corners[0], + bottomLeft: corners[0], + }; + } + if (corners.length === 2) { + return { + topLeft: corners[0], + topRight: corners[1], + bottomRight: corners[0], + bottomLeft: corners[1], + }; + } + if (corners.length === 3) { + return { + topLeft: corners[0], + topRight: corners[1], + bottomRight: corners[2], + bottomLeft: corners[1], + }; + } + return { + topLeft: corners[0], + topRight: corners[1], + bottomRight: corners[2], + bottomLeft: corners[3], + }; +} + +function parseBorderRadius(value: unknown, attributeName: string): ParsedBorderRadius { + const source = parseCssLength(value, attributeName).trim(); + const corners: BorderRadiusCorner[] = []; + const cssTokens: string[] = []; + let usesPercent = false; + let index = 0; + while (index < source.length) { + const token = readWhitespaceSeparatedToken(source, index); + if (!token) { + break; + } + const parsedToken = parseBorderRadiusToken(token.token); + if (!parsedToken) { + return { corners: undefined, css: source, usesPercent: false }; + } + corners.push(parsedToken.corner); + cssTokens.push(parsedToken.css); + usesPercent = usesPercent || parsedToken.usesPercent; + index = token.nextIndex; + } + if (corners.length === 0 || corners.length > 4) { + return { corners: undefined, css: source, usesPercent: false }; + } + return { + corners: expandCorners(corners), + css: cssTokens.join(' '), + usesPercent, + }; +} + +function parseAnimationEndpoint(value: unknown): AnimatedBorderRadius | undefined { + if (value === undefined || value === null) { + return ZERO_BORDER_RADIUS; + } + if (value instanceof AnimatedBorderRadius) { + return value; + } + const parsed = parseBorderRadius(value, 'borderRadius'); + if (!parsed.corners) { + return undefined; + } + if ( + !isValidAnimationCorner(parsed.corners.topLeft) || + !isValidAnimationCorner(parsed.corners.topRight) || + !isValidAnimationCorner(parsed.corners.bottomRight) || + !isValidAnimationCorner(parsed.corners.bottomLeft) + ) { + return undefined; + } + return new AnimatedBorderRadius(parsed.corners, parsed.usesPercent); +} + +function isValidAnimationCorner(corner: BorderRadiusCorner): boolean { + return corner.pixels >= 0 && corner.percent >= 0; +} + +function interpolateCorner(from: BorderRadiusCorner, to: BorderRadiusCorner, progress: number): BorderRadiusCorner { + return { + pixels: from.pixels + (to.pixels - from.pixels) * progress, + percent: from.percent + (to.percent - from.percent) * progress, + }; +} + +function makeBorderRadiusInterpolator(from: unknown, to: unknown): AnimationInterpolator | undefined { + const start = parseAnimationEndpoint(from); + const end = parseAnimationEndpoint(to); + if (!start || !end) { + return undefined; + } + const usesPercent = start.usesPercent || end.usesPercent; + return progress => { + const clampedProgress = Math.max(0, Math.min(1, progress)); + return new AnimatedBorderRadius( + { + topLeft: interpolateCorner(start.corners.topLeft, end.corners.topLeft, clampedProgress), + topRight: interpolateCorner(start.corners.topRight, end.corners.topRight, clampedProgress), + bottomRight: interpolateCorner(start.corners.bottomRight, end.corners.bottomRight, clampedProgress), + bottomLeft: interpolateCorner(start.corners.bottomLeft, end.corners.bottomLeft, clampedProgress), + }, + usesPercent, + ); + }; +} + +function formatResolvedBorderRadius(corners: BorderRadiusCorners, sideLength: number): string { + const sizeRatio = sideLength / 100; + const topLeft = Math.max(0, corners.topLeft.pixels + corners.topLeft.percent * sizeRatio); + const topRight = Math.max(0, corners.topRight.pixels + corners.topRight.percent * sizeRatio); + const bottomRight = Math.max(0, corners.bottomRight.pixels + corners.bottomRight.percent * sizeRatio); + const bottomLeft = Math.max(0, corners.bottomLeft.pixels + corners.bottomLeft.percent * sizeRatio); + if (topLeft === topRight && topLeft === bottomRight && topLeft === bottomLeft) { + return formatPixels(topLeft); + } + if (topLeft === bottomRight && topRight === bottomLeft) { + return `${formatPixels(topLeft)} ${formatPixels(topRight)}`; + } + if (topRight === bottomLeft) { + return `${formatPixels(topLeft)} ${formatPixels(topRight)} ${formatPixels(bottomRight)}`; + } + return `${formatPixels(topLeft)} ${formatPixels(topRight)} ${formatPixels(bottomRight)} ${formatPixels(bottomLeft)}`; +} + +function hasPercentComponent(corners: BorderRadiusCorners): boolean { + return ( + corners.topLeft.percent !== 0 || + corners.topRight.percent !== 0 || + corners.bottomRight.percent !== 0 || + corners.bottomLeft.percent !== 0 + ); +} + +function applyBorderRadiusCss(element: HTMLElement, css: string, updatesClipPath: boolean): void { + element.style.borderRadius = css; + if (updatesClipPath) { + element.style.clipPath = css ? `inset(0 round ${css})` : ''; + } +} + +function applyResolvedBorderRadius( + element: HTMLElement, + viewAttributeElement: HTMLElement, + state: ViewPresentationState, + css: string, + updatesClipPath: boolean, +): void { + state.borderRadiusCss = css; + applyBorderRadiusCss(viewAttributeElement, css, updatesClipPath); + if (viewAttributeElement !== element) { + element.style.borderRadius = state.slowClipping ? css : ''; + } +} + +class BorderRadiusLayoutObserver implements ElementLayoutObserver { + private corners: BorderRadiusCorners = ZERO_BORDER_RADIUS.corners; + private css: string | undefined = ''; + private hasSize = false; + private sideLength = 0; + + constructor( + private readonly hostElement: HTMLElement, + private readonly viewAttributeElement: HTMLElement, + private readonly state: ViewPresentationState, + private readonly updatesClipPath: boolean, + ) {} + + update(corners: BorderRadiusCorners, fallbackCss: string | undefined): string | undefined { + this.corners = corners; + if (this.hasSize) { + this.css = formatResolvedBorderRadius(corners, this.sideLength); + } else if (!hasPercentComponent(corners)) { + this.css = formatResolvedBorderRadius(corners, 0); + } else { + this.css = fallbackCss; + } + return this.css; + } + + onSizeChanged(width: number, height: number): void { + this.hasSize = true; + this.sideLength = Math.min(width, height); + this.css = formatResolvedBorderRadius(this.corners, this.sideLength); + } + + onCommit(_element: HTMLElement): void { + if (this.css !== undefined) { + applyResolvedBorderRadius( + this.hostElement, + this.viewAttributeElement, + this.state, + this.css, + this.updatesClipPath, + ); + } + } +} + +function applyObservedBorderRadius( + element: HTMLElement, + corners: BorderRadiusCorners, + fallbackCss: string | undefined, + attributeName: string, + context: AttributeApplierContext, + updatesClipPath: boolean, +): void { + const viewAttributeElement = context.getViewAttributeElement(); + const state = getViewPresentationState(context); + const existingObserver = context.getLayoutObserver(attributeName); + const observer = + existingObserver instanceof BorderRadiusLayoutObserver + ? existingObserver + : new BorderRadiusLayoutObserver(element, viewAttributeElement, state, updatesClipPath); + const css = observer.update(corners, fallbackCss); + if (css !== undefined) { + applyResolvedBorderRadius(element, viewAttributeElement, state, css, updatesClipPath); + } + if (observer !== existingObserver) { + context.setLayoutObserver(attributeName, observer); + } +} + +export function createBorderRadiusAttributeApplier(updatesClipPath: boolean): AttributeApplier { + return { + animationMinimumVisibleChange: MIN_VISIBLE_CHANGE_PIXEL, + makeAnimationInterpolator(_element, from, to, _context) { + return makeBorderRadiusInterpolator(from, to); + }, + apply(element, value, attributeName, context) { + const viewAttributeElement = context.getViewAttributeElement(); + const state = getViewPresentationState(context); + if (value instanceof AnimatedBorderRadius) { + if (value.usesPercent) { + applyObservedBorderRadius(element, value.corners, undefined, attributeName, context, updatesClipPath); + } else { + context.setLayoutObserver(attributeName, undefined); + applyResolvedBorderRadius( + element, + viewAttributeElement, + state, + formatResolvedBorderRadius(value.corners, 0), + updatesClipPath, + ); + } + return; + } + + const parsed = parseBorderRadius(value, attributeName); + if (!parsed.corners || !parsed.usesPercent) { + context.setLayoutObserver(attributeName, undefined); + applyResolvedBorderRadius(element, viewAttributeElement, state, parsed.css, updatesClipPath); + return; + } + + applyObservedBorderRadius(element, parsed.corners, parsed.css, attributeName, context, updatesClipPath); + }, + reset(element, attributeName, context) { + context.setLayoutObserver(attributeName, undefined); + const viewAttributeElement = context.getViewAttributeElement(); + const state = getViewPresentationState(context); + applyResolvedBorderRadius(element, viewAttributeElement, state, '', updatesClipPath); + }, + }; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/core/ElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/core/ElementClass.ts new file mode 100644 index 000000000..d6200e9c0 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/core/ElementClass.ts @@ -0,0 +1,168 @@ +import type { ElementFrame } from 'valdi_tsx/src/Geometry'; +import type { AnimationInterpolator } from '../attributes/AttributeAnimation'; + +export type MakeAnimationInterpolator = ( + element: TElement, + from: unknown, + to: unknown, + context: AttributeApplierContext, +) => AnimationInterpolator | undefined; + +export interface ElementLayoutObserver { + onMeasure?(element: HTMLElement): void; + onSizeChanged?(width: number, height: number): void; + onCommit?(element: HTMLElement): void; +} + +export interface AttributeUpdatedExternallyDelegate { + onAttributeUpdatedExternally(elementId: number, attributeName: string, attributeValue: unknown): void; +} + +export interface AttributeApplierContext { + readonly id: number; + getState(key: string): T | undefined; + setState(key: string, value: unknown): void; + getViewAttributeElement(): HTMLElement; + resolveColor(value: string): string; + setColorPalette(colorPaletteName: string | undefined): void; + addCleanup(callback: () => void): void; + enqueuePostLayoutCallback(callback: () => void): void; + getLayoutObserver(attributeName: string): ElementLayoutObserver | undefined; + setLayoutObserver(attributeName: string, observer: ElementLayoutObserver | undefined): void; + requestLayoutPass(): void; + getChildHtmlElement(index: number): HTMLElement | undefined; + setOnLayoutCallback(callback: ((frame: ElementFrame) => void) | undefined): void; + onAttributeUpdatedExternally(attributeName: string, attributeValue: unknown): void; + emitCurrentViewCreate(callback: Function): void; + emitCurrentViewChange(): void; + isAnimationEnabled(): boolean; + setAnimationsEnabled(enabled: boolean): void; +} + +export interface LayoutAnimationTranslationCorrection { + readonly x: number; + readonly y: number; +} + +export interface LayoutAnimationSizeApplier { + apply(scaleX: number, scaleY: number): LayoutAnimationTranslationCorrection; + reset(): void; +} + +export interface AttributeApplier { + apply(element: TElement, value: unknown, attributeName: string, context: AttributeApplierContext): void; + reset(element: TElement, attributeName: string, context: AttributeApplierContext): void; + makeAnimationInterpolator?: MakeAnimationInterpolator; + animationMinimumVisibleChange?: number; + colorDependent?: boolean; + layoutDependent?: boolean; +} + +export interface CompositeAttributePart { + name: string; + optional: boolean; + colorDependent?: boolean; + layoutDependent?: boolean; + parse?: (element: TElement, value: unknown, attributeName: string, context: AttributeApplierContext) => unknown; +} + +export interface CompositeAttribute { + name: string; + parts: ReadonlyArray>; + apply( + element: TElement, + values: ReadonlyArray, + attributeName: string, + context: AttributeApplierContext, + ): void; + reset(element: TElement, attributeName: string, context: AttributeApplierContext): void; + makeAnimationInterpolator?: MakeAnimationInterpolator; + animationMinimumVisibleChange?: number; + colorDependent?: boolean; + layoutDependent?: boolean; +} + +export interface UnknownAttributeApplier { + apply(element: TElement, value: unknown, attributeName: string, context: AttributeApplierContext): void; + reset(element: TElement, attributeName: string, context: AttributeApplierContext): void; + layoutDependent?: boolean; +} + +export interface ElementAttribute { + applier: AttributeApplier | undefined; + composite: CompositeAttribute | undefined; + isCompositePart: boolean; +} + +export abstract class ElementClass { + readonly elementAttributes: Readonly | undefined>>; + + protected constructor( + readonly className: string, + readonly attributeAppliers: Readonly>>, + readonly compositeAttributes: Readonly>> = {}, + readonly unknownAttributeApplier?: UnknownAttributeApplier, + ) { + const elementAttributes: Record | undefined> = {}; + Object.keys(attributeAppliers).forEach(name => { + const applier = attributeAppliers[name]; + if (applier.layoutDependent && applier.makeAnimationInterpolator) { + throw new Error(`Layout-dependent attribute '${name}' cannot be animated`); + } + elementAttributes[name] = { applier, composite: undefined, isCompositePart: false }; + }); + Object.keys(compositeAttributes).forEach(name => { + const composite = compositeAttributes[name]; + if (!composite.colorDependent && composite.parts.some(part => part.colorDependent)) { + (composite as { colorDependent?: boolean }).colorDependent = true; + } + if (!composite.layoutDependent && composite.parts.some(part => part.layoutDependent)) { + (composite as { layoutDependent?: boolean }).layoutDependent = true; + } + if (composite.layoutDependent && composite.makeAnimationInterpolator) { + throw new Error(`Layout-dependent composite attribute '${name}' cannot be animated`); + } + elementAttributes[composite.name] = { applier: undefined, composite, isCompositePart: false }; + composite.parts.forEach(part => { + const elementAttribute = elementAttributes[part.name]; + if (elementAttribute) { + elementAttribute.composite = composite; + elementAttribute.isCompositePart = true; + } else { + elementAttributes[part.name] = { applier: undefined, composite, isCompositePart: true }; + } + }); + }); + this.elementAttributes = elementAttributes; + } + + private templateElement?: TElement; + + createElement(_id: number, _viewClass: string): TElement { + let templateElement = this.templateElement; + if (!templateElement) { + templateElement = this.onCreateElement(); + this.templateElement = templateElement; + } + return templateElement.cloneNode(true) as TElement; + } + + protected abstract onCreateElement(): TElement; + + getViewAttributeElement(element: TElement, _context: AttributeApplierContext): HTMLElement { + return element; + } + + makeLayoutAnimationSizeApplier( + _element: TElement, + _context: AttributeApplierContext, + _finalWidth: number, + _finalHeight: number, + ): LayoutAnimationSizeApplier | undefined { + return undefined; + } + + destroy(_element: TElement): void {} +} + +export type AnyElementClass = ElementClass; diff --git a/src/valdi_modules/src/valdi/web_renderer/src/core/Palette.ts b/src/valdi_modules/src/valdi/web_renderer/src/core/Palette.ts new file mode 100644 index 000000000..f8906cd54 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/core/Palette.ts @@ -0,0 +1,54 @@ +export interface ColorPalette { + [key: string]: string; +} + +export class ColorPaletteManager { + private readonly palettesByName = new Map(); + private readonly changeListeners = new Set<() => void>(); + private activeColorPaletteName = 'default'; + + configureColorPalette(name: string, palette: ColorPalette): void { + this.palettesByName.set(name, palette); + this.notifyChangeListeners(); + } + + getColorPalette(name?: string): ColorPalette | undefined { + return this.palettesByName.get(name ?? this.activeColorPaletteName); + } + + getActiveColorPaletteName(): string { + return this.activeColorPaletteName; + } + + setActiveColorPalette(name: string): void { + if (this.activeColorPaletteName === name) { + return; + } + this.activeColorPaletteName = name; + this.notifyChangeListeners(); + } + + resolveColor(paletteName: string, value: string): string { + return this.getColorPalette(paletteName)?.[value] ?? value; + } + + addChangeListener(listener: () => void): () => void { + this.changeListeners.add(listener); + return () => { + this.changeListeners.delete(listener); + }; + } + + private notifyChangeListeners(): void { + const listeners = Array.from(this.changeListeners); + for (const listener of listeners) { + try { + listener(); + } catch (error) { + console.error('Valdi web renderer palette listener failed', error); + } + } + } +} + +export const COLOR_PALETTE_MANAGER = new ColorPaletteManager(); diff --git a/src/valdi_modules/src/valdi/web_renderer/src/core/ViewNode.ts b/src/valdi_modules/src/valdi/web_renderer/src/core/ViewNode.ts new file mode 100644 index 000000000..6d70b2ffb --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/core/ViewNode.ts @@ -0,0 +1,914 @@ +import type { AnimationAppearanceAttributes } from 'valdi_core/src/AnimationOptions'; +import type { ElementFrame } from 'valdi_tsx/src/Geometry'; +import { KeyAnimation } from '../animations/KeyAnimation'; +import type { LayoutAnimation, LayoutFrame } from '../animations/LayoutAnimation'; +import { AttributesApplier, AttributeSetResult } from '../attributes/AttributesApplier'; +import { + getAppearanceAttributeOwner, + getNativeOverrideAttributeOwner, + type AttributeOwner, +} from '../attributes/AttributeOwner'; +import { MIN_VISIBLE_CHANGE_ALPHA } from '../attributes/AttributesBinder'; +import type { Animator } from '../animations/Animator'; +import type { + AnyElementClass, + AttributeApplierContext, + AttributeUpdatedExternallyDelegate, + ElementLayoutObserver, + LayoutAnimationSizeApplier, +} from './ElementClass'; +import { ColorPaletteManager } from './Palette'; +import type { ViewNodeTree } from './ViewNodeTree'; + +export interface ViewNodeDebugSnapshot { + id: string; + tag: string; + element: { + id: number; + attributes: Record; + dom: { + attributes: Record; + tagName: string; + textContent?: string; + }; + }; + bounds: { + x: number; + y: number; + width: number; + height: number; + }; + state?: Record; + children: ViewNodeDebugSnapshot[]; +} + +const CHILD_COLOR_PALETTE_DIRTY = 1; +const CHILD_COLOR_PALETTE_FORCE_UPDATE = 2; +const NEEDS_ATTRIBUTE_UPDATE = 1; +const NEEDS_DESCENDANT_UPDATE = 2; +const NEEDS_COLOR_PALETTE_UPDATE = 4; +const NEEDS_FORCED_COLOR_PALETTE_UPDATE = 8; +const VIEW_CREATE_LIFECYCLE_CALLBACK_PRIORITY = 0; +const VIEW_CHANGE_LIFECYCLE_CALLBACK_PRIORITY = 1; +const VIEW_DESTROY_LIFECYCLE_CALLBACK_PRIORITY = 2; +const ENTER_APPEARANCE_CLEANUP_KEY = 'enterAppearanceCleanup'; +const PENDING_REMOVAL_KEY = 'pendingRemoval'; +const TRANSFORM_COMPOSITE_ATTRIBUTE = 'transformComposite'; + +interface AppearanceAnimationState { + enterAnimator?: Animator; + pendingRemovalAnimator?: Animator; + animation?: KeyAnimation; + pendingRemoval?: boolean; + pendingRemovalChildCount?: number; +} + +export class ViewNode implements AttributeApplierContext { + readonly htmlElement: HTMLElement; + private readonly children: ViewNode[] = []; + private readonly attributesApplier: AttributesApplier; + private parent: ViewNode | null = null; + + private state?: Record; + private attributeUpdatedExternallyDelegate?: AttributeUpdatedExternallyDelegate; + private cleanupCallbacks?: Array<() => void>; + private colorPaletteNameOverride: string | undefined; + private resolvedColorPaletteName!: string; + private needsUpdateFlag = 0; + private attached = false; + private viewCreateEmitted = false; + private lastViewChangeCallback?: Function; + private lastViewChangeAttached?: boolean; + private destroyed = false; + private animationsEnabled = true; + private appearanceAnimationState?: AppearanceAnimationState; + private layoutAnimation?: LayoutAnimation; + + constructor( + readonly id: number, + readonly viewClass: string, + private readonly elementClass: AnyElementClass, + private readonly tree: ViewNodeTree, + private readonly colorPaletteManager: ColorPaletteManager, + attributeUpdatedExternallyDelegate?: AttributeUpdatedExternallyDelegate, + ) { + this.attributeUpdatedExternallyDelegate = attributeUpdatedExternallyDelegate; + this.appearanceAnimationState = undefined; + this.layoutAnimation = undefined; + this.htmlElement = elementClass.createElement(id, viewClass); + this.attributesApplier = new AttributesApplier(id, elementClass); + } + + makeRoot(root: HTMLElement | ShadowRoot): void { + this.parent?.removeChild(this); + this.parent = null; + root.replaceChildren(this.htmlElement); + this.propagateCurrentDirtyStateToAncestors(); + this.markColorPaletteDirty(false); + this.setAttached(true); + } + + move(parent: ViewNode, index: number): void { + this.parent?.removeChild(this); + this.parent = parent; + const physicalIndex = parent.getPhysicalChildIndex(index); + const referenceElement = parent.children[physicalIndex]?.htmlElement ?? null; + parent.children.splice(physicalIndex, 0, this); + if (this.isPendingRemoval()) { + parent.incrementPendingRemovalChildCount(); + } + parent.htmlElement.insertBefore(this.htmlElement, referenceElement); + this.propagateCurrentDirtyStateToAncestors(); + this.markColorPaletteDirty(false); + this.setAttached(true); + } + + destroy(): void { + if (this.destroyed) { + return; + } + this.layoutAnimation?.retireNode(this); + this.destroyed = true; + this.appearanceAnimationState?.animation?.cancel(); + this.attributesApplier.cancelAnimations(); + this.setAttached(false); + this.enqueueViewDestroyIfNeeded(); + const cleanupCallbacks = this.cleanupCallbacks; + if (cleanupCallbacks) { + for (let i = 0; i < cleanupCallbacks.length; i++) { + cleanupCallbacks[i](); + } + cleanupCallbacks.length = 0; + } + this.parent?.removeChild(this); + this.parent = null; + this.elementClass.destroy(this.htmlElement); + this.htmlElement.remove(); + } + + setAttribute(attributeName: string, value: unknown): boolean { + const actualAttributeName = attributeName.startsWith('$') ? attributeName.substring(1) : attributeName; + if (actualAttributeName === 'observeVisibility') { + this.tree.setElementVisibilityObserved(this.id, this.htmlElement, !!value); + return false; + } + if (actualAttributeName === 'onViewChange') { + this.lastViewChangeCallback = undefined; + this.lastViewChangeAttached = undefined; + } + const result = this.attributesApplier.setAttribute(attributeName, value); + if (result !== AttributeSetResult.Unchanged) { + this.markNeedsUpdate(); + } + return result === AttributeSetResult.ChangedAndInvalidatesLayout; + } + + setAttributeForOwner(attributeName: string, owner: AttributeOwner, value: unknown): boolean { + const result = this.attributesApplier.setAttributeForOwner(attributeName, owner, value); + if (result !== AttributeSetResult.Unchanged) { + this.markNeedsUpdate(); + } + return result === AttributeSetResult.ChangedAndInvalidatesLayout; + } + + requestAnimatedAppearance(animator: Animator): void { + const attributes = animator.options.appearanceBehavior?.enterAttributes; + if (!attributes || !hasAppearanceAttributes(attributes)) { + return; + } + this.getOrCreateAppearanceAnimationState().enterAnimator = animator; + this.markNeedsUpdate(); + } + + requestAnimatedDisappearance(animator: Animator): boolean { + const state = this.appearanceAnimationState; + if (state?.pendingRemoval) { + return true; + } + if (state?.enterAnimator === animator) { + state.enterAnimator = undefined; + this.releaseAppearanceAnimationStateIfEmpty(); + return false; + } + const attributes = animator.options.appearanceBehavior?.exitAttributes; + if (!this.parent || !attributes || !hasAppearanceAttributes(attributes)) { + return false; + } + const pendingState = this.getOrCreateAppearanceAnimationState(); + pendingState.pendingRemoval = true; + pendingState.pendingRemovalAnimator = animator; + this.completeLayoutAnimationsInSubtree(); + this.parent.incrementPendingRemovalChildCount(); + this.markNeedsUpdate(); + return true; + } + + isPendingRemoval(): boolean { + return this.appearanceAnimationState?.pendingRemoval === true; + } + + resolveColor(value: string): string { + return this.colorPaletteManager.resolveColor(this.resolvedColorPaletteName, value); + } + + setColorPalette(colorPaletteName: string | undefined): void { + const nextColorPaletteName = colorPaletteName && colorPaletteName.length > 0 ? colorPaletteName : undefined; + if (this.colorPaletteNameOverride === nextColorPaletteName) { + return; + } + this.colorPaletteNameOverride = nextColorPaletteName; + this.markColorPaletteDirty(false); + } + + getState(key: string): T | undefined { + return this.state?.[key] as T | undefined; + } + + setState(key: string, value: unknown): void { + if (value === undefined && !this.state) { + return; + } + this.state ??= {}; + this.state[key] = value; + } + + getViewAttributeElement(): HTMLElement { + return this.elementClass.getViewAttributeElement(this.htmlElement, this); + } + + markColorPaletteDirty(forceColorPaletteUpdate: boolean): void { + this.markUpdateFlag( + forceColorPaletteUpdate + ? NEEDS_COLOR_PALETTE_UPDATE | NEEDS_FORCED_COLOR_PALETTE_UPDATE + : NEEDS_COLOR_PALETTE_UPDATE, + ); + } + + update( + parentPaletteName: string, + inheritedColorPaletteDirty: boolean, + inheritedForceColorPaletteUpdate: boolean, + animator: Animator | undefined, + ): void { + const inheritedUpdateFlag = + (inheritedColorPaletteDirty ? NEEDS_COLOR_PALETTE_UPDATE : 0) | + (inheritedForceColorPaletteUpdate ? NEEDS_FORCED_COLOR_PALETTE_UPDATE : 0); + if (!(this.needsUpdateFlag | inheritedUpdateFlag)) { + return; + } + + let enterAppearanceAttributes: AnimationAppearanceAttributes | undefined; + let pendingRemovalAnimator: Animator | undefined; + let initialAnimator = animator; + const appearanceAnimationState = this.appearanceAnimationState; + if (appearanceAnimationState) { + enterAppearanceAttributes = this.takeEnterAppearanceAttributes(appearanceAnimationState, animator); + if (enterAppearanceAttributes) { + this.setAppearanceAttributes(enterAppearanceAttributes, false); + } + pendingRemovalAnimator = appearanceAnimationState.pendingRemoval + ? appearanceAnimationState.pendingRemovalAnimator + : undefined; + if (enterAppearanceAttributes || pendingRemovalAnimator) { + initialAnimator = undefined; + } + } + + let childColorPaletteUpdateFlags = 0; + if ( + (this.needsUpdateFlag | inheritedUpdateFlag) & + (NEEDS_COLOR_PALETTE_UPDATE | NEEDS_FORCED_COLOR_PALETTE_UPDATE) + ) { + childColorPaletteUpdateFlags = this.resolveColorPaletteForUpdatePass( + parentPaletteName, + inheritedForceColorPaletteUpdate, + ); + } + childColorPaletteUpdateFlags |= this.flushAttributesForUpdate(parentPaletteName, initialAnimator); + + const resolvedAnimator = animator && this.isAnimationEnabled() ? animator : undefined; + let suppressDescendantAnimations = false; + if (enterAppearanceAttributes) { + suppressDescendantAnimations = true; + childColorPaletteUpdateFlags |= this.startAnimatedAppearance( + parentPaletteName, + enterAppearanceAttributes, + resolvedAnimator, + ); + } + + if (pendingRemovalAnimator) { + suppressDescendantAnimations = true; + if (!resolvedAnimator || resolvedAnimator !== pendingRemovalAnimator) { + this.tree.finishPendingRemoval(this); + return; + } + childColorPaletteUpdateFlags |= this.startAnimatedDisappearance(parentPaletteName, resolvedAnimator); + } + + if (this.needsUpdateFlag & NEEDS_DESCENDANT_UPDATE || childColorPaletteUpdateFlags !== 0) { + this.needsUpdateFlag &= ~NEEDS_DESCENDANT_UPDATE; + const childColorPaletteDirty = (childColorPaletteUpdateFlags & CHILD_COLOR_PALETTE_DIRTY) !== 0; + const childForceColorPaletteUpdate = (childColorPaletteUpdateFlags & CHILD_COLOR_PALETTE_FORCE_UPDATE) !== 0; + const childAnimator = suppressDescendantAnimations ? undefined : resolvedAnimator; + for (let index = 0; index < this.children.length; ) { + const child = this.children[index]; + child.update( + this.resolvedColorPaletteName, + childColorPaletteDirty, + childForceColorPaletteUpdate, + childAnimator, + ); + if (this.children[index] === child) { + index++; + } + } + this.needsUpdateFlag &= ~NEEDS_DESCENDANT_UPDATE; + } + } + + getChildrenSnapshot(): ViewNode[] { + return Array.from(this.children); + } + + getParent(): ViewNode | null { + return this.parent; + } + + isDestroyed(): boolean { + return this.destroyed; + } + + makeLayoutAnimationSizeApplier(finalWidth: number, finalHeight: number): LayoutAnimationSizeApplier | undefined { + return this.elementClass.makeLayoutAnimationSizeApplier(this.htmlElement, this, finalWidth, finalHeight); + } + + setLayoutAnimation(animation: LayoutAnimation): void { + this.layoutAnimation = animation; + } + + clearLayoutAnimation(animation: LayoutAnimation): void { + if (this.layoutAnimation === animation) { + this.layoutAnimation = undefined; + } + } + + getLayoutAnimationFrame(current: boolean): LayoutFrame | undefined { + return this.layoutAnimation?.getFrame(this, current); + } + + getDebugSnapshot(): ViewNodeDebugSnapshot { + const rect = this.htmlElement.getBoundingClientRect(); + const domAttributes: Record = {}; + for (let i = 0; i < this.htmlElement.attributes.length; i++) { + const attribute = this.htmlElement.attributes.item(i); + if (attribute) { + domAttributes[attribute.name] = attribute.value; + } + } + + const snapshot: ViewNodeDebugSnapshot = { + id: String(this.id), + tag: this.viewClass, + element: { + id: this.id, + attributes: this.attributesApplier.getDebugAttributes(), + dom: { + attributes: domAttributes, + tagName: this.htmlElement.tagName.toLowerCase(), + }, + }, + bounds: { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }, + children: this.children.filter(child => !child.isPendingRemoval()).map(child => child.getDebugSnapshot()), + }; + + const textContent = this.htmlElement.childElementCount === 0 ? this.htmlElement.textContent?.trim() : undefined; + if (textContent) { + snapshot.element.dom.textContent = truncateDebugText(textContent); + } + + const state = this.getDebugDomState(); + if (Object.keys(state).length > 0) { + snapshot.state = state; + } + return snapshot; + } + + private resolveColorPaletteForUpdatePass( + parentPaletteName: string, + inheritedForceColorPaletteUpdate: boolean, + ): number { + const forceColorPaletteUpdate = + !!(this.needsUpdateFlag & NEEDS_FORCED_COLOR_PALETTE_UPDATE) || inheritedForceColorPaletteUpdate; + const changed = this.resolveColorPalette(parentPaletteName, forceColorPaletteUpdate); + this.needsUpdateFlag &= ~(NEEDS_COLOR_PALETTE_UPDATE | NEEDS_FORCED_COLOR_PALETTE_UPDATE); + let flags = 0; + if (changed) { + flags |= CHILD_COLOR_PALETTE_DIRTY; + } + if (forceColorPaletteUpdate) { + flags |= CHILD_COLOR_PALETTE_FORCE_UPDATE; + } + return flags; + } + + private resolveColorPalette(parentPaletteName: string, forceColorPaletteUpdate: boolean): boolean { + const nextPaletteName = this.colorPaletteNameOverride ?? parentPaletteName; + const changed = nextPaletteName !== this.resolvedColorPaletteName; + this.resolvedColorPaletteName = nextPaletteName; + if ((changed || forceColorPaletteUpdate) && this.attributesApplier.markColorDependentAttributesDirty()) { + this.needsUpdateFlag |= NEEDS_ATTRIBUTE_UPDATE; + } + return changed; + } + + private flushAttributesForUpdate(parentPaletteName: string, animator: Animator | undefined): number { + let childColorPaletteUpdateFlags = 0; + while (this.needsUpdateFlag & NEEDS_ATTRIBUTE_UPDATE) { + this.needsUpdateFlag &= ~NEEDS_ATTRIBUTE_UPDATE; + this.attributesApplier.flush(this.htmlElement, this, animator); + if (this.needsUpdateFlag & (NEEDS_COLOR_PALETTE_UPDATE | NEEDS_FORCED_COLOR_PALETTE_UPDATE)) { + childColorPaletteUpdateFlags |= this.resolveColorPaletteForUpdatePass(parentPaletteName, false); + } + } + return childColorPaletteUpdateFlags; + } + + private takeEnterAppearanceAttributes( + state: AppearanceAnimationState, + animator: Animator | undefined, + ): AnimationAppearanceAttributes | undefined { + const enterAnimator = state.enterAnimator; + if (!enterAnimator) { + return undefined; + } + if (!animator) { + state.enterAnimator = undefined; + this.releaseAppearanceAnimationStateIfEmpty(); + return undefined; + } + if (enterAnimator !== animator) { + return undefined; + } + state.enterAnimator = undefined; + this.releaseAppearanceAnimationStateIfEmpty(); + return animator.options.appearanceBehavior!.enterAttributes!; + } + + private freezeForPendingRemoval(): void { + const owner = getNativeOverrideAttributeOwner(); + this.setAttributeForOwner('position', owner, 'absolute'); + this.setAttributeForOwner('left', owner, this.htmlElement.offsetLeft); + this.setAttributeForOwner('top', owner, this.htmlElement.offsetTop); + this.setAttributeForOwner('width', owner, this.htmlElement.offsetWidth); + this.setAttributeForOwner('height', owner, this.htmlElement.offsetHeight); + this.setAttributeForOwner('marginLeft', owner, 0); + this.setAttributeForOwner('marginTop', owner, 0); + } + + private startAnimatedAppearance( + parentPaletteName: string, + attributes: AnimationAppearanceAttributes, + animator: Animator | undefined, + ): number { + if (animator) { + this.removeEnterAppearanceAttributes(attributes); + } else { + this.removeAppearanceAttributes(attributes); + } + const childColorPaletteUpdateFlags = this.flushAttributesForUpdate(parentPaletteName, animator); + if (animator && hasAppearanceTransform(attributes)) { + const animation = new EnterAppearanceCleanupAnimation(this); + this.setAppearanceAnimation(animation); + animator.addAnimation(this, ENTER_APPEARANCE_CLEANUP_KEY, animation); + } + return childColorPaletteUpdateFlags; + } + + private startAnimatedDisappearance(parentPaletteName: string, animator: Animator): number { + this.appearanceAnimationState!.pendingRemovalAnimator = undefined; + this.appearanceAnimationState!.animation?.cancel(); + this.freezeForPendingRemoval(); + let childColorPaletteUpdateFlags = this.flushAttributesForUpdate(parentPaletteName, undefined); + this.setAppearanceAttributes(animator.options.appearanceBehavior!.exitAttributes!, false); + childColorPaletteUpdateFlags |= this.flushAttributesForUpdate(parentPaletteName, animator); + const animation = new PendingRemovalAnimation(this); + this.setAppearanceAnimation(animation); + animator.addAnimation(this, PENDING_REMOVAL_KEY, animation); + return childColorPaletteUpdateFlags; + } + + private setAppearanceAttributes(attributes: AnimationAppearanceAttributes, identityTransform: boolean): void { + const owner = getAppearanceAttributeOwner(); + if (attributes.opacity !== undefined) { + this.setAttributeForOwner('opacity', owner, attributes.opacity); + } + if (hasAppearanceTransform(attributes)) { + this.setAttributeForOwner( + TRANSFORM_COMPOSITE_ATTRIBUTE, + owner, + makeAppearanceTransformValues(attributes, identityTransform), + ); + } + } + + private removeEnterAppearanceAttributes(attributes: AnimationAppearanceAttributes): void { + const owner = getAppearanceAttributeOwner(); + if (attributes.opacity !== undefined) { + this.setAttributeForOwner('opacity', owner, undefined); + } + if (hasAppearanceTransform(attributes)) { + this.setAttributeForOwner(TRANSFORM_COMPOSITE_ATTRIBUTE, owner, makeAppearanceTransformValues(attributes, true)); + } + } + + private removeAppearanceAttributes(attributes: AnimationAppearanceAttributes): void { + const owner = getAppearanceAttributeOwner(); + if (attributes.opacity !== undefined) { + this.setAttributeForOwner('opacity', owner, undefined); + } + if (hasAppearanceTransform(attributes)) { + this.setAttributeForOwner(TRANSFORM_COMPOSITE_ATTRIBUTE, owner, undefined); + } + } + + private setAppearanceAnimation(animation: KeyAnimation): void { + const state = this.getOrCreateAppearanceAnimationState(); + const previousAnimation = state.animation; + state.animation = animation; + if (previousAnimation && previousAnimation !== animation) { + previousAnimation.cancel(); + } + } + + finishEnterAppearanceAnimation(animation: EnterAppearanceCleanupAnimation): void { + if (this.appearanceAnimationState?.animation !== animation) { + return; + } + this.setAttributeForOwner(TRANSFORM_COMPOSITE_ATTRIBUTE, getAppearanceAttributeOwner(), undefined); + } + + finishPendingRemoval(animation: PendingRemovalAnimation): void { + if (this.appearanceAnimationState?.animation === animation) { + this.tree.finishPendingRemoval(this); + } + } + + unregisterAppearanceAnimation(animation: KeyAnimation): void { + const state = this.appearanceAnimationState; + if (state?.animation !== animation) { + return; + } + state.animation = undefined; + this.releaseAppearanceAnimationStateIfEmpty(); + } + + private getOrCreateAppearanceAnimationState(): AppearanceAnimationState { + return (this.appearanceAnimationState ??= {}); + } + + private releaseAppearanceAnimationStateIfEmpty(): void { + const state = this.appearanceAnimationState; + if ( + state && + !state.enterAnimator && + !state.pendingRemovalAnimator && + !state.animation && + !state.pendingRemoval && + !state.pendingRemovalChildCount + ) { + this.appearanceAnimationState = undefined; + } + } + + private getPhysicalChildIndex(index: number): number { + if (!this.appearanceAnimationState?.pendingRemovalChildCount) { + return index; + } + let liveIndex = 0; + for (let physicalIndex = 0; physicalIndex < this.children.length; physicalIndex++) { + if (this.children[physicalIndex].isPendingRemoval()) { + continue; + } + if (liveIndex === index) { + return physicalIndex; + } + liveIndex++; + } + return this.children.length; + } + + private incrementPendingRemovalChildCount(): void { + const state = this.getOrCreateAppearanceAnimationState(); + state.pendingRemovalChildCount = (state.pendingRemovalChildCount ?? 0) + 1; + } + + private decrementPendingRemovalChildCount(): void { + const state = this.appearanceAnimationState!; + state.pendingRemovalChildCount = state.pendingRemovalChildCount! - 1; + this.releaseAppearanceAnimationStateIfEmpty(); + } + + addCleanup(callback: () => void): void { + this.cleanupCallbacks ??= []; + this.cleanupCallbacks.push(callback); + } + + enqueuePostLayoutCallback(callback: () => void): void { + this.tree.enqueuePostLayoutCallback(() => { + if (!this.destroyed && this.attached) { + callback(); + } + }); + } + + getLayoutObserver(attributeName: string): ElementLayoutObserver | undefined { + return this.tree.getElementLayoutObserver(this.id, attributeName); + } + + setLayoutObserver(attributeName: string, observer: ElementLayoutObserver | undefined): void { + this.tree.setElementLayoutObserver( + this.id, + this.viewClass, + this.htmlElement, + this.attached, + attributeName, + observer, + ); + } + + requestLayoutPass(): void { + this.tree.requestLayoutPass(); + } + + getChildHtmlElement(index: number): HTMLElement | undefined { + return this.children[this.getPhysicalChildIndex(index)]?.htmlElement; + } + + setOnLayoutCallback(callback: ((frame: ElementFrame) => void) | undefined): void { + this.tree.setElementOnLayoutCallback(this.id, this.viewClass, this.htmlElement, this.attached, callback); + } + + onAttributeUpdatedExternally(attributeName: string, attributeValue: unknown): void { + this.attributeUpdatedExternallyDelegate?.onAttributeUpdatedExternally(this.id, attributeName, attributeValue); + } + + emitCurrentViewCreate(callback: Function): void { + if (this.attached) { + this.enqueueViewCreate(callback); + } + } + + emitCurrentViewChange(): void { + this.enqueueViewChangeIfNeeded(this.attached); + } + + isAnimationEnabled(): boolean { + return this.animationsEnabled; + } + + setAnimationsEnabled(enabled: boolean): void { + if (this.animationsEnabled === enabled) { + return; + } + this.animationsEnabled = enabled; + if (!enabled) { + this.completeAnimationsInSubtree(); + } + } + + private getAttribute(attributeName: string): unknown { + return this.attributesApplier.getAttribute(attributeName); + } + + private getDebugDomState(): Record { + const state: Record = {}; + if (this.htmlElement.scrollLeft !== 0 || this.htmlElement.scrollTop !== 0) { + state.scrollLeft = this.htmlElement.scrollLeft; + state.scrollTop = this.htmlElement.scrollTop; + } + if ('value' in this.htmlElement && typeof this.htmlElement.value === 'string') { + state.value = this.htmlElement.value; + } + if ('checked' in this.htmlElement && typeof this.htmlElement.checked === 'boolean') { + state.checked = this.htmlElement.checked; + } + if ('disabled' in this.htmlElement && typeof this.htmlElement.disabled === 'boolean') { + state.disabled = this.htmlElement.disabled; + } + return state; + } + + private getAttributeFunction(attributeName: string): Function | undefined { + const value = this.getAttribute(attributeName); + return typeof value === 'function' ? value : undefined; + } + + private removeChild(child: ViewNode): void { + const index = this.children.indexOf(child); + if (index >= 0) { + this.children.splice(index, 1); + if (child.isPendingRemoval()) { + this.decrementPendingRemovalChildCount(); + } + } + } + + private completeAnimationsInSubtree(): void { + this.attributesApplier.completeAnimations(); + this.appearanceAnimationState?.animation?.complete(); + this.layoutAnimation?.completeNode(this); + if (this.destroyed) { + return; + } + for (let index = 0; index < this.children.length; ) { + const child = this.children[index]; + child.completeAnimationsInSubtree(); + if (this.children[index] === child) { + index++; + } + } + } + + private completeLayoutAnimationsInSubtree(): void { + this.layoutAnimation?.completeNode(this); + for (const child of this.children) { + child.completeLayoutAnimationsInSubtree(); + } + } + + private propagateCurrentDirtyStateToAncestors(): void { + if (this.needsUpdateFlag) { + this.notifyNeedsUpdate(); + } + } + + private markNeedsUpdate(): void { + this.markUpdateFlag(NEEDS_ATTRIBUTE_UPDATE); + } + + private markDescendantNeedsUpdate(): void { + this.markUpdateFlag(NEEDS_DESCENDANT_UPDATE); + } + + private markUpdateFlag(updateFlag: number): void { + const previousUpdateFlag = this.needsUpdateFlag; + const nextUpdateFlag = previousUpdateFlag | updateFlag; + if (previousUpdateFlag === nextUpdateFlag) { + return; + } + this.needsUpdateFlag = nextUpdateFlag; + if (!previousUpdateFlag) { + this.notifyNeedsUpdate(); + } + } + + private notifyNeedsUpdate(): void { + if (this.parent) { + this.parent.markDescendantNeedsUpdate(); + } else { + this.tree.onNodeNeedsUpdate(this); + } + } + + private setAttached(attached: boolean): void { + if (this.attached === attached) { + return; + } + this.attached = attached; + this.tree.setElementLayoutAttached(this.id, attached); + if (attached) { + this.queueViewCreateIfNeeded(); + } + this.enqueueViewChangeIfNeeded(attached); + } + + private queueViewCreateIfNeeded(): void { + const onViewCreate = this.getAttributeFunction('onViewCreate'); + if (onViewCreate) { + this.enqueueViewCreate(onViewCreate); + } + } + + private enqueueViewCreate(callback: Function): void { + if (this.viewCreateEmitted) { + return; + } + this.viewCreateEmitted = true; + this.tree.enqueueLifecycleCallback(() => { + this.invokeLifecycleCallback('onViewCreate', callback); + }, VIEW_CREATE_LIFECYCLE_CALLBACK_PRIORITY); + } + + private enqueueViewChangeIfNeeded(attached: boolean): void { + const onViewChange = this.getAttributeFunction('onViewChange'); + if (onViewChange) { + if (this.lastViewChangeCallback === onViewChange && this.lastViewChangeAttached === attached) { + return; + } + this.lastViewChangeCallback = onViewChange; + this.lastViewChangeAttached = attached; + this.tree.enqueueLifecycleCallback(() => { + this.invokeLifecycleCallback('onViewChange', onViewChange, { type: attached ? 'Attached' : 'Detached' }); + }, VIEW_CHANGE_LIFECYCLE_CALLBACK_PRIORITY); + } + } + + private enqueueViewDestroyIfNeeded(): void { + const onViewDestroy = this.getAttributeFunction('onViewDestroy'); + if (onViewDestroy) { + this.tree.enqueueLifecycleCallback(() => { + this.invokeLifecycleCallback('onViewDestroy', onViewDestroy); + }, VIEW_DESTROY_LIFECYCLE_CALLBACK_PRIORITY); + } + } + + private invokeLifecycleCallback(attributeName: string, callback: Function, ...args: unknown[]): void { + try { + callback(...args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Valdi web renderer failed to call '${attributeName}' on node ${this.id} (${this.elementClass.className}): ${message}`, + ); + } + } +} + +function truncateDebugText(value: string): string { + const maxLength = 240; + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}...` : value; +} + +class EnterAppearanceCleanupAnimation extends KeyAnimation { + constructor(private readonly node: ViewNode) { + super(MIN_VISIBLE_CHANGE_ALPHA); + } + + override applyProgress(_progress: number): boolean { + return true; + } + + override applyFinalValue(): void { + this.node.finishEnterAppearanceAnimation(this); + } + + protected override didFinish(): void { + this.node.unregisterAppearanceAnimation(this); + } +} + +class PendingRemovalAnimation extends KeyAnimation { + constructor(private readonly node: ViewNode) { + super(MIN_VISIBLE_CHANGE_ALPHA); + } + + override applyProgress(_progress: number): boolean { + return true; + } + + override applyFinalValue(): void { + this.node.finishPendingRemoval(this); + } + + protected override didFinish(): void { + this.node.unregisterAppearanceAnimation(this); + } +} + +function hasAppearanceAttributes(attributes: AnimationAppearanceAttributes): boolean { + return attributes.opacity !== undefined || hasAppearanceTransform(attributes); +} + +function hasAppearanceTransform(attributes: AnimationAppearanceAttributes): boolean { + return ( + attributes.translationX !== undefined || + attributes.translationY !== undefined || + attributes.scaleX !== undefined || + attributes.scaleY !== undefined + ); +} + +function makeAppearanceTransformValues( + attributes: AnimationAppearanceAttributes, + identity: boolean, +): ReadonlyArray { + const originX = (attributes.originX ?? 0.5) * 100; + const originY = (attributes.originY ?? 0.5) * 100; + const translationX = (identity ? 0 : (attributes.translationX ?? 0)) * 100; + const translationY = (identity ? 0 : (attributes.translationY ?? 0)) * 100; + return [ + `${originX}% ${originY}%`, + undefined, + `${translationX}%`, + `${translationY}%`, + identity ? 1 : (attributes.scaleX ?? 1), + identity ? 1 : (attributes.scaleY ?? 1), + 0, + ]; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/core/ViewNodeTree.ts b/src/valdi_modules/src/valdi/web_renderer/src/core/ViewNodeTree.ts new file mode 100644 index 000000000..12039cd31 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/core/ViewNodeTree.ts @@ -0,0 +1,494 @@ +import type { VisibilityObserver } from 'valdi_core/src/IRendererDelegate'; +import type { AnimationOptions } from 'valdi_core/src/AnimationOptions'; +import { Style } from 'valdi_core/src/Style'; +import type { ElementFrame } from 'valdi_tsx/src/Geometry'; +import { LayoutObserverController, measureElementFrame } from '../LayoutObserverController'; +import { VisibilityObserverController } from '../VisibilityObserverController'; +import { AnimationController } from '../animations/AnimationController'; +import { Animator, type AnimatorDelegate } from '../animations/Animator'; +import { + captureLayoutSnapshot, + LayoutAnimation, + LayoutAnimationPass, + type LayoutSnapshot, +} from '../animations/LayoutAnimation'; +import { getElementClassForViewClass } from '../elements/ElementClassRegistry'; +import type { AttributeUpdatedExternallyDelegate, ElementLayoutObserver } from './ElementClass'; +import { ColorPaletteManager, COLOR_PALETTE_MANAGER } from './Palette'; +import { ViewNode, type ViewNodeDebugSnapshot } from './ViewNode'; + +export interface ViewNodeTreeDebugSnapshot { + tree: ViewNodeDebugSnapshot | null; + viewport: { + width: number; + height: number; + }; +} + +type PendingLifecycleCallback = { + callback: () => void; + priority: number; + sequence: number; +}; + +type RenderCompleteScheduler = (callback: () => void) => void; + +const LAYOUT_COMMIT_PREPARATION_KEY = 'layout'; + +export class ViewNodeTree implements AnimatorDelegate { + private readonly nodesById = new Map(); + private readonly nodeIdByHtmlElement = new WeakMap(); + private readonly colorPaletteManager: ColorPaletteManager; + private readonly visibilityObserverController: VisibilityObserverController; + private readonly layoutObserverController: LayoutObserverController; + private rootNode: ViewNode | null = null; + private pendingLifecycleCallbacks?: PendingLifecycleCallback[]; + private nextLifecycleCallbackSequence = 0; + private renderBatchDepth = 0; + private flushScheduled = false; + private layoutRefreshNeeded = false; + private isFlushing = false; + private flushRequestedDuringFlush = false; + private destroyed = false; + private readonly animatorStack: Animator[] = []; + private animationController?: AnimationController; + private activeLayoutAnimation?: LayoutAnimation; + private activeLayoutAnimationValidationNeeded = false; + private readonly removePaletteChangeListener: () => void; + + constructor(colorPaletteManager?: ColorPaletteManager) { + this.colorPaletteManager = colorPaletteManager ?? COLOR_PALETTE_MANAGER; + this.visibilityObserverController = new VisibilityObserverController(); + this.layoutObserverController = new LayoutObserverController(() => this.onLayoutPassCommitted()); + this.removePaletteChangeListener = this.colorPaletteManager.addChangeListener(() => { + this.reapplyColorPalettesOnAllNodes(); + }); + } + + setRenderCompleteScheduler(scheduler: RenderCompleteScheduler): void { + this.layoutObserverController.setRenderCompleteScheduler(scheduler); + } + + setPostLayoutScheduler(scheduler: ((callback: () => void) => void) | undefined): void { + this.layoutObserverController.setPostLayoutScheduler(scheduler); + } + + createElement( + id: number, + viewClass: string, + attributeUpdatedExternallyDelegate?: AttributeUpdatedExternallyDelegate, + ): ViewNode { + const elementClass = getElementClassForViewClass(viewClass); + if (!elementClass) { + throw new Error(`Unknown viewClass: ${viewClass}`); + } + const node = new ViewNode( + id, + viewClass, + elementClass, + this, + this.colorPaletteManager, + attributeUpdatedExternallyDelegate, + ); + this.nodesById.set(id, node); + this.nodeIdByHtmlElement.set(node.htmlElement, id); + const animator = this.getCurrentAnimator(); + if (animator) { + node.requestAnimatedAppearance(animator); + } + return node; + } + + destroyElement(id: number): void { + const node = this.nodesById.get(id); + if (!node) { + return; + } + const animator = this.getCurrentAnimator(); + if (animator && node.requestAnimatedDisappearance(animator)) { + animator.willApplyLayoutMutation(); + this.markLayoutRefreshNeeded(); + return; + } + animator?.willApplyLayoutMutation(); + this.markLayoutRefreshNeeded(); + this.destroyNodeSubtree(node); + if (!animator) { + this.requestActiveLayoutAnimationValidation(); + } + } + + makeElementRoot(id: number, root: HTMLElement | ShadowRoot): void { + const node = this.getNodeOrThrow(id); + this.rootNode = node; + node.makeRoot(root); + this.visibilityObserverController.setRoot(root); + this.markLayoutRefreshNeeded(); + } + + moveElement(id: number, parentId: number, parentIndex: number): void { + const node = this.nodesById.get(id); + const parent = this.nodesById.get(parentId); + if (!node || !parent) { + throw new Error(`moveElement: element or parent is missing, id: ${id}, parentId: ${parentId}`); + } + const animator = this.getCurrentAnimator(); + animator?.willApplyLayoutMutation(); + node.move(parent, parentIndex); + this.markLayoutRefreshNeeded(); + if (!animator) { + this.requestActiveLayoutAnimationValidation(); + } + } + + setAttributeOnElement(id: number, attributeName: string, attributeValue: unknown): void { + if (this.getNodeOrThrow(id).setAttribute(attributeName, attributeValue)) { + if (!this.getCurrentAnimator()) { + this.requestActiveLayoutAnimationValidation(); + } + this.markLayoutRefreshNeeded(); + } + } + + setStyleAttributeOnElement(id: number, _attributeName: string, style: Style | undefined): void { + this.setAttributeOnElement(id, 'style', style); + } + + getNode(id: number): ViewNode | undefined { + return this.nodesById.get(id); + } + + getNodeIdForHtmlElement(element: Element): number | undefined { + return this.nodeIdByHtmlElement.get(element); + } + + getDebugSnapshot(): ViewNodeTreeDebugSnapshot { + this.flush(); + return { + tree: this.rootNode?.getDebugSnapshot() ?? null, + viewport: { + width: typeof window === 'undefined' ? 0 : window.innerWidth, + height: typeof window === 'undefined' ? 0 : window.innerHeight, + }, + }; + } + + registerVisibilityObserver(observer: VisibilityObserver): void { + this.visibilityObserverController.registerObserver(observer); + } + + scheduleVisibilityRefresh(force: boolean): void { + this.visibilityObserverController.scheduleRefresh(force); + } + + drainScheduledVisibilityRefresh(force: boolean): void { + this.visibilityObserverController.drainScheduledRefresh(force); + } + + drainScheduledLayoutObserverRefresh(): void { + this.layoutObserverController.drainScheduledRefresh(); + } + + getElementFrame(id: number): ElementFrame | undefined { + const node = this.nodesById.get(id); + if (!node) { + return undefined; + } + return measureElementFrame(node.htmlElement); + } + + setElementOnLayoutCallback( + id: number, + viewClass: string, + element: HTMLElement, + attached: boolean, + callback: ((frame: ElementFrame) => void) | undefined, + ): void { + this.layoutObserverController.setOnLayoutCallback(id, viewClass, element, attached, callback); + } + + setElementLayoutObserver( + id: number, + viewClass: string, + element: HTMLElement, + attached: boolean, + attributeName: string, + observer: ElementLayoutObserver | undefined, + ): void { + this.layoutObserverController.setLayoutObserver(id, viewClass, element, attached, attributeName, observer); + } + + getElementLayoutObserver(id: number, attributeName: string): ElementLayoutObserver | undefined { + return this.layoutObserverController.getLayoutObserver(id, attributeName); + } + + setElementLayoutAttached(id: number, attached: boolean): void { + this.layoutObserverController.setElementAttached(id, attached); + } + + requestLayoutPass(): void { + this.layoutObserverController.scheduleRefresh(); + } + + beginRender(): void { + this.renderBatchDepth++; + } + + endRender(): void { + if (this.renderBatchDepth > 0) { + this.renderBatchDepth--; + } + if (this.renderBatchDepth === 0) { + this.flush(); + } + } + + beginAnimation(options: AnimationOptions, token: number): void { + this.flush(); + this.animatorStack.push(new Animator(options, token, this)); + } + + endAnimation(): void { + const animator = this.getCurrentAnimator(); + if (!animator) { + return; + } + this.flush(); + animator.prepareForCommit(); + this.animatorStack.pop(); + if (animator.empty) { + animator.complete(false); + return; + } + this.getOrCreateAnimationController().commit(animator); + } + + cancelAnimation(token: number): void { + this.animationController?.cancelTransaction(token); + } + + animatorWillApplyLayoutMutation(animator: Animator): void { + const snapshot = this.captureLayoutAnimationSnapshot(); + const useCurrentFrame = animator.options.beginFromCurrentState === true; + for (const entry of snapshot.entries) { + const animationFrame = entry.node.getLayoutAnimationFrame(useCurrentFrame); + if (animationFrame) { + entry.frame = animationFrame; + } + } + this.activeLayoutAnimation?.cancel(); + animator.addCommitPreparation(LAYOUT_COMMIT_PREPARATION_KEY, new LayoutAnimationPass(this, snapshot)); + } + + captureLayoutAnimationSnapshot(): LayoutSnapshot { + return captureLayoutSnapshot(this.rootNode); + } + + setActiveLayoutAnimation(animation: LayoutAnimation): void { + if (this.activeLayoutAnimation !== animation) { + this.activeLayoutAnimation?.cancel(); + } + this.activeLayoutAnimation = animation; + } + + layoutAnimationDidFinish(animation: LayoutAnimation): void { + if (this.activeLayoutAnimation === animation) { + this.activeLayoutAnimation = undefined; + } + } + + onNodeNeedsUpdate(node: ViewNode): void { + if (this.destroyed || node !== this.rootNode) { + return; + } + if (this.isFlushing) { + this.flushRequestedDuringFlush = true; + return; + } + this.scheduleDirtyFlush(); + } + + enqueueLifecycleCallback(callback: () => void, priority: number): void { + (this.pendingLifecycleCallbacks ??= []).push({ + callback, + priority, + sequence: this.nextLifecycleCallbackSequence++, + }); + if (!this.isFlushing) { + this.scheduleDirtyFlush(); + } + } + + enqueuePostLayoutCallback(callback: () => void): void { + this.layoutObserverController.enqueuePostLayoutCallback(callback); + } + + flush(): void { + if (this.destroyed) { + return; + } + this.flushScheduled = false; + if (this.isFlushing) { + this.flushRequestedDuringFlush = true; + return; + } + this.isFlushing = true; + this.layoutObserverController.beginUpdate(); + try { + do { + this.flushRequestedDuringFlush = false; + this.rootNode?.update( + this.colorPaletteManager.getActiveColorPaletteName(), + false, + false, + this.getCurrentAnimator(), + ); + this.flushLifecycleCallbacks(); + } while (this.flushRequestedDuringFlush); + this.validateActiveLayoutAnimationIfNeeded(); + } finally { + this.isFlushing = false; + this.schedulePendingLayoutRefresh(); + this.layoutObserverController.endUpdate(); + } + } + + reapplyColorPalettesOnAllNodes(): void { + this.rootNode?.markColorPaletteDirty(true); + this.flush(); + } + + setElementVisibilityObserved(id: number, element: HTMLElement, observed: boolean): void { + if (observed) { + this.visibilityObserverController.observeElement(id, element); + } else { + this.visibilityObserverController.unobserveElement(id); + } + } + + destroy(): void { + if (this.destroyed) { + return; + } + this.removePaletteChangeListener(); + this.layoutRefreshNeeded = false; + this.activeLayoutAnimationValidationNeeded = false; + this.activeLayoutAnimation?.destroy(); + this.activeLayoutAnimation = undefined; + this.animationController?.destroy(); + this.animationController = undefined; + for (const animator of this.animatorStack) { + animator.complete(true); + } + this.animatorStack.length = 0; + for (const node of Array.from(this.nodesById.values())) { + node.destroy(); + } + this.layoutObserverController.destroy(); + this.visibilityObserverController.destroy(); + this.flushLifecycleCallbacks(); + this.destroyed = true; + this.nodesById.clear(); + this.rootNode = null; + } + + finishPendingRemoval(node: ViewNode): void { + if (this.nodesById.get(node.id) !== node || !node.isPendingRemoval()) { + return; + } + this.markLayoutRefreshNeeded(); + this.destroyNodeSubtree(node); + } + + private scheduleDirtyFlush(): void { + if (this.renderBatchDepth > 0 || this.flushScheduled) { + return; + } + this.flushScheduled = true; + Promise.resolve().then(() => { + this.flushScheduled = false; + this.flush(); + }); + } + + private getOrCreateAnimationController(): AnimationController { + this.animationController ??= new AnimationController(); + return this.animationController; + } + + private getCurrentAnimator(): Animator | undefined { + return this.animatorStack[this.animatorStack.length - 1]; + } + + private requestActiveLayoutAnimationValidation(): void { + if (!this.activeLayoutAnimation) { + return; + } + this.activeLayoutAnimationValidationNeeded = true; + if (!this.isFlushing) { + this.scheduleDirtyFlush(); + } + } + + private validateActiveLayoutAnimationIfNeeded(): void { + if (!this.activeLayoutAnimationValidationNeeded) { + return; + } + this.activeLayoutAnimationValidationNeeded = false; + this.activeLayoutAnimation?.cancelAnimationsWithChangedFrames(); + } + + private markLayoutRefreshNeeded(): void { + this.layoutRefreshNeeded = true; + } + + private schedulePendingLayoutRefresh(): void { + if (!this.layoutRefreshNeeded) { + return; + } + this.layoutRefreshNeeded = false; + this.layoutObserverController.scheduleRefresh(); + } + + private onLayoutPassCommitted(): void { + this.visibilityObserverController.scheduleRefresh(true); + } + + private flushLifecycleCallbacks(): void { + const callbacks = this.pendingLifecycleCallbacks; + if (!callbacks) { + return; + } + callbacks.sort((a, b) => a.priority - b.priority || a.sequence - b.sequence); + for (let i = 0; i < callbacks.length; i++) { + callbacks[i].callback(); + } + callbacks.length = 0; + } + + private getNodeOrThrow(id: number): ViewNode { + const node = this.nodesById.get(id); + if (!node) { + throw new Error(`ViewNode is missing, id: ${id}`); + } + return node; + } + + private destroyNodeSubtree(node: ViewNode): void { + for (const child of node.getChildrenSnapshot()) { + this.destroyNodeSubtree(child); + } + this.destroySingleNode(node); + } + + private destroySingleNode(node: ViewNode): void { + if (this.rootNode === node) { + this.rootNode = null; + } + this.nodesById.delete(node.id); + this.nodeIdByHtmlElement.delete(node.htmlElement); + this.layoutObserverController.destroyElement(node.id); + this.visibilityObserverController.destroyElement(node.id); + node.destroy(); + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/debug/WebDebuggerBridge.ts b/src/valdi_modules/src/valdi/web_renderer/src/debug/WebDebuggerBridge.ts new file mode 100644 index 000000000..f379acd86 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/debug/WebDebuggerBridge.ts @@ -0,0 +1,133 @@ +import type { ViewNodeTree } from '../core/ViewNodeTree'; + +const WEB_DEBUGGER_CHANNEL = 'valdi-web-debugger'; +const WEB_DEBUGGER_QUERY_KEY = 'valdiDebugger'; +const WEB_DEBUGGER_QUERY_VALUE = '1'; + +interface WebDebuggerMessage { + channel?: string; + type?: string; +} + +export class WebDebuggerBridge { + private mutationObserver?: MutationObserver; + private snapshotScheduled = false; + private destroyed = false; + private readonly enabled: boolean; + + constructor( + private readonly root: HTMLElement | ShadowRoot, + private readonly viewNodeTree: ViewNodeTree, + ) { + this.enabled = shouldEnableWebDebuggerBridge(); + if (!this.enabled) { + return; + } + + window.addEventListener('message', this.handleMessage); + window.addEventListener('resize', this.handleLayoutChange); + window.addEventListener('scroll', this.handleLayoutChange, true); + window.addEventListener('input', this.handleLayoutChange, true); + window.addEventListener('change', this.handleLayoutChange, true); + window.addEventListener('pointerdown', this.handlePointerDown, true); + if (typeof MutationObserver !== 'undefined') { + this.mutationObserver = new MutationObserver(this.handleLayoutChange); + this.mutationObserver.observe(this.root, { + attributes: true, + childList: true, + subtree: true, + }); + } + this.scheduleSnapshot(); + } + + destroy(): void { + if (!this.enabled || this.destroyed) { + return; + } + this.destroyed = true; + window.removeEventListener('message', this.handleMessage); + window.removeEventListener('resize', this.handleLayoutChange); + window.removeEventListener('scroll', this.handleLayoutChange, true); + window.removeEventListener('input', this.handleLayoutChange, true); + window.removeEventListener('change', this.handleLayoutChange, true); + window.removeEventListener('pointerdown', this.handlePointerDown, true); + this.mutationObserver?.disconnect(); + this.mutationObserver = undefined; + } + + private readonly handleMessage = (event: MessageEvent): void => { + if (this.destroyed || event.source !== window.parent) { + return; + } + const message = event.data; + if (message?.channel !== WEB_DEBUGGER_CHANNEL || message.type !== 'request-snapshot') { + return; + } + this.scheduleSnapshot(); + }; + + private readonly handleLayoutChange = (): void => { + this.scheduleSnapshot(); + }; + + private readonly handlePointerDown = (event: PointerEvent): void => { + let element = event.target instanceof Element ? event.target : null; + while (element) { + const nodeId = this.viewNodeTree.getNodeIdForHtmlElement(element); + if (nodeId !== undefined) { + this.postMessage({ + type: 'selection', + nodeId: String(nodeId), + }); + return; + } + element = element.parentElement; + } + }; + + private scheduleSnapshot(): void { + if (this.destroyed || this.snapshotScheduled) { + return; + } + this.snapshotScheduled = true; + requestAnimationFrame(() => { + this.snapshotScheduled = false; + this.postSnapshot(); + }); + } + + private postSnapshot(): void { + if (this.destroyed || window.parent === window) { + return; + } + this.postMessage({ + type: 'snapshot', + source: { + title: document.title, + url: window.location.href, + }, + snapshot: this.viewNodeTree.getDebugSnapshot(), + }); + } + + private postMessage(payload: Record): void { + if (this.destroyed || window.parent === window) { + return; + } + window.parent.postMessage( + { + channel: WEB_DEBUGGER_CHANNEL, + ...payload, + }, + '*', + ); + } +} + +function shouldEnableWebDebuggerBridge(): boolean { + if (typeof window === 'undefined' || window.parent === window) { + return false; + } + return new URLSearchParams(window.location.search).get(WEB_DEBUGGER_QUERY_KEY) === WEB_DEBUGGER_QUERY_VALUE; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/BlurElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/BlurElementClass.ts new file mode 100644 index 000000000..dd1a5ed1d --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/BlurElementClass.ts @@ -0,0 +1,85 @@ +import { AttributeApplier, ElementClass } from '../core/ElementClass'; +import { AttributesBinder } from '../attributes/AttributesBinder'; +import { createBorderRadiusAttributeApplier } from '../attributes/BorderRadiusAttribute'; +import { assignStyles, createBaseElement } from './ElementClassSupport'; +import { ViewElementClass } from './ViewElementClass'; + +interface BlurMaterial { + blur: number; + saturate: number; + backgroundColor: string; +} + +const MAX_MATERIAL_SATURATION = 110; + +const DEFAULT_MATERIAL: BlurMaterial = { + blur: 20, + saturate: 180, + backgroundColor: 'rgba(255, 255, 255, 0.55)', +}; + +const MATERIALS: Record = { + extraLight: { blur: 18, saturate: 170, backgroundColor: 'rgba(255, 255, 255, 0.72)' }, + light: DEFAULT_MATERIAL, + regular: { blur: 22, saturate: 180, backgroundColor: 'rgba(246, 246, 246, 0.36)' }, + prominent: { blur: 28, saturate: 190, backgroundColor: 'rgba(255, 255, 255, 0.58)' }, + dark: { blur: 22, saturate: 150, backgroundColor: 'rgba(28, 28, 30, 0.62)' }, + systemUltraThinMaterial: { blur: 14, saturate: 180, backgroundColor: 'rgba(246, 246, 246, 0.22)' }, + systemThinMaterial: { blur: 18, saturate: 180, backgroundColor: 'rgba(246, 246, 246, 0.30)' }, + systemMaterial: { blur: 22, saturate: 180, backgroundColor: 'rgba(246, 246, 246, 0.38)' }, + systemThickMaterial: { blur: 28, saturate: 185, backgroundColor: 'rgba(246, 246, 246, 0.50)' }, + systemChromeMaterial: { blur: 24, saturate: 190, backgroundColor: 'rgba(246, 246, 246, 0.62)' }, + systemUltraThinMaterialLight: { blur: 14, saturate: 180, backgroundColor: 'rgba(255, 255, 255, 0.32)' }, + systemThinMaterialLight: { blur: 18, saturate: 180, backgroundColor: 'rgba(255, 255, 255, 0.44)' }, + systemMaterialLight: { blur: 22, saturate: 180, backgroundColor: 'rgba(255, 255, 255, 0.54)' }, + systemThickMaterialLight: { blur: 28, saturate: 185, backgroundColor: 'rgba(255, 255, 255, 0.66)' }, + systemChromeMaterialLight: { blur: 24, saturate: 190, backgroundColor: 'rgba(255, 255, 255, 0.78)' }, + systemUltraThinMaterialDark: { blur: 14, saturate: 150, backgroundColor: 'rgba(28, 28, 30, 0.32)' }, + systemThinMaterialDark: { blur: 18, saturate: 150, backgroundColor: 'rgba(28, 28, 30, 0.44)' }, + systemMaterialDark: { blur: 22, saturate: 150, backgroundColor: 'rgba(28, 28, 30, 0.56)' }, + systemThickMaterialDark: { blur: 28, saturate: 150, backgroundColor: 'rgba(28, 28, 30, 0.68)' }, + systemChromeMaterialDark: { blur: 24, saturate: 150, backgroundColor: 'rgba(28, 28, 30, 0.78)' }, +}; + +function applyBlurMaterial(element: HTMLElement, material: BlurMaterial): void { + const filter = `blur(${material.blur}px) saturate(${Math.min(material.saturate, MAX_MATERIAL_SATURATION)}%)`; + element.style.backdropFilter = filter; + element.style.setProperty('-webkit-backdrop-filter', filter); + element.style.backgroundColor = material.backgroundColor; +} + +function blurStyleAttributeApplier(): AttributeApplier { + return { + apply(element, value, attributeName) { + if (typeof value !== 'string') { + throw new Error(`Expected '${attributeName}' to be a string`); + } + applyBlurMaterial(element, MATERIALS[value] ?? DEFAULT_MATERIAL); + }, + reset(element) { + applyBlurMaterial(element, DEFAULT_MATERIAL); + }, + }; +} + +export class BlurElementClass extends ElementClass { + constructor(viewElementClass: ViewElementClass) { + const binder = new AttributesBinder(); + binder.bindAttribute('borderRadius', createBorderRadiusAttributeApplier(true)); + binder.bindAttribute('blurStyle', blurStyleAttributeApplier()); + super( + 'blur', + { ...viewElementClass.attributeAppliers, ...binder.attributeAppliers }, + viewElementClass.compositeAttributes, + ); + } + + protected onCreateElement(): HTMLElement { + const element = createBaseElement('div'); + assignStyles(element, { + overflow: 'hidden', + }); + applyBlurMaterial(element, DEFAULT_MATERIAL); + return element; + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/CanvasImageRenderer.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/CanvasImageRenderer.ts new file mode 100644 index 000000000..ff6bf4aa4 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/CanvasImageRenderer.ts @@ -0,0 +1,203 @@ +import { ParsedCssColor } from '../utils/cssColor'; +import { + ImageFilterOperation, + applyColorMatrixToImageData, + applyTintToImageData, +} from '../utils/imageFilterOperations'; + +export const WEB_IMAGE_NATURAL_SCALE = 3; + +export type ImageObjectFit = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'; + +export type CanvasImageRenderOptions = { + contentRotation: number; + contentScaleX: number; + contentScaleY: number; + devicePixelRatio: number; + displayHeight: number; + displayWidth: number; + filterOperations: ImageFilterOperation[]; + flip: boolean; + img: HTMLImageElement; + isLoaded: boolean; + loadHeight: number | undefined; + loadWidth: number | undefined; + logicalHeightOverride: number | undefined; + logicalWidthOverride: number | undefined; + objectFit: ImageObjectFit; + tint: ParsedCssColor | undefined; +}; + +export function getDecodedImageSize( + img: HTMLImageElement, + logicalWidthOverride: number | undefined, + logicalHeightOverride: number | undefined, +): { width: number; height: number } { + if (logicalWidthOverride !== undefined && logicalHeightOverride !== undefined) { + return { + width: logicalWidthOverride * WEB_IMAGE_NATURAL_SCALE, + height: logicalHeightOverride * WEB_IMAGE_NATURAL_SCALE, + }; + } + return { + width: img.naturalWidth, + height: img.naturalHeight, + }; +} + +export function getImageLogicalSize( + img: HTMLImageElement, + logicalWidthOverride: number | undefined, + logicalHeightOverride: number | undefined, +): { width: number; height: number } { + return { + width: logicalWidthOverride ?? img.naturalWidth / WEB_IMAGE_NATURAL_SCALE, + height: logicalHeightOverride ?? img.naturalHeight / WEB_IMAGE_NATURAL_SCALE, + }; +} + +export function getImageDisplaySize( + width: number, + height: number, + fallbackWidth: number, + fallbackHeight: number, +): { + width: number; + height: number; + devicePixelRatio: number; +} { + const devicePixelRatio = typeof window !== 'undefined' && window.devicePixelRatio ? window.devicePixelRatio : 1; + return { + width: width > 0 ? width : fallbackWidth, + height: height > 0 ? height : fallbackHeight, + devicePixelRatio, + }; +} + +export function calculateObjectFitDrawSize( + objectFit: ImageObjectFit, + displayWidth: number, + displayHeight: number, + contentWidth: number, + contentHeight: number, +): { width: number; height: number } { + if (objectFit === 'fill') { + return { width: displayWidth, height: displayHeight }; + } + + let scale = 1; + if (objectFit === 'contain') { + scale = Math.min(displayWidth / contentWidth, displayHeight / contentHeight); + } else if (objectFit === 'cover') { + scale = Math.max(displayWidth / contentWidth, displayHeight / contentHeight); + } else if (objectFit === 'scale-down') { + scale = Math.min(1, Math.min(displayWidth / contentWidth, displayHeight / contentHeight)); + } + return { width: contentWidth * scale, height: contentHeight * scale }; +} + +export function isQuarterTurnRotation(contentRotation: number): boolean { + return Math.abs((Math.abs(contentRotation) % Math.PI) - Math.PI / 2) < 0.01; +} + +export function isRtlForImage(element: HTMLElement): boolean { + if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') { + return window.getComputedStyle(element).direction === 'rtl'; + } + return typeof document !== 'undefined' && document.dir === 'rtl'; +} + +function clearCanvas(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void { + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); +} + +export class CanvasImageRenderer { + render(canvas: HTMLCanvasElement, options: CanvasImageRenderOptions): void { + const ctx = canvas.getContext('2d'); + if (ctx === null) { + throw new Error('Cannot get canvas context'); + } + + const { naturalWidth, naturalHeight } = options.img; + if (!options.isLoaded || naturalWidth === 0 || naturalHeight === 0) { + clearCanvas(canvas, ctx); + return; + } + + const logicalSize = getImageLogicalSize(options.img, options.logicalWidthOverride, options.logicalHeightOverride); + const logicalWidth = logicalSize.width; + const logicalHeight = logicalSize.height; + const isRotated90Or270 = isQuarterTurnRotation(options.contentRotation); + + const baseImageWidth = logicalWidth; + const baseImageHeight = logicalHeight; + const effectiveImageWidth = isRotated90Or270 ? baseImageHeight : baseImageWidth; + const effectiveImageHeight = isRotated90Or270 ? baseImageWidth : baseImageHeight; + const displayWidth = options.displayWidth; + const displayHeight = options.displayHeight; + const devicePixelRatio = options.devicePixelRatio; + canvas.width = Math.max(1, Math.round(displayWidth * devicePixelRatio)); + canvas.height = Math.max(1, Math.round(displayHeight * devicePixelRatio)); + ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + ctx.clearRect(0, 0, displayWidth, displayHeight); + + const flip = options.flip; + if (flip) { + ctx.save(); + ctx.scale(-1, 1); + ctx.translate(-displayWidth, 0); + } + + const drawContext = ctx as CanvasRenderingContext2D & { filter?: string }; + const blurFilters = options.filterOperations + .filter((operation): operation is Extract => operation.type === 'blur') + .map(operation => `blur(${operation.radius}px)`); + if (blurFilters.length > 0 && drawContext.filter !== undefined) { + drawContext.filter = blurFilters.join(' '); + } + + const contentDrawWidth = + options.objectFit === 'none' ? (options.loadWidth ?? effectiveImageWidth) : effectiveImageWidth; + const contentDrawHeight = + options.objectFit === 'none' ? (options.loadHeight ?? effectiveImageHeight) : effectiveImageHeight; + const { width: drawWidth, height: drawHeight } = calculateObjectFitDrawSize( + options.objectFit, + displayWidth, + displayHeight, + contentDrawWidth, + contentDrawHeight, + ); + + ctx.save(); + ctx.translate(displayWidth / 2, displayHeight / 2); + ctx.rotate(options.contentRotation); + ctx.scale(options.contentScaleX, options.contentScaleY); + const finalDrawWidth = isRotated90Or270 ? drawHeight : drawWidth; + const finalDrawHeight = isRotated90Or270 ? drawWidth : drawHeight; + ctx.drawImage(options.img, -finalDrawWidth / 2, -finalDrawHeight / 2, finalDrawWidth, finalDrawHeight); + ctx.restore(); + if (blurFilters.length > 0 && drawContext.filter !== undefined) { + drawContext.filter = 'none'; + } + + const colorMatrixOperations = options.filterOperations.filter( + (operation): operation is Extract => + operation.type === 'colorMatrix', + ); + if (options.tint || colorMatrixOperations.length > 0) { + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + for (const operation of colorMatrixOperations) { + applyColorMatrixToImageData(imageData, operation.matrix); + } + if (options.tint) { + applyTintToImageData(imageData, options.tint); + } + ctx.putImageData(imageData, 0, 0); + } + + if (flip) { + ctx.restore(); + } + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/CustomViewElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/CustomViewElementClass.ts new file mode 100644 index 000000000..523470d82 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/CustomViewElementClass.ts @@ -0,0 +1,131 @@ +import { AttributesBinder } from '../attributes/AttributesBinder'; +import { AttributeApplier, AttributeApplierContext, ElementClass, UnknownAttributeApplier } from '../core/ElementClass'; +import { getWebViewClassFactory, WebViewClassAttributeHandler } from '../WebViewClassRegistry'; +import { createBaseElement } from './ElementClassSupport'; +import { ViewElementClass } from './ViewElementClass'; + +interface CustomViewState { + webClassApplied: boolean; + attributeHandler?: WebViewClassAttributeHandler; + pendingAttributes: Array<[string, unknown]>; +} + +const CUSTOM_VIEW_STATE = '__customViewElementClassState'; +const LAYOUT_DEPENDENT = true; + +function getCustomViewState(context: AttributeApplierContext): CustomViewState { + const existing = context.getState(CUSTOM_VIEW_STATE); + if (existing) { + return existing; + } + const state: CustomViewState = { + webClassApplied: false, + pendingAttributes: [], + }; + context.setState(CUSTOM_VIEW_STATE, state); + return state; +} + +function appendPlaceholder(element: HTMLElement, message: string): void { + element.style.position = 'relative'; + const label = document.createElement('span'); + label.textContent = message; + Object.assign(label.style, { + alignItems: 'center', + color: 'inherit', + display: 'flex', + fontSize: '14px', + inset: '0', + justifyContent: 'center', + pointerEvents: 'none', + position: 'absolute', + }); + element.appendChild(label); +} + +function forwardCustomViewAttribute(context: AttributeApplierContext, attributeName: string, value: unknown): void { + const state = getCustomViewState(context); + if (state.attributeHandler) { + state.attributeHandler.changeAttribute(attributeName, value); + } else if (!state.webClassApplied) { + state.pendingAttributes.push([attributeName, value]); + } +} + +const customViewUnknownAttributeApplier: UnknownAttributeApplier = { + layoutDependent: true, + apply(_element, value, attributeName, context) { + forwardCustomViewAttribute(context, attributeName, value); + }, + reset(_element, attributeName, context) { + forwardCustomViewAttribute(context, attributeName, undefined); + }, +}; + +function buildCustomViewAttributeAppliers(viewElementClass: ViewElementClass): Record { + const binder = new AttributesBinder(); + binder.bindNoOpAttribute('androidClass'); + binder.bindNoOpAttribute('iosClass'); + binder.bindNoOpAttribute('macosClass'); + binder.bindStringAttribute( + 'webClass', + (element, value, context) => { + if (value.length === 0) { + throw new Error("Expected 'webClass' to be a non-empty string"); + } + const state = getCustomViewState(context); + if (state.webClassApplied) { + return; + } + state.webClassApplied = true; + const factory = getWebViewClassFactory(value); + if (factory) { + element.replaceChildren(); + const result = factory(element); + if (result) { + state.attributeHandler = result; + const destroy = result.destroy; + if (destroy) { + let destroyed = false; + context.addCleanup(() => { + if (destroyed) { + return; + } + destroyed = true; + state.attributeHandler = undefined; + destroy.call(result); + }); + } + } + for (let i = 0; i < state.pendingAttributes.length; i++) { + const [name, pendingValue] = state.pendingAttributes[i]; + state.attributeHandler?.changeAttribute(name, pendingValue); + } + state.pendingAttributes.length = 0; + } else { + appendPlaceholder(element, value); + } + if (!element.style.height) { + element.style.minHeight = '80px'; + } + }, + () => {}, + LAYOUT_DEPENDENT, + ); + return { ...viewElementClass.attributeAppliers, ...binder.attributeAppliers }; +} + +export class CustomViewElementClass extends ElementClass { + constructor(viewElementClass: ViewElementClass) { + super( + 'custom-view', + buildCustomViewAttributeAppliers(viewElementClass), + viewElementClass.compositeAttributes, + customViewUnknownAttributeApplier, + ); + } + + protected onCreateElement(): HTMLElement { + return createBaseElement('div'); + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/DatePickerElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/DatePickerElementClass.ts new file mode 100644 index 000000000..8d8c1ad7a --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/DatePickerElementClass.ts @@ -0,0 +1,14 @@ +import { ElementClass } from '../core/ElementClass'; +import { createBaseElement } from './ElementClassSupport'; +import { ViewElementClass } from './ViewElementClass'; + +export class DatePickerElementClass extends ElementClass { + constructor(viewElementClass: ViewElementClass) { + // TODO: Implement date picker input behavior and platform-specific attributes. + super('datepicker', viewElementClass.attributeAppliers, viewElementClass.compositeAttributes); + } + + protected onCreateElement(): HTMLElement { + return createBaseElement('div'); + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/ElementClassRegistry.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/ElementClassRegistry.ts new file mode 100644 index 000000000..ffe67ec43 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/ElementClassRegistry.ts @@ -0,0 +1,70 @@ +import { AnyElementClass } from '../core/ElementClass'; +import { BlurElementClass } from './BlurElementClass'; +import { CustomViewElementClass } from './CustomViewElementClass'; +import { DatePickerElementClass } from './DatePickerElementClass'; +import { ImageElementClass } from './ImageElementClass'; +import { LabelElementClass } from './LabelElementClass'; +import { LayoutElementClass } from './LayoutElementClass'; +import { ScrollElementClass } from './ScrollElementClass'; +import { ShapeElementClass } from './ShapeElementClass'; +import { SpinnerElementClass } from './SpinnerElementClass'; +import { TextAnimationGroupElementClass } from './TextAnimationGroupElementClass'; +import { TextFieldElementClass } from './TextFieldElementClass'; +import { TextViewElementClass } from './TextViewElementClass'; +import { VideoElementClass } from './VideoElementClass'; +import { ViewElementClass } from './ViewElementClass'; +import { WebViewElementClass } from './WebViewElementClass'; + +const layoutClass = new LayoutElementClass('layout', {}, {}); +const viewClass = new ViewElementClass(); +const labelClass = new LabelElementClass(viewClass); +const scrollClass = new ScrollElementClass(viewClass); +const imageClass = new ImageElementClass(viewClass); +const textFieldClass = new TextFieldElementClass(labelClass); +const textViewClass = new TextViewElementClass(textFieldClass); +const videoClass = new VideoElementClass(viewClass); +const spinnerClass = new SpinnerElementClass(viewClass); +const textAnimationGroupClass = new TextAnimationGroupElementClass(layoutClass); +const customViewClass = new CustomViewElementClass(viewClass); +const shapeClass = new ShapeElementClass(viewClass); +const blurClass = new BlurElementClass(viewClass); +const webViewClass = new WebViewElementClass(viewClass); +const datePickerClass = new DatePickerElementClass(viewClass); + +const elementClassesByName = new Map([ + ['layout', layoutClass], + ['view', viewClass], + ['SCValdiView', viewClass], + ['textanimationgroup', textAnimationGroupClass], + ['SCValdiTextAnimationGroup', textAnimationGroupClass], + ['textselectiongroup', viewClass], + ['SCValdiTextSelectionGroup', viewClass], + ['label', labelClass], + ['SCValdiLabel', labelClass], + ['scroll', scrollClass], + ['SCValdiScrollView', scrollClass], + ['image', imageClass], + ['animatedimage', imageClass], + ['SCValdiImageView', imageClass], + ['textfield', textFieldClass], + ['SCValdiTextField', textFieldClass], + ['textview', textViewClass], + ['SCValdiTextView', textViewClass], + ['video', videoClass], + ['SCValdiVideoView', videoClass], + ['spinner', spinnerClass], + ['custom-view', customViewClass], + ['shape', shapeClass], + ['SCValdiShapeView', shapeClass], + ['blur', blurClass], + ['webview', webViewClass], + ['SCValdiDatePicker', datePickerClass], +]); + +export function getElementClassForViewClass(viewClassName: string): AnyElementClass | undefined { + return elementClassesByName.get(viewClassName); +} + +export function registerElementClassAlias(alias: string, elementClass: AnyElementClass): void { + elementClassesByName.set(alias, elementClass); +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/ElementClassSupport.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/ElementClassSupport.ts new file mode 100644 index 000000000..2fccb40df --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/ElementClassSupport.ts @@ -0,0 +1,204 @@ +import { parseCssLength } from '../attributes/AttributeApplierHelpers'; +import { AttributeApplier, AttributeApplierContext } from '../core/ElementClass'; +import { resolveRenderableAssetSource } from '../utils/assetSource'; +import { isPlainCssNumber, readWhitespaceSeparatedToken, skipCssWhitespace } from '../utils/cssScanner'; + +export interface AttributeApplierMap { + [name: string]: AttributeApplier; +} + +const BASE_LAYOUT_ITEM_STYLES: Record = { + flexShrink: 0, + minHeight: 0, + minWidth: 0, + position: 'relative', +}; + +const BASE_ELEMENT_STYLES: Record = { + ...BASE_LAYOUT_ITEM_STYLES, + display: 'flex', + flexDirection: 'column', +}; + +export function assignStyles(element: HTMLElement, styles: Record): void { + Object.assign(element.style, styles); +} + +export function getActiveElement(element: HTMLElement): Element | null { + const root = element.getRootNode(); + if (typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot) { + return root.activeElement; + } + return document.activeElement; +} + +export function createBaseElement(tagName: K): HTMLElementTagNameMap[K] { + const element = document.createElement(tagName); + assignStyles(element, BASE_ELEMENT_STYLES); + return element; +} + +export function createBaseLayoutItemElement( + tagName: K, +): HTMLElementTagNameMap[K] { + const element = document.createElement(tagName); + assignStyles(element, BASE_LAYOUT_ITEM_STYLES); + return element; +} + +export const SYSTEM_FONT_FAMILY = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + +function applyFontDescriptor(element: HTMLElement, descriptor: string): void { + element.style.fontWeight = ''; + element.style.fontStyle = ''; + + if (descriptor === 'system' || descriptor === 'title' || descriptor.startsWith('system-')) { + element.style.fontFamily = SYSTEM_FONT_FAMILY; + if (descriptor.includes('bold') || descriptor === 'title') { + element.style.fontWeight = '700'; + } + if (descriptor.includes('italic')) { + element.style.fontStyle = 'italic'; + } + return; + } + + if (descriptor === 'bold') { + element.style.fontFamily = SYSTEM_FONT_FAMILY; + element.style.fontWeight = '700'; + return; + } + + if (descriptor === 'italic') { + element.style.fontFamily = SYSTEM_FONT_FAMILY; + element.style.fontStyle = 'italic'; + return; + } + + element.style.fontFamily = descriptor; +} + +export function applyFontString(element: HTMLElement, font: string, attributeName: string): void { + if (!font) { + throw new Error(`Expected '${attributeName}' to be a non-empty font string`); + } + const parts = font.split(' '); + applyFontDescriptor(element, parts[0]); + if (parts.length > 1) { + element.style.fontSize = parseCssLength(Number(parts[1]), attributeName); + } +} + +export function setApplierCleanup( + context: AttributeApplierContext, + key: string, + cleanup: (() => void) | undefined, +): void { + const cleanupByKeyKey = '__elementClassCleanupByKey'; + let cleanupByKey = context.getState void) | undefined>>(cleanupByKeyKey); + if (!cleanupByKey) { + if (!cleanup) { + return; + } + cleanupByKey = {}; + context.setState(cleanupByKeyKey, cleanupByKey); + } + cleanupByKey[key]?.(); + cleanupByKey[key] = cleanup; + if (cleanup) { + const cleanupMap = cleanupByKey; + context.addCleanup(() => { + if (cleanupMap[key] === cleanup) { + cleanupMap[key] = undefined; + cleanup(); + } + }); + } +} + +export function replaceEventListener( + element: HTMLElement, + context: AttributeApplierContext, + key: string, + eventName: string, + listener: ((event: any) => void) | undefined, +): void { + if (!listener) { + setApplierCleanup(context, key, undefined); + return; + } + element.addEventListener(eventName, listener); + setApplierCleanup(context, key, () => { + element.removeEventListener(eventName, listener); + }); +} + +export function setFont(): AttributeApplier { + return { + layoutDependent: true, + apply(element, value, attributeName) { + applyFontString(element, String(value), attributeName); + }, + reset(element) { + element.style.fontFamily = ''; + element.style.fontSize = ''; + element.style.fontStyle = ''; + element.style.fontWeight = ''; + }, + }; +} + +export function borderAttributeApplier(): AttributeApplier { + return { + colorDependent: true, + apply(_element, value, _attributeName, context) { + const element = context.getViewAttributeElement(); + const border = String(value); + if (!border) { + element.style.border = ''; + return; + } + const widthToken = readWhitespaceSeparatedToken(border, 0); + const styleToken = widthToken ? readWhitespaceSeparatedToken(border, widthToken.nextIndex) : undefined; + if (!widthToken || !styleToken) { + element.style.border = border; + return; + } + const colorStartIndex = skipCssWhitespace(border, styleToken.nextIndex); + if (colorStartIndex >= border.length) { + element.style.border = border; + return; + } + const width = isPlainCssNumber(widthToken.token) ? `${widthToken.token}px` : widthToken.token; + const color = context.resolveColor(border.slice(colorStartIndex)); + element.style.border = `${width} ${styleToken.token} ${color}`; + }, + reset(_element, _attributeName, context) { + const element = context.getViewAttributeElement(); + element.style.border = '0 solid transparent'; + }, + }; +} + +export function resolveRenderableSrc(src: unknown): string | undefined { + return resolveRenderableAssetSource(src); +} + +export function srcAttributeApplier< + TElement extends HTMLImageElement | HTMLVideoElement, +>(): AttributeApplier { + return { + layoutDependent: true, + apply(element, value) { + const src = resolveRenderableSrc(value); + if (src === undefined) { + element.removeAttribute('src'); + } else { + element.src = src; + } + }, + reset(element) { + element.removeAttribute('src'); + }, + }; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/ImageElement.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/ImageElement.ts new file mode 100644 index 000000000..ab9d22477 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/ImageElement.ts @@ -0,0 +1,442 @@ +import { ParsedCssColor } from '../utils/cssColor'; +import { ImageFilterOperation } from '../utils/imageFilterOperations'; +import type { ElementLayoutObserver } from '../core/ElementClass'; +import { assignStyles } from './ElementClassSupport'; +import { + calculateObjectFitDrawSize, + CanvasImageRenderer, + getDecodedImageSize, + getImageDisplaySize, + getImageLogicalSize, + ImageObjectFit, + isQuarterTurnRotation, + isRtlForImage, +} from './CanvasImageRenderer'; + +const CANVAS_IMAGE_RENDERER = new CanvasImageRenderer(); + +function isCrossOriginHttpSource(source: string): boolean { + if (!/^https?:\/\//i.test(source)) { + return false; + } + if (typeof window === 'undefined' || !window.location) { + return true; + } + try { + return new URL(source, window.location.href).origin !== window.location.origin; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Valdi web renderer could not parse image URL for origin comparison: ${message}`); + return true; + } +} + +export interface ImageLogicalSize { + width: number; + height: number; +} + +export interface ImageElementConfiguration { + contentRotation: number; + contentScaleX: number; + contentScaleY: number; + cssFilter: string; + explicitHeight: string | undefined; + explicitWidth: string | undefined; + filterOperations: ImageFilterOperation[]; + flipOnRtl: boolean; + logicalSize: ImageLogicalSize | undefined; + objectFit: ImageObjectFit; + source: string | undefined; + tint: ParsedCssColor | undefined; +} + +interface ImageLoadResult { + success: boolean; + errorMessage?: string; + width?: number; + height?: number; +} + +export type ImageAssetLoadCallback = (success: boolean, errorMessage?: string) => void; +export type ImageDecodedCallback = (width: number, height: number) => void; + +interface ImageDisplaySize { + width: number; + height: number; + devicePixelRatio: number; +} + +interface LayoutAnimationSize { + width: number; + height: number; +} + +export class ImageElement implements ElementLayoutObserver { + private readonly element: HTMLElement; + private readonly enqueuePostLayoutCallback: (callback: () => void) => void; + private readonly requestLayoutPass: () => void; + private canvas: HTMLCanvasElement | undefined; + private image: HTMLImageElement | undefined; + private source: string | undefined; + private imageLoaded = false; + private imageCanvasSafe = false; + private loadVersion = 0; + private loadResult: ImageLoadResult | undefined; + private onAssetLoad: ImageAssetLoadCallback | undefined; + private onImageDecoded: ImageDecodedCallback | undefined; + private notifiedOnAssetLoad: ImageAssetLoadCallback | undefined; + private notifiedOnImageDecoded: ImageDecodedCallback | undefined; + private tint: ParsedCssColor | undefined; + private objectFit: ImageObjectFit = 'fill'; + private contentRotation = 0; + private contentScaleX = 1; + private contentScaleY = 1; + private flipOnRtl = false; + private filterOperations: ImageFilterOperation[] = []; + private hasExplicitWidth = false; + private hasExplicitHeight = false; + private logicalWidthOverride: number | undefined; + private logicalHeightOverride: number | undefined; + private displaySize?: ImageDisplaySize; + private layoutAnimationSize?: LayoutAnimationSize; + private flip = false; + + constructor( + element: HTMLElement, + enqueuePostLayoutCallback: (callback: () => void) => void, + requestLayoutPass: () => void, + ) { + this.element = element; + this.enqueuePostLayoutCallback = enqueuePostLayoutCallback; + this.requestLayoutPass = requestLayoutPass; + } + + destroy(): void { + this.loadVersion++; + this.releaseImage(); + } + + setOnAssetLoad(callback: ImageAssetLoadCallback | undefined): void { + this.onAssetLoad = callback; + this.scheduleLoadCallbackReplay(); + } + + setOnImageDecoded(callback: ImageDecodedCallback | undefined): void { + this.onImageDecoded = callback; + this.scheduleLoadCallbackReplay(); + } + + configure(configuration: ImageElementConfiguration): void { + const sourceChanged = this.source !== configuration.source; + this.contentRotation = configuration.contentRotation; + this.contentScaleX = configuration.contentScaleX; + this.contentScaleY = configuration.contentScaleY; + this.element.style.filter = configuration.cssFilter; + this.hasExplicitHeight = configuration.explicitHeight !== undefined; + this.hasExplicitWidth = configuration.explicitWidth !== undefined; + this.element.style.height = configuration.explicitHeight ?? ''; + this.element.style.width = configuration.explicitWidth ?? ''; + this.filterOperations = configuration.filterOperations; + this.flipOnRtl = configuration.flipOnRtl; + this.logicalWidthOverride = configuration.logicalSize?.width; + this.logicalHeightOverride = configuration.logicalSize?.height; + this.objectFit = configuration.objectFit; + this.tint = configuration.tint; + if (sourceChanged) { + if (configuration.source) { + this.source = configuration.source; + this.startLoad(this.requiresCanvas(), true); + } else { + this.clearSource(); + } + } + if (this.imageLoaded && this.image) { + this.updateIntrinsicSize(this.image); + } + } + + private clearSource(): void { + this.loadVersion++; + this.releaseImage(); + this.source = undefined; + this.image = undefined; + this.imageLoaded = false; + this.imageCanvasSafe = false; + this.loadResult = undefined; + this.notifiedOnAssetLoad = undefined; + this.notifiedOnImageDecoded = undefined; + this.element.replaceChildren(); + } + + private releaseImage(): void { + if (!this.image) { + return; + } + this.image.onload = null; + this.image.onerror = null; + this.image.onabort = null; + this.image.removeAttribute('src'); + } + + private requiresCanvas(): boolean { + return this.tint !== undefined || this.filterOperations.some(operation => operation.type === 'colorMatrix'); + } + + private startLoad(corsEnabled: boolean, resetLoadResult: boolean): void { + const source = this.source; + if (!source) { + return; + } + const loadVersion = ++this.loadVersion; + this.releaseImage(); + const image = new Image(); + this.image = image; + this.imageLoaded = false; + this.imageCanvasSafe = corsEnabled || !isCrossOriginHttpSource(source); + if (resetLoadResult) { + this.loadResult = undefined; + this.notifiedOnAssetLoad = undefined; + this.notifiedOnImageDecoded = undefined; + } + if (corsEnabled) { + image.crossOrigin = 'anonymous'; + } + image.onload = () => { + if (loadVersion !== this.loadVersion) { + return; + } + this.imageLoaded = true; + const decodedSize = getDecodedImageSize(image, this.logicalWidthOverride, this.logicalHeightOverride); + this.loadResult = { + success: true, + width: decodedSize.width, + height: decodedSize.height, + }; + this.updateIntrinsicSize(image); + this.renderCurrentLayout(); + this.requestLayoutPass(); + this.scheduleLoadCallbackReplay(); + }; + image.onerror = () => { + if (loadVersion !== this.loadVersion) { + return; + } + if (corsEnabled && !this.requiresCanvas()) { + this.startLoad(false, false); + this.requestLayoutPass(); + return; + } + this.failLoad('Failed to load image'); + }; + image.onabort = () => { + if (loadVersion === this.loadVersion) { + this.failLoad('Image load aborted'); + } + }; + image.src = source; + } + + private failLoad(errorMessage: string): void { + this.imageLoaded = false; + this.loadResult = { + success: false, + errorMessage, + }; + this.notifiedOnAssetLoad = undefined; + this.notifiedOnImageDecoded = undefined; + this.renderCurrentLayout(); + this.requestLayoutPass(); + this.scheduleLoadCallbackReplay(); + } + + private scheduleLoadCallbackReplay(): void { + this.enqueuePostLayoutCallback(() => this.replayLoadCallbacks()); + } + + private replayLoadCallbacks(): void { + const loadResult = this.loadResult; + if (!loadResult) { + return; + } + if ( + loadResult.success && + loadResult.width !== undefined && + loadResult.height !== undefined && + this.onImageDecoded && + this.notifiedOnImageDecoded !== this.onImageDecoded + ) { + this.notifiedOnImageDecoded = this.onImageDecoded; + this.onImageDecoded(loadResult.width, loadResult.height); + } + if (this.onAssetLoad && this.notifiedOnAssetLoad !== this.onAssetLoad) { + this.notifiedOnAssetLoad = this.onAssetLoad; + this.onAssetLoad(loadResult.success, loadResult.errorMessage); + } + } + + onSizeChanged(width: number, height: number): void { + const layoutAnimationSize = this.layoutAnimationSize; + if (layoutAnimationSize) { + width = layoutAnimationSize.width; + height = layoutAnimationSize.height; + } + this.updateDisplaySize(width, height); + } + + setLayoutAnimationSize(width: number, height: number): void { + this.layoutAnimationSize = { width, height }; + this.updateDisplaySize(width, height); + this.renderCurrentLayout(); + } + + clearLayoutAnimationSize(): void { + this.layoutAnimationSize = undefined; + } + + private updateDisplaySize(width: number, height: number): void { + const image = this.image; + if (!this.source || !image) { + this.displaySize = undefined; + return; + } + const logicalSize = getImageLogicalSize(image, this.logicalWidthOverride, this.logicalHeightOverride); + this.displaySize = getImageDisplaySize(width, height, logicalSize.width, logicalSize.height); + this.flip = this.flipOnRtl && isRtlForImage(this.element); + } + + onCommit(_element: HTMLElement): void { + this.renderCurrentLayout(); + } + + private renderCurrentLayout(): void { + const displaySize = this.displaySize; + if (displaySize) { + this.render(displaySize, this.flip); + } + } + + private render(displaySize: ImageDisplaySize, flip: boolean): void { + if (!this.source || !this.image) { + this.element.replaceChildren(); + return; + } + if (this.requiresCanvas() && !this.imageCanvasSafe) { + this.startLoad(true, false); + } + const image = this.image!; + if (!this.imageLoaded) { + if (this.requiresCanvas()) { + this.renderCanvas(displaySize, flip); + } else { + this.element.replaceChildren(); + } + return; + } + if (this.requiresCanvas()) { + this.renderCanvas(displaySize, flip); + } else { + this.renderImage(image, displaySize, flip); + } + } + + private updateIntrinsicSize(image: HTMLImageElement): void { + if (this.hasExplicitWidth || this.hasExplicitHeight) { + return; + } + const logicalSize = getImageLogicalSize(image, this.logicalWidthOverride, this.logicalHeightOverride); + const rotated = isQuarterTurnRotation(this.contentRotation); + this.element.style.width = `${rotated ? logicalSize.height : logicalSize.width}px`; + this.element.style.height = `${rotated ? logicalSize.width : logicalSize.height}px`; + } + + private renderImage(image: HTMLImageElement, displaySize: ImageDisplaySize, flip: boolean): void { + const logicalSize = getImageLogicalSize(image, this.logicalWidthOverride, this.logicalHeightOverride); + const rotated = isQuarterTurnRotation(this.contentRotation); + const effectiveImageWidth = rotated ? logicalSize.height : logicalSize.width; + const effectiveImageHeight = rotated ? logicalSize.width : logicalSize.height; + const contentDrawWidth = + this.objectFit === 'none' ? (this.loadResult?.width ?? effectiveImageWidth) : effectiveImageWidth; + const contentDrawHeight = + this.objectFit === 'none' ? (this.loadResult?.height ?? effectiveImageHeight) : effectiveImageHeight; + const drawSize = calculateObjectFitDrawSize( + this.objectFit, + displaySize.width, + displaySize.height, + contentDrawWidth, + contentDrawHeight, + ); + const finalDrawWidth = rotated ? drawSize.height : drawSize.width; + const finalDrawHeight = rotated ? drawSize.width : drawSize.height; + const flipScale = flip ? -1 : 1; + const scaleX = this.contentScaleX * flipScale; + const transforms: string[] = []; + if (this.contentRotation !== 0) { + transforms.push(`rotate(${this.contentRotation}rad)`); + } + if (scaleX !== 1 || this.contentScaleY !== 1) { + transforms.push(`scale(${scaleX}, ${this.contentScaleY})`); + } + const blurFilters = this.filterOperations + .filter((operation): operation is Extract => operation.type === 'blur') + .map(operation => `blur(${operation.radius}px)`); + assignStyles(image, { + filter: blurFilters.join(' '), + height: `${finalDrawHeight}px`, + left: `${(displaySize.width - finalDrawWidth) / 2}px`, + position: 'absolute', + top: `${(displaySize.height - finalDrawHeight) / 2}px`, + width: `${finalDrawWidth}px`, + }); + if (transforms.length > 0) { + image.style.transform = transforms.join(' '); + } else { + image.style.removeProperty('transform'); + } + this.setImplementation(image); + } + + private renderCanvas(displaySize: ImageDisplaySize, flip: boolean): void { + const image = this.image; + if (!image) { + return; + } + let canvas = this.canvas; + if (!canvas) { + canvas = document.createElement('canvas'); + assignStyles(canvas, { + height: '100%', + left: '0', + position: 'absolute', + top: '0', + width: '100%', + }); + this.canvas = canvas; + } + this.setImplementation(canvas); + CANVAS_IMAGE_RENDERER.render(canvas, { + contentRotation: this.contentRotation, + contentScaleX: this.contentScaleX, + contentScaleY: this.contentScaleY, + devicePixelRatio: displaySize.devicePixelRatio, + displayHeight: displaySize.height, + displayWidth: displaySize.width, + filterOperations: this.filterOperations, + flip, + img: image, + isLoaded: this.imageLoaded && this.loadResult?.success === true, + loadHeight: this.loadResult?.height, + loadWidth: this.loadResult?.width, + logicalHeightOverride: this.logicalHeightOverride, + logicalWidthOverride: this.logicalWidthOverride, + objectFit: this.objectFit, + tint: this.tint, + }); + } + + private setImplementation(implementation: HTMLElement): void { + if (this.element.childNodes.length !== 1 || this.element.childNodes.item(0) !== implementation) { + this.element.replaceChildren(implementation); + } + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/ImageElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/ImageElementClass.ts new file mode 100644 index 000000000..ca9b4fd7c --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/ImageElementClass.ts @@ -0,0 +1,318 @@ +import { AttributesBinder } from '../attributes/AttributesBinder'; +import { parseBoolean, parseCssLength, parseNumber, parseString } from '../attributes/AttributeApplierHelpers'; +import { + AttributeApplierContext, + CompositeAttribute, + ElementClass, + LayoutAnimationSizeApplier, + LayoutAnimationTranslationCorrection, +} from '../core/ElementClass'; +import { ParsedCssColor, parseCssColor } from '../utils/cssColor'; +import { ImageFilterOperation, parseImageFilterOperations } from '../utils/imageFilterOperations'; +import { svgViewBoxIntrinsicSize } from '../utils/imageSource'; +import { ImageObjectFit } from './CanvasImageRenderer'; +import { AttributeApplierMap, assignStyles, resolveRenderableSrc } from './ElementClassSupport'; +import { + ImageAssetLoadCallback, + ImageDecodedCallback, + ImageElement, + ImageElementConfiguration, + ImageLogicalSize, +} from './ImageElement'; +import { ViewElementClass } from './ViewElementClass'; + +const IMAGE_ELEMENT_STATE = '__imageElementClassState'; + +interface TransformOrigin { + readonly x: number; + readonly y: number; +} + +interface IndependentScale { + readonly x: number; + readonly y: number; +} + +class ImageLayoutAnimationSizeApplier implements LayoutAnimationSizeApplier { + private readonly originalScale: string; + private readonly origin: TransformOrigin; + private readonly originalScaleX: number; + private readonly originalScaleY: number; + + constructor( + private readonly element: HTMLElement, + private readonly imageElement: ImageElement | undefined, + finalWidth: number, + finalHeight: number, + ) { + this.originalScale = element.style.getPropertyValue('scale'); + const originalScale = parseIndependentScale(this.originalScale); + this.originalScaleX = originalScale.x; + this.originalScaleY = originalScale.y; + this.origin = resolveTransformOrigin(element, finalWidth, finalHeight); + imageElement?.setLayoutAnimationSize(finalWidth, finalHeight); + } + + apply(scaleX: number, scaleY: number): LayoutAnimationTranslationCorrection { + this.element.style.setProperty('scale', `${this.originalScaleX * scaleX} ${this.originalScaleY * scaleY}`); + return { + x: -(1 - scaleX) * this.origin.x * this.originalScaleX, + y: -(1 - scaleY) * this.origin.y * this.originalScaleY, + }; + } + + reset(): void { + this.imageElement?.clearLayoutAnimationSize(); + if (this.originalScale) { + this.element.style.setProperty('scale', this.originalScale); + } else { + this.element.style.removeProperty('scale'); + } + } +} + +function parseIndependentScale(value: string): IndependentScale { + const source = value.trim(); + if (!source || source === 'none') { + return { x: 1, y: 1 }; + } + const tokens = source.split(/\s+/); + const x = parseScaleComponent(tokens[0]); + return { x, y: tokens.length > 1 ? parseScaleComponent(tokens[1]) : x }; +} + +function parseScaleComponent(value: string): number { + const parsed = Number.parseFloat(value); + return value.endsWith('%') ? parsed / 100 : parsed; +} + +function resolveTransformOrigin(element: HTMLElement, width: number, height: number): TransformOrigin { + const value = + typeof getComputedStyle === 'function' ? getComputedStyle(element).transformOrigin : element.style.transformOrigin; + const tokens = value.trim().split(/\s+/).filter(Boolean).slice(0, 2); + let horizontal: string | undefined; + let vertical: string | undefined; + const remaining: string[] = []; + for (const token of tokens) { + if ((token === 'left' || token === 'right') && horizontal === undefined) { + horizontal = token; + } else if ((token === 'top' || token === 'bottom') && vertical === undefined) { + vertical = token; + } else { + remaining.push(token); + } + } + if (horizontal === undefined && remaining.length > 0) { + horizontal = remaining.shift(); + } + if (vertical === undefined && remaining.length > 0) { + vertical = remaining.shift(); + } + return { + x: resolveTransformOriginComponent(horizontal, width, 'left', 'right'), + y: resolveTransformOriginComponent(vertical, height, 'top', 'bottom'), + }; +} + +function resolveTransformOriginComponent( + value: string | undefined, + size: number, + startKeyword: string, + endKeyword: string, +): number { + if (value === undefined || value === 'center') { + return size / 2; + } + if (value === startKeyword) { + return 0; + } + if (value === endKeyword) { + return size; + } + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed)) { + return size / 2; + } + return value.endsWith('%') ? (parsed / 100) * size : parsed; +} + +interface ResolvedImageSource { + logicalSize: ImageLogicalSize | undefined; + source: string | undefined; +} + +interface ResolvedImageFilter { + cssFilter: string; + operations: ImageFilterOperation[]; +} + +const DEFAULT_IMAGE_CONFIGURATION: ImageElementConfiguration = { + contentRotation: 0, + contentScaleX: 1, + contentScaleY: 1, + cssFilter: '', + explicitHeight: undefined, + explicitWidth: undefined, + filterOperations: [], + flipOnRtl: false, + logicalSize: undefined, + objectFit: 'fill', + source: undefined, + tint: undefined, +}; + +function getImageElement(element: HTMLElement, context: AttributeApplierContext): ImageElement { + const existing = context.getState(IMAGE_ELEMENT_STATE); + if (existing) { + return existing; + } + const imageElement = new ImageElement( + element, + callback => context.enqueuePostLayoutCallback(callback), + () => context.requestLayoutPass(), + ); + context.setState(IMAGE_ELEMENT_STATE, imageElement); + context.addCleanup(() => imageElement.destroy()); + return imageElement; +} + +function resolveImageSource(value: unknown): ResolvedImageSource { + const source = resolveRenderableSrc(value); + return { + source, + logicalSize: source ? svgViewBoxIntrinsicSize(source) : undefined, + }; +} + +function resolveImageFilter(value: unknown): ResolvedImageFilter { + const operations = parseImageFilterOperations(value); + return operations + ? { cssFilter: '', operations } + : { cssFilter: typeof value === 'string' ? value : '', operations: [] }; +} + +function configureImageElement( + element: HTMLElement, + context: AttributeApplierContext, + attributeName: string, + values: ReadonlyArray, +): void { + const source = values[0] as ResolvedImageSource | undefined; + const objectFit = values[1] as ImageObjectFit | undefined; + const tint = values[2] as ParsedCssColor | undefined; + const flipOnRtl = values[3] as boolean | undefined; + const contentScaleX = values[4] as number | undefined; + const contentScaleY = values[5] as number | undefined; + const contentRotation = values[6] as number | undefined; + const filter = values[7] as ResolvedImageFilter | undefined; + const explicitWidth = values[8] as string | undefined; + const explicitHeight = values[9] as string | undefined; + const imageElement = getImageElement(element, context); + imageElement.configure({ + contentRotation: contentRotation || 0, + contentScaleX: contentScaleX || 1, + contentScaleY: contentScaleY || 1, + cssFilter: filter?.cssFilter ?? '', + explicitHeight, + explicitWidth, + filterOperations: filter?.operations ?? [], + flipOnRtl: flipOnRtl ?? false, + logicalSize: source?.logicalSize, + objectFit: objectFit ?? 'fill', + source: source?.source, + tint, + }); + context.setLayoutObserver(attributeName, source?.source ? imageElement : undefined); +} + +const imageRenderComposite: CompositeAttribute = { + name: 'imageRenderComposite', + parts: [ + { name: 'src', optional: true, layoutDependent: true, parse: (_element, value) => resolveImageSource(value) }, + { name: 'objectFit', optional: true, parse: (_element, value, name) => parseString(value, name) }, + { + name: 'tint', + optional: true, + colorDependent: true, + parse: (_element, value, name, context) => parseCssColor(context.resolveColor(parseString(value, name))), + }, + { name: 'flipOnRtl', optional: true, parse: (_element, value, name) => parseBoolean(value, name) }, + { name: 'contentScaleX', optional: true, parse: (_element, value, name) => parseNumber(value, name) }, + { name: 'contentScaleY', optional: true, parse: (_element, value, name) => parseNumber(value, name) }, + { name: 'contentRotation', optional: true, parse: (_element, value, name) => parseNumber(value, name) }, + { name: 'filter', optional: true, parse: (_element, value) => resolveImageFilter(value) }, + { + name: 'width', + optional: true, + layoutDependent: true, + parse: (_element, value, name) => parseCssLength(value, name), + }, + { + name: 'height', + optional: true, + layoutDependent: true, + parse: (_element, value, name) => parseCssLength(value, name), + }, + ], + apply(element, values, attributeName, context) { + configureImageElement(element, context, attributeName, values); + }, + reset(element, attributeName, context) { + getImageElement(element, context).configure(DEFAULT_IMAGE_CONFIGURATION); + context.setLayoutObserver(attributeName, undefined); + }, +}; + +function buildImageAttributeAppliers(viewElementClass: ViewElementClass): AttributeApplierMap { + const binder = new AttributesBinder(); + binder.bindFunctionAttribute( + 'onAssetLoad', + (element, callback, context) => + getImageElement(element, context).setOnAssetLoad(callback as ImageAssetLoadCallback), + (element, context) => getImageElement(element, context).setOnAssetLoad(undefined), + ); + binder.bindFunctionAttribute( + 'onImageDecoded', + (element, callback, context) => + getImageElement(element, context).setOnImageDecoded(callback as ImageDecodedCallback), + (element, context) => getImageElement(element, context).setOnImageDecoded(undefined), + ); + binder.bindNoOpAttribute('ref'); + return { + ...(viewElementClass.attributeAppliers as AttributeApplierMap), + ...binder.attributeAppliers, + }; +} + +export class ImageElementClass extends ElementClass { + constructor(viewElementClass: ViewElementClass) { + super('image', buildImageAttributeAppliers(viewElementClass), { + ...viewElementClass.compositeAttributes, + imageRenderComposite, + }); + } + + protected onCreateElement(): HTMLElement { + const element = document.createElement('div'); + assignStyles(element, { + display: 'block', + overflow: 'hidden', + position: 'relative', + }); + return element; + } + + override makeLayoutAnimationSizeApplier( + element: HTMLElement, + context: AttributeApplierContext, + finalWidth: number, + finalHeight: number, + ): LayoutAnimationSizeApplier { + return new ImageLayoutAnimationSizeApplier( + element, + context.getState(IMAGE_ELEMENT_STATE), + finalWidth, + finalHeight, + ); + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/LabelElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/LabelElementClass.ts new file mode 100644 index 000000000..856f565fd --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/LabelElementClass.ts @@ -0,0 +1,277 @@ +import { AttributesBinder } from '../attributes/AttributesBinder'; +import { parseCssLength, parseNumber } from '../attributes/AttributeApplierHelpers'; +import { + AttributeApplier, + AttributeApplierContext, + ElementClass, + LayoutAnimationSizeApplier, +} from '../core/ElementClass'; +import { + isAttributedText, + ParsedAttributedText, + registerAttributedTextLayouts, + renderAttributedText, + unregisterAttributedTextLayouts, +} from '../utils/parseAttributedText'; +import { registerTextAnimationParticipant, unregisterTextAnimationParticipant } from '../utils/TextAnimationRegistry'; +import { textShadowCssValue } from '../utils/textStyle'; +import { + assignStyles, + AttributeApplierMap, + createBaseLayoutItemElement, + setFont, + SYSTEM_FONT_FAMILY, +} from './ElementClassSupport'; +import { ViewElementClass } from './ViewElementClass'; + +const TEXT_LINE_HEIGHT_STATE = '__labelElementClassLineHeightState'; +const TEXT_CONTENT_ELEMENT_STATE = '__labelElementClassTextContentElementState'; +const LAYOUT_DEPENDENT = true; + +interface TextLineHeightState { + lineHeight?: string; + lineHeightMultiple?: number; +} + +function getTextContentElement(element: HTMLElement, context: AttributeApplierContext): HTMLElement { + return context.getState(TEXT_CONTENT_ELEMENT_STATE) ?? element; +} + +function getTextLineHeightState(context: AttributeApplierContext): TextLineHeightState { + let state = context.getState(TEXT_LINE_HEIGHT_STATE); + if (!state) { + state = {}; + context.setState(TEXT_LINE_HEIGHT_STATE, state); + } + return state; +} + +function updateTextLineHeight(element: HTMLElement, context: AttributeApplierContext): void { + const state = getTextLineHeightState(context); + if (state.lineHeight !== undefined) { + element.style.lineHeight = state.lineHeight; + } else if (state.lineHeightMultiple !== undefined) { + element.style.lineHeight = String(state.lineHeightMultiple); + } else { + element.style.lineHeight = ''; + } +} + +function lineHeightAttributeApplier(): AttributeApplier { + return { + layoutDependent: true, + apply(element, value, attributeName, context) { + const state = getTextLineHeightState(context); + state.lineHeight = parseCssLength(value, attributeName); + updateTextLineHeight(element, context); + }, + reset(element, _attributeName, context) { + const state = getTextLineHeightState(context); + state.lineHeight = undefined; + updateTextLineHeight(element, context); + }, + }; +} + +function lineHeightMultipleAttributeApplier(): AttributeApplier { + return { + layoutDependent: true, + apply(element, value, attributeName, context) { + const state = getTextLineHeightState(context); + state.lineHeightMultiple = parseNumber(value, attributeName); + updateTextLineHeight(element, context); + }, + reset(element, _attributeName, context) { + const state = getTextLineHeightState(context); + state.lineHeightMultiple = undefined; + updateTextLineHeight(element, context); + }, + }; +} + +function applyTextShadow(element: HTMLElement, value: string, context: AttributeApplierContext): void { + element.style.textShadow = textShadowCssValue(value, context) ?? ''; +} + +function labelValueAttributeApplier(): AttributeApplier { + return { + colorDependent: true, + layoutDependent: true, + apply(element, value, attributeName, context) { + const textContentElement = getTextContentElement(element, context); + if (isAttributedText(value)) { + const parsedAttributedText = ParsedAttributedText.parse(value); + const container = renderAttributedText(parsedAttributedText, context); + textContentElement.replaceChildren(container); + registerTextAnimationParticipant(element, container, context); + registerAttributedTextLayouts(context, attributeName, parsedAttributedText, container); + return; + } + unregisterTextAnimationParticipant(context); + unregisterAttributedTextLayouts(context, attributeName); + textContentElement.textContent = String(value); + }, + reset(element, attributeName, context) { + unregisterTextAnimationParticipant(context); + unregisterAttributedTextLayouts(context, attributeName); + getTextContentElement(element, context).textContent = ''; + }, + }; +} + +function textDecorationAttributeApplier(): AttributeApplier { + return { + apply(element, value, attributeName) { + if (typeof value !== 'string') { + throw new Error(`Expected '${attributeName}' to be a string`); + } + element.style.textDecorationLine = ''; + element.style.textDecorationStyle = ''; + switch (value) { + case 'underline': + element.style.textDecorationLine = 'underline'; + return; + case 'dashed-underline': + element.style.textDecorationLine = 'underline'; + element.style.textDecorationStyle = 'dashed'; + return; + case 'dotted-underline': + element.style.textDecorationLine = 'underline'; + element.style.textDecorationStyle = 'dotted'; + return; + case 'strikethrough': + element.style.textDecorationLine = 'line-through'; + return; + case 'none': + case '': + default: + element.style.textDecorationLine = 'none'; + } + }, + reset(element) { + element.style.textDecorationLine = ''; + element.style.textDecorationStyle = ''; + }, + }; +} + +function textAlignAttributeApplier(): AttributeApplier { + const binder = new AttributesBinder(); + binder.bindStringAttribute( + 'textAlign', + (element, value) => { + element.style.textAlign = value === 'justified' ? 'justify' : value; + }, + element => { + element.style.textAlign = ''; + }, + ); + return binder.attributeAppliers.textAlign; +} + +function buildTextAttributeAppliers(viewElementClass: ViewElementClass): AttributeApplierMap { + const binder = new AttributesBinder(); + binder.bindAttribute('value', labelValueAttributeApplier()); + binder.bindAttribute('font', setFont()); + binder.bindAttribute('textAlign', textAlignAttributeApplier()); + binder.bindAttribute('textDecoration', textDecorationAttributeApplier()); + binder.bindStringAttribute( + 'textShadow', + (element, value, context) => { + applyTextShadow(element, value, context); + }, + element => { + element.style.textShadow = ''; + }, + ); + binder.bindAttribute('lineHeight', lineHeightAttributeApplier()); + binder.bindAttribute('lineHeightMultiple', lineHeightMultipleAttributeApplier()); + binder.bindCssLengthStyleAttribute('letterSpacing', 'letterSpacing', LAYOUT_DEPENDENT); + binder.bindNumberAttribute( + 'numberOfLines', + (element, value) => { + if (value <= 0) { + element.style.removeProperty('-webkit-line-clamp'); + element.style.overflow = ''; + return; + } + element.style.display = '-webkit-box'; + element.style.overflow = 'hidden'; + element.style.setProperty('-webkit-line-clamp', String(value)); + element.style.setProperty('-webkit-box-orient', 'vertical'); + }, + element => { + element.style.removeProperty('-webkit-line-clamp'); + element.style.removeProperty('-webkit-box-orient'); + element.style.display = 'inline'; + element.style.overflow = ''; + }, + LAYOUT_DEPENDENT, + ); + return { ...viewElementClass.attributeAppliers, ...binder.attributeAppliers }; +} + +export class LabelElementClass extends ElementClass { + private textContentElementTemplate: HTMLElement | undefined; + + constructor(private readonly viewElementClass: ViewElementClass) { + super('label', buildTextAttributeAppliers(viewElementClass), viewElementClass.compositeAttributes); + } + + override getViewAttributeElement(element: HTMLElement, context: AttributeApplierContext): HTMLElement { + this.getOrCreateTextContentElement(element, context); + return this.viewElementClass.getViewAttributeElement(element, context); + } + + override makeLayoutAnimationSizeApplier( + element: HTMLElement, + context: AttributeApplierContext, + finalWidth: number, + finalHeight: number, + ): LayoutAnimationSizeApplier | undefined { + return this.viewElementClass.makeLayoutAnimationSizeApplier(element, context, finalWidth, finalHeight); + } + + protected onCreateElement(): HTMLElement { + const element = createBaseLayoutItemElement('span'); + assignStyles(element, { + display: 'inline', + whiteSpace: 'pre-wrap', + wordWrap: 'break-word', + fontFamily: SYSTEM_FONT_FAMILY, + color: 'black', + }); + return element; + } + + private getOrCreateTextContentElement(element: HTMLElement, context: AttributeApplierContext): HTMLElement { + const existing = context.getState(TEXT_CONTENT_ELEMENT_STATE); + if (existing) { + return existing; + } + + const textContentElement = this.getTextContentElementTemplate().cloneNode(false) as HTMLElement; + const textContent = element.childNodes.length === 0 ? element.textContent : undefined; + while (element.childNodes.length !== 0) { + const child = element.childNodes.item(0)!; + element.removeChild(child); + textContentElement.appendChild(child); + } + if (textContent !== undefined) { + textContentElement.textContent = textContent; + element.textContent = ''; + } + element.appendChild(textContentElement); + context.setState(TEXT_CONTENT_ELEMENT_STATE, textContentElement); + return textContentElement; + } + + private getTextContentElementTemplate(): HTMLElement { + if (!this.textContentElementTemplate) { + const textContentElement = document.createElement('span'); + textContentElement.style.display = 'contents'; + this.textContentElementTemplate = textContentElement; + } + return this.textContentElementTemplate; + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/LayoutElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/LayoutElementClass.ts new file mode 100644 index 000000000..c0461dcf5 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/LayoutElementClass.ts @@ -0,0 +1,650 @@ +import { AttributesBinder } from '../attributes/AttributesBinder'; +import { parseCssTrackList } from '../attributes/AttributeApplierHelpers'; +import type { ElementFrame } from 'valdi_tsx/src/Geometry'; +import { + AttributeApplier, + AttributeApplierContext, + CompositeAttribute, + ElementClass, + ElementLayoutObserver, +} from '../core/ElementClass'; +import { isPlainCssNumber, parseCssFunctionCall, readCssNumber, skipCssWhitespace } from '../utils/cssScanner'; +import { AttributeApplierMap, createBaseElement, replaceEventListener } from './ElementClassSupport'; +import { getViewPresentationState } from './ViewElementState'; + +const MEASURE_MODE_UNSPECIFIED = 0; +const MEASURE_MODE_EXACTLY = 1; +const MEASURE_MODE_AT_MOST = 2; + +function mapFlexWrap(value: string): string { + return value === 'no-wrap' ? 'nowrap' : value; +} + +function mapViewOverflow(value: string): string { + return value === 'scroll' ? 'visible' : value; +} + +const VIEW_LAZY_SIZE_STATE = '__viewElementClassLazySizeState'; +const VIEW_GRID_COMPAT_STATE = '__viewElementClassGridCompatState'; + +interface ViewLazySizeState { + estimatedWidthStyle?: string; + estimatedHeightStyle?: string; +} + +interface ViewGridCompatState { + gridTemplateColumnsCss?: string; + gridTemplateColumnsSource?: string; + updateScheduled: boolean; +} + +function getViewLazySizeState(context: AttributeApplierContext): ViewLazySizeState { + const existing = context.getState(VIEW_LAZY_SIZE_STATE); + if (existing) { + return existing; + } + const state: ViewLazySizeState = {}; + context.setState(VIEW_LAZY_SIZE_STATE, state); + return state; +} + +function getViewGridCompatState(context: AttributeApplierContext): ViewGridCompatState { + const existing = context.getState(VIEW_GRID_COMPAT_STATE); + if (existing) { + return existing; + } + const state: ViewGridCompatState = { updateScheduled: false }; + context.setState(VIEW_GRID_COMPAT_STATE, state); + return state; +} + +function skipOptionalLengthUnit(value: string, index: number): number { + if (value.startsWith('px', index) || value.startsWith('pt', index)) { + return index + 2; + } + return index; +} + +function readCssNumberWithOptionalLengthUnit( + value: string, + index: number, +): { value: number; nextIndex: number } | undefined { + const number = readCssNumber(value, index); + if (!number) { + return undefined; + } + return { value: number.value, nextIndex: skipOptionalLengthUnit(value, number.nextIndex) }; +} + +function parseMinmaxOneFlexibleTrack( + value: string, + index: number, +): { minTrack: number; nextIndex: number } | undefined { + const parsed = parseCssFunctionCall(value, index); + if (!parsed || parsed.name !== 'minmax' || parsed.parameters.length !== 2) { + return undefined; + } + + const minTrack = readCssNumberWithOptionalLengthUnit( + parsed.parameters[0], + skipCssWhitespace(parsed.parameters[0], 0), + ); + if (!minTrack || skipCssWhitespace(parsed.parameters[0], minTrack.nextIndex) !== parsed.parameters[0].length) { + return undefined; + } + if (parsed.parameters[1].trim() !== '1fr') { + return undefined; + } + return { minTrack: minTrack.value, nextIndex: parsed.nextIndex }; +} + +function parseRepeatTwoMinmaxFrFixed(value: string): { minTrack: number; fixedTrack: number } | undefined { + const repeat = parseCssFunctionCall(value, 0); + if ( + !repeat || + repeat.name !== 'repeat' || + repeat.parameters.length !== 2 || + repeat.parameters[0].trim() !== '2' || + skipCssWhitespace(value, repeat.nextIndex) !== value.length + ) { + return undefined; + } + + const repeatedTrack = repeat.parameters[1]; + const minmax = parseMinmaxOneFlexibleTrack(repeatedTrack, 0); + if (!minmax) { + return undefined; + } + const fixedTrackStartIndex = skipCssWhitespace(repeatedTrack, minmax.nextIndex); + if (fixedTrackStartIndex === minmax.nextIndex) { + return undefined; + } + const fixedTrack = readCssNumberWithOptionalLengthUnit(repeatedTrack, fixedTrackStartIndex); + if (!fixedTrack) { + return undefined; + } + if (skipCssWhitespace(repeatedTrack, fixedTrack.nextIndex) !== repeatedTrack.length) { + return undefined; + } + + return { minTrack: minmax.minTrack, fixedTrack: fixedTrack.value }; +} + +function applyMeasuredSize(element: HTMLElement, result: unknown, widthMode: number, heightMode: number): boolean { + const width = Array.isArray(result) + ? Number(result[0]) + : typeof result === 'object' && result !== null && 'width' in result + ? Number((result as { width: unknown }).width) + : NaN; + const height = Array.isArray(result) + ? Number(result[1]) + : typeof result === 'object' && result !== null && 'height' in result + ? Number((result as { height: unknown }).height) + : NaN; + + let changed = false; + if (widthMode !== MEASURE_MODE_EXACTLY && Number.isFinite(width) && width >= 0) { + const value = `${width}px`; + if (element.style.width !== value) { + element.style.width = value; + changed = true; + } + } + if (heightMode !== MEASURE_MODE_EXACTLY && Number.isFinite(height) && height >= 0) { + const value = `${height}px`; + if (element.style.height !== value) { + element.style.height = value; + changed = true; + } + } + return changed; +} + +function getChildElement(element: HTMLElement, index: number): HTMLElement | undefined { + const child = element.children?.item(index) ?? element.childNodes.item(index); + return child && typeof child === 'object' && 'style' in child ? (child as HTMLElement) : undefined; +} + +function childSpansRepeatedFlexibleTrackEnd(element: HTMLElement): boolean { + for (let index = 0; ; index++) { + const child = getChildElement(element, index); + if (!child) { + return false; + } + if ( + child.style.gridColumnStart === '3' && + (child.style.gridColumnEnd === '5' || child.style.gridColumnEnd === 'span 2') + ) { + return true; + } + } +} + +function yogaCompatibleGridTemplateColumns(element: HTMLElement, state: ViewGridCompatState): string | undefined { + const source = state.gridTemplateColumnsSource; + const css = state.gridTemplateColumnsCss; + if (!source || !css || !childSpansRepeatedFlexibleTrackEnd(element)) { + return css; + } + const repeatTracks = parseRepeatTwoMinmaxFrFixed(source); + if (!repeatTracks) { + return css; + } + const { minTrack, fixedTrack } = repeatTracks; + return `${minTrack + fixedTrack / 2}px ${fixedTrack}px minmax(0, 1fr) ${fixedTrack}px`; +} + +function updateGridTemplateColumnsForYogaCompatibility(element: HTMLElement, state: ViewGridCompatState): void { + const value = yogaCompatibleGridTemplateColumns(element, state); + if (value !== undefined && element.style.gridTemplateColumns !== value) { + element.style.gridTemplateColumns = value; + } +} + +function scheduleGridTemplateColumnsCompatibilityUpdate(element: HTMLElement, state: ViewGridCompatState): void { + if (state.updateScheduled) { + return; + } + state.updateScheduled = true; + Promise.resolve().then(() => { + state.updateScheduled = false; + updateGridTemplateColumnsForYogaCompatibility(element, state); + }); +} + +function gridTemplateColumnsAttributeApplier(): AttributeApplier { + return { + layoutDependent: true, + apply(element, value, attributeName, context) { + const state = getViewGridCompatState(context); + state.gridTemplateColumnsSource = parseCssTrackList(value, attributeName); + state.gridTemplateColumnsCss = state.gridTemplateColumnsSource; + element.style.gridTemplateColumns = state.gridTemplateColumnsCss; + scheduleGridTemplateColumnsCompatibilityUpdate(element, state); + }, + reset(element, _attributeName, context) { + const state = getViewGridCompatState(context); + state.gridTemplateColumnsSource = undefined; + state.gridTemplateColumnsCss = undefined; + element.style.gridTemplateColumns = ''; + }, + }; +} + +class OnMeasureLayoutObserver implements ElementLayoutObserver { + private width = 0; + private widthMode = MEASURE_MODE_UNSPECIFIED; + private height = 0; + private heightMode = MEASURE_MODE_UNSPECIFIED; + + constructor( + private readonly callback: Function, + private readonly requestLayoutPass: () => void, + ) {} + + onMeasure(element: HTMLElement): void { + const rect = element.getBoundingClientRect(); + const parentRect = rect.width <= 0 || rect.height <= 0 ? element.parentElement?.getBoundingClientRect() : undefined; + const parentWidth = parentRect?.width ?? 0; + const parentHeight = parentRect?.height ?? 0; + this.width = rect.width > 0 ? rect.width : parentWidth; + this.widthMode = rect.width > 0 || parentWidth > 0 ? MEASURE_MODE_EXACTLY : MEASURE_MODE_UNSPECIFIED; + this.height = rect.height > 0 ? rect.height : parentHeight; + this.heightMode = + rect.height > 0 ? MEASURE_MODE_EXACTLY : parentHeight > 0 ? MEASURE_MODE_AT_MOST : MEASURE_MODE_UNSPECIFIED; + } + + onCommit(element: HTMLElement): void { + const result = this.callback(this.width, this.widthMode, this.height, this.heightMode); + if (applyMeasuredSize(element, result, this.widthMode, this.heightMode)) { + this.requestLayoutPass(); + } + } +} + +function applyEstimatedSize( + element: HTMLElement, + context: AttributeApplierContext, + axis: 'width' | 'height', + value: number, +): void { + const state = getViewLazySizeState(context); + const styleValue = `${value}px`; + if (axis === 'width') { + element.style.containIntrinsicWidth = styleValue; + state.estimatedWidthStyle = styleValue; + } else { + element.style.containIntrinsicHeight = styleValue; + if (!element.style.height) { + element.style.height = styleValue; + state.estimatedHeightStyle = styleValue; + } + } +} + +function resetEstimatedSize(element: HTMLElement, context: AttributeApplierContext, axis: 'width' | 'height'): void { + const state = getViewLazySizeState(context); + if (axis === 'width') { + element.style.containIntrinsicWidth = ''; + if (state.estimatedWidthStyle !== undefined && element.style.width === state.estimatedWidthStyle) { + element.style.width = ''; + } + state.estimatedWidthStyle = undefined; + } else { + element.style.containIntrinsicHeight = ''; + if (state.estimatedHeightStyle !== undefined && element.style.height === state.estimatedHeightStyle) { + element.style.height = ''; + } + state.estimatedHeightStyle = undefined; + } +} + +const layoutCompositeAttributes: Readonly> = {}; + +const LAYOUT_DEPENDENT = true; + +function buildLayoutAttributeAppliers(): AttributeApplierMap { + const binder = new AttributesBinder(); + binder.bindNoOpAttribute('allowReuse'); + bindLifecycleFunctionAttribute(binder, 'onViewCreate', (context, callback) => { + context.emitCurrentViewCreate(callback); + }); + bindLifecycleFunctionAttribute(binder, 'onViewDestroy', undefined); + bindLifecycleFunctionAttribute(binder, 'onViewChange', context => { + context.emitCurrentViewChange(); + }); + binder.bindCssLengthStyleAttribute('width', 'width', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('height', 'height', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('minWidth', 'minWidth', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('minHeight', 'minHeight', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('maxWidth', 'maxWidth', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('maxHeight', 'maxHeight', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('top', 'top', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('right', 'right', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('bottom', 'bottom', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('left', 'left', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('margin', 'margin', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('marginTop', 'marginTop', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('marginRight', 'marginRight', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('marginBottom', 'marginBottom', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('marginLeft', 'marginLeft', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('padding', 'padding', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('paddingTop', 'paddingTop', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('paddingRight', 'paddingRight', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('paddingBottom', 'paddingBottom', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('paddingLeft', 'paddingLeft', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('gap', 'gap', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('rowGap', 'rowGap', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('columnGap', 'columnGap', LAYOUT_DEPENDENT); + binder.bindCssLengthStyleAttribute('flexBasis', 'flexBasis', LAYOUT_DEPENDENT); + binder.bindAttribute('gridTemplateColumns', gridTemplateColumnsAttributeApplier()); + binder.bindCssTrackListStyleAttribute('gridTemplateRows', 'gridTemplateRows', LAYOUT_DEPENDENT); + binder.bindCssTrackListStyleAttribute('gridAutoColumns', 'gridAutoColumns', LAYOUT_DEPENDENT); + binder.bindCssTrackListStyleAttribute('gridAutoRows', 'gridAutoRows', LAYOUT_DEPENDENT); + binder.bindStyleValueAttribute('gridColumnStart', 'gridColumnStart', LAYOUT_DEPENDENT); + binder.bindStyleValueAttribute('gridColumnEnd', 'gridColumnEnd', LAYOUT_DEPENDENT); + binder.bindStyleValueAttribute('gridRowStart', 'gridRowStart', LAYOUT_DEPENDENT); + binder.bindStyleValueAttribute('gridRowEnd', 'gridRowEnd', LAYOUT_DEPENDENT); + binder.bindNumberAttribute( + 'aspectRatio', + (element, value) => { + element.style.aspectRatio = String(value); + }, + element => { + element.style.aspectRatio = ''; + }, + LAYOUT_DEPENDENT, + ); + binder.bindEnumAttribute( + 'position', + ['relative', 'absolute'] as const, + (element, value) => { + element.style.position = value; + }, + element => { + element.style.position = 'relative'; + }, + LAYOUT_DEPENDENT, + ); + binder.bindEnumAttribute( + 'display', + ['flex', 'grid', 'none'] as const, + (element, value) => { + element.style.display = value; + }, + element => { + element.style.display = 'flex'; + }, + LAYOUT_DEPENDENT, + ); + binder.bindStyleValueAttribute('flexDirection', 'flexDirection', LAYOUT_DEPENDENT); + binder.bindStringAttribute( + 'flexWrap', + (element, value) => { + element.style.flexWrap = mapFlexWrap(value); + }, + element => { + element.style.flexWrap = ''; + }, + LAYOUT_DEPENDENT, + ); + binder.bindNumberAttribute( + 'flexGrow', + (element, value) => { + element.style.flexGrow = String(value); + }, + element => { + element.style.flexGrow = ''; + }, + LAYOUT_DEPENDENT, + ); + binder.bindNumberAttribute( + 'flexShrink', + (element, value) => { + element.style.flexShrink = String(value); + }, + element => { + element.style.flexShrink = '0'; + }, + LAYOUT_DEPENDENT, + ); + binder.bindStyleValueAttribute('justifyContent', 'justifyContent', LAYOUT_DEPENDENT); + binder.bindStyleValueAttribute('justifyItems', 'justifyItems', LAYOUT_DEPENDENT); + binder.bindStyleValueAttribute('justifySelf', 'justifySelf', LAYOUT_DEPENDENT); + binder.bindStyleValueAttribute('alignContent', 'alignContent', LAYOUT_DEPENDENT); + binder.bindStyleValueAttribute('alignItems', 'alignItems', LAYOUT_DEPENDENT); + binder.bindStyleValueAttribute('alignSelf', 'alignSelf', LAYOUT_DEPENDENT); + binder.bindStyleValueAttribute('direction', 'direction', LAYOUT_DEPENDENT); + binder.bindStringAttribute( + 'overflow', + (element, value, context) => { + const state = getViewPresentationState(context); + state.overflow = mapViewOverflow(value); + element.style.overflow = state.slowClipping ? 'hidden' : state.overflow; + }, + (element, context) => { + const state = getViewPresentationState(context); + state.overflow = undefined; + element.style.overflow = state.slowClipping ? 'hidden' : 'visible'; + }, + LAYOUT_DEPENDENT, + ); + binder.bindStringAttribute( + 'colorPaletteName', + (_element, value, context) => { + context.setColorPalette(value); + }, + (_element, context) => { + context.setColorPalette(undefined); + }, + ); + binder.bindNumberAttribute( + 'zIndex', + (element, value) => { + element.style.zIndex = String(value); + }, + element => { + element.style.zIndex = '0'; + }, + ); + + binder.bindDirectAttribute('id', 'id'); + binder.bindDirectAttribute('key', 'data-key'); + binder.bindDirectAttribute('accessibilityId', 'id'); + binder.bindStringAttribute( + 'class', + (element, value) => { + element.className = value; + }, + element => { + element.className = ''; + }, + LAYOUT_DEPENDENT, + ); + binder.bindDirectAttribute('accessibilityLabel', 'aria-label'); + binder.bindDirectAttribute('accessibilityCategory', 'aria-roledescription'); + binder.bindDirectAttribute('accessibilityRole', 'role'); + binder.bindDirectAttribute('accessibilityHint', 'title'); + binder.bindDirectAttribute('accessibilityValue', 'aria-valuetext'); + binder.bindDirectAttribute('accessibilityStateSelected', 'aria-selected'); + binder.bindDirectAttribute('accessibilityStateChecked', 'aria-checked'); + binder.bindAriaBooleanAttribute('accessibilityStateDisabled', 'aria-disabled'); + binder.bindAriaBooleanAttribute('accessibilityStateExpanded', 'aria-expanded'); + binder.bindAttribute('accessibilityStateLiveRegion', { + apply(element, value) { + element.setAttribute('aria-live', value ? 'polite' : 'off'); + }, + reset(element) { + element.removeAttribute('aria-live'); + }, + }); + binder.bindAriaBooleanAttribute('accessibilityHidden', 'aria-hidden'); + binder.bindAriaBooleanAttribute('accessibilityElementsHidden', 'aria-hidden'); + binder.bindAriaBooleanAttribute('accessibilityViewIsModal', 'aria-modal'); + binder.bindNoOpAttribute('accessibilityIgnoresInvertColors'); + binder.bindNoOpAttribute('accessibilityTraits'); + binder.bindNoOpAttribute('accessibilityNavigation'); + binder.bindNoOpAttribute('accessibilityPriority'); + binder.bindNoOpAttribute('onAccessibilityMagicTap'); + binder.bindNoOpAttribute('onAccessibilityIncrement'); + binder.bindNoOpAttribute('onAccessibilityDecrement'); + binder.bindFunctionAttribute( + 'onAccessibilityTap', + (element, callback, context) => { + replaceEventListener(element, context, 'view:onAccessibilityTap', 'click', event => callback(event)); + }, + (element, context) => { + replaceEventListener(element, context, 'view:onAccessibilityTap', 'click', undefined); + }, + ); + binder.bindFunctionAttribute( + 'onAccessibilityActivate', + (element, callback, context) => { + replaceEventListener(element, context, 'view:onAccessibilityActivate', 'click', event => callback(event)); + }, + (element, context) => { + replaceEventListener(element, context, 'view:onAccessibilityActivate', 'click', undefined); + }, + ); + binder.bindFunctionAttribute( + 'onAccessibilityEscape', + (element, callback, context) => { + replaceEventListener(element, context, 'view:onAccessibilityEscape', 'keydown', event => { + if (event.key === 'Escape') { + callback(event); + } + }); + }, + (element, context) => { + replaceEventListener(element, context, 'view:onAccessibilityEscape', 'keydown', undefined); + }, + ); + binder.bindFunctionAttribute( + 'onLayout', + (_element, callback, context) => { + context.setOnLayoutCallback(callback as (frame: ElementFrame) => void); + }, + (_element, context) => { + context.setOnLayoutCallback(undefined); + }, + ); + // Renderer owns these callbacks and registers the element with VisibilityObserverController. + binder.bindNoOpAttribute('onVisibilityChanged'); + binder.bindNoOpAttribute('onViewportChanged'); + binder.bindFunctionAttribute( + 'onLayoutComplete', + (_element, callback) => { + requestAnimationFrame(() => callback()); + }, + () => {}, + ); + binder.bindBooleanAttribute( + 'lazyLayout', + element => { + element.style.removeProperty('content-visibility'); + }, + element => { + element.style.removeProperty('content-visibility'); + }, + LAYOUT_DEPENDENT, + ); + binder.bindBooleanAttribute( + 'lazy', + element => { + element.style.removeProperty('content-visibility'); + }, + element => { + element.style.removeProperty('content-visibility'); + }, + LAYOUT_DEPENDENT, + ); + binder.bindFunctionAttribute( + 'onMeasure', + (_element, callback, context, attributeName) => { + context.setLayoutObserver( + attributeName, + new OnMeasureLayoutObserver(callback, () => { + context.requestLayoutPass(); + }), + ); + }, + (_element, context, attributeName) => { + context.setLayoutObserver(attributeName, undefined); + }, + LAYOUT_DEPENDENT, + ); + binder.bindNumberAttribute( + 'estimatedWidth', + (element, value, context) => { + applyEstimatedSize(element, context, 'width', value); + }, + (element, context) => { + resetEstimatedSize(element, context, 'width'); + }, + LAYOUT_DEPENDENT, + ); + binder.bindNumberAttribute( + 'estimatedHeight', + (element, value, context) => { + applyEstimatedSize(element, context, 'height', value); + }, + (element, context) => { + resetEstimatedSize(element, context, 'height'); + }, + LAYOUT_DEPENDENT, + ); + binder.bindBooleanAttribute( + 'limitToViewport', + (element, value) => { + element.style.overflow = value ? 'hidden' : 'visible'; + }, + element => { + element.style.overflow = ''; + }, + ); + binder.bindBooleanAttribute( + 'animationsEnabled', + (_element, value, context) => { + context.setAnimationsEnabled(value); + }, + (_element, context) => { + context.setAnimationsEnabled(true); + }, + ); + binder.bindNoOpAttribute('ignoreParentViewport'); + binder.bindStyleValueAttribute('flex', 'flex', LAYOUT_DEPENDENT); + + return binder.attributeAppliers; +} + +function bindLifecycleFunctionAttribute( + binder: AttributesBinder, + name: string, + onApply: ((context: AttributeApplierContext, callback: Function) => void) | undefined, +): void { + binder.bindFunctionAttribute( + name, + (_element, callback, context) => { + onApply?.(context, callback); + }, + () => {}, + ); +} + +export class LayoutElementClass extends ElementClass { + constructor( + className: string, + additionalAttributeAppliers: AttributeApplierMap, + additionalCompositeAttributes: Readonly>, + ) { + super( + className, + { ...buildLayoutAttributeAppliers(), ...additionalAttributeAppliers }, + { ...layoutCompositeAttributes, ...additionalCompositeAttributes }, + ); + } + + protected onCreateElement(): HTMLElement { + return createBaseElement('div'); + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/ScrollElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/ScrollElementClass.ts new file mode 100644 index 000000000..a3c49f452 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/ScrollElementClass.ts @@ -0,0 +1,574 @@ +import { AttributesBinder } from '../attributes/AttributesBinder'; +import { parseBoolean, parseNumber } from '../attributes/AttributeApplierHelpers'; +import { AttributeApplierContext, CompositeAttribute, ElementClass, ElementLayoutObserver } from '../core/ElementClass'; +import { + assignStyles, + AttributeApplierMap, + createBaseElement, + getActiveElement, + replaceEventListener, + setApplierCleanup, +} from './ElementClassSupport'; +import { ViewElementClass } from './ViewElementClass'; +import { injectScrollbarStyles } from '../styles/scrollbar'; + +interface ScrollState { + canAlwaysScrollHorizontal: boolean; + canAlwaysScrollVertical: boolean; + contentOffsetAnimated: boolean; + pendingContentOffsetX?: ScrollOffsetRequest; + pendingContentOffsetY?: ScrollOffsetRequest; + fadingEdgeLength: number; + fadingEdgeStartEnabled: boolean; + fadingEdgeEndEnabled: boolean; + showsHorizontalScrollIndicator: boolean; + showsVerticalScrollIndicator: boolean; +} + +interface ScrollOffsetRequest { + animated: boolean; + value: number; +} + +const SCROLL_STATE = '__scrollElementClassState'; + +function getScrollState(context: AttributeApplierContext): ScrollState { + const existing = context.getState(SCROLL_STATE); + if (existing) { + return existing; + } + const state: ScrollState = { + canAlwaysScrollHorizontal: false, + canAlwaysScrollVertical: false, + contentOffsetAnimated: false, + fadingEdgeLength: 0, + fadingEdgeStartEnabled: true, + fadingEdgeEndEnabled: true, + showsHorizontalScrollIndicator: false, + showsVerticalScrollIndicator: false, + }; + context.setState(SCROLL_STATE, state); + return state; +} + +function resolveFadingEdgeMask(element: HTMLElement, state: ScrollState): string { + if (state.fadingEdgeLength <= 0) { + return ''; + } + + const length = `${state.fadingEdgeLength}px`; + const isHorizontal = element.style.overflowX !== 'hidden'; + const offset = isHorizontal ? element.scrollLeft : element.scrollTop; + const scrollSize = isHorizontal ? element.scrollWidth : element.scrollHeight; + const clientSize = isHorizontal ? element.clientWidth : element.clientHeight; + const maxOffset = Math.max(0, scrollSize - clientSize); + const fadeStart = state.fadingEdgeStartEnabled && offset > 0; + const fadeEnd = state.fadingEdgeEndEnabled && (maxOffset === 0 || offset < maxOffset - 0.5); + const gradientDirection = isHorizontal ? 'to right' : 'to bottom'; + let gradientStops: string; + if (fadeStart && fadeEnd) { + gradientStops = `transparent, black ${length}, black calc(100% - ${length}), transparent`; + } else if (fadeStart) { + gradientStops = `transparent, black ${length}, black`; + } else if (fadeEnd) { + gradientStops = `black, black calc(100% - ${length}), transparent`; + } else { + return ''; + } + + return `linear-gradient(${gradientDirection}, ${gradientStops})`; +} + +function updateFadingEdge(element: HTMLElement, context: AttributeApplierContext): void { + const mask = resolveFadingEdgeMask(element, getScrollState(context)); + element.style.maskImage = mask; + element.style.webkitMaskImage = mask; +} + +class FadingEdgeLayoutObserver implements ElementLayoutObserver { + private mask = ''; + + constructor( + private readonly element: HTMLElement, + private readonly state: ScrollState, + ) {} + + onSizeChanged(_width: number, _height: number): void { + this.mask = resolveFadingEdgeMask(this.element, this.state); + } + + onCommit(element: HTMLElement): void { + element.style.maskImage = this.mask; + element.style.webkitMaskImage = this.mask; + } +} + +function updateFadingEdgeScrollListener(element: HTMLElement, context: AttributeApplierContext): void { + const state = getScrollState(context); + if (state.fadingEdgeLength <= 0) { + replaceEventListener(element, context, 'scroll:fadingEdge', 'scroll', undefined); + return; + } + replaceEventListener(element, context, 'scroll:fadingEdge', 'scroll', () => updateFadingEdge(element, context)); +} + +function applyFadingEdgeConfiguration( + element: HTMLElement, + context: AttributeApplierContext, + horizontal: boolean, + fadingEdgeLength: number, + fadingEdgeStartEnabled: boolean, + fadingEdgeEndEnabled: boolean, + attributeName: string, +): void { + element.style.overflowX = horizontal ? 'auto' : 'hidden'; + element.style.overflowY = horizontal ? 'hidden' : 'auto'; + element.style.flexDirection = horizontal ? 'row' : 'column'; + const state = getScrollState(context); + state.fadingEdgeLength = fadingEdgeLength; + state.fadingEdgeStartEnabled = fadingEdgeStartEnabled; + state.fadingEdgeEndEnabled = fadingEdgeEndEnabled; + updateFadingEdgeScrollListener(element, context); + if (fadingEdgeLength > 0) { + context.setLayoutObserver(attributeName, new FadingEdgeLayoutObserver(element, state)); + } else { + context.setLayoutObserver(attributeName, undefined); + element.style.maskImage = ''; + element.style.webkitMaskImage = ''; + } +} + +const fadingEdgeComposite: CompositeAttribute = { + name: 'scrollFadingEdge', + parts: [ + { + name: 'horizontal', + optional: true, + layoutDependent: true, + parse: (_element, value, name) => parseBoolean(value, name), + }, + { + name: 'fadingEdgeLength', + optional: true, + parse: (_element, value, name) => parseNumber(value, name), + }, + { + name: 'fadingEdgeStart', + optional: true, + parse: (_element, value, name) => parseBoolean(value, name), + }, + { + name: 'fadingEdgeEnd', + optional: true, + parse: (_element, value, name) => parseBoolean(value, name), + }, + ], + apply(element, values, attributeName, context) { + applyFadingEdgeConfiguration( + element, + context, + (values[0] as boolean | undefined) ?? false, + (values[1] as number | undefined) ?? 0, + (values[2] as boolean | undefined) ?? true, + (values[3] as boolean | undefined) ?? true, + attributeName, + ); + }, + reset(element, attributeName, context) { + applyFadingEdgeConfiguration(element, context, false, 0, true, true, attributeName); + }, +}; + +class ContentSizeLayoutObserver implements ElementLayoutObserver { + onCommit: ((element: HTMLElement) => void) | undefined; + private lastWidth: number | undefined; + private lastHeight: number | undefined; + private pendingWidth = 0; + private pendingHeight = 0; + + constructor( + private readonly element: HTMLElement, + private readonly callback: Function, + ) {} + + onSizeChanged(_width: number, _height: number): void { + const width = this.element.scrollWidth; + const height = this.element.scrollHeight; + if (this.lastWidth === width && this.lastHeight === height) { + this.onCommit = undefined; + return; + } + this.pendingWidth = width; + this.pendingHeight = height; + this.onCommit = this.commit; + } + + private readonly commit = (): void => { + this.lastWidth = this.pendingWidth; + this.lastHeight = this.pendingHeight; + this.callback({ width: this.pendingWidth, height: this.pendingHeight }); + }; +} + +function getPendingScrollOffsetRequest(state: ScrollState, axis: 'x' | 'y'): ScrollOffsetRequest | undefined { + return axis === 'x' ? state.pendingContentOffsetX : state.pendingContentOffsetY; +} + +function setPendingScrollOffsetRequest( + state: ScrollState, + axis: 'x' | 'y', + request: ScrollOffsetRequest | undefined, +): void { + if (axis === 'x') { + state.pendingContentOffsetX = request; + } else { + state.pendingContentOffsetY = request; + } +} + +function applyScrollOffsetNow(element: HTMLElement, axis: 'x' | 'y', value: number, animated: boolean): void { + if (axis === 'x') { + if (animated) { + element.scrollTo({ left: value, behavior: 'smooth' }); + } else { + element.scrollLeft = value; + } + } else if (animated) { + element.scrollTo({ top: value, behavior: 'smooth' }); + } else { + element.scrollTop = value; + } +} + +function applyScrollOffset( + element: HTMLElement, + context: AttributeApplierContext, + axis: 'x' | 'y', + value: number, + animated: boolean, +): void { + const state = getScrollState(context); + const request: ScrollOffsetRequest = { animated, value }; + setPendingScrollOffsetRequest(state, axis, request); + + if (!animated) { + applyScrollOffsetNow(element, axis, value, false); + } + + context.enqueuePostLayoutCallback(() => { + if (getPendingScrollOffsetRequest(state, axis) !== request) { + return; + } + applyScrollOffsetNow(element, axis, request.value, request.animated); + }); +} + +function resetScrollOffset(element: HTMLElement, context: AttributeApplierContext, axis: 'x' | 'y'): void { + const state = getScrollState(context); + setPendingScrollOffsetRequest(state, axis, undefined); + if (axis === 'x') { + element.scrollLeft = 0; + } else { + element.scrollTop = 0; + } +} + +function installScrollbarStyles(element: HTMLElement): void { + const rootNode = element.getRootNode(); + if (typeof ShadowRoot !== 'undefined' && rootNode instanceof ShadowRoot) { + injectScrollbarStyles(rootNode); + } else if (typeof Document !== 'undefined' && rootNode instanceof Document) { + injectScrollbarStyles(rootNode); + } else if (typeof document !== 'undefined') { + injectScrollbarStyles(document); + } +} + +function updateScrollIndicators(element: HTMLElement, context: AttributeApplierContext): void { + const state = getScrollState(context); + const hideHorizontal = !state.showsHorizontalScrollIndicator && !state.canAlwaysScrollHorizontal; + const hideVertical = !state.showsVerticalScrollIndicator && !state.canAlwaysScrollVertical; + if (hideHorizontal) { + element.classList.add('hide-h-scrollbar'); + } else { + element.classList.remove('hide-h-scrollbar'); + } + if (hideVertical) { + element.classList.add('hide-v-scrollbar'); + } else { + element.classList.remove('hide-v-scrollbar'); + } + element.style.setProperty('scrollbar-width', hideHorizontal && hideVertical ? 'none' : 'auto'); +} + +function buildScrollAttributeAppliers(viewElementClass: ViewElementClass): AttributeApplierMap { + const binder = new AttributesBinder(); + binder.bindFunctionAttribute( + 'onScroll', + (element, callback, context) => { + replaceEventListener(element, context, 'scroll:onScroll', 'scroll', () => { + callback({ + contentOffset: { + x: element.scrollLeft, + y: element.scrollTop, + }, + contentSize: { + width: element.scrollWidth, + height: element.scrollHeight, + }, + layoutMeasurement: { + width: element.clientWidth, + height: element.clientHeight, + }, + }); + }); + }, + (element, context) => { + replaceEventListener(element, context, 'scroll:onScroll', 'scroll', undefined); + }, + ); + binder.bindFunctionAttribute( + 'onScrollEnd', + (element, callback, context) => { + let timer: number | undefined; + const listener = () => { + if (timer !== undefined) { + clearTimeout(timer); + } + timer = window.setTimeout(() => callback(), 100); + }; + element.addEventListener('scroll', listener); + setApplierCleanup(context, 'scroll:onScrollEnd', () => { + element.removeEventListener('scroll', listener); + if (timer !== undefined) { + clearTimeout(timer); + } + }); + }, + (_element, context) => { + setApplierCleanup(context, 'scroll:onScrollEnd', undefined); + }, + ); + binder.bindFunctionAttribute( + 'onDragStart', + (element, callback, context) => { + replaceEventListener(element, context, 'scroll:onDragStartMouse', 'mousedown', event => callback(event)); + replaceEventListener(element, context, 'scroll:onDragStartTouch', 'touchstart', event => callback(event)); + }, + (element, context) => { + replaceEventListener(element, context, 'scroll:onDragStartMouse', 'mousedown', undefined); + replaceEventListener(element, context, 'scroll:onDragStartTouch', 'touchstart', undefined); + }, + ); + binder.bindFunctionAttribute( + 'onDragEnd', + (element, callback, context) => { + replaceEventListener(element, context, 'scroll:onDragEndMouse', 'mouseup', event => callback(event)); + replaceEventListener(element, context, 'scroll:onDragEndTouch', 'touchend', event => callback(event)); + }, + (element, context) => { + replaceEventListener(element, context, 'scroll:onDragEndMouse', 'mouseup', undefined); + replaceEventListener(element, context, 'scroll:onDragEndTouch', 'touchend', undefined); + }, + ); + binder.bindFunctionAttribute( + 'onDragEnding', + (element, callback, context) => { + let timer: number | undefined; + const listener = () => { + if (timer !== undefined) { + clearTimeout(timer); + } + timer = window.setTimeout(() => callback(), 150); + }; + element.addEventListener('scroll', listener); + setApplierCleanup(context, 'scroll:onDragEnding', () => { + element.removeEventListener('scroll', listener); + if (timer !== undefined) { + clearTimeout(timer); + } + }); + }, + (_element, context) => { + setApplierCleanup(context, 'scroll:onDragEnding', undefined); + }, + ); + binder.bindFunctionAttribute( + 'onContentSizeChange', + (element, callback, context, attributeName) => { + context.setLayoutObserver(attributeName, new ContentSizeLayoutObserver(element, callback)); + }, + (_element, context, attributeName) => { + context.setLayoutObserver(attributeName, undefined); + }, + ); + binder.bindBooleanAttribute( + 'bounces', + (element, value) => { + element.style.overscrollBehavior = value ? 'auto' : 'contain'; + }, + element => { + element.style.overscrollBehavior = ''; + }, + ); + binder.bindNoOpAttribute('bouncesFromDragAtStart'); + binder.bindNoOpAttribute('bouncesFromDragAtEnd'); + binder.bindNoOpAttribute('bouncesVerticalWithSmallContent'); + binder.bindNoOpAttribute('bouncesHorizontalWithSmallContent'); + binder.bindNoOpAttribute('cancelsTouchesOnScroll'); + binder.bindBooleanAttribute( + 'dismissKeyboardOnDrag', + (element, value, context) => { + if (!value) { + setApplierCleanup(context, 'scroll:dismissKeyboardOnDrag', undefined); + return; + } + const listener = () => { + const activeElement = getActiveElement(element); + if (activeElement instanceof HTMLElement) { + activeElement.blur(); + } + }; + element.addEventListener('scroll', listener); + setApplierCleanup(context, 'scroll:dismissKeyboardOnDrag', () => { + element.removeEventListener('scroll', listener); + }); + }, + (_element, context) => { + setApplierCleanup(context, 'scroll:dismissKeyboardOnDrag', undefined); + }, + ); + binder.bindBooleanAttribute( + 'pagingEnabled', + (element, value) => { + if (value) { + element.style.scrollSnapType = element.style.overflowX === 'hidden' ? 'y mandatory' : 'x mandatory'; + } else { + element.style.scrollSnapType = ''; + } + }, + element => { + element.style.scrollSnapType = ''; + }, + ); + binder.bindBooleanAttribute( + 'showsVerticalScrollIndicator', + (element, value, context) => { + installScrollbarStyles(element); + getScrollState(context).showsVerticalScrollIndicator = value; + updateScrollIndicators(element, context); + }, + (element, context) => { + installScrollbarStyles(element); + getScrollState(context).showsVerticalScrollIndicator = false; + updateScrollIndicators(element, context); + }, + ); + binder.bindBooleanAttribute( + 'showsHorizontalScrollIndicator', + (element, value, context) => { + installScrollbarStyles(element); + getScrollState(context).showsHorizontalScrollIndicator = value; + updateScrollIndicators(element, context); + }, + (element, context) => { + installScrollbarStyles(element); + getScrollState(context).showsHorizontalScrollIndicator = false; + updateScrollIndicators(element, context); + }, + ); + binder.bindBooleanAttribute( + 'canAlwaysScrollHorizontal', + (element, value, context) => { + getScrollState(context).canAlwaysScrollHorizontal = value; + element.style.overflowX = value ? 'scroll' : 'auto'; + updateScrollIndicators(element, context); + }, + (element, context) => { + getScrollState(context).canAlwaysScrollHorizontal = false; + element.style.overflowX = ''; + updateScrollIndicators(element, context); + }, + ); + binder.bindBooleanAttribute( + 'canAlwaysScrollVertical', + (element, value, context) => { + getScrollState(context).canAlwaysScrollVertical = value; + element.style.overflowY = value ? 'scroll' : 'auto'; + updateScrollIndicators(element, context); + }, + (element, context) => { + getScrollState(context).canAlwaysScrollVertical = false; + element.style.overflowY = ''; + updateScrollIndicators(element, context); + }, + ); + binder.bindBooleanAttribute( + 'scrollEnabled', + (element, enabled) => { + element.style.overflow = enabled ? 'auto' : 'hidden'; + }, + element => { + element.style.overflow = ''; + }, + ); + binder.bindNoOpAttribute('ref'); + binder.bindNoOpAttribute('scrollPerfLoggerBridge'); + binder.bindNoOpAttribute('circularRatio'); + binder.bindNoOpAttribute('decelerationRate'); + binder.bindNoOpAttribute('viewportExtensionTop'); + binder.bindNoOpAttribute('viewportExtensionRight'); + binder.bindNoOpAttribute('viewportExtensionBottom'); + binder.bindNoOpAttribute('viewportExtensionLeft'); + binder.bindNumberAttribute( + 'contentOffsetX', + (element, value, context) => { + applyScrollOffset(element, context, 'x', value, getScrollState(context).contentOffsetAnimated); + }, + (element, context) => { + resetScrollOffset(element, context, 'x'); + }, + ); + binder.bindNumberAttribute( + 'contentOffsetY', + (element, value, context) => { + applyScrollOffset(element, context, 'y', value, getScrollState(context).contentOffsetAnimated); + }, + (element, context) => { + resetScrollOffset(element, context, 'y'); + }, + ); + binder.bindBooleanAttribute( + 'contentOffsetAnimated', + (_element, value, context) => { + getScrollState(context).contentOffsetAnimated = value; + }, + (_element, context) => { + getScrollState(context).contentOffsetAnimated = false; + }, + ); + binder.bindNoOpAttribute('staticContentWidth'); + binder.bindNoOpAttribute('staticContentHeight'); + return { ...viewElementClass.attributeAppliers, ...binder.attributeAppliers }; +} + +export class ScrollElementClass extends ElementClass { + constructor(viewElementClass: ViewElementClass) { + super('scroll', buildScrollAttributeAppliers(viewElementClass), { + ...viewElementClass.compositeAttributes, + [fadingEdgeComposite.name]: fadingEdgeComposite, + }); + } + + protected onCreateElement(): HTMLElement { + const element = createBaseElement('div'); + installScrollbarStyles(element); + element.classList.add('hide-v-scrollbar', 'hide-h-scrollbar'); + assignStyles(element, { + overflowX: 'hidden', + overflowY: 'auto', + scrollbarWidth: 'none', + }); + return element; + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/ShapeElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/ShapeElementClass.ts new file mode 100644 index 000000000..5b12c4b1b --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/ShapeElementClass.ts @@ -0,0 +1,196 @@ +import { + AttributesBinder, + MIN_VISIBLE_CHANGE_COLOR, + MIN_VISIBLE_CHANGE_PIXEL, +} from '../attributes/AttributesBinder'; +import { AttributeApplierContext, ElementClass } from '../core/ElementClass'; +import { geometricPathToSvgPath, isGeometricPathValue } from '../utils/geometricPath'; +import { assignStyles, AttributeApplierMap } from './ElementClassSupport'; +import { ViewElementClass } from './ViewElementClass'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; +const SHAPE_STATE = '__shapeElementClassState'; + +interface ShapeState { + strokeStart: number; + strokeEnd: number; +} + +function getShapeState(context: AttributeApplierContext): ShapeState { + const existing = context.getState(SHAPE_STATE); + if (existing) { + return existing; + } + const state: ShapeState = { strokeStart: 0, strokeEnd: 1 }; + context.setState(SHAPE_STATE, state); + return state; +} + +function getSvgElement(element: HTMLElement): SVGSVGElement { + return element.querySelector('svg') as SVGSVGElement; +} + +function getPathElement(element: HTMLElement): SVGPathElement { + return element.querySelector('path') as SVGPathElement; +} + +function pathToString(element: HTMLElement, path: unknown): string | undefined { + if (typeof path === 'string') { + return path; + } + if (isGeometricPathValue(path)) { + const result = geometricPathToSvgPath(path); + getSvgElement(element).setAttribute('viewBox', result.viewBox); + getSvgElement(element).setAttribute('preserveAspectRatio', result.preserveAspectRatio); + return result.d || undefined; + } + return undefined; +} + +function applyStrokeDash(element: HTMLElement, context: AttributeApplierContext): void { + const path = getPathElement(element); + const state = getShapeState(context); + if (state.strokeStart <= 0 && state.strokeEnd >= 1) { + path.removeAttribute('stroke-dasharray'); + path.removeAttribute('stroke-dashoffset'); + return; + } + const total = path.getTotalLength(); + if (total <= 0) { + path.removeAttribute('stroke-dasharray'); + path.removeAttribute('stroke-dashoffset'); + return; + } + const start = state.strokeStart * total; + const length = (state.strokeEnd - state.strokeStart) * total; + path.setAttribute('stroke-dasharray', `${length} ${total}`); + path.setAttribute('stroke-dashoffset', String(-start)); +} + +function buildShapeAttributeAppliers(viewElementClass: ViewElementClass): AttributeApplierMap { + const binder = new AttributesBinder(); + binder.bindAttribute('path', { + apply(element, value, _attributeName, context) { + const path = getPathElement(element); + const d = pathToString(element, value); + if (d !== undefined) { + path.setAttribute('d', d); + applyStrokeDash(element, context); + } else { + path.removeAttribute('d'); + } + }, + reset(element) { + getPathElement(element).removeAttribute('d'); + }, + }); + binder.bindAnimatableNumberAttribute( + 'strokeWidth', + 1, + MIN_VISIBLE_CHANGE_PIXEL, + (element, value) => { + getPathElement(element).setAttribute('stroke-width', String(value)); + }, + element => { + getPathElement(element).removeAttribute('stroke-width'); + }, + ); + binder.bindAnimatableColorAttribute( + 'strokeColor', + 'transparent', + MIN_VISIBLE_CHANGE_COLOR, + (element, value) => { + getPathElement(element).setAttribute('stroke', value); + }, + element => { + getPathElement(element).setAttribute('stroke', 'transparent'); + }, + ); + binder.bindAnimatableColorAttribute( + 'fillColor', + 'transparent', + MIN_VISIBLE_CHANGE_COLOR, + (element, value) => { + getPathElement(element).setAttribute('fill', value); + }, + element => { + getPathElement(element).setAttribute('fill', 'transparent'); + }, + ); + binder.bindStringAttribute( + 'strokeCap', + (element, value) => { + getPathElement(element).setAttribute('stroke-linecap', value); + }, + element => { + getPathElement(element).setAttribute('stroke-linecap', 'butt'); + }, + ); + binder.bindStringAttribute( + 'strokeJoin', + (element, value) => { + getPathElement(element).setAttribute('stroke-linejoin', value); + }, + element => { + getPathElement(element).setAttribute('stroke-linejoin', 'miter'); + }, + ); + binder.bindAnimatableNumberAttribute( + 'strokeStart', + 0, + MIN_VISIBLE_CHANGE_PIXEL, + (element, value, context) => { + getShapeState(context).strokeStart = value; + applyStrokeDash(element, context); + }, + (element, context) => { + getShapeState(context).strokeStart = 0; + applyStrokeDash(element, context); + }, + ); + binder.bindAnimatableNumberAttribute( + 'strokeEnd', + 1, + MIN_VISIBLE_CHANGE_PIXEL, + (element, value, context) => { + getShapeState(context).strokeEnd = value; + applyStrokeDash(element, context); + }, + (element, context) => { + getShapeState(context).strokeEnd = 1; + applyStrokeDash(element, context); + }, + ); + return { ...viewElementClass.attributeAppliers, ...binder.attributeAppliers }; +} + +export class ShapeElementClass extends ElementClass { + constructor(viewElementClass: ViewElementClass) { + super('shape', buildShapeAttributeAppliers(viewElementClass), viewElementClass.compositeAttributes); + } + + protected onCreateElement(): HTMLElement { + const path = document.createElementNS(SVG_NS, 'path') as SVGPathElement; + path.setAttribute('fill', 'none'); + path.setAttribute('vector-effect', 'non-scaling-stroke'); + + const wrapper = document.createElement('div'); + assignStyles(wrapper, { + display: 'block', + height: '100%', + width: '100%', + }); + + const svg = document.createElementNS(SVG_NS, 'svg') as SVGSVGElement; + svg.setAttribute('viewBox', '0 0 1 1'); + svg.setAttribute('preserveAspectRatio', 'none'); + assignStyles(svg as unknown as HTMLElement, { + display: 'block', + height: '100%', + width: '100%', + }); + svg.appendChild(path); + wrapper.appendChild(svg); + return wrapper; + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/SpinnerElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/SpinnerElementClass.ts new file mode 100644 index 000000000..321ed9688 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/SpinnerElementClass.ts @@ -0,0 +1,148 @@ +import { parseCssLength } from '../attributes/AttributeApplierHelpers'; +import { AttributesBinder, MIN_VISIBLE_CHANGE_COLOR } from '../attributes/AttributesBinder'; +import { ElementClass } from '../core/ElementClass'; +import { assignStyles, AttributeApplierMap, createBaseElement } from './ElementClassSupport'; +import { ViewElementClass } from './ViewElementClass'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +function getSpinnerSvg(element: HTMLElement): SVGElement | null { + return element.querySelector('svg'); +} + +function buildSpinnerAttributeAppliers(viewElementClass: ViewElementClass): AttributeApplierMap { + const binder = new AttributesBinder(); + binder.bindAnimatableColorAttribute( + 'color', + 'currentColor', + MIN_VISIBLE_CHANGE_COLOR, + (element, color) => { + element.style.color = color; + const svg = getSpinnerSvg(element); + if (svg) { + svg.style.color = color; + } + }, + element => { + element.style.color = ''; + const svg = getSpinnerSvg(element); + if (svg) { + svg.style.color = 'currentColor'; + } + }, + ); + binder.bindAttribute('width', { + layoutDependent: true, + apply(element, value, attributeName) { + const width = parseCssLength(value, attributeName); + element.style.width = width; + const svg = getSpinnerSvg(element); + if (svg) { + svg.style.width = width; + } + }, + reset(element) { + element.style.width = ''; + const svg = getSpinnerSvg(element); + if (svg) { + svg.style.width = '20px'; + } + }, + }); + binder.bindAttribute('height', { + layoutDependent: true, + apply(element, value, attributeName) { + const height = parseCssLength(value, attributeName); + element.style.height = height; + const svg = getSpinnerSvg(element); + if (svg) { + svg.style.height = height; + } + }, + reset(element) { + element.style.height = ''; + const svg = getSpinnerSvg(element); + if (svg) { + svg.style.height = '20px'; + } + }, + }); + return { ...viewElementClass.attributeAppliers, ...binder.attributeAppliers }; +} + +export class SpinnerElementClass extends ElementClass { + constructor(viewElementClass: ViewElementClass) { + super('spinner', buildSpinnerAttributeAppliers(viewElementClass), viewElementClass.compositeAttributes); + } + + protected onCreateElement(): HTMLElement { + const element = createBaseElement('div'); + assignStyles(element, { + alignItems: 'center', + justifyContent: 'center', + pointerEvents: 'none', + }); + + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.classList.add('valdi-spinner'); + svg.setAttribute('viewBox', '0 0 12 12'); + svg.setAttribute('role', 'status'); + Object.assign(svg.style, { + color: 'currentColor', + filter: 'drop-shadow(0 0 4px rgba(0, 0, 0, 0.35))', + height: '20px', + overflow: 'visible', + width: '20px', + }); + + const outerCircle = document.createElementNS(SVG_NS, 'circle'); + outerCircle.classList.add('valdi-spinner-outer'); + outerCircle.setAttribute('cx', '6'); + outerCircle.setAttribute('cy', '6'); + outerCircle.setAttribute('r', '5'); + outerCircle.setAttribute('fill', 'none'); + outerCircle.setAttribute('stroke', 'currentColor'); + outerCircle.setAttribute('stroke-linecap', 'round'); + outerCircle.setAttribute('stroke-width', '1'); + outerCircle.setAttribute('stroke-dasharray', '31.416'); + Object.assign(outerCircle.style, { + animation: + 'valdi-spin-cw 1s linear infinite, valdi-dash-outer 0.9s ease-out forwards, valdi-grow 0.5s ease-out forwards', + transformBox: 'fill-box', + transformOrigin: 'center', + }); + + const innerCircle = document.createElementNS(SVG_NS, 'circle'); + innerCircle.classList.add('valdi-spinner-inner'); + innerCircle.setAttribute('cx', '6'); + innerCircle.setAttribute('cy', '6'); + innerCircle.setAttribute('r', '3'); + innerCircle.setAttribute('fill', 'none'); + innerCircle.setAttribute('stroke', 'currentColor'); + innerCircle.setAttribute('stroke-linecap', 'round'); + innerCircle.setAttribute('stroke-width', '1'); + innerCircle.setAttribute('stroke-dasharray', '18.85'); + Object.assign(innerCircle.style, { + animation: + 'valdi-spin-ccw 1s linear infinite, valdi-dash-inner 0.9s ease-out forwards, valdi-grow 0.5s ease-out forwards', + transformBox: 'fill-box', + transformOrigin: 'center', + }); + + svg.appendChild(outerCircle); + svg.appendChild(innerCircle); + + const style = document.createElement('style'); + style.textContent = ` + @keyframes valdi-spin-cw { to { transform: rotate(360deg); } } + @keyframes valdi-spin-ccw { to { transform: rotate(-360deg); } } + @keyframes valdi-dash-outer { from { stroke-dashoffset: 31.416; } to { stroke-dashoffset: 12.566; } } + @keyframes valdi-dash-inner { from { stroke-dashoffset: 18.85; } to { stroke-dashoffset: 7.54; } } + @keyframes valdi-grow { from { stroke-width: 0; } to { stroke-width: 1; } } + `; + + element.appendChild(style); + element.appendChild(svg); + return element; + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/TextAnimationGroupElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/TextAnimationGroupElementClass.ts new file mode 100644 index 000000000..99332fed8 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/TextAnimationGroupElementClass.ts @@ -0,0 +1,60 @@ +import { AttributesBinder } from '../attributes/AttributesBinder'; +import { ElementClass } from '../core/ElementClass'; +import { AttributeApplierMap, createBaseElement } from './ElementClassSupport'; +import { + registerTextAnimationGroup, + setTextAnimationGroupFlushDurationThreshold, + setTextAnimationGroupFlushMultiplier, + unregisterTextAnimationGroup, +} from '../utils/TextAnimationRegistry'; +import { LayoutElementClass } from './LayoutElementClass'; + +function buildTextAnimationGroupAttributeAppliers(): AttributeApplierMap { + const binder = new AttributesBinder(); + binder.bindNumberAttribute( + 'flushDurationThreshold', + (element, value) => { + setTextAnimationGroupFlushDurationThreshold(element, Math.max(value, 0)); + }, + element => { + setTextAnimationGroupFlushDurationThreshold(element, undefined); + }, + ); + binder.bindNumberAttribute( + 'flushMultiplier', + (element, value) => { + setTextAnimationGroupFlushMultiplier(element, Math.max(value, 0)); + }, + element => { + setTextAnimationGroupFlushMultiplier(element, undefined); + }, + ); + return binder.attributeAppliers; +} + +export class TextAnimationGroupElementClass extends ElementClass { + constructor(layoutElementClass: LayoutElementClass) { + super( + 'textanimationgroup', + { + ...layoutElementClass.attributeAppliers, + ...buildTextAnimationGroupAttributeAppliers(), + }, + layoutElementClass.compositeAttributes, + ); + } + + createElement(id: number, viewClass: string): HTMLElement { + const element = super.createElement(id, viewClass); + registerTextAnimationGroup(element); + return element; + } + + destroy(element: HTMLElement): void { + unregisterTextAnimationGroup(element); + } + + protected onCreateElement(): HTMLElement { + return createBaseElement('div'); + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/TextFieldElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/TextFieldElementClass.ts new file mode 100644 index 000000000..5e4560ae2 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/TextFieldElementClass.ts @@ -0,0 +1,402 @@ +import { AttributesBinder } from '../attributes/AttributesBinder'; +import { AttributeApplier, AttributeApplierContext, ElementClass } from '../core/ElementClass'; +import { isAttributedText, ParsedAttributedText } from '../utils/parseAttributedText'; +import { textShadowCssValue } from '../utils/textStyle'; +import { + assignStyles, + AttributeApplierMap, + getActiveElement, + replaceEventListener, + setApplierCleanup, + SYSTEM_FONT_FAMILY, +} from './ElementClassSupport'; +import { LabelElementClass } from './LabelElementClass'; + +export type TextInputElement = HTMLInputElement | HTMLTextAreaElement; +const LAYOUT_DEPENDENT = true; + +function editEvent(element: TextInputElement): { text: string; selectionStart: number; selectionEnd: number } { + return { + text: element.value, + selectionStart: element.selectionStart ?? 0, + selectionEnd: element.selectionEnd ?? 0, + }; +} + +function textInputValueAttributeApplier(): AttributeApplier { + return { + layoutDependent: true, + apply(element, value) { + if (isAttributedText(value)) { + element.value = ParsedAttributedText.parse(value).toString(); + return; + } + element.value = String(value); + }, + reset(element) { + element.value = ''; + }, + }; +} + +function setPlaceholderColor( + element: TextInputElement, + context: AttributeApplierContext, + color: string | undefined, +): void { + const className = `valdi-placeholder-${context.id}`; + element.classList.add(className); + if (!color) { + setApplierCleanup(context, 'textfield:placeholderColor', undefined); + return; + } + const style = document.createElement('style'); + style.textContent = `.${className}::placeholder { color: ${color}; opacity: 1; }`; + const root = element.getRootNode(); + if (typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot) { + root.appendChild(style); + } else { + document.head.appendChild(style); + } + setApplierCleanup(context, 'textfield:placeholderColor', () => { + style.remove(); + }); +} + +function getContentType(type: string): string { + switch (type) { + case 'phoneNumber': + return 'tel'; + case 'email': + return 'email'; + case 'password': + return 'password'; + case 'url': + return 'url'; + default: + return 'text'; + } +} + +function buildEditTextAttributeAppliers(labelElementClass: LabelElementClass): AttributeApplierMap { + const binder = new AttributesBinder(); + binder.bindFunctionAttribute( + 'onWillChange', + (element, callback, context) => { + replaceEventListener(element, context, 'textfield:onWillChange', 'beforeinput', event => { + if (callback(editEvent(element)) === false) { + event.preventDefault(); + } + }); + }, + (element, context) => replaceEventListener(element, context, 'textfield:onWillChange', 'beforeinput', undefined), + ); + binder.bindFunctionAttribute( + 'onChange', + (element, callback, context) => { + replaceEventListener(element, context, 'textfield:onChange', 'input', () => { + const event = editEvent(element); + context.onAttributeUpdatedExternally('value', event.text); + callback(event); + }); + }, + (element, context) => replaceEventListener(element, context, 'textfield:onChange', 'input', undefined), + ); + binder.bindFunctionAttribute( + 'onEditBegin', + (element, callback, context) => { + replaceEventListener(element, context, 'textfield:onEditBegin', 'focus', () => callback(editEvent(element))); + }, + (element, context) => replaceEventListener(element, context, 'textfield:onEditBegin', 'focus', undefined), + ); + binder.bindFunctionAttribute( + 'onEditEnd', + (element, callback, context) => { + replaceEventListener(element, context, 'textfield:onEditEnd', 'blur', () => + callback({ ...editEvent(element), reason: 'blur' }), + ); + }, + (element, context) => replaceEventListener(element, context, 'textfield:onEditEnd', 'blur', undefined), + ); + binder.bindFunctionAttribute( + 'onReturn', + (element, callback, context) => { + replaceEventListener(element, context, 'textfield:onReturn', 'keydown', event => { + if (event.key === 'Enter') { + callback(editEvent(element)); + } + }); + }, + (element, context) => replaceEventListener(element, context, 'textfield:onReturn', 'keydown', undefined), + ); + binder.bindFunctionAttribute( + 'onWillDelete', + (element, callback, context) => { + replaceEventListener(element, context, 'textfield:onWillDelete', 'keydown', event => { + if (event.key === 'Backspace' || event.key === 'Delete') { + callback(editEvent(element)); + } + }); + }, + (element, context) => replaceEventListener(element, context, 'textfield:onWillDelete', 'keydown', undefined), + ); + binder.bindFunctionAttribute( + 'onSelectionChange', + (element, callback, context) => { + const listener = () => { + if (getActiveElement(element) === element) { + callback(editEvent(element)); + } + }; + document.addEventListener('selectionchange', listener); + setApplierCleanup(context, 'textfield:onSelectionChange', () => { + document.removeEventListener('selectionchange', listener); + }); + }, + (_element, context) => setApplierCleanup(context, 'textfield:onSelectionChange', undefined), + ); + binder.bindColorAttribute( + 'tintColor', + (element, value) => { + element.style.caretColor = value; + }, + element => { + element.style.caretColor = ''; + }, + ); + binder.bindColorAttribute( + 'placeholderColor', + (element, value, context) => { + setPlaceholderColor(element, context, value); + }, + (element, context) => { + setPlaceholderColor(element, context, undefined); + }, + ); + binder.bindStringAttribute( + 'textGradient', + (element, value) => { + element.style.backgroundImage = value; + element.style.backgroundClip = 'text'; + element.style.webkitBackgroundClip = 'text'; + element.style.color = 'transparent'; + }, + element => { + element.style.backgroundImage = ''; + element.style.backgroundClip = ''; + element.style.webkitBackgroundClip = ''; + element.style.color = ''; + }, + ); + binder.bindStringAttribute( + 'textShadow', + (element, value, context) => { + const shadowCssValue = textShadowCssValue(value, context); + if (shadowCssValue !== undefined) { + element.style.textShadow = shadowCssValue; + } + }, + element => { + element.style.textShadow = ''; + }, + ); + binder.bindAttribute('value', textInputValueAttributeApplier()); + binder.bindStringAttribute( + 'placeholder', + (element, value) => { + element.placeholder = value; + }, + element => { + element.placeholder = ''; + }, + LAYOUT_DEPENDENT, + ); + binder.bindBooleanAttribute( + 'enabled', + (element, enabled) => { + element.disabled = !enabled; + }, + element => { + element.disabled = false; + }, + ); + binder.bindBooleanAttribute( + 'focused', + (element, focused) => { + if (focused) { + element.focus(); + } else { + element.blur(); + } + }, + element => { + element.blur(); + }, + ); + binder.bindBooleanAttribute( + 'selectTextOnFocus', + (element, value, context) => { + if (!value) { + replaceEventListener(element, context, 'textfield:selectTextOnFocus', 'focus', undefined); + return; + } + replaceEventListener(element, context, 'textfield:selectTextOnFocus', 'focus', () => { + element.select(); + }); + }, + (element, context) => replaceEventListener(element, context, 'textfield:selectTextOnFocus', 'focus', undefined), + ); + binder.bindBooleanAttribute( + 'closesWhenReturnKeyPressed', + (element, value, context) => { + if (!value) { + replaceEventListener(element, context, 'textfield:closesWhenReturnKeyPressed', 'keydown', undefined); + return; + } + replaceEventListener(element, context, 'textfield:closesWhenReturnKeyPressed', 'keydown', event => { + if (event.key === 'Enter') { + element.blur(); + } + }); + }, + (element, context) => + replaceEventListener(element, context, 'textfield:closesWhenReturnKeyPressed', 'keydown', undefined), + ); + binder.bindStringAttribute( + 'contentType', + (element, value) => { + element.setAttribute('type', getContentType(value)); + }, + element => { + element.setAttribute('type', 'text'); + }, + ); + binder.bindStringAttribute( + 'keyboardType', + (element, value) => { + element.setAttribute('inputmode', value); + }, + element => { + element.removeAttribute('inputmode'); + }, + ); + binder.bindStringAttribute( + 'keyboardAppearance', + (element, value) => { + element.style.colorScheme = value; + }, + element => { + element.style.colorScheme = ''; + }, + ); + binder.bindStringAttribute( + 'returnKeyType', + (element, value) => { + element.setAttribute('enterkeyhint', value); + }, + element => { + element.removeAttribute('enterkeyhint'); + }, + ); + binder.bindStringAttribute( + 'returnKeyText', + (element, value) => { + element.setAttribute('enterkeyhint', value); + }, + element => { + element.removeAttribute('enterkeyhint'); + }, + ); + binder.bindStringAttribute( + 'returnType', + (element, value) => { + element.setAttribute('enterkeyhint', value === 'linereturn' ? 'enter' : value); + }, + element => { + element.removeAttribute('enterkeyhint'); + }, + ); + binder.bindStringAttribute( + 'autocapitalization', + (element, value) => { + element.setAttribute('autocapitalize', value); + }, + element => { + element.removeAttribute('autocapitalize'); + }, + ); + binder.bindBooleanAttribute( + 'autocorrection', + (element, value) => { + element.setAttribute('autocorrect', value ? 'on' : 'off'); + }, + element => { + element.removeAttribute('autocorrect'); + }, + ); + binder.bindNumberAttribute( + 'characterLimit', + (element, value) => { + element.maxLength = value; + }, + element => { + element.removeAttribute('maxlength'); + }, + ); + binder.bindBooleanAttribute( + 'enableInlinePredictions', + (element, value) => { + element.setAttribute('autocomplete', value ? 'on' : 'off'); + }, + element => { + element.removeAttribute('autocomplete'); + }, + ); + binder.bindBooleanAttribute( + 'selectable', + (element, value) => { + element.style.userSelect = value === false ? 'none' : ''; + }, + element => { + element.style.userSelect = ''; + }, + ); + binder.bindNoOpAttribute('textGravity'); + binder.bindNoOpAttribute('backgroundEffectColor'); + binder.bindNoOpAttribute('backgroundEffectBorderRadius'); + binder.bindCssLengthStyleAttribute('backgroundEffectPadding', 'padding', LAYOUT_DEPENDENT); + binder.bindAttribute('selection', { + apply(element, value) { + if (!Array.isArray(value) || value.length !== 2) { + throw new Error('Expected selection to be a two item array'); + } + element.setSelectionRange(Number(value[0]), Number(value[1])); + }, + reset(element) { + element.setSelectionRange(0, 0); + }, + }); + return { + ...(labelElementClass.attributeAppliers as AttributeApplierMap), + ...binder.attributeAppliers, + }; +} + +export class TextFieldElementClass extends ElementClass { + constructor(labelElementClass: LabelElementClass) { + super('textfield', buildEditTextAttributeAppliers(labelElementClass), labelElementClass.compositeAttributes); + } + + protected onCreateElement(): TextInputElement { + const element = document.createElement('input'); + element.setAttribute('type', 'text'); + assignStyles(element, { + backgroundColor: 'transparent', + border: '0', + fontFamily: SYSTEM_FONT_FAMILY, + margin: 0, + padding: 0, + }); + return element; + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/TextViewElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/TextViewElementClass.ts new file mode 100644 index 000000000..556b73eb4 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/TextViewElementClass.ts @@ -0,0 +1,593 @@ +import { AttributesBinder } from '../attributes/AttributesBinder'; +import { parseCssLength, parseString } from '../attributes/AttributeApplierHelpers'; +import { AttributeApplier, AttributeApplierContext, CompositeAttribute, ElementClass } from '../core/ElementClass'; +import { + isAttributedText, + ParsedAttributedText, + registerAttributedTextLayouts, + renderAttributedText, + unregisterAttributedTextLayouts, +} from '../utils/parseAttributedText'; +import { registerTextAnimationParticipant, unregisterTextAnimationParticipant } from '../utils/TextAnimationRegistry'; +import { + assignStyles, + AttributeApplierMap, + getActiveElement, + replaceEventListener, + setApplierCleanup, + SYSTEM_FONT_FAMILY, +} from './ElementClassSupport'; +import { TextFieldElementClass } from './TextFieldElementClass'; + +type TextViewElement = HTMLDivElement & { + disabled?: boolean; + select?: () => void; + selectionStart?: number; + selectionEnd?: number; + setSelectionRange?: (selectionStart: number, selectionEnd: number) => void; + value?: string; +}; + +const TEXT_VIEW_STATE = '__textViewElementClassState'; +const LAYOUT_DEPENDENT = true; + +interface TextViewState { + backgroundEffectBorderRadius?: string; + backgroundEffectColor?: string; + backgroundEffectPadding?: string; + backgroundEffectPaddingPx?: number; + selectionEnd: number; + selectionStart: number; + value: unknown; +} + +function getTextViewState(element: TextViewElement, context: AttributeApplierContext): TextViewState { + let state = context.getState(TEXT_VIEW_STATE); + if (!state) { + state = { + selectionEnd: 0, + selectionStart: 0, + value: element.value ?? '', + }; + context.setState(TEXT_VIEW_STATE, state); + } + return state; +} + +function plainTextValue(value: unknown): string { + if (isAttributedText(value)) { + return ParsedAttributedText.parse(value).toString(); + } + return value === undefined || value === null ? '' : String(value); +} + +function textNodeLength(node: Node): number { + if (node.nodeType === 3) { + return node.textContent?.length ?? 0; + } + let length = 0; + for (let i = 0; i < node.childNodes.length; i++) { + length += textNodeLength(node.childNodes.item(i)!); + } + return length; +} + +function textOffsetForNode(root: Node, target: Node, targetOffset: number): number { + let offset = 0; + const visit = (node: Node): boolean => { + if (node === target) { + if (node.nodeType === 3) { + offset += Math.min(targetOffset, node.textContent?.length ?? 0); + } else { + for (let i = 0; i < Math.min(targetOffset, node.childNodes.length); i++) { + offset += textNodeLength(node.childNodes.item(i)!); + } + } + return true; + } + if (node.nodeType === 3) { + offset += node.textContent?.length ?? 0; + return false; + } + for (let i = 0; i < node.childNodes.length; i++) { + if (visit(node.childNodes.item(i)!)) { + return true; + } + } + return false; + }; + visit(root); + return offset; +} + +function findTextPosition(root: Node, targetOffset: number): { node: Node; offset: number } { + let remaining = Math.max(0, targetOffset); + let lastTextNode: Node | undefined; + const visit = (node: Node): { node: Node; offset: number } | undefined => { + if (node.nodeType === 3) { + lastTextNode = node; + const length = node.textContent?.length ?? 0; + if (remaining <= length) { + return { node, offset: remaining }; + } + remaining -= length; + return undefined; + } + for (let i = 0; i < node.childNodes.length; i++) { + const found = visit(node.childNodes.item(i)!); + if (found) { + return found; + } + } + return undefined; + }; + return visit(root) ?? { node: lastTextNode ?? root, offset: lastTextNode ? textNodeLength(lastTextNode) : 0 }; +} + +function applyTextViewSelection(element: TextViewElement, selectionStart: number, selectionEnd: number): void { + element.selectionStart = selectionStart; + element.selectionEnd = selectionEnd; + if (typeof document.createRange !== 'function' || typeof document.getSelection !== 'function') { + return; + } + if (!element.firstChild && typeof document.createTextNode === 'function') { + element.appendChild(document.createTextNode('')); + } + const start = findTextPosition(element, selectionStart); + const end = findTextPosition(element, selectionEnd); + const range = document.createRange(); + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +} + +function syncTextViewSelectionFromDom(element: TextViewElement, context: AttributeApplierContext): void { + if (typeof document.getSelection !== 'function') { + return; + } + const selection = document.getSelection(); + if (!selection || !selection.anchorNode || !selection.focusNode) { + return; + } + if (!element.contains(selection.anchorNode) || !element.contains(selection.focusNode)) { + return; + } + const anchorOffset = textOffsetForNode(element, selection.anchorNode, selection.anchorOffset); + const focusOffset = textOffsetForNode(element, selection.focusNode, selection.focusOffset); + const state = getTextViewState(element, context); + state.selectionStart = Math.min(anchorOffset, focusOffset); + state.selectionEnd = Math.max(anchorOffset, focusOffset); + element.selectionStart = state.selectionStart; + element.selectionEnd = state.selectionEnd; +} + +function syncTextViewValueFromDom(element: TextViewElement, context: AttributeApplierContext): string { + const text = element.textContent ?? ''; + const state = getTextViewState(element, context); + state.value = text; + element.value = text; + syncTextViewSelectionFromDom(element, context); + return text; +} + +function textViewEditEvent( + element: TextViewElement, + context: AttributeApplierContext, +): { text: string; selectionStart: number; selectionEnd: number } { + const text = syncTextViewValueFromDom(element, context); + const state = getTextViewState(element, context); + return { + text, + selectionStart: state.selectionStart, + selectionEnd: state.selectionEnd, + }; +} + +function applyBackgroundEffect(span: HTMLSpanElement, state: TextViewState): void { + if (!state.backgroundEffectColor) { + return; + } + const verticalPadding = backgroundEffectVerticalPadding(state); + const horizontalPadding = state.backgroundEffectPadding; + span.style.backgroundColor = state.backgroundEffectColor; + span.style.setProperty('box-decoration-break', 'clone'); + span.style.setProperty('-webkit-box-decoration-break', 'clone'); + if (verticalPadding || horizontalPadding) { + span.style.padding = `${verticalPadding ?? '0'} ${horizontalPadding ?? '0'}`; + } + if (horizontalPadding) { + span.style.marginLeft = `-${horizontalPadding}`; + span.style.marginRight = `-${horizontalPadding}`; + } + span.style.position = 'relative'; + if (state.backgroundEffectBorderRadius) { + span.style.borderRadius = state.backgroundEffectBorderRadius; + } +} + +function backgroundEffectVerticalPadding(state: TextViewState): string | undefined { + if (state.backgroundEffectPaddingPx === undefined) { + return undefined; + } + return `${state.backgroundEffectPaddingPx / 2}px`; +} + +function wrapBackgroundEffectContent(content: HTMLElement, state: TextViewState): HTMLElement { + const verticalPadding = backgroundEffectVerticalPadding(state); + const wrapper = document.createElement('span'); + wrapper.style.display = 'block'; + wrapper.style.position = 'relative'; + wrapper.style.whiteSpace = 'inherit'; + wrapper.style.width = '100%'; + if (state.backgroundEffectPadding || verticalPadding) { + wrapper.style.padding = `${verticalPadding ?? '0'} ${state.backgroundEffectPadding ?? '0'}`; + } + wrapper.appendChild(content); + return wrapper; +} + +function renderTextViewContent( + element: TextViewElement, + context: AttributeApplierContext, + attributeName: string, +): void { + const state = getTextViewState(element, context); + const parsedAttributedText = isAttributedText(state.value) ? ParsedAttributedText.parse(state.value) : undefined; + const text = parsedAttributedText ? parsedAttributedText.toString() : plainTextValue(state.value); + element.value = text; + element.replaceChildren(); + + if (parsedAttributedText) { + const container = renderAttributedText(parsedAttributedText, context); + applyBackgroundEffect(container, state); + const renderedContent = state.backgroundEffectColor ? wrapBackgroundEffectContent(container, state) : container; + element.appendChild(renderedContent); + registerTextAnimationParticipant(element, container, context); + registerAttributedTextLayouts(context, attributeName, parsedAttributedText, container, renderedContent); + return; + } + + unregisterTextAnimationParticipant(context); + unregisterAttributedTextLayouts(context, attributeName); + if (state.backgroundEffectColor) { + const span = document.createElement('span'); + span.textContent = text; + applyBackgroundEffect(span, state); + element.appendChild(wrapBackgroundEffectContent(span, state)); + return; + } + + element.textContent = text; +} + +const textViewContentComposite: CompositeAttribute = { + name: 'textViewContent', + parts: [ + { name: 'value', optional: true, colorDependent: true, layoutDependent: true }, + { + name: 'backgroundEffectBorderRadius', + optional: true, + parse: (_element, value, name) => parseCssLength(value, name), + }, + { + name: 'backgroundEffectColor', + optional: true, + colorDependent: true, + parse: (_element, value, name, context) => context.resolveColor(parseString(value, name)), + }, + { + name: 'backgroundEffectPadding', + optional: true, + layoutDependent: true, + parse: (_element, value, name) => parseCssLength(value, name), + }, + ], + apply(element, values, attributeName, context) { + const state = getTextViewState(element, context); + state.value = values[0] ?? ''; + state.backgroundEffectBorderRadius = values[1] as string | undefined; + state.backgroundEffectColor = values[2] as string | undefined; + state.backgroundEffectPadding = values[3] as string | undefined; + const numericPadding = Number.parseFloat(state.backgroundEffectPadding ?? ''); + state.backgroundEffectPaddingPx = Number.isFinite(numericPadding) ? numericPadding : undefined; + renderTextViewContent(element, context, attributeName); + }, + reset(element, attributeName, context) { + const state = getTextViewState(element, context); + state.value = ''; + state.backgroundEffectBorderRadius = undefined; + state.backgroundEffectColor = undefined; + state.backgroundEffectPadding = undefined; + state.backgroundEffectPaddingPx = undefined; + renderTextViewContent(element, context, attributeName); + }, +}; + +function enabledAttributeApplier(): AttributeApplier { + const binder = new AttributesBinder(); + binder.bindBooleanAttribute( + 'enabled', + (element, enabled) => { + element.disabled = !enabled; + element.setAttribute('aria-disabled', String(!enabled)); + element.contentEditable = enabled ? 'plaintext-only' : 'false'; + }, + element => { + element.disabled = false; + element.removeAttribute('aria-disabled'); + element.contentEditable = 'false'; + }, + ); + return binder.attributeAppliers.enabled; +} + +function selectableAttributeApplier(): AttributeApplier { + const binder = new AttributesBinder(); + binder.bindBooleanAttribute( + 'selectable', + (element, selectable) => { + element.style.userSelect = selectable === false ? 'none' : 'text'; + }, + element => { + element.style.userSelect = 'text'; + }, + ); + return binder.attributeAppliers.selectable; +} + +function focusedAttributeApplier(): AttributeApplier { + const binder = new AttributesBinder(); + binder.bindBooleanAttribute( + 'focused', + (element, focused) => { + if (focused) { + element.focus(); + } else { + element.blur(); + } + }, + element => { + element.blur(); + }, + ); + return binder.attributeAppliers.focused; +} + +function selectTextOnFocusAttributeApplier(): AttributeApplier { + const binder = new AttributesBinder(); + binder.bindBooleanAttribute( + 'selectTextOnFocus', + (element, value, context) => { + if (!value) { + replaceEventListener(element, context, 'textview:selectTextOnFocus', 'focus', undefined); + return; + } + replaceEventListener(element, context, 'textview:selectTextOnFocus', 'focus', () => { + element.select?.(); + }); + }, + (element, context) => replaceEventListener(element, context, 'textview:selectTextOnFocus', 'focus', undefined), + ); + return binder.attributeAppliers.selectTextOnFocus; +} + +function selectionAttributeApplier(): AttributeApplier { + return { + apply(element, value, _attributeName, context) { + if (!Array.isArray(value) || value.length !== 2) { + throw new Error('Expected selection to be a two item array'); + } + applyTextViewSelection(element, Number(value[0]), Number(value[1])); + const state = getTextViewState(element, context); + state.selectionStart = Number(value[0]); + state.selectionEnd = Number(value[1]); + }, + reset(element, _attributeName, context) { + applyTextViewSelection(element, 0, 0); + const state = getTextViewState(element, context); + state.selectionStart = 0; + state.selectionEnd = 0; + }, + }; +} + +function textGravityAttributeApplier(): AttributeApplier { + const binder = new AttributesBinder(); + binder.bindStringAttribute( + 'textGravity', + (element, value) => { + element.style.alignContent = value === 'bottom' ? 'end' : value; + }, + element => { + element.style.alignContent = ''; + }, + ); + return binder.attributeAppliers.textGravity; +} + +function onSelectionChangeAttributeApplier(): AttributeApplier { + return { + apply(element, value, attributeName, context) { + if (typeof value !== 'function') { + throw new Error(`Expected '${attributeName}' to be a function`); + } + const listener = () => { + if (getActiveElement(element) !== element) { + return; + } + syncTextViewSelectionFromDom(element, context); + const state = getTextViewState(element, context); + value({ + selectionEnd: state.selectionEnd, + selectionStart: state.selectionStart, + text: plainTextValue(state.value), + }); + }; + document.addEventListener('selectionchange', listener); + setApplierCleanup(context, 'textview:onSelectionChange', () => { + document.removeEventListener('selectionchange', listener); + }); + }, + reset(_element, _attributeName, context) { + setApplierCleanup(context, 'textview:onSelectionChange', undefined); + }, + }; +} + +function bindTextViewEventAttributes(binder: AttributesBinder): void { + binder.bindFunctionAttribute( + 'onWillChange', + (element, callback, context) => { + replaceEventListener(element, context, 'textview:onWillChange', 'beforeinput', event => { + if (callback(textViewEditEvent(element, context)) === false) { + event.preventDefault(); + } + }); + }, + (element, context) => replaceEventListener(element, context, 'textview:onWillChange', 'beforeinput', undefined), + ); + binder.bindFunctionAttribute( + 'onChange', + (element, callback, context) => { + replaceEventListener(element, context, 'textview:onChange', 'input', () => { + const event = textViewEditEvent(element, context); + context.onAttributeUpdatedExternally('value', event.text); + callback(event); + }); + }, + (element, context) => replaceEventListener(element, context, 'textview:onChange', 'input', undefined), + ); + binder.bindFunctionAttribute( + 'onEditBegin', + (element, callback, context) => { + replaceEventListener(element, context, 'textview:onEditBegin', 'focus', () => + callback(textViewEditEvent(element, context)), + ); + }, + (element, context) => replaceEventListener(element, context, 'textview:onEditBegin', 'focus', undefined), + ); + binder.bindFunctionAttribute( + 'onEditEnd', + (element, callback, context) => { + replaceEventListener(element, context, 'textview:onEditEnd', 'blur', () => + callback({ ...textViewEditEvent(element, context), reason: 'blur' }), + ); + }, + (element, context) => replaceEventListener(element, context, 'textview:onEditEnd', 'blur', undefined), + ); + binder.bindFunctionAttribute( + 'onReturn', + (element, callback, context) => { + replaceEventListener(element, context, 'textview:onReturn', 'keydown', event => { + if (event.key === 'Enter') { + callback(textViewEditEvent(element, context)); + } + }); + }, + (element, context) => replaceEventListener(element, context, 'textview:onReturn', 'keydown', undefined), + ); + binder.bindFunctionAttribute( + 'onWillDelete', + (element, callback, context) => { + replaceEventListener(element, context, 'textview:onWillDelete', 'keydown', event => { + if (event.key === 'Backspace' || event.key === 'Delete') { + callback(textViewEditEvent(element, context)); + } + }); + }, + (element, context) => replaceEventListener(element, context, 'textview:onWillDelete', 'keydown', undefined), + ); +} + +function numberOfLinesAttributeApplier(): AttributeApplier { + const binder = new AttributesBinder(); + binder.bindNumberAttribute( + 'numberOfLines', + (element, value) => { + if (value <= 0) { + element.style.removeProperty('-webkit-line-clamp'); + element.style.removeProperty('-webkit-box-orient'); + element.style.display = ''; + element.style.overflow = 'hidden'; + return; + } + element.style.display = '-webkit-box'; + element.style.overflow = 'hidden'; + element.style.setProperty('-webkit-line-clamp', String(value)); + element.style.setProperty('-webkit-box-orient', 'vertical'); + }, + element => { + element.style.removeProperty('-webkit-line-clamp'); + element.style.removeProperty('-webkit-box-orient'); + element.style.display = ''; + element.style.overflow = 'hidden'; + }, + LAYOUT_DEPENDENT, + ); + return binder.attributeAppliers.numberOfLines; +} + +function buildTextViewAttributeAppliers( + textFieldElementClass: TextFieldElementClass, +): AttributeApplierMap { + const binder = new AttributesBinder(); + bindTextViewEventAttributes(binder); + binder.bindNoOpAttribute('contentType'); + binder.bindAttribute('enabled', enabledAttributeApplier()); + binder.bindAttribute('focused', focusedAttributeApplier()); + binder.bindNoOpAttribute('keyboardAppearance'); + binder.bindNoOpAttribute('keyboardType'); + binder.bindAttribute('numberOfLines', numberOfLinesAttributeApplier()); + binder.bindAttribute('onSelectionChange', onSelectionChangeAttributeApplier()); + binder.bindNoOpAttribute('placeholder'); + binder.bindNoOpAttribute('placeholderColor'); + binder.bindNoOpAttribute('returnKeyText'); + binder.bindNoOpAttribute('returnKeyType'); + binder.bindNoOpAttribute('returnType'); + binder.bindAttribute('selectable', selectableAttributeApplier()); + binder.bindAttribute('selectTextOnFocus', selectTextOnFocusAttributeApplier()); + binder.bindAttribute('selection', selectionAttributeApplier()); + binder.bindAttribute('textGravity', textGravityAttributeApplier()); + return { + ...(textFieldElementClass.attributeAppliers as AttributeApplierMap), + ...binder.attributeAppliers, + }; +} + +export class TextViewElementClass extends ElementClass { + constructor(textFieldElementClass: TextFieldElementClass) { + super('textview', buildTextViewAttributeAppliers(textFieldElementClass), { + ...textFieldElementClass.compositeAttributes, + [textViewContentComposite.name]: textViewContentComposite, + } as any); + } + + createElement(_id: number, _viewClass: string): TextViewElement { + return this.onCreateElement(); + } + + protected onCreateElement(): TextViewElement { + const element = document.createElement('div') as TextViewElement; + assignStyles(element, { + fontFamily: SYSTEM_FONT_FAMILY, + overflow: 'hidden', + whiteSpace: 'pre-wrap', + wordBreak: 'normal', + wordWrap: 'break-word', + }); + element.tabIndex = -1; + element.contentEditable = 'false'; + element.value = ''; + element.selectionStart = 0; + element.selectionEnd = 0; + element.setSelectionRange = (selectionStart: number, selectionEnd: number) => { + applyTextViewSelection(element, selectionStart, selectionEnd); + }; + element.select = () => { + applyTextViewSelection(element, 0, element.textContent?.length ?? 0); + }; + return element; + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/VideoElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/VideoElementClass.ts new file mode 100644 index 000000000..f262d1c9e --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/VideoElementClass.ts @@ -0,0 +1,122 @@ +import { AttributesBinder } from '../attributes/AttributesBinder'; +import { ElementClass } from '../core/ElementClass'; +import { + assignStyles, + AttributeApplierMap, + replaceEventListener, + setApplierCleanup, + srcAttributeApplier, +} from './ElementClassSupport'; +import { ViewElementClass } from './ViewElementClass'; + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function buildVideoAttributeAppliers(viewElementClass: ViewElementClass): AttributeApplierMap { + const binder = new AttributesBinder(); + binder.bindAttribute('src', srcAttributeApplier()); + binder.bindNumberAttribute( + 'volume', + (element, value) => { + element.volume = Math.max(0, Math.min(1, value)); + }, + element => { + element.volume = 1; + }, + ); + binder.bindNumberAttribute( + 'playbackRate', + (element, value, context) => { + element.playbackRate = value <= 0 ? 1 : value; + if (value > 0) { + element.play().catch(error => { + console.error( + `Valdi web renderer failed to play video on node ${context.id} (video) with playbackRate ${value}: ${stringifyError(error)}`, + ); + }); + } else { + element.pause(); + } + }, + element => { + element.playbackRate = 1; + element.pause(); + }, + ); + binder.bindNumberAttribute( + 'seekToTime', + (element, value) => { + element.currentTime = value / 1000; + }, + element => { + element.currentTime = 0; + }, + ); + binder.bindFunctionAttribute( + 'onVideoLoaded', + (element, callback, context) => { + replaceEventListener(element, context, 'video:onVideoLoaded', 'loadedmetadata', () => { + callback(Math.round(element.duration * 1000)); + }); + }, + (element, context) => replaceEventListener(element, context, 'video:onVideoLoaded', 'loadedmetadata', undefined), + ); + binder.bindFunctionAttribute( + 'onBeginPlaying', + (element, callback, context) => { + replaceEventListener(element, context, 'video:onBeginPlaying', 'play', () => callback()); + }, + (element, context) => replaceEventListener(element, context, 'video:onBeginPlaying', 'play', undefined), + ); + binder.bindFunctionAttribute( + 'onError', + (element, callback, context) => { + replaceEventListener(element, context, 'video:onError', 'error', () => { + callback(element.error?.message ?? 'Unknown error'); + }); + }, + (element, context) => replaceEventListener(element, context, 'video:onError', 'error', undefined), + ); + binder.bindFunctionAttribute( + 'onCompleted', + (element, callback, context) => { + replaceEventListener(element, context, 'video:onCompleted', 'ended', () => callback()); + }, + (element, context) => replaceEventListener(element, context, 'video:onCompleted', 'ended', undefined), + ); + binder.bindFunctionAttribute( + 'onProgressUpdated', + (element, callback, context) => { + const interval = window.setInterval(() => { + if (element.duration) { + callback(Math.round(element.currentTime * 1000), Math.round(element.duration * 1000)); + } + }, 250); + setApplierCleanup(context, 'video:onProgressUpdated', () => { + clearInterval(interval); + }); + }, + (_element, context) => setApplierCleanup(context, 'video:onProgressUpdated', undefined), + ); + return { + ...(viewElementClass.attributeAppliers as AttributeApplierMap), + ...binder.attributeAppliers, + }; +} + +export class VideoElementClass extends ElementClass { + constructor(viewElementClass: ViewElementClass) { + super('video', buildVideoAttributeAppliers(viewElementClass), viewElementClass.compositeAttributes); + } + + protected onCreateElement(): HTMLVideoElement { + const element = document.createElement('video'); + assignStyles(element, { + display: 'block', + objectFit: 'contain', + position: 'relative', + }); + return element; + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/ViewElementAttributes.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/ViewElementAttributes.ts new file mode 100644 index 000000000..4922f2e0c --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/ViewElementAttributes.ts @@ -0,0 +1,889 @@ +import { + AttributesBinder, + MIN_VISIBLE_CHANGE_ALPHA, + MIN_VISIBLE_CHANGE_COLOR, + MIN_VISIBLE_CHANGE_PIXEL, +} from '../attributes/AttributesBinder'; +import { parseCssLength, parseNumber, resolveValdiGradientAngles } from '../attributes/AttributeApplierHelpers'; +import { createBorderRadiusAttributeApplier } from '../attributes/BorderRadiusAttribute'; +import type { AttributeApplier, AttributeApplierContext, CompositeAttribute } from '../core/ElementClass'; +import { TouchEventState } from 'valdi_tsx/src/GestureEvents'; +import { geometricPathToSvgPath, isGeometricPathValue, SvgGeometricPath } from '../utils/geometricPath'; +import { isPlainCssNumber, readWhitespaceSeparatedToken, skipCssWhitespace } from '../utils/cssScanner'; +import { injectTouchAreaStyles } from '../styles/touchAreaExtension'; +import { AttributeApplierMap, borderAttributeApplier, replaceEventListener } from './ElementClassSupport'; +import { getViewPaintElement, getViewPresentationState } from './ViewElementState'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; +const VIEW_INTERACTION_STATE = '__viewElementClassInteractionState'; +const VIEW_MASK_STATE = '__viewElementClassMaskState'; +const VIEW_BORDER_STYLE_STATE = '__viewElementClassBorderStyle'; + +interface ViewInteractionState { + longPressDuration: number; + onTapDisabled: boolean; + onDoubleTapDisabled: boolean; + onLongPressDisabled: boolean; + onDragDisabled: boolean; + onTap?: (event: unknown) => void; + onTapPredicate?: (event: unknown) => boolean; + onDoubleTap?: (event: unknown) => void; + onDoubleTapPredicate?: (event: unknown) => boolean; + onLongPress?: (event: unknown) => void; + onLongPressPredicate?: (event: unknown) => boolean; + onDrag?: (event: unknown) => void; + onDragPredicate?: (event: unknown) => boolean; + longPressTimer?: number; + gestureListenersInstalled?: boolean; + touchAreaExtension?: { top: number; right: number; bottom: number; left: number }; +} + +interface ViewMaskState { + maskOpacity: number; + path?: SvgGeometricPath; +} + +function getViewInteractionState(context: AttributeApplierContext): ViewInteractionState { + const existing = context.getState(VIEW_INTERACTION_STATE); + if (existing) { + return existing; + } + const state: ViewInteractionState = { + longPressDuration: 500, + onTapDisabled: false, + onDoubleTapDisabled: false, + onLongPressDisabled: false, + onDragDisabled: false, + }; + context.setState(VIEW_INTERACTION_STATE, state); + return state; +} + +function getViewMaskState(context: AttributeApplierContext): ViewMaskState { + const existing = context.getState(VIEW_MASK_STATE); + if (existing) { + return existing; + } + const state: ViewMaskState = { maskOpacity: 1 }; + context.setState(VIEW_MASK_STATE, state); + return state; +} + +function escapeSvgAttribute(value: string): string { + return value.split('&').join('&').split('"').join('"').split('<').join('<'); +} + +function clampMaskOpacity(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function clearMask(element: HTMLElement): void { + element.style.removeProperty('mask-image'); + element.style.removeProperty('mask-mode'); + element.style.removeProperty('mask-position'); + element.style.removeProperty('mask-repeat'); + element.style.removeProperty('mask-size'); + element.style.removeProperty('-webkit-mask-image'); + element.style.removeProperty('-webkit-mask-position'); + element.style.removeProperty('-webkit-mask-repeat'); + element.style.removeProperty('-webkit-mask-size'); + element.style.removeProperty('-webkit-mask-source-type'); +} + +function maskDataUrl(path: SvgGeometricPath, maskOpacity: number): string { + const svg = ``; + return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; +} + +function updateMask(element: HTMLElement, context: AttributeApplierContext): void { + const state = getViewMaskState(context); + if (!state.path || !state.path.d) { + clearMask(element); + return; + } + const image = maskDataUrl(state.path, state.maskOpacity); + element.style.setProperty('mask-image', image); + element.style.setProperty('mask-mode', 'luminance'); + element.style.setProperty('mask-position', 'center'); + element.style.setProperty('mask-repeat', 'no-repeat'); + element.style.setProperty('mask-size', '100% 100%'); + element.style.setProperty('-webkit-mask-image', image); + element.style.setProperty('-webkit-mask-position', 'center'); + element.style.setProperty('-webkit-mask-repeat', 'no-repeat'); + element.style.setProperty('-webkit-mask-size', '100% 100%'); + element.style.setProperty('-webkit-mask-source-type', 'luminance'); +} + +function createTouchEvent(event: MouseEvent | TouchEvent, state: TouchEventState): unknown { + const touch = 'touches' in event ? event.touches[0] || event.changedTouches[0] : event; + return { + state, + x: touch.clientX, + y: touch.clientY, + absoluteX: touch.clientX, + absoluteY: touch.clientY, + pointerCount: 'touches' in event ? event.touches.length : 1, + pointerLocations: [], + }; +} + +function runTouchCallback( + callback: ((event: unknown) => void) | undefined, + predicate: ((event: unknown) => boolean) | undefined, + event: MouseEvent | TouchEvent, + state: TouchEventState, +): void { + if (!callback) { + return; + } + const touchEvent = createTouchEvent(event, state); + if (!predicate || predicate(touchEvent)) { + callback(touchEvent); + } +} + +function ensureGestureListeners(element: HTMLElement, context: AttributeApplierContext): void { + const state = getViewInteractionState(context); + if (state.gestureListenersInstalled) { + return; + } + state.gestureListenersInstalled = true; + const clickListener = (event: MouseEvent) => { + if (!state.onTapDisabled) { + runTouchCallback(state.onTap, state.onTapPredicate, event, TouchEventState.Ended); + } + }; + const doubleClickListener = (event: MouseEvent) => { + if (!state.onDoubleTapDisabled) { + runTouchCallback(state.onDoubleTap, state.onDoubleTapPredicate, event, TouchEventState.Ended); + } + }; + const startLongPress = (event: MouseEvent | TouchEvent) => { + if (state.onLongPressDisabled || !state.onLongPress) { + return; + } + state.longPressTimer = window.setTimeout(() => { + runTouchCallback(state.onLongPress, state.onLongPressPredicate, event, TouchEventState.Started); + }, state.longPressDuration); + }; + const cancelLongPress = () => { + if (state.longPressTimer !== undefined) { + clearTimeout(state.longPressTimer); + state.longPressTimer = undefined; + } + }; + const dragListener = (event: MouseEvent | TouchEvent) => { + if (event instanceof MouseEvent && event.buttons !== 1) { + return; + } + if (!state.onDragDisabled) { + runTouchCallback(state.onDrag, state.onDragPredicate, event, TouchEventState.Changed); + } + }; + element.addEventListener('click', clickListener); + element.addEventListener('dblclick', doubleClickListener); + element.addEventListener('mousedown', startLongPress); + element.addEventListener('touchstart', startLongPress); + element.addEventListener('mouseup', cancelLongPress); + element.addEventListener('mouseleave', cancelLongPress); + element.addEventListener('touchend', cancelLongPress); + element.addEventListener('touchcancel', cancelLongPress); + element.addEventListener('touchmove', cancelLongPress); + element.addEventListener('mousemove', dragListener); + element.addEventListener('touchmove', dragListener); + context.addCleanup(() => { + element.removeEventListener('click', clickListener); + element.removeEventListener('dblclick', doubleClickListener); + element.removeEventListener('mousedown', startLongPress); + element.removeEventListener('touchstart', startLongPress); + element.removeEventListener('mouseup', cancelLongPress); + element.removeEventListener('mouseleave', cancelLongPress); + element.removeEventListener('touchend', cancelLongPress); + element.removeEventListener('touchcancel', cancelLongPress); + element.removeEventListener('touchmove', cancelLongPress); + element.removeEventListener('mousemove', dragListener); + element.removeEventListener('touchmove', dragListener); + cancelLongPress(); + }); +} + +function installTouchAreaStyles(element: HTMLElement): void { + const rootNode = element.getRootNode(); + if (typeof ShadowRoot !== 'undefined' && rootNode instanceof ShadowRoot) { + injectTouchAreaStyles(rootNode); + } else if (typeof Document !== 'undefined' && rootNode instanceof Document) { + injectTouchAreaStyles(rootNode); + } +} + +function updateTouchAreaExtension(element: HTMLElement, context: AttributeApplierContext): void { + const state = getViewInteractionState(context); + const extension = state.touchAreaExtension ?? { top: 0, right: 0, bottom: 0, left: 0 }; + const hasExtension = extension.top > 0 || extension.right > 0 || extension.bottom > 0 || extension.left > 0; + if (hasExtension) { + installTouchAreaStyles(element); + element.setAttribute('data-touch-ext', ''); + element.style.setProperty('--touch-ext-top', `${extension.top}px`); + element.style.setProperty('--touch-ext-right', `${extension.right}px`); + element.style.setProperty('--touch-ext-bottom', `${extension.bottom}px`); + element.style.setProperty('--touch-ext-left', `${extension.left}px`); + } else { + element.removeAttribute('data-touch-ext'); + element.style.removeProperty('--touch-ext-top'); + element.style.removeProperty('--touch-ext-right'); + element.style.removeProperty('--touch-ext-bottom'); + element.style.removeProperty('--touch-ext-left'); + } +} + +function setTouchAreaExtensionPart( + element: HTMLElement, + context: AttributeApplierContext, + part: 'top' | 'right' | 'bottom' | 'left', + value: number, +): void { + const state = getViewInteractionState(context); + state.touchAreaExtension ??= { top: 0, right: 0, bottom: 0, left: 0 }; + state.touchAreaExtension[part] = value; + updateTouchAreaExtension(element, context); +} + +function normalizeValdiBoxShadow(value: string): string { + const source = value.trim(); + if (!source) { + return ''; + } + const firstToken = readWhitespaceSeparatedToken(source, 0); + if (!firstToken) { + return ''; + } + const offsetXToken = + firstToken.token === 'complex' ? readWhitespaceSeparatedToken(source, firstToken.nextIndex) : firstToken; + if (!offsetXToken) { + return source; + } + const offsetYToken = readWhitespaceSeparatedToken(source, offsetXToken.nextIndex); + const blurToken = offsetYToken ? readWhitespaceSeparatedToken(source, offsetYToken.nextIndex) : undefined; + if (!offsetYToken || !blurToken) { + return source; + } + const colorStartIndex = skipCssWhitespace(source, blurToken.nextIndex); + if ( + colorStartIndex >= source.length || + !isPlainCssNumber(offsetXToken.token) || + !isPlainCssNumber(offsetYToken.token) || + !isPlainCssNumber(blurToken.token) + ) { + return source; + } + return `${Number(offsetXToken.token)}px ${Number(offsetYToken.token)}px ${Number(blurToken.token)}px ${source.slice(colorStartIndex)}`; +} + +function updateViewBoxShadow(context: AttributeApplierContext): void { + const state = getViewPresentationState(context); + if (state.boxShadowElement) { + state.boxShadowElement.style.boxShadow = + state.boxShadow && !state.slowClipping ? normalizeValdiBoxShadow(state.boxShadow) : ''; + } +} + +function updateViewOverflow(element: HTMLElement, context: AttributeApplierContext): void { + const state = getViewPresentationState(context); + element.style.overflow = state.slowClipping ? 'hidden' : (state.overflow ?? 'visible'); + if (getViewPaintElement(context)) { + element.style.borderRadius = state.slowClipping ? (state.borderRadiusCss ?? '') : ''; + } +} + +function boxShadowAttributeApplier(): AttributeApplier { + return { + apply(_element, value, attributeName, context) { + if (typeof value !== 'string') { + throw new Error(`Expected '${attributeName}' to be a string`); + } + const state = getViewPresentationState(context); + state.boxShadow = value; + state.boxShadowElement = context.getViewAttributeElement(); + updateViewBoxShadow(context); + }, + reset(_element, _attributeName, context) { + const state = getViewPresentationState(context); + state.boxShadow = undefined; + updateViewBoxShadow(context); + }, + }; +} + +type ResolvedTranslationUnit = 'px' | '%'; + +interface ResolvedTranslation { + value: number; + unit: ResolvedTranslationUnit; +} + +interface ResolvedTransform { + transformOrigin: string | undefined; + transform: string | undefined; + translationX: ResolvedTranslation; + translationY: ResolvedTranslation; + scaleX: number; + scaleY: number; + rotation: number; +} + +function resolveTranslation(value: unknown): ResolvedTranslation { + if (value === undefined || value === null) { + return { value: 0, unit: 'px' }; + } + if (typeof value === 'number') { + if (Number.isFinite(value)) { + return { value, unit: 'px' }; + } + } else if (typeof value === 'string') { + let number = value.trim(); + let unit: ResolvedTranslationUnit = 'px'; + if (number.endsWith('%')) { + number = number.slice(0, -1); + unit = '%'; + } else if (number.endsWith('px') || number.endsWith('pt')) { + number = number.slice(0, -2); + } + const parsedValue = Number(number); + if (number.length > 0 && Number.isFinite(parsedValue)) { + return { value: parsedValue, unit }; + } + } + throw new Error('Expected translation to use a unitless, px, pt, or percent dimension'); +} + +function optionalNumber(value: unknown, fallback: number): number { + return value === undefined || value === null ? fallback : parseNumber(value, 'transform'); +} + +function resolveTransform(values: ReadonlyArray): ResolvedTransform { + return { + transformOrigin: typeof values[0] === 'string' ? values[0] : undefined, + transform: typeof values[1] === 'string' && values[1].length > 0 ? values[1] : undefined, + translationX: resolveTranslation(values[2]), + translationY: resolveTranslation(values[3]), + scaleX: optionalNumber(values[4], 1), + scaleY: optionalNumber(values[5], 1), + rotation: optionalNumber(values[6], 0), + }; +} + +function translationToCss(translation: ResolvedTranslation): string { + return `${translation.value}${translation.unit}`; +} + +function resolvedTransformToValues(transform: ResolvedTransform): ReadonlyArray { + return [ + transform.transformOrigin, + transform.transform, + translationToCss(transform.translationX), + translationToCss(transform.translationY), + transform.scaleX, + transform.scaleY, + transform.rotation, + ]; +} + +function applyResolvedTransform(element: HTMLElement, transform: ResolvedTransform): void { + element.style.transformOrigin = transform.transformOrigin ?? ''; + if (transform.transform) { + element.style.transform = transform.transform; + return; + } + const parts: string[] = []; + if (transform.translationX.value !== 0 || transform.translationY.value !== 0) { + parts.push(`translate(${translationToCss(transform.translationX)}, ${translationToCss(transform.translationY)})`); + } + if (transform.scaleX !== 1 || transform.scaleY !== 1) { + parts.push(`scale(${transform.scaleX}, ${transform.scaleY})`); + } + if (transform.rotation !== 0) { + parts.push(`rotate(${transform.rotation}rad)`); + } + element.style.transform = parts.join(' '); +} + +function makeTranslationInterpolator( + from: ResolvedTranslation, + to: ResolvedTranslation, +): ((progress: number) => ResolvedTranslation) | undefined { + let unit = from.unit; + if (from.unit !== to.unit) { + if (from.value === 0) { + unit = to.unit; + } else if (to.value !== 0) { + return undefined; + } + } + return progress => ({ value: from.value + (to.value - from.value) * progress, unit }); +} + +function hasInterpolatedTransformChange(source: ResolvedTransform, target: ResolvedTransform): boolean { + return ( + source.translationX.value !== target.translationX.value || + source.translationY.value !== target.translationY.value || + source.scaleX !== target.scaleX || + source.scaleY !== target.scaleY || + source.rotation !== target.rotation + ); +} + +const transformComposite: CompositeAttribute = { + name: 'transformComposite', + parts: [ + { name: 'transformOrigin', optional: true }, + { name: 'transform', optional: true }, + { name: 'translationX', optional: true }, + { name: 'translationY', optional: true }, + { name: 'scaleX', optional: true, parse: (_element, value, name) => parseNumber(value, name) }, + { name: 'scaleY', optional: true, parse: (_element, value, name) => parseNumber(value, name) }, + { name: 'rotation', optional: true, parse: (_element, value, name) => parseNumber(value, name) }, + ], + apply(element, values) { + applyResolvedTransform(element, resolveTransform(values)); + }, + reset(element) { + element.style.transform = ''; + element.style.transformOrigin = ''; + }, + animationMinimumVisibleChange: MIN_VISIBLE_CHANGE_PIXEL, + makeAnimationInterpolator(_element, from, to) { + if (to !== undefined && to !== null && !Array.isArray(to)) { + return undefined; + } + const source = resolveTransform(Array.isArray(from) ? from : []); + const target = resolveTransform(Array.isArray(to) ? to : []); + if (source.transform !== target.transform || source.transform || target.transform) { + return undefined; + } + if (!hasInterpolatedTransformChange(source, target)) { + return undefined; + } + const translationX = makeTranslationInterpolator(source.translationX, target.translationX); + const translationY = makeTranslationInterpolator(source.translationY, target.translationY); + if (!translationX || !translationY) { + return undefined; + } + return progress => + resolvedTransformToValues({ + transformOrigin: target.transformOrigin, + transform: target.transform, + translationX: translationX(progress), + translationY: translationY(progress), + scaleX: source.scaleX + (target.scaleX - source.scaleX) * progress, + scaleY: source.scaleY + (target.scaleY - source.scaleY) * progress, + rotation: source.rotation + (target.rotation - source.rotation) * progress, + }); + }, +}; + +export function buildViewAttributeAppliers(): AttributeApplierMap { + const binder = new AttributesBinder(); + binder.bindNumberAttribute( + 'touchAreaExtension', + (element, value, context) => { + getViewInteractionState(context).touchAreaExtension = { + top: value, + right: value, + bottom: value, + left: value, + }; + updateTouchAreaExtension(element, context); + }, + (element, context) => { + getViewInteractionState(context).touchAreaExtension = { top: 0, right: 0, bottom: 0, left: 0 }; + updateTouchAreaExtension(element, context); + }, + ); + binder.bindNumberAttribute( + 'touchAreaExtensionTop', + (element, value, context) => setTouchAreaExtensionPart(element, context, 'top', value), + (element, context) => setTouchAreaExtensionPart(element, context, 'top', 0), + ); + binder.bindNumberAttribute( + 'touchAreaExtensionRight', + (element, value, context) => setTouchAreaExtensionPart(element, context, 'right', value), + (element, context) => setTouchAreaExtensionPart(element, context, 'right', 0), + ); + binder.bindNumberAttribute( + 'touchAreaExtensionBottom', + (element, value, context) => setTouchAreaExtensionPart(element, context, 'bottom', value), + (element, context) => setTouchAreaExtensionPart(element, context, 'bottom', 0), + ); + binder.bindNumberAttribute( + 'touchAreaExtensionLeft', + (element, value, context) => setTouchAreaExtensionPart(element, context, 'left', value), + (element, context) => setTouchAreaExtensionPart(element, context, 'left', 0), + ); + + binder.bindColorAttribute( + 'background', + (_element, value, context) => { + context.getViewAttributeElement().style.background = resolveValdiGradientAngles(value); + }, + (_element, context) => { + context.getViewAttributeElement().style.background = ''; + }, + ); + binder.bindAnimatableColorAttribute( + 'backgroundColor', + 'transparent', + MIN_VISIBLE_CHANGE_COLOR, + (_element, value, context) => { + context.getViewAttributeElement().style.backgroundColor = value; + }, + (_element, context) => { + context.getViewAttributeElement().style.backgroundColor = ''; + }, + ); + binder.bindAttribute('borderWidth', { + apply(_element, value, attributeName, context) { + const element = context.getViewAttributeElement(); + const borderWidth = parseCssLength(value, attributeName); + element.style.borderWidth = borderWidth; + if (!context.getState(VIEW_BORDER_STYLE_STATE)) { + element.style.borderStyle = borderWidth === '0px' ? '' : 'solid'; + } + }, + reset(_element, _attributeName, context) { + const element = context.getViewAttributeElement(); + element.style.borderWidth = '0px'; + if (!context.getState(VIEW_BORDER_STYLE_STATE)) { + element.style.borderStyle = ''; + } + }, + }); + binder.bindAttribute('borderRadius', createBorderRadiusAttributeApplier(false)); + binder.bindAnimatableColorAttribute( + 'borderColor', + 'transparent', + MIN_VISIBLE_CHANGE_COLOR, + (_element, value, context) => { + context.getViewAttributeElement().style.borderColor = value; + }, + (_element, context) => { + context.getViewAttributeElement().style.borderColor = ''; + }, + ); + binder.bindAttribute('border', borderAttributeApplier()); + binder.bindStringAttribute( + 'borderStyle', + (_element, value, context) => { + const element = context.getViewAttributeElement(); + context.setState(VIEW_BORDER_STYLE_STATE, value); + if (!element.style.borderWidth) { + element.style.borderWidth = '0px'; + } + element.style.borderStyle = value; + }, + (_element, context) => { + context.setState(VIEW_BORDER_STYLE_STATE, undefined); + const element = context.getViewAttributeElement(); + element.style.borderStyle = element.style.borderWidth === '0px' ? '' : 'solid'; + }, + ); + binder.bindAttribute('boxShadow', boxShadowAttributeApplier()); + + binder.bindAnimatableColorAttribute( + 'color', + 'black', + MIN_VISIBLE_CHANGE_COLOR, + (element, value) => { + element.style.color = value; + }, + element => { + element.style.color = 'black'; + }, + ); + binder.bindAnimatableNumberAttribute( + 'opacity', + 1, + MIN_VISIBLE_CHANGE_ALPHA, + (element, value) => { + element.style.opacity = String(value); + }, + element => { + element.style.opacity = ''; + }, + ); + binder.bindStyleValueAttribute('cursor', 'cursor'); + binder.bindBooleanAttribute( + 'touchEnabled', + (element, enabled) => { + element.style.pointerEvents = enabled ? 'auto' : 'none'; + }, + element => { + element.style.pointerEvents = 'auto'; + }, + ); + binder.bindBooleanAttribute( + 'slowClipping', + (element, enabled, context) => { + getViewPresentationState(context).slowClipping = enabled; + updateViewBoxShadow(context); + updateViewOverflow(element, context); + }, + (element, context) => { + getViewPresentationState(context).slowClipping = false; + updateViewBoxShadow(context); + updateViewOverflow(element, context); + }, + ); + binder.bindBooleanAttribute( + 'hitTest', + (element, enabled) => { + element.style.pointerEvents = enabled ? 'auto' : 'none'; + }, + element => { + element.style.pointerEvents = 'auto'; + }, + ); + binder.bindFunctionAttribute( + 'onTouch', + (element, callback, context) => { + replaceEventListener(element, context, 'view:onTouchStart', 'touchstart', event => + callback(createTouchEvent(event, TouchEventState.Started)), + ); + replaceEventListener(element, context, 'view:onTouchMove', 'touchmove', event => + callback(createTouchEvent(event, TouchEventState.Changed)), + ); + replaceEventListener(element, context, 'view:onTouchEnd', 'touchend', event => + callback(createTouchEvent(event, TouchEventState.Ended)), + ); + replaceEventListener(element, context, 'view:onTouchCancel', 'touchcancel', event => + callback(createTouchEvent(event, TouchEventState.Ended)), + ); + }, + (element, context) => { + replaceEventListener(element, context, 'view:onTouchStart', 'touchstart', undefined); + replaceEventListener(element, context, 'view:onTouchMove', 'touchmove', undefined); + replaceEventListener(element, context, 'view:onTouchEnd', 'touchend', undefined); + replaceEventListener(element, context, 'view:onTouchCancel', 'touchcancel', undefined); + }, + ); + binder.bindFunctionAttribute( + 'onTouchStart', + (element, callback, context) => { + replaceEventListener(element, context, 'view:onTouchStartOnly', 'touchstart', event => + callback(createTouchEvent(event, TouchEventState.Started)), + ); + }, + (element, context) => { + replaceEventListener(element, context, 'view:onTouchStartOnly', 'touchstart', undefined); + }, + ); + binder.bindFunctionAttribute( + 'onTouchEnd', + (element, callback, context) => { + replaceEventListener(element, context, 'view:onTouchEndOnly', 'touchend', event => + callback(createTouchEvent(event, TouchEventState.Ended)), + ); + }, + (element, context) => { + replaceEventListener(element, context, 'view:onTouchEndOnly', 'touchend', undefined); + }, + ); + binder.bindNumberAttribute( + 'onTouchDelayDuration', + (_element, value, context) => { + getViewInteractionState(context).longPressDuration = value; + }, + (_element, context) => { + getViewInteractionState(context).longPressDuration = 500; + }, + ); + binder.bindBooleanAttribute( + 'onTapDisabled', + (element, value, context) => { + getViewInteractionState(context).onTapDisabled = value; + ensureGestureListeners(element, context); + }, + (_element, context) => { + getViewInteractionState(context).onTapDisabled = false; + }, + ); + binder.bindFunctionAttribute( + 'onTap', + (element, callback, context) => { + const state = getViewInteractionState(context); + state.onTap = callback as (event: unknown) => void; + element.style.pointerEvents = 'auto'; + element.style.cursor = 'pointer'; + ensureGestureListeners(element, context); + }, + (element, context) => { + getViewInteractionState(context).onTap = undefined; + element.style.cursor = ''; + }, + ); + binder.bindFunctionAttribute( + 'onTapPredicate', + (element, callback, context) => { + getViewInteractionState(context).onTapPredicate = callback as (event: unknown) => boolean; + ensureGestureListeners(element, context); + }, + (_element, context) => { + getViewInteractionState(context).onTapPredicate = undefined; + }, + ); + binder.bindBooleanAttribute( + 'onDoubleTapDisabled', + (element, value, context) => { + getViewInteractionState(context).onDoubleTapDisabled = value; + ensureGestureListeners(element, context); + }, + (_element, context) => { + getViewInteractionState(context).onDoubleTapDisabled = false; + }, + ); + binder.bindFunctionAttribute( + 'onDoubleTap', + (element, callback, context) => { + getViewInteractionState(context).onDoubleTap = callback as (event: unknown) => void; + ensureGestureListeners(element, context); + }, + (_element, context) => { + getViewInteractionState(context).onDoubleTap = undefined; + }, + ); + binder.bindFunctionAttribute( + 'onDoubleTapPredicate', + (element, callback, context) => { + getViewInteractionState(context).onDoubleTapPredicate = callback as (event: unknown) => boolean; + ensureGestureListeners(element, context); + }, + (_element, context) => { + getViewInteractionState(context).onDoubleTapPredicate = undefined; + }, + ); + binder.bindNumberAttribute( + 'longPressDuration', + (element, value, context) => { + getViewInteractionState(context).longPressDuration = value; + ensureGestureListeners(element, context); + }, + (_element, context) => { + getViewInteractionState(context).longPressDuration = 500; + }, + ); + binder.bindBooleanAttribute( + 'onLongPressDisabled', + (element, value, context) => { + getViewInteractionState(context).onLongPressDisabled = value; + ensureGestureListeners(element, context); + }, + (_element, context) => { + getViewInteractionState(context).onLongPressDisabled = false; + }, + ); + binder.bindFunctionAttribute( + 'onLongPress', + (element, callback, context) => { + getViewInteractionState(context).onLongPress = callback as (event: unknown) => void; + ensureGestureListeners(element, context); + }, + (_element, context) => { + getViewInteractionState(context).onLongPress = undefined; + }, + ); + binder.bindFunctionAttribute( + 'onLongPressPredicate', + (element, callback, context) => { + getViewInteractionState(context).onLongPressPredicate = callback as (event: unknown) => boolean; + ensureGestureListeners(element, context); + }, + (_element, context) => { + getViewInteractionState(context).onLongPressPredicate = undefined; + }, + ); + binder.bindBooleanAttribute( + 'onDragDisabled', + (element, value, context) => { + getViewInteractionState(context).onDragDisabled = value; + ensureGestureListeners(element, context); + }, + (_element, context) => { + getViewInteractionState(context).onDragDisabled = false; + }, + ); + binder.bindFunctionAttribute( + 'onDrag', + (element, callback, context) => { + getViewInteractionState(context).onDrag = callback as (event: unknown) => void; + ensureGestureListeners(element, context); + }, + (_element, context) => { + getViewInteractionState(context).onDrag = undefined; + }, + ); + binder.bindFunctionAttribute( + 'onDragPredicate', + (element, callback, context) => { + getViewInteractionState(context).onDragPredicate = callback as (event: unknown) => boolean; + ensureGestureListeners(element, context); + }, + (_element, context) => { + getViewInteractionState(context).onDragPredicate = undefined; + }, + ); + binder.bindNoOpAttribute('onPinchDisabled'); + binder.bindNoOpAttribute('onPinch'); + binder.bindNoOpAttribute('onPinchPredicate'); + binder.bindNoOpAttribute('onRotateDisabled'); + binder.bindNoOpAttribute('onRotate'); + binder.bindNoOpAttribute('onRotatePredicate'); + binder.bindBooleanAttribute( + 'canAlwaysScrollHorizontal', + (element, value) => { + element.style.overflowX = value ? 'scroll' : 'auto'; + }, + element => { + element.style.overflowX = ''; + }, + ); + binder.bindBooleanAttribute( + 'canAlwaysScrollVertical', + (element, value) => { + element.style.overflowY = value ? 'scroll' : 'auto'; + }, + element => { + element.style.overflowY = ''; + }, + ); + binder.bindNumberAttribute( + 'maskOpacity', + (element, value, context) => { + getViewMaskState(context).maskOpacity = value; + updateMask(element, context); + }, + (element, context) => { + getViewMaskState(context).maskOpacity = 1; + updateMask(element, context); + }, + ); + binder.bindAttribute('maskPath', { + apply(element, value, _attributeName, context) { + const state = getViewMaskState(context); + if (typeof value === 'string') { + state.path = { d: value, viewBox: '0 0 1 1', preserveAspectRatio: 'none' }; + } else if (isGeometricPathValue(value)) { + state.path = geometricPathToSvgPath(value); + } else { + state.path = undefined; + } + updateMask(element, context); + }, + reset(element, _attributeName, context) { + getViewMaskState(context).path = undefined; + updateMask(element, context); + }, + }); + binder.bindNoOpAttribute('filterTouchesWhenObscured'); + return binder.attributeAppliers; +} + +export const viewCompositeAttributes: Readonly> = { transformComposite }; diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/ViewElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/ViewElementClass.ts new file mode 100644 index 000000000..7f4eb798f --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/ViewElementClass.ts @@ -0,0 +1,79 @@ +import type { + AttributeApplierContext, + LayoutAnimationSizeApplier, + LayoutAnimationTranslationCorrection, +} from '../core/ElementClass'; +import { assignStyles } from './ElementClassSupport'; +import { LayoutElementClass } from './LayoutElementClass'; +import { buildViewAttributeAppliers, viewCompositeAttributes } from './ViewElementAttributes'; +import { getViewPaintElement, setViewPaintElement } from './ViewElementState'; + +const ZERO_TRANSLATION_CORRECTION: LayoutAnimationTranslationCorrection = { x: 0, y: 0 }; + +class PaintElementLayoutAnimationSizeApplier implements LayoutAnimationSizeApplier { + private readonly originalScale: string; + + constructor(private readonly element: HTMLElement) { + this.originalScale = element.style.getPropertyValue('scale'); + } + + apply(scaleX: number, scaleY: number): LayoutAnimationTranslationCorrection { + this.element.style.setProperty('scale', `${scaleX} ${scaleY}`); + return ZERO_TRANSLATION_CORRECTION; + } + + reset(): void { + if (this.originalScale) { + this.element.style.setProperty('scale', this.originalScale); + } else { + this.element.style.removeProperty('scale'); + } + } +} + +export class ViewElementClass extends LayoutElementClass { + private paintElementTemplate: HTMLElement | undefined; + + constructor() { + super('view', buildViewAttributeAppliers(), viewCompositeAttributes); + } + + override getViewAttributeElement(element: HTMLElement, context: AttributeApplierContext): HTMLElement { + const existing = getViewPaintElement(context); + if (existing) { + return existing; + } + + const paintElement = this.getPaintElementTemplate().cloneNode(false) as HTMLElement; + element.style.isolation = 'isolate'; + element.insertBefore(paintElement, element.childNodes.item(0)); + setViewPaintElement(context, paintElement); + return paintElement; + } + + private getPaintElementTemplate(): HTMLElement { + if (!this.paintElementTemplate) { + const paintElement = document.createElement('div'); + paintElement.setAttribute('aria-hidden', 'true'); + assignStyles(paintElement, { + inset: '0', + pointerEvents: 'none', + position: 'absolute', + transformOrigin: '0 0', + zIndex: '-1', + }); + this.paintElementTemplate = paintElement; + } + return this.paintElementTemplate; + } + + override makeLayoutAnimationSizeApplier( + _element: HTMLElement, + context: AttributeApplierContext, + _finalWidth: number, + _finalHeight: number, + ): LayoutAnimationSizeApplier | undefined { + const paintElement = getViewPaintElement(context); + return paintElement ? new PaintElementLayoutAnimationSizeApplier(paintElement) : undefined; + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/ViewElementState.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/ViewElementState.ts new file mode 100644 index 000000000..f63904e32 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/ViewElementState.ts @@ -0,0 +1,36 @@ +import type { AttributeApplierContext } from '../core/ElementClass'; + +const VIEW_PRESENTATION_STATE = '__viewElementClassPresentationState'; +const VIEW_PAINT_ELEMENT_STATE = '__viewElementClassPaintElement'; + +export interface ViewPresentationState { + borderRadiusCss: string | undefined; + boxShadow: string | undefined; + boxShadowElement: HTMLElement | undefined; + overflow: string | undefined; + slowClipping: boolean; +} + +export function getViewPresentationState(context: AttributeApplierContext): ViewPresentationState { + const existing = context.getState(VIEW_PRESENTATION_STATE); + if (existing) { + return existing; + } + const state: ViewPresentationState = { + borderRadiusCss: undefined, + boxShadow: undefined, + boxShadowElement: undefined, + overflow: undefined, + slowClipping: false, + }; + context.setState(VIEW_PRESENTATION_STATE, state); + return state; +} + +export function getViewPaintElement(context: AttributeApplierContext): HTMLElement | undefined { + return context.getState(VIEW_PAINT_ELEMENT_STATE); +} + +export function setViewPaintElement(context: AttributeApplierContext, element: HTMLElement): void { + context.setState(VIEW_PAINT_ELEMENT_STATE, element); +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/elements/WebViewElementClass.ts b/src/valdi_modules/src/valdi/web_renderer/src/elements/WebViewElementClass.ts new file mode 100644 index 000000000..249e9338f --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/elements/WebViewElementClass.ts @@ -0,0 +1,14 @@ +import { ElementClass } from '../core/ElementClass'; +import { createBaseElement } from './ElementClassSupport'; +import { ViewElementClass } from './ViewElementClass'; + +export class WebViewElementClass extends ElementClass { + constructor(viewElementClass: ViewElementClass) { + // TODO: Implement webview controller and iframe/content behavior. + super('webview', viewElementClass.attributeAppliers, viewElementClass.compositeAttributes); + } + + protected onCreateElement(): HTMLElement { + return createBaseElement('div'); + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/styles/scrollbar.ts b/src/valdi_modules/src/valdi/web_renderer/src/styles/scrollbar.ts new file mode 100644 index 000000000..9c8177803 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/styles/scrollbar.ts @@ -0,0 +1,26 @@ +const SCROLLBAR_STYLES = ` + .hide-v-scrollbar::-webkit-scrollbar:vertical { + display: none; + width: 0; + } + + .hide-h-scrollbar::-webkit-scrollbar:horizontal { + display: none; + height: 0; + } +`; + +const STYLE_ID = 'valdi-scrollbar-styles'; + +export function injectScrollbarStyles(root: Document | ShadowRoot): void { + const container = + typeof Document !== 'undefined' && root instanceof Document ? root.head : root; + if (container.querySelector(`#${STYLE_ID}`)) { + return; + } + + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = SCROLLBAR_STYLES; + container.appendChild(style); +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/tracing/ChromeDevToolsTracing.ts b/src/valdi_modules/src/valdi/web_renderer/src/tracing/ChromeDevToolsTracing.ts new file mode 100644 index 000000000..1a4b4b96f --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/tracing/ChromeDevToolsTracing.ts @@ -0,0 +1,73 @@ +import { ValdiWebTracing, valdiWebTraceArguments, valdiWebTraceName } from './ValdiWebTracing'; + +type DevToolsColor = + | 'primary' + | 'primary-light' + | 'primary-dark' + | 'secondary' + | 'secondary-light' + | 'secondary-dark' + | 'tertiary' + | 'tertiary-light' + | 'tertiary-dark' + | 'error'; + +declare global { + interface Console { + timeStamp( + label: string, + start: string | number, + end: string | number | undefined, + trackName: string, + trackGroup: string, + color: DevToolsColor, + data?: Record, + ): void; + } +} + +interface ActiveTrace { + readonly tag: string; + readonly startTime: number; +} + +const TRACK_NAME = 'Valdi JS'; +const TRACK_GROUP = 'Valdi'; +const TRACK_COLOR: DevToolsColor = 'primary'; + +export class ChromeDevToolsTracing implements ValdiWebTracing { + private readonly activeTraces: ActiveTrace[] = []; + + beginTrace(tag: string): void { + this.activeTraces.push({ tag, startTime: performance.now() }); + } + + endTrace(): void { + const activeTrace = this.activeTraces.pop(); + if (!activeTrace) { + return; + } + + console.timeStamp( + valdiWebTraceName(activeTrace.tag), + activeTrace.startTime, + performance.now(), + TRACK_NAME, + TRACK_GROUP, + TRACK_COLOR, + ); + } + + instantTrace(tag: string, args: readonly unknown[] | undefined): void { + const timestamp = performance.now(); + console.timeStamp( + valdiWebTraceName(tag), + timestamp, + timestamp, + TRACK_NAME, + TRACK_GROUP, + TRACK_COLOR, + valdiWebTraceArguments(args), + ); + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/tracing/PerformanceTimelineTracing.ts b/src/valdi_modules/src/valdi/web_renderer/src/tracing/PerformanceTimelineTracing.ts new file mode 100644 index 000000000..75336d9c9 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/tracing/PerformanceTimelineTracing.ts @@ -0,0 +1,44 @@ +import { ValdiWebTracing, valdiWebTraceArguments, valdiWebTraceName } from './ValdiWebTracing'; + +interface ActiveTrace { + readonly tag: string; + readonly startTime: number; +} + +export class PerformanceTimelineTracing implements ValdiWebTracing { + private readonly activeTraces: ActiveTrace[] = []; + + beginTrace(tag: string): void { + this.activeTraces.push({ tag, startTime: performance.now() }); + } + + endTrace(): void { + const activeTrace = this.activeTraces.pop(); + if (!activeTrace) { + return; + } + + performance.measure(valdiWebTraceName(activeTrace.tag), { + start: activeTrace.startTime, + end: performance.now(), + }); + } + + instantTrace(tag: string, args: readonly unknown[] | undefined): void { + const traceName = valdiWebTraceName(tag); + const detail = valdiWebTraceArguments(args); + if (!detail) { + performance.mark(traceName); + return; + } + + try { + performance.mark(traceName, { detail }); + } catch (error) { + if (!(error instanceof Error) || error.name !== 'DataCloneError') { + throw error; + } + performance.mark(traceName); + } + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/tracing/ValdiWebTracing.ts b/src/valdi_modules/src/valdi/web_renderer/src/tracing/ValdiWebTracing.ts new file mode 100644 index 000000000..74fb464f5 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/tracing/ValdiWebTracing.ts @@ -0,0 +1,87 @@ +export interface ValdiWebTracing { + beginTrace(tag: string): void; + endTrace(): void; + instantTrace(tag: string, args: readonly unknown[] | undefined): void; +} + +const TRACE_NAME_PREFIX = 'Valdi.'; + +let currentTracing: ValdiWebTracing | undefined; + +export function setValdiWebTracing(tracing: ValdiWebTracing | undefined): void { + currentTracing = tracing; +} + +export function isValdiWebTracingEnabled(): boolean { + return currentTracing !== undefined; +} + +export function beginValdiWebTrace(tag: string): void { + const tracing = currentTracing; + if (!tracing) { + return; + } + + try { + tracing.beginTrace(tag); + } catch (error) { + logTracingError('begin', tag, error); + } +} + +export function endValdiWebTrace(): void { + const tracing = currentTracing; + if (!tracing) { + return; + } + + try { + tracing.endTrace(); + } catch (error) { + console.error('[ValdiWebTracing] Failed to end trace', error); + } +} + +export function instantValdiWebTrace(tag: string, args: readonly unknown[] | undefined): void { + const tracing = currentTracing; + if (!tracing) { + return; + } + + try { + tracing.instantTrace(tag, args); + } catch (error) { + logTracingError('emit instant', tag, error); + } +} + +export function makeValdiWebTraceProxy(tag: string, callback: Function): (...parameters: any[]) => any { + return function (this: unknown, ...parameters: any[]) { + beginValdiWebTrace(tag); + try { + return callback.apply(this, parameters); + } finally { + endValdiWebTrace(); + } + }; +} + +export function valdiWebTraceName(tag: string): string { + return `${TRACE_NAME_PREFIX}${tag}`; +} + +export function valdiWebTraceArguments(args: readonly unknown[] | undefined): Record | undefined { + if (!args || args.length === 0) { + return undefined; + } + + const traceArguments: Record = {}; + for (let index = 0; index < args.length; index += 2) { + traceArguments[String(args[index])] = args[index + 1]; + } + return traceArguments; +} + +function logTracingError(operation: string, tag: string, error: unknown): void { + console.error(`[ValdiWebTracing] Failed to ${operation} trace '${tag}'`, error); +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/utils/IndexedRecord.ts b/src/valdi_modules/src/valdi/web_renderer/src/utils/IndexedRecord.ts new file mode 100644 index 000000000..199a27c0a --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/utils/IndexedRecord.ts @@ -0,0 +1,71 @@ +export class IndexedRecord { + private readonly values: Record = {}; + private readonly keyIndexes: Record = {}; + private readonly recordKeys: string[] = []; + + get length(): number { + return this.recordKeys.length; + } + + get empty(): boolean { + return this.recordKeys.length === 0; + } + + get keys(): string[] { + return this.recordKeys; + } + + get(key: string): T | undefined { + return this.values[key]; + } + + set(key: string, value: T): void { + if (this.keyIndexes[key] === undefined) { + this.keyIndexes[key] = this.recordKeys.length; + this.recordKeys.push(key); + } + this.values[key] = value; + } + + remove(key: string): void { + const index = this.keyIndexes[key]; + if (index === undefined) { + return; + } + + const lastIndex = this.recordKeys.length - 1; + const lastKey = this.recordKeys[lastIndex]; + this.recordKeys.pop(); + if (index !== lastIndex) { + this.recordKeys[index] = lastKey; + this.keyIndexes[lastKey] = index; + } + + this.keyIndexes[key] = undefined; + this.values[key] = undefined; + } + + pop(): T | undefined { + if (this.recordKeys.length === 0) { + return undefined; + } + + const lastIndex = this.recordKeys.length - 1; + const key = this.recordKeys[lastIndex]; + const value = this.values[key]; + this.recordKeys.pop(); + this.keyIndexes[key] = undefined; + this.values[key] = undefined; + return value; + } + + clear(): void { + const keys = this.recordKeys; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + this.keyIndexes[key] = undefined; + this.values[key] = undefined; + } + keys.length = 0; + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/utils/TextAnimationController.ts b/src/valdi_modules/src/valdi/web_renderer/src/utils/TextAnimationController.ts new file mode 100644 index 000000000..ee7f39714 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/utils/TextAnimationController.ts @@ -0,0 +1,710 @@ +import { + isTextAnimationAttachmentSpan, + NormalizedTextAnimationTransform, + textAnimationTransformForSpan, +} from './TextAnimationTypes'; + +const MAX_PART_PATTERN_CACHE_SIZE = 64; +const DEFAULT_FLUSH_MULTIPLIER = 20; + +interface TextAnimationPart { + element: HTMLElement; + partIndex: number; + transform: NormalizedTextAnimationTransform; +} + +interface TextAnimationInstance { + active: boolean; + element: HTMLElement; + key: string; + partIndex: number; + progress: number; + scheduledStartTime: number; + startTime?: number; + timelineKey: string; + transform: NormalizedTextAnimationTransform; +} + +interface OriginalAnimationStyle { + display: string; + opacity: string; + transform: string; + transformOrigin: string; + willChange: string; +} + +type TextAnimationFrameCallback = (time: number) => void; + +const ORIGINAL_STYLES_BY_ELEMENT = new WeakMap(); +const PART_PATTERN_CACHE = new Map(); + +interface TextAnimationTimelineState { + compressedNewAnimationStartTimesByStartDelay?: Map; + existingAnimationStartTime?: number; + minimumNewAnimationStartDelay: number; + newAnimationStartTime?: number; + newAnimationReferenceTime?: number; + newAnimationStartDelays: Set; +} + +export interface TextAnimationControllerRegistry { + hasTextAnimationGroup(element: HTMLElement): boolean; + nearestTextAnimationGroup(element: HTMLElement): TextAnimationGroupController | undefined; + textAnimationParticipantForElement(element: HTMLElement): TextAnimationParticipant | undefined; +} + +export function easeOutTextAnimationProgress(progress: number): number { + const clampedProgress = Math.max(0, Math.min(progress, 1)); + return 1 - Math.pow(1 - clampedProgress, 3); +} + +class TextAnimationTimeline { + private readonly timelineStates = new Map(); + private flushDurationThresholdMillis: number | undefined; + private flushMultiplier = DEFAULT_FLUSH_MULTIPLIER; + + resetFrameState(): void { + this.timelineStates.clear(); + } + + setFlushDurationThreshold(flushDurationThreshold: number | undefined): void { + this.flushDurationThresholdMillis = + flushDurationThreshold === undefined ? undefined : Math.max(flushDurationThreshold * 1000, 0); + } + + setFlushMultiplier(flushMultiplier: number | undefined): void { + this.flushMultiplier = flushMultiplier === undefined ? DEFAULT_FLUSH_MULTIPLIER : Math.max(flushMultiplier, 0); + } + + recordExistingAnimationScheduledStartTime(timelineKey: string, scheduledStartTime: number): void { + const timelineState = this.timelineStateForKey(timelineKey); + const existingAnimationStartTime = Math.max( + timelineState.existingAnimationStartTime ?? Number.NEGATIVE_INFINITY, + scheduledStartTime, + ); + if (existingAnimationStartTime !== timelineState.existingAnimationStartTime) { + timelineState.existingAnimationStartTime = existingAnimationStartTime; + timelineState.compressedNewAnimationStartTimesByStartDelay = undefined; + } + } + + recordNewAnimationStartDelay(timelineKey: string, startDelay: number): void { + const timelineState = this.timelineStateForKey(timelineKey); + timelineState.minimumNewAnimationStartDelay = Math.min(timelineState.minimumNewAnimationStartDelay, startDelay); + if (!timelineState.newAnimationStartDelays.has(startDelay)) { + timelineState.newAnimationStartDelays.add(startDelay); + timelineState.compressedNewAnimationStartTimesByStartDelay = undefined; + } + } + + startTimeForNewAnimation(timelineKey: string, currentTime: number, timeOffset: number, startDelay: number): number { + const timelineState = this.timelineStateForKey(timelineKey); + this.recordNewAnimationStartDelay(timelineKey, startDelay); + if (this.flushDurationThresholdMillis !== undefined) { + return this.compressedStartTimeForNewAnimation(timelineState, currentTime, timeOffset, startDelay); + } + + if (timelineState.newAnimationStartTime === undefined) { + const firstScheduledStartTime = + timelineState.existingAnimationStartTime === undefined + ? currentTime + : Math.max(currentTime, timelineState.existingAnimationStartTime + timeOffset); + timelineState.newAnimationStartTime = firstScheduledStartTime - timelineState.minimumNewAnimationStartDelay; + } + return timelineState.newAnimationStartTime; + } + + private compressedStartTimeForNewAnimation( + timelineState: TextAnimationTimelineState, + currentTime: number, + timeOffset: number, + startDelay: number, + ): number { + if (timelineState.newAnimationReferenceTime === undefined) { + timelineState.newAnimationReferenceTime = currentTime; + } + + const referenceTime = timelineState.newAnimationReferenceTime; + const compressedStartTimesByStartDelay = + timelineState.compressedNewAnimationStartTimesByStartDelay ?? + this.compressedStartTimesByStartDelay(timelineState, referenceTime, timeOffset); + timelineState.compressedNewAnimationStartTimesByStartDelay = compressedStartTimesByStartDelay; + return compressedStartTimesByStartDelay.get(startDelay) ?? referenceTime; + } + + private compressedStartTimesByStartDelay( + timelineState: TextAnimationTimelineState, + referenceTime: number, + timeOffset: number, + ): Map { + let previousScheduledStartTime = referenceTime; + let previousDelay = 0; + let hasPreviousDelay = false; + const startTimesByStartDelay = new Map(); + + if (timelineState.existingAnimationStartTime !== undefined) { + previousScheduledStartTime = timelineState.existingAnimationStartTime; + previousScheduledStartTime = Math.max( + referenceTime, + previousScheduledStartTime + this.effectiveDelta(timeOffset, previousScheduledStartTime, referenceTime), + ); + } + + const sortedStartDelays = Array.from(timelineState.newAnimationStartDelays).sort((left, right) => left - right); + for (let i = 0; i < sortedStartDelays.length; i++) { + const delay = sortedStartDelays[i]; + if (timelineState.existingAnimationStartTime === undefined && !hasPreviousDelay) { + previousScheduledStartTime = referenceTime; + } else if (hasPreviousDelay) { + const normalDelta = Math.max(delay - previousDelay, 0); + previousScheduledStartTime = Math.max( + referenceTime, + previousScheduledStartTime + this.effectiveDelta(normalDelta, previousScheduledStartTime, referenceTime), + ); + } + previousDelay = delay; + hasPreviousDelay = true; + startTimesByStartDelay.set(delay, previousScheduledStartTime - delay); + } + + return startTimesByStartDelay; + } + + private effectiveDelta(normalDelta: number, previousScheduledStartTime: number, currentTime: number): number { + const threshold = this.flushDurationThresholdMillis; + if (threshold === undefined || normalDelta <= 0) { + return normalDelta; + } + + const lead = previousScheduledStartTime - currentTime; + if (lead <= threshold) { + return normalDelta; + } + + const lagSeconds = (lead - threshold) / 1000; + return normalDelta / (1 + lagSeconds * this.flushMultiplier); + } + + private timelineStateForKey(timelineKey: string): TextAnimationTimelineState { + let timelineState = this.timelineStates.get(timelineKey); + if (!timelineState) { + timelineState = { + minimumNewAnimationStartDelay: Number.POSITIVE_INFINITY, + newAnimationStartDelays: new Set(), + }; + this.timelineStates.set(timelineKey, timelineState); + } + return timelineState; + } +} + +export class TextAnimationGroupController { + private readonly timeline = new TextAnimationTimeline(); + private frameRequest: number | undefined; + + constructor( + readonly element: HTMLElement, + private readonly registry: TextAnimationControllerRegistry, + ) {} + + setFlushDurationThreshold(flushDurationThreshold: number | undefined): void { + this.timeline.setFlushDurationThreshold(flushDurationThreshold); + } + + setFlushMultiplier(flushMultiplier: number | undefined): void { + this.timeline.setFlushMultiplier(flushMultiplier); + } + + startFrameLoopIfNeeded(): void { + if (this.frameRequest !== undefined) { + return; + } + this.frameRequest = requestTextAnimationFrame(time => this.runFrame(time)); + } + + destroy(): void { + this.cancelFrameLoop(); + } + + private runFrame(currentTime: number): void { + this.frameRequest = undefined; + const participants = this.collectOrderedParticipants(); + this.timeline.resetFrameState(); + + let basePartIndex = 0; + for (let i = 0; i < participants.length; i++) { + const participant = participants[i]; + participant.setBasePartIndex(basePartIndex); + basePartIndex += participant.textAnimationPartCount; + participant.prepareFrame(this.timeline); + } + + let hasActiveAnimations = false; + for (let i = 0; i < participants.length; i++) { + hasActiveAnimations = participants[i].updatePreparedFrame(currentTime, this.timeline) || hasActiveAnimations; + } + + if (hasActiveAnimations) { + this.frameRequest = requestTextAnimationFrame(time => this.runFrame(time)); + } + } + + private cancelFrameLoop(): void { + if (this.frameRequest === undefined) { + return; + } + cancelTextAnimationFrame(this.frameRequest); + this.frameRequest = undefined; + } + + private collectOrderedParticipants(): TextAnimationParticipant[] { + const participants: TextAnimationParticipant[] = []; + const childNodes = this.element.childNodes; + for (let i = 0; i < childNodes.length; i++) { + this.collectParticipantsInElement(childNodes.item(i), participants); + } + return participants; + } + + private collectParticipantsInElement(node: Node | null, output: TextAnimationParticipant[]): void { + const element = nodeAsHTMLElement(node); + if (!element) { + return; + } + if (this.registry.hasTextAnimationGroup(element)) { + return; + } + + const participant = this.registry.textAnimationParticipantForElement(element); + if (participant && participant.hasTextAnimationParts()) { + output.push(participant); + } + + const childNodes = element.childNodes; + for (let i = 0; i < childNodes.length; i++) { + this.collectParticipantsInElement(childNodes.item(i), output); + } + } +} + +export class TextAnimationParticipant { + private readonly animations = new Map(); + private readonly localTimeline = new TextAnimationTimeline(); + private frameRequest: number | undefined; + private hasTextAnimationPartDefinitions = false; + private basePartIndex = 0; + + constructor( + readonly ownerElement: HTMLElement, + private readonly registry: TextAnimationControllerRegistry, + ) {} + + get textAnimationPartCount(): number { + return this.hasTextAnimationPartDefinitions ? this.animations.size : 0; + } + + setContainer(container: HTMLElement): void { + const parts = buildTextAnimationParts(container); + const activeKeys = new Set(); + this.hasTextAnimationPartDefinitions = parts.length > 0; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const key = keyFor(part.partIndex, part.transform); + activeKeys.add(key); + + let animation = this.animations.get(key); + if (!animation) { + animation = { + active: true, + element: part.element, + key, + partIndex: part.partIndex, + progress: 0, + scheduledStartTime: 0, + timelineKey: timelineKeyFor(part.transform), + transform: part.transform, + }; + this.animations.set(key, animation); + } else { + animation.element = part.element; + animation.partIndex = part.partIndex; + animation.timelineKey = timelineKeyFor(part.transform); + animation.transform = part.transform; + } + } + + for (const [key, animation] of this.animations) { + if (!activeKeys.has(key)) { + restoreAnimationStyle(animation.element); + this.animations.delete(key); + } + } + } + + hasTextAnimationParts(): boolean { + return this.hasTextAnimationPartDefinitions; + } + + setBasePartIndex(basePartIndex: number): void { + this.basePartIndex = basePartIndex; + } + + startFrameLoopIfNeeded(): void { + const group = this.registry.nearestTextAnimationGroup(this.ownerElement); + if (group) { + this.cancelFrameLoop(); + this.applyFrame(currentTimeMillis()); + group.startFrameLoopIfNeeded(); + return; + } + + this.cancelFrameLoop(); + this.runFrame(currentTimeMillis()); + } + + prepareFrame(timeline: TextAnimationTimeline): void { + for (const animation of this.animations.values()) { + if (animation.startTime === undefined || !animation.active) { + if (animation.startTime === undefined) { + timeline.recordNewAnimationStartDelay( + animation.timelineKey, + delayMillisFor(animation.transform, this.basePartIndex), + ); + } + continue; + } + timeline.recordExistingAnimationScheduledStartTime(animation.timelineKey, animation.scheduledStartTime); + } + } + + updatePreparedFrame(currentTime: number, timeline: TextAnimationTimeline): boolean { + this.schedulePendingAnimations(currentTime, timeline); + return this.applyFrame(currentTime); + } + + destroy(): void { + this.cancelFrameLoop(); + for (const animation of this.animations.values()) { + restoreAnimationStyle(animation.element); + } + this.animations.clear(); + this.hasTextAnimationPartDefinitions = false; + } + + private runFrame(currentTime: number): void { + this.frameRequest = undefined; + const group = this.registry.nearestTextAnimationGroup(this.ownerElement); + if (group) { + group.startFrameLoopIfNeeded(); + return; + } + + this.localTimeline.resetFrameState(); + this.prepareFrame(this.localTimeline); + const hasActiveAnimations = this.updatePreparedFrame(currentTime, this.localTimeline); + if (hasActiveAnimations) { + this.frameRequest = requestTextAnimationFrame(time => this.runFrame(time)); + } + } + + private cancelFrameLoop(): void { + if (this.frameRequest === undefined) { + return; + } + cancelTextAnimationFrame(this.frameRequest); + this.frameRequest = undefined; + } + + private schedulePendingAnimations(currentTime: number, timeline: TextAnimationTimeline): void { + for (const animation of this.animations.values()) { + if (animation.startTime !== undefined) { + continue; + } + const timeOffset = timeOffsetMillisFor(animation.transform); + const startDelay = delayMillisFor(animation.transform, this.basePartIndex); + const startTime = timeline.startTimeForNewAnimation(animation.timelineKey, currentTime, timeOffset, startDelay); + animation.startTime = startTime; + animation.scheduledStartTime = startTime + startDelay; + animation.progress = 0; + animation.active = true; + } + } + + private applyFrame(currentTime: number): boolean { + let hasActiveAnimations = false; + for (const animation of this.animations.values()) { + const progress = progressForAnimation(animation, currentTime, this.basePartIndex); + animation.progress = progress; + if (progress >= 1) { + animation.active = false; + restoreAnimationStyle(animation.element); + continue; + } + + animation.active = true; + applyAnimationStyle(animation.element, animation.transform, easeOutTextAnimationProgress(progress)); + hasActiveAnimations = true; + } + return hasActiveAnimations; + } +} + +function buildTextAnimationParts(container: HTMLElement): TextAnimationPart[] { + const parts: TextAnimationPart[] = []; + const partIndexesByGroup: number[] = []; + const childNodes = container.childNodes; + for (let i = 0; i < childNodes.length; i++) { + const span = nodeAsSpan(childNodes.item(i)); + const transform = span ? textAnimationTransformForSpan(span) : undefined; + if (!span || !transform || isNoOpStartTransform(transform)) { + continue; + } + appendPartsForSpan(span, transform, partIndexesByGroup, parts); + } + return parts; +} + +function appendPartsForSpan( + span: HTMLSpanElement, + transform: NormalizedTextAnimationTransform, + partIndexesByGroup: number[], + output: TextAnimationPart[], +): void { + const partPattern = transform.partPattern; + if (isTextAnimationAttachmentSpan(span) || !partPattern) { + appendWholePart(span, transform, partIndexesByGroup, output); + return; + } + + const pattern = compiledPartPattern(partPattern); + if (!pattern) { + return; + } + + const text = span.textContent ?? ''; + const segments: HTMLSpanElement[] = []; + let previousEnd = 0; + let matched = false; + pattern.lastIndex = 0; + + while (true) { + const match = pattern.exec(text); + if (!match) { + break; + } + const matchStart = match.index; + const matchEnd = matchStart + match[0].length; + if (matchEnd > matchStart) { + appendTextSegment(text, previousEnd, matchStart, segments); + const animatedSegment = createTextSegment(text.slice(matchStart, matchEnd)); + appendWholePart(animatedSegment, transform, partIndexesByGroup, output); + segments.push(animatedSegment); + previousEnd = matchEnd; + matched = true; + } + if (pattern.lastIndex === matchStart) { + pattern.lastIndex++; + } + } + + if (!matched) { + return; + } + + appendTextSegment(text, previousEnd, text.length, segments); + span.replaceChildren(...segments); +} + +function appendWholePart( + element: HTMLElement, + transform: NormalizedTextAnimationTransform, + partIndexesByGroup: number[], + output: TextAnimationPart[], +): void { + const groupIndex = transform.groupIndex; + const partIndexInGroup = partIndexesByGroup[groupIndex] ?? 0; + partIndexesByGroup[groupIndex] = partIndexInGroup + 1; + output.push({ + element, + partIndex: output.length, + transform: { + ...transform, + partIndexInGroup, + }, + }); +} + +function appendTextSegment(text: string, start: number, end: number, output: HTMLSpanElement[]): void { + if (end <= start) { + return; + } + output.push(createTextSegment(text.slice(start, end))); +} + +function createTextSegment(text: string): HTMLSpanElement { + const span = document.createElement('span'); + span.textContent = text; + return span; +} + +function compiledPartPattern(partPattern: string): RegExp | undefined { + const cachedPattern = PART_PATTERN_CACHE.get(partPattern); + if (cachedPattern) { + return cachedPattern; + } + + let pattern: RegExp; + try { + pattern = new RegExp(partPattern, 'g'); + } catch (error) { + console.error(`Invalid text animation partPattern: ${partPattern}`, error); + return undefined; + } + + if (PART_PATTERN_CACHE.size > MAX_PART_PATTERN_CACHE_SIZE) { + PART_PATTERN_CACHE.clear(); + } + PART_PATTERN_CACHE.set(partPattern, pattern); + return pattern; +} + +function applyAnimationStyle( + element: HTMLElement, + transform: NormalizedTextAnimationTransform, + easedProgress: number, +): void { + ensureOriginalAnimationStyle(element); + if (!element.style.display || element.style.display === 'inline') { + element.style.display = 'inline-block'; + } + element.style.transformOrigin = 'center center'; + element.style.willChange = 'opacity, transform'; + element.style.opacity = String(transform.opacity + (1 - transform.opacity) * easedProgress); + + const translationX = transform.translationX * (1 - easedProgress); + const translationY = transform.translationY * (1 - easedProgress); + const scale = transform.scale + (1 - transform.scale) * easedProgress; + const transforms: string[] = []; + if (translationX !== 0) { + transforms.push(`translateX(${translationX}px)`); + } + if (translationY !== 0) { + transforms.push(`translateY(${translationY}px)`); + } + if (scale !== 1) { + transforms.push(`scale(${scale})`); + } + element.style.transform = transforms.join(' '); +} + +function ensureOriginalAnimationStyle(element: HTMLElement): void { + if (ORIGINAL_STYLES_BY_ELEMENT.has(element)) { + return; + } + ORIGINAL_STYLES_BY_ELEMENT.set(element, { + display: element.style.display ?? '', + opacity: element.style.opacity ?? '', + transform: element.style.transform ?? '', + transformOrigin: element.style.transformOrigin ?? '', + willChange: element.style.willChange ?? '', + }); +} + +function restoreAnimationStyle(element: HTMLElement): void { + const originalStyle = ORIGINAL_STYLES_BY_ELEMENT.get(element); + if (!originalStyle) { + return; + } + element.style.display = originalStyle.display; + element.style.opacity = originalStyle.opacity; + element.style.transform = originalStyle.transform; + element.style.transformOrigin = originalStyle.transformOrigin; + element.style.willChange = originalStyle.willChange; + ORIGINAL_STYLES_BY_ELEMENT.delete(element); +} + +function progressForAnimation(animation: TextAnimationInstance, currentTime: number, basePartIndex: number): number { + if (animation.startTime === undefined) { + return 0; + } + const delayedElapsed = currentTime - animation.startTime - delayMillisFor(animation.transform, basePartIndex); + const duration = durationMillisFor(animation.transform); + if (duration === 0) { + return delayedElapsed >= 0 ? 1 : 0; + } + if (delayedElapsed <= 0) { + return 0; + } + return Math.max(0, Math.min(delayedElapsed / duration, 1)); +} + +function delayMillisFor(transform: NormalizedTextAnimationTransform, basePartIndex: number): number { + const delaySeconds = transform.timeOffsetBetweenParts * (basePartIndex + transform.partIndexInGroup); + return Math.max(delaySeconds * 1000, 0); +} + +function timeOffsetMillisFor(transform: NormalizedTextAnimationTransform): number { + return Math.max(transform.timeOffsetBetweenParts * 1000, 0); +} + +function durationMillisFor(transform: NormalizedTextAnimationTransform): number { + return Math.max(transform.duration * 1000, 0); +} + +function isNoOpStartTransform(transform: NormalizedTextAnimationTransform): boolean { + return ( + transform.translationX === 0 && + transform.translationY === 0 && + transform.scale === 1 && + transform.opacity === 1 + ); +} + +function keyFor(partIndex: number, transform: NormalizedTextAnimationTransform): string { + return transform.key === undefined ? String(partIndex) : `${transform.key}:${partIndex}`; +} + +function timelineKeyFor(transform: NormalizedTextAnimationTransform): string { + return transform.key ?? `group:${transform.groupIndex}`; +} + +function nodeAsSpan(node: Node | null): HTMLSpanElement | undefined { + const element = nodeAsHTMLElement(node); + if (!element || element.tagName !== 'SPAN') { + return undefined; + } + return element as HTMLSpanElement; +} + +function nodeAsHTMLElement(node: Node | null): HTMLElement | undefined { + const element = node as HTMLElement | null; + return element && typeof element === 'object' && element.style !== undefined ? element : undefined; +} + +function requestTextAnimationFrame(callback: TextAnimationFrameCallback): number { + const requestAnimationFrameFn = (globalThis as { requestAnimationFrame?: TextAnimationFrameCallbackScheduler }) + .requestAnimationFrame; + if (typeof requestAnimationFrameFn === 'function') { + return requestAnimationFrameFn(callback); + } + return setTimeout(() => callback(currentTimeMillis()), 16); +} + +function cancelTextAnimationFrame(handle: number): void { + const cancelAnimationFrameFn = (globalThis as { cancelAnimationFrame?: (handle: number) => void }) + .cancelAnimationFrame; + if (typeof cancelAnimationFrameFn === 'function') { + cancelAnimationFrameFn(handle); + return; + } + clearTimeout(handle); +} + +interface TextAnimationFrameCallbackScheduler { + (callback: TextAnimationFrameCallback): number; +} + +function currentTimeMillis(): number { + return performance.now(); +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/utils/TextAnimationRegistry.ts b/src/valdi_modules/src/valdi/web_renderer/src/utils/TextAnimationRegistry.ts new file mode 100644 index 000000000..52f497a05 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/utils/TextAnimationRegistry.ts @@ -0,0 +1,107 @@ +import type { AttributeApplierContext } from '../core/ElementClass'; +import type { TextAnimationControllerRegistry } from './TextAnimationController'; +import { TextAnimationGroupController, TextAnimationParticipant } from './TextAnimationController'; + +const TEXT_ANIMATION_PARTICIPANT_STATE_KEY = '__textAnimationParticipantState'; +const GROUP_CONTROLLERS_BY_ELEMENT = new WeakMap(); +const PARTICIPANTS_BY_ELEMENT = new WeakMap(); + +interface TextAnimationParticipantState { + participant?: TextAnimationParticipant; +} + +const TEXT_ANIMATION_CONTROLLER_REGISTRY: TextAnimationControllerRegistry = { + hasTextAnimationGroup(element: HTMLElement): boolean { + return GROUP_CONTROLLERS_BY_ELEMENT.has(element); + }, + + nearestTextAnimationGroup(element: HTMLElement): TextAnimationGroupController | undefined { + let parent = element.parentElement; + while (parent) { + const group = GROUP_CONTROLLERS_BY_ELEMENT.get(parent); + if (group) { + return group; + } + parent = parent.parentElement; + } + return undefined; + }, + + textAnimationParticipantForElement(element: HTMLElement): TextAnimationParticipant | undefined { + return PARTICIPANTS_BY_ELEMENT.get(element); + }, +}; + +export function registerTextAnimationGroup(element: HTMLElement): TextAnimationGroupController { + const controller = new TextAnimationGroupController(element, TEXT_ANIMATION_CONTROLLER_REGISTRY); + GROUP_CONTROLLERS_BY_ELEMENT.set(element, controller); + return controller; +} + +export function unregisterTextAnimationGroup(element: HTMLElement): void { + GROUP_CONTROLLERS_BY_ELEMENT.get(element)?.destroy(); + GROUP_CONTROLLERS_BY_ELEMENT.delete(element); +} + +export function setTextAnimationGroupFlushDurationThreshold( + element: HTMLElement, + flushDurationThreshold: number | undefined, +): void { + GROUP_CONTROLLERS_BY_ELEMENT.get(element)?.setFlushDurationThreshold(flushDurationThreshold); +} + +export function setTextAnimationGroupFlushMultiplier(element: HTMLElement, flushMultiplier: number | undefined): void { + GROUP_CONTROLLERS_BY_ELEMENT.get(element)?.setFlushMultiplier(flushMultiplier); +} + +export function registerTextAnimationParticipant( + ownerElement: HTMLElement, + container: HTMLElement, + context: AttributeApplierContext, +): void { + const state = textAnimationParticipantState(context); + let participant = state.participant; + if (!participant) { + participant = new TextAnimationParticipant(ownerElement, TEXT_ANIMATION_CONTROLLER_REGISTRY); + state.participant = participant; + PARTICIPANTS_BY_ELEMENT.set(ownerElement, participant); + } + + participant.setContainer(container); + if (!participant.hasTextAnimationParts()) { + unregisterTextAnimationParticipant(context); + return; + } + + participant.startFrameLoopIfNeeded(); +} + +export function unregisterTextAnimationParticipant(context: AttributeApplierContext): void { + const state = context.getState(TEXT_ANIMATION_PARTICIPANT_STATE_KEY); + const participant = state?.participant; + if (!participant) { + return; + } + + participant.destroy(); + PARTICIPANTS_BY_ELEMENT.delete(participant.ownerElement); + state.participant = undefined; +} + +function textAnimationParticipantState(context: AttributeApplierContext): TextAnimationParticipantState { + let state = context.getState(TEXT_ANIMATION_PARTICIPANT_STATE_KEY); + if (state) { + return state; + } + + state = {}; + context.setState(TEXT_ANIMATION_PARTICIPANT_STATE_KEY, state); + context.addCleanup(() => { + state?.participant?.destroy(); + if (state?.participant) { + PARTICIPANTS_BY_ELEMENT.delete(state.participant.ownerElement); + state.participant = undefined; + } + }); + return state; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/utils/TextAnimationTypes.ts b/src/valdi_modules/src/valdi/web_renderer/src/utils/TextAnimationTypes.ts new file mode 100644 index 000000000..f47f34557 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/utils/TextAnimationTypes.ts @@ -0,0 +1,60 @@ +import type { AttributedTextAnimationTransform } from 'valdi_tsx/src/AttributedText'; + +export interface NormalizedTextAnimationTransform { + key?: string; + translationX: number; + translationY: number; + scale: number; + opacity: number; + duration: number; + timeOffsetBetweenParts: number; + groupIndex: number; + partIndexInGroup: number; + partPattern?: string; +} + +const TEXT_ANIMATION_TRANSFORMS_BY_SPAN = new WeakMap(); +const TEXT_ANIMATION_ATTACHMENT_SPANS = new WeakSet(); + +export function setTextAnimationTransform(span: HTMLSpanElement, transform: NormalizedTextAnimationTransform): void { + TEXT_ANIMATION_TRANSFORMS_BY_SPAN.set(span, transform); +} + +export function textAnimationTransformForSpan(span: HTMLSpanElement): NormalizedTextAnimationTransform | undefined { + return TEXT_ANIMATION_TRANSFORMS_BY_SPAN.get(span); +} + +export function markTextAnimationAttachmentSpan(span: HTMLSpanElement): void { + TEXT_ANIMATION_ATTACHMENT_SPANS.add(span); +} + +export function isTextAnimationAttachmentSpan(span: HTMLSpanElement): boolean { + return TEXT_ANIMATION_ATTACHMENT_SPANS.has(span); +} + +export function normalizeTextAnimationTransform( + value: unknown, + groupIndex: number, +): NormalizedTextAnimationTransform | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + + const transform = value as AttributedTextAnimationTransform; + return { + key: typeof transform.key === 'string' ? transform.key : undefined, + translationX: numberOrDefault(transform.translationX, 0), + translationY: numberOrDefault(transform.translationY, 0), + scale: numberOrDefault(transform.scale, 1), + opacity: numberOrDefault(transform.opacity, 1), + duration: numberOrDefault(transform.duration, 0.35), + timeOffsetBetweenParts: numberOrDefault(transform.timeOffsetBetweenParts, 0), + groupIndex, + partIndexInGroup: 0, + partPattern: typeof transform.partPattern === 'string' ? transform.partPattern : undefined, + }; +} + +function numberOrDefault(value: unknown, defaultValue: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : defaultValue; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/utils/assetSource.ts b/src/valdi_modules/src/valdi/web_renderer/src/utils/assetSource.ts new file mode 100644 index 000000000..d100437a6 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/utils/assetSource.ts @@ -0,0 +1,93 @@ +import { isAsciiAlphaCode, isAsciiDigitCode } from './cssScanner'; + +const ASSET_SOURCE_FIELDS = ['default', 'src', 'url', 'href'] as const; + +function isSchemeCode(code: number): boolean { + return isAsciiAlphaCode(code) || isAsciiDigitCode(code) || code === 43 || code === 45 || code === 46; +} + +function hasUrlScheme(value: string): boolean { + if (value.length < 2 || !isAsciiAlphaCode(value.charCodeAt(0))) { + return false; + } + for (let index = 1; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code === 58) { + return true; + } + if (!isSchemeCode(code)) { + return false; + } + } + return false; +} + +function hasFileExtension(value: string): boolean { + let end = value.length; + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code === 63 || code === 35) { + end = index; + break; + } + } + + let extensionLength = 0; + for (let index = end - 1; index >= 0; index--) { + const code = value.charCodeAt(index); + if (code === 47) { + return false; + } + if (code === 46) { + return extensionLength > 0; + } + if (!isAsciiAlphaCode(code) && !isAsciiDigitCode(code)) { + return false; + } + extensionLength++; + } + return false; +} + +function isRenderableAssetPath(value: string): boolean { + return ( + hasUrlScheme(value) || + value.startsWith('/') || + value.startsWith('.') || + value.indexOf('/') >= 0 || + hasFileExtension(value) + ); +} + +export function resolveAssetSourceUrl(source: unknown): string | undefined { + if (typeof source === 'string') { + return source; + } + if (!source || typeof source !== 'object') { + return undefined; + } + + const objectSource = source as Record; + for (const field of ASSET_SOURCE_FIELDS) { + if (field in objectSource) { + const resolved = resolveAssetSourceUrl(objectSource[field]); + if (resolved !== undefined) { + return resolved; + } + } + } + return undefined; +} + +export function resolveRenderableAssetSource(source: unknown): string | undefined { + const resolved = resolveAssetSourceUrl(source); + if (resolved !== undefined) { + return resolved; + } + if (!source || typeof source !== 'object') { + return undefined; + } + + const path = (source as { path?: unknown }).path; + return typeof path === 'string' && isRenderableAssetPath(path) ? path : undefined; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/utils/cssColor.ts b/src/valdi_modules/src/valdi/web_renderer/src/utils/cssColor.ts new file mode 100644 index 000000000..f0e3cee9c --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/utils/cssColor.ts @@ -0,0 +1,118 @@ +import { parseCssFunction } from './cssFunction'; + +export type ParsedCssColor = { r: number; g: number; b: number; a: number }; + +function clampColorChannel(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function clampUnit(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function hexNibble(value: string, index: number): number { + const code = value.charCodeAt(index); + if (code >= 48 && code <= 57) { + return code - 48; + } + const lowerCode = code | 32; + if (lowerCode >= 97 && lowerCode <= 102) { + return lowerCode - 87; + } + return -1; +} + +function parseHexPair(value: string, index: number): number { + const high = hexNibble(value, index); + const low = hexNibble(value, index + 1); + return high < 0 || low < 0 ? -1 : high * 16 + low; +} + +function parseHexColor(value: string): ParsedCssColor | undefined { + const start = value.charCodeAt(0) === 35 ? 1 : 0; + const length = value.length - start; + if (length === 3) { + const r = hexNibble(value, start); + const g = hexNibble(value, start + 1); + const b = hexNibble(value, start + 2); + if (r < 0 || g < 0 || b < 0) { + return undefined; + } + return { + r: r * 17, + g: g * 17, + b: b * 17, + a: 1, + }; + } + if (length !== 6) { + return undefined; + } + const r = parseHexPair(value, start); + const g = parseHexPair(value, start + 2); + const b = parseHexPair(value, start + 4); + if (r < 0 || g < 0 || b < 0) { + return undefined; + } + return { + r, + g, + b, + a: 1, + }; +} + +function parseRgbFunctionColor(value: string): ParsedCssColor | undefined { + const cssFunction = parseCssFunction(value); + if (!cssFunction || (cssFunction.name !== 'rgb' && cssFunction.name !== 'rgba')) { + return undefined; + } + const parameters = cssFunction.parameters; + if (parameters.length !== 3 && parameters.length !== 4) { + return undefined; + } + const r = parseFiniteNumber(parameters[0]); + const g = parseFiniteNumber(parameters[1]); + const b = parseFiniteNumber(parameters[2]); + const a = parameters.length === 4 ? parseFiniteNumber(parameters[3]) : 1; + if (r === undefined || g === undefined || b === undefined || a === undefined) { + return undefined; + } + return { + r: clampColorChannel(r), + g: clampColorChannel(g), + b: clampColorChannel(b), + a: clampUnit(a), + }; +} + +function parseFiniteNumber(value: string): number | undefined { + if (value.length === 0) { + return undefined; + } + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : undefined; +} + +export function parseCssColor(value: string): ParsedCssColor | undefined { + const trimmed = value.trim(); + return parseHexColor(trimmed) ?? parseRgbFunctionColor(trimmed); +} + +export function applyCssColorOpacity(color: string, opacityValue: string): string { + const opacity = parseFloat(opacityValue); + if (Number.isNaN(opacity) || opacity < 0 || opacity > 1) { + return color; + } + + if (color.startsWith('hsla')) { + return color; + } + + const parsedColor = parseCssColor(color); + if (!parsedColor) { + return color; + } + const alpha = clampUnit(parsedColor.a * opacity); + return `rgba(${parsedColor.r}, ${parsedColor.g}, ${parsedColor.b}, ${alpha})`; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/utils/cssFunction.ts b/src/valdi_modules/src/valdi/web_renderer/src/utils/cssFunction.ts new file mode 100644 index 000000000..dc4befdfd --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/utils/cssFunction.ts @@ -0,0 +1,2 @@ +export type { ParsedCssFunction } from './cssScanner'; +export { parseCssFunction } from './cssScanner'; diff --git a/src/valdi_modules/src/valdi/web_renderer/src/utils/cssScanner.ts b/src/valdi_modules/src/valdi/web_renderer/src/utils/cssScanner.ts new file mode 100644 index 000000000..3d4b91751 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/utils/cssScanner.ts @@ -0,0 +1,266 @@ +const CHAR_TAB = 9; +const CHAR_LINE_FEED = 10; +const CHAR_FORM_FEED = 12; +const CHAR_CARRIAGE_RETURN = 13; +const CHAR_SPACE = 32; +const CHAR_OPEN_PAREN = 40; +const CHAR_CLOSE_PAREN = 41; +const CHAR_COMMA = 44; +const CHAR_DOT = 46; +const CHAR_MINUS = 45; +const CHAR_SINGLE_QUOTE = 39; +const CHAR_DOUBLE_QUOTE = 34; +const CHAR_BACKSLASH = 92; +const CHAR_UNDERSCORE = 95; +const CHAR_ZERO = 48; +const CHAR_NINE = 57; +const CHAR_UPPER_A = 65; +const CHAR_UPPER_Z = 90; +const CHAR_LOWER_A = 97; +const CHAR_LOWER_Z = 122; + +export type CssNumberToken = { + nextIndex: number; + startIndex: number; + text: string; + value: number; +}; + +export type CssToken = { + nextIndex: number; + startIndex: number; + token: string; +}; + +export type ParsedCssFunction = { + name: string; + parameters: string[]; +}; + +export type ParsedCssFunctionCall = ParsedCssFunction & { + nextIndex: number; + startIndex: number; +}; + +export function isAsciiDigitCode(code: number): boolean { + return code >= CHAR_ZERO && code <= CHAR_NINE; +} + +export function isAsciiAlphaCode(code: number): boolean { + return (code >= CHAR_UPPER_A && code <= CHAR_UPPER_Z) || (code >= CHAR_LOWER_A && code <= CHAR_LOWER_Z); +} + +export function isCssWhitespaceCode(code: number): boolean { + return ( + code === CHAR_TAB || + code === CHAR_LINE_FEED || + code === CHAR_FORM_FEED || + code === CHAR_CARRIAGE_RETURN || + code === CHAR_SPACE + ); +} + +export function skipCssWhitespace(value: string, index: number): number { + let nextIndex = index; + while (nextIndex < value.length && isCssWhitespaceCode(value.charCodeAt(nextIndex))) { + nextIndex++; + } + return nextIndex; +} + +export function skipTrailingCssWhitespace(value: string, endIndex: number): number { + let nextIndex = endIndex; + while (nextIndex > 0 && isCssWhitespaceCode(value.charCodeAt(nextIndex - 1))) { + nextIndex--; + } + return nextIndex; +} + +export function trimCssStartIndex(value: string, start: number, end: number): number { + let nextStart = start; + while (nextStart < end && isCssWhitespaceCode(value.charCodeAt(nextStart))) { + nextStart++; + } + return nextStart; +} + +export function trimCssEndIndex(value: string, start: number, end: number): number { + let nextEnd = end; + while (nextEnd > start && isCssWhitespaceCode(value.charCodeAt(nextEnd - 1))) { + nextEnd--; + } + return nextEnd; +} + +export function consumeCssNumber(value: string, start: number): number { + const length = value.length; + let index = start; + if (value.charCodeAt(index) === CHAR_MINUS) { + index++; + } + + const digitStart = index; + while (index < length && isAsciiDigitCode(value.charCodeAt(index))) { + index++; + } + const hasIntegerDigits = index > digitStart; + if (index < length && value.charCodeAt(index) === CHAR_DOT) { + const fractionStart = index + 1; + let fractionEnd = fractionStart; + while (fractionEnd < length && isAsciiDigitCode(value.charCodeAt(fractionEnd))) { + fractionEnd++; + } + if (fractionEnd > fractionStart) { + return fractionEnd; + } + } + + return hasIntegerDigits ? index : -1; +} + +export function readCssNumber(value: string, index: number): CssNumberToken | undefined { + const nextIndex = consumeCssNumber(value, index); + if (nextIndex < 0) { + return undefined; + } + const text = value.slice(index, nextIndex); + return { + nextIndex, + startIndex: index, + text, + value: Number(text), + }; +} + +export function isPlainCssNumber(value: string): boolean { + if (value.length === 0) { + return false; + } + const number = readCssNumber(value, 0); + return number !== undefined && number.nextIndex === value.length; +} + +export function readWhitespaceSeparatedToken(value: string, index: number): CssToken | undefined { + const startIndex = skipCssWhitespace(value, index); + if (startIndex >= value.length) { + return undefined; + } + let nextIndex = startIndex; + while (nextIndex < value.length && !isCssWhitespaceCode(value.charCodeAt(nextIndex))) { + nextIndex++; + } + return { token: value.slice(startIndex, nextIndex), startIndex, nextIndex }; +} + +export function readPreviousWhitespaceSeparatedToken(value: string, endIndex: number): CssToken | undefined { + const tokenEndIndex = skipTrailingCssWhitespace(value, endIndex); + if (tokenEndIndex === 0) { + return undefined; + } + + let tokenStartIndex = tokenEndIndex; + while (tokenStartIndex > 0 && !isCssWhitespaceCode(value.charCodeAt(tokenStartIndex - 1))) { + tokenStartIndex--; + } + return { + token: value.slice(tokenStartIndex, tokenEndIndex), + startIndex: tokenStartIndex, + nextIndex: tokenEndIndex, + }; +} + +export function consumeLiteral(value: string, index: number, literal: string): number { + return value.startsWith(literal, index) ? index + literal.length : -1; +} + +function isCssFunctionNameCode(code: number): boolean { + return isAsciiAlphaCode(code) || isAsciiDigitCode(code) || code === CHAR_MINUS || code === CHAR_UNDERSCORE; +} + +function appendParameter(value: string, parameters: string[], start: number, end: number): void { + const trimmedStart = trimCssStartIndex(value, start, end); + const trimmedEnd = trimCssEndIndex(value, trimmedStart, end); + parameters.push(value.slice(trimmedStart, trimmedEnd)); +} + +function appendFinalParameter(value: string, parameters: string[], start: number, end: number): void { + const trimmedStart = trimCssStartIndex(value, start, end); + const trimmedEnd = trimCssEndIndex(value, trimmedStart, end); + if (parameters.length > 0 || trimmedStart !== trimmedEnd) { + parameters.push(value.slice(trimmedStart, trimmedEnd)); + } +} + +export function parseCssFunctionCall(value: string, index: number): ParsedCssFunctionCall | undefined { + const length = value.length; + const startIndex = skipCssWhitespace(value, index); + let nextIndex = startIndex; + const nameStart = nextIndex; + while (nextIndex < length && isCssFunctionNameCode(value.charCodeAt(nextIndex))) { + nextIndex++; + } + if (nextIndex === nameStart) { + return undefined; + } + + const nameEnd = nextIndex; + nextIndex = skipCssWhitespace(value, nextIndex); + if (nextIndex >= length || value.charCodeAt(nextIndex) !== CHAR_OPEN_PAREN) { + return undefined; + } + + nextIndex++; + let parameterStart = nextIndex; + let nestedDepth = 0; + let quote = 0; + const parameters: string[] = []; + for (; nextIndex < length; nextIndex++) { + const code = value.charCodeAt(nextIndex); + if (quote !== 0) { + if (code === CHAR_BACKSLASH) { + nextIndex++; + } else if (code === quote) { + quote = 0; + } + continue; + } + + if (code === CHAR_DOUBLE_QUOTE || code === CHAR_SINGLE_QUOTE) { + quote = code; + continue; + } + if (code === CHAR_OPEN_PAREN) { + nestedDepth++; + continue; + } + if (code === CHAR_CLOSE_PAREN) { + if (nestedDepth > 0) { + nestedDepth--; + continue; + } + appendFinalParameter(value, parameters, parameterStart, nextIndex); + return { + name: value.slice(nameStart, nameEnd).toLowerCase(), + nextIndex: nextIndex + 1, + parameters, + startIndex, + }; + } + if (code === CHAR_COMMA && nestedDepth === 0) { + appendParameter(value, parameters, parameterStart, nextIndex); + parameterStart = nextIndex + 1; + } + } + return undefined; +} + +export function parseCssFunction(value: string): ParsedCssFunction | undefined { + const parsed = parseCssFunctionCall(value, 0); + if (!parsed || skipCssWhitespace(value, parsed.nextIndex) !== value.length) { + return undefined; + } + return { + name: parsed.name, + parameters: parsed.parameters, + }; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/utils/geometricPath.ts b/src/valdi_modules/src/valdi/web_renderer/src/utils/geometricPath.ts new file mode 100644 index 000000000..588ed358f --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/utils/geometricPath.ts @@ -0,0 +1,98 @@ +import { GeometricPathScaleType, visitGeometricPath } from 'valdi_core/src/GeometricPath'; +import type { GeometricPath, GeometricPathVisitor } from 'valdi_core/src/GeometricPath'; + +export interface SvgGeometricPath { + d: string; + viewBox: string; + preserveAspectRatio: string; +} + +function preserveAspectRatioForScaleType(scaleType: number): string { + switch (scaleType) { + case GeometricPathScaleType.Contain: + return 'xMidYMid meet'; + case GeometricPathScaleType.Cover: + return 'xMidYMid slice'; + case GeometricPathScaleType.None: + return 'xMidYMid meet'; + default: + return 'none'; + } +} + +export function isGeometricPathValue(value: unknown): value is GeometricPath { + return value instanceof Float64Array; +} + +class SvgGeometricPathVisitor implements GeometricPathVisitor { + private readonly parts: string[] = []; + private extentWidth = 1; + private extentHeight = 1; + private scaleType = GeometricPathScaleType.Fill; + + beginPath(width: number, height: number, pathScaleType: GeometricPathScaleType): void { + this.extentWidth = width; + this.extentHeight = height; + this.scaleType = pathScaleType; + } + + moveTo(x: number, y: number): void { + this.parts.push(`M ${x} ${y}`); + } + + lineTo(x: number, y: number): void { + this.parts.push(`L ${x} ${y}`); + } + + quadTo(controlX: number, controlY: number, x: number, y: number): void { + this.parts.push(`Q ${controlX} ${controlY} ${x} ${y}`); + } + + cubicTo(controlX1: number, controlY1: number, controlX2: number, controlY2: number, x: number, y: number): void { + this.parts.push(`C ${controlX1} ${controlY1} ${controlX2} ${controlY2} ${x} ${y}`); + } + + roundRectTo(x: number, y: number, width: number, height: number, radiusX: number, radiusY: number): void { + const rx = Math.min(radiusX, width / 2); + const ry = Math.min(radiusY, height / 2); + if (rx <= 0 && ry <= 0) { + this.parts.push(`M ${x} ${y} h ${width} v ${height} h ${-width} Z`); + } else { + this.parts.push( + `M ${x + rx} ${y} L ${x + width - rx} ${y} Q ${x + width} ${y} ${x + width} ${y + ry} L ${x + width} ${y + height - ry} Q ${x + width} ${y + height} ${x + width - rx} ${y + height} L ${x + rx} ${y + height} Q ${x} ${y + height} ${x} ${y + height - ry} L ${x} ${y + ry} Q ${x} ${y} ${x + rx} ${y} Z`, + ); + } + } + + arcTo(centerX: number, centerY: number, radius: number, startAngle: number, sweepAngle: number): void { + const startX = centerX + radius * Math.cos(startAngle); + const startY = centerY + radius * Math.sin(startAngle); + const endX = centerX + radius * Math.cos(startAngle + sweepAngle); + const endY = centerY + radius * Math.sin(startAngle + sweepAngle); + const largeArc = Math.abs(sweepAngle) >= Math.PI ? 1 : 0; + const sweepFlag = sweepAngle > 0 ? 1 : 0; + this.parts.push(`M ${startX} ${startY} A ${radius} ${radius} 0 ${largeArc} ${sweepFlag} ${endX} ${endY}`); + } + + close(): void { + this.parts.push('Z'); + } + + getResult(): SvgGeometricPath { + return { + d: this.parts.join(' '), + viewBox: `0 0 ${this.extentWidth} ${this.extentHeight}`, + preserveAspectRatio: preserveAspectRatioForScaleType(this.scaleType), + }; + } +} + +export function geometricPathToSvgPath(data: GeometricPath): SvgGeometricPath { + const visitor = new SvgGeometricPathVisitor(); + + if (!visitGeometricPath(data, visitor)) { + return { d: '', viewBox: '0 0 1 1', preserveAspectRatio: 'none' }; + } + + return visitor.getResult(); +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/utils/imageFilterOperations.ts b/src/valdi_modules/src/valdi/web_renderer/src/utils/imageFilterOperations.ts new file mode 100644 index 000000000..a16ba5f22 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/utils/imageFilterOperations.ts @@ -0,0 +1,107 @@ +import { ParsedCssColor } from './cssColor'; + +const FILTER_TYPE_BLUR = 1; +const FILTER_TYPE_COLOR_MATRIX = 2; + +export type ImageFilterOperation = + | { + type: 'blur'; + radius: number; + } + | { + type: 'colorMatrix'; + matrix: number[]; + }; + +function parseFilterNumbers(value: unknown): number[] | undefined { + if (Array.isArray(value)) { + const numbers = value.map(Number); + return numbers.every(Number.isFinite) ? numbers : undefined; + } + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + if (trimmed.length === 0) { + return []; + } + let parsed: unknown; + try { + parsed = trimmed.startsWith('[') ? JSON.parse(trimmed) : trimmed.split(','); + } catch (_error) { + return undefined; + } + if (!Array.isArray(parsed)) { + return undefined; + } + const numbers = parsed.map(Number); + return numbers.every(Number.isFinite) ? numbers : undefined; +} + +export function parseImageFilterOperations(value: unknown): ImageFilterOperation[] | undefined { + const numbers = parseFilterNumbers(value); + if (!numbers) { + return undefined; + } + + const operations: ImageFilterOperation[] = []; + let index = 0; + while (index < numbers.length) { + const type = numbers[index++]; + if (type === FILTER_TYPE_BLUR) { + const radius = numbers[index++]; + if (!Number.isFinite(radius)) { + return undefined; + } + operations.push({ type: 'blur', radius }); + continue; + } + if (type === FILTER_TYPE_COLOR_MATRIX) { + const matrix = numbers.slice(index, index + 20); + if (matrix.length !== 20 || !matrix.every(Number.isFinite)) { + return undefined; + } + operations.push({ type: 'colorMatrix', matrix }); + index += 20; + continue; + } + return undefined; + } + + return operations; +} + +function clampImageDataChannel(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); +} + +export function applyColorMatrixToImageData(imageData: ImageData, matrix: number[]): void { + const data = imageData.data; + for (let i = 0; i < data.length; i += 4) { + const r = data[i] / 255; + const g = data[i + 1] / 255; + const b = data[i + 2] / 255; + const a = data[i + 3] / 255; + data[i] = clampImageDataChannel((matrix[0] * r + matrix[1] * g + matrix[2] * b + matrix[3] * a + matrix[4]) * 255); + data[i + 1] = clampImageDataChannel((matrix[5] * r + matrix[6] * g + matrix[7] * b + matrix[8] * a + matrix[9]) * 255); + data[i + 2] = clampImageDataChannel( + (matrix[10] * r + matrix[11] * g + matrix[12] * b + matrix[13] * a + matrix[14]) * 255, + ); + data[i + 3] = clampImageDataChannel( + (matrix[15] * r + matrix[16] * g + matrix[17] * b + matrix[18] * a + matrix[19]) * 255, + ); + } +} + +export function applyTintToImageData(imageData: ImageData, tint: ParsedCssColor): void { + const data = imageData.data; + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] === 0) { + continue; + } + data[i] = tint.r; + data[i + 1] = tint.g; + data[i + 2] = tint.b; + data[i + 3] = clampImageDataChannel(data[i + 3] * tint.a); + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/utils/imageSource.ts b/src/valdi_modules/src/valdi/web_renderer/src/utils/imageSource.ts new file mode 100644 index 000000000..f393a7f2c --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/utils/imageSource.ts @@ -0,0 +1,122 @@ +import { readCssNumber, skipCssWhitespace } from './cssScanner'; + +const CHAR_COMMA = 44; +const CHAR_LINE_FEED = 10; +const CHAR_SPACE = 32; +const GIF_87A_MAGIC_BYTES: readonly number[] = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]; +const GIF_89A_MAGIC_BYTES: readonly number[] = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; +const JPEG_MAGIC_BYTES: readonly number[] = [0xff, 0xd8, 0xff]; +const PNG_MAGIC_BYTES: readonly number[] = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const RIFF_MAGIC_BYTES: readonly number[] = [0x52, 0x49, 0x46, 0x46]; +const SVG_MAGIC_BYTES: readonly number[] = [0x3c, 0x73, 0x76, 0x67]; +const WEBP_MAGIC_BYTES: readonly number[] = [0x57, 0x45, 0x42, 0x50]; +const WEBP_MAGIC_BYTES_OFFSET = 8; +const XML_MAGIC_BYTES: readonly number[] = [0x3c, 0x3f, 0x78, 0x6d, 0x6c]; + +function bytesMatch(bytes: Uint8Array, offset: number, expected: readonly number[]): boolean { + if (bytes.length < offset + expected.length) { + return false; + } + for (let index = 0; index < expected.length; index++) { + if (bytes[offset + index] !== expected[index]) { + return false; + } + } + return true; +} + +function isSvg(bytes: Uint8Array): boolean { + let offset = 0; + while (bytes[offset] === CHAR_SPACE || bytes[offset] === CHAR_LINE_FEED) { + offset++; + } + return bytesMatch(bytes, offset, SVG_MAGIC_BYTES) || bytesMatch(bytes, offset, XML_MAGIC_BYTES); +} + +export function detectImageMimeType(bytes: Uint8Array): string { + if (bytesMatch(bytes, 0, PNG_MAGIC_BYTES)) { + return 'image/png'; + } + if (bytesMatch(bytes, 0, JPEG_MAGIC_BYTES)) { + return 'image/jpeg'; + } + if (bytesMatch(bytes, 0, GIF_87A_MAGIC_BYTES) || bytesMatch(bytes, 0, GIF_89A_MAGIC_BYTES)) { + return 'image/gif'; + } + if (bytesMatch(bytes, 0, RIFF_MAGIC_BYTES) && bytesMatch(bytes, WEBP_MAGIC_BYTES_OFFSET, WEBP_MAGIC_BYTES)) { + return 'image/webp'; + } + if (isSvg(bytes)) { + return 'image/svg+xml'; + } + return 'application/octet-stream'; +} + +function decodeBase64Text(value: string): string { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return new TextDecoder().decode(bytes); +} + +export function decodeTextDataUrl(value: string, expectedPrefix: string): string | undefined { + if (!value.startsWith(expectedPrefix)) { + return undefined; + } + const commaIndex = value.indexOf(','); + if (commaIndex < 0) { + return undefined; + } + const metadata = value.slice(0, commaIndex); + const payload = value.slice(commaIndex + 1); + return metadata.includes(';base64') ? decodeBase64Text(payload) : decodeURIComponent(payload); +} + +function skipSvgNumberSeparators(value: string, index: number): number { + let nextIndex = skipCssWhitespace(value, index); + if (value.charCodeAt(nextIndex) === CHAR_COMMA) { + nextIndex = skipCssWhitespace(value, nextIndex + 1); + } + return nextIndex; +} + +function parseSvgNumberList(value: string): number[] | undefined { + const numbers: number[] = []; + let index = skipCssWhitespace(value, 0); + while (index < value.length) { + const number = readCssNumber(value, index); + if (!number) { + return undefined; + } + numbers.push(number.value); + index = skipSvgNumberSeparators(value, number.nextIndex); + } + return numbers; +} + +export function svgViewBoxIntrinsicSize(src: string): { width: number; height: number } | undefined { + const text = decodeTextDataUrl(src, 'data:image/svg+xml'); + if (text === undefined) { + return undefined; + } + const viewBoxIndex = text.indexOf('viewBox='); + if (viewBoxIndex < 0) { + return undefined; + } + const quote = text.charAt(viewBoxIndex + 8); + if (quote !== '"' && quote !== "'") { + return undefined; + } + const start = viewBoxIndex + 9; + const end = text.indexOf(quote, start); + if (end < 0) { + return undefined; + } + const parts = parseSvgNumberList(text.slice(start, end)); + if (!parts || parts.length !== 4 || parts[2] <= 0 || parts[3] <= 0) { + return undefined; + } + return { width: parts[2], height: parts[3] }; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/utils/parseAttributedText.ts b/src/valdi_modules/src/valdi/web_renderer/src/utils/parseAttributedText.ts index bfe65f109..c7d62a417 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/utils/parseAttributedText.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/utils/parseAttributedText.ts @@ -1,6 +1,20 @@ -import { AttributedText, AttributedTextOnTap } from 'valdi_tsx/src/AttributedText'; +import { Base64 } from 'coreutils/src/Base64'; +import { AttributedText, AttributedTextOnLayout, AttributedTextOnTap } from 'valdi_tsx/src/AttributedText'; +import { AttributedTextInlineImageAttachment } from 'valdi_tsx/src/AttributedTextInlineImageAttachment'; +import { + AttributedTextInlineViewAttachment, + AttributedTextInlineViewVerticalAlignment, +} from 'valdi_tsx/src/AttributedTextInlineViewAttachment'; import { LabelTextDecoration } from 'valdi_tsx/src/NativeTemplateElements'; -import { convertColor } from '../styles/ValdiWebStyles'; +import type { AttributeApplierContext, ElementLayoutObserver } from '../core/ElementClass'; +import { COLOR_PALETTE_MANAGER } from '../core/Palette'; +import { applyFontString } from '../elements/ElementClassSupport'; +import { + markTextAnimationAttachmentSpan, + NormalizedTextAnimationTransform, + normalizeTextAnimationTransform, + setTextAnimationTransform, +} from './TextAnimationTypes'; const enum AttributedTextEntryType { Content = 1, @@ -22,167 +36,488 @@ const enum AttributedTextEntryType { InlineView, } -interface StyleState { +export interface StyleState { font?: string; color?: string; + backgroundColor?: string; + backgroundPadding?: number | { left?: number; top?: number; right?: number; bottom?: number }; + backgroundBorderRadius?: number | string; textDecoration?: LabelTextDecoration; onTap?: AttributedTextOnTap; + onLayout?: AttributedTextOnLayout; outlineColor?: string; outlineWidth?: number; outerOutlineColor?: string; + outerOutlineWidth?: number; + inlineImage?: AttributedTextInlineImageAttachment; + inlineView?: AttributedTextInlineViewAttachment; + animationTransform?: NormalizedTextAnimationTransform; } interface StyleStackEntry { - type: keyof StyleState; - value: any; + type?: keyof StyleState; + value?: any; } +export interface AttributedTextPart { + content: string; + style: StyleState; +} + +interface AttributedLayoutMeasurement { + onLayout: AttributedTextOnLayout; + span: Element; + outlineWidth: number; + x: number; + y: number; + width: number; + height: number; +} + +type ScheduleAttributedTextLayoutNotification = (callback: () => void) => void; + +const ATTRIBUTED_TEXT_LAYOUT_OBSERVER_STATE_KEY = '__attributedTextLayoutObserver'; + export function isAttributedText(value: any): value is AttributedText { return Array.isArray(value) && value.length > 0 && typeof value[0] === 'number'; } -export function renderAttributedText(attributedText: AttributedText): HTMLSpanElement { +export class ParsedAttributedText { + static parse(attributedText: AttributedText): ParsedAttributedText { + const parts: AttributedTextPart[] = []; + const styleStack: StyleStackEntry[] = []; + const animationPartCounts: number[] = []; + let hasOnLayout = false; + + let i = 0; + while (i < attributedText.length) { + const entry = attributedText[i]; + + if (typeof entry !== 'number') { + i++; + continue; + } + + switch (entry) { + case AttributedTextEntryType.Content: { + const style = styleStateForPart(styleStack, animationPartCounts); + hasOnLayout = hasOnLayout || !!style.onLayout; + parts.push({ content: String(attributedText[i + 1] ?? ''), style }); + i += 2; + break; + } + case AttributedTextEntryType.Pop: + styleStack.pop(); + i++; + break; + case AttributedTextEntryType.PushFont: + styleStack.push({ type: 'font', value: attributedText[i + 1] }); + i += 2; + break; + case AttributedTextEntryType.PushTextDecoration: + styleStack.push({ type: 'textDecoration', value: attributedText[i + 1] }); + i += 2; + break; + case AttributedTextEntryType.PushColor: + styleStack.push({ type: 'color', value: attributedText[i + 1] }); + i += 2; + break; + case AttributedTextEntryType.PushBackgroundColor: + styleStack.push({ type: 'backgroundColor', value: attributedText[i + 1] }); + i += 2; + break; + case AttributedTextEntryType.PushBackgroundPadding: + styleStack.push({ type: 'backgroundPadding', value: attributedText[i + 1] }); + i += 2; + break; + case AttributedTextEntryType.PushBackgroundBorderRadius: + styleStack.push({ type: 'backgroundBorderRadius', value: attributedText[i + 1] }); + i += 2; + break; + case AttributedTextEntryType.PushOnTap: + styleStack.push({ type: 'onTap', value: attributedText[i + 1] }); + i += 2; + break; + case AttributedTextEntryType.PushOnLayout: + styleStack.push({ type: 'onLayout', value: attributedText[i + 1] }); + i += 2; + break; + case AttributedTextEntryType.PushOutlineColor: + styleStack.push({ type: 'outlineColor', value: attributedText[i + 1] }); + i += 2; + break; + case AttributedTextEntryType.PushOutlineWidth: + styleStack.push({ type: 'outlineWidth', value: attributedText[i + 1] }); + i += 2; + break; + case AttributedTextEntryType.PushOuterOutlineColor: + styleStack.push({ type: 'outerOutlineColor', value: attributedText[i + 1] }); + i += 2; + break; + case AttributedTextEntryType.PushOuterOutlineWidth: + styleStack.push({ type: 'outerOutlineWidth', value: attributedText[i + 1] }); + i += 2; + break; + case AttributedTextEntryType.InlineImage: { + const style = styleStateForPart(styleStack, animationPartCounts); + hasOnLayout = hasOnLayout || !!style.onLayout; + style.inlineImage = attributedText[i + 1] as AttributedTextInlineImageAttachment; + parts.push({ content: '', style }); + i += 2; + break; + } + case AttributedTextEntryType.InlineView: { + const style = styleStateForPart(styleStack, animationPartCounts); + hasOnLayout = hasOnLayout || !!style.onLayout; + style.inlineView = attributedText[i + 1] as AttributedTextInlineViewAttachment; + parts.push({ content: '', style }); + i += 2; + break; + } + case AttributedTextEntryType.PushAnimationTransform: + { + const entryValue = attributedText[i + 1]; + const animationTransform = normalizeTextAnimationTransform(entryValue, animationPartCounts.length); + if (animationTransform) { + animationPartCounts.push(0); + styleStack.push({ type: 'animationTransform', value: animationTransform }); + } else { + logInvalidTextAnimationTransform(entryValue); + styleStack.push({}); + } + } + i += 2; + break; + default: + i++; + break; + } + } + + return new ParsedAttributedText(parts, hasOnLayout); + } + + constructor( + readonly parts: AttributedTextPart[], + readonly hasOnLayout: boolean, + ) {} + + toString(): string { + let out = ''; + for (const part of this.parts) { + out += part.content; + } + return out; + } +} + +export function renderAttributedText( + attributedText: ParsedAttributedText, + context?: AttributeApplierContext, +): HTMLSpanElement { const container = document.createElement('span'); - const styleStack: StyleStackEntry[] = []; + for (const part of attributedText.parts) { + container.appendChild(createStyledSpan(part.content, part.style, context)); + } - let i = 0; - while (i < attributedText.length) { - const entry = attributedText[i]; + return container; +} - if (typeof entry !== 'number') { - i++; +function styleStateFromStack(styleStack: StyleStackEntry[]): StyleState { + const style: StyleState = {}; + for (let i = styleStack.length - 1; i >= 0; i--) { + const stackEntry = styleStack[i]; + if (!stackEntry.type) { continue; } + if (style[stackEntry.type] === undefined) { + style[stackEntry.type] = stackEntry.value; + } + } + return style; +} - switch (entry) { - case AttributedTextEntryType.Content: { - const text = attributedText[i + 1] as string; - i += 2; +function styleStateForPart(styleStack: StyleStackEntry[], animationPartCounts: number[]): StyleState { + const style = styleStateFromStack(styleStack); + const animationTransform = style.animationTransform; + if (animationTransform && animationTransform.groupIndex < animationPartCounts.length) { + style.animationTransform = { + ...animationTransform, + partIndexInGroup: animationPartCounts[animationTransform.groupIndex]++, + }; + } + return style; +} - const style: StyleState = {}; - for (let j = styleStack.length - 1; j >= 0; j--) { - const stackEntry = styleStack[j]; - if (style[stackEntry.type] === undefined) { - style[stackEntry.type] = stackEntry.value; - } - } +function logInvalidTextAnimationTransform(value: unknown): void { + console.error('Invalid text animation transform: expected an object', value); +} + +class AttributedTextLayoutObserver implements ElementLayoutObserver { + private readonly measurements: AttributedLayoutMeasurement[] = []; + private notificationScheduled = false; + private readonly notifyLayouts = () => { + this.notificationScheduled = false; + for (let i = 0; i < this.measurements.length; i++) { + const measurement = this.measurements[i]; + measurement.onLayout(measurement.x, measurement.y, measurement.width, measurement.height); + } + }; - const span = createStyledSpan(text, style); - container.appendChild(span); - break; + constructor( + attributedText: ParsedAttributedText, + private readonly container: HTMLElement, + private readonly relativeTo: HTMLElement | undefined, + private readonly scheduleNotification: ScheduleAttributedTextLayoutNotification | undefined, + ) { + for (let i = 0; i < attributedText.parts.length; i++) { + const style = attributedText.parts[i].style; + const onLayout = style.onLayout; + if (onLayout) { + const outlineColor = style.outerOutlineColor ?? style.outlineColor; + const outlineWidth = style.outerOutlineWidth ?? style.outlineWidth; + this.measurements.push({ + onLayout, + span: container.childNodes.item(i) as HTMLSpanElement, + outlineWidth: outlineColor && outlineWidth ? outlineWidth : 0, + x: 0, + y: 0, + width: 0, + height: 0, + }); } - case AttributedTextEntryType.Pop: - styleStack.pop(); - i++; - break; - case AttributedTextEntryType.PushFont: - styleStack.push({ type: 'font', value: attributedText[i + 1] }); - i += 2; - break; - case AttributedTextEntryType.PushTextDecoration: - styleStack.push({ type: 'textDecoration', value: attributedText[i + 1] }); - i += 2; - break; - case AttributedTextEntryType.PushColor: - styleStack.push({ type: 'color', value: attributedText[i + 1] }); - i += 2; - break; - case AttributedTextEntryType.PushOnTap: - styleStack.push({ type: 'onTap', value: attributedText[i + 1] }); - i += 2; - break; - case AttributedTextEntryType.PushOnLayout: - i += 2; - break; - case AttributedTextEntryType.PushOutlineColor: - styleStack.push({ type: 'outlineColor', value: attributedText[i + 1] }); - i += 2; - break; - case AttributedTextEntryType.PushOutlineWidth: - styleStack.push({ type: 'outlineWidth', value: attributedText[i + 1] }); - i += 2; - break; - case AttributedTextEntryType.PushOuterOutlineColor: - styleStack.push({ type: 'outerOutlineColor', value: attributedText[i + 1] }); - i += 2; - break; - case AttributedTextEntryType.PushOuterOutlineWidth: - case AttributedTextEntryType.InlineImage: - case AttributedTextEntryType.PushAnimationTransform: - case AttributedTextEntryType.PushBackgroundColor: - case AttributedTextEntryType.PushBackgroundPadding: - case AttributedTextEntryType.PushBackgroundBorderRadius: - case AttributedTextEntryType.InlineView: - i += 2; - break; - default: - i++; - break; } } - return container; + onSizeChanged(_width: number, _height: number): void { + const parentRect = (this.relativeTo ?? this.container).getBoundingClientRect(); + for (let i = 0; i < this.measurements.length; i++) { + const measurement = this.measurements[i]; + const rect = measurement.span.getBoundingClientRect(); + measurement.x = rect.left - parentRect.left; + measurement.y = rect.top - parentRect.top; + measurement.width = Math.max(0, rect.width - measurement.outlineWidth * 2); + measurement.height = rect.height; + } + } + + onCommit(_element: HTMLElement): void { + if (!this.scheduleNotification) { + this.notifyLayouts(); + return; + } + if (!this.notificationScheduled) { + this.notificationScheduled = true; + this.scheduleNotification(this.notifyLayouts); + } + } } -function createStyledSpan(text: string, style: StyleState): HTMLSpanElement { +export function dispatchAttributedTextLayouts( + attributedText: ParsedAttributedText, + container: HTMLElement, + relativeTo?: HTMLElement, +): void { + if (attributedText.hasOnLayout) { + const observer = new AttributedTextLayoutObserver(attributedText, container, relativeTo, undefined); + observer.onSizeChanged(0, 0); + observer.onCommit(container); + } +} + +export function unregisterAttributedTextLayouts(context: AttributeApplierContext, attributeName: string): void { + context.setState(ATTRIBUTED_TEXT_LAYOUT_OBSERVER_STATE_KEY, undefined); + context.setLayoutObserver(attributeName, undefined); +} + +export function registerAttributedTextLayouts( + context: AttributeApplierContext, + attributeName: string, + attributedText: ParsedAttributedText, + container: HTMLElement, + relativeTo?: HTMLElement, +): void { + if (!attributedText.hasOnLayout) { + unregisterAttributedTextLayouts(context, attributeName); + return; + } + + let observer: AttributedTextLayoutObserver; + observer = new AttributedTextLayoutObserver(attributedText, container, relativeTo, callback => { + context.enqueuePostLayoutCallback(() => { + if (context.getState(ATTRIBUTED_TEXT_LAYOUT_OBSERVER_STATE_KEY) !== observer) { + return; + } + try { + callback(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Valdi web renderer failed to notify attributed text layout on node ${context.id}: ${message}`); + } + }); + }); + context.setState(ATTRIBUTED_TEXT_LAYOUT_OBSERVER_STATE_KEY, observer); + context.setLayoutObserver(attributeName, observer); +} + +function backgroundPaddingToCss( + padding: number | { left?: number; top?: number; right?: number; bottom?: number }, +): string { + if (typeof padding === 'number') { + return `${padding}px`; + } + return `${padding.top ?? 0}px ${padding.right ?? 0}px ${padding.bottom ?? 0}px ${padding.left ?? 0}px`; +} + +function backgroundBorderRadiusToCss(radius: number | string): string { + return typeof radius === 'number' ? `${radius}px` : radius; +} + +function applyTextDecoration(span: HTMLSpanElement, decoration: LabelTextDecoration | undefined): void { + switch (decoration) { + case 'underline': + span.style.textDecorationLine = 'underline'; + break; + case 'dashed-underline': + span.style.textDecorationLine = 'underline'; + span.style.textDecorationStyle = 'dashed'; + break; + case 'dotted-underline': + span.style.textDecorationLine = 'underline'; + span.style.textDecorationStyle = 'dotted'; + break; + case 'strikethrough': + span.style.textDecorationLine = 'line-through'; + break; + default: + break; + } +} + +function applyOutline( + span: HTMLSpanElement, + color: string | undefined, + width: number | undefined, + context: AttributeApplierContext | undefined, +): void { + if (!color || !width) { + return; + } + span.style.webkitTextStroke = `${width}px ${convertColor(color, context)}`; + span.style.paintOrder = 'stroke fill'; +} + +function applyInlineImage(span: HTMLSpanElement, attachment: AttributedTextInlineImageAttachment): void { + markTextAnimationAttachmentSpan(span); + span.textContent = ''; + const image = document.createElement('img'); + image.alt = attachment.attachmentId; + image.style.display = 'inline-block'; + image.style.height = `${attachment.height}px`; + image.style.verticalAlign = 'middle'; + image.style.width = `${attachment.width}px`; + if (attachment.imageData) { + image.src = `data:image/png;base64,${Base64.fromByteArray(attachment.imageData)}`; + } + span.appendChild(image); +} + +function verticalAlignForInlineView(attachment: AttributedTextInlineViewAttachment): string { + switch (attachment.verticalAlignment) { + case AttributedTextInlineViewVerticalAlignment.Top: + return 'top'; + case AttributedTextInlineViewVerticalAlignment.Bottom: + return 'bottom'; + case AttributedTextInlineViewVerticalAlignment.Baseline: + return 'baseline'; + case AttributedTextInlineViewVerticalAlignment.TextBottom: + return 'text-bottom'; + case AttributedTextInlineViewVerticalAlignment.Center: + default: + return 'middle'; + } +} + +function applyInlineView( + span: HTMLSpanElement, + attachment: AttributedTextInlineViewAttachment, + context: AttributeApplierContext | undefined, +): void { + markTextAnimationAttachmentSpan(span); + span.textContent = ''; + span.style.alignItems = 'center'; + span.style.display = 'inline-flex'; + span.style.verticalAlign = verticalAlignForInlineView(attachment); + const child = context?.getChildHtmlElement(attachment.childIndex); + if (child) { + span.appendChild(child); + } +} + +function createStyledSpan( + text: string, + style: StyleState, + context: AttributeApplierContext | undefined, +): HTMLSpanElement { const span = document.createElement('span'); span.textContent = text; - if (style.color) { - span.style.color = convertColor(style.color); + if (style.inlineImage) { + applyInlineImage(span, style.inlineImage); } - if (style.font) { - const tokens = style.font.trim().split(/\s+/); - let familyEnd = tokens.length; - // Parse optional weight (last token) and size (second-to-last) from the - // tail so multi-word families like "Times New Roman 16 bold" work. - if (familyEnd > 1 && isWeightToken(tokens[familyEnd - 1])) { - span.style.fontWeight = tokens[--familyEnd]; - } - if (familyEnd > 1 && !Number.isNaN(Number(tokens[familyEnd - 1]))) { - span.style.fontSize = `${tokens[--familyEnd]}px`; - } - span.style.fontFamily = tokens.slice(0, familyEnd).join(' '); + if (style.inlineView) { + applyInlineView(span, style.inlineView, context); + } + + if (style.color) { + span.style.color = convertColor(style.color, context); } - if (style.textDecoration === 'underline') { - span.style.textDecoration = 'underline'; - } else if (style.textDecoration === 'strikethrough') { - span.style.textDecoration = 'line-through'; + if (style.font) { + applyFontString(span, style.font, 'font'); } - if (style.outerOutlineColor) { - span.style.backgroundColor = convertColor(style.outerOutlineColor); - span.style.borderRadius = '6px'; - span.style.padding = '2px 8px'; + if (style.backgroundColor) { + span.style.backgroundColor = convertColor(style.backgroundColor, context); span.style.setProperty('box-decoration-break', 'clone'); span.style.setProperty('-webkit-box-decoration-break', 'clone'); - if (style.outlineColor) { - span.style.border = `1px solid ${convertColor(style.outlineColor)}`; - } - } else if (style.outlineColor && style.outlineWidth) { - const w = style.outlineWidth; - span.style.textShadow = `-${w}px -${w}px 0 ${convertColor(style.outlineColor)}, ${w}px -${w}px 0 ${convertColor(style.outlineColor)}, -${w}px ${w}px 0 ${convertColor(style.outlineColor)}, ${w}px ${w}px 0 ${convertColor(style.outlineColor)}`; } + if (style.backgroundPadding !== undefined) { + span.style.padding = backgroundPaddingToCss(style.backgroundPadding); + } + + if (style.backgroundBorderRadius !== undefined) { + span.style.borderRadius = backgroundBorderRadiusToCss(style.backgroundBorderRadius); + } + + applyTextDecoration(span, style.textDecoration); + applyOutline( + span, + style.outerOutlineColor ?? style.outlineColor, + style.outerOutlineWidth ?? style.outlineWidth, + context, + ); + if (style.onTap) { span.style.cursor = 'pointer'; const onTap = style.onTap; - span.onclick = (e) => { + span.onclick = e => { e.stopPropagation(); onTap(); }; } + if (style.animationTransform) { + setTextAnimationTransform(span, style.animationTransform); + } + return span; } -const FONT_WEIGHTS = new Set([ - 'normal', 'bold', 'lighter', 'bolder', - '100', '200', '300', '400', '500', '600', '700', '800', '900', -]); - -function isWeightToken(token: string): boolean { - return FONT_WEIGHTS.has(token.toLowerCase()); +function convertColor(color: string, context: AttributeApplierContext | undefined): string { + return context + ? context.resolveColor(color) + : COLOR_PALETTE_MANAGER.resolveColor(COLOR_PALETTE_MANAGER.getActiveColorPaletteName(), color); } diff --git a/src/valdi_modules/src/valdi/web_renderer/src/utils/textStyle.ts b/src/valdi_modules/src/valdi/web_renderer/src/utils/textStyle.ts new file mode 100644 index 000000000..c129f81ad --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/utils/textStyle.ts @@ -0,0 +1,37 @@ +import { AttributeApplierContext } from '../core/ElementClass'; +import { applyCssColorOpacity } from './cssColor'; +import { isPlainCssNumber, readPreviousWhitespaceSeparatedToken } from './cssScanner'; + +function readTrailingNumberToken( + value: string, + endIndex: number, +): { value: number; startIndex: number } | undefined { + const token = readPreviousWhitespaceSeparatedToken(value, endIndex); + if (!token || !isPlainCssNumber(token.token)) { + return undefined; + } + return { + value: Number(token.token), + startIndex: token.startIndex, + }; +} + +export function textShadowCssValue( + value: string, + context: AttributeApplierContext, +): string | undefined { + const offsetY = readTrailingNumberToken(value, value.length); + const offsetX = offsetY ? readTrailingNumberToken(value, offsetY.startIndex) : undefined; + const opacity = offsetX ? readTrailingNumberToken(value, offsetX.startIndex) : undefined; + const radius = opacity ? readTrailingNumberToken(value, opacity.startIndex) : undefined; + if (!offsetY || !offsetX || !opacity || !radius) { + return undefined; + } + + const color = value.slice(0, radius.startIndex).trim(); + if (color.length === 0) { + return undefined; + } + const finalColor = applyCssColorOpacity(context.resolveColor(color), String(opacity.value)); + return `${offsetX.value}px ${offsetY.value}px ${radius.value}px ${finalColor}`; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/test/Animator.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/Animator.spec.ts new file mode 100644 index 000000000..fdbd6b69f --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/Animator.spec.ts @@ -0,0 +1,93 @@ +import 'jasmine/src/jasmine'; +import { Animator, type AnimatorDelegate } from '../src/animations/Animator'; +import type { AnimatorCommitPreparation } from '../src/animations/AnimatorCommitPreparation'; +import { KeyAnimation } from '../src/animations/KeyAnimation'; + +class TestKeyAnimation extends KeyAnimation { + didFinishAnimation = false; + + constructor() { + super(0.1); + } + + override applyProgress(_progress: number): boolean { + return true; + } + + override applyFinalValue(): void {} + + protected override didFinish(): void { + this.didFinishAnimation = true; + } +} + +class TestCommitPreparation implements AnimatorCommitPreparation { + prepared = false; + cancelled = false; + + constructor(private readonly animation: KeyAnimation) {} + + prepareForCommit(animator: Animator): void { + this.prepared = true; + animator.addAnimation(this, 'prepared', this.animation); + } + + cancel(): void { + this.cancelled = true; + } +} + +describe('Animator', () => { + const delegate: AnimatorDelegate = { + animatorWillApplyLayoutMutation: () => {}, + }; + it('replaces animations with the same owner and key', () => { + const animator = new Animator({ duration: 1 }, 1, delegate); + const owner = {}; + const first = new TestKeyAnimation(); + const second = new TestKeyAnimation(); + + animator.addAnimation(owner, 'value', first); + animator.addAnimation(owner, 'value', second); + + expect(first.didFinishAnimation).toBeTrue(); + expect(animator.takeAnimations()).toEqual([second]); + }); + + it('notifies its delegate only once about layout mutations', () => { + let callCount = 0; + const animator = new Animator({ duration: 1 }, 2, { + animatorWillApplyLayoutMutation: () => callCount++, + }); + + animator.willApplyLayoutMutation(); + animator.willApplyLayoutMutation(); + + expect(callCount).toBe(1); + }); + + it('runs commit preparations once and allows them to add animations', () => { + const animator = new Animator({ duration: 1 }, 3, delegate); + const animation = new TestKeyAnimation(); + const preparation = new TestCommitPreparation(animation); + + animator.addCommitPreparation('layout', preparation); + expect(animator.getCommitPreparation('layout')).toBe(preparation); + animator.prepareForCommit(); + + expect(preparation.prepared).toBeTrue(); + expect(preparation.cancelled).toBeFalse(); + expect(animator.takeAnimations()).toEqual([animation]); + }); + + it('cancels commit preparations when the animator completes before preparation', () => { + const animator = new Animator({ duration: 1 }, 4, delegate); + const preparation = new TestCommitPreparation(new TestKeyAnimation()); + animator.addCommitPreparation('layout', preparation); + + animator.complete(true); + + expect(preparation.prepared).toBeFalse(); + expect(preparation.cancelled).toBeTrue(); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/AttributeApplierHelpers.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/AttributeApplierHelpers.spec.ts new file mode 100644 index 000000000..7afcaf40a --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/AttributeApplierHelpers.spec.ts @@ -0,0 +1,14 @@ +import 'jasmine/src/jasmine'; +import { parseCssLength, parseCssTrackList } from '../src/attributes/AttributeApplierHelpers'; + +describe('AttributeApplierHelpers', () => { + it('adds px to Valdi CSS lengths without changing existing units', () => { + expect(parseCssLength('8 12px -4', 'padding')).toBe('8px 12px -4px'); + }); + + it('preserves repeat counts while adding px to grid track sizes', () => { + expect(parseCssTrackList('repeat(2, minmax(40, 1fr) 20)', 'gridTemplateColumns')).toBe( + 'repeat(2, minmax(40px, 1fr) 20px)', + ); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/AttributesBinder.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/AttributesBinder.spec.ts new file mode 100644 index 000000000..94f93b8c8 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/AttributesBinder.spec.ts @@ -0,0 +1,231 @@ +import 'jasmine/src/jasmine'; +import { AttributesApplier, AttributeSetResult } from '../src/attributes/AttributesApplier'; +import { AttributesBinder } from '../src/attributes/AttributesBinder'; +import { AttributeApplierContext, ElementClass } from '../src/core/ElementClass'; + +function createContext(): AttributeApplierContext { + const state = new Map(); + const viewAttributeElement = createElement(); + return { + id: 1, + getState(key: string): T | undefined { + return state.get(key) as T | undefined; + }, + setState(key: string, value: unknown): void { + state.set(key, value); + }, + getViewAttributeElement(): HTMLElement { + return viewAttributeElement; + }, + resolveColor(value: string): string { + return value === 'primary' ? '#123456' : value; + }, + setColorPalette(): void {}, + addCleanup(): void {}, + enqueuePostLayoutCallback(): void {}, + getLayoutObserver(): undefined { + return undefined; + }, + setLayoutObserver(_attributeName: string): void {}, + requestLayoutPass(): void {}, + setOnLayoutCallback(): void {}, + getChildHtmlElement(): HTMLElement | undefined { + return undefined; + }, + onAttributeUpdatedExternally(): void {}, + emitCurrentViewCreate(): void {}, + emitCurrentViewChange(): void {}, + isAnimationEnabled(): boolean { + return true; + }, + setAnimationsEnabled(): void {}, + }; +} + +function createElement(): HTMLElement { + const attributes = new Map(); + return { + style: {}, + getAttribute(name: string): string | null { + return attributes.get(name) ?? null; + }, + removeAttribute(name: string): void { + attributes.delete(name); + }, + setAttribute(name: string, value: string): void { + attributes.set(name, value); + }, + } as HTMLElement; +} + +describe('AttributesBinder', () => { + it('forwards attribute names to apply and reset callbacks', () => { + const binder = new AttributesBinder(); + const element = createElement(); + const context = createContext(); + let appliedAttributeName: string | undefined; + let resetAttributeName: string | undefined; + binder.bindStringAttribute( + 'value', + (_target, _value, _context, attributeName) => { + appliedAttributeName = attributeName; + }, + (_target, _context, attributeName) => { + resetAttributeName = attributeName; + }, + ); + + binder.attributeAppliers.value.apply(element, 'text', 'value', context); + binder.attributeAppliers.value.reset(element, 'value', context); + + expect(appliedAttributeName).toBe('value'); + expect(resetAttributeName).toBe('value'); + }); + + it('binds number attributes with parsing and reset handling', () => { + const binder = new AttributesBinder(); + const element = createElement(); + const context = createContext(); + binder.bindNumberAttribute( + 'opacity', + (target, value) => { + target.style.opacity = String(value); + }, + target => { + target.style.opacity = ''; + }, + ); + + binder.attributeAppliers.opacity.apply(element, 0.5, 'opacity', context); + expect(element.style.opacity).toBe('0.5'); + + binder.attributeAppliers.opacity.reset(element, 'opacity', context); + expect(element.style.opacity).toBe(''); + expect(() => binder.attributeAppliers.opacity.apply(element, '0.5', 'opacity', context)).toThrow(); + }); + + it('binds enum attributes with validation', () => { + const binder = new AttributesBinder(); + const element = createElement(); + const context = createContext(); + binder.bindEnumAttribute( + 'display', + ['flex', 'grid'] as const, + (target, value) => { + target.style.display = value; + }, + target => { + target.style.display = 'flex'; + }, + ); + + binder.attributeAppliers.display.apply(element, 'grid', 'display', context); + expect(element.style.display).toBe('grid'); + expect(() => binder.attributeAppliers.display.apply(element, 'block', 'display', context)).toThrow(); + }); + + it('binds function attributes with validation', () => { + const binder = new AttributesBinder(); + const element = createElement(); + const context = createContext(); + let called = false; + binder.bindFunctionAttribute( + 'onTap', + (_target, callback) => { + callback(); + }, + () => {}, + ); + + binder.attributeAppliers.onTap.apply( + element, + () => { + called = true; + }, + 'onTap', + context, + ); + + expect(called).toBeTrue(); + expect(() => binder.attributeAppliers.onTap.apply(element, 1, 'onTap', context)).toThrow(); + }); + + it('binds direct DOM attributes', () => { + const binder = new AttributesBinder(); + const element = createElement(); + const context = createContext(); + binder.bindDirectAttribute('accessibilityLabel', 'aria-label'); + + binder.attributeAppliers.accessibilityLabel.apply(element, 'Label', 'accessibilityLabel', context); + expect(element.getAttribute('aria-label')).toBe('Label'); + + binder.attributeAppliers.accessibilityLabel.reset(element, 'accessibilityLabel', context); + expect(element.getAttribute('aria-label')).toBeNull(); + }); + + it('binds ARIA boolean attributes using DOM boolean text values', () => { + const binder = new AttributesBinder(); + const element = createElement(); + const context = createContext(); + binder.bindAriaBooleanAttribute('accessibilityHidden', 'aria-hidden'); + + binder.attributeAppliers.accessibilityHidden.apply(element, 1, 'accessibilityHidden', context); + expect(element.getAttribute('aria-hidden')).toBe('true'); + + binder.attributeAppliers.accessibilityHidden.apply(element, 0, 'accessibilityHidden', context); + expect(element.getAttribute('aria-hidden')).toBe('false'); + + binder.attributeAppliers.accessibilityHidden.reset(element, 'accessibilityHidden', context); + expect(element.getAttribute('aria-hidden')).toBeNull(); + }); + + it('marks color style attributes as color dependent', () => { + const binder = new AttributesBinder(); + const element = createElement(); + const context = createContext(); + binder.bindColorStyleAttribute('backgroundColor', 'backgroundColor', ''); + + expect(binder.attributeAppliers.backgroundColor.colorDependent).toBeTrue(); + binder.attributeAppliers.backgroundColor.apply(element, 'primary', 'backgroundColor', context); + expect(element.style.backgroundColor).toBe('#123456'); + }); + + it('marks layout dependent attributes explicitly', () => { + const binder = new AttributesBinder(); + binder.bindNoOpAttribute('paintOnly'); + binder.bindNoOpAttribute('width', true); + + expect(binder.attributeAppliers.paintOnly.layoutDependent).toBeUndefined(); + expect(binder.attributeAppliers.width.layoutDependent).toBeTrue(); + }); +}); + +describe('AttributesApplier', () => { + class TestElementClass extends ElementClass { + constructor() { + const binder = new AttributesBinder(); + binder.bindNoOpAttribute('paintOnly'); + binder.bindNoOpAttribute('width', true); + super('test', binder.attributeAppliers); + } + + protected onCreateElement(): HTMLElement { + return createElement(); + } + } + + it('reports layout invalidation only for changed layout dependent attributes', () => { + const applier = new AttributesApplier(1, new TestElementClass()); + + expect(applier.setAttribute('paintOnly', 1)).toBe(AttributeSetResult.Changed); + expect(applier.setAttribute('width', 10)).toBe(AttributeSetResult.ChangedAndInvalidatesLayout); + expect(applier.setAttribute('width', 10)).toBe(AttributeSetResult.Unchanged); + }); + + it('does not invalidate layout when a style value loses to direct attribute precedence', () => { + const applier = new AttributesApplier(1, new TestElementClass()); + + expect(applier.setAttribute('width', 10)).toBe(AttributeSetResult.ChangedAndInvalidatesLayout); + expect(applier.setAttribute('style', { attributes: { width: 20 } })).toBe(AttributeSetResult.Unchanged); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/CanvasImageRenderer.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/CanvasImageRenderer.spec.ts new file mode 100644 index 000000000..391788c03 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/CanvasImageRenderer.spec.ts @@ -0,0 +1,14 @@ +import 'jasmine/src/jasmine'; +import { WEB_IMAGE_NATURAL_SCALE, getDecodedImageSize } from '../src/elements/CanvasImageRenderer'; + +describe('CanvasImageRenderer', () => { + it('reports logical SVG overrides using the web natural scale', () => { + const image = { naturalWidth: 30, naturalHeight: 15 } as HTMLImageElement; + + expect(getDecodedImageSize(image, 120, 80)).toEqual({ + width: 120 * WEB_IMAGE_NATURAL_SCALE, + height: 80 * WEB_IMAGE_NATURAL_SCALE, + }); + expect(getDecodedImageSize(image, undefined, undefined)).toEqual({ width: 30, height: 15 }); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/ChromeDevToolsTracing.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/ChromeDevToolsTracing.spec.ts new file mode 100644 index 000000000..9e7b92cb8 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/ChromeDevToolsTracing.spec.ts @@ -0,0 +1,54 @@ +import 'jasmine/src/jasmine'; +import { ChromeDevToolsTracing } from '../src/tracing/ChromeDevToolsTracing'; + +describe('ChromeDevToolsTracing', () => { + let timeStampSpy: any; + let originalTimeStamp: typeof console.timeStamp | undefined; + + beforeEach(() => { + originalTimeStamp = console.timeStamp; + timeStampSpy = jasmine.createSpy('timeStamp'); + Object.defineProperty(console, 'timeStamp', { configurable: true, value: timeStampSpy }); + }); + + afterEach(() => { + if (originalTimeStamp) { + Object.defineProperty(console, 'timeStamp', { configurable: true, value: originalTimeStamp }); + } else { + Reflect.deleteProperty(console, 'timeStamp'); + } + }); + + it('emits nested duration traces on the Valdi custom track', () => { + spyOn(performance, 'now').and.returnValues(10, 20, 30, 40); + const tracing = new ChromeDevToolsTracing(); + + tracing.beginTrace('outer'); + tracing.beginTrace('inner'); + tracing.endTrace(); + tracing.endTrace(); + + expect(timeStampSpy.calls.allArgs()).toEqual([ + ['Valdi.inner', 20, 30, 'Valdi JS', 'Valdi', 'primary'], + ['Valdi.outer', 10, 40, 'Valdi JS', 'Valdi', 'primary'], + ]); + }); + + it('emits instant traces with arguments', () => { + spyOn(performance, 'now').and.returnValue(25); + const tracing = new ChromeDevToolsTracing(); + + tracing.instantTrace('event', ['nodeId', 12, 'attributeName', 'opacity']); + + expect(timeStampSpy).toHaveBeenCalledWith('Valdi.event', 25, 25, 'Valdi JS', 'Valdi', 'primary', { + nodeId: 12, + attributeName: 'opacity', + }); + }); + + it('ignores unmatched trace ends', () => { + new ChromeDevToolsTracing().endTrace(); + + expect(timeStampSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/ElementClassSupport.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/ElementClassSupport.spec.ts new file mode 100644 index 000000000..9ea44016c --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/ElementClassSupport.spec.ts @@ -0,0 +1,25 @@ +import { getActiveElement } from '../src/elements/ElementClassSupport'; + +class FakeShadowRoot { + constructor(readonly activeElement: Element | null) {} +} + +describe('ElementClassSupport', () => { + const previousShadowRoot = globalThis.ShadowRoot; + + beforeEach(() => { + (globalThis as unknown as { ShadowRoot: typeof FakeShadowRoot }).ShadowRoot = FakeShadowRoot; + }); + + afterEach(() => { + (globalThis as unknown as { ShadowRoot: typeof ShadowRoot }).ShadowRoot = previousShadowRoot; + }); + + it('reads the active element from the containing shadow root', () => { + const activeElement = {} as Element; + const root = new FakeShadowRoot(activeElement); + const element = { getRootNode: () => root } as unknown as HTMLElement; + + expect(getActiveElement(element)).toBe(activeElement); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/IndexedRecord.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/IndexedRecord.spec.ts new file mode 100644 index 000000000..5a8b5d8d7 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/IndexedRecord.spec.ts @@ -0,0 +1,101 @@ +import 'jasmine/src/jasmine'; +import { IndexedRecord } from '../src/utils/IndexedRecord'; + +function sorted(values: string[]): string[] { + return values.slice().sort(); +} + +describe('IndexedRecord', () => { + it('stores values and tracks keys without duplicating updated keys', () => { + const record = new IndexedRecord(); + + record.set('width', 10); + record.set('height', 20); + record.set('width', 30); + + expect(record.empty).toBe(false); + expect(record.length).toBe(2); + expect(record.get('width')).toBe(30); + expect(record.get('height')).toBe(20); + expect(sorted(record.keys)).toEqual(['height', 'width']); + }); + + it('removes keys in constant time without preserving key order', () => { + const record = new IndexedRecord(); + + record.set('a', 'first'); + record.set('b', 'second'); + record.set('c', 'third'); + record.remove('b'); + + expect(record.length).toBe(2); + expect(record.get('a')).toBe('first'); + expect(record.get('b')).toBeUndefined(); + expect(record.get('c')).toBe('third'); + expect(sorted(record.keys)).toEqual(['a', 'c']); + }); + + it('ignores removal of missing keys', () => { + const record = new IndexedRecord(); + + record.set('x', 1); + record.remove('missing'); + + expect(record.length).toBe(1); + expect(record.get('x')).toBe(1); + expect(record.keys).toEqual(['x']); + }); + + it('clears all keys and can be reused', () => { + const record = new IndexedRecord(); + + record.set('x', 1); + record.set('y', 2); + record.clear(); + + expect(record.empty).toBe(true); + expect(record.length).toBe(0); + expect(record.keys).toEqual([]); + expect(record.get('x')).toBeUndefined(); + expect(record.get('y')).toBeUndefined(); + + record.set('z', 3); + expect(record.length).toBe(1); + expect(record.keys).toEqual(['z']); + expect(record.get('z')).toBe(3); + }); + + it('pops the last stored value and removes it from the record', () => { + const record = new IndexedRecord(); + + expect(record.empty).toBe(true); + expect(record.pop()).toBeUndefined(); + + record.set('x', 1); + record.set('y', 2); + + expect(record.pop()).toBe(2); + expect(record.length).toBe(1); + expect(record.get('x')).toBe(1); + expect(record.get('y')).toBeUndefined(); + expect(record.keys).toEqual(['x']); + + expect(record.pop()).toBe(1); + expect(record.empty).toBe(true); + expect(record.pop()).toBeUndefined(); + }); + + it('stores falsy values as present values', () => { + const record = new IndexedRecord(); + + record.set('false', false); + record.set('zero', 0); + record.set('empty', ''); + + expect(record.length).toBe(3); + expect(record.get('false')).toBe(false); + expect(record.get('zero')).toBe(0); + expect(record.get('empty')).toBe(''); + expect(sorted(record.keys)).toEqual(['empty', 'false', 'zero']); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/LayoutObserverController.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/LayoutObserverController.spec.ts new file mode 100644 index 000000000..057511414 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/LayoutObserverController.spec.ts @@ -0,0 +1,240 @@ +import 'jasmine/src/jasmine'; +import { ElementFrame } from 'valdi_tsx/src/Geometry'; +import { LayoutObserverController, measureElementFrame } from '../src/LayoutObserverController'; +import { FakeResizeObserver, installObserverTestGlobals, makeElement } from './ObserverTestUtils'; + +interface FakeWindow { + addEventListener(name: string, listener: () => void): void; + removeEventListener(name: string, listener: () => void): void; + dispatchResize(): void; + listenerCount(name: string): number; +} + +function createFakeWindow(): FakeWindow { + const listeners = new Map void>>(); + return { + addEventListener(name, listener) { + const callbacks = listeners.get(name) ?? []; + callbacks.push(listener); + listeners.set(name, callbacks); + }, + removeEventListener(name, listener) { + const callbacks = listeners.get(name); + const index = callbacks?.indexOf(listener) ?? -1; + if (callbacks && index >= 0) { + callbacks.splice(index, 1); + } + }, + dispatchResize() { + const callbacks = listeners.get('resize'); + if (callbacks) { + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](); + } + } + }, + listenerCount(name) { + return listeners.get(name)?.length ?? 0; + }, + }; +} + +describe('LayoutObserverController', () => { + let previousWindow: unknown; + let uninstallGlobals: () => void; + let fakeWindow: FakeWindow; + let scheduledPasses: Array<() => void>; + let controller: LayoutObserverController; + + beforeEach(() => { + uninstallGlobals = installObserverTestGlobals(); + previousWindow = (globalThis as { window?: unknown }).window; + fakeWindow = createFakeWindow(); + (globalThis as { window?: unknown }).window = fakeWindow; + scheduledPasses = []; + controller = new LayoutObserverController(() => {}); + controller.setPostLayoutScheduler(callback => scheduledPasses.push(callback)); + }); + + afterEach(() => { + controller.destroy(); + if (previousWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window?: unknown }).window = previousWindow; + } + uninstallGlobals(); + }); + + function flushPass(): void { + scheduledPasses.shift()!(); + } + + it('measures element frames on demand without observing layout', () => { + const parent = makeElement({ left: 100, top: 50, width: 200, height: 200 }); + parent.scrollLeft = 7; + parent.scrollTop = 9; + parent.borderLeftWidth = '2px'; + parent.borderTopWidth = '3px'; + const element = makeElement({ left: 140, top: 90, width: 30, height: 20 }); + element.offsetParent = parent; + + expect(FakeResizeObserver.lastInstance).toBeUndefined(); + expect(measureElementFrame(element)).toEqual({ x: 45, y: 46, width: 30, height: 20 }); + }); + + it('runs all measurement work before commits and onLayout notifications', () => { + const order: string[] = []; + const first = makeElement({ left: 1, top: 2, width: 30, height: 20 }); + const second = makeElement({ left: 3, top: 4, width: 40, height: 25 }); + controller.setLayoutObserver(1, 'view', first, true, 'borderRadius', { + onSizeChanged() { + order.push('size'); + }, + onCommit() { + order.push('size commit'); + }, + }); + controller.setLayoutObserver(2, 'view', second, true, 'onMeasure', { + onMeasure() { + order.push('measure'); + }, + onCommit() { + order.push('measure commit'); + }, + }); + controller.setOnLayoutCallback(1, 'view', first, true, () => order.push('onLayout')); + + expect(scheduledPasses.length).toBe(1); + flushPass(); + + expect(order).toEqual(['measure', 'size', 'size commit', 'measure commit', 'onLayout']); + }); + + it('owns and defers post-layout callbacks during updates', () => { + const order: string[] = []; + controller.beginUpdate(); + controller.enqueuePostLayoutCallback(() => order.push('first')); + controller.enqueuePostLayoutCallback(() => order.push('second')); + + expect(scheduledPasses).toEqual([]); + controller.endUpdate(); + expect(scheduledPasses.length).toBe(1); + + flushPass(); + expect(order).toEqual(['first', 'second']); + }); + + it('measures each element once and suppresses unchanged size callbacks', () => { + const element = makeElement({ left: 1, top: 2, width: 30, height: 20 }); + const originalGetBoundingClientRect = element.getBoundingClientRect.bind(element); + let rectReadCount = 0; + element.getBoundingClientRect = () => { + rectReadCount++; + return originalGetBoundingClientRect() as DOMRect; + }; + const first = jasmine.createSpy('first'); + const second = jasmine.createSpy('second'); + controller.setLayoutObserver(1, 'view', element, true, 'first', { onSizeChanged: first }); + controller.setLayoutObserver(1, 'view', element, true, 'second', { onSizeChanged: second }); + + flushPass(); + expect(rectReadCount).toBe(1); + expect(first).toHaveBeenCalledOnceWith(30, 20); + expect(second).toHaveBeenCalledOnceWith(30, 20); + + controller.scheduleRefresh(); + flushPass(); + expect(rectReadCount).toBe(2); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('runs onMeasure on every pass even when the element size is unchanged', () => { + const element = makeElement({ left: 1, top: 2, width: 30, height: 20 }); + const onMeasure = jasmine.createSpy('onMeasure'); + const onCommit = jasmine.createSpy('onCommit'); + controller.setLayoutObserver(1, 'view', element, true, 'onMeasure', { onMeasure, onCommit }); + + flushPass(); + controller.scheduleRefresh(); + flushPass(); + + expect(onMeasure).toHaveBeenCalledTimes(2); + expect(onCommit).toHaveBeenCalledTimes(2); + }); + + it('replaces, resets, and destroys attribute-scoped observers', () => { + const element = makeElement({ left: 1, top: 2, width: 30, height: 20 }); + const replaced = jasmine.createSpy('replaced'); + const replacement = jasmine.createSpy('replacement'); + controller.setLayoutObserver(1, 'view', element, true, 'radius', { onSizeChanged: replaced }); + controller.setLayoutObserver(1, 'view', element, true, 'radius', { onSizeChanged: replacement }); + flushPass(); + expect(replaced).not.toHaveBeenCalled(); + expect(replacement).toHaveBeenCalledTimes(1); + + controller.setLayoutObserver(1, 'view', element, true, 'radius', undefined); + expect(FakeResizeObserver.lastInstance!.observedElements).toEqual([]); + controller.setLayoutObserver(1, 'view', element, true, 'radius', { onSizeChanged: replacement }); + controller.destroyElement(1); + expect(FakeResizeObserver.lastInstance!.observedElements).toEqual([]); + }); + + it('uses one resize observer and one browser resize listener', () => { + const first = makeElement({ left: 1, top: 2, width: 30, height: 20 }); + const second = makeElement({ left: 3, top: 4, width: 40, height: 25 }); + controller.setLayoutObserver(1, 'view', first, true, 'first', { onSizeChanged() {} }); + const resizeObserver = FakeResizeObserver.lastInstance; + controller.setOnLayoutCallback(2, 'view', second, true, (_frame: ElementFrame) => {}); + + expect(FakeResizeObserver.lastInstance).toBe(resizeObserver); + expect(resizeObserver!.observedElements).toEqual([first, second]); + expect(fakeWindow.listenerCount('resize')).toBe(1); + flushPass(); + + fakeWindow.dispatchResize(); + fakeWindow.dispatchResize(); + expect(scheduledPasses.length).toBe(1); + + controller.destroy(); + expect(fakeWindow.listenerCount('resize')).toBe(0); + }); + + it('isolates measurement and commit failures from other observers', () => { + const errorSpy = spyOn(console, 'error'); + const first = makeElement({ left: 1, top: 2, width: 30, height: 20 }); + const second = makeElement({ left: 3, top: 4, width: 40, height: 25 }); + const third = makeElement({ left: 5, top: 6, width: 50, height: 35 }); + const successfulCommit = jasmine.createSpy('successfulCommit'); + controller.setLayoutObserver(1, 'view', first, true, 'first', { + onSizeChanged() { + throw new Error('measurement failed'); + }, + }); + controller.setLayoutObserver(2, 'view', second, true, 'second', { + onSizeChanged() {}, + onCommit() { + throw new Error('commit failed'); + }, + }); + controller.setLayoutObserver(3, 'view', third, true, 'third', { + onSizeChanged() {}, + onCommit: successfulCommit, + }); + + flushPass(); + + expect(successfulCommit).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledTimes(2); + }); + + it('rejects onMeasure on attributes other than onMeasure', () => { + const element = makeElement({ left: 1, top: 2, width: 30, height: 20 }); + expect(() => + controller.setLayoutObserver(1, 'view', element, true, 'width', { + onMeasure() {}, + }), + ).toThrowError("Only the 'onMeasure' attribute can define ElementLayoutObserver.onMeasure"); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/ObserverTestUtils.ts b/src/valdi_modules/src/valdi/web_renderer/test/ObserverTestUtils.ts new file mode 100644 index 000000000..15521468b --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/ObserverTestUtils.ts @@ -0,0 +1,194 @@ +export type Rect = { + left: number; + top: number; + width: number; + height: number; + right?: number; + bottom?: number; +}; + +export type FakeElement = HTMLElement & { + rect: Rect; + offsetParent: FakeElement | null; + scrollLeft: number; + scrollTop: number; + borderLeftWidth: string; + borderTopWidth: string; +}; + +type ResizeCallback = (entries: ResizeObserverEntry[]) => void; +type IntersectionCallback = (entries: IntersectionObserverEntry[]) => void; + +export class FakeResizeObserver { + static lastInstance?: FakeResizeObserver; + + readonly observedElements: Element[] = []; + private readonly callback: ResizeCallback; + + constructor(callback: ResizeCallback) { + this.callback = callback; + FakeResizeObserver.lastInstance = this; + } + + observe(element: Element): void { + this.observedElements.push(element); + } + + unobserve(element: Element): void { + const index = this.observedElements.indexOf(element); + if (index >= 0) { + this.observedElements.splice(index, 1); + } + } + + disconnect(): void { + this.observedElements.length = 0; + } + + trigger(element: Element): void { + this.callback([{ target: element } as ResizeObserverEntry]); + } +} + +export class FakeIntersectionObserver { + static instances: FakeIntersectionObserver[] = []; + static lastInstance?: FakeIntersectionObserver; + + readonly observedElements: Element[] = []; + readonly root: Element | Document | null; + readonly thresholds: ReadonlyArray; + private readonly callback: IntersectionCallback; + + constructor(callback: IntersectionCallback, options?: IntersectionObserverInit) { + this.callback = callback; + this.root = options?.root ?? null; + const threshold = options?.threshold ?? 0; + this.thresholds = typeof threshold === 'number' ? [threshold] : [...threshold]; + FakeIntersectionObserver.instances.push(this); + FakeIntersectionObserver.lastInstance = this; + } + + observe(element: Element): void { + if (!this.observedElements.includes(element)) { + this.observedElements.push(element); + } + } + + unobserve(element: Element): void { + const index = this.observedElements.indexOf(element); + if (index >= 0) { + this.observedElements.splice(index, 1); + } + } + + disconnect(): void { + this.observedElements.length = 0; + } + + takeRecords(): IntersectionObserverEntry[] { + return []; + } + + trigger(...elements: Element[]): void { + const rootRect = this.root && 'getBoundingClientRect' in this.root ? this.root.getBoundingClientRect() : undefined; + const entries = elements.map(element => { + const boundingClientRect = element.getBoundingClientRect(); + const left = rootRect ? Math.max(boundingClientRect.left, rootRect.left) : boundingClientRect.left; + const top = rootRect ? Math.max(boundingClientRect.top, rootRect.top) : boundingClientRect.top; + const right = rootRect ? Math.min(boundingClientRect.right, rootRect.right) : boundingClientRect.right; + const bottom = rootRect ? Math.min(boundingClientRect.bottom, rootRect.bottom) : boundingClientRect.bottom; + const width = Math.max(0, right - left); + const height = Math.max(0, bottom - top); + return { + target: element, + isIntersecting: width > 0 && height > 0, + boundingClientRect, + intersectionRect: { left, top, right, bottom, width, height }, + } as IntersectionObserverEntry; + }); + this.callback(entries); + } +} + +export function makeElement(rect: Rect): FakeElement { + return { + rect, + offsetParent: null, + scrollLeft: 0, + scrollTop: 0, + borderLeftWidth: '0', + borderTopWidth: '0', + getBoundingClientRect(): Rect { + return this.rect; + }, + } as FakeElement; +} + +export function installObserverTestGlobals(): () => void { + const previousRequestAnimationFrame = (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame; + const previousGetComputedStyle = (globalThis as { getComputedStyle?: unknown }).getComputedStyle; + const previousResizeObserver = (globalThis as { ResizeObserver?: unknown }).ResizeObserver; + const previousIntersectionObserver = (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver; + const previousPerformance = (globalThis as { performance?: unknown }).performance; + const pendingAnimationFrames: Array<() => void> = []; + + FakeResizeObserver.lastInstance = undefined; + FakeIntersectionObserver.instances = []; + FakeIntersectionObserver.lastInstance = undefined; + + (globalThis as { requestAnimationFrame?: (callback: () => void) => number }).requestAnimationFrame = callback => { + pendingAnimationFrames.push(callback); + return pendingAnimationFrames.length; + }; + (globalThis as { getComputedStyle?: (element: unknown) => { borderLeftWidth: string; borderTopWidth: string } }) + .getComputedStyle = element => ({ + borderLeftWidth: (element as FakeElement).borderLeftWidth, + borderTopWidth: (element as FakeElement).borderTopWidth, + }); + (globalThis as { ResizeObserver?: unknown }).ResizeObserver = FakeResizeObserver; + (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = FakeIntersectionObserver; + (globalThis as { performance?: unknown }).performance = { now: () => 1234 }; + + (globalThis as { __flushObserverAnimationFrame?: () => void }).__flushObserverAnimationFrame = () => { + const callback = pendingAnimationFrames.shift(); + if (callback) { + callback(); + } + }; + + return () => { + if (previousRequestAnimationFrame === undefined) { + delete (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame; + } else { + (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = previousRequestAnimationFrame; + } + if (previousGetComputedStyle === undefined) { + delete (globalThis as { getComputedStyle?: unknown }).getComputedStyle; + } else { + (globalThis as { getComputedStyle?: unknown }).getComputedStyle = previousGetComputedStyle; + } + if (previousResizeObserver === undefined) { + delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver; + } else { + (globalThis as { ResizeObserver?: unknown }).ResizeObserver = previousResizeObserver; + } + if (previousIntersectionObserver === undefined) { + delete (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver; + } else { + (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = previousIntersectionObserver; + } + if (previousPerformance === undefined) { + delete (globalThis as { performance?: unknown }).performance; + } else { + (globalThis as { performance?: unknown }).performance = previousPerformance; + } + delete (globalThis as { __flushObserverAnimationFrame?: unknown }).__flushObserverAnimationFrame; + FakeResizeObserver.lastInstance = undefined; + FakeIntersectionObserver.instances = []; + FakeIntersectionObserver.lastInstance = undefined; + }; +} + +export function flushObserverAnimationFrame(): void { + (globalThis as unknown as { __flushObserverAnimationFrame: () => void }).__flushObserverAnimationFrame(); +} diff --git a/src/valdi_modules/src/valdi/web_renderer/test/PerformanceTimelineTracing.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/PerformanceTimelineTracing.spec.ts new file mode 100644 index 000000000..60fce4681 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/PerformanceTimelineTracing.spec.ts @@ -0,0 +1,84 @@ +import 'jasmine/src/jasmine'; +import { PerformanceTimelineTracing } from '../src/tracing/PerformanceTimelineTracing'; + +describe('PerformanceTimelineTracing', () => { + let markSpy: any; + let measureSpy: any; + let originalMark: typeof performance.mark | undefined; + let originalMeasure: typeof performance.measure | undefined; + + beforeEach(() => { + originalMark = performance.mark; + originalMeasure = performance.measure; + markSpy = jasmine.createSpy('mark'); + measureSpy = jasmine.createSpy('measure'); + Object.defineProperty(performance, 'mark', { configurable: true, value: markSpy }); + Object.defineProperty(performance, 'measure', { configurable: true, value: measureSpy }); + }); + + afterEach(() => { + restorePerformanceMethod('mark', originalMark); + restorePerformanceMethod('measure', originalMeasure); + }); + + it('emits nested duration measures', () => { + spyOn(performance, 'now').and.returnValues(10, 20, 30, 40); + const tracing = new PerformanceTimelineTracing(); + + tracing.beginTrace('outer'); + tracing.beginTrace('inner'); + tracing.endTrace(); + tracing.endTrace(); + + expect(measureSpy.calls.allArgs()).toEqual([ + ['Valdi.inner', { start: 20, end: 30 }], + ['Valdi.outer', { start: 10, end: 40 }], + ]); + }); + + it('emits instant marks with arguments as detail', () => { + const tracing = new PerformanceTimelineTracing(); + + tracing.instantTrace('event', ['nodeId', 12, 'attributeName', 'opacity']); + + expect(markSpy).toHaveBeenCalledWith('Valdi.event', { + detail: { nodeId: 12, attributeName: 'opacity' }, + }); + }); + + it('retries an instant mark without detail when the browser cannot clone it', () => { + markSpy.and.callFake((name: string, options?: PerformanceMarkOptions) => { + if (options) { + const error = new Error('Uncloneable detail'); + error.name = 'DataCloneError'; + throw error; + } + return { name } as PerformanceMark; + }); + const tracing = new PerformanceTimelineTracing(); + + tracing.instantTrace('event', ['callback', () => {}]); + + expect(markSpy.calls.allArgs()).toEqual([ + ['Valdi.event', { detail: { callback: jasmine.any(Function) } }], + ['Valdi.event'], + ]); + }); + + it('ignores unmatched trace ends', () => { + new PerformanceTimelineTracing().endTrace(); + + expect(measureSpy).not.toHaveBeenCalled(); + }); +}); + +function restorePerformanceMethod( + name: 'mark' | 'measure', + original: typeof performance.mark | typeof performance.measure | undefined, +): void { + if (original) { + Object.defineProperty(performance, name, { configurable: true, value: original }); + } else { + Reflect.deleteProperty(performance, name); + } +} diff --git a/src/valdi_modules/src/valdi/web_renderer/test/TextAnimationController.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/TextAnimationController.spec.ts new file mode 100644 index 000000000..c230a239a --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/TextAnimationController.spec.ts @@ -0,0 +1,536 @@ +import 'jasmine/src/jasmine'; +import type { AttributeApplierContext } from '../src/core/ElementClass'; +import { easeOutTextAnimationProgress } from '../src/utils/TextAnimationController'; +import { + registerTextAnimationGroup, + registerTextAnimationParticipant, + unregisterTextAnimationGroup, + unregisterTextAnimationParticipant, +} from '../src/utils/TextAnimationRegistry'; +import { + markTextAnimationAttachmentSpan, + NormalizedTextAnimationTransform, + setTextAnimationTransform, +} from '../src/utils/TextAnimationTypes'; + +type FakeStyle = Record & { + setProperty(name: string, value: string): void; + removeProperty(name: string): void; +}; + +type FakeElement = { + tagName: string; + style: FakeStyle; + childNodes: { readonly length: number; item(index: number): FakeElement | null }; + parentElement: FakeElement | null; + textContent: string; + appendChild(child: FakeElement): void; + replaceChildren(...newChildren: FakeElement[]): void; +}; + +class FakeAttributeApplierContext implements AttributeApplierContext { + readonly id: number; + private readonly state = new Map(); + private readonly cleanups: Array<() => void> = []; + + constructor(id: number) { + this.id = id; + } + + getState(key: string): T | undefined { + return this.state.get(key) as T | undefined; + } + + setState(key: string, value: unknown): void { + this.state.set(key, value); + } + + getViewAttributeElement(): HTMLElement { + throw new Error('View attribute element is not available in text animation tests'); + } + + resolveColor(value: string): string { + return value; + } + + setColorPalette(_colorPaletteName: string | undefined): void {} + + addCleanup(callback: () => void): void { + this.cleanups.push(callback); + } + + enqueuePostLayoutCallback(_callback: () => void): void {} + + getLayoutObserver(): undefined { + return undefined; + } + + setLayoutObserver(_attributeName: string): void {} + + requestLayoutPass(): void {} + + getChildHtmlElement(_index: number): HTMLElement | undefined { + return undefined; + } + + setOnLayoutCallback(): void {} + + onAttributeUpdatedExternally(_attributeName: string, _attributeValue: unknown): void {} + + emitCurrentViewCreate(_callback: Function): void {} + + emitCurrentViewChange(): void {} + + isAnimationEnabled(): boolean { + return true; + } + + setAnimationsEnabled(): void {} + + runCleanups(): void { + for (let i = 0; i < this.cleanups.length; i++) { + this.cleanups[i](); + } + this.cleanups.length = 0; + } +} + +function makeStyle(): FakeStyle { + const style = {} as FakeStyle; + style.setProperty = (name: string, value: string) => { + style[name] = value; + }; + style.removeProperty = (name: string) => { + delete style[name]; + }; + return style; +} + +function makeFakeElement(tagName: string, textContent: string): FakeElement { + const children: FakeElement[] = []; + return { + tagName: tagName.toUpperCase(), + style: makeStyle(), + childNodes: { + get length(): number { + return children.length; + }, + item(index: number): FakeElement | null { + return children[index] ?? null; + }, + }, + parentElement: null, + textContent, + appendChild(child: FakeElement): void { + child.parentElement = this; + children.push(child); + }, + replaceChildren(...newChildren: FakeElement[]): void { + for (let i = 0; i < children.length; i++) { + children[i].parentElement = null; + } + children.length = 0; + for (let i = 0; i < newChildren.length; i++) { + newChildren[i].parentElement = this; + children.push(newChildren[i]); + } + }, + }; +} + +function asHtmlElement(element: FakeElement): HTMLElement { + return element as unknown as HTMLElement; +} + +function asHtmlSpanElement(element: FakeElement): HTMLSpanElement { + return element as unknown as HTMLSpanElement; +} + +function makeTransform(overrides: Partial): NormalizedTextAnimationTransform { + return { + translationX: 0, + translationY: 0, + scale: 1, + opacity: 0, + duration: 1, + timeOffsetBetweenParts: 0, + groupIndex: 0, + partIndexInGroup: 0, + ...overrides, + }; +} + +function appendAnimatedSpan( + container: FakeElement, + text: string, + transform: NormalizedTextAnimationTransform, +): FakeElement { + const span = makeFakeElement('span', text); + setTextAnimationTransform(asHtmlSpanElement(span), transform); + container.appendChild(span); + return span; +} + +function installAnimationDomStubs(): { + flushFrame(time: number): void; + pendingFrameCount(): number; + uninstall(): void; +} { + const previousDocument = (globalThis as { document?: unknown }).document; + const previousRequestAnimationFrame = (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame; + const previousCancelAnimationFrame = (globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame; + const previousPerformance = (globalThis as { performance?: unknown }).performance; + const frameCallbacks = new Map void>(); + let frameTime = 0; + let nextFrameHandle = 1; + + (globalThis as { document?: unknown }).document = { + createElement(tagName: string): FakeElement { + return makeFakeElement(tagName, ''); + }, + }; + (globalThis as { requestAnimationFrame?: (callback: (time: number) => void) => number }).requestAnimationFrame = + callback => { + const handle = nextFrameHandle++; + frameCallbacks.set(handle, callback); + return handle; + }; + (globalThis as { cancelAnimationFrame?: (handle: number) => void }).cancelAnimationFrame = handle => { + frameCallbacks.delete(handle); + }; + (globalThis as { performance?: unknown }).performance = { + now(): number { + return frameTime; + }, + }; + + return { + flushFrame(time: number): void { + frameTime = time; + const callbacks = Array.from(frameCallbacks.values()); + frameCallbacks.clear(); + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](time); + } + }, + pendingFrameCount(): number { + return frameCallbacks.size; + }, + uninstall(): void { + if (previousDocument === undefined) { + delete (globalThis as { document?: unknown }).document; + } else { + (globalThis as { document?: unknown }).document = previousDocument; + } + if (previousRequestAnimationFrame === undefined) { + delete (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame; + } else { + (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = previousRequestAnimationFrame; + } + if (previousCancelAnimationFrame === undefined) { + delete (globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame; + } else { + (globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame = previousCancelAnimationFrame; + } + if (previousPerformance === undefined) { + delete (globalThis as { performance?: unknown }).performance; + } else { + (globalThis as { performance?: unknown }).performance = previousPerformance; + } + }, + }; +} + +describe('TextAnimationController', () => { + let domStubs: ReturnType; + + beforeEach(() => { + domStubs = installAnimationDomStubs(); + }); + + afterEach(() => { + domStubs.uninstall(); + }); + + it('exposes cubic ease-out progress clamped to the animation range', () => { + expect(easeOutTextAnimationProgress(-1)).toBe(0); + expect(easeOutTextAnimationProgress(0)).toBe(0); + expect(easeOutTextAnimationProgress(0.5)).toBeCloseTo(0.875, 5); + expect(easeOutTextAnimationProgress(1)).toBe(1); + expect(easeOutTextAnimationProgress(2)).toBe(1); + }); + + it('animates whole parts and restores the original inline style on completion', () => { + const owner = makeFakeElement('label', ''); + const container = makeFakeElement('span', ''); + const span = appendAnimatedSpan( + container, + 'hello', + makeTransform({ + opacity: 0.2, + scale: 0.5, + translationX: 5, + translationY: 10, + }), + ); + const context = new FakeAttributeApplierContext(1); + owner.appendChild(container); + span.style.display = 'inline'; + span.style.opacity = '0.9'; + span.style.transform = 'rotate(1deg)'; + + registerTextAnimationParticipant(asHtmlElement(owner), asHtmlElement(container), context); + + expect(span.style.display).toBe('inline-block'); + expect(span.style.opacity).toBe('0.2'); + expect(span.style.transform).toBe('translateX(5px) translateY(10px) scale(0.5)'); + + domStubs.flushFrame(500); + + expect(Number(span.style.opacity)).toBeCloseTo(0.9, 5); + expect(span.style.transform).toBe('translateX(0.625px) translateY(1.25px) scale(0.9375)'); + + domStubs.flushFrame(1000); + + expect(span.style.display).toBe('inline'); + expect(span.style.opacity).toBe('0.9'); + expect(span.style.transform).toBe('rotate(1deg)'); + unregisterTextAnimationParticipant(context); + }); + + it('splits partPattern matches, leaves unmatched text unanimated, and applies part delays', () => { + const owner = makeFakeElement('label', ''); + const container = makeFakeElement('span', ''); + const partSpan = appendAnimatedSpan( + container, + 'hi all', + makeTransform({ + partPattern: '\\S+', + timeOffsetBetweenParts: 0.1, + }), + ); + const context = new FakeAttributeApplierContext(2); + owner.appendChild(container); + + registerTextAnimationParticipant(asHtmlElement(owner), asHtmlElement(container), context); + + const firstAnimated = partSpan.childNodes.item(0)!; + const unmatched = partSpan.childNodes.item(1)!; + const secondAnimated = partSpan.childNodes.item(2)!; + expect(partSpan.childNodes.length).toBe(3); + expect(firstAnimated.textContent).toBe('hi'); + expect(unmatched.textContent).toBe(' '); + expect(secondAnimated.textContent).toBe('all'); + expect(unmatched.style.opacity).toBeUndefined(); + + domStubs.flushFrame(50); + + expect(Number(firstAnimated.style.opacity)).toBeGreaterThan(0); + expect(secondAnimated.style.opacity).toBe('0'); + unregisterTextAnimationParticipant(context); + }); + + it('logs invalid partPattern values and leaves the text unanimated', () => { + const errorSpy = spyOn(console, 'error'); + const owner = makeFakeElement('label', ''); + const container = makeFakeElement('span', ''); + const span = appendAnimatedSpan(container, 'invalid', makeTransform({ partPattern: '[' })); + const context = new FakeAttributeApplierContext(3); + owner.appendChild(container); + + registerTextAnimationParticipant(asHtmlElement(owner), asHtmlElement(container), context); + + expect(errorSpy).toHaveBeenCalled(); + expect(String(errorSpy.calls.mostRecent().args[0])).toContain('Invalid text animation partPattern'); + expect(span.childNodes.length).toBe(0); + expect(span.style.opacity).toBeUndefined(); + expect(domStubs.pendingFrameCount()).toBe(0); + }); + + it('treats inline attachments as one animated unit even when partPattern is present', () => { + const owner = makeFakeElement('label', ''); + const container = makeFakeElement('span', ''); + const attachmentSpan = appendAnimatedSpan(container, '', makeTransform({ partPattern: '.' })); + const image = makeFakeElement('img', ''); + attachmentSpan.appendChild(image); + markTextAnimationAttachmentSpan(asHtmlSpanElement(attachmentSpan)); + const context = new FakeAttributeApplierContext(4); + owner.appendChild(container); + + registerTextAnimationParticipant(asHtmlElement(owner), asHtmlElement(container), context); + + expect(attachmentSpan.childNodes.length).toBe(1); + expect(attachmentSpan.childNodes.item(0)).toBe(image); + expect(attachmentSpan.style.opacity).toBe('0'); + unregisterTextAnimationParticipant(context); + }); + + it('unregisters participants by restoring styles and cancelling pending frames', () => { + const owner = makeFakeElement('label', ''); + const container = makeFakeElement('span', ''); + const span = appendAnimatedSpan(container, 'cleanup', makeTransform({ translationY: 6 })); + const context = new FakeAttributeApplierContext(5); + owner.appendChild(container); + + registerTextAnimationParticipant(asHtmlElement(owner), asHtmlElement(container), context); + expect(domStubs.pendingFrameCount()).toBe(1); + expect(span.style.transform).toBe('translateY(6px)'); + + unregisterTextAnimationParticipant(context); + + expect(domStubs.pendingFrameCount()).toBe(0); + expect(span.style.transform).toBe(''); + domStubs.flushFrame(100); + expect(span.style.transform).toBe(''); + }); + + it('coordinates grouped participants in DOM order while isolating nested groups', () => { + const group = makeFakeElement('textanimationgroup', ''); + const nestedGroup = makeFakeElement('textanimationgroup', ''); + const firstOwner = makeFakeElement('label', ''); + const nestedOwner = makeFakeElement('label', ''); + const lastOwner = makeFakeElement('label', ''); + const firstContainer = makeFakeElement('span', ''); + const nestedContainer = makeFakeElement('span', ''); + const lastContainer = makeFakeElement('span', ''); + const firstSpan = appendAnimatedSpan(firstContainer, 'first', makeTransform({ timeOffsetBetweenParts: 0.1 })); + const nestedSpan = appendAnimatedSpan(nestedContainer, 'nested', makeTransform({ timeOffsetBetweenParts: 0.1 })); + const lastSpan = appendAnimatedSpan(lastContainer, 'last', makeTransform({ timeOffsetBetweenParts: 0.1 })); + const firstContext = new FakeAttributeApplierContext(6); + const nestedContext = new FakeAttributeApplierContext(7); + const lastContext = new FakeAttributeApplierContext(8); + + firstOwner.appendChild(firstContainer); + nestedOwner.appendChild(nestedContainer); + lastOwner.appendChild(lastContainer); + nestedGroup.appendChild(nestedOwner); + group.appendChild(firstOwner); + group.appendChild(nestedGroup); + group.appendChild(lastOwner); + + registerTextAnimationGroup(asHtmlElement(group)); + registerTextAnimationGroup(asHtmlElement(nestedGroup)); + registerTextAnimationParticipant(asHtmlElement(firstOwner), asHtmlElement(firstContainer), firstContext); + registerTextAnimationParticipant(asHtmlElement(nestedOwner), asHtmlElement(nestedContainer), nestedContext); + registerTextAnimationParticipant(asHtmlElement(lastOwner), asHtmlElement(lastContainer), lastContext); + + domStubs.flushFrame(0); + domStubs.flushFrame(150); + + expect(Number(firstSpan.style.opacity)).toBeGreaterThan(0); + expect(Number(nestedSpan.style.opacity)).toBeGreaterThan(0); + expect(Number(lastSpan.style.opacity)).toBeGreaterThan(0); + + unregisterTextAnimationParticipant(firstContext); + unregisterTextAnimationParticipant(nestedContext); + unregisterTextAnimationParticipant(lastContext); + unregisterTextAnimationGroup(asHtmlElement(nestedGroup)); + unregisterTextAnimationGroup(asHtmlElement(group)); + }); + + it('starts independent keyed timelines together within a group', () => { + const group = makeFakeElement('textanimationgroup', ''); + const firstOwner = makeFakeElement('label', ''); + const secondOwner = makeFakeElement('label', ''); + const firstContainer = makeFakeElement('span', ''); + const secondContainer = makeFakeElement('span', ''); + const firstSpan = appendAnimatedSpan( + firstContainer, + 'first', + makeTransform({ key: 'first-timeline', timeOffsetBetweenParts: 0.1 }), + ); + const secondSpan = appendAnimatedSpan( + secondContainer, + 'second', + makeTransform({ key: 'second-timeline', timeOffsetBetweenParts: 0.1 }), + ); + const firstContext = new FakeAttributeApplierContext(9); + const secondContext = new FakeAttributeApplierContext(10); + + firstOwner.appendChild(firstContainer); + secondOwner.appendChild(secondContainer); + group.appendChild(firstOwner); + group.appendChild(secondOwner); + + registerTextAnimationGroup(asHtmlElement(group)); + registerTextAnimationParticipant(asHtmlElement(firstOwner), asHtmlElement(firstContainer), firstContext); + registerTextAnimationParticipant(asHtmlElement(secondOwner), asHtmlElement(secondContainer), secondContext); + + domStubs.flushFrame(0); + domStubs.flushFrame(50); + + expect(Number(firstSpan.style.opacity)).toBeGreaterThan(0); + expect(Number(secondSpan.style.opacity)).toBeGreaterThan(0); + + unregisterTextAnimationParticipant(firstContext); + unregisterTextAnimationParticipant(secondContext); + unregisterTextAnimationGroup(asHtmlElement(group)); + }); + + it('compresses grouped pending segment delays after the flush threshold', () => { + const group = makeFakeElement('textanimationgroup', ''); + const owner = makeFakeElement('label', ''); + const container = makeFakeElement('span', ''); + const partSpan = appendAnimatedSpan( + container, + 'one two three', + makeTransform({ + duration: 1, + key: 'flush-demo', + partPattern: '\\S+', + timeOffsetBetweenParts: 1, + }), + ); + const context = new FakeAttributeApplierContext(11); + + owner.appendChild(container); + group.appendChild(owner); + + const groupController = registerTextAnimationGroup(asHtmlElement(group)); + groupController.setFlushDurationThreshold(0.3); + registerTextAnimationParticipant(asHtmlElement(owner), asHtmlElement(container), context); + + domStubs.flushFrame(0); + domStubs.flushFrame(1100); + + const thirdAnimated = partSpan.childNodes.item(4)!; + expect(Number(thirdAnimated.style.opacity)).toBeGreaterThan(0); + + unregisterTextAnimationParticipant(context); + unregisterTextAnimationGroup(asHtmlElement(group)); + }); + + it('uses custom grouped flush multiplier values', () => { + const group = makeFakeElement('textanimationgroup', ''); + const owner = makeFakeElement('label', ''); + const container = makeFakeElement('span', ''); + const partSpan = appendAnimatedSpan( + container, + 'one two three', + makeTransform({ + duration: 1, + key: 'flush-demo', + partPattern: '\\S+', + timeOffsetBetweenParts: 1, + }), + ); + const context = new FakeAttributeApplierContext(12); + + owner.appendChild(container); + group.appendChild(owner); + + const groupController = registerTextAnimationGroup(asHtmlElement(group)); + groupController.setFlushDurationThreshold(0.3); + groupController.setFlushMultiplier(0); + registerTextAnimationParticipant(asHtmlElement(owner), asHtmlElement(container), context); + + domStubs.flushFrame(0); + domStubs.flushFrame(1100); + + const thirdAnimated = partSpan.childNodes.item(4)!; + expect(thirdAnimated.style.opacity).toBe('0'); + + unregisterTextAnimationParticipant(context); + unregisterTextAnimationGroup(asHtmlElement(group)); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/ValdiWebTracing.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/ValdiWebTracing.spec.ts new file mode 100644 index 000000000..29d80de33 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/ValdiWebTracing.spec.ts @@ -0,0 +1,115 @@ +import 'jasmine/src/jasmine'; +import { + ValdiWebTracing, + beginValdiWebTrace, + endValdiWebTrace, + instantValdiWebTrace, + isValdiWebTracingEnabled, + makeValdiWebTraceProxy, + setValdiWebTracing, +} from '../src/tracing/ValdiWebTracing'; + +class RecordingTracing implements ValdiWebTracing { + readonly events: string[] = []; + + beginTrace(tag: string): void { + this.events.push(`begin:${tag}`); + } + + endTrace(): void { + this.events.push('end'); + } + + instantTrace(tag: string, args: readonly unknown[] | undefined): void { + this.events.push(`instant:${tag}:${JSON.stringify(args)}`); + } +} + +describe('ValdiWebTracing', () => { + afterEach(() => { + setValdiWebTracing(undefined); + }); + + it('is disabled by default', () => { + setValdiWebTracing(undefined); + + expect(isValdiWebTracingEnabled()).toBeFalse(); + expect(() => { + beginValdiWebTrace('disabled'); + instantValdiWebTrace('disabled', undefined); + endValdiWebTrace(); + }).not.toThrow(); + }); + + it('forwards duration and instant traces to the configured implementation', () => { + const tracing = new RecordingTracing(); + setValdiWebTracing(tracing); + + beginValdiWebTrace('work'); + instantValdiWebTrace('event', ['key', 42]); + endValdiWebTrace(); + + expect(tracing.events).toEqual(['begin:work', 'instant:event:["key",42]', 'end']); + }); + + it('stops forwarding traces when disabled', () => { + const tracing = new RecordingTracing(); + + setValdiWebTracing(tracing); + beginValdiWebTrace('enabled'); + endValdiWebTrace(); + setValdiWebTracing(undefined); + beginValdiWebTrace('disabled'); + endValdiWebTrace(); + + expect(tracing.events).toEqual(['begin:enabled', 'end']); + }); + + it('logs tracing implementation errors without changing control flow', () => { + const errorSpy = spyOn(console, 'error'); + const tracing: ValdiWebTracing = { + beginTrace: () => { + throw new Error('begin failure'); + }, + endTrace: () => { + throw new Error('end failure'); + }, + instantTrace: () => { + throw new Error('instant failure'); + }, + }; + + setValdiWebTracing(tracing); + expect(() => beginValdiWebTrace('begin')).not.toThrow(); + expect(() => instantValdiWebTrace('instant', undefined)).not.toThrow(); + + tracing.beginTrace = () => {}; + beginValdiWebTrace('end'); + expect(() => endValdiWebTrace()).not.toThrow(); + + expect(errorSpy).toHaveBeenCalledTimes(3); + }); + + it('activates trace proxies that were created before tracing was configured', () => { + setValdiWebTracing(undefined); + const wrapped = makeValdiWebTraceProxy('Proxy.call', function (this: { base: number }, value: number) { + return this.base + value; + }); + const tracing = new RecordingTracing(); + setValdiWebTracing(tracing); + + expect(wrapped.call({ base: 4 }, 3)).toBe(7); + expect(tracing.events).toEqual(['begin:Proxy.call', 'end']); + }); + + it('ends a proxied trace when the wrapped function throws', () => { + const wrapped = makeValdiWebTraceProxy('Proxy.throw', () => { + throw new Error('callback failure'); + }); + const tracing = new RecordingTracing(); + setValdiWebTracing(tracing); + + expect(() => wrapped()).toThrowError('callback failure'); + expect(tracing.events).toEqual(['begin:Proxy.throw', 'end']); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/ValdiWebWorker.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/ValdiWebWorker.spec.ts new file mode 100644 index 000000000..ac2285aa1 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/ValdiWebWorker.spec.ts @@ -0,0 +1,83 @@ +import 'jasmine/src/jasmine'; +import { createValdiWebWorker, registerValdiWebWorker } from '../src/ValdiWebWorker'; + +class RecordingWorker { + readonly messages: unknown[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + terminated = false; + postMessageError: Error | undefined; + + postMessage(data: unknown): void { + if (this.postMessageError) { + throw this.postMessageError; + } + this.messages.push(data); + } + + terminate(): void { + this.terminated = true; + } +} + +describe('ValdiWebWorker', () => { + let moduleSequence = 0; + + function nextModulePath(): string { + moduleSequence += 1; + return `worker/test/Worker${moduleSequence}`; + } + + it('allows idempotent registration of the same factory', () => { + const modulePath = nextModulePath(); + const factory = () => new RecordingWorker() as unknown as Worker; + + registerValdiWebWorker(modulePath, factory); + expect(() => registerValdiWebWorker(modulePath, factory)).not.toThrow(); + expect(() => registerValdiWebWorker(modulePath, () => factory())).toThrowError( + `Valdi web worker "${modulePath}" is already registered`, + ); + }); + + it('throws synchronously when the logical module is not registered', () => { + const modulePath = nextModulePath(); + + expect(() => createValdiWebWorker(`${modulePath}?value=1`)).toThrowError( + `Valdi web worker is not registered: ${modulePath}`, + ); + }); + + it('uses the registered factory for requests with query parameters', () => { + const modulePath = nextModulePath(); + const worker = new RecordingWorker(); + registerValdiWebWorker(modulePath, () => worker as unknown as Worker); + + createValdiWebWorker(`${modulePath}?value=1`); + + expect(worker.terminated).toBeFalse(); + }); + + it('forwards messages, browser events, clone errors, and termination', () => { + const modulePath = nextModulePath(); + const worker = new RecordingWorker(); + registerValdiWebWorker(modulePath, () => worker as unknown as Worker); + const nativeWorker = createValdiWebWorker(modulePath); + const event = { data: { response: true } } as MessageEvent; + let receivedEvent: MessageEvent | undefined; + + nativeWorker.setOnMessage(message => { + receivedEvent = message as MessageEvent; + }); + worker.onmessage!(event); + nativeWorker.postMessage({ request: true }); + + expect(receivedEvent).toBe(event); + expect(worker.messages).toEqual([{ request: true }]); + + const cloneError = new Error('The object could not be cloned'); + worker.postMessageError = cloneError; + expect(() => nativeWorker.postMessage(() => {})).toThrow(cloneError); + + nativeWorker.terminate(); + expect(worker.terminated).toBeTrue(); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/VisibilityObserverController.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/VisibilityObserverController.spec.ts new file mode 100644 index 000000000..0f9fecc11 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/VisibilityObserverController.spec.ts @@ -0,0 +1,111 @@ +import 'jasmine/src/jasmine'; +import { VisibilityObserverController } from '../src/VisibilityObserverController'; +import { FakeIntersectionObserver, installObserverTestGlobals, makeElement } from './ObserverTestUtils'; + +describe('VisibilityObserverController', () => { + let uninstallGlobals: () => void; + + beforeEach(() => { + uninstallGlobals = installObserverTestGlobals(); + }); + + afterEach(() => { + uninstallGlobals(); + }); + + it('reports appearing and viewport updates for observed elements', () => { + const root = makeElement({ left: 0, top: 0, width: 100, height: 100, right: 100, bottom: 100 }); + const element = makeElement({ left: 10, top: 20, width: 50, height: 40, right: 60, bottom: 60 }); + const events: Array<{ + appearing: number[]; + disappearing: number[]; + viewportUpdates: number[]; + eventTime: number; + }> = []; + const controller = new VisibilityObserverController(); + + controller.setRoot(root); + controller.registerObserver((appearing, disappearing, viewportUpdates, eventTime) => { + events.push({ appearing, disappearing, viewportUpdates, eventTime }); + }); + controller.observeElement(7, element); + FakeIntersectionObserver.lastInstance!.trigger(element); + + expect(events).toEqual([ + { + appearing: [7], + disappearing: [], + viewportUpdates: [7, 0, 0, 50, 40], + eventTime: 1234, + }, + ]); + }); + + it('reports viewport clipping changes and disappearing elements', () => { + const root = makeElement({ left: 0, top: 0, width: 100, height: 100, right: 100, bottom: 100 }); + const element = makeElement({ left: 10, top: 20, width: 50, height: 40, right: 60, bottom: 60 }); + const events: Array<{ appearing: number[]; disappearing: number[]; viewportUpdates: number[] }> = []; + const controller = new VisibilityObserverController(); + + controller.setRoot(root); + controller.registerObserver((appearing, disappearing, viewportUpdates) => { + events.push({ appearing, disappearing, viewportUpdates }); + }); + controller.observeElement(9, element); + const intersectionObserver = FakeIntersectionObserver.lastInstance!; + intersectionObserver.trigger(element); + + element.rect = { left: -10, top: 20, width: 50, height: 40, right: 40, bottom: 60 }; + intersectionObserver.trigger(element); + + element.rect = { left: 120, top: 20, width: 50, height: 40, right: 170, bottom: 60 }; + intersectionObserver.trigger(element); + + expect(events).toEqual([ + { appearing: [9], disappearing: [], viewportUpdates: [9, 0, 0, 50, 40] }, + { appearing: [], disappearing: [], viewportUpdates: [9, 10, 0, 40, 40] }, + { appearing: [], disappearing: [9], viewportUpdates: [] }, + ]); + }); + + it('uses one intersection observer for all elements and cleans up observations', () => { + const root = makeElement({ left: 0, top: 0, width: 100, height: 100, right: 100, bottom: 100 }); + const first = makeElement({ left: 10, top: 20, width: 20, height: 20, right: 30, bottom: 40 }); + const second = makeElement({ left: 40, top: 50, width: 20, height: 20, right: 60, bottom: 70 }); + const firstGetBoundingClientRect = first.getBoundingClientRect.bind(first); + let firstRectReadCount = 0; + first.getBoundingClientRect = () => { + firstRectReadCount++; + return firstGetBoundingClientRect() as DOMRect; + }; + const events: Array<{ appearing: number[]; viewportUpdates: number[] }> = []; + const controller = new VisibilityObserverController(); + + controller.setRoot(root); + controller.registerObserver((appearing, _disappearing, viewportUpdates) => { + events.push({ appearing, viewportUpdates }); + }); + controller.observeElement(1, first); + controller.observeElement(2, second); + + expect(FakeIntersectionObserver.instances.length).toBe(1); + expect(FakeIntersectionObserver.lastInstance!.root).toBe(root); + expect(FakeIntersectionObserver.lastInstance!.thresholds).toEqual([0, 1]); + expect(FakeIntersectionObserver.lastInstance!.observedElements).toEqual([first, second]); + + FakeIntersectionObserver.lastInstance!.trigger(first, second); + expect(events).toEqual([ + { + appearing: [1, 2], + viewportUpdates: [1, 0, 0, 20, 20, 2, 0, 0, 20, 20], + }, + ]); + expect(firstRectReadCount).toBe(1); + + controller.unobserveElement(1); + expect(FakeIntersectionObserver.lastInstance!.observedElements).toEqual([second]); + + controller.destroy(); + expect(FakeIntersectionObserver.lastInstance!.observedElements).toEqual([]); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/WebRendererCore.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/WebRendererCore.spec.ts new file mode 100644 index 000000000..acd768184 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/WebRendererCore.spec.ts @@ -0,0 +1,4601 @@ +import 'jasmine/src/jasmine'; +import { AnimationCurve, type AnimationOptions } from 'valdi_core/src/AnimationOptions'; +import { GeometricPathBuilder, GeometricPathScaleType } from 'valdi_core/src/GeometricPath'; +import { Style } from 'valdi_core/src/Style'; +import { AttributedTextBuilder } from 'valdi_core/src/utils/AttributedTextBuilder'; +import { AttributeApplier, ElementClass } from '../src/core/ElementClass'; +import { registerElementClassAlias } from '../src/elements/ElementClassRegistry'; +import { ColorPaletteManager } from '../src/core/Palette'; +import { ViewNode } from '../src/core/ViewNode'; +import { ViewNodeTree } from '../src/core/ViewNodeTree'; +import { registerWebViewClass, type WebViewClassFactory } from '../src/WebViewClassRegistry'; +import { + dispatchAttributedTextLayouts, + ParsedAttributedText, + renderAttributedText, +} from '../src/utils/parseAttributedText'; +import { geometricPathToSvgPath } from '../src/utils/geometricPath'; + +type FakeStyle = Record & { + getPropertyValue(name: string): string; + setProperty(name: string, value: string): void; + removeProperty(name: string): void; +}; + +type FakeCanvasContext = { + drawImage: jasmine.Spy; + clearRect: jasmine.Spy; + save: jasmine.Spy; + restore: jasmine.Spy; + scale: jasmine.Spy; + translate: jasmine.Spy; + rotate: jasmine.Spy; + setTransform: jasmine.Spy; + fillStyle: string; + strokeStyle: string; + lineWidth: number; + getImageData(_x: number, _y: number, _width: number, _height: number): { data: Uint8ClampedArray }; + putImageData(_imageData: { data: Uint8ClampedArray }, _x: number, _y: number): void; +}; + +type FakeDomEvent = { + type: string; + defaultPrevented?: boolean; + key?: string; + preventDefault(): void; +}; + +type FakeEventListener = ((event: FakeDomEvent) => void) | { handleEvent(event: FakeDomEvent): void }; + +type FakeElement = { + id: string; + tagName: string; + style: FakeStyle; + attributes: Record; + childNodes: { readonly length: number; item(index: number): FakeElement | null }; + parentElement: FakeElement | null; + ownerDocument: Record; + textContent: string; + value: string; + className: string; + classList: { add(...names: string[]): void; contains(name: string): boolean; remove(...names: string[]): void }; + rectWidth: number; + rectHeight: number; + rectLeft: number; + rectTop: number; + rectReadCount: number; + layoutLeft: number; + layoutTop: number; + layoutWidth: number; + layoutHeight: number; + layoutReadCount: number; + offsetParent: FakeElement | null; + readonly offsetLeft: number; + readonly offsetTop: number; + readonly offsetWidth: number; + readonly offsetHeight: number; + width: number; + height: number; + canvasContext: FakeCanvasContext; + scrollLeft: number; + scrollTop: number; + scrollWidth: number; + scrollHeight: number; + clientWidth: number; + clientHeight: number; + cloneNode(deep?: boolean): FakeElement; + setAttribute(name: string, value: string): void; + getAttribute(name: string): string | null; + removeAttribute(name: string): void; + appendChild(child: FakeElement): void; + removeChild(child: FakeElement): void; + insertBefore(child: FakeElement, before: FakeElement | null): void; + querySelector(selector: string): FakeElement | null; + querySelectorAll(selector: string): FakeElement[]; + replaceChildren(...newChildren: FakeElement[]): void; + remove(): void; + addEventListener(name: string, listener: unknown): void; + removeEventListener(name: string, listener: unknown): void; + dispatchEvent(event: FakeDomEvent): boolean; + focus(): void; + blur(): void; + setSelectionRange(start: number, end: number): void; + getBoundingClientRect(): { left: number; top: number; width: number; height: number }; + getContext(contextId: string): FakeCanvasContext | null; + getRootNode(): Record; + getTotalLength(): number; + play(): Promise; + pause(): void; +}; + +type FakeImage = FakeElement & { + crossOrigin: string | null; + naturalWidth: number; + naturalHeight: number; + src: string; + onload: (() => void) | null; + onabort: (() => void) | null; + onerror: (() => void) | null; + removeAttribute(name: string): void; +}; + +function makeStyle(): FakeStyle { + const style = {} as FakeStyle; + style.getPropertyValue = (name: string) => style[name] ?? ''; + style.setProperty = (name: string, value: string) => { + style[name] = value; + }; + style.removeProperty = (name: string) => { + delete style[name]; + }; + return style; +} + +function makeFakeElement(tagName: string): FakeElement { + const children: FakeElement[] = []; + const classNames = new Set(); + const listeners = new Map(); + const canvasContext: FakeCanvasContext = { + drawImage: jasmine.createSpy('drawImage'), + clearRect: jasmine.createSpy('clearRect'), + save: jasmine.createSpy('save'), + restore: jasmine.createSpy('restore'), + scale: jasmine.createSpy('scale'), + translate: jasmine.createSpy('translate'), + rotate: jasmine.createSpy('rotate'), + setTransform: jasmine.createSpy('setTransform'), + fillStyle: '', + strokeStyle: '', + lineWidth: 0, + getImageData(_x: number, _y: number, _width: number, _height: number): { data: Uint8ClampedArray } { + return { data: new Uint8ClampedArray(0) }; + }, + putImageData(_imageData: { data: Uint8ClampedArray }, _x: number, _y: number): void {}, + }; + const element: FakeElement = { + id: '', + tagName: tagName.toUpperCase(), + style: makeStyle(), + attributes: {}, + childNodes: { + get length(): number { + return children.length; + }, + item(index: number): FakeElement | null { + return children[index] ?? null; + }, + }, + parentElement: null, + ownerDocument: {}, + textContent: '', + value: '', + className: '', + classList: { + add(...names: string[]): void { + for (let i = 0; i < names.length; i++) { + classNames.add(names[i]); + } + }, + contains(name: string): boolean { + return classNames.has(name); + }, + remove(...names: string[]): void { + for (let i = 0; i < names.length; i++) { + classNames.delete(names[i]); + } + }, + }, + rectWidth: 0, + rectHeight: 0, + rectLeft: 0, + rectTop: 0, + rectReadCount: 0, + layoutLeft: 0, + layoutTop: 0, + layoutWidth: 0, + layoutHeight: 0, + layoutReadCount: 0, + offsetParent: null, + get offsetLeft(): number { + this.layoutReadCount++; + return this.layoutLeft; + }, + get offsetTop(): number { + this.layoutReadCount++; + return this.layoutTop; + }, + get offsetWidth(): number { + this.layoutReadCount++; + return this.layoutWidth; + }, + get offsetHeight(): number { + this.layoutReadCount++; + return this.layoutHeight; + }, + width: 0, + height: 0, + canvasContext, + scrollLeft: 0, + scrollTop: 0, + scrollWidth: 0, + scrollHeight: 0, + clientWidth: 0, + clientHeight: 0, + cloneNode(deep?: boolean): FakeElement { + const clone = makeFakeElement(tagName); + const styleKeys = Object.keys(this.style); + for (let i = 0; i < styleKeys.length; i++) { + const key = styleKeys[i]; + const value = this.style[key]; + if (typeof value !== 'function') { + clone.style[key] = value; + } + } + const attributeNames = Object.keys(this.attributes); + for (let i = 0; i < attributeNames.length; i++) { + const name = attributeNames[i]; + clone.attributes[name] = this.attributes[name]; + } + clone.textContent = this.textContent; + clone.value = this.value; + clone.className = this.className; + classNames.forEach(name => clone.classList.add(name)); + clone.rectWidth = this.rectWidth; + clone.rectHeight = this.rectHeight; + clone.rectLeft = this.rectLeft; + clone.rectTop = this.rectTop; + clone.rectReadCount = this.rectReadCount; + clone.layoutLeft = this.layoutLeft; + clone.layoutTop = this.layoutTop; + clone.layoutWidth = this.layoutWidth; + clone.layoutHeight = this.layoutHeight; + clone.layoutReadCount = this.layoutReadCount; + clone.width = this.width; + clone.height = this.height; + clone.scrollLeft = this.scrollLeft; + clone.scrollTop = this.scrollTop; + clone.scrollWidth = this.scrollWidth; + clone.scrollHeight = this.scrollHeight; + clone.clientWidth = this.clientWidth; + clone.clientHeight = this.clientHeight; + if (deep) { + for (let i = 0; i < children.length; i++) { + clone.appendChild(children[i].cloneNode(true)); + } + } + return clone; + }, + setAttribute(name: string, value: string): void { + this.attributes[name] = String(value); + }, + getAttribute(name: string): string | null { + return this.attributes[name] ?? null; + }, + removeAttribute(name: string): void { + delete this.attributes[name]; + }, + appendChild(child: FakeElement): void { + child.parentElement = this; + children.push(child); + }, + removeChild(child: FakeElement): void { + const index = children.indexOf(child); + if (index >= 0) { + children.splice(index, 1); + child.parentElement = null; + } + }, + insertBefore(child: FakeElement, before: FakeElement | null): void { + const existingIndex = children.indexOf(child); + if (existingIndex >= 0) { + children.splice(existingIndex, 1); + } + child.parentElement = this; + const index = before ? children.indexOf(before) : -1; + if (index >= 0) { + children.splice(index, 0, child); + } else { + children.push(child); + } + }, + querySelector(selector: string): FakeElement | null { + const isIdSelector = selector.charAt(0) === '#'; + const id = isIdSelector ? selector.slice(1) : ''; + const tag = isIdSelector ? '' : selector.toUpperCase(); + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if ((isIdSelector && child.id === id) || (!isIdSelector && child.tagName === tag)) { + return child; + } + const nestedChild = child.querySelector(selector); + if (nestedChild) { + return nestedChild; + } + } + return null; + }, + querySelectorAll(selector: string): FakeElement[] { + const matches: FakeElement[] = []; + const isIdSelector = selector.charAt(0) === '#'; + const id = isIdSelector ? selector.slice(1) : ''; + const tag = isIdSelector ? '' : selector.toUpperCase(); + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if ((isIdSelector && child.id === id) || (!isIdSelector && child.tagName === tag)) { + matches.push(child); + } + matches.push(...child.querySelectorAll(selector)); + } + return matches; + }, + replaceChildren(...newChildren: FakeElement[]): void { + children.length = 0; + for (let i = 0; i < newChildren.length; i++) { + newChildren[i].parentElement = this; + } + children.push(...newChildren); + }, + remove(): void { + this.parentElement?.removeChild(this); + }, + addEventListener(name: string, listener: unknown): void { + const listenerObject = listener as { handleEvent?: unknown }; + if (typeof listener !== 'function' && (!listener || typeof listenerObject.handleEvent !== 'function')) { + return; + } + const namedListeners = listeners.get(name) ?? []; + namedListeners.push(listener as FakeEventListener); + listeners.set(name, namedListeners); + }, + removeEventListener(name: string, listener: unknown): void { + const namedListeners = listeners.get(name); + if (!namedListeners) { + return; + } + const index = namedListeners.indexOf(listener as FakeEventListener); + if (index >= 0) { + namedListeners.splice(index, 1); + } + }, + dispatchEvent(event: FakeDomEvent): boolean { + const namedListeners = listeners.get(event.type) ?? []; + for (let i = 0; i < namedListeners.length; i++) { + const listener = namedListeners[i]; + if (typeof listener === 'function') { + listener(event); + } else { + listener.handleEvent(event); + } + } + return event.defaultPrevented !== true; + }, + focus(): void {}, + blur(): void {}, + setSelectionRange(_start: number, _end: number): void {}, + getBoundingClientRect(): { left: number; top: number; width: number; height: number } { + this.rectReadCount++; + const width = this.rectWidth || (this.style.width === '100%' ? (this.parentElement?.rectWidth ?? 0) : 0); + const height = this.rectHeight || (this.style.height === '100%' ? (this.parentElement?.rectHeight ?? 0) : 0); + return { left: this.rectLeft, top: this.rectTop, width, height }; + }, + getContext(contextId: string): FakeCanvasContext | null { + return contextId === '2d' ? this.canvasContext : null; + }, + getRootNode(): Record { + return (globalThis as unknown as { document?: Record }).document ?? this.ownerDocument; + }, + getTotalLength(): number { + return 100; + }, + play(): Promise { + return Promise.resolve(); + }, + pause(): void {}, + }; + return element; +} + +let lastImage: FakeImage | undefined; +let imageConstructionCount = 0; + +function installDomStubs(): () => void { + const previousDocument = (globalThis as { document?: unknown }).document; + const previousImage = (globalThis as { Image?: unknown }).Image; + const previousRequestAnimationFrame = (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame; + const previousCancelAnimationFrame = (globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame; + const previousPerformance = (globalThis as { performance?: unknown }).performance; + const previousWindow = (globalThis as { window?: unknown }).window; + const previousResizeObserver = (globalThis as { ResizeObserver?: unknown }).ResizeObserver; + const animationFrameCallbacks = new Map void>(); + const windowListeners = new Map void>>(); + let animationFrameTime = 0; + let animationFrameRequestCount = 0; + let nextAnimationFrameHandle = 1; + lastImage = undefined; + imageConstructionCount = 0; + const head = makeFakeElement('head'); + (globalThis as { document?: unknown }).document = { + dir: 'ltr', + activeElement: null, + head, + createElement(tagName: string): FakeElement { + return makeFakeElement(tagName); + }, + createElementNS(_namespaceURI: string, qualifiedName: string): FakeElement { + return makeFakeElement(qualifiedName); + }, + querySelector(selector: string): FakeElement | null { + return head.querySelector(selector); + }, + appendChild(child: FakeElement): void { + head.appendChild(child); + }, + addEventListener(_name: string, _listener: unknown): void {}, + removeEventListener(_name: string, _listener: unknown): void {}, + }; + (globalThis as { Image?: unknown }).Image = function () { + imageConstructionCount++; + const image = makeFakeElement('img') as FakeImage; + const removeElementAttribute = image.removeAttribute.bind(image); + image.crossOrigin = null; + image.naturalWidth = 0; + image.naturalHeight = 0; + image.src = ''; + image.onload = null; + image.onabort = null; + image.onerror = null; + image.removeAttribute = (name: string): void => { + removeElementAttribute(name); + if (name === 'src') { + image.src = ''; + } else if (name === 'crossorigin') { + image.crossOrigin = null; + } + }; + lastImage = image; + return image; + }; + (globalThis as { window?: unknown }).window = { + devicePixelRatio: 1, + addEventListener(name: string, listener: () => void): void { + const listeners = windowListeners.get(name) ?? []; + listeners.push(listener); + windowListeners.set(name, listeners); + }, + removeEventListener(name: string, listener: () => void): void { + const listeners = windowListeners.get(name); + if (!listeners) { + return; + } + const index = listeners.indexOf(listener); + if (index >= 0) { + listeners.splice(index, 1); + } + }, + dispatchEvent(event: { type: string }): void { + const listeners = windowListeners.get(event.type) ?? []; + for (const listener of Array.from(listeners)) { + listener(); + } + }, + listenerCount(name: string): number { + return windowListeners.get(name)?.length ?? 0; + }, + }; + (globalThis as { requestAnimationFrame?: (callback: (time: number) => void) => number }).requestAnimationFrame = + callback => { + animationFrameRequestCount++; + const handle = nextAnimationFrameHandle++; + animationFrameCallbacks.set(handle, callback); + return handle; + }; + (globalThis as { cancelAnimationFrame?: (handle: number) => void }).cancelAnimationFrame = handle => { + animationFrameCallbacks.delete(handle); + }; + (globalThis as { performance?: unknown }).performance = { now: () => animationFrameTime }; + (globalThis as { __flushTextAnimationFrame?: (time: number) => void }).__flushTextAnimationFrame = time => { + animationFrameTime = time; + const callbacks = Array.from(animationFrameCallbacks.values()); + animationFrameCallbacks.clear(); + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](time); + } + }; + (globalThis as { __getAnimationFrameRequestCount?: () => number }).__getAnimationFrameRequestCount = () => + animationFrameRequestCount; + delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver; + return () => { + (globalThis as { document?: unknown }).document = previousDocument; + if (previousImage === undefined) { + delete (globalThis as { Image?: unknown }).Image; + } else { + (globalThis as { Image?: unknown }).Image = previousImage; + } + if (previousRequestAnimationFrame === undefined) { + delete (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame; + } else { + (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = previousRequestAnimationFrame; + } + if (previousCancelAnimationFrame === undefined) { + delete (globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame; + } else { + (globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame = previousCancelAnimationFrame; + } + if (previousPerformance === undefined) { + delete (globalThis as { performance?: unknown }).performance; + } else { + (globalThis as { performance?: unknown }).performance = previousPerformance; + } + if (previousWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window?: unknown }).window = previousWindow; + } + if (previousResizeObserver === undefined) { + delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver; + } else { + (globalThis as { ResizeObserver?: unknown }).ResizeObserver = previousResizeObserver; + } + delete (globalThis as { __flushTextAnimationFrame?: unknown }).__flushTextAnimationFrame; + delete (globalThis as { __getAnimationFrameRequestCount?: unknown }).__getAnimationFrameRequestCount; + }; +} + +describe('web renderer core', () => { + let uninstallDomStubs: () => void; + let nextId = 1; + let paletteManager: ColorPaletteManager; + let tree: ViewNodeTree; + + beforeEach(() => { + uninstallDomStubs = installDomStubs(); + paletteManager = new ColorPaletteManager(); + tree = new ViewNodeTree(paletteManager); + tree.setPostLayoutScheduler(callback => callback()); + }); + + afterEach(() => { + tree.destroy(); + uninstallDomStubs(); + }); + + function getNode(id: number): ViewNode { + const node = tree.getNode(id); + if (!node) { + throw new Error(`Missing test node ${id}`); + } + return node; + } + + function getViewPaintElement(id: number): FakeElement { + const element = getNode(id).htmlElement as unknown as FakeElement; + const paintElement = element.childNodes.item(0); + if (!paintElement) { + throw new Error(`Missing paint element for test node ${id}`); + } + return paintElement; + } + + function reflectPixelStyleSizeInLayout(element: FakeElement): void { + let width = element.style.width; + let height = element.style.height; + Object.defineProperty(element.style, 'width', { + configurable: true, + get: () => width, + set: value => { + width = value; + if (typeof value === 'string' && value.endsWith('px')) { + element.layoutWidth = Number.parseFloat(value); + } + }, + }); + Object.defineProperty(element.style, 'height', { + configurable: true, + get: () => height, + set: value => { + height = value; + if (typeof value === 'string' && value.endsWith('px')) { + element.layoutHeight = Number.parseFloat(value); + } + }, + }); + } + + function reflectPixelStylePositionInLayout(element: FakeElement): void { + let left = element.style.left; + let top = element.style.top; + Object.defineProperty(element.style, 'left', { + configurable: true, + get: () => left, + set: value => { + left = value; + if (typeof value === 'string' && value.endsWith('px')) { + element.layoutLeft = Number.parseFloat(value); + } + }, + }); + Object.defineProperty(element.style, 'top', { + configurable: true, + get: () => top, + set: value => { + top = value; + if (typeof value === 'string' && value.endsWith('px')) { + element.layoutTop = Number.parseFloat(value); + } + }, + }); + } + + function createTestElement(viewClass: string): number { + const id = nextId++; + tree.createElement(id, viewClass); + return id; + } + + function createRootTestElement(viewClass: string): number { + const id = createTestElement(viewClass); + tree.makeElementRoot(id, makeFakeElement('root') as unknown as HTMLElement); + return id; + } + + function getLastImage(): FakeImage { + if (!lastImage) { + throw new Error('Expected an image to be constructed'); + } + return lastImage; + } + + function triggerImageLoad(naturalWidth: number, naturalHeight: number): void { + const image = getLastImage(); + image.naturalWidth = naturalWidth; + image.naturalHeight = naturalHeight; + image.onload?.(); + } + + function triggerImageError(): void { + getLastImage().onerror?.(); + } + + function flushTextAnimationFrame(time: number): void { + (globalThis as unknown as { __flushTextAnimationFrame: (time: number) => void }).__flushTextAnimationFrame(time); + } + + function getAnimationFrameRequestCount(): number { + return ( + globalThis as unknown as { __getAnimationFrameRequestCount: () => number } + ).__getAnimationFrameRequestCount(); + } + + function makeFakeEvent(type: string, key?: string): FakeDomEvent { + const event: FakeDomEvent = { + type, + key, + defaultPrevented: false, + preventDefault(): void { + event.defaultPrevented = true; + }, + }; + return event; + } + + function animatedText(text: string) { + return new AttributedTextBuilder() + .append(text, { + animationTransform: { + duration: 1, + opacity: 0, + timeOffsetBetweenParts: 0.1, + }, + }) + .build(); + } + + function attributedPartSpan(id: number): FakeElement { + const element = getNode(id).htmlElement as unknown as FakeElement; + const container = element.childNodes.item(0)!; + return container.childNodes.item(0)!; + } + + let nextTestElementClassId = 1; + + function registerTestElementClass( + attributeAppliers: Readonly>, + destroy?: (element: HTMLElement) => void, + ): string { + const viewClass = `test-view-node-${nextTestElementClassId++}`; + class TestElementClass extends ElementClass { + constructor() { + super(viewClass, attributeAppliers); + } + + protected onCreateElement(): HTMLElement { + return document.createElement('div'); + } + + destroy(element: HTMLElement): void { + destroy?.(element); + } + } + registerElementClassAlias(viewClass, new TestElementClass()); + return viewClass; + } + + function registerTestWebViewClass(factory: WebViewClassFactory): string { + const className = `test-web-view-${nextTestElementClassId++}`; + registerWebViewClass(className, factory); + return className; + } + + async function waitForScheduledFlush(): Promise { + await Promise.resolve(); + await Promise.resolve(); + } + + function dispatchWindowResize(): void { + (window as unknown as { dispatchEvent(event: { type: string }): void }).dispatchEvent({ type: 'resize' }); + } + + it('resolves style attributes below direct attributes and falls back after direct removal', () => { + const id = createRootTestElement('view'); + const style = new Style({ width: '100%', backgroundColor: 'red' }); + + tree.setStyleAttributeOnElement(id, 'style', style); + tree.flush(); + expect(getNode(id).htmlElement.style.width).toBe('100%'); + + tree.setAttributeOnElement(id, 'width', 42); + tree.flush(); + expect(getNode(id).htmlElement.style.width).toBe('42px'); + + tree.setAttributeOnElement(id, 'width', undefined); + tree.flush(); + expect(getNode(id).htmlElement.style.width).toBe('100%'); + }); + + it('animates opacity through an animation transaction and applies the exact final value', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + let completion: boolean | undefined; + tree.setAttributeOnElement(id, 'opacity', 0); + tree.flush(); + + tree.beginAnimation( + { + curve: AnimationCurve.Linear, + duration: 1, + completion: cancelled => { + completion = cancelled; + }, + }, + 1, + ); + tree.setAttributeOnElement(id, 'opacity', 1); + tree.endAnimation(); + + expect(element.style.opacity).toBe('0'); + flushTextAnimationFrame(500); + expect(Number(element.style.opacity)).toBeCloseTo(0.5, 5); + expect(completion).toBeUndefined(); + flushTextAnimationFrame(1000); + expect(element.style.opacity).toBe('1'); + expect(completion).toBeFalse(); + }); + + it('keeps layout elements separate from view appearance bindings', () => { + const id = createRootTestElement('layout'); + const element = getNode(id).htmlElement as unknown as FakeElement; + const warnSpy = spyOn(console, 'warn'); + let created = false; + + tree.setAttributeOnElement(id, 'width', 120); + tree.setAttributeOnElement(id, 'accessibilityLabel', 'Layout container'); + tree.setAttributeOnElement(id, 'onViewCreate', () => { + created = true; + }); + tree.setAttributeOnElement(id, 'backgroundColor', 'red'); + tree.setAttributeOnElement(id, 'opacity', 0.5); + tree.setAttributeOnElement(id, 'translationX', 20); + tree.setAttributeOnElement(id, 'onTap', () => {}); + tree.flush(); + + expect(element.style.width).toBe('120px'); + expect(element.getAttribute('aria-label')).toBe('Layout container'); + expect(created).toBeTrue(); + expect(element.childNodes.length).toBe(0); + expect(element.style.backgroundColor).toBeUndefined(); + expect(element.style.opacity).toBeUndefined(); + expect(element.style.transform).toBeUndefined(); + expect( + warnSpy.calls + .allArgs() + .map(args => String(args[0])) + .join('\n'), + ).toContain("'backgroundColor'"); + expect( + warnSpy.calls + .allArgs() + .map(args => String(args[0])) + .join('\n'), + ).toContain("'onTap'"); + }); + + it('does not create a paint element for layout, subtree, or transform-only behavior', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement as unknown as FakeElement; + + tree.setAttributeOnElement(id, 'width', 100); + tree.setAttributeOnElement(id, 'color', 'purple'); + tree.setAttributeOnElement(id, 'opacity', 0.6); + tree.setAttributeOnElement(id, 'translationX', 12); + tree.setAttributeOnElement(id, 'maskPath', 'M 0 0 L 1 1'); + tree.setAttributeOnElement(id, 'slowClipping', true); + tree.setAttributeOnElement(id, 'touchEnabled', false); + tree.flush(); + + expect(element.childNodes.length).toBe(0); + expect(element.style.color).toBe('purple'); + expect(element.style.opacity).toBe('0.6'); + expect(element.style.transform).toContain('translate(12px, 0px)'); + expect(element.style.getPropertyValue('mask-image')).toContain('data:image/svg+xml'); + expect(element.style.overflow).toBe('hidden'); + expect(element.style.pointerEvents).toBe('none'); + }); + + it('creates one retained paint element for all generic view decorations', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement as unknown as FakeElement; + + tree.setAttributeOnElement(id, 'backgroundColor', 'red'); + tree.setAttributeOnElement(id, 'borderWidth', 3); + tree.setAttributeOnElement(id, 'borderColor', 'blue'); + tree.setAttributeOnElement(id, 'borderStyle', 'solid'); + tree.setAttributeOnElement(id, 'borderRadius', 14); + tree.setAttributeOnElement(id, 'boxShadow', '0 2 6 black'); + tree.flush(); + + const paintElement = getViewPaintElement(id); + expect(element.childNodes.length).toBe(1); + expect(paintElement.style.position).toBe('absolute'); + expect(paintElement.style.inset).toBe('0'); + expect(paintElement.style.pointerEvents).toBe('none'); + expect(paintElement.style.transformOrigin).toBe('0 0'); + expect(paintElement.style.zIndex).toBe('-1'); + expect(paintElement.getAttribute('aria-hidden')).toBe('true'); + expect(element.style.isolation).toBe('isolate'); + expect(paintElement.style.backgroundColor).toBe('red'); + expect(paintElement.style.borderWidth).toBe('3px'); + expect(paintElement.style.borderColor).toBe('blue'); + expect(paintElement.style.borderStyle).toBe('solid'); + expect(paintElement.style.borderRadius).toBe('14px'); + expect(paintElement.style.boxShadow).toBe('0px 2px 6px black'); + expect(element.style.backgroundColor).toBeUndefined(); + + tree.setAttributeOnElement(id, 'backgroundColor', undefined); + tree.setAttributeOnElement(id, 'borderWidth', undefined); + tree.setAttributeOnElement(id, 'borderColor', undefined); + tree.setAttributeOnElement(id, 'borderStyle', undefined); + tree.setAttributeOnElement(id, 'borderRadius', undefined); + tree.setAttributeOnElement(id, 'boxShadow', undefined); + tree.flush(); + + expect(element.childNodes.length).toBe(1); + expect(element.childNodes.item(0)).toBe(paintElement); + }); + + it('renders border width and color without relying on an ambient border style', () => { + const id = createRootTestElement('view'); + + tree.setAttributeOnElement(id, 'borderWidth', 2); + tree.setAttributeOnElement(id, 'borderColor', 'black'); + tree.flush(); + + const paintElement = getViewPaintElement(id); + expect(paintElement.style.borderWidth).toBe('2px'); + expect(paintElement.style.borderColor).toBe('black'); + expect(paintElement.style.borderStyle).toBe('solid'); + + tree.setAttributeOnElement(id, 'borderWidth', undefined); + tree.flush(); + + expect(paintElement.style.borderWidth).toBe('0px'); + }); + + it('scales label decorations without scaling or replacing its text content', () => { + const id = createRootTestElement('label'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.layoutWidth = 100; + element.layoutHeight = 20; + reflectPixelStyleSizeInLayout(element); + + tree.setAttributeOnElement(id, 'value', 'Initial label'); + tree.setAttributeOnElement(id, 'backgroundColor', 'red'); + tree.setAttributeOnElement(id, 'borderWidth', 2); + tree.setAttributeOnElement(id, 'borderColor', 'blue'); + tree.setAttributeOnElement(id, 'borderStyle', 'solid'); + tree.flush(); + + const paintElement = getViewPaintElement(id); + const textContentElement = element.childNodes.item(1)!; + expect(element.childNodes.length).toBe(2); + expect(paintElement.style.backgroundColor).toBe('red'); + expect(paintElement.style.borderWidth).toBe('2px'); + expect(paintElement.style.borderColor).toBe('blue'); + expect(paintElement.style.borderStyle).toBe('solid'); + expect(textContentElement.style.display).toBe('contents'); + expect(textContentElement.textContent).toBe('Initial label'); + expect(element.style.backgroundColor).toBeUndefined(); + + tree.setAttributeOnElement(id, 'value', 'Updated label'); + tree.flush(); + expect(element.childNodes.item(0)).toBe(paintElement); + expect(element.childNodes.item(1)).toBe(textContentElement); + expect(textContentElement.textContent).toBe('Updated label'); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 898); + tree.setAttributeOnElement(id, 'width', 200); + tree.setAttributeOnElement(id, 'height', 40); + tree.endAnimation(); + + expect(element.style.scale).toBeUndefined(); + expect(textContentElement.style.scale).toBeUndefined(); + expect(paintElement.style.scale).toBe('0.5 0.5'); + flushTextAnimationFrame(1000); + expect(paintElement.style.scale).toBeUndefined(); + }); + + it('keeps the paint element outside logical child ordering', () => { + const root = createRootTestElement('view'); + tree.setAttributeOnElement(root, 'backgroundColor', 'red'); + tree.flush(); + const paintElement = getViewPaintElement(root); + const first = createTestElement('view'); + const second = createTestElement('view'); + const firstElement = getNode(first).htmlElement as unknown as FakeElement; + const secondElement = getNode(second).htmlElement as unknown as FakeElement; + + tree.moveElement(first, root, 0); + tree.moveElement(second, root, 1); + const rootElement = getNode(root).htmlElement as unknown as FakeElement; + expect(rootElement.childNodes.item(0)).toBe(paintElement); + expect(rootElement.childNodes.item(1)).toBe(firstElement); + expect(rootElement.childNodes.item(2)).toBe(secondElement); + + tree.moveElement(second, root, 0); + expect(rootElement.childNodes.item(0)).toBe(paintElement); + expect(rootElement.childNodes.item(1)).toBe(secondElement); + expect(rootElement.childNodes.item(2)).toBe(firstElement); + + tree.destroyElement(second); + expect(rootElement.childNodes.item(0)).toBe(paintElement); + expect(rootElement.childNodes.item(1)).toBe(firstElement); + }); + + it('does not capture layout when a decoration changes', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.layoutWidth = 100; + element.layoutHeight = 100; + element.layoutReadCount = 0; + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 899); + tree.setAttributeOnElement(id, 'backgroundColor', 'red'); + tree.endAnimation(); + + expect(element.layoutReadCount).toBe(0); + expect(getViewPaintElement(id).style.backgroundColor).toBe('transparent'); + expect(getAnimationFrameRequestCount()).toBe(1); + }); + + it('captures one layout pass for multiple layout attributes', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.layoutWidth = 100; + element.layoutHeight = 100; + reflectPixelStyleSizeInLayout(element); + tree.setAttributeOnElement(id, 'backgroundColor', 'red'); + tree.flush(); + const paintElement = getViewPaintElement(id); + element.layoutReadCount = 0; + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 900); + tree.setAttributeOnElement(id, 'width', 200); + tree.setAttributeOnElement(id, 'height', 150); + tree.endAnimation(); + + expect(element.layoutReadCount).toBe(8); + expect(element.style.scale).toBeUndefined(); + expect(paintElement.style.scale).toBe('0.5 0.6666666666666666'); + expect(getAnimationFrameRequestCount()).toBe(1); + + flushTextAnimationFrame(500); + expect(paintElement.style.scale).toBe('0.75 0.8333333333333334'); + flushTextAnimationFrame(1000); + expect(paintElement.style.scale).toBeUndefined(); + }); + + it('reads each nested element layout once per snapshot', () => { + const viewClass = registerTestElementClass({ + testWidth: { + layoutDependent: true, + apply(element, value) { + (element as unknown as FakeElement).layoutWidth = Number(value); + }, + reset(element) { + (element as unknown as FakeElement).layoutWidth = 0; + }, + }, + }); + const root = createRootTestElement(viewClass); + const child = createTestElement('view'); + const grandchild = createTestElement('view'); + tree.moveElement(child, root, 0); + tree.moveElement(grandchild, child, 0); + const rootElement = getNode(root).htmlElement as unknown as FakeElement; + const childElement = getNode(child).htmlElement as unknown as FakeElement; + const grandchildElement = getNode(grandchild).htmlElement as unknown as FakeElement; + rootElement.layoutWidth = 100; + rootElement.layoutHeight = 100; + childElement.layoutWidth = 50; + childElement.layoutHeight = 50; + grandchildElement.layoutWidth = 25; + grandchildElement.layoutHeight = 25; + childElement.offsetParent = rootElement; + grandchildElement.offsetParent = childElement; + rootElement.layoutReadCount = 0; + childElement.layoutReadCount = 0; + grandchildElement.layoutReadCount = 0; + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 914); + tree.setAttributeOnElement(root, 'testWidth', 200); + tree.endAnimation(); + + expect(rootElement.layoutReadCount).toBe(8); + expect(childElement.layoutReadCount).toBe(8); + expect(grandchildElement.layoutReadCount).toBe(8); + }); + + it('captures layout before inserting a child', () => { + const root = createRootTestElement('view'); + const existing = createTestElement('view'); + tree.moveElement(existing, root, 0); + const rootElement = getNode(root).htmlElement as unknown as FakeElement; + const existingElement = getNode(existing).htmlElement as unknown as FakeElement; + rootElement.layoutWidth = 300; + rootElement.layoutHeight = 100; + existingElement.layoutWidth = 100; + existingElement.layoutHeight = 50; + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 907); + const inserted = createTestElement('view'); + tree.moveElement(inserted, root, 0); + const insertedElement = getNode(inserted).htmlElement as unknown as FakeElement; + insertedElement.layoutWidth = 80; + insertedElement.layoutHeight = 50; + existingElement.layoutLeft = 80; + tree.endAnimation(); + + expect(existingElement.style.translate).toBe('-80px 0px'); + expect(insertedElement.style.translate).toBeUndefined(); + flushTextAnimationFrame(500); + expect(existingElement.style.translate).toBe('-40px 0px'); + flushTextAnimationFrame(1000); + expect(existingElement.style.translate).toBeUndefined(); + }); + + it('captures layout before reordering children', () => { + const root = createRootTestElement('view'); + const first = createTestElement('view'); + const second = createTestElement('view'); + tree.moveElement(first, root, 0); + tree.moveElement(second, root, 1); + const rootElement = getNode(root).htmlElement as unknown as FakeElement; + const firstElement = getNode(first).htmlElement as unknown as FakeElement; + const secondElement = getNode(second).htmlElement as unknown as FakeElement; + rootElement.layoutWidth = 300; + rootElement.layoutHeight = 100; + firstElement.layoutWidth = 100; + firstElement.layoutHeight = 50; + secondElement.layoutLeft = 100; + secondElement.layoutWidth = 100; + secondElement.layoutHeight = 50; + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 910); + tree.moveElement(second, root, 0); + firstElement.layoutLeft = 100; + secondElement.layoutLeft = 0; + tree.endAnimation(); + + expect(firstElement.style.translate).toBe('-100px 0px'); + expect(secondElement.style.translate).toBe('100px 0px'); + flushTextAnimationFrame(1000); + expect(firstElement.style.translate).toBeUndefined(); + expect(secondElement.style.translate).toBeUndefined(); + }); + + it('captures layout before removing a child', () => { + const root = createRootTestElement('view'); + const removed = createTestElement('view'); + const remaining = createTestElement('view'); + tree.moveElement(removed, root, 0); + tree.moveElement(remaining, root, 1); + const rootElement = getNode(root).htmlElement as unknown as FakeElement; + const removedElement = getNode(removed).htmlElement as unknown as FakeElement; + const remainingElement = getNode(remaining).htmlElement as unknown as FakeElement; + rootElement.layoutWidth = 300; + rootElement.layoutHeight = 100; + removedElement.layoutWidth = 100; + removedElement.layoutHeight = 50; + remainingElement.layoutLeft = 100; + remainingElement.layoutWidth = 100; + remainingElement.layoutHeight = 50; + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 908); + tree.destroyElement(removed); + remainingElement.layoutLeft = 0; + tree.endAnimation(); + + expect(remainingElement.style.translate).toBe('100px 0px'); + flushTextAnimationFrame(500); + expect(remainingElement.style.translate).toBe('50px 0px'); + flushTextAnimationFrame(1000); + expect(remainingElement.style.translate).toBeUndefined(); + }); + + it('animates sibling layout while retaining a child for exit appearance', () => { + const root = createRootTestElement('view'); + const exiting = createTestElement('view'); + const remaining = createTestElement('view'); + tree.moveElement(exiting, root, 0); + tree.moveElement(remaining, root, 1); + const rootElement = getNode(root).htmlElement as unknown as FakeElement; + const exitingElement = getNode(exiting).htmlElement as unknown as FakeElement; + const remainingElement = getNode(remaining).htmlElement as unknown as FakeElement; + rootElement.layoutWidth = 300; + rootElement.layoutHeight = 100; + exitingElement.layoutWidth = 100; + exitingElement.layoutHeight = 50; + remainingElement.layoutLeft = 100; + remainingElement.layoutWidth = 100; + remainingElement.layoutHeight = 50; + + tree.beginAnimation( + { + curve: AnimationCurve.Linear, + duration: 1, + appearanceBehavior: { exitAttributes: { opacity: 0 } }, + }, + 911, + ); + tree.destroyElement(exiting); + remainingElement.layoutLeft = 0; + tree.endAnimation(); + + expect(tree.getNode(exiting)).toBeDefined(); + expect(remainingElement.style.translate).toBe('100px 0px'); + flushTextAnimationFrame(1000); + expect(tree.getNode(exiting)).toBeUndefined(); + expect(remainingElement.style.translate).toBeUndefined(); + }); + + it('keeps active layout animations whose frames survive an immediate tree mutation', () => { + const root = createRootTestElement('view'); + const rootElement = getNode(root).htmlElement as unknown as FakeElement; + rootElement.layoutWidth = 100; + rootElement.layoutHeight = 100; + reflectPixelStyleSizeInLayout(rootElement); + tree.setAttributeOnElement(root, 'backgroundColor', 'red'); + tree.flush(); + const paintElement = getViewPaintElement(root); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 909); + tree.setAttributeOnElement(root, 'width', 200); + tree.endAnimation(); + expect(paintElement.style.scale).toBe('0.5 1'); + + const child = createTestElement('view'); + tree.moveElement(child, root, 0); + tree.flush(); + + expect(paintElement.style.scale).toBe('0.5 1'); + flushTextAnimationFrame(1000); + expect(paintElement.style.scale).toBeUndefined(); + }); + + it('cancels only layout animations whose scheduled frames changed', () => { + const viewClass = registerTestElementClass({ + testLeft: { + layoutDependent: true, + apply(element, value) { + (element as unknown as FakeElement).layoutLeft = Number(value); + }, + reset(element) { + (element as unknown as FakeElement).layoutLeft = 0; + }, + }, + }); + const root = createRootTestElement('view'); + const first = createTestElement(viewClass); + const second = createTestElement(viewClass); + tree.moveElement(first, root, 0); + tree.moveElement(second, root, 1); + const rootElement = getNode(root).htmlElement as unknown as FakeElement; + const firstElement = getNode(first).htmlElement as unknown as FakeElement; + const secondElement = getNode(second).htmlElement as unknown as FakeElement; + rootElement.layoutWidth = 400; + rootElement.layoutHeight = 100; + firstElement.layoutWidth = 50; + firstElement.layoutHeight = 50; + secondElement.layoutLeft = 100; + secondElement.layoutWidth = 50; + secondElement.layoutHeight = 50; + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 912); + tree.setAttributeOnElement(first, 'testLeft', 100); + tree.setAttributeOnElement(second, 'testLeft', 200); + tree.endAnimation(); + expect(firstElement.style.translate).toBe('-100px 0px'); + expect(secondElement.style.translate).toBe('-100px 0px'); + + const inserted = createTestElement('view'); + tree.moveElement(inserted, root, 0); + firstElement.layoutLeft = 150; + tree.flush(); + + expect(firstElement.style.translate).toBeUndefined(); + expect(secondElement.style.translate).toBe('-100px 0px'); + flushTextAnimationFrame(500); + expect(secondElement.style.translate).toBe('-50px 0px'); + flushTextAnimationFrame(1000); + expect(secondElement.style.translate).toBeUndefined(); + }); + + it('reprojects a surviving counter-translation after cancelling its animated parent', () => { + const viewClass = registerTestElementClass({ + testLeft: { + layoutDependent: true, + apply(element, value) { + (element as unknown as FakeElement).layoutLeft = Number(value); + }, + reset(element) { + (element as unknown as FakeElement).layoutLeft = 0; + }, + }, + }); + const parent = createRootTestElement(viewClass); + const child = createTestElement(viewClass); + tree.moveElement(child, parent, 0); + const parentElement = getNode(parent).htmlElement as unknown as FakeElement; + const childElement = getNode(child).htmlElement as unknown as FakeElement; + parentElement.layoutWidth = 100; + parentElement.layoutHeight = 100; + childElement.layoutWidth = 50; + childElement.layoutHeight = 20; + childElement.offsetParent = parentElement; + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 913); + tree.setAttributeOnElement(parent, 'testLeft', 100); + tree.setAttributeOnElement(child, 'testLeft', -100); + tree.endAnimation(); + expect(parentElement.style.translate).toBe('-100px 0px'); + expect(childElement.style.translate).toBe('100px 0px'); + + tree.beginRender(); + tree.setAttributeOnElement(parent, 'testLeft', 150); + tree.setAttributeOnElement(child, 'testLeft', -150); + tree.endRender(); + tree.flush(); + + expect(parentElement.style.translate).toBeUndefined(); + expect(childElement.style.translate).toBe('0px 0px'); + flushTextAnimationFrame(1000); + expect(childElement.style.translate).toBeUndefined(); + }); + + it('scales generic view paint without scaling its text descendants', () => { + const parentId = createRootTestElement('view'); + const textId = createTestElement('label'); + tree.moveElement(textId, parentId, 0); + const parent = getNode(parentId).htmlElement as unknown as FakeElement; + const text = getNode(textId).htmlElement as unknown as FakeElement; + parent.layoutWidth = 100; + parent.layoutHeight = 100; + reflectPixelStyleSizeInLayout(parent); + text.layoutWidth = 50; + text.layoutHeight = 20; + tree.setAttributeOnElement(parentId, 'backgroundColor', 'red'); + tree.flush(); + const paintElement = getViewPaintElement(parentId); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 901); + tree.setAttributeOnElement(parentId, 'width', 200); + tree.endAnimation(); + + expect(parent.style.scale).toBeUndefined(); + expect(paintElement.style.scale).toBe('0.5 1'); + expect(text.style.scale).toBeUndefined(); + flushTextAnimationFrame(1000); + expect(paintElement.style.scale).toBeUndefined(); + expect(text.style.scale).toBeUndefined(); + }); + + it('scales image nodes and compensates their transform origins', () => { + const root = createRootTestElement('view'); + const cases = [ + { origin: 'center', scale: '0.5 0.5', translate: '-50px -40px' }, + { origin: 'left top', scale: '0.5 0.5', translate: '0px 0px' }, + { origin: '25% 75%', scale: '0.5 0.5', translate: '-25px -60px' }, + { origin: '12px 18px', scale: '0.5 0.5', translate: '-6px -9px' }, + { origin: 'center', originalScale: '2 3', scale: '1 1.5', translate: '-100px -120px' }, + ]; + const ids: number[] = []; + for (const testCase of cases) { + const id = createTestElement('image'); + ids.push(id); + tree.moveElement(id, root, ids.length - 1); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.layoutWidth = 100; + element.layoutHeight = 80; + reflectPixelStyleSizeInLayout(element); + if (testCase.originalScale) { + element.style.setProperty('scale', testCase.originalScale); + } + tree.setAttributeOnElement(id, 'transformOrigin', testCase.origin); + } + const rootElement = getNode(root).htmlElement as unknown as FakeElement; + rootElement.layoutWidth = 500; + rootElement.layoutHeight = 200; + tree.flush(); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 915); + for (const id of ids) { + tree.setAttributeOnElement(id, 'width', 200); + tree.setAttributeOnElement(id, 'height', 160); + } + tree.endAnimation(); + + for (let index = 0; index < ids.length; index++) { + const element = getNode(ids[index]).htmlElement; + expect(element.style.scale).toBe(cases[index].scale); + expect(element.style.translate).toBe(cases[index].translate); + } + + flushTextAnimationFrame(1000); + for (let index = 0; index < ids.length; index++) { + const element = getNode(ids[index]).htmlElement; + if (cases[index].originalScale) { + expect(element.style.scale).toBe(cases[index].originalScale!); + } else { + expect(element.style.scale).toBeUndefined(); + } + expect(element.style.translate).toBeUndefined(); + } + }); + + it('renders image content at the final layout size before scaling it in either direction', () => { + const id = createRootTestElement('image'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.layoutWidth = 100; + element.layoutHeight = 50; + element.rectWidth = 100; + element.rectHeight = 50; + element.style.transformOrigin = 'center'; + reflectPixelStyleSizeInLayout(element); + tree.setAttributeOnElement(id, 'src', 'test.png'); + tree.setAttributeOnElement(id, 'objectFit', 'cover'); + tree.setAttributeOnElement(id, 'width', 100); + tree.setAttributeOnElement(id, 'height', 50); + tree.flush(); + triggerImageLoad(300, 150); + tree.drainScheduledLayoutObserverRefresh(); + const image = getLastImage(); + expect(image.style.width).toBe('100px'); + expect(image.style.height).toBe('50px'); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 916); + tree.setAttributeOnElement(id, 'width', 200); + tree.setAttributeOnElement(id, 'height', 100); + tree.endAnimation(); + + expect(element.style.scale).toBe('0.5 0.5'); + expect(image.style.width).toBe('200px'); + expect(image.style.height).toBe('100px'); + + element.rectWidth = 100; + element.rectHeight = 50; + tree.drainScheduledLayoutObserverRefresh(); + expect(image.style.width).toBe('200px'); + expect(image.style.height).toBe('100px'); + + flushTextAnimationFrame(1000); + element.rectWidth = 200; + element.rectHeight = 100; + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 917); + tree.setAttributeOnElement(id, 'width', 100); + tree.setAttributeOnElement(id, 'height', 50); + tree.endAnimation(); + + expect(element.style.scale).toBe('2 2'); + expect(image.style.width).toBe('100px'); + expect(image.style.height).toBe('50px'); + + element.rectWidth = 200; + element.rectHeight = 100; + tree.drainScheduledLayoutObserverRefresh(); + expect(image.style.width).toBe('100px'); + expect(image.style.height).toBe('50px'); + + flushTextAnimationFrame(2000); + expect(element.style.scale).toBeUndefined(); + }); + + it('snaps label size to final layout while animating its position', () => { + const id = createRootTestElement('label'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.layoutWidth = 100; + element.layoutHeight = 40; + reflectPixelStyleSizeInLayout(element); + reflectPixelStylePositionInLayout(element); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 916); + tree.setAttributeOnElement(id, 'left', 100); + tree.setAttributeOnElement(id, 'width', 200); + tree.endAnimation(); + + expect(element.layoutWidth).toBe(200); + expect(element.style.translate).toBe('-100px 0px'); + expect(element.style.scale).toBeUndefined(); + flushTextAnimationFrame(500); + expect(element.style.translate).toBe('-50px 0px'); + expect(element.style.scale).toBeUndefined(); + }); + + it('does not create size animation work for undecorated generic views', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.layoutWidth = 100; + element.layoutHeight = 40; + reflectPixelStyleSizeInLayout(element); + const animationFrameCount = getAnimationFrameRequestCount(); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 917); + tree.setAttributeOnElement(id, 'width', 200); + tree.endAnimation(); + + expect(element.childNodes.length).toBe(0); + expect(element.layoutWidth).toBe(200); + expect(element.style.scale).toBeUndefined(); + expect(getAnimationFrameRequestCount()).toBe(animationFrameCount); + }); + + it('does not capture layout for an animations-disabled subtree', () => { + const viewClass = registerTestElementClass({ + animationsEnabled: { + apply(_element, value, _attributeName, context) { + context.setAnimationsEnabled(Boolean(value)); + }, + reset(_element, _attributeName, context) { + context.setAnimationsEnabled(true); + }, + }, + testWidth: { + layoutDependent: true, + apply(element, value) { + (element as unknown as FakeElement).layoutWidth = Number(value); + }, + reset(element) { + (element as unknown as FakeElement).layoutWidth = 0; + }, + }, + }); + const id = createRootTestElement(viewClass); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.layoutWidth = 100; + element.layoutHeight = 100; + tree.setAttributeOnElement(id, 'animationsEnabled', false); + tree.flush(); + element.layoutReadCount = 0; + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 902); + tree.setAttributeOnElement(id, 'testWidth', 200); + tree.endAnimation(); + + expect(element.layoutReadCount).toBe(0); + expect(element.layoutWidth).toBe(200); + expect(element.style.scale).toBeUndefined(); + expect(getAnimationFrameRequestCount()).toBe(0); + }); + + it('removes layout projection when its transaction is cancelled', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.layoutWidth = 100; + element.layoutHeight = 100; + reflectPixelStyleSizeInLayout(element); + tree.setAttributeOnElement(id, 'backgroundColor', 'red'); + tree.flush(); + const paintElement = getViewPaintElement(id); + let completion: boolean | undefined; + + tree.beginAnimation( + { + curve: AnimationCurve.Linear, + duration: 1, + completion: cancelled => { + completion = cancelled; + }, + }, + 903, + ); + tree.setAttributeOnElement(id, 'width', 200); + tree.endAnimation(); + expect(paintElement.style.scale).toBe('0.5 1'); + + tree.cancelAnimation(903); + + expect(element.layoutWidth).toBe(200); + expect(paintElement.style.scale).toBeUndefined(); + expect(completion).toBeTrue(); + }); + + it('rebases interrupted layout animations from current or logical frames', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.layoutWidth = 100; + element.layoutHeight = 100; + reflectPixelStyleSizeInLayout(element); + tree.setAttributeOnElement(id, 'backgroundColor', 'red'); + tree.flush(); + const paintElement = getViewPaintElement(id); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 904); + tree.setAttributeOnElement(id, 'width', 200); + tree.endAnimation(); + flushTextAnimationFrame(500); + expect(paintElement.style.scale).toBe('0.75 1'); + + tree.beginAnimation({ beginFromCurrentState: true, curve: AnimationCurve.Linear, duration: 1 }, 905); + tree.setAttributeOnElement(id, 'width', 300); + tree.endAnimation(); + expect(paintElement.style.scale).toBe('0.5 1'); + + tree.beginAnimation({ beginFromCurrentState: false, curve: AnimationCurve.Linear, duration: 1 }, 906); + tree.setAttributeOnElement(id, 'width', 400); + tree.endAnimation(); + expect(paintElement.style.scale).toBe('0.75 1'); + }); + + it('keeps an interrupted layout animation stable after an external ancestor scrolls', () => { + const viewClass = registerTestElementClass({ + testTop: { + layoutDependent: true, + apply(element, value) { + (element as unknown as FakeElement).layoutTop = Number(value); + }, + reset(element) { + (element as unknown as FakeElement).layoutTop = 0; + }, + }, + }); + const scrollContainer = makeFakeElement('div'); + const id = createTestElement(viewClass); + tree.makeElementRoot(id, scrollContainer as unknown as HTMLElement); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.layoutTop = 100; + element.layoutWidth = 100; + element.layoutHeight = 100; + + tree.beginAnimation({ beginFromCurrentState: true, curve: AnimationCurve.Linear, duration: 1 }, 920); + tree.setAttributeOnElement(id, 'testTop', 200); + tree.endAnimation(); + flushTextAnimationFrame(500); + + scrollContainer.scrollTop = 40; + const visualYBeforeInterruption = + element.layoutTop - scrollContainer.scrollTop + Number.parseFloat(element.style.translate.split(' ')[1]); + + tree.beginAnimation({ beginFromCurrentState: true, curve: AnimationCurve.Linear, duration: 1 }, 921); + tree.setAttributeOnElement(id, 'testTop', 300); + tree.endAnimation(); + + const visualYAfterInterruption = + element.layoutTop - scrollContainer.scrollTop + Number.parseFloat(element.style.translate.split(' ')[1]); + expect(visualYBeforeInterruption).toBe(110); + expect(visualYAfterInterruption).toBe(visualYBeforeInterruption); + }); + + it('keeps an interrupted layout animation stable after a Valdi scroll ancestor scrolls', () => { + const viewClass = registerTestElementClass({ + testTop: { + layoutDependent: true, + apply(element, value) { + (element as unknown as FakeElement).layoutTop = Number(value); + }, + reset(element) { + (element as unknown as FakeElement).layoutTop = 0; + }, + }, + }); + const root = createRootTestElement('view'); + const scroller = createTestElement('scroll'); + const id = createTestElement(viewClass); + tree.moveElement(scroller, root, 0); + tree.moveElement(id, scroller, 0); + const rootElement = getNode(root).htmlElement as unknown as FakeElement; + const scrollElement = getNode(scroller).htmlElement as unknown as FakeElement; + const element = getNode(id).htmlElement as unknown as FakeElement; + rootElement.layoutWidth = 500; + rootElement.layoutHeight = 500; + scrollElement.layoutWidth = 300; + scrollElement.layoutHeight = 300; + scrollElement.offsetParent = rootElement; + element.layoutTop = 100; + element.layoutWidth = 100; + element.layoutHeight = 100; + element.offsetParent = scrollElement; + + tree.beginAnimation({ beginFromCurrentState: true, curve: AnimationCurve.Linear, duration: 1 }, 922); + tree.setAttributeOnElement(id, 'testTop', 200); + tree.endAnimation(); + flushTextAnimationFrame(500); + + scrollElement.scrollTop = 40; + const visualYBeforeInterruption = + element.layoutTop - scrollElement.scrollTop + Number.parseFloat(element.style.translate.split(' ')[1]); + + tree.beginAnimation({ beginFromCurrentState: true, curve: AnimationCurve.Linear, duration: 1 }, 923); + tree.setAttributeOnElement(id, 'testTop', 300); + tree.endAnimation(); + + const visualYAfterInterruption = + element.layoutTop - scrollElement.scrollTop + Number.parseFloat(element.style.translate.split(' ')[1]); + expect(visualYBeforeInterruption).toBe(110); + expect(visualYAfterInterruption).toBe(visualYBeforeInterruption); + }); + + it('samples preset and custom duration curves with shared frame timing', () => { + const root = createRootTestElement('view'); + const curves: Array<{ options: AnimationOptions; id: number }> = [ + { id: createTestElement('view'), options: { curve: AnimationCurve.Linear, duration: 1 } }, + { id: createTestElement('view'), options: { curve: AnimationCurve.EaseIn, duration: 1 } }, + { id: createTestElement('view'), options: { curve: AnimationCurve.EaseOut, duration: 1 } }, + { id: createTestElement('view'), options: { curve: AnimationCurve.EaseInOut, duration: 1 } }, + { id: createTestElement('view'), options: { controlPoints: [0.2, 0.8, 0.2, 1], duration: 1 } }, + ]; + for (const curve of curves) { + tree.moveElement(curve.id, root, 0); + tree.setAttributeOnElement(curve.id, 'opacity', 0); + tree.flush(); + tree.beginAnimation(curve.options, 100 + curve.id); + tree.setAttributeOnElement(curve.id, 'opacity', 1); + tree.endAnimation(); + } + + flushTextAnimationFrame(500); + const values = curves.map(curve => Number(getNode(curve.id).htmlElement.style.opacity)); + expect(values[0]).toBeCloseTo(0.5, 4); + expect(values[1]).toBeLessThan(values[0]); + expect(values[2]).toBeGreaterThan(values[0]); + expect(values[3]).toBeCloseTo(0.5, 4); + expect(values[4]).toBeGreaterThan(values[2]); + }); + + it('finishes underdamped, critically damped, and overdamped springs', () => { + const root = createRootTestElement('view'); + const dampingValues = [10, 20, 30]; + const completions: boolean[] = []; + for (let index = 0; index < dampingValues.length; index++) { + const id = createTestElement('view'); + tree.moveElement(id, root, 0); + tree.setAttributeOnElement(id, 'opacity', 0); + tree.flush(); + tree.beginAnimation( + { + stiffness: 100, + damping: dampingValues[index], + completion: cancelled => completions.push(cancelled), + }, + 200 + index, + ); + tree.setAttributeOnElement(id, 'opacity', 1); + tree.endAnimation(); + } + + for (let time = 16; time <= 10000 && completions.length < dampingValues.length; time += 16) { + flushTextAnimationFrame(time); + } + expect(completions).toEqual([false, false, false]); + }); + + it('animates palette-ready colors and preserves the exact final color', () => { + const id = createRootTestElement('view'); + tree.setAttributeOnElement(id, 'backgroundColor', '#000000'); + tree.flush(); + const paintElement = getViewPaintElement(id); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 300); + tree.setAttributeOnElement(id, 'backgroundColor', '#ffffff'); + tree.endAnimation(); + flushTextAnimationFrame(500); + expect(paintElement.style.backgroundColor).toContain('color-mix(in srgb'); + flushTextAnimationFrame(1000); + expect(paintElement.style.backgroundColor).toBe('#ffffff'); + }); + + it('animates to the lower-priority style value when a direct value is removed', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + tree.setStyleAttributeOnElement(id, 'style', new Style({ opacity: 0.8 })); + tree.setAttributeOnElement(id, 'opacity', 0.2); + tree.flush(); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 301); + tree.setAttributeOnElement(id, 'opacity', undefined); + tree.endAnimation(); + flushTextAnimationFrame(500); + expect(Number(element.style.opacity)).toBeCloseTo(0.5, 5); + flushTextAnimationFrame(1000); + expect(element.style.opacity).toBe('0.8'); + }); + + it('begins from the current opacity when interrupting and requested', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + tree.setAttributeOnElement(id, 'opacity', 0); + tree.flush(); + + tree.beginAnimation({ beginFromCurrentState: true, curve: AnimationCurve.Linear, duration: 1 }, 2); + tree.setAttributeOnElement(id, 'opacity', 1); + tree.endAnimation(); + flushTextAnimationFrame(400); + expect(Number(element.style.opacity)).toBeCloseTo(0.4, 5); + + tree.beginAnimation({ beginFromCurrentState: true, curve: AnimationCurve.Linear, duration: 1 }, 3); + tree.setAttributeOnElement(id, 'opacity', 0); + tree.endAnimation(); + expect(Number(element.style.opacity)).toBeCloseTo(0.4, 5); + flushTextAnimationFrame(900); + expect(Number(element.style.opacity)).toBeCloseTo(0.2, 5); + }); + + it('finishes an explicitly cancelled transaction at its exact target', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + let completion: boolean | undefined; + tree.setAttributeOnElement(id, 'opacity', 0); + tree.flush(); + tree.beginAnimation({ duration: 1, completion: cancelled => (completion = cancelled) }, 4); + tree.setAttributeOnElement(id, 'opacity', 1); + tree.endAnimation(); + flushTextAnimationFrame(250); + + tree.cancelAnimation(4); + expect(element.style.opacity).toBe('1'); + expect(completion).toBeTrue(); + }); + + it('animates the top newly created node from its enter appearance attributes', () => { + const root = createRootTestElement('view'); + const token = 401; + + tree.beginAnimation( + { + curve: AnimationCurve.Linear, + duration: 1, + appearanceBehavior: { + enterAttributes: { + originX: 0, + originY: 1, + translationX: -1, + translationY: 0.5, + scaleX: 0.5, + scaleY: 0.25, + opacity: 0, + }, + }, + }, + token, + ); + const parent = createTestElement('view'); + const child = createTestElement('view'); + tree.moveElement(parent, root, 0); + tree.moveElement(child, parent, 0); + tree.endAnimation(); + + const parentElement = getNode(parent).htmlElement; + const childElement = getNode(child).htmlElement; + expect(parentElement.style.opacity).toBe('0'); + expect(parentElement.style.transformOrigin).toBe('0% 100%'); + expect(parentElement.style.transform).toContain('translate(-100%, 50%)'); + expect(parentElement.style.transform).toContain('scale(0.5, 0.25)'); + expect(childElement.style.opacity).toBeUndefined(); + expect(childElement.style.transform).toBeUndefined(); + + flushTextAnimationFrame(500); + expect(Number(parentElement.style.opacity)).toBeCloseTo(0.5, 5); + expect(parentElement.style.transform).toContain('translate(-50%, 25%)'); + expect(parentElement.style.transform).toContain('scale(0.75, 0.625)'); + + flushTextAnimationFrame(1000); + tree.flush(); + expect(parentElement.style.opacity).toBe(''); + expect(parentElement.style.transform).toBe(''); + expect(parentElement.style.transformOrigin).toBe(''); + }); + + it('keeps authored attributes above enter appearance attributes', () => { + const root = createRootTestElement('view'); + tree.beginAnimation( + { + curve: AnimationCurve.Linear, + duration: 1, + appearanceBehavior: { + enterAttributes: { opacity: 0, translationX: -1, scaleX: 0.5 }, + }, + }, + 402, + ); + const child = createTestElement('view'); + tree.setAttributeOnElement(child, 'opacity', 0.7); + tree.setAttributeOnElement(child, 'translationX', 20); + tree.moveElement(child, root, 0); + tree.endAnimation(); + + const element = getNode(child).htmlElement; + expect(element.style.opacity).toBe('0.7'); + expect(element.style.transform).toContain('translate(20px, 0px)'); + flushTextAnimationFrame(1000); + expect(element.style.opacity).toBe('0.7'); + expect(element.style.transform).toContain('translate(20px, 0px)'); + }); + + it('freezes and retains an exiting subtree until its appearance animation completes', () => { + const root = createRootTestElement('view'); + const child = createTestElement('view'); + const grandchild = createTestElement('view'); + tree.moveElement(child, root, 0); + tree.moveElement(grandchild, child, 0); + let destroyCount = 0; + tree.setAttributeOnElement(child, 'onViewDestroy', () => destroyCount++); + tree.setAttributeOnElement(child, 'position', 'relative'); + tree.setAttributeOnElement(child, 'left', 2); + tree.setAttributeOnElement(child, 'top', 3); + tree.setAttributeOnElement(child, 'width', 20); + tree.setAttributeOnElement(child, 'height', 10); + tree.setAttributeOnElement(child, 'marginLeft', 20); + tree.setAttributeOnElement(child, 'marginTop', 14); + tree.flush(); + + const element = getNode(child).htmlElement as unknown as FakeElement; + const grandchildElement = getNode(grandchild).htmlElement; + element.layoutLeft = 12; + element.layoutTop = 18; + element.layoutWidth = 80; + element.layoutHeight = 40; + + tree.beginAnimation( + { + curve: AnimationCurve.Linear, + duration: 1, + appearanceBehavior: { + exitAttributes: { + originX: 1, + originY: 0, + translationX: 1, + translationY: -0.5, + scaleX: 0.5, + scaleY: 0.25, + opacity: 0, + }, + }, + }, + 403, + ); + tree.destroyElement(child); + tree.endAnimation(); + + expect(tree.getNode(child)).toBeDefined(); + expect(tree.getNode(grandchild)).toBeDefined(); + expect(element.parentElement).not.toBeNull(); + expect(element.style.position).toBe('absolute'); + expect(element.style.left).toBe('12px'); + expect(element.style.top).toBe('18px'); + expect(element.style.width).toBe('80px'); + expect(element.style.height).toBe('40px'); + expect(element.style.marginLeft).toBe('0px'); + expect(element.style.marginTop).toBe('0px'); + expect(element.layoutReadCount).toBe(4); + expect(grandchildElement.style.opacity).toBeUndefined(); + expect(destroyCount).toBe(0); + + flushTextAnimationFrame(500); + expect(Number(element.style.opacity)).toBeCloseTo(0.5, 5); + expect(element.style.transformOrigin).toBe('100% 0%'); + expect(element.style.transform).toContain('translate(50%, -25%)'); + expect(element.style.transform).toContain('scale(0.75, 0.625)'); + + flushTextAnimationFrame(1000); + expect(tree.getNode(child)).toBeUndefined(); + expect(tree.getNode(grandchild)).toBeUndefined(); + expect(element.parentElement).toBeNull(); + tree.flush(); + expect(destroyCount).toBe(1); + }); + + it('inserts live siblings independently of a retained exiting element', () => { + const root = createRootTestElement('view'); + const first = createTestElement('view'); + const second = createTestElement('view'); + tree.moveElement(first, root, 0); + tree.moveElement(second, root, 1); + tree.flush(); + const rootElement = getNode(root).htmlElement as unknown as FakeElement; + const firstElement = getNode(first).htmlElement as unknown as FakeElement; + const secondElement = getNode(second).htmlElement as unknown as FakeElement; + + tree.beginAnimation( + { + curve: AnimationCurve.Linear, + duration: 1, + appearanceBehavior: { exitAttributes: { opacity: 0 } }, + }, + 404, + ); + tree.destroyElement(first); + const replacement = createTestElement('view'); + const replacementElement = getNode(replacement).htmlElement as unknown as FakeElement; + tree.moveElement(replacement, root, 0); + tree.endAnimation(); + + expect(rootElement.childNodes.item(0)).toBe(firstElement); + expect(rootElement.childNodes.item(1)).toBe(replacementElement); + expect(rootElement.childNodes.item(2)).toBe(secondElement); + + flushTextAnimationFrame(1000); + expect(rootElement.childNodes.item(0)).toBe(replacementElement); + expect(rootElement.childNodes.item(1)).toBe(secondElement); + expect(rootElement.childNodes.item(2)).toBeNull(); + }); + + it('destroys a node created and removed in the same appearance transaction immediately', () => { + const root = createRootTestElement('view'); + const initialFrameRequests = getAnimationFrameRequestCount(); + let completion: boolean | undefined; + tree.beginAnimation( + { + curve: AnimationCurve.Linear, + duration: 1, + appearanceBehavior: { + enterAttributes: { opacity: 0 }, + exitAttributes: { opacity: 0 }, + }, + completion: cancelled => (completion = cancelled), + }, + 405, + ); + const child = createTestElement('view'); + tree.moveElement(child, root, 0); + tree.destroyElement(child); + tree.endAnimation(); + + expect(tree.getNode(child)).toBeUndefined(); + expect(completion).toBeFalse(); + expect(getAnimationFrameRequestCount()).toBe(initialFrameRequests); + }); + + it('destroys a cancelled exit at its final target and reports cancellation once', () => { + const root = createRootTestElement('view'); + const child = createTestElement('view'); + tree.moveElement(child, root, 0); + tree.flush(); + let completionCount = 0; + let wasCancelled: boolean | undefined; + + tree.beginAnimation( + { + duration: 1, + appearanceBehavior: { exitAttributes: { opacity: 0 } }, + completion: cancelled => { + completionCount++; + wasCancelled = cancelled; + }, + }, + 406, + ); + tree.destroyElement(child); + tree.endAnimation(); + flushTextAnimationFrame(250); + expect(tree.getNode(child)).toBeDefined(); + + tree.cancelAnimation(406); + tree.cancelAnimation(406); + expect(tree.getNode(child)).toBeUndefined(); + expect(completionCount).toBe(1); + expect(wasCancelled).toBeTrue(); + }); + + it('begins an interrupted exit from the current enter presentation when requested', () => { + const root = createRootTestElement('view'); + tree.beginAnimation( + { + curve: AnimationCurve.Linear, + duration: 1, + appearanceBehavior: { enterAttributes: { opacity: 0 } }, + }, + 411, + ); + const child = createTestElement('view'); + tree.moveElement(child, root, 0); + tree.endAnimation(); + const element = getNode(child).htmlElement; + flushTextAnimationFrame(400); + expect(Number(element.style.opacity)).toBeCloseTo(0.4, 5); + + tree.beginAnimation( + { + beginFromCurrentState: true, + curve: AnimationCurve.Linear, + duration: 1, + appearanceBehavior: { exitAttributes: { opacity: 0 } }, + }, + 412, + ); + tree.destroyElement(child); + tree.endAnimation(); + expect(Number(element.style.opacity)).toBeCloseTo(0.4, 5); + flushTextAnimationFrame(900); + expect(Number(element.style.opacity)).toBeCloseTo(0.2, 5); + }); + + it('begins an interrupted exit from the enter destination by default', () => { + const root = createRootTestElement('view'); + tree.beginAnimation( + { + curve: AnimationCurve.Linear, + duration: 1, + appearanceBehavior: { enterAttributes: { opacity: 0 } }, + }, + 413, + ); + const child = createTestElement('view'); + tree.moveElement(child, root, 0); + tree.endAnimation(); + const element = getNode(child).htmlElement; + flushTextAnimationFrame(400); + expect(Number(element.style.opacity)).toBeCloseTo(0.4, 5); + + tree.beginAnimation( + { + curve: AnimationCurve.Linear, + duration: 1, + appearanceBehavior: { exitAttributes: { opacity: 0 } }, + }, + 414, + ); + tree.destroyElement(child); + tree.endAnimation(); + expect(element.style.opacity).toBe('1'); + }); + + it('keeps authored values above exit appearance attributes until removal', () => { + const root = createRootTestElement('view'); + const child = createTestElement('view'); + tree.moveElement(child, root, 0); + tree.setAttributeOnElement(child, 'opacity', 0.7); + tree.setAttributeOnElement(child, 'translationX', 20); + tree.flush(); + const element = getNode(child).htmlElement; + + tree.beginAnimation( + { + curve: AnimationCurve.Linear, + duration: 1, + appearanceBehavior: { exitAttributes: { opacity: 0, translationX: 1 } }, + }, + 415, + ); + tree.destroyElement(child); + tree.endAnimation(); + flushTextAnimationFrame(500); + + expect(element.style.opacity).toBe('0.7'); + expect(element.style.transform).toContain('translate(20px, 0px)'); + expect(tree.getNode(child)).toBeDefined(); + flushTextAnimationFrame(1000); + expect(tree.getNode(child)).toBeUndefined(); + }); + + it('bypasses appearance work in disabled subtrees', () => { + const root = createRootTestElement('view'); + const parent = createTestElement('view'); + tree.moveElement(parent, root, 0); + tree.setAttributeOnElement(parent, 'animationsEnabled', false); + tree.flush(); + const initialFrameRequests = getAnimationFrameRequestCount(); + + tree.beginAnimation( + { + duration: 1, + appearanceBehavior: { + enterAttributes: { opacity: 0 }, + exitAttributes: { opacity: 0 }, + }, + }, + 407, + ); + const child = createTestElement('view'); + tree.moveElement(child, parent, 0); + tree.endAnimation(); + expect(getNode(child).htmlElement.style.opacity).toBeUndefined(); + expect(getAnimationFrameRequestCount()).toBe(initialFrameRequests); + + tree.beginAnimation( + { + duration: 1, + appearanceBehavior: { exitAttributes: { opacity: 0 } }, + }, + 408, + ); + tree.destroyElement(child); + tree.endAnimation(); + expect(tree.getNode(child)).toBeUndefined(); + expect(getAnimationFrameRequestCount()).toBe(initialFrameRequests); + }); + + it('bypasses exit appearance when animations are disabled on the node in the same update', () => { + const root = createRootTestElement('view'); + const child = createTestElement('view'); + tree.moveElement(child, root, 0); + tree.flush(); + const initialFrameRequests = getAnimationFrameRequestCount(); + + tree.beginAnimation( + { + duration: 1, + appearanceBehavior: { exitAttributes: { opacity: 0 } }, + }, + 416, + ); + tree.setAttributeOnElement(child, 'animationsEnabled', false); + tree.destroyElement(child); + tree.endAnimation(); + + expect(tree.getNode(child)).toBeUndefined(); + expect(getAnimationFrameRequestCount()).toBe(initialFrameRequests); + }); + + it('bypasses enter appearance when animations are disabled on the node in the same update', () => { + const root = createRootTestElement('view'); + const initialFrameRequests = getAnimationFrameRequestCount(); + + tree.beginAnimation( + { + duration: 1, + appearanceBehavior: { enterAttributes: { opacity: 0, scaleX: 0.5 } }, + }, + 417, + ); + const child = createTestElement('view'); + tree.setAttributeOnElement(child, 'animationsEnabled', false); + tree.moveElement(child, root, 0); + tree.endAnimation(); + + const element = getNode(child).htmlElement; + expect(element.style.opacity).toBe(''); + expect(element.style.transform).toBe(''); + expect(getAnimationFrameRequestCount()).toBe(initialFrameRequests); + }); + + it('completes a pending exit when animations are disabled on its ancestor', () => { + const root = createRootTestElement('view'); + const parent = createTestElement('view'); + const child = createTestElement('view'); + tree.moveElement(parent, root, 0); + tree.moveElement(child, parent, 0); + tree.flush(); + const element = getNode(child).htmlElement; + + tree.beginAnimation( + { + curve: AnimationCurve.Linear, + duration: 1, + appearanceBehavior: { exitAttributes: { opacity: 0 } }, + }, + 409, + ); + tree.destroyElement(child); + tree.endAnimation(); + flushTextAnimationFrame(250); + expect(Number(element.style.opacity)).toBeCloseTo(0.75, 5); + + tree.setAttributeOnElement(parent, 'animationsEnabled', false); + tree.flush(); + expect(element.style.opacity).toBe('0'); + expect(tree.getNode(child)).toBeUndefined(); + }); + + it('cancels appearance lifecycles without final writes when the tree is destroyed', () => { + const root = createRootTestElement('view'); + const child = createTestElement('view'); + tree.moveElement(child, root, 0); + tree.flush(); + const element = getNode(child).htmlElement as unknown as FakeElement; + let completion: boolean | undefined; + + tree.beginAnimation( + { + duration: 1, + appearanceBehavior: { exitAttributes: { opacity: 0 } }, + completion: cancelled => (completion = cancelled), + }, + 410, + ); + tree.destroyElement(child); + tree.endAnimation(); + flushTextAnimationFrame(250); + const opacityBeforeDestroy = element.style.opacity; + + tree.destroy(); + expect(element.style.opacity).toBe(opacityBeforeDestroy); + expect(element.parentElement).toBeNull(); + expect(completion).toBeTrue(); + }); + + it('does not read layout or schedule animation frames for ordinary destruction', () => { + const root = createRootTestElement('view'); + const child = createTestElement('view'); + tree.moveElement(child, root, 0); + tree.flush(); + const element = getNode(child).htmlElement as unknown as FakeElement; + const initialFrameRequests = getAnimationFrameRequestCount(); + + tree.destroyElement(child); + + expect(element.layoutReadCount).toBe(0); + expect(getAnimationFrameRequestCount()).toBe(initialFrameRequests); + }); + + it('does not schedule animation work for appearance origins without transforms', () => { + const root = createRootTestElement('view'); + const initialFrameRequests = getAnimationFrameRequestCount(); + let completion: boolean | undefined; + + tree.beginAnimation( + { + duration: 1, + appearanceBehavior: { enterAttributes: { originX: 0, originY: 1 } }, + completion: cancelled => (completion = cancelled), + }, + 419, + ); + const child = createTestElement('view'); + tree.moveElement(child, root, 0); + tree.endAnimation(); + + expect(completion).toBeFalse(); + expect(getAnimationFrameRequestCount()).toBe(initialFrameRequests); + }); + + it('animates component transforms atomically', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + tree.setAttributeOnElement(id, 'translationX', 0); + tree.setAttributeOnElement(id, 'translationY', 0); + tree.setAttributeOnElement(id, 'scaleX', 1); + tree.setAttributeOnElement(id, 'scaleY', 1); + tree.setAttributeOnElement(id, 'rotation', 0); + tree.flush(); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 5); + tree.setAttributeOnElement(id, 'translationX', 100); + tree.setAttributeOnElement(id, 'translationY', 40); + tree.setAttributeOnElement(id, 'scaleX', 2); + tree.setAttributeOnElement(id, 'scaleY', 0.5); + tree.setAttributeOnElement(id, 'rotation', 1); + tree.endAnimation(); + flushTextAnimationFrame(500); + + expect(element.style.transform).toContain('translate(50px, 20px)'); + expect(element.style.transform).toContain('scale(1.5, 0.75)'); + expect(element.style.transform).toContain('rotate(0.5rad)'); + }); + + it('keeps transform origin changes immediate inside animation transactions', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + tree.setAttributeOnElement(id, 'transformOrigin', '0% 0%'); + tree.flush(); + const initialFrameRequests = getAnimationFrameRequestCount(); + let completion: boolean | undefined; + + tree.beginAnimation( + { + curve: AnimationCurve.Linear, + duration: 1, + completion: cancelled => (completion = cancelled), + }, + 418, + ); + tree.setAttributeOnElement(id, 'transformOrigin', '100% 100%'); + tree.endAnimation(); + + expect(element.style.transformOrigin).toBe('100% 100%'); + expect(completion).toBeFalse(); + expect(getAnimationFrameRequestCount()).toBe(initialFrameRequests); + }); + + it('animates a component transform from its unset defaults', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 51); + tree.setAttributeOnElement(id, 'translationX', 100); + tree.setAttributeOnElement(id, 'scaleX', 2); + tree.endAnimation(); + flushTextAnimationFrame(500); + + expect(element.style.transform).toContain('translate(50px, 0px)'); + expect(element.style.transform).toContain('scale(1.5, 1)'); + }); + + it('animates a component transform to its unset defaults before resetting it', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + tree.setAttributeOnElement(id, 'translationX', 100); + tree.setAttributeOnElement(id, 'scaleX', 2); + tree.flush(); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 52); + tree.setAttributeOnElement(id, 'translationX', undefined); + tree.setAttributeOnElement(id, 'scaleX', undefined); + tree.endAnimation(); + flushTextAnimationFrame(500); + + expect(element.style.transform).toContain('translate(50px, 0px)'); + expect(element.style.transform).toContain('scale(1.5, 1)'); + flushTextAnimationFrame(1000); + expect(element.style.transform).toBe(''); + }); + + it('applies an unsupported mixed-unit translation immediately', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + let completion: boolean | undefined; + tree.setAttributeOnElement(id, 'translationX', '10%'); + tree.flush(); + + tree.beginAnimation({ duration: 1, completion: cancelled => (completion = cancelled) }, 6); + tree.setAttributeOnElement(id, 'translationX', '20px'); + tree.endAnimation(); + + expect(element.style.transform).toContain('translate(20px, 0px)'); + expect(completion).toBeFalse(); + }); + + it('accepts the translation dimensions supported by ValueConverter', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + + tree.setAttributeOnElement(id, 'translationX', '12'); + tree.setAttributeOnElement(id, 'translationY', '25%'); + tree.flush(); + expect(element.style.transform).toContain('translate(12px, 25%)'); + + tree.setAttributeOnElement(id, 'translationX', '7pt'); + tree.setAttributeOnElement(id, 'translationY', '9px'); + tree.flush(); + expect(element.style.transform).toContain('translate(7px, 9px)'); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 61); + tree.setAttributeOnElement(id, 'translationX', '17px'); + tree.endAnimation(); + flushTextAnimationFrame(500); + expect(element.style.transform).toContain('translate(12px, 9px)'); + }); + + it('rejects translation units that ValueConverter does not support', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + const errorSpy = spyOn(console, 'error'); + tree.setAttributeOnElement(id, 'translationX', 5); + tree.flush(); + + tree.setAttributeOnElement(id, 'translationX', '1em'); + tree.flush(); + + expect(element.style.transform).toContain('translate(5px, 0px)'); + expect(errorSpy).toHaveBeenCalledWith(jasmine.stringContaining('unitless, px, pt, or percent')); + }); + + it('resets an unset animated opacity only after applying intermediate frames', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + tree.setAttributeOnElement(id, 'opacity', 0.25); + tree.flush(); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 7); + tree.setAttributeOnElement(id, 'opacity', undefined); + tree.endAnimation(); + flushTextAnimationFrame(500); + expect(Number(element.style.opacity)).toBeCloseTo(0.625, 5); + flushTextAnimationFrame(1000); + expect(element.style.opacity).toBe(''); + }); + + it('animates border radius shorthand values and applies the exact final value', () => { + const id = createRootTestElement('view'); + tree.setAttributeOnElement(id, 'borderRadius', 4); + tree.flush(); + const paintElement = getViewPaintElement(id); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 71); + tree.setAttributeOnElement(id, 'borderRadius', '20 30 40 50'); + tree.endAnimation(); + flushTextAnimationFrame(500); + + expect(paintElement.style.borderRadius).toBe('12px 17px 22px 27px'); + flushTextAnimationFrame(1000); + expect(paintElement.style.borderRadius).toBe('20px 30px 40px 50px'); + }); + + it('animates mixed point and percent border radii using the last observed size', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 200; + element.rectHeight = 80; + tree.setAttributeOnElement(id, 'borderRadius', 8); + tree.flush(); + const paintElement = getViewPaintElement(id); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 72); + tree.setAttributeOnElement(id, 'borderRadius', '50%'); + tree.endAnimation(); + const readsAfterCommit = element.rectReadCount; + flushTextAnimationFrame(500); + + expect(paintElement.style.borderRadius).toBe('24px'); + expect(element.rectReadCount).toBe(readsAfterCommit); + + element.rectHeight = 40; + dispatchWindowResize(); + expect(paintElement.style.borderRadius).toBe('14px'); + + flushTextAnimationFrame(1000); + expect(paintElement.style.borderRadius).toBe('20px'); + }); + + it('begins an interrupted border radius animation from its current presentation', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 100; + element.rectHeight = 100; + tree.setAttributeOnElement(id, 'borderRadius', 0); + tree.flush(); + const paintElement = getViewPaintElement(id); + + tree.beginAnimation({ beginFromCurrentState: true, curve: AnimationCurve.Linear, duration: 1 }, 73); + tree.setAttributeOnElement(id, 'borderRadius', '100%'); + tree.endAnimation(); + flushTextAnimationFrame(400); + expect(paintElement.style.borderRadius).toBe('40px'); + + tree.beginAnimation({ beginFromCurrentState: true, curve: AnimationCurve.Linear, duration: 1 }, 74); + tree.setAttributeOnElement(id, 'borderRadius', 0); + tree.endAnimation(); + expect(paintElement.style.borderRadius).toBe('40px'); + flushTextAnimationFrame(900); + expect(paintElement.style.borderRadius).toBe('20px'); + }); + + it('animates border radius to its unset default before resetting it', () => { + const id = createRootTestElement('view'); + tree.setAttributeOnElement(id, 'borderRadius', 20); + tree.flush(); + const paintElement = getViewPaintElement(id); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 75); + tree.setAttributeOnElement(id, 'borderRadius', undefined); + tree.endAnimation(); + flushTextAnimationFrame(500); + expect(paintElement.style.borderRadius).toBe('10px'); + flushTextAnimationFrame(1000); + expect(paintElement.style.borderRadius).toBe(''); + }); + + it('animates blur border radius and clip path through the shared applier', () => { + const id = createRootTestElement('blur'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 80; + element.rectHeight = 40; + tree.setAttributeOnElement(id, 'borderRadius', 0); + tree.flush(); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 76); + tree.setAttributeOnElement(id, 'borderRadius', '50%'); + tree.endAnimation(); + flushTextAnimationFrame(500); + + expect(element.style.borderRadius).toBe('10px'); + expect(element.style.clipPath).toBe('inset(0 round 10px)'); + flushTextAnimationFrame(1000); + expect(element.style.borderRadius).toBe('20px'); + expect(element.style.clipPath).toBe('inset(0 round 20px)'); + }); + + it('applies unsupported border radius expressions immediately', () => { + const id = createRootTestElement('view'); + let completion: boolean | undefined; + tree.setAttributeOnElement(id, 'borderRadius', 8); + tree.flush(); + const paintElement = getViewPaintElement(id); + + tree.beginAnimation({ duration: 1, completion: cancelled => (completion = cancelled) }, 77); + tree.setAttributeOnElement(id, 'borderRadius', 'calc(10px + 2%)'); + tree.endAnimation(); + + expect(paintElement.style.borderRadius).toBe('calc(10px + 2%)'); + expect(completion).toBeFalse(); + }); + + it('finishes active descendant properties when animations are disabled on an ancestor', () => { + const parent = createRootTestElement('view'); + const child = createTestElement('view'); + tree.moveElement(child, parent, 0); + const childElement = getNode(child).htmlElement; + tree.setAttributeOnElement(child, 'opacity', 0); + tree.flush(); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 8); + tree.setAttributeOnElement(child, 'opacity', 1); + tree.endAnimation(); + flushTextAnimationFrame(250); + expect(Number(childElement.style.opacity)).toBeCloseTo(0.25, 5); + + tree.setAttributeOnElement(parent, 'animationsEnabled', false); + tree.flush(); + expect(childElement.style.opacity).toBe('1'); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 82); + tree.setAttributeOnElement(child, 'opacity', 0); + tree.endAnimation(); + expect(childElement.style.opacity).toBe('0'); + + tree.setAttributeOnElement(parent, 'animationsEnabled', true); + tree.flush(); + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 83); + tree.setAttributeOnElement(child, 'opacity', 1); + tree.endAnimation(); + expect(childElement.style.opacity).toBe('0'); + }); + + it('finishes or bypasses animations when animations are disabled in the same flush', () => { + const parent = createRootTestElement('view'); + const animationFirst = createTestElement('view'); + const disableFirst = createTestElement('view'); + tree.moveElement(animationFirst, parent, 0); + tree.moveElement(disableFirst, parent, 1); + tree.setAttributeOnElement(animationFirst, 'opacity', 0); + tree.setAttributeOnElement(disableFirst, 'opacity', 0); + tree.flush(); + + tree.beginAnimation({ curve: AnimationCurve.Linear, duration: 1 }, 81); + tree.setAttributeOnElement(animationFirst, 'animationsEnabled', false); + tree.setAttributeOnElement(animationFirst, 'opacity', 1); + tree.setAttributeOnElement(disableFirst, 'opacity', 1); + tree.setAttributeOnElement(disableFirst, 'animationsEnabled', false); + tree.endAnimation(); + + expect(getNode(animationFirst).htmlElement.style.opacity).toBe('1'); + expect(getNode(disableFirst).htmlElement.style.opacity).toBe('1'); + }); + + it('resolves percent border radii against the shorter element side', () => { + tree.setPostLayoutScheduler(callback => callback()); + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 84; + element.rectHeight = 48; + + tree.setAttributeOnElement(id, 'borderRadius', '100%'); + tree.flush(); + const paintElement = getViewPaintElement(id); + expect(paintElement.style.borderRadius).toBe('48px'); + + tree.setAttributeOnElement(id, 'borderRadius', '8 50% 100% 0'); + tree.flush(); + expect(paintElement.style.borderRadius).toBe('8px 24px 48px 0px'); + }); + + it('does not read geometry while layout-dependent attributes are being flushed', () => { + let runLayoutPass: (() => void) | undefined; + tree.setPostLayoutScheduler(callback => { + runLayoutPass = callback; + }); + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement as unknown as FakeElement; + const parent = element.parentElement!; + parent.rectWidth = 320; + parent.rectHeight = 200; + + tree.setAttributeOnElement(id, 'borderRadius', '100%'); + tree.setAttributeOnElement(id, 'onMeasure', () => [180, 76]); + tree.flush(); + + expect(element.rectReadCount).toBe(0); + expect(parent.rectReadCount).toBe(0); + expect(runLayoutPass).toBeDefined(); + + runLayoutPass!(); + expect(element.rectReadCount).toBe(2); + expect(parent.rectReadCount).toBe(1); + }); + + it('runs layout observers for changed layout attributes and structural moves only', () => { + const observerViewClass = registerTestElementClass({ + onMeasure: { + apply(_element, value, attributeName, context) { + context.setLayoutObserver(attributeName, { + onMeasure() { + (value as Function)(); + }, + }); + }, + reset(_element, attributeName, context) { + context.setLayoutObserver(attributeName, undefined); + }, + }, + layoutValue: { + layoutDependent: true, + apply() {}, + reset() {}, + }, + paintValue: { + apply() {}, + reset() {}, + }, + }); + const rootId = createRootTestElement(observerViewClass); + tree.flush(); + let rootLayoutCount = 0; + tree.setAttributeOnElement(rootId, 'onMeasure', () => rootLayoutCount++); + tree.flush(); + expect(rootLayoutCount).toBe(1); + + tree.setAttributeOnElement(rootId, 'paintValue', 'red'); + tree.flush(); + expect(rootLayoutCount).toBe(1); + + tree.setAttributeOnElement(rootId, 'layoutValue', 100); + tree.flush(); + expect(rootLayoutCount).toBe(2); + tree.setAttributeOnElement(rootId, 'layoutValue', 100); + tree.flush(); + expect(rootLayoutCount).toBe(2); + + const childId = createTestElement(observerViewClass); + let childLayoutCount = 0; + tree.setAttributeOnElement(childId, 'onMeasure', () => childLayoutCount++); + tree.flush(); + expect(childLayoutCount).toBe(0); + + tree.moveElement(childId, rootId, 0); + tree.flush(); + expect(childLayoutCount).toBe(1); + }); + + it('updates percent border radii through the centralized browser resize pass', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 80; + element.rectHeight = 40; + + tree.setAttributeOnElement(id, 'borderRadius', '100%'); + tree.flush(); + const paintElement = getViewPaintElement(id); + expect(paintElement.style.borderRadius).toBe('40px'); + + element.rectWidth = 60; + element.rectHeight = 32; + dispatchWindowResize(); + expect(paintElement.style.borderRadius).toBe('32px'); + }); + + it('removes attribute layout observers on reset and node destruction', () => { + const resetId = createRootTestElement('view'); + const resetElement = getNode(resetId).htmlElement as unknown as FakeElement; + resetElement.rectWidth = 80; + resetElement.rectHeight = 40; + tree.setAttributeOnElement(resetId, 'borderRadius', '100%'); + tree.flush(); + + tree.setAttributeOnElement(resetId, 'borderRadius', undefined); + tree.flush(); + resetElement.rectReadCount = 0; + dispatchWindowResize(); + expect(resetElement.rectReadCount).toBe(0); + + const destroyedId = createRootTestElement('view'); + const destroyedElement = getNode(destroyedId).htmlElement as unknown as FakeElement; + destroyedElement.rectWidth = 80; + destroyedElement.rectHeight = 40; + tree.setAttributeOnElement(destroyedId, 'borderRadius', '100%'); + tree.flush(); + tree.destroyElement(destroyedId); + destroyedElement.rectReadCount = 0; + dispatchWindowResize(); + expect(destroyedElement.rectReadCount).toBe(0); + }); + + it('keeps boolean false as a real attribute value', () => { + const id = createRootTestElement('view'); + + tree.setAttributeOnElement(id, 'touchEnabled', false); + tree.flush(); + expect(getNode(id).htmlElement.style.pointerEvents).toBe('none'); + + tree.setAttributeOnElement(id, 'touchEnabled', true); + tree.flush(); + expect(getNode(id).htmlElement.style.pointerEvents).toBe('auto'); + }); + + it('exports a serializable debug snapshot for the rooted tree', () => { + const root = createRootTestElement('view'); + const child = createTestElement('label'); + tree.moveElement(child, root, 0); + tree.setAttributeOnElement(root, 'width', 42); + tree.setAttributeOnElement(child, 'value', 'Hello debugger'); + + const rootElement = getNode(root).htmlElement as unknown as FakeElement; + rootElement.rectLeft = 4; + rootElement.rectTop = 8; + rootElement.rectWidth = 200; + rootElement.rectHeight = 120; + const childElement = getNode(child).htmlElement as unknown as FakeElement; + childElement.rectLeft = 12; + childElement.rectTop = 24; + childElement.rectWidth = 140; + childElement.rectHeight = 20; + + const snapshot = tree.getDebugSnapshot(); + + expect(snapshot.tree?.id).toBe(String(root)); + expect(snapshot.tree?.tag).toBe('view'); + expect(snapshot.tree?.element.attributes.width).toBe(42); + expect(snapshot.tree?.bounds).toEqual({ x: 4, y: 8, width: 200, height: 120 }); + expect(snapshot.tree?.children[0].id).toBe(String(child)); + expect(snapshot.tree?.children[0].tag).toBe('label'); + expect(snapshot.tree?.children[0].element.attributes.value).toBe('Hello debugger'); + expect(snapshot.tree?.children[0].bounds).toEqual({ x: 12, y: 24, width: 140, height: 20 }); + }); + + it('destroys descendant nodes when a subtree root is destroyed', () => { + const root = createTestElement('view'); + const child = createTestElement('view'); + const grandchild = createTestElement('view'); + + tree.makeElementRoot(root, makeFakeElement('root') as unknown as HTMLElement); + tree.moveElement(child, root, 0); + tree.moveElement(grandchild, child, 0); + tree.destroyElement(child); + + expect(tree.getNode(root)).toBeDefined(); + expect(tree.getNode(child)).toBeUndefined(); + expect(tree.getNode(grandchild)).toBeUndefined(); + }); + + it('replays buffered custom-view attributes in order and forwards live updates', () => { + const changes: Array<[string, unknown]> = []; + const webClass = registerTestWebViewClass(() => ({ + changeAttribute(name: string, value: unknown): void { + changes.push([name, value]); + }, + destroy(): void {}, + })); + const id = createRootTestElement('custom-view'); + + tree.setAttributeOnElement(id, 'latex', 'x'); + tree.flush(); + tree.setAttributeOnElement(id, 'block', false); + tree.flush(); + tree.setAttributeOnElement(id, 'webClass', webClass); + tree.flush(); + + expect(changes).toEqual([ + ['latex', 'x'], + ['block', false], + ]); + + tree.setAttributeOnElement(id, 'latex', 'y'); + tree.flush(); + tree.setAttributeOnElement(id, 'block', undefined); + tree.flush(); + + expect(changes).toEqual([ + ['latex', 'x'], + ['block', false], + ['latex', 'y'], + ['block', undefined], + ]); + }); + + it('destroys a custom-view attribute handler exactly once when its node is removed', () => { + const destroy = jasmine.createSpy('destroy'); + const webClass = registerTestWebViewClass(() => ({ + changeAttribute(): void {}, + destroy, + })); + const id = createRootTestElement('custom-view'); + tree.setAttributeOnElement(id, 'webClass', webClass); + tree.flush(); + + tree.destroyElement(id); + tree.destroy(); + + expect(destroy).toHaveBeenCalledTimes(1); + }); + + it('destroys custom-view attribute handlers during full tree teardown', () => { + const destroy = jasmine.createSpy('destroy'); + const webClass = registerTestWebViewClass(() => ({ + changeAttribute(): void {}, + destroy, + })); + const id = createRootTestElement('custom-view'); + tree.setAttributeOnElement(id, 'webClass', webClass); + tree.flush(); + + tree.destroy(); + + expect(destroy).toHaveBeenCalledTimes(1); + }); + + it('allows custom-view factories without an attribute handler', () => { + const factory = jasmine.createSpy('factory'); + const webClass = registerTestWebViewClass(factory); + const id = createRootTestElement('custom-view'); + tree.setAttributeOnElement(id, 'webClass', webClass); + tree.flush(); + + expect(factory).toHaveBeenCalledTimes(1); + expect(() => tree.destroyElement(id)).not.toThrow(); + }); + + it('allows custom-view attribute handlers without a destroy method', () => { + const changes: Array<[string, unknown]> = []; + const webClass = registerTestWebViewClass(() => ({ + changeAttribute(name: string, value: unknown): void { + changes.push([name, value]); + }, + })); + const id = createRootTestElement('custom-view'); + tree.setAttributeOnElement(id, 'webClass', webClass); + tree.flush(); + tree.setAttributeOnElement(id, 'latex', 'x'); + tree.flush(); + + expect(changes).toEqual([['latex', 'x']]); + expect(() => tree.destroyElement(id)).not.toThrow(); + }); + + it('invokes lifecycle callbacks from resolved attributes without scratch state indirection', () => { + const id = createTestElement('view'); + const records: string[] = []; + + tree.setAttributeOnElement(id, 'onViewCreate', () => records.push('create')); + tree.setAttributeOnElement(id, 'onViewChange', (event: { type: string }) => records.push(`change:${event.type}`)); + tree.setAttributeOnElement(id, 'onViewDestroy', () => records.push('destroy')); + + tree.makeElementRoot(id, makeFakeElement('root') as unknown as HTMLElement); + expect(records).toEqual([]); + + tree.flush(); + expect(records).toEqual(['create', 'change:Attached']); + + tree.destroyElement(id); + expect(records).toEqual(['create', 'change:Attached']); + + tree.flush(); + expect(records).toEqual(['create', 'change:Attached', 'change:Detached', 'destroy']); + }); + + it('invokes create and change callbacks when lifecycle attributes are attached after the node', () => { + const id = createTestElement('view'); + const records: string[] = []; + + tree.makeElementRoot(id, makeFakeElement('root') as unknown as HTMLElement); + tree.flush(); + + tree.setAttributeOnElement(id, 'onViewCreate', () => records.push('create')); + tree.setAttributeOnElement(id, 'onViewChange', (event: { type: string }) => records.push(`change:${event.type}`)); + tree.setAttributeOnElement(id, 'onViewDestroy', () => records.push('destroy')); + tree.flush(); + + expect(records).toEqual(['create', 'change:Attached']); + + tree.destroyElement(id); + tree.flush(); + + expect(records).toEqual(['create', 'change:Attached', 'change:Detached', 'destroy']); + }); + + it('flushes lifecycle callbacks when the full tree is destroyed', () => { + const id = createRootTestElement('view'); + const records: string[] = []; + + tree.setAttributeOnElement(id, 'onViewCreate', () => records.push('create')); + tree.setAttributeOnElement(id, 'onViewChange', (event: { type: string }) => records.push(`change:${event.type}`)); + tree.setAttributeOnElement(id, 'onViewDestroy', () => records.push('destroy')); + tree.flush(); + + expect(records).toEqual(['create', 'change:Attached']); + + tree.destroy(); + + expect(records).toEqual(['create', 'change:Attached', 'change:Detached', 'destroy']); + }); + + it('updates the html element lookup when nodes are destroyed', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + + expect(tree.getNodeIdForHtmlElement(element)).toBe(id); + + tree.destroyElement(id); + + expect(tree.getNode(id)).toBeUndefined(); + expect(tree.getNodeIdForHtmlElement(element)).toBeUndefined(); + }); + + it('creates elements by cloning one template per element class', () => { + let templateCreateCount = 0; + const viewClass = `test-template-clone-${nextTestElementClassId++}`; + class TestTemplateElementClass extends ElementClass { + constructor() { + super(viewClass, {}); + } + + protected onCreateElement(): HTMLElement { + templateCreateCount++; + const element = document.createElement('div'); + element.style.width = '10px'; + const child = document.createElement('span'); + child.textContent = 'template child'; + element.appendChild(child); + return element; + } + } + registerElementClassAlias(viewClass, new TestTemplateElementClass()); + + const first = createTestElement(viewClass); + const second = createTestElement(viewClass); + const firstElement = getNode(first).htmlElement; + const secondElement = getNode(second).htmlElement; + + expect(templateCreateCount).toBe(1); + expect(firstElement).not.toBe(secondElement); + expect(firstElement.style.width).toBe('10px'); + expect(secondElement.style.width).toBe('10px'); + expect(firstElement.childNodes.item(0)).not.toBe(secondElement.childNodes.item(0)); + + firstElement.style.width = '20px'; + expect(secondElement.style.width).toBe('10px'); + }); + + it('throws for unknown view classes and missing move endpoints', () => { + const root = createTestElement('view'); + tree.makeElementRoot(root, makeFakeElement('root') as unknown as HTMLElement); + + expect(() => tree.createElement(nextId++, 'not-a-view-class')).toThrowError(/Unknown viewClass/); + expect(() => tree.moveElement(nextId++, root, 0)).toThrowError(/moveElement/); + expect(() => tree.moveElement(root, nextId++, 0)).toThrowError(/moveElement/); + }); + + it('renders text animation groups as plain views', () => { + const id = createTestElement('SCValdiTextAnimationGroup'); + + expect(getNode(id).htmlElement.tagName).toBe('DIV'); + }); + + it('renders text selection groups as plain views', () => { + const id = createTestElement('SCValdiTextSelectionGroup'); + + expect(getNode(id).htmlElement.tagName).toBe('DIV'); + }); + + it('replaces the root container children when making an element root', () => { + const host = makeFakeElement('host'); + const previousChild = makeFakeElement('previous'); + host.replaceChildren(previousChild); + + const id = createTestElement('view'); + tree.makeElementRoot(id, host as unknown as HTMLElement); + + expect(host.childNodes.item(0)).toBe(getNode(id).htmlElement as unknown as FakeElement); + expect(host.childNodes.item(1)).toBeNull(); + }); + + it('inserts and reorders moved elements at the requested parent index', () => { + const root = createRootTestElement('view'); + const first = createTestElement('view'); + const second = createTestElement('view'); + const third = createTestElement('view'); + const rootElement = getNode(root).htmlElement; + const firstElement = getNode(first).htmlElement; + const secondElement = getNode(second).htmlElement; + const thirdElement = getNode(third).htmlElement; + + tree.moveElement(first, root, 0); + tree.moveElement(second, root, 0); + tree.moveElement(third, root, 1); + + expect(rootElement.childNodes.item(0)).toBe(secondElement); + expect(rootElement.childNodes.item(1)).toBe(thirdElement); + expect(rootElement.childNodes.item(2)).toBe(firstElement); + + tree.moveElement(first, root, 0); + + expect(rootElement.childNodes.item(0)).toBe(firstElement); + expect(rootElement.childNodes.item(1)).toBe(secondElement); + expect(rootElement.childNodes.item(2)).toBe(thirdElement); + }); + + it('schedules one microtask flush for dirty root updates outside a render batch', async () => { + const appliedValues: unknown[] = []; + const viewClass = registerTestElementClass({ + score: { + apply(_element, value) { + appliedValues.push(value); + }, + reset() {}, + }, + }); + const id = createRootTestElement(viewClass); + tree.flush(); + await waitForScheduledFlush(); + const flushSpy = spyOn(tree, 'flush').and.callThrough(); + + tree.setAttributeOnElement(id, 'score', 1); + tree.setAttributeOnElement(id, 'score', 2); + await waitForScheduledFlush(); + + expect(flushSpy.calls.count()).toBe(1); + expect(appliedValues).toEqual([2]); + }); + + it('waits for the outermost render batch before flushing dirty nodes', () => { + let applyCount = 0; + const viewClass = registerTestElementClass({ + score: { + apply() { + applyCount++; + }, + reset() {}, + }, + }); + const id = createRootTestElement(viewClass); + + tree.beginRender(); + tree.beginRender(); + tree.setAttributeOnElement(id, 'score', 1); + tree.endRender(); + + expect(applyCount).toBe(0); + + tree.endRender(); + + expect(applyCount).toBe(1); + }); + + it('coalesces dirty attributes during a render batch', () => { + let applyCount = 0; + let appliedValue: unknown; + const applier: AttributeApplier = { + apply(_element, value) { + applyCount++; + appliedValue = value; + }, + reset() {}, + }; + class TestCounterElementClass extends ElementClass { + constructor() { + super('test-counter', { score: applier }); + } + + protected onCreateElement(): HTMLElement { + return document.createElement('div'); + } + } + registerElementClassAlias('test-counter', new TestCounterElementClass()); + const id = createRootTestElement('test-counter'); + + tree.beginRender(); + tree.setAttributeOnElement(id, 'score', 1); + tree.setAttributeOnElement(id, 'score', 2); + tree.setAttributeOnElement(id, 'score', 3); + expect(applyCount).toBe(0); + tree.endRender(); + + expect(applyCount).toBe(1); + expect(appliedValue).toBe(3); + }); + + it('does not notify the tree again when a node already needs update', () => { + const id = createRootTestElement('view'); + tree.flush(); + const needsUpdateSpy = spyOn(tree, 'onNodeNeedsUpdate').and.callThrough(); + + tree.beginRender(); + tree.setAttributeOnElement(id, 'width', 1); + tree.setAttributeOnElement(id, 'height', 2); + + expect(needsUpdateSpy.calls.count()).toBe(1); + tree.endRender(); + }); + + it('does not schedule a dirty flush for detached nodes', async () => { + const id = createTestElement('view'); + const flushSpy = spyOn(tree, 'flush').and.callThrough(); + + tree.setAttributeOnElement(id, 'width', 1); + await Promise.resolve(); + + expect(flushSpy).not.toHaveBeenCalled(); + }); + + it('flushes from the root node instead of scanning detached nodes', () => { + const appliedValues: unknown[] = []; + const applier: AttributeApplier = { + apply(_element, value) { + appliedValues.push(value); + }, + reset() {}, + }; + class TestRootFlushElementClass extends ElementClass { + constructor() { + super('test-root-flush', { score: applier }); + } + + protected onCreateElement(): HTMLElement { + return document.createElement('div'); + } + } + registerElementClassAlias('test-root-flush', new TestRootFlushElementClass()); + + const root = createRootTestElement('test-root-flush'); + const detached = createTestElement('test-root-flush'); + + tree.setAttributeOnElement(root, 'score', 1); + tree.setAttributeOnElement(detached, 'score', 2); + tree.flush(); + expect(appliedValues).toEqual([1]); + + tree.moveElement(detached, root, 0); + tree.flush(); + expect(appliedValues).toEqual([1, 2]); + }); + + it('propagates an already dirty detached subtree when it is moved under the root', async () => { + const appliedValues: unknown[] = []; + const viewClass = registerTestElementClass({ + score: { + apply(_element, value) { + appliedValues.push(value); + }, + reset() {}, + }, + }); + const root = createRootTestElement(viewClass); + const detachedParent = createTestElement(viewClass); + const detachedChild = createTestElement(viewClass); + tree.flush(); + await waitForScheduledFlush(); + + tree.moveElement(detachedChild, detachedParent, 0); + tree.setAttributeOnElement(detachedChild, 'score', 'child'); + await waitForScheduledFlush(); + expect(appliedValues).toEqual([]); + + tree.moveElement(detachedParent, root, 0); + await waitForScheduledFlush(); + + expect(appliedValues).toEqual(['child']); + }); + + it('schedules a dirty detached root when it becomes the tree root', async () => { + const appliedValues: unknown[] = []; + const viewClass = registerTestElementClass({ + score: { + apply(_element, value) { + appliedValues.push(value); + }, + reset() {}, + }, + }); + const id = createTestElement(viewClass); + + tree.setAttributeOnElement(id, 'score', 'root'); + await waitForScheduledFlush(); + expect(appliedValues).toEqual([]); + + tree.makeElementRoot(id, makeFakeElement('root') as unknown as HTMLElement); + await waitForScheduledFlush(); + + expect(appliedValues).toEqual(['root']); + }); + + it('continues the update pass when an attribute applier dirties another attribute during flush', () => { + const appliedValues: string[] = []; + let id = 0; + const viewClass = registerTestElementClass({ + first: { + apply() { + appliedValues.push('first'); + tree.setAttributeOnElement(id, 'second', 'from-first'); + }, + reset() {}, + }, + second: { + apply(_element, value) { + appliedValues.push(`second:${String(value)}`); + }, + reset() {}, + }, + }); + id = createRootTestElement(viewClass); + + tree.setAttributeOnElement(id, 'first', true); + tree.flush(); + + expect(appliedValues).toEqual(['first', 'second:from-first']); + }); + + it('resolves palette updates triggered by an attribute during the same update pass', () => { + paletteManager.configureColorPalette('light', { tone: 'blue' }); + paletteManager.configureColorPalette('dark', { tone: 'red' }); + paletteManager.setActiveColorPalette('light'); + const appliedColors: string[] = []; + const viewClass = registerTestElementClass({ + tone: { + colorDependent: true, + apply(_element, value, _attributeName, context) { + appliedColors.push(context.resolveColor(String(value))); + }, + reset() {}, + }, + useDarkPalette: { + apply(_element, _value, _attributeName, context) { + context.setColorPalette('dark'); + }, + reset(_element, _attributeName, context) { + context.setColorPalette(undefined); + }, + }, + }); + const id = createRootTestElement(viewClass); + + tree.beginRender(); + tree.setAttributeOnElement(id, 'tone', 'tone'); + tree.setAttributeOnElement(id, 'useDarkPalette', true); + tree.endRender(); + + expect(appliedColors.length).toBeGreaterThan(0); + expect(appliedColors[appliedColors.length - 1]).toBe('red'); + }); + + it('signals externally updated attributes through the node creation delegate', () => { + const records: Array<{ id: number; attributeName: string; attributeValue: unknown }> = []; + const applier: AttributeApplier = { + apply(_element, _value, _attributeName, context) { + context.onAttributeUpdatedExternally('value', 'dom-value'); + }, + reset() {}, + }; + class TestExternalUpdateElementClass extends ElementClass { + constructor() { + super('test-external-update', { trigger: applier }); + } + + protected onCreateElement(): HTMLElement { + return document.createElement('div'); + } + } + registerElementClassAlias('test-external-update', new TestExternalUpdateElementClass()); + + const id = nextId++; + tree.createElement(id, 'test-external-update', { + onAttributeUpdatedExternally(elementId, attributeName, attributeValue) { + records.push({ id: elementId, attributeName, attributeValue }); + }, + }); + tree.makeElementRoot(id, makeFakeElement('root') as unknown as HTMLElement); + + tree.setAttributeOnElement(id, 'trigger', true); + tree.flush(); + + expect(records).toEqual([{ id, attributeName: 'value', attributeValue: 'dom-value' }]); + }); + + it('logs typed applier failures with node context without escaping', () => { + const id = createRootTestElement('view'); + const errorSpy = spyOn(console, 'error'); + + expect(() => { + tree.setAttributeOnElement(id, 'width', {}); + tree.flush(); + }).not.toThrow(); + + expect(errorSpy).toHaveBeenCalled(); + const message = String(errorSpy.calls.mostRecent().args[0]); + expect(message).toContain(`node ${id}`); + expect(message).toContain('(view)'); + expect(message).toContain("'width'"); + expect(message).toContain('Expected'); + }); + + it('warns with node context when an attribute has no applier', () => { + const id = createRootTestElement('view'); + const warnSpy = spyOn(console, 'warn'); + + tree.setAttributeOnElement(id, 'unknownAttribute', 42); + tree.flush(); + + expect(warnSpy).toHaveBeenCalled(); + const message = String(warnSpy.calls.mostRecent().args[0]); + expect(message).toContain(`node ${id}`); + expect(message).toContain('(view)'); + expect(message).toContain("'unknownAttribute'"); + expect(message).toContain('42'); + }); + + it('reapplies color-dependent attributes across palette changes and subtree overrides', () => { + paletteManager.configureColorPalette('light', { background: 'blue', foreground: 'green' }); + paletteManager.configureColorPalette('dark', { background: 'red', foreground: 'yellow' }); + paletteManager.setActiveColorPalette('light'); + + const root = createTestElement('view'); + const child = createTestElement('view'); + const grandchild = createTestElement('view'); + tree.makeElementRoot(root, makeFakeElement('root') as unknown as HTMLElement); + tree.moveElement(child, root, 0); + tree.moveElement(grandchild, child, 0); + + tree.setAttributeOnElement(root, 'backgroundColor', 'background'); + tree.setAttributeOnElement(child, 'colorPaletteName', 'dark'); + tree.setAttributeOnElement(child, 'backgroundColor', 'background'); + tree.setAttributeOnElement(grandchild, 'backgroundColor', 'foreground'); + tree.flush(); + + expect(getViewPaintElement(root).style.backgroundColor).toBe('blue'); + expect(getViewPaintElement(child).style.backgroundColor).toBe('red'); + expect(getViewPaintElement(grandchild).style.backgroundColor).toBe('yellow'); + + paletteManager.configureColorPalette('dark', { background: 'black', foreground: 'white' }); + expect(getViewPaintElement(child).style.backgroundColor).toBe('black'); + expect(getViewPaintElement(grandchild).style.backgroundColor).toBe('white'); + + paletteManager.setActiveColorPalette('dark'); + expect(getViewPaintElement(root).style.backgroundColor).toBe('black'); + }); + + it('resolves attributed text colors through the node palette override', () => { + paletteManager.configureColorPalette('light', { foreground: 'black' }); + paletteManager.configureColorPalette('dark', { foreground: 'white' }); + paletteManager.setActiveColorPalette('light'); + const attributedText = new AttributedTextBuilder().pushColor('foreground').append('themed').pop().build(); + const id = createRootTestElement('label'); + + tree.setAttributeOnElement(id, 'colorPaletteName', 'dark'); + tree.setAttributeOnElement(id, 'value', attributedText); + tree.flush(); + + const label = getNode(id).htmlElement as unknown as FakeElement; + let styledSpan = label.childNodes.item(0)!.childNodes.item(0)!; + expect(styledSpan.style.color).toBe('white'); + + paletteManager.configureColorPalette('dark', { foreground: 'yellow' }); + + styledSpan = label.childNodes.item(0)!.childNodes.item(0)!; + expect(styledSpan.style.color).toBe('yellow'); + }); + + it('uses the active palette from the root update pass rather than node creation time', () => { + paletteManager.configureColorPalette('light', { background: 'blue' }); + paletteManager.configureColorPalette('dark', { background: 'red' }); + paletteManager.setActiveColorPalette('light'); + + const id = createTestElement('view'); + paletteManager.setActiveColorPalette('dark'); + tree.makeElementRoot(id, makeFakeElement('root') as unknown as HTMLElement); + tree.setAttributeOnElement(id, 'backgroundColor', 'background'); + tree.flush(); + + expect(getViewPaintElement(id).style.backgroundColor).toBe('red'); + }); + + it('recomputes inherited palette when a node moves to a different palette scope', () => { + paletteManager.configureColorPalette('light', { background: 'blue' }); + paletteManager.configureColorPalette('dark', { background: 'red' }); + paletteManager.setActiveColorPalette('light'); + + const root = createRootTestElement('view'); + const darkParent = createTestElement('view'); + const child = createTestElement('view'); + + tree.moveElement(darkParent, root, 0); + tree.moveElement(child, darkParent, 0); + tree.setAttributeOnElement(darkParent, 'colorPaletteName', 'dark'); + tree.setAttributeOnElement(child, 'backgroundColor', 'background'); + tree.flush(); + expect(getViewPaintElement(child).style.backgroundColor).toBe('red'); + + tree.moveElement(child, root, 1); + tree.flush(); + + expect(getViewPaintElement(child).style.backgroundColor).toBe('blue'); + }); + + it('does not keep palette change listeners after the tree is destroyed', () => { + const id = createRootTestElement('view'); + tree.setAttributeOnElement(id, 'backgroundColor', 'background'); + tree.flush(); + const reapplySpy = spyOn(tree, 'reapplyColorPalettesOnAllNodes').and.callThrough(); + + tree.destroy(); + paletteManager.configureColorPalette('default', { background: 'black' }); + + expect(reapplySpy).not.toHaveBeenCalled(); + }); + + it('coalesces transform composite parts into one final transform value', () => { + const id = createRootTestElement('view'); + + tree.beginRender(); + tree.setAttributeOnElement(id, 'translationX', 10); + tree.setAttributeOnElement(id, 'scaleX', 2); + tree.setAttributeOnElement(id, 'rotation', 1); + tree.endRender(); + + expect(getNode(id).htmlElement.style.transform).toBe('translate(10px, 0px) scale(2, 1) rotate(1rad)'); + }); + + it('does not expose the synthetic transform composite as a rendered attribute', () => { + const id = createRootTestElement('view'); + tree.setAttributeOnElement(id, 'translationX', 10); + tree.flush(); + + const attributes = getNode(id).getDebugSnapshot().element.attributes; + expect(attributes.translationX).toBe(10); + expect(attributes.transformComposite).toBeUndefined(); + }); + + it('maps Yoga overflow scroll to visible CSS overflow for plain views', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + + tree.setAttributeOnElement(id, 'overflow', 'scroll'); + tree.flush(); + + expect(element.style.overflow).toBe('visible'); + }); + + it('applies onMeasure tuple results as measured lazy layout dimensions', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement as unknown as FakeElement; + const records: string[] = []; + if (!element.parentElement) { + throw new Error('Expected root parent element'); + } + element.parentElement.rectWidth = 320; + element.parentElement.rectHeight = 200; + + tree.setAttributeOnElement( + id, + 'onMeasure', + (width: number, widthMode: number, height: number, heightMode: number) => { + records.push(`${width}:${widthMode}:${height}:${heightMode}`); + return [180, 76]; + }, + ); + tree.flush(); + + expect(records).toEqual(['320:1:200:2', '320:1:200:2']); + expect(element.style.width).toBeUndefined(); + expect(element.style.height).toBe('76px'); + }); + + it('uses estimated dimensions as lazy layout placeholder dimensions', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + + tree.setAttributeOnElement(id, 'estimatedWidth', 150); + tree.setAttributeOnElement(id, 'estimatedHeight', 64); + tree.flush(); + + expect(element.style.containIntrinsicWidth).toBe('150px'); + expect(element.style.containIntrinsicHeight).toBe('64px'); + expect(element.style.width).toBeUndefined(); + expect(element.style.height).toBe('64px'); + + tree.setAttributeOnElement(id, 'estimatedWidth', undefined); + tree.setAttributeOnElement(id, 'estimatedHeight', undefined); + tree.flush(); + + expect(element.style.width).toBeUndefined(); + expect(element.style.height).toBe(''); + }); + + it('suppresses boxShadow while slowClipping clips child content', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + + tree.setAttributeOnElement(id, 'boxShadow', 'complex 0 12 28 rgba(17, 24, 39, 0.35)'); + tree.flush(); + const paintElement = getViewPaintElement(id); + + expect(paintElement.style.boxShadow).toBe('0px 12px 28px rgba(17, 24, 39, 0.35)'); + + tree.setAttributeOnElement(id, 'slowClipping', true); + tree.flush(); + + expect(paintElement.style.boxShadow).toBe(''); + expect(element.style.overflow).toBe('hidden'); + + tree.setAttributeOnElement(id, 'slowClipping', false); + tree.flush(); + expect(paintElement.style.boxShadow).toBe('0px 12px 28px rgba(17, 24, 39, 0.35)'); + }); + + it('mirrors the resolved paint radius onto the host only while slowClipping is enabled', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 100; + element.rectHeight = 60; + tree.setAttributeOnElement(id, 'borderRadius', '50%'); + tree.flush(); + const paintElement = getViewPaintElement(id); + + expect(paintElement.style.borderRadius).toBe('30px'); + expect(element.style.borderRadius).toBe(''); + + tree.setAttributeOnElement(id, 'slowClipping', true); + tree.flush(); + expect(element.style.borderRadius).toBe('30px'); + + tree.setAttributeOnElement(id, 'slowClipping', false); + tree.flush(); + expect(element.style.borderRadius).toBe(''); + expect(paintElement.style.borderRadius).toBe('30px'); + }); + + it('keeps slowClipping active when overflow is applied afterward', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + + tree.setAttributeOnElement(id, 'slowClipping', true); + tree.flush(); + expect(element.style.overflow).toBe('hidden'); + + tree.setAttributeOnElement(id, 'overflow', 'visible'); + tree.flush(); + expect(element.style.overflow).toBe('hidden'); + + tree.setAttributeOnElement(id, 'slowClipping', false); + tree.flush(); + expect(element.style.overflow).toBe('visible'); + }); + + it('adds CSS units to Valdi layout shorthand and grid track numbers', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement; + + tree.setAttributeOnElement(id, 'padding', '8 18 14 28'); + tree.setAttributeOnElement(id, 'gap', '10 14'); + tree.setAttributeOnElement(id, 'gridTemplateColumns', 'repeat(2, minmax(40, 1fr) 20)'); + tree.setAttributeOnElement(id, 'gridAutoRows', '42'); + tree.flush(); + + expect(element.style.padding).toBe('8px 18px 14px 28px'); + expect(element.style.gap).toBe('10px 14px'); + expect(element.style.gridTemplateColumns).toBe('repeat(2, minmax(40px, 1fr) 20px)'); + expect(element.style.gridAutoRows).toBe('42px'); + }); + + it('adjusts repeated grid columns when a child spans the trailing flexible tracks', async () => { + const parent = createRootTestElement('view'); + const child = createTestElement('view'); + const element = getNode(parent).htmlElement; + + tree.setAttributeOnElement(parent, 'display', 'grid'); + tree.setAttributeOnElement(parent, 'gridTemplateColumns', 'repeat(2, minmax(40, 1fr) 20)'); + tree.setAttributeOnElement(child, 'gridColumnStart', 3); + tree.setAttributeOnElement(child, 'gridColumnEnd', 5); + tree.moveElement(child, parent, 0); + tree.flush(); + await Promise.resolve(); + + expect(element.style.gridTemplateColumns).toBe('50px 20px minmax(0, 1fr) 20px'); + }); + + it('applies maskPath using CSS mask styles', () => { + const id = createRootTestElement('view'); + const element = getNode(id).htmlElement as unknown as FakeElement; + + tree.setAttributeOnElement(id, 'maskOpacity', 0.5); + tree.setAttributeOnElement(id, 'maskPath', 'M 0 0 L 1 0 L 1 1 Z'); + tree.flush(); + + expect(element.style['mask-image']).toContain('data:image/svg+xml'); + expect(element.style['mask-mode']).toBe('luminance'); + + tree.setAttributeOnElement(id, 'maskPath', undefined); + tree.flush(); + expect(element.style['mask-image']).toBeUndefined(); + }); + + it('converts serialized geometric paths to SVG paths', () => { + const path = new GeometricPathBuilder(10, 20, GeometricPathScaleType.Contain) + .moveTo(1, 2) + .lineTo(3, 4) + .quadTo(5, 6, 7, 8) + .cubicTo(9, 10, 11, 12, 13, 14) + .roundRectTo(1, 1, 4, 6, 2, 3) + .arcTo(5, 5, 2, 0, Math.PI / 2) + .close() + .build(); + + const svgPath = geometricPathToSvgPath(path); + expect(svgPath.viewBox).toBe('0 0 10 20'); + expect(svgPath.preserveAspectRatio).toBe('xMidYMid meet'); + expect(svgPath.d).toContain('M 1 2 L 3 4 Q 5 6 7 8 C 9 10 11 12 13 14'); + expect(svgPath.d).toContain('A 2 2 0 0 1'); + }); + + it('respects explicit scroll indicator booleans', () => { + const id = createRootTestElement('scroll'); + const element = getNode(id).htmlElement as unknown as FakeElement; + + expect(element.classList.contains('hide-v-scrollbar')).toBeTrue(); + expect(element.classList.contains('hide-h-scrollbar')).toBeTrue(); + + tree.setAttributeOnElement(id, 'showsVerticalScrollIndicator', true); + tree.setAttributeOnElement(id, 'showsHorizontalScrollIndicator', true); + tree.flush(); + expect(element.classList.contains('hide-v-scrollbar')).toBeFalse(); + expect(element.classList.contains('hide-h-scrollbar')).toBeFalse(); + + tree.setAttributeOnElement(id, 'showsVerticalScrollIndicator', false); + tree.setAttributeOnElement(id, 'showsHorizontalScrollIndicator', false); + tree.flush(); + expect(element.classList.contains('hide-v-scrollbar')).toBeTrue(); + expect(element.classList.contains('hide-h-scrollbar')).toBeTrue(); + }); + + it('keeps the scrollbar visible when an axis is always scrollable', () => { + const id = createRootTestElement('scroll'); + const element = getNode(id).htmlElement as unknown as FakeElement; + + tree.setAttributeOnElement(id, 'showsHorizontalScrollIndicator', false); + tree.setAttributeOnElement(id, 'showsVerticalScrollIndicator', false); + tree.setAttributeOnElement(id, 'canAlwaysScrollHorizontal', true); + tree.flush(); + + expect(element.style.overflowX).toBe('scroll'); + expect(element.style['scrollbar-width']).toBe('auto'); + expect(element.classList.contains('hide-h-scrollbar')).toBeFalse(); + expect(element.classList.contains('hide-v-scrollbar')).toBeTrue(); + + tree.setAttributeOnElement(id, 'canAlwaysScrollHorizontal', false); + tree.flush(); + + expect(element.style['scrollbar-width']).toBe('none'); + expect(element.classList.contains('hide-h-scrollbar')).toBeTrue(); + }); + + it('reports changed scroll content size when the observed element size changes', () => { + const sizes: Array<{ width: number; height: number }> = []; + const id = createRootTestElement('scroll'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 200; + element.rectHeight = 300; + element.scrollWidth = 240; + element.scrollHeight = 360; + + tree.setAttributeOnElement(id, 'onContentSizeChange', (size: { width: number; height: number }) => { + sizes.push(size); + }); + tree.flush(); + expect(sizes).toEqual([{ width: 240, height: 360 }]); + tree.setAttributeOnElement(id, 'width', 200); + tree.flush(); + expect(sizes.length).toBe(1); + + element.scrollHeight = 420; + element.rectHeight = 320; + dispatchWindowResize(); + expect(sizes).toEqual([ + { width: 240, height: 360 }, + { width: 240, height: 420 }, + ]); + }); + + it('reapplies scroll contentOffset after renderer post-layout callbacks', () => { + let runPostLayoutCallbacks: (() => void) | undefined; + tree.setPostLayoutScheduler(callback => { + runPostLayoutCallbacks = callback; + }); + const id = createRootTestElement('scroll'); + const element = getNode(id).htmlElement as unknown as FakeElement; + + tree.setAttributeOnElement(id, 'contentOffsetY', 48); + tree.flush(); + + expect(element.scrollTop).toBe(48); + expect(runPostLayoutCallbacks).toBeDefined(); + + element.scrollTop = 0; + runPostLayoutCallbacks!(); + + expect(element.scrollTop).toBe(48); + }); + + it('ignores stale scheduled scroll contentOffset callbacks', () => { + let runPostLayoutCallbacks: (() => void) | undefined; + tree.setPostLayoutScheduler(callback => { + runPostLayoutCallbacks = callback; + }); + const id = createRootTestElement('scroll'); + const element = getNode(id).htmlElement as unknown as FakeElement; + + tree.setAttributeOnElement(id, 'contentOffsetY', 24); + tree.flush(); + tree.setAttributeOnElement(id, 'contentOffsetY', 96); + tree.flush(); + + expect(element.scrollTop).toBe(96); + expect(runPostLayoutCallbacks).toBeDefined(); + + element.scrollTop = 0; + runPostLayoutCallbacks!(); + + expect(element.scrollTop).toBe(96); + }); + + it('omits the start fading edge when scroll offset is at the start', () => { + const id = createRootTestElement('scroll'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.clientHeight = 260; + element.scrollHeight = 460; + element.scrollTop = 0; + + tree.setAttributeOnElement(id, 'fadingEdgeLength', 32); + tree.setAttributeOnElement(id, 'fadingEdgeStart', true); + tree.setAttributeOnElement(id, 'fadingEdgeEnd', true); + tree.flush(); + + expect(element.style.maskImage).toBe('linear-gradient(to bottom, black, black calc(100% - 32px), transparent)'); + + element.scrollTop = 20; + element.dispatchEvent(makeFakeEvent('scroll')); + + expect(element.style.maskImage).toBe( + 'linear-gradient(to bottom, transparent, black 32px, black calc(100% - 32px), transparent)', + ); + }); + + it('updates fading edge after renderer post-layout callbacks resolve scroll metrics', () => { + let runPostLayoutCallbacks: (() => void) | undefined; + tree.setPostLayoutScheduler(callback => { + runPostLayoutCallbacks = callback; + }); + const id = createRootTestElement('scroll'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.clientHeight = 260; + element.scrollHeight = 460; + element.scrollTop = 0; + + tree.setAttributeOnElement(id, 'fadingEdgeLength', 32); + tree.flush(); + + expect(element.style.maskImage).toBeUndefined(); + expect(runPostLayoutCallbacks).toBeDefined(); + + element.scrollTop = 20; + runPostLayoutCallbacks!(); + + expect(element.style.maskImage).toBe( + 'linear-gradient(to bottom, transparent, black 32px, black calc(100% - 32px), transparent)', + ); + }); + + it('reports textview contenteditable changes from DOM text content', () => { + const externalUpdates: Array<{ id: number; attributeName: string; attributeValue: unknown }> = []; + const changeEvents: Array<{ text: string; selectionStart: number; selectionEnd: number }> = []; + const id = nextId++; + + tree.createElement(id, 'textview', { + onAttributeUpdatedExternally(elementId, attributeName, attributeValue) { + externalUpdates.push({ id: elementId, attributeName, attributeValue }); + }, + }); + tree.makeElementRoot(id, makeFakeElement('root') as unknown as HTMLElement); + + tree.setAttributeOnElement(id, 'value', 'initial'); + tree.setAttributeOnElement( + id, + 'onChange', + (event: { text: string; selectionStart: number; selectionEnd: number }) => { + changeEvents.push(event); + }, + ); + tree.flush(); + + const element = getNode(id).htmlElement as unknown as FakeElement; + element.textContent = 'edited'; + element.dispatchEvent(makeFakeEvent('input')); + + expect(changeEvents).toEqual([{ text: 'edited', selectionStart: 0, selectionEnd: 0 }]); + expect(externalUpdates).toEqual([{ id, attributeName: 'value', attributeValue: 'edited' }]); + }); + + it('renders textview background effects while preserving font attributes', () => { + const id = createRootTestElement('textview'); + const element = getNode(id).htmlElement as unknown as FakeElement; + + tree.setAttributeOnElement(id, 'font', 'system-bold 18'); + tree.setAttributeOnElement(id, 'value', 'highlighted text'); + tree.setAttributeOnElement(id, 'backgroundEffectColor', 'rgba(251, 191, 36, 0.45)'); + tree.setAttributeOnElement(id, 'backgroundEffectBorderRadius', 12); + tree.setAttributeOnElement(id, 'backgroundEffectPadding', 8); + tree.flush(); + + const wrapper = element.childNodes.item(0)!; + const span = wrapper.childNodes.item(0)!; + expect(element.style.fontFamily).toContain('-apple-system'); + expect(element.style.fontWeight).toBe('700'); + expect(wrapper.style.padding).toBe('4px 8px'); + expect(span.style.backgroundColor).toBe('rgba(251, 191, 36, 0.45)'); + expect(span.style['box-decoration-break']).toBe('clone'); + expect(span.style.borderRadius).toBe('12px'); + expect(span.style.padding).toBe('4px 8px'); + expect(span.style.marginLeft).toBe('-8px'); + expect(span.style.marginRight).toBe('-8px'); + }); + + it('uses the system font stack for default text controls', () => { + const labelId = createRootTestElement('label'); + const textFieldId = createRootTestElement('textfield'); + const textViewId = createRootTestElement('textview'); + + expect(getNode(labelId).htmlElement.style.fontFamily).toContain('-apple-system'); + expect(getNode(textFieldId).htmlElement.style.fontFamily).toContain('-apple-system'); + expect(getNode(textViewId).htmlElement.style.fontFamily).toContain('-apple-system'); + }); + + it('parses Valdi textShadow values with colors that contain spaces', () => { + const id = createRootTestElement('label'); + const element = getNode(id).htmlElement; + + tree.setAttributeOnElement(id, 'textShadow', 'rgba(10, 20, 30, 0.8) 4 0.5 6 8'); + tree.flush(); + + expect(element.style.textShadow).toBe('6px 8px 4px rgba(10, 20, 30, 0.4)'); + }); + + it('reports attributed text layout without outline stroke inflation', () => { + let reported: { x: number; y: number; width: number; height: number } | undefined; + const attributedText = new AttributedTextBuilder() + .append('outline', { + font: 'system-bold 18', + onLayout: (x, y, width, height) => { + reported = { x, y, width, height }; + }, + outlineColor: '#FBBF24', + outlineWidth: 1, + }) + .build(); + const parsedAttributedText = ParsedAttributedText.parse(attributedText); + const container = renderAttributedText(parsedAttributedText) as unknown as FakeElement; + const span = container.childNodes.item(0)!; + container.rectLeft = 10; + container.rectTop = 20; + span.rectLeft = 10; + span.rectTop = 20; + span.rectWidth = 59; + span.rectHeight = 21; + + dispatchAttributedTextLayouts(parsedAttributedText, container as unknown as HTMLElement); + + expect(reported).toEqual({ x: 0, y: 0, width: 57, height: 21 }); + }); + + it('reports attributed text layout after the post-layout scheduler when measurement is drained early', () => { + let runPostLayoutCallbacks: (() => void) | undefined; + tree.setPostLayoutScheduler(callback => { + runPostLayoutCallbacks = callback; + }); + + let reported: { x: number; y: number; width: number; height: number } | undefined; + const attributedText = new AttributedTextBuilder() + .append('scheduled', { + onLayout: (x, y, width, height) => { + reported = { x, y, width, height }; + }, + }) + .build(); + const id = createRootTestElement('label'); + const label = getNode(id).htmlElement as unknown as FakeElement; + label.rectWidth = 100; + label.rectHeight = 20; + + tree.setAttributeOnElement(id, 'value', attributedText); + tree.flush(); + + expect(reported).toBeUndefined(); + expect(runPostLayoutCallbacks).toBeDefined(); + + const container = label.childNodes.item(0)!; + const span = container.childNodes.item(0)!; + container.rectLeft = 20; + container.rectTop = 30; + span.rectLeft = 25; + span.rectTop = 37; + span.rectWidth = 44; + span.rectHeight = 12; + + tree.drainScheduledLayoutObserverRefresh(); + expect(reported).toBeUndefined(); + runPostLayoutCallbacks!(); + + expect(reported).toEqual({ x: 5, y: 7, width: 44, height: 12 }); + + reported = undefined; + container.rectLeft = 30; + span.rectLeft = 38; + span.rectWidth = 52; + label.rectWidth = 110; + dispatchWindowResize(); + expect(runPostLayoutCallbacks).toBeDefined(); + tree.drainScheduledLayoutObserverRefresh(); + expect(reported).toBeUndefined(); + runPostLayoutCallbacks!(); + expect(reported as { x: number; y: number; width: number; height: number } | undefined).toEqual({ + x: 8, + y: 7, + width: 52, + height: 12, + }); + }); + + it('does not report a queued attributed text layout after the value is replaced', () => { + let runPostLayoutCallbacks: (() => void) | undefined; + tree.setPostLayoutScheduler(callback => { + runPostLayoutCallbacks = callback; + }); + + let reportCount = 0; + const attributedText = new AttributedTextBuilder() + .append('scheduled', { + onLayout: () => { + reportCount++; + }, + }) + .build(); + const id = createRootTestElement('label'); + const label = getNode(id).htmlElement as unknown as FakeElement; + label.rectWidth = 100; + label.rectHeight = 20; + + tree.setAttributeOnElement(id, 'value', attributedText); + tree.flush(); + tree.drainScheduledLayoutObserverRefresh(); + + tree.setAttributeOnElement(id, 'value', 'replacement'); + tree.flush(); + runPostLayoutCallbacks!(); + + expect(reportCount).toBe(0); + }); + + it('renders label inline view attachments using child elements', () => { + const labelId = createRootTestElement('label'); + const childId = createTestElement('view'); + const label = getNode(labelId).htmlElement; + const child = getNode(childId).htmlElement as unknown as FakeElement; + tree.moveElement(childId, labelId, 0); + const attributedText = new AttributedTextBuilder().append('before ').appendInlineView(0).append(' after').build(); + + tree.setAttributeOnElement(labelId, 'value', attributedText); + tree.flush(); + + const container = label.childNodes.item(0)!; + const inlineSpan = container.childNodes.item(1)! as unknown as FakeElement; + expect(inlineSpan.childNodes.item(0)).toBe(child); + expect(inlineSpan.style.display).toBe('inline-flex'); + expect(inlineSpan.style.verticalAlign).toBe('middle'); + }); + + it('animates attributed label parts and restores final styles', () => { + const id = createRootTestElement('label'); + const attributedText = new AttributedTextBuilder() + .append('fade', { + animationTransform: { + duration: 1, + opacity: 0, + scale: 0.5, + translationY: 10, + }, + }) + .build(); + + tree.setAttributeOnElement(id, 'value', attributedText); + tree.flush(); + + const label = getNode(id).htmlElement as unknown as FakeElement; + const container = label.childNodes.item(0)!; + const span = container.childNodes.item(0)!; + expect(span.style.opacity).toBe('0'); + expect(span.style.transform).toContain('translateY(10px)'); + expect(span.style.transform).toContain('scale(0.5)'); + + flushTextAnimationFrame(1000); + + expect(span.style.opacity).toBe(''); + expect(span.style.transform).toBe(''); + }); + + it('splits text animation partPattern matches and leaves unmatched text unanimated', () => { + const id = createRootTestElement('label'); + const attributedText = new AttributedTextBuilder() + .append('hi there', { + animationTransform: { + duration: 1, + opacity: 0, + partPattern: '\\S+', + }, + }) + .build(); + + tree.setAttributeOnElement(id, 'value', attributedText); + tree.flush(); + + const label = getNode(id).htmlElement as unknown as FakeElement; + const container = label.childNodes.item(0)!; + const partSpan = container.childNodes.item(0)!; + const firstWord = partSpan.childNodes.item(0)!; + const space = partSpan.childNodes.item(1)!; + const secondWord = partSpan.childNodes.item(2)!; + + expect(partSpan.childNodes.length).toBe(3); + expect(firstWord.textContent).toBe('hi'); + expect(space.textContent).toBe(' '); + expect(secondWord.textContent).toBe('there'); + expect(firstWord.style.opacity).toBe('0'); + expect(space.style.opacity).toBeUndefined(); + expect(secondWord.style.opacity).toBe('0'); + }); + + it('logs invalid text animation partPattern values and renders the text unanimated', () => { + const errorSpy = spyOn(console, 'error'); + const id = createRootTestElement('label'); + const attributedText = new AttributedTextBuilder() + .append('invalid', { + animationTransform: { + opacity: 0, + partPattern: '[', + }, + }) + .build(); + + tree.setAttributeOnElement(id, 'value', attributedText); + tree.flush(); + + const label = getNode(id).htmlElement as unknown as FakeElement; + const container = label.childNodes.item(0)!; + const partSpan = container.childNodes.item(0)!; + + expect(errorSpy).toHaveBeenCalled(); + expect(String(errorSpy.calls.mostRecent().args[0])).toContain('Invalid text animation partPattern'); + expect(partSpan.childNodes.length).toBe(0); + expect(partSpan.style.opacity).toBeUndefined(); + }); + + it('continues textview animations across content rerenders with the same key', () => { + const id = createRootTestElement('textview'); + const attributedText = new AttributedTextBuilder() + .append('rerender', { + animationTransform: { + duration: 1, + key: 'stable', + opacity: 0, + }, + }) + .build(); + + tree.setAttributeOnElement(id, 'value', attributedText); + tree.flush(); + + const textView = getNode(id).htmlElement as unknown as FakeElement; + let container = textView.childNodes.item(0)!; + let span = container.childNodes.item(0)!; + expect(span.style.opacity).toBe('0'); + + flushTextAnimationFrame(500); + expect(Number(span.style.opacity)).toBeCloseTo(0.875, 3); + + tree.setAttributeOnElement(id, 'backgroundEffectColor', '#DDEEFF'); + tree.flush(); + + const wrapper = textView.childNodes.item(0)!; + container = wrapper.childNodes.item(0)!; + span = container.childNodes.item(0)!; + expect(Number(span.style.opacity)).toBeCloseTo(0.875, 3); + }); + + it('coordinates text animations across textanimationgroup descendants', () => { + const root = createRootTestElement('view'); + const group = createTestElement('SCValdiTextAnimationGroup'); + const first = createTestElement('label'); + const nestedView = createTestElement('view'); + const second = createTestElement('label'); + + tree.moveElement(group, root, 0); + tree.moveElement(first, group, 0); + tree.moveElement(nestedView, group, 1); + tree.moveElement(second, nestedView, 0); + + tree.setAttributeOnElement(first, 'value', animatedText('first')); + tree.setAttributeOnElement(second, 'value', animatedText('second')); + tree.flush(); + + const firstSpan = attributedPartSpan(first); + const secondSpan = attributedPartSpan(second); + expect(firstSpan.style.opacity).toBe('0'); + expect(secondSpan.style.opacity).toBe('0'); + + flushTextAnimationFrame(0); + flushTextAnimationFrame(50); + + expect(Number(firstSpan.style.opacity)).toBeGreaterThan(0); + expect(secondSpan.style.opacity).toBe('0'); + + flushTextAnimationFrame(150); + + expect(Number(secondSpan.style.opacity)).toBeGreaterThan(0); + }); + + it('keeps nested textanimationgroup timelines isolated from ancestor groups', () => { + const root = createRootTestElement('view'); + const outerGroup = createTestElement('SCValdiTextAnimationGroup'); + const outerFirst = createTestElement('label'); + const innerGroup = createTestElement('SCValdiTextAnimationGroup'); + const innerLabel = createTestElement('label'); + const outerSecond = createTestElement('label'); + + tree.moveElement(outerGroup, root, 0); + tree.moveElement(outerFirst, outerGroup, 0); + tree.moveElement(innerGroup, outerGroup, 1); + tree.moveElement(innerLabel, innerGroup, 0); + tree.moveElement(outerSecond, outerGroup, 2); + + tree.setAttributeOnElement(outerFirst, 'value', animatedText('outer first')); + tree.setAttributeOnElement(innerLabel, 'value', animatedText('inner')); + tree.setAttributeOnElement(outerSecond, 'value', animatedText('outer second')); + tree.flush(); + + const innerSpan = attributedPartSpan(innerLabel); + const outerSecondSpan = attributedPartSpan(outerSecond); + + flushTextAnimationFrame(0); + flushTextAnimationFrame(50); + + expect(Number(innerSpan.style.opacity)).toBeGreaterThan(0); + expect(outerSecondSpan.style.opacity).toBe('0'); + }); + + it('unregisters text animation participants when attributed text becomes plain text', () => { + const id = createRootTestElement('label'); + const attributedText = new AttributedTextBuilder() + .append('animated', { + animationTransform: { + duration: 1, + opacity: 0, + }, + }) + .build(); + + tree.setAttributeOnElement(id, 'value', attributedText); + tree.flush(); + + const animatedSpan = attributedPartSpan(id); + expect(animatedSpan.style.opacity).toBe('0'); + + tree.setAttributeOnElement(id, 'value', 'plain'); + tree.flush(); + + expect(animatedSpan.style.opacity).toBe(''); + }); + + it('skips stale attributed text layout callbacks after content is replaced', () => { + let runPostLayoutCallbacks: (() => void) | undefined; + tree.setPostLayoutScheduler(callback => { + runPostLayoutCallbacks = callback; + }); + + let reported = false; + const attributedText = new AttributedTextBuilder() + .append('stale', { + onLayout: () => { + reported = true; + }, + }) + .build(); + const id = createRootTestElement('label'); + + tree.setAttributeOnElement(id, 'value', attributedText); + tree.flush(); + tree.setAttributeOnElement(id, 'value', 'plain'); + tree.flush(); + + expect(runPostLayoutCallbacks).toBeDefined(); + runPostLayoutCallbacks!(); + + expect(reported).toBeFalse(); + }); + + it('maps textview textDecoration values through the element class applier', () => { + const id = createRootTestElement('textview'); + const element = getNode(id).htmlElement; + + tree.setAttributeOnElement(id, 'textDecoration', 'underline'); + tree.flush(); + expect(element.style.textDecorationLine).toBe('underline'); + expect(element.style.textDecorationStyle).toBe(''); + + tree.setAttributeOnElement(id, 'textDecoration', 'dashed-underline'); + tree.flush(); + expect(element.style.textDecorationLine).toBe('underline'); + expect(element.style.textDecorationStyle).toBe('dashed'); + + tree.setAttributeOnElement(id, 'textDecoration', 'dotted-underline'); + tree.flush(); + expect(element.style.textDecorationLine).toBe('underline'); + expect(element.style.textDecorationStyle).toBe('dotted'); + + tree.setAttributeOnElement(id, 'textDecoration', 'strikethrough'); + tree.flush(); + expect(element.style.textDecorationLine).toBe('line-through'); + expect(element.style.textDecorationStyle).toBe(''); + + tree.setAttributeOnElement(id, 'textDecoration', 'none'); + tree.flush(); + expect(element.style.textDecorationLine).toBe('none'); + expect(element.style.textDecorationStyle).toBe(''); + }); + + it('renders image assets with logical 3x dimensions for objectFit variants', () => { + const id = createRootTestElement('image'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 200; + element.rectHeight = 200; + + tree.setAttributeOnElement(id, 'src', 'test.png'); + tree.flush(); + triggerImageLoad(300, 150); + const image = getLastImage(); + + tree.setAttributeOnElement(id, 'objectFit', 'none'); + tree.flush(); + expect(image.style.width).toBe('300px'); + expect(image.style.height).toBe('150px'); + + tree.setAttributeOnElement(id, 'objectFit', 'contain'); + tree.flush(); + expect(image.style.width).toBe('200px'); + expect(image.style.height).toBe('100px'); + + tree.setAttributeOnElement(id, 'objectFit', 'cover'); + tree.flush(); + expect(image.style.width).toBe('400px'); + expect(image.style.height).toBe('200px'); + + element.rectWidth = 50; + element.rectHeight = 50; + tree.setAttributeOnElement(id, 'objectFit', 'scale-down'); + tree.flush(); + expect(image.style.width).toBe('50px'); + expect(image.style.height).toBe('25px'); + }); + + it('defers loaded image geometry reads until the centralized layout pass', () => { + let runLayoutPass: (() => void) | undefined; + tree.setPostLayoutScheduler(callback => { + runLayoutPass = callback; + }); + const id = createRootTestElement('image'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 200; + element.rectHeight = 100; + + tree.setAttributeOnElement(id, 'src', 'test.png'); + tree.flush(); + triggerImageLoad(300, 150); + + expect(element.rectReadCount).toBe(0); + expect(element.childNodes.length).toBe(0); + expect(runLayoutPass).toBeDefined(); + + runLayoutPass!(); + expect(element.rectReadCount).toBe(1); + expect(element.querySelector('img')).toBe(getLastImage()); + }); + + it('uses SVG viewBox dimensions as logical image dimensions', async () => { + const id = createRootTestElement('image'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 200; + element.rectHeight = 200; + const svg = ''; + let decodedWidth = -1; + let decodedHeight = -1; + + tree.setAttributeOnElement(id, 'onImageDecoded', (width: number, height: number) => { + decodedWidth = width; + decodedHeight = height; + }); + tree.setAttributeOnElement(id, 'src', `data:image/svg+xml,${encodeURIComponent(svg)}`); + tree.setAttributeOnElement(id, 'objectFit', 'none'); + tree.flush(); + triggerImageLoad(300, 150); + + expect(getLastImage().style.width).toBe('360px'); + expect(getLastImage().style.height).toBe('240px'); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(decodedWidth).toBe(360); + expect(decodedHeight).toBe(240); + }); + + it('resolves renderable path-only Asset objects for image sources', () => { + const id = createRootTestElement('image'); + + tree.setAttributeOnElement(id, 'src', { path: 'asset-from-path.png', width: 120, height: 80 }); + tree.flush(); + + expect(getLastImage().src).toBe('asset-from-path.png'); + + tree.setAttributeOnElement(id, 'src', { path: 'image', src: { default: 'asset-from-src.png' } }); + tree.flush(); + + expect(getLastImage().src).toBe('asset-from-src.png'); + + const logicalId = createRootTestElement('image'); + tree.setAttributeOnElement(logicalId, 'src', { path: 'image', width: 120, height: 80 }); + tree.flush(); + + expect(getNode(logicalId).htmlElement.childNodes.length).toBe(0); + }); + + it('only applies shape stroke dash attributes for partial strokes', () => { + const id = createRootTestElement('shape'); + tree.setAttributeOnElement(id, 'path', 'M 0 0 L 100 0'); + tree.flush(); + + const path = getNode(id).htmlElement.querySelector('path')!; + expect(path.getAttribute('stroke-dasharray')).toBeNull(); + expect(path.getAttribute('stroke-dashoffset')).toBeNull(); + + tree.setAttributeOnElement(id, 'strokeStart', 0.25); + tree.setAttributeOnElement(id, 'strokeEnd', 0.75); + tree.flush(); + + expect(path.getAttribute('stroke-dasharray')).toBe('50 100'); + expect(path.getAttribute('stroke-dashoffset')).toBe('-25'); + }); + + it('applies image contentRotation as a CSS transform', () => { + const id = createRootTestElement('image'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 220; + element.rectHeight = 140; + + tree.setAttributeOnElement(id, 'src', 'test.png'); + tree.setAttributeOnElement(id, 'contentRotation', 0.24); + tree.flush(); + triggerImageLoad(300, 150); + + expect(getLastImage().style.transform).toContain('rotate(0.24rad)'); + }); + + it('mirrors the image element when flipOnRtl is set under RTL layout', () => { + const windowStub = (globalThis as { window?: { getComputedStyle?: (element: unknown) => { direction: string } } }) + .window; + if (windowStub) { + windowStub.getComputedStyle = () => ({ direction: 'rtl' }); + } + const id = createRootTestElement('image'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 220; + element.rectHeight = 140; + + tree.setAttributeOnElement(id, 'src', 'test.png'); + tree.setAttributeOnElement(id, 'flipOnRtl', true); + tree.flush(); + triggerImageLoad(300, 150); + + expect(getLastImage().style.transform).toContain('scale(-1, 1)'); + }); + + it('reports image decode/load callbacks asynchronously and auto-sizes using logical 3x dimensions', (done: DoneFn) => { + const id = createRootTestElement('image'); + const element = getNode(id).htmlElement; + let decodedWidth = -1; + let decodedHeight = -1; + let assetLoadSuccess = false; + + tree.setAttributeOnElement(id, 'onAssetLoad', (success: boolean) => { + assetLoadSuccess = success; + }); + tree.setAttributeOnElement(id, 'onImageDecoded', (width: number, height: number) => { + decodedWidth = width; + decodedHeight = height; + }); + tree.setAttributeOnElement(id, 'src', 'test.png'); + tree.flush(); + triggerImageLoad(300, 150); + + expect(element.style.width).toBe('100px'); + expect(element.style.height).toBe('50px'); + setTimeout(() => { + expect(assetLoadSuccess).toBeTrue(); + expect(decodedWidth).toBe(300); + expect(decodedHeight).toBe(150); + done(); + }, 0); + }); + + it('reports image load failures asynchronously', (done: DoneFn) => { + const id = createRootTestElement('image'); + const element = getNode(id).htmlElement as unknown as FakeElement; + let assetLoadSuccess = true; + let assetLoadError = ''; + + element.rectWidth = 100; + element.rectHeight = 50; + tree.setAttributeOnElement(id, 'src', 'test.png'); + tree.flush(); + triggerImageLoad(300, 150); + expect(element.querySelector('img')).not.toBeNull(); + + tree.setAttributeOnElement(id, 'onAssetLoad', (success: boolean, error: string | undefined) => { + assetLoadSuccess = success; + assetLoadError = error ?? ''; + }); + tree.setAttributeOnElement(id, 'src', 'missing.png'); + tree.flush(); + triggerImageError(); + + expect(element.childNodes.length).toBe(0); + + setTimeout(() => { + expect(assetLoadSuccess).toBeFalse(); + expect(assetLoadError).toBe('Failed to load image'); + done(); + }, 0); + }); + + it('loads ordinary remote images directly without CORS', () => { + const id = createRootTestElement('image'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 100; + element.rectHeight = 50; + + tree.setAttributeOnElement(id, 'src', 'https://example.test/image.png'); + tree.flush(); + expect(getLastImage().crossOrigin).toBeNull(); + expect(getLastImage().src).toBe('https://example.test/image.png'); + + triggerImageLoad(300, 150); + expect(element.querySelector('img')).toBe(getLastImage()); + expect(element.querySelector('canvas')).toBeNull(); + expect(getLastImage().style.transform ?? '').toBe(''); + }); + + it('treats malformed absolute image URLs as cross-origin without throwing', () => { + const windowStub = (globalThis as { window?: { location?: { href: string; origin: string } } }).window; + expect(windowStub).toBeDefined(); + windowStub!.location = { href: 'https://app.example/', origin: 'https://app.example' }; + const warnSpy = spyOn(console, 'warn'); + const id = createRootTestElement('image'); + + tree.setAttributeOnElement(id, 'src', 'https://[invalid'); + tree.flush(); + + expect(getLastImage().src).toBe('https://[invalid'); + expect(warnSpy).toHaveBeenCalledWith( + jasmine.stringMatching('Valdi web renderer could not parse image URL for origin comparison'), + ); + }); + + it('coalesces resolved image attributes into one render configuration', () => { + const id = createRootTestElement('image'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 100; + element.rectHeight = 50; + + tree.setAttributeOnElement(id, 'src', 'https://example.test/image.png'); + tree.setAttributeOnElement(id, 'objectFit', 'contain'); + tree.setAttributeOnElement(id, 'contentScaleX', 2); + tree.setAttributeOnElement(id, 'tint', '#ff0000'); + tree.flush(); + + expect(imageConstructionCount).toBe(1); + expect(getLastImage().crossOrigin).toBe('anonymous'); + expect(element.querySelector('canvas')).not.toBeNull(); + }); + + it('reuses a canvas-safe image when a pixel effect is enabled', () => { + const id = createRootTestElement('image'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 100; + element.rectHeight = 50; + + tree.setAttributeOnElement(id, 'src', 'test.png'); + tree.flush(); + triggerImageLoad(300, 150); + const image = getLastImage(); + + tree.setAttributeOnElement(id, 'tint', '#ff0000'); + tree.flush(); + + expect(getLastImage()).toBe(image); + expect(element.querySelector('img')).toBeNull(); + expect(element.querySelector('canvas')!.canvasContext.drawImage).toHaveBeenCalled(); + }); + + it('swaps to a CORS-enabled canvas implementation only for pixel effects', () => { + const id = createRootTestElement('image'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 100; + element.rectHeight = 50; + + tree.setAttributeOnElement(id, 'src', 'https://example.test/image.png'); + tree.flush(); + triggerImageLoad(300, 150); + const displayImage = getLastImage(); + expect(displayImage.crossOrigin).toBeNull(); + expect(element.querySelector('img')).toBe(displayImage); + + tree.setAttributeOnElement(id, 'tint', '#ff0000'); + tree.flush(); + const canvasImage = getLastImage(); + expect(canvasImage).not.toBe(displayImage); + expect(canvasImage.crossOrigin).toBe('anonymous'); + expect(element.querySelector('canvas')).not.toBeNull(); + expect(element.querySelector('img')).toBeNull(); + + triggerImageLoad(300, 150); + const canvas = element.querySelector('canvas')!; + expect(canvas.canvasContext.drawImage).toHaveBeenCalled(); + + tree.setAttributeOnElement(id, 'tint', undefined); + tree.flush(); + expect(element.querySelector('img')).toBe(canvasImage); + expect(element.querySelector('canvas')).toBeNull(); + }); + + it('clears the active image implementation when the source is removed', () => { + const id = createRootTestElement('image'); + const element = getNode(id).htmlElement as unknown as FakeElement; + element.rectWidth = 100; + element.rectHeight = 50; + + tree.setAttributeOnElement(id, 'src', 'test.png'); + tree.flush(); + triggerImageLoad(300, 150); + expect(element.childNodes.length).toBe(1); + + tree.setAttributeOnElement(id, 'src', undefined); + tree.flush(); + + expect(element.childNodes.length).toBe(0); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/WebRendererRoot.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/WebRendererRoot.spec.ts new file mode 100644 index 000000000..832d5d888 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/WebRendererRoot.spec.ts @@ -0,0 +1,69 @@ +import 'jasmine/src/jasmine'; +import { createIsolatedWebRendererRoot } from '../src/WebRendererRoot'; + +class FakeShadowRoot { + children: FakeElement[] = []; + + replaceChildren(...children: FakeElement[]): void { + this.children = children; + } +} + +interface FakeElement { + textContent?: string; + style: Record; +} + +function makeFakeElement(): FakeElement { + return { style: {} }; +} + +describe('WebRendererRoot', () => { + const previousDocument = globalThis.document; + const previousShadowRoot = globalThis.ShadowRoot; + + beforeEach(() => { + (globalThis as unknown as { ShadowRoot: typeof FakeShadowRoot }).ShadowRoot = FakeShadowRoot; + (globalThis as unknown as { document: { createElement(): FakeElement } }).document = { + createElement: makeFakeElement, + }; + }); + + afterEach(() => { + (globalThis as unknown as { document: Document }).document = previousDocument; + (globalThis as unknown as { ShadowRoot: typeof ShadowRoot }).ShadowRoot = previousShadowRoot; + }); + + it('mounts the renderer in an open shadow root with isolated inherited styles', () => { + const shadowRoot = new FakeShadowRoot(); + let attachedMode: ShadowRootMode | undefined; + const host = { + shadowRoot: null, + attachShadow(init: ShadowRootInit) { + attachedMode = init.mode; + return shadowRoot; + }, + }; + + const root = createIsolatedWebRendererRoot(host as unknown as HTMLElement) as unknown as FakeElement; + + expect(attachedMode).toBe('open'); + expect(shadowRoot.children.length).toBe(2); + expect(shadowRoot.children[0].textContent).toContain('box-sizing: border-box'); + expect(shadowRoot.children[1]).toBe(root); + expect(root.style.all).toBe('initial'); + expect(root.style.direction).toBeUndefined(); + expect(root.style.display).toBe('block'); + expect(root.style.fontFamily).toBeUndefined(); + expect(root.style.height).toBe('100%'); + expect(root.style.width).toBe('100%'); + }); + + it('uses a supplied shadow root without creating another one', () => { + const shadowRoot = new FakeShadowRoot(); + + const root = createIsolatedWebRendererRoot(shadowRoot as unknown as ShadowRoot) as unknown as FakeElement; + + expect(shadowRoot.children[1]).toBe(root); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/assetSource.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/assetSource.spec.ts new file mode 100644 index 000000000..9e58f4ffd --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/assetSource.spec.ts @@ -0,0 +1,17 @@ +import 'jasmine/src/jasmine'; +import { resolveAssetSourceUrl, resolveRenderableAssetSource } from '../src/utils/assetSource'; + +describe('assetSource', () => { + it('resolves nested asset source fields consistently', () => { + expect(resolveAssetSourceUrl({ src: { default: 'asset.png' } })).toBe('asset.png'); + expect(resolveAssetSourceUrl({ href: { url: 'https://example.test/asset.png' } })).toBe( + 'https://example.test/asset.png', + ); + }); + + it('falls back to renderable asset paths only for renderer sources', () => { + expect(resolveAssetSourceUrl({ path: 'asset.png' })).toBeUndefined(); + expect(resolveRenderableAssetSource({ path: 'asset.png' })).toBe('asset.png'); + expect(resolveRenderableAssetSource({ path: 'asset-without-extension' })).toBeUndefined(); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/cssColor.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/cssColor.spec.ts new file mode 100644 index 000000000..0f7a56fc2 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/cssColor.spec.ts @@ -0,0 +1,10 @@ +import 'jasmine/src/jasmine'; +import { applyCssColorOpacity, parseCssColor } from '../src/utils/cssColor'; + +describe('cssColor', () => { + it('parses and applies CSS color opacity without regex-specific assumptions', () => { + expect(parseCssColor('#0f8')).toEqual({ r: 0, g: 255, b: 136, a: 1 }); + expect(parseCssColor('rgba(260, -4, 10.4, 0.25)')).toEqual({ r: 255, g: 0, b: 10, a: 0.25 }); + expect(applyCssColorOpacity('rgba(10, 20, 30, 0.8)', '0.5')).toBe('rgba(10, 20, 30, 0.4)'); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/cssFunction.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/cssFunction.spec.ts new file mode 100644 index 000000000..f09fe6234 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/cssFunction.spec.ts @@ -0,0 +1,19 @@ +import 'jasmine/src/jasmine'; +import { parseCssFunction } from '../src/utils/cssFunction'; + +describe('cssFunction', () => { + it('parses CSS functions with nested functions and quoted commas', () => { + const parsed = parseCssFunction(' linear-gradient(45deg, rgba(10, 20, 30, 0.5), "literal, comma") '); + + expect(parsed).toEqual({ + name: 'linear-gradient', + parameters: ['45deg', 'rgba(10, 20, 30, 0.5)', '"literal, comma"'], + }); + }); + + it('rejects malformed CSS function text', () => { + expect(parseCssFunction('rgba(1, 2, 3')).toBeUndefined(); + expect(parseCssFunction('rgba(1, 2, 3))')).toBeUndefined(); + expect(parseCssFunction('not a function')).toBeUndefined(); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/cssScanner.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/cssScanner.spec.ts new file mode 100644 index 000000000..080014d53 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/cssScanner.spec.ts @@ -0,0 +1,39 @@ +import 'jasmine/src/jasmine'; +import { + consumeCssNumber, + isPlainCssNumber, + parseCssFunction, + parseCssFunctionCall, + readPreviousWhitespaceSeparatedToken, + readWhitespaceSeparatedToken, +} from '../src/utils/cssScanner'; + +describe('cssScanner', () => { + it('consumes plain CSS numbers', () => { + expect(consumeCssNumber('-12.5px', 0)).toBe(5); + expect(consumeCssNumber('.5', 0)).toBe(2); + expect(isPlainCssNumber('12.5')).toBeTrue(); + expect(isPlainCssNumber('12px')).toBeFalse(); + }); + + it('reads forward and backward whitespace-separated tokens', () => { + expect(readWhitespaceSeparatedToken(' one two ', 0)).toEqual({ token: 'one', startIndex: 2, nextIndex: 5 }); + expect(readPreviousWhitespaceSeparatedToken(' one two ', 10)).toEqual({ + token: 'two', + startIndex: 6, + nextIndex: 9, + }); + }); + + it('parses CSS function calls with nested parameters', () => { + const call = parseCssFunctionCall(' repeat(2, minmax(40px, 1fr) 20px) trailing', 0); + + expect(call?.name).toBe('repeat'); + expect(call?.parameters).toEqual(['2', 'minmax(40px, 1fr) 20px']); + expect(call?.nextIndex).toBe(35); + expect(parseCssFunction('rgba(1, 2, calc(3 + 4))')).toEqual({ + name: 'rgba', + parameters: ['1', '2', 'calc(3 + 4)'], + }); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/geometricPath.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/geometricPath.spec.ts new file mode 100644 index 000000000..74dbc3b6b --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/geometricPath.spec.ts @@ -0,0 +1,41 @@ +import 'jasmine/src/jasmine'; +import { GeometricPathBuilder, GeometricPathScaleType } from 'valdi_core/src/GeometricPath'; +import { geometricPathToSvgPath } from '../src/utils/geometricPath'; + +describe('geometricPath', () => { + it('generates preserveAspectRatio for cover paths and SVG commands for curves', () => { + const path = new GeometricPathBuilder(80, 40, GeometricPathScaleType.Cover) + .moveTo(2, 4) + .quadTo(6, 8, 10, 12) + .cubicTo(1, 2, 3, 4, 5, 6) + .close() + .build(); + + const svgPath = geometricPathToSvgPath(path); + + expect(svgPath.viewBox).toBe('0 0 80 40'); + expect(svgPath.preserveAspectRatio).toBe('xMidYMid slice'); + expect(svgPath.d).toBe('M 2 4 Q 6 8 10 12 C 1 2 3 4 5 6 Z'); + }); + + it('clamps round rect radii and emits negative sweep arcs', () => { + const path = new GeometricPathBuilder(20, 20, GeometricPathScaleType.Fill) + .roundRectTo(0, 0, 10, 6, 20, 20) + .arcTo(5, 5, 4, 0, -Math.PI / 2) + .build(); + + const svgPath = geometricPathToSvgPath(path); + + expect(svgPath.preserveAspectRatio).toBe('none'); + expect(svgPath.d).toContain('M 5 0 L 5 0 Q 10 0 10 3'); + expect(svgPath.d).toContain('A 4 4 0 0 0'); + }); + + it('returns an empty fallback for invalid geometric path data', () => { + expect(geometricPathToSvgPath(Float64Array.from([10, 20, 999]))).toEqual({ + d: '', + viewBox: '0 0 1 1', + preserveAspectRatio: 'none', + }); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/imageFilterOperations.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/imageFilterOperations.spec.ts new file mode 100644 index 000000000..2cb13c6bd --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/imageFilterOperations.spec.ts @@ -0,0 +1,33 @@ +import 'jasmine/src/jasmine'; +import { + applyColorMatrixToImageData, + applyTintToImageData, + parseImageFilterOperations, +} from '../src/utils/imageFilterOperations'; + +describe('imageFilterOperations', () => { + it('parses serialized blur and color matrix operations', () => { + const identityMatrix = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0]; + + expect(parseImageFilterOperations([1, 3, 2, ...identityMatrix])).toEqual([ + { type: 'blur', radius: 3 }, + { type: 'colorMatrix', matrix: identityMatrix }, + ]); + expect(parseImageFilterOperations('1,3')).toEqual([{ type: 'blur', radius: 3 }]); + expect(parseImageFilterOperations('2,1,2')).toBeUndefined(); + }); + + it('applies tint and color matrix operations to image data', () => { + const imageData = { + data: new Uint8ClampedArray([10, 20, 30, 255, 4, 5, 6, 0]), + } as ImageData; + + applyTintToImageData(imageData, { r: 100, g: 120, b: 140, a: 0.5 }); + + expect(Array.from(imageData.data)).toEqual([100, 120, 140, 128, 4, 5, 6, 0]); + + applyColorMatrixToImageData(imageData, [0, 0, 0, 0, 1, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0]); + + expect(Array.from(imageData.data)).toEqual([255, 128, 0, 128, 255, 128, 0, 0]); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/imageSource.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/imageSource.spec.ts new file mode 100644 index 000000000..783e98537 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/imageSource.spec.ts @@ -0,0 +1,34 @@ +import 'jasmine/src/jasmine'; +import { detectImageMimeType, svgViewBoxIntrinsicSize } from '../src/utils/imageSource'; + +describe('imageSource', () => { + it('detects the MIME types supported by byte-backed image assets', () => { + const textEncoder = new TextEncoder(); + + expect(detectImageMimeType(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))).toBe( + 'image/png', + ); + expect(detectImageMimeType(new Uint8Array([0xff, 0xd8, 0xff]))).toBe('image/jpeg'); + expect(detectImageMimeType(textEncoder.encode('GIF89a'))).toBe('image/gif'); + expect(detectImageMimeType(textEncoder.encode('RIFF1234WEBP'))).toBe('image/webp'); + expect(detectImageMimeType(textEncoder.encode(' \n'))).toBe('image/svg+xml'); + expect(detectImageMimeType(textEncoder.encode(''))).toBe( + 'image/svg+xml', + ); + expect(detectImageMimeType(textEncoder.encode('{"value":1}'))).toBe('application/octet-stream'); + }); + + it('reads SVG viewBox intrinsic size from text data URLs', () => { + const svg = ''; + + expect(svgViewBoxIntrinsicSize(`data:image/svg+xml,${encodeURIComponent(svg)}`)).toEqual({ + width: 120, + height: 80, + }); + }); + + it('rejects missing or invalid SVG viewBox data', () => { + expect(svgViewBoxIntrinsicSize('asset.svg')).toBeUndefined(); + expect(svgViewBoxIntrinsicSize('data:image/svg+xml,')).toBeUndefined(); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/parseAttributedText.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/parseAttributedText.spec.ts new file mode 100644 index 000000000..b25397d97 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/parseAttributedText.spec.ts @@ -0,0 +1,138 @@ +import 'jasmine/src/jasmine'; +import { AttributedTextBuilder } from 'valdi_core/src/utils/AttributedTextBuilder'; +import { AttributedText, AttributedTextEntryType } from 'valdi_tsx/src/AttributedText'; +import { AttributedTextInlineViewVerticalAlignment } from 'valdi_tsx/src/AttributedTextInlineViewAttachment'; +import { ParsedAttributedText } from '../src/utils/parseAttributedText'; + +describe('parseAttributedText', () => { + it('parses attributed text into styled parts and plain text', () => { + const attributedText = new AttributedTextBuilder() + .append('plain ') + .pushColor('#111111') + .append('colored') + .pushFont('system-bold 18') + .append(' bold') + .pop() + .append(' still colored') + .pop() + .build(); + + const parsed = ParsedAttributedText.parse(attributedText); + + expect(parsed.toString()).toBe('plain colored bold still colored'); + expect(parsed.parts.map(part => part.content)).toEqual(['plain ', 'colored', ' bold', ' still colored']); + expect(parsed.parts.map(part => part.style.color)).toEqual([undefined, '#111111', '#111111', '#111111']); + expect(parsed.parts.map(part => part.style.font)).toEqual([undefined, undefined, 'system-bold 18', undefined]); + expect(parsed.hasOnLayout).toBeFalse(); + }); + + it('records whether any parsed part has an onLayout callback', () => { + const parsed = ParsedAttributedText.parse( + new AttributedTextBuilder() + .append('plain') + .append('measured', { + onLayout() {}, + }) + .build(), + ); + + expect(parsed.hasOnLayout).toBeTrue(); + }); + + it('parses inline view attachments without adding plain text content', () => { + const attributedText = new AttributedTextBuilder() + .append('before ') + .appendInlineView(0, AttributedTextInlineViewVerticalAlignment.Baseline) + .append(' after') + .build(); + + const parsed = ParsedAttributedText.parse(attributedText); + + expect(parsed.toString()).toBe('before after'); + expect(parsed.parts.length).toBe(3); + expect(parsed.parts[1].style.inlineView?.childIndex).toBe(0); + expect(parsed.parts[1].style.inlineView?.verticalAlignment).toBe( + AttributedTextInlineViewVerticalAlignment.Baseline, + ); + }); + + it('preserves text-bottom inline view attachment alignment', () => { + const attributedText = new AttributedTextBuilder() + .appendInlineView(0, AttributedTextInlineViewVerticalAlignment.TextBottom) + .build(); + + const parsed = ParsedAttributedText.parse(attributedText); + + expect(parsed.parts[0].style.inlineView?.verticalAlignment).toBe( + AttributedTextInlineViewVerticalAlignment.TextBottom, + ); + }); + + it('preserves normalized animation transform metadata on styled parts', () => { + const attributedText = new AttributedTextBuilder() + .append('animated', { + animationTransform: { + key: 'intro', + opacity: 0, + partPattern: '\\S+', + }, + }) + .build(); + + const parsed = ParsedAttributedText.parse(attributedText); + const transform = parsed.parts[0].style.animationTransform; + + expect(transform?.key).toBe('intro'); + expect(transform?.opacity).toBe(0); + expect(transform?.translationY).toBe(0); + expect(transform?.scale).toBe(1); + expect(transform?.duration).toBe(0.35); + expect(transform?.timeOffsetBetweenParts).toBe(0); + expect(transform?.groupIndex).toBe(0); + expect(transform?.partIndexInGroup).toBe(0); + expect(transform?.partPattern).toBe('\\S+'); + }); + + it('logs invalid animation transform payloads and keeps the pushed style frame balanced', () => { + const errorSpy = spyOn(console, 'error'); + const attributedText = [ + AttributedTextEntryType.PushColor, + '#123456', + AttributedTextEntryType.PushAnimationTransform, + 'invalid', + AttributedTextEntryType.Content, + 'plain', + AttributedTextEntryType.Pop, + AttributedTextEntryType.Content, + ' still colored', + AttributedTextEntryType.Pop, + ] as AttributedText; + + const parsed = ParsedAttributedText.parse(attributedText); + + expect(errorSpy).toHaveBeenCalled(); + expect(String(errorSpy.calls.mostRecent().args[0])).toContain('Invalid text animation transform'); + expect(errorSpy.calls.mostRecent().args[1]).toBe('invalid'); + expect(parsed.parts.map(part => part.style.animationTransform)).toEqual([undefined, undefined]); + expect(parsed.parts.map(part => part.style.color)).toEqual(['#123456', '#123456']); + }); + + it('keeps animation part indexes continuous for parts in the same transform group', () => { + const attributedText = new AttributedTextBuilder() + .pushAnimationTransform({ + opacity: 0, + timeOffsetBetweenParts: 0.1, + }) + .append('one') + .append('two') + .pop() + .build(); + + const parsed = ParsedAttributedText.parse(attributedText); + + expect(parsed.parts[0].style.animationTransform?.groupIndex).toBe(0); + expect(parsed.parts[0].style.animationTransform?.partIndexInGroup).toBe(0); + expect(parsed.parts[1].style.animationTransform?.groupIndex).toBe(0); + expect(parsed.parts[1].style.animationTransform?.partIndexInGroup).toBe(1); + }); +}); From d158115bdc61befd93dd0cb4626272ced6061951 Mon Sep 17 00:00:00 2001 From: Simon Corsin Date: Tue, 18 Aug 2026 13:05:43 -0500 Subject: [PATCH 08/10] Fix Valdi CLI OSS assertion linkage --- valdi/BUILD.bazel | 1 + 1 file changed, 1 insertion(+) diff --git a/valdi/BUILD.bazel b/valdi/BUILD.bazel index c3136c30d..64833e1c7 100644 --- a/valdi/BUILD.bazel +++ b/valdi/BUILD.bazel @@ -409,6 +409,7 @@ cc_library( deps = [ ":valdi_quickjs", ":valdi_standalone_runtime", + "//libs/utils:utils_oss_cc", ], ) From cc437530b5ea8c8fb4ac033015d2e2289944c2ce Mon Sep 17 00:00:00 2001 From: Simon Corsin Date: Tue, 18 Aug 2026 13:16:42 -0500 Subject: [PATCH 09/10] Add complete Valdi Web platform support --- BUILD.bazel | 6 + .../skills/valdi-polyglot-module/skill.md | 6 +- .../src/valdi/hello_world/web/CppModule.ts | 8 +- .../src/valdi/hello_world/web/NativeModule.ts | 6 +- .../src/valdi/hello_world/web/tsconfig.json | 3 +- apps/integration_test/AGENTS.md | 145 + apps/integration_test/BUILD.bazel | 31 + apps/integration_test/src/android/BUILD.bazel | 13 + .../src/android/IntegrationTestHostFactory.kt | 239 + apps/integration_test/src/cpp/BUILD.bazel | 27 + .../src/cpp/ImageDiffNative.cpp | 264 + .../src/cpp/IntegrationTestHost.cpp | 63 + apps/integration_test/src/ios/BUILD.bazel | 14 + .../src/ios/SCIntegrationTestHostFactory.m | 252 + .../valdi/integration_test_app/BUILD.bazel | 59 + .../integration_test_app/res/animation.json | 52 + .../valdi/integration_test_app/res/image.svg | 9 + .../integration_test_app/res/tint_mask.svg | 5 + .../src/FactoryIntegrationHost.d.ts | 8 + .../src/IntegrationTestApp.tsx | 192 + .../src/IntegrationTestCases.tsx | 5552 +++++++++++++++++ .../src/IntegrationTestHost.d.ts | 32 + .../src/IntegrationTestRunner.ts | 336 + .../src/IntegrationTestTypes.ts | 74 + .../src/RenderedNodeOutput.ts | 25 + .../src/WebWorkerProbe.ts | 12 + .../web/FactoryIntegrationHost.ts | 66 + .../web/IntegrationTestHost.ts | 222 + .../integration_test_app/web/tsconfig.json | 16 + .../valdi/integration_test_cli/BUILD.bazel | 19 + .../src/ImageDiffNative.d.ts | 20 + .../valdi/integration_test_cli/src/compare.ts | 293 + .../src/exportSnapshots.ts | 233 + .../src/fullComparison.ts | 64 + .../integration_test_cli/src/htmlReport.ts | 946 +++ .../valdi/integration_test_cli/src/main.ts | 87 + .../src/valdi/integration_test_cli/src/png.ts | 117 + .../src/valdi/integration_test_cli/src/run.ts | 380 ++ .../integration_test_cli/src/selfTest.ts | 186 + .../valdi/integration_test_cli/src/types.ts | 27 + .../valdi/integration_test_cli/src/webRun.ts | 536 ++ apps/ssr_example/BUILD.bazel | 18 + apps/ssr_example/index.tsx | 43 + apps/valdi_gpt/web_demo/src/App.js | 6 +- .../web_demo/src/RegisterNativeModules.js | 14 - bzl/valdi/app_templates/BUILD.bazel | 4 + bzl/valdi/app_templates/web_index.html.tpl | 26 + bzl/valdi/app_templates/web_index.js.tpl | 11 + .../web_path_browserify_shim.js.tpl | 31 + .../app_templates/web_webpack.config.js.tpl | 88 + bzl/valdi/npm/package.json | 1 + bzl/valdi/npm/pnpm-lock.yaml | 8 + bzl/valdi/package.json.tmpl | 11 +- bzl/valdi/valdi_application.bzl | 10 + bzl/valdi/valdi_collapse_web_paths.bzl | 925 +-- bzl/valdi/valdi_compiled.bzl | 217 +- bzl/valdi/valdi_exported_library.bzl | 66 +- bzl/valdi/valdi_module.bzl | 88 +- bzl/valdi/valdi_module_info_extractor.bzl | 7 +- bzl/valdi/valdi_paths.bzl | 5 +- bzl/valdi/valdi_test.bzl | 2 +- bzl/valdi/valdi_web_application.bzl | 180 + bzl/valdi/valdi_web_package.bzl | 107 + bzl/valdi/valdi_web_workers.bzl | 14 + .../src/ConsoleLogTransformer.spec.ts | 56 +- .../companion/src/ConsoleLogTransformer.ts | 58 +- compiler/companion/src/JSXProcessor.spec.ts | 28 + compiler/companion/src/JSXProcessor.ts | 36 +- .../Compiler/Sources/BundleManager.swift | 2 + .../Sources/Config/CompilerConfig.swift | 4 + .../Config/WebNativeModuleIdOverride.swift | 30 + .../Generation/Cpp/CppModuleGenerator.swift | 4 +- .../Sources/Pipeline/CompilationItem.swift | 3 + .../GenerateModuleBuildFileProcessor.swift | 32 +- ...WebNativeModulePackageFilesProcessor.swift | 133 + .../Sources/Reloader/AutoRecompiler.swift | 6 +- .../Reloader/HotReloadLifecycleReporter.swift | 57 + .../Utils/Extensions/URL+Navigation.swift | 7 +- .../Sources/ValdiCompilerArguments.swift | 6 + .../Sources/ValdiCompilerRunner.swift | 9 +- .../HotReloadArgumentsTests.swift | 15 + .../HotReloadLifecycleReporterTests.swift | 31 + .../WebNativeModuleIdOverrideTests.swift | 20 + docs/README.md | 3 + docs/docs/command-line-references.md | 24 +- docs/docs/native-customviews.md | 8 +- docs/docs/performance-tracing.md | 44 + docs/docs/stdlib-persistence.md | 51 +- docs/docs/workflow-server-side-rendering.md | 50 + docs/docs/workflow-web-development.md | 56 + npm_modules/cli/README.md | 7 +- npm_modules/cli/src/commands/build.ts | 6 + npm_modules/cli/src/commands/export.ts | 85 +- npm_modules/cli/src/commands/hotreload.ts | 231 +- npm_modules/cli/src/commands/install.ts | 94 +- npm_modules/cli/src/core/constants.ts | 13 +- npm_modules/cli/src/utils/applicationUtils.ts | 21 +- npm_modules/cli/src/utils/directorySync.ts | 188 + npm_modules/cli/src/utils/processUtils.ts | 12 + .../src/utils/webApplicationSession.spec.ts | 68 + .../cli/src/utils/webApplicationSession.ts | 75 + .../cli/src/utils/webHotReloadSession.spec.ts | 90 + .../cli/src/utils/webHotReloadSession.ts | 264 + npm_modules/cli/src/utils/webServerUtils.ts | 229 + npm_modules/cli/src/utils/zipUtils.spec.ts | 74 + npm_modules/cli/src/utils/zipUtils.ts | 26 +- npm_modules/cli/test/export.spec.ts | 84 + npm_modules/cli/test/hotreload.spec.ts | 47 + .../src/snap_drawing/cpp/Utils/Image.cpp | 23 +- .../src/snap_drawing/cpp/Utils/Image.hpp | 2 + snap_drawing/test/src/ImageBitmap_tests.cpp | 16 + .../src/cpp/valdi_cli/BUILD.bazel | 11 + .../src/cpp/valdi_cli/ChildProcessNative.cpp | 577 ++ .../src/valdi/coreutils/BUILD.bazel | 4 +- .../src/valdi/coreutils/src/ByteBuffer.ts | 41 + .../valdi/coreutils/test/ByteBuffer.spec.ts | 44 + .../valdi/coreutils/test/TextCoding.spec.ts | 10 +- .../src/valdi/drawing/BUILD.bazel | 9 +- .../drawing/src/ManagedContextFactory.ts | 30 +- .../drawing/src/ManagedContextNative.d.ts | 13 - .../valdi/drawing/web/ManagedContextNative.ts | 14 +- .../src/valdi/file_system/BUILD.bazel | 2 - .../src/valdi/foundation/BUILD.bazel | 21 +- .../foundation/src/makePropertiesOpaque.ts | 4 +- .../src/valdi/persistence/BUILD.bazel | 19 +- .../valdi/persistence/src/PersistentStore.ts | 10 +- .../persistence/test/PersistentStoreTest.ts | 13 + .../test/WebPersistentStoreNativeTest.js | 248 + .../persistence/web/PersistentStoreNative.ts | 777 ++- .../src/valdi/persistence/web/tsconfig.json | 4 +- .../src/valdi/source_map/BUILD.bazel | 2 +- .../valdi/source_map/src/StackSymbolicator.ts | 27 +- .../valdi/source_map/test/SourceMap.spec.ts | 26 + .../src/valdi/valdi_cli/BUILD.bazel | 37 + .../src/valdi/valdi_cli/module.yaml | 4 + .../native_specs/ChildProcess.spec.ts | 67 + .../valdi/valdi_cli/src/ArgumentsParser.ts | 189 + .../src/valdi/valdi_cli/src/ChildProcess.ts | 144 + .../valdi_cli/src/ChildProcessNative.d.ts | 34 + .../src/valdi/valdi_cli/src/Path.ts | 119 + .../src/valdi/valdi_cli/src/Process.d.ts | 19 + .../src/valdi/valdi_cli/tsconfig.json | 8 + .../valdi/valdi_cli/web/ChildProcessNative.ts | 28 + .../src/valdi/valdi_cli/web/tsconfig.json | 11 + .../src/valdi/valdi_core/BUILD.bazel | 7 +- .../valdi/valdi_core/src/AnimationOptions.ts | 91 + .../src/valdi/valdi_core/src/Asset.ts | 5 +- .../src/valdi/valdi_core/src/AssetCatalog.ts | 5 +- .../src/valdi/valdi_core/src/BuildType.ts | 4 +- .../src/valdi/valdi_core/src/CSSModule.ts | 5 +- .../src/valdi/valdi_core/src/GeometricPath.ts | 88 + .../src/valdi/valdi_core/src/IRenderer.ts | 11 + .../valdi_core/src/IRendererDelegate.d.ts | 11 +- .../valdi_core/src/IViewNodeAssetTracker.d.ts | 43 + .../src/valdi/valdi_core/src/Init.js | 10 +- .../src/valdi/valdi_core/src/JSXBootstrap.ts | 20 +- .../valdi_core/src/JSXRendererDelegate.ts | 137 +- .../src/valdi/valdi_core/src/LazyImport.ts | 18 +- .../valdi_core/src/LocalizableStrings.ts | 4 +- .../src/valdi/valdi_core/src/ModuleLoader.ts | 19 +- .../valdi_core/src/ModuleLoaderGlobal.ts | 4 +- .../valdi/valdi_core/src/NativeReferences.ts | 4 +- .../src/valdi/valdi_core/src/PostInit.ts | 59 +- .../src/valdi/valdi_core/src/Renderer.ts | 187 +- .../valdi_core/src/RootComponentsManager.ts | 2 +- .../src/valdi/valdi_core/src/SetTimeout.ts | 4 +- .../valdi_core/src/UncaughtErrorHandler.ts | 27 +- .../src/valdi/valdi_core/src/Valdi.ts | 5 +- .../valdi/valdi_core/src/ValdiRuntime.d.ts | 8 +- .../valdi_core/src/ValdiRuntimeProvider.ts | 15 + .../src/ViewNodeAssetTracker.ts} | 37 +- .../src/debugging/DefaultErrorBoundary.tsx | 4 +- .../src/provider/GlobalProviderSource.ts | 6 +- .../valdi_core/src/utils/FunctionUtils.ts | 4 +- .../valdi/valdi_core/src/utils/NumberUtils.ts | 12 +- .../src/valdi/valdi_core/src/utils/OnIdle.ts | 4 +- .../valdi/valdi_core/src/utils/StringUtils.ts | 2 +- .../valdi/valdi_core/src/utils/TestUtils.ts | 6 +- .../src/valdi/valdi_core/src/utils/Trace.ts | 113 +- .../valdi/valdi_core/test/ModuleLoaderTest.ts | 5 +- .../test/ViewNodeAssetTracker.spec.ts} | 57 +- .../valdi/valdi_core/web/NumberFormatting.ts | 26 + .../src/valdi/valdi_http/BUILD.bazel | 1 + .../src/valdi/valdi_http/src/HTTPServer.ts | 459 ++ .../src/valdi/valdi_http/web/TCPSocket.ts | 3 + .../src/valdi/valdi_protobuf/BUILD.bazel | 6 +- .../src/valdi/valdi_ssr/BUILD.bazel | 18 + .../src/IValdiHTMLRendererListener.ts | 3 + .../src/RenderMutationCoordinator.ts | 76 + .../valdi/valdi_ssr/src/ValdiHTMLRenderer.ts | 87 + .../src/valdi/valdi_ssr/src/ValdiSSRRouter.ts | 191 + .../src/valdi/valdi_ssr/src/dom/ServerDOM.ts | 850 +++ .../valdi_ssr/src/dom/ServerDOMEnvironment.ts | 184 + .../src/dom/ServerStyleDeclaration.ts | 80 + .../src/serialization/ServerCSSScoper.ts | 33 + .../src/serialization/ServerHTMLSerializer.ts | 172 + .../valdi_ssr/test/ServerHTMLRenderer.spec.ts | 74 + .../src/valdi/valdi_ssr/tsconfig.json | 8 + .../valdi_standalone/src/JasmineBootstrap.ts | 35 +- .../valdi_standalone/src/ValdiStandalone.ts | 4 +- .../src/valdi/valdi_test/BUILD.bazel | 21 +- .../valdi/valdi_test/test/Component.spec.ts | 5 + .../valdi/valdi_test/test/MicroBenchmarks.ts | 4 +- .../valdi_test/test/ModuleLoader.spec.ts | 14 + .../valdi/valdi_test/test/Remember.spec.ts | 2 +- .../valdi/valdi_test/test/Renderer.spec.ts | 60 +- .../valdi_test/test/RendererTestDelegate.ts | 45 +- .../valdi/valdi_tsx/src/AttributedText.d.ts | 7 + .../src/valdi/valdi_web/BUILD.bazel | 2 +- .../src/valdi/valdi_webview/BUILD.bazel | 17 + .../valdi/valdi_webview/web/WebViewNative.ts | 147 + .../src/valdi/valdi_webview/web/tsconfig.json | 11 + .../src/valdi/web_renderer/BUILD.bazel | 10 + .../valdi/web_renderer/src/HTMLRenderer.ts | 115 - .../valdi/web_renderer/src/RouteRegistry.ts | 30 - .../web_renderer/src/ValdiWebRenderer.ts | 68 +- .../src/ValdiWebRendererDelegate.ts | 23 + .../valdi/web_renderer/src/ValdiWebRuntime.ts | 136 +- .../src/valdi/web_renderer/src/ViewFactory.ts | 12 + .../src/VisibilityObserverController.ts | 51 +- .../src/valdi/web_renderer/src/WebNavStack.ts | 495 -- .../valdi/web_renderer/src/WebNavigator.ts | 45 - .../valdi/web_renderer/src/WebRendererRoot.ts | 46 +- .../src/attributes/AttributesApplier.ts | 26 + .../web_renderer/src/core/ElementClass.ts | 2 + .../valdi/web_renderer/src/core/ViewNode.ts | 10 +- .../web_renderer/src/core/ViewNodeTree.ts | 42 +- .../src/elements/CustomViewElementClass.ts | 2 +- .../src/elements/ElementClassRegistry.ts | 13 +- .../src/elements/ElementClassSupport.ts | 10 +- .../src/elements/GlassElementClass.ts | 52 + .../web_renderer/src/elements/ImageElement.ts | 25 + .../src/elements/ImageElementClass.ts | 2 + .../src/elements/LabelElementClass.ts | 48 +- .../src/elements/ScrollElementClass.ts | 95 +- .../src/elements/TextFieldElementClass.ts | 14 +- .../src/elements/TextViewElementClass.ts | 110 +- .../src/elements/ViewElementAttributes.ts | 130 +- .../src/elements/ViewElementClass.ts | 15 +- .../src/elements/WebViewElementClass.ts | 47 +- .../src/navigation/WebNavigator.ts | 592 ++ .../web_renderer/src/styles/ValdiWebStyles.ts | 162 - .../src/styles/handleMarginPadding.ts | 40 - .../valdi/web_renderer/src/styles/isNumber.ts | 11 - .../src/styles/requiresUnitlessNumber.ts | 82 - .../src/utils/parseAttributedText.ts | 2 - .../src/views/WebValdiCustomView.ts | 76 - .../web_renderer/src/views/WebValdiImage.ts | 256 - .../web_renderer/src/views/WebValdiLabel.ts | 80 - .../web_renderer/src/views/WebValdiLayout.ts | 646 -- .../web_renderer/src/views/WebValdiScroll.ts | 277 - .../web_renderer/src/views/WebValdiShape.ts | 198 - .../web_renderer/src/views/WebValdiSpinner.ts | 151 - .../src/views/WebValdiTextField.ts | 558 -- .../src/views/WebValdiTextView.ts | 295 - .../web_renderer/src/views/WebValdiVideo.ts | 154 - .../web_renderer/src/views/WebValdiView.ts | 5 - .../test/AttributesBinder.spec.ts | 39 + .../test/TextAnimationController.spec.ts | 4 + .../test/ValdiWebRendererDelegate.spec.ts | 37 + .../test/VisibilityObserverController.spec.ts | 26 + .../web_renderer/test/WebNavigator.spec.ts | 357 ++ .../web_renderer/test/WebRendererCore.spec.ts | 763 ++- .../web_renderer/test/WebRendererRoot.spec.ts | 26 +- .../test/WebValdiComponents.spec.ts | 252 - .../web_renderer/test/WebValdiImage.spec.ts | 259 - .../test/WebValdiTextField.spec.ts | 171 - .../test/WebValdiTextView.spec.ts | 67 - .../test/parseAttributedText.spec.ts | 12 - .../src/valdi/worker/BUILD.bazel | 2 +- .../src/valdi/worker/src/Worker.ts | 9 +- .../src/valdi/worker/src/WorkerService.ts | 6 +- .../src/internal/ManagedWorkerService.ts | 30 +- tools/valdi_web_devtools/webpack.js | 2 +- valdi/BUILD.bazel | 1 + valdi/compiler/toolbox/BUILD.bazel | 11 + .../compiler_toolbox/CollapseWebPaths.cpp | 486 ++ .../compiler_toolbox/CollapseWebPaths.hpp | 18 + .../compiler_toolbox/CompilerToolbox.cpp | 54 +- .../compiler_toolbox/RewriteWebRequires.cpp | 333 + .../compiler_toolbox/RewriteWebRequires.hpp | 13 + .../compiler_toolbox/WebPackageUtils.cpp | 99 + .../compiler_toolbox/WebPackageUtils.hpp | 25 + .../toolbox/test/CollapseWebPaths_tests.cpp | 213 + .../toolbox/test/RewriteWebRequires_tests.cpp | 146 + .../ViewNodesAssetTrackerCallbackBridge.cpp | 31 + .../ViewNodesAssetTrackerCallbackBridge.hpp | 27 + .../runtime/JavaScript/JavaScriptRuntime.cpp | 35 + .../runtime/JavaScript/JavaScriptRuntime.hpp | 1 + .../ManagedContextNativeModuleFactory.cpp | 44 - .../ValdiStandaloneRuntime.cpp | 38 + valdi/test/integration/Runtime_tests.cpp | 107 +- valdi/test/runtime/PathUtils_tests.cpp | 9 + .../modules/test/src/TrackedBundledAsset.tsx | 48 + .../Marshalling/CppGeneratedModuleFactory.hpp | 4 +- .../src/valdi_core/cpp/Utils/DiskUtils.cpp | 36 + .../src/valdi_core/cpp/Utils/DiskUtils.hpp | 2 + .../src/valdi_core/cpp/Utils/PathUtils.cpp | 22 + .../src/valdi_core/cpp/Utils/PathUtils.hpp | 5 + 299 files changed, 23916 insertions(+), 6422 deletions(-) create mode 100644 apps/integration_test/AGENTS.md create mode 100644 apps/integration_test/BUILD.bazel create mode 100644 apps/integration_test/src/android/BUILD.bazel create mode 100644 apps/integration_test/src/android/IntegrationTestHostFactory.kt create mode 100644 apps/integration_test/src/cpp/BUILD.bazel create mode 100644 apps/integration_test/src/cpp/ImageDiffNative.cpp create mode 100644 apps/integration_test/src/cpp/IntegrationTestHost.cpp create mode 100644 apps/integration_test/src/ios/BUILD.bazel create mode 100644 apps/integration_test/src/ios/SCIntegrationTestHostFactory.m create mode 100644 apps/integration_test/src/valdi/integration_test_app/BUILD.bazel create mode 100644 apps/integration_test/src/valdi/integration_test_app/res/animation.json create mode 100644 apps/integration_test/src/valdi/integration_test_app/res/image.svg create mode 100644 apps/integration_test/src/valdi/integration_test_app/res/tint_mask.svg create mode 100644 apps/integration_test/src/valdi/integration_test_app/src/FactoryIntegrationHost.d.ts create mode 100644 apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestApp.tsx create mode 100644 apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestCases.tsx create mode 100644 apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestHost.d.ts create mode 100644 apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestRunner.ts create mode 100644 apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestTypes.ts create mode 100644 apps/integration_test/src/valdi/integration_test_app/src/RenderedNodeOutput.ts create mode 100644 apps/integration_test/src/valdi/integration_test_app/src/WebWorkerProbe.ts create mode 100644 apps/integration_test/src/valdi/integration_test_app/web/FactoryIntegrationHost.ts create mode 100644 apps/integration_test/src/valdi/integration_test_app/web/IntegrationTestHost.ts create mode 100644 apps/integration_test/src/valdi/integration_test_app/web/tsconfig.json create mode 100644 apps/integration_test/src/valdi/integration_test_cli/BUILD.bazel create mode 100644 apps/integration_test/src/valdi/integration_test_cli/src/ImageDiffNative.d.ts create mode 100644 apps/integration_test/src/valdi/integration_test_cli/src/compare.ts create mode 100644 apps/integration_test/src/valdi/integration_test_cli/src/exportSnapshots.ts create mode 100644 apps/integration_test/src/valdi/integration_test_cli/src/fullComparison.ts create mode 100644 apps/integration_test/src/valdi/integration_test_cli/src/htmlReport.ts create mode 100644 apps/integration_test/src/valdi/integration_test_cli/src/main.ts create mode 100644 apps/integration_test/src/valdi/integration_test_cli/src/png.ts create mode 100644 apps/integration_test/src/valdi/integration_test_cli/src/run.ts create mode 100644 apps/integration_test/src/valdi/integration_test_cli/src/selfTest.ts create mode 100644 apps/integration_test/src/valdi/integration_test_cli/src/types.ts create mode 100644 apps/integration_test/src/valdi/integration_test_cli/src/webRun.ts create mode 100644 apps/ssr_example/BUILD.bazel create mode 100644 apps/ssr_example/index.tsx delete mode 100644 apps/valdi_gpt/web_demo/src/RegisterNativeModules.js create mode 100644 bzl/valdi/app_templates/web_index.html.tpl create mode 100644 bzl/valdi/app_templates/web_index.js.tpl create mode 100644 bzl/valdi/app_templates/web_path_browserify_shim.js.tpl create mode 100644 bzl/valdi/app_templates/web_webpack.config.js.tpl create mode 100644 bzl/valdi/valdi_web_application.bzl create mode 100644 bzl/valdi/valdi_web_package.bzl create mode 100644 bzl/valdi/valdi_web_workers.bzl create mode 100644 compiler/compiler/Compiler/Sources/Config/WebNativeModuleIdOverride.swift create mode 100644 compiler/compiler/Compiler/Sources/Processors/GenerateWebNativeModulePackageFilesProcessor.swift create mode 100644 compiler/compiler/Compiler/Sources/Reloader/HotReloadLifecycleReporter.swift create mode 100644 compiler/compiler/Compiler/Tests/CompilerTests/HotReloadArgumentsTests.swift create mode 100644 compiler/compiler/Compiler/Tests/CompilerTests/HotReloadLifecycleReporterTests.swift create mode 100644 compiler/compiler/Compiler/Tests/CompilerTests/WebNativeModuleIdOverrideTests.swift create mode 100644 docs/docs/workflow-server-side-rendering.md create mode 100644 docs/docs/workflow-web-development.md create mode 100644 npm_modules/cli/src/utils/directorySync.ts create mode 100644 npm_modules/cli/src/utils/processUtils.ts create mode 100644 npm_modules/cli/src/utils/webApplicationSession.spec.ts create mode 100644 npm_modules/cli/src/utils/webApplicationSession.ts create mode 100644 npm_modules/cli/src/utils/webHotReloadSession.spec.ts create mode 100644 npm_modules/cli/src/utils/webHotReloadSession.ts create mode 100644 npm_modules/cli/src/utils/webServerUtils.ts create mode 100644 npm_modules/cli/src/utils/zipUtils.spec.ts create mode 100644 npm_modules/cli/test/export.spec.ts create mode 100644 npm_modules/cli/test/hotreload.spec.ts create mode 100644 src/valdi_modules/src/cpp/valdi_cli/BUILD.bazel create mode 100644 src/valdi_modules/src/cpp/valdi_cli/ChildProcessNative.cpp create mode 100644 src/valdi_modules/src/valdi/coreutils/src/ByteBuffer.ts create mode 100644 src/valdi_modules/src/valdi/coreutils/test/ByteBuffer.spec.ts create mode 100644 src/valdi_modules/src/valdi/persistence/test/WebPersistentStoreNativeTest.js create mode 100644 src/valdi_modules/src/valdi/valdi_cli/BUILD.bazel create mode 100644 src/valdi_modules/src/valdi/valdi_cli/module.yaml create mode 100644 src/valdi_modules/src/valdi/valdi_cli/native_specs/ChildProcess.spec.ts create mode 100644 src/valdi_modules/src/valdi/valdi_cli/src/ArgumentsParser.ts create mode 100644 src/valdi_modules/src/valdi/valdi_cli/src/ChildProcess.ts create mode 100644 src/valdi_modules/src/valdi/valdi_cli/src/ChildProcessNative.d.ts create mode 100644 src/valdi_modules/src/valdi/valdi_cli/src/Path.ts create mode 100644 src/valdi_modules/src/valdi/valdi_cli/src/Process.d.ts create mode 100644 src/valdi_modules/src/valdi/valdi_cli/tsconfig.json create mode 100644 src/valdi_modules/src/valdi/valdi_cli/web/ChildProcessNative.ts create mode 100644 src/valdi_modules/src/valdi/valdi_cli/web/tsconfig.json create mode 100644 src/valdi_modules/src/valdi/valdi_core/src/IViewNodeAssetTracker.d.ts create mode 100644 src/valdi_modules/src/valdi/valdi_core/src/ValdiRuntimeProvider.ts rename src/valdi_modules/src/valdi/{drawing/src/ManagedContextAssetTracker.ts => valdi_core/src/ViewNodeAssetTracker.ts} (70%) rename src/valdi_modules/src/valdi/{drawing/test/ManagedContextAssetTracker.spec.ts => valdi_core/test/ViewNodeAssetTracker.spec.ts} (63%) create mode 100644 src/valdi_modules/src/valdi/valdi_core/web/NumberFormatting.ts create mode 100644 src/valdi_modules/src/valdi/valdi_http/src/HTTPServer.ts create mode 100644 src/valdi_modules/src/valdi/valdi_http/web/TCPSocket.ts create mode 100644 src/valdi_modules/src/valdi/valdi_ssr/BUILD.bazel create mode 100644 src/valdi_modules/src/valdi/valdi_ssr/src/IValdiHTMLRendererListener.ts create mode 100644 src/valdi_modules/src/valdi/valdi_ssr/src/RenderMutationCoordinator.ts create mode 100644 src/valdi_modules/src/valdi/valdi_ssr/src/ValdiHTMLRenderer.ts create mode 100644 src/valdi_modules/src/valdi/valdi_ssr/src/ValdiSSRRouter.ts create mode 100644 src/valdi_modules/src/valdi/valdi_ssr/src/dom/ServerDOM.ts create mode 100644 src/valdi_modules/src/valdi/valdi_ssr/src/dom/ServerDOMEnvironment.ts create mode 100644 src/valdi_modules/src/valdi/valdi_ssr/src/dom/ServerStyleDeclaration.ts create mode 100644 src/valdi_modules/src/valdi/valdi_ssr/src/serialization/ServerCSSScoper.ts create mode 100644 src/valdi_modules/src/valdi/valdi_ssr/src/serialization/ServerHTMLSerializer.ts create mode 100644 src/valdi_modules/src/valdi/valdi_ssr/test/ServerHTMLRenderer.spec.ts create mode 100644 src/valdi_modules/src/valdi/valdi_ssr/tsconfig.json create mode 100644 src/valdi_modules/src/valdi/valdi_webview/web/WebViewNative.ts create mode 100644 src/valdi_modules/src/valdi/valdi_webview/web/tsconfig.json delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/HTMLRenderer.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/RouteRegistry.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/ViewFactory.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/WebNavStack.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/WebNavigator.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/elements/GlassElementClass.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/navigation/WebNavigator.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/styles/ValdiWebStyles.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/styles/handleMarginPadding.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/styles/isNumber.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/styles/requiresUnitlessNumber.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiCustomView.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiImage.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiLabel.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiLayout.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiScroll.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiShape.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiSpinner.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiTextField.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiTextView.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiVideo.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiView.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/ValdiWebRendererDelegate.spec.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/WebNavigator.spec.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/test/WebValdiComponents.spec.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/test/WebValdiImage.spec.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/test/WebValdiTextField.spec.ts delete mode 100644 src/valdi_modules/src/valdi/web_renderer/test/WebValdiTextView.spec.ts create mode 100644 valdi/compiler/toolbox/src/valdi/compiler_toolbox/CollapseWebPaths.cpp create mode 100644 valdi/compiler/toolbox/src/valdi/compiler_toolbox/CollapseWebPaths.hpp create mode 100644 valdi/compiler/toolbox/src/valdi/compiler_toolbox/RewriteWebRequires.cpp create mode 100644 valdi/compiler/toolbox/src/valdi/compiler_toolbox/RewriteWebRequires.hpp create mode 100644 valdi/compiler/toolbox/src/valdi/compiler_toolbox/WebPackageUtils.cpp create mode 100644 valdi/compiler/toolbox/src/valdi/compiler_toolbox/WebPackageUtils.hpp create mode 100644 valdi/compiler/toolbox/test/CollapseWebPaths_tests.cpp create mode 100644 valdi/compiler/toolbox/test/RewriteWebRequires_tests.cpp create mode 100644 valdi/src/valdi/runtime/Context/ViewNodesAssetTrackerCallbackBridge.cpp create mode 100644 valdi/src/valdi/runtime/Context/ViewNodesAssetTrackerCallbackBridge.hpp create mode 100644 valdi/testdata/resources/modules/test/src/TrackedBundledAsset.tsx diff --git a/BUILD.bazel b/BUILD.bazel index cbc8ae60e..1cd7baca6 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -12,3 +12,9 @@ npm_link_package( src = "@valdi//src/valdi_modules/src/valdi/valdi_core:valdi_core_dts", visibility = ["//visibility:public"], ) + +npm_link_package( + name = "web_renderer_link", + src = "@valdi//src/valdi_modules/src/valdi/web_renderer:web_renderer_dts", + visibility = ["//visibility:public"], +) diff --git a/ai-skills/skills/valdi-polyglot-module/skill.md b/ai-skills/skills/valdi-polyglot-module/skill.md index af1083ddd..ca6539256 100644 --- a/ai-skills/skills/valdi-polyglot-module/skill.md +++ b/ai-skills/skills/valdi-polyglot-module/skill.md @@ -62,9 +62,10 @@ Web entry files export view factories that are auto-registered with the `WebView ```typescript interface AttributeHandler { changeAttribute(name: string, value: unknown): void; + destroy?(): void; } -type ViewFactory = (container: HTMLElement) => AttributeHandler; +type ViewFactory = (container: HTMLElement) => AttributeHandler | void; function createMyViewFactory(): ViewFactory { return (container: HTMLElement): AttributeHandler => { @@ -77,6 +78,9 @@ function createMyViewFactory(): ViewFactory { element.textContent = String(value); } }, + destroy(): void { + element.remove(); + }, }; }; } diff --git a/apps/helloworld/src/valdi/hello_world/web/CppModule.ts b/apps/helloworld/src/valdi/hello_world/web/CppModule.ts index 3a94d1778..f45421ef4 100644 --- a/apps/helloworld/src/valdi/hello_world/web/CppModule.ts +++ b/apps/helloworld/src/valdi/hello_world/web/CppModule.ts @@ -1,7 +1,3 @@ -// Web override for the C++ native module. No-op on the browser; the -// original C++ implementation logs the root component ID via native -// bindings that don't exist here. Kept as a stub so the app boots. - -export function onRootComponentCreated(_contextId: string): void { - // no-op on web +export function onRootComponentCreated(contextId: string): void { + console.log(`From web: Root component created with contextId '${contextId}'`); } diff --git a/apps/helloworld/src/valdi/hello_world/web/NativeModule.ts b/apps/helloworld/src/valdi/hello_world/web/NativeModule.ts index 664a188a0..3b899178f 100644 --- a/apps/helloworld/src/valdi/hello_world/web/NativeModule.ts +++ b/apps/helloworld/src/valdi/hello_world/web/NativeModule.ts @@ -1,5 +1 @@ -// Web override for the platform-specific NativeModule. iOS/Android -// return the app name suffixed with the platform; on web we just say -// "Web". - -export const APP_NAME: string = 'Valdi Hello World (Web)'; +export const APP_NAME = 'Valdi Web'; diff --git a/apps/helloworld/src/valdi/hello_world/web/tsconfig.json b/apps/helloworld/src/valdi/hello_world/web/tsconfig.json index dac319734..fcb232e3c 100644 --- a/apps/helloworld/src/valdi/hello_world/web/tsconfig.json +++ b/apps/helloworld/src/valdi/hello_world/web/tsconfig.json @@ -7,5 +7,6 @@ "composite": true, "allowJs": true, "declaration": true - } + }, + "exclude": ["debug/**", "release/**"] } diff --git a/apps/integration_test/AGENTS.md b/apps/integration_test/AGENTS.md new file mode 100644 index 000000000..67eca7ae7 --- /dev/null +++ b/apps/integration_test/AGENTS.md @@ -0,0 +1,145 @@ +# Integration Test App Guide + +This directory contains Valdi's cross-platform snapshot integration test harness. It renders a curated set of Valdi elements and attributes, captures screenshots and node output on each platform, and compares results across platforms or repos. + +## What Is Here + +- `src/valdi/integration_test_app`: the Valdi app under test. + - `IntegrationTestCases.tsx` defines the cases. + - `IntegrationTestApp.tsx` renders one case at a time. + - `IntegrationTestRunner.ts` captures snapshots, observations, progress, and result JSON. + - `web/IntegrationTestHost.ts` provides the web host implementation for screenshots, synthetic input, and file writes. +- `src/valdi/integration_test_cli`: the CLI for running captures, comparing outputs, exporting snapshots, and self-testing the comparison logic. +- `src/ios`, `src/android`, and `src/cpp`: native host/module support used by the app and CLI. +- Top-level targets: + - `//apps/integration_test:integration_test` + - `//apps/integration_test:integration_test_cli` + - generated platform app targets such as `integration_test_ios`, `integration_test_android`, `integration_test_macos` + - web package target `//apps/integration_test:integration_test_web_npm` + +## Common Commands + +Build the CLI first: + +```bash +bazel build //apps/integration_test:integration_test_cli +``` + +For full saved comparisons or repeated `compare` runs, build/run the CLI with optimizations enabled. The comparison path can be much slower in non-optimized builds, especially when image conversion or resizing goes through SnapDrawing/Skia: + +```bash +bazel run -c opt //apps/integration_test:integration_test_cli -- compare \ + --before /private/tmp/valdi-integration-ios.json \ + --after /private/tmp/valdi-integration-web.json \ + --output-dir /private/tmp/valdi-integration-compare +``` + +Print usage: + +```bash +bazel-bin/apps/integration_test/integration_test_cli help +``` + +Run a web capture: + +```bash +bazel-bin/apps/integration_test/integration_test_cli run \ + --platform web \ + --output /private/tmp/valdi-integration-web.json \ + --timeout-ms 240000 +``` + +Run an iOS simulator capture: + +```bash +bazel-bin/apps/integration_test/integration_test_cli run \ + --platform ios \ + --device-id booted \ + --output /private/tmp/valdi-integration-ios.json \ + --timeout-ms 240000 +``` + +Compare two result JSON files: + +```bash +bazel-bin/apps/integration_test/integration_test_cli compare \ + --before /private/tmp/valdi-integration-ios.json \ + --after /private/tmp/valdi-integration-web.json \ + --output-dir /private/tmp/valdi-integration-compare +``` + +The compare command writes: + +- `index.html`: interactive report +- `summary.json`: machine-readable summary +- `summary.md`: concise Markdown summary +- `before/`, `after/`, `diffs/`: decoded and generated PNGs + +Run both sides and compare in one command: + +```bash +bazel-bin/apps/integration_test/integration_test_cli full-comparison \ + --before-repo /path/to/repoA \ + --after-repo /path/to/repoB \ + --before-platform ios \ + --after-platform web \ + --output-dir /private/tmp/valdi-integration-full +``` + +Export snapshots from one result JSON into PNGs plus a contact sheet: + +```bash +bazel-bin/apps/integration_test/integration_test_cli export-snapshots \ + --result /private/tmp/valdi-integration-web.json \ + --output-dir /private/tmp/valdi-integration-web-snapshots +``` + +Run the CLI comparison self-test: + +```bash +bazel-bin/apps/integration_test/integration_test_cli self-test +``` + +## Web Harness Notes + +The web run path builds `//apps/integration_test:integration_test_web_npm` with web enabled, creates a temporary webpack harness under `/tmp`, serves it locally, and launches a Chrome-compatible browser in headless mode. + +If browser discovery fails, set `CHROME_BIN` to Chrome, Chromium, Chrome for Testing, or `chrome-headless-shell`. + +The web harness logs progress lines like: + +```text +[web progress] index=12 phase=snapshotting case=view-background-color captured=12 +``` + +Use those progress lines to locate renderer hangs. Avoid changing test cases just to hide a timeout; renderer bugs should usually be fixed in the renderer. + +## Native Run Notes + +Native `run` uses `valdi install` internally. Useful flags: + +- `--device-id`: simulator, emulator, or device id. Use `booted` for the current iOS simulator. +- `--ios-device-build`: build for a physical iOS device. +- `--bazel-args`: pass extra Bazel args through to `valdi install`. +- `--valdi-bin`: use a specific `valdi` executable. + +The app writes result JSON from inside the platform host. The CLI waits for completion, copies the result out, and terminates the app. + +## Comparison Expectations + +When validating a refactor, compare both summary metrics and generated diff PNGs if possible. A good smoke check is: + +- same `caseCount` +- same `changedCaseCount` +- same per-case `diffPercent`, `changedPixels`, `totalPixels`, and `dimensionMismatch` +- same diff PNG hashes when comparing an implementation rewrite against a known baseline + +Use `--pixel-threshold` for channel tolerance and `--fail-above` when the command should fail if max diff exceeds a percent threshold. + +## Editing Guidance + +- Add or update cases in `IntegrationTestCases.tsx`. +- Keep case ids stable; reports and comparisons key by id. +- Prefer recording platform limitations in observations over silently skipping behavior. +- If a snapshot can hang a platform, use the explicit skip/expected-failure mechanisms already present in the cases. +- Keep output under `/private/tmp` or another disposable directory; result sets and decoded images can be large. diff --git a/apps/integration_test/BUILD.bazel b/apps/integration_test/BUILD.bazel new file mode 100644 index 000000000..e81abba8e --- /dev/null +++ b/apps/integration_test/BUILD.bazel @@ -0,0 +1,31 @@ +load("//bzl/valdi:valdi_application.bzl", "valdi_application") +load("//bzl/valdi:valdi_cli_application.bzl", "valdi_cli_application") +load("//bzl/valdi:valdi_exported_library.bzl", "valdi_exported_library") + +valdi_application( + name = "integration_test", + android_package = "com.snap.valdi.integrationtest", + desktop_window_height = 720, + desktop_window_resizable = False, + desktop_window_width = 420, + ios_bundle_id = "com.snap.valdi.integrationtest", + ios_families = ["iphone"], + root_component_path = "IntegrationTestApp@integration_test_app/src/IntegrationTestApp", + title = "Valdi Integration Test", + version = "1.0.0", + deps = ["//apps/integration_test/src/valdi/integration_test_app"], +) + +valdi_cli_application( + name = "integration_test_cli", + script_path = "integration_test_cli/src/main", + deps = ["//apps/integration_test/src/valdi/integration_test_cli"], +) + +valdi_exported_library( + name = "integration_test_export", + ios_bundle_id = "com.snap.valdi.integrationtest.lib", + ios_bundle_name = "ValdiIntegrationTest", + web_package_name = "integration_test_web_npm", + deps = ["//apps/integration_test/src/valdi/integration_test_app"], +) diff --git a/apps/integration_test/src/android/BUILD.bazel b/apps/integration_test/src/android/BUILD.bazel new file mode 100644 index 000000000..da1125cd0 --- /dev/null +++ b/apps/integration_test/src/android/BUILD.bazel @@ -0,0 +1,13 @@ +load("//bzl/valdi:valdi_android_library.bzl", "valdi_android_library") + +valdi_android_library( + name = "integration_test_host_android", + srcs = glob([ + "**/*.kt", + ]), + visibility = ["//visibility:public"], + deps = [ + "//apps/integration_test/src/valdi/integration_test_app:integration_test_app_api_kt", + "//valdi:valdi_android_support", + ], +) diff --git a/apps/integration_test/src/android/IntegrationTestHostFactory.kt b/apps/integration_test/src/android/IntegrationTestHostFactory.kt new file mode 100644 index 000000000..958f4c61d --- /dev/null +++ b/apps/integration_test/src/android/IntegrationTestHostFactory.kt @@ -0,0 +1,239 @@ +package com.snap.valdi.integrationtest + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Typeface +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.widget.EditText +import com.snap.valdi.ViewFactory +import com.snap.valdi.attributes.AttributesBinder +import com.snap.valdi.attributes.AttributesBindingContext +import com.snap.valdi.context.ValdiContext +import com.snap.valdi.createViewFactory +import com.snap.valdi.modules.RegisterValdiModule +import com.snap.valdi.modules.integration_test_app.FactoryIntegrationHostModule +import com.snap.valdi.modules.integration_test_app.FactoryIntegrationHostModuleFactory +import com.snap.valdi.modules.integration_test_app.IntegrationTestHostModule +import com.snap.valdi.modules.integration_test_app.IntegrationTestHostModuleFactory +import com.snap.valdi.nodes.IValdiViewNode +import com.snap.valdi.nodes.ValdiViewNode +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.json.JSONObject +import java.io.File + +@RegisterValdiModule +class IntegrationTestHostFactory : IntegrationTestHostModuleFactory() { + override fun onLoadModule(): IntegrationTestHostModule { + return IntegrationTestHostModuleImpl() + } +} + +@RegisterValdiModule +class FactoryIntegrationHostFactory : FactoryIntegrationHostModuleFactory() { + override fun onLoadModule(): FactoryIntegrationHostModule { + return FactoryIntegrationHostModuleImpl() + } +} + +private class FactoryIntegrationHostModuleImpl : FactoryIntegrationHostModule { + override fun createIntegrationViewFactory(): ViewFactory { + val runtime = checkNotNull(ValdiContext.current()).runtime + return runtime.createViewFactory( + IntegrationFactoryView::class.java, + { context -> IntegrationFactoryView(context) }, + IntegrationFactoryAttributesBinder(), + ) + } +} + +private class IntegrationFactoryView(context: Context) : View(context) { + var factoryText: String? = null + set(value) { + field = value + invalidate() + } + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val path = Path() + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + val scale = resources.displayMetrics.density + val centerY = height / 2f + + path.reset() + path.moveTo(44f * scale, centerY - 26f * scale) + path.lineTo(66f * scale, centerY - 13f * scale) + path.lineTo(66f * scale, centerY + 13f * scale) + path.lineTo(44f * scale, centerY + 26f * scale) + path.lineTo(22f * scale, centerY + 13f * scale) + path.lineTo(22f * scale, centerY - 13f * scale) + path.close() + paint.color = Color.rgb(79, 70, 229) + canvas.drawPath(path, paint) + + path.reset() + path.moveTo(44f * scale, centerY - 16f * scale) + path.lineTo(49f * scale, centerY - 5f * scale) + path.lineTo(60f * scale, centerY) + path.lineTo(49f * scale, centerY + 5f * scale) + path.lineTo(44f * scale, centerY + 16f * scale) + path.lineTo(39f * scale, centerY + 5f * scale) + path.lineTo(28f * scale, centerY) + path.lineTo(39f * scale, centerY - 5f * scale) + path.close() + paint.color = Color.WHITE + canvas.drawPath(path, paint) + + paint.color = Color.rgb(23, 37, 84) + paint.textSize = 16f * scale + paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + val baseline = centerY - (paint.ascent() + paint.descent()) / 2f + canvas.drawText(factoryText.orEmpty(), 82f * scale, baseline, paint) + } +} + +private class IntegrationFactoryAttributesBinder : AttributesBinder { + override val viewClass = IntegrationFactoryView::class.java + + override fun bindAttributes(attributesBindingContext: AttributesBindingContext) { + attributesBindingContext.bindStringAttribute( + "factoryText", + false, + { view, value, _ -> view.factoryText = value }, + { view, _ -> view.factoryText = null }, + ) + } +} + +private class IntegrationTestHostModuleImpl : IntegrationTestHostModule { + private val mainHandler = Handler(Looper.getMainLooper()) + + override fun getPlatform(): String = "android" + + override fun getOutputPath(): String { + return "/data/data/com.snap.valdi.integrationtest/files/valdi-integration-test/results.json" + } + + override fun markFinished(path: String) { + // The TypeScript harness writes the result and .done sentinel through file_system. + } + + override fun writeTextFile(path: String, contents: String) { + val file = File(path) + file.parentFile?.mkdirs() + file.writeText(contents) + } + + override fun submitTouchSequence(node: IValdiViewNode, sequenceJson: String): String { + val target = getBackingView(node) ?: return "no backing Android View for ${node.viewClassName}" + val request = JSONObject(sequenceJson) + val events = request.optJSONArray("events") ?: return "no events in sequence" + val downTime = SystemClock.uptimeMillis() + val latch = CountDownLatch(1) + val errors = mutableListOf() + + mainHandler.post { + try { + var eventTime = downTime + for (i in 0 until events.length()) { + val event = events.getJSONObject(i) + eventTime += event.optLong("delayMs", 16) + val action = when (event.optString("action")) { + "down" -> MotionEvent.ACTION_DOWN + "move" -> MotionEvent.ACTION_MOVE + "up" -> MotionEvent.ACTION_UP + "cancel" -> MotionEvent.ACTION_CANCEL + else -> MotionEvent.ACTION_CANCEL + } + val x = (event.optDouble("x", 0.5) * target.width).toFloat() + val y = (event.optDouble("y", 0.5) * target.height).toFloat() + val motionEvent = MotionEvent.obtain(downTime, eventTime, action, x, y, 0) + try { + target.dispatchTouchEvent(motionEvent) + } finally { + motionEvent.recycle() + } + } + } catch (error: Throwable) { + errors.add(error.message ?: error.javaClass.name) + } finally { + latch.countDown() + } + } + + latch.await(2, TimeUnit.SECONDS) + return if (errors.isEmpty()) { + "dispatched ${events.length()} event(s) to ${target.javaClass.simpleName}" + } else { + "dispatch failed: ${errors.joinToString("; ")}" + } + } + + override fun focusTextInput(node: IValdiViewNode): String { + val editText = getBackingView(node) as? EditText ?: return "target is not EditText" + runOnMainSync { + editText.requestFocus() + } + return "focused ${editText.javaClass.simpleName}" + } + + override fun replaceText(node: IValdiViewNode, value: String): String { + val editText = getBackingView(node) as? EditText ?: return "target is not EditText" + runOnMainSync { + editText.setText(value) + editText.setSelection(value.length) + } + return "set text length=${value.length}" + } + + override fun pressReturn(node: IValdiViewNode): String { + val editText = getBackingView(node) as? EditText ?: return "target is not EditText" + runOnMainSync { + editText.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)) + editText.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)) + } + return "sent enter key" + } + + override fun pressBackspace(node: IValdiViewNode): String { + val editText = getBackingView(node) as? EditText ?: return "target is not EditText" + runOnMainSync { + editText.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)) + editText.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL)) + } + return "sent delete key" + } + + private fun getBackingView(node: IValdiViewNode): View? { + val ref = (node as? ValdiViewNode)?.getBackingViewRef() + return ref?.get() as? View + } + + private fun runOnMainSync(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + return + } + + val latch = CountDownLatch(1) + mainHandler.post { + try { + block() + } finally { + latch.countDown() + } + } + latch.await(2, TimeUnit.SECONDS) + } +} diff --git a/apps/integration_test/src/cpp/BUILD.bazel b/apps/integration_test/src/cpp/BUILD.bazel new file mode 100644 index 000000000..90c7de65b --- /dev/null +++ b/apps/integration_test/src/cpp/BUILD.bazel @@ -0,0 +1,27 @@ +cc_library( + name = "integration_test_host_cpp", + srcs = ["IntegrationTestHost.cpp"], + hdrs = [], + includes = [], + visibility = ["//visibility:public"], + deps = [ + "//apps/integration_test/src/valdi/integration_test_app:integration_test_app_cpp", + "@valdi//valdi_core", + ], + alwayslink = 1, +) + +cc_library( + name = "integration_test_cli_cpp", + srcs = ["ImageDiffNative.cpp"], + copts = ["-Os"], + hdrs = [], + includes = [], + visibility = ["//visibility:public"], + deps = [ + "//apps/integration_test/src/valdi/integration_test_cli:integration_test_cli_cpp", + "//snap_drawing", + "@valdi//valdi_core", + ], + alwayslink = 1, +) diff --git a/apps/integration_test/src/cpp/ImageDiffNative.cpp b/apps/integration_test/src/cpp/ImageDiffNative.cpp new file mode 100644 index 000000000..19faece81 --- /dev/null +++ b/apps/integration_test/src/cpp/ImageDiffNative.cpp @@ -0,0 +1,264 @@ +#include "valdi_modules/integration_test_cli/integration_test_cli.hpp" + +#include "snap_drawing/cpp/Utils/Image.hpp" +#include "valdi_core/cpp/Interfaces/IBitmap.hpp" +#include "valdi_core/cpp/JavaScript/ModuleFactoryRegistry.hpp" +#include "valdi_core/cpp/Utils/BitmapWithBuffer.hpp" +#include "valdi_core/cpp/Utils/Bytes.hpp" +#include "valdi_core/cpp/Utils/Exception.hpp" +#include "valdi_core/cpp/Utils/Format.hpp" +#include "valdi_core/cpp/Utils/Shared.hpp" +#include "valdi_core/cpp/Utils/Value.hpp" + +#include +#include +#include + +namespace snap::valdi_modules::integration_test_cli { +namespace { + +constexpr uint8_t kDiffRed = 255; +constexpr uint8_t kDiffGreen = 0; +constexpr uint8_t kDiffBlue = 0; +constexpr uint8_t kDiffAlpha = 255; + +class LockedBitmapPixels { +public: + LockedBitmapPixels(Valdi::Ref bitmap, const char* name) : _bitmap(std::move(bitmap)), _name(name) { + if (_bitmap == nullptr) { + throw Valdi::Exception(STRING_FORMAT("{} bitmap is null", _name)); + } + + info = _bitmap->getInfo(); + if (info.width < 0 || info.height < 0) { + throw Valdi::Exception( + STRING_FORMAT("{} bitmap has invalid dimensions {}x{}", _name, info.width, info.height)); + } + if (info.colorType != Valdi::ColorTypeRGBA8888) { + throw Valdi::Exception(STRING_FORMAT( + "{} bitmap must be RGBA8888, got color type {}", _name, static_cast(info.colorType))); + } + if (info.rowBytes < static_cast(info.width) * 4) { + throw Valdi::Exception( + STRING_FORMAT("{} bitmap rowBytes {} is smaller than width * 4", _name, info.rowBytes)); + } + + bytes = static_cast(_bitmap->lockBytes()); + if (bytes == nullptr && info.width > 0 && info.height > 0) { + throw Valdi::Exception(STRING_FORMAT("Failed to lock {} bitmap pixels", _name)); + } + } + + ~LockedBitmapPixels() { + if (_bitmap != nullptr && bytes != nullptr) { + _bitmap->unlockBytes(); + } + } + + LockedBitmapPixels(const LockedBitmapPixels&) = delete; + LockedBitmapPixels& operator=(const LockedBitmapPixels&) = delete; + + bool contains(int x, int y) const { + return x >= 0 && y >= 0 && x < info.width && y < info.height; + } + + const uint8_t* pixel(int x, int y) const { + if (!contains(x, y)) { + return nullptr; + } + return bytes + static_cast(y) * info.rowBytes + static_cast(x) * 4; + } + + Valdi::BitmapInfo info = Valdi::BitmapInfo(0, 0, Valdi::ColorTypeRGBA8888, Valdi::AlphaTypeUnpremul, 0); + const uint8_t* bytes = nullptr; + +private: + Valdi::Ref _bitmap; + const char* _name; +}; + +uint8_t fadedChannel(uint8_t channel) { + return static_cast((static_cast(channel) * 55 + 255 * 45 + 50) / 100); +} + +bool pixelsEqual(const uint8_t* beforePixel, const uint8_t* afterPixel, int threshold) { + for (int channel = 0; channel < 4; channel++) { + const int beforeValue = beforePixel == nullptr ? 0 : beforePixel[channel]; + const int afterValue = afterPixel == nullptr ? 0 : afterPixel[channel]; + const int delta = beforeValue - afterValue; + if (delta > threshold || delta < -threshold) { + return false; + } + } + return true; +} + +void writeDiffPixel(uint8_t* outPixel) { + outPixel[0] = kDiffRed; + outPixel[1] = kDiffGreen; + outPixel[2] = kDiffBlue; + outPixel[3] = kDiffAlpha; +} + +void writeFadedPixel(uint8_t* outPixel, const uint8_t* afterPixel) { + const uint8_t red = afterPixel == nullptr ? 0 : afterPixel[0]; + const uint8_t green = afterPixel == nullptr ? 0 : afterPixel[1]; + const uint8_t blue = afterPixel == nullptr ? 0 : afterPixel[2]; + outPixel[0] = fadedChannel(red); + outPixel[1] = fadedChannel(green); + outPixel[2] = fadedChannel(blue); + outPixel[3] = afterPixel == nullptr ? 0 : afterPixel[3]; +} + +Valdi::Ref imageFromBytes(Valdi::BytesView data, const char* name) { + auto imageResult = snap::drawing::Image::make(data); + if (!imageResult) { + throw Valdi::Exception(imageResult.moveError()); + } + auto image = imageResult.value(); + if (image == nullptr) { + throw Valdi::Exception(STRING_FORMAT("Failed to decode {} image", name)); + } + return image; +} + +Valdi::Ref convertImageForComparison(const Valdi::Ref& image, + int width, + int height, + const char* name) { + auto bitmap = image->toConvertedBitmap(Valdi::BitmapInfo( + width, height, Valdi::ColorTypeRGBA8888, Valdi::AlphaTypeUnpremul, static_cast(width) * 4)); + if (bitmap == nullptr) { + throw Valdi::Exception(STRING_FORMAT("Failed to convert {} image for comparison", name)); + } + return bitmap; +} + +void diffSameSize(const LockedBitmapPixels& before, + const LockedBitmapPixels& after, + int width, + int height, + int threshold, + uint8_t* outBytes, + size_t outRowBytes, + double& changedPixels) { + for (int y = 0; y < height; y++) { + const uint8_t* beforeRow = before.bytes + static_cast(y) * before.info.rowBytes; + const uint8_t* afterRow = after.bytes + static_cast(y) * after.info.rowBytes; + uint8_t* outRow = outBytes + static_cast(y) * outRowBytes; + for (int x = 0; x < width; x++) { + const uint8_t* beforePixel = beforeRow + static_cast(x) * 4; + const uint8_t* afterPixel = afterRow + static_cast(x) * 4; + uint8_t* outPixel = outRow + static_cast(x) * 4; + if (!pixelsEqual(beforePixel, afterPixel, threshold)) { + changedPixels++; + writeDiffPixel(outPixel); + } else { + writeFadedPixel(outPixel, afterPixel); + } + } + } +} + +void diffWithBounds(const LockedBitmapPixels& before, + const LockedBitmapPixels& after, + int width, + int height, + int threshold, + uint8_t* outBytes, + size_t outRowBytes, + double& changedPixels) { + for (int y = 0; y < height; y++) { + uint8_t* outRow = outBytes + static_cast(y) * outRowBytes; + for (int x = 0; x < width; x++) { + const uint8_t* beforePixel = before.pixel(x, y); + const uint8_t* afterPixel = after.pixel(x, y); + uint8_t* outPixel = outRow + static_cast(x) * 4; + if (!pixelsEqual(beforePixel, afterPixel, threshold)) { + changedPixels++; + writeDiffPixel(outPixel); + } else { + writeFadedPixel(outPixel, afterPixel); + } + } + } +} + +NativeImageDiffResult diffComparableBitmaps(const Valdi::Ref& beforeBitmap, + const Valdi::Ref& afterBitmap, + bool dimensionMismatch, + double pixelThreshold) { + const LockedBitmapPixels before(beforeBitmap, "before"); + const LockedBitmapPixels after(afterBitmap, "after"); + const int width = std::max(before.info.width, after.info.width); + const int height = std::max(before.info.height, after.info.height); + const size_t rowBytes = static_cast(width) * 4; + const size_t byteLength = static_cast(height) * rowBytes; + + auto outBytes = Valdi::makeShared(); + outBytes->resize(byteLength); + uint8_t* out = outBytes->data(); + + double changedPixels = 0; + const int threshold = static_cast(pixelThreshold); + if (before.info.width == width && after.info.width == width && before.info.height == height && + after.info.height == height) { + diffSameSize(before, after, width, height, threshold, out, rowBytes, changedPixels); + } else { + diffWithBounds(before, after, width, height, threshold, out, rowBytes, changedPixels); + } + + NativeImageDiffResult result; + result.setChangedPixels(changedPixels); + result.setTotalPixels(static_cast(width) * height); + result.setDimensionMismatch(dimensionMismatch); + result.setImage(Valdi::Value(Valdi::makeShared( + Valdi::BytesView(outBytes), + Valdi::BitmapInfo(width, height, Valdi::ColorTypeRGBA8888, Valdi::AlphaTypeUnpremul, rowBytes)))); + return result; +} + +bool hasSameAspectRatio(const Valdi::Ref& beforeImage, + const Valdi::Ref& afterImage) { + return static_cast(beforeImage->width()) * afterImage->height() == + static_cast(afterImage->width()) * beforeImage->height(); +} + +} // namespace + +class ImageDiffNativeModuleImpl : public ImageDiffNativeModule { +public: + NativeImageDiffResult diffEncodedImages(Valdi::BytesView beforeData, + Valdi::BytesView afterData, + double pixelThreshold) { + auto beforeImage = imageFromBytes(beforeData, "before"); + auto afterImage = imageFromBytes(afterData, "after"); + const bool dimensionMismatch = + beforeImage->width() != afterImage->width() || beforeImage->height() != afterImage->height(); + int beforeWidth = beforeImage->width(); + int beforeHeight = beforeImage->height(); + int afterWidth = afterImage->width(); + int afterHeight = afterImage->height(); + if (dimensionMismatch && hasSameAspectRatio(beforeImage, afterImage)) { + beforeWidth = std::min(beforeImage->width(), afterImage->width()); + beforeHeight = std::min(beforeImage->height(), afterImage->height()); + afterWidth = beforeWidth; + afterHeight = beforeHeight; + } + + auto beforeBitmap = convertImageForComparison(beforeImage, beforeWidth, beforeHeight, "before"); + auto afterBitmap = convertImageForComparison(afterImage, afterWidth, afterHeight, "after"); + return diffComparableBitmaps(beforeBitmap, afterBitmap, dimensionMismatch, pixelThreshold); + } +}; + +class ImageDiffNativeModuleFactoryImpl : public ImageDiffNativeModuleFactory { +public: + Valdi::Ref onLoadModule() final { + return Valdi::makeShared(); + } +}; + +auto registerImageDiffNativeModule = Valdi::RegisterModuleFactory::registerTyped(); + +} // namespace snap::valdi_modules::integration_test_cli diff --git a/apps/integration_test/src/cpp/IntegrationTestHost.cpp b/apps/integration_test/src/cpp/IntegrationTestHost.cpp new file mode 100644 index 000000000..26d20011e --- /dev/null +++ b/apps/integration_test/src/cpp/IntegrationTestHost.cpp @@ -0,0 +1,63 @@ +#include "valdi_modules/integration_test_app/integration_test_app.hpp" + +#include "valdi_core/cpp/Utils/DiskUtils.hpp" +#include "valdi_core/cpp/Utils/Shared.hpp" + +#include + +namespace snap::valdi_modules::integration_test_app { + +class IntegrationTestHostModuleImpl : public IntegrationTestHostModule { +public: + Valdi::StringBox getPlatform() final { + return Valdi::StringBox::fromCString("macos"); + } + + Valdi::StringBox getOutputPath() final { + return Valdi::StringBox::fromCString("/tmp/valdi-integration-test/results.json"); + } + + void markFinished(Valdi::StringBox /*path*/) final {} + + void writeTextFile(Valdi::StringBox path, Valdi::StringBox contents) final { + Valdi::Path filePath(path.toStringView()); + if (filePath.getComponents().size() > 1) { + auto directory = filePath.removingLastComponent(); + if (!Valdi::DiskUtils::isDirectory(directory)) { + Valdi::DiskUtils::makeDirectory(directory, true); + } + } + Valdi::DiskUtils::store(filePath, contents.toStringView()); + } + + Valdi::StringBox submitTouchSequence(Valdi::Value /*node*/, Valdi::StringBox /*sequenceJson*/) final { + return Valdi::StringBox::fromCString("macos C++ host does not synthesize SnapDrawing touch input yet"); + } + + Valdi::StringBox focusTextInput(Valdi::Value /*node*/) final { + return Valdi::StringBox::fromCString("macos C++ host text focus not implemented"); + } + + Valdi::StringBox replaceText(Valdi::Value /*node*/, Valdi::StringBox value) final { + return Valdi::StringBox::fromString("macos C++ host accepted text length=" + std::to_string(value.length())); + } + + Valdi::StringBox pressReturn(Valdi::Value /*node*/) final { + return Valdi::StringBox::fromCString("macos C++ host return key not implemented"); + } + + Valdi::StringBox pressBackspace(Valdi::Value /*node*/) final { + return Valdi::StringBox::fromCString("macos C++ host backspace key not implemented"); + } +}; + +class IntegrationTestHostModuleFactoryImpl : public IntegrationTestHostModuleFactory { +public: + Valdi::Ref onLoadModule() final { + return Valdi::makeShared(); + } +}; + +auto registerIntegrationTestHostModule = Valdi::RegisterModuleFactory::registerTyped(); + +} // namespace snap::valdi_modules::integration_test_app diff --git a/apps/integration_test/src/ios/BUILD.bazel b/apps/integration_test/src/ios/BUILD.bazel new file mode 100644 index 000000000..8cabf7e44 --- /dev/null +++ b/apps/integration_test/src/ios/BUILD.bazel @@ -0,0 +1,14 @@ +objc_library( + name = "integration_test_host_ios", + srcs = glob([ + "**/*.m", + ]), + hdrs = [], + copts = ["-I."], + target_compatible_with = ["@platforms//os:ios"], + visibility = ["//visibility:public"], + deps = [ + "//apps/integration_test/src/valdi/integration_test_app:integration_test_app_api_objc", + "//valdi:valdi_ios", + ], +) diff --git a/apps/integration_test/src/ios/SCIntegrationTestHostFactory.m b/apps/integration_test/src/ios/SCIntegrationTestHostFactory.m new file mode 100644 index 000000000..33cefc48b --- /dev/null +++ b/apps/integration_test/src/ios/SCIntegrationTestHostFactory.m @@ -0,0 +1,252 @@ +#import "valdi/ios/Gestures/SCValdiGestureRecognizers.h" +#import "valdi/ios/SCValdiRuntimeManager.h" +#import "valdi_core/SCValdiAttributesBinderBase.h" +#import "valdi_core/SCValdiModuleFactoryRegistry.h" +#import "valdi_core/SCValdiRuntimeProtocol.h" +#import "valdi_core/SCValdiViewFactory.h" +#import "valdi_core/SCValdiViewNodeProtocol.h" +#import +#import +#import + +@interface SCIntegrationTestHost : NSObject +@end + +@implementation SCIntegrationTestHost + +- (NSString *)resolvePath:(NSString *)path +{ + if (path.isAbsolutePath) { + return path; + } + + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); + NSString *documents = paths.firstObject ?: NSTemporaryDirectory(); + return [[documents stringByAppendingPathComponent:@"Valdi"] stringByAppendingPathComponent:path]; +} + +- (NSString *)getPlatform +{ + return @"ios"; +} + +- (NSString *)getOutputPath +{ + return @"valdi-integration-test/results.json"; +} + +- (void)markFinishedWithPath:(NSString *)path +{ + NSString *resolvedPath = [self resolvePath:path]; + NSString *directory = resolvedPath.stringByDeletingLastPathComponent; + [NSFileManager.defaultManager createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:nil]; + [@"done" writeToFile:resolvedPath atomically:YES encoding:NSUTF8StringEncoding error:nil]; +} + +- (void)writeTextFileWithPath:(NSString *)path contents:(NSString *)contents +{ + NSString *resolvedPath = [self resolvePath:path]; + NSString *directory = resolvedPath.stringByDeletingLastPathComponent; + [NSFileManager.defaultManager createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:nil]; + [contents writeToFile:resolvedPath atomically:YES encoding:NSUTF8StringEncoding error:nil]; +} + +- (NSString *)submitTouchSequenceWithNode:(id)node sequenceJson:(NSString *)sequenceJson +{ + UIView *view = node.view; + if (!view) { + return @"no backing UIView"; + } + + NSData *data = [sequenceJson dataUsingEncoding:NSUTF8StringEncoding]; + NSDictionary *request = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil; + NSString *kind = [request[@"kind"] isKindOfClass:NSString.class] ? request[@"kind"] : @"tap"; + CGPoint location = CGPointMake(CGRectGetMidX(view.bounds), CGRectGetMidY(view.bounds)); + NSUInteger triggered = 0; + + for (UIGestureRecognizer *recognizer in view.gestureRecognizers) { + if ([kind isEqualToString:@"doubleTap"] && [recognizer isKindOfClass:SCValdiFastDoubleTapGestureRecognizer.class]) { + [(SCValdiFastDoubleTapGestureRecognizer *)recognizer triggerAtLocation:location forState:UIGestureRecognizerStateEnded]; + triggered++; + } else if ([kind isEqualToString:@"longPress"] && [recognizer isKindOfClass:SCValdiLongPressGestureRecognizer.class]) { + [(SCValdiLongPressGestureRecognizer *)recognizer triggerAtLocation:location forState:UIGestureRecognizerStateBegan]; + triggered++; + } else if ([recognizer isKindOfClass:SCValdiTapGestureRecognizer.class]) { + [(SCValdiTapGestureRecognizer *)recognizer triggerAtLocation:location forState:UIGestureRecognizerStateEnded]; + triggered++; + } + } + + if (triggered == 0) { + return [NSString stringWithFormat:@"no triggerable recognizer for %@ on %@", kind, NSStringFromClass(view.class)]; + } + return [NSString stringWithFormat:@"triggered %lu recognizer(s) for %@ on %@", (unsigned long)triggered, kind, NSStringFromClass(view.class)]; +} + +- (NSString *)focusTextInputWithNode:(id)node +{ + UIView *view = node.view; + if ([view respondsToSelector:@selector(becomeFirstResponder)]) { + [view becomeFirstResponder]; + return [NSString stringWithFormat:@"focused %@", NSStringFromClass(view.class)]; + } + return @"target cannot become first responder"; +} + +- (NSString *)replaceTextWithNode:(id)node value:(NSString *)value +{ + UIView *view = node.view; + if ([view isKindOfClass:UITextField.class]) { + UITextField *textField = (UITextField *)view; + textField.text = value; + [textField sendActionsForControlEvents:UIControlEventEditingChanged]; + return [NSString stringWithFormat:@"set UITextField text length=%lu", (unsigned long)value.length]; + } + if ([view isKindOfClass:UITextView.class]) { + ((UITextView *)view).text = value; + return [NSString stringWithFormat:@"set UITextView text length=%lu", (unsigned long)value.length]; + } + return [NSString stringWithFormat:@"target is not editable text: %@", NSStringFromClass(view.class)]; +} + +- (NSString *)pressReturnWithNode:(id)node +{ + UIView *view = node.view; + if ([view isKindOfClass:UITextField.class]) { + UITextField *textField = (UITextField *)view; + [textField sendActionsForControlEvents:UIControlEventEditingDidEndOnExit]; + return @"sent UITextField return"; + } + return [NSString stringWithFormat:@"return key unsupported for %@", NSStringFromClass(view.class)]; +} + +- (NSString *)pressBackspaceWithNode:(id)node +{ + UIView *view = node.view; + if ([view isKindOfClass:UITextField.class]) { + UITextField *textField = (UITextField *)view; + NSString *text = textField.text ?: @""; + textField.text = text.length > 0 ? [text substringToIndex:text.length - 1] : text; + [textField sendActionsForControlEvents:UIControlEventEditingChanged]; + return @"sent UITextField backspace"; + } + if ([view isKindOfClass:UITextView.class]) { + UITextView *textView = (UITextView *)view; + NSString *text = textView.text ?: @""; + textView.text = text.length > 0 ? [text substringToIndex:text.length - 1] : text; + return @"sent UITextView backspace"; + } + return [NSString stringWithFormat:@"backspace unsupported for %@", NSStringFromClass(view.class)]; +} + +@end + +@interface SCIntegrationTestHostFactory : SCCIntegrationTestAppIntegrationTestHostModuleFactory +@end + +@implementation SCIntegrationTestHostFactory + +VALDI_REGISTER_MODULE() + +- (id)onLoadModule +{ + return [SCIntegrationTestHost new]; +} + +@end + +@interface SCIntegrationTestFactoryView : UIView + +@property (nonatomic, copy, nullable) NSString *factoryText; + +@end + +@implementation SCIntegrationTestFactoryView + +- (void)setFactoryText:(NSString *)factoryText +{ + _factoryText = [factoryText copy]; + [self setNeedsDisplay]; +} + +- (void)drawRect:(CGRect)rect +{ + CGFloat centerY = CGRectGetMidY(rect); + + UIBezierPath *hexagon = [UIBezierPath bezierPath]; + [hexagon moveToPoint:CGPointMake(44, centerY - 26)]; + [hexagon addLineToPoint:CGPointMake(66, centerY - 13)]; + [hexagon addLineToPoint:CGPointMake(66, centerY + 13)]; + [hexagon addLineToPoint:CGPointMake(44, centerY + 26)]; + [hexagon addLineToPoint:CGPointMake(22, centerY + 13)]; + [hexagon addLineToPoint:CGPointMake(22, centerY - 13)]; + [hexagon closePath]; + [[UIColor colorWithRed:79.0 / 255.0 green:70.0 / 255.0 blue:229.0 / 255.0 alpha:1] setFill]; + [hexagon fill]; + + UIBezierPath *sparkle = [UIBezierPath bezierPath]; + [sparkle moveToPoint:CGPointMake(44, centerY - 16)]; + [sparkle addLineToPoint:CGPointMake(49, centerY - 5)]; + [sparkle addLineToPoint:CGPointMake(60, centerY)]; + [sparkle addLineToPoint:CGPointMake(49, centerY + 5)]; + [sparkle addLineToPoint:CGPointMake(44, centerY + 16)]; + [sparkle addLineToPoint:CGPointMake(39, centerY + 5)]; + [sparkle addLineToPoint:CGPointMake(28, centerY)]; + [sparkle addLineToPoint:CGPointMake(39, centerY - 5)]; + [sparkle closePath]; + [UIColor.whiteColor setFill]; + [sparkle fill]; + + UIFont *font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; + CGRect textRect = CGRectMake(82, floor(centerY - font.lineHeight / 2), CGRectGetWidth(rect) - 82, font.lineHeight); + [self.factoryText drawInRect:textRect withAttributes:@{ + NSFontAttributeName: font, + NSForegroundColorAttributeName: [UIColor colorWithRed:23.0 / 255.0 green:37.0 / 255.0 blue:84.0 / 255.0 alpha:1], + }]; +} + +@end + +@interface SCFactoryIntegrationHost : NSObject +@end + +@implementation SCFactoryIntegrationHost + +- (id)createIntegrationViewFactory +{ + id runtime = SCValdiRuntimeManager.allRuntimeManagers.firstObject.mainRuntime; + NSAssert(runtime != nil, @"The integration app must have an active Valdi runtime"); + + return [runtime makeViewFactoryWithBlock:^UIView *{ + SCIntegrationTestFactoryView *view = [SCIntegrationTestFactoryView new]; + view.opaque = NO; + view.accessibilityIdentifier = @"integration-factory-view"; + return view; + } attributesBinder:^(id binder) { + [binder bindAttribute:@"factoryText" + invalidateLayoutOnChange:NO + withStringBlock:^BOOL(SCIntegrationTestFactoryView *view, NSString *value, id animator) { + view.factoryText = value; + return YES; + } + resetBlock:^(SCIntegrationTestFactoryView *view, id animator) { + view.factoryText = nil; + }]; + } forClass:SCIntegrationTestFactoryView.class]; +} + +@end + +@interface SCFactoryIntegrationHostFactory : SCCIntegrationTestAppFactoryIntegrationHostModuleFactory +@end + +@implementation SCFactoryIntegrationHostFactory + +VALDI_REGISTER_MODULE() + +- (id)onLoadModule +{ + return [SCFactoryIntegrationHost new]; +} + +@end diff --git a/apps/integration_test/src/valdi/integration_test_app/BUILD.bazel b/apps/integration_test/src/valdi/integration_test_app/BUILD.bazel new file mode 100644 index 000000000..1011405e4 --- /dev/null +++ b/apps/integration_test/src/valdi/integration_test_app/BUILD.bazel @@ -0,0 +1,59 @@ +load("@aspect_rules_ts//ts:defs.bzl", "ts_project") +load("//bzl/valdi:valdi_module.bzl", "valdi_module") + +ts_project( + name = "integration_test_app_web", + srcs = glob([ + "web/**/*.ts", + "src/**/*.d.ts", + ]), + allow_js = True, + composite = True, + transpiler = "tsc", + tsconfig = "web/tsconfig.json", + deps = [ + "//:valdi_core_link", + "//:valdi_tsx_link", + "//:web_renderer_link", + ], +) + +valdi_module( + name = "integration_test_app", + srcs = glob([ + "src/**/*.ts", + "src/**/*.tsx", + ]), + android_class_path = "com.snap.valdi.modules.integration_test_app", + android_deps = ["//apps/integration_test/src/android:integration_test_host_android"], + android_output_target = "release", + ios_deps = ["//apps/integration_test/src/ios:integration_test_host_ios"], + ios_module_name = "SCCIntegrationTestApp", + ios_output_target = "release", + inline_assets = True, + macos_deps = ["//apps/integration_test/src/cpp:integration_test_host_cpp"], + res = glob([ + "res/**/*.json", + "res/**/*.svg", + ]), + visibility = ["//visibility:public"], + web_deps = [":integration_test_app_web"], + # Exercise external Web asset emission in the integration harness. Other + # examples, including Hello World, retain the default inline policy. + web_no_inline_images = True, + web_register_native_module_id_overrides = { + "integration_test_app/web/FactoryIntegrationHost.js": "integration_test_app/src/FactoryIntegrationHost", + "integration_test_app/web/IntegrationTestHost.js": "integration_test_app/src/IntegrationTestHost", + }, + web_workers = ["integration_test_app/src/WebWorkerProbe"], + deps = [ + "//src/valdi_modules/src/valdi/coreutils", + "//src/valdi_modules/src/valdi/drawing", + "//src/valdi_modules/src/valdi/file_system", + "//src/valdi_modules/src/valdi/valdi_core", + "//src/valdi_modules/src/valdi/valdi_tsx", + "//src/valdi_modules/src/valdi/valdi_webview", + "//src/valdi_modules/src/valdi/web_renderer", + "//src/valdi_modules/src/valdi/worker", + ], +) diff --git a/apps/integration_test/src/valdi/integration_test_app/res/animation.json b/apps/integration_test/src/valdi/integration_test_app/res/animation.json new file mode 100644 index 000000000..99b0ebc56 --- /dev/null +++ b/apps/integration_test/src/valdi/integration_test_app/res/animation.json @@ -0,0 +1,52 @@ +{ + "v": "5.7.4", + "fr": 30, + "ip": 0, + "op": 60, + "w": 120, + "h": 120, + "nm": "ValdiIntegrationFixture", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "circle", + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100}, + "r": {"a": 0, "k": 0}, + "p": {"a": 0, "k": [60, 60, 0]}, + "a": {"a": 0, "k": [0, 0, 0]}, + "s": { + "a": 1, + "k": [ + {"t": 0, "s": [70, 70, 100]}, + {"t": 30, "s": [100, 100, 100]}, + {"t": 60, "s": [70, 70, 100]} + ] + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + {"ty": "el", "p": {"a": 0, "k": [0, 0]}, "s": {"a": 0, "k": [80, 80]}, "nm": "ellipse"}, + {"ty": "fl", "c": {"a": 0, "k": [0.0, 0.48, 1.0, 1]}, "o": {"a": 0, "k": 100}, "nm": "fill"}, + {"ty": "st", "c": {"a": 0, "k": [1.0, 0.8, 0.0, 1]}, "o": {"a": 0, "k": 100}, "w": {"a": 0, "k": 8}, "lc": 2, "lj": 2, "nm": "stroke"}, + {"ty": "tr", "p": {"a": 0, "k": [0, 0]}, "a": {"a": 0, "k": [0, 0]}, "s": {"a": 0, "k": [100, 100]}, "r": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100}} + ], + "nm": "group" + } + ], + "ip": 0, + "op": 60, + "st": 0, + "bm": 0 + } + ], + "markers": [] +} diff --git a/apps/integration_test/src/valdi/integration_test_app/res/image.svg b/apps/integration_test/src/valdi/integration_test_app/res/image.svg new file mode 100644 index 000000000..6bd09ffe5 --- /dev/null +++ b/apps/integration_test/src/valdi/integration_test_app/res/image.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/integration_test/src/valdi/integration_test_app/res/tint_mask.svg b/apps/integration_test/src/valdi/integration_test_app/res/tint_mask.svg new file mode 100644 index 000000000..7dd3a281e --- /dev/null +++ b/apps/integration_test/src/valdi/integration_test_app/res/tint_mask.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/integration_test/src/valdi/integration_test_app/src/FactoryIntegrationHost.d.ts b/apps/integration_test/src/valdi/integration_test_app/src/FactoryIntegrationHost.d.ts new file mode 100644 index 000000000..c3aea55d4 --- /dev/null +++ b/apps/integration_test/src/valdi/integration_test_app/src/FactoryIntegrationHost.d.ts @@ -0,0 +1,8 @@ +import { ViewFactory } from 'valdi_tsx/src/ViewFactory'; + +/** + * @ExportModule + */ + +// @ExportFunction +export function createIntegrationViewFactory(): ViewFactory; diff --git a/apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestApp.tsx b/apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestApp.tsx new file mode 100644 index 000000000..dedc78fd8 --- /dev/null +++ b/apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestApp.tsx @@ -0,0 +1,192 @@ +import { StatefulComponent } from 'valdi_core/src/Component'; +import { Device } from 'valdi_core/src/Device'; +import { ElementRef } from 'valdi_core/src/ElementRef'; + +import { INTEGRATION_TEST_CASES } from './IntegrationTestCases'; +import { + CaptureOutcome, + IntegrationTestRunner, + SNAPSHOT_HEIGHT, + SNAPSHOT_WIDTH, +} from './IntegrationTestRunner'; + +/** + * @ViewModel + * @ExportModel + */ +export interface ViewModel {} + +/** + * @Context + * @ExportModel + */ +export interface ComponentContext {} + +interface State { + currentIndex: number; + finished: boolean; + summary: string; + insetTop: number; + insetRight: number; + insetBottom: number; + insetLeft: number; +} + +function readShellInsets(): Pick { + return { + insetTop: Device.getDisplayTopInset(), + insetRight: Device.getDisplayRightInset(), + insetBottom: Device.getDisplayBottomInset(), + insetLeft: Device.getDisplayLeftInset(), + }; +} + +/** + * @Component + * @ExportModel + */ +export class IntegrationTestApp extends StatefulComponent { + state: State = { + currentIndex: 0, + finished: false, + summary: 'starting', + ...readShellInsets(), + }; + + private rootRef = new ElementRef(); + private snapshotRef = new ElementRef(); + private targetRef = new ElementRef(); + private runner?: IntegrationTestRunner; + private hasStarted = false; + private scheduledCaptureCaseId?: string; + + onCreate(): void { + this.runner = new IntegrationTestRunner(this.renderer); + + const insetsObserver = Device.observeDisplayInsetChange(() => { + this.setState(readShellInsets()); + }); + this.registerDisposable(() => insetsObserver.cancel()); + } + + private getRunner(): IntegrationTestRunner { + if (!this.runner) { + this.runner = new IntegrationTestRunner(this.renderer); + } + return this.runner; + } + + private async captureCurrentCase(): Promise { + const runner = this.getRunner(); + this.hasStarted = true; + const outcome = await runner.captureCurrentCase({ + currentIndex: this.state?.currentIndex ?? 0, + isFinished: this.state?.finished ?? false, + rootRef: this.rootRef, + snapshotRef: this.snapshotRef, + targetRef: this.targetRef, + }); + this.applyCaptureOutcome(outcome); + } + + private applyCaptureOutcome(outcome: CaptureOutcome): void { + if (outcome.kind === 'noop') { + return; + } + + if (outcome.kind === 'finished') { + this.setState({ + finished: true, + summary: outcome.summary, + }); + return; + } + + this.rootRef = new ElementRef(); + this.snapshotRef = new ElementRef(); + this.targetRef = new ElementRef(); + this.setState({ + currentIndex: outcome.nextIndex, + summary: outcome.summary, + }); + } + + private scheduleCaptureForRenderedCase(caseId: string): void { + if (this.scheduledCaptureCaseId === caseId) { + return; + } + + this.scheduledCaptureCaseId = caseId; + this.renderer.onLayoutComplete(() => { + const currentIndex = this.state?.currentIndex ?? 0; + const currentCase = INTEGRATION_TEST_CASES[currentIndex]; + if (!this.state?.finished && currentCase?.id === caseId) { + void this.captureCurrentCase(); + } + }); + } + + onRender(): void { + const currentIndex = this.state?.currentIndex ?? 0; + const testCase = INTEGRATION_TEST_CASES[currentIndex]; + const insetTop = this.state?.insetTop ?? 0; + const insetRight = this.state?.insetRight ?? 0; + const insetBottom = this.state?.insetBottom ?? 0; + const insetLeft = this.state?.insetLeft ?? 0; + + if (this.state?.finished || !testCase) { + + ; + return; + } + + const runner = this.getRunner(); + runner.prepareCase(testCase.id); + + + + {testCase.render({ + caseId: testCase.id, + rootRef: this.rootRef, + targetRef: this.targetRef, + record: (message: string) => runner.record(message), + })} + + ; + + // Schedule after emitting the test body so the layout callback observes this render. + this.scheduleCaptureForRenderedCase(testCase.id); + } +} diff --git a/apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestCases.tsx b/apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestCases.tsx new file mode 100644 index 000000000..b7bb5b1de --- /dev/null +++ b/apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestCases.tsx @@ -0,0 +1,5552 @@ +import { FontManager } from 'drawing/src/FontManager'; +import { Base64 } from 'coreutils/src/Base64'; +import { GeometricPathBuilder, GeometricPathScaleType } from 'valdi_core/src/GeometricPath'; +import { Component, StatefulComponent } from 'valdi_core/src/Component'; +import { Style } from 'valdi_core/src/Style'; +import type { Asset } from 'valdi_core/src/Asset'; +import { ValdiRuntime } from 'valdi_core/src/ValdiRuntime'; +import { AttributedTextBuilder } from 'valdi_core/src/utils/AttributedTextBuilder'; +import { ImageFilters } from 'valdi_core/src/utils/ImageFilter'; +import { AttributedTextInlineViewVerticalAlignment } from 'valdi_tsx/src/AttributedTextInlineViewAttachment'; +import type { ViewFactory } from 'valdi_tsx/src/ViewFactory'; +import { Worker } from 'worker/src/Worker'; +import type { + AnimatedImage, + BlurView, + ImageView, + IWebViewNativeController, + Label, + Layout, + ShapeView, + SpinnerView, + TextField, + TextView, + View, + WebViewElement, +} from 'valdi_tsx/src/NativeTemplateElements'; + +import res from '../res'; +import { createIntegrationViewFactory } from './FactoryIntegrationHost'; +import { + getPlatform, + submitTouchSequence, + focusTextInput, + pressReturn, + replaceText, + pressBackspace, +} from './IntegrationTestHost'; +import { + IntegrationTestAttributeCoverage, + IntegrationTestCase, + IntegrationTestCoverageKind, + IntegrationTestInteractionContext, + IntegrationTestRenderContext, + NativeTemplateElementName, +} from './IntegrationTestTypes'; + +declare const runtime: ValdiRuntime; + +const WIDTH = 360; +const HEIGHT = 560; +const CARD = '#F8FAFC'; +const ANIMATED_IMAGE_PNG_DATA_URL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHgAAABQCAYAAADSm7GJAAABiUlEQVR4nO3TsU0FMRQF0W2BjA5ICciohFZokISelgqQ/n7be98bz5UmXa985OP8fD936uPna6uO9IULLLDAAgsssMB7lL5wgQUWWGCBBRZ4jwSGJzA8geEJDE9geALDExiewPDQwC+vbw+VRhB4Aeou2BjgUVgqdHvg2bA06NbAq3EJyC2B74IlQLcDTuF2RW4FnMbtiCywwDVKo3ZFbgE8ivHfdkDGAl+dwGBcMjIOeHQCg3GJyBjg2RO4EPCqCVwAtwJwZWSBBa4NvHoCCyywwEHg4/s8K9YN+Pw9SiawwAILHAReiXzpHwpgCixwrboApxHxwCuQCa8XBTwT+fK5BSBbAieQSbhI4BHkp84qgNga+C5kIi4a+BHs4e8WAEQAz0CeXRoOB1wJOY0msMA9gSsgp8HwwEnkNNY2wHdDp5G2Bb4DOQ20PfAq6DSMwIug0yACL8BOIwg8sfSFCyywwAILLLDAeyQwPIHhCQxPYHgCwxMYnsDwBIYnMDyB4QkMT2B4AsP7AzsXR9oA2ei4AAAAAElFTkSuQmCC'; + +const INLINE_IMAGE_BYTES = pngDataUrlBytes(ANIMATED_IMAGE_PNG_DATA_URL); + +let integrationViewFactory: ViewFactory | undefined; + +function pngDataUrlBytes(dataUrl: string): Uint8Array { + return Base64.toByteArray(dataUrl.substring(dataUrl.indexOf(',') + 1)); +} + +function coverage( + kind: IntegrationTestCoverageKind, + attributes: readonly string[], +): readonly IntegrationTestAttributeCoverage[] { + return [{ kind, attributes }]; +} + +function configureIntegrationColorPalettes(): void { + runtime.configureColorPalette('integration-light', { + background: '#DBEAFE', + foreground: '#1D4ED8', + accent: '#047857', + }); + runtime.configureColorPalette('integration-dark', { + background: '#111827', + foreground: '#FBBF24', + accent: '#F97316', + }); + runtime.setActiveColorPalette('integration-light'); +} + +function lottieResourceSource(ctx: IntegrationTestRenderContext): Asset | string { + try { + const bytes = runtime.getModuleEntry('integration_test_app', 'res/animation.json', false) as Uint8Array; + ctx.record(`lottie resource bytes:${bytes.length}`); + return runtime.makeAssetFromBytes(bytes); + } catch (error: any) { + ctx.record(`lottie resource unavailable:${error?.message ?? String(error)}`); + return 'integration-test-missing-lottie.json'; + } +} + +const styleLayoutBase = new Style({ + padding: 12, + width: '100%', +}); + +const styleViewCard = new Style({ + backgroundColor: '#DBEAFE', + border: '2 solid #2563EB', + borderRadius: 12, + height: 92, + width: '100%', +}); + +const styleLabelSample = new Style