initial support for auto complete in model and parts

also move part creation out of model, into partcreator, which
can then also contain dependencies for creating the auto completer.
pull/21833/head
Bruno Windels 2019-05-09 14:59:52 +02:00
parent 7507d0d7e1
commit 1330b438d6
2 changed files with 131 additions and 22 deletions

View File

@ -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 {

View File

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