Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into joriks/eslint-config
This commit is contained in:
commit
c0ce6e8161
16 changed files with 808 additions and 629 deletions
|
@ -1,121 +0,0 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export default class AutocompleteWrapperModel {
|
||||
constructor(updateCallback, getAutocompleterComponent, updateQuery, partCreator) {
|
||||
this._updateCallback = updateCallback;
|
||||
this._getAutocompleterComponent = getAutocompleterComponent;
|
||||
this._updateQuery = updateQuery;
|
||||
this._partCreator = partCreator;
|
||||
this._query = null;
|
||||
}
|
||||
|
||||
onEscape(e) {
|
||||
this._getAutocompleterComponent().onEscape(e);
|
||||
this._updateCallback({
|
||||
replaceParts: [this._partCreator.plain(this._queryPart.text)],
|
||||
close: true,
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this._updateCallback({close: true});
|
||||
}
|
||||
|
||||
hasSelection() {
|
||||
return this._getAutocompleterComponent().hasSelection();
|
||||
}
|
||||
|
||||
hasCompletions() {
|
||||
const ac = this._getAutocompleterComponent();
|
||||
return ac && ac.countCompletions() > 0;
|
||||
}
|
||||
|
||||
onEnter() {
|
||||
this._updateCallback({close: true});
|
||||
}
|
||||
|
||||
async onTab(e) {
|
||||
const acComponent = this._getAutocompleterComponent();
|
||||
|
||||
if (acComponent.countCompletions() === 0) {
|
||||
// Force completions to show for the text currently entered
|
||||
await acComponent.forceComplete();
|
||||
// Select the first item by moving "down"
|
||||
await acComponent.moveSelection(+1);
|
||||
} else {
|
||||
await acComponent.moveSelection(e.shiftKey ? -1 : +1);
|
||||
}
|
||||
}
|
||||
|
||||
onUpArrow() {
|
||||
this._getAutocompleterComponent().moveSelection(-1);
|
||||
}
|
||||
|
||||
onDownArrow() {
|
||||
this._getAutocompleterComponent().moveSelection(+1);
|
||||
}
|
||||
|
||||
onPartUpdate(part, pos) {
|
||||
// cache the typed value and caret here
|
||||
// so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text)
|
||||
this._queryPart = part;
|
||||
this._partIndex = pos.index;
|
||||
return this._updateQuery(part.text);
|
||||
}
|
||||
|
||||
onComponentSelectionChange(completion) {
|
||||
if (!completion) {
|
||||
this._updateCallback({
|
||||
replaceParts: [this._queryPart],
|
||||
});
|
||||
} else {
|
||||
this._updateCallback({
|
||||
replaceParts: this._partForCompletion(completion),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onComponentConfirm(completion) {
|
||||
this._updateCallback({
|
||||
replaceParts: this._partForCompletion(completion),
|
||||
close: true,
|
||||
});
|
||||
}
|
||||
|
||||
_partForCompletion(completion) {
|
||||
const {completionId} = completion;
|
||||
const text = completion.completion;
|
||||
switch (completion.type) {
|
||||
case "room":
|
||||
return [this._partCreator.roomPill(text, completionId), this._partCreator.plain(completion.suffix)];
|
||||
case "at-room":
|
||||
return [this._partCreator.atRoomPill(completionId), this._partCreator.plain(completion.suffix)];
|
||||
case "user":
|
||||
// not using suffix here, because we also need to calculate
|
||||
// the suffix when clicking a display name to insert a mention,
|
||||
// which happens in createMentionParts
|
||||
return this._partCreator.createMentionParts(this._partIndex, text, completionId);
|
||||
case "command":
|
||||
// command needs special handling for auto complete, but also renders as plain texts
|
||||
return [this._partCreator.command(text)];
|
||||
default:
|
||||
// used for emoji and other plain text completion replacement
|
||||
return [this._partCreator.plain(text)];
|
||||
}
|
||||
}
|
||||
}
|
140
src/editor/autocomplete.ts
Normal file
140
src/editor/autocomplete.ts
Normal file
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {KeyboardEvent} from "react";
|
||||
|
||||
import {Part, CommandPartCreator, PartCreator} from "./parts";
|
||||
import DocumentPosition from "./position";
|
||||
import {ICompletion} from "../autocomplete/Autocompleter";
|
||||
import Autocomplete from "../components/views/rooms/Autocomplete";
|
||||
|
||||
export interface ICallback {
|
||||
replaceParts?: Part[];
|
||||
close?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateCallback = (data: ICallback) => void;
|
||||
export type GetAutocompleterComponent = () => Autocomplete;
|
||||
export type UpdateQuery = (test: string) => Promise<void>;
|
||||
|
||||
export default class AutocompleteWrapperModel {
|
||||
private queryPart: Part;
|
||||
private partIndex: number;
|
||||
|
||||
constructor(
|
||||
private updateCallback: UpdateCallback,
|
||||
private getAutocompleterComponent: GetAutocompleterComponent,
|
||||
private updateQuery: UpdateQuery,
|
||||
private partCreator: PartCreator | CommandPartCreator,
|
||||
) {
|
||||
}
|
||||
|
||||
public onEscape(e: KeyboardEvent) {
|
||||
this.getAutocompleterComponent().onEscape(e);
|
||||
this.updateCallback({
|
||||
replaceParts: [this.partCreator.plain(this.queryPart.text)],
|
||||
close: true,
|
||||
});
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.updateCallback({close: true});
|
||||
}
|
||||
|
||||
public hasSelection() {
|
||||
return this.getAutocompleterComponent().hasSelection();
|
||||
}
|
||||
|
||||
public hasCompletions() {
|
||||
const ac = this.getAutocompleterComponent();
|
||||
return ac && ac.countCompletions() > 0;
|
||||
}
|
||||
|
||||
public onEnter() {
|
||||
this.updateCallback({close: true});
|
||||
}
|
||||
|
||||
public async onTab(e: KeyboardEvent) {
|
||||
const acComponent = this.getAutocompleterComponent();
|
||||
|
||||
if (acComponent.countCompletions() === 0) {
|
||||
// Force completions to show for the text currently entered
|
||||
await acComponent.forceComplete();
|
||||
// Select the first item by moving "down"
|
||||
await acComponent.moveSelection(+1);
|
||||
} else {
|
||||
await acComponent.moveSelection(e.shiftKey ? -1 : +1);
|
||||
}
|
||||
}
|
||||
|
||||
public onUpArrow(e: KeyboardEvent) {
|
||||
this.getAutocompleterComponent().moveSelection(-1);
|
||||
}
|
||||
|
||||
public onDownArrow(e: KeyboardEvent) {
|
||||
this.getAutocompleterComponent().moveSelection(+1);
|
||||
}
|
||||
|
||||
public onPartUpdate(part: Part, pos: DocumentPosition) {
|
||||
// cache the typed value and caret here
|
||||
// so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text)
|
||||
this.queryPart = part;
|
||||
this.partIndex = pos.index;
|
||||
return this.updateQuery(part.text);
|
||||
}
|
||||
|
||||
public onComponentSelectionChange(completion: ICompletion) {
|
||||
if (!completion) {
|
||||
this.updateCallback({
|
||||
replaceParts: [this.queryPart],
|
||||
});
|
||||
} else {
|
||||
this.updateCallback({
|
||||
replaceParts: this.partForCompletion(completion),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public onComponentConfirm(completion: ICompletion) {
|
||||
this.updateCallback({
|
||||
replaceParts: this.partForCompletion(completion),
|
||||
close: true,
|
||||
});
|
||||
}
|
||||
|
||||
private partForCompletion(completion: ICompletion) {
|
||||
const {completionId} = completion;
|
||||
const text = completion.completion;
|
||||
switch (completion.type) {
|
||||
case "room":
|
||||
return [this.partCreator.roomPill(text, completionId), this.partCreator.plain(completion.suffix)];
|
||||
case "at-room":
|
||||
return [this.partCreator.atRoomPill(completionId), this.partCreator.plain(completion.suffix)];
|
||||
case "user":
|
||||
// not using suffix here, because we also need to calculate
|
||||
// the suffix when clicking a display name to insert a mention,
|
||||
// which happens in createMentionParts
|
||||
return this.partCreator.createMentionParts(this.partIndex, text, completionId);
|
||||
case "command":
|
||||
// command needs special handling for auto complete, but also renders as plain texts
|
||||
return [(this.partCreator as CommandPartCreator).command(text)];
|
||||
default:
|
||||
// used for emoji and other plain text completion replacement
|
||||
return [this.partCreator.plain(text)];
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,8 +17,13 @@ limitations under the License.
|
|||
|
||||
import {needsCaretNodeBefore, needsCaretNodeAfter} from "./render";
|
||||
import Range from "./range";
|
||||
import EditorModel from "./model";
|
||||
import DocumentPosition, {IPosition} from "./position";
|
||||
import {Part} from "./parts";
|
||||
|
||||
export function setSelection(editor, model, selection) {
|
||||
export type Caret = Range | DocumentPosition;
|
||||
|
||||
export function setSelection(editor: HTMLDivElement, model: EditorModel, selection: Range | IPosition) {
|
||||
if (selection instanceof Range) {
|
||||
setDocumentRangeSelection(editor, model, selection);
|
||||
} else {
|
||||
|
@ -26,7 +31,7 @@ export function setSelection(editor, model, selection) {
|
|||
}
|
||||
}
|
||||
|
||||
function setDocumentRangeSelection(editor, model, range) {
|
||||
function setDocumentRangeSelection(editor: HTMLDivElement, model: EditorModel, range: Range) {
|
||||
const sel = document.getSelection();
|
||||
sel.removeAllRanges();
|
||||
const selectionRange = document.createRange();
|
||||
|
@ -37,7 +42,7 @@ function setDocumentRangeSelection(editor, model, range) {
|
|||
sel.addRange(selectionRange);
|
||||
}
|
||||
|
||||
export function setCaretPosition(editor, model, caretPosition) {
|
||||
export function setCaretPosition(editor: HTMLDivElement, model: EditorModel, caretPosition: IPosition) {
|
||||
const range = document.createRange();
|
||||
const {node, offset} = getNodeAndOffsetForPosition(editor, model, caretPosition);
|
||||
range.setStart(node, offset);
|
||||
|
@ -62,7 +67,7 @@ export function setCaretPosition(editor, model, caretPosition) {
|
|||
sel.addRange(range);
|
||||
}
|
||||
|
||||
function getNodeAndOffsetForPosition(editor, model, position) {
|
||||
function getNodeAndOffsetForPosition(editor: HTMLDivElement, model: EditorModel, position: IPosition) {
|
||||
const {offset, lineIndex, nodeIndex} = getLineAndNodePosition(model, position);
|
||||
const lineNode = editor.childNodes[lineIndex];
|
||||
|
||||
|
@ -80,7 +85,7 @@ function getNodeAndOffsetForPosition(editor, model, position) {
|
|||
return {node: focusNode, offset};
|
||||
}
|
||||
|
||||
export function getLineAndNodePosition(model, caretPosition) {
|
||||
export function getLineAndNodePosition(model: EditorModel, caretPosition: IPosition) {
|
||||
const {parts} = model;
|
||||
const partIndex = caretPosition.index;
|
||||
const lineResult = findNodeInLineForPart(parts, partIndex);
|
||||
|
@ -99,7 +104,7 @@ export function getLineAndNodePosition(model, caretPosition) {
|
|||
return {lineIndex, nodeIndex, offset};
|
||||
}
|
||||
|
||||
function findNodeInLineForPart(parts, partIndex) {
|
||||
function findNodeInLineForPart(parts: Part[], partIndex: number) {
|
||||
let lineIndex = 0;
|
||||
let nodeIndex = -1;
|
||||
|
||||
|
@ -135,7 +140,7 @@ function findNodeInLineForPart(parts, partIndex) {
|
|||
return {lineIndex, nodeIndex};
|
||||
}
|
||||
|
||||
function moveOutOfUneditablePart(parts, partIndex, nodeIndex, offset) {
|
||||
function moveOutOfUneditablePart(parts: Part[], partIndex: number, nodeIndex: number, offset: number) {
|
||||
// move caret before or after uneditable part
|
||||
const part = parts[partIndex];
|
||||
if (part && !part.canEdit) {
|
|
@ -257,7 +257,7 @@ function parseHtmlMessage(html: string, partCreator: PartCreator, isQuotedMessag
|
|||
return parts;
|
||||
}
|
||||
|
||||
export function parsePlainTextMessage(body: string, partCreator: PartCreator, isQuotedMessage: boolean) {
|
||||
export function parsePlainTextMessage(body: string, partCreator: PartCreator, isQuotedMessage?: boolean) {
|
||||
const lines = body.split(/\r\n|\r|\n/g); // split on any new-line combination not just \n, collapses \r\n
|
||||
return lines.reduce((parts, line, i) => {
|
||||
if (isQuotedMessage) {
|
||||
|
|
|
@ -15,7 +15,13 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
function firstDiff(a, b) {
|
||||
export interface IDiff {
|
||||
removed?: string;
|
||||
added?: string;
|
||||
at?: number;
|
||||
}
|
||||
|
||||
function firstDiff(a: string, b: string) {
|
||||
const compareLen = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < compareLen; ++i) {
|
||||
if (a[i] !== b[i]) {
|
||||
|
@ -25,7 +31,7 @@ function firstDiff(a, b) {
|
|||
return compareLen;
|
||||
}
|
||||
|
||||
function diffStringsAtEnd(oldStr, newStr) {
|
||||
function diffStringsAtEnd(oldStr: string, newStr: string): IDiff {
|
||||
const len = Math.min(oldStr.length, newStr.length);
|
||||
const startInCommon = oldStr.substr(0, len) === newStr.substr(0, len);
|
||||
if (startInCommon && oldStr.length > newStr.length) {
|
||||
|
@ -43,7 +49,7 @@ function diffStringsAtEnd(oldStr, newStr) {
|
|||
}
|
||||
|
||||
// assumes only characters have been deleted at one location in the string, and none added
|
||||
export function diffDeletion(oldStr, newStr) {
|
||||
export function diffDeletion(oldStr: string, newStr: string): IDiff {
|
||||
if (oldStr === newStr) {
|
||||
return {};
|
||||
}
|
||||
|
@ -61,7 +67,7 @@ export function diffDeletion(oldStr, newStr) {
|
|||
* `added` with the added string (if any), and
|
||||
* `removed` with the removed string (if any)
|
||||
*/
|
||||
export function diffAtCaret(oldValue, newValue, caretPosition) {
|
||||
export function diffAtCaret(oldValue: string, newValue: string, caretPosition: number): IDiff {
|
||||
const diffLen = newValue.length - oldValue.length;
|
||||
const caretPositionBeforeInput = caretPosition - diffLen;
|
||||
const oldValueBeforeCaret = oldValue.substr(0, caretPositionBeforeInput);
|
|
@ -17,8 +17,12 @@ limitations under the License.
|
|||
|
||||
import {CARET_NODE_CHAR, isCaretNode} from "./render";
|
||||
import DocumentOffset from "./offset";
|
||||
import EditorModel from "./model";
|
||||
|
||||
export function walkDOMDepthFirst(rootNode, enterNodeCallback, leaveNodeCallback) {
|
||||
type Predicate = (node: Node) => boolean;
|
||||
type Callback = (node: Node) => void;
|
||||
|
||||
export function walkDOMDepthFirst(rootNode: Node, enterNodeCallback: Predicate, leaveNodeCallback: Callback) {
|
||||
let node = rootNode.firstChild;
|
||||
while (node && node !== rootNode) {
|
||||
const shouldDescend = enterNodeCallback(node);
|
||||
|
@ -40,12 +44,12 @@ export function walkDOMDepthFirst(rootNode, enterNodeCallback, leaveNodeCallback
|
|||
}
|
||||
}
|
||||
|
||||
export function getCaretOffsetAndText(editor, sel) {
|
||||
export function getCaretOffsetAndText(editor: HTMLDivElement, sel: Selection) {
|
||||
const {offset, text} = getSelectionOffsetAndText(editor, sel.focusNode, sel.focusOffset);
|
||||
return {caret: offset, text};
|
||||
}
|
||||
|
||||
function tryReduceSelectionToTextNode(selectionNode, selectionOffset) {
|
||||
function tryReduceSelectionToTextNode(selectionNode: Node, selectionOffset: number) {
|
||||
// if selectionNode is an element, the selected location comes after the selectionOffset-th child node,
|
||||
// which can point past any childNode, in which case, the end of selectionNode is selected.
|
||||
// we try to simplify this to point at a text node with the offset being
|
||||
|
@ -82,7 +86,7 @@ function tryReduceSelectionToTextNode(selectionNode, selectionOffset) {
|
|||
};
|
||||
}
|
||||
|
||||
function getSelectionOffsetAndText(editor, selectionNode, selectionOffset) {
|
||||
function getSelectionOffsetAndText(editor: HTMLDivElement, selectionNode: Node, selectionOffset: number) {
|
||||
const {node, characterOffset} = tryReduceSelectionToTextNode(selectionNode, selectionOffset);
|
||||
const {text, offsetToNode} = getTextAndOffsetToNode(editor, node);
|
||||
const offset = getCaret(node, offsetToNode, characterOffset);
|
||||
|
@ -91,7 +95,7 @@ function getSelectionOffsetAndText(editor, selectionNode, selectionOffset) {
|
|||
|
||||
// gets the caret position details, ignoring and adjusting to
|
||||
// the ZWS if you're typing in a caret node
|
||||
function getCaret(node, offsetToNode, offsetWithinNode) {
|
||||
function getCaret(node: Node, offsetToNode: number, offsetWithinNode: number) {
|
||||
// if no node is selected, return an offset at the start
|
||||
if (!node) {
|
||||
return new DocumentOffset(0, false);
|
||||
|
@ -114,7 +118,7 @@ function getCaret(node, offsetToNode, offsetWithinNode) {
|
|||
// gets the text of the editor as a string,
|
||||
// and the offset in characters where the selectionNode starts in that string
|
||||
// all ZWS from caret nodes are filtered out
|
||||
function getTextAndOffsetToNode(editor, selectionNode) {
|
||||
function getTextAndOffsetToNode(editor: HTMLDivElement, selectionNode: Node) {
|
||||
let offsetToNode = 0;
|
||||
let foundNode = false;
|
||||
let text = "";
|
|
@ -14,25 +14,40 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import EditorModel from "./model";
|
||||
import {IDiff} from "./diff";
|
||||
import {SerializedPart} from "./parts";
|
||||
import {Caret} from "./caret";
|
||||
|
||||
interface IHistory {
|
||||
parts: SerializedPart[];
|
||||
caret: Caret;
|
||||
}
|
||||
|
||||
export const MAX_STEP_LENGTH = 10;
|
||||
|
||||
export default class HistoryManager {
|
||||
constructor() {
|
||||
this.clear();
|
||||
}
|
||||
private stack: IHistory[] = [];
|
||||
private newlyTypedCharCount = 0;
|
||||
private currentIndex = -1;
|
||||
private changedSinceLastPush = false;
|
||||
private lastCaret: Caret = null;
|
||||
private nonWordBoundarySinceLastPush = false;
|
||||
private addedSinceLastPush = false;
|
||||
private removedSinceLastPush = false;
|
||||
|
||||
clear() {
|
||||
this._stack = [];
|
||||
this._newlyTypedCharCount = 0;
|
||||
this._currentIndex = -1;
|
||||
this._changedSinceLastPush = false;
|
||||
this._lastCaret = null;
|
||||
this._nonWordBoundarySinceLastPush = false;
|
||||
this._addedSinceLastPush = false;
|
||||
this._removedSinceLastPush = false;
|
||||
this.stack = [];
|
||||
this.newlyTypedCharCount = 0;
|
||||
this.currentIndex = -1;
|
||||
this.changedSinceLastPush = false;
|
||||
this.lastCaret = null;
|
||||
this.nonWordBoundarySinceLastPush = false;
|
||||
this.addedSinceLastPush = false;
|
||||
this.removedSinceLastPush = false;
|
||||
}
|
||||
|
||||
_shouldPush(inputType, diff) {
|
||||
private shouldPush(inputType, diff) {
|
||||
// right now we can only push a step after
|
||||
// the input has been applied to the model,
|
||||
// so we can't push the state before something happened.
|
||||
|
@ -43,24 +58,24 @@ export default class HistoryManager {
|
|||
inputType === "deleteContentBackward";
|
||||
if (diff && isNonBulkInput) {
|
||||
if (diff.added) {
|
||||
this._addedSinceLastPush = true;
|
||||
this.addedSinceLastPush = true;
|
||||
}
|
||||
if (diff.removed) {
|
||||
this._removedSinceLastPush = true;
|
||||
this.removedSinceLastPush = true;
|
||||
}
|
||||
// as long as you've only been adding or removing since the last push
|
||||
if (this._addedSinceLastPush !== this._removedSinceLastPush) {
|
||||
if (this.addedSinceLastPush !== this.removedSinceLastPush) {
|
||||
// add steps by word boundary, up to MAX_STEP_LENGTH characters
|
||||
const str = diff.added ? diff.added : diff.removed;
|
||||
const isWordBoundary = str === " " || str === "\t" || str === "\n";
|
||||
if (this._nonWordBoundarySinceLastPush && isWordBoundary) {
|
||||
if (this.nonWordBoundarySinceLastPush && isWordBoundary) {
|
||||
return true;
|
||||
}
|
||||
if (!isWordBoundary) {
|
||||
this._nonWordBoundarySinceLastPush = true;
|
||||
this.nonWordBoundarySinceLastPush = true;
|
||||
}
|
||||
this._newlyTypedCharCount += str.length;
|
||||
return this._newlyTypedCharCount > MAX_STEP_LENGTH;
|
||||
this.newlyTypedCharCount += str.length;
|
||||
return this.newlyTypedCharCount > MAX_STEP_LENGTH;
|
||||
} else {
|
||||
// if starting to remove while adding before, or the opposite, push
|
||||
return true;
|
||||
|
@ -71,24 +86,24 @@ export default class HistoryManager {
|
|||
}
|
||||
}
|
||||
|
||||
_pushState(model, caret) {
|
||||
private pushState(model: EditorModel, caret: Caret) {
|
||||
// remove all steps after current step
|
||||
while (this._currentIndex < (this._stack.length - 1)) {
|
||||
this._stack.pop();
|
||||
while (this.currentIndex < (this.stack.length - 1)) {
|
||||
this.stack.pop();
|
||||
}
|
||||
const parts = model.serializeParts();
|
||||
this._stack.push({parts, caret});
|
||||
this._currentIndex = this._stack.length - 1;
|
||||
this._lastCaret = null;
|
||||
this._changedSinceLastPush = false;
|
||||
this._newlyTypedCharCount = 0;
|
||||
this._nonWordBoundarySinceLastPush = false;
|
||||
this._addedSinceLastPush = false;
|
||||
this._removedSinceLastPush = false;
|
||||
this.stack.push({parts, caret});
|
||||
this.currentIndex = this.stack.length - 1;
|
||||
this.lastCaret = null;
|
||||
this.changedSinceLastPush = false;
|
||||
this.newlyTypedCharCount = 0;
|
||||
this.nonWordBoundarySinceLastPush = false;
|
||||
this.addedSinceLastPush = false;
|
||||
this.removedSinceLastPush = false;
|
||||
}
|
||||
|
||||
// needs to persist parts and caret position
|
||||
tryPush(model, caret, inputType, diff) {
|
||||
tryPush(model: EditorModel, caret: Caret, inputType: string, diff: IDiff) {
|
||||
// ignore state restoration echos.
|
||||
// these respect the inputType values of the input event,
|
||||
// but are actually passed in from MessageEditor calling model.reset()
|
||||
|
@ -96,45 +111,45 @@ export default class HistoryManager {
|
|||
if (inputType === "historyUndo" || inputType === "historyRedo") {
|
||||
return false;
|
||||
}
|
||||
const shouldPush = this._shouldPush(inputType, diff);
|
||||
const shouldPush = this.shouldPush(inputType, diff);
|
||||
if (shouldPush) {
|
||||
this._pushState(model, caret);
|
||||
this.pushState(model, caret);
|
||||
} else {
|
||||
this._lastCaret = caret;
|
||||
this._changedSinceLastPush = true;
|
||||
this.lastCaret = caret;
|
||||
this.changedSinceLastPush = true;
|
||||
}
|
||||
return shouldPush;
|
||||
}
|
||||
|
||||
ensureLastChangesPushed(model) {
|
||||
if (this._changedSinceLastPush) {
|
||||
this._pushState(model, this._lastCaret);
|
||||
ensureLastChangesPushed(model: EditorModel) {
|
||||
if (this.changedSinceLastPush) {
|
||||
this.pushState(model, this.lastCaret);
|
||||
}
|
||||
}
|
||||
|
||||
canUndo() {
|
||||
return this._currentIndex >= 1 || this._changedSinceLastPush;
|
||||
return this.currentIndex >= 1 || this.changedSinceLastPush;
|
||||
}
|
||||
|
||||
canRedo() {
|
||||
return this._currentIndex < (this._stack.length - 1);
|
||||
return this.currentIndex < (this.stack.length - 1);
|
||||
}
|
||||
|
||||
// returns state that should be applied to model
|
||||
undo(model) {
|
||||
undo(model: EditorModel) {
|
||||
if (this.canUndo()) {
|
||||
this.ensureLastChangesPushed(model);
|
||||
this._currentIndex -= 1;
|
||||
return this._stack[this._currentIndex];
|
||||
this.currentIndex -= 1;
|
||||
return this.stack[this.currentIndex];
|
||||
}
|
||||
}
|
||||
|
||||
// returns state that should be applied to model
|
||||
redo() {
|
||||
if (this.canRedo()) {
|
||||
this._changedSinceLastPush = false;
|
||||
this._currentIndex += 1;
|
||||
return this._stack[this._currentIndex];
|
||||
this.changedSinceLastPush = false;
|
||||
this.currentIndex += 1;
|
||||
return this.stack[this.currentIndex];
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,9 +15,13 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {diffAtCaret, diffDeletion} from "./diff";
|
||||
import DocumentPosition from "./position";
|
||||
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";
|
||||
|
||||
/**
|
||||
* @callback ModelCallback
|
||||
|
@ -40,16 +44,23 @@ import Range from "./range";
|
|||
* @return the caret position
|
||||
*/
|
||||
|
||||
type TransformCallback = (caretPosition: DocumentPosition, inputType: string, diff: IDiff) => number | void;
|
||||
type UpdateCallback = (caret: Caret, inputType?: string, diff?: IDiff) => void;
|
||||
type ManualTransformCallback = () => Caret;
|
||||
|
||||
export default class EditorModel {
|
||||
constructor(parts, partCreator, updateCallback = null) {
|
||||
private _parts: Part[];
|
||||
private readonly _partCreator: PartCreator;
|
||||
private activePartIdx: number = null;
|
||||
private _autoComplete: AutocompleteWrapperModel = null;
|
||||
private autoCompletePartIdx: number = null;
|
||||
private autoCompletePartCount = 0;
|
||||
private transformCallback: TransformCallback = null;
|
||||
|
||||
constructor(parts: Part[], partCreator: PartCreator, private updateCallback: UpdateCallback = null) {
|
||||
this._parts = parts;
|
||||
this._partCreator = partCreator;
|
||||
this._activePartIdx = null;
|
||||
this._autoComplete = null;
|
||||
this._autoCompletePartIdx = null;
|
||||
this._autoCompletePartCount = 0;
|
||||
this._transformCallback = null;
|
||||
this.setUpdateCallback(updateCallback);
|
||||
this.transformCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -59,16 +70,16 @@ export default class EditorModel {
|
|||
* on the model that can span multiple parts. Also see `startRange()`.
|
||||
* @param {TransformCallback} transformCallback
|
||||
*/
|
||||
setTransformCallback(transformCallback) {
|
||||
this._transformCallback = transformCallback;
|
||||
setTransformCallback(transformCallback: TransformCallback) {
|
||||
this.transformCallback = transformCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback for rerendering the model after it has been updated.
|
||||
* @param {ModelCallback} updateCallback
|
||||
*/
|
||||
setUpdateCallback(updateCallback) {
|
||||
this._updateCallback = updateCallback;
|
||||
setUpdateCallback(updateCallback: UpdateCallback) {
|
||||
this.updateCallback = updateCallback;
|
||||
}
|
||||
|
||||
get partCreator() {
|
||||
|
@ -80,34 +91,34 @@ export default class EditorModel {
|
|||
}
|
||||
|
||||
clone() {
|
||||
return new EditorModel(this._parts, this._partCreator, this._updateCallback);
|
||||
return new EditorModel(this._parts, this._partCreator, this.updateCallback);
|
||||
}
|
||||
|
||||
_insertPart(index, part) {
|
||||
private insertPart(index: number, part: Part) {
|
||||
this._parts.splice(index, 0, part);
|
||||
if (this._activePartIdx >= index) {
|
||||
++this._activePartIdx;
|
||||
if (this.activePartIdx >= index) {
|
||||
++this.activePartIdx;
|
||||
}
|
||||
if (this._autoCompletePartIdx >= index) {
|
||||
++this._autoCompletePartIdx;
|
||||
if (this.autoCompletePartIdx >= index) {
|
||||
++this.autoCompletePartIdx;
|
||||
}
|
||||
}
|
||||
|
||||
_removePart(index) {
|
||||
private removePart(index: number) {
|
||||
this._parts.splice(index, 1);
|
||||
if (index === this._activePartIdx) {
|
||||
this._activePartIdx = null;
|
||||
} else if (this._activePartIdx > index) {
|
||||
--this._activePartIdx;
|
||||
if (index === this.activePartIdx) {
|
||||
this.activePartIdx = null;
|
||||
} else if (this.activePartIdx > index) {
|
||||
--this.activePartIdx;
|
||||
}
|
||||
if (index === this._autoCompletePartIdx) {
|
||||
this._autoCompletePartIdx = null;
|
||||
} else if (this._autoCompletePartIdx > index) {
|
||||
--this._autoCompletePartIdx;
|
||||
if (index === this.autoCompletePartIdx) {
|
||||
this.autoCompletePartIdx = null;
|
||||
} else if (this.autoCompletePartIdx > index) {
|
||||
--this.autoCompletePartIdx;
|
||||
}
|
||||
}
|
||||
|
||||
_replacePart(index, part) {
|
||||
private replacePart(index: number, part: Part) {
|
||||
this._parts.splice(index, 1, part);
|
||||
}
|
||||
|
||||
|
@ -116,7 +127,7 @@ export default class EditorModel {
|
|||
}
|
||||
|
||||
get autoComplete() {
|
||||
if (this._activePartIdx === this._autoCompletePartIdx) {
|
||||
if (this.activePartIdx === this.autoCompletePartIdx) {
|
||||
return this._autoComplete;
|
||||
}
|
||||
return null;
|
||||
|
@ -137,7 +148,7 @@ export default class EditorModel {
|
|||
return this._parts.map(p => p.serialize());
|
||||
}
|
||||
|
||||
_diff(newValue, inputType, caret) {
|
||||
private diff(newValue: string, inputType: string, caret: DocumentOffset) {
|
||||
const previousValue = this.parts.reduce((text, p) => text + p.text, "");
|
||||
// can't use caret position with drag and drop
|
||||
if (inputType === "deleteByDrag") {
|
||||
|
@ -147,7 +158,7 @@ export default class EditorModel {
|
|||
}
|
||||
}
|
||||
|
||||
reset(serializedParts, caret, inputType) {
|
||||
reset(serializedParts: SerializedPart[], caret: Caret, inputType: string) {
|
||||
this._parts = serializedParts.map(p => this._partCreator.deserializePart(p));
|
||||
if (!caret) {
|
||||
caret = this.getPositionAtEnd();
|
||||
|
@ -157,9 +168,9 @@ export default class EditorModel {
|
|||
// a message with the autocomplete still open
|
||||
if (this._autoComplete) {
|
||||
this._autoComplete = null;
|
||||
this._autoCompletePartIdx = null;
|
||||
this.autoCompletePartIdx = null;
|
||||
}
|
||||
this._updateCallback(caret, inputType);
|
||||
this.updateCallback(caret, inputType);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -169,19 +180,19 @@ export default class EditorModel {
|
|||
* @param {DocumentPosition} position the position to start inserting at
|
||||
* @return {Number} the amount of characters added
|
||||
*/
|
||||
insert(parts, position) {
|
||||
const insertIndex = this._splitAt(position);
|
||||
insert(parts: Part[], position: IPosition) {
|
||||
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);
|
||||
this.insertPart(insertIndex + i, part);
|
||||
}
|
||||
return newTextLength;
|
||||
}
|
||||
|
||||
update(newValue, inputType, caret) {
|
||||
const diff = this._diff(newValue, inputType, caret);
|
||||
update(newValue: string, inputType: string, caret: DocumentOffset) {
|
||||
const diff = this.diff(newValue, inputType, caret);
|
||||
const position = this.positionForOffset(diff.at, caret.atNodeEnd);
|
||||
let removedOffsetDecrease = 0;
|
||||
if (diff.removed) {
|
||||
|
@ -189,40 +200,40 @@ export default class EditorModel {
|
|||
}
|
||||
let addedLen = 0;
|
||||
if (diff.added) {
|
||||
addedLen = this._addText(position, diff.added, inputType);
|
||||
addedLen = this.addText(position, diff.added, inputType);
|
||||
}
|
||||
this._mergeAdjacentParts();
|
||||
this.mergeAdjacentParts();
|
||||
const caretOffset = diff.at - 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._transform(newPosition, inputType, diff);
|
||||
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);
|
||||
this.updateCallback(newPosition, inputType, diff);
|
||||
return acPromise;
|
||||
}
|
||||
|
||||
_transform(newPosition, inputType, diff) {
|
||||
const result = this._transformCallback(newPosition, inputType, diff);
|
||||
return Number.isFinite(result) ? result : 0;
|
||||
private getTransformAddedLen(newPosition: DocumentPosition, inputType: string, diff: IDiff): number {
|
||||
const result = this.transformCallback(newPosition, inputType, diff);
|
||||
return Number.isFinite(result) ? result as number : 0;
|
||||
}
|
||||
|
||||
_setActivePart(pos, canOpenAutoComplete) {
|
||||
private setActivePart(pos: DocumentPosition, canOpenAutoComplete: boolean) {
|
||||
const {index} = pos;
|
||||
const part = this._parts[index];
|
||||
if (part) {
|
||||
if (index !== this._activePartIdx) {
|
||||
this._activePartIdx = index;
|
||||
if (canOpenAutoComplete && this._activePartIdx !== this._autoCompletePartIdx) {
|
||||
if (index !== this.activePartIdx) {
|
||||
this.activePartIdx = index;
|
||||
if (canOpenAutoComplete && this.activePartIdx !== this.autoCompletePartIdx) {
|
||||
// else try to create one
|
||||
const ac = part.createAutoComplete(this._onAutoComplete);
|
||||
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;
|
||||
this.autoCompletePartIdx = index;
|
||||
this.autoCompletePartCount = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -231,35 +242,35 @@ export default class EditorModel {
|
|||
return this.autoComplete.onPartUpdate(part, pos);
|
||||
}
|
||||
} else {
|
||||
this._activePartIdx = null;
|
||||
this.activePartIdx = null;
|
||||
this._autoComplete = null;
|
||||
this._autoCompletePartIdx = null;
|
||||
this._autoCompletePartCount = 0;
|
||||
this.autoCompletePartIdx = null;
|
||||
this.autoCompletePartCount = 0;
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
_onAutoComplete = ({replaceParts, close}) => {
|
||||
private onAutoComplete = ({replaceParts, close}: ICallback) => {
|
||||
let pos;
|
||||
if (replaceParts) {
|
||||
this._parts.splice(this._autoCompletePartIdx, this._autoCompletePartCount, ...replaceParts);
|
||||
this._autoCompletePartCount = replaceParts.length;
|
||||
this._parts.splice(this.autoCompletePartIdx, this.autoCompletePartCount, ...replaceParts);
|
||||
this.autoCompletePartCount = replaceParts.length;
|
||||
const lastPart = replaceParts[replaceParts.length - 1];
|
||||
const lastPartIndex = this._autoCompletePartIdx + replaceParts.length - 1;
|
||||
const lastPartIndex = this.autoCompletePartIdx + replaceParts.length - 1;
|
||||
pos = new DocumentPosition(lastPartIndex, lastPart.text.length);
|
||||
}
|
||||
if (close) {
|
||||
this._autoComplete = null;
|
||||
this._autoCompletePartIdx = null;
|
||||
this._autoCompletePartCount = 0;
|
||||
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);
|
||||
}
|
||||
this.updateCallback(pos);
|
||||
};
|
||||
|
||||
_mergeAdjacentParts() {
|
||||
private mergeAdjacentParts() {
|
||||
let prevPart;
|
||||
for (let i = 0; i < this._parts.length; ++i) {
|
||||
let part = this._parts[i];
|
||||
|
@ -268,7 +279,7 @@ export default class EditorModel {
|
|||
if (isEmpty || isMerged) {
|
||||
// remove empty or merged part
|
||||
part = prevPart;
|
||||
this._removePart(i);
|
||||
this.removePart(i);
|
||||
//repeat this index, as it's removed now
|
||||
--i;
|
||||
}
|
||||
|
@ -283,7 +294,7 @@ export default class EditorModel {
|
|||
* @return {Number} how many characters before pos were also removed,
|
||||
* usually because of non-editable parts that can only be removed in their entirety.
|
||||
*/
|
||||
removeText(pos, len) {
|
||||
removeText(pos: IPosition, len: number) {
|
||||
let {index, offset} = pos;
|
||||
let removedOffsetDecrease = 0;
|
||||
while (len > 0) {
|
||||
|
@ -295,18 +306,18 @@ export default class EditorModel {
|
|||
if (part.canEdit) {
|
||||
const replaceWith = part.remove(offset, amount);
|
||||
if (typeof replaceWith === "string") {
|
||||
this._replacePart(index, this._partCreator.createDefaultPart(replaceWith));
|
||||
this.replacePart(index, this._partCreator.createDefaultPart(replaceWith));
|
||||
}
|
||||
part = this._parts[index];
|
||||
// remove empty part
|
||||
if (!part.text.length) {
|
||||
this._removePart(index);
|
||||
this.removePart(index);
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
} else {
|
||||
removedOffsetDecrease += offset;
|
||||
this._removePart(index);
|
||||
this.removePart(index);
|
||||
}
|
||||
} else {
|
||||
index += 1;
|
||||
|
@ -316,8 +327,9 @@ export default class EditorModel {
|
|||
}
|
||||
return removedOffsetDecrease;
|
||||
}
|
||||
|
||||
// return part index where insertion will insert between at offset
|
||||
_splitAt(pos) {
|
||||
private splitAt(pos: IPosition) {
|
||||
if (pos.index === -1) {
|
||||
return 0;
|
||||
}
|
||||
|
@ -330,7 +342,7 @@ export default class EditorModel {
|
|||
}
|
||||
|
||||
const secondPart = part.split(pos.offset);
|
||||
this._insertPart(pos.index + 1, secondPart);
|
||||
this.insertPart(pos.index + 1, secondPart);
|
||||
return pos.index + 1;
|
||||
}
|
||||
|
||||
|
@ -344,7 +356,7 @@ export default class EditorModel {
|
|||
* @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.
|
||||
*/
|
||||
_addText(pos, str, inputType) {
|
||||
private addText(pos: IPosition, str: string, inputType: string) {
|
||||
let {index} = pos;
|
||||
const {offset} = pos;
|
||||
let addLen = str.length;
|
||||
|
@ -356,7 +368,7 @@ export default class EditorModel {
|
|||
} else {
|
||||
const splitPart = part.split(offset);
|
||||
index += 1;
|
||||
this._insertPart(index, splitPart);
|
||||
this.insertPart(index, splitPart);
|
||||
}
|
||||
} else if (offset !== 0) {
|
||||
// not-editable part, caret is not at start,
|
||||
|
@ -372,13 +384,13 @@ export default class EditorModel {
|
|||
while (str) {
|
||||
const newPart = this._partCreator.createPartForInput(str, index, inputType);
|
||||
str = newPart.appendUntilRejected(str, inputType);
|
||||
this._insertPart(index, newPart);
|
||||
this.insertPart(index, newPart);
|
||||
index += 1;
|
||||
}
|
||||
return addLen;
|
||||
}
|
||||
|
||||
positionForOffset(totalOffset, atPartEnd) {
|
||||
positionForOffset(totalOffset: number, atPartEnd: boolean) {
|
||||
let currentOffset = 0;
|
||||
const index = this._parts.findIndex(part => {
|
||||
const partLen = part.text.length;
|
||||
|
@ -404,28 +416,27 @@ export default class EditorModel {
|
|||
* @param {DocumentPosition?} positionB the other boundary of the range, optional
|
||||
* @return {Range}
|
||||
*/
|
||||
startRange(positionA, positionB = positionA) {
|
||||
startRange(positionA: DocumentPosition, positionB = positionA) {
|
||||
return new Range(this, positionA, positionB);
|
||||
}
|
||||
|
||||
// called from Range.replace
|
||||
_replaceRange(startPosition, endPosition, parts) {
|
||||
replaceRange(startPosition: DocumentPosition, endPosition: DocumentPosition, parts: Part[]) {
|
||||
// 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);
|
||||
const newStartPartIndex = this.splitAt(startPosition);
|
||||
// convert it back to position once split at start
|
||||
endPosition = endOffset.asPosition(this);
|
||||
const newEndPartIndex = this._splitAt(endPosition);
|
||||
const newEndPartIndex = this.splitAt(endPosition);
|
||||
for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) {
|
||||
this._removePart(i);
|
||||
this.removePart(i);
|
||||
}
|
||||
let insertIdx = newStartPartIndex;
|
||||
for (const part of parts) {
|
||||
this._insertPart(insertIdx, part);
|
||||
this.insertPart(insertIdx, part);
|
||||
insertIdx += 1;
|
||||
}
|
||||
this._mergeAdjacentParts();
|
||||
this.mergeAdjacentParts();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -434,15 +445,15 @@ export default class EditorModel {
|
|||
* @param {ManualTransformCallback} callback to run the transformations in
|
||||
* @return {Promise} a promise when auto-complete (if applicable) is done updating
|
||||
*/
|
||||
transform(callback) {
|
||||
transform(callback: ManualTransformCallback) {
|
||||
const pos = callback();
|
||||
let acPromise = null;
|
||||
if (!(pos instanceof Range)) {
|
||||
acPromise = this._setActivePart(pos, true);
|
||||
acPromise = this.setActivePart(pos, true);
|
||||
} else {
|
||||
acPromise = Promise.resolve();
|
||||
}
|
||||
this._updateCallback(pos);
|
||||
this.updateCallback(pos);
|
||||
return acPromise;
|
||||
}
|
||||
}
|
|
@ -14,17 +14,17 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import EditorModel from "./model";
|
||||
|
||||
export default class DocumentOffset {
|
||||
constructor(offset, atNodeEnd) {
|
||||
this.offset = offset;
|
||||
this.atNodeEnd = atNodeEnd;
|
||||
constructor(public offset: number, public readonly atNodeEnd: boolean) {
|
||||
}
|
||||
|
||||
asPosition(model) {
|
||||
asPosition(model: EditorModel) {
|
||||
return model.positionForOffset(this.offset, this.atNodeEnd);
|
||||
}
|
||||
|
||||
add(delta, atNodeEnd = false) {
|
||||
add(delta: number, atNodeEnd = false) {
|
||||
return new DocumentOffset(this.offset + delta, atNodeEnd);
|
||||
}
|
||||
}
|
|
@ -14,11 +14,14 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import Range from "./range";
|
||||
import {Part} from "./parts";
|
||||
|
||||
/**
|
||||
* Some common queries and transformations on the editor model
|
||||
*/
|
||||
|
||||
export function replaceRangeAndExpandSelection(range, newParts) {
|
||||
export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]) {
|
||||
const {model} = range;
|
||||
model.transform(() => {
|
||||
const oldLen = range.length;
|
||||
|
@ -29,7 +32,7 @@ export function replaceRangeAndExpandSelection(range, newParts) {
|
|||
});
|
||||
}
|
||||
|
||||
export function replaceRangeAndMoveCaret(range, newParts) {
|
||||
export function replaceRangeAndMoveCaret(range: Range, newParts: Part[]) {
|
||||
const {model} = range;
|
||||
model.transform(() => {
|
||||
const oldLen = range.length;
|
||||
|
@ -40,7 +43,7 @@ export function replaceRangeAndMoveCaret(range, newParts) {
|
|||
});
|
||||
}
|
||||
|
||||
export function rangeStartsAtBeginningOfLine(range) {
|
||||
export function rangeStartsAtBeginningOfLine(range: Range) {
|
||||
const {model} = range;
|
||||
const startsWithPartial = range.start.offset !== 0;
|
||||
const isFirstPart = range.start.index === 0;
|
||||
|
@ -48,16 +51,16 @@ export function rangeStartsAtBeginningOfLine(range) {
|
|||
return !startsWithPartial && (isFirstPart || previousIsNewline);
|
||||
}
|
||||
|
||||
export function rangeEndsAtEndOfLine(range) {
|
||||
export function rangeEndsAtEndOfLine(range: Range) {
|
||||
const {model} = range;
|
||||
const lastPart = model.parts[range.end.index];
|
||||
const endsWithPartial = range.end.offset !== lastPart.length;
|
||||
const endsWithPartial = range.end.offset !== lastPart.text.length;
|
||||
const isLastPart = range.end.index === model.parts.length - 1;
|
||||
const nextIsNewline = !isLastPart && model.parts[range.end.index + 1].type === "newline";
|
||||
return !endsWithPartial && (isLastPart || nextIsNewline);
|
||||
}
|
||||
|
||||
export function formatRangeAsQuote(range) {
|
||||
export function formatRangeAsQuote(range: Range) {
|
||||
const {model, parts} = range;
|
||||
const {partCreator} = model;
|
||||
for (let i = 0; i < parts.length; ++i) {
|
||||
|
@ -78,7 +81,7 @@ export function formatRangeAsQuote(range) {
|
|||
replaceRangeAndExpandSelection(range, parts);
|
||||
}
|
||||
|
||||
export function formatRangeAsCode(range) {
|
||||
export function formatRangeAsCode(range: Range) {
|
||||
const {model, parts} = range;
|
||||
const {partCreator} = model;
|
||||
const needsBlock = parts.some(p => p.type === "newline");
|
||||
|
@ -104,7 +107,7 @@ export function formatRangeAsCode(range) {
|
|||
const isBlank = part => !part.text || !/\S/.test(part.text);
|
||||
const isNL = part => part.type === "newline";
|
||||
|
||||
export function toggleInlineFormat(range, prefix, suffix = prefix) {
|
||||
export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix) {
|
||||
const {model, parts} = range;
|
||||
const {partCreator} = model;
|
||||
|
||||
|
@ -140,10 +143,10 @@ export function toggleInlineFormat(range, prefix, suffix = prefix) {
|
|||
|
||||
// keep track of how many things we have inserted as an offset:=0
|
||||
let offset = 0;
|
||||
paragraphIndexes.forEach(([startIndex, endIndex]) => {
|
||||
paragraphIndexes.forEach(([startIdx, endIdx]) => {
|
||||
// for each paragraph apply the same rule
|
||||
const base = startIndex + offset;
|
||||
const index = endIndex + offset;
|
||||
const base = startIdx + offset;
|
||||
const index = endIdx + offset;
|
||||
|
||||
const isFormatted = (index - base > 0) &&
|
||||
parts[base].text.startsWith(prefix) &&
|
|
@ -15,27 +15,89 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import AutocompleteWrapperModel from "./autocomplete";
|
||||
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import AutocompleteWrapperModel, {
|
||||
GetAutocompleterComponent,
|
||||
UpdateCallback,
|
||||
UpdateQuery
|
||||
} from "./autocomplete";
|
||||
import * as Avatar from "../Avatar";
|
||||
|
||||
class BasePart {
|
||||
interface ISerializedPart {
|
||||
type: Type.Plain | Type.Newline | Type.Command | Type.PillCandidate;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ISerializedPillPart {
|
||||
type: Type.AtRoomPill | Type.RoomPill | Type.UserPill;
|
||||
text: string;
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
export type SerializedPart = ISerializedPart | ISerializedPillPart;
|
||||
|
||||
enum Type {
|
||||
Plain = "plain",
|
||||
Newline = "newline",
|
||||
Command = "command",
|
||||
UserPill = "user-pill",
|
||||
RoomPill = "room-pill",
|
||||
AtRoomPill = "at-room-pill",
|
||||
PillCandidate = "pill-candidate",
|
||||
}
|
||||
|
||||
interface IBasePart {
|
||||
text: string;
|
||||
type: Type.Plain | Type.Newline;
|
||||
canEdit: boolean;
|
||||
|
||||
createAutoComplete(updateCallback: UpdateCallback): void;
|
||||
|
||||
serialize(): SerializedPart;
|
||||
remove(offset: number, len: number): string;
|
||||
split(offset: number): IBasePart;
|
||||
validateAndInsert(offset: number, str: string, inputType: string): boolean;
|
||||
appendUntilRejected(str: string, inputType: string): string;
|
||||
updateDOMNode(node: Node);
|
||||
canUpdateDOMNode(node: Node);
|
||||
toDOMNode(): Node;
|
||||
}
|
||||
|
||||
interface IPillCandidatePart extends Omit<IBasePart, "type" | "createAutoComplete"> {
|
||||
type: Type.PillCandidate | Type.Command;
|
||||
createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel;
|
||||
}
|
||||
|
||||
interface IPillPart extends Omit<IBasePart, "type" | "resourceId"> {
|
||||
type: Type.AtRoomPill | Type.RoomPill | Type.UserPill;
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
export type Part = IBasePart | IPillCandidatePart | IPillPart;
|
||||
|
||||
abstract class BasePart {
|
||||
protected _text: string;
|
||||
|
||||
constructor(text = "") {
|
||||
this._text = text;
|
||||
}
|
||||
|
||||
acceptsInsertion(chr, offset, inputType) {
|
||||
acceptsInsertion(chr: string, offset: number, inputType: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
acceptsRemoval(position, chr) {
|
||||
acceptsRemoval(position: number, chr: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
merge(part) {
|
||||
merge(part: Part) {
|
||||
return false;
|
||||
}
|
||||
|
||||
split(offset) {
|
||||
split(offset: number) {
|
||||
const splitText = this.text.substr(offset);
|
||||
this._text = this.text.substr(0, offset);
|
||||
return new PlainPart(splitText);
|
||||
|
@ -43,7 +105,7 @@ class BasePart {
|
|||
|
||||
// removes len chars, or returns the plain text this part should be replaced with
|
||||
// if the part would become invalid if it removed everything.
|
||||
remove(offset, len) {
|
||||
remove(offset: number, len: number) {
|
||||
// validate
|
||||
const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len);
|
||||
for (let i = offset; i < (len + offset); ++i) {
|
||||
|
@ -56,7 +118,7 @@ class BasePart {
|
|||
}
|
||||
|
||||
// append str, returns the remaining string if a character was rejected.
|
||||
appendUntilRejected(str, inputType) {
|
||||
appendUntilRejected(str: string, inputType: string) {
|
||||
const offset = this.text.length;
|
||||
for (let i = 0; i < str.length; ++i) {
|
||||
const chr = str.charAt(i);
|
||||
|
@ -70,7 +132,7 @@ class BasePart {
|
|||
|
||||
// inserts str at offset if all the characters in str were accepted, otherwise don't do anything
|
||||
// return whether the str was accepted or not.
|
||||
validateAndInsert(offset, str, inputType) {
|
||||
validateAndInsert(offset: number, str: string, inputType: string) {
|
||||
for (let i = 0; i < str.length; ++i) {
|
||||
const chr = str.charAt(i);
|
||||
if (!this.acceptsInsertion(chr, offset + i, inputType)) {
|
||||
|
@ -83,9 +145,9 @@ class BasePart {
|
|||
return true;
|
||||
}
|
||||
|
||||
createAutoComplete() {}
|
||||
createAutoComplete(updateCallback: UpdateCallback): void {}
|
||||
|
||||
trim(len) {
|
||||
trim(len: number) {
|
||||
const remaining = this._text.substr(len);
|
||||
this._text = this._text.substr(0, len);
|
||||
return remaining;
|
||||
|
@ -95,6 +157,8 @@ class BasePart {
|
|||
return this._text;
|
||||
}
|
||||
|
||||
abstract get type(): Type;
|
||||
|
||||
get canEdit() {
|
||||
return true;
|
||||
}
|
||||
|
@ -103,14 +167,20 @@ class BasePart {
|
|||
return `${this.type}(${this.text})`;
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {type: this.type, text: this.text};
|
||||
serialize(): SerializedPart {
|
||||
return {
|
||||
type: this.type as ISerializedPart["type"],
|
||||
text: this.text,
|
||||
};
|
||||
}
|
||||
|
||||
abstract updateDOMNode(node: Node);
|
||||
abstract canUpdateDOMNode(node: Node);
|
||||
abstract toDOMNode(): Node;
|
||||
}
|
||||
|
||||
// exported for unit tests, should otherwise only be used through PartCreator
|
||||
export class PlainPart extends BasePart {
|
||||
acceptsInsertion(chr, offset, inputType) {
|
||||
abstract class PlainBasePart extends BasePart {
|
||||
acceptsInsertion(chr: string, offset: number, inputType: string) {
|
||||
if (chr === "\n") {
|
||||
return false;
|
||||
}
|
||||
|
@ -133,32 +203,34 @@ export class PlainPart extends BasePart {
|
|||
return false;
|
||||
}
|
||||
|
||||
get type() {
|
||||
return "plain";
|
||||
}
|
||||
|
||||
updateDOMNode(node) {
|
||||
updateDOMNode(node: Node) {
|
||||
if (node.textContent !== this.text) {
|
||||
node.textContent = this.text;
|
||||
}
|
||||
}
|
||||
|
||||
canUpdateDOMNode(node) {
|
||||
canUpdateDOMNode(node: Node) {
|
||||
return node.nodeType === Node.TEXT_NODE;
|
||||
}
|
||||
}
|
||||
|
||||
class PillPart extends BasePart {
|
||||
constructor(resourceId, label) {
|
||||
// exported for unit tests, should otherwise only be used through PartCreator
|
||||
export class PlainPart extends PlainBasePart implements IBasePart {
|
||||
get type(): IBasePart["type"] {
|
||||
return Type.Plain;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class PillPart extends BasePart implements IPillPart {
|
||||
constructor(public resourceId: string, label) {
|
||||
super(label);
|
||||
this.resourceId = resourceId;
|
||||
}
|
||||
|
||||
acceptsInsertion(chr) {
|
||||
acceptsInsertion(chr: string) {
|
||||
return chr !== " ";
|
||||
}
|
||||
|
||||
acceptsRemoval(position, chr) {
|
||||
acceptsRemoval(position: number, chr: string) {
|
||||
return position !== 0; //if you remove initial # or @, pill should become plain
|
||||
}
|
||||
|
||||
|
@ -171,7 +243,7 @@ class PillPart extends BasePart {
|
|||
return container;
|
||||
}
|
||||
|
||||
updateDOMNode(node) {
|
||||
updateDOMNode(node: HTMLElement) {
|
||||
const textNode = node.childNodes[0];
|
||||
if (textNode.textContent !== this.text) {
|
||||
textNode.textContent = this.text;
|
||||
|
@ -182,7 +254,7 @@ class PillPart extends BasePart {
|
|||
this.setAvatar(node);
|
||||
}
|
||||
|
||||
canUpdateDOMNode(node) {
|
||||
canUpdateDOMNode(node: HTMLElement) {
|
||||
return node.nodeType === Node.ELEMENT_NODE &&
|
||||
node.nodeName === "SPAN" &&
|
||||
node.childNodes.length === 1 &&
|
||||
|
@ -190,7 +262,7 @@ class PillPart extends BasePart {
|
|||
}
|
||||
|
||||
// helper method for subclasses
|
||||
_setAvatarVars(node, avatarUrl, initialLetter) {
|
||||
_setAvatarVars(node: HTMLElement, avatarUrl: string, initialLetter: string) {
|
||||
const avatarBackground = `url('${avatarUrl}')`;
|
||||
const avatarLetter = `'${initialLetter}'`;
|
||||
// check if the value is changing,
|
||||
|
@ -206,14 +278,20 @@ class PillPart extends BasePart {
|
|||
get canEdit() {
|
||||
return false;
|
||||
}
|
||||
|
||||
abstract get type(): IPillPart["type"];
|
||||
|
||||
abstract get className(): string;
|
||||
|
||||
abstract setAvatar(node: HTMLElement): void;
|
||||
}
|
||||
|
||||
class NewlinePart extends BasePart {
|
||||
acceptsInsertion(chr, offset) {
|
||||
class NewlinePart extends BasePart implements IBasePart {
|
||||
acceptsInsertion(chr: string, offset: number) {
|
||||
return offset === 0 && chr === "\n";
|
||||
}
|
||||
|
||||
acceptsRemoval(position, chr) {
|
||||
acceptsRemoval(position: number, chr: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -227,12 +305,12 @@ class NewlinePart extends BasePart {
|
|||
|
||||
updateDOMNode() {}
|
||||
|
||||
canUpdateDOMNode(node) {
|
||||
canUpdateDOMNode(node: HTMLElement) {
|
||||
return node.tagName === "BR";
|
||||
}
|
||||
|
||||
get type() {
|
||||
return "newline";
|
||||
get type(): IBasePart["type"] {
|
||||
return Type.Newline;
|
||||
}
|
||||
|
||||
// this makes the cursor skip this part when it is inserted
|
||||
|
@ -245,27 +323,26 @@ class NewlinePart extends BasePart {
|
|||
}
|
||||
|
||||
class RoomPillPart extends PillPart {
|
||||
constructor(displayAlias, room) {
|
||||
constructor(displayAlias, private room: Room) {
|
||||
super(displayAlias, displayAlias);
|
||||
this._room = room;
|
||||
}
|
||||
|
||||
setAvatar(node) {
|
||||
setAvatar(node: HTMLElement) {
|
||||
let initialLetter = "";
|
||||
let avatarUrl = Avatar.avatarUrlForRoom(
|
||||
this._room,
|
||||
this.room,
|
||||
16 * window.devicePixelRatio,
|
||||
16 * window.devicePixelRatio,
|
||||
"crop");
|
||||
if (!avatarUrl) {
|
||||
initialLetter = Avatar.getInitialLetter(this._room ? this._room.name : this.resourceId);
|
||||
avatarUrl = Avatar.defaultAvatarUrlForString(this._room ? this._room.roomId : this.resourceId);
|
||||
initialLetter = Avatar.getInitialLetter(this.room ? this.room.name : this.resourceId);
|
||||
avatarUrl = Avatar.defaultAvatarUrlForString(this.room ? this.room.roomId : this.resourceId);
|
||||
}
|
||||
this._setAvatarVars(node, avatarUrl, initialLetter);
|
||||
}
|
||||
|
||||
get type() {
|
||||
return "room-pill";
|
||||
get type(): IPillPart["type"] {
|
||||
return Type.RoomPill;
|
||||
}
|
||||
|
||||
get className() {
|
||||
|
@ -274,25 +351,24 @@ class RoomPillPart extends PillPart {
|
|||
}
|
||||
|
||||
class AtRoomPillPart extends RoomPillPart {
|
||||
get type() {
|
||||
return "at-room-pill";
|
||||
get type(): IPillPart["type"] {
|
||||
return Type.AtRoomPill;
|
||||
}
|
||||
}
|
||||
|
||||
class UserPillPart extends PillPart {
|
||||
constructor(userId, displayName, member) {
|
||||
constructor(userId, displayName, private member: RoomMember) {
|
||||
super(userId, displayName);
|
||||
this._member = member;
|
||||
}
|
||||
|
||||
setAvatar(node) {
|
||||
if (!this._member) {
|
||||
setAvatar(node: HTMLElement) {
|
||||
if (!this.member) {
|
||||
return;
|
||||
}
|
||||
const name = this._member.name || this._member.userId;
|
||||
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this._member.userId);
|
||||
const name = this.member.name || this.member.userId;
|
||||
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this.member.userId);
|
||||
const avatarUrl = Avatar.avatarUrlForMember(
|
||||
this._member,
|
||||
this.member,
|
||||
16 * window.devicePixelRatio,
|
||||
16 * window.devicePixelRatio,
|
||||
"crop");
|
||||
|
@ -303,33 +379,33 @@ class UserPillPart extends PillPart {
|
|||
this._setAvatarVars(node, avatarUrl, initialLetter);
|
||||
}
|
||||
|
||||
get type() {
|
||||
return "user-pill";
|
||||
get type(): IPillPart["type"] {
|
||||
return Type.UserPill;
|
||||
}
|
||||
|
||||
get className() {
|
||||
return "mx_UserPill mx_Pill";
|
||||
}
|
||||
|
||||
serialize() {
|
||||
const obj = super.serialize();
|
||||
obj.resourceId = this.resourceId;
|
||||
return obj;
|
||||
serialize(): ISerializedPillPart {
|
||||
return {
|
||||
type: this.type,
|
||||
text: this.text,
|
||||
resourceId: this.resourceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PillCandidatePart extends PlainPart {
|
||||
constructor(text, autoCompleteCreator) {
|
||||
class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
|
||||
constructor(text: string, private autoCompleteCreator: IAutocompleteCreator) {
|
||||
super(text);
|
||||
this._autoCompleteCreator = autoCompleteCreator;
|
||||
}
|
||||
|
||||
createAutoComplete(updateCallback) {
|
||||
return this._autoCompleteCreator.create(updateCallback);
|
||||
createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel {
|
||||
return this.autoCompleteCreator.create(updateCallback);
|
||||
}
|
||||
|
||||
acceptsInsertion(chr, offset, inputType) {
|
||||
acceptsInsertion(chr: string, offset: number, inputType: string) {
|
||||
if (offset === 0) {
|
||||
return true;
|
||||
} else {
|
||||
|
@ -341,18 +417,18 @@ class PillCandidatePart extends PlainPart {
|
|||
return false;
|
||||
}
|
||||
|
||||
acceptsRemoval(position, chr) {
|
||||
acceptsRemoval(position: number, chr: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
get type() {
|
||||
return "pill-candidate";
|
||||
get type(): IPillCandidatePart["type"] {
|
||||
return Type.PillCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
export function autoCompleteCreator(getAutocompleterComponent, updateQuery) {
|
||||
return (partCreator) => {
|
||||
return (updateCallback) => {
|
||||
export function getAutoCompleteCreator(getAutocompleterComponent: GetAutocompleterComponent, updateQuery: UpdateQuery) {
|
||||
return (partCreator: PartCreator) => {
|
||||
return (updateCallback: UpdateCallback) => {
|
||||
return new AutocompleteWrapperModel(
|
||||
updateCallback,
|
||||
getAutocompleterComponent,
|
||||
|
@ -363,20 +439,26 @@ export function autoCompleteCreator(getAutocompleterComponent, updateQuery) {
|
|||
};
|
||||
}
|
||||
|
||||
type AutoCompleteCreator = ReturnType<typeof getAutoCompleteCreator>;
|
||||
|
||||
interface IAutocompleteCreator {
|
||||
create(updateCallback: UpdateCallback): AutocompleteWrapperModel;
|
||||
}
|
||||
|
||||
export class PartCreator {
|
||||
constructor(room, client, autoCompleteCreator = null) {
|
||||
this._room = room;
|
||||
this._client = client;
|
||||
protected readonly autoCompleteCreator: IAutocompleteCreator;
|
||||
|
||||
constructor(private room: Room, private client: MatrixClient, autoCompleteCreator: AutoCompleteCreator = null) {
|
||||
// pre-create the creator as an object even without callback so it can already be passed
|
||||
// to PillCandidatePart (e.g. while deserializing) and set later on
|
||||
this._autoCompleteCreator = {create: autoCompleteCreator && autoCompleteCreator(this)};
|
||||
this.autoCompleteCreator = {create: autoCompleteCreator && autoCompleteCreator(this)};
|
||||
}
|
||||
|
||||
setAutoCompleteCreator(autoCompleteCreator) {
|
||||
this._autoCompleteCreator.create = autoCompleteCreator(this);
|
||||
setAutoCompleteCreator(autoCompleteCreator: AutoCompleteCreator) {
|
||||
this.autoCompleteCreator.create = autoCompleteCreator(this);
|
||||
}
|
||||
|
||||
createPartForInput(input) {
|
||||
createPartForInput(input: string, partIndex: number, inputType?: string): Part {
|
||||
switch (input[0]) {
|
||||
case "#":
|
||||
case "@":
|
||||
|
@ -389,28 +471,28 @@ export class PartCreator {
|
|||
}
|
||||
}
|
||||
|
||||
createDefaultPart(text) {
|
||||
createDefaultPart(text: string) {
|
||||
return this.plain(text);
|
||||
}
|
||||
|
||||
deserializePart(part) {
|
||||
deserializePart(part: SerializedPart): Part {
|
||||
switch (part.type) {
|
||||
case "plain":
|
||||
case Type.Plain:
|
||||
return this.plain(part.text);
|
||||
case "newline":
|
||||
case Type.Newline:
|
||||
return this.newline();
|
||||
case "at-room-pill":
|
||||
case Type.AtRoomPill:
|
||||
return this.atRoomPill(part.text);
|
||||
case "pill-candidate":
|
||||
case Type.PillCandidate:
|
||||
return this.pillCandidate(part.text);
|
||||
case "room-pill":
|
||||
case Type.RoomPill:
|
||||
return this.roomPill(part.text);
|
||||
case "user-pill":
|
||||
case Type.UserPill:
|
||||
return this.userPill(part.text, part.resourceId);
|
||||
}
|
||||
}
|
||||
|
||||
plain(text) {
|
||||
plain(text: string) {
|
||||
return new PlainPart(text);
|
||||
}
|
||||
|
||||
|
@ -418,16 +500,16 @@ export class PartCreator {
|
|||
return new NewlinePart("\n");
|
||||
}
|
||||
|
||||
pillCandidate(text) {
|
||||
return new PillCandidatePart(text, this._autoCompleteCreator);
|
||||
pillCandidate(text: string) {
|
||||
return new PillCandidatePart(text, this.autoCompleteCreator);
|
||||
}
|
||||
|
||||
roomPill(alias, roomId) {
|
||||
roomPill(alias: string, roomId?: string) {
|
||||
let room;
|
||||
if (roomId || alias[0] !== "#") {
|
||||
room = this._client.getRoom(roomId || alias);
|
||||
room = this.client.getRoom(roomId || alias);
|
||||
} else {
|
||||
room = this._client.getRooms().find((r) => {
|
||||
room = this.client.getRooms().find((r) => {
|
||||
return r.getCanonicalAlias() === alias ||
|
||||
r.getAltAliases().includes(alias);
|
||||
});
|
||||
|
@ -435,16 +517,16 @@ export class PartCreator {
|
|||
return new RoomPillPart(alias, room);
|
||||
}
|
||||
|
||||
atRoomPill(text) {
|
||||
return new AtRoomPillPart(text, this._room);
|
||||
atRoomPill(text: string) {
|
||||
return new AtRoomPillPart(text, this.room);
|
||||
}
|
||||
|
||||
userPill(displayName, userId) {
|
||||
const member = this._room.getMember(userId);
|
||||
userPill(displayName: string, userId: string) {
|
||||
const member = this.room.getMember(userId);
|
||||
return new UserPillPart(userId, displayName, member);
|
||||
}
|
||||
|
||||
createMentionParts(partIndex, displayName, userId) {
|
||||
createMentionParts(partIndex: number, displayName: string, userId: string) {
|
||||
const pill = this.userPill(displayName, userId);
|
||||
const postfix = this.plain(partIndex === 0 ? ": " : " ");
|
||||
return [pill, postfix];
|
||||
|
@ -454,7 +536,7 @@ export class PartCreator {
|
|||
// part creator that support auto complete for /commands,
|
||||
// used in SendMessageComposer
|
||||
export class CommandPartCreator extends PartCreator {
|
||||
createPartForInput(text, partIndex) {
|
||||
createPartForInput(text: string, partIndex: number) {
|
||||
// at beginning and starts with /? create
|
||||
if (partIndex === 0 && text[0] === "/") {
|
||||
// text will be inserted by model, so pass empty string
|
||||
|
@ -464,11 +546,11 @@ export class CommandPartCreator extends PartCreator {
|
|||
}
|
||||
}
|
||||
|
||||
command(text) {
|
||||
return new CommandPart(text, this._autoCompleteCreator);
|
||||
command(text: string) {
|
||||
return new CommandPart(text, this.autoCompleteCreator);
|
||||
}
|
||||
|
||||
deserializePart(part) {
|
||||
deserializePart(part: Part): Part {
|
||||
if (part.type === "command") {
|
||||
return this.command(part.text);
|
||||
} else {
|
||||
|
@ -478,7 +560,7 @@ export class CommandPartCreator extends PartCreator {
|
|||
}
|
||||
|
||||
class CommandPart extends PillCandidatePart {
|
||||
get type() {
|
||||
return "command";
|
||||
get type(): IPillCandidatePart["type"] {
|
||||
return Type.Command;
|
||||
}
|
||||
}
|
|
@ -15,30 +15,30 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import DocumentOffset from "./offset";
|
||||
import EditorModel from "./model";
|
||||
import {Part} from "./parts";
|
||||
|
||||
export default class DocumentPosition {
|
||||
constructor(index, offset) {
|
||||
this._index = index;
|
||||
this._offset = offset;
|
||||
export interface IPosition {
|
||||
index: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
type Callback = (part: Part, startIdx: number, endIdx: number) => void;
|
||||
export type Predicate = (index: number, offset: number, part: Part) => boolean;
|
||||
|
||||
export default class DocumentPosition implements IPosition {
|
||||
constructor(public readonly index: number, public readonly offset: number) {
|
||||
}
|
||||
|
||||
get index() {
|
||||
return this._index;
|
||||
}
|
||||
|
||||
get offset() {
|
||||
return this._offset;
|
||||
}
|
||||
|
||||
compare(otherPos) {
|
||||
if (this._index === otherPos._index) {
|
||||
return this._offset - otherPos._offset;
|
||||
compare(otherPos: DocumentPosition) {
|
||||
if (this.index === otherPos.index) {
|
||||
return this.offset - otherPos.offset;
|
||||
} else {
|
||||
return this._index - otherPos._index;
|
||||
return this.index - otherPos.index;
|
||||
}
|
||||
}
|
||||
|
||||
iteratePartsBetween(other, model, callback) {
|
||||
iteratePartsBetween(other: DocumentPosition, model: EditorModel, callback: Callback) {
|
||||
if (this.index === -1 || other.index === -1) {
|
||||
return;
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ export default class DocumentPosition {
|
|||
}
|
||||
}
|
||||
|
||||
forwardsWhile(model, predicate) {
|
||||
forwardsWhile(model: EditorModel, predicate: Predicate) {
|
||||
if (this.index === -1) {
|
||||
return this;
|
||||
}
|
||||
|
@ -82,7 +82,7 @@ export default class DocumentPosition {
|
|||
}
|
||||
}
|
||||
|
||||
backwardsWhile(model, predicate) {
|
||||
backwardsWhile(model: EditorModel, predicate: Predicate) {
|
||||
if (this.index === -1) {
|
||||
return this;
|
||||
}
|
||||
|
@ -107,7 +107,7 @@ export default class DocumentPosition {
|
|||
}
|
||||
}
|
||||
|
||||
asOffset(model) {
|
||||
asOffset(model: EditorModel) {
|
||||
if (this.index === -1) {
|
||||
return new DocumentOffset(0, true);
|
||||
}
|
||||
|
@ -121,7 +121,7 @@ export default class DocumentPosition {
|
|||
return new DocumentOffset(offset, atEnd);
|
||||
}
|
||||
|
||||
isAtEnd(model) {
|
||||
isAtEnd(model: EditorModel) {
|
||||
if (model.parts.length === 0) {
|
||||
return true;
|
||||
}
|
|
@ -14,32 +14,34 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import EditorModel from "./model";
|
||||
import DocumentPosition, {Predicate} from "./position";
|
||||
import {Part} from "./parts";
|
||||
|
||||
export default class Range {
|
||||
constructor(model, positionA, positionB = positionA) {
|
||||
this._model = model;
|
||||
private _start: DocumentPosition;
|
||||
private _end: DocumentPosition;
|
||||
|
||||
constructor(public readonly model: EditorModel, positionA: DocumentPosition, positionB = positionA) {
|
||||
const bIsLarger = positionA.compare(positionB) < 0;
|
||||
this._start = bIsLarger ? positionA : positionB;
|
||||
this._end = bIsLarger ? positionB : positionA;
|
||||
}
|
||||
|
||||
moveStart(delta) {
|
||||
this._start = this._start.forwardsWhile(this._model, () => {
|
||||
moveStart(delta: number) {
|
||||
this._start = this._start.forwardsWhile(this.model, () => {
|
||||
delta -= 1;
|
||||
return delta >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
expandBackwardsWhile(predicate) {
|
||||
this._start = this._start.backwardsWhile(this._model, predicate);
|
||||
}
|
||||
|
||||
get model() {
|
||||
return this._model;
|
||||
expandBackwardsWhile(predicate: Predicate) {
|
||||
this._start = this._start.backwardsWhile(this.model, predicate);
|
||||
}
|
||||
|
||||
get text() {
|
||||
let text = "";
|
||||
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
|
||||
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
|
||||
const t = part.text.substring(startIdx, endIdx);
|
||||
text = text + t;
|
||||
});
|
||||
|
@ -52,13 +54,13 @@ export default class Range {
|
|||
* @param {Part[]} parts the parts to replace the range with
|
||||
* @return {Number} the net amount of characters added, can be negative.
|
||||
*/
|
||||
replace(parts) {
|
||||
replace(parts: Part[]) {
|
||||
const newLength = parts.reduce((sum, part) => sum + part.text.length, 0);
|
||||
let oldLength = 0;
|
||||
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
|
||||
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
|
||||
oldLength += endIdx - startIdx;
|
||||
});
|
||||
this._model._replaceRange(this._start, this._end, parts);
|
||||
this.model.replaceRange(this._start, this._end, parts);
|
||||
return newLength - oldLength;
|
||||
}
|
||||
|
||||
|
@ -68,10 +70,10 @@ export default class Range {
|
|||
*/
|
||||
get parts() {
|
||||
const parts = [];
|
||||
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
|
||||
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
|
||||
const serializedPart = part.serialize();
|
||||
serializedPart.text = part.text.substring(startIdx, endIdx);
|
||||
const newPart = this._model.partCreator.deserializePart(serializedPart);
|
||||
const newPart = this.model.partCreator.deserializePart(serializedPart);
|
||||
parts.push(newPart);
|
||||
});
|
||||
return parts;
|
||||
|
@ -79,7 +81,7 @@ export default class Range {
|
|||
|
||||
get length() {
|
||||
let len = 0;
|
||||
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
|
||||
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
|
||||
len += endIdx - startIdx;
|
||||
});
|
||||
return len;
|
|
@ -15,16 +15,19 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
export function needsCaretNodeBefore(part, prevPart) {
|
||||
import {Part} from "./parts";
|
||||
import EditorModel from "./model";
|
||||
|
||||
export function needsCaretNodeBefore(part: Part, prevPart: Part) {
|
||||
const isFirst = !prevPart || prevPart.type === "newline";
|
||||
return !part.canEdit && (isFirst || !prevPart.canEdit);
|
||||
}
|
||||
|
||||
export function needsCaretNodeAfter(part, isLastOfLine) {
|
||||
export function needsCaretNodeAfter(part: Part, isLastOfLine: boolean) {
|
||||
return !part.canEdit && isLastOfLine;
|
||||
}
|
||||
|
||||
function insertAfter(node, nodeToInsert) {
|
||||
function insertAfter(node: HTMLElement, nodeToInsert: HTMLElement) {
|
||||
const next = node.nextSibling;
|
||||
if (next) {
|
||||
node.parentElement.insertBefore(nodeToInsert, next);
|
||||
|
@ -48,18 +51,18 @@ function createCaretNode() {
|
|||
return span;
|
||||
}
|
||||
|
||||
function updateCaretNode(node) {
|
||||
function updateCaretNode(node: HTMLElement) {
|
||||
// ensure the caret node contains only a zero-width space
|
||||
if (node.textContent !== CARET_NODE_CHAR) {
|
||||
node.textContent = CARET_NODE_CHAR;
|
||||
}
|
||||
}
|
||||
|
||||
export function isCaretNode(node) {
|
||||
export function isCaretNode(node: HTMLElement) {
|
||||
return node && node.tagName === "SPAN" && node.className === "caretNode";
|
||||
}
|
||||
|
||||
function removeNextSiblings(node) {
|
||||
function removeNextSiblings(node: ChildNode) {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
@ -71,7 +74,7 @@ function removeNextSiblings(node) {
|
|||
}
|
||||
}
|
||||
|
||||
function removeChildren(parent) {
|
||||
function removeChildren(parent: HTMLElement) {
|
||||
const firstChild = parent.firstChild;
|
||||
if (firstChild) {
|
||||
removeNextSiblings(firstChild);
|
||||
|
@ -79,7 +82,7 @@ function removeChildren(parent) {
|
|||
}
|
||||
}
|
||||
|
||||
function reconcileLine(lineContainer, parts) {
|
||||
function reconcileLine(lineContainer: ChildNode, parts: Part[]) {
|
||||
let currentNode;
|
||||
let prevPart;
|
||||
const lastPart = parts[parts.length - 1];
|
||||
|
@ -146,23 +149,23 @@ function reconcileEmptyLine(lineContainer) {
|
|||
}
|
||||
}
|
||||
|
||||
export function renderModel(editor, model) {
|
||||
const lines = model.parts.reduce((lines, part) => {
|
||||
export function renderModel(editor: HTMLDivElement, model: EditorModel) {
|
||||
const lines = model.parts.reduce((linesArr, part) => {
|
||||
if (part.type === "newline") {
|
||||
lines.push([]);
|
||||
linesArr.push([]);
|
||||
} else {
|
||||
const lastLine = lines[lines.length - 1];
|
||||
const lastLine = linesArr[linesArr.length - 1];
|
||||
lastLine.push(part);
|
||||
}
|
||||
return lines;
|
||||
return linesArr;
|
||||
}, [[]]);
|
||||
lines.forEach((parts, i) => {
|
||||
// find first (and remove anything else) div without className
|
||||
// (as browsers insert these in contenteditable) line container
|
||||
let lineContainer = editor.childNodes[i];
|
||||
let lineContainer = editor.children[i];
|
||||
while (lineContainer && (lineContainer.tagName !== "DIV" || !!lineContainer.className)) {
|
||||
editor.removeChild(lineContainer);
|
||||
lineContainer = editor.childNodes[i];
|
||||
lineContainer = editor.children[i];
|
||||
}
|
||||
if (!lineContainer) {
|
||||
lineContainer = document.createElement("div");
|
Loading…
Add table
Add a link
Reference in a new issue