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.
This commit is contained in:
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);
}
}