diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx index 8665a7da23..8937549e47 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx @@ -1,4 +1,4 @@ -import { notify, Scrollable, useHasScrollTop } from '@affine/component'; +import { notify, Scrollable } from '@affine/component'; import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton'; import type { ChatPanel } from '@affine/core/blocksuite/presets/ai'; import { AIProvider } from '@affine/core/blocksuite/presets/ai'; @@ -33,7 +33,7 @@ import { WorkspaceService, } from '@toeverything/infra'; import clsx from 'clsx'; -import { memo, useCallback, useEffect, useRef } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import { AffineErrorBoundary } from '../../../../components/affine/affine-error-boundary'; @@ -227,28 +227,36 @@ const DetailPageImpl = memo(function DetailPageImpl() { }) ); - editor.setEditorContainer(editorContainer); const unbind = editor.bindEditorContainer( editorContainer, - (editorContainer as any).docTitle // set from proxy + (editorContainer as any).docTitle, // set from proxy + scrollViewportRef.current ); return () => { unbind(); - editor.setEditorContainer(null); disposable.dispose(); }; }, [editor, openPage, docCollection.id, jumpToPageBlock, t] ); - const [refCallback, hasScrollTop] = useHasScrollTop(); + const [hasScrollTop, setHasScrollTop] = useState(false); const openOutlinePanel = useCallback(() => { workbench.openSidebar(); view.activeSidebarTab('outline'); }, [workbench, view]); + const scrollViewportRef = useRef(null); + + const handleScroll = useCallback((e: React.UIEvent) => { + const scrollTop = e.currentTarget.scrollTop; + + const hasScrollTop = scrollTop > 0; + setHasScrollTop(hasScrollTop); + }, []); + return ( @@ -265,7 +273,8 @@ const DetailPageImpl = memo(function DetailPageImpl() { { unbind(); - editor.setEditorContainer(null); }; }, [editor, setActiveBlocksuiteEditor, jumpToPageBlock, openPage, workspaceId] diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx index fd5ff82a7c..cab6c9a00d 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx @@ -32,7 +32,7 @@ import { WorkspaceService, } from '@toeverything/infra'; import clsx from 'clsx'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useParams } from 'react-router-dom'; import { PageHeader } from '../../../components'; @@ -67,6 +67,8 @@ const DetailPageImpl = () => { const isInTrash = useLiveData(doc.meta$.map(meta => meta.trash)); const { openPage, jumpToPageBlock } = useNavigateHelper(); + const scrollViewportRef = useRef(null); + const editorContainer = useLiveData(editor.editorContainer$); const enableKeyboardToolbar = @@ -157,7 +159,11 @@ const DetailPageImpl = () => { ); } - editor.setEditorContainer(editorContainer); + editor.bindEditorContainer( + editorContainer, + null, + scrollViewportRef.current + ); return () => { disposable.dispose(); @@ -171,6 +177,7 @@ const DetailPageImpl = () => {
( undefined ); + workbenchView: WorkbenchView | null = null; + defaultScrollPosition: + | number + | { + centerX: number; + centerY: number; + zoom: number; + } + | null = null; + + private readonly focusAt$ = LiveData.computed(get => { + const selector = get(this.selector$); + const mode = get(this.mode$); + let id = selector?.blockIds?.[0]; + let key = 'blockIds'; + + if (mode === 'edgeless') { + const elementId = selector?.elementIds?.[0]; + if (elementId) { + id = elementId; + key = 'elementIds'; + } + } + + if (!id) return null; + + return { id, key, mode, refreshKey: selector?.refreshKey }; + }); isPresenting$ = new LiveData(false); @@ -61,10 +90,6 @@ export class Editor extends Entity { this.mode$.next(mode); } - setEditorContainer(editorContainer: AffineEditorContainer | null) { - this.editorContainer$.next(editorContainer); - } - setDefaultOpenProperty(defaultOpenProperty: DefaultOpenProperty | undefined) { this.defaultOpenProperty$.next(defaultOpenProperty); } @@ -73,6 +98,12 @@ export class Editor extends Entity { * sync editor params with view query string */ bindWorkbenchView(view: WorkbenchView) { + if (this.workbenchView) { + throw new Error('already bound'); + } + this.workbenchView = view; + this.defaultScrollPosition = view.getScrollPosition() ?? null; + const stablePrimaryMode = this.doc.getPrimaryMode(); // eslint-disable-next-line rxjs/finnish @@ -147,6 +178,7 @@ export class Editor extends Entity { }); return () => { + this.workbenchView = null; unsubscribeEditorParams.unsubscribe(); unsubscribeViewParams.unsubscribe(); }; @@ -154,42 +186,95 @@ export class Editor extends Entity { bindEditorContainer( editorContainer: AffineEditorContainer, - docTitle: DocTitle | null + docTitle?: DocTitle | null, + scrollViewport?: HTMLElement | null ) { + if (this.editorContainer$.value) { + throw new Error('already bound'); + } + this.editorContainer$.next(editorContainer); const unsubs: (() => void)[] = []; - const focusAt$ = LiveData.computed(get => { - const selector = get(this.selector$); - const mode = get(this.mode$); - let id = selector?.blockIds?.[0]; - let key = 'blockIds'; + const rootService = editorContainer.host?.std.getService('affine:page'); - if (mode === 'edgeless') { - const elementId = selector?.elementIds?.[0]; - if (elementId) { - id = elementId; - key = 'elementIds'; - } + // ----- Scroll Position and Selection ----- + if (this.defaultScrollPosition) { + // if we have default scroll position, we should restore it + if ( + this.mode$.value === 'page' && + typeof this.defaultScrollPosition === 'number' + ) { + scrollViewport?.scrollTo(0, this.defaultScrollPosition || 0); + } else if ( + this.mode$.value === 'edgeless' && + typeof this.defaultScrollPosition === 'object' && + rootService instanceof EdgelessRootService + ) { + rootService.viewport.setViewport(this.defaultScrollPosition.zoom, [ + this.defaultScrollPosition.centerX, + this.defaultScrollPosition.centerY, + ]); } - if (!id) return null; + this.defaultScrollPosition = null; // reset default scroll position + } else { + const initialFocusAt = this.focusAt$.value; - return { id, key, mode, refreshKey: selector?.refreshKey }; - }); - if (focusAt$.value === null && docTitle) { - const title = docTitle.querySelector< - HTMLElement & { inlineEditor: InlineEditor | null } - >('rich-text'); - title?.inlineEditor?.focusEnd(); + if (initialFocusAt === null) { + const title = docTitle?.querySelector< + HTMLElement & { inlineEditor: InlineEditor | null } + >('rich-text'); + title?.inlineEditor?.focusEnd(); + } else { + const selection = editorContainer.host?.std.selection; + + const { id, key, mode } = initialFocusAt; + + if (mode === this.mode$.value) { + selection?.setGroup('scene', [ + selection?.create('highlight', { + mode, + [key]: [id], + }), + ]); + } + } } - const subscription = focusAt$ + // update scroll position when scrollViewport scroll + const saveScrollPosition = () => { + if (this.mode$.value === 'page' && scrollViewport) { + this.workbenchView?.setScrollPosition(scrollViewport.scrollTop); + } else if ( + this.mode$.value === 'edgeless' && + rootService instanceof EdgelessRootService + ) { + this.workbenchView?.setScrollPosition({ + centerX: rootService.viewport.centerX, + centerY: rootService.viewport.centerY, + zoom: rootService.viewport.zoom, + }); + } + }; + scrollViewport?.addEventListener('scroll', saveScrollPosition); + unsubs.push(() => { + scrollViewport?.removeEventListener('scroll', saveScrollPosition); + }); + if (rootService instanceof EdgelessRootService) { + unsubs.push( + rootService.viewport.viewportUpdated.on(saveScrollPosition).dispose + ); + } + + // update selection when focusAt$ changed + const subscription = this.focusAt$ .distinctUntilChanged( (a, b) => a?.id === b?.id && a?.key === b?.key && a?.refreshKey === b?.refreshKey ) + .pipe(skip(1)) .subscribe(anchor => { if (!anchor) return; @@ -207,6 +292,7 @@ export class Editor extends Entity { }); unsubs.push(subscription.unsubscribe.bind(subscription)); + // ----- Presenting ----- const edgelessPage = editorContainer.host?.querySelector( 'affine-edgeless-root' ); @@ -226,6 +312,7 @@ export class Editor extends Entity { } return () => { + this.editorContainer$.next(null); for (const unsub of unsubs) { unsub(); } diff --git a/packages/frontend/core/src/modules/peek-view/view/doc-preview/doc-peek-view.tsx b/packages/frontend/core/src/modules/peek-view/view/doc-preview/doc-peek-view.tsx index d716de2281..c817019615 100644 --- a/packages/frontend/core/src/modules/peek-view/view/doc-preview/doc-peek-view.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/doc-preview/doc-peek-view.tsx @@ -108,7 +108,6 @@ function DocPeekPreviewEditor({ }) ); - editor.setEditorContainer(editorContainer); const unbind = editor.bindEditorContainer( editorContainer, (editorContainer as any).title @@ -120,7 +119,6 @@ function DocPeekPreviewEditor({ return () => { unbind(); - editor.setEditorContainer(null); disposableGroup.dispose(); }; }, diff --git a/packages/frontend/core/src/modules/workbench/entities/view.ts b/packages/frontend/core/src/modules/workbench/entities/view.ts index 162df94ae3..94ada00889 100644 --- a/packages/frontend/core/src/modules/workbench/entities/view.ts +++ b/packages/frontend/core/src/modules/workbench/entities/view.ts @@ -30,6 +30,16 @@ export class View extends Entity<{ sidebarTabs$ = new LiveData([]); + scrollPositions = new WeakMap< + Location, + | number + | { + centerX: number; + centerY: number; + zoom: number; + } + >(); + // _activeTabId may point to a non-existent tab. // In this case, we still retain the activeTabId data and wait for the non-existent tab to be mounted. _activeSidebarTabId$ = new LiveData(null); @@ -161,6 +171,22 @@ export class View extends Entity<{ this._activeSidebarTabId$.next(id); } + getScrollPosition() { + return this.scrollPositions.get(this.history.location); + } + + setScrollPosition( + position: + | number + | { + centerX: number; + centerY: number; + zoom: number; + } + ) { + this.scrollPositions.set(this.history.location, position); + } + setTitle(title: string) { this.title$.next(title); } diff --git a/packages/frontend/core/src/modules/workbench/view/browser-adapter.ts b/packages/frontend/core/src/modules/workbench/view/browser-adapter.ts index 542dd92629..b140042ab1 100644 --- a/packages/frontend/core/src/modules/workbench/view/browser-adapter.ts +++ b/packages/frontend/core/src/modules/workbench/view/browser-adapter.ts @@ -36,10 +36,6 @@ export function useBindWorkbenchToBrowserRouter( if (update.action === 'POP') { // This is because the history of view and browser are two different stacks, // the POP action cannot be synchronized. - throw new Error('POP view history is not allowed on browser'); - } - - if (update.location.state === 'fromBrowser') { return; } @@ -48,13 +44,11 @@ export function useBindWorkbenchToBrowserRouter( basename ); - if (locationIsEqual(browserLocation, newBrowserLocation)) { - return; - } - navigate(newBrowserLocation, { - state: 'fromView', - replace: update.action === 'REPLACE', + state: 'fromView,' + newBrowserLocation.key, + replace: + update.action === 'REPLACE' || + newBrowserLocation.state === 'fromBrowser', }); }); }, [basename, browserLocation, navigate, view]); @@ -67,7 +61,25 @@ export function useBindWorkbenchToBrowserRouter( if (newLocation === null) { return; } - + if ( + typeof newLocation.state === 'string' && + newLocation.state.startsWith('fromView') + ) { + const fromViewKey = newLocation.state.substring('fromView,'.length); + if (fromViewKey === view.location$.value.key) { + return; + } else { + const target = view.history.entries.findIndex( + entry => entry.key === fromViewKey + ); + if (target !== -1) { + const now = view.history.index; + const delta = target - now; + view.history.go(delta); + return; + } + } + } view.history.push(newLocation, 'fromBrowser'); }, [basename, browserLocation, view]); } @@ -94,12 +106,3 @@ function viewLocationToBrowserLocation( pathname: `${basename}${location.pathname}`, }; } - -function locationIsEqual(a: Location, b: Location) { - return ( - a.hash === b.hash && - a.pathname === b.pathname && - a.search === b.search && - a.state === b.state - ); -}