chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -0,0 +1,31 @@
import type { ExtensionType } from '@blocksuite/block-std';
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { BlockAdapterMatcher } from '../types/adapter.js';
import type { HtmlAST } from '../types/hast.js';
import type { HtmlDeltaConverter } from './delta-converter.js';
export type BlockHtmlAdapterMatcher = BlockAdapterMatcher<
HtmlAST,
HtmlDeltaConverter
>;
export const BlockHtmlAdapterMatcherIdentifier =
createIdentifier<BlockHtmlAdapterMatcher>('BlockHtmlAdapterMatcher');
export function BlockHtmlAdapterExtension(
matcher: BlockHtmlAdapterMatcher
): ExtensionType & {
identifier: ServiceIdentifier<BlockHtmlAdapterMatcher>;
} {
const identifier = BlockHtmlAdapterMatcherIdentifier(matcher.flavour);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
@@ -0,0 +1,134 @@
import type { ExtensionType } from '@blocksuite/block-std';
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { DeltaInsert } from '@blocksuite/inline';
import type { AffineTextAttributes } from '../../types/index.js';
import {
type ASTToDeltaMatcher,
DeltaASTConverter,
type DeltaASTConverterOptions,
type InlineDeltaMatcher,
} from '../types/adapter.js';
import type { HtmlAST, InlineHtmlAST } from '../types/hast.js';
import { TextUtils } from '../utils/text.js';
export type InlineDeltaToHtmlAdapterMatcher = InlineDeltaMatcher<InlineHtmlAST>;
export const InlineDeltaToHtmlAdapterMatcherIdentifier =
createIdentifier<InlineDeltaToHtmlAdapterMatcher>(
'InlineDeltaToHtmlAdapterMatcher'
);
export function InlineDeltaToHtmlAdapterExtension(
matcher: InlineDeltaToHtmlAdapterMatcher
): ExtensionType & {
identifier: ServiceIdentifier<InlineDeltaToHtmlAdapterMatcher>;
} {
const identifier = InlineDeltaToHtmlAdapterMatcherIdentifier(matcher.name);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
export type HtmlASTToDeltaMatcher = ASTToDeltaMatcher<HtmlAST>;
export const HtmlASTToDeltaMatcherIdentifier =
createIdentifier<HtmlASTToDeltaMatcher>('HtmlASTToDeltaMatcher');
export function HtmlASTToDeltaExtension(
matcher: HtmlASTToDeltaMatcher
): ExtensionType & {
identifier: ServiceIdentifier<HtmlASTToDeltaMatcher>;
} {
const identifier = HtmlASTToDeltaMatcherIdentifier(matcher.name);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
export class HtmlDeltaConverter extends DeltaASTConverter<
AffineTextAttributes,
HtmlAST
> {
constructor(
readonly configs: Map<string, string>,
readonly inlineDeltaMatchers: InlineDeltaToHtmlAdapterMatcher[],
readonly htmlASTToDeltaMatchers: HtmlASTToDeltaMatcher[]
) {
super();
}
private _applyTextFormatting(
delta: DeltaInsert<AffineTextAttributes>
): InlineHtmlAST {
let hast: InlineHtmlAST = {
type: 'text',
value: delta.insert,
};
const context: {
configs: Map<string, string>;
current: InlineHtmlAST;
} = {
configs: this.configs,
current: hast,
};
for (const matcher of this.inlineDeltaMatchers) {
if (matcher.match(delta)) {
hast = matcher.toAST(delta, context);
context.current = hast;
}
}
return hast;
}
private _spreadAstToDelta(
ast: HtmlAST,
options: DeltaASTConverterOptions = Object.create(null)
): DeltaInsert<AffineTextAttributes>[] {
const context = {
configs: this.configs,
options,
toDelta: (ast: HtmlAST, options?: DeltaASTConverterOptions) =>
this._spreadAstToDelta(ast, options),
};
for (const matcher of this.htmlASTToDeltaMatchers) {
if (matcher.match(ast)) {
return matcher.toDelta(ast, context);
}
}
return 'children' in ast
? ast.children.flatMap(child => this._spreadAstToDelta(child, options))
: [];
}
astToDelta(
ast: HtmlAST,
options: DeltaASTConverterOptions = Object.create(null)
): DeltaInsert<AffineTextAttributes>[] {
return this._spreadAstToDelta(ast, options).reduce((acc, cur) => {
return TextUtils.mergeDeltas(acc, cur);
}, [] as DeltaInsert<AffineTextAttributes>[]);
}
deltaToAST(
deltas: DeltaInsert<AffineTextAttributes>[],
depth = 0
): InlineHtmlAST[] {
if (depth > 0) {
deltas.unshift({ insert: ' '.repeat(4).repeat(depth) });
}
return deltas.map(delta => this._applyTextFormatting(delta));
}
}
@@ -0,0 +1,3 @@
export * from './block-adapter.js';
export * from './delta-converter.js';
export * from './type.js';
@@ -0,0 +1 @@
export type Html = string;
@@ -0,0 +1,53 @@
export {
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
BlockHtmlAdapterMatcherIdentifier,
type Html,
type HtmlASTToDeltaMatcher,
HtmlASTToDeltaMatcherIdentifier,
HtmlDeltaConverter,
type InlineDeltaToHtmlAdapterMatcher,
InlineDeltaToHtmlAdapterMatcherIdentifier,
} from './html-adapter/index.js';
export {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
BlockMarkdownAdapterMatcherIdentifier,
type InlineDeltaToMarkdownAdapterMatcher,
InlineDeltaToMarkdownAdapterMatcherIdentifier,
isMarkdownAST,
type Markdown,
type MarkdownAST,
type MarkdownASTToDeltaMatcher,
MarkdownASTToDeltaMatcherIdentifier,
MarkdownDeltaConverter,
} from './markdown/index.js';
export {
BlockNotionHtmlAdapterExtension,
type BlockNotionHtmlAdapterMatcher,
BlockNotionHtmlAdapterMatcherIdentifier,
type InlineDeltaToNotionHtmlAdapterMatcher,
type NotionHtml,
type NotionHtmlASTToDeltaMatcher,
NotionHtmlASTToDeltaMatcherIdentifier,
NotionHtmlDeltaConverter,
} from './notion-html/index.js';
export {
BlockPlainTextAdapterExtension,
type BlockPlainTextAdapterMatcher,
BlockPlainTextAdapterMatcherIdentifier,
type InlineDeltaToPlainTextAdapterMatcher,
InlineDeltaToPlainTextAdapterMatcherIdentifier,
type PlainText,
PlainTextDeltaConverter,
} from './plain-text/index.js';
export {
type AdapterContext,
type BlockAdapterMatcher,
DeltaASTConverter,
type HtmlAST,
type InlineHtmlAST,
isBlockSnapshotNode,
type TextBuffer,
} from './types/index.js';
export * from './utils/index.js';
@@ -0,0 +1,31 @@
import type { ExtensionType } from '@blocksuite/block-std';
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { BlockAdapterMatcher } from '../types/adapter.js';
import type { MarkdownDeltaConverter } from './delta-converter.js';
import type { MarkdownAST } from './type.js';
export type BlockMarkdownAdapterMatcher = BlockAdapterMatcher<
MarkdownAST,
MarkdownDeltaConverter
>;
export const BlockMarkdownAdapterMatcherIdentifier =
createIdentifier<BlockMarkdownAdapterMatcher>('BlockMarkdownAdapterMatcher');
export function BlockMarkdownAdapterExtension(
matcher: BlockMarkdownAdapterMatcher
): ExtensionType & {
identifier: ServiceIdentifier<BlockMarkdownAdapterMatcher>;
} {
const identifier = BlockMarkdownAdapterMatcherIdentifier(matcher.flavour);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
@@ -0,0 +1,125 @@
import type { ExtensionType } from '@blocksuite/block-std';
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { DeltaInsert } from '@blocksuite/inline/types';
import type { PhrasingContent } from 'mdast';
import type { AffineTextAttributes } from '../../types/index.js';
import {
type ASTToDeltaMatcher,
DeltaASTConverter,
type InlineDeltaMatcher,
} from '../types/adapter.js';
import type { MarkdownAST } from './type.js';
export type InlineDeltaToMarkdownAdapterMatcher =
InlineDeltaMatcher<PhrasingContent>;
export const InlineDeltaToMarkdownAdapterMatcherIdentifier =
createIdentifier<InlineDeltaToMarkdownAdapterMatcher>(
'InlineDeltaToMarkdownAdapterMatcher'
);
export function InlineDeltaToMarkdownAdapterExtension(
matcher: InlineDeltaToMarkdownAdapterMatcher
): ExtensionType & {
identifier: ServiceIdentifier<InlineDeltaToMarkdownAdapterMatcher>;
} {
const identifier = InlineDeltaToMarkdownAdapterMatcherIdentifier(
matcher.name
);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
export type MarkdownASTToDeltaMatcher = ASTToDeltaMatcher<MarkdownAST>;
export const MarkdownASTToDeltaMatcherIdentifier =
createIdentifier<MarkdownASTToDeltaMatcher>('MarkdownASTToDeltaMatcher');
export function MarkdownASTToDeltaExtension(
matcher: MarkdownASTToDeltaMatcher
): ExtensionType & {
identifier: ServiceIdentifier<MarkdownASTToDeltaMatcher>;
} {
const identifier = MarkdownASTToDeltaMatcherIdentifier(matcher.name);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
export class MarkdownDeltaConverter extends DeltaASTConverter<
AffineTextAttributes,
MarkdownAST
> {
constructor(
readonly configs: Map<string, string>,
readonly inlineDeltaMatchers: InlineDeltaToMarkdownAdapterMatcher[],
readonly markdownASTToDeltaMatchers: MarkdownASTToDeltaMatcher[]
) {
super();
}
applyTextFormatting(
delta: DeltaInsert<AffineTextAttributes>
): PhrasingContent {
let mdast: PhrasingContent = {
type: 'text',
value: delta.attributes?.underline
? `<u>${delta.insert}</u>`
: delta.insert,
};
const context: {
configs: Map<string, string>;
current: PhrasingContent;
} = {
configs: this.configs,
current: mdast,
};
for (const matcher of this.inlineDeltaMatchers) {
if (matcher.match(delta)) {
mdast = matcher.toAST(delta, context);
context.current = mdast;
}
}
return mdast;
}
astToDelta(ast: MarkdownAST): DeltaInsert<AffineTextAttributes>[] {
const context = {
configs: this.configs,
options: Object.create(null),
toDelta: (ast: MarkdownAST) => this.astToDelta(ast),
};
for (const matcher of this.markdownASTToDeltaMatchers) {
if (matcher.match(ast)) {
return matcher.toDelta(ast, context);
}
}
return 'children' in ast
? ast.children.flatMap(child => this.astToDelta(child))
: [];
}
deltaToAST(
deltas: DeltaInsert<AffineTextAttributes>[],
depth = 0
): PhrasingContent[] {
if (depth > 0) {
deltas.unshift({ insert: ' '.repeat(4).repeat(depth) });
}
return deltas.map(delta => this.applyTextFormatting(delta));
}
}
@@ -0,0 +1,3 @@
export * from './block-adapter.js';
export * from './delta-converter.js';
export * from './type.js';
@@ -0,0 +1,17 @@
import type { Root, RootContentMap } from 'mdast';
export type Markdown = string;
type MdastUnionType<
K extends keyof RootContentMap,
V extends RootContentMap[K],
> = V;
export type MarkdownAST =
| MdastUnionType<keyof RootContentMap, RootContentMap[keyof RootContentMap]>
| Root;
export const isMarkdownAST = (node: unknown): node is MarkdownAST =>
!Array.isArray(node) &&
'type' in (node as object) &&
(node as MarkdownAST).type !== undefined;
@@ -0,0 +1,33 @@
import type { ExtensionType } from '@blocksuite/block-std';
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { BlockAdapterMatcher } from '../types/adapter.js';
import type { HtmlAST } from '../types/hast.js';
import type { NotionHtmlDeltaConverter } from './delta-converter.js';
export type BlockNotionHtmlAdapterMatcher = BlockAdapterMatcher<
HtmlAST,
NotionHtmlDeltaConverter
>;
export const BlockNotionHtmlAdapterMatcherIdentifier =
createIdentifier<BlockNotionHtmlAdapterMatcher>(
'BlockNotionHtmlAdapterMatcher'
);
export function BlockNotionHtmlAdapterExtension(
matcher: BlockNotionHtmlAdapterMatcher
): ExtensionType & {
identifier: ServiceIdentifier<BlockNotionHtmlAdapterMatcher>;
} {
const identifier = BlockNotionHtmlAdapterMatcherIdentifier(matcher.flavour);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
@@ -0,0 +1,106 @@
import type { ExtensionType } from '@blocksuite/block-std';
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import { isEqual } from '@blocksuite/global/utils';
import type { DeltaInsert } from '@blocksuite/inline';
import type { AffineTextAttributes } from '../../types/index.js';
import {
type ASTToDeltaMatcher,
DeltaASTConverter,
type DeltaASTConverterOptions,
type InlineDeltaMatcher,
} from '../types/adapter.js';
import type { HtmlAST, InlineHtmlAST } from '../types/hast.js';
export type InlineDeltaToNotionHtmlAdapterMatcher =
InlineDeltaMatcher<InlineHtmlAST>;
export type NotionHtmlASTToDeltaMatcher = ASTToDeltaMatcher<HtmlAST>;
export const NotionHtmlASTToDeltaMatcherIdentifier =
createIdentifier<NotionHtmlASTToDeltaMatcher>('NotionHtmlASTToDeltaMatcher');
export function NotionHtmlASTToDeltaExtension(
matcher: NotionHtmlASTToDeltaMatcher
): ExtensionType & {
identifier: ServiceIdentifier<NotionHtmlASTToDeltaMatcher>;
} {
const identifier = NotionHtmlASTToDeltaMatcherIdentifier(matcher.name);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
export class NotionHtmlDeltaConverter extends DeltaASTConverter<
AffineTextAttributes,
HtmlAST
> {
constructor(
readonly configs: Map<string, string>,
readonly inlineDeltaMatchers: InlineDeltaToNotionHtmlAdapterMatcher[],
readonly htmlASTToDeltaMatchers: NotionHtmlASTToDeltaMatcher[]
) {
super();
}
private _spreadAstToDelta(
ast: HtmlAST,
options: DeltaASTConverterOptions = Object.create(null)
): DeltaInsert<AffineTextAttributes>[] {
const context = {
configs: this.configs,
options,
toDelta: (ast: HtmlAST, options?: DeltaASTConverterOptions) =>
this._spreadAstToDelta(ast, options),
};
for (const matcher of this.htmlASTToDeltaMatchers) {
if (matcher.match(ast)) {
return matcher.toDelta(ast, context);
}
}
const result =
'children' in ast
? ast.children.flatMap(child => this._spreadAstToDelta(child, options))
: [];
if (options.removeLastBr && result.length > 0) {
const lastItem = result[result.length - 1];
if (lastItem.insert === '\n') {
result.pop();
}
}
return result;
}
astToDelta(
ast: HtmlAST,
options: DeltaASTConverterOptions = Object.create(null)
): DeltaInsert<AffineTextAttributes>[] {
return this._spreadAstToDelta(ast, options).reduce((acc, cur) => {
if (acc.length === 0) {
return [cur];
}
const last = acc[acc.length - 1];
if (
typeof last.insert === 'string' &&
typeof cur.insert === 'string' &&
isEqual(last.attributes, cur.attributes)
) {
last.insert += cur.insert;
return acc;
}
return [...acc, cur];
}, [] as DeltaInsert<AffineTextAttributes>[]);
}
deltaToAST(_: DeltaInsert<AffineTextAttributes>[]): InlineHtmlAST[] {
return [];
}
}
@@ -0,0 +1,3 @@
export * from './block-adapter.js';
export * from './delta-converter.js';
export * from './type.js';
@@ -0,0 +1 @@
export type NotionHtml = string;
@@ -0,0 +1,28 @@
import type { ExtensionType } from '@blocksuite/block-std';
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { BlockAdapterMatcher, TextBuffer } from '../types/adapter.js';
export type BlockPlainTextAdapterMatcher = BlockAdapterMatcher<TextBuffer>;
export const BlockPlainTextAdapterMatcherIdentifier =
createIdentifier<BlockPlainTextAdapterMatcher>(
'BlockPlainTextAdapterMatcher'
);
export function BlockPlainTextAdapterExtension(
matcher: BlockPlainTextAdapterMatcher
): ExtensionType & {
identifier: ServiceIdentifier<BlockPlainTextAdapterMatcher>;
} {
const identifier = BlockPlainTextAdapterMatcherIdentifier(matcher.flavour);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
@@ -0,0 +1,62 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { DeltaInsert } from '@blocksuite/inline';
import type { AffineTextAttributes } from '../../types/index.js';
import {
type ASTToDeltaMatcher,
DeltaASTConverter,
type InlineDeltaMatcher,
type TextBuffer,
} from '../types/adapter.js';
export type InlineDeltaToPlainTextAdapterMatcher =
InlineDeltaMatcher<TextBuffer>;
export const InlineDeltaToPlainTextAdapterMatcherIdentifier =
createIdentifier<InlineDeltaToPlainTextAdapterMatcher>(
'InlineDeltaToPlainTextAdapterMatcher'
);
export type PlainTextASTToDeltaMatcher = ASTToDeltaMatcher<string>;
export class PlainTextDeltaConverter extends DeltaASTConverter<
AffineTextAttributes,
string
> {
constructor(
readonly configs: Map<string, string>,
readonly inlineDeltaMatchers: InlineDeltaToPlainTextAdapterMatcher[],
readonly plainTextASTToDeltaMatchers: PlainTextASTToDeltaMatcher[]
) {
super();
}
astToDelta(ast: string) {
const context = {
configs: this.configs,
options: Object.create(null),
toDelta: (ast: string) => this.astToDelta(ast),
};
for (const matcher of this.plainTextASTToDeltaMatchers) {
if (matcher.match(ast)) {
return matcher.toDelta(ast, context);
}
}
return [];
}
deltaToAST(deltas: DeltaInsert<AffineTextAttributes>[]): string[] {
return deltas.map(delta => {
const context = {
configs: this.configs,
current: { content: delta.insert },
};
for (const matcher of this.inlineDeltaMatchers) {
if (matcher.match(delta)) {
context.current = matcher.toAST(delta, context);
}
}
return context.current.content;
});
}
}
@@ -0,0 +1,3 @@
export * from './block-adapter.js';
export * from './delta-converter.js';
export * from './type.js';
@@ -0,0 +1 @@
export type PlainText = string;
@@ -0,0 +1,170 @@
import type { BaseTextAttributes, DeltaInsert } from '@blocksuite/inline';
import {
type AssetsManager,
type ASTWalker,
type ASTWalkerContext,
type BlockSnapshot,
BlockSnapshotSchema,
type Job,
type NodeProps,
} from '@blocksuite/store';
import type { AffineTextAttributes } from '../../types/index.js';
export const isBlockSnapshotNode = (node: unknown): node is BlockSnapshot =>
BlockSnapshotSchema.safeParse(node).success;
export type TextBuffer = {
content: string;
};
export type DeltaASTConverterOptions = {
trim?: boolean;
pre?: boolean;
pageMap?: Map<string, string>;
removeLastBr?: boolean;
};
export type AdapterContext<
ONode extends object,
TNode extends object = never,
TConverter extends DeltaASTConverter = DeltaASTConverter,
> = {
walker: ASTWalker<ONode, TNode>;
walkerContext: ASTWalkerContext<TNode>;
configs: Map<string, string>;
job: Job;
deltaConverter: TConverter;
textBuffer: TextBuffer;
assets?: AssetsManager;
pageMap?: Map<string, string>;
updateAssetIds?: (assetsId: string) => void;
};
/**
* Defines the interface for adapting between different blocks and target formats.
* Used to convert blocks between a source format (TNode) and BlockSnapshot format.
*
* @template TNode - The source/target node type to convert from/to
* @template TConverter - The converter used for handling delta format conversions
*/
export type BlockAdapterMatcher<
TNode extends object = never,
TConverter extends DeltaASTConverter = DeltaASTConverter,
> = {
/** The block flavour identifier */
flavour: string;
/**
* Function to check if a target node matches this adapter
* @param o - The target node properties to check
* @returns true if this adapter can handle the node
*/
toMatch: (o: NodeProps<TNode>) => boolean;
/**
* Function to check if a BlockSnapshot matches this adapter
* @param o - The BlockSnapshot properties to check
* @returns true if this adapter can handle the snapshot
*/
fromMatch: (o: NodeProps<BlockSnapshot>) => boolean;
/**
* Handlers for converting from target format to BlockSnapshot
*/
toBlockSnapshot: {
/**
* Called when entering a target walker node during traversal
* @param o - The target node properties
* @param context - The adapter context
*/
enter?: (
o: NodeProps<TNode>,
context: AdapterContext<TNode, BlockSnapshot, TConverter>
) => void | Promise<void>;
/**
* Called when leaving a target walker node during traversal
* @param o - The target node properties
* @param context - The adapter context
*/
leave?: (
o: NodeProps<TNode>,
context: AdapterContext<TNode, BlockSnapshot, TConverter>
) => void | Promise<void>;
};
/**
* Handlers for converting from BlockSnapshot to target format
*/
fromBlockSnapshot: {
/**
* Called when entering a BlockSnapshot walker node during traversal
* @param o - The BlockSnapshot properties
* @param context - The adapter context
*/
enter?: (
o: NodeProps<BlockSnapshot>,
context: AdapterContext<BlockSnapshot, TNode, TConverter>
) => void | Promise<void>;
/**
* Called when leaving a BlockSnapshot walker node during traversal
* @param o - The BlockSnapshot properties
* @param context - The adapter context
*/
leave?: (
o: NodeProps<BlockSnapshot>,
context: AdapterContext<BlockSnapshot, TNode, TConverter>
) => void | Promise<void>;
};
};
export abstract class DeltaASTConverter<
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
AST = unknown,
> {
/**
* Convert AST format to delta format
*/
abstract astToDelta(
ast: AST,
options?: unknown
): DeltaInsert<TextAttributes>[];
/**
* Convert delta format to AST format
*/
abstract deltaToAST(
deltas: DeltaInsert<TextAttributes>[],
options?: unknown
): AST[];
}
export type InlineDeltaMatcher<TNode extends object = never> = {
name: keyof AffineTextAttributes | string;
match: (delta: DeltaInsert<AffineTextAttributes>) => boolean;
toAST: (
delta: DeltaInsert<AffineTextAttributes>,
context: {
configs: Map<string, string>;
current: TNode;
}
) => TNode;
};
export type ASTToDeltaMatcher<AST> = {
name: string;
match: (ast: AST) => boolean;
toDelta: (
ast: AST,
context: {
configs: Map<string, string>;
options: DeltaASTConverterOptions;
toDelta: (
ast: AST,
options?: DeltaASTConverterOptions
) => DeltaInsert<AffineTextAttributes>[];
}
) => DeltaInsert<AffineTextAttributes>[];
};
@@ -0,0 +1,12 @@
import type { Element, Root, RootContentMap, Text } from 'hast';
export type HastUnionType<
K extends keyof RootContentMap,
V extends RootContentMap[K],
> = V;
export type HtmlAST =
| HastUnionType<keyof RootContentMap, RootContentMap[keyof RootContentMap]>
| Root;
export type InlineHtmlAST = Element | Text;
@@ -0,0 +1,2 @@
export * from './adapter.js';
export * from './hast.js';
@@ -0,0 +1,37 @@
const fetchImage = async (url: string, init?: RequestInit, proxy?: string) => {
try {
if (!proxy) {
return await fetch(url, init);
}
if (url.startsWith('blob:')) {
return await fetch(url, init);
}
if (url.startsWith('data:')) {
return await fetch(url, init);
}
if (url.startsWith(window.location.origin)) {
return await fetch(url, init);
}
return await fetch(proxy + '?url=' + encodeURIComponent(url), init)
.then(res => {
if (!res.ok) {
throw new Error('Network response was not ok');
}
return res;
})
.catch(() => fetch(url, init));
} catch (error) {
console.warn('Error fetching image:', error);
return null;
}
};
const fetchable = (url: string) =>
url.startsWith('http:') ||
url.startsWith('https:') ||
url.startsWith('data:');
export const FetchUtils = {
fetchImage,
fetchable,
};
@@ -0,0 +1,266 @@
import type { Element, ElementContent, Text } from 'hast';
import type { HtmlAST } from '../types/hast.js';
const isElement = (ast: HtmlAST): ast is Element => {
return ast.type === 'element';
};
const getTextContent = (ast: HtmlAST | undefined, defaultStr = ''): string => {
if (!ast) {
return defaultStr;
}
switch (ast.type) {
case 'text': {
return ast.value.replace(/\s+/g, ' ');
}
case 'element': {
switch (ast.tagName) {
case 'br': {
return '\n';
}
}
return ast.children.map(child => getTextContent(child)).join('');
}
}
return defaultStr;
};
const getElementChildren = (ast: HtmlAST | undefined): Element[] => {
if (!ast) {
return [];
}
if (ast.type === 'element') {
return ast.children.filter(child => child.type === 'element') as Element[];
}
return [];
};
const getTextChildren = (ast: HtmlAST | undefined): Text[] => {
if (!ast) {
return [];
}
if (ast.type === 'element') {
return ast.children.filter(child => child.type === 'text') as Text[];
}
return [];
};
const getTextChildrenOnlyAst = (ast: Element): Element => {
return {
...ast,
children: getTextChildren(ast),
};
};
const isTagInline = (tagName: string): boolean => {
// Phrasing content
const inlineElements = [
'a',
'abbr',
'audio',
'b',
'bdi',
'bdo',
'br',
'button',
'canvas',
'cite',
'code',
'data',
'datalist',
'del',
'dfn',
'em',
'embed',
'i',
// 'iframe' is not included because it needs special handling
// 'img' is not included because it needs special handling
'input',
'ins',
'kbd',
'label',
'link',
'map',
'mark',
'math',
'meta',
'meter',
'noscript',
'object',
'output',
'picture',
'progress',
'q',
'ruby',
's',
'samp',
'script',
'select',
'slot',
'small',
'span',
'strong',
'sub',
'sup',
'svg',
'template',
'textarea',
'time',
'u',
'var',
'video',
'wbr',
];
return inlineElements.includes(tagName);
};
const isElementInline = (element: Element): boolean => {
return (
isTagInline(element.tagName) ||
// Inline elements
!!(
typeof element.properties?.style === 'string' &&
element.properties.style.match(/display:\s*inline/)
)
);
};
const getInlineElementsAndText = (ast: Element): (Element | Text)[] => {
if (!ast || !ast.children) {
return [];
}
return ast.children.filter((child): child is Element | Text => {
if (child.type === 'text') {
return true;
}
if (child.type === 'element' && child.tagName && isElementInline(child)) {
return true;
}
return false;
});
};
const getInlineOnlyElementAST = (ast: Element): Element => {
return {
...ast,
children: getInlineElementsAndText(ast),
};
};
const querySelectorTag = (
ast: HtmlAST,
tagName: string
): Element | undefined => {
if (ast.type === 'element') {
if (ast.tagName === tagName) {
return ast;
}
for (const child of ast.children) {
const result = querySelectorTag(child, tagName);
if (result) {
return result;
}
}
}
return undefined;
};
const querySelectorClass = (
ast: HtmlAST,
className: string
): Element | undefined => {
if (ast.type === 'element') {
if (
Array.isArray(ast.properties?.className) &&
ast.properties.className.includes(className)
) {
return ast;
}
for (const child of ast.children) {
const result = querySelectorClass(child, className);
if (result) {
return result;
}
}
}
return undefined;
};
const querySelectorId = (ast: HtmlAST, id: string): Element | undefined => {
if (ast.type === 'element') {
if (ast.properties.id === id) {
return ast;
}
for (const child of ast.children) {
const result = querySelectorId(child, id);
if (result) {
return result;
}
}
}
return undefined;
};
const querySelector = (ast: HtmlAST, selector: string): Element | undefined => {
if (ast.type === 'root') {
for (const child of ast.children) {
const result = querySelector(child, selector);
if (result) {
return result;
}
}
} else if (ast.type === 'element') {
if (selector.startsWith('.')) {
return querySelectorClass(ast, selector.slice(1));
} else if (selector.startsWith('#')) {
return querySelectorId(ast, selector.slice(1));
} else {
return querySelectorTag(ast, selector);
}
}
return undefined;
};
const flatNodes = (
ast: HtmlAST,
expression: (tagName: string) => boolean
): HtmlAST => {
if (ast.type === 'element') {
const children = ast.children.map(child => flatNodes(child, expression));
return {
...ast,
children: children.flatMap(child => {
if (child.type === 'element' && expression(child.tagName)) {
return child.children;
}
return child;
}) as ElementContent[],
};
}
return ast;
};
// Check if it is a paragraph like element
// https://html.spec.whatwg.org/#paragraph
const isParagraphLike = (node: Element): boolean => {
// Flex container
return (
(typeof node.properties?.style === 'string' &&
node.properties.style.match(/display:\s*flex/) !== null) ||
getElementChildren(node).every(child => isElementInline(child))
);
};
export const HastUtils = {
isElement,
getTextContent,
getElementChildren,
getTextChildren,
getTextChildrenOnlyAst,
getInlineOnlyElementAST,
querySelector,
flatNodes,
isParagraphLike,
};
@@ -0,0 +1,3 @@
export * from './fetch.js';
export * from './hast.js';
export * from './text.js';
@@ -0,0 +1,83 @@
import { isEqual } from '@blocksuite/global/utils';
import type { DeltaInsert } from '@blocksuite/inline';
const mergeDeltas = (
acc: DeltaInsert[],
cur: DeltaInsert,
options: { force?: boolean } = { force: false }
) => {
if (acc.length === 0) {
return [cur];
}
const last = acc[acc.length - 1];
if (options?.force) {
last.insert = last.insert + cur.insert;
last.attributes = Object.create(null);
return acc;
} else if (
typeof last.insert === 'string' &&
typeof cur.insert === 'string' &&
(isEqual(last.attributes, cur.attributes) ||
(last.attributes === undefined && cur.attributes === undefined))
) {
last.insert += cur.insert;
return acc;
}
return [...acc, cur];
};
const isNullish = (value: unknown) => value === null || value === undefined;
const createText = (s: string) => {
return {
'$blocksuite:internal:text$': true,
delta: s.length === 0 ? [] : [{ insert: s }],
};
};
const isText = (o: unknown) => {
if (
typeof o === 'object' &&
o !== null &&
'$blocksuite:internal:text$' in o
) {
return o['$blocksuite:internal:text$'] === true;
}
return false;
};
function toURLSearchParams(
params?: Partial<Record<string, string | string[]>>
) {
if (!params) return;
const items = Object.entries(params)
.filter(([_, v]) => !isNullish(v))
.filter(([_, v]) => {
if (typeof v === 'string') {
return v.length > 0;
}
if (Array.isArray(v)) {
return v.length > 0;
}
return false;
})
.map(([k, v]) => [k, Array.isArray(v) ? v.filter(v => v.length) : v]) as [
string,
string | string[],
][];
return new URLSearchParams(
items
.filter(([_, v]) => v.length)
.map(([k, v]) => [k, Array.isArray(v) ? v.join(',') : v])
);
}
export const TextUtils = {
mergeDeltas,
isNullish,
createText,
isText,
toURLSearchParams,
};