UNPKG

uicore-ts

Version:

UICore is a library to build native-like user interfaces using pure Typescript. No HTML is needed at all. Components are described as TS classes and all user interactions are handled explicitly. This library is strongly inspired by the UIKit framework tha

1,651 lines (1,230 loc) 150 kB
import { IS_FIREFOX, IS_SAFARI } from "./ClientCheckers" import toPx from "./SizeConverter" import { UIColor } from "./UIColor" import { UICore } from "./UICore" import "./UICoreExtensions" import type { UIDialogView } from "./UIDialogView" import { UILocalizedTextObject } from "./UIInterfaces" import { UILayoutCycleTracer } from "./UILayoutCycleTracer" import { UILayoutDebugger } from "./UILayoutDebugger" import { FIRST, FIRST_OR_NIL, IF, IS, IS_DEFINED, IS_NIL, IS_NOT, IS_NOT_LIKE_NULL, nil, NO, RETURNER, UIInitializerObject, UIObject, YES } from "./UIObject" import { UIPoint } from "./UIPoint" import { UIRectangle } from "./UIRectangle" import { UITextMeasurement } from "./UITextMeasurement" import { UIViewController } from "./UIViewController" declare module AutoLayout { class Constraint { [key: string]: any } class View { [key: string]: any } class VisualFormat { static parse(arg0: any, arg1: any): any; [key: string]: any } const enum Attribute { LEFT = 0, RIGHT = 1, BOTTOM = 2, TOP = 3, CENTERX = 4, CENTERY = 5, WIDTH = 6, HEIGHT = 7, ZINDEX = 8, VARIABLE = 9, NOTANATTRIBUTE = 10 } const enum Relation { EQU = 0, LEQ = 1, GEQ = 2 } } // @ts-ignore if (!window.AutoLayout) { // @ts-ignore window.AutoLayout = nil } declare global { interface HTMLElement { UIViewObject?: UIView; } } export interface LooseObject { [key: string]: any } export interface ControlEventTargetsObject { [key: string]: Function[]; } export interface UIViewBroadcastEvent { name: string; parameters?: { [key: string]: string | string[]; } } type Mutable<T> = { -readonly [P in keyof T]: T[P] }; type ColorStyleProxy = { [K in keyof CSSStyleDeclaration as K extends string ? (Lowercase<K> extends `${string}color${string}` ? K : never) : never]?: UIColor } export type UIViewAddControlEventTargetObject<T extends { controlEvent: Record<string, any> }> = Mutable<{ -readonly [K in keyof T["controlEvent"]]: (( sender: UIView, event: Event ) => void) & Partial<UIViewAddControlEventTargetObject<T>> }> interface Constraint { constant: number; multiplier: number; view1: any; attr2: any; priority: number; attr1: any; view2: any; relation: any } export interface IUILoadingView extends UIView { theme: "light" | "dark"; // Add any other specific methods you need to call from UIView } /** * A template literal tag to enable CSS highlighting in IDEs. * Example: css` .myClass { color: red; } ` */ export function css(strings: TemplateStringsArray, ...values: any[]): string { // Simply combine the strings and values to return a valid CSS string return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "") } export function UIComponentView(target: Function, context: ClassDecoratorContext) { console.log("Recording annotation UIComponentView on " + target.name) UIObject.recordAnnotation(UIComponentView, target) } export class UIView extends UIObject { _nativeSelectionEnabled: boolean = YES _shouldLayout?: boolean _UITableViewRowIndex?: number _UITableViewReusabilityIdentifier: any _UIViewIntrinsicTemporaryWidth?: string _UIViewIntrinsicTemporaryHeight?: string _enabled: boolean = YES _frame?: UIRectangle & { zIndex?: number } _frameCache?: UIRectangle _backgroundColor: UIColor = nil _colorStyleProxy?: ColorStyleProxy _liveCSSValues: Map<string, { keys: string[], producer: () => string }> = new Map() _liveCSSCallback: () => void = () => { for (const [property, { producer }] of this._liveCSSValues) { (this.style as any)[property] = producer() } } _overlayView?: UIView get overlayView(): UIView | undefined { return this._overlayView } set overlayView(view: UIView) { if (IS(view)) { this.addSubview(view) } else { this._overlayView?.removeFromSuperview() } this._overlayView = view } _viewHTMLElement!: HTMLElement & LooseObject // Dynamic innerHTML _innerHTMLKey?: string _defaultInnerHTML?: string _parameters?: { [x: string]: (string | UILocalizedTextObject) } _localizedTextObject?: UILocalizedTextObject _controlEventTargets: ControlEventTargetsObject = {} //{ "PointerDown": Function[]; "PointerMove": Function[]; // "PointerLeave": Function[]; "PointerEnter": Function[]; // "PointerUpInside": Function[]; "PointerUp": Function[]; // "PointerHover": Function[]; }; _broadcastEventTargets: { [eventName: string]: ((event: UIViewBroadcastEvent) => void)[] } = {} _ongoingControlEventHandlers: { _controlEventKey: string }[] = [] _frameTransform: string viewController?: UIViewController _updateLayoutFunction?: Function // @ts-ignore _constraints: any[] //AutoLayout.Constraint[]; superview?: UIView subviews: UIView[] = [] _styleClasses: any[] _isHidden: boolean = NO pausesPointerEvents: boolean = NO stopsPointerEventPropagation: boolean = YES pointerDraggingPoint: UIPoint = new UIPoint(0, 0) _previousClientPoint: UIPoint = new UIPoint(0, 0) _isPointerInside?: boolean _isPointerValid?: boolean _isPointerValidForMovement?: boolean _isPointerDown = NO _initialPointerPosition?: UIPoint _hasPointerDragged?: boolean _pointerDragThreshold = 2 ignoresTouches: boolean = NO ignoresMouse: boolean = NO core: UICore = UICore.main static _UIViewIndex: number = -1 _UIViewIndex: number static _viewsToLayout: UIView[] = [] forceIntrinsicSizeZero: boolean = NO _touchEventTime?: number static _pageScale = 1 _scale: number = 1 isInternalScaling: boolean = YES // When the page's fonts finish loading the browser's glyph metrics change, // making all previously cached text measurements stale. We hook the native // FontFaceSet.ready promise once at class-load time. When it fires we: // 1. Clear all UITextMeasurement style caches (stale fallback-font metrics) // 2. Clear every view's intrinsic size cache and frame cache // 3. Force a full re-layout, mirroring what the window resize handler does // document.fonts.ready resolves once all fonts have loaded, but the browser // may not yet have applied the new glyphs to rendered elements at that point. // A requestAnimationFrame defers the cache-bust and re-layout by one paint // cycle, ensuring getComputedStyle and the canvas measurer see real metrics. static _fontReadyHook = (() => { document.fonts.ready.then(() => { requestAnimationFrame(() => { UITextMeasurement.clearCaches() const rootView = UICore.main?.rootViewController?.view if (!rootView) { return } rootView.forEachViewInSubtree(view => { view.clearIntrinsicSizeCache() view._frameCache = undefined view.documentFontsDidLoad() view.setNeedsLayout() }) UIView.layoutViewsIfNeeded() }) }) return YES })() static resizeObserver = new ResizeObserver((entries, observer) => { for (let i = 0; i < entries.length; i++) { const entry = entries[i] // @ts-ignore const view: UIView = entry.target.UIView view?.didResize?.(entry) } }) private _isMovable = NO makeNotMovable?: () => void private _isResizable = NO makeNotResizable?: () => void resizingHandles: UIView[] = [] public static LoadingViewClass: new () => IUILoadingView private _loadingView?: IUILoadingView public set loading(isLoading: boolean) { this.userInteractionEnabled = !isLoading if (isLoading) { if (!IS(this._loadingView)) { if (UIView.LoadingViewClass) { this._loadingView = new UIView.LoadingViewClass() } else { console.warn("UILoadingView class not registered yet.") return } } // Add to superview if not already added if (this._loadingView.superview != this) { this.addSubview(this._loadingView) // Ensure it sits above other content this._loadingView.style.zIndex = "1000" } // Force an immediate layout update to position the overlay // this._loadingView.setFrame(this.bounds) } else { this._loadingView?.removeFromSuperview() } } public get loading(): boolean { return IS(this._loadingView) && IS(this._loadingView.superview) } private _isMoving: boolean = NO _isCBEditorTemporaryResizable = NO _isCBEditorTemporaryMovable = NO static _onWindowTouchMove: (event: TouchEvent) => void = nil static _onWindowMouseMove: (event: MouseEvent) => void = nil static _onWindowMouseup: (event: MouseEvent) => void = nil private _resizeObserverEntry?: ResizeObserverEntry protected _intrinsicSizesCache: Record<string, UIRectangle> = {} /** When set, this view reads and writes its intrinsic size cache entries from the * static shared map keyed by this identifier rather than from its own per-instance * cache. All views that share the same identifier share the same cached sizes, so * only the first measurement in a group is ever computed. * * Invalidate with UIView.invalidateSharedIntrinsicSizeCache(identifier). */ sharedIntrinsicSizeCacheIdentifier?: string /** Static shared intrinsic size cache, keyed by sharedIntrinsicSizeCacheIdentifier. * Each bucket mirrors the per-instance _intrinsicSizesCache format. */ static _sharedIntrinsicSizeCaches: Map<string, Record<string, UIRectangle>> = new Map() static invalidateSharedIntrinsicSizeCache(identifier: string): void { UIView._sharedIntrinsicSizeCaches.delete(identifier) } static invalidateAllSharedIntrinsicSizeCaches(): void { UIView._sharedIntrinsicSizeCaches.clear() } private static _virtualLayoutingDepth = 0 static get isVirtualLayouting(): boolean { return this._virtualLayoutingDepth > 0 } get isVirtualLayouting(): boolean { return UIView._virtualLayoutingDepth > 0 } startVirtualLayout() { UIView._virtualLayoutingDepth = UIView._virtualLayoutingDepth + 1 } finishVirtualLayout() { if (UIView._virtualLayoutingDepth === 0) { throw new Error("Unbalanced finishVirtualLayout()") } UIView._virtualLayoutingDepth = UIView._virtualLayoutingDepth - 1 } _frameForVirtualLayouting?: UIRectangle _frameCacheForVirtualLayouting?: UIRectangle // Change this to no if the view contains pure HTML content that does not // use frame logic that can influence the intrinsic size usesVirtualLayoutingForIntrinsicSizing = YES _contentInsets = { top: 0, left: 0, bottom: 0, right: 0 } get contentInsets() { return this._contentInsets } set contentInsets(insets: { left: number, right: number, bottom: number, top: number }) { this._contentInsets = insets this.setNeedsLayout() } constructor( elementID: string = ("UIView" + UIView.nextIndex), viewHTMLElement: HTMLElement & LooseObject | null = null, elementType: string | null = null, preInitConfiguratorObject?: any ) { super() this.configureWithObject(preInitConfiguratorObject) // Instance variables UIView._UIViewIndex = UIView.nextIndex this._UIViewIndex = UIView._UIViewIndex this._styleClasses = [] this._initViewHTMLElement(elementID, viewHTMLElement, elementType) this._constraints = [] this._frameTransform = "" this._initViewCSSSelectorsIfNeeded() this._loadUIEvents() this.setNeedsLayout() } static get nextIndex() { return UIView._UIViewIndex + 1 } // static get pageHeight() { // const body = document.body // const html = document.documentElement // return Math.max( // body.scrollHeight, // body.offsetHeight, // html.clientHeight, // html.scrollHeight, // html.offsetHeight // ) // } // // static get pageWidth() { // const body = document.body // const html = document.documentElement // return Math.max(body.scrollWidth, body.offsetWidth, html.clientWidth, html.scrollWidth, html.offsetWidth) // } //#region Static Properties - Page Dimensions Cache private static _cachedPageWidth: number | undefined private static _cachedPageHeight: number | undefined private static _pageDimensionsCacheValid = false private static _resizeObserverInitialized = false //#endregion //#region Static Methods - Page Dimensions /** * Initialize resize observer to invalidate cache when page dimensions change. * This is called lazily on first access. */ private static _initializePageDimensionsCacheIfNeeded() { if (this._resizeObserverInitialized) { return } this._resizeObserverInitialized = true // Invalidate cache on window resize window.addEventListener("resize", () => { this._pageDimensionsCacheValid = false }, { passive: true }) // Observe document.body for mutations that might affect dimensions const bodyObserver = new ResizeObserver(() => { this._pageDimensionsCacheValid = false }) // Start observing once body is available if (document.body) { bodyObserver.observe(document.body) } else { // Wait for DOMContentLoaded if body isn't ready yet document.addEventListener("DOMContentLoaded", () => { bodyObserver.observe(document.body) }, { once: true }) } // Also invalidate on DOM mutations that might add/remove content const mutationObserver = new MutationObserver(() => { this._pageDimensionsCacheValid = false }) const observeMutations = () => { mutationObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style", "class"] }) } if (document.body) { observeMutations() } else { document.addEventListener("DOMContentLoaded", observeMutations, { once: true }) } } /** * Compute and cache page dimensions. * Only triggers reflow when cache is invalid. */ private static _updatePageDimensionsCacheIfNeeded() { if (this._pageDimensionsCacheValid && this._cachedPageWidth !== undefined && this._cachedPageHeight !== undefined) { return } const body = document.body const html = document.documentElement // Compute both at once to minimize reflows this._cachedPageWidth = Math.max( body.scrollWidth, body.offsetWidth, html.clientWidth, html.scrollWidth, html.offsetWidth ) this._cachedPageHeight = Math.max( body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight ) this._pageDimensionsCacheValid = true } static get pageWidth() { this._initializePageDimensionsCacheIfNeeded() this._updatePageDimensionsCacheIfNeeded() return this._cachedPageWidth! } static get pageHeight() { this._initializePageDimensionsCacheIfNeeded() this._updatePageDimensionsCacheIfNeeded() return this._cachedPageHeight! } /** * Manually invalidate the page dimensions cache. * Useful if you know dimensions changed and want to force a recalculation. */ static invalidatePageDimensionsCache() { this._pageDimensionsCacheValid = false } //#endregion centerInContainer() { this.style.left = "50%" this.style.top = "50%" this.style.transform = "translateX(-50%) translateY(-50%)" } centerXInContainer() { this.style.left = "50%" this.style.transform = "translateX(-50%)" } centerYInContainer() { this.style.top = "50%" this.style.transform = "translateY(-50%)" } _initViewHTMLElement( elementID: string, viewHTMLElement: (HTMLElement & LooseObject) | null, elementType?: string | null ) { if (!IS(elementType)) { elementType = "div" } if (!IS(viewHTMLElement)) { this._viewHTMLElement = this.createElement(elementID, elementType) this.style.position = "absolute" this.style.margin = "0" } else { this._viewHTMLElement = viewHTMLElement } if (IS(elementID)) { this.viewHTMLElement.id = elementID } this.viewHTMLElement.obeyAutolayout = YES this.viewHTMLElement.UIViewObject = this this.addStyleClass(this.styleClassName) } set nativeSelectionEnabled(selectable: boolean) { this._nativeSelectionEnabled = selectable if (!selectable) { this.style.cssText = this.style.cssText + " -webkit-touch-callout: none; -webkit-user-select: none; " + "-khtml-user-select: none; -moz-user-select: none; " + "-ms-user-select: none; user-select: none;" } else { this.style.cssText = this.style.cssText + " -webkit-touch-callout: text; -webkit-user-select: text; " + "-khtml-user-select: text; -moz-user-select: text; " + "-ms-user-select: text; user-select: text;" } } get nativeSelectionEnabled() { return this._nativeSelectionEnabled } get styleClassName() { return "UICore_UIView_" + this.class.name } protected _initViewCSSSelectorsIfNeeded() { if (!this.class._areViewCSSSelectorsInitialized) { this.initViewStyleSelectors() this.class._areViewCSSSelectorsInitialized = YES } } initViewStyleSelectors() { // Override this in a subclass } initStyleSelector(selector: string, style: string) { const styleRules = UIView.getStyleRules(selector) if (!styleRules) { UIView.createStyleSelector(selector, style) } } createElement(elementID: string, elementType: string) { let result = document.getElementById(elementID) if (!result) { result = document.createElement(elementType) } return result } public get viewHTMLElement() { return this._viewHTMLElement } public get elementID() { return this.viewHTMLElement.id } setInnerHTML(key: string, defaultString: string, parameters?: { [x: string]: string | UILocalizedTextObject }) { this._innerHTMLKey = key this._defaultInnerHTML = defaultString this._parameters = parameters const languageName = UICore.languageService.currentLanguageKey const result = UICore.languageService.stringForKey(key, languageName, defaultString, parameters) this.innerHTML = result ?? "" } protected _setInnerHTMLFromKeyIfPossible() { if (this._innerHTMLKey && this._defaultInnerHTML) { this.setInnerHTML(this._innerHTMLKey, this._defaultInnerHTML, this._parameters) } } protected _setInnerHTMLFromLocalizedTextObjectIfPossible() { if (IS(this._localizedTextObject)) { this.innerHTML = UICore.languageService.stringForCurrentLanguage(this._localizedTextObject) } } get localizedTextObject() { return this._localizedTextObject } set localizedTextObject(localizedTextObject: UILocalizedTextObject | undefined) { this._localizedTextObject = localizedTextObject this._setInnerHTMLFromLocalizedTextObjectIfPossible() } get innerHTML() { return this.viewHTMLElement.innerHTML } set innerHTML(innerHTML) { if (this.innerHTML != innerHTML) { this.viewHTMLElement.innerHTML = FIRST(innerHTML, "") } } set hoverText(hoverText: string | undefined | null) { if (IS_NOT_LIKE_NULL(hoverText)) { this.viewHTMLElement.setAttribute("title", hoverText) } else { this.viewHTMLElement.removeAttribute("title") } } get hoverText() { return this.viewHTMLElement.getAttribute("title") } get scrollSize() { return new UIRectangle(0, 0, this.viewHTMLElement.scrollHeight, this.viewHTMLElement.scrollWidth) } get dialogView(): UIDialogView | undefined { if (!IS(this.superview)) { return } if (!((this as any)["_isAUIDialogView"])) { return this.superview.dialogView } return this as any as UIDialogView } get rootView(): UIView { if (IS(this.superview)) { return this.superview.rootView } return this } public set enabled(enabled: boolean) { this._enabled = enabled this.updateContentForCurrentEnabledState() } public get enabled(): boolean { return this._enabled } updateContentForCurrentEnabledState() { this.hidden = !this.enabled this.userInteractionEnabled = this.enabled } public get tabIndex(): number { return Number(this.viewHTMLElement.getAttribute("tabindex")) } public set tabIndex(index: number) { this.viewHTMLElement.setAttribute("tabindex", "" + index) } get propertyDescriptors(): { object: UIObject; name: string }[] { let result: any[] = [] function isPlainObject(value: any): value is object { return value instanceof Object && Object.getPrototypeOf(value) === Object.prototype } function isAnArray(value: any): value is any[] { return value instanceof Array && Object.getPrototypeOf(value) === Array.prototype } this.withAllSuperviews.forEach(view => { const descriptorFromObject = function ( this: UIView, object?: object, pathRootObject: object = object!, existingPathComponents: string[] = [], lookInArrays = YES, depthLeft = 5 ): { subObjects: { object: object; key: string }[]; descriptor: { name: string; object: object } | undefined } { let resultDescriptor: { name: string; object: object } | undefined const subObjects: { object: object, key: string }[] = [] const subArrays: { array: any[], key: string }[] = [] FIRST_OR_NIL(object).forEach((value, key, stopLooping) => { if (this == value) { existingPathComponents.push(key) resultDescriptor = { object: pathRootObject!, name: existingPathComponents.join(".") } stopLooping() return } if ( isPlainObject(value) && !_UIViewPropertyKeys.contains(key) && !_UIViewControllerPropertyKeys.contains(key) ) { subObjects.push({ object: value, key: key }) } if ( lookInArrays && isAnArray(value) && !_UIViewPropertyKeys.contains(key) && !_UIViewControllerPropertyKeys.contains(key) ) { subArrays.push({ array: value, key: key }) } }) if (!resultDescriptor && lookInArrays) { subArrays.copy().forEach(value => { if (value.key.startsWith("_")) { subArrays.removeElement(value) subArrays.push(value) } }) subArrays.find(arrayObject => arrayObject.array.find((value, index) => { if (this == value) { existingPathComponents.push(arrayObject.key + "." + index) resultDescriptor = { object: pathRootObject!, name: existingPathComponents.join(".") } return YES } return NO }) ) } // if (!resultDescriptor && depthLeft) { // // // @ts-ignore // resultDescriptor = subObjects.find(object => descriptorFromObject( // object, // pathRootObject, // existingPathComponents, // NO, // depthLeft - 1 // )) // // } if (resultDescriptor?.object?.constructor?.name == "Object") { var asd = 1 } const result = { descriptor: resultDescriptor, subObjects: subObjects } return result }.bind(this) const viewControllerResult = descriptorFromObject(view.viewController) if (viewControllerResult?.descriptor) { result.push(viewControllerResult.descriptor) } const viewResult = descriptorFromObject(view) if (viewResult?.descriptor) { result.push(viewResult.descriptor) } else if (this.superview && this.superview == view) { result.push({ object: view, key: "subviews." + view.subviews.indexOf(this) }) } // view.forEach((value, key, stopLooping) => { // // if (this == value) { // // result.push({ object: view, name: key }) // // stopLooping() // // } // // }) }) return result } get styleClasses() { return this._styleClasses } set styleClasses(styleClasses) { this._styleClasses = styleClasses } hasStyleClass(styleClass: string) { // This is for performance reasons if (!IS(styleClass)) { return NO } const index = this.styleClasses.indexOf(styleClass) if (index > -1) { return YES } return NO } addStyleClass(styleClass: string) { if (!IS(styleClass)) { return } if (!this.hasStyleClass(styleClass)) { this._styleClasses.push(styleClass) } } removeStyleClass(styleClass: string) { // This is for performance reasons if (!IS(styleClass)) { return } const index = this.styleClasses.indexOf(styleClass) if (index > -1) { this.styleClasses.splice(index, 1) } } static findViewWithElementID(elementID: string): UIView | undefined { const viewHTMLElement = document.getElementById(elementID) if (IS_NOT(viewHTMLElement)) { return } // @ts-ignore return viewHTMLElement.UIView } static injectCSS(cssText: string, id?: string) { if (id) { const existing = document.getElementById(id) as HTMLStyleElement | null if (existing) { if (existing.textContent !== cssText) { existing.textContent = cssText } return } } const style = document.createElement("style") if (id) { style.id = id } style.textContent = cssText document.head.appendChild(style) } static createStyleSelector(selector: string, style: string) { this.injectCSS(selector + " { " + style + " }") } static getStyleRules(selector: string) { // https://stackoverflow.com/questions/324486/how-do-you-read-css-rule-values-with-javascript //Inside closure so that the inner functions don't need regeneration on every call. const getCssClasses = (function () { function normalize(str: string) { if (!str) { return "" } str = String(str).replace(/\s*([>~+])\s*/g, " $1 ") //Normalize symbol spacing. return str.replace(/(\s+)/g, " ").trim() //Normalize whitespace } function split(str: string, on: string) { //Split, Trim, and remove empty elements return str.split(on).map(x => x.trim()).filter(x => x) } function containsAny(selText: string | any[], ors: any[]) { return selText ? ors.some(x => selText.indexOf(x) >= 0) : false } return function (selector: string) { const logicalORs = split(normalize(selector), ",") const sheets = Array.from(window.document.styleSheets) const ruleArrays = sheets.map((x) => Array.from(x.rules || x.cssRules || [])) const allRules = ruleArrays.reduce((all, x) => all.concat(x), []) // @ts-ignore return allRules.filter((x) => containsAny(normalize(x.selectorText), logicalORs)) } })() return getCssClasses(selector) // selector = selector.toLowerCase() // let styleRules // for (let i = 0; i < document.styleSheets.length; i++) { // const styleSheet = document.styleSheets[i] as any // // try { // // styleRules = styleSheet.cssRules ? styleSheet.cssRules : styleSheet.rules // // } catch (error) { // // console.log(error) // // } // // } // // return styleRules } get style() { return this.viewHTMLElement.style } get computedStyle() { return getComputedStyle(this.viewHTMLElement) } public get hidden(): boolean { return this._isHidden } public set hidden(v: boolean) { this._isHidden = v if (this._isHidden) { this.style.visibility = "hidden" } else { this.style.visibility = "visible" } } static set pageScale(scale: number) { UIView._pageScale = scale const zoom = scale const width = 100 / zoom const viewHTMLElement = UICore.main.rootViewController.view.viewHTMLElement viewHTMLElement.style.transformOrigin = "left top" viewHTMLElement.style.transform = "scale(" + zoom + ")" viewHTMLElement.style.width = width + "%" } static get pageScale() { return UIView._pageScale } set scale(scale: number) { this._scale = scale const zoom = scale // const width = 100 / zoom // const height = 100 / zoom const viewHTMLElement = this.viewHTMLElement viewHTMLElement.style.transformOrigin = "left top" viewHTMLElement.style.transform = viewHTMLElement.style.transform.replace( (viewHTMLElement.style.transform.match( new RegExp("scale\\(.*\\)", "g") )?.firstElement ?? ""), "" ) + "scale(" + zoom + ")" // viewHTMLElement.style.width = width + "%" // viewHTMLElement.style.height = height + "%" if (this.isInternalScaling) { this.setFrame(this.frame, this.frame.zIndex, YES) } } get scale() { return this._scale } // Use this method to calculate the frame for the view itself // This can be used when adding subviews to existing views like buttons calculateAndSetViewFrame() { } public get frame() { let result: UIRectangle & { zIndex?: number } if (!this.isVirtualLayouting) { result = this._frame?.copy() as any } else { result = this._frameForVirtualLayouting?.copy() as any } if (!result) { result = new UIRectangle( this._resizeObserverEntry?.contentRect.left ?? this.viewHTMLElement.offsetLeft, this._resizeObserverEntry?.contentRect.top ?? this.viewHTMLElement.offsetTop, this._resizeObserverEntry?.contentRect.height ?? this.viewHTMLElement.offsetHeight, this._resizeObserverEntry?.contentRect.width ?? this.viewHTMLElement.offsetWidth ) as any result.zIndex = 0 } return result } public set frame(rectangle: UIRectangle & { zIndex?: number }) { if (IS(rectangle)) { this.setFrame(rectangle, rectangle.zIndex) } } // If YES, then the view is not counted in intrinsic content size calculation. // This should be used for things like background views that just take the shape of the parent view. hasWeakFrame = NO // Set view as having a weak frame and set the frame. public set weakFrame(rectangle: UIRectangle & { zIndex?: number }) { this.hasWeakFrame = YES this.frame = rectangle } // Set view as having a strong frame and set the frame. public set strongFrame(rectangle: UIRectangle & { zIndex?: number }) { this.hasWeakFrame = NO this.frame = rectangle } setFrame(rectangle: UIRectangle & { zIndex?: number }, zIndex = 0, performUncheckedLayout = NO) { const frame: (UIRectangle & { zIndex?: number }) = this._frame || new UIRectangle(nil, nil, nil, nil) as any if (!rectangle) { return } rectangle.materialize() if (zIndex != undefined) { rectangle.zIndex = zIndex } if (!this.isVirtualLayouting) { this._frame = rectangle } else { this._frameForVirtualLayouting = rectangle } if (frame && frame != nil && frame.isEqualTo( rectangle) && frame.zIndex == rectangle.zIndex && !performUncheckedLayout) { return } if (this.isInternalScaling) { rectangle.scale(1 / this.scale) } if (!this.isVirtualLayouting) { UIView._setAbsoluteSizeAndPosition( this.viewHTMLElement, rectangle.topLeft.x, rectangle.topLeft.y, rectangle.width, rectangle.height, rectangle.zIndex, this.frameTransform ) } const haveBoundsChanged = (frame.height.integerValue != rectangle.height.integerValue) || (frame.width.integerValue != rectangle.width.integerValue) if (haveBoundsChanged || performUncheckedLayout) { this.setNeedsLayout() this.boundsDidChange(this.bounds) } } get bounds() { let _frame: (UIRectangle & { zIndex?: number }) | undefined if (!this.isVirtualLayouting) { _frame = this._frame } else { _frame = this._frameForVirtualLayouting } let result: UIRectangle if (IS_NOT(_frame)) { let cachedFrame: UIRectangle | undefined if (!this.isVirtualLayouting) { cachedFrame = this._frameCache } else { cachedFrame = this._frameCacheForVirtualLayouting } result = cachedFrame ?? new UIRectangle( 0, 0, this._resizeObserverEntry?.contentRect.height ?? this.viewHTMLElement.offsetHeight, this._resizeObserverEntry?.contentRect.width ?? this.viewHTMLElement.offsetWidth ) if (!this.isVirtualLayouting && this.isMemberOfViewTree && this.viewHTMLElement.isConnected) { this._frameCache = result } else if (this.isMemberOfViewTree && this.viewHTMLElement.isConnected) { this._frameCacheForVirtualLayouting = result } } else { let frame: (UIRectangle & { zIndex?: number }) if (!this.isVirtualLayouting) { frame = this.frame } else { frame = this._frameForVirtualLayouting ?? this.frame } result = frame.copy() result.x = 0 result.y = 0 } result.minHeight = 0 result.minWidth = 0 return result } set bounds(rectangle: UIRectangle) { const frame = this.frame const newFrame = new UIRectangle(frame.topLeft.x, frame.topLeft.y, rectangle.height, rectangle.width) // @ts-ignore newFrame.zIndex = frame.zIndex this.frame = newFrame } boundsDidChange(bounds: UIRectangle) { } didResize(entry: ResizeObserverEntry) { this._resizeObserverEntry = entry this._frameCache = undefined this._frameCacheForVirtualLayouting = undefined this.setNeedsLayout() this.boundsDidChange(new UIRectangle(0, 0, entry.contentRect.height, entry.contentRect.width)) } get frameTransform(): string { return this._frameTransform } set frameTransform(value: string) { this._frameTransform = value this.setFrame(this.frame, this.frame.zIndex, YES) } setPosition( left: number | string = nil, right: number | string = nil, bottom: number | string = nil, top: number | string = nil, height: number | string = nil, width: number | string = nil ) { const previousBounds = this.bounds this.setStyleProperty("left", left) this.setStyleProperty("right", right) this.setStyleProperty("bottom", bottom) this.setStyleProperty("top", top) this.setStyleProperty("height", height) this.setStyleProperty("width", width) const bounds = this.bounds if (bounds.height != previousBounds.height || bounds.width != previousBounds.width) { this.setNeedsLayout() this.boundsDidChange(bounds) } } setSizes(height?: number | string, width?: number | string) { const previousBounds = this.bounds this.setStyleProperty("height", height) this.setStyleProperty("width", width) const bounds = this.bounds if (bounds.height != previousBounds.height || bounds.width != previousBounds.width) { this.setNeedsLayout() this.boundsDidChange(bounds) } } setMinSizes(height?: number | string, width?: number | string) { const previousBounds = this.bounds this.setStyleProperty("minHeight", height) this.setStyleProperty("minWidth", width) const bounds = this.bounds if (bounds.height != previousBounds.height || bounds.width != previousBounds.width) { this.setNeedsLayout() this.boundsDidChange(bounds) } } setMaxSizes(height?: number | string, width?: number | string) { const previousBounds = this.bounds this.setStyleProperty("maxHeight", height) this.setStyleProperty("maxWidth", width) const bounds = this.bounds if (bounds.height != previousBounds.height || bounds.width != previousBounds.width) { this.setNeedsLayout() this.boundsDidChange(bounds) } } setMargin(margin?: number | string) { const previousBounds = this.bounds this.setStyleProperty("margin", margin) const bounds = this.bounds if (bounds.height != previousBounds.height || bounds.width != previousBounds.width) { this.setNeedsLayout() this.boundsDidChange(bounds) } } setMargins(left?: number | string, right?: number | string, bottom?: number | string, top?: number | string) { const previousBounds = this.bounds this.setStyleProperty("marginLeft", left) this.setStyleProperty("marginRight", right) this.setStyleProperty("marginBottom", bottom) this.setStyleProperty("marginTop", top) const bounds = this.bounds if (bounds.height != previousBounds.height || bounds.width != previousBounds.width) { this.setNeedsLayout() this.boundsDidChange(bounds) } } setPadding(padding?: number | string) { const previousBounds = this.bounds this.setStyleProperty("padding", padding) const bounds = this.bounds if (bounds.height != previousBounds.height || bounds.width != previousBounds.width) { this.setNeedsLayout() this.boundsDidChange(bounds) } } setPaddings(left?: number | string, right?: number | string, bottom?: number | string, top?: number | string) { const previousBounds = this.bounds this.setStyleProperty("paddingLeft", left) this.setStyleProperty("paddingRight", right) this.setStyleProperty("paddingBottom", bottom) this.setStyleProperty("paddingTop", top) const bounds = this.bounds if (bounds.height != previousBounds.height || bounds.width != previousBounds.width) { this.setNeedsLayout() this.boundsDidChange(bounds) } } setBorder( radius: number | string = nil, width: number | string = 1, color: UIColor = UIColor.blackColor, style: string = "solid" ) { this.setStyleProperty("borderStyle", style) this.setStyleProperty("borderRadius", radius) this.colorStyleProxy.borderColor = color this.setStyleProperty("borderWidth", width) } setStyleProperty(propertyName: string, value?: number | string) { try { if (IS_NIL(value)) { return } if (IS_DEFINED(value) && (value as Number).isANumber) { value = "" + (value as number).integerValue + "px" } // @ts-ignore this.style[propertyName] = value } catch (exception) { console.log(exception) } } get userInteractionEnabled() { return (this.style.pointerEvents != "none") } set userInteractionEnabled(userInteractionEnabled) { if (userInteractionEnabled) { this.style.pointerEvents = "" } else { this.style.pointerEvents = "none" } } get backgroundColor() { return this._backgroundColor } set backgroundColor(backgroundColor: UIColor) { const previous = this._backgroundColor if (previous === backgroundColor) { return } if ( previous?.semanticKey && previous.semanticKey === backgroundColor?.semanticKey && previous._semanticClass === backgroundColor?._semanticClass ) { return } if (!previous?.semanticKey && !backgroundColor?.semanticKey && previous?.stringValue === backgroundColor?.stringValue) { return } this._backgroundColor = backgroundColor this.colorStyleProxy.backgroundColor = backgroundColor } get colorStyleProxy(): ColorStyleProxy { if (!this._colorStyleProxy) { const element = this._viewHTMLElement this._colorStyleProxy = new Proxy(element.style, { set: (target, property, value: UIColor) => { const registrationKey = `${this._UIViewIndex}_${property as string}` const previousColor = UIColor._registrationMap.get(registrationKey) if (previousColor) { previousColor._elementRef = undefined previousColor._styleProperty = undefined } value._elementRef = element value._styleProperty = property as string if (value.semanticKey) { UIColor._liveColors.push(new WeakRef(value)) } UIColor._registrationMap.set(registrationKey, value) Reflect.set(target, property, value.stringValue) return true } }) as unknown as ColorStyleProxy } return this._colorStyleProxy } /** * Registers a producer function that generates a composite CSS string value * (e.g. a gradient or box-shadow with embedded colors) and re-runs it * automatically whenever any of the given semantic keys are updated via * applySemanticColors(). Replaces any previous registration for the same * CSS property. The initial value is written immediately. */ registerLiveCSSValue(property: string, keys: string[], producer: () => string) { const previous = this._liveCSSValues.get(property) if (previous) { for (const key of previous.keys) {