chore: merge blocksuite source code (#9213)

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

View File

@@ -0,0 +1,57 @@
{
"name": "@blocksuite/affine-shared",
"description": "Default BlockSuite editable blocks.",
"type": "module",
"scripts": {
"build": "tsc",
"test:unit": "nx vite:test --run",
"test:unit:coverage": "nx vite:test --run --coverage",
"test:e2e": "playwright test"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.1.75",
"@blocksuite/inline": "workspace:*",
"@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.10",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.1",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"lodash.clonedeep": "^4.5.0",
"lodash.mergewith": "^4.6.2",
"minimatch": "^10.0.1",
"zod": "^3.23.8"
},
"exports": {
".": "./src/index.ts",
"./selection": "./src/selection/index.ts",
"./utils": "./src/utils/index.ts",
"./consts": "./src/consts/index.ts",
"./types": "./src/types/index.ts",
"./commands": "./src/commands/index.ts",
"./mixins": "./src/mixins/index.ts",
"./theme": "./src/theme/index.ts",
"./styles": "./src/styles/index.ts",
"./services": "./src/services/index.ts",
"./adapters": "./src/adapters/index.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"devDependencies": {
"@types/lodash.clonedeep": "^4.5.9",
"@types/lodash.mergewith": "^4"
}
}

View File

@@ -0,0 +1,86 @@
import {
atLeastNMatches,
countBy,
groupBy,
maxBy,
} from '@blocksuite/global/utils';
import { describe, expect, it } from 'vitest';
describe('countBy', () => {
it('basic', () => {
const items = [
{ name: 'a', classroom: 'c1' },
{ name: 'b', classroom: 'c2' },
{ name: 'a', classroom: 'c2' },
];
const counted = countBy(items, i => i.name);
expect(counted).toEqual({ a: 2, b: 1 });
});
it('empty items', () => {
const counted = countBy([], i => i);
expect(Object.keys(counted).length).toBe(0);
});
});
describe('maxBy', () => {
it('basic', () => {
const items = [{ n: 1 }, { n: 2 }];
const max = maxBy(items, i => i.n);
expect(max).toBe(items[1]);
});
it('empty items', () => {
expect(maxBy([], i => i)).toBeNull();
});
});
describe('atLeastNMatches', () => {
it('basic', () => {
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const isEven = (num: number): boolean => num % 2 === 0;
const isGreaterThan5 = (num: number): boolean => num > 5;
const isNegative = (num: number): boolean => num < 0;
expect(atLeastNMatches(arr, isEven, 3)).toBe(true);
expect(atLeastNMatches(arr, isGreaterThan5, 5)).toBe(false);
expect(atLeastNMatches(arr, isNegative, 1)).toBe(false);
const strArr = ['apple', 'banana', 'orange', 'kiwi', 'mango'];
const startsWithA = (str: string): boolean => str[0].toLowerCase() === 'a';
const longerThan5 = (str: string): boolean => str.length > 5;
expect(atLeastNMatches(strArr, startsWithA, 1)).toBe(true);
expect(atLeastNMatches(strArr, longerThan5, 3)).toBe(false);
});
});
describe('groupBy', () => {
it('basic', () => {
const students = [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 23 },
{ name: 'Cathy', age: 25 },
{ name: 'David', age: 23 },
];
const groupedByAge = groupBy(students, student => student.age.toString());
const expectedGroupedByAge = {
'23': [
{ name: 'Bob', age: 23 },
{ name: 'David', age: 23 },
],
'25': [
{ name: 'Alice', age: 25 },
{ name: 'Cathy', age: 25 },
],
};
expect(groupedByAge).toMatchObject(expectedGroupedByAge);
});
it('empty', () => {
const emptyArray: string[] = [];
const groupedEmptyArray = groupBy(emptyArray, item => item);
expect(Object.keys(groupedEmptyArray).length).toBe(0);
});
});

View File

@@ -0,0 +1,57 @@
import { Point } from '@blocksuite/global/utils';
import { describe, expect, it } from 'vitest';
describe('Point', () => {
it('should return a min point', () => {
const a = new Point(0, 0);
const b = new Point(-1, 1);
expect(Point.min(a, b)).toEqual(new Point(-1, 0));
});
it('should return a max point', () => {
const a = new Point(0, 0);
const b = new Point(-1, 1);
expect(Point.max(a, b)).toEqual(new Point(0, 1));
});
it('should return a clamp point', () => {
const min = new Point(0, 0);
const max = new Point(1, 1);
const a = new Point(-1, 2);
expect(Point.clamp(a, min, max)).toEqual(new Point(0, 1));
const b = new Point(2, 2);
expect(Point.clamp(b, min, max)).toEqual(new Point(1, 1));
const c = new Point(0.5, 0.5);
expect(Point.clamp(c, min, max)).toEqual(new Point(0.5, 0.5));
});
it('should return a copy of point', () => {
const a = new Point(0, 0);
expect(a.clone()).toEqual(new Point(0, 0));
});
it('#set method should set x and y', () => {
const p = new Point(0, 0);
p.set(1, 2);
expect(p).toEqual(new Point(1, 2));
});
it('#add', () => {
const a = new Point(1, 2);
const b = new Point(3, 4);
expect(a.add(b)).toEqual(new Point(4, 6));
});
it('#subtract', () => {
const a = new Point(1, 2);
const b = new Point(3, 4);
expect(a.subtract(b)).toEqual(new Point(-2, -2));
});
it('#scale', () => {
const a = new Point(1, 2);
expect(a.scale(2)).toEqual(new Point(2, 4));
});
});

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { isFuzzyMatch, substringMatchScore } from '../../utils/string.js';
describe('fuzzyMatch', () => {
it('basic case', () => {
expect(isFuzzyMatch('John Smith', 'j')).toEqual(true);
expect(isFuzzyMatch('John Smith', 'js')).toEqual(true);
expect(isFuzzyMatch('John Smith', 'jsa')).toEqual(false);
});
it('should works with CJK', () => {
expect(isFuzzyMatch('中', '中')).toEqual(true);
expect(isFuzzyMatch('中文', '中')).toEqual(true);
expect(isFuzzyMatch('中文字符', '中字')).toEqual(true);
expect(isFuzzyMatch('中文字符', '字中')).toEqual(false);
});
it('should works with IME', () => {
// IME will generate a space between 'da' and 't'
expect(isFuzzyMatch('database', 'da t')).toEqual(true);
});
});
describe('substringMatchScore', () => {
it('should return a fraction if there exists a common maximal length substring. ', () => {
const result = substringMatchScore('testing the function', 'tet');
expect(result).toBeLessThan(1);
expect(result).toBeGreaterThan(0);
});
it('should return bigger score for longer match', () => {
const result = substringMatchScore('testing the function', 'functin');
const result2 = substringMatchScore('testing the function', 'tet');
// because th length of common substring of 'functin' is bigger than 'tet'
expect(result).toBeGreaterThan(result2);
});
it('should return bigger score when using same query to search a shorter string', () => {
const result = substringMatchScore('test', 'test');
const result2 = substringMatchScore('testing the function', 'test');
expect(result).toBeGreaterThan(result2);
});
it('should return 0 when there is no match', () => {
const result = substringMatchScore('abc', 'defghijk');
expect(result).toBe(0);
});
it('should handle cases where the query is longer than the string', () => {
const result = substringMatchScore('short', 'longer substring');
expect(result).toBe(0);
});
it('should handle empty strings correctly', () => {
const result = substringMatchScore('any string', '');
expect(result).toBe(0);
});
it('should handle both strings being empty', () => {
const result = substringMatchScore('', '');
expect(result).toBe(0);
});
it('should handle cases where both strings are identical', () => {
const result = substringMatchScore('identical', 'identical');
expect(result).toBe(1);
});
});

View File

@@ -0,0 +1,83 @@
import { describe, expect, test } from 'vitest';
import { isValidUrl } from '../../utils/url.js';
describe('isValidUrl: determining whether a URL is valid is very complicated', () => {
test('basic case', () => {
expect(isValidUrl('')).toEqual(false);
expect(isValidUrl('1.co')).toEqual(true);
expect(isValidUrl('https://www.example.com')).toEqual(true);
expect(isValidUrl('www.example.com')).toEqual(true);
expect(isValidUrl('http://www.github.com/toeverything/blocksuite')).toEqual(
true
);
});
test('CAUTION: any link include allowed schema is a valid url!', () => {
expect(isValidUrl('http://www.example.cm')).toEqual(true);
expect(isValidUrl('https://x ')).toEqual(true);
expect(isValidUrl('mailto://w:80')).toEqual(true);
});
test('link include a unknown schema is not a valid url', () => {
expect(isValidUrl('xxx://www.example.com')).toEqual(false);
expect(isValidUrl('https://')).toEqual(false);
expect(isValidUrl('http://w.... !@#*(!!!!')).toEqual(false);
});
test('URL without protocol is a valid URL', () => {
expect(isValidUrl('www.example.com')).toEqual(true);
expect(isValidUrl('example.co')).toEqual(true);
expect(isValidUrl('example.cm')).toEqual(true);
expect(isValidUrl('1.1.1.1')).toEqual(true);
expect(isValidUrl('example.c')).toEqual(false);
});
test('special cases', () => {
expect(isValidUrl('example.com.')).toEqual(true);
// I don't know why
// private & local networks is excluded
expect(isValidUrl('127.0.0.1')).toEqual(false);
expect(isValidUrl('10.0.0.1')).toEqual(false);
expect(isValidUrl('localhost')).toEqual(false);
expect(isValidUrl('0.0.0.0')).toEqual(false);
expect(isValidUrl('128.0.0.1')).toEqual(true);
expect(isValidUrl('1.0.0.1')).toEqual(true);
});
test('email link is a valid URL', () => {
// See https://www.rapidtables.com/web/html/mailto.html
expect(isValidUrl('mailto:name@email.com')).toEqual(true);
expect(
isValidUrl(
'mailto:name@rapidtables.com?subject=The%20subject%20of%20the%20mail'
)
).toEqual(true);
expect(
isValidUrl(
'mailto:name1@rapidtables.com?cc=name2@rapidtables.com&bcc=name3@rapidtables.com&subject=The%20subject%20of%20the%20email&body=The%20body%20of%20the%20email'
)
).toEqual(true);
// multiple email recipients
expect(isValidUrl('mailto:name1@mail.com,name2@mail.com')).toEqual(true);
});
test('misc case', () => {
// Emoji domain
expect(isValidUrl('xn--i-7iq.ws')).toEqual(true);
expect(
isValidUrl('https://username:password@www.example.com:80/?q_a=1234567')
).toEqual(true);
expect(isValidUrl('新华网.cn')).toEqual(true);
expect(isValidUrl('example.com/中文/にほんご')).toEqual(true);
// It's a valid url, but we don't want to support it
// Longest TLD up to date is `.xn--vermgensberatung-pwb`, at 24 characters in Punycode and 17 when decoded [vermögensberatung].
// See also https://stackoverflow.com/questions/9238640/how-long-can-a-tld-possibly-be#:~:text=Longest%20TLD%20up%20to%20date,17%20when%20decoded%20%5Bverm%C3%B6gensberatung%5D.
expect(isValidUrl('example.xn--vermgensberatung-pwb')).toEqual(false);
});
});

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export * from './block-adapter.js';
export * from './delta-converter.js';
export * from './type.js';

View File

@@ -0,0 +1 @@
export type Html = string;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export * from './block-adapter.js';
export * from './delta-converter.js';
export * from './type.js';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export * from './block-adapter.js';
export * from './delta-converter.js';
export * from './type.js';

View File

@@ -0,0 +1 @@
export type NotionHtml = string;

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export * from './block-adapter.js';
export * from './delta-converter.js';
export * from './type.js';

View File

@@ -0,0 +1 @@
export type PlainText = string;

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export * from './adapter.js';
export * from './hast.js';

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export * from './fetch.js';
export * from './hast.js';
export * from './text.js';

View File

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

View File

@@ -0,0 +1,4 @@
# @blocksuite/affine-shared/commands
This package contains the common commands used in the affine blocks.
Keep in mind that you should not put commands that are specific to a single kind of block here.

View File

@@ -0,0 +1,44 @@
import type { BlockComponent, Command } from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
export const getBlockIndexCommand: Command<
'currentSelectionPath',
'blockIndex' | 'parentBlock',
{
path?: string;
}
> = (ctx, next) => {
const path = ctx.path ?? ctx.currentSelectionPath;
assertExists(
path,
'`path` is required, you need to pass it in args or ctx before adding this command to the pipeline.'
);
const parentModel = ctx.std.doc.getParent(path);
if (!parentModel) return;
const parent = ctx.std.view.getBlock(parentModel.id);
if (!parent) return;
const index = parent.childBlocks.findIndex(x => {
return x.blockId === path;
});
next({
blockIndex: index,
parentBlock: parent as BlockComponent,
});
};
declare global {
namespace BlockSuite {
interface CommandContext {
blockIndex?: number;
parentBlock?: BlockComponent;
}
interface Commands {
getBlockIndex: typeof getBlockIndexCommand;
}
}
}

View File

@@ -0,0 +1,45 @@
import type { BlockComponent, Command } from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
import { getNextContentBlock } from '../../utils/index.js';
function getNextBlock(std: BlockSuite.Std, path: string) {
const view = std.view;
const model = std.doc.getBlock(path)?.model;
if (!model) return null;
const nextModel = getNextContentBlock(std.host, model);
if (!nextModel) return null;
return view.getBlock(nextModel.id);
}
export const getNextBlockCommand: Command<
'currentSelectionPath',
'nextBlock',
{
path?: string;
}
> = (ctx, next) => {
const path = ctx.path ?? ctx.currentSelectionPath;
assertExists(
path,
'`path` is required, you need to pass it in args or ctx before adding this command to the pipeline.'
);
const nextBlock = getNextBlock(ctx.std, path);
if (nextBlock) {
next({ nextBlock });
}
};
declare global {
namespace BlockSuite {
interface CommandContext {
nextBlock?: BlockComponent;
}
interface Commands {
getNextBlock: typeof getNextBlockCommand;
}
}
}

View File

@@ -0,0 +1,46 @@
import type { BlockComponent, Command } from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
import { getPrevContentBlock } from '../../utils/index.js';
function getPrevBlock(std: BlockSuite.Std, path: string) {
const view = std.view;
const model = std.doc.getBlock(path)?.model;
if (!model) return null;
const prevModel = getPrevContentBlock(std.host, model);
if (!prevModel) return null;
return view.getBlock(prevModel.id);
}
export const getPrevBlockCommand: Command<
'currentSelectionPath',
'prevBlock',
{
path?: string;
}
> = (ctx, next) => {
const path = ctx.path ?? ctx.currentSelectionPath;
assertExists(
path,
'`path` is required, you need to pass it in args or ctx before adding this command to the pipeline.'
);
const prevBlock = getPrevBlock(ctx.std, path);
if (prevBlock) {
next({ prevBlock });
}
};
declare global {
namespace BlockSuite {
interface CommandContext {
prevBlock?: BlockComponent;
}
interface Commands {
getPrevBlock: typeof getPrevBlockCommand;
}
}
}

View File

