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 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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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', {
|
globalStyle(':root', {
|
||||||
vars: {
|
vars: {
|
||||||
[globalVars.appTabHeight]: '62px',
|
[globalVars.appTabHeight]: BUILD_CONFIG.isIOS ? '49px' : '62px',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user