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:
@@ -1 +1,2 @@
|
|||||||
export { createComponent as createReactComponentFromLit } from './create-component';
|
export { createComponent as createReactComponentFromLit } from './create-component';
|
||||||
|
export * from './lit-portal';
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
type ElementOrFactory,
|
||||||
|
useLitPortal,
|
||||||
|
useLitPortalFactory,
|
||||||
|
} from './lite-portal';
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user