diff --git a/packages/common/infra/src/livedata/livedata.ts b/packages/common/infra/src/livedata/livedata.ts index 058379940c..bc3c0dd024 100644 --- a/packages/common/infra/src/livedata/livedata.ts +++ b/packages/common/infra/src/livedata/livedata.ts @@ -23,6 +23,8 @@ import { throttleTime, } from 'rxjs'; +import { shallowEqual } from '../utils/shallow-equal'; + const logger = new DebugLogger('livedata'); /** @@ -334,6 +336,32 @@ export class LiveData return sub$; } + /** + * same as map, but do shallow equal check before emit + */ + selector(selector: (v: T) => R): LiveData { + const sub$ = LiveData.from( + new Observable(subscriber => { + let last: any = undefined; + return this.subscribe({ + next: v => { + const data = selector(v); + if (!shallowEqual(last, data)) { + subscriber.next(data); + } + last = data; + }, + complete: () => { + sub$.complete(); + }, + }); + }), + undefined as R // is safe + ); + + return sub$; + } + distinctUntilChanged(comparator?: (previous: T, current: T) => boolean) { return LiveData.from( this.pipe(distinctUntilChanged(comparator)), diff --git a/packages/common/infra/src/utils/shallow-equal.ts b/packages/common/infra/src/utils/shallow-equal.ts new file mode 100644 index 0000000000..b9d4a9d7a3 --- /dev/null +++ b/packages/common/infra/src/utils/shallow-equal.ts @@ -0,0 +1,34 @@ +// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js +export function shallowEqual(objA: any, objB: any) { + if (Object.is(objA, objB)) { + return true; + } + + if ( + typeof objA !== 'object' || + objA === null || + typeof objB !== 'object' || + objB === null + ) { + return false; + } + + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + + if (keysA.length !== keysB.length) { + return false; + } + + // Test for A's keys different from B. + for (const key of keysA) { + if ( + !Object.prototype.hasOwnProperty.call(objB, key) || + !Object.is(objA[key], objB[key]) + ) { + return false; + } + } + + return true; +} diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor-container.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor-container.tsx index a637233b95..1474eb2174 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor-container.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor-container.tsx @@ -1,7 +1,10 @@ +import type { EditorSelector } from '@affine/core/modules/editor'; import type { ReferenceInfo } from '@blocksuite/affine-model'; import { DocMode } from '@blocksuite/blocks'; +import type { InlineEditor } from '@blocksuite/inline/inline-editor'; import type { AffineEditorContainer, + DocTitle, EdgelessEditor, PageEditor, } from '@blocksuite/presets'; @@ -10,6 +13,7 @@ import clsx from 'clsx'; import type React from 'react'; import { forwardRef, + useCallback, useEffect, useLayoutEffect, useMemo, @@ -42,8 +46,7 @@ interface BlocksuiteEditorContainerProps { shared?: boolean; className?: string; style?: React.CSSProperties; - blockIds?: string[]; - elementIds?: string[]; + defaultEditorSelector?: EditorSelector; } // mimic the interface of the webcomponent and expose slots & host @@ -57,14 +60,24 @@ export const BlocksuiteEditorContainer = forwardRef< AffineEditorContainer, BlocksuiteEditorContainerProps >(function AffineEditorContainer( - { page, mode, className, style, shared, blockIds, elementIds }, + { page, mode, className, style, shared, defaultEditorSelector }, ref ) { - const scrolledRef = useRef(false); const rootRef = useRef(null); const docRef = useRef(null); + const docTitleRef = useRef(null); const edgelessRef = useRef(null); - const [anchor, setAnchor] = useState(null); + const [anchor] = useState(() => { + if ( + mode === DocMode.Edgeless && + defaultEditorSelector?.elementIds?.length + ) { + return defaultEditorSelector.elementIds[0]; + } else if (defaultEditorSelector?.blockIds?.length) { + return defaultEditorSelector.blockIds[0]; + } + return null; + }); const slots: BlocksuiteEditorContainerRef['slots'] = useMemo(() => { return { @@ -75,14 +88,6 @@ export const BlocksuiteEditorContainer = forwardRef< }; }, []); - useEffect(() => { - if (mode === DocMode.Edgeless && elementIds?.length) { - setAnchor(elementIds[0]); - } else if (blockIds?.length) { - setAnchor(blockIds[0]); - } - }, [blockIds, elementIds, mode]); - // forward the slot to the webcomponent useLayoutEffect(() => { requestAnimationFrame(() => { @@ -162,7 +167,7 @@ export const BlocksuiteEditorContainer = forwardRef< } return undefined; }, - }) as unknown as AffineEditorContainer; + }) as unknown as AffineEditorContainer & { origin: HTMLDivElement }; return proxy; }, [mode, page, slots]); @@ -177,31 +182,49 @@ export const BlocksuiteEditorContainer = forwardRef< } }, [affineEditorContainerProxy, ref]); - // `scrolledRef` should be updated if blockElement is changed useEffect(() => { - scrolledRef.current = false; - }, [anchor]); - - useEffect(() => { - if (!anchor) return; - - let canceled = false; - affineEditorContainerProxy.updateComplete - .then(() => { - if (!scrolledRef.current && !canceled) { - const std = affineEditorContainerProxy.host?.std; - if (std) { - scrollAnchoring(std, mode, anchor); + if (anchor) { + let canceled = false; + affineEditorContainerProxy.updateComplete + .then(() => { + if (!canceled) { + const std = affineEditorContainerProxy.host?.std; + if (std) { + scrollAnchoring(std, mode, anchor); + } } - scrolledRef.current = true; - } - }) - .catch(console.error); - return () => { - canceled = true; - }; + }) + .catch(console.error); + return () => { + canceled = true; + }; + } else { + // if no anchor, focus the title + let canceled = false; + + affineEditorContainerProxy.updateComplete + .then(() => { + if (!canceled) { + const title = docTitleRef.current?.querySelector< + HTMLElement & { inlineEditor: InlineEditor } + >('rich-text'); + title?.inlineEditor.focusEnd(); + } + }) + .catch(console.error); + return () => { + canceled = true; + }; + } }, [anchor, affineEditorContainerProxy, mode]); + const handleClickPageModeBlank = useCallback(() => { + affineEditorContainerProxy.host?.std.command.exec( + 'appendParagraph' as never, + {} + ); + }, [affineEditorContainerProxy]); + return (
{mode === 'page' ? ( - + ) : ( () => void; style?: CSSProperties; className?: string; - blockIds?: string[]; - elementIds?: string[]; + defaultEditorSelector?: EditorSelector; }; function usePageRoot(page: Doc) { @@ -64,8 +64,7 @@ const BlockSuiteEditorImpl = forwardRef( onLoadEditor, shared, style, - blockIds, - elementIds, + defaultEditorSelector, }, ref ) { @@ -116,8 +115,7 @@ const BlockSuiteEditorImpl = forwardRef( ref={onRefChange} className={className} style={style} - blockIds={blockIds} - elementIds={elementIds} + defaultEditorSelector={defaultEditorSelector} /> ); } diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx index 0f64f34733..3045ebc189 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx @@ -6,7 +6,6 @@ import { import { useJournalInfoHelper } from '@affine/core/hooks/use-journal'; import { EditorSettingService } from '@affine/core/modules/editor-settting'; import { PeekViewService } from '@affine/core/modules/peek-view'; -import { WorkbenchService } from '@affine/core/modules/workbench'; import { DocMode } from '@blocksuite/blocks'; import { DocTitle, EdgelessEditor, PageEditor } from '@blocksuite/presets'; import type { Doc } from '@blocksuite/store'; @@ -24,7 +23,6 @@ import React, { useEffect, useMemo, useRef, - useState, } from 'react'; import { PagePropertiesTable } from '../../affine/page-properties'; @@ -146,18 +144,18 @@ const usePatchSpecs = (page: Doc, shared: boolean, mode: DocMode) => { export const BlocksuiteDocEditor = forwardRef< PageEditor, - BlocksuiteEditorProps ->(function BlocksuiteDocEditor({ page, shared }, ref) { - const titleRef = useRef(null); + BlocksuiteEditorProps & { + onClickBlank?: () => void; + titleRef?: React.Ref; + } +>(function BlocksuiteDocEditor( + { page, shared, onClickBlank, titleRef: externalTitleRef }, + ref +) { + const titleRef = useRef(null); const docRef = useRef(null); - const [docPage, setDocPage] = - useState(); const { isJournal } = useJournalInfoHelper(page.collection, page.id); - const workbench = useService(WorkbenchService).workbench; - const activeView = useLiveData(workbench.activeView$); - const hash = useLiveData(activeView.location$).hash; - const editorSettingService = useService(EditorSettingService); const onDocRef = useCallback( @@ -174,22 +172,19 @@ export const BlocksuiteDocEditor = forwardRef< [ref] ); - useEffect(() => { - // auto focus the title - setTimeout(() => { - const docPage = docRef.current?.querySelector('affine-page-root'); - if (docPage) { - setDocPage(docPage); + const onTitleRef = useCallback( + (el: DocTitle) => { + titleRef.current = el; + if (externalTitleRef) { + if (typeof externalTitleRef === 'function') { + externalTitleRef(el); + } else { + (externalTitleRef as any).current = el; + } } - if (titleRef.current && !hash) { - const richText = titleRef.current.querySelector('rich-text'); - richText?.inlineEditor?.focusEnd(); - } else { - docPage?.focusFirstParagraph(); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, + [externalTitleRef] + ); const [specs, portals] = usePatchSpecs(page, !!shared, DocMode.Page); @@ -199,7 +194,7 @@ export const BlocksuiteDocEditor = forwardRef< <>
{!isJournal ? ( - + ) : ( )} @@ -211,14 +206,7 @@ export const BlocksuiteDocEditor = forwardRef< specs={specs} hasViewport={false} /> - {docPage && !page.readonly ? ( -
{ - docPage.std.command.exec('appendParagraph' as never, {}); - }} - >
- ) : null} +
{!shared && settings.displayBiDirectionalLink ? ( ) : null} diff --git a/packages/frontend/core/src/components/page-detail-editor.tsx b/packages/frontend/core/src/components/page-detail-editor.tsx index fd152adbdc..9eddee14e6 100644 --- a/packages/frontend/core/src/components/page-detail-editor.tsx +++ b/packages/frontend/core/src/components/page-detail-editor.tsx @@ -1,22 +1,17 @@ import './page-detail-editor.css'; import { useDocCollectionPage } from '@affine/core/hooks/use-block-suite-workspace-page'; -import { ViewService } from '@affine/core/modules/workbench/services/view'; import type { DocMode } from '@blocksuite/blocks'; import { DisposableGroup } from '@blocksuite/global/utils'; import type { AffineEditorContainer } from '@blocksuite/presets'; import type { Doc as BlockSuiteDoc, DocCollection } from '@blocksuite/store'; -import { - useLiveData, - useService, - useServiceOptional, -} from '@toeverything/infra'; +import { useLiveData, useService } from '@toeverything/infra'; import { cssVar } from '@toeverything/theme'; import clsx from 'clsx'; import type { CSSProperties } from 'react'; -import { memo, Suspense, useCallback, useMemo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; -import { EditorService } from '../modules/editor'; +import { type EditorSelector, EditorService } from '../modules/editor'; import { EditorSettingService, fontStyleOptions, @@ -40,35 +35,26 @@ export interface PageDetailEditorProps { docCollection: DocCollection; pageId: string; onLoad?: OnLoadEditor; + defaultEditorSelector?: EditorSelector; } const PageDetailEditorMain = memo(function PageDetailEditorMain({ page, onLoad, + defaultEditorSelector, }: PageDetailEditorProps & { page: BlockSuiteDoc }) { - const viewService = useServiceOptional(ViewService); - const params = useLiveData( - viewService?.view.queryString$<{ - mode?: string; - blockIds?: string[]; - elementIds?: string[]; - }>({ - // Cannot handle single id situation correctly: `blockIds=xxx` - arrayFormat: 'none', - types: { - mode: 'string', - blockIds: value => (value.length ? value.split(',') : []), - elementIds: value => (value.length ? value.split(',') : []), - }, - }) - ); - const editor = useService(EditorService).editor; const mode = useLiveData(editor.mode$); const isSharedMode = editor.isSharedMode; const editorSetting = useService(EditorSettingService).editorSetting; - const settings = useLiveData(editorSetting.settings$); + const settings = useLiveData( + editorSetting.settings$.selector(s => ({ + fontFamily: s.fontFamily, + customFontFamily: s.customFontFamily, + fullWidthLayout: s.fullWidthLayout, + })) + ); const value = useMemo(() => { const fontStyle = fontStyleOptions.find( @@ -122,8 +108,7 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({ mode={mode} page={page} shared={isSharedMode} - blockIds={params?.blockIds} - elementIds={params?.elementIds} + defaultEditorSelector={defaultEditorSelector} onLoadEditor={onLoadEditor} /> ); @@ -135,9 +120,5 @@ export const PageDetailEditor = (props: PageDetailEditorProps) => { if (!page) { return null; } - return ( - - - - ); + return ; }; diff --git a/packages/frontend/core/src/modules/editor/entities/editor.ts b/packages/frontend/core/src/modules/editor/entities/editor.ts index 8521848cb8..d4081e2dee 100644 --- a/packages/frontend/core/src/modules/editor/entities/editor.ts +++ b/packages/frontend/core/src/modules/editor/entities/editor.ts @@ -4,13 +4,20 @@ import type { DocService, WorkspaceService } from '@toeverything/infra'; import { Entity, LiveData } from '@toeverything/infra'; import { EditorScope } from '../scopes/editor'; +import type { EditorSelector } from '../types'; -export class Editor extends Entity<{ defaultMode: DocMode }> { +export class Editor extends Entity<{ + defaultMode: DocMode; + defaultEditorSelector?: EditorSelector; +}> { readonly scope = this.framework.createScope(EditorScope, { editor: this as Editor, }); readonly mode$ = new LiveData(this.props.defaultMode); + readonly selector$ = new LiveData( + this.props.defaultEditorSelector + ); readonly doc = this.docService.doc; readonly isSharedMode = this.workspaceService.workspace.openOptions.isSharedMode; diff --git a/packages/frontend/core/src/modules/editor/index.ts b/packages/frontend/core/src/modules/editor/index.ts index 3ee383b183..7b2eb8ee3a 100644 --- a/packages/frontend/core/src/modules/editor/index.ts +++ b/packages/frontend/core/src/modules/editor/index.ts @@ -15,6 +15,7 @@ export { Editor } from './entities/editor'; export { EditorScope } from './scopes/editor'; export { EditorService } from './services/editor'; export { EditorsService } from './services/editors'; +export type { EditorSelector } from './types'; export function configureEditorModule(framework: Framework) { framework diff --git a/packages/frontend/core/src/modules/editor/services/editors.ts b/packages/frontend/core/src/modules/editor/services/editors.ts index c60dca7853..189df432c9 100644 --- a/packages/frontend/core/src/modules/editor/services/editors.ts +++ b/packages/frontend/core/src/modules/editor/services/editors.ts @@ -2,9 +2,13 @@ import type { DocMode } from '@blocksuite/blocks'; import { Service } from '@toeverything/infra'; import { Editor } from '../entities/editor'; +import type { EditorSelector } from '../types'; export class EditorsService extends Service { - createEditor(defaultMode: DocMode) { - return this.framework.createEntity(Editor, { defaultMode }); + createEditor(defaultMode: DocMode, defaultEditorSelector?: EditorSelector) { + return this.framework.createEntity(Editor, { + defaultMode, + defaultEditorSelector, + }); } } diff --git a/packages/frontend/core/src/modules/editor/types.ts b/packages/frontend/core/src/modules/editor/types.ts new file mode 100644 index 0000000000..b08e002323 --- /dev/null +++ b/packages/frontend/core/src/modules/editor/types.ts @@ -0,0 +1,4 @@ +export type EditorSelector = { + blockIds?: string[]; + elementIds?: string[]; +}; 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 589c494f7d..3d1e62c76a 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 @@ -10,7 +10,12 @@ import { DebugLogger } from '@affine/debug'; import { DocMode, type EdgelessRootService } from '@blocksuite/blocks'; import { Bound, DisposableGroup } from '@blocksuite/global/utils'; import type { AffineEditorContainer } from '@blocksuite/presets'; -import { DocsService, FrameworkScope, useService } from '@toeverything/infra'; +import { + DocsService, + FrameworkScope, + useLiveData, + useService, +} from '@toeverything/infra'; import clsx from 'clsx'; import { useCallback, useEffect, useState } from 'react'; @@ -70,10 +75,14 @@ export function DocPeekPreview({ mode?: DocMode; xywh?: `[${number},${number},${number},${number}]`; }) { - const { doc, workspace, loading } = useEditor(docId, mode); + const { doc, workspace, editor, loading } = useEditor(docId, mode, { + blockIds, + elementIds, + }); const { jumpToTag } = useNavigateHelper(); const workbench = useService(WorkbenchService).workbench; const peekView = useService(PeekViewService).peekView; + const defaultEditorSelector = useLiveData(editor?.selector$); const [editorElement, setEditorElement] = useState(null); @@ -162,7 +171,7 @@ export function DocPeekPreview({ }, [docId, peekView, workbench]); // if sync engine has been synced and the page is null, show 404 page. - if (!doc || !resolvedMode) { + if (!doc || !resolvedMode || !editor) { return loading || !resolvedMode ? ( ) : ( @@ -177,14 +186,15 @@ export function DocPeekPreview({ className={clsx('affine-page-viewport', styles.affineDocViewport)} > - + + + { +export const useEditor = ( + pageId: string, + preferMode?: DocMode, + preferSelector?: EditorSelector +) => { const currentWorkspace = useService(WorkspaceService).workspace; const docsService = useService(DocsService); const docRecordList = docsService.list; const docListReady = useLiveData(docRecordList.isReady$); const docRecord = docRecordList.doc$(pageId).value; const preferModeRef = useRef(preferMode); + const preferSelectorRef = useRef(preferSelector); const [doc, setDoc] = useState(null); const [editor, setEditor] = useState(null); @@ -38,7 +43,10 @@ export const useEditor = (pageId: string, preferMode?: DocMode) => { } const editor = doc.scope .get(EditorsService) - .createEditor(preferModeRef.current || doc.primaryMode$.value); + .createEditor( + preferModeRef.current || doc.primaryMode$.value, + preferSelectorRef.current + ); setEditor(editor); return () => { editor.dispose(); diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page-wrapper.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-wrapper.tsx index 537102194c..4cdd86e9fd 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page-wrapper.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-wrapper.tsx @@ -31,7 +31,17 @@ const useLoadDoc = (pageId: string) => { const queryString = useLiveData( viewService.view.queryString$<{ mode?: string; - }>() + blockIds?: string[]; + elementIds?: string[]; + }>({ + // Cannot handle single id situation correctly: `blockIds=xxx` + arrayFormat: 'none', + types: { + mode: 'string', + blockIds: value => (value.length ? value.split(',') : []), + elementIds: value => (value.length ? value.split(',') : []), + }, + }) ); const queryStringMode = @@ -41,6 +51,10 @@ const useLoadDoc = (pageId: string) => { // We only read the querystring mode when entering, so use useState here. const [initialQueryStringMode] = useState(() => queryStringMode); + const [initialQueryStringSelector] = useState(() => ({ + blockIds: queryString.blockIds, + elementIds: queryString.elementIds, + })); const [doc, setDoc] = useState(null); const [editor, setEditor] = useState(null); @@ -64,13 +78,14 @@ const useLoadDoc = (pageId: string) => { const editor = doc.scope .get(EditorsService) .createEditor( - initialQueryStringMode || doc.getPrimaryMode() || ('page' as DocMode) + initialQueryStringMode || doc.getPrimaryMode() || ('page' as DocMode), + initialQueryStringSelector ); setEditor(editor); return () => { editor.dispose(); }; - }, [doc, initialQueryStringMode]); + }, [doc, initialQueryStringMode, initialQueryStringSelector]); // update editor mode to queryString useEffect(() => { diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx index d176294a06..181f7794e2 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx @@ -32,7 +32,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 type { Map as YMap } from 'yjs'; @@ -91,6 +91,7 @@ const DetailPageImpl = memo(function DetailPageImpl() { const isInTrash = useLiveData(doc.meta$.map(meta => meta.trash)); const { openPage, jumpToPageBlock, jumpToTag } = useNavigateHelper(); const editorContainer = useLiveData(editor.editorContainer$); + const [defaultSelector] = useState(() => editor.selector$.value); const isSideBarOpen = useLiveData(workbench.sidebarOpen$); const { appSettings } = useAppSettingHelper(); @@ -287,6 +288,7 @@ const DetailPageImpl = memo(function DetailPageImpl() { pageId={doc.id} onLoad={onLoad} docCollection={docCollection} + defaultEditorSelector={defaultSelector} />