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.
This commit is contained in:
Peng Xiao
2024-03-18 07:04:02 +00:00
parent e896f19f1a
commit dc8f351051
7 changed files with 195 additions and 20 deletions

View File

@@ -1 +1,2 @@
export { createComponent as createReactComponentFromLit } from './create-component'; export { createComponent as createReactComponentFromLit } from './create-component';
export * from './lit-portal';

View File

@@ -0,0 +1,5 @@
export {
type ElementOrFactory,
useLitPortal,
useLitPortalFactory,
} from './lite-portal';

View File

@@ -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<PortalListener> = 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`<lit-react-portal portalId=${id}></lit-react-portal>`;
}
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<LitPortal[]>([]);
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<HTMLElement>();
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,
};
};

View File

@@ -4,12 +4,13 @@ import { WorkbenchLink } from '@affine/core/modules/workbench';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { LinkedPageIcon, TodayIcon } from '@blocksuite/icons'; import { LinkedPageIcon, TodayIcon } from '@blocksuite/icons';
import type { DocCollection } from '@blocksuite/store'; import type { DocCollection } from '@blocksuite/store';
import type { PropsWithChildren } from 'react'; import { type PropsWithChildren } from 'react';
import * as styles from './styles.css'; import * as styles from './styles.css';
export interface PageReferenceRendererOptions { export interface PageReferenceRendererOptions {
pageId: string; pageId: string;
docCollection: DocCollection;
pageMetaHelper: ReturnType<typeof useDocMetaHelper>; pageMetaHelper: ReturnType<typeof useDocMetaHelper>;
journalHelper: ReturnType<typeof useJournalHelper>; journalHelper: ReturnType<typeof useJournalHelper>;
t: ReturnType<typeof useAFFiNEI18N>; t: ReturnType<typeof useAFFiNEI18N>;
@@ -32,6 +33,7 @@ export function pageReferenceRenderer({
title = localizedJournalDate; title = localizedJournalDate;
icon = <TodayIcon className={styles.pageReferenceIcon} />; icon = <TodayIcon className={styles.pageReferenceIcon} />;
} }
return ( return (
<> <>
{icon} {icon}
@@ -58,6 +60,7 @@ export function AffinePageReference({
pageId, pageId,
pageMetaHelper, pageMetaHelper,
journalHelper, journalHelper,
docCollection,
t, t,
}); });

View File

@@ -18,7 +18,7 @@ import {
} from 'react'; } from 'react';
import { import {
pageReferenceRenderer, AffinePageReference,
type PageReferenceRendererOptions, type PageReferenceRendererOptions,
} from '../../affine/reference-link'; } from '../../affine/reference-link';
import { BlocksuiteEditorContainer } from './blocksuite-editor-container'; import { BlocksuiteEditorContainer } from './blocksuite-editor-container';
@@ -61,7 +61,6 @@ function usePageRoot(page: Doc) {
return page.root; return page.root;
} }
// we cannot pass components to lit renderers, but give them the rendered elements
const customRenderersFactory: ( const customRenderersFactory: (
opts: Omit<PageReferenceRendererOptions, 'pageId'> opts: Omit<PageReferenceRendererOptions, 'pageId'>
) => InlineRenderers = opts => ({ ) => InlineRenderers = opts => ({
@@ -70,10 +69,9 @@ const customRenderersFactory: (
if (!pageId) { if (!pageId) {
return <span />; return <span />;
} }
return pageReferenceRenderer({ return (
...opts, <AffinePageReference docCollection={opts.docCollection} pageId={pageId} />
pageId, );
});
}, },
}); });
@@ -119,8 +117,9 @@ const BlockSuiteEditorImpl = forwardRef<AffineEditorContainer, EditorProps>(
pageMetaHelper, pageMetaHelper,
journalHelper, journalHelper,
t, t,
docCollection: page.collection,
}); });
}, [journalHelper, pageMetaHelper, t]); }, [journalHelper, page.collection, pageMetaHelper, t]);
return ( return (
<BlocksuiteEditorContainer <BlocksuiteEditorContainer

View File

@@ -1,4 +1,7 @@
import { createReactComponentFromLit } from '@affine/component'; import {
createReactComponentFromLit,
useLitPortalFactory,
} from '@affine/component';
import { useJournalInfoHelper } from '@affine/core/hooks/use-journal'; import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
import { import {
BiDirectionalLinkPanel, BiDirectionalLinkPanel,
@@ -10,6 +13,7 @@ import {
import { type Doc } from '@blocksuite/store'; import { type Doc } from '@blocksuite/store';
import React, { import React, {
forwardRef, forwardRef,
Fragment,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@@ -80,9 +84,11 @@ export const BlocksuiteDocEditor = forwardRef<
[ref] [ref]
); );
const [litToTemplate, portals] = useLitPortalFactory();
const specs = useMemo(() => { const specs = useMemo(() => {
return patchSpecs(docModeSpecs, customRenderers); return patchSpecs(docModeSpecs, litToTemplate, customRenderers);
}, [customRenderers]); }, [customRenderers, litToTemplate]);
useEffect(() => { useEffect(() => {
// auto focus the title // auto focus the title
@@ -128,6 +134,9 @@ export const BlocksuiteDocEditor = forwardRef<
<adapted.BiDirectionalLinkPanel doc={page} pageRoot={docPage} /> <adapted.BiDirectionalLinkPanel doc={page} pageRoot={docPage} />
) : null} ) : null}
</div> </div>
{portals.map(p => (
<Fragment key={p.id}>{p.portal}</Fragment>
))}
</div> </div>
); );
}); });
@@ -136,8 +145,16 @@ export const BlocksuiteEdgelessEditor = forwardRef<
EdgelessEditor, EdgelessEditor,
BlocksuiteDocEditorProps BlocksuiteDocEditorProps
>(function BlocksuiteEdgelessEditor({ page, customRenderers }, ref) { >(function BlocksuiteEdgelessEditor({ page, customRenderers }, ref) {
const [litToTemplate, portals] = useLitPortalFactory();
const specs = useMemo(() => { const specs = useMemo(() => {
return patchSpecs(edgelessModeSpecs, customRenderers); return patchSpecs(edgelessModeSpecs, litToTemplate, customRenderers);
}, [customRenderers]); }, [customRenderers, litToTemplate]);
return <adapted.EdgelessEditor ref={ref} doc={page} specs={specs} />; return (
<>
<adapted.EdgelessEditor ref={ref} doc={page} specs={specs} />
{portals.map(p => (
<Fragment key={p.id}>{p.portal}</Fragment>
))}
</>
);
}); });

