import type { Applog } from '@wovin/core/applog' import type { Thread, ThreadOnlyCurrentNoDeleted } from '@wovin/core/thread' import type { TiptapContent } from './TypeMap' import type { VMstatic } from './utils-typemap' import { lastWriteWins, queryDivergencesByPrev, rollingFilter } from '@wovin/core' import { action, autorun, computed, makeObservable, observable, Reaction, runInAction } from '@wovin/core/mobx' import { withoutDeleted } from '@wovin/core/query' import { Logger } from 'besonders-logger' import { debounce } from 'lodash-es' import { useBlockAt, useKidRelations, useKidVMs, usePlacementRelations, withDS } from '../../ui/reactive' import { getEditorForBlockID, persistBlockContent } from '../block-utils' import { compareBlockContent, getRegexForTagInContext, mapAndRecurseKids, replaceInTiptap, } from '../block-utils-nowin' import { BLOCK_DEF } from '../data-types' import { getAllTagsInText, parseBlockContentValue, tiptapToPlaintext, } from '../note3-utils-nodeps' import { parseSmartQuery } from '../smart-list' import { ObjectBuilder } from './builder' import { getMappedVMtoExtend, getUseFx } from './MappedVMbase' import { knownAtMap } from './utils-typemap' const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO) export const { Block: BLK } = knownAtMap const debounceWait = 1500 // Global registry of BlockVMs with pending content updates const pendingFlushes = new Set() export type Block = VMstatic<'Block'> export interface BlockVM extends Block {} // eslint-disable-line ts/no-unsafe-declaration-merging // export class BlkBuilder extends ObjectBuilder {} const BLOCK = 'Block' as const export class BlockVM extends getMappedVMtoExtend(BLOCK, undefined, ['content']) { public editingPos: number = null private editingBasedOn: Applog = null // ? Applog ? or rather pv/cid ? // observable _content: TiptapContent | null get editor() { return getEditorForBlockID(this.en) } get selection() { return this.editor?.view?.state?.selection } get selectionText() { return this.selection?.content?.content } get exists() { return !this.entityThread.isEmpty // HACK: use a query that's less expensive } get kidRelations() { return withDS(this.thread, () => useKidRelations(this.en)) } get kidVMs() { return withDS(this.thread, () => useKidVMs(this.en)) } get parentRelations() { return withDS(this.thread, () => usePlacementRelations(this.en)) } get currentRelation() { // HACK: BlockVM doesn't know about view context return this.parentRelations?.find(eachRel => eachRel.block === this.en) } persistContent = async (newContent: TiptapContent) => { return persistBlockContent(this.thread, this.en, newContent, this.editingBasedOn?.cid) } markNotEditing = () => { this.editingBasedOn = this.editingPos = null pendingFlushes.delete(this) } debouncedSetContent = debounce(this.persistContent.bind(this), debounceWait, { leading: false /* 'maxWait': 5000 */ }) // HACK re-enable maxWait (when block/content insert performance is not an issue anymore) debouncedMarkNoEdit = debounce(this.markNotEditing.bind(this), debounceWait + 100) get isEditing() { return !!this.editingBasedOn } get isEmpty() { return !this.contentPlaintext?.trim() && this.isText } get historicContentLogs() { return this.entityThread?.applogs?.filter(l => l.at === BLOCK_DEF.content) } get contentApplog() { return this.entityThread?.applogs?.findLast(l => l.at === BLOCK_DEF.content) // HACK: use divergence handling } get contentFromApplog() { return this.contentApplog?.vl as string } get content() { if (!this._content) { WARN('Empty _content?!', this) } return this._content // || withDS(this.thread, () => useBlockAt(this.en, 'content').get()) // HACK: unify with contentFromApplog // ? Not sure if we want that unified but i guess @manu has thoughts #51 } set content(newContent: TiptapContent) { if (this.thread.readOnly) throw ERROR(`[BlockVM.content] setter called but readOnly thread`, { en: this.en, newContent }) // if (`${newContent}` === 'undefined') WARN(`Setting undefined as content`, newContent) // HA CK: trying to catch a weird bug // we are assuming that the only time this setter will be called is during editing // thus we set the editingBasedOn prop if (this._content === newContent) { VERBOSE('BlockVM noop setting content', newContent) } else { this._content = newContent // optimistic ui, BlockVM debounces persist pendingFlushes.add(this) this.debouncedSetContent(newContent) // lazy atom writing - if you need to avoid optimistic ui and rely on observables use setContent directly this.editingBasedOn = this.contentApplog this.debouncedMarkNoEdit() // TODO: register beforeunload listener } } cancelDebouncedContent() { this.debouncedSetContent.cancel() this.debouncedMarkNoEdit.cancel() pendingFlushes.delete(this) } get contentDivergences() { const divergences = queryDivergencesByPrev( rollingFilter(this.entityThread, { at: BLOCK_DEF.content }), ) if (divergences.length <= 1) { return null } DEBUG('Found content divergence: ', divergences) return divergences satisfies readonly { log: Applog, thread: Thread }[] } get smartQuery() { const text = this.contentPlaintext // ? smartQuery separate from content if (!text) return null return parseSmartQuery(text) } // getSituationSituation(appThread) { // const currentBlockVM = useBlk(this.en, appThread) // TODO readonly // DEBUG('situation icon check VMs', { incomingBlockVM: this, currentBlockVM }) // DEBUG('situation icon check threads', { incomingThread: this.thread, currentThread: currentBlockVM.thread }) // const latestTsFromCurrent = currentBlockVM.contentApplog?.ts // const latestTsFromIncoming = this.contentApplog?.ts // DEBUG('situation icon check content TS', { // latestTsFromIncoming, // latestTsFromCurrent, // isCurrentNewer: latestTsFromCurrent > latestTsFromIncoming, // }) // return null // TODO: return actual result // } get contentPlaintext() { DEBUG(`[contentPlaintext] input:`, this.content) const text = tiptapToPlaintext(this.content) DEBUG(`[contentPlaintext] output:`, text) return text } getTagsIncludingSubtags(tag: string) { if (!this.contentPlaintext) return [] return getAllTagsInText(this.contentPlaintext, getRegexForTagInContext(tag, 'g')) } get tags() { return getAllTagsInText(this.contentPlaintext) } get isText() { return !this.type || this.type === 'text' } get isReply() { return this.currentRelation?.isReply } get isTodo() { if (!this.isText) return false return this.getTagsIncludingSubtags('+todo').length > 0 } get isTodoDone() { return this.getTagsIncludingSubtags('+todo/done').length > 0 } setTodoDone = action((newVal = true) => { const todoTags = Array.from(this.contentPlaintext.matchAll(getRegexForTagInContext(newVal ? '+todo' : '+todo/done', 'g'))) DEBUG(`[setTodoDone] matches`, todoTags) if (todoTags.length === 0) throw ERROR(`[setTodoDone] no todo tags`, { newVal, content: this.contentPlaintext }) if (todoTags.length > 1) WARN(`[setTodoDone] multiple todo tags`, { newVal, content: this.contentPlaintext, todoTags }) if (newVal && todoTags[0][0].includes('/done')) { WARN(`[setTodoDone] tag is already done`, { newVal, content: this.contentPlaintext, todoTags }) } const newContent = replaceInTiptap( this.content, // todoTags[0][0], newVal ? /\+todo(\/doing)?/ : /\+todo\/done/, newVal ? '+todo/done' : '+todo', ) this.content = newContent }) // async setDeleted(newisDeleted: boolean) { // persistBlockisDeleted(this.thread, this.en, newisDeleted) // } // get isDeleted() { // return this.entityThread.applogs.findLast(l => ['isDeleted', 'block/isDeleted'].includes(l.at)).vl // // HACK: handle old isDeleted properly // } // set isDeleted(newIsDeleted) { // if (this.isDeleted === newIsDeleted) { // VERBOSE('BlockVM noop setting isDeleted', newIsDeleted) // } else { // void this.setDeleted(newIsDeleted) // lazy atom writing - if you need to avoid optimistic ui and rely on observables use setisDeleted directly // } // } get recursiveKidCount() { const currentDS = withoutDeleted(lastWriteWins(this.thread)) as ThreadOnlyCurrentNoDeleted // HACK: how to do actual withHistory? return mapAndRecurseKids( currentDS, this.en, _kid => 1, (myOne, kidsCount) => myOne + kidsCount.reduce((a, b) => a + b, 0), ) } toggleSurvey = action(() => { const type: typeof this.type = this.type === 'survey' ? 'text' : 'survey' const update = { type } // TODO consider if seiralizing is needed this.buildUpdate(update).commit(this.thread) return type }) toggleSmartlist = action(() => { const type: typeof this.type = this.type === 'smartlist' ? 'text' : 'smartlist' const update = { type } // TODO consider if seiralizing is needed this.buildUpdate(update).commit(this.thread) return type }) initialize = action((instanceRef: InstanceType, thread: Thread) => { // TODO avoid re-initialization VERBOSE(`Init BlockVM`, instanceRef) const blockID = instanceRef.en if (!blockID) { throw ERROR(`Empty BlockID`, instanceRef) } // assertRaw(thread) - not actually required (only for history) // ? warn in functions that requre history? // if (instanceRef.entityThread.isEmpty) /* throw ERROR */ WARN(`[BlockVM] created for unknown ID:`, blockID) // HACK: using a Reaction manually because even with fireImmediately reaction(..) would wait until the current action is done // TODO: refactor to autorunButAlsoImmediately const reaction = new Reaction(`BlockVM#${instanceRef.en}.content`, (...args) => { VERBOSE(`[BlockVM#${instanceRef.en}] reaction.invalidate`, args) runAndTrack() }) let initialized = false function runAndTrack() { // HACK chicken and egg error here: reaction and runAndTrack both use eachother so i hoisted runAndTrack reaction.track(() => { let newContent = withDS(thread, () => useBlockAt(blockID, 'content').get() as string) if (newContent === undefined) newContent = null // no applog with block/content if (newContent !== null && typeof newContent !== 'string') { WARN(`[BlockVM] block/content is not null|string:`, newContent, { blockID }) } const parsedNew = parseBlockContentValue(newContent) const oldContent = initialized ? instanceRef.content : null VERBOSE(`[BlockVM#${blockID}] block/content ${initialized ? 'update' : 'init'}:`, parsedNew, { editing: instanceRef.editingBasedOn, old: oldContent, }) if (!instanceRef.editingBasedOn) { if (compareBlockContent(instanceRef._content, parsedNew)) { VERBOSE('skipping content update via reaction', { newContent, oldContent, isEditing: instanceRef.editingBasedOn, }) } else { runInAction(() => instanceRef._content = parsedNew) } } else if (!compareBlockContent(instanceRef._content, parsedNew)) { VERBOSE(`content update while isEditing`, { newContent, current: oldContent, isEditing: instanceRef.editingBasedOn }) } initialized = true }) } runAndTrack() // initial run if (!VERBOSE.isDisabled) { autorun(() => VERBOSE('entityThread appears to have changed', blockID, [...instanceRef.entityThread.applogs])) autorun(() => VERBOSE('content appears to have changed', blockID, instanceRef.content)) autorun(() => VERBOSE('contentFromApplog appears to have changed', blockID, [instanceRef.contentFromApplog])) } // I, manu, think _content needs to be made observable // I, gotjoshua, was hoping we don't need this, but alas observable prop of a class instance seems to be only possible like this makeObservable(instanceRef, { _content: observable.ref, // HACK: should do structural compare, but not deeply observable content: computed, // https://stackoverflow.com/a/68067250 contentDivergences: computed, contentPlaintext: computed, }) }) } /** * Flushes all pending block content updates by immediately executing their debounced functions. * Used before sync to ensure all local edits are persisted before syncing with remote. * Also called on page unload to prevent data loss. * * @returns Promise that resolves when all pending flushes complete */ export async function flushAllPendingBlockContent(): Promise { if (pendingFlushes.size === 0) { VERBOSE('[flushAllPendingBlockContent] no pending flushes') return } DEBUG(`[flushAllPendingBlockContent] flushing ${pendingFlushes.size} block(s)`) const flushPromises = Array.from(pendingFlushes).map((blockVM) => { return Promise.resolve().then(() => { try { // flush() triggers immediate execution of the debounced function blockVM.debouncedSetContent.flush() DEBUG(`[flushAllPendingBlockContent] flushed block ${blockVM.en}`) } catch (error) { ERROR(`[flushAllPendingBlockContent] error flushing block ${blockVM.en}:`, error) // Continue processing other blocks even if one fails return Promise.reject(error) } }) }) const results = await Promise.allSettled(flushPromises) const errors = results .filter(result => result.status === 'rejected') .map(result => (result as PromiseRejectedResult).reason) if (errors.length > 0) { ERROR(`[flushAllPendingBlockContent] ${errors.length} error(s) during flush`, errors) } else { LOG(`[flushAllPendingBlockContent] successfully flushed all pending blocks`) } } export const BlockBuilder = ObjectBuilder> // TODO: runtime/typebox checking is not actually bound here, just TS generics export const useBlk = getUseFx(BLOCK, BlockVM, BlockBuilder) if (typeof window !== 'undefined') (window as any).useBlk = useBlk // export function blkVMtest(vl = '5a650ede', ds?: Thread) { // ds = ds ?? useCurrentThread() ?? getApplogDB() // // vl = 'b3290703' //rel 'aefd2758' // kid: 'b3290703' //parent: 'f7e2bb3f' // const bVM = useBlk(vl) // const c = bVM.content // bVM.buildUpdate({ content: { content: `${bVM.content}changed` } }) // .commit() // const bVM2 = useBlk(vl) // autorun(() => DEBUG('[BlkVM testing]', bVM2.content, { bVM, bVM2, c, bB })) // }