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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,106 @@
import { DEFAULT_NOTE_BACKGROUND_COLOR } from '@blocksuite/affine-model';
import type { SliceSnapshot } from '@blocksuite/store';
import { describe, expect, test } from 'vitest';
import { NotionTextAdapter } from '../../_common/adapters/notion-text.js';
import { nanoidReplacement } from '../../_common/test-utils/test-utils.js';
import { createJob } from '../utils/create-job.js';
describe('notion-text to snapshot', () => {
test('basic', () => {
const notionText =
'{"blockType":"text","editing":[["aaa ",[["_"],["b"],["i"]]],["nbbbb ",[["_"],["i"]]],["hjhj ",[["_"]]],["a",[["_"],["c"]]],[" ",[["_"]]],["ccc d",[["_"],["s"]]],["dd",[["_"],["s"]]]]}';
const sliceSnapshot: SliceSnapshot = {
type: 'slice',
content: [
{
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: 'both',
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'aaa ',
attributes: {
underline: true,
bold: true,
italic: true,
},
},
{
insert: 'nbbbb ',
attributes: {
underline: true,
italic: true,
},
},
{
insert: 'hjhj ',
attributes: {
underline: true,
},
},
{
insert: 'a',
attributes: {
underline: true,
code: true,
},
},
{
insert: ' ',
attributes: {
underline: true,
},
},
{
insert: 'ccc d',
attributes: {
underline: true,
strike: true,
},
},
{
insert: 'dd',
attributes: {
underline: true,
strike: true,
},
},
],
},
},
children: [],
},
],
},
],
workspaceId: '',
pageId: '',
};
const ntAdapter = new NotionTextAdapter(createJob());
const target = ntAdapter.toSliceSnapshot({
file: notionText,
workspaceId: '',
pageId: '',
});
expect(nanoidReplacement(target!)).toEqual(sliceSnapshot);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,235 @@
import {
type Cell,
type Column,
type DatabaseBlockModel,
DatabaseBlockSchema,
NoteBlockSchema,
ParagraphBlockSchema,
RootBlockSchema,
} from '@blocksuite/affine-model';
import { propertyModelPresets } from '@blocksuite/data-view/property-pure-presets';
import type { BlockModel, Doc } from '@blocksuite/store';
import { DocCollection, IdGeneratorType, Schema } from '@blocksuite/store';
import { beforeEach, describe, expect, test } from 'vitest';
import { databaseBlockColumns } from '../../database-block/index.js';
import {
addProperty,
copyCellsByProperty,
deleteColumn,
getCell,
getProperty,
updateCell,
} from '../../database-block/utils/block-utils.js';
const AffineSchemas = [
RootBlockSchema,
NoteBlockSchema,
ParagraphBlockSchema,
DatabaseBlockSchema,
];
function createTestOptions() {
const idGenerator = IdGeneratorType.AutoIncrement;
const schema = new Schema();
schema.register(AffineSchemas);
return { id: 'test-collection', idGenerator, schema };
}
function createTestDoc(docId = 'doc0') {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: docId });
doc.load();
return doc;
}
describe('DatabaseManager', () => {
let doc: Doc;
let db: DatabaseBlockModel;
let rootId: BlockModel['id'];
let noteBlockId: BlockModel['id'];
let databaseBlockId: BlockModel['id'];
let p1: BlockModel['id'];
let p2: BlockModel['id'];
let col1: Column['id'];
let col2: Column['id'];
let col3: Column['id'];
const selection = [
{ id: '1', value: 'Done', color: 'var(--affine-tag-white)' },
{ id: '2', value: 'TODO', color: 'var(--affine-tag-pink)' },
{ id: '3', value: 'WIP', color: 'var(--affine-tag-blue)' },
];
beforeEach(() => {
doc = createTestDoc();
rootId = doc.addBlock('affine:page', {
title: new doc.Text('database test'),
});
noteBlockId = doc.addBlock('affine:note', {}, rootId);
databaseBlockId = doc.addBlock(
'affine:database' as BlockSuite.Flavour,
{
columns: [],
titleColumn: 'Title',
},
noteBlockId
);
const databaseModel = doc.getBlockById(
databaseBlockId
) as DatabaseBlockModel;
db = databaseModel;
col1 = addProperty(
db,
'end',
databaseBlockColumns.numberColumnConfig.create('Number')
);
col2 = addProperty(
db,
'end',
propertyModelPresets.selectPropertyModelConfig.create('Single Select', {
options: selection,
})
);
col3 = addProperty(
db,
'end',
databaseBlockColumns.richTextColumnConfig.create('Rich Text')
);
doc.updateBlock(databaseModel, {
columns: [col1, col2, col3],
});
p1 = doc.addBlock(
'affine:paragraph',
{
text: new doc.Text('text1'),
},
databaseBlockId
);
p2 = doc.addBlock(
'affine:paragraph',
{
text: new doc.Text('text2'),
},
databaseBlockId
);
updateCell(db, p1, {
columnId: col1,
value: 0.1,
});
updateCell(db, p2, {
columnId: col2,
value: [selection[1]],
});
});
test('getColumn', () => {
const column = {
...databaseBlockColumns.numberColumnConfig.create('testColumnId'),
id: 'testColumnId',
};
addProperty(db, 'end', column);
const result = getProperty(db, column.id);
expect(result).toEqual(column);
});
test('addColumn', () => {
const column =
databaseBlockColumns.numberColumnConfig.create('Test Column');
const id = addProperty(db, 'end', column);
const result = getProperty(db, id);
expect(result).toMatchObject(column);
expect(result).toHaveProperty('id');
});
test('deleteColumn', () => {
const column = {
...databaseBlockColumns.numberColumnConfig.create('Test Column'),
id: 'testColumnId',
};
addProperty(db, 'end', column);
expect(getProperty(db, column.id)).toEqual(column);
deleteColumn(db, column.id);
expect(getProperty(db, column.id)).toBeUndefined();
});
test('getCell', () => {
const modelId = doc.addBlock(
'affine:paragraph',
{
text: new doc.Text('paragraph'),
},
noteBlockId
);
const column = {
...databaseBlockColumns.numberColumnConfig.create('Test Column'),
id: 'testColumnId',
};
const cell: Cell = {
columnId: column.id,
value: 42,
};
addProperty(db, 'end', column);
updateCell(db, modelId, cell);
const model = doc.getBlockById(modelId);
expect(model).not.toBeNull();
const result = getCell(db, model!.id, column.id);
expect(result).toEqual(cell);
});
test('updateCell', () => {
const newRowId = doc.addBlock(
'affine:paragraph',
{
text: new doc.Text('text3'),
},
databaseBlockId
);
updateCell(db, newRowId, {
columnId: col2,
value: [selection[2]],
});
const cell = getCell(db, newRowId, col2);
expect(cell).toEqual({
columnId: col2,
value: [selection[2]],
});
});
test('copyCellsByColumn', () => {
const newColId = addProperty(
db,
'end',
propertyModelPresets.selectPropertyModelConfig.create('Copied Select', {
options: selection,
})
);
copyCellsByProperty(db, col2, newColId);
const cell = getCell(db, p2, newColId);
expect(cell).toEqual({
columnId: newColId,
value: [selection[1]],
});
});
});

