UNPKG

@v4fire/client

Version:

V4Fire client core library

1,128 lines (948 loc) • 25.6 kB
/*! * V4Fire Client Core * https://github.com/V4Fire/Client * * Released under the MIT license * https://github.com/V4Fire/Client/blob/master/LICENSE */ /** * [[include:super/i-block/modules/sync/README.md]] * @packageDocumentation */ import { isProxy } from 'core/object/watch'; import { bindingRgxp, customWatcherRgxp, getPropertyInfo, PropertyInfo, SyncLinkCache } from 'core/component'; import type iBlock from 'super/i-block/i-block'; import { statuses } from 'super/i-block/const'; import Friend from 'super/i-block/modules/friend'; import type { LinkDecl, PropLinks, Link, LinkWrapper, ModValueConverter, AsyncWatchOptions } from 'super/i-block/modules/sync/interface'; export * from 'super/i-block/modules/sync/interface'; /** * Class provides API to organize a "link" from one component property to another */ export default class Sync extends Friend { /** * Cache of functions to synchronize modifiers */ readonly syncModCache!: Dictionary<Function>; /** @see [[iBlock.$syncLinkCache]] */ protected get syncLinkCache(): SyncLinkCache { return this.ctx.$syncLinkCache; } /** @see [[iBlock.$syncLinkCache]] */ protected set syncLinkCache(value: SyncLinkCache) { Object.set(this.ctx, '$syncLinkCache', value); } /** * Cache for links */ protected readonly linksCache!: Dictionary<Dictionary>; constructor(component: iBlock) { super(component); this.linksCache = Object.createDict(); this.syncLinkCache = new Map(); this.syncModCache = Object.createDict(); } /** * Sets a link to a property that logically connected to the current property. * * The link is mean every time a value by the link is changed or linked event is fired * a value that refers to the link will be also changed. * * Logical connection is based on a name convention: properties that match the pattern * "${property} -> ${property}Prop | ${property}Store -> ${property}Prop" * are connected with each other. * * Mind, this method can be used only within a property decorator. * * @param [optsOrWrapper] - additional options or a wrapper * * @example * ```typescript * @component() * class Foo extends iBlock { * @prop() * blaProp: number = 0; * * @field((ctx) => ctx.sync.link()) * bla!: number; * } * ``` */ link<D = unknown, R = D>(optsOrWrapper?: AsyncWatchOptions | LinkWrapper<this['C'], D, R>): CanUndef<R>; /** * Sets a link to a property that logically connected to the current property. * * The link is mean every time a value by the link is changed or linked event is fired * a value that refers to the link will be also changed. * * Logical connection is based on a name convention: * properties that match the pattern "${property} -> ${property}Prop" are connected with each other. * * Mind, this method can be used only within a property decorator. * * @param opts - additional options * @param [wrapper] * * @example * ```typescript * @component() * class Foo extends iBlock { * @prop() * blaProp: number = 0; * * @field((ctx) => ctx.sync.link({deep: true}, (val) => val + 1})) * bla!: number; * } * ``` */ link<D = unknown, R = D>(opts: AsyncWatchOptions, wrapper?: LinkWrapper<this['C'], D, R>): CanUndef<R>; /** * Sets a link to a component/object property or event by the specified path. * * The link is mean every time a value by the link is changed or linked event is fired * a value that refers to the link will be also changed. * * To listen an event you need to use the special delimiter ":" within a path. * Also, you can specify an event emitter to listen by writing a link before ":". * * @see [[iBlock.watch]] * @param path - path to a property/event that we are referring or * [path to a property that contains a link, path to a property/event that we are referring] * * @param [optsOrWrapper] - additional options or a wrapper * * @example * ```typescript * @component() * class Foo extends iBlock { * @prop() * bla: number = 0; * * @field((ctx) => ctx.sync.link('bla')) * baz!: number; * * @field((ctx) => ctx.sync.link({ctx: remoteObject, path: 'bla'})) * ban!: number; * } * ``` * * ```typescript * @component() * class Foo extends iBlock { * @prop() * bla: number = 0; * * @field() * baz!: number; * * @field() * ban!: number; * * created() { * this.baz = this.sync.link(['baz', 'bla']); * this.ban = this.sync.link(['ban', remoteObject]); * } * } * ``` */ link<D = unknown, R = D>( path: LinkDecl, optsOrWrapper?: AsyncWatchOptions | LinkWrapper<this['C'], D, R> ): CanUndef<R>; /** * Sets a link to a component/object property or event by the specified path. * * The link is mean every time a value by the link is changed or linked event is fired * a value that refers to the link will be also changed. * * To listen an event you need to use the special delimiter ":" within a path. * Also, you can specify an event emitter to listen by writing a link before ":". * * @see [[iBlock.watch]] * @param path - path to a property/event that we are referring or * [path to a property that contains a link, path to a property/event that we are referring] * * @param opts - additional options * @param [wrapper] * * @example * ```typescript * @component() * class Foo extends iBlock { * @prop() * bla: number = 0; * * @field((ctx) => ctx.sync.link('bla', {deep: true}, (val) => val + 1)) * baz!: number; * * @field((ctx) => ctx.sync.link({ctx: remoteObject, path: 'bla'}, {deep: true}, (val) => val + 1))) * ban!: number; * } * ``` * * ```typescript * @component() * class Foo extends iBlock { * @prop() * bla: number = 0; * * @field() * baz!: number; * * @field() * ban!: number; * * created() { * this.baz = this.sync.link(['baz', 'bla'], {deep: true}, (val) => val + 1)); * this.ban = this.sync.link(['ban', remoteObject], {deep: true}, (val) => val + 1)); * } * } * ``` */ link<D = unknown, R = D>( path: LinkDecl, opts: AsyncWatchOptions, wrapper?: LinkWrapper<this['C'], D, R> ): CanUndef<R>; link<D = unknown, R = D>( path?: LinkDecl | AsyncWatchOptions | LinkWrapper<this['C'], D>, opts?: AsyncWatchOptions | LinkWrapper<this['C'], D>, wrapper?: LinkWrapper<this['C'], D> ): CanUndef<R> { let destPath, resolvedPath: CanUndef<LinkDecl>; if (Object.isArray(path)) { destPath = path[0]; path = path[1]; } else { destPath = this.activeField; if (Object.isFunction(path)) { wrapper = path; path = undefined; } } if (Object.isFunction(opts)) { wrapper = opts; } if (destPath == null) { throw new Error('A path to the property that is contained a link is not defined'); } const { meta, ctx, linksCache, syncLinkCache } = this; if (linksCache[destPath] != null) { return; } let resolvedOpts: AsyncWatchOptions = {}; if (path == null) { resolvedPath = `${destPath.replace(bindingRgxp, '')}Prop`; } else if (Object.isString(path) || isProxy(path) || 'ctx' in path) { resolvedPath = path; } else if (Object.isDictionary(path)) { resolvedOpts = path; } if (Object.isDictionary(opts)) { resolvedOpts = opts; } if (resolvedPath == null) { throw new ReferenceError('A path or object to watch is not specified'); } let info, normalizedPath: CanUndef<ObjectPropertyPath>, topPathIndex = 1; let isMountedWatcher = false, isCustomWatcher = false; if (!Object.isString(resolvedPath)) { isMountedWatcher = true; if (isProxy(resolvedPath)) { info = {ctx: resolvedPath}; normalizedPath = undefined; } else { info = resolvedPath; normalizedPath = info.path; topPathIndex = 0; } } else { normalizedPath = resolvedPath; if (RegExp.test(customWatcherRgxp, normalizedPath)) { isCustomWatcher = true; } else { info = getPropertyInfo(normalizedPath, this.ctx); if (info.type === 'mounted') { isMountedWatcher = true; normalizedPath = info.path; topPathIndex = Object.size(info.path) > 0 ? 0 : 1; } } } const isAccessor = info != null ? Boolean(info.type === 'accessor' || info.type === 'computed' || info.accessor) : false; if (isAccessor) { resolvedOpts.immediate = resolvedOpts.immediate !== false; } if (!isCustomWatcher) { if ( normalizedPath != null && ( Object.isArray(normalizedPath) && normalizedPath.length > topPathIndex || Object.isString(normalizedPath) && normalizedPath.split('.', 2).length > topPathIndex ) ) { if (!resolvedOpts.deep && !resolvedOpts.collapse) { resolvedOpts.collapse = false; } } else if (resolvedOpts.deep !== false && resolvedOpts.collapse !== false) { resolvedOpts.deep = true; resolvedOpts.collapse = true; } } linksCache[destPath] = {}; const sync = (val?, oldVal?) => { const res = wrapper ? wrapper.call(this.component, val, oldVal) : val; this.field.set(destPath, res); return res; }; if (wrapper != null && (wrapper.length > 1 || wrapper['originalLength'] > 1)) { ctx.watch(info ?? normalizedPath, resolvedOpts, (val, oldVal, ...args) => { if (isCustomWatcher) { oldVal = undefined; } else { if (args.length === 0 && Object.isArray(val) && val.length > 0) { const mutation = <[unknown, unknown]>val[val.length - 1]; val = mutation[0]; oldVal = mutation[1]; } if (this.fastCompare(val, oldVal, destPath, resolvedOpts)) { return; } } sync(val, oldVal); }); } else { ctx.watch(info ?? normalizedPath, resolvedOpts, (val, ...args) => { let oldVal: unknown = undefined; if (!isCustomWatcher) { if (args.length === 0 && Object.isArray(val) && val.length > 0) { const mutation = <[unknown, unknown]>val[val.length - 1]; val = mutation[0]; oldVal = mutation[1]; } else { oldVal ??= args[0]; } if (this.fastCompare(val, oldVal, destPath, resolvedOpts)) { return; } } sync(val, oldVal); }); } { let key; if (isMountedWatcher) { const o = info?.originalPath; key = Object.isString(o) ? o : info?.ctx ?? normalizedPath; } else { key = normalizedPath; } syncLinkCache.set(key, Object.assign(syncLinkCache.get(key) ?? {}, { [destPath]: { path: destPath, sync } })); } if (isCustomWatcher) { return sync(); } const needCollapse = resolvedOpts.collapse !== false; if (isMountedWatcher) { const obj = info?.ctx; if (needCollapse || Object.size(normalizedPath) === 0) { return sync(obj); } return sync(Object.get(obj, normalizedPath!)); } const initSync = () => sync( this.field.get(needCollapse ? info.originalTopPath : info.originalPath) ); if (this.lfc.isBeforeCreate('beforeDataCreate')) { const name = '[[SYNC]]', hooks = meta.hooks.beforeDataCreate; let pos = 0; for (let i = 0; i < hooks.length; i++) { if (hooks[i].name === name) { pos = i + 1; } } hooks.splice(pos, 0, {fn: initSync, name}); return; } return initSync(); } /** * Creates an object where all keys are referring to another properties/events as links. * * The link is mean every time a value by the link is changed or linked event is fired * a value that refers to the link will be also changed. * * To listen an event you need to use the special delimiter ":" within a path. * Also, you can specify an event emitter to listen by writing a link before ":". * * Mind, this method can be used only within a property decorator. * * @see [[iBlock.watch]] * @param decl - declaration of object properties * * @example * ```typescript * @component() * class Foo extends iBlock { * @field() * bla: number = 0; * * @field() * bar: number = 0; * * @field() * bad: number = 0; * * @field((ctx) => ctx.sync.object([ * 'bla', * ['barAlias', 'bar'], * ['bad', String] * ])) * * baz!: {bla: number; barAlias: number; bad: string}}; * } * ``` */ object(decl: PropLinks): Dictionary; /** * Creates an object where all keys refer to another properties/events as links. * * The link is mean every time a value by the link is changed or linked event is fired * a value that refers to the link will be also changed. * * To listen an event you need to use the special delimiter ":" within a path. * Also, you can specify an event emitter to listen by writing a link before ":". * * Mind, this method can be used only within a property decorator. * * @see [[iBlock.watch]] * @param opts - additional options * @param fields - declaration of object properties * * @example * ```typescript * @component() * class Foo extends iBlock { * @field() * bla: number = 0; * * @field() * bar: number = 0; * * @field() * bad: number = 0; * * @field((ctx) => ctx.sync.object({deep: true}, [ * 'bla', * ['barAlias', 'bar'], * ['bad', String] * ])) * * baz!: {bla: number; barAlias: number; bad: string}}; * } * ``` */ object(opts: AsyncWatchOptions, fields: PropLinks): Dictionary; /** * Creates an object where all keys refer to another properties/events as links. * * The link is mean every time a value by the link is changed or linked event is fired * a value that refers to the link will be also changed. * * To listen an event you need to use the special delimiter ":" within a path. * Also, you can specify an event emitter to listen by writing a link before ":". * * @see [[iBlock.watch]] * @param path - path to a property that contains the result object * (if the method is used within a property decorator, this value will be concatenated to an active field name) * * @param fields - declaration of object properties * * @example * ```typescript * @component() * class Foo extends iBlock { * @field() * bla: number = 0; * * @field() * bar: number = 0; * * @field() * bad: number = 0; * * @field((ctx) => ctx.sync.object('links', [ * 'bla', * ['barAlias', 'bar'], * ['bad', String] * ])) * * baz: {links: {bla: number; barAlias: number; bad: string}}} * } * ``` */ // eslint-disable-next-line @typescript-eslint/unified-signatures object(path: Link, fields: PropLinks): Dictionary; /** * Creates an object where all keys refer to another properties/events as links. * * The link is mean every time a value by the link is changed or linked event is fired * a value that refers to the link will be also changed. * * To listen an event you need to use the special delimiter ":" within a path. * Also, you can specify an event emitter to listen by writing a link before ":". * * @see [[iBlock.watch]] * @param path - path to a property that contains the result object * (if the method is used within a property decorator, this value will be concatenated to an active field name) * * @param opts - additional options * @param fields - declaration of object properties * * @example * ```typescript * @component() * class Foo extends iBlock { * @field() * bla: number = 0; * * @field() * bar: number = 0; * * @field() * bad: number = 0; * * @field((ctx) => ctx.sync.object('links', {deep: true}, [ * 'bla', * ['barAlias', 'bar'], * ['bad', String] * ])) * * baz: {links: {bla: number; barAlias: number; bad: string}}} * } * ``` */ object( path: Link, opts: AsyncWatchOptions, fields: PropLinks ): Dictionary; object( path: Link | AsyncWatchOptions | PropLinks, opts?: AsyncWatchOptions | PropLinks, fields?: PropLinks ): Dictionary { if (Object.isString(path)) { if (Object.isArray(opts)) { fields = opts; opts = undefined; } } else { if (Object.isArray(path)) { fields = path; opts = undefined; } else { if (Object.isArray(opts)) { fields = opts; } opts = path; } path = ''; } let destHead = this.activeField; if (destHead != null) { if (Object.size(path) > 0) { path = [destHead, path].join('.'); } else { path = destHead; } } else { destHead = path.split('.', 1)[0]; } const localPath = path.split('.').slice(1); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (destHead == null) { throw new ReferenceError('A path to a property that is contained the final object is not defined'); } const { ctx, syncLinkCache, linksCache, meta: {hooks: {beforeDataCreate: hooks}} } = this; const resObj = {}; if (!Object.isArray(fields)) { return resObj; } if (localPath.length > 0) { Object.set(resObj, localPath, {}); } const cursor = Object.get<StrictDictionary>(resObj, localPath); const attachWatcher = (watchPath, destPath, getVal, wrapper?) => { Object.set(linksCache, destPath, true); let info, isMountedWatcher = false, isCustomWatcher = false, topPathIndex = 1; if (!Object.isString(watchPath)) { isMountedWatcher = true; if (isProxy(watchPath)) { info = {ctx: watchPath}; watchPath = undefined; } else { info = watchPath; watchPath = info.path; topPathIndex = 0; } } else if (RegExp.test(customWatcherRgxp, watchPath)) { isCustomWatcher = true; } else { info = getPropertyInfo(watchPath, this.ctx); if (info.type === 'mounted') { isMountedWatcher = true; watchPath = info.path; topPathIndex = Object.size(info.path) > 0 ? 0 : 1; } } const isAccessor = info != null ? Boolean(info.type === 'accessor' || info.type === 'computed' || info.accessor) : false; const sync = (val?, oldVal?, init?) => this.field.set(destPath, getVal(val, oldVal, init)), isolatedOpts = <AsyncWatchOptions>{...opts}; if (isAccessor) { isolatedOpts.immediate = isolatedOpts.immediate !== false; } if (!isCustomWatcher) { if ( watchPath != null && ( Object.isArray(watchPath) && watchPath.length > topPathIndex || Object.isString(watchPath) && watchPath.split('.', 2).length > topPathIndex ) ) { if (!isolatedOpts.deep && !isolatedOpts.collapse) { isolatedOpts.collapse = false; } } else if (isolatedOpts.deep !== false && isolatedOpts.collapse !== false) { isolatedOpts.deep = true; isolatedOpts.collapse = true; } } if (wrapper != null && (wrapper.length > 1 || wrapper['originalLength'] > 1)) { ctx.watch(info ?? watchPath, isolatedOpts, (val, oldVal, ...args) => { if (isCustomWatcher) { oldVal = undefined; } else { if (args.length === 0 && Object.isArray(val) && val.length > 0) { const mutation = <[unknown, unknown]>val[val.length - 1]; val = mutation[0]; oldVal = mutation[1]; } if (this.fastCompare(val, oldVal, destPath, isolatedOpts)) { return; } } sync(val, oldVal); }); } else { ctx.watch(info ?? watchPath, isolatedOpts, (val, ...args) => { let oldVal: unknown = undefined; if (!isCustomWatcher) { if (args.length === 0 && Object.isArray(val) && val.length > 0) { const mutation = <[unknown, unknown]>val[val.length - 1]; val = mutation[0]; oldVal = mutation[1]; } else { oldVal ??= args[0]; } if (this.fastCompare(val, oldVal, destPath, isolatedOpts)) { return; } } sync(val, oldVal); }); } { let key; if (isMountedWatcher) { const o = info?.originalPath; key = Object.isString(o) ? o : info?.ctx ?? watchPath; } else { key = watchPath; } syncLinkCache.set(key, Object.assign(syncLinkCache.get(key) ?? {}, { [destPath]: { path: destPath, sync } })); } if (isCustomWatcher) { return ['custom', isolatedOpts]; } if (isMountedWatcher) { return ['mounted', isolatedOpts, info]; } if (this.lfc.isBeforeCreate('beforeDataCreate')) { hooks.push({fn: () => sync(null, null, true)}); } return ['regular', isolatedOpts, info]; }; for (let i = 0; i < fields.length; i++) { const el = fields[i]; let type: string, opts: AsyncWatchOptions, info: PropertyInfo; const createGetVal = (watchPath, wrapper) => (val?, oldVal?, init?: boolean) => { if (init) { switch (type) { case 'regular': val = this.field.get(opts.collapse ? info.originalTopPath : watchPath); break; case 'mounted': { const obj = info.ctx; if (opts.collapse || Object.size(info.path) === 0) { val = obj; } else { val = Object.get(obj, info.path); } break; } default: val = undefined; break; } } if (wrapper == null) { return val; } return wrapper.call(this.component, val, oldVal); }; let wrapper, watchPath, savePath; if (Object.isArray(el)) { if (el.length === 3) { watchPath = el[1]; wrapper = el[2]; } else if (Object.isFunction(el[1])) { watchPath = el[0]; wrapper = el[1]; } else { watchPath = el[1]; } savePath = el[0]; } else { watchPath = el; savePath = el; } const destPath = [path, savePath].join('.'); if (Object.get(linksCache, destPath) == null) { const getVal = createGetVal(watchPath, wrapper); [type, opts, info] = attachWatcher(watchPath, destPath, getVal); Object.set(cursor, savePath, getVal(null, null, true)); } } this.field.set(path, cursor); return resObj; } /** * Synchronizes component link values with values they are linked * * @param path - path to a property/event that we are referring or * [path to a property that contains a link, path to a property/event that we are referring] * * @param [value] - value to synchronize links */ syncLinks(path?: LinkDecl, value?: unknown): void { let linkPath, storePath; if (Object.isArray(path)) { storePath = path[0]; linkPath = path[1]; } else { linkPath = path; } const cache = this.syncLinkCache; const sync = (linkName) => { const o = cache.get(linkName); if (o == null) { return; } for (let keys = Object.keys(o), i = 0; i < keys.length; i++) { const key = keys[i], el = o[key]; if (el == null) { continue; } if (storePath == null || key === storePath) { el.sync(value ?? this.field.get(linkName)); } } }; if (linkPath != null) { sync(linkPath); } else { for (let o = cache.keys(), el = o.next(); !el.done; el = o.next()) { sync(el.value); } } } /** * Binds a modifier to a property by the specified path * * @param modName * @param path * @param [converter] - converter function */ mod<D = unknown, R = unknown>( modName: string, path: string, converter?: ModValueConverter<this['C'], D, R> ): void; /** * Binds a modifier to a property by the specified path * * @param modName * @param path * @param opts - additional options * @param [converter] - converter function */ mod<D = unknown, R = unknown>( modName: string, path: string, opts: AsyncWatchOptions, converter?: ModValueConverter<this['C'], D, R> ): void; mod<D = unknown, R = unknown>( modName: string, path: string, optsOrConverter?: AsyncWatchOptions | ModValueConverter<this['C'], D, R>, converter: ModValueConverter<this['C'], D, R> = (v) => v != null ? Boolean(v) : undefined ): void { modName = modName.camelize(false); let opts; if (Object.isFunction(optsOrConverter)) { converter = optsOrConverter; } else { opts = optsOrConverter; } const {ctx} = this; const setWatcher = () => { const wrapper = (val, ...args) => { val = converter.call(this.component, val, ...args); if (val !== undefined) { void this.ctx.setMod(modName, val); } }; if (converter.length > 1) { ctx.watch(path, opts, (val, oldVal) => wrapper(val, oldVal)); } else { ctx.watch(path, opts, wrapper); } }; if (this.lfc.isBeforeCreate()) { const sync = () => { const v = converter.call(this.component, this.field.get(path)); if (v !== undefined) { ctx.mods[modName] = String(v); } }; this.syncModCache[modName] = sync; if (ctx.hook !== 'beforeDataCreate') { this.meta.hooks.beforeDataCreate.push({ fn: sync }); } else { sync(); } setWatcher(); } else if (statuses[ctx.componentStatus] >= 1) { setWatcher(); } } /** * Wrapper of `Object.fastCompare` to compare watchable values * * @param value * @param oldValue * @param destPath - path to the property * @param opts - watch options */ protected fastCompare( value: unknown, oldValue: unknown, destPath: string, opts: AsyncWatchOptions ): boolean { if (opts.collapse === false) { return value === oldValue; } return !opts.withProto && ( Object.fastCompare(value, oldValue) && Object.fastCompare(value, this.field.get(destPath)) ); } }