feat(core): peek view api enhancements (#7288)

upstream https://github.com/toeverything/blocksuite/pull/7390
fix AF-917
This commit is contained in:
pengx17
2024-06-21 07:38:42 +00:00
parent f85a321bfa
commit e085b927f6
15 changed files with 182 additions and 219 deletions
@@ -1,2 +1,3 @@
export { createComponent as createReactComponentFromLit } from './create-component'; export { createComponent as createReactComponentFromLit } from './create-component';
export * from './lit-portal'; export * from './lit-portal';
export { toReactNode } from './to-react-node';
@@ -0,0 +1,35 @@
import { LitElement, type TemplateResult } from 'lit';
import React, { createElement, type ReactNode } from 'react';
import { createComponent } from './create-component';
export class LitTemplateWrapper extends LitElement {
static override get properties() {
return {
template: { type: Object },
};
}
template: TemplateResult | null = null;
// do not enable shadow root
override createRenderRoot() {
return this;
}
override render() {
return this.template;
}
}
window.customElements.define('affine-lit-template-wrapper', LitTemplateWrapper);
const TemplateWrapper = createComponent({
elementClass: LitTemplateWrapper,
react: React,
});
export const toReactNode = (template?: TemplateResult | string): ReactNode => {
if (!template) return null;
return typeof template === 'string'
? template
: createElement(TemplateWrapper, { template });
};
@@ -101,7 +101,7 @@ export function AffinePageReference({
if (e.shiftKey && ref.current) { if (e.shiftKey && ref.current) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
peekView.open(ref.current); peekView.open(ref.current).catch(console.error);
return false; // means this click is handled return false; // means this click is handled
} }
if (isInPeekView) { if (isInPeekView) {
@@ -1,10 +1,10 @@
import { import {
createReactComponentFromLit,
type ElementOrFactory, type ElementOrFactory,
Input, Input,
notify, notify,
toast, toast,
type ToastOptions, type ToastOptions,
toReactNode,
type useConfirmModal, type useConfirmModal,
} from '@affine/component'; } from '@affine/component';
import type { import type {
@@ -27,47 +27,15 @@ import {
type RootService, type RootService,
} from '@blocksuite/blocks'; } from '@blocksuite/blocks';
import type { DocMode, DocService, DocsService } from '@toeverything/infra'; import type { DocMode, DocService, DocsService } from '@toeverything/infra';
import { html, LitElement, type TemplateResult } from 'lit'; import { html, type TemplateResult } from 'lit';
import { customElement } from 'lit/decorators.js'; import { customElement } from 'lit/decorators.js';
import { literal } from 'lit/static-html.js'; import { literal } from 'lit/static-html.js';
import React, { createElement, type ReactNode } from 'react';
const logger = new DebugLogger('affine::spec-patchers');
export type ReferenceReactRenderer = ( export type ReferenceReactRenderer = (
reference: AffineReference reference: AffineReference
) => React.ReactElement; ) => React.ReactElement;
export class LitTemplateWrapper extends LitElement { const logger = new DebugLogger('affine::spec-patchers');
static override get properties() {
return {
template: { type: Object },
};
}
template: TemplateResult | null = null;
// do not enable shadow root
override createRenderRoot() {
return this;
}
override render() {
return this.template;
}
}
window.customElements.define('affine-lit-template-wrapper', LitTemplateWrapper);
const TemplateWrapper = createReactComponentFromLit({
elementClass: LitTemplateWrapper,
react: React,
});
const toReactNode = (template?: TemplateResult | string): ReactNode => {
if (!template) return null;
return typeof template === 'string'
? template
: createElement(TemplateWrapper, { template });
};
function patchSpecService<Spec extends BlockSpec>( function patchSpecService<Spec extends BlockSpec>(
spec: Spec, spec: Spec,
@@ -274,10 +242,9 @@ export function patchPeekViewService(
patchSpecService(rootSpec, pageService => { patchSpecService(rootSpec, pageService => {
pageService.peekViewService = { pageService.peekViewService = {
peek: (target: ActivePeekView['target']) => { peek: (target: ActivePeekView['target'], template?: TemplateResult) => {
logger.debug('center peek', target); logger.debug('center peek', target, template);
service.peekView.open(target); return service.peekView.open(target, template);
return Promise.resolve();
}, },
}; };
}); });
@@ -6,7 +6,12 @@ import {
type SurfaceRefBlockComponent, type SurfaceRefBlockComponent,
type SurfaceRefBlockModel, type SurfaceRefBlockModel,
} from '@blocksuite/blocks'; } from '@blocksuite/blocks';
import type { BlockModel } from '@blocksuite/store';
import { type DocMode, Entity, LiveData } from '@toeverything/infra'; import { type DocMode, Entity, LiveData } from '@toeverything/infra';
import type { TemplateResult } from 'lit';
import { firstValueFrom, map, race } from 'rxjs';
import { resolveLinkToDoc } from '../../navigation';
export type PeekViewTarget = export type PeekViewTarget =
| HTMLElement | HTMLElement
@@ -24,13 +29,10 @@ export type DocPeekViewInfo = {
export type ActivePeekView = { export type ActivePeekView = {
target: PeekViewTarget; target: PeekViewTarget;
info: DocPeekViewInfo; info?: DocPeekViewInfo;
template?: TemplateResult;
}; };
import type { BlockModel } from '@blocksuite/store';
import { resolveLinkToDoc } from '../../navigation';
const EMBED_DOC_FLAVOURS = [ const EMBED_DOC_FLAVOURS = [
'affine:embed-linked-doc', 'affine:embed-linked-doc',
'affine:embed-synced-doc', 'affine:embed-synced-doc',
@@ -50,8 +52,8 @@ const isSurfaceRefModel = (
function resolvePeekInfoFromPeekTarget( function resolvePeekInfoFromPeekTarget(
peekTarget?: PeekViewTarget peekTarget?: PeekViewTarget
): DocPeekViewInfo | null { ): DocPeekViewInfo | undefined {
if (!peekTarget) return null; if (!peekTarget) return;
if (peekTarget instanceof AffineReference) { if (peekTarget instanceof AffineReference) {
if (peekTarget.refMeta) { if (peekTarget.refMeta) {
return { return {
@@ -91,14 +93,10 @@ function resolvePeekInfoFromPeekTarget(
blockId: peekTarget.blockId, blockId: peekTarget.blockId,
}; };
} }
return null; return;
} }
export class PeekViewEntity extends Entity { export class PeekViewEntity extends Entity {
constructor() {
super();
}
private readonly _active$ = new LiveData<ActivePeekView | null>(null); private readonly _active$ = new LiveData<ActivePeekView | null>(null);
private readonly _show$ = new LiveData<boolean>(false); private readonly _show$ = new LiveData<boolean>(false);
@@ -108,14 +106,17 @@ export class PeekViewEntity extends Entity {
.distinctUntilChanged(); .distinctUntilChanged();
// return true if the peek view will be handled // return true if the peek view will be handled
open = (target: ActivePeekView['target']) => { open = async (
target: ActivePeekView['target'],
template?: TemplateResult
) => {
const resolvedInfo = resolvePeekInfoFromPeekTarget(target); const resolvedInfo = resolvePeekInfoFromPeekTarget(target);
if (!resolvedInfo) { if (!resolvedInfo && !template) {
return false; return;
} }
this._active$.next({ target, info: resolvedInfo }); this._active$.next({ target, info: resolvedInfo, template });
this._show$.next(true); this._show$.next(true);
return true; return firstValueFrom(race(this._active$, this.show$).pipe(map(() => {})));
}; };
close = () => { close = () => {
@@ -10,28 +10,52 @@ import { DisposableGroup } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets'; import type { AffineEditorContainer } from '@blocksuite/presets';
import type { DocMode } from '@toeverything/infra'; import type { DocMode } from '@toeverything/infra';
import { DocsService, FrameworkScope, useService } from '@toeverything/infra'; import { DocsService, FrameworkScope, useService } from '@toeverything/infra';
import { import { useEffect, useState } from 'react';
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useState,
} from 'react';
import { WorkbenchService } from '../../workbench'; import { WorkbenchService } from '../../workbench';
import { PeekViewService } from '../services/peek-view'; import { PeekViewService } from '../services/peek-view';
import * as styles from './doc-peek-view.css'; import * as styles from './doc-peek-view.css';
import { useDoc } from './utils'; import { useDoc } from './utils';
export type DocPreviewRef = { function fitViewport(
getEditor: () => AffineEditorContainer | null; editor: AffineEditorContainer,
fitViewportToTarget: () => void; xywh?: `[${number},${number},${number},${number}]`
}; ) {
const rootService =
editor.host.std.spec.getService<EdgelessRootService>('affine:page');
rootService.viewport.onResize();
const DocPreview = forwardRef< if (xywh) {
DocPreviewRef, const viewport = {
{ docId: string; blockId?: string; mode?: DocMode } xywh: xywh,
>(function DocPreview({ docId, blockId, mode }, ref) { padding: [60, 20, 20, 20] as [number, number, number, number],
};
rootService.viewport.setViewportByBound(
Bound.deserialize(viewport.xywh),
viewport.padding,
false
);
} else {
const data = rootService.getFitToScreenData();
rootService.viewport.setViewport(
data.zoom,
[data.centerX, data.centerY],
false
);
}
}
export function DocPeekPreview({
docId,
blockId,
mode,
xywh,
}: {
docId: string;
blockId?: string;
mode?: DocMode;
xywh?: `[${number},${number},${number},${number}]`;
}) {
const { doc, workspace, loading } = useDoc(docId); const { doc, workspace, loading } = useDoc(docId);
const { jumpToTag } = useNavigateHelper(); const { jumpToTag } = useNavigateHelper();
const workbench = useService(WorkbenchService).workbench; const workbench = useService(WorkbenchService).workbench;
@@ -45,26 +69,22 @@ const DocPreview = forwardRef<
const docs = useService(DocsService); const docs = useService(DocsService);
const [resolvedMode, setResolvedMode] = useState<DocMode | undefined>(mode); const [resolvedMode, setResolvedMode] = useState<DocMode | undefined>(mode);
useImperativeHandle( useEffect(() => {
ref, if (editor && resolvedMode === 'edgeless') {
() => ({ editor.host
getEditor: () => editor, .closest('[data-testid="peek-view-modal-animation-container"]')
fitViewportToTarget: () => { ?.addEventListener(
if (editor && resolvedMode === 'edgeless') { 'animationend',
const rootService = () => {
editor.host.std.spec.getService<EdgelessRootService>('affine:page'); fitViewport(editor, xywh);
rootService.viewport.onResize(); },
const data = rootService.getFitToScreenData(); {
rootService.viewport.setViewport( once: true,
data.zoom, }
[data.centerX, data.centerY], );
false }
); return;
} }, [editor, resolvedMode, xywh]);
},
}),
[editor, resolvedMode]
);
useEffect(() => { useEffect(() => {
if (!mode || !resolvedMode) { if (!mode || !resolvedMode) {
@@ -95,7 +115,7 @@ const DocPreview = forwardRef<
// doc change event inside peek view should be handled by peek view // doc change event inside peek view should be handled by peek view
disposableGroup.add( disposableGroup.add(
rootService.slots.docLinkClicked.on(({ docId, blockId }) => { rootService.slots.docLinkClicked.on(({ docId, blockId }) => {
peekView.open({ docId, blockId }); peekView.open({ docId, blockId }).catch(console.error);
}) })
); );
// todo: no tag peek view yet // todo: no tag peek view yet
@@ -140,86 +160,4 @@ const DocPreview = forwardRef<
</Scrollable.Root> </Scrollable.Root>
</AffineErrorBoundary> </AffineErrorBoundary>
); );
}); }
DocPreview.displayName = 'DocPreview';
export const DocPeekView = forwardRef<
DocPreviewRef,
{
docId: string;
blockId?: string;
mode?: DocMode;
}
>(function DocPeekView({ docId, blockId, mode }, ref) {
return <DocPreview ref={ref} mode={mode} docId={docId} blockId={blockId} />;
});
export type SurfaceRefPeekViewRef = {
fitViewportToTarget: () => void;
};
export const SurfaceRefPeekView = forwardRef<
SurfaceRefPeekViewRef,
{ docId: string; xywh: `[${number},${number},${number},${number}]` }
>(function SurfaceRefPeekView({ docId, xywh }, ref) {
const [editorRef, setEditorRef] = useState<AffineEditorContainer | null>(
null
);
const onRef = (editor: AffineEditorContainer | null) => {
setEditorRef(editor);
};
const fitViewportToTarget = useCallback(() => {
if (!editorRef) {
return;
}
const viewport = {
xywh: xywh,
padding: [60, 20, 20, 20] as [number, number, number, number],
};
const rootService =
editorRef.host.std.spec.getService<EdgelessRootService>('affine:page');
rootService.viewport.onResize();
rootService.viewport.setViewportByBound(
Bound.deserialize(viewport.xywh),
viewport.padding
);
}, [editorRef, xywh]);
useImperativeHandle(
ref,
() => ({
fitViewportToTarget,
}),
[fitViewportToTarget]
);
useEffect(() => {
let mounted = true;
if (editorRef) {
editorRef.host?.updateComplete
.then(() => {
if (mounted) {
fitViewportToTarget();
}
})
.catch(e => {
console.error(e);
});
}
return () => {
mounted = false;
};
}, [editorRef, fitViewportToTarget]);
return (
<DocPreview
ref={ref => {
onRef(ref?.getEditor() ?? null);
}}
docId={docId}
mode={'edgeless'}
/>
);
});
SurfaceRefPeekView.displayName = 'SurfaceRefPeekView';
@@ -112,7 +112,11 @@ export const PeekViewModalContainer = ({
[styles.animationTimeout]: `${animationTimeout}ms`, [styles.animationTimeout]: `${animationTimeout}ms`,
})} })}
> >
<div className={styles.modalContentContainer} data-state={status}> <div
className={styles.modalContentContainer}
data-testid="peek-view-modal-animation-container"
data-state={status}
>
<Dialog.Content <Dialog.Content
{...contentOptions} {...contentOptions}
className={styles.modalContent} className={styles.modalContent}
@@ -19,7 +19,7 @@ import {
import { WorkbenchService } from '../../workbench'; import { WorkbenchService } from '../../workbench';
import { PeekViewService } from '../services/peek-view'; import { PeekViewService } from '../services/peek-view';
import * as styles from './doc-peek-controls.css'; import * as styles from './peek-view-controls.css';
import { useDoc } from './utils'; import { useDoc } from './utils';
type ControlButtonProps = { type ControlButtonProps = {
@@ -66,6 +66,31 @@ type DocPeekViewControlsProps = HTMLAttributes<HTMLDivElement> & {
mode?: DocMode; mode?: DocMode;
}; };
export const DefaultPeekViewControls = ({
className,
...rest
}: HTMLAttributes<HTMLDivElement>) => {
const peekView = useService(PeekViewService).peekView;
const t = useI18n();
const controls = useMemo(() => {
return [
{
icon: <CloseIcon />,
nameKey: 'close',
name: t['com.affine.peek-view-controls.close'](),
onClick: peekView.close,
},
].filter((opt): opt is ControlButtonProps => Boolean(opt));
}, [peekView, t]);
return (
<div {...rest} className={clsx(styles.root, className)}>
{controls.map(option => (
<ControlButton key={option.nameKey} {...option} />
))}
</div>
);
};
export const DocPeekViewControls = ({ export const DocPeekViewControls = ({
docId, docId,
blockId, blockId,
@@ -1,32 +1,30 @@
import { toReactNode } from '@affine/component';
import { BlockElement } from '@blocksuite/block-std'; import { BlockElement } from '@blocksuite/block-std';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo } from 'react';
import type { ActivePeekView } from '../entities/peek-view'; import type { ActivePeekView } from '../entities/peek-view';
import { PeekViewService } from '../services/peek-view'; import { PeekViewService } from '../services/peek-view';
import { DocPeekViewControls } from './doc-peek-controls'; import { DocPeekPreview } from './doc-peek-view';
import type { DocPreviewRef, SurfaceRefPeekViewRef } from './doc-peek-view';
import { DocPeekView, SurfaceRefPeekView } from './doc-peek-view';
import { PeekViewModalContainer } from './modal-container'; import { PeekViewModalContainer } from './modal-container';
import {
DefaultPeekViewControls,
DocPeekViewControls,
} from './peek-view-controls';
function renderPeekView( function renderPeekView({ info, template }: ActivePeekView) {
{ info }: ActivePeekView, if (template) {
refCallback: (editor: SurfaceRefPeekViewRef | DocPreviewRef | null) => void return toReactNode(template);
) { }
if (info.mode === 'edgeless' && info.xywh) {
return ( if (!info) {
<SurfaceRefPeekView return null;
ref={refCallback}
docId={info.docId}
xywh={info.xywh}
/>
);
} }
return ( return (
<DocPeekView <DocPeekPreview
ref={refCallback}
mode={info.mode} mode={info.mode}
xywh={info.xywh}
docId={info.docId} docId={info.docId}
blockId={info.blockId} blockId={info.blockId}
/> />
@@ -34,29 +32,26 @@ function renderPeekView(
} }
const renderControls = ({ info }: ActivePeekView) => { const renderControls = ({ info }: ActivePeekView) => {
return ( if (info && 'docId' in info) {
<DocPeekViewControls return (
mode={info.mode} <DocPeekViewControls
docId={info.docId} mode={info.mode}
blockId={info.docId} docId={info.docId}
/> blockId={info.docId}
); />
);
}
return <DefaultPeekViewControls />;
}; };
export const PeekViewManagerModal = () => { export const PeekViewManagerModal = () => {
const peekViewEntity = useService(PeekViewService).peekView; const peekViewEntity = useService(PeekViewService).peekView;
const activePeekView = useLiveData(peekViewEntity.active$); const activePeekView = useLiveData(peekViewEntity.active$);
const show = useLiveData(peekViewEntity.show$); const show = useLiveData(peekViewEntity.show$);
const peekViewRef = useRef<SurfaceRefPeekViewRef | DocPreviewRef | null>(
null
);
const preview = useMemo(() => { const preview = useMemo(() => {
return activePeekView return activePeekView ? renderPeekView(activePeekView) : null;
? renderPeekView(activePeekView, editor => {
peekViewRef.current = editor;
})
: null;
}, [activePeekView]); }, [activePeekView]);
const controls = useMemo(() => { const controls = useMemo(() => {
@@ -89,9 +84,6 @@ export const PeekViewManagerModal = () => {
peekViewEntity.close(); peekViewEntity.close();
} }
}} }}
onAnimateEnd={() => {
peekViewRef.current?.fitViewportToTarget();
}}
> >
{preview} {preview}
</PeekViewModalContainer> </PeekViewModalContainer>
@@ -56,7 +56,7 @@ export const AllPage = () => {
}; };
export const Component = () => { export const Component = () => {
performanceRenderLogger.info('AllPage'); performanceRenderLogger.debug('AllPage');
return <AllPage />; return <AllPage />;
}; };
@@ -362,7 +362,7 @@ export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => {
}; };
export const Component = () => { export const Component = () => {
performanceRenderLogger.info('DetailPage'); performanceRenderLogger.debug('DetailPage');
const params = useParams(); const params = useParams();
const recentPages = useService(RecentPagesService); const recentPages = useService(RecentPagesService);
@@ -37,7 +37,7 @@ declare global {
} }
export const Component = (): ReactElement => { export const Component = (): ReactElement => {
performanceRenderLogger.info('WorkspaceLayout'); performanceRenderLogger.debug('WorkspaceLayout');
const params = useParams(); const params = useParams();
+1 -1
View File
@@ -93,7 +93,7 @@ window.addEventListener('focus', () => {
frameworkProvider.get(LifecycleService).applicationStart(); frameworkProvider.get(LifecycleService).applicationStart();
export function App() { export function App() {
performanceRenderLogger.info('App'); performanceRenderLogger.debug('App');
if (!languageLoadingPromise) { if (!languageLoadingPromise) {
languageLoadingPromise = loadLanguage().catch(console.error); languageLoadingPromise = loadLanguage().catch(console.error);
+1 -1
View File
@@ -81,7 +81,7 @@ window.addEventListener('focus', () => {
frameworkProvider.get(LifecycleService).applicationStart(); frameworkProvider.get(LifecycleService).applicationStart();
export function App() { export function App() {
performanceRenderLogger.info('App'); performanceRenderLogger.debug('App');
if (!languageLoadingPromise) { if (!languageLoadingPromise) {
languageLoadingPromise = loadLanguage().catch(console.error); languageLoadingPromise = loadLanguage().catch(console.error);