mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-11 20:08:37 +00:00
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:
@@ -16,6 +16,7 @@ export const collectionListHeaderTitle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
userSelect: 'none',
|
||||
});
|
||||
export const newCollectionButton = style({
|
||||
padding: '6px 10px',
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ export const tagListHeaderTitle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
userSelect: 'none',
|
||||
});
|
||||
export const newTagButton = style({
|
||||
padding: '6px 10px',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const body = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user