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
@@ -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
}
+19 -1
View File
@@ -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"
}