mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-11-22 09:13:18 +03:00
feat: center peek view (#7073)
<div class='graphite__hidden'> <div>🎥 Video uploaded on Graphite:</div> <a href="https://app.graphite.dev/media/video/T2klNLEk0wxLh4NRDzhk/7380c06e-880e-424a-9204-8cfb5e924978.mp4"> <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/T2klNLEk0wxLh4NRDzhk/7380c06e-880e-424a-9204-8cfb5e924978.mp4"> </a> </div> <video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/7380c06e-880e-424a-9204-8cfb5e924978.mp4">Kapture 2024-05-30 at 19.42.46.mp4</video>
This commit is contained in:
parent
b65c01c5e1
commit
ea0059fa1b
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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',
|
||||
|
@ -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 => {
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
>
|
||||
|
@ -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
|
||||
|
@ -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 (
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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}
|
||||
|
@ -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={
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
};
|
||||
}
|
11
packages/frontend/core/src/modules/peek-view/index.ts
Normal file
11
packages/frontend/core/src/modules/peek-view/index.ts
Normal 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';
|
@ -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);
|
||||
}
|
@ -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',
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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%',
|
||||
});
|
@ -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'} />;
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
export { useInsidePeekView } from './modal-container';
|
||||
export { PeekViewManagerModal } from './peek-view-manager';
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
39
packages/frontend/core/src/modules/peek-view/view/utils.ts
Normal file
39
packages/frontend/core/src/modules/peek-view/view/utils.ts
Normal 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 };
|
||||
};
|
@ -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} />;
|
||||
});
|
||||
|
@ -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,
|
||||
]
|
||||
|
@ -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 />}
|
||||
</>
|
||||
);
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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) {
|
@ -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: [
|
||||
|
Loading…
Reference in New Issue
Block a user