mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-16 09:36:17 +08:00
feat(core): init organize (#7456)
This commit is contained in:
@@ -1,26 +1,6 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { createContext } from 'react';
|
||||
|
||||
import type { PagePropertiesManager } from './page-properties-manager';
|
||||
|
||||
// @ts-expect-error this should always be set
|
||||
export const managerContext = createContext<PagePropertiesManager>();
|
||||
|
||||
type TagColorHelper<T> = T extends `paletteLine${infer Color}` ? Color : never;
|
||||
export type TagColorName = TagColorHelper<Parameters<typeof cssVar>[0]>;
|
||||
|
||||
const tagColorIds: TagColorName[] = [
|
||||
'Red',
|
||||
'Magenta',
|
||||
'Orange',
|
||||
'Yellow',
|
||||
'Green',
|
||||
'Teal',
|
||||
'Blue',
|
||||
'Purple',
|
||||
'Grey',
|
||||
];
|
||||
|
||||
export const tagColors = tagColorIds.map(
|
||||
color => [color, cssVar(`paletteLine${color}`)] as const
|
||||
);
|
||||
|
||||
+14
-16
@@ -5,12 +5,11 @@ import {
|
||||
Scrollable,
|
||||
} from '@affine/component';
|
||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||
import type { Doc } from '@blocksuite/store';
|
||||
import {
|
||||
LiveData,
|
||||
useLiveData,
|
||||
useService,
|
||||
type Workspace,
|
||||
useServices,
|
||||
WorkspaceService,
|
||||
} from '@toeverything/infra';
|
||||
import { Suspense, useCallback, useContext, useMemo, useRef } from 'react';
|
||||
|
||||
@@ -30,27 +29,26 @@ import { TimeRow } from './time-row';
|
||||
export const InfoModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
page,
|
||||
workspace,
|
||||
docId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
page: Doc;
|
||||
workspace: Workspace;
|
||||
docId: string;
|
||||
}) => {
|
||||
const { docsSearchService, workspaceService } = useServices({
|
||||
DocsSearchService,
|
||||
WorkspaceService,
|
||||
});
|
||||
const titleInputHandleRef = useRef<InlineEditHandle>(null);
|
||||
|
||||
const manager = usePagePropertiesManager(page);
|
||||
|
||||
const manager = usePagePropertiesManager(docId);
|
||||
const handleClose = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const docsSearchService = useService(DocsSearchService);
|
||||
const references = useLiveData(
|
||||
useMemo(
|
||||
() => LiveData.from(docsSearchService.watchRefsFrom(page.id), null),
|
||||
[docsSearchService, page.id]
|
||||
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
|
||||
[docId, docsSearchService]
|
||||
)
|
||||
);
|
||||
|
||||
@@ -76,14 +74,14 @@ export const InfoModal = ({
|
||||
<BlocksuiteHeaderTitle
|
||||
className={styles.titleStyle}
|
||||
inputHandleRef={titleInputHandleRef}
|
||||
pageId={page.id}
|
||||
docCollection={workspace.docCollection}
|
||||
pageId={docId}
|
||||
docCollection={workspaceService.workspace.docCollection}
|
||||
/>
|
||||
</div>
|
||||
<managerContext.Provider value={manager}>
|
||||
<Suspense>
|
||||
<InfoTable
|
||||
docId={page.id}
|
||||
docId={docId}
|
||||
onClose={handleClose}
|
||||
references={references}
|
||||
readonly={manager.readonly}
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
ToggleExpandIcon,
|
||||
ViewIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import type { Doc } from '@blocksuite/store';
|
||||
import type { DragEndEvent, DraggableAttributes } from '@dnd-kit/core';
|
||||
import {
|
||||
DndContext,
|
||||
@@ -1085,21 +1084,21 @@ const PagePropertiesTableInner = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const usePagePropertiesManager = (page: Doc) => {
|
||||
export const usePagePropertiesManager = (docId: string) => {
|
||||
// the workspace properties adapter adapter is reactive,
|
||||
// which means it's reference will change when any of the properties change
|
||||
// also it will trigger a re-render of the component
|
||||
const adapter = useCurrentWorkspacePropertiesAdapter();
|
||||
const manager = useMemo(() => {
|
||||
return new PagePropertiesManager(adapter, page.id);
|
||||
}, [adapter, page.id]);
|
||||
return new PagePropertiesManager(adapter, docId);
|
||||
}, [adapter, docId]);
|
||||
return manager;
|
||||
};
|
||||
|
||||
// this is the main component that renders the page properties table at the top of the page below
|
||||
// the page title
|
||||
export const PagePropertiesTable = ({ page }: { page: Doc }) => {
|
||||
const manager = usePagePropertiesManager(page);
|
||||
export const PagePropertiesTable = ({ docId }: { docId: string }) => {
|
||||
const manager = usePagePropertiesManager(docId);
|
||||
|
||||
// if the given page is not in the current workspace, then we don't render anything
|
||||
// eg. when it is in history modal
|
||||
|
||||
+11
-7
@@ -13,7 +13,6 @@ import type { HTMLAttributes, PropsWithChildren } from 'react';
|
||||
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
|
||||
|
||||
import { TagItem, TempTagItem } from '../../page-list';
|
||||
import { tagColors } from './common';
|
||||
import type { MenuItemOption } from './menu-items';
|
||||
import { renderMenuItemOptions } from './menu-items';
|
||||
import * as styles from './tags-inline-editor.css';
|
||||
@@ -80,7 +79,8 @@ export const EditTagMenu = ({
|
||||
}>) => {
|
||||
const t = useI18n();
|
||||
const legacyProperties = useService(WorkspaceLegacyProperties);
|
||||
const tagList = useService(TagService).tagList;
|
||||
const tagService = useService(TagService);
|
||||
const tagList = tagService.tagList;
|
||||
const tag = useLiveData(tagList.tagByTagId$(tagId));
|
||||
const tagColor = useLiveData(tag?.color$);
|
||||
const tagValue = useLiveData(tag?.value$);
|
||||
@@ -133,7 +133,7 @@ export const EditTagMenu = ({
|
||||
options.push('-');
|
||||
|
||||
options.push(
|
||||
tagColors.map(([name, color], i) => {
|
||||
tagService.tagColors.map(([name, color], i) => {
|
||||
return {
|
||||
text: name,
|
||||
icon: (
|
||||
@@ -170,6 +170,7 @@ export const EditTagMenu = ({
|
||||
t,
|
||||
tag,
|
||||
tagColor,
|
||||
tagService.tagColors,
|
||||
tagValue,
|
||||
]);
|
||||
|
||||
@@ -185,7 +186,8 @@ const isCreateNewTag = (
|
||||
|
||||
export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
|
||||
const t = useI18n();
|
||||
const tagList = useService(TagService).tagList;
|
||||
const tagService = useService(TagService);
|
||||
const tagList = tagService.tagList;
|
||||
const tags = useLiveData(tagList.tags$);
|
||||
const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
@@ -265,10 +267,12 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
|
||||
|
||||
const [nextColor, rotateNextColor] = useReducer(
|
||||
color => {
|
||||
const idx = tagColors.findIndex(c => c[1] === color);
|
||||
return tagColors[(idx + 1) % tagColors.length][1];
|
||||
const idx = tagService.tagColors.findIndex(c => c[1] === color);
|
||||
return tagService.tagColors[(idx + 1) % tagService.tagColors.length][1];
|
||||
},
|
||||
tagColors[Math.floor(Math.random() * tagColors.length)][1]
|
||||
tagService.tagColors[
|
||||
Math.floor(Math.random() * tagService.tagColors.length)
|
||||
][1]
|
||||
);
|
||||
|
||||
const onCreateTag = useCallback(
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import clsx from 'clsx';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { type ForwardedRef, forwardRef, type PropsWithChildren } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
|
||||
interface CategoryDividerProps extends PropsWithChildren {
|
||||
label: string;
|
||||
}
|
||||
export type CategoryDividerProps = PropsWithChildren<
|
||||
{
|
||||
label: string;
|
||||
className?: string;
|
||||
} & {
|
||||
[key: `data-${string}`]: unknown;
|
||||
}
|
||||
>;
|
||||
|
||||
export function CategoryDivider({ label, children }: CategoryDividerProps) {
|
||||
return (
|
||||
<div className={clsx([styles.root])}>
|
||||
<div className={styles.label}>{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export const CategoryDivider = forwardRef(
|
||||
(
|
||||
{ label, children, className, ...otherProps }: CategoryDividerProps,
|
||||
ref: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
return (
|
||||
<div className={clsx([styles.root, className])} ref={ref} {...otherProps}>
|
||||
<div className={styles.label}>{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CategoryDivider.displayName = 'CategoryDivider';
|
||||
|
||||
@@ -193,7 +193,7 @@ export const BlocksuiteDocEditor = forwardRef<
|
||||
) : (
|
||||
<BlocksuiteEditorJournalDocTitle page={page} />
|
||||
)}
|
||||
<PagePropertiesTable page={page} />
|
||||
<PagePropertiesTable docId={page.id} />
|
||||
<adapted.DocEditor
|
||||
className={styles.docContainer}
|
||||
ref={onDocRef}
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ export const root = style({
|
||||
});
|
||||
|
||||
export const dragPageItemOverlay = style({
|
||||
height: '54px',
|
||||
height: '45px',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
+123
-109
@@ -1,17 +1,12 @@
|
||||
import { Checkbox } from '@affine/component';
|
||||
import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { Checkbox, useDraggable } from '@affine/component';
|
||||
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ForwardedRef, PropsWithChildren } from 'react';
|
||||
import { forwardRef, useCallback, useMemo } from 'react';
|
||||
|
||||
import { selectionStateAtom, useAtom } from '../scoped-atoms';
|
||||
import type {
|
||||
CollectionListItemProps,
|
||||
DraggableTitleCellData,
|
||||
PageListItemProps,
|
||||
} from '../types';
|
||||
import type { CollectionListItemProps, PageListItemProps } from '../types';
|
||||
import { ColWrapper, stopPropagation } from '../utils';
|
||||
import * as styles from './collection-list-item.css';
|
||||
|
||||
@@ -109,56 +104,64 @@ export const CollectionListItem = (props: CollectionListItemProps) => {
|
||||
props.title,
|
||||
]);
|
||||
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: getDNDId('collection-list', 'collection', props.collectionId),
|
||||
data: {
|
||||
preview: collectionTitleElement,
|
||||
} satisfies DraggableTitleCellData,
|
||||
disabled: !props.draggable,
|
||||
});
|
||||
const { dragRef, dragging, CustomDragPreview } = useDraggable<AffineDNDData>(
|
||||
() => ({
|
||||
data: {
|
||||
entity: {
|
||||
type: 'collection',
|
||||
id: props.collectionId,
|
||||
},
|
||||
from: {
|
||||
at: 'all-collections:list',
|
||||
},
|
||||
},
|
||||
canDrag: props.draggable,
|
||||
}),
|
||||
[props.collectionId, props.draggable]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollectionListItemWrapper
|
||||
onClick={props.onClick}
|
||||
to={props.to}
|
||||
collectionId={props.collectionId}
|
||||
draggable={props.draggable}
|
||||
isDragging={isDragging}
|
||||
>
|
||||
<ColWrapper flex={9}>
|
||||
<ColWrapper
|
||||
className={styles.dndCell}
|
||||
flex={8}
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<CollectionSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<ListIconCell icon={props.icon} />
|
||||
</div>
|
||||
<ListTitleCell title={props.title} />
|
||||
<>
|
||||
<CollectionListItemWrapper
|
||||
onClick={props.onClick}
|
||||
to={props.to}
|
||||
collectionId={props.collectionId}
|
||||
draggable={props.draggable}
|
||||
isDragging={dragging}
|
||||
ref={dragRef}
|
||||
>
|
||||
<ColWrapper flex={9}>
|
||||
<ColWrapper className={styles.dndCell} flex={8}>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<CollectionSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<ListIconCell icon={props.icon} />
|
||||
</div>
|
||||
<ListTitleCell title={props.title} />
|
||||
</ColWrapper>
|
||||
<ColWrapper
|
||||
flex={4}
|
||||
alignment="end"
|
||||
style={{ overflow: 'visible' }}
|
||||
></ColWrapper>
|
||||
</ColWrapper>
|
||||
<ColWrapper
|
||||
flex={4}
|
||||
alignment="end"
|
||||
style={{ overflow: 'visible' }}
|
||||
></ColWrapper>
|
||||
</ColWrapper>
|
||||
{props.operations ? (
|
||||
<ColWrapper
|
||||
className={styles.actionsCellWrapper}
|
||||
flex={3}
|
||||
alignment="end"
|
||||
>
|
||||
<CollectionListOperationsCell operations={props.operations} />
|
||||
</ColWrapper>
|
||||
) : null}
|
||||
</CollectionListItemWrapper>
|
||||
{props.operations ? (
|
||||
<ColWrapper
|
||||
className={styles.actionsCellWrapper}
|
||||
flex={3}
|
||||
alignment="end"
|
||||
>
|
||||
<CollectionListOperationsCell operations={props.operations} />
|
||||
</ColWrapper>
|
||||
) : null}
|
||||
</CollectionListItemWrapper>
|
||||
<CustomDragPreview position="pointer-outside">
|
||||
{collectionTitleElement}
|
||||
</CustomDragPreview>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -171,58 +174,69 @@ type collectionListWrapperProps = PropsWithChildren<
|
||||
}
|
||||
>;
|
||||
|
||||
function CollectionListItemWrapper({
|
||||
to,
|
||||
isDragging,
|
||||
collectionId,
|
||||
onClick,
|
||||
children,
|
||||
draggable,
|
||||
}: collectionListWrapperProps) {
|
||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!selectionState.selectable) {
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
stopPropagation(e);
|
||||
setSelectionActive(true);
|
||||
onClick?.();
|
||||
return;
|
||||
}
|
||||
if (selectionState.selectionActive) {
|
||||
return onClick?.();
|
||||
}
|
||||
},
|
||||
[
|
||||
const CollectionListItemWrapper = forwardRef(
|
||||
(
|
||||
{
|
||||
to,
|
||||
isDragging,
|
||||
collectionId,
|
||||
onClick,
|
||||
selectionState.selectable,
|
||||
selectionState.selectionActive,
|
||||
setSelectionActive,
|
||||
]
|
||||
);
|
||||
|
||||
const commonProps = useMemo(
|
||||
() => ({
|
||||
'data-testid': 'collection-list-item',
|
||||
'data-collection-id': collectionId,
|
||||
'data-draggable': draggable,
|
||||
className: styles.root,
|
||||
'data-clickable': !!onClick || !!to,
|
||||
'data-dragging': isDragging,
|
||||
onClick: handleClick,
|
||||
}),
|
||||
[collectionId, draggable, isDragging, onClick, to, handleClick]
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<WorkbenchLink {...commonProps} to={to}>
|
||||
{children}
|
||||
</WorkbenchLink>
|
||||
children,
|
||||
draggable,
|
||||
}: collectionListWrapperProps,
|
||||
ref: ForwardedRef<HTMLAnchorElement & HTMLDivElement>
|
||||
) => {
|
||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!selectionState.selectable) {
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
stopPropagation(e);
|
||||
setSelectionActive(true);
|
||||
onClick?.();
|
||||
return;
|
||||
}
|
||||
if (selectionState.selectionActive) {
|
||||
return onClick?.();
|
||||
}
|
||||
},
|
||||
[
|
||||
onClick,
|
||||
selectionState.selectable,
|
||||
selectionState.selectionActive,
|
||||
setSelectionActive,
|
||||
]
|
||||
);
|
||||
} else {
|
||||
return <div {...commonProps}>{children}</div>;
|
||||
|
||||
const commonProps = useMemo(
|
||||
() => ({
|
||||
'data-testid': 'collection-list-item',
|
||||
'data-collection-id': collectionId,
|
||||
'data-draggable': draggable,
|
||||
className: styles.root,
|
||||
'data-clickable': !!onClick || !!to,
|
||||
'data-dragging': isDragging,
|
||||
onClick: handleClick,
|
||||
}),
|
||||
[collectionId, draggable, isDragging, onClick, to, handleClick]
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<WorkbenchLink {...commonProps} to={to} ref={ref}>
|
||||
{children}
|
||||
</WorkbenchLink>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div {...commonProps} ref={ref}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
CollectionListItemWrapper.displayName = 'CollectionListItemWrapper';
|
||||
|
||||
@@ -23,8 +23,8 @@ export const root = style({
|
||||
});
|
||||
|
||||
export const dragPageItemOverlay = style({
|
||||
height: '54px',
|
||||
borderRadius: '10px',
|
||||
height: '45px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: cssVar('hoverColorFilled'),
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Checkbox, Tooltip } from '@affine/component';
|
||||
import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { Checkbox, Tooltip, useDraggable } from '@affine/component';
|
||||
import { TagService } from '@affine/core/modules/tag';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { i18nTime } from '@affine/i18n';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import type { ForwardedRef, PropsWithChildren } from 'react';
|
||||
import { forwardRef, useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { WorkbenchLink } from '../../../modules/workbench/view/workbench-link';
|
||||
import {
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
selectionStateAtom,
|
||||
useAtom,
|
||||
} from '../scoped-atoms';
|
||||
import type { DraggableTitleCellData, PageListItemProps } from '../types';
|
||||
import type { PageListItemProps } from '../types';
|
||||
import { useAllDocDisplayProperties } from '../use-all-doc-display-properties';
|
||||
import { ColWrapper, stopPropagation } from '../utils';
|
||||
import * as styles from './page-list-item.css';
|
||||
@@ -167,76 +166,84 @@ export const PageListItem = (props: PageListItemProps) => {
|
||||
props.title,
|
||||
]);
|
||||
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: getDNDId('doc-list', 'doc', props.pageId),
|
||||
data: {
|
||||
preview: pageTitleElement,
|
||||
} satisfies DraggableTitleCellData,
|
||||
disabled: !props.draggable,
|
||||
});
|
||||
const { dragRef, CustomDragPreview, dragging } = useDraggable<AffineDNDData>(
|
||||
() => ({
|
||||
canDrag: props.draggable,
|
||||
data: {
|
||||
entity: {
|
||||
type: 'doc',
|
||||
id: props.pageId,
|
||||
},
|
||||
from: {
|
||||
at: 'all-docs:list',
|
||||
},
|
||||
},
|
||||
}),
|
||||
[props.draggable, props.pageId]
|
||||
);
|
||||
|
||||
return (
|
||||
<PageListItemWrapper
|
||||
onClick={props.onClick}
|
||||
to={props.to}
|
||||
pageId={props.pageId}
|
||||
draggable={props.draggable}
|
||||
isDragging={isDragging}
|
||||
pageIds={props.pageIds || []}
|
||||
>
|
||||
<ColWrapper flex={9}>
|
||||
<ColWrapper
|
||||
className={styles.dndCell}
|
||||
flex={8}
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<PageSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<ListIconCell icon={props.icon} />
|
||||
</div>
|
||||
<ListTitleCell title={props.title} preview={props.preview} />
|
||||
<>
|
||||
<PageListItemWrapper
|
||||
onClick={props.onClick}
|
||||
to={props.to}
|
||||
pageId={props.pageId}
|
||||
draggable={props.draggable}
|
||||
isDragging={dragging}
|
||||
ref={dragRef}
|
||||
pageIds={props.pageIds || []}
|
||||
>
|
||||
<ColWrapper flex={9}>
|
||||
<ColWrapper className={styles.dndCell} flex={8}>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<PageSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<ListIconCell icon={props.icon} />
|
||||
</div>
|
||||
<ListTitleCell title={props.title} preview={props.preview} />
|
||||
</ColWrapper>
|
||||
<ColWrapper
|
||||
flex={4}
|
||||
alignment="end"
|
||||
style={{ overflow: 'visible' }}
|
||||
hidden={!displayProperties.displayProperties.tags}
|
||||
>
|
||||
<PageTagsCell pageId={props.pageId} />
|
||||
</ColWrapper>
|
||||
</ColWrapper>
|
||||
<ColWrapper
|
||||
flex={4}
|
||||
alignment="end"
|
||||
style={{ overflow: 'visible' }}
|
||||
hidden={!displayProperties.displayProperties.tags}
|
||||
>
|
||||
<PageTagsCell pageId={props.pageId} />
|
||||
</ColWrapper>
|
||||
</ColWrapper>
|
||||
<ColWrapper
|
||||
flex={1}
|
||||
alignment="end"
|
||||
hideInSmallContainer
|
||||
hidden={!displayProperties.displayProperties.createDate}
|
||||
>
|
||||
<PageCreateDateCell createDate={props.createDate} />
|
||||
</ColWrapper>
|
||||
<ColWrapper
|
||||
flex={1}
|
||||
alignment="end"
|
||||
hideInSmallContainer
|
||||
hidden={!displayProperties.displayProperties.updatedDate}
|
||||
>
|
||||
<PageUpdatedDateCell updatedDate={props.updatedDate} />
|
||||
</ColWrapper>
|
||||
{props.operations ? (
|
||||
<ColWrapper
|
||||
className={styles.actionsCellWrapper}
|
||||
flex={1}
|
||||
alignment="end"
|
||||
hideInSmallContainer
|
||||
hidden={!displayProperties.displayProperties.createDate}
|
||||
>
|
||||
<PageListOperationsCell operations={props.operations} />
|
||||
<PageCreateDateCell createDate={props.createDate} />
|
||||
</ColWrapper>
|
||||
) : null}
|
||||
</PageListItemWrapper>
|
||||
<ColWrapper
|
||||
flex={1}
|
||||
alignment="end"
|
||||
hideInSmallContainer
|
||||
hidden={!displayProperties.displayProperties.updatedDate}
|
||||
>
|
||||
<PageUpdatedDateCell updatedDate={props.updatedDate} />
|
||||
</ColWrapper>
|
||||
{props.operations ? (
|
||||
<ColWrapper
|
||||
className={styles.actionsCellWrapper}
|
||||
flex={1}
|
||||
alignment="end"
|
||||
>
|
||||
<PageListOperationsCell operations={props.operations} />
|
||||
</ColWrapper>
|
||||
) : null}
|
||||
</PageListItemWrapper>
|
||||
<CustomDragPreview position="pointer-outside">
|
||||
{pageTitleElement}
|
||||
</CustomDragPreview>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -247,132 +254,142 @@ type PageListWrapperProps = PropsWithChildren<
|
||||
}
|
||||
>;
|
||||
|
||||
function PageListItemWrapper({
|
||||
to,
|
||||
isDragging,
|
||||
pageId,
|
||||
pageIds,
|
||||
onClick,
|
||||
children,
|
||||
draggable,
|
||||
}: PageListWrapperProps) {
|
||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||
const [anchorIndex, setAnchorIndex] = useAtom(anchorIndexAtom);
|
||||
const [rangeIds, setRangeIds] = useAtom(rangeIdsAtom);
|
||||
|
||||
const handleShiftClick = useCallback(
|
||||
(currentIndex: number) => {
|
||||
if (anchorIndex === undefined) {
|
||||
setAnchorIndex(currentIndex);
|
||||
onClick?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerIndex = Math.min(anchorIndex, currentIndex);
|
||||
const upperIndex = Math.max(anchorIndex, currentIndex);
|
||||
const newRangeIds = pageIds.slice(lowerIndex, upperIndex + 1);
|
||||
|
||||
const currentSelected = selectionState.selectedIds || [];
|
||||
|
||||
// Set operations
|
||||
const setRange = new Set(rangeIds);
|
||||
const newSelected = new Set(
|
||||
currentSelected.filter(id => !setRange.has(id)).concat(newRangeIds)
|
||||
);
|
||||
|
||||
selectionState.onSelectedIdsChange?.([...newSelected]);
|
||||
setRangeIds(newRangeIds);
|
||||
},
|
||||
[
|
||||
anchorIndex,
|
||||
onClick,
|
||||
pageIds,
|
||||
selectionState,
|
||||
setAnchorIndex,
|
||||
rangeIds,
|
||||
setRangeIds,
|
||||
]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!selectionState.selectable) {
|
||||
return;
|
||||
}
|
||||
stopPropagation(e);
|
||||
const currentIndex = pageIds.indexOf(pageId);
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (!selectionState.selectionActive) {
|
||||
setSelectionActive(true);
|
||||
setAnchorIndex(currentIndex);
|
||||
onClick?.();
|
||||
return false;
|
||||
}
|
||||
handleShiftClick(currentIndex);
|
||||
return false;
|
||||
} else {
|
||||
setAnchorIndex(undefined);
|
||||
setRangeIds([]);
|
||||
onClick?.();
|
||||
return;
|
||||
}
|
||||
},
|
||||
[
|
||||
handleShiftClick,
|
||||
onClick,
|
||||
const PageListItemWrapper = forwardRef(
|
||||
(
|
||||
{
|
||||
to,
|
||||
isDragging,
|
||||
pageId,
|
||||
pageIds,
|
||||
selectionState.selectable,
|
||||
onClick,
|
||||
children,
|
||||
draggable,
|
||||
}: PageListWrapperProps,
|
||||
ref: ForwardedRef<HTMLAnchorElement & HTMLDivElement>
|
||||
) => {
|
||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||
const [anchorIndex, setAnchorIndex] = useAtom(anchorIndexAtom);
|
||||
const [rangeIds, setRangeIds] = useAtom(rangeIdsAtom);
|
||||
|
||||
const handleShiftClick = useCallback(
|
||||
(currentIndex: number) => {
|
||||
if (anchorIndex === undefined) {
|
||||
setAnchorIndex(currentIndex);
|
||||
onClick?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerIndex = Math.min(anchorIndex, currentIndex);
|
||||
const upperIndex = Math.max(anchorIndex, currentIndex);
|
||||
const newRangeIds = pageIds.slice(lowerIndex, upperIndex + 1);
|
||||
|
||||
const currentSelected = selectionState.selectedIds || [];
|
||||
|
||||
// Set operations
|
||||
const setRange = new Set(rangeIds);
|
||||
const newSelected = new Set(
|
||||
currentSelected.filter(id => !setRange.has(id)).concat(newRangeIds)
|
||||
);
|
||||
|
||||
selectionState.onSelectedIdsChange?.([...newSelected]);
|
||||
setRangeIds(newRangeIds);
|
||||
},
|
||||
[
|
||||
anchorIndex,
|
||||
onClick,
|
||||
pageIds,
|
||||
selectionState,
|
||||
setAnchorIndex,
|
||||
rangeIds,
|
||||
setRangeIds,
|
||||
]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!selectionState.selectable) {
|
||||
return;
|
||||
}
|
||||
stopPropagation(e);
|
||||
const currentIndex = pageIds.indexOf(pageId);
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (!selectionState.selectionActive) {
|
||||
setSelectionActive(true);
|
||||
setAnchorIndex(currentIndex);
|
||||
onClick?.();
|
||||
return false;
|
||||
}
|
||||
handleShiftClick(currentIndex);
|
||||
return false;
|
||||
} else {
|
||||
setAnchorIndex(undefined);
|
||||
setRangeIds([]);
|
||||
onClick?.();
|
||||
return;
|
||||
}
|
||||
},
|
||||
[
|
||||
handleShiftClick,
|
||||
onClick,
|
||||
pageId,
|
||||
pageIds,
|
||||
selectionState.selectable,
|
||||
selectionState.selectionActive,
|
||||
setAnchorIndex,
|
||||
setRangeIds,
|
||||
setSelectionActive,
|
||||
]
|
||||
);
|
||||
|
||||
const commonProps = useMemo(
|
||||
() => ({
|
||||
'data-testid': 'page-list-item',
|
||||
'data-page-id': pageId,
|
||||
'data-draggable': draggable,
|
||||
className: styles.root,
|
||||
'data-clickable': !!onClick || !!to,
|
||||
'data-dragging': isDragging,
|
||||
onClick: onClick ? handleClick : undefined,
|
||||
}),
|
||||
[pageId, draggable, onClick, to, isDragging, handleClick]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionState.selectionActive) {
|
||||
// listen for shift key up
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
setAnchorIndex(undefined);
|
||||
setRangeIds([]);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [
|
||||
selectionState.selectionActive,
|
||||
setAnchorIndex,
|
||||
setRangeIds,
|
||||
setSelectionActive,
|
||||
]
|
||||
);
|
||||
]);
|
||||
|
||||
const commonProps = useMemo(
|
||||
() => ({
|
||||
'data-testid': 'page-list-item',
|
||||
'data-page-id': pageId,
|
||||
'data-draggable': draggable,
|
||||
className: styles.root,
|
||||
'data-clickable': !!onClick || !!to,
|
||||
'data-dragging': isDragging,
|
||||
onClick: onClick ? handleClick : undefined,
|
||||
}),
|
||||
[pageId, draggable, onClick, to, isDragging, handleClick]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionState.selectionActive) {
|
||||
// listen for shift key up
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
setAnchorIndex(undefined);
|
||||
setRangeIds([]);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
if (to) {
|
||||
return (
|
||||
<WorkbenchLink ref={ref} {...commonProps} to={to}>
|
||||
{children}
|
||||
</WorkbenchLink>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div ref={ref} {...commonProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return;
|
||||
}, [
|
||||
selectionState.selectionActive,
|
||||
setAnchorIndex,
|
||||
setRangeIds,
|
||||
setSelectionActive,
|
||||
]);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<WorkbenchLink {...commonProps} to={to}>
|
||||
{children}
|
||||
</WorkbenchLink>
|
||||
);
|
||||
} else {
|
||||
return <div {...commonProps}>{children}</div>;
|
||||
}
|
||||
}
|
||||
);
|
||||
PageListItemWrapper.displayName = 'PageListItemWrapper';
|
||||
|
||||
@@ -239,8 +239,7 @@ export const PageOperationCell = ({
|
||||
<InfoModal
|
||||
open={openInfoModal}
|
||||
onOpenChange={setOpenInfoModal}
|
||||
page={blocksuiteDoc}
|
||||
workspace={currentWorkspace}
|
||||
docId={blocksuiteDoc.id}
|
||||
/>
|
||||
) : null}
|
||||
<DisablePublicSharing.DisablePublicSharingModal
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { tagColors } from '../../affine/page-properties/common';
|
||||
import type { TagMeta } from '../types';
|
||||
import * as styles from './create-tag.css';
|
||||
|
||||
@@ -18,11 +17,6 @@ const TagIcon = ({ color, large }: { color: string; large?: boolean }) => (
|
||||
/>
|
||||
);
|
||||
|
||||
const randomTagColor = () => {
|
||||
const randomIndex = Math.floor(Math.random() * tagColors.length);
|
||||
return tagColors[randomIndex][1];
|
||||
};
|
||||
|
||||
export const CreateOrEditTag = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -32,7 +26,8 @@ export const CreateOrEditTag = ({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
tagMeta?: TagMeta;
|
||||
}) => {
|
||||
const tagList = useService(TagService).tagList;
|
||||
const tagService = useService(TagService);
|
||||
const tagList = tagService.tagList;
|
||||
const tagOptions = useLiveData(tagList.tagMetas$);
|
||||
const tag = useLiveData(tagList.tagByTagId$(tagMeta?.id));
|
||||
const t = useI18n();
|
||||
@@ -43,14 +38,16 @@ export const CreateOrEditTag = ({
|
||||
setTagName(value);
|
||||
}, []);
|
||||
|
||||
const [tagIcon, setTagIcon] = useState(tagMeta?.color || randomTagColor());
|
||||
const [tagIcon, setTagIcon] = useState(
|
||||
tagMeta?.color || tagService.randomTagColor()
|
||||
);
|
||||
|
||||
const handleChangeIcon = useCallback((value: string) => {
|
||||
setTagIcon(value);
|
||||
}, []);
|
||||
|
||||
const tags = useMemo(() => {
|
||||
return tagColors.map(([_, color]) => {
|
||||
return tagService.tagColors.map(([name, color]) => {
|
||||
return {
|
||||
name: name,
|
||||
color: color,
|
||||
@@ -60,7 +57,7 @@ export const CreateOrEditTag = ({
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [handleChangeIcon]);
|
||||
}, [handleChangeIcon, tagService.tagColors]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const tagItems = tags.map(item => {
|
||||
@@ -81,11 +78,11 @@ export const CreateOrEditTag = ({
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
if (!tagMeta) {
|
||||
handleChangeIcon(randomTagColor());
|
||||
handleChangeIcon(tagService.randomTagColor());
|
||||
setTagName('');
|
||||
}
|
||||
onOpenChange(false);
|
||||
}, [handleChangeIcon, onOpenChange, tagMeta]);
|
||||
}, [handleChangeIcon, onOpenChange, tagMeta, tagService]);
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
if (!tagName?.trim()) return;
|
||||
@@ -129,8 +126,8 @@ export const CreateOrEditTag = ({
|
||||
|
||||
useEffect(() => {
|
||||
setTagName(tagMeta?.title || '');
|
||||
setTagIcon(tagMeta?.color || randomTagColor());
|
||||
}, [tagMeta?.color, tagMeta?.title]);
|
||||
setTagIcon(tagMeta?.color || tagService.randomTagColor());
|
||||
}, [tagMeta?.color, tagMeta?.title, tagService]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
|
||||
@@ -22,7 +22,7 @@ export const root = style({
|
||||
},
|
||||
});
|
||||
export const dragPageItemOverlay = style({
|
||||
height: '54px',
|
||||
height: '45px',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Checkbox } from '@affine/component';
|
||||
import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { Checkbox, useDraggable } from '@affine/component';
|
||||
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ForwardedRef, PropsWithChildren } from 'react';
|
||||
import { forwardRef, useCallback, useMemo } from 'react';
|
||||
|
||||
import { selectionStateAtom, useAtom } from '../scoped-atoms';
|
||||
import type { DraggableTitleCellData, TagListItemProps } from '../types';
|
||||
import type { TagListItemProps } from '../types';
|
||||
import { ColWrapper, stopPropagation } from '../utils';
|
||||
import * as styles from './tag-list-item.css';
|
||||
|
||||
@@ -83,45 +82,62 @@ const TagListOperationsCell = ({
|
||||
};
|
||||
|
||||
export const TagListItem = (props: TagListItemProps) => {
|
||||
const tagTitleElement = useMemo(() => {
|
||||
return (
|
||||
<div className={styles.dragPageItemOverlay}>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<TagSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<ListIconCell color={props.color} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [props.color, props.onSelectedChange, props.selectable, props.selected]);
|
||||
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: getDNDId('tag-list', 'tag', props.tagId),
|
||||
data: {
|
||||
preview: tagTitleElement,
|
||||
} satisfies DraggableTitleCellData,
|
||||
disabled: !props.draggable,
|
||||
});
|
||||
const { dragRef, CustomDragPreview, dragging } = useDraggable<AffineDNDData>(
|
||||
() => ({
|
||||
canDrag: props.draggable,
|
||||
data: {
|
||||
entity: {
|
||||
type: 'tag',
|
||||
id: props.tagId,
|
||||
},
|
||||
from: {
|
||||
at: 'all-tags:list',
|
||||
},
|
||||
},
|
||||
}),
|
||||
[props.draggable, props.tagId]
|
||||
);
|
||||
|
||||
return (
|
||||
<TagListItemWrapper
|
||||
onClick={props.onClick}
|
||||
to={props.to}
|
||||
tagId={props.tagId}
|
||||
draggable={props.draggable}
|
||||
isDragging={isDragging}
|
||||
>
|
||||
<ColWrapper flex={9}>
|
||||
<ColWrapper
|
||||
className={styles.dndCell}
|
||||
flex={8}
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<>
|
||||
<TagListItemWrapper
|
||||
onClick={props.onClick}
|
||||
to={props.to}
|
||||
tagId={props.tagId}
|
||||
draggable={props.draggable}
|
||||
isDragging={dragging}
|
||||
ref={dragRef}
|
||||
>
|
||||
<ColWrapper flex={9}>
|
||||
<ColWrapper className={styles.dndCell} flex={8}>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<TagSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<ListIconCell color={props.color} />
|
||||
</div>
|
||||
<TagListTitleCell title={props.title} pageCount={props.pageCount} />
|
||||
</ColWrapper>
|
||||
<ColWrapper
|
||||
flex={4}
|
||||
alignment="end"
|
||||
style={{ overflow: 'visible' }}
|
||||
></ColWrapper>
|
||||
</ColWrapper>
|
||||
{props.operations ? (
|
||||
<ColWrapper
|
||||
className={styles.actionsCellWrapper}
|
||||
flex={2}
|
||||
alignment="end"
|
||||
>
|
||||
<TagListOperationsCell operations={props.operations} />
|
||||
</ColWrapper>
|
||||
) : null}
|
||||
</TagListItemWrapper>
|
||||
<CustomDragPreview position="pointer-outside">
|
||||
<div className={styles.dragPageItemOverlay}>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<TagSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
@@ -131,23 +147,9 @@ export const TagListItem = (props: TagListItemProps) => {
|
||||
<ListIconCell color={props.color} />
|
||||
</div>
|
||||
<TagListTitleCell title={props.title} pageCount={props.pageCount} />
|
||||
</ColWrapper>
|
||||
<ColWrapper
|
||||
flex={4}
|
||||
alignment="end"
|
||||
style={{ overflow: 'visible' }}
|
||||
></ColWrapper>
|
||||
</ColWrapper>
|
||||
{props.operations ? (
|
||||
<ColWrapper
|
||||
className={styles.actionsCellWrapper}
|
||||
flex={2}
|
||||
alignment="end"
|
||||
>
|
||||
<TagListOperationsCell operations={props.operations} />
|
||||
</ColWrapper>
|
||||
) : null}
|
||||
</TagListItemWrapper>
|
||||
</div>
|
||||
</CustomDragPreview>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -157,58 +159,68 @@ type TagListWrapperProps = PropsWithChildren<
|
||||
}
|
||||
>;
|
||||
|
||||
function TagListItemWrapper({
|
||||
to,
|
||||
isDragging,
|
||||
tagId,
|
||||
onClick,
|
||||
children,
|
||||
draggable,
|
||||
}: TagListWrapperProps) {
|
||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!selectionState.selectable) {
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
stopPropagation(e);
|
||||
setSelectionActive(true);
|
||||
onClick?.();
|
||||
return;
|
||||
}
|
||||
if (selectionState.selectionActive) {
|
||||
return onClick?.();
|
||||
}
|
||||
},
|
||||
[
|
||||
const TagListItemWrapper = forwardRef(
|
||||
(
|
||||
{
|
||||
to,
|
||||
isDragging,
|
||||
tagId,
|
||||
onClick,
|
||||
selectionState.selectable,
|
||||
selectionState.selectionActive,
|
||||
setSelectionActive,
|
||||
]
|
||||
);
|
||||
|
||||
const commonProps = useMemo(
|
||||
() => ({
|
||||
'data-testid': 'tag-list-item',
|
||||
'data-tag-id': tagId,
|
||||
'data-draggable': draggable,
|
||||
className: styles.root,
|
||||
'data-clickable': !!onClick || !!to,
|
||||
'data-dragging': isDragging,
|
||||
onClick: handleClick,
|
||||
}),
|
||||
[tagId, draggable, isDragging, onClick, to, handleClick]
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<WorkbenchLink {...commonProps} to={to}>
|
||||
{children}
|
||||
</WorkbenchLink>
|
||||
children,
|
||||
draggable,
|
||||
}: TagListWrapperProps,
|
||||
ref: ForwardedRef<HTMLAnchorElement & HTMLDivElement>
|
||||
) => {
|
||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!selectionState.selectable) {
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
stopPropagation(e);
|
||||
setSelectionActive(true);
|
||||
onClick?.();
|
||||
return;
|
||||
}
|
||||
if (selectionState.selectionActive) {
|
||||
return onClick?.();
|
||||
}
|
||||
},
|
||||
[
|
||||
onClick,
|
||||
selectionState.selectable,
|
||||
selectionState.selectionActive,
|
||||
setSelectionActive,
|
||||
]
|
||||
);
|
||||
} else {
|
||||
return <div {...commonProps}>{children}</div>;
|
||||
|
||||
const commonProps = useMemo(
|
||||
() => ({
|
||||
'data-testid': 'tag-list-item',
|
||||
'data-tag-id': tagId,
|
||||
'data-draggable': draggable,
|
||||
className: styles.root,
|
||||
'data-clickable': !!onClick || !!to,
|
||||
'data-dragging': isDragging,
|
||||
onClick: handleClick,
|
||||
}),
|
||||
[tagId, draggable, isDragging, onClick, to, handleClick]
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<WorkbenchLink {...commonProps} to={to} ref={ref}>
|
||||
{children}
|
||||
</WorkbenchLink>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div {...commonProps} ref={ref}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
TagListItemWrapper.displayName = 'TagListItemWrapper';
|
||||
|
||||
@@ -86,7 +86,7 @@ export const VirtualizedTagList = ({
|
||||
<VirtualizedList
|
||||
ref={listRef}
|
||||
selectable="toggle"
|
||||
draggable={false}
|
||||
draggable={true}
|
||||
atTopThreshold={80}
|
||||
onSelectionActiveChange={setShowFloatingToolbar}
|
||||
heading={<TagListHeader onOpen={onOpenCreate} />}
|
||||
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
import { IconButton } from '@affine/component/ui/button';
|
||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export const AddCollectionButton = ({
|
||||
node,
|
||||
onClick,
|
||||
}: {
|
||||
node: ReactElement | null;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
data-testid="slider-bar-add-collection-button"
|
||||
onClick={onClick}
|
||||
size="small"
|
||||
>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
{node}
|
||||
</>
|
||||
);
|
||||
};
|
||||
-327
@@ -1,327 +0,0 @@
|
||||
import {
|
||||
AnimatedCollectionsIcon,
|
||||
toast,
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import { RenameModal } from '@affine/component/rename-modal';
|
||||
import { Button, IconButton } from '@affine/component/ui/button';
|
||||
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
|
||||
import {
|
||||
CollectionOperations,
|
||||
filterPage,
|
||||
stopPropagation,
|
||||
} from '@affine/core/components/page-list';
|
||||
import {
|
||||
type DNDIdentifier,
|
||||
getDNDId,
|
||||
parseDNDId,
|
||||
resolveDragEndIntent,
|
||||
} from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
ViewLayersIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import type { DocCollection } from '@blocksuite/store';
|
||||
import { type AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useAllPageListConfig } from '../../../../hooks/affine/use-all-page-list-config';
|
||||
import { useBlockSuiteDocMeta } from '../../../../hooks/use-block-suite-page-meta';
|
||||
import { WorkbenchService } from '../../../../modules/workbench';
|
||||
import { WorkbenchLink } from '../../../../modules/workbench/view/workbench-link';
|
||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
||||
import * as draggableMenuItemStyles from '../components/draggable-menu-item.css';
|
||||
import { SidebarDocItem } from '../doc-tree/doc';
|
||||
import { SidebarDocTreeNode } from '../doc-tree/node';
|
||||
import type { CollectionsListProps } from '../index';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const animateLayoutChanges: AnimateLayoutChanges = ({
|
||||
isSorting,
|
||||
wasDragging,
|
||||
}) => (isSorting || wasDragging ? false : true);
|
||||
|
||||
export const CollectionSidebarNavItem = ({
|
||||
collection,
|
||||
docCollection,
|
||||
className,
|
||||
dndId,
|
||||
}: {
|
||||
collection: Collection;
|
||||
docCollection: DocCollection;
|
||||
dndId: DNDIdentifier;
|
||||
className?: string;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const collectionService = useService(CollectionService);
|
||||
const { createPage } = usePageHelper(docCollection);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const t = useI18n();
|
||||
|
||||
const overlayPreview = useMemo(() => {
|
||||
return (
|
||||
<DragMenuItemOverlay icon={<ViewLayersIcon />} title={collection.name} />
|
||||
);
|
||||
}, [collection.name]);
|
||||
|
||||
const {
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
attributes,
|
||||
listeners,
|
||||
transform,
|
||||
over,
|
||||
active,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id: dndId,
|
||||
data: {
|
||||
preview: overlayPreview,
|
||||
},
|
||||
animateLayoutChanges,
|
||||
});
|
||||
|
||||
const isSorting = parseDNDId(active?.id)?.where === 'sidebar-pin';
|
||||
const dragOverIntent = resolveDragEndIntent(active, over);
|
||||
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition: isSorting ? transition : undefined,
|
||||
};
|
||||
|
||||
const isOver = over?.id === dndId && dragOverIntent === 'collection:add';
|
||||
|
||||
const currentPath = useLiveData(
|
||||
useService(WorkbenchService).workbench.location$.map(
|
||||
location => location.pathname
|
||||
)
|
||||
);
|
||||
const path = `/collection/${collection.id}`;
|
||||
|
||||
const onRename = useCallback(
|
||||
(name: string) => {
|
||||
collectionService.updateCollection(collection.id, () => ({
|
||||
...collection,
|
||||
name,
|
||||
}));
|
||||
toast(t['com.affine.toastMessage.rename']());
|
||||
},
|
||||
[collection, collectionService, t]
|
||||
);
|
||||
const handleOpen = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const createAndAddDocument = useCallback(() => {
|
||||
const newDoc = createPage();
|
||||
collectionService.addPageToCollection(collection.id, newDoc.id);
|
||||
}, [collection.id, collectionService, createPage]);
|
||||
|
||||
const onConfirmAddDocToCollection = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t['com.affine.collection.add-doc.confirm.title'](),
|
||||
description: t['com.affine.collection.add-doc.confirm.description'](),
|
||||
cancelText: t['Cancel'](),
|
||||
confirmText: t['Confirm'](),
|
||||
confirmButtonOptions: {
|
||||
type: 'primary',
|
||||
},
|
||||
onConfirm: createAndAddDocument,
|
||||
});
|
||||
}, [createAndAddDocument, openConfirmModal, t]);
|
||||
|
||||
const postfix = (
|
||||
<div
|
||||
onClick={stopPropagation}
|
||||
onMouseDown={e => {
|
||||
// prevent drag
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<IconButton onClick={onConfirmAddDocToCollection} size="small">
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
<CollectionOperations
|
||||
collection={collection}
|
||||
openRenameModal={handleOpen}
|
||||
onAddDocToCollection={onConfirmAddDocToCollection}
|
||||
>
|
||||
<IconButton
|
||||
data-testid="collection-options"
|
||||
type="plain"
|
||||
size="small"
|
||||
style={{ marginLeft: 4 }}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</IconButton>
|
||||
</CollectionOperations>
|
||||
<RenameModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onRename={onRename}
|
||||
currentName={collection.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarDocTreeNode
|
||||
ref={setNodeRef}
|
||||
node={{ type: 'collection', data: collection }}
|
||||
to={path}
|
||||
linkComponent={WorkbenchLink}
|
||||
subTree={
|
||||
<CollectionSidebarNavItemContent
|
||||
collection={collection}
|
||||
docCollection={docCollection}
|
||||
dndId={dndId}
|
||||
/>
|
||||
}
|
||||
rootProps={{
|
||||
className,
|
||||
style,
|
||||
...attributes,
|
||||
}}
|
||||
menuItemProps={{
|
||||
...listeners,
|
||||
'data-draggable': true,
|
||||
'data-dragging': isDragging,
|
||||
'data-testid': 'collection-item',
|
||||
'data-collection-id': collection.id,
|
||||
'data-type': 'collection-list-item',
|
||||
className: draggableMenuItemStyles.draggableMenuItem,
|
||||
active: isOver || currentPath === path,
|
||||
icon: <AnimatedCollectionsIcon closed={isOver} />,
|
||||
postfix,
|
||||
}}
|
||||
>
|
||||
<span>{collection.name}</span>
|
||||
</SidebarDocTreeNode>
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionSidebarNavItemContent = ({
|
||||
collection,
|
||||
docCollection,
|
||||
dndId,
|
||||
}: {
|
||||
collection: Collection;
|
||||
docCollection: DocCollection;
|
||||
dndId: DNDIdentifier;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const pages = useBlockSuiteDocMeta(docCollection);
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const collectionService = useService(CollectionService);
|
||||
|
||||
const config = useAllPageListConfig();
|
||||
const favourites = useLiveData(favAdapter.favorites$);
|
||||
const allowList = useMemo(
|
||||
() => new Set(collection.allowList),
|
||||
[collection.allowList]
|
||||
);
|
||||
const removeFromAllowList = useCallback(
|
||||
(id: string) => {
|
||||
collectionService.deletePageFromCollection(collection.id, id);
|
||||
toast(t['com.affine.collection.removePage.success']());
|
||||
},
|
||||
[collection, collectionService, t]
|
||||
);
|
||||
|
||||
const filtered = pages.filter(meta => {
|
||||
if (meta.trash) return false;
|
||||
const pageData = {
|
||||
meta,
|
||||
publicMode: config.getPublicMode(meta.id),
|
||||
favorite: favourites.some(fav => fav.id === meta.id),
|
||||
};
|
||||
return filterPage(collection, pageData);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.docsListContainer}>
|
||||
{filtered.length > 0 ? (
|
||||
filtered.map(page => {
|
||||
return (
|
||||
<SidebarDocItem
|
||||
key={page.id}
|
||||
docId={page.id}
|
||||
postfixConfig={{
|
||||
inAllowList: allowList.has(page.id),
|
||||
removeFromAllowList: removeFromAllowList,
|
||||
}}
|
||||
dragConfig={{
|
||||
parentId: dndId,
|
||||
where: 'collection-list',
|
||||
}}
|
||||
menuItemProps={{
|
||||
'data-testid': 'collection-page',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className={styles.noReferences}>
|
||||
{t['com.affine.collection.emptyCollection']()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollectionsList = ({
|
||||
docCollection: workspace,
|
||||
onCreate,
|
||||
}: CollectionsListProps) => {
|
||||
const collections = useLiveData(useService(CollectionService).collections$);
|
||||
const t = useI18n();
|
||||
|
||||
if (collections.length === 0) {
|
||||
return (
|
||||
<div className={styles.emptyCollectionWrapper}>
|
||||
<div className={styles.emptyCollectionContent}>
|
||||
<div className={styles.emptyCollectionIconWrapper}>
|
||||
<ViewLayersIcon className={styles.emptyCollectionIcon} />
|
||||
</div>
|
||||
<div
|
||||
data-testid="slider-bar-collection-null-description"
|
||||
className={styles.emptyCollectionMessage}
|
||||
>
|
||||
{t['com.affine.collections.empty.message']()}
|
||||
</div>
|
||||
</div>
|
||||
<Button className={styles.emptyCollectionNewButton} onClick={onCreate}>
|
||||
{t['com.affine.collections.empty.new-collection-button']()}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div data-testid="collections" className={styles.wrapper}>
|
||||
{collections.map(view => {
|
||||
const dragItemId = getDNDId(
|
||||
'sidebar-collections',
|
||||
'collection',
|
||||
view.id
|
||||
);
|
||||
|
||||
return (
|
||||
<CollectionSidebarNavItem
|
||||
key={view.id}
|
||||
collection={view}
|
||||
docCollection={workspace}
|
||||
dndId={dragItemId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './collections-list';
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const wrapper = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
userSelect: 'none',
|
||||
// marginLeft:8,
|
||||
});
|
||||
export const collapsedIcon = style({
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
transform: 'rotate(-90deg)',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const view = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const viewTitle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const more = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 2,
|
||||
fontSize: 16,
|
||||
color: cssVar('iconColor'),
|
||||
':hover': {
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
},
|
||||
});
|
||||
export const deleteFolder = style({
|
||||
':hover': {
|
||||
color: cssVar('errorColor'),
|
||||
backgroundColor: cssVar('backgroundErrorColor'),
|
||||
},
|
||||
});
|
||||
globalStyle(`${deleteFolder}:hover svg`, {
|
||||
color: cssVar('errorColor'),
|
||||
});
|
||||
export const menuDividerStyle = style({
|
||||
marginTop: '2px',
|
||||
marginBottom: '2px',
|
||||
marginLeft: '12px',
|
||||
marginRight: '8px',
|
||||
height: '1px',
|
||||
background: cssVar('borderColor'),
|
||||
});
|
||||
export const collapsibleContent = style({
|
||||
overflow: 'hidden',
|
||||
marginTop: '4px',
|
||||
selectors: {
|
||||
'&[data-hidden="true"]': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const emptyCollectionWrapper = style({
|
||||
padding: '9px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
export const emptyCollectionContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
});
|
||||
export const emptyCollectionIconWrapper = style({
|
||||
width: 36,
|
||||
height: 36,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
});
|
||||
export const emptyCollectionIcon = style({
|
||||
fontSize: 20,
|
||||
color: cssVar('iconSecondary'),
|
||||
});
|
||||
export const emptyCollectionMessage = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
textAlign: 'center',
|
||||
color: cssVar('black30'),
|
||||
userSelect: 'none',
|
||||
});
|
||||
export const emptyCollectionNewButton = style({
|
||||
padding: '0 8px',
|
||||
height: '28px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
export const docsListContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
});
|
||||
export const noReferences = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
textAlign: 'left',
|
||||
paddingLeft: '32px',
|
||||
color: cssVar('black30'),
|
||||
userSelect: 'none',
|
||||
});
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
import * as styles from '../favorite/styles.css';
|
||||
|
||||
export const DragMenuItemOverlay = ({
|
||||
title,
|
||||
icon,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.dragPageItemOverlay}>
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const draggableMenuItem = style({
|
||||
selectors: {
|
||||
'&[data-draggable=true]:before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
left: 0,
|
||||
width: 4,
|
||||
height: 4,
|
||||
transition: 'height 0.2s, opacity 0.2s',
|
||||
backgroundColor: cssVar('placeholderColor'),
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
willChange: 'height, opacity',
|
||||
},
|
||||
'&[data-draggable=true]:hover:before': {
|
||||
height: 12,
|
||||
opacity: 1,
|
||||
},
|
||||
'&[data-draggable=true][data-dragging=true]': {
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
},
|
||||
'&[data-draggable=true][data-dragging=true]:before': {
|
||||
height: 32,
|
||||
width: 2,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
-182
@@ -1,182 +0,0 @@
|
||||
import type { MenuItemProps } from '@affine/component';
|
||||
import { MenuIcon, MenuItem, MenuSeparator } from '@affine/component';
|
||||
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
FavoriteIcon,
|
||||
FilterMinusIcon,
|
||||
InformationIcon,
|
||||
LinkedPageIcon,
|
||||
SplitViewIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type OperationItemsProps = {
|
||||
inFavorites?: boolean;
|
||||
isReferencePage?: boolean;
|
||||
inAllowList?: boolean;
|
||||
onRemoveFromAllowList?: () => void;
|
||||
setRenameModalOpen?: () => void;
|
||||
onRename: () => void;
|
||||
onAddLinkedPage: () => void;
|
||||
onRemoveFromFavourites?: () => void;
|
||||
onDelete: () => void;
|
||||
onOpenInSplitView: () => void;
|
||||
onOpenInfoModal: () => void;
|
||||
};
|
||||
|
||||
export const OperationItems = ({
|
||||
inFavorites,
|
||||
isReferencePage,
|
||||
inAllowList,
|
||||
onRemoveFromAllowList,
|
||||
onRename,
|
||||
onAddLinkedPage,
|
||||
onRemoveFromFavourites,
|
||||
onDelete,
|
||||
onOpenInSplitView,
|
||||
onOpenInfoModal,
|
||||
}: OperationItemsProps) => {
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
const t = useI18n();
|
||||
const actions = useMemo<
|
||||
Array<
|
||||
| {
|
||||
icon: ReactElement;
|
||||
name: string;
|
||||
click: () => void;
|
||||
type?: MenuItemProps['type'];
|
||||
element?: undefined;
|
||||
}
|
||||
| {
|
||||
element: ReactElement;
|
||||
}
|
||||
>
|
||||
>(
|
||||
() => [
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<EditIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['Rename'](),
|
||||
click: onRename,
|
||||
},
|
||||
...(runtimeConfig.enableInfoModal
|
||||
? [
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<InformationIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['com.affine.page-properties.page-info.view'](),
|
||||
click: onOpenInfoModal,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<LinkedPageIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['com.affine.page-operation.add-linked-page'](),
|
||||
click: onAddLinkedPage,
|
||||
},
|
||||
...(inFavorites && onRemoveFromFavourites && !isReferencePage
|
||||
? [
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<FavoriteIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['Remove from favorites'](),
|
||||
click: onRemoveFromFavourites,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(inAllowList && onRemoveFromAllowList
|
||||
? [
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<FilterMinusIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['Remove special filter'](),
|
||||
click: onRemoveFromAllowList,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
...(appSettings.enableMultiView
|
||||
? [
|
||||
// open split view
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<SplitViewIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['com.affine.workbench.split-view.page-menu-open'](),
|
||||
click: onOpenInSplitView,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
element: <MenuSeparator key="menu-separator" />,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<DeleteIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['com.affine.moveToTrash.title'](),
|
||||
click: onDelete,
|
||||
type: 'danger',
|
||||
},
|
||||
],
|
||||
[
|
||||
t,
|
||||
onRename,
|
||||
onAddLinkedPage,
|
||||
inFavorites,
|
||||
onRemoveFromFavourites,
|
||||
isReferencePage,
|
||||
inAllowList,
|
||||
onRemoveFromAllowList,
|
||||
appSettings.enableMultiView,
|
||||
onOpenInSplitView,
|
||||
onOpenInfoModal,
|
||||
onDelete,
|
||||
]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{actions.map(action => {
|
||||
if (action.element) {
|
||||
return action.element;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
data-testid="sidebar-page-option-item"
|
||||
key={action.name}
|
||||
type={action.type}
|
||||
preFix={action.icon}
|
||||
onClick={action.click}
|
||||
>
|
||||
{action.name}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
import { toast } from '@affine/component';
|
||||
import { IconButton } from '@affine/component/ui/button';
|
||||
import { Menu } from '@affine/component/ui/menu';
|
||||
import { InfoModal } from '@affine/core/components/affine/page-properties';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
|
||||
import { useService, useServices, WorkspaceService } from '@toeverything/infra';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useTrashModalHelper } from '../../../../hooks/affine/use-trash-modal-helper';
|
||||
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
|
||||
import { OperationItems } from './operation-item';
|
||||
|
||||
export type OperationMenuButtonProps = {
|
||||
pageId: string;
|
||||
pageTitle: string;
|
||||
setRenameModalOpen: () => void;
|
||||
inFavorites?: boolean;
|
||||
isReferencePage?: boolean;
|
||||
inAllowList?: boolean;
|
||||
removeFromAllowList?: (id: string) => void;
|
||||
};
|
||||
|
||||
export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
|
||||
const {
|
||||
pageId,
|
||||
pageTitle,
|
||||
setRenameModalOpen,
|
||||
removeFromAllowList,
|
||||
inAllowList,
|
||||
inFavorites,
|
||||
isReferencePage,
|
||||
} = props;
|
||||
const t = useI18n();
|
||||
const [openInfoModal, setOpenInfoModal] = useState(false);
|
||||
|
||||
const { workspaceService } = useServices({
|
||||
WorkspaceService,
|
||||
});
|
||||
const page = workspaceService.workspace.docCollection.getDoc(pageId);
|
||||
const { createLinkedPage } = usePageHelper(
|
||||
workspaceService.workspace.docCollection
|
||||
);
|
||||
const { setTrashModal } = useTrashModalHelper(
|
||||
workspaceService.workspace.docCollection
|
||||
);
|
||||
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
setRenameModalOpen?.();
|
||||
}, [setRenameModalOpen]);
|
||||
|
||||
const handleAddLinkedPage = useCallback(() => {
|
||||
createLinkedPage(pageId);
|
||||
toast(t['com.affine.toastMessage.addLinkedPage']());
|
||||
}, [createLinkedPage, pageId, t]);
|
||||
|
||||
const handleRemoveFromFavourites = useCallback(() => {
|
||||
favAdapter.remove(pageId, 'doc');
|
||||
toast(t['com.affine.toastMessage.removedFavorites']());
|
||||
}, [favAdapter, pageId, t]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageIds: [pageId],
|
||||
pageTitles: [pageTitle],
|
||||
});
|
||||
}, [pageId, pageTitle, setTrashModal]);
|
||||
|
||||
const handleRemoveFromAllowList = useCallback(() => {
|
||||
removeFromAllowList?.(pageId);
|
||||
}, [pageId, removeFromAllowList]);
|
||||
|
||||
const handleOpenInSplitView = useCallback(() => {
|
||||
workbench.openDoc(pageId, { at: 'tail' });
|
||||
}, [pageId, workbench]);
|
||||
|
||||
const handleOpenInfoModal = useCallback(() => {
|
||||
setOpenInfoModal(true);
|
||||
}, [setOpenInfoModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
items={
|
||||
<OperationItems
|
||||
onAddLinkedPage={handleAddLinkedPage}
|
||||
onDelete={handleDelete}
|
||||
onRemoveFromAllowList={handleRemoveFromAllowList}
|
||||
onRemoveFromFavourites={handleRemoveFromFavourites}
|
||||
onRename={handleRename}
|
||||
onOpenInSplitView={handleOpenInSplitView}
|
||||
onOpenInfoModal={handleOpenInfoModal}
|
||||
inAllowList={inAllowList}
|
||||
inFavorites={inFavorites}
|
||||
isReferencePage={isReferencePage}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
type="plain"
|
||||
data-testid="left-sidebar-page-operation-button"
|
||||
style={{ marginLeft: 4 }}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</IconButton>
|
||||
</Menu>
|
||||
{page ? (
|
||||
<InfoModal
|
||||
open={openInfoModal}
|
||||
onOpenChange={setOpenInfoModal}
|
||||
page={page}
|
||||
workspace={workspaceService.workspace}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import { toast } from '@affine/component';
|
||||
import { RenameModal } from '@affine/component/rename-modal';
|
||||
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useServices, WorkspaceService } from '@toeverything/infra';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { AddFavouriteButton } from '../favorite/add-favourite-button';
|
||||
import * as styles from '../favorite/styles.css';
|
||||
import { OperationMenuButton } from './operation-menu-button';
|
||||
|
||||
export type PostfixItemProps = {
|
||||
pageId: string;
|
||||
pageTitle: string;
|
||||
inFavorites?: boolean;
|
||||
isReferencePage?: boolean;
|
||||
inAllowList?: boolean;
|
||||
removeFromAllowList?: (id: string) => void;
|
||||
};
|
||||
|
||||
export const PostfixItem = ({ ...props }: PostfixItemProps) => {
|
||||
const { pageId, pageTitle } = props;
|
||||
const t = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { workspaceService } = useServices({
|
||||
WorkspaceService,
|
||||
});
|
||||
const { setDocTitle } = useDocMetaHelper(
|
||||
workspaceService.workspace.docCollection
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(newName: string) => {
|
||||
setDocTitle(pageId, newName);
|
||||
setOpen(false);
|
||||
toast(t['com.affine.toastMessage.rename']());
|
||||
},
|
||||
[pageId, setDocTitle, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.favoritePostfixItem}
|
||||
onMouseDown={e => {
|
||||
// prevent drag
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={e => {
|
||||
// prevent jump to page
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<AddFavouriteButton {...props} />
|
||||
<OperationMenuButton
|
||||
setRenameModalOpen={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
<RenameModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onRename={handleRename}
|
||||
currentName={pageTitle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const title = style({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
|
||||
globalStyle(`[data-draggable=true] ${title}:before`, {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
left: 0,
|
||||
width: 4,
|
||||
height: 4,
|
||||
transition: 'height 0.2s, opacity 0.2s',
|
||||
backgroundColor: cssVar('placeholderColor'),
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
willChange: 'height, opacity',
|
||||
});
|
||||
globalStyle(`[data-draggable=true] ${title}:hover:before`, {
|
||||
height: 12,
|
||||
opacity: 1,
|
||||
});
|
||||
globalStyle(`[data-draggable=true][data-dragging=true] ${title}`, {
|
||||
opacity: 0.5,
|
||||
});
|
||||
globalStyle(`[data-draggable=true][data-dragging=true] ${title}:before`, {
|
||||
height: 32,
|
||||
width: 2,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
export const label = style({
|
||||
selectors: {
|
||||
'&[data-untitled="true"]': {
|
||||
opacity: 0.6,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const labelContainer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const labelTooltipContainer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const noReferences = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
textAlign: 'left',
|
||||
paddingLeft: '32px',
|
||||
color: cssVar('black30'),
|
||||
userSelect: 'none',
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
import { Loading, Tooltip } from '@affine/component';
|
||||
import type { MenuItemProps } from '@affine/core/components/app-sidebar';
|
||||
import {
|
||||
type DNDIdentifier,
|
||||
type DndWhere,
|
||||
getDNDId,
|
||||
} from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||
import {
|
||||
WorkbenchLink,
|
||||
WorkbenchService,
|
||||
} from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import {
|
||||
DocsService,
|
||||
LiveData,
|
||||
useLiveData,
|
||||
useServices,
|
||||
} from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
||||
import { PostfixItem, type PostfixItemProps } from '../components/postfix-item';
|
||||
import * as styles from './doc.css';
|
||||
import { SidebarDocTreeNode } from './node';
|
||||
|
||||
export type SidebarDocItemProps = {
|
||||
docId: string;
|
||||
postfixConfig?: Omit<
|
||||
PostfixItemProps,
|
||||
'pageId' | 'pageTitle' | 'isReferencePage'
|
||||
>;
|
||||
isReference?: boolean;
|
||||
dragConfig?: {
|
||||
parentId?: DNDIdentifier;
|
||||
where: DndWhere;
|
||||
};
|
||||
menuItemProps?: Partial<MenuItemProps> & Record<`data-${string}`, string>;
|
||||
};
|
||||
|
||||
export const SidebarDocItem = function SidebarDocItem({
|
||||
docId,
|
||||
postfixConfig,
|
||||
isReference,
|
||||
dragConfig,
|
||||
menuItemProps,
|
||||
}: SidebarDocItemProps) {
|
||||
const { docsSearchService, workbenchService, docsService } = useServices({
|
||||
DocsSearchService,
|
||||
WorkbenchService,
|
||||
DocsService,
|
||||
});
|
||||
const t = useI18n();
|
||||
const location = useLiveData(workbenchService.workbench.location$);
|
||||
const active = location.pathname === '/' + docId;
|
||||
|
||||
const docRecord = useLiveData(docsService.list.doc$(docId));
|
||||
const docMode = useLiveData(docRecord?.mode$);
|
||||
const docTitle = useLiveData(docRecord?.title$);
|
||||
const icon = useMemo(() => {
|
||||
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
||||
}, [docMode]);
|
||||
|
||||
const references = useLiveData(
|
||||
useMemo(
|
||||
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
|
||||
[docsSearchService, docId]
|
||||
)
|
||||
);
|
||||
const indexerLoading = useLiveData(
|
||||
docsSearchService.indexer.status$.map(
|
||||
v => v.remaining === undefined || v.remaining > 0
|
||||
)
|
||||
);
|
||||
const [referencesLoading, setReferencesLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
setReferencesLoading(
|
||||
prev =>
|
||||
prev &&
|
||||
indexerLoading /* after loading becomes false, it never becomes true */
|
||||
);
|
||||
}, [indexerLoading]);
|
||||
const untitled = !docTitle;
|
||||
const title = docTitle || t['Untitled']();
|
||||
|
||||
// drag (not available for sub-docs)
|
||||
const dragItemId = dragConfig
|
||||
? getDNDId(dragConfig.where, 'doc', docId, dragConfig.parentId)
|
||||
: nanoid();
|
||||
const docTitleElement = useMemo(() => {
|
||||
return <DragMenuItemOverlay icon={icon} title={docTitle} />;
|
||||
}, [icon, docTitle]);
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: dragItemId,
|
||||
data: { preview: docTitleElement },
|
||||
disabled: !dragConfig || isReference,
|
||||
});
|
||||
|
||||
const dragAttrs: Partial<MenuItemProps> = isReference
|
||||
? {
|
||||
// prevent dragging parent node
|
||||
onMouseDown: e => e.stopPropagation(),
|
||||
}
|
||||
: { ...attributes, ...listeners };
|
||||
|
||||
// workaround to avoid invisible in playwright caused by nested drag
|
||||
delete dragAttrs['aria-disabled'];
|
||||
|
||||
return (
|
||||
<SidebarDocTreeNode
|
||||
ref={setNodeRef}
|
||||
rootProps={{ 'data-dragging': isDragging }}
|
||||
node={{ type: 'doc', data: docId }}
|
||||
to={`/${docId}`}
|
||||
linkComponent={WorkbenchLink}
|
||||
menuItemProps={{
|
||||
'data-type': isReference ? 'reference-page' : undefined,
|
||||
icon,
|
||||
active,
|
||||
className: styles.title,
|
||||
postfix: (
|
||||
<PostfixItem
|
||||
pageId={docId}
|
||||
pageTitle={title}
|
||||
isReferencePage={isReference}
|
||||
{...postfixConfig}
|
||||
/>
|
||||
),
|
||||
...dragAttrs,
|
||||
...menuItemProps,
|
||||
}}
|
||||
subTree={
|
||||
references ? (
|
||||
references.length > 0 ? (
|
||||
references.map(({ docId: childDocId }) => {
|
||||
return (
|
||||
<SidebarDocItem
|
||||
key={childDocId}
|
||||
docId={childDocId}
|
||||
isReference={true}
|
||||
menuItemProps={{
|
||||
'data-testid': `reference-page-${childDocId}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className={styles.noReferences}>
|
||||
{t['com.affine.rootAppSidebar.docs.no-subdoc']()}
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className={styles.labelContainer}>
|
||||
<span className={styles.label} data-untitled={untitled}>
|
||||
{title || t['Untitled']()}
|
||||
</span>
|
||||
{referencesLoading && (
|
||||
<Tooltip
|
||||
content={t['com.affine.rootAppSidebar.docs.references-loading']()}
|
||||
>
|
||||
<div className={styles.labelTooltipContainer}>
|
||||
<Loading />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</SidebarDocTreeNode>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const collapseContent = style({
|
||||
paddingTop: 2,
|
||||
paddingLeft: 20,
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import {
|
||||
MenuItem,
|
||||
type MenuItemProps,
|
||||
MenuLinkItem,
|
||||
} from '@affine/core/components/app-sidebar';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Link, type To } from 'react-router-dom';
|
||||
|
||||
import * as styles from './node.css';
|
||||
|
||||
type SidebarDocTreeNode =
|
||||
| {
|
||||
type: 'collection';
|
||||
data: Collection;
|
||||
}
|
||||
// | { type: 'tag' }
|
||||
// | { type: 'folder' }
|
||||
| {
|
||||
type: 'doc';
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type SidebarDocTreeNodeProps = PropsWithChildren<{
|
||||
node: SidebarDocTreeNode;
|
||||
subTree?: ReactNode;
|
||||
to?: To;
|
||||
linkComponent?: React.ComponentType<{ to: To; className?: string }>;
|
||||
|
||||
menuItemProps?: MenuItemProps & Record<`data-${string}`, unknown>;
|
||||
rootProps?: Collapsible.CollapsibleProps & Record<`data-${string}`, unknown>;
|
||||
}>;
|
||||
|
||||
type SidebarDocTreeNodeContext = {
|
||||
ancestors: SidebarDocTreeNode[];
|
||||
};
|
||||
|
||||
export const sidebarDocTreeContext =
|
||||
createContext<SidebarDocTreeNodeContext | null>(null);
|
||||
|
||||
/**
|
||||
* Tree node for the sidebar doc/folder/tag/collection tree.
|
||||
* This component is used to manage:
|
||||
* - Collapsing state
|
||||
* - Ancestors context
|
||||
* - Link/Menu item rendering
|
||||
* - Subtree indentation (left/top)
|
||||
*/
|
||||
export const SidebarDocTreeNode = forwardRef(function SidebarDocTreeNode(
|
||||
{
|
||||
node,
|
||||
children,
|
||||
subTree,
|
||||
to,
|
||||
linkComponent: LinkComponent = Link,
|
||||
menuItemProps,
|
||||
rootProps,
|
||||
}: SidebarDocTreeNodeProps,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const { ancestors } = useContext(sidebarDocTreeContext) ?? { ancestors: [] };
|
||||
|
||||
const finalMenuItemProps: SidebarDocTreeNodeProps['menuItemProps'] = {
|
||||
...menuItemProps,
|
||||
collapsed,
|
||||
onCollapsedChange: setCollapsed,
|
||||
};
|
||||
|
||||
return (
|
||||
<sidebarDocTreeContext.Provider value={{ ancestors: [...ancestors, node] }}>
|
||||
<Collapsible.Root
|
||||
{...rootProps}
|
||||
ref={ref}
|
||||
open={!collapsed}
|
||||
onOpenChange={setCollapsed}
|
||||
>
|
||||
{to ? (
|
||||
<MenuLinkItem
|
||||
to={to}
|
||||
linkComponent={LinkComponent}
|
||||
{...finalMenuItemProps}
|
||||
>
|
||||
{children}
|
||||
</MenuLinkItem>
|
||||
) : (
|
||||
<MenuItem {...finalMenuItemProps}>{children}</MenuItem>
|
||||
)}
|
||||
<Collapsible.Content className={styles.collapseContent}>
|
||||
{collapsed ? null : subTree}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</sidebarDocTreeContext.Provider>
|
||||
);
|
||||
});
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
import { IconButton } from '@affine/component/ui/button';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||
import { TelemetryWorkspaceContextService } from '@affine/core/modules/telemetry/services/telemetry';
|
||||
import { mixpanel } from '@affine/core/utils';
|
||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||
import { useService, useServices, WorkspaceService } from '@toeverything/infra';
|
||||
|
||||
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
|
||||
|
||||
type AddFavouriteButtonProps = {
|
||||
pageId?: string;
|
||||
};
|
||||
|
||||
export const AddFavouriteButton = ({ pageId }: AddFavouriteButtonProps) => {
|
||||
const { workspaceService } = useServices({
|
||||
WorkspaceService,
|
||||
});
|
||||
const { createPage, createLinkedPage } = usePageHelper(
|
||||
workspaceService.workspace.docCollection
|
||||
);
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const telemetry = useService(TelemetryWorkspaceContextService);
|
||||
const handleAddFavorite = useAsyncCallback(
|
||||
async e => {
|
||||
const page = telemetry.getPageContext();
|
||||
|
||||
if (pageId) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
createLinkedPage(pageId);
|
||||
mixpanel.track('DocCreated', {
|
||||
// page:
|
||||
segment: 'all doc',
|
||||
module: 'favorite',
|
||||
control: 'new fav sub doc',
|
||||
type: 'doc',
|
||||
category: 'page',
|
||||
page: page,
|
||||
});
|
||||
} else {
|
||||
const page = createPage();
|
||||
page.load();
|
||||
favAdapter.set(page.id, 'doc', true);
|
||||
mixpanel.track('DocCreated', {
|
||||
// page:
|
||||
segment: 'all doc',
|
||||
module: 'favorite',
|
||||
control: 'new fav doc',
|
||||
type: 'doc',
|
||||
category: 'page',
|
||||
page: page,
|
||||
});
|
||||
}
|
||||
},
|
||||
[telemetry, pageId, createLinkedPage, createPage, favAdapter]
|
||||
);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
data-testid="slider-bar-add-favorite-button"
|
||||
onClick={handleAddFavorite}
|
||||
size="small"
|
||||
>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { FavoriteIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
export const EmptyItem = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<div className={styles.emptyFavouritesContent}>
|
||||
<div className={styles.emptyFavouritesIconWrapper}>
|
||||
<FavoriteIcon className={styles.emptyFavouritesIcon} />
|
||||
</div>
|
||||
<div
|
||||
data-testid="slider-bar-favourites-empty-message"
|
||||
className={styles.emptyFavouritesMessage}
|
||||
>
|
||||
{t['com.affine.rootAppSidebar.favorites.empty']()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyItem;
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
import { CategoryDivider } from '@affine/core/components/app-sidebar';
|
||||
import {
|
||||
getDNDId,
|
||||
resolveDragEndIntent,
|
||||
} from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||
import type { WorkspaceFavoriteItem } from '@affine/core/modules/properties/services/schema';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useDndContext, useDroppable } from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import {
|
||||
DocsService,
|
||||
useLiveData,
|
||||
useService,
|
||||
useServices,
|
||||
} from '@toeverything/infra';
|
||||
import { Fragment, useCallback, useMemo } from 'react';
|
||||
|
||||
import { CollectionSidebarNavItem } from '../collections';
|
||||
import type { FavoriteListProps } from '../index';
|
||||
import { AddFavouriteButton } from './add-favourite-button';
|
||||
import EmptyItem from './empty-item';
|
||||
import { FavouriteDocSidebarNavItem } from './favourite-nav-item';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const FavoriteListInner = ({ docCollection: workspace }: FavoriteListProps) => {
|
||||
const { favoriteItemsAdapter, docsService, collectionService } = useServices({
|
||||
FavoriteItemsAdapter,
|
||||
DocsService,
|
||||
CollectionService,
|
||||
});
|
||||
const collections = useLiveData(collectionService.collections$);
|
||||
const docs = useLiveData(docsService.list.docs$);
|
||||
const trashDocs = useLiveData(docsService.list.trashDocs$);
|
||||
const dropItemId = getDNDId('sidebar-pin', 'container', workspace.id);
|
||||
|
||||
const favourites = useLiveData(
|
||||
favoriteItemsAdapter.orderedFavorites$.map(favs => {
|
||||
return favs.filter(fav => {
|
||||
if (fav.type === 'doc') {
|
||||
return (
|
||||
docs.some(doc => doc.id === fav.id) &&
|
||||
!trashDocs.some(doc => doc.id === fav.id)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// disable drop styles when dragging from the pin list
|
||||
const { active } = useDndContext();
|
||||
|
||||
const { setNodeRef, over } = useDroppable({
|
||||
id: dropItemId,
|
||||
});
|
||||
|
||||
const intent = resolveDragEndIntent(active, over);
|
||||
const shouldRenderDragOver = intent === 'pin:add';
|
||||
|
||||
const renderFavItem = useCallback(
|
||||
(item: WorkspaceFavoriteItem) => {
|
||||
if (item.type === 'collection') {
|
||||
const collection = collections.find(c => c.id === item.id);
|
||||
if (collection) {
|
||||
const dragItemId = getDNDId(
|
||||
'sidebar-pin',
|
||||
'collection',
|
||||
collection.id
|
||||
);
|
||||
return (
|
||||
<CollectionSidebarNavItem
|
||||
dndId={dragItemId}
|
||||
className={styles.favItemWrapper}
|
||||
docCollection={workspace}
|
||||
collection={collection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (item.type === 'doc') {
|
||||
return (
|
||||
<FavouriteDocSidebarNavItem
|
||||
docId={item.id}
|
||||
// memo?
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[collections, workspace]
|
||||
);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.favoriteList}
|
||||
data-testid="favourites"
|
||||
ref={setNodeRef}
|
||||
data-over={shouldRenderDragOver}
|
||||
>
|
||||
<CategoryDivider label={t['com.affine.rootAppSidebar.favorites']()}>
|
||||
<AddFavouriteButton />
|
||||
</CategoryDivider>
|
||||
{favourites.map(item => {
|
||||
return <Fragment key={item.id}>{renderFavItem(item)}</Fragment>;
|
||||
})}
|
||||
{favourites.length === 0 && <EmptyItem />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FavoriteList = ({
|
||||
docCollection: workspace,
|
||||
}: FavoriteListProps) => {
|
||||
const favAdapter = useService(FavoriteItemsAdapter);
|
||||
const favourites = useLiveData(favAdapter.orderedFavorites$);
|
||||
const sortItems = useMemo(() => {
|
||||
return favourites.map(fav => getDNDId('sidebar-pin', fav.type, fav.id));
|
||||
}, [favourites]);
|
||||
return (
|
||||
<SortableContext items={sortItems} strategy={verticalListSortingStrategy}>
|
||||
<FavoriteListInner docCollection={workspace} />
|
||||
</SortableContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default FavoriteList;
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
import {
|
||||
getDNDId,
|
||||
parseDNDId,
|
||||
} from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
||||
import { type AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { DocsService, useLiveData, useService } from '@toeverything/infra';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
||||
import { SidebarDocItem } from '../doc-tree/doc';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const animateLayoutChanges: AnimateLayoutChanges = ({
|
||||
isSorting,
|
||||
wasDragging,
|
||||
}) => (isSorting || wasDragging ? false : true);
|
||||
|
||||
export const FavouriteDocSidebarNavItem = ({ docId }: { docId: string }) => {
|
||||
const t = useI18n();
|
||||
const docsService = useService(DocsService);
|
||||
const docRecord = useLiveData(docsService.list.doc$(docId));
|
||||
const docMode = useLiveData(docRecord?.mode$);
|
||||
const docTitle = useLiveData(docRecord?.title$);
|
||||
const pageTitle = docTitle || t['Untitled']();
|
||||
|
||||
const icon = useMemo(() => {
|
||||
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
||||
}, [docMode]);
|
||||
|
||||
const overlayPreview = useMemo(() => {
|
||||
return <DragMenuItemOverlay icon={icon} title={pageTitle} />;
|
||||
}, [icon, pageTitle]);
|
||||
|
||||
const dragItemId = getDNDId('sidebar-pin', 'doc', docId);
|
||||
|
||||
const {
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
attributes,
|
||||
listeners,
|
||||
transform,
|
||||
transition,
|
||||
active,
|
||||
} = useSortable({
|
||||
id: dragItemId,
|
||||
data: {
|
||||
preview: overlayPreview,
|
||||
},
|
||||
animateLayoutChanges,
|
||||
});
|
||||
|
||||
const isSorting = parseDNDId(active?.id)?.where === 'sidebar-pin';
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition: isSorting ? transition : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.favItemWrapper}
|
||||
style={style}
|
||||
ref={setNodeRef}
|
||||
data-draggable={true}
|
||||
data-dragging={isDragging}
|
||||
data-testid={`favourite-page-${docId}`}
|
||||
data-favourite-page-item
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<SidebarDocItem
|
||||
docId={docId}
|
||||
postfixConfig={{
|
||||
inFavorites: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './favorite-list';
|
||||
-123
@@ -1,123 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const label = style({
|
||||
selectors: {
|
||||
'&[data-untitled="true"]': {
|
||||
opacity: 0.6,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const labelContainer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const labelTooltipContainer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const favItemWrapper = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
userSelect: 'none',
|
||||
});
|
||||
export const collapsibleContent = style({
|
||||
overflow: 'hidden',
|
||||
marginTop: '4px',
|
||||
selectors: {
|
||||
'&[data-hidden="true"]': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const collapsibleContentInner = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const dragPageItemOverlay = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: cssVar('hoverColorFilled'),
|
||||
boxShadow: cssVar('menuShadow'),
|
||||
minHeight: '30px',
|
||||
maxWidth: '360px',
|
||||
width: '100%',
|
||||
fontSize: cssVar('fontSm'),
|
||||
gap: '8px',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
});
|
||||
globalStyle(`${dragPageItemOverlay} svg`, {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
color: cssVar('iconColor'),
|
||||
});
|
||||
globalStyle(`${dragPageItemOverlay} span`, {
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
export const favoriteList = style({
|
||||
overflow: 'hidden',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
selectors: {
|
||||
'&[data-over="true"]': {
|
||||
background: cssVar('hoverColorFilled'),
|
||||
},
|
||||
},
|
||||
});
|
||||
export const favoritePostfixItem = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const menuItem = style({
|
||||
gap: '8px',
|
||||
});
|
||||
globalStyle(`${menuItem} svg`, {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
color: cssVar('iconColor'),
|
||||
});
|
||||
globalStyle(`${menuItem}.danger:hover svg`, {
|
||||
color: cssVar('errorColor'),
|
||||
});
|
||||
export const emptyFavouritesContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '9px 20px 25px 21px',
|
||||
});
|
||||
export const emptyFavouritesIconWrapper = style({
|
||||
width: 36,
|
||||
height: 36,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
});
|
||||
export const emptyFavouritesIcon = style({
|
||||
fontSize: 20,
|
||||
color: cssVar('iconSecondary'),
|
||||
});
|
||||
export const emptyFavouritesMessage = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
textAlign: 'center',
|
||||
color: cssVar('black30'),
|
||||
userSelect: 'none',
|
||||
});
|
||||
export const noReferences = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
textAlign: 'left',
|
||||
paddingLeft: '32px',
|
||||
color: cssVar('black30'),
|
||||
lineHeight: '30px',
|
||||
userSelect: 'none',
|
||||
});
|
||||
@@ -1,23 +1,23 @@
|
||||
import { AnimatedDeleteIcon } from '@affine/component';
|
||||
import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import {
|
||||
ExplorerCollections,
|
||||
ExplorerFavorites,
|
||||
ExplorerOrganize,
|
||||
} from '@affine/core/modules/explorer';
|
||||
import { ExplorerTags } from '@affine/core/modules/explorer/views/sections/tags';
|
||||
import { TelemetryWorkspaceContextService } from '@affine/core/modules/telemetry/services/telemetry';
|
||||
import { mixpanel } from '@affine/core/utils';
|
||||
import { apis, events } from '@affine/electron-api';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { FolderIcon, SettingsIcon } from '@blocksuite/icons/rc';
|
||||
import type { Doc } from '@blocksuite/store';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import type { Workspace } from '@toeverything/infra';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { HTMLAttributes, ReactElement } from 'react';
|
||||
import { forwardRef, memo, useCallback, useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { memo, useEffect } from 'react';
|
||||
|
||||
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
|
||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||
import { WorkbenchService } from '../../modules/workbench';
|
||||
import {
|
||||
AddPageButton,
|
||||
@@ -31,10 +31,6 @@ import {
|
||||
SidebarContainer,
|
||||
SidebarScrollableContainer,
|
||||
} from '../app-sidebar';
|
||||
import { createEmptyCollection, useEditCollectionName } from '../page-list';
|
||||
import { CollectionsList } from '../pure/workspace-slider-bar/collections';
|
||||
import { AddCollectionButton } from '../pure/workspace-slider-bar/collections/add-collection-button';
|
||||
import FavoriteList from '../pure/workspace-slider-bar/favorite/favorite-list';
|
||||
import { WorkspaceSelector } from '../workspace-selector';
|
||||
import ImportPage from './import-page';
|
||||
import {
|
||||
@@ -44,6 +40,7 @@ import {
|
||||
workspaceWrapper,
|
||||
} from './index.css';
|
||||
import { AppSidebarJournalButton } from './journal-button';
|
||||
import { TrashButton } from './trash-button';
|
||||
import { UpdaterButton } from './updater-button';
|
||||
import { UserInfo } from './user-info';
|
||||
|
||||
@@ -61,29 +58,6 @@ export type RootAppSidebarProps = {
|
||||
};
|
||||
};
|
||||
|
||||
const RouteMenuLinkItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
path: string;
|
||||
icon: ReactElement;
|
||||
active?: boolean;
|
||||
children?: ReactElement;
|
||||
} & HTMLAttributes<HTMLDivElement>
|
||||
>(({ path, icon, active, children, ...props }, ref) => {
|
||||
return (
|
||||
<MenuLinkItem
|
||||
ref={ref}
|
||||
{...props}
|
||||
active={active}
|
||||
to={path ?? ''}
|
||||
icon={icon}
|
||||
>
|
||||
{children}
|
||||
</MenuLinkItem>
|
||||
);
|
||||
});
|
||||
RouteMenuLinkItem.displayName = 'RouteMenuLinkItem';
|
||||
|
||||
/**
|
||||
* This is for the whole affine app sidebar.
|
||||
* This component wraps the app sidebar in `@affine/component` with logic and data.
|
||||
@@ -113,8 +87,6 @@ export const RootAppSidebar = memo(
|
||||
|
||||
const allPageActive = currentPath === '/all';
|
||||
|
||||
const trashActive = currentPath === '/trash';
|
||||
|
||||
const onClickNewPage = useAsyncCallback(async () => {
|
||||
const page = createPage();
|
||||
page.load();
|
||||
@@ -129,7 +101,6 @@ export const RootAppSidebar = memo(
|
||||
});
|
||||
}, [createPage, openPage, telemetry]);
|
||||
|
||||
const navigateHelper = useNavigateHelper();
|
||||
// Listen to the "New Page" action from the menu
|
||||
useEffect(() => {
|
||||
if (environment.isDesktop) {
|
||||
@@ -147,28 +118,6 @@ export const RootAppSidebar = memo(
|
||||
}
|
||||
}, [sidebarOpen]);
|
||||
|
||||
const dropItemId = getDNDId('sidebar-trash', 'container', 'trash');
|
||||
const trashDroppable = useDroppable({
|
||||
id: dropItemId,
|
||||
});
|
||||
|
||||
const collection = useService(CollectionService);
|
||||
const { node, open } = useEditCollectionName({
|
||||
title: t['com.affine.editCollection.createCollection'](),
|
||||
showTips: true,
|
||||
});
|
||||
const handleCreateCollection = useCallback(() => {
|
||||
open('')
|
||||
.then(name => {
|
||||
const id = nanoid();
|
||||
collection.addCollection(createEmptyCollection(id, { name }));
|
||||
navigateHelper.jumpToCollection(docCollection.id, id);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [docCollection.id, collection, navigateHelper, open]);
|
||||
|
||||
return (
|
||||
<AppSidebar
|
||||
clientBorder={appSettings.clientBorder}
|
||||
@@ -189,15 +138,15 @@ export const RootAppSidebar = memo(
|
||||
/>
|
||||
<AddPageButton onClick={onClickNewPage} />
|
||||
</div>
|
||||
<RouteMenuLinkItem
|
||||
<MenuLinkItem
|
||||
icon={<FolderIcon />}
|
||||
active={allPageActive}
|
||||
path={paths.all(currentWorkspaceId)}
|
||||
to={paths.all(currentWorkspaceId)}
|
||||
>
|
||||
<span data-testid="all-pages">
|
||||
{t['com.affine.workspaceSubPath.all']()}
|
||||
</span>
|
||||
</RouteMenuLinkItem>
|
||||
</MenuLinkItem>
|
||||
<AppSidebarJournalButton
|
||||
docCollection={currentWorkspace.docCollection}
|
||||
/>
|
||||
@@ -212,28 +161,15 @@ export const RootAppSidebar = memo(
|
||||
</MenuItem>
|
||||
</SidebarContainer>
|
||||
<SidebarScrollableContainer>
|
||||
<FavoriteList docCollection={docCollection} />
|
||||
<CategoryDivider label={t['com.affine.rootAppSidebar.collections']()}>
|
||||
<AddCollectionButton node={node} onClick={handleCreateCollection} />
|
||||
</CategoryDivider>
|
||||
<CollectionsList
|
||||
docCollection={docCollection}
|
||||
onCreate={handleCreateCollection}
|
||||
/>
|
||||
{runtimeConfig.enableOrganize && <ExplorerOrganize />}
|
||||
<ExplorerFavorites />
|
||||
<ExplorerCollections />
|
||||
<ExplorerTags />
|
||||
<CategoryDivider label={t['com.affine.rootAppSidebar.others']()} />
|
||||
{/* fixme: remove the following spacer */}
|
||||
<div style={{ height: '4px' }} />
|
||||
<div style={{ padding: '0 8px' }}>
|
||||
<RouteMenuLinkItem
|
||||
ref={trashDroppable.setNodeRef}
|
||||
icon={<AnimatedDeleteIcon closed={trashDroppable.isOver} />}
|
||||
active={trashActive || trashDroppable.isOver}
|
||||
path={paths.trash(currentWorkspaceId)}
|
||||
>
|
||||
<span data-testid="trash-page">
|
||||
{t['com.affine.workspaceSubPath.trash']()}
|
||||
</span>
|
||||
</RouteMenuLinkItem>
|
||||
<TrashButton />
|
||||
<ImportPage docCollection={docCollection} />
|
||||
</div>
|
||||
</SidebarScrollableContainer>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
AnimatedDeleteIcon,
|
||||
useConfirmModal,
|
||||
useDropTarget,
|
||||
} from '@affine/component';
|
||||
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
DocsService,
|
||||
GlobalContextService,
|
||||
useLiveData,
|
||||
useService,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { MenuLinkItem } from '../app-sidebar';
|
||||
|
||||
export const TrashButton = () => {
|
||||
const t = useI18n();
|
||||
const docsService = useService(DocsService);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const globalContextService = useService(GlobalContextService);
|
||||
const trashActive = useLiveData(globalContextService.globalContext.isTrash.$);
|
||||
|
||||
const { dropTargetRef, draggedOver } = useDropTarget<AffineDNDData>(
|
||||
() => ({
|
||||
data: {
|
||||
at: 'app-sidebar:trash',
|
||||
},
|
||||
canDrop(data) {
|
||||
return data.source.data.entity?.type === 'doc';
|
||||
},
|
||||
onDrop(data) {
|
||||
if (data.source.data.entity?.type === 'doc') {
|
||||
const docId = data.source.data.entity.id;
|
||||
const docRecord = docsService.list.doc$(docId).value;
|
||||
if (docRecord) {
|
||||
openConfirmModal({
|
||||
title: t['com.affine.moveToTrash.confirmModal.title'](),
|
||||
description: t['com.affine.moveToTrash.confirmModal.description'](
|
||||
{
|
||||
title: docRecord.title$.value || t['Untitled'](),
|
||||
}
|
||||
),
|
||||
confirmText: t.Delete(),
|
||||
confirmButtonOptions: {
|
||||
type: 'error',
|
||||
},
|
||||
onConfirm() {
|
||||
docRecord.moveToTrash();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
[docsService.list, openConfirmModal, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuLinkItem
|
||||
ref={dropTargetRef}
|
||||
icon={<AnimatedDeleteIcon closed={draggedOver} />}
|
||||
active={trashActive || draggedOver}
|
||||
linkComponent={WorkbenchLink}
|
||||
to={'/trash'}
|
||||
>
|
||||
<span data-testid="trash-page">
|
||||
{t['com.affine.workspaceSubPath.trash']()}
|
||||
</span>
|
||||
</MenuLinkItem>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user