View File

@@ -0,0 +1,79 @@
import { type SelectTag, t, typeSystem } from '@blocksuite/data-view';
import { describe, expect, test } from 'vitest';
describe('subtyping', () => {
test('all type is subtype of unknown', () => {
expect(typeSystem.unify(t.boolean.instance(), t.unknown.instance())).toBe(
true
);
expect(typeSystem.unify(t.string.instance(), t.unknown.instance())).toBe(
true
);
expect(
typeSystem.unify(
t.array.instance(t.string.instance()),
t.unknown.instance()
)
).toBe(true);
expect(typeSystem.unify(t.tag.instance(), t.unknown.instance())).toBe(true);
});
});
describe('function apply', () => {
test('generic type function', () => {
const fn = t.fn.instance(
[t.typeVarReference.create('A'), t.typeVarReference.create('A')],
t.boolean.instance(),
[t.typeVarDefine.create('A', t.unknown.instance())]
);
const instancedFn = typeSystem.instanceFn(
fn,
[t.boolean.instance()],
t.boolean.instance(),
{}
);
expect(instancedFn?.args[1]).toStrictEqual(t.boolean.instance());
});
test('tags infer', () => {
const fn = t.fn.instance(
[
t.typeVarReference.create('A'),
t.array.instance(t.typeVarReference.create('A')),
] as const,
t.boolean.instance(),
[t.typeVarDefine.create('A', t.tag.instance())]
);
const fnArray = t.fn.instance(
[
t.array.instance(t.typeVarReference.create('A')),
t.array.instance(t.typeVarReference.create('A')),
] as const,
t.boolean.instance(),
[t.typeVarDefine.create('A', t.tag.instance())]
);
const tags: SelectTag[] = [{ id: 'a', value: 'b', color: 'c' }];
const instancedFn = typeSystem.instanceFn(
fn,
[t.tag.instance(tags)],
t.boolean.instance(),
{}
);
const instancedFnArray = typeSystem.instanceFn(
fnArray,
[t.array.instance(t.tag.instance(tags))],
t.boolean.instance(),
{}
);
expect(
typeSystem.unify(
instancedFn?.args[1],
t.array.instance(t.tag.instance(tags))
)
).toBe(true);
expect(
typeSystem.unify(
instancedFnArray?.args[1],
t.array.instance(t.tag.instance(tags))
)
).toBe(true);
});
});

View File

@@ -0,0 +1,92 @@
import { DocCollection, Schema } from '@blocksuite/store';
import { describe, expect, test } from 'vitest';
import { markdownToMindmap } from '../../surface-block/mini-mindmap/mindmap-preview.js';
describe('markdownToMindmap: convert markdown list to a mind map tree', () => {
test('basic case', () => {
const markdown = `
- Text A
- Text B
- Text C
- Text D
- Text E
`;
const collection = new DocCollection({ schema: new Schema() });
collection.meta.initialize();
const doc = collection.createDoc();
const nodes = markdownToMindmap(markdown, doc);
expect(nodes).toEqual({
text: 'Text A',
children: [
{
text: 'Text B',
children: [
{
text: 'Text C',
children: [],
},
],
},
{
text: 'Text D',
children: [
{
text: 'Text E',
children: [],
},
],
},
],
});
});
test('basic case with different indent', () => {
const markdown = `
- Text A
- Text B
- Text C
- Text D
- Text E
`;
const collection = new DocCollection({ schema: new Schema() });
collection.meta.initialize();
const doc = collection.createDoc();
const nodes = markdownToMindmap(markdown, doc);
expect(nodes).toEqual({
text: 'Text A',
children: [
{
text: 'Text B',
children: [
{
text: 'Text C',
children: [],
},
],
},
{
text: 'Text D',
children: [
{
text: 'Text E',
children: [],
},
],
},
],
});
});
test('empty case', () => {
const markdown = '';
const collection = new DocCollection({ schema: new Schema() });
collection.meta.initialize();
const doc = collection.createDoc();
const nodes = markdownToMindmap(markdown, doc);
expect(nodes).toEqual(null);
});
});

View File

