mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-18 14:56:59 +08:00
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:
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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']()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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) {
|
||||
|
||||
71
packages/frontend/core/src/modules/tag/entities/tag.ts
Normal file
71
packages/frontend/core/src/modules/tag/entities/tag.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
16
packages/frontend/core/src/modules/tag/entities/utils.ts
Normal file
16
packages/frontend/core/src/modules/tag/entities/utils.ts
Normal 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;
|
||||
};
|
||||
3
packages/frontend/core/src/modules/tag/index.ts
Normal file
3
packages/frontend/core/src/modules/tag/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Tag } from './entities/tag';
|
||||
export { tagColorMap } from './entities/utils';
|
||||
export { TagService } from './service/tag';
|
||||
85
packages/frontend/core/src/modules/tag/service/tag.ts
Normal file
85
packages/frontend/core/src/modules/tag/service/tag.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user