@@ -189,6 +189,10 @@ type MobileSelectionContext =
189189
190190type 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+
192196export 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 }
0 commit comments