diff --git a/CHANGELOG.md b/CHANGELOG.md index a0b7f096..1611d55a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## next +- Introduced the `Model` class as a base for `Widget` and `App`: + - Added a new `setup` option for configuring model-related aspects during initialization (immutable during the lifecycle), such as object markers, additional query methods, etc. + - Implements `loadDataFrom*` methods - Added handling of empty payload on data loading (raise an error "Empty payload") - Added `props` options for a view definition, a function (or a string as a jora query) `(data, { props, context}) => any` which should return a normalized props - Added additional block into inspector popup to display normalized props when `view.options.props` is specified diff --git a/src/lib.js b/src/lib.ts similarity index 85% rename from src/lib.js rename to src/lib.ts index 6a28aeb1..5a495f42 100644 --- a/src/lib.js +++ b/src/lib.ts @@ -1,5 +1,5 @@ import { version } from './version.js'; -import { Widget, App } from './main/index.js'; +import { Model, Widget, App } from './main/index.js'; import * as views from './views/index.js'; import * as pages from './pages/index.js'; import inspector from './extensions/inspector.js'; @@ -10,8 +10,10 @@ import { buttons as navButtons } from './nav/index.js'; import jsonxl from './core/encodings/jsonxl.js'; import utils from './core/utils/index.js'; +export type * from './main/index.js'; export { version, + Model, Widget, App, views, diff --git a/src/main/app.js b/src/main/app.ts similarity index 72% rename from src/main/app.js rename to src/main/app.ts index 6706cfe5..7ebb549e 100644 --- a/src/main/app.js +++ b/src/main/app.ts @@ -1,30 +1,45 @@ /* eslint-env browser */ -import { Widget } from './widget.js'; -import upload from '../extensions/upload.js'; +import { SetDataProgressOptions, Widget, WidgetEvents, WidgetOptions } from './widget.js'; +import upload, { UploadOptions } from '../extensions/upload.js'; import embed from '../extensions/embed-client.js'; import router from '../extensions/router.js'; import { createElement } from '../core/utils/dom.js'; -import Progressbar from '../core/utils/progressbar.js'; +import Progressbar, { ProgressbarOptions, loadStages } from '../core/utils/progressbar.js'; import * as navButtons from '../nav/buttons.js'; -import { - loadDataFromStream, - loadDataFromFile, - loadDataFromEvent, - loadDataFromUrl, - syncLoaderWithProgressbar -} from '../core/utils/load-data.js'; +import { syncLoaderWithProgressbar } from '../core/utils/load-data.js'; +import { LoadDataBaseOptions, LoadDataState } from '../core/utils/load-data.types.js'; const coalesceOption = (value, fallback) => value !== undefined ? value : fallback; -const mixinEncodings = (host, options) => ({ - ...options, - encodings: Array.isArray(options?.encodings) - ? [...options.encodings, ...host.encodings] - : host.encodings -}); - -export class App extends Widget { - constructor(options = {}) { + +export type AppLoadingState = 'init' | 'error' | 'success'; +export type AppLoadingStateOptions = + T extends 'init' ? { progressbar: Progressbar } : + T extends 'error' ? { error: Error & { renderContent?: any }, progressbar: Progressbar } : + undefined; + +export interface AppEvents extends WidgetEvents { + startLoadData: [subscribe: Parameters]; +} +export interface AppOptions extends WidgetOptions { + mode: 'modelfree'; + router: boolean; + upload: UploadOptions + embed: boolean; +} +type AppOptionsBind = AppOptions; // to fix: Type parameter 'Options' has a circular default. + +export class App< + Options extends AppOptions = AppOptionsBind, + Events extends AppEvents = AppEvents +> extends Widget { + mode: string | undefined; + _defaultPageId: string | undefined; + declare dom: Widget['dom'] & { + loadingOverlay: HTMLElement; + }; + + constructor(options: Partial = {}) { const extensions = options.extensions ? [options.extensions] : []; extensions.push(navButtons.darkmodeToggle); @@ -62,20 +77,21 @@ export class App extends Widget { this.mode = this.options.mode; } - setLoadingState(state, { error, progressbar } = {}) { + setLoadingState(state: S, options?: AppLoadingStateOptions) { const loadingOverlayEl = this.dom.loadingOverlay; + const { progressbar } = options || {}; switch (state) { case 'init': { loadingOverlayEl.classList.remove('error', 'done'); // if progressbar already has parent element -> do nothing - if (progressbar.el.parentNode) { + if (progressbar?.el.parentNode) { return; } loadingOverlayEl.innerHTML = ''; - loadingOverlayEl.append(progressbar.el); + loadingOverlayEl.append(progressbar?.el || ''); loadingOverlayEl.classList.add('init'); requestAnimationFrame(() => loadingOverlayEl.classList.remove('init')); @@ -89,6 +105,8 @@ export class App extends Widget { } case 'error': { + const error = (options as AppLoadingStateOptions<'error'>)?.error; + loadingOverlayEl.classList.add('error'); loadingOverlayEl.innerHTML = ''; @@ -133,7 +151,7 @@ export class App extends Widget { } } - async setDataProgress(data, context, options) { + async setDataProgress(data: unknown, context: unknown, options?: SetDataProgressOptions) { const dataset = options?.dataset; const progressbar = options?.progressbar || this.progressbar({ title: 'Set data' }); @@ -146,7 +164,7 @@ export class App extends Widget { } } - progressbar(options) { + progressbar(options: ProgressbarOptions & { title?: string }) { return new Progressbar({ delay: 200, domReady: this.dom.ready, @@ -159,8 +177,8 @@ export class App extends Widget { }); } - trackLoadDataProgress(loader) { - const progressbar = this.progressbar({ title: loader.title }); + async trackLoadDataProgress(loader: LoadDataState) { + const progressbar = this.progressbar({ title: loadStages[loader.state.value.stage].title }); this.setLoadingState('init', { progressbar }); this.emit('startLoadData', progressbar.subscribe.bind(progressbar)); @@ -170,17 +188,10 @@ export class App extends Widget { error => this.setLoadingState('error', { error, progressbar }) ); - return loader.result; + await loader.result; } - loadDataFromStream(stream, options) { - return this.trackLoadDataProgress(loadDataFromStream( - stream, - mixinEncodings(this, typeof options === 'number' ? { size: options } : options) - )); - } - - loadDataFromEvent(event, options) { + loadDataFromEvent(event: DragEvent | InputEvent, options?: LoadDataBaseOptions) { if (this.options.mode === 'modelfree' && this.defaultPageId !== this.discoveryPageId) { this._defaultPageId = this.defaultPageId; this.defaultPageId = this.discoveryPageId; @@ -188,20 +199,12 @@ export class App extends Widget { this.cancelScheduledRender(); } - return this.trackLoadDataProgress(loadDataFromEvent(event, mixinEncodings(this, options))); - } - - loadDataFromFile(file, options) { - return this.trackLoadDataProgress(loadDataFromFile(file, mixinEncodings(this, options))); - } - - loadDataFromUrl(url, options) { - return this.trackLoadDataProgress(loadDataFromUrl(url, mixinEncodings(this, options))); + return super.loadDataFromEvent(event, options); } unloadData() { if (this.hasDatasets() && this.options.mode === 'modelfree' && this._defaultPageId !== this.defaultPageId) { - this.defaultPageId = this._defaultPageId; + this.defaultPageId = this._defaultPageId as string; this.setPageHash(this.pageHash, true); this.cancelScheduledRender(); } diff --git a/src/main/data-extension-api.js b/src/main/data-extension-api.js deleted file mode 100644 index 280521bb..00000000 --- a/src/main/data-extension-api.js +++ /dev/null @@ -1,168 +0,0 @@ -import ObjectMarker from '../core/object-marker.js'; -import jora from 'jora'; - -export function createDataExtensionApi(host) { - const objectMarkers = new ObjectMarker(); - const linkResolvers = []; - const annotations = []; - const methods = { - rejectData(message, renderContent) { - throw Object.assign(new Error(message), { renderContent }); - }, - defineObjectMarker, - lookupObjectMarker, - lookupObjectMarkerAll, - resolveValueLinks, - addValueAnnotation, - addQueryHelpers(helpers) { - joraSetup = jora.setup({ - methods: queryCustomMethods = { - ...queryCustomMethods, - ...helpers - } - }); - }, - query(query, ...args) { - return host.queryFn.call({ queryFnFromString: joraSetup }, query)(...args); - } - }; - let queryCustomMethods = { - query: (...args) => host.query(...args), - overrideProps(current, props = this.context.props) { - if (!props) { - return current; - } - - const result = { ...current }; - - for (const key of Object.keys(result)) { - if (Object.hasOwn(props, key)) { - result[key] = props[key]; - } - } - - return result; - }, - pageLink: (pageRef, pageId, pageParams) => - host.encodePageHash(pageId, pageRef, pageParams), - marker: lookupObjectMarker, - markerAll: lookupObjectMarkerAll, - callAction, - actionHandler: (actionName, ...args) => host.action.has(actionName) - ? () => callAction(actionName, ...args) - : undefined - }; - let joraSetup = jora.setup({ methods: queryCustomMethods }); - - return Object.assign(host => Object.assign(host, { - objectMarkers, - linkResolvers, - resolveValueLinks, - annotations, - queryFnFromString: joraSetup - }), { methods: methods }); - - // - // Helpers - // - - function defineObjectMarker(name, options) { - const annotateScalars = Boolean(options?.annotateScalars); - const { page, mark, lookup } = objectMarkers.define(name, options) || {}; - - if (!lookup) { - return () => {}; - } - - if (page !== null) { - if (!host.page.isDefined(options.page)) { - host.log('error', `Page reference "${options.page}" doesn't exist`); - return; - } - - linkResolvers.push(value => { - const marker = lookup(value); - - if (marker !== null) { - return { - type: page, - text: marker.title, - href: marker.href, - entity: marker.object - }; - } - }); - } - - addValueAnnotation((value, context) => { - const marker = annotateScalars || (typeof value === 'object' && value !== null) - ? lookup(value) - : null; - - if (marker !== null && marker.object !== context.host) { - return { - place: 'before', - style: 'badge', - text: name, - href: marker.href - }; - } - }); - - return mark; - } - - function lookupObjectMarker(value, type) { - return objectMarkers.lookup(value, type); - } - - function lookupObjectMarkerAll(value) { - return objectMarkers.lookupAll(value); - } - - function addValueAnnotation(query, options = false) { - if (typeof options === 'boolean') { - options = { - debug: options - }; - } - - annotations.push({ - query, - ...options - }); - } - - function resolveValueLinks(value) { - const result = []; - const type = typeof value; - - if (value && (type === 'object' || type === 'string')) { - for (const resolver of linkResolvers) { - const link = resolver(value); - - if (link) { - result.push(link); - } - } - } - - return result.length ? result : null; - } - - function callAction(actionName, ...args) { - let callback = null; - - if (typeof args[args.length - 1] === 'function') { - callback = args.pop(); - } - - const ret = host.action.call(actionName, ...args); - - if (ret && callback && typeof ret.then === 'function') { - return ret.then(callback); - } - - return callback ? callback(ret) : ret; - } -} diff --git a/src/main/index.js b/src/main/index.js deleted file mode 100644 index 898e3bf0..00000000 --- a/src/main/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { Widget } from './widget.js'; -export { App } from './app.js'; diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 00000000..5f761349 --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,3 @@ +export * from './model.js'; +export * from './widget.js'; +export { App } from './app.js'; diff --git a/src/main/model-extension-api.ts b/src/main/model-extension-api.ts new file mode 100644 index 00000000..75932c2f --- /dev/null +++ b/src/main/model-extension-api.ts @@ -0,0 +1,99 @@ +import jora from 'jora'; +import type { Model, ModelOptions, PrepareContextApiWrapper, SetupMethods, SetupQueryMethodsExtension } from './model.js'; +import { ObjectMarkerConfig } from '../core/object-marker.js'; + +export function createExtensionApi(host: Model): PrepareContextApiWrapper { + return { + before() { + host.objectMarkers.reset(); + }, + contextApi: { + markers: host.objectMarkers.markerMap(), + rejectData(message: string, renderContent: any) { + throw Object.assign(new Error(message), { renderContent }); + } + } + }; +} + +export function setupModel(host: Model, setup: ModelOptions['setup']) { + const objectMarkers = host.objectMarkers; + const methods: SetupMethods = { + setPrepare: host.setPrepare.bind(host), + defineObjectMarker, + addQueryHelpers(helpers: SetupQueryMethodsExtension) { + queryCustomMethods = { + ...queryCustomMethods, + ...helpers + }; + } + }; + let queryCustomMethods = { + query: host.query.bind(host), + overrideProps, + pageLink: (pageRef, pageId, pageParams) => + host.encodePageHash(pageId, pageRef, pageParams), + marker: objectMarkers.lookup.bind(objectMarkers), + markerAll: objectMarkers.lookupAll.bind(objectMarkers), + callAction, + actionHandler: (actionName: string, ...args: unknown[]) => host.action.has(actionName) + ? () => callAction(actionName, ...args) + : undefined + }; + + if (typeof setup === 'function') { + setup(methods); + } + + objectMarkers.lock(); + + host.queryFnFromString = jora.setup({ methods: queryCustomMethods }); + + // + // Helpers + // + + function defineObjectMarker(name: string, options: ObjectMarkerConfig) { + const { mark, lookup } = objectMarkers.define(name, options); + + if (!lookup) { + return () => {}; + } + + return mark; + } + + function overrideProps(current: any, props = this.context.props) { + if (!props) { + return current; + } + + const result = { ...current }; + + for (const key of Object.keys(result)) { + if (Object.hasOwn(props, key)) { + result[key] = props[key]; + } + } + + return result; + } + + function callAction(actionName: string, ...args: unknown[]) { + const lastArg = args[args.length - 1]; + let callback: Function | null = null; + + if (typeof lastArg === 'function') { + callback = lastArg; + args.pop(); + } + + const ret: any = host.action.call(actionName, ...args); + + if (ret && callback && typeof ret.then === 'function') { + return ret.then(callback); + } + + return callback ? callback(ret) : ret; + } +} diff --git a/src/main/model-legacy-extension-api.ts b/src/main/model-legacy-extension-api.ts new file mode 100644 index 00000000..803a99e4 --- /dev/null +++ b/src/main/model-legacy-extension-api.ts @@ -0,0 +1,158 @@ +import jora from 'jora'; +import ObjectMarker, { ObjectMarkerConfig } from '../core/object-marker.js'; +import { LegacyPrepareContextApi, PrepareContextApiWrapper, Model, Query, SetupQueryMethodsExtension } from './model.js'; +import { ValueAnnotationContext, Widget } from './widget.js'; + +export function createLegacyExtensionApi(host: Model): PrepareContextApiWrapper { + const objectMarkers = new ObjectMarker(); + const linkResolvers: Model['linkResolvers'] = []; + const annotations: Widget['annotations'] = []; + const contextApi: LegacyPrepareContextApi = { + rejectData(message: string, renderContent: any) { + throw Object.assign(new Error(message), { renderContent }); + }, + defineObjectMarker, + lookupObjectMarker, + lookupObjectMarkerAll, + addValueAnnotation, + addQueryHelpers(helpers: SetupQueryMethodsExtension) { + joraSetup = jora.setup({ + methods: queryCustomMethods = { + ...queryCustomMethods, + ...helpers + } + }); + }, + query(query: Query, ...args: unknown[]) { + return host.queryFn.call({ queryFnFromString: joraSetup }, query)(...args); + } + }; + let queryCustomMethods = { + query: host.query.bind(host), + overrideProps, + pageLink: (pageRef, pageId, pageParams) => + host.encodePageHash(pageId, pageRef, pageParams), + marker: lookupObjectMarker, + markerAll: lookupObjectMarkerAll, + callAction, + actionHandler: (actionName: string, ...args: unknown[]) => host.action.has(actionName) + ? () => callAction(actionName, ...args) + : undefined + }; + let joraSetup = jora.setup({ methods: queryCustomMethods }); + + return { + contextApi, + after(host: Model) { + Object.assign(host, { + objectMarkers, + linkResolvers, + annotations, + queryFnFromString: joraSetup + }); + } + }; + + // + // Helpers + // + + function defineObjectMarker(name: string, options: ObjectMarkerConfig & { annotateScalars?: boolean }) { + const annotateScalars = Boolean(options?.annotateScalars); + const { page, mark, lookup } = objectMarkers.define(name, options) || {}; + + if (!lookup) { + return () => {}; + } + + if (page !== null) { + if (!(host as any).page?.isDefined(page)) { // FIXME: temporary solution + host.log('error', `Page reference "${page}" doesn't exist`); + return () => {}; + } + + linkResolvers.push((value: any) => { + const marker = lookup(value); + + return marker && { + type: page, + text: marker.title, + href: marker.href, + entity: marker.object + }; + }); + } + + addValueAnnotation((value: any, context: ValueAnnotationContext) => { + const marker = annotateScalars || (typeof value === 'object' && value !== null) + ? lookup(value) + : null; + + if (marker !== null && marker.object !== context.host) { + return { + place: 'before', + style: 'badge', + text: name, + href: marker.href + }; + } + }); + + return mark; + } + + function lookupObjectMarker(value: any, type?: string) { + return objectMarkers.lookup(value, type); + } + + function lookupObjectMarkerAll(value) { + return objectMarkers.lookupAll(value); + } + + function overrideProps(current: any, props = this.context.props) { + if (!props) { + return current; + } + + const result = { ...current }; + + for (const key of Object.keys(result)) { + if (Object.hasOwn(props, key)) { + result[key] = props[key]; + } + } + + return result; + } + + function addValueAnnotation(query: Query, options: object | boolean = false) { + if (typeof options === 'boolean') { + options = { + debug: options + }; + } + + annotations.push({ + query, + ...options + }); + } + + function callAction(actionName: string, ...args: unknown[]) { + const lastArg = args[args.length - 1]; + let callback: Function | null = null; + + if (typeof lastArg === 'function') { + callback = lastArg; + args.pop(); + } + + const ret: any = host.action.call(actionName, ...args); + + if (ret && callback && typeof ret.then === 'function') { + return ret.then(callback); + } + + return callback ? callback(ret) : ret; + } +} diff --git a/src/main/model.ts b/src/main/model.ts new file mode 100644 index 00000000..dfc0f629 --- /dev/null +++ b/src/main/model.ts @@ -0,0 +1,425 @@ +import jora from 'jora'; +import Emitter, { EventMap } from '../core/emitter.js'; +import ActionManager from '../core/action.js'; +import { normalizeEncodings } from '../core/encodings/utils.js'; +import { createExtensionApi, setupModel } from './model-extension-api.js'; +import ObjectMarkerManager, { ObjectMarker, ObjectMarkerConfig, ObjectMarkerDescriptor } from '../core/object-marker.js'; +import type { Dataset, Encoding, LoadDataBaseOptions, LoadDataFetchOptions, LoadDataState } from '../core/utils/load-data.types.js'; +import { querySuggestions } from './query-suggestions.js'; +import { loadDataFromEvent, loadDataFromFile, loadDataFromStream, loadDataFromUrl } from '../core/utils/load-data.js'; +import { createLegacyExtensionApi } from './model-legacy-extension-api.js'; + +export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'perf' | 'debug'; +export type LogOptions = { + level: LogLevel; + lazy?: () => any[]; + message?: string; + collapsed?: null | Iterable | (() => Iterable); +}; +export type ConsoleMethods = 'error' | 'warn' | 'info' | 'debug' | 'groupCollapsed' | 'groupEnd'; +export type ConsoleLike = { + [key in ConsoleMethods]: (...args: any[]) => void; +}; + +export type ExtensionFunction = (host: T) => void; +export type ExtensionArray = Extension[]; +export type ExtensionObject = { [key: string]: Extension; }; +export type Extension = ExtensionFunction | ExtensionArray | ExtensionObject; + +export type PageRef = string | number | null; +export type PageParams = Record | [string, unknown][] | string; +export type LinkResolver = (value: unknown) => null | ResolvedLink; +export type ResolvedLink = { + type: string; + text: string; + href: string | null; + entity: object; +}; + +export type Query = string | QueryFunction; +export type QueryFunction = (data: unknown, context: unknown) => unknown; + +export type RawDataDataset = { data: any }; +export type ModelDataset = Dataset | RawDataDataset; + +export interface SetDataOptions { + dataset?: Dataset; +} + +export type SetupMethods = { + setPrepare(fn: PrepareFunction): void; + defineObjectMarker(name: string, options: ObjectMarkerConfig): ObjectMarker['mark']; + addQueryHelpers(helpers: SetupQueryMethodsExtension): void; +}; +export type SetupQueryMethodsExtension = { + [key: string]: string | ((...args: unknown[]) => any); +}; + +export type PrepareFunction = (input: any, prepareContext: PrepareContextApi | LegacyPrepareContextApi) => any; +export type PrepareContextApiWrapper = { + before?(host: Model): void; + after?(host: Model): void; + contextApi: PrepareContextApi | LegacyPrepareContextApi; +}; +export interface PrepareContextApi { + rejectData: (message: string, extra: any) => void; + markers: Record void>; +} +export interface LegacyPrepareContextApi { + rejectData: (message: string, extra: any) => void; + defineObjectMarker(name: string, options: ObjectMarkerConfig): ObjectMarker['mark']; + lookupObjectMarker(value: any, type?: string): ObjectMarkerDescriptor | null; + lookupObjectMarkerAll(value: any): ObjectMarkerDescriptor[]; + addValueAnnotation(query: Query, options: object | boolean): void; + addQueryHelpers(helpers: SetupQueryMethodsExtension): void; + query(query: Query, ...args: unknown[]): any; +} + +export interface ModelEvents extends EventMap { + data: []; + unloadData: []; +} +export interface ModelOptions { + logger: ConsoleLike + logLevel: LogLevel; + extensions: Extension; + encodings: Encoding[]; + setup(api: SetupMethods): void; +} +type ModelOptionsBind = ModelOptions; // to fix: Type parameter 'Options' has a circular default. + +const noopQuery = () => void 0; +const noopLogger = new Proxy({}, { get: () => () => {} }); +const logLevels: LogLevel[] = ['silent', 'error', 'warn', 'info', 'perf', 'debug']; +const logPrefix = '[Discovery]'; + +const mixinEncodings = (host: Model, options?: LoadDataBaseOptions) => ({ + ...options, + encodings: Array.isArray(options?.encodings) + ? [...options.encodings, ...host.encodings] + : host.encodings +}); + +export class Model< + Options extends ModelOptions = ModelOptionsBind, + Events extends ModelEvents = ModelEvents +> extends Emitter { + options: Partial; + + logger: ConsoleLike; + logLevel: LogLevel; + + action: ActionManager; + objectMarkers: ObjectMarkerManager; + linkResolvers: LinkResolver[]; + + encodings: Encoding[]; + datasets: ModelDataset[]; + #currentSetData: Symbol | undefined; + data: any; + context: any; + prepare: PrepareFunction; + #legacyPrepare: boolean; + + constructor(options?: Partial) { + super(); + + this.options = options || {}; + + const { + logLevel = 'warn', + logger = console, + extensions, + setup + } = this.options; + + this.logger = logger || noopLogger; + this.logLevel = logLevels.includes(logLevel) ? logLevel : 'warn'; + + this.action = new ActionManager(); + this.objectMarkers = new ObjectMarkerManager(this.log.bind(this)); + this.linkResolvers = []; + + this.datasets = []; + this.encodings = normalizeEncodings(this.options.encodings); + this.data = undefined; + this.context = undefined; + this.prepare = data => data; + this.#legacyPrepare = true; + + this.apply(extensions); + + if (typeof setup === 'function') { + this.#legacyPrepare = false; + setupModel(this, setup); + } else { + setupModel(this, () => {}); + } + + for (const { page, lookup } of this.objectMarkers.values) { + if (page) { + this.linkResolvers.push((value: unknown) => { + const marker = lookup(value); + + return marker && { + type: page, + text: marker.title, + href: marker.href, + entity: marker.object + }; + }); + } + } + } + + // extension + apply(extensions?: Extension) { + if (Array.isArray(extensions)) { + extensions.forEach(extension => this.apply(extension)); + } else if (typeof extensions === 'function') { + extensions.call(null, this); + } else if (extensions) { + this.apply(Object.values(extensions)); + } + } + + // logging + log(levelOrOpts: LogOptions | LogLevel, ...args: unknown[]) { + const { + level, + lazy = null, + message = null, + collapsed = null + } = typeof levelOrOpts === 'object' && levelOrOpts !== null ? levelOrOpts : { level: levelOrOpts }; + const levelIndex = logLevels.indexOf(level); + + if (levelIndex > 0 && levelIndex <= logLevels.indexOf(this.logLevel)) { + const method = level === 'perf' ? 'log' : level; + + if (collapsed) { + this.logger.groupCollapsed(`${logPrefix} ${message ?? args[0]}`); + + const entries = typeof collapsed === 'function' ? collapsed() : collapsed; + for (const entry of Array.isArray(entries) ? entries : [entries]) { + this.logger[method](...Array.isArray(entry) ? entry : [entry]); + } + + this.logger.groupEnd(); + } else { + this.logger[method](logPrefix, ...typeof lazy === 'function' ? lazy() : args); + } + } else if (levelIndex === -1) { + this.logger.error(`${logPrefix} Bad log level "${level}", supported: ${logLevels.slice(1).join(', ')}`); + } + } + + // ========== + // Data + // + + setPrepare(fn: PrepareFunction) { + if (typeof fn !== 'function') { + throw new Error('An argument should be a function'); + } + + this.prepare = fn; + } + + setData(data: unknown, options: SetDataOptions) { + options = options || {}; + + // mark as last setData promise + const setDataMarker = Symbol(); + this.#currentSetData = setDataMarker; + + const startTime = Date.now(); + const checkIsNotPrevented = () => { + // prevent race conditions, perform only if this promise is last one + if (this.#currentSetData !== setDataMarker) { + throw new Error('Prevented by another setData()'); + } + }; + + const prepareApi = this.#legacyPrepare + ? createLegacyExtensionApi(this) + : createExtensionApi(this); + const setDataPromise = Promise.resolve() + .then(() => { + checkIsNotPrevented(); + + prepareApi.before?.(this); + return this.prepare.call(null, data, prepareApi.contextApi) || data; + }) + .then((data) => { + checkIsNotPrevented(); + + this.datasets = [{ ...options.dataset, data }]; + this.data = data; + + prepareApi.after?.(this); + + this.emit('data'); + this.log('perf', `Data prepared in ${Date.now() - startTime}ms`); + }); + + return setDataPromise; + } + + async trackLoadDataProgress(loadDataState: LoadDataState) { + const startTime = Date.now(); + const dataset = await loadDataState.result; + + this.log('perf', `Data loaded in ${Date.now() - startTime}ms`); + + return this.setData(dataset.data, { dataset }); + } + + loadDataFromStream(stream: ReadableStream, options?: LoadDataBaseOptions) { + return this.trackLoadDataProgress(loadDataFromStream(stream, mixinEncodings(this, options))); + } + + loadDataFromEvent(event: DragEvent | InputEvent, options?: LoadDataBaseOptions) { + return this.trackLoadDataProgress(loadDataFromEvent(event, mixinEncodings(this, options))); + } + + loadDataFromFile(file: File, options?: LoadDataBaseOptions) { + return this.trackLoadDataProgress(loadDataFromFile(file, mixinEncodings(this, options))); + } + + loadDataFromUrl(url: string, options?: LoadDataFetchOptions) { + return this.trackLoadDataProgress(loadDataFromUrl(url, mixinEncodings(this, options))); + } + + unloadData() { + if (!this.hasDatasets()) { + return; + } + + this.datasets = []; + this.data = undefined; + this.context = undefined; + + this.emit('unloadData'); + } + + hasDatasets() { + return this.datasets.length !== 0; + } + + // ====================== + // Data query + // + + getQueryEngineInfo() { + return { + name: 'jora', + version: jora.version, + link: 'https://discoveryjs.github.io/jora/#article:jora-syntax' + }; + } + + queryFnFromString(query: string): QueryFunction { + return noopQuery; + } + + queryFn(query: Query): QueryFunction { + switch (typeof query) { + case 'function': + return query; + + case 'string': + return this.queryFnFromString(query); + } + } + + query(query: any, data: unknown, context: unknown): unknown { + switch (typeof query) { + case 'function': + return query(data, context); + + case 'string': + return this.queryFn(query)(data, context); + + default: + return query; + } + } + + queryBool(query: any, data: unknown, context: unknown): boolean { + return jora.buildin.bool(this.query(query, data, context)); + } + + querySuggestions(query: any, offset: number, data: unknown, context: unknown) { + return querySuggestions(this, query, offset, data, context); + } + + pathToQuery(path: (string | number)[]): string { + return path.map((part, idx) => + part === '*' + ? (idx === 0 ? 'values()' : '.values()') + : typeof part === 'number' || !/^[a-zA-Z_][a-zA-Z_$0-9]*$/.test(part) + ? (idx === 0 ? `$[${JSON.stringify(part)}]` : `[${JSON.stringify(part)}]`) + : (idx === 0 ? part : '.' + part) + ).join(''); + } + + // ====================== + // Links + // + + encodePageHash(pageId: string, pageRef: PageRef = null, pageParams?: PageParams) { + let encodedParams = pageParams; + + if (encodedParams && typeof encodedParams !== 'string') { + if (!Array.isArray(encodedParams)) { + encodedParams = Object.entries(encodedParams); + } + + encodedParams = encodedParams + .map(pair => pair.map(encodeURIComponent).join('=')) + .join('&'); + } + + return `#${ + pageId ? encodeURIComponent(pageId) : '' + }${ + (typeof pageRef === 'string' && pageRef) || (typeof pageRef === 'number') ? ':' + encodeURIComponent(pageRef) : '' + }${ + encodedParams ? '&' + encodedParams : '' + }`; + } + + decodePageHash(hash: string) { + const delimIndex = (hash.indexOf('&') + 1 || hash.length + 1) - 1; + const [pageId, pageRef] = hash.substring(hash[0] === '#' ? 1 : 0, delimIndex).split(':').map(decodeURIComponent); + const pairs: [string, string | boolean][] = hash.slice(delimIndex + 1).split('&').filter(Boolean).map(pair => { + const eqIndex = pair.indexOf('='); + return eqIndex !== -1 + ? [decodeURIComponent(pair.slice(0, eqIndex)), decodeURIComponent(pair.slice(eqIndex + 1))] + : [decodeURIComponent(pair), true]; + }); + + return { + pageId, + pageRef, + pageParams: pairs + }; + } + + resolveValueLinks(value: unknown) { + const result: ResolvedLink[] = []; + const type = typeof value; + + if (value && (type === 'object' || type === 'string')) { + for (const resolver of this.linkResolvers) { + const link = resolver(value); + + if (link) { + result.push(link); + } + } + } + + return result.length ? result : null; + } +} diff --git a/src/main/widget.js b/src/main/widget.ts similarity index 55% rename from src/main/widget.js rename to src/main/widget.ts index f88f7d7b..1006d79e 100644 --- a/src/main/widget.js +++ b/src/main/widget.ts @@ -2,33 +2,42 @@ import jora from 'jora'; import { createElement } from '../core/utils/dom.js'; -import injectStyles from '../core/utils/inject-styles.js'; +import injectStyles, { Style } from '../core/utils/inject-styles.js'; import { deepEqual } from '../core/utils/compare.js'; -import { normalizeEncodings } from '../core/encodings/utils.js'; -import ActionManager from '../core/action.js'; -import { DarkModeController } from '../core/darkmode.js'; -import Emitter from '../core/emitter.js'; +import { DarkModeController, InitValue } from '../core/darkmode.js'; import PageRenderer from '../core/page.js'; -import ViewRenderer from '../core/view.js'; +import ViewRenderer, { SingleViewConfig } from '../core/view.js'; import PresetRenderer from '../core/preset.js'; import { Observer } from '../core/observer.js'; import inspector from '../extensions/inspector.js'; import * as views from '../views/index.js'; import * as pages from '../pages/index.js'; import { WidgetNavigation } from '../nav/index.js'; -import { createDataExtensionApi } from './data-extension-api.js'; -import { querySuggestions } from './query-suggestions.js'; - -const lastSetDataPromise = new WeakMap(); -const renderScheduler = new WeakMap(); -const logLevels = ['silent', 'error', 'warn', 'info', 'perf', 'debug']; -const logPrefix = '[Discovery]'; -const noopLogger = new Proxy({}, { get: () => () => {} }); - -const defaultEncodeParams = (params) => params; -const defaultDecodeParams = (pairs) => Object.fromEntries(pairs); - -function setDatasetValue(el, key, value) { +import { Model, ModelEvents, ModelOptions, PageParams, PageRef, Query, SetDataOptions } from './model.js'; +import { Dataset } from '../core/utils/load-data.types.js'; +import Progressbar from '../core/utils/progressbar.js'; + +export type RenderSubject = 'page' | 'sidebar'; +export type ValueAnnotation = + | ((value: unknown, context: ValueAnnotationContext) => any) + | { query: Query, [key: string]: any }; +export type ValueAnnotationContext = { + parent: ValueAnnotationContext | null; + host: any; + key: string | number; + index: number; +}; +export type SetDataProgressOptions = Partial<{ + dataset: Dataset; + progressbar: Progressbar; +}>; + +const renderScheduler = new WeakMap & { timer?: Promise | null }>(); + +const defaultEncodeParams = (params: [string, unknown][]) => params; +const defaultDecodeParams = (pairs: [string, unknown][]) => Object.fromEntries(pairs); + +function setDatasetValue(el: HTMLElement, key: string, value: any) { if (value) { el.dataset[key] = value; } else { @@ -36,15 +45,15 @@ function setDatasetValue(el, key, value) { } } -function getPageOption(host, pageId, name, fallback) { - const page = host.page.get(pageId); +function getPageOption(host: Widget, pageId: string, name: string, fallback: any) { + const options = host.page.get(pageId)?.options; - return page && Object.hasOwnProperty.call(page.options, name) - ? page.options[name] + return options !== undefined && Object.hasOwn(options, name) + ? options[name] : fallback; } -function getPageMethod(host, pageId, name, fallback) { +function getPageMethod(host: Widget, pageId: string, name: string, fallback: any) { const method = getPageOption(host, pageId, name, fallback); return typeof method === 'function' @@ -52,32 +61,85 @@ function getPageMethod(host, pageId, name, fallback) { : fallback; } -export class Widget extends Emitter { - constructor(options = {}) { - super(); +export interface WidgetEvents extends ModelEvents { + startSetData: [subscribe: (...args: Parameters) => void]; + pageHashChange: [replace: boolean]; +} +export interface WidgetOptions extends ModelOptions { + container: HTMLElement; + styles: Style[]; + + compact: boolean; + darkmode: InitValue; + darkmodePersistent: boolean; + + defaultPage: string; + defaultPageId: string; + discoveryPageId: string; + reportToDiscoveryRedirect: boolean; - this.options = options || {}; + inspector: boolean; +} +type WidgetOptionsBind = WidgetOptions; // to fix: Type parameter 'Options' has a circular default. + +export class Widget< + Options extends WidgetOptions = WidgetOptionsBind, + Events extends WidgetEvents = WidgetEvents +> extends Model { + darkmode: DarkModeController; + inspectMode: Observer; + + view: ViewRenderer; + nav: WidgetNavigation; + preset: PresetRenderer; + page: PageRenderer; + + annotations: ValueAnnotation[]; + + defaultPageId: string; + discoveryPageId: string; + reportToDiscoveryRedirect: boolean; // TODO: to make bookmarks work, remove sometime in the future + pageId: string; + pageRef: PageRef; + pageParams: Record; + pageHash: string; + + dom: { + ready: Promise; + wrapper: HTMLElement; + root: HTMLElement | ShadowRoot; + container: HTMLElement; + nav: HTMLElement; + sidebar: HTMLElement; + content: HTMLElement; + pageContent: HTMLElement; + detachDarkMode: null | (() => void); + }; + queryExtensions: Record; + + constructor(options: Partial) { const { + extensions, logLevel, - logger = console, darkmode = 'disabled', darkmodePersistent = false, defaultPage, defaultPageId, discoveryPageId, reportToDiscoveryRedirect = true, - extensions, inspector: useInspector = false - } = this.options; + } = options || {}; - this.logger = logger || noopLogger; - this.logLevel = logLevels.includes(logLevel) ? logLevel : 'perf'; + super({ + ...options, + logLevel: logLevel || 'perf', + extensions: undefined + }); this.darkmode = new DarkModeController(darkmode, darkmodePersistent); this.inspectMode = new Observer(false); this.initDom(); - this.action = new ActionManager(); this.action .on('define', () => { if (this.context) { @@ -106,12 +168,6 @@ export class Widget extends Emitter { }); renderScheduler.set(this, new Set()); - this.datasets = []; - this.encodings = normalizeEncodings(options.encodings); - this.data = undefined; - this.context = undefined; - this.prepare = data => data; - this.defaultPageId = defaultPageId || 'default'; this.discoveryPageId = discoveryPageId || 'discovery'; this.reportToDiscoveryRedirect = Boolean(reportToDiscoveryRedirect); // TODO: to make bookmarks work, remove sometime in the future @@ -119,14 +175,11 @@ export class Widget extends Emitter { this.pageRef = null; this.pageParams = {}; this.pageHash = this.encodePageHash(this.pageId, this.pageRef, this.pageParams); + this.annotations = []; - this.apply(createDataExtensionApi(this)); this.apply(views); this.apply(pages); - - if (extensions) { - this.apply(extensions); - } + this.apply(extensions); if (defaultPage) { this.page.define(this.defaultPageId, defaultPage); @@ -136,108 +189,59 @@ export class Widget extends Emitter { this.apply(inspector); } - this.nav.render(this.dom.nav, this.data, this.getRenderContext()); - this.setContainer(this.options.container); - } - - apply(extensions) { - if (Array.isArray(extensions)) { - extensions.forEach(extension => this.apply(extension)); - } else if (typeof extensions === 'function') { - extensions.call(null, this); - } else if (extensions) { - this.apply(Object.values(extensions)); - } - } - - log(levelOrOpts, ...args) { - const { level, lazy, message, collapsed } = levelOrOpts && typeof levelOrOpts === 'object' ? levelOrOpts : { level: levelOrOpts }; - const levelIndex = logLevels.indexOf(level); - - if (levelIndex > 0 && levelIndex <= logLevels.indexOf(this.logLevel)) { - const method = level === 'perf' ? 'log' : level; - - if (collapsed) { - this.logger.groupCollapsed(`${logPrefix} ${message || args?.[0]}`); + for (const { name, page, lookup } of this.objectMarkers.values) { + if (page && !this.page.isDefined(page)) { + this.log('error', `Page reference "${page}" in object marker "${name}" doesn't exist`); + } - const entries = typeof collapsed === 'function' ? collapsed() : collapsed; - for (const entry of Array.isArray(entries) ? entries : [entries]) { - this.logger[method](...Array.isArray(entry) ? entry : [entry]); + this.annotations.push((value: unknown, context: ValueAnnotationContext) => { + const marker = //annotateScalars || + (value !== null && typeof value === 'object') + ? lookup(value) + : null; + + if (marker !== null && marker.object !== context.host) { + return { + place: 'before', + style: 'badge', + text: name, + href: marker.href + }; } - - this.logger.groupEnd(); - } else { - this.logger[method](logPrefix, ...typeof lazy === 'function' ? lazy() : args); - } - } else if (levelIndex === -1) { - this.logger.error(`${logPrefix} Bad log level "${level}", supported: ${logLevels.slice(1).join(', ')}`); + }); } + + this.nav.render(this.dom.nav, this.data, this.getRenderContext()); + this.setContainer(this.options.container); } // // Data // - setPrepare(fn) { - if (typeof fn !== 'function') { - throw new Error('An argument should be a function'); - } - - this.prepare = fn; - } - - setData(data, context = {}, options) { + async setData(data: unknown, context: unknown, options?: SetDataOptions & { render?: boolean }) { options = options || {}; - const startTime = Date.now(); - const prepareExtension = createDataExtensionApi(this); - const checkIsNotPrevented = () => { - const lastPromise = lastSetDataPromise.get(this); + await super.setData(data, options); - // prevent race conditions, perform only if this promise is last one - if (lastPromise !== setDataPromise) { - throw new Error('Prevented by another setData()'); - } - }; - const setDataPromise = Promise.resolve() - .then(() => { - checkIsNotPrevented(); - - return this.prepare(data, prepareExtension.methods) || data; - }) - .then((data) => { - checkIsNotPrevented(); - - this.datasets = [{ ...options.dataset, data }]; - this.data = data; - this.context = context; - this.apply(prepareExtension); - - this.emit('data'); - this.log('perf', `Data prepared in ${Date.now() - startTime}ms`); - }); - - // mark as last setData promise - lastSetDataPromise.set(this, setDataPromise); + this.context = context || {}; // run after data is prepared and set if ('render' in options === false || options.render) { - setDataPromise.then(() => { - this.scheduleRender('sidebar'); - this.scheduleRender('page'); - }); + this.scheduleRender('sidebar'); + this.scheduleRender('page'); } - - return setDataPromise; } - async setDataProgress(data, context, options) { + async setDataProgress(data: unknown, context: unknown, options?: SetDataProgressOptions) { const { dataset, progressbar } = options || {}; - this.emit('startSetData', (...args) => progressbar?.subscribeSync(...args)); + this.emit('startSetData', (...args: Parameters) => + progressbar?.subscribeSync(...args) + ); // set new data & context await progressbar?.setState({ stage: 'prepare' }); @@ -252,72 +256,18 @@ export class Widget extends Emitter { this.scheduleRender('page'); await Promise.all([ this.dom.wrapper.parentNode ? this.dom.ready : true, - renderScheduler.get(this).timer + renderScheduler.get(this)?.timer ]); // finish progress await progressbar?.finish(); } - unloadData() { - if (!this.hasDatasets()) { - return; - } - - this.datasets = []; - this.data = undefined; - this.context = undefined; - - this.scheduleRender('sidebar'); - this.scheduleRender('page'); - - this.emit('unloadData'); - } - - hasDatasets() { - return this.datasets.length !== 0; - } - - // The method is overriding by createDataExtensionApi().apply() - resolveValueLinks() { - return null; - } - // // Data query // - queryFn(query) { - switch (typeof query) { - case 'function': - return query; - - case 'string': { - const fn = this.queryFnFromString(query); - fn.query = query; // FIXME: jora should add it for all kinds of queries - return fn; - } - } - } - - query(query, data, context) { - switch (typeof query) { - case 'function': - return query(data, context); - - case 'string': - return this.queryFn(query)(data, context); - - default: - return query; - } - } - - queryBool(...args) { - return jora.buildin.bool(this.query(...args)); - } - - queryToConfig(view, query) { + queryToConfig(view: string, query: string): SingleViewConfig { const { ast } = jora.syntax.parse(query); const config = { view }; @@ -334,7 +284,7 @@ export class Widget extends Emitter { throw new SyntaxError('[Discovery] Widget#queryToConfig(): unsupported object entry type "' + entry.type + '"'); } - let key; + let key: string; let value = entry.value; switch (entry.key.type) { case 'Literal': @@ -380,28 +330,6 @@ export class Widget extends Emitter { return config; } - querySuggestions(query, offset, data, context) { - return querySuggestions(this, query, offset, data, context); - } - - pathToQuery(path) { - return path.map((part, idx) => - part === '*' - ? (idx === 0 ? 'values()' : '.values()') - : typeof part === 'number' || !/^[a-zA-Z_][a-zA-Z_$0-9]*$/.test(part) - ? (idx === 0 ? `$[${JSON.stringify(part)}]` : `[${JSON.stringify(part)}]`) - : (idx === 0 ? part : '.' + part) - ).join(''); - } - - getQueryEngineInfo() { - return { - name: 'jora', - version: jora.version, - link: 'https://discoveryjs.github.io/jora/#article:jora-syntax' - }; - } - // // UI // @@ -411,25 +339,32 @@ export class Widget extends Emitter { const shadow = wrapper.attachShadow({ mode: 'open' }); const readyStyles = injectStyles(shadow, this.options.styles); const container = shadow.appendChild(createElement('div')); - - this.dom = {}; - this.dom.ready = Promise.all([readyStyles]); - this.dom.wrapper = wrapper; - this.dom.root = shadow; - this.dom.container = container; + const pageContent = createElement('article'); + const nav = createElement('div', 'discovery-nav discovery-hidden-in-dzen'); + const sidebar = createElement('nav', 'discovery-sidebar discovery-hidden-in-dzen'); + const content = createElement('main', 'discovery-content', [pageContent]); + + this.dom = { + ready: readyStyles, + wrapper, + root: shadow, + container, + nav, + sidebar, + content, + pageContent, + detachDarkMode: this.darkmode.subscribe( + dark => container.classList.toggle('discovery-root-darkmode', dark), + true + ) + }; container.classList.add('discovery-root', 'discovery'); - container.append( - this.dom.nav = createElement('div', 'discovery-nav discovery-hidden-in-dzen'), - this.dom.sidebar = createElement('nav', 'discovery-sidebar discovery-hidden-in-dzen'), - this.dom.content = createElement('main', 'discovery-content', [ - this.dom.pageContent = createElement('article') - ]) - ); + container.append(nav, sidebar, content); // TODO: use Navigation API when it become mature and wildly supported (https://developer.chrome.com/docs/web-platform/navigation-api/) shadow.addEventListener('click', (event) => { - const linkEl = event.target.closest('a'); + const linkEl = (event.target as HTMLElement)?.closest('a'); // do nothing when there is no in target's ancestors, or it's an external link if (!linkEl || linkEl.getAttribute('target')) { @@ -448,18 +383,14 @@ export class Widget extends Emitter { } }, true); - this.dom.detachDarkMode = this.darkmode.subscribe( - dark => container.classList.toggle('discovery-root-darkmode', dark), - true - ); this.dom.ready.then(() => { getComputedStyle(this.dom.wrapper).opacity; // trigger repaint this.dom.wrapper.classList.remove('init'); }); } - setContainer(container) { - if (container instanceof Node) { + setContainer(container?: HTMLElement) { + if (container instanceof HTMLElement) { container.append(this.dom.wrapper); } else { this.dom.wrapper.remove(); @@ -471,30 +402,39 @@ export class Widget extends Emitter { this.dom.detachDarkMode(); this.dom.detachDarkMode = null; } + this.dom.container.remove(); - this.dom = null; + this.dom = null as any; } - addGlobalEventListener(eventName, handler, options) { - document.addEventListener(eventName, handler, options); - return () => document.removeEventListener(eventName, handler, options); + addGlobalEventListener( + type: E, + listener: (e: DocumentEventMap[E]) => void, + options?: boolean | AddEventListenerOptions + ) { + document.addEventListener(type, listener, options); + return () => document.removeEventListener(type, listener, options); } - addHostElEventListener(eventName, handler, options) { + addHostElEventListener( + type: E, + listener: (e: HTMLElementEventMap[E]) => void, + options?: boolean | AddEventListenerOptions + ) { const el = this.dom.container; - el.addEventListener(eventName, handler, options); - return () => el.removeEventListener(eventName, handler, options); + el.addEventListener(type, listener, options); + return () => el.removeEventListener(type, listener, options); } // // Render common // - scheduleRender(subject) { + scheduleRender(subject: RenderSubject) { const scheduledRenders = renderScheduler.get(this); - if (scheduledRenders.has(subject)) { + if (scheduledRenders === undefined || scheduledRenders?.has(subject)) { return; } @@ -522,7 +462,7 @@ export class Widget extends Emitter { return scheduledRenders.timer; } - cancelScheduledRender(subject) { + cancelScheduledRender(subject?: RenderSubject) { const scheduledRenders = renderScheduler.get(this); if (scheduledRenders) { @@ -552,7 +492,7 @@ export class Widget extends Emitter { renderSidebar() { // cancel scheduled renderSidebar - renderScheduler.get(this).delete('sidebar'); + renderScheduler.get(this)?.delete('sidebar'); if (this.hasDatasets() && this.view.isDefined('sidebar')) { const renderStartTime = Date.now(); @@ -571,63 +511,46 @@ export class Widget extends Emitter { // Page // - encodePageHash(pageId, pageRef, pageParams) { + encodePageHash(pageId: string, pageRef: PageRef = null, pageParams?: PageParams) { + const encodedPageId = pageId || this.defaultPageId; const encodeParams = getPageMethod(this, pageId, 'encodeParams', defaultEncodeParams); - let encodedParams = encodeParams(pageParams || {}); - - if (encodedParams && typeof encodedParams !== 'string') { - if (!Array.isArray(encodedParams)) { - encodedParams = Object.entries(encodedParams); - } + let encodedParams: [string, any][] | string = encodeParams(pageParams || {}); - encodedParams = encodedParams - .map(pair => pair.map(encodeURIComponent).join('=')) - .join('&'); - } - - return `#${ - pageId !== this.defaultPageId ? encodeURIComponent(pageId) : '' - }${ - (typeof pageRef === 'string' && pageRef) || (typeof pageRef === 'number') ? ':' + encodeURIComponent(pageRef) : '' - }${ - encodedParams ? '&' + encodedParams : '' - }`; + return super.encodePageHash( + encodedPageId !== this.defaultPageId ? encodedPageId : '', + pageRef, + encodedParams + ); } - decodePageHash(hash) { - const delimIndex = (hash.indexOf('&') + 1 || hash.length + 1) - 1; - const [pageId, pageRef] = hash.substring(hash[0] === '#' ? 1 : 0, delimIndex).split(':').map(decodeURIComponent); - const decodeParams = getPageMethod(this, pageId || this.defaultPageId, 'decodeParams', defaultDecodeParams); - const pairs = hash.substr(delimIndex + 1).split('&').filter(Boolean).map(pair => { - const eqIndex = pair.indexOf('='); - return eqIndex !== -1 - ? [decodeURIComponent(pair.slice(0, eqIndex)), decodeURIComponent(pair.slice(eqIndex + 1))] - : [decodeURIComponent(pair), true]; - }); + decodePageHash(hash: string) { + const { pageId, pageRef, pageParams } = super.decodePageHash(hash); + const decodedPageId = pageId || this.defaultPageId; + const decodeParams = getPageMethod(this, decodedPageId, 'decodeParams', defaultDecodeParams); return { - pageId: pageId || this.defaultPageId, + pageId: decodedPageId, pageRef, - pageParams: decodeParams(pairs) + pageParams: decodeParams(pageParams) }; } - setPage(pageId, pageRef, pageParams, replace = false) { + setPage(pageId: string, pageRef: PageRef = null, pageParams?: PageParams, replace = false) { return this.setPageHash( this.encodePageHash(pageId || this.defaultPageId, pageRef, pageParams), replace ); } - setPageRef(pageRef, replace = false) { + setPageRef(pageRef: PageRef = null, replace = false) { return this.setPage(this.pageId, pageRef, this.pageParams, replace); } - setPageParams(pageParams, replace = false) { + setPageParams(pageParams: PageParams, replace = false) { return this.setPage(this.pageId, this.pageRef, pageParams, replace); } - setPageHash(hash, replace = false) { + setPageHash(hash: string, replace = false) { let { pageId, pageRef, pageParams } = this.decodePageHash(hash); // TODO: remove sometime in the future @@ -657,7 +580,7 @@ export class Widget extends Emitter { renderPage() { // cancel scheduled renderPage - renderScheduler.get(this).delete('page'); + renderScheduler.get(this)?.delete('page'); const data = this.data; const context = this.getRenderContext(); @@ -684,10 +607,10 @@ export class Widget extends Emitter { renderState.then(() => { if (this.pageParams['!anchor']) { - const el = pageEl.querySelector('#' + CSS.escape('!anchor:' + this.pageParams['!anchor'])); + const el: HTMLElement | null = pageEl.querySelector('#' + CSS.escape('!anchor:' + this.pageParams['!anchor'])); if (el) { - const pageHeaderEl = pageEl.querySelector('.view-page-header'); // TODO: remove, should be abstract + const pageHeaderEl: HTMLElement | null = pageEl.querySelector('.view-page-header'); // TODO: remove, should be abstract el.style.scrollMargin = pageHeaderEl ? pageHeaderEl.offsetHeight + 'px' : ''; el.scrollIntoView(true);