feat(editor): extract linked doc widget package (#11589)

Close [BS-2738](https://github.com/toeverything/AFFiNE/pull/11589)
This commit is contained in:
Saul-Mirone
2025-04-10 15:42:46 +00:00
parent 149433b8d0
commit 4f9a4e739a
59 changed files with 200 additions and 139 deletions
@@ -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);
}
`;