diff --git a/src/components/richEditorTransformErrorRecoveryExtension.test.ts b/src/components/richEditorTransformErrorRecoveryExtension.test.ts index 116bc91a..4a70be05 100644 --- a/src/components/richEditorTransformErrorRecoveryExtension.test.ts +++ b/src/components/richEditorTransformErrorRecoveryExtension.test.ts @@ -67,6 +67,9 @@ describe('isRecoverableEditorTransformError', () => { expect(isRecoverableEditorTransformError(new RangeError( 'Index 1 out of range for ', ))).toBe(true) + expect(isRecoverableEditorTransformError(new Error( + 'Block with ID 6c1c3bb4-e218-4f00-aaf5-40606852d286 not found', + ))).toBe(true) expect(isRecoverableEditorTransformError(new RangeError( 'Index 1 out of range for ', ))).toBe(false) @@ -122,6 +125,21 @@ describe('installRichEditorTransformErrorRecovery', () => { ) }) + it('recovers stale block-reference transactions from toolbar actions', () => { + const { currentDoc, view } = createView(new Error( + 'Block with ID 6c1c3bb4-e218-4f00-aaf5-40606852d286 not found', + )) + const recoverDocument = vi.fn() + + installRichEditorTransformErrorRecovery(view, { recoverDocument }) + + expect(() => view.dispatch({ before: currentDoc })).not.toThrow() + expect(recoverDocument).not.toHaveBeenCalled() + expect(trackEvent).toHaveBeenCalledWith('rich_editor_transform_error_recovered', { + reason: 'stale_block_reference', + }) + }) + it('keeps non-ProseMirror dispatch failures visible', () => { const { view } = createView(new Error('plugin failed')) diff --git a/src/components/richEditorTransformErrorRecoveryExtension.ts b/src/components/richEditorTransformErrorRecoveryExtension.ts index d92e2b5d..f0266bda 100644 --- a/src/components/richEditorTransformErrorRecoveryExtension.ts +++ b/src/components/richEditorTransformErrorRecoveryExtension.ts @@ -38,6 +38,7 @@ interface RepairableBlockNoteEditor { type RecoveryReason = | 'invalid_insertion_depth' | 'mismatched_transaction' + | 'stale_block_reference' | 'stale_transaction' | 'table_position_out_of_range' | 'transform_error' @@ -80,6 +81,10 @@ function isTablePositionOutOfRangeError(error: unknown): boolean { return error instanceof RangeError && /^Index \d+ out of range for predicate(error)) } function recoveryReason( @@ -101,6 +113,7 @@ function recoveryReason( ): RecoveryReason { if (transactionDocIsStale(transaction, view)) return 'stale_transaction' if (isMismatchedTransactionError(error)) return 'mismatched_transaction' + if (isStaleBlockReferenceError(error)) return 'stale_block_reference' if (isInvalidInsertionDepthError(error)) return 'invalid_insertion_depth' if (isTablePositionOutOfRangeError(error)) return 'table_position_out_of_range' return 'transform_error' diff --git a/src/components/tolariaBlockNoteSideMenu.test.tsx b/src/components/tolariaBlockNoteSideMenu.test.tsx index a8835ed8..7d45b483 100644 --- a/src/components/tolariaBlockNoteSideMenu.test.tsx +++ b/src/components/tolariaBlockNoteSideMenu.test.tsx @@ -367,6 +367,20 @@ describe('TolariaSideMenu', () => { }) }) + it('hides table header actions when the live block lookup throws after reload churn', () => { + const staleTable = { + id: 'table-block', + type: 'table', + content: { type: 'tableContent', rows: [], headerRows: undefined }, + } + mockEditor.getBlock.mockImplementation(() => { + throw staleBlockError(staleTable) + }) + + expect(() => renderSideMenuWithBlock(staleTable)).not.toThrow() + expect(screen.queryByText('Header row')).not.toBeInTheDocument() + }) + it('ignores stale drag starts after reload churn', () => { renderSideMenuWithBlock(sideMenuBlock) @@ -386,6 +400,18 @@ describe('TolariaSideMenu', () => { expect(mockEditor.insertBlocks).toHaveBeenCalledWith([draggedBlock], targetBlock.id, 'before') }) + it('ignores pointer reorders when a target block lookup throws after reload churn', () => { + const { draggedBlock, dragHandle, targetBlock } = renderPointerReorderFixture() + mockEditor.getBlock.mockImplementation((id: string) => { + if (id === targetBlock.id) throw staleBlockError(id) + return id === draggedBlock.id ? draggedBlock : undefined + }) + + expect(() => dispatchHandlePointerReorder(dragHandle)).not.toThrow() + expect(mockEditor.removeBlocks).not.toHaveBeenCalled() + expect(mockEditor.insertBlocks).not.toHaveBeenCalled() + }) + it('shows and clears pointer reorder affordances while dragging', () => { const { draggedElement, dragHandle } = renderPointerReorderFixture() diff --git a/src/components/tolariaBlockNoteSideMenu.tsx b/src/components/tolariaBlockNoteSideMenu.tsx index bb3ba718..86f600f5 100644 --- a/src/components/tolariaBlockNoteSideMenu.tsx +++ b/src/components/tolariaBlockNoteSideMenu.tsx @@ -25,6 +25,7 @@ import { type PointerEvent as ReactPointerEvent, type ReactNode, } from 'react' +import { isStaleBlockReferenceError } from './richEditorTransformErrorRecoveryExtension' type TolariaBlockNoteEditor = BlockNoteEditor type TolariaBlock = NonNullable> @@ -83,14 +84,26 @@ const SIDE_MENU_ALIGNMENT_ATTEMPTS = 8 function liveSideMenuBlock(editor: TolariaBlockNoteEditor, block: SideMenuBlock | undefined) { if (!block) return undefined - return editor.getBlock(block.id) + try { + return editor.getBlock(block.id) + } catch (error) { + if (isStaleBlockReferenceError(error)) { + console.warn('[editor] Ignored stale block side-menu lookup:', error) + return undefined + } + throw error + } } function runSideMenuAction(action: () => void) { try { action() } catch (error) { - console.warn('[editor] Ignored stale block side-menu action:', error) + if (isStaleBlockReferenceError(error)) { + console.warn('[editor] Ignored stale block side-menu action:', error) + return + } + throw error } } @@ -457,8 +470,8 @@ function validDropTarget({ const blockId = blockIdFromElement(targetElement) if (!blockId || blockId === state.draggedBlockId) return null - const draggedBlock = editor.getBlock(state.draggedBlockId) - const targetBlock = editor.getBlock(blockId) + const draggedBlock = liveSideMenuBlock(editor, { id: state.draggedBlockId, type: '' }) + const targetBlock = liveSideMenuBlock(editor, { id: blockId, type: '' }) if (!draggedBlock || !targetBlock || hasChildBlock(draggedBlock, blockId)) return null return { @@ -481,15 +494,15 @@ function moveBlockByPointerDrop({ }): boolean { if (draggedBlockId === targetBlockId) return false - const draggedBlock = editor.getBlock(draggedBlockId) - const targetBlock = editor.getBlock(targetBlockId) + const draggedBlock = liveSideMenuBlock(editor, { id: draggedBlockId, type: '' }) + const targetBlock = liveSideMenuBlock(editor, { id: targetBlockId, type: '' }) if (!draggedBlock || !targetBlock || hasChildBlock(draggedBlock, targetBlockId)) return false let moved = false editor.focus() editor.transact(() => { - const currentDraggedBlock = editor.getBlock(draggedBlockId) - const currentTargetBlock = editor.getBlock(targetBlockId) + const currentDraggedBlock = liveSideMenuBlock(editor, { id: draggedBlockId, type: '' }) + const currentTargetBlock = liveSideMenuBlock(editor, { id: targetBlockId, type: '' }) if (!currentDraggedBlock || !currentTargetBlock) return if (hasChildBlock(currentDraggedBlock, targetBlockId)) return diff --git a/src/components/tolariaEditorFormatting.behavior.test.tsx b/src/components/tolariaEditorFormatting.behavior.test.tsx index 9b43167d..cc8853be 100644 --- a/src/components/tolariaEditorFormatting.behavior.test.tsx +++ b/src/components/tolariaEditorFormatting.behavior.test.tsx @@ -79,6 +79,7 @@ vi.mock('@blocknote/react', () => ({ vi.mock('@blocknote/core', () => ({ blockHasType: blockHasTypeMock, + createExtension: (factory: unknown) => factory, defaultProps: { textAlignment: 'left' }, editorHasBlockWithType: editorHasBlockWithTypeMock, })) @@ -160,6 +161,7 @@ function createMockEditor(blockType = 'image', props: Record = domElement, focus: vi.fn(), getActiveStyles: () => ({ bold: true }), + getBlock: vi.fn((id: string) => (id === selectedBlock.id ? selectedBlock : undefined)), getSelection: () => ({ blocks: [selectedBlock] }), getTextCursorPosition: () => ({ block: selectedBlock }), toggleStyles: vi.fn(), @@ -192,11 +194,27 @@ describe('tolariaEditorFormatting behavior', () => { expect(editor.toggleStyles).toHaveBeenCalledWith({ code: true }) expect(editor.transact).toHaveBeenCalledTimes(1) expect(editor.updateBlock).toHaveBeenCalledWith( - expect.objectContaining({ id: 'file-block' }), + 'file-block', { type: 'heading', props: { level: 1 } }, ) }) + it('ignores stale block-type clicks when the selected block disappeared before the action', () => { + const editor = createMockEditor('paragraph') + editor.getBlock.mockImplementation(() => { + throw new Error('Block with ID file-block not found') + }) + useBlockNoteEditorMock.mockReturnValue(editor) + + render() + + expect(() => { + fireEvent.click(screen.getByRole('button', { name: 'Heading 1' })) + }).not.toThrow() + expect(editor.transact).not.toHaveBeenCalled() + expect(editor.updateBlock).not.toHaveBeenCalled() + }) + it('opens selected file blocks through the active vault path', () => { const editor = createMockEditor('file', { url: 'asset://localhost/%2Fvault%2Fattachments%2Freport.pdf', diff --git a/src/components/tolariaEditorFormatting.tsx b/src/components/tolariaEditorFormatting.tsx index 2d368225..4307711c 100644 --- a/src/components/tolariaEditorFormatting.tsx +++ b/src/components/tolariaEditorFormatting.tsx @@ -59,6 +59,10 @@ import { } from './tolariaEditorFormattingConfig' import { useBlockNoteFormattingToolbarHoverGuard } from './blockNoteFormattingToolbarHoverGuard' import { openEditorAttachmentOrUrl } from './editorAttachmentActions' +import { + isStaleBlockReferenceError, + reportRecoveredEditorTransformError, +} from './richEditorTransformErrorRecoveryExtension' type TolariaBasicTextStyle = 'bold' | 'italic' | 'strike' | 'code' @@ -359,6 +363,40 @@ function getSelectedFileBlockState( : null } +function reportStaleFormattingToolbarBlockReference(error: unknown) { + reportRecoveredEditorTransformError('stale_block_reference', error) +} + +function liveSelectedBlock( + editor: BlockNoteEditor, + block: TolariaSelectedBlock, +) { + try { + return editor.getBlock(block.id) as TolariaSelectedBlock | undefined + } catch (error) { + if (isStaleBlockReferenceError(error)) { + reportStaleFormattingToolbarBlockReference(error) + return undefined + } + throw error + } +} + +function liveSelectedBlocks( + editor: BlockNoteEditor, + selectedBlocks: TolariaSelectedBlock[], +) { + const liveBlocks: TolariaSelectedBlock[] = [] + + for (const block of selectedBlocks) { + const liveBlock = liveSelectedBlock(editor, block) + if (!liveBlock) return [] + liveBlocks.push(liveBlock) + } + + return liveBlocks +} + function fileDownloadTooltip(dict: unknown, blockType: string): string { const tooltip = (dict as { formatting_toolbar?: { @@ -383,15 +421,26 @@ function updateSelectedBlocksToType( selectedBlocks: TolariaSelectedBlock[], item: ReturnType[number], ) { - editor.focus() - editor.transact(() => { - for (const block of selectedBlocks) { - editor.updateBlock(block, { - type: item.type as never, - props: item.props as never, - }) + const blocks = liveSelectedBlocks(editor, selectedBlocks) + if (!blocks.length) return + + try { + editor.focus() + editor.transact(() => { + for (const block of blocks) { + editor.updateBlock(block.id, { + type: item.type as never, + props: item.props as never, + }) + } + }) + } catch (error) { + if (isStaleBlockReferenceError(error)) { + reportStaleFormattingToolbarBlockReference(error) + return } - }) + throw error + } } function TolariaBasicTextStyleButton({ diff --git a/src/lib/locales/be-BY.json b/src/lib/locales/be-BY.json index d76800ea..ac507e58 100644 --- a/src/lib/locales/be-BY.json +++ b/src/lib/locales/be-BY.json @@ -57,6 +57,7 @@ "command.git.commitPush": "Закаміціць і адправіць (Commit & Push)", "command.git.addRemote": "Дадаць аддалены сервер (Remote) да сховішча", "command.git.pull": "Атрымаць змены (Pull)", + "command.git.pullRepository": "Атрымаць з аддаленага рэпазіторыя: {repository}", "command.git.resolveConflicts": "Вырашыць канфлікты", "command.git.viewChanges": "Прагледзець чакаючыя змены", "git.repository.select": "Сховішча", diff --git a/src/lib/locales/be-Latn.json b/src/lib/locales/be-Latn.json index af193b95..f4dffa59 100644 --- a/src/lib/locales/be-Latn.json +++ b/src/lib/locales/be-Latn.json @@ -57,6 +57,7 @@ "command.git.commitPush": "Zakamicić i adpravić (Commit & Push)", "command.git.addRemote": "Dadać addalieny siervier (Remote) da schovišča", "command.git.pull": "Atrymać zmieny (Pull)", + "command.git.pullRepository": "Atrymać z addalienaha repazitoryja: {repository}", "command.git.resolveConflicts": "Vyrašyć kanflikty", "command.git.viewChanges": "Prahliedzieć čakajučyja zmieny", "git.repository.select": "Schovišča",