mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
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>) {}
|
||||
}
|
||||
Reference in New Issue
Block a user