Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into joriks/eslint-config

This commit is contained in:
Jorik Schellekens 2020-07-20 20:44:52 +01:00
commit c0ce6e8161
16 changed files with 808 additions and 629 deletions

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import React, {createRef, KeyboardEvent} from 'react';
import classNames from 'classnames';
import flatMap from 'lodash/flatMap';
import {ICompletion, ISelectionRange, IProviderCompletions} from '../../../autocomplete/Autocompleter';

View file

@ -16,11 +16,13 @@ limitations under the License.
*/
import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import React, {createRef, ClipboardEvent} from 'react';
import {Room} from 'matrix-js-sdk/src/models/room';
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
import EditorModel from '../../../editor/model';
import HistoryManager from '../../../editor/history';
import {setSelection} from '../../../editor/caret';
import {Caret, setSelection} from '../../../editor/caret';
import {
formatRangeAsQuote,
formatRangeAsCode,
@ -29,17 +31,21 @@ import {
} from '../../../editor/operations';
import {getCaretOffsetAndText, getRangeForSelection} from '../../../editor/dom';
import Autocomplete, {generateCompletionDomId} from '../rooms/Autocomplete';
import {autoCompleteCreator} from '../../../editor/parts';
import {getAutoCompleteCreator} from '../../../editor/parts';
import {parsePlainTextMessage} from '../../../editor/deserialize';
import {renderModel} from '../../../editor/render';
import {Room} from 'matrix-js-sdk';
import TypingStore from "../../../stores/TypingStore";
import SettingsStore from "../../../settings/SettingsStore";
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
import * as sdk from '../../../index';
import {Key} from "../../../Keyboard";
import {EMOTICON_TO_EMOJI} from "../../../emoji";
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$');
@ -49,7 +55,7 @@ function ctrlShortcutLabel(key) {
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
}
function cloneSelection(selection) {
function cloneSelection(selection: Selection): Partial<Selection> {
return {
anchorNode: selection.anchorNode,
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 &&
a.anchorOffset === b.anchorOffset &&
a.focusNode === b.focusNode &&
@ -71,45 +77,75 @@ function selectionEquals(a: Selection, b: Selection): boolean {
a.type === b.type;
}
export default class BasicMessageEditor extends React.Component {
static propTypes = {
onChange: PropTypes.func,
onPaste: PropTypes.func, // returns true if handled and should skip internal onPaste handler
model: PropTypes.instanceOf(EditorModel).isRequired,
room: PropTypes.instanceOf(Room).isRequired,
placeholder: PropTypes.string,
label: PropTypes.string, // the aria label
initialCaret: PropTypes.object, // See DocumentPosition in editor/model.js
};
enum Formatting {
Bold = "bold",
Italics = "italics",
Strikethrough = "strikethrough",
Code = "code",
Quote = "quote",
}
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) {
super(props);
this.state = {
autoComplete: null,
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
};
this._editorRef = null;
this._autocompleteRef = null;
this._formatBarRef = null;
this._modifiedFlag = false;
this._isIMEComposing = false;
this._hasTextSelected = false;
this._emoticonSettingHandle = null;
this._shouldShowPillAvatarSettingHandle = null;
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
this.configureEmoticonAutoReplace);
this.configureEmoticonAutoReplace();
this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
this.configureShouldShowPillAvatar);
}
componentDidUpdate(prevProps) {
public componentDidUpdate(prevProps: IProps) {
if (this.props.placeholder !== prevProps.placeholder && this.props.placeholder) {
const {isEmpty} = this.props.model;
if (isEmpty) {
this._showPlaceholder();
this.showPlaceholder();
} else {
this._hidePlaceholder();
this.hidePlaceholder();
}
}
}
_replaceEmoticon = (caretPosition, inputType, diff) => {
private replaceEmoticon = (caretPosition: DocumentPosition) => {
const {model} = this.props;
const range = model.startRange(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 + " ")]);
}
}
}
};
_updateEditorState = (selection, inputType, diff) => {
renderModel(this._editorRef, this.props.model);
private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff) => {
renderModel(this.editorRef.current, this.props.model);
if (selection) { // set the caret/selection
try {
setSelection(this._editorRef, this.props.model, selection);
setSelection(this.editorRef.current, this.props.model, selection);
} catch (err) {
console.error(err);
}
// if caret selection is a range, take the end position
const position = selection.end || selection;
this._setLastCaretFromPosition(position);
const position = selection instanceof Range ? selection.end : selection;
this.setLastCaretFromPosition(position);
}
const {isEmpty} = this.props.model;
if (this.props.placeholder) {
if (isEmpty) {
this._showPlaceholder();
this.showPlaceholder();
} else {
this._hidePlaceholder();
this.hidePlaceholder();
}
}
if (isEmpty) {
this._formatBarRef.hide();
this.formatBarRef.current.hide();
}
this.setState({autoComplete: this.props.model.autoComplete});
this.historyManager.tryPush(this.props.model, selection, inputType, diff);
@ -180,26 +216,26 @@ export default class BasicMessageEditor extends React.Component {
if (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() {
this._editorRef.style.setProperty("--placeholder", `'${this.props.placeholder}'`);
this._editorRef.classList.add("mx_BasicMessageComposer_inputEmpty");
private hidePlaceholder() {
this.editorRef.current.classList.remove("mx_BasicMessageComposer_inputEmpty");
this.editorRef.current.style.removeProperty("--placeholder");
}
_hidePlaceholder() {
this._editorRef.classList.remove("mx_BasicMessageComposer_inputEmpty");
this._editorRef.style.removeProperty("--placeholder");
}
_onCompositionStart = (event) => {
this._isIMEComposing = true;
private onCompositionStart = () => {
this.isIMEComposing = true;
// even if the model is empty, the composition text shouldn't be mixed with the placeholder
this._hidePlaceholder();
}
this.hidePlaceholder();
};
_onCompositionEnd = (event) => {
this._isIMEComposing = false;
private onCompositionEnd = () => {
this.isIMEComposing = false;
// 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.
@ -213,48 +249,48 @@ export default class BasicMessageEditor extends React.Component {
const isSafari = ua.includes('safari/') && !ua.includes('chrome/');
if (isSafari) {
this._onInput({inputType: "insertCompositionText"});
this.onInput({inputType: "insertCompositionText"});
} else {
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
// emits events related to the composition after compositionend
// 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 text = selection.toString();
if (text) {
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());
event.clipboardData.setData("application/x-riot-composer", JSON.stringify(selectedParts));
event.clipboardData.setData("text/plain", text); // so plain copy/paste works
if (type === "cut") {
// Remove the text, updating the model as appropriate
this._modifiedFlag = true;
this.modifiedFlag = true;
replaceRangeAndMoveCaret(range, []);
}
event.preventDefault();
}
}
};
_onCopy = (event) => {
this._onCutCopy(event, "copy");
}
private onCopy = (event: ClipboardEvent) => {
this.onCutCopy(event, "copy");
};
_onCut = (event) => {
this._onCutCopy(event, "cut");
}
private onCut = (event: ClipboardEvent) => {
this.onCutCopy(event, "cut");
};
_onPaste = (event) => {
private onPaste = (event: ClipboardEvent<HTMLDivElement>) => {
event.preventDefault(); // we always handle the paste ourselves
if (this.props.onPaste && this.props.onPaste(event, this.props.model)) {
// 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");
parts = parsePlainTextMessage(text, partCreator);
}
this._modifiedFlag = true;
const range = getRangeForSelection(this._editorRef, model, document.getSelection());
this.modifiedFlag = true;
const range = getRangeForSelection(this.editorRef.current, model, document.getSelection());
replaceRangeAndMoveCaret(range, parts);
}
};
_onInput = (event) => {
private onInput = (event: Partial<InputEvent>) => {
// ignore any input while doing IME compositions
if (this._isIMEComposing) {
if (this.isIMEComposing) {
return;
}
this._modifiedFlag = true;
this.modifiedFlag = true;
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);
}
};
_insertText(textToInsert, inputType = "insertText") {
private insertText(textToInsert: string, inputType = "insertText") {
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);
caret.offset += textToInsert.length;
this._modifiedFlag = true;
this.modifiedFlag = true;
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 need to recalculate it, to be able to know where to insert content after
// losing focus
_setLastCaretFromPosition(position) {
private setLastCaretFromPosition(position: DocumentPosition) {
const {model} = this.props;
this._isCaretAtEnd = position.isAtEnd(model);
this._lastCaret = position.asOffset(model);
this._lastSelection = cloneSelection(document.getSelection());
this.lastCaret = position.asOffset(model);
this.lastSelection = cloneSelection(document.getSelection());
}
_refreshLastCaretIfNeeded() {
private refreshLastCaretIfNeeded() {
// XXX: needed when going up and down in editing messages ... not sure why yet
// because the editors should stop doing this when when blurred ...
// maybe it's on focus and the _editorRef isn't available yet or something.
if (!this._editorRef) {
if (!this.editorRef.current) {
return;
}
const selection = document.getSelection();
if (!this._lastSelection || !selectionEquals(this._lastSelection, selection)) {
this._lastSelection = cloneSelection(selection);
const {caret, text} = getCaretOffsetAndText(this._editorRef, selection);
this._lastCaret = caret;
if (!this.lastSelection || !selectionEquals(this.lastSelection, selection)) {
this.lastSelection = cloneSelection(selection);
const {caret, text} = getCaretOffsetAndText(this.editorRef.current, selection);
this.lastCaret = caret;
this._isCaretAtEnd = caret.offset === text.length;
}
return this._lastCaret;
return this.lastCaret;
}
clearUndoHistory() {
@ -332,11 +368,11 @@ export default class BasicMessageEditor extends React.Component {
}
getCaret() {
return this._lastCaret;
return this.lastCaret;
}
isSelectionCollapsed() {
return !this._lastSelection || this._lastSelection.isCollapsed;
return !this.lastSelection || this.lastSelection.isCollapsed;
}
isCaretAtStart() {
@ -347,51 +383,51 @@ export default class BasicMessageEditor extends React.Component {
return this._isCaretAtEnd;
}
_onBlur = () => {
document.removeEventListener("selectionchange", this._onSelectionChange);
}
private onBlur = () => {
document.removeEventListener("selectionchange", this.onSelectionChange);
};
_onFocus = () => {
document.addEventListener("selectionchange", this._onSelectionChange);
private onFocus = () => {
document.addEventListener("selectionchange", this.onSelectionChange);
// force to recalculate
this._lastSelection = null;
this._refreshLastCaretIfNeeded();
}
this.lastSelection = null;
this.refreshLastCaretIfNeeded();
};
_onSelectionChange = () => {
private onSelectionChange = () => {
const {isEmpty} = this.props.model;
this._refreshLastCaretIfNeeded();
this.refreshLastCaretIfNeeded();
const selection = document.getSelection();
if (this._hasTextSelected && selection.isCollapsed) {
this._hasTextSelected = false;
if (this._formatBarRef) {
this._formatBarRef.hide();
if (this.hasTextSelected && selection.isCollapsed) {
this.hasTextSelected = false;
if (this.formatBarRef.current) {
this.formatBarRef.current.hide();
}
} else if (!selection.isCollapsed && !isEmpty) {
this._hasTextSelected = true;
if (this._formatBarRef) {
this.hasTextSelected = true;
if (this.formatBarRef.current) {
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 modKey = IS_MAC ? event.metaKey : event.ctrlKey;
let handled = false;
// format bold
if (modKey && event.key === Key.B) {
this._onFormatAction("bold");
this.onFormatAction(Formatting.Bold);
handled = true;
// format italics
} else if (modKey && event.key === Key.I) {
this._onFormatAction("italics");
this.onFormatAction(Formatting.Italics);
handled = true;
// format quote
} else if (modKey && event.key === Key.GREATER_THAN) {
this._onFormatAction("quote");
this.onFormatAction(Formatting.Quote);
handled = true;
// redo
} else if ((!IS_MAC && modKey && event.key === Key.Y) ||
@ -414,18 +450,18 @@ export default class BasicMessageEditor extends React.Component {
handled = true;
// insert newline on Shift+Enter
} else if (event.key === Key.ENTER && (event.shiftKey || (IS_MAC && event.altKey))) {
this._insertText("\n");
this.insertText("\n");
handled = true;
// move selection to start of composer
} else if (modKey && event.key === Key.HOME && !event.shiftKey) {
setSelection(this._editorRef, model, {
setSelection(this.editorRef.current, model, {
index: 0,
offset: 0,
});
handled = true;
// move selection to end of composer
} else if (modKey && event.key === Key.END && !event.shiftKey) {
setSelection(this._editorRef, model, {
setSelection(this.editorRef.current, model, {
index: model.parts.length - 1,
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
}
} else if (event.key === Key.TAB) {
this._tabCompleteName();
this.tabCompleteName(event);
handled = true;
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
this._formatBarRef.hide();
this.formatBarRef.current.hide();
}
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}
};
async _tabCompleteName() {
private async tabCompleteName(event: React.KeyboardEvent) {
try {
await new Promise(resolve => this.setState({showVisualBell: false}, resolve));
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
if (model.autoComplete) {
await model.autoComplete.onTab();
await model.autoComplete.onTab(event);
if (!model.autoComplete.hasSelection()) {
this.setState({showVisualBell: true});
model.autoComplete.close();
@ -512,64 +548,58 @@ export default class BasicMessageEditor extends React.Component {
}
isModified() {
return this._modifiedFlag;
return this.modifiedFlag;
}
_onAutoCompleteConfirm = (completion) => {
private onAutoCompleteConfirm = (completion: ICompletion) => {
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");
this.setState({ showPillAvatar });
};
componentWillUnmount() {
document.removeEventListener("selectionchange", this._onSelectionChange);
this._editorRef.removeEventListener("input", this._onInput, true);
this._editorRef.removeEventListener("compositionstart", this._onCompositionStart, true);
this._editorRef.removeEventListener("compositionend", this._onCompositionEnd, true);
SettingsStore.unwatchSetting(this._emoticonSettingHandle);
SettingsStore.unwatchSetting(this._shouldShowPillAvatarSettingHandle);
document.removeEventListener("selectionchange", this.onSelectionChange);
this.editorRef.current.removeEventListener("input", this.onInput, true);
this.editorRef.current.removeEventListener("compositionstart", this.onCompositionStart, true);
this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true);
SettingsStore.unwatchSetting(this.emoticonSettingHandle);
SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle);
}
componentDidMount() {
const model = this.props.model;
model.setUpdateCallback(this._updateEditorState);
this._emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
this._configureEmoticonAutoReplace);
this._configureEmoticonAutoReplace();
this._shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
this._configureShouldShowPillAvatar);
model.setUpdateCallback(this.updateEditorState);
const partCreator = model.partCreator;
// TODO: does this allow us to get rid of EditorStateTransfer?
// not really, but we could not serialize the parts, and just change the autoCompleter
partCreator.setAutoCompleteCreator(autoCompleteCreator(
() => this._autocompleteRef,
partCreator.setAutoCompleteCreator(getAutoCompleteCreator(
() => this.autocompleteRef.current,
query => new Promise(resolve => this.setState({query}, resolve)),
));
this.historyManager = new HistoryManager(partCreator);
// initial render of model
this._updateEditorState(this._getInitialCaretPosition());
this.updateEditorState(this.getInitialCaretPosition());
// attach input listener by hand so React doesn't proxy the events,
// as the proxied event doesn't support inputType, which we need.
this._editorRef.addEventListener("input", this._onInput, true);
this._editorRef.addEventListener("compositionstart", this._onCompositionStart, true);
this._editorRef.addEventListener("compositionend", this._onCompositionEnd, true);
this._editorRef.focus();
this.editorRef.current.addEventListener("input", this.onInput, true);
this.editorRef.current.addEventListener("compositionstart", this.onCompositionStart, true);
this.editorRef.current.addEventListener("compositionend", this.onCompositionEnd, true);
this.editorRef.current.focus();
}
_getInitialCaretPosition() {
private getInitialCaretPosition() {
let caretPosition;
if (this.props.initialCaret) {
// if restoring state from a previous editor,
@ -583,34 +613,34 @@ export default class BasicMessageEditor extends React.Component {
return caretPosition;
}
_onFormatAction = (action) => {
private onFormatAction = (action: Formatting) => {
const range = getRangeForSelection(
this._editorRef,
this.editorRef.current,
this.props.model,
document.getSelection());
if (range.length === 0) {
return;
}
this.historyManager.ensureLastChangesPushed(this.props.model);
this._modifiedFlag = true;
this.modifiedFlag = true;
switch (action) {
case "bold":
case Formatting.Bold:
toggleInlineFormat(range, "**");
break;
case "italics":
case Formatting.Italics:
toggleInlineFormat(range, "_");
break;
case "strikethrough":
case Formatting.Strikethrough:
toggleInlineFormat(range, "<del>", "</del>");
break;
case "code":
case Formatting.Code:
formatRangeAsCode(range);
break;
case "quote":
case Formatting.Quote:
formatRangeAsQuote(range);
break;
}
}
};
render() {
let autoComplete;
@ -619,10 +649,10 @@ export default class BasicMessageEditor extends React.Component {
const queryLen = query.length;
autoComplete = (<div className="mx_BasicMessageComposer_AutoCompleteWrapper">
<Autocomplete
ref={ref => this._autocompleteRef = ref}
ref={this.autocompleteRef}
query={query}
onConfirm={this._onAutoCompleteConfirm}
onSelectionChange={this._onAutoCompleteSelectionChange}
onConfirm={this.onAutoCompleteConfirm}
onSelectionChange={this.onAutoCompleteSelectionChange}
selection={{beginning: true, end: queryLen, start: queryLen}}
room={this.props.room}
/>
@ -635,7 +665,6 @@ export default class BasicMessageEditor extends React.Component {
"mx_BasicMessageComposer_input_shouldShowPillAvatar": this.state.showPillAvatar,
});
const MessageComposerFormatBar = sdk.getComponent('rooms.MessageComposerFormatBar');
const shortcuts = {
bold: ctrlShortcutLabel("B"),
italics: ctrlShortcutLabel("I"),
@ -646,18 +675,18 @@ export default class BasicMessageEditor extends React.Component {
return (<div className={wrapperClasses}>
{ autoComplete }
<MessageComposerFormatBar ref={ref => this._formatBarRef = ref} onAction={this._onFormatAction} shortcuts={shortcuts} />
<MessageComposerFormatBar ref={this.formatBarRef} onAction={this.onFormatAction} shortcuts={shortcuts} />
<div
className={classes}
contentEditable="true"
tabIndex="0"
onBlur={this._onBlur}
onFocus={this._onFocus}
onCopy={this._onCopy}
onCut={this._onCut}
onPaste={this._onPaste}
onKeyDown={this._onKeyDown}
ref={ref => this._editorRef = ref}
tabIndex={0}
onBlur={this.onBlur}
onFocus={this.onFocus}
onCopy={this.onCopy}
onCut={this.onCut}
onPaste={this.onPaste}
onKeyDown={this.onKeyDown}
ref={this.editorRef}
aria-label={this.props.label}
role="textbox"
aria-multiline="true"
@ -671,6 +700,6 @@ export default class BasicMessageEditor extends React.Component {
}
focus() {
this._editorRef.focus();
this.editorRef.current.focus();
}
}

View file

@ -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
View 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)];
}
}
}

View file

@ -17,8 +17,13 @@ limitations under the License.
import {needsCaretNodeBefore, needsCaretNodeAfter} from "./render";
import Range from "./range";
import EditorModel from "./model";
import DocumentPosition, {IPosition} from "./position";
import {Part} from "./parts";
export function setSelection(editor, model, selection) {
export type Caret = Range | DocumentPosition;
export function setSelection(editor: HTMLDivElement, model: EditorModel, selection: Range | IPosition) {
if (selection instanceof Range) {
setDocumentRangeSelection(editor, model, selection);
} else {
@ -26,7 +31,7 @@ export function setSelection(editor, model, selection) {
}
}
function setDocumentRangeSelection(editor, model, range) {
function setDocumentRangeSelection(editor: HTMLDivElement, model: EditorModel, range: Range) {
const sel = document.getSelection();
sel.removeAllRanges();
const selectionRange = document.createRange();
@ -37,7 +42,7 @@ function setDocumentRangeSelection(editor, model, range) {
sel.addRange(selectionRange);
}
export function setCaretPosition(editor, model, caretPosition) {
export function setCaretPosition(editor: HTMLDivElement, model: EditorModel, caretPosition: IPosition) {
const range = document.createRange();
const {node, offset} = getNodeAndOffsetForPosition(editor, model, caretPosition);
range.setStart(node, offset);
@ -62,7 +67,7 @@ export function setCaretPosition(editor, model, caretPosition) {
sel.addRange(range);
}
function getNodeAndOffsetForPosition(editor, model, position) {
function getNodeAndOffsetForPosition(editor: HTMLDivElement, model: EditorModel, position: IPosition) {
const {offset, lineIndex, nodeIndex} = getLineAndNodePosition(model, position);
const lineNode = editor.childNodes[lineIndex];
@ -80,7 +85,7 @@ function getNodeAndOffsetForPosition(editor, model, position) {
return {node: focusNode, offset};
}
export function getLineAndNodePosition(model, caretPosition) {
export function getLineAndNodePosition(model: EditorModel, caretPosition: IPosition) {
const {parts} = model;
const partIndex = caretPosition.index;
const lineResult = findNodeInLineForPart(parts, partIndex);
@ -99,7 +104,7 @@ export function getLineAndNodePosition(model, caretPosition) {
return {lineIndex, nodeIndex, offset};
}
function findNodeInLineForPart(parts, partIndex) {
function findNodeInLineForPart(parts: Part[], partIndex: number) {
let lineIndex = 0;
let nodeIndex = -1;
@ -135,7 +140,7 @@ function findNodeInLineForPart(parts, partIndex) {
return {lineIndex, nodeIndex};
}
function moveOutOfUneditablePart(parts, partIndex, nodeIndex, offset) {
function moveOutOfUneditablePart(parts: Part[], partIndex: number, nodeIndex: number, offset: number) {
// move caret before or after uneditable part
const part = parts[partIndex];
if (part && !part.canEdit) {

View file

@ -257,7 +257,7 @@ function parseHtmlMessage(html: string, partCreator: PartCreator, isQuotedMessag
return parts;
}
export function parsePlainTextMessage(body: string, partCreator: PartCreator, isQuotedMessage: boolean) {
export function parsePlainTextMessage(body: string, partCreator: PartCreator, isQuotedMessage?: boolean) {
const lines = body.split(/\r\n|\r|\n/g); // split on any new-line combination not just \n, collapses \r\n
return lines.reduce((parts, line, i) => {
if (isQuotedMessage) {

View file

@ -15,7 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
function firstDiff(a, b) {
export interface IDiff {
removed?: string;
added?: string;
at?: number;
}
function firstDiff(a: string, b: string) {
const compareLen = Math.min(a.length, b.length);
for (let i = 0; i < compareLen; ++i) {
if (a[i] !== b[i]) {
@ -25,7 +31,7 @@ function firstDiff(a, b) {
return compareLen;
}
function diffStringsAtEnd(oldStr, newStr) {
function diffStringsAtEnd(oldStr: string, newStr: string): IDiff {
const len = Math.min(oldStr.length, newStr.length);
const startInCommon = oldStr.substr(0, len) === newStr.substr(0, len);
if (startInCommon && oldStr.length > newStr.length) {
@ -43,7 +49,7 @@ function diffStringsAtEnd(oldStr, newStr) {
}
// assumes only characters have been deleted at one location in the string, and none added
export function diffDeletion(oldStr, newStr) {
export function diffDeletion(oldStr: string, newStr: string): IDiff {
if (oldStr === newStr) {
return {};
}
@ -61,7 +67,7 @@ export function diffDeletion(oldStr, newStr) {
* `added` with the added string (if any), and
* `removed` with the removed string (if any)
*/
export function diffAtCaret(oldValue, newValue, caretPosition) {
export function diffAtCaret(oldValue: string, newValue: string, caretPosition: number): IDiff {
const diffLen = newValue.length - oldValue.length;
const caretPositionBeforeInput = caretPosition - diffLen;
const oldValueBeforeCaret = oldValue.substr(0, caretPositionBeforeInput);

View file

@ -17,8 +17,12 @@ limitations under the License.
import {CARET_NODE_CHAR, isCaretNode} from "./render";
import DocumentOffset from "./offset";
import EditorModel from "./model";
export function walkDOMDepthFirst(rootNode, enterNodeCallback, leaveNodeCallback) {
type Predicate = (node: Node) => boolean;
type Callback = (node: Node) => void;
export function walkDOMDepthFirst(rootNode: Node, enterNodeCallback: Predicate, leaveNodeCallback: Callback) {
let node = rootNode.firstChild;
while (node && node !== rootNode) {
const shouldDescend = enterNodeCallback(node);
@ -40,12 +44,12 @@ export function walkDOMDepthFirst(rootNode, enterNodeCallback, leaveNodeCallback
}
}
export function getCaretOffsetAndText(editor, sel) {
export function getCaretOffsetAndText(editor: HTMLDivElement, sel: Selection) {
const {offset, text} = getSelectionOffsetAndText(editor, sel.focusNode, sel.focusOffset);
return {caret: offset, text};
}
function tryReduceSelectionToTextNode(selectionNode, selectionOffset) {
function tryReduceSelectionToTextNode(selectionNode: Node, selectionOffset: number) {
// if selectionNode is an element, the selected location comes after the selectionOffset-th child node,
// which can point past any childNode, in which case, the end of selectionNode is selected.
// we try to simplify this to point at a text node with the offset being
@ -82,7 +86,7 @@ function tryReduceSelectionToTextNode(selectionNode, selectionOffset) {
};
}
function getSelectionOffsetAndText(editor, selectionNode, selectionOffset) {
function getSelectionOffsetAndText(editor: HTMLDivElement, selectionNode: Node, selectionOffset: number) {
const {node, characterOffset} = tryReduceSelectionToTextNode(selectionNode, selectionOffset);
const {text, offsetToNode} = getTextAndOffsetToNode(editor, node);
const offset = getCaret(node, offsetToNode, characterOffset);
@ -91,7 +95,7 @@ function getSelectionOffsetAndText(editor, selectionNode, selectionOffset) {
// gets the caret position details, ignoring and adjusting to
// the ZWS if you're typing in a caret node
function getCaret(node, offsetToNode, offsetWithinNode) {
function getCaret(node: Node, offsetToNode: number, offsetWithinNode: number) {
// if no node is selected, return an offset at the start
if (!node) {
return new DocumentOffset(0, false);
@ -114,7 +118,7 @@ function getCaret(node, offsetToNode, offsetWithinNode) {
// gets the text of the editor as a string,
// and the offset in characters where the selectionNode starts in that string
// all ZWS from caret nodes are filtered out
function getTextAndOffsetToNode(editor, selectionNode) {
function getTextAndOffsetToNode(editor: HTMLDivElement, selectionNode: Node) {
let offsetToNode = 0;
let foundNode = false;
let text = "";

View file

@ -14,25 +14,40 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import EditorModel from "./model";
import {IDiff} from "./diff";
import {SerializedPart} from "./parts";
import {Caret} from "./caret";
interface IHistory {
parts: SerializedPart[];
caret: Caret;
}
export const MAX_STEP_LENGTH = 10;
export default class HistoryManager {
constructor() {
this.clear();
}
private stack: IHistory[] = [];
private newlyTypedCharCount = 0;
private currentIndex = -1;
private changedSinceLastPush = false;
private lastCaret: Caret = null;
private nonWordBoundarySinceLastPush = false;
private addedSinceLastPush = false;
private removedSinceLastPush = false;
clear() {
this._stack = [];
this._newlyTypedCharCount = 0;
this._currentIndex = -1;
this._changedSinceLastPush = false;
this._lastCaret = null;
this._nonWordBoundarySinceLastPush = false;
this._addedSinceLastPush = false;
this._removedSinceLastPush = false;
this.stack = [];
this.newlyTypedCharCount = 0;
this.currentIndex = -1;
this.changedSinceLastPush = false;
this.lastCaret = null;
this.nonWordBoundarySinceLastPush = false;
this.addedSinceLastPush = false;
this.removedSinceLastPush = false;
}
_shouldPush(inputType, diff) {
private shouldPush(inputType, diff) {
// right now we can only push a step after
// the input has been applied to the model,
// so we can't push the state before something happened.
@ -43,24 +58,24 @@ export default class HistoryManager {
inputType === "deleteContentBackward";
if (diff && isNonBulkInput) {
if (diff.added) {
this._addedSinceLastPush = true;
this.addedSinceLastPush = true;
}
if (diff.removed) {
this._removedSinceLastPush = true;
this.removedSinceLastPush = true;
}
// as long as you've only been adding or removing since the last push
if (this._addedSinceLastPush !== this._removedSinceLastPush) {
if (this.addedSinceLastPush !== this.removedSinceLastPush) {
// add steps by word boundary, up to MAX_STEP_LENGTH characters
const str = diff.added ? diff.added : diff.removed;
const isWordBoundary = str === " " || str === "\t" || str === "\n";
if (this._nonWordBoundarySinceLastPush && isWordBoundary) {
if (this.nonWordBoundarySinceLastPush && isWordBoundary) {
return true;
}
if (!isWordBoundary) {
this._nonWordBoundarySinceLastPush = true;
this.nonWordBoundarySinceLastPush = true;
}
this._newlyTypedCharCount += str.length;
return this._newlyTypedCharCount > MAX_STEP_LENGTH;
this.newlyTypedCharCount += str.length;
return this.newlyTypedCharCount > MAX_STEP_LENGTH;
} else {
// if starting to remove while adding before, or the opposite, push
return true;
@ -71,24 +86,24 @@ export default class HistoryManager {
}
}
_pushState(model, caret) {
private pushState(model: EditorModel, caret: Caret) {
// remove all steps after current step
while (this._currentIndex < (this._stack.length - 1)) {
this._stack.pop();
while (this.currentIndex < (this.stack.length - 1)) {
this.stack.pop();
}
const parts = model.serializeParts();
this._stack.push({parts, caret});
this._currentIndex = this._stack.length - 1;
this._lastCaret = null;
this._changedSinceLastPush = false;
this._newlyTypedCharCount = 0;
this._nonWordBoundarySinceLastPush = false;
this._addedSinceLastPush = false;
this._removedSinceLastPush = false;
this.stack.push({parts, caret});
this.currentIndex = this.stack.length - 1;
this.lastCaret = null;
this.changedSinceLastPush = false;
this.newlyTypedCharCount = 0;
this.nonWordBoundarySinceLastPush = false;
this.addedSinceLastPush = false;
this.removedSinceLastPush = false;
}
// needs to persist parts and caret position
tryPush(model, caret, inputType, diff) {
tryPush(model: EditorModel, caret: Caret, inputType: string, diff: IDiff) {
// ignore state restoration echos.
// these respect the inputType values of the input event,
// but are actually passed in from MessageEditor calling model.reset()
@ -96,45 +111,45 @@ export default class HistoryManager {
if (inputType === "historyUndo" || inputType === "historyRedo") {
return false;
}
const shouldPush = this._shouldPush(inputType, diff);
const shouldPush = this.shouldPush(inputType, diff);
if (shouldPush) {
this._pushState(model, caret);
this.pushState(model, caret);
} else {
this._lastCaret = caret;
this._changedSinceLastPush = true;
this.lastCaret = caret;
this.changedSinceLastPush = true;
}
return shouldPush;
}
ensureLastChangesPushed(model) {
if (this._changedSinceLastPush) {
this._pushState(model, this._lastCaret);
ensureLastChangesPushed(model: EditorModel) {
if (this.changedSinceLastPush) {
this.pushState(model, this.lastCaret);
}
}
canUndo() {
return this._currentIndex >= 1 || this._changedSinceLastPush;
return this.currentIndex >= 1 || this.changedSinceLastPush;
}
canRedo() {
return this._currentIndex < (this._stack.length - 1);
return this.currentIndex < (this.stack.length - 1);
}
// returns state that should be applied to model
undo(model) {
undo(model: EditorModel) {
if (this.canUndo()) {
this.ensureLastChangesPushed(model);
this._currentIndex -= 1;
return this._stack[this._currentIndex];
this.currentIndex -= 1;
return this.stack[this.currentIndex];
}
}
// returns state that should be applied to model
redo() {
if (this.canRedo()) {
this._changedSinceLastPush = false;
this._currentIndex += 1;
return this._stack[this._currentIndex];
this.changedSinceLastPush = false;
this.currentIndex += 1;
return this.stack[this.currentIndex];
}
}
}

View file

@ -15,9 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {diffAtCaret, diffDeletion} from "./diff";
import DocumentPosition from "./position";
import {diffAtCaret, diffDeletion, IDiff} from "./diff";
import DocumentPosition, {IPosition} from "./position";
import Range from "./range";
import {SerializedPart, Part, PartCreator} from "./parts";
import AutocompleteWrapperModel, {ICallback} from "./autocomplete";
import DocumentOffset from "./offset";
import {Caret} from "./caret";
/**
* @callback ModelCallback
@ -40,16 +44,23 @@ import Range from "./range";
* @return the caret position
*/
type TransformCallback = (caretPosition: DocumentPosition, inputType: string, diff: IDiff) => number | void;
type UpdateCallback = (caret: Caret, inputType?: string, diff?: IDiff) => void;
type ManualTransformCallback = () => Caret;
export default class EditorModel {
constructor(parts, partCreator, updateCallback = null) {
private _parts: Part[];
private readonly _partCreator: PartCreator;
private activePartIdx: number = null;
private _autoComplete: AutocompleteWrapperModel = null;
private autoCompletePartIdx: number = null;
private autoCompletePartCount = 0;
private transformCallback: TransformCallback = null;
constructor(parts: Part[], partCreator: PartCreator, private updateCallback: UpdateCallback = null) {
this._parts = parts;
this._partCreator = partCreator;
this._activePartIdx = null;
this._autoComplete = null;
this._autoCompletePartIdx = null;
this._autoCompletePartCount = 0;
this._transformCallback = null;
this.setUpdateCallback(updateCallback);
this.transformCallback = null;
}
/**
@ -59,16 +70,16 @@ export default class EditorModel {
* on the model that can span multiple parts. Also see `startRange()`.
* @param {TransformCallback} transformCallback
*/
setTransformCallback(transformCallback) {
this._transformCallback = transformCallback;
setTransformCallback(transformCallback: TransformCallback) {
this.transformCallback = transformCallback;
}
/**
* Set a callback for rerendering the model after it has been updated.
* @param {ModelCallback} updateCallback
*/
setUpdateCallback(updateCallback) {
this._updateCallback = updateCallback;
setUpdateCallback(updateCallback: UpdateCallback) {
this.updateCallback = updateCallback;
}
get partCreator() {
@ -80,34 +91,34 @@ export default class EditorModel {
}
clone() {
return new EditorModel(this._parts, this._partCreator, this._updateCallback);
return new EditorModel(this._parts, this._partCreator, this.updateCallback);
}
_insertPart(index, part) {
private insertPart(index: number, part: Part) {
this._parts.splice(index, 0, part);
if (this._activePartIdx >= index) {
++this._activePartIdx;
if (this.activePartIdx >= index) {
++this.activePartIdx;
}
if (this._autoCompletePartIdx >= index) {
++this._autoCompletePartIdx;
if (this.autoCompletePartIdx >= index) {
++this.autoCompletePartIdx;
}
}
_removePart(index) {
private removePart(index: number) {
this._parts.splice(index, 1);
if (index === this._activePartIdx) {
this._activePartIdx = null;
} else if (this._activePartIdx > index) {
--this._activePartIdx;
if (index === this.activePartIdx) {
this.activePartIdx = null;
} else if (this.activePartIdx > index) {
--this.activePartIdx;
}
if (index === this._autoCompletePartIdx) {
this._autoCompletePartIdx = null;
} else if (this._autoCompletePartIdx > index) {
--this._autoCompletePartIdx;
if (index === this.autoCompletePartIdx) {
this.autoCompletePartIdx = null;
} else if (this.autoCompletePartIdx > index) {
--this.autoCompletePartIdx;
}
}
_replacePart(index, part) {
private replacePart(index: number, part: Part) {
this._parts.splice(index, 1, part);
}
@ -116,7 +127,7 @@ export default class EditorModel {
}
get autoComplete() {
if (this._activePartIdx === this._autoCompletePartIdx) {
if (this.activePartIdx === this.autoCompletePartIdx) {
return this._autoComplete;
}
return null;
@ -137,7 +148,7 @@ export default class EditorModel {
return this._parts.map(p => p.serialize());
}
_diff(newValue, inputType, caret) {
private diff(newValue: string, inputType: string, caret: DocumentOffset) {
const previousValue = this.parts.reduce((text, p) => text + p.text, "");
// can't use caret position with drag and drop
if (inputType === "deleteByDrag") {
@ -147,7 +158,7 @@ export default class EditorModel {
}
}
reset(serializedParts, caret, inputType) {
reset(serializedParts: SerializedPart[], caret: Caret, inputType: string) {
this._parts = serializedParts.map(p => this._partCreator.deserializePart(p));
if (!caret) {
caret = this.getPositionAtEnd();
@ -157,9 +168,9 @@ export default class EditorModel {
// a message with the autocomplete still open
if (this._autoComplete) {
this._autoComplete = null;
this._autoCompletePartIdx = null;
this.autoCompletePartIdx = null;
}
this._updateCallback(caret, inputType);
this.updateCallback(caret, inputType);
}
/**
@ -169,19 +180,19 @@ export default class EditorModel {
* @param {DocumentPosition} position the position to start inserting at
* @return {Number} the amount of characters added
*/
insert(parts, position) {
const insertIndex = this._splitAt(position);
insert(parts: Part[], position: IPosition) {
const insertIndex = this.splitAt(position);
let newTextLength = 0;
for (let i = 0; i < parts.length; ++i) {
const part = parts[i];
newTextLength += part.text.length;
this._insertPart(insertIndex + i, part);
this.insertPart(insertIndex + i, part);
}
return newTextLength;
}
update(newValue, inputType, caret) {
const diff = this._diff(newValue, inputType, caret);
update(newValue: string, inputType: string, caret: DocumentOffset) {
const diff = this.diff(newValue, inputType, caret);
const position = this.positionForOffset(diff.at, caret.atNodeEnd);
let removedOffsetDecrease = 0;
if (diff.removed) {
@ -189,40 +200,40 @@ export default class EditorModel {
}
let addedLen = 0;
if (diff.added) {
addedLen = this._addText(position, diff.added, inputType);
addedLen = this.addText(position, diff.added, inputType);
}
this._mergeAdjacentParts();
this.mergeAdjacentParts();
const caretOffset = diff.at - removedOffsetDecrease + addedLen;
let newPosition = this.positionForOffset(caretOffset, true);
const canOpenAutoComplete = inputType !== "insertFromPaste" && inputType !== "insertFromDrop";
const acPromise = this._setActivePart(newPosition, canOpenAutoComplete);
if (this._transformCallback) {
const transformAddedLen = this._transform(newPosition, inputType, diff);
const acPromise = this.setActivePart(newPosition, canOpenAutoComplete);
if (this.transformCallback) {
const transformAddedLen = this.getTransformAddedLen(newPosition, inputType, diff);
newPosition = this.positionForOffset(caretOffset + transformAddedLen, true);
}
this._updateCallback(newPosition, inputType, diff);
this.updateCallback(newPosition, inputType, diff);
return acPromise;
}
_transform(newPosition, inputType, diff) {
const result = this._transformCallback(newPosition, inputType, diff);
return Number.isFinite(result) ? result : 0;
private getTransformAddedLen(newPosition: DocumentPosition, inputType: string, diff: IDiff): number {
const result = this.transformCallback(newPosition, inputType, diff);
return Number.isFinite(result) ? result as number : 0;
}
_setActivePart(pos, canOpenAutoComplete) {
private setActivePart(pos: DocumentPosition, canOpenAutoComplete: boolean) {
const {index} = pos;
const part = this._parts[index];
if (part) {
if (index !== this._activePartIdx) {
this._activePartIdx = index;
if (canOpenAutoComplete && this._activePartIdx !== this._autoCompletePartIdx) {
if (index !== this.activePartIdx) {
this.activePartIdx = index;
if (canOpenAutoComplete && this.activePartIdx !== this.autoCompletePartIdx) {
// else try to create one
const ac = part.createAutoComplete(this._onAutoComplete);
const ac = part.createAutoComplete(this.onAutoComplete);
if (ac) {
// make sure that react picks up the difference between both acs
this._autoComplete = ac;
this._autoCompletePartIdx = index;
this._autoCompletePartCount = 1;
this.autoCompletePartIdx = index;
this.autoCompletePartCount = 1;
}
}
}
@ -231,35 +242,35 @@ export default class EditorModel {
return this.autoComplete.onPartUpdate(part, pos);
}
} else {
this._activePartIdx = null;
this.activePartIdx = null;
this._autoComplete = null;
this._autoCompletePartIdx = null;
this._autoCompletePartCount = 0;
this.autoCompletePartIdx = null;
this.autoCompletePartCount = 0;
}
return Promise.resolve();
}
_onAutoComplete = ({replaceParts, close}) => {
private onAutoComplete = ({replaceParts, close}: ICallback) => {
let pos;
if (replaceParts) {
this._parts.splice(this._autoCompletePartIdx, this._autoCompletePartCount, ...replaceParts);
this._autoCompletePartCount = replaceParts.length;
this._parts.splice(this.autoCompletePartIdx, this.autoCompletePartCount, ...replaceParts);
this.autoCompletePartCount = replaceParts.length;
const lastPart = replaceParts[replaceParts.length - 1];
const lastPartIndex = this._autoCompletePartIdx + replaceParts.length - 1;
const lastPartIndex = this.autoCompletePartIdx + replaceParts.length - 1;
pos = new DocumentPosition(lastPartIndex, lastPart.text.length);
}
if (close) {
this._autoComplete = null;
this._autoCompletePartIdx = null;
this._autoCompletePartCount = 0;
this.autoCompletePartIdx = null;
this.autoCompletePartCount = 0;
}
// rerender even if editor contents didn't change
// to make sure the MessageEditor checks
// model.autoComplete being empty and closes it
this._updateCallback(pos);
}
this.updateCallback(pos);
};
_mergeAdjacentParts() {
private mergeAdjacentParts() {
let prevPart;
for (let i = 0; i < this._parts.length; ++i) {
let part = this._parts[i];
@ -268,7 +279,7 @@ export default class EditorModel {
if (isEmpty || isMerged) {
// remove empty or merged part
part = prevPart;
this._removePart(i);
this.removePart(i);
//repeat this index, as it's removed now
--i;
}
@ -283,7 +294,7 @@ export default class EditorModel {
* @return {Number} how many characters before pos were also removed,
* usually because of non-editable parts that can only be removed in their entirety.
*/
removeText(pos, len) {
removeText(pos: IPosition, len: number) {
let {index, offset} = pos;
let removedOffsetDecrease = 0;
while (len > 0) {
@ -295,18 +306,18 @@ export default class EditorModel {
if (part.canEdit) {
const replaceWith = part.remove(offset, amount);
if (typeof replaceWith === "string") {
this._replacePart(index, this._partCreator.createDefaultPart(replaceWith));
this.replacePart(index, this._partCreator.createDefaultPart(replaceWith));
}
part = this._parts[index];
// remove empty part
if (!part.text.length) {
this._removePart(index);
this.removePart(index);
} else {
index += 1;
}
} else {
removedOffsetDecrease += offset;
this._removePart(index);
this.removePart(index);
}
} else {
index += 1;
@ -316,8 +327,9 @@ export default class EditorModel {
}
return removedOffsetDecrease;
}
// return part index where insertion will insert between at offset
_splitAt(pos) {
private splitAt(pos: IPosition) {
if (pos.index === -1) {
return 0;
}
@ -330,7 +342,7 @@ export default class EditorModel {
}
const secondPart = part.split(pos.offset);
this._insertPart(pos.index + 1, secondPart);
this.insertPart(pos.index + 1, secondPart);
return pos.index + 1;
}
@ -344,7 +356,7 @@ export default class EditorModel {
* @return {Number} how far from position (in characters) the insertion ended.
* This can be more than the length of `str` when crossing non-editable parts, which are skipped.
*/
_addText(pos, str, inputType) {
private addText(pos: IPosition, str: string, inputType: string) {
let {index} = pos;
const {offset} = pos;
let addLen = str.length;
@ -356,7 +368,7 @@ export default class EditorModel {
} else {
const splitPart = part.split(offset);
index += 1;
this._insertPart(index, splitPart);
this.insertPart(index, splitPart);
}
} else if (offset !== 0) {
// not-editable part, caret is not at start,
@ -372,13 +384,13 @@ export default class EditorModel {
while (str) {
const newPart = this._partCreator.createPartForInput(str, index, inputType);
str = newPart.appendUntilRejected(str, inputType);
this._insertPart(index, newPart);
this.insertPart(index, newPart);
index += 1;
}
return addLen;
}
positionForOffset(totalOffset, atPartEnd) {
positionForOffset(totalOffset: number, atPartEnd: boolean) {
let currentOffset = 0;
const index = this._parts.findIndex(part => {
const partLen = part.text.length;
@ -404,28 +416,27 @@ export default class EditorModel {
* @param {DocumentPosition?} positionB the other boundary of the range, optional
* @return {Range}
*/
startRange(positionA, positionB = positionA) {
startRange(positionA: DocumentPosition, positionB = positionA) {
return new Range(this, positionA, positionB);
}
// called from Range.replace
_replaceRange(startPosition, endPosition, parts) {
replaceRange(startPosition: DocumentPosition, endPosition: DocumentPosition, parts: Part[]) {
// convert end position to offset, so it is independent of how the document is split into parts
// which we'll change when splitting up at the start position
const endOffset = endPosition.asOffset(this);
const newStartPartIndex = this._splitAt(startPosition);
const newStartPartIndex = this.splitAt(startPosition);
// convert it back to position once split at start
endPosition = endOffset.asPosition(this);
const newEndPartIndex = this._splitAt(endPosition);
const newEndPartIndex = this.splitAt(endPosition);
for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) {
this._removePart(i);
this.removePart(i);
}
let insertIdx = newStartPartIndex;
for (const part of parts) {
this._insertPart(insertIdx, part);
this.insertPart(insertIdx, part);
insertIdx += 1;
}
this._mergeAdjacentParts();
this.mergeAdjacentParts();
}
/**
@ -434,15 +445,15 @@ export default class EditorModel {
* @param {ManualTransformCallback} callback to run the transformations in
* @return {Promise} a promise when auto-complete (if applicable) is done updating
*/
transform(callback) {
transform(callback: ManualTransformCallback) {
const pos = callback();
let acPromise = null;
if (!(pos instanceof Range)) {
acPromise = this._setActivePart(pos, true);
acPromise = this.setActivePart(pos, true);
} else {
acPromise = Promise.resolve();
}
this._updateCallback(pos);
this.updateCallback(pos);
return acPromise;
}
}

View file

@ -14,17 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import EditorModel from "./model";
export default class DocumentOffset {
constructor(offset, atNodeEnd) {
this.offset = offset;
this.atNodeEnd = atNodeEnd;
constructor(public offset: number, public readonly atNodeEnd: boolean) {
}
asPosition(model) {
asPosition(model: EditorModel) {
return model.positionForOffset(this.offset, this.atNodeEnd);
}
add(delta, atNodeEnd = false) {
add(delta: number, atNodeEnd = false) {
return new DocumentOffset(this.offset + delta, atNodeEnd);
}
}

View file

@ -14,11 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import Range from "./range";
import {Part} from "./parts";
/**
* Some common queries and transformations on the editor model
*/
export function replaceRangeAndExpandSelection(range, newParts) {
export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]) {
const {model} = range;
model.transform(() => {
const oldLen = range.length;
@ -29,7 +32,7 @@ export function replaceRangeAndExpandSelection(range, newParts) {
});
}
export function replaceRangeAndMoveCaret(range, newParts) {
export function replaceRangeAndMoveCaret(range: Range, newParts: Part[]) {
const {model} = range;
model.transform(() => {
const oldLen = range.length;
@ -40,7 +43,7 @@ export function replaceRangeAndMoveCaret(range, newParts) {
});
}
export function rangeStartsAtBeginningOfLine(range) {
export function rangeStartsAtBeginningOfLine(range: Range) {
const {model} = range;
const startsWithPartial = range.start.offset !== 0;
const isFirstPart = range.start.index === 0;
@ -48,16 +51,16 @@ export function rangeStartsAtBeginningOfLine(range) {
return !startsWithPartial && (isFirstPart || previousIsNewline);
}
export function rangeEndsAtEndOfLine(range) {
export function rangeEndsAtEndOfLine(range: Range) {
const {model} = range;
const lastPart = model.parts[range.end.index];
const endsWithPartial = range.end.offset !== lastPart.length;
const endsWithPartial = range.end.offset !== lastPart.text.length;
const isLastPart = range.end.index === model.parts.length - 1;
const nextIsNewline = !isLastPart && model.parts[range.end.index + 1].type === "newline";
return !endsWithPartial && (isLastPart || nextIsNewline);
}
export function formatRangeAsQuote(range) {
export function formatRangeAsQuote(range: Range) {
const {model, parts} = range;
const {partCreator} = model;
for (let i = 0; i < parts.length; ++i) {
@ -78,7 +81,7 @@ export function formatRangeAsQuote(range) {
replaceRangeAndExpandSelection(range, parts);
}
export function formatRangeAsCode(range) {
export function formatRangeAsCode(range: Range) {
const {model, parts} = range;
const {partCreator} = model;
const needsBlock = parts.some(p => p.type === "newline");
@ -104,7 +107,7 @@ export function formatRangeAsCode(range) {
const isBlank = part => !part.text || !/\S/.test(part.text);
const isNL = part => part.type === "newline";
export function toggleInlineFormat(range, prefix, suffix = prefix) {
export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix) {
const {model, parts} = range;
const {partCreator} = model;
@ -140,10 +143,10 @@ export function toggleInlineFormat(range, prefix, suffix = prefix) {
// keep track of how many things we have inserted as an offset:=0
let offset = 0;
paragraphIndexes.forEach(([startIndex, endIndex]) => {
paragraphIndexes.forEach(([startIdx, endIdx]) => {
// for each paragraph apply the same rule
const base = startIndex + offset;
const index = endIndex + offset;
const base = startIdx + offset;
const index = endIdx + offset;
const isFormatted = (index - base > 0) &&
parts[base].text.startsWith(prefix) &&

View file

@ -15,27 +15,89 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import AutocompleteWrapperModel from "./autocomplete";
import {MatrixClient} from "matrix-js-sdk/src/client";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import {Room} from "matrix-js-sdk/src/models/room";
import AutocompleteWrapperModel, {
GetAutocompleterComponent,
UpdateCallback,
UpdateQuery
} from "./autocomplete";
import * as Avatar from "../Avatar";
class BasePart {
interface ISerializedPart {
type: Type.Plain | Type.Newline | Type.Command | Type.PillCandidate;
text: string;
}
interface ISerializedPillPart {
type: Type.AtRoomPill | Type.RoomPill | Type.UserPill;
text: string;
resourceId: string;
}
export type SerializedPart = ISerializedPart | ISerializedPillPart;
enum Type {
Plain = "plain",
Newline = "newline",
Command = "command",
UserPill = "user-pill",
RoomPill = "room-pill",
AtRoomPill = "at-room-pill",
PillCandidate = "pill-candidate",
}
interface IBasePart {
text: string;
type: Type.Plain | Type.Newline;
canEdit: boolean;
createAutoComplete(updateCallback: UpdateCallback): void;
serialize(): SerializedPart;
remove(offset: number, len: number): string;
split(offset: number): IBasePart;
validateAndInsert(offset: number, str: string, inputType: string): boolean;
appendUntilRejected(str: string, inputType: string): string;
updateDOMNode(node: Node);
canUpdateDOMNode(node: Node);
toDOMNode(): Node;
}
interface IPillCandidatePart extends Omit<IBasePart, "type" | "createAutoComplete"> {
type: Type.PillCandidate | Type.Command;
createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel;
}
interface IPillPart extends Omit<IBasePart, "type" | "resourceId"> {
type: Type.AtRoomPill | Type.RoomPill | Type.UserPill;
resourceId: string;
}
export type Part = IBasePart | IPillCandidatePart | IPillPart;
abstract class BasePart {
protected _text: string;
constructor(text = "") {
this._text = text;
}
acceptsInsertion(chr, offset, inputType) {
acceptsInsertion(chr: string, offset: number, inputType: string) {
return true;
}
acceptsRemoval(position, chr) {
acceptsRemoval(position: number, chr: string) {
return true;
}
merge(part) {
merge(part: Part) {
return false;
}
split(offset) {
split(offset: number) {
const splitText = this.text.substr(offset);
this._text = this.text.substr(0, offset);
return new PlainPart(splitText);
@ -43,7 +105,7 @@ class BasePart {
// removes len chars, or returns the plain text this part should be replaced with
// if the part would become invalid if it removed everything.
remove(offset, len) {
remove(offset: number, len: number) {
// validate
const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len);
for (let i = offset; i < (len + offset); ++i) {
@ -56,7 +118,7 @@ class BasePart {
}
// append str, returns the remaining string if a character was rejected.
appendUntilRejected(str, inputType) {
appendUntilRejected(str: string, inputType: string) {
const offset = this.text.length;
for (let i = 0; i < str.length; ++i) {
const chr = str.charAt(i);
@ -70,7 +132,7 @@ class BasePart {
// inserts str at offset if all the characters in str were accepted, otherwise don't do anything
// return whether the str was accepted or not.
validateAndInsert(offset, str, inputType) {
validateAndInsert(offset: number, str: string, inputType: string) {
for (let i = 0; i < str.length; ++i) {
const chr = str.charAt(i);
if (!this.acceptsInsertion(chr, offset + i, inputType)) {
@ -83,9 +145,9 @@ class BasePart {
return true;
}
createAutoComplete() {}
createAutoComplete(updateCallback: UpdateCallback): void {}
trim(len) {
trim(len: number) {
const remaining = this._text.substr(len);
this._text = this._text.substr(0, len);
return remaining;
@ -95,6 +157,8 @@ class BasePart {
return this._text;
}
abstract get type(): Type;
get canEdit() {
return true;
}
@ -103,14 +167,20 @@ class BasePart {
return `${this.type}(${this.text})`;
}
serialize() {
return {type: this.type, text: this.text};
serialize(): SerializedPart {
return {
type: this.type as ISerializedPart["type"],
text: this.text,
};
}
abstract updateDOMNode(node: Node);
abstract canUpdateDOMNode(node: Node);
abstract toDOMNode(): Node;
}
// exported for unit tests, should otherwise only be used through PartCreator
export class PlainPart extends BasePart {
acceptsInsertion(chr, offset, inputType) {
abstract class PlainBasePart extends BasePart {
acceptsInsertion(chr: string, offset: number, inputType: string) {
if (chr === "\n") {
return false;
}
@ -133,32 +203,34 @@ export class PlainPart extends BasePart {
return false;
}
get type() {
return "plain";
}
updateDOMNode(node) {
updateDOMNode(node: Node) {
if (node.textContent !== this.text) {
node.textContent = this.text;
}
}
canUpdateDOMNode(node) {
canUpdateDOMNode(node: Node) {
return node.nodeType === Node.TEXT_NODE;
}
}
class PillPart extends BasePart {
constructor(resourceId, label) {
// exported for unit tests, should otherwise only be used through PartCreator
export class PlainPart extends PlainBasePart implements IBasePart {
get type(): IBasePart["type"] {
return Type.Plain;
}
}
abstract class PillPart extends BasePart implements IPillPart {
constructor(public resourceId: string, label) {
super(label);
this.resourceId = resourceId;
}
acceptsInsertion(chr) {
acceptsInsertion(chr: string) {
return chr !== " ";
}
acceptsRemoval(position, chr) {
acceptsRemoval(position: number, chr: string) {
return position !== 0; //if you remove initial # or @, pill should become plain
}
@ -171,7 +243,7 @@ class PillPart extends BasePart {
return container;
}
updateDOMNode(node) {
updateDOMNode(node: HTMLElement) {
const textNode = node.childNodes[0];
if (textNode.textContent !== this.text) {
textNode.textContent = this.text;
@ -182,7 +254,7 @@ class PillPart extends BasePart {
this.setAvatar(node);
}
canUpdateDOMNode(node) {
canUpdateDOMNode(node: HTMLElement) {
return node.nodeType === Node.ELEMENT_NODE &&
node.nodeName === "SPAN" &&
node.childNodes.length === 1 &&
@ -190,7 +262,7 @@ class PillPart extends BasePart {
}
// helper method for subclasses
_setAvatarVars(node, avatarUrl, initialLetter) {
_setAvatarVars(node: HTMLElement, avatarUrl: string, initialLetter: string) {
const avatarBackground = `url('${avatarUrl}')`;
const avatarLetter = `'${initialLetter}'`;
// check if the value is changing,
@ -206,14 +278,20 @@ class PillPart extends BasePart {
get canEdit() {
return false;
}
abstract get type(): IPillPart["type"];
abstract get className(): string;
abstract setAvatar(node: HTMLElement): void;
}
class NewlinePart extends BasePart {
acceptsInsertion(chr, offset) {
class NewlinePart extends BasePart implements IBasePart {
acceptsInsertion(chr: string, offset: number) {
return offset === 0 && chr === "\n";
}
acceptsRemoval(position, chr) {
acceptsRemoval(position: number, chr: string) {
return true;
}
@ -227,12 +305,12 @@ class NewlinePart extends BasePart {
updateDOMNode() {}
canUpdateDOMNode(node) {
canUpdateDOMNode(node: HTMLElement) {
return node.tagName === "BR";
}
get type() {
return "newline";
get type(): IBasePart["type"] {
return Type.Newline;
}
// this makes the cursor skip this part when it is inserted
@ -245,27 +323,26 @@ class NewlinePart extends BasePart {
}
class RoomPillPart extends PillPart {
constructor(displayAlias, room) {
constructor(displayAlias, private room: Room) {
super(displayAlias, displayAlias);
this._room = room;
}
setAvatar(node) {
setAvatar(node: HTMLElement) {
let initialLetter = "";
let avatarUrl = Avatar.avatarUrlForRoom(
this._room,
this.room,
16 * window.devicePixelRatio,
16 * window.devicePixelRatio,
"crop");
if (!avatarUrl) {
initialLetter = Avatar.getInitialLetter(this._room ? this._room.name : this.resourceId);
avatarUrl = Avatar.defaultAvatarUrlForString(this._room ? this._room.roomId : this.resourceId);
initialLetter = Avatar.getInitialLetter(this.room ? this.room.name : this.resourceId);
avatarUrl = Avatar.defaultAvatarUrlForString(this.room ? this.room.roomId : this.resourceId);
}
this._setAvatarVars(node, avatarUrl, initialLetter);
}
get type() {
return "room-pill";
get type(): IPillPart["type"] {
return Type.RoomPill;
}
get className() {
@ -274,25 +351,24 @@ class RoomPillPart extends PillPart {
}
class AtRoomPillPart extends RoomPillPart {
get type() {
return "at-room-pill";
get type(): IPillPart["type"] {
return Type.AtRoomPill;
}
}
class UserPillPart extends PillPart {
constructor(userId, displayName, member) {
constructor(userId, displayName, private member: RoomMember) {
super(userId, displayName);
this._member = member;
}
setAvatar(node) {
if (!this._member) {
setAvatar(node: HTMLElement) {
if (!this.member) {
return;
}
const name = this._member.name || this._member.userId;
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this._member.userId);
const name = this.member.name || this.member.userId;
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this.member.userId);
const avatarUrl = Avatar.avatarUrlForMember(
this._member,
this.member,
16 * window.devicePixelRatio,
16 * window.devicePixelRatio,
"crop");
@ -303,33 +379,33 @@ class UserPillPart extends PillPart {
this._setAvatarVars(node, avatarUrl, initialLetter);
}
get type() {
return "user-pill";
get type(): IPillPart["type"] {
return Type.UserPill;
}
get className() {
return "mx_UserPill mx_Pill";
}
serialize() {
const obj = super.serialize();
obj.resourceId = this.resourceId;
return obj;
serialize(): ISerializedPillPart {
return {
type: this.type,
text: this.text,
resourceId: this.resourceId,
};
}
}
class PillCandidatePart extends PlainPart {
constructor(text, autoCompleteCreator) {
class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
constructor(text: string, private autoCompleteCreator: IAutocompleteCreator) {
super(text);
this._autoCompleteCreator = autoCompleteCreator;
}
createAutoComplete(updateCallback) {
return this._autoCompleteCreator.create(updateCallback);
createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel {
return this.autoCompleteCreator.create(updateCallback);
}
acceptsInsertion(chr, offset, inputType) {
acceptsInsertion(chr: string, offset: number, inputType: string) {
if (offset === 0) {
return true;
} else {
@ -341,18 +417,18 @@ class PillCandidatePart extends PlainPart {
return false;
}
acceptsRemoval(position, chr) {
acceptsRemoval(position: number, chr: string) {
return true;
}
get type() {
return "pill-candidate";
get type(): IPillCandidatePart["type"] {
return Type.PillCandidate;
}
}
export function autoCompleteCreator(getAutocompleterComponent, updateQuery) {
return (partCreator) => {
return (updateCallback) => {
export function getAutoCompleteCreator(getAutocompleterComponent: GetAutocompleterComponent, updateQuery: UpdateQuery) {
return (partCreator: PartCreator) => {
return (updateCallback: UpdateCallback) => {
return new AutocompleteWrapperModel(
updateCallback,
getAutocompleterComponent,
@ -363,20 +439,26 @@ export function autoCompleteCreator(getAutocompleterComponent, updateQuery) {
};
}
type AutoCompleteCreator = ReturnType<typeof getAutoCompleteCreator>;
interface IAutocompleteCreator {
create(updateCallback: UpdateCallback): AutocompleteWrapperModel;
}
export class PartCreator {
constructor(room, client, autoCompleteCreator = null) {
this._room = room;
this._client = client;
protected readonly autoCompleteCreator: IAutocompleteCreator;
constructor(private room: Room, private client: MatrixClient, autoCompleteCreator: AutoCompleteCreator = null) {
// pre-create the creator as an object even without callback so it can already be passed
// to PillCandidatePart (e.g. while deserializing) and set later on
this._autoCompleteCreator = {create: autoCompleteCreator && autoCompleteCreator(this)};
this.autoCompleteCreator = {create: autoCompleteCreator && autoCompleteCreator(this)};
}
setAutoCompleteCreator(autoCompleteCreator) {
this._autoCompleteCreator.create = autoCompleteCreator(this);
setAutoCompleteCreator(autoCompleteCreator: AutoCompleteCreator) {
this.autoCompleteCreator.create = autoCompleteCreator(this);
}
createPartForInput(input) {
createPartForInput(input: string, partIndex: number, inputType?: string): Part {
switch (input[0]) {
case "#":
case "@":
@ -389,28 +471,28 @@ export class PartCreator {
}
}
createDefaultPart(text) {
createDefaultPart(text: string) {
return this.plain(text);
}
deserializePart(part) {
deserializePart(part: SerializedPart): Part {
switch (part.type) {
case "plain":
case Type.Plain:
return this.plain(part.text);
case "newline":
case Type.Newline:
return this.newline();
case "at-room-pill":
case Type.AtRoomPill:
return this.atRoomPill(part.text);
case "pill-candidate":
case Type.PillCandidate:
return this.pillCandidate(part.text);
case "room-pill":
case Type.RoomPill:
return this.roomPill(part.text);
case "user-pill":
case Type.UserPill:
return this.userPill(part.text, part.resourceId);
}
}
plain(text) {
plain(text: string) {
return new PlainPart(text);
}
@ -418,16 +500,16 @@ export class PartCreator {
return new NewlinePart("\n");
}
pillCandidate(text) {
return new PillCandidatePart(text, this._autoCompleteCreator);
pillCandidate(text: string) {
return new PillCandidatePart(text, this.autoCompleteCreator);
}
roomPill(alias, roomId) {
roomPill(alias: string, roomId?: string) {
let room;
if (roomId || alias[0] !== "#") {
room = this._client.getRoom(roomId || alias);
room = this.client.getRoom(roomId || alias);
} else {
room = this._client.getRooms().find((r) => {
room = this.client.getRooms().find((r) => {
return r.getCanonicalAlias() === alias ||
r.getAltAliases().includes(alias);
});
@ -435,16 +517,16 @@ export class PartCreator {
return new RoomPillPart(alias, room);
}
atRoomPill(text) {
return new AtRoomPillPart(text, this._room);
atRoomPill(text: string) {
return new AtRoomPillPart(text, this.room);
}
userPill(displayName, userId) {
const member = this._room.getMember(userId);
userPill(displayName: string, userId: string) {
const member = this.room.getMember(userId);
return new UserPillPart(userId, displayName, member);
}
createMentionParts(partIndex, displayName, userId) {
createMentionParts(partIndex: number, displayName: string, userId: string) {
const pill = this.userPill(displayName, userId);
const postfix = this.plain(partIndex === 0 ? ": " : " ");
return [pill, postfix];
@ -454,7 +536,7 @@ export class PartCreator {
// part creator that support auto complete for /commands,
// used in SendMessageComposer
export class CommandPartCreator extends PartCreator {
createPartForInput(text, partIndex) {
createPartForInput(text: string, partIndex: number) {
// at beginning and starts with /? create
if (partIndex === 0 && text[0] === "/") {
// text will be inserted by model, so pass empty string
@ -464,11 +546,11 @@ export class CommandPartCreator extends PartCreator {
}
}
command(text) {
return new CommandPart(text, this._autoCompleteCreator);
command(text: string) {
return new CommandPart(text, this.autoCompleteCreator);
}
deserializePart(part) {
deserializePart(part: Part): Part {
if (part.type === "command") {
return this.command(part.text);
} else {
@ -478,7 +560,7 @@ export class CommandPartCreator extends PartCreator {
}
class CommandPart extends PillCandidatePart {
get type() {
return "command";
get type(): IPillCandidatePart["type"] {
return Type.Command;
}
}

View file

@ -15,30 +15,30 @@ limitations under the License.
*/
import DocumentOffset from "./offset";
import EditorModel from "./model";
import {Part} from "./parts";
export default class DocumentPosition {
constructor(index, offset) {
this._index = index;
this._offset = offset;
export interface IPosition {
index: number;
offset: number;
}
type Callback = (part: Part, startIdx: number, endIdx: number) => void;
export type Predicate = (index: number, offset: number, part: Part) => boolean;
export default class DocumentPosition implements IPosition {
constructor(public readonly index: number, public readonly offset: number) {
}
get index() {
return this._index;
}
get offset() {
return this._offset;
}
compare(otherPos) {
if (this._index === otherPos._index) {
return this._offset - otherPos._offset;
compare(otherPos: DocumentPosition) {
if (this.index === otherPos.index) {
return this.offset - otherPos.offset;
} else {
return this._index - otherPos._index;
return this.index - otherPos.index;
}
}
iteratePartsBetween(other, model, callback) {
iteratePartsBetween(other: DocumentPosition, model: EditorModel, callback: Callback) {
if (this.index === -1 || other.index === -1) {
return;
}
@ -57,7 +57,7 @@ export default class DocumentPosition {
}
}
forwardsWhile(model, predicate) {
forwardsWhile(model: EditorModel, predicate: Predicate) {
if (this.index === -1) {
return this;
}
@ -82,7 +82,7 @@ export default class DocumentPosition {
}
}
backwardsWhile(model, predicate) {
backwardsWhile(model: EditorModel, predicate: Predicate) {
if (this.index === -1) {
return this;
}
@ -107,7 +107,7 @@ export default class DocumentPosition {
}
}
asOffset(model) {
asOffset(model: EditorModel) {
if (this.index === -1) {
return new DocumentOffset(0, true);
}
@ -121,7 +121,7 @@ export default class DocumentPosition {
return new DocumentOffset(offset, atEnd);
}
isAtEnd(model) {
isAtEnd(model: EditorModel) {
if (model.parts.length === 0) {
return true;
}

View file

@ -14,32 +14,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import EditorModel from "./model";
import DocumentPosition, {Predicate} from "./position";
import {Part} from "./parts";
export default class Range {
constructor(model, positionA, positionB = positionA) {
this._model = model;
private _start: DocumentPosition;
private _end: DocumentPosition;
constructor(public readonly model: EditorModel, positionA: DocumentPosition, positionB = positionA) {
const bIsLarger = positionA.compare(positionB) < 0;
this._start = bIsLarger ? positionA : positionB;
this._end = bIsLarger ? positionB : positionA;
}
moveStart(delta) {
this._start = this._start.forwardsWhile(this._model, () => {
moveStart(delta: number) {
this._start = this._start.forwardsWhile(this.model, () => {
delta -= 1;
return delta >= 0;
});
}
expandBackwardsWhile(predicate) {
this._start = this._start.backwardsWhile(this._model, predicate);
}
get model() {
return this._model;
expandBackwardsWhile(predicate: Predicate) {
this._start = this._start.backwardsWhile(this.model, predicate);
}
get text() {
let text = "";
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
const t = part.text.substring(startIdx, endIdx);
text = text + t;
});
@ -52,13 +54,13 @@ export default class Range {
* @param {Part[]} parts the parts to replace the range with
* @return {Number} the net amount of characters added, can be negative.
*/
replace(parts) {
replace(parts: Part[]) {
const newLength = parts.reduce((sum, part) => sum + part.text.length, 0);
let oldLength = 0;
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
oldLength += endIdx - startIdx;
});
this._model._replaceRange(this._start, this._end, parts);
this.model.replaceRange(this._start, this._end, parts);
return newLength - oldLength;
}
@ -68,10 +70,10 @@ export default class Range {
*/
get parts() {
const parts = [];
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
const serializedPart = part.serialize();
serializedPart.text = part.text.substring(startIdx, endIdx);
const newPart = this._model.partCreator.deserializePart(serializedPart);
const newPart = this.model.partCreator.deserializePart(serializedPart);
parts.push(newPart);
});
return parts;
@ -79,7 +81,7 @@ export default class Range {
get length() {
let len = 0;
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
len += endIdx - startIdx;
});
return len;

View file

@ -15,16 +15,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export function needsCaretNodeBefore(part, prevPart) {
import {Part} from "./parts";
import EditorModel from "./model";
export function needsCaretNodeBefore(part: Part, prevPart: Part) {
const isFirst = !prevPart || prevPart.type === "newline";
return !part.canEdit && (isFirst || !prevPart.canEdit);
}
export function needsCaretNodeAfter(part, isLastOfLine) {
export function needsCaretNodeAfter(part: Part, isLastOfLine: boolean) {
return !part.canEdit && isLastOfLine;
}
function insertAfter(node, nodeToInsert) {
function insertAfter(node: HTMLElement, nodeToInsert: HTMLElement) {
const next = node.nextSibling;
if (next) {
node.parentElement.insertBefore(nodeToInsert, next);
@ -48,18 +51,18 @@ function createCaretNode() {
return span;
}
function updateCaretNode(node) {
function updateCaretNode(node: HTMLElement) {
// ensure the caret node contains only a zero-width space
if (node.textContent !== CARET_NODE_CHAR) {
node.textContent = CARET_NODE_CHAR;
}
}
export function isCaretNode(node) {
export function isCaretNode(node: HTMLElement) {
return node && node.tagName === "SPAN" && node.className === "caretNode";
}
function removeNextSiblings(node) {
function removeNextSiblings(node: ChildNode) {
if (!node) {
return;
}
@ -71,7 +74,7 @@ function removeNextSiblings(node) {
}
}
function removeChildren(parent) {
function removeChildren(parent: HTMLElement) {
const firstChild = parent.firstChild;
if (firstChild) {
removeNextSiblings(firstChild);
@ -79,7 +82,7 @@ function removeChildren(parent) {
}
}
function reconcileLine(lineContainer, parts) {
function reconcileLine(lineContainer: ChildNode, parts: Part[]) {
let currentNode;
let prevPart;
const lastPart = parts[parts.length - 1];
@ -146,23 +149,23 @@ function reconcileEmptyLine(lineContainer) {
}
}
export function renderModel(editor, model) {
const lines = model.parts.reduce((lines, part) => {
export function renderModel(editor: HTMLDivElement, model: EditorModel) {
const lines = model.parts.reduce((linesArr, part) => {
if (part.type === "newline") {
lines.push([]);
linesArr.push([]);
} else {
const lastLine = lines[lines.length - 1];
const lastLine = linesArr[linesArr.length - 1];
lastLine.push(part);
}
return lines;
return linesArr;
}, [[]]);
lines.forEach((parts, i) => {
// find first (and remove anything else) div without className
// (as browsers insert these in contenteditable) line container
let lineContainer = editor.childNodes[i];
let lineContainer = editor.children[i];
while (lineContainer && (lineContainer.tagName !== "DIV" || !!lineContainer.className)) {
editor.removeChild(lineContainer);
lineContainer = editor.childNodes[i];
lineContainer = editor.children[i];
}
if (!lineContainer) {
lineContainer = document.createElement("div");