feat(mobile): explorer create/rename operation (#8628)

close AF-1560
This commit is contained in:
CatsJuice
2024-11-04 05:28:05 +00:00
parent 12e3cf1d07
commit 4cbf4b74d6
44 changed files with 1139 additions and 252 deletions

View File

@@ -40,7 +40,7 @@ export const WorkspaceDeleteModal = ({
confirmButtonOptions={{
variant: 'error',
disabled: !allowDelete,
['data-testid' as string]: 'delete-workspace-confirm-button',
'data-testid': 'delete-workspace-confirm-button',
}}
{...props}
>

View File

@@ -127,7 +127,7 @@ export const Export = ({ exportHandler, className, pageMode }: ExportProps) => {
triggerOptions={{
className: transitionStyle,
prefixIcon: <ExportIcon />,
['data-testid' as string]: 'export-menu',
'data-testid': 'export-menu',
}}
subOptions={{
onOpenChange: handleExportMenuOpenChange,

View File

@@ -213,7 +213,7 @@ export const Snapshot = ({ className }: SnapshotProps) => {
triggerOptions={{
className: transitionStyle,
prefixIcon: <ToneIcon />,
['data-testid' as string]: 'snapshot-menu',
'data-testid': 'snapshot-menu',
}}
subOptions={{}}
>

View File

@@ -0,0 +1,43 @@
import { useI18n } from '@affine/i18n';
import {
RenameDialog,
type RenameDialogProps,
RenameSubMenu,
type RenameSubMenuProps,
} from '../../../rename';
export const CollectionRenameSubMenu = ({
title,
text,
...props
}: RenameSubMenuProps) => {
const t = useI18n();
return (
<RenameSubMenu
title={title || t['com.affine.m.explorer.collection.rename-menu-title']()}
text={text || t['com.affine.m.explorer.collection.rename']()}
{...props}
/>
);
};
const CollectionDesc = () => {
const t = useI18n();
return t['com.affine.collection.emptyCollectionDescription']();
};
export const CollectionRenameDialog = ({
title,
confirmText,
...props
}: RenameDialogProps) => {
return (
<RenameDialog
title={title}
confirmText={confirmText}
{...props}
descRenderer={CollectionDesc}
/>
);
};

View File

@@ -52,23 +52,6 @@ export const ExplorerCollectionNode = ({
const collection = useLiveData(collectionService.collection$(collectionId));
const handleRename = useCallback(
(name: string) => {
if (collection && collection.name !== name) {
collectionService.updateCollection(collectionId, () => ({
...collection,
name,
}));
track.$.navigationPanel.organize.renameOrganizeItem({
type: 'collection',
});
notify.success({ message: t['com.affine.toastMessage.rename']() });
}
},
[collection, collectionId, collectionService, t]
);
const handleOpenCollapsed = useCallback(() => {
setCollapsed(false);
}, []);
@@ -105,7 +88,7 @@ export const ExplorerCollectionNode = ({
return [...additionalOperations, ...collectionOperations];
}
return collectionOperations;
}, [collectionOperations, additionalOperations]);
}, [additionalOperations, collectionOperations]);
if (!collection) {
return null;
@@ -115,12 +98,10 @@ export const ExplorerCollectionNode = ({
<ExplorerTreeNode
icon={CollectionIcon}
name={collection.name || t['Untitled']()}
renameable
collapsed={collapsed}
setCollapsed={setCollapsed}
to={`/collection/${collection.id}`}
active={active}
onRename={handleRename}
operations={finalOperations}
data-testid={`explorer-collection-${collectionId}`}
>

View File

@@ -2,6 +2,7 @@ import {
IconButton,
MenuItem,
MenuSeparator,
notify,
useConfirmModal,
} from '@affine/component';
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
@@ -28,6 +29,8 @@ import {
} from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { CollectionRenameSubMenu } from './dialog';
export const useExplorerCollectionNodeOperations = (
collectionId: string,
onOpenCollapsed: () => void,
@@ -113,6 +116,24 @@ export const useExplorerCollectionNodeOperations = (
onOpenEdit();
}, [onOpenEdit]);
const handleRename = useCallback(
(name: string) => {
const collection = collectionService.collection$(collectionId).value;
if (collection && collection.name !== name) {
collectionService.updateCollection(collectionId, () => ({
...collection,
name,
}));
track.$.navigationPanel.organize.renameOrganizeItem({
type: 'collection',
});
notify.success({ message: t['com.affine.toastMessage.rename']() });
}
},
[collectionId, collectionService, t]
);
return useMemo(
() => ({
favorite,
@@ -122,6 +143,7 @@ export const useExplorerCollectionNodeOperations = (
handleOpenInSplitView,
handleShowEdit,
handleToggleFavoriteCollection,
handleRename,
}),
[
favorite,
@@ -129,6 +151,7 @@ export const useExplorerCollectionNodeOperations = (
handleDeleteCollection,
handleOpenInNewTab,
handleOpenInSplitView,
handleRename,
handleShowEdit,
handleToggleFavoriteCollection,
]
@@ -154,6 +177,7 @@ export const useExplorerCollectionNodeOperationsMenu = (
handleOpenInSplitView,
handleShowEdit,
handleToggleFavoriteCollection,
handleRename,
} = useExplorerCollectionNodeOperations(
collectionId,
onOpenCollapsed,
@@ -177,6 +201,14 @@ export const useExplorerCollectionNodeOperationsMenu = (
</IconButton>
),
},
{
index: 10,
view: <CollectionRenameSubMenu onConfirm={handleRename} />,
},
{
index: 11,
view: <MenuSeparator />,
},
{
index: 99,
view: (
@@ -256,6 +288,7 @@ export const useExplorerCollectionNodeOperationsMenu = (
handleDeleteCollection,
handleOpenInNewTab,
handleOpenInSplitView,
handleRename,
handleShowEdit,
handleToggleFavoriteCollection,
t,

View File

@@ -0,0 +1,18 @@
import { useI18n } from '@affine/i18n';
import { RenameSubMenu, type RenameSubMenuProps } from '../../../rename';
export const DocRenameSubMenu = ({
title,
text,
...props
}: RenameSubMenuProps) => {
const t = useI18n();
return (
<RenameSubMenu
title={title || t['com.affine.m.explorer.doc.rename']()}
text={text || t['com.affine.m.explorer.doc.rename']()}
{...props}
/>
);
};

View File

@@ -1,11 +1,9 @@
import { Loading } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { DocInfoService } from '@affine/core/modules/doc-info';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import type { NodeOperation } from '@affine/core/modules/explorer';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import {
DocsService,
FeatureFlagService,
@@ -92,14 +90,6 @@ export const ExplorerDocNode = ({
);
}, [indexerLoading]);
const handleRename = useAsyncCallback(
async (newName: string) => {
await docsService.changeDocTitle(docId, newName);
track.$.navigationPanel.organize.renameOrganizeItem({ type: 'doc' });
},
[docId, docsService]
);
const docInfoModal = useService(DocInfoService).modal;
const option = useMemo(
() => ({
@@ -126,7 +116,6 @@ export const ExplorerDocNode = ({
<ExplorerTreeNode
icon={Icon}
name={t.t(docTitle)}
renameable
extractEmojiAsIcon={enableEmojiIcon}
collapsed={collapsed}
setCollapsed={setCollapsed}
@@ -140,7 +129,6 @@ export const ExplorerDocNode = ({
</div>
)
}
onRename={handleRename}
operations={finalOperations}
data-testid={`explorer-doc-${docId}`}
>

View File

@@ -33,6 +33,8 @@ import {
} from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { DocRenameSubMenu } from './dialog';
export const useExplorerDocNodeOperations = (
docId: string,
options: {
@@ -135,6 +137,14 @@ export const useExplorerDocNodeOperations = (
});
}, [docId, compatibleFavoriteItemsAdapter]);
const handleRename = useAsyncCallback(
async (newName: string) => {
await docsService.changeDocTitle(docId, newName);
track.$.navigationPanel.organize.renameOrganizeItem({ type: 'doc' });
},
[docId, docsService]
);
return useMemo(
() => ({
favorite,
@@ -145,6 +155,7 @@ export const useExplorerDocNodeOperations = (
handleOpenInNewTab,
handleMoveToTrash,
handleOpenInfoModal,
handleRename,
}),
[
favorite,
@@ -154,6 +165,7 @@ export const useExplorerDocNodeOperations = (
handleOpenInNewTab,
handleOpenInSplitView,
handleOpenInfoModal,
handleRename,
handleToggleFavoriteDoc,
]
);
@@ -177,8 +189,12 @@ export const useExplorerDocNodeOperationsMenu = (
handleOpenInNewTab,
handleMoveToTrash,
handleOpenInfoModal,
handleRename,
} = useExplorerDocNodeOperations(docId, options);
const docService = useService(DocsService);
const docRecord = useLiveData(docService.list.doc$(docId));
const title = useLiveData(docRecord?.title$);
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
@@ -197,6 +213,14 @@ export const useExplorerDocNodeOperationsMenu = (
/>
),
},
{
index: 10,
view: <DocRenameSubMenu onConfirm={handleRename} initialName={title} />,
},
{
index: 11,
view: <MenuSeparator />,
},
{
index: 50,
view: (
@@ -289,8 +313,10 @@ export const useExplorerDocNodeOperationsMenu = (
handleOpenInNewTab,
handleOpenInSplitView,
handleOpenInfoModal,
handleRename,
handleToggleFavoriteDoc,
t,
title,
]
);
};

View File

@@ -0,0 +1,58 @@
import { useI18n } from '@affine/i18n';
import { EditIcon } from '@blocksuite/icons/rc';
import type { RenameDialogProps, RenameSubMenuProps } from '../../../rename';
import { RenameDialog, RenameSubMenu } from '../../../rename';
export const FolderCreateTip = ({
input,
parentName,
}: {
input?: string;
parentName?: string;
}) => {
const t = useI18n();
const parent = parentName
? parentName
: t['com.affine.m.explorer.folder.root']();
const tip = input
? t['com.affine.m.explorer.folder.new-tip-not-empty']({
value: input,
parent,
})
: t['com.affine.m.explorer.folder.new-tip-empty']({ parent });
return tip;
};
export const FolderRenameSubMenu = ({
title: propsTitle,
icon: propsIcon,
text: propsText,
...props
}: RenameSubMenuProps) => {
const t = useI18n();
const title = propsTitle || t['com.affine.m.explorer.folder.rename']();
const icon = propsIcon || <EditIcon />;
const text = propsText || title;
return <RenameSubMenu title={title} icon={icon} text={text} {...props} />;
};
export const FolderRenameDialog = ({
title: propsTitle,
confirmText: propsConfirmText,
...props
}: RenameDialogProps & {
open?: boolean;
onOpenChange?: (v: boolean) => void;
}) => {
const t = useI18n();
const title =
propsTitle || t['com.affine.m.explorer.folder.new-dialog-title']();
const confirmText =
propsConfirmText || t['com.affine.m.explorer.folder.rename-confirm']();
return <RenameDialog title={title} confirmText={confirmText} {...props} />;
};

View File

@@ -47,14 +47,13 @@ import { ExplorerTreeNode } from '../../tree/node';
import { ExplorerCollectionNode } from '../collection';
import { ExplorerDocNode } from '../doc';
import { ExplorerTagNode } from '../tag';
import { FolderCreateTip, FolderRenameSubMenu } from './dialog';
import { FavoriteFolderOperation } from './operations';
export const ExplorerFolderNode = ({
nodeId,
defaultRenaming,
operations,
}: {
defaultRenaming?: boolean;
nodeId: string;
operations?:
| NodeOperation[]
@@ -83,11 +82,7 @@ export const ExplorerFolderNode = ({
if (type === 'folder') {
return (
<ExplorerFolderNodeFolder
node={node}
defaultRenaming={defaultRenaming}
operations={additionalOperations}
/>
<ExplorerFolderNodeFolder node={node} operations={additionalOperations} />
);
}
if (!data) return null;
@@ -123,10 +118,8 @@ const ExplorerFolderIcon: ExplorerTreeNodeIcon = ({
const ExplorerFolderNodeFolder = ({
node,
defaultRenaming,
operations: additionalOperations,
}: {
defaultRenaming?: boolean;
node: FolderNode;
operations?: NodeOperation[];
}) => {
@@ -144,7 +137,6 @@ const ExplorerFolderNodeFolder = ({
featureFlagService.flags.enable_emoji_folder_icon.$
);
const [collapsed, setCollapsed] = useState(true);
const [newFolderId, setNewFolderId] = useState<string | null>(null);
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
@@ -182,15 +174,14 @@ const ExplorerFolderNodeFolder = ({
setCollapsed(false);
}, [createPage, node]);
const handleCreateSubfolder = useCallback(() => {
const newFolderId = node.createFolder(
t['com.affine.rootAppSidebar.organize.new-folders'](),
node.indexAt('before')
);
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
setCollapsed(false);
setNewFolderId(newFolderId);
}, [node, t]);
const handleCreateSubfolder = useCallback(
(name: string) => {
node.createFolder(name, node.indexAt('before'));
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
setCollapsed(false);
},
[node]
);
const handleAddToFolder = useCallback(
(type: 'doc' | 'collection' | 'tag') => {
@@ -237,6 +228,13 @@ const ExplorerFolderNodeFolder = ({
]
);
const createSubTipRenderer = useCallback(
({ input }: { input: string }) => {
return <FolderCreateTip input={input} parentName={name} />;
},
[name]
);
const folderOperations = useMemo(() => {
return [
{
@@ -254,12 +252,39 @@ const ExplorerFolderNodeFolder = ({
</IconButton>
),
},
{
index: 98,
view: (
<FolderRenameSubMenu
initialName={name}
onConfirm={handleRename}
menuProps={{
triggerOptions: { 'data-testid': 'rename-folder' },
}}
/>
),
},
{
index: 99,
view: <MenuSeparator />,
},
{
index: 100,
view: (
<MenuItem prefixIcon={<FolderIcon />} onClick={handleCreateSubfolder}>
{t['com.affine.rootAppSidebar.organize.folder.create-subfolder']()}
</MenuItem>
<FolderRenameSubMenu
text={t[
'com.affine.rootAppSidebar.organize.folder.create-subfolder'
]()}
title={t[
'com.affine.rootAppSidebar.organize.folder.create-subfolder'
]()}
onConfirm={handleCreateSubfolder}
descRenderer={createSubTipRenderer}
icon={<FolderIcon />}
menuProps={{
triggerOptions: { 'data-testid': 'create-subfolder' },
}}
/>
),
},
{
@@ -327,11 +352,14 @@ const ExplorerFolderNodeFolder = ({
},
];
}, [
createSubTipRenderer,
handleAddToFolder,
handleCreateSubfolder,
handleDelete,
handleNewDoc,
node,
handleRename,
name,
node.id,
t,
]);
@@ -370,7 +398,6 @@ const ExplorerFolderNodeFolder = ({
const handleCollapsedChange = useCallback((collapsed: boolean) => {
if (collapsed) {
setNewFolderId(null); // reset new folder id to clear the renaming state
setCollapsed(true);
} else {
setCollapsed(false);
@@ -381,26 +408,25 @@ const ExplorerFolderNodeFolder = ({
<ExplorerTreeNode
icon={ExplorerFolderIcon}
name={name}
defaultRenaming={defaultRenaming}
renameable
extractEmojiAsIcon={enableEmojiIcon}
collapsed={collapsed}
setCollapsed={handleCollapsedChange}
onRename={handleRename}
operations={finalOperations}
data-testid={`explorer-folder-${node.id}`}
aria-label={name}
data-role="explorer-folder"
>
{children.map(child => (
<ExplorerFolderNode
key={child.id}
nodeId={child.id as string}
defaultRenaming={child.id === newFolderId}
operations={childrenOperations}
/>
))}
<AddItemPlaceholder
label={t['com.affine.rootAppSidebar.organize.folder.add-docs']()}
onClick={() => handleAddToFolder('doc')}
data-testid="new-folder-in-folder-button"
/>
</ExplorerTreeNode>
);

View File

@@ -0,0 +1,53 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const colorDot = style({
width: 42,
height: 42,
textAlign: 'center',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
':before': {
content: '""',
width: 22,
height: 22,
borderRadius: 11,
display: 'block',
background: 'currentColor',
},
});
export const colorTrigger = style([
colorDot,
{
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
selectors: {
'&[data-active="true"]': {
borderColor: cssVarV2('input/border/active'),
},
},
},
]);
export const colorsRow = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 12px',
height: 54,
selectors: {
// TODO(@CatsJuice): this animation is conflicting with sub-menu height detection
'&[data-enable-fold]': {
height: 0,
overflow: 'hidden',
transition: 'all 0.23s ease',
},
'&[data-enable-fold][data-active="true"]': {
height: 54,
},
},
});

View File

@@ -0,0 +1,198 @@
import { type MenuSubProps, useMobileMenuController } from '@affine/component';
import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import {
createContext,
type Dispatch,
type ReactNode,
type SetStateAction,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import { RenameContent, RenameSubMenu } from '../../../rename';
import { RenameDialog } from '../../../rename/dialog';
import type { RenameContentProps } from '../../../rename/type';
import * as styles from './dialog.css';
const TagColorContext = createContext<{
colors: string[];
color: string;
setColor: Dispatch<SetStateAction<string>>;
show: boolean;
setShow: Dispatch<SetStateAction<boolean>>;
enableAnimation?: boolean;
}>({
color: '',
setColor: () => {},
colors: [],
show: false,
setShow: () => {},
});
const ColorPickerTrigger = () => {
const { color, show, setShow } = useContext(TagColorContext);
return (
<div
data-testid="tag-color-picker-trigger"
className={styles.colorTrigger}
style={{ color }}
data-active={show}
onClick={() => setShow(prev => !prev)}
/>
);
};
const ColorPickerSelect = () => {
const {
enableAnimation,
colors,
color: current,
setColor,
show,
} = useContext(TagColorContext);
if (!show && !enableAnimation) return null;
return (
<div
data-testid="tag-color-picker-select"
className={styles.colorsRow}
data-active={show}
data-enable-fold={enableAnimation || undefined}
>
{colors.map(color => (
<div
key={color}
aria-checked={color === current}
onClick={() => setColor(color)}
className={styles.colorDot}
style={{ color }}
data-color={color}
/>
))}
</div>
);
};
interface TagRenameContentProps extends Omit<RenameContentProps, 'onConfirm'> {
initialColor?: string;
onConfirm?: (name: string, color: string) => void;
enableAnimation?: boolean;
}
const TagRenameContent = ({
initialColor,
onConfirm,
enableAnimation,
...props
}: TagRenameContentProps) => {
const tagService = useService(TagService);
const colors = useMemo(() => {
return tagService.tagColors.map(([_, value]) => value);
}, [tagService.tagColors]);
const [color, setColor] = useState(
initialColor || tagService.randomTagColor()
);
const [show, setShow] = useState(false);
const handleConfirm = useCallback(
(name: string) => {
onConfirm?.(name, color);
},
[color, onConfirm]
);
return (
<TagColorContext.Provider
value={{ colors, color, setColor, show, setShow, enableAnimation }}
>
<RenameContent
inputPrefixRenderer={ColorPickerTrigger}
inputBelowRenderer={ColorPickerSelect}
onConfirm={handleConfirm}
{...props}
/>
</TagColorContext.Provider>
);
};
interface TagRenameDialogProps extends TagRenameContentProps {
title?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export const TagRenameDialog = ({
title: propsTitle,
confirmText: propsConfirmText,
open,
onOpenChange,
...props
}: TagRenameDialogProps) => {
const t = useI18n();
const title = propsTitle || t['com.affine.m.explorer.tag.new-dialog-title']();
const confirmText =
propsConfirmText || t['com.affine.m.explorer.tag.rename-confirm']();
return (
<RenameDialog
open={open}
onOpenChange={onOpenChange}
inputPrefixRenderer={ColorPickerTrigger}
title={title}
confirmText={confirmText}
>
<TagRenameContent {...props} />
</RenameDialog>
);
};
interface TagRenameSubMenuProps {
tagId?: string;
title?: string;
icon?: ReactNode;
text?: string;
onConfirm?: (name: string, color: string) => void;
menuProps?: Partial<MenuSubProps>;
}
export const TagRenameSubMenu = ({
tagId,
title,
icon,
text,
menuProps,
onConfirm,
}: TagRenameSubMenuProps) => {
const t = useI18n();
const { close } = useMobileMenuController();
const tagService = useService(TagService);
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
const tagName = useLiveData(tagRecord?.value$);
const tagColor = useLiveData(tagRecord?.color$);
const handleCloseAndConfirm = useCallback(
(name: string, color: string) => {
close();
onConfirm?.(name, color);
},
[close, onConfirm]
);
return (
<RenameSubMenu
title={title ?? t['com.affine.m.explorer.tag.rename-menu-title']()}
icon={icon}
text={text}
menuProps={menuProps}
>
<TagRenameContent
initialName={tagName}
initialColor={tagColor}
onConfirm={handleCloseAndConfirm}
/>
</RenameSubMenu>
);
};

View File

@@ -2,7 +2,6 @@ import type { NodeOperation } from '@affine/core/modules/explorer';
import type { Tag } from '@affine/core/modules/tag';
import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import {
GlobalContextService,
useLiveData,
@@ -23,10 +22,8 @@ import * as styles from './styles.css';
export const ExplorerTagNode = ({
tagId,
operations: additionalOperations,
defaultRenaming,
}: {
tagId: string;
defaultRenaming?: boolean;
operations?: NodeOperation[];
}) => {
const t = useI18n();
@@ -47,6 +44,7 @@ export const ExplorerTagNode = ({
return (
<div className={clsx(styles.tagIconContainer, className)}>
<div
data-testid="explorer-tag-icon-dot"
className={styles.tagIcon}
style={{
backgroundColor: tagColor,
@@ -58,18 +56,6 @@ export const ExplorerTagNode = ({
[tagColor]
);
const handleRename = useCallback(
(newName: string) => {
if (tagRecord && tagRecord.value$.value !== newName) {
tagRecord.rename(newName);
track.$.navigationPanel.organize.renameOrganizeItem({
type: 'tag',
});
}
},
[tagRecord]
);
const option = useMemo(
() => ({
openNodeCollapsed: () => setCollapsed(false),
@@ -94,15 +80,14 @@ export const ExplorerTagNode = ({
<ExplorerTreeNode
icon={Icon}
name={tagName || t['Untitled']()}
renameable
collapsed={collapsed}
setCollapsed={setCollapsed}
to={`/tag/${tagId}`}
active={active}
defaultRenaming={defaultRenaming}
onRename={handleRename}
operations={finalOperations}
data-testid={`explorer-tag-${tagId}`}
aria-label={tagName}
data-role="explorer-tag"
>
<ExplorerTagNodeDocs tag={tagRecord} onNewDoc={handleNewDoc} />
</ExplorerTreeNode>

View File

@@ -7,12 +7,7 @@ import { TagService } from '@affine/core/modules/tag';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import {
DeleteIcon,
OpenInNewIcon,
PlusIcon,
SplitViewIcon,
} from '@blocksuite/icons/rc';
import { DeleteIcon, PlusIcon, SplitViewIcon } from '@blocksuite/icons/rc';
import {
DocsService,
FeatureFlagService,
@@ -23,6 +18,8 @@ import {
} from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { TagRenameSubMenu } from './dialog';
export const useExplorerTagNodeOperations = (
tagId: string,
{
@@ -86,6 +83,37 @@ export const useExplorerTagNodeOperations = (
track.$.navigationPanel.organize.openInNewTab({ type: 'tag' });
}, [tagId, workbenchService]);
const handleRename = useCallback(
(newName: string) => {
if (tagRecord && tagRecord.value$.value !== newName) {
tagRecord.rename(newName);
track.$.navigationPanel.organize.renameOrganizeItem({
type: 'tag',
});
}
},
[tagRecord]
);
const handleChangeColor = useCallback(
(color: string) => {
if (tagRecord && tagRecord.color$.value !== color) {
tagRecord.changeColor(color);
}
},
[tagRecord]
);
const handleChangeNameOrColor = useCallback(
(name?: string, color?: string) => {
if (name !== undefined) {
handleRename(name);
}
if (color !== undefined) {
handleChangeColor(color);
}
},
[handleChangeColor, handleRename]
);
return useMemo(
() => ({
favorite,
@@ -94,13 +122,19 @@ export const useExplorerTagNodeOperations = (
handleOpenInSplitView,
handleToggleFavoriteTag,
handleOpenInNewTab,
handleRename,
handleChangeColor,
handleChangeNameOrColor,
}),
[
favorite,
handleChangeColor,
handleChangeNameOrColor,
handleMoveToTrash,
handleNewDoc,
handleOpenInNewTab,
handleOpenInSplitView,
handleRename,
handleToggleFavoriteTag,
]
);
@@ -122,7 +156,7 @@ export const useExplorerTagNodeOperationsMenu = (
handleMoveToTrash,
handleOpenInSplitView,
handleToggleFavoriteTag,
handleOpenInNewTab,
handleChangeNameOrColor,
} = useExplorerTagNodeOperations(tagId, option);
return useMemo(
@@ -131,21 +165,19 @@ export const useExplorerTagNodeOperationsMenu = (
index: 0,
inline: true,
view: (
<IconButton
size="16"
onClick={handleNewDoc}
tooltip={t['com.affine.rootAppSidebar.explorer.tag-add-tooltip']()}
>
<IconButton size="16" onClick={handleNewDoc}>
<PlusIcon />
</IconButton>
),
},
{
index: 50,
index: 10,
view: (
<MenuItem prefixIcon={<OpenInNewIcon />} onClick={handleOpenInNewTab}>
{t['com.affine.workbench.tab.page-menu-open']()}
</MenuItem>
<TagRenameSubMenu
onConfirm={handleChangeNameOrColor}
tagId={tagId}
menuProps={{ triggerOptions: { 'data-testid': 'rename-tag' } }}
/>
),
},
...(BUILD_CONFIG.isElectron && enableMultiView
@@ -196,12 +228,13 @@ export const useExplorerTagNodeOperationsMenu = (
[
enableMultiView,
favorite,
handleChangeNameOrColor,
handleMoveToTrash,
handleNewDoc,
handleOpenInNewTab,
handleOpenInSplitView,
handleToggleFavoriteTag,
t,
tagId,
]
);
};

View File

@@ -1,4 +1,3 @@
import { useEditCollectionName } from '@affine/core/components/page-list';
import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager';
import { CollectionService } from '@affine/core/modules/collection';
import { ExplorerService } from '@affine/core/modules/explorer';
@@ -8,11 +7,12 @@ import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { useLiveData, useServices } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { CollapsibleSection } from '../../layouts/collapsible-section';
import { ExplorerCollectionNode } from '../../nodes/collection';
import { CollectionRenameDialog } from '../../nodes/collection/dialog';
export const ExplorerCollections = () => {
const t = useI18n();
@@ -23,31 +23,22 @@ export const ExplorerCollections = () => {
});
const explorerSection = explorerService.sections.collections;
const collections = useLiveData(collectionService.collections$);
const { open: openCreateCollectionModel } = useEditCollectionName({
title: t['com.affine.editCollection.createCollection'](),
showTips: true,
});
const [showCreateCollectionModal, setShowCreateCollectionModal] =
useState(false);
const handleCreateCollection = useCallback(() => {
openCreateCollectionModel('')
.then(name => {
const id = nanoid();
collectionService.addCollection(createEmptyCollection(id, { name }));
track.$.navigationPanel.organize.createOrganizeItem({
type: 'collection',
});
workbenchService.workbench.openCollection(id);
explorerSection.setCollapsed(false);
})
.catch(err => {
console.error(err);
const handleCreateCollection = useCallback(
(name: string) => {
setShowCreateCollectionModal(false);
const id = nanoid();
collectionService.addCollection(createEmptyCollection(id, { name }));
track.$.navigationPanel.organize.createOrganizeItem({
type: 'collection',
});
}, [
collectionService,
explorerSection,
openCreateCollectionModel,
workbenchService.workbench,
]);
workbenchService.workbench.openCollection(id);
explorerSection.setCollapsed(false);
},
[collectionService, explorerSection, workbenchService.workbench]
);
return (
<CollapsibleSection
@@ -65,7 +56,13 @@ export const ExplorerCollections = () => {
<AddItemPlaceholder
data-testid="explorer-bar-add-collection-button"
label={t['com.affine.rootAppSidebar.collection.new']()}
onClick={handleCreateCollection}
onClick={() => setShowCreateCollectionModal(true)}
/>
<CollectionRenameDialog
title={t['com.affine.m.explorer.collection.new-dialog-title']()}
open={showCreateCollectionModal}
onOpenChange={setShowCreateCollectionModal}
onConfirm={handleCreateCollection}
/>
</ExplorerTreeRoot>
</CollapsibleSection>

View File

@@ -7,11 +7,12 @@ import { OrganizeService } from '@affine/core/modules/organize';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { CollapsibleSection } from '../../layouts/collapsible-section';
import { ExplorerFolderNode } from '../../nodes/folder';
import { FolderCreateTip, FolderRenameDialog } from '../../nodes/folder/dialog';
export const ExplorerOrganize = () => {
const { organizeService, explorerService } = useServices({
@@ -19,8 +20,7 @@ export const ExplorerOrganize = () => {
ExplorerService,
});
const explorerSection = explorerService.sections.organize;
const collapsed = useLiveData(explorerSection.collapsed$);
const [newFolderId, setNewFolderId] = useState<string | null>(null);
const [openNewFolderDialog, setOpenNewFolderDialog] = useState(false);
const t = useI18n();
@@ -30,20 +30,18 @@ export const ExplorerOrganize = () => {
const folders = useLiveData(rootFolder.sortedChildren$);
const isLoading = useLiveData(folderTree.isLoading$);
const handleCreateFolder = useCallback(() => {
const newFolderId = rootFolder.createFolder(
'New Folder',
rootFolder.indexAt('before')
);
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
setNewFolderId(newFolderId);
explorerSection.setCollapsed(false);
return newFolderId;
}, [explorerSection, rootFolder]);
useEffect(() => {
if (collapsed) setNewFolderId(null); // reset new folder id to clear the renaming state
}, [collapsed]);
const handleCreateFolder = useCallback(
(name: string) => {
const newFolderId = rootFolder.createFolder(
name,
rootFolder.indexAt('before')
);
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
explorerSection.setCollapsed(false);
return newFolderId;
},
[explorerSection, rootFolder]
);
return (
<CollapsibleSection
@@ -53,18 +51,20 @@ export const ExplorerOrganize = () => {
{/* TODO(@CatsJuice): Organize loading UI */}
<ExplorerTreeRoot placeholder={isLoading ? <Skeleton /> : null}>
{folders.map(child => (
<ExplorerFolderNode
key={child.id}
nodeId={child.id as string}
defaultRenaming={child.id === newFolderId}
/>
<ExplorerFolderNode key={child.id} nodeId={child.id as string} />
))}
<AddItemPlaceholder
data-testid="explorer-bar-add-organize-button"
label={t['com.affine.rootAppSidebar.organize.add-folder']()}
onClick={handleCreateFolder}
onClick={() => setOpenNewFolderDialog(true)}
/>
</ExplorerTreeRoot>
<FolderRenameDialog
open={openNewFolderDialog}
onConfirm={handleCreateFolder}
onOpenChange={setOpenNewFolderDialog}
descRenderer={FolderCreateTip}
/>
</CollapsibleSection>
);
};

View File

@@ -1,15 +1,23 @@
import { ExplorerService } from '@affine/core/modules/explorer';
import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree';
import type { Tag } from '@affine/core/modules/tag';
import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { CollapsibleSection } from '../../layouts/collapsible-section';
import { ExplorerTagNode } from '../../nodes/tag';
import { TagRenameDialog } from '../../nodes/tag/dialog';
export const TagDesc = ({ input }: { input: string }) => {
const t = useI18n();
return input
? t['com.affine.m.explorer.tag.new-tip-not-empty']({ value: input })
: t['com.affine.m.explorer.tag.new-tip-empty']();
};
export const ExplorerTags = () => {
const { tagService, explorerService } = useServices({
@@ -17,25 +25,20 @@ export const ExplorerTags = () => {
ExplorerService,
});
const explorerSection = explorerService.sections.tags;
const collapsed = useLiveData(explorerSection.collapsed$);
const [createdTag, setCreatedTag] = useState<Tag | null>(null);
const tags = useLiveData(tagService.tagList.tags$);
const [showNewTagDialog, setShowNewTagDialog] = useState(false);
const t = useI18n();
const handleCreateNewFavoriteDoc = useCallback(() => {
const newTags = tagService.tagList.createTag(
t['com.affine.rootAppSidebar.tags.new-tag'](),
tagService.randomTagColor()
);
setCreatedTag(newTags);
track.$.navigationPanel.organize.createOrganizeItem({ type: 'tag' });
explorerSection.setCollapsed(false);
}, [explorerSection, t, tagService]);
useEffect(() => {
if (collapsed) setCreatedTag(null); // reset created tag to clear the renaming state
}, [collapsed]);
const handleNewTag = useCallback(
(name: string, color: string) => {
setShowNewTagDialog(false);
tagService.tagList.createTag(name, color);
track.$.navigationPanel.organize.createOrganizeItem({ type: 'tag' });
explorerSection.setCollapsed(false);
},
[explorerSection, tagService]
);
return (
<CollapsibleSection
@@ -44,19 +47,22 @@ export const ExplorerTags = () => {
>
<ExplorerTreeRoot>
{tags.map(tag => (
<ExplorerTagNode
key={tag.id}
tagId={tag.id}
defaultRenaming={createdTag?.id === tag.id}
/>
<ExplorerTagNode key={tag.id} tagId={tag.id} />
))}
<AddItemPlaceholder
data-testid="explorer-bar-add-favorite-button"
onClick={handleCreateNewFavoriteDoc}
data-testid="explorer-add-tag-button"
onClick={() => setShowNewTagDialog(true)}
label={t[
'com.affine.rootAppSidebar.explorer.tag-section-add-tooltip'
]()}
/>
<TagRenameDialog
open={showNewTagDialog}
onOpenChange={setShowNewTagDialog}
onConfirm={handleNewTag}
enableAnimation
descRenderer={TagDesc}
/>
</ExplorerTreeRoot>
</CollapsibleSection>
);

View File

@@ -1,17 +1,10 @@
import { MenuItem, MobileMenu } from '@affine/component';
import { RenameModal } from '@affine/component/rename-modal';
import { AppSidebarService } from '@affine/core/modules/app-sidebar';
import type {
BaseExplorerTreeNodeProps,
NodeOperation,
} from '@affine/core/modules/explorer';
import { MobileMenu } from '@affine/component';
import type { BaseExplorerTreeNodeProps } from '@affine/core/modules/explorer';
import { ExplorerTreeContext } from '@affine/core/modules/explorer';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { extractEmojiIcon } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import { ArrowDownSmallIcon, EditIcon } from '@blocksuite/icons/rc';
import { ArrowDownSmallIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
import { useLiveData, useService } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import {
Fragment,
@@ -33,9 +26,6 @@ export const ExplorerTreeNode = ({
onClick,
to,
active,
defaultRenaming,
renameable,
onRename,
disabled,
collapsed,
extractEmojiAsIcon,
@@ -47,18 +37,13 @@ export const ExplorerTreeNode = ({
linkComponent: LinkComponent = WorkbenchLink,
...otherProps
}: ExplorerTreeNodeProps) => {
const t = useI18n();
const context = useContext(ExplorerTreeContext);
const level = context?.level ?? 0;
// If no onClick or to is provided, clicking on the node will toggle the collapse state
const clickForCollapse = !onClick && !to && !disabled;
const [childCount, setChildCount] = useState(0);
const [renaming, setRenaming] = useState(defaultRenaming);
const rootRef = useRef<HTMLDivElement>(null);
const appSidebarService = useService(AppSidebarService).sidebar;
const sidebarWidth = useLiveData(appSidebarService.width$);
const { emoji, name } = useMemo(() => {
if (!extractEmojiAsIcon || !rawName) {
return {
@@ -73,39 +58,13 @@ export const ExplorerTreeNode = ({
};
}, [extractEmojiAsIcon, rawName]);
const presetOperations = useMemo(
() =>
(
[
renameable
? {
index: 0,
view: (
<MenuItem
key={'explorer-tree-rename'}
type={'default'}
prefixIcon={<EditIcon />}
onClick={() => setRenaming(true)}
>
{t['com.affine.menu.rename']()}
</MenuItem>
),
}
: null,
] as (NodeOperation | null)[]
).filter((t): t is NodeOperation => t !== null),
[renameable, t]
);
const { menuOperations } = useMemo(() => {
const sorted = [...presetOperations, ...operations].sort(
(a, b) => a.index - b.index
);
const sorted = [...operations].sort((a, b) => a.index - b.index);
return {
menuOperations: sorted.filter(({ inline }) => !inline),
inlineOperations: sorted.filter(({ inline }) => !!inline),
};
}, [presetOperations, operations]);
}, [operations]);
const contextValue = useMemo(() => {
return {
@@ -127,11 +86,6 @@ export const ExplorerTreeNode = ({
[collapsed, setCollapsed]
);
const handleRename = useCallback(
(newName: string) => onRename?.(newName),
[onRename]
);
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (e.defaultPrevented) {
@@ -166,7 +120,7 @@ export const ExplorerTreeNode = ({
<Fragment key={index}>{view}</Fragment>
))}
>
<div className={styles.iconContainer}>
<div className={styles.iconContainer} data-testid="menu-trigger">
{emoji ?? (Icon && <Icon collapsed={collapsed} />)}
</div>
</MobileMenu>
@@ -193,18 +147,6 @@ export const ExplorerTreeNode = ({
data-collapsed={collapsed !== false}
/>
</div>
{renameable && (
<RenameModal
open={!!renaming}
width={sidebarWidth - 32}
onOpenChange={setRenaming}
onRename={handleRename}
currentName={rawName ?? ''}
>
<div className={styles.itemRenameAnchor} />
</RenameModal>
)}
</div>
);

View File

@@ -1,6 +1,7 @@
export * from './app-tabs';
export * from './doc-card';
export * from './page-header';
export * from './rename';
export * from './search-input';
export * from './search-result';
export * from './user-plan-tag';

View File

@@ -0,0 +1,35 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const inputWrapper = style({
padding: '4px 12px',
display: 'flex',
alignItems: 'center',
gap: 10,
});
export const input = style({
width: '100%',
height: 42,
border: '1px solid ' + cssVarV2('input/border/active'),
borderRadius: 8,
padding: '0 4px',
});
export const desc = style({
padding: '11px 16px',
fontSize: 17,
fontWeight: 400,
lineHeight: '22px',
letterSpacing: -0.43,
color: cssVarV2('text/secondary'),
});
export const doneWrapper = style({
width: '100%',
padding: '8px 16px',
});
export const done = style({
width: '100%',
height: 44,
borderRadius: 8,
fontSize: 17,
fontWeight: 400,
});

View File

@@ -0,0 +1,67 @@
import { Button, RowInput } from '@affine/component';
import { useI18n } from '@affine/i18n';
import clsx from 'clsx';
import { useCallback, useState } from 'react';
import * as styles from './content.css';
import type { RenameContentProps } from './type';
export const RenameContent = ({
initialName = '',
inputProps,
confirmButtonProps,
inputPrefixRenderer: InputPrefixRenderer,
inputBelowRenderer: InputBelowRenderer,
descRenderer: DescRenderer,
confirmText = 'Done',
onConfirm,
}: RenameContentProps) => {
const t = useI18n();
const [value, setValue] = useState(initialName);
const { className: inputClassName, ...restInputProps } = inputProps ?? {};
const { className: confirmButtonClassName, ...restConfirmButtonProps } =
confirmButtonProps ?? {};
const handleDone = useCallback(() => {
onConfirm?.(value);
}, [onConfirm, value]);
return (
<div>
<div className={styles.inputWrapper}>
{InputPrefixRenderer ? <InputPrefixRenderer input={value} /> : null}
<RowInput
autoFocus
className={clsx(styles.input, inputClassName)}
value={value}
onChange={setValue}
data-testid="rename-input"
{...restInputProps}
/>
</div>
{}
{InputBelowRenderer ? <InputBelowRenderer input={value} /> : null}
<div className={styles.desc}>
{DescRenderer ? (
<DescRenderer input={value} />
) : (
t['com.affine.m.rename-to']({ name: value })
)}
</div>
<div className={styles.doneWrapper}>
<Button
className={clsx(styles.done, confirmButtonClassName)}
onClick={handleDone}
disabled={!value}
variant="primary"
size="extraLarge"
data-testid="rename-confirm"
{...restConfirmButtonProps}
>
{confirmText}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,17 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const header = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 8,
padding: '10px 16px',
});
export const title = style({
fontSize: 17,
fontWeight: 600,
lineHeight: '22px',
letterSpacing: -0.43,
color: cssVarV2('text/primary'),
});

View File

@@ -0,0 +1,49 @@
import { IconButton, Modal } from '@affine/component';
import { CloseIcon } from '@blocksuite/icons/rc';
import { useCallback } from 'react';
import { RenameContent } from './content';
import * as styles from './dialog.css';
import type { RenameDialogProps } from './type';
export const RenameDialog = ({
open,
title,
onOpenChange,
onConfirm,
children,
...props
}: RenameDialogProps & {
open?: boolean;
onOpenChange?: (v: boolean) => void;
}) => {
const handleRename = useCallback(
(value: string) => {
onConfirm?.(value);
onOpenChange?.(false);
},
[onOpenChange, onConfirm]
);
const close = useCallback(() => {
onOpenChange?.(false);
}, [onOpenChange]);
return (
<Modal
width="100%"
open={open}
onOpenChange={onOpenChange}
withoutCloseButton
contentOptions={{
style: { minHeight: 0, padding: '12px 0', borderRadius: 22 },
}}
>
<div className={styles.header}>
<span className={styles.title}>{title}</span>
<IconButton size="24" icon={<CloseIcon />} onClick={close} />
</div>
{children ?? <RenameContent onConfirm={handleRename} {...props} />}
</Modal>
);
};

View File

@@ -0,0 +1,4 @@
export * from './content';
export * from './dialog';
export * from './sub-menu';
export * from './type';

View File

@@ -0,0 +1,53 @@
import { MobileMenuSub, useMobileMenuController } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { EditIcon } from '@blocksuite/icons/rc';
import { useCallback } from 'react';
import { RenameContent } from './content';
import type { RenameSubMenuProps } from './type';
export const RenameSubMenu = ({
initialName = '',
title,
icon,
text,
children,
menuProps,
onConfirm,
...props
}: RenameSubMenuProps) => {
const t = useI18n();
const { close } = useMobileMenuController();
const handleRename = useCallback(
(value: string) => {
onConfirm?.(value);
close();
},
[close, onConfirm]
);
const { triggerOptions, ...otherMenuProps } = menuProps ?? {};
return (
<MobileMenuSub
triggerOptions={{
prefixIcon: icon ?? <EditIcon />,
suffixIcon: null,
...triggerOptions,
}}
items={
children ?? (
<RenameContent
initialName={initialName}
onConfirm={handleRename}
{...props}
/>
)
}
title={title}
{...otherMenuProps}
>
{text ?? t['com.affine.m.explorer.folder.rename']()}
</MobileMenuSub>
);
};

View File

@@ -0,0 +1,38 @@
import type {
ButtonProps,
MenuSubProps,
RowInputProps,
} from '@affine/component';
import type { PropsWithChildren, ReactNode } from 'react';
export interface RenameBaseProps {
initialName?: string;
onConfirm?: (name: string) => void;
}
export interface RenameContentProps extends RenameBaseProps {
inputProps?: Omit<RowInputProps, 'value' | 'onChange'>;
confirmButtonProps?: Omit<ButtonProps, 'onClick' | 'children'>;
confirmText?: string;
inputPrefixRenderer?: (props: { input: string }) => ReactNode;
descRenderer?: (props: { input: string }) => ReactNode;
inputBelowRenderer?: (props: { input: string }) => ReactNode;
}
export interface RenameSubMenuProps
extends PropsWithChildren<RenameContentProps> {
/** Submenu's title */
title?: string;
/** MenuItem.icon */
icon?: ReactNode;
/** MenuItem.text */
text?: string;
menuProps?: Partial<MenuSubProps>;
}
export interface RenameDialogProps
extends PropsWithChildren<RenameContentProps> {
open?: boolean;
onOpenChange?: (open: boolean) => void;
title?: string;
}

View File

@@ -95,7 +95,7 @@ const NameWorkspaceContent = ({
variant: 'primary',
loading,
disabled: !workspaceName,
['data-testid' as string]: 'create-workspace-create-button',
'data-testid': 'create-workspace-create-button',
}}
closeButtonOptions={{
['data-testid' as string]: 'create-workspace-close-button',

View File

@@ -61,12 +61,9 @@ export interface BaseExplorerTreeNodeProps {
icon?: ExplorerTreeNodeIcon;
children?: React.ReactNode;
active?: boolean;
defaultRenaming?: boolean;
extractEmojiAsIcon?: boolean;
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
renameable?: boolean;
onRename?: (newName: string) => void;
disabled?: boolean;
onClick?: () => void;
to?: To;
@@ -81,6 +78,10 @@ export interface BaseExplorerTreeNodeProps {
}
interface WebExplorerTreeNodeProps extends BaseExplorerTreeNodeProps {
renameable?: boolean;
onRename?: (newName: string) => void;
defaultRenaming?: boolean;
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
reorderable?: boolean;
dndData?: AffineDNDData;