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
parent
7507d0d7e1
commit
1330b438d6
|
@ -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 New Issue