diff --git a/packages/frontend/component/src/components/block-suite-editor/index.css.ts b/packages/frontend/component/src/components/block-suite-editor/index.css.ts deleted file mode 100644 index 3b7bd0186..000000000 --- a/packages/frontend/component/src/components/block-suite-editor/index.css.ts +++ /dev/null @@ -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', -}); diff --git a/packages/frontend/component/src/components/block-suite-editor/index.tsx b/packages/frontend/component/src/components/block-suite-editor/index.tsx deleted file mode 100644 index 568895e9a..000000000 --- a/packages/frontend/component/src/components/block-suite-editor/index.tsx +++ /dev/null @@ -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(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>; - 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 | 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( - ( - { - mode, - page, - className, - defaultSelectedBlockId, - onLoadEditor, - onModeChange, - style, - }, - ref - ): ReactElement => { - usePageRoot(page); - - assertExists(page, 'page should not be null'); - const editorRef = useRef(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(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 ( -
- ); - } -); -BlockSuiteEditorImpl.displayName = 'BlockSuiteEditorImpl'; - -export const EditorLoading = memo(function EditorLoading() { - return ( -
- - -
- ); -}); - -export const BlockSuiteEditor = memo( - forwardRef( - function BlockSuiteEditor(props, ref): ReactElement { - return ( - }> - - - ); - } - ) -); - -BlockSuiteEditor.displayName = 'BlockSuiteEditor'; diff --git a/packages/frontend/component/src/components/block-suite-editor/specs.ts b/packages/frontend/component/src/components/block-suite-editor/specs.ts deleted file mode 100644 index 8fd387959..000000000 --- a/packages/frontend/component/src/components/block-suite-editor/specs.ts +++ /dev/null @@ -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(); diff --git a/packages/frontend/component/src/components/page-detail-skeleton/index.css.ts b/packages/frontend/component/src/components/page-detail-skeleton/index.css.ts index abf38dd19..738980504 100644 --- a/packages/frontend/component/src/components/page-detail-skeleton/index.css.ts +++ b/packages/frontend/component/src/components/page-detail-skeleton/index.css.ts @@ -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', +}); diff --git a/packages/frontend/component/src/components/page-detail-skeleton/index.tsx b/packages/frontend/component/src/components/page-detail-skeleton/index.tsx index 889288085..7ae31d600 100644 --- a/packages/frontend/component/src/components/page-detail-skeleton/index.tsx +++ b/packages/frontend/component/src/components/page-detail-skeleton/index.tsx @@ -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 ( +
+ + +
+ ); +}; + export const PageDetailSkeleton = () => { return (
diff --git a/packages/frontend/component/src/index.ts b/packages/frontend/component/src/index.ts index 044b0a2d0..af04559e1 100644 --- a/packages/frontend/component/src/index.ts +++ b/packages/frontend/component/src/index.ts @@ -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'; diff --git a/packages/frontend/component/src/lit-react/create-component.ts b/packages/frontend/component/src/lit-react/create-component.ts new file mode 100644 index 000000000..67413016f --- /dev/null +++ b/packages/frontend/component/src/lit-react/create-component.ts @@ -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 extends any + ? K extends keyof T + ? Omit + : T + : T; +type PropsWithoutRef = DistributiveOmit; + +/** + * 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; + * } + * } + * } + * ``` + */ +export type WebComponentProps = React.DetailedHTMLProps< + React.HTMLAttributes, + I +> & + ElementProps; + +// 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> & React.RefAttributes +>; + +// 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 = Partial>; + +// Acceptable props to the React component. +type ComponentProps = Omit< + React.HTMLAttributes, + // Prefer type of provided event handler props or those on element over + // built-in HTMLAttributes + keyof E | keyof ElementProps +> & + EventListeners & + ElementProps; + +/** + * 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, + * } + * }); + * ``` + * + * `onfoo` prop will have the type `(e: FooEvent) => void`. + */ +export type EventName = string & { + __eventType: T; +}; + +// A key value map matching React prop names to event names. +type EventNames = Record; + +// A map of expected event listener types based on EventNames. +type EventListeners = { + [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; + events?: E; + displayName?: string; +} + +type Constructor = { new (): T }; + +const reservedReactProperties = new Set([ + 'children', + 'localName', + 'ref', + 'style', + 'className', +]); + +const listenedEvents = new WeakMap>(); + +/** + * 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 = ( + 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): ReactWebComponent => { + 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; + + const ReactComponent = React.forwardRef((props, ref) => { + const containerRef = React.useRef(null); + const prevPropsRef = React.useRef(null); + const elementRef = React.useRef(null); + + // Props to be passed to React.createElement + const reactProps: Record = { + 'data-lit-react-wrapper': elementClass.name, + }; + const elementProps: Record = {}; + + 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; +}; diff --git a/packages/frontend/component/src/lit-react/index.ts b/packages/frontend/component/src/lit-react/index.ts new file mode 100644 index 000000000..25f45aab2 --- /dev/null +++ b/packages/frontend/component/src/lit-react/index.ts @@ -0,0 +1 @@ +export { createComponent as createReactComponentFromLit } from './create-component'; diff --git a/packages/frontend/component/src/lit-react/readme.md b/packages/frontend/component/src/lit-react/readme.md new file mode 100644 index 000000000..4ea197fb3 --- /dev/null +++ b/packages/frontend/component/src/lit-react/readme.md @@ -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. diff --git a/packages/frontend/component/src/theme/global.css b/packages/frontend/component/src/theme/global.css index 66a35fc6e..dcd9f41f9 100644 --- a/packages/frontend/component/src/theme/global.css +++ b/packages/frontend/component/src/theme/global.css @@ -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; +} diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/no-page-root-fallback.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/no-page-root-fallback.tsx index 88bbe9d5e..82d908102 100644 --- a/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/no-page-root-fallback.tsx +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/no-page-root-fallback.tsx @@ -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'; diff --git a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx index 70d046e16..abfb7ee98 100644 --- a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx +++ b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx @@ -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} /> ) : ( diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor-container.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor-container.tsx new file mode 100644 index 000000000..9df5df320 --- /dev/null +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor-container.tsx @@ -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>>( + from: T, + to: Partial +) { + 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(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(null); + const docRef = useRef(null); + const edgelessRef = useRef(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 ( +
+ {mode === 'page' ? ( + + ) : ( + + )} +
+ ); +}); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor.tsx new file mode 100644 index 000000000..b6bfbfea5 --- /dev/null +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor.tsx @@ -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 | 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 = ; + 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 = ; + } + return ( + + {icon} {lTitle} + + ); +} + +const customRenderers: InlineRenderers = { + pageReference(reference) { + return ; + }, +}; + +/** + * 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>; + 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( + 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(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 ( + + ); + } +); + +export const BlockSuiteEditor = memo( + forwardRef( + function BlockSuiteEditor(props, ref): ReactElement { + return ( + }> + + + ); + } + ) +); + +BlockSuiteEditor.displayName = 'BlockSuiteEditor'; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/index.tsx index c507b085a..149ceeeac 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/index.tsx @@ -1,2 +1 @@ -export type { EditorProps } from '@affine/component/block-suite-editor'; -export { BlockSuiteEditor } from '@affine/component/block-suite-editor'; +export * from './blocksuite-editor'; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx new file mode 100644 index 000000000..5b985106e --- /dev/null +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx @@ -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(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 ( +
+
+ + {/* We will replace page meta tags with our own implementation */} + + + +
+
+ ); +}); + +export const BlocksuiteEdgelessEditor = forwardRef< + EdgelessEditor, + BlocksuiteDocEditorProps +>(function BlocksuiteEdgelessEditor({ page, customRenderers }, ref) { + const specs = useMemo(() => { + return patchSpecs(edgelessModeSpecs, customRenderers); + }, [customRenderers]); + return ; +}); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs.ts new file mode 100644 index 000000000..7c45d3694 --- /dev/null +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs.ts @@ -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[], + 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[], + 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; +}); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/styles.css.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/styles.css.ts new file mode 100644 index 000000000..5c029ce47 --- /dev/null +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/styles.css.ts @@ -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, +}); diff --git a/packages/frontend/core/src/components/page-detail-editor.tsx b/packages/frontend/core/src/components/page-detail-editor.tsx index 21b67a285..e7a13d5a3 100644 --- a/packages/frontend/core/src/components/page-detail-editor.tsx +++ b/packages/frontend/core/src/components/page-detail-editor.tsx @@ -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} /> diff --git a/packages/frontend/core/src/components/pure/cmdk/main.tsx b/packages/frontend/core/src/components/pure/cmdk/main.tsx index dc6423952..b86684326 100644 --- a/packages/frontend/core/src/components/pure/cmdk/main.tsx +++ b/packages/frontend/core/src/components/pure/cmdk/main.tsx @@ -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(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} = (_, { loaded }) => { + return ( +
+ +
+ ); +}; + +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, + }; + }, +]; diff --git a/tests/storybook/src/stories/image-preview-modal.stories.tsx b/tests/storybook/src/stories/image-preview-modal.stories.tsx index 832783642..b0a017eed 100644 --- a/tests/storybook/src/stories/image-preview-modal.stories.tsx +++ b/tests/storybook/src/stories/image-preview-modal.stories.tsx @@ -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';