diff --git a/.gitignore b/.gitignore index d9476c44e2..038a491050 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ *.launch .settings/ *.sublime-workspace - +results.md # IDE - VSCode .vscode/* !.vscode/settings.json diff --git a/NativeScript.code-workspace b/NativeScript.code-workspace new file mode 100644 index 0000000000..9db1b7fa64 --- /dev/null +++ b/NativeScript.code-workspace @@ -0,0 +1,13 @@ +{ + "folders": [ + { + "path": "." + }, + { + "path": "../dominative" + }, + { + "path": "../../apps/undom-ng" + } + ] +} \ No newline at end of file diff --git a/apps/automated/package.json b/apps/automated/package.json index 154e9cf951..8d17d7ed50 100644 --- a/apps/automated/package.json +++ b/apps/automated/package.json @@ -1,4 +1,5 @@ { + "version": "0.0.0", "main": "src/main.ts", "description": "NativeScript Application", "license": "MIT", diff --git a/apps/automated/src/main-page.ts b/apps/automated/src/main-page.ts index 5a62b4412d..1595d0dcd0 100644 --- a/apps/automated/src/main-page.ts +++ b/apps/automated/src/main-page.ts @@ -18,7 +18,12 @@ Trace.addCategories(Trace.categories.Test + ',' + Trace.categories.Error); // )); function runTests() { - setTimeout(() => tests.runAll(''), 10); + setTimeout(() => { + // Calling these two consectively causes a crash. + // Otherwise all tests are passing. + tests.runAll('TAB-VIEW-NAVIGATION'); + tests.runAll('IMAGE'); + }, 10); } export function onNavigatedTo(args) { diff --git a/apps/automated/src/ui/action-bar/action-bar-tests-common.ts b/apps/automated/src/ui/action-bar/action-bar-tests-common.ts index b0abe4c7ed..0e7b456663 100644 --- a/apps/automated/src/ui/action-bar/action-bar-tests-common.ts +++ b/apps/automated/src/ui/action-bar/action-bar-tests-common.ts @@ -143,12 +143,12 @@ export function test_ActionBarItemBindingToEvent() { const actionBarItem = page.actionBar.actionItems.getItemAt(0); TKUnit.assertEqual((actionBarItem)._observers['tap'].length, 1, 'There should be only one listener'); - TKUnit.assertEqual((actionBarItem)._observers['tap'][0].callback + '', firstHandler.toString(), 'First handler is not equal'); + TKUnit.assertEqual((actionBarItem)._observers['tap'][0].listener + '', firstHandler.toString(), 'First handler is not equal'); p.bindingContext.set('test', secondHandler); TKUnit.assertEqual((actionBarItem)._observers['tap'].length, 1, 'There should be only one listener'); - TKUnit.assertEqual((actionBarItem)._observers['tap'][0].callback + '', secondHandler.toString(), 'Second handler is not equal'); + TKUnit.assertEqual((actionBarItem)._observers['tap'][0].listener + '', secondHandler.toString(), 'Second handler is not equal'); }; helper.navigate(function () { diff --git a/apps/toolbox/package.json b/apps/toolbox/package.json index 636850fce3..9339723bb7 100644 --- a/apps/toolbox/package.json +++ b/apps/toolbox/package.json @@ -8,13 +8,16 @@ }, "dependencies": { "@nativescript/core": "file:../../packages/core", - "nativescript-theme-core": "file:../../node_modules/nativescript-theme-core", - "@nativescript/imagepicker": "^1.0.6" + "@nativescript/imagepicker": "^1.0.6", + "benchmark": "^2.1.4", + "lodash": "^4.17.21", + "nativescript-theme-core": "file:../../node_modules/nativescript-theme-core" }, "devDependencies": { "@nativescript/android": "~8.4.0", "@nativescript/ios": "~8.4.0", "@nativescript/webpack": "file:../../dist/packages/nativescript-webpack.tgz", - "typescript": "~4.9.5" + "typescript": "~4.9.5", + "@types/benchmark": "2.1.2" } } diff --git a/apps/toolbox/src/main.ts b/apps/toolbox/src/main.ts index a4c5c529a8..5703294acb 100644 --- a/apps/toolbox/src/main.ts +++ b/apps/toolbox/src/main.ts @@ -1,3 +1,85 @@ -import { Application } from '@nativescript/core'; +import { Application, FlexboxLayout, GridLayout } from '@nativescript/core'; +import Benchmark from 'benchmark'; +const suite = new Benchmark.Suite(); -Application.run({ moduleName: 'app-root' }); + +//@ts-ignore +global.performance = { + now() { + if (global.android) { + return java.lang.System.nanoTime() / 1000000; + } else { + return CACurrentMediaTime(); + } + }, +}; + +//@ts-ignore +//registerElement('view-element', FlexboxLayout); + +// declare global { +// interface HTMLElementTagNameMap { +// 'view-element': FlexboxLayout; +// } +// } + +Application.run({ + create: () => { + const root = new GridLayout(); + const view = new FlexboxLayout(); + //const element = document.createElement('view-element'); + root.addChild(view); + //root.addChild(element); + + for (let i = 0; i < 20; i++) { + //element.on('loaded', () => {}); + view.on('loaded', () => {}); + } + // Cache views to prevent GC from kicking in. + const views = []; + suite + .add('new FlexboxLayout()', () => { + views.push(new FlexboxLayout()); + }) + // .add('document.createElement', () => { + // document.createElement('view-element'); + // }) + .add('view.notify (20 Listeners)', () => { + view.notify({ + eventName: 'loaded', + object: view, + }); + }) + // .add('element.dispatchEvent (20 Listeners)', () => { + // element.dispatchEvent(new Event('loaded')); + // }) + .add('view.addChild (shallow)', () => { + view.addChild(new FlexboxLayout()); + }) + // .add('element.appendChild (shallow)', () => { + // element.appendChild(document.createElement('view-element')); + // }) + .add('view.addChild (deep)', () => { + let current = view; + for (let i = 0; i < 3; i++) { + const child = new FlexboxLayout(); + current.addChild(child); + current = child; + } + }) + // .add('element.appendChild (deep)', () => { + // let current = element; + // for (let i = 0; i < 10; i++) { + // const child = document.createElement('view-element'); + // current.appendChild(child); + // current = child; + // } + // }) + .on('cycle', function (event) { + console.log(String(event.target)); + }) + .run({ async: true }); + + return root; + }, +}); diff --git a/apps/toolbox/tsconfig.json b/apps/toolbox/tsconfig.json index 0d0dc494b0..86c7b3c250 100644 --- a/apps/toolbox/tsconfig.json +++ b/apps/toolbox/tsconfig.json @@ -4,6 +4,7 @@ "diagnostics": false, "paths": { "~/*": ["src/*"] - } + }, + "allowSyntheticDefaultImports": true } } diff --git a/package.json b/package.json index 2c26db0b5d..6502425122 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ "eslint": "7.22.0", "eslint-config-prettier": "^8.1.0", "gonzales": "^1.0.7", + "html-entities": "^2.3.3", + "html-escaper": "3.0.3", "husky": "^8.0.1", "jest": "29.4.3", "lint-staged": "^13.1.0", @@ -73,4 +75,3 @@ ] } } - diff --git a/packages/core/accessibility/font-scale.android.ts b/packages/core/accessibility/font-scale.android.ts index efe18c172b..5dc0e02d81 100644 --- a/packages/core/accessibility/font-scale.android.ts +++ b/packages/core/accessibility/font-scale.android.ts @@ -36,7 +36,6 @@ function setupConfigListener() { if (configChangedCallback) { return; } - Application.off(Application.launchEvent, setupConfigListener); const context = Application.android?.context as android.content.Context; if (!context) { diff --git a/packages/core/data/observable/index.ts b/packages/core/data/observable/index.ts index 60e6c81da3..9b392eec3c 100644 --- a/packages/core/data/observable/index.ts +++ b/packages/core/data/observable/index.ts @@ -1,4 +1,5 @@ import { Optional } from '../../utils/typescript-utils'; +import { Event as NativeDOMEvent, EventPhases } from '../../ui/core/dom/src/event/Event'; /** * Base event data. @@ -85,16 +86,174 @@ const _wrappedValues = [new WrappedValue(null), new WrappedValue(null), new Wrap const _globalEventHandlers: { [eventClass: string]: { - [eventName: string]: ListenerEntry[]; + [eventName: string]: Array; }; } = {}; +interface EventDescriptior extends AddEventListenerOptions { + target: EventTarget; + type: string; + removed?: boolean; + abortHandler?: () => void; + thisArg?: unknown; + listener?: EventListener; + listenerObject?: EventListenerObject; +} + +function isThisArg(value: any) { + if (!value) return false; + if (typeof value === 'function') return false; + if (typeof value !== 'object') return false; + if (value?.constructor?.toString().indexOf('class') === 0) return true; + // If object has none of the values in event options, it's thisArg. + // We might never reach this point (hopefully) but adding this just to be + // careful. + if (!value.once && !value.signal && !value.passive && !value.capture && !value.thisArg) return true; + return false; +} + +const getEventDescriptor = (target: EventTarget, type: string, listener: EventListenerOrEventListenerObject, options?: (AddEventListenerOptions & { thisArg: any }) | boolean): EventDescriptior => { + const listenerPart: { listener?: EventListener; listenerObject?: EventListenerObject } = {}; + + if (typeof listener === 'function') { + listenerPart.listener = listener; + } else if (listener.handleEvent) { + listenerPart.listenerObject = listener; + if (typeof options === 'function') { + listenerPart.listener = options; + } + } + + /** + * the most common case is handled first. No options. No capturing. No thisArg. + */ + if (!options || typeof options === 'boolean') { + return { target, capture: !!options, type, ...listenerPart }; + } + + // The second most common case, last argument is thisArg. + if (isThisArg(options)) { + return { + type, + target, + thisArg: options, + ...listenerPart, + }; + } + // Finally the last and "new" case of event options. + const { capture, once, passive, signal, thisArg } = options; + return { + target, + capture, + type, + once, + passive, + signal, + thisArg: thisArg, + ...listenerPart, + }; +}; + +function indexOfListener(list: EventDescriptior[], listener: EventListenerOrEventListenerObject | ((data: EventData) => void), thisArg?: any): number { + for (let i = 0, l = list.length; i < l; i++) { + const entry = list[i]; + if (thisArg) { + if (entry.listener === listener && entry.thisArg === thisArg) { + return i; + } + } else { + if (entry.listener === listener) { + return i; + } + } + } + + return -1; +} + +const CAPTURE_PHASE_KEY = '_captureObservers'; +const BUBBLE_PHASE_KEY = '_observers'; +type EventPhaseKey = '_captureObservers' | '_observers'; + +function addListener(this: any, type: string, listener: EventListenerOrEventListenerObject, descriptor: EventDescriptior) { + const eventPhaseKey = descriptor.capture ? CAPTURE_PHASE_KEY : BUBBLE_PHASE_KEY; + // Create an abort handler if any of the following is set. + if (descriptor.once || descriptor.signal) { + const abortHandler = () => { + if (!descriptor.removed) this.removeEventListener(type, listener); + }; + descriptor.abortHandler = abortHandler; + + if (descriptor.once) { + descriptor.listener = function (...handlerArgs) { + abortHandler(); + //@ts-ignore + listener.call(this, ...handlerArgs); + }; + } + + if (descriptor.signal) { + descriptor.signal.addEventListener('abort', abortHandler); + } + } + getEventList.call(this, type, eventPhaseKey, true).push(descriptor); +} + +function removeListener(this: any, type: string, listener: EventListenerOrEventListenerObject, options: any, phase?: EventPhaseKey) { + const eventPhaseKey = phase || BUBBLE_PHASE_KEY; + /** + * If no listener is provided, we remove all attached + * listeners. + */ + if (!listener) { + delete this[eventPhaseKey][type]; + return; + } + + const list = getEventList.call(this, type, eventPhaseKey, false) as EventDescriptior[]; + if (!list) return; + + const index = indexOfListener(list, listener, isThisArg(options) ? options : undefined); + const descriptor = list[index]; + /** + * If descriptor is not found, the provided + * listener is not registered, simply return. + */ + if (!descriptor) return; + /** + * Mark the listener as removed. This ensures that + * we don't fire listeners that are removed but still + * present in a shallow copy of currrent event's decriptors. + * For example, during an event dispatch, we iterate over a shallow + * copy of decriptors. + */ + descriptor.removed = true; + list.splice(index, 1); + if (descriptor.signal && descriptor.abortHandler) { + descriptor.signal.removeEventListener('abort', descriptor.abortHandler); + } + if (!list.length) delete this[eventPhaseKey][type]; +} + +function getEventList(eventName: string, phaseKey: EventPhaseKey, createIfNeeded?: boolean): Array { + if (!eventName) { + throw new TypeError('EventName must be valid string.'); + } + + let list = this[phaseKey][eventName]; + if (!list && createIfNeeded) list = this[phaseKey][eventName] = []; + return list; +} + +// An empty class to make instanceOf EventTarget checks pass. +// All the logic for EventTarget is implemented in Observable. +class EventTarget {} /** * Observable is used when you want to be notified when a change occurs. Use on/off methods to add/remove listener. * Please note that should you be using the `new Observable({})` constructor, it is **obsolete** since v3.0, * and you have to migrate to the "data/observable" `fromObject({})` or the `fromObjectRecursive({})` functions. */ -export class Observable { +export class Observable extends EventTarget implements globalThis.EventTarget { /** * String value used when hooking to propertyChange event. */ @@ -106,7 +265,16 @@ export class Observable { */ public _isViewBase: boolean; - private readonly _observers: { [eventName: string]: ListenerEntry[] } = {}; + public _isRegisteredDOMElement: boolean; + + /** + * Observers for the default/bubbling phase. + */ + private readonly _observers: { [eventName: string]: EventDescriptior[] } = {}; + /** + * Observers for the capture phase. + */ + private readonly _captureObservers: { [eventName: string]: EventDescriptior[] } = {}; /** * Gets the value of the specified property. @@ -166,16 +334,10 @@ export class Observable { * @param thisArg An optional parameter which when set will be used as "this" in callback method call. */ public once(event: string, callback: (data: EventData) => void, thisArg?: any): void { - if (typeof event !== 'string') { - throw new TypeError('Event must be string.'); - } - - if (typeof callback !== 'function') { - throw new TypeError('callback must be function.'); - } - - const list = this._getEventList(event, true); - list.push({ callback, thisArg, once: true }); + this.addEventListener(event, callback, { + thisArg, + once: true, + }); } /** @@ -185,135 +347,163 @@ export class Observable { this.removeEventListener(eventNames, callback, thisArg); } + // public addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + // public addEventListener(eventNames: string, callback: (data: EventData) => void, thisArg?: any): void; /** * Adds a listener for the specified event name. * @param eventNames Comma delimited names of the events to attach the listener to. * @param callback A function to be called when some of the specified event(s) is raised. * @param thisArg An optional parameter which when set will be used as "this" in callback method call. */ - public addEventListener(eventNames: string, callback: (data: EventData) => void, thisArg?: any): void { - if (typeof eventNames !== 'string') { - throw new TypeError('Events name(s) must be string.'); - } - - if (typeof callback !== 'function') { - throw new TypeError('callback must be function.'); + public addEventListener(...args: unknown[]): void { + const [type, listener, options] = args as [type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | any]; + + if (typeof type !== 'string') throw new TypeError('Events name(s) must be string.'); + if (typeof listener !== 'function' && !listener?.handleEvent) throw new TypeError('Callback must be function or an object with handeEvent function'); + // Add the event directly if it's a single event name. + if (!type.includes(',')) { + // Get the event descriptor. + const descriptor = getEventDescriptor(this, type, listener, options); + addListener.call(this, type, listener, descriptor); + return; } - const events = eventNames.split(','); - for (let i = 0, l = events.length; i < l; i++) { - const event = events[i].trim(); - const list = this._getEventList(event, true); - // TODO: Performance optimization - if we do not have the thisArg specified, do not wrap the callback in additional object (ObserveEntry) - list.push({ - callback: callback, - thisArg: thisArg, - }); + // Loop over all events and attach the descriptor. + const events = type.split(','); + const length = events.length; + for (let i = 0, l = length; i < l; i++) { + const type = events[i].trim(); + // Get the event descriptor. + const descriptor = getEventDescriptor(this, type, listener, options); + addListener.call(this, type, listener, descriptor); } } + // public removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + // public removeEventListener(eventNames: string, callback?: (data: EventData) => void, thisArg?: any): void; + /** * Removes listener(s) for the specified event name. * @param eventNames Comma delimited names of the events the specified listener is associated with. * @param callback An optional parameter pointing to a specific listener. If not defined, all listeners for the event names will be removed. - * @param thisArg An optional parameter which when set will be used to refine search of the correct callback which will be removed as event listener. + * @param option An optional parameter which when set will be used to refine search of the correct callback which will be removed as event listener. */ - public removeEventListener(eventNames: string, callback?: (data: EventData) => void, thisArg?: any): void { - if (typeof eventNames !== 'string') { - throw new TypeError('Events name(s) must be string.'); - } + public removeEventListener(...args: unknown[]): void { + const [type, listener, options] = args as [type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | any]; + + if (typeof type !== 'string') throw new TypeError('Events name(s) must be string.'); + if (listener && typeof listener !== 'function' && !listener?.handleEvent) throw new TypeError(`Callback must be function or an object with handeEvent function`); - if (callback && typeof callback !== 'function') { - throw new TypeError('callback must be function.'); + if (!type.includes(',')) { + removeListener.call(this, type, listener, options, options?.capture ? CAPTURE_PHASE_KEY : BUBBLE_PHASE_KEY); + return; + } + const events = type.split(','); + const length = events.length; + for (let i = 0, l = length; i < l; i++) { + removeListener.call(this, events[i].trim(), listener, options, options?.capture ? CAPTURE_PHASE_KEY : BUBBLE_PHASE_KEY); } + } + /** + * Dispatches a synthetic event event to target and returns true if either event's cancelable attribute value is false or its preventDefault() method was not invoked, and false otherwise. + * + * @nativescript Event bubbling is disabled by default. + */ + dispatchEvent(event: Event): boolean { + //@ts-ignore + const { bubbles, captures, type } = event; + /** + * If event already has target/currentTarget set, skip setting these values. + */ - const events = eventNames.split(','); - for (let i = 0, l = events.length; i < l; i++) { - const event = events[i].trim(); - if (callback) { - const list = this._getEventList(event, false); - if (list) { - const index = Observable._indexOfListener(list, callback, thisArg); - if (index >= 0) { - list.splice(index, 1); - } - if (list.length === 0) { - delete this._observers[event]; - } - } - } else { - this._observers[event] = undefined; - delete this._observers[event]; + (event as NativeDOMEvent)._target = this; + + const capturePhase: Array[] = []; + + const targetPhase: Array = this._observers[type]; + const targetPhaseCapture: Array = this._captureObservers[type]; + + const bubblePhase: Array[] = []; + // Walk the tree & find ancestors that have any handlers attached. + if (bubbles || captures) { + // eslint-disable-next-line consistent-this, @typescript-eslint/no-this-alias + // Start from the first parent. + let currentNode = (this as unknown as import('../../ui/core/dom/src/nodes/node/Node').default)._parentNode as import('../../ui/core/view-base').ViewBase; + while (currentNode) { + if (captures && currentNode._captureObservers[type]) capturePhase.unshift(currentNode._captureObservers[type]); + if (bubbles && currentNode._observers[type]) bubblePhase.push(currentNode._observers[type]); + //@ts-ignore todo + currentNode = currentNode._getTheParent(event); } } - } - public static on(eventName: string, callback: (data: EventData) => void, thisArg?: any): void { - this.addEventListener(eventName, callback, thisArg); - } + // Capturing starts from the highest ancestor and goes down - public static once(eventName: string, callback: (data: EventData) => void, thisArg?: any): void { - if (typeof eventName !== 'string') { - throw new TypeError('Event must be string.'); + for (const listeners of capturePhase) { + (event as NativeDOMEvent).eventPhase = EventPhases.CAPTURING_PHASE; + Observable._handleEvent(listeners, event as NativeDOMEvent); + if (!event.bubbles || (event as NativeDOMEvent)._propagationStopped) return !event.cancelable || !event.defaultPrevented; } - if (typeof callback !== 'function') { - throw new TypeError('callback must be function.'); + // Fire the event of the element that is the actual target. + + if (targetPhase) { + (event as NativeDOMEvent).eventPhase = EventPhases.AT_TARGET; + Observable._handleEvent(targetPhase, event as NativeDOMEvent); + if (!event.bubbles || (event as NativeDOMEvent)._propagationStopped) return !event.cancelable || !event.defaultPrevented; } - const eventClass = this.name === 'Observable' ? '*' : this.name; - if (!_globalEventHandlers[eventClass]) { - _globalEventHandlers[eventClass] = {}; + if (targetPhaseCapture) { + Observable._handleEvent(targetPhaseCapture, event as NativeDOMEvent); + if (!event.bubbles || (event as NativeDOMEvent)._propagationStopped) return !event.cancelable || !event.defaultPrevented; } - if (!Array.isArray(_globalEventHandlers[eventClass][eventName])) { - _globalEventHandlers[eventClass][eventName] = []; + + // Bubbling starts from the nearest ancestor and goes up. + + for (const listeners of bubblePhase) { + (event as NativeDOMEvent).eventPhase = EventPhases.BUBBLING_PHASE; + Observable._handleEvent(listeners, event as NativeDOMEvent); + if (!event.bubbles || (event as NativeDOMEvent)._propagationStopped) return !event.cancelable || !event.defaultPrevented; } - _globalEventHandlers[eventClass][eventName].push({ callback, thisArg, once: true }); + // Reset the event phase. + (event as NativeDOMEvent).eventPhase = EventPhases.NONE; + + return !event.cancelable || !event.defaultPrevented; + } + + public static on(eventName: string, callback: (data: EventData) => void, thisArg?: any): void { + this.addEventListener(eventName, callback, thisArg); + } + + public static once(eventName: string, callback: (data: EventData) => void, thisArg?: any): void { + this.addEventListener(eventName, callback, { once: true, thisArg }); } public static off(eventName: string, callback?: (data: EventData) => void, thisArg?: any): void { this.removeEventListener(eventName, callback, thisArg); } - public static removeEventListener(eventName: string, callback?: (data: EventData) => void, thisArg?: any): void { - if (typeof eventName !== 'string') { - throw new TypeError('Event must be string.'); - } - - if (callback && typeof callback !== 'function') { - throw new TypeError('callback must be function.'); - } + // public static removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + // public static removeEventListener(eventNames: string, callback?: (data: EventData) => void, thisArg?: any): void; + public static removeEventListener(...args: unknown[]): void { + const [type, listener, options] = args as [type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | any]; + if (typeof type !== 'string') throw new TypeError('Events name(s) must be string.'); + if (listener && typeof listener !== 'function' && !listener?.handleEvent) throw new TypeError('callback must be function or an object with handeEvent function'); const eventClass = this.name === 'Observable' ? '*' : this.name; // Short Circuit if no handlers exist.. - if (!_globalEventHandlers[eventClass] || !Array.isArray(_globalEventHandlers[eventClass][eventName])) { + if (!_globalEventHandlers[eventClass] || !_globalEventHandlers[eventClass][type] || !_globalEventHandlers[eventClass][type].length) { return; } - const events = _globalEventHandlers[eventClass][eventName]; - if (thisArg) { - for (let i = 0; i < events.length; i++) { - if (events[i].callback === callback && events[i].thisArg === thisArg) { - events.splice(i, 1); - i--; - } - } - } else if (callback) { - for (let i = 0; i < events.length; i++) { - if (events[i].callback === callback) { - events.splice(i, 1); - i--; - } - } + const events = _globalEventHandlers[eventClass][type]; + if (!listener) { + delete _globalEventHandlers[eventClass][type]; } else { - // Clear all events of this type - delete _globalEventHandlers[eventClass][eventName]; - } - - if (events.length === 0) { - // Clear all events of this type - delete _globalEventHandlers[eventClass][eventName]; + const index = indexOfListener(events, listener, isThisArg(options) ? options : undefined); + if (index > -1) events.splice(index, 1); + if (events.length === 0) delete _globalEventHandlers[eventClass][type]; } // Clear the primary class grouping if no events are left @@ -323,23 +513,42 @@ export class Observable { } } - public static addEventListener(eventName: string, callback: (data: EventData) => void, thisArg?: any): void { - if (typeof eventName !== 'string') { - throw new TypeError('Event must be string.'); - } + // public static addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + // public static addEventListener(eventNames: string, callback: (data: EventData) => void, thisArg?: any): void; - if (typeof callback !== 'function') { - throw new TypeError('callback must be function.'); - } + public static addEventListener(...args: unknown[]): void { + const [type, listener, options] = args as [type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | any]; + if (typeof type !== 'string') throw new TypeError('Events name(s) must be string.'); + if (typeof listener !== 'function' && !listener?.handleEvent) throw new TypeError('callback must be function or an object with handeEvent function'); const eventClass = this.name === 'Observable' ? '*' : this.name; if (!_globalEventHandlers[eventClass]) { _globalEventHandlers[eventClass] = {}; } - if (!Array.isArray(_globalEventHandlers[eventClass][eventName])) { - _globalEventHandlers[eventClass][eventName] = []; + if (!_globalEventHandlers[eventClass][type]) { + _globalEventHandlers[eventClass][type] = []; + } + const descriptor = getEventDescriptor(this, type, listener, options); + + if (descriptor.once || descriptor.signal) { + const abortHandler = () => { + if (!descriptor.removed) Observable.removeEventListener(type, listener); + }; + descriptor.abortHandler = abortHandler; + + if (descriptor.once) { + descriptor.listener = function (...handlerArgs) { + abortHandler(); + //@ts-ignore + listener.call(this, ...handlerArgs); + }; + } + + if (descriptor.signal) { + descriptor.signal.addEventListener('abort', abortHandler); + } } - _globalEventHandlers[eventClass][eventName].push({ callback, thisArg }); + _globalEventHandlers[eventClass][type].push(descriptor); } private _globalNotify(eventClass: string, eventType: string, data: T): void { @@ -348,16 +557,18 @@ export class Observable { const event = data.eventName + eventType; const events = _globalEventHandlers[eventClass][event]; if (events) { - Observable._handleEvent(events, data); + const event = new NativeDOMEvent(data.eventName, { __event_data: data as unknown }); + Observable._handleEvent(events.slice(0), event); } } - // Check for he Global handlers for ALL classes + // Check for the Global handlers for ALL classes if (_globalEventHandlers['*']) { const event = data.eventName + eventType; const events = _globalEventHandlers['*'][event]; if (events) { - Observable._handleEvent(events, data); + const event = new NativeDOMEvent(data.eventName, { __event_data: data as unknown }); + Observable._handleEvent(events.slice(0), event); } } } @@ -374,43 +585,58 @@ export class Observable { public notify>(data: T): void { data.object = data.object || this; const dataWithObject = data as EventData; - const eventClass = this.constructor.name; - this._globalNotify(eventClass, 'First', dataWithObject); - const observers = this._observers[data.eventName]; - if (observers) { - Observable._handleEvent(observers, dataWithObject); + this._globalNotify(eventClass, 'First', dataWithObject); + if (!this._isRegisteredDOMElement) { + Observable._handleEvent(this._observers[data.eventName], dataWithObject, false); + } else { + Observable._handleEvent(this._observers[data.eventName], new NativeDOMEvent(data.eventName, { __event_data: dataWithObject as unknown }), true); } this._globalNotify(eventClass, '', dataWithObject); } - private static _handleEvent(observers: Array, data: T): void { - if (!observers) { - return; - } - for (let i = observers.length - 1; i >= 0; i--) { - const entry = observers[i]; - if (entry) { - if (entry.once) { - observers.splice(i, 1); - } + private static _handleEvent(listeners: Array, event: T, domEvent: boolean = true): void { + if (!listeners || !listeners.length) return; + // Create a shallow copy here because there is a possibility + // that listeners will be removed while we iterate over events. For example + // events that will unsub themselves. + const _listeners = listeners.slice(0); + for (let i = 0, l = listeners.length; i < l; i++) { + const descriptor = _listeners[i]; + const { listener, removed, thisArg } = descriptor; + if (removed) continue; + + if (domEvent) { + (event as NativeDOMEvent).passive = !(event as NativeDOMEvent).cancelable || descriptor.passive; + (event as NativeDOMEvent)._currentTarget = (thisArg as any) || (event as NativeDOMEvent).object || (descriptor.target as globalThis.EventTarget); + } - let returnValue: any; - if (entry.thisArg) { - returnValue = entry.callback.apply(entry.thisArg, [data]); + let returnValue; + if (descriptor.listenerObject) { + if (thisArg) { + returnValue = descriptor.listenerObject.handleEvent.apply(thisArg, [event]); } else { - returnValue = entry.callback(data); + returnValue = descriptor.listenerObject.handleEvent(event as NativeDOMEvent); } - - // This ensures errors thrown inside asynchronous functions do not get swallowed - if (returnValue && returnValue instanceof Promise) { - returnValue.catch((err) => { - console.error(err); - }); + } else if (listener) { + if (thisArg) { + returnValue = listener.apply(thisArg, [event]); + } else { + returnValue = listener(event as NativeDOMEvent); } } + + // This ensures errors thrown inside asynchronous functions do not get swallowed + if (returnValue && returnValue instanceof Promise) { + returnValue.catch((err) => { + console.error(err); + }); + } + + //@ts-ignore + if (event._immediatePropagationStopped) return; } } @@ -426,7 +652,7 @@ export class Observable { * @param eventName The name of the event to check for. */ public hasListeners(eventName: string): boolean { - return eventName in this._observers; + return eventName in this._observers || eventName in this._captureObservers; } /** @@ -450,39 +676,31 @@ export class Observable { this.notify({ eventName: event, object: this }); } } +} - private _getEventList(eventName: string, createIfNeeded?: boolean): Array { - if (!eventName) { - throw new TypeError('EventName must be valid string.'); - } - - let list = >this._observers[eventName]; - if (!list && createIfNeeded) { - list = []; - this._observers[eventName] = list; - } +export interface Observable extends globalThis.EventTarget { + /** + * Raised when a propertyChange occurs. + */ + on(event: 'propertyChange', callback: (data: EventData) => void, thisArg?: any): void; - return list; - } + /** + * Updates the specified property with the provided value. + */ + set(name: string, value: any): void; - private static _indexOfListener(list: Array, callback: (data: EventData) => void, thisArg?: any): number { - for (let i = 0; i < list.length; i++) { - const entry = list[i]; - if (thisArg) { - if (entry.callback === callback && entry.thisArg === thisArg) { - return i; - } - } else { - if (entry.callback === callback) { - return i; - } - } - } + /** + * Updates the specified property with the provided value and raises a property change event and a specific change event based on the property name. + */ + setProperty(name: string, value: any): void; - return -1; - } + /** + * Gets the value of the specified property. + */ + get(name: string): any; } + class ObservableFromObject extends Observable { public readonly _map: Record = {}; diff --git a/packages/core/global-types.d.ts b/packages/core/global-types.d.ts index 983834cfa2..54b8f1c5d3 100644 --- a/packages/core/global-types.d.ts +++ b/packages/core/global-types.d.ts @@ -381,3 +381,5 @@ declare function float(value: number): any; * Create a Java char from a string */ declare function char(value: string): any; + +declare function registerElement(name: string, element: any): void; diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index e58fc76a8f..0dfe5a47ce 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -110,6 +110,8 @@ export * from './ui'; import { GC, isFontIconURI, isDataURI, isFileOrResourcePath, executeOnMainThread, mainThreadify, isMainThread, dispatchToMainThread, executeOnUIThread, releaseNativeObject, getModuleName, openFile, openUrl, isRealDevice, layout, ad as androidUtils, iOSNativeHelper as iosUtils, Source, escapeRegexSymbols, convertString, dismissSoftInput, dismissKeyboard, queueMacrotask, queueGC, throttle, debounce, dataSerialize, dataDeserialize, copyToClipboard, getFileExtension, isEmoji } from './utils'; import { SDK_VERSION } from './utils/constants'; import { ClassInfo, getClass, getBaseClasses, getClassInfo, isBoolean, isDefined, isFunction, isNullOrUndefined, isNumber, isObject, isString, isUndefined, toUIString, verifyCallback, numberHasDecimals, numberIs64Bit } from './utils/types'; +export * from './ui/core/dom/index'; + export declare const Utils: { GC: typeof GC; SDK_VERSION: typeof SDK_VERSION; diff --git a/packages/core/index.ts b/packages/core/index.ts index 239a62d01d..4d01125bd9 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -6,8 +6,10 @@ export { iOSApplication, AndroidApplication } from './application'; export type { ApplicationEventData, LaunchEventData, OrientationChangedEventData, UnhandledErrorEventData, DiscardedErrorEventData, CssChangedEventData, LoadAppCSSEventData, AndroidActivityEventData, AndroidActivityBundleEventData, AndroidActivityRequestPermissionsEventData, AndroidActivityResultEventData, AndroidActivityNewIntentEventData, AndroidActivityBackPressedEventData, SystemAppearanceChangedEventData } from './application'; import { fontScaleChangedEvent, launchEvent, displayedEvent, uncaughtErrorEvent, discardedErrorEvent, suspendEvent, resumeEvent, exitEvent, lowMemoryEvent, orientationChangedEvent, systemAppearanceChanged, systemAppearanceChangedEvent, getMainEntry, getRootView, _resetRootView, getResources, setResources, setCssFileName, getCssFileName, loadAppCss, addCss, on, off, notify, hasListeners, run, orientation, getNativeApplication, hasLaunched, android as appAndroid, ios as iosApp, systemAppearance, setAutoSystemAppearanceChanged, ensureNativeApplication, setMaxRefreshRate } from './application'; +export * from './ui/core/dom/index'; import { inBackground, suspended, foregroundEvent, backgroundEvent } from './application/application-common'; + export const Application = { launchEvent, displayedEvent, diff --git a/packages/core/package.json b/packages/core/package.json index d08dbedb58..078c21a8f5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@nativescript/core", - "version": "8.5.0", + "version": "8.5.0-nsdom.3", "description": "A JavaScript library providing an easy to use api for interacting with iOS and Android platform APIs.", "main": "index", "types": "index.d.ts", @@ -49,7 +49,9 @@ "css-tree": "^1.1.2", "emoji-regex": "^10.2.1", "reduce-css-calc": "^2.1.7", - "tslib": "^2.0.0" + "tslib": "^2.0.0", + "html-entities": "2.3.3", + "html-escaper": "3.0.3" }, "nativescript": { "platforms": { diff --git a/packages/core/ui/core/dom/LICENSE-dominative b/packages/core/ui/core/dom/LICENSE-dominative new file mode 100644 index 0000000000..dc2318d3f8 --- /dev/null +++ b/packages/core/ui/core/dom/LICENSE-dominative @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Yukino Song + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/core/ui/core/dom/LICENSE-happy-dom b/packages/core/ui/core/dom/LICENSE-happy-dom new file mode 100644 index 0000000000..eebd5782b0 --- /dev/null +++ b/packages/core/ui/core/dom/LICENSE-happy-dom @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 David Ortner (capricorn86) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/core/ui/core/dom/LICENSE-undom-ng b/packages/core/ui/core/dom/LICENSE-undom-ng new file mode 100644 index 0000000000..fd9ceb1b38 --- /dev/null +++ b/packages/core/ui/core/dom/LICENSE-undom-ng @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2016 Jason Miller +Copyright (c) 2022 Yukino Song + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/core/ui/core/dom/index.ts b/packages/core/ui/core/dom/index.ts new file mode 100644 index 0000000000..0e30d400c6 --- /dev/null +++ b/packages/core/ui/core/dom/index.ts @@ -0,0 +1,3 @@ +import GlobalWindow from './src/window/Window'; + +export const Window = GlobalWindow; diff --git a/packages/core/ui/core/dom/src/config/ChildLessElements.ts b/packages/core/ui/core/dom/src/config/ChildLessElements.ts new file mode 100644 index 0000000000..dc5606eb3f --- /dev/null +++ b/packages/core/ui/core/dom/src/config/ChildLessElements.ts @@ -0,0 +1 @@ +export default ['style', 'script']; diff --git a/packages/core/ui/core/dom/src/config/ElementTag.ts b/packages/core/ui/core/dom/src/config/ElementTag.ts new file mode 100644 index 0000000000..16bc76b798 --- /dev/null +++ b/packages/core/ui/core/dom/src/config/ElementTag.ts @@ -0,0 +1,12 @@ +import { HTMLArrayPropElement, HTMLKeyPropElement } from '../nodes/html-prop-element/HTMLPropElement'; +import HTMLSlotElement from '../nodes/html-slot-element/HTMLSlotElement'; +import HTMLTemplateElement from '../nodes/html-template-element/HTMLTemplateElement'; +import { HTMLItemTemplateElement } from '../nodes/html-item-template-element/HTMLItemTemplateElement'; + +export default { + template: HTMLTemplateElement, + itemtemplate: HTMLItemTemplateElement, + slot: HTMLSlotElement, + keyprop: HTMLKeyPropElement, + arrayprop: HTMLArrayPropElement, +}; diff --git a/packages/core/ui/core/dom/src/config/NamespaceURI.ts b/packages/core/ui/core/dom/src/config/NamespaceURI.ts new file mode 100644 index 0000000000..2d67cc2d12 --- /dev/null +++ b/packages/core/ui/core/dom/src/config/NamespaceURI.ts @@ -0,0 +1,5 @@ +export default { + html: 'http://www.w3.org/1999/xhtml', + svg: 'http://www.w3.org/2000/svg', + mathML: 'http://www.w3.org/1998/Math/MathML', +}; diff --git a/packages/core/ui/core/dom/src/config/NonImplemenetedElementClasses.ts b/packages/core/ui/core/dom/src/config/NonImplemenetedElementClasses.ts new file mode 100644 index 0000000000..ad4c443800 --- /dev/null +++ b/packages/core/ui/core/dom/src/config/NonImplemenetedElementClasses.ts @@ -0,0 +1,53 @@ +export default [ + 'HTMLHeadElement', + 'HTMLTitleElement', + 'HTMLMetaElement', + 'HTMLBodyElement', + 'HTMLHeadingElement', + 'HTMLParagraphElement', + 'HTMLHRElement', + 'HTMLPreElement', + 'HTMLUListElement', + 'HTMLOListElement', + 'HTMLLIElement', + 'HTMLMenuElement', + 'HTMLDListElement', + 'HTMLDivElement', + 'HTMLAnchorElement', + 'HTMLAreaElement', + 'HTMLBRElement', + 'HTMLButtonElement', + 'HTMLCanvasElement', + 'HTMLDataElement', + 'HTMLDataListElement', + 'HTMLDetailsElement', + 'HTMLDirectoryElement', + 'HTMLFieldSetElement', + 'HTMLFontElement', + 'HTMLHtmlElement', + 'HTMLLegendElement', + 'HTMLMapElement', + 'HTMLMarqueeElement', + 'HTMLMeterElement', + 'HTMLModElement', + 'HTMLOutputElement', + 'HTMLPictureElement', + 'HTMLProgressElement', + 'HTMLQuoteElement', + 'HTMLSourceElement', + 'HTMLSpanElement', + 'HTMLTableCaptionElement', + 'HTMLTableCellElement', + 'HTMLTableColElement', + 'HTMLTableElement', + 'HTMLTimeElement', + 'HTMLTableRowElement', + 'HTMLTableSectionElement', + 'HTMLFrameElement', + 'HTMLFrameSetElement', + 'HTMLIFrameElement', + 'HTMLEmbedElement', + 'HTMLObjectElement', + 'HTMLParamElement', + 'HTMLTrackElement', +]; diff --git a/packages/core/ui/core/dom/src/config/UnnestableElements.ts b/packages/core/ui/core/dom/src/config/UnnestableElements.ts new file mode 100644 index 0000000000..50bf69c4dd --- /dev/null +++ b/packages/core/ui/core/dom/src/config/UnnestableElements.ts @@ -0,0 +1 @@ +export default ['a', 'button', 'dd', 'dt', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'option', 'p', 'select', 'table']; diff --git a/packages/core/ui/core/dom/src/config/VoidElements.ts b/packages/core/ui/core/dom/src/config/VoidElements.ts new file mode 100644 index 0000000000..2d6c3b12d0 --- /dev/null +++ b/packages/core/ui/core/dom/src/config/VoidElements.ts @@ -0,0 +1 @@ +export default ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr', 'meta']; diff --git a/packages/core/ui/core/dom/src/cssstylesheet/CSSStyleSheet.ts b/packages/core/ui/core/dom/src/cssstylesheet/CSSStyleSheet.ts new file mode 100644 index 0000000000..4aa78f35c7 --- /dev/null +++ b/packages/core/ui/core/dom/src/cssstylesheet/CSSStyleSheet.ts @@ -0,0 +1,67 @@ +import { addTaggedAdditionalCSS, removeTaggedAdditionalCSS } from '../../../../styling/style-scope'; + +class CSSRuleList extends Array {} + +class CSSRule { + constructor(public cssText: string) {} +} + +export class CSSStyleSheet { + /** + * Style Scope of the CSSStyleSheet is + * shared across the app. + */ + //_scope: StyleScope; + timestampTag: number; + cssRules: CSSRuleList; + ownerRule: CSSRule; + get rules(): CSSRuleList { + return this.cssRules; + } + addRule(selector?: string, style?: string, index?: number): number { + return this.insertRule(`${selector} {${style}}`, index); + } + deleteRule(index: number): void { + this.removeRule(index); + } + insertRule(rule: string, index?: number): number { + if (typeof index !== 'number') index = -1; + this.cssRules.splice(index, 0, new CSSRule(rule)); + return 0; + } + removeRule(index?: number): void { + this.cssRules.splice(index, 1); + } + replace(text: string): Promise { + return new Promise((resolve) => { + this.replaceSync(text); + resolve(this); + }); + } + replaceSync(text: string): void { + this.cssRules = [new CSSRule(text)]; + this.adoptStyles(); + } + disabled: boolean; + href: string; + media: MediaList; + ownerNode: globalThis.Element | ProcessingInstruction; + parentStyleSheet: CSSStyleSheet; + title: string; + type: string; + + // get styleScope() { + // if (this._scope) return this._scope; + // this._scope = new StyleScope(); + // const cssText = (this.cssRules as unknown as Array).map((rule) => rule.cssText).join(''); + // this._scope.addCss(cssText); + // return this._scope; + // } + + adoptStyles() { + this.timestampTag = this.timestampTag || Date.now(); + const cssText = (this.cssRules as unknown as Array).map((rule) => rule.cssText).join(''); + removeTaggedAdditionalCSS(this.timestampTag); + addTaggedAdditionalCSS(cssText, this.timestampTag); + } +} diff --git a/packages/core/ui/core/dom/src/custom-element/CustomElementRegistry.ts b/packages/core/ui/core/dom/src/custom-element/CustomElementRegistry.ts new file mode 100644 index 0000000000..8e74c62104 --- /dev/null +++ b/packages/core/ui/core/dom/src/custom-element/CustomElementRegistry.ts @@ -0,0 +1,82 @@ +import HTMLElement from '../nodes/html-element/HTMLElement'; +import Node from '../nodes/node/Node'; + +/** + * Custom elements registry. + */ +export default class CustomElementRegistry { + private _registry: Map = new Map(); + private _callbacks: { [k: string]: (() => void)[] } = {}; + + /** + * Defines a custom element class. + * + * @param tagName Tag name of element. + * @param elementClass Element class. + * @param [options] Options. + * @param options.extends + */ + public define(tagName: string, elementClass: typeof HTMLElement, options?: { extends: string }): void { + if (!tagName.includes('-')) { + throw new Error("[DOM] Failed to execute 'define' on 'CustomElementRegistry': \"" + tagName + '" is not a valid custom element name.'); + } + + this._registry.set(tagName, { + elementClass, + extends: options && options.extends ? options.extends.toLowerCase() : null, + }); + + // ObservedAttributes should only be called once by CustomElementRegistry + if (elementClass.prototype.attributeChangedCallback) { + elementClass._observedAttributes = elementClass.observedAttributes; + } + + //@ts-ignore Add a css type so this tag name can be referred to in css files. + elementClass.prototype.cssType = tagName; + + if (this._callbacks[tagName]) { + const callbacks = this._callbacks[tagName]; + delete this._callbacks[tagName]; + for (const callback of callbacks) { + callback(); + } + } + } + + /** + * Returns a defined element class. + * + * @param tagName Tag name of element. + * @param HTMLElement Class defined. + */ + public get(tagName: string): typeof HTMLElement { + return this._registry.has(tagName) ? this._registry.get(tagName).elementClass : undefined; + } + + /** + * Upgrades a custom element directly, even before it is connected to its shadow root. + * + * Not implemented yet. + * + * @param _root Root node. + */ + public upgrade(_root: Node): void { + // Do nothing + } + + /** + * When defined. + * + * @param tagName Tag name of element. + * @returns Promise. + */ + public whenDefined(tagName: string): Promise { + if (this.get(tagName)) { + return Promise.resolve(); + } + return new Promise((resolve) => { + this._callbacks[tagName] = this._callbacks[tagName] || []; + this._callbacks[tagName].push(resolve); + }); + } +} diff --git a/packages/core/ui/core/dom/src/dom-implementation/DOMImplementation.ts b/packages/core/ui/core/dom/src/dom-implementation/DOMImplementation.ts new file mode 100644 index 0000000000..63e8a509d7 --- /dev/null +++ b/packages/core/ui/core/dom/src/dom-implementation/DOMImplementation.ts @@ -0,0 +1,57 @@ +import DocumentType from '../nodes/document-type/DocumentType'; +import type Document from '../nodes/document/Document'; + +/** + * The DOMImplementation interface represents an object providing methods which are not dependent on any particular document. Such an object is returned by the. + */ +export default class DOMImplementation { + protected _ownerDocument: Document = null; + + /** + * Constructor. + * + * @param ownerDocument + */ + constructor(ownerDocument: Document) { + this._ownerDocument = ownerDocument; + } + + /** + * Creates and returns an XML Document. + * + * TODO: Not fully implemented. + */ + public createDocument(): Document { + const documentClass = this._ownerDocument.constructor; + // @ts-ignore + documentClass._defaultView = this._ownerDocument.defaultView; + // @ts-ignore + const doc = new documentClass(); + doc.initDocument(); + return doc; + } + + /** + * Creates and returns an HTML Document. + */ + public createHTMLDocument(): Document { + return this.createDocument(); + } + + /** + * Creates and returns an HTML Document. + * + * @param qualifiedName Qualified name. + * @param publicId Public ID. + * @param systemId System ID. + */ + public createDocumentType(qualifiedName: string, publicId: string, systemId: string): DocumentType { + //@ts-ignore + DocumentType._ownerDocument = this._ownerDocument; + const documentType = new DocumentType(); + documentType.name = qualifiedName; + documentType.publicId = publicId; + documentType.systemId = systemId; + return documentType; + } +} diff --git a/packages/core/ui/core/dom/src/event/AbortSignal.ts b/packages/core/ui/core/dom/src/event/AbortSignal.ts new file mode 100644 index 0000000000..e428d31a36 --- /dev/null +++ b/packages/core/ui/core/dom/src/event/AbortSignal.ts @@ -0,0 +1,44 @@ +export default class AbortSignal { + aborted: boolean = false; + reason: any; + listeners = new Map(); + + addEventListener( + type: 'abort', + listener: (this: AbortSignal, event: any) => any, + options?: + | boolean + | { + capture?: boolean | undefined; + once?: boolean | undefined; + passive?: boolean | undefined; + } + ) { + if (this.listeners.has(listener)) return; + this.listeners.set(listener, type); + } + + removeEventListener( + type: 'abort', + listener: (this: AbortSignal, event: any) => any, + options?: + | boolean + | { + capture?: boolean | undefined; + } + ) { + if (!this.listeners.has(listener)) return; + this.listeners.delete(listener); + } + + abort() { + this.listeners.forEach((_, listener) => { + this.aborted = true; + listener(); + this.listeners.delete(listener); + }); + return this; + } + + timeout() {} +} diff --git a/packages/core/ui/core/dom/src/event/CustomEvent.ts b/packages/core/ui/core/dom/src/event/CustomEvent.ts new file mode 100644 index 0000000000..a7e2a5d5b3 --- /dev/null +++ b/packages/core/ui/core/dom/src/event/CustomEvent.ts @@ -0,0 +1,13 @@ +import { Event, EVENT_OPTIONS_DEFAULT } from './Event'; + +export class CustomEvent extends Event { + _details: any; + constructor(type: string, { bubbles, captures, cancelable, composed, detail } = EVENT_OPTIONS_DEFAULT as typeof EVENT_OPTIONS_DEFAULT & { detail: any }) { + super(type, { bubbles, captures, cancelable, composed }); + this._details = detail; + } + initCustomEvent() {} + get detail() { + return this._details; + } +} diff --git a/packages/core/ui/core/dom/src/event/Event.ts b/packages/core/ui/core/dom/src/event/Event.ts new file mode 100644 index 0000000000..449c9d636c --- /dev/null +++ b/packages/core/ui/core/dom/src/event/Event.ts @@ -0,0 +1,130 @@ +interface EventInitOptions extends EventInit { + captures?: boolean; + __event_data?: any; +} + +export const EVENT_OPTIONS_DEFAULT: EventInit & { captures?: boolean } = { + bubbles: false, + captures: false, + cancelable: false, +}; + +export const EventPhases = { + BUBBLING_PHASE: 3, + AT_TARGET: 2, + CAPTURING_PHASE: 1, + NONE: 0, +}; + +/** + * Event. + */ +export class Event { + BUBBLING_PHASE: number = 3; + AT_TARGET: number = 2; + CAPTURING_PHASE: number = 1; + NONE: number = 0; + + public eventName: string = undefined; + public object: any = undefined; + public composed = false; + public bubbles = false; + public cancelable = false; + public defaultPrevented = false; + public captures = false; + public passive = false; + public _immediatePropagationStopped = false; + public _propagationStopped = false; + //@ts-ignore + public _target: EventTarget = undefined; + //@ts-ignore + public _currentTarget: EventTarget = undefined; + //@ts-ignore + public type: string = undefined; + + public cancelBubble: boolean; + public eventPhase: number = 0; + public isTrusted: boolean = true; + public returnValue: boolean; + public srcElement: EventTarget; + public timeStamp: number; + + constructor(type: string, options?: EventInitOptions) { + if (!options) options = EVENT_OPTIONS_DEFAULT; + this.initEvent(type, options.bubbles, options.cancelable, options.captures); + if (options.__event_data) { + for (const key in options.__event_data) { + if (/(eventName|object)/g.test(key)) { + this[key] = options.__event_data[key]; + continue; + } + /** + * We want to keep reference of original EventData + * object because it's possible that values are set on it + * inside the event listener. For example in a ListView, + * the `itemLoading` event expects that we set `event.view` + * value in the event. Therefore we install getter/setter for each property + * to get/set values on the original object. + * + * This is only added for backward compatibilty. + * Moving to `dispatchEvent` will not require doing this. + */ + Object.defineProperty(this, key, { + get: () => options.__event_data[key], + set: (v) => (options.__event_data[key] = v), + configurable: true, + }); + } + } + } + + composedPath(): EventTarget[] { + return []; + } + + // eslint-disable-next-line max-params + initEvent(type: string, bubbles?: boolean, cancelable = true, captures?: boolean) { + this.type = type; + this.bubbles = bubbles; + this.cancelable = cancelable; + this.captures = captures; + } + public stopPropagation() { + this._propagationStopped = true; + } + public stopImmediatePropagation() { + this._immediatePropagationStopped = this._propagationStopped = true; + } + public preventDefault() { + if (this.passive) { + console.error('[DOM] Unable to preventDefault inside passive event listener invocation.'); + return; + } + this.defaultPrevented = true; + } + + /** + * Returns target. + * + * @returns Target. + */ + public get target(): EventTarget { + return this._target || this._currentTarget; + } + + /** + * Returns target. + * + * @returns Target. + */ + public get currentTarget(): EventTarget { + return this._currentTarget; + } +} + +new Proxy(Event.prototype, { + get: (target, prop, reciever) => { + console.log('proxy'); + return target[prop]; + }, +}); diff --git a/packages/core/ui/core/dom/src/nodes/character-data/CharacterData.ts b/packages/core/ui/core/dom/src/nodes/character-data/CharacterData.ts new file mode 100644 index 0000000000..22ba511716 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/character-data/CharacterData.ts @@ -0,0 +1,59 @@ +import type Element from '../element/Element'; +import Node from '../node/Node'; +import NodeTypeEnum from '../node/NodeTypeEnum'; + +/** + * Character data base class. + * + * Reference: + * https://developer.mozilla.org/en-US/docs/Web/API/CharacterData. + */ +export default abstract class CharacterData extends Node { + get data(): string { + return this._nodeValue; + } + + set data(data) { + this._nodeValue = data; + propogateTextChangeToParentElement(this); + } + get length() { + return this._nodeValue.length; + } + + get nodeValue() { + return this._nodeValue; + } + set nodeValue(data) { + this._nodeValue = data; + propogateTextChangeToParentElement(this); + } + + get textContent() { + return this._nodeValue; + } + set textContent(text) { + this._nodeValue = `${text}`; + propogateTextChangeToParentElement(this); + } + + appendData(data: string) { + this._nodeValue += data; + } +} + +function propogateTextChangeToParentElement(node: Node) { + let parentElement: Element = null; + let currentNode = node.parentNode; + while (currentNode && !parentElement) { + if (currentNode.nodeType === NodeTypeEnum.elementNode) { + parentElement = currentNode as Element; + } + currentNode = currentNode.parentNode; + } + if (!parentElement) return; + + if ('text' in parentElement) { + parentElement['text'] = parentElement.textContent; + } +} diff --git a/packages/core/ui/core/dom/src/nodes/comment/Comment.ts b/packages/core/ui/core/dom/src/nodes/comment/Comment.ts new file mode 100644 index 0000000000..853e928a81 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/comment/Comment.ts @@ -0,0 +1,43 @@ +import Node from '../node/Node'; +import CharacterData from '../character-data/CharacterData'; +import type Element from '../element/Element'; + +/** + * Comment node. + */ +export default class Comment extends CharacterData { + public readonly nodeType = Node.COMMENT_NODE; + + /** + * Node name. + * + * @returns Node name. + */ + nodeName = '#comment'; + + constructor(comment: string) { + super(); + this.data = comment; + this.textContent = comment; + } + + /** + * Converts to string. + * + * @returns String. + */ + public toString(): string { + return '[object Comment]'; + } + + /** + * Clones a node. + * + * @override + * @param [deep=false] "true" to clone deep. + * @returns Cloned node. + */ + public cloneNode(deep = false): Comment { + return super.cloneNode(deep); + } +} diff --git a/packages/core/ui/core/dom/src/nodes/document-fragment/DocumentFragment.ts b/packages/core/ui/core/dom/src/nodes/document-fragment/DocumentFragment.ts new file mode 100644 index 0000000000..29f4870f37 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/document-fragment/DocumentFragment.ts @@ -0,0 +1,22 @@ +import NodeTypeEnum from '../node/NodeTypeEnum'; +import ParentNode from '../parent-node/ParentNode'; +/** + * DocumentFragment. + */ +export default class DocumentFragment extends ParentNode { + public nodeType = NodeTypeEnum.documentFragmentNode; + public nodeName: string = '#document-fragment'; + public localName: string = '#document-fragment'; + isParentNode = true; + // Set this to true if you want the + // children of this fragment to render. + // By default native counerparts of the + // elements inside a DocumentFragement are + // not created. + canRender = false; + constructor() { + super(); + this._rootNode = this; + } +} +DocumentFragment.prototype['adoptedStyleSheets'] = []; diff --git a/packages/core/ui/core/dom/src/nodes/document-type/DocumentType.ts b/packages/core/ui/core/dom/src/nodes/document-type/DocumentType.ts new file mode 100644 index 0000000000..9c354b998a --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/document-type/DocumentType.ts @@ -0,0 +1,42 @@ +import Node from '../node/Node'; + +/** + * DocumentType. + */ +export default class DocumentType extends Node { + public readonly nodeType = Node.DOCUMENT_TYPE_NODE; + public name: string = null; + public publicId = ''; + public systemId = ''; + + //@ts-ignore + get nodeName() { + return this.name; + } + + set nodeName(name: string) {} + + /** + * Converts to string. + * + * @returns String. + */ + public toString(): string { + return '[object DocumentType]'; + } + + /** + * Clones a node. + * + * @override + * @param [deep=false] "true" to clone deep. + * @returns Cloned node. + */ + public cloneNode(deep = false): DocumentType { + const clone = super.cloneNode(deep); + clone.name = this.name; + clone.publicId = this.publicId; + clone.systemId = this.systemId; + return clone; + } +} diff --git a/packages/core/ui/core/dom/src/nodes/document/Document.ts b/packages/core/ui/core/dom/src/nodes/document/Document.ts new file mode 100644 index 0000000000..bd7abb0eab --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/document/Document.ts @@ -0,0 +1,133 @@ +import ElementTag from '../../config/ElementTag'; +import DOMImplementation from '../../dom-implementation/DOMImplementation'; +import { Event } from '../../event/Event'; +import INodeFilter from '../../tree-walker/INodeFilter'; +import TreeWalker from '../../tree-walker/TreeWalker'; +import Comment from '../comment/Comment'; +import DocumentFragment from '../document-fragment/DocumentFragment'; +import HTMLElement from '../html-element/HTMLElement'; +import type Node from '../node/Node'; +import NodeTypeEnum from '../node/NodeTypeEnum'; +import ParentNode from '../parent-node/ParentNode'; +import Text from '../text/Text'; + +const createElement = (type: string, owner: Document) => { + let element; + if (htmlElementRegistry.has(type)) { + const Class = htmlElementRegistry.get(type) as any; + element = new Class(); + } else if (ElementTag[type]) { + element = new ElementTag[type](); + } else if (customElements.get(type)) { + element = new (customElements.get(type))(); + } else { + element = new HTMLElement(); + } + element.tagName = type; + element.ownerDocument = owner; + element._isRegisteredDOMElement = true; + return element; +}; + +/** + * Document. + */ +export default class Document extends ParentNode { + _defaultView = undefined; + documentElement: HTMLElement; + head: HTMLElement; + body: HTMLElement; + implementation: DOMImplementation; + nodeType: NodeTypeEnum = NodeTypeEnum.documentNode; + isParentNode = true; + /* eslint-disable class-methods-use-this */ + constructor() { + super(); + this.nodeName = '#document'; + this.localName = '#document'; + this.implementation = new DOMImplementation(this); + const doctype = this.implementation.createDocumentType('html', '', ''); + this.appendChild(doctype as any); + } + /** + * Once the document gets created, you must call this method to + * attach `html`, `head` and `body` elements to the document. + * + * ```ts + * const window = new Window(); + * window.document.initDocument('html', 'ContentView'); + * ``` + * @param documentElement + * @param body + */ + initDocument(documentElement = 'html', body = 'body') { + this.documentElement = this.createElement(documentElement); + this.head = this.createElement('head'); + this.body = this.createElement(body); + this.documentElement.appendChild(this.head); + this.documentElement.appendChild(this.body); + document.appendChild(document.documentElement); + } + + createDocumentFragment() { + return new DocumentFragment(); + } + + createElement(type: string) { + return createElement(type, this); + } + + createElementNS(namespace: string, type: string) { + const element = this.createElement(type); + //@ts-ignore + element.namespaceURI = namespace; + return element; + } + + createComment(data: string) { + return new Comment(data); + } + + createTextNode(text: string) { + return new Text(text); + } + + createEvent(type: string) { + return new Event(type); + } + + get defaultView() { + return this._defaultView; + } + set defaultView(scope: any) { + this._defaultView = scope; + } + + /** + * Creates a Tree Walker. + * + * @param root Root. + * @param [whatToShow] What to show. + * @param [filter] Filter. + */ + public createTreeWalker(root: Node, whatToShow = -1, filter: INodeFilter = null): TreeWalker { + return new TreeWalker(root, whatToShow, filter); + } + + /** + * Imports a node. + * + * @param node Node to import. + * @param [deep=false] Set to "true" if the clone should be deep. + */ + public importNode(node: Node, deep = false): Node { + if (!node.isNode) { + throw new Error('Parameter 1 was not of type Node.'); + } + const clone = node.cloneNode(deep); + (clone.ownerDocument) = this; + return clone; + } +} + +Document.prototype['adoptedStyleSheets'] = []; diff --git a/packages/core/ui/core/dom/src/nodes/element/DOMTokenList.ts b/packages/core/ui/core/dom/src/nodes/element/DOMTokenList.ts new file mode 100644 index 0000000000..770cf2beba --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/element/DOMTokenList.ts @@ -0,0 +1,86 @@ +export class DOMTokenList { + constructor(public element: any) {} + changeTimer: NodeJS.Timeout; + + get tokenList() { + return (this.element['cssClasses'] as Set) || (this.element['cssClasses'] = new Set()); + } + + remove(value: any) { + this.tokenList.delete(value); + this.onChange(); + return true; + } + contains(value: any) { + return this.tokenList.has(value); + } + + replace(token: string, oldToken: string) { + this.tokenList.delete(oldToken); + this.tokenList.add(token); + this.onChange(); + } + + add(value: any) { + this.tokenList.add(value); + this.onChange(); + } + + item(index: number) { + const values = Array.from(this.tokenList.values()); + return values[index]; + } + + toggle(value: any, force?: boolean) { + let returnValue = false; + if (force === true) { + this.tokenList.add(value); + returnValue = true; + } else if (force === false) { + this.tokenList.delete(value); + returnValue = false; + } else { + if (this.tokenList.has(value)) { + this.tokenList.delete(value); + returnValue = false; + } else { + this.tokenList.add(value); + returnValue = true; + } + } + this.onChange(); + return returnValue; + } + + get value() { + return this.toString(); + } + + set value(value: any) { + const tokens = value.split(' '); + for (const token of tokens) { + this.tokenList.add(token); + } + this.onChange(); + } + onChange() { + if (this.element['_onCssStateChange']) { + clearTimeout(this.changeTimer); + this.changeTimer = setTimeout(() => { + this.element['_onCssStateChange'](); + }, 0); + } + } + + toString() { + let value = ''; + for (const token in this.tokenList.values()) { + value += ' ' + token; + } + return value; + } + + get length() { + return this.tokenList.size; + } +} diff --git a/packages/core/ui/core/dom/src/nodes/element/Element.ts b/packages/core/ui/core/dom/src/nodes/element/Element.ts new file mode 100644 index 0000000000..8eac16185e --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/element/Element.ts @@ -0,0 +1,403 @@ +import QuerySelector from '../../query-selector/QuerySelector'; +import { createAttributeFilter, findWhere, splice } from '../../utils'; +import XMLParser from '../../xml-parser/XMLParser'; +import XMLSerializer from '../../xml-serializer'; +import type HTMLElement from '../html-element/HTMLElement'; +import type HTMLSlotElement from '../html-slot-element/HTMLSlotElement'; +import type Node from '../node/Node'; +import NodeList from '../node/NodeList'; +import NodeTypeEnum from '../node/NodeTypeEnum'; +import ParentNode from '../parent-node/ParentNode'; +import { LightDOM } from '../shadow-root/LightDOM'; +import { LIGHT_DOM_REF, RETAIN_MARKER, SHADOW_NODE } from '../shadow-root/ShadowNode'; +import { ShadowRoot } from '../shadow-root/ShadowRoot'; +import { DOMTokenList } from './DOMTokenList'; + +export interface IAttr { + namespaceURI: string; + name: string; + value?: any; +} + +/** + * Element. + */ +export default class Element extends ParentNode { + _tagName: string; + attributes: IAttr[] = []; + _shadowRoot: ShadowRoot = null; + _lightDOM: LightDOM = null; + id: string; + _classList: DOMTokenList; + public namespaceURI: string = 'http://www.w3.org/1999/xhtml'; + static _observedAttributes: string[]; + static observedAttributes: string[]; + constructor() { + super(); + this.attributes = []; + this.nodeType = this.ELEMENT_NODE; + } + + get classList() { + return this._classList || (this._classList = new DOMTokenList(this)); + } + + assignedSlot: HTMLSlotElement; + + /** + * Returns slot. + * + * @returns Slot. + */ + public get slot(): string { + return this.getAttribute('slot') || ''; + } + + /** + * Returns slot. + * + * @param slot Slot. + */ + public set slot(title: string) { + this.setAttribute('slot', title); + } + + get tagName() { + if (!this._tagName) return (this._tagName = this.nodeName.toUpperCase()); + return this._tagName; + } + + set tagName(name: string) { + this.nodeName = name; + this.localName = name; + } + + get className() { + return this.getAttribute('class') || ''; + } + set className(val) { + this.setAttribute('class', val); + } + + get innerHTML() { + return new XMLSerializer().serializeToString(this as never, { innerHTML: true }); + } + + set innerHTML(html: string) { + let currentNode = this.firstChild; + while (currentNode) { + const nextSibling = currentNode.nextSibling; + currentNode.remove(); + currentNode = nextSibling; + } + + if (html === '') return; + const nodes = XMLParser.parse(this.ownerDocument, html).childNodes.slice(); + for (const node of nodes) { + this.appendChild(node); + } + } + + /** + * Get text value of children. + * + * @returns Text content. + */ + public get textContent(): string { + let result = ''; + let currentNode = this.firstChild; + while (currentNode) { + if (currentNode.nodeType === NodeTypeEnum.elementNode || currentNode.nodeType === NodeTypeEnum.textNode) { + result += (currentNode as Element).textContent; + } + currentNode = currentNode.nextSibling; + } + return result; + } + + /** + * Sets text content. + * + * @param textContent Text content. + */ + public set textContent(textContent: string) { + for (const child of this.childNodes) { + this.removeChild(child); + } + if (textContent) { + this.appendChild(this.ownerDocument.createTextNode(textContent)); + } + } + + get outerHTML() { + return new XMLSerializer().serializeToString(this as never); + } + + set outerHTML(value) { + // Setting outerHTML with an empty string just removes the element from it's parent + if (value === '') { + this.remove(); + return; + } + + throw new Error(`[DOM] Failed to set 'outerHTML' on '${this.localName}': Not implemented.`); + } + + get cssText() { + return this.getAttribute('style'); + } + + set cssText(val) { + this.setAttribute('style', val); + } + + setAttribute(name: string, value: unknown) { + //@ts-ignore + this.setAttributeNS(null, name, value); + } + getAttribute(name: string) { + //@ts-ignore + return this.getAttributeNS(null, name); + } + removeAttribute(name: string) { + //@ts-ignore + this.removeAttributeNS(null, name); + } + + setAttributeNS(namespace: string, name: string, value: unknown) { + updateAttributeNS(this, namespace, name, value); + } + + getAttributeNS(namespace: string, name: string) { + const attr = findWhere(this.attributes, createAttributeFilter(namespace, name), false, false); + return attr && attr.value; + } + removeAttributeNS(namespace: string, name: string) { + this[name] = null; + if (!this.attributeChangedCallback || !(this.constructor)._observedAttributes || !(this.constructor)._observedAttributes.includes(name)) { + splice(this.attributes, createAttributeFilter(namespace, name), false, false); + return; + } + const oldValue = this.getAttribute(name); + splice(this.attributes, createAttributeFilter(namespace, name), false, false); + this.attributeChangedCallback(name, oldValue, null); + } + + /** + * Returns a boolean value indicating whether the specified element has the attribute or not. + * + * @param name Attribute name. + * @returns True if attribute exists, false otherwise. + */ + public hasAttribute(name: string): boolean { + return !!this.attributes.some((attribute) => attribute.name === name); + } + + /** + * Returns a boolean value indicating whether the specified element has the namespace attribute or not. + * + * @param namespace Namespace URI. + * @param localName Local name. + * @returns True if attribute exists, false otherwise. + */ + public hasAttributeNS(namespace: string, localName: string): boolean { + return !!this.attributes.some((attribute) => attribute.name === localName && attribute.namespaceURI === namespace); + } + + hasAttributes(): boolean { + return !!this.attributes.length; + } + + getAttributeNames(): string[] { + return this.attributes.map((attribute) => attribute.name); + } + + get shadowRoot() { + if (this._shadowRoot && this._shadowRoot.mode === 'open') return this._shadowRoot; + return null; + } + + attachShadow({ mode = 'open' }: { mode: 'open' | 'closed' }) { + if (this._shadowRoot) throw new Error('[DOM] Shadow root cannot be created on a host which already hosts a shadow tree.'); + const shadow = new ShadowRoot({ + ownerDocument: this.ownerDocument, + mode: mode, + host: this, + }); + + this._shadowRoot = shadow; + const childNodes = this.childNodes; + // Remove any childern from rendered DOM + for (const node of childNodes) { + node.remove(); + } + this._lightDOM = new LightDOM(LIGHT_DOM_REF, this); + // // Add childern back to Light DOM + // // If they are slottable, they will + // // get rendered again in the + // // Shadow Tree. + this.append(...childNodes); + return shadow; + } + + appendChild(node: Node) { + return this.insertBefore(node, undefined); + } + insertBefore(newNode: Node, referenceNode: Node) { + newNode.canRender = true; + if (!this._shadowRoot || newNode[SHADOW_NODE]) { + return super.insertBefore(newNode, referenceNode); + } + + const slot = this._shadowRoot._slots.get((newNode as any).slot); + if (slot) { + (newNode as any).assignedSlot = slot; + this._lightDOM.insertBefore(newNode, referenceNode); + slot.insertBefore(newNode, referenceNode); + if (newNode[SHADOW_NODE]) super.insertBefore(newNode, referenceNode); + slot.dispatchSlotChangeEvent(); + } else { + // Ensures that this node does not render in the native view tree. + newNode.canRender = false; + // Insert the node in DOM, this will get moved to it's + // slottable parent automatically when a slot is available. + super.insertBefore(newNode, referenceNode); + this._lightDOM.insertBefore(newNode, referenceNode); + } + return newNode; + } + + replaceChild(newChild: Node, oldChild: Node) { + newChild.canRender = true; + if (!this._shadowRoot || newChild[SHADOW_NODE]) { + return super.replaceChild(newChild, oldChild); + } + + const slot = this._shadowRoot._slots.get((newChild as any).slot); + if (slot) { + (newChild as any).assignedSlot = slot; + this._lightDOM.replaceChild(newChild, oldChild); + slot.replaceChild(newChild, oldChild); + newChild.canRender = true; + if (newChild[SHADOW_NODE]) super.replaceChild(newChild, oldChild); + slot.dispatchSlotChangeEvent(); + } else { + // Ensures that this node does not render in the native view tree. + newChild.canRender = false; + // Insert the node in DOM, this will get moved to it's + // slottable parent automatically when a slot is available. + super.replaceChild(newChild, oldChild); + this._lightDOM.replaceChild(newChild, oldChild); + } + return newChild; + } + removeChild(node: any) { + node.canRender = true; + if (!this._shadowRoot || node[SHADOW_NODE]) { + if (node[SHADOW_NODE]) { + node[SHADOW_NODE] = false; + node._rootNode = null; + } + return super.removeChild(node); + } + + const slot = (this as any)._shadowRoot._slots.get((node as any).slot); + if (slot && node.assignedSlot) { + (node as HTMLElement).assignedSlot = slot; + if (!node[RETAIN_MARKER] && this._lightDOM) this._lightDOM.removeChild(node); + slot.removeChild(node); + slot.dispatchSlotChangeEvent(); + } else { + // Ensures that this node can render in the native view tree. + super.removeChild(node); + if (!node[RETAIN_MARKER] && this._lightDOM) this._lightDOM.removeChild(node); + } + } + + /** + * The matches() method checks to see if the Element would be selected by the provided selectorString. + * + * @param selector Selector. + * @returns "true" if matching. + */ + public matches(selector: string): boolean { + return QuerySelector.match(this, selector).matches; + } + + /** + * Traverses the Element and its parents (heading toward the document root) until it finds a node that matches the provided selector string. + * + * @param selector Selector. + * @returns Closest matching element. + */ + public closest(selector: string): Element { + let rootElement: Element = this.ownerDocument.documentElement; + if (!this.parentNode) { + // eslint-disable-next-line + rootElement = this; + while (rootElement.parentNode) { + rootElement = rootElement.parentNode as Element; + } + } + const elements = rootElement.querySelectorAll(selector); + + // eslint-disable-next-line + let parent: Element = this; + while (parent) { + if (elements.includes(parent)) { + return parent; + } + parent = parent.parentNode as Element; + } + + // QuerySelectorAll() will not match the element it is looking in when searched for + // Therefore we need to check if it matches the root + if (rootElement.matches(selector)) { + return rootElement; + } + + return null; + } + + /** + * Query CSS selector to find matching nodes. + * + * @param selector CSS selector. + * @returns Matching elements. + */ + public querySelectorAll(selector: string): NodeList { + return QuerySelector.querySelectorAll(this, selector); + } + + /** + * Query CSS Selector to find matching node. + * + * @param selector CSS selector. + * @returns Matching element. + */ + public querySelector(selector: string): Element { + return QuerySelector.querySelector(this, selector); + } + /** + * A callback that fires for element's observed attributes. + * @param name + * @param oldValue + * @param newValue + */ + public attributeChangedCallback?(name: string, oldValue: string, newValue: any): void; +} + +// eslint-disable-next-line max-params +const updateAttributeNS = (self: Element, namespace: string, name: string, value: any) => { + let attr = findWhere(self.attributes, createAttributeFilter(namespace, name), false, false); + if (!attr) self.attributes.push((attr = { namespaceURI: namespace, name } as IAttr)); + if (attr.value === value) return; + attr.value = value; + if (self.canRender && self['isNativeElement']) { + self[name] = value; + } + + if (self.attributeChangedCallback && (self.constructor)._observedAttributes && (self.constructor)._observedAttributes.includes(name)) { + self.attributeChangedCallback(name, attr.value, value); + } +}; diff --git a/packages/core/ui/core/dom/src/nodes/html-element/HTMLElement.ts b/packages/core/ui/core/dom/src/nodes/html-element/HTMLElement.ts new file mode 100644 index 0000000000..4c3258ed93 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/html-element/HTMLElement.ts @@ -0,0 +1,18 @@ +import { Style } from '../../../../../styling/style'; +import Element from '../element/Element'; + +export default class HTMLElement extends Element { + isElement: boolean = true; + + get style(): Style { + return this['_style'] || (this['_style'] = new Style(new WeakRef(this) as any)); + } + /** + * Determines if an element needs to be added to the native + * view hierarchy. + */ + isNativeElement: boolean = false; + constructor() { + super(); + } +} diff --git a/packages/core/ui/core/dom/src/nodes/html-item-template-element/HTMLItemTemplateElement.ts b/packages/core/ui/core/dom/src/nodes/html-item-template-element/HTMLItemTemplateElement.ts new file mode 100644 index 0000000000..75d7cb13ca --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/html-item-template-element/HTMLItemTemplateElement.ts @@ -0,0 +1,147 @@ +import type { View } from './../../../../view/index'; +import { ContentView } from '../../../../../content-view'; +import { DOMUtils } from '../../utils'; +import type HTMLElement from '../html-element/HTMLElement'; +import { HTMLPropBaseElement } from '../html-prop-element/HTMLPropElement'; +import NodeTypeEnum from '../node/NodeTypeEnum'; +import { Event } from '../../event/Event'; + +export class ItemTemplate extends ContentView { + itemTemplateElement: HTMLItemTemplateElement; + constructor(template: HTMLItemTemplateElement) { + super(); + this.style.padding = '0, 0, 0, 0'; + this.style.margin = '0, 0, 0, 0'; + this.itemTemplateElement = template; + } +} + +class CreateViewEvent extends Event { + public view: HTMLElement = undefined; + constructor(eventInit?: EventInit) { + super('createView', eventInit); + } +} + +class ItemLoadingEvent extends Event { + public view: HTMLElement = undefined; + public index: number = 0; + public data: unknown = undefined; + public item: unknown = undefined; + constructor(eventInit?: EventInit) { + super('itemLoading', eventInit); + } +} + +const hydrate = (source: HTMLElement, target: HTMLElement) => { + if (process.env.NODE_ENV !== 'production') { + if (!source.isNode || target.isNode) throw new TypeError('[DOM] Can only patch undom nodes!'); + if (target && source.constructor !== target.constructor) throw new TypeError('[DOM] Cannot patch different type of nodes!'); + } + + if (source.isElement) { + let sourceNode = source.firstChild; + let targetNode = target.firstChild; + while (sourceNode) { + if (!targetNode || targetNode.constructor !== sourceNode.constructor) { + const clonedNode = hydrate(sourceNode as HTMLElement, sourceNode.cloneNode() as HTMLElement); + if (targetNode) targetNode.replaceWith(clonedNode); + else target.appendChild(clonedNode); + } else { + hydrate(sourceNode as HTMLElement, targetNode as HTMLElement); + } + + sourceNode = sourceNode.nextSibling; + if (targetNode) targetNode = targetNode.nextSibling; + } + + while (targetNode) { + targetNode.remove(); + targetNode = targetNode.nextSibling; + } + } else if (source.nodeType === NodeTypeEnum.textNode || source.nodeType === NodeTypeEnum.commentNode) { + target._nodeValue = source._nodeValue; + } + + DOMUtils.reassignObjectProperties((target as any)._observers, (source as any)._observers); + DOMUtils.reassignObjectProperties((target as any)._captureObservers, (source as any)._captureObservers); + return target; +}; + +const defaultCreateView = (self: HTMLItemTemplateElement) => { + if (!self._content) return null; + return hydrate(self._content, self._content.cloneNode() as HTMLElement); +}; + +export class HTMLItemTemplateElement extends HTMLPropBaseElement { + _content: HTMLElement; + _loadingEvents = new Map(); + constructor(key = 'itemTemplate') { + super(key); + this._type = 'key'; + this._value = () => this.createView(); + } + + set type(value: string) { + if (process.env.NODE_ENV !== 'production') console.warn('[DOM] Cannot set type of a Template.'); + return; + } + + set value(value: string) { + if (process.env.NODE_ENV !== 'production') console.warn('[DOM] Cannot set value of a Template.'); + return; + } + + get content() { + return this._content; + } + set content(value: HTMLElement) { + this._content = value; + } + private getLoadingEvent(view: HTMLElement) { + if (this._loadingEvents.has(view)) return this._loadingEvents.get(view); + const event = new ItemLoadingEvent(); + event.view = view; + this._loadingEvents.set(view, event); + return event; + } + + patch({ view, index, item, data }: { view: HTMLElement; index: number; item: unknown; data: unknown }) { + if (view.nodeType !== NodeTypeEnum.elementNode) return; + if (this._content) return hydrate(this._content, view); + + const event = this.getLoadingEvent(view); + event.index = index; + event.item = item; + event.data = data; + this.dispatchEvent(event); + + return event.view || null; + } + + createView() { + const wrapper = new ItemTemplate(this); + if (this._content) wrapper.content = defaultCreateView(this) as any; + else { + const event = new CreateViewEvent(); + this.dispatchEvent(event); + wrapper.content = (event.view as never) || null; + } + return wrapper; + } + + public setProp(): void { + if (!(this.parentNode instanceof HTMLPropBaseElement)) return super.setProp(); + } + + insertBefore(newNode: HTMLElement, referenceNode: HTMLElement): HTMLElement { + super.insertBefore(newNode, referenceNode); + this._content = newNode; + return newNode; + } + + removeChild(node: any) { + super.removeChild(node); + if (this._content === node) this._content = null; + } +} diff --git a/packages/core/ui/core/dom/src/nodes/html-label-element/HTMLLabelELement.ts b/packages/core/ui/core/dom/src/nodes/html-label-element/HTMLLabelELement.ts new file mode 100644 index 0000000000..1ad0c27ee6 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/html-label-element/HTMLLabelELement.ts @@ -0,0 +1,57 @@ +import { Label } from '../../../../../label'; +import type HTMLElement from '../html-element/HTMLElement'; + +/** + * HTML Label Element. + * + * Reference: + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLLabelElement. + */ +export class HTMLLabelElement extends Label { + /** + * Returns a string containing the ID of the labeled control. This reflects the "for" attribute. + * + * @returns ID of the labeled control. + */ + public get htmlFor(): string { + return ''; + } + + /** + * Sets a string containing the ID of the labeled control. This reflects the "for" attribute. + * + * @param htmlFor ID of the labeled control. + */ + public set htmlFor(htmlFor: string) { + //this.setAttribute('for', htmlFor); + } + + /** + * Returns an HTML element representing the control with which the label is associated. + * + * @returns Control element. + */ + public get control(): HTMLElement { + return null; + } + + /** + * Returns the parent form element. + * + * @returns Form. + */ + public get form(): any { + return null; + } + + /** + * Clones a node. + * + * @override + * @param [deep=false] "true" to clone deep. + * @returns Cloned node. + */ + public cloneNode(deep = false): HTMLLabelElement { + return super.cloneNode(deep); + } +} diff --git a/packages/core/ui/core/dom/src/nodes/html-prop-element/HTMLPropElement.ts b/packages/core/ui/core/dom/src/nodes/html-prop-element/HTMLPropElement.ts new file mode 100644 index 0000000000..0baa3efca6 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/html-prop-element/HTMLPropElement.ts @@ -0,0 +1,126 @@ +import { DOMUtils } from './../../utils/index'; +import HTMLElement from '../html-element/HTMLElement'; +import type Node from '../node/Node'; +import NodeTypeEnum from '../node/NodeTypeEnum'; +export type ParentPropType = 'array' | 'key'; + +/** + * An element whose childern are set as an attribute + * on the parent. + */ +export class HTMLPropBaseElement extends HTMLElement { + /** + * The prop on the parent that should be set by this + * prop element + */ + public _key: string = undefined; + /** + * The type of prop, can be an "array" or a "key" prop. + */ + public _type: ParentPropType = undefined; + + public _value: any = undefined; + + constructor(key: string, type: ParentPropType = 'key') { + super(); + if (key) this.key = key; + if (type) this.type = type; + } + + get key() { + return this._key; + } + set key(value: string) { + if (this._key === value) return; + // If the key has changed, reset the current prop + if (this.parentNode && this._key) (this.parentNode as HTMLElement)[this.key] = null; + this._key = value; + this.setProp(); + } + + get type() { + return this._type || 'key'; + } + + set type(value: unknown) { + if (this._type || this._value) { + if (process.env.NODE_ENV !== 'production') console.warn('[DOM] Prop type cannot be changed once set or when children exist.'); + return; + } + if (value !== 'array') return; + + this._type = value; + if (value === 'array') { + this._value = []; + const children = this.childNodes.slice(); + for (const i of children) this.appendChild(i); + } + this.setProp(); + } + + get value() { + if (this.parentNode && this._key) this._value = (this.parentNode as HTMLElement)[this.key]; + return this._value; + } + + set value(value) { + this._value = value; + this.setProp(); + } + + get class() { + return `${this._key}:${this._type}`; + } + + set class(value: string) { + const [key, type] = value.split(':'); + if (type) this._type = type as ParentPropType; + if (key) this._key = key; + } + + get className() { + return `${this._key}:${this._type}`; + } + set className(val) { + this.class = val; + } + + public setProp() { + if (!this._key || !this.parentNode) return; + (this.parentNode as HTMLElement)[this.key] = this._value; + } +} + +export class HTMLPropElement extends HTMLPropBaseElement { + insertBefore(newNode: Node, referenceNode: Node): Node { + super.insertBefore(newNode, referenceNode); + if (Array.isArray(this._value)) { + DOMUtils.addToArrayProp(this, '_value', newNode, referenceNode); + if (this.key && this.parentNode && this._value !== this.parentNode[this.key]) DOMUtils.addToArrayProp(this.parentNode, this.key, newNode, referenceNode); + } else { + this.value = newNode; + } + return newNode; + } + + removeChild(node: any) { + if (this.type === 'array') { + DOMUtils.removeFromArrayProp(this, '_value', node); + if (this._key && this.parentNode) DOMUtils.removeFromArrayProp(this.parentNode, this.key, node); + } else if (this.value === node) this.value = null; + + super.removeChild(node); + } +} + +export class HTMLKeyPropElement extends HTMLPropElement { + constructor(key: string) { + super(key, 'key'); + } +} + +export class HTMLArrayPropElement extends HTMLPropElement { + constructor(key: string) { + super(key, 'array'); + } +} diff --git a/packages/core/ui/core/dom/src/nodes/html-slot-element/HTMLSlotElement.ts b/packages/core/ui/core/dom/src/nodes/html-slot-element/HTMLSlotElement.ts new file mode 100644 index 0000000000..b32990a1e8 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/html-slot-element/HTMLSlotElement.ts @@ -0,0 +1,255 @@ +import type Element from '../element/Element'; +import HTMLElement from '../html-element/HTMLElement'; +import type Node from '../node/Node'; +import { LightDOM } from '../shadow-root/LightDOM'; +import { BIND_SLOTTABLE, FALLBACK_REF, SHADOW_NODE, SLOTTED_CHILD_REF } from '../shadow-root/ShadowNode'; +import type { ShadowRoot } from '../shadow-root/ShadowRoot'; + +function isNullOrUndefined(value: any) { + return value === undefined || value === null; +} + +export default class HTMLSlotElement extends HTMLElement { + private _slotChangeEvent: Event; + /** + * Childern that get rendered when slot has + * no assigned nodes or it's assigned nodes get removed. + */ + _fallbackChildern: LightDOM = new LightDOM(FALLBACK_REF); + + /** + * Childern that are currently/will be rendered in the DOM Tree. + * Since slot element does not render any childern in the Shadow DOM. + * We keep track of all it's current slotted childern that are attached + * with it's nearest native parent element. + */ + _slotAssignments: LightDOM = new LightDOM(SLOTTED_CHILD_REF); + /** + * Returns the slot name. + * + * @returns Name. + */ + public get name(): string { + return this.getAttributeNS(null, 'name') || ''; + } + + /** + * Sets the slot name. + * + * @param name Name. + */ + public set name(name: string) { + const oldValue = this.name; + this.setAttributeNS(null, 'name', name); + // If the slot name changes, we notify the shadow root. + const root = this.getRootNode() as ShadowRoot; + if (root.isShadow && !root._slots.has(name)) { + const prev = root._slots.get(oldValue); + if (prev === this) root._slots.delete(oldValue); + root._slots.set(name, this); + this.dispatchSlotChangeEvent(); + } + } + + /** + * Returns assigned nodes. + * + * @param [options] Options. + * @param [options.flatten] A boolean value indicating whether to return the assigned nodes of any available child elements (true) or not (false). Defaults to false. + * @returns Nodes. + */ + public assignedNodes(options: { flatten?: boolean } = { flatten: false }): Node[] { + const host = (this.getRootNode())?.host; + + if (!host) return []; + + const name = this.name; + + if (!isNullOrUndefined(name)) { + return this.assignedElements(options); + } + + return []; + } + + /** + * Returns assigned elements. + * + * @param [_options] Options. + * @param [_options.flatten] A boolean value indicating whether to return the assigned elements of any available child elements (true) or not (false). Defaults to false. + * @returns Nodes. + */ + public assignedElements(_options: { flatten?: boolean } = { flatten: false }): Element[] { + const host = (this.getRootNode())?.host as Element; + if (!host) return []; + if (_options.flatten) { + return flattenAssignedNodes([], this); + } + + const name = this.name; + if (!isNullOrUndefined(name)) { + const assignedElements = []; + /** + * Look up assigned nodes in the Light DOM. + */ + let current = (host as any)._lightDOM.firstChild; + while (current) { + const node = current.node; + if (node.slot === name && !node.assignedSlot) { + assignedElements.push(node); + } + current = current.nextSibling; + } + return assignedElements; + } + + return []; + } + + /** + * Clones a node. + * + * @override + * @param [deep=false] "true" to clone deep. + * @returns Cloned node. + */ + public cloneNode(deep = false): HTMLSlotElement { + const clone = super.cloneNode(deep); + return clone; + } + + private _getSlotChangeEvent() { + return this._slotChangeEvent || (this._slotChangeEvent = new Event('slotchange', { bubbles: true, cancelable: false })); + } + + dispatchSlotChangeEvent() { + const event = this._getSlotChangeEvent(); + this.dispatchEvent(event); + } + + public connectedCallback(): void { + const root = this.getRootNode() as ShadowRoot; + if (root.isShadow) { + // Only one slot with a specific slot name can render + // slottables. + if (root._slots.has(this.name)) return; + root._slots.set(this.name, this); + this.dispatchSlotChangeEvent(); + } else { + // if shadow does not exist then attach assigned fallback content. + if (!this._slotAssignments.firstChild?.node.parentNode) { + (this.parentNode as HTMLElement).append(...this._slotAssignments.childNodes); + } + } + } + + public disconnectedCallback(): void { + const root = this.getRootNode() as ShadowRoot; + if (!root.isShadow) return; + if (root.isShadow) { + if (root._slots.get(this.name) !== this) return; + if (root._slots.delete(this.name)) { + this.dispatchSlotChangeEvent(); + } + } else { + // if shadow does not exist, detach slot assignments from their parent. + if (this._slotAssignments.firstChild?.node.parentNode) { + for (const node of this._slotAssignments.childNodes) { + node.remove(); + } + } + } + } + + isFallbackRendered() { + if (!this._slotAssignments.firstChild) return true; + return this._slotAssignments.firstChild.node === this._fallbackChildern.firstChild.node; + } + + appendChild(node: Node): Node { + return this.insertBefore(node, undefined); + } + + insertBefore(newNode: Node, referenceNode: Node): Node { + if (!(newNode as Element).assignedSlot) { + this._fallbackChildern.insertBefore(newNode, referenceNode); + if (!this.isFallbackRendered()) { + newNode.canRender = false; + } + } + + if (!newNode[SLOTTED_CHILD_REF]) { + this._slotAssignments.insertBefore(newNode, referenceNode); + } + + // If slottable is not yet attached to DOM, return. + if (!this.parentNode) return; + + newNode[SHADOW_NODE] = true; + newNode._rootNode = this._rootNode; + if (newNode[BIND_SLOTTABLE] || newNode[FALLBACK_REF]) { + this.parentNode.insertBefore(newNode, referenceNode); + } + return newNode; + } + + replaceChild(newChild: Node, oldChild: Node): Node { + if (!(newChild as Element).assignedSlot && !(newChild as Element).assignedSlot) { + this._fallbackChildern.replaceChild(newChild, oldChild); + if (!this.isFallbackRendered()) { + newChild.canRender = false; + } + } + + if (!newChild[SLOTTED_CHILD_REF]) { + this._slotAssignments.replaceChild(newChild, oldChild); + } + if (!this.parentNode) return; + newChild[SHADOW_NODE] = true; + newChild._rootNode = this._rootNode; + + if (newChild[BIND_SLOTTABLE] || newChild[FALLBACK_REF]) { + this.parentNode.replaceChild(newChild, oldChild); + } + return newChild; + } + + removeChild(node: any) { + if (!(node as Element).assignedSlot) { + this._fallbackChildern.removeChild(node); + if (!this.isFallbackRendered()) return; + } + + if (node[SLOTTED_CHILD_REF]) { + this._slotAssignments.removeChild(node); + } + if (!this.parentNode) return; + node[SHADOW_NODE] = false; + node._rootNode = null; + this.parentNode.removeChild(node); + } + + renderFallback() { + if (this._fallbackChildern.childrenCount > 0 && this._fallbackChildern.firstChild?.node !== this._slotAssignments.firstChild?.node) { + for (const node of this._fallbackChildern.childNodes) { + if (node.parentNode) node.remove(); + node.canRender = true; + this.appendChild(node); + } + } + } +} + +function flattenAssignedNodes(nodes: Node[], node: HTMLSlotElement) { + // Get assigned nodes of this node + const assignedNodes = node.assignedElements(); + for (const child of assignedNodes) { + if (child.nodeName === 'slot') { + // Flatten any child slot elements + flattenAssignedNodes(nodes, child as HTMLSlotElement); + } else { + nodes.push(child); + } + } + return nodes as Element[]; +} diff --git a/packages/core/ui/core/dom/src/nodes/html-template-element/HTMLTemplateElement.ts b/packages/core/ui/core/dom/src/nodes/html-template-element/HTMLTemplateElement.ts new file mode 100644 index 0000000000..733776e515 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/html-template-element/HTMLTemplateElement.ts @@ -0,0 +1,128 @@ +import XMLParser from '../../xml-parser/XMLParser'; +import XMLSerializer from '../../xml-serializer'; +import type DocumentFragment from '../document-fragment/DocumentFragment'; +import HTMLElement from '../html-element/HTMLElement'; +import type Node from '../node/Node'; + +/** + * HTML Template Element. + * + * Reference: + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLTemplateElement. + */ +export default class HTMLTemplateElement extends HTMLElement { + public _content: DocumentFragment; + constructor() { + super(); + this.nodeName = 'template'; + } + + get content(): DocumentFragment { + if (!this.ownerDocument) return {} as any; + return this._content || (this._content = this.ownerDocument.createDocumentFragment()); + } + + set content(document: DocumentFragment) { + this._content = document; + } + + /** + * @override + */ + public get innerHTML(): string { + return this.getInnerHTML(); + } + + /** + * @override + */ + public set innerHTML(html: string) { + for (const child of this.content.childNodes.slice()) { + this.content.removeChild(child); + } + + for (const node of XMLParser.parse(this.ownerDocument, html).childNodes.slice()) { + this.content.appendChild(node); + } + } + + /** + * @override + */ + //@ts-ignore + public get firstChild(): Node { + return this.content.firstChild; + } + + /** + * @override + */ + //@ts-ignore + public set firstChild(node: Node) { + this.content.firstChild = node; + } + + /** + * @override + */ + //@ts-ignore + public get lastChild(): Node { + return this.content.lastChild; + } + + /** + * @override + */ + //@ts-ignore + public set lastChild(node: Node) { + this.content.lastChild = node; + } + + /** + * @override + */ + public getInnerHTML(options: { includeShadowRoots?: boolean } = { includeShadowRoots: false }): string { + const xmlSerializer = new XMLSerializer(); + return xmlSerializer.serializeToString(this.content, { + includeShadowRoots: options.includeShadowRoots, + innerHTML: true, + }); + } + + /** + * @override + */ + public appendChild(node: Node): Node { + return this.content.appendChild(node); + } + + /** + * @override + */ + public removeChild(node: Node): Node { + return this.content.removeChild(node); + } + + /** + * @override + */ + public insertBefore(newNode: Node, referenceNode: Node): Node { + return this.content.insertBefore(newNode, referenceNode); + } + + /** + * @override + */ + public replaceChild(newChild: Node, oldChild: Node): Node { + return this.content.replaceChild(newChild, oldChild); + } + + /** + * @override + */ + public cloneNode(deep = false): HTMLTemplateElement { + const clone = super.cloneNode(deep); + clone.content = this.content.cloneNode(deep) as DocumentFragment; + return clone; + } +} diff --git a/packages/core/ui/core/dom/src/nodes/node/Node.ts b/packages/core/ui/core/dom/src/nodes/node/Node.ts new file mode 100644 index 0000000000..0db51e1d0d --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/node/Node.ts @@ -0,0 +1,318 @@ +import { Observable } from './../../../../../../data/observable'; +import type Element from '../element/Element'; +import NodeList from './NodeList'; +import NodeTypeEnum from './NodeTypeEnum'; + +/** + * Node. + */ +export default class Node extends Observable { + public static readonly ELEMENT_NODE = NodeTypeEnum.elementNode; + public static readonly ATTRIBUTE_NODE = NodeTypeEnum.attributeNode; + public static readonly TEXT_NODE = NodeTypeEnum.textNode; + public static readonly CDATA_SECTION_NODE = NodeTypeEnum.cdataSectionNode; + public static readonly COMMENT_NODE = NodeTypeEnum.commentNode; + public static readonly DOCUMENT_NODE = NodeTypeEnum.documentNode; + public static readonly DOCUMENT_TYPE_NODE = NodeTypeEnum.documentTypeNode; + public static readonly DOCUMENT_FRAGMENT_NODE = NodeTypeEnum.documentFragmentNode; + public static readonly PROCESSING_INSTRUCTION_NODE = NodeTypeEnum.processingInstructionNode; + public readonly ELEMENT_NODE = NodeTypeEnum.elementNode; + public readonly ATTRIBUTE_NODE = NodeTypeEnum.attributeNode; + public readonly TEXT_NODE = NodeTypeEnum.textNode; + public readonly CDATA_SECTION_NODE = NodeTypeEnum.cdataSectionNode; + public readonly COMMENT_NODE = NodeTypeEnum.commentNode; + public readonly DOCUMENT_NODE = NodeTypeEnum.documentNode; + public readonly DOCUMENT_TYPE_NODE = NodeTypeEnum.documentTypeNode; + public readonly DOCUMENT_FRAGMENT_NODE = NodeTypeEnum.documentFragmentNode; + public readonly PROCESSING_INSTRUCTION_NODE = NodeTypeEnum.processingInstructionNode; + + nodeType: NodeTypeEnum = NodeTypeEnum.elementNode; + nodeName: string = ''; + _nodeValue: string = null; + _textContent: string = null; + + readonly ownerDocument: any; + readonly baseURI: string = null; + + parentElement: Element = null; + _parentNode: Node = null; + parentNode: Node = null; + + nextSibling: Node = null; + previousSibling: Node = null; + firstChild: Node = null; + lastChild: Node = null; + + localName: string = null; + _rootNode: Node = null; + + isNode: boolean = true; + canRender: boolean = true; + isParentNode: boolean = false; + + constructor() { + super(); + } + + // get parentNode() { + // if (this._parentNode && (this._parentNode as Element)._shadowRoot) return (this._parentNode as Element)._shadowRoot; + // return this._parentNode; + // } + + // set parentNode(parent: Node) { + // this._parentNode = parent; + // } + + get previousElementSibling(): Node { + let currentNode = this.previousSibling; + while (currentNode) { + if (currentNode.nodeType === NodeTypeEnum.elementNode) return currentNode; + currentNode = currentNode.previousSibling; + } + //@ts-ignore + return null; + } + + get nextElementSibling(): Node { + let currentNode = this.nextSibling; + while (currentNode) { + if (currentNode.nodeType === NodeTypeEnum.elementNode) return currentNode; + currentNode = currentNode.nextSibling; + } + //@ts-ignore + return null; + } + + remove() { + if (this._parentNode) this._parentNode.removeChild(this as never); + } + + get childNodes() { + const childNodes = new NodeList(); + let currentNode = this.firstChild; + while (currentNode) { + childNodes.push(currentNode); + currentNode = currentNode.nextSibling; + } + return childNodes; + } + + /** + * Clones a node. + * + * @param [deep=false] "true" to clone deep. + * @returns Cloned node. + */ + cloneNode(deep?: boolean) { + let clonedNode: Node; + if (this.isParentNode) { + if (this.nodeType === NodeTypeEnum.documentNode) + //@ts-ignore + clonedNode = new Document(); + else if (this.nodeType === NodeTypeEnum.documentFragmentNode) + //@ts-ignore + clonedNode = new DocumentFragment(); + else { + clonedNode = this.ownerDocument.createElement(this.localName); + const sourceAttrs = (this as unknown as Element).attributes; + for (const { namespaceURI, name, value } of sourceAttrs) { + (clonedNode as Element).setAttributeNS(namespaceURI, name, value); + } + } + + if (deep) { + let currentNode = this.firstChild; + while (currentNode) { + clonedNode.appendChild(currentNode.cloneNode(deep)); + currentNode = currentNode.nextSibling; + } + } + } else if (this.nodeType === NodeTypeEnum.textNode) + //@ts-ignore + clonedNode = new Text(this._nodeValue); + else if (this.nodeType === NodeTypeEnum.commentNode) + //@ts-ignore + clonedNode = new Comment(this._nodeValue); + //@ts-ignore + return clonedNode; + } + + hasChildNodes() { + return !!this.firstChild; + } + + /** + * Returns "true" if this node contains the other node. + * + * @param otherNode Node to test with. + * @returns "true" if this node contains the other node. + */ + public contains(otherNode: Node): boolean { + for (const childNode of this.childNodes) { + if (childNode === otherNode || childNode.contains(otherNode)) { + return true; + } + } + return false; + } + + insertBefore(newNode: Node, referenceNode: Node): Node { + //@ts-ignore + if (referenceNode && referenceNode._parentNode !== this) throw new Error(`[UNDOM-NG] Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.`); + + if (newNode === referenceNode) return newNode; + + if (newNode.nodeType === NodeTypeEnum.documentFragmentNode) { + const { firstChild, lastChild } = newNode; + + if (firstChild && lastChild) { + let currentNode = firstChild; + while (currentNode) { + const nextSibling = currentNode.nextSibling; + //@ts-ignore + currentNode._parentNode = this; + currentNode = nextSibling; + } + + if (referenceNode) { + firstChild.previousSibling = referenceNode.previousSibling; + lastChild.nextSibling = referenceNode; + referenceNode.previousSibling = lastChild; + } else { + firstChild.previousSibling = this.lastChild; + //@ts-ignore + lastChild.nextSibling = null; + } + + if (firstChild.previousSibling) firstChild.previousSibling.nextSibling = firstChild; + else this.firstChild = firstChild; + + if (lastChild.nextSibling) lastChild.nextSibling.previousSibling = lastChild; + else this.lastChild = lastChild; + //@ts-ignore + newNode.firstChild = null; + //@ts-ignore + newNode.lastChild = null; + } + } else { + newNode.remove(); + //@ts-ignore + newNode._parentNode = this; + if (referenceNode) { + newNode.previousSibling = referenceNode.previousSibling; + newNode.nextSibling = referenceNode; + referenceNode.previousSibling = newNode; + } else { + newNode.previousSibling = this.lastChild; + this.lastChild = newNode; + } + + if (newNode.previousSibling) newNode.previousSibling.nextSibling = newNode; + else this.firstChild = newNode; + } + assignParentNode(newNode); + newNode['canRender'] = this.canRender; + if (newNode.connectedCallback) newNode.connectedCallback(); + return newNode; + } + + replaceChild(newChild: Node, oldChild: Node) { + //@ts-ignore + if (oldChild._parentNode !== this) throw new Error(`[UNDOM-NG] Failed to execute 'replaceChild' on 'Node': The node to be replaced is not a child of this node.`); + + const referenceNode = oldChild.nextSibling; + oldChild.remove(); + //@ts-ignore + //@ts-ignore + this.insertBefore(newChild, referenceNode); + return oldChild; + } + + removeChild(node: any) { + if (this.firstChild === node) this.firstChild = node.nextSibling; + if (this.lastChild === node) this.lastChild = node.previousSibling; + if (node.previousSibling) node.previousSibling.nextSibling = node.nextSibling; + if (node.nextSibling) node.nextSibling.previousSibling = node.previousSibling; + //@ts-ignore + node._parentNode = null; + //@ts-ignore + node.previousSibling = null; + //@ts-ignore + node.nextSibling = null; + + // if (node._rootNode && node.nodeName === 'slot') { + // /** + // * Removes the slot element from shadowRoot's slot map. + // * Web component must have unique slots names only. + // */ + // (this._rootNode as ShadowRoot)._slots.delete((node as HTMLSlotElement).name); + // } + + node._rootNode = null; + if (node.disconnectedCallback) node.disconnectedCallback(); + assignParentNode(node); + node.canRender = true; + return node; + } + + appendChild(node: Node) { + //@ts-ignore + return this.insertBefore(node); + } + + public getRootNode(options: { composed: boolean } = { composed: false }): Node { + if (!this._parentNode) { + return this; + } + if (this._rootNode && !options.composed) { + return this._rootNode; + } + return this.ownerDocument; + } + + /** + * Converts the node to a string. + * + * @param listener Listener. + */ + public toString(): string { + return `[object ${this.constructor.name}]`; + } + + replaceWith(...nodes: Node[]) { + if (!this._parentNode) return; + + const ref = this.nextSibling; + const parent = this._parentNode; + for (const i of nodes) { + i.remove(); + parent.insertBefore(i, ref); + } + } + + _getTheParent(event: Event) { + return this._parentNode; + } + /** + * A callback called when a node is attached to parent. + */ + public connectedCallback?(): void; + /** + * A callback called when a node is detacthed from parent. + */ + public disconnectedCallback?(): void; +} + +function assignParentNode(node: Node) { + const parentNode = node._parentNode as Element; + + if (!parentNode || node.nodeName === '#shadow-root') { + return (node.parentNode = null); + } + + if (parentNode._shadowRoot) { + return (node.parentNode = parentNode._shadowRoot); + } + + return (node.parentNode = node._parentNode); +} diff --git a/packages/core/ui/core/dom/src/nodes/node/NodeList.ts b/packages/core/ui/core/dom/src/nodes/node/NodeList.ts new file mode 100644 index 0000000000..d4f2bedc7c --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/node/NodeList.ts @@ -0,0 +1,24 @@ +import type Node from './Node'; + +/** + * Class list. + */ +export default class NodeList extends Array { + /** + * Returns `Symbol.toStringTag`. + * + * @returns `Symbol.toStringTag`. + */ + public get [Symbol.toStringTag](): string { + return this.constructor.name; + } + + /** + * Returns item by index. + * + * @param index Index. + */ + public item(index: number): T { + return index >= 0 && this[index] ? this[index] : null; + } +} diff --git a/packages/core/ui/core/dom/src/nodes/node/NodeTypeEnum.ts b/packages/core/ui/core/dom/src/nodes/node/NodeTypeEnum.ts new file mode 100644 index 0000000000..d684dc0d9d --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/node/NodeTypeEnum.ts @@ -0,0 +1,13 @@ +enum NodeTypeEnum { + elementNode = 1, + attributeNode = 2, + textNode = 3, + cdataSectionNode = 4, + commentNode = 8, + documentNode = 9, + documentTypeNode = 10, + documentFragmentNode = 11, + processingInstructionNode = 7, +} + +export default NodeTypeEnum; diff --git a/packages/core/ui/core/dom/src/nodes/parent-node/ParentNode.ts b/packages/core/ui/core/dom/src/nodes/parent-node/ParentNode.ts new file mode 100644 index 0000000000..cc7a9b5043 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/parent-node/ParentNode.ts @@ -0,0 +1,177 @@ +import type Element from '../element/Element'; +import Node from '../node/Node'; +import NodeTypeEnum from '../node/NodeTypeEnum'; +import { escape } from 'html-escaper'; +import NodeList from '../node/NodeList'; +import type { View } from '../../../../view'; +import QuerySelector from '../../query-selector/QuerySelector'; +/** + * Parent node + */ +export default class ParentNode extends Node { + isParentNode = true; + get childElementCount() { + let count = 0; + + let currentNode = this.firstElementChild; + while (currentNode) { + count += 1; + //@ts-ignore + currentNode = currentNode.nextElementSibling; + } + + return count; + } + + get firstElementChild(): Element { + const currentNode = this.firstChild; + //@ts-ignore + if (!currentNode) return null; + if (currentNode.nodeType === NodeTypeEnum.elementNode) return currentNode as Element; + return currentNode.nextElementSibling as Element; + } + + get lastElementChild(): Element { + const currentNode = this.lastChild; + //@ts-ignore + if (!currentNode) return null; + if (currentNode.nodeType === NodeTypeEnum.elementNode) return currentNode as Element; + return currentNode.previousElementSibling as Element; + } + + get children() { + const children: Element[] = []; + let currentNode = this.firstElementChild; + while (currentNode) { + children.push(currentNode); + //@ts-ignore + currentNode = currentNode.nextElementSibling; + } + + return children; + } + + append(...nodes: Node[]) { + for (const node of nodes) { + this.appendChild(node); + } + } + + prepend(...nodes: Node[]) { + const prependBeforeChild = this.firstElementChild; + for (const node of nodes) { + this.insertBefore(node, prependBeforeChild); + } + } + + replaceChildren(...nodes: Node[]) { + for (const node of this.childNodes) { + node.remove(); + } + + for (const node of nodes) { + this.appendChild(node); + } + } + + get textContent() { + const textArr: string[] = []; + + let currentNode = this.firstChild; + while (currentNode) { + if (currentNode.nodeType !== 8) { + const textContent = currentNode._textContent; + if (textContent) textArr.push(escape(textContent) as never); + } + currentNode = currentNode.nextSibling; + } + + return ''.concat(...textArr); + } + set textContent(val) { + this._textContent = val; + } + + getElementsByTagName(tagName: string) { + if (!this.firstChild) return []; + const elements = new NodeList(); + let current = this.firstChild as Element; + const tagNameUpper = tagName.toUpperCase(); + while (current) { + if (current.nodeType === NodeTypeEnum.elementNode || current.nodeType === NodeTypeEnum.textNode) { + if (current.tagName === tagNameUpper) elements.push(current); + + if (current.firstChild) { + const matches = current.getElementsByTagName(tagName); + if (matches.length) elements.push(...matches); + } + } + current = current.nextSibling as Element; + } + return elements; + } + + getElementsByClassName(className: string) { + if (!this.firstChild) return []; + const elements = new NodeList(); + let current = this.firstChild as unknown as View; + while (current) { + if (current.nodeType === NodeTypeEnum.elementNode || current.nodeType === NodeTypeEnum.textNode) { + if (current.cssClasses && current.cssClasses.has(className)) { + elements.push(current); + } else if (current.className.split(' ').includes(className)) { + elements.push(current); + } + + if (current.firstChild) { + const matches = current.getElementsByClassName(className); + if (matches.length) elements.push(...matches); + } + } + current = current.nextSibling as View; + } + return elements; + } + + public getElementById(id: string) { + let element: Element; + let current = this.firstChild as unknown as View; + while (current) { + if (current.nodeType === NodeTypeEnum.elementNode || current.nodeType === NodeTypeEnum.textNode) { + if (current.id === id) return current; + + if (current.firstChild) { + if ((element = current.getElementById(id) as any)) return element; + } + } + current = current.nextSibling as unknown as View; + } + return element; + } + + /** + * Query CSS selector to find matching nodes. + * + * @param selector CSS selector. + * @returns Matching elements. + */ + public querySelectorAll(selector: string): NodeList { + return QuerySelector.querySelectorAll(this, selector); + } + + /** + * Query CSS Selector to find matching node. + * + * @param selector CSS selector. + * @returns Matching element. + */ + public querySelector(selector: string): Element { + return QuerySelector.querySelector(this, selector); + } + /** + * A callback that fires for element's observed attributes. + * @param name + * @param oldValue + * @param newValue + */ +} diff --git a/packages/core/ui/core/dom/src/nodes/shadow-root/LightDOM.ts b/packages/core/ui/core/dom/src/nodes/shadow-root/LightDOM.ts new file mode 100644 index 0000000000..b70d4e924b --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/shadow-root/LightDOM.ts @@ -0,0 +1,135 @@ +interface LightNodeRef { + node: any; + previousSibling?: LightNodeRef; + nextSibling?: LightNodeRef; +} + +function createLightNodeRef(node: any, dom?: LightDOM, previousSibling?: LightNodeRef, nextSibling?: LightNodeRef) { + return { + node, + previousSibling, + nextSibling, + dom, + }; +} +/** + * This is the part of the Shadow DOM that is **not** rendered + * in the DOM tree but only used by Shadow DOM to reference + * and find nodes. + */ +export class LightDOM { + firstChild: LightNodeRef | undefined; + lastChild: LightNodeRef | undefined; + childrenCount: number = 0; + /** + * A unique key to track childern nodes. + */ + private ref: string; + public parent: any; + constructor(ref: string = '__lightRef', parent?: any) { + this.ref = ref; + this.parent = parent; + } + + appendChild(node: any) { + if (node[this.ref]) return; + const ref = createLightNodeRef(node, this); + if (!this.firstChild) { + this.firstChild = ref; + this.lastChild = ref; + node[this.ref] = ref; + this.childrenCount++; + return; + } + + ref.previousSibling = this.lastChild; + if (this.lastChild) this.lastChild.nextSibling = ref; + this.lastChild = ref; + node[this.ref] = ref; + this.childrenCount++; + } + + insertBefore(newNode: any, refNode: any) { + if (refNode && !refNode[this.ref]) return; + if (newNode === refNode) return; + if (!refNode) { + this.appendChild(newNode); + return; + } + + const referencedRef = refNode[this.ref] as LightNodeRef; + const ref = createLightNodeRef(newNode, this, referencedRef.previousSibling, referencedRef); + if (referencedRef.previousSibling) { + referencedRef.previousSibling.nextSibling = ref; + } else { + this.firstChild = ref; + } + referencedRef.previousSibling = ref; + + if (!referencedRef.nextSibling) { + this.lastChild = referencedRef; + } + + newNode[this.ref] = ref; + this.childrenCount++; + } + + replaceChild(newChild: any, oldChild: any) { + const oldRef = oldChild[this.ref] as LightNodeRef; + const ref = createLightNodeRef(newChild, this, oldRef.previousSibling, oldRef.nextSibling); + if (ref.nextSibling) { + ref.nextSibling.previousSibling = ref; + } else { + // if old child has no next siblings, it's the last child + this.lastChild = ref; + } + + if (ref.previousSibling) { + ref.previousSibling.nextSibling = ref; + } else { + // if old child has no previous siblings, it's the first child + this.firstChild = ref; + } + + oldChild[this.ref] = undefined; + } + + removeChild(node: any) { + if (!node[this.ref]) return; + const ref = node[this.ref] as LightNodeRef; + const previousSibling = ref.previousSibling; + const nextSibling = ref.nextSibling; + // One node, the dom becomes empty. + if (!previousSibling && !nextSibling) { + this.firstChild = undefined; + this.lastChild = undefined; + } + // Node is at start, nextSibling becomes the firstChild. + else if (!previousSibling && nextSibling) { + this.firstChild = nextSibling; + } + // Node is at end, previousSibling becomes lastChild + else if (previousSibling && !nextSibling) { + previousSibling.nextSibling = undefined; + this.lastChild = previousSibling; + } + // Node is in between some where, link prev & next siblings with each other. + else if (previousSibling && nextSibling) { + nextSibling.previousSibling = previousSibling; + previousSibling.nextSibling = nextSibling; + } + + node[this.ref] = undefined; + this.childrenCount--; + } + + get childNodes() { + const childNodes = []; + let currentChild: LightNodeRef | undefined = this.firstChild; + while (currentChild) { + childNodes.push(currentChild.node); + currentChild = currentChild.nextSibling; + } + return childNodes; + } +} diff --git a/packages/core/ui/core/dom/src/nodes/shadow-root/ShadowNode.ts b/packages/core/ui/core/dom/src/nodes/shadow-root/ShadowNode.ts new file mode 100644 index 0000000000..fd67ea939f --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/shadow-root/ShadowNode.ts @@ -0,0 +1,30 @@ +/** + * Indicates that this node is part of the shadow tree. + */ +export const SHADOW_NODE = '__shadowNode'; +/** + * Indicates that the slottable was added to slot + * at a later point after it was first attached to DOM, + * this happens when slottable exists and slot get's added + * later on. If slot is already present in DOM, + * and the slottable get's added to the tree, it becomes + * slotted automatically. + */ +export const BIND_SLOTTABLE = '__bindSlottable'; +/** + * Indicates that the node should not be removed from + * light dom when we remove it from the rendered DOM tree. + */ +export const RETAIN_MARKER = '__retainLightRef'; +/** + * The ref that holds information of a shadow node's light dom reference. + */ +export const LIGHT_DOM_REF = '__lightRef'; +/** + * Ref of slot's fallback children + */ +export const FALLBACK_REF = '__slotFallbackRef'; +/** + * Ref of rendered children from the slot. + */ +export const SLOTTED_CHILD_REF = '__slotAssignmentRef'; diff --git a/packages/core/ui/core/dom/src/nodes/shadow-root/ShadowRoot.ts b/packages/core/ui/core/dom/src/nodes/shadow-root/ShadowRoot.ts new file mode 100644 index 0000000000..83c5062f56 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/shadow-root/ShadowRoot.ts @@ -0,0 +1,207 @@ +import DocumentFragment from '../document-fragment/DocumentFragment'; +import type Element from '../element/Element'; +import type HTMLElement from '../html-element/HTMLElement'; +import type HTMLSlotElement from '../html-slot-element/HTMLSlotElement'; +import type Node from '../node/Node'; +import NodeList from '../node/NodeList'; +import NodeTypeEnum from '../node/NodeTypeEnum'; +import { BIND_SLOTTABLE, RETAIN_MARKER, SHADOW_NODE } from './ShadowNode'; +class Slots { + private _shadowRoot: ShadowRoot; + private _slots: Map = new Map(); + constructor(shadow: ShadowRoot) { + this._shadowRoot = shadow; + } + + get(name: string) { + return this._slots.get(name); + } + + has(name: string) { + return this._slots.has(name); + } + + set(name: string, slot: HTMLSlotElement) { + /** + * Once a slot is connected to DOM, we look up for + * any available slottables and assign them to this + * slot. If there's already a slot with the same name, + * this slot get's ignored. + */ + this._slots.set(name, slot); + const nodes = slot.assignedNodes(); + // If no assigned nodes are found, we render fallback childern. + if (!nodes.length && slot.parentNode) { + slot.renderFallback(); + return; + } + // Remove fallback content + let fallbackNodes = []; + if (slot.firstChild) { + fallbackNodes = slot.childNodes; + for (const node of fallbackNodes as Node[]) { + node.remove(); + } + } + for (const node of nodes) { + // Detach the node from light parent while keeping + // the ref in lightDOM. + this._shadowRoot._detachFromLightParent(node as Element); + (node as HTMLElement).assignedSlot = slot; + node[BIND_SLOTTABLE] = true; + slot.appendChild(node); + node[BIND_SLOTTABLE] = false; + } + + for (const node of fallbackNodes) { + // We add back fallback nodes to the tree, + // however they won't take part in + // rendering. + slot.appendChild(node); + } + } + + delete(name: string) { + const slot = this._slots.get(name); + if (!slot) return; + const deleted = this._slots.delete(name); + if (deleted) { + for (const child of slot.childNodes) { + (child as HTMLElement).assignedSlot = undefined; + child.remove(); + // When child gets removed, add it back + // to lightDOM's parent. + // const lightDOMRef = child['__lightRef']; + // if (lightDOMRef) { + // const lightParent = lightDOMRef?.dom?.parent; + // if (lightParent) { + // const nextSibling = lightDOMRef.nextSibling; + // lightDOMRef?.dom.removeChild(child); + // lightParent.insertBefore(child, nextSibling?.node); + // } + // } + } + } + return deleted; + } +} + +export class ShadowRoot extends DocumentFragment { + _mode: 'open' | 'closed' = 'open'; + _host: Node; + _slots: Slots; + isShadow: boolean = true; + constructor({ mode, ownerDocument, host }: { mode: 'open' | 'closed'; ownerDocument: any; host: Node }) { + super(); + this._mode = mode; + this.nodeName = '#shadow-root'; + this.localName = '#shadow-root'; + //@ts-ignore + this.ownerDocument = ownerDocument; + this._host = host; + this._slots = new Slots(this); + } + + _getTheParent(event: Event) { + if (event.composed) return this._host; + return null; + } + + get mode(): 'open' | 'closed' { + return this._mode; + } + + get host(): Node { + return this._host; + } + + appendChild(node: Node): Node { + // Childern added by shadow-root are real nodes and attached to host and rendered. + // Childern added directly to the host are light dom nodes and are not actually rendered. + // How to diff between the two cases? + return this.insertBefore(node, undefined); + } + + insertBefore(newNode: Node, referenceNode: Node): Node { + newNode[SHADOW_NODE] = true; + newNode._rootNode = this; + // If this element is a document fragment node, insert it's children + // into the host. The document fragment is discarded. + if (newNode.nodeType === NodeTypeEnum.documentFragmentNode) { + for (const child of newNode.childNodes) { + // remove from document fragment + child.remove(); + child[SHADOW_NODE] = true; + child._rootNode = this; + // Insert into the host before reference node. + this._host.insertBefore(child, referenceNode); + } + } else { + this._detachFromLightParent(newNode as Element); + this._host.insertBefore(newNode, referenceNode); + } + + return newNode; + } + + adoptStyles(node: Node) { + if (node['isNativeElement']) { + const adoptedStyles = this['adoptedStyleSheets']; + if (adoptedStyles?.length) { + for (const styles of adoptedStyles) { + const cssText = (styles.cssRules as unknown as Array).map((rule) => rule.cssText).join(''); + node['_updateStyleScope'](undefined, cssText); + } + } + } + } + + public _detachFromLightParent(node: Element) { + if (!node.canRender && node.parentNode) { + node[RETAIN_MARKER] = true; + node.remove(); + node[RETAIN_MARKER] = false; + } + node.canRender = true; + } + + replaceChild(newChild: Node, oldChild: Node): Node { + this._detachFromLightParent(newChild as Element); + + newChild[SHADOW_NODE] = true; + newChild._rootNode = this; + return this._host.replaceChild(newChild, oldChild); + } + + removeChild(node: any) { + node._rootNode = null; + this._host.removeChild(node); + } + + /** + * Query CSS selector to find matching nodes. + * + * @param selector CSS selector. + * @returns Matching elements. + */ + public querySelectorAll(selector: string): NodeList { + return ((this.host as Element).parentNode as Element).querySelectorAll(selector); + } + + /** + * Query CSS Selector to find matching node. + * + * @param selector CSS selector. + * @returns Matching element. + */ + public querySelector(selector: string): Element { + return ((this.host as Element).parentNode as Element).querySelector(selector); + } + /** + * A callback that fires for element's observed attributes. + * @param name + * @param oldValue + * @param newValue + */ +} +ShadowRoot.prototype['adoptedStyleSheets'] = []; diff --git a/packages/core/ui/core/dom/src/nodes/svg-element/SVGElement.ts b/packages/core/ui/core/dom/src/nodes/svg-element/SVGElement.ts new file mode 100644 index 0000000000..cd2641743e --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/svg-element/SVGElement.ts @@ -0,0 +1,9 @@ +import type Element from '../element/Element'; +import NodeTypeEnum from '../node/NodeTypeEnum'; + +export default class SVGElement { + // constructor(nodeType: NodeTypeEnum, localName: string) { + // super(nodeType, localName); + // this.namespaceURI = 'http://www.w3.org/2000/svg'; + // } +} diff --git a/packages/core/ui/core/dom/src/nodes/text/Text.ts b/packages/core/ui/core/dom/src/nodes/text/Text.ts new file mode 100644 index 0000000000..72568f71bb --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/text/Text.ts @@ -0,0 +1,43 @@ +import Node from '../node/Node'; +import CharacterData from '../character-data/CharacterData'; + +/** + * Text node. + */ +export default class Text extends CharacterData { + public isTextNode: boolean = true; + public readonly nodeType = Node.TEXT_NODE; + + /** + * Node name. + * + * @returns Node name. + */ + public nodeName = '#text'; + + constructor(text: string) { + super(); + this.data = text; + this.textContent = text; + } + + /** + * Converts to string. + * + * @returns String. + */ + public toString(): string { + return '[object Text]'; + } + + /** + * Clones a node. + * + * @override + * @param [deep=false] "true" to clone deep. + * @returns Cloned node. + */ + public cloneNode(deep = false): Text { + return super.cloneNode(deep); + } +} diff --git a/packages/core/ui/core/dom/src/query-selector/QuerySelector.ts b/packages/core/ui/core/dom/src/query-selector/QuerySelector.ts new file mode 100644 index 0000000000..8c39f6d766 --- /dev/null +++ b/packages/core/ui/core/dom/src/query-selector/QuerySelector.ts @@ -0,0 +1,254 @@ +import Element from '../nodes/element/Element'; +import Node from '../nodes/node/Node'; +import NodeList from '../nodes/node/NodeList'; +import SelectorItem from './SelectorItem'; + +const SELECTOR_PART_REGEXP = /(\[[^\]]+\]|[a-zA-Z0-9-_.#"*:()\]]+)|([ ,>]+)/g; + +/** + * https://github.com/capricorn86/happy-dom/blob/master/packages/happy-dom/src/query-selector/QuerySelector.ts + * + * Utility for query selection in an HTML element. + * + * @class QuerySelector + */ +export default class QuerySelector { + /** + * Finds elements based on a query selector. + * + * @param node Node to search in. + * @param selector Selector. + * @returns HTML elements. + */ + public static querySelectorAll(node: Node, selector: string): NodeList { + const matches = new NodeList(); + + for (const parts of this.getSelectorParts(selector)) { + for (const element of this.findAll(node, parts)) { + if (!matches.includes(element)) { + matches.push(element); + } + } + } + + return matches; + } + + /** + * Finds an element based on a query selector. + * + * @param node Node to search in. + * @param selector Selector. + * @returns HTML element. + */ + public static querySelector(node: Node, selector: string): Element { + for (const parts of this.getSelectorParts(selector)) { + const match = this.findFirst(node, parts); + + if (match) { + return match; + } + } + + return null; + } + + /** + * Checks if a node matches a selector and returns priority weight. + * + * @param node Node to search in. + * @param selector Selector. + * @returns Result. + */ + public static match(node: Node, selector: string): { priorityWeight: number; matches: boolean } { + for (const parts of this.getSelectorParts(selector)) { + const result = this.matchesSelector(node, node, parts.reverse()); + + if (result.matches) { + return result; + } + } + + return { priorityWeight: 0, matches: false }; + } + + /** + * Checks if a node matches a selector. + * + * @param targetNode Target node. + * @param currentNode Current node. + * @param selectorParts Selector parts. + * @param [priorityWeight] Priority weight. + * @returns Result. + */ + private static matchesSelector( + targetNode: Node, + currentNode: Node, + selectorParts: string[], + priorityWeight = 0 + ): { + priorityWeight: number; + matches: boolean; + } { + const isDirectChild = selectorParts[0] === '>'; + if (isDirectChild) { + selectorParts = selectorParts.slice(1); + if (selectorParts.length === 0) { + return { priorityWeight: 0, matches: false }; + } + } + + if (selectorParts.length === 0) { + return { priorityWeight, matches: true }; + } + + const selector = new SelectorItem(selectorParts[0]); + const result = selector.match(currentNode); + + if (result.matches && selectorParts.length === 1) { + return { + priorityWeight: priorityWeight + result.priorityWeight, + matches: true, + }; + } + + if (!currentNode.parentElement || (targetNode === currentNode && !result.matches)) { + return { priorityWeight: 0, matches: false }; + } + + return this.matchesSelector(isDirectChild ? currentNode.parentElement : targetNode, currentNode.parentElement, result.matches ? selectorParts.slice(1) : selectorParts, priorityWeight + result.priorityWeight); + } + + /** + * Finds elements based on a query selector for a part of a list of selectors separated with comma. + * + * @param rootNode Root node. + * @param nodes Nodes. + * @param selectorParts Selector parts. + * @param [selectorItem] Selector item. + * @returns HTML elements. + */ + private static findAll(rootNode: Node, selectorParts: string[], selectorItem?: SelectorItem): Element[] { + const isDirectChild = selectorParts[0] === '>'; + if (isDirectChild) { + selectorParts = selectorParts.slice(1); + } + const selector = selectorItem || new SelectorItem(selectorParts[0]); + let matched = []; + let node = rootNode.firstChild; + + //@ts-ignore + while (node) { + if (node.nodeType === Node.ELEMENT_NODE) { + if (selector.match(node).matches) { + if (selectorParts.length === 1) { + if (rootNode !== node) { + matched.push(node); + } + } else { + const childMatches = this.findAll(node, selectorParts.slice(1), null); + matched = matched.concat(childMatches); + } + } + } + + if (!isDirectChild && node['firstChild']) { + matched = matched.concat(this.findAll(node, selectorParts, selector)); + } + node = node.nextSibling; + } + + return matched; + } + + /** + * Finds an element based on a query selector for a part of a list of selectors separated with comma. + * + * @param rootNode + * @param nodes Nodes. + * @param selector Selector. + * @param selectorParts + * @param [selectorItem] Selector item. + * @returns HTML element. + */ + private static findFirst(rootNode: Node, selectorParts: string[], selectorItem?: SelectorItem): Element { + const isDirectChild = selectorParts[0] === '>'; + if (isDirectChild) { + selectorParts = selectorParts.slice(1); + } + const selector = selectorItem || new SelectorItem(selectorParts[0]); + let node = rootNode.firstChild; + + while (node) { + if (node.nodeType === Node.ELEMENT_NODE && selector.match(node).matches) { + if (selectorParts.length === 1) { + if (rootNode !== node) { + return node; + } + } else { + const childSelector = this.findFirst(node, selectorParts.slice(1), null); + if (childSelector) { + return childSelector; + } + } + } + + if (!isDirectChild && node['firstChild']) { + const childSelector = this.findFirst(node, selectorParts, selector); + + if (childSelector) { + return childSelector; + } + } + node = node.nextSibling; + } + + return null; + } + + /** + * Splits a selector string into groups and parts. + * + * @param selector Selector. + * @returns HTML element. + */ + private static getSelectorParts(selector: string): string[][] { + if (selector === '*' || (!selector.includes(',') && !selector.includes(' '))) { + return [[selector]]; + } + + const regexp = new RegExp(SELECTOR_PART_REGEXP); + const groups = []; + let currentSelector = ''; + let parts = []; + let match; + + while ((match = regexp.exec(selector))) { + if (match[2]) { + const trimmed = match[2].trim(); + + parts.push(currentSelector); + currentSelector = ''; + + if (trimmed === ',') { + groups.push(parts); + parts = []; + } else if (trimmed === '>') { + parts.push('>'); + } + } else if (match[1]) { + currentSelector += match[1]; + } + } + + if (currentSelector !== '') { + parts.push(currentSelector); + } + + if (parts.length > 0) { + groups.push(parts); + } + + return groups; + } +} diff --git a/packages/core/ui/core/dom/src/query-selector/SelectorItem.ts b/packages/core/ui/core/dom/src/query-selector/SelectorItem.ts new file mode 100644 index 0000000000..5d31feca7f --- /dev/null +++ b/packages/core/ui/core/dom/src/query-selector/SelectorItem.ts @@ -0,0 +1,361 @@ +/* eslint-disable no-case-declarations */ +import Element from '../nodes/element/Element'; +import { createAttributeFilter, findWhere } from '../utils'; + +const ATTRIBUTE_REGEXP = /\[([a-zA-Z0-9-_]+)\]|\[([a-zA-Z0-9-_]+)([~|^$*]{0,1})[ ]*=[ ]*["']{0,1}([^"']+)["']{0,1}\]/g; +const ATTRIBUTE_NAME_REGEXP = /[^a-zA-Z0-9-_$]/; +const PSUEDO_REGEXP = /(?element.parentNode).children : []; + + switch (psuedo.toLowerCase()) { + case 'nth-of-type': + children = children.filter((child) => child.tagName === element.tagName); + break; + case 'nth-last-child': + children = children.reverse(); + break; + case 'nth-last-of-type': + children = children.filter((child) => child.tagName === element.tagName).reverse(); + break; + } + + if (place === 'odd') { + const index = children.indexOf(element); + return index !== -1 && (index + 1) % 2 !== 0; + } else if (place === 'even') { + const index = children.indexOf(element); + return index !== -1 && (index + 1) % 2 === 0; + } else if (place.includes('n')) { + const [a, b] = place.replace(/ /g, '').split('n'); + const childIndex = children.indexOf(element); + const aNumber = a !== '' ? Number(a) : 1; + const bNumber = b !== undefined ? Number(b) : 0; + if (isNaN(aNumber) || isNaN(bNumber)) { + throw new Error(`[DOM] The selector "${this.selector}" is not valid.`); + } + + for (let i = 0, max = children.length; i <= max; i += aNumber) { + if (childIndex === i + bNumber - 1) { + return true; + } + } + + return false; + } + + const number = Number(place); + + if (isNaN(number)) { + throw new Error(`[DOM] The selector "${this.selector}" is not valid.`); + } + + return children[number - 1] === element; + } + + /** + * Matches a psuedo selector expression. + * + * @param element Element. + * @param psuedo Psuedo name. + * @returns True if it is a match. + */ + private matchesPsuedoExpression(element: Element, psuedo: string): boolean { + const parent = element.parentNode; + + if (!parent) { + return false; + } + + switch (psuedo.toLowerCase()) { + case 'first-child': + return parent.children[0] === element; + case 'last-child': + const lastChildChildren = parent.children; + return lastChildChildren[lastChildChildren.length - 1] === element; + case 'only-child': + const onlyChildChildren = parent.children; + return onlyChildChildren.length === 1 && onlyChildChildren[0] === element; + case 'first-of-type': + for (const child of parent.children) { + if (child.tagName === element.tagName) { + return child === element; + } + } + return false; + case 'last-of-type': + for (let i = parent.children.length - 1; i >= 0; i--) { + const child = parent.children[i]; + if (child.tagName === element.tagName) { + return child === element; + } + } + return false; + case 'only-of-type': + let isFound = false; + for (const child of parent.children) { + if (child.tagName === element.tagName) { + if (isFound || child !== element) { + return false; + } + isFound = true; + } + } + return isFound; + } + + return false; + } + + /** + * Matches attribute. + * + * @param element Element. + * @param selector Selector. + * @returns Result. + */ + private matchesAttribute(element: Element, selector: string): { priorityWeight: number; matches: boolean } { + const regexp = AttributeRegex; + let match: RegExpMatchArray; + let priorityWeight = 0; + + while ((match = regexp.exec(selector))) { + const isPsuedo = match.index > 0 && selector[match.index - 1] === '('; + + priorityWeight += 10; + + if (!isPsuedo && ((match[1] && !this.matchesAttributeName(element, match[1])) || (match[2] && !this.matchesAttributeNameAndValue(element, match[2], match[4], match[3])))) { + return { priorityWeight: 0, matches: false }; + } + } + + return { priorityWeight, matches: true }; + } + + /** + * Matches class. + * + * @param element Element. + * @param selector Selector. + * @returns Result. + */ + private matchesClass(element: Element, selector: string): { priorityWeight: number; matches: boolean } { + const regexp = ClassRegex; + const classList = element.className; + const classSelector = selector; + let priorityWeight = 0; + let match: RegExpMatchArray; + while ((match = regexp.exec(classSelector))) { + priorityWeight += 10; + if (!classList.includes(match[1])) { + return { priorityWeight: 0, matches: false }; + } + } + + return { priorityWeight, matches: true }; + } + + /** + * Matches attribute name only. + * + * @param element Element. + * @param attributeName Attribute name. + * @returns True if it is a match. + */ + private matchesAttributeName(element: Element, attributeName: string): boolean { + if (ATTRIBUTE_NAME_REGEXP.test(attributeName)) { + throw new Error(`[DOM] The selector "${this.selector}" is not valid.`); + } + + return !!findWhere(element.attributes, createAttributeFilter(null, attributeName), false, false); + } + + /** . + * + * Matches attribute name and value. + * + * @param element Element. + * @param attributeName Attribute name. + * @param attributeValue Attribute value. + * @param [matchType] Match type. + * @returns True if it is a match. + */ + /** + * + * @param element + * @param attributeName + * @param attributeValue + * @param matchType + */ + private matchesAttributeNameAndValue(element: Element, attributeName: string, attributeValue: string, matchType: string = null): boolean { + const attribute = findWhere(element.attributes, createAttributeFilter(null, attributeName), false, false); + const value = attributeValue; + + if (ATTRIBUTE_NAME_REGEXP.test(attributeName)) { + throw new Error(`[DOM] The selector "${this.selector}" is not valid.`); + } + + if (!attribute) { + return false; + } + + if (matchType) { + switch (matchType) { + // [attribute~="value"] - Contains a specified word. + case '~': + return attribute.value && attribute.value.split(' ').includes(value); + // [attribute|="value"] - Starts with the specified word. + case '|': + return attribute && attribute.value && new RegExp(`^${value}[- ]`).test(attribute.value); + // [attribute^="value"] - Begins with a specified value. + case '^': + return attribute && attribute.value && attribute.value.startsWith(value); + // [attribute$="value"] - Ends with a specified value. + case '$': + return attribute && attribute.value && attribute.value.endsWith(value); + // [attribute*="value"] - Contains a specified value. + case '*': + return attribute && attribute.value && attribute.value.includes(value); + } + } + + return attribute && attribute.value === value; + } +} diff --git a/packages/core/ui/core/dom/src/tree-walker/INodeFilter.ts b/packages/core/ui/core/dom/src/tree-walker/INodeFilter.ts new file mode 100644 index 0000000000..45ff8caab8 --- /dev/null +++ b/packages/core/ui/core/dom/src/tree-walker/INodeFilter.ts @@ -0,0 +1,5 @@ +import Node from '../nodes/node/Node'; + +export default interface INodeFilter { + acceptNode(node: Node): number; +} diff --git a/packages/core/ui/core/dom/src/tree-walker/NodeFilter.ts b/packages/core/ui/core/dom/src/tree-walker/NodeFilter.ts new file mode 100644 index 0000000000..f7199f1e1b --- /dev/null +++ b/packages/core/ui/core/dom/src/tree-walker/NodeFilter.ts @@ -0,0 +1,18 @@ +export default { + FILTER_ACCEPT: 1, + FILTER_REJECT: 2, + FILTER_SKIP: 3, + SHOW_ALL: -1, + SHOW_ELEMENT: 1, + SHOW_ATTRIBUTE: 2, + SHOW_TEXT: 4, + SHOW_CDATA_SECTION: 8, + SHOW_ENTITY_REFERENCE: 16, + SHOW_ENTITY: 32, + SHOW_PROCESSING_INSTRUCTION: 64, + SHOW_COMMENT: 128, + SHOW_DOCUMENT: 256, + SHOW_DOCUMENT_TYPE: 512, + SHOW_DOCUMENT_FRAGMENT: 1024, + SHOW_NOTATION: 2048, +}; diff --git a/packages/core/ui/core/dom/src/tree-walker/NodeFilterMask.ts b/packages/core/ui/core/dom/src/tree-walker/NodeFilterMask.ts new file mode 100644 index 0000000000..e3f868bb94 --- /dev/null +++ b/packages/core/ui/core/dom/src/tree-walker/NodeFilterMask.ts @@ -0,0 +1,28 @@ +import NodeFilter from './NodeFilter'; + +export default { + /* ELEMENT_NODE */ + 1: NodeFilter.SHOW_ELEMENT, + /* ATTRIBUTE_NODE */ + 2: NodeFilter.SHOW_ATTRIBUTE, + /* TEXT_NODE */ + 3: NodeFilter.SHOW_TEXT, + /* CDATA_SECTION_NODE */ + 4: NodeFilter.SHOW_CDATA_SECTION, + /* ENTITY_REFERENCE_NODE */ + 5: NodeFilter.SHOW_ENTITY_REFERENCE, + /* ENTITY_NODE */ + 6: NodeFilter.SHOW_PROCESSING_INSTRUCTION, + /* PROCESSING_INSTRUCTION_NODE */ + 7: NodeFilter.SHOW_PROCESSING_INSTRUCTION, + /* COMMENT_NODE */ + 8: NodeFilter.SHOW_COMMENT, + /* DOCUMENT_NODE */ + 9: NodeFilter.SHOW_DOCUMENT, + /* DOCUMENT_TYPE_NODE */ + 10: NodeFilter.SHOW_DOCUMENT_TYPE, + /* DOCUMENT_FRAGMENT_NODE */ + 11: NodeFilter.SHOW_DOCUMENT_FRAGMENT, + /* NOTATION_NODE */ + 12: NodeFilter.SHOW_NOTATION, +}; diff --git a/packages/core/ui/core/dom/src/tree-walker/TreeWalker.ts b/packages/core/ui/core/dom/src/tree-walker/TreeWalker.ts new file mode 100644 index 0000000000..5a95d6fa51 --- /dev/null +++ b/packages/core/ui/core/dom/src/tree-walker/TreeWalker.ts @@ -0,0 +1,366 @@ +// import Node from '../nodes/node/Node'; +// import INodeFilter from './INodeFilter'; +// import NodeFilter from './NodeFilter'; +// import NodeFilterMask from './NodeFilterMask'; + +// /** +// * The TreeWalker object represents the nodes of a document subtree and a position within them. +// */ +// export default class TreeWalker { +// public root: Node = null; +// public whatToShow = -1; +// public filter: INodeFilter = null; +// public currentNode: Node = null; + +// /** +// * Constructor. +// * +// * @param root Root. +// * @param [whatToShow] What to show. +// * @param [filter] Filter. +// */ +// constructor(root: Node, whatToShow = -1, filter: INodeFilter = null) { +// if (!(root instanceof Node)) { +// throw new Error('Parameter 1 was not of type Node.'); +// } + +// this.root = root; +// this.whatToShow = whatToShow; +// this.filter = filter; +// this.currentNode = root; +// } + +// /** +// * Moves the current Node to the next visible node in the document order. +// * +// * @returns Current node. +// */ +// public nextNode(): Node { +// if (!this.firstChild()) { +// while (!this.nextSibling() && this.parentNode()) {} +// this.currentNode = this.currentNode === this.root ? null : this.currentNode || null; +// } +// return this.currentNode; +// } + +// /** +// * Moves the current Node to the previous visible node in the document order, and returns the found node. It also moves the current node to this one. If no such node exists, or if it is before that the root node defined at the object construction, returns null and the current node is not changed. +// * +// * @returns Current node. +// */ +// public previousNode(): Node { +// while (!this.previousSibling() && this.parentNode()) {} +// this.currentNode = this.currentNode === this.root ? null : this.currentNode || null; +// return this.currentNode; +// } + +// /** +// * Moves the current Node to the first visible ancestor node in the document order, and returns the found node. It also moves the current node to this one. If no such node exists, or if it is before that the root node defined at the object construction, returns null and the current node is not changed. +// * +// * @returns Current node. +// */ +// public parentNode(): Node { +// if (this.currentNode !== this.root && this.currentNode && this.currentNode.parentNode) { +// this.currentNode = this.currentNode.parentNode; + +// if (this.filterNode(this.currentNode) === NodeFilter.FILTER_ACCEPT) { +// return this.currentNode; +// } + +// this.parentNode(); +// } + +// this.currentNode = null; + +// return null; +// } + +// /** +// * Moves the current Node to the first visible child of the current node, and returns the found child. It also moves the current node to this child. If no such child exists, returns null and the current node is not changed. +// * +// * @returns Current node. +// */ +// public firstChild(): Node { +// const childNodes = this.currentNode ? this.currentNode.childNodes : []; + +// if (childNodes.length > 0) { +// this.currentNode = childNodes[0]; + +// if (this.filterNode(this.currentNode) === NodeFilter.FILTER_ACCEPT) { +// return this.currentNode; +// } + +// return this.nextSibling(); +// } + +// return null; +// } + +// /** +// * Moves the current Node to the last visible child of the current node, and returns the found child. It also moves the current node to this child. If no such child exists, null is returned and the current node is not changed. +// * +// * @returns Current node. +// */ +// public lastChild(): Node { +// const childNodes = this.currentNode ? this.currentNode.childNodes : []; + +// if (childNodes.length > 0) { +// this.currentNode = childNodes[childNodes.length - 1]; + +// if (this.filterNode(this.currentNode) === NodeFilter.FILTER_ACCEPT) { +// return this.currentNode; +// } + +// return this.previousSibling(); +// } + +// return null; +// } + +// /** +// * Moves the current Node to its previous sibling, if any, and returns the found sibling. If there is no such node, return null and the current node is not changed. +// * +// * @returns Current node. +// */ +// public previousSibling(): Node { +// if (this.currentNode !== this.root && this.currentNode && this.currentNode.parentNode) { +// const siblings = this.currentNode.parentNode.childNodes; +// const index = siblings.indexOf(this.currentNode); + +// if (index > 0) { +// this.currentNode = siblings[index - 1]; + +// if (this.filterNode(this.currentNode) === NodeFilter.FILTER_ACCEPT) { +// return this.currentNode; +// } + +// return this.previousSibling(); +// } +// } + +// return null; +// } + +// /** +// * Moves the current Node to its next sibling, if any, and returns the found sibling. If there is no such node, null is returned and the current node is not changed. +// * +// * @returns Current node. +// */ +// public nextSibling(): Node { +// if (this.currentNode !== this.root && this.currentNode && this.currentNode.parentNode) { +// const siblings = this.currentNode.parentNode.childNodes; +// const index = siblings.indexOf(this.currentNode); + +// if (index + 1 < siblings.length) { +// this.currentNode = siblings[index + 1]; + +// if (this.filterNode(this.currentNode) === NodeFilter.FILTER_ACCEPT) { +// return this.currentNode; +// } + +// return this.nextSibling(); +// } +// } + +// return null; +// } + +// /** +// * Filters a node. +// * +// * Based on solution: +// * https://gist.github.com/shawndumas/1132009. +// * +// * @param node Node. +// * @returns Child nodes. +// */ +// private filterNode(node: Node): number { +// const mask = NodeFilterMask[node.nodeType]; + +// if (mask && (this.whatToShow & mask) == 0) { +// return NodeFilter.FILTER_SKIP; +// } else if (this.filter) { +// return this.filter.acceptNode(node); +// } + +// return NodeFilter.FILTER_ACCEPT; +// } +// } + +import Element from '../nodes/element/Element'; +import Node from '../nodes/node/Node'; +import INodeFilter from './INodeFilter'; +import NodeFilter from './NodeFilter'; +import NodeFilterMask from './NodeFilterMask'; + +/** + * The TreeWalker object represents the nodes of a document subtree and a position within them. + */ +export default class TreeWalker { + public root: Node = null; + public whatToShow = -1; + public filter: INodeFilter = null; + public currentNode: Node = null; + + /** + * Constructor. + * + * @param root Root. + * @param [whatToShow] What to show. + * @param [filter] Filter. + */ + constructor(root: Node, whatToShow = -1, filter: INodeFilter = null) { + if (!(root instanceof Node)) { + throw new Error('Parameter 1 was not of type Node.'); + } + + this.root = root; + this.whatToShow = whatToShow; + this.filter = filter; + this.currentNode = root; + } + + /** + * Moves the current Node to the next visible node in the document order. + * + * @returns Current node. + */ + public nextNode(): Node { + if (!this.firstChild()) { + while (!this.nextSibling() && this.parentNode()) {} + this.currentNode = this.currentNode === this.root ? null : this.currentNode || null; + } + return this.currentNode; + } + + /** + * Moves the current Node to the previous visible node in the document order, and returns the found node. It also moves the current node to this one. If no such node exists, or if it is before that the root node defined at the object construction, returns null and the current node is not changed. + * + * @returns Current node. + */ + public previousNode(): Node { + while (!this.previousSibling() && this.parentNode()) {} + this.currentNode = this.currentNode === this.root ? null : this.currentNode || null; + return this.currentNode; + } + + /** + * Moves the current Node to the first visible ancestor node in the document order, and returns the found node. It also moves the current node to this one. If no such node exists, or if it is before that the root node defined at the object construction, returns null and the current node is not changed. + * + * @returns Current node. + */ + public parentNode(): Node { + if (this.currentNode !== this.root && this.currentNode && this.currentNode.parentNode) { + this.currentNode = this.currentNode.parentNode; + + if (this.filterNode(this.currentNode) === NodeFilter.FILTER_ACCEPT) { + return this.currentNode; + } + + this.parentNode(); + } + + this.currentNode = null; + + return null; + } + + /** + * Moves the current Node to the first visible child of the current node, and returns the found child. It also moves the current node to this child. If no such child exists, returns null and the current node is not changed. + * + * @returns Current node. + */ + public firstChild(): Node { + if (this.currentNode.firstChild) { + this.currentNode = this.currentNode.firstChild; + if (this.filterNode(this.currentNode) === NodeFilter.FILTER_ACCEPT) { + return this.currentNode; + } + return this.nextSibling(); + } + + return null; + } + + /** + * Moves the current Node to the last visible child of the current node, and returns the found child. It also moves the current node to this child. If no such child exists, null is returned and the current node is not changed. + * + * @returns Current node. + */ + public lastChild(): Node { + if (this.currentNode.firstChild) { + this.currentNode = this.currentNode.lastChild; + + if (this.filterNode(this.currentNode) === NodeFilter.FILTER_ACCEPT) { + return this.currentNode; + } + + return this.previousSibling(); + } + + return null; + } + + /** + * Moves the current Node to its previous sibling, if any, and returns the found sibling. If there is no such node, return null and the current node is not changed. + * + * @returns Current node. + */ + public previousSibling(): Node { + if (this.currentNode !== this.root && this.currentNode && this.currentNode.parentNode) { + if (this.currentNode.previousSibling) { + this.currentNode = this.currentNode.previousSibling; + + if (this.filterNode(this.currentNode) === NodeFilter.FILTER_ACCEPT) { + return this.currentNode; + } + + return this.previousSibling(); + } + } + + return null; + } + + /** + * Moves the current Node to its next sibling, if any, and returns the found sibling. If there is no such node, null is returned and the current node is not changed. + * + * @returns Current node. + */ + public nextSibling(): Node { + if (this.currentNode !== this.root && this.currentNode && this.currentNode.parentNode) { + if (this.currentNode.nextSibling) { + this.currentNode = this.currentNode.nextSibling; + + if (this.filterNode(this.currentNode) === NodeFilter.FILTER_ACCEPT) { + return this.currentNode; + } + + return this.nextSibling(); + } + } + + return null; + } + + /** + * Filters a node. + * + * Based on solution: + * https://gist.github.com/shawndumas/1132009. + * + * @param node Node. + * @returns Child nodes. + */ + private filterNode(node: Node): number { + const mask = NodeFilterMask[node.nodeType]; + + if (mask && (this.whatToShow & mask) == 0) { + return NodeFilter.FILTER_SKIP; + } else if (this.filter) { + return this.filter.acceptNode(node); + } + + return NodeFilter.FILTER_ACCEPT; + } +} diff --git a/packages/core/ui/core/dom/src/utils/index.ts b/packages/core/ui/core/dom/src/utils/index.ts new file mode 100644 index 0000000000..ffb2e5b4b5 --- /dev/null +++ b/packages/core/ui/core/dom/src/utils/index.ts @@ -0,0 +1,74 @@ +import { ObservableArray } from '../../../../../data/observable-array'; +import type { IAttr } from '../nodes/element/Element'; + +export const toLower = (str: string) => String(str).toLowerCase(); + +// eslint-disable-next-line max-params +export const findWhere = (arr: any[], fn: (val: any) => any, returnIndex: boolean, byValue: any) => { + let i = arr.length; + while (i) { + i -= 1; + const val = arr[i]; + if (byValue) { + if (val === fn) return returnIndex ? i : val; + } else if (fn(val)) return returnIndex ? i : val; + } +}; + +// eslint-disable-next-line max-params +export const splice = (arr: any[], item: any, add: any, byValue: any) => { + const i = arr ? findWhere(arr, item, true, byValue) : -1; + if (i > -1) { + if (add) arr.splice(i, 0, add); + else arr.splice(i, 1); + } + return i; +}; + +export const createAttributeFilter = (namespaceURI: string, name: string) => (attribute: IAttr) => attribute.namespaceURI === namespaceURI && toLower(attribute.name) === toLower(name); + +const addToArrayProp = (node, key, item, ref) => { + if (!node[key]) node[key] = []; + + let currentArr = node[key]; + + let refIndex = currentArr.indexOf(ref); + if (refIndex < 0) refIndex = node[key].length; + + if (currentArr instanceof ObservableArray) { + currentArr.splice(refIndex, 0, item); + } else { + currentArr = currentArr.slice(); + currentArr.splice(refIndex, 0, item); + node[key] = currentArr; + } +}; + +const removeFromArrayProp = (node, key, item) => { + if (!node[key]) return; + + let currentArr = node[key]; + const itemIndex = currentArr.indexOf(item); + if (itemIndex < 0) return; + + if (currentArr instanceof ObservableArray) { + currentArr.splice(itemIndex, 1); + } else { + currentArr = currentArr.slice(); + currentArr.splice(itemIndex, 1); + node[key] = currentArr; + } +}; +/** + * Assign properties of target object on the source object. + */ +const reassignObjectProperties = (target: unknown, source: unknown) => { + for (const value of Object.keys(target)) delete target[value]; + Object.assign(target, source); +}; + +export const DOMUtils = { + addToArrayProp, + removeFromArrayProp, + reassignObjectProperties, +}; diff --git a/packages/core/ui/core/dom/src/window/Window.ts b/packages/core/ui/core/dom/src/window/Window.ts new file mode 100644 index 0000000000..5ea6a68de3 --- /dev/null +++ b/packages/core/ui/core/dom/src/window/Window.ts @@ -0,0 +1,152 @@ +import { Observable } from '../../../../../data/observable'; +import AbortSignal from '../event/AbortSignal'; +import { Event } from '../event/Event'; +import { CustomEvent } from '../event/CustomEvent'; +import CharacterData from '../nodes/character-data/CharacterData'; +import Comment from '../nodes/comment/Comment'; +import DocumentFragment from '../nodes/document-fragment/DocumentFragment'; +import Document from '../nodes/document/Document'; +import Element from '../nodes/element/Element'; +import HTMLElement from '../nodes/html-element/HTMLElement'; +import Node from '../nodes/node/Node'; +import NodeList from '../nodes/node/NodeList'; +import SVGElement from '../nodes/svg-element/SVGElement'; +import Text from '../nodes/text/Text'; +import XMLSerializer from '../xml-serializer'; +import { HTMLKeyPropElement, HTMLArrayPropElement } from '../nodes/html-prop-element/HTMLPropElement'; +import HTMLSlotElement from '../nodes/html-slot-element/HTMLSlotElement'; +import { HTMLItemTemplateElement } from '../nodes/html-item-template-element/HTMLItemTemplateElement'; +import HTMLTemplateElement from '../nodes/html-template-element/HTMLTemplateElement'; +import { ShadowRoot } from '../nodes/shadow-root/ShadowRoot'; +import DocumentType from '../nodes/document-type/DocumentType'; +import CustomElementRegistry from '../custom-element/CustomElementRegistry'; +import NodeFilter from '../tree-walker/NodeFilter'; +import TreeWalker from '../tree-walker/TreeWalker'; +import { CSSStyleSheet } from '../cssstylesheet/CSSStyleSheet'; + +declare global { + // eslint-disable-next-line no-var + var htmlElementRegistry: Map; +} + +/** + * Browser window. + * + * Reference: + * https://developer.mozilla.org/en-US/docs/Web/API/Window. + */ +export default class Window { + public readonly Node = Node; + public readonly HTMLElement = HTMLElement; + public readonly Text = Text; + public readonly Comment = Comment; + public readonly Element = Element; + public readonly DocumentFragment = DocumentFragment; + public readonly CharacterData = CharacterData; + public readonly Document = Document; + public readonly Event = Event; + public readonly EventTarget = Observable; + public readonly XMLSerializer = XMLSerializer; + public readonly NodeList = NodeList; + public readonly AbortSignal = AbortSignal; + public readonly SVGElement = SVGElement; + public readonly CustomEvent = CustomEvent; + public readonly HTMLKeyPropElement = HTMLKeyPropElement; + public readonly HTMLArrayPropElement = HTMLArrayPropElement; + public readonly HTMLSlotElement = HTMLSlotElement; + public readonly HTMLItemTemplateElement = HTMLItemTemplateElement; + public readonly HTMLTemplateElement = HTMLTemplateElement; + public readonly ShadowRoot = ShadowRoot; + public readonly DocumentType = DocumentType; + public readonly document: Document; + public readonly self: Window; + public readonly customElements: CustomElementRegistry; + public readonly CustomElementRegistry = CustomElementRegistry; + public readonly NodeFilter = NodeFilter; + public readonly TreeWalker = TreeWalker; + public readonly setTimeout = globalThis.setTimeout; + public readonly setInterval = globalThis.setInterval; + public readonly clearInterval = globalThis.clearInterval; + public readonly clearTimeout = globalThis.clearTimeout; + public readonly clearImmediate = globalThis.clearImmediate; + public readonly setImmediate = globalThis.setImmediate; + public readonly location = {}; + constructor() { + this.document = new Document(); + this.document.defaultView = this; + globalThis.htmlElementRegistry = new Map(); + globalThis.registerElement = this.registerElement; + this.self = this; + this.customElements = new CustomElementRegistry(); + } + + registerElement(name: string, element: typeof HTMLElement) { + if (!htmlElementRegistry.has(name)) { + element.prototype['cssType'] = name.replace(/-/g, ''); + globalThis.htmlElementRegistry.set(name, element); + } + } + + bindToGlobal() { + //@ts-ignore + globalThis.window = this; + //@ts-ignore + globalThis.document = this.document; + //@ts-ignore + globalThis.self = this; + //@ts-ignore + globalThis.Node = Node; + //@ts-ignore + globalThis.HTMLElement = HTMLElement; + //@ts-ignore + globalThis.Text = Text; + //@ts-ignore + globalThis.Comment = Comment; + //@ts-ignore + globalThis.Element = Element; + //@ts-ignore + globalThis.DocumentFragment = DocumentFragment; + //@ts-ignore + globalThis.CharacterData = CharacterData; + //@ts-ignore + globalThis.Document = Document; + //@ts-ignore + globalThis.Event = Event; + //@ts-ignore + globalThis.EventTarget = Observable; + //@ts-ignore + globalThis.XMLSerializer = XMLSerializer; + //@ts-ignore + globalThis.SVGElement = SVGElement; + //@ts-ignore + globalThis.NodeList = NodeList; + //@ts-ignore + globalThis.AbortSignal = AbortSignal; + //@ts-ignore + globalThis.CustomEvent = CustomEvent; + globalThis.HTMLKeyPropElement = HTMLKeyPropElement; + globalThis.HTMLArrayPropElement = HTMLArrayPropElement; + //@ts-ignore + globalThis.HTMLSlotElement = HTMLSlotElement; + //@ts-ignore + globalThis.HTMLTemplateElement = HTMLTemplateElement; + //@ts-ignore + globalThis.ShadowRoot = ShadowRoot; + //@ts-ignore + globalThis.DocumentType = DocumentType; + //@ts-ignore + globalThis.CustomElementRegistry = CustomElementRegistry; + //@ts-ignore + globalThis.customElements = this.customElements; + globalThis.NodeFilter = NodeFilter; + //@ts-ignore + globalThis.TreeWalker = TreeWalker; + //@ts-ignore + globalThis.CSSStyleSheet = CSSStyleSheet; + return this; + } + + public getComputedStyle(element: Element) { + return element['style']; + } +} diff --git a/packages/core/ui/core/dom/src/xml-parser/XMLParser.ts b/packages/core/ui/core/dom/src/xml-parser/XMLParser.ts new file mode 100755 index 0000000000..6652be245b --- /dev/null +++ b/packages/core/ui/core/dom/src/xml-parser/XMLParser.ts @@ -0,0 +1,241 @@ +import Document from '../nodes/document/Document'; +import VoidElements from '../config/VoidElements'; +import UnnestableElements from '../config/UnnestableElements'; +import ChildLessElements from '../config/ChildLessElements'; +import { decode } from 'html-entities'; +import NamespaceURI from '../config/NamespaceURI'; +//import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement'; +import Node from '../nodes/node/Node'; +import Element from '../nodes/element/Element'; +//import HTMLLinkElement from '../nodes/html-link-element/HTMLLinkElement'; +import DocumentFragment from '../nodes/document-fragment/DocumentFragment'; + +const MARKUP_REGEXP = /<(\/?)([a-z][-.0-9_a-z]*)\s*([^<>]*?)(\/?)>/gi; +const COMMENT_REGEXP = /|<([!?])([^>]*)>/gi; +const DOCUMENT_TYPE_ATTRIBUTE_REGEXP = /"([^"]+)"/gm; +const ATTRIBUTE_REGEXP = /([^\s=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))/gms; + +/** + * XML parser. + * https://github.com/capricorn86/happy-dom/blob/master/packages/happy-dom/src/xml-parser/XMLParser.ts + */ +export default class XMLParser { + /** + * Parses XML/HTML and returns a root element. + * + * @param document Document. + * @param data HTML data. + * @param [evaluateScripts = false] Set to "true" to enable script execution. + * @returns Root element. + */ + public static parse(document: Document, data: string, evaluateScripts = false): DocumentFragment { + const root = document.createDocumentFragment(); + const stack: Array = [root]; + const markupRegexp = new RegExp(MARKUP_REGEXP, 'gi'); + let parent: DocumentFragment | Element = root; + let parentUnnestableTagName = null; + let lastTextIndex = 0; + let match: RegExpExecArray; + + if (data !== null && data !== undefined) { + data = String(data); + while ((match = markupRegexp.exec(data))) { + const tagName = match[2].toLowerCase(); + const isStartTag = !match[1]; + + if (parent && match.index !== lastTextIndex) { + const text = data.substring(lastTextIndex, match.index); + this.appendTextAndCommentNodes(document, parent, text); + } + + if (isStartTag) { + const namespaceURI = tagName === 'svg' ? NamespaceURI.svg : (parent).namespaceURI || NamespaceURI.html; + const newElement = document.createElementNS(namespaceURI, tagName); + // Scripts are not allowed to be executed when they are parsed using innerHTML, outerHTML, replaceWith() etc. + // However, they are allowed to be executed when document.write() is used. + // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLScriptElement + if (tagName === 'script') { + continue; + //(newElement)._evaluateScript = evaluateScripts; + } + + // An assumption that the same rule should be applied for the HTMLLinkElement is made here. + if (tagName === 'link') { + continue; + //(newElement)._evaluateCSS = evaluateScripts; + } + + this.setAttributes(newElement, match[3]); + + if (!match[4] && !VoidElements.includes(tagName)) { + // Some elements are not allowed to be nested (e.g. "" is not allowed.). + // Therefore we will auto-close the tag. + if (parentUnnestableTagName === tagName) { + stack.pop(); + parent = parent.parentNode || root; + } + + parent = parent.appendChild(newElement); + parentUnnestableTagName = this.getUnnestableTagName(parent); + stack.push(parent); + } else { + parent.appendChild(newElement); + } + lastTextIndex = markupRegexp.lastIndex; + + // Tags which contain non-parsed content + // For example: