From 8812f98b35359352b70bb10ad4ccfbc3cd4f4824 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Jul 2020 09:45:45 +0100 Subject: [PATCH] Convert editor to TypeScript Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/rooms/Autocomplete.tsx | 2 +- ...geComposer.js => BasicMessageComposer.tsx} | 391 ++++++++++-------- src/editor/autocomplete.js | 121 ------ src/editor/autocomplete.ts | 140 +++++++ src/editor/{caret.js => caret.ts} | 19 +- src/editor/deserialize.ts | 2 +- src/editor/{diff.js => diff.ts} | 14 +- src/editor/{dom.js => dom.ts} | 16 +- src/editor/{history.js => history.ts} | 108 ++--- src/editor/{model.js => model.ts} | 188 +++++---- src/editor/{offset.js => offset.ts} | 10 +- src/editor/{operations.js => operations.ts} | 25 +- src/editor/{parts.js => parts.ts} | 177 ++++---- src/editor/{position.js => position.ts} | 42 +- src/editor/{range.js => range.ts} | 29 +- src/editor/{render.js => render.ts} | 34 +- src/editor/serialize.ts | 3 +- 17 files changed, 721 insertions(+), 600 deletions(-) rename src/components/views/rooms/{BasicMessageComposer.js => BasicMessageComposer.tsx} (67%) delete mode 100644 src/editor/autocomplete.js create mode 100644 src/editor/autocomplete.ts rename src/editor/{caret.js => caret.ts} (86%) rename src/editor/{diff.js => diff.ts} (87%) rename src/editor/{dom.js => dom.ts} (91%) rename src/editor/{history.js => history.ts} (52%) rename src/editor/{model.js => model.ts} (69%) rename src/editor/{offset.js => offset.ts} (80%) rename src/editor/{operations.js => operations.ts} (89%) rename src/editor/{parts.js => parts.ts} (69%) rename src/editor/{position.js => position.ts} (79%) rename src/editor/{range.js => range.ts} (71%) rename src/editor/{render.js => render.ts} (84%) diff --git a/src/components/views/rooms/Autocomplete.tsx b/src/components/views/rooms/Autocomplete.tsx index f5cf1a981c..70f7556550 100644 --- a/src/components/views/rooms/Autocomplete.tsx +++ b/src/components/views/rooms/Autocomplete.tsx @@ -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'; diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.tsx similarity index 67% rename from src/components/views/rooms/BasicMessageComposer.js rename to src/components/views/rooms/BasicMessageComposer.tsx index 82f61e0e1f..18ea75f8a9 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -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 { 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, 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, model: EditorModel): boolean; +} + +interface IState { + showPillAvatar: boolean; + query?: string; + showVisualBell?: boolean; + autoComplete?: AutocompleteWrapperModel; + completionIndex?: number; +} + +export default class BasicMessageEditor extends React.Component { + private editorRef = createRef(); + private autocompleteRef = createRef(); + private formatBarRef = createRef(); + + private modifiedFlag = false; + private isIMEComposing = false; + private hasTextSelected = false; + + private _isCaretAtEnd: boolean; + private lastCaret: DocumentOffset; + private lastSelection: ReturnType; + + 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) { + 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) => { 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) => { // 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, "", ""); 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 = (
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 (
{ autoComplete } - this._formatBarRef = ref} onAction={this._onFormatAction} shortcuts={shortcuts} /> +
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(); } } diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js deleted file mode 100644 index fcde6e0ce4..0000000000 --- a/src/editor/autocomplete.js +++ /dev/null @@ -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)]; - } - } -} diff --git a/src/editor/autocomplete.ts b/src/editor/autocomplete.ts new file mode 100644 index 0000000000..5832557ae9 --- /dev/null +++ b/src/editor/autocomplete.ts @@ -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 {BasePart, CommandPartCreator, PartCreator} from "./parts"; +import DocumentPosition from "./position"; +import {ICompletion} from "../autocomplete/Autocompleter"; +import Autocomplete from "../components/views/rooms/Autocomplete"; + +export interface ICallback { + replaceParts?: BasePart[]; + close?: boolean; +} + +export type UpdateCallback = (data: ICallback) => void; +export type GetAutocompleterComponent = () => Autocomplete; +export type UpdateQuery = (test: string) => Promise; + +export default class AutocompleteWrapperModel { + private queryPart: BasePart; + private partIndex: number; + + constructor( + private updateCallback: UpdateCallback, + private getAutocompleterComponent: GetAutocompleterComponent, + private updateQuery: UpdateQuery, + private partCreator: PartCreator | CommandPartCreator, + ) { + } + + onEscape(e: KeyboardEvent) { + 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: 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); + } + } + + onUpArrow(e: KeyboardEvent) { + this.getAutocompleterComponent().moveSelection(-1); + } + + onDownArrow(e: KeyboardEvent) { + this.getAutocompleterComponent().moveSelection(+1); + } + + onPartUpdate(part: BasePart, 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); + } + + onComponentSelectionChange(completion: ICompletion) { + if (!completion) { + this.updateCallback({ + replaceParts: [this.queryPart], + }); + } else { + this.updateCallback({ + replaceParts: this.partForCompletion(completion), + }); + } + } + + 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)]; + } + } +} diff --git a/src/editor/caret.js b/src/editor/caret.ts similarity index 86% rename from src/editor/caret.js rename to src/editor/caret.ts index 8c0090a6f1..cfbc701183 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.ts @@ -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 {BasePart} 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: BasePart[], 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: BasePart[], partIndex: number, nodeIndex: number, offset: number) { // move caret before or after uneditable part const part = parts[partIndex]; if (part && !part.canEdit) { diff --git a/src/editor/deserialize.ts b/src/editor/deserialize.ts index 48d1d98ae4..46eb74f818 100644 --- a/src/editor/deserialize.ts +++ b/src/editor/deserialize.ts @@ -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) { diff --git a/src/editor/diff.js b/src/editor/diff.ts similarity index 87% rename from src/editor/diff.js rename to src/editor/diff.ts index 27d10689b3..cda454306a 100644 --- a/src/editor/diff.js +++ b/src/editor/diff.ts @@ -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); diff --git a/src/editor/dom.js b/src/editor/dom.ts similarity index 91% rename from src/editor/dom.js rename to src/editor/dom.ts index 3efc64f1c9..6a43ffaac5 100644 --- a/src/editor/dom.js +++ b/src/editor/dom.ts @@ -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 = ""; diff --git a/src/editor/history.js b/src/editor/history.ts similarity index 52% rename from src/editor/history.js rename to src/editor/history.ts index d66def4704..f0dc3c251b 100644 --- a/src/editor/history.js +++ b/src/editor/history.ts @@ -14,25 +14,41 @@ See the License for the specific language governing permissions and limitations under the License. */ +import EditorModel from "./model"; +import {IDiff} from "./diff"; +import {ISerializedPart} from "./parts"; +import Range from "./range"; +import {Caret} from "./caret"; + +interface IHistory { + parts: ISerializedPart[]; + 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 +59,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 +87,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 +112,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]; } } } diff --git a/src/editor/model.js b/src/editor/model.ts similarity index 69% rename from src/editor/model.js rename to src/editor/model.ts index 5072c5b2c6..460f95ec0f 100644 --- a/src/editor/model.js +++ b/src/editor/model.ts @@ -15,9 +15,12 @@ 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 {BasePart, ISerializedPart, PartCreator} from "./parts"; +import AutocompleteWrapperModel, {ICallback} from "./autocomplete"; +import DocumentOffset from "./offset"; /** * @callback ModelCallback @@ -40,16 +43,23 @@ import Range from "./range"; * @return the caret position */ +type TransformCallback = (caretPosition: IPosition, inputType: string, diff: IDiff) => number | void; +type UpdateCallback = (caret: Range | IPosition, inputType?: string, diff?: IDiff) => void; +type ManualTransformCallback = () => Range | DocumentPosition; + export default class EditorModel { - constructor(parts, partCreator, updateCallback = null) { + private _parts: BasePart[]; + 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: BasePart[], 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 +69,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 +90,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: BasePart) { 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: BasePart) { this._parts.splice(index, 1, part); } @@ -116,7 +126,7 @@ export default class EditorModel { } get autoComplete() { - if (this._activePartIdx === this._autoCompletePartIdx) { + if (this.activePartIdx === this.autoCompletePartIdx) { return this._autoComplete; } return null; @@ -137,7 +147,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 +157,7 @@ export default class EditorModel { } } - reset(serializedParts, caret, inputType) { + reset(serializedParts: ISerializedPart[], caret: Range | IPosition, inputType: string) { this._parts = serializedParts.map(p => this._partCreator.deserializePart(p)); if (!caret) { caret = this.getPositionAtEnd(); @@ -157,9 +167,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 +179,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: BasePart[], 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 +199,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: IPosition, 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 +241,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 +278,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 +293,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 +305,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 +326,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 +341,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 +355,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 +367,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 +383,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 +415,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: BasePart[]) { // 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 +444,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; } } diff --git a/src/editor/offset.js b/src/editor/offset.ts similarity index 80% rename from src/editor/offset.js rename to src/editor/offset.ts index 785f16bc6d..413a22c71b 100644 --- a/src/editor/offset.js +++ b/src/editor/offset.ts @@ -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); } } diff --git a/src/editor/operations.js b/src/editor/operations.ts similarity index 89% rename from src/editor/operations.js rename to src/editor/operations.ts index d677d7016c..ee3aa04671 100644 --- a/src/editor/operations.js +++ b/src/editor/operations.ts @@ -14,11 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ +import Range from "./range"; +import {BasePart} from "./parts"; + /** * Some common queries and transformations on the editor model */ -export function replaceRangeAndExpandSelection(range, newParts) { +export function replaceRangeAndExpandSelection(range: Range, newParts: BasePart[]) { 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: BasePart[]) { 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) && diff --git a/src/editor/parts.js b/src/editor/parts.ts similarity index 69% rename from src/editor/parts.js rename to src/editor/parts.ts index 0adc5573ea..f90308a202 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.ts @@ -15,27 +15,38 @@ 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 { +export interface ISerializedPart { + type: string; + text: string; +} + +export 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: BasePart) { 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 +54,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 +67,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 +81,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 +94,9 @@ class BasePart { return true; } - createAutoComplete() {} + createAutoComplete(updateCallback): AutocompleteWrapperModel | void {} - trim(len) { + trim(len: number) { const remaining = this._text.substr(len); this._text = this._text.substr(0, len); return remaining; @@ -95,6 +106,8 @@ class BasePart { return this._text; } + abstract get type(): string; + get canEdit() { return true; } @@ -103,14 +116,18 @@ class BasePart { return `${this.type}(${this.text})`; } - serialize() { + serialize(): ISerializedPart { return {type: this.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) { + acceptsInsertion(chr: string, offset: number, inputType: string) { if (chr === "\n") { return false; } @@ -137,28 +154,27 @@ export class PlainPart extends BasePart { 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) { +export abstract class PillPart extends BasePart { + 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 +187,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 +198,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 +206,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 +222,18 @@ class PillPart extends BasePart { get canEdit() { return false; } + + abstract get className(): string; + + abstract setAvatar(node: HTMLElement): void; } class NewlinePart extends BasePart { - acceptsInsertion(chr, offset) { + acceptsInsertion(chr: string, offset: number) { return offset === 0 && chr === "\n"; } - acceptsRemoval(position, chr) { + acceptsRemoval(position: number, chr: string) { return true; } @@ -227,7 +247,7 @@ class NewlinePart extends BasePart { updateDOMNode() {} - canUpdateDOMNode(node) { + canUpdateDOMNode(node: HTMLElement) { return node.tagName === "BR"; } @@ -245,21 +265,20 @@ 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); } @@ -280,19 +299,18 @@ class AtRoomPillPart extends RoomPillPart { } 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"); @@ -312,24 +330,23 @@ class UserPillPart extends PillPart { } serialize() { - const obj = super.serialize(); - obj.resourceId = this.resourceId; - return obj; + return { + ...super.serialize(), + resourceId: this.resourceId, + }; } } - class PillCandidatePart extends PlainPart { - constructor(text, autoCompleteCreator) { + constructor(text: string, private autoCompleteCreator: IAutocompleteCreator) { super(text); - this._autoCompleteCreator = autoCompleteCreator; } - createAutoComplete(updateCallback) { - return this._autoCompleteCreator.create(updateCallback); + createAutoComplete(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,7 +358,7 @@ class PillCandidatePart extends PlainPart { return false; } - acceptsRemoval(position, chr) { + acceptsRemoval(position: number, chr: string) { return true; } @@ -350,9 +367,9 @@ class PillCandidatePart extends PlainPart { } } -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 +380,26 @@ export function autoCompleteCreator(getAutocompleterComponent, updateQuery) { }; } +type AutoCompleteCreator = ReturnType; + +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): BasePart { switch (input[0]) { case "#": case "@": @@ -389,11 +412,11 @@ export class PartCreator { } } - createDefaultPart(text) { + createDefaultPart(text: string) { return this.plain(text); } - deserializePart(part) { + deserializePart(part: ISerializedPart) { switch (part.type) { case "plain": return this.plain(part.text); @@ -406,11 +429,11 @@ export class PartCreator { case "room-pill": return this.roomPill(part.text); case "user-pill": - return this.userPill(part.text, part.resourceId); + return this.userPill(part.text, (part as PillPart).resourceId); } } - plain(text) { + plain(text: string) { return new PlainPart(text); } @@ -418,16 +441,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 +458,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 +477,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 +487,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: BasePart) { if (part.type === "command") { return this.command(part.text); } else { diff --git a/src/editor/position.js b/src/editor/position.ts similarity index 79% rename from src/editor/position.js rename to src/editor/position.ts index 726377ef48..9c12fff778 100644 --- a/src/editor/position.js +++ b/src/editor/position.ts @@ -15,30 +15,30 @@ limitations under the License. */ import DocumentOffset from "./offset"; +import EditorModel from "./model"; +import {BasePart} 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: BasePart, startIdx: number, endIdx: number) => void; +type Predicate = (index: number, offset: number, part: BasePart) => 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; } diff --git a/src/editor/range.js b/src/editor/range.ts similarity index 71% rename from src/editor/range.js rename to src/editor/range.ts index 822c3b13a7..456509a855 100644 --- a/src/editor/range.js +++ b/src/editor/range.ts @@ -14,32 +14,33 @@ See the License for the specific language governing permissions and limitations under the License. */ +import EditorModel from "./model"; +import DocumentPosition from "./position"; + 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, () => { + 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; + 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; }); @@ -55,10 +56,10 @@ export default class Range { replace(parts) { 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 +69,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 +80,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; diff --git a/src/editor/render.js b/src/editor/render.ts similarity index 84% rename from src/editor/render.js rename to src/editor/render.ts index 84e57c2a3f..a60fb19730 100644 --- a/src/editor/render.js +++ b/src/editor/render.ts @@ -15,16 +15,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -export function needsCaretNodeBefore(part, prevPart) { +import {BasePart} from "./parts"; +import EditorModel from "./model"; +import {instanceOf} from "prop-types"; + +export function needsCaretNodeBefore(part: BasePart, prevPart: BasePart) { const isFirst = !prevPart || prevPart.type === "newline"; return !part.canEdit && (isFirst || !prevPart.canEdit); } -export function needsCaretNodeAfter(part, isLastOfLine) { +export function needsCaretNodeAfter(part: BasePart, 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 +52,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 +75,7 @@ function removeNextSiblings(node) { } } -function removeChildren(parent) { +function removeChildren(parent: HTMLElement) { const firstChild = parent.firstChild; if (firstChild) { removeNextSiblings(firstChild); @@ -79,7 +83,7 @@ function removeChildren(parent) { } } -function reconcileLine(lineContainer, parts) { +function reconcileLine(lineContainer: ChildNode, parts: BasePart[]) { let currentNode; let prevPart; const lastPart = parts[parts.length - 1]; @@ -146,23 +150,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"); diff --git a/src/editor/serialize.ts b/src/editor/serialize.ts index 7e8f4a3bfc..8ee726e8a1 100644 --- a/src/editor/serialize.ts +++ b/src/editor/serialize.ts @@ -18,6 +18,7 @@ limitations under the License. import Markdown from '../Markdown'; import {makeGenericPermalink} from "../utils/permalinks/Permalinks"; import EditorModel from "./model"; +import {PillPart} from "./parts"; export function mdSerialize(model: EditorModel) { return model.parts.reduce((html, part) => { @@ -31,7 +32,7 @@ export function mdSerialize(model: EditorModel) { return html + part.text; case "room-pill": case "user-pill": - return html + `[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`; + return html + `[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink((part as PillPart).resourceId)})`; } }, ""); }