mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
3
blocksuite/framework/inline/README.md
Normal file
3
blocksuite/framework/inline/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `@blocksuite/inline`
|
||||
|
||||
Inline rich text editing component for BlockSuite. Checkout the docs at [blocksuite.io/inline](https://blocksuite.io/guide/inline.html).
|
||||
40
blocksuite/framework/inline/package.json
Normal file
40
blocksuite/framework/inline/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@blocksuite/inline",
|
||||
"description": "A micro editor.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test:unit": "nx vite:test --run",
|
||||
"test:unit:coverage": "nx vite:test --run --coverage",
|
||||
"test:unit:ui": "nx vite:test --ui"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"lit": "^3.2.0",
|
||||
"yjs": "patch:yjs@npm%3A13.6.18#~/.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lit": "^3.2.0",
|
||||
"yjs": "*"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./consts": "./src/consts.ts",
|
||||
"./effects": "./src/effects.ts",
|
||||
"./types": "./src/types.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
145
blocksuite/framework/inline/src/__tests__/convert.unit.spec.ts
Normal file
145
blocksuite/framework/inline/src/__tests__/convert.unit.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
deltaInsertsToChunks,
|
||||
transformDelta,
|
||||
} from '../utils/delta-convert.js';
|
||||
|
||||
test('transformDelta', () => {
|
||||
expect(
|
||||
transformDelta({
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
transformDelta({
|
||||
insert: '\n\naaa\n\nbbb\n\n',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
})
|
||||
).toEqual([
|
||||
'\n',
|
||||
'\n',
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
'\n',
|
||||
'\n',
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
'\n',
|
||||
'\n',
|
||||
]);
|
||||
});
|
||||
|
||||
test('deltaInsertsToChunks', () => {
|
||||
expect(
|
||||
deltaInsertsToChunks([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
deltaInsertsToChunks([
|
||||
{
|
||||
insert: '\n\naaa\nbbb\n\n',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
]);
|
||||
|
||||
expect(
|
||||
deltaInsertsToChunks([
|
||||
{
|
||||
insert: '\n\naaa\n',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: '\nbbb\n\n',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
]);
|
||||
});
|
||||
526
blocksuite/framework/inline/src/__tests__/editor.unit.spec.ts
Normal file
526
blocksuite/framework/inline/src/__tests__/editor.unit.spec.ts
Normal file
@@ -0,0 +1,526 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { InlineEditor } from '../inline-editor.js';
|
||||
|
||||
test('getDeltaByRangeIndex', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
yText.applyDelta([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const inlineEditor = new InlineEditor(yText);
|
||||
|
||||
expect(inlineEditor.getDeltaByRangeIndex(0)).toEqual({
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(inlineEditor.getDeltaByRangeIndex(1)).toEqual({
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(inlineEditor.getDeltaByRangeIndex(3)).toEqual({
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(inlineEditor.getDeltaByRangeIndex(4)).toEqual({
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('getDeltasByInlineRange', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
yText.applyDelta([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'ccc',
|
||||
attributes: {
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const inlineEditor = new InlineEditor(yText);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 0,
|
||||
length: 0,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 0,
|
||||
length: 1,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 0,
|
||||
length: 3,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 0,
|
||||
length: 4,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 3,
|
||||
length: 1,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 3,
|
||||
length: 3,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 3,
|
||||
length: 4,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'ccc',
|
||||
attributes: {
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 6,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 4,
|
||||
length: 0,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 4,
|
||||
length: 1,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 4,
|
||||
length: 2,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 4,
|
||||
length: 4,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'ccc',
|
||||
attributes: {
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 6,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('cursor with format', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
const inlineEditor = new InlineEditor(yText);
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 0,
|
||||
length: 0,
|
||||
},
|
||||
'aaa',
|
||||
{
|
||||
bold: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.setMarks({
|
||||
italic: true,
|
||||
});
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 3,
|
||||
length: 0,
|
||||
},
|
||||
'bbb'
|
||||
);
|
||||
|
||||
expect(inlineEditor.yText.toDelta()).toEqual([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('getFormat', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
const inlineEditor = new InlineEditor(yText);
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 0,
|
||||
length: 0,
|
||||
},
|
||||
'aaa',
|
||||
{
|
||||
bold: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 3,
|
||||
length: 0,
|
||||
},
|
||||
'bbb',
|
||||
{
|
||||
italic: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 0, length: 0 })).toEqual({});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 0, length: 1 })).toEqual({
|
||||
bold: true,
|
||||
});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 0, length: 3 })).toEqual({
|
||||
bold: true,
|
||||
});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 3, length: 0 })).toEqual({
|
||||
bold: true,
|
||||
});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 3, length: 1 })).toEqual({
|
||||
italic: true,
|
||||
});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 3, length: 3 })).toEqual({
|
||||
italic: true,
|
||||
});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 6, length: 0 })).toEqual({
|
||||
italic: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('incorrect format value `false`', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
const inlineEditor = new InlineEditor(yText);
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 0,
|
||||
length: 0,
|
||||
},
|
||||
'aaa',
|
||||
{
|
||||
// @ts-expect-error insert incorrect value
|
||||
bold: false,
|
||||
italic: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 3,
|
||||
length: 0,
|
||||
},
|
||||
'bbb',
|
||||
{
|
||||
underline: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(inlineEditor.yText.toDelta()).toEqual([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('yText should not contain \r', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
yText.insert(0, 'aaa\r');
|
||||
|
||||
expect(yText.toString()).toEqual('aaa\r');
|
||||
expect(() => {
|
||||
new InlineEditor(yText);
|
||||
}).toThrow(
|
||||
'yText must not contain "\\r" because it will break the range synchronization'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
intersectInlineRange,
|
||||
isInlineRangeAfter,
|
||||
isInlineRangeBefore,
|
||||
isInlineRangeContain,
|
||||
isInlineRangeEdge,
|
||||
isInlineRangeEdgeAfter,
|
||||
isInlineRangeEdgeBefore,
|
||||
isInlineRangeEqual,
|
||||
isInlineRangeIntersect,
|
||||
isPoint,
|
||||
mergeInlineRange,
|
||||
} from '../utils/inline-range.js';
|
||||
|
||||
test('isInlineRangeContain', () => {
|
||||
expect(
|
||||
isInlineRangeContain({ index: 0, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 0, length: 0 }, { index: 0, length: 2 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 0, length: 2 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 0, length: 2 }, { index: 0, length: 1 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 0, length: 2 }, { index: 0, length: 2 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 3 }, { index: 0, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 3 }, { index: 0, length: 1 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 3 }, { index: 0, length: 2 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 4 }, { index: 2, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 4 }, { index: 2, length: 3 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 4 }, { index: 2, length: 4 })
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
test('isInlineRangeEqual', () => {
|
||||
expect(
|
||||
isInlineRangeEqual({ index: 0, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeEqual({ index: 0, length: 2 }, { index: 0, length: 1 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeEqual({ index: 1, length: 3 }, { index: 1, length: 3 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeEqual({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeEqual({ index: 2, length: 0 }, { index: 2, length: 0 })
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
test('isInlineRangeIntersect', () => {
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 0, length: 2 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 0, length: 2 }, { index: 2, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 1, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 1, length: 0 }, { index: 0, length: 1 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 1, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 1, length: 0 }, { index: 2, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 1, length: 0 }, { index: 0, length: 2 })
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
test('isInlineRangeBefore', () => {
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 0, length: 1 }, { index: 2, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 2, length: 0 }, { index: 0, length: 1 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 1, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 0, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 0, length: 0 }, { index: 0, length: 1 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 0, length: 1 }, { index: 0, length: 0 })
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
test('isInlineRangeAfter', () => {
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 2, length: 0 }, { index: 0, length: 1 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 0, length: 1 }, { index: 2, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 1, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 0, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 0, length: 0 }, { index: 0, length: 1 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 0, length: 1 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
test('isInlineRangeEdge', () => {
|
||||
expect(isInlineRangeEdge(1, { index: 1, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdge(1, { index: 0, length: 1 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdge(0, { index: 0, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdge(1, { index: 0, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdge(0, { index: 1, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdge(0, { index: 0, length: 1 })).toEqual(true);
|
||||
});
|
||||
|
||||
test('isInlineRangeEdgeBefore', () => {
|
||||
expect(isInlineRangeEdgeBefore(1, { index: 1, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdgeBefore(1, { index: 0, length: 1 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeBefore(0, { index: 0, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdgeBefore(1, { index: 0, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeBefore(0, { index: 1, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeBefore(0, { index: 0, length: 1 })).toEqual(true);
|
||||
});
|
||||
|
||||
test('isInlineRangeEdgeAfter', () => {
|
||||
expect(isInlineRangeEdgeAfter(1, { index: 0, length: 1 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(1, { index: 1, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(0, { index: 0, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(0, { index: 1, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(1, { index: 0, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(0, { index: 0, length: 1 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(0, { index: 0, length: 0 })).toEqual(true);
|
||||
});
|
||||
|
||||
test('isPoint', () => {
|
||||
expect(isPoint({ index: 1, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isPoint({ index: 0, length: 2 })).toEqual(false);
|
||||
|
||||
expect(isPoint({ index: 0, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isPoint({ index: 2, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isPoint({ index: 2, length: 2 })).toEqual(false);
|
||||
});
|
||||
|
||||
test('mergeInlineRange', () => {
|
||||
expect(
|
||||
mergeInlineRange({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual({
|
||||
index: 0,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 0, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual({
|
||||
index: 0,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 1, length: 0 }, { index: 2, length: 0 })
|
||||
).toEqual({
|
||||
index: 1,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 2, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual({
|
||||
index: 1,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 1, length: 3 }, { index: 2, length: 2 })
|
||||
).toEqual({
|
||||
index: 1,
|
||||
length: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 2, length: 2 }, { index: 1, length: 1 })
|
||||
).toEqual({
|
||||
index: 1,
|
||||
length: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 3, length: 2 }, { index: 2, length: 1 })
|
||||
).toEqual({
|
||||
index: 2,
|
||||
length: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 0, length: 4 }, { index: 1, length: 1 })
|
||||
).toEqual({
|
||||
index: 0,
|
||||
length: 4,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 1, length: 1 }, { index: 0, length: 4 })
|
||||
).toEqual({
|
||||
index: 0,
|
||||
length: 4,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 0, length: 2 }, { index: 1, length: 3 })
|
||||
).toEqual({
|
||||
index: 0,
|
||||
length: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test('intersectInlineRange', () => {
|
||||
expect(
|
||||
intersectInlineRange({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(null);
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 0, length: 2 }, { index: 1, length: 1 })
|
||||
).toEqual({ index: 1, length: 1 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 0, length: 2 }, { index: 2, length: 0 })
|
||||
).toEqual({ index: 2, length: 0 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 1, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual({ index: 1, length: 0 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 1, length: 3 }, { index: 2, length: 2 })
|
||||
).toEqual({ index: 2, length: 2 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 1, length: 2 }, { index: 0, length: 3 })
|
||||
).toEqual({ index: 1, length: 2 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 1, length: 1 }, { index: 2, length: 2 })
|
||||
).toEqual({ index: 2, length: 0 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 2, length: 2 }, { index: 1, length: 3 })
|
||||
).toEqual({ index: 2, length: 2 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 2, length: 1 }, { index: 1, length: 1 })
|
||||
).toEqual({ index: 2, length: 0 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 0, length: 4 }, { index: 1, length: 2 })
|
||||
).toEqual({ index: 1, length: 2 });
|
||||
});
|
||||
172
blocksuite/framework/inline/src/__tests__/utils.ts
Normal file
172
blocksuite/framework/inline/src/__tests__/utils.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
import type { DeltaInsert, InlineEditor, InlineRange } from '../index.js';
|
||||
|
||||
const defaultPlaygroundURL = new URL(
|
||||
`http://localhost:${process.env.CI ? 4173 : 5173}/`
|
||||
);
|
||||
|
||||
export async function type(page: Page, content: string) {
|
||||
await page.keyboard.type(content, { delay: 50 });
|
||||
}
|
||||
|
||||
export async function press(page: Page, content: string) {
|
||||
await page.keyboard.press(content, { delay: 50 });
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
|
||||
export async function enterInlineEditorPlayground(page: Page) {
|
||||
const url = new URL('examples/inline/index.html', defaultPlaygroundURL);
|
||||
await page.goto(url.toString());
|
||||
}
|
||||
|
||||
export async function focusInlineRichText(
|
||||
page: Page,
|
||||
index = 0
|
||||
): Promise<void> {
|
||||
await page.evaluate(index => {
|
||||
const richTexts = document
|
||||
.querySelector('test-page')
|
||||
?.querySelectorAll('test-rich-text');
|
||||
|
||||
if (!richTexts) {
|
||||
throw new Error('Cannot find test-rich-text');
|
||||
}
|
||||
|
||||
(richTexts[index] as any).inlineEditor.focusEnd();
|
||||
}, index);
|
||||
}
|
||||
|
||||
export async function getDeltaFromInlineRichText(
|
||||
page: Page,
|
||||
index = 0
|
||||
): Promise<DeltaInsert> {
|
||||
await page.waitForTimeout(100);
|
||||
return page.evaluate(index => {
|
||||
const richTexts = document
|
||||
.querySelector('test-page')
|
||||
?.querySelectorAll('test-rich-text');
|
||||
|
||||
if (!richTexts) {
|
||||
throw new Error('Cannot find test-rich-text');
|
||||
}
|
||||
|
||||
const editor = (richTexts[index] as any).inlineEditor as InlineEditor;
|
||||
return editor.yText.toDelta();
|
||||
}, index);
|
||||
}
|
||||
|
||||
export async function getInlineRangeFromInlineRichText(
|
||||
page: Page,
|
||||
index = 0
|
||||
): Promise<InlineRange | null> {
|
||||
await page.waitForTimeout(100);
|
||||
return page.evaluate(index => {
|
||||
const richTexts = document
|
||||
.querySelector('test-page')
|
||||
?.querySelectorAll('test-rich-text');
|
||||
|
||||
if (!richTexts) {
|
||||
throw new Error('Cannot find test-rich-text');
|
||||
}
|
||||
|
||||
const editor = (richTexts[index] as any).inlineEditor as InlineEditor;
|
||||
return editor.getInlineRange();
|
||||
}, index);
|
||||
}
|
||||
|
||||
export async function setInlineRichTextRange(
|
||||
page: Page,
|
||||
inlineRange: InlineRange,
|
||||
index = 0
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
([inlineRange, index]) => {
|
||||
const richTexts = document
|
||||
.querySelector('test-page')
|
||||
?.querySelectorAll('test-rich-text');
|
||||
|
||||
if (!richTexts) {
|
||||
throw new Error('Cannot find test-rich-text');
|
||||
}
|
||||
|
||||
const editor = (richTexts[index as number] as any)
|
||||
.inlineEditor as InlineEditor;
|
||||
editor.setInlineRange(inlineRange as InlineRange);
|
||||
},
|
||||
[inlineRange, index]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getInlineRichTextLine(
|
||||
page: Page,
|
||||
index: number,
|
||||
i = 0
|
||||
): Promise<readonly [string, number]> {
|
||||
return page.evaluate(
|
||||
([index, i]) => {
|
||||
const richTexts = document.querySelectorAll('test-rich-text');
|
||||
|
||||
if (!richTexts) {
|
||||
throw new Error('Cannot find test-rich-text');
|
||||
}
|
||||
|
||||
const editor = (richTexts[i] as any).inlineEditor as InlineEditor;
|
||||
const result = editor.getLine(index);
|
||||
if (!result) {
|
||||
throw new Error('Cannot find line');
|
||||
}
|
||||
const { line, rangeIndexRelatedToLine } = result;
|
||||
return [line.vTextContent, rangeIndexRelatedToLine] as const;
|
||||
},
|
||||
[index, i]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getInlineRangeIndexRect(
|
||||
page: Page,
|
||||
[richTextIndex, inlineIndex]: [number, number],
|
||||
coordOffSet: { x: number; y: number } = { x: 0, y: 0 }
|
||||
) {
|
||||
const rect = await page.evaluate(
|
||||
({ richTextIndex, inlineIndex: vIndex, coordOffSet }) => {
|
||||
const richText = document.querySelectorAll('test-rich-text')[
|
||||
richTextIndex
|
||||
] as any;
|
||||
const domRange = richText.inlineEditor.toDomRange({
|
||||
index: vIndex,
|
||||
length: 0,
|
||||
});
|
||||
const pointBound = domRange.getBoundingClientRect();
|
||||
return {
|
||||
x: pointBound.left + coordOffSet.x,
|
||||
y: pointBound.top + pointBound.height / 2 + coordOffSet.y,
|
||||
};
|
||||
},
|
||||
{
|
||||
richTextIndex,
|
||||
inlineIndex,
|
||||
coordOffSet,
|
||||
}
|
||||
);
|
||||
return rect;
|
||||
}
|
||||
|
||||
export async function assertSelection(
|
||||
page: Page,
|
||||
richTextIndex: number,
|
||||
rangeIndex: number,
|
||||
rangeLength = 0
|
||||
) {
|
||||
const actual = await page.evaluate(
|
||||
([richTextIndex]) => {
|
||||
const richText =
|
||||
document?.querySelectorAll('test-rich-text')[richTextIndex];
|
||||
// @ts-expect-error FIXME: ts error
|
||||
const inlineEditor = richText.inlineEditor;
|
||||
return inlineEditor?.getInlineRange();
|
||||
},
|
||||
[richTextIndex]
|
||||
);
|
||||
expect(actual).toEqual({ index: rangeIndex, length: rangeLength });
|
||||
}
|
||||
12
blocksuite/framework/inline/src/components/embed-gap.ts
Normal file
12
blocksuite/framework/inline/src/components/embed-gap.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
export const EmbedGap = html`<span
|
||||
data-v-embed-gap="true"
|
||||
style=${styleMap({
|
||||
userSelect: 'text',
|
||||
padding: '0 0.5px',
|
||||
outline: 'none',
|
||||
})}
|
||||
><v-text></v-text
|
||||
></span>`;
|
||||
3
blocksuite/framework/inline/src/components/index.ts
Normal file
3
blocksuite/framework/inline/src/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './v-element.js';
|
||||
export * from './v-line.js';
|
||||
export * from './v-text.js';
|
||||
113
blocksuite/framework/inline/src/components/v-element.ts
Normal file
113
blocksuite/framework/inline/src/components/v-element.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { DisposableGroup, SignalWatcher } from '@blocksuite/global/utils';
|
||||
import { effect, signal } from '@preact/signals-core';
|
||||
import { html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { ZERO_WIDTH_SPACE } from '../consts.js';
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
import type { DeltaInsert } from '../types.js';
|
||||
import type { BaseTextAttributes } from '../utils/base-attributes.js';
|
||||
import { isInlineRangeIntersect } from '../utils/inline-range.js';
|
||||
|
||||
export class VElement<
|
||||
T extends BaseTextAttributes = BaseTextAttributes,
|
||||
> extends SignalWatcher(LitElement) {
|
||||
readonly disposables = new DisposableGroup();
|
||||
|
||||
readonly selected = signal(false);
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const inlineRange = this.inlineEditor.inlineRange$.value;
|
||||
this.selected.value =
|
||||
!!inlineRange &&
|
||||
isInlineRangeIntersect(inlineRange, {
|
||||
index: this.startOffset,
|
||||
length: this.endOffset - this.startOffset,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
const result = await super.getUpdateComplete();
|
||||
const span = this.querySelector('[data-v-element="true"]') as HTMLElement;
|
||||
const el = span.firstElementChild as LitElement;
|
||||
await el.updateComplete;
|
||||
const vTexts = Array.from(this.querySelectorAll('v-text'));
|
||||
await Promise.all(vTexts.map(vText => vText.updateComplete));
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
const attributeRenderer = inlineEditor.attributeService.attributeRenderer;
|
||||
const renderProps: Parameters<typeof attributeRenderer>[0] = {
|
||||
delta: this.delta,
|
||||
selected: this.selected.value,
|
||||
startOffset: this.startOffset,
|
||||
endOffset: this.endOffset,
|
||||
lineIndex: this.lineIndex,
|
||||
editor: inlineEditor,
|
||||
};
|
||||
|
||||
const isEmbed = inlineEditor.isEmbed(this.delta);
|
||||
if (isEmbed) {
|
||||
if (this.delta.insert.length !== 1) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
`The length of embed node should only be 1.
|
||||
This seems to be an internal issue with inline editor.
|
||||
Please go to https://github.com/toeverything/blocksuite/issues
|
||||
to report it.`
|
||||
);
|
||||
}
|
||||
|
||||
return html`<span
|
||||
data-v-embed="true"
|
||||
data-v-element="true"
|
||||
contenteditable="false"
|
||||
style=${styleMap({ userSelect: 'none' })}
|
||||
>${attributeRenderer(renderProps)}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
// we need to avoid \n appearing before and after the span element, which will
|
||||
// cause the unexpected space
|
||||
return html`<span data-v-element="true"
|
||||
>${attributeRenderer(renderProps)}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
@property({ type: Object })
|
||||
accessor delta: DeltaInsert<T> = {
|
||||
insert: ZERO_WIDTH_SPACE,
|
||||
};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor endOffset!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor inlineEditor!: InlineEditor;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor lineIndex!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor startOffset!: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'v-element': VElement;
|
||||
}
|
||||
}
|
||||
148
blocksuite/framework/inline/src/components/v-line.ts
Normal file
148
blocksuite/framework/inline/src/components/v-line.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { html, LitElement, type TemplateResult } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { INLINE_ROOT_ATTR, ZERO_WIDTH_SPACE } from '../consts.js';
|
||||
import type { InlineRootElement } from '../inline-editor.js';
|
||||
import type { DeltaInsert } from '../types.js';
|
||||
import { EmbedGap } from './embed-gap.js';
|
||||
|
||||
export class VLine extends LitElement {
|
||||
get inlineEditor() {
|
||||
const rootElement = this.closest(
|
||||
`[${INLINE_ROOT_ATTR}]`
|
||||
) as InlineRootElement;
|
||||
assertExists(rootElement, 'v-line must be inside a v-root');
|
||||
const inlineEditor = rootElement.inlineEditor;
|
||||
assertExists(
|
||||
inlineEditor,
|
||||
'v-line must be inside a v-root with inline-editor'
|
||||
);
|
||||
|
||||
return inlineEditor;
|
||||
}
|
||||
|
||||
get vElements() {
|
||||
return Array.from(this.querySelectorAll('v-element'));
|
||||
}
|
||||
|
||||
get vTextContent() {
|
||||
return this.vElements.reduce((acc, el) => acc + el.delta.insert, '');
|
||||
}
|
||||
|
||||
get vTextLength() {
|
||||
return this.vElements.reduce((acc, el) => acc + el.delta.insert.length, 0);
|
||||
}
|
||||
|
||||
// you should use vElements.length or vTextLength because v-element corresponds to the actual delta
|
||||
get vTexts() {
|
||||
return Array.from(this.querySelectorAll('v-text'));
|
||||
}
|
||||
|
||||
override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override firstUpdated(): void {
|
||||
this.style.display = 'block';
|
||||
|
||||
this.addEventListener('mousedown', e => {
|
||||
if (e.detail >= 2 && this.startOffset === this.endOffset) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.detail >= 3) {
|
||||
e.preventDefault();
|
||||
this.inlineEditor.setInlineRange({
|
||||
index: this.startOffset,
|
||||
length: this.endOffset - this.startOffset,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// vTexts.length > 0 does not mean the line is not empty,
|
||||
override async getUpdateComplete() {
|
||||
const result = await super.getUpdateComplete();
|
||||
await Promise.all(this.vElements.map(el => el.updateComplete));
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.isConnected) return;
|
||||
|
||||
if (this.inlineEditor.vLineRenderer) {
|
||||
return this.inlineEditor.vLineRenderer(this);
|
||||
}
|
||||
return this.renderVElements();
|
||||
}
|
||||
|
||||
renderVElements() {
|
||||
if (this.elements.length === 0) {
|
||||
// don't use v-element because it not correspond to the actual delta
|
||||
return html`<div><v-text .str=${ZERO_WIDTH_SPACE}></v-text></div>`;
|
||||
}
|
||||
|
||||
const inlineEditor = this.inlineEditor;
|
||||
const renderElements = this.elements.flatMap(([template, delta], index) => {
|
||||
if (inlineEditor.isEmbed(delta)) {
|
||||
if (delta.insert.length !== 1) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
`The length of embed node should only be 1.
|
||||
This seems to be an internal issue with inline editor.
|
||||
Please go to https://github.com/toeverything/blocksuite/issues
|
||||
to report it.`
|
||||
);
|
||||
}
|
||||
// we add `EmbedGap` to make cursor can be placed between embed elements
|
||||
if (index === 0) {
|
||||
const nextDelta = this.elements[index + 1]?.[1];
|
||||
if (!nextDelta || inlineEditor.isEmbed(nextDelta)) {
|
||||
return [EmbedGap, template, EmbedGap];
|
||||
} else {
|
||||
return [EmbedGap, template];
|
||||
}
|
||||
} else {
|
||||
const nextDelta = this.elements[index + 1]?.[1];
|
||||
if (!nextDelta || inlineEditor.isEmbed(nextDelta)) {
|
||||
return [template, EmbedGap];
|
||||
} else {
|
||||
return [template];
|
||||
}
|
||||
}
|
||||
}
|
||||
return template;
|
||||
});
|
||||
|
||||
// prettier will generate \n and cause unexpected space and line break
|
||||
// prettier-ignore
|
||||
return html`<div style=${styleMap({
|
||||
// this padding is used to make cursor can be placed at the
|
||||
// start and end of the line when the first and last element is embed element
|
||||
padding: '0 0.5px',
|
||||
display: 'inline-block',
|
||||
})}>${renderElements}</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor elements: [TemplateResult<1>, DeltaInsert][] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor endOffset!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor index!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor startOffset!: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'v-line': VLine;
|
||||
}
|
||||
}
|
||||
34
blocksuite/framework/inline/src/components/v-text.ts
Normal file
34
blocksuite/framework/inline/src/components/v-text.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { ZERO_WIDTH_SPACE } from '../consts.js';
|
||||
|
||||
export class VText extends LitElement {
|
||||
override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
override render() {
|
||||
// we need to avoid \n appearing before and after the span element, which will
|
||||
// cause the sync problem about the cursor position
|
||||
return html`<span
|
||||
style=${styleMap({
|
||||
'word-break': 'break-word',
|
||||
'text-wrap': 'wrap',
|
||||
'white-space-collapse': 'break-spaces',
|
||||
})}
|
||||
data-v-text="true"
|
||||
>${this.str}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor str: string = ZERO_WIDTH_SPACE;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'v-text': VText;
|
||||
}
|
||||
}
|
||||
7
blocksuite/framework/inline/src/consts.ts
Normal file
7
blocksuite/framework/inline/src/consts.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IS_SAFARI } from '@blocksuite/global/env';
|
||||
|
||||
export const ZERO_WIDTH_SPACE = IS_SAFARI ? '\u200C' : '\u200B';
|
||||
// see https://en.wikipedia.org/wiki/Zero-width_non-joiner
|
||||
export const ZERO_WIDTH_NON_JOINER = '\u200C';
|
||||
|
||||
export const INLINE_ROOT_ATTR = 'data-v-root';
|
||||
7
blocksuite/framework/inline/src/effects.ts
Normal file
7
blocksuite/framework/inline/src/effects.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { VElement, VLine, VText } from './components/index.js';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('v-element', VElement);
|
||||
customElements.define('v-line', VLine);
|
||||
customElements.define('v-text', VText);
|
||||
}
|
||||
6
blocksuite/framework/inline/src/index.ts
Normal file
6
blocksuite/framework/inline/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './components/index.js';
|
||||
export * from './consts.js';
|
||||
export * from './inline-editor.js';
|
||||
export * from './services/index.js';
|
||||
export * from './types.js';
|
||||
export * from './utils/index.js';
|
||||
296
blocksuite/framework/inline/src/inline-editor.ts
Normal file
296
blocksuite/framework/inline/src/inline-editor.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { assertExists, DisposableGroup, Slot } from '@blocksuite/global/utils';
|
||||
import { type Signal, signal } from '@preact/signals-core';
|
||||
import { nothing, render, type TemplateResult } from 'lit';
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
import type { VLine } from './components/v-line.js';
|
||||
import { INLINE_ROOT_ATTR } from './consts.js';
|
||||
import { InlineHookService } from './services/hook.js';
|
||||
import {
|
||||
AttributeService,
|
||||
DeltaService,
|
||||
EventService,
|
||||
RangeService,
|
||||
} from './services/index.js';
|
||||
import { RenderService } from './services/render.js';
|
||||
import { InlineTextService } from './services/text.js';
|
||||
import type { DeltaInsert, InlineRange } from './types.js';
|
||||
import {
|
||||
type BaseTextAttributes,
|
||||
nativePointToTextPoint,
|
||||
textPointToDomPoint,
|
||||
} from './utils/index.js';
|
||||
import { getTextNodesFromElement } from './utils/text.js';
|
||||
|
||||
export type InlineRootElement<
|
||||
T extends BaseTextAttributes = BaseTextAttributes,
|
||||
> = HTMLElement & {
|
||||
inlineEditor: InlineEditor<T>;
|
||||
};
|
||||
|
||||
export interface InlineRangeProvider {
|
||||
inlineRange$: Signal<InlineRange | null>;
|
||||
setInlineRange(inlineRange: InlineRange | null): void;
|
||||
}
|
||||
|
||||
export class InlineEditor<
|
||||
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
|
||||
> {
|
||||
static getTextNodesFromElement = getTextNodesFromElement;
|
||||
|
||||
static nativePointToTextPoint = nativePointToTextPoint;
|
||||
|
||||
static textPointToDomPoint = textPointToDomPoint;
|
||||
|
||||
readonly disposables = new DisposableGroup();
|
||||
|
||||
readonly attributeService: AttributeService<TextAttributes> =
|
||||
new AttributeService<TextAttributes>(this);
|
||||
getFormat = this.attributeService.getFormat;
|
||||
normalizeAttributes = this.attributeService.normalizeAttributes;
|
||||
resetMarks = this.attributeService.resetMarks;
|
||||
setAttributeRenderer = this.attributeService.setAttributeRenderer;
|
||||
setAttributeSchema = this.attributeService.setAttributeSchema;
|
||||
setMarks = this.attributeService.setMarks;
|
||||
get marks() {
|
||||
return this.attributeService.marks;
|
||||
}
|
||||
|
||||
readonly textService: InlineTextService<TextAttributes> =
|
||||
new InlineTextService<TextAttributes>(this);
|
||||
deleteText = this.textService.deleteText;
|
||||
formatText = this.textService.formatText;
|
||||
insertLineBreak = this.textService.insertLineBreak;
|
||||
insertText = this.textService.insertText;
|
||||
resetText = this.textService.resetText;
|
||||
setText = this.textService.setText;
|
||||
|
||||
readonly deltaService: DeltaService<TextAttributes> =
|
||||
new DeltaService<TextAttributes>(this);
|
||||
getDeltaByRangeIndex = this.deltaService.getDeltaByRangeIndex;
|
||||
getDeltasByInlineRange = this.deltaService.getDeltasByInlineRange;
|
||||
mapDeltasInInlineRange = this.deltaService.mapDeltasInInlineRange;
|
||||
get embedDeltas() {
|
||||
return this.deltaService.embedDeltas;
|
||||
}
|
||||
|
||||
readonly rangeService: RangeService<TextAttributes> =
|
||||
new RangeService<TextAttributes>(this);
|
||||
focusEnd = this.rangeService.focusEnd;
|
||||
focusIndex = this.rangeService.focusIndex;
|
||||
focusStart = this.rangeService.focusStart;
|
||||
getInlineRangeFromElement = this.rangeService.getInlineRangeFromElement;
|
||||
isFirstLine = this.rangeService.isFirstLine;
|
||||
isLastLine = this.rangeService.isLastLine;
|
||||
isValidInlineRange = this.rangeService.isValidInlineRange;
|
||||
selectAll = this.rangeService.selectAll;
|
||||
syncInlineRange = this.rangeService.syncInlineRange;
|
||||
toDomRange = this.rangeService.toDomRange;
|
||||
toInlineRange = this.rangeService.toInlineRange;
|
||||
getLine = this.rangeService.getLine;
|
||||
getNativeRange = this.rangeService.getNativeRange;
|
||||
getNativeSelection = this.rangeService.getNativeSelection;
|
||||
getTextPoint = this.rangeService.getTextPoint;
|
||||
get lastStartRelativePosition() {
|
||||
return this.rangeService.lastStartRelativePosition;
|
||||
}
|
||||
get lastEndRelativePosition() {
|
||||
return this.rangeService.lastEndRelativePosition;
|
||||
}
|
||||
|
||||
readonly eventService: EventService<TextAttributes> =
|
||||
new EventService<TextAttributes>(this);
|
||||
get isComposing() {
|
||||
return this.eventService.isComposing;
|
||||
}
|
||||
|
||||
readonly renderService: RenderService<TextAttributes> =
|
||||
new RenderService<TextAttributes>(this);
|
||||
waitForUpdate = this.renderService.waitForUpdate;
|
||||
rerenderWholeEditor = this.renderService.rerenderWholeEditor;
|
||||
render = this.renderService.render;
|
||||
get rendering() {
|
||||
return this.renderService.rendering;
|
||||
}
|
||||
|
||||
readonly hooksService: InlineHookService<TextAttributes>;
|
||||
get hooks() {
|
||||
return this.hooksService.hooks;
|
||||
}
|
||||
|
||||
private _eventSource: HTMLElement | null = null;
|
||||
get eventSource() {
|
||||
return this._eventSource;
|
||||
}
|
||||
|
||||
private _isReadonly = false;
|
||||
get isReadonly() {
|
||||
return this._isReadonly;
|
||||
}
|
||||
|
||||
private _mounted = false;
|
||||
get mounted() {
|
||||
return this._mounted;
|
||||
}
|
||||
|
||||
private _rootElement: InlineRootElement<TextAttributes> | null = null;
|
||||
get rootElement() {
|
||||
assertExists(this._rootElement);
|
||||
return this._rootElement;
|
||||
}
|
||||
|
||||
private _inlineRangeProviderOverride = false;
|
||||
get inlineRangeProviderOverride() {
|
||||
return this._inlineRangeProviderOverride;
|
||||
}
|
||||
readonly inlineRangeProvider: InlineRangeProvider = {
|
||||
inlineRange$: signal(null),
|
||||
setInlineRange: inlineRange => {
|
||||
this.inlineRange$.value = inlineRange;
|
||||
},
|
||||
};
|
||||
get inlineRange$() {
|
||||
return this.inlineRangeProvider.inlineRange$;
|
||||
}
|
||||
setInlineRange = (inlineRange: InlineRange | null) => {
|
||||
this.inlineRangeProvider.setInlineRange(inlineRange);
|
||||
};
|
||||
getInlineRange = () => {
|
||||
return this.inlineRange$.peek();
|
||||
};
|
||||
|
||||
readonly slots = {
|
||||
mounted: new Slot(),
|
||||
unmounted: new Slot(),
|
||||
renderComplete: new Slot(),
|
||||
textChange: new Slot(),
|
||||
inlineRangeSync: new Slot<Range | null>(),
|
||||
/**
|
||||
* Corresponding to the `compositionUpdate` and `beforeInput` events, and triggered only when the `inlineRange` is not null.
|
||||
*/
|
||||
inputting: new Slot(),
|
||||
/**
|
||||
* Triggered only when the `inlineRange` is not null.
|
||||
*/
|
||||
keydown: new Slot<KeyboardEvent>(),
|
||||
};
|
||||
|
||||
readonly vLineRenderer: ((vLine: VLine) => TemplateResult) | null;
|
||||
|
||||
readonly yText: Y.Text;
|
||||
get yTextDeltas() {
|
||||
return this.yText.toDelta();
|
||||
}
|
||||
get yTextLength() {
|
||||
return this.yText.length;
|
||||
}
|
||||
get yTextString() {
|
||||
return this.yText.toString();
|
||||
}
|
||||
|
||||
readonly isEmbed: (delta: DeltaInsert<TextAttributes>) => boolean;
|
||||
|
||||
constructor(
|
||||
yText: InlineEditor['yText'],
|
||||
ops: {
|
||||
isEmbed?: (delta: DeltaInsert<TextAttributes>) => boolean;
|
||||
hooks?: InlineHookService<TextAttributes>['hooks'];
|
||||
inlineRangeProvider?: InlineRangeProvider;
|
||||
vLineRenderer?: (vLine: VLine) => TemplateResult;
|
||||
} = {}
|
||||
) {
|
||||
if (!yText.doc) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'yText must be attached to a Y.Doc'
|
||||
);
|
||||
}
|
||||
|
||||
if (yText.toString().includes('\r')) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'yText must not contain "\\r" because it will break the range synchronization'
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
isEmbed = () => false,
|
||||
hooks = {},
|
||||
inlineRangeProvider,
|
||||
vLineRenderer = null,
|
||||
} = ops;
|
||||
this.yText = yText;
|
||||
this.isEmbed = isEmbed;
|
||||
this.vLineRenderer = vLineRenderer;
|
||||
this.hooksService = new InlineHookService(this, hooks);
|
||||
if (inlineRangeProvider) {
|
||||
this.inlineRangeProvider = inlineRangeProvider;
|
||||
this._inlineRangeProviderOverride = true;
|
||||
}
|
||||
}
|
||||
|
||||
mount(
|
||||
rootElement: HTMLElement,
|
||||
eventSource: HTMLElement = rootElement,
|
||||
isReadonly = false
|
||||
) {
|
||||
const inlineRoot = rootElement as InlineRootElement<TextAttributes>;
|
||||
inlineRoot.inlineEditor = this;
|
||||
this._rootElement = inlineRoot;
|
||||
this._eventSource = eventSource;
|
||||
this._eventSource.style.outline = 'none';
|
||||
this._rootElement.dataset.vRoot = 'true';
|
||||
this.setReadonly(isReadonly);
|
||||
|
||||
this.rootElement.replaceChildren();
|
||||
|
||||
delete (this.rootElement as any)['_$litPart$'];
|
||||
|
||||
this.eventService.mount();
|
||||
this.rangeService.mount();
|
||||
this.renderService.mount();
|
||||
|
||||
this._mounted = true;
|
||||
this.slots.mounted.emit();
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
unmount() {
|
||||
if (this.rootElement.isConnected) {
|
||||
render(nothing, this.rootElement);
|
||||
}
|
||||
this.rootElement.removeAttribute(INLINE_ROOT_ATTR);
|
||||
this._rootElement = null;
|
||||
this._mounted = false;
|
||||
this.disposables.dispose();
|
||||
this.slots.unmounted.emit();
|
||||
}
|
||||
|
||||
setReadonly(isReadonly: boolean): void {
|
||||
const value = isReadonly ? 'false' : 'true';
|
||||
|
||||
if (this.rootElement.contentEditable !== value) {
|
||||
this.rootElement.contentEditable = value;
|
||||
}
|
||||
|
||||
if (this.eventSource && this.eventSource.contentEditable !== value) {
|
||||
this.eventSource.contentEditable = value;
|
||||
}
|
||||
|
||||
this._isReadonly = isReadonly;
|
||||
}
|
||||
|
||||
transact(fn: () => void): void {
|
||||
const doc = this.yText.doc;
|
||||
if (!doc) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'yText is not attached to a doc'
|
||||
);
|
||||
}
|
||||
|
||||
doc.transact(fn, doc.clientID);
|
||||
}
|
||||
}
|
||||
109
blocksuite/framework/inline/src/services/attribute.ts
Normal file
109
blocksuite/framework/inline/src/services/attribute.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { z, ZodTypeDef } from 'zod';
|
||||
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
import type { AttributeRenderer, InlineRange } from '../types.js';
|
||||
import type { BaseTextAttributes } from '../utils/index.js';
|
||||
import {
|
||||
baseTextAttributes,
|
||||
getDefaultAttributeRenderer,
|
||||
} from '../utils/index.js';
|
||||
|
||||
export class AttributeService<TextAttributes extends BaseTextAttributes> {
|
||||
private _attributeRenderer: AttributeRenderer<TextAttributes> =
|
||||
getDefaultAttributeRenderer<TextAttributes>();
|
||||
|
||||
private _attributeSchema: z.ZodSchema<TextAttributes, ZodTypeDef, unknown> =
|
||||
baseTextAttributes as z.ZodSchema<TextAttributes, ZodTypeDef, unknown>;
|
||||
|
||||
private _marks: TextAttributes | null = null;
|
||||
|
||||
getFormat = (inlineRange: InlineRange, loose = false): TextAttributes => {
|
||||
const deltas = this.editor.deltaService
|
||||
.getDeltasByInlineRange(inlineRange)
|
||||
.filter(([_, position]) => {
|
||||
const deltaStart = position.index;
|
||||
const deltaEnd = position.index + position.length;
|
||||
const inlineStart = inlineRange.index;
|
||||
const inlineEnd = inlineRange.index + inlineRange.length;
|
||||
|
||||
if (inlineStart === inlineEnd) {
|
||||
return deltaStart < inlineStart && inlineStart <= deltaEnd;
|
||||
} else {
|
||||
return deltaEnd > inlineStart && deltaStart <= inlineEnd;
|
||||
}
|
||||
});
|
||||
const maybeAttributesList = deltas.map(([delta]) => delta.attributes);
|
||||
if (loose) {
|
||||
return maybeAttributesList.reduce(
|
||||
(acc, cur) => ({ ...acc, ...cur }),
|
||||
{}
|
||||
) as TextAttributes;
|
||||
}
|
||||
if (
|
||||
!maybeAttributesList.length ||
|
||||
// some text does not have any attribute
|
||||
maybeAttributesList.some(attributes => !attributes)
|
||||
) {
|
||||
return {} as TextAttributes;
|
||||
}
|
||||
const attributesList = maybeAttributesList as TextAttributes[];
|
||||
return attributesList.reduce((acc, cur) => {
|
||||
const newFormat = {} as TextAttributes;
|
||||
for (const key in acc) {
|
||||
const typedKey = key as keyof TextAttributes;
|
||||
// If the given range contains multiple different formats
|
||||
// such as links with different values,
|
||||
// we will treat it as having no format
|
||||
if (acc[typedKey] === cur[typedKey]) {
|
||||
// This cast is secure because we have checked that the value of the key is the same.
|
||||
|
||||
newFormat[typedKey] = acc[typedKey] as any;
|
||||
}
|
||||
}
|
||||
return newFormat;
|
||||
});
|
||||
};
|
||||
|
||||
normalizeAttributes = (textAttributes?: TextAttributes) => {
|
||||
if (!textAttributes) {
|
||||
return undefined;
|
||||
}
|
||||
const attributeResult = this._attributeSchema.safeParse(textAttributes);
|
||||
if (!attributeResult.success) {
|
||||
console.error(attributeResult.error);
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
// filter out undefined values
|
||||
Object.entries(attributeResult.data).filter(([_, v]) => v !== undefined)
|
||||
) as TextAttributes;
|
||||
};
|
||||
|
||||
resetMarks = (): void => {
|
||||
this._marks = null;
|
||||
};
|
||||
|
||||
setAttributeRenderer = (renderer: AttributeRenderer<TextAttributes>) => {
|
||||
this._attributeRenderer = renderer;
|
||||
};
|
||||
|
||||
setAttributeSchema = (
|
||||
schema: z.ZodSchema<TextAttributes, ZodTypeDef, unknown>
|
||||
) => {
|
||||
this._attributeSchema = schema;
|
||||
};
|
||||
|
||||
setMarks = (marks: TextAttributes): void => {
|
||||
this._marks = marks;
|
||||
};
|
||||
|
||||
get attributeRenderer() {
|
||||
return this._attributeRenderer;
|
||||
}
|
||||
|
||||
get marks() {
|
||||
return this._marks;
|
||||
}
|
||||
|
||||
constructor(readonly editor: InlineEditor<TextAttributes>) {}
|
||||
}
|
||||
152
blocksuite/framework/inline/src/services/delta.ts
Normal file
152
blocksuite/framework/inline/src/services/delta.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
import type { DeltaEntry, DeltaInsert, InlineRange } from '../types.js';
|
||||
import type { BaseTextAttributes } from '../utils/index.js';
|
||||
import { transformDeltasToEmbedDeltas } from '../utils/index.js';
|
||||
|
||||
export class DeltaService<TextAttributes extends BaseTextAttributes> {
|
||||
/**
|
||||
* Here are examples of how this function computes and gets the delta.
|
||||
*
|
||||
* We have such a text:
|
||||
* ```
|
||||
* [
|
||||
* {
|
||||
* insert: 'aaa',
|
||||
* attributes: { bold: true },
|
||||
* },
|
||||
* {
|
||||
* insert: 'bbb',
|
||||
* attributes: { italic: true },
|
||||
* },
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* `getDeltaByRangeIndex(0)` returns `{ insert: 'aaa', attributes: { bold: true } }`.
|
||||
*
|
||||
* `getDeltaByRangeIndex(1)` returns `{ insert: 'aaa', attributes: { bold: true } }`.
|
||||
*
|
||||
* `getDeltaByRangeIndex(3)` returns `{ insert: 'aaa', attributes: { bold: true } }`.
|
||||
*
|
||||
* `getDeltaByRangeIndex(4)` returns `{ insert: 'bbb', attributes: { italic: true } }`.
|
||||
*/
|
||||
getDeltaByRangeIndex = (rangeIndex: number) => {
|
||||
const deltas = this.editor.embedDeltas;
|
||||
|
||||
let index = 0;
|
||||
for (const delta of deltas) {
|
||||
if (index + delta.insert.length >= rangeIndex) {
|
||||
return delta;
|
||||
}
|
||||
index += delta.insert.length;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Here are examples of how this function computes and gets the deltas.
|
||||
*
|
||||
* We have such a text:
|
||||
* ```
|
||||
* [
|
||||
* {
|
||||
* insert: 'aaa',
|
||||
* attributes: { bold: true },
|
||||
* },
|
||||
* {
|
||||
* insert: 'bbb',
|
||||
* attributes: { italic: true },
|
||||
* },
|
||||
* {
|
||||
* insert: 'ccc',
|
||||
* attributes: { underline: true },
|
||||
* },
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* `getDeltasByInlineRange({ index: 0, length: 0 })` returns
|
||||
* ```
|
||||
* [{ insert: 'aaa', attributes: { bold: true }, }, { index: 0, length: 3, }]]
|
||||
* ```
|
||||
*
|
||||
* `getDeltasByInlineRange({ index: 0, length: 1 })` returns
|
||||
* ```
|
||||
* [{ insert: 'aaa', attributes: { bold: true }, }, { index: 0, length: 3, }]]
|
||||
* ```
|
||||
*
|
||||
* `getDeltasByInlineRange({ index: 0, length: 4 })` returns
|
||||
* ```
|
||||
* [{ insert: 'aaa', attributes: { bold: true }, }, { index: 0, length: 3, }],
|
||||
* [{ insert: 'bbb', attributes: { italic: true }, }, { index: 3, length: 3, }]]
|
||||
* ```
|
||||
*
|
||||
* `getDeltasByInlineRange({ index: 3, length: 1 })` returns
|
||||
* ```
|
||||
* [{ insert: 'aaa', attributes: { bold: true }, }, { index: 0, length: 3, }],
|
||||
* [{ insert: 'bbb', attributes: { italic: true }, }, { index: 3, length: 3, }]]
|
||||
* ```
|
||||
*
|
||||
* `getDeltasByInlineRange({ index: 3, length: 3 })` returns
|
||||
* ```
|
||||
* [{ insert: 'aaa', attributes: { bold: true }, }, { index: 0, length: 3, }],
|
||||
* [{ insert: 'bbb', attributes: { italic: true }, }, { index: 3, length: 3, }]]
|
||||
* ```
|
||||
*
|
||||
* `getDeltasByInlineRange({ index: 3, length: 4 })` returns
|
||||
* ```
|
||||
* [{ insert: 'aaa', attributes: { bold: true }, }, { index: 0, length: 3, }],
|
||||
* [{ insert: 'bbb', attributes: { italic: true }, }, { index: 3, length: 3, }],
|
||||
* [{ insert: 'ccc', attributes: { underline: true }, }, { index: 6, length: 3, }]]
|
||||
* ```
|
||||
*/
|
||||
getDeltasByInlineRange = (
|
||||
inlineRange: InlineRange
|
||||
): DeltaEntry<TextAttributes>[] => {
|
||||
return this.mapDeltasInInlineRange(
|
||||
inlineRange,
|
||||
(delta, index): DeltaEntry<TextAttributes> => [
|
||||
delta,
|
||||
{ index, length: delta.insert.length },
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
mapDeltasInInlineRange = <Result>(
|
||||
inlineRange: InlineRange,
|
||||
callback: (
|
||||
delta: DeltaInsert<TextAttributes>,
|
||||
rangeIndex: number,
|
||||
deltaIndex: number
|
||||
) => Result
|
||||
) => {
|
||||
const deltas = this.editor.embedDeltas;
|
||||
const result: Result[] = [];
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-ignored-return
|
||||
deltas.reduce((rangeIndex, delta, deltaIndex) => {
|
||||
const length = delta.insert.length;
|
||||
const from = inlineRange.index - length;
|
||||
const to = inlineRange.index + inlineRange.length;
|
||||
|
||||
const deltaInRange =
|
||||
rangeIndex >= from &&
|
||||
(rangeIndex < to ||
|
||||
(inlineRange.length === 0 && rangeIndex === inlineRange.index));
|
||||
|
||||
if (deltaInRange) {
|
||||
const value = callback(delta, rangeIndex, deltaIndex);
|
||||
result.push(value);
|
||||
}
|
||||
|
||||
return rangeIndex + length;
|
||||
}, 0);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
get embedDeltas() {
|
||||
return transformDeltasToEmbedDeltas(this.editor, this.editor.yTextDeltas);
|
||||
}
|
||||
|
||||
constructor(readonly editor: InlineEditor<TextAttributes>) {}
|
||||
}
|
||||
372
blocksuite/framework/inline/src/services/event.ts
Normal file
372
blocksuite/framework/inline/src/services/event.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
import type { InlineRange } from '../types.js';
|
||||
import {
|
||||
type BaseTextAttributes,
|
||||
isInEmbedElement,
|
||||
isInEmbedGap,
|
||||
isInEmptyLine,
|
||||
} from '../utils/index.js';
|
||||
import { isMaybeInlineRangeEqual } from '../utils/inline-range.js';
|
||||
import { transformInput } from '../utils/transform-input.js';
|
||||
import type { BeforeinputHookCtx, CompositionEndHookCtx } from './hook.js';
|
||||
|
||||
export class EventService<TextAttributes extends BaseTextAttributes> {
|
||||
private _compositionInlineRange: InlineRange | null = null;
|
||||
|
||||
private _isComposing = false;
|
||||
|
||||
private _isRangeCompletelyInRoot = (range: Range) => {
|
||||
if (range.commonAncestorContainer.ownerDocument !== document) return false;
|
||||
|
||||
const rootElement = this.editor.rootElement;
|
||||
const rootRange = document.createRange();
|
||||
rootRange.selectNode(rootElement);
|
||||
|
||||
if (
|
||||
range.startContainer.compareDocumentPosition(range.endContainer) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
) {
|
||||
return (
|
||||
rootRange.comparePoint(range.startContainer, range.startOffset) >= 0 &&
|
||||
rootRange.comparePoint(range.endContainer, range.endOffset) <= 0
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
rootRange.comparePoint(range.endContainer, range.startOffset) >= 0 &&
|
||||
rootRange.comparePoint(range.startContainer, range.endOffset) <= 0
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private _onBeforeInput = (event: InputEvent) => {
|
||||
const range = this.editor.rangeService.getNativeRange();
|
||||
if (
|
||||
this.editor.isReadonly ||
|
||||
this._isComposing ||
|
||||
!range ||
|
||||
!this._isRangeCompletelyInRoot(range)
|
||||
)
|
||||
return;
|
||||
|
||||
let inlineRange = this.editor.toInlineRange(range);
|
||||
if (!inlineRange) return;
|
||||
|
||||
let ifHandleTargetRange = true;
|
||||
|
||||
if (event.inputType.startsWith('delete')) {
|
||||
if (
|
||||
isInEmbedGap(range.commonAncestorContainer) &&
|
||||
inlineRange.length === 0 &&
|
||||
inlineRange.index > 0
|
||||
) {
|
||||
inlineRange = {
|
||||
index: inlineRange.index - 1,
|
||||
length: 1,
|
||||
};
|
||||
ifHandleTargetRange = false;
|
||||
} else if (
|
||||
isInEmptyLine(range.commonAncestorContainer) &&
|
||||
inlineRange.length === 0 &&
|
||||
inlineRange.index > 0
|
||||
// eslint-disable-next-line sonarjs/no-duplicated-branches
|
||||
) {
|
||||
// do not use target range when deleting across lines
|
||||
// https://github.com/toeverything/blocksuite/issues/5381
|
||||
inlineRange = {
|
||||
index: inlineRange.index - 1,
|
||||
length: 1,
|
||||
};
|
||||
ifHandleTargetRange = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (ifHandleTargetRange) {
|
||||
const targetRanges = event.getTargetRanges();
|
||||
if (targetRanges.length > 0) {
|
||||
const staticRange = targetRanges[0];
|
||||
const range = document.createRange();
|
||||
range.setStart(staticRange.startContainer, staticRange.startOffset);
|
||||
range.setEnd(staticRange.endContainer, staticRange.endOffset);
|
||||
const targetInlineRange = this.editor.toInlineRange(range);
|
||||
|
||||
if (!isMaybeInlineRangeEqual(inlineRange, targetInlineRange)) {
|
||||
inlineRange = targetInlineRange;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!inlineRange) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const ctx: BeforeinputHookCtx<TextAttributes> = {
|
||||
inlineEditor: this.editor,
|
||||
raw: event,
|
||||
inlineRange,
|
||||
data: event.data ?? event.dataTransfer?.getData('text/plain') ?? null,
|
||||
attributes: {} as TextAttributes,
|
||||
};
|
||||
this.editor.hooks.beforeinput?.(ctx);
|
||||
|
||||
transformInput<TextAttributes>(
|
||||
ctx.raw.inputType,
|
||||
ctx.data,
|
||||
ctx.attributes,
|
||||
ctx.inlineRange,
|
||||
this.editor as InlineEditor
|
||||
);
|
||||
|
||||
this.editor.slots.inputting.emit();
|
||||
};
|
||||
|
||||
private _onClick = (event: MouseEvent) => {
|
||||
// select embed element when click on it
|
||||
if (event.target instanceof Node && isInEmbedElement(event.target)) {
|
||||
const selection = document.getSelection();
|
||||
if (!selection) return;
|
||||
if (event.target instanceof HTMLElement) {
|
||||
const vElement = event.target.closest('v-element');
|
||||
if (vElement) {
|
||||
selection.selectAllChildren(vElement);
|
||||
}
|
||||
} else {
|
||||
const vElement = event.target.parentElement?.closest('v-element');
|
||||
if (vElement) {
|
||||
selection.selectAllChildren(vElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private _onCompositionEnd = async (event: CompositionEvent) => {
|
||||
this._isComposing = false;
|
||||
if (!this.editor.rootElement.isConnected) return;
|
||||
|
||||
const range = this.editor.rangeService.getNativeRange();
|
||||
if (
|
||||
this.editor.isReadonly ||
|
||||
!range ||
|
||||
!this._isRangeCompletelyInRoot(range)
|
||||
)
|
||||
return;
|
||||
|
||||
this.editor.rerenderWholeEditor();
|
||||
await this.editor.waitForUpdate();
|
||||
|
||||
const inlineRange = this._compositionInlineRange;
|
||||
if (!inlineRange) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const ctx: CompositionEndHookCtx<TextAttributes> = {
|
||||
inlineEditor: this.editor,
|
||||
raw: event,
|
||||
inlineRange,
|
||||
data: event.data,
|
||||
attributes: {} as TextAttributes,
|
||||
};
|
||||
this.editor.hooks.compositionEnd?.(ctx);
|
||||
|
||||
const { inlineRange: newInlineRange, data: newData } = ctx;
|
||||
if (newData && newData.length > 0) {
|
||||
this.editor.insertText(newInlineRange, newData, ctx.attributes);
|
||||
this.editor.setInlineRange({
|
||||
index: newInlineRange.index + newData.length,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
|
||||
this.editor.slots.inputting.emit();
|
||||
};
|
||||
|
||||
private _onCompositionStart = () => {
|
||||
this._isComposing = true;
|
||||
// embeds is not editable and it will break IME
|
||||
const embeds = this.editor.rootElement.querySelectorAll(
|
||||
'[data-v-embed="true"]'
|
||||
);
|
||||
embeds.forEach(embed => {
|
||||
embed.removeAttribute('contenteditable');
|
||||
});
|
||||
|
||||
const range = this.editor.rangeService.getNativeRange();
|
||||
if (range) {
|
||||
this._compositionInlineRange = this.editor.toInlineRange(range);
|
||||
} else {
|
||||
this._compositionInlineRange = null;
|
||||
}
|
||||
};
|
||||
|
||||
private _onCompositionUpdate = () => {
|
||||
if (!this.editor.rootElement.isConnected) return;
|
||||
|
||||
const range = this.editor.rangeService.getNativeRange();
|
||||
if (
|
||||
this.editor.isReadonly ||
|
||||
!range ||
|
||||
!this._isRangeCompletelyInRoot(range)
|
||||
)
|
||||
return;
|
||||
|
||||
this.editor.slots.inputting.emit();
|
||||
};
|
||||
|
||||
private _onKeyDown = (event: KeyboardEvent) => {
|
||||
const inlineRange = this.editor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
this.editor.slots.keydown.emit(event);
|
||||
|
||||
if (
|
||||
!event.shiftKey &&
|
||||
(event.key === 'ArrowLeft' || event.key === 'ArrowRight')
|
||||
) {
|
||||
if (inlineRange.length !== 0) return;
|
||||
|
||||
const prevent = () => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const deltas = this.editor.getDeltasByInlineRange(inlineRange);
|
||||
if (deltas.length === 2) {
|
||||
if (event.key === 'ArrowLeft' && this.editor.isEmbed(deltas[0][0])) {
|
||||
prevent();
|
||||
this.editor.setInlineRange({
|
||||
index: inlineRange.index - 1,
|
||||
length: 1,
|
||||
});
|
||||
} else if (
|
||||
event.key === 'ArrowRight' &&
|
||||
this.editor.isEmbed(deltas[1][0])
|
||||
) {
|
||||
prevent();
|
||||
this.editor.setInlineRange({
|
||||
index: inlineRange.index,
|
||||
length: 1,
|
||||
});
|
||||
}
|
||||
} else if (deltas.length === 1) {
|
||||
const delta = deltas[0][0];
|
||||
if (this.editor.isEmbed(delta)) {
|
||||
if (event.key === 'ArrowLeft' && inlineRange.index - 1 >= 0) {
|
||||
prevent();
|
||||
this.editor.setInlineRange({
|
||||
index: inlineRange.index - 1,
|
||||
length: 1,
|
||||
});
|
||||
} else if (
|
||||
event.key === 'ArrowRight' &&
|
||||
inlineRange.index + 1 <= this.editor.yTextLength
|
||||
) {
|
||||
prevent();
|
||||
this.editor.setInlineRange({
|
||||
index: inlineRange.index,
|
||||
length: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private _onSelectionChange = () => {
|
||||
const rootElement = this.editor.rootElement;
|
||||
const previousInlineRange = this.editor.getInlineRange();
|
||||
if (this._isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = document.getSelection();
|
||||
if (!selection) return;
|
||||
if (selection.rangeCount === 0) {
|
||||
if (previousInlineRange !== null) {
|
||||
this.editor.setInlineRange(null);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!range.intersectsNode(rootElement)) {
|
||||
const isContainerSelected =
|
||||
range.endContainer.contains(rootElement) &&
|
||||
Array.from(range.endContainer.childNodes).filter(
|
||||
node => node instanceof HTMLElement
|
||||
).length === 1 &&
|
||||
range.startContainer.contains(rootElement) &&
|
||||
Array.from(range.startContainer.childNodes).filter(
|
||||
node => node instanceof HTMLElement
|
||||
).length === 1;
|
||||
if (isContainerSelected) {
|
||||
this.editor.focusEnd();
|
||||
return;
|
||||
} else {
|
||||
if (previousInlineRange !== null) {
|
||||
this.editor.setInlineRange(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const inlineRange = this.editor.toInlineRange(selection.getRangeAt(0));
|
||||
if (!isMaybeInlineRangeEqual(previousInlineRange, inlineRange)) {
|
||||
this.editor.rangeService.lockSyncInlineRange();
|
||||
this.editor.setInlineRange(inlineRange);
|
||||
this.editor.rangeService.unlockSyncInlineRange();
|
||||
}
|
||||
};
|
||||
|
||||
mount = () => {
|
||||
const eventSource = this.editor.eventSource;
|
||||
const rootElement = this.editor.rootElement;
|
||||
|
||||
if (!this.editor.inlineRangeProviderOverride) {
|
||||
this.editor.disposables.addFromEvent(
|
||||
document,
|
||||
'selectionchange',
|
||||
this._onSelectionChange
|
||||
);
|
||||
}
|
||||
|
||||
if (!eventSource) {
|
||||
console.error('Mount inline editor without event source ready');
|
||||
return;
|
||||
}
|
||||
|
||||
this.editor.disposables.addFromEvent(
|
||||
eventSource,
|
||||
'beforeinput',
|
||||
this._onBeforeInput
|
||||
);
|
||||
this.editor.disposables.addFromEvent(
|
||||
eventSource,
|
||||
'compositionstart',
|
||||
this._onCompositionStart
|
||||
);
|
||||
this.editor.disposables.addFromEvent(
|
||||
eventSource,
|
||||
'compositionupdate',
|
||||
this._onCompositionUpdate
|
||||
);
|
||||
this.editor.disposables.addFromEvent(
|
||||
eventSource,
|
||||
'compositionend',
|
||||
(event: CompositionEvent) => {
|
||||
this._onCompositionEnd(event).catch(console.error);
|
||||
}
|
||||
);
|
||||
this.editor.disposables.addFromEvent(
|
||||
eventSource,
|
||||
'keydown',
|
||||
this._onKeyDown
|
||||
);
|
||||
this.editor.disposables.addFromEvent(rootElement, 'click', this._onClick);
|
||||
};
|
||||
|
||||
get isComposing() {
|
||||
return this._isComposing;
|
||||
}
|
||||
|
||||
constructor(readonly editor: InlineEditor<TextAttributes>) {}
|
||||
}
|
||||
34
blocksuite/framework/inline/src/services/hook.ts
Normal file
34
blocksuite/framework/inline/src/services/hook.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
import type { InlineRange } from '../types.js';
|
||||
import type { BaseTextAttributes } from '../utils/base-attributes.js';
|
||||
|
||||
export interface BeforeinputHookCtx<TextAttributes extends BaseTextAttributes> {
|
||||
inlineEditor: InlineEditor<TextAttributes>;
|
||||
raw: InputEvent;
|
||||
inlineRange: InlineRange;
|
||||
data: string | null;
|
||||
attributes: TextAttributes;
|
||||
}
|
||||
export interface CompositionEndHookCtx<
|
||||
TextAttributes extends BaseTextAttributes,
|
||||
> {
|
||||
inlineEditor: InlineEditor<TextAttributes>;
|
||||
raw: CompositionEvent;
|
||||
inlineRange: InlineRange;
|
||||
data: string | null;
|
||||
attributes: TextAttributes;
|
||||
}
|
||||
|
||||
export type HookContext<TextAttributes extends BaseTextAttributes> =
|
||||
| BeforeinputHookCtx<TextAttributes>
|
||||
| CompositionEndHookCtx<TextAttributes>;
|
||||
|
||||
export class InlineHookService<TextAttributes extends BaseTextAttributes> {
|
||||
constructor(
|
||||
readonly editor: InlineEditor<TextAttributes>,
|
||||
readonly hooks: {
|
||||
beforeinput?: (props: BeforeinputHookCtx<TextAttributes>) => void;
|
||||
compositionEnd?: (props: CompositionEndHookCtx<TextAttributes>) => void;
|
||||
} = {}
|
||||
) {}
|
||||
}
|
||||
6
blocksuite/framework/inline/src/services/index.ts
Normal file
6
blocksuite/framework/inline/src/services/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './attribute.js';
|
||||
export * from './delta.js';
|
||||
export * from './event.js';
|
||||
export * from './hook.js';
|
||||
export * from './range.js';
|
||||
export * from './render.js';
|
||||
374
blocksuite/framework/inline/src/services/range.ts
Normal file
374
blocksuite/framework/inline/src/services/range.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import type { VLine } from '../components/v-line.js';
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
import type { InlineRange, TextPoint } from '../types.js';
|
||||
import type { BaseTextAttributes } from '../utils/base-attributes.js';
|
||||
import { isInEmbedGap } from '../utils/embed.js';
|
||||
import { isMaybeInlineRangeEqual } from '../utils/inline-range.js';
|
||||
import {
|
||||
domRangeToInlineRange,
|
||||
inlineRangeToDomRange,
|
||||
} from '../utils/range-conversion.js';
|
||||
import { calculateTextLength, getTextNodesFromElement } from '../utils/text.js';
|
||||
|
||||
export class RangeService<TextAttributes extends BaseTextAttributes> {
|
||||
private _lastEndRelativePosition: Y.RelativePosition | null = null;
|
||||
|
||||
private _lastStartRelativePosition: Y.RelativePosition | null = null;
|
||||
|
||||
focusEnd = (): void => {
|
||||
this.editor.setInlineRange({
|
||||
index: this.editor.yTextLength,
|
||||
length: 0,
|
||||
});
|
||||
};
|
||||
|
||||
focusIndex = (index: number): void => {
|
||||
this.editor.setInlineRange({
|
||||
index,
|
||||
length: 0,
|
||||
});
|
||||
};
|
||||
|
||||
focusStart = (): void => {
|
||||
this.editor.setInlineRange({
|
||||
index: 0,
|
||||
length: 0,
|
||||
});
|
||||
};
|
||||
|
||||
getInlineRangeFromElement = (element: Element): InlineRange | null => {
|
||||
const range = document.createRange();
|
||||
const text = element.querySelector('[data-v-text]');
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const textNode = text.childNodes[1];
|
||||
assertExists(textNode instanceof Text);
|
||||
range.setStart(textNode, 0);
|
||||
range.setEnd(textNode, textNode.textContent?.length ?? 0);
|
||||
const inlineRange = this.toInlineRange(range);
|
||||
return inlineRange;
|
||||
};
|
||||
|
||||
// the number is related to the VLine's textLength
|
||||
getLine = (
|
||||
rangeIndex: InlineRange['index']
|
||||
): {
|
||||
line: VLine;
|
||||
lineIndex: number;
|
||||
rangeIndexRelatedToLine: number;
|
||||
} | null => {
|
||||
const rootElement = this.editor.rootElement;
|
||||
const lineElements = Array.from(rootElement.querySelectorAll('v-line'));
|
||||
|
||||
let beforeIndex = 0;
|
||||
for (const [lineIndex, lineElement] of lineElements.entries()) {
|
||||
if (
|
||||
rangeIndex >= beforeIndex &&
|
||||
rangeIndex < beforeIndex + lineElement.vTextLength + 1
|
||||
) {
|
||||
return {
|
||||
line: lineElement,
|
||||
lineIndex,
|
||||
rangeIndexRelatedToLine: rangeIndex - beforeIndex,
|
||||
};
|
||||
}
|
||||
beforeIndex += lineElement.vTextLength + 1;
|
||||
}
|
||||
|
||||
console.error('failed to find line');
|
||||
return null;
|
||||
};
|
||||
|
||||
getNativeRange = (): Range | null => {
|
||||
const selection = this.getNativeSelection();
|
||||
if (!selection) return null;
|
||||
return selection.getRangeAt(0);
|
||||
};
|
||||
|
||||
getNativeSelection = (): Selection | null => {
|
||||
const selection = document.getSelection();
|
||||
if (!selection) return null;
|
||||
if (selection.rangeCount === 0) return null;
|
||||
|
||||
return selection;
|
||||
};
|
||||
|
||||
getTextPoint = (rangeIndex: InlineRange['index']): TextPoint | null => {
|
||||
const rootElement = this.editor.rootElement;
|
||||
const vLines = Array.from(rootElement.querySelectorAll('v-line'));
|
||||
|
||||
let index = 0;
|
||||
for (const vLine of vLines) {
|
||||
const texts = getTextNodesFromElement(vLine);
|
||||
if (texts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const text of texts.filter(text => !isInEmbedGap(text))) {
|
||||
if (!text.textContent) {
|
||||
return null;
|
||||
}
|
||||
if (index + text.textContent.length >= rangeIndex) {
|
||||
return [text, rangeIndex - index];
|
||||
}
|
||||
index += calculateTextLength(text);
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* There are two cases to have the second line:
|
||||
* 1. long text auto wrap in span element
|
||||
* 2. soft break
|
||||
*/
|
||||
isFirstLine = (inlineRange: InlineRange | null): boolean => {
|
||||
if (!inlineRange || inlineRange.length > 0) return false;
|
||||
|
||||
const range = this.toDomRange(inlineRange);
|
||||
if (!range) {
|
||||
console.error('failed to convert inline range to domRange');
|
||||
return false;
|
||||
}
|
||||
|
||||
// check case 1:
|
||||
const beforeText = this.editor.yTextString.slice(0, inlineRange.index);
|
||||
if (beforeText.includes('\n')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check case 2:
|
||||
// If there is a wrapped text, there are two possible positions for
|
||||
// cursor: (in first line and in second line)
|
||||
// aaaaaaaa| or aaaaaaaa
|
||||
// bb |bb
|
||||
// We have no way to distinguish them and we just assume that the cursor
|
||||
// can not in the first line because if we apply the inline ranage manually the
|
||||
// cursor will jump to the second line.
|
||||
const container = range.commonAncestorContainer.parentElement;
|
||||
assertExists(container);
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
// There will be two rects if the cursor is at the edge of the line:
|
||||
// aaaaaaaa| or aaaaaaaa
|
||||
// bb |bb
|
||||
const rangeRects = range.getClientRects();
|
||||
// We use last rect here to make sure we get the second rect.
|
||||
// (Based on the assumption that the cursor can not in the first line)
|
||||
const rangeRect = rangeRects[rangeRects.length - 1];
|
||||
const tolerance = 1;
|
||||
return Math.abs(rangeRect.top - containerRect.top) < tolerance;
|
||||
};
|
||||
|
||||
/**
|
||||
* There are two cases to have the second line:
|
||||
* 1. long text auto wrap in span element
|
||||
* 2. soft break
|
||||
*/
|
||||
isLastLine = (inlineRange: InlineRange | null): boolean => {
|
||||
if (!inlineRange || inlineRange.length > 0) return false;
|
||||
|
||||
// check case 1:
|
||||
const afterText = this.editor.yTextString.slice(inlineRange.index);
|
||||
if (afterText.includes('\n')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const range = this.toDomRange(inlineRange);
|
||||
if (!range) {
|
||||
console.error('failed to convert inline range to domRange');
|
||||
return false;
|
||||
}
|
||||
|
||||
// check case 2:
|
||||
// If there is a wrapped text, there are two possible positions for
|
||||
// cursor: (in first line and in second line)
|
||||
// aaaaaaaa| or aaaaaaaa
|
||||
// bb |bb
|
||||
// We have no way to distinguish them and we just assume that the cursor
|
||||
// can not in the first line because if we apply the inline range manually the
|
||||
// cursor will jump to the second line.
|
||||
const container = range.commonAncestorContainer.parentElement;
|
||||
assertExists(container);
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
// There will be two rects if the cursor is at the edge of the line:
|
||||
// aaaaaaaa| or aaaaaaaa
|
||||
// bb |bb
|
||||
const rangeRects = range.getClientRects();
|
||||
// We use last rect here to make sure we get the second rect.
|
||||
// (Based on the assumption that the cursor can not be in the first line)
|
||||
const rangeRect = rangeRects[rangeRects.length - 1];
|
||||
|
||||
const tolerance = 1;
|
||||
return Math.abs(rangeRect.bottom - containerRect.bottom) < tolerance;
|
||||
};
|
||||
|
||||
isValidInlineRange = (inlineRange: InlineRange | null): boolean => {
|
||||
return !(
|
||||
inlineRange &&
|
||||
(inlineRange.index < 0 ||
|
||||
inlineRange.index + inlineRange.length > this.editor.yText.length)
|
||||
);
|
||||
};
|
||||
|
||||
mount = () => {
|
||||
const editor = this.editor;
|
||||
let lastInlineRange: InlineRange | null = editor.inlineRange$.value;
|
||||
editor.disposables.add(
|
||||
effect(() => {
|
||||
const newInlineRange = editor.inlineRange$.value;
|
||||
if (!editor.mounted) return;
|
||||
|
||||
const eq = isMaybeInlineRangeEqual(lastInlineRange, newInlineRange);
|
||||
if (eq) return;
|
||||
lastInlineRange = newInlineRange;
|
||||
|
||||
const yText = editor.yText;
|
||||
if (newInlineRange) {
|
||||
this._lastStartRelativePosition =
|
||||
Y.createRelativePositionFromTypeIndex(yText, newInlineRange.index);
|
||||
this._lastEndRelativePosition = Y.createRelativePositionFromTypeIndex(
|
||||
yText,
|
||||
newInlineRange.index + newInlineRange.length
|
||||
);
|
||||
} else {
|
||||
this._lastStartRelativePosition = null;
|
||||
this._lastEndRelativePosition = null;
|
||||
}
|
||||
|
||||
if (editor.inlineRangeProviderOverride) return;
|
||||
|
||||
if (this.editor.renderService.rendering) {
|
||||
editor.slots.renderComplete.once(() => {
|
||||
this.syncInlineRange(newInlineRange);
|
||||
});
|
||||
} else {
|
||||
this.syncInlineRange();
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
selectAll = (): void => {
|
||||
this.editor.setInlineRange({
|
||||
index: 0,
|
||||
length: this.editor.yTextLength,
|
||||
});
|
||||
};
|
||||
|
||||
private _syncInlineRangeLock = false;
|
||||
lockSyncInlineRange = () => {
|
||||
this._syncInlineRangeLock = true;
|
||||
};
|
||||
unlockSyncInlineRange = () => {
|
||||
this._syncInlineRangeLock = false;
|
||||
};
|
||||
/**
|
||||
* sync the dom selection from inline range for **this Editor**
|
||||
*/
|
||||
syncInlineRange = (inlineRange?: InlineRange | null) => {
|
||||
if (!this.editor.mounted || this._syncInlineRangeLock) return;
|
||||
inlineRange = inlineRange ?? this.editor.getInlineRange();
|
||||
|
||||
const handler = () => {
|
||||
const selection = document.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
if (inlineRange === null) {
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
if (range.intersectsNode(this.editor.rootElement)) {
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const newRange = this.toDomRange(inlineRange);
|
||||
if (newRange) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
this.editor.rootElement.focus();
|
||||
|
||||
this.editor.slots.inlineRangeSync.emit(newRange);
|
||||
} else {
|
||||
this.editor.slots.renderComplete.once(() => {
|
||||
this.syncInlineRange(inlineRange);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('failed to apply inline range');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (this.editor.renderService.rendering) {
|
||||
this.editor.slots.renderComplete.once(handler);
|
||||
} else {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* calculate the dom selection from inline ranage for **this Editor**
|
||||
*/
|
||||
toDomRange = (inlineRange: InlineRange): Range | null => {
|
||||
const rootElement = this.editor.rootElement;
|
||||
return inlineRangeToDomRange(rootElement, inlineRange);
|
||||
};
|
||||
|
||||
/**
|
||||
* calculate the inline ranage from dom selection for **this Editor**
|
||||
* there are three cases when the inline ranage of this Editor is not null:
|
||||
* (In the following, "|" mean anchor and focus, each line is a separate Editor)
|
||||
* 1. anchor and focus are in this Editor
|
||||
* ```
|
||||
* aaaaaa
|
||||
* b|bbbb|b
|
||||
* cccccc
|
||||
* ```
|
||||
* the inline ranage of second Editor is `{index: 1, length: 4}`, the others are null
|
||||
* 2. anchor and focus one in this Editor, one in another Editor
|
||||
* ```
|
||||
* aaa|aaa aaaaaa
|
||||
* bbbbb|b or bbbbb|b
|
||||
* cccccc cc|cccc
|
||||
* ```
|
||||
* 2.1
|
||||
* the inline ranage of first Editor is `{index: 3, length: 3}`, the second is `{index: 0, length: 5}`,
|
||||
* the third is null
|
||||
* 2.2
|
||||
* the inline ranage of first Editor is null, the second is `{index: 5, length: 1}`,
|
||||
* the third is `{index: 0, length: 2}`
|
||||
* 3. anchor and focus are in another Editor
|
||||
* ```
|
||||
* aa|aaaa
|
||||
* bbbbbb
|
||||
* cccc|cc
|
||||
* ```
|
||||
* the inline range of first Editor is `{index: 2, length: 4}`,
|
||||
* the second is `{index: 0, length: 6}`, the third is `{index: 0, length: 4}`
|
||||
*/
|
||||
toInlineRange = (range: Range): InlineRange | null => {
|
||||
const { rootElement, yText } = this.editor;
|
||||
|
||||
return domRangeToInlineRange(range, rootElement, yText);
|
||||
};
|
||||
|
||||
get lastEndRelativePosition() {
|
||||
return this._lastEndRelativePosition;
|
||||
}
|
||||
|
||||
get lastStartRelativePosition() {
|
||||
return this._lastStartRelativePosition;
|
||||
}
|
||||
|
||||
constructor(readonly editor: InlineEditor<TextAttributes>) {}
|
||||
}
|
||||
179
blocksuite/framework/inline/src/services/render.ts
Normal file
179
blocksuite/framework/inline/src/services/render.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { html, render } from 'lit';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import type { VLine } from '../components/v-line.js';
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
import type { InlineRange } from '../types.js';
|
||||
import type { BaseTextAttributes } from '../utils/base-attributes.js';
|
||||
import { deltaInsertsToChunks } from '../utils/delta-convert.js';
|
||||
|
||||
export class RenderService<TextAttributes extends BaseTextAttributes> {
|
||||
private _onYTextChange = (_: Y.YTextEvent, transaction: Y.Transaction) => {
|
||||
this.editor.slots.textChange.emit();
|
||||
|
||||
const yText = this.editor.yText;
|
||||
|
||||
if (yText.toString().includes('\r')) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'yText must not contain "\\r" because it will break the range synchronization'
|
||||
);
|
||||
}
|
||||
|
||||
this.render();
|
||||
|
||||
const inlineRange = this.editor.inlineRange$.peek();
|
||||
if (!inlineRange || transaction.local) return;
|
||||
|
||||
const lastStartRelativePosition = this.editor.lastStartRelativePosition;
|
||||
const lastEndRelativePosition = this.editor.lastEndRelativePosition;
|
||||
if (!lastStartRelativePosition || !lastEndRelativePosition) return;
|
||||
|
||||
const doc = this.editor.yText.doc;
|
||||
assertExists(doc);
|
||||
const absoluteStart = Y.createAbsolutePositionFromRelativePosition(
|
||||
lastStartRelativePosition,
|
||||
doc
|
||||
);
|
||||
const absoluteEnd = Y.createAbsolutePositionFromRelativePosition(
|
||||
lastEndRelativePosition,
|
||||
doc
|
||||
);
|
||||
|
||||
const startIndex = absoluteStart?.index;
|
||||
const endIndex = absoluteEnd?.index;
|
||||
if (!startIndex || !endIndex) return;
|
||||
|
||||
const newInlineRange: InlineRange = {
|
||||
index: startIndex,
|
||||
length: endIndex - startIndex,
|
||||
};
|
||||
if (!this.editor.isValidInlineRange(newInlineRange)) return;
|
||||
|
||||
this.editor.setInlineRange(newInlineRange);
|
||||
this.editor.syncInlineRange();
|
||||
};
|
||||
|
||||
mount = () => {
|
||||
const editor = this.editor;
|
||||
const yText = editor.yText;
|
||||
|
||||
yText.observe(this._onYTextChange);
|
||||
editor.disposables.add({
|
||||
dispose: () => {
|
||||
yText.unobserve(this._onYTextChange);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private _rendering = false;
|
||||
get rendering() {
|
||||
return this._rendering;
|
||||
}
|
||||
// render current deltas to VLines
|
||||
render = () => {
|
||||
if (!this.editor.mounted) return;
|
||||
|
||||
this._rendering = true;
|
||||
|
||||
const rootElement = this.editor.rootElement;
|
||||
const embedDeltas = this.editor.deltaService.embedDeltas;
|
||||
const chunks = deltaInsertsToChunks(embedDeltas);
|
||||
|
||||
let deltaIndex = 0;
|
||||
// every chunk is a line
|
||||
const lines = chunks.map((chunk, lineIndex) => {
|
||||
if (lineIndex > 0) {
|
||||
deltaIndex += 1; // for '\n'
|
||||
}
|
||||
|
||||
const lineStartOffset = deltaIndex;
|
||||
if (chunk.length > 0) {
|
||||
const elements: VLine['elements'] = chunk.map(delta => {
|
||||
const startOffset = deltaIndex;
|
||||
deltaIndex += delta.insert.length;
|
||||
const endOffset = deltaIndex;
|
||||
|
||||
return [
|
||||
html`<v-element
|
||||
.inlineEditor=${this.editor}
|
||||
.delta=${{
|
||||
insert: delta.insert,
|
||||
attributes: this.editor.attributeService.normalizeAttributes(
|
||||
delta.attributes
|
||||
),
|
||||
}}
|
||||
.startOffset=${startOffset}
|
||||
.endOffset=${endOffset}
|
||||
.lineIndex=${lineIndex}
|
||||
></v-element>`,
|
||||
delta,
|
||||
];
|
||||
});
|
||||
|
||||
return html`<v-line
|
||||
.elements=${elements}
|
||||
.index=${lineIndex}
|
||||
.startOffset=${lineStartOffset}
|
||||
.endOffset=${deltaIndex}
|
||||
></v-line>`;
|
||||
} else {
|
||||
return html`<v-line
|
||||
.elements=${[]}
|
||||
.index=${lineIndex}
|
||||
.startOffset=${lineStartOffset}
|
||||
.endOffset=${deltaIndex}
|
||||
></v-line>`;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
render(
|
||||
repeat(
|
||||
lines.map((line, i) => ({ line, index: i })),
|
||||
entry => entry.index,
|
||||
entry => entry.line
|
||||
),
|
||||
rootElement
|
||||
);
|
||||
} catch {
|
||||
// Lit may be crashed by IME input and we need to rerender whole editor for it
|
||||
this.editor.rerenderWholeEditor();
|
||||
}
|
||||
|
||||
this.editor
|
||||
.waitForUpdate()
|
||||
.then(() => {
|
||||
this._rendering = false;
|
||||
this.editor.slots.renderComplete.emit();
|
||||
this.editor.syncInlineRange();
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
rerenderWholeEditor = () => {
|
||||
const rootElement = this.editor.rootElement;
|
||||
|
||||
if (!rootElement.isConnected) return;
|
||||
|
||||
rootElement.replaceChildren();
|
||||
// Because we bypassed Lit and disrupted the DOM structure, this will cause an inconsistency in the original state of `ChildPart`.
|
||||
// Therefore, we need to remove the original `ChildPart`.
|
||||
// https://github.com/lit/lit/blob/a2cd76cfdea4ed717362bb1db32710d70550469d/packages/lit-html/src/lit-html.ts#L2248
|
||||
|
||||
delete (rootElement as any)['_$litPart$'];
|
||||
this.render();
|
||||
};
|
||||
|
||||
waitForUpdate = async () => {
|
||||
const vLines = Array.from(
|
||||
this.editor.rootElement.querySelectorAll('v-line')
|
||||
);
|
||||
await Promise.all(vLines.map(line => line.updateComplete));
|
||||
};
|
||||
|
||||
constructor(readonly editor: InlineEditor<TextAttributes>) {}
|
||||
}
|
||||
141
blocksuite/framework/inline/src/services/text.ts
Normal file
141
blocksuite/framework/inline/src/services/text.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
import type { DeltaInsert, InlineRange } from '../types.js';
|
||||
import type { BaseTextAttributes } from '../utils/base-attributes.js';
|
||||
import { intersectInlineRange } from '../utils/inline-range.js';
|
||||
|
||||
export class InlineTextService<TextAttributes extends BaseTextAttributes> {
|
||||
deleteText = (inlineRange: InlineRange): void => {
|
||||
if (this.editor.isReadonly) return;
|
||||
|
||||
this.transact(() => {
|
||||
this.yText.delete(inlineRange.index, inlineRange.length);
|
||||
});
|
||||
};
|
||||
|
||||
formatText = (
|
||||
inlineRange: InlineRange,
|
||||
attributes: TextAttributes,
|
||||
options: {
|
||||
match?: (delta: DeltaInsert, deltaInlineRange: InlineRange) => boolean;
|
||||
mode?: 'replace' | 'merge';
|
||||
} = {}
|
||||
): void => {
|
||||
if (this.editor.isReadonly) return;
|
||||
|
||||
const { match = () => true, mode = 'merge' } = options;
|
||||
const deltas = this.editor.deltaService.getDeltasByInlineRange(inlineRange);
|
||||
|
||||
deltas
|
||||
.filter(([delta, deltaInlineRange]) => match(delta, deltaInlineRange))
|
||||
.forEach(([_delta, deltaInlineRange]) => {
|
||||
const normalizedAttributes =
|
||||
this.editor.attributeService.normalizeAttributes(attributes);
|
||||
if (!normalizedAttributes) return;
|
||||
|
||||
const targetInlineRange = intersectInlineRange(
|
||||
inlineRange,
|
||||
deltaInlineRange
|
||||
);
|
||||
if (!targetInlineRange) return;
|
||||
|
||||
if (mode === 'replace') {
|
||||
this.resetText(targetInlineRange);
|
||||
}
|
||||
|
||||
this.transact(() => {
|
||||
this.yText.format(
|
||||
targetInlineRange.index,
|
||||
targetInlineRange.length,
|
||||
normalizedAttributes
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
insertLineBreak = (inlineRange: InlineRange): void => {
|
||||
if (this.editor.isReadonly) return;
|
||||
|
||||
this.transact(() => {
|
||||
this.yText.delete(inlineRange.index, inlineRange.length);
|
||||
this.yText.insert(inlineRange.index, '\n');
|
||||
});
|
||||
};
|
||||
|
||||
insertText = (
|
||||
inlineRange: InlineRange,
|
||||
text: string,
|
||||
attributes: TextAttributes = {} as TextAttributes
|
||||
): void => {
|
||||
if (this.editor.isReadonly) return;
|
||||
|
||||
if (this.editor.attributeService.marks) {
|
||||
attributes = { ...attributes, ...this.editor.attributeService.marks };
|
||||
}
|
||||
const normalizedAttributes =
|
||||
this.editor.attributeService.normalizeAttributes(attributes);
|
||||
|
||||
if (!text || !text.length) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'text must not be empty'
|
||||
);
|
||||
}
|
||||
|
||||
this.transact(() => {
|
||||
this.yText.delete(inlineRange.index, inlineRange.length);
|
||||
this.yText.insert(inlineRange.index, text, normalizedAttributes);
|
||||
});
|
||||
};
|
||||
|
||||
resetText = (inlineRange: InlineRange): void => {
|
||||
if (this.editor.isReadonly) return;
|
||||
|
||||
const coverDeltas: DeltaInsert[] = [];
|
||||
for (
|
||||
let i = inlineRange.index;
|
||||
i <= inlineRange.index + inlineRange.length;
|
||||
i++
|
||||
) {
|
||||
const delta = this.editor.getDeltaByRangeIndex(i);
|
||||
if (delta) {
|
||||
coverDeltas.push(delta);
|
||||
}
|
||||
}
|
||||
|
||||
const unset = Object.fromEntries(
|
||||
coverDeltas.flatMap(delta =>
|
||||
delta.attributes
|
||||
? Object.keys(delta.attributes).map(key => [key, null])
|
||||
: []
|
||||
)
|
||||
);
|
||||
|
||||
this.transact(() => {
|
||||
this.yText.format(inlineRange.index, inlineRange.length, {
|
||||
...unset,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
setText = (
|
||||
text: string,
|
||||
attributes: TextAttributes = {} as TextAttributes
|
||||
): void => {
|
||||
if (this.editor.isReadonly) return;
|
||||
|
||||
this.transact(() => {
|
||||
this.yText.delete(0, this.yText.length);
|
||||
this.yText.insert(0, text, attributes);
|
||||
});
|
||||
};
|
||||
|
||||
readonly transact = this.editor.transact;
|
||||
|
||||
get yText() {
|
||||
return this.editor.yText;
|
||||
}
|
||||
|
||||
constructor(readonly editor: InlineEditor<TextAttributes>) {}
|
||||
}
|
||||
43
blocksuite/framework/inline/src/types.ts
Normal file
43
blocksuite/framework/inline/src/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import type { InlineEditor } from './inline-editor.js';
|
||||
import type { BaseTextAttributes } from './utils/index.js';
|
||||
|
||||
export type DeltaInsert<
|
||||
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
|
||||
> = {
|
||||
insert: string;
|
||||
attributes?: TextAttributes;
|
||||
};
|
||||
|
||||
export type AttributeRenderer<
|
||||
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
|
||||
> = (props: {
|
||||
editor: InlineEditor<TextAttributes>;
|
||||
delta: DeltaInsert<TextAttributes>;
|
||||
selected: boolean;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
lineIndex: number;
|
||||
}) => TemplateResult<1>;
|
||||
|
||||
export interface InlineRange {
|
||||
index: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
export type DeltaEntry<
|
||||
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
|
||||
> = [delta: DeltaInsert<TextAttributes>, range: InlineRange];
|
||||
|
||||
// corresponding to [anchorNode/focusNode, anchorOffset/focusOffset]
|
||||
export type NativePoint = readonly [node: Node, offset: number];
|
||||
// the number here is relative to the text node
|
||||
export type TextPoint = readonly [text: Text, offset: number];
|
||||
|
||||
export interface DomPoint {
|
||||
// which text node this point is in
|
||||
text: Text;
|
||||
// the index here is relative to the Editor, not text node
|
||||
index: number;
|
||||
}
|
||||
49
blocksuite/framework/inline/src/utils/attribute-renderer.ts
Normal file
49
blocksuite/framework/inline/src/utils/attribute-renderer.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { AttributeRenderer } from '../types.js';
|
||||
import type { BaseTextAttributes } from './base-attributes.js';
|
||||
|
||||
function inlineTextStyles(
|
||||
props: BaseTextAttributes
|
||||
): ReturnType<typeof styleMap> {
|
||||
let textDecorations = '';
|
||||
if (props.underline) {
|
||||
textDecorations += 'underline';
|
||||
}
|
||||
if (props.strike) {
|
||||
textDecorations += ' line-through';
|
||||
}
|
||||
|
||||
let inlineCodeStyle = {};
|
||||
if (props.code) {
|
||||
inlineCodeStyle = {
|
||||
'font-family':
|
||||
'"SFMono-Regular", Menlo, Consolas, "PT Mono", "Liberation Mono", Courier, monospace',
|
||||
'line-height': 'normal',
|
||||
background: 'rgba(135,131,120,0.15)',
|
||||
color: '#EB5757',
|
||||
'border-radius': '3px',
|
||||
'font-size': '85%',
|
||||
padding: '0.2em 0.4em',
|
||||
};
|
||||
}
|
||||
|
||||
return styleMap({
|
||||
'font-weight': props.bold ? 'bold' : 'normal',
|
||||
'font-style': props.italic ? 'italic' : 'normal',
|
||||
'text-decoration': textDecorations.length > 0 ? textDecorations : 'none',
|
||||
...inlineCodeStyle,
|
||||
});
|
||||
}
|
||||
|
||||
export const getDefaultAttributeRenderer =
|
||||
<T extends BaseTextAttributes>(): AttributeRenderer<T> =>
|
||||
({ delta }) => {
|
||||
const style = delta.attributes
|
||||
? inlineTextStyles(delta.attributes)
|
||||
: styleMap({});
|
||||
return html`<span style=${style}
|
||||
><v-text .str=${delta.insert}></v-text
|
||||
></span>`;
|
||||
};
|
||||
12
blocksuite/framework/inline/src/utils/base-attributes.ts
Normal file
12
blocksuite/framework/inline/src/utils/base-attributes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const baseTextAttributes = z.object({
|
||||
bold: z.literal(true).optional().nullable().catch(undefined),
|
||||
italic: z.literal(true).optional().nullable().catch(undefined),
|
||||
underline: z.literal(true).optional().nullable().catch(undefined),
|
||||
strike: z.literal(true).optional().nullable().catch(undefined),
|
||||
code: z.literal(true).optional().nullable().catch(undefined),
|
||||
link: z.string().optional().nullable().catch(undefined),
|
||||
});
|
||||
|
||||
export type BaseTextAttributes = z.infer<typeof baseTextAttributes>;
|
||||
64
blocksuite/framework/inline/src/utils/delta-convert.ts
Normal file
64
blocksuite/framework/inline/src/utils/delta-convert.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { DeltaInsert } from '../types.js';
|
||||
import type { BaseTextAttributes } from './base-attributes.js';
|
||||
|
||||
export function transformDelta<TextAttributes extends BaseTextAttributes>(
|
||||
delta: DeltaInsert<TextAttributes>
|
||||
): (DeltaInsert<TextAttributes> | '\n')[] {
|
||||
const result: (DeltaInsert<TextAttributes> | '\n')[] = [];
|
||||
|
||||
let tmpString = delta.insert;
|
||||
while (tmpString.length > 0) {
|
||||
const index = tmpString.indexOf('\n');
|
||||
if (index === -1) {
|
||||
result.push({
|
||||
insert: tmpString,
|
||||
attributes: delta.attributes,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if (tmpString.slice(0, index).length > 0) {
|
||||
result.push({
|
||||
insert: tmpString.slice(0, index),
|
||||
attributes: delta.attributes,
|
||||
});
|
||||
}
|
||||
|
||||
result.push('\n');
|
||||
tmpString = tmpString.slice(index + 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* convert a delta insert array to chunks, each chunk is a line
|
||||
*/
|
||||
export function deltaInsertsToChunks<TextAttributes extends BaseTextAttributes>(
|
||||
delta: DeltaInsert<TextAttributes>[]
|
||||
): DeltaInsert<TextAttributes>[][] {
|
||||
if (delta.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
const transformedDelta = delta.flatMap(transformDelta);
|
||||
|
||||
function* chunksGenerator(arr: (DeltaInsert<TextAttributes> | '\n')[]) {
|
||||
let start = 0;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr[i] === '\n') {
|
||||
const chunk = arr.slice(start, i);
|
||||
start = i + 1;
|
||||
yield chunk as DeltaInsert<TextAttributes>[];
|
||||
} else if (i === arr.length - 1) {
|
||||
yield arr.slice(start) as DeltaInsert<TextAttributes>[];
|
||||
}
|
||||
}
|
||||
|
||||
if (arr.at(-1) === '\n') {
|
||||
yield [];
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(chunksGenerator(transformedDelta));
|
||||
}
|
||||
47
blocksuite/framework/inline/src/utils/embed.ts
Normal file
47
blocksuite/framework/inline/src/utils/embed.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { VElement } from '../components/v-element.js';
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
import type { DeltaInsert } from '../types.js';
|
||||
import type { BaseTextAttributes } from './base-attributes.js';
|
||||
|
||||
export function isInEmbedElement(node: Node): boolean {
|
||||
if (node instanceof Element) {
|
||||
if (node instanceof VElement) {
|
||||
return node.querySelector('[data-v-embed="true"]') !== null;
|
||||
}
|
||||
const vElement = node.closest('[data-v-embed="true"]');
|
||||
return !!vElement;
|
||||
} else {
|
||||
const vElement = node.parentElement?.closest('[data-v-embed="true"]');
|
||||
return !!vElement;
|
||||
}
|
||||
}
|
||||
|
||||
export function isInEmbedGap(node: Node): boolean {
|
||||
const el = node instanceof Element ? node : node.parentElement;
|
||||
if (!el) return false;
|
||||
return !!el.closest('[data-v-embed-gap="true"]');
|
||||
}
|
||||
|
||||
export function transformDeltasToEmbedDeltas<
|
||||
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
|
||||
>(
|
||||
editor: InlineEditor<TextAttributes>,
|
||||
deltas: DeltaInsert<TextAttributes>[]
|
||||
): DeltaInsert<TextAttributes>[] {
|
||||
// According to our regulations, the length of each "embed" node should only be 1.
|
||||
// Therefore, if the length of an "embed" type node is greater than 1,
|
||||
// we will divide it into multiple parts.
|
||||
const result: DeltaInsert<TextAttributes>[] = [];
|
||||
for (const delta of deltas) {
|
||||
if (editor.isEmbed(delta)) {
|
||||
const dividedDeltas = [...delta.insert].map(subInsert => ({
|
||||
insert: subInsert,
|
||||
attributes: delta.attributes,
|
||||
}));
|
||||
result.push(...dividedDeltas);
|
||||
} else {
|
||||
result.push(delta);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
29
blocksuite/framework/inline/src/utils/guard.ts
Normal file
29
blocksuite/framework/inline/src/utils/guard.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { VElement, VLine } from '../components/index.js';
|
||||
|
||||
export function isNativeTextInVText(text: unknown): text is Text {
|
||||
return text instanceof Text && text.parentElement?.dataset.vText === 'true';
|
||||
}
|
||||
|
||||
export function isVElement(element: unknown): element is HTMLElement {
|
||||
return (
|
||||
element instanceof HTMLElement &&
|
||||
(element.dataset.vElement === 'true' || element instanceof VElement)
|
||||
);
|
||||
}
|
||||
|
||||
export function isVLine(element: unknown): element is HTMLElement {
|
||||
return (
|
||||
element instanceof HTMLElement &&
|
||||
(element instanceof VLine || element.parentElement instanceof VLine)
|
||||
);
|
||||
}
|
||||
|
||||
export function isInEmptyLine(element: Node) {
|
||||
const el = element instanceof Element ? element : element.parentElement;
|
||||
const vLine = el?.closest<VLine>('v-line');
|
||||
return !!vLine && vLine.vTextLength === 0;
|
||||
}
|
||||
|
||||
export function isInlineRoot(element: unknown): element is HTMLElement {
|
||||
return element instanceof HTMLElement && element.dataset.vRoot === 'true';
|
||||
}
|
||||
12
blocksuite/framework/inline/src/utils/index.ts
Normal file
12
blocksuite/framework/inline/src/utils/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * from './attribute-renderer.js';
|
||||
export * from './base-attributes.js';
|
||||
export * from './delta-convert.js';
|
||||
export * from './embed.js';
|
||||
export * from './guard.js';
|
||||
export * from './keyboard.js';
|
||||
export * from './point-conversion.js';
|
||||
export * from './query.js';
|
||||
export * from './range-conversion.js';
|
||||
export * from './renderer.js';
|
||||
export * from './text.js';
|
||||
export * from './transform-input.js';
|
||||
74
blocksuite/framework/inline/src/utils/inline-range.ts
Normal file
74
blocksuite/framework/inline/src/utils/inline-range.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { InlineRange } from '../types.js';
|
||||
|
||||
export function isMaybeInlineRangeEqual(
|
||||
a: InlineRange | null,
|
||||
b: InlineRange | null
|
||||
): boolean {
|
||||
return a === b || (a && b ? isInlineRangeEqual(a, b) : false);
|
||||
}
|
||||
|
||||
export function isInlineRangeContain(a: InlineRange, b: InlineRange): boolean {
|
||||
return a.index <= b.index && a.index + a.length >= b.index + b.length;
|
||||
}
|
||||
|
||||
export function isInlineRangeEqual(a: InlineRange, b: InlineRange): boolean {
|
||||
return a.index === b.index && a.length === b.length;
|
||||
}
|
||||
|
||||
export function isInlineRangeIntersect(
|
||||
a: InlineRange,
|
||||
b: InlineRange
|
||||
): boolean {
|
||||
return a.index <= b.index + b.length && a.index + a.length >= b.index;
|
||||
}
|
||||
|
||||
export function isInlineRangeBefore(a: InlineRange, b: InlineRange): boolean {
|
||||
return a.index + a.length <= b.index;
|
||||
}
|
||||
|
||||
export function isInlineRangeAfter(a: InlineRange, b: InlineRange): boolean {
|
||||
return a.index >= b.index + b.length;
|
||||
}
|
||||
|
||||
export function isInlineRangeEdge(
|
||||
index: InlineRange['index'],
|
||||
range: InlineRange
|
||||
): boolean {
|
||||
return index === range.index || index === range.index + range.length;
|
||||
}
|
||||
|
||||
export function isInlineRangeEdgeBefore(
|
||||
index: InlineRange['index'],
|
||||
range: InlineRange
|
||||
): boolean {
|
||||
return index === range.index;
|
||||
}
|
||||
|
||||
export function isInlineRangeEdgeAfter(
|
||||
index: InlineRange['index'],
|
||||
range: InlineRange
|
||||
): boolean {
|
||||
return index === range.index + range.length;
|
||||
}
|
||||
|
||||
export function isPoint(range: InlineRange): boolean {
|
||||
return range.length === 0;
|
||||
}
|
||||
|
||||
export function mergeInlineRange(a: InlineRange, b: InlineRange): InlineRange {
|
||||
const index = Math.min(a.index, b.index);
|
||||
const length = Math.max(a.index + a.length, b.index + b.length) - index;
|
||||
return { index, length };
|
||||
}
|
||||
|
||||
export function intersectInlineRange(
|
||||
a: InlineRange,
|
||||
b: InlineRange
|
||||
): InlineRange | null {
|
||||
if (!isInlineRangeIntersect(a, b)) {
|
||||
return null;
|
||||
}
|
||||
const index = Math.max(a.index, b.index);
|
||||
const length = Math.min(a.index + a.length, b.index + b.length) - index;
|
||||
return { index, length };
|
||||
}
|
||||
135
blocksuite/framework/inline/src/utils/keyboard.ts
Normal file
135
blocksuite/framework/inline/src/utils/keyboard.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { IS_IOS, IS_MAC } from '@blocksuite/global/env';
|
||||
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
import type { InlineRange } from '../types.js';
|
||||
import type { BaseTextAttributes } from './base-attributes.js';
|
||||
|
||||
const SHORT_KEY_PROPERTY = IS_IOS || IS_MAC ? 'metaKey' : 'ctrlKey';
|
||||
|
||||
export const KEYBOARD_PREVENT_DEFAULT = false;
|
||||
export const KEYBOARD_ALLOW_DEFAULT = true;
|
||||
|
||||
export interface KeyboardBinding {
|
||||
key: number | string | string[];
|
||||
handler: KeyboardBindingHandler;
|
||||
prefix?: RegExp;
|
||||
suffix?: RegExp;
|
||||
shortKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
}
|
||||
export type KeyboardBindingRecord = Record<string, KeyboardBinding>;
|
||||
|
||||
export interface KeyboardBindingContext<
|
||||
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
|
||||
> {
|
||||
inlineRange: InlineRange;
|
||||
inlineEditor: InlineEditor<TextAttributes>;
|
||||
collapsed: boolean;
|
||||
prefixText: string;
|
||||
suffixText: string;
|
||||
raw: KeyboardEvent;
|
||||
}
|
||||
export type KeyboardBindingHandler = (
|
||||
context: KeyboardBindingContext
|
||||
) => typeof KEYBOARD_PREVENT_DEFAULT | typeof KEYBOARD_ALLOW_DEFAULT;
|
||||
|
||||
export function createInlineKeyDownHandler(
|
||||
inlineEditor: InlineEditor,
|
||||
bindings: KeyboardBindingRecord
|
||||
): (evt: KeyboardEvent) => void {
|
||||
const bindingStore: Record<string, KeyboardBinding[]> = {};
|
||||
|
||||
function normalize(binding: KeyboardBinding): KeyboardBinding {
|
||||
if (binding.shortKey) {
|
||||
binding[SHORT_KEY_PROPERTY] = binding.shortKey;
|
||||
delete binding.shortKey;
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
function keyMatch(evt: KeyboardEvent, binding: KeyboardBinding) {
|
||||
if (
|
||||
(['altKey', 'ctrlKey', 'metaKey', 'shiftKey'] as const).some(
|
||||
key => Object.hasOwn(binding, key) && binding[key] !== evt[key]
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return binding.key === evt.key;
|
||||
}
|
||||
|
||||
function addBinding(keyBinding: KeyboardBinding) {
|
||||
const binding = normalize(keyBinding);
|
||||
const keys = Array.isArray(binding.key) ? binding.key : [binding.key];
|
||||
keys.forEach(key => {
|
||||
const singleBinding = {
|
||||
...binding,
|
||||
key,
|
||||
};
|
||||
bindingStore[key] = bindingStore[key] ?? [];
|
||||
bindingStore[key].push(singleBinding);
|
||||
});
|
||||
}
|
||||
|
||||
Object.values(bindings).forEach(binding => {
|
||||
addBinding(binding);
|
||||
});
|
||||
|
||||
function keyDownHandler(evt: KeyboardEvent) {
|
||||
if (evt.defaultPrevented || evt.isComposing) return;
|
||||
const keyBindings = bindingStore[evt.key] ?? [];
|
||||
|
||||
const keyMatches = keyBindings.filter(binding => keyMatch(evt, binding));
|
||||
if (keyMatches.length === 0) return;
|
||||
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
const startTextPoint = inlineEditor.getTextPoint(inlineRange.index);
|
||||
if (!startTextPoint) return;
|
||||
const [leafStart, offsetStart] = startTextPoint;
|
||||
let leafEnd: Text;
|
||||
let offsetEnd: number;
|
||||
if (inlineRange.length === 0) {
|
||||
leafEnd = leafStart;
|
||||
offsetEnd = offsetStart;
|
||||
} else {
|
||||
const endTextPoint = inlineEditor.getTextPoint(
|
||||
inlineRange.index + inlineRange.length
|
||||
);
|
||||
if (!endTextPoint) return;
|
||||
[leafEnd, offsetEnd] = endTextPoint;
|
||||
}
|
||||
const prefixText = leafStart.textContent
|
||||
? leafStart.textContent.slice(0, offsetStart)
|
||||
: '';
|
||||
const suffixText = leafEnd.textContent
|
||||
? leafEnd.textContent.slice(offsetEnd)
|
||||
: '';
|
||||
const currContext: KeyboardBindingContext = {
|
||||
inlineRange,
|
||||
inlineEditor: inlineEditor,
|
||||
collapsed: inlineRange.length === 0,
|
||||
prefixText,
|
||||
suffixText,
|
||||
raw: evt,
|
||||
};
|
||||
const prevented = keyMatches.some(binding => {
|
||||
if (binding.prefix && !binding.prefix.test(currContext.prefixText)) {
|
||||
return false;
|
||||
}
|
||||
if (binding.suffix && !binding.suffix.test(currContext.suffixText)) {
|
||||
return false;
|
||||
}
|
||||
return binding.handler(currContext) === KEYBOARD_PREVENT_DEFAULT;
|
||||
});
|
||||
if (prevented) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
return keyDownHandler;
|
||||
}
|
||||
191
blocksuite/framework/inline/src/utils/point-conversion.ts
Normal file
191
blocksuite/framework/inline/src/utils/point-conversion.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
|
||||
import type { VElement, VLine } from '../components/index.js';
|
||||
import { INLINE_ROOT_ATTR, ZERO_WIDTH_SPACE } from '../consts.js';
|
||||
import type { DomPoint, TextPoint } from '../types.js';
|
||||
import {
|
||||
isInlineRoot,
|
||||
isNativeTextInVText,
|
||||
isVElement,
|
||||
isVLine,
|
||||
} from './guard.js';
|
||||
import { calculateTextLength, getTextNodesFromElement } from './text.js';
|
||||
|
||||
export function nativePointToTextPoint(
|
||||
node: unknown,
|
||||
offset: number
|
||||
): TextPoint | null {
|
||||
if (isNativeTextInVText(node)) {
|
||||
return [node, offset];
|
||||
}
|
||||
|
||||
if (isVElement(node)) {
|
||||
const texts = getTextNodesFromElement(node);
|
||||
const vElement = texts[0].parentElement?.closest('[data-v-element="true"]');
|
||||
|
||||
if (
|
||||
texts.length === 1 &&
|
||||
vElement instanceof HTMLElement &&
|
||||
vElement.dataset.vEmbed === 'true'
|
||||
) {
|
||||
return [texts[0], 0];
|
||||
}
|
||||
|
||||
if (texts.length > 0) {
|
||||
return texts[offset] ? [texts[offset], 0] : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isVLine(node) || isInlineRoot(node)) {
|
||||
return getTextPointRoughlyFromElementByOffset(node, offset, true);
|
||||
}
|
||||
|
||||
if (!(node instanceof Node)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const vNodes = getVNodesFromNode(node);
|
||||
|
||||
if (vNodes) {
|
||||
return getTextPointFromVNodes(vNodes, node, offset);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function textPointToDomPoint(
|
||||
text: Text,
|
||||
offset: number,
|
||||
rootElement: HTMLElement
|
||||
): DomPoint | null {
|
||||
if (rootElement.dataset.vRoot !== 'true') {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'textRangeToDomPoint should be called with editor root element'
|
||||
);
|
||||
}
|
||||
|
||||
if (!rootElement.contains(text)) return null;
|
||||
|
||||
const texts = getTextNodesFromElement(rootElement);
|
||||
if (texts.length === 0) return null;
|
||||
|
||||
const goalIndex = texts.indexOf(text);
|
||||
let index = 0;
|
||||
for (const text of texts.slice(0, goalIndex)) {
|
||||
index += calculateTextLength(text);
|
||||
}
|
||||
|
||||
if (text.wholeText !== ZERO_WIDTH_SPACE) {
|
||||
index += offset;
|
||||
}
|
||||
|
||||
const textParentElement = text.parentElement;
|
||||
if (!textParentElement) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'text element parent not found'
|
||||
);
|
||||
}
|
||||
|
||||
const lineElement = textParentElement.closest('v-line');
|
||||
|
||||
if (!lineElement) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'line element not found'
|
||||
);
|
||||
}
|
||||
|
||||
const lineIndex = Array.from(rootElement.querySelectorAll('v-line')).indexOf(
|
||||
lineElement
|
||||
);
|
||||
|
||||
return { text, index: index + lineIndex };
|
||||
}
|
||||
|
||||
function getVNodesFromNode(node: Node): VElement[] | VLine[] | null {
|
||||
const vLine = node.parentElement?.closest('v-line');
|
||||
|
||||
if (vLine) {
|
||||
return Array.from(vLine.querySelectorAll('v-element'));
|
||||
}
|
||||
|
||||
const container =
|
||||
node instanceof Element
|
||||
? node.closest(`[${INLINE_ROOT_ATTR}]`)
|
||||
: node.parentElement?.closest(`[${INLINE_ROOT_ATTR}]`);
|
||||
|
||||
if (container) {
|
||||
return Array.from(container.querySelectorAll('v-line'));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getTextPointFromVNodes(
|
||||
vNodes: VLine[] | VElement[],
|
||||
node: Node,
|
||||
offset: number
|
||||
): TextPoint | null {
|
||||
const first = vNodes[0];
|
||||
for (let i = 0; i < vNodes.length; i++) {
|
||||
const vNode = vNodes[i];
|
||||
|
||||
if (i === 0 && AFollowedByB(node, vNode)) {
|
||||
return getTextPointRoughlyFromElementByOffset(first, offset, true);
|
||||
}
|
||||
|
||||
if (AInsideB(node, vNode)) {
|
||||
return getTextPointRoughlyFromElementByOffset(first, offset, false);
|
||||
}
|
||||
|
||||
if (i === vNodes.length - 1 && APrecededByB(node, vNode)) {
|
||||
return getTextPointRoughlyFromElement(vNode);
|
||||
}
|
||||
|
||||
if (
|
||||
i < vNodes.length - 1 &&
|
||||
APrecededByB(node, vNode) &&
|
||||
AFollowedByB(node, vNodes[i + 1])
|
||||
) {
|
||||
return getTextPointRoughlyFromElement(vNode);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getTextPointRoughlyFromElement(element: Element): TextPoint | null {
|
||||
const texts = getTextNodesFromElement(element);
|
||||
if (texts.length === 0) return null;
|
||||
const text = texts[texts.length - 1];
|
||||
return [text, calculateTextLength(text)];
|
||||
}
|
||||
|
||||
function getTextPointRoughlyFromElementByOffset(
|
||||
element: Element,
|
||||
offset: number,
|
||||
fromStart: boolean
|
||||
): TextPoint | null {
|
||||
const texts = getTextNodesFromElement(element);
|
||||
if (texts.length === 0) return null;
|
||||
const text = fromStart ? texts[0] : texts[texts.length - 1];
|
||||
return [text, offset === 0 ? offset : text.length];
|
||||
}
|
||||
|
||||
function AInsideB(a: Node, b: Node): boolean {
|
||||
return (
|
||||
b.compareDocumentPosition(a) === Node.DOCUMENT_POSITION_CONTAINED_BY ||
|
||||
b.compareDocumentPosition(a) ===
|
||||
(Node.DOCUMENT_POSITION_CONTAINED_BY | Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
);
|
||||
}
|
||||
|
||||
function AFollowedByB(a: Node, b: Node): boolean {
|
||||
return a.compareDocumentPosition(b) === Node.DOCUMENT_POSITION_FOLLOWING;
|
||||
}
|
||||
|
||||
function APrecededByB(a: Node, b: Node): boolean {
|
||||
return a.compareDocumentPosition(b) === Node.DOCUMENT_POSITION_PRECEDING;
|
||||
}
|
||||
20
blocksuite/framework/inline/src/utils/query.ts
Normal file
20
blocksuite/framework/inline/src/utils/query.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { INLINE_ROOT_ATTR } from '../consts.js';
|
||||
import type { InlineEditor, InlineRootElement } from '../inline-editor.js';
|
||||
|
||||
export function getInlineEditorInsideRoot(
|
||||
element: Element
|
||||
): InlineEditor | null {
|
||||
const rootElement = element.closest(
|
||||
`[${INLINE_ROOT_ATTR}]`
|
||||
) as InlineRootElement;
|
||||
if (!rootElement) {
|
||||
console.error('element must be inside a v-root');
|
||||
return null;
|
||||
}
|
||||
const inlineEditor = rootElement.inlineEditor;
|
||||
if (!inlineEditor) {
|
||||
console.error('element must be inside a v-root with inline-editor');
|
||||
return null;
|
||||
}
|
||||
return inlineEditor;
|
||||
}
|
||||
368
blocksuite/framework/inline/src/utils/range-conversion.ts
Normal file
368
blocksuite/framework/inline/src/utils/range-conversion.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
import { VElement } from '../components/v-element.js';
|
||||
import type { InlineRange } from '../types.js';
|
||||
import { isInEmbedElement } from './embed.js';
|
||||
import {
|
||||
nativePointToTextPoint,
|
||||
textPointToDomPoint,
|
||||
} from './point-conversion.js';
|
||||
import { calculateTextLength, getTextNodesFromElement } from './text.js';
|
||||
|
||||
type InlineRangeRunnerContext = {
|
||||
rootElement: HTMLElement;
|
||||
range: Range;
|
||||
yText: Y.Text;
|
||||
startNode: Node | null;
|
||||
startOffset: number;
|
||||
startText: Text;
|
||||
startTextOffset: number;
|
||||
endNode: Node | null;
|
||||
endOffset: number;
|
||||
endText: Text;
|
||||
endTextOffset: number;
|
||||
};
|
||||
|
||||
type Predict = (context: InlineRangeRunnerContext) => boolean;
|
||||
type Handler = (context: InlineRangeRunnerContext) => InlineRange | null;
|
||||
|
||||
const rangeHasAnchorAndFocus: Predict = ({
|
||||
rootElement,
|
||||
startText,
|
||||
endText,
|
||||
}) => {
|
||||
return rootElement.contains(startText) && rootElement.contains(endText);
|
||||
};
|
||||
|
||||
const rangeHasAnchorAndFocusHandler: Handler = ({
|
||||
rootElement,
|
||||
startText,
|
||||
endText,
|
||||
startTextOffset,
|
||||
endTextOffset,
|
||||
}) => {
|
||||
const anchorDomPoint = textPointToDomPoint(
|
||||
startText,
|
||||
startTextOffset,
|
||||
rootElement
|
||||
);
|
||||
const focusDomPoint = textPointToDomPoint(
|
||||
endText,
|
||||
endTextOffset,
|
||||
rootElement
|
||||
);
|
||||
|
||||
if (!anchorDomPoint || !focusDomPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
index: Math.min(anchorDomPoint.index, focusDomPoint.index),
|
||||
length: Math.abs(anchorDomPoint.index - focusDomPoint.index),
|
||||
};
|
||||
};
|
||||
|
||||
const rangeOnlyHasFocus: Predict = ({ rootElement, startText, endText }) => {
|
||||
return !rootElement.contains(startText) && rootElement.contains(endText);
|
||||
};
|
||||
|
||||
const rangeOnlyHasFocusHandler: Handler = ({
|
||||
rootElement,
|
||||
endText,
|
||||
endTextOffset,
|
||||
}) => {
|
||||
const focusDomPoint = textPointToDomPoint(
|
||||
endText,
|
||||
endTextOffset,
|
||||
rootElement
|
||||
);
|
||||
|
||||
if (!focusDomPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
index: 0,
|
||||
length: focusDomPoint.index,
|
||||
};
|
||||
};
|
||||
|
||||
const rangeOnlyHasAnchor: Predict = ({ rootElement, startText, endText }) => {
|
||||
return rootElement.contains(startText) && !rootElement.contains(endText);
|
||||
};
|
||||
|
||||
const rangeOnlyHasAnchorHandler: Handler = ({
|
||||
yText,
|
||||
rootElement,
|
||||
startText,
|
||||
startTextOffset,
|
||||
}) => {
|
||||
const startDomPoint = textPointToDomPoint(
|
||||
startText,
|
||||
startTextOffset,
|
||||
rootElement
|
||||
);
|
||||
|
||||
if (!startDomPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
index: startDomPoint.index,
|
||||
length: yText.length - startDomPoint.index,
|
||||
};
|
||||
};
|
||||
|
||||
const rangeHasNoAnchorAndFocus: Predict = ({
|
||||
rootElement,
|
||||
startText,
|
||||
endText,
|
||||
range,
|
||||
}) => {
|
||||
return (
|
||||
!rootElement.contains(startText) &&
|
||||
!rootElement.contains(endText) &&
|
||||
range.intersectsNode(rootElement)
|
||||
);
|
||||
};
|
||||
|
||||
const rangeHasNoAnchorAndFocusHandler: Handler = ({ yText }) => {
|
||||
return {
|
||||
index: 0,
|
||||
length: yText.length,
|
||||
};
|
||||
};
|
||||
|
||||
const buildContext = (
|
||||
range: Range,
|
||||
rootElement: HTMLElement,
|
||||
yText: Y.Text
|
||||
): InlineRangeRunnerContext | null => {
|
||||
const { startContainer, startOffset, endContainer, endOffset } = range;
|
||||
|
||||
const startTextPoint = nativePointToTextPoint(startContainer, startOffset);
|
||||
const endTextPoint = nativePointToTextPoint(endContainer, endOffset);
|
||||
|
||||
if (!startTextPoint || !endTextPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [startText, startTextOffset] = startTextPoint;
|
||||
const [endText, endTextOffset] = endTextPoint;
|
||||
|
||||
return {
|
||||
rootElement,
|
||||
range,
|
||||
yText,
|
||||
startNode: startContainer,
|
||||
startOffset,
|
||||
endNode: endContainer,
|
||||
endOffset,
|
||||
startText,
|
||||
startTextOffset,
|
||||
endText,
|
||||
endTextOffset,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* calculate the inline range from dom selection for **this Editor**
|
||||
* there are three cases when the inline range of this Editor is not null:
|
||||
* (In the following, "|" mean anchor and focus, each line is a separate Editor)
|
||||
* 1. anchor and focus are in this Editor
|
||||
* aaaaaa
|
||||
* b|bbbb|b
|
||||
* cccccc
|
||||
* the inline range of second Editor is {index: 1, length: 4}, the others are null
|
||||
* 2. anchor and focus one in this Editor, one in another Editor
|
||||
* aaa|aaa aaaaaa
|
||||
* bbbbb|b or bbbbb|b
|
||||
* cccccc cc|cccc
|
||||
* 2.1
|
||||
* the inline range of first Editor is {index: 3, length: 3}, the second is {index: 0, length: 5},
|
||||
* the third is null
|
||||
* 2.2
|
||||
* the inline range of first Editor is null, the second is {index: 5, length: 1},
|
||||
* the third is {index: 0, length: 2}
|
||||
* 3. anchor and focus are in another Editor
|
||||
* aa|aaaa
|
||||
* bbbbbb
|
||||
* cccc|cc
|
||||
* the inline range of first Editor is {index: 2, length: 4},
|
||||
* the second is {index: 0, length: 6}, the third is {index: 0, length: 4}
|
||||
*/
|
||||
export function domRangeToInlineRange(
|
||||
range: Range,
|
||||
rootElement: HTMLElement,
|
||||
yText: Y.Text
|
||||
): InlineRange | null {
|
||||
const context = buildContext(range, rootElement, yText);
|
||||
|
||||
if (!context) return null;
|
||||
|
||||
// handle embed
|
||||
if (
|
||||
context.startNode &&
|
||||
context.startNode === context.endNode &&
|
||||
isInEmbedElement(context.startNode)
|
||||
) {
|
||||
const anchorDomPoint = textPointToDomPoint(
|
||||
context.startText,
|
||||
context.startTextOffset,
|
||||
rootElement
|
||||
);
|
||||
|
||||
if (anchorDomPoint) {
|
||||
return {
|
||||
index: anchorDomPoint.index,
|
||||
length: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// case 1
|
||||
if (rangeHasAnchorAndFocus(context)) {
|
||||
return rangeHasAnchorAndFocusHandler(context);
|
||||
}
|
||||
|
||||
// case 2.1
|
||||
if (rangeOnlyHasFocus(context)) {
|
||||
return rangeOnlyHasFocusHandler(context);
|
||||
}
|
||||
|
||||
// case 2.2
|
||||
if (rangeOnlyHasAnchor(context)) {
|
||||
return rangeOnlyHasAnchorHandler(context);
|
||||
}
|
||||
|
||||
// case 3
|
||||
if (rangeHasNoAnchorAndFocus(context)) {
|
||||
return rangeHasNoAnchorAndFocusHandler(context);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* calculate the dom selection from inline range for **this Editor**
|
||||
*/
|
||||
export function inlineRangeToDomRange(
|
||||
rootElement: HTMLElement,
|
||||
inlineRange: InlineRange
|
||||
): Range | null {
|
||||
const lineElements = Array.from(rootElement.querySelectorAll('v-line'));
|
||||
|
||||
// calculate anchorNode and focusNode
|
||||
let startText: Text | null = null;
|
||||
let endText: Text | null = null;
|
||||
let anchorOffset = 0;
|
||||
let focusOffset = 0;
|
||||
let index = 0;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < lineElements.length; i++) {
|
||||
if (startText && endText) {
|
||||
break;
|
||||
}
|
||||
|
||||
const texts = getTextNodesFromElement(lineElements[i]);
|
||||
if (texts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const text of texts) {
|
||||
const textLength = calculateTextLength(text);
|
||||
|
||||
if (!startText && index + textLength >= inlineRange.index) {
|
||||
startText = text;
|
||||
anchorOffset = inlineRange.index - index;
|
||||
}
|
||||
if (
|
||||
!endText &&
|
||||
index + textLength >= inlineRange.index + inlineRange.length
|
||||
) {
|
||||
endText = text;
|
||||
focusOffset = inlineRange.index + inlineRange.length - index;
|
||||
}
|
||||
|
||||
if (startText && endText) {
|
||||
break;
|
||||
}
|
||||
|
||||
index += textLength;
|
||||
}
|
||||
|
||||
// the one because of the line break
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (!startText || !endText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isInEmbedElement(startText)) {
|
||||
const anchorVElement = startText.parentElement?.closest('v-element');
|
||||
if (!anchorVElement) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'failed to find vElement for a text note in an embed element'
|
||||
);
|
||||
}
|
||||
const nextSibling = anchorVElement.nextElementSibling;
|
||||
if (!nextSibling) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'failed to find nextSibling sibling of an embed element'
|
||||
);
|
||||
}
|
||||
|
||||
const texts = getTextNodesFromElement(nextSibling);
|
||||
if (texts.length === 0) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'text node in v-text not found'
|
||||
);
|
||||
}
|
||||
if (nextSibling instanceof VElement) {
|
||||
startText = texts[texts.length - 1];
|
||||
anchorOffset = calculateTextLength(startText);
|
||||
} else {
|
||||
// nextSibling is a gap
|
||||
startText = texts[0];
|
||||
anchorOffset = 0;
|
||||
}
|
||||
}
|
||||
if (isInEmbedElement(endText)) {
|
||||
const focusVElement = endText.parentElement?.closest('v-element');
|
||||
if (!focusVElement) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'failed to find vElement for a text note in an embed element'
|
||||
);
|
||||
}
|
||||
const nextSibling = focusVElement.nextElementSibling;
|
||||
if (!nextSibling) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'failed to find nextSibling sibling of an embed element'
|
||||
);
|
||||
}
|
||||
|
||||
const texts = getTextNodesFromElement(nextSibling);
|
||||
if (texts.length === 0) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'text node in v-text not found'
|
||||
);
|
||||
}
|
||||
endText = texts[0];
|
||||
focusOffset = 0;
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(startText, anchorOffset);
|
||||
range.setEnd(endText, focusOffset);
|
||||
|
||||
return range;
|
||||
}
|
||||
20
blocksuite/framework/inline/src/utils/renderer.ts
Normal file
20
blocksuite/framework/inline/src/utils/renderer.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { html, type TemplateResult } from 'lit';
|
||||
|
||||
import type { DeltaInsert } from '../types.js';
|
||||
import type { BaseTextAttributes } from './base-attributes.js';
|
||||
|
||||
export function renderElement<TextAttributes extends BaseTextAttributes>(
|
||||
delta: DeltaInsert<TextAttributes>,
|
||||
parseAttributes: (
|
||||
textAttributes?: TextAttributes
|
||||
) => TextAttributes | undefined,
|
||||
selected: boolean
|
||||
): TemplateResult<1> {
|
||||
return html`<v-element
|
||||
.selected=${selected}
|
||||
.delta=${{
|
||||
insert: delta.insert,
|
||||
attributes: parseAttributes(delta.attributes),
|
||||
}}
|
||||
></v-element>`;
|
||||
}
|
||||
25
blocksuite/framework/inline/src/utils/text.ts
Normal file
25
blocksuite/framework/inline/src/utils/text.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ZERO_WIDTH_SPACE } from '../consts.js';
|
||||
|
||||
export function calculateTextLength(text: Text): number {
|
||||
if (text.wholeText === ZERO_WIDTH_SPACE) {
|
||||
return 0;
|
||||
} else {
|
||||
return text.wholeText.length;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTextNodesFromElement(element: Element): Text[] {
|
||||
const textSpanElements = Array.from(
|
||||
element.querySelectorAll('[data-v-text="true"]')
|
||||
);
|
||||
const textNodes = textSpanElements.flatMap(textSpanElement => {
|
||||
const textNode = Array.from(textSpanElement.childNodes).find(
|
||||
(node): node is Text => node instanceof Text
|
||||
);
|
||||
if (!textNode) return [];
|
||||
|
||||
return textNode;
|
||||
});
|
||||
|
||||
return textNodes;
|
||||
}
|
||||
77
blocksuite/framework/inline/src/utils/transform-input.ts
Normal file
77
blocksuite/framework/inline/src/utils/transform-input.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
import type { InlineRange } from '../types.js';
|
||||
import type { BaseTextAttributes } from './base-attributes.js';
|
||||
|
||||
function handleInsertText<TextAttributes extends BaseTextAttributes>(
|
||||
inlineRange: InlineRange,
|
||||
data: string | null,
|
||||
editor: InlineEditor,
|
||||
attributes: TextAttributes
|
||||
) {
|
||||
if (!data) return;
|
||||
editor.insertText(inlineRange, data, attributes);
|
||||
editor.setInlineRange({
|
||||
index: inlineRange.index + data.length,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function handleInsertReplacementText<TextAttributes extends BaseTextAttributes>(
|
||||
inlineRange: InlineRange,
|
||||
data: string | null,
|
||||
editor: InlineEditor,
|
||||
attributes: TextAttributes
|
||||
) {
|
||||
editor.getDeltasByInlineRange(inlineRange).forEach(deltaEntry => {
|
||||
attributes = { ...deltaEntry[0].attributes, ...attributes };
|
||||
});
|
||||
if (data) {
|
||||
editor.insertText(inlineRange, data, attributes);
|
||||
editor.setInlineRange({
|
||||
index: inlineRange.index + data.length,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleInsertParagraph(inlineRange: InlineRange, editor: InlineEditor) {
|
||||
editor.insertLineBreak(inlineRange);
|
||||
editor.setInlineRange({
|
||||
index: inlineRange.index + 1,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(inlineRange: InlineRange, editor: InlineEditor) {
|
||||
editor.deleteText(inlineRange);
|
||||
editor.setInlineRange({
|
||||
index: inlineRange.index,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function transformInput<TextAttributes extends BaseTextAttributes>(
|
||||
inputType: string,
|
||||
data: string | null,
|
||||
attributes: TextAttributes,
|
||||
inlineRange: InlineRange,
|
||||
editor: InlineEditor
|
||||
) {
|
||||
if (!editor.isValidInlineRange(inlineRange)) return;
|
||||
|
||||
if (inputType === 'insertText') {
|
||||
handleInsertText(inlineRange, data, editor, attributes);
|
||||
} else if (
|
||||
inputType === 'insertParagraph' ||
|
||||
inputType === 'insertLineBreak'
|
||||
) {
|
||||
handleInsertParagraph(inlineRange, editor);
|
||||
} else if (inputType.startsWith('delete')) {
|
||||
handleDelete(inlineRange, editor);
|
||||
} else if (inputType === 'insertReplacementText') {
|
||||
// Spell Checker
|
||||
handleInsertReplacementText(inlineRange, data, editor, attributes);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
14
blocksuite/framework/inline/tsconfig.json
Normal file
14
blocksuite/framework/inline/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src/",
|
||||
"outDir": "./dist/",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../global"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
blocksuite/framework/inline/typedoc.json
Normal file
4
blocksuite/framework/inline/typedoc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["../../../typedoc.base.json"],
|
||||
"entryPoints": ["src/index.ts"]
|
||||
}
|
||||
28
blocksuite/framework/inline/vitest.config.ts
Normal file
28
blocksuite/framework/inline/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
esbuild: {
|
||||
target: 'es2018',
|
||||
},
|
||||
test: {
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 500,
|
||||
coverage: {
|
||||
provider: 'istanbul', // or 'c8'
|
||||
reporter: ['lcov'],
|
||||
reportsDirectory: '../../../.coverage/inline',
|
||||
},
|
||||
/**
|
||||
* Custom handler for console.log in tests.
|
||||
*
|
||||
* Return `false` to ignore the log.
|
||||
*/
|
||||
onConsoleLog(log, type) {
|
||||
if (log.includes('https://lit.dev/msg/dev-mode')) {
|
||||
return false;
|
||||
}
|
||||
console.warn(`Unexpected ${type} log`, log);
|
||||
throw new Error(log);
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user