refactor(core): better block link (#8082)

This commit is contained in:
EYHN
2024-09-04 08:13:21 +00:00
parent 2799e8cd43
commit 01b2339173
17 changed files with 282 additions and 253 deletions
@@ -1,29 +1,32 @@
import type { DocMode } from '@blocksuite/blocks';
import type { AffineEditorContainer } from '@blocksuite/presets';
import type { InlineEditor } from '@blocksuite/inline/inline-editor';
import type { AffineEditorContainer, DocTitle } from '@blocksuite/presets';
import type { DocService, WorkspaceService } from '@toeverything/infra';
import { Entity, LiveData } from '@toeverything/infra';
import { isEqual } from 'lodash-es';
import type { WorkbenchView } from '../../workbench';
import { EditorScope } from '../scopes/editor';
import type { EditorSelector } from '../types';
import { scrollAnchoring } from '../utils/scroll-anchoring';
export class Editor extends Entity<{
defaultMode: DocMode;
defaultEditorSelector?: EditorSelector;
}> {
export class Editor extends Entity {
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 mode$ = new LiveData<DocMode>('page');
readonly selector$ = new LiveData<EditorSelector | undefined>(undefined);
readonly doc = this.docService.doc;
readonly isSharedMode =
this.workspaceService.workspace.openOptions.isSharedMode;
readonly editorContainer$ = new LiveData<AffineEditorContainer | null>(null);
setSelector(selector: EditorSelector | undefined) {
this.selector$.next(selector);
}
toggleMode() {
this.mode$.next(this.mode$.value === 'edgeless' ? 'page' : 'edgeless');
}
@@ -36,6 +39,149 @@ export class Editor extends Entity<{
this.editorContainer$.next(editorContainer);
}
/**
* sync editor params with view query string
*/
bindWorkbenchView(view: WorkbenchView) {
// eslint-disable-next-line rxjs/finnish
const viewParams$ = view.queryString$<{
mode?: DocMode;
blockIds?: string[];
elementIds?: string[];
refreshKey?: string;
}>({
// Cannot handle single id situation correctly: `blockIds=xxx`
arrayFormat: 'none',
types: {
mode: value =>
value === 'page' || value === 'edgeless' ? value : undefined,
blockIds: value => (value.length ? value.split(',') : []),
elementIds: value => (value.length ? value.split(',') : []),
refreshKey: 'string',
},
});
const stablePrimaryMode = this.doc.getPrimaryMode();
const editorParams$ = LiveData.computed(get => {
const selector = get(this.selector$);
return {
mode: get(this.mode$),
blockIds: selector?.blockIds,
elementIds: selector?.elementIds,
refreshKey: selector?.refreshKey,
};
});
// prevent infinite loop
let updating = false;
const unsubscribeViewParams = viewParams$.subscribe(params => {
if (updating) return;
updating = true;
// when view params changed, sync to editor
try {
const mode =
viewParams$.value.mode || stablePrimaryMode || ('page' as DocMode);
if (mode !== editorParams$.value.mode) {
this.setMode(mode);
}
const newSelector = {
blockIds: params.blockIds,
elementIds: params.elementIds,
refreshKey: params.refreshKey,
};
if (!isEqual(newSelector, editorParams$.value)) {
this.setSelector({
blockIds: params.blockIds,
elementIds: params.elementIds,
refreshKey: params.refreshKey,
});
}
} finally {
updating = false;
}
});
const unsubscribeEditorParams = editorParams$.subscribe(params => {
if (updating) return;
updating = true;
try {
// when editor params changed, sync to view
const newQueryString: any = {};
let updated = false;
if (params.mode !== viewParams$.value.mode) {
newQueryString.mode = params.mode;
updated = true;
}
const stringBlockIds = params.blockIds?.join(',');
const stringElementIds = params.elementIds?.join(',');
const stringViewBlockIds = viewParams$.value.blockIds?.join(',');
const stringViewElementIds = viewParams$.value.elementIds?.join(',');
if (
stringBlockIds !== stringViewBlockIds ||
stringElementIds !== stringViewElementIds ||
params.refreshKey !== viewParams$.value.refreshKey
) {
newQueryString.blockIds = stringBlockIds;
newQueryString.elementIds = stringElementIds;
newQueryString.refreshKey = params.refreshKey;
updated = true;
}
if (updated) {
view.updateQueryString(newQueryString, { replace: true });
}
} finally {
updating = false;
}
});
return () => {
unsubscribeEditorParams.unsubscribe();
unsubscribeViewParams.unsubscribe();
};
}
bindEditorContainer(
editorContainer: AffineEditorContainer,
docTitle: DocTitle
) {
const focusAt$ = LiveData.computed(get => {
const selector = get(this.selector$);
const id =
get(this.mode$) === 'edgeless' && selector?.elementIds?.length
? selector?.elementIds?.[0]
: selector?.blockIds?.[0];
if (id) {
return { id, refreshKey: selector?.refreshKey };
} else {
return null;
}
});
if (focusAt$.value === null) {
const title = docTitle.querySelector<
HTMLElement & { inlineEditor: InlineEditor }
>('rich-text');
title?.inlineEditor.focusEnd();
}
const unsubscribe = focusAt$
.distinctUntilChanged(
(a, b) => a?.id === b?.id && a?.refreshKey === b?.refreshKey
)
.subscribe(params => {
if (params?.id) {
const std = editorContainer.host?.std;
if (std) {
scrollAnchoring(std, this.mode$.value, params.id);
}
}
});
return () => {
unsubscribe.unsubscribe();
};
}
constructor(
private readonly docService: DocService,
private readonly workspaceService: WorkspaceService
@@ -1,14 +1,9 @@
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, defaultEditorSelector?: EditorSelector) {
return this.framework.createEntity(Editor, {
defaultMode,
defaultEditorSelector,
});
createEditor() {
return this.framework.createEntity(Editor);
}
}
@@ -1,4 +1,5 @@
export type EditorSelector = {
blockIds?: string[];
elementIds?: string[];
refreshKey?: string;
};
@@ -0,0 +1,109 @@
import type { BlockStdScope } from '@blocksuite/block-std';
import type {
DocMode,
EdgelessRootService,
PageRootService,
} from '@blocksuite/blocks';
import { Bound, deserializeXYWH } from '@blocksuite/global/utils';
function scrollAnchoringInEdgelessMode(
service: EdgelessRootService,
id: string
) {
requestAnimationFrame(() => {
let isNotInNote = true;
let bounds: Bound | null = null;
const blockComponent = service.std.view.getBlock(id);
const parentComponent = blockComponent?.parentComponent;
if (parentComponent && parentComponent.flavour === 'affine:note') {
isNotInNote = false;
const selection = parentComponent.std.selection;
if (!selection) return;
selection.set([
selection.create('block', {
blockId: id,
}),
]);
const { left: x, width: w } = parentComponent.getBoundingClientRect();
const { top: y, height: h } = blockComponent.getBoundingClientRect();
const coord = service.viewport.toModelCoordFromClientCoord([x, y]);
bounds = new Bound(
coord[0],
coord[1],
w / service.viewport.zoom,
h / service.viewport.zoom
);
} else {
if (!service.getElementById) return;
const model = service.getElementById(id);
if (!model) return;
bounds = Bound.fromXYWH(deserializeXYWH(model.xywh));
}
if (!bounds) return;
if (isNotInNote) {
service.selection.set({
elements: [id],
editing: false,
});
}
const { zoom, centerX, centerY } = service.getFitToScreenData(
[20, 20, 20, 20],
[bounds]
);
service.viewport.setViewport(zoom, [centerX, centerY]);
// const surfaceComponent = service.std.view.getBlock(service.surface.id);
// if (!surfaceComponent) return;
// (surfaceComponent as SurfaceBlockComponent).refresh();
// TODO(@fundon): toolbar should be hidden
});
}
function scrollAnchoringInPageMode(service: PageRootService, id: string) {
const blockComponent = service.std.view.getBlock(id);
if (!blockComponent) return;
blockComponent.scrollIntoView({
behavior: 'instant',
block: 'center',
});
const selection = service.std.selection;
if (!selection) return;
selection.set([
selection.create('block', {
blockId: id,
}),
]);
// TODO(@fundon): toolbar should be hidden
}
// TODO(@fundon): it should be a command
export function scrollAnchoring(std: BlockStdScope, mode: DocMode, id: string) {
if (mode === 'edgeless') {
const service = std.getService<EdgelessRootService>('affine:page');
if (!service) return;
scrollAnchoringInEdgelessMode(service, id);
return;
}
const service = std.getService<PageRootService>('affine:page');
if (!service) return;
scrollAnchoringInPageMode(service, id);
}
@@ -5,16 +5,17 @@ import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error
import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor';
import { EditorOutlineViewer } from '@affine/core/components/blocksuite/outline-viewer';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { EditorService } from '@affine/core/modules/editor';
import { PageNotFound } from '@affine/core/pages/404';
import { DebugLogger } from '@affine/debug';
import type { DocMode, EdgelessRootService } from '@blocksuite/blocks';
import { Bound, DisposableGroup } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
import {
DocsService,
FrameworkScope,
useLiveData,
useService,
useServices,
} from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useEffect, useState } from 'react';
@@ -62,27 +63,21 @@ function fitViewport(
}
}
export function DocPeekPreview({
docId,
blockIds,
elementIds,
mode,
function DocPeekPreviewEditor({
xywh,
}: {
docId: string;
blockIds?: string[];
elementIds?: string[];
mode?: DocMode;
xywh?: `[${number},${number},${number},${number}]`;
}) {
const { doc, workspace, editor, loading } = useEditor(docId, mode, {
blockIds,
elementIds,
const { editorService } = useServices({
EditorService,
});
const editor = editorService.editor;
const doc = editor.doc;
const workspace = editor.doc.workspace;
const mode = useLiveData(editor.mode$);
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);
@@ -90,27 +85,16 @@ export function DocPeekPreview({
setEditorElement(editor);
};
const docs = useService(DocsService);
const [resolvedMode, setResolvedMode] = useState<DocMode | undefined>(mode);
useEffect(() => {
editorElement?.updateComplete
.then(() => {
if (resolvedMode === 'edgeless') {
if (mode === 'edgeless') {
fitViewport(editorElement, xywh);
}
})
.catch(console.error);
return;
}, [editorElement, resolvedMode, xywh]);
useEffect(() => {
if (!mode || !resolvedMode) {
setResolvedMode(
docs.list.doc$(docId).value?.primaryMode$.value || 'page'
);
}
}, [docId, docs.list, resolvedMode, mode]);
}, [editorElement, mode, xywh]);
useEffect(() => {
const disposable = AIProvider.slots.requestOpenWithChat.on(() => {
@@ -164,20 +148,11 @@ export function DocPeekPreview({
}, [editorElement, jumpToTag, peekView, workspace.id]);
const openOutlinePanel = useCallback(() => {
workbench.openDoc(docId);
workbench.openDoc(doc.id);
workbench.openSidebar();
workbench.activeView$.value.activeSidebarTab('outline');
peekView.close();
}, [docId, peekView, workbench]);
// if sync engine has been synced and the page is null, show 404 page.
if (!doc || !resolvedMode || !editor) {
return loading || !resolvedMode ? (
<PageDetailSkeleton key="current-page-is-null" />
) : (
<PageNotFound noPermission />
);
}
}, [doc, peekView, workbench]);
return (
<AffineErrorBoundary>
@@ -185,20 +160,15 @@ export function DocPeekPreview({
<Scrollable.Viewport
className={clsx('affine-page-viewport', styles.affineDocViewport)}
>
<FrameworkScope scope={doc.scope}>
<FrameworkScope scope={editor.scope}>
<BlockSuiteEditor
ref={onRef}
className={styles.editor}
mode={resolvedMode}
defaultEditorSelector={defaultEditorSelector}
page={doc.blockSuiteDoc}
/>
</FrameworkScope>
</FrameworkScope>
<BlockSuiteEditor
ref={onRef}
className={styles.editor}
mode={mode}
page={doc.blockSuiteDoc}
/>
<EditorOutlineViewer
editor={editorElement}
show={resolvedMode === 'page'}
show={mode === 'page'}
openOutlinePanel={openOutlinePanel}
/>
</Scrollable.Viewport>
@@ -208,3 +178,39 @@ export function DocPeekPreview({
</AffineErrorBoundary>
);
}
export function DocPeekPreview({
docId,
blockIds,
elementIds,
mode,
xywh,
}: {
docId: string;
blockIds?: string[];
elementIds?: string[];
mode?: DocMode;
xywh?: `[${number},${number},${number},${number}]`;
}) {
const { doc, editor, loading } = useEditor(docId, mode, {
blockIds,
elementIds,
});
// if sync engine has been synced and the page is null, show 404 page.
if (!doc || !editor) {
return loading ? (
<PageDetailSkeleton key="current-page-is-null" />
) : (
<PageNotFound noPermission />
);
}
return (
<FrameworkScope scope={doc.scope}>
<FrameworkScope scope={editor.scope}>
<DocPeekPreviewEditor xywh={xywh} />
</FrameworkScope>
</FrameworkScope>
);
}
@@ -41,12 +41,9 @@ export const useEditor = (
if (!doc) {
return;
}
const editor = doc.scope
.get(EditorsService)
.createEditor(
preferModeRef.current || doc.primaryMode$.value,
preferSelectorRef.current
);
const editor = doc.scope.get(EditorsService).createEditor();
editor.setMode(preferModeRef.current || doc.primaryMode$.value);
editor.setSelector(preferSelectorRef.current);
setEditor(editor);
return () => {
editor.dispose();
@@ -86,9 +86,9 @@ export class View extends Entity<{
parseBooleans: true,
}
) {
return this.location$.map(
location => queryString.parse(location.search, options) as Partial<T>
);
return this.location$
.selector(v => v.search)
.map(search => queryString.parse(search, options) as Partial<T>);
}
updateQueryString<T extends Record<string, unknown>>(
@@ -1,3 +1,4 @@
export { View as WorkbenchView } from './entities/view';
export { Workbench } from './entities/workbench';
export { ViewScope } from './scopes/view';
export { WorkbenchService } from './services/workbench';