mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-24 05:07:06 +08: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:
@@ -106,4 +106,7 @@ export const setPageModeAtom = atom(
|
|||||||
export type PageModeOption = 'all' | 'page' | 'edgeless';
|
export type PageModeOption = 'all' | 'page' | 'edgeless';
|
||||||
export const allPageModeSelectAtom = atom<PageModeOption>('all');
|
export const allPageModeSelectAtom = atom<PageModeOption>('all');
|
||||||
|
|
||||||
|
export type AllPageFilterOption = 'docs' | 'collections' | 'tags';
|
||||||
|
export const allPageFilterSelectAtom = atom<AllPageFilterOption>('docs');
|
||||||
|
|
||||||
export const openWorkspaceListModalAtom = atom(false);
|
export const openWorkspaceListModalAtom = atom(false);
|
||||||
|
|||||||
@@ -17,13 +17,11 @@ export function registerAffineNavigationCommands({
|
|||||||
store,
|
store,
|
||||||
workspace,
|
workspace,
|
||||||
navigationHelper,
|
navigationHelper,
|
||||||
pageListMode,
|
|
||||||
setPageListMode,
|
setPageListMode,
|
||||||
}: {
|
}: {
|
||||||
t: ReturnType<typeof useAFFiNEI18N>;
|
t: ReturnType<typeof useAFFiNEI18N>;
|
||||||
store: ReturnType<typeof createStore>;
|
store: ReturnType<typeof createStore>;
|
||||||
navigationHelper: ReturnType<typeof useNavigateHelper>;
|
navigationHelper: ReturnType<typeof useNavigateHelper>;
|
||||||
pageListMode: PageModeOption;
|
|
||||||
setPageListMode: React.Dispatch<React.SetStateAction<PageModeOption>>;
|
setPageListMode: React.Dispatch<React.SetStateAction<PageModeOption>>;
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
}) {
|
}) {
|
||||||
@@ -43,32 +41,26 @@ export function registerAffineNavigationCommands({
|
|||||||
|
|
||||||
unsubs.push(
|
unsubs.push(
|
||||||
registerAffineCommand({
|
registerAffineCommand({
|
||||||
id: 'affine:goto-page-list',
|
id: 'affine:goto-collection-list',
|
||||||
category: 'affine:navigation',
|
category: 'affine:navigation',
|
||||||
icon: <ArrowRightBigIcon />,
|
icon: <ArrowRightBigIcon />,
|
||||||
preconditionStrategy: () => {
|
label: 'Go to Collection List',
|
||||||
return pageListMode !== 'page';
|
|
||||||
},
|
|
||||||
label: t['com.affine.cmdk.affine.navigation.goto-page-list'](),
|
|
||||||
run() {
|
run() {
|
||||||
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
|
navigationHelper.jumpToCollections(workspace.id);
|
||||||
setPageListMode('page');
|
setPageListMode('all');
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
unsubs.push(
|
unsubs.push(
|
||||||
registerAffineCommand({
|
registerAffineCommand({
|
||||||
id: 'affine:goto-edgeless-list',
|
id: 'affine:goto-tag-list',
|
||||||
category: 'affine:navigation',
|
category: 'affine:navigation',
|
||||||
icon: <ArrowRightBigIcon />,
|
icon: <ArrowRightBigIcon />,
|
||||||
preconditionStrategy: () => {
|
label: 'Go to Tag List',
|
||||||
return pageListMode !== 'edgeless';
|
|
||||||
},
|
|
||||||
label: t['com.affine.cmdk.affine.navigation.goto-edgeless-list'](),
|
|
||||||
run() {
|
run() {
|
||||||
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
|
navigationHelper.jumpToTags(workspace.id);
|
||||||
setPageListMode('edgeless');
|
setPageListMode('all');
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
+29
@@ -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',
|
||||||
|
});
|
||||||
+29
@@ -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';
|
||||||
+151
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+8
@@ -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,
|
||||||
|
});
|
||||||
+8
-8
@@ -4,12 +4,12 @@ import { useDraggable } from '@dnd-kit/core';
|
|||||||
import { type PropsWithChildren, useCallback, useMemo } from 'react';
|
import { type PropsWithChildren, useCallback, useMemo } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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 * as styles from './page-list-item.css';
|
||||||
import { PageTags } from './page-tags';
|
import { PageTags } from './page-tags';
|
||||||
import type { DraggableTitleCellData, PageListItemProps } from './types';
|
|
||||||
import { ColWrapper, formatDate, stopPropagation } from './utils';
|
|
||||||
|
|
||||||
const PageListTitleCell = ({
|
const ListTitleCell = ({
|
||||||
title,
|
title,
|
||||||
preview,
|
preview,
|
||||||
}: Pick<PageListItemProps, '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 (
|
return (
|
||||||
<div data-testid="page-list-item-icon" className={styles.iconCell}>
|
<div data-testid="page-list-item-icon" className={styles.iconCell}>
|
||||||
{icon}
|
{icon}
|
||||||
@@ -128,9 +128,9 @@ export const PageListItem = (props: PageListItemProps) => {
|
|||||||
selectable={props.selectable}
|
selectable={props.selectable}
|
||||||
selected={props.selected}
|
selected={props.selected}
|
||||||
/>
|
/>
|
||||||
<PageListIconCell icon={props.icon} />
|
<ListIconCell icon={props.icon} />
|
||||||
</div>
|
</div>
|
||||||
<PageListTitleCell title={props.title} preview={props.preview} />
|
<ListTitleCell title={props.title} preview={props.preview} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
@@ -174,9 +174,9 @@ export const PageListItem = (props: PageListItemProps) => {
|
|||||||
selectable={props.selectable}
|
selectable={props.selectable}
|
||||||
selected={props.selected}
|
selected={props.selected}
|
||||||
/>
|
/>
|
||||||
<PageListIconCell icon={props.icon} />
|
<ListIconCell icon={props.icon} />
|
||||||
</div>
|
</div>
|
||||||
<PageListTitleCell title={props.title} preview={props.preview} />
|
<ListTitleCell title={props.title} preview={props.preview} />
|
||||||
</ColWrapper>
|
</ColWrapper>
|
||||||
<ColWrapper flex={4} alignment="end" style={{ overflow: 'visible' }}>
|
<ColWrapper flex={4} alignment="end" style={{ overflow: 'visible' }}>
|
||||||
<PageTagsCell tags={props.tags} />
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+2
-19
@@ -5,8 +5,8 @@ import { assignInlineVars } from '@vanilla-extract/dynamic';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { stopPropagation, tagColorMap } from '../utils';
|
||||||
import * as styles from './page-tags.css';
|
import * as styles from './page-tags.css';
|
||||||
import { stopPropagation } from './utils';
|
|
||||||
|
|
||||||
export interface PageTagsProps {
|
export interface PageTagsProps {
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
@@ -22,24 +22,7 @@ interface TagItemProps {
|
|||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
|
export const TagItem = ({ tag, idx, mode, style }: TagItemProps) => {
|
||||||
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) => {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="page-tag"
|
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/favorite-tag';
|
||||||
export * from './components/floating-toobar';
|
export * from './components/floating-toolbar';
|
||||||
export * from './components/new-page-buttton';
|
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 './filter';
|
||||||
|
export * from './header-col-def';
|
||||||
|
export * from './list';
|
||||||
export * from './operation-cell';
|
export * from './operation-cell';
|
||||||
export * from './operation-menu-items';
|
export * from './operation-menu-items';
|
||||||
export * from './page-list';
|
export * from './page-group';
|
||||||
export * from './page-list-item';
|
export * from './page-header';
|
||||||
export * from './page-tags';
|
export * from './tags';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './use-collection-manager';
|
export * from './use-collection-manager';
|
||||||
|
export * from './use-filtered-page-metas';
|
||||||
|
export * from './use-tag-metas';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
export * from './view';
|
export * from './view';
|
||||||
export * from './virtualized-page-list';
|
export * from './virtualized-list';
|
||||||
|
|||||||
+26
-21
@@ -1,72 +1,77 @@
|
|||||||
import { Trans } from '@affine/i18n';
|
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 { type DateKey } from './types';
|
||||||
import { betweenDaysAgo, withinDaysAgo } from './utils';
|
import { betweenDaysAgo, withinDaysAgo } from './utils';
|
||||||
|
|
||||||
// todo: optimize date matchers
|
// todo: optimize date matchers
|
||||||
const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [
|
const getDateGroupDefinitions = <T extends ListItem>(
|
||||||
|
key: DateKey
|
||||||
|
): ItemGroupDefinition<T>[] => [
|
||||||
{
|
{
|
||||||
id: 'today',
|
id: 'today',
|
||||||
label: <Trans i18nKey="com.affine.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',
|
id: 'yesterday',
|
||||||
label: <Trans i18nKey="com.affine.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',
|
id: 'last7Days',
|
||||||
label: <Trans i18nKey="com.affine.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',
|
id: 'last30Days',
|
||||||
label: <Trans i18nKey="com.affine.last30Days" />,
|
label: <Trans i18nKey="com.affine.last30Days" />,
|
||||||
match: item =>
|
match: item =>
|
||||||
betweenDaysAgo(new Date(item[key] ?? item.createDate), 7, 30),
|
betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 7, 30),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'moreThan30Days',
|
id: 'moreThan30Days',
|
||||||
label: <Trans i18nKey="com.affine.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'),
|
createDate: getDateGroupDefinitions('createDate'),
|
||||||
updatedDate: getDateGroupDefinitions('updatedDate'),
|
updatedDate: getDateGroupDefinitions('updatedDate'),
|
||||||
// add more here later
|
// add more here later
|
||||||
// todo: some page group definitions maybe dynamic
|
// todo: some page group definitions maybe dynamic
|
||||||
};
|
};
|
||||||
|
|
||||||
export function pagesToPageGroups(
|
export function itemsToItemGroups<T extends ListItem>(
|
||||||
pages: PageMeta[],
|
items: T[],
|
||||||
key?: DateKey
|
key?: DateKey
|
||||||
): PageGroupProps[] {
|
): ItemGroupProps<T>[] {
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'all',
|
id: 'all',
|
||||||
items: pages,
|
items: items,
|
||||||
allItems: pages,
|
allItems: items,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// assume pages are already sorted, we will use the page order to determine the group order
|
// assume pages are already sorted, we will use the page order to determine the group order
|
||||||
const groupDefs = pageGroupDefinitions[key];
|
const groupDefs = itemGroupDefinitions[key];
|
||||||
const groups: PageGroupProps[] = [];
|
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
|
// 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) {
|
for (const groupDef of matchedGroups) {
|
||||||
const group = groups.find(g => g.id === groupDef.id);
|
const group = groups.find(g => g.id === groupDef.id);
|
||||||
if (group) {
|
if (group) {
|
||||||
group.items.push(page);
|
group.items.push(item);
|
||||||
} else {
|
} else {
|
||||||
const label =
|
const label =
|
||||||
typeof groupDef.label === 'function'
|
typeof groupDef.label === 'function'
|
||||||
@@ -75,8 +80,8 @@ export function pagesToPageGroups(
|
|||||||
groups.push({
|
groups.push({
|
||||||
id: groupDef.id,
|
id: groupDef.id,
|
||||||
label: label,
|
label: label,
|
||||||
items: [page],
|
items: [item],
|
||||||
allItems: pages,
|
allItems: items,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
+35
-34
@@ -12,41 +12,42 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { PageGroup } from './page-group';
|
import { pageHeaderColsDef } from './header-col-def';
|
||||||
import { PageListTableHeader } from './page-header';
|
import * as styles from './list.css';
|
||||||
import * as styles from './page-list.css';
|
import { ItemGroup } from './page-group';
|
||||||
|
import { ListTableHeader } from './page-header';
|
||||||
import {
|
import {
|
||||||
pageGroupsAtom,
|
groupsAtom,
|
||||||
pageListPropsAtom,
|
listPropsAtom,
|
||||||
PageListProvider,
|
ListProvider,
|
||||||
selectionStateAtom,
|
selectionStateAtom,
|
||||||
useAtom,
|
useAtom,
|
||||||
useAtomValue,
|
useAtomValue,
|
||||||
useSetAtom,
|
useSetAtom,
|
||||||
} from './scoped-atoms';
|
} 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
|
* Given a list of pages, render a list of pages
|
||||||
*/
|
*/
|
||||||
export const PageList = forwardRef<PageListHandle, PageListProps>(
|
export const List = forwardRef<ItemListHandle, ListProps<ListItem>>(
|
||||||
function PageList(props, ref) {
|
function List(props, ref) {
|
||||||
return (
|
return (
|
||||||
// push pageListProps to the atom so that downstream components can consume it
|
// push pageListProps to the atom so that downstream components can consume it
|
||||||
// this makes sure pageListPropsAtom is always populated
|
// this makes sure pageListPropsAtom is always populated
|
||||||
// @ts-expect-error fix type issues later
|
// @ts-expect-error fix type issues later
|
||||||
<PageListProvider initialValues={[[pageListPropsAtom, props]]}>
|
<ListProvider initialValues={[[listPropsAtom, props]]}>
|
||||||
<PageListInnerWrapper {...props} handleRef={ref}>
|
<ListInnerWrapper {...props} handleRef={ref}>
|
||||||
<PageListInner {...props} />
|
<ListInner {...props} />
|
||||||
</PageListInnerWrapper>
|
</ListInnerWrapper>
|
||||||
</PageListProvider>
|
</ListProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// when pressing ESC or double clicking outside of the page list, close the selection mode
|
// when pressing ESC or double clicking outside of the page list, close the selection mode
|
||||||
// todo: use jotai-effect instead but it seems it does not work with jotai-scope?
|
// todo: use jotai-effect instead but it seems it does not work with jotai-scope?
|
||||||
const usePageSelectionStateEffect = () => {
|
const useItemSelectionStateEffect = () => {
|
||||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -96,23 +97,22 @@ const usePageSelectionStateEffect = () => {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageListInnerWrapper = memo(
|
export const ListInnerWrapper = memo(
|
||||||
({
|
({
|
||||||
handleRef,
|
handleRef,
|
||||||
children,
|
children,
|
||||||
onSelectionActiveChange,
|
onSelectionActiveChange,
|
||||||
...props
|
...props
|
||||||
}: PropsWithChildren<
|
}: PropsWithChildren<
|
||||||
PageListProps & { handleRef: ForwardedRef<PageListHandle> }
|
ListProps<ListItem> & { handleRef: ForwardedRef<ItemListHandle> }
|
||||||
>) => {
|
>) => {
|
||||||
const setPageListPropsAtom = useSetAtom(pageListPropsAtom);
|
const setListPropsAtom = useSetAtom(listPropsAtom);
|
||||||
const [selectionState, setPageListSelectionState] =
|
const [selectionState, setListSelectionState] = useAtom(selectionStateAtom);
|
||||||
useAtom(selectionStateAtom);
|
useItemSelectionStateEffect();
|
||||||
usePageSelectionStateEffect();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageListPropsAtom(props);
|
setListPropsAtom(props);
|
||||||
}, [props, setPageListPropsAtom]);
|
}, [props, setListPropsAtom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSelectionActiveChange?.(!!selectionState.selectionActive);
|
onSelectionActiveChange?.(!!selectionState.selectionActive);
|
||||||
@@ -123,41 +123,42 @@ export const PageListInnerWrapper = memo(
|
|||||||
() => {
|
() => {
|
||||||
return {
|
return {
|
||||||
toggleSelectable: () => {
|
toggleSelectable: () => {
|
||||||
setPageListSelectionState(false);
|
setListSelectionState(false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[setPageListSelectionState]
|
[setListSelectionState]
|
||||||
);
|
);
|
||||||
return children;
|
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;
|
const hideHeader = props.hideHeader;
|
||||||
return (
|
return (
|
||||||
<div className={clsx(props.className, styles.root)}>
|
<div className={clsx(props.className, styles.root)}>
|
||||||
{!hideHeader ? <PageListTableHeader /> : null}
|
{!hideHeader ? <ListTableHeader headerCols={pageHeaderColsDef} /> : null}
|
||||||
<div className={styles.groupsContainer}>
|
<div className={styles.groupsContainer}>
|
||||||
{groups.map(group => (
|
{groups.map(group => (
|
||||||
<PageGroup key={group.id} {...group} />
|
<ItemGroup key={group.id} {...group} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PageListScrollContainerProps {
|
interface ListScrollContainerProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PageListScrollContainer = forwardRef<
|
export const ListScrollContainer = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
PropsWithChildren<PageListScrollContainerProps>
|
PropsWithChildren<ListScrollContainerProps>
|
||||||
>(({ className, children, style }, ref) => {
|
>(({ className, children, style }, ref) => {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hasScrollTop = useHasScrollTop(containerRef);
|
const hasScrollTop = useHasScrollTop(containerRef);
|
||||||
@@ -188,4 +189,4 @@ export const PageListScrollContainer = forwardRef<
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
PageListScrollContainer.displayName = 'PageListScrollContainer';
|
ListScrollContainer.displayName = 'ListScrollContainer';
|
||||||
@@ -6,24 +6,34 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
|
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import {
|
import {
|
||||||
|
DeleteIcon,
|
||||||
DeletePermanentlyIcon,
|
DeletePermanentlyIcon,
|
||||||
|
EditIcon,
|
||||||
FavoritedIcon,
|
FavoritedIcon,
|
||||||
FavoriteIcon,
|
FavoriteIcon,
|
||||||
|
FilterIcon,
|
||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
OpenInNewIcon,
|
OpenInNewIcon,
|
||||||
ResetIcon,
|
ResetIcon,
|
||||||
} from '@blocksuite/icons';
|
} from '@blocksuite/icons';
|
||||||
import { useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { FavoriteTag } from './components/favorite-tag';
|
import { FavoriteTag } from './components/favorite-tag';
|
||||||
|
import * as styles from './list.css';
|
||||||
import { DisablePublicSharing, MoveToTrash } from './operation-menu-items';
|
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 { ColWrapper, stopPropagationWithoutPrevent } from './utils';
|
||||||
|
import {
|
||||||
|
type AllPageListConfig,
|
||||||
|
useEditCollection,
|
||||||
|
useEditCollectionName,
|
||||||
|
} from './view';
|
||||||
|
|
||||||
export interface OperationCellProps {
|
export interface PageOperationCellProps {
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
link: string;
|
link: string;
|
||||||
@@ -32,14 +42,14 @@ export interface OperationCellProps {
|
|||||||
onDisablePublicSharing: () => void;
|
onDisablePublicSharing: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OperationCell = ({
|
export const PageOperationCell = ({
|
||||||
favorite,
|
favorite,
|
||||||
isPublic,
|
isPublic,
|
||||||
link,
|
link,
|
||||||
onToggleFavoritePage,
|
onToggleFavoritePage,
|
||||||
onRemoveToTrash,
|
onRemoveToTrash,
|
||||||
onDisablePublicSharing,
|
onDisablePublicSharing,
|
||||||
}: OperationCellProps) => {
|
}: PageOperationCellProps) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [openDisableShared, setOpenDisableShared] = useState(false);
|
const [openDisableShared, setOpenDisableShared] = useState(false);
|
||||||
const OperationMenu = (
|
const OperationMenu = (
|
||||||
@@ -178,3 +188,94 @@ export const TrashOperationCell = ({
|
|||||||
</ColWrapper>
|
</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,
|
PageIcon,
|
||||||
TodayIcon,
|
TodayIcon,
|
||||||
ToggleCollapseIcon,
|
ToggleCollapseIcon,
|
||||||
|
ViewLayersIcon,
|
||||||
} from '@blocksuite/icons';
|
} from '@blocksuite/icons';
|
||||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||||
@@ -15,21 +16,36 @@ import clsx from 'clsx';
|
|||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
import { type MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
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 { PagePreview } from './page-content-preview';
|
||||||
import * as styles from './page-group.css';
|
import * as styles from './page-group.css';
|
||||||
import { PageListItem } from './page-list-item';
|
|
||||||
import {
|
import {
|
||||||
pageGroupCollapseStateAtom,
|
groupCollapseStateAtom,
|
||||||
pageListPropsAtom,
|
listPropsAtom,
|
||||||
selectionStateAtom,
|
selectionStateAtom,
|
||||||
useAtom,
|
useAtom,
|
||||||
useAtomValue,
|
useAtomValue,
|
||||||
} from './scoped-atoms';
|
} 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';
|
import { shallowEqual } from './utils';
|
||||||
|
|
||||||
export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
|
export const ItemGroupHeader = <T extends ListItem>({
|
||||||
const [collapseState, setCollapseState] = useAtom(pageGroupCollapseStateAtom);
|
id,
|
||||||
|
items,
|
||||||
|
label,
|
||||||
|
}: ItemGroupProps<T>) => {
|
||||||
|
const [collapseState, setCollapseState] = useAtom(groupCollapseStateAtom);
|
||||||
const collapsed = collapseState[id];
|
const collapsed = collapseState[id];
|
||||||
const onExpandedClicked: MouseEventHandler = useCallback(
|
const onExpandedClicked: MouseEventHandler = useCallback(
|
||||||
e => {
|
e => {
|
||||||
@@ -42,9 +58,9 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
|
|||||||
|
|
||||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||||
const selectedItems = useMemo(() => {
|
const selectedItems = useMemo(() => {
|
||||||
const selectedPageIds = selectionState.selectedPageIds ?? [];
|
const selectedIds = selectionState.selectedIds ?? [];
|
||||||
return items.filter(item => selectedPageIds.includes(item.id));
|
return items.filter(item => selectedIds.includes(item.id));
|
||||||
}, [items, selectionState.selectedPageIds]);
|
}, [items, selectionState.selectedIds]);
|
||||||
|
|
||||||
const allSelected = selectedItems.length === items.length;
|
const allSelected = selectedItems.length === items.length;
|
||||||
|
|
||||||
@@ -53,7 +69,7 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
|
|||||||
setSelectionActive(true);
|
setSelectionActive(true);
|
||||||
|
|
||||||
const nonCurrentGroupIds =
|
const nonCurrentGroupIds =
|
||||||
selectionState.selectedPageIds?.filter(
|
selectionState.selectedIds?.filter(
|
||||||
id => !items.map(item => item.id).includes(id)
|
id => !items.map(item => item.id).includes(id)
|
||||||
) ?? [];
|
) ?? [];
|
||||||
|
|
||||||
@@ -61,7 +77,7 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
|
|||||||
? nonCurrentGroupIds
|
? nonCurrentGroupIds
|
||||||
: [...nonCurrentGroupIds, ...items.map(item => item.id)];
|
: [...nonCurrentGroupIds, ...items.map(item => item.id)];
|
||||||
|
|
||||||
selectionState.onSelectedPageIdsChange?.(newSelectedPageIds);
|
selectionState.onSelectedIdsChange?.(newSelectedPageIds);
|
||||||
}, [setSelectionActive, selectionState, allSelected, items]);
|
}, [setSelectionActive, selectionState, allSelected, items]);
|
||||||
|
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
@@ -103,7 +119,11 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
|
|||||||
) : null;
|
) : 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 [collapsed, setCollapsed] = useState(false);
|
||||||
const onExpandedClicked: MouseEventHandler = useCallback(e => {
|
const onExpandedClicked: MouseEventHandler = useCallback(e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -112,16 +132,16 @@ export const PageGroup = ({ id, items, label }: PageGroupProps) => {
|
|||||||
}, []);
|
}, []);
|
||||||
const selectionState = useAtomValue(selectionStateAtom);
|
const selectionState = useAtomValue(selectionStateAtom);
|
||||||
const selectedItems = useMemo(() => {
|
const selectedItems = useMemo(() => {
|
||||||
const selectedPageIds = selectionState.selectedPageIds ?? [];
|
const selectedIds = selectionState.selectedIds ?? [];
|
||||||
return items.filter(item => selectedPageIds.includes(item.id));
|
return items.filter(item => selectedIds.includes(item.id));
|
||||||
}, [items, selectionState.selectedPageIds]);
|
}, [items, selectionState.selectedIds]);
|
||||||
const onSelectAll = useCallback(() => {
|
const onSelectAll = useCallback(() => {
|
||||||
const nonCurrentGroupIds =
|
const nonCurrentGroupIds =
|
||||||
selectionState.selectedPageIds?.filter(
|
selectionState.selectedIds?.filter(
|
||||||
id => !items.map(item => item.id).includes(id)
|
id => !items.map(item => item.id).includes(id)
|
||||||
) ?? [];
|
) ?? [];
|
||||||
|
|
||||||
selectionState.onSelectedPageIdsChange?.([
|
selectionState.onSelectedIdsChange?.([
|
||||||
...nonCurrentGroupIds,
|
...nonCurrentGroupIds,
|
||||||
...items.map(item => item.id),
|
...items.map(item => item.id),
|
||||||
]);
|
]);
|
||||||
@@ -164,7 +184,7 @@ export const PageGroup = ({ id, items, label }: PageGroupProps) => {
|
|||||||
<Collapsible.Content className={styles.collapsibleContent}>
|
<Collapsible.Content className={styles.collapsibleContent}>
|
||||||
<div className={styles.collapsibleContentInner}>
|
<div className={styles.collapsibleContentInner}>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<PageMetaListItemRenderer key={item.id} {...item} />
|
<PageListItemRenderer key={item.id} {...item} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
@@ -177,32 +197,64 @@ const requiredPropNames = [
|
|||||||
'blockSuiteWorkspace',
|
'blockSuiteWorkspace',
|
||||||
'rowAsLink',
|
'rowAsLink',
|
||||||
'isPreferredEdgeless',
|
'isPreferredEdgeless',
|
||||||
'pageOperationsRenderer',
|
'operationsRenderer',
|
||||||
'selectedPageIds',
|
'selectedIds',
|
||||||
'onSelectedPageIdsChange',
|
'onSelectedIdsChange',
|
||||||
'draggable',
|
'draggable',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type RequiredProps = Pick<PageListProps, (typeof requiredPropNames)[number]> & {
|
type RequiredProps<T> = Pick<
|
||||||
|
ListProps<T>,
|
||||||
|
(typeof requiredPropNames)[number]
|
||||||
|
> & {
|
||||||
selectable: boolean;
|
selectable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const listPropsAtom = selectAtom(
|
const listsPropsAtom = selectAtom(
|
||||||
pageListPropsAtom,
|
listPropsAtom,
|
||||||
props => {
|
props => {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
requiredPropNames.map(name => [name, props[name]])
|
requiredPropNames.map(name => [name, props[name]])
|
||||||
) as RequiredProps;
|
) as RequiredProps<ListItem>;
|
||||||
},
|
},
|
||||||
shallowEqual
|
shallowEqual
|
||||||
);
|
);
|
||||||
|
|
||||||
export const PageMetaListItemRenderer = (pageMeta: PageMeta) => {
|
export const PageListItemRenderer = (item: ListItem) => {
|
||||||
const props = useAtomValue(listPropsAtom);
|
const props = useAtomValue(listsPropsAtom);
|
||||||
const { selectionActive } = useAtomValue(selectionStateAtom);
|
const { selectionActive } = useAtomValue(selectionStateAtom);
|
||||||
|
const page = item as PageMeta;
|
||||||
return (
|
return (
|
||||||
<PageListItem
|
<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,
|
...props,
|
||||||
selectable: !!selectionActive,
|
selectable: !!selectionActive,
|
||||||
})}
|
})}
|
||||||
@@ -231,9 +283,9 @@ const UnifiedPageIcon = ({
|
|||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
workspace: Workspace;
|
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);
|
const { isJournal } = useJournalInfoHelper(workspace, id);
|
||||||
if (isJournal) {
|
if (isJournal) {
|
||||||
return <TodayIcon />;
|
return <TodayIcon />;
|
||||||
@@ -241,61 +293,132 @@ const UnifiedPageIcon = ({
|
|||||||
return isEdgeless ? <EdgelessIcon /> : <PageIcon />;
|
return isEdgeless ? <EdgelessIcon /> : <PageIcon />;
|
||||||
};
|
};
|
||||||
|
|
||||||
function pageMetaToPageItemProp(
|
function pageMetaToListItemProp(
|
||||||
pageMeta: PageMeta,
|
item: PageMeta,
|
||||||
props: RequiredProps
|
props: RequiredProps<PageMeta>
|
||||||
): PageListItemProps {
|
): PageListItemProps {
|
||||||
const toggleSelection = props.onSelectedPageIdsChange
|
const toggleSelection = props.onSelectedIdsChange
|
||||||
? () => {
|
? () => {
|
||||||
assertExists(props.selectedPageIds);
|
assertExists(props.selectedIds);
|
||||||
const prevSelected = props.selectedPageIds.includes(pageMeta.id);
|
const prevSelected = props.selectedIds.includes(item.id);
|
||||||
const shouldAdd = !prevSelected;
|
const shouldAdd = !prevSelected;
|
||||||
const shouldRemove = prevSelected;
|
const shouldRemove = prevSelected;
|
||||||
|
|
||||||
if (shouldAdd) {
|
if (shouldAdd) {
|
||||||
props.onSelectedPageIdsChange?.([
|
props.onSelectedIdsChange?.([...props.selectedIds, item.id]);
|
||||||
...props.selectedPageIds,
|
|
||||||
pageMeta.id,
|
|
||||||
]);
|
|
||||||
} else if (shouldRemove) {
|
} else if (shouldRemove) {
|
||||||
props.onSelectedPageIdsChange?.(
|
props.onSelectedIdsChange?.(
|
||||||
props.selectedPageIds.filter(id => id !== pageMeta.id)
|
props.selectedIds.filter(id => id !== item.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const itemProps: PageListItemProps = {
|
const itemProps: PageListItemProps = {
|
||||||
pageId: pageMeta.id,
|
pageId: item.id,
|
||||||
title: <PageTitle id={pageMeta.id} workspace={props.blockSuiteWorkspace} />,
|
title: <PageTitle id={item.id} workspace={props.blockSuiteWorkspace} />,
|
||||||
preview: (
|
preview: (
|
||||||
<PagePreview workspace={props.blockSuiteWorkspace} pageId={pageMeta.id} />
|
<PagePreview workspace={props.blockSuiteWorkspace} pageId={item.id} />
|
||||||
),
|
),
|
||||||
createDate: new Date(pageMeta.createDate),
|
createDate: new Date(item.createDate),
|
||||||
updatedDate: pageMeta.updatedDate
|
updatedDate: item.updatedDate ? new Date(item.updatedDate) : undefined,
|
||||||
? new Date(pageMeta.updatedDate)
|
|
||||||
: undefined,
|
|
||||||
to:
|
to:
|
||||||
props.rowAsLink && !props.selectable
|
props.rowAsLink && !props.selectable
|
||||||
? `/workspace/${props.blockSuiteWorkspace.id}/${pageMeta.id}`
|
? `/workspace/${props.blockSuiteWorkspace.id}/${item.id}`
|
||||||
: undefined,
|
: undefined,
|
||||||
onClick: props.selectable ? toggleSelection : undefined,
|
onClick: props.selectable ? toggleSelection : undefined,
|
||||||
icon: (
|
icon: (
|
||||||
<UnifiedPageIcon
|
<UnifiedPageIcon
|
||||||
id={pageMeta.id}
|
id={item.id}
|
||||||
workspace={props.blockSuiteWorkspace}
|
workspace={props.blockSuiteWorkspace}
|
||||||
isPreferredEdgeless={props.isPreferredEdgeless}
|
isPreferredEdgeless={props.isPreferredEdgeless}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
tags:
|
tags:
|
||||||
pageMeta.tags
|
item.tags
|
||||||
?.map(id => tagIdToTagOption(id, props.blockSuiteWorkspace))
|
?.map(id => tagIdToTagOption(id, props.blockSuiteWorkspace))
|
||||||
.filter((v): v is Tag => v != null) ?? [],
|
.filter((v): v is Tag => v != null) ?? [],
|
||||||
operations: props.pageOperationsRenderer?.(pageMeta),
|
operations: props.operationsRenderer?.(item),
|
||||||
selectable: props.selectable,
|
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,
|
onSelectedChange: toggleSelection,
|
||||||
draggable: props.draggable,
|
draggable: props.draggable,
|
||||||
isPublicPage: !!pageMeta.isPublic,
|
|
||||||
};
|
};
|
||||||
return itemProps;
|
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 { Checkbox, type CheckboxProps } from '@affine/component';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { MultiSelectIcon, SortDownIcon, SortUpIcon } from '@blocksuite/icons';
|
import { MultiSelectIcon } from '@blocksuite/icons';
|
||||||
import type { PageMeta } from '@blocksuite/store';
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
import {
|
import { type MouseEventHandler, useCallback } from 'react';
|
||||||
type MouseEventHandler,
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import * as styles from './page-list.css';
|
import { ListHeaderCell } from './components/list-header-cell';
|
||||||
|
import * as styles from './page-header.css';
|
||||||
import {
|
import {
|
||||||
pageListHandlersAtom,
|
itemsAtom,
|
||||||
pageListPropsAtom,
|
listHandlersAtom,
|
||||||
pagesAtom,
|
listPropsAtom,
|
||||||
selectionStateAtom,
|
selectionStateAtom,
|
||||||
showOperationsAtom,
|
|
||||||
sorterAtom,
|
sorterAtom,
|
||||||
useAtom,
|
useAtom,
|
||||||
useAtomValue,
|
useAtomValue,
|
||||||
} from './scoped-atoms';
|
} from './scoped-atoms';
|
||||||
import { ColWrapper, type ColWrapperProps, stopPropagation } from './utils';
|
import type { HeaderColDef, ListItem } from './types';
|
||||||
|
import { 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
// the checkbox on the header has three states:
|
// the checkbox on the header has three states:
|
||||||
// when list selectable = true, the checkbox will be presented
|
// 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 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
|
// 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 [selectionState, setSelectionState] = useAtom(selectionStateAtom);
|
||||||
const pages = useAtomValue(pagesAtom);
|
const items = useAtomValue(itemsAtom);
|
||||||
const onActivateSelection: MouseEventHandler = useCallback(
|
const onActivateSelection: MouseEventHandler = useCallback(
|
||||||
e => {
|
e => {
|
||||||
stopPropagation(e);
|
stopPropagation(e);
|
||||||
@@ -86,13 +33,13 @@ const PageListHeaderCheckbox = () => {
|
|||||||
},
|
},
|
||||||
[setSelectionState]
|
[setSelectionState]
|
||||||
);
|
);
|
||||||
const handlers = useAtomValue(pageListHandlersAtom);
|
const handlers = useAtomValue(listHandlersAtom);
|
||||||
const onChange: NonNullable<CheckboxProps['onChange']> = useCallback(
|
const onChange: NonNullable<CheckboxProps['onChange']> = useCallback(
|
||||||
(e, checked) => {
|
(e, checked) => {
|
||||||
stopPropagation(e);
|
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) {
|
if (!selectionState.selectable) {
|
||||||
@@ -109,11 +56,11 @@ const PageListHeaderCheckbox = () => {
|
|||||||
<MultiSelectIcon />
|
<MultiSelectIcon />
|
||||||
) : (
|
) : (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectionState.selectedPageIds?.length === pages.length}
|
checked={selectionState.selectedIds?.length === items.length}
|
||||||
indeterminate={
|
indeterminate={
|
||||||
selectionState.selectedPageIds &&
|
selectionState.selectedIds &&
|
||||||
selectionState.selectedPageIds.length > 0 &&
|
selectionState.selectedIds.length > 0 &&
|
||||||
selectionState.selectedPageIds.length < pages.length
|
selectionState.selectedIds.length < items.length
|
||||||
}
|
}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
@@ -122,64 +69,37 @@ const PageListHeaderCheckbox = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PageListHeaderTitleCell = () => {
|
export const ListHeaderTitleCell = () => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
return (
|
return (
|
||||||
<div className={styles.headerTitleCell}>
|
<div className={styles.headerTitleCell}>
|
||||||
<PageListHeaderCheckbox />
|
<ListHeaderCheckbox />
|
||||||
{t['Title']()}
|
{t['Title']()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideHeaderAtom = selectAtom(pageListPropsAtom, props => props.hideHeader);
|
const hideHeaderAtom = selectAtom(listPropsAtom, props => props.hideHeader);
|
||||||
|
|
||||||
// the table header for page list
|
// the table header for page list
|
||||||
export const PageListTableHeader = () => {
|
export const ListTableHeader = ({
|
||||||
const t = useAFFiNEI18N();
|
headerCols,
|
||||||
const showOperations = useAtomValue(showOperationsAtom);
|
}: {
|
||||||
|
headerCols: HeaderColDef[];
|
||||||
|
}) => {
|
||||||
|
const [sorter, setSorter] = useAtom(sorterAtom);
|
||||||
const hideHeader = useAtomValue(hideHeaderAtom);
|
const hideHeader = useAtomValue(hideHeaderAtom);
|
||||||
const selectionState = useAtomValue(selectionStateAtom);
|
const selectionState = useAtomValue(selectionStateAtom);
|
||||||
const headerCols = useMemo(() => {
|
const onSort = useCallback(
|
||||||
const cols: (HeaderColDef | boolean)[] = [
|
(sortable?: boolean, sortKey?: keyof ListItem) => {
|
||||||
{
|
if (sortable && sortKey) {
|
||||||
key: 'title',
|
setSorter({
|
||||||
content: <PageListHeaderTitleCell />,
|
newSortKey: sortKey,
|
||||||
flex: 6,
|
});
|
||||||
alignment: 'start',
|
}
|
||||||
sortable: true,
|
},
|
||||||
},
|
[setSorter]
|
||||||
{
|
);
|
||||||
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]);
|
|
||||||
|
|
||||||
if (hideHeader) {
|
if (hideHeader) {
|
||||||
return false;
|
return false;
|
||||||
@@ -193,17 +113,20 @@ export const PageListTableHeader = () => {
|
|||||||
>
|
>
|
||||||
{headerCols.map(col => {
|
{headerCols.map(col => {
|
||||||
return (
|
return (
|
||||||
<PageListHeaderCell
|
<ListHeaderCell
|
||||||
flex={col.flex}
|
flex={col.flex}
|
||||||
alignment={col.alignment}
|
alignment={col.alignment}
|
||||||
key={col.key}
|
key={col.key}
|
||||||
sortKey={col.key as keyof PageMeta}
|
sortKey={col.key as keyof ListItem}
|
||||||
sortable={col.sortable}
|
sortable={col.sortable}
|
||||||
|
sorting={sorter.key === col.key}
|
||||||
|
order={sorter.order}
|
||||||
|
onSort={onSort}
|
||||||
style={{ overflow: 'visible' }}
|
style={{ overflow: 'visible' }}
|
||||||
hideInSmallContainer={col.hideInSmallContainer}
|
hideInSmallContainer={col.hideInSmallContainer}
|
||||||
>
|
>
|
||||||
{col.content}
|
{col.content}
|
||||||
</PageListHeaderCell>
|
</ListHeaderCell>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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 { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
||||||
import type { PageMeta } from '@blocksuite/store';
|
|
||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
import { createIsolation } from 'jotai-scope';
|
import { createIsolation } from 'jotai-scope';
|
||||||
|
|
||||||
import { pagesToPageGroups } from './pages-to-page-group';
|
import { itemsToItemGroups } from './items-to-item-group';
|
||||||
import type {
|
import type {
|
||||||
PageListProps,
|
ListItem,
|
||||||
PageMetaRecord,
|
ListProps,
|
||||||
VirtualizedPageListProps,
|
MetaRecord,
|
||||||
|
VirtualizedListProps,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { shallowEqual } from './utils';
|
import { shallowEqual } from './utils';
|
||||||
|
|
||||||
// for ease of use in the component tree
|
// for ease of use in the component tree
|
||||||
// note: must use selectAtom to access this atom for efficiency
|
// 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
|
// @ts-expect-error the error is expected but we will assume the default value is always there by using useHydrateAtoms
|
||||||
export const pageListPropsAtom = atom<
|
export const listPropsAtom = atom<
|
||||||
PageListProps & Partial<VirtualizedPageListProps>
|
ListProps<ListItem> & Partial<VirtualizedListProps<ListItem>>
|
||||||
>();
|
>();
|
||||||
|
|
||||||
// whether or not the table is in selection mode (showing selection checkbox & selection floating bar)
|
// 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(
|
export const selectionStateAtom = atom(
|
||||||
get => {
|
get => {
|
||||||
const baseAtom = selectAtom(
|
const baseAtom = selectAtom(
|
||||||
pageListPropsAtom,
|
listPropsAtom,
|
||||||
props => {
|
props => {
|
||||||
const { selectable, selectedPageIds, onSelectedPageIdsChange } = props;
|
const { selectable, selectedIds, onSelectedIdsChange } = props;
|
||||||
return {
|
return {
|
||||||
selectable,
|
selectable,
|
||||||
selectedPageIds,
|
selectedIds,
|
||||||
onSelectedPageIdsChange,
|
onSelectedIdsChange,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
shallowEqual
|
shallowEqual
|
||||||
@@ -53,50 +53,49 @@ export const selectionStateAtom = atom(
|
|||||||
|
|
||||||
// id -> isCollapsed
|
// id -> isCollapsed
|
||||||
// maybe reset on page on unmount?
|
// maybe reset on page on unmount?
|
||||||
export const pageGroupCollapseStateAtom = atom<Record<string, boolean>>({});
|
export const groupCollapseStateAtom = atom<Record<string, boolean>>({});
|
||||||
|
|
||||||
// get handlers from pageListPropsAtom
|
// get handlers from pageListPropsAtom
|
||||||
export const pageListHandlersAtom = selectAtom(
|
export const listHandlersAtom = selectAtom(
|
||||||
pageListPropsAtom,
|
listPropsAtom,
|
||||||
props => {
|
props => {
|
||||||
const { onSelectedPageIdsChange } = props;
|
const { onSelectedIdsChange } = props;
|
||||||
return {
|
return {
|
||||||
onSelectedPageIdsChange,
|
onSelectedIdsChange,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
shallowEqual
|
shallowEqual
|
||||||
);
|
);
|
||||||
|
|
||||||
export const pagesAtom = selectAtom(
|
export const itemsAtom = selectAtom(
|
||||||
pageListPropsAtom,
|
listPropsAtom,
|
||||||
props => props.pages,
|
props => props.items,
|
||||||
shallowEqual
|
shallowEqual
|
||||||
);
|
);
|
||||||
|
|
||||||
export const showOperationsAtom = selectAtom(
|
export const showOperationsAtom = selectAtom(
|
||||||
pageListPropsAtom,
|
listPropsAtom,
|
||||||
props => !!props.pageOperationsRenderer
|
props => !!props.operationsRenderer
|
||||||
);
|
);
|
||||||
|
|
||||||
type SortingContext<T extends string | number | symbol> = {
|
type SortingContext<KeyType extends string | number | symbol> = {
|
||||||
key: T;
|
key: KeyType;
|
||||||
order: 'asc' | 'desc';
|
order: 'asc' | 'desc';
|
||||||
fallbackKey?: T;
|
fallbackKey?: KeyType;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SorterConfig<T extends Record<string, unknown> = Record<string, unknown>> =
|
type SorterConfig<T> = {
|
||||||
{
|
key?: keyof T;
|
||||||
key?: keyof T;
|
order: 'asc' | 'desc';
|
||||||
order: 'asc' | 'desc';
|
sortingFn: (ctx: SortingContext<keyof T>, a: T, b: T) => number;
|
||||||
sortingFn: (ctx: SortingContext<keyof T>, a: T, b: T) => number;
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const defaultSortingFn: SorterConfig<PageMetaRecord>['sortingFn'] = (
|
const defaultSortingFn: SorterConfig<MetaRecord<ListItem>>['sortingFn'] = (
|
||||||
ctx,
|
ctx,
|
||||||
a,
|
a,
|
||||||
b
|
b
|
||||||
) => {
|
) => {
|
||||||
const val = (obj: PageMetaRecord) => {
|
const val = (obj: MetaRecord<ListItem>) => {
|
||||||
let v = obj[ctx.key];
|
let v = obj[ctx.key];
|
||||||
if (v === undefined && ctx.fallbackKey) {
|
if (v === undefined && ctx.fallbackKey) {
|
||||||
v = obj[ctx.fallbackKey];
|
v = obj[ctx.fallbackKey];
|
||||||
@@ -134,7 +133,14 @@ const defaultSortingFn: SorterConfig<PageMetaRecord>['sortingFn'] = (
|
|||||||
return 0;
|
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,
|
key: DEFAULT_SORT_KEY,
|
||||||
order: 'desc',
|
order: 'desc',
|
||||||
sortingFn: defaultSortingFn,
|
sortingFn: defaultSortingFn,
|
||||||
@@ -142,47 +148,45 @@ const sorterStateAtom = atom<SorterConfig<PageMetaRecord>>({
|
|||||||
|
|
||||||
export const sorterAtom = atom(
|
export const sorterAtom = atom(
|
||||||
get => {
|
get => {
|
||||||
let pages = get(pagesAtom);
|
let items = get(itemsAtom);
|
||||||
const sorterState = get(sorterStateAtom);
|
const sorterState = get(sorterStateAtom);
|
||||||
const sortCtx: SortingContext<keyof PageMetaRecord> | null = sorterState.key
|
const sortCtx: SortingContext<keyof MetaRecord<ListItem>> | null =
|
||||||
? {
|
sorterState.key
|
||||||
key: sorterState.key,
|
? {
|
||||||
order: sorterState.order,
|
key: sorterState.key,
|
||||||
}
|
order: sorterState.order,
|
||||||
: null;
|
}
|
||||||
|
: null;
|
||||||
if (sortCtx) {
|
if (sortCtx) {
|
||||||
if (sorterState.key === 'updatedDate') {
|
if (sorterState.key === 'updatedDate') {
|
||||||
sortCtx.fallbackKey = 'createDate';
|
sortCtx.fallbackKey = 'createDate';
|
||||||
}
|
}
|
||||||
const compareFn = (a: PageMetaRecord, b: PageMetaRecord) =>
|
const compareFn = (a: MetaRecord<ListItem>, b: MetaRecord<ListItem>) =>
|
||||||
sorterState.sortingFn(sortCtx, a, b);
|
sorterState.sortingFn(sortCtx, a, b);
|
||||||
pages = [...pages].sort(compareFn);
|
items = [...items].sort(compareFn);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
pages,
|
items,
|
||||||
...sortCtx,
|
...sortCtx,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
(_get, set, { newSortKey }: { newSortKey: keyof PageMeta }) => {
|
(_get, set, { newSortKey }: { newSortKey: keyof MetaRecord<ListItem> }) => {
|
||||||
set(sorterStateAtom, sorterState => {
|
set(sorterStateAtom, sorterState => {
|
||||||
if (sorterState.key === newSortKey) {
|
if (validKeys.includes(newSortKey)) {
|
||||||
return {
|
return {
|
||||||
...sorterState,
|
...sorterState,
|
||||||
order: sorterState.order === 'asc' ? 'desc' : 'asc',
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
key: newSortKey,
|
key: newSortKey,
|
||||||
order: 'desc',
|
order: sorterState.order === 'asc' ? 'desc' : 'asc',
|
||||||
sortingFn: sorterState.sortingFn,
|
sortingFn: sorterState.sortingFn,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
return sorterState;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const pageGroupsAtom = atom(get => {
|
export const groupsAtom = atom(get => {
|
||||||
let groupBy = get(selectAtom(pageListPropsAtom, props => props.groupBy));
|
let groupBy = get(selectAtom(listPropsAtom, props => props.groupBy));
|
||||||
const sorter = get(sorterAtom);
|
const sorter = get(sorterAtom);
|
||||||
|
|
||||||
if (groupBy === false) {
|
if (groupBy === false) {
|
||||||
@@ -196,11 +200,11 @@ export const pageGroupsAtom = atom(get => {
|
|||||||
? DEFAULT_SORT_KEY
|
? DEFAULT_SORT_KEY
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
return pagesToPageGroups(sorter.pages, groupBy);
|
return itemsToItemGroups<ListItem>(sorter.items, groupBy);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
Provider: PageListProvider,
|
Provider: ListProvider,
|
||||||
useAtom,
|
useAtom,
|
||||||
useAtomValue,
|
useAtomValue,
|
||||||
useSetAtom,
|
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 { PageMeta, Workspace } from '@blocksuite/store';
|
||||||
import type { ReactNode } from 'react';
|
import type { PropsWithChildren, ReactNode } from 'react';
|
||||||
import type { To } from 'react-router-dom';
|
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
|
// TODO: consider reducing the number of props here
|
||||||
// using type instead of interface to make it Record compatible
|
// using type instead of interface to make it Record compatible
|
||||||
export type PageListItemProps = {
|
export type PageListItemProps = {
|
||||||
@@ -23,10 +39,41 @@ export type PageListItemProps = {
|
|||||||
onSelectedChange?: () => void;
|
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
|
// 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
|
// todo: a temporary solution. may need to be refactored later
|
||||||
export interface SortBy {
|
export interface SortBy {
|
||||||
@@ -36,56 +83,75 @@ export interface SortBy {
|
|||||||
|
|
||||||
export type DateKey = 'createDate' | 'updatedDate';
|
export type DateKey = 'createDate' | 'updatedDate';
|
||||||
|
|
||||||
export interface PageListProps {
|
export interface ListProps<T> {
|
||||||
// required data:
|
// required data:
|
||||||
pages: PageMeta[];
|
items: T[];
|
||||||
blockSuiteWorkspace: Workspace;
|
blockSuiteWorkspace: Workspace;
|
||||||
className?: string;
|
className?: string;
|
||||||
hideHeader?: boolean; // whether or not to hide the header. default is false (showing header)
|
hideHeader?: boolean; // whether or not to hide the header. default is false (showing header)
|
||||||
groupBy?: PagesGroupByType | false;
|
groupBy?: ItemGroupByType | false;
|
||||||
isPreferredEdgeless: (pageId: string) => boolean; // determines the icon used for each row
|
isPreferredEdgeless?: (pageId: string) => boolean; // determines the icon used for each row
|
||||||
rowAsLink?: boolean;
|
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
|
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
|
selectedIds?: string[]; // selected page ids
|
||||||
onSelectedPageIdsChange?: (selected: string[]) => void;
|
onSelectedIdsChange?: (selected: string[]) => void;
|
||||||
onSelectionActiveChange?: (active: boolean) => void;
|
onSelectionActiveChange?: (active: boolean) => void;
|
||||||
draggable?: boolean; // whether or not to allow dragging this page item
|
draggable?: boolean; // whether or not to allow dragging this page item
|
||||||
// we also need the following to make sure the page list functions properly
|
// we also need the following to make sure the page list functions properly
|
||||||
// maybe we could also give a function to render PageListItem?
|
// 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)
|
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
|
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
|
atTopStateChange?: (atTop: boolean) => void; // called when the user scrolls to the top or not
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageListHandle {
|
export interface ItemListHandle {
|
||||||
toggleSelectable: () => void;
|
toggleSelectable: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageGroupDefinition {
|
export interface ItemGroupDefinition<T> {
|
||||||
id: string;
|
id: string;
|
||||||
// using a function to render custom group header
|
// using a function to render custom group header
|
||||||
label: (() => ReactNode) | ReactNode;
|
label: (() => ReactNode) | ReactNode;
|
||||||
match: (item: PageMeta) => boolean;
|
match: (item: T) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageGroupProps {
|
export interface ItemGroupProps<T> {
|
||||||
id: string;
|
id: string;
|
||||||
label?: ReactNode; // if there is no label, it is a default group (without header)
|
label?: ReactNode; // if there is no label, it is a default group (without header)
|
||||||
items: PageMeta[];
|
items: T[];
|
||||||
allItems: PageMeta[];
|
allItems: T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type MakeRecord<T> = {
|
type MakeRecord<T> = {
|
||||||
[P in keyof T]: T[P];
|
[P in keyof T]: T[P];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PageMetaRecord = MakeRecord<PageMeta>;
|
export type MetaRecord<T> = MakeRecord<T>;
|
||||||
|
|
||||||
export type DraggableTitleCellData = {
|
export type DraggableTitleCellData = {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
pageTitle: ReactNode;
|
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>;
|
||||||
|
|||||||
+9
-9
@@ -1,16 +1,16 @@
|
|||||||
import {
|
import { allPageModeSelectAtom } from '@affine/core/atoms';
|
||||||
filterPage,
|
import { collectionsCRUDAtom } from '@affine/core/atoms/collections';
|
||||||
filterPageByRules,
|
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
|
||||||
useCollectionManager,
|
import type { BlockSuiteWorkspace } from '@affine/core/shared';
|
||||||
} from '@affine/core/components/page-list';
|
|
||||||
import type { PageMeta } from '@blocksuite/store';
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { allPageModeSelectAtom } from '../../atoms';
|
import {
|
||||||
import { collectionsCRUDAtom } from '../../atoms/collections';
|
filterPage,
|
||||||
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
|
filterPageByRules,
|
||||||
import type { BlockSuiteWorkspace } from '../../shared';
|
useCollectionManager,
|
||||||
|
} from './use-collection-manager';
|
||||||
|
|
||||||
export const useFilteredPageMetas = (
|
export const useFilteredPageMetas = (
|
||||||
route: 'all' | 'trash',
|
route: 'all' | 'trash',
|
||||||
@@ -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 clsx from 'clsx';
|
||||||
import {
|
import { type BaseSyntheticEvent, forwardRef } from 'react';
|
||||||
type BaseSyntheticEvent,
|
|
||||||
forwardRef,
|
|
||||||
type PropsWithChildren,
|
|
||||||
} 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 {
|
export function isToday(date: Date): boolean {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -71,14 +68,6 @@ export const formatDate = (date: Date): string => {
|
|||||||
return `${month}-${day} ${hours}:${minutes}`;
|
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>(
|
export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
|
||||||
function ColWrapper(
|
function ColWrapper(
|
||||||
{
|
{
|
||||||
@@ -103,11 +92,10 @@ export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
|
|||||||
flexBasis: flex ? `${(flex / 12) * 100}%` : 'auto',
|
flexBasis: flex ? `${(flex / 12) * 100}%` : 'auto',
|
||||||
justifyContent: alignment,
|
justifyContent: alignment,
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
data-hide-item={hideInSmallContainer ? true : undefined}
|
||||||
className,
|
className={clsx(className, styles.colWrapper, {
|
||||||
styles.colWrapper,
|
[styles.hideInSmallContainer]: hideInSmallContainer,
|
||||||
hideInSmallContainer ? styles.hideInSmallContainer : null
|
})}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -173,3 +161,20 @@ export function shallowEqual(objA: any, objB: any) {
|
|||||||
|
|
||||||
return true;
|
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': {
|
':hover': {
|
||||||
backgroundColor: 'var(--affine-hover-color)',
|
backgroundColor: 'var(--affine-hover-color)',
|
||||||
},
|
},
|
||||||
|
selectors: {
|
||||||
|
[`&[data-is-hidden="true"]`]: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ export const CollectionList = ({
|
|||||||
propertiesMeta,
|
propertiesMeta,
|
||||||
allPageListConfig,
|
allPageListConfig,
|
||||||
userInfo,
|
userInfo,
|
||||||
|
disable,
|
||||||
}: {
|
}: {
|
||||||
setting: ReturnType<typeof useCollectionManager>;
|
setting: ReturnType<typeof useCollectionManager>;
|
||||||
propertiesMeta: PropertiesMeta;
|
propertiesMeta: PropertiesMeta;
|
||||||
allPageListConfig: AllPageListConfig;
|
allPageListConfig: AllPageListConfig;
|
||||||
userInfo: DeleteCollectionInfo;
|
userInfo: DeleteCollectionInfo;
|
||||||
|
disable?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [collection, setCollection] = useState<Collection>();
|
const [collection, setCollection] = useState<Collection>();
|
||||||
@@ -72,6 +74,7 @@ export const CollectionList = ({
|
|||||||
className={styles.filterMenuTrigger}
|
className={styles.filterMenuTrigger}
|
||||||
type="default"
|
type="default"
|
||||||
icon={<FilterIcon />}
|
icon={<FilterIcon />}
|
||||||
|
data-is-hidden={disable}
|
||||||
data-testid="create-first-filter"
|
data-testid="create-first-filter"
|
||||||
>
|
>
|
||||||
{t['com.affine.filter']()}
|
{t['com.affine.filter']()}
|
||||||
|
|||||||
+24
-8
@@ -8,7 +8,11 @@ import { type ReactNode, useCallback } from 'react';
|
|||||||
|
|
||||||
import { FilterList } from '../../filter/filter-list';
|
import { FilterList } from '../../filter/filter-list';
|
||||||
import { VariableSelect } from '../../filter/vars';
|
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 type { AllPageListConfig } from './edit-collection';
|
||||||
import * as styles from './edit-collection.css';
|
import * as styles from './edit-collection.css';
|
||||||
import { EmptyList } from './select-page';
|
import { EmptyList } from './select-page';
|
||||||
@@ -46,9 +50,19 @@ export const PagesMode = ({
|
|||||||
});
|
});
|
||||||
}, [collection, updateCollection]);
|
}, [collection, updateCollection]);
|
||||||
const pageOperationsRenderer = useCallback(
|
const pageOperationsRenderer = useCallback(
|
||||||
(page: PageMeta) => allPageListConfig.favoriteRender(page),
|
(item: ListItem) => {
|
||||||
|
const page = item as PageMeta;
|
||||||
|
return allPageListConfig.favoriteRender(page);
|
||||||
|
},
|
||||||
[allPageListConfig]
|
[allPageListConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pageItemRenderer = useCallback((item: ListItem) => {
|
||||||
|
return <PageListItemRenderer {...item} />;
|
||||||
|
}, []);
|
||||||
|
const pageHeaderRenderer = useCallback(() => {
|
||||||
|
return <ListTableHeader headerCols={pageHeaderColsDef} />;
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -102,22 +116,24 @@ export const PagesMode = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{searchedList.length ? (
|
{searchedList.length ? (
|
||||||
<VirtualizedPageList
|
<VirtualizedList
|
||||||
className={styles.pageList}
|
className={styles.pageList}
|
||||||
pages={searchedList}
|
items={searchedList}
|
||||||
groupBy={false}
|
groupBy={false}
|
||||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||||
selectable
|
selectable
|
||||||
onSelectedPageIdsChange={ids => {
|
onSelectedIdsChange={ids => {
|
||||||
updateCollection({
|
updateCollection({
|
||||||
...collection,
|
...collection,
|
||||||
allowList: ids,
|
allowList: ids,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
pageOperationsRenderer={pageOperationsRenderer}
|
itemRenderer={pageItemRenderer}
|
||||||
selectedPageIds={collection.allowList}
|
operationsRenderer={pageOperationsRenderer}
|
||||||
|
headerRenderer={pageHeaderRenderer}
|
||||||
|
selectedIds={collection.allowList}
|
||||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||||
></VirtualizedPageList>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyList search={searchText} />
|
<EmptyList search={searchText} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+19
-11
@@ -13,7 +13,8 @@ import clsx from 'clsx';
|
|||||||
import { type ReactNode, useCallback, useEffect, useState } from 'react';
|
import { type ReactNode, useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { FilterList } from '../../filter';
|
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 { filterPageByRules } from '../../use-collection-manager';
|
||||||
import { AffineShapeIcon } from '../affine-shape';
|
import { AffineShapeIcon } from '../affine-shape';
|
||||||
import type { AllPageListConfig } from './edit-collection';
|
import type { AllPageListConfig } from './edit-collection';
|
||||||
@@ -78,6 +79,13 @@ export const RulesMode = ({
|
|||||||
const [expandInclude, setExpandInclude] = useState(
|
const [expandInclude, setExpandInclude] = useState(
|
||||||
collection.allowList.length > 0
|
collection.allowList.length > 0
|
||||||
);
|
);
|
||||||
|
const operationsRenderer = useCallback(
|
||||||
|
(item: ListItem) => {
|
||||||
|
const page = item as PageMeta;
|
||||||
|
return allPageListConfig.favoriteRender(page);
|
||||||
|
},
|
||||||
|
[allPageListConfig]
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/*prevents modal autofocus to the first input*/}
|
{/*prevents modal autofocus to the first input*/}
|
||||||
@@ -241,22 +249,22 @@ export const RulesMode = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PageListScrollContainer
|
<ListScrollContainer
|
||||||
className={styles.rulesContainerRight}
|
className={styles.rulesContainerRight}
|
||||||
style={{
|
style={{
|
||||||
display: showPreview ? 'flex' : 'none',
|
display: showPreview ? 'flex' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{rulesPages.length > 0 ? (
|
{rulesPages.length > 0 ? (
|
||||||
<PageList
|
<List
|
||||||
hideHeader
|
hideHeader
|
||||||
className={styles.resultPages}
|
className={styles.resultPages}
|
||||||
pages={rulesPages}
|
items={rulesPages}
|
||||||
groupBy={false}
|
groupBy={false}
|
||||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||||
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
operationsRenderer={operationsRenderer}
|
||||||
></PageList>
|
></List>
|
||||||
) : (
|
) : (
|
||||||
<RulesEmpty
|
<RulesEmpty
|
||||||
noRules={collection.filterList.length === 0}
|
noRules={collection.filterList.length === 0}
|
||||||
@@ -268,18 +276,18 @@ export const RulesMode = ({
|
|||||||
<div className={styles.includeListTitle}>
|
<div className={styles.includeListTitle}>
|
||||||
{t['com.affine.editCollection.rules.include.title']()}
|
{t['com.affine.editCollection.rules.include.title']()}
|
||||||
</div>
|
</div>
|
||||||
<PageList
|
<List
|
||||||
hideHeader
|
hideHeader
|
||||||
className={styles.resultPages}
|
className={styles.resultPages}
|
||||||
pages={allowListPages}
|
items={allowListPages}
|
||||||
groupBy={false}
|
groupBy={false}
|
||||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||||
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
operationsRenderer={operationsRenderer}
|
||||||
></PageList>
|
></List>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</PageListScrollContainer>
|
</ListScrollContainer>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.rulesBottom}>
|
<div className={styles.rulesBottom}>
|
||||||
<div className={styles.bottomLeft}>
|
<div className={styles.bottomLeft}>
|
||||||
|
|||||||
+17
-6
@@ -2,12 +2,14 @@ import { Button, Menu } from '@affine/component';
|
|||||||
import { Trans } from '@affine/i18n';
|
import { Trans } from '@affine/i18n';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { FilterIcon } from '@blocksuite/icons';
|
import { FilterIcon } from '@blocksuite/icons';
|
||||||
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { FilterList } from '../../filter';
|
import { FilterList } from '../../filter';
|
||||||
import { VariableSelect } from '../../filter/vars';
|
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 { AffineShapeIcon } from '../affine-shape';
|
||||||
import type { AllPageListConfig } from './edit-collection';
|
import type { AllPageListConfig } from './edit-collection';
|
||||||
import * as styles from './edit-collection.css';
|
import * as styles from './edit-collection.css';
|
||||||
@@ -43,6 +45,15 @@ export const SelectPage = ({
|
|||||||
} = useFilter(allPageListConfig.allPages);
|
} = useFilter(allPageListConfig.allPages);
|
||||||
const { searchText, updateSearchText, searchedList } =
|
const { searchText, updateSearchText, searchedList } =
|
||||||
useSearch(filteredList);
|
useSearch(filteredList);
|
||||||
|
|
||||||
|
const operationsRenderer = useCallback(
|
||||||
|
(item: ListItem) => {
|
||||||
|
const page = item as PageMeta;
|
||||||
|
return allPageListConfig.favoriteRender(page);
|
||||||
|
},
|
||||||
|
[allPageListConfig]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
<input
|
<input
|
||||||
@@ -94,16 +105,16 @@ export const SelectPage = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{searchedList.length ? (
|
{searchedList.length ? (
|
||||||
<VirtualizedPageList
|
<VirtualizedList
|
||||||
className={styles.pageList}
|
className={styles.pageList}
|
||||||
pages={searchedList}
|
items={searchedList}
|
||||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||||
selectable
|
selectable
|
||||||
groupBy={false}
|
groupBy={false}
|
||||||
onSelectedPageIdsChange={onChange}
|
onSelectedIdsChange={onChange}
|
||||||
selectedPageIds={value}
|
selectedIds={value}
|
||||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||||
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
operationsRenderer={operationsRenderer}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyList search={searchText} />
|
<EmptyList search={searchText} />
|
||||||
|
|||||||
+64
-63
@@ -1,5 +1,4 @@
|
|||||||
import { Scrollable } from '@affine/component';
|
import { Scrollable } from '@affine/component';
|
||||||
import type { PageMeta } from '@blocksuite/store';
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
import {
|
import {
|
||||||
@@ -12,29 +11,29 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { Virtuoso } from 'react-virtuoso';
|
import { Virtuoso } from 'react-virtuoso';
|
||||||
|
|
||||||
import { PageGroupHeader, PageMetaListItemRenderer } from './page-group';
|
import { ListInnerWrapper } from './list';
|
||||||
import { PageListTableHeader } from './page-header';
|
import * as styles from './list.css';
|
||||||
import { PageListInnerWrapper } from './page-list';
|
import { ItemGroupHeader } from './page-group';
|
||||||
import * as styles from './page-list.css';
|
|
||||||
import {
|
import {
|
||||||
pageGroupCollapseStateAtom,
|
groupCollapseStateAtom,
|
||||||
pageGroupsAtom,
|
groupsAtom,
|
||||||
pageListPropsAtom,
|
listPropsAtom,
|
||||||
PageListProvider,
|
ListProvider,
|
||||||
useAtomValue,
|
useAtomValue,
|
||||||
} from './scoped-atoms';
|
} from './scoped-atoms';
|
||||||
import type {
|
import type {
|
||||||
PageGroupProps,
|
ItemGroupProps,
|
||||||
PageListHandle,
|
ItemListHandle,
|
||||||
VirtualizedPageListProps,
|
ListItem,
|
||||||
|
VirtualizedListProps,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// we have three item types for rendering rows in Virtuoso
|
// we have three item types for rendering rows in Virtuoso
|
||||||
type VirtuosoItemType =
|
type VirtuosoItemType =
|
||||||
| 'sticky-header'
|
| 'sticky-header'
|
||||||
| 'page-group-header'
|
| 'group-header'
|
||||||
| 'page-item'
|
| 'item'
|
||||||
| 'page-item-spacer';
|
| 'item-spacer';
|
||||||
|
|
||||||
interface BaseVirtuosoItem {
|
interface BaseVirtuosoItem {
|
||||||
type: VirtuosoItemType;
|
type: VirtuosoItemType;
|
||||||
@@ -44,62 +43,62 @@ interface VirtuosoItemStickyHeader extends BaseVirtuosoItem {
|
|||||||
type: 'sticky-header';
|
type: 'sticky-header';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VirtuosoItemPageItem extends BaseVirtuosoItem {
|
interface VirtuosoItemItem<T> extends BaseVirtuosoItem {
|
||||||
type: 'page-item';
|
type: 'item';
|
||||||
data: PageMeta;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VirtuosoItemPageGroupHeader extends BaseVirtuosoItem {
|
interface VirtuosoItemGroupHeader<T> extends BaseVirtuosoItem {
|
||||||
type: 'page-group-header';
|
type: 'group-header';
|
||||||
data: PageGroupProps;
|
data: ItemGroupProps<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VirtuosoPageItemSpacer extends BaseVirtuosoItem {
|
interface VirtuosoPageItemSpacer extends BaseVirtuosoItem {
|
||||||
type: 'page-item-spacer';
|
type: 'item-spacer';
|
||||||
data: {
|
data: {
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type VirtuosoItem =
|
type VirtuosoItem<T> =
|
||||||
| VirtuosoItemStickyHeader
|
| VirtuosoItemStickyHeader
|
||||||
| VirtuosoItemPageItem
|
| VirtuosoItemItem<T>
|
||||||
| VirtuosoItemPageGroupHeader
|
| VirtuosoItemGroupHeader<T>
|
||||||
| VirtuosoPageItemSpacer;
|
| VirtuosoPageItemSpacer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a list of pages, render a list of pages
|
* Given a list of pages, render a list of pages
|
||||||
* Similar to normal PageList, but uses react-virtuoso to render the list (virtual rendering)
|
* Similar to normal PageList, but uses react-virtuoso to render the list (virtual rendering)
|
||||||
*/
|
*/
|
||||||
export const VirtualizedPageList = forwardRef<
|
export const VirtualizedList = forwardRef<
|
||||||
PageListHandle,
|
ItemListHandle,
|
||||||
VirtualizedPageListProps
|
VirtualizedListProps<ListItem>
|
||||||
>(function VirtualizedPageList(props, ref) {
|
>(function VirtualizedList(props, ref) {
|
||||||
return (
|
return (
|
||||||
// push pageListProps to the atom so that downstream components can consume it
|
// push pageListProps to the atom so that downstream components can consume it
|
||||||
// this makes sure pageListPropsAtom is always populated
|
// this makes sure pageListPropsAtom is always populated
|
||||||
// @ts-expect-error fix type issues later
|
// @ts-expect-error fix type issues later
|
||||||
<PageListProvider initialValues={[[pageListPropsAtom, props]]}>
|
<ListProvider initialValues={[[listPropsAtom, props]]}>
|
||||||
<PageListInnerWrapper {...props} handleRef={ref}>
|
<ListInnerWrapper {...props} handleRef={ref}>
|
||||||
<PageListInner {...props} />
|
<ListInner {...props} />
|
||||||
</PageListInnerWrapper>
|
</ListInnerWrapper>
|
||||||
</PageListProvider>
|
</ListProvider>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const headingAtom = selectAtom(pageListPropsAtom, props => props.heading);
|
const headingAtom = selectAtom(listPropsAtom, props => props.heading);
|
||||||
|
|
||||||
const PageListHeading = () => {
|
const PageListHeading = () => {
|
||||||
const heading = useAtomValue(headingAtom);
|
const heading = useAtomValue(headingAtom);
|
||||||
return <div className={styles.heading}>{heading}</div>;
|
return <div className={styles.heading}>{heading}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useVirtuosoItems = () => {
|
const useVirtuosoItems = <T extends ListItem>() => {
|
||||||
const groups = useAtomValue(pageGroupsAtom);
|
const groups = useAtomValue(groupsAtom);
|
||||||
const groupCollapsedState = useAtomValue(pageGroupCollapseStateAtom);
|
const groupCollapsedState = useAtomValue(groupCollapseStateAtom);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const items: VirtuosoItem[] = [];
|
const items: VirtuosoItem<T>[] = [];
|
||||||
|
|
||||||
// 1.
|
// 1.
|
||||||
// always put sticky header at the top
|
// 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")
|
// skip empty group header since it will cause issue in virtuoso ("Zero-sized element")
|
||||||
if (group.label) {
|
if (group.label) {
|
||||||
items.push({
|
items.push({
|
||||||
type: 'page-group-header',
|
type: 'group-header',
|
||||||
data: group,
|
data: group as ItemGroupProps<T>,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// do not render items if the group is collapsed
|
// do not render items if the group is collapsed
|
||||||
if (!groupCollapsedState[group.id]) {
|
if (!groupCollapsedState[group.id]) {
|
||||||
for (const item of group.items) {
|
for (const item of group.items) {
|
||||||
items.push({
|
items.push({
|
||||||
type: 'page-item',
|
type: 'item',
|
||||||
data: item,
|
data: item as T,
|
||||||
});
|
});
|
||||||
// add a spacer between items (4px), unless it's the last item
|
// add a spacer between items (4px), unless it's the last item
|
||||||
if (item !== group.items[group.items.length - 1]) {
|
if (item !== group.items[group.items.length - 1]) {
|
||||||
items.push({
|
items.push({
|
||||||
type: 'page-item-spacer',
|
type: 'item-spacer',
|
||||||
data: {
|
data: {
|
||||||
height: 4,
|
height: 4,
|
||||||
},
|
},
|
||||||
@@ -139,7 +138,7 @@ const useVirtuosoItems = () => {
|
|||||||
|
|
||||||
// add a spacer between groups (16px)
|
// add a spacer between groups (16px)
|
||||||
items.push({
|
items.push({
|
||||||
type: 'page-item-spacer',
|
type: 'item-spacer',
|
||||||
data: {
|
data: {
|
||||||
height: 16,
|
height: 16,
|
||||||
},
|
},
|
||||||
@@ -149,19 +148,6 @@ const useVirtuosoItems = () => {
|
|||||||
}, [groupCollapsedState, groups]);
|
}, [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<
|
const Scroller = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
PropsWithChildren<HTMLAttributes<HTMLDivElement>>
|
PropsWithChildren<HTMLAttributes<HTMLDivElement>>
|
||||||
@@ -178,12 +164,12 @@ const Scroller = forwardRef<
|
|||||||
|
|
||||||
Scroller.displayName = 'Scroller';
|
Scroller.displayName = 'Scroller';
|
||||||
|
|
||||||
const PageListInner = ({
|
const ListInner = ({
|
||||||
atTopStateChange,
|
atTopStateChange,
|
||||||
atTopThreshold,
|
atTopThreshold,
|
||||||
...props
|
...props
|
||||||
}: VirtualizedPageListProps) => {
|
}: VirtualizedListProps<ListItem>) => {
|
||||||
const virtuosoItems = useVirtuosoItems();
|
const virtuosoItems = useVirtuosoItems<ListItem>();
|
||||||
const [atTop, setAtTop] = useState(false);
|
const [atTop, setAtTop] = useState(false);
|
||||||
const handleAtTopStateChange = useCallback(
|
const handleAtTopStateChange = useCallback(
|
||||||
(atTop: boolean) => {
|
(atTop: boolean) => {
|
||||||
@@ -198,15 +184,30 @@ const PageListInner = ({
|
|||||||
Scroller: Scroller,
|
Scroller: Scroller,
|
||||||
};
|
};
|
||||||
}, [props.heading]);
|
}, [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 (
|
return (
|
||||||
<Virtuoso<VirtuosoItem>
|
<Virtuoso<VirtuosoItem<ListItem>>
|
||||||
data-has-scroll-top={!atTop}
|
data-has-scroll-top={!atTop}
|
||||||
atTopThreshold={atTopThreshold ?? 0}
|
atTopThreshold={atTopThreshold ?? 0}
|
||||||
atTopStateChange={handleAtTopStateChange}
|
atTopStateChange={handleAtTopStateChange}
|
||||||
components={components}
|
components={components}
|
||||||
data={virtuosoItems}
|
data={virtuosoItems}
|
||||||
data-testid="virtualized-page-list"
|
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
|
topItemCount={1} // sticky header
|
||||||
totalCount={virtuosoItems.length}
|
totalCount={virtuosoItems.length}
|
||||||
itemContent={itemContentRenderer}
|
itemContent={itemContentRenderer}
|
||||||
@@ -1,30 +1,59 @@
|
|||||||
import { RadioButton, RadioButtonGroup } from '@affine/component';
|
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 { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { allPageModeSelectAtom } from '../../../atoms';
|
|
||||||
import * as styles from './index.css';
|
import * as styles from './index.css';
|
||||||
|
|
||||||
export const WorkspaceModeFilterTab = () => {
|
export const WorkspaceModeFilterTab = ({
|
||||||
|
workspaceId,
|
||||||
|
activeFilter,
|
||||||
|
}: {
|
||||||
|
workspaceId: string;
|
||||||
|
activeFilter: AllPageFilterOption;
|
||||||
|
}) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [value, setMode] = useAtom(allPageModeSelectAtom);
|
const [value, setValue] = useState(activeFilter);
|
||||||
const handleValueChange = (value: string) => {
|
const [filterMode, setFilterMode] = useAtom(allPageFilterSelectAtom);
|
||||||
if (value !== 'all' && value !== 'page' && value !== 'edgeless') {
|
const { jumpToCollections, jumpToTags, jumpToSubPath } = useNavigateHelper();
|
||||||
throw new Error('Invalid value for page mode option');
|
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 (
|
return (
|
||||||
<RadioButtonGroup value={value} onValueChange={handleValueChange}>
|
<RadioButtonGroup value={value} onValueChange={handleValueChange}>
|
||||||
<RadioButton value="all" spanStyle={styles.filterTab}>
|
<RadioButton spanStyle={styles.filterTab} value="docs">
|
||||||
{t['com.affine.pageMode.all']()}
|
{t['com.affine.docs.header']()}
|
||||||
</RadioButton>
|
</RadioButton>
|
||||||
<RadioButton spanStyle={styles.filterTab} value="page">
|
<RadioButton spanStyle={styles.filterTab} value="collections">
|
||||||
{t['com.affine.pageMode.page']()}
|
{t['com.affine.collections.header']()}
|
||||||
</RadioButton>
|
</RadioButton>
|
||||||
<RadioButton spanStyle={styles.filterTab} value="edgeless">
|
<RadioButton spanStyle={styles.filterTab} value="tags">
|
||||||
{t['com.affine.pageMode.edgeless']()}
|
{t['Tags']()}
|
||||||
</RadioButton>
|
</RadioButton>
|
||||||
</RadioButtonGroup>
|
</RadioButtonGroup>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -69,15 +69,12 @@ export type RootAppSidebarProps = {
|
|||||||
const RouteMenuLinkItem = forwardRef<
|
const RouteMenuLinkItem = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
{
|
{
|
||||||
currentPath: string; // todo: pass through useRouter?
|
|
||||||
path: string;
|
path: string;
|
||||||
icon: ReactElement;
|
icon: ReactElement;
|
||||||
|
active?: boolean;
|
||||||
children?: ReactElement;
|
children?: ReactElement;
|
||||||
isDraggedOver?: boolean;
|
|
||||||
} & HTMLAttributes<HTMLDivElement>
|
} & HTMLAttributes<HTMLDivElement>
|
||||||
>(({ currentPath, path, icon, children, isDraggedOver, ...props }, ref) => {
|
>(({ path, icon, active, children, ...props }, ref) => {
|
||||||
// Force active style when a page is dragged over
|
|
||||||
const active = isDraggedOver || currentPath === path;
|
|
||||||
return (
|
return (
|
||||||
<MenuLinkItem
|
<MenuLinkItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -197,6 +194,22 @@ export const RootAppSidebar = ({
|
|||||||
});
|
});
|
||||||
}, [blockSuiteWorkspace.id, navigateHelper, open, setting]);
|
}, [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 (
|
return (
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
router={router}
|
router={router}
|
||||||
@@ -247,7 +260,7 @@ export const RootAppSidebar = ({
|
|||||||
/>
|
/>
|
||||||
<RouteMenuLinkItem
|
<RouteMenuLinkItem
|
||||||
icon={<FolderIcon />}
|
icon={<FolderIcon />}
|
||||||
currentPath={currentPath}
|
active={allPageActive}
|
||||||
path={paths.all(currentWorkspaceId)}
|
path={paths.all(currentWorkspaceId)}
|
||||||
onClick={backToAll}
|
onClick={backToAll}
|
||||||
>
|
>
|
||||||
@@ -289,9 +302,8 @@ export const RootAppSidebar = ({
|
|||||||
<div style={{ height: '4px' }} />
|
<div style={{ height: '4px' }} />
|
||||||
<RouteMenuLinkItem
|
<RouteMenuLinkItem
|
||||||
ref={trashDroppable.setNodeRef}
|
ref={trashDroppable.setNodeRef}
|
||||||
isDraggedOver={trashDroppable.isOver}
|
|
||||||
icon={<AnimatedDeleteIcon closed={trashDroppable.isOver} />}
|
icon={<AnimatedDeleteIcon closed={trashDroppable.isOver} />}
|
||||||
currentPath={currentPath}
|
active={trashActive}
|
||||||
path={paths.trash(currentWorkspaceId)}
|
path={paths.trash(currentWorkspaceId)}
|
||||||
>
|
>
|
||||||
<span data-testid="trash-page">
|
<span data-testid="trash-page">
|
||||||
|
|||||||
@@ -42,6 +42,22 @@ export function useNavigateHelper() {
|
|||||||
},
|
},
|
||||||
[navigate]
|
[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(
|
const jumpToCollection = useCallback(
|
||||||
(
|
(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -144,6 +160,8 @@ export function useNavigateHelper() {
|
|||||||
jumpToExpired,
|
jumpToExpired,
|
||||||
jumpToSignIn,
|
jumpToSignIn,
|
||||||
jumpToCollection,
|
jumpToCollection,
|
||||||
|
jumpToCollections,
|
||||||
|
jumpToTags,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
jumpToPage,
|
jumpToPage,
|
||||||
@@ -156,6 +174,8 @@ export function useNavigateHelper() {
|
|||||||
jumpToExpired,
|
jumpToExpired,
|
||||||
jumpToSignIn,
|
jumpToSignIn,
|
||||||
jumpToCollection,
|
jumpToCollection,
|
||||||
|
jumpToCollections,
|
||||||
|
jumpToTags,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { useAtom, useAtomValue, useStore } from 'jotai';
|
import { useAtomValue, useSetAtom, useStore } from 'jotai';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export function useRegisterWorkspaceCommands() {
|
|||||||
const languageHelper = useLanguageHelper();
|
const languageHelper = useLanguageHelper();
|
||||||
const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace);
|
const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace);
|
||||||
const navigationHelper = useNavigateHelper();
|
const navigationHelper = useNavigateHelper();
|
||||||
const [pageListMode, setPageListMode] = useAtom(allPageModeSelectAtom);
|
const setPageListMode = useSetAtom(allPageModeSelectAtom);
|
||||||
const [editor] = useActiveBlocksuiteEditor();
|
const [editor] = useActiveBlocksuiteEditor();
|
||||||
|
|
||||||
// register AffineUpdatesCommands
|
// register AffineUpdatesCommands
|
||||||
@@ -48,7 +48,6 @@ export function useRegisterWorkspaceCommands() {
|
|||||||
t,
|
t,
|
||||||
workspace: currentWorkspace.blockSuiteWorkspace,
|
workspace: currentWorkspace.blockSuiteWorkspace,
|
||||||
navigationHelper,
|
navigationHelper,
|
||||||
pageListMode,
|
|
||||||
setPageListMode,
|
setPageListMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +59,6 @@ export function useRegisterWorkspaceCommands() {
|
|||||||
t,
|
t,
|
||||||
currentWorkspace.blockSuiteWorkspace,
|
currentWorkspace.blockSuiteWorkspace,
|
||||||
navigationHelper,
|
navigationHelper,
|
||||||
pageListMode,
|
|
||||||
setPageListMode,
|
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',
|
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({
|
export const headerCreateNewButton = style({
|
||||||
transition: 'opacity 0.1s ease-in-out',
|
transition: 'opacity 0.1s ease-in-out',
|
||||||
});
|
});
|
||||||
|
export const headerCreateNewCollectionIconButton = style({
|
||||||
export const newPageButtonLabel = style({
|
padding: '4px 8px',
|
||||||
display: 'flex',
|
fontSize: '16px',
|
||||||
alignItems: 'center',
|
width: '32px',
|
||||||
|
height: '28px',
|
||||||
|
borderRadius: '8px',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const headerCreateNewButtonHidden = style({
|
export const headerCreateNewButtonHidden = style({
|
||||||
@@ -72,5 +34,9 @@ export const headerRightWindows = style({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
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 {
|
import {
|
||||||
CollectionList,
|
CollectionListHeader,
|
||||||
|
type CollectionMeta,
|
||||||
|
createEmptyCollection,
|
||||||
currentCollectionAtom,
|
currentCollectionAtom,
|
||||||
FloatingToolbar,
|
PageListHeader,
|
||||||
NewPageButton as PureNewPageButton,
|
|
||||||
OperationCell,
|
|
||||||
type PageListHandle,
|
|
||||||
useCollectionManager,
|
useCollectionManager,
|
||||||
|
useEditCollectionName,
|
||||||
|
useFilteredPageMetas,
|
||||||
|
useSavedCollections,
|
||||||
|
useTagMetas,
|
||||||
|
VirtualizedCollectionList,
|
||||||
VirtualizedPageList,
|
VirtualizedPageList,
|
||||||
} from '@affine/core/components/page-list';
|
} 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 { 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 { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||||
import { Trans } from '@affine/i18n';
|
import { performanceRenderLogger } from '@affine/core/shared';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
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 { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import {
|
import { nanoid } from 'nanoid';
|
||||||
type PropsWithChildren,
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
useCallback,
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { NIL } from 'uuid';
|
import { NIL } from 'uuid';
|
||||||
|
|
||||||
import { collectionsCRUDAtom } from '../../../atoms/collections';
|
import {
|
||||||
import { HubIsland } from '../../../components/affine/hub-island';
|
EmptyCollectionList,
|
||||||
import { usePageHelper } from '../../../components/blocksuite/block-suite-page-list/utils';
|
EmptyPageList,
|
||||||
import { Header } from '../../../components/pure/header';
|
EmptyTagList,
|
||||||
import { WindowsAppControls } from '../../../components/pure/header/windows-app-controls';
|
} from '../page-list-empty';
|
||||||
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 * as styles from './all-page.css';
|
import * as styles from './all-page.css';
|
||||||
import { FilterContainer } from './all-page-filter';
|
import { AllPageHeader } from './all-page-header';
|
||||||
|
|
||||||
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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// even though it is called all page, it is also being used for collection route as well
|
// 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 currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
||||||
const { isPreferredEdgeless } = usePageHelper(
|
|
||||||
currentWorkspace.blockSuiteWorkspace
|
|
||||||
);
|
|
||||||
const pageMetas = useBlockSuitePageMeta(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(
|
const filteredPageMetas = useFilteredPageMetas(
|
||||||
'all',
|
'all',
|
||||||
pageMetas,
|
pageMetas,
|
||||||
currentWorkspace.blockSuiteWorkspace
|
currentWorkspace.blockSuiteWorkspace
|
||||||
);
|
);
|
||||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
const tagPageMetas = useMemo(() => {
|
||||||
const pageListRef = useRef<PageListHandle>(null);
|
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(() => {
|
const navigateHelper = useNavigateHelper();
|
||||||
pageListRef.current?.toggleSelectable();
|
const { open, node } = useEditCollectionName({
|
||||||
}, []);
|
title: t['com.affine.editCollection.createCollection'](),
|
||||||
|
showTips: true,
|
||||||
|
});
|
||||||
|
|
||||||
// make sure selected id is in the filtered list
|
const handleCreateCollection = useCallback(() => {
|
||||||
const filteredSelectedPageIds = useMemo(() => {
|
open('')
|
||||||
const ids = filteredPageMetas.map(page => page.id);
|
.then(name => {
|
||||||
return selectedPageIds.filter(id => ids.includes(id));
|
const id = nanoid();
|
||||||
}, [filteredPageMetas, selectedPageIds]);
|
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 (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<AllPageHeader
|
<AllPageHeader
|
||||||
workspace={currentWorkspace.blockSuiteWorkspace}
|
workspace={currentWorkspace.blockSuiteWorkspace}
|
||||||
showCreateNew={!hideHeaderCreateNewPage}
|
showCreateNew={!hideHeaderCreateNew}
|
||||||
|
isDefaultFilter={setting.isDefault}
|
||||||
|
activeFilter={activeFilter}
|
||||||
|
onCreateCollection={handleCreateCollection}
|
||||||
/>
|
/>
|
||||||
{filteredPageMetas.length > 0 ? (
|
{content}
|
||||||
<>
|
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<HubIsland />
|
<HubIsland />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -329,6 +216,18 @@ export const Component = () => {
|
|||||||
const currentCollection = useSetAtom(currentCollectionAtom);
|
const currentCollection = useSetAtom(currentCollectionAtom);
|
||||||
const navigateHelper = useNavigateHelper();
|
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(() => {
|
useEffect(() => {
|
||||||
function checkJumpOnce() {
|
function checkJumpOnce() {
|
||||||
for (const [pageId] of currentWorkspace.blockSuiteWorkspace.pages) {
|
for (const [pageId] of currentWorkspace.blockSuiteWorkspace.pages) {
|
||||||
@@ -355,5 +254,5 @@ export const Component = () => {
|
|||||||
currentCollection(NIL);
|
currentCollection(NIL);
|
||||||
}, [currentCollection]);
|
}, [currentCollection]);
|
||||||
|
|
||||||
return <AllPage />;
|
return <AllPage activeFilter={activeFilter} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
useEditCollection,
|
useEditCollection,
|
||||||
} from '@affine/core/components/page-list';
|
} from '@affine/core/components/page-list';
|
||||||
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
|
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 { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||||
import type { Collection } from '@affine/env/filter';
|
import type { Collection } from '@affine/env/filter';
|
||||||
@@ -31,7 +32,6 @@ import {
|
|||||||
collectionsCRUDAtom,
|
collectionsCRUDAtom,
|
||||||
pageCollectionBaseAtom,
|
pageCollectionBaseAtom,
|
||||||
} from '../../atoms/collections';
|
} from '../../atoms/collections';
|
||||||
import { useAllPageListConfig } from '../../hooks/affine/use-all-page-list-config';
|
|
||||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||||
import { WorkspaceSubPath } from '../../shared';
|
import { WorkspaceSubPath } from '../../shared';
|
||||||
import { getWorkspaceSetting } from '../../utils/workspace-setting';
|
import { getWorkspaceSetting } from '../../utils/workspace-setting';
|
||||||
@@ -89,17 +89,24 @@ export const Component = function CollectionPage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return isEmpty(collection) ? (
|
return isEmpty(collection) ? (
|
||||||
<Placeholder collection={collection} />
|
<Placeholder collection={collection} workspaceId={workspace.id} />
|
||||||
) : (
|
) : (
|
||||||
<AllPage />
|
<AllPage activeFilter="collections" />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
|
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
|
||||||
|
|
||||||
const Placeholder = ({ collection }: { collection: Collection }) => {
|
const Placeholder = ({
|
||||||
|
collection,
|
||||||
|
workspaceId,
|
||||||
|
}: {
|
||||||
|
collection: Collection;
|
||||||
|
workspaceId: string;
|
||||||
|
}) => {
|
||||||
const { updateCollection } = useCollectionManager(collectionsCRUDAtom);
|
const { updateCollection } = useCollectionManager(collectionsCRUDAtom);
|
||||||
const { node, open } = useEditCollection(useAllPageListConfig());
|
const { node, open } = useEditCollection(useAllPageListConfig());
|
||||||
|
const { jumpToCollections } = useNavigateHelper();
|
||||||
const openPageEdit = useAsyncCallback(async () => {
|
const openPageEdit = useAsyncCallback(async () => {
|
||||||
const ret = await open({ ...collection }, 'page');
|
const ret = await open({ ...collection }, 'page');
|
||||||
updateCollection(ret);
|
updateCollection(ret);
|
||||||
@@ -118,6 +125,11 @@ const Placeholder = ({ collection }: { collection: Collection }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
|
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
|
||||||
|
|
||||||
|
const handleJumpToCollections = useCallback(() => {
|
||||||
|
jumpToCollections(workspaceId);
|
||||||
|
}, [jumpToCollections, workspaceId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -143,9 +155,11 @@ const Placeholder = ({ collection }: { collection: Collection }) => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
color: 'var(--affine-text-secondary-color)',
|
color: 'var(--affine-text-secondary-color)',
|
||||||
['WebkitAppRegion' as string]: 'no-drag',
|
['WebkitAppRegion' as string]: 'no-drag',
|
||||||
}}
|
}}
|
||||||
|
onClick={handleJumpToCollections}
|
||||||
>
|
>
|
||||||
<ViewLayersIcon
|
<ViewLayersIcon
|
||||||
style={{ color: 'var(--affine-icon-color)' }}
|
style={{ color: 'var(--affine-icon-color)' }}
|
||||||
|
|||||||
@@ -66,3 +66,22 @@ export const EmptyPageList = ({
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 { toast } from '@affine/component';
|
||||||
|
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
|
||||||
import {
|
import {
|
||||||
currentCollectionAtom,
|
currentCollectionAtom,
|
||||||
|
type ListItem,
|
||||||
|
ListTableHeader,
|
||||||
|
PageListItemRenderer,
|
||||||
TrashOperationCell,
|
TrashOperationCell,
|
||||||
VirtualizedPageList,
|
useFilteredPageMetas,
|
||||||
|
VirtualizedList,
|
||||||
} from '@affine/core/components/page-list';
|
} 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 { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
@@ -16,12 +25,7 @@ import { useCallback } from 'react';
|
|||||||
import { type LoaderFunction } from 'react-router-dom';
|
import { type LoaderFunction } from 'react-router-dom';
|
||||||
import { NIL } from 'uuid';
|
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 { EmptyPageList } from './page-list-empty';
|
||||||
import { useFilteredPageMetas } from './pages';
|
|
||||||
import * as styles from './trash-page.css';
|
import * as styles from './trash-page.css';
|
||||||
|
|
||||||
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
|
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
|
||||||
@@ -74,7 +78,8 @@ export const TrashPage = () => {
|
|||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
|
|
||||||
const pageOperationsRenderer = useCallback(
|
const pageOperationsRenderer = useCallback(
|
||||||
(page: PageMeta) => {
|
(item: ListItem) => {
|
||||||
|
const page = item as PageMeta;
|
||||||
const onRestorePage = () => {
|
const onRestorePage = () => {
|
||||||
restoreFromTrash(page.id);
|
restoreFromTrash(page.id);
|
||||||
toast(
|
toast(
|
||||||
@@ -94,20 +99,28 @@ export const TrashPage = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
[permanentlyDeletePage, restoreFromTrash, t]
|
[permanentlyDeletePage, restoreFromTrash, t]
|
||||||
);
|
);
|
||||||
|
const pageItemRenderer = useCallback((item: ListItem) => {
|
||||||
|
return <PageListItemRenderer {...item} />;
|
||||||
|
}, []);
|
||||||
|
const pageHeaderRenderer = useCallback(() => {
|
||||||
|
return <ListTableHeader headerCols={pageHeaderColsDef} />;
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<TrashHeader />
|
<TrashHeader />
|
||||||
{filteredPageMetas.length > 0 ? (
|
{filteredPageMetas.length > 0 ? (
|
||||||
<VirtualizedPageList
|
<VirtualizedList
|
||||||
pages={filteredPageMetas}
|
items={filteredPageMetas}
|
||||||
rowAsLink
|
rowAsLink
|
||||||
groupBy={false}
|
groupBy={false}
|
||||||
isPreferredEdgeless={isPreferredEdgeless}
|
isPreferredEdgeless={isPreferredEdgeless}
|
||||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||||
pageOperationsRenderer={pageOperationsRenderer}
|
operationsRenderer={pageOperationsRenderer}
|
||||||
|
itemRenderer={pageItemRenderer}
|
||||||
|
headerRenderer={pageHeaderRenderer}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyPageList
|
<EmptyPageList
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export const routes = [
|
|||||||
path: 'collection/:collectionId',
|
path: 'collection/:collectionId',
|
||||||
lazy: () => import('./pages/workspace/collection'),
|
lazy: () => import('./pages/workspace/collection'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'tag/:tagId',
|
||||||
|
lazy: () => import('./pages/workspace/tag'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'trash',
|
path: 'trash',
|
||||||
lazy: () => import('./pages/workspace/trash-page'),
|
lazy: () => import('./pages/workspace/trash-page'),
|
||||||
|
|||||||
@@ -554,9 +554,10 @@
|
|||||||
"com.affine.collection.menu.edit": "Edit Collection",
|
"com.affine.collection.menu.edit": "Edit Collection",
|
||||||
"com.affine.collection.menu.rename": "Rename",
|
"com.affine.collection.menu.rename": "Rename",
|
||||||
"com.affine.collectionBar.backToAll": "Back to all",
|
"com.affine.collectionBar.backToAll": "Back to all",
|
||||||
|
"com.affine.docs.header": "Docs",
|
||||||
"com.affine.collections.header": "Collections",
|
"com.affine.collections.header": "Collections",
|
||||||
"com.affine.collections.empty.message": "No 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.confirmModal.button.cancel": "Cancel",
|
||||||
"com.affine.currentYear": "Current Year",
|
"com.affine.currentYear": "Current Year",
|
||||||
"com.affine.deleteLeaveWorkspace.description": "Delete workspace from this device and optionally delete all data.",
|
"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.editCollectionName.name.placeholder": "Collection Name",
|
||||||
"com.affine.editorModeSwitch.tooltip": "Switch",
|
"com.affine.editorModeSwitch.tooltip": "Switch",
|
||||||
"com.affine.emptyDesc": "There's no page here yet",
|
"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.enableAffineCloudModal.button.cancel": "Cancel",
|
||||||
"com.affine.expired.page.subtitle": "Please request a new reset password link.",
|
"com.affine.expired.page.subtitle": "Please request a new reset password link.",
|
||||||
"com.affine.expired.page.title": "This link has expired...",
|
"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": "<0>{{count}}</0> selected",
|
||||||
"com.affine.page.toolbar.selected_one": "<0>{{count}}</0> page 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.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": "Page Mode",
|
||||||
"com.affine.pageMode.all": "all",
|
"com.affine.pageMode.all": "all",
|
||||||
"com.affine.pageMode.edgeless": "Edgeless",
|
"com.affine.pageMode.edgeless": "Edgeless",
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ test('select two pages and delete', async ({ page }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// click delete button
|
// 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
|
// the confirm dialog should appear
|
||||||
await expect(page.getByText('Delete 2 pages?')).toBeVisible();
|
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();
|
await page.getByTestId('page-list-header-selection-checkbox').click();
|
||||||
// click again to select all
|
// click again to select all
|
||||||
await page.getByTestId('page-list-header-selection-checkbox').click();
|
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
|
// confirm delete
|
||||||
await page.getByTestId('confirm-delete-page').click();
|
await page.getByTestId('confirm-delete-page').click();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { toast } from '@affine/component';
|
import { toast } from '@affine/component';
|
||||||
import {
|
import {
|
||||||
FloatingToolbar,
|
FloatingToolbar,
|
||||||
|
List,
|
||||||
|
type ListItem,
|
||||||
|
type ListProps,
|
||||||
|
ListScrollContainer,
|
||||||
NewPageButton,
|
NewPageButton,
|
||||||
OperationCell,
|
|
||||||
type OperationCellProps,
|
|
||||||
PageList,
|
|
||||||
PageListItem,
|
PageListItem,
|
||||||
type PageListItemProps,
|
type PageListItemProps,
|
||||||
type PageListProps,
|
PageOperationCell,
|
||||||
PageListScrollContainer,
|
type PageOperationCellProps,
|
||||||
PageTags,
|
PageTags,
|
||||||
type PageTagsProps,
|
type PageTagsProps,
|
||||||
} from '@affine/core/components/page-list';
|
} from '@affine/core/components/page-list';
|
||||||
@@ -29,9 +30,9 @@ export default {
|
|||||||
},
|
},
|
||||||
} satisfies Meta;
|
} satisfies Meta;
|
||||||
|
|
||||||
export const AffineOperationCell: StoryFn<OperationCellProps> = ({
|
export const AffineOperationCell: StoryFn<PageOperationCellProps> = ({
|
||||||
...props
|
...props
|
||||||
}) => <OperationCell {...props} />;
|
}) => <PageOperationCell {...props} />;
|
||||||
|
|
||||||
AffineOperationCell.args = {
|
AffineOperationCell.args = {
|
||||||
favorite: false,
|
favorite: false,
|
||||||
@@ -159,11 +160,11 @@ const testTags = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ListItem: StoryFn<PageListItemProps> = props => (
|
export const PageListItemComponent: StoryFn<PageListItemProps> = props => (
|
||||||
<PageListItem {...props}></PageListItem>
|
<PageListItem {...props}></PageListItem>
|
||||||
);
|
);
|
||||||
|
|
||||||
ListItem.args = {
|
PageListItemComponent.args = {
|
||||||
pageId: 'test-page-id',
|
pageId: 'test-page-id',
|
||||||
title: 'Test Page Title',
|
title: 'Test Page Title',
|
||||||
preview:
|
preview:
|
||||||
@@ -178,7 +179,7 @@ ListItem.args = {
|
|||||||
selected: true,
|
selected: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
ListItem.decorators = [withRouter];
|
PageListItemComponent.decorators = [withRouter];
|
||||||
|
|
||||||
export const ListItemTags: StoryFn<PageTagsProps> = props => (
|
export const ListItemTags: StoryFn<PageTagsProps> = props => (
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
@@ -195,15 +196,18 @@ ListItemTags.args = {
|
|||||||
maxItems: 5,
|
maxItems: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageListStory: StoryFn<PageListProps> = (props, { loaded }) => {
|
export const PageListStory: StoryFn<ListProps<ListItem>> = (
|
||||||
|
props,
|
||||||
|
{ loaded }
|
||||||
|
) => {
|
||||||
return (
|
return (
|
||||||
<PageListScrollContainer
|
<ListScrollContainer
|
||||||
style={{
|
style={{
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PageList {...props} {...loaded}></PageList>
|
<List {...props} {...loaded}></List>
|
||||||
</PageListScrollContainer>
|
</ListScrollContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user