mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat(core): peek view api enhancements (#7288)
upstream https://github.com/toeverything/blocksuite/pull/7390 fix AF-917
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
export { createComponent as createReactComponentFromLit } from './create-component';
|
||||
export * from './lit-portal';
|
||||
export { toReactNode } from './to-react-node';
|
||||
|
||||
35
packages/frontend/component/src/lit-react/to-react-node.ts
Normal file
35
packages/frontend/component/src/lit-react/to-react-node.ts
Normal 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 });
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
|
||||
@@ -56,7 +56,7 @@ export const AllPage = () => {
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
performanceRenderLogger.info('AllPage');
|
||||
performanceRenderLogger.debug('AllPage');
|
||||
|
||||
return <AllPage />;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -37,7 +37,7 @@ declare global {
|
||||
}
|
||||
|
||||
export const Component = (): ReactElement => {
|
||||
performanceRenderLogger.info('WorkspaceLayout');
|
||||
performanceRenderLogger.debug('WorkspaceLayout');
|
||||
|
||||
const params = useParams();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user