@@ -0,0 +1,160 @@
import type {
BlockSelection,
Command,
TextSelection,
} from '@blocksuite/block-std';
import { BlockComponent } from '@blocksuite/block-std';
import type { RoleType } from '@blocksuite/store';
import type { ImageSelection } from '../../selection/index.js';
export const getSelectedBlocksCommand: Command<
'currentTextSelection' | 'currentBlockSelections' | 'currentImageSelections',
'selectedBlocks',
{
textSelection?: TextSelection;
blockSelections?: BlockSelection[];
imageSelections?: ImageSelection[];
filter?: (el: BlockComponent) => boolean;
types?: Extract<BlockSuite.SelectionType, 'block' | 'text' | 'image'>[];
roles?: RoleType[];
mode?: 'all' | 'flat' | 'highest';
}
> = (ctx, next) => {
const {
types = ['block', 'text', 'image'],
roles = ['content'],
mode = 'flat',
} = ctx;
let dirtyResult: BlockComponent[] = [];
const textSelection = ctx.textSelection ?? ctx.currentTextSelection;
if (types.includes('text') && textSelection) {
try {
const range = ctx.std.range.textSelectionToRange(textSelection);
if (!range) return;
const selectedBlocks = ctx.std.range.getSelectedBlockComponentsByRange(
range,
{
match: (el: BlockComponent) => roles.includes(el.model.role),
mode,
}
);
dirtyResult.push(...selectedBlocks);
} catch {
return;
}
}
const blockSelections = ctx.blockSelections ?? ctx.currentBlockSelections;
if (types.includes('block') && blockSelections) {
const viewStore = ctx.std.view;
const doc = ctx.std.doc;
const selectedBlockComponents = blockSelections.flatMap(selection => {
const el = viewStore.getBlock(selection.blockId);
if (!el) {
return [];
}
const blocks: BlockComponent[] = [el];
let selectionPath = selection.blockId;
if (mode === 'all') {
let parent = null;
do {
parent = doc.getParent(selectionPath);
if (!parent) {
break;
}
const view = parent;
if (
view instanceof BlockComponent &&
!roles.includes(view.model.role)
) {
break;
}
selectionPath = parent.id;
} while (parent);
parent = viewStore.getBlock(selectionPath);
if (parent) {
blocks.push(parent);
}
}
if (['all', 'flat'].includes(mode)) {
viewStore.walkThrough(node => {
const view = node;
if (!(view instanceof BlockComponent)) {
return true;
}
if (roles.includes(view.model.role)) {
blocks.push(view);
}
return;
}, selectionPath);
}
return blocks;
});
dirtyResult.push(...selectedBlockComponents);
}
const imageSelections = ctx.imageSelections ?? ctx.currentImageSelections;
if (types.includes('image') && imageSelections) {
const viewStore = ctx.std.view;
const selectedBlocks = imageSelections
.map(selection => {
const el = viewStore.getBlock(selection.blockId);
return el;
})
.filter((el): el is BlockComponent => Boolean(el));
dirtyResult.push(...selectedBlocks);
}
if (ctx.filter) {
dirtyResult = dirtyResult.filter(ctx.filter);
}
// remove duplicate elements
const result: BlockComponent[] = dirtyResult
.filter((el, index) => dirtyResult.indexOf(el) === index)
// sort by document position
.sort((a, b) => {
if (a === b) {
return 0;
}
const position = a.compareDocumentPosition(b);
if (
position & Node.DOCUMENT_POSITION_FOLLOWING ||
position & Node.DOCUMENT_POSITION_CONTAINED_BY
) {
return -1;
}
if (
position & Node.DOCUMENT_POSITION_PRECEDING ||
position & Node.DOCUMENT_POSITION_CONTAINS
) {
return 1;
}
return 0;
});
if (result.length === 0) return;
next({
selectedBlocks: result,
});
};
declare global {
namespace BlockSuite {
interface CommandContext {
selectedBlocks?: BlockComponent[];
}
interface Commands {
getSelectedBlocks: typeof getSelectedBlocksCommand;
}
}
}

View File

@@ -0,0 +1,4 @@
export { getBlockIndexCommand } from './get-block-index.js';
export { getNextBlockCommand } from './get-next-block.js';
export { getPrevBlockCommand } from './get-prev-block.js';
export { getSelectedBlocksCommand } from './get-selected-blocks.js';

View File

@@ -0,0 +1,5 @@
// This is a dev-only file to make other packages use commands from this package.
export type * from './block-crud/index.js';
export type * from './model-crud/index.js';
export type * from './selection/index.js';

View File

@@ -0,0 +1,32 @@
export {
getBlockIndexCommand,
getNextBlockCommand,
getPrevBlockCommand,
getSelectedBlocksCommand,
} from './block-crud/index.js';
export {
clearAndSelectFirstModelCommand,
copySelectedModelsCommand,
deleteSelectedModelsCommand,
draftSelectedModelsCommand,
duplicateSelectedModelsCommand,
getSelectedModelsCommand,
retainFirstModelCommand,
} from './model-crud/index.js';
export {
getBlockSelectionsCommand,
getImageSelectionsCommand,
getSelectionRectsCommand,
getTextSelectionCommand,
type SelectionRect,
} from './selection/index.js';
declare global {
namespace BlockSuite {
// if we use `with` or `inline` to add command data either then use a command we
// need to update this interface
interface CommandContext {
currentSelectionPath?: string;
}
}
}

View File

@@ -0,0 +1,41 @@
import type { Command } from '@blocksuite/block-std';
export const clearAndSelectFirstModelCommand: Command<'selectedModels'> = (
ctx,
next
) => {
const models = ctx.selectedModels;
if (!models) {
console.error(
'`selectedModels` is required, you need to use `getSelectedModels` command before adding this command to the pipeline.'
);
return;
}
if (models.length > 0) {
const firstModel = models[0];
if (firstModel.text) {
firstModel.text.clear();
const selection = ctx.std.selection.create('text', {
from: {
blockId: firstModel.id,
index: 0,
length: 0,
},
to: null,
});
ctx.std.selection.setGroup('note', [selection]);
}
}
return next();
};
declare global {
namespace BlockSuite {
interface Commands {
clearAndSelectFirstModel: typeof clearAndSelectFirstModelCommand;
}
}
}

View File

@@ -0,0 +1,36 @@
import type { Command } from '@blocksuite/block-std';
import { Slice } from '@blocksuite/store';
export const copySelectedModelsCommand: Command<'draftedModels' | 'onCopy'> = (
ctx,
next
) => {
const models = ctx.draftedModels;
if (!models) {
console.error(
'`draftedModels` is required, you need to use `draftSelectedModels` command before adding this command to the pipeline.'
);
return;
}
models
.then(models => {
const slice = Slice.fromModels(ctx.std.doc, models);
return ctx.std.clipboard.copy(slice);
})
.then(() => ctx.onCopy?.())
.catch(console.error);
return next();
};
declare global {
namespace BlockSuite {
interface CommandContext {
onCopy?: () => void;
}
interface Commands {
copySelectedModels: typeof copySelectedModelsCommand;
}
}
}

View File

@@ -0,0 +1,29 @@
import type { Command } from '@blocksuite/block-std';
export const deleteSelectedModelsCommand: Command<'selectedModels'> = (
ctx,
next
) => {
const models = ctx.selectedModels;
if (!models) {
console.error(
'`selectedModels` is required, you need to use `getSelectedModels` command before adding this command to the pipeline.'
);
return;
}
models.forEach(model => {
ctx.std.doc.deleteBlock(model);
});
return next();
};
declare global {
namespace BlockSuite {
interface Commands {
deleteSelectedModels: typeof deleteSelectedModelsCommand;
}
}
}

View File

@@ -0,0 +1,58 @@
import type { Command } from '@blocksuite/block-std';
import {
type BlockModel,
type DraftModel,
toDraftModel,
} from '@blocksuite/store';
export const draftSelectedModelsCommand: Command<
'selectedModels',
'draftedModels'
> = (ctx, next) => {
const models = ctx.selectedModels;
if (!models) {
console.error(
'`selectedModels` is required, you need to use `getSelectedModels` command before adding this command to the pipeline.'
);
return;
}
const draftedModelsPromise = new Promise<DraftModel[]>(resolve => {
const draftedModels = models.map(toDraftModel);
const modelMap = new Map(draftedModels.map(model => [model.id, model]));
const traverse = (model: DraftModel) => {
const isDatabase = model.flavour === 'affine:database';
const children = isDatabase
? model.children
: model.children.filter(child => modelMap.has(child.id));
children.forEach(child => {
modelMap.delete(child.id);
traverse(child);
});
model.children = children;
};
draftedModels.forEach(traverse);
const remainingDraftedModels = Array.from(modelMap.values());
resolve(remainingDraftedModels);
});
return next({ draftedModels: draftedModelsPromise });
};
declare global {
namespace BlockSuite {
interface CommandContext {
draftedModels?: Promise<DraftModel<BlockModel<object>>[]>;
}
interface Commands {
draftSelectedModels: typeof draftSelectedModelsCommand;
}
}
}

View File

@@ -0,0 +1,38 @@
import type { Command } from '@blocksuite/block-std';
import { Slice } from '@blocksuite/store';
export const duplicateSelectedModelsCommand: Command<
'draftedModels' | 'selectedModels'
> = (ctx, next) => {
const { std, draftedModels, selectedModels } = ctx;
if (!draftedModels || !selectedModels) return;
const model = selectedModels[selectedModels.length - 1];
const parentModel = std.doc.getParent(model.id);
if (!parentModel) return;
const index = parentModel.children.findIndex(x => x.id === model.id);
draftedModels
.then(models => {
const slice = Slice.fromModels(std.doc, models);
return std.clipboard.duplicateSlice(
slice,
std.doc,
parentModel.id,
index + 1
);
})
.catch(console.error);
return next();
};
declare global {
namespace BlockSuite {
interface Commands {
duplicateSelectedModels: typeof duplicateSelectedModelsCommand;
}
}
}

View File

@@ -0,0 +1,72 @@
import type { Command } from '@blocksuite/block-std';
import type { BlockModel } from '@blocksuite/store';
/**
* Retrieves the selected models based on the provided selection types and mode.
*
* @param ctx - The command context, which includes the types of selections to be retrieved and the mode of the selection.
* @param ctx.types - The selection types to be retrieved. Can be an array of 'block', 'text', or 'image'.
* @param ctx.mode - The mode of the selection. Can be 'all', 'flat', or 'highest'.
* @example
* // Assuming `commandContext` is an instance of the command context
* getSelectedModelsCommand(commandContext, (result) => {
* console.log(result.selectedModels);
* });
*
* // Example selection:
* // aaa
* // b[bb
* // ccc
* // ddd
* // ee]e
*
* // all mode: [aaa, bbb, ccc, ddd, eee]
* // flat mode: [bbb, ccc, ddd, eee]
* // highest mode: [bbb, ddd]
*
* // The match function will be evaluated before filtering using mode
* @param next - The next function to be called.
* @returns An object containing the selected models as an array of BlockModel instances.
*/
export const getSelectedModelsCommand: Command<
never,
'selectedModels',
{
types?: Extract<BlockSuite.SelectionType, 'block' | 'text' | 'image'>[];
mode?: 'all' | 'flat' | 'highest';
}
> = (ctx, next) => {
const types = ctx.types ?? ['block', 'text', 'image'];
const mode = ctx.mode ?? 'flat';
const selectedModels: BlockModel[] = [];
ctx.std.command
.chain()
.tryAll(chain => [
chain.getTextSelection(),
chain.getBlockSelections(),
chain.getImageSelections(),
])
.getSelectedBlocks({
types,
mode,
})
.inline(ctx => {
const { selectedBlocks = [] } = ctx;
selectedModels.push(...selectedBlocks.map(el => el.model));
})
.run();
next({ selectedModels });
};
declare global {
namespace BlockSuite {
interface CommandContext {
selectedModels?: BlockModel[];
}
interface Commands {
getSelectedModels: typeof getSelectedModelsCommand;
}
}
}

View File

@@ -0,0 +1,7 @@
export { clearAndSelectFirstModelCommand } from './clear-and-select-first-model.js';
export { copySelectedModelsCommand } from './copy-selected-models.js';
export { deleteSelectedModelsCommand } from './delete-selected-models.js';
export { draftSelectedModelsCommand } from './draft-selected-models.js';
export { duplicateSelectedModelsCommand } from './duplicate-selected-model.js';
export { getSelectedModelsCommand } from './get-selected-models.js';
export { retainFirstModelCommand } from './retain-first-model.js';

View File

@@ -0,0 +1,27 @@
import type { Command } from '@blocksuite/block-std';
export const retainFirstModelCommand: Command<'selectedModels'> = (
ctx,
next
) => {
if (!ctx.selectedModels) {
console.error(
'`selectedModels` is required, you need to use `getSelectedModels` command before adding this command to the pipeline.'
);
return;
}
if (ctx.selectedModels.length > 0) {
ctx.selectedModels.shift();
}
return next();
};
declare global {
namespace BlockSuite {
interface Commands {
retainFirstModel: typeof retainFirstModelCommand;
}
}
}

View File

@@ -0,0 +1,23 @@
import type { BlockSelection, Command } from '@blocksuite/block-std';
export const getBlockSelectionsCommand: Command<
never,
'currentBlockSelections'
> = (ctx, next) => {
const currentBlockSelections = ctx.std.selection.filter('block');
if (currentBlockSelections.length === 0) return;
next({ currentBlockSelections });
};
declare global {
namespace BlockSuite {
interface CommandContext {
currentBlockSelections?: BlockSelection[];
}
interface Commands {
getBlockSelections: typeof getBlockSelectionsCommand;
}
}
}

View File

@@ -0,0 +1,25 @@
import type { Command } from '@blocksuite/block-std';
import type { ImageSelection } from '../../selection/index.js';
export const getImageSelectionsCommand: Command<
never,
'currentImageSelections'
> = (ctx, next) => {
const currentImageSelections = ctx.std.selection.filter('image');
if (currentImageSelections.length === 0) return;
next({ currentImageSelections });
};
declare global {
namespace BlockSuite {
interface CommandContext {
currentImageSelections?: ImageSelection[];
}
interface Commands {
getImageSelections: typeof getImageSelectionsCommand;
}
}
}

View File

