2026-05-13 07:21:37 +02:00
|
|
|
import { createExtension } from '@blocknote/core'
|
|
|
|
|
import { trackEvent } from '../lib/telemetry'
|
2026-05-17 03:06:49 +02:00
|
|
|
import { repairMalformedEditorBlocks } from '../hooks/editorBlockRepair'
|
2026-05-13 07:21:37 +02:00
|
|
|
|
|
|
|
|
const DISPATCH_RECOVERY_STATE_KEY = '__tolariaRichEditorTransformErrorRecovery'
|
|
|
|
|
|
|
|
|
|
type RichEditorDispatch = (transaction: unknown) => unknown
|
2026-05-17 03:06:49 +02:00
|
|
|
type RecoverEditorDocument = () => void
|
2026-05-17 19:15:55 +02:00
|
|
|
type RecoveryToken = symbol
|
2026-05-13 07:21:37 +02:00
|
|
|
|
|
|
|
|
interface RichEditorDispatchView {
|
|
|
|
|
dispatch: RichEditorDispatch
|
|
|
|
|
state?: {
|
|
|
|
|
doc?: {
|
|
|
|
|
eq?: (other: unknown) => boolean
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DispatchRecoveryState {
|
|
|
|
|
originalDispatch: RichEditorDispatch
|
2026-05-17 19:15:55 +02:00
|
|
|
recoverDocuments: Array<{
|
|
|
|
|
recoverDocument: RecoverEditorDocument
|
|
|
|
|
token: RecoveryToken
|
|
|
|
|
}>
|
2026-05-13 07:21:37 +02:00
|
|
|
refCount: number
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 03:06:49 +02:00
|
|
|
interface InstallRecoveryOptions {
|
|
|
|
|
recoverDocument?: RecoverEditorDocument
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RepairableBlockNoteEditor {
|
|
|
|
|
document?: unknown[]
|
|
|
|
|
replaceBlocks?: (currentBlocks: unknown[], nextBlocks: unknown[]) => unknown
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 15:28:35 +02:00
|
|
|
type RecoveryReason =
|
2026-05-23 16:11:20 +02:00
|
|
|
| 'invalid_insertion_depth'
|
2026-05-23 15:28:35 +02:00
|
|
|
| 'mismatched_transaction'
|
|
|
|
|
| 'stale_transaction'
|
|
|
|
|
| 'table_position_out_of_range'
|
|
|
|
|
| 'transform_error'
|
2026-05-13 07:21:37 +02:00
|
|
|
|
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
|
|
return typeof value === 'object' && value !== null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isDispatchRecoveryState(value: unknown): value is DispatchRecoveryState {
|
|
|
|
|
return isRecord(value)
|
|
|
|
|
&& typeof value.originalDispatch === 'function'
|
|
|
|
|
&& typeof value.refCount === 'number'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function transactionBefore(transaction: unknown): unknown {
|
|
|
|
|
return isRecord(transaction) ? transaction.before : undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function transactionDocIsStale(transaction: unknown, view: RichEditorDispatchView): boolean {
|
|
|
|
|
const before = transactionBefore(transaction)
|
|
|
|
|
const currentDoc = view.state?.doc
|
|
|
|
|
if (!before || !currentDoc || typeof currentDoc.eq !== 'function') return false
|
|
|
|
|
|
|
|
|
|
return !currentDoc.eq(before)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isMismatchedTransactionError(error: unknown): boolean {
|
|
|
|
|
return error instanceof Error && error.message.includes('Applying a mismatched transaction')
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 09:55:20 +02:00
|
|
|
function isInvalidContentTransactionError(error: unknown): boolean {
|
|
|
|
|
return error instanceof RangeError && error.message.startsWith('Invalid content for node ')
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 16:11:20 +02:00
|
|
|
function isInvalidInsertionDepthError(error: unknown): boolean {
|
|
|
|
|
return error instanceof RangeError && error.message.includes('Inserted content deeper than insertion position')
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 15:28:35 +02:00
|
|
|
function isTablePositionOutOfRangeError(error: unknown): boolean {
|
|
|
|
|
return error instanceof RangeError && /^Index \d+ out of range for <tableRow\(/.test(error.message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isTransformError(error: unknown): boolean {
|
|
|
|
|
return error instanceof Error && error.name === 'TransformError'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isRecoverableRangeError(error: unknown): boolean {
|
2026-05-23 16:11:20 +02:00
|
|
|
return isInvalidContentTransactionError(error)
|
|
|
|
|
|| isInvalidInsertionDepthError(error)
|
|
|
|
|
|| isTablePositionOutOfRangeError(error)
|
2026-05-23 15:28:35 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 07:21:37 +02:00
|
|
|
export function isRecoverableEditorTransformError(error: unknown): boolean {
|
2026-05-23 15:28:35 +02:00
|
|
|
return isTransformError(error) || isMismatchedTransactionError(error) || isRecoverableRangeError(error)
|
2026-05-13 07:21:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function recoveryReason(
|
|
|
|
|
error: unknown,
|
|
|
|
|
transaction: unknown,
|
|
|
|
|
view: RichEditorDispatchView,
|
|
|
|
|
): RecoveryReason {
|
|
|
|
|
if (transactionDocIsStale(transaction, view)) return 'stale_transaction'
|
|
|
|
|
if (isMismatchedTransactionError(error)) return 'mismatched_transaction'
|
2026-05-23 16:11:20 +02:00
|
|
|
if (isInvalidInsertionDepthError(error)) return 'invalid_insertion_depth'
|
2026-05-23 15:28:35 +02:00
|
|
|
if (isTablePositionOutOfRangeError(error)) return 'table_position_out_of_range'
|
2026-05-13 07:21:37 +02:00
|
|
|
return 'transform_error'
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 15:28:35 +02:00
|
|
|
function shouldRepairEditorDocument(error: unknown): boolean {
|
|
|
|
|
return isRecoverableRangeError(error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 07:21:37 +02:00
|
|
|
export function reportRecoveredEditorTransformError(reason: RecoveryReason, error: unknown): void {
|
|
|
|
|
console.warn('[editor] Recovered rich-editor transform error:', error)
|
|
|
|
|
trackEvent('rich_editor_transform_error_recovered', { reason })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function releaseRecoveryState(
|
|
|
|
|
view: RichEditorDispatchView,
|
|
|
|
|
recoveryState: DispatchRecoveryState,
|
|
|
|
|
originalDispatch: RichEditorDispatch,
|
2026-05-17 19:15:55 +02:00
|
|
|
token: RecoveryToken,
|
2026-05-13 07:21:37 +02:00
|
|
|
): void {
|
|
|
|
|
const state = Reflect.get(view, DISPATCH_RECOVERY_STATE_KEY)
|
|
|
|
|
if (!isDispatchRecoveryState(state) || state.originalDispatch !== originalDispatch) return
|
|
|
|
|
|
2026-05-17 19:15:55 +02:00
|
|
|
state.recoverDocuments = state.recoverDocuments.filter((entry) => entry.token !== token)
|
2026-05-13 07:21:37 +02:00
|
|
|
state.refCount -= 1
|
|
|
|
|
if (state.refCount > 0) return
|
|
|
|
|
|
|
|
|
|
view.dispatch = recoveryState.originalDispatch
|
|
|
|
|
Reflect.deleteProperty(view, DISPATCH_RECOVERY_STATE_KEY)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function retainRecoveryState(
|
|
|
|
|
view: RichEditorDispatchView,
|
|
|
|
|
recoveryState: DispatchRecoveryState,
|
2026-05-17 19:15:55 +02:00
|
|
|
token: RecoveryToken,
|
2026-05-17 03:06:49 +02:00
|
|
|
recoverDocument?: RecoverEditorDocument,
|
2026-05-13 07:21:37 +02:00
|
|
|
): () => void {
|
|
|
|
|
recoveryState.refCount += 1
|
2026-05-17 19:15:55 +02:00
|
|
|
if (recoverDocument) recoveryState.recoverDocuments.push({ recoverDocument, token })
|
|
|
|
|
return () => releaseRecoveryState(view, recoveryState, recoveryState.originalDispatch, token)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function activeRecoverDocument(recoveryState: DispatchRecoveryState): RecoverEditorDocument | undefined {
|
|
|
|
|
return recoveryState.recoverDocuments.at(-1)?.recoverDocument
|
2026-05-13 07:21:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createRecoveringDispatch(
|
|
|
|
|
view: RichEditorDispatchView,
|
2026-05-17 03:06:49 +02:00
|
|
|
recoveryState: DispatchRecoveryState,
|
2026-05-13 07:21:37 +02:00
|
|
|
): RichEditorDispatch {
|
|
|
|
|
return (transaction: unknown) => {
|
|
|
|
|
try {
|
2026-05-17 03:06:49 +02:00
|
|
|
return recoveryState.originalDispatch.call(view, transaction)
|
2026-05-13 07:21:37 +02:00
|
|
|
} catch (error) {
|
|
|
|
|
if (!isRecoverableEditorTransformError(error)) throw error
|
|
|
|
|
|
2026-05-23 15:28:35 +02:00
|
|
|
if (shouldRepairEditorDocument(error)) {
|
2026-05-17 19:15:55 +02:00
|
|
|
activeRecoverDocument(recoveryState)?.()
|
2026-05-17 03:06:49 +02:00
|
|
|
}
|
2026-05-13 07:21:37 +02:00
|
|
|
reportRecoveredEditorTransformError(recoveryReason(error, transaction, view), error)
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function installRecoveryState(
|
|
|
|
|
view: RichEditorDispatchView,
|
|
|
|
|
originalDispatch: RichEditorDispatch,
|
2026-05-17 19:15:55 +02:00
|
|
|
token: RecoveryToken,
|
2026-05-17 03:06:49 +02:00
|
|
|
recoverDocument?: RecoverEditorDocument,
|
2026-05-13 07:21:37 +02:00
|
|
|
): DispatchRecoveryState {
|
|
|
|
|
const recoveryState: DispatchRecoveryState = {
|
|
|
|
|
originalDispatch,
|
2026-05-17 19:15:55 +02:00
|
|
|
recoverDocuments: recoverDocument ? [{ recoverDocument, token }] : [],
|
2026-05-13 07:21:37 +02:00
|
|
|
refCount: 1,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 03:06:49 +02:00
|
|
|
view.dispatch = createRecoveringDispatch(view, recoveryState)
|
2026-05-13 07:21:37 +02:00
|
|
|
Reflect.set(view, DISPATCH_RECOVERY_STATE_KEY, recoveryState)
|
|
|
|
|
return recoveryState
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 03:06:49 +02:00
|
|
|
function repairEditorDocumentAfterInvalidContentError(editor: RepairableBlockNoteEditor): void {
|
|
|
|
|
if (!Array.isArray(editor.document) || typeof editor.replaceBlocks !== 'function') return
|
|
|
|
|
|
|
|
|
|
const currentBlocks = editor.document
|
|
|
|
|
const safeBlocks = repairMalformedEditorBlocks(currentBlocks)
|
|
|
|
|
if (safeBlocks === currentBlocks) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
editor.replaceBlocks(currentBlocks, safeBlocks)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('[editor] Failed to repair rich-editor document after transform error:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function installRichEditorTransformErrorRecovery(
|
|
|
|
|
view: RichEditorDispatchView,
|
|
|
|
|
options: InstallRecoveryOptions = {},
|
|
|
|
|
): () => void {
|
2026-05-17 19:15:55 +02:00
|
|
|
const token = Symbol('rich-editor-transform-error-recovery')
|
2026-05-13 07:21:37 +02:00
|
|
|
const currentState = Reflect.get(view, DISPATCH_RECOVERY_STATE_KEY)
|
|
|
|
|
if (isDispatchRecoveryState(currentState)) {
|
2026-05-17 19:15:55 +02:00
|
|
|
return retainRecoveryState(view, currentState, token, options.recoverDocument)
|
2026-05-13 07:21:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const originalDispatch = view.dispatch
|
2026-05-17 19:15:55 +02:00
|
|
|
const recoveryState = installRecoveryState(view, originalDispatch, token, options.recoverDocument)
|
2026-05-13 07:21:37 +02:00
|
|
|
|
2026-05-17 19:15:55 +02:00
|
|
|
return () => releaseRecoveryState(view, recoveryState, originalDispatch, token)
|
2026-05-13 07:21:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const createRichEditorTransformErrorRecoveryExtension = createExtension(({ editor }) => ({
|
|
|
|
|
key: 'richEditorTransformErrorRecovery',
|
|
|
|
|
mount: ({ signal }) => {
|
|
|
|
|
const view = editor._tiptapEditor?.view ?? editor.prosemirrorView
|
|
|
|
|
if (!view || typeof view.dispatch !== 'function') return
|
|
|
|
|
|
2026-05-17 03:06:49 +02:00
|
|
|
const uninstall = installRichEditorTransformErrorRecovery(
|
|
|
|
|
view as unknown as RichEditorDispatchView,
|
|
|
|
|
{ recoverDocument: () => repairEditorDocumentAfterInvalidContentError(editor as RepairableBlockNoteEditor) },
|
|
|
|
|
)
|
2026-05-13 07:21:37 +02:00
|
|
|
signal.addEventListener('abort', uninstall, { once: true })
|
|
|
|
|
},
|
|
|
|
|
} as const))
|