mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +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',
|
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',
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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 = ({
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,6 +141,20 @@ export const TagPageListHeader = ({
|
|||||||
>
|
>
|
||||||
{t['Tags']()} /
|
{t['Tags']()} /
|
||||||
</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.tagSticky}>
|
||||||
<div
|
<div
|
||||||
className={styles.tagIndicator}
|
className={styles.tagIndicator}
|
||||||
@@ -139,7 +163,9 @@ export const TagPageListHeader = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className={styles.tagLabel}>{tag.value}</div>
|
<div className={styles.tagLabel}>{tag.value}</div>
|
||||||
|
<ArrowDownSmallIcon className={styles.arrowDownSmallIcon} />
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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',
|
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',
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 className={styles.tagIndicatorWrapper}>
|
||||||
<div
|
<div
|
||||||
className={styles.tagIndicator}
|
className={styles.tagIndicator}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: tagColorMap(color),
|
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} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
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 (
|
||||||
<>
|
<>
|
||||||
|
<ViewHeaderIsland>
|
||||||
<TagDetailHeader />
|
<TagDetailHeader />
|
||||||
|
</ViewHeaderIsland>
|
||||||
|
<ViewBodyIsland>
|
||||||
|
<div className={styles.body}>
|
||||||
{tagPageMetas.length > 0 ? (
|
{tagPageMetas.length > 0 ? (
|
||||||
<VirtualizedPageList tag={currentTag} listItem={tagPageMetas} />
|
<VirtualizedPageList tag={currentTag} listItem={tagPageMetas} />
|
||||||
) : (
|
) : (
|
||||||
<EmptyPageList
|
<EmptyPageList
|
||||||
type="all"
|
type="all"
|
||||||
heading={<PageListHeader />}
|
heading={
|
||||||
|
<TagPageListHeader
|
||||||
|
tag={currentTag}
|
||||||
|
workspaceId={currentWorkspace.id}
|
||||||
|
/>
|
||||||
|
}
|
||||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</ViewBodyIsland>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 ..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user