mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 20:38:52 +00:00
298 lines
8.8 KiB
TypeScript
298 lines
8.8 KiB
TypeScript
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 readonly _inlineRangeProviderOverride: boolean;
|
|
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._inlineRangeProviderOverride = false;
|
|
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);
|
|
}
|
|
}
|