refactor(core): refactor editor query string selector (#8058)

The editor selector is the information for locating a block, which can automatically focus on a certain content when a user opens a document.

```
export type EditorSelector = {
  blockIds?: string[];
  elementIds?: string[];
};
```

The selector can be set from multiple places, such as passing it in the center peek parameter, or passing it in the query part of the URL.

This pr decoupled the selector from the query string and now available at `editorService.editor.selector$`
This commit is contained in:
EYHN
2024-09-03 06:37:58 +00:00
parent f9d0a348c4
commit a6484018ef
14 changed files with 240 additions and 131 deletions

View File

@@ -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<HTMLDivElement>(null);
const docRef = useRef<PageEditor>(null);
const docTitleRef = useRef<DocTitle>(null);
const edgelessRef = useRef<EdgelessEditor>(null);
const [anchor, setAnchor] = useState<string | null>(null);
const [anchor] = useState<string | null>(() => {
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 (
<div
data-testid={`editor-${page.id}`}
@@ -215,7 +238,13 @@ export const BlocksuiteEditorContainer = forwardRef<
ref={rootRef}
>
{mode === 'page' ? (
<BlocksuiteDocEditor shared={shared} page={page} ref={docRef} />
<BlocksuiteDocEditor
shared={shared}
page={page}
ref={docRef}
titleRef={docTitleRef}
onClickBlank={handleClickPageModeBlank}
/>
) : (
<BlocksuiteEdgelessEditor
shared={shared}

View File

@@ -1,4 +1,5 @@
import { EditorLoading } from '@affine/component/page-detail-skeleton';
import type { EditorSelector } from '@affine/core/modules/editor';
import type { DocMode } from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
@@ -29,8 +30,7 @@ export type EditorProps = {
onLoadEditor?: (editor: AffineEditorContainer) => () => void;
style?: CSSProperties;
className?: string;
blockIds?: string[];
elementIds?: string[];
defaultEditorSelector?: EditorSelector;
};
function usePageRoot(page: Doc) {
@@ -64,8 +64,7 @@ const BlockSuiteEditorImpl = forwardRef<AffineEditorContainer, EditorProps>(
onLoadEditor,
shared,
style,
blockIds,
elementIds,
defaultEditorSelector,
},
ref
) {
@@ -116,8 +115,7 @@ const BlockSuiteEditorImpl = forwardRef<AffineEditorContainer, EditorProps>(
ref={onRefChange}
className={className}
style={style}
blockIds={blockIds}
elementIds={elementIds}
defaultEditorSelector={defaultEditorSelector}
/>
);
}

View File

@@ -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<DocTitle>(null);
BlocksuiteEditorProps & {
onClickBlank?: () => void;
titleRef?: React.Ref<DocTitle>;
}
>(function BlocksuiteDocEditor(
{ page, shared, onClickBlank, titleRef: externalTitleRef },
ref
) {
const titleRef = useRef<DocTitle | null>(null);
const docRef = useRef<PageEditor | null>(null);
const [docPage, setDocPage] =
useState<HTMLElementTagNameMap['affine-page-root']>();
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<
<>
<div className={styles.affineDocViewport} style={{ height: '100%' }}>
{!isJournal ? (
<adapted.DocTitle doc={page} ref={titleRef} />
<adapted.DocTitle doc={page} ref={onTitleRef} />
) : (
<BlocksuiteEditorJournalDocTitle page={page} />
)}
@@ -211,14 +206,7 @@ export const BlocksuiteDocEditor = forwardRef<
specs={specs}
hasViewport={false}
/>
{docPage && !page.readonly ? (
<div
className={styles.docEditorGap}
onClick={() => {
docPage.std.command.exec('appendParagraph' as never, {});
}}
></div>
) : null}
<div className={styles.docEditorGap} onClick={onClickBlank}></div>
{!shared && settings.displayBiDirectionalLink ? (
<BiDirectionalLinkPanel />
) : null}

View File

@@ -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 (
<Suspense>
<PageDetailEditorMain {...props} page={page} />
</Suspense>
);
return <PageDetailEditorMain {...props} page={page} />;
};

View File

@@ -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<EditorSelector | undefined>(
this.props.defaultEditorSelector
);
readonly doc = this.docService.doc;
readonly isSharedMode =
this.workspaceService.workspace.openOptions.isSharedMode;

View File

@@ -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

View File

@@ -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,
});
}
}

View File

@@ -0,0 +1,4 @@
export type EditorSelector = {
blockIds?: string[];
elementIds?: string[];
};

View File

@@ -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<AffineEditorContainer | null>(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 ? (
<PageDetailSkeleton key="current-page-is-null" />
) : (
@@ -177,14 +186,15 @@ export function DocPeekPreview({
className={clsx('affine-page-viewport', styles.affineDocViewport)}
>
<FrameworkScope scope={doc.scope}>
<BlockSuiteEditor
ref={onRef}
className={styles.editor}
mode={resolvedMode}
blockIds={blockIds}
elementIds={elementIds}
page={doc.blockSuiteDoc}
/>
<FrameworkScope scope={editor.scope}>
<BlockSuiteEditor
ref={onRef}
className={styles.editor}
mode={resolvedMode}
defaultEditorSelector={defaultEditorSelector}
page={doc.blockSuiteDoc}
/>
</FrameworkScope>
</FrameworkScope>
<EditorOutlineViewer
editor={editorElement}

View File

@@ -8,15 +8,20 @@ import {
} from '@toeverything/infra';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { type Editor, EditorsService } from '../../editor';
import { type Editor, type EditorSelector, EditorsService } from '../../editor';
export const useEditor = (pageId: string, preferMode?: DocMode) => {
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<Doc | null>(null);
const [editor, setEditor] = useState<Editor | null>(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();

View File

@@ -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<Doc | null>(null);
const [editor, setEditor] = useState<Editor | null>(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(() => {

View File

@@ -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}
/>
</Scrollable.Viewport>
<Scrollable.Scrollbar