chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions

View File

@@ -0,0 +1,139 @@
import type { ExtensionType } from '@blocksuite/block-std';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { sha } from '@blocksuite/global/utils';
import {
type AssetsManager,
BaseAdapter,
type BlockSnapshot,
type DocSnapshot,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
type Job,
nanoid,
type SliceSnapshot,
type ToBlockSnapshotPayload,
type ToDocSnapshotPayload,
} from '@blocksuite/store';
import { AdapterFactoryIdentifier } from './type.js';
export type Attachment = File[];
type AttachmentToSliceSnapshotPayload = {
file: Attachment;
assets?: AssetsManager;
blockVersions: Record<string, number>;
workspaceId: string;
pageId: string;
};
export class AttachmentAdapter extends BaseAdapter<Attachment> {
override fromBlockSnapshot(
_payload: FromBlockSnapshotPayload
): Promise<FromBlockSnapshotResult<Attachment>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'AttachmentAdapter.fromBlockSnapshot is not implemented.'
);
}
override fromDocSnapshot(
_payload: FromDocSnapshotPayload
): Promise<FromDocSnapshotResult<Attachment>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'AttachmentAdapter.fromDocSnapshot is not implemented.'
);
}
override fromSliceSnapshot(
payload: FromSliceSnapshotPayload
): Promise<FromSliceSnapshotResult<Attachment>> {
const attachments: Attachment = [];
for (const contentSlice of payload.snapshot.content) {
if (contentSlice.type === 'block') {
const { flavour, props } = contentSlice;
if (flavour === 'affine:attachment') {
const { sourceId } = props;
const file = payload.assets?.getAssets().get(sourceId as string) as
| File
| undefined;
if (file) {
attachments.push(file);
}
}
}
}
return Promise.resolve({ file: attachments, assetsIds: [] });
}
override toBlockSnapshot(
_payload: ToBlockSnapshotPayload<Attachment>
): Promise<BlockSnapshot> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'AttachmentAdapter.toBlockSnapshot is not implemented.'
);
}
override toDocSnapshot(
_payload: ToDocSnapshotPayload<Attachment>
): Promise<DocSnapshot> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'AttachmentAdapter.toDocSnapshot is not implemented.'
);
}
override async toSliceSnapshot(
payload: AttachmentToSliceSnapshotPayload
): Promise<SliceSnapshot | null> {
const content: SliceSnapshot['content'] = [];
for (const item of payload.file) {
const blobId = await sha(await item.arrayBuffer());
payload.assets?.getAssets().set(blobId, item);
await payload.assets?.writeToBlob(blobId);
content.push({
type: 'block',
flavour: 'affine:attachment',
id: nanoid(),
props: {
name: item.name,
size: item.size,
type: item.type,
embed: false,
style: 'horizontalThin',
index: 'a0',
xywh: '[0,0,0,0]',
rotate: 0,
sourceId: blobId,
},
children: [],
});
}
if (content.length === 0) {
return null;
}
return {
type: 'slice',
content,
workspaceId: payload.workspaceId,
pageId: payload.pageId,
};
}
}
export const AttachmentAdapterFactoryIdentifier =
AdapterFactoryIdentifier('Attachment');
export const AttachmentAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(AttachmentAdapterFactoryIdentifier, () => ({
get: (job: Job) => new AttachmentAdapter(job),
}));
},
};

View File

@@ -0,0 +1,32 @@
import type { ExtensionType } from '@blocksuite/block-std';
import { AttachmentAdapterFactoryExtension } from './attachment.js';
import { BlockHtmlAdapterExtensions } from './html-adapter/block-matcher.js';
import { HtmlAdapterFactoryExtension } from './html-adapter/html.js';
import { ImageAdapterFactoryExtension } from './image.js';
import { BlockMarkdownAdapterExtensions } from './markdown/block-matcher.js';
import { MarkdownAdapterFactoryExtension } from './markdown/markdown.js';
import { MixTextAdapterFactoryExtension } from './mix-text.js';
import { BlockNotionHtmlAdapterExtensions } from './notion-html/block-matcher.js';
import { NotionHtmlAdapterFactoryExtension } from './notion-html/notion-html.js';
import { NotionTextAdapterFactoryExtension } from './notion-text.js';
import { BlockPlainTextAdapterExtensions } from './plain-text/block-matcher.js';
import { PlainTextAdapterFactoryExtension } from './plain-text/plain-text.js';
export const AdapterFactoryExtensions: ExtensionType[] = [
AttachmentAdapterFactoryExtension,
ImageAdapterFactoryExtension,
MarkdownAdapterFactoryExtension,
PlainTextAdapterFactoryExtension,
HtmlAdapterFactoryExtension,
NotionTextAdapterFactoryExtension,
NotionHtmlAdapterFactoryExtension,
MixTextAdapterFactoryExtension,
];
export const BlockAdapterMatcherExtensions: ExtensionType[] = [
BlockPlainTextAdapterExtensions,
BlockMarkdownAdapterExtensions,
BlockHtmlAdapterExtensions,
BlockNotionHtmlAdapterExtensions,
].flat();

View File

@@ -0,0 +1,82 @@
import {
EmbedFigmaBlockHtmlAdapterExtension,
embedFigmaBlockHtmlAdapterMatcher,
EmbedGithubBlockHtmlAdapterExtension,
embedGithubBlockHtmlAdapterMatcher,
embedLinkedDocBlockHtmlAdapterMatcher,
EmbedLinkedDocHtmlAdapterExtension,
EmbedLoomBlockHtmlAdapterExtension,
embedLoomBlockHtmlAdapterMatcher,
EmbedSyncedDocBlockHtmlAdapterExtension,
embedSyncedDocBlockHtmlAdapterMatcher,
EmbedYoutubeBlockHtmlAdapterExtension,
embedYoutubeBlockHtmlAdapterMatcher,
} from '@blocksuite/affine-block-embed';
import {
ListBlockHtmlAdapterExtension,
listBlockHtmlAdapterMatcher,
} from '@blocksuite/affine-block-list';
import {
ParagraphBlockHtmlAdapterExtension,
paragraphBlockHtmlAdapterMatcher,
} from '@blocksuite/affine-block-paragraph';
import type { ExtensionType } from '@blocksuite/block-std';
import {
BookmarkBlockHtmlAdapterExtension,
bookmarkBlockHtmlAdapterMatcher,
} from '../../../bookmark-block/adapters/html.js';
import {
CodeBlockHtmlAdapterExtension,
codeBlockHtmlAdapterMatcher,
} from '../../../code-block/adapters/html.js';
import {
DatabaseBlockHtmlAdapterExtension,
databaseBlockHtmlAdapterMatcher,
} from '../../../database-block/adapters/html.js';
import {
DividerBlockHtmlAdapterExtension,
dividerBlockHtmlAdapterMatcher,
} from '../../../divider-block/adapters/html.js';
import {
ImageBlockHtmlAdapterExtension,
imageBlockHtmlAdapterMatcher,
} from '../../../image-block/adapters/html.js';
import {
RootBlockHtmlAdapterExtension,
rootBlockHtmlAdapterMatcher,
} from '../../../root-block/adapters/html.js';
export const defaultBlockHtmlAdapterMatchers = [
listBlockHtmlAdapterMatcher,
paragraphBlockHtmlAdapterMatcher,
codeBlockHtmlAdapterMatcher,
dividerBlockHtmlAdapterMatcher,
imageBlockHtmlAdapterMatcher,
rootBlockHtmlAdapterMatcher,
embedYoutubeBlockHtmlAdapterMatcher,
embedFigmaBlockHtmlAdapterMatcher,
embedLoomBlockHtmlAdapterMatcher,
embedGithubBlockHtmlAdapterMatcher,
bookmarkBlockHtmlAdapterMatcher,
databaseBlockHtmlAdapterMatcher,
embedLinkedDocBlockHtmlAdapterMatcher,
embedSyncedDocBlockHtmlAdapterMatcher,
];
export const BlockHtmlAdapterExtensions: ExtensionType[] = [
ListBlockHtmlAdapterExtension,
ParagraphBlockHtmlAdapterExtension,
CodeBlockHtmlAdapterExtension,
DividerBlockHtmlAdapterExtension,
ImageBlockHtmlAdapterExtension,
RootBlockHtmlAdapterExtension,
EmbedYoutubeBlockHtmlAdapterExtension,
EmbedFigmaBlockHtmlAdapterExtension,
EmbedLoomBlockHtmlAdapterExtension,
EmbedGithubBlockHtmlAdapterExtension,
BookmarkBlockHtmlAdapterExtension,
DatabaseBlockHtmlAdapterExtension,
EmbedLinkedDocHtmlAdapterExtension,
EmbedSyncedDocBlockHtmlAdapterExtension,
];

View File

@@ -0,0 +1,234 @@
import type {
HtmlAST,
HtmlASTToDeltaMatcher,
} from '@blocksuite/affine-shared/adapters';
import { collapseWhiteSpace } from 'collapse-white-space';
import type { Element } from 'hast';
const isElement = (ast: HtmlAST): ast is Element => {
return ast.type === 'element';
};
const textLikeElementTags = new Set(['span', 'bdi', 'bdo', 'ins']);
const listElementTags = new Set(['ol', 'ul']);
const strongElementTags = new Set(['strong', 'b']);
const italicElementTags = new Set(['i', 'em']);
export const htmlTextToDeltaMatcher: HtmlASTToDeltaMatcher = {
name: 'text',
match: ast => ast.type === 'text',
toDelta: (ast, context) => {
if (!('value' in ast)) {
return [];
}
const { options } = context;
options.trim ??= true;
if (options.pre) {
return [{ insert: ast.value }];
}
const value = options.trim
? collapseWhiteSpace(ast.value, { trim: options.trim })
: collapseWhiteSpace(ast.value);
return value ? [{ insert: value }] : [];
},
};
export const htmlTextLikeElementToDeltaMatcher: HtmlASTToDeltaMatcher = {
name: 'text-like-element',
match: ast => isElement(ast) && textLikeElementTags.has(ast.tagName),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false })
);
},
};
export const htmlListToDeltaMatcher: HtmlASTToDeltaMatcher = {
name: 'list-element',
match: ast => isElement(ast) && listElementTags.has(ast.tagName),
toDelta: () => {
return [];
},
};
export const htmlStrongElementToDeltaMatcher: HtmlASTToDeltaMatcher = {
name: 'strong-element',
match: ast => isElement(ast) && strongElementTags.has(ast.tagName),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes, bold: true };
return delta;
})
);
},
};
export const htmlItalicElementToDeltaMatcher: HtmlASTToDeltaMatcher = {
name: 'italic-element',
match: ast => isElement(ast) && italicElementTags.has(ast.tagName),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes, italic: true };
return delta;
})
);
},
};
export const htmlCodeElementToDeltaMatcher: HtmlASTToDeltaMatcher = {
name: 'code-element',
match: ast => isElement(ast) && ast.tagName === 'code',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes, code: true };
return delta;
})
);
},
};
export const htmlDelElementToDeltaMatcher: HtmlASTToDeltaMatcher = {
name: 'del-element',
match: ast => isElement(ast) && ast.tagName === 'del',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes, strike: true };
return delta;
})
);
},
};
export const htmlUnderlineElementToDeltaMatcher: HtmlASTToDeltaMatcher = {
name: 'underline-element',
match: ast => isElement(ast) && ast.tagName === 'u',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes, underline: true };
return delta;
})
);
},
};
export const htmlLinkElementToDeltaMatcher: HtmlASTToDeltaMatcher = {
name: 'link-element',
match: ast => isElement(ast) && ast.tagName === 'a',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const href = ast.properties?.href;
if (typeof href !== 'string') {
return [];
}
const { configs } = context;
const baseUrl = configs.get('docLinkBaseUrl') ?? '';
if (baseUrl && href.startsWith(baseUrl)) {
const path = href.substring(baseUrl.length);
// ^ - /{pageId}?mode={mode}&blockIds={blockIds}&elementIds={elementIds}
const match = path.match(/^\/([^?]+)(\?.*)?$/);
if (match) {
const pageId = match?.[1];
const search = match?.[2];
const searchParams = search ? new URLSearchParams(search) : undefined;
const mode = searchParams?.get('mode');
const blockIds = searchParams?.get('blockIds')?.split(',');
const elementIds = searchParams?.get('elementIds')?.split(',');
return [
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId,
params: {
mode:
mode && ['edgeless', 'page'].includes(mode)
? (mode as 'edgeless' | 'page')
: undefined,
blockIds,
elementIds,
},
},
},
},
];
}
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
if (href.startsWith('http')) {
delta.attributes = {
...delta.attributes,
link: href,
};
return delta;
}
return delta;
})
);
},
};
export const htmlMarkElementToDeltaMatcher: HtmlASTToDeltaMatcher = {
name: 'mark-element',
match: ast => isElement(ast) && ast.tagName === 'mark',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes };
return delta;
})
);
},
};
export const htmlBrElementToDeltaMatcher: HtmlASTToDeltaMatcher = {
name: 'br-element',
match: ast => isElement(ast) && ast.tagName === 'br',
toDelta: () => {
return [{ insert: '\n' }];
},
};
export const htmlInlineToDeltaMatchers: HtmlASTToDeltaMatcher[] = [
htmlTextToDeltaMatcher,
htmlTextLikeElementToDeltaMatcher,
htmlStrongElementToDeltaMatcher,
htmlItalicElementToDeltaMatcher,
htmlCodeElementToDeltaMatcher,
htmlDelElementToDeltaMatcher,
htmlUnderlineElementToDeltaMatcher,
htmlLinkElementToDeltaMatcher,
htmlMarkElementToDeltaMatcher,
htmlBrElementToDeltaMatcher,
];

View File

@@ -0,0 +1,145 @@
import { generateDocUrl } from '@blocksuite/affine-block-embed';
import type {
InlineDeltaToHtmlAdapterMatcher,
InlineHtmlAST,
} from '@blocksuite/affine-shared/adapters';
export const boldDeltaToHtmlAdapterMatcher: InlineDeltaToHtmlAdapterMatcher = {
name: 'bold',
match: delta => !!delta.attributes?.bold,
toAST: (_, context) => {
return {
type: 'element',
tagName: 'strong',
properties: {},
children: [context.current],
};
},
};
export const italicDeltaToHtmlAdapterMatcher: InlineDeltaToHtmlAdapterMatcher =
{
name: 'italic',
match: delta => !!delta.attributes?.italic,
toAST: (_, context) => {
return {
type: 'element',
tagName: 'em',
properties: {},
children: [context.current],
};
},
};
export const strikeDeltaToHtmlAdapterMatcher: InlineDeltaToHtmlAdapterMatcher =
{
name: 'strike',
match: delta => !!delta.attributes?.strike,
toAST: (_, context) => {
return {
type: 'element',
tagName: 'del',
properties: {},
children: [context.current],
};
},
};
export const inlineCodeDeltaToMarkdownAdapterMatcher: InlineDeltaToHtmlAdapterMatcher =
{
name: 'inlineCode',
match: delta => !!delta.attributes?.code,
toAST: (_, context) => {
return {
type: 'element',
tagName: 'code',
properties: {},
children: [context.current],
};
},
};
export const underlineDeltaToHtmlAdapterMatcher: InlineDeltaToHtmlAdapterMatcher =
{
name: 'underline',
match: delta => !!delta.attributes?.underline,
toAST: (_, context) => {
return {
type: 'element',
tagName: 'u',
properties: {},
children: [context.current],
};
},
};
export const referenceDeltaToHtmlAdapterMatcher: InlineDeltaToHtmlAdapterMatcher =
{
name: 'reference',
match: delta => !!delta.attributes?.reference,
toAST: (delta, context) => {
let hast: InlineHtmlAST = {
type: 'text',
value: delta.insert,
};
const reference = delta.attributes?.reference;
if (!reference) {
return hast;
}
const { configs } = context;
const title = configs.get(`title:${reference.pageId}`);
const url = generateDocUrl(
configs.get('docLinkBaseUrl') ?? '',
String(reference.pageId),
reference.params ?? Object.create(null)
);
if (title) {
hast.value = title;
}
hast = {
type: 'element',
tagName: 'a',
properties: {
href: url,
},
children: [hast],
};
return hast;
},
};
export const linkDeltaToHtmlAdapterMatcher: InlineDeltaToHtmlAdapterMatcher = {
name: 'link',
match: delta => !!delta.attributes?.link,
toAST: (delta, _) => {
const hast: InlineHtmlAST = {
type: 'text',
value: delta.insert,
};
const link = delta.attributes?.link;
if (!link) {
return hast;
}
return {
type: 'element',
tagName: 'a',
properties: {
href: link,
},
children: [hast],
};
},
};
export const inlineDeltaToHtmlAdapterMatchers: InlineDeltaToHtmlAdapterMatcher[] =
[
boldDeltaToHtmlAdapterMatcher,
italicDeltaToHtmlAdapterMatcher,
strikeDeltaToHtmlAdapterMatcher,
underlineDeltaToHtmlAdapterMatcher,
inlineCodeDeltaToMarkdownAdapterMatcher,
referenceDeltaToHtmlAdapterMatcher,
linkDeltaToHtmlAdapterMatcher,
];

View File

@@ -0,0 +1,385 @@
import {
DEFAULT_NOTE_BACKGROUND_COLOR,
NoteDisplayMode,
} from '@blocksuite/affine-model';
import {
type AdapterContext,
type BlockHtmlAdapterMatcher,
BlockHtmlAdapterMatcherIdentifier,
HastUtils,
type HtmlAST,
HtmlDeltaConverter,
} from '@blocksuite/affine-shared/adapters';
import type { ExtensionType } from '@blocksuite/block-std';
import {
type AssetsManager,
ASTWalker,
BaseAdapter,
type BlockSnapshot,
BlockSnapshotSchema,
type DocSnapshot,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
type Job,
nanoid,
type SliceSnapshot,
type ToBlockSnapshotPayload,
type ToDocSnapshotPayload,
} from '@blocksuite/store';
import type { Root } from 'hast';
import rehypeParse from 'rehype-parse';
import rehypeStringify from 'rehype-stringify';
import { unified } from 'unified';
import { AdapterFactoryIdentifier } from '../type.js';
import { defaultBlockHtmlAdapterMatchers } from './block-matcher.js';
import { htmlInlineToDeltaMatchers } from './delta-converter/html-inline.js';
import { inlineDeltaToHtmlAdapterMatchers } from './delta-converter/inline-delta.js';
export type Html = string;
type HtmlToSliceSnapshotPayload = {
file: Html;
assets?: AssetsManager;
blockVersions: Record<string, number>;
workspaceId: string;
pageId: string;
};
export class HtmlAdapter extends BaseAdapter<Html> {
private _astToHtml = (ast: Root) => {
return unified().use(rehypeStringify).stringify(ast);
};
private _traverseHtml = async (
html: HtmlAST,
snapshot: BlockSnapshot,
assets?: AssetsManager
) => {
const walker = new ASTWalker<HtmlAST, BlockSnapshot>();
walker.setONodeTypeGuard(
(node): node is HtmlAST =>
'type' in (node as object) && (node as HtmlAST).type !== undefined
);
walker.setEnter(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.toMatch(o)) {
const adapterContext: AdapterContext<
HtmlAST,
BlockSnapshot,
HtmlDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
};
await matcher.toBlockSnapshot.enter?.(o, adapterContext);
}
}
});
walker.setLeave(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.toMatch(o)) {
const adapterContext: AdapterContext<
HtmlAST,
BlockSnapshot,
HtmlDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
};
await matcher.toBlockSnapshot.leave?.(o, adapterContext);
}
}
});
return walker.walk(html, snapshot);
};
private _traverseSnapshot = async (
snapshot: BlockSnapshot,
html: HtmlAST,
assets?: AssetsManager
) => {
const assetsIds: string[] = [];
const walker = new ASTWalker<BlockSnapshot, HtmlAST>();
walker.setONodeTypeGuard(
(node): node is BlockSnapshot =>
BlockSnapshotSchema.safeParse(node).success
);
walker.setEnter(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.fromMatch(o)) {
const adapterContext: AdapterContext<
BlockSnapshot,
HtmlAST,
HtmlDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
updateAssetIds: (assetsId: string) => {
assetsIds.push(assetsId);
},
};
await matcher.fromBlockSnapshot.enter?.(o, adapterContext);
}
}
});
walker.setLeave(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.fromMatch(o)) {
const adapterContext: AdapterContext<
BlockSnapshot,
HtmlAST,
HtmlDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
};
await matcher.fromBlockSnapshot.leave?.(o, adapterContext);
}
}
});
return {
ast: (await walker.walk(snapshot, html)) as Root,
assetsIds,
};
};
deltaConverter: HtmlDeltaConverter;
constructor(
job: Job,
readonly blockMatchers: BlockHtmlAdapterMatcher[] = defaultBlockHtmlAdapterMatchers
) {
super(job);
this.deltaConverter = new HtmlDeltaConverter(
job.adapterConfigs,
inlineDeltaToHtmlAdapterMatchers,
htmlInlineToDeltaMatchers
);
}
private _htmlToAst(html: Html) {
return unified().use(rehypeParse).parse(html);
}
override async fromBlockSnapshot(
payload: FromBlockSnapshotPayload
): Promise<FromBlockSnapshotResult<string>> {
const root: Root = {
type: 'root',
children: [
{
type: 'doctype',
},
],
};
const { ast, assetsIds } = await this._traverseSnapshot(
payload.snapshot,
root,
payload.assets
);
return {
file: this._astToHtml(ast),
assetsIds,
};
}
override async fromDocSnapshot(
payload: FromDocSnapshotPayload
): Promise<FromDocSnapshotResult<string>> {
const { file, assetsIds } = await this.fromBlockSnapshot({
snapshot: payload.snapshot.blocks,
assets: payload.assets,
});
return {
file: file.replace(
'<!--BlockSuiteDocTitlePlaceholder-->',
`<h1>${payload.snapshot.meta.title}</h1>`
),
assetsIds,
};
}
override async fromSliceSnapshot(
payload: FromSliceSnapshotPayload
): Promise<FromSliceSnapshotResult<string>> {
let buffer = '';
const sliceAssetsIds: string[] = [];
for (const contentSlice of payload.snapshot.content) {
const root: Root = {
type: 'root',
children: [],
};
const { ast, assetsIds } = await this._traverseSnapshot(
contentSlice,
root,
payload.assets
);
sliceAssetsIds.push(...assetsIds);
buffer += this._astToHtml(ast);
}
const html = buffer;
return {
file: html,
assetsIds: sliceAssetsIds,
};
}
override toBlockSnapshot(
payload: ToBlockSnapshotPayload<string>
): Promise<BlockSnapshot> {
const htmlAst = this._htmlToAst(payload.file);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
return this._traverseHtml(
htmlAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets
);
}
override async toDocSnapshot(
payload: ToDocSnapshotPayload<string>
): Promise<DocSnapshot> {
const htmlAst = this._htmlToAst(payload.file);
const titleAst = HastUtils.querySelector(htmlAst, 'title');
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
return {
type: 'page',
meta: {
id: nanoid(),
title: HastUtils.getTextContent(titleAst, 'Untitled'),
createDate: Date.now(),
tags: [],
},
blocks: {
type: 'block',
id: nanoid(),
flavour: 'affine:page',
props: {
title: {
'$blocksuite:internal:text$': true,
delta: this.deltaConverter.astToDelta(
titleAst ?? {
type: 'text',
value: 'Untitled',
}
),
},
},
children: [
{
type: 'block',
id: nanoid(),
flavour: 'affine:surface',
props: {
elements: {},
},
children: [],
},
await this._traverseHtml(
htmlAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets
),
],
},
};
}
override async toSliceSnapshot(
payload: HtmlToSliceSnapshotPayload
): Promise<SliceSnapshot | null> {
const htmlAst = this._htmlToAst(payload.file);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
const contentSlice = (await this._traverseHtml(
htmlAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets
)) as BlockSnapshot;
if (contentSlice.children.length === 0) {
return null;
}
return {
type: 'slice',
content: [contentSlice],
workspaceId: payload.workspaceId,
pageId: payload.pageId,
};
}
}
export const HtmlAdapterFactoryIdentifier = AdapterFactoryIdentifier('Html');
export const HtmlAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(HtmlAdapterFactoryIdentifier, provider => ({
get: (job: Job) =>
new HtmlAdapter(
job,
Array.from(
provider.getAll(BlockHtmlAdapterMatcherIdentifier).values()
)
),
}));
},
};

View File

@@ -0,0 +1,9 @@
export {
BlockHtmlAdapterExtensions,
defaultBlockHtmlAdapterMatchers,
} from './block-matcher.js';
export {
HtmlAdapter,
HtmlAdapterFactoryExtension,
HtmlAdapterFactoryIdentifier,
} from './html.js';

View File

@@ -0,0 +1,130 @@
import type { ExtensionType } from '@blocksuite/block-std';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { sha } from '@blocksuite/global/utils';
import {
type AssetsManager,
BaseAdapter,
type BlockSnapshot,
type DocSnapshot,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
type Job,
nanoid,
type SliceSnapshot,
type ToBlockSnapshotPayload,
type ToDocSnapshotPayload,
} from '@blocksuite/store';
import { AdapterFactoryIdentifier } from './type.js';
export type Image = File[];
type ImageToSliceSnapshotPayload = {
file: Image;
assets?: AssetsManager;
blockVersions: Record<string, number>;
workspaceId: string;
pageId: string;
};
export class ImageAdapter extends BaseAdapter<Image> {
override fromBlockSnapshot(
_payload: FromBlockSnapshotPayload
): Promise<FromBlockSnapshotResult<Image>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ImageAdapter.fromBlockSnapshot is not implemented.'
);
}
override fromDocSnapshot(
_payload: FromDocSnapshotPayload
): Promise<FromDocSnapshotResult<Image>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ImageAdapter.fromDocSnapshot is not implemented.'
);
}
override fromSliceSnapshot(
payload: FromSliceSnapshotPayload
): Promise<FromSliceSnapshotResult<Image>> {
const images: Image = [];
for (const contentSlice of payload.snapshot.content) {
if (contentSlice.type === 'block') {
const { flavour, props } = contentSlice;
if (flavour === 'affine:image') {
const { sourceId } = props;
const file = payload.assets?.getAssets().get(sourceId as string) as
| File
| undefined;
if (file) {
images.push(file);
}
}
}
}
return Promise.resolve({ file: images, assetsIds: [] });
}
override toBlockSnapshot(
_payload: ToBlockSnapshotPayload<Image>
): Promise<BlockSnapshot> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ImageAdapter.toBlockSnapshot is not implemented.'
);
}
override toDocSnapshot(
_payload: ToDocSnapshotPayload<Image>
): Promise<DocSnapshot> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ImageAdapter.toDocSnapshot is not implemented'
);
}
override async toSliceSnapshot(
payload: ImageToSliceSnapshotPayload
): Promise<SliceSnapshot | null> {
const content: SliceSnapshot['content'] = [];
for (const item of payload.file) {
const blobId = await sha(await item.arrayBuffer());
payload.assets?.getAssets().set(blobId, item);
await payload.assets?.writeToBlob(blobId);
content.push({
type: 'block',
flavour: 'affine:image',
id: nanoid(),
props: {
sourceId: blobId,
},
children: [],
});
}
if (content.length === 0) {
return null;
}
return {
type: 'slice',
content,
workspaceId: payload.workspaceId,
pageId: payload.pageId,
};
}
}
export const ImageAdapterFactoryIdentifier = AdapterFactoryIdentifier('Image');
export const ImageAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(ImageAdapterFactoryIdentifier, () => ({
get: (job: Job) => new ImageAdapter(job),
}));
},
};

View File

@@ -0,0 +1,8 @@
export * from './attachment.js';
export * from './html-adapter/html.js';
export * from './image.js';
export * from './markdown/index.js';
export * from './mix-text.js';
export * from './notion-html/index.js';
export * from './notion-text.js';
export * from './plain-text/plain-text.js';

View File