@@ -0,0 +1,31 @@
import {
DocCollection,
Job,
type JobMiddleware,
Schema,
} from '@blocksuite/store';
import { defaultImageProxyMiddleware } from '../../_common/transformers/middlewares.js';
import { AffineSchemas } from '../../schemas.js';
declare global {
interface Window {
happyDOM: {
settings: {
fetch: {
disableSameOriginPolicy: boolean;
};
};
};
}
}
export function createJob(middlewares?: JobMiddleware[]) {
window.happyDOM.settings.fetch.disableSameOriginPolicy = true;
const testMiddlewares = middlewares ?? [];
testMiddlewares.push(defaultImageProxyMiddleware);
const schema = new Schema().register(AffineSchemas);
const docCollection = new DocCollection({ schema });
docCollection.meta.initialize();
return new Job({ collection: docCollection, middlewares: testMiddlewares });
}

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

View File

@@ -0,0 +1,58 @@
import { EmbedExtensions } from '@blocksuite/affine-block-embed';
import { ListBlockSpec } from '@blocksuite/affine-block-list';
import { ParagraphBlockSpec } from '@blocksuite/affine-block-paragraph';
import { RichTextExtensions } from '@blocksuite/affine-components/rich-text';
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import type { ExtensionType } from '@blocksuite/block-std';
import {
AdapterFactoryExtensions,
BlockAdapterMatcherExtensions,
} from '../_common/adapters/extension.js';
import { AttachmentBlockSpec } from '../attachment-block/attachment-spec.js';
import { BookmarkBlockSpec } from '../bookmark-block/bookmark-spec.js';
import { CodeBlockSpec } from '../code-block/code-block-spec.js';
import { DataViewBlockSpec } from '../data-view-block/data-view-spec.js';
import { DatabaseBlockSpec } from '../database-block/database-spec.js';
import { DividerBlockSpec } from '../divider-block/divider-spec.js';
import { ImageBlockSpec } from '../image-block/image-spec.js';
import {
EdgelessNoteBlockSpec,
NoteBlockSpec,
} from '../note-block/note-spec.js';
export const CommonFirstPartyBlockSpecs: ExtensionType[] = [
RichTextExtensions,
EditPropsStore,
ListBlockSpec,
NoteBlockSpec,
DatabaseBlockSpec,
DataViewBlockSpec,
DividerBlockSpec,
CodeBlockSpec,
ImageBlockSpec,
ParagraphBlockSpec,
BookmarkBlockSpec,
AttachmentBlockSpec,
EmbedExtensions,
BlockAdapterMatcherExtensions,
AdapterFactoryExtensions,
].flat();
export const EdgelessFirstPartyBlockSpecs: ExtensionType[] = [
RichTextExtensions,
EditPropsStore,
ListBlockSpec,
EdgelessNoteBlockSpec,
DatabaseBlockSpec,
DataViewBlockSpec,
DividerBlockSpec,
CodeBlockSpec,
ImageBlockSpec,
ParagraphBlockSpec,
BookmarkBlockSpec,
AttachmentBlockSpec,
EmbedExtensions,
BlockAdapterMatcherExtensions,
AdapterFactoryExtensions,
].flat();

View File

@@ -0,0 +1,44 @@
import {
EmbedFigmaBlockSpec,
EmbedGithubBlockSpec,
EmbedHtmlBlockSpec,
EmbedLinkedDocBlockSpec,
EmbedLoomBlockSpec,
EmbedSyncedDocBlockSpec,
EmbedYoutubeBlockSpec,
} from '@blocksuite/affine-block-embed';
import { ListBlockSpec } from '@blocksuite/affine-block-list';
import { ParagraphBlockSpec } from '@blocksuite/affine-block-paragraph';
import { AttachmentBlockSpec } from '../../attachment-block/attachment-spec.js';
import { BookmarkBlockSpec } from '../../bookmark-block/bookmark-spec.js';
import { CodeBlockSpec } from '../../code-block/code-block-spec.js';
import { DataViewBlockSpec } from '../../data-view-block/data-view-spec.js';
import { DatabaseBlockSpec } from '../../database-block/database-spec.js';
import { DividerBlockSpec } from '../../divider-block/divider-spec.js';
import { ImageBlockSpec } from '../../image-block/image-spec.js';
import {
EdgelessNoteBlockSpec,
NoteBlockSpec,
} from '../../note-block/note-spec.js';
export {
AttachmentBlockSpec,
BookmarkBlockSpec,
CodeBlockSpec,
DatabaseBlockSpec,
DataViewBlockSpec,
DividerBlockSpec,
EdgelessNoteBlockSpec,
EmbedFigmaBlockSpec,
EmbedGithubBlockSpec,
EmbedHtmlBlockSpec,
EmbedLinkedDocBlockSpec,
EmbedLoomBlockSpec,
EmbedSyncedDocBlockSpec,
EmbedYoutubeBlockSpec,
ImageBlockSpec,
ListBlockSpec,
NoteBlockSpec,
ParagraphBlockSpec,
};

View File

@@ -0,0 +1,16 @@
import { EdgelessSurfaceBlockSpec } from '@blocksuite/affine-block-surface';
import { EdgelessTextBlockSpec } from '../../edgeless-text-block/index.js';
import { FrameBlockSpec } from '../../frame-block/frame-spec.js';
import { LatexBlockSpec } from '../../latex-block/latex-spec.js';
import { EdgelessRootBlockSpec } from '../../root-block/edgeless/edgeless-root-spec.js';
import { EdgelessSurfaceRefBlockSpec } from '../../surface-ref-block/surface-ref-spec.js';
export {
EdgelessRootBlockSpec,
EdgelessSurfaceBlockSpec,
EdgelessSurfaceRefBlockSpec,
EdgelessTextBlockSpec,
FrameBlockSpec,
LatexBlockSpec,
};

View File

@@ -0,0 +1,6 @@
import { PageSurfaceBlockSpec } from '@blocksuite/affine-block-surface';
import { PageRootBlockSpec } from '../../root-block/page/page-root-spec.js';
import { PageSurfaceRefBlockSpec } from '../../surface-ref-block/surface-ref-spec.js';
export { PageRootBlockSpec, PageSurfaceBlockSpec, PageSurfaceRefBlockSpec };

View File

@@ -0,0 +1,6 @@
export * from './group/common.js';
export * from './preset/edgeless-specs.js';
export * from './preset/mobile-patch.js';
export * from './preset/page-specs.js';
export * from './preset/preview-specs.js';
export { SpecBuilder, SpecProvider } from '@blocksuite/affine-shared/utils';

View File

@@ -0,0 +1,73 @@
import {
ConnectionOverlay,
EdgelessSurfaceBlockSpec,
} from '@blocksuite/affine-block-surface';
import { FontLoaderService } from '@blocksuite/affine-shared/services';
import type { ExtensionType } from '@blocksuite/block-std';
import { EdgelessTextBlockSpec } from '../../edgeless-text-block/edgeless-text-spec.js';
import { FrameBlockSpec } from '../../frame-block/frame-spec.js';
import { LatexBlockSpec } from '../../latex-block/latex-spec.js';
import { EdgelessRootBlockSpec } from '../../root-block/edgeless/edgeless-root-spec.js';
import {
EdgelessFrameManager,
FrameOverlay,
} from '../../root-block/edgeless/frame-manager.js';
import { BrushTool } from '../../root-block/edgeless/gfx-tool/brush-tool.js';
import { ConnectorTool } from '../../root-block/edgeless/gfx-tool/connector-tool.js';
import { CopilotTool } from '../../root-block/edgeless/gfx-tool/copilot-tool.js';
import { DefaultTool } from '../../root-block/edgeless/gfx-tool/default-tool.js';
import { MindMapIndicatorOverlay } from '../../root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/indicator-overlay.js';
import { EmptyTool } from '../../root-block/edgeless/gfx-tool/empty-tool.js';
import { EraserTool } from '../../root-block/edgeless/gfx-tool/eraser-tool.js';
import { PresentTool } from '../../root-block/edgeless/gfx-tool/frame-navigator-tool.js';
import { FrameTool } from '../../root-block/edgeless/gfx-tool/frame-tool.js';
import { LassoTool } from '../../root-block/edgeless/gfx-tool/lasso-tool.js';
import { NoteTool } from '../../root-block/edgeless/gfx-tool/note-tool.js';
import { PanTool } from '../../root-block/edgeless/gfx-tool/pan-tool.js';
import { ShapeTool } from '../../root-block/edgeless/gfx-tool/shape-tool.js';
import { TemplateTool } from '../../root-block/edgeless/gfx-tool/template-tool.js';
import { TextTool } from '../../root-block/edgeless/gfx-tool/text-tool.js';
import { EditPropsMiddlewareBuilder } from '../../root-block/edgeless/middlewares/base.js';
import { EdgelessSnapManager } from '../../root-block/edgeless/utils/snap-manager.js';
import { EdgelessSurfaceRefBlockSpec } from '../../surface-ref-block/surface-ref-spec.js';
import { EdgelessFirstPartyBlockSpecs } from '../common.js';
export const EdgelessToolExtension: ExtensionType[] = [
DefaultTool,
PanTool,
EraserTool,
TextTool,
ShapeTool,
NoteTool,
BrushTool,
ConnectorTool,
CopilotTool,
TemplateTool,
EmptyTool,
FrameTool,
LassoTool,
PresentTool,
];
export const EdgelessBuiltInManager: ExtensionType[] = [
ConnectionOverlay,
FrameOverlay,
MindMapIndicatorOverlay,
EdgelessSnapManager,
EdgelessFrameManager,
EditPropsMiddlewareBuilder,
];
export const EdgelessEditorBlockSpecs: ExtensionType[] = [
EdgelessRootBlockSpec,
...EdgelessFirstPartyBlockSpecs,
EdgelessSurfaceBlockSpec,
EdgelessSurfaceRefBlockSpec,
FrameBlockSpec,
EdgelessTextBlockSpec,
LatexBlockSpec,
FontLoaderService,
EdgelessToolExtension,
EdgelessBuiltInManager,
].flat();

View File

@@ -0,0 +1,119 @@
import {
type ReferenceNodeConfig,
ReferenceNodeConfigIdentifier,
} from '@blocksuite/affine-components/rich-text';
import {
type BlockStdScope,
ConfigIdentifier,
LifeCycleWatcher,
WidgetViewMapIdentifier,
type WidgetViewMapType,
} from '@blocksuite/block-std';
import type { Container } from '@blocksuite/global/di';
import type { CodeBlockConfig } from '../../code-block/code-block-config.js';
import { AFFINE_EMBED_CARD_TOOLBAR_WIDGET } from '../../root-block/widgets/embed-card-toolbar/embed-card-toolbar.js';
import { AFFINE_FORMAT_BAR_WIDGET } from '../../root-block/widgets/format-bar/format-bar.js';
import { AFFINE_SLASH_MENU_WIDGET } from '../../root-block/widgets/slash-menu/index.js';
export class MobileSpecsPatches extends LifeCycleWatcher {
static override key = 'mobile-patches';
constructor(std: BlockStdScope) {
super(std);
std.doc.awarenessStore.setFlag('enable_mobile_keyboard_toolbar', true);
std.doc.awarenessStore.setFlag('enable_mobile_linked_doc_menu', true);
}
static override setup(di: Container) {
super.setup(di);
// Hide reference popup on mobile.
{
const prev = di.getFactory(ReferenceNodeConfigIdentifier);
di.override(ReferenceNodeConfigIdentifier, provider => {
return {
...prev?.(provider),
hidePopup: true,
} satisfies ReferenceNodeConfig;
});
}
// Hide number lines for code block on mobile.
{
const codeConfigIdentifier = ConfigIdentifier('affine:code');
const prev = di.getFactory(codeConfigIdentifier);
di.override(codeConfigIdentifier, provider => {
return {
...prev?.(provider),
showLineNumbers: false,
} satisfies CodeBlockConfig;
});
}
// Disable root level widgets for mobile.
{
const rootWidgetViewMapIdentifier =
WidgetViewMapIdentifier('affine:page');
const prev = di.getFactory(rootWidgetViewMapIdentifier);
di.override(rootWidgetViewMapIdentifier, provider => {
const ignoreWidgets = [
AFFINE_FORMAT_BAR_WIDGET,
AFFINE_EMBED_CARD_TOOLBAR_WIDGET,
AFFINE_SLASH_MENU_WIDGET,
];
const newMap = { ...prev?.(provider) };
ignoreWidgets.forEach(widget => {
if (widget in newMap) delete newMap[widget];
});
return newMap;
});
}
// Disable block level toolbar widgets for mobile.
{
di.override(
WidgetViewMapIdentifier('affine:code'),
(): WidgetViewMapType => ({})
);
di.override(
WidgetViewMapIdentifier('affine:image'),
(): WidgetViewMapType => ({})
);
di.override(
WidgetViewMapIdentifier('affine:surface-ref'),
(): WidgetViewMapType => ({})
);
}
}
override mounted() {
// remove slash placeholder for mobile: `type / ...`
{
const paragraphService = this.std.getService('affine:paragraph');
if (!paragraphService) return;
paragraphService.placeholderGenerator = model => {
const placeholders = {
text: '',
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
h4: 'Heading 4',
h5: 'Heading 5',
h6: 'Heading 6',
quote: '',
};
return placeholders[model.type];
};
}
}
}

View File

@@ -0,0 +1,17 @@
import { PageSurfaceBlockSpec } from '@blocksuite/affine-block-surface';
import { FontLoaderService } from '@blocksuite/affine-shared/services';
import type { ExtensionType } from '@blocksuite/block-std';
import { LatexBlockSpec } from '../../latex-block/latex-spec.js';
import { PageRootBlockSpec } from '../../root-block/page/page-root-spec.js';
import { PageSurfaceRefBlockSpec } from '../../surface-ref-block/surface-ref-spec.js';
import { CommonFirstPartyBlockSpecs } from '../common.js';
export const PageEditorBlockSpecs: ExtensionType[] = [
PageRootBlockSpec,
...CommonFirstPartyBlockSpecs,
PageSurfaceBlockSpec,
PageSurfaceRefBlockSpec,
LatexBlockSpec,
FontLoaderService,
].flat();

View File

@@ -0,0 +1,64 @@
import {
EdgelessSurfaceBlockSpec,
PageSurfaceBlockSpec,
} from '@blocksuite/affine-block-surface';
import { RefNodeSlotsExtension } from '@blocksuite/affine-components/rich-text';
import {
DocDisplayMetaService,
DocModeService,
EmbedOptionService,
FontLoaderService,
ThemeService,
} from '@blocksuite/affine-shared/services';
import {
BlockViewExtension,
type ExtensionType,
FlavourExtension,
} from '@blocksuite/block-std';
import { literal } from 'lit/static-html.js';
import { EdgelessTextBlockSpec } from '../../edgeless-text-block/index.js';
import { FrameBlockSpec } from '../../frame-block/frame-spec.js';
import { LatexBlockSpec } from '../../latex-block/latex-spec.js';
import { PreviewEdgelessRootBlockSpec } from '../../root-block/edgeless/edgeless-root-spec.js';
import { PageRootService } from '../../root-block/page/page-root-service.js';
import {
EdgelessSurfaceRefBlockSpec,
PageSurfaceRefBlockSpec,
} from '../../surface-ref-block/surface-ref-spec.js';
import {
CommonFirstPartyBlockSpecs,
EdgelessFirstPartyBlockSpecs,
} from '../common.js';
const PreviewPageSpec: ExtensionType[] = [
FlavourExtension('affine:page'),
PageRootService,
DocModeService,
ThemeService,
EmbedOptionService,
BlockViewExtension('affine:page', literal`affine-preview-root`),
DocDisplayMetaService,
];
export const PreviewEdgelessEditorBlockSpecs: ExtensionType[] = [
PreviewEdgelessRootBlockSpec,
...EdgelessFirstPartyBlockSpecs,
EdgelessSurfaceBlockSpec,
EdgelessSurfaceRefBlockSpec,
FrameBlockSpec,
EdgelessTextBlockSpec,
LatexBlockSpec,
FontLoaderService,
RefNodeSlotsExtension(),
].flat();
export const PreviewEditorBlockSpecs: ExtensionType[] = [
PreviewPageSpec,
...CommonFirstPartyBlockSpecs,
PageSurfaceBlockSpec,
PageSurfaceRefBlockSpec,
LatexBlockSpec,
FontLoaderService,
RefNodeSlotsExtension(),
].flat();

View File

@@ -0,0 +1,18 @@
import { SpecProvider } from '@blocksuite/affine-shared/utils';
import { EdgelessEditorBlockSpecs } from './preset/edgeless-specs.js';
import { PageEditorBlockSpecs } from './preset/page-specs.js';
import {
PreviewEdgelessEditorBlockSpecs,
PreviewEditorBlockSpecs,
} from './preset/preview-specs.js';
export function registerSpecs() {
SpecProvider.getInstance().addSpec('page', PageEditorBlockSpecs);
SpecProvider.getInstance().addSpec('edgeless', EdgelessEditorBlockSpecs);
SpecProvider.getInstance().addSpec('page:preview', PreviewEditorBlockSpecs);
SpecProvider.getInstance().addSpec(
'edgeless:preview',
PreviewEdgelessEditorBlockSpecs
);
}

View File

@@ -0,0 +1,115 @@
import { AttachmentBlockSchema } from '@blocksuite/affine-model';
import {
BlockNotionHtmlAdapterExtension,
type BlockNotionHtmlAdapterMatcher,
FetchUtils,
HastUtils,
} from '@blocksuite/affine-shared/adapters';
import { getFilenameFromContentDisposition } from '@blocksuite/affine-shared/utils';
import { sha } from '@blocksuite/global/utils';
import { getAssetName, nanoid } from '@blocksuite/store';
export const attachmentBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
{
flavour: AttachmentBlockSchema.model.flavour,
toMatch: o => {
return (
HastUtils.isElement(o.node) &&
o.node.tagName === 'figure' &&
!!HastUtils.querySelector(o.node, '.source')
);
},
fromMatch: () => false,
toBlockSnapshot: {
enter: async (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { assets, walkerContext } = context;
if (!assets) {
return;
}
const embededFigureWrapper = HastUtils.querySelector(o.node, '.source');
let embededURL = '';
if (embededFigureWrapper) {
const embedA = HastUtils.querySelector(embededFigureWrapper, 'a');
embededURL =
typeof embedA?.properties.href === 'string'
? embedA.properties.href
: '';
}
if (embededURL) {
let blobId = '';
let name = '';
let type = '';
let size = 0;
if (!FetchUtils.fetchable(embededURL)) {
const embededURLSplit = embededURL.split('/');
while (embededURLSplit.length > 0) {
const key = assets
.getPathBlobIdMap()
.get(decodeURIComponent(embededURLSplit.join('/')));
if (key) {
blobId = key;
break;
}
embededURLSplit.shift();
}
const value = assets.getAssets().get(blobId);
if (value) {
name = getAssetName(assets.getAssets(), blobId);
size = value.size;
type = value.type;
}
} else {
const res = await fetch(embededURL).catch(error => {
console.warn('Error fetching embed:', error);
return null;
});
if (!res) {
return;
}
const resCloned = res.clone();
name =
getFilenameFromContentDisposition(
res.headers.get('Content-Disposition') ?? ''
) ??
(embededURL.split('/').at(-1) ?? 'file') +
'.' +
(res.headers.get('Content-Type')?.split('/').at(-1) ?? 'blob');
const file = new File([await res.blob()], name, {
type: res.headers.get('Content-Type') ?? '',
});
size = file.size;
type = file.type;
blobId = await sha(await resCloned.arrayBuffer());
assets?.getAssets().set(blobId, file);
await assets?.writeToBlob(blobId);
}
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: AttachmentBlockSchema.model.flavour,
props: {
name,
size,
type,
sourceId: blobId,
},
children: [],
},
'children'
)
.closeNode();
walkerContext.skipAllChildren();
}
},
},
fromBlockSnapshot: {},
};
export const AttachmentBlockNotionHtmlAdapterExtension =
BlockNotionHtmlAdapterExtension(attachmentBlockNotionHtmlAdapterMatcher);

View File

