From b215934269378ba6354876f4b951a5ae92c158b5 Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Sun, 26 Feb 2023 15:11:34 +0500 Subject: [PATCH 01/24] main --- apps/toolbox/src/main.ts | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/toolbox/src/main.ts b/apps/toolbox/src/main.ts index fba0a27ac5..93ef935fce 100644 --- a/apps/toolbox/src/main.ts +++ b/apps/toolbox/src/main.ts @@ -1,3 +1,38 @@ -import { Application, Utils } from '@nativescript/core'; +import { Application, Label, View, FlexboxLayout } from '@nativescript/core'; -Application.run({ moduleName: 'app-root' }); +const bench = (fn: Function, times = 10) => { + console.time(`bench: ${fn.name}`); + for (let i = 0; i < times; i++) { + fn(); + } + console.timeEnd(`bench: ${fn.name}`); +}; + +// //@ts-ignore +// registerElement('view', FlexboxLayout); + +// declare global { +// interface HTMLElementTagNameMap { +// view: View; +// } +// } + +Application.run({ + create: () => { + // bench(function warmup() { + // new FlexboxLayout(); + // }, 1000); + const element = new FlexboxLayout(); + bench(() => { + bench(function addEvent() { + element.addEventListener('loaded', (data) => { + console.log(data); + }); + }, 1); + }, 100); + + const view = new FlexboxLayout(); + + return view; + }, +}); From 193eaec47e7dffe92f3ca1e6968ef53ff77a74f9 Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Sun, 26 Feb 2023 15:25:00 +0500 Subject: [PATCH 02/24] sda --- apps/toolbox/src/main.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/toolbox/src/main.ts b/apps/toolbox/src/main.ts index 93ef935fce..f260ba6883 100644 --- a/apps/toolbox/src/main.ts +++ b/apps/toolbox/src/main.ts @@ -26,8 +26,14 @@ Application.run({ bench(() => { bench(function addEvent() { element.addEventListener('loaded', (data) => { - console.log(data); + const d = data; }); + }, 100); + }, 100); + + bench(() => { + bench(function fireEvent() { + element._emit('loaded'); }, 1); }, 100); From 411cfa19c4cc9ddab534b4bf267122d835fd1b64 Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Sun, 5 Mar 2023 19:39:17 +0500 Subject: [PATCH 03/24] test --- .gitignore | 2 +- NativeScript.code-workspace | 13 +++++ apps/automated/package.json | 1 + apps/toolbox/package.json | 9 ++-- apps/toolbox/src/main.ts | 95 ++++++++++++++++++++++++++----------- apps/toolbox/tsconfig.json | 3 +- 6 files changed, 90 insertions(+), 33 deletions(-) create mode 100644 NativeScript.code-workspace 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/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 f260ba6883..1284b0ab5a 100644 --- a/apps/toolbox/src/main.ts +++ b/apps/toolbox/src/main.ts @@ -1,44 +1,83 @@ -import { Application, Label, View, FlexboxLayout } from '@nativescript/core'; +import { Application, FlexboxLayout, GridLayout } from '@nativescript/core'; +import Benchmark from 'benchmark'; +const suite = new Benchmark.Suite(); -const bench = (fn: Function, times = 10) => { - console.time(`bench: ${fn.name}`); - for (let i = 0; i < times; i++) { - fn(); - } - console.timeEnd(`bench: ${fn.name}`); +//@ts-ignore +global.performance = { + now() { + if (global.android) { + return java.lang.System.nanoTime() / 1000000; + } else { + return CACurrentMediaTime(); + } + }, }; -// //@ts-ignore -// registerElement('view', FlexboxLayout); +//@ts-ignore +//registerElement('view-element', FlexboxLayout); // declare global { // interface HTMLElementTagNameMap { -// view: View; +// 'view-element': FlexboxLayout; // } // } Application.run({ create: () => { - // bench(function warmup() { - // new FlexboxLayout(); - // }, 1000); - const element = new FlexboxLayout(); - bench(() => { - bench(function addEvent() { - element.addEventListener('loaded', (data) => { - const d = data; - }); - }, 100); - }, 100); + const root = new GridLayout(); + const view = new FlexboxLayout(); + //const element = document.createElement('view-element'); + root.addChild(view); + //root.addChild(element); - bench(() => { - bench(function fireEvent() { - element._emit('loaded'); - }, 1); - }, 100); + for (let i = 0; i < 20; i++) { + //element.on('loaded', () => {}); + view.on('loaded', () => {}); + } - const view = new FlexboxLayout(); + suite + .add('new FlexboxLayout()', () => { + 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 view; + 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 } } From d8b47f06e5e3f186e3fd707089ab63c51887f121 Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Tue, 7 Mar 2023 23:15:53 +0500 Subject: [PATCH 04/24] domless --- apps/toolbox/src/main.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/toolbox/src/main.ts b/apps/toolbox/src/main.ts index 1284b0ab5a..56415ce276 100644 --- a/apps/toolbox/src/main.ts +++ b/apps/toolbox/src/main.ts @@ -34,10 +34,11 @@ Application.run({ //element.on('loaded', () => {}); view.on('loaded', () => {}); } - + // Cache views to prevent GC from kicking in. + const views = []; suite .add('new FlexboxLayout()', () => { - new FlexboxLayout(); + views.push(new FlexboxLayout()); }) // .add('document.createElement', () => { // document.createElement('view-element'); From c52ea55a164acfbac6cbff1e5887cec02fb4b734 Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Mon, 27 Feb 2023 23:43:26 +0500 Subject: [PATCH 05/24] dom-in-core --- apps/automated/src/main-page.ts | 7 +- .../ui/action-bar/action-bar-tests-common.ts | 4 +- .../core/accessibility/font-scale.android.ts | 1 - packages/core/data/observable/index.ts | 490 ++++++++++++------ packages/core/global-types.d.ts | 2 + packages/core/index.ts | 1 + packages/core/ui/core/dom/index.ts | 5 + .../core/ui/core/dom/src/event/AbortSignal.ts | 44 ++ .../core/ui/core/dom/src/event/CustomEvent.ts | 13 + packages/core/ui/core/dom/src/event/Event.ts | 123 +++++ .../src/nodes/character-data/CharacterData.ts | 38 ++ .../ui/core/dom/src/nodes/comment/Comment.ts | 42 ++ .../document-fragment/DocumentFragment.ts | 10 + .../core/dom/src/nodes/document/Document.ts | 68 +++ .../ui/core/dom/src/nodes/element/Element.ts | 121 +++++ .../dom/src/nodes/html-element/HTMLElement.ts | 8 + .../html-label-element/HTMLLabelELement.ts | 57 ++ .../core/ui/core/dom/src/nodes/node/Node.ts | 262 ++++++++++ .../ui/core/dom/src/nodes/node/NodeList.ts | 24 + .../core/dom/src/nodes/node/NodeTypeEnum.ts | 13 + .../dom/src/nodes/parent-node/ParentNode.ts | 83 +++ .../dom/src/nodes/svg-element/SVGElement.ts | 9 + .../core/ui/core/dom/src/nodes/text/Text.ts | 42 ++ packages/core/ui/core/dom/src/utils/index.ts | 65 +++ .../core/ui/core/dom/src/window/Window.ts | 94 ++++ .../ui/core/dom/src/xml-serializer/index.ts | 89 ++++ packages/core/ui/core/view-base/index.ts | 12 +- .../core/ui/layouts/layout-base-common.ts | 18 +- .../core/ui/text-base/formatted-string.ts | 16 + packages/core/ui/text-base/span.ts | 17 + .../core/ui/text-base/text-base-common.ts | 19 + 31 files changed, 1617 insertions(+), 180 deletions(-) create mode 100644 packages/core/ui/core/dom/index.ts create mode 100644 packages/core/ui/core/dom/src/event/AbortSignal.ts create mode 100644 packages/core/ui/core/dom/src/event/CustomEvent.ts create mode 100644 packages/core/ui/core/dom/src/event/Event.ts create mode 100644 packages/core/ui/core/dom/src/nodes/character-data/CharacterData.ts create mode 100644 packages/core/ui/core/dom/src/nodes/comment/Comment.ts create mode 100644 packages/core/ui/core/dom/src/nodes/document-fragment/DocumentFragment.ts create mode 100644 packages/core/ui/core/dom/src/nodes/document/Document.ts create mode 100644 packages/core/ui/core/dom/src/nodes/element/Element.ts create mode 100644 packages/core/ui/core/dom/src/nodes/html-element/HTMLElement.ts create mode 100644 packages/core/ui/core/dom/src/nodes/html-label-element/HTMLLabelELement.ts create mode 100644 packages/core/ui/core/dom/src/nodes/node/Node.ts create mode 100644 packages/core/ui/core/dom/src/nodes/node/NodeList.ts create mode 100644 packages/core/ui/core/dom/src/nodes/node/NodeTypeEnum.ts create mode 100644 packages/core/ui/core/dom/src/nodes/parent-node/ParentNode.ts create mode 100644 packages/core/ui/core/dom/src/nodes/svg-element/SVGElement.ts create mode 100644 packages/core/ui/core/dom/src/nodes/text/Text.ts create mode 100644 packages/core/ui/core/dom/src/utils/index.ts create mode 100644 packages/core/ui/core/dom/src/window/Window.ts create mode 100644 packages/core/ui/core/dom/src/xml-serializer/index.ts 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/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 5fd348d91f..da2246ddb4 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,146 @@ 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: EventListenerOrEventListenerObject; +} + +function isThisArg(value: any) { + if (!value) 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 handleEvent = typeof listener === 'function' ? listener : listener.handleEvent; + /** + * the most common case is handled first. No options. No capturing. No thisArg. + */ + if (!options || typeof options === 'boolean') { + return { target, capture: !!options, type, listener: handleEvent }; + } + + // The second most common case, last argument is thisArg. + if (isThisArg(options)) { + return { + type, + target, + thisArg: options, + listener: handleEvent, + }; + } + // Finally the last and "new" case of event options. + const { capture, once, passive, signal, thisArg } = options; + return { + target, + capture, + type, + once, + passive, + signal, + listener: handleEvent, + thisArg: thisArg, + }; +}; + +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 PhaseKey = '_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?: PhaseKey) { + const eventPhaseKey = phase || BUBBLE_PHASE_KEY; + const list = getEventList.call(this, type, eventPhaseKey, false) as EventDescriptior[]; + if (!list) return; + + if (!listener) { + delete this[eventPhaseKey][type]; + return; + } + + const index = indexOfListener(list, listener, isThisArg(options) ? options : undefined); + const descriptor = list[index]; + if (!descriptor) return; + 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: PhaseKey, 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 automatically. +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 +237,8 @@ export class Observable { */ public _isViewBase: boolean; - private readonly _observers: { [eventName: string]: ListenerEntry[] } = {}; + private readonly _observers: { [eventName: string]: EventDescriptior[] } = {}; + private readonly _captureObservers: { [eventName: string]: EventDescriptior[] } = {}; public get(name: string): any { return this[name]; @@ -157,16 +289,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, + }); } /** @@ -176,135 +302,165 @@ 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 (callback && typeof callback !== 'function') { - throw new TypeError('callback must be function.'); + 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 (!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; + //@ts-ignore + (event as NativeDOMEvent).currentTarget = 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 any).parentNode; + 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.parentNode; } } - } - public static on(eventName: string, callback: (data: EventData) => void, thisArg?: any): void { - this.addEventListener(eventName, callback, thisArg); - } + // Capturing starts from the highest ancestor 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.slice(0), 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.slice(0), 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 @@ -314,23 +470,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] = []; } - _globalEventHandlers[eventClass][eventName].push({ callback, thisArg }); + 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][type].push(descriptor); } private _globalNotify(eventClass: string, eventType: string, data: T): void { @@ -339,7 +514,8 @@ 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, { data: data as unknown }); + Observable._handleEvent(events.slice(0), event); } } @@ -348,7 +524,8 @@ export class Observable { const event = data.eventName + eventType; const events = _globalEventHandlers['*'][event]; if (events) { - Observable._handleEvent(events, data); + const event = new NativeDOMEvent(data.eventName, { data: data as unknown }); + Observable._handleEvent(events.slice(0), event); } } } @@ -369,39 +546,41 @@ export class Observable { const eventClass = this.constructor.name; this._globalNotify(eventClass, 'First', dataWithObject); - const observers = this._observers[data.eventName]; - if (observers) { - Observable._handleEvent(observers, dataWithObject); + const listeners = this._observers[data.eventName]; + if (listeners) { + const event = new NativeDOMEvent(data.eventName, { data: dataWithObject as unknown }); + // 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. + Observable._handleEvent(listeners.slice(0), event); } 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); - } - - let returnValue: any; - if (entry.thisArg) { - returnValue = entry.callback.apply(entry.thisArg, [data]); - } else { - returnValue = entry.callback(data); - } - - // This ensures errors thrown inside asynchronous functions do not get swallowed - if (returnValue && returnValue instanceof Promise) { - returnValue.catch((err) => { - console.error(err); - }); - } + private static _handleEvent(listeners: Array, event: T): void { + if (!listeners || !listeners.length) return; + for (let i = 0, l = listeners.length; i < l; i++) { + const descriptor = listeners[i]; + const { listener, removed, thisArg } = descriptor; + if (removed) continue; + event.passive = !event.cancelable || descriptor.passive; + event._currentTarget = (thisArg as any) || (event as NativeDOMEvent).object || (descriptor.target as globalThis.EventTarget); + let returnValue; + if (thisArg) { + returnValue = (listener as EventListener).apply(thisArg, [event]); + } else { + returnValue = (listener as EventListener)(event); } + // 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; } } @@ -417,7 +596,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; } /** @@ -441,40 +620,9 @@ 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; - } - - return list; - } - - 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; - } - } - } - - return -1; - } } -export interface Observable { +export interface Observable extends globalThis.EventTarget { /** * Raised when a propertyChange occurs. */ diff --git a/packages/core/global-types.d.ts b/packages/core/global-types.d.ts index 43bb67539c..c6ef0af355 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.ts b/packages/core/index.ts index 3f8f78582b..e3bce03d9a 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -7,6 +7,7 @@ export type { ApplicationEventData, LaunchEventData, OrientationChangedEventData 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'; import { inBackground, suspended } from './application/application-common'; +export { Window } from './ui/core/dom/index'; export const Application = { launchEvent, diff --git a/packages/core/ui/core/dom/index.ts b/packages/core/ui/core/dom/index.ts new file mode 100644 index 0000000000..c810f309d2 --- /dev/null +++ b/packages/core/ui/core/dom/index.ts @@ -0,0 +1,5 @@ +export { HTMLLabelElement } from './src/nodes/html-label-element/HTMLLabelELement'; +import GlobalWindow from './src/window/Window'; + +export const Window = GlobalWindow; +new Window().bindToGlobal(); 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..79a91080fe --- /dev/null +++ b/packages/core/ui/core/dom/src/event/Event.ts @@ -0,0 +1,123 @@ +interface EventInitOptions extends EventInit { + captures?: boolean; + 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.data) { + for (const key in options.data) { + if (/(eventName|object)/g.test(key)) { + this[key] = options.data[key]; + continue; + } + Object.defineProperty(this, key, { + get: () => { + return options.data[key]; + }, + set: (v) => { + options.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..8ac71a1097 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/character-data/CharacterData.ts @@ -0,0 +1,38 @@ +import Node from '../node/Node'; + +/** + * 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.data; + } + + set data(data) { + this.data = data; + } + get length() { + return this.data.length; + } + + get nodeValue() { + return this.data; + } + set nodeValue(data) { + this.data = data; + } + + get textContent() { + return this.data; + } + set textContent(text) { + this.data = `${text}`; + } + + appendData(data: string) { + this.data += data; + } +} 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..5ca7bd777b --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/comment/Comment.ts @@ -0,0 +1,42 @@ +import Node from '../node/Node'; +import CharacterData from '../character-data/CharacterData'; + +/** + * 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..13e9b40f04 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/document-fragment/DocumentFragment.ts @@ -0,0 +1,10 @@ +import Node from '../node/Node'; +import ParentNode from '../parent-node/ParentNode'; + +/** + * DocumentFragment. + */ +export default class DocumentFragment extends ParentNode { + public nodeType = Node.DOCUMENT_FRAGMENT_NODE; + public nodeName: string = '#document-fragment'; +} 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..2db5fc2383 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/document/Document.ts @@ -0,0 +1,68 @@ +import DocumentFragment from '../document-fragment/DocumentFragment'; +import NodeTypeEnum from '../node/NodeTypeEnum'; +import ParentNode from '../parent-node/ParentNode'; +import HTMLElement from '../html-element/HTMLElement'; +import Comment from '../comment/Comment'; +import Text from '../text/Text'; +import { Event } from '../../event/Event'; + +const createElement = (type: string, owner: Document) => { + //@ts-ignore + if (htmlElementRegistry && htmlElementRegistry[type]) { + //@ts-ignore + const element = new htmlElementRegistry[type](); + element.ownerDocument = owner; + //@ts-ignore + element.tagName = htmlElementRegistry[type].NODE_TAG_NAME; + return element; + } + return new HTMLElement(NodeTypeEnum.elementNode, type); +}; + +/** + * Document. + */ +export default class Document extends ParentNode { + _defaultView = undefined; + nodeType: NodeTypeEnum = NodeTypeEnum.documentNode; + /* eslint-disable class-methods-use-this */ + constructor() { + super(); + this.nodeName = '#document'; + this.localName = '#document'; + } + + 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; + } +} 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..af812e2d54 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/element/Element.ts @@ -0,0 +1,121 @@ +import { createAttributeFilter, findWhere, splice } from '../../utils'; +import NodeTypeEnum from '../node/NodeTypeEnum'; +import ParentNode from '../parent-node/ParentNode'; + +// eslint-disable-next-line max-params +const updateAttributeNS = (self: Element, ns: string, name: string, value: any) => { + let attr = findWhere(self.attributes, createAttributeFilter(ns, name), false, false); + if (!attr) self.attributes.push((attr = { ns, name } as never)); + attr.value = value; +}; + +/** + * Element. + */ +export default class Element extends ParentNode { + attributes: { ns: string; name: string; value?: any }[] = []; + constructor(nodeType: NodeTypeEnum, localName: string) { + super(); + this.nodeType = nodeType; + this.localName = localName; + this.nodeName = localName; + this.attributes = []; + } + + get tagName() { + return this.nodeName; + } + + set tagName(name: string) { + this.nodeName = name; + this.localName = name; + } + + get namespaceURI() { + return 'http://www.w3.org/1999/xhtml'; + } + + set namespaceURI(namespace: string) { + this.namespaceURI = namespace; + } + + get className() { + return this.getAttribute('class'); + } + set className(val) { + this.setAttribute('class', val); + } + + // Actually innerHTML and outerHTML is out of DOM's spec + // But we just put it here for some frameworks to work + // Or warn people not trying to treat undom like a browser + get innerHTML() { + const serializedChildren: string[] = []; + let currentNode = this.firstChild; + while (currentNode) { + serializedChildren.push(new XMLSerializer().serializeToString(currentNode as never)); + currentNode = currentNode.nextSibling; + } + return ''.concat(...serializedChildren); + } + + set innerHTML(value) { + // Setting innerHTML with an empty string just clears the element's children + if (value === '') { + let currentNode = this.firstChild; + while (currentNode) { + const nextSibling = currentNode.nextSibling; + currentNode.remove(); + currentNode = nextSibling; + } + return; + } + + throw new Error(`[UNDOM-NG] Failed to set 'innerHTML' on '${this.localName}': Not implemented.`); + } + + 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(`[UNDOM-NG] 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) { + splice(this.attributes, createAttributeFilter(namespace, name), false, false); + } +} 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..9156d04889 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/html-element/HTMLElement.ts @@ -0,0 +1,8 @@ +import Element from '../element/Element'; +import NodeTypeEnum from '../node/NodeTypeEnum'; + +export default class HTMLElement extends Element { + constructor(nodeType: NodeTypeEnum, type: string) { + super(nodeType, type); + } +} 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..c2b02bb153 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/html-label-element/HTMLLabelELement.ts @@ -0,0 +1,57 @@ +import { Label } from '../../../../../label'; +import 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/node/Node.ts b/packages/core/ui/core/dom/src/nodes/node/Node.ts new file mode 100644 index 0000000000..0b8135009b --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/node/Node.ts @@ -0,0 +1,262 @@ +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 = ''; + //@ts-ignore + _nodeValue: string = null; + //@ts-ignore + _textContent: string = null; + readonly ownerDocument: any; + //@ts-ignore + readonly baseURI: string = null; + //@ts-ignore + parentElement: Element = null; + //@ts-ignore + _parentNode: Node = null; + //@ts-ignore + nextSibling: Node = null; + //@ts-ignore + previousSibling: Node = null; + //@ts-ignore + firstChild: Node = null; + //@ts-ignore + lastChild: Node = null; + isParentNode: boolean = false; + //@ts-ignore + localName: string = null; + constructor() { + super(); + } + + get parentNode() { + return this._parentNode; + } + + 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 { ns, name, value } of sourceAttrs) { + (clonedNode as Element).setAttributeNS(ns, 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; + } + + 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; + return node; + } + + appendChild(node: Node) { + //@ts-ignore + return this.insertBefore(node); + } + + /** + * 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); + } + } +} 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..1b1a074730 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/node/NodeList.ts @@ -0,0 +1,24 @@ +import 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): Node { + 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..7fae1eb014 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/parent-node/ParentNode.ts @@ -0,0 +1,83 @@ +import type Element from '../element/Element'; +import Node from '../node/Node'; +import NodeTypeEnum from '../node/NodeTypeEnum'; + +/** + * Parent node + */ +export default class ParentNode extends Node { + 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 i of nodes) { + this.appendChild(i); + } + } + + replaceChildren(...nodes: Node[]) { + for (const old of nodes) { + old.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(textContent as never); + } + currentNode = currentNode.nextSibling; + } + + return ''.concat(...textArr); + } + set textContent(val) { + this._textContent = val; + } +} 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..5fdf92be65 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/svg-element/SVGElement.ts @@ -0,0 +1,9 @@ +import Element from '../element/Element'; +import NodeTypeEnum from '../node/NodeTypeEnum'; + +export default class SVGElement extends Element { + 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..13d99539d0 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/text/Text.ts @@ -0,0 +1,42 @@ +import Node from '../node/Node'; +import CharacterData from '../character-data/CharacterData'; + +/** + * Text node. + */ +export default class Text extends CharacterData { + 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/utils/index.ts b/packages/core/ui/core/dom/src/utils/index.ts new file mode 100644 index 0000000000..54bc06ea7a --- /dev/null +++ b/packages/core/ui/core/dom/src/utils/index.ts @@ -0,0 +1,65 @@ +import { ObservableArray } from '../../../../../data/observable-array'; + +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 = (ns, name) => (o) => o.ns === ns && toLower(o.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; + } +}; + +export const DOMUtils = { + addToArrayProp, + removeFromArrayProp, +}; 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..b692666c65 --- /dev/null +++ b/packages/core/ui/core/dom/src/window/Window.ts @@ -0,0 +1,94 @@ +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'; +/** + * 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 document: Document; + constructor() { + this.bindToGlobal(); + //@ts-ignore + globalThis.htmlElementRegistry = {}; + //@ts-ignore + globalThis.registerElement = this.registerElement; + this.document = new Document(); + this.document.defaultView = this; + //@ts-ignore + globalThis.window = this; + //@ts-ignore + globalThis.document = this.document; + } + + registerElement(name: string, element: HTMLElement) { + //@ts-ignore + element.NODE_TAG_NAME = name; + //@ts-ignore + globalThis.htmlElementRegistry[name] = element; + } + + bindToGlobal() { + //@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; + + return this; + } +} diff --git a/packages/core/ui/core/dom/src/xml-serializer/index.ts b/packages/core/ui/core/dom/src/xml-serializer/index.ts new file mode 100644 index 0000000000..05f5f0eed6 --- /dev/null +++ b/packages/core/ui/core/dom/src/xml-serializer/index.ts @@ -0,0 +1,89 @@ +import NodeTypeEnum from '../nodes/node/NodeTypeEnum'; +const selfClosingTags = { + area: true, + base: true, + br: true, + col: true, + command: true, + embed: true, + hr: true, + img: true, + input: true, + keygen: true, + link: true, + menuitem: true, + meta: true, + param: true, + source: true, + track: true, + wbr: true, +}; + +// const serializerRegexp = /[&'"<>\u00a0-\u00b6\u00b8-\u00ff\u0152\u0153\u0160\u0161\u0178\u0192\u02c6\u02dc\u0391-\u03a1\u03a3-\u03a9\u03b1-\u03c9\u03d1\u03d2\u03d6\u2002\u2003\u2009\u200c-\u200f\u2013\u2014\u2018-\u201a\u201c-\u201e\u2020-\u2022\u2026\u2030\u2032\u2033\u2039\u203a\u203e\u20ac\u2122\u2190-\u2194\u21b5\u2200\u2202\u2203\u2205\u2207-\u2209\u220b\u220f\u2211\u2212\u2217\u221a\u221d\u221e\u2220\u2227-\u222b\u2234\u223c\u2245\u2248\u2260\u2261\u2264\u2265\u2282-\u2284\u2286\u2287\u2295\u2297\u22a5\u22c5\u2308-\u230b\u25ca\u2660\u2663\u2665\u2666]/g +const serializerRegexp = /[&'"<>]/g; + +const enc = (s: string) => `${s}`.replace(serializerRegexp, (a) => `&#${a.codePointAt(0)};`); + +const attr = (a: { value: any; ns: string; name: string }) => { + if (a.value) { + if (a.ns) { + return ` ${a.ns}:${a.name}="${enc(a.value)}"`; + } + return ` ${a.name}="${enc(a.value)}"`; + } + return ` ${a.name}`; +}; + +const serialize = (el: Node, useRawName?: boolean) => { + switch (el.nodeType) { + case NodeTypeEnum.textNode: { + if ((el as Text).data) { + if (el.parentNode && ['SCRIPT', 'STYLE'].indexOf(el.parentNode.nodeName) > -1) return (el as Text).data; + return enc((el as Text).data); + } + return ''; + } + + case NodeTypeEnum.commentNode: { + if ((el as Comment).data) return ``; + return ''; + } + + default: { + if (!el.nodeName) return ''; + + const { nodeName, localName, attributes, firstChild } = el as HTMLElement; + const xmlStringFrags = []; + + let tag = nodeName; + + if (useRawName) tag = localName; + else tag = nodeName.toLowerCase(); + + if (tag && tag[0] === '#') tag = tag.substring(1); + + if (tag) xmlStringFrags.push(`<${tag}`); + if (attributes) xmlStringFrags.push(...(attributes as unknown as any[]).map(attr)); + if (firstChild) { + if (tag) xmlStringFrags.push('>'); + + let currentNode = firstChild; + while (currentNode) { + xmlStringFrags.push(serialize(currentNode, useRawName)); + currentNode = currentNode.nextSibling; + } + + if (tag) xmlStringFrags.push(``); + } else if (tag) { + if (selfClosingTags[tag]) xmlStringFrags.push('/>'); + else xmlStringFrags.push(`>`); + } + + return ''.concat(...xmlStringFrags); + } + } +}; + +export default { + serializeToString: serialize, +}; diff --git a/packages/core/ui/core/view-base/index.ts b/packages/core/ui/core/view-base/index.ts index 1abf942cd4..42ac29fcb2 100644 --- a/packages/core/ui/core/view-base/index.ts +++ b/packages/core/ui/core/view-base/index.ts @@ -9,7 +9,7 @@ import { CSSUtils } from '../../../css/system-classes'; import { Source } from '../../../utils/debug'; import { Binding, BindingOptions } from '../bindable'; import { Trace } from '../../../trace'; -import { Observable, PropertyChangeData, WrappedValue } from '../../../data/observable'; +import { PropertyChangeData, WrappedValue } from '../../../data/observable'; import { Style } from '../../styling/style'; import { paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty } from '../../styling/style-properties'; @@ -21,6 +21,8 @@ import { profile } from '../../../profiling'; import * as dnm from '../../../debugger/dom-node'; import * as ssm from '../../styling/style-scope'; import { ViewBase as ViewBaseDefinition } from '.'; +import HTMLElement from '../dom/src/nodes/html-element/HTMLElement'; +import NodeTypeEnum from '../dom/src/nodes/node/NodeTypeEnum'; let domNodeModule: typeof dnm; @@ -244,7 +246,7 @@ namespace SuspendType { } } -export abstract class ViewBase extends Observable implements ViewBaseDefinition { +export abstract class ViewBase extends HTMLElement implements ViewBaseDefinition { public static loadedEvent = 'loaded'; public static unloadedEvent = 'unloaded'; public static createdEvent = 'created'; @@ -271,7 +273,6 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition public isCollapsed; // Default(false) set in prototype public id: string; - public className: string; public _domId: number; public _context: any; @@ -338,7 +339,9 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition public reusable: boolean; constructor() { - super(); + super(NodeTypeEnum.elementNode, 'ViewBase'); + this.nodeName = this.constructor.name; + this.localName = this.constructor.name; this._domId = viewIdCounter++; this._style = new Style(new WeakRef(this)); this.notify({ eventName: ViewBase.createdEvent, type: this.constructor.name, object: this }); @@ -368,6 +371,7 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition get style(): Style { return this._style; } + set style(inlineStyle: Style /* | string */) { if (typeof inlineStyle === 'string') { this.setInlineStyle(inlineStyle); diff --git a/packages/core/ui/layouts/layout-base-common.ts b/packages/core/ui/layouts/layout-base-common.ts index 95e10c70ff..ebb7ec66c1 100644 --- a/packages/core/ui/layouts/layout-base-common.ts +++ b/packages/core/ui/layouts/layout-base-common.ts @@ -42,6 +42,21 @@ export class LayoutBaseCommon extends CustomLayoutView implements LayoutBaseDefi //Overridden } + public insertBefore(newNode: View, referenceNode: View): View { + super.insertBefore(newNode, referenceNode); + if (referenceNode) { + this.insertChild(newNode, this.getChildIndex(referenceNode)); + } else { + this.addChild(newNode); + } + return newNode; + } + + public appendChild(node: View): View { + //this.addChild(node); + return this.insertBefore(node, undefined); + } + public addChild(child: View): void { // TODO: Do we need this method since we have the core logic in the View implementation? this._subViews.push(child); @@ -55,13 +70,14 @@ export class LayoutBaseCommon extends CustomLayoutView implements LayoutBaseDefi this._registerLayoutChild(child); } + //@ts-ignore public removeChild(child: View): void { this._removeView(child); - // TODO: consider caching the index on the child. const index = this._subViews.indexOf(child); this._subViews.splice(index, 1); this._unregisterLayoutChild(child); + return super.removeChild(child); } public removeChildren(): void { diff --git a/packages/core/ui/text-base/formatted-string.ts b/packages/core/ui/text-base/formatted-string.ts index fdb243b475..ee90e7dec3 100644 --- a/packages/core/ui/text-base/formatted-string.ts +++ b/packages/core/ui/text-base/formatted-string.ts @@ -7,6 +7,7 @@ import { ViewBase } from '../core/view-base'; import { Color } from '../../color'; import { FontStyleType, FontWeightType } from '../styling/font'; import { CoreTypes } from '../../core-types'; +import { DOMUtils } from '../core/dom/src/utils'; export class FormattedString extends ViewBase implements FormattedStringDefinition, AddArrayFromBuilder, AddChildFromBuilder { private _spans: ObservableArray; @@ -17,6 +18,21 @@ export class FormattedString extends ViewBase implements FormattedStringDefiniti this._spans.addEventListener(ObservableArray.changeEvent, this.onSpansCollectionChanged, this); } + appendChild(node: Span): Span { + return this.insertBefore(node, undefined); + } + + insertBefore(newNode: Span, referenceNode: Span): Span { + if (!(newNode instanceof Span)) return null; + DOMUtils.addToArrayProp(this, 'spans', newNode, referenceNode); + super.insertBefore(newNode, referenceNode); + } + + removeChild(node: any) { + DOMUtils.removeFromArrayProp(this, 'spans', node); + super.removeChild(node); + } + get fontFamily(): string { return this.style.fontFamily; } diff --git a/packages/core/ui/text-base/span.ts b/packages/core/ui/text-base/span.ts index da3130b256..4c427efd3e 100644 --- a/packages/core/ui/text-base/span.ts +++ b/packages/core/ui/text-base/span.ts @@ -5,12 +5,29 @@ import { FontStyleType, FontWeightType } from '../styling/font'; import { CoreTypes } from '../../core-types'; import { EventData } from '../../data/observable'; import { isNullOrUndefined, isString } from '../../utils/types'; +import NodeTypeEnum from '../core/dom/src/nodes/node/NodeTypeEnum'; +import { View } from '../core/view'; export class Span extends ViewBase implements SpanDefinition { static linkTapEvent = 'linkTap'; private _text: string; private _tappable = false; + constructor() { + super(); + this.nodeType = NodeTypeEnum.textNode; + } + + appendChild(node: View): View { + return this.insertBefore(node, undefined); + } + + insertBefore(newNode: View, referenceNode: View): View { + super.insertBefore(newNode, referenceNode); + //if (newNode.nodeType === NodeTypeEnum.textNode) this.text = this._textContent; + return newNode; + } + get fontFamily(): string { return this.style.fontFamily; } diff --git a/packages/core/ui/text-base/text-base-common.ts b/packages/core/ui/text-base/text-base-common.ts index bb2d4d6490..696466eeff 100644 --- a/packages/core/ui/text-base/text-base-common.ts +++ b/packages/core/ui/text-base/text-base-common.ts @@ -14,6 +14,7 @@ import { CoreTypes } from '../../core-types'; import { TextBase as TextBaseDefinition } from '.'; import { Color } from '../../color'; import { CSSShadow, parseCSSShadow } from '../styling/css-shadow'; +import NodeTypeEnum from '../core/dom/src/nodes/node/NodeTypeEnum'; const CHILD_SPAN = 'Span'; const CHILD_FORMATTED_TEXT = 'formattedText'; @@ -24,6 +25,24 @@ export abstract class TextBaseCommon extends View implements TextBaseDefinition public text: string; public formattedText: FormattedString; + appendChild(node: View): View { + return this.insertBefore(node, undefined); + } + + insertBefore(newNode: View, referenceNode: View): View { + super.insertBefore(newNode, referenceNode); + if (newNode instanceof FormattedString) return (this.formattedText = newNode); + if (newNode.nodeType === NodeTypeEnum.textNode) this.text = this.textContent; + } + + get textContent(): string { + return this.text; + } + + set textContent(val: string) { + this.text = val; + } + /*** * In the NativeScript Core; by default the nativeTextViewProtected points to the same value as nativeViewProtected. * At this point no internal NS components need this indirection functionality. From adc5af3d0abe539654af39f7c432e66ab7dd3518 Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Mon, 27 Feb 2023 23:49:49 +0500 Subject: [PATCH 06/24] rename key --- packages/core/data/observable/index.ts | 6 +++--- packages/core/ui/core/dom/src/event/Event.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/core/data/observable/index.ts b/packages/core/data/observable/index.ts index da2246ddb4..48e5c3dd62 100644 --- a/packages/core/data/observable/index.ts +++ b/packages/core/data/observable/index.ts @@ -514,7 +514,7 @@ export class Observable extends EventTarget implements globalThis.EventTarget { const event = data.eventName + eventType; const events = _globalEventHandlers[eventClass][event]; if (events) { - const event = new NativeDOMEvent(data.eventName, { data: data as unknown }); + const event = new NativeDOMEvent(data.eventName, { __event_data: data as unknown }); Observable._handleEvent(events.slice(0), event); } } @@ -524,7 +524,7 @@ export class Observable extends EventTarget implements globalThis.EventTarget { const event = data.eventName + eventType; const events = _globalEventHandlers['*'][event]; if (events) { - const event = new NativeDOMEvent(data.eventName, { data: data as unknown }); + const event = new NativeDOMEvent(data.eventName, { __event_data: data as unknown }); Observable._handleEvent(events.slice(0), event); } } @@ -548,7 +548,7 @@ export class Observable extends EventTarget implements globalThis.EventTarget { const listeners = this._observers[data.eventName]; if (listeners) { - const event = new NativeDOMEvent(data.eventName, { data: dataWithObject as unknown }); + const event = new NativeDOMEvent(data.eventName, { __event_data: dataWithObject as unknown }); // 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. diff --git a/packages/core/ui/core/dom/src/event/Event.ts b/packages/core/ui/core/dom/src/event/Event.ts index 79a91080fe..2e83e6a40f 100644 --- a/packages/core/ui/core/dom/src/event/Event.ts +++ b/packages/core/ui/core/dom/src/event/Event.ts @@ -1,6 +1,6 @@ interface EventInitOptions extends EventInit { captures?: boolean; - data?: any; + __event_data?: any; } export const EVENT_OPTIONS_DEFAULT: EventInit & { captures?: boolean } = { @@ -52,18 +52,18 @@ export class Event { constructor(type: string, options: EventInitOptions) { if (!options) options = EVENT_OPTIONS_DEFAULT; this.initEvent(type, options.bubbles, options.cancelable, options.captures); - if (options.data) { - for (const key in options.data) { + if (options.__event_data) { + for (const key in options.__event_data) { if (/(eventName|object)/g.test(key)) { - this[key] = options.data[key]; + this[key] = options.__event_data[key]; continue; } Object.defineProperty(this, key, { get: () => { - return options.data[key]; + return options.__event_data[key]; }, set: (v) => { - options.data[key] = v; + options.__event_data[key] = v; }, configurable: true, }); From d4da15700e7b48ffa2b93c35245f7e0c978d861a Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Tue, 28 Feb 2023 07:57:15 +0500 Subject: [PATCH 07/24] add licenses --- packages/core/ui/core/dom/LICENSE-dominative | 21 +++++++++++++++++++ packages/core/ui/core/dom/LICENSE-happy-dom | 21 +++++++++++++++++++ packages/core/ui/core/dom/LICENSE-undom-ng | 22 ++++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 packages/core/ui/core/dom/LICENSE-dominative create mode 100644 packages/core/ui/core/dom/LICENSE-happy-dom create mode 100644 packages/core/ui/core/dom/LICENSE-undom-ng 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. From 6e5ba43c0c6bfd2526f9e06b2effb311c66d0c5c Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Tue, 28 Feb 2023 07:57:38 +0500 Subject: [PATCH 08/24] add comments --- packages/core/data/observable/index.ts | 43 +++++++++++++++----- packages/core/ui/core/dom/src/event/Event.ts | 19 ++++++--- 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/packages/core/data/observable/index.ts b/packages/core/data/observable/index.ts index 48e5c3dd62..e0b241ea6d 100644 --- a/packages/core/data/observable/index.ts +++ b/packages/core/data/observable/index.ts @@ -161,7 +161,7 @@ function indexOfListener(list: EventDescriptior[], listener: EventListenerOrEven const CAPTURE_PHASE_KEY = '_captureObservers'; const BUBBLE_PHASE_KEY = '_observers'; -type PhaseKey = '_captureObservers' | '_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; @@ -187,19 +187,34 @@ function addListener(this: any, type: string, listener: EventListenerOrEventList getEventList.call(this, type, eventPhaseKey, true).push(descriptor); } -function removeListener(this: any, type: string, listener: EventListenerOrEventListenerObject, options: any, phase?: PhaseKey) { +function removeListener(this: any, type: string, listener: EventListenerOrEventListenerObject, options: any, phase?: EventPhaseKey) { const eventPhaseKey = phase || BUBBLE_PHASE_KEY; - const list = getEventList.call(this, type, eventPhaseKey, false) as EventDescriptior[]; - if (!list) return; - + /** + * 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) { @@ -208,7 +223,7 @@ function removeListener(this: any, type: string, listener: EventListenerOrEventL if (!list.length) delete this[eventPhaseKey][type]; } -function getEventList(eventName: string, phaseKey: PhaseKey, createIfNeeded?: boolean): Array { +function getEventList(eventName: string, phaseKey: EventPhaseKey, createIfNeeded?: boolean): Array { if (!eventName) { throw new TypeError('EventName must be valid string.'); } @@ -218,7 +233,8 @@ function getEventList(eventName: string, phaseKey: PhaseKey, createIfNeeded?: bo return list; } -// An empty class to make instanceOf EventTarget checks pass automatically. +// 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. @@ -236,8 +252,13 @@ export class Observable extends EventTarget implements globalThis.EventTarget { * @private */ public _isViewBase: boolean; - + /** + * Observers for the default/bubbling phase. + */ private readonly _observers: { [eventName: string]: EventDescriptior[] } = {}; + /** + * Observers for the capture phase. + */ private readonly _captureObservers: { [eventName: string]: EventDescriptior[] } = {}; public get(name: string): any { @@ -385,16 +406,16 @@ export class Observable extends EventTarget implements globalThis.EventTarget { if (bubbles || captures) { // eslint-disable-next-line consistent-this, @typescript-eslint/no-this-alias // Start from the first parent. - let currentNode = (this as any).parentNode; + 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.parentNode; + currentNode = currentNode._parentNode; } } - // Capturing starts from the highest ancestor goes down + // Capturing starts from the highest ancestor and goes down for (const listeners of capturePhase) { (event as NativeDOMEvent).eventPhase = EventPhases.CAPTURING_PHASE; diff --git a/packages/core/ui/core/dom/src/event/Event.ts b/packages/core/ui/core/dom/src/event/Event.ts index 2e83e6a40f..ce6a4b0ecd 100644 --- a/packages/core/ui/core/dom/src/event/Event.ts +++ b/packages/core/ui/core/dom/src/event/Event.ts @@ -58,13 +58,20 @@ export class Event { 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: () => { - return options.__event_data[key]; - }, - set: (v) => { - options.__event_data[key] = v; - }, + get: () => options.__event_data[key], + set: (v) => (options.__event_data[key] = v), configurable: true, }); } From 595b6de6a8a12b2176dd0df83e2723927bf6e652 Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Sun, 5 Mar 2023 19:10:22 +0500 Subject: [PATCH 09/24] fix dispatchEvent call failing --- packages/core/data/observable/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/data/observable/index.ts b/packages/core/data/observable/index.ts index e0b241ea6d..b7738d45f4 100644 --- a/packages/core/data/observable/index.ts +++ b/packages/core/data/observable/index.ts @@ -393,8 +393,6 @@ export class Observable extends EventTarget implements globalThis.EventTarget { */ (event as NativeDOMEvent)._target = this; - //@ts-ignore - (event as NativeDOMEvent).currentTarget = this; const capturePhase: Array[] = []; From 8781d956cba6dfc8742ea7f499911b512ecf8bca Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Tue, 7 Mar 2023 23:17:26 +0500 Subject: [PATCH 10/24] optimize notify calls in domless mode --- packages/core/data/observable/index.ts | 41 +++++++++++-------- packages/core/ui/core/dom/src/event/Event.ts | 2 +- .../core/dom/src/nodes/document/Document.ts | 1 + 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/core/data/observable/index.ts b/packages/core/data/observable/index.ts index b7738d45f4..7b1d27cbb2 100644 --- a/packages/core/data/observable/index.ts +++ b/packages/core/data/observable/index.ts @@ -252,6 +252,9 @@ export class Observable extends EventTarget implements globalThis.EventTarget { * @private */ public _isViewBase: boolean; + + public _isRegisteredDOMElement: boolean; + /** * Observers for the default/bubbling phase. */ @@ -417,7 +420,7 @@ export class Observable extends EventTarget implements globalThis.EventTarget { for (const listeners of capturePhase) { (event as NativeDOMEvent).eventPhase = EventPhases.CAPTURING_PHASE; - Observable._handleEvent(listeners.slice(0), event as NativeDOMEvent); + Observable._handleEvent(listeners, event as NativeDOMEvent); if (!event.bubbles || (event as NativeDOMEvent)._propagationStopped) return !event.cancelable || !event.defaultPrevented; } @@ -438,7 +441,7 @@ export class Observable extends EventTarget implements globalThis.EventTarget { for (const listeners of bubblePhase) { (event as NativeDOMEvent).eventPhase = EventPhases.BUBBLING_PHASE; - Observable._handleEvent(listeners.slice(0), event as NativeDOMEvent); + Observable._handleEvent(listeners, event as NativeDOMEvent); if (!event.bubbles || (event as NativeDOMEvent)._propagationStopped) return !event.cancelable || !event.defaultPrevented; } // Reset the event phase. @@ -538,7 +541,7 @@ export class Observable extends EventTarget implements globalThis.EventTarget { } } - // 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]; @@ -561,35 +564,39 @@ export class Observable extends EventTarget implements globalThis.EventTarget { 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 listeners = this._observers[data.eventName]; - if (listeners) { - const event = new NativeDOMEvent(data.eventName, { __event_data: dataWithObject as unknown }); - // 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. - Observable._handleEvent(listeners.slice(0), event); + 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(listeners: Array, event: T): void { + 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 descriptor = _listeners[i]; const { listener, removed, thisArg } = descriptor; if (removed) continue; - event.passive = !event.cancelable || descriptor.passive; - event._currentTarget = (thisArg as any) || (event as NativeDOMEvent).object || (descriptor.target as globalThis.EventTarget); + + 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; if (thisArg) { returnValue = (listener as EventListener).apply(thisArg, [event]); } else { - returnValue = (listener as EventListener)(event); + returnValue = (listener as EventListener)(event as NativeDOMEvent); } // This ensures errors thrown inside asynchronous functions do not get swallowed if (returnValue && returnValue instanceof Promise) { diff --git a/packages/core/ui/core/dom/src/event/Event.ts b/packages/core/ui/core/dom/src/event/Event.ts index ce6a4b0ecd..449c9d636c 100644 --- a/packages/core/ui/core/dom/src/event/Event.ts +++ b/packages/core/ui/core/dom/src/event/Event.ts @@ -49,7 +49,7 @@ export class Event { public srcElement: EventTarget; public timeStamp: number; - constructor(type: string, options: EventInitOptions) { + constructor(type: string, options?: EventInitOptions) { if (!options) options = EVENT_OPTIONS_DEFAULT; this.initEvent(type, options.bubbles, options.cancelable, options.captures); if (options.__event_data) { diff --git a/packages/core/ui/core/dom/src/nodes/document/Document.ts b/packages/core/ui/core/dom/src/nodes/document/Document.ts index 2db5fc2383..570f960177 100644 --- a/packages/core/ui/core/dom/src/nodes/document/Document.ts +++ b/packages/core/ui/core/dom/src/nodes/document/Document.ts @@ -14,6 +14,7 @@ const createElement = (type: string, owner: Document) => { element.ownerDocument = owner; //@ts-ignore element.tagName = htmlElementRegistry[type].NODE_TAG_NAME; + element._isRegisteredDOMElement = true; return element; } return new HTMLElement(NodeTypeEnum.elementNode, type); From eaa1708715337318c5b20f542785ec5953e46f9b Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Tue, 7 Mar 2023 23:19:17 +0500 Subject: [PATCH 11/24] do not bind window to global by default --- packages/core/ui/core/dom/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/ui/core/dom/index.ts b/packages/core/ui/core/dom/index.ts index c810f309d2..0e30d400c6 100644 --- a/packages/core/ui/core/dom/index.ts +++ b/packages/core/ui/core/dom/index.ts @@ -1,5 +1,3 @@ -export { HTMLLabelElement } from './src/nodes/html-label-element/HTMLLabelELement'; import GlobalWindow from './src/window/Window'; export const Window = GlobalWindow; -new Window().bindToGlobal(); From e7bedcd497b3534290abb3e40f617b90eab79934 Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Tue, 7 Mar 2023 23:27:16 +0500 Subject: [PATCH 12/24] export window from core --- packages/core/index.d.ts | 2 ++ packages/core/index.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index 74ffa7fba4..83e1249f21 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -108,6 +108,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 e3bce03d9a..e975731ae5 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -7,7 +7,7 @@ export type { ApplicationEventData, LaunchEventData, OrientationChangedEventData 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'; import { inBackground, suspended } from './application/application-common'; -export { Window } from './ui/core/dom/index'; +export * from './ui/core/dom/index'; export const Application = { launchEvent, From 6be1a9abb7b09eb3d14a62d49abb7f2caae769e7 Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Tue, 7 Mar 2023 23:27:44 +0500 Subject: [PATCH 13/24] add key/array prop elements --- .../ui/core/dom/src/nodes/element/Element.ts | 4 +- .../html-prop-element/HTMLPropElement.ts | 127 ++++++++++++++++++ .../core/ui/core/dom/src/window/Window.ts | 7 +- 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 packages/core/ui/core/dom/src/nodes/html-prop-element/HTMLPropElement.ts diff --git a/packages/core/ui/core/dom/src/nodes/element/Element.ts b/packages/core/ui/core/dom/src/nodes/element/Element.ts index af812e2d54..264e63f16a 100644 --- a/packages/core/ui/core/dom/src/nodes/element/Element.ts +++ b/packages/core/ui/core/dom/src/nodes/element/Element.ts @@ -13,6 +13,7 @@ const updateAttributeNS = (self: Element, ns: string, name: string, value: any) * Element. */ export default class Element extends ParentNode { + _tagName: string; attributes: { ns: string; name: string; value?: any }[] = []; constructor(nodeType: NodeTypeEnum, localName: string) { super(); @@ -23,7 +24,8 @@ export default class Element extends ParentNode { } get tagName() { - return this.nodeName; + if (!this._tagName) return (this._tagName = this.nodeName.toUpperCase()); + return this._tagName; } set tagName(name: string) { 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..375119ab44 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/html-prop-element/HTMLPropElement.ts @@ -0,0 +1,127 @@ +import { DOMUtils } from './../../utils/index'; +import HTMLElement from '../html-element/HTMLElement'; +import 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) { + super(NodeTypeEnum.elementNode, 'prop'); + 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; + } + + private setProp() { + if (!this._key || !this.parentNode) return; + (this.parentNode as HTMLElement)[this.key] = this._value; + } +} + +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/window/Window.ts b/packages/core/ui/core/dom/src/window/Window.ts index b692666c65..c0621cc903 100644 --- a/packages/core/ui/core/dom/src/window/Window.ts +++ b/packages/core/ui/core/dom/src/window/Window.ts @@ -13,6 +13,7 @@ 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'; /** * Browser window. * @@ -35,9 +36,10 @@ export default class Window { public readonly AbortSignal = AbortSignal; public readonly SVGElement = SVGElement; public readonly CustomEvent = CustomEvent; + public readonly HTMLKeyPropElement = HTMLKeyPropElement; + public readonly HTMLArrayPropElement = HTMLArrayPropElement; public readonly document: Document; constructor() { - this.bindToGlobal(); //@ts-ignore globalThis.htmlElementRegistry = {}; //@ts-ignore @@ -88,7 +90,8 @@ export default class Window { globalThis.AbortSignal = AbortSignal; //@ts-ignore globalThis.CustomEvent = CustomEvent; - + globalThis.HTMLKeyPropElement = HTMLKeyPropElement; + globalThis.HTMLArrayPropElement = HTMLArrayPropElement; return this; } } From 44ac7ce270eb2e21090277ae3a91e803754224da Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Wed, 8 Mar 2023 23:31:08 +0500 Subject: [PATCH 14/24] add HTMLItemTemplateElement --- .../core/dom/src/nodes/document/Document.ts | 5 +- .../dom/src/nodes/html-element/HTMLElement.ts | 2 + .../HTMLItemTemplateElement.ts | 146 ++++++++++++++++++ .../html-prop-element/HTMLPropElement.ts | 4 +- .../core/ui/core/dom/src/nodes/node/Node.ts | 3 +- packages/core/ui/core/dom/src/utils/index.ts | 8 + .../core/ui/core/dom/src/window/Window.ts | 35 +++-- packages/core/ui/core/view-base/index.ts | 6 +- 8 files changed, 188 insertions(+), 21 deletions(-) create mode 100644 packages/core/ui/core/dom/src/nodes/html-item-template-element/HTMLItemTemplateElement.ts diff --git a/packages/core/ui/core/dom/src/nodes/document/Document.ts b/packages/core/ui/core/dom/src/nodes/document/Document.ts index 570f960177..f409ff48e8 100644 --- a/packages/core/ui/core/dom/src/nodes/document/Document.ts +++ b/packages/core/ui/core/dom/src/nodes/document/Document.ts @@ -7,10 +7,9 @@ import Text from '../text/Text'; import { Event } from '../../event/Event'; const createElement = (type: string, owner: Document) => { - //@ts-ignore - if (htmlElementRegistry && htmlElementRegistry[type]) { + if (htmlElementRegistry.has(type)) { //@ts-ignore - const element = new htmlElementRegistry[type](); + const element = new htmlElementRegistry.get(type)(); element.ownerDocument = owner; //@ts-ignore element.tagName = htmlElementRegistry[type].NODE_TAG_NAME; 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 index 9156d04889..201c5cb3cd 100644 --- a/packages/core/ui/core/dom/src/nodes/html-element/HTMLElement.ts +++ b/packages/core/ui/core/dom/src/nodes/html-element/HTMLElement.ts @@ -2,6 +2,8 @@ import Element from '../element/Element'; import NodeTypeEnum from '../node/NodeTypeEnum'; export default class HTMLElement extends Element { + isElement: boolean = true; + isNativeELement: boolean = false; constructor(nodeType: NodeTypeEnum, type: string) { super(nodeType, type); } 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..336fa798c0 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/html-item-template-element/HTMLItemTemplateElement.ts @@ -0,0 +1,146 @@ +import { View } from './../../../../view/index'; +import { ContentView } from '../../../../../content-view'; +import { DOMUtils } from '../../utils'; +import HTMLElement from '../html-element/HTMLElement'; +import { HTMLPropBaseElement } from '../html-prop-element/HTMLPropElement'; +import NodeTypeEnum from '../node/NodeTypeEnum'; + +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 View; + 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-prop-element/HTMLPropElement.ts b/packages/core/ui/core/dom/src/nodes/html-prop-element/HTMLPropElement.ts index 375119ab44..3492da06c6 100644 --- 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 @@ -22,7 +22,7 @@ export class HTMLPropBaseElement extends HTMLElement { public _value: any = undefined; - constructor(key: string, type: ParentPropType) { + constructor(key: string, type: ParentPropType = 'key') { super(NodeTypeEnum.elementNode, 'prop'); if (key) this.key = key; if (type) this.type = type; @@ -86,7 +86,7 @@ export class HTMLPropBaseElement extends HTMLElement { this.class = val; } - private setProp() { + public setProp() { if (!this._key || !this.parentNode) return; (this.parentNode as HTMLElement)[this.key] = this._value; } diff --git a/packages/core/ui/core/dom/src/nodes/node/Node.ts b/packages/core/ui/core/dom/src/nodes/node/Node.ts index 0b8135009b..2019529e97 100644 --- a/packages/core/ui/core/dom/src/nodes/node/Node.ts +++ b/packages/core/ui/core/dom/src/nodes/node/Node.ts @@ -49,6 +49,7 @@ export default class Node extends Observable { isParentNode: boolean = false; //@ts-ignore localName: string = null; + isNode: boolean = true; constructor() { super(); } @@ -97,7 +98,7 @@ export default class Node extends Observable { * @param [deep=false] "true" to clone deep. * @returns Cloned node. */ - cloneNode(deep: boolean) { + cloneNode(deep?: boolean) { let clonedNode: Node; if (this.isParentNode) { if (this.nodeType === NodeTypeEnum.documentNode) diff --git a/packages/core/ui/core/dom/src/utils/index.ts b/packages/core/ui/core/dom/src/utils/index.ts index 54bc06ea7a..9f92435461 100644 --- a/packages/core/ui/core/dom/src/utils/index.ts +++ b/packages/core/ui/core/dom/src/utils/index.ts @@ -58,8 +58,16 @@ const removeFromArrayProp = (node, key, item) => { 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 index c0621cc903..fa323bc142 100644 --- a/packages/core/ui/core/dom/src/window/Window.ts +++ b/packages/core/ui/core/dom/src/window/Window.ts @@ -14,6 +14,12 @@ 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'; + +declare global { + // eslint-disable-next-line no-var + var htmlElementRegistry: Map; +} + /** * Browser window. * @@ -39,27 +45,31 @@ export default class Window { public readonly HTMLKeyPropElement = HTMLKeyPropElement; public readonly HTMLArrayPropElement = HTMLArrayPropElement; public readonly document: Document; + public readonly self: Window; constructor() { - //@ts-ignore - globalThis.htmlElementRegistry = {}; - //@ts-ignore - globalThis.registerElement = this.registerElement; this.document = new Document(); this.document.defaultView = this; - //@ts-ignore - globalThis.window = this; - //@ts-ignore - globalThis.document = this.document; + globalThis.htmlElementRegistry = new Map(); + globalThis.registerElement = this.registerElement; + this.self = this; } registerElement(name: string, element: HTMLElement) { - //@ts-ignore - element.NODE_TAG_NAME = name; - //@ts-ignore - globalThis.htmlElementRegistry[name] = element; + if (!htmlElementRegistry.has(name)) { + //@ts-ignore + element.NODE_TAG_NAME = name; + //@ts-ignore + 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 @@ -92,6 +102,7 @@ export default class Window { globalThis.CustomEvent = CustomEvent; globalThis.HTMLKeyPropElement = HTMLKeyPropElement; globalThis.HTMLArrayPropElement = HTMLArrayPropElement; + return this; } } diff --git a/packages/core/ui/core/view-base/index.ts b/packages/core/ui/core/view-base/index.ts index 42ac29fcb2..9408c62c2f 100644 --- a/packages/core/ui/core/view-base/index.ts +++ b/packages/core/ui/core/view-base/index.ts @@ -336,12 +336,12 @@ export abstract class ViewBase extends HTMLElement implements ViewBaseDefinition public _moduleName: string; + public isNativeELement: boolean = true; + public reusable: boolean; constructor() { - super(NodeTypeEnum.elementNode, 'ViewBase'); - this.nodeName = this.constructor.name; - this.localName = this.constructor.name; + super(NodeTypeEnum.elementNode, 'view-base'); this._domId = viewIdCounter++; this._style = new Style(new WeakRef(this)); this.notify({ eventName: ViewBase.createdEvent, type: this.constructor.name, object: this }); From 959bbfd075b04a96e312395195ce6ba6acb3260d Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Thu, 16 Mar 2023 23:14:49 +0500 Subject: [PATCH 15/24] push changes --- package.json | 3 +- packages/core/data/observable/index.ts | 2 +- packages/core/package.json | 4 +- .../core/dom/src/config/ChildLessElements.ts | 1 + .../core/ui/core/dom/src/config/ElementTag.ts | 12 + .../ui/core/dom/src/config/NamespaceURI.ts | 5 + .../config/NonImplemenetedElementClasses.ts | 53 +++ .../core/dom/src/config/UnnestableElements.ts | 1 + .../ui/core/dom/src/config/VoidElements.ts | 1 + .../custom-element/CustomElementRegistry.ts | 79 ++++ .../dom-implementation/DOMImplementation.ts | 55 +++ .../document-fragment/DocumentFragment.ts | 10 +- .../src/nodes/document-type/DocumentType.ts | 45 +++ .../core/dom/src/nodes/document/Document.ts | 55 ++- .../ui/core/dom/src/nodes/element/Element.ts | 135 ++++++- .../HTMLItemTemplateElement.ts | 5 +- .../html-label-element/HTMLLabelELement.ts | 2 +- .../html-prop-element/HTMLPropElement.ts | 5 +- .../html-slot-element/HTMLSlotElement.ts | 249 ++++++++++++ .../HTMLTemplateElement.ts | 100 +++++ .../core/ui/core/dom/src/nodes/node/Node.ts | 58 ++- .../ui/core/dom/src/nodes/node/NodeList.ts | 6 +- .../dom/src/nodes/parent-node/ParentNode.ts | 78 +++- .../dom/src/nodes/shadow-root/LightDOM.ts | 133 +++++++ .../dom/src/nodes/shadow-root/ShadowHost.ts | 90 +++++ .../dom/src/nodes/shadow-root/ShadowRoot.ts | 113 ++++++ .../dom/src/nodes/svg-element/SVGElement.ts | 12 +- .../dom/src/query-selector/QuerySelector.ts | 251 ++++++++++++ .../dom/src/query-selector/SelectorItem.ts | 361 ++++++++++++++++++ .../core/ui/core/dom/src/window/Window.ts | 27 +- .../ui/core/dom/src/xml-parser/XMLParser.ts | 243 ++++++++++++ .../ui/core/dom/src/xml-serializer/index.ts | 161 ++++---- 32 files changed, 2214 insertions(+), 141 deletions(-) create mode 100644 packages/core/ui/core/dom/src/config/ChildLessElements.ts create mode 100644 packages/core/ui/core/dom/src/config/ElementTag.ts create mode 100644 packages/core/ui/core/dom/src/config/NamespaceURI.ts create mode 100644 packages/core/ui/core/dom/src/config/NonImplemenetedElementClasses.ts create mode 100644 packages/core/ui/core/dom/src/config/UnnestableElements.ts create mode 100644 packages/core/ui/core/dom/src/config/VoidElements.ts create mode 100644 packages/core/ui/core/dom/src/custom-element/CustomElementRegistry.ts create mode 100644 packages/core/ui/core/dom/src/dom-implementation/DOMImplementation.ts create mode 100644 packages/core/ui/core/dom/src/nodes/document-type/DocumentType.ts create mode 100644 packages/core/ui/core/dom/src/nodes/html-slot-element/HTMLSlotElement.ts create mode 100644 packages/core/ui/core/dom/src/nodes/html-template-element/HTMLTemplateElement.ts create mode 100644 packages/core/ui/core/dom/src/nodes/shadow-root/LightDOM.ts create mode 100644 packages/core/ui/core/dom/src/nodes/shadow-root/ShadowHost.ts create mode 100644 packages/core/ui/core/dom/src/nodes/shadow-root/ShadowRoot.ts create mode 100644 packages/core/ui/core/dom/src/query-selector/QuerySelector.ts create mode 100644 packages/core/ui/core/dom/src/query-selector/SelectorItem.ts create mode 100755 packages/core/ui/core/dom/src/xml-parser/XMLParser.ts diff --git a/package.json b/package.json index f5a4a69f1e..e29186466d 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.1", "lint-staged": "^13.1.0", @@ -73,4 +75,3 @@ ] } } - diff --git a/packages/core/data/observable/index.ts b/packages/core/data/observable/index.ts index 7b1d27cbb2..37d30df506 100644 --- a/packages/core/data/observable/index.ts +++ b/packages/core/data/observable/index.ts @@ -412,7 +412,7 @@ export class Observable extends EventTarget implements globalThis.EventTarget { 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._parentNode; + currentNode = currentNode._getTheParent(event); } } diff --git a/packages/core/package.json b/packages/core/package.json index 49a8ef0aee..a4dcf793fb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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/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/custom-element/CustomElementRegistry.ts b/packages/core/ui/core/dom/src/custom-element/CustomElementRegistry.ts new file mode 100644 index 0000000000..27f716cc3b --- /dev/null +++ b/packages/core/ui/core/dom/src/custom-element/CustomElementRegistry.ts @@ -0,0 +1,79 @@ +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; + } + + 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..e992fc7994 --- /dev/null +++ b/packages/core/ui/core/dom/src/dom-implementation/DOMImplementation.ts @@ -0,0 +1,55 @@ +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 + return new documentClass(); + } + + /** + * 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/nodes/document-fragment/DocumentFragment.ts b/packages/core/ui/core/dom/src/nodes/document-fragment/DocumentFragment.ts index 13e9b40f04..163264b16d 100644 --- a/packages/core/ui/core/dom/src/nodes/document-fragment/DocumentFragment.ts +++ b/packages/core/ui/core/dom/src/nodes/document-fragment/DocumentFragment.ts @@ -1,10 +1,14 @@ -import Node from '../node/Node'; +import NodeTypeEnum from '../node/NodeTypeEnum'; import ParentNode from '../parent-node/ParentNode'; - /** * DocumentFragment. */ export default class DocumentFragment extends ParentNode { - public nodeType = Node.DOCUMENT_FRAGMENT_NODE; + public nodeType = NodeTypeEnum.documentFragmentNode; public nodeName: string = '#document-fragment'; + public localName: string = '#document-fragment'; + constructor() { + super(); + this._rootNode = this; + } } 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..a84ad8512e --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/document-type/DocumentType.ts @@ -0,0 +1,45 @@ +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 = ''; + + set parentNode(node: any) { + this._parentNode = node; + } + //@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 index f409ff48e8..7ccf5ab2c4 100644 --- a/packages/core/ui/core/dom/src/nodes/document/Document.ts +++ b/packages/core/ui/core/dom/src/nodes/document/Document.ts @@ -1,22 +1,31 @@ +import ElementTag from '../../config/ElementTag'; +import DOMImplementation from '../../dom-implementation/DOMImplementation'; +import { Event } from '../../event/Event'; +import Comment from '../comment/Comment'; import DocumentFragment from '../document-fragment/DocumentFragment'; +import HTMLElement from '../html-element/HTMLElement'; import NodeTypeEnum from '../node/NodeTypeEnum'; import ParentNode from '../parent-node/ParentNode'; -import HTMLElement from '../html-element/HTMLElement'; -import Comment from '../comment/Comment'; import Text from '../text/Text'; -import { Event } from '../../event/Event'; const createElement = (type: string, owner: Document) => { + let element; + if (htmlElementRegistry.has(type)) { - //@ts-ignore - const element = new htmlElementRegistry.get(type)(); - element.ownerDocument = owner; - //@ts-ignore + element = new (htmlElementRegistry.get(type) as any)(); element.tagName = htmlElementRegistry[type].NODE_TAG_NAME; - element._isRegisteredDOMElement = true; - return element; + } else if (ElementTag[type]) { + element = new ElementTag[type](); + element.tagName = type; + } else if (customElements.get(type)) { + element = new (customElements.get(type))(); + element.tagName = type; + } else { + element = new HTMLElement(NodeTypeEnum.elementNode, type); } - return new HTMLElement(NodeTypeEnum.elementNode, type); + element.ownerDocument = owner; + element._isRegisteredDOMElement = true; + return element; }; /** @@ -24,12 +33,38 @@ const createElement = (type: string, owner: Document) => { */ export default class Document extends ParentNode { _defaultView = undefined; + documentElement: HTMLElement; + head: HTMLElement; + body: HTMLElement; + implementation: DOMImplementation; nodeType: NodeTypeEnum = NodeTypeEnum.documentNode; /* 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() { diff --git a/packages/core/ui/core/dom/src/nodes/element/Element.ts b/packages/core/ui/core/dom/src/nodes/element/Element.ts index 264e63f16a..ef057cd1da 100644 --- a/packages/core/ui/core/dom/src/nodes/element/Element.ts +++ b/packages/core/ui/core/dom/src/nodes/element/Element.ts @@ -1,13 +1,13 @@ import { createAttributeFilter, findWhere, splice } from '../../utils'; +import type HTMLSlotElement from '../html-slot-element/HTMLSlotElement'; +import NodeList from '../node/NodeList'; import NodeTypeEnum from '../node/NodeTypeEnum'; import ParentNode from '../parent-node/ParentNode'; - -// eslint-disable-next-line max-params -const updateAttributeNS = (self: Element, ns: string, name: string, value: any) => { - let attr = findWhere(self.attributes, createAttributeFilter(ns, name), false, false); - if (!attr) self.attributes.push((attr = { ns, name } as never)); - attr.value = value; -}; +import { ShadowHostMixin } from '../shadow-root/ShadowHost'; +import { ShadowRoot } from '../shadow-root/ShadowRoot'; +import XMLParser from '../../xml-parser/XMLParser'; +import XMLSerializer from '../../xml-serializer'; +import QuerySelector from '../../query-selector/QuerySelector'; /** * Element. @@ -15,6 +15,10 @@ const updateAttributeNS = (self: Element, ns: string, name: string, value: any) export default class Element extends ParentNode { _tagName: string; attributes: { ns: string; name: string; value?: any }[] = []; + _shadowRoot: ShadowRoot = null; + id: string; + static _observedAttributes: string[]; + static observedAttributes: string[]; constructor(nodeType: NodeTypeEnum, localName: string) { super(); this.nodeType = nodeType; @@ -23,6 +27,26 @@ export default class Element extends ParentNode { this.attributes = []; } + 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; @@ -42,7 +66,7 @@ export default class Element extends ParentNode { } get className() { - return this.getAttribute('class'); + return this.getAttribute('class') || ''; } set className(val) { this.setAttribute('class', val); @@ -52,18 +76,12 @@ export default class Element extends ParentNode { // But we just put it here for some frameworks to work // Or warn people not trying to treat undom like a browser get innerHTML() { - const serializedChildren: string[] = []; - let currentNode = this.firstChild; - while (currentNode) { - serializedChildren.push(new XMLSerializer().serializeToString(currentNode as never)); - currentNode = currentNode.nextSibling; - } - return ''.concat(...serializedChildren); + return new XMLSerializer().serializeToString(this as never, { innerHTML: true }); } - set innerHTML(value) { + set innerHTML(html: string) { // Setting innerHTML with an empty string just clears the element's children - if (value === '') { + if (html === '') { let currentNode = this.firstChild; while (currentNode) { const nextSibling = currentNode.nextSibling; @@ -73,7 +91,9 @@ export default class Element extends ParentNode { return; } - throw new Error(`[UNDOM-NG] Failed to set 'innerHTML' on '${this.localName}': Not implemented.`); + for (const node of XMLParser.parse(this.ownerDocument, html).childNodes.slice()) { + this.appendChild(node); + } } get outerHTML() { @@ -87,12 +107,13 @@ export default class Element extends ParentNode { return; } - throw new Error(`[UNDOM-NG] Failed to set 'outerHTML' on '${this.localName}': Not implemented.`); + 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); } @@ -113,11 +134,87 @@ export default class Element extends ParentNode { 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) { + 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); + } + + 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(); + } + ShadowHostMixin; + // Make this element a Shadow Host. + //Object.assign(this, ShadowHostMixin); + // // Add childern back to Light DOM + // // If they are slottable, they will + // // get rendered again in the + // // Shadow Tree. + // this.append(...childNodes); + + return shadow; + } + + /** + * 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, ns: string, name: string, value: any) => { + let attr = findWhere(self.attributes, createAttributeFilter(ns, name), false, false); + if (!attr) self.attributes.push((attr = { ns, name } as never)); + if (self.attributeChangedCallback && (self.constructor)._observedAttributes && (self.constructor)._observedAttributes.includes(name)) { + self.attributeChangedCallback(name, attr.value, value); + } + attr.value = value; +}; 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 index 336fa798c0..b2a23df07a 100644 --- 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 @@ -1,9 +1,10 @@ -import { View } from './../../../../view/index'; +import type { View } from './../../../../view/index'; import { ContentView } from '../../../../../content-view'; import { DOMUtils } from '../../utils'; -import HTMLElement from '../html-element/HTMLElement'; +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; 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 index c2b02bb153..1ad0c27ee6 100644 --- 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 @@ -1,5 +1,5 @@ import { Label } from '../../../../../label'; -import HTMLElement from '../html-element/HTMLElement'; +import type HTMLElement from '../html-element/HTMLElement'; /** * HTML Label Element. 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 index 3492da06c6..5a71f6516d 100644 --- 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 @@ -1,8 +1,7 @@ import { DOMUtils } from './../../utils/index'; import HTMLElement from '../html-element/HTMLElement'; -import Node from '../node/Node'; +import type Node from '../node/Node'; import NodeTypeEnum from '../node/NodeTypeEnum'; - export type ParentPropType = 'array' | 'key'; /** @@ -92,7 +91,7 @@ export class HTMLPropBaseElement extends HTMLElement { } } -class HTMLPropElement extends HTMLPropBaseElement { +export class HTMLPropElement extends HTMLPropBaseElement { insertBefore(newNode: Node, referenceNode: Node): Node { super.insertBefore(newNode, referenceNode); if (Array.isArray(this._value)) { 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..498ae996a5 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/html-slot-element/HTMLSlotElement.ts @@ -0,0 +1,249 @@ +import type Element from '../element/Element'; +import HTMLElement from '../html-element/HTMLElement'; +import type Node from '../node/Node'; +import NodeTypeEnum from '../node/NodeTypeEnum'; +import { LightDOM } from '../shadow-root/LightDOM'; +import type { ShadowHostMixin } from '../shadow-root/ShadowHost'; +import type { ShadowRoot } from '../shadow-root/ShadowRoot'; + +function isNullOrUndefined(value: any) { + return value === undefined || value === null; +} + +const FALLBACK_REF = '__slotFallbackRef'; +const SLOTTED_CHILD_REF = '__slotAssignmentRef'; +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 here. + */ + _slotAssignments: LightDOM = new LightDOM(SLOTTED_CHILD_REF); + constructor() { + super(NodeTypeEnum.elementNode, 'slot'); + } + /** + * 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 }): 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 }): Element[] { + const host = (this.getRootNode())?.host as unknown as ShadowHostMixin; + 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._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 { + return super.cloneNode(deep); + } + + 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 && !root._slots.has(this.name)) { + 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 { + // If node does not have any assigned slot then it's part of the + // fallback content. + if (!(node as Element).assignedSlot && !node[FALLBACK_REF]) { + this._fallbackChildern.appendChild(node); + // If fallback content is not rendered, we return here. + if (!this.isFallbackRendered()) return; + } + + if (!node[SLOTTED_CHILD_REF]) { + this._slotAssignments.appendChild(node); + } + if (!this.parentNode) return; + return this.parentNode.appendChild(node); + } + + insertBefore(newNode: Node, referenceNode: Node): Node { + if (!(newNode as Element).assignedSlot && !(referenceNode as Element).assignedSlot) { + this._fallbackChildern.insertBefore(newNode, referenceNode); + if (!this.isFallbackRendered()) return; + } + + if (!newNode[SLOTTED_CHILD_REF]) { + this._slotAssignments.insertBefore(newNode, referenceNode); + } + if (!this.parentNode) return; + return this.parentNode.insertBefore(newNode, referenceNode); + } + + replaceChild(newChild: Node, oldChild: Node): Node { + if (!(newChild as Element).assignedSlot && !(newChild as Element).assignedSlot) { + this._fallbackChildern.replaceChild(newChild, oldChild); + if (!this.isFallbackRendered()) return; + } + + if (!newChild[SLOTTED_CHILD_REF]) { + this._slotAssignments.replaceChild(newChild, oldChild); + } + if (!this.parentNode) return; + return this.parentNode.replaceChild(newChild, oldChild); + } + + 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; + this.parentNode.removeChild(node); + } + + renderFallback() { + if (this._fallbackChildern.childrenCount > 0) { + if (this._fallbackChildern.firstChild?.node !== this._slotAssignments.firstChild?.node) { + for (const node of this._fallbackChildern.childNodes) { + this.appendChild(node); + } + } + return; + } + } +} + +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..34066f640d --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/html-template-element/HTMLTemplateElement.ts @@ -0,0 +1,100 @@ +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 = this.ownerDocument.createDocumentFragment(); + + /** + * @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 get lastChild(): Node { + return this.content.lastChild; + } + + /** + * @override + */ + public getInnerHTML(options?: { includeShadowRoots?: boolean }): string { + const xmlSerializer = new XMLSerializer(); + let xml = ''; + for (const node of this.content.childNodes) { + xml += xmlSerializer.serializeToString(node, options); + } + return xml; + } + + /** + * @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 index 2019529e97..960dc486c6 100644 --- a/packages/core/ui/core/dom/src/nodes/node/Node.ts +++ b/packages/core/ui/core/dom/src/nodes/node/Node.ts @@ -50,6 +50,7 @@ export default class Node extends Observable { //@ts-ignore localName: string = null; isNode: boolean = true; + _rootNode: Node = null; constructor() { super(); } @@ -58,6 +59,10 @@ export default class Node extends Observable { return this._parentNode; } + set parentNode(parent: Node) { + this._parentNode = parent; + } + get previousElementSibling(): Node { let currentNode = this.previousSibling; while (currentNode) { @@ -79,7 +84,7 @@ export default class Node extends Observable { } remove() { - if (this.parentNode) this.parentNode.removeChild(this as never); + if (this._parentNode) this._parentNode.removeChild(this as never); } get childNodes() { @@ -153,7 +158,7 @@ export default class Node extends Observable { 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 (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; @@ -165,7 +170,8 @@ export default class Node extends Observable { while (currentNode) { const nextSibling = currentNode.nextSibling; //@ts-ignore - currentNode.parentNode = this; + currentNode._parentNode = this; + currentNode._rootNode = this._rootNode; currentNode = nextSibling; } @@ -192,8 +198,7 @@ export default class Node extends Observable { } else { newNode.remove(); //@ts-ignore - newNode.parentNode = this; - + newNode._parentNode = this; if (referenceNode) { newNode.previousSibling = referenceNode.previousSibling; newNode.nextSibling = referenceNode; @@ -207,12 +212,14 @@ export default class Node extends Observable { else this.firstChild = newNode; } + newNode._rootNode = this._rootNode; + 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.`); + 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(); @@ -228,11 +235,22 @@ export default class Node extends Observable { if (node.previousSibling) node.previousSibling.nextSibling = node.nextSibling; if (node.nextSibling) node.nextSibling.previousSibling = node.previousSibling; //@ts-ignore - node.parentNode = null; + 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(); return node; } @@ -241,6 +259,16 @@ export default class Node extends Observable { 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. * @@ -251,13 +279,25 @@ export default class Node extends Observable { } replaceWith(...nodes: Node[]) { - if (!this.parentNode) return; + if (!this._parentNode) return; const ref = this.nextSibling; - const parent = this.parentNode; + 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; } diff --git a/packages/core/ui/core/dom/src/nodes/node/NodeList.ts b/packages/core/ui/core/dom/src/nodes/node/NodeList.ts index 1b1a074730..d4f2bedc7c 100644 --- a/packages/core/ui/core/dom/src/nodes/node/NodeList.ts +++ b/packages/core/ui/core/dom/src/nodes/node/NodeList.ts @@ -1,9 +1,9 @@ -import Node from './Node'; +import type Node from './Node'; /** * Class list. */ -export default class NodeList extends Array { +export default class NodeList extends Array { /** * Returns `Symbol.toStringTag`. * @@ -18,7 +18,7 @@ export default class NodeList extends Array { * * @param index Index. */ - public item(index: number): Node { + public item(index: number): T { return index >= 0 && this[index] ? this[index] : null; } } 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 index 7fae1eb014..aadea0e2d7 100644 --- a/packages/core/ui/core/dom/src/nodes/parent-node/ParentNode.ts +++ b/packages/core/ui/core/dom/src/nodes/parent-node/ParentNode.ts @@ -1,7 +1,9 @@ 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'; /** * Parent node */ @@ -48,14 +50,21 @@ export default class ParentNode extends Node { } append(...nodes: Node[]) { - for (const i of nodes) { - this.appendChild(i); + 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 old of nodes) { - old.remove(); + for (const node of this.childNodes) { + node.remove(); } for (const node of nodes) { @@ -70,7 +79,7 @@ export default class ParentNode extends Node { while (currentNode) { if (currentNode.nodeType !== 8) { const textContent = currentNode._textContent; - if (textContent) textArr.push(textContent as never); + if (textContent) textArr.push(escape(textContent) as never); } currentNode = currentNode.nextSibling; } @@ -80,4 +89,61 @@ export default class ParentNode extends Node { 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.concat(matches); + } + } + current = current.nextSibling as Element; + } + return elements; + } + + getElementsByClassName(className: string) { + if (!this.firstChild) return []; + const elements = new NodeList(); + let current = this.firstChild 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.concat(matches); + } + } + current = current.nextSibling as View; + } + return elements; + } + + public getElementById(id: string) { + let element: Element; + let current = this.firstChild as Element; + 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))) return element; + } + } + current = current.nextSibling as View; + } + return element; + } } 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..e33bddc9a0 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/shadow-root/LightDOM.ts @@ -0,0 +1,133 @@ +interface LightNodeRef { + node: any; + previousSibling?: LightNodeRef; + nextSibling?: LightNodeRef; +} + +function createLightNodeRef(node: any, previousSibling?: LightNodeRef, nextSibling?: LightNodeRef) { + Node; + return { + node, + previousSibling, + nextSibling, + }; +} +/** + * 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; + constructor(ref: string = '__lightRef') { + this.ref = ref; + } + + appendChild(node: any) { + if (node[this.ref]) return; + const ref = createLightNodeRef(node); + 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, 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, 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/ShadowHost.ts b/packages/core/ui/core/dom/src/nodes/shadow-root/ShadowHost.ts new file mode 100644 index 0000000000..aea984ebc9 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/shadow-root/ShadowHost.ts @@ -0,0 +1,90 @@ +import type HTMLElement from '../html-element/HTMLElement'; +import type Node from '../node/Node'; +import { LightDOM } from './LightDOM'; + +/** + * A mixin that adds support for Light DOM + * + * If child is added from shadow, it's a rendered node, otherwise it's part of the light dom. + * Nodes that are part of the light DOM get rendered in their respective named slots. If no slot is found + * and the default slot is not present, those nodes are not rendered in the shadow dom but still show up + * in the light dom. + */ +export class ShadowHostMixin { + _lightDOM: LightDOM = new LightDOM(); + appendChild(node: Node, shadow: boolean) { + if (shadow) { + /** + * The shadow dom requested to append a child, + * such childern must be part of the render phase. + */ + //@ts-ignore + super.appendChild(node); + } else { + /** + * This element is a slottable and part of the light dom. + * We will look up a valid slot in the Shadow DOM. + * If no slot is found, the element is not rendered in DOM + * Tree hence it's skipped from render phase. + */ + const slot = (this as unknown as HTMLElement)._shadowRoot._slots.get((node as HTMLElement).slot); + if (slot) { + (node as HTMLElement).assignedSlot = slot; + this._lightDOM.appendChild(node); + slot.appendChild(node); + slot.dispatchSlotChangeEvent(); + } else { + this._lightDOM.appendChild(node); + } + } + } + insertBefore(newNode: Node, referenceNode: Node, shadow: boolean) { + if (shadow) { + //@ts-ignore + super.insertBefore(newNode, referenceNode); + } else { + const slot = (this as unknown as HTMLElement)._shadowRoot._slots.get((newNode as HTMLElement).slot); + const refSlot = (this as unknown as HTMLElement)._shadowRoot._slots.get((referenceNode as HTMLElement).slot); + if (slot === refSlot) { + (newNode as HTMLElement).assignedSlot = slot; + this._lightDOM.insertBefore(newNode, referenceNode); + slot.insertBefore(newNode, referenceNode); + slot.dispatchSlotChangeEvent(); + } else { + this._lightDOM.insertBefore(newNode, referenceNode); + } + } + } + replaceChild(newChild: Node, oldChild: Node, shadow: boolean) { + if (shadow) { + //@ts-ignore + super.replaceChild(newChild, oldChild); + } else { + const slot = (this as unknown as HTMLElement)._shadowRoot._slots.get((newChild as HTMLElement).slot); + if (slot) { + (newChild as HTMLElement).assignedSlot = slot; + this._lightDOM.replaceChild(newChild, oldChild); + slot.replaceChild(newChild, oldChild); + slot.dispatchSlotChangeEvent(); + } else { + this._lightDOM.replaceChild(newChild, oldChild); + } + } + } + removeChild(node: any, shadow: boolean) { + if (shadow) { + //@ts-ignore + super.removeChild(node); + } else { + const slot = (this as unknown as HTMLElement)._shadowRoot._slots.get((node as HTMLElement).slot); + if (slot) { + (node as HTMLElement).assignedSlot = slot; + this._lightDOM.removeChild(node); + slot.removeChild(node); + slot.dispatchSlotChangeEvent(); + } else { + this._lightDOM.removeChild(node); + } + } + } +} 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..cc0f77c099 --- /dev/null +++ b/packages/core/ui/core/dom/src/nodes/shadow-root/ShadowRoot.ts @@ -0,0 +1,113 @@ +import DocumentFragment from '../document-fragment/DocumentFragment'; +import type HTMLElement from '../html-element/HTMLElement'; +import type HTMLSlotElement from '../html-slot-element/HTMLSlotElement'; +import type Node from '../node/Node'; +import type { ShadowHostMixin } from './ShadowHost'; + +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 + if (slot.firstChild) { + for (const node of slot.childNodes as Node[]) { + node.remove(); + } + } + for (const node of nodes) { + (node as HTMLElement).assignedSlot = slot; + 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(); + } + } + 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? + //@ts-ignore + return (this._host as unknown as ShadowHostMixin).appendChild(node, true); + } + + insertBefore(newNode: Node, referenceNode: Node): Node { + //@ts-ignore + return (this._host as unknown as ShadowHostMixin).insertBefore(newNode, referenceNode, true); + } + + replaceChild(newChild: Node, oldChild: Node): Node { + //@ts-ignore + return (this._host as unknown as ShadowHostMixin).replaceChild(newChild, oldChild, true); + } + + removeChild(node: any) { + //@ts-ignore + (this._host as unknown as ShadowHostMixin).removeChild(node, true); + } +} 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 index 5fdf92be65..cd2641743e 100644 --- a/packages/core/ui/core/dom/src/nodes/svg-element/SVGElement.ts +++ b/packages/core/ui/core/dom/src/nodes/svg-element/SVGElement.ts @@ -1,9 +1,9 @@ -import Element from '../element/Element'; +import type Element from '../element/Element'; import NodeTypeEnum from '../node/NodeTypeEnum'; -export default class SVGElement extends Element { - constructor(nodeType: NodeTypeEnum, localName: string) { - super(nodeType, localName); - this.namespaceURI = 'http://www.w3.org/2000/svg'; - } +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/query-selector/QuerySelector.ts b/packages/core/ui/core/dom/src/query-selector/QuerySelector.ts new file mode 100644 index 0000000000..9c9031210d --- /dev/null +++ b/packages/core/ui/core/dom/src/query-selector/QuerySelector.ts @@ -0,0 +1,251 @@ +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, [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, [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, nodes: 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 = []; + + for (const node of nodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + if (selector.match(node).matches) { + if (selectorParts.length === 1) { + if (rootNode !== node) { + matched.push(node); + } + } else { + if (node.firstChild) { + const childMatches = this.findAll(rootNode, (node).children, selectorParts.slice(1), null); + matched = matched.concat(childMatches); + } + } + } + } + + if (!isDirectChild && node['firstChild']) { + matched = matched.concat(this.findAll(rootNode, node['children'], selectorParts, selector)); + } + } + + 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, nodes: Node[], selectorParts: string[], selectorItem?: SelectorItem): Element { + const isDirectChild = selectorParts[0] === '>'; + if (isDirectChild) { + selectorParts = selectorParts.slice(1); + } + const selector = selectorItem || new SelectorItem(selectorParts[0]); + + for (const node of nodes) { + if (node.nodeType === Node.ELEMENT_NODE && selector.match(node).matches) { + if (selectorParts.length === 1) { + if (rootNode !== node) { + return node; + } + } else { + const childSelector = this.findFirst(rootNode, (node).children, selectorParts.slice(1), null); + if (childSelector) { + return childSelector; + } + } + } + + if (!isDirectChild && node['firstChild']) { + const childSelector = this.findFirst(rootNode, node['children'], selectorParts, selector); + + if (childSelector) { + return childSelector; + } + } + } + + 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..7ee8cd3336 --- /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 = new RegExp(ATTRIBUTE_REGEXP, 'g'); + 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 = new RegExp(CLASS_REGEXP, 'g'); + const classList = element.className.split(' '); + const classSelector = selector.split(CSS_ESCAPE_REGEXP)[0]; + let priorityWeight = 0; + let match: RegExpMatchArray; + + while ((match = regexp.exec(classSelector))) { + priorityWeight += 10; + if (!classList.includes(match[1].replace(CSS_ESCAPE_CHAR_REGEXP, ''))) { + 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/window/Window.ts b/packages/core/ui/core/dom/src/window/Window.ts index fa323bc142..7436081253 100644 --- a/packages/core/ui/core/dom/src/window/Window.ts +++ b/packages/core/ui/core/dom/src/window/Window.ts @@ -14,6 +14,12 @@ 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'; declare global { // eslint-disable-next-line no-var @@ -44,14 +50,22 @@ export default class Window { 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; 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: HTMLElement) { @@ -102,7 +116,18 @@ export default class Window { 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; return this; } } 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..74a0d2a98b --- /dev/null +++ b/packages/core/ui/core/dom/src/xml-parser/XMLParser.ts @@ -0,0 +1,243 @@ +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: