From dc8f351051e42ef77978e78105aacb33694bf5f5 Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Mon, 18 Mar 2024 07:04:02 +0000 Subject: [PATCH] refactor(component): render react element into lit (#6124) See docs in https://insider.affine.pro/share/af3478a2-9c9c-4d16-864d-bffa1eb10eb6/oL1ifjA4rKv7HRn5nYzIF This PR also enables opening split view by ctrl-click a page link in a doc. --- .../frontend/component/src/lit-react/index.ts | 1 + .../src/lit-react/lit-portal/index.ts | 5 + .../src/lit-react/lit-portal/lite-portal.tsx | 148 ++++++++++++++++++ .../affine/reference-link/index.tsx | 5 +- .../block-suite-editor/blocksuite-editor.tsx | 13 +- .../block-suite-editor/lit-adaper.tsx | 29 +++- .../blocksuite/block-suite-editor/specs.ts | 14 +- 7 files changed, 195 insertions(+), 20 deletions(-) create mode 100644 packages/frontend/component/src/lit-react/lit-portal/index.ts create mode 100644 packages/frontend/component/src/lit-react/lit-portal/lite-portal.tsx diff --git a/packages/frontend/component/src/lit-react/index.ts b/packages/frontend/component/src/lit-react/index.ts index 25f45aab2c172..e3afb1b203c21 100644 --- a/packages/frontend/component/src/lit-react/index.ts +++ b/packages/frontend/component/src/lit-react/index.ts @@ -1 +1,2 @@ export { createComponent as createReactComponentFromLit } from './create-component'; +export * from './lit-portal'; diff --git a/packages/frontend/component/src/lit-react/lit-portal/index.ts b/packages/frontend/component/src/lit-react/lit-portal/index.ts new file mode 100644 index 0000000000000..a71ef464aaeba --- /dev/null +++ b/packages/frontend/component/src/lit-react/lit-portal/index.ts @@ -0,0 +1,5 @@ +export { + type ElementOrFactory, + useLitPortal, + useLitPortalFactory, +} from './lite-portal'; diff --git a/packages/frontend/component/src/lit-react/lit-portal/lite-portal.tsx b/packages/frontend/component/src/lit-react/lit-portal/lite-portal.tsx new file mode 100644 index 0000000000000..291c7f0fcf49a --- /dev/null +++ b/packages/frontend/component/src/lit-react/lit-portal/lite-portal.tsx @@ -0,0 +1,148 @@ +import { html, LitElement } from 'lit'; +import { nanoid } from 'nanoid'; +import { useCallback, useMemo, useState } from 'react'; +import ReactDOM from 'react-dom'; + +type PortalEvent = { + name: 'connectedCallback' | 'disconnectedCallback' | 'willUpdate'; + target: LitReactPortal; + previousPortalId?: string; +}; + +type PortalListener = (event: PortalEvent) => void; +const listeners: Set = new Set(); + +export function createLitPortalAnchor(callback: (event: PortalEvent) => void) { + const id = nanoid(); + // todo: clean up listeners? + listeners.add(event => { + if (event.target.portalId !== id) { + return; + } + callback(event); + }); + return html``; +} + +class LitReactPortal extends LitElement { + portalId: string = ''; + + static override get properties() { + return { + portalId: { type: String }, + }; + } + + // do not enable shadow root + override createRenderRoot() { + return this; + } + + override connectedCallback() { + listeners.forEach(l => + l({ + name: 'connectedCallback', + target: this, + }) + ); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + listeners.forEach(l => + l({ + name: 'disconnectedCallback', + target: this, + }) + ); + } + + override willUpdate(changedProperties: any) { + super.willUpdate(changedProperties); + listeners.forEach(l => + l({ + name: 'willUpdate', + target: this, + previousPortalId: changedProperties.get('portalId'), + }) + ); + } +} + +window.customElements.define('lit-react-portal', LitReactPortal); + +declare global { + interface HTMLElementTagNameMap { + 'lit-react-portal': LitReactPortal; + } +} + +export type ElementOrFactory = React.ReactElement | (() => React.ReactElement); + +type LitPortal = { + id: string; + portal: React.ReactPortal; +}; + +// returns a factory function that renders a given element to a lit template +export const useLitPortalFactory = () => { + const [portals, setPortals] = useState([]); + + return [ + useCallback( + (elementOrFactory: React.ReactElement | (() => React.ReactElement)) => { + const element = + typeof elementOrFactory === 'function' + ? elementOrFactory() + : elementOrFactory; + return createLitPortalAnchor(event => { + const portalId = event.target.portalId; + setPortals(portals => { + const newPortals = portals.filter( + p => p.id !== event.previousPortalId && p.id !== portalId + ); + if (event.name !== 'disconnectedCallback') { + newPortals.push({ + id: portalId, + portal: ReactDOM.createPortal(element, event.target), + }); + } + return newPortals; + }); + }); + }, + [setPortals] + ), + portals, + ] as const; +}; + +// render a react element to a lit template +export const useLitPortal = ( + elementOrFactory: React.ReactElement | (() => React.ReactElement) +) => { + const [anchor, setAnchor] = useState(); + const template = useMemo( + () => + createLitPortalAnchor(event => { + if (event.name !== 'disconnectedCallback') { + setAnchor(event.target as HTMLElement); + } else { + setAnchor(undefined); + } + }), + [] + ); + + const element = useMemo( + () => + typeof elementOrFactory === 'function' + ? elementOrFactory() + : elementOrFactory, + [elementOrFactory] + ); + return { + template, + portal: anchor ? ReactDOM.createPortal(element, anchor) : undefined, + }; +}; diff --git a/packages/frontend/core/src/components/affine/reference-link/index.tsx b/packages/frontend/core/src/components/affine/reference-link/index.tsx index a6ae2bde617a8..8f3c14651e3cb 100644 --- a/packages/frontend/core/src/components/affine/reference-link/index.tsx +++ b/packages/frontend/core/src/components/affine/reference-link/index.tsx @@ -4,12 +4,13 @@ import { WorkbenchLink } from '@affine/core/modules/workbench'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { LinkedPageIcon, TodayIcon } from '@blocksuite/icons'; import type { DocCollection } from '@blocksuite/store'; -import type { PropsWithChildren } from 'react'; +import { type PropsWithChildren } from 'react'; import * as styles from './styles.css'; export interface PageReferenceRendererOptions { pageId: string; + docCollection: DocCollection; pageMetaHelper: ReturnType; journalHelper: ReturnType; t: ReturnType; @@ -32,6 +33,7 @@ export function pageReferenceRenderer({ title = localizedJournalDate; icon = ; } + return ( <> {icon} @@ -58,6 +60,7 @@ export function AffinePageReference({ pageId, pageMetaHelper, journalHelper, + docCollection, t, }); 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 index e0af6cd8e4b3a..e114f2b312cd9 100644 --- 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 @@ -18,7 +18,7 @@ import { } from 'react'; import { - pageReferenceRenderer, + AffinePageReference, type PageReferenceRendererOptions, } from '../../affine/reference-link'; import { BlocksuiteEditorContainer } from './blocksuite-editor-container'; @@ -61,7 +61,6 @@ function usePageRoot(page: Doc) { return page.root; } -// we cannot pass components to lit renderers, but give them the rendered elements const customRenderersFactory: ( opts: Omit ) => InlineRenderers = opts => ({ @@ -70,10 +69,9 @@ const customRenderersFactory: ( if (!pageId) { return ; } - return pageReferenceRenderer({ - ...opts, - pageId, - }); + return ( + + ); }, }); @@ -119,8 +117,9 @@ const BlockSuiteEditorImpl = forwardRef( pageMetaHelper, journalHelper, t, + docCollection: page.collection, }); - }, [journalHelper, pageMetaHelper, t]); + }, [journalHelper, page.collection, pageMetaHelper, t]); return ( { - return patchSpecs(docModeSpecs, customRenderers); - }, [customRenderers]); + return patchSpecs(docModeSpecs, litToTemplate, customRenderers); + }, [customRenderers, litToTemplate]); useEffect(() => { // auto focus the title @@ -128,6 +134,9 @@ export const BlocksuiteDocEditor = forwardRef< ) : null} + {portals.map(p => ( + {p.portal} + ))} ); }); @@ -136,8 +145,16 @@ export const BlocksuiteEdgelessEditor = forwardRef< EdgelessEditor, BlocksuiteDocEditorProps >(function BlocksuiteEdgelessEditor({ page, customRenderers }, ref) { + const [litToTemplate, portals] = useLitPortalFactory(); const specs = useMemo(() => { - return patchSpecs(edgelessModeSpecs, customRenderers); - }, [customRenderers]); - return ; + return patchSpecs(edgelessModeSpecs, litToTemplate, customRenderers); + }, [customRenderers, litToTemplate]); + return ( + <> + + {portals.map(p => ( + {p.portal} + ))} + + ); }); 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 index e3e683ec60270..661aa1ff1c2b1 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs.ts @@ -1,3 +1,4 @@ +import type { ElementOrFactory } from '@affine/component'; import type { BlockSpec } from '@blocksuite/block-std'; import type { ParagraphService, RootService } from '@blocksuite/blocks'; import { @@ -9,8 +10,7 @@ import { PageRootService, } from '@blocksuite/blocks'; import bytes from 'bytes'; -import { html, unsafeStatic } from 'lit/static-html.js'; -import ReactDOMServer from 'react-dom/server'; +import type { TemplateResult } from 'lit'; class CustomAttachmentService extends AttachmentService { override mounted(): void { @@ -53,12 +53,12 @@ export interface InlineRenderers { function patchSpecsWithReferenceRenderer( specs: BlockSpec[], - pageReferenceRenderer: PageReferenceRenderer + pageReferenceRenderer: PageReferenceRenderer, + toLitTemplate: (element: ElementOrFactory) => TemplateResult ) { const renderer = (reference: AffineReference) => { const node = pageReferenceRenderer(reference); - const inner = ReactDOMServer.renderToString(node); - return html`${unsafeStatic(inner)}`; + return toLitTemplate(node); }; return specs.map(spec => { if ( @@ -84,13 +84,15 @@ function patchSpecsWithReferenceRenderer( */ export function patchSpecs( specs: BlockSpec[], + toLitTemplate: (element: ElementOrFactory) => TemplateResult, inlineRenderers?: InlineRenderers ) { let newSpecs = specs; if (inlineRenderers?.pageReference) { newSpecs = patchSpecsWithReferenceRenderer( newSpecs, - inlineRenderers.pageReference + inlineRenderers.pageReference, + toLitTemplate ); } return newSpecs;