Skip to content

Instantly share code, notes, and snippets.

@bwindels
Last active May 6, 2019 13:46
Show Gist options
  • Save bwindels/1db725d39631df81f105b8df0b6a7c31 to your computer and use it in GitHub Desktop.
Save bwindels/1db725d39631df81f105b8df0b6a7c31 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style type="text/css">
#editor {
border: 1px solid black;
min-height: 20px;
white-space: pre;
}
#editor span {
display: inline-block;
padding: 0 5px;
border-radius: 4px;
color: white;
}
#editor span.user-pill {
background: red;
}
#editor span.room-pill {
background: green;
}
#model {
background: lightgrey;
padding: 5px;
display: block;
white-space: pre;
}
</style>
</head>
<body>
<p>Try typing @ or # in the editor below:</p>
<p id="editor" contenteditable spellcheck="true"></p>
<p>editor model</p>
<code id="model"></code>
<script type="text/javascript">
class DocumentPosition {
constructor(index, offset) {
this._index = index;
this._offset = offset;
}
static atPartEnd(parts, partIndex) {
const part = parts[partIndex];
if (part) {
return new DocumentPosition(partIndex, part.text.length);
} else {
if (partIndex >= parts.length) {
const lastIndex = parts.length - 1;
return new DocumentPosition(lastIndex, parts[lastIndex].text.length);
} else {
return new DocumentPosition(0, 0);
}
}
}
adjustToPartRemoval(index) {
if (this._index >= index && this._index > 0) {
--this._index;
}
}
adjustToPartMerge(index, firstPartPreviousLen) {
if (index === this._index) {
this._offset += firstPartPreviousLen;
}
this.adjustToPartRemoval(index);
}
get index() {
return this._index;
}
get offset() {
return this._offset;
}
}
function firstDiff(a, b) {
const compareLen = Math.min(a.length, b.length);
for (let i = 0; i < compareLen; ++i) {
if (a[i] !== b[i]) {
return i;
}
}
return compareLen;
}
function lastDiff(a, b) {
const compareLen = Math.min(a.length, b.length);
for (let i = 0; i < compareLen; ++i) {
if (a[a.length - i] !== b[b.length - i]) {
return i;
}
}
return compareLen;
}
function diffStringsAtEnd(oldStr, newStr) {
const len = Math.min(oldStr.length, newStr.length);
const startInCommon = oldStr.substr(0, len) === newStr.substr(0, len);
if (startInCommon && oldStr.length > newStr.length) {
return {removed: oldStr.substr(len), at: len};
} else if (startInCommon && oldStr.length < newStr.length) {
return {added: newStr.substr(len), at: len};
} else {
const commonStartLen = firstDiff(oldStr, newStr);
return {
removed: oldStr.substr(commonStartLen),
added: newStr.substr(commonStartLen),
at: commonStartLen,
};
}
}
function diffDeletion(oldStr, newStr) {
if (oldStr === newStr) {
return {};
}
const firstDiffIdx = firstDiff(oldStr, newStr);
const lastDiffIdx = oldStr.length - lastDiff(oldStr, newStr) + 1;
return {at: firstDiffIdx, removed: oldStr.substring(firstDiffIdx, lastDiffIdx)};
}
function diffInsertion(oldStr, newStr) {
const diff = diffDeletion(newStr, oldStr);
if (diff.removed) {
return {at: diff.at, added: diff.removed};
} else {
return diff;
}
}
function diffAtCaret(oldValue, newValue, caretPosition) {
const diffLen = newValue.length - oldValue.length;
const caretPositionBeforeInput = caretPosition - diffLen;
const oldValueBeforeCaret = oldValue.substr(0, caretPositionBeforeInput);
const newValueBeforeCaret = newValue.substr(0, caretPosition);
return diffStringsAtEnd(oldValueBeforeCaret, newValueBeforeCaret);
}
class BasePart {
constructor(text = "") {
this._text = text;
}
acceptsInsertion(chr) {
return true;
}
acceptsRemoval(position, chr) {
return true;
}
merge(part) {
return false;
}
split(offset) {
const splitText = this.text.substr(offset);
this._text = this.text.substr(0, offset);
return new PlainPart(splitText);
}
// 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) {
const chr = this.text.charAt(i);
if (!this.acceptsRemoval(i, chr)) {
return strWithRemoval;
}
}
this._text = strWithRemoval;
}
// append str, returns the remaining string if a character was rejected.
appendUntilRejected(str) {
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);
return str.substr(i);
}
}
this._text = this._text + str;
}
// 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) {
const chr = str.charAt(i);
if (!this.acceptsInsertion(chr)) {
return false;
}
}
const beforeInsert = this._text.substr(0, offset);
const afterInsert = this._text.substr(offset);
this._text = beforeInsert + str + afterInsert;
return true;
}
trim(len) {
const remaining = this._text.substr(len);
this._text = this._text.substr(0, len);
return remaining;
}
get text() {
return this._text;
}
}
class PlainPart extends BasePart {
acceptsInsertion(chr) {
return chr !== "@" && chr !== "#";
}
toDOMNode() {
return document.createTextNode(this.text);
}
merge(part) {
if (part.type === this.type) {
this._text = this.text + part.text;
return true;
}
return false;
}
get type() {
return "plain";
}
updateDOMNode(node) {
if (node.textContent !== this.text) {
// console.log("changing plain text from", node.textContent, "to", this.text);
node.textContent = this.text;
}
}
canUpdateDOMNode(node) {
return node.nodeType === Node.TEXT_NODE;
}
}
class PillPart extends BasePart {
acceptsInsertion(chr) {
return chr !== " ";
}
acceptsRemoval(position, chr) {
return position !== 0; //if you remove initial # or @, pill should become plain
}
toDOMNode() {
const container = document.createElement("span");
container.className = this.type;
container.appendChild(document.createTextNode(this.text));
return container;
}
updateDOMNode(node) {
const textNode = node.childNodes[0];
if (textNode.textContent !== this.text) {
// console.log("changing pill text from", textNode.textContent, "to", this.text);
textNode.textContent = this.text;
}
if (node.className !== this.type) {
// console.log("turning", node.className, "into", this.type);
node.className = this.type;
}
}
canUpdateDOMNode(node) {
return node.nodeType === Node.ELEMENT_NODE &&
node.nodeName === "SPAN" &&
node.childNodes.length === 1 &&
node.childNodes[0].nodeType === Node.TEXT_NODE;
}
}
class RoomPillPart extends PillPart {
get type() {
return "room-pill";
}
}
class UserPillPart extends PillPart {
get type() {
return "user-pill";
}
}
class Actions {
constructor() {
this._actions = [];
}
change(index) {
this._actions.push({action: "change", index});
}
insert(index) {
this._actions.push({action: "insert", index});
}
remove(index) {
this._actions.push({action: "remove", index});
}
replace(index) {
this.remove(index);
this.insert(index);
}
_findNextIndex(current = -1) {
const idx = this._actions.reduce((idx, a) => {
if (a.index > current) {
if (idx === current) {
return a.index;
} else {
return Math.min(idx, a.index);
}
}
return idx;
}, current);
if (idx !== current) {
return idx;
}
}
replay(callback) {
let index = this._findNextIndex();
while (index !== undefined) {
let insertAction;
let changeAction;
for(let a of this._actions) {
if (a.index === index) {
switch (a.action) {
case "insert":
insertAction = a;
break;
case "remove":
// don't emit anything if this was just inserted
if (!insertAction) {
callback(a);
}
insertAction = null;
changeAction = null;
break;
case "change":
changeAction = a;
break;
}
}
}
if (insertAction) {
callback(insertAction);
} else if (changeAction) {
callback(changeAction);
}
index = this._findNextIndex(index);
}
}
}
class PartCollection {
constructor(parts = []) {
this._parts = parts;
this.actions = null;
}
insert(index, part) {
this._parts.splice(index, 0, part);
if (this.actions) {
this.actions.insert(index);
}
}
change(index) {
if (this.actions) {
this.actions.change(index);
}
}
remove(index) {
this._parts.splice(index, 1);
if (this.actions) {
this.actions.remove(index);
}
}
replace(index, part) {
this._parts.splice(index, 1, part);
if (this.actions) {
this.actions.replace(index);
}
}
get parts() {
return this._parts;
}
}
const editor = document.getElementById("editor");
const modelOutput = document.getElementById("model");
let model = new PartCollection([
new PlainPart("hello "),
new UserPillPart("@user"),
new PlainPart(", welcome to "),
new RoomPillPart("#theroom"),
new PlainPart("!"),
]);
rerenderModel();
renderModelOutput();
let previousValue = editor.textContent;
editor.addEventListener("input", event => {
const newValue = editor.textContent;
const caret = getCaretPosition();
let diff;
if (event.inputType === "deleteByDrag") {
diff = diffDeletion(previousValue, newValue);
} /* else if (event.inputType === "insertFromDrop") {
diff = diffInsertion(previousValue, newValue);
} */ else {
diff = diffAtCaret(previousValue, newValue, caret.position);
}
console.log("input", event.inputType, {previousValue, newValue, caret, diff});
const caretPosition = updateModel(diff, caret);
const shouldRerender = event.inputType === "insertFromDrop" || event.inputType === "insertFromPaste";
if (shouldRerender) {
rerenderModel(caretPosition);
} else {
renderModel(caretPosition);
}
previousValue = editor.textContent;
renderModelOutput();
});
function renderModelOutput() {
const str = JSON.stringify(model.parts.map(p => {return {type: p.type, text: p.text}}), undefined, 2);
console.log(str);
modelOutput.textContent = str;
}
function updateModel(diff, caret) {
// part modification log to update UI with afterwards
// const actions = new Actions();
// model.actions = actions;
const caretPos = offsetToDocumentPosition(diff.at, caret.atNodeEnd);
console.log("update at", {caretPos, diff});
if (diff.removed) {
removeFromModel(caretPos, diff.removed.length);
}
if (diff.added) {
addToModel(caretPos, diff.added);
}
mergeAdjacentParts();
let caretOffset = diff.at + (diff.added ? diff.added.length : 0);
return offsetToDocumentPosition(caretOffset, true);
// model.actions = null;
// return {actions, caretPosition: newCaretPos};
}
function mergeAdjacentParts(docPos) {
let prevPart = model.parts[0];
for (let i = 1; i < model.parts.length; ++i) {
let part = model.parts[i];
const prevLen = prevPart.text.length;
let isEmpty = !part.text.length;
let isMerged = !isEmpty && prevPart.merge(part);
if (isMerged) {
model.change(i - 1);
}
if (isEmpty || isMerged) {
// remove empty or merged part
part = prevPart;
model.remove(i);
//repeat this index, as it's removed now
--i;
}
prevPart = part;
}
}
function removeFromModel(pos, len) {
let {index, offset} = pos;
while(len !== 0) {
// part might be undefined here
let part = model.parts[index];
let amount = Math.min(len, part.text.length - offset);
let replaceWith = part.remove(offset, amount);
if (typeof replaceWith === "string") {
model.replace(index, new PlainPart(replaceWith));
}
part = model.parts[index];
// remove empty part
if (!part.text.length) {
model.remove(index);
} else {
index += 1;
}
len -= amount;
offset = 0;
}
}
function addToModel(pos, str, actions) {
let {index, offset} = pos;
let part = model.parts[index];
if (part) {
if (part.insertAll(offset, str)) {
str = null;
} else {
// console.log("splitting", offset, [part.text]);
const splitPart = part.split(offset);
// console.log("splitted", [part.text, splitPart.text]);
model.change(index);
index += 1;
model.insert(index, splitPart);
}
}
while (str) {
let newPart;
switch (str[0]) {
case "#":
newPart = new RoomPillPart();
break;
case "@":
newPart = new UserPillPart();
break;
default:
newPart = new PlainPart();
}
str = newPart.appendUntilRejected(str);
model.insert(index, newPart);
index += 1;
}
}
/*
why need atPartEnd?
take this editor model:
|------||--||--|
12
v
this is bold, ok
|--8---||-4||-4|
currentOffset = 8
partLen = 4
atPartEnd = true
atPartEnd = false
insert at end of 2nd or start of 3rd part?
*/
function offsetToDocumentPosition(totalOffset, atPartEnd) {
let firstPart;
let currentOffset = 0;
const index = model.parts.findIndex(part => {
const partLen = part.text.length;
if (
(atPartEnd && (currentOffset + partLen) >= totalOffset) ||
(!atPartEnd && (currentOffset + partLen) > totalOffset)
) {
return true;
}
currentOffset += partLen;
return false;
});
return new DocumentPosition(index, totalOffset - currentOffset);
}
function getCaretPosition() {
const sel = document.getSelection();
let atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length;
let position = sel.focusOffset;
let node = sel.focusNode;
// when deleting the last character of a node,
// the caret gets reported as being after the focusOffset-th node,
// with the focusNode being the editor
if (node === editor) {
let position = 0;
for(let i = 0; i < sel.focusOffset; ++i) {
position += editor.childNodes[i].textContent.length;
}
return {position, atNodeEnd: false};
}
// first make sure we're at the level of a direct child of editor
if (node.parentElement !== editor) {
// include all preceding siblings of the non-direct editor children
while(node.previousSibling) {
node = node.previousSibling;
position += node.textContent.length;
}
// then move up
// I guess technically there could be preceding text nodes in the parents here as well,
// but we're assuming there are no mixed text and element nodes
while(node.parentElement !== editor) {
node = node.parentElement;
}
}
// now include the text length of all preceding direct editor children
while(node.previousSibling) {
node = node.previousSibling;
position += node.textContent.length;
}
{
const {focusOffset, focusNode} = sel;
console.log("selection", {focusOffset, focusNode, position, atNodeEnd});
}
return {position, atNodeEnd};
}
function rerenderModel(caretPosition) {
while(editor.firstChild) {
editor.removeChild(editor.firstChild);
}
for (part of model.parts) {
editor.appendChild(part.toDOMNode());
}
setCaretPosition(caretPosition);
}
function renderModel(caretPosition) {
// remove unwanted nodes, like <br>s
for(let i = 0; i < model.parts.length; ++i) {
const part = model.parts[i];
let node = editor.childNodes[i];
while (node && !part.canUpdateDOMNode(node)) {
editor.removeChild(node);
node = editor.childNodes[i];
}
}
for(let i = 0; i < model.parts.length; ++i) {
const part = model.parts[i];
let node = editor.childNodes[i];
if (node && part) {
part.updateDOMNode(node);
} else if (part) {
editor.appendChild(part.toDOMNode());
} else if(node) {
editor.removeChild(node);
}
}
let surplusElementCount = Math.max(0, editor.childNodes.length - model.parts.length);
while(surplusElementCount) {
editor.removeChild(editor.lastChild);
--surplusElementCount;
}
setCaretPosition(caretPosition);
}
function setCaretPosition(caretPosition) {
if (caretPosition) {
let focusNode = editor.childNodes[caretPosition.index];
if (!focusNode) {
focusNode = editor;
} else {
// make sure we have a text node
if (focusNode.nodeType === Node.ELEMENT_NODE) {
focusNode = focusNode.childNodes[0];
}
}
const sel = document.getSelection();
sel.removeAllRanges();
const range = document.createRange();
range.setStart(focusNode, caretPosition.offset);
range.collapse(true);
sel.addRange(range);
}
}
// prevent newlines
/*
editor.addEventListener("keydown", e => {
if (e.key === "Enter") {
e.preventDefault();
}
});
*/
// prevent paste
// editor.addEventListener("paste", e => e.preventDefault());
/*
for pasting we can either get the text from the clipboardData, transform the model at the caret position,
and rerender the model to the editor.
or... we just allow pasting, and modify the model in the input event handler, and rerender somehow (because pasting is likely to screw up the markup if you paste in a span or so)
*/
// text/plain is at 1 in current test case
//editor.addEventListener("paste", e => e.clipboardData.items[1].getAsString(data => console.log("pasting", data)));
/*
TODO
- how to capture Enter to confirm autocomplete if we don't want to allow newlines (e.g. p or br) in the editor?
- we'll have to do it through keypress even though that is deprecated and browsers only still emit Enter as a keypress (as opposed to keydown) for compatibility reasons. keydown could also work for something like enter, but it really describes keys, not characters. Enter typically only is one key though.
*/
</script>
</body>
</html>
  • suppport displaying pills inline
    • find existing script that supports autocomplete
    • build it ourselves
      • editing pills
        • either make them contenteditable=false so you can only delete them. Might need hack to backspace as well as delete.
        • or make them editable as well so you can change/refine your autocomplete search
          • need to detect pills when moving cursor
            • pop up autocomplete when moving cursor into pill?
          • what does it mean to have a selection
      • prevent inserting random html?
        • intercept paste event and manually insert text/plain clipboard data at cursor position
        • any other way of inserting html? dropping a file?
      • how to detect input
        • keydown/press doesn't work well with IME, other input methods
          • keypress is only (supposed to be) for printable characters
          • keydown doesn't give character
          • probably fine for our pill starter chars though
        • input event, but FF doesn't have data property
          • use diffing of target.value to find out what changed between input events, prototyped in bwindels/simplecomposer branch
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment