mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-19 15:26:59 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
57
blocksuite/affine/shared/package.json
Normal file
57
blocksuite/affine/shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ExtensionType } from '@blocksuite/block-std';
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
|
||||
import type { BlockAdapterMatcher } from '../types/adapter.js';
|
||||
import type { HtmlAST } from '../types/hast.js';
|
||||
import type { HtmlDeltaConverter } from './delta-converter.js';
|
||||
|
||||
export type BlockHtmlAdapterMatcher = BlockAdapterMatcher<
|
||||
HtmlAST,
|
||||
HtmlDeltaConverter
|
||||
>;
|
||||
|
||||
export const BlockHtmlAdapterMatcherIdentifier =
|
||||
createIdentifier<BlockHtmlAdapterMatcher>('BlockHtmlAdapterMatcher');
|
||||
|
||||
export function BlockHtmlAdapterExtension(
|
||||
matcher: BlockHtmlAdapterMatcher
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<BlockHtmlAdapterMatcher>;
|
||||
} {
|
||||
const identifier = BlockHtmlAdapterMatcherIdentifier(matcher.flavour);
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, () => matcher);
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { ExtensionType } from '@blocksuite/block-std';
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
|
||||
import type { AffineTextAttributes } from '../../types/index.js';
|
||||
import {
|
||||
type ASTToDeltaMatcher,
|
||||
DeltaASTConverter,
|
||||
type DeltaASTConverterOptions,
|
||||
type InlineDeltaMatcher,
|
||||
} from '../types/adapter.js';
|
||||
import type { HtmlAST, InlineHtmlAST } from '../types/hast.js';
|
||||
import { TextUtils } from '../utils/text.js';
|
||||
|
||||
export type InlineDeltaToHtmlAdapterMatcher = InlineDeltaMatcher<InlineHtmlAST>;
|
||||
|
||||
export const InlineDeltaToHtmlAdapterMatcherIdentifier =
|
||||
createIdentifier<InlineDeltaToHtmlAdapterMatcher>(
|
||||
'InlineDeltaToHtmlAdapterMatcher'
|
||||
);
|
||||
|
||||
export function InlineDeltaToHtmlAdapterExtension(
|
||||
matcher: InlineDeltaToHtmlAdapterMatcher
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<InlineDeltaToHtmlAdapterMatcher>;
|
||||
} {
|
||||
const identifier = InlineDeltaToHtmlAdapterMatcherIdentifier(matcher.name);
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, () => matcher);
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
|
||||
export type HtmlASTToDeltaMatcher = ASTToDeltaMatcher<HtmlAST>;
|
||||
|
||||
export const HtmlASTToDeltaMatcherIdentifier =
|
||||
createIdentifier<HtmlASTToDeltaMatcher>('HtmlASTToDeltaMatcher');
|
||||
|
||||
export function HtmlASTToDeltaExtension(
|
||||
matcher: HtmlASTToDeltaMatcher
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<HtmlASTToDeltaMatcher>;
|
||||
} {
|
||||
const identifier = HtmlASTToDeltaMatcherIdentifier(matcher.name);
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, () => matcher);
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
|
||||
export class HtmlDeltaConverter extends DeltaASTConverter<
|
||||
AffineTextAttributes,
|
||||
HtmlAST
|
||||
> {
|
||||
constructor(
|
||||
readonly configs: Map<string, string>,
|
||||
readonly inlineDeltaMatchers: InlineDeltaToHtmlAdapterMatcher[],
|
||||
readonly htmlASTToDeltaMatchers: HtmlASTToDeltaMatcher[]
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private _applyTextFormatting(
|
||||
delta: DeltaInsert<AffineTextAttributes>
|
||||
): InlineHtmlAST {
|
||||
let hast: InlineHtmlAST = {
|
||||
type: 'text',
|
||||
value: delta.insert,
|
||||
};
|
||||
|
||||
const context: {
|
||||
configs: Map<string, string>;
|
||||
current: InlineHtmlAST;
|
||||
} = {
|
||||
configs: this.configs,
|
||||
current: hast,
|
||||
};
|
||||
for (const matcher of this.inlineDeltaMatchers) {
|
||||
if (matcher.match(delta)) {
|
||||
hast = matcher.toAST(delta, context);
|
||||
context.current = hast;
|
||||
}
|
||||
}
|
||||
|
||||
return hast;
|
||||
}
|
||||
|
||||
private _spreadAstToDelta(
|
||||
ast: HtmlAST,
|
||||
options: DeltaASTConverterOptions = Object.create(null)
|
||||
): DeltaInsert<AffineTextAttributes>[] {
|
||||
const context = {
|
||||
configs: this.configs,
|
||||
options,
|
||||
toDelta: (ast: HtmlAST, options?: DeltaASTConverterOptions) =>
|
||||
this._spreadAstToDelta(ast, options),
|
||||
};
|
||||
for (const matcher of this.htmlASTToDeltaMatchers) {
|
||||
if (matcher.match(ast)) {
|
||||
return matcher.toDelta(ast, context);
|
||||
}
|
||||
}
|
||||
return 'children' in ast
|
||||
? ast.children.flatMap(child => this._spreadAstToDelta(child, options))
|
||||
: [];
|
||||
}
|
||||
|
||||
astToDelta(
|
||||
ast: HtmlAST,
|
||||
options: DeltaASTConverterOptions = Object.create(null)
|
||||
): DeltaInsert<AffineTextAttributes>[] {
|
||||
return this._spreadAstToDelta(ast, options).reduce((acc, cur) => {
|
||||
return TextUtils.mergeDeltas(acc, cur);
|
||||
}, [] as DeltaInsert<AffineTextAttributes>[]);
|
||||
}
|
||||
|
||||
deltaToAST(
|
||||
deltas: DeltaInsert<AffineTextAttributes>[],
|
||||
depth = 0
|
||||
): InlineHtmlAST[] {
|
||||
if (depth > 0) {
|
||||
deltas.unshift({ insert: ' '.repeat(4).repeat(depth) });
|
||||
}
|
||||
|
||||
return deltas.map(delta => this._applyTextFormatting(delta));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './block-adapter.js';
|
||||
export * from './delta-converter.js';
|
||||
export * from './type.js';
|
||||
@@ -0,0 +1 @@
|
||||
export type Html = string;
|
||||
53
blocksuite/affine/shared/src/adapters/index.ts
Normal file
53
blocksuite/affine/shared/src/adapters/index.ts
Normal 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';
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ExtensionType } from '@blocksuite/block-std';
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
|
||||
import type { BlockAdapterMatcher } from '../types/adapter.js';
|
||||
import type { MarkdownDeltaConverter } from './delta-converter.js';
|
||||
import type { MarkdownAST } from './type.js';
|
||||
|
||||
export type BlockMarkdownAdapterMatcher = BlockAdapterMatcher<
|
||||
MarkdownAST,
|
||||
MarkdownDeltaConverter
|
||||
>;
|
||||
|
||||
export const BlockMarkdownAdapterMatcherIdentifier =
|
||||
createIdentifier<BlockMarkdownAdapterMatcher>('BlockMarkdownAdapterMatcher');
|
||||
|
||||
export function BlockMarkdownAdapterExtension(
|
||||
matcher: BlockMarkdownAdapterMatcher
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<BlockMarkdownAdapterMatcher>;
|
||||
} {
|
||||
const identifier = BlockMarkdownAdapterMatcherIdentifier(matcher.flavour);
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, () => matcher);
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { ExtensionType } from '@blocksuite/block-std';
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
import type { DeltaInsert } from '@blocksuite/inline/types';
|
||||
import type { PhrasingContent } from 'mdast';
|
||||
|
||||
import type { AffineTextAttributes } from '../../types/index.js';
|
||||
import {
|
||||
type ASTToDeltaMatcher,
|
||||
DeltaASTConverter,
|
||||
type InlineDeltaMatcher,
|
||||
} from '../types/adapter.js';
|
||||
import type { MarkdownAST } from './type.js';
|
||||
|
||||
export type InlineDeltaToMarkdownAdapterMatcher =
|
||||
InlineDeltaMatcher<PhrasingContent>;
|
||||
|
||||
export const InlineDeltaToMarkdownAdapterMatcherIdentifier =
|
||||
createIdentifier<InlineDeltaToMarkdownAdapterMatcher>(
|
||||
'InlineDeltaToMarkdownAdapterMatcher'
|
||||
);
|
||||
|
||||
export function InlineDeltaToMarkdownAdapterExtension(
|
||||
matcher: InlineDeltaToMarkdownAdapterMatcher
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<InlineDeltaToMarkdownAdapterMatcher>;
|
||||
} {
|
||||
const identifier = InlineDeltaToMarkdownAdapterMatcherIdentifier(
|
||||
matcher.name
|
||||
);
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, () => matcher);
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
|
||||
export type MarkdownASTToDeltaMatcher = ASTToDeltaMatcher<MarkdownAST>;
|
||||
|
||||
export const MarkdownASTToDeltaMatcherIdentifier =
|
||||
createIdentifier<MarkdownASTToDeltaMatcher>('MarkdownASTToDeltaMatcher');
|
||||
|
||||
export function MarkdownASTToDeltaExtension(
|
||||
matcher: MarkdownASTToDeltaMatcher
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<MarkdownASTToDeltaMatcher>;
|
||||
} {
|
||||
const identifier = MarkdownASTToDeltaMatcherIdentifier(matcher.name);
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, () => matcher);
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
|
||||
export class MarkdownDeltaConverter extends DeltaASTConverter<
|
||||
AffineTextAttributes,
|
||||
MarkdownAST
|
||||
> {
|
||||
constructor(
|
||||
readonly configs: Map<string, string>,
|
||||
readonly inlineDeltaMatchers: InlineDeltaToMarkdownAdapterMatcher[],
|
||||
readonly markdownASTToDeltaMatchers: MarkdownASTToDeltaMatcher[]
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
applyTextFormatting(
|
||||
delta: DeltaInsert<AffineTextAttributes>
|
||||
): PhrasingContent {
|
||||
let mdast: PhrasingContent = {
|
||||
type: 'text',
|
||||
value: delta.attributes?.underline
|
||||
? `<u>${delta.insert}</u>`
|
||||
: delta.insert,
|
||||
};
|
||||
|
||||
const context: {
|
||||
configs: Map<string, string>;
|
||||
current: PhrasingContent;
|
||||
} = {
|
||||
configs: this.configs,
|
||||
current: mdast,
|
||||
};
|
||||
for (const matcher of this.inlineDeltaMatchers) {
|
||||
if (matcher.match(delta)) {
|
||||
mdast = matcher.toAST(delta, context);
|
||||
context.current = mdast;
|
||||
}
|
||||
}
|
||||
|
||||
return mdast;
|
||||
}
|
||||
|
||||
astToDelta(ast: MarkdownAST): DeltaInsert<AffineTextAttributes>[] {
|
||||
const context = {
|
||||
configs: this.configs,
|
||||
options: Object.create(null),
|
||||
toDelta: (ast: MarkdownAST) => this.astToDelta(ast),
|
||||
};
|
||||
for (const matcher of this.markdownASTToDeltaMatchers) {
|
||||
if (matcher.match(ast)) {
|
||||
return matcher.toDelta(ast, context);
|
||||
}
|
||||
}
|
||||
return 'children' in ast
|
||||
? ast.children.flatMap(child => this.astToDelta(child))
|
||||
: [];
|
||||
}
|
||||
|
||||
deltaToAST(
|
||||
deltas: DeltaInsert<AffineTextAttributes>[],
|
||||
depth = 0
|
||||
): PhrasingContent[] {
|
||||
if (depth > 0) {
|
||||
deltas.unshift({ insert: ' '.repeat(4).repeat(depth) });
|
||||
}
|
||||
|
||||
return deltas.map(delta => this.applyTextFormatting(delta));
|
||||
}
|
||||
}
|
||||
3
blocksuite/affine/shared/src/adapters/markdown/index.ts
Normal file
3
blocksuite/affine/shared/src/adapters/markdown/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './block-adapter.js';
|
||||
export * from './delta-converter.js';
|
||||
export * from './type.js';
|
||||
17
blocksuite/affine/shared/src/adapters/markdown/type.ts
Normal file
17
blocksuite/affine/shared/src/adapters/markdown/type.ts
Normal 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;
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ExtensionType } from '@blocksuite/block-std';
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
|
||||
import type { BlockAdapterMatcher } from '../types/adapter.js';
|
||||
import type { HtmlAST } from '../types/hast.js';
|
||||
import type { NotionHtmlDeltaConverter } from './delta-converter.js';
|
||||
|
||||
export type BlockNotionHtmlAdapterMatcher = BlockAdapterMatcher<
|
||||
HtmlAST,
|
||||
NotionHtmlDeltaConverter
|
||||
>;
|
||||
|
||||
export const BlockNotionHtmlAdapterMatcherIdentifier =
|
||||
createIdentifier<BlockNotionHtmlAdapterMatcher>(
|
||||
'BlockNotionHtmlAdapterMatcher'
|
||||
);
|
||||
|
||||
export function BlockNotionHtmlAdapterExtension(
|
||||
matcher: BlockNotionHtmlAdapterMatcher
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<BlockNotionHtmlAdapterMatcher>;
|
||||
} {
|
||||
const identifier = BlockNotionHtmlAdapterMatcherIdentifier(matcher.flavour);
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, () => matcher);
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ExtensionType } from '@blocksuite/block-std';
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
import { isEqual } from '@blocksuite/global/utils';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
|
||||
import type { AffineTextAttributes } from '../../types/index.js';
|
||||
import {
|
||||
type ASTToDeltaMatcher,
|
||||
DeltaASTConverter,
|
||||
type DeltaASTConverterOptions,
|
||||
type InlineDeltaMatcher,
|
||||
} from '../types/adapter.js';
|
||||
import type { HtmlAST, InlineHtmlAST } from '../types/hast.js';
|
||||
|
||||
export type InlineDeltaToNotionHtmlAdapterMatcher =
|
||||
InlineDeltaMatcher<InlineHtmlAST>;
|
||||
|
||||
export type NotionHtmlASTToDeltaMatcher = ASTToDeltaMatcher<HtmlAST>;
|
||||
|
||||
export const NotionHtmlASTToDeltaMatcherIdentifier =
|
||||
createIdentifier<NotionHtmlASTToDeltaMatcher>('NotionHtmlASTToDeltaMatcher');
|
||||
|
||||
export function NotionHtmlASTToDeltaExtension(
|
||||
matcher: NotionHtmlASTToDeltaMatcher
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<NotionHtmlASTToDeltaMatcher>;
|
||||
} {
|
||||
const identifier = NotionHtmlASTToDeltaMatcherIdentifier(matcher.name);
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, () => matcher);
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
|
||||
export class NotionHtmlDeltaConverter extends DeltaASTConverter<
|
||||
AffineTextAttributes,
|
||||
HtmlAST
|
||||
> {
|
||||
constructor(
|
||||
readonly configs: Map<string, string>,
|
||||
readonly inlineDeltaMatchers: InlineDeltaToNotionHtmlAdapterMatcher[],
|
||||
readonly htmlASTToDeltaMatchers: NotionHtmlASTToDeltaMatcher[]
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private _spreadAstToDelta(
|
||||
ast: HtmlAST,
|
||||
options: DeltaASTConverterOptions = Object.create(null)
|
||||
): DeltaInsert<AffineTextAttributes>[] {
|
||||
const context = {
|
||||
configs: this.configs,
|
||||
options,
|
||||
toDelta: (ast: HtmlAST, options?: DeltaASTConverterOptions) =>
|
||||
this._spreadAstToDelta(ast, options),
|
||||
};
|
||||
for (const matcher of this.htmlASTToDeltaMatchers) {
|
||||
if (matcher.match(ast)) {
|
||||
return matcher.toDelta(ast, context);
|
||||
}
|
||||
}
|
||||
|
||||
const result =
|
||||
'children' in ast
|
||||
? ast.children.flatMap(child => this._spreadAstToDelta(child, options))
|
||||
: [];
|
||||
|
||||
if (options.removeLastBr && result.length > 0) {
|
||||
const lastItem = result[result.length - 1];
|
||||
if (lastItem.insert === '\n') {
|
||||
result.pop();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
astToDelta(
|
||||
ast: HtmlAST,
|
||||
options: DeltaASTConverterOptions = Object.create(null)
|
||||
): DeltaInsert<AffineTextAttributes>[] {
|
||||
return this._spreadAstToDelta(ast, options).reduce((acc, cur) => {
|
||||
if (acc.length === 0) {
|
||||
return [cur];
|
||||
}
|
||||
const last = acc[acc.length - 1];
|
||||
if (
|
||||
typeof last.insert === 'string' &&
|
||||
typeof cur.insert === 'string' &&
|
||||
isEqual(last.attributes, cur.attributes)
|
||||
) {
|
||||
last.insert += cur.insert;
|
||||
return acc;
|
||||
}
|
||||
return [...acc, cur];
|
||||
}, [] as DeltaInsert<AffineTextAttributes>[]);
|
||||
}
|
||||
|
||||
deltaToAST(_: DeltaInsert<AffineTextAttributes>[]): InlineHtmlAST[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './block-adapter.js';
|
||||
export * from './delta-converter.js';
|
||||
export * from './type.js';
|
||||
@@ -0,0 +1 @@
|
||||
export type NotionHtml = string;
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ExtensionType } from '@blocksuite/block-std';
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
|
||||
import type { BlockAdapterMatcher, TextBuffer } from '../types/adapter.js';
|
||||
|
||||
export type BlockPlainTextAdapterMatcher = BlockAdapterMatcher<TextBuffer>;
|
||||
|
||||
export const BlockPlainTextAdapterMatcherIdentifier =
|
||||
createIdentifier<BlockPlainTextAdapterMatcher>(
|
||||
'BlockPlainTextAdapterMatcher'
|
||||
);
|
||||
|
||||
export function BlockPlainTextAdapterExtension(
|
||||
matcher: BlockPlainTextAdapterMatcher
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<BlockPlainTextAdapterMatcher>;
|
||||
} {
|
||||
const identifier = BlockPlainTextAdapterMatcherIdentifier(matcher.flavour);
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, () => matcher);
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
|
||||
import type { AffineTextAttributes } from '../../types/index.js';
|
||||
import {
|
||||
type ASTToDeltaMatcher,
|
||||
DeltaASTConverter,
|
||||
type InlineDeltaMatcher,
|
||||
type TextBuffer,
|
||||
} from '../types/adapter.js';
|
||||
|
||||
export type InlineDeltaToPlainTextAdapterMatcher =
|
||||
InlineDeltaMatcher<TextBuffer>;
|
||||
|
||||
export const InlineDeltaToPlainTextAdapterMatcherIdentifier =
|
||||
createIdentifier<InlineDeltaToPlainTextAdapterMatcher>(
|
||||
'InlineDeltaToPlainTextAdapterMatcher'
|
||||
);
|
||||
|
||||
export type PlainTextASTToDeltaMatcher = ASTToDeltaMatcher<string>;
|
||||
|
||||
export class PlainTextDeltaConverter extends DeltaASTConverter<
|
||||
AffineTextAttributes,
|
||||
string
|
||||
> {
|
||||
constructor(
|
||||
readonly configs: Map<string, string>,
|
||||
readonly inlineDeltaMatchers: InlineDeltaToPlainTextAdapterMatcher[],
|
||||
readonly plainTextASTToDeltaMatchers: PlainTextASTToDeltaMatcher[]
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
astToDelta(ast: string) {
|
||||
const context = {
|
||||
configs: this.configs,
|
||||
options: Object.create(null),
|
||||
toDelta: (ast: string) => this.astToDelta(ast),
|
||||
};
|
||||
for (const matcher of this.plainTextASTToDeltaMatchers) {
|
||||
if (matcher.match(ast)) {
|
||||
return matcher.toDelta(ast, context);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
deltaToAST(deltas: DeltaInsert<AffineTextAttributes>[]): string[] {
|
||||
return deltas.map(delta => {
|
||||
const context = {
|
||||
configs: this.configs,
|
||||
current: { content: delta.insert },
|
||||
};
|
||||
for (const matcher of this.inlineDeltaMatchers) {
|
||||
if (matcher.match(delta)) {
|
||||
context.current = matcher.toAST(delta, context);
|
||||
}
|
||||
}
|
||||
return context.current.content;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './block-adapter.js';
|
||||
export * from './delta-converter.js';
|
||||
export * from './type.js';
|
||||
1
blocksuite/affine/shared/src/adapters/plain-text/type.ts
Normal file
1
blocksuite/affine/shared/src/adapters/plain-text/type.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type PlainText = string;
|
||||
170
blocksuite/affine/shared/src/adapters/types/adapter.ts
Normal file
170
blocksuite/affine/shared/src/adapters/types/adapter.ts
Normal 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>[];
|
||||
};
|
||||
12
blocksuite/affine/shared/src/adapters/types/hast.ts
Normal file
12
blocksuite/affine/shared/src/adapters/types/hast.ts
Normal 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;
|
||||
2
blocksuite/affine/shared/src/adapters/types/index.ts
Normal file
2
blocksuite/affine/shared/src/adapters/types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './adapter.js';
|
||||
export * from './hast.js';
|
||||
37
blocksuite/affine/shared/src/adapters/utils/fetch.ts
Normal file
37
blocksuite/affine/shared/src/adapters/utils/fetch.ts
Normal 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,
|
||||
};
|
||||
266
blocksuite/affine/shared/src/adapters/utils/hast.ts
Normal file
266
blocksuite/affine/shared/src/adapters/utils/hast.ts
Normal 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,
|
||||
};
|
||||
3
blocksuite/affine/shared/src/adapters/utils/index.ts
Normal file
3
blocksuite/affine/shared/src/adapters/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './fetch.js';
|
||||
export * from './hast.js';
|
||||
export * from './text.js';
|
||||
83
blocksuite/affine/shared/src/adapters/utils/text.ts
Normal file
83
blocksuite/affine/shared/src/adapters/utils/text.ts
Normal 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,
|
||||
};
|
||||
4
blocksuite/affine/shared/src/commands/README.md
Normal file
4
blocksuite/affine/shared/src/commands/README.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
5
blocksuite/affine/shared/src/commands/index.d.ts
vendored
Normal file
5
blocksuite/affine/shared/src/commands/index.d.ts
vendored
Normal 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';
|
||||
32
blocksuite/affine/shared/src/commands/index.ts
Normal file
32
blocksuite/affine/shared/src/commands/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
7
blocksuite/affine/shared/src/commands/selection/index.ts
Normal file
7
blocksuite/affine/shared/src/commands/selection/index.ts
Normal 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';
|
||||
73
blocksuite/affine/shared/src/consts/bracket-pairs.ts
Normal file
73
blocksuite/affine/shared/src/consts/bracket-pairs.ts
Normal 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: '』',
|
||||
},
|
||||
];
|
||||
67
blocksuite/affine/shared/src/consts/index.ts
Normal file
67
blocksuite/affine/shared/src/consts/index.ts
Normal 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';
|
||||
2
blocksuite/affine/shared/src/consts/note.ts
Normal file
2
blocksuite/affine/shared/src/consts/note.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const NOTE_SELECTOR =
|
||||
'affine-note, affine-edgeless-note .edgeless-note-page-content, affine-edgeless-text';
|
||||
1
blocksuite/affine/shared/src/index.ts
Normal file
1
blocksuite/affine/shared/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
1
blocksuite/affine/shared/src/mixins/index.ts
Normal file
1
blocksuite/affine/shared/src/mixins/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
62
blocksuite/affine/shared/src/selection/hightlight.ts
Normal file
62
blocksuite/affine/shared/src/selection/hightlight.ts
Normal 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);
|
||||
41
blocksuite/affine/shared/src/selection/image.ts
Normal file
41
blocksuite/affine/shared/src/selection/image.ts
Normal 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);
|
||||
5
blocksuite/affine/shared/src/selection/index.ts
Normal file
5
blocksuite/affine/shared/src/selection/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
HighlightSelection,
|
||||
HighlightSelectionExtension,
|
||||
} from './hightlight.js';
|
||||
export { ImageSelection, ImageSelectionExtension } from './image.js';
|
||||
@@ -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 });
|
||||
}
|
||||
108
blocksuite/affine/shared/src/services/doc-mode-service.ts
Normal file
108
blocksuite/affine/shared/src/services/doc-mode-service.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
125
blocksuite/affine/shared/src/services/drag-handle-config.ts
Normal file
125
blocksuite/affine/shared/src/services/drag-handle-config.ts
Normal 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: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
211
blocksuite/affine/shared/src/services/edit-props-store.ts
Normal file
211
blocksuite/affine/shared/src/services/edit-props-store.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
376
blocksuite/affine/shared/src/services/font-loader/config.ts
Normal file
376
blocksuite/affine/shared/src/services/font-loader/config.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './config.js';
|
||||
export * from './font-loader-service.js';
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
13
blocksuite/affine/shared/src/services/index.ts
Normal file
13
blocksuite/affine/shared/src/services/index.ts
Normal 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';
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
22
blocksuite/affine/shared/src/services/parse-url-service.ts
Normal file
22
blocksuite/affine/shared/src/services/parse-url-service.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './database.js';
|
||||
export * from './link.js';
|
||||
export * from './telemetry-service.js';
|
||||
export * from './types.js';
|
||||
@@ -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>;
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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';
|
||||
}
|
||||
207
blocksuite/affine/shared/src/services/theme-service.ts
Normal file
207
blocksuite/affine/shared/src/services/theme-service.ts
Normal 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;
|
||||
};
|
||||
})();
|
||||
24
blocksuite/affine/shared/src/styles/font.ts
Normal file
24
blocksuite/affine/shared/src/styles/font.ts
Normal 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;
|
||||
`);
|
||||
2
blocksuite/affine/shared/src/styles/index.ts
Normal file
2
blocksuite/affine/shared/src/styles/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { FONT_BASE, FONT_SM, FONT_XS } from './font.js';
|
||||
export { PANEL_BASE, PANEL_BASE_COLORS } from './panel.js';
|
||||
23
blocksuite/affine/shared/src/styles/panel.ts
Normal file
23
blocksuite/affine/shared/src/styles/panel.ts
Normal 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};
|
||||
`);
|
||||
132
blocksuite/affine/shared/src/theme/css-variables.ts
Normal file
132
blocksuite/affine/shared/src/theme/css-variables.ts
Normal 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));
|
||||
1
blocksuite/affine/shared/src/theme/index.ts
Normal file
1
blocksuite/affine/shared/src/theme/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './css-variables.js';
|
||||
73
blocksuite/affine/shared/src/types/index.ts
Normal file
73
blocksuite/affine/shared/src/types/index.ts
Normal 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;
|
||||
}
|
||||
159
blocksuite/affine/shared/src/utils/button-popper.ts
Normal file
159
blocksuite/affine/shared/src/utils/button-popper.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
1
blocksuite/affine/shared/src/utils/collapsed/index.ts
Normal file
1
blocksuite/affine/shared/src/utils/collapsed/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './paragraph.js';
|
||||
57
blocksuite/affine/shared/src/utils/collapsed/paragraph.ts
Normal file
57
blocksuite/affine/shared/src/utils/collapsed/paragraph.ts
Normal 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;
|
||||
}
|
||||
1
blocksuite/affine/shared/src/utils/dnd/index.ts
Normal file
1
blocksuite/affine/shared/src/utils/dnd/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './legacy.js';
|
||||
175
blocksuite/affine/shared/src/utils/dnd/legacy.ts
Normal file
175
blocksuite/affine/shared/src/utils/dnd/legacy.ts
Normal 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;
|
||||
}
|
||||
15
blocksuite/affine/shared/src/utils/dom/checker.ts
Normal file
15
blocksuite/affine/shared/src/utils/dom/checker.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
6
blocksuite/affine/shared/src/utils/dom/index.ts
Normal file
6
blocksuite/affine/shared/src/utils/dom/index.ts
Normal 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';
|
||||
320
blocksuite/affine/shared/src/utils/dom/point-to-block.ts
Normal file
320
blocksuite/affine/shared/src/utils/dom/point-to-block.ts
Normal 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');
|
||||
}
|
||||
86
blocksuite/affine/shared/src/utils/dom/point-to-range.ts
Normal file
86
blocksuite/affine/shared/src/utils/dom/point-to-range.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
39
blocksuite/affine/shared/src/utils/dom/query.ts
Normal file
39
blocksuite/affine/shared/src/utils/dom/query.ts
Normal 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');
|
||||
}
|
||||
12
blocksuite/affine/shared/src/utils/dom/scroll-container.ts
Normal file
12
blocksuite/affine/shared/src/utils/dom/scroll-container.ts
Normal 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';
|
||||
};
|
||||
34
blocksuite/affine/shared/src/utils/dom/viewport.ts
Normal file
34
blocksuite/affine/shared/src/utils/dom/viewport.ts
Normal 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;
|
||||
}
|
||||
196
blocksuite/affine/shared/src/utils/event.ts
Normal file
196
blocksuite/affine/shared/src/utils/event.ts
Normal 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;
|
||||
};
|
||||
327
blocksuite/affine/shared/src/utils/file/filesys.ts
Normal file
327
blocksuite/affine/shared/src/utils/file/filesys.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
};
|
||||
2
blocksuite/affine/shared/src/utils/file/index.ts
Normal file
2
blocksuite/affine/shared/src/utils/file/index.ts
Normal 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
Reference in New Issue
Block a user