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