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.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {PlainPart, RoomPillPart, UserPillPart} from "./parts";
|
|
||||||
import {diffAtCaret, diffDeletion} from "./diff";
|
import {diffAtCaret, diffDeletion} from "./diff";
|
||||||
|
|
||||||
export default class EditorModel {
|
export default class EditorModel {
|
||||||
constructor(parts = []) {
|
constructor(parts, partCreator) {
|
||||||
this._parts = parts;
|
this._parts = parts;
|
||||||
this.actions = null;
|
this._partCreator = partCreator;
|
||||||
this._previousValue = parts.reduce((text, p) => text + p.text, "");
|
this._previousValue = parts.reduce((text, p) => text + p.text, "");
|
||||||
|
this._activePartIdx = null;
|
||||||
|
this._autoComplete = null;
|
||||||
|
this._autoCompletePartIdx = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_insertPart(index, part) {
|
_insertPart(index, part) {
|
||||||
this._parts.splice(index, 0, part);
|
this._parts.splice(index, 0, part);
|
||||||
|
if (this._activePartIdx >= index) {
|
||||||
|
++this._activePartIdx;
|
||||||
|
}
|
||||||
|
if (this._autoCompletePartIdx >= index) {
|
||||||
|
++this._autoCompletePartIdx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_removePart(index) {
|
_removePart(index) {
|
||||||
this._parts.splice(index, 1);
|
this._parts.splice(index, 1);
|
||||||
|
if (this._activePartIdx >= index) {
|
||||||
|
--this._activePartIdx;
|
||||||
|
}
|
||||||
|
if (this._autoCompletePartIdx >= index) {
|
||||||
|
--this._autoCompletePartIdx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_replacePart(index, part) {
|
_replacePart(index, part) {
|
||||||
|
@ -40,11 +54,21 @@ export default class EditorModel {
|
||||||
return this._parts;
|
return this._parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get autoComplete() {
|
||||||
|
if (this._activePartIdx === this._autoCompletePartIdx) {
|
||||||
|
return this._autoComplete;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
serializeParts() {
|
serializeParts() {
|
||||||
return this._parts.map(({type, text}) => {return {type, text};});
|
return this._parts.map(({type, text}) => {return {type, text};});
|
||||||
}
|
}
|
||||||
|
|
||||||
_diff(newValue, inputType, caret) {
|
_diff(newValue, inputType, caret) {
|
||||||
|
// handle deleteContentForward (Delete key)
|
||||||
|
// and deleteContentBackward (Backspace)
|
||||||
|
|
||||||
// can't use caret position with drag and drop
|
// can't use caret position with drag and drop
|
||||||
if (inputType === "deleteByDrag") {
|
if (inputType === "deleteByDrag") {
|
||||||
return diffDeletion(this._previousValue, newValue);
|
return diffDeletion(this._previousValue, newValue);
|
||||||
|
@ -66,9 +90,38 @@ export default class EditorModel {
|
||||||
this._mergeAdjacentParts();
|
this._mergeAdjacentParts();
|
||||||
this._previousValue = newValue;
|
this._previousValue = newValue;
|
||||||
const caretOffset = diff.at + (diff.added ? diff.added.length : 0);
|
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) {
|
_mergeAdjacentParts(docPos) {
|
||||||
let prevPart = this._parts[0];
|
let prevPart = this._parts[0];
|
||||||
for (let i = 1; i < this._parts.length; ++i) {
|
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 amount = Math.min(len, part.text.length - offset);
|
||||||
const replaceWith = part.remove(offset, amount);
|
const replaceWith = part.remove(offset, amount);
|
||||||
if (typeof replaceWith === "string") {
|
if (typeof replaceWith === "string") {
|
||||||
this._replacePart(index, new PlainPart(replaceWith));
|
this._replacePart(index, this._partCreator.createDefaultPart(replaceWith));
|
||||||
}
|
}
|
||||||
part = this._parts[index];
|
part = this._parts[index];
|
||||||
// remove empty part
|
// remove empty part
|
||||||
|
@ -123,17 +176,7 @@ export default class EditorModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while (str) {
|
while (str) {
|
||||||
let newPart;
|
const newPart = this._partCreator.createPartForInput(str);
|
||||||
switch (str[0]) {
|
|
||||||
case "#":
|
|
||||||
newPart = new RoomPillPart();
|
|
||||||
break;
|
|
||||||
case "@":
|
|
||||||
newPart = new UserPillPart();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
newPart = new PlainPart();
|
|
||||||
}
|
|
||||||
str = newPart.appendUntilRejected(str);
|
str = newPart.appendUntilRejected(str);
|
||||||
this._insertPart(index, newPart);
|
this._insertPart(index, newPart);
|
||||||
index += 1;
|
index += 1;
|
||||||
|
@ -156,6 +199,14 @@ export default class EditorModel {
|
||||||
|
|
||||||
return new DocumentPosition(index, totalOffset - currentOffset);
|
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 {
|
class DocumentPosition {
|
||||||
|
|
|
@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import AutocompleteWrapperModel from "./autocomplete";
|
||||||
|
|
||||||
class BasePart {
|
class BasePart {
|
||||||
constructor(text = "") {
|
constructor(text = "") {
|
||||||
this._text = text;
|
this._text = text;
|
||||||
|
@ -39,12 +41,10 @@ class BasePart {
|
||||||
|
|
||||||
// removes len chars, or returns the plain text this part should be replaced with
|
// removes len chars, or returns the plain text this part should be replaced with
|
||||||
// if the part would become invalid if it removed everything.
|
// 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) {
|
remove(offset, len) {
|
||||||
// validate
|
// validate
|
||||||
const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len);
|
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);
|
const chr = this.text.charAt(i);
|
||||||
if (!this.acceptsRemoval(i, chr)) {
|
if (!this.acceptsRemoval(i, chr)) {
|
||||||
return strWithRemoval;
|
return strWithRemoval;
|
||||||
|
@ -55,7 +55,7 @@ class BasePart {
|
||||||
|
|
||||||
// append str, returns the remaining string if a character was rejected.
|
// append str, returns the remaining string if a character was rejected.
|
||||||
appendUntilRejected(str) {
|
appendUntilRejected(str) {
|
||||||
for(let i = 0; i < str.length; ++i) {
|
for (let i = 0; i < str.length; ++i) {
|
||||||
const chr = str.charAt(i);
|
const chr = str.charAt(i);
|
||||||
if (!this.acceptsInsertion(chr)) {
|
if (!this.acceptsInsertion(chr)) {
|
||||||
this._text = this._text + str.substr(0, i);
|
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
|
// 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.
|
// return whether the str was accepted or not.
|
||||||
insertAll(offset, str) {
|
insertAll(offset, str) {
|
||||||
for(let i = 0; i < str.length; ++i) {
|
for (let i = 0; i < str.length; ++i) {
|
||||||
const chr = str.charAt(i);
|
const chr = str.charAt(i);
|
||||||
if (!this.acceptsInsertion(chr)) {
|
if (!this.acceptsInsertion(chr)) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -80,6 +80,7 @@ class BasePart {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createAutoComplete() {}
|
||||||
|
|
||||||
trim(len) {
|
trim(len) {
|
||||||
const remaining = this._text.substr(len);
|
const remaining = this._text.substr(len);
|
||||||
|
@ -94,7 +95,7 @@ class BasePart {
|
||||||
|
|
||||||
export class PlainPart extends BasePart {
|
export class PlainPart extends BasePart {
|
||||||
acceptsInsertion(chr) {
|
acceptsInsertion(chr) {
|
||||||
return chr !== "@" && chr !== "#";
|
return chr !== "@" && chr !== "#" && chr !== ":";
|
||||||
}
|
}
|
||||||
|
|
||||||
toDOMNode() {
|
toDOMNode() {
|
||||||
|
@ -126,6 +127,11 @@ export class PlainPart extends BasePart {
|
||||||
}
|
}
|
||||||
|
|
||||||
class PillPart extends BasePart {
|
class PillPart extends BasePart {
|
||||||
|
constructor(resourceId, label) {
|
||||||
|
super(label);
|
||||||
|
this.resourceId = resourceId;
|
||||||
|
}
|
||||||
|
|
||||||
acceptsInsertion(chr) {
|
acceptsInsertion(chr) {
|
||||||
return chr !== " ";
|
return chr !== " ";
|
||||||
}
|
}
|
||||||
|
@ -162,6 +168,10 @@ class PillPart extends BasePart {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RoomPillPart extends PillPart {
|
export class RoomPillPart extends PillPart {
|
||||||
|
constructor(displayAlias) {
|
||||||
|
super(displayAlias, displayAlias);
|
||||||
|
}
|
||||||
|
|
||||||
get type() {
|
get type() {
|
||||||
return "room-pill";
|
return "room-pill";
|
||||||
}
|
}
|
||||||
|
@ -172,3 +182,51 @@ export class UserPillPart extends PillPart {
|
||||||
return "user-pill";
|
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