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',
alignItems: 'center',
gap: 8,
userSelect: 'none',
});
export const newCollectionButton = style({
padding: '6px 10px',

View File

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

View File

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

View File

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

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 { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace';
import type { Collection, Tag } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ViewLayersIcon } from '@blocksuite/icons';
import { useService } from '@toeverything/infra/di';
import {
ArrowDownSmallIcon,
SearchIcon,
ViewLayersIcon,
} from '@blocksuite/icons';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
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 { createTagFilter } from '../filter/utils';
@@ -88,9 +95,12 @@ export const TagPageListHeader = ({
tag: Tag;
workspaceId: string;
}) => {
const legacyProperties = useService(WorkspaceLegacyProperties);
const options = useLiveData(legacyProperties.tagOptions$);
const t = useAFFiNEI18N();
const { jumpToTags, jumpToCollection } = useNavigateHelper();
const collectionService = useService(CollectionService);
const [openMenu, setOpenMenu] = useState(false);
const { open, node } = useEditCollectionName({
title: t['com.affine.editCollection.saveCollection'](),
showTips: true,
@@ -131,15 +141,31 @@ export const TagPageListHeader = ({
>
{t['Tags']()} /
</div>
<div className={styles.tagSticky}>
<div
className={styles.tagIndicator}
style={{
backgroundColor: tagColorMap(tag.color),
}}
/>
<div className={styles.tagLabel}>{tag.value}</div>
</div>
<Menu
rootOptions={{
open: openMenu,
onOpenChange: setOpenMenu,
}}
contentOptions={{
side: 'bottom',
align: 'start',
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>
<Button className={styles.addPageButton} onClick={handleClick}>
{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',
},
});
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,
MenuIcon,
MenuItem,
toast,
Tooltip,
} from '@affine/component';
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 * as styles from './list.css';
import { DisablePublicSharing, MoveToTrash } from './operation-menu-items';
import { CreateOrEditTag } from './tags/create-tag';
import type { TagMeta } from './types';
import { ColWrapper, stopPropagationWithoutPrevent } from './utils';
import {
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',
alignItems: 'center',
gap: 8,
userSelect: 'none',
});
export const newTagButton = style({
padding: '6px 10px',

View File

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

View File

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

View File

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

View File

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

View File

@@ -104,7 +104,7 @@ export interface ListProps<T> {
export interface VirtualizedListProps<T> extends ListProps<T> {
heading?: ReactNode; // the user provided heading part (non sticky, above the original header)
headerRenderer?: () => ReactNode; // the user provided header renderer
headerRenderer?: (item?: T) => ReactNode; // the user provided header renderer
itemRenderer?: (item: T) => ReactNode; // the user provided item renderer
atTopThreshold?: number; // the threshold to determine whether or not the user has scrolled to the top. default is 0
atTopStateChange?: (atTop: boolean) => void; // called when the user scrolls to the top or not

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';
interface TagUsageCounts {
[key: string]: number;
}
export function useTagMetas(currentWorkspace: Workspace, pageMetas: DocMeta[]) {
const tags = useMemo(() => {
return currentWorkspace.meta.properties.tags?.options || [];
}, [currentWorkspace]);
export function useTagMetas(pageMetas: DocMeta[]) {
const legacyProperties = useService(WorkspaceLegacyProperties);
const tags = useLiveData(legacyProperties.tagOptions$);
const [tagMetas, tagUsageCounts] = useMemo(() => {
const tagUsageCounts: TagUsageCounts = {};
@@ -48,41 +49,13 @@ export function useTagMetas(currentWorkspace: Workspace, pageMetas: DocMeta[]) {
[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 },
tagIds.forEach(tagId => {
legacyProperties.removeTagOption(tagId);
});
},
[currentWorkspace.meta, tags]
[legacyProperties]
);
return {
@@ -90,8 +63,6 @@ export function useTagMetas(currentWorkspace: Workspace, pageMetas: DocMeta[]) {
tagMetas,
tagUsageCounts,
filterPageMetaByTag,
addNewTag,
updateTag,
deleteTags,
};
}

View File

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

View File

@@ -3,23 +3,39 @@ import {
TagListHeader,
VirtualizedTagList,
} 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 { useService } from '@toeverything/infra';
import { Workspace } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench';
import { EmptyTagList } from '../page-list-empty';
import * as styles from './all-tag.css';
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 = () => {
const currentWorkspace = useService(Workspace);
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.blockSuiteWorkspace);
const { tags, tagMetas, deleteTags } = useTagMetas(
currentWorkspace.blockSuiteWorkspace,
pageMetas
);
const { tags, tagMetas, deleteTags } = useTagMetas(pageMetas);
return (
<>
@@ -35,7 +51,7 @@ export const AllTag = () => {
onTagDelete={deleteTags}
/>
) : (
<EmptyTagList heading={<TagListHeader />} />
<EmptyTagList heading={<EmptyTagListHeader />} />
)}
</div>
</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 {
PageListHeader,
TagPageListHeader,
useTagMetas,
VirtualizedPageList,
} from '@affine/core/components/page-list';
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 { Workspace } from '@toeverything/infra';
import { useMemo } from 'react';
@@ -12,15 +16,13 @@ import { useParams } from 'react-router-dom';
import { PageNotFound } from '../../404';
import { EmptyPageList } from '../page-list-empty';
import { TagDetailHeader } from './header';
import * as styles from './index.css';
export const TagDetail = ({ tagId }: { tagId?: string }) => {
const currentWorkspace = useService(Workspace);
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.blockSuiteWorkspace);
const { tags, filterPageMetaByTag } = useTagMetas(
currentWorkspace.blockSuiteWorkspace,
pageMetas
);
const { tags, filterPageMetaByTag } = useTagMetas(pageMetas);
const tagPageMetas = useMemo(() => {
if (tagId) {
return filterPageMetaByTag(tagId);
@@ -39,16 +41,27 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
return (
<>
<TagDetailHeader />
{tagPageMetas.length > 0 ? (
<VirtualizedPageList tag={currentTag} listItem={tagPageMetas} />
) : (
<EmptyPageList
type="all"
heading={<PageListHeader />}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
)}
<ViewHeaderIsland>
<TagDetailHeader />
</ViewHeaderIsland>
<ViewBodyIsland>
<div className={styles.body}>
{tagPageMetas.length > 0 ? (
<VirtualizedPageList tag={currentTag} listItem={tagPageMetas} />
) : (
<EmptyPageList
type="all"
heading={
<TagPageListHeader
tag={currentTag}
workspaceId={currentWorkspace.id}
/>
}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
)}
</div>
</ViewBodyIsland>
</>
);
};