mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 14:28:51 +08:00
feat(mobile): manage docs/tags/collections in explorer (#8649)
close AF-1564, AF-1542
This commit is contained in:
@@ -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',
|
||||
}),
|
||||
};
|
||||
@@ -5,9 +5,11 @@ import { createContext, useCallback, useContext, useState } from 'react';
|
||||
|
||||
import type { ButtonProps } from '../button';
|
||||
import { Button } from '../button';
|
||||
import { desktopStyles, mobileStyles } from './confirm-modal.css';
|
||||
import type { ModalProps } from './modal';
|
||||
import { Modal } from './modal';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const styles = BUILD_CONFIG.isMobileEdition ? mobileStyles : desktopStyles;
|
||||
|
||||
export interface ConfirmModalProps extends ModalProps {
|
||||
confirmButtonOptions?: Omit<ButtonProps, 'children'>;
|
||||
@@ -36,6 +38,8 @@ export const ConfirmModal = ({
|
||||
onCancel,
|
||||
width = 480,
|
||||
autoFocusConfirm = true,
|
||||
headerClassName,
|
||||
descriptionClassName,
|
||||
...props
|
||||
}: ConfirmModalProps) => {
|
||||
const onConfirmClick = useCallback(() => {
|
||||
@@ -46,7 +50,7 @@ export const ConfirmModal = ({
|
||||
return (
|
||||
<Modal
|
||||
contentOptions={{
|
||||
className: styles.confirmModalContainer,
|
||||
className: styles.container,
|
||||
onPointerDownOutside: e => {
|
||||
e.stopPropagation();
|
||||
onCancel?.();
|
||||
@@ -56,19 +60,20 @@ export const ConfirmModal = ({
|
||||
closeButtonOptions={{
|
||||
onClick: onCancel,
|
||||
}}
|
||||
headerClassName={clsx(styles.header, headerClassName)}
|
||||
descriptionClassName={clsx(styles.description, descriptionClassName)}
|
||||
{...props}
|
||||
>
|
||||
{children ? (
|
||||
<div className={styles.confirmModalContent}>{children}</div>
|
||||
) : null}
|
||||
{children ? <div className={styles.content}>{children}</div> : null}
|
||||
<div
|
||||
className={clsx(styles.modalFooter, {
|
||||
className={clsx(styles.footer, {
|
||||
modalFooterWithChildren: !!children,
|
||||
reverse: reverseFooter,
|
||||
})}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className={styles.action}
|
||||
onClick={onCancel}
|
||||
data-testid="confirm-modal-cancel"
|
||||
{...cancelButtonOptions}
|
||||
@@ -77,6 +82,7 @@ export const ConfirmModal = ({
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<Button
|
||||
className={styles.action}
|
||||
onClick={onConfirmClick}
|
||||
data-testid="confirm-modal-confirm"
|
||||
autoFocus={autoFocusConfirm}
|
||||
|
||||
@@ -23,7 +23,9 @@ export interface ModalProps extends DialogProps {
|
||||
height?: CSSProperties['height'];
|
||||
minHeight?: CSSProperties['minHeight'];
|
||||
title?: React.ReactNode;
|
||||
headerClassName?: string;
|
||||
description?: React.ReactNode;
|
||||
descriptionClassName?: string;
|
||||
withoutCloseButton?: boolean;
|
||||
/**
|
||||
* __Click outside__ or __Press `Esc`__ won't close the modal
|
||||
@@ -130,7 +132,9 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
||||
height,
|
||||
minHeight = 194,
|
||||
title,
|
||||
headerClassName,
|
||||
description,
|
||||
descriptionClassName,
|
||||
withoutCloseButton = false,
|
||||
persistent,
|
||||
contentOptions: {
|
||||
@@ -263,7 +267,9 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
||||
</Dialog.Close>
|
||||
)}
|
||||
{title ? (
|
||||
<Dialog.Title className={styles.modalHeader}>
|
||||
<Dialog.Title
|
||||
className={clsx(styles.modalHeader, headerClassName)}
|
||||
>
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
) : (
|
||||
@@ -274,7 +280,12 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
||||
</VisuallyHidden.Root>
|
||||
)}
|
||||
{description ? (
|
||||
<Dialog.Description className={styles.modalDescription}>
|
||||
<Dialog.Description
|
||||
className={clsx(
|
||||
styles.modalDescription,
|
||||
descriptionClassName
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</Dialog.Description>
|
||||
) : null}
|
||||
|
||||
@@ -178,32 +178,6 @@ export const modalDescription = style({
|
||||
whiteSpace: 'pre-wrap',
|
||||
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}`, {
|
||||
pointerEvents: 'none',
|
||||
@@ -212,9 +186,3 @@ globalStyle(`[data-modal="false"]${modalContentWrapper}`, {
|
||||
globalStyle(`[data-modal="false"] ${modalContent}`, {
|
||||
pointerEvents: 'auto',
|
||||
});
|
||||
|
||||
export const promptModalContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
@@ -3,11 +3,7 @@ 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;
|
||||
}
|
||||
export * from './use-select-dialog';
|
||||
|
||||
/**
|
||||
* Return a `open` function to open the select collection dialog.
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Modal } from '@affine/component';
|
||||
import { Modal, type ModalProps } from '@affine/component';
|
||||
import { useMount } from '@toeverything/infra';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
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>(
|
||||
Component: React.FC<BaseSelectorDialogProps<T>>,
|
||||
debugKey?: string
|
||||
const defaultModalProps: Partial<Omit<ModalProps, 'children'>> = {};
|
||||
export const useSelectDialog = function useSelectDialog<T, P>(
|
||||
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
|
||||
// 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;
|
||||
onConfirm: (v: T) => void;
|
||||
}>();
|
||||
const [additionalProps, setAdditionalProps] = useState<P>();
|
||||
|
||||
const onOpenChanged = useCallback((open: boolean) => {
|
||||
if (!open) setValue(undefined);
|
||||
@@ -28,9 +37,10 @@ export const useSelectDialog = function useSelectDialog<T>(
|
||||
* Open a dialog to select items
|
||||
*/
|
||||
const open = useCallback(
|
||||
(ids?: T) => {
|
||||
(ids?: T, additionalProps?: P) => {
|
||||
return new Promise<T>(resolve => {
|
||||
setShow(true);
|
||||
setAdditionalProps(additionalProps);
|
||||
setValue({
|
||||
init: ids,
|
||||
onConfirm: list => {
|
||||
@@ -46,6 +56,9 @@ export const useSelectDialog = function useSelectDialog<T>(
|
||||
const { mount } = useMount(debugKey);
|
||||
|
||||
useEffect(() => {
|
||||
const { contentOptions, ...otherModalProps } =
|
||||
options?.modalProps ?? defaultModalProps;
|
||||
|
||||
return mount(
|
||||
<Modal
|
||||
open={show}
|
||||
@@ -59,18 +72,31 @@ export const useSelectDialog = function useSelectDialog<T>(
|
||||
maxWidth: 976,
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
},
|
||||
...contentOptions,
|
||||
}}
|
||||
{...otherModalProps}
|
||||
>
|
||||
{value ? (
|
||||
<Component
|
||||
init={value.init}
|
||||
onCancel={close}
|
||||
onConfirm={value.onConfirm}
|
||||
{...(additionalProps as any)}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}, [Component, close, mount, onOpenChanged, show, value]);
|
||||
}, [
|
||||
Component,
|
||||
additionalProps,
|
||||
close,
|
||||
debugKey,
|
||||
mount,
|
||||
onOpenChanged,
|
||||
options?.modalProps,
|
||||
show,
|
||||
value,
|
||||
]);
|
||||
|
||||
return open;
|
||||
};
|
||||
|
||||
@@ -7,11 +7,6 @@ import {
|
||||
notify,
|
||||
} from '@affine/component';
|
||||
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 {
|
||||
ExplorerTreeNodeIcon,
|
||||
NodeOperation,
|
||||
@@ -42,6 +37,11 @@ import {
|
||||
import { difference } from 'lodash-es';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
useSelectCollection,
|
||||
useSelectDoc,
|
||||
useSelectTag,
|
||||
} from '../../../selector';
|
||||
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
|
||||
import { ExplorerTreeNode } from '../../tree/node';
|
||||
import { ExplorerCollectionNode } from '../collection';
|
||||
@@ -195,7 +195,7 @@ const ExplorerFolderNodeFolder = ({
|
||||
: type === 'collection'
|
||||
? openCollectionsSelector
|
||||
: openTagsSelector;
|
||||
selector(initialIds)
|
||||
selector(initialIds, { type, where: 'folder' })
|
||||
.then(selectedIds => {
|
||||
const newItemIds = difference(selectedIds, initialIds);
|
||||
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,
|
||||
view: (
|
||||
@@ -307,6 +296,12 @@ const ExplorerFolderNodeFolder = ({
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<MenuItem
|
||||
prefixIcon={<PageIcon />}
|
||||
onClick={() => handleAddToFolder('doc')}
|
||||
>
|
||||
{t['com.affine.rootAppSidebar.organize.folder.add-docs']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => handleAddToFolder('tag')}
|
||||
prefixIcon={<TagsIcon />}
|
||||
|
||||
@@ -7,7 +7,12 @@ import { TagService } from '@affine/core/modules/tag';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import { DeleteIcon, PlusIcon, SplitViewIcon } from '@blocksuite/icons/rc';
|
||||
import {
|
||||
DeleteIcon,
|
||||
FolderIcon,
|
||||
PlusIcon,
|
||||
SplitViewIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import {
|
||||
DocsService,
|
||||
FeatureFlagService,
|
||||
@@ -18,6 +23,7 @@ import {
|
||||
} from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useSelectDoc } from '../../../selector';
|
||||
import { TagRenameSubMenu } from './dialog';
|
||||
|
||||
export const useExplorerTagNodeOperations = (
|
||||
@@ -29,6 +35,7 @@ export const useExplorerTagNodeOperations = (
|
||||
}
|
||||
) => {
|
||||
const t = useI18n();
|
||||
const openDocSelector = useSelectDoc();
|
||||
const { workbenchService, workspaceService, tagService, favoriteService } =
|
||||
useServices({
|
||||
WorkbenchService,
|
||||
@@ -113,6 +120,17 @@ export const useExplorerTagNodeOperations = (
|
||||
},
|
||||
[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(
|
||||
() => ({
|
||||
@@ -125,6 +143,7 @@ export const useExplorerTagNodeOperations = (
|
||||
handleRename,
|
||||
handleChangeColor,
|
||||
handleChangeNameOrColor,
|
||||
handleOpenDocSelector,
|
||||
}),
|
||||
[
|
||||
favorite,
|
||||
@@ -136,6 +155,7 @@ export const useExplorerTagNodeOperations = (
|
||||
handleOpenInSplitView,
|
||||
handleRename,
|
||||
handleToggleFavoriteTag,
|
||||
handleOpenDocSelector,
|
||||
]
|
||||
);
|
||||
};
|
||||
@@ -157,6 +177,7 @@ export const useExplorerTagNodeOperationsMenu = (
|
||||
handleOpenInSplitView,
|
||||
handleToggleFavoriteTag,
|
||||
handleChangeNameOrColor,
|
||||
handleOpenDocSelector,
|
||||
} = useExplorerTagNodeOperations(tagId, option);
|
||||
|
||||
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
|
||||
? [
|
||||
{
|
||||
@@ -231,6 +264,7 @@ export const useExplorerTagNodeOperationsMenu = (
|
||||
handleChangeNameOrColor,
|
||||
handleMoveToTrash,
|
||||
handleNewDoc,
|
||||
handleOpenDocSelector,
|
||||
handleOpenInSplitView,
|
||||
handleToggleFavoriteTag,
|
||||
t,
|
||||
|
||||
@@ -4,5 +4,6 @@ export * from './page-header';
|
||||
export * from './rename';
|
||||
export * from './search-input';
|
||||
export * from './search-result';
|
||||
export * from './selector';
|
||||
export * from './user-plan-tag';
|
||||
export * from './workspace-selector';
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,7 @@ export const globalVars = {
|
||||
|
||||
globalStyle(':root', {
|
||||
vars: {
|
||||
[globalVars.appTabHeight]: '62px',
|
||||
[globalVars.appTabHeight]: BUILD_CONFIG.isIOS ? '49px' : '62px',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"ar": 82,
|
||||
"ar": 83,
|
||||
"ca": 6,
|
||||
"da": 6,
|
||||
"de": 31,
|
||||
@@ -13,10 +13,10 @@
|
||||
"ja": 97,
|
||||
"ko": 86,
|
||||
"pl": 0,
|
||||
"pt-BR": 94,
|
||||
"ru": 80,
|
||||
"pt-BR": 95,
|
||||
"ru": 81,
|
||||
"sv-SE": 5,
|
||||
"ur": 3,
|
||||
"zh-Hans": 98,
|
||||
"zh-Hant": 97
|
||||
"zh-Hans": 97,
|
||||
"zh-Hant": 96
|
||||
}
|
||||
@@ -1366,8 +1366,26 @@
|
||||
"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-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-menu-title": "Rename 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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user