@@ -0,0 +1,88 @@
import {
embedFigmaBlockMarkdownAdapterMatcher,
EmbedFigmaMarkdownAdapterExtension,
embedGithubBlockMarkdownAdapterMatcher,
EmbedGithubMarkdownAdapterExtension,
embedLinkedDocBlockMarkdownAdapterMatcher,
EmbedLinkedDocMarkdownAdapterExtension,
embedLoomBlockMarkdownAdapterMatcher,
EmbedLoomMarkdownAdapterExtension,
EmbedSyncedDocBlockMarkdownAdapterExtension,
embedSyncedDocBlockMarkdownAdapterMatcher,
embedYoutubeBlockMarkdownAdapterMatcher,
EmbedYoutubeMarkdownAdapterExtension,
} from '@blocksuite/affine-block-embed';
import {
ListBlockMarkdownAdapterExtension,
listBlockMarkdownAdapterMatcher,
} from '@blocksuite/affine-block-list';
import {
ParagraphBlockMarkdownAdapterExtension,
paragraphBlockMarkdownAdapterMatcher,
} from '@blocksuite/affine-block-paragraph';
import type { ExtensionType } from '@blocksuite/block-std';
import {
BookmarkBlockMarkdownAdapterExtension,
bookmarkBlockMarkdownAdapterMatcher,
} from '../../../bookmark-block/adapters/markdown.js';
import {
CodeBlockMarkdownAdapterExtension,
codeBlockMarkdownAdapterMatcher,
} from '../../../code-block/adapters/markdown.js';
import {
DatabaseBlockMarkdownAdapterExtension,
databaseBlockMarkdownAdapterMatcher,
} from '../../../database-block/adapters/markdown.js';
import {
DividerBlockMarkdownAdapterExtension,
dividerBlockMarkdownAdapterMatcher,
} from '../../../divider-block/adapters/markdown.js';
import {
ImageBlockMarkdownAdapterExtension,
imageBlockMarkdownAdapterMatcher,
} from '../../../image-block/adapters/markdown.js';
import {
LatexBlockMarkdownAdapterExtension,
latexBlockMarkdownAdapterMatcher,
} from '../../../latex-block/adapters/markdown.js';
import {
RootBlockMarkdownAdapterExtension,
rootBlockMarkdownAdapterMatcher,
} from '../../../root-block/adapters/markdown.js';
export const defaultBlockMarkdownAdapterMatchers = [
embedFigmaBlockMarkdownAdapterMatcher,
embedGithubBlockMarkdownAdapterMatcher,
embedLinkedDocBlockMarkdownAdapterMatcher,
embedLoomBlockMarkdownAdapterMatcher,
embedSyncedDocBlockMarkdownAdapterMatcher,
embedYoutubeBlockMarkdownAdapterMatcher,
listBlockMarkdownAdapterMatcher,
paragraphBlockMarkdownAdapterMatcher,
bookmarkBlockMarkdownAdapterMatcher,
codeBlockMarkdownAdapterMatcher,
databaseBlockMarkdownAdapterMatcher,
dividerBlockMarkdownAdapterMatcher,
imageBlockMarkdownAdapterMatcher,
latexBlockMarkdownAdapterMatcher,
rootBlockMarkdownAdapterMatcher,
];
export const BlockMarkdownAdapterExtensions: ExtensionType[] = [
EmbedFigmaMarkdownAdapterExtension,
EmbedGithubMarkdownAdapterExtension,
EmbedLinkedDocMarkdownAdapterExtension,
EmbedLoomMarkdownAdapterExtension,
EmbedSyncedDocBlockMarkdownAdapterExtension,
EmbedYoutubeMarkdownAdapterExtension,
ListBlockMarkdownAdapterExtension,
ParagraphBlockMarkdownAdapterExtension,
BookmarkBlockMarkdownAdapterExtension,
CodeBlockMarkdownAdapterExtension,
DatabaseBlockMarkdownAdapterExtension,
DividerBlockMarkdownAdapterExtension,
ImageBlockMarkdownAdapterExtension,
LatexBlockMarkdownAdapterExtension,
RootBlockMarkdownAdapterExtension,
];

View File

@@ -0,0 +1,153 @@
import { generateDocUrl } from '@blocksuite/affine-block-embed';
import type { InlineDeltaToMarkdownAdapterMatcher } from '@blocksuite/affine-shared/adapters';
import type { PhrasingContent } from 'mdast';
export const boldDeltaToMarkdownAdapterMatcher: InlineDeltaToMarkdownAdapterMatcher =
{
name: 'bold',
match: delta => !!delta.attributes?.bold,
toAST: (_, context) => {
const { current: currentMdast } = context;
return {
type: 'strong',
children: [currentMdast],
};
},
};
export const italicDeltaToMarkdownAdapterMatcher: InlineDeltaToMarkdownAdapterMatcher =
{
name: 'italic',
match: delta => !!delta.attributes?.italic,
toAST: (_, context) => {
const { current: currentMdast } = context;
return {
type: 'emphasis',
children: [currentMdast],
};
},
};
export const strikeDeltaToMarkdownAdapterMatcher: InlineDeltaToMarkdownAdapterMatcher =
{
name: 'strike',
match: delta => !!delta.attributes?.strike,
toAST: (_, context) => {
const { current: currentMdast } = context;
return {
type: 'delete',
children: [currentMdast],
};
},
};
export const inlineCodeDeltaToMarkdownAdapterMatcher: InlineDeltaToMarkdownAdapterMatcher =
{
name: 'inlineCode',
match: delta => !!delta.attributes?.code,
toAST: delta => ({
type: 'inlineCode',
value: delta.insert,
}),
};
export const referenceDeltaToMarkdownAdapterMatcher: InlineDeltaToMarkdownAdapterMatcher =
{
name: 'reference',
match: delta => !!delta.attributes?.reference,
toAST: (delta, context) => {
let mdast: PhrasingContent = {
type: 'text',
value: delta.insert,
};
const reference = delta.attributes?.reference;
if (!reference) {
return mdast;
}
const { configs } = context;
const title = configs.get(`title:${reference.pageId}`);
const params = reference.params ?? {};
const url = generateDocUrl(
configs.get('docLinkBaseUrl') ?? '',
String(reference.pageId),
params
);
mdast = {
type: 'link',
url,
children: [
{
type: 'text',
value: title ?? '',
},
],
};
return mdast;
},
};
export const linkDeltaToMarkdownAdapterMatcher: InlineDeltaToMarkdownAdapterMatcher =
{
name: 'link',
match: delta => !!delta.attributes?.link,
toAST: (delta, context) => {
const mdast: PhrasingContent = {
type: 'text',
value: delta.insert,
};
const link = delta.attributes?.link;
if (!link) {
return mdast;
}
const { current: currentMdast } = context;
if ('value' in currentMdast) {
if (currentMdast.value === '') {
return {
type: 'text',
value: link,
};
}
if (mdast.value !== link) {
return {
type: 'link',
url: link,
children: [currentMdast],
};
}
}
return mdast;
},
};
export const latexDeltaToMarkdownAdapterMatcher: InlineDeltaToMarkdownAdapterMatcher =
{
name: 'inlineLatex',
match: delta => !!delta.attributes?.latex,
toAST: delta => {
const mdast: PhrasingContent = {
type: 'text',
value: delta.insert,
};
if (delta.attributes?.latex) {
return {
type: 'inlineMath',
value: delta.attributes.latex,
};
}
return mdast;
},
};
export const inlineDeltaToMarkdownAdapterMatchers: InlineDeltaToMarkdownAdapterMatcher[] =
[
referenceDeltaToMarkdownAdapterMatcher,
linkDeltaToMarkdownAdapterMatcher,
inlineCodeDeltaToMarkdownAdapterMatcher,
boldDeltaToMarkdownAdapterMatcher,
italicDeltaToMarkdownAdapterMatcher,
strikeDeltaToMarkdownAdapterMatcher,
latexDeltaToMarkdownAdapterMatcher,
];

View File

@@ -0,0 +1,150 @@
import type { MarkdownASTToDeltaMatcher } from '@blocksuite/affine-shared/adapters';
export const markdownTextToDeltaMatcher: MarkdownASTToDeltaMatcher = {
name: 'text',
match: ast => ast.type === 'text',
toDelta: ast => {
if (!('value' in ast)) {
return [];
}
return [{ insert: ast.value }];
},
};
export const markdownInlineCodeToDeltaMatcher: MarkdownASTToDeltaMatcher = {
name: 'inlineCode',
match: ast => ast.type === 'inlineCode',
toDelta: ast => {
if (!('value' in ast)) {
return [];
}
return [{ insert: ast.value, attributes: { code: true } }];
},
};
export const markdownStrongToDeltaMatcher: MarkdownASTToDeltaMatcher = {
name: 'strong',
match: ast => ast.type === 'strong',
toDelta: (ast, context) => {
if (!('children' in ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child).map(delta => {
delta.attributes = { ...delta.attributes, bold: true };
return delta;
})
);
},
};
export const markdownEmphasisToDeltaMatcher: MarkdownASTToDeltaMatcher = {
name: 'emphasis',
match: ast => ast.type === 'emphasis',
toDelta: (ast, context) => {
if (!('children' in ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child).map(delta => {
delta.attributes = { ...delta.attributes, italic: true };
return delta;
})
);
},
};
export const markdownDeleteToDeltaMatcher: MarkdownASTToDeltaMatcher = {
name: 'delete',
match: ast => ast.type === 'delete',
toDelta: (ast, context) => {
if (!('children' in ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child).map(delta => {
delta.attributes = { ...delta.attributes, strike: true };
return delta;
})
);
},
};
export const markdownLinkToDeltaMatcher: MarkdownASTToDeltaMatcher = {
name: 'link',
match: ast => ast.type === 'link',
toDelta: (ast, context) => {
if (!('children' in ast) || !('url' in ast)) {
return [];
}
const { configs } = context;
const baseUrl = configs.get('docLinkBaseUrl') ?? '';
if (baseUrl && ast.url.startsWith(baseUrl)) {
const path = ast.url.substring(baseUrl.length);
// ^ - /{pageId}?mode={mode}&blockIds={blockIds}&elementIds={elementIds}
const match = path.match(/^\/([^?]+)(\?.*)?$/);
if (match) {
const pageId = match?.[1];
const search = match?.[2];
const searchParams = search ? new URLSearchParams(search) : undefined;
const mode = searchParams?.get('mode');
const blockIds = searchParams?.get('blockIds')?.split(',');
const elementIds = searchParams?.get('elementIds')?.split(',');
return [
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId,
params: {
mode:
mode && ['edgeless', 'page'].includes(mode)
? (mode as 'edgeless' | 'page')
: undefined,
blockIds,
elementIds,
},
},
},
},
];
}
}
return ast.children.flatMap(child =>
context.toDelta(child).map(delta => {
delta.attributes = { ...delta.attributes, link: ast.url };
return delta;
})
);
},
};
export const markdownListToDeltaMatcher: MarkdownASTToDeltaMatcher = {
name: 'list',
match: ast => ast.type === 'list',
toDelta: () => [],
};
export const markdownInlineMathToDeltaMatcher: MarkdownASTToDeltaMatcher = {
name: 'inlineMath',
match: ast => ast.type === 'inlineMath',
toDelta: ast => {
if (!('value' in ast)) {
return [];
}
return [{ insert: ' ', attributes: { latex: ast.value } }];
},
};
export const markdownInlineToDeltaMatchers: MarkdownASTToDeltaMatcher[] = [
markdownTextToDeltaMatcher,
markdownInlineCodeToDeltaMatcher,
markdownStrongToDeltaMatcher,
markdownEmphasisToDeltaMatcher,
markdownDeleteToDeltaMatcher,
markdownLinkToDeltaMatcher,
markdownInlineMathToDeltaMatcher,
markdownListToDeltaMatcher,
];

View File

@@ -0,0 +1,69 @@
/*
MIT License
Copyright (c) 2020 Titus Wormer <tituswormer@gmail.com>
mdast-util-gfm-autolink-literal is from markdown only.
mdast-util-gfm-footnote is not included.
*/
import { gfmAutolinkLiteralFromMarkdown } from 'mdast-util-gfm-autolink-literal';
import {
gfmStrikethroughFromMarkdown,
gfmStrikethroughToMarkdown,
} from 'mdast-util-gfm-strikethrough';
import { gfmTableFromMarkdown, gfmTableToMarkdown } from 'mdast-util-gfm-table';
import {
gfmTaskListItemFromMarkdown,
gfmTaskListItemToMarkdown,
} from 'mdast-util-gfm-task-list-item';
import { gfmAutolinkLiteral } from 'micromark-extension-gfm-autolink-literal';
import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough';
import { gfmTable } from 'micromark-extension-gfm-table';
import { gfmTaskListItem } from 'micromark-extension-gfm-task-list-item';
import { combineExtensions } from 'micromark-util-combine-extensions';
import type { Processor } from 'unified';
export function gfm() {
return combineExtensions([
gfmAutolinkLiteral(),
gfmStrikethrough(),
gfmTable(),
gfmTaskListItem(),
]);
}
function gfmFromMarkdown() {
return [
gfmStrikethroughFromMarkdown(),
gfmTableFromMarkdown(),
gfmTaskListItemFromMarkdown(),
gfmAutolinkLiteralFromMarkdown(),
];
}
function gfmToMarkdown() {
return {
extensions: [
gfmStrikethroughToMarkdown(),
gfmTableToMarkdown(),
gfmTaskListItemToMarkdown(),
],
};
}
export function remarkGfm(this: Processor) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
const data = self.data();
const micromarkExtensions =
data.micromarkExtensions || (data.micromarkExtensions = []);
const fromMarkdownExtensions =
data.fromMarkdownExtensions || (data.fromMarkdownExtensions = []);
const toMarkdownExtensions =
data.toMarkdownExtensions || (data.toMarkdownExtensions = []);
micromarkExtensions.push(gfm());
fromMarkdownExtensions.push(gfmFromMarkdown());
toMarkdownExtensions.push(gfmToMarkdown());
}

View File

@@ -0,0 +1,9 @@
export {
BlockMarkdownAdapterExtensions,
defaultBlockMarkdownAdapterMatchers,
} from './block-matcher.js';
export {
MarkdownAdapter,
MarkdownAdapterFactoryExtension,
MarkdownAdapterFactoryIdentifier,
} from './markdown.js';

View File

@@ -0,0 +1,455 @@
import {
DEFAULT_NOTE_BACKGROUND_COLOR,
NoteDisplayMode,
} from '@blocksuite/affine-model';
import {
type AdapterContext,
type BlockMarkdownAdapterMatcher,
BlockMarkdownAdapterMatcherIdentifier,
type Markdown,
type MarkdownAST,
MarkdownDeltaConverter,
} from '@blocksuite/affine-shared/adapters';
import type { ExtensionType } from '@blocksuite/block-std';
import {
type AssetsManager,
ASTWalker,
BaseAdapter,
type BlockSnapshot,
BlockSnapshotSchema,
type DocSnapshot,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
type Job,
nanoid,
type SliceSnapshot,
type ToBlockSnapshotPayload,
type ToDocSnapshotPayload,
} from '@blocksuite/store';
import type { Root } from 'mdast';
import remarkMath from 'remark-math';
import remarkParse from 'remark-parse';
import remarkStringify from 'remark-stringify';
import { unified } from 'unified';
import { AdapterFactoryIdentifier } from '../type.js';
import { defaultBlockMarkdownAdapterMatchers } from './block-matcher.js';
import { inlineDeltaToMarkdownAdapterMatchers } from './delta-converter/inline-delta.js';
import { markdownInlineToDeltaMatchers } from './delta-converter/markdown-inline.js';
import { remarkGfm } from './gfm.js';
type MarkdownToSliceSnapshotPayload = {
file: Markdown;
assets?: AssetsManager;
workspaceId: string;
pageId: string;
};
export class MarkdownAdapter extends BaseAdapter<Markdown> {
private _traverseMarkdown = (
markdown: MarkdownAST,
snapshot: BlockSnapshot,
assets?: AssetsManager
) => {
const walker = new ASTWalker<MarkdownAST, BlockSnapshot>();
walker.setONodeTypeGuard(
(node): node is MarkdownAST =>
!Array.isArray(node) &&
'type' in (node as object) &&
(node as MarkdownAST).type !== undefined
);
walker.setEnter(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.toMatch(o)) {
const adapterContext: AdapterContext<
MarkdownAST,
BlockSnapshot,
MarkdownDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
};
await matcher.toBlockSnapshot.enter?.(o, adapterContext);
}
}
});
walker.setLeave(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.toMatch(o)) {
const adapterContext: AdapterContext<
MarkdownAST,
BlockSnapshot,
MarkdownDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
};
await matcher.toBlockSnapshot.leave?.(o, adapterContext);
}
}
});
return walker.walk(markdown, snapshot);
};
private _traverseSnapshot = async (
snapshot: BlockSnapshot,
markdown: MarkdownAST,
assets?: AssetsManager
) => {
const assetsIds: string[] = [];
const walker = new ASTWalker<BlockSnapshot, MarkdownAST>();
walker.setONodeTypeGuard(
(node): node is BlockSnapshot =>
BlockSnapshotSchema.safeParse(node).success
);
walker.setEnter(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.fromMatch(o)) {
const adapterContext: AdapterContext<
BlockSnapshot,
MarkdownAST,
MarkdownDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
updateAssetIds: (assetsId: string) => {
assetsIds.push(assetsId);
},
};
await matcher.fromBlockSnapshot.enter?.(o, adapterContext);
}
}
});
walker.setLeave(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.fromMatch(o)) {
const adapterContext: AdapterContext<
BlockSnapshot,
MarkdownAST,
MarkdownDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
};
await matcher.fromBlockSnapshot.leave?.(o, adapterContext);
}
}
});
return {
ast: (await walker.walk(snapshot, markdown)) as Root,
assetsIds,
};
};
deltaConverter: MarkdownDeltaConverter;
constructor(
job: Job,
readonly blockMatchers: BlockMarkdownAdapterMatcher[] = defaultBlockMarkdownAdapterMatchers
) {
super(job);
this.deltaConverter = new MarkdownDeltaConverter(
job.adapterConfigs,
inlineDeltaToMarkdownAdapterMatchers,
markdownInlineToDeltaMatchers
);
}
private _astToMarkdown(ast: Root) {
return unified()
.use(remarkGfm)
.use(remarkStringify, {
resourceLink: true,
})
.use(remarkMath)
.stringify(ast)
.replace(/&#x20;\n/g, ' \n');
}
private _markdownToAst(markdown: Markdown) {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMath)
.parse(markdown);
}
async fromBlockSnapshot({
snapshot,
assets,
}: FromBlockSnapshotPayload): Promise<FromBlockSnapshotResult<Markdown>> {
const root: Root = {
type: 'root',
children: [],
};
const { ast, assetsIds } = await this._traverseSnapshot(
snapshot,
root,
assets
);
return {
file: this._astToMarkdown(ast),
assetsIds,
};
}
async fromDocSnapshot({
snapshot,
assets,
}: FromDocSnapshotPayload): Promise<FromDocSnapshotResult<Markdown>> {
let buffer = '';
const { file, assetsIds } = await this.fromBlockSnapshot({
snapshot: snapshot.blocks,
assets,
});
buffer += file;
return {
file: buffer,
assetsIds,
};
}
async fromSliceSnapshot({
snapshot,
assets,
}: FromSliceSnapshotPayload): Promise<FromSliceSnapshotResult<Markdown>> {
let buffer = '';
const sliceAssetsIds: string[] = [];
for (const contentSlice of snapshot.content) {
const root: Root = {
type: 'root',
children: [],
};
const { ast, assetsIds } = await this._traverseSnapshot(
contentSlice,
root,
assets
);
sliceAssetsIds.push(...assetsIds);
buffer += this._astToMarkdown(ast);
}
const markdown =
buffer.match(/\n/g)?.length === 1 ? buffer.trimEnd() : buffer;
return {
file: markdown,
assetsIds: sliceAssetsIds,
};
}
async toBlockSnapshot(
payload: ToBlockSnapshotPayload<Markdown>
): Promise<BlockSnapshot> {
const markdownAst = this._markdownToAst(payload.file);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
return this._traverseMarkdown(
markdownAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets
);
}
async toDocSnapshot(
payload: ToDocSnapshotPayload<Markdown>
): Promise<DocSnapshot> {
const markdownAst = this._markdownToAst(payload.file);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
return {
type: 'page',
meta: {
id: nanoid(),
title: 'Untitled',
createDate: Date.now(),
tags: [],
},
blocks: {
type: 'block',
id: nanoid(),
flavour: 'affine:page',
props: {
title: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'Untitled',
},
],
},
},
children: [
{
type: 'block',
id: nanoid(),
flavour: 'affine:surface',
props: {
elements: {},
},
children: [],
},
await this._traverseMarkdown(
markdownAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets
),
],
},
};
}
async toSliceSnapshot(
payload: MarkdownToSliceSnapshotPayload
): Promise<SliceSnapshot | null> {
let codeFence = '';
payload.file = payload.file
.split('\n')
.map(line => {
if (line.trimStart().startsWith('-')) {
return line;
}
let trimmedLine = line.trimStart();
if (!codeFence && trimmedLine.startsWith('```')) {
codeFence = trimmedLine.substring(
0,
trimmedLine.lastIndexOf('```') + 3
);
if (codeFence.split('').every(c => c === '`')) {
return line;
}
codeFence = '';
}
if (!codeFence && trimmedLine.startsWith('~~~')) {
codeFence = trimmedLine.substring(
0,
trimmedLine.lastIndexOf('~~~') + 3
);
if (codeFence.split('').every(c => c === '~')) {
return line;
}
codeFence = '';
}
if (
!!codeFence &&
trimmedLine.startsWith(codeFence) &&
trimmedLine.lastIndexOf(codeFence) === 0
) {
codeFence = '';
}
if (codeFence) {
return line;
}
trimmedLine = trimmedLine.trimEnd();
if (!trimmedLine.startsWith('<') && !trimmedLine.endsWith('>')) {
// check if it is a url link and wrap it with the angle brackets
// sometimes the url includes emphasis `_` that will break URL parsing
//
// eg. /MuawcBMT1Mzvoar09-_66?mode=page&blockIds=rL2_GXbtLU2SsJVfCSmh_
// https://www.markdownguide.org/basic-syntax/#urls-and-email-addresses
try {
const valid =
URL.canParse?.(trimmedLine) ?? Boolean(new URL(trimmedLine));
if (valid) {
return `<${trimmedLine}>`;
}
} catch (err) {
console.log(err);
}
}
return line.replace(/^ /, '&#x20;');
})
.join('\n');
const markdownAst = this._markdownToAst(payload.file);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
} as BlockSnapshot;
const contentSlice = (await this._traverseMarkdown(
markdownAst,
blockSnapshotRoot,
payload.assets
)) as BlockSnapshot;
if (contentSlice.children.length === 0) {
return null;
}
return {
type: 'slice',
content: [contentSlice],
workspaceId: payload.workspaceId,
pageId: payload.pageId,
};
}
}
export const MarkdownAdapterFactoryIdentifier =
AdapterFactoryIdentifier('Markdown');
export const MarkdownAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(MarkdownAdapterFactoryIdentifier, provider => ({
get: (job: Job) =>
new MarkdownAdapter(
job,
Array.from(
provider.getAll(BlockMarkdownAdapterMatcherIdentifier).values()
)
),
}));
},
};

View File

@@ -0,0 +1,363 @@
import {
DEFAULT_NOTE_BACKGROUND_COLOR,
NoteDisplayMode,
} from '@blocksuite/affine-model';
import type { ExtensionType } from '@blocksuite/block-std';
import type { DeltaInsert } from '@blocksuite/inline';
import {
type AssetsManager,
ASTWalker,
BaseAdapter,
type BlockSnapshot,
BlockSnapshotSchema,
type DocSnapshot,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
type Job,
nanoid,
type SliceSnapshot,
type ToBlockSnapshotPayload,
type ToDocSnapshotPayload,
} from '@blocksuite/store';
import { MarkdownAdapter } from './markdown/index.js';
import { AdapterFactoryIdentifier } from './type.js';
export type MixText = string;
type MixTextToSliceSnapshotPayload = {
file: MixText;
assets?: AssetsManager;
blockVersions: Record<string, number>;
workspaceId: string;
pageId: string;
};
export class MixTextAdapter extends BaseAdapter<MixText> {
private _markdownAdapter: MarkdownAdapter;
constructor(job: Job) {
super(job);
this._markdownAdapter = new MarkdownAdapter(job);
}
private _splitDeltas(deltas: DeltaInsert[]): DeltaInsert[][] {
const result: DeltaInsert[][] = [[]];
const pending: DeltaInsert[] = deltas;
while (pending.length > 0) {
const delta = pending.shift();
if (!delta) {
break;
}
if (delta.insert.includes('\n')) {
const splitIndex = delta.insert.indexOf('\n');
const line = delta.insert.slice(0, splitIndex);
const rest = delta.insert.slice(splitIndex + 1);
result[result.length - 1].push({ ...delta, insert: line });
result.push([]);
if (rest) {
pending.unshift({ ...delta, insert: rest });
}
} else {
result[result.length - 1].push(delta);
}
}
return result;
}
private async _traverseSnapshot(
snapshot: BlockSnapshot
): Promise<{ mixtext: string }> {
let buffer = '';
const walker = new ASTWalker<BlockSnapshot, never>();
walker.setONodeTypeGuard(
(node): node is BlockSnapshot =>
BlockSnapshotSchema.safeParse(node).success
);
walker.setEnter(o => {
const text = (o.node.props.text ?? { delta: [] }) as {
delta: DeltaInsert[];
};
if (buffer.length > 0) {
buffer += '\n';
}
switch (o.node.flavour) {
case 'affine:code': {
buffer += text.delta.map(delta => delta.insert).join('');
break;
}
case 'affine:paragraph': {
buffer += text.delta.map(delta => delta.insert).join('');
break;
}
case 'affine:list': {
buffer += text.delta.map(delta => delta.insert).join('');
break;
}
case 'affine:divider': {
buffer += '---';
break;
}
}
});
await walker.walkONode(snapshot);
return {
mixtext: buffer,
};
}
async fromBlockSnapshot({
snapshot,
}: FromBlockSnapshotPayload): Promise<FromBlockSnapshotResult<MixText>> {
const { mixtext } = await this._traverseSnapshot(snapshot);
return {
file: mixtext,
assetsIds: [],
};
}
async fromDocSnapshot({
snapshot,
assets,
}: FromDocSnapshotPayload): Promise<FromDocSnapshotResult<MixText>> {
let buffer = '';
if (snapshot.meta.title) {
buffer += `${snapshot.meta.title}\n\n`;
}
const { file, assetsIds } = await this.fromBlockSnapshot({
snapshot: snapshot.blocks,
assets,
});
buffer += file;
return {
file: buffer,
assetsIds,
};
}
async fromSliceSnapshot({
snapshot,
}: FromSliceSnapshotPayload): Promise<FromSliceSnapshotResult<MixText>> {
let buffer = '';
const sliceAssetsIds: string[] = [];
for (const contentSlice of snapshot.content) {
const { mixtext } = await this._traverseSnapshot(contentSlice);
buffer += mixtext;
}
const mixtext =
buffer.match(/\n/g)?.length === 1 ? buffer.trimEnd() : buffer;
return {
file: mixtext,
assetsIds: sliceAssetsIds,
};
}
toBlockSnapshot(payload: ToBlockSnapshotPayload<MixText>): BlockSnapshot {
payload.file = payload.file.replaceAll('\r', '');
return {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: payload.file.split('\n').map((line): BlockSnapshot => {
return {
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: line,
},
],
},
},
children: [],
};
}),
};
}
toDocSnapshot(payload: ToDocSnapshotPayload<MixText>): DocSnapshot {
payload.file = payload.file.replaceAll('\r', '');
return {
type: 'page',
meta: {
id: nanoid(),
title: 'Untitled',
createDate: Date.now(),
tags: [],
},
blocks: {
type: 'block',
id: nanoid(),
flavour: 'affine:page',
props: {
title: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'Untitled',
},
],
},
},
children: [
{
type: 'block',
id: nanoid(),
flavour: 'affine:surface',
props: {
elements: {},
},
children: [],
},
{
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: payload.file.split('\n').map((line): BlockSnapshot => {
return {
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: line,
},
],
},
},
children: [],
};
}),
},
],
},
};
}
async toSliceSnapshot(
payload: MixTextToSliceSnapshotPayload
): Promise<SliceSnapshot | null> {
if (payload.file.trim().length === 0) {
return null;
}
payload.file = payload.file.replaceAll('\r', '');
const sliceSnapshot = await this._markdownAdapter.toSliceSnapshot({
file: payload.file,
assets: payload.assets,
workspaceId: payload.workspaceId,
pageId: payload.pageId,
});
if (!sliceSnapshot) {
return null;
}
for (const contentSlice of sliceSnapshot.content) {
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
} as BlockSnapshot;
const walker = new ASTWalker<BlockSnapshot, BlockSnapshot>();
walker.setONodeTypeGuard(
(node): node is BlockSnapshot =>
BlockSnapshotSchema.safeParse(node).success
);
walker.setEnter((o, context) => {
switch (o.node.flavour) {
case 'affine:note': {
break;
}
case 'affine:paragraph': {
if (o.parent?.node.flavour !== 'affine:note') {
context.openNode({ ...o.node, children: [] });
break;
}
const text = (o.node.props.text ?? { delta: [] }) as {
delta: DeltaInsert[];
};
const newDeltas = this._splitDeltas(text.delta);
for (const [i, delta] of newDeltas.entries()) {
context.openNode({
...o.node,
id: i === 0 ? o.node.id : nanoid(),
props: {
...o.node.props,
text: {
'$blocksuite:internal:text$': true,
delta,
},
},
children: [],
});
if (i < newDeltas.length - 1) {
context.closeNode();
}
}
break;
}
default: {
context.openNode({ ...o.node, children: [] });
}
}
});
walker.setLeave((o, context) => {
switch (o.node.flavour) {
case 'affine:note': {
break;
}
default: {
context.closeNode();
}
}
});
await walker.walk(contentSlice, blockSnapshotRoot);
contentSlice.children = blockSnapshotRoot.children;
}
return sliceSnapshot;
}
}
export const MixTextAdapterFactoryIdentifier =
AdapterFactoryIdentifier('MixText');
export const MixTextAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(MixTextAdapterFactoryIdentifier, () => ({
get: (job: Job) => new MixTextAdapter(job),
}));
},
};

View File

@@ -0,0 +1,70 @@
import {
ListBlockNotionHtmlAdapterExtension,
listBlockNotionHtmlAdapterMatcher,
} from '@blocksuite/affine-block-list';
import {
ParagraphBlockNotionHtmlAdapterExtension,
paragraphBlockNotionHtmlAdapterMatcher,
} from '@blocksuite/affine-block-paragraph';
import type { BlockNotionHtmlAdapterMatcher } from '@blocksuite/affine-shared/adapters';
import type { ExtensionType } from '@blocksuite/block-std';
import {
AttachmentBlockNotionHtmlAdapterExtension,
attachmentBlockNotionHtmlAdapterMatcher,
} from '../../../attachment-block/adapters/notion-html.js';
import {
BookmarkBlockNotionHtmlAdapterExtension,
bookmarkBlockNotionHtmlAdapterMatcher,
} from '../../../bookmark-block/adapters/notion-html.js';
import {
CodeBlockNotionHtmlAdapterExtension,
codeBlockNotionHtmlAdapterMatcher,
} from '../../../code-block/adapters/notion-html.js';
import {
DatabaseBlockNotionHtmlAdapterExtension,
databaseBlockNotionHtmlAdapterMatcher,
} from '../../../database-block/adapters/notion-html.js';
import {
DividerBlockNotionHtmlAdapterExtension,
dividerBlockNotionHtmlAdapterMatcher,
} from '../../../divider-block/adapters/notion-html.js';
import {
ImageBlockNotionHtmlAdapterExtension,
imageBlockNotionHtmlAdapterMatcher,
} from '../../../image-block/adapters/notion-html.js';
import {
LatexBlockNotionHtmlAdapterExtension,
latexBlockNotionHtmlAdapterMatcher,
} from '../../../latex-block/adapters/notion-html.js';
import {
RootBlockNotionHtmlAdapterExtension,
rootBlockNotionHtmlAdapterMatcher,
} from '../../../root-block/adapters/notion-html.js';
export const defaultBlockNotionHtmlAdapterMatchers: BlockNotionHtmlAdapterMatcher[] =
[
listBlockNotionHtmlAdapterMatcher,
paragraphBlockNotionHtmlAdapterMatcher,
codeBlockNotionHtmlAdapterMatcher,
dividerBlockNotionHtmlAdapterMatcher,
imageBlockNotionHtmlAdapterMatcher,
rootBlockNotionHtmlAdapterMatcher,
bookmarkBlockNotionHtmlAdapterMatcher,
databaseBlockNotionHtmlAdapterMatcher,
attachmentBlockNotionHtmlAdapterMatcher,
latexBlockNotionHtmlAdapterMatcher,
];
export const BlockNotionHtmlAdapterExtensions: ExtensionType[] = [
ListBlockNotionHtmlAdapterExtension,
ParagraphBlockNotionHtmlAdapterExtension,
CodeBlockNotionHtmlAdapterExtension,
DividerBlockNotionHtmlAdapterExtension,
ImageBlockNotionHtmlAdapterExtension,
RootBlockNotionHtmlAdapterExtension,
BookmarkBlockNotionHtmlAdapterExtension,
DatabaseBlockNotionHtmlAdapterExtension,
AttachmentBlockNotionHtmlAdapterExtension,
LatexBlockNotionHtmlAdapterExtension,
];

View File

@@ -0,0 +1,296 @@
import {
HastUtils,
type HtmlAST,
type NotionHtmlASTToDeltaMatcher,
} from '@blocksuite/affine-shared/adapters';
import { collapseWhiteSpace } from 'collapse-white-space';
import type { Element, Text } from 'hast';
const isElement = (ast: HtmlAST): ast is Element => {
return ast.type === 'element';
};
const isText = (ast: HtmlAST): ast is Text => {
return ast.type === 'text';
};
const listElementTags = new Set(['ol', 'ul']);
const strongElementTags = new Set(['strong', 'b']);
const italicElementTags = new Set(['i', 'em']);
const NotionInlineEquationToken = 'notion-text-equation-token';
const NotionUnderlineStyleToken = 'border-bottom:0.05em solid';
export const notionHtmlTextToDeltaMatcher: NotionHtmlASTToDeltaMatcher = {
name: 'text',
match: ast => isText(ast),
toDelta: (ast, context) => {
if (!isText(ast)) {
return [];
}
const { options } = context;
options.trim ??= true;
if (options.pre || ast.value === ' ') {
return [{ insert: ast.value }];
}
if (options.trim) {
const value = collapseWhiteSpace(ast.value, { trim: options.trim });
if (value) {
return [{ insert: value }];
}
return [];
}
if (ast.value) {
return [{ insert: collapseWhiteSpace(ast.value) }];
}
return [];
},
};
export const notionHtmlSpanElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'span-element',
match: ast => isElement(ast) && ast.tagName === 'span',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
if (
Array.isArray(ast.properties?.className) &&
ast.properties?.className.includes(NotionInlineEquationToken)
) {
const latex = HastUtils.getTextContent(
HastUtils.querySelector(ast, 'annotation')
);
return [{ insert: ' ', attributes: { latex } }];
}
// Add underline style detection
if (
typeof ast.properties?.style === 'string' &&
ast.properties?.style?.includes(NotionUnderlineStyleToken)
) {
return ast.children.flatMap(child =>
context.toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, underline: true };
return delta;
})
);
}
return ast.children.flatMap(child => toDelta(child, options));
},
};
export const notionHtmlListToDeltaMatcher: NotionHtmlASTToDeltaMatcher = {
name: 'list-element',
match: ast => isElement(ast) && listElementTags.has(ast.tagName),
toDelta: () => {
return [];
},
};
export const notionHtmlStrongElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'strong-element',
match: ast => isElement(ast) && strongElementTags.has(ast.tagName),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, bold: true };
return delta;
})
);
},
};
export const notionHtmlItalicElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'italic-element',
match: ast => isElement(ast) && italicElementTags.has(ast.tagName),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, italic: true };
return delta;
})
);
},
};
export const notionHtmlCodeElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'code-element',
match: ast => isElement(ast) && ast.tagName === 'code',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, code: true };
return delta;
})
);
},
};
export const notionHtmlDelElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = {
name: 'del-element',
match: ast => isElement(ast) && ast.tagName === 'del',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, strike: true };
return delta;
})
);
},
};
export const notionHtmlUnderlineElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'underline-element',
match: ast => isElement(ast) && ast.tagName === 'u',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, underline: true };
return delta;
})
);
},
};
export const notionHtmlLinkElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'link-element',
match: ast => isElement(ast) && ast.tagName === 'a',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const href = ast.properties?.href;
if (typeof href !== 'string') {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
if (options.pageMap) {
const pageId = options.pageMap.get(decodeURIComponent(href));
if (pageId) {
delta.attributes = {
...delta.attributes,
reference: {
type: 'LinkedPage',
pageId,
},
};
delta.insert = ' ';
return delta;
}
}
if (href.startsWith('http')) {
delta.attributes = {
...delta.attributes,
link: href,
};
return delta;
}
return delta;
})
);
},
};
export const notionHtmlMarkElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'mark-element',
match: ast => isElement(ast) && ast.tagName === 'mark',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes };
return delta;
})
);
},
};
export const notionHtmlLiElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = {
name: 'li-element',
match: ast =>
isElement(ast) &&
ast.tagName === 'li' &&
!!HastUtils.querySelector(ast, '.checkbox'),
toDelta: (ast, context) => {
if (!isElement(ast) || !HastUtils.querySelector(ast, '.checkbox')) {
return [];
}
const { toDelta, options } = context;
// Should ignore the children of to do list which is the checkbox and the space following it
const checkBox = HastUtils.querySelector(ast, '.checkbox');
const checkBoxIndex = ast.children.findIndex(child => child === checkBox);
return ast.children
.slice(checkBoxIndex + 2)
.flatMap(child => toDelta(child, options));
},
};
export const notionHtmlBrElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = {
name: 'br-element',
match: ast => isElement(ast) && ast.tagName === 'br',
toDelta: () => {
return [{ insert: '\n' }];
},
};
export const notionHtmlStyleElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'style-element',
match: ast => isElement(ast) && ast.tagName === 'style',
toDelta: () => {
return [];
},
};
export const notionHtmlInlineToDeltaMatchers: NotionHtmlASTToDeltaMatcher[] = [
notionHtmlTextToDeltaMatcher,
notionHtmlSpanElementToDeltaMatcher,
notionHtmlStrongElementToDeltaMatcher,
notionHtmlItalicElementToDeltaMatcher,
notionHtmlCodeElementToDeltaMatcher,
notionHtmlDelElementToDeltaMatcher,
notionHtmlUnderlineElementToDeltaMatcher,
notionHtmlLinkElementToDeltaMatcher,
notionHtmlMarkElementToDeltaMatcher,
notionHtmlListToDeltaMatcher,
notionHtmlLiElementToDeltaMatcher,
notionHtmlBrElementToDeltaMatcher,
notionHtmlStyleElementToDeltaMatcher,
];

View File

@@ -0,0 +1,9 @@
export {
BlockNotionHtmlAdapterExtensions,
defaultBlockNotionHtmlAdapterMatchers,
} from './block-matcher.js';
export {
NotionHtmlAdapter,
NotionHtmlAdapterFactoryExtension,
NotionHtmlAdapterFactoryIdentifier,
} from './notion-html.js';

View File

@@ -0,0 +1,299 @@
import {
DEFAULT_NOTE_BACKGROUND_COLOR,
NoteDisplayMode,
} from '@blocksuite/affine-model';
import {
type AdapterContext,
type BlockNotionHtmlAdapterMatcher,
BlockNotionHtmlAdapterMatcherIdentifier,
HastUtils,
type HtmlAST,
type NotionHtml,
NotionHtmlDeltaConverter,
} from '@blocksuite/affine-shared/adapters';
import type { ExtensionType } from '@blocksuite/block-std';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import {
type AssetsManager,
ASTWalker,
BaseAdapter,
type BlockSnapshot,
type DocSnapshot,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
type Job,
nanoid,
type SliceSnapshot,
} from '@blocksuite/store';
import rehypeParse from 'rehype-parse';
import { unified } from 'unified';
import { AdapterFactoryIdentifier } from '../type.js';
import { defaultBlockNotionHtmlAdapterMatchers } from './block-matcher.js';
import { notionHtmlInlineToDeltaMatchers } from './delta-converter/html-inline.js';
type NotionHtmlToSliceSnapshotPayload = {
file: NotionHtml;
assets?: AssetsManager;
blockVersions: Record<string, number>;
workspaceId: string;
pageId: string;
};
type NotionHtmlToDocSnapshotPayload = {
file: NotionHtml;
assets?: AssetsManager;
pageId?: string;
pageMap?: Map<string, string>;
};
type NotionHtmlToBlockSnapshotPayload = NotionHtmlToDocSnapshotPayload;
export class NotionHtmlAdapter extends BaseAdapter<NotionHtml> {
private _traverseNotionHtml = async (
html: HtmlAST,
snapshot: BlockSnapshot,
assets?: AssetsManager,
pageMap?: Map<string, string>
) => {
const walker = new ASTWalker<HtmlAST, BlockSnapshot>();
walker.setONodeTypeGuard(
(node): node is HtmlAST =>
'type' in (node as object) && (node as HtmlAST).type !== undefined
);
walker.setEnter(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.toMatch(o)) {
const adapterContext: AdapterContext<
HtmlAST,
BlockSnapshot,
NotionHtmlDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
pageMap,
};
await matcher.toBlockSnapshot.enter?.(o, adapterContext);
}
}
});
walker.setLeave(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.toMatch(o)) {
const adapterContext: AdapterContext<
HtmlAST,
BlockSnapshot,
NotionHtmlDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
pageMap,
};
await matcher.toBlockSnapshot.leave?.(o, adapterContext);
}
}
});
return walker.walk(html, snapshot);
};
deltaConverter: NotionHtmlDeltaConverter;
constructor(
job: Job,
readonly blockMatchers: BlockNotionHtmlAdapterMatcher[] = defaultBlockNotionHtmlAdapterMatchers
) {
super(job);
this.deltaConverter = new NotionHtmlDeltaConverter(
job.adapterConfigs,
[],
notionHtmlInlineToDeltaMatchers
);
}
private _htmlToAst(notionHtml: NotionHtml) {
return unified().use(rehypeParse).parse(notionHtml);
}
override fromBlockSnapshot(
_payload: FromBlockSnapshotPayload
): Promise<FromBlockSnapshotResult<NotionHtml>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionHtmlAdapter.fromBlockSnapshot is not implemented'
);
}
override fromDocSnapshot(
_payload: FromDocSnapshotPayload
): Promise<FromDocSnapshotResult<NotionHtml>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionHtmlAdapter.fromDocSnapshot is not implemented'
);
}
override fromSliceSnapshot(
_payload: FromSliceSnapshotPayload
): Promise<FromSliceSnapshotResult<NotionHtml>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionHtmlAdapter.fromSliceSnapshot is not implemented'
);
}
override toBlockSnapshot(
payload: NotionHtmlToBlockSnapshotPayload
): Promise<BlockSnapshot> {
const notionHtmlAst = this._htmlToAst(payload.file);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
return this._traverseNotionHtml(
notionHtmlAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets,
payload.pageMap
);
}
override async toDoc(payload: NotionHtmlToDocSnapshotPayload) {
const snapshot = await this.toDocSnapshot(payload);
return this.job.snapshotToDoc(snapshot);
}
override async toDocSnapshot(
payload: NotionHtmlToDocSnapshotPayload
): Promise<DocSnapshot> {
const notionHtmlAst = this._htmlToAst(payload.file);
const titleAst = HastUtils.querySelector(notionHtmlAst, 'title');
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
return {
type: 'page',
meta: {
id: payload.pageId ?? nanoid(),
title: HastUtils.getTextContent(titleAst, ''),
createDate: Date.now(),
tags: [],
},
blocks: {
type: 'block',
id: nanoid(),
flavour: 'affine:page',
props: {
title: {
'$blocksuite:internal:text$': true,
delta: this.deltaConverter.astToDelta(
titleAst ?? {
type: 'text',
value: '',
}
),
},
},
children: [
{
type: 'block',
id: nanoid(),
flavour: 'affine:surface',
props: {
elements: {},
},
children: [],
},
await this._traverseNotionHtml(
notionHtmlAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets,
payload.pageMap
),
],
},
};
}
override async toSliceSnapshot(
payload: NotionHtmlToSliceSnapshotPayload
): Promise<SliceSnapshot | null> {
const notionHtmlAst = this._htmlToAst(payload.file);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
const contentSlice = (await this._traverseNotionHtml(
notionHtmlAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets
)) as BlockSnapshot;
if (contentSlice.children.length === 0) {
return null;
}
return {
type: 'slice',
content: [contentSlice],
workspaceId: payload.workspaceId,
pageId: payload.pageId,
};
}
}
export const NotionHtmlAdapterFactoryIdentifier =
AdapterFactoryIdentifier('NotionHtml');
export const NotionHtmlAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(NotionHtmlAdapterFactoryIdentifier, provider => ({
get: (job: Job) =>
new NotionHtmlAdapter(
job,
Array.from(
provider.getAll(BlockNotionHtmlAdapterMatcherIdentifier).values()
)
),
}));
},
};

View File

@@ -0,0 +1,170 @@
import { DEFAULT_NOTE_BACKGROUND_COLOR } from '@blocksuite/affine-model';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { ExtensionType } from '@blocksuite/block-std';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { DeltaInsert } from '@blocksuite/inline';
import {
type AssetsManager,
BaseAdapter,
type BlockSnapshot,
type DocSnapshot,
type FromBlockSnapshotResult,
type FromDocSnapshotResult,
type FromSliceSnapshotResult,
type Job,
nanoid,
type SliceSnapshot,
} from '@blocksuite/store';
import { AdapterFactoryIdentifier } from './type.js';
type NotionEditingStyle = {
0: string;
};
type NotionEditing = {
0: string;
1: Array<NotionEditingStyle>;
};
export type NotionTextSerialized = {
blockType: string;
editing: Array<NotionEditing>;
};
export type NotionText = string;
type NotionHtmlToSliceSnapshotPayload = {
file: NotionText;
assets?: AssetsManager;
workspaceId: string;
pageId: string;
};
export class NotionTextAdapter extends BaseAdapter<NotionText> {
override fromBlockSnapshot():
| FromBlockSnapshotResult<NotionText>
| Promise<FromBlockSnapshotResult<NotionText>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionTextAdapter.fromBlockSnapshot is not implemented.'
);
}
override fromDocSnapshot():
| FromDocSnapshotResult<NotionText>
| Promise<FromDocSnapshotResult<NotionText>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionTextAdapter.fromDocSnapshot is not implemented.'
);
}
override fromSliceSnapshot():
| FromSliceSnapshotResult<NotionText>
| Promise<FromSliceSnapshotResult<NotionText>> {
return {
file: JSON.stringify({
blockType: 'text',
editing: [
['Notion Text is not supported to be exported from BlockSuite', []],
],
}),
assetsIds: [],
};
}
override toBlockSnapshot(): Promise<BlockSnapshot> | BlockSnapshot {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionTextAdapter.toBlockSnapshot is not implemented.'
);
}
override toDocSnapshot(): Promise<DocSnapshot> | DocSnapshot {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionTextAdapter.toDocSnapshot is not implemented.'
);
}
override toSliceSnapshot(
payload: NotionHtmlToSliceSnapshotPayload
): SliceSnapshot | null {
const notionText = JSON.parse(payload.file) as NotionTextSerialized;
const content: SliceSnapshot['content'] = [];
const deltas: DeltaInsert<AffineTextAttributes>[] = [];
for (const editing of notionText.editing) {
const delta: DeltaInsert<AffineTextAttributes> = {
insert: editing[0],
attributes: Object.create(null),
};
for (const styleElement of editing[1]) {
switch (styleElement[0]) {
case 'b':
delta.attributes!.bold = true;
break;
case 'i':
delta.attributes!.italic = true;
break;
case '_':
delta.attributes!.underline = true;
break;
case 'c':
delta.attributes!.code = true;
break;
case 's':
delta.attributes!.strike = true;
break;
}
}
deltas.push(delta);
}
content.push({
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: 'both',
},
children: [
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: deltas,
},
},
children: [],
},
],
});
return {
type: 'slice',
content,
workspaceId: payload.workspaceId,
pageId: payload.pageId,
};
}
}
export const NotionTextAdapterFactoryIdentifier =
AdapterFactoryIdentifier('NotionText');
export const NotionTextAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(NotionTextAdapterFactoryIdentifier, () => ({
get: (job: Job) => new NotionTextAdapter(job),
}));
},
};

View File

@@ -0,0 +1,72 @@
import {
EmbedFigmaBlockPlainTextAdapterExtension,
embedFigmaBlockPlainTextAdapterMatcher,
EmbedGithubBlockPlainTextAdapterExtension,
embedGithubBlockPlainTextAdapterMatcher,
EmbedLinkedDocBlockPlainTextAdapterExtension,
embedLinkedDocBlockPlainTextAdapterMatcher,
EmbedLoomBlockPlainTextAdapterExtension,
embedLoomBlockPlainTextAdapterMatcher,
EmbedSyncedDocBlockPlainTextAdapterExtension,
embedSyncedDocBlockPlainTextAdapterMatcher,
EmbedYoutubeBlockPlainTextAdapterExtension,
embedYoutubeBlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-block-embed';
import {
ListBlockPlainTextAdapterExtension,
listBlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-block-list';
import {
ParagraphBlockPlainTextAdapterExtension,
paragraphBlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-block-paragraph';
import type { BlockPlainTextAdapterMatcher } from '@blocksuite/affine-shared/adapters';
import type { ExtensionType } from '@blocksuite/block-std';
import {
BookmarkBlockPlainTextAdapterExtension,
bookmarkBlockPlainTextAdapterMatcher,
} from '../../../bookmark-block/adapters/plain-text.js';
import {
CodeBlockPlainTextAdapterExtension,
codeBlockPlainTextAdapterMatcher,
} from '../../../code-block/adapters/plain-text.js';
import {
DividerBlockPlainTextAdapterExtension,
dividerBlockPlainTextAdapterMatcher,
} from '../../../divider-block/adapters/plain-text.js';
import {
LatexBlockPlainTextAdapterExtension,
latexBlockPlainTextAdapterMatcher,
} from '../../../latex-block/adapters/plain-text.js';
export const defaultBlockPlainTextAdapterMatchers: BlockPlainTextAdapterMatcher[] =
[
paragraphBlockPlainTextAdapterMatcher,
listBlockPlainTextAdapterMatcher,
dividerBlockPlainTextAdapterMatcher,
codeBlockPlainTextAdapterMatcher,
bookmarkBlockPlainTextAdapterMatcher,
embedFigmaBlockPlainTextAdapterMatcher,
embedGithubBlockPlainTextAdapterMatcher,
embedLoomBlockPlainTextAdapterMatcher,
embedYoutubeBlockPlainTextAdapterMatcher,
embedLinkedDocBlockPlainTextAdapterMatcher,
embedSyncedDocBlockPlainTextAdapterMatcher,
latexBlockPlainTextAdapterMatcher,
];
export const BlockPlainTextAdapterExtensions: ExtensionType[] = [
ParagraphBlockPlainTextAdapterExtension,
ListBlockPlainTextAdapterExtension,
DividerBlockPlainTextAdapterExtension,
CodeBlockPlainTextAdapterExtension,
BookmarkBlockPlainTextAdapterExtension,
EmbedFigmaBlockPlainTextAdapterExtension,
EmbedGithubBlockPlainTextAdapterExtension,
EmbedLoomBlockPlainTextAdapterExtension,
EmbedYoutubeBlockPlainTextAdapterExtension,
EmbedLinkedDocBlockPlainTextAdapterExtension,
EmbedSyncedDocBlockPlainTextAdapterExtension,
LatexBlockPlainTextAdapterExtension,
];

View File

@@ -0,0 +1,78 @@
import { generateDocUrl } from '@blocksuite/affine-block-embed';
import type {
InlineDeltaToPlainTextAdapterMatcher,
TextBuffer,
} from '@blocksuite/affine-shared/adapters';
export const referenceDeltaMarkdownAdapterMatch: InlineDeltaToPlainTextAdapterMatcher =
{
name: 'reference',
match: delta => !!delta.attributes?.reference,
toAST: (delta, context) => {
const node: TextBuffer = {
content: delta.insert,
};
const reference = delta.attributes?.reference;
if (!reference) {
return node;
}
const { configs } = context;
const title = configs.get(`title:${reference.pageId}`) ?? '';
const url = generateDocUrl(
configs.get('docLinkBaseUrl') ?? '',
String(reference.pageId),
reference.params ?? Object.create(null)
);
const content = `${title ? `${title}: ` : ''}${url}`;
return {
content,
};
},
};
export const linkDeltaMarkdownAdapterMatch: InlineDeltaToPlainTextAdapterMatcher =
{
name: 'link',
match: delta => !!delta.attributes?.link,
toAST: delta => {
const linkText = delta.insert;
const node: TextBuffer = {
content: linkText,
};
const link = delta.attributes?.link;
if (!link) {
return node;
}
const content = `${linkText ? `${linkText}: ` : ''}${link}`;
return {
content,
};
},
};
export const latexDeltaMarkdownAdapterMatch: InlineDeltaToPlainTextAdapterMatcher =
{
name: 'inlineLatex',
match: delta => !!delta.attributes?.latex,
toAST: delta => {
const node: TextBuffer = {
content: delta.insert,
};
if (!delta.attributes?.latex) {
return node;
}
return {
content: delta.attributes?.latex,
};
},
};
export const inlineDeltaToPlainTextAdapterMatchers: InlineDeltaToPlainTextAdapterMatcher[] =
[
referenceDeltaMarkdownAdapterMatch,
linkDeltaMarkdownAdapterMatch,
latexDeltaMarkdownAdapterMatch,
];

View File

@@ -0,0 +1,321 @@
import {
DEFAULT_NOTE_BACKGROUND_COLOR,
NoteDisplayMode,
} from '@blocksuite/affine-model';
import {
type AdapterContext,
type BlockPlainTextAdapterMatcher,
BlockPlainTextAdapterMatcherIdentifier,
type PlainText,
PlainTextDeltaConverter,
type TextBuffer,
} from '@blocksuite/affine-shared/adapters';
import type { ExtensionType } from '@blocksuite/block-std';
import {
type AssetsManager,
ASTWalker,
BaseAdapter,
type BlockSnapshot,
BlockSnapshotSchema,
type DocSnapshot,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
type Job,
nanoid,
type SliceSnapshot,
type ToBlockSnapshotPayload,
type ToDocSnapshotPayload,
} from '@blocksuite/store';
import { AdapterFactoryIdentifier } from '../type.js';
import { defaultBlockPlainTextAdapterMatchers } from './block-matcher.js';
import { inlineDeltaToPlainTextAdapterMatchers } from './delta-converter/inline-delta.js';
type PlainTextToSliceSnapshotPayload = {
file: PlainText;
assets?: AssetsManager;
blockVersions: Record<string, number>;
workspaceId: string;
pageId: string;
};
export class PlainTextAdapter extends BaseAdapter<PlainText> {
deltaConverter: PlainTextDeltaConverter;
constructor(
job: Job,
readonly blockMatchers: BlockPlainTextAdapterMatcher[] = defaultBlockPlainTextAdapterMatchers
) {
super(job);
this.deltaConverter = new PlainTextDeltaConverter(
job.adapterConfigs,
inlineDeltaToPlainTextAdapterMatchers,
[]
);
}
private async _traverseSnapshot(
snapshot: BlockSnapshot
): Promise<{ plaintext: string }> {
const textBuffer: TextBuffer = {
content: '',
};
const walker = new ASTWalker<BlockSnapshot, TextBuffer>();
walker.setONodeTypeGuard(
(node): node is BlockSnapshot =>
BlockSnapshotSchema.safeParse(node).success
);
walker.setEnter(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.fromMatch(o)) {
const adapterContext: AdapterContext<BlockSnapshot, TextBuffer> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer,
};
await matcher.fromBlockSnapshot.enter?.(o, adapterContext);
}
}
});
walker.setLeave(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.fromMatch(o)) {
const adapterContext: AdapterContext<BlockSnapshot, TextBuffer> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer,
};
await matcher.fromBlockSnapshot.leave?.(o, adapterContext);
}
}
});
await walker.walkONode(snapshot);
return {
plaintext: textBuffer.content,
};
}
async fromBlockSnapshot({
snapshot,
}: FromBlockSnapshotPayload): Promise<FromBlockSnapshotResult<PlainText>> {
const { plaintext } = await this._traverseSnapshot(snapshot);
return {
file: plaintext,
assetsIds: [],
};
}
async fromDocSnapshot({
snapshot,
assets,
}: FromDocSnapshotPayload): Promise<FromDocSnapshotResult<PlainText>> {
let buffer = '';
if (snapshot.meta.title) {
buffer += `${snapshot.meta.title}\n\n`;
}
const { file, assetsIds } = await this.fromBlockSnapshot({
snapshot: snapshot.blocks,
assets,
});
buffer += file;
return {
file: buffer,
assetsIds,
};
}
async fromSliceSnapshot({
snapshot,
}: FromSliceSnapshotPayload): Promise<FromSliceSnapshotResult<PlainText>> {
let buffer = '';
const sliceAssetsIds: string[] = [];
for (const contentSlice of snapshot.content) {
const { plaintext } = await this._traverseSnapshot(contentSlice);
buffer += plaintext;
}
const plaintext =
buffer.match(/\n/g)?.length === 1 ? buffer.trimEnd() : buffer;
return {
file: plaintext,
assetsIds: sliceAssetsIds,
};
}
toBlockSnapshot(payload: ToBlockSnapshotPayload<PlainText>): BlockSnapshot {
payload.file = payload.file.replaceAll('\r', '');
return {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: payload.file.split('\n').map((line): BlockSnapshot => {
return {
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: line,
},
],
},
},
children: [],
};
}),
};
}
toDocSnapshot(payload: ToDocSnapshotPayload<PlainText>): DocSnapshot {
payload.file = payload.file.replaceAll('\r', '');
return {
type: 'page',
meta: {
id: nanoid(),
title: 'Untitled',
createDate: Date.now(),
tags: [],
},
blocks: {
type: 'block',
id: nanoid(),
flavour: 'affine:page',
props: {
title: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'Untitled',
},
],
},
},
children: [
{
type: 'block',
id: nanoid(),
flavour: 'affine:surface',
props: {
elements: {},
},
children: [],
},
{
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: payload.file.split('\n').map((line): BlockSnapshot => {
return {
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: line,
},
],
},
},
children: [],
};
}),
},
],
},
};
}
toSliceSnapshot(
payload: PlainTextToSliceSnapshotPayload
): SliceSnapshot | null {
if (payload.file.trim().length === 0) {
return null;
}
payload.file = payload.file.replaceAll('\r', '');
const contentSlice = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: payload.file.split('\n').map((line): BlockSnapshot => {
return {
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: line,
},
],
},
},
children: [],
};
}),
} as BlockSnapshot;
return {
type: 'slice',
content: [contentSlice],
workspaceId: payload.workspaceId,
pageId: payload.pageId,
};
}
}
export const PlainTextAdapterFactoryIdentifier =
AdapterFactoryIdentifier('PlainText');
export const PlainTextAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(PlainTextAdapterFactoryIdentifier, provider => ({
get: (job: Job) =>
new PlainTextAdapter(
job,
Array.from(
provider.getAll(BlockPlainTextAdapterMatcherIdentifier).values()
)
),
}));
},
};

