mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
139
blocksuite/blocks/src/_common/adapters/attachment.ts
Normal file
139
blocksuite/blocks/src/_common/adapters/attachment.ts
Normal 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),
|
||||
}));
|
||||
},
|
||||
};
|
||||
32
blocksuite/blocks/src/_common/adapters/extension.ts
Normal file
32
blocksuite/blocks/src/_common/adapters/extension.ts
Normal 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();
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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,
|
||||
];
|
||||
385
blocksuite/blocks/src/_common/adapters/html-adapter/html.ts
Normal file
385
blocksuite/blocks/src/_common/adapters/html-adapter/html.ts
Normal 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()
|
||||
)
|
||||
),
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
BlockHtmlAdapterExtensions,
|
||||
defaultBlockHtmlAdapterMatchers,
|
||||
} from './block-matcher.js';
|
||||
export {
|
||||
HtmlAdapter,
|
||||
HtmlAdapterFactoryExtension,
|
||||
HtmlAdapterFactoryIdentifier,
|
||||
} from './html.js';
|
||||
130
blocksuite/blocks/src/_common/adapters/image.ts
Normal file
130
blocksuite/blocks/src/_common/adapters/image.ts
Normal 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),
|
||||
}));
|
||||
},
|
||||
};
|
||||
8
blocksuite/blocks/src/_common/adapters/index.ts
Normal file
8
blocksuite/blocks/src/_common/adapters/index.ts
Normal 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';
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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,
|
||||
];
|
||||
69
blocksuite/blocks/src/_common/adapters/markdown/gfm.ts
Normal file
69
blocksuite/blocks/src/_common/adapters/markdown/gfm.ts
Normal 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());
|
||||
}
|
||||
9
blocksuite/blocks/src/_common/adapters/markdown/index.ts
Normal file
9
blocksuite/blocks/src/_common/adapters/markdown/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
BlockMarkdownAdapterExtensions,
|
||||
defaultBlockMarkdownAdapterMatchers,
|
||||
} from './block-matcher.js';
|
||||
export {
|
||||
MarkdownAdapter,
|
||||
MarkdownAdapterFactoryExtension,
|
||||
MarkdownAdapterFactoryIdentifier,
|
||||
} from './markdown.js';
|
||||
455
blocksuite/blocks/src/_common/adapters/markdown/markdown.ts
Normal file
455
blocksuite/blocks/src/_common/adapters/markdown/markdown.ts
Normal 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(/ \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(/^ /, ' ');
|
||||
})
|
||||
.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()
|
||||
)
|
||||
),
|
||||
}));
|
||||
},
|
||||
};
|
||||
363
blocksuite/blocks/src/_common/adapters/mix-text.ts
Normal file
363
blocksuite/blocks/src/_common/adapters/mix-text.ts
Normal 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),
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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,
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
BlockNotionHtmlAdapterExtensions,
|
||||
defaultBlockNotionHtmlAdapterMatchers,
|
||||
} from './block-matcher.js';
|
||||
export {
|
||||
NotionHtmlAdapter,
|
||||
NotionHtmlAdapterFactoryExtension,
|
||||
NotionHtmlAdapterFactoryIdentifier,
|
||||
} from './notion-html.js';
|
||||
@@ -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()
|
||||
)
|
||||
),
|
||||
}));
|
||||
},
|
||||
};
|
||||
170
blocksuite/blocks/src/_common/adapters/notion-text.ts
Normal file
170
blocksuite/blocks/src/_common/adapters/notion-text.ts
Normal 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),
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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,
|
||||
];
|
||||
321
blocksuite/blocks/src/_common/adapters/plain-text/plain-text.ts
Normal file
321
blocksuite/blocks/src/_common/adapters/plain-text/plain-text.ts
Normal 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()
|
||||
)
|
||||
),
|
||||
}));
|
||||
},
|
||||
};
|
||||
10
blocksuite/blocks/src/_common/adapters/type.ts
Normal file
10
blocksuite/blocks/src/_common/adapters/type.ts
Normal 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');
|
||||
150
blocksuite/blocks/src/_common/components/ai-item/ai-item-list.ts
Normal file
150
blocksuite/blocks/src/_common/components/ai-item/ai-item-list.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
66
blocksuite/blocks/src/_common/components/ai-item/ai-item.ts
Normal file
66
blocksuite/blocks/src/_common/components/ai-item/ai-item.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const SUBMENU_OFFSET_MAIN_AXIS = 12;
|
||||
export const SUBMENU_OFFSET_CROSS_AXIS = -60;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ai-item-list.js';
|
||||
export * from './types.js';
|
||||
71
blocksuite/blocks/src/_common/components/ai-item/styles.ts
Normal file
71
blocksuite/blocks/src/_common/components/ai-item/styles.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
68
blocksuite/blocks/src/_common/components/ai-item/types.ts
Normal file
68
blocksuite/blocks/src/_common/components/ai-item/types.ts
Normal 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;
|
||||
74
blocksuite/blocks/src/_common/components/block-selection.ts
Normal file
74
blocksuite/blocks/src/_common/components/block-selection.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
53
blocksuite/blocks/src/_common/components/block-zero-width.ts
Normal file
53
blocksuite/blocks/src/_common/components/block-zero-width.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
242
blocksuite/blocks/src/_common/components/button.ts
Normal file
242
blocksuite/blocks/src/_common/components/button.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './embed-card-create-modal.js';
|
||||
export * from './embed-card-edit-modal.js';
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
79
blocksuite/blocks/src/_common/components/embed-card/type.ts
Normal file
79
blocksuite/blocks/src/_common/components/embed-card/type.ts
Normal 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
|
||||
);
|
||||
}
|
||||
173
blocksuite/blocks/src/_common/components/file-drop-manager.ts
Normal file
173
blocksuite/blocks/src/_common/components/file-drop-manager.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
6
blocksuite/blocks/src/_common/components/index.ts
Normal file
6
blocksuite/blocks/src/_common/components/index.ts
Normal 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';
|
||||
103
blocksuite/blocks/src/_common/components/loader.ts
Normal file
103
blocksuite/blocks/src/_common/components/loader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
50
blocksuite/blocks/src/_common/components/menu-divider.ts
Normal file
50
blocksuite/blocks/src/_common/components/menu-divider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
184
blocksuite/blocks/src/_common/components/smooth-corner.ts
Normal file
184
blocksuite/blocks/src/_common/components/smooth-corner.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
89
blocksuite/blocks/src/_common/components/toggle-switch.ts
Normal file
89
blocksuite/blocks/src/_common/components/toggle-switch.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
256
blocksuite/blocks/src/_common/components/utils.ts
Normal file
256
blocksuite/blocks/src/_common/components/utils.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
};
|
||||
125
blocksuite/blocks/src/_common/configs/move-block.ts
Normal file
125
blocksuite/blocks/src/_common/configs/move-block.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
];
|
||||
152
blocksuite/blocks/src/_common/configs/quick-action/config.ts
Normal file
152
blocksuite/blocks/src/_common/configs/quick-action/config.ts
Normal 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);
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
136
blocksuite/blocks/src/_common/configs/text-conversion.ts
Normal file
136
blocksuite/blocks/src/_common/configs/text-conversion.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
1
blocksuite/blocks/src/_common/consts.ts
Normal file
1
blocksuite/blocks/src/_common/consts.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/affine-shared/consts';
|
||||
1
blocksuite/blocks/src/_common/edgeless/frame/consts.ts
Normal file
1
blocksuite/blocks/src/_common/edgeless/frame/consts.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type NavigatorMode = 'fill' | 'fit';
|
||||
68
blocksuite/blocks/src/_common/edgeless/mindmap/index.ts
Normal file
68
blocksuite/blocks/src/_common/edgeless/mindmap/index.ts
Normal 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];
|
||||
}
|
||||
590
blocksuite/blocks/src/_common/export-manager/export-manager.ts
Normal file
590
blocksuite/blocks/src/_common/export-manager/export-manager.ts
Normal 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]);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
50
blocksuite/blocks/src/_common/test-utils/test-utils.ts
Normal file
50
blocksuite/blocks/src/_common/test-utils/test-utils.ts
Normal 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));
|
||||
}
|
||||
168
blocksuite/blocks/src/_common/transformers/html.ts
Normal file
168
blocksuite/blocks/src/_common/transformers/html.ts
Normal 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,
|
||||
};
|
||||
15
blocksuite/blocks/src/_common/transformers/index.ts
Normal file
15
blocksuite/blocks/src/_common/transformers/index.ts
Normal 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';
|
||||
217
blocksuite/blocks/src/_common/transformers/markdown.ts
Normal file
217
blocksuite/blocks/src/_common/transformers/markdown.ts
Normal 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,
|
||||
};
|
||||
292
blocksuite/blocks/src/_common/transformers/middlewares.ts
Normal file
292
blocksuite/blocks/src/_common/transformers/middlewares.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
};
|
||||
146
blocksuite/blocks/src/_common/transformers/notion-html.ts
Normal file
146
blocksuite/blocks/src/_common/transformers/notion-html.ts
Normal 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,
|
||||
};
|
||||
115
blocksuite/blocks/src/_common/transformers/utils.ts
Normal file
115
blocksuite/blocks/src/_common/transformers/utils.ts
Normal 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);
|
||||
}
|
||||
120
blocksuite/blocks/src/_common/transformers/zip.ts
Normal file
120
blocksuite/blocks/src/_common/transformers/zip.ts
Normal 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,
|
||||
};
|
||||
28
blocksuite/blocks/src/_common/types.ts
Normal file
28
blocksuite/blocks/src/_common/types.ts
Normal 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';
|
||||
174
blocksuite/blocks/src/_common/utils/drag-and-drop.ts
Normal file
174
blocksuite/blocks/src/_common/utils/drag-and-drop.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
17
blocksuite/blocks/src/_common/utils/index.ts
Normal file
17
blocksuite/blocks/src/_common/utils/index.ts
Normal 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';
|
||||
211
blocksuite/blocks/src/_common/utils/query.ts
Normal file
211
blocksuite/blocks/src/_common/utils/query.ts
Normal 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));
|
||||
}
|
||||
246
blocksuite/blocks/src/_common/utils/render-linked-doc.ts
Normal file
246
blocksuite/blocks/src/_common/utils/render-linked-doc.ts
Normal 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;
|
||||
}
|
||||
89
blocksuite/blocks/src/_common/utils/url.ts
Normal file
89
blocksuite/blocks/src/_common/utils/url.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user