mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +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}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// TODO: Check `input` , `loading`, not migrated from `design`
|
||||
export * from './lit-react';
|
||||
export * from './styles';
|
||||
export * from './ui/avatar';
|
||||
export * from './ui/button';
|
||||
|
||||
340
packages/frontend/component/src/lit-react/create-component.ts
Normal file
340
packages/frontend/component/src/lit-react/create-component.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2018 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
const DEV_MODE = process.env.NODE_ENV !== 'production';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type DistributiveOmit<T, K extends string | number | symbol> = T extends any
|
||||
? K extends keyof T
|
||||
? Omit<T, K>
|
||||
: T
|
||||
: T;
|
||||
type PropsWithoutRef<T> = DistributiveOmit<T, 'ref'>;
|
||||
|
||||
/**
|
||||
* Creates a type to be used for the props of a web component used directly in
|
||||
* React JSX.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```ts
|
||||
* declare module "react" {
|
||||
* namespace JSX {
|
||||
* interface IntrinsicElements {
|
||||
* 'x-foo': WebComponentProps<XFoo>;
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type WebComponentProps<I extends HTMLElement> = React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<I>,
|
||||
I
|
||||
> &
|
||||
ElementProps<I>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
type EmptyObject = {};
|
||||
|
||||
/**
|
||||
* Type of the React component wrapping the web component. This is the return
|
||||
* type of `createComponent`.
|
||||
*/
|
||||
export type ReactWebComponent<
|
||||
I extends HTMLElement,
|
||||
E extends EventNames = EmptyObject,
|
||||
> = React.ForwardRefExoticComponent<
|
||||
// TODO(augustjk): Remove and use `React.PropsWithoutRef` when
|
||||
// https://github.com/preactjs/preact/issues/4124 is fixed.
|
||||
PropsWithoutRef<ComponentProps<I, E>> & React.RefAttributes<I>
|
||||
>;
|
||||
|
||||
// Props derived from custom element class. Currently has limitations of making
|
||||
// all properties optional and also surfaces life cycle methods in autocomplete.
|
||||
// TODO(augustjk) Consider omitting keyof LitElement to remove "internal"
|
||||
// lifecycle methods or allow user to explicitly provide props.
|
||||
type ElementProps<I> = Partial<Omit<I, keyof HTMLElement>>;
|
||||
|
||||
// Acceptable props to the React component.
|
||||
type ComponentProps<I, E extends EventNames = EmptyObject> = Omit<
|
||||
React.HTMLAttributes<I>,
|
||||
// Prefer type of provided event handler props or those on element over
|
||||
// built-in HTMLAttributes
|
||||
keyof E | keyof ElementProps<I>
|
||||
> &
|
||||
EventListeners<E> &
|
||||
ElementProps<I>;
|
||||
|
||||
/**
|
||||
* Type used to cast an event name with an event type when providing the
|
||||
* `events` option to `createComponent` for better typing of the event handler
|
||||
* prop.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```ts
|
||||
* const FooComponent = createComponent({
|
||||
* ...
|
||||
* events: {
|
||||
* onfoo: 'foo' as EventName<FooEvent>,
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* `onfoo` prop will have the type `(e: FooEvent) => void`.
|
||||
*/
|
||||
export type EventName<T extends Event = Event> = string & {
|
||||
__eventType: T;
|
||||
};
|
||||
|
||||
// A key value map matching React prop names to event names.
|
||||
type EventNames = Record<string, EventName | string>;
|
||||
|
||||
// A map of expected event listener types based on EventNames.
|
||||
type EventListeners<R extends EventNames> = {
|
||||
[K in keyof R]?: R[K] extends EventName
|
||||
? (e: R[K]['__eventType']) => void
|
||||
: (e: Event) => void;
|
||||
};
|
||||
|
||||
export interface Options<
|
||||
I extends HTMLElement,
|
||||
E extends EventNames = EmptyObject,
|
||||
> {
|
||||
react: typeof React;
|
||||
tagName?: string; // default to `div`
|
||||
elementClass: Constructor<I>;
|
||||
events?: E;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
type Constructor<T> = { new (): T };
|
||||
|
||||
const reservedReactProperties = new Set([
|
||||
'children',
|
||||
'localName',
|
||||
'ref',
|
||||
'style',
|
||||
'className',
|
||||
]);
|
||||
|
||||
const listenedEvents = new WeakMap<Element, Map<string, EventListenerObject>>();
|
||||
|
||||
/**
|
||||
* Adds an event listener for the specified event to the given node. In the
|
||||
* React setup, there should only ever be one event listener. Thus, for
|
||||
* efficiency only one listener is added and the handler for that listener is
|
||||
* updated to point to the given listener function.
|
||||
*/
|
||||
const addOrUpdateEventListener = (
|
||||
node: Element,
|
||||
event: string,
|
||||
listener: (event?: Event) => void
|
||||
) => {
|
||||
let events = listenedEvents.get(node);
|
||||
if (events === undefined) {
|
||||
listenedEvents.set(node, (events = new Map()));
|
||||
}
|
||||
let handler = events.get(event);
|
||||
if (listener !== undefined) {
|
||||
// If necessary, add listener and track handler
|
||||
if (handler === undefined) {
|
||||
events.set(event, (handler = { handleEvent: listener }));
|
||||
node.addEventListener(event, handler);
|
||||
// Otherwise just update the listener with new value
|
||||
} else {
|
||||
handler.handleEvent = listener;
|
||||
}
|
||||
// Remove listener if one exists and value is undefined
|
||||
} else if (handler !== undefined) {
|
||||
events.delete(event);
|
||||
node.removeEventListener(event, handler);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets properties and events on custom elements. These properties and events
|
||||
* have been pre-filtered so we know they should apply to the custom element.
|
||||
*/
|
||||
const setProperty = <E extends Element>(
|
||||
node: E,
|
||||
name: string,
|
||||
value: unknown,
|
||||
old: unknown,
|
||||
events?: EventNames
|
||||
) => {
|
||||
const event = events?.[name];
|
||||
// Dirty check event value.
|
||||
if (event !== undefined && value !== old) {
|
||||
addOrUpdateEventListener(node, event, value as (e?: Event) => void);
|
||||
return;
|
||||
}
|
||||
// But don't dirty check properties; elements are assumed to do this.
|
||||
node[name as keyof E] = value as E[keyof E];
|
||||
|
||||
// This block is to replicate React's behavior for attributes of native
|
||||
// elements where `undefined` or `null` values result in attributes being
|
||||
// removed.
|
||||
// https://github.com/facebook/react/blob/899cb95f52cc83ab5ca1eb1e268c909d3f0961e7/packages/react-dom-bindings/src/client/DOMPropertyOperations.js#L107-L141
|
||||
//
|
||||
// It's only needed here for native HTMLElement properties that reflect
|
||||
// attributes of the same name but don't have that behavior like "id" or
|
||||
// "draggable".
|
||||
if (
|
||||
(value === undefined || value === null) &&
|
||||
name in HTMLElement.prototype
|
||||
) {
|
||||
node.removeAttribute(name);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a React component for a custom element. Properties are distinguished
|
||||
* from attributes automatically, and events can be configured so they are added
|
||||
* to the custom element as event listeners.
|
||||
*
|
||||
* note from pengx17:
|
||||
* This is a workaround for https://github.com/lit/lit/issues/4435
|
||||
*
|
||||
* Instead of directly using tag names of the custom elements, we create instance and then
|
||||
* append to the container instead. An issue in this workaround is that the custom element
|
||||
* will always be wrapped in an additional tag (the container, e.g. a div).
|
||||
*
|
||||
* @param options An options bag containing the parameters needed to generate a
|
||||
* wrapped web component.
|
||||
*
|
||||
* @param options.react The React module, typically imported from the `react`
|
||||
* npm package.
|
||||
* @param options.tagName The custom element tag name registered via
|
||||
* `customElements.define`.
|
||||
* @param options.elementClass The custom element class registered via
|
||||
* `customElements.define`.
|
||||
* @param options.events An object listing events to which the component can
|
||||
* listen. The object keys are the event property names passed in via React
|
||||
* props and the object values are the names of the corresponding events
|
||||
* generated by the custom element. For example, given `{onactivate:
|
||||
* 'activate'}` an event function may be passed via the component's `onactivate`
|
||||
* prop and will be called when the custom element fires its `activate` event.
|
||||
* @param options.displayName A React component display name, used in debugging
|
||||
* messages. Default value is inferred from the name of custom element class
|
||||
* registered via `customElements.define`.
|
||||
*/
|
||||
export const createComponent = <
|
||||
I extends HTMLElement,
|
||||
E extends EventNames = EmptyObject,
|
||||
>({
|
||||
react: React,
|
||||
tagName = 'div',
|
||||
elementClass,
|
||||
events,
|
||||
displayName,
|
||||
}: Options<I, E>): ReactWebComponent<I, E> => {
|
||||
const eventProps = new Set(Object.keys(events ?? {}));
|
||||
|
||||
if (DEV_MODE) {
|
||||
for (const p of reservedReactProperties) {
|
||||
if (p in elementClass.prototype && !(p in HTMLElement.prototype)) {
|
||||
// Note, this effectively warns only for `ref` since the other
|
||||
// reserved props are on HTMLElement.prototype. To address this
|
||||
// would require crawling down the prototype, which doesn't feel worth
|
||||
// it since implementing these properties on an element is extremely
|
||||
// rare.
|
||||
console.warn(
|
||||
`${tagName} contains property ${p} which is a React reserved ` +
|
||||
`property. It will be used by React and not set on the element.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Props = ComponentProps<I, E>;
|
||||
|
||||
const ReactComponent = React.forwardRef<I, Props>((props, ref) => {
|
||||
const containerRef = React.useRef<HTMLElement | null>(null);
|
||||
const prevPropsRef = React.useRef<Props | null>(null);
|
||||
const elementRef = React.useRef<I | null>(null);
|
||||
|
||||
// Props to be passed to React.createElement
|
||||
const reactProps: Record<string, unknown> = {
|
||||
'data-lit-react-wrapper': elementClass.name,
|
||||
};
|
||||
const elementProps: Record<string, unknown> = {};
|
||||
|
||||
if (elementRef.current === null) {
|
||||
const element = new elementClass();
|
||||
elementRef.current = element;
|
||||
if (typeof ref === 'function') {
|
||||
ref(elementRef.current);
|
||||
} else if (ref) {
|
||||
ref.current = element;
|
||||
}
|
||||
}
|
||||
|
||||
const element = elementRef.current;
|
||||
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (reservedReactProperties.has(k)) {
|
||||
reactProps[k] = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventProps.has(k) || k in elementClass.prototype) {
|
||||
elementProps[k] = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
reactProps[k] = v;
|
||||
}
|
||||
|
||||
// This one has no dependency array so it'll run on every re-render.
|
||||
React.useLayoutEffect(() => {
|
||||
if (elementRef.current === null) {
|
||||
return;
|
||||
}
|
||||
for (const prop in elementProps) {
|
||||
setProperty(
|
||||
elementRef.current,
|
||||
prop,
|
||||
props[prop],
|
||||
prevPropsRef.current ? prevPropsRef.current[prop] : undefined,
|
||||
events
|
||||
);
|
||||
}
|
||||
// Note, the spirit of React might be to "unset" any old values that
|
||||
// are no longer included; however, there's no reasonable value to set
|
||||
// them to so we just leave the previous state as is.
|
||||
|
||||
prevPropsRef.current = props;
|
||||
});
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.append(element);
|
||||
return () => {
|
||||
element.remove();
|
||||
};
|
||||
}, [element]);
|
||||
|
||||
return React.createElement(tagName, {
|
||||
...reactProps,
|
||||
ref: React.useCallback(
|
||||
(node: HTMLElement) => {
|
||||
containerRef.current = node;
|
||||
},
|
||||
[containerRef]
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
ReactComponent.displayName = displayName ?? elementClass.name;
|
||||
|
||||
return ReactComponent;
|
||||
};
|
||||
1
packages/frontend/component/src/lit-react/index.ts
Normal file
1
packages/frontend/component/src/lit-react/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createComponent as createReactComponentFromLit } from './create-component';
|
||||
3
packages/frontend/component/src/lit-react/readme.md
Normal file
3
packages/frontend/component/src/lit-react/readme.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# our custom @lit/react wrapper
|
||||
|
||||
The official @lit/react createComponent has an issue with properties that accessed in `connectedCallback` lifecycle hook in lit.
|
||||
@@ -309,3 +309,10 @@ body {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* A hack to make the anchor wrapper not affect the layout of the page.
|
||||
*/
|
||||
[data-lit-react-wrapper] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
@@ -1,2 +1 @@
|
||||
export type { EditorProps } from '@affine/component/block-suite-editor';
|
||||
export { BlockSuiteEditor } from '@affine/component/block-suite-editor';
|
||||
export * from './blocksuite-editor';
|
||||
|
||||
@@ -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} />;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -400,7 +400,9 @@ test('can use cmdk to search page content and scroll to it, then the block will
|
||||
await page.keyboard.press('Enter', { delay: 10 });
|
||||
}
|
||||
await page.keyboard.insertText('123456');
|
||||
const textBlock = page.locator('affine-editor-container').getByText('123456');
|
||||
const textBlock = page
|
||||
.locator('[data-affine-editor-container]')
|
||||
.getByText('123456');
|
||||
await expect(textBlock).toBeVisible();
|
||||
await clickSideBarAllPageButton(page);
|
||||
await openQuickSearchByShortcut(page);
|
||||
@@ -414,7 +416,7 @@ test('can use cmdk to search page content and scroll to it, then the block will
|
||||
await waitForScrollToFinish(page);
|
||||
const isVisitable = await checkElementIsInView(
|
||||
page,
|
||||
page.locator('affine-editor-container').getByText('123456')
|
||||
page.locator('[data-affine-editor-container]').getByText('123456')
|
||||
);
|
||||
expect(isVisitable).toBe(true);
|
||||
const selectionElement = page.locator('affine-block-selection');
|
||||
|
||||
58
tests/storybook/src/stories/blocksuite-editor.stories.tsx
Normal file
58
tests/storybook/src/stories/blocksuite-editor.stories.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor';
|
||||
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
|
||||
import { Workspace } from '@blocksuite/store';
|
||||
import { Schema } from '@blocksuite/store';
|
||||
import type { StoryFn } from '@storybook/react';
|
||||
import { initEmptyPage } from '@toeverything/infra/blocksuite';
|
||||
|
||||
const schema = new Schema();
|
||||
schema.register(AffineSchemas).register(__unstableSchemas);
|
||||
|
||||
async function createAndInitPage(
|
||||
workspace: Workspace,
|
||||
title: string,
|
||||
preview: string
|
||||
) {
|
||||
const page = workspace.createPage();
|
||||
await initEmptyPage(page, title);
|
||||
page.getBlockByFlavour('affine:paragraph').at(0)?.text?.insert(preview, 0);
|
||||
return page;
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'AFFiNE/BlocksuiteEditor/DocEditor',
|
||||
};
|
||||
|
||||
export const DocEditor: StoryFn<typeof BlockSuiteEditor> = (_, { loaded }) => {
|
||||
return (
|
||||
<div style={{ height: '100vh' }}>
|
||||
<BlockSuiteEditor mode="page" page={loaded.page} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DocEditor.loaders = [
|
||||
async () => {
|
||||
const workspace = new Workspace({
|
||||
id: 'test-workspace-id',
|
||||
schema,
|
||||
});
|
||||
|
||||
workspace.meta.setProperties({
|
||||
tags: {
|
||||
options: [],
|
||||
},
|
||||
});
|
||||
|
||||
const page = await createAndInitPage(
|
||||
workspace,
|
||||
'This is page 1',
|
||||
'Hello World from page 1'
|
||||
);
|
||||
|
||||
return {
|
||||
page,
|
||||
workspace,
|
||||
};
|
||||
},
|
||||
];
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BlockSuiteEditor } from '@affine/component/block-suite-editor';
|
||||
import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor';
|
||||
import { ImagePreviewModal } from '@affine/core/components/image-preview';
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
|
||||
Reference in New Issue
Block a user