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:
parent
7507d0d7e1
commit
1330b438d6
2 changed files with 131 additions and 22 deletions
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue