snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
This commit is contained in:
48
blocksuite/affine/widgets/linked-doc/package.json
Normal file
48
blocksuite/affine/widgets/linked-doc/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-widget-linked-doc",
|
||||
"description": "Affine linked doc widget.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-inline-reference": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"fflate": "^0.8.2",
|
||||
"js-yaml": "^5.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"mammoth": "^1.11.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./view": "./src/view.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.27.5"
|
||||
}
|
||||
230
blocksuite/affine/widgets/linked-doc/src/config.ts
Normal file
230
blocksuite/affine/widgets/linked-doc/src/config.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
ImportIcon,
|
||||
LinkedDocIcon,
|
||||
LinkedEdgelessIcon,
|
||||
NewDocIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { StoreExtensionManagerIdentifier } from '@blocksuite/affine-ext-loader';
|
||||
import { insertLinkedNode } from '@blocksuite/affine-inline-reference';
|
||||
import {
|
||||
DocModeProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
createDefaultDoc,
|
||||
isFuzzyMatch,
|
||||
type Signal,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import {
|
||||
type BlockStdScope,
|
||||
ConfigExtensionFactory,
|
||||
type EditorHost,
|
||||
} from '@blocksuite/std';
|
||||
import type { InlineRange } from '@blocksuite/std/inline';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import { showImportModal } from './import-doc/index.js';
|
||||
import type { LinkedDocViewExtensionOptions } from './view';
|
||||
|
||||
export type LinkedWidgetConfig = Required<
|
||||
Omit<LinkedDocViewExtensionOptions, 'autoFocusedItemKey'>
|
||||
> &
|
||||
Pick<LinkedDocViewExtensionOptions, 'autoFocusedItemKey'>;
|
||||
|
||||
export type LinkedMenuItem = {
|
||||
key: string;
|
||||
name: string | TemplateResult<1>;
|
||||
icon: TemplateResult<1>;
|
||||
suffix?: string | TemplateResult<1>;
|
||||
// disabled?: boolean;
|
||||
action: LinkedMenuAction;
|
||||
};
|
||||
|
||||
export type LinkedMenuAction = () => Promise<void> | void;
|
||||
|
||||
export type LinkedMenuGroup = {
|
||||
name: string;
|
||||
items: LinkedMenuItem[] | Signal<LinkedMenuItem[]>;
|
||||
styles?: string;
|
||||
// maximum quantity displayed by default
|
||||
maxDisplay?: number;
|
||||
// if the menu is loading
|
||||
loading?: boolean | Signal<boolean>;
|
||||
// copywriting when display quantity exceeds
|
||||
overflowText?: string | Signal<string>;
|
||||
// hide the group
|
||||
hidden?: boolean | Signal<boolean>;
|
||||
};
|
||||
|
||||
export type LinkedDocContext = {
|
||||
std: BlockStdScope;
|
||||
inlineEditor: AffineInlineEditor;
|
||||
startRange: InlineRange;
|
||||
startNativeRange: Range;
|
||||
triggerKey: string;
|
||||
config: LinkedWidgetConfig;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_DOC_NAME = 'Untitled';
|
||||
const DISPLAY_NAME_LENGTH = 8;
|
||||
|
||||
export function createLinkedDocMenuGroup(
|
||||
query: string,
|
||||
abort: () => void,
|
||||
editorHost: EditorHost,
|
||||
inlineEditor: AffineInlineEditor
|
||||
) {
|
||||
const doc = editorHost.store;
|
||||
const { docMetas } = doc.workspace.meta;
|
||||
const filteredDocList = docMetas
|
||||
.filter(({ id }) => id !== doc.id)
|
||||
.filter(({ title }) => isFuzzyMatch(title, query));
|
||||
const MAX_DOCS = 6;
|
||||
|
||||
return {
|
||||
name: 'Link to Doc',
|
||||
items: filteredDocList.map(doc => ({
|
||||
key: doc.id,
|
||||
name: doc.title || DEFAULT_DOC_NAME,
|
||||
icon:
|
||||
editorHost.std.get(DocModeProvider).getPrimaryMode(doc.id) ===
|
||||
'edgeless'
|
||||
? LinkedEdgelessIcon
|
||||
: LinkedDocIcon,
|
||||
action: () => {
|
||||
abort();
|
||||
insertLinkedNode({
|
||||
inlineEditor,
|
||||
docId: doc.id,
|
||||
});
|
||||
editorHost.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('LinkedDocCreated', {
|
||||
control: 'linked doc',
|
||||
module: 'inline @',
|
||||
type: 'doc',
|
||||
other: 'existing doc',
|
||||
});
|
||||
},
|
||||
})),
|
||||
maxDisplay: MAX_DOCS,
|
||||
overflowText: `${filteredDocList.length - MAX_DOCS} more docs`,
|
||||
};
|
||||
}
|
||||
|
||||
export function createNewDocMenuGroup(
|
||||
query: string,
|
||||
abort: () => void,
|
||||
editorHost: EditorHost,
|
||||
inlineEditor: AffineInlineEditor
|
||||
): LinkedMenuGroup {
|
||||
const doc = editorHost.store;
|
||||
const docName = query || DEFAULT_DOC_NAME;
|
||||
const displayDocName =
|
||||
docName.slice(0, DISPLAY_NAME_LENGTH) +
|
||||
(docName.length > DISPLAY_NAME_LENGTH ? '..' : '');
|
||||
|
||||
const items: LinkedMenuItem[] = [
|
||||
{
|
||||
key: 'create',
|
||||
name: `Create "${displayDocName}" doc`,
|
||||
icon: NewDocIcon,
|
||||
action: () => {
|
||||
abort();
|
||||
const docName = query;
|
||||
const newDoc = createDefaultDoc(doc.workspace, {
|
||||
title: docName,
|
||||
});
|
||||
insertLinkedNode({
|
||||
inlineEditor,
|
||||
docId: newDoc.id,
|
||||
});
|
||||
const telemetryService = editorHost.std.getOptional(TelemetryProvider);
|
||||
telemetryService?.track('LinkedDocCreated', {
|
||||
control: 'new doc',
|
||||
module: 'inline @',
|
||||
type: 'doc',
|
||||
other: 'new doc',
|
||||
});
|
||||
telemetryService?.track('DocCreated', {
|
||||
control: 'new doc',
|
||||
module: 'inline @',
|
||||
type: 'doc',
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (!IS_MOBILE) {
|
||||
items.push({
|
||||
key: 'import',
|
||||
name: 'Import',
|
||||
icon: ImportIcon,
|
||||
action: () => {
|
||||
abort();
|
||||
const onSuccess = (
|
||||
docIds: string[],
|
||||
options: {
|
||||
importedCount: number;
|
||||
}
|
||||
) => {
|
||||
toast(
|
||||
editorHost,
|
||||
`Successfully imported ${options.importedCount} Doc${options.importedCount > 1 ? 's' : ''}.`
|
||||
);
|
||||
for (const docId of docIds) {
|
||||
insertLinkedNode({
|
||||
inlineEditor,
|
||||
docId,
|
||||
});
|
||||
}
|
||||
};
|
||||
const onFail = (message: string) => {
|
||||
toast(editorHost, message);
|
||||
};
|
||||
const storeManager = editorHost.std.get(
|
||||
StoreExtensionManagerIdentifier
|
||||
);
|
||||
showImportModal({
|
||||
collection: doc.workspace,
|
||||
schema: doc.schema,
|
||||
extensions: storeManager.get('store'),
|
||||
onSuccess,
|
||||
onFail,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'New Doc',
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMenus(
|
||||
query: string,
|
||||
abort: () => void,
|
||||
editorHost: EditorHost,
|
||||
inlineEditor: AffineInlineEditor
|
||||
): Promise<LinkedMenuGroup[]> {
|
||||
return Promise.resolve([
|
||||
createLinkedDocMenuGroup(query, abort, editorHost, inlineEditor),
|
||||
createNewDocMenuGroup(query, abort, editorHost, inlineEditor),
|
||||
]);
|
||||
}
|
||||
|
||||
export const LinkedWidgetUtils = {
|
||||
createNewDocMenuGroup,
|
||||
insertLinkedNode,
|
||||
};
|
||||
|
||||
export const AFFINE_LINKED_DOC_WIDGET = 'affine-linked-doc-widget';
|
||||
|
||||
export const LinkedWidgetConfigExtension = ConfigExtensionFactory<
|
||||
Partial<LinkedWidgetConfig>
|
||||
>('affine:widget-linked-doc');
|
||||
17
blocksuite/affine/widgets/linked-doc/src/effects.ts
Normal file
17
blocksuite/affine/widgets/linked-doc/src/effects.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AFFINE_LINKED_DOC_WIDGET } from './config.js';
|
||||
import { ImportDoc } from './import-doc/import-doc.js';
|
||||
import { Loader } from './import-doc/loader.js';
|
||||
import { AffineLinkedDocWidget } from './index.js';
|
||||
import { LinkedDocPopover } from './linked-doc-popover.js';
|
||||
import { AffineMobileLinkedDocMenu } from './mobile-linked-doc-menu.js';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-linked-doc-popover', LinkedDocPopover);
|
||||
customElements.define(AFFINE_LINKED_DOC_WIDGET, AffineLinkedDocWidget);
|
||||
customElements.define('import-doc', ImportDoc);
|
||||
customElements.define(
|
||||
'affine-mobile-linked-doc-menu',
|
||||
AffineMobileLinkedDocMenu
|
||||
);
|
||||
customElements.define('loader-element', Loader);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
CloseIcon,
|
||||
ExportToHTMLIcon,
|
||||
ExportToMarkdownIcon,
|
||||
NewIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { openFilesWith } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import type { ExtensionType, 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 { styles } from './styles.js';
|
||||
|
||||
export type OnSuccessHandler = (
|
||||
pageIds: string[],
|
||||
options: {
|
||||
isWorkspaceFile: boolean;
|
||||
importedCount: number;
|
||||
docEmojis?: Map<string, string>;
|
||||
}
|
||||
) => 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 extensions: ExtensionType[],
|
||||
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() {
|
||||
try {
|
||||
const files = await openFilesWith('Html');
|
||||
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,
|
||||
extensions: this.extensions,
|
||||
html: text,
|
||||
fileName,
|
||||
});
|
||||
needLoading && this.abortController.abort();
|
||||
if (pageId) {
|
||||
pageIds.push(pageId);
|
||||
}
|
||||
}
|
||||
this._onImportSuccess(pageIds);
|
||||
} catch (error) {
|
||||
this._onFail(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async _importMarkDown() {
|
||||
try {
|
||||
const files = await openFilesWith('Markdown');
|
||||
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,
|
||||
extensions: this.extensions,
|
||||
});
|
||||
needLoading && this.abortController.abort();
|
||||
if (pageId) {
|
||||
pageIds.push(pageId);
|
||||
}
|
||||
}
|
||||
this._onImportSuccess(pageIds);
|
||||
} catch (error) {
|
||||
this._onFail(error);
|
||||
}
|
||||
}
|
||||
|
||||
private _onCloseClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private _onFail(error: unknown) {
|
||||
this.onFail?.(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
private _onImportSuccess(
|
||||
pageIds: string[],
|
||||
options: {
|
||||
isWorkspaceFile?: boolean;
|
||||
importedCount?: number;
|
||||
docEmojis?: Map<string, string>;
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
isWorkspaceFile = false,
|
||||
importedCount: pagesImportedCount = pageIds.length,
|
||||
docEmojis,
|
||||
} = options;
|
||||
this.onSuccess?.(pageIds, {
|
||||
isWorkspaceFile,
|
||||
importedCount: pagesImportedCount,
|
||||
docEmojis,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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://affine.pro/redirect/discord" 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="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;
|
||||
}
|
||||
38
blocksuite/affine/widgets/linked-doc/src/import-doc/index.ts
Normal file
38
blocksuite/affine/widgets/linked-doc/src/import-doc/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ExtensionType, Schema, Workspace } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
ImportDoc,
|
||||
type OnFailHandler,
|
||||
type OnSuccessHandler,
|
||||
} from './import-doc.js';
|
||||
|
||||
export function showImportModal({
|
||||
schema,
|
||||
collection,
|
||||
extensions,
|
||||
onSuccess,
|
||||
onFail,
|
||||
container = document.body,
|
||||
abortController = new AbortController(),
|
||||
}: {
|
||||
schema: Schema;
|
||||
collection: Workspace;
|
||||
extensions: ExtensionType[];
|
||||
onSuccess?: OnSuccessHandler;
|
||||
onFail?: OnFailHandler;
|
||||
multiple?: boolean;
|
||||
container?: HTMLElement;
|
||||
abortController?: AbortController;
|
||||
}) {
|
||||
const importDoc = new ImportDoc(
|
||||
collection,
|
||||
schema,
|
||||
extensions,
|
||||
onSuccess,
|
||||
onFail,
|
||||
abortController
|
||||
);
|
||||
container.append(importDoc);
|
||||
abortController.signal.addEventListener('abort', () => importDoc.remove());
|
||||
return importDoc;
|
||||
}
|
||||
103
blocksuite/affine/widgets/linked-doc/src/import-doc/loader.ts
Normal file
103
blocksuite/affine/widgets/linked-doc/src/import-doc/loader.ts
Normal file
@@ -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);
|
||||
}
|
||||
`;
|
||||
4
blocksuite/affine/widgets/linked-doc/src/index.ts
Normal file
4
blocksuite/affine/widgets/linked-doc/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './config';
|
||||
export * from './import-doc';
|
||||
export * from './transformers';
|
||||
export * from './widget';
|
||||
443
blocksuite/affine/widgets/linked-doc/src/linked-doc-popover.ts
Normal file
443
blocksuite/affine/widgets/linked-doc/src/linked-doc-popover.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import type { IconButton } from '@blocksuite/affine-components/icon-button';
|
||||
import { LoadingIcon } from '@blocksuite/affine-components/icons';
|
||||
import {
|
||||
cleanSpecifiedTail,
|
||||
getTextContentFromInlineRange,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import { unsafeCSSVar } from '@blocksuite/affine-shared/theme';
|
||||
import {
|
||||
createKeydownObserver,
|
||||
getPopperPosition,
|
||||
getViewportElement,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
|
||||
import { PropTypes, requiredProperties } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property, query, queryAll, state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
|
||||
import type { LinkedDocContext, LinkedMenuGroup } from './config.js';
|
||||
import { linkedDocPopoverStyles } from './styles.js';
|
||||
import { resolveSignal } from './utils.js';
|
||||
|
||||
@requiredProperties({
|
||||
context: PropTypes.object,
|
||||
})
|
||||
export class LinkedDocPopover extends SignalWatcher(
|
||||
WithDisposable(LitElement)
|
||||
) {
|
||||
static override styles = linkedDocPopoverStyles;
|
||||
|
||||
private readonly _abort = () => {
|
||||
// remove popover dom
|
||||
this.context.close();
|
||||
// clear input query
|
||||
cleanSpecifiedTail(
|
||||
this.context.std,
|
||||
this.context.inlineEditor,
|
||||
this.context.triggerKey + (this._query || '')
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _expanded = new Map<string, boolean>();
|
||||
|
||||
private _menusItemsEffectCleanup: () => void = () => {};
|
||||
|
||||
private readonly _updateLinkedDocGroup = async () => {
|
||||
const query = this._query;
|
||||
if (this._updateLinkedDocGroupAbortController) {
|
||||
this._updateLinkedDocGroupAbortController.abort();
|
||||
}
|
||||
this._updateLinkedDocGroupAbortController = new AbortController();
|
||||
|
||||
if (query === null || query.startsWith(' ')) {
|
||||
this.context.close();
|
||||
return;
|
||||
}
|
||||
this._linkedDocGroup = await this.context.config.getMenus(
|
||||
query,
|
||||
this._abort,
|
||||
this.context.std.host,
|
||||
this.context.inlineEditor,
|
||||
this._updateLinkedDocGroupAbortController.signal
|
||||
);
|
||||
|
||||
this._menusItemsEffectCleanup();
|
||||
|
||||
// need to rebind the effect because this._linkedDocGroup has changed.
|
||||
this._menusItemsEffectCleanup = effect(() => {
|
||||
this._updateAutoFocusedItem();
|
||||
|
||||
// wait for the next tick to ensure the items are rendered to DOM
|
||||
setTimeout(() => {
|
||||
this.scrollToFocusedItem();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _updateAutoFocusedItem = () => {
|
||||
// Get the auto-focused item key from the config
|
||||
const autoFocusedItemKey = this.context.config.autoFocusedItemKey?.(
|
||||
this._linkedDocGroup,
|
||||
this._query || '',
|
||||
this._activatedItemKey,
|
||||
this.context.std.host,
|
||||
this.context.inlineEditor
|
||||
);
|
||||
|
||||
if (autoFocusedItemKey) {
|
||||
this._activatedItemKey = autoFocusedItemKey;
|
||||
return;
|
||||
}
|
||||
|
||||
// If no auto-focused item key is returned from the config and no item is currently focused,
|
||||
// focus the first item in the flattened action list
|
||||
if (!this._activatedItemKey && this._flattenActionList.length > 0) {
|
||||
this._activatedItemKey = this._flattenActionList[0].key;
|
||||
}
|
||||
};
|
||||
|
||||
private _updateLinkedDocGroupAbortController: AbortController | null = null;
|
||||
|
||||
private get _actionGroup() {
|
||||
return this._linkedDocGroup.map(group => {
|
||||
return {
|
||||
...group,
|
||||
items: this._getActionItems(group),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private get _flattenActionList() {
|
||||
return this._actionGroup.flatMap(group =>
|
||||
group.items.map(item => ({ ...item, groupName: group.name }))
|
||||
);
|
||||
}
|
||||
|
||||
private get _query() {
|
||||
return getTextContentFromInlineRange(
|
||||
this.context.inlineEditor,
|
||||
this.context.startRange
|
||||
);
|
||||
}
|
||||
|
||||
private _getActionItems(group: LinkedMenuGroup) {
|
||||
const isExpanded = !!this._expanded.get(group.name);
|
||||
let items = resolveSignal(group.items);
|
||||
|
||||
const isOverflow = !!group.maxDisplay && items.length > group.maxDisplay;
|
||||
|
||||
items = isExpanded ? items : items.slice(0, group.maxDisplay);
|
||||
|
||||
if (isOverflow && !isExpanded && group.maxDisplay) {
|
||||
items = items.concat({
|
||||
key: `${group.name} More`,
|
||||
name: resolveSignal(group.overflowText) || 'more',
|
||||
icon: MoreHorizontalIcon({ width: '24px', height: '24px' }),
|
||||
action: () => {
|
||||
this._expanded.set(group.name, true);
|
||||
this.requestUpdate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private _isTextOverflowing(element: HTMLElement) {
|
||||
return element.scrollWidth > element.clientWidth;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// init
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
this._disposables.addFromEvent(this, 'pointerdown', e => {
|
||||
// Prevent input from losing focus
|
||||
e.preventDefault();
|
||||
});
|
||||
this._disposables.addFromEvent(this, 'mousedown', e => {
|
||||
// Prevent input from losing focus in electron
|
||||
e.preventDefault();
|
||||
});
|
||||
this._disposables.addFromEvent(window, 'pointerdown', e => {
|
||||
if (e.target === this) return;
|
||||
// We don't clear the query when clicking outside the popover
|
||||
this.context.close();
|
||||
});
|
||||
|
||||
const keydownObserverAbortController = new AbortController();
|
||||
this._disposables.add(() => keydownObserverAbortController.abort());
|
||||
|
||||
const { eventSource } = this.context.inlineEditor;
|
||||
if (!eventSource) return;
|
||||
|
||||
createKeydownObserver({
|
||||
target: eventSource,
|
||||
signal: keydownObserverAbortController.signal,
|
||||
interceptor: (event, next) => {
|
||||
if (event.key === 'GroupNext' || event.key === 'GroupPrevious') {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
this.context.close();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
},
|
||||
onInput: isComposition => {
|
||||
if (isComposition) {
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
} else {
|
||||
const subscription =
|
||||
this.context.inlineEditor.slots.renderComplete.subscribe(() => {
|
||||
subscription.unsubscribe();
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
});
|
||||
}
|
||||
},
|
||||
onPaste: () => {
|
||||
setTimeout(() => {
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
}, 50);
|
||||
},
|
||||
onDelete: () => {
|
||||
const curRange = this.context.inlineEditor.getInlineRange();
|
||||
if (!this.context.startRange || !curRange) {
|
||||
return;
|
||||
}
|
||||
if (curRange.index < this.context.startRange.index) {
|
||||
this.context.close();
|
||||
}
|
||||
const subscription =
|
||||
this.context.inlineEditor.slots.renderComplete.subscribe(() => {
|
||||
subscription.unsubscribe();
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
});
|
||||
},
|
||||
onMove: step => {
|
||||
const itemLen = this._flattenActionList.length;
|
||||
const nextIndex = (itemLen + this._activatedItemIndex + step) % itemLen;
|
||||
const item = this._flattenActionList[nextIndex];
|
||||
if (item) {
|
||||
this._activatedItemKey = item.key;
|
||||
}
|
||||
this.scrollToFocusedItem();
|
||||
},
|
||||
onConfirm: () => {
|
||||
this._flattenActionList[this._activatedItemIndex]
|
||||
.action()
|
||||
?.catch(console.error);
|
||||
},
|
||||
onAbort: () => {
|
||||
this.context.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._menusItemsEffectCleanup();
|
||||
this._updateLinkedDocGroupAbortController?.abort();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const MAX_HEIGHT = 390;
|
||||
const style = this._position
|
||||
? styleMap({
|
||||
transform: `translate(${this._position.x}, ${this._position.y})`,
|
||||
maxHeight: `${Math.min(this._position.height, MAX_HEIGHT)}px`,
|
||||
})
|
||||
: styleMap({
|
||||
visibility: 'hidden',
|
||||
});
|
||||
|
||||
const actionGroups = this._actionGroup.map(group => {
|
||||
// Check if the group is loading or hidden
|
||||
const isLoading = resolveSignal(group.loading);
|
||||
const isHidden = resolveSignal(group.hidden);
|
||||
return {
|
||||
...group,
|
||||
isLoading,
|
||||
isHidden,
|
||||
};
|
||||
});
|
||||
|
||||
return html`<div class="linked-doc-popover" style="${style}">
|
||||
${actionGroups
|
||||
.filter(
|
||||
group =>
|
||||
(group.items.length > 0 || group.isLoading) && !group.isHidden
|
||||
)
|
||||
.map((group, idx) => {
|
||||
return html`
|
||||
<div class="divider" ?hidden=${idx === 0}></div>
|
||||
<div class="group-title">
|
||||
<div class="group-title-text">${group.name}</div>
|
||||
${
|
||||
group.isLoading
|
||||
? html`<span class="loading-icon">${LoadingIcon()}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<div class="group" style=${group.styles ?? ''}>
|
||||
${group.items.map(({ key, name, icon, action }) => {
|
||||
const tooltip = this._showTooltip
|
||||
? html`<affine-tooltip
|
||||
tip-position=${'right'}
|
||||
.tooltipStyle=${css`
|
||||
* {
|
||||
color: ${unsafeCSSVar('white')} !important;
|
||||
}
|
||||
`}
|
||||
>${name}</affine-tooltip
|
||||
>`
|
||||
: nothing;
|
||||
return html`<icon-button
|
||||
width="260px"
|
||||
height="30px"
|
||||
data-id=${key}
|
||||
.text=${name}
|
||||
hover=${this._activatedItemKey === key}
|
||||
@pointerdown=${(e: PointerEvent) => {
|
||||
// Prevent event listeners being registered on the root document
|
||||
// eg., radix-ui dialogs usePointerDownOutside hooks
|
||||
e.stopPropagation();
|
||||
}}
|
||||
@click=${() => {
|
||||
action()?.catch(console.error);
|
||||
}}
|
||||
@mousemove=${() => {
|
||||
// Use `mousemove` instead of `mouseover` to avoid navigate conflict with keyboard
|
||||
this._activatedItemKey = key;
|
||||
// show tooltip whether text length overflows
|
||||
for (const button of this.iconButtons.values()) {
|
||||
if (button.dataset.id == key && button.textElement) {
|
||||
const isOverflowing = this._isTextOverflowing(
|
||||
button.textElement
|
||||
);
|
||||
this._showTooltip = isOverflowing;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
${icon} ${tooltip}
|
||||
</icon-button>`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override willUpdate() {
|
||||
if (!this.hasUpdated) {
|
||||
const updatePosition = throttle(() => {
|
||||
this._position = getPopperPosition(
|
||||
{
|
||||
getBoundingClientRect: () => {
|
||||
return {
|
||||
...this.getBoundingClientRect(),
|
||||
// Workaround: the width of the popover is zero when it is not rendered
|
||||
width: 280,
|
||||
};
|
||||
},
|
||||
},
|
||||
this.context.startNativeRange
|
||||
);
|
||||
}, 10);
|
||||
|
||||
this.disposables.addFromEvent(window, 'resize', updatePosition);
|
||||
const scrollContainer = getViewportElement(this.context.std.host);
|
||||
if (scrollContainer) {
|
||||
// Note: in edgeless mode, the scroll container is not exist!
|
||||
this.disposables.addFromEvent(
|
||||
scrollContainer,
|
||||
'scroll',
|
||||
updatePosition,
|
||||
{
|
||||
passive: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const gfx = this.context.std.get(GfxControllerIdentifier);
|
||||
this.disposables.add(
|
||||
gfx.viewport.viewportUpdated.subscribe(updatePosition)
|
||||
);
|
||||
|
||||
updatePosition();
|
||||
}
|
||||
}
|
||||
|
||||
private scrollToFocusedItem() {
|
||||
const shadowRoot = this.shadowRoot;
|
||||
if (!shadowRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's no active item key, don't try to scroll
|
||||
if (!this._activatedItemKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ele = shadowRoot.querySelector(
|
||||
`icon-button[data-id=${CSS.escape(this._activatedItemKey)}]`
|
||||
);
|
||||
|
||||
// If the element doesn't exist, don't log a warning
|
||||
if (!ele) {
|
||||
return;
|
||||
}
|
||||
|
||||
ele.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
|
||||
get _activatedItemIndex() {
|
||||
const index = this._flattenActionList.findIndex(
|
||||
item => item.key === this._activatedItemKey
|
||||
);
|
||||
return index === -1 ? 0 : index;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _activatedItemKey: string | null = null;
|
||||
|
||||
@state()
|
||||
private accessor _linkedDocGroup: LinkedMenuGroup[] = [];
|
||||
|
||||
@state()
|
||||
private accessor _position: {
|
||||
height: number;
|
||||
x: string;
|
||||
y: string;
|
||||
} | null = null;
|
||||
|
||||
@state()
|
||||
private accessor _showTooltip = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor context!: LinkedDocContext;
|
||||
|
||||
@queryAll('icon-button')
|
||||
accessor iconButtons!: NodeListOf<IconButton>;
|
||||
|
||||
@query('.linked-doc-popover')
|
||||
accessor linkedDocElement: Element | null = null;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
cleanSpecifiedTail,
|
||||
getTextContentFromInlineRange,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import { VirtualKeyboardProvider } from '@blocksuite/affine-shared/services';
|
||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
|
||||
import { PropTypes, requiredProperties } from '@blocksuite/std';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import type {
|
||||
LinkedDocContext,
|
||||
LinkedMenuGroup,
|
||||
LinkedMenuItem,
|
||||
} from './config.js';
|
||||
import { mobileLinkedDocMenuStyles } from './styles.js';
|
||||
import { resolveSignal } from './utils.js';
|
||||
|
||||
export const AFFINE_MOBILE_LINKED_DOC_MENU = 'affine-mobile-linked-doc-menu';
|
||||
|
||||
@requiredProperties({
|
||||
context: PropTypes.object,
|
||||
})
|
||||
export class AffineMobileLinkedDocMenu extends SignalWatcher(
|
||||
WithDisposable(LitElement)
|
||||
) {
|
||||
static override styles = mobileLinkedDocMenuStyles;
|
||||
|
||||
private readonly _expand = new Set<string>();
|
||||
|
||||
private readonly _linkedDocGroup$ = signal<LinkedMenuGroup[]>([]);
|
||||
|
||||
private readonly _renderGroup = (group: LinkedMenuGroup) => {
|
||||
let items = resolveSignal(group.items);
|
||||
|
||||
const isOverflow = !!group.maxDisplay && items.length > group.maxDisplay;
|
||||
const expanded = this._expand.has(group.name);
|
||||
|
||||
let moreItem = null;
|
||||
if (!expanded && isOverflow) {
|
||||
items = items.slice(0, group.maxDisplay);
|
||||
|
||||
moreItem = html`<div
|
||||
class="mobile-linked-doc-menu-item"
|
||||
@click=${() => {
|
||||
this._expand.add(group.name);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
>
|
||||
${MoreHorizontalIcon()}
|
||||
<div class="text">${group.overflowText || 'more'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
${repeat(items, item => item.key, this._renderItem)} ${moreItem}
|
||||
`;
|
||||
};
|
||||
|
||||
private readonly _renderItem = ({
|
||||
key,
|
||||
name,
|
||||
icon,
|
||||
action,
|
||||
}: LinkedMenuItem) => {
|
||||
return html`<button
|
||||
class="mobile-linked-doc-menu-item"
|
||||
data-id=${key}
|
||||
@pointerup=${() => {
|
||||
action()?.catch(console.error);
|
||||
}}
|
||||
>
|
||||
${icon}
|
||||
<div class="text">${name}</div>
|
||||
</button>`;
|
||||
};
|
||||
|
||||
private readonly _scrollInputToTop = () => {
|
||||
const { inlineEditor } = this.context;
|
||||
const { scrollContainer, scrollTopOffset } = this.context.config.mobile;
|
||||
|
||||
let container = null;
|
||||
let containerScrollTop = 0;
|
||||
if (typeof scrollContainer === 'string') {
|
||||
container = document.querySelector(scrollContainer);
|
||||
containerScrollTop = container?.scrollTop ?? 0;
|
||||
} else if (scrollContainer instanceof HTMLElement) {
|
||||
container = scrollContainer;
|
||||
containerScrollTop = scrollContainer.scrollTop;
|
||||
} else if (scrollContainer === window) {
|
||||
container = window;
|
||||
containerScrollTop = scrollContainer.scrollY;
|
||||
} else {
|
||||
container = getViewportElement(this.context.std.host);
|
||||
containerScrollTop = container?.scrollTop ?? 0;
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
if (typeof scrollTopOffset === 'function') {
|
||||
offset = scrollTopOffset();
|
||||
} else {
|
||||
offset = scrollTopOffset ?? 0;
|
||||
}
|
||||
|
||||
if (!inlineEditor.rootElement || !container) return;
|
||||
container.scrollTo({
|
||||
top:
|
||||
inlineEditor.rootElement.getBoundingClientRect().top +
|
||||
containerScrollTop -
|
||||
offset,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _updateLinkedDocGroup = async () => {
|
||||
if (this._updateLinkedDocGroupAbortController) {
|
||||
this._updateLinkedDocGroupAbortController.abort();
|
||||
}
|
||||
this._updateLinkedDocGroupAbortController = new AbortController();
|
||||
this._linkedDocGroup$.value = await this.context.config.getMenus(
|
||||
this._query ?? '',
|
||||
() => {
|
||||
this.context.close();
|
||||
cleanSpecifiedTail(
|
||||
this.context.std,
|
||||
this.context.inlineEditor,
|
||||
this.context.triggerKey + (this._query ?? '')
|
||||
);
|
||||
},
|
||||
this.context.std.host,
|
||||
this.context.inlineEditor,
|
||||
this._updateLinkedDocGroupAbortController.signal
|
||||
);
|
||||
};
|
||||
|
||||
private _updateLinkedDocGroupAbortController: AbortController | null = null;
|
||||
|
||||
private get _query() {
|
||||
return getTextContentFromInlineRange(
|
||||
this.context.inlineEditor,
|
||||
this.context.startRange
|
||||
);
|
||||
}
|
||||
|
||||
get keyboard() {
|
||||
return this.context.std.get(VirtualKeyboardProvider);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
const { inlineEditor, close } = this.context;
|
||||
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
|
||||
// prevent editor blur when click menu
|
||||
this._disposables.addFromEvent(this, 'pointerdown', e => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// close menu when click outside
|
||||
this.disposables.addFromEvent(
|
||||
window,
|
||||
'pointerdown',
|
||||
e => {
|
||||
if (e.target === this) return;
|
||||
close();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
// bind some key events
|
||||
{
|
||||
const { eventSource } = inlineEditor;
|
||||
if (!eventSource) return;
|
||||
|
||||
const keydownObserverAbortController = new AbortController();
|
||||
this._disposables.add(() => keydownObserverAbortController.abort());
|
||||
|
||||
// we need use beforeinput because the event.key of keypress event usually is `Unidentified` in Android
|
||||
this.disposables.addFromEvent(eventSource, 'beforeinput', () => {
|
||||
const curRange = inlineEditor.getInlineRange();
|
||||
if (curRange && curRange.index < this.context.startRange.index) {
|
||||
this.context.close();
|
||||
return;
|
||||
}
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._scrollInputToTop();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const groups = this._linkedDocGroup$.value;
|
||||
if (groups.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
this.style.bottom = `${this.keyboard.height$.value}px`;
|
||||
|
||||
return html`
|
||||
${join(groups.map(this._renderGroup), html`<div class="divider"></div>`)}
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor context!: LinkedDocContext;
|
||||
}
|
||||
169
blocksuite/affine/widgets/linked-doc/src/styles.ts
Normal file
169
blocksuite/affine/widgets/linked-doc/src/styles.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, unsafeCSS } from 'lit';
|
||||
|
||||
export const linkedDocWidgetStyles = css`
|
||||
.input-mask {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const linkedDocPopoverStyles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.linked-doc-popover {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
font-size: var(--affine-font-base);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
gap: 4px;
|
||||
|
||||
background: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
|
||||
border-radius: 4px;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
|
||||
.linked-doc-popover icon-button {
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.linked-doc-popover .group-title {
|
||||
color: var(--affine-text-secondary-color);
|
||||
padding: 0 8px;
|
||||
height: 30px;
|
||||
font-size: var(--affine-font-xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
justify-content: space-between;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.linked-doc-popover .group-title .group-title-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.linked-doc-popover .group-title .loading-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.linked-doc-popover .group-title .loading-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.linked-doc-popover .divider {
|
||||
border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
}
|
||||
|
||||
.group icon-button svg,
|
||||
.group icon-button .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.group icon-button .icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.linked-doc-popover .group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
${scrollbarStyle('.linked-doc-popover')}
|
||||
`;
|
||||
|
||||
export const mobileLinkedDocMenuStyles = css`
|
||||
:host {
|
||||
height: 220px;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
overflow-y: auto;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-shrink: 0;
|
||||
|
||||
--border-style: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
|
||||
border-radius: 12px 12px 0px 0px;
|
||||
border-top: var(--border-style);
|
||||
border-right: var(--border-style);
|
||||
border-left: var(--border-style);
|
||||
background: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
box-shadow: 0px -3px 10px 0px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
:host::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
}
|
||||
|
||||
.mobile-linked-doc-menu-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
padding: 11px 20px;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
border: none;
|
||||
background: inherit;
|
||||
|
||||
> svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
}
|
||||
|
||||
.text {
|
||||
overflow: hidden;
|
||||
color: ${unsafeCSSVarV2('text/primary')};
|
||||
text-align: justify;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 17px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
letter-spacing: -0.43px;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-linked-doc-menu-item:active {
|
||||
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
||||
}
|
||||
`;
|
||||
622
blocksuite/affine/widgets/linked-doc/src/transformers/bear.ts
Normal file
622
blocksuite/affine/widgets/linked-doc/src/transformers/bear.ts
Normal file
@@ -0,0 +1,622 @@
|
||||
import {
|
||||
defaultImageProxyMiddleware,
|
||||
docLinkBaseURLMiddleware,
|
||||
fileNameMiddleware,
|
||||
filePathMiddleware,
|
||||
MarkdownAdapter,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { Container } from '@blocksuite/global/di';
|
||||
import { sha } from '@blocksuite/global/utils';
|
||||
import type {
|
||||
DocSnapshot,
|
||||
ExtensionType,
|
||||
Schema,
|
||||
Workspace,
|
||||
} from '@blocksuite/store';
|
||||
import { extMimeMap, Transformer } from '@blocksuite/store';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
import {
|
||||
blobsFromAssets,
|
||||
type ImportBatch,
|
||||
type ImportDoc,
|
||||
type ImportFolder,
|
||||
type ImportTag,
|
||||
type ImportWarning,
|
||||
} from './import-batch.js';
|
||||
import { createCollectionDocCRUD } from './markdown.js';
|
||||
|
||||
/** Recursive tree node representing a tag-based folder hierarchy. */
|
||||
export type BearFolderHierarchy = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: Map<string, BearFolderHierarchy>;
|
||||
pageId?: string;
|
||||
parentPath?: string;
|
||||
};
|
||||
|
||||
type BearImportOptions = {
|
||||
collection: Workspace;
|
||||
schema: Schema;
|
||||
imported: Blob;
|
||||
extensions: ExtensionType[];
|
||||
};
|
||||
|
||||
export type PlanBearBackupResult = {
|
||||
docIds: string[];
|
||||
tags: Map<string, string[]>;
|
||||
folderHierarchy: BearFolderHierarchy;
|
||||
batch: ImportBatch;
|
||||
};
|
||||
|
||||
type BundleEntry = {
|
||||
bundlePath: string;
|
||||
markdownPath: string | null;
|
||||
infoJsonPath: string | null;
|
||||
assetPaths: string[];
|
||||
};
|
||||
|
||||
/** Create a DI provider from the given extensions. */
|
||||
function getProvider(extensions: ExtensionType[]) {
|
||||
const container = new Container();
|
||||
extensions.forEach(ext => {
|
||||
ext.setup(container);
|
||||
});
|
||||
return container.provider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Bear tags from the trailing footer of a markdown document.
|
||||
* Bear places tags (e.g. `#tag`, `#multi word tag#`, `#nested/tag`) at the end
|
||||
* of notes. This scans from the bottom up, collecting tag-only lines (up to 5)
|
||||
* and returns the deduplicated tags plus the content with those lines removed.
|
||||
*/
|
||||
function parseBearTags(markdown: string): {
|
||||
tags: string[];
|
||||
content: string;
|
||||
} {
|
||||
const lines = markdown.split('\n');
|
||||
|
||||
const codeFenceState: boolean[] = [];
|
||||
let inCodeBlock = false;
|
||||
for (const line of lines) {
|
||||
if (line.trimStart().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
}
|
||||
codeFenceState.push(inCodeBlock);
|
||||
}
|
||||
|
||||
const tags: string[] = [];
|
||||
const tagLineIndices = new Set<number>();
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
if (codeFenceState[i]) break;
|
||||
|
||||
const lineTags = extractTagsFromLine(line);
|
||||
if (lineTags.length > 0) {
|
||||
for (const tag of lineTags) {
|
||||
tags.push(tag);
|
||||
}
|
||||
tagLineIndices.add(i);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
if (tagLineIndices.size >= 5) break;
|
||||
}
|
||||
|
||||
const filteredLines = lines.filter((_, i) => !tagLineIndices.has(i));
|
||||
while (
|
||||
filteredLines.length > 0 &&
|
||||
filteredLines[filteredLines.length - 1].trim() === ''
|
||||
) {
|
||||
filteredLines.pop();
|
||||
}
|
||||
|
||||
return {
|
||||
tags: deduplicateTags(tags),
|
||||
content: filteredLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Bear tags from a single line. Supports open tags (`#tag`),
|
||||
* closed tags (`#multi word tag#`), and nested tags (`#parent/child`).
|
||||
* Returns an empty array if the line contains non-tag content.
|
||||
*/
|
||||
function extractTagsFromLine(line: string): string[] {
|
||||
const tags: string[] = [];
|
||||
let remaining = line;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
remaining = remaining.trimStart();
|
||||
if (!remaining) break;
|
||||
|
||||
if (remaining.startsWith('[')) return [];
|
||||
|
||||
if (remaining.startsWith('#')) {
|
||||
if (remaining.length > 1 && remaining[1] === ' ') return [];
|
||||
if (remaining.length > 2 && remaining[1] === '#') return [];
|
||||
|
||||
const closedMatch = remaining.match(/^#([^#\n]+)#/);
|
||||
if (closedMatch) {
|
||||
const tagValue = closedMatch[1].trim();
|
||||
if (tagValue) {
|
||||
tags.push(tagValue);
|
||||
remaining = remaining.slice(closedMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const openMatch = remaining.match(
|
||||
/^#([\p{L}\p{N}_][\p{L}\p{N}_/-]*)(.*)$/u
|
||||
);
|
||||
if (openMatch) {
|
||||
const tagValue = openMatch[1];
|
||||
const after = openMatch[2].trim();
|
||||
if (tagValue) {
|
||||
tags.push(tagValue);
|
||||
remaining = after;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate tags case-insensitively while preserving the original
|
||||
* capitalization of the first occurrence of each tag.
|
||||
*/
|
||||
function deduplicateTags(tags: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const tag of tags) {
|
||||
const normalized = tag.toLowerCase();
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
result.push(tag);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nested folder hierarchy from Bear tags.
|
||||
* Tags like `parent/child` create nested folders. Documents are attached
|
||||
* as leaf nodes under their tag's folder using `__doc__` prefixed keys.
|
||||
*/
|
||||
function buildFolderHierarchyFromTags(
|
||||
tagDocMap: Map<string, string[]>
|
||||
): BearFolderHierarchy {
|
||||
const root: BearFolderHierarchy = {
|
||||
name: '',
|
||||
path: '',
|
||||
children: new Map(),
|
||||
};
|
||||
|
||||
for (const [tag, docIds] of tagDocMap) {
|
||||
const parts = tag.split('/');
|
||||
let current = root;
|
||||
let currentPath = '';
|
||||
|
||||
for (const part of parts) {
|
||||
const parentPath = currentPath;
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
|
||||
if (!current.children.has(part)) {
|
||||
current.children.set(part, {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
parentPath: parentPath || undefined,
|
||||
children: new Map(),
|
||||
});
|
||||
}
|
||||
current = current.children.get(part)!;
|
||||
}
|
||||
|
||||
for (const docId of docIds) {
|
||||
const docNodeKey = `__doc__${docId}`;
|
||||
if (!current.children.has(docNodeKey)) {
|
||||
current.children.set(docNodeKey, {
|
||||
name: docNodeKey,
|
||||
path: `${current.path}/${docNodeKey}`,
|
||||
parentPath: current.path,
|
||||
children: new Map(),
|
||||
pageId: docId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function flattenFolderHierarchy(root: BearFolderHierarchy): ImportFolder[] {
|
||||
const folders: ImportFolder[] = [];
|
||||
|
||||
const visit = (node: BearFolderHierarchy) => {
|
||||
if (node.name) {
|
||||
folders.push({
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
parentPath: node.parentPath,
|
||||
pageId: node.pageId,
|
||||
});
|
||||
}
|
||||
for (const child of node.children.values()) {
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
|
||||
for (const child of root.children.values()) {
|
||||
visit(child);
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
function tagsToBatchTags(tags: Map<string, string[]>): ImportTag[] {
|
||||
return Array.from(tags, ([name, docIds]) => ({ name, docIds }));
|
||||
}
|
||||
|
||||
const GFM_CALLOUT_MAP: Record<string, string> = {
|
||||
IMPORTANT: '\u26A0',
|
||||
NOTE: '\uD83D\uDCDD',
|
||||
WARNING: '\u26A0',
|
||||
TIP: '\uD83D\uDCA1',
|
||||
CAUTION: '\uD83D\uDD34',
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert GFM-style callouts (`> [!NOTE]`, `> [!WARNING]`, etc.) to
|
||||
* emoji-based callouts that AFFiNE's remark-callout plugin understands.
|
||||
* Skips content inside fenced code blocks.
|
||||
*/
|
||||
function convertGfmCallouts(markdown: string): string {
|
||||
const lines = markdown.split('\n');
|
||||
let inCodeBlock = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trimStart().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
continue;
|
||||
}
|
||||
if (!inCodeBlock) {
|
||||
lines[i] = lines[i].replace(
|
||||
/^(>\s*)\[!(\w+)\]/,
|
||||
(match, prefix: string, type: string) => {
|
||||
const emoji = GFM_CALLOUT_MAP[type.toUpperCase()];
|
||||
return emoji ? `${prefix}[!${emoji}]` : match;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function stripBearMetadataComments(markdown: string): string {
|
||||
let current = markdown;
|
||||
while (true) {
|
||||
const next = current.replace(/<!--\s*\{[^}]*\}\s*-->/g, '');
|
||||
if (next === current) {
|
||||
return current;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
const HIGHLIGHT_COLOR_MAP: Record<string, string> = {
|
||||
'\uD83D\uDFE2': 'green',
|
||||
'\uD83D\uDD35': 'blue',
|
||||
'\uD83D\uDFE3': 'purple',
|
||||
'\uD83D\uDD34': 'red',
|
||||
'\uD83D\uDFE1': 'yellow',
|
||||
'\uD83D\uDFE0': 'orange',
|
||||
};
|
||||
|
||||
/** Escape HTML special characters to prevent markup injection. */
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Bear `==highlight==` syntax to `<mark>` HTML elements.
|
||||
* Supports colored highlights via leading color emoji (e.g. `==🟢green text==`).
|
||||
* Skips content inside fenced code blocks.
|
||||
*/
|
||||
function convertHighlights(markdown: string): string {
|
||||
const lines = markdown.split('\n');
|
||||
let inCodeBlock = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trimStart().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
continue;
|
||||
}
|
||||
if (!inCodeBlock) {
|
||||
lines[i] = lines[i].replace(
|
||||
/==(\S(?:[^=]|=[^=])*?)==/g,
|
||||
(...[, content]) => {
|
||||
const firstChar = String.fromCodePoint(content.codePointAt(0)!);
|
||||
const color = HIGHLIGHT_COLOR_MAP[firstChar];
|
||||
if (color) {
|
||||
const text = content.slice(firstChar.length);
|
||||
return `<mark data-color="${color}">${escapeHtml(text)}</mark>`;
|
||||
}
|
||||
return `<mark>${escapeHtml(content)}</mark>`;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Extract the document title from the first `# heading` or fall back to the bundle name. */
|
||||
function extractTitle(markdown: string, bundleName: string): string {
|
||||
const lines = markdown.split('\n');
|
||||
let inCodeBlock = false;
|
||||
for (const line of lines) {
|
||||
if (line.trimStart().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
continue;
|
||||
}
|
||||
if (inCodeBlock) continue;
|
||||
const match = line.match(/^#\s+(.+)/);
|
||||
if (match) {
|
||||
const title = match[1].trim();
|
||||
if (title) return title;
|
||||
}
|
||||
}
|
||||
return bundleName.replace(/\.textbundle$/i, '') || 'Untitled';
|
||||
}
|
||||
|
||||
function applySnapshotTitle(snapshot: DocSnapshot, title: string) {
|
||||
snapshot.meta.title = title;
|
||||
snapshot.blocks.props.title = {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [{ insert: title }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a Bear .bear2bk backup file.
|
||||
* Uses JSZip for lazy/streaming decompression to handle large backups.
|
||||
*/
|
||||
async function planBearBackup({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
}: BearImportOptions): Promise<PlanBearBackupResult> {
|
||||
const provider = getProvider(extensions);
|
||||
|
||||
// JSZip reads the zip directory without decompressing all entries
|
||||
const zip = await JSZip.loadAsync(imported);
|
||||
|
||||
// Scan entries and group by textbundle
|
||||
const bundleMap = new Map<string, BundleEntry>();
|
||||
|
||||
zip.forEach(path => {
|
||||
if (path.includes('__MACOSX') || path.includes('.DS_Store')) return;
|
||||
|
||||
const tbMatch = path.match(/^(.+?\.textbundle)\/(.*)/i);
|
||||
if (!tbMatch) return;
|
||||
|
||||
const bundlePath = tbMatch[1];
|
||||
const innerPath = tbMatch[2];
|
||||
|
||||
if (!bundleMap.has(bundlePath)) {
|
||||
bundleMap.set(bundlePath, {
|
||||
bundlePath,
|
||||
markdownPath: null,
|
||||
infoJsonPath: null,
|
||||
assetPaths: [],
|
||||
});
|
||||
}
|
||||
const bundle = bundleMap.get(bundlePath)!;
|
||||
|
||||
if (innerPath === 'text.md' || innerPath === 'text.txt') {
|
||||
bundle.markdownPath = path;
|
||||
} else if (innerPath === 'info.json') {
|
||||
bundle.infoJsonPath = path;
|
||||
} else if (innerPath.startsWith('assets/') && innerPath !== 'assets/') {
|
||||
bundle.assetPaths.push(path);
|
||||
}
|
||||
});
|
||||
|
||||
// Read info.json for all bundles to filter out trashed notes
|
||||
// (info.json is tiny, safe to read all at once)
|
||||
const validBundles: Array<{
|
||||
entry: BundleEntry;
|
||||
bearMeta: Record<string, unknown> | undefined;
|
||||
}> = [];
|
||||
|
||||
for (const entry of bundleMap.values()) {
|
||||
if (!entry.markdownPath) continue;
|
||||
|
||||
let info: Record<string, unknown> = {};
|
||||
if (entry.infoJsonPath) {
|
||||
try {
|
||||
const text = await zip.file(entry.infoJsonPath)!.async('string');
|
||||
info = JSON.parse(text);
|
||||
} catch {
|
||||
// Invalid JSON
|
||||
}
|
||||
}
|
||||
|
||||
const bearMeta = info['net.shinyfrog.bear'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (bearMeta?.trashed === 1) continue;
|
||||
|
||||
validBundles.push({ entry, bearMeta });
|
||||
}
|
||||
|
||||
if (validBundles.length === 0) {
|
||||
throw new Error(
|
||||
'No valid Bear textbundles found in the archive. Please select a .bear2bk backup file.'
|
||||
);
|
||||
}
|
||||
|
||||
const docIds: string[] = [];
|
||||
const docs: ImportDoc[] = [];
|
||||
const importBlobs = new Map<
|
||||
string,
|
||||
Awaited<ReturnType<typeof blobsFromAssets>>[0]
|
||||
>();
|
||||
const warnings: ImportWarning[] = [];
|
||||
const tagDocMap = new Map<string, string[]>();
|
||||
|
||||
// Process bundles sequentially to limit memory.
|
||||
// Each bundle is wrapped in try/catch so one bad note does not abort the
|
||||
// entire import after earlier notes have already been written.
|
||||
for (const { entry, bearMeta } of validBundles) {
|
||||
try {
|
||||
// Read markdown (decompress on demand)
|
||||
const rawMarkdown = await zip.file(entry.markdownPath!)!.async('string');
|
||||
if (!rawMarkdown.trim()) continue;
|
||||
|
||||
const { tags, content: cleanedMarkdown } = parseBearTags(rawMarkdown);
|
||||
const bundleDirName =
|
||||
entry.bundlePath.split('/').findLast(Boolean) ?? 'Untitled';
|
||||
const title = extractTitle(cleanedMarkdown, bundleDirName);
|
||||
const markdown = convertHighlights(
|
||||
convertGfmCallouts(stripBearMetadataComments(cleanedMarkdown))
|
||||
);
|
||||
|
||||
// Read assets on demand (decompress only this bundle's assets)
|
||||
const pendingAssets = new Map<string, File>();
|
||||
const pendingPathBlobIdMap = new Map<string, string>();
|
||||
|
||||
for (const assetFullPath of entry.assetPaths) {
|
||||
try {
|
||||
const data = await zip.file(assetFullPath)!.async('arraybuffer');
|
||||
const tbMatch = assetFullPath.match(/^.+?\.textbundle\/(.*)/i);
|
||||
const assetRelPath = tbMatch ? tbMatch[1] : assetFullPath;
|
||||
const ext = assetRelPath.split('.').at(-1) ?? '';
|
||||
const mime = extMimeMap.get(ext.toLowerCase()) ?? '';
|
||||
const key = await sha(data);
|
||||
// Map both the full zip path and the relative path (assets/...)
|
||||
pendingPathBlobIdMap.set(assetFullPath, key);
|
||||
pendingPathBlobIdMap.set(assetRelPath, key);
|
||||
try {
|
||||
const decodedRel = decodeURIComponent(assetRelPath);
|
||||
if (decodedRel !== assetRelPath) {
|
||||
pendingPathBlobIdMap.set(decodedRel, key);
|
||||
}
|
||||
const decodedFull = decodeURIComponent(assetFullPath);
|
||||
if (decodedFull !== assetFullPath) {
|
||||
pendingPathBlobIdMap.set(decodedFull, key);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URI encoding
|
||||
}
|
||||
const fileName = assetRelPath.split('/').pop() ?? '';
|
||||
pendingAssets.set(key, new File([data], fileName, { type: mime }));
|
||||
} catch {
|
||||
// Failed to read asset, skip
|
||||
}
|
||||
}
|
||||
|
||||
const fullPath = `${entry.bundlePath}/text.md`;
|
||||
const job = new Transformer({
|
||||
schema,
|
||||
blobCRUD: collection.blobSync,
|
||||
docCRUD: createCollectionDocCRUD(collection),
|
||||
middlewares: [
|
||||
defaultImageProxyMiddleware,
|
||||
fileNameMiddleware(title),
|
||||
filePathMiddleware(fullPath),
|
||||
docLinkBaseURLMiddleware(collection.id),
|
||||
],
|
||||
});
|
||||
|
||||
const assets = job.assets;
|
||||
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
|
||||
for (const [p, key] of pendingPathBlobIdMap.entries()) {
|
||||
pathBlobIdMap.set(p, key);
|
||||
}
|
||||
for (const [key, file] of pendingAssets.entries()) {
|
||||
assets.set(key, file);
|
||||
}
|
||||
|
||||
const mdAdapter = new MarkdownAdapter(job, provider);
|
||||
const snapshot = await mdAdapter.toDocSnapshot({
|
||||
file: markdown,
|
||||
assets: job.assetsManager,
|
||||
});
|
||||
|
||||
const docId = collection.idGenerator();
|
||||
snapshot.meta.id = docId;
|
||||
applySnapshotTitle(snapshot, title);
|
||||
const meta: ImportDoc['meta'] = { title };
|
||||
if (bearMeta?.creationDate) {
|
||||
const ts = Date.parse(String(bearMeta.creationDate));
|
||||
if (!isNaN(ts)) meta.createDate = ts;
|
||||
}
|
||||
if (bearMeta?.modificationDate) {
|
||||
const ts = Date.parse(String(bearMeta.modificationDate));
|
||||
if (!isNaN(ts)) meta.updatedDate = ts;
|
||||
}
|
||||
|
||||
docs.push({
|
||||
id: docId,
|
||||
snapshot,
|
||||
meta,
|
||||
});
|
||||
docIds.push(docId);
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!tagDocMap.has(tag)) {
|
||||
tagDocMap.set(tag, []);
|
||||
}
|
||||
tagDocMap.get(tag)!.push(docId);
|
||||
}
|
||||
|
||||
for (const blob of await blobsFromAssets(
|
||||
pendingAssets,
|
||||
pendingPathBlobIdMap
|
||||
)) {
|
||||
importBlobs.set(blob.blobId, blob);
|
||||
}
|
||||
} catch {
|
||||
warnings.push({
|
||||
code: 'bear-bundle-import-failed',
|
||||
message: `Failed to import bundle: ${entry.bundlePath}`,
|
||||
sourcePath: entry.bundlePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const folderHierarchy = buildFolderHierarchyFromTags(tagDocMap);
|
||||
return {
|
||||
docIds,
|
||||
tags: tagDocMap,
|
||||
folderHierarchy,
|
||||
batch: {
|
||||
docs,
|
||||
blobs: Array.from(importBlobs.values()),
|
||||
folders: flattenFolderHierarchy(folderHierarchy),
|
||||
tags: tagsToBatchTags(tagDocMap),
|
||||
warnings,
|
||||
done: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Public API for importing Bear .bear2bk backup archives. */
|
||||
export const BearTransformer = {
|
||||
planBearBackup,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ExtensionType, Schema, Workspace } from '@blocksuite/store';
|
||||
// @ts-expect-error -- mammoth.browser has no compatible type declaration for this subpath.
|
||||
import { convertToHtml } from 'mammoth/mammoth.browser';
|
||||
|
||||
import { HtmlTransformer } from './html';
|
||||
|
||||
type ImportDocxOptions = {
|
||||
collection: Workspace;
|
||||
schema: Schema;
|
||||
imported: Blob;
|
||||
extensions: ExtensionType[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Imports a .docx file into a doc.
|
||||
*
|
||||
* @param options - The import options.
|
||||
* @param options.collection - The target doc collection.
|
||||
* @param options.schema - The schema of the target doc collection.
|
||||
* @param options.imported - The .docx file as a Blob.
|
||||
* @returns A Promise that resolves to the ID of the newly created doc, or undefined if import fails.
|
||||
*/
|
||||
async function importDocx({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
}: ImportDocxOptions) {
|
||||
try {
|
||||
const { value } = await convertToHtml({
|
||||
arrayBuffer: await imported.arrayBuffer(),
|
||||
});
|
||||
return await HtmlTransformer.importHTMLToDoc({
|
||||
collection,
|
||||
schema,
|
||||
html: value,
|
||||
extensions,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to import .docx file:', e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const DocxTransformer = {
|
||||
importDocx,
|
||||
};
|
||||
218
blocksuite/affine/widgets/linked-doc/src/transformers/html.ts
Normal file
218
blocksuite/affine/widgets/linked-doc/src/transformers/html.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
defaultImageProxyMiddleware,
|
||||
docLinkBaseURLMiddleware,
|
||||
fileNameMiddleware,
|
||||
filePathMiddleware,
|
||||
HtmlAdapter,
|
||||
titleMiddleware,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { Container } from '@blocksuite/global/di';
|
||||
import { sha } from '@blocksuite/global/utils';
|
||||
import type {
|
||||
ExtensionType,
|
||||
Schema,
|
||||
Store,
|
||||
Workspace,
|
||||
} from '@blocksuite/store';
|
||||
import { extMimeMap, Transformer } from '@blocksuite/store';
|
||||
|
||||
import type { AssetMap, ImportedFileEntry, PathBlobIdMap } from './type.js';
|
||||
import { createAssetsArchive, download, Unzip } from './utils.js';
|
||||
|
||||
type ImportHTMLToDocOptions = {
|
||||
collection: Workspace;
|
||||
schema: Schema;
|
||||
html: string;
|
||||
fileName?: string;
|
||||
extensions: ExtensionType[];
|
||||
};
|
||||
|
||||
type ImportHTMLZipOptions = {
|
||||
collection: Workspace;
|
||||
schema: Schema;
|
||||
imported: Blob;
|
||||
extensions: ExtensionType[];
|
||||
};
|
||||
|
||||
function getProvider(extensions: ExtensionType[]) {
|
||||
const container = new Container();
|
||||
extensions.forEach(ext => {
|
||||
ext.setup(container);
|
||||
});
|
||||
return container.provider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports a doc to HTML format.
|
||||
*
|
||||
* @param doc - The doc to be exported.
|
||||
* @returns A Promise that resolves when the export is complete.
|
||||
*/
|
||||
async function exportDoc(doc: Store) {
|
||||
const provider = doc.provider;
|
||||
const job = doc.getTransformer([
|
||||
docLinkBaseURLMiddleware(doc.workspace.id),
|
||||
titleMiddleware(doc.workspace.meta.docMetas),
|
||||
]);
|
||||
const snapshot = job.docToSnapshot(doc);
|
||||
const adapter = new HtmlAdapter(job, provider);
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
const htmlResult = await adapter.fromDocSnapshot({
|
||||
snapshot,
|
||||
assets: job.assetsManager,
|
||||
});
|
||||
|
||||
let downloadBlob: Blob;
|
||||
const docTitle = doc.meta?.title || 'Untitled';
|
||||
let name: string;
|
||||
const contentBlob = new Blob([htmlResult.file], { type: 'plain/text' });
|
||||
if (htmlResult.assetsIds.length > 0) {
|
||||
const zip = await createAssetsArchive(job.assets, htmlResult.assetsIds);
|
||||
|
||||
await zip.file('index.html', contentBlob);
|
||||
|
||||
downloadBlob = await zip.generate();
|
||||
name = `${docTitle}.zip`;
|
||||
} else {
|
||||
downloadBlob = contentBlob;
|
||||
name = `${docTitle}.html`;
|
||||
}
|
||||
download(downloadBlob, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports HTML content into a new doc within a collection.
|
||||
*
|
||||
* @param options - The import options.
|
||||
* @param options.collection - The target doc collection.
|
||||
* @param options.schema - The schema of the target doc collection.
|
||||
* @param options.html - The HTML content to import.
|
||||
* @param options.fileName - Optional filename for the imported doc.
|
||||
* @returns A Promise that resolves to the ID of the newly created doc, or undefined if import fails.
|
||||
*/
|
||||
async function importHTMLToDoc({
|
||||
collection,
|
||||
schema,
|
||||
html,
|
||||
fileName,
|
||||
extensions,
|
||||
}: ImportHTMLToDocOptions) {
|
||||
const provider = getProvider(extensions);
|
||||
const job = new Transformer({
|
||||
schema,
|
||||
blobCRUD: collection.blobSync,
|
||||
docCRUD: {
|
||||
create: (id: string) => collection.createDoc(id).getStore({ id }),
|
||||
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
|
||||
delete: (id: string) => collection.removeDoc(id),
|
||||
},
|
||||
middlewares: [
|
||||
defaultImageProxyMiddleware,
|
||||
fileNameMiddleware(fileName),
|
||||
docLinkBaseURLMiddleware(collection.id),
|
||||
],
|
||||
});
|
||||
const htmlAdapter = new HtmlAdapter(job, provider);
|
||||
const page = await htmlAdapter.toDoc({
|
||||
file: html,
|
||||
assets: job.assetsManager,
|
||||
});
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
return page.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a zip file containing HTML files and assets into a collection.
|
||||
*
|
||||
* @param options - The import options.
|
||||
* @param options.collection - The target doc collection.
|
||||
* @param options.schema - The schema of the target doc collection.
|
||||
* @param options.imported - The zip file as a Blob.
|
||||
* @returns A Promise that resolves to an array of IDs of the newly created docs.
|
||||
*/
|
||||
async function importHTMLZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
}: ImportHTMLZipOptions) {
|
||||
const provider = getProvider(extensions);
|
||||
const unzip = new Unzip();
|
||||
await unzip.load(imported);
|
||||
|
||||
const docIds: string[] = [];
|
||||
const pendingAssets: AssetMap = new Map();
|
||||
const pendingPathBlobIdMap: PathBlobIdMap = new Map();
|
||||
const htmlBlobs: ImportedFileEntry[] = [];
|
||||
|
||||
for (const { path, content: blob } of unzip) {
|
||||
if (path.includes('__MACOSX') || path.includes('.DS_Store')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = path.split('/').pop() ?? '';
|
||||
if (fileName.endsWith('.html')) {
|
||||
htmlBlobs.push({
|
||||
filename: fileName,
|
||||
contentBlob: blob,
|
||||
fullPath: path,
|
||||
});
|
||||
} else {
|
||||
const ext = path.split('.').at(-1) ?? '';
|
||||
const mime = extMimeMap.get(ext) ?? '';
|
||||
const key = await sha(await blob.arrayBuffer());
|
||||
pendingPathBlobIdMap.set(path, key);
|
||||
pendingAssets.set(key, new File([blob], fileName, { type: mime }));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
htmlBlobs.map(async htmlFile => {
|
||||
const { filename, contentBlob, fullPath } = htmlFile;
|
||||
const fileNameWithoutExt = filename.replace(/\.[^/.]+$/, '');
|
||||
const job = new Transformer({
|
||||
schema,
|
||||
blobCRUD: collection.blobSync,
|
||||
docCRUD: {
|
||||
create: (id: string) => collection.createDoc(id).getStore({ id }),
|
||||
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
|
||||
delete: (id: string) => collection.removeDoc(id),
|
||||
},
|
||||
middlewares: [
|
||||
defaultImageProxyMiddleware,
|
||||
fileNameMiddleware(fileNameWithoutExt),
|
||||
docLinkBaseURLMiddleware(collection.id),
|
||||
filePathMiddleware(fullPath),
|
||||
],
|
||||
});
|
||||
const assets = job.assets;
|
||||
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
|
||||
for (const [assetPath, key] of pendingPathBlobIdMap.entries()) {
|
||||
pathBlobIdMap.set(assetPath, key);
|
||||
if (pendingAssets.get(key)) {
|
||||
assets.set(key, pendingAssets.get(key)!);
|
||||
}
|
||||
}
|
||||
const htmlAdapter = new HtmlAdapter(job, provider);
|
||||
const html = await contentBlob.text();
|
||||
const doc = await htmlAdapter.toDoc({
|
||||
file: html,
|
||||
assets: job.assetsManager,
|
||||
});
|
||||
if (doc) {
|
||||
docIds.push(doc.id);
|
||||
}
|
||||
})
|
||||
);
|
||||
return docIds;
|
||||
}
|
||||
|
||||
export const HtmlTransformer = {
|
||||
exportDoc,
|
||||
importHTMLToDoc,
|
||||
importHTMLZip,
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
type DocMeta,
|
||||
type DocSnapshot,
|
||||
type Schema,
|
||||
Transformer,
|
||||
type Workspace,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
export type ImportBlob = {
|
||||
blobId: string;
|
||||
sourcePath: string;
|
||||
fileName: string;
|
||||
mime: string;
|
||||
bytes: Uint8Array;
|
||||
};
|
||||
|
||||
export type ImportIconData =
|
||||
| {
|
||||
type: 'emoji';
|
||||
unicode: string;
|
||||
}
|
||||
| {
|
||||
type: 'image';
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ImportFolder = {
|
||||
path: string;
|
||||
name: string;
|
||||
parentPath?: string;
|
||||
pageId?: string;
|
||||
icon?: ImportIconData;
|
||||
};
|
||||
|
||||
export type ImportDoc = {
|
||||
id: string;
|
||||
sourcePath?: string;
|
||||
snapshot: DocSnapshot;
|
||||
meta?: Partial<
|
||||
Pick<
|
||||
DocMeta,
|
||||
'title' | 'createDate' | 'updatedDate' | 'tags' | 'favorite' | 'trash'
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
export type ImportTag = {
|
||||
name: string;
|
||||
docIds: string[];
|
||||
};
|
||||
|
||||
export type ImportIcon = {
|
||||
docId: string;
|
||||
icon: ImportIconData;
|
||||
};
|
||||
|
||||
export type ImportWarning = {
|
||||
code: string;
|
||||
message: string;
|
||||
sourcePath?: string;
|
||||
};
|
||||
|
||||
export type ImportBatch = {
|
||||
docs: ImportDoc[];
|
||||
blobs: ImportBlob[];
|
||||
folders?: ImportFolder[];
|
||||
tags?: ImportTag[];
|
||||
icons?: ImportIcon[];
|
||||
warnings?: ImportWarning[];
|
||||
progress?: {
|
||||
completed: number;
|
||||
total: number;
|
||||
};
|
||||
entryId?: string;
|
||||
isWorkspaceFile?: boolean;
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
export type ImportCommitResult = {
|
||||
docIds: string[];
|
||||
entryId?: string;
|
||||
isWorkspaceFile?: boolean;
|
||||
rootFolderId?: string;
|
||||
warnings: ImportWarning[];
|
||||
};
|
||||
|
||||
export async function blobsFromAssets(
|
||||
assets: ReadonlyMap<string, File>,
|
||||
pathBlobIdMap: ReadonlyMap<string, string> = new Map()
|
||||
) {
|
||||
const sourcePathByBlobId = new Map<string, string>();
|
||||
for (const [path, blobId] of pathBlobIdMap) {
|
||||
sourcePathByBlobId.set(blobId, path);
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
Array.from(assets, async ([blobId, file]): Promise<ImportBlob> => ({
|
||||
blobId,
|
||||
sourcePath: sourcePathByBlobId.get(blobId) ?? file.name,
|
||||
fileName: file.name,
|
||||
mime: file.type,
|
||||
bytes: new Uint8Array(await file.arrayBuffer()),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
function copyToArrayBuffer(bytes: Uint8Array) {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return copy.buffer;
|
||||
}
|
||||
|
||||
export async function commitImportBatchToWorkspace(
|
||||
collection: Workspace,
|
||||
schema: Schema,
|
||||
batch: ImportBatch
|
||||
): Promise<ImportCommitResult> {
|
||||
for (const blob of batch.blobs) {
|
||||
await collection.blobSync.set(
|
||||
blob.blobId,
|
||||
new File([copyToArrayBuffer(blob.bytes)], blob.fileName, {
|
||||
type: blob.mime,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const transformer = new Transformer({
|
||||
schema,
|
||||
blobCRUD: collection.blobSync,
|
||||
docCRUD: {
|
||||
create: (id: string) => collection.createDoc(id).getStore({ id }),
|
||||
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
|
||||
delete: (id: string) => collection.removeDoc(id),
|
||||
},
|
||||
middlewares: [],
|
||||
});
|
||||
|
||||
const docIds: string[] = [];
|
||||
for (const doc of batch.docs) {
|
||||
const store = await transformer.snapshotToDoc(doc.snapshot);
|
||||
if (!store) continue;
|
||||
docIds.push(store.id);
|
||||
if (doc.meta && Object.keys(doc.meta).length) {
|
||||
collection.meta.setDocMeta(store.id, doc.meta);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
docIds,
|
||||
entryId: batch.entryId,
|
||||
isWorkspaceFile: batch.isWorkspaceFile,
|
||||
warnings: batch.warnings ?? [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export { BearTransformer } from './bear.js';
|
||||
export { DocxTransformer } from './docx.js';
|
||||
export { HtmlTransformer } from './html.js';
|
||||
export {
|
||||
blobsFromAssets,
|
||||
commitImportBatchToWorkspace,
|
||||
type ImportBatch,
|
||||
type ImportBlob,
|
||||
type ImportCommitResult,
|
||||
type ImportDoc,
|
||||
type ImportFolder,
|
||||
type ImportIcon,
|
||||
type ImportIconData,
|
||||
type ImportTag,
|
||||
type ImportWarning,
|
||||
} from './import-batch.js';
|
||||
export { MarkdownTransformer } from './markdown.js';
|
||||
export { NotionHtmlTransformer } from './notion-html.js';
|
||||
export { ObsidianTransformer } from './obsidian.js';
|
||||
export { PdfTransformer } from './pdf.js';
|
||||
export { createAssetsArchive, download, Unzip } from './utils.js';
|
||||
export { ZipTransformer } from './zip.js';
|
||||
@@ -0,0 +1,986 @@
|
||||
import {
|
||||
defaultImageProxyMiddleware,
|
||||
docLinkBaseURLMiddleware,
|
||||
fileNameMiddleware,
|
||||
filePathMiddleware,
|
||||
FULL_FILE_PATH_KEY,
|
||||
getImageFullPath,
|
||||
MarkdownAdapter,
|
||||
type MarkdownAST,
|
||||
MarkdownASTToDeltaExtension,
|
||||
normalizeFilePathReference,
|
||||
titleMiddleware,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { Container } from '@blocksuite/global/di';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { sha } from '@blocksuite/global/utils';
|
||||
import type {
|
||||
DocMeta,
|
||||
DocSnapshot,
|
||||
ExtensionType,
|
||||
Schema,
|
||||
Store,
|
||||
Workspace,
|
||||
} from '@blocksuite/store';
|
||||
import { extMimeMap, Transformer } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
blobsFromAssets,
|
||||
type ImportBatch,
|
||||
type ImportDoc,
|
||||
type ImportFolder,
|
||||
} from './import-batch.js';
|
||||
import type { AssetMap, ImportedFileEntry, PathBlobIdMap } from './type.js';
|
||||
import { createAssetsArchive, download, parseMatter, Unzip } from './utils.js';
|
||||
|
||||
export type ParsedFrontmatterMeta = Partial<
|
||||
Pick<
|
||||
DocMeta,
|
||||
'title' | 'createDate' | 'updatedDate' | 'tags' | 'favorite' | 'trash'
|
||||
>
|
||||
>;
|
||||
|
||||
const FRONTMATTER_KEYS = {
|
||||
title: ['title', 'name'],
|
||||
created: [
|
||||
'created',
|
||||
'createdat',
|
||||
'created_at',
|
||||
'createddate',
|
||||
'created_date',
|
||||
'creationdate',
|
||||
'date',
|
||||
'time',
|
||||
],
|
||||
updated: [
|
||||
'updated',
|
||||
'updatedat',
|
||||
'updated_at',
|
||||
'updateddate',
|
||||
'updated_date',
|
||||
'modified',
|
||||
'modifiedat',
|
||||
'modified_at',
|
||||
'lastmodified',
|
||||
'last_modified',
|
||||
'lastedited',
|
||||
'last_edited',
|
||||
'lasteditedtime',
|
||||
'last_edited_time',
|
||||
],
|
||||
tags: ['tags', 'tag', 'categories', 'category', 'labels', 'keywords'],
|
||||
favorite: ['favorite', 'favourite', 'star', 'starred', 'pinned'],
|
||||
trash: ['trash', 'trashed', 'deleted', 'archived'],
|
||||
};
|
||||
|
||||
const MARKDOWN_ZIP_PAGE_ID_CONFIG_PREFIX = 'markdown-zip:page-id:';
|
||||
|
||||
function normalizeMarkdownZipLookupPath(path: string) {
|
||||
return normalizeFilePathReference(path).toLowerCase();
|
||||
}
|
||||
|
||||
function stripMarkdownExtension(path: string) {
|
||||
return path.replace(/\.md$/i, '');
|
||||
}
|
||||
|
||||
function splitMarkdownLinkTarget(url: string) {
|
||||
const queryIndex = url.indexOf('?');
|
||||
const hashIndex = url.indexOf('#');
|
||||
const splitIndex = [queryIndex, hashIndex]
|
||||
.filter(index => index >= 0)
|
||||
.sort((a, b) => a - b)[0];
|
||||
|
||||
return splitIndex === undefined ? url : url.slice(0, splitIndex);
|
||||
}
|
||||
|
||||
function isLocalMarkdownDocLink(url: string) {
|
||||
const path = splitMarkdownLinkTarget(url).trim();
|
||||
if (!path || path.startsWith('//') || path.startsWith('#')) {
|
||||
return false;
|
||||
}
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileName = path.split('/').at(-1) ?? '';
|
||||
return path.toLowerCase().endsWith('.md') || !fileName.includes('.');
|
||||
}
|
||||
|
||||
function markdownAstText(ast: MarkdownAST): string {
|
||||
if ('value' in ast && typeof ast.value === 'string') {
|
||||
return ast.value;
|
||||
}
|
||||
if ('children' in ast && Array.isArray(ast.children)) {
|
||||
return ast.children.map(child => markdownAstText(child)).join('');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getMarkdownZipPageIdConfigKey(path: string) {
|
||||
return `${MARKDOWN_ZIP_PAGE_ID_CONFIG_PREFIX}${normalizeMarkdownZipLookupPath(
|
||||
path
|
||||
)}`;
|
||||
}
|
||||
|
||||
function getMarkdownZipTargetPageId(
|
||||
configs: Map<string, string>,
|
||||
currentFilePath: string,
|
||||
url: string
|
||||
) {
|
||||
const targetPath = splitMarkdownLinkTarget(url);
|
||||
const fullPath = getImageFullPath(currentFilePath, targetPath);
|
||||
const candidates = [fullPath, stripMarkdownExtension(fullPath)];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const pageId = configs.get(getMarkdownZipPageIdConfigKey(candidate));
|
||||
if (pageId) {
|
||||
return pageId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const markdownZipDocLinkToDeltaMatcher = MarkdownASTToDeltaExtension({
|
||||
name: 'markdown-zip-doc-link',
|
||||
match: ast =>
|
||||
ast.type === 'link' &&
|
||||
'url' in ast &&
|
||||
typeof ast.url === 'string' &&
|
||||
isLocalMarkdownDocLink(ast.url),
|
||||
toDelta: (ast, context) => {
|
||||
if (!('children' in ast) || !('url' in ast)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const currentFilePath = context.configs.get(FULL_FILE_PATH_KEY);
|
||||
const targetPageId =
|
||||
typeof currentFilePath === 'string'
|
||||
? getMarkdownZipTargetPageId(context.configs, currentFilePath, ast.url)
|
||||
: null;
|
||||
|
||||
if (targetPageId) {
|
||||
const title = markdownAstText(ast).trim();
|
||||
return [
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: targetPageId,
|
||||
...(title ? { title } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return ast.children.flatMap(child =>
|
||||
context.toDelta(child).map(delta => {
|
||||
delta.attributes = { ...delta.attributes, link: ast.url };
|
||||
return delta;
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const truthyStrings = new Set(['true', 'yes', 'y', '1', 'on']);
|
||||
const falsyStrings = new Set(['false', 'no', 'n', '0', 'off']);
|
||||
|
||||
function parseBoolean(value: unknown): boolean | undefined {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') {
|
||||
if (value === 1) return true;
|
||||
if (value === 0) return false;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (truthyStrings.has(normalized)) return true;
|
||||
if (falsyStrings.has(normalized)) return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseTimestamp(value: unknown): number | undefined {
|
||||
if (value && value instanceof Date) {
|
||||
return value.getTime();
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value > 1e10 ? value : Math.round(value * 1000);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const num = Number(value);
|
||||
if (!Number.isNaN(num)) {
|
||||
return num > 1e10 ? num : Math.round(num * 1000);
|
||||
}
|
||||
const parsed = Date.parse(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseTags(value: unknown): string[] | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
const tags = value
|
||||
.map(v => (typeof v === 'string' ? v : String(v)))
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean);
|
||||
return tags.length ? [...new Set(tags)] : undefined;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const tags = value
|
||||
.split(/[,;]+/)
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean);
|
||||
return tags.length ? [...new Set(tags)] : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildMetaFromFrontmatter(
|
||||
data: Record<string, unknown>
|
||||
): ParsedFrontmatterMeta {
|
||||
const meta: ParsedFrontmatterMeta = {};
|
||||
for (const [rawKey, value] of Object.entries(data)) {
|
||||
const key = rawKey.trim().toLowerCase();
|
||||
if (FRONTMATTER_KEYS.title.includes(key) && typeof value === 'string') {
|
||||
const title = value.trim();
|
||||
if (title) meta.title = title;
|
||||
continue;
|
||||
}
|
||||
if (FRONTMATTER_KEYS.created.includes(key)) {
|
||||
const timestamp = parseTimestamp(value);
|
||||
if (timestamp !== undefined) {
|
||||
meta.createDate = timestamp;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (FRONTMATTER_KEYS.updated.includes(key)) {
|
||||
const timestamp = parseTimestamp(value);
|
||||
if (timestamp !== undefined) {
|
||||
meta.updatedDate = timestamp;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (FRONTMATTER_KEYS.tags.includes(key)) {
|
||||
const tags = parseTags(value);
|
||||
if (tags) meta.tags = tags;
|
||||
continue;
|
||||
}
|
||||
if (FRONTMATTER_KEYS.favorite.includes(key)) {
|
||||
const favorite = parseBoolean(value);
|
||||
if (favorite !== undefined) {
|
||||
meta.favorite = favorite;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (FRONTMATTER_KEYS.trash.includes(key)) {
|
||||
const trash = parseBoolean(value);
|
||||
if (trash !== undefined) {
|
||||
meta.trash = trash;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
export function parseFrontmatter(markdown: string): {
|
||||
content: string;
|
||||
meta: ParsedFrontmatterMeta;
|
||||
} {
|
||||
try {
|
||||
const parsed = parseMatter(markdown);
|
||||
if (!parsed) {
|
||||
return { content: markdown, meta: {} };
|
||||
}
|
||||
const content = parsed.body ?? markdown;
|
||||
|
||||
if (Array.isArray(parsed.metadata)) {
|
||||
return { content: String(content), meta: {} };
|
||||
}
|
||||
|
||||
const meta = buildMetaFromFrontmatter({ ...parsed.metadata });
|
||||
return { content: String(content), meta };
|
||||
} catch {
|
||||
return { content: markdown, meta: {} };
|
||||
}
|
||||
}
|
||||
|
||||
export function applyMetaPatch(
|
||||
collection: Workspace,
|
||||
docId: string,
|
||||
meta: ParsedFrontmatterMeta
|
||||
) {
|
||||
const metaPatch: Partial<DocMeta> = {};
|
||||
if (meta.title) metaPatch.title = meta.title;
|
||||
if (meta.createDate !== undefined) metaPatch.createDate = meta.createDate;
|
||||
if (meta.updatedDate !== undefined) metaPatch.updatedDate = meta.updatedDate;
|
||||
if (meta.tags) metaPatch.tags = meta.tags;
|
||||
if (meta.favorite !== undefined) metaPatch.favorite = meta.favorite;
|
||||
if (meta.trash !== undefined) metaPatch.trash = meta.trash;
|
||||
|
||||
if (Object.keys(metaPatch).length) {
|
||||
collection.meta.setDocMeta(docId, metaPatch);
|
||||
}
|
||||
}
|
||||
|
||||
export function getProvider(extensions: ExtensionType[]) {
|
||||
const container = new Container();
|
||||
extensions.forEach(ext => {
|
||||
ext.setup(container);
|
||||
});
|
||||
return container.provider();
|
||||
}
|
||||
|
||||
type ImportMarkdownToBlockOptions = {
|
||||
doc: Store;
|
||||
markdown: string;
|
||||
blockId: string;
|
||||
extensions: ExtensionType[];
|
||||
};
|
||||
|
||||
type ImportMarkdownToDocOptions = {
|
||||
collection: Workspace;
|
||||
schema: Schema;
|
||||
markdown: string;
|
||||
fileName?: string;
|
||||
extensions: ExtensionType[];
|
||||
};
|
||||
|
||||
type ImportMarkdownZipOptions = {
|
||||
collection: Workspace;
|
||||
schema: Schema;
|
||||
imported: Blob;
|
||||
extensions: ExtensionType[];
|
||||
};
|
||||
|
||||
type PrepareMarkdownFileOptions = {
|
||||
filename: string;
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
type PreparedMarkdownFile = {
|
||||
content: string;
|
||||
meta: ParsedFrontmatterMeta;
|
||||
preferredTitle: string;
|
||||
};
|
||||
|
||||
type ImportMarkdownZipInternalOptions = ImportMarkdownZipOptions & {
|
||||
createRootFolderForTopLevelDocs?: boolean;
|
||||
normalizeFolderName?: (folderName: string) => string;
|
||||
prepareMarkdownFile?: (
|
||||
options: PrepareMarkdownFileOptions
|
||||
) => PreparedMarkdownFile;
|
||||
preserveCommonRoot?: boolean;
|
||||
recursiveZip?: boolean;
|
||||
};
|
||||
|
||||
function getFileNameWithoutExtension(filename: string) {
|
||||
return filename.replace(/\.[^/.]+$/, '');
|
||||
}
|
||||
|
||||
function stripNotionHash(name: string) {
|
||||
return name
|
||||
.replace(
|
||||
/\s+[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
''
|
||||
)
|
||||
.replace(/\s+[0-9a-f]{32}$/i, '');
|
||||
}
|
||||
|
||||
function parseNotionMarkdownTitle(markdown: string):
|
||||
| {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
| undefined {
|
||||
const match = markdown.match(/^\uFEFF?#(?!#)\s+(.+?)\s*(?:\r?\n|$)/);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = match?.[1]?.trim();
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
content: markdown.slice(match[0].length),
|
||||
};
|
||||
}
|
||||
|
||||
function prepareDefaultMarkdownFile({
|
||||
filename,
|
||||
markdown,
|
||||
}: PrepareMarkdownFileOptions): PreparedMarkdownFile {
|
||||
const fileNameWithoutExt = getFileNameWithoutExtension(filename);
|
||||
const { content, meta } = parseFrontmatter(markdown);
|
||||
return {
|
||||
content,
|
||||
meta,
|
||||
preferredTitle: meta.title ?? fileNameWithoutExt,
|
||||
};
|
||||
}
|
||||
|
||||
function prepareNotionMarkdownFile({
|
||||
filename,
|
||||
markdown,
|
||||
}: PrepareMarkdownFileOptions): PreparedMarkdownFile {
|
||||
const notionTitle = parseNotionMarkdownTitle(markdown);
|
||||
const { content, meta } = parseFrontmatter(notionTitle?.content ?? markdown);
|
||||
const fallbackTitle = stripNotionHash(getFileNameWithoutExtension(filename));
|
||||
const preferredTitle = notionTitle?.title ?? meta.title ?? fallbackTitle;
|
||||
|
||||
return {
|
||||
content,
|
||||
meta: {
|
||||
...meta,
|
||||
title: preferredTitle,
|
||||
},
|
||||
preferredTitle,
|
||||
};
|
||||
}
|
||||
|
||||
function applySnapshotTitle(snapshot: DocSnapshot, title: string) {
|
||||
snapshot.meta.title = title;
|
||||
snapshot.blocks.props.title = {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [{ insert: title }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters hidden/system entries that should never participate in imports.
|
||||
*/
|
||||
export function isSystemImportPath(path: string) {
|
||||
return path.includes('__MACOSX') || path.includes('.DS_Store');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the doc CRUD bridge used by importer transformers.
|
||||
*/
|
||||
export function createCollectionDocCRUD(collection: Workspace) {
|
||||
return {
|
||||
create: (id: string) => collection.createDoc(id).getStore({ id }),
|
||||
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
|
||||
delete: (id: string) => collection.removeDoc(id),
|
||||
};
|
||||
}
|
||||
|
||||
type CreateMarkdownImportJobOptions = {
|
||||
collection: Workspace;
|
||||
schema: Schema;
|
||||
preferredTitle?: string;
|
||||
fullPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a markdown import job with the standard collection middlewares.
|
||||
*/
|
||||
export function createMarkdownImportJob({
|
||||
collection,
|
||||
schema,
|
||||
preferredTitle,
|
||||
fullPath,
|
||||
}: CreateMarkdownImportJobOptions) {
|
||||
return new Transformer({
|
||||
schema,
|
||||
blobCRUD: collection.blobSync,
|
||||
docCRUD: createCollectionDocCRUD(collection),
|
||||
middlewares: [
|
||||
defaultImageProxyMiddleware,
|
||||
fileNameMiddleware(preferredTitle),
|
||||
docLinkBaseURLMiddleware(collection.id),
|
||||
...(fullPath ? [filePathMiddleware(fullPath)] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
type StageImportedAssetOptions = {
|
||||
pendingAssets: AssetMap;
|
||||
pendingPathBlobIdMap: PathBlobIdMap;
|
||||
path: string;
|
||||
content: Blob;
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hashes a non-markdown import file and stages it into the shared asset maps.
|
||||
*/
|
||||
export async function stageImportedAsset({
|
||||
pendingAssets,
|
||||
pendingPathBlobIdMap,
|
||||
path,
|
||||
content,
|
||||
fileName,
|
||||
}: StageImportedAssetOptions) {
|
||||
const ext = path.split('.').at(-1) ?? '';
|
||||
const mime = extMimeMap.get(ext.toLowerCase()) ?? '';
|
||||
const key = await sha(await content.arrayBuffer());
|
||||
pendingPathBlobIdMap.set(path, key);
|
||||
pendingAssets.set(key, new File([content], fileName, { type: mime }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds previously staged asset files into a transformer job before import.
|
||||
*/
|
||||
export function bindImportedAssetsToJob(
|
||||
job: Transformer,
|
||||
pendingAssets: AssetMap,
|
||||
pendingPathBlobIdMap: PathBlobIdMap
|
||||
) {
|
||||
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
|
||||
// Iterate over all assets to be imported
|
||||
for (const [assetPath, key] of pendingPathBlobIdMap.entries()) {
|
||||
// Get the relative path of the asset to the markdown file
|
||||
// Store the path to blobId map
|
||||
pathBlobIdMap.set(assetPath, key);
|
||||
// Store the asset to assets, the key is the blobId, the value is the file object
|
||||
// In block adapter, it will use the blobId to get the file object
|
||||
const assetFile = pendingAssets.get(key);
|
||||
if (assetFile) {
|
||||
job.assets.set(key, assetFile);
|
||||
}
|
||||
}
|
||||
|
||||
return pathBlobIdMap;
|
||||
}
|
||||
|
||||
function bindImportedMarkdownPagesToJob(
|
||||
job: Transformer,
|
||||
pagePathIdMap: ReadonlyMap<string, string>
|
||||
) {
|
||||
for (const [path, pageId] of pagePathIdMap.entries()) {
|
||||
job.adapterConfigs.set(getMarkdownZipPageIdConfigKey(path), pageId);
|
||||
}
|
||||
}
|
||||
|
||||
function registerMarkdownZipPagePath(
|
||||
pagePathIdMap: Map<string, string>,
|
||||
path: string,
|
||||
pageId: string
|
||||
) {
|
||||
const normalizedPath = normalizeFilePathReference(path);
|
||||
pagePathIdMap.set(normalizedPath, pageId);
|
||||
pagePathIdMap.set(stripMarkdownExtension(normalizedPath), pageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports a doc to a Markdown file or a zip archive containing Markdown and assets.
|
||||
* @param doc The doc to export
|
||||
* @returns A Promise that resolves when the export is complete
|
||||
*/
|
||||
async function exportDoc(doc: Store) {
|
||||
const provider = doc.provider;
|
||||
const job = doc.getTransformer([
|
||||
docLinkBaseURLMiddleware(doc.workspace.id),
|
||||
titleMiddleware(doc.workspace.meta.docMetas),
|
||||
]);
|
||||
const snapshot = job.docToSnapshot(doc);
|
||||
|
||||
const adapter = new MarkdownAdapter(job, provider);
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markdownResult = await adapter.fromDocSnapshot({
|
||||
snapshot,
|
||||
assets: job.assetsManager,
|
||||
});
|
||||
|
||||
let downloadBlob: Blob;
|
||||
const docTitle = doc.meta?.title || 'Untitled';
|
||||
let name: string;
|
||||
const contentBlob = new Blob([markdownResult.file], { type: 'plain/text' });
|
||||
if (markdownResult.assetsIds.length > 0) {
|
||||
if (!job.assets) {
|
||||
throw new BlockSuiteError(ErrorCode.ValueNotExists, 'No assets found');
|
||||
}
|
||||
const zip = await createAssetsArchive(job.assets, markdownResult.assetsIds);
|
||||
|
||||
await zip.file('index.md', contentBlob);
|
||||
|
||||
downloadBlob = await zip.generate();
|
||||
name = `${docTitle}.zip`;
|
||||
} else {
|
||||
downloadBlob = contentBlob;
|
||||
name = `${docTitle}.md`;
|
||||
}
|
||||
download(downloadBlob, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports Markdown content into a specific block within a doc.
|
||||
* @param options Object containing import options
|
||||
* @param options.doc The target doc
|
||||
* @param options.markdown The Markdown content to import
|
||||
* @param options.blockId The ID of the block where the content will be imported
|
||||
* @returns A Promise that resolves when the import is complete
|
||||
*/
|
||||
async function importMarkdownToBlock({
|
||||
doc,
|
||||
markdown,
|
||||
blockId,
|
||||
extensions,
|
||||
}: ImportMarkdownToBlockOptions) {
|
||||
const provider = getProvider(extensions);
|
||||
const job = doc.getTransformer([
|
||||
defaultImageProxyMiddleware,
|
||||
docLinkBaseURLMiddleware(doc.workspace.id),
|
||||
]);
|
||||
const adapter = new MarkdownAdapter(job, provider);
|
||||
const snapshot = await adapter.toSliceSnapshot({
|
||||
file: markdown,
|
||||
assets: job.assetsManager,
|
||||
workspaceId: doc.workspace.id,
|
||||
pageId: doc.id,
|
||||
});
|
||||
|
||||
if (!snapshot) {
|
||||
throw new BlockSuiteError(
|
||||
BlockSuiteError.ErrorCode.ValueNotExists,
|
||||
'import markdown failed, expected to get a snapshot'
|
||||
);
|
||||
}
|
||||
|
||||
const blocks = snapshot.content.flatMap(x => x.children);
|
||||
|
||||
for (const block of blocks) {
|
||||
await job.snapshotToBlock(block, doc, blockId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports Markdown content into a new doc within a collection.
|
||||
* @param options Object containing import options
|
||||
* @param options.collection The target doc collection
|
||||
* @param options.schema The schema of the target doc collection
|
||||
* @param options.markdown The Markdown content to import
|
||||
* @param options.fileName Optional filename for the imported doc
|
||||
* @returns A Promise that resolves to the ID of the newly created doc, or undefined if import fails
|
||||
*/
|
||||
async function importMarkdownToDoc({
|
||||
collection,
|
||||
schema,
|
||||
markdown,
|
||||
fileName,
|
||||
extensions,
|
||||
}: ImportMarkdownToDocOptions) {
|
||||
const { content, meta } = parseFrontmatter(markdown);
|
||||
const preferredTitle = meta.title ?? fileName;
|
||||
const provider = getProvider(extensions);
|
||||
const job = createMarkdownImportJob({
|
||||
collection,
|
||||
schema,
|
||||
preferredTitle,
|
||||
});
|
||||
const mdAdapter = new MarkdownAdapter(job, provider);
|
||||
const page = await mdAdapter.toDoc({
|
||||
file: content,
|
||||
assets: job.assetsManager,
|
||||
});
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
applyMetaPatch(collection, page.id, meta);
|
||||
return page.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a zip file containing Markdown files and assets into a collection.
|
||||
* @param options Object containing import options
|
||||
* @param options.collection The target doc collection
|
||||
* @param options.schema The schema of the target doc collection
|
||||
* @param options.imported The zip file as a Blob
|
||||
* @returns A Promise that resolves to an array of IDs of the newly created docs
|
||||
*/
|
||||
export type FolderHierarchy = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: Map<string, FolderHierarchy>;
|
||||
pageId?: string;
|
||||
parentPath?: string;
|
||||
};
|
||||
|
||||
export type PlanMarkdownZipResult = {
|
||||
docIds: string[];
|
||||
folderHierarchy?: FolderHierarchy;
|
||||
batch: ImportBatch;
|
||||
};
|
||||
|
||||
async function planMarkdownZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
}: ImportMarkdownZipOptions): Promise<PlanMarkdownZipResult> {
|
||||
return planMarkdownZipInternal({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
});
|
||||
}
|
||||
|
||||
async function planNotionMarkdownZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
}: ImportMarkdownZipOptions): Promise<PlanMarkdownZipResult> {
|
||||
return planMarkdownZipInternal({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
normalizeFolderName: stripNotionHash,
|
||||
prepareMarkdownFile: prepareNotionMarkdownFile,
|
||||
preserveCommonRoot: true,
|
||||
createRootFolderForTopLevelDocs: true,
|
||||
recursiveZip: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function planMarkdownZipInternal({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
createRootFolderForTopLevelDocs = false,
|
||||
normalizeFolderName,
|
||||
prepareMarkdownFile = prepareDefaultMarkdownFile,
|
||||
preserveCommonRoot = false,
|
||||
recursiveZip = false,
|
||||
}: ImportMarkdownZipInternalOptions): Promise<PlanMarkdownZipResult> {
|
||||
const provider = getProvider([
|
||||
markdownZipDocLinkToDeltaMatcher,
|
||||
...extensions,
|
||||
]);
|
||||
const docIds: string[] = [];
|
||||
const docs: ImportDoc[] = [];
|
||||
const pendingAssets: AssetMap = new Map();
|
||||
const pendingPathBlobIdMap: PathBlobIdMap = new Map();
|
||||
const docPathMap: Array<{ fullPath: string; docId: string }> = [];
|
||||
const pendingPagePathIdMap = new Map<string, string>();
|
||||
const markdownBlobs: Array<ImportedFileEntry & { pageId: string }> = [];
|
||||
|
||||
async function collectZipEntries(zipBlob: Blob, basePath = '') {
|
||||
const unzip = new Unzip();
|
||||
await unzip.load(zipBlob);
|
||||
|
||||
for (const { path, content: blob } of unzip) {
|
||||
if (isSystemImportPath(path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = path.split('/').pop() ?? '';
|
||||
const fullPath = basePath ? `${basePath}/${path}` : path;
|
||||
if (fileName.endsWith('.md')) {
|
||||
const pageId = collection.idGenerator();
|
||||
registerMarkdownZipPagePath(pendingPagePathIdMap, fullPath, pageId);
|
||||
markdownBlobs.push({
|
||||
filename: fileName,
|
||||
contentBlob: blob,
|
||||
fullPath,
|
||||
pageId,
|
||||
});
|
||||
} else if (recursiveZip && fileName.endsWith('.zip')) {
|
||||
await collectZipEntries(blob, getFileNameWithoutExtension(fullPath));
|
||||
} else {
|
||||
await stageImportedAsset({
|
||||
pendingAssets,
|
||||
pendingPathBlobIdMap,
|
||||
path: fullPath,
|
||||
content: blob,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await collectZipEntries(imported);
|
||||
|
||||
await Promise.all(
|
||||
markdownBlobs.map(async markdownFile => {
|
||||
const { filename, contentBlob, fullPath, pageId } = markdownFile;
|
||||
const markdown = await contentBlob.text();
|
||||
const { content, meta, preferredTitle } = prepareMarkdownFile({
|
||||
filename,
|
||||
markdown,
|
||||
});
|
||||
const job = createMarkdownImportJob({
|
||||
collection,
|
||||
schema,
|
||||
preferredTitle,
|
||||
fullPath,
|
||||
});
|
||||
bindImportedAssetsToJob(job, pendingAssets, pendingPathBlobIdMap);
|
||||
bindImportedMarkdownPagesToJob(job, pendingPagePathIdMap);
|
||||
|
||||
const mdAdapter = new MarkdownAdapter(job, provider);
|
||||
const snapshot = await mdAdapter.toDocSnapshot({
|
||||
file: content,
|
||||
assets: job.assetsManager,
|
||||
});
|
||||
snapshot.meta.id = pageId;
|
||||
applySnapshotTitle(snapshot, preferredTitle);
|
||||
docs.push({
|
||||
id: pageId,
|
||||
snapshot,
|
||||
meta: {
|
||||
...meta,
|
||||
title: preferredTitle,
|
||||
},
|
||||
});
|
||||
docIds.push(pageId);
|
||||
docPathMap.push({ fullPath, docId: pageId });
|
||||
})
|
||||
);
|
||||
|
||||
const folderHierarchy = buildMarkdownZipFolderHierarchy(
|
||||
docPathMap,
|
||||
normalizeFolderName,
|
||||
preserveCommonRoot,
|
||||
createRootFolderForTopLevelDocs
|
||||
);
|
||||
|
||||
return {
|
||||
docIds,
|
||||
folderHierarchy,
|
||||
batch: {
|
||||
docs,
|
||||
blobs: await blobsFromAssets(pendingAssets, pendingPathBlobIdMap),
|
||||
folders: folderHierarchy
|
||||
? flattenFolderHierarchy(folderHierarchy)
|
||||
: undefined,
|
||||
done: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a tree of {@link FolderHierarchy} nodes from the zip paths of
|
||||
* imported markdown files. Returns `undefined` when every entry sits at
|
||||
* the same level (no real subfolder structure). A common root directory
|
||||
* shared by all entries is stripped automatically so that the resulting
|
||||
* hierarchy starts one level deeper.
|
||||
*/
|
||||
function buildMarkdownZipFolderHierarchy(
|
||||
entries: Array<{ fullPath: string; docId: string }>,
|
||||
normalizeFolderName?: (folderName: string) => string,
|
||||
preserveCommonRoot = false,
|
||||
createRootFolderForTopLevelDocs = false
|
||||
): FolderHierarchy | undefined {
|
||||
if (entries.length === 0) return undefined;
|
||||
|
||||
// Check once whether all entries share a common root directory
|
||||
const candidateRoot = entries[0]?.fullPath.split('/').find(Boolean);
|
||||
const skipRoot =
|
||||
!preserveCommonRoot &&
|
||||
!!candidateRoot &&
|
||||
entries.every(e => e.fullPath.startsWith(candidateRoot + '/'));
|
||||
|
||||
// Check if any entries have folder structure after the common root is stripped.
|
||||
const hasSubfolders = entries.some(e => {
|
||||
const parts = e.fullPath.split('/').filter(Boolean);
|
||||
const fileName = parts.pop();
|
||||
const folderParts = skipRoot ? parts.slice(1) : parts;
|
||||
return (
|
||||
folderParts.length > 0 || (createRootFolderForTopLevelDocs && !!fileName)
|
||||
);
|
||||
});
|
||||
if (!hasSubfolders) {
|
||||
// All files are at the same level, no folder hierarchy needed
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const root: FolderHierarchy = {
|
||||
name: '',
|
||||
path: '',
|
||||
children: new Map(),
|
||||
};
|
||||
|
||||
for (const { fullPath, docId } of entries) {
|
||||
const parts = fullPath.split('/').filter(Boolean);
|
||||
const fileName = parts.pop(); // Remove filename
|
||||
if (!fileName) continue;
|
||||
|
||||
const folderParts = skipRoot ? parts.slice(1) : parts;
|
||||
if (folderParts.length === 0 && createRootFolderForTopLevelDocs) {
|
||||
folderParts.push(getFileNameWithoutExtension(fileName));
|
||||
}
|
||||
|
||||
if (folderParts.length === 0) {
|
||||
// Root-level file, no folder needed
|
||||
continue;
|
||||
}
|
||||
|
||||
let current = root;
|
||||
let currentPath = '';
|
||||
|
||||
for (const folderName of folderParts) {
|
||||
const parentPath = currentPath;
|
||||
currentPath = currentPath ? `${currentPath}/${folderName}` : folderName;
|
||||
|
||||
if (!current.children.has(folderName)) {
|
||||
current.children.set(folderName, {
|
||||
name: normalizeFolderName?.(folderName) ?? folderName,
|
||||
path: currentPath,
|
||||
parentPath: parentPath || undefined,
|
||||
children: new Map(),
|
||||
});
|
||||
}
|
||||
current = current.children.get(folderName)!;
|
||||
}
|
||||
|
||||
// Add the doc as a leaf
|
||||
const docNodeKey = `__doc__${docId}`;
|
||||
current.children.set(docNodeKey, {
|
||||
name: docNodeKey,
|
||||
path: `${current.path}/${docNodeKey}`,
|
||||
parentPath: current.path,
|
||||
children: new Map(),
|
||||
pageId: docId,
|
||||
});
|
||||
}
|
||||
|
||||
return root.children.size > 0 ? root : undefined;
|
||||
}
|
||||
|
||||
function flattenFolderHierarchy(root: FolderHierarchy): ImportFolder[] {
|
||||
const folders: ImportFolder[] = [];
|
||||
|
||||
const visit = (node: FolderHierarchy) => {
|
||||
if (node.name) {
|
||||
folders.push({
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
parentPath: node.parentPath,
|
||||
pageId: node.pageId,
|
||||
});
|
||||
}
|
||||
for (const child of node.children.values()) {
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
|
||||
for (const child of root.children.values()) {
|
||||
visit(child);
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
export const MarkdownTransformer = {
|
||||
exportDoc,
|
||||
importMarkdownToBlock,
|
||||
importMarkdownToDoc,
|
||||
planMarkdownZip,
|
||||
planNotionMarkdownZip,
|
||||
};
|
||||
@@ -0,0 +1,414 @@
|
||||
import {
|
||||
defaultImageProxyMiddleware,
|
||||
NotionHtmlAdapter,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { Container } from '@blocksuite/global/di';
|
||||
import { sha } from '@blocksuite/global/utils';
|
||||
import {
|
||||
type ExtensionType,
|
||||
extMimeMap,
|
||||
type Schema,
|
||||
Transformer,
|
||||
type Workspace,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
blobsFromAssets,
|
||||
type ImportBatch,
|
||||
type ImportDoc,
|
||||
type ImportFolder,
|
||||
type ImportIconData,
|
||||
type ImportWarning,
|
||||
} from './import-batch.js';
|
||||
import { Unzip } from './utils.js';
|
||||
|
||||
type ImportNotionZipOptions = {
|
||||
collection: Workspace;
|
||||
schema: Schema;
|
||||
imported: Blob;
|
||||
extensions: ExtensionType[];
|
||||
};
|
||||
|
||||
export type PageIcon = {
|
||||
type: 'emoji' | 'image';
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type FolderHierarchy = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: Map<string, FolderHierarchy>;
|
||||
pageId?: string;
|
||||
parentPath?: string;
|
||||
icon?: PageIcon;
|
||||
};
|
||||
|
||||
export type PlanNotionHtmlZipResult = {
|
||||
entryId: string | undefined;
|
||||
pageIds: string[];
|
||||
isWorkspaceFile: boolean;
|
||||
hasMarkdown: boolean;
|
||||
folderHierarchy?: FolderHierarchy;
|
||||
batch: ImportBatch;
|
||||
};
|
||||
|
||||
function getProvider(extensions: ExtensionType[]) {
|
||||
const container = new Container();
|
||||
extensions.forEach(ext => {
|
||||
ext.setup(container);
|
||||
});
|
||||
return container.provider();
|
||||
}
|
||||
|
||||
function parseFolderPath(filePath: string): {
|
||||
folderParts: string[];
|
||||
fileName: string;
|
||||
} {
|
||||
const parts = filePath.split('/');
|
||||
const fileName = parts.pop() || '';
|
||||
return { folderParts: parts.filter(part => part.length > 0), fileName };
|
||||
}
|
||||
|
||||
function extractPageIcon(doc: Document): PageIcon | undefined {
|
||||
const notionIconSpan = doc.querySelector('.page-header-icon .icon');
|
||||
if (notionIconSpan && notionIconSpan.textContent) {
|
||||
const iconContent = notionIconSpan.textContent.trim();
|
||||
if (/\p{Emoji}/u.test(iconContent)) {
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: iconContent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const emojiIcon = doc.querySelector('.page-header-icon .notion-emoji');
|
||||
if (emojiIcon && emojiIcon.textContent) {
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: emojiIcon.textContent.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const altEmojiIcon = doc.querySelector('[role="img"][aria-label]');
|
||||
if (
|
||||
altEmojiIcon &&
|
||||
altEmojiIcon.textContent &&
|
||||
/\p{Emoji}/u.test(altEmojiIcon.textContent)
|
||||
) {
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: altEmojiIcon.textContent.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const imageIcon = doc.querySelector('.page-header-icon img');
|
||||
if (imageIcon) {
|
||||
const src = imageIcon.getAttribute('src');
|
||||
if (src) {
|
||||
return {
|
||||
type: 'image',
|
||||
content: src,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const iconSpans = doc.querySelectorAll('span.icon');
|
||||
for (const span of iconSpans) {
|
||||
if (span.textContent && /\p{Emoji}/u.test(span.textContent.trim())) {
|
||||
const parent = span.parentElement;
|
||||
if (
|
||||
parent &&
|
||||
(parent.classList.contains('page-header-icon') ||
|
||||
parent.closest('.page-header-icon'))
|
||||
) {
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: span.textContent.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pageTitle = doc.querySelector('.page-title, h1');
|
||||
if (pageTitle && pageTitle.textContent) {
|
||||
const text = pageTitle.textContent.trim();
|
||||
const emojiMatch = text.match(/^(\p{Emoji}+)/u);
|
||||
if (emojiMatch) {
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: emojiMatch[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildFolderHierarchy(
|
||||
pagePaths: Array<{ path: string; pageId: string; icon?: PageIcon }>
|
||||
): FolderHierarchy {
|
||||
const root: FolderHierarchy = {
|
||||
name: '',
|
||||
path: '',
|
||||
children: new Map(),
|
||||
};
|
||||
|
||||
for (const { path, pageId, icon } of pagePaths) {
|
||||
const { folderParts, fileName } = parseFolderPath(path);
|
||||
let current = root;
|
||||
let currentPath = '';
|
||||
|
||||
for (const folderName of folderParts) {
|
||||
const parentPath = currentPath;
|
||||
currentPath = currentPath ? `${currentPath}/${folderName}` : folderName;
|
||||
|
||||
if (!current.children.has(folderName)) {
|
||||
current.children.set(folderName, {
|
||||
name: folderName,
|
||||
path: currentPath,
|
||||
parentPath: parentPath || undefined,
|
||||
children: new Map(),
|
||||
});
|
||||
}
|
||||
current = current.children.get(folderName)!;
|
||||
}
|
||||
|
||||
if (fileName.endsWith('.html') && !fileName.startsWith('index.html')) {
|
||||
const pageName = fileName.replace(/\.html$/, '');
|
||||
if (!current.children.has(pageName)) {
|
||||
current.children.set(pageName, {
|
||||
name: pageName,
|
||||
path: path,
|
||||
parentPath: current.path || undefined,
|
||||
children: new Map(),
|
||||
pageId: pageId,
|
||||
icon: icon,
|
||||
});
|
||||
} else {
|
||||
const existingPage = current.children.get(pageName)!;
|
||||
existingPage.pageId = pageId;
|
||||
if (icon) {
|
||||
existingPage.icon = icon;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function toImportIconData(icon?: PageIcon): ImportIconData | undefined {
|
||||
if (!icon) return undefined;
|
||||
if (icon.type === 'emoji') {
|
||||
return {
|
||||
type: 'emoji',
|
||||
unicode: icon.content,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'image',
|
||||
content: icon.content,
|
||||
};
|
||||
}
|
||||
|
||||
function flattenFolderHierarchy(root: FolderHierarchy): ImportFolder[] {
|
||||
const folders: ImportFolder[] = [];
|
||||
|
||||
const visit = (node: FolderHierarchy) => {
|
||||
if (node.name) {
|
||||
folders.push({
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
parentPath: node.parentPath,
|
||||
pageId: node.pageId,
|
||||
icon: toImportIconData(node.icon),
|
||||
});
|
||||
}
|
||||
for (const child of node.children.values()) {
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
|
||||
for (const child of root.children.values()) {
|
||||
visit(child);
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
async function planNotionHtmlZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
}: ImportNotionZipOptions): Promise<PlanNotionHtmlZipResult> {
|
||||
const provider = getProvider(extensions);
|
||||
const pageIds: string[] = [];
|
||||
const docs: ImportDoc[] = [];
|
||||
const blobs = new Map<
|
||||
string,
|
||||
Awaited<ReturnType<typeof blobsFromAssets>>[0]
|
||||
>();
|
||||
const warnings: ImportWarning[] = [];
|
||||
let isWorkspaceFile = false;
|
||||
let hasMarkdown = false;
|
||||
let entryId: string | undefined;
|
||||
const pagePathsWithIds: Array<{
|
||||
path: string;
|
||||
pageId: string;
|
||||
icon?: PageIcon;
|
||||
}> = [];
|
||||
const parseZipFile = async (path: File | Blob) => {
|
||||
const unzip = new Unzip();
|
||||
await unzip.load(path);
|
||||
const zipFile = new Map<string, Blob>();
|
||||
const pageMap = new Map<string, string>();
|
||||
const pagePaths: string[] = [];
|
||||
const promises: Promise<void>[] = [];
|
||||
const pendingAssets = new Map<string, File>();
|
||||
const pendingPathBlobIdMap = new Map<string, string>();
|
||||
for (const { path, content, index } of unzip) {
|
||||
if (path.startsWith('__MACOSX/')) continue;
|
||||
|
||||
zipFile.set(path, content);
|
||||
|
||||
const lastSplitIndex = path.lastIndexOf('/');
|
||||
|
||||
const fileName = path.substring(lastSplitIndex + 1);
|
||||
if (fileName.endsWith('.md')) {
|
||||
hasMarkdown = true;
|
||||
continue;
|
||||
}
|
||||
if (fileName.endsWith('.html')) {
|
||||
if (path.endsWith('/index.html')) {
|
||||
isWorkspaceFile = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let pageIcon: PageIcon | undefined;
|
||||
if (lastSplitIndex !== -1) {
|
||||
const text = await content.text();
|
||||
const doc = new DOMParser().parseFromString(text, 'text/html');
|
||||
const pageBody = doc.querySelector('.page-body');
|
||||
if (pageBody && pageBody.children.length === 0) continue;
|
||||
pageIcon = extractPageIcon(doc);
|
||||
}
|
||||
|
||||
const id = collection.idGenerator();
|
||||
const splitPath = path.split('/');
|
||||
while (splitPath.length > 0) {
|
||||
pageMap.set(splitPath.join('/'), id);
|
||||
splitPath.shift();
|
||||
}
|
||||
pagePaths.push(path);
|
||||
pagePathsWithIds.push({ path, pageId: id, icon: pageIcon });
|
||||
if (entryId === undefined && lastSplitIndex === -1) {
|
||||
entryId = id;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (index === 0 && fileName.endsWith('.csv')) {
|
||||
warnings.push({
|
||||
code: 'notion-csv-export',
|
||||
message:
|
||||
'The imported Notion export appears to be CSV instead of HTML.',
|
||||
sourcePath: path,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (fileName.endsWith('.zip')) {
|
||||
const innerZipFile = content;
|
||||
if (innerZipFile) {
|
||||
promises.push(...(await parseZipFile(innerZipFile)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const blob = content;
|
||||
const ext = path.split('.').at(-1) ?? '';
|
||||
const mime = extMimeMap.get(ext.toLowerCase()) ?? '';
|
||||
const key = await sha(await blob.arrayBuffer());
|
||||
const filePathSplit = path.split('/');
|
||||
while (filePathSplit.length > 1) {
|
||||
pendingPathBlobIdMap.set(filePathSplit.join('/'), key);
|
||||
filePathSplit.shift();
|
||||
}
|
||||
pendingAssets.set(key, new File([blob], fileName, { type: mime }));
|
||||
}
|
||||
const pagePromises = Array.from(pagePaths).map(async path => {
|
||||
const job = new Transformer({
|
||||
schema,
|
||||
blobCRUD: collection.blobSync,
|
||||
docCRUD: {
|
||||
create: (id: string) => collection.createDoc(id).getStore({ id }),
|
||||
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
|
||||
delete: (id: string) => collection.removeDoc(id),
|
||||
},
|
||||
middlewares: [defaultImageProxyMiddleware],
|
||||
});
|
||||
const htmlAdapter = new NotionHtmlAdapter(job, provider);
|
||||
const assets = job.assetsManager.getAssets();
|
||||
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
|
||||
for (const [key, value] of pendingAssets.entries()) {
|
||||
if (!assets.has(key)) {
|
||||
assets.set(key, value);
|
||||
}
|
||||
}
|
||||
for (const [key, value] of pendingPathBlobIdMap.entries()) {
|
||||
if (!pathBlobIdMap.has(key)) {
|
||||
pathBlobIdMap.set(key, value);
|
||||
}
|
||||
}
|
||||
const snapshot = await htmlAdapter.toDocSnapshot({
|
||||
file: await zipFile.get(path)!.text(),
|
||||
pageId: pageMap.get(path),
|
||||
pageMap,
|
||||
assets: job.assetsManager,
|
||||
});
|
||||
docs.push({
|
||||
id: snapshot.meta.id,
|
||||
snapshot,
|
||||
});
|
||||
pageIds.push(snapshot.meta.id);
|
||||
});
|
||||
promises.push(...pagePromises);
|
||||
promises.push(
|
||||
blobsFromAssets(pendingAssets, pendingPathBlobIdMap).then(importBlobs => {
|
||||
for (const blob of importBlobs) {
|
||||
blobs.set(blob.blobId, blob);
|
||||
}
|
||||
})
|
||||
);
|
||||
return promises;
|
||||
};
|
||||
const allPromises = await parseZipFile(imported);
|
||||
await Promise.all(allPromises.flat());
|
||||
entryId = entryId ?? pageIds[0];
|
||||
|
||||
const folderHierarchy =
|
||||
pagePathsWithIds.length > 0
|
||||
? buildFolderHierarchy(pagePathsWithIds)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
entryId,
|
||||
pageIds,
|
||||
isWorkspaceFile,
|
||||
hasMarkdown,
|
||||
folderHierarchy,
|
||||
batch: {
|
||||
docs,
|
||||
blobs: Array.from(blobs.values()),
|
||||
folders: folderHierarchy
|
||||
? flattenFolderHierarchy(folderHierarchy)
|
||||
: undefined,
|
||||
warnings,
|
||||
entryId,
|
||||
isWorkspaceFile,
|
||||
done: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const NotionHtmlTransformer = {
|
||||
planNotionHtmlZip,
|
||||
};
|
||||
1000
blocksuite/affine/widgets/linked-doc/src/transformers/obsidian.ts
Normal file
1000
blocksuite/affine/widgets/linked-doc/src/transformers/obsidian.ts
Normal file
File diff suppressed because it is too large
Load Diff
32
blocksuite/affine/widgets/linked-doc/src/transformers/pdf.ts
Normal file
32
blocksuite/affine/widgets/linked-doc/src/transformers/pdf.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
docLinkBaseURLMiddleware,
|
||||
embedSyncedDocMiddleware,
|
||||
PdfAdapter,
|
||||
titleMiddleware,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { Store } from '@blocksuite/store';
|
||||
|
||||
import { download } from './utils.js';
|
||||
|
||||
async function exportDoc(doc: Store) {
|
||||
const provider = doc.provider;
|
||||
const job = doc.getTransformer([
|
||||
docLinkBaseURLMiddleware(doc.workspace.id),
|
||||
titleMiddleware(doc.workspace.meta.docMetas),
|
||||
embedSyncedDocMiddleware('content'),
|
||||
]);
|
||||
const snapshot = job.docToSnapshot(doc);
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
const adapter = new PdfAdapter(job, provider);
|
||||
const { file } = await adapter.fromDocSnapshot({
|
||||
snapshot,
|
||||
assets: job.assetsManager,
|
||||
});
|
||||
download(file.blob, file.fileName);
|
||||
}
|
||||
|
||||
export const PdfTransformer = {
|
||||
exportDoc,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ParsedFrontmatterMeta } from './markdown.js';
|
||||
|
||||
/**
|
||||
* Represents an imported file entry in the zip archive
|
||||
*/
|
||||
export type ImportedFileEntry = {
|
||||
/** The filename of the file (e.g. "document.md", "document.html") */
|
||||
filename: string;
|
||||
/** The blob containing the file content */
|
||||
contentBlob: Blob;
|
||||
/** The full path of the file in the zip archive */
|
||||
fullPath: string;
|
||||
};
|
||||
|
||||
export type MarkdownFileImportEntry = ImportedFileEntry & {
|
||||
pageId: string;
|
||||
preferredTitle: string;
|
||||
content: string;
|
||||
meta: ParsedFrontmatterMeta;
|
||||
};
|
||||
|
||||
/**
|
||||
* Map of asset hash to File object for all media files in the zip
|
||||
* Key: SHA hash of the file content (blobId)
|
||||
* Value: File object containing the actual media data
|
||||
*/
|
||||
export type AssetMap = Map<string, File>;
|
||||
|
||||
/**
|
||||
* Map of file paths to their corresponding asset hashes
|
||||
* Key: Original file path in the zip
|
||||
* Value: SHA hash of the file content (blobId)
|
||||
*/
|
||||
export type PathBlobIdMap = Map<string, string>;
|
||||
231
blocksuite/affine/widgets/linked-doc/src/transformers/utils.ts
Normal file
231
blocksuite/affine/widgets/linked-doc/src/transformers/utils.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { extMimeMap, getAssetName } from '@blocksuite/store';
|
||||
import * as fflate from 'fflate';
|
||||
import { FAILSAFE_SCHEMA, load as loadYaml } from 'js-yaml';
|
||||
|
||||
export class Zip {
|
||||
private compressed = new Uint8Array();
|
||||
|
||||
private finalize?: () => void;
|
||||
|
||||
private finalized = false;
|
||||
|
||||
private readonly zip = new fflate.Zip((err, chunk, final) => {
|
||||
if (!err) {
|
||||
const temp = new Uint8Array(this.compressed.length + chunk.length);
|
||||
temp.set(this.compressed);
|
||||
temp.set(chunk, this.compressed.length);
|
||||
this.compressed = temp;
|
||||
}
|
||||
if (final) {
|
||||
this.finalized = true;
|
||||
this.finalize?.();
|
||||
}
|
||||
});
|
||||
|
||||
async file(path: string, content: Blob | File | string) {
|
||||
const deflate = new fflate.ZipDeflate(path);
|
||||
this.zip.add(deflate);
|
||||
if (typeof content === 'string') {
|
||||
deflate.push(fflate.strToU8(content), true);
|
||||
} else {
|
||||
deflate.push(new Uint8Array(await content.arrayBuffer()), true);
|
||||
}
|
||||
}
|
||||
|
||||
folder(folderPath: string) {
|
||||
return {
|
||||
folder: (folderPath2: string) => {
|
||||
return this.folder(`${folderPath}/${folderPath2}`);
|
||||
},
|
||||
file: async (name: string, blob: Blob) => {
|
||||
await this.file(`${folderPath}/${name}`, blob);
|
||||
},
|
||||
generate: async () => {
|
||||
return this.generate();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async generate() {
|
||||
this.zip.end();
|
||||
return new Promise<Blob>(resolve => {
|
||||
if (this.finalized) {
|
||||
resolve(new Blob([this.compressed], { type: 'application/zip' }));
|
||||
} else {
|
||||
this.finalize = () =>
|
||||
resolve(new Blob([this.compressed], { type: 'application/zip' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class Unzip {
|
||||
private unzipped?: ReturnType<typeof fflate.unzipSync>;
|
||||
|
||||
async load(blob: Blob) {
|
||||
this.unzipped = fflate.unzipSync(new Uint8Array(await blob.arrayBuffer()));
|
||||
}
|
||||
|
||||
private fixFileNameEncoding(fileName: string): string {
|
||||
try {
|
||||
// `fflate` already returns valid Unicode filenames for UTF-8 zip entries.
|
||||
// Only retry decoding for legacy byte-like mojibake strings, otherwise
|
||||
// normal CJK characters can be corrupted by truncating their code points.
|
||||
if (
|
||||
fileName.split('').some(char => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code >= 0x80 && code <= 0xff;
|
||||
})
|
||||
) {
|
||||
// try different encodings
|
||||
const fixedName = this.tryDifferentEncodings(fileName);
|
||||
if (fixedName && fixedName !== fileName) {
|
||||
return fixedName;
|
||||
}
|
||||
}
|
||||
return fileName;
|
||||
} catch {
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
|
||||
// try different encodings
|
||||
private tryDifferentEncodings(fileName: string): string | null {
|
||||
try {
|
||||
// convert string to bytes
|
||||
const bytes = new Uint8Array(fileName.length);
|
||||
for (let i = 0; i < fileName.length; i++) {
|
||||
bytes[i] = fileName.charCodeAt(i);
|
||||
}
|
||||
|
||||
// try different encodings
|
||||
// The macOS system zip tool creates archives with UTF-8 encoded filenames.
|
||||
// However, this implementation doesn't strictly adhere to the ZIP specification.
|
||||
// Simply forcing UTF-8 encoding when unzipping should resolve filename corruption issues.
|
||||
const encodings = ['utf-8'];
|
||||
|
||||
for (const encoding of encodings) {
|
||||
try {
|
||||
const decoder = new TextDecoder(encoding);
|
||||
const result = decoder.decode(bytes);
|
||||
|
||||
// check if decoded result is valid
|
||||
if (result && this.isValidDecodedString(result)) {
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// ignore encoding error, try next encoding
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore conversion error
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// check if decoded string is valid
|
||||
private isValidDecodedString(str: string): boolean {
|
||||
// check if contains control characters
|
||||
const controlCharCodes = new Set([
|
||||
0x00,
|
||||
0x01,
|
||||
0x02,
|
||||
0x03,
|
||||
0x04,
|
||||
0x05,
|
||||
0x06,
|
||||
0x07,
|
||||
0x08, // \x00-\x08
|
||||
0x0b,
|
||||
0x0c, // \x0B, \x0C
|
||||
0x0e,
|
||||
0x0f,
|
||||
0x10,
|
||||
0x11,
|
||||
0x12,
|
||||
0x13,
|
||||
0x14,
|
||||
0x15,
|
||||
0x16,
|
||||
0x17,
|
||||
0x18,
|
||||
0x19,
|
||||
0x1a,
|
||||
0x1b,
|
||||
0x1c,
|
||||
0x1d,
|
||||
0x1e,
|
||||
0x1f, // \x0E-\x1F
|
||||
0x7f, // \x7F
|
||||
]);
|
||||
|
||||
return !str
|
||||
.split('')
|
||||
.some(char => controlCharCodes.has(char.charCodeAt(0)));
|
||||
}
|
||||
|
||||
*[Symbol.iterator]() {
|
||||
const keys = Object.keys(this.unzipped ?? {});
|
||||
let index = 0;
|
||||
while (keys.length) {
|
||||
const path = keys.shift()!;
|
||||
if (path.includes('__MACOSX') || path.includes('DS_Store')) {
|
||||
continue;
|
||||
}
|
||||
const lastSplitIndex = path.lastIndexOf('/');
|
||||
const fileName = path.substring(lastSplitIndex + 1);
|
||||
const fileExt =
|
||||
fileName.lastIndexOf('.') === -1 ? '' : fileName.split('.').at(-1);
|
||||
const mime = extMimeMap.get(fileExt ?? '');
|
||||
const content = new File(
|
||||
[new Uint8Array(this.unzipped![path]).buffer],
|
||||
fileName,
|
||||
mime ? { type: mime } : undefined
|
||||
) as Blob;
|
||||
|
||||
const fixedPath = this.fixFileNameEncoding(path);
|
||||
|
||||
yield { path: fixedPath, content, index };
|
||||
index++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAssetsArchive(
|
||||
assetsMap: Map<string, Blob>,
|
||||
assetsIds: string[]
|
||||
) {
|
||||
const zip = new Zip();
|
||||
|
||||
for (const [id, blob] of assetsMap) {
|
||||
if (!assetsIds.includes(id)) continue;
|
||||
const name = getAssetName(assetsMap, id);
|
||||
await zip.folder('assets').file(name, blob);
|
||||
}
|
||||
|
||||
return zip;
|
||||
}
|
||||
|
||||
export function download(blob: Blob, name: string) {
|
||||
const element = document.createElement('a');
|
||||
element.setAttribute('download', name);
|
||||
const fileURL = URL.createObjectURL(blob);
|
||||
element.setAttribute('href', fileURL);
|
||||
element.style.display = 'none';
|
||||
document.body.append(element);
|
||||
element.click();
|
||||
element.remove();
|
||||
URL.revokeObjectURL(fileURL);
|
||||
}
|
||||
|
||||
const metaMatcher = /(?<=---)(.*?)(?=---)/ms;
|
||||
const bodyMatcher = /---.*?---/s;
|
||||
export const parseMatter = (contents: string) => {
|
||||
const matterMatch = contents.match(metaMatcher);
|
||||
if (!matterMatch || !matterMatch[0]) return null;
|
||||
const metadata = loadYaml(matterMatch[0], { schema: FAILSAFE_SCHEMA });
|
||||
if (!metadata || typeof metadata !== 'object') return null;
|
||||
const body = contents.replace(bodyMatcher, '');
|
||||
return { matter: matterMatch[0], body, metadata };
|
||||
};
|
||||
172
blocksuite/affine/widgets/linked-doc/src/transformers/zip.ts
Normal file
172
blocksuite/affine/widgets/linked-doc/src/transformers/zip.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
replaceIdMiddleware,
|
||||
titleMiddleware,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { sha } from '@blocksuite/global/utils';
|
||||
import type { DocSnapshot, Schema, Store, Workspace } from '@blocksuite/store';
|
||||
import { extMimeMap, getAssetName, Transformer } from '@blocksuite/store';
|
||||
|
||||
import { download, Unzip, Zip } from './utils.js';
|
||||
|
||||
async function exportDocs(
|
||||
collection: Workspace,
|
||||
schema: Schema,
|
||||
docs: Store[]
|
||||
) {
|
||||
const zip = new Zip();
|
||||
const job = new Transformer({
|
||||
schema,
|
||||
blobCRUD: collection.blobSync,
|
||||
docCRUD: {
|
||||
create: (id: string) => collection.createDoc(id).getStore({ id }),
|
||||
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
|
||||
delete: (id: string) => collection.removeDoc(id),
|
||||
},
|
||||
middlewares: [
|
||||
replaceIdMiddleware(collection.idGenerator),
|
||||
titleMiddleware(collection.meta.docMetas),
|
||||
],
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
docs
|
||||
.map(job.docToSnapshot)
|
||||
.filter((snapshot): snapshot is DocSnapshot => !!snapshot)
|
||||
.map(async snapshot => {
|
||||
// Use the title and id as the snapshot file name
|
||||
const title = snapshot.meta.title || 'untitled';
|
||||
const id = snapshot.meta.id;
|
||||
const snapshotName = `${title}-${id}.snapshot.json`;
|
||||
await zip.file(snapshotName, JSON.stringify(snapshot, null, 2));
|
||||
})
|
||||
);
|
||||
|
||||
const assets = zip.folder('assets');
|
||||
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
|
||||
const assetsMap = job.assets;
|
||||
|
||||
// Add blobs to assets folder, if failed, log the error and continue
|
||||
const results = await Promise.all(
|
||||
Array.from(pathBlobIdMap.values()).map(async blobId => {
|
||||
try {
|
||||
await job.assetsManager.readFromBlob(blobId);
|
||||
const ext = getAssetName(assetsMap, blobId).split('.').at(-1);
|
||||
const blob = assetsMap.get(blobId);
|
||||
if (blob) {
|
||||
await assets.file(`${blobId}.${ext}`, blob);
|
||||
return { success: true, blobId };
|
||||
}
|
||||
return { success: false, blobId, error: 'Blob not found' };
|
||||
} catch (error) {
|
||||
console.error(`Failed to process blob: ${blobId}`, error);
|
||||
return { success: false, blobId, error };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const failures = results.filter(r => !r.success);
|
||||
if (failures.length > 0) {
|
||||
console.warn(`Failed to process ${failures.length} blobs:`, failures);
|
||||
}
|
||||
|
||||
const downloadBlob = await zip.generate();
|
||||
// Use the collection id as the zip file name
|
||||
return download(downloadBlob, `${collection.id}.bs.zip`);
|
||||
}
|
||||
|
||||
async function importDocs(
|
||||
collection: Workspace,
|
||||
schema: Schema,
|
||||
imported: Blob
|
||||
) {
|
||||
const unzip = new Unzip();
|
||||
await unzip.load(imported);
|
||||
|
||||
const assetBlobs: [string, Blob][] = [];
|
||||
const snapshotsBlobs: Blob[] = [];
|
||||
|
||||
for (const { path, content: blob } of unzip) {
|
||||
if (path.includes('MACOSX') || path.includes('DS_Store')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (path.startsWith('assets/')) {
|
||||
assetBlobs.push([path, blob]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (path === 'info.json') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (path.endsWith('.snapshot.json')) {
|
||||
snapshotsBlobs.push(blob);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const job = new Transformer({
|
||||
schema,
|
||||
blobCRUD: collection.blobSync,
|
||||
docCRUD: {
|
||||
create: (id: string) => collection.createDoc(id).getStore({ id }),
|
||||
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
|
||||
delete: (id: string) => collection.removeDoc(id),
|
||||
},
|
||||
middlewares: [
|
||||
replaceIdMiddleware(collection.idGenerator),
|
||||
titleMiddleware(collection.meta.docMetas),
|
||||
],
|
||||
});
|
||||
const assetsMap = job.assets;
|
||||
|
||||
assetBlobs.forEach(([name, blob]) => {
|
||||
const nameWithExt = name.replace('assets/', '');
|
||||
const assetsId = nameWithExt.replace(/\.[^/.]+$/, '');
|
||||
const ext = nameWithExt.split('.').at(-1) ?? '';
|
||||
const mime = extMimeMap.get(ext) ?? '';
|
||||
const file = new File([blob], nameWithExt, {
|
||||
type: mime,
|
||||
});
|
||||
assetsMap.set(assetsId, file);
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
snapshotsBlobs.map(async blob => {
|
||||
const json = await blob.text();
|
||||
const snapshot = JSON.parse(json) as DocSnapshot;
|
||||
const tasks: Promise<void>[] = [];
|
||||
|
||||
job.walk(snapshot, block => {
|
||||
const sourceId = block.props?.sourceId as string | undefined;
|
||||
|
||||
if (sourceId && sourceId.startsWith('/')) {
|
||||
const removeSlashId = sourceId.replace(/^\//, '');
|
||||
|
||||
if (assetsMap.has(removeSlashId)) {
|
||||
const blob = assetsMap.get(removeSlashId)!;
|
||||
|
||||
tasks.push(
|
||||
blob
|
||||
.arrayBuffer()
|
||||
.then(buffer => sha(buffer))
|
||||
.then(hash => {
|
||||
assetsMap.set(hash, blob);
|
||||
block.props.sourceId = hash;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(tasks);
|
||||
|
||||
return job.snapshotToDoc(snapshot);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export const ZipTransformer = {
|
||||
exportDocs,
|
||||
importDocs,
|
||||
};
|
||||
5
blocksuite/affine/widgets/linked-doc/src/utils.ts
Normal file
5
blocksuite/affine/widgets/linked-doc/src/utils.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Signal } from '@preact/signals-core';
|
||||
|
||||
export function resolveSignal<T>(data: T | Signal<T>): T {
|
||||
return data instanceof Signal ? data.value : data;
|
||||
}
|
||||
83
blocksuite/affine/widgets/linked-doc/src/view.ts
Normal file
83
blocksuite/affine/widgets/linked-doc/src/view.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
|
||||
import type { EditorHost } from '@blocksuite/std';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type LinkedMenuGroup, LinkedWidgetConfigExtension } from './config';
|
||||
import { effects } from './effects';
|
||||
import { linkedDocWidget } from './widget';
|
||||
|
||||
const optionsSchema = z.object({
|
||||
triggerKeys: z.optional(z.tuple([z.string()]).rest(z.string())),
|
||||
convertTriggerKey: z.boolean().optional(),
|
||||
ignoreBlockTypes: z.array(z.string()).optional(),
|
||||
ignoreSelector: z.string().optional(),
|
||||
getMenus: z.optional(
|
||||
z
|
||||
.function()
|
||||
.args(
|
||||
z.string(),
|
||||
z.function().returns(z.void()),
|
||||
z.custom<EditorHost>(),
|
||||
z.custom<AffineInlineEditor>(),
|
||||
z.instanceof(AbortSignal)
|
||||
)
|
||||
.returns(
|
||||
z.union([
|
||||
z.promise(z.array(z.custom<LinkedMenuGroup>())),
|
||||
z.array(z.custom<LinkedMenuGroup>()),
|
||||
])
|
||||
)
|
||||
),
|
||||
|
||||
autoFocusedItemKey: z.optional(
|
||||
z
|
||||
.function()
|
||||
.args(
|
||||
z.array(z.custom<LinkedMenuGroup>()),
|
||||
z.string(),
|
||||
z.string().nullable(),
|
||||
z.custom<EditorHost>(),
|
||||
z.custom<AffineInlineEditor>()
|
||||
)
|
||||
.returns(z.string().nullable())
|
||||
),
|
||||
|
||||
mobile: z
|
||||
.object({
|
||||
scrollContainer: z.optional(
|
||||
z.union([z.string(), z.instanceof(HTMLElement), z.custom<Window>()])
|
||||
),
|
||||
scrollTopOffset: z.optional(
|
||||
z.union([z.number(), z.function().returns(z.number())])
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type LinkedDocViewExtensionOptions = z.infer<typeof optionsSchema>;
|
||||
|
||||
export class LinkedDocViewExtension extends ViewExtensionProvider<LinkedDocViewExtensionOptions> {
|
||||
override name = 'affine-linked-doc-widget';
|
||||
|
||||
override schema = optionsSchema;
|
||||
|
||||
override effect() {
|
||||
super.effect();
|
||||
effects();
|
||||
}
|
||||
|
||||
override setup(
|
||||
context: ViewExtensionContext,
|
||||
options?: LinkedDocViewExtensionOptions
|
||||
) {
|
||||
super.setup(context);
|
||||
context.register(linkedDocWidget);
|
||||
if (options) {
|
||||
context.register(LinkedWidgetConfigExtension(options));
|
||||
}
|
||||
}
|
||||
}
|
||||
344
blocksuite/affine/widgets/linked-doc/src/widget.ts
Normal file
344
blocksuite/affine/widgets/linked-doc/src/widget.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
getRangeRects,
|
||||
type SelectionRect,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import {
|
||||
BLOCK_ID_ATTR,
|
||||
WidgetComponent,
|
||||
WidgetViewExtension,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import {
|
||||
INLINE_ROOT_ATTR,
|
||||
type InlineEditor,
|
||||
type InlineRootElement,
|
||||
} from '@blocksuite/std/inline';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
import { choose } from 'lit/directives/choose.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { literal, unsafeStatic } from 'lit/static-html.js';
|
||||
|
||||
import {
|
||||
AFFINE_LINKED_DOC_WIDGET,
|
||||
getMenus,
|
||||
type LinkedDocContext,
|
||||
type LinkedWidgetConfig,
|
||||
LinkedWidgetConfigExtension,
|
||||
} from './config.js';
|
||||
import { linkedDocWidgetStyles } from './styles.js';
|
||||
|
||||
export class AffineLinkedDocWidget extends WidgetComponent<RootBlockModel> {
|
||||
static override styles = linkedDocWidgetStyles;
|
||||
|
||||
private _context: LinkedDocContext | null = null;
|
||||
|
||||
private readonly _inputRects$ = signal<SelectionRect[]>([]);
|
||||
|
||||
private readonly _mode$ = signal<'desktop' | 'mobile' | 'none'>('none');
|
||||
|
||||
private _addTriggerKey(inlineEditor: InlineEditor, triggerKey: string) {
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
inlineEditor.insertText(
|
||||
{ index: inlineRange.index, length: 0 },
|
||||
triggerKey
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + triggerKey.length,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
|
||||
private _updateInputRects() {
|
||||
if (!this._context) return;
|
||||
const { inlineEditor, startRange, triggerKey } = this._context;
|
||||
|
||||
const currentInlineRange = inlineEditor.getInlineRange();
|
||||
if (!currentInlineRange) return;
|
||||
|
||||
const startIndex = startRange.index - triggerKey.length;
|
||||
const range = inlineEditor.toDomRange({
|
||||
index: startIndex,
|
||||
length: currentInlineRange.index - startIndex,
|
||||
});
|
||||
if (!range) return;
|
||||
|
||||
this._inputRects$.value = getRangeRects(
|
||||
range,
|
||||
getViewportElement(this.host)
|
||||
);
|
||||
}
|
||||
|
||||
private get _isCursorAtEnd() {
|
||||
if (!this._context) return false;
|
||||
const { inlineEditor } = this._context;
|
||||
const currentInlineRange = inlineEditor.getInlineRange();
|
||||
if (!currentInlineRange) return false;
|
||||
return currentInlineRange.index === inlineEditor.yTextLength;
|
||||
}
|
||||
|
||||
private readonly _renderLinkedDocMenu = () => {
|
||||
if (!this.block?.rootComponent) return nothing;
|
||||
|
||||
return html`<affine-mobile-linked-doc-menu
|
||||
.context=${this._context}
|
||||
.rootComponent=${this.block.rootComponent}
|
||||
></affine-mobile-linked-doc-menu>`;
|
||||
};
|
||||
|
||||
private readonly _renderLinkedDocPopover = () => {
|
||||
return html`<affine-linked-doc-popover
|
||||
.context=${this._context}
|
||||
></affine-linked-doc-popover>`;
|
||||
};
|
||||
|
||||
private _renderInputMask() {
|
||||
return html`${repeat(
|
||||
this._inputRects$.value,
|
||||
({ top, left, width, height }, index) => {
|
||||
const last =
|
||||
index === this._inputRects$.value.length - 1 && this._isCursorAtEnd;
|
||||
|
||||
const padding = 2;
|
||||
return html`<div
|
||||
class="input-mask"
|
||||
style=${styleMap({
|
||||
top: `${top - padding}px`,
|
||||
left: `${left}px`,
|
||||
width: `${width + (last ? 10 : 0)}px`,
|
||||
height: `${height + 2 * padding}px`,
|
||||
})}
|
||||
></div>`;
|
||||
}
|
||||
)}`;
|
||||
}
|
||||
|
||||
private _watchInput() {
|
||||
this.handleEvent('beforeInput', ctx => {
|
||||
if (this._mode$.peek() !== 'none') return;
|
||||
|
||||
const event = ctx.get('defaultState').event;
|
||||
if (!(event instanceof InputEvent)) return;
|
||||
|
||||
if (event.data === null) return;
|
||||
|
||||
const host = this.std.host;
|
||||
|
||||
const range = host.range.value;
|
||||
if (!range || !range.collapsed) return;
|
||||
|
||||
const containerElement =
|
||||
range.commonAncestorContainer instanceof Element
|
||||
? range.commonAncestorContainer
|
||||
: range.commonAncestorContainer.parentElement;
|
||||
if (!containerElement) return;
|
||||
|
||||
if (containerElement.closest(this.config.ignoreSelector)) return;
|
||||
|
||||
const block = containerElement.closest<BlockComponent>(
|
||||
`[${BLOCK_ID_ATTR}]`
|
||||
);
|
||||
if (!block || this.config.ignoreBlockTypes.includes(block.flavour))
|
||||
return;
|
||||
|
||||
const inlineRoot = containerElement.closest<InlineRootElement>(
|
||||
`[${INLINE_ROOT_ATTR}]`
|
||||
);
|
||||
if (!inlineRoot) return;
|
||||
|
||||
const inlineEditor = inlineRoot.inlineEditor;
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
const triggerKeys = this.config.triggerKeys;
|
||||
const primaryTriggerKey = triggerKeys[0];
|
||||
const convertTriggerKey = this.config.convertTriggerKey;
|
||||
if (primaryTriggerKey.length > inlineRange.index) return;
|
||||
const matchedText = inlineEditor.yTextString.slice(
|
||||
inlineRange.index - primaryTriggerKey.length,
|
||||
inlineRange.index
|
||||
);
|
||||
|
||||
let converted = false;
|
||||
if (matchedText !== primaryTriggerKey && convertTriggerKey) {
|
||||
for (const key of triggerKeys.slice(1)) {
|
||||
if (key.length > inlineRange.index) continue;
|
||||
const matchedText = inlineEditor.yTextString.slice(
|
||||
inlineRange.index - key.length,
|
||||
inlineRange.index
|
||||
);
|
||||
if (matchedText === key) {
|
||||
const startIdxBeforeMatchKey = inlineRange.index - key.length;
|
||||
inlineEditor.deleteText({
|
||||
index: startIdxBeforeMatchKey,
|
||||
length: key.length,
|
||||
});
|
||||
inlineEditor.insertText(
|
||||
{ index: startIdxBeforeMatchKey, length: 0 },
|
||||
primaryTriggerKey
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIdxBeforeMatchKey + primaryTriggerKey.length,
|
||||
length: 0,
|
||||
});
|
||||
converted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedText !== primaryTriggerKey && !converted) return;
|
||||
|
||||
inlineEditor
|
||||
.waitForUpdate()
|
||||
.then(() => {
|
||||
this.show({
|
||||
inlineEditor,
|
||||
primaryTriggerKey,
|
||||
mode: IS_MOBILE ? 'mobile' : 'desktop',
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
});
|
||||
}
|
||||
|
||||
private _watchViewportChange() {
|
||||
const gfx = this.std.get(GfxControllerIdentifier);
|
||||
this.disposables.add(
|
||||
gfx.viewport.viewportUpdated.subscribe(() => {
|
||||
this._updateInputRects();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
get config(): LinkedWidgetConfig {
|
||||
return {
|
||||
triggerKeys: ['@', '[[', '【【'],
|
||||
ignoreBlockTypes: ['affine:code'],
|
||||
ignoreSelector:
|
||||
'edgeless-text-editor, edgeless-shape-text-editor, edgeless-group-title-editor, edgeless-frame-title-editor, edgeless-connector-label-editor',
|
||||
convertTriggerKey: true,
|
||||
getMenus,
|
||||
mobile: {
|
||||
scrollContainer: getViewportElement(this.std.host) ?? window,
|
||||
scrollTopOffset: 46,
|
||||
},
|
||||
...this.std.getOptional(LinkedWidgetConfigExtension.identifier),
|
||||
};
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this._watchInput();
|
||||
this._watchViewportChange();
|
||||
}
|
||||
|
||||
show(props?: {
|
||||
inlineEditor?: InlineEditor;
|
||||
primaryTriggerKey?: string;
|
||||
mode?: 'desktop' | 'mobile';
|
||||
addTriggerKey?: boolean;
|
||||
}) {
|
||||
const host = this.host;
|
||||
const {
|
||||
primaryTriggerKey = '@',
|
||||
mode = 'desktop',
|
||||
addTriggerKey = false,
|
||||
} = props ?? {};
|
||||
let inlineEditor: InlineEditor;
|
||||
if (!props?.inlineEditor) {
|
||||
const range = host.range.value;
|
||||
if (!range || !range.collapsed) return;
|
||||
const containerElement =
|
||||
range.commonAncestorContainer instanceof Element
|
||||
? range.commonAncestorContainer
|
||||
: range.commonAncestorContainer.parentElement;
|
||||
if (!containerElement) return;
|
||||
const inlineRoot = containerElement.closest<InlineRootElement>(
|
||||
`[${INLINE_ROOT_ATTR}]`
|
||||
);
|
||||
if (!inlineRoot) return;
|
||||
inlineEditor = inlineRoot.inlineEditor;
|
||||
} else {
|
||||
inlineEditor = props.inlineEditor;
|
||||
}
|
||||
|
||||
if (addTriggerKey) {
|
||||
this._addTriggerKey(inlineEditor, primaryTriggerKey);
|
||||
// we need to wait the range sync to get the correct startNativeRange
|
||||
const subscription = inlineEditor.slots.inlineRangeSync.subscribe(() => {
|
||||
this.show({ ...props, addTriggerKey: false });
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const startRange = inlineEditor.getInlineRange();
|
||||
if (!startRange) return;
|
||||
|
||||
const startNativeRange = inlineEditor.getNativeRange();
|
||||
if (!startNativeRange) return;
|
||||
|
||||
const disposable = inlineEditor.slots.renderComplete.subscribe(() => {
|
||||
this._updateInputRects();
|
||||
});
|
||||
this._context = {
|
||||
std: this.std,
|
||||
inlineEditor,
|
||||
startRange,
|
||||
startNativeRange,
|
||||
triggerKey: primaryTriggerKey,
|
||||
config: this.config,
|
||||
close: () => {
|
||||
disposable.unsubscribe();
|
||||
this._inputRects$.value = [];
|
||||
this._mode$.value = 'none';
|
||||
this._context = null;
|
||||
},
|
||||
};
|
||||
|
||||
this._updateInputRects();
|
||||
|
||||
const enableMobile = this.store
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_mobile_linked_doc_menu');
|
||||
this._mode$.value = enableMobile ? mode : 'desktop';
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this._mode$.value === 'none') return nothing;
|
||||
|
||||
return html`${this._renderInputMask()}
|
||||
<blocksuite-portal
|
||||
.shadowDom=${false}
|
||||
.template=${choose(
|
||||
this._mode$.value,
|
||||
[
|
||||
['desktop', this._renderLinkedDocPopover],
|
||||
['mobile', this._renderLinkedDocMenu],
|
||||
],
|
||||
() => html`${nothing}`
|
||||
)}
|
||||
></blocksuite-portal>`;
|
||||
}
|
||||
}
|
||||
|
||||
export const linkedDocWidget = WidgetViewExtension(
|
||||
'affine:page',
|
||||
AFFINE_LINKED_DOC_WIDGET,
|
||||
literal`${unsafeStatic(AFFINE_LINKED_DOC_WIDGET)}`
|
||||
);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_LINKED_DOC_WIDGET]: AffineLinkedDocWidget;
|
||||
}
|
||||
}
|
||||
20
blocksuite/affine/widgets/linked-doc/tsconfig.json
Normal file
20
blocksuite/affine/widgets/linked-doc/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../ext-loader" },
|
||||
{ "path": "../../inlines/reference" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/std" },
|
||||
{ "path": "../../../framework/store" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user