mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 06:16:59 +08:00
feat: use custom @ import dialog (#8723)
[BS-1747](https://linear.app/affine-design/issue/BS-1747/[ui]-通过导入的ui还是旧的,需要更新)
This commit is contained in:
@@ -1,376 +0,0 @@
|
||||
import { Button, IconButton, Modal } from '@affine/component';
|
||||
import { UrlService } from '@affine/core/modules/url';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
import {
|
||||
MarkdownTransformer,
|
||||
NotionHtmlTransformer,
|
||||
openFileOrFiles,
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import type { DocCollection } from '@blocksuite/affine/store';
|
||||
import {
|
||||
ExportToMarkdownIcon,
|
||||
HelpIcon,
|
||||
NotionIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useService, WorkspaceService } from '@toeverything/infra';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { type ReactElement, useCallback, useState } from 'react';
|
||||
|
||||
import { openImportModalAtom } from '../../atoms';
|
||||
import { useAsyncCallback } from '../../hooks/affine-async-hooks';
|
||||
import * as style from './style.css';
|
||||
|
||||
type ImportType = 'markdown' | 'markdownZip' | 'notion';
|
||||
type AcceptType = 'Markdown' | 'Zip';
|
||||
type Status = 'idle' | 'importing' | 'success' | 'error';
|
||||
|
||||
type ImportConfig = {
|
||||
fileOptions: { acceptType: AcceptType; multiple: boolean };
|
||||
importFunction: (
|
||||
docCollection: DocCollection,
|
||||
file: File | File[]
|
||||
) => Promise<string[]>;
|
||||
};
|
||||
|
||||
const DISCORD_URL = 'https://discord.gg/whd5mjYqVw';
|
||||
|
||||
const importOptions = [
|
||||
{
|
||||
label: 'com.affine.import.markdown-files',
|
||||
prefixIcon: (
|
||||
<ExportToMarkdownIcon
|
||||
color={cssVarV2('icon/primary')}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
),
|
||||
testId: 'editor-option-menu-import-markdown-files',
|
||||
type: 'markdown' as ImportType,
|
||||
},
|
||||
{
|
||||
label: 'com.affine.import.markdown-with-media-files',
|
||||
prefixIcon: (
|
||||
<ExportToMarkdownIcon
|
||||
color={cssVarV2('icon/primary')}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
),
|
||||
testId: 'editor-option-menu-import-markdown-with-media',
|
||||
type: 'markdownZip' as ImportType,
|
||||
},
|
||||
{
|
||||
label: 'com.affine.import.notion',
|
||||
prefixIcon: <NotionIcon color={cssVar('black')} width={20} height={20} />,
|
||||
suffixIcon: (
|
||||
<HelpIcon color={cssVarV2('icon/primary')} width={20} height={20} />
|
||||
),
|
||||
suffixTooltip: 'com.affine.import.notion.tooltip',
|
||||
testId: 'editor-option-menu-import-notion',
|
||||
type: 'notion' as ImportType,
|
||||
},
|
||||
];
|
||||
|
||||
const importConfigs: Record<ImportType, ImportConfig> = {
|
||||
markdown: {
|
||||
fileOptions: { acceptType: 'Markdown', multiple: true },
|
||||
importFunction: async (docCollection, files) => {
|
||||
if (!Array.isArray(files)) {
|
||||
throw new Error('Expected an array of files for markdown files import');
|
||||
}
|
||||
const pageIds: string[] = [];
|
||||
for (const file of files) {
|
||||
const text = await file.text();
|
||||
const fileName = file.name.split('.').slice(0, -1).join('.');
|
||||
const pageId = await MarkdownTransformer.importMarkdownToDoc({
|
||||
collection: docCollection,
|
||||
markdown: text,
|
||||
fileName,
|
||||
});
|
||||
if (pageId) pageIds.push(pageId);
|
||||
}
|
||||
return pageIds;
|
||||
},
|
||||
},
|
||||
markdownZip: {
|
||||
fileOptions: { acceptType: 'Zip', multiple: false },
|
||||
importFunction: async (docCollection, file) => {
|
||||
if (Array.isArray(file)) {
|
||||
throw new Error('Expected a single zip file for markdownZip import');
|
||||
}
|
||||
return MarkdownTransformer.importMarkdownZip({
|
||||
collection: docCollection,
|
||||
imported: file,
|
||||
});
|
||||
},
|
||||
},
|
||||
notion: {
|
||||
fileOptions: { acceptType: 'Zip', multiple: false },
|
||||
importFunction: async (docCollection, file) => {
|
||||
if (Array.isArray(file)) {
|
||||
throw new Error('Expected a single zip file for notion import');
|
||||
}
|
||||
const { pageIds } = await NotionHtmlTransformer.importNotionZip({
|
||||
collection: docCollection,
|
||||
imported: file,
|
||||
});
|
||||
return pageIds;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ImportOptionItem = ({
|
||||
label,
|
||||
prefixIcon,
|
||||
suffixIcon,
|
||||
suffixTooltip,
|
||||
type,
|
||||
onImport,
|
||||
}: {
|
||||
label: string;
|
||||
prefixIcon: ReactElement;
|
||||
suffixIcon?: ReactElement;
|
||||
suffixTooltip?: string;
|
||||
type: ImportType;
|
||||
onImport: (type: ImportType) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<div className={style.importItem} onClick={() => onImport(type)}>
|
||||
{prefixIcon}
|
||||
<div className={style.importItemLabel}>{t[label]()}</div>
|
||||
{suffixIcon && (
|
||||
<IconButton
|
||||
className={style.importItemSuffix}
|
||||
icon={suffixIcon}
|
||||
tooltip={suffixTooltip ? t[suffixTooltip]() : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ImportOptions = ({
|
||||
onImport,
|
||||
}: {
|
||||
onImport: (type: ImportType) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<>
|
||||
<div className={style.importModalTitle}>{t['Import']()}</div>
|
||||
<div className={style.importModalContent}>
|
||||
{importOptions.map(
|
||||
({ label, prefixIcon, suffixIcon, suffixTooltip, testId, type }) => (
|
||||
<ImportOptionItem
|
||||
key={testId}
|
||||
prefixIcon={prefixIcon}
|
||||
suffixIcon={suffixIcon}
|
||||
suffixTooltip={suffixTooltip}
|
||||
label={label}
|
||||
data-testid={testId}
|
||||
type={type}
|
||||
onImport={onImport}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className={style.importModalTip}>
|
||||
{t['com.affine.import.modal.tip']()}{' '}
|
||||
<a
|
||||
className={style.link}
|
||||
href="https://discord.gg/whd5mjYqVw"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Discord
|
||||
</a>{' '}
|
||||
.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ImportingStatus = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<>
|
||||
<div className={style.importModalTitle}>
|
||||
{t['com.affine.import.status.importing.title']()}
|
||||
</div>
|
||||
<p className={style.importStatusContent}>
|
||||
{t['com.affine.import.status.importing.message']()}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SuccessStatus = ({ onComplete }: { onComplete: () => void }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<>
|
||||
<div className={style.importModalTitle}>
|
||||
{t['com.affine.import.status.success.title']()}
|
||||
</div>
|
||||
<p className={style.importStatusContent}>
|
||||
{t['com.affine.import.status.success.message']()}{' '}
|
||||
<a
|
||||
className={style.link}
|
||||
href={DISCORD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div className={style.importModalButtonContainer}>
|
||||
<Button onClick={onComplete} variant="primary">
|
||||
{t['Complete']()}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorStatus = ({
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
error: string | null;
|
||||
onRetry: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const urlService = useService(UrlService);
|
||||
return (
|
||||
<>
|
||||
<div className={style.importModalTitle}>
|
||||
{t['com.affine.import.status.failed.title']()}
|
||||
</div>
|
||||
<p className={style.importStatusContent}>
|
||||
{error || 'Unknown error occurred'}
|
||||
</p>
|
||||
<div className={style.importModalButtonContainer}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
urlService.openPopupWindow(DISCORD_URL);
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
{t['Feedback']()}
|
||||
</Button>
|
||||
<Button onClick={onRetry} variant="primary">
|
||||
{t['Retry']()}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImportModal = ({ ...modalProps }) => {
|
||||
const t = useI18n();
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [pageIds, setPageIds] = useState<string[]>([]);
|
||||
const setOpenImportModalAtom = useSetAtom(openImportModalAtom);
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const docCollection = workspace.docCollection;
|
||||
|
||||
const handleImport = useAsyncCallback(
|
||||
async (type: ImportType) => {
|
||||
setImportError(null);
|
||||
try {
|
||||
const importConfig = importConfigs[type];
|
||||
const file = await openFileOrFiles(importConfig.fileOptions);
|
||||
|
||||
if (!file || (Array.isArray(file) && file.length === 0)) {
|
||||
throw new Error(
|
||||
t['com.affine.import.status.failed.message.no-file-selected']()
|
||||
);
|
||||
}
|
||||
|
||||
setStatus('importing');
|
||||
track.$.importModal.$.import({
|
||||
type,
|
||||
status: 'importing',
|
||||
});
|
||||
|
||||
const pageIds = await importConfig.importFunction(docCollection, file);
|
||||
|
||||
setPageIds(pageIds);
|
||||
setStatus('success');
|
||||
track.$.importModal.$.import({
|
||||
type,
|
||||
status: 'success',
|
||||
result: {
|
||||
docCount: pageIds.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
setImportError(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
);
|
||||
setStatus('error');
|
||||
track.$.importModal.$.import({
|
||||
type,
|
||||
status: 'failed',
|
||||
error: importError || undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
[docCollection, t, importError]
|
||||
);
|
||||
|
||||
const handleComplete = useCallback(() => {
|
||||
if (pageIds.length > 1) {
|
||||
workbench.openAll();
|
||||
} else if (pageIds.length === 1) {
|
||||
workbench.openDoc(pageIds[0]);
|
||||
}
|
||||
setOpenImportModalAtom(false);
|
||||
}, [pageIds, workbench, setOpenImportModalAtom]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setStatus('idle');
|
||||
};
|
||||
|
||||
const statusComponents = {
|
||||
idle: <ImportOptions onImport={handleImport} />,
|
||||
importing: <ImportingStatus />,
|
||||
success: <SuccessStatus onComplete={handleComplete} />,
|
||||
error: <ErrorStatus error={importError} onRetry={handleRetry} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
width={480}
|
||||
contentOptions={{
|
||||
['data-testid' as string]: 'import-modal',
|
||||
style: {
|
||||
maxHeight: '85vh',
|
||||
maxWidth: '70vw',
|
||||
minHeight: '126px',
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
},
|
||||
}}
|
||||
closeButtonOptions={{
|
||||
className: style.closeButton,
|
||||
}}
|
||||
withoutCloseButton={status === 'importing'}
|
||||
persistent={status === 'importing'}
|
||||
{...modalProps}
|
||||
>
|
||||
<div className={style.importModalContainer}>
|
||||
{statusComponents[status]}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,110 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const importModalContainer = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
boxSizing: 'border-box',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px 24px',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const importModalTitle = style({
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
fontSize: cssVar('fontH6'),
|
||||
fontWeight: '600',
|
||||
lineHeight: cssVar('lineHeight'),
|
||||
});
|
||||
|
||||
export const importModalContent = style({
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const closeButton = style({
|
||||
top: '24px',
|
||||
right: '24px',
|
||||
});
|
||||
|
||||
export const importModalTip = style({
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
fontSize: cssVar('fontSm'),
|
||||
lineHeight: cssVar('lineHeight'),
|
||||
fontWeight: '400',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
});
|
||||
|
||||
export const link = style({
|
||||
color: cssVar('linkColor'),
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const importStatusContent = style({
|
||||
width: '100%',
|
||||
fontSize: cssVar('fontBase'),
|
||||
lineHeight: cssVar('lineHeight'),
|
||||
fontWeight: '400',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
});
|
||||
|
||||
export const importModalButtonContainer = style({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '20px',
|
||||
justifyContent: 'end',
|
||||
marginTop: '20px',
|
||||
});
|
||||
|
||||
export const importItem = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
gap: '4px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
background: cssVarV2('button/secondary'),
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: cssVarV2('layer/background/hoverOverlay'),
|
||||
cursor: 'pointer',
|
||||
transition: 'background .30s',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const importItemLabel = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 4px',
|
||||
textAlign: 'left',
|
||||
flex: 1,
|
||||
color: cssVar('textPrimaryColor'),
|
||||
fontSize: cssVar('fontBase'),
|
||||
lineHeight: cssVar('lineHeight'),
|
||||
fontWeight: '500',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const importItemPrefix = style({
|
||||
marginRight: 'auto',
|
||||
});
|
||||
|
||||
export const importItemSuffix = style({
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
||||
import { JournalService } from '@affine/core/modules/journal';
|
||||
import { I18n } from '@affine/i18n';
|
||||
@@ -8,6 +9,68 @@ import { LinkedWidgetUtils } from '@blocksuite/affine/blocks';
|
||||
import type { DocMeta } from '@blocksuite/affine/store';
|
||||
import { type FrameworkProvider, WorkspaceService } from '@toeverything/infra';
|
||||
|
||||
function createNewDocMenuGroup(
|
||||
framework: FrameworkProvider,
|
||||
query: string,
|
||||
abort: () => void,
|
||||
editorHost: EditorHost,
|
||||
inlineEditor: AffineInlineEditor
|
||||
) {
|
||||
const originalNewDocMenuGroup = LinkedWidgetUtils.createNewDocMenuGroup(
|
||||
query,
|
||||
abort,
|
||||
editorHost,
|
||||
inlineEditor
|
||||
);
|
||||
|
||||
// Patch the import item, to use the custom import dialog.
|
||||
const importItemIndex = originalNewDocMenuGroup.items.findIndex(
|
||||
item => item.key === 'import'
|
||||
);
|
||||
if (importItemIndex === -1) {
|
||||
return originalNewDocMenuGroup;
|
||||
}
|
||||
|
||||
const originalItems = originalNewDocMenuGroup.items;
|
||||
const originalImportItem = originalItems[importItemIndex];
|
||||
const customImportItem = {
|
||||
...originalImportItem,
|
||||
action: () => {
|
||||
abort();
|
||||
track.doc.editor.atMenu.import();
|
||||
framework
|
||||
.get(WorkspaceDialogService)
|
||||
.open('import', undefined, payload => {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the imported file is a workspace file, insert the entry page node.
|
||||
const { docIds, entryId, isWorkspaceFile } = payload;
|
||||
if (isWorkspaceFile && entryId) {
|
||||
LinkedWidgetUtils.insertLinkedNode({
|
||||
inlineEditor,
|
||||
docId: entryId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, insert all the doc nodes.
|
||||
for (const docId of docIds) {
|
||||
LinkedWidgetUtils.insertLinkedNode({
|
||||
inlineEditor,
|
||||
docId,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// only replace the original import item
|
||||
originalItems.splice(importItemIndex, 1, customImportItem);
|
||||
return originalNewDocMenuGroup;
|
||||
}
|
||||
|
||||
// TODO: fix the type
|
||||
export function createLinkedWidgetConfig(
|
||||
framework: FrameworkProvider
|
||||
@@ -70,7 +133,8 @@ export function createLinkedWidgetConfig(
|
||||
maxDisplay: MAX_DOCS,
|
||||
overflowText: `${docMetas.length - MAX_DOCS} more docs`,
|
||||
},
|
||||
LinkedWidgetUtils.createNewDocMenuGroup(
|
||||
createNewDocMenuGroup(
|
||||
framework,
|
||||
query,
|
||||
abort,
|
||||
editorHost,
|
||||
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
} from '@affine/core/components/page-list';
|
||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||
import { useDetailPageHeaderResponsive } from '@affine/core/desktop/pages/workspace/detail-page/use-header-responsive';
|
||||
import {
|
||||
GlobalDialogService,
|
||||
WorkspaceDialogService,
|
||||
} from '@affine/core/modules/dialogs';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { EditorService } from '@affine/core/modules/editor';
|
||||
import { OpenInAppService } from '@affine/core/modules/open-in-app/services';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
@@ -79,7 +76,6 @@ export const PageHeaderMenuButton = ({
|
||||
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
|
||||
const globalDialogService = useService(GlobalDialogService);
|
||||
const editorService = useService(EditorService);
|
||||
const isInTrash = useLiveData(
|
||||
editorService.editor.doc.meta$.map(meta => meta.trash)
|
||||
@@ -204,10 +200,39 @@ export const PageHeaderMenuButton = ({
|
||||
});
|
||||
}, [duplicate, pageId]);
|
||||
|
||||
const handleOpenDocs = useCallback(
|
||||
(result: {
|
||||
docIds: string[];
|
||||
entryId?: string;
|
||||
isWorkspaceFile?: boolean;
|
||||
}) => {
|
||||
const { docIds, entryId, isWorkspaceFile } = result;
|
||||
// If the imported file is a workspace file, open the entry page.
|
||||
if (isWorkspaceFile && entryId) {
|
||||
workbench.openDoc(entryId);
|
||||
} else if (!docIds.length) {
|
||||
return;
|
||||
}
|
||||
// Open all the docs when there are multiple docs imported.
|
||||
if (docIds.length > 1) {
|
||||
workbench.openAll();
|
||||
} else {
|
||||
// Otherwise, open the only doc.
|
||||
workbench.openDoc(docIds[0]);
|
||||
}
|
||||
},
|
||||
[workbench]
|
||||
);
|
||||
|
||||
const handleOpenImportModal = useCallback(() => {
|
||||
track.$.header.importModal.open();
|
||||
globalDialogService.open('import', undefined);
|
||||
}, [globalDialogService]);
|
||||
workspaceDialogService.open('import', undefined, payload => {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
handleOpenDocs(payload);
|
||||
});
|
||||
}, [workspaceDialogService, handleOpenDocs]);
|
||||
|
||||
const handleShareMenuOpenChange = useCallback((open: boolean) => {
|
||||
if (open) {
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
SidebarScrollableContainer,
|
||||
} from '@affine/core/modules/app-sidebar/views';
|
||||
import { ExternalMenuLinkItem } from '@affine/core/modules/app-sidebar/views/menu-item/external-menu-link-item';
|
||||
import { GlobalDialogService } from '@affine/core/modules/dialogs';
|
||||
import {
|
||||
GlobalDialogService,
|
||||
WorkspaceDialogService,
|
||||
} from '@affine/core/modules/dialogs';
|
||||
import {
|
||||
ExplorerCollections,
|
||||
ExplorerFavorites,
|
||||
@@ -84,6 +87,7 @@ export const RootAppSidebar = (): ReactElement => {
|
||||
const currentWorkspace = workspaceService.workspace;
|
||||
const t = useI18n();
|
||||
const globalDialogService = useService(GlobalDialogService);
|
||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||
const workbench = workbenchService.workbench;
|
||||
const currentPath = useLiveData(
|
||||
workbench.location$.map(location => location.pathname)
|
||||
@@ -111,10 +115,39 @@ export const RootAppSidebar = (): ReactElement => {
|
||||
track.$.navigationPanel.$.openSettings();
|
||||
}, [globalDialogService]);
|
||||
|
||||
const handleOpenDocs = useCallback(
|
||||
(result: {
|
||||
docIds: string[];
|
||||
entryId?: string;
|
||||
isWorkspaceFile?: boolean;
|
||||
}) => {
|
||||
const { docIds, entryId, isWorkspaceFile } = result;
|
||||
// If the imported file is a workspace file, open the entry page.
|
||||
if (isWorkspaceFile && entryId) {
|
||||
workbench.openDoc(entryId);
|
||||
} else if (!docIds.length) {
|
||||
return;
|
||||
}
|
||||
// Open all the docs when there are multiple docs imported.
|
||||
if (docIds.length > 1) {
|
||||
workbench.openAll();
|
||||
} else {
|
||||
// Otherwise, open the only doc.
|
||||
workbench.openDoc(docIds[0]);
|
||||
}
|
||||
},
|
||||
[workbench]
|
||||
);
|
||||
|
||||
const onOpenImportModal = useCallback(() => {
|
||||
track.$.navigationPanel.importModal.open();
|
||||
globalDialogService.open('import', undefined);
|
||||
}, [globalDialogService]);
|
||||
workspaceDialogService.open('import', undefined, payload => {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
handleOpenDocs(payload);
|
||||
});
|
||||
}, [workspaceDialogService, handleOpenDocs]);
|
||||
|
||||
return (
|
||||
<AppSidebar>
|
||||
|
||||
@@ -2,11 +2,11 @@ import { Button, IconButton, Modal } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import type {
|
||||
DialogComponentProps,
|
||||
GLOBAL_DIALOG_SCHEMA,
|
||||
WORKSPACE_DIALOG_SCHEMA,
|
||||
} from '@affine/core/modules/dialogs';
|
||||
import { UrlService } from '@affine/core/modules/url';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
import {
|
||||
MarkdownTransformer,
|
||||
NotionHtmlTransformer,
|
||||
@@ -28,13 +28,18 @@ import * as style from './styles.css';
|
||||
type ImportType = 'markdown' | 'markdownZip' | 'notion';
|
||||
type AcceptType = 'Markdown' | 'Zip';
|
||||
type Status = 'idle' | 'importing' | 'success' | 'error';
|
||||
type ImportResult = {
|
||||
docIds: string[];
|
||||
entryId?: string;
|
||||
isWorkspaceFile?: boolean;
|
||||
};
|
||||
|
||||
type ImportConfig = {
|
||||
fileOptions: { acceptType: AcceptType; multiple: boolean };
|
||||
importFunction: (
|
||||
docCollection: DocCollection,
|
||||
file: File | File[]
|
||||
) => Promise<string[]>;
|
||||
) => Promise<ImportResult>;
|
||||
};
|
||||
|
||||
const DISCORD_URL = 'https://discord.gg/whd5mjYqVw';
|
||||
@@ -83,18 +88,20 @@ const importConfigs: Record<ImportType, ImportConfig> = {
|
||||
if (!Array.isArray(files)) {
|
||||
throw new Error('Expected an array of files for markdown files import');
|
||||
}
|
||||
const pageIds: string[] = [];
|
||||
const docIds: string[] = [];
|
||||
for (const file of files) {
|
||||
const text = await file.text();
|
||||
const fileName = file.name.split('.').slice(0, -1).join('.');
|
||||
const pageId = await MarkdownTransformer.importMarkdownToDoc({
|
||||
const docId = await MarkdownTransformer.importMarkdownToDoc({
|
||||
collection: docCollection,
|
||||
markdown: text,
|
||||
fileName,
|
||||
});
|
||||
if (pageId) pageIds.push(pageId);
|
||||
if (docId) docIds.push(docId);
|
||||
}
|
||||
return pageIds;
|
||||
return {
|
||||
docIds,
|
||||
};
|
||||
},
|
||||
},
|
||||
markdownZip: {
|
||||
@@ -103,10 +110,13 @@ const importConfigs: Record<ImportType, ImportConfig> = {
|
||||
if (Array.isArray(file)) {
|
||||
throw new Error('Expected a single zip file for markdownZip import');
|
||||
}
|
||||
return MarkdownTransformer.importMarkdownZip({
|
||||
const docIds = await MarkdownTransformer.importMarkdownZip({
|
||||
collection: docCollection,
|
||||
imported: file,
|
||||
});
|
||||
return {
|
||||
docIds,
|
||||
};
|
||||
},
|
||||
},
|
||||
notion: {
|
||||
@@ -115,11 +125,16 @@ const importConfigs: Record<ImportType, ImportConfig> = {
|
||||
if (Array.isArray(file)) {
|
||||
throw new Error('Expected a single zip file for notion import');
|
||||
}
|
||||
const { pageIds } = await NotionHtmlTransformer.importNotionZip({
|
||||
collection: docCollection,
|
||||
imported: file,
|
||||
});
|
||||
return pageIds;
|
||||
const { entryId, pageIds, isWorkspaceFile } =
|
||||
await NotionHtmlTransformer.importNotionZip({
|
||||
collection: docCollection,
|
||||
imported: file,
|
||||
});
|
||||
return {
|
||||
docIds: pageIds,
|
||||
entryId,
|
||||
isWorkspaceFile,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -274,13 +289,12 @@ const ErrorStatus = ({
|
||||
|
||||
export const ImportDialog = ({
|
||||
close,
|
||||
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['import']>) => {
|
||||
}: DialogComponentProps<WORKSPACE_DIALOG_SCHEMA['import']>) => {
|
||||
const t = useI18n();
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [pageIds, setPageIds] = useState<string[]>([]);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const docCollection = workspace.docCollection;
|
||||
|
||||
const handleImport = useAsyncCallback(
|
||||
@@ -297,29 +311,41 @@ export const ImportDialog = ({
|
||||
}
|
||||
|
||||
setStatus('importing');
|
||||
track.$.importModal.$.import({
|
||||
type,
|
||||
status: 'importing',
|
||||
});
|
||||
|
||||
const pageIds = await importConfig.importFunction(docCollection, file);
|
||||
const { docIds, entryId, isWorkspaceFile } =
|
||||
await importConfig.importFunction(docCollection, file);
|
||||
|
||||
setPageIds(pageIds);
|
||||
setImportResult({ docIds, entryId, isWorkspaceFile });
|
||||
setStatus('success');
|
||||
track.$.importModal.$.import({
|
||||
type,
|
||||
status: 'success',
|
||||
result: {
|
||||
docCount: docIds.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
setImportError(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
setImportError(errorMessage);
|
||||
setStatus('error');
|
||||
track.$.importModal.$.import({
|
||||
type,
|
||||
status: 'failed',
|
||||
error: errorMessage || undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
[docCollection, t]
|
||||
);
|
||||
|
||||
const handleComplete = useCallback(() => {
|
||||
if (pageIds.length > 1) {
|
||||
workbench.openAll();
|
||||
} else if (pageIds.length === 1) {
|
||||
workbench.openDoc(pageIds[0]);
|
||||
}
|
||||
close();
|
||||
}, [pageIds, close, workbench]);
|
||||
close(importResult || undefined);
|
||||
}, [importResult, close]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setStatus('idle');
|
||||
@@ -335,8 +361,10 @@ export const ImportDialog = ({
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onOpenChange={() => {
|
||||
close();
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
close(importResult || undefined);
|
||||
}
|
||||
}}
|
||||
width={480}
|
||||
contentOptions={{
|
||||
@@ -357,7 +385,7 @@ export const ImportDialog = ({
|
||||
withoutCloseButton={status === 'importing'}
|
||||
persistent={status === 'importing'}
|
||||
>
|
||||
<div className={style.importModalContainer}>
|
||||
<div className={style.importModalContainer} data-testid="import-dialog">
|
||||
{statusComponents[status]}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -24,7 +24,6 @@ const GLOBAL_DIALOGS = {
|
||||
'import-workspace': ImportWorkspaceDialog,
|
||||
'import-template': ImportTemplateDialog,
|
||||
setting: SettingDialog,
|
||||
import: ImportDialog,
|
||||
} satisfies {
|
||||
[key in keyof GLOBAL_DIALOG_SCHEMA]?: React.FC<
|
||||
DialogComponentProps<GLOBAL_DIALOG_SCHEMA[key]>
|
||||
@@ -37,6 +36,7 @@ const WORKSPACE_DIALOGS = {
|
||||
'tag-selector': TagSelectorDialog,
|
||||
'doc-selector': DocSelectorDialog,
|
||||
'collection-selector': CollectionSelectorDialog,
|
||||
import: ImportDialog,
|
||||
} satisfies {
|
||||
[key in keyof WORKSPACE_DIALOG_SCHEMA]?: React.FC<
|
||||
DialogComponentProps<WORKSPACE_DIALOG_SCHEMA[key]>
|
||||
|
||||
@@ -26,7 +26,6 @@ export type GLOBAL_DIALOG_SCHEMA = {
|
||||
templateMode: DocMode;
|
||||
snapshotUrl: string;
|
||||
}) => void;
|
||||
import: () => void;
|
||||
setting: (props: {
|
||||
activeTab?: SettingTab;
|
||||
workspaceMetadata?: WorkspaceMetadata | null;
|
||||
@@ -52,4 +51,9 @@ export type WORKSPACE_DIALOG_SCHEMA = {
|
||||
init: string[];
|
||||
onBeforeConfirm?: (ids: string[], cb: () => void) => void;
|
||||
}) => string[];
|
||||
import: () => {
|
||||
docIds: string[];
|
||||
entryId?: string;
|
||||
isWorkspaceFile?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user