feat(core): remove old all docs code (#12558)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Removed Features** - The "All Pages (Old)" workspace view and its associated header have been removed. - The previous page list UI, including virtualized lists, group headers, and multi-selection, is no longer available. - Search and tag aggregation features within the old page list have been removed. - **Style** - Styles related to the old page list and its components have been deleted. - **Navigation** - The "All Pages (Old)" route has been removed from workspace navigation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,5 +1,2 @@
|
|||||||
export * from './page-list-header';
|
|
||||||
export * from './page-list-item';
|
|
||||||
export * from './page-list-new-page-button';
|
export * from './page-list-new-page-button';
|
||||||
export * from './page-tags';
|
export * from './page-tags';
|
||||||
export * from './virtualized-page-list';
|
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
|
||||||
import { style } from '@vanilla-extract/css';
|
|
||||||
export const docListHeader = style({
|
|
||||||
height: 100,
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '48px 16px 20px 24px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
background: cssVar('backgroundPrimaryColor'),
|
|
||||||
});
|
|
||||||
export const docListHeaderTitle = style({
|
|
||||||
fontSize: cssVar('fontH5'),
|
|
||||||
fontWeight: 500,
|
|
||||||
color: cssVar('textSecondaryColor'),
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
height: '28px',
|
|
||||||
userSelect: 'none',
|
|
||||||
});
|
|
||||||
export const titleIcon = style({
|
|
||||||
color: cssVar('iconColor'),
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
});
|
|
||||||
export const titleCollectionName = style({
|
|
||||||
color: cssVar('textPrimaryColor'),
|
|
||||||
});
|
|
||||||
export const tagSticky = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '1px 8px',
|
|
||||||
color: cssVar('textPrimaryColor'),
|
|
||||||
fontSize: cssVar('fontXs'),
|
|
||||||
borderRadius: '10px',
|
|
||||||
columnGap: '4px',
|
|
||||||
border: `1px solid ${cssVar('borderColor')}`,
|
|
||||||
background: cssVar('backgroundPrimaryColor'),
|
|
||||||
maxWidth: '30vw',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
height: '22px',
|
|
||||||
lineHeight: '1.67em',
|
|
||||||
cursor: 'pointer',
|
|
||||||
});
|
|
||||||
export const tagIndicator = style({
|
|
||||||
width: '8px',
|
|
||||||
height: '8px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
flexShrink: 0,
|
|
||||||
});
|
|
||||||
export const tagLabel = style({
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
});
|
|
||||||
export const arrowDownSmallIcon = style({
|
|
||||||
color: cssVar('iconColor'),
|
|
||||||
fontSize: '12px',
|
|
||||||
});
|
|
||||||
export const searchIcon = style({
|
|
||||||
color: cssVar('iconColor'),
|
|
||||||
fontSize: '20px',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tagsEditorRoot = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tagsMenu = style({
|
|
||||||
padding: 0,
|
|
||||||
width: '296px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tagsEditorSelectedTags = style({
|
|
||||||
display: 'flex',
|
|
||||||
gap: '8px',
|
|
||||||
flexWrap: 'nowrap',
|
|
||||||
padding: '6px 12px',
|
|
||||||
minHeight: 42,
|
|
||||||
alignItems: 'center',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const searchInput = style({
|
|
||||||
flexGrow: 1,
|
|
||||||
padding: '10px 0',
|
|
||||||
margin: '-10px 0',
|
|
||||||
border: 'none',
|
|
||||||
outline: 'none',
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
color: 'inherit',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
'::placeholder': {
|
|
||||||
color: cssVar('placeholderColor'),
|
|
||||||
},
|
|
||||||
overflow: 'hidden',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tagsEditorTagsSelector = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
maxHeight: '400px',
|
|
||||||
overflow: 'auto',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tagSelectorTagsScrollContainer = style({
|
|
||||||
overflowX: 'hidden',
|
|
||||||
position: 'relative',
|
|
||||||
maxHeight: '200px',
|
|
||||||
gap: '8px',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tagSelectorItem = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '4px 16px',
|
|
||||||
height: '32px',
|
|
||||||
gap: 8,
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderRadius: '4px',
|
|
||||||
color: cssVar('textPrimaryColor'),
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: cssVar('hoverColor'),
|
|
||||||
},
|
|
||||||
':visited': {
|
|
||||||
color: cssVar('textPrimaryColor'),
|
|
||||||
},
|
|
||||||
selectors: {
|
|
||||||
'&.disable:hover': {
|
|
||||||
backgroundColor: 'unset',
|
|
||||||
cursor: 'auto',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tagIcon = style({
|
|
||||||
width: '8px',
|
|
||||||
height: '8px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
flexShrink: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tagSelectorItemText = style({
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const rightButtonGroup = style({
|
|
||||||
display: 'flex',
|
|
||||||
gap: '12px',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const buttonText = style({
|
|
||||||
fontSize: cssVar('fontXs'),
|
|
||||||
fontWeight: 500,
|
|
||||||
color: cssVar('textPrimaryColor'),
|
|
||||||
});
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
import {
|
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
Menu,
|
|
||||||
RowInput,
|
|
||||||
Scrollable,
|
|
||||||
useConfirmModal,
|
|
||||||
} from '@affine/component';
|
|
||||||
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
|
|
||||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
|
||||||
import type { DocRecord } from '@affine/core/modules/doc';
|
|
||||||
import type { Tag } from '@affine/core/modules/tag';
|
|
||||||
import { TagService } from '@affine/core/modules/tag';
|
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
|
||||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
|
||||||
import { inferOpenMode } from '@affine/core/utils';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import { track } from '@affine/track';
|
|
||||||
import type { DocMode } from '@blocksuite/affine/model';
|
|
||||||
import {
|
|
||||||
ArrowDownSmallIcon,
|
|
||||||
SearchIcon,
|
|
||||||
ViewLayersIcon,
|
|
||||||
} from '@blocksuite/icons/rc';
|
|
||||||
import { useLiveData, useService, useServices } from '@toeverything/infra';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
|
|
||||||
import {
|
|
||||||
type Collection,
|
|
||||||
CollectionService,
|
|
||||||
} from '../../../modules/collection';
|
|
||||||
import { SaveAsCollectionButton } from '../view';
|
|
||||||
import * as styles from './page-list-header.css';
|
|
||||||
import { PageListNewPageButton } from './page-list-new-page-button';
|
|
||||||
|
|
||||||
export const PageListHeader = () => {
|
|
||||||
const t = useI18n();
|
|
||||||
const { workspaceService, workspaceDialogService, workbenchService } =
|
|
||||||
useServices({
|
|
||||||
WorkspaceService,
|
|
||||||
WorkspaceDialogService,
|
|
||||||
WorkbenchService,
|
|
||||||
});
|
|
||||||
|
|
||||||
const workbench = workbenchService.workbench;
|
|
||||||
const workspace = workspaceService.workspace;
|
|
||||||
const { createEdgeless, createPage } = usePageHelper(workspace.docCollection);
|
|
||||||
|
|
||||||
const title = useMemo(() => {
|
|
||||||
return t['com.affine.all-pages.header']();
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
const handleOpenDocs = useCallback(
|
|
||||||
(result: {
|
|
||||||
docIds: string[];
|
|
||||||
entryId?: string;
|
|
||||||
isWorkspaceFile?: boolean;
|
|
||||||
}) => {
|
|
||||||
const { docIds, entryId, isWorkspaceFile } = result;
|
|
||||||
// If the imported file is a workspace file, open the entry page.
|
|
||||||
if (isWorkspaceFile && entryId) {
|
|
||||||
workbench.openDoc(entryId);
|
|
||||||
} else if (!docIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Open all the docs when there are multiple docs imported.
|
|
||||||
if (docIds.length > 1) {
|
|
||||||
workbench.openAll();
|
|
||||||
} else {
|
|
||||||
// Otherwise, open the only doc.
|
|
||||||
workbench.openDoc(docIds[0]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[workbench]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onImportFile = useCallback(() => {
|
|
||||||
track.$.header.importModal.open();
|
|
||||||
workspaceDialogService.open('import', undefined, payload => {
|
|
||||||
if (!payload) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleOpenDocs(payload);
|
|
||||||
});
|
|
||||||
}, [workspaceDialogService, handleOpenDocs]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.docListHeader}>
|
|
||||||
<div className={styles.docListHeaderTitle}>{title}</div>
|
|
||||||
<PageListNewPageButton
|
|
||||||
size="small"
|
|
||||||
data-testid="new-page-button-trigger"
|
|
||||||
onCreateEdgeless={e => createEdgeless({ at: inferOpenMode(e) })}
|
|
||||||
onCreatePage={e =>
|
|
||||||
createPage('page' as DocMode, { at: inferOpenMode(e) })
|
|
||||||
}
|
|
||||||
onCreateDoc={e => createPage(undefined, { at: inferOpenMode(e) })}
|
|
||||||
onImportFile={onImportFile}
|
|
||||||
>
|
|
||||||
<div className={styles.buttonText}>{t['New Page']()}</div>
|
|
||||||
</PageListNewPageButton>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
*/
|
|
||||||
export const CollectionPageListHeader = ({
|
|
||||||
collection,
|
|
||||||
workspaceId,
|
|
||||||
}: {
|
|
||||||
collection: Collection;
|
|
||||||
workspaceId: string;
|
|
||||||
}) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const { jumpToCollections } = useNavigateHelper();
|
|
||||||
const { collectionService, workspaceService, workspaceDialogService } =
|
|
||||||
useServices({
|
|
||||||
CollectionService,
|
|
||||||
WorkspaceService,
|
|
||||||
WorkspaceDialogService,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleJumpToCollections = useCallback(() => {
|
|
||||||
jumpToCollections(workspaceId);
|
|
||||||
}, [jumpToCollections, workspaceId]);
|
|
||||||
|
|
||||||
const handleEdit = useCallback(() => {
|
|
||||||
workspaceDialogService.open('collection-editor', {
|
|
||||||
collectionId: collection.id,
|
|
||||||
});
|
|
||||||
}, [collection, workspaceDialogService]);
|
|
||||||
|
|
||||||
const workspace = workspaceService.workspace;
|
|
||||||
const { createEdgeless, createPage } = usePageHelper(workspace.docCollection);
|
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const name = useLiveData(collection.name$);
|
|
||||||
|
|
||||||
const createAndAddDocument = useCallback(
|
|
||||||
(createDocumentFn: () => DocRecord) => {
|
|
||||||
const newDoc = createDocumentFn();
|
|
||||||
collectionService.addDocToCollection(collection.id, newDoc.id);
|
|
||||||
},
|
|
||||||
[collection.id, collectionService]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onConfirmAddDocument = useCallback(
|
|
||||||
(createDocumentFn: () => DocRecord) => {
|
|
||||||
openConfirmModal({
|
|
||||||
title: t['com.affine.collection.add-doc.confirm.title'](),
|
|
||||||
description: t['com.affine.collection.add-doc.confirm.description'](),
|
|
||||||
cancelText: t['Cancel'](),
|
|
||||||
confirmText: t['Confirm'](),
|
|
||||||
confirmButtonOptions: {
|
|
||||||
variant: 'primary',
|
|
||||||
},
|
|
||||||
onConfirm: () => createAndAddDocument(createDocumentFn),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[openConfirmModal, t, createAndAddDocument]
|
|
||||||
);
|
|
||||||
|
|
||||||
const createPageModeDoc = useCallback(
|
|
||||||
() => createPage('page' as DocMode),
|
|
||||||
[createPage]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onCreateEdgeless = useCallback(
|
|
||||||
() => onConfirmAddDocument(createEdgeless),
|
|
||||||
[createEdgeless, onConfirmAddDocument]
|
|
||||||
);
|
|
||||||
const onCreatePage = useCallback(() => {
|
|
||||||
onConfirmAddDocument(createPageModeDoc);
|
|
||||||
}, [createPageModeDoc, onConfirmAddDocument]);
|
|
||||||
const onCreateDoc = useCallback(() => {
|
|
||||||
onConfirmAddDocument(createPage);
|
|
||||||
}, [createPage, onConfirmAddDocument]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.docListHeader}>
|
|
||||||
<div className={styles.docListHeaderTitle}>
|
|
||||||
<div style={{ cursor: 'pointer' }} onClick={handleJumpToCollections}>
|
|
||||||
{t['com.affine.collections.header']()} /
|
|
||||||
</div>
|
|
||||||
<div className={styles.titleIcon}>
|
|
||||||
<ViewLayersIcon />
|
|
||||||
</div>
|
|
||||||
<div className={styles.titleCollectionName}>{name}</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.rightButtonGroup}>
|
|
||||||
<Button onClick={handleEdit}>{t['Edit']()}</Button>
|
|
||||||
<PageListNewPageButton
|
|
||||||
size="small"
|
|
||||||
data-testid="new-page-button-trigger"
|
|
||||||
onCreateDoc={onCreateDoc}
|
|
||||||
onCreateEdgeless={onCreateEdgeless}
|
|
||||||
onCreatePage={onCreatePage}
|
|
||||||
>
|
|
||||||
<div className={styles.buttonText}>{t['New Page']()}</div>
|
|
||||||
</PageListNewPageButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
*/
|
|
||||||
export const TagPageListHeader = ({
|
|
||||||
tag,
|
|
||||||
workspaceId,
|
|
||||||
}: {
|
|
||||||
tag: Tag;
|
|
||||||
workspaceId: string;
|
|
||||||
}) => {
|
|
||||||
const tagColor = useLiveData(tag.color$);
|
|
||||||
const tagTitle = useLiveData(tag.value$);
|
|
||||||
|
|
||||||
const t = useI18n();
|
|
||||||
const { jumpToTags, jumpToCollection } = useNavigateHelper();
|
|
||||||
const collectionService = useService(CollectionService);
|
|
||||||
const [openMenu, setOpenMenu] = useState(false);
|
|
||||||
|
|
||||||
const handleJumpToTags = useCallback(() => {
|
|
||||||
jumpToTags(workspaceId);
|
|
||||||
}, [jumpToTags, workspaceId]);
|
|
||||||
|
|
||||||
const saveToCollection = useCallback(
|
|
||||||
(collectionName: string) => {
|
|
||||||
const id = collectionService.createCollection({
|
|
||||||
name: collectionName,
|
|
||||||
rules: {
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
type: 'system',
|
|
||||||
key: 'tags',
|
|
||||||
method: 'include-all',
|
|
||||||
value: tag.id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
jumpToCollection(workspaceId, id);
|
|
||||||
},
|
|
||||||
[collectionService, tag.id, jumpToCollection, workspaceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.docListHeader}>
|
|
||||||
<div className={styles.docListHeaderTitle}>
|
|
||||||
<div
|
|
||||||
style={{ cursor: 'pointer', lineHeight: '1.4em' }}
|
|
||||||
onClick={handleJumpToTags}
|
|
||||||
>
|
|
||||||
{t['Tags']()} /
|
|
||||||
</div>
|
|
||||||
<Menu
|
|
||||||
rootOptions={{
|
|
||||||
open: openMenu,
|
|
||||||
onOpenChange: setOpenMenu,
|
|
||||||
}}
|
|
||||||
contentOptions={{
|
|
||||||
side: 'bottom',
|
|
||||||
align: 'start',
|
|
||||||
sideOffset: 18,
|
|
||||||
avoidCollisions: false,
|
|
||||||
className: styles.tagsMenu,
|
|
||||||
}}
|
|
||||||
items={<SwitchTag onClick={setOpenMenu} />}
|
|
||||||
>
|
|
||||||
<div className={styles.tagSticky}>
|
|
||||||
<div
|
|
||||||
className={styles.tagIndicator}
|
|
||||||
style={{
|
|
||||||
backgroundColor: tagColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className={styles.tagLabel}>{tagTitle}</div>
|
|
||||||
<ArrowDownSmallIcon className={styles.arrowDownSmallIcon} />
|
|
||||||
</div>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
<SaveAsCollectionButton onConfirm={saveToCollection} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SwitchTagProps {
|
|
||||||
onClick: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SwitchTag = ({ onClick }: SwitchTagProps) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const tagList = useService(TagService).tagList;
|
|
||||||
const filteredTags = useLiveData(
|
|
||||||
inputValue ? tagList.filterTagsByName$(inputValue) : tagList.tags$
|
|
||||||
);
|
|
||||||
|
|
||||||
const onInputChange = useCallback((value: string) => {
|
|
||||||
setInputValue(value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
setInputValue('');
|
|
||||||
onClick(false);
|
|
||||||
}, [onClick]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.tagsEditorRoot}>
|
|
||||||
<div className={styles.tagsEditorSelectedTags}>
|
|
||||||
<SearchIcon className={styles.searchIcon} />
|
|
||||||
<RowInput
|
|
||||||
value={inputValue}
|
|
||||||
onChange={onInputChange}
|
|
||||||
autoFocus
|
|
||||||
className={styles.searchInput}
|
|
||||||
placeholder={t['com.affine.search-tags.placeholder']()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Divider />
|
|
||||||
<div className={styles.tagsEditorTagsSelector}>
|
|
||||||
<Scrollable.Root>
|
|
||||||
<Scrollable.Viewport
|
|
||||||
className={styles.tagSelectorTagsScrollContainer}
|
|
||||||
>
|
|
||||||
{filteredTags.map(tag => {
|
|
||||||
return <TagLink key={tag.id} tag={tag} onClick={handleClick} />;
|
|
||||||
})}
|
|
||||||
{filteredTags.length === 0 ? (
|
|
||||||
<div className={clsx(styles.tagSelectorItem, 'disable')}>
|
|
||||||
{t['Find 0 result']()}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Scrollable.Viewport>
|
|
||||||
<Scrollable.Scrollbar style={{ transform: 'translateX(6px)' }} />
|
|
||||||
</Scrollable.Root>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TagLink = ({ tag, onClick }: { tag: Tag; onClick: () => void }) => {
|
|
||||||
const tagColor = useLiveData(tag.color$);
|
|
||||||
const tagTitle = useLiveData(tag.value$);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={tag.id}
|
|
||||||
className={styles.tagSelectorItem}
|
|
||||||
data-tag-id={tag.id}
|
|
||||||
data-tag-value={tagTitle}
|
|
||||||
to={`/tag/${tag.id}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<div className={styles.tagIcon} style={{ background: tagColor }} />
|
|
||||||
<div className={styles.tagSelectorItemText}>{tagTitle}</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
|
||||||
import { globalStyle, style } from '@vanilla-extract/css';
|
|
||||||
export const root = style({
|
|
||||||
display: 'flex',
|
|
||||||
color: cssVar('textPrimaryColor'),
|
|
||||||
height: '54px',
|
|
||||||
// 42 + 12
|
|
||||||
flexShrink: 0,
|
|
||||||
width: '100%',
|
|
||||||
alignItems: 'stretch',
|
|
||||||
contain: 'strict',
|
|
||||||
transition: 'background-color 0.2s, opacity 0.2s',
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: cssVar('hoverColor'),
|
|
||||||
},
|
|
||||||
overflow: 'hidden',
|
|
||||||
cursor: 'default',
|
|
||||||
selectors: {
|
|
||||||
'&[data-clickable=true]': {
|
|
||||||
cursor: 'pointer',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dragPageItemOverlay = style({
|
|
||||||
height: '45px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
background: cssVar('hoverColorFilled'),
|
|
||||||
boxShadow: cssVar('menuShadow'),
|
|
||||||
maxWidth: '360px',
|
|
||||||
minWidth: '260px',
|
|
||||||
});
|
|
||||||
export const dndCell = style({
|
|
||||||
position: 'relative',
|
|
||||||
marginLeft: -8,
|
|
||||||
height: '100%',
|
|
||||||
outline: 'none',
|
|
||||||
paddingLeft: 8,
|
|
||||||
});
|
|
||||||
globalStyle(`[data-draggable=true] ${dndCell}:before`, {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
transform: 'translateY(-50%)',
|
|
||||||
left: 0,
|
|
||||||
width: 4,
|
|
||||||
height: 4,
|
|
||||||
transition: 'height 0.2s, opacity 0.2s',
|
|
||||||
backgroundColor: cssVar('placeholderColor'),
|
|
||||||
borderRadius: '2px',
|
|
||||||
opacity: 0,
|
|
||||||
});
|
|
||||||
globalStyle(`[data-draggable=true] ${dndCell}:hover:before`, {
|
|
||||||
height: 12,
|
|
||||||
opacity: 1,
|
|
||||||
});
|
|
||||||
globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}`, {
|
|
||||||
opacity: 0.5,
|
|
||||||
});
|
|
||||||
globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}:before`, {
|
|
||||||
height: 32,
|
|
||||||
width: 2,
|
|
||||||
opacity: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
globalStyle(`${root} > :first-child`, {
|
|
||||||
paddingLeft: '16px',
|
|
||||||
});
|
|
||||||
globalStyle(`${root} > :last-child`, {
|
|
||||||
paddingRight: '8px',
|
|
||||||
});
|
|
||||||
export const titleIconsWrapper = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: '10px',
|
|
||||||
});
|
|
||||||
export const selectionCell = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
fontSize: cssVar('fontH3'),
|
|
||||||
});
|
|
||||||
export const titleCell = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
padding: '0 16px',
|
|
||||||
maxWidth: 'calc(100% - 64px)',
|
|
||||||
flex: 1,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
userSelect: 'none',
|
|
||||||
});
|
|
||||||
export const titleCellMain = style({
|
|
||||||
overflow: 'hidden',
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
fontWeight: 600,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
flex: 1,
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
alignSelf: 'stretch',
|
|
||||||
});
|
|
||||||
export const titleCellPreview = style({
|
|
||||||
overflow: 'hidden',
|
|
||||||
color: cssVar('textSecondaryColor'),
|
|
||||||
fontSize: cssVar('fontXs'),
|
|
||||||
flex: 1,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
alignSelf: 'stretch',
|
|
||||||
});
|
|
||||||
export const iconCell = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
fontSize: cssVar('fontH3'),
|
|
||||||
color: cssVar('iconColor'),
|
|
||||||
flexShrink: 0,
|
|
||||||
});
|
|
||||||
export const tagsCell = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
fontSize: cssVar('fontXs'),
|
|
||||||
color: cssVar('textSecondaryColor'),
|
|
||||||
padding: '0 8px',
|
|
||||||
height: '60px',
|
|
||||||
width: '100%',
|
|
||||||
});
|
|
||||||
export const dateCell = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
fontSize: cssVar('fontXs'),
|
|
||||||
color: cssVar('textPrimaryColor'),
|
|
||||||
flexShrink: 0,
|
|
||||||
flexWrap: 'nowrap',
|
|
||||||
padding: '0 8px',
|
|
||||||
userSelect: 'none',
|
|
||||||
});
|
|
||||||
export const actionsCellWrapper = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
flexShrink: 0,
|
|
||||||
});
|
|
||||||
export const operationsCell = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
columnGap: '6px',
|
|
||||||
flexShrink: 0,
|
|
||||||
});
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
import { Checkbox, Tooltip, useDraggable } from '@affine/component';
|
|
||||||
import { TagService } from '@affine/core/modules/tag';
|
|
||||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
|
||||||
import { stopPropagation } from '@affine/core/utils';
|
|
||||||
import { i18nTime } from '@affine/i18n';
|
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
|
||||||
import type { ForwardedRef, PropsWithChildren } from 'react';
|
|
||||||
import { forwardRef, useCallback, useEffect, useMemo } from 'react';
|
|
||||||
|
|
||||||
import { WorkbenchLink } from '../../../modules/workbench/view/workbench-link';
|
|
||||||
import {
|
|
||||||
anchorIndexAtom,
|
|
||||||
rangeIdsAtom,
|
|
||||||
selectionStateAtom,
|
|
||||||
useAtom,
|
|
||||||
} from '../scoped-atoms';
|
|
||||||
import type { PageListItemProps } from '../types';
|
|
||||||
import { useAllDocDisplayProperties } from '../use-all-doc-display-properties';
|
|
||||||
import { ColWrapper } from '../utils';
|
|
||||||
import * as styles from './page-list-item.css';
|
|
||||||
import { PageTags } from './page-tags';
|
|
||||||
|
|
||||||
const ListTitleCell = ({
|
|
||||||
title,
|
|
||||||
preview,
|
|
||||||
}: Pick<PageListItemProps, 'title' | 'preview'>) => {
|
|
||||||
const [displayProperties] = useAllDocDisplayProperties();
|
|
||||||
return (
|
|
||||||
<div data-testid="page-list-item-title" className={styles.titleCell}>
|
|
||||||
<div
|
|
||||||
data-testid="page-list-item-title-text"
|
|
||||||
className={styles.titleCellMain}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
{preview && displayProperties.displayProperties.bodyNotes ? (
|
|
||||||
<div
|
|
||||||
data-testid="page-list-item-preview-text"
|
|
||||||
className={styles.titleCellPreview}
|
|
||||||
>
|
|
||||||
{preview}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ListIconCell = ({ icon }: Pick<PageListItemProps, 'icon'>) => {
|
|
||||||
return (
|
|
||||||
<div data-testid="page-list-item-icon" className={styles.iconCell}>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PageSelectionCell = ({
|
|
||||||
selectable,
|
|
||||||
onSelectedChange,
|
|
||||||
selected,
|
|
||||||
}: Pick<PageListItemProps, 'selectable' | 'onSelectedChange' | 'selected'>) => {
|
|
||||||
const onSelectionChange = useCallback(
|
|
||||||
(_event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
return onSelectedChange?.();
|
|
||||||
},
|
|
||||||
[onSelectedChange]
|
|
||||||
);
|
|
||||||
if (!selectable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className={styles.selectionCell}>
|
|
||||||
<Checkbox
|
|
||||||
onClick={stopPropagation}
|
|
||||||
checked={!!selected}
|
|
||||||
onChange={onSelectionChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PageTagsCell = ({ pageId }: Pick<PageListItemProps, 'pageId'>) => {
|
|
||||||
const tagList = useService(TagService).tagList;
|
|
||||||
const tags = useLiveData(tagList.tagsByPageId$(pageId));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-testid="page-list-item-tags" className={styles.tagsCell}>
|
|
||||||
<PageTags
|
|
||||||
tags={tags}
|
|
||||||
hoverExpandDirection="left"
|
|
||||||
widthOnHover="300%"
|
|
||||||
maxItems={5}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PageCreateDateCell = ({
|
|
||||||
createDate,
|
|
||||||
}: Pick<PageListItemProps, 'createDate'>) => {
|
|
||||||
return (
|
|
||||||
<Tooltip content={i18nTime(createDate)}>
|
|
||||||
<div
|
|
||||||
data-testid="page-list-item-date"
|
|
||||||
data-date-raw={createDate}
|
|
||||||
className={styles.dateCell}
|
|
||||||
>
|
|
||||||
{i18nTime(createDate, {
|
|
||||||
relative: true,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PageUpdatedDateCell = ({
|
|
||||||
updatedDate,
|
|
||||||
}: Pick<PageListItemProps, 'updatedDate'>) => {
|
|
||||||
return (
|
|
||||||
<Tooltip content={updatedDate ? i18nTime(updatedDate) : undefined}>
|
|
||||||
<div
|
|
||||||
data-testid="page-list-item-date"
|
|
||||||
data-date-raw={updatedDate}
|
|
||||||
className={styles.dateCell}
|
|
||||||
>
|
|
||||||
{updatedDate
|
|
||||||
? i18nTime(updatedDate, {
|
|
||||||
relative: true,
|
|
||||||
})
|
|
||||||
: '-'}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PageListOperationsCell = ({
|
|
||||||
operations,
|
|
||||||
}: Pick<PageListItemProps, 'operations'>) => {
|
|
||||||
return operations ? (
|
|
||||||
<div onClick={stopPropagation} className={styles.operationsCell}>
|
|
||||||
{operations}
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PageListItem = (props: PageListItemProps) => {
|
|
||||||
const [displayProperties] = useAllDocDisplayProperties();
|
|
||||||
const pageTitleElement = useMemo(() => {
|
|
||||||
return (
|
|
||||||
<div className={styles.dragPageItemOverlay}>
|
|
||||||
<div className={styles.titleIconsWrapper}>
|
|
||||||
<PageSelectionCell
|
|
||||||
onSelectedChange={props.onSelectedChange}
|
|
||||||
selectable={props.selectable}
|
|
||||||
selected={props.selected}
|
|
||||||
/>
|
|
||||||
<ListIconCell icon={props.icon} />
|
|
||||||
</div>
|
|
||||||
<ListTitleCell title={props.title} preview={props.preview} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
props.icon,
|
|
||||||
props.onSelectedChange,
|
|
||||||
props.preview,
|
|
||||||
props.selectable,
|
|
||||||
props.selected,
|
|
||||||
props.title,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { dragRef, CustomDragPreview, dragging } = useDraggable<AffineDNDData>(
|
|
||||||
() => ({
|
|
||||||
canDrag: props.draggable,
|
|
||||||
data: {
|
|
||||||
entity: {
|
|
||||||
type: 'doc',
|
|
||||||
id: props.pageId,
|
|
||||||
},
|
|
||||||
from: {
|
|
||||||
at: 'all-docs:list',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[props.draggable, props.pageId, props.selectable]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageListItemWrapper
|
|
||||||
onClick={props.onClick}
|
|
||||||
to={props.to}
|
|
||||||
pageId={props.pageId}
|
|
||||||
draggable={props.draggable}
|
|
||||||
isDragging={dragging}
|
|
||||||
ref={dragRef}
|
|
||||||
pageIds={props.pageIds || []}
|
|
||||||
>
|
|
||||||
<ColWrapper flex={9}>
|
|
||||||
<ColWrapper className={styles.dndCell} flex={8}>
|
|
||||||
<div className={styles.titleIconsWrapper}>
|
|
||||||
<PageSelectionCell
|
|
||||||
onSelectedChange={props.onSelectedChange}
|
|
||||||
selectable={props.selectable}
|
|
||||||
selected={props.selected}
|
|
||||||
/>
|
|
||||||
<ListIconCell icon={props.icon} />
|
|
||||||
</div>
|
|
||||||
<ListTitleCell title={props.title} preview={props.preview} />
|
|
||||||
</ColWrapper>
|
|
||||||
<ColWrapper
|
|
||||||
flex={4}
|
|
||||||
alignment="end"
|
|
||||||
style={{ overflow: 'visible' }}
|
|
||||||
hidden={!displayProperties.displayProperties.tags}
|
|
||||||
>
|
|
||||||
<PageTagsCell pageId={props.pageId} />
|
|
||||||
</ColWrapper>
|
|
||||||
</ColWrapper>
|
|
||||||
<ColWrapper
|
|
||||||
flex={1}
|
|
||||||
alignment="end"
|
|
||||||
hideInSmallContainer
|
|
||||||
hidden={!displayProperties.displayProperties.createDate}
|
|
||||||
>
|
|
||||||
<PageCreateDateCell createDate={props.createDate} />
|
|
||||||
</ColWrapper>
|
|
||||||
<ColWrapper
|
|
||||||
flex={1}
|
|
||||||
alignment="end"
|
|
||||||
hideInSmallContainer
|
|
||||||
hidden={!displayProperties.displayProperties.updatedDate}
|
|
||||||
>
|
|
||||||
<PageUpdatedDateCell updatedDate={props.updatedDate} />
|
|
||||||
</ColWrapper>
|
|
||||||
{props.operations ? (
|
|
||||||
<ColWrapper
|
|
||||||
className={styles.actionsCellWrapper}
|
|
||||||
flex={1}
|
|
||||||
alignment="end"
|
|
||||||
>
|
|
||||||
<PageListOperationsCell operations={props.operations} />
|
|
||||||
</ColWrapper>
|
|
||||||
) : null}
|
|
||||||
</PageListItemWrapper>
|
|
||||||
<CustomDragPreview position="pointer-outside">
|
|
||||||
{pageTitleElement}
|
|
||||||
</CustomDragPreview>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type PageListWrapperProps = PropsWithChildren<
|
|
||||||
Pick<PageListItemProps, 'to' | 'pageId' | 'onClick' | 'draggable'> & {
|
|
||||||
isDragging: boolean;
|
|
||||||
pageIds: string[];
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
const PageListItemWrapper = forwardRef(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
to,
|
|
||||||
isDragging,
|
|
||||||
pageId,
|
|
||||||
pageIds,
|
|
||||||
onClick,
|
|
||||||
children,
|
|
||||||
draggable,
|
|
||||||
}: PageListWrapperProps,
|
|
||||||
ref: ForwardedRef<HTMLAnchorElement & HTMLDivElement>
|
|
||||||
) => {
|
|
||||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
|
||||||
const [anchorIndex, setAnchorIndex] = useAtom(anchorIndexAtom);
|
|
||||||
const [rangeIds, setRangeIds] = useAtom(rangeIdsAtom);
|
|
||||||
|
|
||||||
const handleShiftClick = useCallback(
|
|
||||||
(currentIndex: number) => {
|
|
||||||
if (anchorIndex === undefined) {
|
|
||||||
setAnchorIndex(currentIndex);
|
|
||||||
onClick?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowerIndex = Math.min(anchorIndex, currentIndex);
|
|
||||||
const upperIndex = Math.max(anchorIndex, currentIndex);
|
|
||||||
const newRangeIds = pageIds.slice(lowerIndex, upperIndex + 1);
|
|
||||||
|
|
||||||
const currentSelected = selectionState.selectedIds || [];
|
|
||||||
|
|
||||||
// Set operations
|
|
||||||
const setRange = new Set(rangeIds);
|
|
||||||
const newSelected = new Set(
|
|
||||||
currentSelected.filter(id => !setRange.has(id)).concat(newRangeIds)
|
|
||||||
);
|
|
||||||
|
|
||||||
selectionState.onSelectedIdsChange?.([...newSelected]);
|
|
||||||
setRangeIds(newRangeIds);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
anchorIndex,
|
|
||||||
onClick,
|
|
||||||
pageIds,
|
|
||||||
selectionState,
|
|
||||||
setAnchorIndex,
|
|
||||||
rangeIds,
|
|
||||||
setRangeIds,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClick = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
if (!selectionState.selectable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.stopPropagation();
|
|
||||||
const currentIndex = pageIds.indexOf(pageId);
|
|
||||||
|
|
||||||
if (e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!selectionState.selectionActive) {
|
|
||||||
setSelectionActive(true);
|
|
||||||
setAnchorIndex(currentIndex);
|
|
||||||
onClick?.();
|
|
||||||
} else {
|
|
||||||
handleShiftClick(currentIndex);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setAnchorIndex(undefined);
|
|
||||||
setRangeIds([]);
|
|
||||||
onClick?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
handleShiftClick,
|
|
||||||
onClick,
|
|
||||||
pageId,
|
|
||||||
pageIds,
|
|
||||||
selectionState.selectable,
|
|
||||||
selectionState.selectionActive,
|
|
||||||
setAnchorIndex,
|
|
||||||
setRangeIds,
|
|
||||||
setSelectionActive,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const commonProps = useMemo(
|
|
||||||
() => ({
|
|
||||||
role: 'list-item',
|
|
||||||
'data-testid': 'page-list-item',
|
|
||||||
'data-page-id': pageId,
|
|
||||||
'data-draggable': draggable,
|
|
||||||
className: styles.root,
|
|
||||||
'data-clickable': !!onClick || !!to,
|
|
||||||
'data-dragging': isDragging,
|
|
||||||
onClick: onClick ? handleClick : undefined,
|
|
||||||
}),
|
|
||||||
[pageId, draggable, onClick, to, isDragging, handleClick]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectionState.selectionActive) {
|
|
||||||
// listen for shift key up
|
|
||||||
const handleKeyUp = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Shift') {
|
|
||||||
setAnchorIndex(undefined);
|
|
||||||
setRangeIds([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('keyup', handleKeyUp);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keyup', handleKeyUp);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}, [
|
|
||||||
selectionState.selectionActive,
|
|
||||||
setAnchorIndex,
|
|
||||||
setRangeIds,
|
|
||||||
setSelectionActive,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (to) {
|
|
||||||
return (
|
|
||||||
<WorkbenchLink ref={ref} draggable={false} {...commonProps} to={to}>
|
|
||||||
{children}
|
|
||||||
</WorkbenchLink>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<div ref={ref} {...commonProps}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
PageListItemWrapper.displayName = 'PageListItemWrapper';
|
|
||||||
@@ -1,14 +1,6 @@
|
|||||||
import { Menu } from '@affine/component';
|
|
||||||
import { TagItem as TagItemComponent } from '@affine/core/components/tags';
|
import { TagItem as TagItemComponent } from '@affine/core/components/tags';
|
||||||
import type { Tag } from '@affine/core/modules/tag';
|
import type { Tag } from '@affine/core/modules/tag';
|
||||||
import { stopPropagation } from '@affine/core/utils';
|
import { useLiveData } from '@toeverything/infra';
|
||||||
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
|
|
||||||
import { LiveData, useLiveData } from '@toeverything/infra';
|
|
||||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import * as styles from './page-tags.css';
|
|
||||||
|
|
||||||
export interface PageTagsProps {
|
export interface PageTagsProps {
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
@@ -47,90 +39,3 @@ export const TagItem = ({ tag, ...props }: TagItemProps) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TagItemNormal = ({
|
|
||||||
tags,
|
|
||||||
maxItems,
|
|
||||||
}: {
|
|
||||||
tags: Tag[];
|
|
||||||
maxItems?: number;
|
|
||||||
}) => {
|
|
||||||
const nTags = useMemo(() => {
|
|
||||||
return maxItems ? tags.slice(0, maxItems) : tags;
|
|
||||||
}, [maxItems, tags]);
|
|
||||||
|
|
||||||
const tagsOrdered = useLiveData(
|
|
||||||
useMemo(() => {
|
|
||||||
return LiveData.computed(get =>
|
|
||||||
[...nTags].sort((a, b) => get(a.value$).length - get(b.value$).length)
|
|
||||||
);
|
|
||||||
}, [nTags])
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() =>
|
|
||||||
tagsOrdered.map((tag, idx) => (
|
|
||||||
<TagItem key={tag.id} tag={tag} idx={idx} mode="inline" />
|
|
||||||
)),
|
|
||||||
[tagsOrdered]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PageTags = ({
|
|
||||||
tags,
|
|
||||||
widthOnHover,
|
|
||||||
maxItems,
|
|
||||||
hoverExpandDirection,
|
|
||||||
}: PageTagsProps) => {
|
|
||||||
const sanitizedWidthOnHover = widthOnHover
|
|
||||||
? typeof widthOnHover === 'string'
|
|
||||||
? widthOnHover
|
|
||||||
: `${widthOnHover}px`
|
|
||||||
: 'auto';
|
|
||||||
|
|
||||||
const tagsInPopover = useMemo(() => {
|
|
||||||
const lastTags = tags.slice(maxItems);
|
|
||||||
return (
|
|
||||||
<div className={styles.tagsListContainer}>
|
|
||||||
{lastTags.map((tag, idx) => (
|
|
||||||
<TagItem key={tag.id} tag={tag} idx={idx} mode="list-item" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [maxItems, tags]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-testid="page-tags"
|
|
||||||
className={styles.root}
|
|
||||||
style={assignInlineVars({
|
|
||||||
[styles.hoverMaxWidth]: sanitizedWidthOnHover,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
right: hoverExpandDirection === 'left' ? 0 : 'auto',
|
|
||||||
left: hoverExpandDirection === 'right' ? 0 : 'auto',
|
|
||||||
}}
|
|
||||||
className={clsx(styles.innerContainer)}
|
|
||||||
>
|
|
||||||
<div className={styles.innerBackdrop} />
|
|
||||||
<div className={styles.tagsScrollContainer}>
|
|
||||||
<TagItemNormal tags={tags} maxItems={maxItems} />
|
|
||||||
</div>
|
|
||||||
{maxItems && tags.length > maxItems ? (
|
|
||||||
<Menu
|
|
||||||
items={tagsInPopover}
|
|
||||||
contentOptions={{
|
|
||||||
onClick: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={styles.showMoreTag}>
|
|
||||||
<MoreHorizontalIcon />
|
|
||||||
</div>
|
|
||||||
</Menu>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import type { DocMeta } from '@blocksuite/affine/store';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
export const useSearch = (list: DocMeta[]) => {
|
|
||||||
const [value, onChange] = useState('');
|
|
||||||
return {
|
|
||||||
searchText: value,
|
|
||||||
updateSearchText: onChange,
|
|
||||||
searchedList: value
|
|
||||||
? list.filter(v => v.title.toLowerCase().includes(value.toLowerCase()))
|
|
||||||
: list,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import { toast, useConfirmModal } from '@affine/component';
|
|
||||||
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
|
|
||||||
import { type Collection } from '@affine/core/modules/collection';
|
|
||||||
import { DocsService } from '@affine/core/modules/doc';
|
|
||||||
import type { Tag } from '@affine/core/modules/tag';
|
|
||||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
|
||||||
import { Trans, useI18n } from '@affine/i18n';
|
|
||||||
import type { DocMeta } from '@blocksuite/affine/store';
|
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
|
|
||||||
import { usePageItemGroupDefinitions } from '../group-definitions';
|
|
||||||
import { usePageHeaderColsDef } from '../header-col-def';
|
|
||||||
import { PageOperationCell } from '../operation-cell';
|
|
||||||
import { PageListItemRenderer } from '../page-group';
|
|
||||||
import { ListTableHeader } from '../page-header';
|
|
||||||
import type { ItemListHandle, ListItem } from '../types';
|
|
||||||
import { VirtualizedList } from '../virtualized-list';
|
|
||||||
import {
|
|
||||||
CollectionPageListHeader,
|
|
||||||
PageListHeader,
|
|
||||||
TagPageListHeader,
|
|
||||||
} from './page-list-header';
|
|
||||||
|
|
||||||
const usePageOperationsRenderer = (collection?: Collection) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const removeFromAllowList = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
collection?.removeDoc(id);
|
|
||||||
toast(t['com.affine.collection.removePage.success']());
|
|
||||||
},
|
|
||||||
[collection, t]
|
|
||||||
);
|
|
||||||
const pageOperationsRenderer = useCallback(
|
|
||||||
(page: DocMeta, isInAllowList?: boolean) => {
|
|
||||||
return (
|
|
||||||
<PageOperationCell
|
|
||||||
page={page}
|
|
||||||
isInAllowList={isInAllowList}
|
|
||||||
onRemoveFromAllowList={() => removeFromAllowList(page.id)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[removeFromAllowList]
|
|
||||||
);
|
|
||||||
return pageOperationsRenderer;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VirtualizedPageList = memo(function VirtualizedPageList({
|
|
||||||
tag,
|
|
||||||
collection,
|
|
||||||
listItem,
|
|
||||||
setHideHeaderCreateNewPage,
|
|
||||||
disableMultiDelete,
|
|
||||||
}: {
|
|
||||||
tag?: Tag;
|
|
||||||
collection?: Collection;
|
|
||||||
listItem?: DocMeta[];
|
|
||||||
setHideHeaderCreateNewPage?: (hide: boolean) => void;
|
|
||||||
disableMultiDelete?: boolean;
|
|
||||||
}) {
|
|
||||||
const t = useI18n();
|
|
||||||
const listRef = useRef<ItemListHandle>(null);
|
|
||||||
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
|
|
||||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
|
||||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
|
||||||
const docsService = useService(DocsService);
|
|
||||||
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
|
|
||||||
const pageOperations = usePageOperationsRenderer(collection);
|
|
||||||
const pageHeaderColsDef = usePageHeaderColsDef();
|
|
||||||
|
|
||||||
const [filteredPageIds, setFilteredPageIds] = useState<string[]>([]);
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = collection?.watch().subscribe(docIds => {
|
|
||||||
setFilteredPageIds(docIds);
|
|
||||||
});
|
|
||||||
return () => subscription?.unsubscribe();
|
|
||||||
}, [collection]);
|
|
||||||
const allowList = useLiveData(collection?.info$.map(info => info.allowList));
|
|
||||||
const pageMetasToRender = useMemo(() => {
|
|
||||||
if (listItem) {
|
|
||||||
return listItem;
|
|
||||||
}
|
|
||||||
if (collection) {
|
|
||||||
return pageMetas.filter(
|
|
||||||
page => filteredPageIds.includes(page.id) && !page.trash
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return pageMetas.filter(page => !page.trash);
|
|
||||||
}, [collection, filteredPageIds, listItem, pageMetas]);
|
|
||||||
|
|
||||||
const filteredSelectedPageIds = useMemo(() => {
|
|
||||||
const ids = new Set(pageMetasToRender.map(page => page.id));
|
|
||||||
return selectedPageIds.filter(id => ids.has(id));
|
|
||||||
}, [pageMetasToRender, selectedPageIds]);
|
|
||||||
|
|
||||||
const hideFloatingToolbar = useCallback(() => {
|
|
||||||
listRef.current?.toggleSelectable();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const pageOperationRenderer = useCallback(
|
|
||||||
(item: ListItem) => {
|
|
||||||
const page = item as DocMeta;
|
|
||||||
const isInAllowList = allowList?.includes(page.id);
|
|
||||||
return pageOperations(page, isInAllowList);
|
|
||||||
},
|
|
||||||
[allowList, pageOperations]
|
|
||||||
);
|
|
||||||
|
|
||||||
const pageHeaderRenderer = useCallback(() => {
|
|
||||||
return <ListTableHeader headerCols={pageHeaderColsDef} />;
|
|
||||||
}, [pageHeaderColsDef]);
|
|
||||||
|
|
||||||
const pageItemRenderer = useCallback((item: ListItem) => {
|
|
||||||
return <PageListItemRenderer {...item} />;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const heading = useMemo(() => {
|
|
||||||
if (tag) {
|
|
||||||
return <TagPageListHeader workspaceId={currentWorkspace.id} tag={tag} />;
|
|
||||||
}
|
|
||||||
if (collection) {
|
|
||||||
return (
|
|
||||||
<CollectionPageListHeader
|
|
||||||
workspaceId={currentWorkspace.id}
|
|
||||||
collection={collection}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <PageListHeader />;
|
|
||||||
}, [collection, currentWorkspace.id, tag]);
|
|
||||||
|
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
|
|
||||||
const handleMultiDelete = useCallback(() => {
|
|
||||||
if (filteredSelectedPageIds.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
openConfirmModal({
|
|
||||||
title: t['com.affine.moveToTrash.confirmModal.title.multiple']({
|
|
||||||
number: filteredSelectedPageIds.length.toString(),
|
|
||||||
}),
|
|
||||||
description: t[
|
|
||||||
'com.affine.moveToTrash.confirmModal.description.multiple'
|
|
||||||
]({
|
|
||||||
number: filteredSelectedPageIds.length.toString(),
|
|
||||||
}),
|
|
||||||
cancelText: t['com.affine.confirmModal.button.cancel'](),
|
|
||||||
confirmText: t.Delete(),
|
|
||||||
confirmButtonOptions: {
|
|
||||||
variant: 'error',
|
|
||||||
},
|
|
||||||
onConfirm: () => {
|
|
||||||
for (const docId of filteredSelectedPageIds) {
|
|
||||||
const doc = docsService.list.doc$(docId).value;
|
|
||||||
doc?.moveToTrash();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
hideFloatingToolbar();
|
|
||||||
}, [
|
|
||||||
docsService.list,
|
|
||||||
filteredSelectedPageIds,
|
|
||||||
hideFloatingToolbar,
|
|
||||||
openConfirmModal,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const group = usePageItemGroupDefinitions();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<VirtualizedList
|
|
||||||
ref={listRef}
|
|
||||||
selectable="toggle"
|
|
||||||
draggable
|
|
||||||
atTopThreshold={80}
|
|
||||||
atTopStateChange={setHideHeaderCreateNewPage}
|
|
||||||
onSelectionActiveChange={setShowFloatingToolbar}
|
|
||||||
heading={heading}
|
|
||||||
groupBy={group}
|
|
||||||
selectedIds={filteredSelectedPageIds}
|
|
||||||
onSelectedIdsChange={setSelectedPageIds}
|
|
||||||
items={pageMetasToRender}
|
|
||||||
rowAsLink
|
|
||||||
docCollection={currentWorkspace.docCollection}
|
|
||||||
operationsRenderer={pageOperationRenderer}
|
|
||||||
itemRenderer={pageItemRenderer}
|
|
||||||
headerRenderer={pageHeaderRenderer}
|
|
||||||
/>
|
|
||||||
<ListFloatingToolbar
|
|
||||||
open={showFloatingToolbar}
|
|
||||||
onDelete={disableMultiDelete ? undefined : handleMultiDelete}
|
|
||||||
onClose={hideFloatingToolbar}
|
|
||||||
content={
|
|
||||||
<Trans
|
|
||||||
i18nKey="com.affine.page.toolbar.selected"
|
|
||||||
count={filteredSelectedPageIds.length}
|
|
||||||
>
|
|
||||||
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
|
|
||||||
{{ count: filteredSelectedPageIds.length } as any}
|
|
||||||
</div>
|
|
||||||
selected
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -4,7 +4,6 @@ export * from './components/floating-toolbar';
|
|||||||
export * from './components/new-page-button';
|
export * from './components/new-page-button';
|
||||||
export * from './components/page-display-menu';
|
export * from './components/page-display-menu';
|
||||||
export * from './docs';
|
export * from './docs';
|
||||||
export * from './docs/page-list-item';
|
|
||||||
export * from './docs/page-tags';
|
export * from './docs/page-tags';
|
||||||
export * from './group-definitions';
|
export * from './group-definitions';
|
||||||
export * from './header-col-def';
|
export * from './header-col-def';
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { cssVar } from '@toeverything/theme';
|
|||||||
import { createContainer, style } from '@vanilla-extract/css';
|
import { createContainer, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
import { root as collectionItemRoot } from './collections/collection-list-item.css';
|
import { root as collectionItemRoot } from './collections/collection-list-item.css';
|
||||||
import { root as pageItemRoot } from './docs/page-list-item.css';
|
|
||||||
import { root as tagItemRoot } from './tags/tag-list-item.css';
|
import { root as tagItemRoot } from './tags/tag-list-item.css';
|
||||||
export const listRootContainer = createContainer('list-root-container');
|
export const listRootContainer = createContainer('list-root-container');
|
||||||
export const pageListScrollContainer = style({
|
export const pageListScrollContainer = style({
|
||||||
@@ -50,7 +49,7 @@ export const favoriteCell = style({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
selectors: {
|
selectors: {
|
||||||
[`&[data-favorite], ${pageItemRoot}:hover &, ${collectionItemRoot}:hover &, ${tagItemRoot}:hover &`]:
|
[`&[data-favorite], ${collectionItemRoot}:hover &, ${tagItemRoot}:hover &`]:
|
||||||
{
|
{
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,47 +1,14 @@
|
|||||||
import { Scrollable, useHasScrollTop } from '@affine/component';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import type { ForwardedRef, PropsWithChildren } from 'react';
|
import type { ForwardedRef, PropsWithChildren } from 'react';
|
||||||
import {
|
import { memo, useEffect, useImperativeHandle } from 'react';
|
||||||
forwardRef,
|
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import { usePageHeaderColsDef } from './header-col-def';
|
|
||||||
import * as styles from './list.css';
|
|
||||||
import { ItemGroup } from './page-group';
|
|
||||||
import { ListTableHeader } from './page-header';
|
|
||||||
import {
|
import {
|
||||||
groupsAtom,
|
|
||||||
listPropsAtom,
|
listPropsAtom,
|
||||||
ListProvider,
|
|
||||||
selectionStateAtom,
|
selectionStateAtom,
|
||||||
useAtom,
|
useAtom,
|
||||||
useAtomValue,
|
|
||||||
useSetAtom,
|
useSetAtom,
|
||||||
} from './scoped-atoms';
|
} from './scoped-atoms';
|
||||||
import type { ItemListHandle, ListItem, ListProps } from './types';
|
import type { ItemListHandle, ListItem, ListProps } from './types';
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a list of pages, render a list of pages
|
|
||||||
*/
|
|
||||||
export const List = forwardRef<ItemListHandle, ListProps<ListItem>>(
|
|
||||||
function List(props, ref) {
|
|
||||||
return (
|
|
||||||
// push pageListProps to the atom so that downstream components can consume it
|
|
||||||
// this makes sure pageListPropsAtom is always populated
|
|
||||||
// @ts-expect-error jotai-scope is not well typed, AnyWritableAtom is should be any rather than unknown
|
|
||||||
<ListProvider initialValues={[[listPropsAtom, props]]}>
|
|
||||||
<ListInnerWrapper {...props} handleRef={ref}>
|
|
||||||
<ListInner {...props} />
|
|
||||||
</ListInnerWrapper>
|
|
||||||
</ListProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// when pressing ESC or double clicking outside of the page list, close the selection mode
|
// when pressing ESC or double clicking outside of the page list, close the selection mode
|
||||||
// TODO(@Peng): use jotai-effect instead but it seems it does not work with jotai-scope?
|
// TODO(@Peng): use jotai-effect instead but it seems it does not work with jotai-scope?
|
||||||
const useItemSelectionStateEffect = () => {
|
const useItemSelectionStateEffect = () => {
|
||||||
@@ -132,58 +99,3 @@ export const ListInnerWrapper = memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
ListInnerWrapper.displayName = 'ListInnerWrapper';
|
ListInnerWrapper.displayName = 'ListInnerWrapper';
|
||||||
|
|
||||||
const ListInner = (props: ListProps<ListItem>) => {
|
|
||||||
const groups = useAtomValue(groupsAtom);
|
|
||||||
const pageHeaderColsDef = usePageHeaderColsDef();
|
|
||||||
const hideHeader = props.hideHeader;
|
|
||||||
return (
|
|
||||||
<div className={clsx(props.className, styles.root)}>
|
|
||||||
{!hideHeader ? <ListTableHeader headerCols={pageHeaderColsDef} /> : null}
|
|
||||||
<div className={styles.groupsContainer}>
|
|
||||||
{groups.map(group => (
|
|
||||||
<ItemGroup key={group.id} {...group} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ListScrollContainerProps {
|
|
||||||
className?: string;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ListScrollContainer = forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
PropsWithChildren<ListScrollContainerProps>
|
|
||||||
>(({ className, children, style }, ref) => {
|
|
||||||
const [setContainer, hasScrollTop] = useHasScrollTop();
|
|
||||||
|
|
||||||
const setNodeRef = useCallback(
|
|
||||||
(r: HTMLDivElement) => {
|
|
||||||
if (ref) {
|
|
||||||
if (typeof ref === 'function') {
|
|
||||||
ref(r);
|
|
||||||
} else {
|
|
||||||
ref.current = r;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return setContainer(r);
|
|
||||||
},
|
|
||||||
[ref, setContainer]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Scrollable.Root
|
|
||||||
style={style}
|
|
||||||
data-has-scroll-top={hasScrollTop}
|
|
||||||
className={clsx(styles.pageListScrollContainer, className)}
|
|
||||||
>
|
|
||||||
<Scrollable.Viewport ref={setNodeRef}>{children}</Scrollable.Viewport>
|
|
||||||
<Scrollable.Scrollbar />
|
|
||||||
</Scrollable.Root>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ListScrollContainer.displayName = 'ListScrollContainer';
|
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
import { shallowEqual } from '@affine/component';
|
import { shallowEqual } from '@affine/component';
|
||||||
import type { CollectionMeta } from '@affine/core/modules/collection';
|
import type { CollectionMeta } from '@affine/core/modules/collection';
|
||||||
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import type { DocMeta } from '@blocksuite/affine/store';
|
|
||||||
import { ToggleRightIcon, ViewLayersIcon } from '@blocksuite/icons/rc';
|
import { ToggleRightIcon, ViewLayersIcon } from '@blocksuite/icons/rc';
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
import type { MouseEventHandler } from 'react';
|
import type { MouseEventHandler } from 'react';
|
||||||
import { memo, useCallback, useMemo, useState } from 'react';
|
import { memo, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { CollectionListItem } from './collections/collection-list-item';
|
import { CollectionListItem } from './collections/collection-list-item';
|
||||||
import { PageListItem } from './docs/page-list-item';
|
|
||||||
import { PagePreview } from './page-content-preview';
|
|
||||||
import * as styles from './page-group.css';
|
import * as styles from './page-group.css';
|
||||||
import {
|
import {
|
||||||
groupCollapseStateAtom,
|
groupCollapseStateAtom,
|
||||||
groupsAtom,
|
|
||||||
listPropsAtom,
|
listPropsAtom,
|
||||||
selectionStateAtom,
|
selectionStateAtom,
|
||||||
useAtom,
|
useAtom,
|
||||||
@@ -29,7 +21,6 @@ import type {
|
|||||||
ItemGroupProps,
|
ItemGroupProps,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListProps,
|
ListProps,
|
||||||
PageListItemProps,
|
|
||||||
TagListItemProps,
|
TagListItemProps,
|
||||||
TagMeta,
|
TagMeta,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -111,82 +102,6 @@ export const ItemGroupHeader = memo(function ItemGroupHeader<
|
|||||||
) : null;
|
) : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ItemGroup = <T extends ListItem>({
|
|
||||||
id,
|
|
||||||
items,
|
|
||||||
label,
|
|
||||||
}: ItemGroupProps<T>) => {
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
const onExpandedClicked: MouseEventHandler = useCallback(e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
setCollapsed(v => !v);
|
|
||||||
}, []);
|
|
||||||
const selectionState = useAtomValue(selectionStateAtom);
|
|
||||||
const selectedItems = useMemo(() => {
|
|
||||||
const selectedIds = selectionState.selectedIds ?? [];
|
|
||||||
return items.filter(item => selectedIds.includes(item.id));
|
|
||||||
}, [items, selectionState.selectedIds]);
|
|
||||||
const onSelectAll = useCallback(() => {
|
|
||||||
const nonCurrentGroupIds =
|
|
||||||
selectionState.selectedIds?.filter(
|
|
||||||
id => !items.map(item => item.id).includes(id)
|
|
||||||
) ?? [];
|
|
||||||
|
|
||||||
selectionState.onSelectedIdsChange?.([
|
|
||||||
...nonCurrentGroupIds,
|
|
||||||
...items.map(item => item.id),
|
|
||||||
]);
|
|
||||||
}, [items, selectionState]);
|
|
||||||
const t = useI18n();
|
|
||||||
return (
|
|
||||||
<Collapsible.Root
|
|
||||||
data-testid="page-list-group"
|
|
||||||
data-group-id={id}
|
|
||||||
open={!collapsed}
|
|
||||||
className={clsx(styles.root)}
|
|
||||||
>
|
|
||||||
{label ? (
|
|
||||||
<div data-testid="page-list-group-header" className={styles.header}>
|
|
||||||
<Collapsible.Trigger
|
|
||||||
role="button"
|
|
||||||
onClick={onExpandedClicked}
|
|
||||||
data-testid="page-list-group-header-collapsed-button"
|
|
||||||
className={styles.collapsedIconContainer}
|
|
||||||
>
|
|
||||||
<ToggleRightIcon
|
|
||||||
className={styles.collapsedIcon}
|
|
||||||
data-collapsed={collapsed !== false}
|
|
||||||
/>
|
|
||||||
</Collapsible.Trigger>
|
|
||||||
<div className={styles.headerLabel}>{label}</div>
|
|
||||||
{selectionState.selectionActive ? (
|
|
||||||
<div className={styles.headerCount}>
|
|
||||||
{selectedItems.length}/{items.length}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className={styles.spacer} />
|
|
||||||
{selectionState.selectionActive ? (
|
|
||||||
<button className={styles.selectAllButton} onClick={onSelectAll}>
|
|
||||||
{t['com.affine.page.group-header.select-all']()}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Collapsible.Content
|
|
||||||
className={styles.collapsibleContent}
|
|
||||||
data-state={!collapsed ? 'open' : 'closed'}
|
|
||||||
>
|
|
||||||
<div className={styles.collapsibleContentInner}>
|
|
||||||
{items.map(item => (
|
|
||||||
<PageListItemRenderer key={item.id} {...item} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Collapsible.Content>
|
|
||||||
</Collapsible.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO(@Peng): optimize how to render page meta list item
|
// TODO(@Peng): optimize how to render page meta list item
|
||||||
const requiredPropNames = [
|
const requiredPropNames = [
|
||||||
'docCollection',
|
'docCollection',
|
||||||
@@ -214,30 +129,6 @@ const listsPropsAtom = selectAtom(
|
|||||||
shallowEqual
|
shallowEqual
|
||||||
);
|
);
|
||||||
|
|
||||||
export const PageListItemRenderer = memo(function PageListItemRenderer(
|
|
||||||
item: ListItem
|
|
||||||
) {
|
|
||||||
const props = useAtomValue(listsPropsAtom);
|
|
||||||
const { selectionActive } = useAtomValue(selectionStateAtom);
|
|
||||||
const groups = useAtomValue(groupsAtom);
|
|
||||||
const pageItems = groups.flatMap(group => group.items).map(item => item.id);
|
|
||||||
|
|
||||||
const page = item as DocMeta;
|
|
||||||
return (
|
|
||||||
<PageListItem
|
|
||||||
{...pageMetaToListItemProp(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
...props,
|
|
||||||
|
|
||||||
selectable: !!selectionActive,
|
|
||||||
},
|
|
||||||
pageItems
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const CollectionListItemRenderer = memo((item: ListItem) => {
|
export const CollectionListItemRenderer = memo((item: ListItem) => {
|
||||||
const props = useAtomValue(listsPropsAtom);
|
const props = useAtomValue(listsPropsAtom);
|
||||||
const { selectionActive } = useAtomValue(selectionStateAtom);
|
const { selectionActive } = useAtomValue(selectionStateAtom);
|
||||||
@@ -270,62 +161,6 @@ export const TagListItemRenderer = memo(function TagListItemRenderer(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const PageTitle = ({ id }: { id: string }) => {
|
|
||||||
const i18n = useI18n();
|
|
||||||
const docDisplayMetaService = useService(DocDisplayMetaService);
|
|
||||||
const title = useLiveData(docDisplayMetaService.title$(id));
|
|
||||||
return i18n.t(title);
|
|
||||||
};
|
|
||||||
|
|
||||||
const UnifiedPageIcon = ({ id }: { id: string }) => {
|
|
||||||
const docDisplayMetaService = useService(DocDisplayMetaService);
|
|
||||||
const Icon = useLiveData(docDisplayMetaService.icon$(id));
|
|
||||||
return <Icon />;
|
|
||||||
};
|
|
||||||
|
|
||||||
function pageMetaToListItemProp(
|
|
||||||
item: DocMeta,
|
|
||||||
props: RequiredProps<DocMeta>,
|
|
||||||
pageIds?: string[]
|
|
||||||
): PageListItemProps {
|
|
||||||
const toggleSelection = props.onSelectedIdsChange
|
|
||||||
? () => {
|
|
||||||
if (!props.selectedIds) {
|
|
||||||
throw new Error('selectedIds is not found');
|
|
||||||
}
|
|
||||||
const prevSelected = props.selectedIds.includes(item.id);
|
|
||||||
const shouldAdd = !prevSelected;
|
|
||||||
const shouldRemove = prevSelected;
|
|
||||||
|
|
||||||
if (shouldAdd) {
|
|
||||||
props.onSelectedIdsChange?.([...props.selectedIds, item.id]);
|
|
||||||
} else if (shouldRemove) {
|
|
||||||
props.onSelectedIdsChange?.(
|
|
||||||
props.selectedIds.filter(id => id !== item.id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
const itemProps: PageListItemProps = {
|
|
||||||
pageId: item.id,
|
|
||||||
pageIds,
|
|
||||||
title: <PageTitle id={item.id} />,
|
|
||||||
preview: <PagePreview pageId={item.id} />,
|
|
||||||
createDate: new Date(item.createDate),
|
|
||||||
updatedDate: item.updatedDate ? new Date(item.updatedDate) : undefined,
|
|
||||||
to: props.rowAsLink && !props.selectable ? `/${item.id}` : undefined,
|
|
||||||
onClick: toggleSelection,
|
|
||||||
icon: <UnifiedPageIcon id={item.id} />,
|
|
||||||
operations: props.operationsRenderer?.(item),
|
|
||||||
selectable: props.selectable,
|
|
||||||
selected: props.selectedIds?.includes(item.id),
|
|
||||||
onSelectedChange: toggleSelection,
|
|
||||||
draggable: props.draggable,
|
|
||||||
isPublicPage: !!item.isPublic,
|
|
||||||
};
|
|
||||||
return itemProps;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectionMetaToListItemProp(
|
function collectionMetaToListItemProp(
|
||||||
item: CollectionMeta,
|
item: CollectionMeta,
|
||||||
props: RequiredProps<CollectionMeta>
|
props: RequiredProps<CollectionMeta>
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
|
|
||||||
import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
|
|
||||||
import {
|
|
||||||
PageDisplayMenu,
|
|
||||||
PageListNewPageButton,
|
|
||||||
} from '@affine/core/components/page-list';
|
|
||||||
import { Header } from '@affine/core/components/pure/header';
|
|
||||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
|
||||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
|
||||||
import { inferOpenMode } from '@affine/core/utils';
|
|
||||||
import { track } from '@affine/track';
|
|
||||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
|
||||||
import { useServices } from '@toeverything/infra';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import * as styles from './all-page.css';
|
|
||||||
|
|
||||||
export const AllPageHeader = ({
|
|
||||||
showCreateNew,
|
|
||||||
}: {
|
|
||||||
showCreateNew: boolean;
|
|
||||||
}) => {
|
|
||||||
const { workspaceService, workspaceDialogService, workbenchService } =
|
|
||||||
useServices({
|
|
||||||
WorkspaceService,
|
|
||||||
WorkspaceDialogService,
|
|
||||||
WorkbenchService,
|
|
||||||
});
|
|
||||||
const workbench = workbenchService.workbench;
|
|
||||||
const workspace = workspaceService.workspace;
|
|
||||||
const { createEdgeless, createPage } = usePageHelper(workspace.docCollection);
|
|
||||||
|
|
||||||
const handleOpenDocs = useCallback(
|
|
||||||
(result: {
|
|
||||||
docIds: string[];
|
|
||||||
entryId?: string;
|
|
||||||
isWorkspaceFile?: boolean;
|
|
||||||
}) => {
|
|
||||||
const { docIds, entryId, isWorkspaceFile } = result;
|
|
||||||
// If the imported file is a workspace file, open the entry page.
|
|
||||||
if (isWorkspaceFile && entryId) {
|
|
||||||
workbench.openDoc(entryId);
|
|
||||||
} else if (!docIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Open all the docs when there are multiple docs imported.
|
|
||||||
if (docIds.length > 1) {
|
|
||||||
workbench.openAll();
|
|
||||||
} else {
|
|
||||||
// Otherwise, open the only doc.
|
|
||||||
workbench.openDoc(docIds[0]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[workbench]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onImportFile = useCallback(() => {
|
|
||||||
track.$.header.importModal.open();
|
|
||||||
workspaceDialogService.open('import', undefined, payload => {
|
|
||||||
if (!payload) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleOpenDocs(payload);
|
|
||||||
});
|
|
||||||
}, [workspaceDialogService, handleOpenDocs]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Header
|
|
||||||
left={<ExplorerNavigation active={'docs'} />}
|
|
||||||
right={
|
|
||||||
<>
|
|
||||||
<PageListNewPageButton
|
|
||||||
size="small"
|
|
||||||
className={clsx(
|
|
||||||
styles.headerCreateNewButton,
|
|
||||||
!showCreateNew && styles.headerCreateNewButtonHidden
|
|
||||||
)}
|
|
||||||
onCreateEdgeless={e => createEdgeless({ at: inferOpenMode(e) })}
|
|
||||||
onCreatePage={e => createPage('page', { at: inferOpenMode(e) })}
|
|
||||||
onCreateDoc={e => createPage(undefined, { at: inferOpenMode(e) })}
|
|
||||||
onImportFile={onImportFile}
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
</PageListNewPageButton>
|
|
||||||
<PageDisplayMenu />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
|
||||||
export const scrollContainer = style({
|
|
||||||
flex: 1,
|
|
||||||
width: '100%',
|
|
||||||
paddingBottom: '32px',
|
|
||||||
});
|
|
||||||
export const headerCreateNewButton = style({
|
|
||||||
transition: 'opacity 0.1s ease-in-out',
|
|
||||||
marginRight: 16,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const headerCreateNewCollectionIconButton = style({
|
|
||||||
padding: '4px 8px',
|
|
||||||
fontSize: '16px',
|
|
||||||
width: '32px',
|
|
||||||
height: '28px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
});
|
|
||||||
export const headerCreateNewButtonHidden = style({
|
|
||||||
opacity: 0,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const body = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
flex: 1,
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
|
|
||||||
import {
|
|
||||||
PageListHeader,
|
|
||||||
VirtualizedPageList,
|
|
||||||
} from '@affine/core/components/page-list';
|
|
||||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
|
||||||
import { IntegrationService } from '@affine/core/modules/integration';
|
|
||||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
|
||||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
useIsActiveView,
|
|
||||||
ViewBody,
|
|
||||||
ViewHeader,
|
|
||||||
ViewIcon,
|
|
||||||
ViewTitle,
|
|
||||||
} from '../../../../modules/workbench';
|
|
||||||
import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs';
|
|
||||||
import { EmptyPageList } from '../page-list-empty';
|
|
||||||
import * as styles from './all-page.css';
|
|
||||||
import { AllPageHeader } from './all-page-header';
|
|
||||||
|
|
||||||
export const AllPage = () => {
|
|
||||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
|
||||||
const globalContext = useService(GlobalContextService).globalContext;
|
|
||||||
const permissionService = useService(WorkspacePermissionService);
|
|
||||||
const integrationService = useService(IntegrationService);
|
|
||||||
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
|
|
||||||
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
|
|
||||||
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
|
|
||||||
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
|
||||||
const importing = useLiveData(integrationService.importing$);
|
|
||||||
|
|
||||||
const filteredPageMetas = useMemo(
|
|
||||||
() => pageMetas.filter(page => !page.trash),
|
|
||||||
[pageMetas]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isActiveView = useIsActiveView();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isActiveView) {
|
|
||||||
globalContext.isAllDocs.set(true);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
globalContext.isAllDocs.set(false);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}, [globalContext, isActiveView]);
|
|
||||||
|
|
||||||
const t = useI18n();
|
|
||||||
|
|
||||||
if (importing) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ViewTitle title={t['All pages']()} />
|
|
||||||
<ViewIcon icon="allDocs" />
|
|
||||||
<ViewHeader>
|
|
||||||
<AllPageHeader showCreateNew={!hideHeaderCreateNew} />
|
|
||||||
</ViewHeader>
|
|
||||||
<ViewBody>
|
|
||||||
<div className={styles.body}>
|
|
||||||
{filteredPageMetas.length > 0 ? (
|
|
||||||
<VirtualizedPageList
|
|
||||||
disableMultiDelete={!isAdmin && !isOwner}
|
|
||||||
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EmptyPageList type="all" heading={<PageListHeader />} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ViewBody>
|
|
||||||
<AllDocSidebarTabs />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Component = () => {
|
|
||||||
return <AllPage />;
|
|
||||||
};
|
|
||||||
@@ -5,10 +5,6 @@ export const workbenchRoutes = [
|
|||||||
path: '/all',
|
path: '/all',
|
||||||
lazy: () => import('./pages/workspace/all-page/all-page'),
|
lazy: () => import('./pages/workspace/all-page/all-page'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/all-old',
|
|
||||||
lazy: () => import('./pages/workspace/all-page-old/all-page'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/collection',
|
path: '/collection',
|
||||||
lazy: () => import('./pages/workspace/all-collection'),
|
lazy: () => import('./pages/workspace/all-collection'),
|
||||||
|
|||||||
Reference in New Issue
Block a user