-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): add doc/collection/tag select hook
close AF-1110
- Loading branch information
Showing
14 changed files
with
596 additions
and
259 deletions.
There are no files selected for viewing
1 change: 1 addition & 0 deletions
1
packages/frontend/core/src/components/page-list/collections/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
export * from './collection-list-header'; | ||
export * from './collection-list-item'; | ||
export * from './select-collection'; | ||
export * from './virtualized-collection-list'; |
107 changes: 107 additions & 0 deletions
107
packages/frontend/core/src/components/page-list/collections/select-collection.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<FavoriteTag | ||
style={{ marginRight: 8 }} | ||
onClick={onToggleFavoriteCollection} | ||
active={isFavorite} | ||
/> | ||
); | ||
}; | ||
|
||
export const SelectCollection = ({ | ||
init = [], | ||
onCancel, | ||
onConfirm, | ||
}: BaseSelectorDialogProps<string[]>) => { | ||
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 <CollectionListItemRenderer {...item} />; | ||
}, []); | ||
|
||
const collectionHeaderRenderer = useCallback(() => { | ||
return <ListTableHeader headerCols={collectionHeaderColsDef} />; | ||
}, []); | ||
|
||
const collectionOperationRenderer = useCallback((item: ListItem) => { | ||
return <FavoriteOperation collection={item} />; | ||
}, []); | ||
|
||
return ( | ||
<SelectorLayout | ||
searchPlaceholder={t[ | ||
'com.affine.selector-collection.search.placeholder' | ||
]()} | ||
selectedCount={selection.length} | ||
onSearch={setKeyword} | ||
onClear={() => setSelection([])} | ||
onCancel={() => onCancel?.()} | ||
onConfirm={() => onConfirm?.(selection)} | ||
> | ||
<VirtualizedList | ||
selectable={true} | ||
draggable={false} | ||
selectedIds={selection} | ||
onSelectedIdsChange={setSelection} | ||
items={collectionMetas} | ||
itemRenderer={collectionItemRenderer} | ||
rowAsLink | ||
docCollection={workspace.docCollection} | ||
operationsRenderer={collectionOperationRenderer} | ||
headerRenderer={collectionHeaderRenderer} | ||
/> | ||
</SelectorLayout> | ||
); | ||
}; |
31 changes: 31 additions & 0 deletions
31
packages/frontend/core/src/components/page-list/selector/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<T> { | ||
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); | ||
}; |
72 changes: 72 additions & 0 deletions
72
packages/frontend/core/src/components/page-list/selector/selector-layout.css.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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', | ||
}); |
88 changes: 88 additions & 0 deletions
88
packages/frontend/core/src/components/page-list/selector/selector-layout.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<HTMLInputElement>) => { | ||
onSearch?.(e.target.value); | ||
}, | ||
[onSearch] | ||
); | ||
|
||
return ( | ||
<div className={styles.root}> | ||
<header className={styles.header}> | ||
<input | ||
className={styles.search} | ||
placeholder={searchPlaceholder} | ||
onChange={onSearchChange} | ||
/> | ||
</header> | ||
|
||
<main className={styles.content}>{children}</main> | ||
|
||
<footer className={styles.footer}> | ||
<div className={styles.footerInfo}> | ||
<div className={styles.selectedCount}> | ||
<span>{t['com.affine.selectPage.selected']()}</span> | ||
<span className={styles.selectedNum}>{selectedCount ?? 0}</span> | ||
</div> | ||
<Button type="plain" className={styles.clearButton} onClick={onClear}> | ||
{t['com.affine.editCollection.pages.clear']()} | ||
</Button> | ||
</div> | ||
|
||
<div className={styles.footerAction}> | ||
{actions ?? ( | ||
<> | ||
<Button onClick={onCancel} className={styles.actionButton}> | ||
{t['Cancel']()} | ||
</Button> | ||
<Button | ||
onClick={onConfirm} | ||
className={styles.actionButton} | ||
type="primary" | ||
> | ||
{t['Confirm']()} | ||
</Button> | ||
</> | ||
)} | ||
</div> | ||
</footer> | ||
</div> | ||
); | ||
}; |
76 changes: 76 additions & 0 deletions
76
packages/frontend/core/src/components/page-list/selector/use-select-dialog.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<T>( | ||
id: string, | ||
Component: React.FC<BaseSelectorDialogProps<T>> | ||
) { | ||
// 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<T>(resolve => { | ||
setShow(true); | ||
setValue({ | ||
init: ids, | ||
onConfirm: list => { | ||
close(); | ||
resolve(list); | ||
}, | ||
}); | ||
}); | ||
}, | ||
[close] | ||
); | ||
|
||
const { mount } = useMount(id); | ||
|
||
useEffect(() => { | ||
return mount( | ||
<Modal | ||
open={show} | ||
onOpenChange={onOpenChanged} | ||
withoutCloseButton | ||
width="calc(100% - 32px)" | ||
height="80%" | ||
contentOptions={{ | ||
style: { | ||
padding: 0, | ||
maxWidth: 976, | ||
background: cssVar('backgroundPrimaryColor'), | ||
}, | ||
}} | ||
> | ||
{value ? ( | ||
<Component | ||
init={value.init} | ||
onCancel={close} | ||
onConfirm={value.onConfirm} | ||
/> | ||
) : null} | ||
</Modal> | ||
); | ||
}, [Component, close, mount, onOpenChanged, show, value]); | ||
|
||
return open; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
export * from './select-tag'; | ||
export * from './tag-list-header'; | ||
export * from './tag-list-item'; | ||
export * from './virtualized-tag-list'; |
Oops, something went wrong.