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

View File

@@ -7,7 +7,7 @@ import type { PagePropertiesManager } from './page-properties-manager';
export const managerContext = createContext<PagePropertiesManager>();
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[] = [
'Red',

View File

@@ -1,6 +1,5 @@
import { Checkbox, DatePicker, Menu } from '@affine/component';
import { useAllBlockSuiteDocMeta } from '@affine/core/hooks/use-all-block-suite-page-meta';
import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace';
import type {
PageInfoCustomProperty,
PageInfoCustomPropertyMeta,
@@ -9,7 +8,7 @@ import type {
import { timestampToLocalDate } from '@affine/core/utils';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
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 {
type ChangeEventHandler,
@@ -179,29 +178,17 @@ export const TagsValue = () => {
const page = useService(Doc);
const docCollection = workspace.docCollection;
const pageMetas = useAllBlockSuiteDocMeta(docCollection);
const legacyProperties = useService(WorkspaceLegacyProperties);
const options = useLiveData(legacyProperties.tagOptions$);
const pageMeta = pageMetas.find(x => x.id === page.id);
assertExists(pageMeta, 'pageMeta should exist');
const tagIds = pageMeta.tags;
const t = useAFFiNEI18N();
const onChange = useCallback(
(tags: string[]) => {
legacyProperties.updatePageTags(page.id, tags);
},
[legacyProperties, page.id]
);
return (
<TagsInlineEditor
className={styles.propertyRowValueCell}
placeholder={t['com.affine.page-properties.property-value-placeholder']()}
value={tagIds}
options={options}
pageId={page.id}
readonly={page.blockSuiteDoc.readonly}
onChange={onChange}
onOptionsChange={legacyProperties.updateTagOptions}
/>
);
};

View File

@@ -6,13 +6,13 @@ import {
Scrollable,
} from '@affine/component';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { TagService } from '@affine/core/modules/tag';
import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
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 clsx from 'clsx';
import { nanoid } from 'nanoid';
import {
type HTMLAttributes,
type PropsWithChildren,
@@ -22,16 +22,13 @@ import {
useState,
} from 'react';
import { TagItem } from '../../page-list';
import { TagItem, TempTagItem } from '../../page-list';
import { tagColors } from './common';
import { type MenuItemOption, renderMenuItemOptions } from './menu-items';
import * as styles from './tags-inline-editor.css';
interface TagsEditorProps {
value: string[]; // selected tag ids
onChange?: (value: string[]) => void;
options: Tag[];
onOptionsChange?: (options: Tag[]) => void; // adding/updating/removing tags
pageId: string;
readonly?: boolean;
}
@@ -40,25 +37,26 @@ interface InlineTagsListProps
Omit<TagsEditorProps, 'onOptionsChange'> {}
const InlineTagsList = ({
value,
onChange,
options,
pageId,
readonly,
children,
}: PropsWithChildren<InlineTagsListProps>) => {
const tagService = useService(TagService);
const tags = useLiveData(tagService.tags);
const tagIds = useLiveData(tagService.tagIdsByPageId(pageId));
return (
<div className={styles.inlineTagsContainer} data-testid="inline-tags-list">
{value.map((tagId, idx) => {
const tag = options.find(t => t.id === tagId);
{tagIds.map((tagId, idx) => {
const tag = tags.find(t => t.id === tagId);
if (!tag) {
return null;
}
const onRemoved =
readonly || !onChange
? undefined
: () => {
onChange(value.filter(v => v !== tagId));
};
const onRemoved = readonly
? undefined
: () => {
tag.untag(pageId);
};
return (
<TagItem
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 = ({
tag,
tagId,
children,
}: PropsWithChildren<{ tag: Tag }>) => {
}: PropsWithChildren<{ tagId: string }>) => {
const t = useAFFiNEI18N();
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 menuProps = useMemo(() => {
@@ -94,14 +90,11 @@ export const EditTagMenu = ({
if (name.trim() === '') {
return;
}
legacyProperties.updateTagOption(tag.id, {
...tag,
value: name,
});
tag?.rename(name);
};
options.push(
<Input
defaultValue={tag.value}
defaultValue={tagValue}
onBlur={e => {
updateTagName(e.currentTarget.value);
}}
@@ -123,7 +116,7 @@ export const EditTagMenu = ({
icon: <DeleteIcon />,
type: 'danger',
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'](),
icon: <TagsIcon />,
onClick() {
navigate.jumpToTag(legacyProperties.workspaceId, tag.id);
navigate.jumpToTag(legacyProperties.workspaceId, tag?.id || '');
},
});
@@ -151,12 +144,9 @@ export const EditTagMenu = ({
/>
</div>
),
checked: tag.color === color,
checked: tagColor === color,
onClick() {
legacyProperties.updateTagOption(tag.id, {
...tag,
color,
});
tag?.changeColor(color);
},
};
})
@@ -171,26 +161,35 @@ export const EditTagMenu = ({
},
items,
} satisfies Partial<MenuProps>;
}, [legacyProperties, navigate, t, tag]);
}, [
legacyProperties.workspaceId,
navigate,
t,
tag,
tagColor,
tagService,
tagValue,
]);
return <Menu {...menuProps}>{children}</Menu>;
};
export const TagsEditor = ({
options,
value,
onChange,
onOptionsChange,
readonly,
}: TagsEditorProps) => {
export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
const t = useAFFiNEI18N();
const tagService = useService(TagService);
const tags = useLiveData(tagService.tags);
const tagIds = useLiveData(tagService.tagIdsByPageId(pageId));
const [inputValue, setInputValue] = useState('');
const exactMatch = options.find(o => o.value === inputValue);
const filteredOptions = useMemo(
() =>
options.filter(o => (inputValue ? filterOption(o, inputValue) : true)),
[inputValue, options]
);
const exactMatch = useLiveData(tagService.tagByTagValue(inputValue));
const filteredLiveData = useMemo(() => {
if (inputValue) {
return tagService.filterTagsByName(inputValue);
}
return tagService.tags;
}, [inputValue, tagService]);
const filteredTags = useLiveData(filteredLiveData);
const onInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -201,11 +200,11 @@ export const TagsEditor = ({
const onAddTag = useCallback(
(id: string) => {
if (!value.includes(id)) {
onChange?.([...value, id]);
if (!tagIds.includes(id)) {
tags.find(o => o.id === id)?.tag(pageId);
}
},
[onChange, value]
[pageId, tagIds, tags]
);
const [nextColor, rotateNextColor] = useReducer(
@@ -221,17 +220,11 @@ export const TagsEditor = ({
if (!name.trim()) {
return;
}
const newTag = {
id: nanoid(),
value: name.trim(),
color: nextColor,
};
rotateNextColor();
onOptionsChange?.([...options, newTag]);
onChange?.([...value, newTag.id]);
const newTag = tagService.createTag(name.trim(), nextColor);
newTag.tag(pageId);
},
[nextColor, onChange, onOptionsChange, options, value]
[nextColor, pageId, tagService]
);
const onInputKeyDown = useCallback(
@@ -243,22 +236,18 @@ export const TagsEditor = ({
onCreateTag(inputValue);
}
setInputValue('');
} else if (e.key === 'Backspace' && inputValue === '' && value.length) {
onChange?.(value.slice(0, value.length - 1));
} else if (e.key === 'Backspace' && inputValue === '' && tagIds.length) {
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 (
<div data-testid="tags-editor-popup" className={styles.tagsEditorRoot}>
<div className={styles.tagsEditorSelectedTags}>
<InlineTagsList
options={options}
value={value}
onChange={onChange}
readonly={readonly}
>
<InlineTagsList pageId={pageId} readonly={readonly}>
<input
value={inputValue}
onChange={onInputChange}
@@ -277,7 +266,7 @@ export const TagsEditor = ({
<Scrollable.Viewport
className={styles.tagSelectorTagsScrollContainer}
>
{filteredOptions.map(tag => {
{filteredTags.map(tag => {
return (
<div
key={tag.id}
@@ -291,7 +280,7 @@ export const TagsEditor = ({
>
<TagItem maxWidth="100%" tag={tag} mode="inline" />
<div className={styles.spacer} />
<EditTagMenu tag={tag}>
<EditTagMenu tagId={tag.id}>
<IconButton
className={styles.tagEditIcon}
type="plain"
@@ -311,15 +300,7 @@ export const TagsEditor = ({
}}
>
{t['Create']()}{' '}
<TagItem
maxWidth="100%"
tag={{
id: inputValue,
value: inputValue,
color: nextColor,
}}
mode="inline"
/>
<TempTagItem value={inputValue} color={nextColor} />
</div>
)}
</Scrollable.Viewport>
@@ -337,15 +318,14 @@ interface TagsInlineEditorProps extends TagsEditorProps {
// this tags value renderer right now only renders the legacy tags for now
export const TagsInlineEditor = ({
value,
onChange,
options,
onOptionsChange,
pageId,
readonly,
placeholder,
className,
}: TagsInlineEditorProps) => {
const empty = !value || value.length === 0;
const tagService = useService(TagService);
const tagIds = useLiveData(tagService.tagIdsByPageId(pageId));
const empty = !tagIds || tagIds.length === 0;
return (
<Menu
contentOptions={{
@@ -358,31 +338,14 @@ export const TagsInlineEditor = ({
e.stopPropagation();
},
}}
items={
<TagsEditor
value={value}
options={options}
onChange={onChange}
onOptionsChange={onOptionsChange}
readonly={readonly}
/>
}
items={<TagsEditor pageId={pageId} readonly={readonly} />}
>
<div
className={clsx(styles.tagsInlineEditor, className)}
data-empty={empty}
data-readonly={readonly}
>
{empty ? (
placeholder
) : (
<InlineTagsList
value={value}
onChange={onChange}
options={options}
readonly
/>
)}
{empty ? placeholder : <InlineTagsList pageId={pageId} readonly />}
</div>
</Menu>
);

View File

@@ -1,8 +1,8 @@
import { Button, Divider, Menu, Scrollable } from '@affine/component';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace';
import type { Collection, Tag } from '@affine/env/filter';
import { type Tag, TagService } from '@affine/core/modules/tag';
import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
ArrowDownSmallIcon,
@@ -18,7 +18,6 @@ import { Link } from 'react-router-dom';
import { CollectionService } from '../../../modules/collection';
import { createTagFilter } from '../filter/utils';
import { createEmptyCollection } from '../use-collection-manager';
import { tagColorMap } from '../utils';
import type { AllPageListConfig } from '../view/edit-collection/edit-collection';
import {
useEditCollection,
@@ -95,8 +94,9 @@ export const TagPageListHeader = ({
tag: Tag;
workspaceId: string;
}) => {
const legacyProperties = useService(WorkspaceLegacyProperties);
const options = useLiveData(legacyProperties.tagOptions$);
const tagColor = useLiveData(tag.color);
const tagTitle = useLiveData(tag.value);
const t = useAFFiNEI18N();
const { jumpToTags, jumpToCollection } = useNavigateHelper();
const collectionService = useService(CollectionService);
@@ -153,16 +153,16 @@ export const TagPageListHeader = ({
avoidCollisions: false,
className: styles.tagsMenu,
}}
items={<TagsEditor options={options} onClick={setOpenMenu} />}
items={<SwitchTag onClick={setOpenMenu} />}
>
<div className={styles.tagSticky}>
<div
className={styles.tagIndicator}
style={{
backgroundColor: tagColorMap(tag.color),
backgroundColor: tagColor,
}}
/>
<div className={styles.tagLabel}>{tag.value}</div>
<div className={styles.tagLabel}>{tagTitle}</div>
<ArrowDownSmallIcon className={styles.arrowDownSmallIcon} />
</div>
</Menu>
@@ -175,25 +175,21 @@ 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[];
interface SwitchTagProps {
onClick: (open: boolean) => void;
}
export const TagsEditor = ({ options, onClick }: TagsEditorProps) => {
export const SwitchTag = ({ onClick }: SwitchTagProps) => {
const t = useAFFiNEI18N();
const [inputValue, setInputValue] = useState('');
const filteredOptions = useMemo(
() =>
options.filter(o => (inputValue ? filterOption(o, inputValue) : true)),
[inputValue, options]
);
const tagService = useService(TagService);
const filteredLiveData = useMemo(() => {
if (inputValue) {
return tagService.filterTagsByName(inputValue);
}
return tagService.tags;
}, [inputValue, tagService]);
const filteredTags = useLiveData(filteredLiveData);
const onInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -225,25 +221,10 @@ export const TagsEditor = ({ options, onClick }: TagsEditorProps) => {
<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>
);
{filteredTags.map(tag => {
return <TagLink key={tag.id} tag={tag} onClick={handleClick} />;
})}
{filteredOptions.length === 0 ? (
{filteredTags.length === 0 ? (
<div className={clsx(styles.tagSelectorItem, 'disable')}>
{t['Find 0 result']()}
</div>
@@ -255,3 +236,21 @@ export const TagsEditor = ({ options, onClick }: TagsEditorProps) => {
</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>
);
};

View File

@@ -1,5 +1,7 @@
import { Checkbox } from '@affine/component';
import { TagService } from '@affine/core/modules/tag';
import { useDraggable } from '@dnd-kit/core';
import { useLiveData, useService } from '@toeverything/infra';
import { type PropsWithChildren, useCallback, useMemo } from 'react';
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 (
<div data-testid="page-list-item-tags" className={styles.tagsCell}>
<PageTags
@@ -177,7 +183,7 @@ export const PageListItem = (props: PageListItemProps) => {
<ListTitleCell title={props.title} preview={props.preview} />
</ColWrapper>
<ColWrapper flex={4} alignment="end" style={{ overflow: 'visible' }}>
<PageTagsCell tags={props.tags} />
<PageTagsCell pageId={props.pageId} />
</ColWrapper>
</ColWrapper>
<ColWrapper flex={1} alignment="end" hideInSmallContainer>

View File

@@ -1,11 +1,12 @@
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 { LiveData, useLiveData } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { type MouseEventHandler, useCallback, useMemo } from 'react';
import { stopPropagation, tagColorMap } from '../utils';
import { stopPropagation } from '../utils';
import * as styles from './page-tags.css';
export interface PageTagsProps {
@@ -16,7 +17,7 @@ export interface PageTagsProps {
}
interface TagItemProps {
tag: Tag;
tag?: Tag;
idx?: number;
maxWidth?: number | string;
mode: 'inline' | 'list-item';
@@ -24,6 +25,30 @@ interface TagItemProps {
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 = ({
tag,
idx,
@@ -32,6 +57,8 @@ export const TagItem = ({
style,
maxWidth,
}: TagItemProps) => {
const value = useLiveData(tag?.value);
const color = useLiveData(tag?.color);
const handleRemove: MouseEventHandler = useCallback(
e => {
e.stopPropagation();
@@ -44,9 +71,9 @@ export const TagItem = ({
data-testid="page-tag"
className={styles.tag}
data-idx={idx}
data-tag-id={tag.id}
data-tag-value={tag.value}
title={tag.value}
data-tag-id={tag?.id}
data-tag-value={value}
title={value}
style={style}
>
<div
@@ -56,10 +83,10 @@ export const TagItem = ({
<div
className={styles.tagIndicator}
style={{
backgroundColor: tagColorMap(tag.color),
backgroundColor: color,
}}
/>
<div className={styles.tagLabel}>{tag.value}</div>
<div className={styles.tagLabel}>{value}</div>
{onRemoved ? (
<div
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 = ({
tags,
widthOnHover,
@@ -97,16 +152,6 @@ export const PageTags = ({
);
}, [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 (
<div
data-testid="page-tags"
@@ -123,7 +168,9 @@ export const PageTags = ({
className={clsx(styles.innerContainer)}
>
<div className={styles.innerBackdrop} />
<div className={styles.tagsScrollContainer}>{tagsNormal}</div>
<div className={styles.tagsScrollContainer}>
<TagItemNormal tags={tags} maxItems={maxItems} />
</div>
{maxItems && tags.length > maxItems ? (
<Menu
items={tagsInPopover}

View File

@@ -2,11 +2,12 @@ import { toast } from '@affine/component';
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
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 type { Collection, Filter } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
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 { Workspace } from '@toeverything/infra';
import { useCallback, useMemo, useRef, useState } from 'react';

View File

@@ -16,7 +16,6 @@ export * from './tags';
export * from './types';
export * from './use-collection-manager';
export * from './use-filtered-page-metas';
export * from './use-tag-metas';
export * from './utils';
export * from './view';
export * from './virtualized-list';

View File

@@ -1,9 +1,8 @@
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 { 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';
@@ -21,7 +20,7 @@ const TagIcon = ({ color, large }: { color: string; large?: boolean }) => (
const randomTagColor = () => {
const randomIndex = Math.floor(Math.random() * tagColors.length);
return tagColors[randomIndex];
return tagColors[randomIndex][1];
};
export const CreateOrEditTag = ({
@@ -33,39 +32,44 @@ export const CreateOrEditTag = ({
onOpenChange: (open: boolean) => void;
tagMeta?: TagMeta;
}) => {
const legacyProperties = useService(WorkspaceLegacyProperties);
const tagOptions = useLiveData(legacyProperties.tagOptions$);
const tagService = useService(TagService);
const tagOptions = useLiveData(tagService.tagMetas);
const tag = useLiveData(tagService.tagByTagId(tagMeta?.id));
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 [tagName, setTagName] = useState(tagMeta?.title);
const handleChangeName = useCallback((value: string) => {
setTagName(value);
}, []);
const [tagIcon, setTagIcon] = useState(tagMeta?.color || randomTagColor());
const handleChangeIcon = useCallback((value: string) => {
setTagIcon(value);
}, []);
const tags = useMemo(() => {
return tagColors.map(([name, color]) => {
return tagColors.map(([_, color]) => {
return {
name: name,
color: color,
onClick: () => {
setActiveTagIcon([name, color]);
handleChangeIcon(color);
setMenuOpen(false);
},
};
});
}, []);
}, [handleChangeIcon]);
const items = useMemo(() => {
const tagItems = tags.map(item => {
return (
<div
key={item.name}
key={item.color}
onClick={item.onClick}
className={clsx(styles.tagItem, {
['active']: item.name === activeTagIcon[0],
['active']: item.color === tagIcon,
})}
>
<TagIcon color={item.color} large={true} />
@@ -73,52 +77,38 @@ export const CreateOrEditTag = ({
);
});
return <div className={styles.tagItemsWrapper}>{tagItems}</div>;
}, [activeTagIcon, tags]);
}, [tagIcon, tags]);
const onClose = useCallback(() => {
if (!tagMeta) {
setActiveTagIcon(randomTagColor);
handleChangeIcon(randomTagColor());
setTagName('');
}
onOpenChange(false);
}, [onOpenChange, tagMeta]);
}, [handleChangeIcon, onOpenChange, tagMeta]);
const onConfirm = useCallback(() => {
if (!tagName.trim()) return;
if (tagOptions.some(tag => tag.value === tagName.trim()) && !tagMeta) {
if (!tagName?.trim()) return;
if (
tagOptions.some(
tag => tag.title === tagName.trim() && tag.id !== tagMeta?.id
)
) {
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]);
tagService.createTag(tagName.trim(), tagIcon);
toast(t['com.affine.tags.create-tag.toast.success']());
onClose();
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']());
onClose();
return;
}, [
activeTagIcon,
legacyProperties,
onClose,
t,
tagMeta,
tagName,
tagOptions,
]);
}, [onClose, t, tag, tagIcon, tagMeta, tagName, tagOptions, tagService]);
useEffect(() => {
if (!open) return;
@@ -137,6 +127,11 @@ export const CreateOrEditTag = ({
};
}, [open, onOpenChange, menuOpen, onClose]);
useEffect(() => {
setTagName(tagMeta?.title);
setTagIcon(tagMeta?.color || randomTagColor());
}, [tagMeta?.color, tagMeta?.title]);
return (
<div className={styles.createTagWrapper} data-show={open}>
<Menu
@@ -147,7 +142,7 @@ export const CreateOrEditTag = ({
items={items}
>
<Button className={styles.menuBtn}>
<TagIcon color={activeTagIcon[1] || ''} />
<TagIcon color={tagIcon} />
</Button>
</Menu>
@@ -156,7 +151,7 @@ export const CreateOrEditTag = ({
inputStyle={{ fontSize: 'var(--affine-font-xs)' }}
onEnter={onConfirm}
value={tagName}
onChange={setTagName}
onChange={handleChangeName}
/>
<Button className={styles.cancelBtn} onClick={onClose}>
{t['Cancel']()}

View File

@@ -5,7 +5,7 @@ import { type PropsWithChildren, useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
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';
const TagListTitleCell = ({
@@ -37,7 +37,7 @@ const ListIconCell = ({ color }: Pick<TagListItemProps, 'color'>) => {
<div
className={styles.tagIndicator}
style={{
backgroundColor: tagColorMap(color),
backgroundColor: color,
}}
/>
</div>

View File

@@ -1,7 +1,7 @@
import { toast } from '@affine/component';
import type { Tag } from '@affine/core/modules/tag';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { Tag } from '@blocksuite/store';
import { useService } from '@toeverything/infra';
import { Workspace } from '@toeverything/infra';
import { useCallback, useMemo, useRef, useState } from 'react';

View File

@@ -7,8 +7,8 @@ export type ListItem = DocMeta | CollectionMeta | TagMeta;
export interface CollectionMeta extends Collection {
title: string;
createDate?: Date;
updatedDate?: Date;
createDate?: Date | number;
updatedDate?: Date | number;
}
export type TagMeta = {
@@ -16,8 +16,8 @@ export type TagMeta = {
title: string;
color: string;
pageCount?: number;
createDate?: Date;
updatedDate?: Date;
createDate?: Date | number;
updatedDate?: Date | number;
};
// TODO: consider reducing the number of props here
// using type instead of interface to make it Record compatible
@@ -59,8 +59,8 @@ export type TagListItemProps = {
color: string;
title: ReactNode; // using ReactNode to allow for rich content rendering
pageCount?: number;
createDate?: Date;
updatedDate?: Date;
createDate?: Date | number;
updatedDate?: Date | number;
to?: To; // whether or not to render this item as a Link
draggable?: boolean; // whether or not to allow dragging this item
selectable?: boolean; // show selection checkbox

View File

@@ -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,
};
}

View File

@@ -161,20 +161,3 @@ export function shallowEqual(objA: any, objB: any) {
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;
};

View File

@@ -1,6 +1,7 @@
import {
GlobalCache,
GlobalState,
PageRecordList,
type ServiceCollection,
Workspace,
WorkspaceScope,
@@ -13,6 +14,7 @@ import {
} from './infra-web/storage';
import { Navigator } from './navigation';
import { RightSidebar } from './right-sidebar/entities/right-sidebar';
import { TagService } from './tag';
import { Workbench } from './workbench';
import {
CurrentWorkspaceService,
@@ -29,7 +31,8 @@ export function configureBusinessServices(services: ServiceCollection) {
.add(RightSidebar)
.add(WorkspacePropertiesAdapter, [Workspace])
.add(CollectionService, [Workspace])
.add(WorkspaceLegacyProperties, [Workspace]);
.add(WorkspaceLegacyProperties, [Workspace])
.add(TagService, [WorkspaceLegacyProperties, PageRecordList]);
}
export function configureWebInfraServices(services: ServiceCollection) {

View File

@@ -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);
});
}

View File

@@ -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;
};

View File

@@ -0,0 +1,3 @@
export { Tag } from './entities/tag';
export { tagColorMap } from './entities/utils';
export { TagService } from './service/tag';

View File

@@ -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));
});
}
}

View File

@@ -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 type { Workspace } from '@toeverything/infra/workspace';
import { Observable } from 'rxjs';

View File

@@ -1,12 +1,11 @@
import { useTagMetas } from '@affine/core/components/page-list';
import {
TagListHeader,
VirtualizedTagList,
} from '@affine/core/components/page-list/tags';
import { CreateOrEditTag } from '@affine/core/components/page-list/tags/create-tag';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { useService } from '@toeverything/infra';
import { Workspace } from '@toeverything/infra';
import type { TagMeta } from '@affine/core/components/page-list/types';
import { TagService } from '@affine/core/modules/tag';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench';
@@ -32,10 +31,19 @@ const EmptyTagListHeader = () => {
};
export const AllTag = () => {
const currentWorkspace = useService(Workspace);
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
const tagService = useService(TagService);
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 (
<>
@@ -48,7 +56,7 @@ export const AllTag = () => {
<VirtualizedTagList
tags={tags}
tagMetas={tagMetas}
onTagDelete={deleteTags}
onTagDelete={handleDelete}
/>
) : (
<EmptyTagList heading={<EmptyTagListHeader />} />

View File

@@ -1,14 +1,14 @@
import {
TagPageListHeader,
useTagMetas,
VirtualizedPageList,
} from '@affine/core/components/page-list';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { TagService } from '@affine/core/modules/tag';
import {
ViewBodyIsland,
ViewHeaderIsland,
} from '@affine/core/modules/workbench';
import { useService } from '@toeverything/infra';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { Workspace } from '@toeverything/infra';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
@@ -22,18 +22,27 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
const currentWorkspace = useService(Workspace);
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
const { tags, filterPageMetaByTag } = useTagMetas(pageMetas);
const tagPageMetas = useMemo(() => {
if (tagId) {
return filterPageMetaByTag(tagId);
}
return [];
}, [filterPageMetaByTag, tagId]);
const tagService = useService(TagService);
const currentTagLiveData = tagService.tagByTagId(tagId);
const currentTag = useLiveData(currentTagLiveData);
const currentTag = useMemo(
() => tags.find(tag => tag.id === tagId),
[tagId, tags]
const pageIdsLiveData = useMemo(
() =>
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) {
return <PageNotFound />;
@@ -46,8 +55,11 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
</ViewHeaderIsland>
<ViewBodyIsland>
<div className={styles.body}>
{tagPageMetas.length > 0 ? (
<VirtualizedPageList tag={currentTag} listItem={tagPageMetas} />
{filteredPageMetas.length > 0 ? (
<VirtualizedPageList
tag={currentTag}
listItem={filteredPageMetas}
/>
) : (
<EmptyPageList
type="all"