From 44aacc2ac9de069b3de2ffb22514995528b9856c 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 +- .../app-sidebar/menu-item/index.css.ts | 1 - .../block-suite-editor/lit-adaper.tsx | 6 +- .../specs/custom/spec-patchers.ts | 29 +++ .../components/page-list/operation-cell.tsx | 4 +- .../collections/collections-list.tsx | 2 +- .../workspace-slider-bar/collections/doc.tsx | 22 ++- .../collections/styles.css.ts | 6 + .../components/reference-page.tsx | 8 +- .../favorite/favourite-nav-item.tsx | 8 +- packages/frontend/core/src/modules/index.ts | 2 + .../modules/peek-view/entities/peek-view.ts | 35 ++++ .../core/src/modules/peek-view/index.ts | 11 ++ .../modules/peek-view/services/peek-view.ts | 9 + .../peek-view/view/doc-peek-controls.css.ts | 20 +++ .../peek-view/view/doc-peek-controls.tsx | 144 +++++++++++++++ .../peek-view/view/doc-peek-view.css.ts | 9 + .../modules/peek-view/view/doc-peek-view.tsx | 170 ++++++++++++++++++ .../core/src/modules/peek-view/view/index.ts | 2 + .../peek-view/view/modal-container.css.ts | 154 ++++++++++++++++ .../peek-view/view/modal-container.tsx | 127 +++++++++++++ .../peek-view/view/peek-view-manager.tsx | 136 ++++++++++++++ .../core/src/modules/peek-view/view/utils.ts | 88 +++++++++ .../modules/workbench/view/workbench-link.tsx | 26 ++- .../workspace/detail-page/detail-page.tsx | 12 +- .../core/src/providers/modal-provider.tsx | 2 + packages/frontend/i18n/src/resources/en.json | 6 +- scripts/setup/{lottie-web.ts => vi-mock.ts} | 14 ++ vitest.config.ts | 2 +- 29 files changed, 1022 insertions(+), 35 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-controls.css.ts create mode 100644 packages/frontend/core/src/modules/peek-view/view/doc-peek-controls.tsx create mode 100644 packages/frontend/core/src/modules/peek-view/view/doc-peek-view.css.ts create mode 100644 packages/frontend/core/src/modules/peek-view/view/doc-peek-view.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.css.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 rename scripts/setup/{lottie-web.ts => vi-mock.ts} (66%) 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/app-sidebar/menu-item/index.css.ts b/packages/frontend/core/src/components/app-sidebar/menu-item/index.css.ts index 53110c9ae518e..ae41cb291575c 100644 --- a/packages/frontend/core/src/components/app-sidebar/menu-item/index.css.ts +++ b/packages/frontend/core/src/components/app-sidebar/menu-item/index.css.ts @@ -2,7 +2,6 @@ import { cssVar } from '@toeverything/theme'; import { style } from '@vanilla-extract/css'; export const linkItemRoot = style({ color: 'inherit', - display: 'contents', }); export const root = style({ display: 'inline-flex', 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/page-list/operation-cell.tsx b/packages/frontend/core/src/components/page-list/operation-cell.tsx index 58cac612b081b..33d06e8ade1ed 100644 --- a/packages/frontend/core/src/components/page-list/operation-cell.tsx +++ b/packages/frontend/core/src/components/page-list/operation-cell.tsx @@ -18,6 +18,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { DeleteIcon, DeletePermanentlyIcon, + DualLinkIcon, DuplicateIcon, EditIcon, FavoritedIcon, @@ -25,7 +26,6 @@ import { FilterIcon, FilterMinusIcon, MoreVerticalIcon, - OpenInNewIcon, PlusIcon, ResetIcon, SplitViewIcon, @@ -170,7 +170,7 @@ export const PageOperationCell = ({ style={{ marginBottom: 4 }} preFix={ - + } > 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 ( : ; }, [docMode]); - const { jumpToPage } = useNavigateHelper(); - const clickDoc = useCallback(() => { - jumpToPage(docCollection.id, doc.id); - }, [jumpToPage, doc.id, docCollection.id]); - const references = useBlockSuitePageReferences(docCollection, docId); const referencesToRender = references.filter( id => allPageMeta[id] && !allPageMeta[id]?.trash @@ -78,11 +75,12 @@ export const Doc = ({ data-draggable={true} data-dragging={isDragging} > - 0 ? collapsed : undefined} @@ -101,7 +99,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); + private readonly _show$ = new LiveData(false); + + active$ = this._active$.distinctUntilChanged(); + show$ = this._show$ + .map(show => show && this._active$.value !== null) + .distinctUntilChanged(); + + open = (target: ActivePeekView['target']) => { + this._active$.next({ target }); + this._show$.next(true); + }; + + close = () => { + this._show$.next(false); + }; +} 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..eb46e5ddba1a4 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/index.ts @@ -0,0 +1,11 @@ +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 }; +export { useInsidePeekView } from './view'; 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..c4abfe232c9ee --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/services/peek-view.ts @@ -0,0 +1,9 @@ +import { OnEvent, Service } from '@toeverything/infra'; + +import { WorkbenchLocationChanged } from '../../workbench/services/workbench'; +import { PeekViewEntity } from '../entities/peek-view'; + +@OnEvent(WorkbenchLocationChanged, e => e.peekView.close) +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-controls.css.ts b/packages/frontend/core/src/modules/peek-view/view/doc-peek-controls.css.ts new file mode 100644 index 0000000000000..38334d2c364b3 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/doc-peek-controls.css.ts @@ -0,0 +1,20 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +export const root = style({ + display: 'flex', + flexDirection: 'column', + height: '100%', + gap: 8, +}); + +export const button = style({ + color: cssVar('iconColor'), + boxShadow: cssVar('shadow2'), + borderRadius: 8, + fontSize: '20px !important', + ':hover': { + background: cssVar('hoverColorFilled'), + }, + pointerEvents: 'auto', +}); diff --git a/packages/frontend/core/src/modules/peek-view/view/doc-peek-controls.tsx b/packages/frontend/core/src/modules/peek-view/view/doc-peek-controls.tsx new file mode 100644 index 0000000000000..5d989132bb653 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/doc-peek-controls.tsx @@ -0,0 +1,144 @@ +import { IconButton, Tooltip } from '@affine/component'; +import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { + CloseIcon, + DualLinkIcon, + ExpandFullIcon, + SplitViewIcon, +} from '@blocksuite/icons'; +import { type DocMode, useService } from '@toeverything/infra'; +import { clsx } from 'clsx'; +import { + type HTMLAttributes, + type MouseEventHandler, + type ReactElement, + useCallback, + useMemo, +} from 'react'; + +import { WorkbenchService } from '../../workbench'; +import { PeekViewService } from '../services/peek-view'; +import * as styles from './doc-peek-controls.css'; +import { useDoc } from './utils'; + +type ControlButtonProps = { + nameKey: string; + icon: ReactElement; + name: string; + onClick: () => void; +}; + +export const ControlButton = ({ + icon, + nameKey, + name, + onClick, +}: ControlButtonProps) => { + const handleClick: MouseEventHandler = useCallback( + e => { + e.stopPropagation(); + e.preventDefault(); + onClick(); + }, + [onClick] + ); + + return ( + + + + ); +}; + +type DocPeekViewControlsProps = HTMLAttributes & { + docId: string; + blockId?: string; + mode?: DocMode; +}; + +export const DocPeekViewControls = ({ + docId, + blockId, + mode, + className, + ...rest +}: DocPeekViewControlsProps) => { + const peekView = useService(PeekViewService).peekView; + const workbench = useService(WorkbenchService).workbench; + const { jumpToPageBlock } = useNavigateHelper(); + const t = useAFFiNEI18N(); + const { doc, workspace } = useDoc(docId); + const controls = useMemo(() => { + return [ + { + icon: , + nameKey: 'close', + name: t['com.affine.peek-view-controls.close'](), + onClick: peekView.close, + }, + { + icon: , + name: t['com.affine.peek-view-controls.open-doc'](), + nameKey: 'open', + onClick: () => { + // todo: for frame blocks, we should mimic "view in edgeless" button behavior + blockId + ? jumpToPageBlock(workspace.id, docId, blockId) + : workbench.openPage(docId); + if (mode) { + doc?.setMode(mode); + } + peekView.close(); + }, + }, + environment.isDesktop && { + icon: , + nameKey: 'split-view', + name: t['com.affine.peek-view-controls.open-doc-in-split-view'](), + onClick: () => { + workbench.openPage(docId, { at: 'beside' }); + peekView.close(); + }, + }, + !environment.isDesktop && { + icon: , + nameKey: 'new-tab', + name: t['com.affine.peek-view-controls.open-doc-in-new-tab'](), + onClick: () => { + window.open( + `/workspace/${workspace.id}/${docId}#${blockId ?? ''}`, + '_blank' + ); + peekView.close(); + }, + }, + ].filter((opt): opt is ControlButtonProps => Boolean(opt)); + }, [ + blockId, + doc, + docId, + jumpToPageBlock, + mode, + peekView, + t, + workbench, + workspace.id, + ]); + return ( +
+ {controls.map(option => ( + + ))} +
+ ); +}; diff --git a/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.css.ts b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.css.ts new file mode 100644 index 0000000000000..e6903aadeeb1e --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.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.tsx b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.tsx new file mode 100644 index 0000000000000..10b3aac8003b9 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.tsx @@ -0,0 +1,170 @@ +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 { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; +import { PageNotFound } from '@affine/core/pages/404'; +import { Bound, type EdgelessRootService } from '@blocksuite/blocks'; +import { DisposableGroup } from '@blocksuite/global/utils'; +import { type AffineEditorContainer, AIProvider } from '@blocksuite/presets'; +import type { DocMode } from '@toeverything/infra'; +import { DocsService, FrameworkScope, useService } from '@toeverything/infra'; +import { forwardRef, 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'; + +const DocPreview = forwardRef< + AffineEditorContainer, + { docId: string; blockId?: string; mode?: DocMode } +>(function DocPreview({ docId, blockId, mode }, ref) { + const { doc, workspace, loading } = useDoc(docId); + const { jumpToTag } = useNavigateHelper(); + const workbench = useService(WorkbenchService).workbench; + const peekView = useService(PeekViewService).peekView; + const [editor, setEditor] = useState(null); + + const onRef = (editor: AffineEditorContainer) => { + if (typeof ref === 'function') { + ref(editor); + } else if (ref) { + ref.current = editor; + } + setEditor(editor); + }; + + 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]); + + useEffect(() => { + const disposable = AIProvider.slots.requestContinueInChat.on(() => { + if (doc) { + workbench.openPage(doc.id); + peekView.close(); + // chat panel open is already handled in + } + }); + + return () => { + disposable.dispose(); + }; + }, [doc, peekView, workbench, workspace.id]); + + useEffect(() => { + const disposableGroup = new DisposableGroup(); + if (editor) { + editor.updateComplete + .then(() => { + const rootService = editor.host.std.spec.getService('affine:page'); + // doc change event inside peek view should be handled by peek view + disposableGroup.add( + rootService.slots.docLinkClicked.on(({ docId, blockId }) => { + peekView.open({ docId, blockId }); + }) + ); + // todo: no tag peek view yet + disposableGroup.add( + rootService.slots.tagClicked.on(({ tagId }) => { + jumpToTag(workspace.id, tagId); + peekView.close(); + }) + ); + }) + .catch(console.error); + } + return () => { + disposableGroup.dispose(); + }; + }, [editor, jumpToTag, peekView, workspace.id]); + + // if sync engine has been synced and the page is null, show 404 page. + if (!doc || !resolvedMode) { + return loading || !resolvedMode ? ( + + ) : ( + + ); + } + + return ( + + + + + + + + + + + ); +}); + +export const DocPeekView = ({ + docId, + blockId, + mode, +}: { + docId: string; + blockId?: string; + mode?: DocMode; +}) => { + return ; +}; + +export const SurfaceRefPeekView = ({ + docId, + xywh, +}: { + docId: string; + xywh: `[${number},${number},${number},${number}]`; +}) => { + const [editorRef, setEditorRef] = useState( + null + ); + + useEffect(() => { + let mounted = true; + if (editorRef) { + editorRef.host.updateComplete + .then(() => { + if (mounted) { + const viewport = { + xywh: 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, xywh]); + + 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..6ac036ea91cee --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/index.ts @@ -0,0 +1,2 @@ +export { useInsidePeekView } from './modal-container'; +export { PeekViewManagerModal } from './peek-view-manager'; diff --git a/packages/frontend/core/src/modules/peek-view/view/modal-container.css.ts b/packages/frontend/core/src/modules/peek-view/view/modal-container.css.ts new file mode 100644 index 0000000000000..a334fcf8101fe --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/modal-container.css.ts @@ -0,0 +1,154 @@ +import { cssVar } from '@toeverything/theme'; +import { createVar, keyframes, style } from '@vanilla-extract/css'; + +export const animationTimeout = createVar(); +export const transformOrigin = createVar(); + +const contentShow = keyframes({ + from: { + opacity: 0, + transform: 'scale(0.10)', + }, + to: { + opacity: 1, + transform: 'scale(1)', + }, +}); +const contentHide = keyframes({ + to: { + opacity: 0, + transform: 'scale(0.10)', + }, + from: { + opacity: 1, + transform: 'scale(1)', + }, +}); + +const fadeIn = keyframes({ + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, +}); + +const fadeOut = keyframes({ + from: { + opacity: 1, + }, + to: { + opacity: 0, + }, +}); + +const slideRight = keyframes({ + from: { + transform: 'translateX(-200%)', + opacity: 0, + }, + to: { + transform: 'translateX(0)', + opacity: 1, + }, +}); + +const slideLeft = keyframes({ + from: { + transform: 'translateX(0)', + opacity: 1, + }, + to: { + transform: 'translateX(-200%)', + opacity: 0, + }, +}); + +export const modalOverlay = style({ + position: 'fixed', + inset: 0, + zIndex: cssVar('zIndexModal'), + backgroundColor: cssVar('black30'), + willChange: 'opacity', + selectors: { + '&[data-state=entered], &[data-state=entering]': { + animation: `${fadeIn} ${animationTimeout} ease-in-out`, + animationFillMode: 'forwards', + }, + '&[data-state=exited], &[data-state=exiting]': { + animation: `${fadeOut} ${animationTimeout} ${animationTimeout} ease-in-out`, + animationFillMode: 'backwards', + }, + }, +}); + +export const modalContentWrapper = style({ + position: 'fixed', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: cssVar('zIndexModal'), +}); + +export const modalContentContainer = style({ + display: 'flex', + alignItems: 'flex-start', + width: '90%', + height: '90%', +}); + +export const modalContent = style({ + flex: 1, + height: '100%', + backgroundColor: cssVar('backgroundOverlayPanelColor'), + boxShadow: '0px 0px 0px 2.23px rgba(0, 0, 0, 0.08)', + borderRadius: '8px', + minHeight: 300, + // :focus-visible will set outline + outline: 'none', + position: 'relative', + willChange: 'transform, opacity', + transformOrigin: transformOrigin, + selectors: { + '&[data-state=entered], &[data-state=entering]': { + animationFillMode: 'forwards', + animationName: contentShow, + animationDuration: animationTimeout, + animationTimingFunction: 'cubic-bezier(0.42, 0, 0.58, 1)', + }, + '&[data-state=exited], &[data-state=exiting]': { + animationFillMode: 'forwards', + animationName: contentHide, + animationDuration: animationTimeout, + animationTimingFunction: 'cubic-bezier(0.42, 0, 0.58, 1)', + animationDelay: animationTimeout, + }, + }, +}); + +export const modalControls = style({ + flexShrink: 0, + zIndex: -1, + minWidth: '48px', + padding: '8px 0 0 16px', + opacity: 0, + transformOrigin: transformOrigin, + pointerEvents: 'auto', + selectors: { + '&[data-state=entered], &[data-state=entering]': { + animationName: slideRight, + animationDuration: animationTimeout, + animationFillMode: 'forwards', + animationTimingFunction: 'ease-in-out', + animationDelay: `calc(${animationTimeout} / 2)`, + }, + '&[data-state=exited], &[data-state=exiting]': { + animationName: slideLeft, + animationDuration: animationTimeout, + animationFillMode: 'forwards', + animationTimingFunction: 'ease-in-out', + }, + }, +}); 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..80c7c6974a27b --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/modal-container.tsx @@ -0,0 +1,127 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import { cssVar } from '@toeverything/theme'; +import { assignInlineVars } from '@vanilla-extract/dynamic'; +import { + createContext, + type PropsWithChildren, + useContext, + useEffect, + useState, +} from 'react'; +import useTransition from 'react-transition-state'; + +import * as styles from './modal-container.css'; + +const animationTimeout = 200; + +const contentOptions: Dialog.DialogContentProps = { + ['data-testid' as string]: 'peek-view-modal', + onPointerDownOutside: e => { + const el = e.target as HTMLElement; + if (el.closest('[data-peek-view-wrapper]')) { + e.preventDefault(); + } + }, + style: { + padding: 0, + backgroundColor: cssVar('backgroundPrimaryColor'), + overflow: 'hidden', + }, +}; + +// a dummy context to let elements know they are inside a peek view +export const PeekViewContext = createContext | null>( + null +); + +const emptyContext = {}; + +export const useInsidePeekView = () => { + const context = useContext(PeekViewContext); + return !!context; +}; + +function getElementScreenPositionCenter(target: HTMLElement) { + const rect = target.getBoundingClientRect(); + + if (rect.top === 0 || rect.left === 0) { + return null; + } + + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const scrollLeft = window.scrollX || document.documentElement.scrollLeft; + + return { + x: rect.x + scrollLeft + rect.width / 2, + y: rect.y + scrollTop + rect.height / 2, + }; +} + +export const PeekViewModalContainer = ({ + onOpenChange, + open, + target, + controls, + children, + onAnimateEnd, +}: PropsWithChildren<{ + open: boolean; + target?: HTMLElement; + onOpenChange: (open: boolean) => void; + controls: React.ReactNode; + onAnimateEnd?: () => void; +}>) => { + const [{ status }, toggle] = useTransition({ + timeout: animationTimeout * 2, + onStateChange(event) { + if (event.current.status === 'exited' && onAnimateEnd) { + onAnimateEnd(); + } + }, + }); + const [transformOrigin, setTransformOrigin] = useState(null); + useEffect(() => { + toggle(open); + const bondingBox = target ? getElementScreenPositionCenter(target) : null; + setTransformOrigin( + bondingBox ? `${bondingBox.x}px ${bondingBox.y}px` : null + ); + }, [open, target]); + return ( + + + + +
+
+ + {children} + +
+ {controls} +
+
+
+
+
+
+ ); +}; 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..46b89d8ba5ac2 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx @@ -0,0 +1,136 @@ +import { + AffineReference, + type SurfaceRefBlockComponent, +} from '@blocksuite/blocks'; +import { type DocMode, useLiveData, useService } from '@toeverything/infra'; +import { useMemo } from 'react'; + +import type { ActivePeekView } from '../entities/peek-view'; +import { PeekViewService } from '../services/peek-view'; +import { DocPeekViewControls } from './doc-peek-controls'; +import { DocPeekView, SurfaceRefPeekView } from './doc-peek-view'; +import { PeekViewModalContainer } from './modal-container'; +import { isEmbedDocModel, isSurfaceRefModel, resolveLinkToDoc } from './utils'; + +type DocPeekViewInfo = { + docId: string; + blockId?: string; + mode?: DocMode; + xywh?: `[${number},${number},${number},${number}]`; +}; + +function resolveDocIdFromPeekTarget( + peekTarget?: ActivePeekView['target'] +): DocPeekViewInfo | null { + if (!peekTarget) return null; + if (peekTarget instanceof AffineReference) { + if (peekTarget.refMeta) { + return { + docId: peekTarget.refMeta.id, + }; + } + } else if ('model' in peekTarget) { + const blockModel = peekTarget.model; + if (isEmbedDocModel(blockModel)) { + return { + docId: blockModel.pageId, + }; + } else if (isSurfaceRefModel(blockModel)) { + const refModel = (peekTarget as SurfaceRefBlockComponent).referenceModel; + // refModel can be null if the reference is invalid + if (refModel) { + const docId = + 'doc' in refModel ? refModel.doc.id : refModel.surface.doc.id; + return { + docId, + mode: 'edgeless', + xywh: refModel.xywh, + }; + } + } + } else if (peekTarget instanceof HTMLAnchorElement) { + const maybeDoc = resolveLinkToDoc(peekTarget.href); + if (maybeDoc) { + return { + docId: maybeDoc.docId, + blockId: maybeDoc.blockId, + }; + } + } else if ('docId' in peekTarget) { + return { + docId: peekTarget.docId, + blockId: peekTarget.blockId, + }; + } + return null; +} + +function renderPeekView(peekTarget?: ActivePeekView['target']) { + const docPeekViewInfo = resolveDocIdFromPeekTarget(peekTarget); + + if (docPeekViewInfo) { + if (docPeekViewInfo.mode === 'edgeless' && docPeekViewInfo.xywh) { + return ( + + ); + } + + return ( + + ); + } + + return null; +} + +const renderControls = (peekTarget?: ActivePeekView['target']) => { + const docPeekViewInfo = resolveDocIdFromPeekTarget(peekTarget); + + if (docPeekViewInfo) { + return ( + + ); + } + + return null; +}; + +export const PeekViewManagerModal = () => { + const peekView = useService(PeekViewService).peekView; + const peekTarget = useLiveData(peekView.active$)?.target; + const show = useLiveData(peekView.show$); + + const preview = useMemo(() => { + return renderPeekView(peekTarget); + }, [peekTarget]); + + const controls = useMemo(() => { + return renderControls(peekTarget); + }, [peekTarget]); + + return ( + { + if (!open) { + peekView.close(); + } + }} + > + {preview} + + ); +}; 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..8509880286eda --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/utils.ts @@ -0,0 +1,88 @@ +import type { + EmbedLinkedDocModel, + EmbedSyncedDocModel, + ImageBlockModel, + SurfaceRefBlockModel, +} from '@blocksuite/blocks'; +import type { BlockModel } from '@blocksuite/store'; +import type { Doc } from '@toeverything/infra'; +import { + DocsService, + useLiveData, + useService, + WorkspaceService, +} from '@toeverything/infra'; +import { useEffect, useLayoutEffect, useState } from 'react'; + +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#xxxx + // to { workspaceId: '48__RTCSwASvWZxyAk3Jw', docId: '-Uge-K6SYcAbcNYfQ5U-j', blockId: 'xxxx' } + + const [_, workspaceId, docId, blockId] = + 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, blockId }; +}; + +export 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, workspace: currentWorkspace, loading: !docListReady }; +}; 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..3935e6d2dedbc 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, useInsidePeekView } from '../../peek-view'; import { WorkbenchService } from '../services/workbench'; export const WorkbenchLink = ({ @@ -14,33 +15,46 @@ 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); + // if the link is opened in peek view, we should open it in the same peek view + const inPeekView = useInsidePeekView(); 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 || inPeekView) && ref.current) { + peekView.open(ref.current); } else { workbench.open(to); } }, - [appSettings.enableMultiView, link, onClick, to, workbench] + [ + to, + appSettings.enableMultiView, + inPeekView, + link, + onClick, + peekView, + workbench, + ] ); // eslint suspicious runtime error // eslint-disable-next-line react/no-danger-with-children - return ; + return ; }; diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx index 21112af429be3..9220712c1a31a 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx @@ -84,7 +84,7 @@ const DetailPageImpl = memo(function DetailPageImpl() { const doc = useService(DocService).doc; const docRecordList = useService(DocsService).list; - const { openPage, jumpToTag } = useNavigateHelper(); + const { openPage, jumpToPageBlock, jumpToTag } = useNavigateHelper(); const [editor, setEditor] = useState(null); const workspace = useService(WorkspaceService).workspace; const globalContext = useService(GlobalContextService).globalContext; @@ -199,10 +199,11 @@ const DetailPageImpl = memo(function DetailPageImpl() { }; doc.setMode(mode); - // fixme: it seems pageLinkClicked is not triggered sometimes? disposable.add( - pageService.slots.docLinkClicked.on(({ docId }) => { - return openPage(docCollection.id, docId); + pageService.slots.docLinkClicked.on(({ docId, blockId }) => { + return blockId + ? jumpToPageBlock(docCollection.id, docId, blockId) + : openPage(docCollection.id, docId); }) ); disposable.add( @@ -226,8 +227,9 @@ const DetailPageImpl = memo(function DetailPageImpl() { doc, mode, docRecordList, - openPage, + jumpToPageBlock, docCollection.id, + openPage, jumpToTag, workspace.id, ] 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 && } ); diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 22570febc3e63..8d06757674ca1 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1323,5 +1323,9 @@ "com.affine.ai-onboarding.edgeless.get-started": "Get Started", "com.affine.ai-onboarding.edgeless.purchase": "Upgrade to Unlimited Usage", "will delete member": "will delete member", - "com.affine.keyboardShortcuts.copy-private-link": "Copy Private Link" + "com.affine.keyboardShortcuts.copy-private-link": "Copy Private Link", + "com.affine.peek-view-controls.close": "Close", + "com.affine.peek-view-controls.open-doc": "Open this doc", + "com.affine.peek-view-controls.open-doc-in-new-tab": "Open in new tab", + "com.affine.peek-view-controls.open-doc-in-split-view": "Open in split view" } diff --git a/scripts/setup/lottie-web.ts b/scripts/setup/vi-mock.ts similarity index 66% rename from scripts/setup/lottie-web.ts rename to scripts/setup/vi-mock.ts index 17e1f7df79e9a..bc7d985cfc5d1 100644 --- a/scripts/setup/lottie-web.ts +++ b/scripts/setup/vi-mock.ts @@ -11,6 +11,20 @@ vi.mock('@blocksuite/presets', () => ({ DocTitle: vi.fn(), EdgelessEditor: vi.fn(), PageEditor: vi.fn(), + AIProvider: { + slots: new Proxy( + {}, + { + get: () => ({ + on: vi.fn(), + }), + } + ), + provide: vi.fn(), + }, + AIEdgelessRootBlockSpec: {}, + AIParagraphBlockSpec: {}, + AIPageRootBlockSpec: {}, })); if (typeof window !== 'undefined' && HTMLCanvasElement) { diff --git a/vitest.config.ts b/vitest.config.ts index 7aca66734a80b..b22cd3ea0d7a3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ test: { setupFiles: [ resolve(rootDir, './scripts/setup/lit.ts'), - resolve(rootDir, './scripts/setup/lottie-web.ts'), + resolve(rootDir, './scripts/setup/vi-mock.ts'), resolve(rootDir, './scripts/setup/global.ts'), ], include: [