feat(mobile): manage docs/tags/collections in explorer (#8649)

close AF-1564, AF-1542
This commit is contained in:
CatsJuice
2024-11-04 05:28:06 +00:00
parent 4cbf4b74d6
commit cdaac5602c
18 changed files with 861 additions and 77 deletions

View File

@@ -0,0 +1,86 @@
import { style } from '@vanilla-extract/css';
// desktop
export const desktopStyles = {
container: style({
display: 'flex',
flexDirection: 'column',
}),
description: style({}),
header: style({}),
content: style({
height: '100%',
overflowY: 'auto',
padding: '12px 4px 20px 4px',
}),
footer: style({
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
paddingTop: '40px',
marginTop: 'auto',
gap: '20px',
selectors: {
'&.modalFooterWithChildren': {
paddingTop: '20px',
},
'&.reverse': {
flexDirection: 'row-reverse',
justifyContent: 'flex-start',
},
},
}),
action: style({}),
};
// mobile
export const mobileStyles = {
container: style({
display: 'flex',
flexDirection: 'column',
padding: '12px 0 !important',
borderRadius: 22,
}),
description: style({
padding: '11px 22px',
fontSize: 17,
fontWeight: 400,
letterSpacing: -0.43,
lineHeight: '22px',
}),
header: style({
padding: '10px 16px',
marginBottom: '0px !important',
fontSize: 17,
fontWeight: 600,
letterSpacing: -0.43,
lineHeight: '22px',
}),
content: style({
padding: '11px 22px',
fontSize: 17,
fontWeight: 400,
letterSpacing: -0.43,
lineHeight: '22px',
}),
footer: style({
padding: '8px 16px',
display: 'flex',
flexDirection: 'column',
gap: 16,
selectors: {
'&.reverse': {
flexDirection: 'column-reverse',
},
},
}),
action: style({
width: '100%',
height: 44,
borderRadius: 8,
fontSize: 17,
fontWeight: 400,
letterSpacing: -0.43,
lineHeight: '22px',
}),
};

View File

@@ -5,9 +5,11 @@ import { createContext, useCallback, useContext, useState } from 'react';
import type { ButtonProps } from '../button'; import type { ButtonProps } from '../button';
import { Button } from '../button'; import { Button } from '../button';
import { desktopStyles, mobileStyles } from './confirm-modal.css';
import type { ModalProps } from './modal'; import type { ModalProps } from './modal';
import { Modal } from './modal'; import { Modal } from './modal';
import * as styles from './styles.css';
const styles = BUILD_CONFIG.isMobileEdition ? mobileStyles : desktopStyles;
export interface ConfirmModalProps extends ModalProps { export interface ConfirmModalProps extends ModalProps {
confirmButtonOptions?: Omit<ButtonProps, 'children'>; confirmButtonOptions?: Omit<ButtonProps, 'children'>;
@@ -36,6 +38,8 @@ export const ConfirmModal = ({
onCancel, onCancel,
width = 480, width = 480,
autoFocusConfirm = true, autoFocusConfirm = true,
headerClassName,
descriptionClassName,
...props ...props
}: ConfirmModalProps) => { }: ConfirmModalProps) => {
const onConfirmClick = useCallback(() => { const onConfirmClick = useCallback(() => {
@@ -46,7 +50,7 @@ export const ConfirmModal = ({
return ( return (
<Modal <Modal
contentOptions={{ contentOptions={{
className: styles.confirmModalContainer, className: styles.container,
onPointerDownOutside: e => { onPointerDownOutside: e => {
e.stopPropagation(); e.stopPropagation();
onCancel?.(); onCancel?.();
@@ -56,19 +60,20 @@ export const ConfirmModal = ({
closeButtonOptions={{ closeButtonOptions={{
onClick: onCancel, onClick: onCancel,
}} }}
headerClassName={clsx(styles.header, headerClassName)}
descriptionClassName={clsx(styles.description, descriptionClassName)}
{...props} {...props}
> >
{children ? ( {children ? <div className={styles.content}>{children}</div> : null}
<div className={styles.confirmModalContent}>{children}</div>
) : null}
<div <div
className={clsx(styles.modalFooter, { className={clsx(styles.footer, {
modalFooterWithChildren: !!children, modalFooterWithChildren: !!children,
reverse: reverseFooter, reverse: reverseFooter,
})} })}
> >
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
className={styles.action}
onClick={onCancel} onClick={onCancel}
data-testid="confirm-modal-cancel" data-testid="confirm-modal-cancel"
{...cancelButtonOptions} {...cancelButtonOptions}
@@ -77,6 +82,7 @@ export const ConfirmModal = ({
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<Button <Button
className={styles.action}
onClick={onConfirmClick} onClick={onConfirmClick}
data-testid="confirm-modal-confirm" data-testid="confirm-modal-confirm"
autoFocus={autoFocusConfirm} autoFocus={autoFocusConfirm}

View File

@@ -23,7 +23,9 @@ export interface ModalProps extends DialogProps {
height?: CSSProperties['height']; height?: CSSProperties['height'];
minHeight?: CSSProperties['minHeight']; minHeight?: CSSProperties['minHeight'];
title?: React.ReactNode; title?: React.ReactNode;
headerClassName?: string;
description?: React.ReactNode; description?: React.ReactNode;
descriptionClassName?: string;
withoutCloseButton?: boolean; withoutCloseButton?: boolean;
/** /**
* __Click outside__ or __Press `Esc`__ won't close the modal * __Click outside__ or __Press `Esc`__ won't close the modal
@@ -130,7 +132,9 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
height, height,
minHeight = 194, minHeight = 194,
title, title,
headerClassName,
description, description,
descriptionClassName,
withoutCloseButton = false, withoutCloseButton = false,
persistent, persistent,
contentOptions: { contentOptions: {
@@ -263,7 +267,9 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
</Dialog.Close> </Dialog.Close>
)} )}
{title ? ( {title ? (
<Dialog.Title className={styles.modalHeader}> <Dialog.Title
className={clsx(styles.modalHeader, headerClassName)}
>
{title} {title}
</Dialog.Title> </Dialog.Title>
) : ( ) : (
@@ -274,7 +280,12 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
</VisuallyHidden.Root> </VisuallyHidden.Root>
)} )}
{description ? ( {description ? (
<Dialog.Description className={styles.modalDescription}> <Dialog.Description
className={clsx(
styles.modalDescription,
descriptionClassName
)}
>
{description} {description}
</Dialog.Description> </Dialog.Description>
) : null} ) : null}

View File

@@ -178,32 +178,6 @@ export const modalDescription = style({
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
overflowWrap: 'break-word', overflowWrap: 'break-word',
}); });
export const modalFooter = style({
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
paddingTop: '40px',
marginTop: 'auto',
gap: '20px',
selectors: {
'&.modalFooterWithChildren': {
paddingTop: '20px',
},
'&.reverse': {
flexDirection: 'row-reverse',
justifyContent: 'flex-start',
},
},
});
export const confirmModalContent = style({
height: '100%',
overflowY: 'auto',
padding: '12px 4px 20px 4px',
});
export const confirmModalContainer = style({
display: 'flex',
flexDirection: 'column',
});
globalStyle(`[data-modal="false"]${modalContentWrapper}`, { globalStyle(`[data-modal="false"]${modalContentWrapper}`, {
pointerEvents: 'none', pointerEvents: 'none',
@@ -212,9 +186,3 @@ globalStyle(`[data-modal="false"]${modalContentWrapper}`, {
globalStyle(`[data-modal="false"] ${modalContent}`, { globalStyle(`[data-modal="false"] ${modalContent}`, {
pointerEvents: 'auto', pointerEvents: 'auto',
}); });
export const promptModalContent = style({
display: 'flex',
flexDirection: 'column',
gap: '12px',
});

View File

@@ -3,11 +3,7 @@ import { SelectTag } from '../tags';
import { SelectPage } from '../view/edit-collection/select-page'; import { SelectPage } from '../view/edit-collection/select-page';
import { useSelectDialog } from './use-select-dialog'; import { useSelectDialog } from './use-select-dialog';
export interface BaseSelectorDialogProps<T> { export * from './use-select-dialog';
init?: T;
onConfirm?: (data: T) => void;
onCancel?: () => void;
}
/** /**
* Return a `open` function to open the select collection dialog. * Return a `open` function to open the select collection dialog.

View File

@@ -1,13 +1,21 @@
import { Modal } from '@affine/component'; import { Modal, type ModalProps } from '@affine/component';
import { useMount } from '@toeverything/infra'; import { useMount } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import type { BaseSelectorDialogProps } from '.'; export interface BaseSelectorDialogProps<T> {
init?: T;
onConfirm?: (data: T) => void;
onCancel?: () => void;
}
export const useSelectDialog = function useSelectDialog<T>( const defaultModalProps: Partial<Omit<ModalProps, 'children'>> = {};
Component: React.FC<BaseSelectorDialogProps<T>>, export const useSelectDialog = function useSelectDialog<T, P>(
debugKey?: string Component: React.FC<BaseSelectorDialogProps<T> & P>,
debugKey?: string,
options?: {
modalProps?: Partial<Omit<ModalProps, 'children'>>;
}
) { ) {
// to control whether the dialog is open, it's not equal to !!value // 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` // when closing the dialog, show will be `false` first, then after the animation, value turns to `undefined`
@@ -16,6 +24,7 @@ export const useSelectDialog = function useSelectDialog<T>(
init?: T; init?: T;
onConfirm: (v: T) => void; onConfirm: (v: T) => void;
}>(); }>();
const [additionalProps, setAdditionalProps] = useState<P>();
const onOpenChanged = useCallback((open: boolean) => { const onOpenChanged = useCallback((open: boolean) => {
if (!open) setValue(undefined); if (!open) setValue(undefined);
@@ -28,9 +37,10 @@ export const useSelectDialog = function useSelectDialog<T>(
* Open a dialog to select items * Open a dialog to select items
*/ */
const open = useCallback( const open = useCallback(
(ids?: T) => { (ids?: T, additionalProps?: P) => {
return new Promise<T>(resolve => { return new Promise<T>(resolve => {
setShow(true); setShow(true);
setAdditionalProps(additionalProps);
setValue({ setValue({
init: ids, init: ids,
onConfirm: list => { onConfirm: list => {
@@ -46,6 +56,9 @@ export const useSelectDialog = function useSelectDialog<T>(
const { mount } = useMount(debugKey); const { mount } = useMount(debugKey);
useEffect(() => { useEffect(() => {
const { contentOptions, ...otherModalProps } =
options?.modalProps ?? defaultModalProps;
return mount( return mount(
<Modal <Modal
open={show} open={show}
@@ -59,18 +72,31 @@ export const useSelectDialog = function useSelectDialog<T>(
maxWidth: 976, maxWidth: 976,
background: cssVar('backgroundPrimaryColor'), background: cssVar('backgroundPrimaryColor'),
}, },
...contentOptions,
}} }}
{...otherModalProps}
> >
{value ? ( {value ? (
<Component <Component
init={value.init} init={value.init}
onCancel={close} onCancel={close}
onConfirm={value.onConfirm} onConfirm={value.onConfirm}
{...(additionalProps as any)}
/> />
) : null} ) : null}
</Modal> </Modal>
); );
}, [Component, close, mount, onOpenChanged, show, value]); }, [
Component,
additionalProps,
close,
debugKey,
mount,
onOpenChanged,
options?.modalProps,
show,
value,
]);
return open; return open;
}; };

View File

@@ -7,11 +7,6 @@ import {
notify, notify,
} from '@affine/component'; } from '@affine/component';
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils'; import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
import {
useSelectCollection,
useSelectDoc,
useSelectTag,
} from '@affine/core/components/page-list/selector';
import type { import type {
ExplorerTreeNodeIcon, ExplorerTreeNodeIcon,
NodeOperation, NodeOperation,
@@ -42,6 +37,11 @@ import {
import { difference } from 'lodash-es'; import { difference } from 'lodash-es';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import {
useSelectCollection,
useSelectDoc,
useSelectTag,
} from '../../../selector';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { ExplorerTreeNode } from '../../tree/node'; import { ExplorerTreeNode } from '../../tree/node';
import { ExplorerCollectionNode } from '../collection'; import { ExplorerCollectionNode } from '../collection';
@@ -195,7 +195,7 @@ const ExplorerFolderNodeFolder = ({
: type === 'collection' : type === 'collection'
? openCollectionsSelector ? openCollectionsSelector
: openTagsSelector; : openTagsSelector;
selector(initialIds) selector(initialIds, { type, where: 'folder' })
.then(selectedIds => { .then(selectedIds => {
const newItemIds = difference(selectedIds, initialIds); const newItemIds = difference(selectedIds, initialIds);
const removedItemIds = difference(initialIds, selectedIds); const removedItemIds = difference(initialIds, selectedIds);
@@ -287,17 +287,6 @@ const ExplorerFolderNodeFolder = ({
/> />
), ),
}, },
{
index: 101,
view: (
<MenuItem
prefixIcon={<PageIcon />}
onClick={() => handleAddToFolder('doc')}
>
{t['com.affine.rootAppSidebar.organize.folder.add-docs']()}
</MenuItem>
),
},
{ {
index: 102, index: 102,
view: ( view: (
@@ -307,6 +296,12 @@ const ExplorerFolderNodeFolder = ({
}} }}
items={ items={
<> <>
<MenuItem
prefixIcon={<PageIcon />}
onClick={() => handleAddToFolder('doc')}
>
{t['com.affine.rootAppSidebar.organize.folder.add-docs']()}
</MenuItem>
<MenuItem <MenuItem
onClick={() => handleAddToFolder('tag')} onClick={() => handleAddToFolder('tag')}
prefixIcon={<TagsIcon />} prefixIcon={<TagsIcon />}

View File

@@ -7,7 +7,12 @@ import { TagService } from '@affine/core/modules/tag';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { track } from '@affine/track'; import { track } from '@affine/track';
import { DeleteIcon, PlusIcon, SplitViewIcon } from '@blocksuite/icons/rc'; import {
DeleteIcon,
FolderIcon,
PlusIcon,
SplitViewIcon,
} from '@blocksuite/icons/rc';
import { import {
DocsService, DocsService,
FeatureFlagService, FeatureFlagService,
@@ -18,6 +23,7 @@ import {
} from '@toeverything/infra'; } from '@toeverything/infra';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useSelectDoc } from '../../../selector';
import { TagRenameSubMenu } from './dialog'; import { TagRenameSubMenu } from './dialog';
export const useExplorerTagNodeOperations = ( export const useExplorerTagNodeOperations = (
@@ -29,6 +35,7 @@ export const useExplorerTagNodeOperations = (
} }
) => { ) => {
const t = useI18n(); const t = useI18n();
const openDocSelector = useSelectDoc();
const { workbenchService, workspaceService, tagService, favoriteService } = const { workbenchService, workspaceService, tagService, favoriteService } =
useServices({ useServices({
WorkbenchService, WorkbenchService,
@@ -113,6 +120,17 @@ export const useExplorerTagNodeOperations = (
}, },
[handleChangeColor, handleRename] [handleChangeColor, handleRename]
); );
const handleOpenDocSelector = useCallback(() => {
const initialIds = tagRecord?.pageIds$.value;
openDocSelector(initialIds, { where: 'tag', type: 'doc' })
.then(selectedIds => {
const newIds = selectedIds.filter(id => !initialIds?.includes(id));
const removedIds = initialIds?.filter(id => !selectedIds.includes(id));
newIds.forEach(id => tagRecord?.tag(id));
removedIds?.forEach(id => tagRecord?.untag(id));
})
.catch(console.error);
}, [openDocSelector, tagRecord]);
return useMemo( return useMemo(
() => ({ () => ({
@@ -125,6 +143,7 @@ export const useExplorerTagNodeOperations = (
handleRename, handleRename,
handleChangeColor, handleChangeColor,
handleChangeNameOrColor, handleChangeNameOrColor,
handleOpenDocSelector,
}), }),
[ [
favorite, favorite,
@@ -136,6 +155,7 @@ export const useExplorerTagNodeOperations = (
handleOpenInSplitView, handleOpenInSplitView,
handleRename, handleRename,
handleToggleFavoriteTag, handleToggleFavoriteTag,
handleOpenDocSelector,
] ]
); );
}; };
@@ -157,6 +177,7 @@ export const useExplorerTagNodeOperationsMenu = (
handleOpenInSplitView, handleOpenInSplitView,
handleToggleFavoriteTag, handleToggleFavoriteTag,
handleChangeNameOrColor, handleChangeNameOrColor,
handleOpenDocSelector,
} = useExplorerTagNodeOperations(tagId, option); } = useExplorerTagNodeOperations(tagId, option);
return useMemo( return useMemo(
@@ -180,6 +201,18 @@ export const useExplorerTagNodeOperationsMenu = (
/> />
), ),
}, },
{
index: 11,
view: <MenuSeparator />,
},
{
index: 12,
view: (
<MenuItem prefixIcon={<FolderIcon />} onClick={handleOpenDocSelector}>
{t['com.affine.m.explorer.tag.manage-docs']()}
</MenuItem>
),
},
...(BUILD_CONFIG.isElectron && enableMultiView ...(BUILD_CONFIG.isElectron && enableMultiView
? [ ? [
{ {
@@ -231,6 +264,7 @@ export const useExplorerTagNodeOperationsMenu = (
handleChangeNameOrColor, handleChangeNameOrColor,
handleMoveToTrash, handleMoveToTrash,
handleNewDoc, handleNewDoc,
handleOpenDocSelector,
handleOpenInSplitView, handleOpenInSplitView,
handleToggleFavoriteTag, handleToggleFavoriteTag,
t, t,

View File

@@ -4,5 +4,6 @@ export * from './page-header';
export * from './rename'; export * from './rename';
export * from './search-input'; export * from './search-input';
export * from './search-result'; export * from './search-result';
export * from './selector';
export * from './user-plan-tag'; export * from './user-plan-tag';
export * from './workspace-selector'; export * from './workspace-selector';

View File

@@ -0,0 +1,40 @@
import type { BaseSelectorDialogProps } from '@affine/core/components/page-list/selector';
import { CollectionService } from '@affine/core/modules/collection';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
import type { DocsSelectorProps } from './doc-selector';
import { GenericSelector, type GenericSelectorProps } from './generic-selector';
export interface CollectionsSelectorProps
extends BaseSelectorDialogProps<string[]>,
Pick<GenericSelectorProps, 'where' | 'type'> {}
export const CollectionsSelector = ({
init = [],
onCancel,
onConfirm,
...otherProps
}: DocsSelectorProps) => {
const collectionService = useService(CollectionService);
const collections = useLiveData(collectionService.collections$);
const list = useMemo(() => {
return collections.map(collection => ({
id: collection.id,
icon: <ViewLayersIcon />,
label: collection.name,
}));
}, [collections]);
return (
<GenericSelector
onBack={onCancel}
onConfirm={onConfirm}
initial={init}
data={list}
{...otherProps}
/>
);
};

View File

@@ -0,0 +1,58 @@
import type { BaseSelectorDialogProps } from '@affine/core/components/page-list/selector';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { useI18n } from '@affine/i18n';
import { DocsService, useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
import { GenericSelector, type GenericSelectorProps } from './generic-selector';
export interface DocsSelectorProps
extends BaseSelectorDialogProps<string[]>,
Pick<GenericSelectorProps, 'where' | 'type'> {}
const DocIcon = ({ docId }: { docId: string }) => {
const docDisplayMetaService = useService(DocDisplayMetaService);
const Icon = useLiveData(docDisplayMetaService.icon$(docId));
return <Icon />;
};
const DocLabel = ({ docId }: { docId: string }) => {
const t = useI18n();
const docDisplayMetaService = useService(DocDisplayMetaService);
const label = useLiveData(docDisplayMetaService.title$(docId));
return typeof label === 'string' ? label : t[label.i18nKey]();
};
export const DocsSelector = ({
init = [],
onCancel,
onConfirm,
...otherProps
}: DocsSelectorProps) => {
const docsService = useService(DocsService);
const docRecords = useLiveData(docsService.list.docs$);
const list = useMemo(() => {
return (
docRecords
?.filter(record => !record.trash$.value) // not reactive
?.map(record => ({
id: record.id,
icon: <DocIcon docId={record.id} />,
label: <DocLabel docId={record.id} />,
})) ?? []
);
}, [docRecords]);
return (
<GenericSelector
onBack={onCancel}
onConfirm={onConfirm}
initial={init}
data={list}
{...otherProps}
/>
);
};

View File

@@ -0,0 +1,293 @@
import {
Button,
Checkbox,
SafeArea,
Scrollable,
useConfirmModal,
useThemeColorMeta,
} from '@affine/component';
import { useI18n } from '@affine/i18n';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { GlobalCacheService, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
type ReactNode,
type TouchEvent as ReactTouchEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { PageHeader } from '../page-header';
import * as styles from './generic.css';
export interface GenericSelectorProps {
title?: ReactNode;
onBack?: () => void;
onConfirm?: (ids: string[]) => void;
confirmText?: string;
initial: string[];
data: Array<{ id: string; icon: ReactNode; label: ReactNode }>;
// totalRenderer?: (props: { total: number }) => ReactNode;
// changedRenderer?: (props: { added: number; removed: number }) => ReactNode;
// removeWarningType?: string;
// removeWarningWhere?: string;
type: 'doc' | 'tag' | 'collection';
where: 'tag' | 'folder' | 'collection';
}
const WILL_BE_REMOVED_WARNING_KEY = 'willBeRemovedWarningRead';
const ChangedRenderer = ({
type,
added,
removed,
}: {
added: number;
removed: number;
type: string;
}) => {
const t = useI18n();
const addedText = added
? t['com.affine.m.selector.info-added']({ count: `${added}`, type })
: '';
const removedText = removed
? t['com.affine.m.selector.info-removed']({
count: `${removed}`,
type,
})
: '';
const connector = added && removed ? ' · ' : '';
return addedText + connector + removedText;
};
export const GenericSelector = ({
initial: originalInitial,
data,
title,
confirmText,
type,
where,
onBack,
onConfirm,
}: GenericSelectorProps) => {
const t = useI18n();
useThemeColorMeta(cssVarV2('layer/background/secondary'));
const listRef = useRef<HTMLUListElement>(null);
const quickScrollRef = useRef<HTMLDivElement>(null);
const globalCache = useService(GlobalCacheService).globalCache;
const { openConfirmModal } = useConfirmModal();
const whereText = t[`com.affine.m.selector.where-${where}`]();
const typeText = t[`com.affine.m.selector.type-${type}`]();
const [willBeRemovedWarningRead] = useState(
globalCache.get<boolean>(WILL_BE_REMOVED_WARNING_KEY)
);
// make sure "initial ids" exist in list
const [initial] = useState(
originalInitial.filter(id => data.some(el => el.id === id))
);
const [selected, setSelected] = useState(initial);
const added = useMemo(
() => selected.filter(id => !initial.includes(id)),
[initial, selected]
);
const removed = useMemo(
() => initial.filter(id => !selected.includes(id)),
[initial, selected]
);
const disableConfirm = added.length === 0 && removed.length === 0;
const handleToggleSelected = useCallback((id: string) => {
setSelected(prev => {
if (prev.includes(id)) {
return prev.filter(v => v !== id);
} else {
return [...prev, id];
}
});
}, []);
const handleReadWillBeRemovedWarning = useCallback(() => {
globalCache.set(WILL_BE_REMOVED_WARNING_KEY, true);
}, [globalCache]);
const handleConfirm = useCallback(() => {
if (!willBeRemovedWarningRead && removed.length > 0) {
openConfirmModal({
title: t['com.affine.m.selector.remove-warning.title'](),
description: t['com.affine.m.selector.remove-warning.message']({
type: typeText,
where: whereText,
}),
cancelText: t['com.affine.m.selector.remove-warning.cancel'](),
confirmText: t['com.affine.m.selector.remove-warning.confirm'](),
reverseFooter: true,
onConfirm: () => {
handleReadWillBeRemovedWarning();
onConfirm?.(selected);
},
});
return;
}
onConfirm?.(selected);
}, [
handleReadWillBeRemovedWarning,
onConfirm,
openConfirmModal,
removed,
selected,
t,
typeText,
whereText,
willBeRemovedWarningRead,
]);
// touch & move to select
useEffect(() => {
const quickSelect = quickScrollRef.current;
if (!quickSelect) return;
const reverseThresholdPx = 10;
const onTouchStart = (e: TouchEvent) => {
e.stopPropagation();
e.preventDefault();
let prevIndex: number | null = null;
let prevY: number | null = null;
let prevDir: 'down' | 'up' | null = null;
let reverseAt: number | null = null;
const check = (e: ReactTouchEvent<HTMLDivElement> | TouchEvent) => {
const list = listRef.current;
if (!list) return;
const { clientY } = e.touches[0];
if (clientY === prevY) return;
const rect = list.getBoundingClientRect();
const index = Math.floor(
((clientY - rect.top) / rect.height) * data.length
);
const newDir = prevY === null ? null : clientY > prevY ? 'down' : 'up';
const dirChanged = prevDir && newDir && newDir !== prevDir;
const indexChanged = index !== prevIndex;
if (dirChanged) {
reverseAt = clientY;
}
const reverseAndMoved =
reverseAt && Math.abs(clientY - reverseAt) > reverseThresholdPx;
if (reverseAndMoved) {
reverseAt = null;
}
if (
index >= 0 &&
index < data.length &&
(reverseAndMoved || indexChanged)
) {
handleToggleSelected(data[index].id);
}
// update prev
prevIndex = index;
prevY = clientY;
prevDir = newDir;
};
check(e);
const onTouchMove = (e: TouchEvent) => {
e.preventDefault();
check(e);
};
const onTouchEnd = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
};
quickSelect.addEventListener('touchstart', onTouchStart, {
passive: false,
});
return () => {
quickSelect.removeEventListener('touchstart', onTouchStart);
};
}, [data, handleToggleSelected]);
return (
<div className={styles.root}>
<PageHeader back backAction={onBack}>
<span className={styles.headerTitle}>
{title ?? t['com.affine.m.selector.title']({ type: typeText })}
</span>
</PageHeader>
<Scrollable.Root className={styles.scrollArea}>
<Scrollable.Scrollbar />
<Scrollable.Viewport>
<ul className={styles.list} ref={listRef}>
{data.map(({ id, icon, label }) => {
return (
<li
key={id}
className={styles.listItem}
onClick={() => handleToggleSelected(id)}
>
<div className={styles.listItemCheckbox}>
<Checkbox
checked={selected.includes(id)}
onChange={() => handleToggleSelected(id)}
/>
</div>
<div className={styles.listItemIcon}>{icon}</div>
<div className={styles.listItemLabel}>{label}</div>
<div className={styles.listItemArrow}>
<ArrowRightSmallIcon />
</div>
</li>
);
})}
<div className={styles.quickSelect} ref={quickScrollRef} />
</ul>
</Scrollable.Viewport>
</Scrollable.Root>
<SafeArea bottom className={styles.footer}>
<div className={styles.info}>
{!disableConfirm ? (
<div className={styles.changedInfo}>
<ChangedRenderer
added={added.length}
removed={removed.length}
type={typeText}
/>
</div>
) : null}
<div className={styles.totalInfo}>
{t['com.affine.m.selector.info-total']({
total: initial.length + '',
where: whereText,
})}
</div>
</div>
<div className={styles.actions}>
<Button
disabled={disableConfirm}
variant="primary"
className={styles.actionButton}
onClick={handleConfirm}
>
{confirmText ?? t['com.affine.m.selector.confirm-default']()}
</Button>
</div>
</SafeArea>
</div>
);
};

View File

@@ -0,0 +1,141 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
const flexCenter = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const root = style({
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
});
// header
export const headerTitle = style({
fontSize: 17,
fontWeight: 600,
letterSpacing: -0.43,
lineHeight: '22px',
color: cssVarV2('text/primary'),
});
export const scrollArea = style({
height: 0,
flex: 1,
});
// content
export const list = style({
padding: '0 8px',
position: 'relative',
});
export const quickSelect = style({
width: 32 + 8 * 2,
height: '100%',
position: 'absolute',
top: 0,
left: 0,
});
export const listItem = style({
display: 'flex',
alignItems: 'center',
padding: 8,
gap: 12,
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
});
export const listItemCheckbox = style([
flexCenter,
{
width: 32,
height: 32,
fontSize: 24,
color: cssVarV2('icon/primary'),
},
]);
export const listItemIcon = style([
flexCenter,
{
width: 32,
height: 32,
fontSize: 24,
color: cssVarV2('icon/primary'),
},
]);
export const listItemLabel = style({
fontWeight: 400,
fontSize: 17,
lineHeight: '22px',
letterSpacing: -0.43,
color: cssVarV2('text/primary'),
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: 0,
flex: 1,
});
export const listItemArrow = style({
fontSize: 16,
color: cssVarV2('icon/disable'),
});
// footer
export const footer = style({
padding: '0 16px',
borderTop: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
});
export const actions = style({
width: '100%',
padding: '8px 16px',
});
export const actionButton = style({
width: '100%',
height: 44,
borderRadius: 8,
fontSize: 17,
fontWeight: 400,
letterSpacing: -0.43,
});
export const changedInfo = style({
fontSize: 15,
fontWeight: 400,
letterSpacing: -0.23,
lineHeight: '20px',
color: cssVarV2('text/primary'),
height: 20,
});
export const totalInfo = style({
fontSize: 13,
fontWeight: 400,
letterSpacing: -0.08,
lineHeight: '18px',
color: cssVarV2('text/tertiary'),
height: 18,
});
export const info = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
height: 0,
gap: 8,
overflow: 'hidden',
transition: 'all 0.3s ease',
selectors: {
[`&:has(> ${changedInfo}), &:has(> ${totalInfo})`]: {
padding: '8px 0',
},
[`&:has(> ${totalInfo})`]: {
height: 18 + 8 * 2,
},
[`&:has(> ${changedInfo})`]: {
height: 20 + 8 * 2,
},
[`&:has(> ${changedInfo}):has(> ${totalInfo})`]: {
height: 20 + 18 + 8 + 8 * 2,
},
},
});

View File

@@ -0,0 +1,47 @@
import { useSelectDialog } from '@affine/core/components/page-list/selector';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
CollectionsSelector,
type CollectionsSelectorProps,
} from './collection-selector';
import { DocsSelector, type DocsSelectorProps } from './doc-selector';
import { TagsSelector, type TagsSelectorProps } from './tag-selector';
const options: Parameters<typeof useSelectDialog>[2] = {
modalProps: {
fullScreen: true,
width: undefined,
height: undefined,
contentOptions: {
style: {
background: cssVarV2('layer/background/secondary'),
padding: 0,
},
},
},
};
export const useSelectDoc = () => {
return useSelectDialog<string[], DocsSelectorProps>(
DocsSelector,
'select-doc-dialog',
options
);
};
export const useSelectCollection = () => {
return useSelectDialog<string[], CollectionsSelectorProps>(
CollectionsSelector,
'select-collection-dialog',
options
);
};
export const useSelectTag = () => {
return useSelectDialog<string[], TagsSelectorProps>(
TagsSelector,
'select-tag-dialog',
options
);
};

View File

@@ -0,0 +1,64 @@
import type { BaseSelectorDialogProps } from '@affine/core/components/page-list/selector';
import { TagService } from '@affine/core/modules/tag';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
import { GenericSelector, type GenericSelectorProps } from './generic-selector';
export interface TagsSelectorProps
extends BaseSelectorDialogProps<string[]>,
Pick<GenericSelectorProps, 'where' | 'type'> {}
const TagIcon = ({ tagId }: { tagId: string }) => {
const tagService = useService(TagService);
const tag = useLiveData(tagService.tagList.tagByTagId$(tagId));
const color = useLiveData(tag?.color$);
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="12" cy="12" r="4" fill={color} />
</svg>
);
};
const TagLabel = ({ tagId }: { tagId: string }) => {
const tagService = useService(TagService);
const tag = useLiveData(tagService.tagList.tagByTagId$(tagId));
const name = useLiveData(tag?.value$);
return name;
};
export const TagsSelector = ({
init = [],
onCancel,
onConfirm,
...otherProps
}: TagsSelectorProps) => {
const tagService = useService(TagService);
const tags = useLiveData(tagService.tagList.tags$);
const list = useMemo(() => {
return tags.map(tag => ({
id: tag.id,
icon: <TagIcon tagId={tag.id} />,
label: <TagLabel tagId={tag.id} />,
}));
}, [tags]);
return (
<GenericSelector
onBack={onCancel}
onConfirm={onConfirm}
initial={init}
data={list}
{...otherProps}
/>
);
};

View File

@@ -7,7 +7,7 @@ export const globalVars = {
globalStyle(':root', { globalStyle(':root', {
vars: { vars: {
[globalVars.appTabHeight]: '62px', [globalVars.appTabHeight]: BUILD_CONFIG.isIOS ? '49px' : '62px',
}, },
}); });

View File

@@ -1,5 +1,5 @@
{ {
"ar": 82, "ar": 83,
"ca": 6, "ca": 6,
"da": 6, "da": 6,
"de": 31, "de": 31,
@@ -13,10 +13,10 @@
"ja": 97, "ja": 97,
"ko": 86, "ko": 86,
"pl": 0, "pl": 0,
"pt-BR": 94, "pt-BR": 95,
"ru": 80, "ru": 81,
"sv-SE": 5, "sv-SE": 5,
"ur": 3, "ur": 3,
"zh-Hans": 98, "zh-Hans": 97,
"zh-Hant": 97 "zh-Hant": 96
} }

View File

@@ -1366,8 +1366,26 @@
"com.affine.m.explorer.tag.rename-confirm": "Done", "com.affine.m.explorer.tag.rename-confirm": "Done",
"com.affine.m.explorer.tag.new-tip-empty": "Create a tag in this workspace.", "com.affine.m.explorer.tag.new-tip-empty": "Create a tag in this workspace.",
"com.affine.m.explorer.tag.new-tip-not-empty": "Create \"{{value}}\" tag in this workspace.", "com.affine.m.explorer.tag.new-tip-not-empty": "Create \"{{value}}\" tag in this workspace.",
"com.affine.m.explorer.tag.manage-docs": "Manage Doc(s)",
"com.affine.m.explorer.collection.rename": "Rename", "com.affine.m.explorer.collection.rename": "Rename",
"com.affine.m.explorer.collection.rename-menu-title": "Rename Collection", "com.affine.m.explorer.collection.rename-menu-title": "Rename Collection",
"com.affine.m.explorer.collection.new-dialog-title": "Create Collection", "com.affine.m.explorer.collection.new-dialog-title": "Create Collection",
"com.affine.m.explorer.doc.rename": "Rename" "com.affine.m.explorer.doc.rename": "Rename",
"com.affine.m.selector.type-doc": "Doc",
"com.affine.m.selector.type-tag": "Tag",
"com.affine.m.selector.type-collection": "Collection",
"com.affine.m.selector.where-folder": "Folder",
"com.affine.m.selector.where-tag": "Tag",
"com.affine.m.selector.where-collection": "Collection",
"com.affine.m.selector.confirm-default": "Apply",
"com.affine.m.selector.title": "Manage {{type}}(s)",
"com.affine.m.selector.info-total": "{{total}} item(s) in this {{where}}",
"com.affine.m.selector.info-added": "Add {{count}} {{type}}(s)",
"com.affine.m.selector.info-removed": "Remove {{count}} {{type}}(s)",
"com.affine.m.selector.remove-warning.title": "Remove items",
"com.affine.m.selector.remove-warning.message": "You unchecked {{type}} that already exist in the current {{where}}, which means you will remove them from this {{where}}. The item will not be deleted.",
"com.affine.m.selector.remove-warning.confirm": "Do not ask again",
"com.affine.m.selector.remove-warning.cancel": "Cancel",
"com.affine.m.selector.remove-warning.where-tag": "tag",
"com.affine.m.selector.remove-warning.where-folder": "folder"
} }