mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-23 09:22:38 +03:00
feat(core): add collection and tag filters to all pages (#5567)
close TOV-69 Added the `filterMode` parameter to the `/all` route. Abstracted the `PageList` and associated components into more universal ones. Added the `useTagMetas` hook to get and update the workspace tags. https://github.com/toeverything/AFFiNE/assets/102217452/7595944d-a056-40c2-8d89-d8df9e901a4b
This commit is contained in:
parent
b867dcbdeb
commit
18068f4ae2
@ -106,4 +106,7 @@ export const setPageModeAtom = atom(
|
||||
export type PageModeOption = 'all' | 'page' | 'edgeless';
|
||||
export const allPageModeSelectAtom = atom<PageModeOption>('all');
|
||||
|
||||
export type AllPageFilterOption = 'docs' | 'collections' | 'tags';
|
||||
export const allPageFilterSelectAtom = atom<AllPageFilterOption>('docs');
|
||||
|
||||
export const openWorkspaceListModalAtom = atom(false);
|
||||
|
@ -17,13 +17,11 @@ export function registerAffineNavigationCommands({
|
||||
store,
|
||||
workspace,
|
||||
navigationHelper,
|
||||
pageListMode,
|
||||
setPageListMode,
|
||||
}: {
|
||||
t: ReturnType<typeof useAFFiNEI18N>;
|
||||
store: ReturnType<typeof createStore>;
|
||||
navigationHelper: ReturnType<typeof useNavigateHelper>;
|
||||
pageListMode: PageModeOption;
|
||||
setPageListMode: React.Dispatch<React.SetStateAction<PageModeOption>>;
|
||||
workspace: Workspace;
|
||||
}) {
|
||||
@ -43,32 +41,26 @@ export function registerAffineNavigationCommands({
|
||||
|
||||
unsubs.push(
|
||||
registerAffineCommand({
|
||||
id: 'affine:goto-page-list',
|
||||
id: 'affine:goto-collection-list',
|
||||
category: 'affine:navigation',
|
||||
icon: <ArrowRightBigIcon />,
|
||||
preconditionStrategy: () => {
|
||||
return pageListMode !== 'page';
|
||||
},
|
||||
label: t['com.affine.cmdk.affine.navigation.goto-page-list'](),
|
||||
label: 'Go to Collection List',
|
||||
run() {
|
||||
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
|
||||
setPageListMode('page');
|
||||
navigationHelper.jumpToCollections(workspace.id);
|
||||
setPageListMode('all');
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
registerAffineCommand({
|
||||
id: 'affine:goto-edgeless-list',
|
||||
id: 'affine:goto-tag-list',
|
||||
category: 'affine:navigation',
|
||||
icon: <ArrowRightBigIcon />,
|
||||
preconditionStrategy: () => {
|
||||
return pageListMode !== 'edgeless';
|
||||
},
|
||||
label: t['com.affine.cmdk.affine.navigation.goto-edgeless-list'](),
|
||||
label: 'Go to Tag List',
|
||||
run() {
|
||||
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
|
||||
setPageListMode('edgeless');
|
||||
navigationHelper.jumpToTags(workspace.id);
|
||||
setPageListMode('all');
|
||||
},
|
||||
})
|
||||
);
|
||||
|
@ -0,0 +1,29 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const collectionListHeader = style({
|
||||
height: 100,
|
||||
alignItems: 'center',
|
||||
padding: '48px 16px 20px 24px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
});
|
||||
|
||||
export const collectionListHeaderTitle = style({
|
||||
fontSize: 'var(--affine-font-h-5)',
|
||||
fontWeight: 500,
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
export const newCollectionButton = style({
|
||||
padding: '6px 10px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
fontWeight: 600,
|
||||
height: '32px',
|
||||
});
|
@ -0,0 +1,29 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import * as styles from './collection-list-header.css';
|
||||
|
||||
export const CollectionListHeader = ({
|
||||
node,
|
||||
onCreate,
|
||||
}: {
|
||||
node: ReactElement | null;
|
||||
onCreate: () => void;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.collectionListHeader}>
|
||||
<div className={styles.collectionListHeaderTitle}>
|
||||
{t['com.affine.collections.header']()}
|
||||
</div>
|
||||
<Button className={styles.newCollectionButton} onClick={onCreate}>
|
||||
{t['com.affine.collections.empty.new-collection-button']()}
|
||||
</Button>
|
||||
</div>
|
||||
{node}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,206 @@
|
||||
import { Checkbox } from '@affine/component';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { type PropsWithChildren, useCallback, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type {
|
||||
CollectionListItemProps,
|
||||
DraggableTitleCellData,
|
||||
PageListItemProps,
|
||||
} from '../types';
|
||||
import { ColWrapper, stopPropagation } from '../utils';
|
||||
import * as styles from './collection-list-item.css';
|
||||
|
||||
const ListTitleCell = ({
|
||||
title,
|
||||
preview,
|
||||
}: Pick<PageListItemProps, 'title' | 'preview'>) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div data-testid="page-list-item-title" className={styles.titleCell}>
|
||||
<div
|
||||
data-testid="page-list-item-title-text"
|
||||
className={styles.titleCellMain}
|
||||
>
|
||||
{title || t['Untitled']()}
|
||||
</div>
|
||||
{preview ? (
|
||||
<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 CollectionSelectionCell = ({
|
||||
selectable,
|
||||
onSelectedChange,
|
||||
selected,
|
||||
}: Pick<
|
||||
CollectionListItemProps,
|
||||
'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>
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionListOperationsCell = ({
|
||||
operations,
|
||||
}: Pick<CollectionListItemProps, 'operations'>) => {
|
||||
return operations ? (
|
||||
<div onClick={stopPropagation} className={styles.operationsCell}>
|
||||
{operations}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export const CollectionListItem = (props: CollectionListItemProps) => {
|
||||
const collectionTitleElement = useMemo(() => {
|
||||
return (
|
||||
<div className={styles.dragPageItemOverlay}>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<CollectionSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<ListIconCell icon={props.icon} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [props.icon, props.onSelectedChange, props.selectable, props.selected]);
|
||||
|
||||
// TODO: use getDropItemId
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: 'collection-list-item-title-' + props.collectionId,
|
||||
data: {
|
||||
pageId: props.collectionId,
|
||||
pageTitle: collectionTitleElement,
|
||||
} satisfies DraggableTitleCellData,
|
||||
disabled: !props.draggable,
|
||||
});
|
||||
|
||||
return (
|
||||
<CollectionListItemWrapper
|
||||
onClick={props.onClick}
|
||||
to={props.to}
|
||||
collectionId={props.collectionId}
|
||||
draggable={props.draggable}
|
||||
isDragging={isDragging}
|
||||
>
|
||||
<ColWrapper flex={9}>
|
||||
<ColWrapper
|
||||
className={styles.dndCell}
|
||||
flex={8}
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<CollectionSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<ListIconCell icon={props.icon} />
|
||||
</div>
|
||||
<ListTitleCell title={props.title} />
|
||||
</ColWrapper>
|
||||
<ColWrapper
|
||||
flex={4}
|
||||
alignment="end"
|
||||
style={{ overflow: 'visible' }}
|
||||
></ColWrapper>
|
||||
</ColWrapper>
|
||||
{props.operations ? (
|
||||
<ColWrapper
|
||||
className={styles.actionsCellWrapper}
|
||||
flex={2}
|
||||
alignment="end"
|
||||
>
|
||||
<CollectionListOperationsCell operations={props.operations} />
|
||||
</ColWrapper>
|
||||
) : null}
|
||||
</CollectionListItemWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
type collectionListWrapperProps = PropsWithChildren<
|
||||
Pick<
|
||||
CollectionListItemProps,
|
||||
'to' | 'collectionId' | 'onClick' | 'draggable'
|
||||
> & {
|
||||
isDragging: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
function CollectionListItemWrapper({
|
||||
to,
|
||||
isDragging,
|
||||
collectionId,
|
||||
onClick,
|
||||
children,
|
||||
draggable,
|
||||
}: collectionListWrapperProps) {
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (onClick) {
|
||||
stopPropagation(e);
|
||||
onClick();
|
||||
}
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
const commonProps = useMemo(
|
||||
() => ({
|
||||
'data-testid': 'collection-list-item',
|
||||
'data-collection-id': collectionId,
|
||||
'data-draggable': draggable,
|
||||
className: styles.root,
|
||||
'data-clickable': !!onClick || !!to,
|
||||
'data-dragging': isDragging,
|
||||
onClick: handleClick,
|
||||
}),
|
||||
[collectionId, draggable, isDragging, onClick, to, handleClick]
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link {...commonProps} to={to}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return <div {...commonProps}>{children}</div>;
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
export * from './collection-list-header';
|
||||
export * from './collection-list-item';
|
||||
export * from './virtualized-collection-list';
|
@ -0,0 +1,151 @@
|
||||
import { collectionsCRUDAtom } from '@affine/core/atoms/collections';
|
||||
import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-collection-info';
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import {
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
|
||||
import { collectionHeaderColsDef } from '../header-col-def';
|
||||
import { CollectionOperationCell } from '../operation-cell';
|
||||
import { CollectionListItemRenderer } from '../page-group';
|
||||
import { ListTableHeader } from '../page-header';
|
||||
import type { CollectionMeta, ItemListHandle, ListItem } from '../types';
|
||||
import { useCollectionManager } from '../use-collection-manager';
|
||||
import type { AllPageListConfig } from '../view';
|
||||
import { VirtualizedList } from '../virtualized-list';
|
||||
import { CollectionListHeader } from './collection-list-header';
|
||||
|
||||
const useCollectionOperationsRenderer = ({
|
||||
info,
|
||||
setting,
|
||||
config,
|
||||
}: {
|
||||
info: DeleteCollectionInfo;
|
||||
config: AllPageListConfig;
|
||||
setting: ReturnType<typeof useCollectionManager>;
|
||||
}) => {
|
||||
const pageOperationsRenderer = useCallback(
|
||||
(collection: Collection) => {
|
||||
return (
|
||||
<CollectionOperationCell
|
||||
info={info}
|
||||
collection={collection}
|
||||
setting={setting}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[config, info, setting]
|
||||
);
|
||||
|
||||
return pageOperationsRenderer;
|
||||
};
|
||||
|
||||
export const VirtualizedCollectionList = ({
|
||||
collections,
|
||||
collectionMetas,
|
||||
setHideHeaderCreateNewCollection,
|
||||
node,
|
||||
handleCreateCollection,
|
||||
config,
|
||||
}: {
|
||||
collections: Collection[];
|
||||
collectionMetas: CollectionMeta[];
|
||||
config: AllPageListConfig;
|
||||
node: ReactElement | null;
|
||||
handleCreateCollection: () => void;
|
||||
setHideHeaderCreateNewCollection: (hide: boolean) => void;
|
||||
}) => {
|
||||
const listRef = useRef<ItemListHandle>(null);
|
||||
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
|
||||
const [selectedCollectionIds, setSelectedCollectionIds] = useState<string[]>(
|
||||
[]
|
||||
);
|
||||
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
||||
const info = useDeleteCollectionInfo();
|
||||
|
||||
const collectionOperations = useCollectionOperationsRenderer({
|
||||
info,
|
||||
setting,
|
||||
config,
|
||||
});
|
||||
|
||||
const filteredSelectedCollectionIds = useMemo(() => {
|
||||
const ids = collections.map(collection => collection.id);
|
||||
return selectedCollectionIds.filter(id => ids.includes(id));
|
||||
}, [collections, selectedCollectionIds]);
|
||||
|
||||
const hideFloatingToolbar = useCallback(() => {
|
||||
listRef.current?.toggleSelectable();
|
||||
}, []);
|
||||
|
||||
const collectionOperationRenderer = useCallback(
|
||||
(item: ListItem) => {
|
||||
const collection = item as CollectionMeta;
|
||||
return collectionOperations(collection);
|
||||
},
|
||||
[collectionOperations]
|
||||
);
|
||||
|
||||
const collectionHeaderRenderer = useCallback(() => {
|
||||
return <ListTableHeader headerCols={collectionHeaderColsDef} />;
|
||||
}, []);
|
||||
|
||||
const collectionItemRenderer = useCallback((item: ListItem) => {
|
||||
return <CollectionListItemRenderer {...item} />;
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
return setting.deleteCollection(info, ...selectedCollectionIds);
|
||||
}, [setting, info, selectedCollectionIds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualizedList
|
||||
ref={listRef}
|
||||
selectable="toggle"
|
||||
draggable={false}
|
||||
groupBy={false}
|
||||
atTopThreshold={80}
|
||||
atTopStateChange={setHideHeaderCreateNewCollection}
|
||||
onSelectionActiveChange={setShowFloatingToolbar}
|
||||
heading={
|
||||
<CollectionListHeader node={node} onCreate={handleCreateCollection} />
|
||||
}
|
||||
selectedIds={filteredSelectedCollectionIds}
|
||||
onSelectedIdsChange={setSelectedCollectionIds}
|
||||
items={collectionMetas}
|
||||
itemRenderer={collectionItemRenderer}
|
||||
rowAsLink
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
operationsRenderer={collectionOperationRenderer}
|
||||
headerRenderer={collectionHeaderRenderer}
|
||||
/>
|
||||
<ListFloatingToolbar
|
||||
open={showFloatingToolbar && selectedCollectionIds.length > 0}
|
||||
content={
|
||||
<Trans
|
||||
i18nKey="com.affine.collection.toolbar.selected"
|
||||
count={selectedCollectionIds.length}
|
||||
>
|
||||
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
|
||||
{{ count: selectedCollectionIds.length } as any}
|
||||
</div>
|
||||
selected
|
||||
</Trans>
|
||||
}
|
||||
onClose={hideFloatingToolbar}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,8 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const floatingToolbar = style({
|
||||
position: 'absolute',
|
||||
bottom: 26,
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
});
|
@ -0,0 +1,31 @@
|
||||
import { CloseIcon, DeleteIcon } from '@blocksuite/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { FloatingToolbar } from './floating-toolbar';
|
||||
import * as styles from './list-floating-toolbar.css';
|
||||
|
||||
export const ListFloatingToolbar = ({
|
||||
content,
|
||||
onClose,
|
||||
open,
|
||||
onDelete,
|
||||
}: {
|
||||
open: boolean;
|
||||
content: ReactNode;
|
||||
onClose: () => void;
|
||||
onDelete: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<FloatingToolbar className={styles.floatingToolbar} open={open}>
|
||||
<FloatingToolbar.Item>{content}</FloatingToolbar.Item>
|
||||
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
|
||||
<FloatingToolbar.Separator />
|
||||
<FloatingToolbar.Button
|
||||
onClick={onDelete}
|
||||
icon={<DeleteIcon />}
|
||||
type="danger"
|
||||
data-testid="list-toolbar-delete"
|
||||
/>
|
||||
</FloatingToolbar>
|
||||
);
|
||||
};
|
@ -0,0 +1,30 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const headerCell = style({
|
||||
padding: '0 8px',
|
||||
userSelect: 'none',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
selectors: {
|
||||
'&[data-sorting], &:hover': {
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
},
|
||||
'&[data-sortable]': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'&:not(:last-child)': {
|
||||
borderRight: '1px solid var(--affine-hover-color-filled)',
|
||||
},
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
columnGap: '4px',
|
||||
position: 'relative',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const headerCellSortIcon = style({
|
||||
display: 'inline-flex',
|
||||
fontSize: 14,
|
||||
color: 'var(--affine-icon-color)',
|
||||
});
|
@ -0,0 +1,54 @@
|
||||
import { SortDownIcon, SortUpIcon } from '@blocksuite/icons';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { ColWrapperProps, ListItem } from '../types';
|
||||
import { ColWrapper } from '../utils';
|
||||
import * as styles from './list-header-cell.css';
|
||||
|
||||
type HeaderCellProps = ColWrapperProps & {
|
||||
sortKey: keyof ListItem;
|
||||
sortable?: boolean;
|
||||
order?: 'asc' | 'desc';
|
||||
sorting?: boolean;
|
||||
onSort?: (sortable?: boolean, sortKey?: keyof ListItem) => void;
|
||||
};
|
||||
|
||||
export const ListHeaderCell = ({
|
||||
sortKey,
|
||||
sortable,
|
||||
order,
|
||||
sorting,
|
||||
onSort,
|
||||
alignment,
|
||||
flex,
|
||||
style,
|
||||
hideInSmallContainer,
|
||||
children,
|
||||
}: HeaderCellProps) => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (sortable) {
|
||||
onSort?.(sortable, sortKey);
|
||||
}
|
||||
}, [sortable, sortKey, onSort]);
|
||||
|
||||
return (
|
||||
<ColWrapper
|
||||
flex={flex}
|
||||
alignment={alignment}
|
||||
onClick={handleClick}
|
||||
className={styles.headerCell}
|
||||
data-sortable={sortable ? true : undefined}
|
||||
data-sorting={sorting ? true : undefined}
|
||||
style={style}
|
||||
role="columnheader"
|
||||
hideInSmallContainer={hideInSmallContainer}
|
||||
>
|
||||
{children}
|
||||
{sorting ? (
|
||||
<div className={styles.headerCellSortIcon}>
|
||||
{order === 'asc' ? <SortUpIcon /> : <SortDownIcon />}
|
||||
</div>
|
||||
) : null}
|
||||
</ColWrapper>
|
||||
);
|
||||
};
|
@ -0,0 +1,5 @@
|
||||
export * from './page-list-header';
|
||||
export * from './page-list-item';
|
||||
export * from './page-list-new-page-button';
|
||||
export * from './page-tags';
|
||||
export * from './virtualized-page-list';
|
@ -0,0 +1,71 @@
|
||||
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: 'var(--affine-background-primary-color)',
|
||||
});
|
||||
|
||||
export const docListHeaderTitle = style({
|
||||
fontSize: 'var(--affine-font-h-5)',
|
||||
fontWeight: 500,
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
height: '28px',
|
||||
});
|
||||
|
||||
export const titleIcon = style({
|
||||
color: 'var(--affine-icon-color)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const titleCollectionName = style({
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
});
|
||||
|
||||
export const addPageButton = style({
|
||||
padding: '6px 10px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
fontWeight: 600,
|
||||
height: '32px',
|
||||
});
|
||||
|
||||
export const tagSticky = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '1px 8px',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
borderRadius: '10px',
|
||||
columnGap: '4px',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
maxWidth: '30vw',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
height: '22px',
|
||||
lineHeight: '1.67em',
|
||||
});
|
||||
|
||||
export const tagIndicator = style({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const tagLabel = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
@ -0,0 +1,181 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { collectionsCRUDAtom } from '@affine/core/atoms/collections';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
||||
import type { Collection, Tag } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ViewLayersIcon } from '@blocksuite/icons';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { createTagFilter } from '../filter/utils';
|
||||
import {
|
||||
createEmptyCollection,
|
||||
useCollectionManager,
|
||||
} from '../use-collection-manager';
|
||||
import { tagColorMap } from '../utils';
|
||||
import type { AllPageListConfig } from '../view/edit-collection/edit-collection';
|
||||
import {
|
||||
useEditCollection,
|
||||
useEditCollectionName,
|
||||
} from '../view/use-edit-collection';
|
||||
import * as styles from './page-list-header.css';
|
||||
import { PageListNewPageButton } from './page-list-new-page-button';
|
||||
|
||||
export const PageListHeader = ({ workspaceId }: { workspaceId: string }) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||
const { jumpToCollections } = useNavigateHelper();
|
||||
|
||||
const handleJumpToCollections = useCallback(() => {
|
||||
jumpToCollections(workspaceId);
|
||||
}, [jumpToCollections, workspaceId]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (setting.isDefault) {
|
||||
return t['com.affine.all-pages.header']();
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div style={{ cursor: 'pointer' }} onClick={handleJumpToCollections}>
|
||||
{t['com.affine.collections.header']()} /
|
||||
</div>
|
||||
<div className={styles.titleIcon}>
|
||||
<ViewLayersIcon />
|
||||
</div>
|
||||
<div className={styles.titleCollectionName}>
|
||||
{setting.currentCollection.name}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
handleJumpToCollections,
|
||||
setting.currentCollection.name,
|
||||
setting.isDefault,
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={styles.docListHeader}>
|
||||
<div className={styles.docListHeaderTitle}>{title}</div>
|
||||
<PageListNewPageButton testId="new-page-button-trigger">
|
||||
{t['New Page']()}
|
||||
</PageListNewPageButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const CollectionPageListHeader = ({
|
||||
collection,
|
||||
workspaceId,
|
||||
config,
|
||||
}: {
|
||||
config: AllPageListConfig;
|
||||
collection: Collection;
|
||||
workspaceId: string;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||
const { jumpToCollections } = useNavigateHelper();
|
||||
|
||||
const handleJumpToCollections = useCallback(() => {
|
||||
jumpToCollections(workspaceId);
|
||||
}, [jumpToCollections, workspaceId]);
|
||||
|
||||
const { updateCollection } = useCollectionManager(collectionsCRUDAtom);
|
||||
const { node, open } = useEditCollection(config);
|
||||
|
||||
const handleAddPage = useAsyncCallback(async () => {
|
||||
const ret = await open({ ...collection }, 'page');
|
||||
updateCollection(ret);
|
||||
}, [collection, open, updateCollection]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{node}
|
||||
<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}>
|
||||
{setting.currentCollection.name}
|
||||
</div>
|
||||
</div>
|
||||
<Button className={styles.addPageButton} onClick={handleAddPage}>
|
||||
{t['com.affine.collection.addPages']()}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const TagPageListHeader = ({
|
||||
tag,
|
||||
workspaceId,
|
||||
}: {
|
||||
tag: Tag;
|
||||
workspaceId: string;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const { jumpToTags, jumpToCollection } = useNavigateHelper();
|
||||
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||
const { open, node } = useEditCollectionName({
|
||||
title: t['com.affine.editCollection.saveCollection'](),
|
||||
showTips: true,
|
||||
});
|
||||
|
||||
const handleJumpToTags = useCallback(() => {
|
||||
jumpToTags(workspaceId);
|
||||
}, [jumpToTags, workspaceId]);
|
||||
|
||||
const saveToCollection = useCallback(
|
||||
(collection: Collection) => {
|
||||
setting.createCollection({
|
||||
...collection,
|
||||
filterList: [createTagFilter(tag.id)],
|
||||
});
|
||||
jumpToCollection(workspaceId, collection.id);
|
||||
},
|
||||
[setting, tag.id, jumpToCollection, workspaceId]
|
||||
);
|
||||
const handleClick = useCallback(() => {
|
||||
open('')
|
||||
.then(name => {
|
||||
return saveToCollection(createEmptyCollection(nanoid(), { name }));
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [open, saveToCollection]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{node}
|
||||
<div className={styles.docListHeader}>
|
||||
<div className={styles.docListHeaderTitle}>
|
||||
<div
|
||||
style={{ cursor: 'pointer', lineHeight: '1.4em' }}
|
||||
onClick={handleJumpToTags}
|
||||
>
|
||||
{t['Tags']()} /
|
||||
</div>
|
||||
<div className={styles.tagSticky}>
|
||||
<div
|
||||
className={styles.tagIndicator}
|
||||
style={{
|
||||
backgroundColor: tagColorMap(tag.color),
|
||||
}}
|
||||
/>
|
||||
<div className={styles.tagLabel}>{tag.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button className={styles.addPageButton} onClick={handleClick}>
|
||||
{t['com.affine.editCollection.saveCollection']()}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,181 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
height: '54px', // 42 + 12
|
||||
flexShrink: 0,
|
||||
width: '100%',
|
||||
alignItems: 'stretch',
|
||||
transition: 'background-color 0.2s, opacity 0.2s',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
overflow: 'hidden',
|
||||
cursor: 'default',
|
||||
willChange: 'opacity',
|
||||
selectors: {
|
||||
'&[data-clickable=true]': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const dragOverlay = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
zIndex: 1001,
|
||||
cursor: 'grabbing',
|
||||
maxWidth: '360px',
|
||||
transition: 'transform 0.2s',
|
||||
willChange: 'transform',
|
||||
selectors: {
|
||||
'&[data-over=true]': {
|
||||
transform: 'scale(0.8)',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const dragPageItemOverlay = style({
|
||||
height: '54px',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: 'var(--affine-hover-color-filled)',
|
||||
boxShadow: 'var(--affine-menu-shadow)',
|
||||
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: 'var(--affine-placeholder-color)',
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
willChange: 'height, opacity',
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// todo: remove global style
|
||||
globalStyle(`${root} > :first-child`, {
|
||||
paddingLeft: '16px',
|
||||
});
|
||||
|
||||
globalStyle(`${root} > :last-child`, {
|
||||
paddingRight: '8px',
|
||||
});
|
||||
|
||||
export const titleIconsWrapper = style({
|
||||
padding: '0 5px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
});
|
||||
|
||||
export const selectionCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
fontSize: 'var(--affine-font-h-3)',
|
||||
});
|
||||
|
||||
export const titleCell = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
padding: '0 16px',
|
||||
maxWidth: 'calc(100% - 64px)',
|
||||
flex: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const titleCellMain = style({
|
||||
overflow: 'hidden',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
textOverflow: 'ellipsis',
|
||||
alignSelf: 'stretch',
|
||||
});
|
||||
|
||||
export const titleCellPreview = style({
|
||||
overflow: 'hidden',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
flex: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
alignSelf: 'stretch',
|
||||
});
|
||||
|
||||
export const iconCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-h-3)',
|
||||
color: 'var(--affine-icon-color)',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const tagsCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
padding: '0 8px',
|
||||
height: '60px',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const dateCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
flexShrink: 0,
|
||||
flexWrap: 'nowrap',
|
||||
padding: '0 8px',
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
@ -4,12 +4,12 @@ import { useDraggable } from '@dnd-kit/core';
|
||||
import { type PropsWithChildren, useCallback, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { DraggableTitleCellData, PageListItemProps } from '../types';
|
||||
import { ColWrapper, formatDate, stopPropagation } from '../utils';
|
||||
import * as styles from './page-list-item.css';
|
||||
import { PageTags } from './page-tags';
|
||||
import type { DraggableTitleCellData, PageListItemProps } from './types';
|
||||
import { ColWrapper, formatDate, stopPropagation } from './utils';
|
||||
|
||||
const PageListTitleCell = ({
|
||||
const ListTitleCell = ({
|
||||
title,
|
||||
preview,
|
||||
}: Pick<PageListItemProps, 'title' | 'preview'>) => {
|
||||
@ -34,7 +34,7 @@ const PageListTitleCell = ({
|
||||
);
|
||||
};
|
||||
|
||||
const PageListIconCell = ({ icon }: Pick<PageListItemProps, 'icon'>) => {
|
||||
const ListIconCell = ({ icon }: Pick<PageListItemProps, 'icon'>) => {
|
||||
return (
|
||||
<div data-testid="page-list-item-icon" className={styles.iconCell}>
|
||||
{icon}
|
||||
@ -128,9 +128,9 @@ export const PageListItem = (props: PageListItemProps) => {
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<PageListIconCell icon={props.icon} />
|
||||
<ListIconCell icon={props.icon} />
|
||||
</div>
|
||||
<PageListTitleCell title={props.title} preview={props.preview} />
|
||||
<ListTitleCell title={props.title} preview={props.preview} />
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
@ -174,9 +174,9 @@ export const PageListItem = (props: PageListItemProps) => {
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<PageListIconCell icon={props.icon} />
|
||||
<ListIconCell icon={props.icon} />
|
||||
</div>
|
||||
<PageListTitleCell title={props.title} preview={props.preview} />
|
||||
<ListTitleCell title={props.title} preview={props.preview} />
|
||||
</ColWrapper>
|
||||
<ColWrapper flex={4} alignment="end" style={{ overflow: 'visible' }}>
|
||||
<PageTagsCell tags={props.tags} />
|
@ -0,0 +1,6 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const newPageButtonLabel = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
@ -0,0 +1,35 @@
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
|
||||
import { NewPageButton } from '../components/new-page-button';
|
||||
import * as styles from './page-list-new-page-button.css';
|
||||
|
||||
export const PageListNewPageButton = ({
|
||||
className,
|
||||
children,
|
||||
size,
|
||||
testId,
|
||||
}: PropsWithChildren<{
|
||||
className?: string;
|
||||
size?: 'small' | 'default';
|
||||
testId?: string;
|
||||
}>) => {
|
||||
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
||||
const { importFile, createEdgeless, createPage } = usePageHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
return (
|
||||
<div className={className} data-testid={testId}>
|
||||
<NewPageButton
|
||||
size={size}
|
||||
importFile={importFile}
|
||||
createNewEdgeless={createEdgeless}
|
||||
createNewPage={createPage}
|
||||
>
|
||||
<div className={styles.newPageButtonLabel}>{children}</div>
|
||||
</NewPageButton>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -5,8 +5,8 @@ import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import clsx from 'clsx';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { stopPropagation, tagColorMap } from '../utils';
|
||||
import * as styles from './page-tags.css';
|
||||
import { stopPropagation } from './utils';
|
||||
|
||||
export interface PageTagsProps {
|
||||
tags: Tag[];
|
||||
@ -22,24 +22,7 @@ interface TagItemProps {
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
|
||||
const tagColorMap = (color: string) => {
|
||||
const mapping: Record<string, string> = {
|
||||
'var(--affine-tag-red)': 'var(--affine-palette-line-red)',
|
||||
'var(--affine-tag-teal)': 'var(--affine-palette-line-green)',
|
||||
'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)',
|
||||
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
|
||||
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
|
||||
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
|
||||
'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)',
|
||||
'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)',
|
||||
'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)',
|
||||
'var(--affine-tag-green)': 'var(--affine-palette-line-green)',
|
||||
};
|
||||
return mapping[color] || color;
|
||||
};
|
||||
|
||||
const TagItem = ({ tag, idx, mode, style }: TagItemProps) => {
|
||||
export const TagItem = ({ tag, idx, mode, style }: TagItemProps) => {
|
||||
return (
|
||||
<div
|
||||
data-testid="page-tag"
|
@ -0,0 +1,210 @@
|
||||
import { toast } from '@affine/component';
|
||||
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
|
||||
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
|
||||
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type { PageMeta, Tag } from '@blocksuite/store';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
|
||||
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
|
||||
import { pageHeaderColsDef } 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 { useFilteredPageMetas } from '../use-filtered-page-metas';
|
||||
import type { AllPageListConfig } from '../view/edit-collection/edit-collection';
|
||||
import { VirtualizedList } from '../virtualized-list';
|
||||
import {
|
||||
CollectionPageListHeader,
|
||||
PageListHeader,
|
||||
TagPageListHeader,
|
||||
} from './page-list-header';
|
||||
|
||||
const usePageOperationsRenderer = () => {
|
||||
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
||||
const { setTrashModal } = useTrashModalHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const { toggleFavorite } = useBlockSuiteMetaHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const t = useAFFiNEI18N();
|
||||
const pageOperationsRenderer = useCallback(
|
||||
(page: PageMeta) => {
|
||||
const onDisablePublicSharing = () => {
|
||||
toast('Successfully disabled', {
|
||||
portal: document.body,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<PageOperationCell
|
||||
favorite={!!page.favorite}
|
||||
isPublic={!!page.isPublic}
|
||||
onDisablePublicSharing={onDisablePublicSharing}
|
||||
link={`/workspace/${currentWorkspace.id}/${page.id}`}
|
||||
onRemoveToTrash={() =>
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageIds: [page.id],
|
||||
pageTitles: [page.title],
|
||||
})
|
||||
}
|
||||
onToggleFavoritePage={() => {
|
||||
const status = page.favorite;
|
||||
toggleFavorite(page.id);
|
||||
toast(
|
||||
status
|
||||
? t['com.affine.toastMessage.removedFavorites']()
|
||||
: t['com.affine.toastMessage.addedFavorites']()
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[currentWorkspace.id, setTrashModal, t, toggleFavorite]
|
||||
);
|
||||
|
||||
return pageOperationsRenderer;
|
||||
};
|
||||
|
||||
export const VirtualizedPageList = ({
|
||||
tag,
|
||||
collection,
|
||||
config,
|
||||
listItem,
|
||||
setHideHeaderCreateNewPage,
|
||||
}: {
|
||||
tag?: Tag;
|
||||
collection?: Collection;
|
||||
config?: AllPageListConfig;
|
||||
listItem?: PageMeta[];
|
||||
setHideHeaderCreateNewPage: (hide: boolean) => void;
|
||||
}) => {
|
||||
const listRef = useRef<ItemListHandle>(null);
|
||||
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
|
||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
||||
const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
|
||||
const pageOperations = usePageOperationsRenderer();
|
||||
const { isPreferredEdgeless } = usePageHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
|
||||
const filteredPageMetas = useFilteredPageMetas(
|
||||
'all',
|
||||
pageMetas,
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const pageMetasToRender = useMemo(() => {
|
||||
if (listItem) {
|
||||
return listItem;
|
||||
}
|
||||
return filteredPageMetas;
|
||||
}, [filteredPageMetas, listItem]);
|
||||
|
||||
const filteredSelectedPageIds = useMemo(() => {
|
||||
const ids = pageMetasToRender.map(page => page.id);
|
||||
return selectedPageIds.filter(id => ids.includes(id));
|
||||
}, [pageMetasToRender, selectedPageIds]);
|
||||
|
||||
const hideFloatingToolbar = useCallback(() => {
|
||||
listRef.current?.toggleSelectable();
|
||||
}, []);
|
||||
|
||||
const pageOperationRenderer = useCallback(
|
||||
(item: ListItem) => {
|
||||
const page = item as PageMeta;
|
||||
return pageOperations(page);
|
||||
},
|
||||
[pageOperations]
|
||||
);
|
||||
|
||||
const pageHeaderRenderer = useCallback(() => {
|
||||
return <ListTableHeader headerCols={pageHeaderColsDef} />;
|
||||
}, []);
|
||||
|
||||
const pageItemRenderer = useCallback((item: ListItem) => {
|
||||
return <PageListItemRenderer {...item} />;
|
||||
}, []);
|
||||
|
||||
const heading = useMemo(() => {
|
||||
if (tag) {
|
||||
return <TagPageListHeader workspaceId={currentWorkspace.id} tag={tag} />;
|
||||
}
|
||||
if (collection && config) {
|
||||
return (
|
||||
<CollectionPageListHeader
|
||||
workspaceId={currentWorkspace.id}
|
||||
collection={collection}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <PageListHeader workspaceId={currentWorkspace.id} />;
|
||||
}, [collection, config, currentWorkspace.id, tag]);
|
||||
|
||||
const { setTrashModal } = useTrashModalHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
|
||||
const handleMultiDelete = useCallback(() => {
|
||||
const pageNameMapping = Object.fromEntries(
|
||||
pageMetas.map(meta => [meta.id, meta.title])
|
||||
);
|
||||
|
||||
const pageNames = filteredSelectedPageIds.map(
|
||||
id => pageNameMapping[id] ?? ''
|
||||
);
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageIds: filteredSelectedPageIds,
|
||||
pageTitles: pageNames,
|
||||
});
|
||||
hideFloatingToolbar();
|
||||
}, [filteredSelectedPageIds, hideFloatingToolbar, pageMetas, setTrashModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualizedList
|
||||
ref={listRef}
|
||||
selectable="toggle"
|
||||
draggable
|
||||
atTopThreshold={80}
|
||||
atTopStateChange={setHideHeaderCreateNewPage}
|
||||
onSelectionActiveChange={setShowFloatingToolbar}
|
||||
heading={heading}
|
||||
selectedIds={filteredSelectedPageIds}
|
||||
onSelectedIdsChange={setSelectedPageIds}
|
||||
items={pageMetasToRender}
|
||||
rowAsLink
|
||||
isPreferredEdgeless={isPreferredEdgeless}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
operationsRenderer={pageOperationRenderer}
|
||||
itemRenderer={pageItemRenderer}
|
||||
headerRenderer={pageHeaderRenderer}
|
||||
/>
|
||||
<ListFloatingToolbar
|
||||
open={showFloatingToolbar && filteredSelectedPageIds.length > 0}
|
||||
onDelete={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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,68 @@
|
||||
import { Trans } from '@affine/i18n';
|
||||
|
||||
import { ListHeaderTitleCell } from './page-header';
|
||||
import type { HeaderColDef } from './types';
|
||||
|
||||
export const pageHeaderColsDef: HeaderColDef[] = [
|
||||
{
|
||||
key: 'title',
|
||||
content: <ListHeaderTitleCell />,
|
||||
flex: 6,
|
||||
alignment: 'start',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
content: <Trans i18nKey="Tags" />,
|
||||
flex: 3,
|
||||
alignment: 'end',
|
||||
},
|
||||
{
|
||||
key: 'createDate',
|
||||
content: <Trans i18nKey="Created" />,
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
},
|
||||
{
|
||||
key: 'updatedDate',
|
||||
content: <Trans i18nKey="Updated" />,
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
content: '',
|
||||
flex: 1,
|
||||
alignment: 'end',
|
||||
},
|
||||
];
|
||||
|
||||
export const collectionHeaderColsDef: HeaderColDef[] = [
|
||||
{
|
||||
key: 'title',
|
||||
content: <ListHeaderTitleCell />,
|
||||
flex: 9,
|
||||
alignment: 'start',
|
||||
sortable: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const tagHeaderColsDef: HeaderColDef[] = [
|
||||
{
|
||||
key: 'title',
|
||||
content: <ListHeaderTitleCell />,
|
||||
flex: 8,
|
||||
alignment: 'start',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
content: '',
|
||||
flex: 1,
|
||||
alignment: 'end',
|
||||
},
|
||||
];
|
@ -1,14 +1,22 @@
|
||||
export * from './collections';
|
||||
export * from './components/favorite-tag';
|
||||
export * from './components/floating-toobar';
|
||||
export * from './components/new-page-buttton';
|
||||
export * from './components/floating-toolbar';
|
||||
export * from './components/new-page-button';
|
||||
export * from './docs';
|
||||
export * from './docs/page-list-item';
|
||||
export * from './docs/page-tags';
|
||||
export * from './filter';
|
||||
export * from './header-col-def';
|
||||
export * from './list';
|
||||
export * from './operation-cell';
|
||||
export * from './operation-menu-items';
|
||||
export * from './page-list';
|
||||
export * from './page-list-item';
|
||||
export * from './page-tags';
|
||||
export * from './page-group';
|
||||
export * from './page-header';
|
||||
export * from './tags';
|
||||
export * from './types';
|
||||
export * from './use-collection-manager';
|
||||
export * from './use-filtered-page-metas';
|
||||
export * from './use-tag-metas';
|
||||
export * from './utils';
|
||||
export * from './view';
|
||||
export * from './virtualized-page-list';
|
||||
export * from './virtualized-list';
|
||||
|
@ -1,72 +1,77 @@
|
||||
import { Trans } from '@affine/i18n';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
|
||||
import type { PageGroupDefinition, PageGroupProps } from './types';
|
||||
import type { ItemGroupDefinition, ItemGroupProps, ListItem } from './types';
|
||||
import { type DateKey } from './types';
|
||||
import { betweenDaysAgo, withinDaysAgo } from './utils';
|
||||
|
||||
// todo: optimize date matchers
|
||||
const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [
|
||||
const getDateGroupDefinitions = <T extends ListItem>(
|
||||
key: DateKey
|
||||
): ItemGroupDefinition<T>[] => [
|
||||
{
|
||||
id: 'today',
|
||||
label: <Trans i18nKey="com.affine.today" />,
|
||||
match: item => withinDaysAgo(new Date(item[key] ?? item.createDate), 1),
|
||||
match: item =>
|
||||
withinDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 1),
|
||||
},
|
||||
{
|
||||
id: 'yesterday',
|
||||
label: <Trans i18nKey="com.affine.yesterday" />,
|
||||
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 1, 2),
|
||||
match: item =>
|
||||
betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 1, 2),
|
||||
},
|
||||
{
|
||||
id: 'last7Days',
|
||||
label: <Trans i18nKey="com.affine.last7Days" />,
|
||||
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 2, 7),
|
||||
match: item =>
|
||||
betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 2, 7),
|
||||
},
|
||||
{
|
||||
id: 'last30Days',
|
||||
label: <Trans i18nKey="com.affine.last30Days" />,
|
||||
match: item =>
|
||||
betweenDaysAgo(new Date(item[key] ?? item.createDate), 7, 30),
|
||||
betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 7, 30),
|
||||
},
|
||||
{
|
||||
id: 'moreThan30Days',
|
||||
label: <Trans i18nKey="com.affine.moreThan30Days" />,
|
||||
match: item => !withinDaysAgo(new Date(item[key] ?? item.createDate), 30),
|
||||
match: item =>
|
||||
!withinDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 30),
|
||||
},
|
||||
];
|
||||
|
||||
const pageGroupDefinitions = {
|
||||
const itemGroupDefinitions = {
|
||||
createDate: getDateGroupDefinitions('createDate'),
|
||||
updatedDate: getDateGroupDefinitions('updatedDate'),
|
||||
// add more here later
|
||||
// todo: some page group definitions maybe dynamic
|
||||
};
|
||||
|
||||
export function pagesToPageGroups(
|
||||
pages: PageMeta[],
|
||||
export function itemsToItemGroups<T extends ListItem>(
|
||||
items: T[],
|
||||
key?: DateKey
|
||||
): PageGroupProps[] {
|
||||
): ItemGroupProps<T>[] {
|
||||
if (!key) {
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
items: pages,
|
||||
allItems: pages,
|
||||
items: items,
|
||||
allItems: items,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// assume pages are already sorted, we will use the page order to determine the group order
|
||||
const groupDefs = pageGroupDefinitions[key];
|
||||
const groups: PageGroupProps[] = [];
|
||||
const groupDefs = itemGroupDefinitions[key];
|
||||
const groups: ItemGroupProps<T>[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
for (const item of items) {
|
||||
// for a single page, there could be multiple groups that it belongs to
|
||||
const matchedGroups = groupDefs.filter(def => def.match(page));
|
||||
const matchedGroups = groupDefs.filter(def => def.match(item));
|
||||
for (const groupDef of matchedGroups) {
|
||||
const group = groups.find(g => g.id === groupDef.id);
|
||||
if (group) {
|
||||
group.items.push(page);
|
||||
group.items.push(item);
|
||||
} else {
|
||||
const label =
|
||||
typeof groupDef.label === 'function'
|
||||
@ -75,8 +80,8 @@ export function pagesToPageGroups(
|
||||
groups.push({
|
||||
id: groupDef.id,
|
||||
label: label,
|
||||
items: [page],
|
||||
allItems: pages,
|
||||
items: [item],
|
||||
allItems: items,
|
||||
});
|
||||
}
|
||||
}
|
69
packages/frontend/core/src/components/page-list/list.css.ts
Normal file
69
packages/frontend/core/src/components/page-list/list.css.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { createContainer, style } from '@vanilla-extract/css';
|
||||
|
||||
import * as itemStyles from './docs/page-list-item.css';
|
||||
|
||||
export const listRootContainer = createContainer('list-root-container');
|
||||
|
||||
export const pageListScrollContainer = style({
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const root = style({
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
containerName: listRootContainer,
|
||||
containerType: 'inline-size',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
});
|
||||
|
||||
export const groupsContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
rowGap: '16px',
|
||||
});
|
||||
|
||||
export const heading = style({});
|
||||
|
||||
export const colWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const hideInSmallContainer = style({
|
||||
'@container': {
|
||||
[`${listRootContainer} (max-width: 800px)`]: {
|
||||
selectors: {
|
||||
'&[data-hide-item="true"]': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const favoriteCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
flexShrink: 0,
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
[`&[data-favorite], ${itemStyles.root}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const clearLinkStyle = style({
|
||||
color: 'inherit',
|
||||
textDecoration: 'none',
|
||||
':visited': {
|
||||
color: 'inherit',
|
||||
},
|
||||
':active': {
|
||||
color: 'inherit',
|
||||
},
|
||||
});
|
@ -12,41 +12,42 @@ import {
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { PageGroup } from './page-group';
|
||||
import { PageListTableHeader } from './page-header';
|
||||
import * as styles from './page-list.css';
|
||||
import { pageHeaderColsDef } from './header-col-def';
|
||||
import * as styles from './list.css';
|
||||
import { ItemGroup } from './page-group';
|
||||
import { ListTableHeader } from './page-header';
|
||||
import {
|
||||
pageGroupsAtom,
|
||||
pageListPropsAtom,
|
||||
PageListProvider,
|
||||
groupsAtom,
|
||||
listPropsAtom,
|
||||
ListProvider,
|
||||
selectionStateAtom,
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
useSetAtom,
|
||||
} from './scoped-atoms';
|
||||
import type { PageListHandle, PageListProps } from './types';
|
||||
import type { ItemListHandle, ListItem, ListProps } from './types';
|
||||
|
||||
/**
|
||||
* Given a list of pages, render a list of pages
|
||||
*/
|
||||
export const PageList = forwardRef<PageListHandle, PageListProps>(
|
||||
function PageList(props, ref) {
|
||||
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 fix type issues later
|
||||
<PageListProvider initialValues={[[pageListPropsAtom, props]]}>
|
||||
<PageListInnerWrapper {...props} handleRef={ref}>
|
||||
<PageListInner {...props} />
|
||||
</PageListInnerWrapper>
|
||||
</PageListProvider>
|
||||
<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
|
||||
// todo: use jotai-effect instead but it seems it does not work with jotai-scope?
|
||||
const usePageSelectionStateEffect = () => {
|
||||
const useItemSelectionStateEffect = () => {
|
||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||
useEffect(() => {
|
||||
if (
|
||||
@ -96,23 +97,22 @@ const usePageSelectionStateEffect = () => {
|
||||
]);
|
||||
};
|
||||
|
||||
export const PageListInnerWrapper = memo(
|
||||
export const ListInnerWrapper = memo(
|
||||
({
|
||||
handleRef,
|
||||
children,
|
||||
onSelectionActiveChange,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
PageListProps & { handleRef: ForwardedRef<PageListHandle> }
|
||||
ListProps<ListItem> & { handleRef: ForwardedRef<ItemListHandle> }
|
||||
>) => {
|
||||
const setPageListPropsAtom = useSetAtom(pageListPropsAtom);
|
||||
const [selectionState, setPageListSelectionState] =
|
||||
useAtom(selectionStateAtom);
|
||||
usePageSelectionStateEffect();
|
||||
const setListPropsAtom = useSetAtom(listPropsAtom);
|
||||
const [selectionState, setListSelectionState] = useAtom(selectionStateAtom);
|
||||
useItemSelectionStateEffect();
|
||||
|
||||
useEffect(() => {
|
||||
setPageListPropsAtom(props);
|
||||
}, [props, setPageListPropsAtom]);
|
||||
setListPropsAtom(props);
|
||||
}, [props, setListPropsAtom]);
|
||||
|
||||
useEffect(() => {
|
||||
onSelectionActiveChange?.(!!selectionState.selectionActive);
|
||||
@ -123,41 +123,42 @@ export const PageListInnerWrapper = memo(
|
||||
() => {
|
||||
return {
|
||||
toggleSelectable: () => {
|
||||
setPageListSelectionState(false);
|
||||
setListSelectionState(false);
|
||||
},
|
||||
};
|
||||
},
|
||||
[setPageListSelectionState]
|
||||
[setListSelectionState]
|
||||
);
|
||||
return children;
|
||||
}
|
||||
);
|
||||
|
||||
PageListInnerWrapper.displayName = 'PageListInnerWrapper';
|
||||
ListInnerWrapper.displayName = 'ListInnerWrapper';
|
||||
|
||||
const ListInner = (props: ListProps<ListItem>) => {
|
||||
const groups = useAtomValue(groupsAtom);
|
||||
|
||||
const PageListInner = (props: PageListProps) => {
|
||||
const groups = useAtomValue(pageGroupsAtom);
|
||||
const hideHeader = props.hideHeader;
|
||||
return (
|
||||
<div className={clsx(props.className, styles.root)}>
|
||||
{!hideHeader ? <PageListTableHeader /> : null}
|
||||
{!hideHeader ? <ListTableHeader headerCols={pageHeaderColsDef} /> : null}
|
||||
<div className={styles.groupsContainer}>
|
||||
{groups.map(group => (
|
||||
<PageGroup key={group.id} {...group} />
|
||||
<ItemGroup key={group.id} {...group} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageListScrollContainerProps {
|
||||
interface ListScrollContainerProps {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const PageListScrollContainer = forwardRef<
|
||||
export const ListScrollContainer = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<PageListScrollContainerProps>
|
||||
PropsWithChildren<ListScrollContainerProps>
|
||||
>(({ className, children, style }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasScrollTop = useHasScrollTop(containerRef);
|
||||
@ -188,4 +189,4 @@ export const PageListScrollContainer = forwardRef<
|
||||
);
|
||||
});
|
||||
|
||||
PageListScrollContainer.displayName = 'PageListScrollContainer';
|
||||
ListScrollContainer.displayName = 'ListScrollContainer';
|
@ -6,24 +6,34 @@ import {
|
||||
MenuItem,
|
||||
Tooltip,
|
||||
} from '@affine/component';
|
||||
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
DeleteIcon,
|
||||
DeletePermanentlyIcon,
|
||||
EditIcon,
|
||||
FavoritedIcon,
|
||||
FavoriteIcon,
|
||||
FilterIcon,
|
||||
MoreVerticalIcon,
|
||||
OpenInNewIcon,
|
||||
ResetIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { FavoriteTag } from './components/favorite-tag';
|
||||
import * as styles from './list.css';
|
||||
import { DisablePublicSharing, MoveToTrash } from './operation-menu-items';
|
||||
import * as styles from './page-list.css';
|
||||
import type { useCollectionManager } from './use-collection-manager';
|
||||
import { ColWrapper, stopPropagationWithoutPrevent } from './utils';
|
||||
import {
|
||||
type AllPageListConfig,
|
||||
useEditCollection,
|
||||
useEditCollectionName,
|
||||
} from './view';
|
||||
|
||||
export interface OperationCellProps {
|
||||
export interface PageOperationCellProps {
|
||||
favorite: boolean;
|
||||
isPublic: boolean;
|
||||
link: string;
|
||||
@ -32,14 +42,14 @@ export interface OperationCellProps {
|
||||
onDisablePublicSharing: () => void;
|
||||
}
|
||||
|
||||
export const OperationCell = ({
|
||||
export const PageOperationCell = ({
|
||||
favorite,
|
||||
isPublic,
|
||||
link,
|
||||
onToggleFavoritePage,
|
||||
onRemoveToTrash,
|
||||
onDisablePublicSharing,
|
||||
}: OperationCellProps) => {
|
||||
}: PageOperationCellProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [openDisableShared, setOpenDisableShared] = useState(false);
|
||||
const OperationMenu = (
|
||||
@ -178,3 +188,94 @@ export const TrashOperationCell = ({
|
||||
</ColWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export interface CollectionOperationCellProps {
|
||||
collection: Collection;
|
||||
info: DeleteCollectionInfo;
|
||||
config: AllPageListConfig;
|
||||
setting: ReturnType<typeof useCollectionManager>;
|
||||
}
|
||||
|
||||
export const CollectionOperationCell = ({
|
||||
collection,
|
||||
config,
|
||||
setting,
|
||||
info,
|
||||
}: CollectionOperationCellProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const { open: openEditCollectionModal, node: editModal } =
|
||||
useEditCollection(config);
|
||||
|
||||
const { open: openEditCollectionNameModal, node: editNameModal } =
|
||||
useEditCollectionName({
|
||||
title: t['com.affine.editCollection.renameCollection'](),
|
||||
});
|
||||
|
||||
const handleEditName = useCallback(() => {
|
||||
// use openRenameModal if it is in the sidebar collection list
|
||||
openEditCollectionNameModal(collection.name)
|
||||
.then(name => {
|
||||
return setting.updateCollection({ ...collection, name });
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [collection, openEditCollectionNameModal, setting]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
openEditCollectionModal(collection)
|
||||
.then(collection => {
|
||||
return setting.updateCollection(collection);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [setting, collection, openEditCollectionModal]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
return setting.deleteCollection(info, collection.id);
|
||||
}, [setting, info, collection]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editModal}
|
||||
{editNameModal}
|
||||
<Tooltip content={t['com.affine.collection.menu.rename']()} side="top">
|
||||
<IconButton onClick={handleEditName}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content={t['com.affine.collection.menu.edit']()} side="top">
|
||||
<IconButton onClick={handleEdit}>
|
||||
<FilterIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<ColWrapper alignment="start">
|
||||
<Menu
|
||||
items={
|
||||
<MenuItem
|
||||
onClick={handleDelete}
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<DeleteIcon />
|
||||
</MenuIcon>
|
||||
}
|
||||
type="danger"
|
||||
>
|
||||
{t['Delete']()}
|
||||
</MenuItem>
|
||||
}
|
||||
contentOptions={{
|
||||
align: 'end',
|
||||
}}
|
||||
>
|
||||
<IconButton type="plain">
|
||||
<MoreVerticalIcon />
|
||||
</IconButton>
|
||||
</Menu>
|
||||
</ColWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
PageIcon,
|
||||
TodayIcon,
|
||||
ToggleCollapseIcon,
|
||||
ViewLayersIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
@ -15,21 +16,36 @@ import clsx from 'clsx';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import { type MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
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 { PageListItem } from './page-list-item';
|
||||
import {
|
||||
pageGroupCollapseStateAtom,
|
||||
pageListPropsAtom,
|
||||
groupCollapseStateAtom,
|
||||
listPropsAtom,
|
||||
selectionStateAtom,
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
} from './scoped-atoms';
|
||||
import type { PageGroupProps, PageListItemProps, PageListProps } from './types';
|
||||
import { TagListItem } from './tags/tag-list-item';
|
||||
import type {
|
||||
CollectionListItemProps,
|
||||
CollectionMeta,
|
||||
ItemGroupProps,
|
||||
ListItem,
|
||||
ListProps,
|
||||
PageListItemProps,
|
||||
TagListItemProps,
|
||||
TagMeta,
|
||||
} from './types';
|
||||
import { shallowEqual } from './utils';
|
||||
|
||||
export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
|
||||
const [collapseState, setCollapseState] = useAtom(pageGroupCollapseStateAtom);
|
||||
export const ItemGroupHeader = <T extends ListItem>({
|
||||
id,
|
||||
items,
|
||||
label,
|
||||
}: ItemGroupProps<T>) => {
|
||||
const [collapseState, setCollapseState] = useAtom(groupCollapseStateAtom);
|
||||
const collapsed = collapseState[id];
|
||||
const onExpandedClicked: MouseEventHandler = useCallback(
|
||||
e => {
|
||||
@ -42,9 +58,9 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
|
||||
|
||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||
const selectedItems = useMemo(() => {
|
||||
const selectedPageIds = selectionState.selectedPageIds ?? [];
|
||||
return items.filter(item => selectedPageIds.includes(item.id));
|
||||
}, [items, selectionState.selectedPageIds]);
|
||||
const selectedIds = selectionState.selectedIds ?? [];
|
||||
return items.filter(item => selectedIds.includes(item.id));
|
||||
}, [items, selectionState.selectedIds]);
|
||||
|
||||
const allSelected = selectedItems.length === items.length;
|
||||
|
||||
@ -53,7 +69,7 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
|
||||
setSelectionActive(true);
|
||||
|
||||
const nonCurrentGroupIds =
|
||||
selectionState.selectedPageIds?.filter(
|
||||
selectionState.selectedIds?.filter(
|
||||
id => !items.map(item => item.id).includes(id)
|
||||
) ?? [];
|
||||
|
||||
@ -61,7 +77,7 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
|
||||
? nonCurrentGroupIds
|
||||
: [...nonCurrentGroupIds, ...items.map(item => item.id)];
|
||||
|
||||
selectionState.onSelectedPageIdsChange?.(newSelectedPageIds);
|
||||
selectionState.onSelectedIdsChange?.(newSelectedPageIds);
|
||||
}, [setSelectionActive, selectionState, allSelected, items]);
|
||||
|
||||
const t = useAFFiNEI18N();
|
||||
@ -103,7 +119,11 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
|
||||
) : null;
|
||||
};
|
||||
|
||||
export const PageGroup = ({ id, items, label }: PageGroupProps) => {
|
||||
export const ItemGroup = <T extends ListItem>({
|
||||
id,
|
||||
items,
|
||||
label,
|
||||
}: ItemGroupProps<T>) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const onExpandedClicked: MouseEventHandler = useCallback(e => {
|
||||
e.stopPropagation();
|
||||
@ -112,16 +132,16 @@ export const PageGroup = ({ id, items, label }: PageGroupProps) => {
|
||||
}, []);
|
||||
const selectionState = useAtomValue(selectionStateAtom);
|
||||
const selectedItems = useMemo(() => {
|
||||
const selectedPageIds = selectionState.selectedPageIds ?? [];
|
||||
return items.filter(item => selectedPageIds.includes(item.id));
|
||||
}, [items, selectionState.selectedPageIds]);
|
||||
const selectedIds = selectionState.selectedIds ?? [];
|
||||
return items.filter(item => selectedIds.includes(item.id));
|
||||
}, [items, selectionState.selectedIds]);
|
||||
const onSelectAll = useCallback(() => {
|
||||
const nonCurrentGroupIds =
|
||||
selectionState.selectedPageIds?.filter(
|
||||
selectionState.selectedIds?.filter(
|
||||
id => !items.map(item => item.id).includes(id)
|
||||
) ?? [];
|
||||
|
||||
selectionState.onSelectedPageIdsChange?.([
|
||||
selectionState.onSelectedIdsChange?.([
|
||||
...nonCurrentGroupIds,
|
||||
...items.map(item => item.id),
|
||||
]);
|
||||
@ -164,7 +184,7 @@ export const PageGroup = ({ id, items, label }: PageGroupProps) => {
|
||||
<Collapsible.Content className={styles.collapsibleContent}>
|
||||
<div className={styles.collapsibleContentInner}>
|
||||
{items.map(item => (
|
||||
<PageMetaListItemRenderer key={item.id} {...item} />
|
||||
<PageListItemRenderer key={item.id} {...item} />
|
||||
))}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
@ -177,32 +197,64 @@ const requiredPropNames = [
|
||||
'blockSuiteWorkspace',
|
||||
'rowAsLink',
|
||||
'isPreferredEdgeless',
|
||||
'pageOperationsRenderer',
|
||||
'selectedPageIds',
|
||||
'onSelectedPageIdsChange',
|
||||
'operationsRenderer',
|
||||
'selectedIds',
|
||||
'onSelectedIdsChange',
|
||||
'draggable',
|
||||
] as const;
|
||||
|
||||
type RequiredProps = Pick<PageListProps, (typeof requiredPropNames)[number]> & {
|
||||
type RequiredProps<T> = Pick<
|
||||
ListProps<T>,
|
||||
(typeof requiredPropNames)[number]
|
||||
> & {
|
||||
selectable: boolean;
|
||||
};
|
||||
|
||||
const listPropsAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
const listsPropsAtom = selectAtom(
|
||||
listPropsAtom,
|
||||
props => {
|
||||
return Object.fromEntries(
|
||||
requiredPropNames.map(name => [name, props[name]])
|
||||
) as RequiredProps;
|
||||
) as RequiredProps<ListItem>;
|
||||
},
|
||||
shallowEqual
|
||||
);
|
||||
|
||||
export const PageMetaListItemRenderer = (pageMeta: PageMeta) => {
|
||||
const props = useAtomValue(listPropsAtom);
|
||||
export const PageListItemRenderer = (item: ListItem) => {
|
||||
const props = useAtomValue(listsPropsAtom);
|
||||
const { selectionActive } = useAtomValue(selectionStateAtom);
|
||||
const page = item as PageMeta;
|
||||
return (
|
||||
<PageListItem
|
||||
{...pageMetaToPageItemProp(pageMeta, {
|
||||
{...pageMetaToListItemProp(page, {
|
||||
...props,
|
||||
selectable: !!selectionActive,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollectionListItemRenderer = (item: ListItem) => {
|
||||
const props = useAtomValue(listsPropsAtom);
|
||||
const { selectionActive } = useAtomValue(selectionStateAtom);
|
||||
const collection = item as CollectionMeta;
|
||||
return (
|
||||
<CollectionListItem
|
||||
{...collectionMetaToListItemProp(collection, {
|
||||
...props,
|
||||
selectable: !!selectionActive,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TagListItemRenderer = (item: ListItem) => {
|
||||
const props = useAtomValue(listsPropsAtom);
|
||||
const { selectionActive } = useAtomValue(selectionStateAtom);
|
||||
const tag = item as TagMeta;
|
||||
return (
|
||||
<TagListItem
|
||||
{...tagMetaToListItemProp(tag, {
|
||||
...props,
|
||||
selectable: !!selectionActive,
|
||||
})}
|
||||
@ -231,9 +283,9 @@ const UnifiedPageIcon = ({
|
||||
}: {
|
||||
id: string;
|
||||
workspace: Workspace;
|
||||
isPreferredEdgeless: (id: string) => boolean;
|
||||
isPreferredEdgeless?: (id: string) => boolean;
|
||||
}) => {
|
||||
const isEdgeless = isPreferredEdgeless(id);
|
||||
const isEdgeless = isPreferredEdgeless ? isPreferredEdgeless(id) : false;
|
||||
const { isJournal } = useJournalInfoHelper(workspace, id);
|
||||
if (isJournal) {
|
||||
return <TodayIcon />;
|
||||
@ -241,61 +293,132 @@ const UnifiedPageIcon = ({
|
||||
return isEdgeless ? <EdgelessIcon /> : <PageIcon />;
|
||||
};
|
||||
|
||||
function pageMetaToPageItemProp(
|
||||
pageMeta: PageMeta,
|
||||
props: RequiredProps
|
||||
function pageMetaToListItemProp(
|
||||
item: PageMeta,
|
||||
props: RequiredProps<PageMeta>
|
||||
): PageListItemProps {
|
||||
const toggleSelection = props.onSelectedPageIdsChange
|
||||
const toggleSelection = props.onSelectedIdsChange
|
||||
? () => {
|
||||
assertExists(props.selectedPageIds);
|
||||
const prevSelected = props.selectedPageIds.includes(pageMeta.id);
|
||||
assertExists(props.selectedIds);
|
||||
const prevSelected = props.selectedIds.includes(item.id);
|
||||
const shouldAdd = !prevSelected;
|
||||
const shouldRemove = prevSelected;
|
||||
|
||||
if (shouldAdd) {
|
||||
props.onSelectedPageIdsChange?.([
|
||||
...props.selectedPageIds,
|
||||
pageMeta.id,
|
||||
]);
|
||||
props.onSelectedIdsChange?.([...props.selectedIds, item.id]);
|
||||
} else if (shouldRemove) {
|
||||
props.onSelectedPageIdsChange?.(
|
||||
props.selectedPageIds.filter(id => id !== pageMeta.id)
|
||||
props.onSelectedIdsChange?.(
|
||||
props.selectedIds.filter(id => id !== item.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
const itemProps: PageListItemProps = {
|
||||
pageId: pageMeta.id,
|
||||
title: <PageTitle id={pageMeta.id} workspace={props.blockSuiteWorkspace} />,
|
||||
pageId: item.id,
|
||||
title: <PageTitle id={item.id} workspace={props.blockSuiteWorkspace} />,
|
||||
preview: (
|
||||
<PagePreview workspace={props.blockSuiteWorkspace} pageId={pageMeta.id} />
|
||||
<PagePreview workspace={props.blockSuiteWorkspace} pageId={item.id} />
|
||||
),
|
||||
createDate: new Date(pageMeta.createDate),
|
||||
updatedDate: pageMeta.updatedDate
|
||||
? new Date(pageMeta.updatedDate)
|
||||
: undefined,
|
||||
createDate: new Date(item.createDate),
|
||||
updatedDate: item.updatedDate ? new Date(item.updatedDate) : undefined,
|
||||
to:
|
||||
props.rowAsLink && !props.selectable
|
||||
? `/workspace/${props.blockSuiteWorkspace.id}/${pageMeta.id}`
|
||||
? `/workspace/${props.blockSuiteWorkspace.id}/${item.id}`
|
||||
: undefined,
|
||||
onClick: props.selectable ? toggleSelection : undefined,
|
||||
icon: (
|
||||
<UnifiedPageIcon
|
||||
id={pageMeta.id}
|
||||
id={item.id}
|
||||
workspace={props.blockSuiteWorkspace}
|
||||
isPreferredEdgeless={props.isPreferredEdgeless}
|
||||
/>
|
||||
),
|
||||
tags:
|
||||
pageMeta.tags
|
||||
item.tags
|
||||
?.map(id => tagIdToTagOption(id, props.blockSuiteWorkspace))
|
||||
.filter((v): v is Tag => v != null) ?? [],
|
||||
operations: props.pageOperationsRenderer?.(pageMeta),
|
||||
operations: props.operationsRenderer?.(item),
|
||||
selectable: props.selectable,
|
||||
selected: props.selectedPageIds?.includes(pageMeta.id),
|
||||
selected: props.selectedIds?.includes(item.id),
|
||||
onSelectedChange: toggleSelection,
|
||||
draggable: props.draggable,
|
||||
isPublicPage: !!item.isPublic,
|
||||
};
|
||||
return itemProps;
|
||||
}
|
||||
|
||||
function collectionMetaToListItemProp(
|
||||
item: CollectionMeta,
|
||||
props: RequiredProps<CollectionMeta>
|
||||
): CollectionListItemProps {
|
||||
const toggleSelection = props.onSelectedIdsChange
|
||||
? () => {
|
||||
assertExists(props.selectedIds);
|
||||
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: CollectionListItemProps = {
|
||||
collectionId: item.id,
|
||||
title: item.title,
|
||||
to:
|
||||
props.rowAsLink && !props.selectable
|
||||
? `/workspace/${props.blockSuiteWorkspace.id}/collection/${item.id}`
|
||||
: undefined,
|
||||
onClick: props.selectable ? toggleSelection : undefined,
|
||||
icon: <ViewLayersIcon />,
|
||||
operations: props.operationsRenderer?.(item),
|
||||
selectable: props.selectable,
|
||||
selected: props.selectedIds?.includes(item.id),
|
||||
onSelectedChange: toggleSelection,
|
||||
draggable: props.draggable,
|
||||
};
|
||||
return itemProps;
|
||||
}
|
||||
function tagMetaToListItemProp(
|
||||
item: TagMeta,
|
||||
props: RequiredProps<TagMeta>
|
||||
): TagListItemProps {
|
||||
const toggleSelection = props.onSelectedIdsChange
|
||||
? () => {
|
||||
assertExists(props.selectedIds);
|
||||
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: TagListItemProps = {
|
||||
tagId: item.id,
|
||||
title: item.title,
|
||||
to:
|
||||
props.rowAsLink && !props.selectable
|
||||
? `/workspace/${props.blockSuiteWorkspace.id}/tag/${item.id}`
|
||||
: undefined,
|
||||
onClick: props.selectable ? toggleSelection : undefined,
|
||||
color: item.color,
|
||||
pageCount: item.pageCount,
|
||||
operations: props.operationsRenderer?.(item),
|
||||
selectable: props.selectable,
|
||||
selected: props.selectedIds?.includes(item.id),
|
||||
onSelectedChange: toggleSelection,
|
||||
draggable: props.draggable,
|
||||
isPublicPage: !!pageMeta.isPublic,
|
||||
};
|
||||
return itemProps;
|
||||
}
|
||||
|
@ -0,0 +1,40 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const headerTitleCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const tableHeader = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '10px 6px 10px 16px',
|
||||
position: 'sticky',
|
||||
overflow: 'hidden',
|
||||
zIndex: 1,
|
||||
top: 0,
|
||||
left: 0,
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
transition: 'box-shadow 0.2s ease-in-out',
|
||||
transform: 'translateY(-0.5px)', // fix sticky look through issue
|
||||
});
|
||||
|
||||
globalStyle(`[data-has-scroll-top=true] ${tableHeader}`, {
|
||||
boxShadow: '0 1px var(--affine-border-color)',
|
||||
});
|
||||
|
||||
export const headerTitleSelectionIconWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
fontSize: '16px',
|
||||
selectors: {
|
||||
[`${tableHeader}[data-selectable=toggle] &`]: {
|
||||
width: 32,
|
||||
},
|
||||
[`${tableHeader}[data-selection-active=true] &`]: {
|
||||
width: 24,
|
||||
},
|
||||
},
|
||||
});
|
@ -1,84 +1,31 @@
|
||||
import { Checkbox, type CheckboxProps } from '@affine/component';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { MultiSelectIcon, SortDownIcon, SortUpIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { MultiSelectIcon } from '@blocksuite/icons';
|
||||
import clsx from 'clsx';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import {
|
||||
type MouseEventHandler,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { type MouseEventHandler, useCallback } from 'react';
|
||||
|
||||
import * as styles from './page-list.css';
|
||||
import { ListHeaderCell } from './components/list-header-cell';
|
||||
import * as styles from './page-header.css';
|
||||
import {
|
||||
pageListHandlersAtom,
|
||||
pageListPropsAtom,
|
||||
pagesAtom,
|
||||
itemsAtom,
|
||||
listHandlersAtom,
|
||||
listPropsAtom,
|
||||
selectionStateAtom,
|
||||
showOperationsAtom,
|
||||
sorterAtom,
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
} from './scoped-atoms';
|
||||
import { ColWrapper, type ColWrapperProps, stopPropagation } from './utils';
|
||||
|
||||
export const PageListHeaderCell = (props: HeaderCellProps) => {
|
||||
const [sorter, setSorter] = useAtom(sorterAtom);
|
||||
const onClick: MouseEventHandler = useCallback(() => {
|
||||
if (props.sortable && props.sortKey) {
|
||||
setSorter({
|
||||
newSortKey: props.sortKey,
|
||||
});
|
||||
}
|
||||
}, [props.sortKey, props.sortable, setSorter]);
|
||||
|
||||
const sorting = sorter.key === props.sortKey;
|
||||
|
||||
return (
|
||||
<ColWrapper
|
||||
flex={props.flex}
|
||||
alignment={props.alignment}
|
||||
onClick={onClick}
|
||||
className={styles.headerCell}
|
||||
data-sortable={props.sortable ? true : undefined}
|
||||
data-sorting={sorting ? true : undefined}
|
||||
style={props.style}
|
||||
role="columnheader"
|
||||
hideInSmallContainer={props.hideInSmallContainer}
|
||||
>
|
||||
{props.children}
|
||||
{sorting ? (
|
||||
<div className={styles.headerCellSortIcon}>
|
||||
{sorter.order === 'asc' ? <SortUpIcon /> : <SortDownIcon />}
|
||||
</div>
|
||||
) : null}
|
||||
</ColWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
type HeaderColDef = {
|
||||
key: string;
|
||||
content: ReactNode;
|
||||
flex: ColWrapperProps['flex'];
|
||||
alignment?: ColWrapperProps['alignment'];
|
||||
sortable?: boolean;
|
||||
hideInSmallContainer?: boolean;
|
||||
};
|
||||
|
||||
type HeaderCellProps = ColWrapperProps & {
|
||||
sortKey: keyof PageMeta;
|
||||
sortable?: boolean;
|
||||
};
|
||||
import type { HeaderColDef, ListItem } from './types';
|
||||
import { stopPropagation } from './utils';
|
||||
|
||||
// the checkbox on the header has three states:
|
||||
// when list selectable = true, the checkbox will be presented
|
||||
// when internal selection state is not enabled, it is a clickable <ListIcon /> that enables the selection state
|
||||
// when internal selection state is enabled, it is a checkbox that reflects the selection state
|
||||
const PageListHeaderCheckbox = () => {
|
||||
const ListHeaderCheckbox = () => {
|
||||
const [selectionState, setSelectionState] = useAtom(selectionStateAtom);
|
||||
const pages = useAtomValue(pagesAtom);
|
||||
const items = useAtomValue(itemsAtom);
|
||||
const onActivateSelection: MouseEventHandler = useCallback(
|
||||
e => {
|
||||
stopPropagation(e);
|
||||
@ -86,13 +33,13 @@ const PageListHeaderCheckbox = () => {
|
||||
},
|
||||
[setSelectionState]
|
||||
);
|
||||
const handlers = useAtomValue(pageListHandlersAtom);
|
||||
const handlers = useAtomValue(listHandlersAtom);
|
||||
const onChange: NonNullable<CheckboxProps['onChange']> = useCallback(
|
||||
(e, checked) => {
|
||||
stopPropagation(e);
|
||||
handlers.onSelectedPageIdsChange?.(checked ? pages.map(p => p.id) : []);
|
||||
handlers.onSelectedIdsChange?.(checked ? items.map(i => i.id) : []);
|
||||
},
|
||||
[handlers, pages]
|
||||
[handlers, items]
|
||||
);
|
||||
|
||||
if (!selectionState.selectable) {
|
||||
@ -109,11 +56,11 @@ const PageListHeaderCheckbox = () => {
|
||||
<MultiSelectIcon />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectionState.selectedPageIds?.length === pages.length}
|
||||
checked={selectionState.selectedIds?.length === items.length}
|
||||
indeterminate={
|
||||
selectionState.selectedPageIds &&
|
||||
selectionState.selectedPageIds.length > 0 &&
|
||||
selectionState.selectedPageIds.length < pages.length
|
||||
selectionState.selectedIds &&
|
||||
selectionState.selectedIds.length > 0 &&
|
||||
selectionState.selectedIds.length < items.length
|
||||
}
|
||||
onChange={onChange}
|
||||
/>
|
||||
@ -122,64 +69,37 @@ const PageListHeaderCheckbox = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const PageListHeaderTitleCell = () => {
|
||||
export const ListHeaderTitleCell = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div className={styles.headerTitleCell}>
|
||||
<PageListHeaderCheckbox />
|
||||
<ListHeaderCheckbox />
|
||||
{t['Title']()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const hideHeaderAtom = selectAtom(pageListPropsAtom, props => props.hideHeader);
|
||||
const hideHeaderAtom = selectAtom(listPropsAtom, props => props.hideHeader);
|
||||
|
||||
// the table header for page list
|
||||
export const PageListTableHeader = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const showOperations = useAtomValue(showOperationsAtom);
|
||||
export const ListTableHeader = ({
|
||||
headerCols,
|
||||
}: {
|
||||
headerCols: HeaderColDef[];
|
||||
}) => {
|
||||
const [sorter, setSorter] = useAtom(sorterAtom);
|
||||
const hideHeader = useAtomValue(hideHeaderAtom);
|
||||
const selectionState = useAtomValue(selectionStateAtom);
|
||||
const headerCols = useMemo(() => {
|
||||
const cols: (HeaderColDef | boolean)[] = [
|
||||
{
|
||||
key: 'title',
|
||||
content: <PageListHeaderTitleCell />,
|
||||
flex: 6,
|
||||
alignment: 'start',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
content: t['Tags'](),
|
||||
flex: 3,
|
||||
alignment: 'end',
|
||||
},
|
||||
{
|
||||
key: 'createDate',
|
||||
content: t['Created'](),
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
},
|
||||
{
|
||||
key: 'updatedDate',
|
||||
content: t['Updated'](),
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
},
|
||||
showOperations && {
|
||||
key: 'actions',
|
||||
content: '',
|
||||
flex: 1,
|
||||
alignment: 'end',
|
||||
},
|
||||
];
|
||||
return cols.filter((def): def is HeaderColDef => !!def);
|
||||
}, [t, showOperations]);
|
||||
const onSort = useCallback(
|
||||
(sortable?: boolean, sortKey?: keyof ListItem) => {
|
||||
if (sortable && sortKey) {
|
||||
setSorter({
|
||||
newSortKey: sortKey,
|
||||
});
|
||||
}
|
||||
},
|
||||
[setSorter]
|
||||
);
|
||||
|
||||
if (hideHeader) {
|
||||
return false;
|
||||
@ -193,17 +113,20 @@ export const PageListTableHeader = () => {
|
||||
>
|
||||
{headerCols.map(col => {
|
||||
return (
|
||||
<PageListHeaderCell
|
||||
<ListHeaderCell
|
||||
flex={col.flex}
|
||||
alignment={col.alignment}
|
||||
key={col.key}
|
||||
sortKey={col.key as keyof PageMeta}
|
||||
sortKey={col.key as keyof ListItem}
|
||||
sortable={col.sortable}
|
||||
sorting={sorter.key === col.key}
|
||||
order={sorter.order}
|
||||
onSort={onSort}
|
||||
style={{ overflow: 'visible' }}
|
||||
hideInSmallContainer={col.hideInSmallContainer}
|
||||
>
|
||||
{col.content}
|
||||
</PageListHeaderCell>
|
||||
</ListHeaderCell>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
@ -1,133 +0,0 @@
|
||||
import { createContainer, globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
import * as itemStyles from './page-list-item.css';
|
||||
|
||||
export const listRootContainer = createContainer('list-root-container');
|
||||
|
||||
export const pageListScrollContainer = style({
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const root = style({
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
containerName: listRootContainer,
|
||||
containerType: 'inline-size',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
});
|
||||
|
||||
export const groupsContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
rowGap: '16px',
|
||||
});
|
||||
|
||||
export const heading = style({});
|
||||
|
||||
export const tableHeader = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '10px 6px 10px 16px',
|
||||
position: 'sticky',
|
||||
overflow: 'hidden',
|
||||
zIndex: 1,
|
||||
top: 0,
|
||||
left: 0,
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
transition: 'box-shadow 0.2s ease-in-out',
|
||||
transform: 'translateY(-0.5px)', // fix sticky look through issue
|
||||
});
|
||||
|
||||
globalStyle(`[data-has-scroll-top=true] ${tableHeader}`, {
|
||||
boxShadow: '0 1px var(--affine-border-color)',
|
||||
});
|
||||
|
||||
export const headerCell = style({
|
||||
padding: '0 8px',
|
||||
userSelect: 'none',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
selectors: {
|
||||
'&[data-sorting], &:hover': {
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
},
|
||||
'&[data-sortable]': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'&:not(:last-child)': {
|
||||
borderRight: '1px solid var(--affine-hover-color-filled)',
|
||||
},
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
columnGap: '4px',
|
||||
position: 'relative',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const headerTitleCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const headerTitleSelectionIconWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
fontSize: '16px',
|
||||
selectors: {
|
||||
[`${tableHeader}[data-selectable=toggle] &`]: {
|
||||
width: 32,
|
||||
},
|
||||
[`${tableHeader}[data-selection-active=true] &`]: {
|
||||
width: 24,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const headerCellSortIcon = style({
|
||||
display: 'inline-flex',
|
||||
fontSize: 14,
|
||||
color: 'var(--affine-icon-color)',
|
||||
});
|
||||
|
||||
export const colWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const hideInSmallContainer = style({
|
||||
'@container': {
|
||||
[`${listRootContainer} (max-width: 800px)`]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const favoriteCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
flexShrink: 0,
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
[`&[data-favorite], ${itemStyles.root}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const clearLinkStyle = style({
|
||||
color: 'inherit',
|
||||
textDecoration: 'none',
|
||||
':visited': {
|
||||
color: 'inherit',
|
||||
},
|
||||
':active': {
|
||||
color: 'inherit',
|
||||
},
|
||||
});
|
@ -1,22 +1,22 @@
|
||||
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { atom } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import { createIsolation } from 'jotai-scope';
|
||||
|
||||
import { pagesToPageGroups } from './pages-to-page-group';
|
||||
import { itemsToItemGroups } from './items-to-item-group';
|
||||
import type {
|
||||
PageListProps,
|
||||
PageMetaRecord,
|
||||
VirtualizedPageListProps,
|
||||
ListItem,
|
||||
ListProps,
|
||||
MetaRecord,
|
||||
VirtualizedListProps,
|
||||
} from './types';
|
||||
import { shallowEqual } from './utils';
|
||||
|
||||
// for ease of use in the component tree
|
||||
// note: must use selectAtom to access this atom for efficiency
|
||||
// @ts-expect-error the error is expected but we will assume the default value is always there by using useHydrateAtoms
|
||||
export const pageListPropsAtom = atom<
|
||||
PageListProps & Partial<VirtualizedPageListProps>
|
||||
export const listPropsAtom = atom<
|
||||
ListProps<ListItem> & Partial<VirtualizedListProps<ListItem>>
|
||||
>();
|
||||
|
||||
// whether or not the table is in selection mode (showing selection checkbox & selection floating bar)
|
||||
@ -25,13 +25,13 @@ const selectionActiveAtom = atom(false);
|
||||
export const selectionStateAtom = atom(
|
||||
get => {
|
||||
const baseAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
listPropsAtom,
|
||||
props => {
|
||||
const { selectable, selectedPageIds, onSelectedPageIdsChange } = props;
|
||||
const { selectable, selectedIds, onSelectedIdsChange } = props;
|
||||
return {
|
||||
selectable,
|
||||
selectedPageIds,
|
||||
onSelectedPageIdsChange,
|
||||
selectedIds,
|
||||
onSelectedIdsChange,
|
||||
};
|
||||
},
|
||||
shallowEqual
|
||||
@ -53,50 +53,49 @@ export const selectionStateAtom = atom(
|
||||
|
||||
// id -> isCollapsed
|
||||
// maybe reset on page on unmount?
|
||||
export const pageGroupCollapseStateAtom = atom<Record<string, boolean>>({});
|
||||
export const groupCollapseStateAtom = atom<Record<string, boolean>>({});
|
||||
|
||||
// get handlers from pageListPropsAtom
|
||||
export const pageListHandlersAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
export const listHandlersAtom = selectAtom(
|
||||
listPropsAtom,
|
||||
props => {
|
||||
const { onSelectedPageIdsChange } = props;
|
||||
const { onSelectedIdsChange } = props;
|
||||
return {
|
||||
onSelectedPageIdsChange,
|
||||
onSelectedIdsChange,
|
||||
};
|
||||
},
|
||||
shallowEqual
|
||||
);
|
||||
|
||||
export const pagesAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
props => props.pages,
|
||||
export const itemsAtom = selectAtom(
|
||||
listPropsAtom,
|
||||
props => props.items,
|
||||
shallowEqual
|
||||
);
|
||||
|
||||
export const showOperationsAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
props => !!props.pageOperationsRenderer
|
||||
listPropsAtom,
|
||||
props => !!props.operationsRenderer
|
||||
);
|
||||
|
||||
type SortingContext<T extends string | number | symbol> = {
|
||||
key: T;
|
||||
type SortingContext<KeyType extends string | number | symbol> = {
|
||||
key: KeyType;
|
||||
order: 'asc' | 'desc';
|
||||
fallbackKey?: T;
|
||||
fallbackKey?: KeyType;
|
||||
};
|
||||
|
||||
type SorterConfig<T extends Record<string, unknown> = Record<string, unknown>> =
|
||||
{
|
||||
key?: keyof T;
|
||||
order: 'asc' | 'desc';
|
||||
sortingFn: (ctx: SortingContext<keyof T>, a: T, b: T) => number;
|
||||
};
|
||||
type SorterConfig<T> = {
|
||||
key?: keyof T;
|
||||
order: 'asc' | 'desc';
|
||||
sortingFn: (ctx: SortingContext<keyof T>, a: T, b: T) => number;
|
||||
};
|
||||
|
||||
const defaultSortingFn: SorterConfig<PageMetaRecord>['sortingFn'] = (
|
||||
const defaultSortingFn: SorterConfig<MetaRecord<ListItem>>['sortingFn'] = (
|
||||
ctx,
|
||||
a,
|
||||
b
|
||||
) => {
|
||||
const val = (obj: PageMetaRecord) => {
|
||||
const val = (obj: MetaRecord<ListItem>) => {
|
||||
let v = obj[ctx.key];
|
||||
if (v === undefined && ctx.fallbackKey) {
|
||||
v = obj[ctx.fallbackKey];
|
||||
@ -134,7 +133,14 @@ const defaultSortingFn: SorterConfig<PageMetaRecord>['sortingFn'] = (
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sorterStateAtom = atom<SorterConfig<PageMetaRecord>>({
|
||||
const validKeys: Array<keyof MetaRecord<ListItem>> = [
|
||||
'id',
|
||||
'title',
|
||||
'createDate',
|
||||
'updatedDate',
|
||||
];
|
||||
|
||||
const sorterStateAtom = atom<SorterConfig<MetaRecord<ListItem>>>({
|
||||
key: DEFAULT_SORT_KEY,
|
||||
order: 'desc',
|
||||
sortingFn: defaultSortingFn,
|
||||
@ -142,47 +148,45 @@ const sorterStateAtom = atom<SorterConfig<PageMetaRecord>>({
|
||||
|
||||
export const sorterAtom = atom(
|
||||
get => {
|
||||
let pages = get(pagesAtom);
|
||||
let items = get(itemsAtom);
|
||||
const sorterState = get(sorterStateAtom);
|
||||
const sortCtx: SortingContext<keyof PageMetaRecord> | null = sorterState.key
|
||||
? {
|
||||
key: sorterState.key,
|
||||
order: sorterState.order,
|
||||
}
|
||||
: null;
|
||||
const sortCtx: SortingContext<keyof MetaRecord<ListItem>> | null =
|
||||
sorterState.key
|
||||
? {
|
||||
key: sorterState.key,
|
||||
order: sorterState.order,
|
||||
}
|
||||
: null;
|
||||
if (sortCtx) {
|
||||
if (sorterState.key === 'updatedDate') {
|
||||
sortCtx.fallbackKey = 'createDate';
|
||||
}
|
||||
const compareFn = (a: PageMetaRecord, b: PageMetaRecord) =>
|
||||
const compareFn = (a: MetaRecord<ListItem>, b: MetaRecord<ListItem>) =>
|
||||
sorterState.sortingFn(sortCtx, a, b);
|
||||
pages = [...pages].sort(compareFn);
|
||||
items = [...items].sort(compareFn);
|
||||
}
|
||||
return {
|
||||
pages,
|
||||
items,
|
||||
...sortCtx,
|
||||
};
|
||||
},
|
||||
(_get, set, { newSortKey }: { newSortKey: keyof PageMeta }) => {
|
||||
(_get, set, { newSortKey }: { newSortKey: keyof MetaRecord<ListItem> }) => {
|
||||
set(sorterStateAtom, sorterState => {
|
||||
if (sorterState.key === newSortKey) {
|
||||
if (validKeys.includes(newSortKey)) {
|
||||
return {
|
||||
...sorterState,
|
||||
order: sorterState.order === 'asc' ? 'desc' : 'asc',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
key: newSortKey,
|
||||
order: 'desc',
|
||||
order: sorterState.order === 'asc' ? 'desc' : 'asc',
|
||||
sortingFn: sorterState.sortingFn,
|
||||
};
|
||||
}
|
||||
return sorterState;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const pageGroupsAtom = atom(get => {
|
||||
let groupBy = get(selectAtom(pageListPropsAtom, props => props.groupBy));
|
||||
export const groupsAtom = atom(get => {
|
||||
let groupBy = get(selectAtom(listPropsAtom, props => props.groupBy));
|
||||
const sorter = get(sorterAtom);
|
||||
|
||||
if (groupBy === false) {
|
||||
@ -196,11 +200,11 @@ export const pageGroupsAtom = atom(get => {
|
||||
? DEFAULT_SORT_KEY
|
||||
: undefined;
|
||||
}
|
||||
return pagesToPageGroups(sorter.pages, groupBy);
|
||||
return itemsToItemGroups<ListItem>(sorter.items, groupBy);
|
||||
});
|
||||
|
||||
export const {
|
||||
Provider: PageListProvider,
|
||||
Provider: ListProvider,
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
useSetAtom,
|
||||
|
@ -0,0 +1,3 @@
|
||||
export * from './tag-list-header';
|
||||
export * from './tag-list-item';
|
||||
export * from './virtualized-tag-list';
|
@ -0,0 +1,29 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const tagListHeader = style({
|
||||
height: 100,
|
||||
alignItems: 'center',
|
||||
padding: '48px 16px 20px 24px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
});
|
||||
|
||||
export const tagListHeaderTitle = style({
|
||||
fontSize: 'var(--affine-font-h-5)',
|
||||
fontWeight: 500,
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
export const newTagButton = style({
|
||||
padding: '6px 10px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
fontWeight: 600,
|
||||
height: '32px',
|
||||
});
|
@ -0,0 +1,12 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
|
||||
import * as styles from './tag-list-header.css';
|
||||
|
||||
export const TagListHeader = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div className={styles.tagListHeader}>
|
||||
<div className={styles.tagListHeaderTitle}>{t['Tags']()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,187 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
height: '54px', // 42 + 12
|
||||
flexShrink: 0,
|
||||
width: '100%',
|
||||
alignItems: 'stretch',
|
||||
transition: 'background-color 0.2s, opacity 0.2s',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
overflow: 'hidden',
|
||||
cursor: 'default',
|
||||
willChange: 'opacity',
|
||||
selectors: {
|
||||
'&[data-clickable=true]': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const dragOverlay = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
zIndex: 1001,
|
||||
cursor: 'grabbing',
|
||||
maxWidth: '360px',
|
||||
transition: 'transform 0.2s',
|
||||
willChange: 'transform',
|
||||
selectors: {
|
||||
'&[data-over=true]': {
|
||||
transform: 'scale(0.8)',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const dragPageItemOverlay = style({
|
||||
height: '54px',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: 'var(--affine-hover-color-filled)',
|
||||
boxShadow: 'var(--affine-menu-shadow)',
|
||||
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: 'var(--affine-placeholder-color)',
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
willChange: 'height, opacity',
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// todo: remove global style
|
||||
globalStyle(`${root} > :first-child`, {
|
||||
paddingLeft: '16px',
|
||||
});
|
||||
|
||||
globalStyle(`${root} > :last-child`, {
|
||||
paddingRight: '8px',
|
||||
});
|
||||
|
||||
export const titleIconsWrapper = style({
|
||||
padding: '0 5px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
});
|
||||
|
||||
export const selectionCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
fontSize: 'var(--affine-font-h-3)',
|
||||
});
|
||||
|
||||
export const titleCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
padding: '0 16px',
|
||||
maxWidth: 'calc(100% - 64px)',
|
||||
flex: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const titleCellMain = style({
|
||||
overflow: 'hidden',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
alignSelf: 'stretch',
|
||||
paddingRight: '4px',
|
||||
});
|
||||
|
||||
export const titleCellPreview = style({
|
||||
overflow: 'hidden',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 'var(--affine-font-base)',
|
||||
flex: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
alignSelf: 'stretch',
|
||||
});
|
||||
|
||||
export const iconCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-h-3)',
|
||||
color: 'var(--affine-icon-color)',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const tagsCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
padding: '0 8px',
|
||||
height: '60px',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const dateCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
flexShrink: 0,
|
||||
flexWrap: 'nowrap',
|
||||
padding: '0 8px',
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
export const tagIndicator = style({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
});
|
@ -0,0 +1,197 @@
|
||||
import { Checkbox } from '@affine/component';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { type PropsWithChildren, useCallback, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { DraggableTitleCellData, TagListItemProps } from '../types';
|
||||
import { ColWrapper, stopPropagation, tagColorMap } from '../utils';
|
||||
import * as styles from './tag-list-item.css';
|
||||
|
||||
const TagListTitleCell = ({
|
||||
title,
|
||||
pageCount,
|
||||
}: Pick<TagListItemProps, 'title' | 'pageCount'>) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div data-testid="page-list-item-title" className={styles.titleCell}>
|
||||
<div
|
||||
data-testid="page-list-item-title-text"
|
||||
className={styles.titleCellMain}
|
||||
>
|
||||
{title || t['Untitled']()}
|
||||
</div>
|
||||
<div
|
||||
data-testid="page-list-item-preview-text"
|
||||
className={styles.titleCellPreview}
|
||||
>
|
||||
{`· ${pageCount} doc(s)`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ListIconCell = ({ color }: Pick<TagListItemProps, 'color'>) => {
|
||||
return (
|
||||
<div
|
||||
className={styles.tagIndicator}
|
||||
style={{
|
||||
backgroundColor: tagColorMap(color),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const TagSelectionCell = ({
|
||||
selectable,
|
||||
onSelectedChange,
|
||||
selected,
|
||||
}: Pick<TagListItemProps, '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>
|
||||
);
|
||||
};
|
||||
|
||||
const TagListOperationsCell = ({
|
||||
operations,
|
||||
}: Pick<TagListItemProps, 'operations'>) => {
|
||||
return operations ? (
|
||||
<div onClick={stopPropagation} className={styles.operationsCell}>
|
||||
{operations}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export const TagListItem = (props: TagListItemProps) => {
|
||||
const tagTitleElement = useMemo(() => {
|
||||
return (
|
||||
<div className={styles.dragPageItemOverlay}>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<TagSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<ListIconCell color={props.color} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [props.color, props.onSelectedChange, props.selectable, props.selected]);
|
||||
|
||||
// TODO: use getDropItemId
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: 'tag-list-item-title-' + props.tagId,
|
||||
data: {
|
||||
pageId: props.tagId,
|
||||
pageTitle: tagTitleElement,
|
||||
} satisfies DraggableTitleCellData,
|
||||
disabled: !props.draggable,
|
||||
});
|
||||
|
||||
return (
|
||||
<TagListItemWrapper
|
||||
onClick={props.onClick}
|
||||
to={props.to}
|
||||
tagId={props.tagId}
|
||||
draggable={props.draggable}
|
||||
isDragging={isDragging}
|
||||
>
|
||||
<ColWrapper flex={9}>
|
||||
<ColWrapper
|
||||
className={styles.dndCell}
|
||||
flex={8}
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<TagSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<ListIconCell color={props.color} />
|
||||
</div>
|
||||
<TagListTitleCell title={props.title} pageCount={props.pageCount} />
|
||||
</ColWrapper>
|
||||
<ColWrapper
|
||||
flex={4}
|
||||
alignment="end"
|
||||
style={{ overflow: 'visible' }}
|
||||
></ColWrapper>
|
||||
</ColWrapper>
|
||||
{props.operations ? (
|
||||
<ColWrapper
|
||||
className={styles.actionsCellWrapper}
|
||||
flex={1}
|
||||
alignment="end"
|
||||
>
|
||||
<TagListOperationsCell operations={props.operations} />
|
||||
</ColWrapper>
|
||||
) : null}
|
||||
</TagListItemWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
type TagListWrapperProps = PropsWithChildren<
|
||||
Pick<TagListItemProps, 'to' | 'tagId' | 'onClick' | 'draggable'> & {
|
||||
isDragging: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
function TagListItemWrapper({
|
||||
to,
|
||||
isDragging,
|
||||
tagId,
|
||||
onClick,
|
||||
children,
|
||||
draggable,
|
||||
}: TagListWrapperProps) {
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (onClick) {
|
||||
stopPropagation(e);
|
||||
onClick();
|
||||
}
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
const commonProps = useMemo(
|
||||
() => ({
|
||||
'data-testid': 'tag-list-item',
|
||||
'data-tag-id': tagId,
|
||||
'data-draggable': draggable,
|
||||
className: styles.root,
|
||||
'data-clickable': !!onClick || !!to,
|
||||
'data-dragging': isDragging,
|
||||
onClick: handleClick,
|
||||
}),
|
||||
[tagId, draggable, isDragging, onClick, to, handleClick]
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link {...commonProps} to={to}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return <div {...commonProps}>{children}</div>;
|
||||
}
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import type { Tag } from '@blocksuite/store';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
|
||||
import { tagHeaderColsDef } from '../header-col-def';
|
||||
import { TagListItemRenderer } from '../page-group';
|
||||
import { ListTableHeader } from '../page-header';
|
||||
import type { ItemListHandle, ListItem, TagMeta } from '../types';
|
||||
import { VirtualizedList } from '../virtualized-list';
|
||||
import { TagListHeader } from './tag-list-header';
|
||||
|
||||
export const VirtualizedTagList = ({
|
||||
tags,
|
||||
tagMetas,
|
||||
setHideHeaderCreateNewTag,
|
||||
onTagDelete,
|
||||
}: {
|
||||
tags: Tag[];
|
||||
tagMetas: TagMeta[];
|
||||
setHideHeaderCreateNewTag: (hide: boolean) => void;
|
||||
onTagDelete: (tagIds: string[]) => void;
|
||||
}) => {
|
||||
const listRef = useRef<ItemListHandle>(null);
|
||||
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
||||
|
||||
const filteredSelectedTagIds = useMemo(() => {
|
||||
const ids = tags.map(tag => tag.id);
|
||||
return selectedTagIds.filter(id => ids.includes(id));
|
||||
}, [selectedTagIds, tags]);
|
||||
|
||||
const hideFloatingToolbar = useCallback(() => {
|
||||
listRef.current?.toggleSelectable();
|
||||
}, []);
|
||||
|
||||
const tagOperationRenderer = useCallback(() => {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const tagHeaderRenderer = useCallback(() => {
|
||||
return <ListTableHeader headerCols={tagHeaderColsDef} />;
|
||||
}, []);
|
||||
|
||||
const tagItemRenderer = useCallback((item: ListItem) => {
|
||||
return <TagListItemRenderer {...item} />;
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
onTagDelete(selectedTagIds);
|
||||
hideFloatingToolbar();
|
||||
return;
|
||||
}, [hideFloatingToolbar, onTagDelete, selectedTagIds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualizedList
|
||||
ref={listRef}
|
||||
selectable={false}
|
||||
draggable={false}
|
||||
groupBy={false}
|
||||
atTopThreshold={80}
|
||||
atTopStateChange={setHideHeaderCreateNewTag}
|
||||
onSelectionActiveChange={setShowFloatingToolbar}
|
||||
heading={<TagListHeader />}
|
||||
selectedIds={filteredSelectedTagIds}
|
||||
onSelectedIdsChange={setSelectedTagIds}
|
||||
items={tagMetas}
|
||||
itemRenderer={tagItemRenderer}
|
||||
rowAsLink
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
operationsRenderer={tagOperationRenderer}
|
||||
headerRenderer={tagHeaderRenderer}
|
||||
/>
|
||||
<ListFloatingToolbar
|
||||
open={showFloatingToolbar && selectedTagIds.length > 0}
|
||||
content={
|
||||
<Trans
|
||||
i18nKey="com.affine.tag.toolbar.selected"
|
||||
count={selectedTagIds.length}
|
||||
>
|
||||
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
|
||||
{{ count: selectedTagIds.length } as any}
|
||||
</div>
|
||||
selected
|
||||
</Trans>
|
||||
}
|
||||
onClose={hideFloatingToolbar}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,8 +1,24 @@
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
import type { Collection, Tag } from '@affine/env/filter';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import type { To } from 'react-router-dom';
|
||||
|
||||
export type ListItem = PageMeta | CollectionMeta | TagMeta;
|
||||
|
||||
export interface CollectionMeta extends Collection {
|
||||
title: string;
|
||||
createDate?: Date;
|
||||
updatedDate?: Date;
|
||||
}
|
||||
|
||||
export type TagMeta = {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
pageCount?: number;
|
||||
createDate?: Date;
|
||||
updatedDate?: Date;
|
||||
};
|
||||
// TODO: consider reducing the number of props here
|
||||
// using type instead of interface to make it Record compatible
|
||||
export type PageListItemProps = {
|
||||
@ -23,10 +39,41 @@ export type PageListItemProps = {
|
||||
onSelectedChange?: () => void;
|
||||
};
|
||||
|
||||
export interface PageListHeaderProps {}
|
||||
export type CollectionListItemProps = {
|
||||
collectionId: string;
|
||||
icon: JSX.Element;
|
||||
title: ReactNode; // using ReactNode to allow for rich content rendering
|
||||
createDate?: Date;
|
||||
updatedDate?: Date;
|
||||
to?: To; // whether or not to render this item as a Link
|
||||
draggable?: boolean; // whether or not to allow dragging this item
|
||||
selectable?: boolean; // show selection checkbox
|
||||
selected?: boolean;
|
||||
operations?: ReactNode; // operations to show on the right side of the item
|
||||
onClick?: () => void;
|
||||
onSelectedChange?: () => void;
|
||||
};
|
||||
|
||||
export type TagListItemProps = {
|
||||
tagId: string;
|
||||
color: string;
|
||||
title: ReactNode; // using ReactNode to allow for rich content rendering
|
||||
pageCount?: number;
|
||||
createDate?: Date;
|
||||
updatedDate?: Date;
|
||||
to?: To; // whether or not to render this item as a Link
|
||||
draggable?: boolean; // whether or not to allow dragging this item
|
||||
selectable?: boolean; // show selection checkbox
|
||||
selected?: boolean;
|
||||
operations?: ReactNode; // operations to show on the right side of the item
|
||||
onClick?: () => void;
|
||||
onSelectedChange?: () => void;
|
||||
};
|
||||
|
||||
export interface ItemListHeaderProps {}
|
||||
|
||||
// todo: a temporary solution. may need to be refactored later
|
||||
export type PagesGroupByType = 'createDate' | 'updatedDate'; // todo: can add more later
|
||||
export type ItemGroupByType = 'createDate' | 'updatedDate'; // todo: can add more later
|
||||
|
||||
// todo: a temporary solution. may need to be refactored later
|
||||
export interface SortBy {
|
||||
@ -36,56 +83,75 @@ export interface SortBy {
|
||||
|
||||
export type DateKey = 'createDate' | 'updatedDate';
|
||||
|
||||
export interface PageListProps {
|
||||
export interface ListProps<T> {
|
||||
// required data:
|
||||
pages: PageMeta[];
|
||||
items: T[];
|
||||
blockSuiteWorkspace: Workspace;
|
||||
className?: string;
|
||||
hideHeader?: boolean; // whether or not to hide the header. default is false (showing header)
|
||||
groupBy?: PagesGroupByType | false;
|
||||
isPreferredEdgeless: (pageId: string) => boolean; // determines the icon used for each row
|
||||
groupBy?: ItemGroupByType | false;
|
||||
isPreferredEdgeless?: (pageId: string) => boolean; // determines the icon used for each row
|
||||
rowAsLink?: boolean;
|
||||
selectable?: 'toggle' | boolean; // show selection checkbox. toggle means showing a toggle selection in header on click; boolean == true means showing a selection checkbox for each item
|
||||
selectedPageIds?: string[]; // selected page ids
|
||||
onSelectedPageIdsChange?: (selected: string[]) => void;
|
||||
selectedIds?: string[]; // selected page ids
|
||||
onSelectedIdsChange?: (selected: string[]) => void;
|
||||
onSelectionActiveChange?: (active: boolean) => void;
|
||||
draggable?: boolean; // whether or not to allow dragging this page item
|
||||
// we also need the following to make sure the page list functions properly
|
||||
// maybe we could also give a function to render PageListItem?
|
||||
pageOperationsRenderer?: (page: PageMeta) => ReactNode;
|
||||
operationsRenderer?: (item: T) => ReactNode;
|
||||
}
|
||||
|
||||
export interface VirtualizedPageListProps extends PageListProps {
|
||||
export interface VirtualizedListProps<T> extends ListProps<T> {
|
||||
heading?: ReactNode; // the user provided heading part (non sticky, above the original header)
|
||||
headerRenderer?: () => ReactNode; // the user provided header renderer
|
||||
itemRenderer?: (item: T) => ReactNode; // the user provided item renderer
|
||||
atTopThreshold?: number; // the threshold to determine whether or not the user has scrolled to the top. default is 0
|
||||
atTopStateChange?: (atTop: boolean) => void; // called when the user scrolls to the top or not
|
||||
}
|
||||
|
||||
export interface PageListHandle {
|
||||
export interface ItemListHandle {
|
||||
toggleSelectable: () => void;
|
||||
}
|
||||
|
||||
export interface PageGroupDefinition {
|
||||
export interface ItemGroupDefinition<T> {
|
||||
id: string;
|
||||
// using a function to render custom group header
|
||||
label: (() => ReactNode) | ReactNode;
|
||||
match: (item: PageMeta) => boolean;
|
||||
match: (item: T) => boolean;
|
||||
}
|
||||
|
||||
export interface PageGroupProps {
|
||||
export interface ItemGroupProps<T> {
|
||||
id: string;
|
||||
label?: ReactNode; // if there is no label, it is a default group (without header)
|
||||
items: PageMeta[];
|
||||
allItems: PageMeta[];
|
||||
items: T[];
|
||||
allItems: T[];
|
||||
}
|
||||
|
||||
type MakeRecord<T> = {
|
||||
[P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
export type PageMetaRecord = MakeRecord<PageMeta>;
|
||||
export type MetaRecord<T> = MakeRecord<T>;
|
||||
|
||||
export type DraggableTitleCellData = {
|
||||
pageId: string;
|
||||
pageTitle: ReactNode;
|
||||
};
|
||||
|
||||
export type HeaderColDef = {
|
||||
key: string;
|
||||
content: ReactNode;
|
||||
flex: ColWrapperProps['flex'];
|
||||
alignment?: ColWrapperProps['alignment'];
|
||||
sortable?: boolean;
|
||||
hideInSmallContainer?: boolean;
|
||||
};
|
||||
|
||||
export type ColWrapperProps = PropsWithChildren<{
|
||||
flex?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
alignment?: 'start' | 'center' | 'end';
|
||||
styles?: React.CSSProperties;
|
||||
hideInSmallContainer?: boolean;
|
||||
}> &
|
||||
React.HTMLAttributes<Element>;
|
||||
|
@ -1,16 +1,16 @@
|
||||
import {
|
||||
filterPage,
|
||||
filterPageByRules,
|
||||
useCollectionManager,
|
||||
} from '@affine/core/components/page-list';
|
||||
import { allPageModeSelectAtom } from '@affine/core/atoms';
|
||||
import { collectionsCRUDAtom } from '@affine/core/atoms/collections';
|
||||
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
|
||||
import type { BlockSuiteWorkspace } from '@affine/core/shared';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { allPageModeSelectAtom } from '../../atoms';
|
||||
import { collectionsCRUDAtom } from '../../atoms/collections';
|
||||
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
|
||||
import type { BlockSuiteWorkspace } from '../../shared';
|
||||
import {
|
||||
filterPage,
|
||||
filterPageByRules,
|
||||
useCollectionManager,
|
||||
} from './use-collection-manager';
|
||||
|
||||
export const useFilteredPageMetas = (
|
||||
route: 'all' | 'trash',
|
100
packages/frontend/core/src/components/page-list/use-tag-metas.ts
Normal file
100
packages/frontend/core/src/components/page-list/use-tag-metas.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import type { PageMeta, Tag, Workspace } from '@blocksuite/store';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
interface TagUsageCounts {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
export function useTagMetas(
|
||||
currentWorkspace: Workspace,
|
||||
pageMetas: PageMeta[]
|
||||
) {
|
||||
const tags = useMemo(() => {
|
||||
return currentWorkspace.meta.properties.tags?.options || [];
|
||||
}, [currentWorkspace]);
|
||||
|
||||
const [tagMetas, tagUsageCounts] = useMemo(() => {
|
||||
const tagUsageCounts: TagUsageCounts = {};
|
||||
tags.forEach(tag => {
|
||||
tagUsageCounts[tag.id] = 0;
|
||||
});
|
||||
|
||||
pageMetas.forEach(page => {
|
||||
if (!page.tags) {
|
||||
return;
|
||||
}
|
||||
page.tags.forEach(tagId => {
|
||||
if (Object.prototype.hasOwnProperty.call(tagUsageCounts, tagId)) {
|
||||
tagUsageCounts[tagId]++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const tagsList = tags.map(tag => {
|
||||
return {
|
||||
...tag,
|
||||
title: tag.value,
|
||||
color: tag.color,
|
||||
pageCount: tagUsageCounts[tag.id] || 0,
|
||||
};
|
||||
});
|
||||
|
||||
return [tagsList, tagUsageCounts];
|
||||
}, [tags, pageMetas]);
|
||||
|
||||
const filterPageMetaByTag = useCallback(
|
||||
(tagId: string) => {
|
||||
return pageMetas.filter(page => {
|
||||
return page.tags.includes(tagId);
|
||||
});
|
||||
},
|
||||
[pageMetas]
|
||||
);
|
||||
|
||||
const addNewTag = useCallback(
|
||||
(tag: Tag) => {
|
||||
const newTags = [...tags, tag];
|
||||
currentWorkspace.meta.setProperties({
|
||||
tags: { options: newTags },
|
||||
});
|
||||
},
|
||||
[currentWorkspace.meta, tags]
|
||||
);
|
||||
|
||||
const updateTag = useCallback(
|
||||
(tag: Tag) => {
|
||||
const newTags = tags.map(t => {
|
||||
if (t.id === tag.id) {
|
||||
return tag;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
currentWorkspace.meta.setProperties({
|
||||
tags: { options: newTags },
|
||||
});
|
||||
},
|
||||
[currentWorkspace.meta, tags]
|
||||
);
|
||||
|
||||
const deleteTags = useCallback(
|
||||
(tagIds: string[]) => {
|
||||
const newTags = tags.filter(tag => {
|
||||
return !tagIds.includes(tag.id);
|
||||
});
|
||||
currentWorkspace.meta.setProperties({
|
||||
tags: { options: newTags },
|
||||
});
|
||||
},
|
||||
[currentWorkspace.meta, tags]
|
||||
);
|
||||
|
||||
return {
|
||||
tags,
|
||||
tagMetas,
|
||||
tagUsageCounts,
|
||||
filterPageMetaByTag,
|
||||
addNewTag,
|
||||
updateTag,
|
||||
deleteTags,
|
||||
};
|
||||
}
|
@ -1,11 +1,8 @@
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
type BaseSyntheticEvent,
|
||||
forwardRef,
|
||||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
import { type BaseSyntheticEvent, forwardRef } from 'react';
|
||||
|
||||
import * as styles from './page-list.css';
|
||||
import * as styles from './list.css';
|
||||
import type { ColWrapperProps } from './types';
|
||||
|
||||
export function isToday(date: Date): boolean {
|
||||
const today = new Date();
|
||||
@ -71,14 +68,6 @@ export const formatDate = (date: Date): string => {
|
||||
return `${month}-${day} ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
export type ColWrapperProps = PropsWithChildren<{
|
||||
flex?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
alignment?: 'start' | 'center' | 'end';
|
||||
styles?: React.CSSProperties;
|
||||
hideInSmallContainer?: boolean;
|
||||
}> &
|
||||
React.HTMLAttributes<Element>;
|
||||
|
||||
export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
|
||||
function ColWrapper(
|
||||
{
|
||||
@ -103,11 +92,10 @@ export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
|
||||
flexBasis: flex ? `${(flex / 12) * 100}%` : 'auto',
|
||||
justifyContent: alignment,
|
||||
}}
|
||||
className={clsx(
|
||||
className,
|
||||
styles.colWrapper,
|
||||
hideInSmallContainer ? styles.hideInSmallContainer : null
|
||||
)}
|
||||
data-hide-item={hideInSmallContainer ? true : undefined}
|
||||
className={clsx(className, styles.colWrapper, {
|
||||
[styles.hideInSmallContainer]: hideInSmallContainer,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@ -173,3 +161,20 @@ export function shallowEqual(objA: any, objB: any) {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
|
||||
export const tagColorMap = (color: string) => {
|
||||
const mapping: Record<string, string> = {
|
||||
'var(--affine-tag-red)': 'var(--affine-palette-line-red)',
|
||||
'var(--affine-tag-teal)': 'var(--affine-palette-line-green)',
|
||||
'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)',
|
||||
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
|
||||
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
|
||||
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
|
||||
'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)',
|
||||
'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)',
|
||||
'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)',
|
||||
'var(--affine-tag-green)': 'var(--affine-palette-line-green)',
|
||||
};
|
||||
return mapping[color] || color;
|
||||
};
|
||||
|
@ -38,4 +38,9 @@ export const filterMenuTrigger = style({
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
selectors: {
|
||||
[`&[data-is-hidden="true"]`]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -25,11 +25,13 @@ export const CollectionList = ({
|
||||
propertiesMeta,
|
||||
allPageListConfig,
|
||||
userInfo,
|
||||
disable,
|
||||
}: {
|
||||
setting: ReturnType<typeof useCollectionManager>;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
userInfo: DeleteCollectionInfo;
|
||||
disable?: boolean;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [collection, setCollection] = useState<Collection>();
|
||||
@ -72,6 +74,7 @@ export const CollectionList = ({
|
||||
className={styles.filterMenuTrigger}
|
||||
type="default"
|
||||
icon={<FilterIcon />}
|
||||
data-is-hidden={disable}
|
||||
data-testid="create-first-filter"
|
||||
>
|
||||
{t['com.affine.filter']()}
|
||||
|
@ -8,7 +8,11 @@ import { type ReactNode, useCallback } from 'react';
|
||||
|
||||
import { FilterList } from '../../filter/filter-list';
|
||||
import { VariableSelect } from '../../filter/vars';
|
||||
import { VirtualizedPageList } from '../../virtualized-page-list';
|
||||
import { pageHeaderColsDef } from '../../header-col-def';
|
||||
import { PageListItemRenderer } from '../../page-group';
|
||||
import { ListTableHeader } from '../../page-header';
|
||||
import type { ListItem } from '../../types';
|
||||
import { VirtualizedList } from '../../virtualized-list';
|
||||
import type { AllPageListConfig } from './edit-collection';
|
||||
import * as styles from './edit-collection.css';
|
||||
import { EmptyList } from './select-page';
|
||||
@ -46,9 +50,19 @@ export const PagesMode = ({
|
||||
});
|
||||
}, [collection, updateCollection]);
|
||||
const pageOperationsRenderer = useCallback(
|
||||
(page: PageMeta) => allPageListConfig.favoriteRender(page),
|
||||
(item: ListItem) => {
|
||||
const page = item as PageMeta;
|
||||
return allPageListConfig.favoriteRender(page);
|
||||
},
|
||||
[allPageListConfig]
|
||||
);
|
||||
|
||||
const pageItemRenderer = useCallback((item: ListItem) => {
|
||||
return <PageListItemRenderer {...item} />;
|
||||
}, []);
|
||||
const pageHeaderRenderer = useCallback(() => {
|
||||
return <ListTableHeader headerCols={pageHeaderColsDef} />;
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
@ -102,22 +116,24 @@ export const PagesMode = ({
|
||||
</div>
|
||||
) : null}
|
||||
{searchedList.length ? (
|
||||
<VirtualizedPageList
|
||||
<VirtualizedList
|
||||
className={styles.pageList}
|
||||
pages={searchedList}
|
||||
items={searchedList}
|
||||
groupBy={false}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
selectable
|
||||
onSelectedPageIdsChange={ids => {
|
||||
onSelectedIdsChange={ids => {
|
||||
updateCollection({
|
||||
...collection,
|
||||
allowList: ids,
|
||||
});
|
||||
}}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
selectedPageIds={collection.allowList}
|
||||
itemRenderer={pageItemRenderer}
|
||||
operationsRenderer={pageOperationsRenderer}
|
||||
headerRenderer={pageHeaderRenderer}
|
||||
selectedIds={collection.allowList}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
></VirtualizedPageList>
|
||||
/>
|
||||
) : (
|
||||
<EmptyList search={searchText} />
|
||||
)}
|
||||
|
@ -13,7 +13,8 @@ import clsx from 'clsx';
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { FilterList } from '../../filter';
|
||||
import { PageList, PageListScrollContainer } from '../../page-list';
|
||||
import { List, ListScrollContainer } from '../../list';
|
||||
import type { ListItem } from '../../types';
|
||||
import { filterPageByRules } from '../../use-collection-manager';
|
||||
import { AffineShapeIcon } from '../affine-shape';
|
||||
import type { AllPageListConfig } from './edit-collection';
|
||||
@ -78,6 +79,13 @@ export const RulesMode = ({
|
||||
const [expandInclude, setExpandInclude] = useState(
|
||||
collection.allowList.length > 0
|
||||
);
|
||||
const operationsRenderer = useCallback(
|
||||
(item: ListItem) => {
|
||||
const page = item as PageMeta;
|
||||
return allPageListConfig.favoriteRender(page);
|
||||
},
|
||||
[allPageListConfig]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{/*prevents modal autofocus to the first input*/}
|
||||
@ -241,22 +249,22 @@ export const RulesMode = ({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<PageListScrollContainer
|
||||
<ListScrollContainer
|
||||
className={styles.rulesContainerRight}
|
||||
style={{
|
||||
display: showPreview ? 'flex' : 'none',
|
||||
}}
|
||||
>
|
||||
{rulesPages.length > 0 ? (
|
||||
<PageList
|
||||
<List
|
||||
hideHeader
|
||||
className={styles.resultPages}
|
||||
pages={rulesPages}
|
||||
items={rulesPages}
|
||||
groupBy={false}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
||||
></PageList>
|
||||
operationsRenderer={operationsRenderer}
|
||||
></List>
|
||||
) : (
|
||||
<RulesEmpty
|
||||
noRules={collection.filterList.length === 0}
|
||||
@ -268,18 +276,18 @@ export const RulesMode = ({
|
||||
<div className={styles.includeListTitle}>
|
||||
{t['com.affine.editCollection.rules.include.title']()}
|
||||
</div>
|
||||
<PageList
|
||||
<List
|
||||
hideHeader
|
||||
className={styles.resultPages}
|
||||
pages={allowListPages}
|
||||
items={allowListPages}
|
||||
groupBy={false}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
||||
></PageList>
|
||||
operationsRenderer={operationsRenderer}
|
||||
></List>
|
||||
</div>
|
||||
) : null}
|
||||
</PageListScrollContainer>
|
||||
</ListScrollContainer>
|
||||
</div>
|
||||
<div className={styles.rulesBottom}>
|
||||
<div className={styles.bottomLeft}>
|
||||
|
@ -2,12 +2,14 @@ import { Button, Menu } from '@affine/component';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FilterIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { FilterList } from '../../filter';
|
||||
import { VariableSelect } from '../../filter/vars';
|
||||
import { VirtualizedPageList } from '../../virtualized-page-list';
|
||||
import type { ListItem } from '../../types';
|
||||
import { VirtualizedList } from '../../virtualized-list';
|
||||
import { AffineShapeIcon } from '../affine-shape';
|
||||
import type { AllPageListConfig } from './edit-collection';
|
||||
import * as styles from './edit-collection.css';
|
||||
@ -43,6 +45,15 @@ export const SelectPage = ({
|
||||
} = useFilter(allPageListConfig.allPages);
|
||||
const { searchText, updateSearchText, searchedList } =
|
||||
useSearch(filteredList);
|
||||
|
||||
const operationsRenderer = useCallback(
|
||||
(item: ListItem) => {
|
||||
const page = item as PageMeta;
|
||||
return allPageListConfig.favoriteRender(page);
|
||||
},
|
||||
[allPageListConfig]
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<input
|
||||
@ -94,16 +105,16 @@ export const SelectPage = ({
|
||||
</div>
|
||||
) : null}
|
||||
{searchedList.length ? (
|
||||
<VirtualizedPageList
|
||||
<VirtualizedList
|
||||
className={styles.pageList}
|
||||
pages={searchedList}
|
||||
items={searchedList}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
selectable
|
||||
groupBy={false}
|
||||
onSelectedPageIdsChange={onChange}
|
||||
selectedPageIds={value}
|
||||
onSelectedIdsChange={onChange}
|
||||
selectedIds={value}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
||||
operationsRenderer={operationsRenderer}
|
||||
/>
|
||||
) : (
|
||||
<EmptyList search={searchText} />
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Scrollable } from '@affine/component';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import clsx from 'clsx';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import {
|
||||
@ -12,29 +11,29 @@ import {
|
||||
} from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import { PageGroupHeader, PageMetaListItemRenderer } from './page-group';
|
||||
import { PageListTableHeader } from './page-header';
|
||||
import { PageListInnerWrapper } from './page-list';
|
||||
import * as styles from './page-list.css';
|
||||
import { ListInnerWrapper } from './list';
|
||||
import * as styles from './list.css';
|
||||
import { ItemGroupHeader } from './page-group';
|
||||
import {
|
||||
pageGroupCollapseStateAtom,
|
||||
pageGroupsAtom,
|
||||
pageListPropsAtom,
|
||||
PageListProvider,
|
||||
groupCollapseStateAtom,
|
||||
groupsAtom,
|
||||
listPropsAtom,
|
||||
ListProvider,
|
||||
useAtomValue,
|
||||
} from './scoped-atoms';
|
||||
import type {
|
||||
PageGroupProps,
|
||||
PageListHandle,
|
||||
VirtualizedPageListProps,
|
||||
ItemGroupProps,
|
||||
ItemListHandle,
|
||||
ListItem,
|
||||
VirtualizedListProps,
|
||||
} from './types';
|
||||
|
||||
// we have three item types for rendering rows in Virtuoso
|
||||
type VirtuosoItemType =
|
||||
| 'sticky-header'
|
||||
| 'page-group-header'
|
||||
| 'page-item'
|
||||
| 'page-item-spacer';
|
||||
| 'group-header'
|
||||
| 'item'
|
||||
| 'item-spacer';
|
||||
|
||||
interface BaseVirtuosoItem {
|
||||
type: VirtuosoItemType;
|
||||
@ -44,62 +43,62 @@ interface VirtuosoItemStickyHeader extends BaseVirtuosoItem {
|
||||
type: 'sticky-header';
|
||||
}
|
||||
|
||||
interface VirtuosoItemPageItem extends BaseVirtuosoItem {
|
||||
type: 'page-item';
|
||||
data: PageMeta;
|
||||
interface VirtuosoItemItem<T> extends BaseVirtuosoItem {
|
||||
type: 'item';
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface VirtuosoItemPageGroupHeader extends BaseVirtuosoItem {
|
||||
type: 'page-group-header';
|
||||
data: PageGroupProps;
|
||||
interface VirtuosoItemGroupHeader<T> extends BaseVirtuosoItem {
|
||||
type: 'group-header';
|
||||
data: ItemGroupProps<T>;
|
||||
}
|
||||
|
||||
interface VirtuosoPageItemSpacer extends BaseVirtuosoItem {
|
||||
type: 'page-item-spacer';
|
||||
type: 'item-spacer';
|
||||
data: {
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
type VirtuosoItem =
|
||||
type VirtuosoItem<T> =
|
||||
| VirtuosoItemStickyHeader
|
||||
| VirtuosoItemPageItem
|
||||
| VirtuosoItemPageGroupHeader
|
||||
| VirtuosoItemItem<T>
|
||||
| VirtuosoItemGroupHeader<T>
|
||||
| VirtuosoPageItemSpacer;
|
||||
|
||||
/**
|
||||
* Given a list of pages, render a list of pages
|
||||
* Similar to normal PageList, but uses react-virtuoso to render the list (virtual rendering)
|
||||
*/
|
||||
export const VirtualizedPageList = forwardRef<
|
||||
PageListHandle,
|
||||
VirtualizedPageListProps
|
||||
>(function VirtualizedPageList(props, ref) {
|
||||
export const VirtualizedList = forwardRef<
|
||||
ItemListHandle,
|
||||
VirtualizedListProps<ListItem>
|
||||
>(function VirtualizedList(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 fix type issues later
|
||||
<PageListProvider initialValues={[[pageListPropsAtom, props]]}>
|
||||
<PageListInnerWrapper {...props} handleRef={ref}>
|
||||
<PageListInner {...props} />
|
||||
</PageListInnerWrapper>
|
||||
</PageListProvider>
|
||||
<ListProvider initialValues={[[listPropsAtom, props]]}>
|
||||
<ListInnerWrapper {...props} handleRef={ref}>
|
||||
<ListInner {...props} />
|
||||
</ListInnerWrapper>
|
||||
</ListProvider>
|
||||
);
|
||||
});
|
||||
|
||||
const headingAtom = selectAtom(pageListPropsAtom, props => props.heading);
|
||||
const headingAtom = selectAtom(listPropsAtom, props => props.heading);
|
||||
|
||||
const PageListHeading = () => {
|
||||
const heading = useAtomValue(headingAtom);
|
||||
return <div className={styles.heading}>{heading}</div>;
|
||||
};
|
||||
|
||||
const useVirtuosoItems = () => {
|
||||
const groups = useAtomValue(pageGroupsAtom);
|
||||
const groupCollapsedState = useAtomValue(pageGroupCollapseStateAtom);
|
||||
const useVirtuosoItems = <T extends ListItem>() => {
|
||||
const groups = useAtomValue(groupsAtom);
|
||||
const groupCollapsedState = useAtomValue(groupCollapseStateAtom);
|
||||
|
||||
return useMemo(() => {
|
||||
const items: VirtuosoItem[] = [];
|
||||
const items: VirtuosoItem<T>[] = [];
|
||||
|
||||
// 1.
|
||||
// always put sticky header at the top
|
||||
@ -114,21 +113,21 @@ const useVirtuosoItems = () => {
|
||||
// skip empty group header since it will cause issue in virtuoso ("Zero-sized element")
|
||||
if (group.label) {
|
||||
items.push({
|
||||
type: 'page-group-header',
|
||||
data: group,
|
||||
type: 'group-header',
|
||||
data: group as ItemGroupProps<T>,
|
||||
});
|
||||
}
|
||||
// do not render items if the group is collapsed
|
||||
if (!groupCollapsedState[group.id]) {
|
||||
for (const item of group.items) {
|
||||
items.push({
|
||||
type: 'page-item',
|
||||
data: item,
|
||||
type: 'item',
|
||||
data: item as T,
|
||||
});
|
||||
// add a spacer between items (4px), unless it's the last item
|
||||
if (item !== group.items[group.items.length - 1]) {
|
||||
items.push({
|
||||
type: 'page-item-spacer',
|
||||
type: 'item-spacer',
|
||||
data: {
|
||||
height: 4,
|
||||
},
|
||||
@ -139,7 +138,7 @@ const useVirtuosoItems = () => {
|
||||
|
||||
// add a spacer between groups (16px)
|
||||
items.push({
|
||||
type: 'page-item-spacer',
|
||||
type: 'item-spacer',
|
||||
data: {
|
||||
height: 16,
|
||||
},
|
||||
@ -149,19 +148,6 @@ const useVirtuosoItems = () => {
|
||||
}, [groupCollapsedState, groups]);
|
||||
};
|
||||
|
||||
const itemContentRenderer = (_index: number, data: VirtuosoItem) => {
|
||||
switch (data.type) {
|
||||
case 'sticky-header':
|
||||
return <PageListTableHeader />;
|
||||
case 'page-group-header':
|
||||
return <PageGroupHeader {...data.data} />;
|
||||
case 'page-item':
|
||||
return <PageMetaListItemRenderer {...data.data} />;
|
||||
case 'page-item-spacer':
|
||||
return <div style={{ height: data.data.height }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const Scroller = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<HTMLAttributes<HTMLDivElement>>
|
||||
@ -178,12 +164,12 @@ const Scroller = forwardRef<
|
||||
|
||||
Scroller.displayName = 'Scroller';
|
||||
|
||||
const PageListInner = ({
|
||||
const ListInner = ({
|
||||
atTopStateChange,
|
||||
atTopThreshold,
|
||||
...props
|
||||
}: VirtualizedPageListProps) => {
|
||||
const virtuosoItems = useVirtuosoItems();
|
||||
}: VirtualizedListProps<ListItem>) => {
|
||||
const virtuosoItems = useVirtuosoItems<ListItem>();
|
||||
const [atTop, setAtTop] = useState(false);
|
||||
const handleAtTopStateChange = useCallback(
|
||||
(atTop: boolean) => {
|
||||
@ -198,15 +184,30 @@ const PageListInner = ({
|
||||
Scroller: Scroller,
|
||||
};
|
||||
}, [props.heading]);
|
||||
const itemContentRenderer = useCallback(
|
||||
(_index: number, data: VirtuosoItem<ListItem>) => {
|
||||
switch (data.type) {
|
||||
case 'sticky-header':
|
||||
return props.headerRenderer?.();
|
||||
case 'group-header':
|
||||
return <ItemGroupHeader {...data.data} />;
|
||||
case 'item':
|
||||
return props.itemRenderer?.(data.data);
|
||||
case 'item-spacer':
|
||||
return <div style={{ height: data.data.height }} />;
|
||||
}
|
||||
},
|
||||
[props]
|
||||
);
|
||||
return (
|
||||
<Virtuoso<VirtuosoItem>
|
||||
<Virtuoso<VirtuosoItem<ListItem>>
|
||||
data-has-scroll-top={!atTop}
|
||||
atTopThreshold={atTopThreshold ?? 0}
|
||||
atTopStateChange={handleAtTopStateChange}
|
||||
components={components}
|
||||
data={virtuosoItems}
|
||||
data-testid="virtualized-page-list"
|
||||
data-total-count={props.pages.length} // for testing, since we do not know the total count in test
|
||||
data-total-count={props.items.length} // for testing, since we do not know the total count in test
|
||||
topItemCount={1} // sticky header
|
||||
totalCount={virtuosoItems.length}
|
||||
itemContent={itemContentRenderer}
|
@ -1,30 +1,59 @@
|
||||
import { RadioButton, RadioButtonGroup } from '@affine/component';
|
||||
import type { AllPageFilterOption } from '@affine/core/atoms';
|
||||
import { allPageFilterSelectAtom } from '@affine/core/atoms';
|
||||
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
||||
import { WorkspaceSubPath } from '@affine/core/shared';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { allPageModeSelectAtom } from '../../../atoms';
|
||||
import * as styles from './index.css';
|
||||
|
||||
export const WorkspaceModeFilterTab = () => {
|
||||
export const WorkspaceModeFilterTab = ({
|
||||
workspaceId,
|
||||
activeFilter,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
activeFilter: AllPageFilterOption;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [value, setMode] = useAtom(allPageModeSelectAtom);
|
||||
const handleValueChange = (value: string) => {
|
||||
if (value !== 'all' && value !== 'page' && value !== 'edgeless') {
|
||||
throw new Error('Invalid value for page mode option');
|
||||
const [value, setValue] = useState(activeFilter);
|
||||
const [filterMode, setFilterMode] = useAtom(allPageFilterSelectAtom);
|
||||
const { jumpToCollections, jumpToTags, jumpToSubPath } = useNavigateHelper();
|
||||
const handleValueChange = useCallback(
|
||||
(value: AllPageFilterOption) => {
|
||||
switch (value) {
|
||||
case 'collections':
|
||||
jumpToCollections(workspaceId);
|
||||
break;
|
||||
case 'tags':
|
||||
jumpToTags(workspaceId);
|
||||
break;
|
||||
case 'docs':
|
||||
jumpToSubPath(workspaceId, WorkspaceSubPath.ALL);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[jumpToCollections, jumpToSubPath, jumpToTags, workspaceId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== activeFilter) {
|
||||
setValue(activeFilter);
|
||||
setFilterMode(activeFilter);
|
||||
}
|
||||
setMode(value);
|
||||
};
|
||||
}, [activeFilter, filterMode, setFilterMode, value]);
|
||||
|
||||
return (
|
||||
<RadioButtonGroup value={value} onValueChange={handleValueChange}>
|
||||
<RadioButton value="all" spanStyle={styles.filterTab}>
|
||||
{t['com.affine.pageMode.all']()}
|
||||
<RadioButton spanStyle={styles.filterTab} value="docs">
|
||||
{t['com.affine.docs.header']()}
|
||||
</RadioButton>
|
||||
<RadioButton spanStyle={styles.filterTab} value="page">
|
||||
{t['com.affine.pageMode.page']()}
|
||||
<RadioButton spanStyle={styles.filterTab} value="collections">
|
||||
{t['com.affine.collections.header']()}
|
||||
</RadioButton>
|
||||
<RadioButton spanStyle={styles.filterTab} value="edgeless">
|
||||
{t['com.affine.pageMode.edgeless']()}
|
||||
<RadioButton spanStyle={styles.filterTab} value="tags">
|
||||
{t['Tags']()}
|
||||
</RadioButton>
|
||||
</RadioButtonGroup>
|
||||
);
|
||||
|
@ -69,15 +69,12 @@ export type RootAppSidebarProps = {
|
||||
const RouteMenuLinkItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
currentPath: string; // todo: pass through useRouter?
|
||||
path: string;
|
||||
icon: ReactElement;
|
||||
active?: boolean;
|
||||
children?: ReactElement;
|
||||
isDraggedOver?: boolean;
|
||||
} & HTMLAttributes<HTMLDivElement>
|
||||
>(({ currentPath, path, icon, children, isDraggedOver, ...props }, ref) => {
|
||||
// Force active style when a page is dragged over
|
||||
const active = isDraggedOver || currentPath === path;
|
||||
>(({ path, icon, active, children, ...props }, ref) => {
|
||||
return (
|
||||
<MenuLinkItem
|
||||
ref={ref}
|
||||
@ -197,6 +194,22 @@ export const RootAppSidebar = ({
|
||||
});
|
||||
}, [blockSuiteWorkspace.id, navigateHelper, open, setting]);
|
||||
|
||||
const allPageActive = useMemo(() => {
|
||||
if (
|
||||
currentPath.startsWith(`/workspace/${currentWorkspaceId}/collection/`) ||
|
||||
currentPath.startsWith(`/workspace/${currentWorkspaceId}/tag/`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return currentPath === paths.all(currentWorkspaceId);
|
||||
}, [currentPath, currentWorkspaceId, paths]);
|
||||
|
||||
const trashActive = useMemo(() => {
|
||||
return (
|
||||
currentPath === paths.trash(currentWorkspaceId) || trashDroppable.isOver
|
||||
);
|
||||
}, [currentPath, currentWorkspaceId, paths, trashDroppable.isOver]);
|
||||
|
||||
return (
|
||||
<AppSidebar
|
||||
router={router}
|
||||
@ -247,7 +260,7 @@ export const RootAppSidebar = ({
|
||||
/>
|
||||
<RouteMenuLinkItem
|
||||
icon={<FolderIcon />}
|
||||
currentPath={currentPath}
|
||||
active={allPageActive}
|
||||
path={paths.all(currentWorkspaceId)}
|
||||
onClick={backToAll}
|
||||
>
|
||||
@ -289,9 +302,8 @@ export const RootAppSidebar = ({
|
||||
<div style={{ height: '4px' }} />
|
||||
<RouteMenuLinkItem
|
||||
ref={trashDroppable.setNodeRef}
|
||||
isDraggedOver={trashDroppable.isOver}
|
||||
icon={<AnimatedDeleteIcon closed={trashDroppable.isOver} />}
|
||||
currentPath={currentPath}
|
||||
active={trashActive}
|
||||
path={paths.trash(currentWorkspaceId)}
|
||||
>
|
||||
<span data-testid="trash-page">
|
||||
|
@ -42,6 +42,22 @@ export function useNavigateHelper() {
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
const jumpToCollections = useCallback(
|
||||
(workspaceId: string, logic: RouteLogic = RouteLogic.PUSH) => {
|
||||
return navigate(`/workspace/${workspaceId}/all?filterMode=collections`, {
|
||||
replace: logic === RouteLogic.REPLACE,
|
||||
});
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
const jumpToTags = useCallback(
|
||||
(workspaceId: string, logic: RouteLogic = RouteLogic.PUSH) => {
|
||||
return navigate(`/workspace/${workspaceId}/all?filterMode=tags`, {
|
||||
replace: logic === RouteLogic.REPLACE,
|
||||
});
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
const jumpToCollection = useCallback(
|
||||
(
|
||||
workspaceId: string,
|
||||
@ -144,6 +160,8 @@ export function useNavigateHelper() {
|
||||
jumpToExpired,
|
||||
jumpToSignIn,
|
||||
jumpToCollection,
|
||||
jumpToCollections,
|
||||
jumpToTags,
|
||||
}),
|
||||
[
|
||||
jumpToPage,
|
||||
@ -156,6 +174,8 @@ export function useNavigateHelper() {
|
||||
jumpToExpired,
|
||||
jumpToSignIn,
|
||||
jumpToCollection,
|
||||
jumpToCollections,
|
||||
jumpToTags,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useAtom, useAtomValue, useStore } from 'jotai';
|
||||
import { useAtomValue, useSetAtom, useStore } from 'jotai';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
@ -26,7 +26,7 @@ export function useRegisterWorkspaceCommands() {
|
||||
const languageHelper = useLanguageHelper();
|
||||
const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace);
|
||||
const navigationHelper = useNavigateHelper();
|
||||
const [pageListMode, setPageListMode] = useAtom(allPageModeSelectAtom);
|
||||
const setPageListMode = useSetAtom(allPageModeSelectAtom);
|
||||
const [editor] = useActiveBlocksuiteEditor();
|
||||
|
||||
// register AffineUpdatesCommands
|
||||
@ -48,7 +48,6 @@ export function useRegisterWorkspaceCommands() {
|
||||
t,
|
||||
workspace: currentWorkspace.blockSuiteWorkspace,
|
||||
navigationHelper,
|
||||
pageListMode,
|
||||
setPageListMode,
|
||||
});
|
||||
|
||||
@ -60,7 +59,6 @@ export function useRegisterWorkspaceCommands() {
|
||||
t,
|
||||
currentWorkspace.blockSuiteWorkspace,
|
||||
navigationHelper,
|
||||
pageListMode,
|
||||
setPageListMode,
|
||||
]);
|
||||
|
||||
|
@ -0,0 +1,110 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import type { AllPageFilterOption } from '@affine/core/atoms';
|
||||
import { collectionsCRUDAtom } from '@affine/core/atoms/collections';
|
||||
import {
|
||||
CollectionList,
|
||||
PageListNewPageButton,
|
||||
useCollectionManager,
|
||||
} from '@affine/core/components/page-list';
|
||||
import { Header } from '@affine/core/components/pure/header';
|
||||
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
|
||||
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
|
||||
import { useAllPageListConfig } from '@affine/core/hooks/affine/use-all-page-list-config';
|
||||
import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-collection-info';
|
||||
import { PlusIcon } from '@blocksuite/icons';
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import clsx from 'clsx';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import * as styles from './all-page.css';
|
||||
import { FilterContainer } from './all-page-filter';
|
||||
|
||||
export const AllPageHeader = ({
|
||||
workspace,
|
||||
showCreateNew,
|
||||
isDefaultFilter,
|
||||
activeFilter,
|
||||
onCreateCollection,
|
||||
}: {
|
||||
workspace: Workspace;
|
||||
showCreateNew: boolean;
|
||||
isDefaultFilter: boolean;
|
||||
activeFilter: AllPageFilterOption;
|
||||
onCreateCollection?: () => void;
|
||||
}) => {
|
||||
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||
const config = useAllPageListConfig();
|
||||
const userInfo = useDeleteCollectionInfo();
|
||||
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
|
||||
|
||||
const disableFilterButton = useMemo(() => {
|
||||
return activeFilter !== 'docs' && isDefaultFilter;
|
||||
}, [activeFilter, isDefaultFilter]);
|
||||
|
||||
const renderRightItem = useMemo(() => {
|
||||
if (activeFilter === 'tags') {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
activeFilter === 'collections' &&
|
||||
isDefaultFilter &&
|
||||
onCreateCollection
|
||||
) {
|
||||
return (
|
||||
<IconButton
|
||||
type="default"
|
||||
icon={<PlusIcon fontSize={16} />}
|
||||
onClick={onCreateCollection}
|
||||
className={clsx(
|
||||
styles.headerCreateNewButton,
|
||||
styles.headerCreateNewCollectionIconButton,
|
||||
!showCreateNew && styles.headerCreateNewButtonHidden
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PageListNewPageButton
|
||||
size="small"
|
||||
className={clsx(
|
||||
styles.headerCreateNewButton,
|
||||
!showCreateNew && styles.headerCreateNewButtonHidden
|
||||
)}
|
||||
>
|
||||
<PlusIcon />
|
||||
</PageListNewPageButton>
|
||||
);
|
||||
}, [activeFilter, isDefaultFilter, onCreateCollection, showCreateNew]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
left={
|
||||
<CollectionList
|
||||
userInfo={userInfo}
|
||||
allPageListConfig={config}
|
||||
setting={setting}
|
||||
propertiesMeta={workspace.meta.properties}
|
||||
disable={disableFilterButton}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<div
|
||||
className={styles.headerRightWindows}
|
||||
data-is-windows-desktop={isWindowsDesktop}
|
||||
>
|
||||
{renderRightItem}
|
||||
{isWindowsDesktop ? <WindowsAppControls /> : null}
|
||||
</div>
|
||||
}
|
||||
center={
|
||||
<WorkspaceModeFilterTab
|
||||
workspaceId={workspace.id}
|
||||
activeFilter={activeFilter}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FilterContainer />
|
||||
</>
|
||||
);
|
||||
};
|
@ -14,53 +14,15 @@ export const scrollContainer = style({
|
||||
paddingBottom: '32px',
|
||||
});
|
||||
|
||||
export const allPagesHeader = style({
|
||||
height: 100,
|
||||
alignItems: 'center',
|
||||
padding: '48px 16px 20px 24px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
});
|
||||
|
||||
export const allPagesHeaderTitle = style({
|
||||
fontSize: 'var(--affine-font-h-5)',
|
||||
fontWeight: 500,
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
export const titleIcon = style({
|
||||
color: 'var(--affine-icon-color)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const titleCollectionName = style({
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
});
|
||||
|
||||
export const floatingToolbar = style({
|
||||
position: 'absolute',
|
||||
bottom: 26,
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
});
|
||||
|
||||
export const toolbarSelectedNumber = style({
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
|
||||
export const headerCreateNewButton = style({
|
||||
transition: 'opacity 0.1s ease-in-out',
|
||||
});
|
||||
|
||||
export const newPageButtonLabel = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
export const headerCreateNewCollectionIconButton = style({
|
||||
padding: '4px 8px',
|
||||
fontSize: '16px',
|
||||
width: '32px',
|
||||
height: '28px',
|
||||
borderRadius: '8px',
|
||||
});
|
||||
|
||||
export const headerCreateNewButtonHidden = style({
|
||||
@ -72,5 +34,9 @@ export const headerRightWindows = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
transform: 'translateX(16px)',
|
||||
selectors: {
|
||||
'&[data-is-windows-desktop="true"]': {
|
||||
transform: 'translateX(16px)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -1,322 +1,209 @@
|
||||
import { toast } from '@affine/component';
|
||||
import type { AllPageFilterOption } from '@affine/core/atoms';
|
||||
import { collectionsCRUDAtom } from '@affine/core/atoms/collections';
|
||||
import { HubIsland } from '@affine/core/components/affine/hub-island';
|
||||
import {
|
||||
CollectionList,
|
||||
CollectionListHeader,
|
||||
type CollectionMeta,
|
||||
createEmptyCollection,
|
||||
currentCollectionAtom,
|
||||
FloatingToolbar,
|
||||
NewPageButton as PureNewPageButton,
|
||||
OperationCell,
|
||||
type PageListHandle,
|
||||
PageListHeader,
|
||||
useCollectionManager,
|
||||
useEditCollectionName,
|
||||
useFilteredPageMetas,
|
||||
useSavedCollections,
|
||||
useTagMetas,
|
||||
VirtualizedCollectionList,
|
||||
VirtualizedPageList,
|
||||
} from '@affine/core/components/page-list';
|
||||
import {
|
||||
TagListHeader,
|
||||
VirtualizedTagList,
|
||||
} from '@affine/core/components/page-list/tags';
|
||||
import { useAllPageListConfig } from '@affine/core/hooks/affine/use-all-page-list-config';
|
||||
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { performanceRenderLogger } from '@affine/core/shared';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
CloseIcon,
|
||||
DeleteIcon,
|
||||
PlusIcon,
|
||||
ViewLayersIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import clsx from 'clsx';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { NIL } from 'uuid';
|
||||
|
||||
import { collectionsCRUDAtom } from '../../../atoms/collections';
|
||||
import { HubIsland } from '../../../components/affine/hub-island';
|
||||
import { usePageHelper } from '../../../components/blocksuite/block-suite-page-list/utils';
|
||||
import { Header } from '../../../components/pure/header';
|
||||
import { WindowsAppControls } from '../../../components/pure/header/windows-app-controls';
|
||||
import { WorkspaceModeFilterTab } from '../../../components/pure/workspace-mode-filter-tab';
|
||||
import { useAllPageListConfig } from '../../../hooks/affine/use-all-page-list-config';
|
||||
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
|
||||
import { useDeleteCollectionInfo } from '../../../hooks/affine/use-delete-collection-info';
|
||||
import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper';
|
||||
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
|
||||
import { performanceRenderLogger } from '../../../shared';
|
||||
import { EmptyPageList } from '../page-list-empty';
|
||||
import { useFilteredPageMetas } from '../pages';
|
||||
import {
|
||||
EmptyCollectionList,
|
||||
EmptyPageList,
|
||||
EmptyTagList,
|
||||
} from '../page-list-empty';
|
||||
import * as styles from './all-page.css';
|
||||
import { FilterContainer } from './all-page-filter';
|
||||
|
||||
const PageListHeader = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||
const title = useMemo(() => {
|
||||
if (setting.isDefault) {
|
||||
return t['com.affine.all-pages.header']();
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{t['com.affine.collections.header']()} /
|
||||
<div className={styles.titleIcon}>
|
||||
<ViewLayersIcon />
|
||||
</div>
|
||||
<div className={styles.titleCollectionName}>
|
||||
{setting.currentCollection.name}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}, [setting.currentCollection.name, setting.isDefault, t]);
|
||||
|
||||
return (
|
||||
<div className={styles.allPagesHeader}>
|
||||
<div className={styles.allPagesHeaderTitle}>{title}</div>
|
||||
<NewPageButton testId="new-page-button-trigger">
|
||||
{t['New Page']()}
|
||||
</NewPageButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const usePageOperationsRenderer = () => {
|
||||
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
||||
const { setTrashModal } = useTrashModalHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const { toggleFavorite } = useBlockSuiteMetaHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const t = useAFFiNEI18N();
|
||||
const pageOperationsRenderer = useCallback(
|
||||
(page: PageMeta) => {
|
||||
const onDisablePublicSharing = () => {
|
||||
toast('Successfully disabled', {
|
||||
portal: document.body,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<OperationCell
|
||||
favorite={!!page.favorite}
|
||||
isPublic={!!page.isPublic}
|
||||
onDisablePublicSharing={onDisablePublicSharing}
|
||||
link={`/workspace/${currentWorkspace.id}/${page.id}`}
|
||||
onRemoveToTrash={() =>
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageIds: [page.id],
|
||||
pageTitles: [page.title],
|
||||
})
|
||||
}
|
||||
onToggleFavoritePage={() => {
|
||||
const status = page.favorite;
|
||||
toggleFavorite(page.id);
|
||||
toast(
|
||||
status
|
||||
? t['com.affine.toastMessage.removedFavorites']()
|
||||
: t['com.affine.toastMessage.addedFavorites']()
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[currentWorkspace.id, setTrashModal, t, toggleFavorite]
|
||||
);
|
||||
|
||||
return pageOperationsRenderer;
|
||||
};
|
||||
|
||||
const PageListFloatingToolbar = ({
|
||||
selectedIds,
|
||||
onClose,
|
||||
open,
|
||||
}: {
|
||||
open: boolean;
|
||||
selectedIds: string[];
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
||||
const { setTrashModal } = useTrashModalHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
|
||||
const handleMultiDelete = useCallback(() => {
|
||||
const pageNameMapping = Object.fromEntries(
|
||||
pageMetas.map(meta => [meta.id, meta.title])
|
||||
);
|
||||
|
||||
const pageNames = selectedIds.map(id => pageNameMapping[id] ?? '');
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageIds: selectedIds,
|
||||
pageTitles: pageNames,
|
||||
});
|
||||
}, [pageMetas, selectedIds, setTrashModal]);
|
||||
|
||||
return (
|
||||
<FloatingToolbar className={styles.floatingToolbar} open={open}>
|
||||
<FloatingToolbar.Item>
|
||||
<Trans
|
||||
i18nKey="com.affine.page.toolbar.selected"
|
||||
count={selectedIds.length}
|
||||
>
|
||||
<div className={styles.toolbarSelectedNumber}>
|
||||
{{ count: selectedIds.length } as any}
|
||||
</div>
|
||||
selected
|
||||
</Trans>
|
||||
</FloatingToolbar.Item>
|
||||
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
|
||||
<FloatingToolbar.Separator />
|
||||
<FloatingToolbar.Button
|
||||
onClick={handleMultiDelete}
|
||||
icon={<DeleteIcon />}
|
||||
type="danger"
|
||||
data-testid="page-list-toolbar-delete"
|
||||
/>
|
||||
</FloatingToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
const NewPageButton = ({
|
||||
className,
|
||||
children,
|
||||
size,
|
||||
testId,
|
||||
}: PropsWithChildren<{
|
||||
className?: string;
|
||||
size?: 'small' | 'default';
|
||||
testId?: string;
|
||||
}>) => {
|
||||
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
||||
const { importFile, createEdgeless, createPage } = usePageHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
return (
|
||||
<div className={className} data-testid={testId}>
|
||||
<PureNewPageButton
|
||||
size={size}
|
||||
importFile={importFile}
|
||||
createNewEdgeless={createEdgeless}
|
||||
createNewPage={createPage}
|
||||
>
|
||||
<div className={styles.newPageButtonLabel}>{children}</div>
|
||||
</PureNewPageButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AllPageHeader = ({
|
||||
workspace,
|
||||
showCreateNew,
|
||||
}: {
|
||||
workspace: Workspace;
|
||||
showCreateNew: boolean;
|
||||
}) => {
|
||||
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||
const config = useAllPageListConfig();
|
||||
const userInfo = useDeleteCollectionInfo();
|
||||
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
left={
|
||||
<CollectionList
|
||||
userInfo={userInfo}
|
||||
allPageListConfig={config}
|
||||
setting={setting}
|
||||
propertiesMeta={workspace.meta.properties}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<div className={clsx(isWindowsDesktop && styles.headerRightWindows)}>
|
||||
<NewPageButton
|
||||
size="small"
|
||||
className={clsx(
|
||||
styles.headerCreateNewButton,
|
||||
!showCreateNew && styles.headerCreateNewButtonHidden
|
||||
)}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NewPageButton>
|
||||
{isWindowsDesktop ? <WindowsAppControls /> : null}
|
||||
</div>
|
||||
}
|
||||
center={<WorkspaceModeFilterTab />}
|
||||
/>
|
||||
<FilterContainer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
import { AllPageHeader } from './all-page-header';
|
||||
|
||||
// even though it is called all page, it is also being used for collection route as well
|
||||
export const AllPage = () => {
|
||||
export const AllPage = ({
|
||||
activeFilter,
|
||||
}: {
|
||||
activeFilter: AllPageFilterOption;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const params = useParams();
|
||||
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
||||
const { isPreferredEdgeless } = usePageHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
|
||||
const pageOperationsRenderer = usePageOperationsRenderer();
|
||||
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
|
||||
|
||||
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||
const config = useAllPageListConfig();
|
||||
const { collections } = useSavedCollections(collectionsCRUDAtom);
|
||||
const { tags, tagMetas, filterPageMetaByTag, deleteTags } = useTagMetas(
|
||||
currentWorkspace.blockSuiteWorkspace,
|
||||
pageMetas
|
||||
);
|
||||
const filteredPageMetas = useFilteredPageMetas(
|
||||
'all',
|
||||
pageMetas,
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||
const pageListRef = useRef<PageListHandle>(null);
|
||||
const tagPageMetas = useMemo(() => {
|
||||
if (params.tagId) {
|
||||
return filterPageMetaByTag(params.tagId);
|
||||
}
|
||||
return [];
|
||||
}, [filterPageMetaByTag, params.tagId]);
|
||||
|
||||
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
|
||||
const collectionMetas = useMemo(() => {
|
||||
const collectionsList: CollectionMeta[] = collections.map(collection => {
|
||||
return {
|
||||
...collection,
|
||||
title: collection.name,
|
||||
};
|
||||
});
|
||||
return collectionsList;
|
||||
}, [collections]);
|
||||
|
||||
const hideFloatingToolbar = useCallback(() => {
|
||||
pageListRef.current?.toggleSelectable();
|
||||
}, []);
|
||||
const navigateHelper = useNavigateHelper();
|
||||
const { open, node } = useEditCollectionName({
|
||||
title: t['com.affine.editCollection.createCollection'](),
|
||||
showTips: true,
|
||||
});
|
||||
|
||||
// make sure selected id is in the filtered list
|
||||
const filteredSelectedPageIds = useMemo(() => {
|
||||
const ids = filteredPageMetas.map(page => page.id);
|
||||
return selectedPageIds.filter(id => ids.includes(id));
|
||||
}, [filteredPageMetas, selectedPageIds]);
|
||||
const handleCreateCollection = useCallback(() => {
|
||||
open('')
|
||||
.then(name => {
|
||||
const id = nanoid();
|
||||
setting.createCollection(createEmptyCollection(id, { name }));
|
||||
navigateHelper.jumpToCollection(currentWorkspace.id, id);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [currentWorkspace.id, navigateHelper, open, setting]);
|
||||
|
||||
const [hideHeaderCreateNewPage, setHideHeaderCreateNewPage] = useState(true);
|
||||
const currentTag = useMemo(() => {
|
||||
if (params.tagId) {
|
||||
return tags.find(tag => tag.id === params.tagId);
|
||||
}
|
||||
return;
|
||||
}, [params.tagId, tags]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (filteredPageMetas.length > 0 && activeFilter === 'docs') {
|
||||
return (
|
||||
<VirtualizedPageList
|
||||
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
|
||||
/>
|
||||
);
|
||||
} else if (activeFilter === 'collections' && !setting.isDefault) {
|
||||
return (
|
||||
<VirtualizedPageList
|
||||
collection={setting.currentCollection}
|
||||
config={config}
|
||||
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
|
||||
/>
|
||||
);
|
||||
} else if (activeFilter === 'collections' && setting.isDefault) {
|
||||
return collectionMetas.length > 0 ? (
|
||||
<VirtualizedCollectionList
|
||||
collections={collections}
|
||||
collectionMetas={collectionMetas}
|
||||
setHideHeaderCreateNewCollection={setHideHeaderCreateNew}
|
||||
node={node}
|
||||
config={config}
|
||||
handleCreateCollection={handleCreateCollection}
|
||||
/>
|
||||
) : (
|
||||
<EmptyCollectionList
|
||||
heading={
|
||||
<CollectionListHeader
|
||||
node={node}
|
||||
onCreate={handleCreateCollection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else if (activeFilter === 'tags') {
|
||||
if (params.tagId) {
|
||||
return tagPageMetas.length > 0 ? (
|
||||
<VirtualizedPageList
|
||||
tag={currentTag}
|
||||
listItem={tagPageMetas}
|
||||
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
|
||||
/>
|
||||
) : (
|
||||
<EmptyPageList
|
||||
type="all"
|
||||
heading={<PageListHeader workspaceId={currentWorkspace.id} />}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return tags.length > 0 ? (
|
||||
<VirtualizedTagList
|
||||
tags={tags}
|
||||
tagMetas={tagMetas}
|
||||
setHideHeaderCreateNewTag={setHideHeaderCreateNew}
|
||||
onTagDelete={deleteTags}
|
||||
/>
|
||||
) : (
|
||||
<EmptyTagList heading={<TagListHeader />} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EmptyPageList
|
||||
type="all"
|
||||
heading={<PageListHeader workspaceId={currentWorkspace.id} />}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
activeFilter,
|
||||
collectionMetas,
|
||||
collections,
|
||||
config,
|
||||
currentTag,
|
||||
currentWorkspace.blockSuiteWorkspace,
|
||||
currentWorkspace.id,
|
||||
deleteTags,
|
||||
filteredPageMetas.length,
|
||||
handleCreateCollection,
|
||||
node,
|
||||
params.tagId,
|
||||
setting.currentCollection,
|
||||
setting.isDefault,
|
||||
tagMetas,
|
||||
tagPageMetas,
|
||||
tags,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<AllPageHeader
|
||||
workspace={currentWorkspace.blockSuiteWorkspace}
|
||||
showCreateNew={!hideHeaderCreateNewPage}
|
||||
showCreateNew={!hideHeaderCreateNew}
|
||||
isDefaultFilter={setting.isDefault}
|
||||
activeFilter={activeFilter}
|
||||
onCreateCollection={handleCreateCollection}
|
||||
/>
|
||||
{filteredPageMetas.length > 0 ? (
|
||||
<>
|
||||
<VirtualizedPageList
|
||||
ref={pageListRef}
|
||||
selectable="toggle"
|
||||
draggable
|
||||
atTopThreshold={80}
|
||||
atTopStateChange={setHideHeaderCreateNewPage}
|
||||
onSelectionActiveChange={setShowFloatingToolbar}
|
||||
heading={<PageListHeader />}
|
||||
selectedPageIds={filteredSelectedPageIds}
|
||||
onSelectedPageIdsChange={setSelectedPageIds}
|
||||
pages={filteredPageMetas}
|
||||
rowAsLink
|
||||
isPreferredEdgeless={isPreferredEdgeless}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
/>
|
||||
<PageListFloatingToolbar
|
||||
open={showFloatingToolbar && filteredSelectedPageIds.length > 0}
|
||||
selectedIds={filteredSelectedPageIds}
|
||||
onClose={hideFloatingToolbar}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyPageList
|
||||
type="all"
|
||||
heading={<PageListHeader />}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
)}
|
||||
{content}
|
||||
<HubIsland />
|
||||
</div>
|
||||
);
|
||||
@ -329,6 +216,18 @@ export const Component = () => {
|
||||
const currentCollection = useSetAtom(currentCollectionAtom);
|
||||
const navigateHelper = useNavigateHelper();
|
||||
|
||||
const location = useLocation();
|
||||
const activeFilter = useMemo(() => {
|
||||
const query = new URLSearchParams(location.search);
|
||||
const filterMode = query.get('filterMode');
|
||||
if (filterMode === 'collections') {
|
||||
return 'collections';
|
||||
} else if (filterMode === 'tags') {
|
||||
return 'tags';
|
||||
}
|
||||
return 'docs';
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
function checkJumpOnce() {
|
||||
for (const [pageId] of currentWorkspace.blockSuiteWorkspace.pages) {
|
||||
@ -355,5 +254,5 @@ export const Component = () => {
|
||||
currentCollection(NIL);
|
||||
}, [currentCollection]);
|
||||
|
||||
return <AllPage />;
|
||||
return <AllPage activeFilter={activeFilter} />;
|
||||
};
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
useEditCollection,
|
||||
} from '@affine/core/components/page-list';
|
||||
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
|
||||
import { useAllPageListConfig } from '@affine/core/hooks/affine/use-all-page-list-config';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
@ -31,7 +32,6 @@ import {
|
||||
collectionsCRUDAtom,
|
||||
pageCollectionBaseAtom,
|
||||
} from '../../atoms/collections';
|
||||
import { useAllPageListConfig } from '../../hooks/affine/use-all-page-list-config';
|
||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||
import { WorkspaceSubPath } from '../../shared';
|
||||
import { getWorkspaceSetting } from '../../utils/workspace-setting';
|
||||
@ -89,17 +89,24 @@ export const Component = function CollectionPage() {
|
||||
return null;
|
||||
}
|
||||
return isEmpty(collection) ? (
|
||||
<Placeholder collection={collection} />
|
||||
<Placeholder collection={collection} workspaceId={workspace.id} />
|
||||
) : (
|
||||
<AllPage />
|
||||
<AllPage activeFilter="collections" />
|
||||
);
|
||||
};
|
||||
|
||||
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
|
||||
|
||||
const Placeholder = ({ collection }: { collection: Collection }) => {
|
||||
const Placeholder = ({
|
||||
collection,
|
||||
workspaceId,
|
||||
}: {
|
||||
collection: Collection;
|
||||
workspaceId: string;
|
||||
}) => {
|
||||
const { updateCollection } = useCollectionManager(collectionsCRUDAtom);
|
||||
const { node, open } = useEditCollection(useAllPageListConfig());
|
||||
const { jumpToCollections } = useNavigateHelper();
|
||||
const openPageEdit = useAsyncCallback(async () => {
|
||||
const ret = await open({ ...collection }, 'page');
|
||||
updateCollection(ret);
|
||||
@ -118,6 +125,11 @@ const Placeholder = ({ collection }: { collection: Collection }) => {
|
||||
}, []);
|
||||
const t = useAFFiNEI18N();
|
||||
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
|
||||
|
||||
const handleJumpToCollections = useCallback(() => {
|
||||
jumpToCollections(workspaceId);
|
||||
}, [jumpToCollections, workspaceId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@ -143,9 +155,11 @@ const Placeholder = ({ collection }: { collection: Collection }) => {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
['WebkitAppRegion' as string]: 'no-drag',
|
||||
}}
|
||||
onClick={handleJumpToCollections}
|
||||
>
|
||||
<ViewLayersIcon
|
||||
style={{ color: 'var(--affine-icon-color)' }}
|
||||
|
@ -66,3 +66,22 @@ export const EmptyPageList = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmptyCollectionList = ({ heading }: { heading: ReactNode }) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div className={styles.pageListEmptyStyle}>
|
||||
{heading && <div>{heading}</div>}
|
||||
<Empty title={t['com.affine.emptyDesc.collection']()} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const EmptyTagList = ({ heading }: { heading: ReactNode }) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div className={styles.pageListEmptyStyle}>
|
||||
{heading && <div>{heading}</div>}
|
||||
<Empty title={t['com.affine.emptyDesc.tag']()} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
52
packages/frontend/core/src/pages/workspace/tag.tsx
Normal file
52
packages/frontend/core/src/pages/workspace/tag.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { TagListHeader, useTagMetas } from '@affine/core/components/page-list';
|
||||
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
import { type LoaderFunction, redirect, useParams } from 'react-router-dom';
|
||||
|
||||
import { AllPage } from './all-page/all-page';
|
||||
import { AllPageHeader } from './all-page/all-page-header';
|
||||
import { EmptyPageList } from './page-list-empty';
|
||||
|
||||
export const loader: LoaderFunction = async args => {
|
||||
if (!args.params.tagId) {
|
||||
return redirect('/404');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const Component = function TagPage() {
|
||||
const params = useParams();
|
||||
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
||||
const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
|
||||
const { tagUsageCounts } = useTagMetas(
|
||||
currentWorkspace.blockSuiteWorkspace,
|
||||
pageMetas
|
||||
);
|
||||
const isEmpty = useMemo(() => {
|
||||
if (params.tagId) {
|
||||
return tagUsageCounts[params.tagId] === 0;
|
||||
}
|
||||
return true;
|
||||
}, [params.tagId, tagUsageCounts]);
|
||||
|
||||
return isEmpty ? (
|
||||
<>
|
||||
<AllPageHeader
|
||||
workspace={currentWorkspace.blockSuiteWorkspace}
|
||||
showCreateNew={false}
|
||||
isDefaultFilter={true}
|
||||
activeFilter={'tags'}
|
||||
/>
|
||||
<EmptyPageList
|
||||
type="all"
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
heading={<TagListHeader />}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<AllPage activeFilter="tags" />
|
||||
);
|
||||
};
|
@ -1,9 +1,18 @@
|
||||
import { toast } from '@affine/component';
|
||||
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
|
||||
import {
|
||||
currentCollectionAtom,
|
||||
type ListItem,
|
||||
ListTableHeader,
|
||||
PageListItemRenderer,
|
||||
TrashOperationCell,
|
||||
VirtualizedPageList,
|
||||
useFilteredPageMetas,
|
||||
VirtualizedList,
|
||||
} from '@affine/core/components/page-list';
|
||||
import { pageHeaderColsDef } from '@affine/core/components/page-list/header-col-def';
|
||||
import { Header } from '@affine/core/components/pure/header';
|
||||
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
|
||||
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
|
||||
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
@ -16,12 +25,7 @@ import { useCallback } from 'react';
|
||||
import { type LoaderFunction } from 'react-router-dom';
|
||||
import { NIL } from 'uuid';
|
||||
|
||||
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
|
||||
import { Header } from '../../components/pure/header';
|
||||
import { WindowsAppControls } from '../../components/pure/header/windows-app-controls';
|
||||
import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper';
|
||||
import { EmptyPageList } from './page-list-empty';
|
||||
import { useFilteredPageMetas } from './pages';
|
||||
import * as styles from './trash-page.css';
|
||||
|
||||
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
|
||||
@ -74,7 +78,8 @@ export const TrashPage = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const pageOperationsRenderer = useCallback(
|
||||
(page: PageMeta) => {
|
||||
(item: ListItem) => {
|
||||
const page = item as PageMeta;
|
||||
const onRestorePage = () => {
|
||||
restoreFromTrash(page.id);
|
||||
toast(
|
||||
@ -94,20 +99,28 @@ export const TrashPage = () => {
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
[permanentlyDeletePage, restoreFromTrash, t]
|
||||
);
|
||||
|
||||
const pageItemRenderer = useCallback((item: ListItem) => {
|
||||
return <PageListItemRenderer {...item} />;
|
||||
}, []);
|
||||
const pageHeaderRenderer = useCallback(() => {
|
||||
return <ListTableHeader headerCols={pageHeaderColsDef} />;
|
||||
}, []);
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<TrashHeader />
|
||||
{filteredPageMetas.length > 0 ? (
|
||||
<VirtualizedPageList
|
||||
pages={filteredPageMetas}
|
||||
<VirtualizedList
|
||||
items={filteredPageMetas}
|
||||
rowAsLink
|
||||
groupBy={false}
|
||||
isPreferredEdgeless={isPreferredEdgeless}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
operationsRenderer={pageOperationsRenderer}
|
||||
itemRenderer={pageItemRenderer}
|
||||
headerRenderer={pageHeaderRenderer}
|
||||
/>
|
||||
) : (
|
||||
<EmptyPageList
|
||||
|
@ -19,6 +19,10 @@ export const routes = [
|
||||
path: 'collection/:collectionId',
|
||||
lazy: () => import('./pages/workspace/collection'),
|
||||
},
|
||||
{
|
||||
path: 'tag/:tagId',
|
||||
lazy: () => import('./pages/workspace/tag'),
|
||||
},
|
||||
{
|
||||
path: 'trash',
|
||||
lazy: () => import('./pages/workspace/trash-page'),
|
||||
|
@ -554,9 +554,10 @@
|
||||
"com.affine.collection.menu.edit": "Edit Collection",
|
||||
"com.affine.collection.menu.rename": "Rename",
|
||||
"com.affine.collectionBar.backToAll": "Back to all",
|
||||
"com.affine.docs.header": "Docs",
|
||||
"com.affine.collections.header": "Collections",
|
||||
"com.affine.collections.empty.message": "No collections",
|
||||
"com.affine.collections.empty.new-collection-button": "New collection",
|
||||
"com.affine.collections.empty.new-collection-button": "New Collection",
|
||||
"com.affine.confirmModal.button.cancel": "Cancel",
|
||||
"com.affine.currentYear": "Current Year",
|
||||
"com.affine.deleteLeaveWorkspace.description": "Delete workspace from this device and optionally delete all data.",
|
||||
@ -601,6 +602,8 @@
|
||||
"com.affine.editCollectionName.name.placeholder": "Collection Name",
|
||||
"com.affine.editorModeSwitch.tooltip": "Switch",
|
||||
"com.affine.emptyDesc": "There's no page here yet",
|
||||
"com.affine.emptyDesc.collection": "There's no collection here yet",
|
||||
"com.affine.emptyDesc.tag": "There's no tag here yet",
|
||||
"com.affine.enableAffineCloudModal.button.cancel": "Cancel",
|
||||
"com.affine.expired.page.subtitle": "Please request a new reset password link.",
|
||||
"com.affine.expired.page.title": "This link has expired...",
|
||||
@ -714,6 +717,12 @@
|
||||
"com.affine.page.toolbar.selected": "<0>{{count}}</0> selected",
|
||||
"com.affine.page.toolbar.selected_one": "<0>{{count}}</0> page selected",
|
||||
"com.affine.page.toolbar.selected_others": "<0>{{count}}</0> page(s) selected",
|
||||
"com.affine.collection.toolbar.selected": "<0>{{count}}</0> selected",
|
||||
"com.affine.collection.toolbar.selected_one": "<0>{{count}}</0> collection selected",
|
||||
"com.affine.collection.toolbar.selected_others": "<0>{{count}}</0> collection(s) selected",
|
||||
"com.affine.tag.toolbar.selected": "<0>{{count}}</0> selected",
|
||||
"com.affine.tag.toolbar.selected_one": "<0>{{count}}</0> tag selected",
|
||||
"com.affine.tag.toolbar.selected_others": "<0>{{count}}</0> tag(s) selected",
|
||||
"com.affine.pageMode": "Page Mode",
|
||||
"com.affine.pageMode.all": "all",
|
||||
"com.affine.pageMode.edgeless": "Edgeless",
|
||||
|
@ -212,7 +212,7 @@ test('select two pages and delete', async ({ page }) => {
|
||||
);
|
||||
|
||||
// click delete button
|
||||
await page.locator('[data-testid="page-list-toolbar-delete"]').click();
|
||||
await page.locator('[data-testid="list-toolbar-delete"]').click();
|
||||
|
||||
// the confirm dialog should appear
|
||||
await expect(page.getByText('Delete 2 pages?')).toBeVisible();
|
||||
|
@ -18,7 +18,7 @@ const removeOnboardingPages = async (page: Page) => {
|
||||
await page.getByTestId('page-list-header-selection-checkbox').click();
|
||||
// click again to select all
|
||||
await page.getByTestId('page-list-header-selection-checkbox').click();
|
||||
await page.getByTestId('page-list-toolbar-delete').click();
|
||||
await page.getByTestId('list-toolbar-delete').click();
|
||||
// confirm delete
|
||||
await page.getByTestId('confirm-delete-page').click();
|
||||
};
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { toast } from '@affine/component';
|
||||
import {
|
||||
FloatingToolbar,
|
||||
List,
|
||||
type ListItem,
|
||||
type ListProps,
|
||||
ListScrollContainer,
|
||||
NewPageButton,
|
||||
OperationCell,
|
||||
type OperationCellProps,
|
||||
PageList,
|
||||
PageListItem,
|
||||
type PageListItemProps,
|
||||
type PageListProps,
|
||||
PageListScrollContainer,
|
||||
PageOperationCell,
|
||||
type PageOperationCellProps,
|
||||
PageTags,
|
||||
type PageTagsProps,
|
||||
} from '@affine/core/components/page-list';
|
||||
@ -29,9 +30,9 @@ export default {
|
||||
},
|
||||
} satisfies Meta;
|
||||
|
||||
export const AffineOperationCell: StoryFn<OperationCellProps> = ({
|
||||
export const AffineOperationCell: StoryFn<PageOperationCellProps> = ({
|
||||
...props
|
||||
}) => <OperationCell {...props} />;
|
||||
}) => <PageOperationCell {...props} />;
|
||||
|
||||
AffineOperationCell.args = {
|
||||
favorite: false,
|
||||
@ -159,11 +160,11 @@ const testTags = [
|
||||
},
|
||||
];
|
||||
|
||||
export const ListItem: StoryFn<PageListItemProps> = props => (
|
||||
export const PageListItemComponent: StoryFn<PageListItemProps> = props => (
|
||||
<PageListItem {...props}></PageListItem>
|
||||
);
|
||||
|
||||
ListItem.args = {
|
||||
PageListItemComponent.args = {
|
||||
pageId: 'test-page-id',
|
||||
title: 'Test Page Title',
|
||||
preview:
|
||||
@ -178,7 +179,7 @@ ListItem.args = {
|
||||
selected: true,
|
||||
};
|
||||
|
||||
ListItem.decorators = [withRouter];
|
||||
PageListItemComponent.decorators = [withRouter];
|
||||
|
||||
export const ListItemTags: StoryFn<PageTagsProps> = props => (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
@ -195,15 +196,18 @@ ListItemTags.args = {
|
||||
maxItems: 5,
|
||||
};
|
||||
|
||||
export const PageListStory: StoryFn<PageListProps> = (props, { loaded }) => {
|
||||
export const PageListStory: StoryFn<ListProps<ListItem>> = (
|
||||
props,
|
||||
{ loaded }
|
||||
) => {
|
||||
return (
|
||||
<PageListScrollContainer
|
||||
<ListScrollContainer
|
||||
style={{
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<PageList {...props} {...loaded}></PageList>
|
||||
</PageListScrollContainer>
|
||||
<List {...props} {...loaded}></List>
|
||||
</ListScrollContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user