refactor(core): refactor tag to use di (#6079)

use case
```
const tagService = useService(TagService);
const tags = useLiveData(tagService.tags);
const currentTagLiveData = tagService.tagByTagId(tagId);
const currentTag = useLiveData(currentTagLiveData);

```
This commit is contained in:
JimmFly
2024-03-19 08:39:15 +00:00
parent 332cd3b380
commit 9030ca511e
24 changed files with 468 additions and 355 deletions
+4
View File
@@ -53,6 +53,8 @@ export const collectionSchema = z.object({
name: z.string(), name: z.string(),
filterList: z.array(filterSchema), filterList: z.array(filterSchema),
allowList: z.array(z.string()), allowList: z.array(z.string()),
createDate: z.union([z.date(), z.number()]).optional(),
updateDate: z.union([z.date(), z.number()]).optional(),
}); });
export const deletedCollectionSchema = z.object({ export const deletedCollectionSchema = z.object({
userId: z.string().optional(), userId: z.string().optional(),
@@ -78,6 +80,8 @@ export const tagSchema = z.object({
value: z.string(), value: z.string(),
color: z.string(), color: z.string(),
parentId: z.string().optional(), parentId: z.string().optional(),
createDate: z.union([z.date(), z.number()]).optional(),
updateDate: z.union([z.date(), z.number()]).optional(),
}); });
export type Tag = z.input<typeof tagSchema>; export type Tag = z.input<typeof tagSchema>;
@@ -7,7 +7,7 @@ import type { PagePropertiesManager } from './page-properties-manager';
export const managerContext = createContext<PagePropertiesManager>(); export const managerContext = createContext<PagePropertiesManager>();
type TagColorHelper<T> = T extends `paletteLine${infer Color}` ? Color : never; type TagColorHelper<T> = T extends `paletteLine${infer Color}` ? Color : never;
type TagColorName = TagColorHelper<Parameters<typeof cssVar>[0]>; export type TagColorName = TagColorHelper<Parameters<typeof cssVar>[0]>;
const tagColorIds: TagColorName[] = [ const tagColorIds: TagColorName[] = [
'Red', 'Red',
@@ -1,6 +1,5 @@
import { Checkbox, DatePicker, Menu } from '@affine/component'; import { Checkbox, DatePicker, Menu } from '@affine/component';
import { useAllBlockSuiteDocMeta } from '@affine/core/hooks/use-all-block-suite-page-meta'; import { useAllBlockSuiteDocMeta } from '@affine/core/hooks/use-all-block-suite-page-meta';
import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace';
import type { import type {
PageInfoCustomProperty, PageInfoCustomProperty,
PageInfoCustomPropertyMeta, PageInfoCustomPropertyMeta,
@@ -9,7 +8,7 @@ import type {
import { timestampToLocalDate } from '@affine/core/utils'; import { timestampToLocalDate } from '@affine/core/utils';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import { Doc, useLiveData, useService, Workspace } from '@toeverything/infra'; import { Doc, useService, Workspace } from '@toeverything/infra';
import { noop } from 'lodash-es'; import { noop } from 'lodash-es';
import { import {
type ChangeEventHandler, type ChangeEventHandler,
@@ -179,29 +178,17 @@ export const TagsValue = () => {
const page = useService(Doc); const page = useService(Doc);
const docCollection = workspace.docCollection; const docCollection = workspace.docCollection;
const pageMetas = useAllBlockSuiteDocMeta(docCollection); const pageMetas = useAllBlockSuiteDocMeta(docCollection);
const legacyProperties = useService(WorkspaceLegacyProperties);
const options = useLiveData(legacyProperties.tagOptions$);
const pageMeta = pageMetas.find(x => x.id === page.id); const pageMeta = pageMetas.find(x => x.id === page.id);
assertExists(pageMeta, 'pageMeta should exist'); assertExists(pageMeta, 'pageMeta should exist');
const tagIds = pageMeta.tags;
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const onChange = useCallback(
(tags: string[]) => {
legacyProperties.updatePageTags(page.id, tags);
},
[legacyProperties, page.id]
);
return ( return (
<TagsInlineEditor <TagsInlineEditor
className={styles.propertyRowValueCell} className={styles.propertyRowValueCell}
placeholder={t['com.affine.page-properties.property-value-placeholder']()} placeholder={t['com.affine.page-properties.property-value-placeholder']()}
value={tagIds} pageId={page.id}
options={options}
readonly={page.blockSuiteDoc.readonly} readonly={page.blockSuiteDoc.readonly}
onChange={onChange}
onOptionsChange={legacyProperties.updateTagOptions}
/> />
); );
}; };
@@ -6,13 +6,13 @@ import {
Scrollable, Scrollable,
} from '@affine/component'; } from '@affine/component';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { TagService } from '@affine/core/modules/tag';
import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace'; import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { DeleteIcon, MoreHorizontalIcon, TagsIcon } from '@blocksuite/icons'; import { DeleteIcon, MoreHorizontalIcon, TagsIcon } from '@blocksuite/icons';
import type { Tag } from '@blocksuite/store'; import { useLiveData } from '@toeverything/infra';
import { useService } from '@toeverything/infra/di'; import { useService } from '@toeverything/infra/di';
import clsx from 'clsx'; import clsx from 'clsx';
import { nanoid } from 'nanoid';
import { import {
type HTMLAttributes, type HTMLAttributes,
type PropsWithChildren, type PropsWithChildren,
@@ -22,16 +22,13 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { TagItem } from '../../page-list'; import { TagItem, TempTagItem } from '../../page-list';
import { tagColors } from './common'; import { tagColors } from './common';
import { type MenuItemOption, renderMenuItemOptions } from './menu-items'; import { type MenuItemOption, renderMenuItemOptions } from './menu-items';
import * as styles from './tags-inline-editor.css'; import * as styles from './tags-inline-editor.css';
interface TagsEditorProps { interface TagsEditorProps {
value: string[]; // selected tag ids pageId: string;
onChange?: (value: string[]) => void;
options: Tag[];
onOptionsChange?: (options: Tag[]) => void; // adding/updating/removing tags
readonly?: boolean; readonly?: boolean;
} }
@@ -40,25 +37,26 @@ interface InlineTagsListProps
Omit<TagsEditorProps, 'onOptionsChange'> {} Omit<TagsEditorProps, 'onOptionsChange'> {}
const InlineTagsList = ({ const InlineTagsList = ({
value, pageId,
onChange,
options,
readonly, readonly,
children, children,
}: PropsWithChildren<InlineTagsListProps>) => { }: PropsWithChildren<InlineTagsListProps>) => {
const tagService = useService(TagService);
const tags = useLiveData(tagService.tags);
const tagIds = useLiveData(tagService.tagIdsByPageId(pageId));
return ( return (
<div className={styles.inlineTagsContainer} data-testid="inline-tags-list"> <div className={styles.inlineTagsContainer} data-testid="inline-tags-list">
{value.map((tagId, idx) => { {tagIds.map((tagId, idx) => {
const tag = options.find(t => t.id === tagId); const tag = tags.find(t => t.id === tagId);
if (!tag) { if (!tag) {
return null; return null;
} }
const onRemoved = const onRemoved = readonly
readonly || !onChange ? undefined
? undefined : () => {
: () => { tag.untag(pageId);
onChange(value.filter(v => v !== tagId)); };
};
return ( return (
<TagItem <TagItem
key={tagId} key={tagId}
@@ -74,18 +72,16 @@ const InlineTagsList = ({
); );
}; };
const filterOption = (option: Tag, inputValue?: string) => {
const trimmedValue = inputValue?.trim().toLowerCase() ?? '';
const trimmedOptionValue = option.value.trim().toLowerCase();
return trimmedOptionValue.includes(trimmedValue);
};
export const EditTagMenu = ({ export const EditTagMenu = ({
tag, tagId,
children, children,
}: PropsWithChildren<{ tag: Tag }>) => { }: PropsWithChildren<{ tagId: string }>) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const legacyProperties = useService(WorkspaceLegacyProperties); const legacyProperties = useService(WorkspaceLegacyProperties);
const tagService = useService(TagService);
const tag = useLiveData(tagService.tagByTagId(tagId));
const tagColor = useLiveData(tag?.color);
const tagValue = useLiveData(tag?.value);
const navigate = useNavigateHelper(); const navigate = useNavigateHelper();
const menuProps = useMemo(() => { const menuProps = useMemo(() => {
@@ -94,14 +90,11 @@ export const EditTagMenu = ({
if (name.trim() === '') { if (name.trim() === '') {
return; return;
} }
legacyProperties.updateTagOption(tag.id, { tag?.rename(name);
...tag,
value: name,
});
}; };
options.push( options.push(
<Input <Input
defaultValue={tag.value} defaultValue={tagValue}
onBlur={e => { onBlur={e => {
updateTagName(e.currentTarget.value); updateTagName(e.currentTarget.value);
}} }}
@@ -123,7 +116,7 @@ export const EditTagMenu = ({
icon: <DeleteIcon />, icon: <DeleteIcon />,
type: 'danger', type: 'danger',
onClick() { onClick() {
legacyProperties.removeTagOption(tag.id); tagService.deleteTag(tag?.id || '');
}, },
}); });
@@ -131,7 +124,7 @@ export const EditTagMenu = ({
text: t['com.affine.page-properties.tags.open-tags-page'](), text: t['com.affine.page-properties.tags.open-tags-page'](),
icon: <TagsIcon />, icon: <TagsIcon />,
onClick() { onClick() {
navigate.jumpToTag(legacyProperties.workspaceId, tag.id); navigate.jumpToTag(legacyProperties.workspaceId, tag?.id || '');
}, },
}); });
@@ -151,12 +144,9 @@ export const EditTagMenu = ({
/> />
</div> </div>
), ),
checked: tag.color === color, checked: tagColor === color,
onClick() { onClick() {
legacyProperties.updateTagOption(tag.id, { tag?.changeColor(color);
...tag,
color,
});
}, },
}; };
}) })
@@ -171,26 +161,35 @@ export const EditTagMenu = ({
}, },
items, items,
} satisfies Partial<MenuProps>; } satisfies Partial<MenuProps>;
}, [legacyProperties, navigate, t, tag]); }, [
legacyProperties.workspaceId,
navigate,
t,
tag,
tagColor,
tagService,
tagValue,
]);
return <Menu {...menuProps}>{children}</Menu>; return <Menu {...menuProps}>{children}</Menu>;
}; };
export const TagsEditor = ({ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
options,
value,
onChange,
onOptionsChange,
readonly,
}: TagsEditorProps) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const tagService = useService(TagService);
const tags = useLiveData(tagService.tags);
const tagIds = useLiveData(tagService.tagIdsByPageId(pageId));
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const exactMatch = options.find(o => o.value === inputValue);
const filteredOptions = useMemo( const exactMatch = useLiveData(tagService.tagByTagValue(inputValue));
() =>
options.filter(o => (inputValue ? filterOption(o, inputValue) : true)), const filteredLiveData = useMemo(() => {
[inputValue, options] if (inputValue) {
); return tagService.filterTagsByName(inputValue);
}
return tagService.tags;
}, [inputValue, tagService]);
const filteredTags = useLiveData(filteredLiveData);
const onInputChange = useCallback( const onInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -201,11 +200,11 @@ export const TagsEditor = ({
const onAddTag = useCallback( const onAddTag = useCallback(
(id: string) => { (id: string) => {
if (!value.includes(id)) { if (!tagIds.includes(id)) {
onChange?.([...value, id]); tags.find(o => o.id === id)?.tag(pageId);
} }
}, },
[onChange, value] [pageId, tagIds, tags]
); );
const [nextColor, rotateNextColor] = useReducer( const [nextColor, rotateNextColor] = useReducer(
@@ -221,17 +220,11 @@ export const TagsEditor = ({
if (!name.trim()) { if (!name.trim()) {
return; return;
} }
const newTag = {
id: nanoid(),
value: name.trim(),
color: nextColor,
};
rotateNextColor(); rotateNextColor();
onOptionsChange?.([...options, newTag]); const newTag = tagService.createTag(name.trim(), nextColor);
onChange?.([...value, newTag.id]); newTag.tag(pageId);
}, },
[nextColor, onChange, onOptionsChange, options, value] [nextColor, pageId, tagService]
); );
const onInputKeyDown = useCallback( const onInputKeyDown = useCallback(
@@ -243,22 +236,18 @@ export const TagsEditor = ({
onCreateTag(inputValue); onCreateTag(inputValue);
} }
setInputValue(''); setInputValue('');
} else if (e.key === 'Backspace' && inputValue === '' && value.length) { } else if (e.key === 'Backspace' && inputValue === '' && tagIds.length) {
onChange?.(value.slice(0, value.length - 1)); const lastTagId = tagIds[tagIds.length - 1];
tags.find(tag => tag.id === lastTagId)?.untag(pageId);
} }
}, },
[exactMatch, inputValue, onAddTag, onChange, onCreateTag, value] [exactMatch, inputValue, onAddTag, onCreateTag, pageId, tagIds, tags]
); );
return ( return (
<div data-testid="tags-editor-popup" className={styles.tagsEditorRoot}> <div data-testid="tags-editor-popup" className={styles.tagsEditorRoot}>
<div className={styles.tagsEditorSelectedTags}> <div className={styles.tagsEditorSelectedTags}>
<InlineTagsList <InlineTagsList pageId={pageId} readonly={readonly}>
options={options}
value={value}
onChange={onChange}
readonly={readonly}
>
<input <input
value={inputValue} value={inputValue}
onChange={onInputChange} onChange={onInputChange}
@@ -277,7 +266,7 @@ export const TagsEditor = ({
<Scrollable.Viewport <Scrollable.Viewport
className={styles.tagSelectorTagsScrollContainer} className={styles.tagSelectorTagsScrollContainer}
> >
{filteredOptions.map(tag => { {filteredTags.map(tag => {
return ( return (
<div <div
key={tag.id} key={tag.id}
@@ -291,7 +280,7 @@ export const TagsEditor = ({
> >
<TagItem maxWidth="100%" tag={tag} mode="inline" /> <TagItem maxWidth="100%" tag={tag} mode="inline" />
<div className={styles.spacer} /> <div className={styles.spacer} />
<EditTagMenu tag={tag}> <EditTagMenu tagId={tag.id}>
<IconButton <IconButton
className={styles.tagEditIcon} className={styles.tagEditIcon}
type="plain" type="plain"
@@ -311,15 +300,7 @@ export const TagsEditor = ({
}} }}
> >
{t['Create']()}{' '} {t['Create']()}{' '}
<TagItem <TempTagItem value={inputValue} color={nextColor} />
maxWidth="100%"
tag={{
id: inputValue,
value: inputValue,
color: nextColor,
}}
mode="inline"
/>
</div> </div>
)} )}
</Scrollable.Viewport> </Scrollable.Viewport>
@@ -337,15 +318,14 @@ interface TagsInlineEditorProps extends TagsEditorProps {
// this tags value renderer right now only renders the legacy tags for now // this tags value renderer right now only renders the legacy tags for now
export const TagsInlineEditor = ({ export const TagsInlineEditor = ({
value, pageId,
onChange,
options,
onOptionsChange,
readonly, readonly,
placeholder, placeholder,
className, className,
}: TagsInlineEditorProps) => { }: TagsInlineEditorProps) => {
const empty = !value || value.length === 0; const tagService = useService(TagService);
const tagIds = useLiveData(tagService.tagIdsByPageId(pageId));
const empty = !tagIds || tagIds.length === 0;
return ( return (
<Menu <Menu
contentOptions={{ contentOptions={{
@@ -358,31 +338,14 @@ export const TagsInlineEditor = ({
e.stopPropagation(); e.stopPropagation();
}, },
}} }}
items={ items={<TagsEditor pageId={pageId} readonly={readonly} />}
<TagsEditor
value={value}
options={options}
onChange={onChange}
onOptionsChange={onOptionsChange}
readonly={readonly}
/>
}
> >
<div <div
className={clsx(styles.tagsInlineEditor, className)} className={clsx(styles.tagsInlineEditor, className)}
data-empty={empty} data-empty={empty}
data-readonly={readonly} data-readonly={readonly}
> >
{empty ? ( {empty ? placeholder : <InlineTagsList pageId={pageId} readonly />}
placeholder
) : (
<InlineTagsList
value={value}
onChange={onChange}
options={options}
readonly
/>
)}
</div> </div>
</Menu> </Menu>
); );
@@ -1,8 +1,8 @@
import { Button, Divider, Menu, Scrollable } 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 Tag, TagService } from '@affine/core/modules/tag';
import type { Collection, Tag } from '@affine/env/filter'; import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { import {
ArrowDownSmallIcon, ArrowDownSmallIcon,
@@ -18,7 +18,6 @@ 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';
import { createEmptyCollection } from '../use-collection-manager'; import { createEmptyCollection } from '../use-collection-manager';
import { tagColorMap } from '../utils';
import type { AllPageListConfig } from '../view/edit-collection/edit-collection'; import type { AllPageListConfig } from '../view/edit-collection/edit-collection';
import { import {
useEditCollection, useEditCollection,
@@ -95,8 +94,9 @@ export const TagPageListHeader = ({
tag: Tag; tag: Tag;
workspaceId: string; workspaceId: string;
}) => { }) => {
const legacyProperties = useService(WorkspaceLegacyProperties); const tagColor = useLiveData(tag.color);
const options = useLiveData(legacyProperties.tagOptions$); const tagTitle = useLiveData(tag.value);
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const { jumpToTags, jumpToCollection } = useNavigateHelper(); const { jumpToTags, jumpToCollection } = useNavigateHelper();
const collectionService = useService(CollectionService); const collectionService = useService(CollectionService);
@@ -153,16 +153,16 @@ export const TagPageListHeader = ({
avoidCollisions: false, avoidCollisions: false,
className: styles.tagsMenu, className: styles.tagsMenu,
}} }}
items={<TagsEditor options={options} onClick={setOpenMenu} />} items={<SwitchTag onClick={setOpenMenu} />}
> >
<div className={styles.tagSticky}> <div className={styles.tagSticky}>
<div <div
className={styles.tagIndicator} className={styles.tagIndicator}
style={{ style={{
backgroundColor: tagColorMap(tag.color), backgroundColor: tagColor,
}} }}
/> />
<div className={styles.tagLabel}>{tag.value}</div> <div className={styles.tagLabel}>{tagTitle}</div>
<ArrowDownSmallIcon className={styles.arrowDownSmallIcon} /> <ArrowDownSmallIcon className={styles.arrowDownSmallIcon} />
</div> </div>
</Menu> </Menu>
@@ -175,25 +175,21 @@ export const TagPageListHeader = ({
); );
}; };
const filterOption = (option: Tag, inputValue?: string) => { interface SwitchTagProps {
const trimmedValue = inputValue?.trim().toLowerCase() ?? '';
const trimmedOptionValue = option.value.trim().toLowerCase();
return trimmedOptionValue.includes(trimmedValue);
};
interface TagsEditorProps {
options: Tag[];
onClick: (open: boolean) => void; onClick: (open: boolean) => void;
} }
export const TagsEditor = ({ options, onClick }: TagsEditorProps) => { export const SwitchTag = ({ onClick }: SwitchTagProps) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const filteredOptions = useMemo( const tagService = useService(TagService);
() => const filteredLiveData = useMemo(() => {
options.filter(o => (inputValue ? filterOption(o, inputValue) : true)), if (inputValue) {
[inputValue, options] return tagService.filterTagsByName(inputValue);
); }
return tagService.tags;
}, [inputValue, tagService]);
const filteredTags = useLiveData(filteredLiveData);
const onInputChange = useCallback( const onInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -225,25 +221,10 @@ export const TagsEditor = ({ options, onClick }: TagsEditorProps) => {
<Scrollable.Viewport <Scrollable.Viewport
className={styles.tagSelectorTagsScrollContainer} className={styles.tagSelectorTagsScrollContainer}
> >
{filteredOptions.map(tag => { {filteredTags.map(tag => {
return ( return <TagLink key={tag.id} tag={tag} onClick={handleClick} />;
<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 ? ( {filteredTags.length === 0 ? (
<div className={clsx(styles.tagSelectorItem, 'disable')}> <div className={clsx(styles.tagSelectorItem, 'disable')}>
{t['Find 0 result']()} {t['Find 0 result']()}
</div> </div>
@@ -255,3 +236,21 @@ export const TagsEditor = ({ options, onClick }: TagsEditorProps) => {
</div> </div>
); );
}; };
const TagLink = ({ tag, onClick }: { tag: Tag; onClick: () => void }) => {
const tagColor = useLiveData(tag.color);
const tagTitle = useLiveData(tag.value);
return (
<Link
key={tag.id}
className={styles.tagSelectorItem}
data-tag-id={tag.id}
data-tag-value={tagTitle}
to={`/tag/${tag.id}`}
onClick={onClick}
>
<div className={styles.tagIcon} style={{ background: tagColor }} />
<div className={styles.tagSelectorItemText}>{tagTitle}</div>
</Link>
);
};
@@ -1,5 +1,7 @@
import { Checkbox } from '@affine/component'; import { Checkbox } from '@affine/component';
import { TagService } from '@affine/core/modules/tag';
import { useDraggable } from '@dnd-kit/core'; import { useDraggable } from '@dnd-kit/core';
import { useLiveData, useService } from '@toeverything/infra';
import { type PropsWithChildren, useCallback, useMemo } from 'react'; import { type PropsWithChildren, useCallback, useMemo } from 'react';
import { WorkbenchLink } from '../../../modules/workbench/view/workbench-link'; import { WorkbenchLink } from '../../../modules/workbench/view/workbench-link';
@@ -65,7 +67,11 @@ const PageSelectionCell = ({
); );
}; };
export const PageTagsCell = ({ tags }: Pick<PageListItemProps, 'tags'>) => { export const PageTagsCell = ({ pageId }: Pick<PageListItemProps, 'pageId'>) => {
const tagsService = useService(TagService);
const tagsLiveData = tagsService.tagsByPageId(pageId);
const tags = useLiveData(tagsLiveData);
return ( return (
<div data-testid="page-list-item-tags" className={styles.tagsCell}> <div data-testid="page-list-item-tags" className={styles.tagsCell}>
<PageTags <PageTags
@@ -177,7 +183,7 @@ export const PageListItem = (props: PageListItemProps) => {
<ListTitleCell title={props.title} preview={props.preview} /> <ListTitleCell title={props.title} preview={props.preview} />
</ColWrapper> </ColWrapper>
<ColWrapper flex={4} alignment="end" style={{ overflow: 'visible' }}> <ColWrapper flex={4} alignment="end" style={{ overflow: 'visible' }}>
<PageTagsCell tags={props.tags} /> <PageTagsCell pageId={props.pageId} />
</ColWrapper> </ColWrapper>
</ColWrapper> </ColWrapper>
<ColWrapper flex={1} alignment="end" hideInSmallContainer> <ColWrapper flex={1} alignment="end" hideInSmallContainer>
@@ -1,11 +1,12 @@
import { Menu } from '@affine/component'; import { Menu } from '@affine/component';
import type { Tag } from '@affine/env/filter'; import { type Tag } from '@affine/core/modules/tag';
import { CloseIcon, MoreHorizontalIcon } from '@blocksuite/icons'; import { CloseIcon, MoreHorizontalIcon } from '@blocksuite/icons';
import { LiveData, useLiveData } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic'; import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx'; import clsx from 'clsx';
import { type MouseEventHandler, useCallback, useMemo } from 'react'; import { type MouseEventHandler, useCallback, useMemo } from 'react';
import { stopPropagation, tagColorMap } from '../utils'; import { stopPropagation } from '../utils';
import * as styles from './page-tags.css'; import * as styles from './page-tags.css';
export interface PageTagsProps { export interface PageTagsProps {
@@ -16,7 +17,7 @@ export interface PageTagsProps {
} }
interface TagItemProps { interface TagItemProps {
tag: Tag; tag?: Tag;
idx?: number; idx?: number;
maxWidth?: number | string; maxWidth?: number | string;
mode: 'inline' | 'list-item'; mode: 'inline' | 'list-item';
@@ -24,6 +25,30 @@ interface TagItemProps {
style?: React.CSSProperties; style?: React.CSSProperties;
} }
export const TempTagItem = ({
value,
color,
maxWidth = '100%',
}: {
value: string;
color: string;
maxWidth?: number | string;
}) => {
return (
<div className={styles.tag} title={value}>
<div style={{ maxWidth: maxWidth }} className={styles.tagInline}>
<div
className={styles.tagIndicator}
style={{
backgroundColor: color,
}}
/>
<div className={styles.tagLabel}>{value}</div>
</div>
</div>
);
};
export const TagItem = ({ export const TagItem = ({
tag, tag,
idx, idx,
@@ -32,6 +57,8 @@ export const TagItem = ({
style, style,
maxWidth, maxWidth,
}: TagItemProps) => { }: TagItemProps) => {
const value = useLiveData(tag?.value);
const color = useLiveData(tag?.color);
const handleRemove: MouseEventHandler = useCallback( const handleRemove: MouseEventHandler = useCallback(
e => { e => {
e.stopPropagation(); e.stopPropagation();
@@ -44,9 +71,9 @@ export const TagItem = ({
data-testid="page-tag" data-testid="page-tag"
className={styles.tag} className={styles.tag}
data-idx={idx} data-idx={idx}
data-tag-id={tag.id} data-tag-id={tag?.id}
data-tag-value={tag.value} data-tag-value={value}
title={tag.value} title={value}
style={style} style={style}
> >
<div <div
@@ -56,10 +83,10 @@ export const TagItem = ({
<div <div
className={styles.tagIndicator} className={styles.tagIndicator}
style={{ style={{
backgroundColor: tagColorMap(tag.color), backgroundColor: color,
}} }}
/> />
<div className={styles.tagLabel}>{tag.value}</div> <div className={styles.tagLabel}>{value}</div>
{onRemoved ? ( {onRemoved ? (
<div <div
data-testid="remove-tag-button" data-testid="remove-tag-button"
@@ -74,6 +101,34 @@ export const TagItem = ({
); );
}; };
const TagItemNormal = ({
tags,
maxItems,
}: {
tags: Tag[];
maxItems?: number;
}) => {
const nTags = useMemo(() => {
return maxItems ? tags.slice(0, maxItems) : tags;
}, [maxItems, tags]);
const tagsOrderedLiveData = useMemo(() => {
return LiveData.computed(get =>
[...nTags].sort((a, b) => get(a.value).length - get(b.value).length)
);
}, [nTags]);
const tagsOrdered = useLiveData(tagsOrderedLiveData);
return useMemo(
() =>
tagsOrdered.map((tag, idx) => (
<TagItem key={tag.id} tag={tag} idx={idx} mode="inline" />
)),
[tagsOrdered]
);
};
export const PageTags = ({ export const PageTags = ({
tags, tags,
widthOnHover, widthOnHover,
@@ -97,16 +152,6 @@ export const PageTags = ({
); );
}, [maxItems, tags]); }, [maxItems, tags]);
const tagsNormal = useMemo(() => {
const nTags = maxItems ? tags.slice(0, maxItems) : tags;
// sort tags by length
nTags.sort((a, b) => a.value.length - b.value.length);
return nTags.map((tag, idx) => (
<TagItem key={tag.id} tag={tag} idx={idx} mode="inline" />
));
}, [maxItems, tags]);
return ( return (
<div <div
data-testid="page-tags" data-testid="page-tags"
@@ -123,7 +168,9 @@ export const PageTags = ({
className={clsx(styles.innerContainer)} className={clsx(styles.innerContainer)}
> >
<div className={styles.innerBackdrop} /> <div className={styles.innerBackdrop} />
<div className={styles.tagsScrollContainer}>{tagsNormal}</div> <div className={styles.tagsScrollContainer}>
<TagItemNormal tags={tags} maxItems={maxItems} />
</div>
{maxItems && tags.length > maxItems ? ( {maxItems && tags.length > maxItems ? (
<Menu <Menu
items={tagsInPopover} items={tagsInPopover}
@@ -2,11 +2,12 @@ import { toast } from '@affine/component';
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper'; import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper'; import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import type { Tag } from '@affine/core/modules/tag';
import { Workbench } from '@affine/core/modules/workbench'; import { Workbench } from '@affine/core/modules/workbench';
import type { Collection, Filter } from '@affine/env/filter'; import type { Collection, Filter } from '@affine/env/filter';
import { Trans } from '@affine/i18n'; import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { DocMeta, Tag } from '@blocksuite/store'; import type { DocMeta } from '@blocksuite/store';
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import { Workspace } from '@toeverything/infra'; import { Workspace } from '@toeverything/infra';
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
@@ -16,7 +16,6 @@ export * from './tags';
export * from './types'; export * from './types';
export * from './use-collection-manager'; export * from './use-collection-manager';
export * from './use-filtered-page-metas'; export * from './use-filtered-page-metas';
export * from './use-tag-metas';
export * from './utils'; export * from './utils';
export * from './view'; export * from './view';
export * from './virtualized-list'; export * from './virtualized-list';
@@ -1,9 +1,8 @@
import { Button, Input, Menu, toast } from '@affine/component'; import { Button, Input, Menu, toast } from '@affine/component';
import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace'; import { TagService } from '@affine/core/modules/tag';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import { nanoid } from 'nanoid';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { tagColors } from '../../affine/page-properties/common'; import { tagColors } from '../../affine/page-properties/common';
@@ -21,7 +20,7 @@ const TagIcon = ({ color, large }: { color: string; large?: boolean }) => (
const randomTagColor = () => { const randomTagColor = () => {
const randomIndex = Math.floor(Math.random() * tagColors.length); const randomIndex = Math.floor(Math.random() * tagColors.length);
return tagColors[randomIndex]; return tagColors[randomIndex][1];
}; };
export const CreateOrEditTag = ({ export const CreateOrEditTag = ({
@@ -33,39 +32,44 @@ export const CreateOrEditTag = ({
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
tagMeta?: TagMeta; tagMeta?: TagMeta;
}) => { }) => {
const legacyProperties = useService(WorkspaceLegacyProperties); const tagService = useService(TagService);
const tagOptions = useLiveData(legacyProperties.tagOptions$); const tagOptions = useLiveData(tagService.tagMetas);
const tag = useLiveData(tagService.tagByTagId(tagMeta?.id));
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [tagName, setTagName] = useState(tagMeta?.title || '');
const [activeTagIcon, setActiveTagIcon] = useState(() => { const [tagName, setTagName] = useState(tagMeta?.title);
return ( const handleChangeName = useCallback((value: string) => {
tagColors.find(([_, color]) => color === tagMeta?.color) || setTagName(value);
randomTagColor() }, []);
);
}); const [tagIcon, setTagIcon] = useState(tagMeta?.color || randomTagColor());
const handleChangeIcon = useCallback((value: string) => {
setTagIcon(value);
}, []);
const tags = useMemo(() => { const tags = useMemo(() => {
return tagColors.map(([name, color]) => { return tagColors.map(([_, color]) => {
return { return {
name: name, name: name,
color: color, color: color,
onClick: () => { onClick: () => {
setActiveTagIcon([name, color]); handleChangeIcon(color);
setMenuOpen(false); setMenuOpen(false);
}, },
}; };
}); });
}, []); }, [handleChangeIcon]);
const items = useMemo(() => { const items = useMemo(() => {
const tagItems = tags.map(item => { const tagItems = tags.map(item => {
return ( return (
<div <div
key={item.name} key={item.color}
onClick={item.onClick} onClick={item.onClick}
className={clsx(styles.tagItem, { className={clsx(styles.tagItem, {
['active']: item.name === activeTagIcon[0], ['active']: item.color === tagIcon,
})} })}
> >
<TagIcon color={item.color} large={true} /> <TagIcon color={item.color} large={true} />
@@ -73,52 +77,38 @@ export const CreateOrEditTag = ({
); );
}); });
return <div className={styles.tagItemsWrapper}>{tagItems}</div>; return <div className={styles.tagItemsWrapper}>{tagItems}</div>;
}, [activeTagIcon, tags]); }, [tagIcon, tags]);
const onClose = useCallback(() => { const onClose = useCallback(() => {
if (!tagMeta) { if (!tagMeta) {
setActiveTagIcon(randomTagColor); handleChangeIcon(randomTagColor());
setTagName(''); setTagName('');
} }
onOpenChange(false); onOpenChange(false);
}, [onOpenChange, tagMeta]); }, [handleChangeIcon, onOpenChange, tagMeta]);
const onConfirm = useCallback(() => { const onConfirm = useCallback(() => {
if (!tagName.trim()) return; if (!tagName?.trim()) return;
if (tagOptions.some(tag => tag.value === tagName.trim()) && !tagMeta) { if (
tagOptions.some(
tag => tag.title === tagName.trim() && tag.id !== tagMeta?.id
)
) {
return toast(t['com.affine.tags.create-tag.toast.exist']()); return toast(t['com.affine.tags.create-tag.toast.exist']());
} }
if (!tagMeta) { if (!tagMeta) {
const newTag = { tagService.createTag(tagName.trim(), tagIcon);
id: nanoid(),
value: tagName.trim(),
color: activeTagIcon[1] || tagColors[0][1],
};
legacyProperties.updateTagOptions([...tagOptions, newTag]);
toast(t['com.affine.tags.create-tag.toast.success']()); toast(t['com.affine.tags.create-tag.toast.success']());
onClose(); onClose();
return; return;
} }
tag?.rename(tagName.trim());
tag?.changeColor(tagIcon);
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']()); toast(t['com.affine.tags.edit-tag.toast.success']());
onClose(); onClose();
return; return;
}, [ }, [onClose, t, tag, tagIcon, tagMeta, tagName, tagOptions, tagService]);
activeTagIcon,
legacyProperties,
onClose,
t,
tagMeta,
tagName,
tagOptions,
]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -137,6 +127,11 @@ export const CreateOrEditTag = ({
}; };
}, [open, onOpenChange, menuOpen, onClose]); }, [open, onOpenChange, menuOpen, onClose]);
useEffect(() => {
setTagName(tagMeta?.title);
setTagIcon(tagMeta?.color || randomTagColor());
}, [tagMeta?.color, tagMeta?.title]);
return ( return (
<div className={styles.createTagWrapper} data-show={open}> <div className={styles.createTagWrapper} data-show={open}>
<Menu <Menu
@@ -147,7 +142,7 @@ export const CreateOrEditTag = ({
items={items} items={items}
> >
<Button className={styles.menuBtn}> <Button className={styles.menuBtn}>
<TagIcon color={activeTagIcon[1] || ''} /> <TagIcon color={tagIcon} />
</Button> </Button>
</Menu> </Menu>
@@ -156,7 +151,7 @@ export const CreateOrEditTag = ({
inputStyle={{ fontSize: 'var(--affine-font-xs)' }} inputStyle={{ fontSize: 'var(--affine-font-xs)' }}
onEnter={onConfirm} onEnter={onConfirm}
value={tagName} value={tagName}
onChange={setTagName} onChange={handleChangeName}
/> />
<Button className={styles.cancelBtn} onClick={onClose}> <Button className={styles.cancelBtn} onClick={onClose}>
{t['Cancel']()} {t['Cancel']()}
@@ -5,7 +5,7 @@ import { type PropsWithChildren, useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { DraggableTitleCellData, TagListItemProps } from '../types'; import type { DraggableTitleCellData, TagListItemProps } from '../types';
import { ColWrapper, stopPropagation, tagColorMap } from '../utils'; import { ColWrapper, stopPropagation } from '../utils';
import * as styles from './tag-list-item.css'; import * as styles from './tag-list-item.css';
const TagListTitleCell = ({ const TagListTitleCell = ({
@@ -37,7 +37,7 @@ const ListIconCell = ({ color }: Pick<TagListItemProps, 'color'>) => {
<div <div
className={styles.tagIndicator} className={styles.tagIndicator}
style={{ style={{
backgroundColor: tagColorMap(color), backgroundColor: color,
}} }}
/> />
</div> </div>
@@ -1,7 +1,7 @@
import { toast } from '@affine/component'; import { toast } from '@affine/component';
import type { Tag } from '@affine/core/modules/tag';
import { Trans } from '@affine/i18n'; import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
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';
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
@@ -7,8 +7,8 @@ export type ListItem = DocMeta | CollectionMeta | TagMeta;
export interface CollectionMeta extends Collection { export interface CollectionMeta extends Collection {
title: string; title: string;
createDate?: Date; createDate?: Date | number;
updatedDate?: Date; updatedDate?: Date | number;
} }
export type TagMeta = { export type TagMeta = {
@@ -16,8 +16,8 @@ export type TagMeta = {
title: string; title: string;
color: string; color: string;
pageCount?: number; pageCount?: number;
createDate?: Date; createDate?: Date | number;
updatedDate?: Date; updatedDate?: Date | number;
}; };
// TODO: consider reducing the number of props here // TODO: consider reducing the number of props here
// using type instead of interface to make it Record compatible // using type instead of interface to make it Record compatible
@@ -59,8 +59,8 @@ export type TagListItemProps = {
color: string; color: string;
title: ReactNode; // using ReactNode to allow for rich content rendering title: ReactNode; // using ReactNode to allow for rich content rendering
pageCount?: number; pageCount?: number;
createDate?: Date; createDate?: Date | number;
updatedDate?: Date; updatedDate?: Date | number;
to?: To; // whether or not to render this item as a Link to?: To; // whether or not to render this item as a Link
draggable?: boolean; // whether or not to allow dragging this item draggable?: boolean; // whether or not to allow dragging this item
selectable?: boolean; // show selection checkbox selectable?: boolean; // show selection checkbox
@@ -1,71 +0,0 @@
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(pageMetas: DocMeta[]) {
const legacyProperties = useService(WorkspaceLegacyProperties);
const tags = useLiveData(legacyProperties.tagOptions$);
const [tagMetas, tagUsageCounts] = useMemo(() => {
const tagUsageCounts: TagUsageCounts = {};
tags.forEach(tag => {
tagUsageCounts[tag.id] = 0;
});
pageMetas.forEach(page => {
if (!page.tags) {
return;
}
page.tags.forEach(tagId => {
if (Object.prototype.hasOwnProperty.call(tagUsageCounts, tagId)) {
tagUsageCounts[tagId]++;
}
});
});
const tagsList = tags.map(tag => {
return {
...tag,
title: tag.value,
color: tag.color,
pageCount: tagUsageCounts[tag.id] || 0,
};
});
return [tagsList, tagUsageCounts];
}, [tags, pageMetas]);
const filterPageMetaByTag = useCallback(
(tagId: string) => {
return pageMetas.filter(page => {
if (!page.tags) {
return false;
}
return page.tags.includes(tagId);
});
},
[pageMetas]
);
const deleteTags = useCallback(
(tagIds: string[]) => {
tagIds.forEach(tagId => {
legacyProperties.removeTagOption(tagId);
});
},
[legacyProperties]
);
return {
tags,
tagMetas,
tagUsageCounts,
filterPageMetaByTag,
deleteTags,
};
}
@@ -161,20 +161,3 @@ export function shallowEqual(objA: any, objB: any) {
return true; return true;
} }
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
export const tagColorMap = (color: string) => {
const mapping: Record<string, string> = {
'var(--affine-tag-red)': 'var(--affine-palette-line-red)',
'var(--affine-tag-teal)': 'var(--affine-palette-line-green)',
'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)',
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)',
'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)',
'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)',
'var(--affine-tag-green)': 'var(--affine-palette-line-green)',
};
return mapping[color] || color;
};
@@ -1,6 +1,7 @@
import { import {
GlobalCache, GlobalCache,
GlobalState, GlobalState,
PageRecordList,
type ServiceCollection, type ServiceCollection,
Workspace, Workspace,
WorkspaceScope, WorkspaceScope,
@@ -13,6 +14,7 @@ import {
} from './infra-web/storage'; } from './infra-web/storage';
import { Navigator } from './navigation'; import { Navigator } from './navigation';
import { RightSidebar } from './right-sidebar/entities/right-sidebar'; import { RightSidebar } from './right-sidebar/entities/right-sidebar';
import { TagService } from './tag';
import { Workbench } from './workbench'; import { Workbench } from './workbench';
import { import {
CurrentWorkspaceService, CurrentWorkspaceService,
@@ -29,7 +31,8 @@ export function configureBusinessServices(services: ServiceCollection) {
.add(RightSidebar) .add(RightSidebar)
.add(WorkspacePropertiesAdapter, [Workspace]) .add(WorkspacePropertiesAdapter, [Workspace])
.add(CollectionService, [Workspace]) .add(CollectionService, [Workspace])
.add(WorkspaceLegacyProperties, [Workspace]); .add(WorkspaceLegacyProperties, [Workspace])
.add(TagService, [WorkspaceLegacyProperties, PageRecordList]);
} }
export function configureWebInfraServices(services: ServiceCollection) { export function configureWebInfraServices(services: ServiceCollection) {
@@ -0,0 +1,71 @@
import type { Tag as TagSchema } from '@affine/env/filter';
import { LiveData, type PageRecordList } from '@toeverything/infra';
import type { WorkspaceLegacyProperties } from '../../workspace';
export class Tag {
constructor(
readonly id: string,
private readonly properties: WorkspaceLegacyProperties,
private readonly pageRecordList: PageRecordList
) {}
private readonly tagOption = this.properties.tagOptions$.map(
tags => tags.find(tag => tag.id === this.id) as TagSchema
);
value = this.tagOption.map(tag => tag?.value || '');
color = this.tagOption.map(tag => tag?.color || '');
createDate = this.tagOption.map(tag => tag?.createDate || Date.now());
updateDate = this.tagOption.map(tag => tag?.updateDate || Date.now());
rename(value: string) {
this.properties.updateTagOption(this.id, {
id: this.id,
value,
color: this.color.value,
createDate: this.createDate.value,
updateDate: Date.now(),
});
}
changeColor(color: string) {
this.properties.updateTagOption(this.id, {
id: this.id,
value: this.value.value,
color,
createDate: this.createDate.value,
updateDate: Date.now(),
});
}
tag(pageId: string) {
const pageRecord = this.pageRecordList.record(pageId).value;
if (!pageRecord) {
return;
}
pageRecord?.setMeta({
tags: [...pageRecord.meta.value.tags, this.id],
});
}
untag(pageId: string) {
const pageRecord = this.pageRecordList.record(pageId).value;
if (!pageRecord) {
return;
}
pageRecord?.setMeta({
tags: pageRecord.meta.value.tags.filter(tagId => tagId !== this.id),
});
}
readonly pageIds = LiveData.computed(get => {
const pages = get(this.pageRecordList.records);
return pages
.filter(page => get(page.meta).tags.includes(this.id))
.map(page => page.id);
});
}
@@ -0,0 +1,16 @@
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
export const tagColorMap = (color: string) => {
const mapping: Record<string, string> = {
'var(--affine-tag-red)': 'var(--affine-palette-line-red)',
'var(--affine-tag-teal)': 'var(--affine-palette-line-green)',
'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)',
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)',
'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)',
'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)',
'var(--affine-tag-green)': 'var(--affine-palette-line-green)',
};
return mapping[color] || color;
};
@@ -0,0 +1,3 @@
export { Tag } from './entities/tag';
export { tagColorMap } from './entities/utils';
export { TagService } from './service/tag';
@@ -0,0 +1,85 @@
import { LiveData, type PageRecordList } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import type { WorkspaceLegacyProperties } from '../../workspace';
import { Tag } from '../entities/tag';
export class TagService {
constructor(
private readonly properties: WorkspaceLegacyProperties,
private readonly pageRecordList: PageRecordList
) {}
readonly tags = this.properties.tagOptions$.map(tags =>
tags.map(tag => new Tag(tag.id, this.properties, this.pageRecordList))
);
createTag(value: string, color: string) {
const newId = nanoid();
this.properties.updateTagOptions([
...this.properties.tagOptions$.value,
{
id: newId,
value,
color,
createDate: Date.now(),
updateDate: Date.now(),
},
]);
const newTag = new Tag(newId, this.properties, this.pageRecordList);
return newTag;
}
deleteTag(tagId: string) {
this.properties.removeTagOption(tagId);
}
tagsByPageId(pageId: string) {
return LiveData.computed(get => {
const pageRecord = get(this.pageRecordList.record(pageId));
if (!pageRecord) return [];
const tagIds = get(pageRecord.meta).tags;
return get(this.tags).filter(tag => tagIds.includes(tag.id));
});
}
tagIdsByPageId(pageId: string) {
return this.tagsByPageId(pageId).map(tags => tags.map(tag => tag.id));
}
tagByTagId(tagId?: string) {
return this.tags.map(tags => tags.find(tag => tag.id === tagId));
}
tagMetas = LiveData.computed(get => {
return get(this.tags).map(tag => {
return {
id: tag.id,
title: get(tag.value),
color: get(tag.color),
pageCount: get(tag.pageIds).length,
createDate: get(tag.createDate),
updatedDate: get(tag.updateDate),
};
});
});
private filterFn(value: string, query?: string) {
const trimmedQuery = query?.trim().toLowerCase() ?? '';
const trimmedValue = value.trim().toLowerCase();
return trimmedValue.includes(trimmedQuery);
}
filterTagsByName(name: string) {
return LiveData.computed(get => {
return get(this.tags).filter(tag => this.filterFn(get(tag.value), name));
});
}
tagByTagValue(value: string) {
return LiveData.computed(get => {
return get(this.tags).find(tag => this.filterFn(get(tag.value), value));
});
}
}
@@ -1,4 +1,5 @@
import type { DocsPropertiesMeta, Tag } from '@blocksuite/store'; import type { Tag } from '@affine/env/filter';
import type { DocsPropertiesMeta } from '@blocksuite/store';
import { LiveData } from '@toeverything/infra/livedata'; import { LiveData } from '@toeverything/infra/livedata';
import type { Workspace } from '@toeverything/infra/workspace'; import type { Workspace } from '@toeverything/infra/workspace';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@@ -1,12 +1,11 @@
import { useTagMetas } from '@affine/core/components/page-list';
import { 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 { CreateOrEditTag } from '@affine/core/components/page-list/tags/create-tag';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import type { TagMeta } from '@affine/core/components/page-list/types';
import { useService } from '@toeverything/infra'; import { TagService } from '@affine/core/modules/tag';
import { Workspace } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench'; import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench';
@@ -32,10 +31,19 @@ const EmptyTagListHeader = () => {
}; };
export const AllTag = () => { export const AllTag = () => {
const currentWorkspace = useService(Workspace); const tagService = useService(TagService);
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection); const tags = useLiveData(tagService.tags);
const { tags, tagMetas, deleteTags } = useTagMetas(pageMetas); const tagMetas: TagMeta[] = useLiveData(tagService.tagMetas);
const handleDelete = useCallback(
(tagIds: string[]) => {
tagIds.forEach(tagId => {
tagService.deleteTag(tagId);
});
},
[tagService]
);
return ( return (
<> <>
@@ -48,7 +56,7 @@ export const AllTag = () => {
<VirtualizedTagList <VirtualizedTagList
tags={tags} tags={tags}
tagMetas={tagMetas} tagMetas={tagMetas}
onTagDelete={deleteTags} onTagDelete={handleDelete}
/> />
) : ( ) : (
<EmptyTagList heading={<EmptyTagListHeader />} /> <EmptyTagList heading={<EmptyTagListHeader />} />
@@ -1,14 +1,14 @@
import { import {
TagPageListHeader, TagPageListHeader,
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 { TagService } from '@affine/core/modules/tag';
import { import {
ViewBodyIsland, ViewBodyIsland,
ViewHeaderIsland, ViewHeaderIsland,
} from '@affine/core/modules/workbench'; } from '@affine/core/modules/workbench';
import { useService } from '@toeverything/infra'; import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { Workspace } from '@toeverything/infra'; import { Workspace } from '@toeverything/infra';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@@ -22,18 +22,27 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
const currentWorkspace = useService(Workspace); const currentWorkspace = useService(Workspace);
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection); const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
const { tags, filterPageMetaByTag } = useTagMetas(pageMetas); const tagService = useService(TagService);
const tagPageMetas = useMemo(() => { const currentTagLiveData = tagService.tagByTagId(tagId);
if (tagId) { const currentTag = useLiveData(currentTagLiveData);
return filterPageMetaByTag(tagId);
}
return [];
}, [filterPageMetaByTag, tagId]);
const currentTag = useMemo( const pageIdsLiveData = useMemo(
() => tags.find(tag => tag.id === tagId), () =>
[tagId, tags] LiveData.computed(get => {
const liveTag = get(currentTagLiveData);
if (liveTag?.pageIds) {
return get(liveTag.pageIds);
}
return [];
}),
[currentTagLiveData]
); );
const pageIds = useLiveData(pageIdsLiveData);
const filteredPageMetas = useMemo(() => {
const pageIdsSet = new Set(pageIds);
return pageMetas.filter(page => pageIdsSet.has(page.id));
}, [pageIds, pageMetas]);
if (!currentTag) { if (!currentTag) {
return <PageNotFound />; return <PageNotFound />;
@@ -46,8 +55,11 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
</ViewHeaderIsland> </ViewHeaderIsland>
<ViewBodyIsland> <ViewBodyIsland>
<div className={styles.body}> <div className={styles.body}>
{tagPageMetas.length > 0 ? ( {filteredPageMetas.length > 0 ? (
<VirtualizedPageList tag={currentTag} listItem={tagPageMetas} /> <VirtualizedPageList
tag={currentTag}
listItem={filteredPageMetas}
/>
) : ( ) : (
<EmptyPageList <EmptyPageList
type="all" type="all"
@@ -201,7 +201,8 @@ export const ListItemTags: StoryFn<PageTagsProps> = props => (
); );
ListItemTags.args = { ListItemTags.args = {
tags: testTags, // FIXME: this is a hack to make the storybook work
// tags: testTags,
hoverExpandDirection: 'left', hoverExpandDirection: 'left',
widthOnHover: 600, widthOnHover: 600,
maxItems: 5, maxItems: 5,