Skip to content

Commit 813888b

Browse files
committed
feat(#1011): 切换文件后,恢复滚动和光标位置
1 parent 3dd317b commit 813888b

3 files changed

Lines changed: 227 additions & 8 deletions

File tree

src/app/core/main/editor/markdown/md-editor-wrapper.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ export function MdEditor({ tabContentsRef, filePath }: MdEditorProps) {
391391
initialContent={initialContent || ''}
392392
onChange={handleContentChange}
393393
placeholder={tEditor('placeholder')}
394-
activeFilePath={activeFilePath}
394+
activeFilePath={filePath}
395395
onQuoteToChat={handleQuoteToChat}
396396
onEditorReady={handleEditorReady}
397397
outlineOpen={outlineOpen}

src/app/core/main/editor/markdown/tiptap-editor.tsx

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,10 @@ type MobileSelectionContext =
189189

190190
type MobileSheetMode = 'ai' | 'image-src' | 'image-alt' | 'table-align' | 'table-more' | null
191191

192+
function clampSelectionPosition(value: number, docSize: number): number {
193+
return Math.max(0, Math.min(value, docSize))
194+
}
195+
192196
export function TipTapEditor({
193197
initialContent,
194198
onChange,
@@ -210,6 +214,8 @@ export function TipTapEditor({
210214
const pendingQuote = useChatStore((state) => state.pendingQuote)
211215
const pendingSearchKeyword = useArticleStore((state) => state.pendingSearchKeyword)
212216
const setPendingSearchKeyword = useArticleStore((state) => state.setPendingSearchKeyword)
217+
const setEditorViewState = useArticleStore((state) => state.setEditorViewState)
218+
const getEditorViewState = useArticleStore((state) => state.getEditorViewState)
213219

214220
const placeholderText = placeholder || t('placeholder')
215221
const isMobile = isMobileDevice()
@@ -226,6 +232,7 @@ export function TipTapEditor({
226232

227233
// 编辑器容器 ref,用于应用字体缩放
228234
const editorContainerRef = useRef<HTMLDivElement>(null)
235+
const scrollContainerRef = useRef<HTMLDivElement>(null)
229236

230237
// Math dialog state
231238
const [mathDialogOpen, setMathDialogOpen] = useState(false)
@@ -247,6 +254,8 @@ export function TipTapEditor({
247254
const initializedForPathRef = useRef<string | null>(null)
248255
const externalUpdateCounterRef = useRef(0)
249256
const pendingSyncUpdateRef = useRef<{ path: string; content: string } | null>(null)
257+
const restoredViewPathRef = useRef<string | null>(null)
258+
const lastViewStateRef = useRef<{ path: string; selectionFrom: number; selectionTo: number; scrollTop: number } | null>(null)
250259

251260
// 读取居中内容设置(移动端强制关闭)
252261
useEffect(() => {
@@ -278,6 +287,7 @@ export function TipTapEditor({
278287
isFirstUpdateRef.current = true
279288
initializedForPathRef.current = activeFilePath
280289
pendingSyncUpdateRef.current = null
290+
restoredViewPathRef.current = null
281291
}
282292
}, [activeFilePath])
283293

@@ -450,6 +460,140 @@ export function TipTapEditor({
450460
},
451461
})
452462

463+
const persistEditorViewState = useCallback(() => {
464+
if (!editor || !activeFilePath || !scrollContainerRef.current) {
465+
return
466+
}
467+
468+
if (restoredViewPathRef.current !== activeFilePath) {
469+
return
470+
}
471+
472+
const { from, to } = editor.state.selection
473+
const nextState = {
474+
path: activeFilePath,
475+
selectionFrom: from,
476+
selectionTo: to,
477+
scrollTop: scrollContainerRef.current.scrollTop,
478+
}
479+
480+
const previousState = lastViewStateRef.current
481+
if (
482+
previousState &&
483+
previousState.path === nextState.path &&
484+
previousState.selectionFrom === nextState.selectionFrom &&
485+
previousState.selectionTo === nextState.selectionTo &&
486+
previousState.scrollTop === nextState.scrollTop
487+
) {
488+
return
489+
}
490+
491+
lastViewStateRef.current = nextState
492+
setEditorViewState(activeFilePath, {
493+
selectionFrom: from,
494+
selectionTo: to,
495+
scrollTop: nextState.scrollTop,
496+
})
497+
}, [activeFilePath, editor, setEditorViewState])
498+
499+
useEffect(() => {
500+
if (!editor || !activeFilePath) {
501+
return
502+
}
503+
504+
const handleSelectionUpdate = () => {
505+
persistEditorViewState()
506+
}
507+
508+
editor.on('selectionUpdate', handleSelectionUpdate)
509+
return () => {
510+
editor.off('selectionUpdate', handleSelectionUpdate)
511+
}
512+
}, [activeFilePath, editor, persistEditorViewState])
513+
514+
useEffect(() => {
515+
const scrollContainer = scrollContainerRef.current
516+
if (!scrollContainer || !activeFilePath) {
517+
return
518+
}
519+
520+
const handleScroll = () => {
521+
persistEditorViewState()
522+
}
523+
524+
scrollContainer.addEventListener('scroll', handleScroll, { passive: true })
525+
return () => {
526+
scrollContainer.removeEventListener('scroll', handleScroll)
527+
}
528+
}, [activeFilePath, persistEditorViewState])
529+
530+
useEffect(() => {
531+
return () => {
532+
persistEditorViewState()
533+
}
534+
}, [persistEditorViewState])
535+
536+
const restoreEditorViewState = useCallback((path: string, attempt: number = 0) => {
537+
if (!editor || !path || !scrollContainerRef.current) {
538+
return
539+
}
540+
541+
if (restoredViewPathRef.current === path) {
542+
return
543+
}
544+
545+
const savedViewState = getEditorViewState(path)
546+
547+
if (!savedViewState) {
548+
restoredViewPathRef.current = path
549+
lastViewStateRef.current = {
550+
path,
551+
selectionFrom: editor.state.selection.from,
552+
selectionTo: editor.state.selection.to,
553+
scrollTop: scrollContainerRef.current.scrollTop,
554+
}
555+
return
556+
}
557+
558+
const docSize = editor.state.doc.content.size
559+
const selectionFrom = clampSelectionPosition(savedViewState.selectionFrom, docSize)
560+
const selectionTo = clampSelectionPosition(savedViewState.selectionTo, docSize)
561+
const wantedSelection = Math.max(savedViewState.selectionFrom, savedViewState.selectionTo)
562+
563+
if (docSize < wantedSelection && attempt < 5) {
564+
setTimeout(() => {
565+
restoreEditorViewState(path, attempt + 1)
566+
}, 16)
567+
return
568+
}
569+
570+
requestAnimationFrame(() => {
571+
if (!scrollContainerRef.current) {
572+
return
573+
}
574+
575+
editor.chain().focus().setTextSelection({
576+
from: selectionFrom,
577+
to: selectionTo,
578+
}).run()
579+
580+
requestAnimationFrame(() => {
581+
if (!scrollContainerRef.current) {
582+
return
583+
}
584+
585+
scrollContainerRef.current.scrollTop = savedViewState.scrollTop
586+
restoredViewPathRef.current = path
587+
lastViewStateRef.current = {
588+
path,
589+
selectionFrom,
590+
selectionTo,
591+
scrollTop: savedViewState.scrollTop,
592+
}
593+
})
594+
})
595+
}, [editor, getEditorViewState])
596+
453597
// 处理编辑器内链接点击
454598
useEffect(() => {
455599
if (!editor || !editorContainerRef.current) return
@@ -1388,9 +1532,10 @@ export function TipTapEditor({
13881532
onReady?.()
13891533
// Notify parent component about editor instance
13901534
onEditorReady?.(editor)
1535+
restoreEditorViewState(currentPath)
13911536
}, 0)
13921537
}
1393-
}, [editor, initialContent, onReady, onEditorReady, activeFilePath])
1538+
}, [editor, initialContent, onReady, onEditorReady, activeFilePath, restoreEditorViewState])
13941539

13951540
// 处理编辑器中图片的相对路径,转换为 asset:// URL
13961541
useEffect(() => {
@@ -2172,6 +2317,7 @@ export function TipTapEditor({
21722317

21732318
{/* Editor content - scrollable area */}
21742319
<div
2320+
ref={scrollContainerRef}
21752321
className="flex-1 overflow-x-hidden overflow-y-auto relative"
21762322
onDragOver={(e) => e.preventDefault()}
21772323
onDrop={handleEditorDrop}

src/stores/article.ts

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ export interface Article {
5555
path: string
5656
}
5757

58+
export interface EditorViewState {
59+
selectionFrom: number
60+
selectionTo: number
61+
scrollTop: number
62+
}
63+
5864
// 查找文件夹节点
5965
export const findFolderInTree = (path: string, tree: DirTree[]): DirTree | null => {
6066
for (const item of tree) {
@@ -199,6 +205,11 @@ interface NoteState {
199205
setActiveTabId: (id: string) => void
200206
addTab: (tab: { id: string; path: string; name: string; isFolder: boolean }) => void
201207
removeTab: (id: string) => void
208+
editorViewStates: Record<string, EditorViewState>
209+
setEditorViewState: (path: string, state: EditorViewState) => void
210+
getEditorViewState: (path: string) => EditorViewState | null
211+
removeEditorViewState: (path: string) => void
212+
moveEditorViewState: (oldPath: string, newPath: string) => void
202213
cleanTabsByDeletedFile: (deletedPath: string) => Promise<void>
203214
cleanTabsByDeletedFolder: (deletedFolderPath: string) => Promise<void>
204215
clearTabs: () => void
@@ -452,8 +463,13 @@ const useArticleStore = create<NoteState>((set, get) => ({
452463
// Tabs initialization - load from store
453464
openTabs: [],
454465
activeTabId: '',
466+
editorViewStates: {},
455467
setOpenTabs: async (tabs) => {
456-
set({ openTabs: tabs })
468+
const keptPaths = new Set(tabs.map(tab => tab.path))
469+
const nextEditorViewStates = Object.fromEntries(
470+
Object.entries(get().editorViewStates).filter(([path]) => keptPaths.has(path))
471+
)
472+
set({ openTabs: tabs, editorViewStates: nextEditorViewStates })
457473
const store = await getStore();
458474
await store.set('openTabs', tabs)
459475
},
@@ -476,11 +492,54 @@ const useArticleStore = create<NoteState>((set, get) => ({
476492
},
477493
removeTab: async (id) => {
478494
const currentTabs = get().openTabs
495+
const removedTab = currentTabs.find(t => t.id === id)
479496
const newTabs = currentTabs.filter(t => t.id !== id)
480-
set({ openTabs: newTabs })
497+
const nextEditorViewStates = { ...get().editorViewStates }
498+
if (removedTab) {
499+
delete nextEditorViewStates[removedTab.path]
500+
}
501+
set({ openTabs: newTabs, editorViewStates: nextEditorViewStates })
481502
const store = await getStore();
482503
await store.set('openTabs', newTabs)
483504
},
505+
setEditorViewState: (path, state) => {
506+
if (!path) {
507+
return
508+
}
509+
set(current => ({
510+
editorViewStates: {
511+
...current.editorViewStates,
512+
[path]: state,
513+
}
514+
}))
515+
},
516+
getEditorViewState: (path) => {
517+
if (!path) {
518+
return null
519+
}
520+
return get().editorViewStates[path] || null
521+
},
522+
removeEditorViewState: (path) => {
523+
if (!path) {
524+
return
525+
}
526+
const nextEditorViewStates = { ...get().editorViewStates }
527+
delete nextEditorViewStates[path]
528+
set({ editorViewStates: nextEditorViewStates })
529+
},
530+
moveEditorViewState: (oldPath, newPath) => {
531+
if (!oldPath || !newPath || oldPath === newPath) {
532+
return
533+
}
534+
const currentState = get().editorViewStates[oldPath]
535+
if (!currentState) {
536+
return
537+
}
538+
const nextEditorViewStates = { ...get().editorViewStates }
539+
delete nextEditorViewStates[oldPath]
540+
nextEditorViewStates[newPath] = currentState
541+
set({ editorViewStates: nextEditorViewStates })
542+
},
484543

485544
// 清理已被删除的文件对应的 tabs(根据路径匹配)
486545
cleanTabsByDeletedFile: async (deletedPath: string) => {
@@ -507,7 +566,9 @@ const useArticleStore = create<NoteState>((set, get) => ({
507566
newActiveFilePath = ''
508567
}
509568

510-
set({ openTabs: newTabs, activeTabId: newActiveTabId, activeFilePath: newActiveFilePath, currentArticle: '' })
569+
const nextEditorViewStates = { ...get().editorViewStates }
570+
delete nextEditorViewStates[deletedPath]
571+
set({ openTabs: newTabs, activeTabId: newActiveTabId, activeFilePath: newActiveFilePath, currentArticle: '', editorViewStates: nextEditorViewStates })
511572
const store = await getStore();
512573
await store.set('openTabs', newTabs)
513574
await store.set('activeTabId', newActiveTabId)
@@ -541,7 +602,13 @@ const useArticleStore = create<NoteState>((set, get) => ({
541602
newActiveFilePath = ''
542603
}
543604

544-
set({ openTabs: newTabs, activeTabId: newActiveTabId, activeFilePath: newActiveFilePath, currentArticle: '' })
605+
const nextEditorViewStates = { ...get().editorViewStates }
606+
Object.keys(nextEditorViewStates).forEach(path => {
607+
if (path.startsWith(folderPrefix)) {
608+
delete nextEditorViewStates[path]
609+
}
610+
})
611+
set({ openTabs: newTabs, activeTabId: newActiveTabId, activeFilePath: newActiveFilePath, currentArticle: '', editorViewStates: nextEditorViewStates })
545612
const store = await getStore();
546613
await store.set('openTabs', newTabs)
547614
await store.set('activeTabId', newActiveTabId)
@@ -550,7 +617,7 @@ const useArticleStore = create<NoteState>((set, get) => ({
550617
},
551618

552619
clearTabs: async () => {
553-
set({ openTabs: [], activeTabId: '' })
620+
set({ openTabs: [], activeTabId: '', editorViewStates: {} })
554621
const store = await getStore();
555622
await store.set('openTabs', [])
556623
await store.set('activeTabId', '')
@@ -673,7 +740,13 @@ const useArticleStore = create<NoteState>((set, get) => ({
673740
? currentActiveTabId
674741
: get().activeTabId
675742

676-
set({ openTabs: newTabs, activeTabId: nextActiveTabId })
743+
const nextEditorViewStates = { ...get().editorViewStates }
744+
if (nextEditorViewStates[oldPath]) {
745+
nextEditorViewStates[newPath] = nextEditorViewStates[oldPath]
746+
delete nextEditorViewStates[oldPath]
747+
}
748+
749+
set({ openTabs: newTabs, activeTabId: nextActiveTabId, editorViewStates: nextEditorViewStates })
677750
const store = await getStore()
678751
await store.set('openTabs', newTabs)
679752
await store.set('activeTabId', nextActiveTabId)

0 commit comments

Comments
 (0)