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

View File

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

View File

@@ -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 });
};

View File

@@ -101,7 +101,7 @@ export function AffinePageReference({
if (e.shiftKey && ref.current) {
e.preventDefault();
e.stopPropagation();
peekView.open(ref.current);
peekView.open(ref.current).catch(console.error);
return false; // means this click is handled
}
if (isInPeekView) {

View File

@@ -1,10 +1,10 @@
import {
createReactComponentFromLit,
type ElementOrFactory,
Input,
notify,
toast,
type ToastOptions,
toReactNode,
type useConfirmModal,
} from '@affine/component';
import type {
@@ -27,47 +27,15 @@ import {
type RootService,
} from '@blocksuite/blocks';
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 { literal } from 'lit/static-html.js';
import React, { createElement, type ReactNode } from 'react';
const logger = new DebugLogger('affine::spec-patchers');
export type ReferenceReactRenderer = (
reference: AffineReference
) => React.ReactElement;
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 = createReactComponentFromLit({
elementClass: LitTemplateWrapper,
react: React,
});
const toReactNode = (template?: TemplateResult | string): ReactNode => {
if (!template) return null;
return typeof template === 'string'
? template
: createElement(TemplateWrapper, { template });
};
const logger = new DebugLogger('affine::spec-patchers');
function patchSpecService<Spec extends BlockSpec>(
spec: Spec,
@@ -274,10 +242,9 @@ export function patchPeekViewService(
patchSpecService(rootSpec, pageService => {
pageService.peekViewService = {
peek: (target: ActivePeekView['target']) => {
logger.debug('center peek', target);
service.peekView.open(target);
return Promise.resolve();
peek: (target: ActivePeekView['target'], template?: TemplateResult) => {
logger.debug('center peek', target, template);
return service.peekView.open(target, template);
},
};
});

View File

@@ -6,7 +6,12 @@ import {
type SurfaceRefBlockComponent,
type SurfaceRefBlockModel,
} from '@blocksuite/blocks';
import type { BlockModel } from '@blocksuite/store';
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 =
| HTMLElement
@@ -24,13 +29,10 @@ export type DocPeekViewInfo = {
export type ActivePeekView = {
target: PeekViewTarget;
info: DocPeekViewInfo;
info?: DocPeekViewInfo;
template?: TemplateResult;
};
import type { BlockModel } from '@blocksuite/store';
import { resolveLinkToDoc } from '../../navigation';
const EMBED_DOC_FLAVOURS = [
'affine:embed-linked-doc',
'affine:embed-synced-doc',
@@ -50,8 +52,8 @@ const isSurfaceRefModel = (
function resolvePeekInfoFromPeekTarget(
peekTarget?: PeekViewTarget
): DocPeekViewInfo | null {
if (!peekTarget) return null;
): DocPeekViewInfo | undefined {
if (!peekTarget) return;
if (peekTarget instanceof AffineReference) {
if (peekTarget.refMeta) {
return {
@@ -91,14 +93,10 @@ function resolvePeekInfoFromPeekTarget(
blockId: peekTarget.blockId,
};
}
return null;
return;
}
export class PeekViewEntity extends Entity {
constructor() {
super();
}
private readonly _active$ = new LiveData<ActivePeekView | null>(null);
private readonly _show$ = new LiveData<boolean>(false);
@@ -108,14 +106,17 @@ export class PeekViewEntity extends Entity {
.distinctUntilChanged();
// return true if the peek view will be handled
open = (target: ActivePeekView['target']) => {
open = async (
target: ActivePeekView['target'],
template?: TemplateResult
) => {
const resolvedInfo = resolvePeekInfoFromPeekTarget(target);
if (!resolvedInfo) {
return false;
if (!resolvedInfo && !template) {
return;
}
this._active$.next({ target, info: resolvedInfo });
this._active$.next({ target, info: resolvedInfo, template });
this._show$.next(true);
return true;
return firstValueFrom(race(this._active$, this.show$).pipe(map(() => {})));
};
close = () => {

View File

@@ -10,28 +10,52 @@ import { DisposableGroup } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
import type { DocMode } from '@toeverything/infra';
import { DocsService, FrameworkScope, useService } from '@toeverything/infra';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useState,
} from 'react';
import { useEffect, useState } from 'react';
import { WorkbenchService } from '../../workbench';
import { PeekViewService } from '../services/peek-view';
import * as styles from './doc-peek-view.css';
import { useDoc } from './utils';
export type DocPreviewRef = {
getEditor: () => AffineEditorContainer | null;
fitViewportToTarget: () => void;
};
function fitViewport(
editor: AffineEditorContainer,
xywh?: `[${number},${number},${number},${number}]`
) {
const rootService =
editor.host.std.spec.getService<EdgelessRootService>('affine:page');
rootService.viewport.onResize();
const DocPreview = forwardRef<
DocPreviewRef,
{ docId: string; blockId?: string; mode?: DocMode }
>(function DocPreview({ docId, blockId, mode }, ref) {
if (xywh) {
const viewport = {
xywh: xywh,
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 { jumpToTag } = useNavigateHelper();
const workbench = useService(WorkbenchService).workbench;
@@ -45,26 +69,22 @@ const DocPreview = forwardRef<
const docs = useService(DocsService);
const [resolvedMode, setResolvedMode] = useState<DocMode | undefined>(mode);
useImperativeHandle(
ref,
() => ({
getEditor: () => editor,
fitViewportToTarget: () => {
if (editor && resolvedMode === 'edgeless') {
const rootService =
editor.host.std.spec.getService<EdgelessRootService>('affine:page');
rootService.viewport.onResize();
const data = rootService.getFitToScreenData();
rootService.viewport.setViewport(
data.zoom,
[data.centerX, data.centerY],
false
);
}
},
}),
[editor, resolvedMode]
);
useEffect(() => {
if (editor && resolvedMode === 'edgeless') {
editor.host
.closest('[data-testid="peek-view-modal-animation-container"]')
?.addEventListener(
'animationend',
() => {
fitViewport(editor, xywh);
},
{
once: true,
}
);
}
return;
}, [editor, resolvedMode, xywh]);
useEffect(() => {
if (!mode || !resolvedMode) {
@@ -95,7 +115,7 @@ const DocPreview = forwardRef<
// doc change event inside peek view should be handled by peek view
disposableGroup.add(
rootService.slots.docLinkClicked.on(({ docId, blockId }) => {
peekView.open({ docId, blockId });
peekView.open({ docId, blockId }).catch(console.error);
})
);
// todo: no tag peek view yet
@@ -140,86 +160,4 @@ const DocPreview = forwardRef<
</Scrollable.Root>
</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';
}

View File

@@ -112,7 +112,11 @@ export const PeekViewModalContainer = ({
[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
{...contentOptions}
className={styles.modalContent}

View File

@@ -19,7 +19,7 @@ import {
import { WorkbenchService } from '../../workbench';
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';
type ControlButtonProps = {
@@ -66,6 +66,31 @@ type DocPeekViewControlsProps = HTMLAttributes<HTMLDivElement> & {
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 = ({
docId,
blockId,

View File

@@ -1,32 +1,30 @@
import { toReactNode } from '@affine/component';
import { BlockElement } from '@blocksuite/block-std';
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 { PeekViewService } from '../services/peek-view';
import { DocPeekViewControls } from './doc-peek-controls';
import type { DocPreviewRef, SurfaceRefPeekViewRef } from './doc-peek-view';
import { DocPeekView, SurfaceRefPeekView } from './doc-peek-view';
import { DocPeekPreview } from './doc-peek-view';
import { PeekViewModalContainer } from './modal-container';
import {
DefaultPeekViewControls,
DocPeekViewControls,
} from './peek-view-controls';
function renderPeekView(
{ info }: ActivePeekView,
refCallback: (editor: SurfaceRefPeekViewRef | DocPreviewRef | null) => void
) {
if (info.mode === 'edgeless' && info.xywh) {
return (
<SurfaceRefPeekView
ref={refCallback}
docId={info.docId}
xywh={info.xywh}
/>
);
function renderPeekView({ info, template }: ActivePeekView) {
if (template) {
return toReactNode(template);
}
if (!info) {
return null;
}
return (
<DocPeekView
ref={refCallback}
<DocPeekPreview
mode={info.mode}
xywh={info.xywh}
docId={info.docId}
blockId={info.blockId}
/>
@@ -34,29 +32,26 @@ function renderPeekView(
}
const renderControls = ({ info }: ActivePeekView) => {
return (
<DocPeekViewControls
mode={info.mode}
docId={info.docId}
blockId={info.docId}
/>
);
if (info && 'docId' in info) {
return (
<DocPeekViewControls
mode={info.mode}
docId={info.docId}
blockId={info.docId}
/>
);
}
return <DefaultPeekViewControls />;
};
export const PeekViewManagerModal = () => {
const peekViewEntity = useService(PeekViewService).peekView;
const activePeekView = useLiveData(peekViewEntity.active$);
const show = useLiveData(peekViewEntity.show$);
const peekViewRef = useRef<SurfaceRefPeekViewRef | DocPreviewRef | null>(
null
);
const preview = useMemo(() => {
return activePeekView
? renderPeekView(activePeekView, editor => {
peekViewRef.current = editor;
})
: null;
return activePeekView ? renderPeekView(activePeekView) : null;
}, [activePeekView]);
const controls = useMemo(() => {
@@ -89,9 +84,6 @@ export const PeekViewManagerModal = () => {
peekViewEntity.close();
}
}}
onAnimateEnd={() => {
peekViewRef.current?.fitViewportToTarget();
}}
>
{preview}
</PeekViewModalContainer>

View File

@@ -56,7 +56,7 @@ export const AllPage = () => {
};
export const Component = () => {
performanceRenderLogger.info('AllPage');
performanceRenderLogger.debug('AllPage');
return <AllPage />;
};

View File

@@ -362,7 +362,7 @@ export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => {
};
export const Component = () => {
performanceRenderLogger.info('DetailPage');
performanceRenderLogger.debug('DetailPage');
const params = useParams();
const recentPages = useService(RecentPagesService);

View File

@@ -37,7 +37,7 @@ declare global {
}
export const Component = (): ReactElement => {
performanceRenderLogger.info('WorkspaceLayout');
performanceRenderLogger.debug('WorkspaceLayout');
const params = useParams();

View File

@@ -93,7 +93,7 @@ window.addEventListener('focus', () => {
frameworkProvider.get(LifecycleService).applicationStart();
export function App() {
performanceRenderLogger.info('App');
performanceRenderLogger.debug('App');
if (!languageLoadingPromise) {
languageLoadingPromise = loadLanguage().catch(console.error);

View File

@@ -81,7 +81,7 @@ window.addEventListener('focus', () => {
frameworkProvider.get(LifecycleService).applicationStart();
export function App() {
performanceRenderLogger.info('App');
performanceRenderLogger.debug('App');
if (!languageLoadingPromise) {
languageLoadingPromise = loadLanguage().catch(console.error);