@@ -0,0 +1,299 @@
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import { HoverController } from '@blocksuite/affine-components/hover';
import {
AttachmentIcon16,
getAttachmentFileIcons,
} from '@blocksuite/affine-components/icons';
import { Peekable } from '@blocksuite/affine-components/peek';
import { toast } from '@blocksuite/affine-components/toast';
import {
type AttachmentBlockModel,
AttachmentBlockStyles,
} from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { humanFileSize } from '@blocksuite/affine-shared/utils';
import { Slice } from '@blocksuite/store';
import { flip, offset } from '@floating-ui/dom';
import { html, nothing } from 'lit';
import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import { getEmbedCardIcons } from '../_common/utils/url.js';
import type { AttachmentBlockService } from './attachment-service.js';
import { AttachmentOptionsTemplate } from './components/options.js';
import { AttachmentEmbedProvider } from './embed.js';
import { styles } from './styles.js';
import { checkAttachmentBlob, downloadAttachmentBlob } from './utils.js';
@Peekable()
export class AttachmentBlockComponent extends CaptionedBlockComponent<
AttachmentBlockModel,
AttachmentBlockService
> {
static override styles = styles;
protected _isDragging = false;
protected _isResizing = false;
protected _isSelected = false;
protected _whenHover: HoverController | null = new HoverController(
this,
({ abortController }) => {
const selection = this.host.selection;
const textSelection = selection.find('text');
if (
!!textSelection &&
(!!textSelection.to || !!textSelection.from.length)
) {
return null;
}
const blockSelections = selection.filter('block');
if (
blockSelections.length > 1 ||
(blockSelections.length === 1 &&
blockSelections[0].blockId !== this.blockId)
) {
return null;
}
return {
template: AttachmentOptionsTemplate({
block: this,
model: this.model,
abortController,
}),
computePosition: {
referenceElement: this,
placement: 'top-start',
middleware: [flip(), offset(4)],
autoUpdate: true,
},
};
}
);
blockDraggable = true;
protected containerStyleMap = styleMap({
position: 'relative',
width: '100%',
margin: '18px 0px',
});
convertTo = () => {
return this.std
.get(AttachmentEmbedProvider)
.convertTo(this.model, this.service.maxFileSize);
};
copy = () => {
const slice = Slice.fromModels(this.doc, [this.model]);
this.std.clipboard.copySlice(slice).catch(console.error);
toast(this.host, 'Copied to clipboard');
};
download = () => {
downloadAttachmentBlob(this);
};
embedded = () => {
return this.std
.get(AttachmentEmbedProvider)
.embedded(this.model, this.service.maxFileSize);
};
open = () => {
if (!this.blobUrl) {
return;
}
window.open(this.blobUrl, '_blank');
};
refreshData = () => {
checkAttachmentBlob(this).catch(console.error);
};
protected get embedView() {
return this.std
.get(AttachmentEmbedProvider)
.render(this.model, this.blobUrl, this.service.maxFileSize);
}
private _selectBlock() {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create('block', {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
override connectedCallback() {
super.connectedCallback();
this.refreshData();
this.contentEditable = 'false';
if (!this.model.style) {
this.doc.withoutTransact(() => {
this.doc.updateBlock(this.model, {
style: AttachmentBlockStyles[1],
});
});
}
this.model.propsUpdated.on(({ key }) => {
if (key === 'sourceId') {
// Reset the blob url when the sourceId is changed
if (this.blobUrl) {
URL.revokeObjectURL(this.blobUrl);
this.blobUrl = undefined;
}
this.refreshData();
}
});
// Workaround for https://github.com/toeverything/blocksuite/issues/4724
this.disposables.add(
this.std.get(ThemeProvider).theme$.subscribe(() => this.requestUpdate())
);
// this is required to prevent iframe from capturing pointer events
this.disposables.add(
this.std.selection.slots.changed.on(() => {
this._isSelected =
!!this.selected?.is('block') || !!this.selected?.is('surface');
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
})
);
// this is required to prevent iframe from capturing pointer events
this.handleEvent('dragStart', () => {
this._isDragging = true;
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
});
this.handleEvent('dragEnd', () => {
this._isDragging = false;
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
});
}
override disconnectedCallback() {
if (this.blobUrl) {
URL.revokeObjectURL(this.blobUrl);
}
super.disconnectedCallback();
}
override firstUpdated() {
// lazy bindings
this.disposables.addFromEvent(this, 'click', this.onClick);
}
protected onClick(event: MouseEvent) {
// the peek view need handle shift + click
if (event.defaultPrevented) return;
event.stopPropagation();
this._selectBlock();
}
override renderBlock() {
const { name, size, style } = this.model;
const cardStyle = style ?? AttachmentBlockStyles[1];
const theme = this.std.get(ThemeProvider).theme;
const { LoadingIcon } = getEmbedCardIcons(theme);
const titleIcon = this.loading ? LoadingIcon : AttachmentIcon16;
const titleText = this.loading ? 'Loading...' : name;
const infoText = this.error ? 'File loading failed.' : humanFileSize(size);
const fileType = name.split('.').pop() ?? '';
const FileTypeIcon = getAttachmentFileIcons(fileType);
const embedView = this.embedView;
return html`
<div
${this._whenHover ? ref(this._whenHover.setReference) : nothing}
class="affine-attachment-container"
draggable="${this.blockDraggable ? 'true' : 'false'}"
style=${this.containerStyleMap}
>
${embedView
? html`<div class="affine-attachment-embed-container">
${embedView}
<div
class=${classMap({
'affine-attachment-iframe-overlay': true,
hide: !this._showOverlay,
})}
></div>
</div>`
: html`<div
class=${classMap({
'affine-attachment-card': true,
[cardStyle]: true,
loading: this.loading,
error: this.error,
unsynced: false,
})}
>
<div class="affine-attachment-content">
<div class="affine-attachment-content-title">
<div class="affine-attachment-content-title-icon">
${titleIcon}
</div>
<div class="affine-attachment-content-title-text">
${titleText}
</div>
</div>
<div class="affine-attachment-content-info">${infoText}</div>
</div>
<div class="affine-attachment-banner">${FileTypeIcon}</div>
</div>`}
</div>
`;
}
@state()
protected accessor _showOverlay = true;
@property({ attribute: false })
accessor allowEmbed = false;
@property({ attribute: false })
accessor blobUrl: string | undefined = undefined;
@property({ attribute: false })
accessor downloading = false;
@property({ attribute: false })
accessor error = false;
@property({ attribute: false })
accessor loading = false;
override accessor useCaptionEditor = true;
}
declare global {
interface HTMLElementTagNameMap {
'affine-attachment': AttachmentBlockComponent;
}
}

View File

@@ -0,0 +1,74 @@
import type { HoverController } from '@blocksuite/affine-components/hover';
import { AttachmentBlockStyles } from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import { toGfxBlockComponent } from '@blocksuite/block-std';
import { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootService } from '../root-block/index.js';
import { AttachmentBlockComponent } from './attachment-block.js';
export class AttachmentEdgelessBlockComponent extends toGfxBlockComponent(
AttachmentBlockComponent
) {
protected override _whenHover: HoverController | null = null;
override blockDraggable = false;
get rootService() {
return this.std.getService('affine:page') as EdgelessRootService;
}
override connectedCallback(): void {
super.connectedCallback();
const rootService = this.rootService;
this._disposables.add(
rootService.slots.elementResizeStart.on(() => {
this._isResizing = true;
this._showOverlay = true;
})
);
this._disposables.add(
rootService.slots.elementResizeEnd.on(() => {
this._isResizing = false;
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
})
);
}
override onClick(_: MouseEvent) {
return;
}
override renderGfxBlock() {
const { style$ } = this.model;
const cardStyle = style$.value ?? AttachmentBlockStyles[1];
const width = EMBED_CARD_WIDTH[cardStyle];
const height = EMBED_CARD_HEIGHT[cardStyle];
const bound = this.model.elementBound;
const scaleX = bound.w / width;
const scaleY = bound.h / height;
this.containerStyleMap = styleMap({
width: `${width}px`,
height: `${height}px`,
transform: `scale(${scaleX}, ${scaleY})`,
transformOrigin: '0 0',
overflow: 'hidden',
});
return this.renderPageContent();
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-edgeless-attachment': AttachmentEdgelessBlockComponent;
}
}

View File

@@ -0,0 +1,128 @@
import { AttachmentBlockSchema } from '@blocksuite/affine-model';
import {
DragHandleConfigExtension,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import {
captureEventTarget,
convertDragPreviewDocToEdgeless,
convertDragPreviewEdgelessToDoc,
isInsideEdgelessEditor,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import { BlockService } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import {
FileDropManager,
type FileDropOptions,
} from '../_common/components/file-drop-manager.js';
import { EMBED_CARD_HEIGHT, EMBED_CARD_WIDTH } from '../_common/consts.js';
import { addAttachments } from '../root-block/edgeless/utils/common.js';
import type { AttachmentBlockComponent } from './attachment-block.js';
import { AttachmentEdgelessBlockComponent } from './attachment-edgeless-block.js';
import { addSiblingAttachmentBlocks } from './utils.js';
export class AttachmentBlockService extends BlockService {
static override readonly flavour = AttachmentBlockSchema.model.flavour;
private _fileDropOptions: FileDropOptions = {
flavour: this.flavour,
onDrop: async ({ files, targetModel, place, point }) => {
if (!files.length) return false;
// generic attachment block for all files except images
const attachmentFiles = files.filter(
file => !file.type.startsWith('image/')
);
if (targetModel && !matchFlavours(targetModel, ['affine:surface'])) {
await addSiblingAttachmentBlocks(
this.host,
attachmentFiles,
this.maxFileSize,
targetModel,
place
);
} else if (isInsideEdgelessEditor(this.host)) {
const gfx = this.std.get(GfxControllerIdentifier);
point = gfx.viewport.toViewCoordFromClientCoord(point);
await addAttachments(this.std, attachmentFiles, point);
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'canvas:drop',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'attachment',
});
}
return true;
},
};
fileDropManager!: FileDropManager;
maxFileSize = 10 * 1000 * 1000; // 10MB (default)
override mounted(): void {
super.mounted();
this.fileDropManager = new FileDropManager(this, this._fileDropOptions);
}
}
export const AttachmentDragHandleOption = DragHandleConfigExtension({
flavour: AttachmentBlockSchema.model.flavour,
edgeless: true,
onDragEnd: props => {
const { state, draggingElements, editorHost } = props;
if (
draggingElements.length !== 1 ||
!matchFlavours(draggingElements[0].model, [
AttachmentBlockSchema.model.flavour,
])
)
return false;
const blockComponent = draggingElements[0] as
| AttachmentBlockComponent
| AttachmentEdgelessBlockComponent;
const isInSurface =
blockComponent instanceof AttachmentEdgelessBlockComponent;
const target = captureEventTarget(state.raw.target);
const isTargetEdgelessContainer =
target?.classList.contains('edgeless-container');
if (isInSurface) {
const style = blockComponent.model.style;
const targetStyle = style === 'cubeThick' ? 'horizontalThin' : style;
return convertDragPreviewEdgelessToDoc({
blockComponent,
style: targetStyle,
...props,
});
} else if (isTargetEdgelessContainer) {
let style = blockComponent.model.style ?? 'cubeThick';
const embed = blockComponent.model.embed;
if (embed) {
style = 'cubeThick';
editorHost.doc.updateBlock(blockComponent.model, {
style,
embed: false,
});
}
return convertDragPreviewDocToEdgeless({
blockComponent,
cssSelector: '.affine-attachment-container',
width: EMBED_CARD_WIDTH[style],
height: EMBED_CARD_HEIGHT[style],
...props,
});
}
return false;
},
});

View File

@@ -0,0 +1,28 @@
import {
BlockViewExtension,
type ExtensionType,
FlavourExtension,
} from '@blocksuite/block-std';
import { literal } from 'lit/static-html.js';
import {
AttachmentBlockService,
AttachmentDragHandleOption,
} from './attachment-service.js';
import {
AttachmentEmbedConfigExtension,
AttachmentEmbedService,
} from './embed.js';
export const AttachmentBlockSpec: ExtensionType[] = [
FlavourExtension('affine:attachment'),
AttachmentBlockService,
BlockViewExtension('affine:attachment', model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-edgeless-attachment`
: literal`affine-attachment`;
}),
AttachmentDragHandleOption,
AttachmentEmbedConfigExtension(),
AttachmentEmbedService,
];

View File

@@ -0,0 +1,77 @@
import {
CopyIcon,
DeleteIcon,
DownloadIcon,
DuplicateIcon,
RefreshIcon,
} from '@blocksuite/affine-components/icons';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { cloneAttachmentProperties } from '../utils.js';
import type { AttachmentToolbarMoreMenuContext } from './context.js';
export const BUILT_IN_GROUPS: MenuItemGroup<AttachmentToolbarMoreMenuContext>[] =
[
{
type: 'clipboard',
items: [
{
type: 'copy',
label: 'Copy',
icon: CopyIcon,
disabled: ({ doc }) => doc.readonly,
action: ctx => ctx.blockComponent.copy(),
},
{
type: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon,
disabled: ({ doc }) => doc.readonly,
action: ({ doc, blockComponent, close }) => {
const model = blockComponent.model;
const prop: { flavour: 'affine:attachment' } = {
flavour: 'affine:attachment',
...cloneAttachmentProperties(model),
};
doc.addSiblingBlocks(model, [prop]);
close();
},
},
{
type: 'reload',
label: 'Reload',
icon: RefreshIcon,
disabled: ({ doc }) => doc.readonly,
action: ({ blockComponent, close }) => {
blockComponent.refreshData();
close();
},
},
{
type: 'download',
label: 'Download',
icon: DownloadIcon,
disabled: ({ doc }) => doc.readonly,
action: ({ blockComponent, close }) => {
blockComponent.download();
close();
},
},
],
},
{
type: 'delete',
items: [
{
type: 'delete',
label: 'Delete',
icon: DeleteIcon,
disabled: ({ doc }) => doc.readonly,
action: ({ doc, blockComponent, close }) => {
doc.deleteBlock(blockComponent.model);
close();
},
},
],
},
];

Some files were not shown because too many files have changed in this diff Show More