pengx17 2024-05-31 10:28:37 +00:00
parent b65c01c5e1
commit ea0059fa1b
No known key found for this signature in database
GPG Key ID: 23F23D9E8B3971ED
31 changed files with 1018 additions and 44 deletions

View File

@ -337,7 +337,7 @@ export class LiveData<T = unknown>
distinctUntilChanged(comparator?: (previous: T, current: T) => boolean) {
return LiveData.from(
this.pipe(distinctUntilChanged(comparator)),
null as any
null as T
);
}

View File

@ -1,10 +1,12 @@
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import { useJournalHelper } from '@affine/core/hooks/use-journal';
import { PeekViewService } from '@affine/core/modules/peek-view';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { LinkedPageIcon, TodayIcon } from '@blocksuite/icons';
import type { DocCollection } from '@blocksuite/store';
import type { PropsWithChildren } from 'react';
import { useService } from '@toeverything/infra';
import { type PropsWithChildren, useCallback, useRef } from 'react';
import * as styles from './styles.css';
@ -64,8 +66,30 @@ export function AffinePageReference({
t,
});
const ref = useRef<HTMLAnchorElement>(null);
const peekView = useService(PeekViewService).peekView;
const onClick = useCallback(
(e: React.MouseEvent) => {
if (e.shiftKey && ref.current) {
e.preventDefault();
e.stopPropagation();
peekView.open(ref.current);
return true; // means this click is handled
}
return false;
},
[peekView]
);
return (
<WorkbenchLink to={`/${pageId}`} className={styles.pageReferenceLink}>
<WorkbenchLink
ref={ref}
to={`/${pageId}`}
onClick={onClick}
className={styles.pageReferenceLink}
>
{Wrapper ? <Wrapper>{el}</Wrapper> : el}
</WorkbenchLink>
);

View File

@ -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',

View File

@ -19,7 +19,7 @@ export interface MenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
export interface MenuLinkItemProps extends MenuItemProps {
to: To;
linkComponent?: React.ComponentType<{ to: To; className: string }>;
linkComponent?: React.ComponentType<{ to: To; className?: string }>;
}
const stopPropagation: React.MouseEventHandler = e => {

View File

@ -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,

View File

@ -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<string, RootService>;
if (!rootSpec) {
return specs;
}
patchSpecService(rootSpec, pageService => {
pageService.peekViewService = {
peek: (target: ActivePeekView['target']) => {
logger.debug('center peek', target);
service.peekView.open(target);
},
};
});
return specs;
}

View File

@ -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={
<MenuIcon>
<OpenInNewIcon />
<DualLinkIcon />
</MenuIcon>
}
>

View File

