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
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {createRef} from 'react';
|
import React, {createRef, KeyboardEvent} from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import flatMap from 'lodash/flatMap';
|
import flatMap from 'lodash/flatMap';
|
||||||
import {ICompletion, ISelectionRange, IProviderCompletions} from '../../../autocomplete/Autocompleter';
|
import {ICompletion, ISelectionRange, IProviderCompletions} from '../../../autocomplete/Autocompleter';
|
||||||
|
|
|
@ -16,11 +16,13 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React from 'react';
|
import React, {createRef, ClipboardEvent} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import {Room} from 'matrix-js-sdk/src/models/room';
|
||||||
|
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
|
||||||
|
|
||||||
import EditorModel from '../../../editor/model';
|
import EditorModel from '../../../editor/model';
|
||||||
import HistoryManager from '../../../editor/history';
|
import HistoryManager from '../../../editor/history';
|
||||||
import {setSelection} from '../../../editor/caret';
|
import {Caret, setSelection} from '../../../editor/caret';
|
||||||
import {
|
import {
|
||||||
formatRangeAsQuote,
|
formatRangeAsQuote,
|
||||||
formatRangeAsCode,
|
formatRangeAsCode,
|
||||||
|
@ -29,17 +31,21 @@ import {
|
||||||
} from '../../../editor/operations';
|
} from '../../../editor/operations';
|
||||||
import {getCaretOffsetAndText, getRangeForSelection} from '../../../editor/dom';
|
import {getCaretOffsetAndText, getRangeForSelection} from '../../../editor/dom';
|
||||||
import Autocomplete, {generateCompletionDomId} from '../rooms/Autocomplete';
|
import Autocomplete, {generateCompletionDomId} from '../rooms/Autocomplete';
|
||||||
import {autoCompleteCreator} from '../../../editor/parts';
|
import {getAutoCompleteCreator} from '../../../editor/parts';
|
||||||
import {parsePlainTextMessage} from '../../../editor/deserialize';
|
import {parsePlainTextMessage} from '../../../editor/deserialize';
|
||||||
import {renderModel} from '../../../editor/render';
|
import {renderModel} from '../../../editor/render';
|
||||||
import {Room} from 'matrix-js-sdk';
|
|
||||||
import TypingStore from "../../../stores/TypingStore";
|
import TypingStore from "../../../stores/TypingStore";
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
|
|
||||||
import * as sdk from '../../../index';
|
|
||||||
import {Key} from "../../../Keyboard";
|
import {Key} from "../../../Keyboard";
|
||||||
import {EMOTICON_TO_EMOJI} from "../../../emoji";
|
import {EMOTICON_TO_EMOJI} from "../../../emoji";
|
||||||
import {CommandCategories, CommandMap, parseCommandString} from "../../../SlashCommands";
|
import {CommandCategories, CommandMap, parseCommandString} from "../../../SlashCommands";
|
||||||
|
import Range from "../../../editor/range";
|
||||||
|
import MessageComposerFormatBar from "./MessageComposerFormatBar";
|
||||||
|
import DocumentOffset from "../../../editor/offset";
|
||||||
|
import {IDiff} from "../../../editor/diff";
|
||||||
|
import AutocompleteWrapperModel from "../../../editor/autocomplete";
|
||||||
|
import DocumentPosition from "../../../editor/position";
|
||||||
|
import {ICompletion} from "../../../autocomplete/Autocompleter";
|
||||||
|
|
||||||
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
|
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
|
||||||
|
|
||||||
|
@ -49,7 +55,7 @@ function ctrlShortcutLabel(key) {
|
||||||
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
|
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneSelection(selection) {
|
function cloneSelection(selection: Selection): Partial<Selection> {
|
||||||
return {
|
return {
|
||||||
anchorNode: selection.anchorNode,
|
anchorNode: selection.anchorNode,
|
||||||
anchorOffset: selection.anchorOffset,
|
anchorOffset: selection.anchorOffset,
|
||||||
|
@ -61,7 +67,7 @@ function cloneSelection(selection) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectionEquals(a: Selection, b: Selection): boolean {
|
function selectionEquals(a: Partial<Selection>, b: Selection): boolean {
|
||||||
return a.anchorNode === b.anchorNode &&
|
return a.anchorNode === b.anchorNode &&
|
||||||
a.anchorOffset === b.anchorOffset &&
|
a.anchorOffset === b.anchorOffset &&
|
||||||
a.focusNode === b.focusNode &&
|
a.focusNode === b.focusNode &&
|
||||||
|
@ -71,45 +77,75 @@ function selectionEquals(a: Selection, b: Selection): boolean {
|
||||||
a.type === b.type;
|
a.type === b.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class BasicMessageEditor extends React.Component {
|
enum Formatting {
|
||||||
static propTypes = {
|
Bold = "bold",
|
||||||
onChange: PropTypes.func,
|
Italics = "italics",
|
||||||
onPaste: PropTypes.func, // returns true if handled and should skip internal onPaste handler
|
Strikethrough = "strikethrough",
|
||||||
model: PropTypes.instanceOf(EditorModel).isRequired,
|
Code = "code",
|
||||||
room: PropTypes.instanceOf(Room).isRequired,
|
Quote = "quote",
|
||||||
placeholder: PropTypes.string,
|
}
|
||||||
label: PropTypes.string, // the aria label
|
|
||||||
initialCaret: PropTypes.object, // See DocumentPosition in editor/model.js
|
interface IProps {
|
||||||
};
|
model: EditorModel;
|
||||||
|
room: Room;
|
||||||
|
placeholder?: string;
|
||||||
|
label?: string;
|
||||||
|
initialCaret?: DocumentOffset;
|
||||||
|
|
||||||
|
onChange();
|
||||||
|
onPaste(event: ClipboardEvent<HTMLDivElement>, model: EditorModel): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
showPillAvatar: boolean;
|
||||||
|
query?: string;
|
||||||
|
showVisualBell?: boolean;
|
||||||
|
autoComplete?: AutocompleteWrapperModel;
|
||||||
|
completionIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class BasicMessageEditor extends React.Component<IProps, IState> {
|
||||||
|
private editorRef = createRef<HTMLDivElement>();
|
||||||
|
private autocompleteRef = createRef<Autocomplete>();
|
||||||
|
private formatBarRef = createRef<typeof MessageComposerFormatBar>();
|
||||||
|
|
||||||
|
private modifiedFlag = false;
|
||||||
|
private isIMEComposing = false;
|
||||||
|
private hasTextSelected = false;
|
||||||
|
|
||||||
|
private _isCaretAtEnd: boolean;
|
||||||
|
private lastCaret: DocumentOffset;
|
||||||
|
private lastSelection: ReturnType<typeof cloneSelection>;
|
||||||
|
|
||||||
|
private readonly emoticonSettingHandle: string;
|
||||||
|
private readonly shouldShowPillAvatarSettingHandle: string;
|
||||||
|
private readonly historyManager = new HistoryManager();
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
autoComplete: null,
|
|
||||||
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
|
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
|
||||||
};
|
};
|
||||||
this._editorRef = null;
|
|
||||||
this._autocompleteRef = null;
|
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
|
||||||
this._formatBarRef = null;
|
this.configureEmoticonAutoReplace);
|
||||||
this._modifiedFlag = false;
|
this.configureEmoticonAutoReplace();
|
||||||
this._isIMEComposing = false;
|
this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
|
||||||
this._hasTextSelected = false;
|
this.configureShouldShowPillAvatar);
|
||||||
this._emoticonSettingHandle = null;
|
|
||||||
this._shouldShowPillAvatarSettingHandle = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
public componentDidUpdate(prevProps: IProps) {
|
||||||
if (this.props.placeholder !== prevProps.placeholder && this.props.placeholder) {
|
if (this.props.placeholder !== prevProps.placeholder && this.props.placeholder) {
|
||||||
const {isEmpty} = this.props.model;
|
const {isEmpty} = this.props.model;
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
this._showPlaceholder();
|
this.showPlaceholder();
|
||||||
} else {
|
} else {
|
||||||
this._hidePlaceholder();
|
this.hidePlaceholder();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_replaceEmoticon = (caretPosition, inputType, diff) => {
|
private replaceEmoticon = (caretPosition: DocumentPosition) => {
|
||||||
const {model} = this.props;
|
const {model} = this.props;
|
||||||
const range = model.startRange(caretPosition);
|
const range = model.startRange(caretPosition);
|
||||||
// expand range max 8 characters backwards from caretPosition,
|
// expand range max 8 characters backwards from caretPosition,
|
||||||
|
@ -139,30 +175,30 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
return range.replace([partCreator.plain(data.unicode + " ")]);
|
return range.replace([partCreator.plain(data.unicode + " ")]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
_updateEditorState = (selection, inputType, diff) => {
|
private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff) => {
|
||||||
renderModel(this._editorRef, this.props.model);
|
renderModel(this.editorRef.current, this.props.model);
|
||||||
if (selection) { // set the caret/selection
|
if (selection) { // set the caret/selection
|
||||||
try {
|
try {
|
||||||
setSelection(this._editorRef, this.props.model, selection);
|
setSelection(this.editorRef.current, this.props.model, selection);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
// if caret selection is a range, take the end position
|
// if caret selection is a range, take the end position
|
||||||
const position = selection.end || selection;
|
const position = selection instanceof Range ? selection.end : selection;
|
||||||
this._setLastCaretFromPosition(position);
|
this.setLastCaretFromPosition(position);
|
||||||
}
|
}
|
||||||
const {isEmpty} = this.props.model;
|
const {isEmpty} = this.props.model;
|
||||||
if (this.props.placeholder) {
|
if (this.props.placeholder) {
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
this._showPlaceholder();
|
this.showPlaceholder();
|
||||||
} else {
|
} else {
|
||||||
this._hidePlaceholder();
|
this.hidePlaceholder();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
this._formatBarRef.hide();
|
this.formatBarRef.current.hide();
|
||||||
}
|
}
|
||||||
this.setState({autoComplete: this.props.model.autoComplete});
|
this.setState({autoComplete: this.props.model.autoComplete});
|
||||||
this.historyManager.tryPush(this.props.model, selection, inputType, diff);
|
this.historyManager.tryPush(this.props.model, selection, inputType, diff);
|
||||||
|
@ -180,26 +216,26 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
if (this.props.onChange) {
|
if (this.props.onChange) {
|
||||||
this.props.onChange();
|
this.props.onChange();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private showPlaceholder() {
|
||||||
|
this.editorRef.current.style.setProperty("--placeholder", `'${this.props.placeholder}'`);
|
||||||
|
this.editorRef.current.classList.add("mx_BasicMessageComposer_inputEmpty");
|
||||||
}
|
}
|
||||||
|
|
||||||
_showPlaceholder() {
|
private hidePlaceholder() {
|
||||||
this._editorRef.style.setProperty("--placeholder", `'${this.props.placeholder}'`);
|
this.editorRef.current.classList.remove("mx_BasicMessageComposer_inputEmpty");
|
||||||
this._editorRef.classList.add("mx_BasicMessageComposer_inputEmpty");
|
this.editorRef.current.style.removeProperty("--placeholder");
|
||||||
}
|
}
|
||||||
|
|
||||||
_hidePlaceholder() {
|
private onCompositionStart = () => {
|
||||||
this._editorRef.classList.remove("mx_BasicMessageComposer_inputEmpty");
|
this.isIMEComposing = true;
|
||||||
this._editorRef.style.removeProperty("--placeholder");
|
|
||||||
}
|
|
||||||
|
|
||||||
_onCompositionStart = (event) => {
|
|
||||||
this._isIMEComposing = true;
|
|
||||||
// even if the model is empty, the composition text shouldn't be mixed with the placeholder
|
// even if the model is empty, the composition text shouldn't be mixed with the placeholder
|
||||||
this._hidePlaceholder();
|
this.hidePlaceholder();
|
||||||
}
|
};
|
||||||
|
|
||||||
_onCompositionEnd = (event) => {
|
private onCompositionEnd = () => {
|
||||||
this._isIMEComposing = false;
|
this.isIMEComposing = false;
|
||||||
// some browsers (Chrome) don't fire an input event after ending a composition,
|
// some browsers (Chrome) don't fire an input event after ending a composition,
|
||||||
// so trigger a model update after the composition is done by calling the input handler.
|
// so trigger a model update after the composition is done by calling the input handler.
|
||||||
|
|
||||||
|
@ -213,48 +249,48 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
const isSafari = ua.includes('safari/') && !ua.includes('chrome/');
|
const isSafari = ua.includes('safari/') && !ua.includes('chrome/');
|
||||||
|
|
||||||
if (isSafari) {
|
if (isSafari) {
|
||||||
this._onInput({inputType: "insertCompositionText"});
|
this.onInput({inputType: "insertCompositionText"});
|
||||||
} else {
|
} else {
|
||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
this._onInput({inputType: "insertCompositionText"});
|
this.onInput({inputType: "insertCompositionText"});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
isComposing(event) {
|
isComposing(event: React.KeyboardEvent) {
|
||||||
// checking the event.isComposing flag just in case any browser out there
|
// checking the event.isComposing flag just in case any browser out there
|
||||||
// emits events related to the composition after compositionend
|
// emits events related to the composition after compositionend
|
||||||
// has been fired
|
// has been fired
|
||||||
return !!(this._isIMEComposing || (event.nativeEvent && event.nativeEvent.isComposing));
|
return !!(this.isIMEComposing || (event.nativeEvent && event.nativeEvent.isComposing));
|
||||||
}
|
}
|
||||||
|
|
||||||
_onCutCopy = (event, type) => {
|
private onCutCopy = (event: ClipboardEvent, type: string) => {
|
||||||
const selection = document.getSelection();
|
const selection = document.getSelection();
|
||||||
const text = selection.toString();
|
const text = selection.toString();
|
||||||
if (text) {
|
if (text) {
|
||||||
const {model} = this.props;
|
const {model} = this.props;
|
||||||
const range = getRangeForSelection(this._editorRef, model, selection);
|
const range = getRangeForSelection(this.editorRef.current, model, selection);
|
||||||
const selectedParts = range.parts.map(p => p.serialize());
|
const selectedParts = range.parts.map(p => p.serialize());
|
||||||
event.clipboardData.setData("application/x-riot-composer", JSON.stringify(selectedParts));
|
event.clipboardData.setData("application/x-riot-composer", JSON.stringify(selectedParts));
|
||||||
event.clipboardData.setData("text/plain", text); // so plain copy/paste works
|
event.clipboardData.setData("text/plain", text); // so plain copy/paste works
|
||||||
if (type === "cut") {
|
if (type === "cut") {
|
||||||
// Remove the text, updating the model as appropriate
|
// Remove the text, updating the model as appropriate
|
||||||
this._modifiedFlag = true;
|
this.modifiedFlag = true;
|
||||||
replaceRangeAndMoveCaret(range, []);
|
replaceRangeAndMoveCaret(range, []);
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
_onCopy = (event) => {
|
private onCopy = (event: ClipboardEvent) => {
|
||||||
this._onCutCopy(event, "copy");
|
this.onCutCopy(event, "copy");
|
||||||
}
|
};
|
||||||
|
|
||||||
_onCut = (event) => {
|
private onCut = (event: ClipboardEvent) => {
|
||||||
this._onCutCopy(event, "cut");
|
this.onCutCopy(event, "cut");
|
||||||
}
|
};
|
||||||
|
|
||||||
_onPaste = (event) => {
|
private onPaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault(); // we always handle the paste ourselves
|
event.preventDefault(); // we always handle the paste ourselves
|
||||||
if (this.props.onPaste && this.props.onPaste(event, this.props.model)) {
|
if (this.props.onPaste && this.props.onPaste(event, this.props.model)) {
|
||||||
// to prevent double handling, allow props.onPaste to skip internal onPaste
|
// to prevent double handling, allow props.onPaste to skip internal onPaste
|
||||||
|
@ -273,28 +309,28 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
const text = event.clipboardData.getData("text/plain");
|
const text = event.clipboardData.getData("text/plain");
|
||||||
parts = parsePlainTextMessage(text, partCreator);
|
parts = parsePlainTextMessage(text, partCreator);
|
||||||
}
|
}
|
||||||
this._modifiedFlag = true;
|
this.modifiedFlag = true;
|
||||||
const range = getRangeForSelection(this._editorRef, model, document.getSelection());
|
const range = getRangeForSelection(this.editorRef.current, model, document.getSelection());
|
||||||
replaceRangeAndMoveCaret(range, parts);
|
replaceRangeAndMoveCaret(range, parts);
|
||||||
}
|
};
|
||||||
|
|
||||||
_onInput = (event) => {
|
private onInput = (event: Partial<InputEvent>) => {
|
||||||
// ignore any input while doing IME compositions
|
// ignore any input while doing IME compositions
|
||||||
if (this._isIMEComposing) {
|
if (this.isIMEComposing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._modifiedFlag = true;
|
this.modifiedFlag = true;
|
||||||
const sel = document.getSelection();
|
const sel = document.getSelection();
|
||||||
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
|
const {caret, text} = getCaretOffsetAndText(this.editorRef.current, sel);
|
||||||
this.props.model.update(text, event.inputType, caret);
|
this.props.model.update(text, event.inputType, caret);
|
||||||
}
|
};
|
||||||
|
|
||||||
_insertText(textToInsert, inputType = "insertText") {
|
private insertText(textToInsert: string, inputType = "insertText") {
|
||||||
const sel = document.getSelection();
|
const sel = document.getSelection();
|
||||||
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
|
const {caret, text} = getCaretOffsetAndText(this.editorRef.current, sel);
|
||||||
const newText = text.substr(0, caret.offset) + textToInsert + text.substr(caret.offset);
|
const newText = text.substr(0, caret.offset) + textToInsert + text.substr(caret.offset);
|
||||||
caret.offset += textToInsert.length;
|
caret.offset += textToInsert.length;
|
||||||
this._modifiedFlag = true;
|
this.modifiedFlag = true;
|
||||||
this.props.model.update(newText, inputType, caret);
|
this.props.model.update(newText, inputType, caret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -303,28 +339,28 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
// we don't need to. But if the user is navigating the caret without input
|
// we don't need to. But if the user is navigating the caret without input
|
||||||
// we need to recalculate it, to be able to know where to insert content after
|
// we need to recalculate it, to be able to know where to insert content after
|
||||||
// losing focus
|
// losing focus
|
||||||
_setLastCaretFromPosition(position) {
|
private setLastCaretFromPosition(position: DocumentPosition) {
|
||||||
const {model} = this.props;
|
const {model} = this.props;
|
||||||
this._isCaretAtEnd = position.isAtEnd(model);
|
this._isCaretAtEnd = position.isAtEnd(model);
|
||||||
this._lastCaret = position.asOffset(model);
|
this.lastCaret = position.asOffset(model);
|
||||||
this._lastSelection = cloneSelection(document.getSelection());
|
this.lastSelection = cloneSelection(document.getSelection());
|
||||||
}
|
}
|
||||||
|
|
||||||
_refreshLastCaretIfNeeded() {
|
private refreshLastCaretIfNeeded() {
|
||||||
// XXX: needed when going up and down in editing messages ... not sure why yet
|
// XXX: needed when going up and down in editing messages ... not sure why yet
|
||||||
// because the editors should stop doing this when when blurred ...
|
// because the editors should stop doing this when when blurred ...
|
||||||
// maybe it's on focus and the _editorRef isn't available yet or something.
|
// maybe it's on focus and the _editorRef isn't available yet or something.
|
||||||
if (!this._editorRef) {
|
if (!this.editorRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selection = document.getSelection();
|
const selection = document.getSelection();
|
||||||
if (!this._lastSelection || !selectionEquals(this._lastSelection, selection)) {
|
if (!this.lastSelection || !selectionEquals(this.lastSelection, selection)) {
|
||||||
this._lastSelection = cloneSelection(selection);
|
this.lastSelection = cloneSelection(selection);
|
||||||
const {caret, text} = getCaretOffsetAndText(this._editorRef, selection);
|
const {caret, text} = getCaretOffsetAndText(this.editorRef.current, selection);
|
||||||
this._lastCaret = caret;
|
this.lastCaret = caret;
|
||||||
this._isCaretAtEnd = caret.offset === text.length;
|
this._isCaretAtEnd = caret.offset === text.length;
|
||||||
}
|
}
|
||||||
return this._lastCaret;
|
return this.lastCaret;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearUndoHistory() {
|
clearUndoHistory() {
|
||||||
|
@ -332,11 +368,11 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
getCaret() {
|
getCaret() {
|
||||||
return this._lastCaret;
|
return this.lastCaret;
|
||||||
}
|
}
|
||||||
|
|
||||||
isSelectionCollapsed() {
|
isSelectionCollapsed() {
|
||||||
return !this._lastSelection || this._lastSelection.isCollapsed;
|
return !this.lastSelection || this.lastSelection.isCollapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
isCaretAtStart() {
|
isCaretAtStart() {
|
||||||
|
@ -347,51 +383,51 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
return this._isCaretAtEnd;
|
return this._isCaretAtEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
_onBlur = () => {
|
private onBlur = () => {
|
||||||
document.removeEventListener("selectionchange", this._onSelectionChange);
|
document.removeEventListener("selectionchange", this.onSelectionChange);
|
||||||
}
|
};
|
||||||
|
|
||||||
_onFocus = () => {
|
private onFocus = () => {
|
||||||
document.addEventListener("selectionchange", this._onSelectionChange);
|
document.addEventListener("selectionchange", this.onSelectionChange);
|
||||||
// force to recalculate
|
// force to recalculate
|
||||||
this._lastSelection = null;
|
this.lastSelection = null;
|
||||||
this._refreshLastCaretIfNeeded();
|
this.refreshLastCaretIfNeeded();
|
||||||
}
|
};
|
||||||
|
|
||||||
_onSelectionChange = () => {
|
private onSelectionChange = () => {
|
||||||
const {isEmpty} = this.props.model;
|
const {isEmpty} = this.props.model;
|
||||||
|
|
||||||
this._refreshLastCaretIfNeeded();
|
this.refreshLastCaretIfNeeded();
|
||||||
const selection = document.getSelection();
|
const selection = document.getSelection();
|
||||||
if (this._hasTextSelected && selection.isCollapsed) {
|
if (this.hasTextSelected && selection.isCollapsed) {
|
||||||
this._hasTextSelected = false;
|
this.hasTextSelected = false;
|
||||||
if (this._formatBarRef) {
|
if (this.formatBarRef.current) {
|
||||||
this._formatBarRef.hide();
|
this.formatBarRef.current.hide();
|
||||||
}
|
}
|
||||||
} else if (!selection.isCollapsed && !isEmpty) {
|
} else if (!selection.isCollapsed && !isEmpty) {
|
||||||
this._hasTextSelected = true;
|
this.hasTextSelected = true;
|
||||||
if (this._formatBarRef) {
|
if (this.formatBarRef.current) {
|
||||||
const selectionRect = selection.getRangeAt(0).getBoundingClientRect();
|
const selectionRect = selection.getRangeAt(0).getBoundingClientRect();
|
||||||
this._formatBarRef.showAt(selectionRect);
|
this.formatBarRef.current.showAt(selectionRect);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
_onKeyDown = (event) => {
|
private onKeyDown = (event: React.KeyboardEvent) => {
|
||||||
const model = this.props.model;
|
const model = this.props.model;
|
||||||
const modKey = IS_MAC ? event.metaKey : event.ctrlKey;
|
const modKey = IS_MAC ? event.metaKey : event.ctrlKey;
|
||||||
let handled = false;
|
let handled = false;
|
||||||
// format bold
|
// format bold
|
||||||
if (modKey && event.key === Key.B) {
|
if (modKey && event.key === Key.B) {
|
||||||
this._onFormatAction("bold");
|
this.onFormatAction(Formatting.Bold);
|
||||||
handled = true;
|
handled = true;
|
||||||
// format italics
|
// format italics
|
||||||
} else if (modKey && event.key === Key.I) {
|
} else if (modKey && event.key === Key.I) {
|
||||||
this._onFormatAction("italics");
|
this.onFormatAction(Formatting.Italics);
|
||||||
handled = true;
|
handled = true;
|
||||||
// format quote
|
// format quote
|
||||||
} else if (modKey && event.key === Key.GREATER_THAN) {
|
} else if (modKey && event.key === Key.GREATER_THAN) {
|
||||||
this._onFormatAction("quote");
|
this.onFormatAction(Formatting.Quote);
|
||||||
handled = true;
|
handled = true;
|
||||||
// redo
|
// redo
|
||||||
} else if ((!IS_MAC && modKey && event.key === Key.Y) ||
|
} else if ((!IS_MAC && modKey && event.key === Key.Y) ||
|
||||||
|
@ -414,18 +450,18 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
handled = true;
|
handled = true;
|
||||||
// insert newline on Shift+Enter
|
// insert newline on Shift+Enter
|
||||||
} else if (event.key === Key.ENTER && (event.shiftKey || (IS_MAC && event.altKey))) {
|
} else if (event.key === Key.ENTER && (event.shiftKey || (IS_MAC && event.altKey))) {
|
||||||
this._insertText("\n");
|
this.insertText("\n");
|
||||||
handled = true;
|
handled = true;
|
||||||
// move selection to start of composer
|
// move selection to start of composer
|
||||||
} else if (modKey && event.key === Key.HOME && !event.shiftKey) {
|
} else if (modKey && event.key === Key.HOME && !event.shiftKey) {
|
||||||
setSelection(this._editorRef, model, {
|
setSelection(this.editorRef.current, model, {
|
||||||
index: 0,
|
index: 0,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
});
|
});
|
||||||
handled = true;
|
handled = true;
|
||||||
// move selection to end of composer
|
// move selection to end of composer
|
||||||
} else if (modKey && event.key === Key.END && !event.shiftKey) {
|
} else if (modKey && event.key === Key.END && !event.shiftKey) {
|
||||||
setSelection(this._editorRef, model, {
|
setSelection(this.editorRef.current, model, {
|
||||||
index: model.parts.length - 1,
|
index: model.parts.length - 1,
|
||||||
offset: model.parts[model.parts.length - 1].text.length,
|
offset: model.parts[model.parts.length - 1].text.length,
|
||||||
});
|
});
|
||||||
|
@ -465,19 +501,19 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
return; // don't preventDefault on anything else
|
return; // don't preventDefault on anything else
|
||||||
}
|
}
|
||||||
} else if (event.key === Key.TAB) {
|
} else if (event.key === Key.TAB) {
|
||||||
this._tabCompleteName();
|
this.tabCompleteName(event);
|
||||||
handled = true;
|
handled = true;
|
||||||
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
|
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
|
||||||
this._formatBarRef.hide();
|
this.formatBarRef.current.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (handled) {
|
if (handled) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
async _tabCompleteName() {
|
private async tabCompleteName(event: React.KeyboardEvent) {
|
||||||
try {
|
try {
|
||||||
await new Promise(resolve => this.setState({showVisualBell: false}, resolve));
|
await new Promise(resolve => this.setState({showVisualBell: false}, resolve));
|
||||||
const {model} = this.props;
|
const {model} = this.props;
|
||||||
|
@ -500,7 +536,7 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
|
|
||||||
// Don't try to do things with the autocomplete if there is none shown
|
// Don't try to do things with the autocomplete if there is none shown
|
||||||
if (model.autoComplete) {
|
if (model.autoComplete) {
|
||||||
await model.autoComplete.onTab();
|
await model.autoComplete.onTab(event);
|
||||||
if (!model.autoComplete.hasSelection()) {
|
if (!model.autoComplete.hasSelection()) {
|
||||||
this.setState({showVisualBell: true});
|
this.setState({showVisualBell: true});
|
||||||
model.autoComplete.close();
|
model.autoComplete.close();
|
||||||
|
@ -512,64 +548,58 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
isModified() {
|
isModified() {
|
||||||
return this._modifiedFlag;
|
return this.modifiedFlag;
|
||||||
}
|
}
|
||||||
|
|
||||||
_onAutoCompleteConfirm = (completion) => {
|
private onAutoCompleteConfirm = (completion: ICompletion) => {
|
||||||
this.props.model.autoComplete.onComponentConfirm(completion);
|
this.props.model.autoComplete.onComponentConfirm(completion);
|
||||||
}
|
|
||||||
|
|
||||||
_onAutoCompleteSelectionChange = (completion, completionIndex) => {
|
|
||||||
this.props.model.autoComplete.onComponentSelectionChange(completion);
|
|
||||||
this.setState({completionIndex});
|
|
||||||
}
|
|
||||||
|
|
||||||
_configureEmoticonAutoReplace = () => {
|
|
||||||
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
|
|
||||||
this.props.model.setTransformCallback(shouldReplace ? this._replaceEmoticon : null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_configureShouldShowPillAvatar = () => {
|
private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number) => {
|
||||||
|
this.props.model.autoComplete.onComponentSelectionChange(completion);
|
||||||
|
this.setState({completionIndex});
|
||||||
|
};
|
||||||
|
|
||||||
|
private configureEmoticonAutoReplace = () => {
|
||||||
|
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
|
||||||
|
this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
private configureShouldShowPillAvatar = () => {
|
||||||
const showPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
|
const showPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
|
||||||
this.setState({ showPillAvatar });
|
this.setState({ showPillAvatar });
|
||||||
};
|
};
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
document.removeEventListener("selectionchange", this._onSelectionChange);
|
document.removeEventListener("selectionchange", this.onSelectionChange);
|
||||||
this._editorRef.removeEventListener("input", this._onInput, true);
|
this.editorRef.current.removeEventListener("input", this.onInput, true);
|
||||||
this._editorRef.removeEventListener("compositionstart", this._onCompositionStart, true);
|
this.editorRef.current.removeEventListener("compositionstart", this.onCompositionStart, true);
|
||||||
this._editorRef.removeEventListener("compositionend", this._onCompositionEnd, true);
|
this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true);
|
||||||
SettingsStore.unwatchSetting(this._emoticonSettingHandle);
|
SettingsStore.unwatchSetting(this.emoticonSettingHandle);
|
||||||
SettingsStore.unwatchSetting(this._shouldShowPillAvatarSettingHandle);
|
SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const model = this.props.model;
|
const model = this.props.model;
|
||||||
model.setUpdateCallback(this._updateEditorState);
|
model.setUpdateCallback(this.updateEditorState);
|
||||||
this._emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
|
|
||||||
this._configureEmoticonAutoReplace);
|
|
||||||
this._configureEmoticonAutoReplace();
|
|
||||||
this._shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
|
|
||||||
this._configureShouldShowPillAvatar);
|
|
||||||
const partCreator = model.partCreator;
|
const partCreator = model.partCreator;
|
||||||
// TODO: does this allow us to get rid of EditorStateTransfer?
|
// TODO: does this allow us to get rid of EditorStateTransfer?
|
||||||
// not really, but we could not serialize the parts, and just change the autoCompleter
|
// not really, but we could not serialize the parts, and just change the autoCompleter
|
||||||
partCreator.setAutoCompleteCreator(autoCompleteCreator(
|
partCreator.setAutoCompleteCreator(getAutoCompleteCreator(
|
||||||
() => this._autocompleteRef,
|
() => this.autocompleteRef.current,
|
||||||
query => new Promise(resolve => this.setState({query}, resolve)),
|
query => new Promise(resolve => this.setState({query}, resolve)),
|
||||||
));
|
));
|
||||||
this.historyManager = new HistoryManager(partCreator);
|
|
||||||
// initial render of model
|
// initial render of model
|
||||||
this._updateEditorState(this._getInitialCaretPosition());
|
this.updateEditorState(this.getInitialCaretPosition());
|
||||||
// attach input listener by hand so React doesn't proxy the events,
|
// attach input listener by hand so React doesn't proxy the events,
|
||||||
// as the proxied event doesn't support inputType, which we need.
|
// as the proxied event doesn't support inputType, which we need.
|
||||||
this._editorRef.addEventListener("input", this._onInput, true);
|
this.editorRef.current.addEventListener("input", this.onInput, true);
|
||||||
this._editorRef.addEventListener("compositionstart", this._onCompositionStart, true);
|
this.editorRef.current.addEventListener("compositionstart", this.onCompositionStart, true);
|
||||||
this._editorRef.addEventListener("compositionend", this._onCompositionEnd, true);
|
this.editorRef.current.addEventListener("compositionend", this.onCompositionEnd, true);
|
||||||
this._editorRef.focus();
|
this.editorRef.current.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
_getInitialCaretPosition() {
|
private getInitialCaretPosition() {
|
||||||
let caretPosition;
|
let caretPosition;
|
||||||
if (this.props.initialCaret) {
|
if (this.props.initialCaret) {
|
||||||
// if restoring state from a previous editor,
|
// if restoring state from a previous editor,
|
||||||
|
@ -583,34 +613,34 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
return caretPosition;
|
return caretPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
_onFormatAction = (action) => {
|
private onFormatAction = (action: Formatting) => {
|
||||||
const range = getRangeForSelection(
|
const range = getRangeForSelection(
|
||||||
this._editorRef,
|
this.editorRef.current,
|
||||||
this.props.model,
|
this.props.model,
|
||||||
document.getSelection());
|
document.getSelection());
|
||||||
if (range.length === 0) {
|
if (range.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.historyManager.ensureLastChangesPushed(this.props.model);
|
this.historyManager.ensureLastChangesPushed(this.props.model);
|
||||||
this._modifiedFlag = true;
|
this.modifiedFlag = true;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "bold":
|
case Formatting.Bold:
|
||||||
toggleInlineFormat(range, "**");
|
toggleInlineFormat(range, "**");
|
||||||
break;
|
break;
|
||||||
case "italics":
|
case Formatting.Italics:
|
||||||
toggleInlineFormat(range, "_");
|
toggleInlineFormat(range, "_");
|
||||||
break;
|
break;
|
||||||
case "strikethrough":
|
case Formatting.Strikethrough:
|
||||||
toggleInlineFormat(range, "<del>", "</del>");
|
toggleInlineFormat(range, "<del>", "</del>");
|
||||||
break;
|
break;
|
||||||
case "code":
|
case Formatting.Code:
|
||||||
formatRangeAsCode(range);
|
formatRangeAsCode(range);
|
||||||
break;
|
break;
|
||||||
case "quote":
|
case Formatting.Quote:
|
||||||
formatRangeAsQuote(range);
|
formatRangeAsQuote(range);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let autoComplete;
|
let autoComplete;
|
||||||
|
@ -619,10 +649,10 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
const queryLen = query.length;
|
const queryLen = query.length;
|
||||||
autoComplete = (<div className="mx_BasicMessageComposer_AutoCompleteWrapper">
|
autoComplete = (<div className="mx_BasicMessageComposer_AutoCompleteWrapper">
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
ref={ref => this._autocompleteRef = ref}
|
ref={this.autocompleteRef}
|
||||||
query={query}
|
query={query}
|
||||||
onConfirm={this._onAutoCompleteConfirm}
|
onConfirm={this.onAutoCompleteConfirm}
|
||||||
onSelectionChange={this._onAutoCompleteSelectionChange}
|
onSelectionChange={this.onAutoCompleteSelectionChange}
|
||||||
selection={{beginning: true, end: queryLen, start: queryLen}}
|
selection={{beginning: true, end: queryLen, start: queryLen}}
|
||||||
room={this.props.room}
|
room={this.props.room}
|
||||||
/>
|
/>
|
||||||
|
@ -635,7 +665,6 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
"mx_BasicMessageComposer_input_shouldShowPillAvatar": this.state.showPillAvatar,
|
"mx_BasicMessageComposer_input_shouldShowPillAvatar": this.state.showPillAvatar,
|
||||||
});
|
});
|
||||||
|
|
||||||
const MessageComposerFormatBar = sdk.getComponent('rooms.MessageComposerFormatBar');
|
|
||||||
const shortcuts = {
|
const shortcuts = {
|
||||||
bold: ctrlShortcutLabel("B"),
|
bold: ctrlShortcutLabel("B"),
|
||||||
italics: ctrlShortcutLabel("I"),
|
italics: ctrlShortcutLabel("I"),
|
||||||
|
@ -646,18 +675,18 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
|
|
||||||
return (<div className={wrapperClasses}>
|
return (<div className={wrapperClasses}>
|
||||||
{ autoComplete }
|
{ autoComplete }
|
||||||
<MessageComposerFormatBar ref={ref => this._formatBarRef = ref} onAction={this._onFormatAction} shortcuts={shortcuts} />
|
<MessageComposerFormatBar ref={this.formatBarRef} onAction={this.onFormatAction} shortcuts={shortcuts} />
|
||||||
<div
|
<div
|
||||||
className={classes}
|
className={classes}
|
||||||
contentEditable="true"
|
contentEditable="true"
|
||||||
tabIndex="0"
|
tabIndex={0}
|
||||||
onBlur={this._onBlur}
|
onBlur={this.onBlur}
|
||||||
onFocus={this._onFocus}
|
onFocus={this.onFocus}
|
||||||
onCopy={this._onCopy}
|
onCopy={this.onCopy}
|
||||||
onCut={this._onCut}
|
onCut={this.onCut}
|
||||||
onPaste={this._onPaste}
|
onPaste={this.onPaste}
|
||||||
onKeyDown={this._onKeyDown}
|
onKeyDown={this.onKeyDown}
|
||||||
ref={ref => this._editorRef = ref}
|
ref={this.editorRef}
|
||||||
aria-label={this.props.label}
|
aria-label={this.props.label}
|
||||||
role="textbox"
|
role="textbox"
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
|
@ -671,6 +700,6 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
focus() {
|
focus() {
|
||||||
this._editorRef.focus();
|
this.editorRef.current.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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 {needsCaretNodeBefore, needsCaretNodeAfter} from "./render";
|
||||||
import Range from "./range";
|
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) {
|
if (selection instanceof Range) {
|
||||||
setDocumentRangeSelection(editor, model, selection);
|
setDocumentRangeSelection(editor, model, selection);
|
||||||
} else {
|
} 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();
|
const sel = document.getSelection();
|
||||||
sel.removeAllRanges();
|
sel.removeAllRanges();
|
||||||
const selectionRange = document.createRange();
|
const selectionRange = document.createRange();
|
||||||
|
@ -37,7 +42,7 @@ function setDocumentRangeSelection(editor, model, range) {
|
||||||
sel.addRange(selectionRange);
|
sel.addRange(selectionRange);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setCaretPosition(editor, model, caretPosition) {
|
export function setCaretPosition(editor: HTMLDivElement, model: EditorModel, caretPosition: IPosition) {
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
const {node, offset} = getNodeAndOffsetForPosition(editor, model, caretPosition);
|
const {node, offset} = getNodeAndOffsetForPosition(editor, model, caretPosition);
|
||||||
range.setStart(node, offset);
|
range.setStart(node, offset);
|
||||||
|
@ -62,7 +67,7 @@ export function setCaretPosition(editor, model, caretPosition) {
|
||||||
sel.addRange(range);
|
sel.addRange(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNodeAndOffsetForPosition(editor, model, position) {
|
function getNodeAndOffsetForPosition(editor: HTMLDivElement, model: EditorModel, position: IPosition) {
|
||||||
const {offset, lineIndex, nodeIndex} = getLineAndNodePosition(model, position);
|
const {offset, lineIndex, nodeIndex} = getLineAndNodePosition(model, position);
|
||||||
const lineNode = editor.childNodes[lineIndex];
|
const lineNode = editor.childNodes[lineIndex];
|
||||||
|
|
||||||
|
@ -80,7 +85,7 @@ function getNodeAndOffsetForPosition(editor, model, position) {
|
||||||
return {node: focusNode, offset};
|
return {node: focusNode, offset};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLineAndNodePosition(model, caretPosition) {
|
export function getLineAndNodePosition(model: EditorModel, caretPosition: IPosition) {
|
||||||
const {parts} = model;
|
const {parts} = model;
|
||||||
const partIndex = caretPosition.index;
|
const partIndex = caretPosition.index;
|
||||||
const lineResult = findNodeInLineForPart(parts, partIndex);
|
const lineResult = findNodeInLineForPart(parts, partIndex);
|
||||||
|
@ -99,7 +104,7 @@ export function getLineAndNodePosition(model, caretPosition) {
|
||||||
return {lineIndex, nodeIndex, offset};
|
return {lineIndex, nodeIndex, offset};
|
||||||
}
|
}
|
||||||
|
|
||||||
function findNodeInLineForPart(parts, partIndex) {
|
function findNodeInLineForPart(parts: Part[], partIndex: number) {
|
||||||
let lineIndex = 0;
|
let lineIndex = 0;
|
||||||
let nodeIndex = -1;
|
let nodeIndex = -1;
|
||||||
|
|
||||||
|
@ -135,7 +140,7 @@ function findNodeInLineForPart(parts, partIndex) {
|
||||||
return {lineIndex, nodeIndex};
|
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
|
// move caret before or after uneditable part
|
||||||
const part = parts[partIndex];
|
const part = parts[partIndex];
|
||||||
if (part && !part.canEdit) {
|
if (part && !part.canEdit) {
|
|
@ -257,7 +257,7 @@ function parseHtmlMessage(html: string, partCreator: PartCreator, isQuotedMessag
|
||||||
return parts;
|
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
|
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) => {
|
return lines.reduce((parts, line, i) => {
|
||||||
if (isQuotedMessage) {
|
if (isQuotedMessage) {
|
||||||
|
|
|
@ -15,7 +15,13 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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);
|
const compareLen = Math.min(a.length, b.length);
|
||||||
for (let i = 0; i < compareLen; ++i) {
|
for (let i = 0; i < compareLen; ++i) {
|
||||||
if (a[i] !== b[i]) {
|
if (a[i] !== b[i]) {
|
||||||
|
@ -25,7 +31,7 @@ function firstDiff(a, b) {
|
||||||
return compareLen;
|
return compareLen;
|
||||||
}
|
}
|
||||||
|
|
||||||
function diffStringsAtEnd(oldStr, newStr) {
|
function diffStringsAtEnd(oldStr: string, newStr: string): IDiff {
|
||||||
const len = Math.min(oldStr.length, newStr.length);
|
const len = Math.min(oldStr.length, newStr.length);
|
||||||
const startInCommon = oldStr.substr(0, len) === newStr.substr(0, len);
|
const startInCommon = oldStr.substr(0, len) === newStr.substr(0, len);
|
||||||
if (startInCommon && oldStr.length > newStr.length) {
|
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
|
// 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) {
|
if (oldStr === newStr) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
@ -61,7 +67,7 @@ export function diffDeletion(oldStr, newStr) {
|
||||||
* `added` with the added string (if any), and
|
* `added` with the added string (if any), and
|
||||||
* `removed` with the removed string (if any)
|
* `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 diffLen = newValue.length - oldValue.length;
|
||||||
const caretPositionBeforeInput = caretPosition - diffLen;
|
const caretPositionBeforeInput = caretPosition - diffLen;
|
||||||
const oldValueBeforeCaret = oldValue.substr(0, caretPositionBeforeInput);
|
const oldValueBeforeCaret = oldValue.substr(0, caretPositionBeforeInput);
|
|
@ -17,8 +17,12 @@ limitations under the License.
|
||||||
|
|
||||||
import {CARET_NODE_CHAR, isCaretNode} from "./render";
|
import {CARET_NODE_CHAR, isCaretNode} from "./render";
|
||||||
import DocumentOffset from "./offset";
|
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;
|
let node = rootNode.firstChild;
|
||||||
while (node && node !== rootNode) {
|
while (node && node !== rootNode) {
|
||||||
const shouldDescend = enterNodeCallback(node);
|
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);
|
const {offset, text} = getSelectionOffsetAndText(editor, sel.focusNode, sel.focusOffset);
|
||||||
return {caret: offset, text};
|
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,
|
// 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.
|
// 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
|
// 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 {node, characterOffset} = tryReduceSelectionToTextNode(selectionNode, selectionOffset);
|
||||||
const {text, offsetToNode} = getTextAndOffsetToNode(editor, node);
|
const {text, offsetToNode} = getTextAndOffsetToNode(editor, node);
|
||||||
const offset = getCaret(node, offsetToNode, characterOffset);
|
const offset = getCaret(node, offsetToNode, characterOffset);
|
||||||
|
@ -91,7 +95,7 @@ function getSelectionOffsetAndText(editor, selectionNode, selectionOffset) {
|
||||||
|
|
||||||
// gets the caret position details, ignoring and adjusting to
|
// gets the caret position details, ignoring and adjusting to
|
||||||
// the ZWS if you're typing in a caret node
|
// 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 no node is selected, return an offset at the start
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return new DocumentOffset(0, false);
|
return new DocumentOffset(0, false);
|
||||||
|
@ -114,7 +118,7 @@ function getCaret(node, offsetToNode, offsetWithinNode) {
|
||||||
// gets the text of the editor as a string,
|
// gets the text of the editor as a string,
|
||||||
// and the offset in characters where the selectionNode starts in that string
|
// and the offset in characters where the selectionNode starts in that string
|
||||||
// all ZWS from caret nodes are filtered out
|
// all ZWS from caret nodes are filtered out
|
||||||
function getTextAndOffsetToNode(editor, selectionNode) {
|
function getTextAndOffsetToNode(editor: HTMLDivElement, selectionNode: Node) {
|
||||||
let offsetToNode = 0;
|
let offsetToNode = 0;
|
||||||
let foundNode = false;
|
let foundNode = false;
|
||||||
let text = "";
|
let text = "";
|
|
@ -14,25 +14,40 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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 const MAX_STEP_LENGTH = 10;
|
||||||
|
|
||||||
export default class HistoryManager {
|
export default class HistoryManager {
|
||||||
constructor() {
|
private stack: IHistory[] = [];
|
||||||
this.clear();
|
private newlyTypedCharCount = 0;
|
||||||
}
|
private currentIndex = -1;
|
||||||
|
private changedSinceLastPush = false;
|
||||||
|
private lastCaret: Caret = null;
|
||||||
|
private nonWordBoundarySinceLastPush = false;
|
||||||
|
private addedSinceLastPush = false;
|
||||||
|
private removedSinceLastPush = false;
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this._stack = [];
|
this.stack = [];
|
||||||
this._newlyTypedCharCount = 0;
|
this.newlyTypedCharCount = 0;
|
||||||
this._currentIndex = -1;
|
this.currentIndex = -1;
|
||||||
this._changedSinceLastPush = false;
|
this.changedSinceLastPush = false;
|
||||||
this._lastCaret = null;
|
this.lastCaret = null;
|
||||||
this._nonWordBoundarySinceLastPush = false;
|
this.nonWordBoundarySinceLastPush = false;
|
||||||
this._addedSinceLastPush = false;
|
this.addedSinceLastPush = false;
|
||||||
this._removedSinceLastPush = false;
|
this.removedSinceLastPush = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_shouldPush(inputType, diff) {
|
private shouldPush(inputType, diff) {
|
||||||
// right now we can only push a step after
|
// right now we can only push a step after
|
||||||
// the input has been applied to the model,
|
// the input has been applied to the model,
|
||||||
// so we can't push the state before something happened.
|
// so we can't push the state before something happened.
|
||||||
|
@ -43,24 +58,24 @@ export default class HistoryManager {
|
||||||
inputType === "deleteContentBackward";
|
inputType === "deleteContentBackward";
|
||||||
if (diff && isNonBulkInput) {
|
if (diff && isNonBulkInput) {
|
||||||
if (diff.added) {
|
if (diff.added) {
|
||||||
this._addedSinceLastPush = true;
|
this.addedSinceLastPush = true;
|
||||||
}
|
}
|
||||||
if (diff.removed) {
|
if (diff.removed) {
|
||||||
this._removedSinceLastPush = true;
|
this.removedSinceLastPush = true;
|
||||||
}
|
}
|
||||||
// as long as you've only been adding or removing since the last push
|
// 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
|
// add steps by word boundary, up to MAX_STEP_LENGTH characters
|
||||||
const str = diff.added ? diff.added : diff.removed;
|
const str = diff.added ? diff.added : diff.removed;
|
||||||
const isWordBoundary = str === " " || str === "\t" || str === "\n";
|
const isWordBoundary = str === " " || str === "\t" || str === "\n";
|
||||||
if (this._nonWordBoundarySinceLastPush && isWordBoundary) {
|
if (this.nonWordBoundarySinceLastPush && isWordBoundary) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!isWordBoundary) {
|
if (!isWordBoundary) {
|
||||||
this._nonWordBoundarySinceLastPush = true;
|
this.nonWordBoundarySinceLastPush = true;
|
||||||
}
|
}
|
||||||
this._newlyTypedCharCount += str.length;
|
this.newlyTypedCharCount += str.length;
|
||||||
return this._newlyTypedCharCount > MAX_STEP_LENGTH;
|
return this.newlyTypedCharCount > MAX_STEP_LENGTH;
|
||||||
} else {
|
} else {
|
||||||
// if starting to remove while adding before, or the opposite, push
|
// if starting to remove while adding before, or the opposite, push
|
||||||
return true;
|
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
|
// remove all steps after current step
|
||||||
while (this._currentIndex < (this._stack.length - 1)) {
|
while (this.currentIndex < (this.stack.length - 1)) {
|
||||||
this._stack.pop();
|
this.stack.pop();
|
||||||
}
|
}
|
||||||
const parts = model.serializeParts();
|
const parts = model.serializeParts();
|
||||||
this._stack.push({parts, caret});
|
this.stack.push({parts, caret});
|
||||||
this._currentIndex = this._stack.length - 1;
|
this.currentIndex = this.stack.length - 1;
|
||||||
this._lastCaret = null;
|
this.lastCaret = null;
|
||||||
this._changedSinceLastPush = false;
|
this.changedSinceLastPush = false;
|
||||||
this._newlyTypedCharCount = 0;
|
this.newlyTypedCharCount = 0;
|
||||||
this._nonWordBoundarySinceLastPush = false;
|
this.nonWordBoundarySinceLastPush = false;
|
||||||
this._addedSinceLastPush = false;
|
this.addedSinceLastPush = false;
|
||||||
this._removedSinceLastPush = false;
|
this.removedSinceLastPush = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// needs to persist parts and caret position
|
// 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.
|
// ignore state restoration echos.
|
||||||
// these respect the inputType values of the input event,
|
// these respect the inputType values of the input event,
|
||||||
// but are actually passed in from MessageEditor calling model.reset()
|
// but are actually passed in from MessageEditor calling model.reset()
|
||||||
|
@ -96,45 +111,45 @@ export default class HistoryManager {
|
||||||
if (inputType === "historyUndo" || inputType === "historyRedo") {
|
if (inputType === "historyUndo" || inputType === "historyRedo") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const shouldPush = this._shouldPush(inputType, diff);
|
const shouldPush = this.shouldPush(inputType, diff);
|
||||||
if (shouldPush) {
|
if (shouldPush) {
|
||||||
this._pushState(model, caret);
|
this.pushState(model, caret);
|
||||||
} else {
|
} else {
|
||||||
this._lastCaret = caret;
|
this.lastCaret = caret;
|
||||||
this._changedSinceLastPush = true;
|
this.changedSinceLastPush = true;
|
||||||
}
|
}
|
||||||
return shouldPush;
|
return shouldPush;
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureLastChangesPushed(model) {
|
ensureLastChangesPushed(model: EditorModel) {
|
||||||
if (this._changedSinceLastPush) {
|
if (this.changedSinceLastPush) {
|
||||||
this._pushState(model, this._lastCaret);
|
this.pushState(model, this.lastCaret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
canUndo() {
|
canUndo() {
|
||||||
return this._currentIndex >= 1 || this._changedSinceLastPush;
|
return this.currentIndex >= 1 || this.changedSinceLastPush;
|
||||||
}
|
}
|
||||||
|
|
||||||
canRedo() {
|
canRedo() {
|
||||||
return this._currentIndex < (this._stack.length - 1);
|
return this.currentIndex < (this.stack.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns state that should be applied to model
|
// returns state that should be applied to model
|
||||||
undo(model) {
|
undo(model: EditorModel) {
|
||||||
if (this.canUndo()) {
|
if (this.canUndo()) {
|
||||||
this.ensureLastChangesPushed(model);
|
this.ensureLastChangesPushed(model);
|
||||||
this._currentIndex -= 1;
|
this.currentIndex -= 1;
|
||||||
return this._stack[this._currentIndex];
|
return this.stack[this.currentIndex];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns state that should be applied to model
|
// returns state that should be applied to model
|
||||||
redo() {
|
redo() {
|
||||||
if (this.canRedo()) {
|
if (this.canRedo()) {
|
||||||
this._changedSinceLastPush = false;
|
this.changedSinceLastPush = false;
|
||||||
this._currentIndex += 1;
|
this.currentIndex += 1;
|
||||||
return this._stack[this._currentIndex];
|
return this.stack[this.currentIndex];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -15,9 +15,13 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {diffAtCaret, diffDeletion} from "./diff";
|
import {diffAtCaret, diffDeletion, IDiff} from "./diff";
|
||||||
import DocumentPosition from "./position";
|
import DocumentPosition, {IPosition} from "./position";
|
||||||
import Range from "./range";
|
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
|
* @callback ModelCallback
|
||||||
|
@ -40,16 +44,23 @@ import Range from "./range";
|
||||||
* @return the caret position
|
* @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 {
|
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._parts = parts;
|
||||||
this._partCreator = partCreator;
|
this._partCreator = partCreator;
|
||||||
this._activePartIdx = null;
|
this.transformCallback = null;
|
||||||
this._autoComplete = null;
|
|
||||||
this._autoCompletePartIdx = null;
|
|
||||||
this._autoCompletePartCount = 0;
|
|
||||||
this._transformCallback = null;
|
|
||||||
this.setUpdateCallback(updateCallback);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -59,16 +70,16 @@ export default class EditorModel {
|
||||||
* on the model that can span multiple parts. Also see `startRange()`.
|
* on the model that can span multiple parts. Also see `startRange()`.
|
||||||
* @param {TransformCallback} transformCallback
|
* @param {TransformCallback} transformCallback
|
||||||
*/
|
*/
|
||||||
setTransformCallback(transformCallback) {
|
setTransformCallback(transformCallback: TransformCallback) {
|
||||||
this._transformCallback = transformCallback;
|
this.transformCallback = transformCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a callback for rerendering the model after it has been updated.
|
* Set a callback for rerendering the model after it has been updated.
|
||||||
* @param {ModelCallback} updateCallback
|
* @param {ModelCallback} updateCallback
|
||||||
*/
|
*/
|
||||||
setUpdateCallback(updateCallback) {
|
setUpdateCallback(updateCallback: UpdateCallback) {
|
||||||
this._updateCallback = updateCallback;
|
this.updateCallback = updateCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
get partCreator() {
|
get partCreator() {
|
||||||
|
@ -80,34 +91,34 @@ export default class EditorModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
clone() {
|
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);
|
this._parts.splice(index, 0, part);
|
||||||
if (this._activePartIdx >= index) {
|
if (this.activePartIdx >= index) {
|
||||||
++this._activePartIdx;
|
++this.activePartIdx;
|
||||||
}
|
}
|
||||||
if (this._autoCompletePartIdx >= index) {
|
if (this.autoCompletePartIdx >= index) {
|
||||||
++this._autoCompletePartIdx;
|
++this.autoCompletePartIdx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_removePart(index) {
|
private removePart(index: number) {
|
||||||
this._parts.splice(index, 1);
|
this._parts.splice(index, 1);
|
||||||
if (index === this._activePartIdx) {
|
if (index === this.activePartIdx) {
|
||||||
this._activePartIdx = null;
|
this.activePartIdx = null;
|
||||||
} else if (this._activePartIdx > index) {
|
} else if (this.activePartIdx > index) {
|
||||||
--this._activePartIdx;
|
--this.activePartIdx;
|
||||||
}
|
}
|
||||||
if (index === this._autoCompletePartIdx) {
|
if (index === this.autoCompletePartIdx) {
|
||||||
this._autoCompletePartIdx = null;
|
this.autoCompletePartIdx = null;
|
||||||
} else if (this._autoCompletePartIdx > index) {
|
} else if (this.autoCompletePartIdx > index) {
|
||||||
--this._autoCompletePartIdx;
|
--this.autoCompletePartIdx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_replacePart(index, part) {
|
private replacePart(index: number, part: Part) {
|
||||||
this._parts.splice(index, 1, part);
|
this._parts.splice(index, 1, part);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,7 +127,7 @@ export default class EditorModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
get autoComplete() {
|
get autoComplete() {
|
||||||
if (this._activePartIdx === this._autoCompletePartIdx) {
|
if (this.activePartIdx === this.autoCompletePartIdx) {
|
||||||
return this._autoComplete;
|
return this._autoComplete;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
@ -137,7 +148,7 @@ export default class EditorModel {
|
||||||
return this._parts.map(p => p.serialize());
|
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, "");
|
const previousValue = this.parts.reduce((text, p) => text + p.text, "");
|
||||||
// can't use caret position with drag and drop
|
// can't use caret position with drag and drop
|
||||||
if (inputType === "deleteByDrag") {
|
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));
|
this._parts = serializedParts.map(p => this._partCreator.deserializePart(p));
|
||||||
if (!caret) {
|
if (!caret) {
|
||||||
caret = this.getPositionAtEnd();
|
caret = this.getPositionAtEnd();
|
||||||
|
@ -157,9 +168,9 @@ export default class EditorModel {
|
||||||
// a message with the autocomplete still open
|
// a message with the autocomplete still open
|
||||||
if (this._autoComplete) {
|
if (this._autoComplete) {
|
||||||
this._autoComplete = null;
|
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
|
* @param {DocumentPosition} position the position to start inserting at
|
||||||
* @return {Number} the amount of characters added
|
* @return {Number} the amount of characters added
|
||||||
*/
|
*/
|
||||||
insert(parts, position) {
|
insert(parts: Part[], position: IPosition) {
|
||||||
const insertIndex = this._splitAt(position);
|
const insertIndex = this.splitAt(position);
|
||||||
let newTextLength = 0;
|
let newTextLength = 0;
|
||||||
for (let i = 0; i < parts.length; ++i) {
|
for (let i = 0; i < parts.length; ++i) {
|
||||||
const part = parts[i];
|
const part = parts[i];
|
||||||
newTextLength += part.text.length;
|
newTextLength += part.text.length;
|
||||||
this._insertPart(insertIndex + i, part);
|
this.insertPart(insertIndex + i, part);
|
||||||
}
|
}
|
||||||
return newTextLength;
|
return newTextLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(newValue, inputType, caret) {
|
update(newValue: string, inputType: string, caret: DocumentOffset) {
|
||||||
const diff = this._diff(newValue, inputType, caret);
|
const diff = this.diff(newValue, inputType, caret);
|
||||||
const position = this.positionForOffset(diff.at, caret.atNodeEnd);
|
const position = this.positionForOffset(diff.at, caret.atNodeEnd);
|
||||||
let removedOffsetDecrease = 0;
|
let removedOffsetDecrease = 0;
|
||||||
if (diff.removed) {
|
if (diff.removed) {
|
||||||
|
@ -189,40 +200,40 @@ export default class EditorModel {
|
||||||
}
|
}
|
||||||
let addedLen = 0;
|
let addedLen = 0;
|
||||||
if (diff.added) {
|
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;
|
const caretOffset = diff.at - removedOffsetDecrease + addedLen;
|
||||||
let newPosition = this.positionForOffset(caretOffset, true);
|
let newPosition = this.positionForOffset(caretOffset, true);
|
||||||
const canOpenAutoComplete = inputType !== "insertFromPaste" && inputType !== "insertFromDrop";
|
const canOpenAutoComplete = inputType !== "insertFromPaste" && inputType !== "insertFromDrop";
|
||||||
const acPromise = this._setActivePart(newPosition, canOpenAutoComplete);
|
const acPromise = this.setActivePart(newPosition, canOpenAutoComplete);
|
||||||
if (this._transformCallback) {
|
if (this.transformCallback) {
|
||||||
const transformAddedLen = this._transform(newPosition, inputType, diff);
|
const transformAddedLen = this.getTransformAddedLen(newPosition, inputType, diff);
|
||||||
newPosition = this.positionForOffset(caretOffset + transformAddedLen, true);
|
newPosition = this.positionForOffset(caretOffset + transformAddedLen, true);
|
||||||
}
|
}
|
||||||
this._updateCallback(newPosition, inputType, diff);
|
this.updateCallback(newPosition, inputType, diff);
|
||||||
return acPromise;
|
return acPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
_transform(newPosition, inputType, diff) {
|
private getTransformAddedLen(newPosition: DocumentPosition, inputType: string, diff: IDiff): number {
|
||||||
const result = this._transformCallback(newPosition, inputType, diff);
|
const result = this.transformCallback(newPosition, inputType, diff);
|
||||||
return Number.isFinite(result) ? result : 0;
|
return Number.isFinite(result) ? result as number : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
_setActivePart(pos, canOpenAutoComplete) {
|
private setActivePart(pos: DocumentPosition, canOpenAutoComplete: boolean) {
|
||||||
const {index} = pos;
|
const {index} = pos;
|
||||||
const part = this._parts[index];
|
const part = this._parts[index];
|
||||||
if (part) {
|
if (part) {
|
||||||
if (index !== this._activePartIdx) {
|
if (index !== this.activePartIdx) {
|
||||||
this._activePartIdx = index;
|
this.activePartIdx = index;
|
||||||
if (canOpenAutoComplete && this._activePartIdx !== this._autoCompletePartIdx) {
|
if (canOpenAutoComplete && this.activePartIdx !== this.autoCompletePartIdx) {
|
||||||
// else try to create one
|
// else try to create one
|
||||||
const ac = part.createAutoComplete(this._onAutoComplete);
|
const ac = part.createAutoComplete(this.onAutoComplete);
|
||||||
if (ac) {
|
if (ac) {
|
||||||
// make sure that react picks up the difference between both acs
|
// make sure that react picks up the difference between both acs
|
||||||
this._autoComplete = ac;
|
this._autoComplete = ac;
|
||||||
this._autoCompletePartIdx = index;
|
this.autoCompletePartIdx = index;
|
||||||
this._autoCompletePartCount = 1;
|
this.autoCompletePartCount = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -231,35 +242,35 @@ export default class EditorModel {
|
||||||
return this.autoComplete.onPartUpdate(part, pos);
|
return this.autoComplete.onPartUpdate(part, pos);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this._activePartIdx = null;
|
this.activePartIdx = null;
|
||||||
this._autoComplete = null;
|
this._autoComplete = null;
|
||||||
this._autoCompletePartIdx = null;
|
this.autoCompletePartIdx = null;
|
||||||
this._autoCompletePartCount = 0;
|
this.autoCompletePartCount = 0;
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
_onAutoComplete = ({replaceParts, close}) => {
|
private onAutoComplete = ({replaceParts, close}: ICallback) => {
|
||||||
let pos;
|
let pos;
|
||||||
if (replaceParts) {
|
if (replaceParts) {
|
||||||
this._parts.splice(this._autoCompletePartIdx, this._autoCompletePartCount, ...replaceParts);
|
this._parts.splice(this.autoCompletePartIdx, this.autoCompletePartCount, ...replaceParts);
|
||||||
this._autoCompletePartCount = replaceParts.length;
|
this.autoCompletePartCount = replaceParts.length;
|
||||||
const lastPart = replaceParts[replaceParts.length - 1];
|
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);
|
pos = new DocumentPosition(lastPartIndex, lastPart.text.length);
|
||||||
}
|
}
|
||||||
if (close) {
|
if (close) {
|
||||||
this._autoComplete = null;
|
this._autoComplete = null;
|
||||||
this._autoCompletePartIdx = null;
|
this.autoCompletePartIdx = null;
|
||||||
this._autoCompletePartCount = 0;
|
this.autoCompletePartCount = 0;
|
||||||
}
|
}
|
||||||
// rerender even if editor contents didn't change
|
// rerender even if editor contents didn't change
|
||||||
// to make sure the MessageEditor checks
|
// to make sure the MessageEditor checks
|
||||||
// model.autoComplete being empty and closes it
|
// model.autoComplete being empty and closes it
|
||||||
this._updateCallback(pos);
|
this.updateCallback(pos);
|
||||||
}
|
};
|
||||||
|
|
||||||
_mergeAdjacentParts() {
|
private mergeAdjacentParts() {
|
||||||
let prevPart;
|
let prevPart;
|
||||||
for (let i = 0; i < this._parts.length; ++i) {
|
for (let i = 0; i < this._parts.length; ++i) {
|
||||||
let part = this._parts[i];
|
let part = this._parts[i];
|
||||||
|
@ -268,7 +279,7 @@ export default class EditorModel {
|
||||||
if (isEmpty || isMerged) {
|
if (isEmpty || isMerged) {
|
||||||
// remove empty or merged part
|
// remove empty or merged part
|
||||||
part = prevPart;
|
part = prevPart;
|
||||||
this._removePart(i);
|
this.removePart(i);
|
||||||
//repeat this index, as it's removed now
|
//repeat this index, as it's removed now
|
||||||
--i;
|
--i;
|
||||||
}
|
}
|
||||||
|
@ -283,7 +294,7 @@ export default class EditorModel {
|
||||||
* @return {Number} how many characters before pos were also removed,
|
* @return {Number} how many characters before pos were also removed,
|
||||||
* usually because of non-editable parts that can only be removed in their entirety.
|
* 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 {index, offset} = pos;
|
||||||
let removedOffsetDecrease = 0;
|
let removedOffsetDecrease = 0;
|
||||||
while (len > 0) {
|
while (len > 0) {
|
||||||
|
@ -295,18 +306,18 @@ export default class EditorModel {
|
||||||
if (part.canEdit) {
|
if (part.canEdit) {
|
||||||
const replaceWith = part.remove(offset, amount);
|
const replaceWith = part.remove(offset, amount);
|
||||||
if (typeof replaceWith === "string") {
|
if (typeof replaceWith === "string") {
|
||||||
this._replacePart(index, this._partCreator.createDefaultPart(replaceWith));
|
this.replacePart(index, this._partCreator.createDefaultPart(replaceWith));
|
||||||
}
|
}
|
||||||
part = this._parts[index];
|
part = this._parts[index];
|
||||||
// remove empty part
|
// remove empty part
|
||||||
if (!part.text.length) {
|
if (!part.text.length) {
|
||||||
this._removePart(index);
|
this.removePart(index);
|
||||||
} else {
|
} else {
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
removedOffsetDecrease += offset;
|
removedOffsetDecrease += offset;
|
||||||
this._removePart(index);
|
this.removePart(index);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
index += 1;
|
index += 1;
|
||||||
|
@ -316,8 +327,9 @@ export default class EditorModel {
|
||||||
}
|
}
|
||||||
return removedOffsetDecrease;
|
return removedOffsetDecrease;
|
||||||
}
|
}
|
||||||
|
|
||||||
// return part index where insertion will insert between at offset
|
// return part index where insertion will insert between at offset
|
||||||
_splitAt(pos) {
|
private splitAt(pos: IPosition) {
|
||||||
if (pos.index === -1) {
|
if (pos.index === -1) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
@ -330,7 +342,7 @@ export default class EditorModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
const secondPart = part.split(pos.offset);
|
const secondPart = part.split(pos.offset);
|
||||||
this._insertPart(pos.index + 1, secondPart);
|
this.insertPart(pos.index + 1, secondPart);
|
||||||
return pos.index + 1;
|
return pos.index + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -344,7 +356,7 @@ export default class EditorModel {
|
||||||
* @return {Number} how far from position (in characters) the insertion ended.
|
* @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.
|
* 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;
|
let {index} = pos;
|
||||||
const {offset} = pos;
|
const {offset} = pos;
|
||||||
let addLen = str.length;
|
let addLen = str.length;
|
||||||
|
@ -356,7 +368,7 @@ export default class EditorModel {
|
||||||
} else {
|
} else {
|
||||||
const splitPart = part.split(offset);
|
const splitPart = part.split(offset);
|
||||||
index += 1;
|
index += 1;
|
||||||
this._insertPart(index, splitPart);
|
this.insertPart(index, splitPart);
|
||||||
}
|
}
|
||||||
} else if (offset !== 0) {
|
} else if (offset !== 0) {
|
||||||
// not-editable part, caret is not at start,
|
// not-editable part, caret is not at start,
|
||||||
|
@ -372,13 +384,13 @@ export default class EditorModel {
|
||||||
while (str) {
|
while (str) {
|
||||||
const newPart = this._partCreator.createPartForInput(str, index, inputType);
|
const newPart = this._partCreator.createPartForInput(str, index, inputType);
|
||||||
str = newPart.appendUntilRejected(str, inputType);
|
str = newPart.appendUntilRejected(str, inputType);
|
||||||
this._insertPart(index, newPart);
|
this.insertPart(index, newPart);
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
return addLen;
|
return addLen;
|
||||||
}
|
}
|
||||||
|
|
||||||
positionForOffset(totalOffset, atPartEnd) {
|
positionForOffset(totalOffset: number, atPartEnd: boolean) {
|
||||||
let currentOffset = 0;
|
let currentOffset = 0;
|
||||||
const index = this._parts.findIndex(part => {
|
const index = this._parts.findIndex(part => {
|
||||||
const partLen = part.text.length;
|
const partLen = part.text.length;
|
||||||
|
@ -404,28 +416,27 @@ export default class EditorModel {
|
||||||
* @param {DocumentPosition?} positionB the other boundary of the range, optional
|
* @param {DocumentPosition?} positionB the other boundary of the range, optional
|
||||||
* @return {Range}
|
* @return {Range}
|
||||||
*/
|
*/
|
||||||
startRange(positionA, positionB = positionA) {
|
startRange(positionA: DocumentPosition, positionB = positionA) {
|
||||||
return new Range(this, positionA, positionB);
|
return new Range(this, positionA, positionB);
|
||||||
}
|
}
|
||||||
|
|
||||||
// called from Range.replace
|
replaceRange(startPosition: DocumentPosition, endPosition: DocumentPosition, parts: Part[]) {
|
||||||
_replaceRange(startPosition, endPosition, parts) {
|
|
||||||
// convert end position to offset, so it is independent of how the document is split into parts
|
// 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
|
// which we'll change when splitting up at the start position
|
||||||
const endOffset = endPosition.asOffset(this);
|
const endOffset = endPosition.asOffset(this);
|
||||||
const newStartPartIndex = this._splitAt(startPosition);
|
const newStartPartIndex = this.splitAt(startPosition);
|
||||||
// convert it back to position once split at start
|
// convert it back to position once split at start
|
||||||
endPosition = endOffset.asPosition(this);
|
endPosition = endOffset.asPosition(this);
|
||||||
const newEndPartIndex = this._splitAt(endPosition);
|
const newEndPartIndex = this.splitAt(endPosition);
|
||||||
for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) {
|
for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) {
|
||||||
this._removePart(i);
|
this.removePart(i);
|
||||||
}
|
}
|
||||||
let insertIdx = newStartPartIndex;
|
let insertIdx = newStartPartIndex;
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
this._insertPart(insertIdx, part);
|
this.insertPart(insertIdx, part);
|
||||||
insertIdx += 1;
|
insertIdx += 1;
|
||||||
}
|
}
|
||||||
this._mergeAdjacentParts();
|
this.mergeAdjacentParts();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -434,15 +445,15 @@ export default class EditorModel {
|
||||||
* @param {ManualTransformCallback} callback to run the transformations in
|
* @param {ManualTransformCallback} callback to run the transformations in
|
||||||
* @return {Promise} a promise when auto-complete (if applicable) is done updating
|
* @return {Promise} a promise when auto-complete (if applicable) is done updating
|
||||||
*/
|
*/
|
||||||
transform(callback) {
|
transform(callback: ManualTransformCallback) {
|
||||||
const pos = callback();
|
const pos = callback();
|
||||||
let acPromise = null;
|
let acPromise = null;
|
||||||
if (!(pos instanceof Range)) {
|
if (!(pos instanceof Range)) {
|
||||||
acPromise = this._setActivePart(pos, true);
|
acPromise = this.setActivePart(pos, true);
|
||||||
} else {
|
} else {
|
||||||
acPromise = Promise.resolve();
|
acPromise = Promise.resolve();
|
||||||
}
|
}
|
||||||
this._updateCallback(pos);
|
this.updateCallback(pos);
|
||||||
return acPromise;
|
return acPromise;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -14,17 +14,17 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import EditorModel from "./model";
|
||||||
|
|
||||||
export default class DocumentOffset {
|
export default class DocumentOffset {
|
||||||
constructor(offset, atNodeEnd) {
|
constructor(public offset: number, public readonly atNodeEnd: boolean) {
|
||||||
this.offset = offset;
|
|
||||||
this.atNodeEnd = atNodeEnd;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
asPosition(model) {
|
asPosition(model: EditorModel) {
|
||||||
return model.positionForOffset(this.offset, this.atNodeEnd);
|
return model.positionForOffset(this.offset, this.atNodeEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
add(delta, atNodeEnd = false) {
|
add(delta: number, atNodeEnd = false) {
|
||||||
return new DocumentOffset(this.offset + delta, atNodeEnd);
|
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.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import Range from "./range";
|
||||||
|
import {Part} from "./parts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Some common queries and transformations on the editor model
|
* Some common queries and transformations on the editor model
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function replaceRangeAndExpandSelection(range, newParts) {
|
export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]) {
|
||||||
const {model} = range;
|
const {model} = range;
|
||||||
model.transform(() => {
|
model.transform(() => {
|
||||||
const oldLen = range.length;
|
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;
|
const {model} = range;
|
||||||
model.transform(() => {
|
model.transform(() => {
|
||||||
const oldLen = range.length;
|
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 {model} = range;
|
||||||
const startsWithPartial = range.start.offset !== 0;
|
const startsWithPartial = range.start.offset !== 0;
|
||||||
const isFirstPart = range.start.index === 0;
|
const isFirstPart = range.start.index === 0;
|
||||||
|
@ -48,16 +51,16 @@ export function rangeStartsAtBeginningOfLine(range) {
|
||||||
return !startsWithPartial && (isFirstPart || previousIsNewline);
|
return !startsWithPartial && (isFirstPart || previousIsNewline);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rangeEndsAtEndOfLine(range) {
|
export function rangeEndsAtEndOfLine(range: Range) {
|
||||||
const {model} = range;
|
const {model} = range;
|
||||||
const lastPart = model.parts[range.end.index];
|
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 isLastPart = range.end.index === model.parts.length - 1;
|
||||||
const nextIsNewline = !isLastPart && model.parts[range.end.index + 1].type === "newline";
|
const nextIsNewline = !isLastPart && model.parts[range.end.index + 1].type === "newline";
|
||||||
return !endsWithPartial && (isLastPart || nextIsNewline);
|
return !endsWithPartial && (isLastPart || nextIsNewline);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRangeAsQuote(range) {
|
export function formatRangeAsQuote(range: Range) {
|
||||||
const {model, parts} = range;
|
const {model, parts} = range;
|
||||||
const {partCreator} = model;
|
const {partCreator} = model;
|
||||||
for (let i = 0; i < parts.length; ++i) {
|
for (let i = 0; i < parts.length; ++i) {
|
||||||
|
@ -78,7 +81,7 @@ export function formatRangeAsQuote(range) {
|
||||||
replaceRangeAndExpandSelection(range, parts);
|
replaceRangeAndExpandSelection(range, parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRangeAsCode(range) {
|
export function formatRangeAsCode(range: Range) {
|
||||||
const {model, parts} = range;
|
const {model, parts} = range;
|
||||||
const {partCreator} = model;
|
const {partCreator} = model;
|
||||||
const needsBlock = parts.some(p => p.type === "newline");
|
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 isBlank = part => !part.text || !/\S/.test(part.text);
|
||||||
const isNL = part => part.type === "newline";
|
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 {model, parts} = range;
|
||||||
const {partCreator} = model;
|
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
|
// keep track of how many things we have inserted as an offset:=0
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
paragraphIndexes.forEach(([startIndex, endIndex]) => {
|
paragraphIndexes.forEach(([startIdx, endIdx]) => {
|
||||||
// for each paragraph apply the same rule
|
// for each paragraph apply the same rule
|
||||||
const base = startIndex + offset;
|
const base = startIdx + offset;
|
||||||
const index = endIndex + offset;
|
const index = endIdx + offset;
|
||||||
|
|
||||||
const isFormatted = (index - base > 0) &&
|
const isFormatted = (index - base > 0) &&
|
||||||
parts[base].text.startsWith(prefix) &&
|
parts[base].text.startsWith(prefix) &&
|
|
@ -15,27 +15,89 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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";
|
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 = "") {
|
constructor(text = "") {
|
||||||
this._text = text;
|
this._text = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptsInsertion(chr, offset, inputType) {
|
acceptsInsertion(chr: string, offset: number, inputType: string) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptsRemoval(position, chr) {
|
acceptsRemoval(position: number, chr: string) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
merge(part) {
|
merge(part: Part) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
split(offset) {
|
split(offset: number) {
|
||||||
const splitText = this.text.substr(offset);
|
const splitText = this.text.substr(offset);
|
||||||
this._text = this.text.substr(0, offset);
|
this._text = this.text.substr(0, offset);
|
||||||
return new PlainPart(splitText);
|
return new PlainPart(splitText);
|
||||||
|
@ -43,7 +105,7 @@ class BasePart {
|
||||||
|
|
||||||
// removes len chars, or returns the plain text this part should be replaced with
|
// removes len chars, or returns the plain text this part should be replaced with
|
||||||
// if the part would become invalid if it removed everything.
|
// if the part would become invalid if it removed everything.
|
||||||
remove(offset, len) {
|
remove(offset: number, len: number) {
|
||||||
// validate
|
// validate
|
||||||
const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len);
|
const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len);
|
||||||
for (let i = offset; i < (len + offset); ++i) {
|
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.
|
// append str, returns the remaining string if a character was rejected.
|
||||||
appendUntilRejected(str, inputType) {
|
appendUntilRejected(str: string, inputType: string) {
|
||||||
const offset = this.text.length;
|
const offset = this.text.length;
|
||||||
for (let i = 0; i < str.length; ++i) {
|
for (let i = 0; i < str.length; ++i) {
|
||||||
const chr = str.charAt(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
|
// 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.
|
// 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) {
|
for (let i = 0; i < str.length; ++i) {
|
||||||
const chr = str.charAt(i);
|
const chr = str.charAt(i);
|
||||||
if (!this.acceptsInsertion(chr, offset + i, inputType)) {
|
if (!this.acceptsInsertion(chr, offset + i, inputType)) {
|
||||||
|
@ -83,9 +145,9 @@ class BasePart {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
createAutoComplete() {}
|
createAutoComplete(updateCallback: UpdateCallback): void {}
|
||||||
|
|
||||||
trim(len) {
|
trim(len: number) {
|
||||||
const remaining = this._text.substr(len);
|
const remaining = this._text.substr(len);
|
||||||
this._text = this._text.substr(0, len);
|
this._text = this._text.substr(0, len);
|
||||||
return remaining;
|
return remaining;
|
||||||
|
@ -95,6 +157,8 @@ class BasePart {
|
||||||
return this._text;
|
return this._text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract get type(): Type;
|
||||||
|
|
||||||
get canEdit() {
|
get canEdit() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -103,14 +167,20 @@ class BasePart {
|
||||||
return `${this.type}(${this.text})`;
|
return `${this.type}(${this.text})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
serialize() {
|
serialize(): SerializedPart {
|
||||||
return {type: this.type, text: this.text};
|
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
|
abstract class PlainBasePart extends BasePart {
|
||||||
export class PlainPart extends BasePart {
|
acceptsInsertion(chr: string, offset: number, inputType: string) {
|
||||||
acceptsInsertion(chr, offset, inputType) {
|
|
||||||
if (chr === "\n") {
|
if (chr === "\n") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -133,32 +203,34 @@ export class PlainPart extends BasePart {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
get type() {
|
updateDOMNode(node: Node) {
|
||||||
return "plain";
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDOMNode(node) {
|
|
||||||
if (node.textContent !== this.text) {
|
if (node.textContent !== this.text) {
|
||||||
node.textContent = this.text;
|
node.textContent = this.text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
canUpdateDOMNode(node) {
|
canUpdateDOMNode(node: Node) {
|
||||||
return node.nodeType === Node.TEXT_NODE;
|
return node.nodeType === Node.TEXT_NODE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PillPart extends BasePart {
|
// exported for unit tests, should otherwise only be used through PartCreator
|
||||||
constructor(resourceId, label) {
|
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);
|
super(label);
|
||||||
this.resourceId = resourceId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptsInsertion(chr) {
|
acceptsInsertion(chr: string) {
|
||||||
return chr !== " ";
|
return chr !== " ";
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptsRemoval(position, chr) {
|
acceptsRemoval(position: number, chr: string) {
|
||||||
return position !== 0; //if you remove initial # or @, pill should become plain
|
return position !== 0; //if you remove initial # or @, pill should become plain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,7 +243,7 @@ class PillPart extends BasePart {
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDOMNode(node) {
|
updateDOMNode(node: HTMLElement) {
|
||||||
const textNode = node.childNodes[0];
|
const textNode = node.childNodes[0];
|
||||||
if (textNode.textContent !== this.text) {
|
if (textNode.textContent !== this.text) {
|
||||||
textNode.textContent = this.text;
|
textNode.textContent = this.text;
|
||||||
|
@ -182,7 +254,7 @@ class PillPart extends BasePart {
|
||||||
this.setAvatar(node);
|
this.setAvatar(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
canUpdateDOMNode(node) {
|
canUpdateDOMNode(node: HTMLElement) {
|
||||||
return node.nodeType === Node.ELEMENT_NODE &&
|
return node.nodeType === Node.ELEMENT_NODE &&
|
||||||
node.nodeName === "SPAN" &&
|
node.nodeName === "SPAN" &&
|
||||||
node.childNodes.length === 1 &&
|
node.childNodes.length === 1 &&
|
||||||
|
@ -190,7 +262,7 @@ class PillPart extends BasePart {
|
||||||
}
|
}
|
||||||
|
|
||||||
// helper method for subclasses
|
// helper method for subclasses
|
||||||
_setAvatarVars(node, avatarUrl, initialLetter) {
|
_setAvatarVars(node: HTMLElement, avatarUrl: string, initialLetter: string) {
|
||||||
const avatarBackground = `url('${avatarUrl}')`;
|
const avatarBackground = `url('${avatarUrl}')`;
|
||||||
const avatarLetter = `'${initialLetter}'`;
|
const avatarLetter = `'${initialLetter}'`;
|
||||||
// check if the value is changing,
|
// check if the value is changing,
|
||||||
|
@ -206,14 +278,20 @@ class PillPart extends BasePart {
|
||||||
get canEdit() {
|
get canEdit() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract get type(): IPillPart["type"];
|
||||||
|
|
||||||
|
abstract get className(): string;
|
||||||
|
|
||||||
|
abstract setAvatar(node: HTMLElement): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
class NewlinePart extends BasePart {
|
class NewlinePart extends BasePart implements IBasePart {
|
||||||
acceptsInsertion(chr, offset) {
|
acceptsInsertion(chr: string, offset: number) {
|
||||||
return offset === 0 && chr === "\n";
|
return offset === 0 && chr === "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptsRemoval(position, chr) {
|
acceptsRemoval(position: number, chr: string) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -227,12 +305,12 @@ class NewlinePart extends BasePart {
|
||||||
|
|
||||||
updateDOMNode() {}
|
updateDOMNode() {}
|
||||||
|
|
||||||
canUpdateDOMNode(node) {
|
canUpdateDOMNode(node: HTMLElement) {
|
||||||
return node.tagName === "BR";
|
return node.tagName === "BR";
|
||||||
}
|
}
|
||||||
|
|
||||||
get type() {
|
get type(): IBasePart["type"] {
|
||||||
return "newline";
|
return Type.Newline;
|
||||||
}
|
}
|
||||||
|
|
||||||
// this makes the cursor skip this part when it is inserted
|
// this makes the cursor skip this part when it is inserted
|
||||||
|
@ -245,27 +323,26 @@ class NewlinePart extends BasePart {
|
||||||
}
|
}
|
||||||
|
|
||||||
class RoomPillPart extends PillPart {
|
class RoomPillPart extends PillPart {
|
||||||
constructor(displayAlias, room) {
|
constructor(displayAlias, private room: Room) {
|
||||||
super(displayAlias, displayAlias);
|
super(displayAlias, displayAlias);
|
||||||
this._room = room;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAvatar(node) {
|
setAvatar(node: HTMLElement) {
|
||||||
let initialLetter = "";
|
let initialLetter = "";
|
||||||
let avatarUrl = Avatar.avatarUrlForRoom(
|
let avatarUrl = Avatar.avatarUrlForRoom(
|
||||||
this._room,
|
this.room,
|
||||||
16 * window.devicePixelRatio,
|
16 * window.devicePixelRatio,
|
||||||
16 * window.devicePixelRatio,
|
16 * window.devicePixelRatio,
|
||||||
"crop");
|
"crop");
|
||||||
if (!avatarUrl) {
|
if (!avatarUrl) {
|
||||||
initialLetter = Avatar.getInitialLetter(this._room ? this._room.name : this.resourceId);
|
initialLetter = Avatar.getInitialLetter(this.room ? this.room.name : this.resourceId);
|
||||||
avatarUrl = Avatar.defaultAvatarUrlForString(this._room ? this._room.roomId : this.resourceId);
|
avatarUrl = Avatar.defaultAvatarUrlForString(this.room ? this.room.roomId : this.resourceId);
|
||||||
}
|
}
|
||||||
this._setAvatarVars(node, avatarUrl, initialLetter);
|
this._setAvatarVars(node, avatarUrl, initialLetter);
|
||||||
}
|
}
|
||||||
|
|
||||||
get type() {
|
get type(): IPillPart["type"] {
|
||||||
return "room-pill";
|
return Type.RoomPill;
|
||||||
}
|
}
|
||||||
|
|
||||||
get className() {
|
get className() {
|
||||||
|
@ -274,25 +351,24 @@ class RoomPillPart extends PillPart {
|
||||||
}
|
}
|
||||||
|
|
||||||
class AtRoomPillPart extends RoomPillPart {
|
class AtRoomPillPart extends RoomPillPart {
|
||||||
get type() {
|
get type(): IPillPart["type"] {
|
||||||
return "at-room-pill";
|
return Type.AtRoomPill;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class UserPillPart extends PillPart {
|
class UserPillPart extends PillPart {
|
||||||
constructor(userId, displayName, member) {
|
constructor(userId, displayName, private member: RoomMember) {
|
||||||
super(userId, displayName);
|
super(userId, displayName);
|
||||||
this._member = member;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAvatar(node) {
|
setAvatar(node: HTMLElement) {
|
||||||
if (!this._member) {
|
if (!this.member) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const name = this._member.name || this._member.userId;
|
const name = this.member.name || this.member.userId;
|
||||||
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this._member.userId);
|
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this.member.userId);
|
||||||
const avatarUrl = Avatar.avatarUrlForMember(
|
const avatarUrl = Avatar.avatarUrlForMember(
|
||||||
this._member,
|
this.member,
|
||||||
16 * window.devicePixelRatio,
|
16 * window.devicePixelRatio,
|
||||||
16 * window.devicePixelRatio,
|
16 * window.devicePixelRatio,
|
||||||
"crop");
|
"crop");
|
||||||
|
@ -303,33 +379,33 @@ class UserPillPart extends PillPart {
|
||||||
this._setAvatarVars(node, avatarUrl, initialLetter);
|
this._setAvatarVars(node, avatarUrl, initialLetter);
|
||||||
}
|
}
|
||||||
|
|
||||||
get type() {
|
get type(): IPillPart["type"] {
|
||||||
return "user-pill";
|
return Type.UserPill;
|
||||||
}
|
}
|
||||||
|
|
||||||
get className() {
|
get className() {
|
||||||
return "mx_UserPill mx_Pill";
|
return "mx_UserPill mx_Pill";
|
||||||
}
|
}
|
||||||
|
|
||||||
serialize() {
|
serialize(): ISerializedPillPart {
|
||||||
const obj = super.serialize();
|
return {
|
||||||
obj.resourceId = this.resourceId;
|
type: this.type,
|
||||||
return obj;
|
text: this.text,
|
||||||
|
resourceId: this.resourceId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
|
||||||
class PillCandidatePart extends PlainPart {
|
constructor(text: string, private autoCompleteCreator: IAutocompleteCreator) {
|
||||||
constructor(text, autoCompleteCreator) {
|
|
||||||
super(text);
|
super(text);
|
||||||
this._autoCompleteCreator = autoCompleteCreator;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createAutoComplete(updateCallback) {
|
createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel {
|
||||||
return this._autoCompleteCreator.create(updateCallback);
|
return this.autoCompleteCreator.create(updateCallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptsInsertion(chr, offset, inputType) {
|
acceptsInsertion(chr: string, offset: number, inputType: string) {
|
||||||
if (offset === 0) {
|
if (offset === 0) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
|
@ -341,18 +417,18 @@ class PillCandidatePart extends PlainPart {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptsRemoval(position, chr) {
|
acceptsRemoval(position: number, chr: string) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
get type() {
|
get type(): IPillCandidatePart["type"] {
|
||||||
return "pill-candidate";
|
return Type.PillCandidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function autoCompleteCreator(getAutocompleterComponent, updateQuery) {
|
export function getAutoCompleteCreator(getAutocompleterComponent: GetAutocompleterComponent, updateQuery: UpdateQuery) {
|
||||||
return (partCreator) => {
|
return (partCreator: PartCreator) => {
|
||||||
return (updateCallback) => {
|
return (updateCallback: UpdateCallback) => {
|
||||||
return new AutocompleteWrapperModel(
|
return new AutocompleteWrapperModel(
|
||||||
updateCallback,
|
updateCallback,
|
||||||
getAutocompleterComponent,
|
getAutocompleterComponent,
|
||||||
|
@ -363,20 +439,26 @@ export function autoCompleteCreator(getAutocompleterComponent, updateQuery) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AutoCompleteCreator = ReturnType<typeof getAutoCompleteCreator>;
|
||||||
|
|
||||||
|
interface IAutocompleteCreator {
|
||||||
|
create(updateCallback: UpdateCallback): AutocompleteWrapperModel;
|
||||||
|
}
|
||||||
|
|
||||||
export class PartCreator {
|
export class PartCreator {
|
||||||
constructor(room, client, autoCompleteCreator = null) {
|
protected readonly autoCompleteCreator: IAutocompleteCreator;
|
||||||
this._room = room;
|
|
||||||
this._client = client;
|
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
|
// 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
|
// to PillCandidatePart (e.g. while deserializing) and set later on
|
||||||
this._autoCompleteCreator = {create: autoCompleteCreator && autoCompleteCreator(this)};
|
this.autoCompleteCreator = {create: autoCompleteCreator && autoCompleteCreator(this)};
|
||||||
}
|
}
|
||||||
|
|
||||||
setAutoCompleteCreator(autoCompleteCreator) {
|
setAutoCompleteCreator(autoCompleteCreator: AutoCompleteCreator) {
|
||||||
this._autoCompleteCreator.create = autoCompleteCreator(this);
|
this.autoCompleteCreator.create = autoCompleteCreator(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
createPartForInput(input) {
|
createPartForInput(input: string, partIndex: number, inputType?: string): Part {
|
||||||
switch (input[0]) {
|
switch (input[0]) {
|
||||||
case "#":
|
case "#":
|
||||||
case "@":
|
case "@":
|
||||||
|
@ -389,28 +471,28 @@ export class PartCreator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createDefaultPart(text) {
|
createDefaultPart(text: string) {
|
||||||
return this.plain(text);
|
return this.plain(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
deserializePart(part) {
|
deserializePart(part: SerializedPart): Part {
|
||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
case "plain":
|
case Type.Plain:
|
||||||
return this.plain(part.text);
|
return this.plain(part.text);
|
||||||
case "newline":
|
case Type.Newline:
|
||||||
return this.newline();
|
return this.newline();
|
||||||
case "at-room-pill":
|
case Type.AtRoomPill:
|
||||||
return this.atRoomPill(part.text);
|
return this.atRoomPill(part.text);
|
||||||
case "pill-candidate":
|
case Type.PillCandidate:
|
||||||
return this.pillCandidate(part.text);
|
return this.pillCandidate(part.text);
|
||||||
case "room-pill":
|
case Type.RoomPill:
|
||||||
return this.roomPill(part.text);
|
return this.roomPill(part.text);
|
||||||
case "user-pill":
|
case Type.UserPill:
|
||||||
return this.userPill(part.text, part.resourceId);
|
return this.userPill(part.text, part.resourceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
plain(text) {
|
plain(text: string) {
|
||||||
return new PlainPart(text);
|
return new PlainPart(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -418,16 +500,16 @@ export class PartCreator {
|
||||||
return new NewlinePart("\n");
|
return new NewlinePart("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
pillCandidate(text) {
|
pillCandidate(text: string) {
|
||||||
return new PillCandidatePart(text, this._autoCompleteCreator);
|
return new PillCandidatePart(text, this.autoCompleteCreator);
|
||||||
}
|
}
|
||||||
|
|
||||||
roomPill(alias, roomId) {
|
roomPill(alias: string, roomId?: string) {
|
||||||
let room;
|
let room;
|
||||||
if (roomId || alias[0] !== "#") {
|
if (roomId || alias[0] !== "#") {
|
||||||
room = this._client.getRoom(roomId || alias);
|
room = this.client.getRoom(roomId || alias);
|
||||||
} else {
|
} else {
|
||||||
room = this._client.getRooms().find((r) => {
|
room = this.client.getRooms().find((r) => {
|
||||||
return r.getCanonicalAlias() === alias ||
|
return r.getCanonicalAlias() === alias ||
|
||||||
r.getAltAliases().includes(alias);
|
r.getAltAliases().includes(alias);
|
||||||
});
|
});
|
||||||
|
@ -435,16 +517,16 @@ export class PartCreator {
|
||||||
return new RoomPillPart(alias, room);
|
return new RoomPillPart(alias, room);
|
||||||
}
|
}
|
||||||
|
|
||||||
atRoomPill(text) {
|
atRoomPill(text: string) {
|
||||||
return new AtRoomPillPart(text, this._room);
|
return new AtRoomPillPart(text, this.room);
|
||||||
}
|
}
|
||||||
|
|
||||||
userPill(displayName, userId) {
|
userPill(displayName: string, userId: string) {
|
||||||
const member = this._room.getMember(userId);
|
const member = this.room.getMember(userId);
|
||||||
return new UserPillPart(userId, displayName, member);
|
return new UserPillPart(userId, displayName, member);
|
||||||
}
|
}
|
||||||
|
|
||||||
createMentionParts(partIndex, displayName, userId) {
|
createMentionParts(partIndex: number, displayName: string, userId: string) {
|
||||||
const pill = this.userPill(displayName, userId);
|
const pill = this.userPill(displayName, userId);
|
||||||
const postfix = this.plain(partIndex === 0 ? ": " : " ");
|
const postfix = this.plain(partIndex === 0 ? ": " : " ");
|
||||||
return [pill, postfix];
|
return [pill, postfix];
|
||||||
|
@ -454,7 +536,7 @@ export class PartCreator {
|
||||||
// part creator that support auto complete for /commands,
|
// part creator that support auto complete for /commands,
|
||||||
// used in SendMessageComposer
|
// used in SendMessageComposer
|
||||||
export class CommandPartCreator extends PartCreator {
|
export class CommandPartCreator extends PartCreator {
|
||||||
createPartForInput(text, partIndex) {
|
createPartForInput(text: string, partIndex: number) {
|
||||||
// at beginning and starts with /? create
|
// at beginning and starts with /? create
|
||||||
if (partIndex === 0 && text[0] === "/") {
|
if (partIndex === 0 && text[0] === "/") {
|
||||||
// text will be inserted by model, so pass empty string
|
// text will be inserted by model, so pass empty string
|
||||||
|
@ -464,11 +546,11 @@ export class CommandPartCreator extends PartCreator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
command(text) {
|
command(text: string) {
|
||||||
return new CommandPart(text, this._autoCompleteCreator);
|
return new CommandPart(text, this.autoCompleteCreator);
|
||||||
}
|
}
|
||||||
|
|
||||||
deserializePart(part) {
|
deserializePart(part: Part): Part {
|
||||||
if (part.type === "command") {
|
if (part.type === "command") {
|
||||||
return this.command(part.text);
|
return this.command(part.text);
|
||||||
} else {
|
} else {
|
||||||
|
@ -478,7 +560,7 @@ export class CommandPartCreator extends PartCreator {
|
||||||
}
|
}
|
||||||
|
|
||||||
class CommandPart extends PillCandidatePart {
|
class CommandPart extends PillCandidatePart {
|
||||||
get type() {
|
get type(): IPillCandidatePart["type"] {
|
||||||
return "command";
|
return Type.Command;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -15,30 +15,30 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import DocumentOffset from "./offset";
|
import DocumentOffset from "./offset";
|
||||||
|
import EditorModel from "./model";
|
||||||
|
import {Part} from "./parts";
|
||||||
|
|
||||||
export default class DocumentPosition {
|
export interface IPosition {
|
||||||
constructor(index, offset) {
|
index: number;
|
||||||
this._index = index;
|
offset: number;
|
||||||
this._offset = offset;
|
}
|
||||||
|
|
||||||
|
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() {
|
compare(otherPos: DocumentPosition) {
|
||||||
return this._index;
|
if (this.index === otherPos.index) {
|
||||||
}
|
return this.offset - otherPos.offset;
|
||||||
|
|
||||||
get offset() {
|
|
||||||
return this._offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
compare(otherPos) {
|
|
||||||
if (this._index === otherPos._index) {
|
|
||||||
return this._offset - otherPos._offset;
|
|
||||||
} else {
|
} 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) {
|
if (this.index === -1 || other.index === -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -57,7 +57,7 @@ export default class DocumentPosition {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
forwardsWhile(model, predicate) {
|
forwardsWhile(model: EditorModel, predicate: Predicate) {
|
||||||
if (this.index === -1) {
|
if (this.index === -1) {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
@ -82,7 +82,7 @@ export default class DocumentPosition {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
backwardsWhile(model, predicate) {
|
backwardsWhile(model: EditorModel, predicate: Predicate) {
|
||||||
if (this.index === -1) {
|
if (this.index === -1) {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
@ -107,7 +107,7 @@ export default class DocumentPosition {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
asOffset(model) {
|
asOffset(model: EditorModel) {
|
||||||
if (this.index === -1) {
|
if (this.index === -1) {
|
||||||
return new DocumentOffset(0, true);
|
return new DocumentOffset(0, true);
|
||||||
}
|
}
|
||||||
|
@ -121,7 +121,7 @@ export default class DocumentPosition {
|
||||||
return new DocumentOffset(offset, atEnd);
|
return new DocumentOffset(offset, atEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
isAtEnd(model) {
|
isAtEnd(model: EditorModel) {
|
||||||
if (model.parts.length === 0) {
|
if (model.parts.length === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
|
@ -14,32 +14,34 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import EditorModel from "./model";
|
||||||
|
import DocumentPosition, {Predicate} from "./position";
|
||||||
|
import {Part} from "./parts";
|
||||||
|
|
||||||
export default class Range {
|
export default class Range {
|
||||||
constructor(model, positionA, positionB = positionA) {
|
private _start: DocumentPosition;
|
||||||
this._model = model;
|
private _end: DocumentPosition;
|
||||||
|
|
||||||
|
constructor(public readonly model: EditorModel, positionA: DocumentPosition, positionB = positionA) {
|
||||||
const bIsLarger = positionA.compare(positionB) < 0;
|
const bIsLarger = positionA.compare(positionB) < 0;
|
||||||
this._start = bIsLarger ? positionA : positionB;
|
this._start = bIsLarger ? positionA : positionB;
|
||||||
this._end = bIsLarger ? positionB : positionA;
|
this._end = bIsLarger ? positionB : positionA;
|
||||||
}
|
}
|
||||||
|
|
||||||
moveStart(delta) {
|
moveStart(delta: number) {
|
||||||
this._start = this._start.forwardsWhile(this._model, () => {
|
this._start = this._start.forwardsWhile(this.model, () => {
|
||||||
delta -= 1;
|
delta -= 1;
|
||||||
return delta >= 0;
|
return delta >= 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
expandBackwardsWhile(predicate) {
|
expandBackwardsWhile(predicate: Predicate) {
|
||||||
this._start = this._start.backwardsWhile(this._model, predicate);
|
this._start = this._start.backwardsWhile(this.model, predicate);
|
||||||
}
|
|
||||||
|
|
||||||
get model() {
|
|
||||||
return this._model;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get text() {
|
get text() {
|
||||||
let 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);
|
const t = part.text.substring(startIdx, endIdx);
|
||||||
text = text + t;
|
text = text + t;
|
||||||
});
|
});
|
||||||
|
@ -52,13 +54,13 @@ export default class Range {
|
||||||
* @param {Part[]} parts the parts to replace the range with
|
* @param {Part[]} parts the parts to replace the range with
|
||||||
* @return {Number} the net amount of characters added, can be negative.
|
* @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);
|
const newLength = parts.reduce((sum, part) => sum + part.text.length, 0);
|
||||||
let oldLength = 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;
|
oldLength += endIdx - startIdx;
|
||||||
});
|
});
|
||||||
this._model._replaceRange(this._start, this._end, parts);
|
this.model.replaceRange(this._start, this._end, parts);
|
||||||
return newLength - oldLength;
|
return newLength - oldLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,10 +70,10 @@ export default class Range {
|
||||||
*/
|
*/
|
||||||
get parts() {
|
get parts() {
|
||||||
const 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();
|
const serializedPart = part.serialize();
|
||||||
serializedPart.text = part.text.substring(startIdx, endIdx);
|
serializedPart.text = part.text.substring(startIdx, endIdx);
|
||||||
const newPart = this._model.partCreator.deserializePart(serializedPart);
|
const newPart = this.model.partCreator.deserializePart(serializedPart);
|
||||||
parts.push(newPart);
|
parts.push(newPart);
|
||||||
});
|
});
|
||||||
return parts;
|
return parts;
|
||||||
|
@ -79,7 +81,7 @@ export default class Range {
|
||||||
|
|
||||||
get length() {
|
get length() {
|
||||||
let len = 0;
|
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;
|
len += endIdx - startIdx;
|
||||||
});
|
});
|
||||||
return len;
|
return len;
|
|
@ -15,16 +15,19 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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";
|
const isFirst = !prevPart || prevPart.type === "newline";
|
||||||
return !part.canEdit && (isFirst || !prevPart.canEdit);
|
return !part.canEdit && (isFirst || !prevPart.canEdit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function needsCaretNodeAfter(part, isLastOfLine) {
|
export function needsCaretNodeAfter(part: Part, isLastOfLine: boolean) {
|
||||||
return !part.canEdit && isLastOfLine;
|
return !part.canEdit && isLastOfLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertAfter(node, nodeToInsert) {
|
function insertAfter(node: HTMLElement, nodeToInsert: HTMLElement) {
|
||||||
const next = node.nextSibling;
|
const next = node.nextSibling;
|
||||||
if (next) {
|
if (next) {
|
||||||
node.parentElement.insertBefore(nodeToInsert, next);
|
node.parentElement.insertBefore(nodeToInsert, next);
|
||||||
|
@ -48,18 +51,18 @@ function createCaretNode() {
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCaretNode(node) {
|
function updateCaretNode(node: HTMLElement) {
|
||||||
// ensure the caret node contains only a zero-width space
|
// ensure the caret node contains only a zero-width space
|
||||||
if (node.textContent !== CARET_NODE_CHAR) {
|
if (node.textContent !== CARET_NODE_CHAR) {
|
||||||
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";
|
return node && node.tagName === "SPAN" && node.className === "caretNode";
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeNextSiblings(node) {
|
function removeNextSiblings(node: ChildNode) {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -71,7 +74,7 @@ function removeNextSiblings(node) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeChildren(parent) {
|
function removeChildren(parent: HTMLElement) {
|
||||||
const firstChild = parent.firstChild;
|
const firstChild = parent.firstChild;
|
||||||
if (firstChild) {
|
if (firstChild) {
|
||||||
removeNextSiblings(firstChild);
|
removeNextSiblings(firstChild);
|
||||||
|
@ -79,7 +82,7 @@ function removeChildren(parent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reconcileLine(lineContainer, parts) {
|
function reconcileLine(lineContainer: ChildNode, parts: Part[]) {
|
||||||
let currentNode;
|
let currentNode;
|
||||||
let prevPart;
|
let prevPart;
|
||||||
const lastPart = parts[parts.length - 1];
|
const lastPart = parts[parts.length - 1];
|
||||||
|
@ -146,23 +149,23 @@ function reconcileEmptyLine(lineContainer) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderModel(editor, model) {
|
export function renderModel(editor: HTMLDivElement, model: EditorModel) {
|
||||||
const lines = model.parts.reduce((lines, part) => {
|
const lines = model.parts.reduce((linesArr, part) => {
|
||||||
if (part.type === "newline") {
|
if (part.type === "newline") {
|
||||||
lines.push([]);
|
linesArr.push([]);
|
||||||
} else {
|
} else {
|
||||||
const lastLine = lines[lines.length - 1];
|
const lastLine = linesArr[linesArr.length - 1];
|
||||||
lastLine.push(part);
|
lastLine.push(part);
|
||||||
}
|
}
|
||||||
return lines;
|
return linesArr;
|
||||||
}, [[]]);
|
}, [[]]);
|
||||||
lines.forEach((parts, i) => {
|
lines.forEach((parts, i) => {
|
||||||
// find first (and remove anything else) div without className
|
// find first (and remove anything else) div without className
|
||||||
// (as browsers insert these in contenteditable) line container
|
// (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)) {
|
while (lineContainer && (lineContainer.tagName !== "DIV" || !!lineContainer.className)) {
|
||||||
editor.removeChild(lineContainer);
|
editor.removeChild(lineContainer);
|
||||||
lineContainer = editor.childNodes[i];
|
lineContainer = editor.children[i];
|
||||||
}
|
}
|
||||||
if (!lineContainer) {
|
if (!lineContainer) {
|
||||||
lineContainer = document.createElement("div");
|
lineContainer = document.createElement("div");
|
Loading…
Add table
Add a link
Reference in a new issue