import { BlockFlavorKeys, BlockFlavors, BlockTypeKeys, BlockTypes, } from '../types'; import { getLogger } from '../utils'; import { BlockInstance, BlockListener, BlockPosition, ContentOperation, ContentTypes, HistoryManager, MapOperation, } from '../yjs/types'; declare const JWT_DEV: boolean; const logger = getLogger('BlockDB:block'); const logger_debug = getLogger('debug:BlockDB:block'); const _GET_BLOCK = Symbol('GET_BLOCK'); const _SET_PARENT = Symbol('SET_PARENT'); const _EMIT_EVENT = Symbol('EMIT_EVENT'); export class AbstractBlock< B extends BlockInstance, C extends ContentOperation > { private readonly _id: string; private readonly _block: BlockInstance; private readonly _history: HistoryManager; private readonly _root: AbstractBlock | undefined; private readonly _listeners: { cascade: Map; children: Map; content: Map; parent: Map; }; private _parent: AbstractBlock | undefined; private _changeParent?: () => void; constructor( block: B, root?: AbstractBlock, parent?: AbstractBlock ) { this._id = block.id; this._block = block; this._history = this._block.scopedHistory([this._id]); this._root = root; this._listeners = { cascade: new Map(), children: new Map(), content: new Map(), parent: new Map(), }; this._block.on('content', 'internal', states => this[_EMIT_EVENT]('content', states) ); this._block.on('children', 'internal', states => this[_EMIT_EVENT]('children', states) ); JWT_DEV && logger_debug(`init: exists ${this._id}`); if (parent) { this._refreshParent(parent); } } public get root() { return this._root; } protected get parent_node() { return this._parent; } protected _getParentPage(warning = true): string | undefined { if (this.flavor === 'page') { return this._block.id; } else if (!this._parent) { if (warning && this.flavor !== 'workspace') { console.warn('parent not found'); } return undefined; } else { return this._parent.parent_page; } } public get parent_page(): string | undefined { return this._getParentPage(); } public on( event: 'cascade' | 'content' | 'children' | 'parent', name: string, callback: BlockListener ) { this._listeners[event]?.set(name, callback); } public off( event: 'cascade' | 'content' | 'children' | 'parent', name: string ) { this._listeners[event]?.delete(name); } public addChildrenListener(name: string, listener: BlockListener) { this._block.addChildrenListener(name, listener); } public removeChildrenListener(name: string) { this._block.removeChildrenListener(name); } public addContentListener(name: string, listener: BlockListener) { this._block.addContentListener(name, listener); } public removeContentListener(name: string) { this._block.removeContentListener(name); } public getContent< T extends ContentTypes = ContentOperation >(): MapOperation { if (this._block.type === BlockTypes.block) { return this._block.content.asMap() as MapOperation; } throw new Error( `this block not a structured block: ${this._id}, ${this._block.type}` ); } public getBinary(): ArrayBuffer | undefined { if (this._block.type === BlockTypes.binary) { return this._block.content.asArray()?.get(0); } throw new Error('this block not a binary block'); } public get(path: string[]): R { const content = this.getContent(); return content.autoGet(content, path) as R; } public set(path: string[], value: V, partial?: boolean) { const content = this.getContent(); content.autoSet(content, path, value, partial); } private get_date_text(timestamp?: number): string | undefined { try { if (timestamp && !Number.isNaN(timestamp)) { return new Date(timestamp) .toISOString() .split('T')[0] ?.replace(/-/g, ''); } // eslint-disable-next-line no-empty } catch (e) {} return undefined; } // Last update UTC time public get lastUpdated(): number { return this._block.updated || this._block.created; } private get last_updated_date(): string | undefined { return this.get_date_text(this.lastUpdated); } // create UTC time public get created(): number { return this._block.created; } private get created_date(): string | undefined { return this.get_date_text(this.created); } // creator id public get creator(): string | undefined { return this._block.creator; } [_GET_BLOCK]() { return this._block; } private _emitParent( parentId: string, type: 'update' | 'delete' = 'update' ) { const states: Map = new Map([ [parentId, type], ]); this[_EMIT_EVENT]('parent', states); } [_EMIT_EVENT]( event: 'cascade' | 'content' | 'children' | 'parent', states: Parameters[0] ) { const listeners = this._listeners[event]; if (listeners) { for (const listener of listeners.values()) { listener(states); } } if (['children', 'content'].includes(event)) { this._parent?.[_EMIT_EVENT]('cascade', states); } } private _refreshParent(parent: AbstractBlock) { this._changeParent?.(); parent.addChildrenListener(this._id, states => { if (states.get(this._id) === 'delete') { this._emitParent(parent._id, 'delete'); } }); this._parent = parent; this._changeParent = () => parent.removeChildrenListener(this._id); } [_SET_PARENT](parent: AbstractBlock) { this._refreshParent(parent); this._emitParent(parent.id); } /** * Get document index tags */ public getTags(): string[] { const created = this.created_date; const updated = this.last_updated_date; return [ `id:${this._id}`, `type:${this.type}`, `type:${this.flavor}`, this.flavor === BlockFlavors.page && `type:doc`, // normal documentation this.flavor === BlockFlavors.tag && `type:card`, // tag document // this.type === ??? && `type:theorem`, // global marked math formula created && `created:${created}`, updated && `updated:${updated}`, ].filter((v): v is string => !!v); } /** * current document instance id */ public get id(): string { return this._id; } /** * current block type */ public get type(): typeof BlockTypes[BlockTypeKeys] { return this._block.type; } /** * current block flavor */ public get flavor(): typeof BlockFlavors[BlockFlavorKeys] { return this._block.flavor; } // TODO: flavor needs optimization setFlavor(flavor: typeof BlockFlavors[BlockFlavorKeys]) { this._block.setFlavor(flavor); } public get children(): string[] { return this._block.children; } /** * Insert children block * @param block Block instance * @param position Insertion position, if it is empty, it will be inserted at the end. If the block already exists, the position will be moved * @returns */ public async insertChildren( block: AbstractBlock, position?: BlockPosition ) { JWT_DEV && logger(`insertChildren: start`); if (block.id === this._id) { // avoid self-reference return; } if ( this.type !== BlockTypes.block || // binary cannot insert children blocks (block.type !== BlockTypes.block && this.flavor !== BlockFlavors.workspace) // binary can only be inserted into workspace ) { throw new Error('insertChildren: binary not allow insert children'); } this._block.insertChildren(block[_GET_BLOCK](), position); block[_SET_PARENT](this); } public hasChildren(id: string): boolean { return this._block.hasChildren(id); } /** * Get an instance of the child Block * @param blockId block id * @returns */ protected get_children(blockId?: string): BlockInstance[] { JWT_DEV && logger(`get children: ${blockId}`); return this._block.getChildren([blockId]); } public removeChildren(blockId?: string) { this._block.removeChildren([blockId]); } public remove() { JWT_DEV && logger(`remove: ${this.id}`); if (this.flavor !== BlockFlavors.workspace) { // Pages other than workspace have parents this.parent_node!.removeChildren(this.id); } } public update(path: string[], value: Record) { this.set(path, value); } private insert_blocks( parentNode: AbstractBlock | undefined, blocks: AbstractBlock[], placement: 'before' | 'after', referenceNode?: AbstractBlock ) { if (!blocks || blocks.length === 0 || !parentNode) { return; } // TODO: array equal if ( !referenceNode && parentNode.children.join('') === blocks.map(node => node.id).join('') ) { return; } blocks.forEach(block => { if (block.parent_node) { block.remove(); } const placement_info = { [placement]: referenceNode?.id || (parentNode.hasChildNodes() && parentNode.children[ placement === 'before' ? 0 : parentNode.children.length - 1 ]), }; parentNode.insertChildren( block, placement_info[placement] ? placement_info : undefined ); }); } prepend(...blocks: AbstractBlock[]) { this.insert_blocks(this, blocks.reverse(), 'before'); } append(...blocks: AbstractBlock[]) { this.insert_blocks(this, blocks, 'after'); } before(...blocks: AbstractBlock[]) { this.insert_blocks(this.parent_node, blocks, 'before', this); } after(...blocks: AbstractBlock[]) { this.insert_blocks(this.parent_node, blocks.reverse(), 'after', this); } hasChildNodes() { return this.children.length > 0; } hasParent(blockId?: string) { let parent = this.parent_node; while (parent) { if (parent.id === blockId) { return true; } parent = parent.parent_node; } return false; } /** * TODO: scoped history */ public get history(): HistoryManager { return this._history; } }