diff --git a/src/editor/model.js b/src/editor/model.js index e4170d88dd..2bf78026b3 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -14,22 +14,36 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {PlainPart, RoomPillPart, UserPillPart} from "./parts"; import {diffAtCaret, diffDeletion} from "./diff"; export default class EditorModel { - constructor(parts = []) { + constructor(parts, partCreator) { this._parts = parts; - this.actions = null; + this._partCreator = partCreator; this._previousValue = parts.reduce((text, p) => text + p.text, ""); + this._activePartIdx = null; + this._autoComplete = null; + this._autoCompletePartIdx = null; } _insertPart(index, part) { this._parts.splice(index, 0, part); + if (this._activePartIdx >= index) { + ++this._activePartIdx; + } + if (this._autoCompletePartIdx >= index) { + ++this._autoCompletePartIdx; + } } _removePart(index) { this._parts.splice(index, 1); + if (this._activePartIdx >= index) { + --this._activePartIdx; + } + if (this._autoCompletePartIdx >= index) { + --this._autoCompletePartIdx; + } } _replacePart(index, part) { @@ -40,11 +54,21 @@ export default class EditorModel { return this._parts; } + get autoComplete() { + if (this._activePartIdx === this._autoCompletePartIdx) { + return this._autoComplete; + } + return null; + } + serializeParts() { return this._parts.map(({type, text}) => {return {type, text};}); } _diff(newValue, inputType, caret) { + // handle deleteContentForward (Delete key) + // and deleteContentBackward (Backspace) + // can't use caret position with drag and drop if (inputType === "deleteByDrag") { return diffDeletion(this._previousValue, newValue); @@ -66,9 +90,38 @@ export default class EditorModel { this._mergeAdjacentParts(); this._previousValue = newValue; const caretOffset = diff.at + (diff.added ? diff.added.length : 0); - return this._positionForOffset(caretOffset, true); + const newPosition = this._positionForOffset(caretOffset, true); + this._setActivePart(newPosition); + return newPosition; } + _setActivePart(pos) { + const {index} = pos; + const part = this._parts[index]; + if (pos.index !== this._activePartIdx) { + this._activePartIdx = index; + // if there is a hidden autocomplete for this part, show it again + if (this._activePartIdx !== this._autoCompletePartIdx) { + // else try to create one + 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; + } + } + } + if (this._autoComplete) { + this._autoComplete.onPartUpdate(part, pos.offset); + } + } + + /* + updateCaret(caret) { + // update active part here as well, hiding/showing autocomplete if needed + } + */ + _mergeAdjacentParts(docPos) { let prevPart = this._parts[0]; for (let i = 1; i < this._parts.length; ++i) { @@ -94,7 +147,7 @@ export default class EditorModel { const amount = Math.min(len, part.text.length - offset); const replaceWith = part.remove(offset, amount); if (typeof replaceWith === "string") { - this._replacePart(index, new PlainPart(replaceWith)); + this._replacePart(index, this._partCreator.createDefaultPart(replaceWith)); } part = this._parts[index]; // remove empty part @@ -123,17 +176,7 @@ export default class EditorModel { } } while (str) { - let newPart; - switch (str[0]) { - case "#": - newPart = new RoomPillPart(); - break; - case "@": - newPart = new UserPillPart(); - break; - default: - newPart = new PlainPart(); - } + const newPart = this._partCreator.createPartForInput(str); str = newPart.appendUntilRejected(str); this._insertPart(index, newPart); index += 1; @@ -156,6 +199,14 @@ export default class EditorModel { return new DocumentPosition(index, totalOffset - currentOffset); } + + _onAutoComplete = ({replacePart, replaceCaret, close}) => { + this._replacePart(this._autoCompletePartIdx, replacePart); + if (close) { + this._autoComplete = null; + this._autoCompletePartIdx = null; + } + } } class DocumentPosition { diff --git a/src/editor/parts.js b/src/editor/parts.js index be5326d98f..e060be716e 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import AutocompleteWrapperModel from "./autocomplete"; + class BasePart { constructor(text = "") { this._text = text; @@ -39,12 +41,10 @@ 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. - - // TODO: this should probably return the Part and caret position within this should be replaced with remove(offset, len) { // validate const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len); - for(let i = offset; i < (len + offset); ++i) { + for (let i = offset; i < (len + offset); ++i) { const chr = this.text.charAt(i); if (!this.acceptsRemoval(i, chr)) { return strWithRemoval; @@ -55,7 +55,7 @@ class BasePart { // append str, returns the remaining string if a character was rejected. appendUntilRejected(str) { - for(let i = 0; i < str.length; ++i) { + for (let i = 0; i < str.length; ++i) { const chr = str.charAt(i); if (!this.acceptsInsertion(chr)) { this._text = this._text + str.substr(0, i); @@ -68,7 +68,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. insertAll(offset, str) { - for(let i = 0; i < str.length; ++i) { + for (let i = 0; i < str.length; ++i) { const chr = str.charAt(i); if (!this.acceptsInsertion(chr)) { return false; @@ -80,6 +80,7 @@ class BasePart { return true; } + createAutoComplete() {} trim(len) { const remaining = this._text.substr(len); @@ -94,7 +95,7 @@ class BasePart { export class PlainPart extends BasePart { acceptsInsertion(chr) { - return chr !== "@" && chr !== "#"; + return chr !== "@" && chr !== "#" && chr !== ":"; } toDOMNode() { @@ -126,6 +127,11 @@ export class PlainPart extends BasePart { } class PillPart extends BasePart { + constructor(resourceId, label) { + super(label); + this.resourceId = resourceId; + } + acceptsInsertion(chr) { return chr !== " "; } @@ -162,6 +168,10 @@ class PillPart extends BasePart { } export class RoomPillPart extends PillPart { + constructor(displayAlias) { + super(displayAlias, displayAlias); + } + get type() { return "room-pill"; } @@ -172,3 +182,51 @@ export class UserPillPart extends PillPart { return "user-pill"; } } + + +export class PillCandidatePart extends PlainPart { + constructor(text, autoCompleteCreator) { + super(text); + this._autoCompleteCreator = autoCompleteCreator; + } + + createAutoComplete(updateCallback) { + return this._autoCompleteCreator(updateCallback); + } + + acceptsInsertion(chr) { + return true; + } + + acceptsRemoval(position, chr) { + return true; + } + + get type() { + return "pill-candidate"; + } +} + +export class PartCreator { + constructor(getAutocompleterComponent, updateQuery) { + this._autoCompleteCreator = (updateCallback) => { + return new AutocompleteWrapperModel(updateCallback, getAutocompleterComponent, updateQuery); + }; + } + + createPartForInput(input) { + switch (input[0]) { + case "#": + case "@": + case ":": + return new PillCandidatePart("", this._autoCompleteCreator); + default: + return new PlainPart(); + } + } + + createDefaultPart(text) { + return new PlainPart(text); + } +} +