Skip to content

Commit

Permalink
feat(core): add doc/collection/tag select hook
Browse files Browse the repository at this point in the history
close AF-1110
  • Loading branch information
CatsJuice authored and EYHN committed Jul 25, 2024
1 parent eecbe82 commit 2953a15
Show file tree
Hide file tree
Showing 14 changed files with 596 additions and 259 deletions.
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';
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 packages/frontend/core/src/components/page-list/selector/index.ts
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);
};
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',
});
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>
);
};
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;
};
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';
Loading

0 comments on commit 2953a15

Please sign in to comment.