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 ( : ; }, [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..ecbd68b90c2a6 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/doc-peek-controls.tsx @@ -0,0 +1,99 @@ +import { IconButton } from '@affine/component'; +import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; +import { CloseIcon, OpenInNewIcon, SplitViewIcon } from '@blocksuite/icons'; +import { + GlobalContextService, + useLiveData, + 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'; + +type ControlButtonProps = { + icon: ReactElement; + name: string; + onClick: () => void; +}; + +export const ControlButton = ({ icon, name, onClick }: ControlButtonProps) => { + const handleClick: MouseEventHandler = useCallback( + e => { + e.stopPropagation(); + e.preventDefault(); + onClick(); + }, + [onClick] + ); + + return ( + + ); +}; + +type DocPeekViewControlsProps = HTMLAttributes & { + docId: string; +}; + +export const DocPeekViewControls = ({ + docId, + className, + ...rest +}: DocPeekViewControlsProps) => { + const peekView = useService(PeekViewService).peekView; + const workspaceId = useLiveData( + useService(GlobalContextService).globalContext.workspaceId.$ + ); + const workbench = useService(WorkbenchService).workbench; + const { openPage } = useNavigateHelper(); + const controls = useMemo(() => { + const res = [ + { + icon: , + name: 'close', + onClick: peekView.close, + }, + { + icon: , + name: 'open-in-new', + onClick: () => (workspaceId ? openPage(workspaceId, docId) : undefined), + }, + ]; + if (environment.isDesktop) { + res.push({ + icon: , + name: 'split-view', + onClick: () => { + workbench.open('/' + docId, { at: 'beside' }); + peekView.close(); + }, + }); + } + return res; + }, [docId, openPage, peekView, workbench, workspaceId]); + return ( +
+ {controls.map((control, index) => ( + + ))} +
+ ); +}; 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..cd33ad9293369 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.tsx @@ -0,0 +1,204 @@ +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 { Doc, DocMode } from '@toeverything/infra'; +import { + DocsService, + FrameworkScope, + useLiveData, + useService, + WorkspaceService, +} from '@toeverything/infra'; +import { forwardRef, useEffect, useLayoutEffect, useState } from 'react'; + +import { PeekViewService } from '../services/peek-view'; +import * as styles from './doc-peek-view.css'; + +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 }; +}; + +const DocPreview = forwardRef< + AffineEditorContainer, + { docId: string; blockId?: string; mode?: DocMode } +>(function DocPreview({ docId, blockId, mode }, ref) { + const { doc, workspace, loading } = useDoc(docId); + const { openPage, jumpToTag } = useNavigateHelper(); + 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) { + openPage(workspace.id, doc.id); + peekView.close(); + // chat panel open is already handled in + } + }); + + return () => { + disposable.dispose(); + }; + }, [doc, openPage, peekView, 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..62f5fd2dd9a5a --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/modal-container.css.ts @@ -0,0 +1,151 @@ +import { cssVar } from '@toeverything/theme'; +import { createVar, keyframes, style } from '@vanilla-extract/css'; + +export const animationTimeout = createVar(); + +const contentShow = keyframes({ + from: { + opacity: 0, + transform: 'translateY(-2%) scale(0.80)', + }, + to: { + opacity: 1, + transform: 'translateY(0) scale(1)', + }, +}); +const contentHide = keyframes({ + to: { + opacity: 0, + transform: 'translateY(-2%) scale(0.96)', + }, + from: { + opacity: 1, + transform: 'translateY(0) 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(-100%)', + opacity: 0, + }, + to: { + transform: 'translateX(0)', + opacity: 1, + }, +}); + +const slideLeft = keyframes({ + from: { + transform: 'translateX(0)', + opacity: 1, + }, + to: { + transform: 'translateX(-100%)', + opacity: 0, + }, +}); + +export const modalOverlay = style({ + position: 'fixed', + inset: 0, + zIndex: cssVar('zIndexModal'), + transition: `background ${animationTimeout}`, + backgroundColor: cssVar('black30'), + willChange: 'opacity', + selectors: { + '&[data-state=entered], &[data-state=entering]': { + animation: `${fadeIn} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`, + animationFillMode: 'forwards', + }, + '&[data-state=exited], &[data-state=exiting]': { + animation: `${fadeOut} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`, + animationFillMode: 'forwards', + }, + }, +}); + +export const modalContentWrapper = style({ + position: 'fixed', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: cssVar('zIndexModal'), +}); + +export const modalContent = style({ + width: 'calc(100% - 240px)', + height: '90%', + 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', + selectors: { + '&[data-state=entered], &[data-state=entering]': { + animation: `${contentShow} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`, + animationFillMode: 'forwards', + }, + '&[data-state=exited], &[data-state=exiting]': { + animation: `${contentHide} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`, + animationFillMode: 'forwards', + }, + }, +}); + +export const modalControlsWrapper = style({ + position: 'absolute', + width: '10%', + top: '5%', + right: '5%', + opacity: 0, + selectors: { + '&[data-state=entered], &[data-state=entering]': { + animation: `${slideRight} ${animationTimeout} 0.1s cubic-bezier(0.42, 0, 0.58, 1)`, + animationFillMode: 'forwards', + }, + '&[data-state=exited], &[data-state=exiting]': { + animation: `${slideLeft} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`, + animationFillMode: 'forwards', + }, + }, +}); + +export const modalControls = style({ + position: 'absolute', + top: '5%', + right: 'calc(120px - 48px)', + opacity: 0, + selectors: { + '&[data-state=entered], &[data-state=entering]': { + animation: `${slideRight} ${animationTimeout} 0.1s cubic-bezier(0.42, 0, 0.58, 1)`, + animationFillMode: 'forwards', + }, + '&[data-state=exited], &[data-state=exiting]': { + animation: `${slideLeft} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`, + animationFillMode: 'forwards', + }, + }, +}); 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..b10aea6a7778e --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/modal-container.tsx @@ -0,0 +1,99 @@ +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, +} 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; +}; + +export const PeekViewModalContainer = ({ + onOpenChange, + open, + controls, + children, + onAnimateEnd, +}: PropsWithChildren<{ + open: boolean; + onOpenChange: (open: boolean) => void; + controls: React.ReactNode; + onAnimateEnd?: () => void; +}>) => { + const [{ status }, toggle] = useTransition({ + timeout: animationTimeout, + onStateChange(event) { + if (event.current.status === 'exited' && onAnimateEnd) { + onAnimateEnd(); + } + }, + }); + useEffect(() => { + toggle(open); + }, [open]); + return ( + + + + +
+
+ {controls} +
+ + {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..7b29b16721942 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx @@ -0,0 +1,129 @@ +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..99e852c779f99 --- /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#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 }; +}; 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 && } );