View File

@@ -1,3 +1,4 @@
import type { ElementOrFactory } from '@affine/component';
import type { BlockSpec } from '@blocksuite/block-std'; import type { BlockSpec } from '@blocksuite/block-std';
import type { ParagraphService, RootService } from '@blocksuite/blocks'; import type { ParagraphService, RootService } from '@blocksuite/blocks';
import { import {
@@ -9,8 +10,7 @@ import {
PageRootService, PageRootService,
} from '@blocksuite/blocks'; } from '@blocksuite/blocks';
import bytes from 'bytes'; import bytes from 'bytes';
import { html, unsafeStatic } from 'lit/static-html.js'; import type { TemplateResult } from 'lit';
import ReactDOMServer from 'react-dom/server';
class CustomAttachmentService extends AttachmentService { class CustomAttachmentService extends AttachmentService {
override mounted(): void { override mounted(): void {
@@ -53,12 +53,12 @@ export interface InlineRenderers {
function patchSpecsWithReferenceRenderer( function patchSpecsWithReferenceRenderer(
specs: BlockSpec<string>[], specs: BlockSpec<string>[],
pageReferenceRenderer: PageReferenceRenderer pageReferenceRenderer: PageReferenceRenderer,
toLitTemplate: (element: ElementOrFactory) => TemplateResult
) { ) {
const renderer = (reference: AffineReference) => { const renderer = (reference: AffineReference) => {
const node = pageReferenceRenderer(reference); const node = pageReferenceRenderer(reference);
const inner = ReactDOMServer.renderToString(node); return toLitTemplate(node);
return html`${unsafeStatic(inner)}`;
}; };
return specs.map(spec => { return specs.map(spec => {
if ( if (
@@ -84,13 +84,15 @@ function patchSpecsWithReferenceRenderer(
*/ */
export function patchSpecs( export function patchSpecs(
specs: BlockSpec<string>[], specs: BlockSpec<string>[],
toLitTemplate: (element: ElementOrFactory) => TemplateResult,
inlineRenderers?: InlineRenderers inlineRenderers?: InlineRenderers
) { ) {
let newSpecs = specs; let newSpecs = specs;
if (inlineRenderers?.pageReference) { if (inlineRenderers?.pageReference) {
newSpecs = patchSpecsWithReferenceRenderer( newSpecs = patchSpecsWithReferenceRenderer(
newSpecs, newSpecs,
inlineRenderers.pageReference inlineRenderers.pageReference,
toLitTemplate
); );
} }
return newSpecs; return newSpecs;