From 2953a152ed422a993d66b34ebb8d0e4e0cdbee4e Mon Sep 17 00:00:00 2001 From: Cats Juice Date: Wed, 24 Jul 2024 17:49:32 +0800 Subject: [PATCH] feat(core): add doc/collection/tag select hook close AF-1110 --- .../components/page-list/collections/index.ts | 1 + .../collections/select-collection.tsx | 107 +++++++++++ .../components/page-list/selector/index.ts | 31 ++++ .../page-list/selector/selector-layout.css.ts | 72 ++++++++ .../page-list/selector/selector-layout.tsx | 88 +++++++++ .../page-list/selector/use-select-dialog.tsx | 76 ++++++++ .../src/components/page-list/tags/index.ts | 1 + .../components/page-list/tags/select-tag.tsx | 97 ++++++++++ .../edit-collection/edit-collection.css.ts | 29 +-- .../view/edit-collection/edit-collection.tsx | 2 - .../page-list/view/edit-collection/hooks.tsx | 72 -------- .../view/edit-collection/rules-mode.tsx | 102 ++--------- .../view/edit-collection/select-page.tsx | 172 ++++++++++-------- packages/frontend/i18n/src/resources/en.json | 5 +- 14 files changed, 596 insertions(+), 259 deletions(-) create mode 100644 packages/frontend/core/src/components/page-list/collections/select-collection.tsx create mode 100644 packages/frontend/core/src/components/page-list/selector/index.ts create mode 100644 packages/frontend/core/src/components/page-list/selector/selector-layout.css.ts create mode 100644 packages/frontend/core/src/components/page-list/selector/selector-layout.tsx create mode 100644 packages/frontend/core/src/components/page-list/selector/use-select-dialog.tsx create mode 100644 packages/frontend/core/src/components/page-list/tags/select-tag.tsx delete mode 100644 packages/frontend/core/src/components/page-list/view/edit-collection/hooks.tsx diff --git a/packages/frontend/core/src/components/page-list/collections/index.ts b/packages/frontend/core/src/components/page-list/collections/index.ts index afed0c1444ff7..ed58ee5e14318 100644 --- a/packages/frontend/core/src/components/page-list/collections/index.ts +++ b/packages/frontend/core/src/components/page-list/collections/index.ts @@ -1,3 +1,4 @@ export * from './collection-list-header'; export * from './collection-list-item'; +export * from './select-collection'; export * from './virtualized-collection-list'; diff --git a/packages/frontend/core/src/components/page-list/collections/select-collection.tsx b/packages/frontend/core/src/components/page-list/collections/select-collection.tsx new file mode 100644 index 0000000000000..24f6154bed0e0 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/collections/select-collection.tsx @@ -0,0 +1,107 @@ +import { toast } from '@affine/component'; +import { CollectionService } from '@affine/core/modules/collection'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; +import { useI18n } from '@affine/i18n'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; +import { useCallback, useMemo, useState } from 'react'; + +import { FavoriteTag } from '../components/favorite-tag'; +import { collectionHeaderColsDef } from '../header-col-def'; +import { CollectionListItemRenderer } from '../page-group'; +import { ListTableHeader } from '../page-header'; +import type { BaseSelectorDialogProps } from '../selector'; +import { SelectorLayout } from '../selector/selector-layout'; +import type { CollectionMeta, ListItem } from '../types'; +import { VirtualizedList } from '../virtualized-list'; + +const FavoriteOperation = ({ collection }: { collection: ListItem }) => { + const t = useI18n(); + const favAdapter = useService(FavoriteItemsAdapter); + const isFavorite = useLiveData( + favAdapter.isFavorite$(collection.id, 'collection') + ); + + const onToggleFavoriteCollection = useCallback(() => { + favAdapter.toggle(collection.id, 'collection'); + toast( + isFavorite + ? t['com.affine.toastMessage.removedFavorites']() + : t['com.affine.toastMessage.addedFavorites']() + ); + }, [collection.id, favAdapter, isFavorite, t]); + + return ( + + ); +}; + +export const SelectCollection = ({ + init = [], + onCancel, + onConfirm, +}: BaseSelectorDialogProps) => { + const t = useI18n(); + const collectionService = useService(CollectionService); + const workspace = useService(WorkspaceService).workspace; + + const collections = useLiveData(collectionService.collections$); + const [selection, setSelection] = useState(init); + const [keyword, setKeyword] = useState(''); + + const collectionMetas = useMemo(() => { + const collectionsList: CollectionMeta[] = collections + .map(collection => { + return { + ...collection, + title: collection.name, + }; + }) + .filter(meta => { + const reg = new RegExp(keyword, 'i'); + return reg.test(meta.title); + }); + return collectionsList; + }, [collections, keyword]); + + const collectionItemRenderer = useCallback((item: ListItem) => { + return ; + }, []); + + const collectionHeaderRenderer = useCallback(() => { + return ; + }, []); + + const collectionOperationRenderer = useCallback((item: ListItem) => { + return ; + }, []); + + return ( + setSelection([])} + onCancel={() => onCancel?.()} + onConfirm={() => onConfirm?.(selection)} + > + + + ); +}; diff --git a/packages/frontend/core/src/components/page-list/selector/index.ts b/packages/frontend/core/src/components/page-list/selector/index.ts new file mode 100644 index 0000000000000..bafefedca12c8 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/selector/index.ts @@ -0,0 +1,31 @@ +import { SelectCollection } from '../collections'; +import { SelectTag } from '../tags'; +import { SelectPage } from '../view/edit-collection/select-page'; +import { useSelectDialog } from './use-select-dialog'; + +export interface BaseSelectorDialogProps { + init?: T; + onConfirm?: (data: T) => void; + onCancel?: () => void; +} + +/** + * Return a `open` function to open the select collection dialog. + */ +export const useSelectCollection = () => { + return useSelectDialog('select-collection', SelectCollection); +}; + +/** + * Return a `open` function to open the select page dialog. + */ +export const useSelectDoc = () => { + return useSelectDialog('select-doc-dialog', SelectPage); +}; + +/** + * Return a `open` function to open the select tag dialog. + */ +export const useSelectTag = () => { + return useSelectDialog('select-tag-dialog', SelectTag); +}; diff --git a/packages/frontend/core/src/components/page-list/selector/selector-layout.css.ts b/packages/frontend/core/src/components/page-list/selector/selector-layout.css.ts new file mode 100644 index 0000000000000..38c94161d4152 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/selector/selector-layout.css.ts @@ -0,0 +1,72 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const root = style({ + display: 'flex', + flexDirection: 'column', + height: '100%', +}); + +export const header = style({ + borderBottom: `1px solid ${cssVarV2('layer/border')}`, + minHeight: 64, +}); +export const search = style({ + width: '100%', + height: '100%', + outline: 'none', + padding: '20px 20px 20px 24px', + + fontSize: 20, + lineHeight: '24px', + fontWeight: 400, + letterSpacing: -0.2, +}); + +export const content = style({ + height: 0, + flex: 1, +}); + +export const footer = style({ + borderTop: `1px solid ${cssVarV2('layer/border')}`, + minHeight: 64, + padding: '20px 24px', + borderBottomLeftRadius: 'inherit', + borderBottomRightRadius: 'inherit', + + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}); + +export const footerInfo = style({ + display: 'flex', + alignItems: 'center', + gap: 18, + + fontSize: cssVar('fontXs'), + lineHeight: '20px', + fontWeight: 500, +}); + +export const selectedCount = style({ + display: 'flex', + alignItems: 'center', + gap: 7, +}); +export const selectedNum = style({ + color: cssVar('primaryColor'), +}); +export const clearButton = style({ + padding: '4px 18px', +}); + +export const footerAction = style({ + display: 'flex', + gap: 20, +}); +export const actionButton = style({ + padding: '4px 18px', +}); diff --git a/packages/frontend/core/src/components/page-list/selector/selector-layout.tsx b/packages/frontend/core/src/components/page-list/selector/selector-layout.tsx new file mode 100644 index 0000000000000..e676543e6623f --- /dev/null +++ b/packages/frontend/core/src/components/page-list/selector/selector-layout.tsx @@ -0,0 +1,88 @@ +import { Button } from '@affine/component'; +import { useI18n } from '@affine/i18n'; +import { type PropsWithChildren, type ReactNode, useCallback } from 'react'; + +import * as styles from './selector-layout.css'; + +export interface SelectorContentProps extends PropsWithChildren { + searchPlaceholder?: string; + selectedCount?: number; + + onSearch?: (value: string) => void; + onClear?: () => void; + onCancel?: () => void; + onConfirm?: () => void; + + actions?: ReactNode; +} + +/** + * Provides a unified layout for doc/collection/tag selector + * - Header (Search input) + * - Content + * - Footer (Selected count + Actions) + */ +export const SelectorLayout = ({ + children, + searchPlaceholder, + selectedCount, + + onSearch, + onClear, + onCancel, + onConfirm, + + actions, +}: SelectorContentProps) => { + const t = useI18n(); + + const onSearchChange = useCallback( + (e: React.ChangeEvent) => { + onSearch?.(e.target.value); + }, + [onSearch] + ); + + return ( +
+
+ +
+ +
{children}
+ +
+
+
+ {t['com.affine.selectPage.selected']()} + {selectedCount ?? 0} +
+ +
+ +
+ {actions ?? ( + <> + + + + )} +
+
+
+ ); +}; diff --git a/packages/frontend/core/src/components/page-list/selector/use-select-dialog.tsx b/packages/frontend/core/src/components/page-list/selector/use-select-dialog.tsx new file mode 100644 index 0000000000000..90aea214b9685 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/selector/use-select-dialog.tsx @@ -0,0 +1,76 @@ +import { Modal } from '@affine/component'; +import { useMount } from '@toeverything/infra'; +import { cssVar } from '@toeverything/theme'; +import { useCallback, useEffect, useState } from 'react'; + +import type { BaseSelectorDialogProps } from './type'; + +export const useSelectDialog = function useSelectDialog( + id: string, + Component: React.FC> +) { + // to control whether the dialog is open, it's not equal to !!value + // when closing the dialog, show will be `false` first, then after the animation, value turns to `undefined` + const [show, setShow] = useState(false); + const [value, setValue] = useState<{ + init: T; + onConfirm: (v: T) => void; + }>(); + + const onOpenChanged = useCallback((open: boolean) => { + if (!open) setValue(undefined); + setShow(open); + }, []); + + const close = useCallback(() => setShow(false), []); + + /** + * Open a dialog to select items + */ + const open = useCallback( + (ids: T) => { + return new Promise(resolve => { + setShow(true); + setValue({ + init: ids, + onConfirm: list => { + close(); + resolve(list); + }, + }); + }); + }, + [close] + ); + + const { mount } = useMount(id); + + useEffect(() => { + return mount( + + {value ? ( + + ) : null} + + ); + }, [Component, close, mount, onOpenChanged, show, value]); + + return open; +}; diff --git a/packages/frontend/core/src/components/page-list/tags/index.ts b/packages/frontend/core/src/components/page-list/tags/index.ts index 11a1b31754c5f..45cd9b36052ac 100644 --- a/packages/frontend/core/src/components/page-list/tags/index.ts +++ b/packages/frontend/core/src/components/page-list/tags/index.ts @@ -1,3 +1,4 @@ +export * from './select-tag'; export * from './tag-list-header'; export * from './tag-list-item'; export * from './virtualized-tag-list'; diff --git a/packages/frontend/core/src/components/page-list/tags/select-tag.tsx b/packages/frontend/core/src/components/page-list/tags/select-tag.tsx new file mode 100644 index 0000000000000..499ef7a8ce3d6 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/tags/select-tag.tsx @@ -0,0 +1,97 @@ +import { TagService } from '@affine/core/modules/tag'; +import { useI18n } from '@affine/i18n'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; +import { useCallback, useMemo, useState } from 'react'; + +import { tagHeaderColsDef } from '../header-col-def'; +import { TagListItemRenderer } from '../page-group'; +import { ListTableHeader } from '../page-header'; +import type { BaseSelectorDialogProps } from '../selector'; +import { SelectorLayout } from '../selector/selector-layout'; +import type { ListItem, TagMeta } from '../types'; +import { VirtualizedList } from '../virtualized-list'; + +// TODO(@EYHN): add tag to favourite support +const FavoriteOperation = ({ tag: _ }: { tag: ListItem }) => { + // const t = useI18n(); + // const favAdapter = useService(FavoriteItemsAdapter); + // const isFavorite = useLiveData( + // favAdapter.isFavorite$(tag.id, 'tag') + // ); + + // const onToggleFavoriteCollection = useCallback(() => { + // favAdapter.toggle(tag.id, 'tag'); + // toast( + // isFavorite + // ? t['com.affine.toastMessage.removedFavorites']() + // : t['com.affine.toastMessage.addedFavorites']() + // ); + // }, [tag.id, favAdapter, isFavorite, t]); + + // return ( + // + // ); + + return null; +}; + +export const SelectTag = ({ + init = [], + onConfirm, + onCancel, +}: BaseSelectorDialogProps) => { + const t = useI18n(); + + const workspace = useService(WorkspaceService).workspace; + const tagList = useService(TagService).tagList; + + const [selection, setSelection] = useState(init); + const [keyword, setKeyword] = useState(''); + const tagMetas: TagMeta[] = useLiveData(tagList.tagMetas$); + + const filteredTagMetas = useMemo(() => { + return tagMetas.filter(tag => { + const reg = new RegExp(keyword, 'i'); + return reg.test(tag.title); + }); + }, [keyword, tagMetas]); + + const tagItemRenderer = useCallback((item: ListItem) => { + return ; + }, []); + + const tagOperationRenderer = useCallback((item: ListItem) => { + return ; + }, []); + + const tagHeaderRenderer = useCallback(() => { + return ; + }, []); + + return ( + onConfirm?.(selection)} + onCancel={onCancel} + onClear={() => setSelection([])} + > + + + ); +}; diff --git a/packages/frontend/core/src/components/page-list/view/edit-collection/edit-collection.css.ts b/packages/frontend/core/src/components/page-list/view/edit-collection/edit-collection.css.ts index b438242188d28..341135b390a84 100644 --- a/packages/frontend/core/src/components/page-list/view/edit-collection/edit-collection.css.ts +++ b/packages/frontend/core/src/components/page-list/view/edit-collection/edit-collection.css.ts @@ -5,19 +5,6 @@ export const ellipsis = style({ textOverflow: 'ellipsis', whiteSpace: 'nowrap', }); -export const pagesBottomLeft = style({ - display: 'flex', - gap: 8, - alignItems: 'center', -}); -export const pagesBottom = style({ - display: 'flex', - justifyContent: 'space-between', - padding: '20px 24px', - borderTop: `1px solid ${cssVar('borderColor')}`, - flexWrap: 'wrap', - gap: '12px', -}); export const pagesTabContent = style({ display: 'flex', justifyContent: 'space-between', @@ -26,10 +13,10 @@ export const pagesTabContent = style({ padding: '16px 16px 8px 16px', }); export const pagesTab = style({ - flex: 1, display: 'flex', flexDirection: 'column', width: '100%', + height: '100%', overflow: 'hidden', }); export const pagesList = style({ @@ -65,15 +52,6 @@ export const rulesContainerRight = style({ overflowX: 'hidden', overflowY: 'auto', }); -export const includeAddButton = style({ - display: 'flex', - alignItems: 'center', - gap: 6, - padding: '4px 8px', - fontSize: 14, - lineHeight: '22px', - width: 'max-content', -}); export const includeItemTitle = style({ overflow: 'hidden', fontWeight: 600, @@ -163,11 +141,6 @@ export const previewCountTips = style({ lineHeight: '20px', color: cssVar('textSecondaryColor'), }); -export const selectedCountTips = style({ - fontSize: 12, - lineHeight: '20px', - color: cssVar('textPrimaryColor'), -}); export const rulesTitleHighlight = style({ color: cssVar('primaryColor'), fontStyle: 'italic', diff --git a/packages/frontend/core/src/components/page-list/view/edit-collection/edit-collection.tsx b/packages/frontend/core/src/components/page-list/view/edit-collection/edit-collection.tsx index 042f80f28b692..bbe07fe9b1b9f 100644 --- a/packages/frontend/core/src/components/page-list/view/edit-collection/edit-collection.tsx +++ b/packages/frontend/core/src/components/page-list/view/edit-collection/edit-collection.tsx @@ -121,7 +121,6 @@ export const EditCollection = ({ {t['com.affine.editCollection.button.cancel']()} - - - )} - - - + ); }; export const EmptyList = ({ search }: { search?: string }) => { diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 52235fe625fb7..6dc00a1d525cb 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1377,5 +1377,8 @@ "will delete member": "will delete member", "com.affine.user-info.usage.ai": "AI Usage", "com.affine.user-info.usage.cloud": "Cloud Storage", - "com.affine.ai.template-insert.failed": "Failed to insert template, please try again." + "com.affine.ai.template-insert.failed": "Failed to insert template, please try again.", + "com.affine.selector-doc.search-placeholder": "Search docs...", + "com.affine.selector-collection.search.placeholder": "Search collections...", + "com.affine.selector-tag.search.placeholder": "Search tags..." }