feat(core): add tag operation to tag list (#5998)

https://github.com/toeverything/AFFiNE/assets/102217452/11745733-0d7b-494b-97fd-33e40a240a02
This commit is contained in:
JimmFly
2024-03-11 08:36:07 +00:00
parent cb96d7de43
commit b26efa4940
22 changed files with 693 additions and 105 deletions

View File

@@ -16,6 +16,7 @@ export const collectionListHeaderTitle = style({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
userSelect: 'none',
}); });
export const newCollectionButton = style({ export const newCollectionButton = style({
padding: '6px 10px', padding: '6px 10px',

View File

@@ -144,7 +144,7 @@ export const CollectionListItem = (props: CollectionListItemProps) => {
{props.operations ? ( {props.operations ? (
<ColWrapper <ColWrapper
className={styles.actionsCellWrapper} className={styles.actionsCellWrapper}
flex={2} flex={3}
alignment="end" alignment="end"
> >
<CollectionListOperationsCell operations={props.operations} /> <CollectionListOperationsCell operations={props.operations} />

View File

@@ -31,7 +31,7 @@ const useCollectionOperationsRenderer = ({
config: AllPageListConfig; config: AllPageListConfig;
service: CollectionService; service: CollectionService;
}) => { }) => {
const pageOperationsRenderer = useCallback( const collectionOperationsRenderer = useCallback(
(collection: Collection) => { (collection: Collection) => {
return ( return (
<CollectionOperationCell <CollectionOperationCell
@@ -45,7 +45,7 @@ const useCollectionOperationsRenderer = ({
[config, info, service] [config, info, service]
); );
return pageOperationsRenderer; return collectionOperationsRenderer;
}; };
export const VirtualizedCollectionList = ({ export const VirtualizedCollectionList = ({

View File

@@ -17,6 +17,7 @@ export const docListHeaderTitle = style({
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
height: '28px', height: '28px',
userSelect: 'none',
}); });
export const titleIcon = style({ export const titleIcon = style({
color: cssVar('iconColor'), color: cssVar('iconColor'),
@@ -50,6 +51,7 @@ export const tagSticky = style({
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
height: '22px', height: '22px',
lineHeight: '1.67em', lineHeight: '1.67em',
cursor: 'pointer',
}); });
export const tagIndicator = style({ export const tagIndicator = style({
width: '8px', width: '8px',
@@ -62,3 +64,101 @@ export const tagLabel = style({
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}); });
export const arrowDownSmallIcon = style({
color: cssVar('iconColor'),
fontSize: '12px',
});
export const searchIcon = style({
color: cssVar('iconColor'),
fontSize: '20px',
});
export const tagsEditorRoot = style({
display: 'flex',
flexDirection: 'column',
width: '100%',
padding: '8px',
});
export const tagsMenu = style({
padding: 0,
width: '296px',
overflow: 'hidden',
});
export const tagsEditorSelectedTags = style({
display: 'flex',
gap: '8px',
flexWrap: 'nowrap',
padding: '6px 12px',
minHeight: 42,
alignItems: 'center',
});
export const searchInput = style({
flexGrow: 1,
padding: '10px 0',
margin: '-10px 0',
border: 'none',
outline: 'none',
fontSize: cssVar('fontSm'),
fontFamily: 'inherit',
color: 'inherit',
backgroundColor: 'transparent',
'::placeholder': {
color: cssVar('placeholderColor'),
},
overflow: 'hidden',
});
export const tagsEditorTagsSelector = style({
display: 'flex',
flexDirection: 'column',
maxHeight: '400px',
overflow: 'auto',
});
export const tagSelectorTagsScrollContainer = style({
overflowX: 'hidden',
position: 'relative',
maxHeight: '200px',
gap: '8px',
});
export const tagSelectorItem = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
padding: '4px 16px',
height: '32px',
gap: 8,
fontSize: cssVar('fontSm'),
cursor: 'pointer',
borderRadius: '4px',
color: cssVar('textPrimaryColor'),
':hover': {
backgroundColor: cssVar('hoverColor'),
},
':visited': {
color: cssVar('textPrimaryColor'),
},
selectors: {
'&.disable:hover': {
backgroundColor: 'unset',
cursor: 'auto',
},
},
});
export const tagIcon = style({
width: '8px',
height: '8px',
borderRadius: '50%',
flexShrink: 0,
});
export const tagSelectorItemText = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});

View File

@@ -1,12 +1,19 @@
import { Button } from '@affine/component'; import { Button, Divider, Menu, Scrollable } from '@affine/component';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace';
import type { Collection, Tag } from '@affine/env/filter'; import type { Collection, Tag } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ViewLayersIcon } from '@blocksuite/icons'; import {
import { useService } from '@toeverything/infra/di'; ArrowDownSmallIcon,
SearchIcon,
ViewLayersIcon,
} from '@blocksuite/icons';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { CollectionService } from '../../../modules/collection'; import { CollectionService } from '../../../modules/collection';
import { createTagFilter } from '../filter/utils'; import { createTagFilter } from '../filter/utils';
@@ -88,9 +95,12 @@ export const TagPageListHeader = ({
tag: Tag; tag: Tag;
workspaceId: string; workspaceId: string;
}) => { }) => {
const legacyProperties = useService(WorkspaceLegacyProperties);
const options = useLiveData(legacyProperties.tagOptions$);
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const { jumpToTags, jumpToCollection } = useNavigateHelper(); const { jumpToTags, jumpToCollection } = useNavigateHelper();
const collectionService = useService(CollectionService); const collectionService = useService(CollectionService);
const [openMenu, setOpenMenu] = useState(false);
const { open, node } = useEditCollectionName({ const { open, node } = useEditCollectionName({
title: t['com.affine.editCollection.saveCollection'](), title: t['com.affine.editCollection.saveCollection'](),
showTips: true, showTips: true,
@@ -131,15 +141,31 @@ export const TagPageListHeader = ({
> >
{t['Tags']()} / {t['Tags']()} /
</div> </div>
<div className={styles.tagSticky}> <Menu
<div rootOptions={{
className={styles.tagIndicator} open: openMenu,
style={{ onOpenChange: setOpenMenu,
backgroundColor: tagColorMap(tag.color), }}
}} contentOptions={{
/> side: 'bottom',
<div className={styles.tagLabel}>{tag.value}</div> align: 'start',
</div> sideOffset: 18,
avoidCollisions: false,
className: styles.tagsMenu,
}}
items={<TagsEditor options={options} onClick={setOpenMenu} />}
>
<div className={styles.tagSticky}>
<div
className={styles.tagIndicator}
style={{
backgroundColor: tagColorMap(tag.color),
}}
/>
<div className={styles.tagLabel}>{tag.value}</div>
<ArrowDownSmallIcon className={styles.arrowDownSmallIcon} />
</div>
</Menu>
</div> </div>
<Button className={styles.addPageButton} onClick={handleClick}> <Button className={styles.addPageButton} onClick={handleClick}>
{t['com.affine.editCollection.saveCollection']()} {t['com.affine.editCollection.saveCollection']()}
@@ -148,3 +174,84 @@ export const TagPageListHeader = ({
</> </>
); );
}; };
const filterOption = (option: Tag, inputValue?: string) => {
const trimmedValue = inputValue?.trim().toLowerCase() ?? '';
const trimmedOptionValue = option.value.trim().toLowerCase();
return trimmedOptionValue.includes(trimmedValue);
};
interface TagsEditorProps {
options: Tag[];
onClick: (open: boolean) => void;
}
export const TagsEditor = ({ options, onClick }: TagsEditorProps) => {
const t = useAFFiNEI18N();
const [inputValue, setInputValue] = useState('');
const filteredOptions = useMemo(
() =>
options.filter(o => (inputValue ? filterOption(o, inputValue) : true)),
[inputValue, options]
);
const onInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
},
[]
);
const handleClick = useCallback(() => {
setInputValue('');
onClick(false);
}, [onClick]);
return (
<div className={styles.tagsEditorRoot}>
<div className={styles.tagsEditorSelectedTags}>
<SearchIcon className={styles.searchIcon} />
<input
value={inputValue}
onChange={onInputChange}
autoFocus
className={styles.searchInput}
placeholder={t['com.affine.search-tags.placeholder']()}
/>
</div>
<Divider />
<div className={styles.tagsEditorTagsSelector}>
<Scrollable.Root>
<Scrollable.Viewport
className={styles.tagSelectorTagsScrollContainer}
>
{filteredOptions.map(tag => {
return (
<Link
key={tag.id}
className={styles.tagSelectorItem}
data-tag-id={tag.id}
data-tag-value={tag.value}
to={`/tag/${tag.id}`}
onClick={handleClick}
>
<div
className={styles.tagIcon}
style={{ background: tag.color }}
/>
<div className={styles.tagSelectorItemText}>{tag.value}</div>
</Link>
);
})}
{filteredOptions.length === 0 ? (
<div className={clsx(styles.tagSelectorItem, 'disable')}>
{t['Find 0 result']()}
</div>
) : null}
</Scrollable.Viewport>
<Scrollable.Scrollbar style={{ transform: 'translateX(6px)' }} />
</Scrollable.Root>
</div>
</div>
);
};

View File

@@ -59,3 +59,20 @@ export const clearLinkStyle = style({
color: 'inherit', color: 'inherit',
}, },
}); });
export const editTagWrapper = style({
position: 'absolute',
right: '0',
width: '100%',
height: '60px',
display: 'none',
selectors: {
'&[data-show=true]': {
background: cssVar('backgroundPrimaryColor'),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'auto',
},
},
});

View File

@@ -4,6 +4,7 @@ import {
Menu, Menu,
MenuIcon, MenuIcon,
MenuItem, MenuItem,
toast,
Tooltip, Tooltip,
} from '@affine/component'; } from '@affine/component';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter'; import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
@@ -27,6 +28,8 @@ import type { CollectionService } from '../../modules/collection';
import { FavoriteTag } from './components/favorite-tag'; import { FavoriteTag } from './components/favorite-tag';
import * as styles from './list.css'; import * as styles from './list.css';
import { DisablePublicSharing, MoveToTrash } from './operation-menu-items'; import { DisablePublicSharing, MoveToTrash } from './operation-menu-items';
import { CreateOrEditTag } from './tags/create-tag';
import type { TagMeta } from './types';
import { ColWrapper, stopPropagationWithoutPrevent } from './utils'; import { ColWrapper, stopPropagationWithoutPrevent } from './utils';
import { import {
type AllPageListConfig, type AllPageListConfig,
@@ -297,3 +300,61 @@ export const CollectionOperationCell = ({
</> </>
); );
}; };
interface TagOperationCellProps {
tag: TagMeta;
onTagDelete: (tagId: string[]) => void;
}
export const TagOperationCell = ({
tag,
onTagDelete,
}: TagOperationCellProps) => {
const t = useAFFiNEI18N();
const [open, setOpen] = useState(false);
const handleDelete = useCallback(() => {
onTagDelete([tag.id]);
toast(t['com.affine.tags.delete-tags.toast']());
}, [onTagDelete, t, tag.id]);
return (
<>
<div className={styles.editTagWrapper} data-show={open}>
<div style={{ width: '100%' }}>
<CreateOrEditTag open={open} onOpenChange={setOpen} tagMeta={tag} />
</div>
</div>
<Tooltip content={t['Rename']()} side="top">
<IconButton onClick={() => setOpen(true)}>
<EditIcon />
</IconButton>
</Tooltip>
<ColWrapper alignment="start">
<Menu
items={
<MenuItem
preFix={
<MenuIcon>
<DeleteIcon />
</MenuIcon>
}
type="danger"
onSelect={handleDelete}
>
{t['Delete']()}
</MenuItem>
}
contentOptions={{
align: 'end',
}}
>
<IconButton type="plain">
<MoreVerticalIcon />
</IconButton>
</Menu>
</ColWrapper>
</>
);
};

View File

@@ -0,0 +1,65 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const createTagWrapper = style({
alignItems: 'center',
padding: '8px',
borderRadius: '8px',
margin: '0 16px',
display: 'flex',
fontSize: cssVar('fontXs'),
background: cssVar('backgroundSecondaryColor'),
selectors: {
'&[data-show="false"]': {
display: 'none',
pointerEvents: 'none',
},
},
});
export const tagColorIcon = style({
width: '8px',
height: '8px',
borderRadius: '50%',
selectors: {
'&.large': {
width: '16px',
height: '16px',
},
},
});
export const tagItemsWrapper = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '4px',
});
export const tagItem = style({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
padding: '2px',
cursor: 'pointer',
border: `1px solid ${cssVar('backgroundOverlayPanelColor')}`,
':hover': {
boxShadow: `0 0 0 1px ${cssVar('primaryColor')}`,
},
selectors: {
'&.active': {
boxShadow: `0 0 0 1px ${cssVar('primaryColor')}`,
},
},
});
export const cancelBtn = style({
marginLeft: '20px',
marginRight: '8px',
});
export const menuBtn = style({
padding: '0px 10px',
marginRight: '4px',
});

View File

@@ -0,0 +1,169 @@
import { Button, Input, Menu, toast } from '@affine/component';
import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { nanoid } from 'nanoid';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { tagColors } from '../../affine/page-properties/common';
import type { TagMeta } from '../types';
import * as styles from './create-tag.css';
const TagIcon = ({ color, large }: { color: string; large?: boolean }) => (
<div
className={clsx(styles.tagColorIcon, {
['large']: large,
})}
style={{ backgroundColor: color }}
/>
);
const randomTagColor = () => {
const randomIndex = Math.floor(Math.random() * tagColors.length);
return tagColors[randomIndex];
};
export const CreateOrEditTag = ({
open,
onOpenChange,
tagMeta,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
tagMeta?: TagMeta;
}) => {
const legacyProperties = useService(WorkspaceLegacyProperties);
const tagOptions = useLiveData(legacyProperties.tagOptions$);
const t = useAFFiNEI18N();
const [menuOpen, setMenuOpen] = useState(false);
const [tagName, setTagName] = useState(tagMeta?.title || '');
const [activeTagIcon, setActiveTagIcon] = useState(() => {
return (
tagColors.find(([_, color]) => color === tagMeta?.color) ||
randomTagColor()
);
});
const tags = useMemo(() => {
return tagColors.map(([name, color]) => {
return {
name: name,
color: color,
onClick: () => {
setActiveTagIcon([name, color]);
setMenuOpen(false);
},
};
});
}, []);
const items = useMemo(() => {
const tagItems = tags.map(item => {
return (
<div
key={item.name}
onClick={item.onClick}
className={clsx(styles.tagItem, {
['active']: item.name === activeTagIcon[0],
})}
>
<TagIcon color={item.color} large={true} />
</div>
);
});
return <div className={styles.tagItemsWrapper}>{tagItems}</div>;
}, [activeTagIcon, tags]);
const onClose = useCallback(() => {
if (!tagMeta) {
setActiveTagIcon(randomTagColor);
setTagName('');
}
onOpenChange(false);
}, [onOpenChange, tagMeta]);
const onConfirm = useCallback(() => {
if (!tagName.trim()) return;
if (tagOptions.some(tag => tag.value === tagName.trim()) && !tagMeta) {
return toast(t['com.affine.tags.create-tag.toast.exist']());
}
if (!tagMeta) {
const newTag = {
id: nanoid(),
value: tagName.trim(),
color: activeTagIcon[1] || tagColors[0][1],
};
legacyProperties.updateTagOptions([...tagOptions, newTag]);
toast(t['com.affine.tags.create-tag.toast.success']());
onClose();
return;
}
const updatedTag = {
id: tagMeta.id,
value: tagName.trim(),
color: activeTagIcon[1] || tagColors[0][1],
};
legacyProperties.updateTagOption(tagMeta.id, updatedTag);
toast(t['com.affine.tags.edit-tag.toast.success']());
onClose();
return;
}, [
activeTagIcon,
legacyProperties,
onClose,
t,
tagMeta,
tagName,
tagOptions,
]);
useEffect(() => {
if (!open) return;
if (menuOpen) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [open, onOpenChange, menuOpen, onClose]);
return (
<div className={styles.createTagWrapper} data-show={open}>
<Menu
rootOptions={{
open: menuOpen,
onOpenChange: setMenuOpen,
}}
items={items}
>
<Button className={styles.menuBtn}>
<TagIcon color={activeTagIcon[1] || ''} />
</Button>
</Menu>
<Input
placeholder={t['com.affine.tags.create-tag.placeholder']()}
inputStyle={{ fontSize: 'var(--affine-font-xs)' }}
onEnter={onConfirm}
value={tagName}
onChange={setTagName}
/>
<Button className={styles.cancelBtn} onClick={onClose}>
{t['Cancel']()}
</Button>
<Button type="primary" onClick={onConfirm} disabled={!tagName}>
{tagMeta ? t['Save']() : t['Create']()}
</Button>
</div>
);
};

View File

@@ -16,6 +16,7 @@ export const tagListHeaderTitle = style({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
userSelect: 'none',
}); });
export const newTagButton = style({ export const newTagButton = style({
padding: '6px 10px', padding: '6px 10px',

View File

@@ -1,12 +1,16 @@
import { Button } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import * as styles from './tag-list-header.css'; import * as styles from './tag-list-header.css';
export const TagListHeader = () => { export const TagListHeader = ({ onOpen }: { onOpen: () => void }) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
return ( return (
<div className={styles.tagListHeader}> <div className={styles.tagListHeader}>
<div className={styles.tagListHeaderTitle}>{t['Tags']()}</div> <div className={styles.tagListHeaderTitle}>{t['Tags']()}</div>
<Button className={styles.newTagButton} onClick={onOpen}>
{t['com.affine.tags.empty.new-tag-button']()}
</Button>
</div> </div>
); );
}; };

View File

@@ -87,7 +87,7 @@ globalStyle(`${root} > :last-child`, {
paddingRight: '8px', paddingRight: '8px',
}); });
export const titleIconsWrapper = style({ export const titleIconsWrapper = style({
padding: '0 5px', padding: '5px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '10px', gap: '10px',
@@ -119,7 +119,7 @@ export const titleCellPreview = style({
overflow: 'hidden', overflow: 'hidden',
color: cssVar('textSecondaryColor'), color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontBase'), fontSize: cssVar('fontBase'),
flex: 1, flexShrink: 0,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
alignSelf: 'stretch', alignSelf: 'stretch',
@@ -162,6 +162,13 @@ export const operationsCell = style({
columnGap: '6px', columnGap: '6px',
flexShrink: 0, flexShrink: 0,
}); });
export const tagIndicatorWrapper = style({
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const tagIndicator = style({ export const tagIndicator = style({
width: '8px', width: '8px',
height: '8px', height: '8px',

View File

@@ -14,9 +14,9 @@ const TagListTitleCell = ({
}: Pick<TagListItemProps, 'title' | 'pageCount'>) => { }: Pick<TagListItemProps, 'title' | 'pageCount'>) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
return ( return (
<div data-testid="page-list-item-title" className={styles.titleCell}> <div data-testid="tag-list-item-title" className={styles.titleCell}>
<div <div
data-testid="page-list-item-title-text" data-testid="tag-list-item-title-text"
className={styles.titleCellMain} className={styles.titleCellMain}
> >
{title || t['Untitled']()} {title || t['Untitled']()}
@@ -25,7 +25,7 @@ const TagListTitleCell = ({
data-testid="page-list-item-preview-text" data-testid="page-list-item-preview-text"
className={styles.titleCellPreview} className={styles.titleCellPreview}
> >
{`· ${pageCount} doc(s)`} {` · ${t['com.affine.tags.count']({ count: pageCount || 0 })}`}
</div> </div>
</div> </div>
); );
@@ -33,12 +33,14 @@ const TagListTitleCell = ({
const ListIconCell = ({ color }: Pick<TagListItemProps, 'color'>) => { const ListIconCell = ({ color }: Pick<TagListItemProps, 'color'>) => {
return ( return (
<div <div className={styles.tagIndicatorWrapper}>
className={styles.tagIndicator} <div
style={{ className={styles.tagIndicator}
backgroundColor: tagColorMap(color), style={{
}} backgroundColor: tagColorMap(color),
/> }}
/>
</div>
); );
}; };
@@ -138,7 +140,7 @@ export const TagListItem = (props: TagListItemProps) => {
{props.operations ? ( {props.operations ? (
<ColWrapper <ColWrapper
className={styles.actionsCellWrapper} className={styles.actionsCellWrapper}
flex={1} flex={2}
alignment="end" alignment="end"
> >
<TagListOperationsCell operations={props.operations} /> <TagListOperationsCell operations={props.operations} />

View File

@@ -1,4 +1,6 @@
import { toast } from '@affine/component';
import { Trans } from '@affine/i18n'; import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { Tag } from '@blocksuite/store'; import type { Tag } from '@blocksuite/store';
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import { Workspace } from '@toeverything/infra'; import { Workspace } from '@toeverything/infra';
@@ -6,10 +8,12 @@ import { useCallback, useMemo, useRef, useState } from 'react';
import { ListFloatingToolbar } from '../components/list-floating-toolbar'; import { ListFloatingToolbar } from '../components/list-floating-toolbar';
import { tagHeaderColsDef } from '../header-col-def'; import { tagHeaderColsDef } from '../header-col-def';
import { TagOperationCell } from '../operation-cell';
import { TagListItemRenderer } from '../page-group'; import { TagListItemRenderer } from '../page-group';
import { ListTableHeader } from '../page-header'; import { ListTableHeader } from '../page-header';
import type { ItemListHandle, ListItem, TagMeta } from '../types'; import type { ItemListHandle, ListItem, TagMeta } from '../types';
import { VirtualizedList } from '../virtualized-list'; import { VirtualizedList } from '../virtualized-list';
import { CreateOrEditTag } from './create-tag';
import { TagListHeader } from './tag-list-header'; import { TagListHeader } from './tag-list-header';
export const VirtualizedTagList = ({ export const VirtualizedTagList = ({
@@ -21,11 +25,20 @@ export const VirtualizedTagList = ({
tagMetas: TagMeta[]; tagMetas: TagMeta[];
onTagDelete: (tagIds: string[]) => void; onTagDelete: (tagIds: string[]) => void;
}) => { }) => {
const t = useAFFiNEI18N();
const listRef = useRef<ItemListHandle>(null); const listRef = useRef<ItemListHandle>(null);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [showCreateTagInput, setShowCreateTagInput] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]); const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const currentWorkspace = useService(Workspace); const currentWorkspace = useService(Workspace);
const tagOperations = useCallback(
(tag: TagMeta) => {
return <TagOperationCell tag={tag} onTagDelete={onTagDelete} />;
},
[onTagDelete]
);
const filteredSelectedTagIds = useMemo(() => { const filteredSelectedTagIds = useMemo(() => {
const ids = tags.map(tag => tag.id); const ids = tags.map(tag => tag.id);
return selectedTagIds.filter(id => ids.includes(id)); return selectedTagIds.filter(id => ids.includes(id));
@@ -35,13 +48,25 @@ export const VirtualizedTagList = ({
listRef.current?.toggleSelectable(); listRef.current?.toggleSelectable();
}, []); }, []);
const tagOperationRenderer = useCallback(() => { const tagOperationRenderer = useCallback(
return null; (item: ListItem) => {
}, []); const tag = item as TagMeta;
return tagOperations(tag);
},
[tagOperations]
);
const tagHeaderRenderer = useCallback(() => { const tagHeaderRenderer = useCallback(() => {
return <ListTableHeader headerCols={tagHeaderColsDef} />; return (
}, []); <>
<ListTableHeader headerCols={tagHeaderColsDef} />
<CreateOrEditTag
open={showCreateTagInput}
onOpenChange={setShowCreateTagInput}
/>
</>
);
}, [showCreateTagInput]);
const tagItemRenderer = useCallback((item: ListItem) => { const tagItemRenderer = useCallback((item: ListItem) => {
return <TagListItemRenderer {...item} />; return <TagListItemRenderer {...item} />;
@@ -49,20 +74,25 @@ export const VirtualizedTagList = ({
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
onTagDelete(selectedTagIds); onTagDelete(selectedTagIds);
toast(t['com.affine.delete-tags.count']({ count: selectedTagIds.length }));
hideFloatingToolbar(); hideFloatingToolbar();
return; return;
}, [hideFloatingToolbar, onTagDelete, selectedTagIds]); }, [hideFloatingToolbar, onTagDelete, selectedTagIds, t]);
const onOpenCreate = useCallback(() => {
setShowCreateTagInput(true);
}, [setShowCreateTagInput]);
return ( return (
<> <>
<VirtualizedList <VirtualizedList
ref={listRef} ref={listRef}
selectable={false} selectable="toggle"
draggable={false} draggable={false}
groupBy={false} groupBy={false}
atTopThreshold={80} atTopThreshold={80}
onSelectionActiveChange={setShowFloatingToolbar} onSelectionActiveChange={setShowFloatingToolbar}
heading={<TagListHeader />} heading={<TagListHeader onOpen={onOpenCreate} />}
selectedIds={filteredSelectedTagIds} selectedIds={filteredSelectedTagIds}
onSelectedIdsChange={setSelectedTagIds} onSelectedIdsChange={setSelectedTagIds}
items={tagMetas} items={tagMetas}

View File

@@ -104,7 +104,7 @@ export interface ListProps<T> {
export interface VirtualizedListProps<T> extends ListProps<T> { 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 headerRenderer?: (item?: T) => ReactNode; // the user provided header renderer
itemRenderer?: (item: T) => ReactNode; // the user provided item 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

View File

@@ -1,14 +1,15 @@
import type { DocMeta, Tag, Workspace } from '@blocksuite/store'; import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace';
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
interface TagUsageCounts { interface TagUsageCounts {
[key: string]: number; [key: string]: number;
} }
export function useTagMetas(currentWorkspace: Workspace, pageMetas: DocMeta[]) { export function useTagMetas(pageMetas: DocMeta[]) {
const tags = useMemo(() => { const legacyProperties = useService(WorkspaceLegacyProperties);
return currentWorkspace.meta.properties.tags?.options || []; const tags = useLiveData(legacyProperties.tagOptions$);
}, [currentWorkspace]);
const [tagMetas, tagUsageCounts] = useMemo(() => { const [tagMetas, tagUsageCounts] = useMemo(() => {
const tagUsageCounts: TagUsageCounts = {}; const tagUsageCounts: TagUsageCounts = {};
@@ -48,41 +49,13 @@ export function useTagMetas(currentWorkspace: Workspace, pageMetas: DocMeta[]) {
[pageMetas] [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( const deleteTags = useCallback(
(tagIds: string[]) => { (tagIds: string[]) => {
const newTags = tags.filter(tag => { tagIds.forEach(tagId => {
return !tagIds.includes(tag.id); legacyProperties.removeTagOption(tagId);
});
currentWorkspace.meta.setProperties({
tags: { options: newTags },
}); });
}, },
[currentWorkspace.meta, tags] [legacyProperties]
); );
return { return {
@@ -90,8 +63,6 @@ export function useTagMetas(currentWorkspace: Workspace, pageMetas: DocMeta[]) {
tagMetas, tagMetas,
tagUsageCounts, tagUsageCounts,
filterPageMetaByTag, filterPageMetaByTag,
addNewTag,
updateTag,
deleteTags, deleteTags,
}; };
} }

View File

@@ -39,8 +39,9 @@ interface BaseVirtuosoItem {
type: VirtuosoItemType; type: VirtuosoItemType;
} }
interface VirtuosoItemStickyHeader extends BaseVirtuosoItem { interface VirtuosoItemStickyHeader<T> extends BaseVirtuosoItem {
type: 'sticky-header'; type: 'sticky-header';
data?: T;
} }
interface VirtuosoItemItem<T> extends BaseVirtuosoItem { interface VirtuosoItemItem<T> extends BaseVirtuosoItem {
@@ -61,7 +62,7 @@ interface VirtuosoPageItemSpacer extends BaseVirtuosoItem {
} }
type VirtuosoItem<T> = type VirtuosoItem<T> =
| VirtuosoItemStickyHeader | VirtuosoItemStickyHeader<T>
| VirtuosoItemItem<T> | VirtuosoItemItem<T>
| VirtuosoItemGroupHeader<T> | VirtuosoItemGroupHeader<T>
| VirtuosoPageItemSpacer; | VirtuosoPageItemSpacer;
@@ -187,7 +188,7 @@ const ListInner = ({
(_index: number, data: VirtuosoItem<ListItem>) => { (_index: number, data: VirtuosoItem<ListItem>) => {
switch (data.type) { switch (data.type) {
case 'sticky-header': case 'sticky-header':
return props.headerRenderer?.(); return props.headerRenderer?.(data.data);
case 'group-header': case 'group-header':
return <ItemGroupHeader {...data.data} />; return <ItemGroupHeader {...data.data} />;
case 'item': case 'item':

View File

@@ -3,23 +3,39 @@ import {
TagListHeader, TagListHeader,
VirtualizedTagList, VirtualizedTagList,
} from '@affine/core/components/page-list/tags'; } from '@affine/core/components/page-list/tags';
import { CreateOrEditTag } from '@affine/core/components/page-list/tags/create-tag';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import { Workspace } from '@toeverything/infra'; import { Workspace } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench'; import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench';
import { EmptyTagList } from '../page-list-empty'; import { EmptyTagList } from '../page-list-empty';
import * as styles from './all-tag.css'; import * as styles from './all-tag.css';
import { AllTagHeader } from './header'; import { AllTagHeader } from './header';
const EmptyTagListHeader = () => {
const [showCreateTagInput, setShowCreateTagInput] = useState(false);
const handleOpen = useCallback(() => {
setShowCreateTagInput(true);
}, [setShowCreateTagInput]);
return (
<div>
<TagListHeader onOpen={handleOpen} />
<CreateOrEditTag
open={showCreateTagInput}
onOpenChange={setShowCreateTagInput}
/>
</div>
);
};
export const AllTag = () => { export const AllTag = () => {
const currentWorkspace = useService(Workspace); const currentWorkspace = useService(Workspace);
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.blockSuiteWorkspace); const pageMetas = useBlockSuiteDocMeta(currentWorkspace.blockSuiteWorkspace);
const { tags, tagMetas, deleteTags } = useTagMetas( const { tags, tagMetas, deleteTags } = useTagMetas(pageMetas);
currentWorkspace.blockSuiteWorkspace,
pageMetas
);
return ( return (
<> <>
@@ -35,7 +51,7 @@ export const AllTag = () => {
onTagDelete={deleteTags} onTagDelete={deleteTags}
/> />
) : ( ) : (
<EmptyTagList heading={<TagListHeader />} /> <EmptyTagList heading={<EmptyTagListHeader />} />
)} )}
</div> </div>
</ViewBodyIsland> </ViewBodyIsland>

View File

@@ -0,0 +1,9 @@
import { style } from '@vanilla-extract/css';
export const body = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
height: '100%',
width: '100%',
});

View File

@@ -1,9 +1,13 @@
import { import {
PageListHeader, TagPageListHeader,
useTagMetas, useTagMetas,
VirtualizedPageList, VirtualizedPageList,
} from '@affine/core/components/page-list'; } from '@affine/core/components/page-list';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import {
ViewBodyIsland,
ViewHeaderIsland,
} from '@affine/core/modules/workbench';
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import { Workspace } from '@toeverything/infra'; import { Workspace } from '@toeverything/infra';
import { useMemo } from 'react'; import { useMemo } from 'react';
@@ -12,15 +16,13 @@ import { useParams } from 'react-router-dom';
import { PageNotFound } from '../../404'; import { PageNotFound } from '../../404';
import { EmptyPageList } from '../page-list-empty'; import { EmptyPageList } from '../page-list-empty';
import { TagDetailHeader } from './header'; import { TagDetailHeader } from './header';
import * as styles from './index.css';
export const TagDetail = ({ tagId }: { tagId?: string }) => { export const TagDetail = ({ tagId }: { tagId?: string }) => {
const currentWorkspace = useService(Workspace); const currentWorkspace = useService(Workspace);
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.blockSuiteWorkspace); const pageMetas = useBlockSuiteDocMeta(currentWorkspace.blockSuiteWorkspace);
const { tags, filterPageMetaByTag } = useTagMetas( const { tags, filterPageMetaByTag } = useTagMetas(pageMetas);
currentWorkspace.blockSuiteWorkspace,
pageMetas
);
const tagPageMetas = useMemo(() => { const tagPageMetas = useMemo(() => {
if (tagId) { if (tagId) {
return filterPageMetaByTag(tagId); return filterPageMetaByTag(tagId);
@@ -39,16 +41,27 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
return ( return (
<> <>
<TagDetailHeader /> <ViewHeaderIsland>
{tagPageMetas.length > 0 ? ( <TagDetailHeader />
<VirtualizedPageList tag={currentTag} listItem={tagPageMetas} /> </ViewHeaderIsland>
) : ( <ViewBodyIsland>
<EmptyPageList <div className={styles.body}>
type="all" {tagPageMetas.length > 0 ? (
heading={<PageListHeader />} <VirtualizedPageList tag={currentTag} listItem={tagPageMetas} />
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} ) : (
/> <EmptyPageList
)} type="all"
heading={
<TagPageListHeader
tag={currentTag}
workspaceId={currentWorkspace.id}
/>
}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
)}
</div>
</ViewBodyIsland>
</> </>
); );
}; };

View File

@@ -568,7 +568,7 @@
"com.affine.collection.removePage.success": "Removed successfully", "com.affine.collection.removePage.success": "Removed successfully",
"com.affine.collection.toolbar.selected": "<0>{{count}}</0> 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_one": "<0>{{count}}</0> collection selected",
"com.affine.collection.toolbar.selected_others": "<0>{{count}}</0> collection(s) selected", "com.affine.collection.toolbar.selected_other": "<0>{{count}}</0> collection(s) selected",
"com.affine.collectionBar.backToAll": "Back to all", "com.affine.collectionBar.backToAll": "Back to all",
"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",
@@ -804,7 +804,7 @@
"com.affine.page.group-header.select-all": "Select All", "com.affine.page.group-header.select-all": "Select All",
"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> doc selected", "com.affine.page.toolbar.selected_one": "<0>{{count}}</0> doc selected",
"com.affine.page.toolbar.selected_others": "<0>{{count}}</0> doc(s) selected", "com.affine.page.toolbar.selected_other": "<0>{{count}}</0> doc(s) selected",
"com.affine.pageMode": "Doc Mode", "com.affine.pageMode": "Doc Mode",
"com.affine.pageMode.all": "all", "com.affine.pageMode.all": "all",
"com.affine.pageMode.edgeless": "Edgeless", "com.affine.pageMode.edgeless": "Edgeless",
@@ -1051,7 +1051,7 @@
"com.affine.storage.used.hint": "Space used", "com.affine.storage.used.hint": "Space used",
"com.affine.tag.toolbar.selected": "<0>{{count}}</0> 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_one": "<0>{{count}}</0> tag selected",
"com.affine.tag.toolbar.selected_others": "<0>{{count}}</0> tag(s) selected", "com.affine.tag.toolbar.selected_other": "<0>{{count}}</0> tag(s) selected",
"com.affine.themeSettings.dark": "Dark", "com.affine.themeSettings.dark": "Dark",
"com.affine.themeSettings.light": "Light", "com.affine.themeSettings.light": "Light",
"com.affine.themeSettings.system": "System", "com.affine.themeSettings.system": "System",
@@ -1146,5 +1146,19 @@
"com.affine.workbench.split-view-menu.close": "Close", "com.affine.workbench.split-view-menu.close": "Close",
"com.affine.workbench.split-view-menu.move-left": "Move Left", "com.affine.workbench.split-view-menu.move-left": "Move Left",
"com.affine.workbench.split-view-menu.move-right": "Move Right", "com.affine.workbench.split-view-menu.move-right": "Move Right",
"com.affine.workbench.split-view-menu.full-screen": "Full Screen" "com.affine.workbench.split-view-menu.full-screen": "Full Screen",
"com.affine.tags.empty.new-tag-button": "New Tag",
"com.affine.tags.create-tag.placeholder": "Type tag name here...",
"com.affine.tags.create-tag.toast.success": "Tag created",
"com.affine.tags.create-tag.toast.exist": "Tag already exists",
"com.affine.tags.edit-tag.toast.success": "Tag updated",
"com.affine.tags.count": "{{count}} doc",
"com.affine.tags.count_zero": "{{count}} doc",
"com.affine.tags.count_one": "{{count}} doc",
"com.affine.tags.count_other": "{{count}} docs",
"com.affine.tags.delete-tags.toast": "Tag deleted",
"com.affine.delete-tags.count": "{{count}} tag deleted",
"com.affine.delete-tags.count_one": "{{count}} tag deleted",
"com.affine.delete-tags.count_other": "{{count}} tags deleted",
"com.affine.search-tags.placeholder": "Type here ..."
} }

View File

@@ -210,7 +210,7 @@ test('select two pages and delete', async ({ page }) => {
// the floating popover should appear // the floating popover should appear
await expect(page.locator('[data-testid="floating-toolbar"]')).toBeVisible(); await expect(page.locator('[data-testid="floating-toolbar"]')).toBeVisible();
await expect(page.locator('[data-testid="floating-toolbar"]')).toHaveText( await expect(page.locator('[data-testid="floating-toolbar"]')).toHaveText(
'2 selected' '2 doc(s) selected'
); );
// click delete button // click delete button
@@ -253,6 +253,6 @@ test('select a group of items by clicking "Select All" in group header', async (
// check the selected count is equal to the one displayed in the floating toolbar // check the selected count is equal to the one displayed in the floating toolbar
await expect(page.locator('[data-testid="floating-toolbar"]')).toHaveText( await expect(page.locator('[data-testid="floating-toolbar"]')).toHaveText(
`${selectedItemCount} selected` `${selectedItemCount} doc(s) selected`
); );
}); });