mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
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:
@@ -1,12 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const blockSuiteEditorStyle = style({
|
||||
maxWidth: 'var(--affine-editor-width)',
|
||||
margin: '0 2rem',
|
||||
padding: '0 24px',
|
||||
});
|
||||
|
||||
export const blockSuiteEditorHeaderStyle = style({
|
||||
marginTop: '40px',
|
||||
marginBottom: '40px',
|
||||
});
|
||||
@@ -1,275 +0,0 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { AffineEditorContainer } from '@blocksuite/presets';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import clsx from 'clsx';
|
||||
import { use } from 'foxact/use';
|
||||
import type { CSSProperties, ReactElement } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { type Map as YMap } from 'yjs';
|
||||
|
||||
import { Skeleton } from '../../ui/skeleton';
|
||||
import {
|
||||
blockSuiteEditorHeaderStyle,
|
||||
blockSuiteEditorStyle,
|
||||
} from './index.css';
|
||||
import { editorSpecs } from './specs';
|
||||
|
||||
interface BlockElement extends Element {
|
||||
path: string[];
|
||||
}
|
||||
|
||||
export type EditorProps = {
|
||||
page: Page;
|
||||
mode: 'page' | 'edgeless';
|
||||
defaultSelectedBlockId?: string;
|
||||
onModeChange?: (mode: 'page' | 'edgeless') => void;
|
||||
// on Editor instance instantiated
|
||||
onLoadEditor?: (editor: AffineEditorContainer) => () => void;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type ErrorBoundaryProps = {
|
||||
onReset?: () => void;
|
||||
};
|
||||
|
||||
// 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) {
|
||||
return;
|
||||
}
|
||||
const element = container.querySelector(
|
||||
`[data-block-id="${blockId}"]`
|
||||
) as BlockElement | null;
|
||||
if (element) {
|
||||
setBlockElement(element);
|
||||
} else if (Date.now() - start < timeout) {
|
||||
setTimeout(run, 100);
|
||||
}
|
||||
}
|
||||
run();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [container, blockId, timeout]);
|
||||
return blockElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
const BlockSuiteEditorImpl = forwardRef<AffineEditorContainer, EditorProps>(
|
||||
(
|
||||
{
|
||||
mode,
|
||||
page,
|
||||
className,
|
||||
defaultSelectedBlockId,
|
||||
onLoadEditor,
|
||||
onModeChange,
|
||||
style,
|
||||
},
|
||||
ref
|
||||
): ReactElement => {
|
||||
usePageRoot(page);
|
||||
|
||||
assertExists(page, 'page should not be null');
|
||||
const editorRef = useRef<AffineEditorContainer | null>(null);
|
||||
if (editorRef.current === null) {
|
||||
editorRef.current = new AffineEditorContainer();
|
||||
editorRef.current.autofocus = true;
|
||||
}
|
||||
const editor = editorRef.current;
|
||||
assertExists(editorRef, 'editorRef.current should not be null');
|
||||
|
||||
if (editor.mode !== mode) {
|
||||
editor.mode = mode;
|
||||
}
|
||||
|
||||
if (editor.page !== page) {
|
||||
editor.page = page;
|
||||
editor.docSpecs = editorSpecs.docModeSpecs;
|
||||
editor.edgelessSpecs = editorSpecs.edgelessModeSpecs;
|
||||
}
|
||||
|
||||
if (ref) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(editor);
|
||||
} else {
|
||||
ref.current = editor;
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (editor) {
|
||||
const disposes: (() => void)[] = [];
|
||||
const disposeModeSwitch = editor.slots.pageModeSwitched.on(mode => {
|
||||
onModeChange?.(mode);
|
||||
});
|
||||
disposes.push(() => disposeModeSwitch?.dispose());
|
||||
if (onLoadEditor) {
|
||||
disposes.push(onLoadEditor(editor));
|
||||
}
|
||||
return () => {
|
||||
disposes.forEach(dispose => dispose());
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [editor, onModeChange, onLoadEditor]);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.append(editor);
|
||||
return () => {
|
||||
editor.remove();
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const blockElement = useBlockElementById(
|
||||
containerRef.current,
|
||||
defaultSelectedBlockId
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (blockElement) {
|
||||
requestIdleCallback(() => {
|
||||
blockElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
});
|
||||
const selectManager = editor.host?.selection;
|
||||
if (!blockElement.path.length || !selectManager) {
|
||||
return;
|
||||
}
|
||||
const newSelection = selectManager.create('block', {
|
||||
path: blockElement.path,
|
||||
});
|
||||
selectManager.set([newSelection]);
|
||||
});
|
||||
}
|
||||
}, [editor, blockElement]);
|
||||
|
||||
// issue: https://github.com/toeverything/AFFiNE/issues/2004
|
||||
return (
|
||||
<div
|
||||
data-testid={`editor-${page.id}`}
|
||||
className={clsx(`editor-wrapper ${editor.mode}-mode`, className)}
|
||||
style={style}
|
||||
ref={containerRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
BlockSuiteEditorImpl.displayName = 'BlockSuiteEditorImpl';
|
||||
|
||||
export const EditorLoading = memo(function EditorLoading() {
|
||||
return (
|
||||
<div className={blockSuiteEditorStyle}>
|
||||
<Skeleton
|
||||
className={blockSuiteEditorHeaderStyle}
|
||||
animation="wave"
|
||||
height={50}
|
||||
/>
|
||||
<Skeleton animation="wave" height={30} width="40%" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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';
|
||||
@@ -1,46 +0,0 @@
|
||||
import {
|
||||
AttachmentService,
|
||||
DocEditorBlockSpecs,
|
||||
EdgelessEditorBlockSpecs,
|
||||
} from '@blocksuite/blocks';
|
||||
import bytes from 'bytes';
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSpecs() {
|
||||
const docModeSpecs = DocEditorBlockSpecs.map(spec => {
|
||||
if (spec.schema.model.flavour === 'affine:attachment') {
|
||||
return {
|
||||
...spec,
|
||||
service: CustomAttachmentService,
|
||||
};
|
||||
}
|
||||
return spec;
|
||||
});
|
||||
const edgelessModeSpecs = EdgelessEditorBlockSpecs.map(spec => {
|
||||
if (spec.schema.model.flavour === 'affine:attachment') {
|
||||
return {
|
||||
...spec,
|
||||
service: CustomAttachmentService,
|
||||
};
|
||||
}
|
||||
return spec;
|
||||
});
|
||||
|
||||
return {
|
||||
docModeSpecs,
|
||||
edgelessModeSpecs,
|
||||
};
|
||||
}
|
||||
|
||||
export const editorSpecs = getSpecs();
|
||||
@@ -8,3 +8,14 @@ export const pageDetailSkeletonTitleStyle = style({
|
||||
height: '52px',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const blockSuiteEditorStyle = style({
|
||||
maxWidth: 'var(--affine-editor-width)',
|
||||
margin: '0 2rem',
|
||||
padding: '0 24px',
|
||||
});
|
||||
|
||||
export const blockSuiteEditorHeaderStyle = style({
|
||||
marginTop: '40px',
|
||||
marginBottom: '40px',
|
||||
});
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { EditorLoading } from '../block-suite-editor';
|
||||
import { Skeleton } from '../../ui/skeleton';
|
||||
import {
|
||||
blockSuiteEditorHeaderStyle,
|
||||
blockSuiteEditorStyle,
|
||||
pageDetailSkeletonStyle,
|
||||
pageDetailSkeletonTitleStyle,
|
||||
} from './index.css';
|
||||
|
||||
export const EditorLoading = () => {
|
||||
return (
|
||||
<div className={blockSuiteEditorStyle}>
|
||||
<Skeleton
|
||||
className={blockSuiteEditorHeaderStyle}
|
||||
animation="wave"
|
||||
height={50}
|
||||
/>
|
||||
<Skeleton animation="wave" height={30} width="40%" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageDetailSkeleton = () => {
|
||||
return (
|
||||
<div className={pageDetailSkeletonStyle}>
|
||||
|
||||
Reference in New Issue
Block a user