From 604ff283947eacf54c14253325de4a1e131d0d77 Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Mon, 27 May 2024 01:31:45 +0800 Subject: [PATCH] feat: center peek view --- .../common/infra/src/livedata/livedata.ts | 2 +- .../block-suite-editor/lit-adaper.tsx | 6 +- .../specs/custom/spec-patchers.ts | 29 +++ .../collections/collections-list.tsx | 2 +- .../workspace-slider-bar/collections/doc.tsx | 11 +- .../collections/styles.css.ts | 6 + .../components/reference-page.tsx | 8 +- .../favorite/favourite-nav-item.tsx | 5 +- packages/frontend/core/src/modules/index.ts | 2 + .../modules/peek-view/entities/peek-view.ts | 27 +++ .../core/src/modules/peek-view/index.ts | 10 ++ .../modules/peek-view/services/peek-view.ts | 7 + .../peek-view/view/doc-peek-view-modal.css.ts | 9 + .../peek-view/view/doc-peek-view-modal.tsx | 168 ++++++++++++++++++ .../core/src/modules/peek-view/view/index.ts | 1 + .../peek-view/view/modal-container.tsx | 34 ++++ .../peek-view/view/peek-view-manager.tsx | 64 +++++++ .../core/src/modules/peek-view/view/utils.ts | 49 +++++ .../modules/workbench/view/workbench-link.tsx | 16 +- .../core/src/providers/modal-provider.tsx | 2 + 20 files changed, 440 insertions(+), 18 deletions(-) create mode 100644 packages/frontend/core/src/modules/peek-view/entities/peek-view.ts create mode 100644 packages/frontend/core/src/modules/peek-view/index.ts create mode 100644 packages/frontend/core/src/modules/peek-view/services/peek-view.ts create mode 100644 packages/frontend/core/src/modules/peek-view/view/doc-peek-view-modal.css.ts create mode 100644 packages/frontend/core/src/modules/peek-view/view/doc-peek-view-modal.tsx create mode 100644 packages/frontend/core/src/modules/peek-view/view/index.ts create mode 100644 packages/frontend/core/src/modules/peek-view/view/modal-container.tsx create mode 100644 packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx create mode 100644 packages/frontend/core/src/modules/peek-view/view/utils.ts diff --git a/packages/common/infra/src/livedata/livedata.ts b/packages/common/infra/src/livedata/livedata.ts index a5a5db0266de6..3432956f61399 100644 --- a/packages/common/infra/src/livedata/livedata.ts +++ b/packages/common/infra/src/livedata/livedata.ts @@ -337,7 +337,7 @@ export class LiveData distinctUntilChanged(comparator?: (previous: T, current: T) => boolean) { return LiveData.from( this.pipe(distinctUntilChanged(comparator)), - null as any + null as T ); } diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx index 8b52be1adfd41..5c04d6274adf8 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx @@ -4,6 +4,7 @@ import { useLitPortalFactory, } from '@affine/component'; import { useJournalInfoHelper } from '@affine/core/hooks/use-journal'; +import { PeekViewService } from '@affine/core/modules/peek-view'; import { WorkbenchService } from '@affine/core/modules/workbench'; import type { BlockSpec } from '@blocksuite/block-std'; import { @@ -30,6 +31,7 @@ import { AffinePageReference } from '../../affine/reference-link'; import { BlocksuiteEditorJournalDocTitle } from './journal-doc-title'; import { patchNotificationService, + patchPeekViewService, patchReferenceRenderer, type ReferenceReactRenderer, } from './specs/custom/spec-patchers'; @@ -66,6 +68,7 @@ interface BlocksuiteEditorProps { const usePatchSpecs = (page: Doc, specs: BlockSpec[]) => { const [reactToLit, portals] = useLitPortalFactory(); + const peekViewService = useService(PeekViewService); const referenceRenderer: ReferenceReactRenderer = useMemo(() => { return function customReference(reference) { const pageId = reference.delta.attributes?.reference?.pageId; @@ -83,8 +86,9 @@ const usePatchSpecs = (page: Doc, specs: BlockSpec[]) => { patchReferenceRenderer(patched, reactToLit, referenceRenderer), confirmModal ); + patched = patchPeekViewService(patched, peekViewService); return patched; - }, [confirmModal, reactToLit, referenceRenderer, specs]); + }, [confirmModal, peekViewService, reactToLit, referenceRenderer, specs]); return [ patchedSpecs, diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.ts index 7da1e034fff95..8734c2e884be5 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.ts @@ -6,6 +6,9 @@ import { type ToastOptions, type useConfirmModal, } from '@affine/component'; +import type { PeekViewService } from '@affine/core/modules/peek-view'; +import type { ActivePeekView } from '@affine/core/modules/peek-view/entities/peek-view'; +import { DebugLogger } from '@affine/debug'; import type { BlockSpec } from '@blocksuite/block-std'; import type { AffineReference, @@ -15,6 +18,8 @@ import type { import { LitElement, type TemplateResult } from 'lit'; import React, { createElement, type ReactNode } from 'react'; +const logger = new DebugLogger('affine::spec-patchers'); + export type ReferenceReactRenderer = ( reference: AffineReference ) => React.ReactElement; @@ -190,3 +195,27 @@ export function patchNotificationService( }); return specs; } + +export function patchPeekViewService( + specs: BlockSpec[], + service: PeekViewService +) { + const rootSpec = specs.find( + spec => spec.schema.model.flavour === 'affine:page' + ) as BlockSpec; + + if (!rootSpec) { + return specs; + } + + patchSpecService(rootSpec, pageService => { + pageService.peekViewService = { + peek: (target: ActivePeekView['target']) => { + logger.debug('center peek', target); + service.peekView.open(target); + }, + }; + }); + + return specs; +} diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx index 6a18f1099972a..5bd59107f01cf 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx @@ -231,7 +231,7 @@ export const CollectionSidebarNavItem = ({ {collection.name} -
+
{pagesToRender.map(page => { return ( - 0 ? collapsed : undefined} @@ -101,7 +102,7 @@ export const Doc = ({ {...listeners} > {doc.title || t['Untitled']()} - + {referencesToRender.map(id => { return ( diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/styles.css.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/styles.css.ts index 24d16682a045b..5ecdb5601ddac 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/styles.css.ts +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/styles.css.ts @@ -150,3 +150,9 @@ export const emptyCollectionNewButton = style({ height: '28px', fontSize: cssVar('fontXs'), }); +export const docsListContainer = style({ + marginLeft: 20, + display: 'flex', + flexDirection: 'column', + gap: 4, +}); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx index 106409e119e48..b4bbb1c4fa045 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx @@ -1,5 +1,8 @@ import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references'; -import { WorkbenchService } from '@affine/core/modules/workbench'; +import { + WorkbenchLink, + WorkbenchService, +} from '@affine/core/modules/workbench'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; import type { DocCollection, DocMeta } from '@blocksuite/store'; @@ -60,10 +63,11 @@ export const ReferencePage = ({ data-type="reference-page" data-testid={`reference-page-${pageId}`} active={active} - to={`/workspace/${docCollection.id}/${pageId}`} + to={`/${pageId}`} icon={icon} collapsed={collapsible ? collapsed : undefined} onCollapsedChange={setCollapsed} + linkComponent={WorkbenchLink} postfix={ (null); + + active$ = this._active$.distinctUntilChanged(); + show$ = this.active$.map(active => active !== null).distinctUntilChanged(); + + open(target: ActivePeekView['target']) { + this._active$.next({ target }); + } + + close() { + if (this._active$.value === null) return; + this._active$.next(null); + } +} diff --git a/packages/frontend/core/src/modules/peek-view/index.ts b/packages/frontend/core/src/modules/peek-view/index.ts new file mode 100644 index 0000000000000..8ed492907cb86 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/index.ts @@ -0,0 +1,10 @@ +import type { Framework } from '@toeverything/infra'; + +import { PeekViewEntity } from './entities/peek-view'; +import { PeekViewService } from './services/peek-view'; + +export function configurePeekViewModule(framework: Framework) { + framework.service(PeekViewService).entity(PeekViewEntity); +} + +export { PeekViewEntity, PeekViewService }; diff --git a/packages/frontend/core/src/modules/peek-view/services/peek-view.ts b/packages/frontend/core/src/modules/peek-view/services/peek-view.ts new file mode 100644 index 0000000000000..58ee1f80b3f42 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/services/peek-view.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { PeekViewEntity } from '../entities/peek-view'; + +export class PeekViewService extends Service { + public readonly peekView = this.framework.createEntity(PeekViewEntity); +} diff --git a/packages/frontend/core/src/modules/peek-view/view/doc-peek-view-modal.css.ts b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view-modal.css.ts new file mode 100644 index 0000000000000..e6903aadeeb1e --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view-modal.css.ts @@ -0,0 +1,9 @@ +import { style } from '@vanilla-extract/css'; + +export const editor = style({ + vars: { + '--affine-editor-width': '100%', + '--affine-editor-side-padding': '160px', + }, + minHeight: '100%', +}); diff --git a/packages/frontend/core/src/modules/peek-view/view/doc-peek-view-modal.tsx b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view-modal.tsx new file mode 100644 index 0000000000000..5ce4fba3ddeb7 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view-modal.tsx @@ -0,0 +1,168 @@ +import { Scrollable } from '@affine/component'; +import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton'; +import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary'; +import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor'; +import { PageNotFound } from '@affine/core/pages/404'; +import { + Bound, + type EdgelessRootService, + type SurfaceRefBlockComponent, + type SurfaceRefBlockModel, +} from '@blocksuite/blocks'; +import type { AffineEditorContainer } from '@blocksuite/presets'; +import type { Doc, DocMode } from '@toeverything/infra'; +import { + DocsService, + useLiveData, + useService, + WorkspaceService, +} from '@toeverything/infra'; +import { forwardRef, useEffect, useLayoutEffect, useState } from 'react'; + +import * as styles from './doc-peek-view-modal.css'; +import { PeekViewModalContainer } from './modal-container'; + +const useDoc = (pageId: string) => { + const currentWorkspace = useService(WorkspaceService).workspace; + const docsService = useService(DocsService); + const docRecordList = docsService.list; + const docListReady = useLiveData(docRecordList.isReady$); + const docRecord = docRecordList.doc$(pageId).value; + + const [doc, setDoc] = useState(null); + + useLayoutEffect(() => { + if (!docRecord) { + return; + } + const { doc: opened, release } = docsService.open(pageId); + setDoc(opened); + return () => { + release(); + }; + }, [docRecord, docsService, pageId]); + + // set sync engine priority target + useEffect(() => { + currentWorkspace.engine.doc.setPriority(pageId, 10); + return () => { + currentWorkspace.engine.doc.setPriority(pageId, 5); + }; + }, [currentWorkspace, pageId]); + + return { doc, loading: !docListReady }; +}; + +const DocPreview = forwardRef< + AffineEditorContainer, + { docId: string; mode?: DocMode } +>(function DocPreview({ docId, mode }, ref) { + const { doc, loading } = useDoc(docId); + + const docs = useService(DocsService); + const [resolvedMode, setResolvedMode] = useState(mode); + + useEffect(() => { + if (!mode || !resolvedMode) { + setResolvedMode(docs.list.doc$(docId).value?.mode$.value || 'page'); + } + }, [docId, docs.list, resolvedMode, mode]); + + // if sync engine has been synced and the page is null, show 404 page. + if (!doc || !resolvedMode) { + return loading || !resolvedMode ? ( + + ) : ( + + ); + } + + return ( + + + + + + + + + ); +}); + +export const DocPeekViewModal = ({ + docId, + mode, + ...rest +}: { + docId: string; + open: boolean; + mode?: DocMode; + onOpenChange: (open: boolean) => void; +}) => { + return ( + + + + ); +}; + +export const SurfaceRefPeekViewModal = ({ + target, + ...rest +}: { + blockModel: SurfaceRefBlockModel; + open: boolean; + target: SurfaceRefBlockComponent; + onOpenChange: (open: boolean) => void; +}) => { + const refModel = target.referenceModel; + const [editorRef, setEditorRef] = useState( + null + ); + + useEffect(() => { + let mounted = true; + if (editorRef) { + editorRef.host.updateComplete + .then(() => { + if (refModel && mounted) { + const viewport = { + xywh: refModel.xywh, + padding: [60, 20, 20, 20] as [number, number, number, number], + }; + const rootService = + editorRef.host.std.spec.getService( + 'affine:page' + ); + rootService.viewport.setViewportByBound( + Bound.deserialize(viewport.xywh), + viewport.padding + ); + } + }) + .catch(e => { + console.error(e); + }); + } + return () => { + mounted = false; + }; + }, [editorRef, refModel]); + + if (!refModel) { + return 'page not found'; + } + + const docId = 'page' in refModel ? refModel.doc.id : refModel?.surface.doc.id; + + return ( + + + + ); +}; diff --git a/packages/frontend/core/src/modules/peek-view/view/index.ts b/packages/frontend/core/src/modules/peek-view/view/index.ts new file mode 100644 index 0000000000000..c2c5d9b7cd4f0 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/index.ts @@ -0,0 +1 @@ +export { PeekViewManagerModal } from './peek-view-manager'; diff --git a/packages/frontend/core/src/modules/peek-view/view/modal-container.tsx b/packages/frontend/core/src/modules/peek-view/view/modal-container.tsx new file mode 100644 index 0000000000000..96c40ee85f90e --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/modal-container.tsx @@ -0,0 +1,34 @@ +import { Modal } from '@affine/component'; +import type { DialogContentProps } from '@radix-ui/react-dialog'; +import type { PropsWithChildren } from 'react'; + +const contentOptions: DialogContentProps = { + ['data-testid' as string]: 'peek-view-modal', + style: { + padding: 0, + backgroundColor: 'var(--affine-background-primary-color)', + overflow: 'hidden', + }, +}; + +export const PeekViewModalContainer = ({ + onOpenChange, + open, + children, +}: PropsWithChildren<{ + open: boolean; + onOpenChange: (open: boolean) => void; +}>) => { + return ( + + {children} + + ); +}; diff --git a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx new file mode 100644 index 0000000000000..c4206a67e6213 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx @@ -0,0 +1,64 @@ +import { + AffineReference, + type SurfaceRefBlockComponent, +} from '@blocksuite/blocks'; +import { useLiveData, useService } from '@toeverything/infra'; +import { useMemo } from 'react'; + +import { PeekViewService } from '../services/peek-view'; +import { + DocPeekViewModal, + SurfaceRefPeekViewModal, +} from './doc-peek-view-modal'; +import { + isEmbedDocModel, + isImageModel, + isSurfaceRefModel, + resolveLinkToDoc, +} from './utils'; + +export const PeekViewManagerModal = () => { + const peekView = useService(PeekViewService).peekView; + const peekTarget = useLiveData(peekView.active$)?.target; + + const options = useMemo(() => { + return { + open: !!peekTarget, + onOpenChange: (open: boolean) => { + if (!open) { + peekView.close(); + } + }, + }; + }, [peekTarget, peekView]); + + if (peekTarget) { + if (peekTarget instanceof AffineReference) { + if (peekTarget.refMeta) { + return ; + } + } else if ('model' in peekTarget) { + const blockModel = peekTarget.model; + if (isEmbedDocModel(blockModel)) { + return ; + } else if (isSurfaceRefModel(blockModel)) { + return ( + + ); + } else if (isImageModel(blockModel)) { + return
TODO: Image Peek View
; + } + } else if (peekTarget instanceof HTMLAnchorElement) { + const maybeDoc = resolveLinkToDoc(peekTarget.href); + // todo: handle cases that workspaceId is not the same as current workspace? + if (maybeDoc) { + return ; + } + } + } + return null; +}; diff --git a/packages/frontend/core/src/modules/peek-view/view/utils.ts b/packages/frontend/core/src/modules/peek-view/view/utils.ts new file mode 100644 index 0000000000000..dc10312cb07c7 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/utils.ts @@ -0,0 +1,49 @@ +import type { + EmbedLinkedDocModel, + EmbedSyncedDocModel, + ImageBlockModel, + SurfaceRefBlockModel, +} from '@blocksuite/blocks'; +import type { BlockModel } from '@blocksuite/store'; + +const EMBED_DOC_FLAVOURS = [ + 'affine:embed-linked-doc', + 'affine:embed-synced-doc', +]; + +export const isEmbedDocModel = ( + blockModel: BlockModel +): blockModel is EmbedSyncedDocModel | EmbedLinkedDocModel => { + return EMBED_DOC_FLAVOURS.includes(blockModel.flavour); +}; + +export const isSurfaceRefModel = ( + blockModel: BlockModel +): blockModel is SurfaceRefBlockModel => { + return blockModel.flavour === 'affine:surface-ref'; +}; + +export const isImageModel = ( + blockModel: BlockModel +): blockModel is ImageBlockModel => { + return blockModel.flavour === 'affine:image'; +}; + +export const resolveLinkToDoc = (href: string) => { + // http://xxx/workspace/48__RTCSwASvWZxyAk3Jw/-Uge-K6SYcAbcNYfQ5U-j + // to { workspaceId: '48__RTCSwASvWZxyAk3Jw', docId: '-Uge-K6SYcAbcNYfQ5U-j' } + + const [_, workspaceId, docId] = + href.match(/workspace\/([^/]+)\/([^/]+)/) || []; + + /** + * @see /packages/frontend/core/src/router.tsx + */ + const excludedPaths = ['all', 'collection', 'tag', 'trash']; + + if (!docId || excludedPaths.includes(docId)) { + return null; + } + + return { workspaceId, docId }; +}; diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx b/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx index 0c33500ab31d6..161eb9952cf99 100644 --- a/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx +++ b/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx @@ -2,8 +2,9 @@ import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-h import { popupWindow } from '@affine/core/utils'; import { useLiveData, useService } from '@toeverything/infra'; import type { To } from 'history'; -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; +import { PeekViewService } from '../../peek-view'; import { WorkbenchService } from '../services/workbench'; export const WorkbenchLink = ({ @@ -14,33 +15,36 @@ export const WorkbenchLink = ({ { to: To } & React.HTMLProps >) => { const workbench = useService(WorkbenchService).workbench; + const peekView = useService(PeekViewService).peekView; const { appSettings } = useAppSettingHelper(); const basename = useLiveData(workbench.basename$); const link = basename + (typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`); + const ref = useRef(null); const handleClick = useCallback( (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); if (onClick?.(event)) { return; } - + event.preventDefault(); + event.stopPropagation(); if (event.ctrlKey || event.metaKey) { if (appSettings.enableMultiView && environment.isDesktop) { workbench.open(to, { at: 'beside' }); } else if (!environment.isDesktop) { popupWindow(link); } + } else if (event.shiftKey && ref.current) { + peekView.open(ref.current); } else { workbench.open(to); } }, - [appSettings.enableMultiView, link, onClick, to, workbench] + [appSettings.enableMultiView, link, onClick, peekView, to, workbench] ); // eslint suspicious runtime error // eslint-disable-next-line react/no-danger-with-children - return ; + return ; }; diff --git a/packages/frontend/core/src/providers/modal-provider.tsx b/packages/frontend/core/src/providers/modal-provider.tsx index 3b5cd62da4790..b428e9a113cb4 100644 --- a/packages/frontend/core/src/providers/modal-provider.tsx +++ b/packages/frontend/core/src/providers/modal-provider.tsx @@ -24,6 +24,7 @@ import { PaymentDisableModal } from '../components/affine/payment-disable'; import { useAsyncCallback } from '../hooks/affine-async-hooks'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; import { AuthService } from '../modules/cloud/services/auth'; +import { PeekViewManagerModal } from '../modules/peek-view/view'; import { WorkspaceSubPath } from '../shared'; const SettingModal = lazy(() => @@ -218,6 +219,7 @@ export function CurrentWorkspaceModals() { )} + {environment.isDesktop && } );