mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-13 04:42:56 +08:00
feat(editor): extract linked doc widget package (#11589)
Close [BS-2738](https://github.com/toeverything/AFFiNE/pull/11589)
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
CloseIcon,
|
||||
ExportToHTMLIcon,
|
||||
ExportToMarkdownIcon,
|
||||
HelpIcon,
|
||||
NewIcon,
|
||||
NotionIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { openFileOrFiles } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import type { Schema, Workspace } from '@blocksuite/store';
|
||||
import { html, LitElement, type PropertyValues } from 'lit';
|
||||
import { query, state } from 'lit/decorators.js';
|
||||
|
||||
import { HtmlTransformer } from '../transformers/html.js';
|
||||
import { MarkdownTransformer } from '../transformers/markdown.js';
|
||||
import { NotionHtmlTransformer } from '../transformers/notion-html.js';
|
||||
import { styles } from './styles.js';
|
||||
|
||||
export type OnSuccessHandler = (
|
||||
pageIds: string[],
|
||||
options: { isWorkspaceFile: boolean; importedCount: number }
|
||||
) => void;
|
||||
|
||||
export type OnFailHandler = (message: string) => void;
|
||||
|
||||
const SHOW_LOADING_SIZE = 1024 * 200;
|
||||
|
||||
export class ImportDoc extends WithDisposable(LitElement) {
|
||||
static override styles = styles;
|
||||
|
||||
constructor(
|
||||
private readonly collection: Workspace,
|
||||
private readonly schema: Schema,
|
||||
private readonly onSuccess?: OnSuccessHandler,
|
||||
private readonly onFail?: OnFailHandler,
|
||||
private readonly abortController = new AbortController()
|
||||
) {
|
||||
super();
|
||||
|
||||
this._loading = false;
|
||||
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this._startX = 0;
|
||||
this._startY = 0;
|
||||
|
||||
this._onMouseMove = this._onMouseMove.bind(this);
|
||||
}
|
||||
|
||||
private async _importHtml() {
|
||||
const files = await openFileOrFiles({ acceptType: 'Html', multiple: true });
|
||||
if (!files) return;
|
||||
const pageIds: string[] = [];
|
||||
for (const file of files) {
|
||||
const text = await file.text();
|
||||
const needLoading = file.size > SHOW_LOADING_SIZE;
|
||||
const fileName = file.name.split('.').slice(0, -1).join('.');
|
||||
if (needLoading) {
|
||||
this.hidden = false;
|
||||
this._loading = true;
|
||||
} else {
|
||||
this.abortController.abort();
|
||||
}
|
||||
const pageId = await HtmlTransformer.importHTMLToDoc({
|
||||
collection: this.collection,
|
||||
schema: this.schema,
|
||||
html: text,
|
||||
fileName,
|
||||
});
|
||||
needLoading && this.abortController.abort();
|
||||
if (pageId) {
|
||||
pageIds.push(pageId);
|
||||
}
|
||||
}
|
||||
this._onImportSuccess(pageIds);
|
||||
}
|
||||
|
||||
private async _importMarkDown() {
|
||||
const files = await openFileOrFiles({
|
||||
acceptType: 'Markdown',
|
||||
multiple: true,
|
||||
});
|
||||
if (!files) return;
|
||||
const pageIds: string[] = [];
|
||||
for (const file of files) {
|
||||
const text = await file.text();
|
||||
const fileName = file.name.split('.').slice(0, -1).join('.');
|
||||
const needLoading = file.size > SHOW_LOADING_SIZE;
|
||||
if (needLoading) {
|
||||
this.hidden = false;
|
||||
this._loading = true;
|
||||
} else {
|
||||
this.abortController.abort();
|
||||
}
|
||||
const pageId = await MarkdownTransformer.importMarkdownToDoc({
|
||||
collection: this.collection,
|
||||
schema: this.schema,
|
||||
markdown: text,
|
||||
fileName,
|
||||
});
|
||||
needLoading && this.abortController.abort();
|
||||
if (pageId) {
|
||||
pageIds.push(pageId);
|
||||
}
|
||||
}
|
||||
this._onImportSuccess(pageIds);
|
||||
}
|
||||
|
||||
private async _importNotion() {
|
||||
const file = await openFileOrFiles({ acceptType: 'Zip' });
|
||||
if (!file) return;
|
||||
const needLoading = file.size > SHOW_LOADING_SIZE;
|
||||
if (needLoading) {
|
||||
this.hidden = false;
|
||||
this._loading = true;
|
||||
} else {
|
||||
this.abortController.abort();
|
||||
}
|
||||
const { entryId, pageIds, isWorkspaceFile, hasMarkdown } =
|
||||
await NotionHtmlTransformer.importNotionZip({
|
||||
collection: this.collection,
|
||||
schema: this.schema,
|
||||
imported: file,
|
||||
});
|
||||
needLoading && this.abortController.abort();
|
||||
if (hasMarkdown) {
|
||||
this._onFail(
|
||||
'Importing markdown files from Notion is deprecated. Please export your Notion pages as HTML.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._onImportSuccess([entryId], {
|
||||
isWorkspaceFile,
|
||||
importedCount: pageIds.length,
|
||||
});
|
||||
}
|
||||
|
||||
private _onCloseClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private _onFail(message: string) {
|
||||
this.onFail?.(message);
|
||||
}
|
||||
|
||||
private _onImportSuccess(
|
||||
pageIds: string[],
|
||||
options: { isWorkspaceFile?: boolean; importedCount?: number } = {}
|
||||
) {
|
||||
const {
|
||||
isWorkspaceFile = false,
|
||||
importedCount: pagesImportedCount = pageIds.length,
|
||||
} = options;
|
||||
this.onSuccess?.(pageIds, {
|
||||
isWorkspaceFile,
|
||||
importedCount: pagesImportedCount,
|
||||
});
|
||||
}
|
||||
|
||||
private _onMouseDown(event: MouseEvent) {
|
||||
this._startX = event.clientX - this.x;
|
||||
this._startY = event.clientY - this.y;
|
||||
window.addEventListener('mousemove', this._onMouseMove);
|
||||
}
|
||||
|
||||
private _onMouseMove(event: MouseEvent) {
|
||||
this.x = event.clientX - this._startX;
|
||||
this.y = event.clientY - this._startY;
|
||||
}
|
||||
|
||||
private _onMouseUp() {
|
||||
window.removeEventListener('mousemove', this._onMouseMove);
|
||||
}
|
||||
|
||||
private _openLearnImportLink(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
window.open(
|
||||
'https://affine.pro/blog/import-your-data-from-notion-into-affine',
|
||||
'_blank'
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this._loading) {
|
||||
return html`
|
||||
<div class="overlay-mask"></div>
|
||||
<div class="container">
|
||||
<header
|
||||
class="loading-header"
|
||||
@mousedown="${this._onMouseDown}"
|
||||
@mouseup="${this._onMouseUp}"
|
||||
>
|
||||
<div>Import</div>
|
||||
<loader-element .width=${'50px'}></loader-element>
|
||||
</header>
|
||||
<div>
|
||||
Importing the file may take some time. It depends on document size
|
||||
and complexity.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<div
|
||||
class="overlay-mask"
|
||||
@click="${() => this.abortController.abort()}"
|
||||
></div>
|
||||
<div class="container">
|
||||
<header @mousedown="${this._onMouseDown}" @mouseup="${this._onMouseUp}">
|
||||
<icon-button height="28px" @click="${this._onCloseClick}">
|
||||
${CloseIcon}
|
||||
</icon-button>
|
||||
<div>Import</div>
|
||||
</header>
|
||||
<div>
|
||||
AFFiNE will gradually support more file formats for import.
|
||||
<a
|
||||
href="https://community.affine.pro/c/feature-requests/import-export"
|
||||
target="_blank"
|
||||
>Provide feedback.</a
|
||||
>
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<icon-button
|
||||
class="button-item"
|
||||
text="Markdown"
|
||||
@click="${this._importMarkDown}"
|
||||
>
|
||||
${ExportToMarkdownIcon}
|
||||
</icon-button>
|
||||
<icon-button
|
||||
class="button-item"
|
||||
text="HTML"
|
||||
@click="${this._importHtml}"
|
||||
>
|
||||
${ExportToHTMLIcon}
|
||||
</icon-button>
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<icon-button
|
||||
class="button-item"
|
||||
text="Notion"
|
||||
@click="${this._importNotion}"
|
||||
>
|
||||
${NotionIcon}
|
||||
<div
|
||||
slot="suffix"
|
||||
class="button-suffix"
|
||||
@click="${this._openLearnImportLink}"
|
||||
>
|
||||
${HelpIcon}
|
||||
<affine-tooltip>
|
||||
Learn how to Import your Notion pages into AFFiNE.
|
||||
</affine-tooltip>
|
||||
</div>
|
||||
</icon-button>
|
||||
<icon-button class="button-item" text="Coming soon..." disabled>
|
||||
${NewIcon}
|
||||
</icon-button>
|
||||
</div>
|
||||
<!-- <div class="footer">
|
||||
<div>Migrate from other versions of AFFiNE?</div>
|
||||
</div> -->
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override updated(changedProps: PropertyValues) {
|
||||
if (changedProps.has('x') || changedProps.has('y')) {
|
||||
this.containerEl.style.transform = `translate(${this.x}px, ${this.y}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor _loading = false;
|
||||
|
||||
@state()
|
||||
accessor _startX = 0;
|
||||
|
||||
@state()
|
||||
accessor _startY = 0;
|
||||
|
||||
@query('.container')
|
||||
accessor containerEl!: HTMLElement;
|
||||
|
||||
@state()
|
||||
accessor x = 0;
|
||||
|
||||
@state()
|
||||
accessor y = 0;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Schema, Workspace } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
ImportDoc,
|
||||
type OnFailHandler,
|
||||
type OnSuccessHandler,
|
||||
} from './import-doc.js';
|
||||
|
||||
export function showImportModal({
|
||||
schema,
|
||||
collection,
|
||||
onSuccess,
|
||||
onFail,
|
||||
container = document.body,
|
||||
abortController = new AbortController(),
|
||||
}: {
|
||||
schema: Schema;
|
||||
collection: Workspace;
|
||||
onSuccess?: OnSuccessHandler;
|
||||
onFail?: OnFailHandler;
|
||||
multiple?: boolean;
|
||||
container?: HTMLElement;
|
||||
abortController?: AbortController;
|
||||
}) {
|
||||
const importDoc = new ImportDoc(
|
||||
collection,
|
||||
schema,
|
||||
onSuccess,
|
||||
onFail,
|
||||
abortController
|
||||
);
|
||||
container.append(importDoc);
|
||||
abortController.signal.addEventListener('abort', () => importDoc.remove());
|
||||
return importDoc;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { BLOCK_ID_ATTR } from '@blocksuite/std';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
export class Loader extends LitElement {
|
||||
static override styles = css`
|
||||
.load-container {
|
||||
margin: 10px auto;
|
||||
width: var(--loader-width);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.load-container .load {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--affine-text-primary-color);
|
||||
|
||||
border-radius: 100%;
|
||||
display: inline-block;
|
||||
-webkit-animation: bouncedelay 1.4s infinite ease-in-out;
|
||||
animation: bouncedelay 1.4s infinite ease-in-out;
|
||||
/* Prevent first note from flickering when animation starts */
|
||||
-webkit-animation-fill-mode: both;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.load-container .load1 {
|
||||
-webkit-animation-delay: -0.32s;
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
.load-container .load2 {
|
||||
-webkit-animation-delay: -0.16s;
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@-webkit-keyframes bouncedelay {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
-webkit-transform: scale(0.625);
|
||||
}
|
||||
40% {
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bouncedelay {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
-webkit-transform: scale(0.625);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hostModel) {
|
||||
this.setAttribute(BLOCK_ID_ATTR, this.hostModel.id);
|
||||
this.dataset.serviceLoading = 'true';
|
||||
}
|
||||
|
||||
const width = this.width;
|
||||
this.style.setProperty(
|
||||
'--loader-width',
|
||||
typeof width === 'string' ? width : `${width}px`
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="load-container">
|
||||
<div class="load load1"></div>
|
||||
<div class="load load2"></div>
|
||||
<div class="load"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor hostModel: BlockModel | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor radius: string | number = '8px';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor width: string | number = '150px';
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'loader-element': Loader;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, unsafeCSS } from 'lit';
|
||||
|
||||
export const styles = css`
|
||||
.container {
|
||||
position: absolute;
|
||||
width: 480px;
|
||||
left: calc(50% - 480px / 2);
|
||||
top: calc(50% - 270px / 2);
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
font-size: var(--affine-font-base);
|
||||
line-height: var(--affine-line-height);
|
||||
padding: 12px 40px 36px;
|
||||
gap: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--affine-background-primary-color);
|
||||
box-shadow: var(--affine-shadow-2);
|
||||
border-radius: 16px;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
|
||||
.container[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
header {
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
font-size: var(--affine-font-h-6);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a {
|
||||
white-space: nowrap;
|
||||
word-break: break-word;
|
||||
color: var(--affine-link-color);
|
||||
fill: var(--affine-link-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
header icon-button {
|
||||
margin-left: auto;
|
||||
position: relative;
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.button-container icon-button {
|
||||
padding: 8px 12px;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
width: 190px;
|
||||
height: 40px;
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
.loading-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button-suffix {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.overlay-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user