feat(component): react wrapper for blocksuite editor (#5606)

A React wrapper for blocksuite editor from title/meta/doc/edgless fragments.
This PR only **clones** the `AffineEditorContainer`'s existing behavior and make it easier for extension in affine later.

fix TOV-315

### Some core issues:

A customized version of `createComponent` from `@lit/react`. The [existing and solutions in the community](https://github.com/lit/lit/issues/4435) does not work well in our case.
Alternatively in this PR the approach we have is to create web component instances in React lifecycle and then append them to DOM. However this make it hard to wrap the exported Lit component's using React and therefore we will have an additional wrapper tag for the wrapped web component.

To mitigate the migration issue on using React instead of Lit listed on last day, we now use [a proxy to mimic the wrapped React component](https://github.com/toeverything/AFFiNE/pull/5606/files#diff-5b7f0ae7b52a08739d50e78e9ec803c26ff3d3e5437581c692add0de12d3ede5R142-R183) into an `AffineEditorContainer` instance.
This commit is contained in:
Peng Xiao
2024-01-22 08:25:29 +00:00
parent 735e1cb117
commit f41b7d7e71
23 changed files with 1132 additions and 378 deletions

View File

@@ -1,4 +1,4 @@
import { NoPageRootError } from '@affine/component/block-suite-editor';
import { NoPageRootError } from '@affine/core/components/blocksuite/block-suite-editor';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ContactUS, ErrorDetail } from '../error-basic/error-detail';

View File

@@ -1,8 +1,5 @@
import { Loading, Scrollable } from '@affine/component';
import {
BlockSuiteEditor,
EditorLoading,
} from '@affine/component/block-suite-editor';
import { EditorLoading } from '@affine/component/page-detail-skeleton';
import { Button, IconButton } from '@affine/component/ui/button';
import { ConfirmModal, Modal } from '@affine/component/ui/modal';
import { openSettingModalAtom, type PageMode } from '@affine/core/atoms';
@@ -32,6 +29,7 @@ import { encodeStateAsUpdate } from 'yjs';
import { currentModeAtom } from '../../../atoms/mode';
import { pageHistoryModalAtom } from '../../../atoms/page-history';
import { BlockSuiteEditor } from '../../blocksuite/block-suite-editor';
import { StyledEditorModeSwitch } from '../../blocksuite/block-suite-mode-switch/style';
import {
EdgelessSwitchItem,
@@ -141,7 +139,6 @@ const HistoryEditorPreview = ({
className={styles.editor}
mode={mode}
page={snapshotPage}
onModeChange={onModeChange}
/>
</AffineErrorBoundary>
) : (

View File

@@ -0,0 +1,253 @@
import type { PageMode } from '@affine/core/atoms';
import type { BlockElement } from '@blocksuite/lit';
import type {
AffineEditorContainer,
DocEditor,
EdgelessEditor,
} from '@blocksuite/presets';
import { type Page, Slot } from '@blocksuite/store';
import clsx from 'clsx';
import type React from 'react';
import {
forwardRef,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { BlocksuiteDocEditor, BlocksuiteEdgelessEditor } from './lit-adaper';
import type { InlineRenderers } from './specs';
import * as styles from './styles.css';
// copy forwardSlot from blocksuite, but it seems we need to dispose the pipe
// after the component is unmounted right?
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function forwardSlot<T extends Record<string, Slot<any>>>(
from: T,
to: Partial<T>
) {
Object.entries(from).forEach(([key, slot]) => {
const target = to[key];
if (target) {
slot.pipe(target);
}
});
}
interface BlocksuiteEditorContainerProps {
page: Page;
mode: PageMode;
className?: string;
style?: React.CSSProperties;
defaultSelectedBlockId?: string;
customRenderers?: InlineRenderers;
}
// mimic the interface of the webcomponent and expose slots & host
type BlocksuiteEditorContainerRef = Pick<
(typeof AffineEditorContainer)['prototype'],
'mode' | 'page' | 'model' | 'slots' | 'host'
> &
HTMLDivElement;
function findBlockElementById(container: HTMLElement, blockId: string) {
const element = container.querySelector(
`[data-block-id="${blockId}"]`
) as BlockElement | null;
return element;
}
// a workaround for returning the webcomponent for the given block id
// by iterating over the children of the rendered dom tree
const useBlockElementById = (
container: HTMLElement | null,
blockId: string | undefined,
timeout = 1000
) => {
const [blockElement, setBlockElement] = useState<BlockElement | null>(null);
useEffect(() => {
if (!blockId) {
return;
}
let canceled = false;
const start = Date.now();
function run() {
if (canceled || !container || !blockId) {
return;
}
const element = findBlockElementById(container, blockId);
if (element) {
setBlockElement(element);
} else if (Date.now() - start < timeout) {
setTimeout(run, 100);
}
}
run();
return () => {
canceled = true;
};
}, [container, blockId, timeout]);
return blockElement;
};
export const BlocksuiteEditorContainer = forwardRef<
AffineEditorContainer,
BlocksuiteEditorContainerProps
>(function AffineEditorContainer(
{ page, mode, className, style, defaultSelectedBlockId, customRenderers },
ref
) {
const rootRef = useRef<HTMLDivElement>(null);
const docRef = useRef<DocEditor>(null);
const edgelessRef = useRef<EdgelessEditor>(null);
const slots: BlocksuiteEditorContainerRef['slots'] = useMemo(() => {
return {
pageLinkClicked: new Slot(),
pageModeSwitched: new Slot(),
pageUpdated: new Slot(),
tagClicked: new Slot(),
};
}, []);
// forward the slot to the webcomponent
useLayoutEffect(() => {
requestAnimationFrame(() => {
const docPage = rootRef.current?.querySelector('affine-doc-page');
const edgelessPage = rootRef.current?.querySelector(
'affine-edgeless-page'
);
('affine-edgeless-page');
if (docPage) {
forwardSlot(docPage.slots, slots);
}
if (edgelessPage) {
forwardSlot(edgelessPage.slots, slots);
}
});
}, [page, slots]);
useLayoutEffect(() => {
slots.pageUpdated.emit({ newPageId: page.id });
}, [page, slots.pageUpdated]);
useLayoutEffect(() => {
slots.pageModeSwitched.emit(mode);
}, [mode, slots.pageModeSwitched]);
/**
* mimic an AffineEditorContainer using proxy
*/
const affineEditorContainerProxy = useMemo(() => {
const api = {
slots,
get page() {
return page;
},
get host() {
return mode === 'page'
? docRef.current?.host
: edgelessRef.current?.host;
},
get model() {
return page.root as any;
},
get updateComplete() {
return mode === 'page'
? docRef.current?.updateComplete
: edgelessRef.current?.updateComplete;
},
};
const proxy = new Proxy(api, {
has(_, prop) {
return (
Reflect.has(api, prop) ||
(rootRef.current ? Reflect.has(rootRef.current, prop) : false)
);
},
get(_, prop) {
if (Reflect.has(api, prop)) {
return api[prop as keyof typeof api];
}
if (rootRef.current && Reflect.has(rootRef.current, prop)) {
const maybeFn = Reflect.get(rootRef.current, prop);
if (typeof maybeFn === 'function') {
return maybeFn.bind(rootRef.current);
} else {
return maybeFn;
}
}
return undefined;
},
}) as unknown as AffineEditorContainer;
return proxy;
}, [mode, page, slots]);
useEffect(() => {
if (ref) {
if (typeof ref === 'function') {
ref(affineEditorContainerProxy);
} else {
ref.current = affineEditorContainerProxy;
}
}
}, [affineEditorContainerProxy, ref]);
const blockElement = useBlockElementById(
rootRef.current,
defaultSelectedBlockId
);
useEffect(() => {
if (blockElement) {
requestIdleCallback(() => {
blockElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
});
const selectManager = affineEditorContainerProxy.host?.selection;
if (!blockElement.path.length || !selectManager) {
return;
}
const newSelection = selectManager.create('block', {
path: blockElement.path,
});
selectManager.set([newSelection]);
});
}
}, [blockElement, affineEditorContainerProxy.host?.selection]);
return (
<div
data-testid={`editor-${page.id}`}
className={clsx(
`editor-wrapper ${mode}-mode`,
styles.docEditorRoot,
className
)}
data-affine-editor-container
style={style}
ref={rootRef}
>
{mode === 'page' ? (
<BlocksuiteDocEditor
page={page}
ref={docRef}
customRenderers={customRenderers}
/>
) : (
<BlocksuiteEdgelessEditor
page={page}
ref={edgelessRef}
customRenderers={customRenderers}
/>
)}
</div>
);
});

View File

@@ -0,0 +1,206 @@
import { EditorLoading } from '@affine/component/page-detail-skeleton';
import { usePageMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import { assertExists } from '@blocksuite/global/utils';
import { DateTimeIcon, PageIcon } from '@blocksuite/icons';
import type { AffineEditorContainer } from '@blocksuite/presets';
import type { Page } from '@blocksuite/store';
import { use } from 'foxact/use';
import type { CSSProperties, ReactElement } from 'react';
import {
forwardRef,
memo,
Suspense,
useCallback,
useEffect,
useRef,
} from 'react';
import { type Map as YMap } from 'yjs';
import { BlocksuiteEditorContainer } from './blocksuite-editor-container';
import type { InlineRenderers } from './specs';
export type ErrorBoundaryProps = {
onReset?: () => void;
};
export type EditorProps = {
page: Page;
mode: 'page' | 'edgeless';
defaultSelectedBlockId?: string;
// on Editor instance instantiated
onLoadEditor?: (editor: AffineEditorContainer) => () => void;
style?: CSSProperties;
className?: string;
};
/**
* TODO: Defined async cache to support suspense, instead of reflect symbol to provider persistent error cache.
*/
const PAGE_LOAD_KEY = Symbol('PAGE_LOAD');
const PAGE_ROOT_KEY = Symbol('PAGE_ROOT');
function usePageRoot(page: Page) {
let load$ = Reflect.get(page, PAGE_LOAD_KEY);
if (!load$) {
load$ = page.load();
Reflect.set(page, PAGE_LOAD_KEY, load$);
}
use(load$);
if (!page.root) {
let root$: Promise<void> | undefined = Reflect.get(page, PAGE_ROOT_KEY);
if (!root$) {
root$ = new Promise((resolve, reject) => {
const disposable = page.slots.rootAdded.once(() => {
resolve();
});
window.setTimeout(() => {
disposable.dispose();
reject(new NoPageRootError(page));
}, 20 * 1000);
});
Reflect.set(page, PAGE_ROOT_KEY, root$);
}
use(root$);
}
return page.root;
}
// TODO: this is a placeholder proof-of-concept implementation
function CustomPageReference({
reference,
}: {
reference: HTMLElementTagNameMap['affine-reference'];
}) {
const workspace = reference.page.workspace;
const meta = usePageMetaHelper(workspace);
assertExists(
reference.delta.attributes?.reference?.pageId,
'pageId should exist for page reference'
);
const referencedPage = meta.getPageMeta(
reference.delta.attributes.reference.pageId
);
const title = referencedPage?.title ?? 'not found';
let icon = <PageIcon />;
let lTitle = title.toLowerCase();
if (title.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/)) {
lTitle = new Date(title).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
icon = <DateTimeIcon />;
}
return (
<a
target="_blank"
rel="noopener noreferrer"
className="page-reference"
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '0 0.25em',
columnGap: '0.25em',
}}
>
{icon} <span className="affine-reference-title">{lTitle}</span>
</a>
);
}
const customRenderers: InlineRenderers = {
pageReference(reference) {
return <CustomPageReference reference={reference} />;
},
};
/**
* TODO: Define error to unexpected state together in the future.
*/
export class NoPageRootError extends Error {
constructor(public page: Page) {
super('Page root not found when render editor!');
// Log info to let sentry collect more message
const hasExpectSpace = Array.from(page.rootDoc.spaces.values()).some(
doc => page.spaceDoc.guid === doc.guid
);
const blocks = page.spaceDoc.getMap('blocks') as YMap<YMap<any>>;
const havePageBlock = Array.from(blocks.values()).some(
block => block.get('sys:flavour') === 'affine:page'
);
console.info(
'NoPageRootError current data: %s',
JSON.stringify({
expectPageId: page.id,
expectGuid: page.spaceDoc.guid,
hasExpectSpace,
blockSize: blocks.size,
havePageBlock,
})
);
}
}
const BlockSuiteEditorImpl = forwardRef<AffineEditorContainer, EditorProps>(
function BlockSuiteEditorImpl(
{ mode, page, className, defaultSelectedBlockId, onLoadEditor, style },
ref
) {
usePageRoot(page);
assertExists(page, 'page should not be null');
const editorDisposeRef = useRef<() => void>(() => {});
const editorRef = useRef<AffineEditorContainer | null>(null);
const onRefChange = useCallback(
(editor: AffineEditorContainer | null) => {
editorRef.current = editor;
if (ref) {
if (typeof ref === 'function') {
ref(editor);
} else {
ref.current = editor;
}
}
if (editor && onLoadEditor) {
editorDisposeRef.current = onLoadEditor(editor);
}
},
[onLoadEditor, ref]
);
useEffect(() => {
return () => {
editorDisposeRef.current();
};
}, []);
return (
<BlocksuiteEditorContainer
mode={mode}
page={page}
ref={onRefChange}
className={className}
style={style}
customRenderers={customRenderers}
defaultSelectedBlockId={defaultSelectedBlockId}
/>
);
}
);
export const BlockSuiteEditor = memo(
forwardRef<AffineEditorContainer, EditorProps>(
function BlockSuiteEditor(props, ref): ReactElement {
return (
<Suspense fallback={<EditorLoading />}>
<BlockSuiteEditorImpl key={props.page.id} ref={ref} {...props} />
</Suspense>
);
}
)
);
BlockSuiteEditor.displayName = 'BlockSuiteEditor';

View File

@@ -1,2 +1 @@
export type { EditorProps } from '@affine/component/block-suite-editor';
export { BlockSuiteEditor } from '@affine/component/block-suite-editor';
export * from './blocksuite-editor';

View File

@@ -0,0 +1,97 @@
import { createReactComponentFromLit } from '@affine/component';
import {
BiDirectionalLinkPanel,
DocEditor,
DocTitle,
EdgelessEditor,
PageMetaTags,
} from '@blocksuite/presets';
import { type Page } from '@blocksuite/store';
import clsx from 'clsx';
import React, { forwardRef, useEffect, useMemo, useRef } from 'react';
import {
docModeSpecs,
edgelessModeSpecs,
type InlineRenderers,
patchSpecs,
} from './specs';
import * as styles from './styles.css';
const adapted = {
DocEditor: createReactComponentFromLit({
react: React,
elementClass: DocEditor,
}),
DocTitle: createReactComponentFromLit({
react: React,
elementClass: DocTitle,
}),
PageMetaTags: createReactComponentFromLit({
react: React,
elementClass: PageMetaTags,
}),
EdgelessEditor: createReactComponentFromLit({
react: React,
elementClass: EdgelessEditor,
}),
BiDirectionalLinkPanel: createReactComponentFromLit({
react: React,
elementClass: BiDirectionalLinkPanel,
}),
};
interface BlocksuiteDocEditorProps {
page: Page;
customRenderers?: InlineRenderers;
// todo: add option to replace docTitle with custom component (e.g., for journal page)
}
export const BlocksuiteDocEditor = forwardRef<
DocEditor,
BlocksuiteDocEditorProps
>(function BlocksuiteDocEditor({ page, customRenderers }, ref) {
const titleRef = useRef<DocTitle>(null);
const specs = useMemo(() => {
return patchSpecs(docModeSpecs, customRenderers);
}, [customRenderers]);
useEffect(() => {
// auto focus the title
setTimeout(() => {
if (titleRef.current) {
const richText = titleRef.current.querySelector('rich-text');
richText?.inlineEditor?.focusEnd();
}
});
}, []);
return (
<div className={styles.docEditorRoot}>
<div className={clsx('affine-doc-viewport', styles.affineDocViewport)}>
<adapted.DocTitle page={page} ref={titleRef} />
{/* We will replace page meta tags with our own implementation */}
<adapted.PageMetaTags page={page} />
<adapted.DocEditor
className={styles.docContainer}
ref={ref}
page={page}
specs={specs}
hasViewport={false}
/>
<adapted.BiDirectionalLinkPanel page={page} />
</div>
</div>
);
});
export const BlocksuiteEdgelessEditor = forwardRef<
EdgelessEditor,
BlocksuiteDocEditorProps
>(function BlocksuiteEdgelessEditor({ page, customRenderers }, ref) {
const specs = useMemo(() => {
return patchSpecs(edgelessModeSpecs, customRenderers);
}, [customRenderers]);
return <adapted.EdgelessEditor ref={ref} page={page} specs={specs} />;
});

View File

@@ -0,0 +1,93 @@
import type { BlockSpec } from '@blocksuite/block-std';
import type { ParagraphService } from '@blocksuite/blocks';
import {
AttachmentService,
DocEditorBlockSpecs,
EdgelessEditorBlockSpecs,
} from '@blocksuite/blocks';
import bytes from 'bytes';
import { html, unsafeStatic } from 'lit/static-html.js';
import ReactDOMServer from 'react-dom/server';
class CustomAttachmentService extends AttachmentService {
override mounted(): void {
//TODO: get user type from store
const userType = 'pro';
if (userType === 'pro') {
this.maxFileSize = bytes.parse('100MB');
} else {
this.maxFileSize = bytes.parse('10MB');
}
}
}
type AffineReference = HTMLElementTagNameMap['affine-reference'];
type PageReferenceRenderer = (reference: AffineReference) => React.ReactElement;
export interface InlineRenderers {
pageReference?: PageReferenceRenderer;
}
function patchSpecsWithReferenceRenderer(
specs: BlockSpec<string>[],
pageReferenceRenderer: PageReferenceRenderer
) {
const renderer = (reference: AffineReference) => {
const node = pageReferenceRenderer(reference);
const inner = ReactDOMServer.renderToString(node);
return html`${unsafeStatic(inner)}`;
};
return specs.map(spec => {
if (
['affine:paragraph', 'affine:list', 'affine:database'].includes(
spec.schema.model.flavour
)
) {
// todo: remove these type assertions
spec.service = class extends (spec.service as typeof ParagraphService) {
override mounted() {
super.mounted();
this.referenceNodeConfig.setCustomContent(renderer);
}
};
}
return spec;
});
}
/**
* Patch the block specs with custom renderers.
*/
export function patchSpecs(
specs: BlockSpec<string>[],
inlineRenderers?: InlineRenderers
) {
let newSpecs = specs;
if (inlineRenderers?.pageReference) {
newSpecs = patchSpecsWithReferenceRenderer(
newSpecs,
inlineRenderers.pageReference
);
}
return newSpecs;
}
export const docModeSpecs = DocEditorBlockSpecs.map(spec => {
if (spec.schema.model.flavour === 'affine:attachment') {
return {
...spec,
service: CustomAttachmentService,
};
}
return spec;
});
export const edgelessModeSpecs = EdgelessEditorBlockSpecs.map(spec => {
if (spec.schema.model.flavour === 'affine:attachment') {
return {
...spec,
service: CustomAttachmentService,
};
}
return spec;
});

View File

@@ -0,0 +1,32 @@
import { style } from '@vanilla-extract/css';
export const docEditorRoot = style({
display: 'block',
height: '100%',
overflow: 'hidden',
background: 'var(--affine-background-primary-color)',
});
// brings styles of .affine-doc-viewport from blocksuite
export const affineDocViewport = style({
display: 'flex',
flexDirection: 'column',
height: '100%',
overflowX: 'hidden',
overflowY: 'auto',
userSelect: 'none',
containerName: 'viewport', // todo: find out what this does in bs
containerType: 'inline-size',
background: 'var(--affine-background-primary-color)',
'@media': {
print: {
display: 'none',
zIndex: -1,
},
},
});
export const docContainer = style({
display: 'block',
flexGrow: 1,
});

View File

@@ -14,7 +14,6 @@ import { useLocation } from 'react-router-dom';
import { type PageMode, pageSettingFamily } from '../atoms';
import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper';
import { useBlockSuiteMetaHelper } from '../hooks/affine/use-block-suite-meta-helper';
import { BlockSuiteEditor as Editor } from './blocksuite/block-suite-editor';
import * as styles from './page-detail-editor.css';
@@ -41,16 +40,12 @@ function useRouterHash() {
}
const PageDetailEditorMain = memo(function PageDetailEditorMain({
workspace,
page,
pageId,
onLoad,
isPublic,
publishMode,
}: PageDetailEditorProps & { page: Page }) {
const { switchToEdgelessMode, switchToPageMode } =
useBlockSuiteMetaHelper(workspace);
const pageSettingAtom = pageSettingFamily(pageId);
const pageSetting = useAtomValue(pageSettingAtom);
@@ -74,20 +69,6 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({
return fontStyle.value;
}, [appSettings.fontStyle]);
const setEditorMode = useCallback(
(mode: 'page' | 'edgeless') => {
if (isPublic) {
return;
}
if (mode === 'edgeless') {
switchToEdgelessMode(pageId);
} else {
switchToPageMode(pageId);
}
},
[isPublic, switchToEdgelessMode, pageId, switchToPageMode]
);
const [, setActiveBlocksuiteEditor] = useActiveBlocksuiteEditor();
const blockId = useRouterHash();
@@ -111,6 +92,7 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({
return () => {
disposableGroup.dispose();
setActiveBlocksuiteEditor(null);
};
},
[onLoad, page, setActiveBlocksuiteEditor]
@@ -129,7 +111,6 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({
}
mode={mode}
page={page}
onModeChange={setEditorMode}
defaultSelectedBlockId={blockId}
onLoadEditor={onLoadEditor}
/>

View File

@@ -6,13 +6,7 @@ import type { CommandCategory } from '@toeverything/infra/command';
import clsx from 'clsx';
import { Command, useCommandState } from 'cmdk';
import { useAtom, useAtomValue } from 'jotai';
import {
Suspense,
useCallback,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import { Suspense, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
cmdkQueryAtom,
@@ -194,11 +188,7 @@ export const CMDKContainer = ({
const isInEditor = pageMeta !== undefined;
const [opening, setOpening] = useState(open);
const handleFocus = useCallback((ref: HTMLInputElement | null) => {
if (ref) {
window.setTimeout(() => ref.focus(), 0);
}
}, []);
const inputRef = useRef<HTMLInputElement>(null);
// fix list height animation on openning
useLayoutEffect(() => {
@@ -206,6 +196,7 @@ export const CMDKContainer = ({
setOpening(true);
const timeout = setTimeout(() => {
setOpening(false);
inputRef.current?.focus();
}, 150);
return () => {
clearTimeout(timeout);
@@ -235,7 +226,7 @@ export const CMDKContainer = ({
) : null}
<Command.Input
placeholder={t['com.affine.cmdk.placeholder']()}
ref={handleFocus}
ref={inputRef}
{...rest}
value={query}
onValueChange={onQueryChange}