@ -231,7 +231,7 @@ export const CollectionSidebarNavItem = ({
<span>{collection.name}</span>
</SidebarMenuLinkItem>
<Collapsible.Content className={styles.collapsibleContent}>
<div style={{ marginLeft: 20, marginTop: -4 }}>
<div className={styles.docsListContainer}>
{pagesToRender.map(page => {
return (
<Doc

View File

@ -1,19 +1,21 @@
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';
import { useDraggable } from '@dnd-kit/core';
import * as Collapsible from '@radix-ui/react-collapsible';
import { DocsService, useLiveData, useService } from '@toeverything/infra';
import React, { useCallback, useMemo } from 'react';
import React, { useMemo } from 'react';
import {
type DNDIdentifier,
getDNDId,
} from '../../../../hooks/affine/use-global-dnd-helper';
import { useNavigateHelper } from '../../../../hooks/use-navigate-helper';
import { MenuItem as CollectionItem } from '../../../app-sidebar';
import { MenuLinkItem } from '../../../app-sidebar';
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
import { PostfixItem } from '../components/postfix-item';
import { ReferencePage } from '../components/reference-page';
@ -50,11 +52,6 @@ export const Doc = ({
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
}, [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}
>
<CollectionItem
<MenuLinkItem
data-testid="collection-page"
data-type="collection-list-item"
icon={icon}
onClick={clickDoc}
to={`/${docId}`}
linkComponent={WorkbenchLink}
className={styles.title}
active={active}
collapsed={referencesToRender.length > 0 ? collapsed : undefined}
@ -101,7 +99,7 @@ export const Doc = ({
{...listeners}
>
{doc.title || t['Untitled']()}
</CollectionItem>
</MenuLinkItem>
<Collapsible.Content className={styles.collapsibleContent}>
{referencesToRender.map(id => {
return (

View File

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

View File

@ -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={
<PostfixItem
docCollection={docCollection}

View File

@ -3,7 +3,10 @@ import {
parseDNDId,
} from '@affine/core/hooks/affine/use-global-dnd-helper';
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 AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable';
@ -102,7 +105,8 @@ export const FavouriteDocSidebarNavItem = ({
data-dragging={isDragging}
className={draggableMenuItemStyles.draggableMenuItem}
active={linkActive}
to={`/workspace/${workspace.id}/${pageId}`}
to={`/${pageId}`}
linkComponent={WorkbenchLink}
collapsed={collapsible ? collapsed : undefined}
onCollapsedChange={setCollapsed}
postfix={

View File

@ -5,6 +5,7 @@ import { configureCloudModule } from './cloud';
import { configureCollectionModule } from './collection';
import { configureFindInPageModule } from './find-in-page';
import { configureNavigationModule } from './navigation';
import { configurePeekViewModule } from './peek-view';
import { configurePermissionsModule } from './permissions';
import { configureWorkspacePropertiesModule } from './properties';
import { configureRightSidebarModule } from './right-sidebar';
@ -28,6 +29,7 @@ export function configureCommonModules(framework: Framework) {
configureShareDocsModule(framework);
configureTelemetryModule(framework);
configureFindInPageModule(framework);
configurePeekViewModule(framework);
}
export function configureImpls(framework: Framework) {

View File

@ -0,0 +1,139 @@
import type { BlockElement } from '@blocksuite/block-std';
import {
AffineReference,
type EmbedLinkedDocModel,
type EmbedSyncedDocModel,
type SurfaceRefBlockComponent,
type SurfaceRefBlockModel,
} from '@blocksuite/blocks';
import { type DocMode, Entity, LiveData } from '@toeverything/infra';
export type PeekViewTarget =
| HTMLElement
| BlockElement
| AffineReference
| HTMLAnchorElement
| { docId: string; blockId?: string };
export type DocPeekViewInfo = {
docId: string;
blockId?: string;
mode?: DocMode;
xywh?: `[${number},${number},${number},${number}]`;
};
export type ActivePeekView = {
target: PeekViewTarget;
info: DocPeekViewInfo;
};
import type { BlockModel } from '@blocksuite/store';
const EMBED_DOC_FLAVOURS = [
'affine:embed-linked-doc',
'affine:embed-synced-doc',
];
const isEmbedDocModel = (
blockModel: BlockModel
): blockModel is EmbedSyncedDocModel | EmbedLinkedDocModel => {
return EMBED_DOC_FLAVOURS.includes(blockModel.flavour);
};
const isSurfaceRefModel = (
blockModel: BlockModel
): blockModel is SurfaceRefBlockModel => {
return blockModel.flavour === 'affine:surface-ref';
};
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 };
};
function resolvePeekInfoFromPeekTarget(
peekTarget?: PeekViewTarget
): 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;
}
export class PeekViewEntity extends Entity {
constructor() {
super();
}
private readonly _active$ = new LiveData<ActivePeekView | null>(null);
private readonly _show$ = new LiveData<boolean>(false);
active$ = this._active$.distinctUntilChanged();
show$ = this._show$
.map(show => show && this._active$.value !== null)
.distinctUntilChanged();
open = (target: ActivePeekView['target']) => {
const resolvedInfo = resolvePeekInfoFromPeekTarget(target);
if (!resolvedInfo) {
return;
}
this._active$.next({ target, info: resolvedInfo });
this._show$.next(true);
};
close = () => {
this._show$.next(false);
};
}

View File

@ -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 { PeekViewManagerModal, useInsidePeekView } from './view';

View File

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

View File

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

View File

@ -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 (
<Tooltip content={name}>
<IconButton
data-testid="peek-view-control"
data-action-name={nameKey}
size="large"
type="default"
onClick={handleClick}
icon={icon}
className={styles.button}
withoutHoverStyle
/>
</Tooltip>
);
};
type DocPeekViewControlsProps = HTMLAttributes<HTMLDivElement> & {
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: <CloseIcon />,
nameKey: 'close',
name: t['com.affine.peek-view-controls.close'](),
onClick: peekView.close,
},
{
icon: <ExpandFullIcon />,
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: <SplitViewIcon />,
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: <DualLinkIcon />,
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 (
<div {...rest} className={clsx(styles.root, className)}>
{controls.map(option => (
<ControlButton key={option.nameKey} {...option} />
))}
</div>
);
};

View File

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

View File

@ -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<AffineEditorContainer | null>(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<DocMode | undefined>(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 <DetailPageImpl />
}
});
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 ? (
<PageDetailSkeleton key="current-page-is-null" />
) : (
<PageNotFound noPermission />
);
}
return (
<AffineErrorBoundary>
<Scrollable.Root>
<Scrollable.Viewport className="affine-page-viewport">
<FrameworkScope scope={doc.scope}>
<BlockSuiteEditor
ref={onRef}
className={styles.editor}
mode={resolvedMode}
defaultSelectedBlockId={blockId}
page={doc.blockSuiteDoc}
/>
</FrameworkScope>
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
</AffineErrorBoundary>
);
});
export const DocPeekView = ({
docId,
blockId,
mode,
}: {
docId: string;
blockId?: string;
mode?: DocMode;
}) => {
return <DocPreview mode={mode} docId={docId} blockId={blockId} />;
};
export const SurfaceRefPeekView = ({
docId,
xywh,
}: {
docId: string;
xywh: `[${number},${number},${number},${number}]`;
}) => {
const [editorRef, setEditorRef] = useState<AffineEditorContainer | null>(
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<EdgelessRootService>(
'affine:page'
);
rootService.viewport.setViewportByBound(
Bound.deserialize(viewport.xywh),
viewport.padding
);
}
})
.catch(e => {
console.error(e);
});
}
return () => {
mounted = false;
};
}, [editorRef, xywh]);
return <DocPreview ref={setEditorRef} docId={docId} mode={'edgeless'} />;
};

View File

@ -0,0 +1,2 @@
export { useInsidePeekView } from './modal-container';
export { PeekViewManagerModal } from './peek-view-manager';

View File

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

View File

@ -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<Record<string, never> | 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<string | null>(null);
useEffect(() => {
toggle(open);
const bondingBox = target ? getElementScreenPositionCenter(target) : null;
setTransformOrigin(
bondingBox ? `${bondingBox.x}px ${bondingBox.y}px` : null
);
}, [open, target]);
return (
<PeekViewContext.Provider value={emptyContext}>
<Dialog.Root modal open={status !== 'exited'} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay
className={styles.modalOverlay}
data-state={status}
style={assignInlineVars({
[styles.transformOrigin]: transformOrigin,
[styles.animationTimeout]: `${animationTimeout}ms`,
})}
/>
<div
data-peek-view-wrapper
className={styles.modalContentWrapper}
style={assignInlineVars({
[styles.transformOrigin]: transformOrigin,
[styles.animationTimeout]: `${animationTimeout}ms`,
})}
>
<div className={styles.modalContentContainer}>
<Dialog.Content
{...contentOptions}
className={styles.modalContent}
data-state={status}
>
{children}
</Dialog.Content>
<div data-state={status} className={styles.modalControls}>
{controls}
</div>
</div>
</div>
</Dialog.Portal>
</Dialog.Root>
</PeekViewContext.Provider>
);
};

View File

@ -0,0 +1,61 @@
import { 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';
function renderPeekView({ info }: ActivePeekView) {
if (info.mode === 'edgeless' && info.xywh) {
return <SurfaceRefPeekView docId={info.docId} xywh={info.xywh} />;
}
return (
<DocPeekView mode={info.mode} docId={info.docId} blockId={info.blockId} />
);
}
const renderControls = ({ info }: ActivePeekView) => {
return (
<DocPeekViewControls
mode={info.mode}
docId={info.docId}
blockId={info.docId}
/>
);
};
export const PeekViewManagerModal = () => {
const peekViewEntity = useService(PeekViewService).peekView;
const activePeekView = useLiveData(peekViewEntity.active$);
const show = useLiveData(peekViewEntity.show$);
const preview = useMemo(() => {
return activePeekView ? renderPeekView(activePeekView) : null;
}, [activePeekView]);
const controls = useMemo(() => {
return activePeekView ? renderControls(activePeekView) : null;
}, [activePeekView]);
return (
<PeekViewModalContainer
open={show}
target={
activePeekView?.target instanceof HTMLElement
? activePeekView.target
: undefined
}
controls={controls}
onOpenChange={open => {
if (!open) {
peekViewEntity.close();
}
}}
>
{preview}
</PeekViewModalContainer>
);
};

View File

@ -0,0 +1,39 @@
import type { Doc } from '@toeverything/infra';
import {
DocsService,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import { useEffect, useLayoutEffect, useState } from 'react';
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<Doc | null>(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 };
};

View File

@ -2,17 +2,14 @@ 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 { forwardRef, useCallback } from 'react';
import { WorkbenchService } from '../services/workbench';
export const WorkbenchLink = ({
to,
onClick,
...other
}: React.PropsWithChildren<
{ to: To } & React.HTMLProps<HTMLAnchorElement>
>) => {
export const WorkbenchLink = forwardRef<
HTMLAnchorElement,
React.PropsWithChildren<{ to: To } & React.HTMLProps<HTMLAnchorElement>>
>(function WorkbenchLink({ to, onClick, ...other }, ref) {
const workbench = useService(WorkbenchService).workbench;
const { appSettings } = useAppSettingHelper();
const basename = useLiveData(workbench.basename$);
@ -21,11 +18,11 @@ export const WorkbenchLink = ({
(typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`);
const handleClick = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
event.stopPropagation();
if (onClick?.(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (event.ctrlKey || event.metaKey) {
if (appSettings.enableMultiView && environment.isDesktop) {
@ -42,5 +39,5 @@ export const WorkbenchLink = ({
// eslint suspicious runtime error
// eslint-disable-next-line react/no-danger-with-children
return <a {...other} href={link} onClick={handleClick} />;
};
return <a {...other} ref={ref} href={link} onClick={handleClick} />;
});

View File

@ -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<AffineEditorContainer | null>(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,
]

View File

@ -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';
import { WorkspaceSubPath } from '../shared';
const SettingModal = lazy(() =>
@ -218,6 +219,7 @@ export function CurrentWorkspaceModals() {
<CloudQuotaModal />
)}
<AiLoginRequiredModal />
<PeekViewManagerModal />
{environment.isDesktop && <FindInPageModal />}
</>
);

View File

@ -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"
}

View File

@ -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) {

View File

@ -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: [