@@ -0,0 +1,200 @@
import type {
BlockSelection,
Command,
TextSelection,
} from '@blocksuite/block-std';
import { getViewportElement } from '../../utils/index.js';
export interface SelectionRect {
width: number;
height: number;
top: number;
left: number;
/**
* The block id that the rect is in. Only available for block selections.
*/
blockId?: string;
}
export const getSelectionRectsCommand: Command<
'currentTextSelection' | 'currentBlockSelections',
'selectionRects',
{
textSelection?: TextSelection;
blockSelections?: BlockSelection[];
}
> = (ctx, next) => {
let textSelection;
let blockSelections;
// priority parameters
if (ctx.textSelection) {
textSelection = ctx.textSelection;
} else if (ctx.blockSelections) {
blockSelections = ctx.blockSelections;
} else if (ctx.currentTextSelection) {
textSelection = ctx.currentTextSelection;
} else if (ctx.currentBlockSelections) {
blockSelections = ctx.currentBlockSelections;
} else {
console.error(
'No selection provided, may forgot to call getTextSelection or getBlockSelections or provide the selection directly.'
);
return;
}
const { std } = ctx;
const container = getViewportElement(std.host);
const containerRect = container?.getBoundingClientRect();
if (textSelection) {
const range = std.range.textSelectionToRange(textSelection);
if (range) {
const nativeRects = Array.from(range.getClientRects());
const rectsWithoutFiltered = nativeRects
.map(rect => ({
width: rect.right - rect.left,
height: rect.bottom - rect.top,
top:
rect.top - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0),
left:
rect.left -
(containerRect?.left ?? 0) +
(container?.scrollLeft ?? 0),
}))
.filter(rect => rect.width > 0 && rect.height > 0);
return next({
selectionRects: filterCoveringRects(rectsWithoutFiltered),
});
}
} else if (blockSelections && blockSelections.length > 0) {
const result = blockSelections
.map(blockSelection => {
const block = std.view.getBlock(blockSelection.blockId);
if (!block) return null;
const rect = block.getBoundingClientRect();
return {
width: rect.width,
height: rect.height,
top:
rect.top - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0),
left:
rect.left -
(containerRect?.left ?? 0) +
(container?.scrollLeft ?? 0),
blockId: blockSelection.blockId,
};
})
.filter(rect => !!rect);
return next({ selectionRects: result });
}
return;
};
declare global {
namespace BlockSuite {
interface CommandContext {
selectionRects?: SelectionRect[];
}
interface Commands {
/**
* Get the selection rects of the current selection or given selections.
*
* @chain may be `getTextSelection`, `getBlockSelections`, or nothing.
* @param textSelection The provided text selection.
* @param blockSelections The provided block selections. If `textSelection` is provided, this will be ignored.
* @returns The selection rects.
*/
getSelectionRects: typeof getSelectionRectsCommand;
}
}
}
function covers(rect1: SelectionRect, rect2: SelectionRect): boolean {
return (
rect1.left <= rect2.left &&
rect1.top <= rect2.top &&
rect1.left + rect1.width >= rect2.left + rect2.width &&
rect1.top + rect1.height >= rect2.top + rect2.height
);
}
function intersects(rect1: SelectionRect, rect2: SelectionRect): boolean {
return (
rect1.left <= rect2.left + rect2.width &&
rect1.left + rect1.width >= rect2.left &&
rect1.top <= rect2.top + rect2.height &&
rect1.top + rect1.height >= rect2.top
);
}
function merge(rect1: SelectionRect, rect2: SelectionRect): SelectionRect {
const left = Math.min(rect1.left, rect2.left);
const top = Math.min(rect1.top, rect2.top);
const right = Math.max(rect1.left + rect1.width, rect2.left + rect2.width);
const bottom = Math.max(rect1.top + rect1.height, rect2.top + rect2.height);
return {
width: right - left,
height: bottom - top,
top,
left,
};
}
export function filterCoveringRects(rects: SelectionRect[]): SelectionRect[] {
let mergedRects: SelectionRect[] = [];
let hasChanges: boolean;
do {
hasChanges = false;
const newMergedRects: SelectionRect[] = [...mergedRects];
for (const rect of rects) {
let merged = false;
for (let i = 0; i < newMergedRects.length; i++) {
if (covers(newMergedRects[i], rect)) {
merged = true;
break;
} else if (intersects(newMergedRects[i], rect)) {
newMergedRects[i] = merge(newMergedRects[i], rect);
merged = true;
hasChanges = true;
break;
}
}
if (!merged) {
newMergedRects.push(rect);
}
}
if (!hasChanges) {
for (let i = 0; i < newMergedRects.length; i++) {
for (let j = i + 1; j < newMergedRects.length; j++) {
if (intersects(newMergedRects[i], newMergedRects[j])) {
newMergedRects[i] = merge(newMergedRects[i], newMergedRects[j]);
newMergedRects.splice(j, 1);
hasChanges = true;
break;
}
}
}
}
mergedRects = newMergedRects;
} while (hasChanges);
return mergedRects;
}

View File

@@ -0,0 +1,23 @@
import type { Command, TextSelection } from '@blocksuite/block-std';
export const getTextSelectionCommand: Command<never, 'currentTextSelection'> = (
ctx,
next
) => {
const currentTextSelection = ctx.std.selection.find('text');
if (!currentTextSelection) return;
next({ currentTextSelection });
};
declare global {
namespace BlockSuite {
interface CommandContext {
currentTextSelection?: TextSelection;
}
interface Commands {
getTextSelection: typeof getTextSelectionCommand;
}
}
}

View File

@@ -0,0 +1,7 @@
export { getBlockSelectionsCommand } from './get-block-selections.js';
export { getImageSelectionsCommand } from './get-image-selections.js';
export {
getSelectionRectsCommand,
type SelectionRect,
} from './get-selection-rects.js';
export { getTextSelectionCommand } from './get-text-selection.js';

View File

@@ -0,0 +1,73 @@
interface BracketPair {
name: string;
left: string;
right: string;
}
export const BRACKET_PAIRS: BracketPair[] = [
{
name: 'parenthesis',
left: '(',
right: ')',
},
{
name: 'square bracket',
left: '[',
right: ']',
},
{
name: 'curly bracket',
left: '{',
right: '}',
},
{
name: 'single quote',
left: "'",
right: "'",
},
{
name: 'double quote',
left: '"',
right: '"',
},
{
name: 'angle bracket',
left: '<',
right: '>',
},
{
name: 'fullwidth single quote',
left: '',
right: '',
},
{
name: 'fullwidth double quote',
left: '“',
right: '”',
},
{
name: 'fullwidth parenthesis',
left: '',
right: '',
},
{
name: 'fullwidth square bracket',
left: '【',
right: '】',
},
{
name: 'fullwidth angle bracket',
left: '《',
right: '》',
},
{
name: 'corner bracket',
left: '「',
right: '」',
},
{
name: 'white corner bracket',
left: '『',
right: '』',
},
];

View File

@@ -0,0 +1,67 @@
import type { EmbedCardStyle } from '@blocksuite/affine-model';
export const BLOCK_CHILDREN_CONTAINER_PADDING_LEFT = 24;
export const EDGELESS_BLOCK_CHILD_PADDING = 24;
export const EDGELESS_BLOCK_CHILD_BORDER_WIDTH = 2;
// The height of the header, which is used to calculate the scroll offset
// In AFFiNE, to avoid the option element to be covered by the header, we need to reserve the space for the header
export const PAGE_HEADER_HEIGHT = 53;
export const EMBED_CARD_MIN_WIDTH = 450;
export const EMBED_CARD_WIDTH: Record<EmbedCardStyle, number> = {
horizontal: 752,
horizontalThin: 752,
list: 752,
vertical: 364,
cube: 170,
cubeThick: 170,
video: 752,
figma: 752,
html: 752,
syncedDoc: 752,
pdf: 537 + 24 + 2,
};
export const EMBED_CARD_HEIGHT: Record<EmbedCardStyle, number> = {
horizontal: 116,
horizontalThin: 80,
list: 46,
vertical: 390,
cube: 114,
cubeThick: 132,
video: 544,
figma: 544,
html: 544,
syncedDoc: 455,
pdf: 759 + 46 + 24 + 2,
};
export const EMBED_BLOCK_FLAVOUR_LIST = [
'affine:embed-github',
'affine:embed-youtube',
'affine:embed-figma',
'affine:embed-linked-doc',
'affine:embed-synced-doc',
'affine:embed-html',
'affine:embed-loom',
] as const;
export const DEFAULT_IMAGE_PROXY_ENDPOINT =
'https://affine-worker.toeverything.workers.dev/api/worker/image-proxy';
// https://github.com/toeverything/affine-workers/tree/main/packages/link-preview
export const DEFAULT_LINK_PREVIEW_ENDPOINT =
'https://affine-worker.toeverything.workers.dev/api/worker/link-preview';
// This constant is used to ignore tags when exporting using html2canvas
export const CANVAS_EXPORT_IGNORE_TAGS = [
'EDGELESS-TOOLBAR-WIDGET',
'AFFINE-DRAG-HANDLE-WIDGET',
'AFFINE-FORMAT-BAR-WIDGET',
'AFFINE-BLOCK-SELECTION',
];
export * from './bracket-pairs.js';
export * from './note.js';

View File

@@ -0,0 +1,2 @@
export const NOTE_SELECTOR =
'affine-note, affine-edgeless-note .edgeless-note-page-content, affine-edgeless-text';

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,62 @@
import {
type ReferenceParams,
ReferenceParamsSchema,
} from '@blocksuite/affine-model';
import { BaseSelection, SelectionExtension } from '@blocksuite/block-std';
export class HighlightSelection extends BaseSelection {
static override group = 'scene';
static override type = 'highlight';
readonly blockIds: string[] = [];
readonly elementIds: string[] = [];
readonly mode: 'page' | 'edgeless' = 'page';
constructor({ mode, blockIds, elementIds }: ReferenceParams) {
super({ blockId: '[scene-highlight]' });
this.mode = mode ?? 'page';
this.blockIds = blockIds ?? [];
this.elementIds = elementIds ?? [];
}
static override fromJSON(json: Record<string, unknown>): HighlightSelection {
const result = ReferenceParamsSchema.parse(json);
return new HighlightSelection(result);
}
override equals(other: HighlightSelection): boolean {
return (
this.mode === other.mode &&
this.blockId === other.blockId &&
this.blockIds.length === other.blockIds.length &&
this.elementIds.length === other.elementIds.length &&
this.blockIds.every((id, n) => id === other.blockIds[n]) &&
this.elementIds.every((id, n) => id === other.elementIds[n])
);
}
override toJSON(): Record<string, unknown> {
return {
type: 'highlight',
mode: this.mode,
blockId: this.blockId,
blockIds: this.blockIds,
elementIds: this.elementIds,
};
}
}
declare global {
namespace BlockSuite {
interface Selection {
highlight: typeof HighlightSelection;
}
}
}
export const HighlightSelectionExtension =
SelectionExtension(HighlightSelection);

View File

@@ -0,0 +1,41 @@
import { BaseSelection, SelectionExtension } from '@blocksuite/block-std';
import z from 'zod';
const ImageSelectionSchema = z.object({
blockId: z.string(),
});
export class ImageSelection extends BaseSelection {
static override group = 'note';
static override type = 'image';
static override fromJSON(json: Record<string, unknown>): ImageSelection {
const result = ImageSelectionSchema.parse(json);
return new ImageSelection(result);
}
override equals(other: BaseSelection): boolean {
if (other instanceof ImageSelection) {
return this.blockId === other.blockId;
}
return false;
}
override toJSON(): Record<string, unknown> {
return {
type: this.type,
blockId: this.blockId,
};
}
}
declare global {
namespace BlockSuite {
interface Selection {
image: typeof ImageSelection;
}
}
}
export const ImageSelectionExtension = SelectionExtension(ImageSelection);

View File

@@ -0,0 +1,5 @@
export {
HighlightSelection,
HighlightSelectionExtension,
} from './hightlight.js';
export { ImageSelection, ImageSelectionExtension } from './image.js';

View File

@@ -0,0 +1,201 @@
import type { AliasInfo, ReferenceParams } from '@blocksuite/affine-model';
import { LifeCycleWatcher, StdIdentifier } from '@blocksuite/block-std';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import type { Disposable } from '@blocksuite/global/utils';
import {
AliasIcon,
BlockLinkIcon,
DeleteIcon,
EdgelessIcon,
LinkedEdgelessIcon,
LinkedPageIcon,
PageIcon,
} from '@blocksuite/icons/lit';
import type { Doc } from '@blocksuite/store';
import { computed, type Signal, signal } from '@preact/signals-core';
import type { TemplateResult } from 'lit';
import { referenceToNode } from '../utils/reference.js';
import { DocModeProvider } from './doc-mode-service.js';
export type DocDisplayMetaParams = {
referenced?: boolean;
params?: ReferenceParams;
} & AliasInfo;
/**
* Customize document display title and icon.
*
* Supports the following blocks:
*
* * Inline View:
* `AffineReference`
* * Card View:
* `EmbedLinkedDocBlockComponent`
* `EmbedEdgelessLinkedDocBlockComponent`
* * Embed View:
* `EmbedSyncedDocBlockComponent`
* `EmbedEdgelessSyncedDocBlockComponent`
*/
export interface DocDisplayMetaExtension {
icon: (
docId: string,
referenceInfo?: DocDisplayMetaParams
) => Signal<TemplateResult>;
title: (
docId: string,
referenceInfo?: DocDisplayMetaParams
) => Signal<string>;
}
export const DocDisplayMetaProvider = createIdentifier<DocDisplayMetaExtension>(
'DocDisplayMetaService'
);
export class DocDisplayMetaService
extends LifeCycleWatcher
implements DocDisplayMetaExtension
{
static icons = {
deleted: iconBuilder(DeleteIcon),
aliased: iconBuilder(AliasIcon),
page: iconBuilder(PageIcon),
edgeless: iconBuilder(EdgelessIcon),
linkedBlock: iconBuilder(BlockLinkIcon),
linkedPage: iconBuilder(LinkedPageIcon),
linkedEdgeless: iconBuilder(LinkedEdgelessIcon),
} as const;
static override key = 'doc-display-meta';
readonly disposables: Disposable[] = [];
readonly iconMap = new WeakMap<Doc, Signal<TemplateResult>>();
readonly titleMap = new WeakMap<Doc, Signal<string>>();
static override setup(di: Container) {
di.addImpl(DocDisplayMetaProvider, this, [StdIdentifier]);
}
dispose() {
while (this.disposables.length > 0) {
this.disposables.pop()?.dispose();
}
}
icon(
pageId: string,
{ params, title, referenced }: DocDisplayMetaParams = {}
): Signal<TemplateResult> {
const doc = this.std.collection.getDoc(pageId);
if (!doc) {
return signal(DocDisplayMetaService.icons.deleted);
}
let icon$ = this.iconMap.get(doc);
if (!icon$) {
icon$ = signal(
this.std.get(DocModeProvider).getPrimaryMode(pageId) === 'edgeless'
? DocDisplayMetaService.icons.edgeless
: DocDisplayMetaService.icons.page
);
const disposable = this.std
.get(DocModeProvider)
.onPrimaryModeChange(mode => {
icon$!.value =
mode === 'edgeless'
? DocDisplayMetaService.icons.edgeless
: DocDisplayMetaService.icons.page;
}, pageId);
this.disposables.push(disposable);
this.disposables.push(
this.std.collection.slots.docRemoved
.filter(docId => docId === doc.id)
.once(() => {
const index = this.disposables.findIndex(d => d === disposable);
if (index !== -1) {
this.disposables.splice(index, 1);
disposable.dispose();
}
this.iconMap.delete(doc);
})
);
this.iconMap.set(doc, icon$);
}
return computed(() => {
if (title) {
return DocDisplayMetaService.icons.aliased;
}
if (referenceToNode({ pageId, params })) {
return DocDisplayMetaService.icons.linkedBlock;
}
if (referenced) {
const mode =
params?.mode ??
this.std.get(DocModeProvider).getPrimaryMode(pageId) ??
'page';
return mode === 'edgeless'
? DocDisplayMetaService.icons.linkedEdgeless
: DocDisplayMetaService.icons.linkedPage;
}
return icon$.value;
});
}
title(pageId: string, { title }: DocDisplayMetaParams = {}): Signal<string> {
const doc = this.std.collection.getDoc(pageId);
if (!doc) {
return signal(title || 'Deleted doc');
}
let title$ = this.titleMap.get(doc);
if (!title$) {
title$ = signal(doc.meta?.title || 'Untitled');
const disposable = this.std.collection.meta.docMetaUpdated.on(() => {
title$!.value = doc.meta?.title || 'Untitled';
});
this.disposables.push(disposable);
this.disposables.push(
this.std.collection.slots.docRemoved
.filter(docId => docId === doc.id)
.once(() => {
const index = this.disposables.findIndex(d => d === disposable);
if (index !== -1) {
this.disposables.splice(index, 1);
disposable.dispose();
}
this.titleMap.delete(doc);
})
);
this.titleMap.set(doc, title$);
}
return computed(() => {
return title || title$.value;
});
}
override unmounted() {
this.dispose();
}
}
function iconBuilder(
icon: typeof PageIcon,
size = '1.25em',
style = 'user-select:none;flex-shrink:0;vertical-align:middle;font-size:inherit;margin-bottom:0.1em;'
) {
return icon({ width: size, height: size, style });
}

View File

