snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (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 / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
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 / 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 / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (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 3s
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 / 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 / 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 / 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 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 (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (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 (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
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 / 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 (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
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View 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"
}

View 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');

View 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);
}

View File

@@ -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;
}

View 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;
}

View 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;
}
}

View File

@@ -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);
}
`;

View File

@@ -0,0 +1,4 @@
export * from './config';
export * from './import-doc';
export * from './transformers';
export * from './widget';

View 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;
}

View File

@@ -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;
}

View 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')};
}
`;

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 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,
};

View File

@@ -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,
};

View 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,
};

View File

@@ -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 ?? [],
};
}

View File

@@ -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';

View File

@@ -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,
};

View File

@@ -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,
};

File diff suppressed because it is too large Load Diff

View 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,
};

View File

@@ -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>;

View 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 };
};

View 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,
};

View 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;
}

View 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));
}
}
}

View 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;
}
}

View 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" }
]
}