element-web/src/editor/model.ts

469 lines
18 KiB
TypeScript
Raw Normal View History

2019-05-06 16:21:28 +00:00
/*
Copyright 2019-2024 New Vector Ltd.
2019-05-22 14:16:32 +00:00
Copyright 2019 The Matrix.org Foundation C.I.C.
2019-05-06 16:21:28 +00:00
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
2019-05-06 16:21:28 +00:00
*/
import { diffAtCaret, diffDeletion, IDiff } from "./diff";
import DocumentPosition, { IPosition } from "./position";
import Range from "./range";
import { SerializedPart, Part, PartCreator } from "./parts";
import AutocompleteWrapperModel, { ICallback } from "./autocomplete";
import DocumentOffset from "./offset";
import { Caret } from "./caret";
2019-05-06 16:21:28 +00:00
2019-08-26 14:10:02 +00:00
/**
* @callback ModelCallback
* @param {DocumentPosition?} caretPosition the position where the caret should be position
* @param {string?} inputType the inputType of the DOM input event
* @param {object?} diff an object with `removed` and `added` strings
*/
2020-07-20 19:48:27 +00:00
/**
* @callback TransformCallback
* @param {DocumentPosition?} caretPosition the position where the caret should be position
* @param {string?} inputType the inputType of the DOM input event
* @param {object?} diff an object with `removed` and `added` strings
* @return {Number?} addedLen how many characters were added/removed (-) before the caret during the transformation step.
2019-08-27 07:54:13 +00:00
* This is used to adjust the caret position.
*/
/**
* @callback ManualTransformCallback
* @return the caret position
*/
type TransformCallback = (caretPosition: DocumentPosition, inputType: string | undefined, diff: IDiff) => number | void;
type UpdateCallback = (caret?: Caret, inputType?: string, diff?: IDiff) => void;
type ManualTransformCallback = () => Caret;
2019-05-06 16:21:28 +00:00
export default class EditorModel {
private _parts: Part[];
private readonly _partCreator: PartCreator;
private activePartIdx: number | null = null;
private _autoComplete: AutocompleteWrapperModel | null = null;
private autoCompletePartIdx: number | null = null;
private autoCompletePartCount = 0;
private transformCallback: TransformCallback | null = null;
public constructor(
parts: Part[],
partCreator: PartCreator,
private updateCallback: UpdateCallback | null = null,
) {
2019-05-06 16:21:28 +00:00
this._parts = parts;
this._partCreator = partCreator;
this.transformCallback = null;
}
2019-08-27 07:54:13 +00:00
/**
* Set a callback for the transformation step.
* While processing an update, right before calling the update callback,
* a transform callback can be called, which serves to do modifications
* on the model that can span multiple parts. Also see `startRange()`.
* @param {TransformCallback} transformCallback
*/
public setTransformCallback(transformCallback: TransformCallback): void {
this.transformCallback = transformCallback;
}
2019-08-27 07:54:13 +00:00
/**
* Set a callback for rerendering the model after it has been updated.
2019-08-26 14:10:02 +00:00
* @param {ModelCallback} updateCallback
*/
public setUpdateCallback(updateCallback: UpdateCallback): void {
this.updateCallback = updateCallback;
2019-05-06 16:21:28 +00:00
}
public get partCreator(): PartCreator {
return this._partCreator;
}
public get isEmpty(): boolean {
2019-08-06 15:52:47 +00:00
return this._parts.reduce((len, part) => len + part.text.length, 0) === 0;
}
public clone(): EditorModel {
const clonedParts = this.parts
.map((p) => this.partCreator.deserializePart(p.serialize()))
.filter((p): p is Part => Boolean(p));
return new EditorModel(clonedParts, this._partCreator, this.updateCallback);
}
private insertPart(index: number, part: Part): void {
2019-05-06 16:21:28 +00:00
this._parts.splice(index, 0, part);
if (this.activePartIdx !== null && this.activePartIdx >= index) {
++this.activePartIdx;
}
if (this.autoCompletePartIdx !== null && this.autoCompletePartIdx >= index) {
++this.autoCompletePartIdx;
}
2019-05-06 16:21:28 +00:00
}
private removePart(index: number): void {
2019-05-06 16:21:28 +00:00
this._parts.splice(index, 1);
if (index === this.activePartIdx) {
this.activePartIdx = null;
} else if (this.activePartIdx !== null && this.activePartIdx > index) {
--this.activePartIdx;
}
if (index === this.autoCompletePartIdx) {
this.autoCompletePartIdx = null;
} else if (this.autoCompletePartIdx !== null && this.autoCompletePartIdx > index) {
--this.autoCompletePartIdx;
}
2019-05-06 16:21:28 +00:00
}
private replacePart(index: number, part: Part): void {
2019-05-06 16:21:28 +00:00
this._parts.splice(index, 1, part);
}
public get parts(): Part[] {
2019-05-06 16:21:28 +00:00
return this._parts;
}
public get autoComplete(): AutocompleteWrapperModel | null {
if (this.activePartIdx === this.autoCompletePartIdx) {
return this._autoComplete;
}
return null;
}
public getPositionAtEnd(): DocumentPosition {
if (this._parts.length) {
const index = this._parts.length - 1;
const part = this._parts[index];
return new DocumentPosition(index, part.text.length);
} else {
// part index -1, as there are no parts to point at
return new DocumentPosition(-1, 0);
}
}
public serializeParts(): SerializedPart[] {
2022-12-12 11:24:14 +00:00
return this._parts.map((p) => p.serialize());
}
private diff(newValue: string, inputType: string | undefined, caret: DocumentOffset): IDiff {
const previousValue = this.parts.reduce((text, p) => text + p.text, "");
2019-05-08 09:13:36 +00:00
// can't use caret position with drag and drop
2019-05-06 16:21:28 +00:00
if (inputType === "deleteByDrag") {
return diffDeletion(previousValue, newValue);
2019-05-06 16:21:28 +00:00
} else {
return diffAtCaret(previousValue, newValue, caret.offset);
2019-05-06 16:21:28 +00:00
}
}
public reset(serializedParts: SerializedPart[], caret?: Caret, inputType?: string): void {
this._parts = serializedParts
.map((p) => this._partCreator.deserializePart(p))
.filter((p): p is Part => Boolean(p));
if (!caret) {
caret = this.getPositionAtEnd();
}
// close auto complete if open
// this would happen when clearing the composer after sending
// a message with the autocomplete still open
if (this._autoComplete) {
this._autoComplete = null;
this.autoCompletePartIdx = null;
}
this.updateCallback?.(caret, inputType);
}
/**
* Inserts the given parts at the given position.
* Should be run inside a `model.transform()` callback.
* @param {Part[]} parts the parts to replace the range with
* @param {DocumentPosition} position the position to start inserting at
* @return {Number} the amount of characters added
*/
public insert(parts: Part[], position: IPosition): number {
const insertIndex = this.splitAt(position);
let newTextLength = 0;
for (let i = 0; i < parts.length; ++i) {
const part = parts[i];
newTextLength += part.text.length;
this.insertPart(insertIndex + i, part);
}
return newTextLength;
}
public update(newValue: string, inputType: string | undefined, caret: DocumentOffset): Promise<void> {
const diff = this.diff(newValue, inputType, caret);
const position = this.positionForOffset(diff.at || 0, caret.atNodeEnd);
let removedOffsetDecrease = 0;
2019-05-06 16:21:28 +00:00
if (diff.removed) {
removedOffsetDecrease = this.removeText(position, diff.removed.length);
2019-05-06 16:21:28 +00:00
}
let addedLen = 0;
2019-05-06 16:21:28 +00:00
if (diff.added) {
addedLen = this.addText(position, diff.added, inputType);
2019-05-06 16:21:28 +00:00
}
this.mergeAdjacentParts();
const caretOffset = (diff.at || 0) - removedOffsetDecrease + addedLen;
let newPosition = this.positionForOffset(caretOffset, true);
const canOpenAutoComplete = inputType !== "insertFromPaste" && inputType !== "insertFromDrop";
const acPromise = this.setActivePart(newPosition, canOpenAutoComplete);
if (this.transformCallback) {
const transformAddedLen = this.getTransformAddedLen(newPosition, inputType, diff);
newPosition = this.positionForOffset(caretOffset + transformAddedLen, true);
}
this.updateCallback?.(newPosition, inputType, diff);
return acPromise;
}
private getTransformAddedLen(newPosition: DocumentPosition, inputType: string | undefined, diff: IDiff): number {
const result = this.transformCallback?.(newPosition, inputType, diff);
2022-12-12 11:24:14 +00:00
return Number.isFinite(result) ? (result as number) : 0;
}
private setActivePart(pos: DocumentPosition, canOpenAutoComplete: boolean): Promise<void> {
2021-06-29 12:11:58 +00:00
const { index } = pos;
const part = this._parts[index];
if (part) {
if (index !== this.activePartIdx) {
this.activePartIdx = index;
if (canOpenAutoComplete && this.activePartIdx !== this.autoCompletePartIdx) {
// else try to create one
const ac = part.createAutoComplete(this.onAutoComplete);
if (ac) {
// make sure that react picks up the difference between both acs
this._autoComplete = ac;
this.autoCompletePartIdx = index;
this.autoCompletePartCount = 1;
}
}
}
2021-05-20 21:25:19 +00:00
// not autoComplete, only there if active part is autocomplete part
if (this.autoComplete) {
return this.autoComplete.onPartUpdate(part, pos);
}
} else {
this.activePartIdx = null;
this._autoComplete = null;
this.autoCompletePartIdx = null;
this.autoCompletePartCount = 0;
}
return Promise.resolve();
}
private onAutoComplete = ({ replaceParts, close }: ICallback): void => {
let pos: DocumentPosition | undefined;
if (replaceParts) {
const autoCompletePartIdx = this.autoCompletePartIdx || 0;
this._parts.splice(autoCompletePartIdx, this.autoCompletePartCount, ...replaceParts);
this.autoCompletePartCount = replaceParts.length;
const lastPart = replaceParts[replaceParts.length - 1];
const lastPartIndex = autoCompletePartIdx + replaceParts.length - 1;
pos = new DocumentPosition(lastPartIndex, lastPart.text.length);
2019-05-09 14:07:00 +00:00
}
if (close) {
this._autoComplete = null;
this.autoCompletePartIdx = null;
this.autoCompletePartCount = 0;
}
// rerender even if editor contents didn't change
// to make sure the MessageEditor checks
// model.autoComplete being empty and closes it
this.updateCallback?.(pos);
};
private mergeAdjacentParts(): void {
let prevPart: Part | undefined;
for (let i = 0; i < this._parts.length; ++i) {
let part: Part | undefined = this._parts[i];
2019-05-06 16:21:28 +00:00
const isEmpty = !part.text.length;
const isMerged = !isEmpty && prevPart && prevPart.merge?.(part);
2019-05-06 16:21:28 +00:00
if (isEmpty || isMerged) {
// remove empty or merged part
part = prevPart;
this.removePart(i);
2019-05-06 16:21:28 +00:00
//repeat this index, as it's removed now
--i;
}
prevPart = part;
}
}
/**
* removes `len` amount of characters at `pos`.
2019-05-14 14:49:53 +00:00
* @param {Object} pos
* @param {Number} len
* @return {Number} how many characters before pos were also removed,
* usually because of non-editable parts that can only be removed in their entirety.
*/
public removeText(pos: IPosition, len: number): number {
2021-06-29 12:11:58 +00:00
let { index, offset } = pos;
let removedOffsetDecrease = 0;
while (len > 0) {
2019-05-06 16:21:28 +00:00
// part might be undefined here
let part = this._parts[index];
const amount = Math.min(len, part.text.length - offset);
// don't allow 0 amount deletions
if (amount) {
if (part.canEdit) {
const replaceWith = part.remove(offset, amount);
if (typeof replaceWith === "string") {
this.replacePart(index, this._partCreator.createDefaultPart(replaceWith));
}
part = this._parts[index];
// remove empty part
if (!part.text.length) {
this.removePart(index);
} else {
index += 1;
}
} else {
removedOffsetDecrease += offset;
this.removePart(index);
}
2019-05-06 16:21:28 +00:00
} else {
index += 1;
2019-05-06 16:21:28 +00:00
}
len -= amount;
offset = 0;
2019-05-06 16:21:28 +00:00
}
return removedOffsetDecrease;
2019-05-06 16:21:28 +00:00
}
// return part index where insertion will insert between at offset
private splitAt(pos: IPosition): number {
if (pos.index === -1) {
return 0;
}
if (pos.offset === 0) {
return pos.index;
}
const part = this._parts[pos.index];
if (pos.offset >= part.text.length) {
return pos.index + 1;
}
const secondPart = part.split(pos.offset);
this.insertPart(pos.index + 1, secondPart);
return pos.index + 1;
}
2019-05-06 16:21:28 +00:00
/**
* inserts `str` into the model at `pos`.
2019-05-14 14:49:53 +00:00
* @param {Object} pos
* @param {string} str
* @param {string} inputType the source of the input, see html InputEvent.inputType
* @return {Number} how far from position (in characters) the insertion ended.
* This can be more than the length of `str` when crossing non-editable parts, which are skipped.
*/
private addText(pos: IPosition, str: string, inputType: string | undefined): number {
2021-06-29 12:11:58 +00:00
let { index } = pos;
const { offset } = pos;
let addLen = str.length;
2019-05-06 16:21:28 +00:00
const part = this._parts[index];
let it: string | undefined = str;
2019-05-06 16:21:28 +00:00
if (part) {
if (part.canEdit) {
if (part.validateAndInsert(offset, str, inputType)) {
it = undefined;
} else {
const splitPart = part.split(offset);
index += 1;
this.insertPart(index, splitPart);
}
} else if (offset !== 0) {
// not-editable part, caret is not at start,
// so insert str after this part
addLen += part.text.length - offset;
2019-05-06 16:21:28 +00:00
index += 1;
}
} else if (index < 0) {
// if position was not found (index: -1, as happens for empty editor)
// reset it to insert as first part
index = 0;
2019-05-06 16:21:28 +00:00
}
while (it) {
const newPart = this._partCreator.createPartForInput(it, index, inputType);
const oldStr = it;
it = newPart.appendUntilRejected(it, inputType);
if (it === oldStr) {
// nothing changed, break out of this infinite loop and log an error
console.error(`Failed to update model for input (str ${it}) (type ${inputType})`);
break;
}
this.insertPart(index, newPart);
2019-05-06 16:21:28 +00:00
index += 1;
}
return addLen;
2019-05-06 16:21:28 +00:00
}
public positionForOffset(totalOffset: number, atPartEnd = false): DocumentPosition {
2019-05-06 16:21:28 +00:00
let currentOffset = 0;
2022-12-12 11:24:14 +00:00
const index = this._parts.findIndex((part) => {
2019-05-06 16:21:28 +00:00
const partLen = part.text.length;
if (
2022-12-12 11:24:14 +00:00
(atPartEnd && currentOffset + partLen >= totalOffset) ||
(!atPartEnd && currentOffset + partLen > totalOffset)
2019-05-06 16:21:28 +00:00
) {
return true;
}
currentOffset += partLen;
return false;
});
if (index === -1) {
return this.getPositionAtEnd();
} else {
return new DocumentPosition(index, totalOffset - currentOffset);
}
2019-05-06 16:21:28 +00:00
}
2019-08-26 14:10:02 +00:00
/**
* Starts a range, which can span across multiple parts, to find and replace text.
* @param {DocumentPosition} positionA a boundary of the range
* @param {DocumentPosition?} positionB the other boundary of the range, optional
2019-08-26 14:10:02 +00:00
* @return {Range}
*/
public startRange(positionA: DocumentPosition, positionB = positionA): Range {
return new Range(this, positionA, positionB);
2019-05-06 16:21:28 +00:00
}
public replaceRange(startPosition: DocumentPosition, endPosition: DocumentPosition, parts: Part[]): void {
// convert end position to offset, so it is independent of how the document is split into parts
// which we'll change when splitting up at the start position
const endOffset = endPosition.asOffset(this);
const newStartPartIndex = this.splitAt(startPosition);
// convert it back to position once split at start
endPosition = endOffset.asPosition(this);
const newEndPartIndex = this.splitAt(endPosition);
for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) {
this.removePart(i);
}
let insertIdx = newStartPartIndex;
for (const part of parts) {
this.insertPart(insertIdx, part);
insertIdx += 1;
}
this.mergeAdjacentParts();
}
/**
* Performs a transformation not part of an update cycle.
* Modifying the model should only happen inside a transform call if not part of an update call.
* @param {ManualTransformCallback} callback to run the transformations in
* @return {Promise} a promise when auto-complete (if applicable) is done updating
*/
public transform(callback: ManualTransformCallback): Promise<void> {
const pos = callback();
let acPromise: Promise<void> | null;
if (!(pos instanceof Range)) {
acPromise = this.setActivePart(pos, true);
} else {
acPromise = Promise.resolve();
}
this.updateCallback?.(pos);
return acPromise;
2019-05-06 16:21:28 +00:00
}
}