@@ -0,0 +1,108 @@
import type { DocMode } from '@blocksuite/affine-model';
import type { ExtensionType } from '@blocksuite/block-std';
import { Extension } from '@blocksuite/block-std';
import type { Container } from '@blocksuite/global/di';
import { createIdentifier } from '@blocksuite/global/di';
import { type Disposable, noop, Slot } from '@blocksuite/global/utils';
const DEFAULT_MODE: DocMode = 'page';
export interface DocModeProvider {
/**
* Set the primary mode of the doc.
* This would not affect the current editor mode.
* If you want to switch the editor mode, use `setEditorMode` instead.
* @param mode - The mode to set.
* @param docId - The id of the doc.
*/
setPrimaryMode: (mode: DocMode, docId: string) => void;
/**
* Get the primary mode of the doc.
* Normally, it would be used to query the mode of other doc.
* @param docId - The id of the doc.
* @returns The primary mode of the document.
*/
getPrimaryMode: (docId: string) => DocMode;
/**
* Toggle the primary mode of the doc.
* @param docId - The id of the doc.
* @returns The new primary mode of the doc.
*/
togglePrimaryMode: (docId: string) => DocMode;
/**
* Subscribe to changes in the primary mode of the doc.
* For example:
* Embed-linked-doc-block will subscribe to the primary mode of the linked doc,
* and will display different UI according to the primary mode of the linked doc.
* @param handler - The handler to call when the primary mode of certain doc changes.
* @param docId - The id of the doc.
* @returns A disposable to stop the subscription.
*/
onPrimaryModeChange: (
handler: (mode: DocMode) => void,
docId: string
) => Disposable;
/**
* Set the editor mode. Normally, it would be used to set the mode of the current editor.
* When patch or override the doc mode service, can pass a callback to set the editor mode.
* @param mode - The mode to set.
*/
setEditorMode: (mode: DocMode) => void;
/**
* Get current editor mode.
* @returns The editor mode.
*/
getEditorMode: () => DocMode | null;
}
export const DocModeProvider = createIdentifier<DocModeProvider>(
'AffineDocModeService'
);
const modeMap = new Map<string, DocMode>();
const slotMap = new Map<string, Slot<DocMode>>();
export class DocModeService extends Extension implements DocModeProvider {
static override setup(di: Container) {
di.addImpl(DocModeProvider, DocModeService);
}
getEditorMode(): DocMode | null {
return null;
}
getPrimaryMode(id: string) {
return modeMap.get(id) ?? DEFAULT_MODE;
}
onPrimaryModeChange(handler: (mode: DocMode) => void, id: string) {
if (!slotMap.get(id)) {
slotMap.set(id, new Slot());
}
return slotMap.get(id)!.on(handler);
}
setEditorMode(mode: DocMode) {
noop(mode);
}
setPrimaryMode(mode: DocMode, id: string) {
modeMap.set(id, mode);
slotMap.get(id)?.emit(mode);
}
togglePrimaryMode(id: string) {
const mode = this.getPrimaryMode(id) === 'page' ? 'edgeless' : 'page';
this.setPrimaryMode(mode, id);
return mode;
}
}
export function DocModeExtension(service: DocModeProvider): ExtensionType {
return {
setup: di => {
di.override(DocModeProvider, () => service);
},
};
}

View File

@@ -0,0 +1,125 @@
import {
type BlockComponent,
type BlockStdScope,
type DndEventState,
type EditorHost,
Extension,
type ExtensionType,
StdIdentifier,
} from '@blocksuite/block-std';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import type { Point } from '@blocksuite/global/utils';
import { Job, Slice, type SliceSnapshot } from '@blocksuite/store';
export type DropType = 'before' | 'after' | 'in';
export type OnDragStartProps = {
state: DndEventState;
startDragging: (
blocks: BlockComponent[],
state: DndEventState,
dragPreview?: HTMLElement,
dragPreviewOffset?: Point
) => void;
anchorBlockId: string | null;
editorHost: EditorHost;
};
export type OnDragEndProps = {
state: DndEventState;
draggingElements: BlockComponent[];
dropBlockId: string;
dropType: DropType | null;
dragPreview: HTMLElement;
noteScale: number;
editorHost: EditorHost;
};
export type OnDragMoveProps = {
state: DndEventState;
draggingElements?: BlockComponent[];
};
export type DragHandleOption = {
flavour: string | RegExp;
edgeless?: boolean;
onDragStart?: (props: OnDragStartProps) => boolean;
onDragMove?: (props: OnDragMoveProps) => boolean;
onDragEnd?: (props: OnDragEndProps) => boolean;
};
export const DragHandleConfigIdentifier = createIdentifier<DragHandleOption>(
'AffineDragHandleIdentifier'
);
export function DragHandleConfigExtension(
option: DragHandleOption
): ExtensionType {
return {
setup: di => {
const key =
typeof option.flavour === 'string'
? option.flavour
: option.flavour.source;
di.addImpl(DragHandleConfigIdentifier(key), () => option);
},
};
}
export const DndApiExtensionIdentifier = createIdentifier<DNDAPIExtension>(
'AffineDndApiIdentifier'
);
export class DNDAPIExtension extends Extension {
mimeType = 'application/x-blocksuite-dnd';
constructor(readonly std: BlockStdScope) {
super();
}
static override setup(di: Container) {
di.add(this, [StdIdentifier]);
di.addImpl(DndApiExtensionIdentifier, provider => provider.get(this));
}
decodeSnapshot(data: string): SliceSnapshot {
return JSON.parse(decodeURIComponent(data));
}
encodeSnapshot(json: SliceSnapshot) {
const snapshot = JSON.stringify(json);
return encodeURIComponent(snapshot);
}
fromEntity(options: {
docId: string;
flavour?: string;
blockId?: string;
}): SliceSnapshot | null {
const { docId, flavour = 'affine:embed-linked-doc', blockId } = options;
const slice = Slice.fromModels(this.std.doc, []);
const job = new Job({ collection: this.std.collection });
const snapshot = job.sliceToSnapshot(slice);
if (!snapshot) {
console.error('Failed to convert slice to snapshot');
return null;
}
const props = {
...(blockId ? { blockId } : {}),
pageId: docId,
};
return {
...snapshot,
content: [
{
id: this.std.collection.idGenerator(),
type: 'block',
flavour,
props,
children: [],
},
],
};
}
}

View File

