From 77a7d9d5191a31b04c08496a7ef16a3c3a0538fc Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 15 Apr 2026 23:33:30 +0200 Subject: [PATCH] fix: preserve editor mode position --- src/components/Editor.tsx | 68 +--- src/components/editorModePosition.test.ts | 156 ++++++++ src/components/editorModePosition.ts | 350 ++++++++++++++++++ .../useEditorModePositionSync.test.tsx | 161 ++++++++ src/components/useEditorModePositionSync.ts | 103 ++++++ src/components/useRawModeWithFlush.ts | 178 +++++++++ .../editor-mode-position-preservation.spec.ts | 190 ++++++++++ 7 files changed, 1140 insertions(+), 66 deletions(-) create mode 100644 src/components/editorModePosition.test.ts create mode 100644 src/components/editorModePosition.ts create mode 100644 src/components/useEditorModePositionSync.test.tsx create mode 100644 src/components/useEditorModePositionSync.ts create mode 100644 src/components/useRawModeWithFlush.ts create mode 100644 tests/smoke/editor-mode-position-preservation.spec.ts diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index c979e1b4..b8afa712 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, useLayoutEffect, useCallback, useState, memo } from 'react' +import { useRef, useEffect, useCallback, memo } from 'react' import { useEditorTabSwap } from '../hooks/useEditorTabSwap' import { useCreateBlockNote } from '@blocknote/react' import '@blocknote/mantine/style.css' @@ -9,22 +9,17 @@ import type { NoteListItem } from '../utils/ai-context' import type { FrontmatterValue } from './Inspector' import { ResizeHandle } from './ResizeHandle' import { useDiffMode, type CommitDiffRequest } from '../hooks/useDiffMode' -import { useRawMode } from '../hooks/useRawMode' import { useEditorFocus } from '../hooks/useEditorFocus' import { useDragRegion } from '../hooks/useDragRegion' 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 { useRawModeWithFlush } from './useRawModeWithFlush' import './Editor.css' import './EditorTheme.css' @@ -159,65 +154,6 @@ interface EditorSetupParams { diffToggleRef?: React.MutableRefObject<() => void> } -function useRawModeWithFlush( - editor: ReturnType, - activeTabPath: string | null, - activeTabContent: string | null, - onContentChange?: (path: string, content: string) => void, -) { - const rawLatestContentRef = useRef(null) - const rawBufferPathRef = useRef(null) - const [pendingRawExitContent, setPendingRawExitContent] = useState(null) - const [rawModeContentOverride, setRawModeContentOverride] = useState(null) - - useLayoutEffect(() => { - if (!activeTabPath) { - rawLatestContentRef.current = null - rawBufferPathRef.current = null - return - } - - if (rawBufferPathRef.current === activeTabPath) { - return - } - - rawLatestContentRef.current = activeTabContent - rawBufferPathRef.current = activeTabContent === null ? null : activeTabPath - }, [activeTabContent, activeTabPath]) - - 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(() => { - setPendingRawExitContent(rememberPendingRawExitContent({ - activeTabPath, - rawLatestContentRef, - onContentChange, - })) - setRawModeContentOverride(null) - rawBufferPathRef.current = null - rawLatestContentRef.current = null - }, [activeTabPath, onContentChange]) - - const { rawMode, handleToggleRaw } = useRawMode({ - activeTabPath, - onFlushPending: handleFlushPending, - onBeforeRawEnd: handleBeforeRawEnd, - }) - - return { rawMode, handleToggleRaw, rawLatestContentRef, pendingRawExitContent, setPendingRawExitContent, rawModeContentOverride } -} - function useEditorSetup({ tabs, activeTabPath, vaultPath, onContentChange, onLoadDiff, onLoadDiffAtCommit, pendingCommitDiffRequest, onPendingCommitDiffHandled, getNoteStatus, diff --git a/src/components/editorModePosition.test.ts b/src/components/editorModePosition.test.ts new file mode 100644 index 00000000..8325b483 --- /dev/null +++ b/src/components/editorModePosition.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { + buildCodeMirrorRestoreState, + captureRawEditorPositionSnapshot, + captureRichEditorPositionSnapshot, + restoreBlockNoteView, + restoreCodeMirrorView, + type BlockNotePositionEditor, + type CodeMirrorViewLike, +} from './editorModePosition' + +interface MockBlock { + id: string + markdown: string +} + +const content = '---\ntitle: Demo\n---\n# Title\n\nParagraph one\n\n## Tail' +const blocks: MockBlock[] = [ + { id: 'title', markdown: '# Title' }, + { id: 'details', markdown: 'Paragraph one' }, + { id: 'tail', markdown: '## Tail' }, +] + +function makeEditor(blocks: MockBlock[]): BlockNotePositionEditor { + let selectedBlocks: MockBlock[] | undefined + const cursorBlock = blocks[0] + + return { + document: blocks, + getSelection: () => selectedBlocks ? { blocks: selectedBlocks } : undefined, + getTextCursorPosition: () => ({ block: cursorBlock }), + blocksToMarkdownLossy: (items: unknown[]) => (items as MockBlock[]).map(item => item.markdown).join('\n\n'), + setSelection: vi.fn(), + setTextCursorPosition: vi.fn(), + focus: vi.fn(), + } +} + +function installRawView(view: CodeMirrorViewLike) { + const host = document.createElement('div') + host.setAttribute('data-testid', 'raw-editor-codemirror') + Object.assign(host, { __cmView: view }) + document.body.appendChild(host) + return host +} + +function captureAndRestoreRawSelection( + selection: { anchor: number; head: number }, +) { + const editor = makeEditor(blocks) + const view: CodeMirrorViewLike = { + state: { + doc: { toString: () => content }, + selection: { main: selection }, + }, + scrollDOM: { scrollTop: 48 }, + dispatch: vi.fn(), + focus: vi.fn(), + } + + installRawView(view) + const snapshot = captureRawEditorPositionSnapshot(document) + + return { + editor, + restored: restoreBlockNoteView(editor, snapshot!, document), + } +} + +describe('editorModePosition', () => { + beforeEach(() => { + const scrollHost = document.createElement('div') + scrollHost.className = 'editor-scroll-area' + scrollHost.scrollTop = 128 + document.body.appendChild(scrollHost) + const detailBlock = document.createElement('div') + detailBlock.setAttribute('data-id', 'details') + detailBlock.scrollIntoView = vi.fn() + document.body.appendChild(detailBlock) + const tailBlock = document.createElement('div') + tailBlock.setAttribute('data-id', 'tail') + tailBlock.scrollIntoView = vi.fn() + document.body.appendChild(tailBlock) + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + it('maps the current BlockNote block to a raw-editor restore selection', () => { + const editor = makeEditor(blocks) + editor.getTextCursorPosition = () => ({ block: blocks[1] }) + + const snapshot = captureRichEditorPositionSnapshot(editor, document) + expect(snapshot).toEqual({ + anchorBlockIndex: 1, + headBlockIndex: 1, + scrollTop: 128, + }) + + const restoreState = buildCodeMirrorRestoreState(editor, content, snapshot!) + expect(restoreState?.scrollTop).toBe(128) + expect(content.slice(restoreState!.anchor, restoreState!.head).trim()).toBe('Paragraph one') + }) + + it('restores a raw-editor selection and scroll position through the DOM bridge', () => { + const dispatch = vi.fn() + const focus = vi.fn() + const view: CodeMirrorViewLike = { + state: { + doc: { toString: () => content }, + selection: { main: { anchor: 0, head: 0 } }, + }, + scrollDOM: { scrollTop: 0 }, + dispatch, + focus, + } + installRawView(view) + + const restored = restoreCodeMirrorView(document, { + anchor: 10, + head: 21, + scrollTop: 96, + }) + + expect(restored).toBe(true) + expect(dispatch).toHaveBeenCalledWith({ selection: { anchor: 10, head: 21 } }) + expect(view.scrollDOM.scrollTop).toBe(96) + expect(focus).toHaveBeenCalled() + }) + + it('maps a raw-editor cursor back to the nearest BlockNote block', () => { + const paragraphOffset = content.indexOf('Paragraph one') + 5 + const { editor, restored } = captureAndRestoreRawSelection({ + anchor: paragraphOffset, + head: paragraphOffset, + }) + + expect(restored).toBe(true) + expect(editor.setTextCursorPosition).toHaveBeenCalledWith('details', 'end') + expect(editor.focus).toHaveBeenCalled() + }) + + it('restores a multi-block raw selection back into a BlockNote block range', () => { + const startOffset = content.indexOf('# Title') + const endOffset = content.indexOf('## Tail') + '## Tail'.length + const { editor, restored } = captureAndRestoreRawSelection({ + anchor: startOffset, + head: endOffset, + }) + + expect(restored).toBe(true) + expect(editor.setSelection).toHaveBeenCalledWith('title', 'tail') + expect(editor.focus).toHaveBeenCalled() + }) +}) diff --git a/src/components/editorModePosition.ts b/src/components/editorModePosition.ts new file mode 100644 index 00000000..a654d5ac --- /dev/null +++ b/src/components/editorModePosition.ts @@ -0,0 +1,350 @@ +import { compactMarkdown } from '../utils/compact-markdown' +import { restoreWikilinksInBlocks, splitFrontmatter } from '../utils/wikilinks' + +interface BlockLike { + id: string +} + +interface BlockSelectionLike { + blocks: BlockLike[] +} + +interface TextCursorPositionLike { + block: BlockLike +} + +export interface BlockNotePositionEditor { + document: BlockLike[] + getSelection?: () => BlockSelectionLike | undefined + getTextCursorPosition?: () => TextCursorPositionLike + blocksToMarkdownLossy: (blocks: unknown[]) => string + setSelection: (startBlock: string, endBlock: string) => void + setTextCursorPosition: (targetBlock: string, placement: 'start' | 'end') => void + focus: () => void +} + +export interface CodeMirrorViewLike { + state: { + doc: { toString: () => string } + selection: { + main: { + anchor: number + head: number + } + } + } + scrollDOM: { + scrollTop: number + } + dispatch: (spec: { selection: { anchor: number; head: number } }) => void + focus: () => void +} + +interface RawEditorHost extends Element { + __cmView?: CodeMirrorViewLike +} + +export interface RichEditorPositionSnapshot { + anchorBlockIndex: number + headBlockIndex: number + scrollTop: number +} + +export interface RawEditorPositionSnapshot { + anchorLineRatio: number + headLineRatio: number +} + +export interface CodeMirrorRestoreState { + anchor: number + head: number + scrollTop: number +} + +interface BlockNoteRestoreState { + startBlockId: string + endBlockId: string +} + +interface BlockLineRange { + startLine: number + endLine: number +} + +const RAW_EDITOR_SELECTOR = '[data-testid="raw-editor-codemirror"]' +const BLOCKNOTE_SCROLL_SELECTOR = '.editor-scroll-area' + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) +} + +function countLines({ text }: { text: string }): number { + return text.length === 0 ? 1 : text.split('\n').length +} + +function countLineBreaks({ text }: { text: string }): number { + return [...text].filter(char => char === '\n').length +} + +function getLineStartOffset({ text, lineIndex }: { text: string; lineIndex: number }): number { + if (lineIndex <= 0 || text.length === 0) return 0 + + let currentLine = 0 + for (let index = 0; index < text.length; index++) { + if (text[index] !== '\n') continue + currentLine += 1 + if (currentLine === lineIndex) { + return index + 1 + } + } + + return text.length +} + +function getLineEndOffset({ text, lineIndex }: { text: string; lineIndex: number }): number { + const start = getLineStartOffset({ text, lineIndex }) + const nextBreak = text.indexOf('\n', start) + return nextBreak === -1 ? text.length : nextBreak +} + +function getLineIndexForOffset({ text, offset }: { text: string; offset: number }): number { + if (text.length === 0) return 0 + const clampedOffset = clamp(offset, 0, text.length) + return countLineBreaks({ text: text.slice(0, clampedOffset) }) +} + +function getLineRatio({ text, offset }: { text: string; offset: number }): number { + const totalLines = countLines({ text }) + if (totalLines <= 1) return 0 + const lineIndex = getLineIndexForOffset({ text, offset }) + return lineIndex / (totalLines - 1) +} + +function getLineIndexFromRatio({ totalLines, ratio }: { totalLines: number; ratio: number }): number { + if (totalLines <= 1) return 0 + return Math.round(clamp(ratio, 0, 1) * (totalLines - 1)) +} + +function serializeBlock(editor: BlockNotePositionEditor, block: BlockLike): string { + return compactMarkdown(editor.blocksToMarkdownLossy(restoreWikilinksInBlocks([block]))) +} + +function serializeEditorBody(editor: BlockNotePositionEditor): string { + return compactMarkdown(editor.blocksToMarkdownLossy(restoreWikilinksInBlocks(editor.document))) +} + +function buildBlockLineRanges({ + body, + editor, +}: { + body: string + editor: BlockNotePositionEditor +}): BlockLineRange[] { + let searchStart = 0 + let fallbackStartLine = 0 + + return editor.document.map((block) => { + const serializedBlock = serializeBlock(editor, block) + if (!serializedBlock) { + return { startLine: fallbackStartLine, endLine: fallbackStartLine } + } + + const bodyIndex = body.indexOf(serializedBlock, searchStart) + if (bodyIndex === -1) { + const lineCount = countLines({ text: serializedBlock }) + const range = { + startLine: fallbackStartLine, + endLine: fallbackStartLine + Math.max(lineCount - 1, 0), + } + fallbackStartLine = range.endLine + 1 + return range + } + + const startLine = countLineBreaks({ text: body.slice(0, bodyIndex) }) + const endLine = countLineBreaks({ text: body.slice(0, bodyIndex + serializedBlock.length) }) + searchStart = bodyIndex + serializedBlock.length + fallbackStartLine = endLine + 1 + return { startLine, endLine } + }) +} + +function findNearestBlockIndex({ + ranges, + targetLine, +}: { + ranges: BlockLineRange[] + targetLine: number +}): number { + let nearestIndex = 0 + let nearestDistance = Number.POSITIVE_INFINITY + + ranges.forEach((range, index) => { + if (targetLine >= range.startLine && targetLine <= range.endLine) { + nearestIndex = index + nearestDistance = 0 + return + } + + const distance = targetLine < range.startLine + ? range.startLine - targetLine + : targetLine - range.endLine + if (distance < nearestDistance) { + nearestIndex = index + nearestDistance = distance + } + }) + + return nearestIndex +} + +function getSelectionIndexes(editor: BlockNotePositionEditor): [number, number] | null { + if (typeof editor.getSelection !== 'function') return null + + const selection = editor.getSelection() + const selectedBlocks = selection?.blocks ?? [] + if (selectedBlocks.length === 0) return null + + const startIndex = editor.document.findIndex(block => block.id === selectedBlocks[0].id) + const endIndex = editor.document.findIndex(block => block.id === selectedBlocks[selectedBlocks.length - 1].id) + if (startIndex === -1 || endIndex === -1) return null + + return [startIndex, endIndex] +} + +function getCursorIndex(editor: BlockNotePositionEditor): number | null { + if (typeof editor.getTextCursorPosition !== 'function') return null + + const cursorBlockId = editor.getTextCursorPosition().block.id + const cursorIndex = editor.document.findIndex(block => block.id === cursorBlockId) + return cursorIndex === -1 ? null : cursorIndex +} + +function buildBlockNoteRestoreState( + editor: BlockNotePositionEditor, + snapshot: RawEditorPositionSnapshot, +): BlockNoteRestoreState | null { + if (editor.document.length === 0) return null + + const body = serializeEditorBody(editor) + const ranges = buildBlockLineRanges({ body, editor }) + const totalLines = countLines({ text: body }) + const anchorLine = getLineIndexFromRatio({ totalLines, ratio: snapshot.anchorLineRatio }) + const headLine = getLineIndexFromRatio({ totalLines, ratio: snapshot.headLineRatio }) + const anchorIndex = findNearestBlockIndex({ ranges, targetLine: anchorLine }) + const headIndex = findNearestBlockIndex({ ranges, targetLine: headLine }) + const startIndex = Math.min(anchorIndex, headIndex) + const endIndex = Math.max(anchorIndex, headIndex) + return { + startBlockId: editor.document[startIndex].id, + endBlockId: editor.document[endIndex].id, + } +} + +export function readBlockNoteScrollTop(documentObject: Document): number { + const scrollElement = documentObject.querySelector(BLOCKNOTE_SCROLL_SELECTOR) + return scrollElement?.scrollTop ?? 0 +} + +export function captureRichEditorPositionSnapshot( + editor: BlockNotePositionEditor, + documentObject: Document, +): RichEditorPositionSnapshot | null { + if (editor.document.length === 0) return null + + const selectionIndexes = getSelectionIndexes(editor) + const [anchorBlockIndex, headBlockIndex] = selectionIndexes ?? [getCursorIndex(editor), getCursorIndex(editor)] + if (anchorBlockIndex === null || headBlockIndex === null) return null + + return { + anchorBlockIndex, + headBlockIndex, + scrollTop: readBlockNoteScrollTop(documentObject), + } +} + +export function buildCodeMirrorRestoreState( + editor: BlockNotePositionEditor, + content: string, + snapshot: RichEditorPositionSnapshot, +): CodeMirrorRestoreState | null { + if (editor.document.length === 0) return null + + const [frontmatter, body] = splitFrontmatter(content) + const ranges = buildBlockLineRanges({ body, editor }) + if (ranges.length === 0) return null + + const anchorRange = ranges[clamp(snapshot.anchorBlockIndex, 0, ranges.length - 1)] + const headRange = ranges[clamp(snapshot.headBlockIndex, 0, ranges.length - 1)] + const anchorBodyOffset = getLineStartOffset({ text: body, lineIndex: anchorRange.startLine }) + const headBodyOffset = getLineEndOffset({ text: body, lineIndex: headRange.endLine }) + + return { + anchor: frontmatter.length + anchorBodyOffset, + head: frontmatter.length + headBodyOffset, + scrollTop: snapshot.scrollTop, + } +} + +export function getRawEditorView(documentObject: Document): CodeMirrorViewLike | null { + const host = documentObject.querySelector(RAW_EDITOR_SELECTOR) + return host?.__cmView ?? null +} + +export function captureRawEditorPositionSnapshot(documentObject: Document): RawEditorPositionSnapshot | null { + const view = getRawEditorView(documentObject) + if (!view) return null + + const content = view.state.doc.toString() + const [frontmatter, body] = splitFrontmatter(content) + const bodyLength = body.length + const anchorOffset = clamp(view.state.selection.main.anchor - frontmatter.length, 0, bodyLength) + const headOffset = clamp(view.state.selection.main.head - frontmatter.length, 0, bodyLength) + return { + anchorLineRatio: getLineRatio({ text: body, offset: anchorOffset }), + headLineRatio: getLineRatio({ text: body, offset: headOffset }), + } +} + +export function captureRawCodeMirrorRestoreState(documentObject: Document): CodeMirrorRestoreState | null { + const view = getRawEditorView(documentObject) + if (!view) return null + + return { + anchor: view.state.selection.main.anchor, + head: view.state.selection.main.head, + scrollTop: view.scrollDOM.scrollTop, + } +} + +export function restoreCodeMirrorView( + documentObject: Document, + state: CodeMirrorRestoreState, +): boolean { + const view = getRawEditorView(documentObject) + if (!view) return false + + view.dispatch({ selection: { anchor: state.anchor, head: state.head } }) + view.scrollDOM.scrollTop = state.scrollTop + view.focus() + return true +} + +export function restoreBlockNoteView( + editor: BlockNotePositionEditor, + snapshot: RawEditorPositionSnapshot, + documentObject: Document, +): boolean { + const state = buildBlockNoteRestoreState(editor, snapshot) + if (!state) return false + + if (state.startBlockId === state.endBlockId) { + editor.setTextCursorPosition(state.endBlockId, 'end') + } else { + editor.setSelection(state.startBlockId, state.endBlockId) + } + editor.focus() + documentObject + .querySelector(`[data-id="${state.endBlockId}"]`) + ?.scrollIntoView({ block: 'center' }) + return true +} diff --git a/src/components/useEditorModePositionSync.test.tsx b/src/components/useEditorModePositionSync.test.tsx new file mode 100644 index 00000000..d3b837f9 --- /dev/null +++ b/src/components/useEditorModePositionSync.test.tsx @@ -0,0 +1,161 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { + buildCodeMirrorRestoreState, + captureRawEditorPositionSnapshot, + captureRichEditorPositionSnapshot, + type CodeMirrorRestoreState, + type BlockNotePositionEditor, + type CodeMirrorViewLike, + type RawEditorPositionSnapshot, +} from './editorModePosition' +import { useEditorModePositionSync } from './useEditorModePositionSync' + +interface MockBlock { + id: string + markdown: string +} + +const content = '---\ntitle: Demo\n---\n# Title\n\nParagraph one\n\n## Tail' +const blocks: MockBlock[] = [ + { id: 'title', markdown: '# Title' }, + { id: 'details', markdown: 'Paragraph one' }, + { id: 'tail', markdown: '## Tail' }, +] + +function makeEditor(): BlockNotePositionEditor { + return { + document: blocks, + getSelection: () => undefined, + getTextCursorPosition: () => ({ block: blocks[1] }), + blocksToMarkdownLossy: (items: unknown[]) => (items as MockBlock[]).map(item => item.markdown).join('\n\n'), + setSelection: vi.fn(), + setTextCursorPosition: vi.fn(), + focus: vi.fn(), + } +} + +function installBlockNoteScrollHost() { + const host = document.createElement('div') + host.className = 'editor-scroll-area' + host.scrollTop = 72 + document.body.appendChild(host) + const detailBlock = document.createElement('div') + detailBlock.setAttribute('data-id', 'details') + detailBlock.scrollIntoView = vi.fn() + document.body.appendChild(detailBlock) +} + +function installRawView(view: CodeMirrorViewLike) { + const host = document.createElement('div') + host.setAttribute('data-testid', 'raw-editor-codemirror') + Object.assign(host, { __cmView: view }) + document.body.appendChild(host) +} + +describe('useEditorModePositionSync', () => { + beforeEach(() => { + installBlockNoteScrollHost() + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + document.body.innerHTML = '' + }) + + it('restores the raw editor view after toggling from rich mode', () => { + const editor = makeEditor() + const dispatch = vi.fn() + const focus = vi.fn() + const pendingRawRestoreRef = { current: null as CodeMirrorRestoreState | null } + const pendingRoundTripRawRestoreRef = { current: null as { path: string; state: CodeMirrorRestoreState } | null } + const pendingRichRestoreRef = { current: null as RawEditorPositionSnapshot | null } + installRawView({ + state: { + doc: { toString: () => content }, + selection: { main: { anchor: 0, head: 0 } }, + }, + scrollDOM: { scrollTop: 0 }, + dispatch, + focus, + }) + + const { result, rerender } = renderHook( + ({ rawMode }) => { + useEditorModePositionSync({ + activeTabPath: 'note.md', + editor: editor as never, + pendingRawRestoreRef, + pendingRoundTripRawRestoreRef, + pendingRichRestoreRef, + rawMode, + }) + return { pendingRawRestoreRef, pendingRoundTripRawRestoreRef, pendingRichRestoreRef } + }, + { initialProps: { rawMode: false } }, + ) + + act(() => { + const snapshot = captureRichEditorPositionSnapshot(editor, document) + result.current.pendingRawRestoreRef.current = snapshot + ? buildCodeMirrorRestoreState(editor, content, snapshot) + : null + }) + rerender({ rawMode: true }) + + const selectionCall = dispatch.mock.calls[0]?.[0] + expect(selectionCall?.selection?.anchor).toBe(content.indexOf('Paragraph one')) + expect(content.slice(selectionCall.selection.anchor, selectionCall.selection.head).trim()).toBe('Paragraph one') + expect(focus).toHaveBeenCalled() + }) + + it('restores the BlockNote cursor after toggling back from raw mode', () => { + const editor = makeEditor() + const paragraphOffset = content.indexOf('Paragraph one') + 5 + const pendingRawRestoreRef = { current: null as CodeMirrorRestoreState | null } + const pendingRoundTripRawRestoreRef = { current: null as { path: string; state: CodeMirrorRestoreState } | null } + const pendingRichRestoreRef = { current: null as RawEditorPositionSnapshot | null } + installRawView({ + state: { + doc: { toString: () => content }, + selection: { main: { anchor: paragraphOffset, head: paragraphOffset } }, + }, + scrollDOM: { scrollTop: 24 }, + dispatch: vi.fn(), + focus: vi.fn(), + }) + + const { result, rerender } = renderHook( + ({ rawMode }) => { + useEditorModePositionSync({ + activeTabPath: 'note.md', + editor: editor as never, + pendingRawRestoreRef, + pendingRoundTripRawRestoreRef, + pendingRichRestoreRef, + rawMode, + }) + return { pendingRawRestoreRef, pendingRoundTripRawRestoreRef, pendingRichRestoreRef } + }, + { initialProps: { rawMode: true } }, + ) + + act(() => { + result.current.pendingRichRestoreRef.current = captureRawEditorPositionSnapshot(document) + }) + rerender({ rawMode: false }) + act(() => { + window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', { + detail: { path: 'note.md' }, + })) + }) + + expect(editor.setTextCursorPosition).toHaveBeenCalledWith('details', 'end') + expect(editor.focus).toHaveBeenCalled() + }) +}) diff --git a/src/components/useEditorModePositionSync.ts b/src/components/useEditorModePositionSync.ts new file mode 100644 index 00000000..fcc19555 --- /dev/null +++ b/src/components/useEditorModePositionSync.ts @@ -0,0 +1,103 @@ +import { useEffect } from 'react' +import type { useCreateBlockNote } from '@blocknote/react' +import { + restoreBlockNoteView, + restoreCodeMirrorView, + type CodeMirrorRestoreState, + type RawEditorPositionSnapshot, +} from './editorModePosition' + +const MAX_RAW_RESTORE_ATTEMPTS = 5 + +function useRawEditorRestoreEffect({ + activeTabPath, + pendingRawRestoreRef, + rawMode, +}: { + activeTabPath: string | null + pendingRawRestoreRef: React.MutableRefObject + rawMode: boolean +}) { + useEffect(() => { + if (!rawMode || !pendingRawRestoreRef.current) return + + let frame = 0 + let attempts = 0 + + const tryRestore = () => { + const pendingState = pendingRawRestoreRef.current + if (!pendingState) return + if (restoreCodeMirrorView(document, pendingState)) { + pendingRawRestoreRef.current = null + return + } + attempts += 1 + if (attempts < MAX_RAW_RESTORE_ATTEMPTS) { + frame = window.requestAnimationFrame(tryRestore) + } + } + + frame = window.requestAnimationFrame(tryRestore) + return () => { + if (frame !== 0) { + window.cancelAnimationFrame(frame) + } + } + }, [activeTabPath, pendingRawRestoreRef, rawMode]) +} + +function useBlockNoteRestoreEffect({ + activeTabPath, + editor, + pendingRoundTripRawRestoreRef, + pendingRichRestoreRef, + rawMode, +}: { + activeTabPath: string | null + editor: ReturnType + pendingRoundTripRawRestoreRef: React.MutableRefObject<{ path: string; state: CodeMirrorRestoreState } | null> + pendingRichRestoreRef: React.MutableRefObject + rawMode: boolean +}) { + useEffect(() => { + if (rawMode) return + + const handleEditorTabSwapped = (event: Event) => { + const pendingSnapshot = pendingRichRestoreRef.current + if (!activeTabPath || !pendingSnapshot) return + + const customEvent = event as CustomEvent<{ path: string }> + if (customEvent.detail.path !== activeTabPath) return + + window.requestAnimationFrame(() => { + restoreBlockNoteView(editor, pendingSnapshot, document) + pendingRoundTripRawRestoreRef.current = null + pendingRichRestoreRef.current = null + }) + } + + window.addEventListener('laputa:editor-tab-swapped', handleEditorTabSwapped) + return () => { + window.removeEventListener('laputa:editor-tab-swapped', handleEditorTabSwapped) + } + }, [activeTabPath, editor, pendingRichRestoreRef, pendingRoundTripRawRestoreRef, rawMode]) +} + +export function useEditorModePositionSync({ + activeTabPath, + editor, + pendingRawRestoreRef, + pendingRoundTripRawRestoreRef, + pendingRichRestoreRef, + rawMode, +}: { + activeTabPath: string | null + editor: ReturnType + pendingRawRestoreRef: React.MutableRefObject + pendingRoundTripRawRestoreRef: React.MutableRefObject<{ path: string; state: CodeMirrorRestoreState } | null> + pendingRichRestoreRef: React.MutableRefObject + rawMode: boolean +}) { + useRawEditorRestoreEffect({ activeTabPath, pendingRawRestoreRef, rawMode }) + useBlockNoteRestoreEffect({ activeTabPath, editor, pendingRoundTripRawRestoreRef, pendingRichRestoreRef, rawMode }) +} diff --git a/src/components/useRawModeWithFlush.ts b/src/components/useRawModeWithFlush.ts new file mode 100644 index 00000000..5ea9b6a6 --- /dev/null +++ b/src/components/useRawModeWithFlush.ts @@ -0,0 +1,178 @@ +import { useRef, useLayoutEffect, useCallback, useState } from 'react' +import type { useCreateBlockNote } from '@blocknote/react' +import { useRawMode } from '../hooks/useRawMode' +import { clearTableResizeState } from './tableResizeState' +import { + buildCodeMirrorRestoreState, + captureRawCodeMirrorRestoreState, + captureRawEditorPositionSnapshot, + captureRichEditorPositionSnapshot, + type CodeMirrorRestoreState, + type RawEditorPositionSnapshot, +} from './editorModePosition' +import { + type PendingRawExitContent, + buildPendingRawExitContent, + rememberPendingRawExitContent, + syncActiveTabIntoRawBuffer, +} from './editorRawModeSync' +import { useEditorModePositionSync } from './useEditorModePositionSync' + +interface PendingRoundTripRawRestore { + path: string + state: CodeMirrorRestoreState +} + +function getRoundTripRawRestore({ + activeTabPath, + pendingRoundTripRawRestore, +}: { + activeTabPath: string | null + pendingRoundTripRawRestore: PendingRoundTripRawRestore | null +}) { + if (!activeTabPath) return null + return pendingRoundTripRawRestore?.path === activeTabPath + ? pendingRoundTripRawRestore.state + : null +} + +function buildPendingRawRestore({ + activeTabContent, + activeTabPath, + editor, + pendingRoundTripRawRestore, + syncedContent, +}: { + activeTabContent: string | null + activeTabPath: string | null + editor: ReturnType + pendingRoundTripRawRestore: PendingRoundTripRawRestore | null + syncedContent: string | null +}) { + const roundTripRestore = getRoundTripRawRestore({ + activeTabPath, + pendingRoundTripRawRestore, + }) + if (roundTripRestore) return roundTripRestore + + const nextContent = syncedContent ?? activeTabContent + if (!nextContent) return null + + const richSnapshot = captureRichEditorPositionSnapshot(editor, document) + return richSnapshot + ? buildCodeMirrorRestoreState(editor, nextContent, richSnapshot) + : null +} + +function capturePendingRoundTripRawRestore(activeTabPath: string | null): PendingRoundTripRawRestore | null { + if (!activeTabPath) return null + + const rawRestoreState = captureRawCodeMirrorRestoreState(document) + return rawRestoreState + ? { path: activeTabPath, state: rawRestoreState } + : null +} + +function useTrackRawBuffer({ + activeTabContent, + activeTabPath, + rawBufferPathRef, + rawLatestContentRef, +}: { + activeTabContent: string | null + activeTabPath: string | null + rawBufferPathRef: React.MutableRefObject + rawLatestContentRef: React.MutableRefObject +}) { + useLayoutEffect(() => { + if (!activeTabPath) { + rawLatestContentRef.current = null + rawBufferPathRef.current = null + return + } + + if (rawBufferPathRef.current === activeTabPath) { + return + } + + rawLatestContentRef.current = activeTabContent + rawBufferPathRef.current = activeTabContent === null ? null : activeTabPath + }, [activeTabContent, activeTabPath, rawBufferPathRef, rawLatestContentRef]) +} + +function resetRawBufferState({ + rawBufferPathRef, + rawLatestContentRef, +}: { + rawBufferPathRef: React.MutableRefObject + rawLatestContentRef: React.MutableRefObject +}) { + rawBufferPathRef.current = null + rawLatestContentRef.current = null +} + +export function useRawModeWithFlush( + editor: ReturnType, + activeTabPath: string | null, + activeTabContent: string | null, + onContentChange?: (path: string, content: string) => void, +) { + const rawLatestContentRef = useRef(null) + const rawBufferPathRef = useRef(null) + const pendingRawRestoreRef = useRef(null) + const pendingRichRestoreRef = useRef(null) + const pendingRoundTripRawRestoreRef = useRef(null) + const [pendingRawExitContent, setPendingRawExitContent] = useState(null) + const [rawModeContentOverride, setRawModeContentOverride] = useState(null) + useTrackRawBuffer({ activeTabContent, activeTabPath, rawBufferPathRef, rawLatestContentRef }) + + const handleFlushPending = useCallback(async () => { + const syncedContent = syncActiveTabIntoRawBuffer({ + editor, + activeTabPath, + activeTabContent, + rawLatestContentRef, + onContentChange, + }) + pendingRawRestoreRef.current = buildPendingRawRestore({ + activeTabContent, + activeTabPath, + editor, + pendingRoundTripRawRestore: pendingRoundTripRawRestoreRef.current, + syncedContent, + }) + pendingRoundTripRawRestoreRef.current = null + setRawModeContentOverride(buildPendingRawExitContent(activeTabPath, syncedContent)) + clearTableResizeState(editor) + return true + }, [activeTabContent, activeTabPath, editor, onContentChange]) + + const handleBeforeRawEnd = useCallback(() => { + pendingRoundTripRawRestoreRef.current = capturePendingRoundTripRawRestore(activeTabPath) + pendingRichRestoreRef.current = captureRawEditorPositionSnapshot(document) + pendingRawRestoreRef.current = null + setPendingRawExitContent(rememberPendingRawExitContent({ + activeTabPath, + rawLatestContentRef, + onContentChange, + })) + setRawModeContentOverride(null) + resetRawBufferState({ rawBufferPathRef, rawLatestContentRef }) + }, [activeTabPath, onContentChange]) + + const { rawMode, handleToggleRaw } = useRawMode({ + activeTabPath, + onFlushPending: handleFlushPending, + onBeforeRawEnd: handleBeforeRawEnd, + }) + useEditorModePositionSync({ + activeTabPath, + editor, + pendingRawRestoreRef, + pendingRoundTripRawRestoreRef, + pendingRichRestoreRef, + rawMode, + }) + + return { rawMode, handleToggleRaw, rawLatestContentRef, pendingRawExitContent, setPendingRawExitContent, rawModeContentOverride } +} diff --git a/tests/smoke/editor-mode-position-preservation.spec.ts b/tests/smoke/editor-mode-position-preservation.spec.ts new file mode 100644 index 00000000..1b4164a7 --- /dev/null +++ b/tests/smoke/editor-mode-position-preservation.spec.ts @@ -0,0 +1,190 @@ +import { test, expect, type Page } from '@playwright/test' + +interface RawEditorState { + lineCount: number + lineIndex: number + lineText: string + nearbyLineTexts: string[] + scrollTop: number +} + +interface RawLineTarget { + lineIndex: number + targetLine: string +} + +function findMarkdownBodyStart(lines: string[]) { + if (lines[0] !== '---') { + return 0 + } + + const frontmatterEnd = lines.indexOf('---', 1) + return frontmatterEnd === -1 ? 0 : frontmatterEnd + 1 +} + +function isPreferredBodyLine(line: string) { + const trimmed = line.trim() + return trimmed !== '' + && !trimmed.startsWith('#') + && !trimmed.startsWith('- ') + && !trimmed.startsWith('* ') + && !/^\d+\.\s/.test(trimmed) +} + +function clampPreferredLineIndex(lines: string[], bodyStart: number, seedIndex: number) { + let lineIndex = seedIndex + + while (lineIndex > bodyStart && !isPreferredBodyLine(lines[lineIndex] ?? '')) { + lineIndex -= 1 + } + + if (isPreferredBodyLine(lines[lineIndex] ?? '')) { + return lineIndex + } + + const firstPreferredIndex = lines.findIndex((line, index) => index >= bodyStart && isPreferredBodyLine(line)) + if (firstPreferredIndex !== -1) { + return firstPreferredIndex + } + + const firstNonEmptyIndex = lines.findIndex((line, index) => index >= bodyStart && line.trim() !== '') + return firstNonEmptyIndex === -1 ? bodyStart : firstNonEmptyIndex +} + +function rawOffsetForLine(lines: string[], lineIndex: number) { + return lines + .slice(0, lineIndex) + .reduce((sum, line) => sum + line.length + 1, 0) +} + +async function openRawEditor(page: Page) { + await page.keyboard.press('Control+Backslash') + await expect(page.locator('[data-testid="raw-editor-codemirror"]')).toBeVisible({ timeout: 5_000 }) +} + +async function openRichEditor(page: Page) { + await page.keyboard.press('Control+Backslash') + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) +} + +async function getRawEditorState(page: Page): Promise { + return page.evaluate(() => { + const host = document.querySelector('[data-testid="raw-editor-codemirror"]') + const view = host && host.__cmView + if (!view) { + return { + lineCount: 0, + lineIndex: 0, + lineText: '', + nearbyLineTexts: [], + scrollTop: 0, + } + } + + const content = view.state.doc.toString() + const lines = content.split('\n') + const head = view.state.selection.main.head + let running = 0 + let lineIndex = 0 + + for (let index = 0; index < lines.length; index++) { + const nextOffset = running + lines[index].length + if (head <= nextOffset) { + lineIndex = index + break + } + running = nextOffset + 1 + lineIndex = index + } + + return { + lineCount: lines.length, + lineIndex, + lineText: lines[lineIndex] ?? '', + nearbyLineTexts: lines.slice(Math.max(0, lineIndex - 1), Math.min(lines.length, lineIndex + 2)).map(line => line.trim()), + scrollTop: view.scrollDOM.scrollTop, + } + }) +} + +async function getRawEditorLines(page: Page): Promise { + return page.evaluate(() => { + const host = document.querySelector('[data-testid="raw-editor-codemirror"]') + const view = host && host.__cmView + return view ? view.state.doc.toString().split('\n') : [] + }) +} + +async function findLongestNoteIndex(page: Page): Promise { + const noteItems = page.locator('[data-testid="note-list-container"] .cursor-pointer') + const count = await noteItems.count() + let bestIndex = 0 + let bestLineCount = 0 + + for (let index = 0; index < Math.min(count, 8); index++) { + await noteItems.nth(index).click() + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) + await openRawEditor(page) + const state = await getRawEditorState(page) + if (state.lineCount > bestLineCount) { + bestLineCount = state.lineCount + bestIndex = index + } + await openRichEditor(page) + } + + return bestIndex +} + +async function selectDeepRawBodyLine(page: Page): Promise { + const lines = await getRawEditorLines(page) + const bodyStart = findMarkdownBodyStart(lines) + const seedIndex = Math.max(bodyStart, Math.floor((bodyStart + lines.length - 1) * 0.75)) + const lineIndex = Math.max(0, clampPreferredLineIndex(lines, bodyStart, seedIndex)) + const offset = rawOffsetForLine(lines, lineIndex) + + await page.evaluate(({ nextOffset }) => { + const host = document.querySelector('[data-testid="raw-editor-codemirror"]') + const view = host && host.__cmView + if (!view) { + return + } + + view.dispatch({ selection: { anchor: nextOffset, head: nextOffset } }) + view.focus() + view.scrollDOM.scrollTop = Math.max(0, view.scrollDOM.scrollHeight * 0.7) + }, { nextOffset: offset }) + + return { lineIndex, targetLine: lines[lineIndex]?.trim() ?? '' } +} + +test.describe('Editor mode position preservation', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + await page.locator('[data-testid="note-list-container"]').waitFor({ timeout: 5_000 }) + }) + + test('keeps editing context near the same content when toggling raw and rich modes', async ({ page }) => { + const noteItems = page.locator('[data-testid="note-list-container"] .cursor-pointer') + const longestNoteIndex = await findLongestNoteIndex(page) + + await noteItems.nth(longestNoteIndex).click() + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) + await openRawEditor(page) + + const target = await selectDeepRawBodyLine(page) + expect(target.targetLine).not.toBe('') + + const rawBefore = await getRawEditorState(page) + await openRichEditor(page) + expect(rawBefore.scrollTop).toBeGreaterThan(0) + + await openRawEditor(page) + const rawAfter = await getRawEditorState(page) + + expect(rawBefore.lineText.trim()).toBe(target.targetLine) + expect(rawAfter.nearbyLineTexts).toContain(target.targetLine) + }) +})