mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 20:16:26 +08:00
refactor(core): better block link (#8082)
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user