@@ -0,0 +1,211 @@
import { type BlockStdScope, LifeCycleWatcher } from '@blocksuite/block-std';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import {
type DeepPartial,
DisposableGroup,
Slot,
} from '@blocksuite/global/utils';
import { DocCollection } from '@blocksuite/store';
import { computed, type Signal, signal } from '@preact/signals-core';
import clonedeep from 'lodash.clonedeep';
import mergeWith from 'lodash.mergewith';
import { z } from 'zod';
import {
ColorSchema,
makeDeepOptional,
NodePropsSchema,
} from '../utils/index.js';
import { EditorSettingProvider } from './editor-setting-service.js';
const LastPropsSchema = NodePropsSchema;
const OptionalPropsSchema = makeDeepOptional(NodePropsSchema);
export type LastProps = z.infer<typeof NodePropsSchema>;
export type LastPropsKey = keyof LastProps;
const SessionPropsSchema = z.object({
viewport: z.union([
z.object({
centerX: z.number(),
centerY: z.number(),
zoom: z.number(),
}),
z.object({
xywh: z.string(),
padding: z
.tuple([z.number(), z.number(), z.number(), z.number()])
.optional(),
}),
]),
templateCache: z.string(),
remoteColor: z.string(),
showBidirectional: z.boolean(),
});
const LocalPropsSchema = z.object({
presentBlackBackground: z.boolean(),
presentFillScreen: z.boolean(),
presentHideToolbar: z.boolean(),
autoHideEmbedHTMLFullScreenToolbar: z.boolean(),
});
type SessionProps = z.infer<typeof SessionPropsSchema>;
type LocalProps = z.infer<typeof LocalPropsSchema>;
type StorageProps = SessionProps & LocalProps;
type StoragePropsKey = keyof StorageProps;
function isLocalProp(key: string): key is keyof LocalProps {
return key in LocalPropsSchema.shape;
}
function isSessionProp(key: string): key is keyof SessionProps {
return key in SessionPropsSchema.shape;
}
function customizer(_target: unknown, source: unknown) {
if (
ColorSchema.safeParse(source).success ||
source instanceof DocCollection.Y.Text ||
source instanceof DocCollection.Y.Array ||
source instanceof DocCollection.Y.Map
) {
return source;
}
return;
}
export class EditPropsStore extends LifeCycleWatcher {
static override key = 'EditPropsStore';
private _disposables = new DisposableGroup();
private innerProps$: Signal<DeepPartial<LastProps>> = signal({});
lastProps$: Signal<LastProps>;
slots = {
storageUpdated: new Slot<{
key: StoragePropsKey;
value: StorageProps[StoragePropsKey];
}>(),
};
constructor(std: BlockStdScope) {
super(std);
const initProps: LastProps = LastPropsSchema.parse(
Object.entries(LastPropsSchema.shape).reduce((value, [key, schema]) => {
return {
...value,
[key]: schema.parse(undefined),
};
}, {})
);
this.lastProps$ = computed(() => {
const editorSetting$ = this.std.getOptional(EditorSettingProvider);
const nextProps = mergeWith(
clonedeep(initProps),
editorSetting$?.value,
this.innerProps$.value,
customizer
);
return LastPropsSchema.parse(nextProps);
});
}
private _getStorage<T extends StoragePropsKey>(key: T) {
return isSessionProp(key) ? sessionStorage : localStorage;
}
private _getStorageKey<T extends StoragePropsKey>(key: T) {
const id = this.std.doc.id;
switch (key) {
case 'viewport':
return 'blocksuite:' + id + ':edgelessViewport';
case 'presentBlackBackground':
return 'blocksuite:presentation:blackBackground';
case 'presentFillScreen':
return 'blocksuite:presentation:fillScreen';
case 'presentHideToolbar':
return 'blocksuite:presentation:hideToolbar';
case 'templateCache':
return 'blocksuite:' + id + ':templateTool';
case 'remoteColor':
return 'blocksuite:remote-color';
case 'showBidirectional':
return 'blocksuite:' + id + ':showBidirectional';
case 'autoHideEmbedHTMLFullScreenToolbar':
return 'blocksuite:embedHTML:autoHideFullScreenToolbar';
default:
return key;
}
}
applyLastProps(key: LastPropsKey, props: Record<string, unknown>) {
if (['__proto__', 'constructor', 'prototype'].includes(key)) {
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Invalid key: ${key}`
);
}
const lastProps = this.lastProps$.value[key];
return mergeWith(clonedeep(lastProps), props, customizer);
}
dispose() {
this._disposables.dispose();
}
getStorage<T extends StoragePropsKey>(key: T) {
try {
const storage = this._getStorage(key);
const value = storage.getItem(this._getStorageKey(key));
if (!value) return null;
if (isLocalProp(key)) {
return LocalPropsSchema.shape[key].parse(
JSON.parse(value)
) as StorageProps[T];
} else if (isSessionProp(key)) {
return SessionPropsSchema.shape[key].parse(
JSON.parse(value)
) as StorageProps[T];
} else {
return null;
}
} catch {
return null;
}
}
recordLastProps(key: LastPropsKey, props: Partial<LastProps[LastPropsKey]>) {
const schema = OptionalPropsSchema._def.innerType.shape[key];
if (!schema) return;
const overrideProps = schema.parse(props);
if (Object.keys(overrideProps).length === 0) return;
const innerProps = this.innerProps$.value;
const nextProps = mergeWith(
clonedeep(innerProps),
{ [key]: overrideProps },
customizer
);
this.innerProps$.value = OptionalPropsSchema.parse(nextProps);
}
setStorage<T extends StoragePropsKey>(key: T, value: StorageProps[T]) {
const oldValue = this.getStorage(key);
this._getStorage(key).setItem(
this._getStorageKey(key),
JSON.stringify(value)
);
if (oldValue === value) return;
this.slots.storageUpdated.emit({ key, value });
}
override unmounted() {
super.unmounted();
this.dispose();
}
}

View File

@@ -0,0 +1,25 @@
import type { ExtensionType } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
import type { DeepPartial } from '@blocksuite/global/utils';
import type { Signal } from '@preact/signals-core';
import type { z } from 'zod';
import { NodePropsSchema } from '../utils/index.js';
export const EditorSettingSchema = NodePropsSchema;
export type EditorSetting = z.infer<typeof EditorSettingSchema>;
export const EditorSettingProvider = createIdentifier<
Signal<DeepPartial<EditorSetting>>
>('AffineEditorSettingProvider');
export function EditorSettingExtension(
signal: Signal<DeepPartial<EditorSetting>>
): ExtensionType {
return {
setup: di => {
di.addImpl(EditorSettingProvider, () => signal);
},
};
}

View File

@@ -0,0 +1,44 @@
import type { EmbedCardStyle } from '@blocksuite/affine-model';
import { Extension } from '@blocksuite/block-std';
import type { Container } from '@blocksuite/global/di';
import { createIdentifier } from '@blocksuite/global/di';
export type EmbedOptions = {
flavour: string;
urlRegex: RegExp;
styles: EmbedCardStyle[];
viewType: 'card' | 'embed';
};
export interface EmbedOptionProvider {
getEmbedBlockOptions(url: string): EmbedOptions | null;
registerEmbedBlockOptions(options: EmbedOptions): void;
}
export const EmbedOptionProvider = createIdentifier<EmbedOptionProvider>(
'AffineEmbedOptionProvider'
);
export class EmbedOptionService
extends Extension
implements EmbedOptionProvider
{
private _embedBlockRegistry = new Set<EmbedOptions>();
getEmbedBlockOptions = (url: string): EmbedOptions | null => {
const entries = this._embedBlockRegistry.entries();
for (const [options] of entries) {
const regex = options.urlRegex;
if (regex.test(url)) return options;
}
return null;
};
registerEmbedBlockOptions = (options: EmbedOptions): void => {
this._embedBlockRegistry.add(options);
};
static override setup(di: Container) {
di.addImpl(EmbedOptionProvider, EmbedOptionService);
}
}

View File

@@ -0,0 +1,376 @@
import { FontFamily, FontStyle, FontWeight } from '@blocksuite/affine-model';
export interface FontConfig {
font: string;
weight: string;
url: string;
style: string;
}
export const AffineCanvasTextFonts: FontConfig[] = [
// Inter, https://fonts.cdnfonts.com/css/inter?styles=29139,29134,29135,29136,29140,29141
{
font: FontFamily.Inter,
url: 'https://cdn.affine.pro/fonts/Inter-Light-BETA.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Inter,
url: 'https://cdn.affine.pro/fonts/Inter-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Inter,
url: 'https://cdn.affine.pro/fonts/Inter-SemiBold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Inter,
url: 'https://cdn.affine.pro/fonts/Inter-LightItalic-BETA.woff',
weight: FontWeight.Light,
style: FontStyle.Italic,
},
{
font: FontFamily.Inter,
url: 'https://cdn.affine.pro/fonts/Inter-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Inter,
url: 'https://cdn.affine.pro/fonts/Inter-SemiBoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// Kalam, https://fonts.cdnfonts.com/css/kalam?styles=15166,170689,170687
{
font: FontFamily.Kalam,
url: 'https://cdn.affine.pro/fonts/Kalam-Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Kalam,
url: 'https://cdn.affine.pro/fonts/Kalam-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Kalam,
url: 'https://cdn.affine.pro/fonts/Kalam-Bold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
// Satoshi, https://fonts.cdnfonts.com/css/satoshi?styles=135009,135004,135005,135006,135002,135003
{
font: FontFamily.Satoshi,
url: 'https://cdn.affine.pro/fonts/Satoshi-Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Satoshi,
url: 'https://cdn.affine.pro/fonts/Satoshi-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Satoshi,
url: 'https://cdn.affine.pro/fonts/Satoshi-Bold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Satoshi,
url: 'https://cdn.affine.pro/fonts/Satoshi-LightItalic.woff',
weight: FontWeight.Light,
style: FontStyle.Italic,
},
{
font: FontFamily.Satoshi,
url: 'https://cdn.affine.pro/fonts/Satoshi-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Satoshi,
url: 'https://cdn.affine.pro/fonts/Satoshi-BoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// Poppins, https://fonts.cdnfonts.com/css/poppins?styles=20394,20389,20390,20391,20395,20396
{
font: FontFamily.Poppins,
url: 'https://cdn.affine.pro/fonts/Poppins-Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://cdn.affine.pro/fonts/Poppins-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://cdn.affine.pro/fonts/Poppins-Medium.woff',
weight: FontWeight.Medium,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://cdn.affine.pro/fonts/Poppins-SemiBold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://cdn.affine.pro/fonts/Poppins-LightItalic.woff',
weight: FontWeight.Light,
style: FontStyle.Italic,
},
{
font: FontFamily.Poppins,
url: 'https://cdn.affine.pro/fonts/Poppins-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Poppins,
url: 'https://cdn.affine.pro/fonts/Poppins-SemiBoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// Lora, https://fonts.cdnfonts.com/css/lora-4?styles=50357,50356,50354,50355
{
font: FontFamily.Lora,
url: 'https://cdn.affine.pro/fonts/Lora-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Lora,
url: 'https://cdn.affine.pro/fonts/Lora-Bold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Lora,
url: 'https://cdn.affine.pro/fonts/Lora-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Lora,
url: 'https://cdn.affine.pro/fonts/Lora-BoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// BebasNeue, https://fonts.cdnfonts.com/css/bebas-neue?styles=169713,17622,17620
{
font: FontFamily.BebasNeue,
url: 'https://cdn.affine.pro/fonts/BebasNeue-Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.BebasNeue,
url: 'https://cdn.affine.pro/fonts/BebasNeue-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
// OrelegaOne, https://fonts.cdnfonts.com/css/orelega-one?styles=148618
{
font: FontFamily.OrelegaOne,
url: 'https://cdn.affine.pro/fonts/OrelegaOne-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
];
export const CommunityCanvasTextFonts: FontConfig[] = [
// Inter, https://fonts.cdnfonts.com/css/inter?styles=29139,29134,29135,29136,29140,29141
{
font: FontFamily.Inter,
url: 'https://fonts.cdnfonts.com/s/19795/Inter-Light-BETA.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Inter,
url: 'https://fonts.cdnfonts.com/s/19795/Inter-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Inter,
url: 'https://fonts.cdnfonts.com/s/19795/Inter-SemiBold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Inter,
url: 'https://fonts.cdnfonts.com/s/19795/Inter-LightItalic-BETA.woff',
weight: FontWeight.Light,
style: FontStyle.Italic,
},
{
font: FontFamily.Inter,
url: 'https://fonts.cdnfonts.com/s/19795/Inter-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Inter,
url: 'https://fonts.cdnfonts.com/s/19795/Inter-SemiBoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// Kalam, https://fonts.cdnfonts.com/css/kalam?styles=15166,170689,170687
{
font: FontFamily.Kalam,
url: 'https://fonts.cdnfonts.com/s/13130/Kalam-Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Kalam,
url: 'https://fonts.cdnfonts.com/s/13130/Kalam-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Kalam,
url: 'https://fonts.cdnfonts.com/s/13130/Kalam-Bold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
// Satoshi, https://fonts.cdnfonts.com/css/satoshi?styles=135009,135004,135005,135006,135002,135003
{
font: FontFamily.Satoshi,
url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Satoshi,
url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Satoshi,
url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-Bold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Satoshi,
url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-LightItalic.woff',
weight: FontWeight.Light,
style: FontStyle.Italic,
},
{
font: FontFamily.Satoshi,
url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Satoshi,
url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-BoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// Poppins, https://fonts.cdnfonts.com/css/poppins?styles=20394,20389,20390,20391,20395,20396
{
font: FontFamily.Poppins,
url: 'https://fonts.cdnfonts.com/s/16009/Poppins-Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://fonts.cdnfonts.com/s/16009/Poppins-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://fonts.cdnfonts.com/s/16009/Poppins-Medium.woff',
weight: FontWeight.Medium,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://fonts.cdnfonts.com/s/16009/Poppins-SemiBold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://fonts.cdnfonts.com/s/16009/Poppins-LightItalic.woff',
weight: FontWeight.Light,
style: FontStyle.Italic,
},
{
font: FontFamily.Poppins,
url: 'https://fonts.cdnfonts.com/s/16009/Poppins-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Poppins,
url: 'https://fonts.cdnfonts.com/s/16009/Poppins-SemiBoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// Lora, https://fonts.cdnfonts.com/css/lora-4?styles=50357,50356,50354,50355
{
font: FontFamily.Lora,
url: 'https://fonts.cdnfonts.com/s/29883/Lora-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Lora,
url: 'https://fonts.cdnfonts.com/s/29883/Lora-Bold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Lora,
url: 'https://fonts.cdnfonts.com/s/29883/Lora-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Lora,
url: 'https://fonts.cdnfonts.com/s/29883/Lora-BoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// BebasNeue, https://fonts.cdnfonts.com/css/bebas-neue?styles=169713,17622,17620
{
font: FontFamily.BebasNeue,
url: 'https://fonts.cdnfonts.com/s/14902/BebasNeue%20Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.BebasNeue,
url: 'https://fonts.cdnfonts.com/s/14902/BebasNeue-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
// OrelegaOne, https://fonts.cdnfonts.com/css/orelega-one?styles=148618
{
font: FontFamily.OrelegaOne,
url: 'https://fonts.cdnfonts.com/s/93179/OrelegaOne-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
];

View File

@@ -0,0 +1,61 @@
import { type ExtensionType, LifeCycleWatcher } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
import { IS_FIREFOX } from '@blocksuite/global/env';
import type { FontConfig } from './config.js';
const initFontFace = IS_FIREFOX
? ({ font, weight, url, style }: FontConfig) =>
new FontFace(`"${font}"`, `url(${url})`, {
weight,
style,
})
: ({ font, weight, url, style }: FontConfig) =>
new FontFace(font, `url(${url})`, {
weight,
style,
});
export class FontLoaderService extends LifeCycleWatcher {
static override readonly key = 'font-loader';
readonly fontFaces: FontFace[] = [];
get ready() {
return Promise.all(this.fontFaces.map(fontFace => fontFace.loaded));
}
load(fonts: FontConfig[]) {
this.fontFaces.push(
...fonts.map(font => {
const fontFace = initFontFace(font);
document.fonts.add(fontFace);
fontFace.load().catch(console.error);
return fontFace;
})
);
}
override mounted() {
const config = this.std.getOptional(FontConfigIdentifier);
if (config) {
this.load(config);
}
}
override unmounted() {
this.fontFaces.forEach(fontFace => document.fonts.delete(fontFace));
this.fontFaces.splice(0, this.fontFaces.length);
}
}
export const FontConfigIdentifier =
createIdentifier<FontConfig[]>('AffineFontConfig');
export const FontConfigExtension = (
fontConfig: FontConfig[]
): ExtensionType => ({
setup: di => {
di.addImpl(FontConfigIdentifier, () => fontConfig);
},
});

View File

@@ -0,0 +1,2 @@
export * from './config.js';
export * from './font-loader-service.js';

View File

@@ -0,0 +1,21 @@
import type { ReferenceParams } from '@blocksuite/affine-model';
import type { ExtensionType } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
export interface GenerateDocUrlService {
generateDocUrl: (docId: string, params?: ReferenceParams) => string | void;
}
export const GenerateDocUrlProvider = createIdentifier<GenerateDocUrlService>(
'GenerateDocUrlService'
);
export function GenerateDocUrlExtension(
generateDocUrlProvider: GenerateDocUrlService
): ExtensionType {
return {
setup: di => {
di.addImpl(GenerateDocUrlProvider, generateDocUrlProvider);
},
};
}

View File

@@ -0,0 +1,13 @@
export * from './doc-display-meta-service.js';
export * from './doc-mode-service.js';
export * from './drag-handle-config.js';
export * from './edit-props-store.js';
export * from './editor-setting-service.js';
export * from './embed-option-service.js';
export * from './font-loader/index.js';
export * from './generate-url-service.js';
export * from './notification-service.js';
export * from './parse-url-service.js';
export * from './quick-search-service.js';
export * from './telemetry-service/index.js';
export * from './theme-service.js';

View File

@@ -0,0 +1,55 @@
import type { ExtensionType } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
import type { TemplateResult } from 'lit';
export interface NotificationService {
toast(
message: string,
options?: {
duration?: number;
portal?: HTMLElement;
}
): void;
confirm(options: {
title: string | TemplateResult;
message: string | TemplateResult;
confirmText?: string;
cancelText?: string;
abort?: AbortSignal;
}): Promise<boolean>;
prompt(options: {
title: string | TemplateResult;
message: string | TemplateResult;
autofill?: string;
placeholder?: string;
confirmText?: string;
cancelText?: string;
abort?: AbortSignal;
}): Promise<string | null>; // when cancel, return null
notify(options: {
title: string | TemplateResult;
message?: string | TemplateResult;
accent?: 'info' | 'success' | 'warning' | 'error';
duration?: number; // unit ms, give 0 to disable auto dismiss
abort?: AbortSignal;
action?: {
label: string | TemplateResult;
onClick: () => void;
};
onClose: () => void;
}): void;
}
export const NotificationProvider = createIdentifier<NotificationService>(
'AffineNotificationService'
);
export function NotificationExtension(
notificationService: NotificationService
): ExtensionType {
return {
setup: di => {
di.addImpl(NotificationProvider, notificationService);
},
};
}

View File

@@ -0,0 +1,22 @@
import type { ReferenceParams } from '@blocksuite/affine-model';
import type { ExtensionType } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
export interface ParseDocUrlService {
parseDocUrl: (
url: string
) => ({ docId: string } & ReferenceParams) | undefined;
}
export const ParseDocUrlProvider =
createIdentifier<ParseDocUrlService>('ParseDocUrlService');
export function ParseDocUrlExtension(
parseDocUrlService: ParseDocUrlService
): ExtensionType {
return {
setup: di => {
di.addImpl(ParseDocUrlProvider, parseDocUrlService);
},
};
}

View File

@@ -0,0 +1,31 @@
import type { ReferenceParams } from '@blocksuite/affine-model';
import type { ExtensionType } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
export interface QuickSearchService {
openQuickSearch: () => Promise<QuickSearchResult>;
}
export type QuickSearchResult =
| {
docId: string;
params?: ReferenceParams;
}
| {
externalUrl: string;
}
| null;
export const QuickSearchProvider = createIdentifier<QuickSearchService>(
'AffineQuickSearchService'
);
export function QuickSearchExtension(
quickSearchService: QuickSearchService
): ExtensionType {
return {
setup: di => {
di.addImpl(QuickSearchProvider, quickSearchService);
},
};
}

View File

@@ -0,0 +1,56 @@
type OrderType = 'desc' | 'asc';
export type WithParams<Map, T> = { [K in keyof Map]: Map[K] & T };
export type SortParams = {
fieldId: string;
fieldType: string;
orderType: OrderType;
orderIndex: number;
};
export type ViewParams = {
viewId: string;
viewType: string;
};
export type DatabaseParams = {
blockId: string;
};
export type DatabaseViewEvents = {
DatabaseSortClear: {
rulesCount: number;
};
};
export type DatabaseEvents = {
AddDatabase: {};
};
export interface DatabaseAllSortEvents {
DatabaseSortAdd: {};
DatabaseSortRemove: {};
DatabaseSortModify: {
oldOrderType: OrderType;
oldFieldType: string;
oldFieldId: string;
};
DatabaseSortReorder: {
prevFieldType: string;
nextFieldType: string;
newOrderIndex: number;
};
}
export type DatabaseAllViewEvents = DatabaseViewEvents &
WithParams<DatabaseAllSortEvents, SortParams>;
export type DatabaseAllEvents = DatabaseEvents &
WithParams<DatabaseAllViewEvents, ViewParams>;
export type OutDatabaseAllEvents = WithParams<
DatabaseAllEvents,
DatabaseParams
>;
export type EventTraceFn<Events> = <K extends keyof Events>(
key: K,
params: Events[K]
) => void;

View File

@@ -0,0 +1,4 @@
export * from './database.js';
export * from './link.js';
export * from './telemetry-service.js';
export * from './types.js';

View File

@@ -0,0 +1,16 @@
import type { TelemetryEvent } from './types.js';
export type LinkEventType =
| 'CopiedLink'
| 'OpenedAliasPopup'
| 'SavedAlias'
| 'ResetedAlias'
| 'OpenedViewSelector'
| 'SelectedView'
| 'OpenedCaptionEditor'
| 'OpenedCardStyleSelector'
| 'SelectedCardStyle'
| 'OpenedCardScaleSelector'
| 'SelectedCardScale';
export type LinkToolbarEvents = Record<LinkEventType, TelemetryEvent>;

View File

@@ -0,0 +1,35 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { OutDatabaseAllEvents } from './database.js';
import type { LinkToolbarEvents } from './link.js';
import type {
AttachmentUploadedEvent,
DocCreatedEvent,
ElementCreationEvent,
ElementLockEvent,
MindMapCollapseEvent,
TelemetryEvent,
} from './types.js';
export type TelemetryEventMap = OutDatabaseAllEvents &
LinkToolbarEvents & {
DocCreated: DocCreatedEvent;
Link: TelemetryEvent;
LinkedDocCreated: TelemetryEvent;
SplitNote: TelemetryEvent;
CanvasElementAdded: ElementCreationEvent;
EdgelessElementLocked: ElementLockEvent;
ExpandedAndCollapsed: MindMapCollapseEvent;
AttachmentUploadedEvent: AttachmentUploadedEvent;
};
export interface TelemetryService {
track<T extends keyof TelemetryEventMap>(
eventName: T,
props: TelemetryEventMap[T]
): void;
}
export const TelemetryProvider = createIdentifier<TelemetryService>(
'AffineTelemetryService'
);

View File

@@ -0,0 +1,63 @@
export type ElementCreationSource =
| 'shortcut'
| 'toolbar:general'
| 'toolbar:dnd'
| 'canvas:drop'
| 'canvas:draw'
| 'canvas:dbclick'
| 'canvas:paste'
| 'context-menu'
| 'ai'
| 'internal'
| 'conversation'
| 'manually save';
export interface TelemetryEvent {
page?: string;
segment?: string;
module?: string;
control?: string;
type?: string;
category?: string;
other?: unknown;
}
export interface DocCreatedEvent extends TelemetryEvent {
page?: 'doc editor' | 'whiteboard editor';
segment?: 'whiteboard' | 'note' | 'doc';
module?:
| 'slash commands'
| 'format toolbar'
| 'edgeless toolbar'
| 'inline @';
category?: 'page' | 'whiteboard';
}
export interface ElementCreationEvent extends TelemetryEvent {
segment?: 'toolbar' | 'whiteboard' | 'right sidebar';
page?: 'doc editor' | 'whiteboard editor';
module?: 'toolbar' | 'canvas' | 'ai chat panel';
control?: ElementCreationSource;
}
export interface ElementLockEvent extends TelemetryEvent {
page: 'whiteboard editor';
segment: 'element toolbar';
module: 'element toolbar';
control: 'lock' | 'unlock' | 'group-lock';
}
export interface MindMapCollapseEvent extends TelemetryEvent {
page: 'whiteboard editor';
segment: 'mind map';
type: 'expand' | 'collapse';
}
export interface AttachmentUploadedEvent extends TelemetryEvent {
page: 'doc editor' | 'whiteboard editor';
segment: 'attachment';
module: 'attachment';
control: 'uploader';
type: string; // file type
category: 'success' | 'failure';
}

View File

@@ -0,0 +1,207 @@
import { type Color, ColorScheme } from '@blocksuite/affine-model';
import {
type BlockStdScope,
Extension,
type ExtensionType,
StdIdentifier,
} from '@blocksuite/block-std';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { type Signal, signal } from '@preact/signals-core';
import {
type AffineCssVariables,
combinedDarkCssVariables,
combinedLightCssVariables,
} from '@toeverything/theme';
import { isInsideEdgelessEditor } from '../utils/index.js';
const TRANSPARENT = 'transparent';
export const ThemeExtensionIdentifier = createIdentifier<ThemeExtension>(
'AffineThemeExtension'
);
export interface ThemeExtension {
getAppTheme?: () => Signal<ColorScheme>;
getEdgelessTheme?: (docId?: string) => Signal<ColorScheme>;
}
export function OverrideThemeExtension(service: ThemeExtension): ExtensionType {
return {
setup: di => {
di.override(ThemeExtensionIdentifier, () => service);
},
};
}
export const ThemeProvider = createIdentifier<ThemeService>(
'AffineThemeProvider'
);
export class ThemeService extends Extension {
app$: Signal<ColorScheme>;
edgeless$: Signal<ColorScheme>;
get appTheme() {
return this.app$.peek();
}
get edgelessTheme() {
return this.edgeless$.peek();
}
get theme() {
return isInsideEdgelessEditor(this.std.host)
? this.edgelessTheme
: this.appTheme;
}
get theme$() {
return isInsideEdgelessEditor(this.std.host) ? this.edgeless$ : this.app$;
}
constructor(private std: BlockStdScope) {
super();
const extension = this.std.getOptional(ThemeExtensionIdentifier);
this.app$ = extension?.getAppTheme?.() || getThemeObserver().theme$;
this.edgeless$ =
extension?.getEdgelessTheme?.(this.std.doc.id) ||
getThemeObserver().theme$;
}
static override setup(di: Container) {
di.addImpl(ThemeProvider, ThemeService, [StdIdentifier]);
}
/**
* Generates a CSS's color property with `var` or `light-dark` functions.
*
* Sometimes used to set the frame/note background.
*
* @param color - A color value.
* @param fallback - If color value processing fails, it will be used as a fallback.
* @returns - A color property string.
*
* @example
*
* ```
* `rgba(255,0,0)`
* `#fff`
* `light-dark(#fff, #000)`
* `var(--affine-palette-shape-blue)`
* ```
*/
generateColorProperty(
color: Color,
fallback = 'transparent',
theme = this.theme
) {
let result: string | undefined = undefined;
if (typeof color === 'object') {
result = color[theme] ?? color.normal;
} else {
result = color;
}
if (!result) {
result = fallback;
}
if (result.startsWith('--')) {
return result.endsWith(TRANSPARENT) ? TRANSPARENT : `var(${result})`;
}
return result ?? TRANSPARENT;
}
/**
* Gets a color with the current theme.
*
* @param color - A color value.
* @param fallback - If color value processing fails, it will be used as a fallback.
* @param real - If true, it returns the computed style.
* @returns - A color property string.
*
* @example
*
* ```
* `rgba(255,0,0)`
* `#fff`
* `--affine-palette-shape-blue`
* ```
*/
getColorValue(
color: Color,
fallback = TRANSPARENT,
real = false,
theme = this.theme
) {
let result: string | undefined = undefined;
if (typeof color === 'object') {
result = color[theme] ?? color.normal;
} else {
result = color;
}
if (!result) {
result = fallback;
}
if (real && result.startsWith('--')) {
result = result.endsWith(TRANSPARENT)
? TRANSPARENT
: this.getCssVariableColor(result, theme);
}
return result ?? TRANSPARENT;
}
getCssVariableColor(property: string, theme = this.theme) {
if (property.startsWith('--')) {
if (property.endsWith(TRANSPARENT)) {
return TRANSPARENT;
}
const key = property as keyof AffineCssVariables;
const color =
theme === ColorScheme.Dark
? combinedDarkCssVariables[key]
: combinedLightCssVariables[key];
return color;
}
return property;
}
}
export class ThemeObserver {
private observer: MutationObserver;
theme$ = signal(ColorScheme.Light);
constructor() {
const COLOR_SCHEMES: string[] = Object.values(ColorScheme);
this.observer = new MutationObserver(() => {
const mode = document.documentElement.dataset.theme;
if (!mode) return;
if (!COLOR_SCHEMES.includes(mode)) return;
if (mode === this.theme$.value) return;
this.theme$.value = mode as ColorScheme;
});
this.observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
}
destroy() {
this.observer.disconnect();
}
}
export const getThemeObserver = (function () {
let observer: ThemeObserver;
return function () {
if (observer) return observer;
observer = new ThemeObserver();
return observer;
};
})();

View File

@@ -0,0 +1,24 @@
import { baseTheme } from '@toeverything/theme';
import { unsafeCSS } from 'lit';
export const FONT_BASE = unsafeCSS(`
font-family: ${baseTheme.fontSansFamily};
font-feature-settings:
'clig' off,
'liga' off;
font-style: normal;
`);
export const FONT_SM = unsafeCSS(`
${FONT_BASE};
font-size: var(--affine-font-sm);
font-weight: 500;
line-height: 22px;
`);
export const FONT_XS = unsafeCSS(`
${FONT_BASE};
font-size: var(--affine-font-xs);
font-weight: 500;
line-height: 20px;
`);

View File

@@ -0,0 +1,2 @@
export { FONT_BASE, FONT_SM, FONT_XS } from './font.js';
export { PANEL_BASE, PANEL_BASE_COLORS } from './panel.js';

View File

@@ -0,0 +1,23 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { unsafeCSS } from 'lit';
import { FONT_SM } from './font.js';
export const PANEL_BASE_COLORS = unsafeCSS(`
color: var(--affine-icon-color);
box-shadow: var(--affine-overlay-shadow);
background: ${cssVarV2('layer/background/overlayPanel')};
`);
export const PANEL_BASE = unsafeCSS(`
display: flex;
align-items: center;
gap: 8px;
width: max-content;
padding: 0 6px;
border-radius: 4px;
border: 0.5px solid ${cssVarV2('layer/insideBorder/border')};
${PANEL_BASE_COLORS};
${FONT_SM};
`);

View File

@@ -0,0 +1,132 @@
/* CSS variables. You need to handle all places where `CSS variables` are marked. */
import { LINE_COLORS, SHAPE_FILL_COLORS } from '@blocksuite/affine-model';
import {
type AffineCssVariables,
type AffineTheme,
cssVar,
} from '@toeverything/theme';
import { type AffineThemeKeyV2, cssVarV2 } from '@toeverything/theme/v2';
import { unsafeCSS } from 'lit';
export const ColorVariables = [
'--affine-brand-color',
'--affine-primary-color',
'--affine-secondary-color',
'--affine-tertiary-color',
'--affine-hover-color',
'--affine-icon-color',
'--affine-icon-secondary',
'--affine-border-color',
'--affine-divider-color',
'--affine-placeholder-color',
'--affine-quote-color',
'--affine-link-color',
'--affine-edgeless-grid-color',
'--affine-success-color',
'--affine-warning-color',
'--affine-error-color',
'--affine-processing-color',
'--affine-text-emphasis-color',
'--affine-text-primary-color',
'--affine-text-secondary-color',
'--affine-text-disable-color',
'--affine-black-10',
'--affine-black-30',
'--affine-black-50',
'--affine-black-60',
'--affine-black-80',
'--affine-black-90',
'--affine-black',
'--affine-white-10',
'--affine-white-30',
'--affine-white-50',
'--affine-white-60',
'--affine-white-80',
'--affine-white-90',
'--affine-white',
'--affine-background-code-block',
'--affine-background-tertiary-color',
'--affine-background-processing-color',
'--affine-background-error-color',
'--affine-background-warning-color',
'--affine-background-success-color',
'--affine-background-primary-color',
'--affine-background-secondary-color',
'--affine-background-modal-color',
'--affine-background-overlay-panel-color',
'--affine-tag-blue',
'--affine-tag-green',
'--affine-tag-teal',
'--affine-tag-white',
'--affine-tag-purple',
'--affine-tag-red',
'--affine-tag-pink',
'--affine-tag-yellow',
'--affine-tag-orange',
'--affine-tag-gray',
...LINE_COLORS,
...SHAPE_FILL_COLORS,
'--affine-tooltip',
'--affine-blue',
];
export const SizeVariables = [
'--affine-font-h-1',
'--affine-font-h-2',
'--affine-font-h-3',
'--affine-font-h-4',
'--affine-font-h-5',
'--affine-font-h-6',
'--affine-font-base',
'--affine-font-sm',
'--affine-font-xs',
'--affine-line-height',
'--affine-z-index-modal',
'--affine-z-index-popover',
];
export const FontFamilyVariables = [
'--affine-font-family',
'--affine-font-number-family',
'--affine-font-code-family',
];
export const StyleVariables = [
'--affine-editor-width',
'--affine-theme-mode',
'--affine-editor-mode',
/* --affine-palette-transparent: special values added for the sake of logical consistency. */
'--affine-palette-transparent',
'--affine-popover-shadow',
'--affine-menu-shadow',
'--affine-float-button-shadow',
'--affine-shadow-1',
'--affine-shadow-2',
'--affine-shadow-3',
'--affine-paragraph-space',
'--affine-popover-radius',
'--affine-scale',
...SizeVariables,
...ColorVariables,
...FontFamilyVariables,
] as const;
type VariablesType = typeof StyleVariables;
export type CssVariableName = Extract<
VariablesType[keyof VariablesType],
string
>;
export type CssVariablesMap = Record<CssVariableName, string>;
export const unsafeCSSVar = (
key: keyof AffineCssVariables | keyof AffineTheme,
fallback?: string
) => unsafeCSS(cssVar(key, fallback));
export const unsafeCSSVarV2 = (key: AffineThemeKeyV2, fallback?: string) =>
unsafeCSS(cssVarV2(key, fallback));

View File

@@ -0,0 +1 @@
export * from './css-variables.js';

View File

@@ -0,0 +1,73 @@
import type { EmbedCardStyle, ReferenceInfo } from '@blocksuite/affine-model';
import type { BlockComponent } from '@blocksuite/block-std';
import type { BlockModel } from '@blocksuite/store';
export interface EditingState {
element: BlockComponent;
model: BlockModel;
rect: DOMRect;
}
export enum LassoMode {
FreeHand,
Polygonal,
}
export type NoteChildrenFlavour =
| 'affine:paragraph'
| 'affine:list'
| 'affine:code'
| 'affine:divider'
| 'affine:database'
| 'affine:data-view'
| 'affine:image'
| 'affine:bookmark'
| 'affine:attachment'
| 'affine:surface-ref';
export interface Viewport {
left: number;
top: number;
scrollLeft: number;
scrollTop: number;
scrollWidth: number;
scrollHeight: number;
clientWidth: number;
clientHeight: number;
}
export type ExtendedModel = BlockModel & Record<string, any>;
export type EmbedOptions = {
flavour: string;
urlRegex: RegExp;
styles: EmbedCardStyle[];
viewType: 'card' | 'embed';
};
export type IndentContext = {
blockId: string;
inlineIndex: number;
flavour: Extract<
keyof BlockSuite.BlockModels,
'affine:paragraph' | 'affine:list'
>;
type: 'indent' | 'dedent';
};
export interface AffineTextAttributes {
bold?: true | null;
italic?: true | null;
underline?: true | null;
strike?: true | null;
code?: true | null;
link?: string | null;
reference?:
| ({
type: 'Subpage' | 'LinkedPage';
} & ReferenceInfo)
| null;
background?: string | null;
color?: string | null;
latex?: string | null;
}

View File

@@ -0,0 +1,159 @@
import type { Disposable } from '@blocksuite/global/utils';
import {
autoPlacement,
autoUpdate,
computePosition,
offset,
type Rect,
shift,
size,
} from '@floating-ui/dom';
export function listenClickAway(
element: HTMLElement,
onClickAway: () => void
): Disposable {
const callback = (event: MouseEvent) => {
const inside = event.composedPath().includes(element);
if (!inside) {
onClickAway();
}
};
document.addEventListener('click', callback);
return {
dispose: () => {
document.removeEventListener('click', callback);
},
};
}
type Display = 'show' | 'hidden';
const ATTR_SHOW = 'data-show';
/**
* Using attribute 'data-show' to control popper visibility.
*
* ```css
* selector {
* display: none;
* }
* selector[data-show] {
* display: block;
* }
* ```
*/
export function createButtonPopper(
reference: HTMLElement,
popperElement: HTMLElement,
stateUpdated: (state: { display: Display }) => void = () => {
/** DEFAULT EMPTY FUNCTION */
},
{
mainAxis,
crossAxis,
rootBoundary,
ignoreShift,
}: {
mainAxis?: number;
crossAxis?: number;
rootBoundary?: Rect | (() => Rect | undefined);
ignoreShift?: boolean;
} = {}
) {
let display: Display = 'hidden';
let cleanup: (() => void) | void;
const originMaxHeight = window.getComputedStyle(popperElement).maxHeight;
function compute() {
const overflowOptions = {
rootBoundary:
typeof rootBoundary === 'function' ? rootBoundary() : rootBoundary,
};
computePosition(reference, popperElement, {
middleware: [
offset({
mainAxis: mainAxis ?? 14,
crossAxis: crossAxis ?? 0,
}),
autoPlacement({
allowedPlacements: ['top', 'bottom'],
...overflowOptions,
}),
shift(overflowOptions),
size({
...overflowOptions,
apply({ availableHeight }) {
popperElement.style.maxHeight = originMaxHeight
? `min(${originMaxHeight}, ${availableHeight}px)`
: `${availableHeight}px`;
},
}),
],
})
.then(({ x, y, middlewareData: data }) => {
if (!ignoreShift) {
x += data.shift?.x ?? 0;
y += data.shift?.y ?? 0;
}
Object.assign(popperElement.style, {
position: 'absolute',
zIndex: 1,
left: `${x}px`,
top: `${y}px`,
});
})
.catch(console.error);
}
const show = (force = false) => {
const displayed = display === 'show';
if (displayed && !force) return;
if (!displayed) {
popperElement.setAttribute(ATTR_SHOW, '');
display = 'show';
stateUpdated({ display });
}
cleanup?.();
cleanup = autoUpdate(reference, popperElement, compute, {
animationFrame: true,
});
};
const hide = () => {
if (display === 'hidden') return;
popperElement.removeAttribute(ATTR_SHOW);
display = 'hidden';
stateUpdated({ display });
cleanup?.();
};
const toggle = () => {
if (popperElement.hasAttribute(ATTR_SHOW)) {
hide();
} else {
show();
}
};
const clickAway = listenClickAway(reference, () => hide());
return {
get state() {
return display;
},
show,
hide,
toggle,
dispose: () => {
cleanup?.();
clickAway.dispose();
},
};
}

View File

@@ -0,0 +1 @@
export * from './paragraph.js';

View File

@@ -0,0 +1,57 @@
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
import type { BlockModel } from '@blocksuite/store';
import { matchFlavours } from '../model/checker.js';
export function calculateCollapsedSiblings(
model: ParagraphBlockModel
): BlockModel[] {
const parent = model.parent;
if (!parent) return [];
const children = parent.children;
const index = children.indexOf(model);
if (index === -1) return [];
const collapsedEdgeIndex = children.findIndex((child, i) => {
if (
i > index &&
matchFlavours(child, ['affine:paragraph']) &&
child.type.startsWith('h')
) {
const modelLevel = parseInt(model.type.slice(1));
const childLevel = parseInt(child.type.slice(1));
return childLevel <= modelLevel;
}
return false;
});
let collapsedSiblings: BlockModel[];
if (collapsedEdgeIndex === -1) {
collapsedSiblings = children.slice(index + 1);
} else {
collapsedSiblings = children.slice(index + 1, collapsedEdgeIndex);
}
return collapsedSiblings;
}
export function getNearestHeadingBefore(
model: BlockModel
): ParagraphBlockModel | null {
const parent = model.parent;
if (!parent) return null;
const index = parent.children.indexOf(model);
if (index === -1) return null;
for (let i = index - 1; i >= 0; i--) {
const sibling = parent.children[i];
if (
matchFlavours(sibling, ['affine:paragraph']) &&
sibling.type.startsWith('h')
) {
return sibling;
}
}
return null;
}

View File

@@ -0,0 +1 @@
export * from './legacy.js';

View File

@@ -0,0 +1,175 @@
import type {
EmbedCardStyle,
EmbedSyncedDocModel,
} from '@blocksuite/affine-model';
import type { BlockComponent } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { assertExists, Bound } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import type { OnDragEndProps } from '../../services/index.js';
import { getBlockProps } from '../model/index.js';
function isEmbedSyncedDocBlock(
element: BlockModel | BlockSuite.EdgelessModel | null
): element is EmbedSyncedDocModel {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:embed-synced-doc'
);
}
/**
* @deprecated
* This is a terrible hack to apply the drag preview,
* do not use it.
* We're migrating to a standard drag and drop API.
*/
export function convertDragPreviewDocToEdgeless({
blockComponent,
dragPreview,
cssSelector,
width,
height,
noteScale,
state,
}: OnDragEndProps & {
blockComponent: BlockComponent;
cssSelector: string;
width?: number;
height?: number;
style?: EmbedCardStyle;
}): boolean {
const edgelessRoot = blockComponent.closest('affine-edgeless-root');
if (!edgelessRoot) {
return false;
}
const previewEl = dragPreview.querySelector(cssSelector);
if (!previewEl) {
return false;
}
const rect = previewEl.getBoundingClientRect();
const border = 2;
const controller = blockComponent.std.get(GfxControllerIdentifier);
const { viewport } = controller;
const { left: viewportLeft, top: viewportTop } = viewport;
const currentViewBound = new Bound(
rect.x - viewportLeft,
rect.y - viewportTop,
rect.width + border / noteScale,
rect.height + border / noteScale
);
const currentModelBound = viewport.toModelBound(currentViewBound);
// Except for embed synced doc block
// The width and height of other card style should be fixed
const newBound = isEmbedSyncedDocBlock(blockComponent.model)
? new Bound(
currentModelBound.x,
currentModelBound.y,
(currentModelBound.w ?? width) * noteScale,
(currentModelBound.h ?? height) * noteScale
)
: new Bound(
currentModelBound.x,
currentModelBound.y,
(width ?? currentModelBound.w) * noteScale,
(height ?? currentModelBound.h) * noteScale
);
const blockModel = blockComponent.model;
const blockProps = getBlockProps(blockModel);
// @ts-expect-error TODO: fix after edgeless refactor
const blockId = edgelessRoot.service.addBlock(
blockComponent.flavour,
{
...blockProps,
xywh: newBound.serialize(),
},
// @ts-expect-error TODO: fix after edgeless refactor
edgelessRoot.surfaceBlockModel
);
// Embed synced doc block should extend the note scale
// @ts-expect-error TODO: fix after edgeless refactor
const newBlock = edgelessRoot.service.getElementById(blockId);
if (isEmbedSyncedDocBlock(newBlock)) {
// @ts-expect-error TODO: fix after edgeless refactor
edgelessRoot.service.updateElement(newBlock.id, {
scale: noteScale,
});
}
const doc = blockComponent.doc;
const host = blockComponent.host;
const altKey = state.raw.altKey;
if (!altKey) {
doc.deleteBlock(blockModel);
host.selection.setGroup('note', []);
}
// @ts-expect-error TODO: fix after edgeless refactor
edgelessRoot.service.selection.set({
elements: [blockId],
editing: false,
});
return true;
}
/**
* @deprecated
* This is a terrible hack to apply the drag preview,
* do not use it.
* We're migrating to a standard drag and drop API.
*/
export function convertDragPreviewEdgelessToDoc({
blockComponent,
dropBlockId,
dropType,
state,
style,
}: OnDragEndProps & {
blockComponent: BlockComponent;
style?: EmbedCardStyle;
}): boolean {
const doc = blockComponent.doc;
const host = blockComponent.host;
const targetBlock = doc.getBlockById(dropBlockId);
if (!targetBlock) return false;
const shouldInsertIn = dropType === 'in';
const parentBlock = shouldInsertIn ? targetBlock : doc.getParent(targetBlock);
assertExists(parentBlock);
const parentIndex = shouldInsertIn
? 0
: parentBlock.children.indexOf(targetBlock) +
(dropType === 'after' ? 1 : 0);
const blockModel = blockComponent.model;
// eslint-disable-next-line no-unused-vars
const { width, height, xywh, rotate, zIndex, ...blockProps } =
getBlockProps(blockModel);
if (style) {
blockProps.style = style;
}
doc.addBlock(
blockModel.flavour as never,
blockProps,
parentBlock,
parentIndex
);
const altKey = state.raw.altKey;
if (!altKey) {
doc.deleteBlock(blockModel);
host.selection.setGroup('gfx', []);
}
return true;
}

View File

@@ -0,0 +1,15 @@
import type { EditorHost } from '@blocksuite/block-std';
export function isInsidePageEditor(host: EditorHost) {
return Array.from(host.children).some(
v => v.tagName.toLowerCase() === 'affine-page-root'
);
}
export function isInsideEdgelessEditor(host: EditorHost) {
return Array.from(host.children).some(
v =>
v.tagName.toLowerCase() === 'affine-edgeless-root' ||
v.tagName.toLowerCase() === 'affine-edgeless-root-preview'
);
}

View File

@@ -0,0 +1,6 @@
export * from './checker.js';
export * from './point-to-block.js';
export * from './point-to-range.js';
export * from './query.js';
export * from './scroll-container.js';
export * from './viewport.js';

View File

@@ -0,0 +1,320 @@
import { BLOCK_ID_ATTR, type BlockComponent } from '@blocksuite/block-std';
import type { Point, Rect } from '@blocksuite/global/utils';
import { BLOCK_CHILDREN_CONTAINER_PADDING_LEFT } from '../../consts/index.js';
import { clamp } from '../math.js';
import { matchFlavours } from '../model/checker.js';
const ATTR_SELECTOR = `[${BLOCK_ID_ATTR}]`;
// margin-top: calc(var(--affine-paragraph-space) + 24px);
// h1.margin-top = 8px + 24px = 32px;
const MAX_SPACE = 32;
const STEPS = MAX_SPACE / 2 / 2;
/**
* Returns `16` if node is contained in the parent.
* Otherwise return `0`.
*/
function contains(parent: Element, node: Element) {
return (
parent.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_CONTAINED_BY
);
}
/**
* Returns `true` if element has `data-block-id` attribute.
*/
function hasBlockId(element: Element): element is BlockComponent {
return element.hasAttribute(BLOCK_ID_ATTR);
}
/**
* Returns `true` if element is default/edgeless page or note.
*/
function isRootOrNoteOrSurface(element: BlockComponent) {
return matchFlavours(element.model, [
'affine:page',
'affine:note',
// @ts-expect-error TODO: migrate surface model to @blocksuite/affine-model
'affine:surface',
]);
}
function isBlock(element: BlockComponent) {
return !isRootOrNoteOrSurface(element);
}
function isImage({ tagName }: Element) {
return tagName === 'AFFINE-IMAGE';
}
function isDatabase({ tagName }: Element) {
return tagName === 'AFFINE-DATABASE-TABLE' || tagName === 'AFFINE-DATABASE';
}
/**
* Returns the closest block element by a point in the rect.
*
* ```
* ############### block
* ||############# block
* ||||########### block
* |||| ...
* |||| y - 2 * n
* |||| ...
* ||||----------- cursor
* |||| ...
* |||| y + 2 * n
* |||| ...
* ||||########### block
* ||############# block
* ############### block
* ```
*/
export function getClosestBlockComponentByPoint(
point: Point,
state: {
rect?: Rect;
container?: Element;
snapToEdge?: {
x: boolean;
y: boolean;
};
} | null = null,
scale = 1
): BlockComponent | null {
const { y } = point;
let container;
let element = null;
let bounds = null;
let childBounds = null;
let diff = 0;
let n = 1;
if (state) {
const {
snapToEdge = {
x: true,
y: false,
},
} = state;
container = state.container;
const rect = state.rect || container?.getBoundingClientRect();
if (rect) {
if (snapToEdge.x) {
point.x = Math.min(
Math.max(point.x, rect.left) +
BLOCK_CHILDREN_CONTAINER_PADDING_LEFT * scale -
1,
rect.right - BLOCK_CHILDREN_CONTAINER_PADDING_LEFT * scale - 1
);
}
if (snapToEdge.y) {
// TODO handle scale
if (scale !== 1) {
console.warn('scale is not supported yet');
}
point.y = clamp(point.y, rect.top + 1, rect.bottom - 1);
}
}
}
// find block element
element = findBlockComponent(
document.elementsFromPoint(point.x, point.y),
container
);
// Horizontal direction: for nested structures
if (element) {
// Database
if (isDatabase(element)) {
bounds = element.getBoundingClientRect();
const rows = getDatabaseBlockRowsElement(element);
if (rows) {
childBounds = rows.getBoundingClientRect();
if (childBounds.height) {
if (point.y < childBounds.top || point.y > childBounds.bottom) {
return element as BlockComponent;
}
childBounds = null;
} else {
return element as BlockComponent;
}
}
} else {
// Indented paragraphs or list
bounds = getRectByBlockComponent(element);
childBounds = element
.querySelector('.affine-block-children-container')
?.firstElementChild?.getBoundingClientRect();
if (childBounds && childBounds.height) {
if (bounds.x < point.x && point.x <= childBounds.x) {
return element as BlockComponent;
}
childBounds = null;
} else {
return element as BlockComponent;
}
}
bounds = null;
element = null;
}
// Vertical direction
do {
point.y = y - n * 2;
if (n < 0) n--;
n *= -1;
// find block element
element = findBlockComponent(
document.elementsFromPoint(point.x, point.y),
container
);
if (element) {
bounds = getRectByBlockComponent(element);
diff = bounds.bottom - point.y;
if (diff >= 0 && diff <= STEPS * 2) {
return element as BlockComponent;
}
diff = point.y - bounds.top;
if (diff >= 0 && diff <= STEPS * 2) {
return element as BlockComponent;
}
bounds = null;
element = null;
}
} while (n <= STEPS);
return element;
}
/**
* Find the most close block on the given position
* @param container container which the blocks can be found inside
* @param point position
* @param selector selector to find the block
*/
export function findClosestBlockComponent(
container: BlockComponent,
point: Point,
selector: string
): BlockComponent | null {
const children = (
Array.from(container.querySelectorAll(selector)) as BlockComponent[]
)
.filter(child => child.host === container.host)
.filter(child => child !== container);
let lastDistance = Number.POSITIVE_INFINITY;
let lastChild = null;
if (!children.length) return null;
for (const child of children) {
const rect = child.getBoundingClientRect();
if (rect.height === 0 || point.y > rect.bottom || point.y < rect.top)
continue;
const distance =
Math.pow(point.y - (rect.y + rect.height / 2), 2) +
Math.pow(point.x - rect.x, 2);
if (distance <= lastDistance) {
lastDistance = distance;
lastChild = child;
} else {
return lastChild;
}
}
return lastChild;
}
/**
* Returns the closest block element by element that does not contain the page element and note element.
*/
export function getClosestBlockComponentByElement(
element: Element | null
): BlockComponent | null {
if (!element) return null;
if (hasBlockId(element) && isBlock(element)) {
return element;
}
const blockComponent = element.closest<BlockComponent>(ATTR_SELECTOR);
if (blockComponent && isBlock(blockComponent)) {
return blockComponent;
}
return null;
}
/**
* Returns rect of the block element.
*
* Compatible with Safari!
* https://github.com/toeverything/blocksuite/issues/902
* https://github.com/toeverything/blocksuite/pull/1121
*/
export function getRectByBlockComponent(element: Element | BlockComponent) {
if (isDatabase(element)) return element.getBoundingClientRect();
return (element.firstElementChild ?? element).getBoundingClientRect();
}
/**
* Returns block elements excluding their subtrees.
* Only keep block elements of same level.
*/
export function getBlockComponentsExcludeSubtrees(
elements: Element[] | BlockComponent[]
): BlockComponent[] {
if (elements.length <= 1) return elements as BlockComponent[];
let parent = elements[0];
return elements.filter((node, index) => {
if (index === 0) return true;
if (contains(parent, node)) {
return false;
} else {
parent = node;
return true;
}
}) as BlockComponent[];
}
/**
* Find block element from an `Element[]`.
* In Chrome/Safari, `document.elementsFromPoint` does not include `affine-image`.
*/
function findBlockComponent(elements: Element[], parent?: Element) {
const len = elements.length;
let element = null;
let i = 0;
while (i < len) {
element = elements[i];
i++;
// if parent does not contain element, it's ignored
if (parent && !contains(parent, element)) continue;
if (hasBlockId(element) && isBlock(element)) return element;
if (isImage(element)) {
const element = elements[i];
if (i < len && hasBlockId(element) && isBlock(element)) {
return elements[i];
}
return getClosestBlockComponentByElement(element);
}
}
return null;
}
/**
* Gets the rows of the database.
*/
function getDatabaseBlockRowsElement(element: Element) {
return element.querySelector('.affine-database-block-rows');
}

View File

@@ -0,0 +1,86 @@
import { IS_FIREFOX } from '@blocksuite/global/env';
declare global {
interface Document {
// firefox API
caretPositionFromPoint(
x: number,
y: number
): {
offsetNode: Node;
offset: number;
};
}
}
/**
* A wrapper for the browser's `caretPositionFromPoint` and `caretRangeFromPoint`,
* but adapted for different browsers.
*/
export function caretRangeFromPoint(
clientX: number,
clientY: number
): Range | null {
if (IS_FIREFOX) {
const caret = document.caretPositionFromPoint(clientX, clientY);
// TODO handle caret is covered by popup
const range = document.createRange();
range.setStart(caret.offsetNode, caret.offset);
return range;
}
const range = document.caretRangeFromPoint(clientX, clientY);
if (!range) {
return null;
}
// See https://github.com/toeverything/blocksuite/issues/1382
const rangeRects = range?.getClientRects();
if (
rangeRects &&
rangeRects.length === 2 &&
range.startOffset === range.endOffset &&
clientY < rangeRects[0].y + rangeRects[0].height
) {
const deltaX = (rangeRects[0].x | 0) - (rangeRects[1].x | 0);
if (deltaX > 0) {
range.setStart(range.startContainer, range.startOffset - 1);
range.setEnd(range.endContainer, range.endOffset - 1);
}
}
return range;
}
export function resetNativeSelection(range: Range | null) {
const selection = window.getSelection();
if (!selection) return;
selection.removeAllRanges();
range && selection.addRange(range);
}
export function getCurrentNativeRange(selection = window.getSelection()) {
// When called on an <iframe> that is not displayed (e.g., where display: none is set) Firefox will return null
// See https://developer.mozilla.org/en-US/docs/Web/API/Window/getSelection for more details
if (!selection) {
console.error('Failed to get current range, selection is null');
return null;
}
if (selection.rangeCount === 0) {
return null;
}
if (selection.rangeCount > 1) {
console.warn('getCurrentNativeRange may be wrong, rangeCount > 1');
}
return selection.getRangeAt(0);
}
export function handleNativeRangeAtPoint(x: number, y: number) {
const range = caretRangeFromPoint(x, y);
const startContainer = range?.startContainer;
// click on rich text
if (startContainer instanceof Node) {
resetNativeSelection(range);
}
}

View File

@@ -0,0 +1,39 @@
import type { RootBlockModel } from '@blocksuite/affine-model';
import { BLOCK_ID_ATTR, type BlockComponent } from '@blocksuite/block-std';
import type { BlockModel } from '@blocksuite/store';
const ATTR_SELECTOR = `[${BLOCK_ID_ATTR}]`;
export function getModelByElement<Model extends BlockModel>(
element: Element
): Model | null {
const closestBlock = element.closest<BlockComponent>(ATTR_SELECTOR);
if (!closestBlock) {
return null;
}
return closestBlock.model as Model;
}
export function getRootByElement(
element: Element
): BlockComponent<RootBlockModel> | null {
const pageRoot = getPageRootByElement(element);
if (pageRoot) return pageRoot;
const edgelessRoot = getEdgelessRootByElement(element);
if (edgelessRoot) return edgelessRoot;
return null;
}
export function getPageRootByElement(
element: Element
): BlockComponent<RootBlockModel> | null {
return element.closest('affine-page-root');
}
export function getEdgelessRootByElement(
element: Element
): BlockComponent<RootBlockModel> | null {
return element.closest('affine-edgeless-root');
}

View File

@@ -0,0 +1,12 @@
export const getScrollContainer = (ele: HTMLElement) => {
let container: HTMLElement | null = ele;
while (container && !isScrollable(container)) {
container = container.parentElement;
}
return container ?? document.body;
};
export const isScrollable = (ele: HTMLElement) => {
const value = window.getComputedStyle(ele).overflowY;
return value === 'scroll' || value === 'auto';
};

View File

@@ -0,0 +1,34 @@
import type { BlockComponent, EditorHost } from '@blocksuite/block-std';
import { isInsidePageEditor } from './checker.js';
/**
* Get editor viewport element.
* @example
* ```ts
* const viewportElement = getViewportElement(this.model.doc);
* if (!viewportElement) return;
* this._disposables.addFromEvent(viewportElement, 'scroll', () => {
* updatePosition();
* });
* ```
*/
export function getViewportElement(editorHost: EditorHost): HTMLElement | null {
if (!isInsidePageEditor(editorHost)) return null;
const doc = editorHost.doc;
if (!doc.root) {
console.error('Failed to get root doc');
return null;
}
const rootComponent = editorHost.view.getBlock(doc.root.id);
if (
!rootComponent ||
rootComponent.closest('affine-page-root') !== rootComponent
) {
console.error('Failed to get viewport element!');
return null;
}
return (rootComponent as BlockComponent & { viewportElement: HTMLElement })
.viewportElement;
}

View File

@@ -0,0 +1,196 @@
import { IS_IOS, IS_MAC } from '@blocksuite/global/env';
export function isTouchPadPinchEvent(e: WheelEvent) {
// two finger pinches on touch pad, ctrlKey is always true.
// https://bugs.chromium.org/p/chromium/issues/detail?id=397027
if (IS_IOS || IS_MAC) {
return e.ctrlKey || e.metaKey;
}
return e.ctrlKey;
}
// See https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
export enum MOUSE_BUTTONS {
AUXILIARY = 4,
FIFTH = 16,
FORTH = 8,
NO_BUTTON = 0,
PRIMARY = 1,
SECONDARY = 2,
}
export enum MOUSE_BUTTON {
AUXILIARY = 1,
FIFTH = 4,
FORTH = 3,
MAIN = 0,
SECONDARY = 2,
}
export function isMiddleButtonPressed(e: MouseEvent) {
return (MOUSE_BUTTONS.AUXILIARY & e.buttons) === MOUSE_BUTTONS.AUXILIARY;
}
export function isRightButtonPressed(e: MouseEvent) {
return (MOUSE_BUTTONS.SECONDARY & e.buttons) === MOUSE_BUTTONS.SECONDARY;
}
export function stopPropagation(event: Event) {
event.stopPropagation();
}
export function isControlledKeyboardEvent(e: KeyboardEvent) {
return e.ctrlKey || e.metaKey || e.altKey;
}
export function on<
T extends HTMLElement,
K extends keyof M,
M = HTMLElementEventMap,
>(
element: T,
event: K,
handler: (ev: M[K]) => void,
options?: boolean | AddEventListenerOptions
): () => void;
export function on<T extends HTMLElement>(
element: T,
event: string,
handler: (ev: Event) => void,
options?: boolean | AddEventListenerOptions
): () => void;
export function on<T extends Document, K extends keyof M, M = DocumentEventMap>(
element: T,
event: K,
handler: (ev: M[K]) => void,
options?: boolean | AddEventListenerOptions
): () => void;
export function on<
T extends HTMLElement | Document,
K extends keyof HTMLElementEventMap,
>(
element: T,
event: K,
handler: (ev: HTMLElementEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
) {
const dispose = () => {
element.removeEventListener(
event as string,
handler as unknown as EventListenerObject,
options
);
};
element.addEventListener(
event as string,
handler as unknown as EventListenerObject,
options
);
return dispose;
}
export function once<
T extends HTMLElement,
K extends keyof M,
M = HTMLElementEventMap,
>(
element: T,
event: K,
handler: (ev: M[K]) => void,
options?: boolean | AddEventListenerOptions
): () => void;
export function once<T extends HTMLElement>(
element: T,
event: string,
handler: (ev: Event) => void,
options?: boolean | AddEventListenerOptions
): () => void;
export function once<
T extends Document,
K extends keyof M,
M = DocumentEventMap,
>(
element: T,
event: K,
handler: (ev: M[K]) => void,
options?: boolean | AddEventListenerOptions
): () => void;
export function once<
T extends HTMLElement,
K extends keyof HTMLElementEventMap,
>(
element: T,
event: K,
handler: (ev: HTMLElementEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
) {
const onceHandler = (e: HTMLElementEventMap[K]) => {
dispose();
handler(e);
};
const dispose = () => {
element.removeEventListener(event, onceHandler, options);
};
element.addEventListener(event, onceHandler, options);
return dispose;
}
export function delayCallback(callback: () => void, delay: number = 0) {
const timeoutId = setTimeout(callback, delay);
return () => clearTimeout(timeoutId);
}
/**
* A wrapper around `requestAnimationFrame` that only calls the callback if the
* element is still connected to the DOM.
*/
export function requestConnectedFrame(
callback: () => void,
element?: HTMLElement
) {
return requestAnimationFrame(() => {
// If element is not provided, fallback to `requestAnimationFrame`
if (element === undefined) {
callback();
return;
}
// Only calls callback if element is still connected to the DOM
if (element.isConnected) callback();
});
}
/**
* A wrapper around `requestConnectedFrame` that only calls at most once in one frame
*/
export function requestThrottledConnectedFrame<
T extends (...args: unknown[]) => void,
>(func: T, element?: HTMLElement): T {
let raqId: number | undefined = undefined;
let latestArgs: unknown[] = [];
return ((...args: unknown[]) => {
latestArgs = args;
if (raqId) return;
raqId = requestConnectedFrame(() => {
raqId = undefined;
func(...latestArgs);
}, element);
}) as T;
}
export const captureEventTarget = (target: EventTarget | null) => {
const isElementOrNode = target instanceof Element || target instanceof Node;
return isElementOrNode
? target instanceof Element
? target
: target.parentElement
: null;
};

View File

@@ -0,0 +1,327 @@
// Polyfill for `showOpenFilePicker` API
// See https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/wicg-file-system-access/index.d.ts
// See also https://caniuse.com/?search=showOpenFilePicker
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
interface OpenFilePickerOptions {
types?:
| {
description?: string | undefined;
accept: Record<string, string | string[]>;
}[]
| undefined;
excludeAcceptAllOption?: boolean | undefined;
multiple?: boolean | undefined;
}
declare global {
interface Window {
// Window API: showOpenFilePicker
showOpenFilePicker?: (
options?: OpenFilePickerOptions
) => Promise<FileSystemFileHandle[]>;
}
}
// See [Common MIME types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types)
const FileTypes: NonNullable<OpenFilePickerOptions['types']> = [
{
description: 'Images',
accept: {
'image/*': [
'.avif',
'.gif',
// '.ico',
'.jpeg',
'.jpg',
'.png',
'.tif',
'.tiff',
// '.svg',
'.webp',
],
},
},
{
description: 'Videos',
accept: {
'video/*': [
'.avi',
'.mp4',
'.mpeg',
'.ogg',
// '.ts',
'.webm',
'.3gp',
'.3g2',
],
},
},
{
description: 'Audios',
accept: {
'audio/*': [
'.aac',
'.mid',
'.midi',
'.mp3',
'.oga',
'.opus',
'.wav',
'.weba',
'.3gp',
'.3g2',
],
},
},
{
description: 'Markdown',
accept: {
'text/markdown': ['.md', '.markdown'],
},
},
{
description: 'Html',
accept: {
'text/html': ['.html', '.htm'],
},
},
{
description: 'Zip',
accept: {
'application/zip': ['.zip'],
},
},
{
description: 'MindMap',
accept: {
'text/xml': ['.mm', '.opml', '.xml'],
},
},
];
/**
* See https://web.dev/patterns/files/open-one-or-multiple-files/
*/
type AcceptTypes =
| 'Any'
| 'Images'
| 'Videos'
| 'Audios'
| 'Markdown'
| 'Html'
| 'Zip'
| 'MindMap';
export function openFileOrFiles(options?: {
acceptType?: AcceptTypes;
}): Promise<File | null>;
export function openFileOrFiles(options: {
acceptType?: AcceptTypes;
multiple: false;
}): Promise<File | null>;
export function openFileOrFiles(options: {
acceptType?: AcceptTypes;
multiple: true;
}): Promise<File[] | null>;
export async function openFileOrFiles({
acceptType = 'Any',
multiple = false,
} = {}) {
// Feature detection. The API needs to be supported
// and the app not run in an iframe.
const supportsFileSystemAccess =
'showOpenFilePicker' in window &&
(() => {
try {
return window.self === window.top;
} catch {
return false;
}
})();
// If the File System Access API is supported…
if (supportsFileSystemAccess && window.showOpenFilePicker) {
try {
const fileType = FileTypes.find(i => i.description === acceptType);
if (acceptType !== 'Any' && !fileType)
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Unexpected acceptType "${acceptType}"`
);
const pickerOpts = {
types: fileType ? [fileType] : undefined,
multiple,
} satisfies OpenFilePickerOptions;
// Show the file picker, optionally allowing multiple files.
const handles = await window.showOpenFilePicker(pickerOpts);
// Only one file is requested.
if (!multiple) {
// Add the `FileSystemFileHandle` as `.handle`.
const file = await handles[0].getFile();
// Add the `FileSystemFileHandle` as `.handle`.
// file.handle = handles[0];
return file;
} else {
const files = await Promise.all(
handles.map(async handle => {
const file = await handle.getFile();
// Add the `FileSystemFileHandle` as `.handle`.
// file.handle = handles[0];
return file;
})
);
return files;
}
} catch (err) {
console.error('Error opening file');
console.error(err);
return null;
}
}
// Fallback if the File System Access API is not supported.
return new Promise(resolve => {
// Append a new `<input type="file" multiple? />` and hide it.
const input = document.createElement('input');
input.classList.add('affine-upload-input');
input.style.display = 'none';
input.type = 'file';
if (multiple) {
input.multiple = true;
}
if (acceptType !== 'Any') {
// For example, `accept="image/*"` or `accept="video/*,audio/*"`.
input.accept = Object.keys(
FileTypes.find(i => i.description === acceptType)?.accept ?? ''
).join(',');
}
document.body.append(input);
// The `change` event fires when the user interacts with the dialog.
input.addEventListener('change', () => {
// Remove the `<input type="file" multiple? />` again from the DOM.
input.remove();
// If no files were selected, return.
if (!input.files) {
resolve(null);
return;
}
// Return all files or just one file.
if (multiple) {
resolve(Array.from(input.files));
return;
}
resolve(input.files[0]);
});
// The `cancel` event fires when the user cancels the dialog.
input.addEventListener('cancel', () => {
resolve(null);
});
// Show the picker.
if ('showPicker' in HTMLInputElement.prototype) {
input.showPicker();
} else {
input.click();
}
});
}
export async function getImageFilesFromLocal() {
const imageFiles = await openFileOrFiles({
acceptType: 'Images',
multiple: true,
});
if (!imageFiles) return [];
return imageFiles;
}
export function downloadBlob(blob: Blob, name: string) {
const dataURL = URL.createObjectURL(blob);
const tmpLink = document.createElement('a');
const event = new MouseEvent('click');
tmpLink.download = name;
tmpLink.href = dataURL;
tmpLink.dispatchEvent(event);
tmpLink.remove();
URL.revokeObjectURL(dataURL);
}
// Use lru strategy is a better choice, but it's just a temporary solution.
const MAX_TEMP_DATA_SIZE = 100;
/**
* TODO @Saul-Mirone use some other way to store the temp data
*
* @deprecated Waiting for migration
*/
const tempAttachmentMap = new Map<
string,
{
// name for the attachment
name: string;
}
>();
const tempImageMap = new Map<
string,
{
// This information comes from pictures.
// If the user switches between pictures and attachments,
// this information should be retained.
width: number | undefined;
height: number | undefined;
}
>();
/**
* Because the image block and attachment block have different props.
* We need to save some data temporarily when converting between them to ensure no data is lost.
*
* For example, before converting from an image block to an attachment block,
* we need to save the image's width and height.
*
* Similarly, when converting from an attachment block to an image block,
* we need to save the attachment's name.
*
* See also https://github.com/toeverything/blocksuite/pull/4583#pullrequestreview-1610662677
*
* @internal
*/
export function withTempBlobData() {
const saveAttachmentData = (sourceId: string, data: { name: string }) => {
if (tempAttachmentMap.size > MAX_TEMP_DATA_SIZE) {
console.warn(
'Clear the temp attachment data. It may cause filename loss when converting between image and attachment.'
);
tempAttachmentMap.clear();
}
tempAttachmentMap.set(sourceId, data);
};
const getAttachmentData = (blockId: string) => {
const data = tempAttachmentMap.get(blockId);
tempAttachmentMap.delete(blockId);
return data;
};
const saveImageData = (
sourceId: string,
data: { width: number | undefined; height: number | undefined }
) => {
if (tempImageMap.size > MAX_TEMP_DATA_SIZE) {
console.warn(
'Clear temp image data. It may cause image width and height loss when converting between image and attachment.'
);
tempImageMap.clear();
}
tempImageMap.set(sourceId, data);
};
const getImageData = (blockId: string) => {
const data = tempImageMap.get(blockId);
tempImageMap.delete(blockId);
return data;
};
return {
saveAttachmentData,
getAttachmentData,
saveImageData,
getImageData,
};
}

View File

@@ -0,0 +1,32 @@
// https://www.rfc-editor.org/rfc/rfc9110#name-field-names
export const getFilenameFromContentDisposition = (header_value: string) => {
header_value = header_value.trim();
const quote_indices = [];
const quote_map = Object.create(null);
for (let i = 0; i < header_value.length; i++) {
if (header_value[i] === '"' && header_value[i - 1] !== '\\') {
quote_indices.push(i);
}
}
let target_index = header_value.indexOf('filename=');
for (let i = 0; i < quote_indices.length; i += 2) {
const start = quote_indices[i];
const end = quote_indices[i + 1];
quote_map[start] = end;
if (start < target_index && target_index < end) {
target_index = header_value.indexOf('filename=', end);
}
}
if (target_index === -1) {
return undefined;
}
if (quote_map[target_index + 9] === undefined) {
const end_space = header_value.indexOf(' ', target_index);
return header_value.slice(
target_index + 9,
end_space === -1 ? header_value.length : end_space
);
}
return header_value.slice(target_index + 10, quote_map[target_index + 9]);
};

View File

@@ -0,0 +1,2 @@
export * from './filesys.js';
export * from './header-value-parser.js';

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