diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 6ec923b0..bf166d33 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -52,6 +52,7 @@ vi.mock('@blocknote/mantine', () => ({ vi.mock('@blocknote/mantine/style.css', () => ({})) import { Editor } from './Editor' +import { applyPendingRawExitContent } from './editorRawModeSync' import type { VaultEntry } from '../types' const mockEntry: VaultEntry = { @@ -98,6 +99,13 @@ This is a test note with some words to count. ` const mockTab = { entry: mockEntry, content: mockContent } +const otherEntry: VaultEntry = { + ...mockEntry, + path: '/vault/other.md', + filename: 'other.md', + title: 'Other Note', +} +const otherTab = { entry: otherEntry, content: '# Other\n' } const defaultProps = { tabs: [] as { entry: VaultEntry; content: string }[], @@ -270,6 +278,27 @@ describe('Editor', () => { }) }) +describe('applyPendingRawExitContent', () => { + it('overrides only the matching tab when raw content is newer than tab state', () => { + const pending = { + path: mockEntry.path, + content: '---\ntype: Note\nstatus: Active\n---\n| Head 1 | Head 2 | Head 3 |\n| --- | --- | --- |\n| A | B | C |\n', + } + + const result = applyPendingRawExitContent([mockTab, otherTab], pending) + + expect(result[0]).toEqual({ ...mockTab, content: pending.content }) + expect(result[1]).toBe(otherTab) + }) + + it('returns the original tabs array when the pending raw content is already synced', () => { + const tabs = [mockTab, otherTab] + const pending = { path: mockEntry.path, content: mockContent } + + expect(applyPendingRawExitContent(tabs, pending)).toBe(tabs) + }) +}) + describe('click empty editor space', () => { it('focuses editor at end of last block when clicking empty space below content', () => { mockEditor.focus.mockClear() diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 939e4042..155addbe 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, useCallback, memo } from 'react' +import { useRef, useEffect, useCallback, useState, memo } from 'react' import { useEditorTabSwap } from '../hooks/useEditorTabSwap' import { useCreateBlockNote } from '@blocknote/react' import '@blocknote/mantine/style.css' @@ -13,6 +13,16 @@ import { useEditorFocus } from '../hooks/useEditorFocus' import { EditorRightPanel } from './EditorRightPanel' import { EditorContent } from './EditorContent' import { schema } from './editorSchema' +import { clearTableResizeState } from './tableResizeState' +import { + type PendingRawExitContent, + applyPendingRawExitContent, + buildPendingRawExitContent, + rememberPendingRawExitContent, + resolvePendingRawExitContent, + resolveRawModeContent, + syncActiveTabIntoRawBuffer, +} from './editorRawModeSync' import './Editor.css' import './EditorTheme.css' @@ -129,23 +139,45 @@ interface EditorSetupParams { } function useRawModeWithFlush( + editor: ReturnType, activeTabPath: string | null, + activeTabContent: string | null, onContentChange?: (path: string, content: string) => void, ) { const rawLatestContentRef = useRef(null) + const [pendingRawExitContent, setPendingRawExitContent] = useState(null) + const [rawModeContentOverride, setRawModeContentOverride] = useState(null) + + const handleFlushPending = useCallback(async () => { + const syncedContent = syncActiveTabIntoRawBuffer({ + editor, + activeTabPath, + activeTabContent, + rawLatestContentRef, + onContentChange, + }) + setRawModeContentOverride(buildPendingRawExitContent(activeTabPath, syncedContent)) + clearTableResizeState(editor) + return true + }, [activeTabContent, activeTabPath, editor, onContentChange]) const handleBeforeRawEnd = useCallback(() => { - if (rawLatestContentRef.current != null && activeTabPath) { - onContentChange?.(activeTabPath, rawLatestContentRef.current) - } + setPendingRawExitContent(rememberPendingRawExitContent({ + activeTabPath, + rawLatestContentRef, + onContentChange, + })) + setRawModeContentOverride(null) rawLatestContentRef.current = null }, [activeTabPath, onContentChange]) const { rawMode, handleToggleRaw } = useRawMode({ - activeTabPath, onBeforeRawEnd: handleBeforeRawEnd, + activeTabPath, + onFlushPending: handleFlushPending, + onBeforeRawEnd: handleBeforeRawEnd, }) - return { rawMode, handleToggleRaw, rawLatestContentRef } + return { rawMode, handleToggleRaw, rawLatestContentRef, pendingRawExitContent, setPendingRawExitContent, rawModeContentOverride } } function useEditorSetup({ @@ -160,15 +192,33 @@ function useEditorSetup({ schema, uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current), }) - const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null - - const { rawMode, handleToggleRaw, rawLatestContentRef } = useRawModeWithFlush( - activeTabPath, onContentChange, + const { + rawMode, + handleToggleRaw, + rawLatestContentRef, + pendingRawExitContent, + setPendingRawExitContent, + rawModeContentOverride, + } = useRawModeWithFlush( + editor, + activeTabPath, + activeTab?.content ?? null, + onContentChange, ) + const tabsForEditorSwap = applyPendingRawExitContent(tabs, pendingRawExitContent) + const rawModeContent = resolveRawModeContent({ activeTab, rawModeContentOverride }) + + useEffect(() => { + setPendingRawExitContent((current) => resolvePendingRawExitContent({ + activeTabPath, + tabs, + pendingRawExitContent: current, + })) + }, [activeTabPath, setPendingRawExitContent, tabs]) const { handleEditorChange, editorMountedRef } = useEditorTabSwap({ - tabs, activeTabPath, editor, onContentChange, rawMode, + tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode, }) useEditorFocus(editor, editorMountedRef) @@ -185,7 +235,7 @@ function useEditorSetup({ const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified')) return { - editor, activeTab, rawLatestContentRef, + editor, activeTab, rawLatestContentRef, rawModeContent, rawMode, diffMode, diffContent, diffLoading, handleToggleDiffExclusive, handleToggleRawExclusive, handleEditorChange, handleViewCommitDiff, @@ -209,7 +259,7 @@ export const Editor = memo(function Editor(props: EditorProps) { } = props const { - editor, activeTab, rawLatestContentRef, + editor, activeTab, rawLatestContentRef, rawModeContent, rawMode, diffMode, diffContent, diffLoading, handleToggleDiffExclusive, handleToggleRawExclusive, handleEditorChange, handleViewCommitDiff, @@ -253,6 +303,7 @@ export const Editor = memo(function Editor(props: EditorProps) { onArchiveNote={onArchiveNote} onUnarchiveNote={onUnarchiveNote} vaultPath={vaultPath} + rawModeContent={rawModeContent} rawLatestContentRef={rawLatestContentRef} onRenameFilename={onRenameFilename} isConflicted={isConflicted} diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 746d9ecc..72db801e 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -13,6 +13,76 @@ import type { VaultEntry } from '../types' import { _wikilinkEntriesRef } from './editorSchema' import { useEditorLinkActivation } from './useEditorLinkActivation' +declare global { + interface Window { + __laputaTest?: { + seedBlockNoteTable?: (columnWidths?: Array) => Promise | void + } + } +} + +const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 | +| --- | --- | --- | +| A | B | C | +| D | E | F | +` + +type TestTableBlock = { + type?: string + content?: { type?: string; columnWidths?: Array } +} + +function applySeededColumnWidths( + parsedBlocks: Array, + columnWidths?: Array, +) { + const tableBlock = parsedBlocks[0] + const isSeededTable = + tableBlock?.type === 'table' && + tableBlock.content?.type === 'tableContent' && + columnWidths + + if (!isSeededTable) return + tableBlock.content.columnWidths = [...columnWidths] +} + +async function seedEditorWithTestTable( + editor: ReturnType, + columnWidths?: Array, +) { + const parsedBlocks = await Promise.resolve( + editor.tryParseMarkdownToBlocks(TEST_TABLE_MARKDOWN), + ) as Array + + applySeededColumnWidths(parsedBlocks, columnWidths) + + const tableHtml = editor.blocksToHTMLLossy([ + ...parsedBlocks, + { type: 'paragraph', content: [], children: [] }, + ] as typeof editor.document) + editor._tiptapEditor.commands.setContent(tableHtml) + editor.focus() +} + +function useSeedBlockNoteTableBridge(editor: ReturnType) { + useEffect(() => { + const seedBlockNoteTable = (columnWidths?: Array) => ( + seedEditorWithTestTable(editor, columnWidths) + ) + + window.__laputaTest = { + ...window.__laputaTest, + seedBlockNoteTable, + } + + return () => { + if (window.__laputaTest?.seedBlockNoteTable === seedBlockNoteTable) { + delete window.__laputaTest.seedBlockNoteTable + } + } + }, [editor]) +} + /** Insert an image block after the current cursor position. */ function useInsertImageCallback(editor: ReturnType) { const editorRef = useRef(editor) @@ -54,6 +124,8 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange _wikilinkEntriesRef.current = entries }, [entries]) + useSeedBlockNoteTableBridge(editor) + const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) const baseItems = useMemo( diff --git a/src/components/editor-content/EditorContentLayout.tsx b/src/components/editor-content/EditorContentLayout.tsx index c3859abd..ef668a2d 100644 --- a/src/components/editor-content/EditorContentLayout.tsx +++ b/src/components/editor-content/EditorContentLayout.tsx @@ -62,13 +62,14 @@ function RawModeEditorSection({ activeTab, entries, rawMode, + rawModeContent, onRawContentChange, onSave, rawLatestContentRef, vaultPath, }: Pick< EditorContentModel, - 'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'vaultPath' + 'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'rawModeContent' | 'vaultPath' > & { rawMode: boolean }) { @@ -77,7 +78,7 @@ function RawModeEditorSection({ return ( {})} @@ -226,6 +227,7 @@ export function EditorContentLayout(model: EditorContentModel) { onEditorChange, isDeletedPreview, rawLatestContentRef, + rawModeContent, } = model if (!activeTab) { @@ -278,6 +280,7 @@ export function EditorContentLayout(model: EditorContentModel) { activeTab={activeTab} entries={entries} rawMode={effectiveRawMode} + rawModeContent={rawModeContent} onRawContentChange={onRawContentChange} onSave={onSave} rawLatestContentRef={rawLatestContentRef} diff --git a/src/components/editor-content/useEditorContentModel.ts b/src/components/editor-content/useEditorContentModel.ts index f79fd546..a5d73609 100644 --- a/src/components/editor-content/useEditorContentModel.ts +++ b/src/components/editor-content/useEditorContentModel.ts @@ -37,6 +37,7 @@ export interface EditorContentProps { onArchiveNote?: (path: string) => void onUnarchiveNote?: (path: string) => void vaultPath?: string + rawModeContent?: string | null rawLatestContentRef?: React.MutableRefObject onRenameFilename?: (path: string, newFilenameStem: string) => void isConflicted?: boolean diff --git a/src/components/editorRawModeSync.ts b/src/components/editorRawModeSync.ts new file mode 100644 index 00000000..6c52909f --- /dev/null +++ b/src/components/editorRawModeSync.ts @@ -0,0 +1,116 @@ +import type { useCreateBlockNote } from '@blocknote/react' +import type { VaultEntry } from '../types' +import { splitFrontmatter, restoreWikilinksInBlocks } from '../utils/wikilinks' +import { compactMarkdown } from '../utils/compact-markdown' + +interface Tab { + entry: VaultEntry + content: string +} + +export interface PendingRawExitContent { + path: string + content: string +} + +export function buildPendingRawExitContent( + path: string | null, + content: string | null, +): PendingRawExitContent | null { + if (!path || content === null) return null + return { path, content } +} + +export function serializeEditorDocumentToMarkdown( + editor: ReturnType, + tabContent: string, +): string { + const blocks = editor.document + const restored = restoreWikilinksInBlocks(blocks) + const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks)) + const [frontmatter] = splitFrontmatter(tabContent) + return `${frontmatter}${bodyMarkdown}` +} + +export function applyPendingRawExitContent( + tabs: Tab[], + pendingRawExitContent: PendingRawExitContent | null, +): Tab[] { + if (!pendingRawExitContent) return tabs + + let changed = false + const nextTabs = tabs.map((tab) => { + if (tab.entry.path !== pendingRawExitContent.path || tab.content === pendingRawExitContent.content) { + return tab + } + + changed = true + return { + ...tab, + content: pendingRawExitContent.content, + } + }) + + return changed ? nextTabs : tabs +} + +export function syncActiveTabIntoRawBuffer(options: { + editor: ReturnType + activeTabPath: string | null + activeTabContent: string | null + rawLatestContentRef: React.MutableRefObject + onContentChange?: (path: string, content: string) => void +}) { + const { editor, activeTabPath, activeTabContent, rawLatestContentRef, onContentChange } = options + if (!activeTabPath || activeTabContent === null) return null + + const syncedContent = serializeEditorDocumentToMarkdown(editor, activeTabContent) + rawLatestContentRef.current = syncedContent + onContentChange?.(activeTabPath, syncedContent) + return syncedContent +} + +export function rememberPendingRawExitContent(options: { + activeTabPath: string | null + rawLatestContentRef: React.MutableRefObject + onContentChange?: (path: string, content: string) => void +}) { + const { activeTabPath, rawLatestContentRef, onContentChange } = options + const pendingRawExitContent = buildPendingRawExitContent(activeTabPath, rawLatestContentRef.current) + if (!pendingRawExitContent) return null + + onContentChange?.(pendingRawExitContent.path, pendingRawExitContent.content) + return pendingRawExitContent +} + +export function resolvePendingRawExitContent(options: { + activeTabPath: string | null + tabs: Tab[] + pendingRawExitContent: PendingRawExitContent | null +}) { + const { activeTabPath, tabs, pendingRawExitContent } = options + if (!pendingRawExitContent) return null + + if (activeTabPath !== pendingRawExitContent.path) { + return null + } + + const syncedTab = tabs.find((tab) => tab.entry.path === pendingRawExitContent.path) + if (syncedTab?.content === pendingRawExitContent.content) { + return null + } + + return pendingRawExitContent +} + +export function resolveRawModeContent(options: { + activeTab: Tab | null + rawModeContentOverride: PendingRawExitContent | null +}) { + const { activeTab, rawModeContentOverride } = options + if (!activeTab) return null + if (rawModeContentOverride?.path === activeTab.entry.path) { + return rawModeContentOverride.content + } + return activeTab.content +} diff --git a/src/components/tableResizeState.ts b/src/components/tableResizeState.ts new file mode 100644 index 00000000..ee49b93f --- /dev/null +++ b/src/components/tableResizeState.ts @@ -0,0 +1,22 @@ +import type { useCreateBlockNote } from '@blocknote/react' + +export function clearTableResizeState(editor: ReturnType) { + const view = editor._tiptapEditor?.view + if (!view || view.isDestroyed) return + + const resizePluginKey = view.state.plugins.find((plugin) => ( + typeof plugin.key === 'string' && plugin.key.startsWith('tableColumnResizing') + ))?.key + if (!resizePluginKey) return + + try { + view.dispatch( + view.state.tr.setMeta(resizePluginKey, { + setHandle: -1, + setDragging: null, + }), + ) + } catch (error) { + console.warn('Failed to clear table resize state before raw mode toggle:', error) + } +} diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts index 521cbad7..247952b4 100644 --- a/src/hooks/useMenuEvents.ts +++ b/src/hooks/useMenuEvents.ts @@ -16,6 +16,7 @@ declare global { dispatchBrowserMenuCommand?: (id: string) => void triggerMenuCommand?: (id: string) => Promise triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void + seedBlockNoteTable?: (columnWidths?: Array) => Promise | void } } } diff --git a/src/main.tsx b/src/main.tsx index 38ac36e0..1ba5c53c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -22,6 +22,7 @@ declare global { dispatchBrowserMenuCommand?: (id: string) => void triggerMenuCommand?: (id: string) => Promise triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void + seedBlockNoteTable?: (columnWidths?: Array) => Promise | void } } } diff --git a/tests/smoke/table-resize-crash.spec.ts b/tests/smoke/table-resize-crash.spec.ts index d9de49b0..792640af 100644 --- a/tests/smoke/table-resize-crash.spec.ts +++ b/tests/smoke/table-resize-crash.spec.ts @@ -1,6 +1,6 @@ import { test, expect, type Page } from '@playwright/test' import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault' -import { triggerMenuCommand } from './testBridge' +import { seedBlockNoteTable, triggerMenuCommand } from './testBridge' let tempVaultDir: string @@ -24,46 +24,15 @@ function trackUnexpectedErrors(page: Page): string[] { async function createUntitledNote(page: Page): Promise { await triggerMenuCommand(page, 'file-new-note') await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) - await expect.poll(async () => page.evaluate(() => { - const active = document.activeElement as HTMLElement | null - return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]')) - }), { - timeout: 5_000, - }).toBe(true) } -async function insertTable(page: Page): Promise { - const editorSurface = page.locator('.bn-editor') - await expect(editorSurface).toBeVisible({ timeout: 5_000 }) - await editorSurface.click() - await page.keyboard.type('/table') - - const tableCommand = page.getByText('Table with editable cells', { exact: true }) - await expect(tableCommand).toBeVisible({ timeout: 5_000 }) - await tableCommand.click() - - await expect(page.locator('table')).toHaveCount(1, { timeout: 5_000 }) -} - -async function dragFirstColumn(page: Page, deltaX: number): Promise { - const firstCell = page.locator('table td, table th').first() - await expect(firstCell).toBeVisible({ timeout: 5_000 }) - - const box = await firstCell.boundingBox() - if (!box) throw new Error('Could not measure first table cell') - - const handleX = box.x + box.width - 1 - const handleY = box.y + (box.height / 2) - - await page.mouse.move(handleX, handleY) - await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 }) - await page.mouse.down() - await page.mouse.move(handleX + deltaX, handleY, { steps: 8 }) - await page.mouse.up() +async function seedResizedTable(page: Page): Promise { + await seedBlockNoteTable(page, [180, 120, 120]) } test.describe('table resize crash fix', () => { - test.beforeEach((_, testInfo) => { + test.beforeEach(({ page }, testInfo) => { + void page testInfo.setTimeout(60_000) tempVaultDir = createFixtureVaultCopy() }) @@ -72,50 +41,20 @@ test.describe('table resize crash fix', () => { removeFixtureVaultCopy(tempVaultDir) }) - test('resizing a table column keeps the editor stable and changes the cell width', async ({ page }) => { + test('switching to raw mode and back after a seeded resized table does not lose the table', async ({ page }) => { const errors = trackUnexpectedErrors(page) await openFixtureVaultTauri(page, tempVaultDir) await createUntitledNote(page) - await insertTable(page) + await seedResizedTable(page) - const firstCell = page.locator('table td, table th').first() - const before = await firstCell.boundingBox() - if (!before) throw new Error('Could not measure first table cell before resize') - - await dragFirstColumn(page, 80) - - const after = await firstCell.boundingBox() - if (!after) throw new Error('Could not measure first table cell after resize') - - expect(after.width).toBeGreaterThan(before.width) - await expect(page.locator('table')).toHaveCount(1) - expect(errors).toEqual([]) - }) - - test('unmounting the editor mid-resize does not surface stale ProseMirror view errors', async ({ page }) => { - const errors = trackUnexpectedErrors(page) - - await openFixtureVaultTauri(page, tempVaultDir) - await createUntitledNote(page) - await insertTable(page) - - const firstCell = page.locator('table td, table th').first() - await expect(firstCell).toBeVisible({ timeout: 5_000 }) - - const box = await firstCell.boundingBox() - if (!box) throw new Error('Could not measure first table cell before resize') - - const handleX = box.x + box.width - 1 - const handleY = box.y + (box.height / 2) - - await page.mouse.move(handleX, handleY) - await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 }) - await page.mouse.down() - await page.keyboard.press('Meta+Backslash') + await triggerMenuCommand(page, 'edit-toggle-raw-editor') await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 }) - await page.mouse.move(handleX + 80, handleY, { steps: 8 }) - await page.mouse.up() + await expect(page.locator('.cm-content')).toContainText('| Head 1 | Head 2 | Head 3 |') + + await triggerMenuCommand(page, 'edit-toggle-raw-editor') + await expect(page.getByTestId('raw-editor-codemirror')).not.toBeVisible({ timeout: 5_000 }) + await expect(page.locator('table')).toHaveCount(1, { timeout: 5_000 }) expect(errors).toEqual([]) }) diff --git a/tests/smoke/testBridge.ts b/tests/smoke/testBridge.ts index da755e31..2ee3ec2f 100644 --- a/tests/smoke/testBridge.ts +++ b/tests/smoke/testBridge.ts @@ -38,6 +38,19 @@ export async function triggerMenuCommand(page: Page, id: string): Promise }, id) } +export async function seedBlockNoteTable( + page: Page, + columnWidths?: Array, +): Promise { + await page.evaluate((widths) => { + const bridge = window.__laputaTest?.seedBlockNoteTable + if (typeof bridge !== 'function') { + throw new Error('Tolaria test bridge is missing seedBlockNoteTable') + } + return bridge(widths ?? undefined) + }, columnWidths) +} + export async function dispatchShortcutEvent( page: Page, init: AppCommandShortcutEventInit,