View File

@@ -0,0 +1,10 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { BaseAdapter, Job } from '@blocksuite/store';
export type AdapterFactory = {
// TODO(@chen): Make it return the specific adapter type
get: (job: Job) => BaseAdapter;
};
export const AdapterFactoryIdentifier =
createIdentifier<AdapterFactory>('AdapterFactory');

View File

@@ -0,0 +1,150 @@
import { createLitPortal } from '@blocksuite/affine-components/portal';
import {
EditorHost,
PropTypes,
requiredProperties,
} from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { flip, offset } from '@floating-ui/dom';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { AIItem } from './ai-item.js';
import {
SUBMENU_OFFSET_CROSS_AXIS,
SUBMENU_OFFSET_MAIN_AXIS,
} from './const.js';
import type { AIItemConfig, AIItemGroupConfig } from './types.js';
@requiredProperties({ host: PropTypes.instanceOf(EditorHost) })
export class AIItemList extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
user-select: none;
}
.group-name {
display: flex;
padding: 4px calc(var(--item-padding, 8px) + 4px);
align-items: center;
color: var(--affine-text-secondary-color);
text-align: justify;
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 500;
line-height: 20px;
width: 100%;
box-sizing: border-box;
}
`;
private _abortController: AbortController | null = null;
private _activeSubMenuItem: AIItemConfig | null = null;
private _closeSubMenu = () => {
if (this._abortController) {
this._abortController.abort();
this._abortController = null;
}
this._activeSubMenuItem = null;
};
private _itemClassName = (item: AIItemConfig) => {
return 'ai-item-' + item.name.split(' ').join('-').toLocaleLowerCase();
};
private _openSubMenu = (item: AIItemConfig) => {
if (!item.subItem || item.subItem.length === 0) {
this._closeSubMenu();
return;
}
if (item === this._activeSubMenuItem) {
return;
}
const aiItem = this.shadowRoot?.querySelector(
`.${this._itemClassName(item)}`
) as AIItem | null;
if (!aiItem || !aiItem.menuItem) return;
this._closeSubMenu();
this._activeSubMenuItem = item;
this._abortController = new AbortController();
this._abortController.signal.addEventListener('abort', () => {
this._closeSubMenu();
});
const aiItemContainer = aiItem.menuItem;
const subMenuOffset = {
mainAxis: item.subItemOffset?.[0] ?? SUBMENU_OFFSET_MAIN_AXIS,
crossAxis: item.subItemOffset?.[1] ?? SUBMENU_OFFSET_CROSS_AXIS,
};
createLitPortal({
template: html`<ai-sub-item-list
.item=${item}
.host=${this.host}
.onClick=${this.onClick}
.abortController=${this._abortController}
></ai-sub-item-list>`,
container: aiItemContainer,
positionStrategy: 'fixed',
computePosition: {
referenceElement: aiItemContainer,
placement: 'right-start',
middleware: [flip(), offset(subMenuOffset)],
autoUpdate: true,
},
abortController: this._abortController,
closeOnClickAway: true,
});
};
override render() {
return html`${repeat(this.groups, group => {
return html`
${group.name
? html`<div class="group-name">
${group.name.toLocaleUpperCase()}
</div>`
: nothing}
${repeat(
group.items,
item =>
html`<ai-item
.onClick=${this.onClick}
.item=${item}
.host=${this.host}
class=${this._itemClassName(item)}
@mouseover=${() => {
this._openSubMenu(item);
}}
></ai-item>`
)}
`;
})}`;
}
@property({ attribute: false })
accessor groups: AIItemGroupConfig[] = [];
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor onClick: (() => void) | undefined = undefined;
}
declare global {
interface HTMLElementTagNameMap {
'ai-item-list': AIItemList;
}
}

View File

@@ -0,0 +1,66 @@
import { ArrowRightIcon, EnterIcon } from '@blocksuite/affine-components/icons';
import {
EditorHost,
PropTypes,
requiredProperties,
} from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { menuItemStyles } from './styles.js';
import type { AIItemConfig } from './types.js';
@requiredProperties({
host: PropTypes.instanceOf(EditorHost),
item: PropTypes.object,
})
export class AIItem extends WithDisposable(LitElement) {
static override styles = css`
${menuItemStyles}
`;
override render() {
const { item } = this;
const className = item.name.split(' ').join('-').toLocaleLowerCase();
return html`<div
class="menu-item ${className}"
@pointerdown=${(e: MouseEvent) => e.stopPropagation()}
@click=${() => {
this.onClick?.();
if (typeof item.handler === 'function') {
item.handler(this.host);
}
}}
>
<span class="item-icon">${item.icon}</span>
<div class="item-name">
${item.name}${item.beta
? html`<div class="item-beta">(Beta)</div>`
: nothing}
</div>
${item.subItem
? html`<span class="arrow-right-icon">${ArrowRightIcon}</span>`
: html`<span class="enter-icon">${EnterIcon}</span>`}
</div>`;
}
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor item!: AIItemConfig;
@query('.menu-item')
accessor menuItem: HTMLDivElement | null = null;
@property({ attribute: false })
accessor onClick: (() => void) | undefined;
}
declare global {
interface HTMLElementTagNameMap {
'ai-item': AIItem;
}
}

View File

@@ -0,0 +1,90 @@
import { EnterIcon } from '@blocksuite/affine-components/icons';
import {
EditorHost,
PropTypes,
requiredProperties,
} from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { menuItemStyles } from './styles.js';
import type { AIItemConfig, AISubItemConfig } from './types.js';
@requiredProperties({
host: PropTypes.instanceOf(EditorHost),
item: PropTypes.object,
})
export class AISubItemList extends WithDisposable(LitElement) {
static override styles = css`
.ai-sub-menu {
display: flex;
flex-direction: column;
box-sizing: border-box;
padding: 8px;
min-width: 240px;
max-height: 320px;
overflow-y: auto;
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
border-radius: 8px;
z-index: var(--affine-z-index-popover);
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
color: var(--affine-text-primary-color);
text-align: justify;
font-feature-settings:
'clig' off,
'liga' off;
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
line-height: 22px;
user-select: none;
}
${menuItemStyles}
`;
private _handleClick = (subItem: AISubItemConfig) => {
this.onClick?.();
if (subItem.handler) {
// TODO: add parameters to ai handler
subItem.handler(this.host);
}
this.abortController.abort();
};
override render() {
if (!this.item.subItem || this.item.subItem.length <= 0) return nothing;
return html`<div class="ai-sub-menu">
${this.item.subItem?.map(
subItem =>
html`<div
class="menu-item"
@click=${() => this._handleClick(subItem)}
>
<div class="item-name">${subItem.type}</div>
<span class="enter-icon">${EnterIcon}</span>
</div>`
)}
</div>`;
}
@property({ attribute: false })
accessor abortController: AbortController = new AbortController();
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor item!: AIItemConfig;
@property({ attribute: false })
accessor onClick: (() => void) | undefined;
}
declare global {
interface HTMLElementTagNameMap {
'ai-sub-item-list': AISubItemList;
}
}

View File

@@ -0,0 +1,2 @@
export const SUBMENU_OFFSET_MAIN_AXIS = 12;
export const SUBMENU_OFFSET_CROSS_AXIS = -60;

View File

@@ -0,0 +1,2 @@
export * from './ai-item-list.js';
export * from './types.js';

View File

@@ -0,0 +1,71 @@
import { css } from 'lit';
export const menuItemStyles = css`
.menu-item {
position: relative;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
padding: 4px var(--item-padding, 12px);
gap: 4px;
align-self: stretch;
border-radius: 4px;
box-sizing: border-box;
}
.menu-item:hover {
background: var(--affine-hover-color);
cursor: pointer;
}
.item-icon {
display: flex;
color: var(--item-icon-color, var(--affine-brand-color));
}
.menu-item:hover .item-icon {
color: var(--item-icon-hover-color, var(--affine-brand-color));
}
.menu-item.discard:hover {
background: var(--affine-background-error-color);
.item-name,
.item-icon,
.enter-icon {
color: var(--affine-error-color);
}
}
.item-name {
display: flex;
padding: 0px 4px;
align-items: baseline;
flex: 1 0 0;
color: var(--affine-text-primary-color);
text-align: start;
white-space: nowrap;
font-feature-settings:
'clig' off,
'liga' off;
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
line-height: 22px;
}
.item-beta {
color: var(--affine-text-secondary-color);
font-size: var(--affine-font-xs);
font-weight: 500;
margin-left: 0.5em;
}
.enter-icon,
.arrow-right-icon {
color: var(--affine-icon-color);
display: flex;
}
.enter-icon {
opacity: 0;
}
.arrow-right-icon,
.menu-item:hover .enter-icon {
opacity: 1;
}
`;

View File

@@ -0,0 +1,68 @@
import type { DocMode } from '@blocksuite/affine-model';
import type { Chain, EditorHost, InitCommandCtx } from '@blocksuite/block-std';
import type { TemplateResult } from 'lit';
export interface AIItemGroupConfig {
name?: string;
items: AIItemConfig[];
}
export interface AIItemConfig {
name: string;
icon: TemplateResult | (() => HTMLElement);
showWhen?: (
chain: Chain<InitCommandCtx>,
editorMode: DocMode,
host: EditorHost
) => boolean;
subItem?: AISubItemConfig[];
subItemOffset?: [number, number];
handler?: (host: EditorHost) => void;
beta?: boolean;
}
export interface AISubItemConfig {
type: string;
handler?: (host: EditorHost) => void;
}
abstract class BaseAIError extends Error {
abstract readonly type: AIErrorType;
}
export enum AIErrorType {
GeneralNetworkError = 'GeneralNetworkError',
PaymentRequired = 'PaymentRequired',
Unauthorized = 'Unauthorized',
}
export class UnauthorizedError extends BaseAIError {
readonly type = AIErrorType.Unauthorized;
constructor() {
super('Unauthorized');
}
}
// user has used up the quota
export class PaymentRequiredError extends BaseAIError {
readonly type = AIErrorType.PaymentRequired;
constructor() {
super('Payment required');
}
}
// general 500x error
export class GeneralNetworkError extends BaseAIError {
readonly type = AIErrorType.GeneralNetworkError;
constructor(message: string = 'Network error') {
super(message);
}
}
export type AIError =
| UnauthorizedError
| PaymentRequiredError
| GeneralNetworkError;

View File

@@ -0,0 +1,74 @@
import type { BlockComponent } from '@blocksuite/block-std';
import { SignalWatcher } from '@blocksuite/global/utils';
import { css, LitElement, type PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
/**
* Renders a the block selection.
*
* @example
* ```ts
* class Block extends LitElement {
* state override styles = css`
* :host {
* position: relative;
* }
*
* render() {
* return html`<affine-block-selection></affine-block-selection>
* };
* }
* ```
*/
export class BlockSelection extends SignalWatcher(LitElement) {
static override styles = css`
:host {
position: absolute;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
background-color: var(--affine-hover-color);
border-color: transparent;
border-style: solid;
}
`;
override connectedCallback(): void {
super.connectedCallback();
this.style.borderRadius = `${this.borderRadius}px`;
if (this.borderWidth !== 0) {
this.style.boxSizing = 'content-box';
this.style.transform = `translate(-${this.borderWidth}px, -${this.borderWidth}px)`;
}
this.style.borderWidth = `${this.borderWidth}px`;
}
override disconnectedCallback() {
super.disconnectedCallback();
this.block = null as unknown as BlockComponent; // force gc
}
protected override updated(_changedProperties: PropertyValues): void {
super.updated(_changedProperties);
this.style.display = this.block.selected?.is('block') ? 'block' : 'none';
}
@property({ attribute: false })
accessor block!: BlockComponent;
@property({ attribute: false })
accessor borderRadius: number = 5;
@property({ attribute: false })
accessor borderWidth: number = 0;
}
declare global {
interface HTMLElementTagNameMap {
'affine-block-selection': BlockSelection;
}
}

View File

@@ -0,0 +1,53 @@
import { focusTextModel } from '@blocksuite/affine-components/rich-text';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/block-std';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
export class BlockZeroWidth extends LitElement {
static override styles = css`
.block-zero-width {
position: absolute;
bottom: -15px;
height: 10px;
width: 100%;
cursor: text;
z-index: 1;
}
`;
_handleClick = (e: MouseEvent) => {
stopPropagation(e);
if (this.block.doc.readonly) return;
const nextBlock = this.block.doc.getNext(this.block.model);
if (nextBlock?.flavour !== 'affine:paragraph') {
const [paragraphId] = this.block.doc.addSiblingBlocks(this.block.model, [
{ flavour: 'affine:paragraph' },
]);
focusTextModel(this.block.host.std, paragraphId);
}
};
override connectedCallback(): void {
super.connectedCallback();
this.addEventListener('click', this._handleClick);
}
override disconnectedCallback(): void {
this.removeEventListener('click', this._handleClick);
super.disconnectedCallback();
}
override render() {
return html`<div class="block-zero-width"></div>`;
}
@property({ attribute: false })
accessor block!: BlockComponent;
}
declare global {
interface HTMLElementTagNameMap {
'block-zero-width': BlockZeroWidth;
}
}

View File

@@ -0,0 +1,242 @@
import { baseTheme } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
css,
html,
LitElement,
nothing,
type TemplateResult,
unsafeCSS,
} from 'lit';
import { property, query } from 'lit/decorators.js';
/**
* Default size is 32px, you can override it by setting `size` property.
* For example, `<icon-button size="32px"></icon-button>`.
*
* You can also set `width` or `height` property to override the size.
*
* Set `text` property to show a text label.
*
* @example
* ```ts
* html`<icon-button @click=${this.onUnlink}>
* ${UnlinkIcon}
* </icon-button>`
*
* html`<icon-button size="32px" text="HTML" @click=${this._importHtml}>
* ${ExportToHTMLIcon}
* </icon-button>`
* ```
*/
export class IconButton extends LitElement {
static override styles = css`
:host {
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
border: none;
width: var(--button-width);
height: var(--button-height);
border-radius: 4px;
background: transparent;
cursor: pointer;
user-select: none;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
color: var(--affine-text-primary-color);
pointer-events: auto;
padding: 4px;
}
// This media query can detect if the device has a hover capability
@media (hover: hover) {
:host(:hover) {
background: var(--affine-hover-color);
}
}
:host(:active) {
background: transparent;
}
:host([disabled]),
:host(:disabled) {
background: transparent;
color: var(--affine-text-disable-color);
cursor: not-allowed;
}
/* You can add a 'hover' attribute to the button to show the hover style */
:host([hover='true']) {
background: var(--affine-hover-color);
}
:host([hover='false']) {
background: transparent;
}
:host(:active[active]) {
background: transparent;
}
/* not supported "until-found" yet */
:host([hidden]) {
display: none;
}
:host > .text-container {
display: flex;
flex-direction: column;
overflow: hidden;
}
:host .text {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: var(--affine-font-sm);
line-height: var(--affine-line-height);
}
:host .sub-text {
font-size: var(--affine-font-xs);
color: var(
--light-textColor-textSecondaryColor,
var(--textColor-textSecondaryColor, #8e8d91)
);
line-height: var(--affine-line-height);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
margin-top: -2px;
}
::slotted(svg) {
flex-shrink: 0;
color: var(--svg-icon-color);
}
::slotted([slot='suffix']) {
margin-left: auto;
}
`;
constructor() {
super();
// Allow activate button by pressing Enter key
this.addEventListener('keypress', event => {
if (this.disabled) {
return;
}
if (event.key === 'Enter' && !event.isComposing) {
this.click();
}
});
// Prevent click event when disabled
this.addEventListener(
'click',
event => {
if (this.disabled === true) {
event.preventDefault();
event.stopPropagation();
}
},
{ capture: true }
);
}
override connectedCallback() {
super.connectedCallback();
this.tabIndex = 0;
this.role = 'button';
const DEFAULT_SIZE = '28px';
if (this.size && (this.width || this.height)) {
return;
}
let width = this.width ?? DEFAULT_SIZE;
let height = this.height ?? DEFAULT_SIZE;
if (this.size) {
width = this.size;
height = this.size;
}
this.style.setProperty(
'--button-width',
typeof width === 'string' ? width : `${width}px`
);
this.style.setProperty(
'--button-height',
typeof height === 'string' ? height : `${height}px`
);
}
override render() {
if (this.hidden) return nothing;
if (this.disabled) {
const disabledColor = cssVarV2('icon/disable');
this.style.setProperty('--svg-icon-color', disabledColor);
this.dataset.testDisabled = 'true';
} else {
this.dataset.testDisabled = 'false';
const iconColor = this.active
? cssVarV2('icon/activated')
: cssVarV2('icon/primary');
this.style.setProperty('--svg-icon-color', iconColor);
}
const text = this.text
? // wrap a span around the text so we can ellipsis it automatically
html`<div class="text">${this.text}</div>`
: nothing;
const subText = this.subText
? html`<div class="sub-text">${this.subText}</div>`
: nothing;
const textContainer =
this.text || this.subText
? html`<div class="text-container">${text}${subText}</div>`
: nothing;
return html`<slot></slot>
${textContainer}
<slot name="suffix"></slot>`;
}
@property({ attribute: true, type: Boolean })
accessor active: boolean = false;
// Do not add `{ attribute: false }` option here, otherwise the `disabled` styles will not work
@property({ attribute: true, type: Boolean })
accessor disabled: boolean | undefined = undefined;
@property()
accessor height: string | number | null = null;
@property({ attribute: true, type: String })
accessor hover: 'true' | 'false' | undefined = undefined;
@property()
accessor size: string | number | null = null;
@property()
accessor subText: string | TemplateResult<1> | null = null;
@property()
accessor text: string | TemplateResult<1> | null = null;
@query('.text-container .text')
accessor textElement: HTMLDivElement | null = null;
@property()
accessor width: string | number | null = null;
}
declare global {
interface HTMLElementTagNameMap {
'icon-button': IconButton;
}
}

View File

@@ -0,0 +1,228 @@
import {
CenterPeekIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
OpenIcon,
RefreshIcon,
} from '@blocksuite/affine-components/icons';
import { isPeekable, peek } from '@blocksuite/affine-components/peek';
import { toast } from '@blocksuite/affine-components/toast';
import { WithDisposable } from '@blocksuite/global/utils';
import { Slice } from '@blocksuite/store';
import { css, html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import {
isEmbedLinkedDocBlock,
isEmbedSyncedDocBlock,
} from '../../../root-block/edgeless/utils/query.js';
import { getBlockProps } from '../../utils/index.js';
import type { EmbedBlockComponent } from './type.js';
export class EmbedCardMoreMenu extends WithDisposable(LitElement) {
static override styles = css`
.embed-card-more-menu {
box-sizing: border-box;
padding-bottom: 4px;
}
.embed-card-more-menu-container {
border-radius: 8px;
padding: 8px;
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
}
.embed-card-more-menu-container > .menu-item {
display: flex;
justify-content: flex-start;
align-items: center;
width: 100%;
}
.embed-card-more-menu-container > .menu-item:hover {
background: var(--affine-hover-color);
}
.embed-card-more-menu-container > .menu-item:hover.delete {
background: var(--affine-background-error-color);
color: var(--affine-error-color);
}
.embed-card-more-menu-container > .menu-item:hover.delete > svg {
color: var(--affine-error-color);
}
.embed-card-more-menu-container > .menu-item svg {
margin: 0 8px;
}
.embed-card-more-menu-container > .divider {
width: 148px;
height: 1px;
margin: 8px;
background-color: var(--affine-border-color);
}
`;
private get _doc() {
return this.block.doc;
}
private get _model() {
return this.block.model;
}
get _openButtonDisabled() {
return (
isEmbedLinkedDocBlock(this._model) && this._model.pageId === this._doc.id
);
}
private get _std() {
return this.block.std;
}
private async _copyBlock() {
const slice = Slice.fromModels(this._doc, [this._model]);
await this._std.clipboard.copySlice(slice);
toast(this.block.host, 'Copied link to clipboard');
this.abortController.abort();
}
private _duplicateBlock() {
const model = this._model;
const blockProps = getBlockProps(model);
const {
width: _width,
height: _height,
xywh: _xywh,
rotate: _rotate,
zIndex: _zIndex,
...duplicateProps
} = blockProps;
const { doc } = model;
const parent = doc.getParent(model);
const index = parent?.children.indexOf(model);
doc.addBlock(
model.flavour as BlockSuite.Flavour,
duplicateProps,
parent,
index
);
this.abortController.abort();
}
private _open() {
this.block.open();
this.abortController.abort();
}
private _peek() {
peek(this.block);
}
private _peekable() {
return isPeekable(this.block);
}
private _refreshData() {
this.block.refreshData();
this.abortController.abort();
}
override render() {
return html`
<div class="embed-card-more-menu">
<div
class="embed-card-more-menu-container"
@pointerdown=${(e: MouseEvent) => e.stopPropagation()}
>
<icon-button
width="126px"
height="32px"
class="menu-item open"
text="Open"
@click=${() => this._open()}
?disabled=${this._openButtonDisabled}
>
${OpenIcon}
</icon-button>
${this._peekable()
? html`<icon-button
width="126px"
height="32px"
text="Open in center peek"
class="menu-item center-peek"
@click=${() => this._peek()}
>
${CenterPeekIcon}
</icon-button>`
: nothing}
<icon-button
width="126px"
height="32px"
class="menu-item copy"
text="Copy"
@click=${() => this._copyBlock()}
>
${CopyIcon}
</icon-button>
<icon-button
width="126px"
height="32px"
class="menu-item duplicate"
text="Duplicate"
?disabled=${this._doc.readonly}
@click=${() => this._duplicateBlock()}
>
${DuplicateIcon}
</icon-button>
${isEmbedLinkedDocBlock(this._model) ||
isEmbedSyncedDocBlock(this._model)
? nothing
: html`<icon-button
width="126px"
height="32px"
class="menu-item reload"
text="Reload"
?disabled=${this._doc.readonly}
@click=${() => this._refreshData()}
>
${RefreshIcon}
</icon-button>`}
<div class="divider"></div>
<icon-button
width="126px"
height="32px"
class="menu-item delete"
text="Delete"
?disabled=${this._doc.readonly}
@click=${() => this._doc.deleteBlock(this._model)}
>
${DeleteIcon}
</icon-button>
</div>
</div>
`;
}
@property({ attribute: false })
accessor abortController!: AbortController;
@property({ attribute: false })
accessor block!: EmbedBlockComponent;
}
declare global {
interface HTMLElementTagNameMap {
'embed-card-more-menu': EmbedCardMoreMenu;
}
}

View File

@@ -0,0 +1,106 @@
import type {
BookmarkBlockModel,
ColorScheme,
EmbedGithubModel,
EmbedLinkedDocModel,
} from '@blocksuite/affine-model';
import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import type { EmbedCardStyle } from '../../types.js';
import { getEmbedCardIcons } from '../../utils/url.js';
export class EmbedCardStyleMenu extends WithDisposable(LitElement) {
static override styles = css`
.embed-card-style-menu {
box-sizing: border-box;
padding-bottom: 8px;
}
.embed-card-style-menu-container {
border-radius: 8px;
padding: 8px;
gap: 8px;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
}
.embed-card-style-menu-container > icon-button {
padding: var(--1, 0px);
}
.embed-card-style-menu-container > icon-button.selected {
border: 1px solid var(--affine-brand-color);
}
`;
private _setEmbedCardStyle(style: EmbedCardStyle) {
this.model.doc.updateBlock(this.model, { style });
this.requestUpdate();
this.abortController.abort();
}
override render() {
const { EmbedCardHorizontalIcon, EmbedCardListIcon } = getEmbedCardIcons(
this.theme
);
return html`
<div class="embed-card-style-menu">
<div
class="embed-card-style-menu-container"
@pointerdown=${(e: MouseEvent) => e.stopPropagation()}
>
<icon-button
width="76px"
height="76px"
class=${classMap({
selected: this.model.style === 'horizontal',
'card-style-button-horizontal': true,
})}
@click=${() => this._setEmbedCardStyle('horizontal')}
>
${EmbedCardHorizontalIcon}
<affine-tooltip .offset=${4}
>${'Large horizontal style'}</affine-tooltip
>
</icon-button>
<icon-button
width="76px"
height="76px"
class=${classMap({
selected: this.model.style === 'list',
'card-style-button-list': true,
})}
@click=${() => this._setEmbedCardStyle('list')}
>
${EmbedCardListIcon}
<affine-tooltip .offset=${4}
>${'Small horizontal style'}</affine-tooltip
>
</icon-button>
</div>
</div>
`;
}
@property({ attribute: false })
accessor abortController!: AbortController;
@property({ attribute: false })
accessor model!: BookmarkBlockModel | EmbedGithubModel | EmbedLinkedDocModel;
@property({ attribute: false })
accessor theme!: ColorScheme;
}
declare global {
interface HTMLElementTagNameMap {
'embed-card-style-menu': EmbedCardStyleMenu;
}
}

View File

@@ -0,0 +1,101 @@
import { type BlockComponent, ShadowlessElement } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import { html } from 'lit';
import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { embedCardModalStyles } from './styles.js';
export class EmbedCardEditCaptionEditModal extends WithDisposable(
ShadowlessElement
) {
static override styles = embedCardModalStyles;
private get _doc() {
return this.block.doc;
}
private get _model() {
return this.block.model as BlockModel<{ caption: string }>;
}
private _onKeydown(e: KeyboardEvent) {
e.stopPropagation();
if (e.key === 'Enter' && !e.isComposing) {
this._onSave();
}
if (e.key === 'Escape') {
this.remove();
}
}
private _onSave() {
const caption = this.captionInput.value;
this._doc.updateBlock(this._model, {
caption,
});
this.remove();
}
override connectedCallback() {
super.connectedCallback();
this.updateComplete
.then(() => {
this.captionInput.focus();
})
.catch(console.error);
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
}
override render() {
return html`
<div class="embed-card-modal">
<div class="embed-card-modal-mask" @click=${() => this.remove()}></div>
<div class="embed-card-modal-wrapper">
<div class="embed-card-modal-row">
<label for="card-title">Caption</label>
<textarea
class="embed-card-modal-input caption"
placeholder="Write a caption..."
.value=${this._model.caption ?? ''}
></textarea>
</div>
<div class="embed-card-modal-row">
<button
class=${classMap({
'embed-card-modal-button': true,
save: true,
})}
@click=${() => this._onSave()}
>
Save
</button>
</div>
</div>
</div>
`;
}
@property({ attribute: false })
accessor block!: BlockComponent;
@query('.embed-card-modal-input.caption')
accessor captionInput!: HTMLTextAreaElement;
}
export function toggleEmbedCardCaptionEditModal(block: BlockComponent) {
const host = block.host;
host.selection.clear();
const embedCardEditCaptionEditModal = new EmbedCardEditCaptionEditModal();
embedCardEditCaptionEditModal.block = block;
document.body.append(embedCardEditCaptionEditModal);
}
declare global {
interface HTMLElementTagNameMap {
'embed-card-caption-edit-modal': EmbedCardEditCaptionEditModal;
}
}

View File

@@ -0,0 +1,226 @@
import { toast } from '@blocksuite/affine-components/toast';
import { EmbedOptionProvider } from '@blocksuite/affine-shared/services';
import type { EditorHost } from '@blocksuite/block-std';
import { ShadowlessElement } from '@blocksuite/block-std';
import {
assertExists,
Bound,
Vec,
WithDisposable,
} from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import { html } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import type { EdgelessRootBlockComponent } from '../../../../root-block/edgeless/edgeless-root-block.js';
import { EMBED_CARD_HEIGHT, EMBED_CARD_WIDTH } from '../../../consts.js';
import type { EmbedCardStyle } from '../../../types.js';
import { getRootByEditorHost, isValidUrl } from '../../../utils/index.js';
import { embedCardModalStyles } from './styles.js';
export class EmbedCardCreateModal extends WithDisposable(ShadowlessElement) {
static override styles = embedCardModalStyles;
private _onCancel = () => {
this.remove();
};
private _onConfirm = () => {
const url = this.input.value;
if (!isValidUrl(url)) {
toast(this.host, 'Invalid link');
return;
}
const embedOptions = this.host.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
const { mode } = this.createOptions;
if (mode === 'page') {
const { parentModel, index } = this.createOptions;
let flavour = 'affine:bookmark';
if (embedOptions) {
flavour = embedOptions.flavour;
}
this.host.doc.addBlock(
flavour as never,
{
url,
},
parentModel,
index
);
} else if (mode === 'edgeless') {
let flavour = 'affine:bookmark',
targetStyle: EmbedCardStyle = 'vertical';
if (embedOptions) {
flavour = embedOptions.flavour;
targetStyle = embedOptions.styles[0];
}
const edgelessRoot = getRootByEditorHost(
this.host
) as EdgelessRootBlockComponent | null;
assertExists(edgelessRoot);
const surface = edgelessRoot.surface;
const center = Vec.toVec(surface.renderer.viewport.center);
edgelessRoot.service.addBlock(
flavour,
{
url,
xywh: Bound.fromCenter(
center,
EMBED_CARD_WIDTH[targetStyle],
EMBED_CARD_HEIGHT[targetStyle]
).serialize(),
style: targetStyle,
},
surface.model
);
edgelessRoot.gfx.tool.setTool('default');
}
this.onConfirm();
this.remove();
};
private _onDocumentKeydown = (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter' && !e.isComposing) {
this._onConfirm();
}
if (e.key === 'Escape') {
this.remove();
}
};
private _handleInput(e: InputEvent) {
const target = e.target as HTMLInputElement;
this._linkInputValue = target.value;
}
override connectedCallback() {
super.connectedCallback();
this.updateComplete
.then(() => {
requestAnimationFrame(() => {
this.input.focus();
});
})
.catch(console.error);
this.disposables.addFromEvent(this, 'keydown', this._onDocumentKeydown);
}
override render() {
return html`<div class="embed-card-modal">
<div class="embed-card-modal-mask" @click=${this._onCancel}></div>
<div class="embed-card-modal-wrapper">
<div class="embed-card-modal-row">
<div class="embed-card-modal-title">${this.titleText}</div>
</div>
<div class="embed-card-modal-row">
<div class="embed-card-modal-description">
${this.descriptionText}
</div>
</div>
<div class="embed-card-modal-row">
<input
class="embed-card-modal-input link"
id="card-description"
type="text"
placeholder="Input in https://..."
value=${this._linkInputValue}
@input=${this._handleInput}
/>
</div>
<div class="embed-card-modal-row">
<button
class=${classMap({
'embed-card-modal-button': true,
save: true,
})}
?disabled=${!isValidUrl(this._linkInputValue)}
@click=${this._onConfirm}
>
Confirm
</button>
</div>
</div>
</div>`;
}
@state()
private accessor _linkInputValue = '';
@property({ attribute: false })
accessor createOptions!:
| {
mode: 'page';
parentModel: BlockModel | string;
index?: number;
}
| {
mode: 'edgeless';
};
@property({ attribute: false })
accessor descriptionText!: string;
@property({ attribute: false })
accessor host!: EditorHost;
@query('input')
accessor input!: HTMLInputElement;
@property({ attribute: false })
accessor onConfirm!: () => void;
@property({ attribute: false })
accessor titleText!: string;
}
export async function toggleEmbedCardCreateModal(
host: EditorHost,
titleText: string,
descriptionText: string,
createOptions:
| {
mode: 'page';
parentModel: BlockModel | string;
index?: number;
}
| {
mode: 'edgeless';
}
): Promise<void> {
host.selection.clear();
const embedCardCreateModal = new EmbedCardCreateModal();
embedCardCreateModal.host = host;
embedCardCreateModal.titleText = titleText;
embedCardCreateModal.descriptionText = descriptionText;
embedCardCreateModal.createOptions = createOptions;
document.body.append(embedCardCreateModal);
return new Promise(resolve => {
embedCardCreateModal.onConfirm = () => resolve();
});
}
declare global {
interface HTMLElementTagNameMap {
'embed-card-create-modal': EmbedCardCreateModal;
}
}

View File

@@ -0,0 +1,450 @@
import {
EmbedLinkedDocBlockComponent,
EmbedSyncedDocBlockComponent,
} from '@blocksuite/affine-block-embed';
import {
notifyLinkedDocClearedAliases,
notifyLinkedDocSwitchedToCard,
} from '@blocksuite/affine-components/notification';
import { toast } from '@blocksuite/affine-components/toast';
import type { AliasInfo } from '@blocksuite/affine-model';
import {
EmbedLinkedDocModel,
EmbedSyncedDocModel,
} from '@blocksuite/affine-model';
import {
type LinkEventType,
type TelemetryEvent,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { FONT_SM, FONT_XS } from '@blocksuite/affine-shared/styles';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import {
listenClickAway,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import type {
BlockComponent,
BlockStdScope,
EditorHost,
} from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { autoUpdate, computePosition, flip, offset } from '@floating-ui/dom';
import { computed, signal } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { classMap } from 'lit/directives/class-map.js';
import { live } from 'lit/directives/live.js';
import type { LinkableEmbedModel } from '../type.js';
import { isInternalEmbedModel } from '../type.js';
export class EmbedCardEditModal extends SignalWatcher(
WithDisposable(LitElement)
) {
static override styles = css`
:host {
position: absolute;
top: 0;
left: 0;
z-index: var(--affine-z-index-popover);
animation: affine-popover-fade-in 0.2s ease;
}
@keyframes affine-popover-fade-in {
from {
opacity: 0;
transform: translateY(-3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.embed-card-modal-wrapper {
display: flex;
padding: 12px;
flex-direction: column;
justify-content: flex-end;
align-items: flex-start;
gap: 12px;
width: 421px;
color: var(--affine-icon-color);
box-shadow: var(--affine-overlay-shadow);
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
border-radius: 4px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
.row {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
}
.row .input {
display: flex;
padding: 4px 10px;
width: 100%;
min-width: 100%;
box-sizing: border-box;
border-radius: 4px;
user-select: none;
background: transparent;
border: 1px solid ${unsafeCSSVarV2('input/border/default')};
color: var(--affine-text-primary-color);
${FONT_SM};
}
.input::placeholder {
color: var(--affine-placeholder-color);
}
.input:focus {
border-color: ${unsafeCSSVarV2('input/border/active')};
outline: none;
}
textarea.input {
min-height: 80px;
resize: none;
}
.row.actions {
justify-content: flex-end;
}
.row.actions .button {
display: flex;
padding: 4px 12px;
align-items: center;
gap: 4px;
border-radius: 4px;
border: 1px solid ${unsafeCSSVarV2('button/innerBlackBorder')};
background: ${unsafeCSSVarV2('button/secondary')};
${FONT_XS};
color: ${unsafeCSSVarV2('text/primary')};
}
.row.actions .button[disabled],
.row.actions .button:disabled {
pointer-events: none;
color: ${unsafeCSSVarV2('text/disable')};
}
.row.actions .button.save {
color: ${unsafeCSSVarV2('button/pureWhiteText')};
background: ${unsafeCSSVarV2('button/primary')};
}
.row.actions .button[disabled].save,
.row.actions .button:disabled.save {
opacity: 0.5;
}
`;
private _blockComponent: BlockComponent | null = null;
private _hide = () => {
this.remove();
};
private _onKeydown = (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter' && !(e.isComposing || e.shiftKey)) {
this._onSave();
}
if (e.key === 'Escape') {
e.preventDefault();
this.remove();
}
};
private _onReset = () => {
const blockComponent = this._blockComponent;
if (!blockComponent) {
this.remove();
return;
}
const std = blockComponent.std;
this.model.doc.updateBlock(this.model, { title: null, description: null });
if (
this.isEmbedLinkedDocModel &&
blockComponent instanceof EmbedLinkedDocBlockComponent
) {
blockComponent.refreshData();
notifyLinkedDocClearedAliases(std);
}
blockComponent.requestUpdate();
track(std, this.model, this.viewType, 'ResetedAlias', { control: 'reset' });
this.remove();
};
private _onSave = () => {
const blockComponent = this._blockComponent;
if (!blockComponent) {
this.remove();
return;
}
const title = this.title$.value.trim();
if (title.length === 0) {
toast(this.host, 'Title can not be empty');
return;
}
const std = blockComponent.std;
const description = this.description$.value.trim();
const props: AliasInfo = { title };
if (description) props.description = description;
if (
this.isEmbedSyncedDocModel &&
blockComponent instanceof EmbedSyncedDocBlockComponent
) {
blockComponent.convertToCard(props);
notifyLinkedDocSwitchedToCard(std);
} else {
this.model.doc.updateBlock(this.model, props);
blockComponent.requestUpdate();
}
track(std, this.model, this.viewType, 'SavedAlias', { control: 'save' });
this.remove();
};
private _updateDescription = (e: InputEvent) => {
const target = e.target as HTMLTextAreaElement;
this.description$.value = target.value;
};
private _updateTitle = (e: InputEvent) => {
const target = e.target as HTMLInputElement;
this.title$.value = target.value;
};
get isEmbedLinkedDocModel() {
return this.model instanceof EmbedLinkedDocModel;
}
get isEmbedSyncedDocModel() {
return this.model instanceof EmbedSyncedDocModel;
}
get isInternalEmbedModel() {
return isInternalEmbedModel(this.model);
}
get modelType(): 'linked' | 'synced' | null {
if (this.isEmbedLinkedDocModel) return 'linked';
if (this.isEmbedSyncedDocModel) return 'synced';
return null;
}
get placeholders() {
if (this.isInternalEmbedModel) {
return {
title: 'Add title alias',
description:
'Add description alias (empty to inherit document content)',
};
}
return {
title: 'Write a title',
description: 'Write a description...',
};
}
private _updateInfo() {
const title = this.model.title || this.originalDocInfo?.title || '';
const description =
this.model.description || this.originalDocInfo?.description || '';
this.title$.value = title;
this.description$.value = description;
}
override connectedCallback() {
super.connectedCallback();
this._updateInfo();
}
override firstUpdated() {
const blockComponent = this.host.std.view.getBlock(this.model.id);
if (!blockComponent) return;
this._blockComponent = blockComponent;
this.disposables.add(
autoUpdate(blockComponent, this, () => {
computePosition(blockComponent, this, {
placement: 'top-start',
middleware: [flip(), offset(8)],
})
.then(({ x, y }) => {
this.style.left = `${x}px`;
this.style.top = `${y}px`;
})
.catch(console.error);
})
);
this.disposables.add(listenClickAway(this, this._hide));
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
this.disposables.addFromEvent(this, 'pointerdown', stopPropagation);
this.titleInput.focus();
this.titleInput.select();
}
override render() {
return html`
<div class="embed-card-modal-wrapper">
<div class="row">
<input
class="input title"
type="text"
placeholder=${this.placeholders.title}
.value=${live(this.title$.value)}
@input=${this._updateTitle}
/>
</div>
<div class="row">
<textarea
class="input description"
maxlength="500"
placeholder=${this.placeholders.description}
.value=${live(this.description$.value)}
@input=${this._updateDescription}
></textarea>
</div>
<div class="row actions">
${choose(this.modelType, [
[
'linked',
() => html`
<button
class=${classMap({
button: true,
reset: true,
})}
.disabled=${this.resetButtonDisabled$.value}
@click=${this._onReset}
>
Reset
</button>
`,
],
[
'synced',
() => html`
<button
class=${classMap({
button: true,
cancel: true,
})}
@click=${this._hide}
>
Cancel
</button>
`,
],
])}
<button
class=${classMap({
button: true,
save: true,
})}
.disabled=${this.saveButtonDisabled$.value}
@click=${this._onSave}
>
Save
</button>
</div>
</div>
`;
}
accessor description$ = signal<string>('');
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor model!: LinkableEmbedModel;
@property({ attribute: false })
accessor originalDocInfo: AliasInfo | undefined = undefined;
accessor resetButtonDisabled$ = computed<boolean>(
() =>
!(
Boolean(this.model.title$.value?.length) ||
Boolean(this.model.description$.value?.length)
)
);
accessor saveButtonDisabled$ = computed<boolean>(
() => this.title$.value.trim().length === 0
);
accessor title$ = signal<string>('');
@query('.input.title')
accessor titleInput!: HTMLInputElement;
@property({ attribute: false })
accessor viewType!: string;
}
export function toggleEmbedCardEditModal(
host: EditorHost,
embedCardModel: LinkableEmbedModel,
viewType: string,
originalDocInfo?: AliasInfo
) {
document.body.querySelector('embed-card-edit-modal')?.remove();
const embedCardEditModal = new EmbedCardEditModal();
embedCardEditModal.model = embedCardModel;
embedCardEditModal.host = host;
embedCardEditModal.viewType = viewType;
embedCardEditModal.originalDocInfo = originalDocInfo;
document.body.append(embedCardEditModal);
}
declare global {
interface HTMLElementTagNameMap {
'embed-card-edit-modal': EmbedCardEditModal;
}
}
function track(
std: BlockStdScope,
model: LinkableEmbedModel,
viewType: string,
event: LinkEventType,
props: Partial<TelemetryEvent>
) {
std.getOptional(TelemetryProvider)?.track(event, {
segment: 'toolbar',
page: 'doc editor',
module: 'embed card edit popup',
type: `${viewType} view`,
category: isInternalEmbedModel(model) ? 'linked doc' : 'link',
...props,
});
}

View File

@@ -0,0 +1,2 @@
export * from './embed-card-create-modal.js';
export * from './embed-card-edit-modal.js';

View File

@@ -0,0 +1,120 @@
import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles';
import { css } from 'lit';
export const embedCardModalStyles = css`
.embed-card-modal-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
z-index: 1;
}
.embed-card-modal-wrapper {
${PANEL_BASE};
flex-direction: column;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
z-index: 2;
width: 305px;
height: max-content;
padding: 12px;
gap: 12px;
border-radius: 8px;
font-size: var(--affine-font-xs);
line-height: 20px;
}
.embed-card-modal-row {
display: flex;
flex-direction: column;
align-self: stretch;
}
.embed-card-modal-row label {
padding: 0px 2px;
color: var(--affine-text-secondary-color);
font-weight: 600;
}
.embed-card-modal-input {
display: flex;
padding-left: 10px;
padding-right: 10px;
border-radius: 8px;
border: 1px solid var(--affine-border-color);
background: var(--affine-white-10);
color: var(--affine-text-primary-color);
${FONT_XS};
}
input.embed-card-modal-input {
padding-top: 4px;
padding-bottom: 4px;
}
textarea.embed-card-modal-input {
padding-top: 6px;
padding-bottom: 6px;
min-width: 100%;
max-width: 100%;
}
.embed-card-modal-input:focus {
border-color: var(--affine-blue-700);
box-shadow: var(--affine-active-shadow);
outline: none;
}
.embed-card-modal-input::placeholder {
color: var(--affine-placeholder-color);
}
.embed-card-modal-row:has(.embed-card-modal-button) {
flex-direction: row;
gap: 4px;
justify-content: flex-end;
}
.embed-card-modal-row:has(.embed-card-modal-button.reset) {
justify-content: space-between;
}
.embed-card-modal-button {
padding: 4px 18px;
border-radius: 8px;
box-sizing: border-box;
}
.embed-card-modal-button.save {
border: 1px solid var(--affine-black-10);
background: var(--affine-primary-color);
color: var(--affine-pure-white);
}
.embed-card-modal-button[disabled] {
pointer-events: none;
cursor: not-allowed;
color: var(--affine-text-disable-color);
background: transparent;
}
.embed-card-modal-button.reset {
padding: 4px 0;
border: none;
background: transparent;
text-decoration: underline;
color: var(--affine-secondary-color);
user-select: none;
}
.embed-card-modal-title {
font-size: 18px;
font-weight: 600;
line-height: 26px;
user-select: none;
}
.embed-card-modal-description {
font-size: 15px;
font-weight: 500;
line-height: 24px;
user-select: none;
}
`;

View File

@@ -0,0 +1,79 @@
import {
EmbedFigmaBlockComponent,
EmbedGithubBlockComponent,
EmbedHtmlBlockComponent,
EmbedLinkedDocBlockComponent,
EmbedLoomBlockComponent,
EmbedSyncedDocBlockComponent,
EmbedYoutubeBlockComponent,
} from '@blocksuite/affine-block-embed';
import type {
BookmarkBlockModel,
EmbedFigmaModel,
EmbedGithubModel,
EmbedHtmlModel,
EmbedLoomModel,
EmbedYoutubeModel,
} from '@blocksuite/affine-model';
import {
EmbedLinkedDocModel,
EmbedSyncedDocModel,
} from '@blocksuite/affine-model';
import type { BlockComponent } from '@blocksuite/block-std';
import { BookmarkBlockComponent } from '../../../bookmark-block/bookmark-block.js';
export type ExternalEmbedBlockComponent =
| BookmarkBlockComponent
| EmbedFigmaBlockComponent
| EmbedGithubBlockComponent
| EmbedLoomBlockComponent
| EmbedYoutubeBlockComponent;
export type InternalEmbedBlockComponent =
| EmbedLinkedDocBlockComponent
| EmbedSyncedDocBlockComponent;
export type LinkableEmbedBlockComponent =
| ExternalEmbedBlockComponent
| InternalEmbedBlockComponent;
export type EmbedBlockComponent =
| LinkableEmbedBlockComponent
| EmbedHtmlBlockComponent;
export type ExternalEmbedModel =
| BookmarkBlockModel
| EmbedFigmaModel
| EmbedGithubModel
| EmbedLoomModel
| EmbedYoutubeModel;
export type InternalEmbedModel = EmbedLinkedDocModel | EmbedSyncedDocModel;
export type LinkableEmbedModel = ExternalEmbedModel | InternalEmbedModel;
export type EmbedModel = LinkableEmbedModel | EmbedHtmlModel;
export function isEmbedCardBlockComponent(
block: BlockComponent
): block is EmbedBlockComponent {
return (
block instanceof BookmarkBlockComponent ||
block instanceof EmbedFigmaBlockComponent ||
block instanceof EmbedGithubBlockComponent ||
block instanceof EmbedHtmlBlockComponent ||
block instanceof EmbedLoomBlockComponent ||
block instanceof EmbedYoutubeBlockComponent ||
block instanceof EmbedLinkedDocBlockComponent ||
block instanceof EmbedSyncedDocBlockComponent
);
}
export function isInternalEmbedModel(
model: EmbedModel
): model is InternalEmbedModel {
return (
model instanceof EmbedLinkedDocModel || model instanceof EmbedSyncedDocModel
);
}

View File

@@ -0,0 +1,173 @@
import type { DragIndicator } from '@blocksuite/affine-components/drag-indicator';
import {
getClosestBlockComponentByPoint,
isInsidePageEditor,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type { BlockService, EditorHost } from '@blocksuite/block-std';
import type { IVec } from '@blocksuite/global/utils';
import { assertExists, Point } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import { calcDropTarget, type DropResult } from '../../_common/utils/index.js';
export type onDropProps = {
files: File[];
targetModel: BlockModel | null;
place: 'before' | 'after';
point: IVec;
};
export type FileDropOptions = {
flavour: string;
onDrop?: ({
files,
targetModel,
place,
point,
}: onDropProps) => Promise<boolean> | void;
};
export class FileDropManager {
private static _dropResult: DropResult | null = null;
private _blockService: BlockService;
private _fileDropOptions: FileDropOptions;
private _indicator!: DragIndicator;
private _onDrop = (event: DragEvent) => {
this._indicator.rect = null;
const { onDrop } = this._fileDropOptions;
if (!onDrop) return;
const dataTransfer = event.dataTransfer;
if (!dataTransfer) return;
const effectAllowed = dataTransfer.effectAllowed;
if (effectAllowed === 'none') return;
const droppedFiles = dataTransfer.files;
if (!droppedFiles || !droppedFiles.length) return;
event.preventDefault();
const { targetModel, type: place } = this;
const { x, y } = event;
onDrop({
files: [...droppedFiles],
targetModel,
place,
point: [x, y],
})?.catch(console.error);
};
onDragLeave = () => {
FileDropManager._dropResult = null;
this._indicator.rect = null;
};
onDragOver = (event: DragEvent) => {
event.preventDefault();
const dataTransfer = event.dataTransfer;
if (!dataTransfer) return;
const effectAllowed = dataTransfer.effectAllowed;
if (effectAllowed === 'none') return;
const { clientX, clientY } = event;
const point = new Point(clientX, clientY);
const element = getClosestBlockComponentByPoint(point.clone());
let result: DropResult | null = null;
if (element) {
const model = element.model;
const parent = this.doc.getParent(model);
if (!matchFlavours(parent, ['affine:surface'])) {
result = calcDropTarget(point, model, element);
}
}
if (result) {
FileDropManager._dropResult = result;
this._indicator.rect = result.rect;
} else {
FileDropManager._dropResult = null;
this._indicator.rect = null;
}
};
get doc() {
return this._blockService.doc;
}
get editorHost(): EditorHost {
return this._blockService.std.host;
}
get targetModel(): BlockModel | null {
let targetModel = FileDropManager._dropResult?.modelState.model || null;
if (!targetModel && isInsidePageEditor(this.editorHost)) {
const rootModel = this.doc.root;
assertExists(rootModel);
let lastNote = rootModel.children[rootModel.children.length - 1];
if (!lastNote || !matchFlavours(lastNote, ['affine:note'])) {
const newNoteId = this.doc.addBlock('affine:note', {}, rootModel.id);
const newNote = this.doc.getBlockById(newNoteId);
assertExists(newNote);
lastNote = newNote;
}
const lastItem = lastNote.children[lastNote.children.length - 1];
if (lastItem) {
targetModel = lastItem;
} else {
const newParagraphId = this.doc.addBlock(
'affine:paragraph',
{},
lastNote,
0
);
const newParagraph = this.doc.getBlockById(newParagraphId);
assertExists(newParagraph);
targetModel = newParagraph;
}
}
return targetModel;
}
get type(): 'before' | 'after' {
return !FileDropManager._dropResult ||
FileDropManager._dropResult.type !== 'before'
? 'after'
: 'before';
}
constructor(blockService: BlockService, fileDropOptions: FileDropOptions) {
this._blockService = blockService;
this._fileDropOptions = fileDropOptions;
this._indicator = document.querySelector(
'affine-drag-indicator'
) as DragIndicator;
if (!this._indicator) {
this._indicator = document.createElement(
'affine-drag-indicator'
) as DragIndicator;
document.body.append(this._indicator);
}
if (fileDropOptions.onDrop) {
this._blockService.disposables.addFromEvent(
this._blockService.std.host,
'drop',
this._onDrop
);
}
}
}

View File

@@ -0,0 +1,260 @@
import {
type AdvancedPortalOptions,
createLitPortal,
} from '@blocksuite/affine-components/portal';
import { WithDisposable } from '@blocksuite/global/utils';
import { DoneIcon, SearchIcon } from '@blocksuite/icons/lit';
import { autoPlacement, offset, type Placement, size } from '@floating-ui/dom';
import { html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { PAGE_HEADER_HEIGHT } from '../../consts.js';
import { filterableListStyles } from './styles.js';
import type { FilterableListItem, FilterableListOptions } from './types.js';
export * from './types.js';
export class FilterableListComponent<Props = unknown> extends WithDisposable(
LitElement
) {
static override styles = filterableListStyles;
private _buildContent(items: FilterableListItem<Props>[]) {
return items.map((item, idx) => {
const focussed = this._curFocusIndex === idx;
return html`
<icon-button
class=${classMap({
'filterable-item': true,
focussed,
})}
@mouseover=${() => (this._curFocusIndex = idx)}
@click=${() => this._select(item)}
hover=${focussed}
width="100%"
height="32px"
>
${item.icon ?? nothing} ${item.label ?? item.name}
<div slot="suffix">
${this.options.active?.(item) ? DoneIcon() : nothing}
</div>
</icon-button>
`;
});
}
private _filterItems() {
const searchFilter = !this._filterText
? this.options.items
: this.options.items.filter(
item =>
item.name.startsWith(this._filterText.toLowerCase()) ||
item.aliases?.some(alias =>
alias.startsWith(this._filterText.toLowerCase())
)
);
return searchFilter.sort((a, b) => {
const isActiveA = this.options.active?.(a);
const isActiveB = this.options.active?.(b);
if (isActiveA && !isActiveB) return -1;
if (!isActiveA && isActiveB) return 1;
return this.listFilter?.(a, b) ?? 0;
});
}
private _scrollFocusedItemIntoView() {
this.updateComplete
.then(() => {
this._focussedItem?.scrollIntoView({
block: 'nearest',
inline: 'start',
});
})
.catch(console.error);
}
private _select(item: FilterableListItem) {
this.abortController?.abort();
this.options.onSelect(item);
}
override connectedCallback() {
super.connectedCallback();
requestAnimationFrame(() => {
this._filterInput.focus();
});
}
override render() {
const filteredItems = this._filterItems();
const content = this._buildContent(filteredItems);
const isFlip = !!this.placement?.startsWith('top');
const _handleInputKeydown = (ev: KeyboardEvent) => {
switch (ev.key) {
case 'ArrowUp': {
ev.preventDefault();
this._curFocusIndex =
(this._curFocusIndex + content.length - 1) % content.length;
this._scrollFocusedItemIntoView();
break;
}
case 'ArrowDown': {
ev.preventDefault();
this._curFocusIndex = (this._curFocusIndex + 1) % content.length;
this._scrollFocusedItemIntoView();
break;
}
case 'Enter': {
if (ev.isComposing) break;
ev.preventDefault();
const item = filteredItems[this._curFocusIndex];
this._select(item);
break;
}
case 'Escape': {
ev.preventDefault();
this.abortController?.abort();
break;
}
}
};
return html`
<div
class=${classMap({ 'affine-filterable-list': true, flipped: isFlip })}
>
<div class="input-wrapper">
${SearchIcon()}
<input
id="filter-input"
type="text"
placeholder=${this.options?.placeholder ?? 'Search'}
@input="${() => {
this._filterText = this._filterInput?.value;
this._curFocusIndex = 0;
}}"
@keydown="${_handleInputKeydown}"
/>
</div>
<editor-toolbar-separator
data-orientation="horizontal"
></editor-toolbar-separator>
<div class="items-container">${content}</div>
</div>
`;
}
@state()
private accessor _curFocusIndex = 0;
@query('#filter-input')
private accessor _filterInput!: HTMLInputElement;
@state()
private accessor _filterText = '';
@query('.filterable-item.focussed')
private accessor _focussedItem!: HTMLElement | null;
@property({ attribute: false })
accessor abortController: AbortController | null = null;
@property({ attribute: false })
accessor listFilter:
| ((a: FilterableListItem<Props>, b: FilterableListItem<Props>) => number)
| undefined = undefined;
@property({ attribute: false })
accessor options!: FilterableListOptions<Props>;
@property({ attribute: false })
accessor placement: Placement | undefined = undefined;
}
export function showPopFilterableList({
options,
filter,
abortController = new AbortController(),
referenceElement,
container,
maxHeight = 440,
portalStyles,
}: {
options: FilterableListComponent['options'];
referenceElement: Element;
container?: Element;
abortController?: AbortController;
filter?: FilterableListComponent['listFilter'];
maxHeight?: number;
portalStyles?: AdvancedPortalOptions['portalStyles'];
}) {
const portalPadding = {
top: PAGE_HEADER_HEIGHT + 12,
bottom: 12,
} as const;
const list = new FilterableListComponent();
list.options = options;
list.listFilter = filter;
list.abortController = abortController;
createLitPortal({
closeOnClickAway: true,
template: ({ positionSlot }) => {
positionSlot.on(({ placement }) => {
list.placement = placement;
});
return list;
},
container,
portalStyles,
computePosition: {
referenceElement,
placement: 'bottom-start',
middleware: [
offset(4),
autoPlacement({
allowedPlacements: ['top-start', 'bottom-start'],
padding: portalPadding,
}),
size({
padding: portalPadding,
apply({ availableHeight, elements, placement }) {
Object.assign(elements.floating.style, {
height: '100%',
maxHeight: `${Math.min(maxHeight, availableHeight)}px`,
pointerEvents: 'none',
...(placement.startsWith('top')
? {
display: 'flex',
alignItems: 'flex-end',
}
: {
display: null,
alignItems: null,
}),
});
},
}),
],
autoUpdate: {
// fix the lang list position incorrectly when scrolling
animationFrame: true,
},
},
abortController,
});
}
declare global {
interface HTMLElementTagNameMap {
'affine-filterable-list': FilterableListComponent;
}
}

View File

@@ -0,0 +1,109 @@
import { PANEL_BASE } from '@blocksuite/affine-shared/styles';
import { css } from 'lit';
import { scrollbarStyle } from '../utils.js';
export const filterableListStyles = css`
:host {
${PANEL_BASE};
flex-direction: column;
padding: 0;
max-height: 100%;
pointer-events: auto;
overflow: hidden;
z-index: var(--affine-z-index-popover);
}
.affine-filterable-list {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
width: 230px;
padding: 8px;
box-sizing: border-box;
overflow: hidden;
}
.affine-filterable-list.flipped {
flex-direction: column-reverse;
}
.items-container {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
overflow-y: scroll;
padding-top: 5px;
padding-left: 4px;
padding-right: 4px;
}
editor-toolbar-separator {
margin: 8px 0;
}
.input-wrapper {
display: flex;
align-items: center;
border-radius: 4px;
padding: 4px 10px;
gap: 4px;
border-width: 1px;
border-style: solid;
border-color: transparent;
}
.input-wrapper:focus-within {
border-color: var(--affine-blue-700);
box-shadow: var(--affine-active-shadow);
}
${scrollbarStyle('.items-container')}
.filterable-item {
display: flex;
justify-content: space-between;
gap: 4px;
padding: 12px;
}
.filterable-item > div[slot='suffix'] {
display: flex;
align-items: center;
}
.filterable-item svg {
width: 20px;
height: 20px;
}
.filterable-item.focussed {
color: var(--affine-blue-700);
background: var(--affine-hover-color-filled);
}
#filter-input {
flex: 1;
align-items: center;
height: 20px;
width: 140px;
border-radius: 8px;
padding-top: 2px;
border: transparent;
background: transparent;
color: inherit;
}
#filter-input:focus {
outline: none;
}
#filter-input::placeholder {
color: var(--affine-placeholder-color);
font-size: var(--affine-font-sm);
}
`;

View File

@@ -0,0 +1,18 @@
import type { TemplateResult } from 'lit';
export type FilterableListItemKey = string;
export interface FilterableListItem<Props = unknown> {
name: string;
label?: string;
icon?: TemplateResult;
aliases?: string[];
props?: Props;
}
export interface FilterableListOptions<Props = unknown> {
placeholder?: string;
items: FilterableListItem<Props>[];
active?: (item: FilterableListItem) => boolean;
onSelect: (item: FilterableListItem) => void;
}

View File

@@ -0,0 +1,6 @@
export * from './ai-item/index.js';
export * from './block-selection.js';
export * from './block-zero-width.js';
export * from './file-drop-manager.js';
export * from './menu-divider.js';
export { scrollbarStyle } from './utils.js';

View File

@@ -0,0 +1,103 @@
import { BLOCK_ID_ATTR } from '@blocksuite/block-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,50 @@
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
// FIXME: horizontal
export class MenuDivider extends LitElement {
static override styles = css`
:host {
display: inline-block;
}
.divider {
background-color: var(--affine-border-color);
}
.divider.vertical {
width: 1px;
height: 100%;
margin: 0 var(--divider-margin);
}
.divider.horizontal {
width: 100%;
height: 1px;
margin: var(--divider-margin) 0;
}
`;
override render() {
const dividerStyles = styleMap({
'--divider-margin': `${this.dividerMargin}px`,
});
return html`<div
class="divider ${this.vertical ? 'vertical' : 'horizontal'}"
style=${dividerStyles}
></div>`;
}
@property({ attribute: false })
accessor dividerMargin = 7;
@property({ attribute: false })
accessor vertical = false;
}
declare global {
interface HTMLElementTagNameMap {
'menu-divider': MenuDivider;
}
}

View File

@@ -0,0 +1,184 @@
import { getFigmaSquircleSvgPath } from '@blocksuite/global/utils';
import { css, html, LitElement, svg, type TemplateResult } from 'lit';
import { property, state } from 'lit/decorators.js';
/**
* ### A component to use figma 'smoothing radius'
*
* ```html
* <smooth-corner
* .borderRadius=${10}
* .smooth=${0.5}
* .borderWidth=${2}
* .bgColor=${'white'}
* style="filter: drop-shadow(0px 0px 10px rgba(0, 0, 0, 0.1));"
* >
* <h1>Smooth Corner</h1>
* </smooth-corner>
* ```
*
* **Just wrap your content with it.**
* - There is a ResizeObserver inside to observe the size of the content.
* - In order to use both border and shadow, we use svg to draw.
* - So we need to use `stroke` and `drop-shadow` to replace `border` and `box-shadow`.
*
* #### required properties
* - `borderRadius`: Equal to the border-radius
* - `smooth`: From 0 to 1, refer to the figma smoothing radius
*
* #### customizable style properties
* Provides some commonly used styles, dealing with their mapping with SVG attributes, such as:
* - `borderWidth` (stroke-width)
* - `borderColor` (stroke)
* - `bgColor` (fill)
* - `bgOpacity` (fill-opacity)
*
* #### More customization
* Use css to customize this component, such as drop-shadow:
* ```css
* smooth-corner {
* filter: drop-shadow(0px 0px 10px rgba(0, 0, 0, 0.1));
* }
* ```
*/
export class SmoothCorner extends LitElement {
static override styles = css`
:host {
position: relative;
}
.smooth-corner-bg,
.smooth-corner-border {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
.smooth-corner-border {
z-index: 2;
}
.smooth-corner-content {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
}
`;
private _resizeObserver: ResizeObserver | null = null;
get _path() {
return getFigmaSquircleSvgPath({
width: this.width,
height: this.height,
cornerRadius: this.borderRadius, // defaults to 0
cornerSmoothing: this.smooth, // cornerSmoothing goes from 0 to 1
});
}
constructor() {
super();
this._resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
this.width = entry.contentRect.width;
this.height = entry.contentRect.height;
}
});
}
private _getSvg(className: string, path: TemplateResult) {
return svg`<svg
class="${className}"
width=${this.width + this.borderWidth}
height=${this.height + this.borderWidth}
viewBox="0 0 ${this.width + this.borderWidth} ${
this.height + this.borderWidth
}"
xmlns="http://www.w3.org/2000/svg"
>
${path}
</svg>`;
}
override connectedCallback(): void {
super.connectedCallback();
this._resizeObserver?.observe(this);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this._resizeObserver?.unobserve(this);
}
override render() {
return html`${this._getSvg(
'smooth-corner-bg',
svg`<path
d="${this._path}"
fill="${this.bgColor}"
fill-opacity="${this.bgOpacity}"
transform="translate(${this.borderWidth / 2} ${this.borderWidth / 2})"
>`
)}
${this._getSvg(
'smooth-corner-border',
svg`<path
fill="none"
d="${this._path}"
stroke="${this.borderColor}"
stroke-width="${this.borderWidth}"
transform="translate(${this.borderWidth / 2} ${this.borderWidth / 2})"
>`
)}
<div class="smooth-corner-content">
<slot></slot>
</div>`;
}
/**
* Background color of the element
*/
@property({ type: String })
accessor bgColor: string = 'white';
/**
* Background opacity of the element
*/
@property({ type: Number })
accessor bgOpacity: number = 1;
/**
* Border color of the element
*/
@property({ type: String })
accessor borderColor: string = 'black';
/**
* Equal to the border-radius
*/
@property({ type: Number })
accessor borderRadius = 0;
/**
* Border width of the element in px
*/
@property({ type: Number })
accessor borderWidth: number = 2;
@state()
accessor height: number = 0;
/**
* From 0 to 1
*/
@property({ type: Number })
accessor smooth: number = 0;
@state()
accessor width: number = 0;
}
declare global {
interface HTMLElementTagNameMap {
'smooth-corner': SmoothCorner;
}
}

View File

@@ -0,0 +1,89 @@
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
const styles = css`
:host {
display: flex;
}
.switch {
height: 0;
width: 0;
visibility: hidden;
margin: 0;
}
label {
cursor: pointer;
text-indent: -9999px;
width: 38px;
height: 20px;
background: var(--affine-icon-color);
border: 1px solid var(--affine-black-10);
display: block;
border-radius: 20px;
position: relative;
}
label:after {
content: '';
position: absolute;
top: 1px;
left: 1px;
width: 16px;
height: 16px;
background: var(--affine-white);
border: 1px solid var(--affine-black-10);
border-radius: 16px;
transition: 0.1s;
}
label.on {
background: var(--affine-primary-color);
}
label.on:after {
left: calc(100% - 1px);
transform: translateX(-100%);
}
label:active:after {
width: 24px;
}
`;
export class ToggleSwitch extends LitElement {
static override styles = styles;
private _toggleSwitch() {
this.on = !this.on;
if (this.onChange) {
this.onChange(this.on);
}
}
override render() {
return html`
<label class=${this.on ? 'on' : ''}>
<input
type="checkbox"
class="switch"
?checked=${this.on}
@change=${this._toggleSwitch}
/>
</label>
`;
}
@property({ attribute: false })
accessor on = false;
@property({ attribute: false })
accessor onChange: ((on: boolean) => void) | undefined = undefined;
}
declare global {
interface HTMLElementTagNameMap {
'toggle-switch': ToggleSwitch;
}
}

View File

@@ -0,0 +1,256 @@
import type { AffineInlineEditor } from '@blocksuite/affine-components/rich-text';
import { getInlineEditorByModel } from '@blocksuite/affine-components/rich-text';
import {
getCurrentNativeRange,
isControlledKeyboardEvent,
} from '@blocksuite/affine-shared/utils';
import type { EditorHost } from '@blocksuite/block-std';
import type { InlineEditor, InlineRange } from '@blocksuite/inline';
import { BlockModel } from '@blocksuite/store';
import { css, unsafeCSS } from 'lit';
export function getQuery(
inlineEditor: InlineEditor,
startRange: InlineRange | null
) {
const nativeRange = getCurrentNativeRange();
if (!nativeRange) {
return null;
}
if (nativeRange.startContainer !== nativeRange.endContainer) {
return null;
}
const curRange = inlineEditor.getInlineRange();
if (!startRange || !curRange) {
return null;
}
if (curRange.index < startRange.index) {
return null;
}
const text = inlineEditor.yText.toString();
return text.slice(startRange.index, curRange.index);
}
interface ObserverParams {
target: HTMLElement;
signal: AbortSignal;
onInput?: (isComposition: boolean) => void;
onDelete?: () => void;
onMove?: (step: 1 | -1) => void;
onConfirm?: () => void;
onAbort?: () => void;
onPaste?: () => void;
interceptor?: (e: KeyboardEvent, next: () => void) => void;
}
export const createKeydownObserver = ({
target,
signal,
onInput,
onDelete,
onMove,
onConfirm,
onAbort,
onPaste,
interceptor = (_, next) => next(),
}: ObserverParams) => {
const keyDownListener = (e: KeyboardEvent) => {
if (e.key === 'Process' || e.isComposing) return;
if (e.defaultPrevented) return;
if (isControlledKeyboardEvent(e)) {
const isOnlyCmd = (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey;
// Ctrl/Cmd + alphabet key
if (isOnlyCmd && e.key.length === 1) {
switch (e.key) {
// Previous command
case 'p': {
onMove?.(-1);
e.stopPropagation();
e.preventDefault();
return;
}
// Next command
case 'n': {
onMove?.(1);
e.stopPropagation();
e.preventDefault();
return;
}
// Paste command
case 'v': {
onPaste?.();
return;
}
}
}
// Pressing **only** modifier key is allowed and will be ignored
// Because we don't know the user's intention
// Aborting here will cause the above hotkeys to not work
if (e.key === 'Control' || e.key === 'Meta' || e.key === 'Alt') {
e.stopPropagation();
return;
}
// Abort when press modifier key + any other key to avoid weird behavior
// e.g. press ctrl + a to select all
onAbort?.();
return;
}
e.stopPropagation();
if (
// input abc, 123, etc.
!isControlledKeyboardEvent(e) &&
e.key.length === 1
) {
onInput?.(false);
return;
}
switch (e.key) {
case 'Backspace': {
onDelete?.();
return;
}
case 'Enter': {
if (e.shiftKey) {
onAbort?.();
return;
}
onConfirm?.();
e.preventDefault();
return;
}
case 'Tab': {
if (e.shiftKey) {
onMove?.(-1);
} else {
onMove?.(1);
}
e.preventDefault();
return;
}
case 'ArrowUp': {
if (e.shiftKey) {
onAbort?.();
return;
}
onMove?.(-1);
e.preventDefault();
return;
}
case 'ArrowDown': {
if (e.shiftKey) {
onAbort?.();
return;
}
onMove?.(1);
e.preventDefault();
return;
}
case 'Escape':
case 'ArrowLeft':
case 'ArrowRight': {
onAbort?.();
return;
}
default:
// Other control keys
return;
}
};
target.addEventListener(
'keydown',
(e: KeyboardEvent) => interceptor(e, () => keyDownListener(e)),
{
// Workaround: Use capture to prevent the event from triggering the keyboard bindings action
capture: true,
signal,
}
);
// Fix paste input
target.addEventListener('paste', () => onDelete?.(), { signal });
// Fix composition input
target.addEventListener('compositionend', () => onInput?.(true), { signal });
};
/**
* Remove specified text from the current range.
*/
export function cleanSpecifiedTail(
editorHost: EditorHost,
inlineEditorOrModel: AffineInlineEditor | BlockModel,
str: string
) {
if (!str) {
console.warn('Failed to clean text! Unexpected empty string');
return;
}
const inlineEditor =
inlineEditorOrModel instanceof BlockModel
? getInlineEditorByModel(editorHost, inlineEditorOrModel)
: inlineEditorOrModel;
if (!inlineEditor) {
return;
}
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) {
return;
}
const idx = inlineRange.index - str.length;
const textStr = inlineEditor.yText.toString().slice(idx, idx + str.length);
if (textStr !== str) {
console.warn(
`Failed to clean text! Text mismatch expected: ${str} but actual: ${textStr}`
);
return;
}
inlineEditor.deleteText({ index: idx, length: str.length });
inlineEditor.setInlineRange({
index: idx,
length: 0,
});
}
/**
* You should add a container before the scrollbar style to prevent the style pollution of the whole doc.
*/
export const scrollbarStyle = (container: string) => {
if (!container) {
console.error(
'To prevent style pollution of the whole doc, you must add a container before the scrollbar style.'
);
return css``;
}
// sanitize container name
if (container.includes('{') || container.includes('}')) {
console.error('Invalid container name! Please use a valid CSS selector.');
return css``;
}
return css`
${unsafeCSS(container)} {
scrollbar-gutter: stable;
}
${unsafeCSS(container)}::-webkit-scrollbar {
-webkit-appearance: none;
width: 4px;
height: 4px;
}
${unsafeCSS(container)}::-webkit-scrollbar-thumb {
border-radius: 2px;
background-color: #b1b1b1;
}
${unsafeCSS(container)}::-webkit-scrollbar-corner {
display: none;
}
`;
};

View File

@@ -0,0 +1,125 @@
import type { BlockSelection, BlockStdScope } from '@blocksuite/block-std';
const getSelection = (std: BlockStdScope) => std.selection;
function getBlockSelectionBySide(std: BlockStdScope, tail: boolean) {
const selection = getSelection(std);
const selections = selection.filter('block');
const sel = selections.at(tail ? -1 : 0) as BlockSelection | undefined;
return sel ?? null;
}
function getTextSelection(std: BlockStdScope) {
const selection = getSelection(std);
return selection.find('text');
}
const pathToBlock = (std: BlockStdScope, blockId: string) =>
std.view.getBlock(blockId);
interface MoveBlockConfig {
name: string;
hotkey: string[];
action: (std: BlockStdScope) => void;
}
export const moveBlockConfigs: MoveBlockConfig[] = [
{
name: 'Move Up',
hotkey: ['Mod-Alt-ArrowUp', 'Mod-Shift-ArrowUp'],
action: std => {
const doc = std.doc;
const textSelection = getTextSelection(std);
if (textSelection) {
const currentModel = pathToBlock(
std,
textSelection.from.blockId
)?.model;
if (!currentModel) return;
const previousSiblingModel = doc.getPrev(currentModel);
if (!previousSiblingModel) return;
const parentModel = std.doc.getParent(previousSiblingModel);
if (!parentModel) return;
std.doc.moveBlocks(
[currentModel],
parentModel,
previousSiblingModel,
true
);
std.host.updateComplete
.then(() => {
std.range.syncTextSelectionToRange(textSelection);
})
.catch(console.error);
return true;
}
const blockSelection = getBlockSelectionBySide(std, true);
if (blockSelection) {
const currentModel = pathToBlock(std, blockSelection.blockId)?.model;
if (!currentModel) return;
const previousSiblingModel = doc.getPrev(currentModel);
if (!previousSiblingModel) return;
const parentModel = doc.getParent(previousSiblingModel);
if (!parentModel) return;
doc.moveBlocks(
[currentModel],
parentModel,
previousSiblingModel,
false
);
return true;
}
return;
},
},
{
name: 'Move Down',
hotkey: ['Mod-Alt-ArrowDown', 'Mod-Shift-ArrowDown'],
action: std => {
const doc = std.doc;
const textSelection = getTextSelection(std);
if (textSelection) {
const currentModel = pathToBlock(
std,
textSelection.from.blockId
)?.model;
if (!currentModel) return;
const nextSiblingModel = doc.getNext(currentModel);
if (!nextSiblingModel) return;
const parentModel = doc.getParent(nextSiblingModel);
if (!parentModel) return;
doc.moveBlocks([currentModel], parentModel, nextSiblingModel, false);
std.host.updateComplete
.then(() => {
std.range.syncTextSelectionToRange(textSelection);
})
.catch(console.error);
return true;
}
const blockSelection = getBlockSelectionBySide(std, true);
if (blockSelection) {
const currentModel = pathToBlock(std, blockSelection.blockId)?.model;
if (!currentModel) return;
const nextSiblingModel = doc.getNext(currentModel);
if (!nextSiblingModel) return;
const parentModel = doc.getParent(nextSiblingModel);
if (!parentModel) return;
doc.moveBlocks([currentModel], parentModel, nextSiblingModel, false);
return true;
}
return;
},
},
];

View File

@@ -0,0 +1,152 @@
import {
CopyIcon,
DatabaseTableViewIcon20,
LinkedDocIcon,
} from '@blocksuite/affine-components/icons';
import { toast } from '@blocksuite/affine-components/toast';
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import type { EditorHost } from '@blocksuite/block-std';
import { tableViewMeta } from '@blocksuite/data-view/view-presets';
import { assertExists } from '@blocksuite/global/utils';
import type { TemplateResult } from 'lit';
import { convertToDatabase } from '../../../database-block/data-source.js';
import { DATABASE_CONVERT_WHITE_LIST } from '../../../database-block/utils/block-utils.js';
import {
convertSelectedBlocksToLinkedDoc,
getTitleFromSelectedModels,
notifyDocCreated,
promptDocTitle,
} from '../../utils/render-linked-doc.js';
export interface QuickActionConfig {
id: string;
name: string;
disabledToolTip?: string;
icon: TemplateResult<1>;
hotkey?: string;
showWhen: (host: EditorHost) => boolean;
enabledWhen: (host: EditorHost) => boolean;
action: (host: EditorHost) => void;
}
export const quickActionConfig: QuickActionConfig[] = [
{
id: 'copy',
name: 'Copy',
disabledToolTip: undefined,
icon: CopyIcon,
hotkey: undefined,
showWhen: () => true,
enabledWhen: () => true,
action: host => {
host.std.command
.chain()
.getSelectedModels()
.with({
onCopy: () => {
toast(host, 'Copied to clipboard');
},
})
.draftSelectedModels()
.copySelectedModels()
.run();
},
},
{
id: 'convert-to-database',
name: 'Group as Table',
disabledToolTip:
'Contains Block types that cannot be converted to Database',
icon: DatabaseTableViewIcon20,
showWhen: host => {
const [_, ctx] = host.std.command
.chain()
.getSelectedModels({
types: ['block', 'text'],
})
.run();
const { selectedModels } = ctx;
if (!selectedModels || selectedModels.length === 0) return false;
const firstBlock = selectedModels[0];
assertExists(firstBlock);
if (matchFlavours(firstBlock, ['affine:database'])) {
return false;
}
return true;
},
enabledWhen: host => {
const [_, ctx] = host.std.command
.chain()
.getSelectedModels({
types: ['block', 'text'],
})
.run();
const { selectedModels } = ctx;
if (!selectedModels || selectedModels.length === 0) return false;
return selectedModels.every(block =>
DATABASE_CONVERT_WHITE_LIST.includes(block.flavour)
);
},
action: host => {
convertToDatabase(host, tableViewMeta.type);
},
},
{
id: 'convert-to-linked-doc',
name: 'Create Linked Doc',
icon: LinkedDocIcon,
hotkey: `Mod-Shift-l`,
showWhen: host => {
const [_, ctx] = host.std.command
.chain()
.getSelectedModels({
types: ['block'],
})
.run();
const { selectedModels } = ctx;
return !!selectedModels && selectedModels.length > 0;
},
enabledWhen: host => {
const [_, ctx] = host.std.command
.chain()
.getSelectedModels({
types: ['block'],
})
.run();
const { selectedModels } = ctx;
return !!selectedModels && selectedModels.length > 0;
},
action: host => {
const [_, ctx] = host.std.command
.chain()
.getSelectedModels({
types: ['block'],
mode: 'highest',
})
.draftSelectedModels()
.run();
const { selectedModels, draftedModels } = ctx;
assertExists(selectedModels);
if (!selectedModels.length || !draftedModels) return;
host.selection.clear();
const doc = host.doc;
const autofill = getTitleFromSelectedModels(selectedModels);
void promptDocTitle(host, autofill).then(title => {
if (title === null) return;
convertSelectedBlocksToLinkedDoc(
host.std,
doc,
draftedModels,
title
).catch(console.error);
notifyDocCreated(host, doc);
});
},
},
];

View File

@@ -0,0 +1,136 @@
import {
BulletedListIcon,
CheckBoxIcon,
CodeBlockIcon,
DividerIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
NumberedListIcon,
QuoteIcon,
TextIcon,
} from '@blocksuite/affine-components/icons';
import type { TemplateResult } from 'lit';
/**
* Text primitive entries used in slash menu and format bar,
* which are also used for registering hotkeys for converting block flavours.
*/
export interface TextConversionConfig {
flavour: BlockSuite.Flavour;
type?: string;
name: string;
description?: string;
hotkey: string[] | null;
icon: TemplateResult<1>;
}
export const textConversionConfigs: TextConversionConfig[] = [
{
flavour: 'affine:paragraph',
type: 'text',
name: 'Text',
description: 'Start typing with plain text.',
hotkey: [`Mod-Alt-0`, `Mod-Shift-0`],
icon: TextIcon,
},
{
flavour: 'affine:paragraph',
type: 'h1',
name: 'Heading 1',
description: 'Headings in the largest font.',
hotkey: [`Mod-Alt-1`, `Mod-Shift-1`],
icon: Heading1Icon,
},
{
flavour: 'affine:paragraph',
type: 'h2',
name: 'Heading 2',
description: 'Headings in the 2nd font size.',
hotkey: [`Mod-Alt-2`, `Mod-Shift-2`],
icon: Heading2Icon,
},
{
flavour: 'affine:paragraph',
type: 'h3',
name: 'Heading 3',
description: 'Headings in the 3rd font size.',
hotkey: [`Mod-Alt-3`, `Mod-Shift-3`],
icon: Heading3Icon,
},
{
flavour: 'affine:paragraph',
type: 'h4',
name: 'Heading 4',
description: 'Headings in the 4th font size.',
hotkey: [`Mod-Alt-4`, `Mod-Shift-4`],
icon: Heading4Icon,
},
{
flavour: 'affine:paragraph',
type: 'h5',
name: 'Heading 5',
description: 'Headings in the 5th font size.',
hotkey: [`Mod-Alt-5`, `Mod-Shift-5`],
icon: Heading5Icon,
},
{
flavour: 'affine:paragraph',
type: 'h6',
name: 'Heading 6',
description: 'Headings in the 6th font size.',
hotkey: [`Mod-Alt-6`, `Mod-Shift-6`],
icon: Heading6Icon,
},
{
flavour: 'affine:list',
type: 'bulleted',
name: 'Bulleted List',
description: 'Create a bulleted list.',
hotkey: [`Mod-Alt-8`, `Mod-Shift-8`],
icon: BulletedListIcon,
},
{
flavour: 'affine:list',
type: 'numbered',
name: 'Numbered List',
description: 'Create a numbered list.',
hotkey: [`Mod-Alt-9`, `Mod-Shift-9`],
icon: NumberedListIcon,
},
{
flavour: 'affine:list',
type: 'todo',
name: 'To-do List',
description: 'Add tasks to a to-do list.',
hotkey: null,
icon: CheckBoxIcon,
},
{
flavour: 'affine:code',
type: undefined,
name: 'Code Block',
description: 'Code snippet with formatting.',
hotkey: [`Mod-Alt-c`],
icon: CodeBlockIcon,
},
{
flavour: 'affine:paragraph',
type: 'quote',
name: 'Quote',
description: 'Add a blockquote for emphasis.',
hotkey: null,
icon: QuoteIcon,
},
{
flavour: 'affine:divider',
type: 'divider',
name: 'Divider',
description: 'Visually separate content.',
hotkey: [`Mod-Alt-d`, `Mod-Shift-d`],
icon: DividerIcon,
},
];

View File

@@ -0,0 +1 @@
export * from '@blocksuite/affine-shared/consts';

View File

@@ -0,0 +1 @@
export type NavigatorMode = 'fill' | 'fit';

View File

@@ -0,0 +1,68 @@
import { MindmapElementModel } from '@blocksuite/affine-model';
import type { Viewport } from '@blocksuite/block-std/gfx';
export function isMindmapNode(el: BlockSuite.EdgelessModel) {
return (
el.group instanceof MindmapElementModel || el instanceof MindmapElementModel
);
}
export function isSingleMindMapNode(els: BlockSuite.EdgelessModel[]) {
return els.length === 1 && els[0].group instanceof MindmapElementModel;
}
export function isElementOutsideViewport(
viewport: Viewport,
element: BlockSuite.EdgelessModel,
padding: [number, number] = [0, 0]
) {
const elementBound = element.elementBound;
padding[0] /= viewport.zoom;
padding[1] /= viewport.zoom;
elementBound.x -= padding[1];
elementBound.w += padding[1];
elementBound.y -= padding[0];
elementBound.h += padding[0];
return !viewport.viewportBounds.contains(elementBound);
}
export function getNearestTranslation(
viewport: Viewport,
element: BlockSuite.EdgelessModel,
padding: [number, number] = [0, 0]
) {
const viewportBound = viewport.viewportBounds;
const elementBound = element.elementBound;
let dx = 0;
let dy = 0;
if (elementBound.x - padding[1] < viewportBound.x) {
dx = viewportBound.x - (elementBound.x - padding[1]);
} else if (
elementBound.x + elementBound.w + padding[1] >
viewportBound.x + viewportBound.w
) {
dx =
viewportBound.x +
viewportBound.w -
(elementBound.x + elementBound.w + padding[1]);
}
if (elementBound.y - padding[0] < viewportBound.y) {
dy = elementBound.y - padding[0] - viewportBound.y;
} else if (
elementBound.y + elementBound.h + padding[0] >
viewportBound.y + viewportBound.h
) {
dy =
elementBound.y +
elementBound.h +
padding[0] -
(viewportBound.y + viewportBound.h);
}
return [dx, dy];
}

View File

@@ -0,0 +1,590 @@
import {
type CanvasRenderer,
SurfaceElementModel,
} from '@blocksuite/affine-block-surface';
import {
GroupElementModel,
type RootBlockModel,
} from '@blocksuite/affine-model';
import { FetchUtils } from '@blocksuite/affine-shared/adapters';
import {
CANVAS_EXPORT_IGNORE_TAGS,
DEFAULT_IMAGE_PROXY_ENDPOINT,
} from '@blocksuite/affine-shared/consts';
import {
isInsidePageEditor,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import {
type BlockStdScope,
type EditorHost,
type ExtensionType,
StdIdentifier,
} from '@blocksuite/block-std';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { IBound } from '@blocksuite/global/utils';
import { Bound } from '@blocksuite/global/utils';
import type { Doc } from '@blocksuite/store';
import {
getBlockComponentByModel,
getRootByEditorHost,
} from '../../_common/utils/index.js';
import type { GfxBlockModel } from '../../root-block/edgeless/block-model.js';
import type { EdgelessRootBlockComponent } from '../../root-block/edgeless/edgeless-root-block.js';
import { getBlocksInFrameBound } from '../../root-block/edgeless/frame-manager.js';
import { xywhArrayToObject } from '../../root-block/edgeless/utils/convert.js';
import { getBackgroundGrid } from '../../root-block/edgeless/utils/query.js';
import { FileExporter } from './file-exporter.js';
// eslint-disable-next-line
type Html2CanvasFunction = typeof import('html2canvas').default;
export type ExportOptions = {
imageProxyEndpoint: string;
};
export class ExportManager {
private _exportOptions: ExportOptions = {
imageProxyEndpoint: DEFAULT_IMAGE_PROXY_ENDPOINT,
};
private _replaceRichTextWithSvgElement = (element: HTMLElement) => {
const richList = Array.from(element.querySelectorAll('.inline-editor'));
richList.forEach(rich => {
const svgEle = this._elementToSvgElement(
rich.cloneNode(true) as HTMLElement,
rich.clientWidth,
rich.clientHeight + 1
);
rich.parentElement?.append(svgEle);
rich.remove();
});
};
replaceImgSrcWithSvg = async (element: HTMLElement) => {
const imgList = Array.from(element.querySelectorAll('img'));
// Create an array of promises
const promises = imgList.map(img => {
return FetchUtils.fetchImage(
img.src,
undefined,
this._exportOptions.imageProxyEndpoint
)
.then(response => response && response.blob())
.then(async blob => {
if (!blob) return;
// If the file type is SVG, set svg width and height
if (blob.type === 'image/svg+xml') {
// Parse the SVG
const parser = new DOMParser();
const svgDoc = parser.parseFromString(
await blob.text(),
'image/svg+xml'
);
const svgElement =
svgDoc.documentElement as unknown as SVGSVGElement;
// Check if the SVG has width and height attributes
if (
!svgElement.hasAttribute('width') &&
!svgElement.hasAttribute('height')
) {
// Get the viewBox
const viewBox = svgElement.viewBox.baseVal;
// Set the SVG width and height
svgElement.setAttribute('width', `${viewBox.width}px`);
svgElement.setAttribute('height', `${viewBox.height}px`);
}
// Replace the img src with the modified SVG
const serializer = new XMLSerializer();
const newSvgStr = serializer.serializeToString(svgElement);
img.src =
'data:image/svg+xml;charset=utf-8,' +
encodeURIComponent(newSvgStr);
}
});
});
// Wait for all promises to resolve
await Promise.all(promises);
};
get doc(): Doc {
return this.std.doc;
}
get editorHost(): EditorHost {
return this.std.host;
}
constructor(readonly std: BlockStdScope) {}
private _checkCanContinueToCanvas(pathName: string, editorMode: boolean) {
if (
location.pathname !== pathName ||
isInsidePageEditor(this.editorHost) !== editorMode
) {
throw new BlockSuiteError(
ErrorCode.EdgelessExportError,
'Unable to export content to canvas'
);
}
}
private async _checkReady() {
const pathname = location.pathname;
const editorMode = isInsidePageEditor(this.editorHost);
const promise = new Promise((resolve, reject) => {
let count = 0;
const checkReactRender = setInterval(() => {
try {
this._checkCanContinueToCanvas(pathname, editorMode);
} catch (e) {
clearInterval(checkReactRender);
reject(e);
}
const rootModel = this.doc.root;
const rootComponent = this.doc.root
? getBlockComponentByModel(this.editorHost, rootModel)
: null;
const imageCard = rootComponent?.querySelector(
'affine-image-fallback-card'
);
const isReady =
!imageCard || imageCard.getAttribute('imageState') === '0';
if (rootComponent && isReady) {
clearInterval(checkReactRender);
resolve(true);
}
count++;
if (count > 10 * 60) {
clearInterval(checkReactRender);
resolve(false);
}
}, 100);
});
return promise;
}
private _createCanvas(bound: IBound, fillStyle: string) {
const canvas = document.createElement('canvas');
const dpr = window.devicePixelRatio || 1;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
canvas.width = (bound.w + 100) * dpr;
canvas.height = (bound.h + 100) * dpr;
ctx.scale(dpr, dpr);
ctx.fillStyle = fillStyle;
ctx.fillRect(0, 0, canvas.width, canvas.height);
return { canvas, ctx };
}
private _disableMediaPrint() {
document.querySelectorAll('.media-print').forEach(mediaPrint => {
mediaPrint.classList.add('hide');
});
}
private async _docToCanvas(): Promise<HTMLCanvasElement | void> {
const html2canvas = (await import('html2canvas')).default;
if (!(html2canvas instanceof Function)) return;
const pathname = location.pathname;
const editorMode = isInsidePageEditor(this.editorHost);
const rootComponent = getRootByEditorHost(this.editorHost);
if (!rootComponent) return;
const viewportElement = rootComponent.viewportElement;
if (!viewportElement) return;
const pageContainer = viewportElement.querySelector(
'.affine-page-root-block-container'
);
const rect = pageContainer?.getBoundingClientRect();
const { viewport } = rootComponent;
if (!viewport) return;
const pageWidth = rect?.width;
const pageLeft = rect?.left ?? 0;
const viewportHeight = viewportElement?.scrollHeight;
const html2canvasOption = {
ignoreElements: function (element: Element) {
if (
CANVAS_EXPORT_IGNORE_TAGS.includes(element.tagName) ||
element.classList.contains('dg')
) {
return true;
} else if (
(element.classList.contains('close') &&
element.parentElement?.classList.contains(
'meta-data-expanded-title'
)) ||
(element.classList.contains('expand') &&
element.parentElement?.classList.contains('meta-data'))
) {
// the close and expand buttons in affine-doc-meta-data is not needed to be showed
return true;
} else {
return false;
}
},
onclone: async (_documentClone: Document, element: HTMLElement) => {
element.style.height = `${viewportHeight}px`;
this._replaceRichTextWithSvgElement(element);
await this.replaceImgSrcWithSvg(element);
},
backgroundColor: window.getComputedStyle(viewportElement).backgroundColor,
x: pageLeft - viewport.left,
width: pageWidth,
height: viewportHeight,
useCORS: this._exportOptions.imageProxyEndpoint ? false : true,
proxy: this._exportOptions.imageProxyEndpoint,
};
let data: HTMLCanvasElement;
try {
this._enableMediaPrint();
data = await html2canvas(
viewportElement as HTMLElement,
html2canvasOption
);
} finally {
this._disableMediaPrint();
}
this._checkCanContinueToCanvas(pathname, editorMode);
return data;
}
private _drawEdgelessBackground(
ctx: CanvasRenderingContext2D,
{
size,
backgroundColor,
gridColor,
}: {
size: number;
backgroundColor: string;
gridColor: string;
}
) {
const svgImg = `<svg width='${ctx.canvas.width}px' height='${ctx.canvas.height}px' xmlns='http://www.w3.org/2000/svg' style='background-size:${size}px ${size}px;background-color:${backgroundColor}; background-image: radial-gradient(${gridColor} 1px, ${backgroundColor} 1px)'></svg>`;
const img = new Image();
const cleanup = () => {
img.onload = null;
img.onerror = null;
};
return new Promise<void>((resolve, reject) => {
img.onload = () => {
cleanup();
ctx.drawImage(img, 0, 0);
resolve();
};
img.onerror = e => {
cleanup();
reject(e);
};
img.src = `data:image/svg+xml,${encodeURIComponent(svgImg)}`;
});
}
private _elementToSvgElement(
node: HTMLElement,
width: number,
height: number
) {
const xmlns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(xmlns, 'svg');
const foreignObject = document.createElementNS(xmlns, 'foreignObject');
svg.setAttribute('width', `${width}`);
svg.setAttribute('height', `${height}`);
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
foreignObject.setAttribute('width', '100%');
foreignObject.setAttribute('height', '100%');
foreignObject.setAttribute('x', '0');
foreignObject.setAttribute('y', '0');
foreignObject.setAttribute('externalResourcesRequired', 'true');
svg.append(foreignObject);
foreignObject.append(node);
return svg;
}
private _enableMediaPrint() {
document.querySelectorAll('.media-print').forEach(mediaPrint => {
mediaPrint.classList.remove('hide');
});
}
private async _html2canvas(
htmlElement: HTMLElement,
options: Parameters<Html2CanvasFunction>[1] = {}
) {
const html2canvas = (await import('html2canvas'))
.default as unknown as Html2CanvasFunction;
const html2canvasOption = {
ignoreElements: function (element: Element) {
if (
CANVAS_EXPORT_IGNORE_TAGS.includes(element.tagName) ||
element.classList.contains('dg')
) {
return true;
} else {
return false;
}
},
onclone: async (documentClone: Document, element: HTMLElement) => {
// html2canvas can't support transform feature
element.style.setProperty('transform', 'none');
const layer = element.classList.contains('.affine-edgeless-layer')
? element
: null;
if (layer instanceof HTMLElement) {
layer.style.setProperty('transform', 'none');
}
const boxShadowEles = documentClone.querySelectorAll(
"[style*='box-shadow']"
);
boxShadowEles.forEach(function (element) {
if (element instanceof HTMLElement) {
element.style.setProperty('box-shadow', 'none');
}
});
this._replaceRichTextWithSvgElement(element);
await this.replaceImgSrcWithSvg(element);
},
useCORS: this._exportOptions.imageProxyEndpoint ? false : true,
proxy: this._exportOptions.imageProxyEndpoint,
};
let data: HTMLCanvasElement;
try {
this._enableMediaPrint();
data = await html2canvas(
htmlElement,
Object.assign(html2canvasOption, options)
);
} finally {
this._disableMediaPrint();
}
return data;
}
private async _toCanvas(): Promise<HTMLCanvasElement | void> {
try {
await this._checkReady();
} catch (e: unknown) {
console.error('Failed to export to canvas');
console.error(e);
return;
}
if (isInsidePageEditor(this.editorHost)) {
return this._docToCanvas();
} else {
const rootModel = this.doc.root;
if (!rootModel) return;
const edgeless = getBlockComponentByModel(
this.editorHost,
rootModel
) as EdgelessRootBlockComponent;
const bound = edgeless.gfx.elementsBound;
return this.edgelessToCanvas(edgeless.surface.renderer, bound, edgeless);
}
}
// TODO: refactor of this part
async edgelessToCanvas(
surfaceRenderer: CanvasRenderer,
bound: IBound,
edgeless?: EdgelessRootBlockComponent,
nodes?: GfxBlockModel[],
surfaces?: BlockSuite.SurfaceElementModel[],
edgelessBackground?: {
zoom: number;
}
): Promise<HTMLCanvasElement | undefined> {
const rootModel = this.doc.root;
if (!rootModel) return;
const pathname = location.pathname;
const editorMode = isInsidePageEditor(this.editorHost);
const rootComponent = getRootByEditorHost(this.editorHost);
if (!rootComponent) return;
const viewportElement = rootComponent.viewportElement;
if (!viewportElement) return;
const containerComputedStyle = window.getComputedStyle(viewportElement);
const html2canvas = (element: HTMLElement) =>
this._html2canvas(element, {
backgroundColor: containerComputedStyle.backgroundColor,
});
const container = rootComponent.querySelector(
'.affine-block-children-container'
);
if (!container) return;
const { ctx, canvas } = this._createCanvas(
bound,
window.getComputedStyle(container).backgroundColor
);
if (edgelessBackground) {
await this._drawEdgelessBackground(ctx, {
backgroundColor: containerComputedStyle.getPropertyValue(
'--affine-background-primary-color'
),
size: getBackgroundGrid(edgelessBackground.zoom, true).gap,
gridColor: containerComputedStyle.getPropertyValue(
'--affine-edgeless-grid-color'
),
});
}
const blocks =
nodes ??
edgeless?.service.gfx.getElementsByBound(bound, { type: 'block' }) ??
[];
for (const block of blocks) {
if (matchFlavours(block, ['affine:image'])) {
if (!block.sourceId) return;
const blob = await block.doc.blobSync.get(block.sourceId);
if (!blob) return;
const blobToImage = (blob: Blob) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = URL.createObjectURL(blob);
});
const blockBound = xywhArrayToObject(block);
ctx.drawImage(
await blobToImage(blob),
blockBound.x - bound.x,
blockBound.y - bound.y,
blockBound.w,
blockBound.h
);
}
const blockComponent = this.editorHost.view.getBlock(block.id);
if (blockComponent) {
const blockBound = xywhArrayToObject(block);
const canvasData = await this._html2canvas(
blockComponent as HTMLElement
);
ctx.drawImage(
canvasData,
blockBound.x - bound.x + 50,
blockBound.y - bound.y + 50,
blockBound.w,
blockBound.h
);
}
if (matchFlavours(block, ['affine:frame'])) {
// TODO(@L-Sun): use children of frame instead of bound
const blocksInsideFrame = getBlocksInFrameBound(this.doc, block, false);
const frameBound = Bound.deserialize(block.xywh);
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < blocksInsideFrame.length; i++) {
const element = blocksInsideFrame[i];
const htmlElement = this.editorHost.view.getBlock(block.id);
const blockBound = xywhArrayToObject(element);
const canvasData = await html2canvas(htmlElement as HTMLElement);
ctx.drawImage(
canvasData,
blockBound.x - bound.x + 50,
blockBound.y - bound.y + 50,
blockBound.w,
(blockBound.w / canvasData.width) * canvasData.height
);
}
const surfaceCanvas = surfaceRenderer.getCanvasByBound(frameBound);
ctx.drawImage(surfaceCanvas, 50, 50, frameBound.w, frameBound.h);
}
this._checkCanContinueToCanvas(pathname, editorMode);
}
if (surfaces?.length) {
const surfaceElements = surfaces.flatMap(element =>
element instanceof GroupElementModel
? (element.childElements.filter(
el => el instanceof SurfaceElementModel
) as SurfaceElementModel[])
: element
);
const surfaceCanvas = surfaceRenderer.getCanvasByBound(
bound,
surfaceElements
);
ctx.drawImage(surfaceCanvas, 50, 50, bound.w, bound.h);
}
return canvas;
}
async exportPdf() {
const rootModel = this.doc.root;
if (!rootModel) return;
const canvasImage = await this._toCanvas();
if (!canvasImage) {
return;
}
const PDFLib = await import('pdf-lib');
const pdfDoc = await PDFLib.PDFDocument.create();
const page = pdfDoc.addPage([canvasImage.width, canvasImage.height]);
const imageEmbed = await pdfDoc.embedPng(canvasImage.toDataURL('PNG'));
const { width, height } = imageEmbed.scale(1);
page.drawImage(imageEmbed, {
x: 0,
y: 0,
width,
height,
});
const pdfBase64 = await pdfDoc.saveAsBase64({ dataUri: true });
FileExporter.exportFile(
(rootModel as RootBlockModel).title.toString() + '.pdf',
pdfBase64
);
}
async exportPng() {
const rootModel = this.doc.root;
if (!rootModel) return;
const canvasImage = await this._toCanvas();
if (!canvasImage) {
return;
}
FileExporter.exportPng(
(this.doc.root as RootBlockModel).title.toString(),
canvasImage.toDataURL('image/png')
);
}
}
export const ExportManagerExtension: ExtensionType = {
setup: di => {
di.add(ExportManager, [StdIdentifier]);
},
};

View File

@@ -0,0 +1,81 @@
/* eslint-disable no-control-regex */
// Context: Lean towards breaking out any localizable content into constants so it's
// easier to track content we may need to localize in the future. (i18n)
const UNTITLED_PAGE_NAME = 'Untitled';
/** Tools for exporting files to device. For example, via browser download. */
export const FileExporter = {
/**
* Create a download for the user's browser.
*
* @param filename
* @param text
* @param mimeType like `"text/plain"`, `"text/html"`, `"application/javascript"`, etc. See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types mdn docs List of MIME types}.
*
* @remarks
* Only accepts data in utf-8 encoding (html files, javascript source, text files, etc).
*
* @example
* const todoMDText = `# Todo items
* [ ] Item 1
* [ ] Item 2
* `
* FileExporter.exportFile("Todo list.md", todoMDText, "text/plain")
*
* @example
* const stateJsonContent = JSON.stringify({ a: 1, b: 2, c: 3 })
* FileExporter.exportFile("state.json", jsonContent, "application/json")
*/
exportFile(filename: string, dataURL: string) {
const element = document.createElement('a');
element.setAttribute('href', dataURL);
const safeFilename = getSafeFileName(filename);
element.setAttribute('download', safeFilename);
element.style.display = 'none';
document.body.append(element);
element.click();
element.remove();
},
exportPng(docTitle: string | undefined, dataURL: string) {
const title = docTitle?.trim() || UNTITLED_PAGE_NAME;
FileExporter.exportFile(title + '.png', dataURL);
},
};
function getSafeFileName(string: string) {
const replacement = ' ';
const filenameReservedRegex = /[<>:"/\\|?*\u0000-\u001F]/g;
const windowsReservedNameRegex = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
const reControlChars = /[\u0000-\u001F\u0080-\u009F]/g;
const reTrailingPeriods = /\.+$/;
const allowedLength = 50;
function trimRepeated(string: string, target: string) {
const escapeStringRegexp = target
.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
.replace(/-/g, '\\x2d');
const regex = new RegExp(`(?:${escapeStringRegexp}){2,}`, 'g');
return string.replace(regex, target);
}
string = string
.normalize('NFD')
.replace(filenameReservedRegex, replacement)
.replace(reControlChars, replacement)
.replace(reTrailingPeriods, '');
string = trimRepeated(string, replacement);
string = windowsReservedNameRegex.test(string)
? string + replacement
: string;
const extIndex = string.lastIndexOf('.');
const filename = string.slice(0, extIndex).trim();
const extension = string.slice(extIndex);
string =
filename.slice(0, Math.max(1, allowedLength - extension.length)) +
extension;
return string;
}

View File

@@ -0,0 +1,50 @@
import type { BlockSnapshot, SliceSnapshot } from '@blocksuite/store';
import {
mergeToCodeModel,
transformModel,
} from '../../root-block/utils/operations/model.js';
class DocTestUtils {
// block model operations (data layer)
mergeToCodeModel = mergeToCodeModel;
transformModel = transformModel;
}
export class TestUtils {
docTestUtils = new DocTestUtils();
}
export function nanoidReplacement(snapshot: BlockSnapshot | SliceSnapshot) {
return JSON.parse(nanoidReplacementString(JSON.stringify(snapshot)));
}
const escapedSnapshotAttributes = new Set([
'"attributes"',
'"conditions"',
'"iconColumn"',
'"background"',
'"LinkedPage"',
'"elementIds"',
]);
function nanoidReplacementString(snapshotString: string) {
const re =
/("block:[A-Za-z0-9-_]{10}")|("[A-Za-z0-9-_]{10}")|("var\(--affine-v2-chip-label-[a-z]{3,10}\)")|("[A-Za-z0-9-_=]{44}")/g;
const matches = snapshotString.matchAll(re);
const matchesReplaceMap = new Map();
let escapedNumber = 0;
Array.from(matches).forEach((match, index) => {
if (escapedSnapshotAttributes.has(match[0])) {
matchesReplaceMap.set(match[0], match[0]);
escapedNumber++;
} else {
matchesReplaceMap.set(
match[0],
`"matchesReplaceMap[${index - escapedNumber}]"`
);
}
});
return snapshotString.replace(re, match => matchesReplaceMap.get(match));
}

View File

@@ -0,0 +1,168 @@
import { sha } from '@blocksuite/global/utils';
import type { Doc, DocCollection } from '@blocksuite/store';
import { extMimeMap, Job } from '@blocksuite/store';
import { HtmlAdapter } from '../adapters/html-adapter/html.js';
import {
defaultImageProxyMiddleware,
docLinkBaseURLMiddleware,
fileNameMiddleware,
titleMiddleware,
} from './middlewares.js';
import { createAssetsArchive, download, Unzip } from './utils.js';
type ImportHTMLToDocOptions = {
collection: DocCollection;
html: string;
fileName?: string;
};
type ImportHTMLZipOptions = {
collection: DocCollection;
imported: Blob;
};
/**
* 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: Doc) {
const job = new Job({
collection: doc.collection,
middlewares: [docLinkBaseURLMiddleware, titleMiddleware],
});
const snapshot = job.docToSnapshot(doc);
const adapter = new HtmlAdapter(job);
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.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,
html,
fileName,
}: ImportHTMLToDocOptions) {
const job = new Job({
collection,
middlewares: [
defaultImageProxyMiddleware,
fileNameMiddleware(fileName),
docLinkBaseURLMiddleware,
],
});
const htmlAdapter = new HtmlAdapter(job);
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.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, imported }: ImportHTMLZipOptions) {
const unzip = new Unzip();
await unzip.load(imported);
const docIds: string[] = [];
const pendingAssets = new Map<string, File>();
const pendingPathBlobIdMap = new Map<string, string>();
const htmlBlobs: [string, Blob][] = [];
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, blob]);
} 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 ([fileName, blob]) => {
const fileNameWithoutExt = fileName.replace(/\.[^/.]+$/, '');
const job = new Job({
collection,
middlewares: [
defaultImageProxyMiddleware,
fileNameMiddleware(fileNameWithoutExt),
docLinkBaseURLMiddleware,
],
});
const assets = job.assets;
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
for (const [key, value] of pendingAssets.entries()) {
assets.set(key, value);
}
for (const [key, value] of pendingPathBlobIdMap.entries()) {
pathBlobIdMap.set(key, value);
}
const htmlAdapter = new HtmlAdapter(job);
const html = await blob.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,15 @@
export { HtmlTransformer } from './html.js';
export { MarkdownTransformer } from './markdown.js';
export {
customImageProxyMiddleware,
defaultImageProxyMiddleware,
docLinkBaseURLMiddleware,
docLinkBaseURLMiddlewareBuilder,
embedSyncedDocMiddleware,
replaceIdMiddleware,
setImageProxyMiddlewareURL,
titleMiddleware,
} from './middlewares.js';
export { NotionHtmlTransformer } from './notion-html.js';
export { createAssetsArchive, download } from './utils.js';
export { ZipTransformer } from './zip.js';

View File

@@ -0,0 +1,217 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { assertExists, sha } from '@blocksuite/global/utils';
import type { Doc, DocCollection } from '@blocksuite/store';
import { extMimeMap, Job } from '@blocksuite/store';
import { MarkdownAdapter } from '../adapters/markdown/index.js';
import {
defaultImageProxyMiddleware,
docLinkBaseURLMiddleware,
fileNameMiddleware,
titleMiddleware,
} from './middlewares.js';
import { createAssetsArchive, download, Unzip } from './utils.js';
type ImportMarkdownToBlockOptions = {
doc: Doc;
markdown: string;
blockId: string;
};
type ImportMarkdownToDocOptions = {
collection: DocCollection;
markdown: string;
fileName?: string;
};
type ImportMarkdownZipOptions = {
collection: DocCollection;
imported: Blob;
};
/**
* 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: Doc) {
const job = new Job({
collection: doc.collection,
middlewares: [docLinkBaseURLMiddleware, titleMiddleware],
});
const snapshot = job.docToSnapshot(doc);
const adapter = new MarkdownAdapter(job);
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,
}: ImportMarkdownToBlockOptions) {
const job = new Job({
collection: doc.collection,
middlewares: [defaultImageProxyMiddleware, docLinkBaseURLMiddleware],
});
const adapter = new MarkdownAdapter(job);
const snapshot = await adapter.toSliceSnapshot({
file: markdown,
assets: job.assetsManager,
workspaceId: doc.collection.id,
pageId: doc.id,
});
assertExists(snapshot, '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.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,
markdown,
fileName,
}: ImportMarkdownToDocOptions) {
const job = new Job({
collection,
middlewares: [
defaultImageProxyMiddleware,
fileNameMiddleware(fileName),
docLinkBaseURLMiddleware,
],
});
const mdAdapter = new MarkdownAdapter(job);
const page = await mdAdapter.toDoc({
file: markdown,
assets: job.assetsManager,
});
if (!page) {
return;
}
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.imported The zip file as a Blob
* @returns A Promise that resolves to an array of IDs of the newly created docs
*/
async function importMarkdownZip({
collection,
imported,
}: ImportMarkdownZipOptions) {
const unzip = new Unzip();
await unzip.load(imported);
const docIds: string[] = [];
const pendingAssets = new Map<string, File>();
const pendingPathBlobIdMap = new Map<string, string>();
const markdownBlobs: [string, Blob][] = [];
for (const { path, content: blob } of unzip) {
if (path.includes('__MACOSX') || path.includes('.DS_Store')) {
continue;
}
const fileName = path.split('/').pop() ?? '';
if (fileName.endsWith('.md')) {
markdownBlobs.push([fileName, blob]);
} 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(
markdownBlobs.map(async ([fileName, blob]) => {
const fileNameWithoutExt = fileName.replace(/\.[^/.]+$/, '');
const job = new Job({
collection,
middlewares: [
defaultImageProxyMiddleware,
fileNameMiddleware(fileNameWithoutExt),
docLinkBaseURLMiddleware,
],
});
const assets = job.assets;
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
for (const [key, value] of pendingAssets.entries()) {
assets.set(key, value);
}
for (const [key, value] of pendingPathBlobIdMap.entries()) {
pathBlobIdMap.set(key, value);
}
const mdAdapter = new MarkdownAdapter(job);
const markdown = await blob.text();
const doc = await mdAdapter.toDoc({
file: markdown,
assets: job.assetsManager,
});
if (doc) {
docIds.push(doc.id);
}
})
);
return docIds;
}
export const MarkdownTransformer = {
exportDoc,
importMarkdownToBlock,
importMarkdownToDoc,
importMarkdownZip,
};

View File

@@ -0,0 +1,292 @@
import type {
DatabaseBlockModel,
EmbedLinkedDocModel,
EmbedSyncedDocModel,
ListBlockModel,
ParagraphBlockModel,
SurfaceRefBlockModel,
} from '@blocksuite/affine-model';
import { assertExists } from '@blocksuite/global/utils';
import type { DeltaOperation, JobMiddleware } from '@blocksuite/store';
import { DEFAULT_IMAGE_PROXY_ENDPOINT } from '../consts.js';
export const replaceIdMiddleware: JobMiddleware = ({ slots, collection }) => {
const idMap = new Map<string, string>();
slots.afterImport.on(payload => {
if (
payload.type === 'block' &&
payload.snapshot.flavour === 'affine:database'
) {
const model = payload.model as DatabaseBlockModel;
Object.keys(model.cells).forEach(cellId => {
if (idMap.has(cellId)) {
model.cells[idMap.get(cellId)!] = model.cells[cellId];
delete model.cells[cellId];
}
});
}
// replace LinkedPage pageId with new id in paragraph blocks
if (
payload.type === 'block' &&
['affine:list', 'affine:paragraph'].includes(payload.snapshot.flavour)
) {
const model = payload.model as ParagraphBlockModel | ListBlockModel;
let prev = 0;
const delta: DeltaOperation[] = [];
for (const d of model.text.toDelta()) {
if (d.attributes?.reference?.pageId) {
const newId = idMap.get(d.attributes.reference.pageId);
if (!newId) {
prev += d.insert?.length ?? 0;
continue;
}
if (prev > 0) {
delta.push({ retain: prev });
}
delta.push({
retain: d.insert?.length ?? 0,
attributes: {
reference: {
...d.attributes.reference,
pageId: newId,
},
},
});
prev = 0;
} else {
prev += d.insert?.length ?? 0;
}
}
if (delta.length > 0) {
model.text.applyDelta(delta);
}
}
if (
payload.type === 'block' &&
payload.snapshot.flavour === 'affine:surface-ref'
) {
const model = payload.model as SurfaceRefBlockModel;
const original = model.reference;
// If there exists a replacement, replace the reference with the new id.
// Otherwise,
// 1. If the reference is an affine:frame not in doc, generate a new id.
// 2. If the reference is graph, keep the original id.
if (idMap.has(original)) {
model.reference = idMap.get(original)!;
} else if (
model.refFlavour === 'affine:frame' &&
!model.doc.hasBlock(original)
) {
const newId = collection.idGenerator();
idMap.set(original, newId);
model.reference = newId;
}
}
// TODO(@fundon): process linked block/element
if (
payload.type === 'block' &&
['affine:embed-linked-doc', 'affine:embed-synced-doc'].includes(
payload.snapshot.flavour
)
) {
const model = payload.model as EmbedLinkedDocModel | EmbedSyncedDocModel;
const original = model.pageId;
// If the pageId is not in the doc, generate a new id.
// If we already have a replacement, use it.
if (!collection.getDoc(original)) {
if (idMap.has(original)) {
model.pageId = idMap.get(original)!;
} else {
const newId = collection.idGenerator();
idMap.set(original, newId);
model.pageId = newId;
}
}
}
});
slots.beforeImport.on(payload => {
if (payload.type === 'page') {
if (idMap.has(payload.snapshot.meta.id)) {
payload.snapshot.meta.id = idMap.get(payload.snapshot.meta.id)!;
return;
}
const newId = collection.idGenerator();
idMap.set(payload.snapshot.meta.id, newId);
payload.snapshot.meta.id = newId;
return;
}
if (payload.type === 'block') {
const { snapshot } = payload;
if (snapshot.flavour === 'affine:page') {
const index = snapshot.children.findIndex(
c => c.flavour === 'affine:surface'
);
if (index !== -1) {
const [surface] = snapshot.children.splice(index, 1);
snapshot.children.push(surface);
}
}
const original = snapshot.id;
let newId: string;
if (idMap.has(original)) {
newId = idMap.get(original)!;
} else {
newId = collection.idGenerator();
idMap.set(original, newId);
}
snapshot.id = newId;
if (snapshot.flavour === 'affine:surface') {
// Generate new IDs for images and frames in advance.
snapshot.children.forEach(child => {
const original = child.id;
if (idMap.has(original)) {
newId = idMap.get(original)!;
} else {
newId = collection.idGenerator();
idMap.set(original, newId);
}
});
Object.entries(
snapshot.props.elements as Record<string, Record<string, unknown>>
).forEach(([_, value]) => {
switch (value.type) {
case 'connector': {
let connection = value.source as Record<string, string>;
if (idMap.has(connection.id)) {
const newId = idMap.get(connection.id);
assertExists(newId, 'reference id must exist');
connection.id = newId;
}
connection = value.target as Record<string, string>;
if (idMap.has(connection.id)) {
const newId = idMap.get(connection.id);
assertExists(newId, 'reference id must exist');
connection.id = newId;
}
break;
}
case 'group': {
// @ts-expect-error FIXME: ts error
const json = value.children.json as Record<string, unknown>;
Object.entries(json).forEach(([key, value]) => {
if (idMap.has(key)) {
delete json[key];
const newKey = idMap.get(key);
assertExists(newKey, 'reference id must exist');
json[newKey] = value;
}
});
break;
}
default:
break;
}
});
}
}
});
};
export const customImageProxyMiddleware = (
imageProxyURL: string
): JobMiddleware => {
return ({ adapterConfigs }) => {
adapterConfigs.set('imageProxy', imageProxyURL);
};
};
const customDocLinkBaseUrlMiddleware = (baseUrl: string): JobMiddleware => {
return ({ adapterConfigs, collection }) => {
const docLinkBaseUrl = baseUrl
? `${baseUrl}/workspace/${collection.id}`
: '';
adapterConfigs.set('docLinkBaseUrl', docLinkBaseUrl);
};
};
export const titleMiddleware: JobMiddleware = ({
slots,
collection,
adapterConfigs,
}) => {
slots.beforeExport.on(() => {
for (const meta of collection.meta.docMetas) {
adapterConfigs.set('title:' + meta.id, meta.title);
}
});
};
export const docLinkBaseURLMiddlewareBuilder = (baseUrl: string) => {
let middleware = customDocLinkBaseUrlMiddleware(baseUrl);
return {
get: () => middleware,
set: (url: string) => {
middleware = customDocLinkBaseUrlMiddleware(url);
},
};
};
const defaultDocLinkBaseURLMiddlewareBuilder = docLinkBaseURLMiddlewareBuilder(
typeof window !== 'undefined' ? window.location.origin : '.'
);
export const docLinkBaseURLMiddleware =
defaultDocLinkBaseURLMiddlewareBuilder.get();
export const setDocLinkBaseURLMiddleware =
defaultDocLinkBaseURLMiddlewareBuilder.set;
const imageProxyMiddlewareBuilder = () => {
let middleware = customImageProxyMiddleware(DEFAULT_IMAGE_PROXY_ENDPOINT);
return {
get: () => middleware,
set: (url: string) => {
middleware = customImageProxyMiddleware(url);
},
};
};
const defaultImageProxyMiddlewarBuilder = imageProxyMiddlewareBuilder();
export const setImageProxyMiddlewareURL = defaultImageProxyMiddlewarBuilder.set;
export const defaultImageProxyMiddleware =
defaultImageProxyMiddlewarBuilder.get();
export const embedSyncedDocMiddleware =
(type: 'content'): JobMiddleware =>
({ adapterConfigs }) => {
adapterConfigs.set('embedSyncedDocExportType', type);
};
export const fileNameMiddleware =
(fileName?: string): JobMiddleware =>
({ slots }) => {
slots.beforeImport.on(payload => {
if (payload.type !== 'page') {
return;
}
if (!fileName) {
return;
}
payload.snapshot.meta.title = fileName;
payload.snapshot.blocks.props.title = {
'$blocksuite:internal:text$': true,
delta: [
{
insert: fileName,
},
],
};
});
};

View File

@@ -0,0 +1,146 @@
import { sha } from '@blocksuite/global/utils';
import { type DocCollection, extMimeMap, Job } from '@blocksuite/store';
import { NotionHtmlAdapter } from '../adapters/notion-html/notion-html.js';
import { defaultImageProxyMiddleware } from './middlewares.js';
import { Unzip } from './utils.js';
type ImportNotionZipOptions = {
collection: DocCollection;
imported: Blob;
};
/**
* Imports a Notion zip file into the BlockSuite collection.
*
* @param {ImportNotionZipOptions} options - The options for importing.
* @param {DocCollection} options.collection - The BlockSuite document collection.
* @param {Blob} options.imported - The imported zip file as a Blob.
*
* @returns {Promise<{entryId: string | undefined, pageIds: string[], isWorkspaceFile: boolean, hasMarkdown: boolean}>}
* A promise that resolves to an object containing:
* - entryId: The ID of the entry page (if any).
* - pageIds: An array of imported page IDs.
* - isWorkspaceFile: Whether the imported file is a workspace file.
* - hasMarkdown: Whether the zip contains markdown files.
*/
async function importNotionZip({
collection,
imported,
}: ImportNotionZipOptions) {
const pageIds: string[] = [];
let isWorkspaceFile = false;
let hasMarkdown = false;
let entryId: string | undefined;
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, Blob>();
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;
}
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) {
// Skip empty pages
continue;
}
}
const id = collection.idGenerator();
const splitPath = path.split('/');
while (splitPath.length > 0) {
pageMap.set(splitPath.join('/'), id);
splitPath.shift();
}
pagePaths.push(path);
if (entryId === undefined && lastSplitIndex === -1) {
entryId = id;
}
continue;
}
if (index === 0 && fileName.endsWith('.csv')) {
window.open(
'https://affine.pro/blog/import-your-data-from-notion-into-affine',
'_blank'
);
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) ?? '';
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 Job({
collection: collection,
middlewares: [defaultImageProxyMiddleware],
});
const htmlAdapter = new NotionHtmlAdapter(job);
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 page = await htmlAdapter.toDoc({
file: await zipFile.get(path)!.text(),
pageId: pageMap.get(path),
pageMap,
assets: job.assetsManager,
});
if (page) {
pageIds.push(page.id);
}
});
promises.push(...pagePromises);
return promises;
};
const allPromises = await parseZipFile(imported);
await Promise.all(allPromises.flat());
entryId = entryId ?? pageIds[0];
return { entryId, pageIds, isWorkspaceFile, hasMarkdown };
}
export const NotionHtmlTransformer = {
importNotionZip,
};

View File

@@ -0,0 +1,115 @@
import { extMimeMap, getAssetName } from '@blocksuite/store';
import * as fflate from 'fflate';
export class Zip {
private compressed = new Uint8Array();
private finalize?: () => void;
private finalized = false;
private 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()));
}
*[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([this.unzipped![path]], fileName, {
type: mime ?? '',
}) as Blob;
yield { path, 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);
}

View File

@@ -0,0 +1,120 @@
import { sha } from '@blocksuite/global/utils';
import type { Doc, DocCollection, DocSnapshot } from '@blocksuite/store';
import { extMimeMap, getAssetName, Job } from '@blocksuite/store';
import { download, Unzip, Zip } from '../transformers/utils.js';
import { replaceIdMiddleware, titleMiddleware } from './middlewares.js';
async function exportDocs(collection: DocCollection, docs: Doc[]) {
const zip = new Zip();
const job = new Job({ collection });
const snapshots = await Promise.all(docs.map(job.docToSnapshot));
const collectionInfo = job.collectionInfoToSnapshot();
await zip.file('info.json', JSON.stringify(collectionInfo, null, 2));
await Promise.all(
snapshots
.filter((snapshot): snapshot is DocSnapshot => !!snapshot)
.map(async snapshot => {
const snapshotName = `${snapshot.meta.id}.snapshot.json`;
await zip.file(snapshotName, JSON.stringify(snapshot, null, 2));
})
);
const assets = zip.folder('assets');
const assetsMap = job.assets;
for (const [id, blob] of assetsMap) {
const ext = getAssetName(assetsMap, id).split('.').at(-1);
const name = `${id}.${ext}`;
await assets.file(name, blob);
}
const downloadBlob = await zip.generate();
return download(downloadBlob, `${collection.id}.bs.zip`);
}
async function importDocs(collection: DocCollection, 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 Job({
collection,
middlewares: [replaceIdMiddleware, titleMiddleware],
});
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,28 @@
import type {
BrushElementModel,
ConnectorElementModel,
DocMode,
GroupElementModel,
} from '@blocksuite/affine-model';
import type { Slot } from '@blocksuite/global/utils';
import type { Doc } from '@blocksuite/store';
/** Common context interface definition for block models. */
type EditorSlots = {
docUpdated: Slot<{ newDocId: string }>;
};
export type AbstractEditor = {
doc: Doc;
mode: DocMode;
readonly slots: EditorSlots;
} & HTMLElement;
export type Connectable = Exclude<
BlockSuite.EdgelessModel,
ConnectorElementModel | BrushElementModel | GroupElementModel
>;
export type { EmbedCardStyle } from '@blocksuite/affine-model';
export * from '@blocksuite/affine-shared/types';

View File

@@ -0,0 +1,174 @@
import {
getClosestBlockComponentByElement,
getRectByBlockComponent,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/block-std';
import { type Point, Rect } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import type { EditingState } from '../types.js';
import { DropFlags, getDropRectByPoint } from './query.js';
/**
* A dropping type.
*/
export type DroppingType = 'none' | 'before' | 'after' | 'database';
export type DropResult = {
type: DroppingType;
rect: Rect;
modelState: EditingState;
};
/**
* Calculates the drop target.
*/
export function calcDropTarget(
point: Point,
model: BlockModel,
element: Element,
draggingElements: BlockComponent[] = [],
scale: number = 1,
flavour: string | null = null // for block-hub
): DropResult | null {
const schema = model.doc.getSchemaByFlavour('affine:database');
const children = schema?.model.children ?? [];
let shouldAppendToDatabase = true;
if (children.length) {
if (draggingElements.length) {
shouldAppendToDatabase = draggingElements
.map(el => el.model)
.every(m => children.includes(m.flavour));
} else if (flavour) {
shouldAppendToDatabase = children.includes(flavour);
}
}
if (!shouldAppendToDatabase && !matchFlavours(model, ['affine:database'])) {
const databaseBlockComponent = element.closest('affine-database');
if (databaseBlockComponent) {
element = databaseBlockComponent;
model = databaseBlockComponent.model;
}
}
let type: DroppingType = 'none';
const height = 3 * scale;
const { rect: domRect, flag } = getDropRectByPoint(point, model, element);
if (flag === DropFlags.EmptyDatabase) {
// empty database
const rect = Rect.fromDOMRect(domRect);
rect.top -= height / 2;
rect.height = height;
type = 'database';
return {
type,
rect,
modelState: {
model,
rect: domRect,
element: element as BlockComponent,
},
};
} else if (flag === DropFlags.Database) {
// not empty database
const distanceToTop = Math.abs(domRect.top - point.y);
const distanceToBottom = Math.abs(domRect.bottom - point.y);
const before = distanceToTop < distanceToBottom;
type = before ? 'before' : 'after';
return {
type,
rect: Rect.fromLWTH(
domRect.left,
domRect.width,
(before ? domRect.top - 1 : domRect.bottom) - height / 2,
height
),
modelState: {
model,
rect: domRect,
element: element as BlockComponent,
},
};
}
const distanceToTop = Math.abs(domRect.top - point.y);
const distanceToBottom = Math.abs(domRect.bottom - point.y);
const before = distanceToTop < distanceToBottom;
type = before ? 'before' : 'after';
let offsetY = 4;
if (type === 'before') {
// before
let prev;
let prevRect;
prev = element.previousElementSibling;
if (prev) {
if (
draggingElements.length &&
prev === draggingElements[draggingElements.length - 1]
) {
type = 'none';
} else {
prevRect = getRectByBlockComponent(prev);
}
} else {
prev = element.parentElement?.previousElementSibling;
if (prev) {
prevRect = prev.getBoundingClientRect();
}
}
if (prevRect) {
offsetY = (domRect.top - prevRect.bottom) / 2;
}
} else {
// after
let next;
let nextRect;
next = element.nextElementSibling;
if (next) {
if (draggingElements.length && next === draggingElements[0]) {
type = 'none';
next = null;
}
} else {
next = getClosestBlockComponentByElement(
element.parentElement
)?.nextElementSibling;
}
if (next) {
nextRect = getRectByBlockComponent(next);
offsetY = (nextRect.top - domRect.bottom) / 2;
}
}
if (type === 'none') return null;
let top = domRect.top;
if (type === 'before') {
top -= offsetY;
} else {
top += domRect.height + offsetY;
}
return {
type,
rect: Rect.fromLWTH(domRect.left, domRect.width, top - height / 2, height),
modelState: {
model,
rect: domRect,
element: element as BlockComponent,
},
};
}

View File

@@ -0,0 +1,17 @@
// Compat with SSR
export * from '../types.js';
export * from './drag-and-drop.js';
export * from './query.js';
export {
createButtonPopper,
getBlockProps,
getImageFilesFromLocal,
isMiddleButtonPressed,
isRightButtonPressed,
isValidUrl,
matchFlavours,
on,
once,
openFileOrFiles,
requestThrottledConnectedFrame,
} from '@blocksuite/affine-shared/utils';

View File

@@ -0,0 +1,211 @@
import {
getRectByBlockComponent,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import { BLOCK_ID_ATTR, type EditorHost } from '@blocksuite/block-std';
import type { Point } from '@blocksuite/global/utils';
import { assertExists } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import type { RootBlockComponent } from '../../index.js';
const ATTR_SELECTOR = `[${BLOCK_ID_ATTR}]`;
/**
* This function is used to build model's "normal" block path.
* If this function does not meet your needs, you may need to build path manually to satisfy your needs.
* You should not modify this function.
*/
export function buildPath(model: BlockModel | null): string[] {
const path: string[] = [];
let current = model;
while (current) {
path.unshift(current.id);
current = current.doc.getParent(current);
}
return path;
}
export function getRootByEditorHost(
editorHost: EditorHost
): RootBlockComponent | null {
return (
getPageRootByEditorHost(editorHost) ??
getEdgelessRootByEditorHost(editorHost)
);
}
/** If it's not in the page mode, it will return `null` directly */
export function getPageRootByEditorHost(editorHost: EditorHost) {
return editorHost.querySelector('affine-page-root');
}
/** If it's not in the edgeless mode, it will return `null` directly */
export function getEdgelessRootByEditorHost(editorHost: EditorHost) {
return editorHost.querySelector('affine-edgeless-root');
}
/**
* Get block component by model.
* Note that this function is used for compatibility only, and may be removed in the future.
*
* @deprecated
*/
export function getBlockComponentByModel(
editorHost: EditorHost,
model: BlockModel | null
) {
if (!model) return null;
return editorHost.view.getBlock(model.id);
}
function isEdgelessChildNote({ classList }: Element) {
return classList.contains('note-background');
}
/**
* Get hovering note with given a point in edgeless mode.
*/
export function getHoveringNote(point: Point) {
return (
document.elementsFromPoint(point.x, point.y).find(isEdgelessChildNote) ||
null
);
}
/**
* Gets the table of the database.
*/
function getDatabaseBlockTableElement(element: Element) {
return element.querySelector('.affine-database-block-table');
}
/**
* Gets the column header of the database.
*/
function getDatabaseBlockColumnHeaderElement(element: Element) {
return element.querySelector('.affine-database-column-header');
}
/**
* Gets the rows of the database.
*/
function getDatabaseBlockRowsElement(element: Element) {
return element.querySelector('.affine-database-block-rows');
}
/**
* Returns a flag for the drop target.
*/
export enum DropFlags {
Normal,
Database,
EmptyDatabase,
}
/**
* Gets the drop rect by block and point.
*/
export function getDropRectByPoint(
point: Point,
model: BlockModel,
element: Element
): {
rect: DOMRect;
flag: DropFlags;
} {
const result = {
rect: getRectByBlockComponent(element),
flag: DropFlags.Normal,
};
const isDatabase = matchFlavours(model, ['affine:database']);
if (isDatabase) {
const table = getDatabaseBlockTableElement(element);
if (!table) {
return result;
}
let bounds = table.getBoundingClientRect();
if (model.isEmpty.value) {
result.flag = DropFlags.EmptyDatabase;
if (point.y < bounds.top) return result;
const header = getDatabaseBlockColumnHeaderElement(element);
assertExists(header);
bounds = header.getBoundingClientRect();
result.rect = new DOMRect(
result.rect.left,
bounds.bottom,
result.rect.width,
1
);
} else {
result.flag = DropFlags.Database;
const rows = getDatabaseBlockRowsElement(element);
assertExists(rows);
const rowsBounds = rows.getBoundingClientRect();
if (point.y < rowsBounds.top || point.y > rowsBounds.bottom)
return result;
const elements = document.elementsFromPoint(point.x, point.y);
const len = elements.length;
let e;
let i = 0;
for (; i < len; i++) {
e = elements[i];
if (e.classList.contains('affine-database-block-row-cell-content')) {
result.rect = getCellRect(e, bounds);
return result;
}
if (e.classList.contains('affine-database-block-row')) {
e = e.querySelector(ATTR_SELECTOR);
assertExists(e);
result.rect = getCellRect(e, bounds);
return result;
}
}
}
} else {
const parent = element.parentElement;
if (parent?.classList.contains('affine-database-block-row-cell-content')) {
result.flag = DropFlags.Database;
result.rect = getCellRect(parent);
return result;
}
}
return result;
}
function getCellRect(element: Element, bounds?: DOMRect) {
if (!bounds) {
const table = element.closest('.affine-database-block-table');
assertExists(table);
bounds = table.getBoundingClientRect();
}
// affine-database-block-row-cell
const col = element.parentElement;
assertExists(col);
// affine-database-block-row
const row = col.parentElement;
assertExists(row);
const colRect = col.getBoundingClientRect();
return new DOMRect(
bounds.left,
colRect.top,
colRect.right - bounds.left,
colRect.height
);
}
/**
* Return `true` if the element has class name in the class list.
*/
export function hasClassNameInList(element: Element, classList: string[]) {
return classList.some(className => element.classList.contains(className));
}

View File

@@ -0,0 +1,246 @@
import type { FrameBlockModel, NoteBlockModel } from '@blocksuite/affine-model';
import { NoteDisplayMode } from '@blocksuite/affine-model';
import {
DocModeProvider,
NotificationProvider,
} from '@blocksuite/affine-shared/services';
import { getBlockProps, matchFlavours } from '@blocksuite/affine-shared/utils';
import type { EditorHost } from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
import {
type BlockModel,
type BlockSnapshot,
type Doc,
type DraftModel,
Slice,
} from '@blocksuite/store';
import { GfxBlockModel } from '../../root-block/edgeless/block-model.js';
import {
getElementProps,
mapFrameIds,
sortEdgelessElements,
} from '../../root-block/edgeless/utils/clone-utils.js';
import {
isFrameBlock,
isNoteBlock,
} from '../../root-block/edgeless/utils/query.js';
import { getSurfaceBlock } from '../../surface-ref-block/utils.js';
export function promptDocTitle(host: EditorHost, autofill?: string) {
const notification = host.std.getOptional(NotificationProvider);
if (!notification) return Promise.resolve(undefined);
return notification.prompt({
title: 'Create linked doc',
message: 'Enter a title for the new doc.',
placeholder: 'Untitled',
autofill,
confirmText: 'Confirm',
cancelText: 'Cancel',
});
}
export function getTitleFromSelectedModels(selectedModels: DraftModel[]) {
const firstBlock = selectedModels[0];
if (
matchFlavours(firstBlock, ['affine:paragraph']) &&
firstBlock.type.startsWith('h')
) {
return firstBlock.text.toString();
}
return undefined;
}
export function notifyDocCreated(host: EditorHost, doc: Doc) {
const notification = host.std.getOptional(NotificationProvider);
if (!notification) return;
const abortController = new AbortController();
const clear = () => {
doc.history.off('stack-item-added', addHandler);
doc.history.off('stack-item-popped', popHandler);
disposable.dispose();
};
const closeNotify = () => {
abortController.abort();
clear();
};
// edit or undo or switch doc, close notify toast
const addHandler = doc.history.on('stack-item-added', closeNotify);
const popHandler = doc.history.on('stack-item-popped', closeNotify);
const disposable = host.slots.unmounted.on(closeNotify);
notification.notify({
title: 'Linked doc created',
message: 'You can click undo to recovery block content',
accent: 'info',
duration: 10 * 1000,
action: {
label: 'Undo',
onClick: () => {
doc.undo();
clear();
},
},
abort: abortController.signal,
onClose: clear,
});
}
export function addBlocksToDoc(
targetDoc: Doc,
model: BlockModel,
parentId: string
) {
// Add current block to linked doc
const blockProps = getBlockProps(model);
const newModelId = targetDoc.addBlock(
model.flavour as BlockSuite.Flavour,
blockProps,
parentId
);
// Add children to linked doc, parent is the new model
const children = model.children;
if (children.length > 0) {
children.forEach(child => {
addBlocksToDoc(targetDoc, child, newModelId);
});
}
}
export async function convertSelectedBlocksToLinkedDoc(
std: BlockSuite.Std,
doc: Doc,
selectedModels: DraftModel[] | Promise<DraftModel[]>,
docTitle?: string
) {
const models = await selectedModels;
const slice = std.clipboard.sliceToSnapshot(Slice.fromModels(doc, models));
if (!slice) {
return;
}
const firstBlock = models[0];
assertExists(firstBlock);
// if title undefined, use the first heading block content as doc title
const title = docTitle || getTitleFromSelectedModels(models);
const linkedDoc = createLinkedDocFromSlice(std, doc, slice.content, title);
// insert linked doc card
doc.addSiblingBlocks(
doc.getBlock(firstBlock.id)!.model,
[
{
flavour: 'affine:embed-linked-doc',
pageId: linkedDoc.id,
},
],
'before'
);
// delete selected elements
models.forEach(model => doc.deleteBlock(model));
return linkedDoc;
}
export function createLinkedDocFromSlice(
std: BlockSuite.Std,
doc: Doc,
snapshots: BlockSnapshot[],
docTitle?: string
) {
// const modelsWithChildren = (list:BlockModel[]):BlockModel[]=>list.flatMap(model=>[model,...modelsWithChildren(model.children)])
const linkedDoc = doc.collection.createDoc({});
linkedDoc.load(() => {
const rootId = linkedDoc.addBlock('affine:page', {
title: new doc.Text(docTitle),
});
linkedDoc.addBlock('affine:surface', {}, rootId);
const noteId = linkedDoc.addBlock('affine:note', {}, rootId);
snapshots.forEach(snapshot => {
std.clipboard
.pasteBlockSnapshot(snapshot, linkedDoc, noteId)
.catch(console.error);
});
});
return linkedDoc;
}
export function createLinkedDocFromNote(
doc: Doc,
note: NoteBlockModel,
docTitle?: string
) {
const linkedDoc = doc.collection.createDoc({});
linkedDoc.load(() => {
const rootId = linkedDoc.addBlock('affine:page', {
title: new doc.Text(docTitle),
});
linkedDoc.addBlock('affine:surface', {}, rootId);
const blockProps = getBlockProps(note);
// keep note props & show in both mode
const noteId = linkedDoc.addBlock(
'affine:note',
{
...blockProps,
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
rootId
);
// Add note to linked doc recursively
note.children.forEach(model => {
addBlocksToDoc(linkedDoc, model, noteId);
});
});
return linkedDoc;
}
export function createLinkedDocFromEdgelessElements(
host: EditorHost,
elements: BlockSuite.EdgelessModel[],
docTitle?: string
) {
const linkedDoc = host.doc.collection.createDoc({});
linkedDoc.load(() => {
const rootId = linkedDoc.addBlock('affine:page', {
title: new host.doc.Text(docTitle),
});
const surfaceId = linkedDoc.addBlock('affine:surface', {}, rootId);
const surface = getSurfaceBlock(linkedDoc);
if (!surface) return;
const sortedElements = sortEdgelessElements(elements);
const ids = new Map<string, string>();
sortedElements.forEach(model => {
let newId = model.id;
if (model instanceof GfxBlockModel) {
const blockProps = getBlockProps(model);
if (isNoteBlock(model)) {
newId = linkedDoc.addBlock('affine:note', blockProps, rootId);
// Add note children to linked doc recursively
model.children.forEach(model => {
addBlocksToDoc(linkedDoc, model, newId);
});
} else {
if (isFrameBlock(model)) {
mapFrameIds(blockProps as unknown as FrameBlockModel, ids);
}
newId = linkedDoc.addBlock(
model.flavour as BlockSuite.Flavour,
blockProps,
surfaceId
);
}
} else {
const props = getElementProps(model, ids);
newId = surface.addElement(props);
}
ids.set(model.id, newId);
});
});
host.std.get(DocModeProvider).setPrimaryMode('edgeless', linkedDoc.id);
return linkedDoc;
}

View File

@@ -0,0 +1,89 @@
import {
DarkLoadingIcon,
EmbedCardDarkBannerIcon,
EmbedCardDarkCubeIcon,
EmbedCardDarkHorizontalIcon,
EmbedCardDarkListIcon,
EmbedCardDarkVerticalIcon,
EmbedCardLightBannerIcon,
EmbedCardLightCubeIcon,
EmbedCardLightHorizontalIcon,
EmbedCardLightListIcon,
EmbedCardLightVerticalIcon,
LightLoadingIcon,
} from '@blocksuite/affine-components/icons';
import {
ColorScheme,
type DocMode,
DocModes,
type ReferenceInfo,
} from '@blocksuite/affine-model';
import type { TemplateResult } from 'lit';
type EmbedCardIcons = {
LoadingIcon: TemplateResult<1>;
EmbedCardBannerIcon: TemplateResult<1>;
EmbedCardHorizontalIcon: TemplateResult<1>;
EmbedCardListIcon: TemplateResult<1>;
EmbedCardVerticalIcon: TemplateResult<1>;
EmbedCardCubeIcon: TemplateResult<1>;
};
export function getEmbedCardIcons(theme: ColorScheme): EmbedCardIcons {
if (theme === ColorScheme.Light) {
return {
LoadingIcon: LightLoadingIcon,
EmbedCardBannerIcon: EmbedCardLightBannerIcon,
EmbedCardHorizontalIcon: EmbedCardLightHorizontalIcon,
EmbedCardListIcon: EmbedCardLightListIcon,
EmbedCardVerticalIcon: EmbedCardLightVerticalIcon,
EmbedCardCubeIcon: EmbedCardLightCubeIcon,
};
} else {
return {
LoadingIcon: DarkLoadingIcon,
EmbedCardBannerIcon: EmbedCardDarkBannerIcon,
EmbedCardHorizontalIcon: EmbedCardDarkHorizontalIcon,
EmbedCardListIcon: EmbedCardDarkListIcon,
EmbedCardVerticalIcon: EmbedCardDarkVerticalIcon,
EmbedCardCubeIcon: EmbedCardDarkCubeIcon,
};
}
}
export function extractSearchParams(link: string) {
try {
const url = new URL(link);
const mode = url.searchParams.get('mode') as DocMode | undefined;
if (mode && DocModes.includes(mode)) {
const params: ReferenceInfo['params'] = { mode: mode as DocMode };
const blockIds = url.searchParams
.get('blockIds')
?.trim()
.split(',')
.map(id => id.trim())
.filter(id => id.length);
const elementIds = url.searchParams
.get('elementIds')
?.trim()
.split(',')
.map(id => id.trim())
.filter(id => id.length);
if (blockIds?.length) {
params.blockIds = blockIds;
}
if (elementIds?.length) {
params.elementIds = elementIds;
}
return { params };
}
} catch (err) {
console.error(err);
}
return null;
}