feat(core): add clipper import interface (#10619)

This commit is contained in:
EYHN
2025-03-05 04:22:02 +00:00
parent 4daa763c95
commit 61635aa77a
11 changed files with 373 additions and 25 deletions

View File

@@ -0,0 +1,199 @@
import { Button } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { useWorkspaceName } from '@affine/core/components/hooks/use-workspace-info';
import { WorkspaceSelector } from '@affine/core/components/workspace-selector';
import { AuthService } from '@affine/core/modules/cloud';
import {
type ClipperInput,
ImportClipperService,
} from '@affine/core/modules/import-clipper';
import {
type WorkspaceMetadata,
WorkspacesService,
} from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { AllDocsIcon } from '@blocksuite/icons/rc';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useEffect, useState } from 'react';
import * as styles from './style.css';
const clipperInput$ = new LiveData<ClipperInput | null>(null);
window.addEventListener('message', event => {
if (
typeof event.data === 'object' &&
event.data.type === 'affine-clipper:import'
) {
clipperInput$.value = event.data.payload;
}
});
export const Component = () => {
const importClipperService = useService(ImportClipperService);
const t = useI18n();
const session = useService(AuthService).session;
const notLogin = useLiveData(session.status$) === 'unauthenticated';
const isSessionRevalidating = useLiveData(session.isRevalidating$);
const [importing, setImporting] = useState(false);
const [importingError, setImportingError] = useState<any>(null);
const clipperInput = useLiveData(clipperInput$);
const [clipperInputSnapshot, setClipperInputSnapshot] =
useState<ClipperInput | null>(null);
const isMissingInput = !clipperInputSnapshot;
const workspacesService = useService(WorkspacesService);
const workspaces = useLiveData(workspacesService.list.workspaces$);
const [rawSelectedWorkspace, setSelectedWorkspace] =
useState<WorkspaceMetadata | null>(null);
const selectedWorkspace =
rawSelectedWorkspace ??
workspaces.find(w => w.flavour !== 'local') ??
workspaces.at(0);
const selectedWorkspaceName = useWorkspaceName(selectedWorkspace);
const { jumpToSignIn } = useNavigateHelper();
const noWorkspace = workspaces.length === 0;
useEffect(() => {
workspacesService.list.revalidate();
}, [workspacesService]);
useEffect(() => {
session.revalidate();
}, [session]);
useEffect(() => {
if (!isSessionRevalidating && notLogin) {
jumpToSignIn('/clipper/import');
}
}, [isSessionRevalidating, jumpToSignIn, notLogin]);
useEffect(() => {
if (!clipperInputSnapshot) {
setClipperInputSnapshot(clipperInput);
}
}, [clipperInput, clipperInputSnapshot]);
const handleSelectedWorkspace = useCallback(
(workspaceMetadata: WorkspaceMetadata) => {
return setSelectedWorkspace(workspaceMetadata);
},
[]
);
const handleCreatedWorkspace = useCallback(
(payload: { metadata: WorkspaceMetadata; defaultDocId?: string }) => {
return setSelectedWorkspace(payload.metadata);
},
[]
);
const handleImportToSelectedWorkspace = useAsyncCallback(async () => {
if (clipperInputSnapshot && selectedWorkspace) {
setImporting(true);
try {
await importClipperService.importToWorkspace(
selectedWorkspace,
clipperInputSnapshot
);
window.close();
} catch (err) {
setImportingError(err);
} finally {
setImporting(false);
}
}
}, [clipperInputSnapshot, importClipperService, selectedWorkspace]);
const handleImportToNewWorkspace = useAsyncCallback(async () => {
if (!clipperInputSnapshot) {
return;
}
setImporting(true);
try {
await importClipperService.importToNewWorkspace(
'affine-cloud',
'Workspace',
clipperInputSnapshot
);
window.close();
} catch (err) {
setImportingError(err);
} finally {
setImporting(false);
}
}, [clipperInputSnapshot, importClipperService]);
const disabled = isMissingInput || importing || notLogin;
return (
<div className={styles.container}>
<AllDocsIcon className={styles.mainIcon} />
<h6 className={styles.mainTitle}>
{t['com.affine.import-clipper.dialog.createDocFromClipper']()}
</h6>
{noWorkspace ? (
<p className={styles.desc}>A new workspace will be created.</p>
) : (
<>
<p className={styles.desc}>Choose a workspace.</p>
<WorkspaceSelector
workspaceMetadata={selectedWorkspace}
onSelectWorkspace={handleSelectedWorkspace}
onCreatedWorkspace={handleCreatedWorkspace}
className={styles.workspaceSelector}
showArrowDownIcon
disable={disabled}
menuContentOptions={{
side: 'top',
style: {
maxHeight: 'min(600px, calc(50vh + 50px))',
width: 352,
maxWidth: 'calc(100vw - 20px)',
},
}}
/>
</>
)}
<div className={styles.buttonContainer}>
{importingError && (
<span style={{ color: cssVar('warningColor') }}>
{t['com.affine.import-clipper.dialog.errorImport']()}
</span>
)}
{isMissingInput ? (
<span style={{ color: cssVar('warningColor') }}>
{t['com.affine.import-clipper.dialog.errorLoad']()}
</span>
) : selectedWorkspace ? (
<Button
className={styles.mainButton}
variant={disabled ? 'secondary' : 'primary'}
loading={disabled}
disabled={disabled}
onClick={handleImportToSelectedWorkspace}
data-testid="import-clipper-to-workspace-btn"
>
{selectedWorkspaceName &&
t['com.affine.import-clipper.dialog.createDocToWorkspace']({
workspace: selectedWorkspaceName,
})}
</Button>
) : (
<Button
className={styles.mainButton}
variant="primary"
loading={disabled}
disabled={disabled}
onClick={handleImportToNewWorkspace}
>
{t['com.affine.import-clipper.dialog.createDocToNewWorkspace']()}
</Button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,50 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const container = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
maxWidth: '400px',
margin: '0 auto',
color: cssVarV2('text/primary'),
padding: '16px',
});
export const buttonContainer = style({
paddingTop: '16px',
});
export const mainIcon = style({
width: 36,
height: 36,
color: cssVarV2('icon/primary'),
});
export const mainTitle = style({
fontSize: '18px',
lineHeight: '26px',
textAlign: 'center',
marginTop: '16px',
fontWeight: 600,
});
export const desc = style({
textAlign: 'center',
color: cssVarV2('text/secondary'),
marginBottom: '20px',
});
export const mainButton = style({
width: '100%',
fontSize: '14px',
height: '42px',
});
export const workspaceSelector = style({
margin: '0 -16px',
width: 'calc(100% + 32px)',
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
padding: '0 16px',
});

View File

@@ -103,6 +103,10 @@ export const topLevelRoutes = [
path: '/theme-editor',
lazy: () => import('./pages/theme-editor'),
},
{
path: '/clipper/import',
lazy: () => import('./pages/import-clipper'),
},
{
path: '/template/import',
lazy: () => import('./pages/import-template'),

View File

@@ -0,0 +1,10 @@
import { type Framework } from '@toeverything/infra';
import { WorkspacesService } from '../workspace';
import { ImportClipperService } from './services/import';
export { type ClipperInput, ImportClipperService } from './services/import';
export function configureImportClipperModule(framework: Framework) {
framework.service(ImportClipperService, [WorkspacesService]);
}

View File

@@ -0,0 +1,76 @@
import { MarkdownTransformer } from '@blocksuite/affine/blocks';
import { Service } from '@toeverything/infra';
import { DocsService } from '../../doc';
import {
getAFFiNEWorkspaceSchema,
type WorkspaceMetadata,
type WorkspacesService,
} from '../../workspace';
export interface ClipperInput {
title: string;
contentMarkdown: string;
contentHtml: string;
attachments: Record<string, Blob>;
}
export class ImportClipperService extends Service {
constructor(private readonly workspacesService: WorkspacesService) {
super();
}
async importToWorkspace(
workspaceMetadata: WorkspaceMetadata,
clipperInput: ClipperInput
) {
const { workspace, dispose: disposeWorkspace } =
this.workspacesService.open({
metadata: workspaceMetadata,
});
await workspace.engine.doc.waitForDocReady(workspace.id); // wait for root doc ready
const docId = await MarkdownTransformer.importMarkdownToDoc({
collection: workspace.docCollection,
schema: getAFFiNEWorkspaceSchema(),
markdown: clipperInput.contentMarkdown,
});
const docsService = workspace.scope.get(DocsService);
if (docId) {
// only support page mode for now
docsService.list.setPrimaryMode(docId, 'page');
workspace.engine.doc.addPriority(workspace.id, 100);
workspace.engine.doc.addPriority(docId, 100);
await workspace.engine.doc.waitForDocSynced(workspace.id);
await workspace.engine.doc.waitForDocSynced(docId);
disposeWorkspace();
return docId;
} else {
throw new Error('Failed to import doc');
}
}
async importToNewWorkspace(
flavour: string,
workspaceName: string,
clipperInput: ClipperInput
) {
// oxlint-disable-next-line @typescript-eslint/no-non-null-assertion
let docId: string | undefined;
const { id: workspaceId } = await this.workspacesService.create(
flavour,
async docCollection => {
docCollection.meta.initialize();
docCollection.meta.setName(workspaceName);
docId = await MarkdownTransformer.importMarkdownToDoc({
collection: docCollection,
schema: getAFFiNEWorkspaceSchema(),
markdown: clipperInput.contentMarkdown,
});
}
);
if (!docId) {
throw new Error('Failed to import doc');
}
return { workspaceId, docId };
}
}

View File

@@ -1,22 +0,0 @@
import type { DocMode } from '@blocksuite/affine/blocks';
import { Entity, LiveData } from '@toeverything/infra';
interface TemplateOptions {
templateName: string;
snapshotUrl: string;
templateMode: DocMode;
}
export class ImportTemplateDialog extends Entity {
readonly isOpen$ = new LiveData(false);
readonly template$ = new LiveData<TemplateOptions | null>(null);
open(options: TemplateOptions) {
this.template$.next(options);
this.isOpen$.next(true);
}
close() {
this.isOpen$.next(false);
}
}

View File

@@ -1,7 +1,6 @@
import { type Framework } from '@toeverything/infra';
import { WorkspacesService } from '../workspace';
import { ImportTemplateDialog } from './entities/dialog';
import { TemplateDownloader } from './entities/downloader';
import { TemplateDownloaderService } from './services/downloader';
import { ImportTemplateService } from './services/import';
@@ -12,7 +11,6 @@ export { ImportTemplateService } from './services/import';
export function configureImportTemplateModule(framework: Framework) {
framework
.entity(ImportTemplateDialog)
.service(TemplateDownloaderService)
.entity(TemplateDownloader, [TemplateDownloaderStore])
.store(TemplateDownloaderStore)

View File

@@ -26,6 +26,7 @@ import { configureFavoriteModule } from './favorite';
import { configureFeatureFlagModule } from './feature-flag';
import { configureGlobalContextModule } from './global-context';
import { configureI18nModule } from './i18n';
import { configureImportClipperModule } from './import-clipper';
import { configureImportTemplateModule } from './import-template';
import { configureJournalModule } from './journal';
import { configureLifecycleModule } from './lifecycle';
@@ -100,4 +101,5 @@ export function configureCommonModules(framework: Framework) {
configureAIButtonModule(framework);
configureTemplateDocModule(framework);
configureBlobManagementModule(framework);
configureImportClipperModule(framework);
}