import * as edist from './protocol';
import * as websocket from './websocket';
export type WordIdString = string;
function wordIdToStr(wordId: edist.IWordId): WordIdString {
return ('' + wordId.clientId).padStart(10, '0')
+ ':'
+ ('' + wordId.counter).padStart(10, '0')
+ ':nid';
}
function versionBump(clientId: bigint, version: edist.IVersion): void {
version.counter += 1n;
version.clientId = clientId;
}
function versionEqual(a: edist.IVersion, b: edist.IVersion): boolean {
return a.clientId === b.clientId && a.counter === b.counter;
}
function versionIsGreaterThan(a: edist.IVersion, b: edist.IVersion): boolean {
return a.counter > b.counter || (a.counter === b.counter && a.clientId > b.clientId);
}
export class Document {
readonly clientId: bigint;
readonly websocketManager: websocket.WebSocketManager;
readonly root: Word;
readonly words: Map<WordIdString, Word>;
private counter: bigint = 0n;
private isDirty: boolean = false;
constructor(clientId: bigint, websocketManager: websocket.WebSocketManager) {
this.clientId = clientId;
this.websocketManager = websocketManager;
this.words = new Map();
this.root = new Word(this);
}
nextWordId = (word: Word): edist.IWordId => {
this.counter++;
if (this.root === undefined && this.counter === 1n) {
// if this.root is undefined then it means we're in the
// constructor. We need the root word to have a globally
// fixed WordId.
return { clientId: 0n, counter: this.counter };
} else {
return { clientId: this.clientId, counter: this.counter };
}
}
setDirty = (): void => {
if (this.isDirty) {
return;
}
this.isDirty = true;
setTimeout(this.flush, 100);
}
flush = (): void => {
const words = this.dirty();
if (words.length === 0) {
return;
}
this.websocketManager.send(edist.Message.encode({ updates: words }));
this.gc();
}
markAllDirty = (): void => {
for (let word of this.words.values()) {
word.isDirty = true;
}
this.setDirty();
}
dirty = (): Array<edist.IWord> => {
const words: Array<edist.IWord> = [];
for (let word of this.words.values()) {
if (word.isDirty) {
words.push(word.serialize());
word.isDirty = false;
}
}
this.isDirty = false;
return words;
}
gc = (): void => {
const reachable = new Set<WordIdString>();
const worklist: Word[] = [this.root];
for (let cur = worklist.shift(); cur; cur = worklist.shift()) {
reachable.add(wordIdToStr(cur.wordId));
if (cur.next) {
worklist.push(cur.next);
}
}
this.words.forEach((word, wordIdStr) => {
if (!reachable.has(wordIdStr)) {
this.words.delete(wordIdStr);
}
});
}
applyUpdates = (updates: Array<edist.IWord>): void => {
const filteredUpdates: Array<[edist.IWord, Word]> = [];
updates.forEach((iword) => {
const wordIdStr = wordIdToStr(iword.wordId);
const word = this.words.get(wordIdStr);
if (word === undefined) {
filteredUpdates.push([iword, new Word(this, iword)]);
} else if (versionIsGreaterThan(iword.version, word.version)) {
filteredUpdates.push([iword, word]);
}
});
filteredUpdates.forEach(([iword, word]: [edist.IWord, Word]) => {
word.version = iword.version;
word.letters = iword.letters;
if (iword.links.previous) {
word.previous = this.words.get(wordIdToStr(iword.links.previous));
} else {
word.previous = undefined;
}
if (iword.links.next) {
word.next = this.words.get(wordIdToStr(iword.links.next));
} else {
word.next = undefined;
}
});
}
render = (container: HTMLDivElement): void => {
const spans = new Array(...container.querySelectorAll('span'));
const spansMap = new Map<string, HTMLSpanElement>();
spans.forEach((span) => {
spansMap.set(span.id, span);
});
const children: Node[] = [];
const selection = window.getSelection();
const selectionValid = selection !== null && selection.isCollapsed;
const anchorOffset = selectionValid ? selection.anchorOffset : 0;
let focus: [HTMLSpanElement, Word] | undefined = undefined;
for (let cur: Word | undefined = this.root; cur !== undefined; cur = cur.next) {
const wordIdStr = wordIdToStr(cur.wordId);
let span = spansMap.get(wordIdStr);
if (span === undefined) {
span = this.makeSpan(wordIdStr, cur.letters);
}
if (selectionValid && (selection.anchorNode?.parentNode === span || selection.anchorNode === span)) {
focus = [span, cur];
}
if (span.textContent !== cur.letters) {
span.replaceChildren(document.createTextNode(cur.letters));
}
children.push(span, document.createTextNode("\u00A0"));
}
container.replaceChildren(...children);
if (selectionValid && focus !== undefined) {
const [span, word] = focus;
span.addEventListener('focus', (event: FocusEvent): void => {
const offset = anchorOffset > word.letters.length ? word.letters.length : anchorOffset;
selection.setBaseAndExtent(span.firstChild!, offset, span.firstChild!, offset);
}, { once: true });
span.focus();
}
}
private makeSpan = (wordIdStr: string, letters: string): HTMLSpanElement => {
const span = document.createElement("span");
span.id = wordIdStr;
span.addEventListener('input', this.spanInput);
span.addEventListener('keydown', this.spanKeydown);
span.contentEditable = "true";
span.spellcheck = false;
span.replaceChildren(document.createTextNode(letters));
return span;
}
private spanInput = (event: Event): void => {
const evt = event as InputEvent;
const span = evt.target as HTMLSpanElement;
const word = this.words.get(span.id);
const selection = window.getSelection();
if (word) {
word.letters = span.textContent || "";
word.bump();
if (word.letters === "" && span.children.length === 0 && selection !== null) {
span.replaceChildren(document.createTextNode(""));
span.addEventListener('focus', (event: FocusEvent): void => {
selection.setBaseAndExtent(span.firstChild!, 0, span.firstChild!, 0);
}, { once: true });
span.focus();
}
}
}
private spanKeydown = (event: KeyboardEvent): void => {
const span = event.target as HTMLSpanElement;
let handled = false;
if (event.key === 'z' && (event.ctrlKey || event.metaKey) && !event.shiftKey) {
this.flush();
this.websocketManager.send(edist.Message.encode({ undo: true }));
handled = true;
} else if ((event.key === 'z' || event.key === 'Z') && (event.ctrlKey || event.metaKey) && event.shiftKey) {
this.flush();
this.websocketManager.send(edist.Message.encode({ redo: true }));
handled = true;
}
const selection = window.getSelection();
if (selection && selection.isCollapsed && (selection.anchorNode?.parentNode === span || selection.anchorNode === span) && selection.rangeCount === 1) {
const word = this.words.get(span.id);
if (word === undefined) {
return;
}
if (event.key === ' ' || event.key === 'Enter') {
this.splitWord(selection, span, word);
handled = true;
} else if (event.key === 'Backspace' && selection.anchorOffset === 0) {
const wordLeft = word.previous;
if (wordLeft === undefined) {
return;
}
const spanLeft = document.getElementById(wordIdToStr(wordLeft.wordId));
if (spanLeft === null) {
return;
}
this.mergeNodes(selection, spanLeft, span, wordLeft, word);
handled = true;
} else if (event.key === 'Delete' && ((selection.anchorNode === span && selection.anchorOffset === 1) || (selection.anchorNode?.parentNode === span && selection.anchorOffset === word.letters.length))) {
const wordRight = word.next;
if (wordRight === undefined) {
return;
}
const spanRight = document.getElementById(wordIdToStr(wordRight.wordId));
if (spanRight === null) {
return;
}
this.mergeNodes(selection, span, spanRight, word, wordRight);
handled = true;
} else if (event.key === 'ArrowLeft' && selection.anchorOffset === 0 && !event.ctrlKey && !event.metaKey) {
let previousSpan = span.previousSibling;
while (previousSpan && previousSpan.nodeName !== "SPAN") {
previousSpan = previousSpan.previousSibling;
}
if (!previousSpan) {
return;
}
selection.setBaseAndExtent(previousSpan.firstChild!, previousSpan.textContent!.length, previousSpan.firstChild!, previousSpan.textContent!.length);
handled = true;
} else if (event.key === 'ArrowRight' && selection.anchorOffset === span.innerText.length && !event.ctrlKey && !event.metaKey) {
let nextSpan = span.nextSibling;
while (nextSpan && nextSpan.nodeName !== "SPAN") {
nextSpan = nextSpan.nextSibling;
}
if (!nextSpan) {
return;
}
selection.setBaseAndExtent(nextSpan.firstChild!, 0, nextSpan.firstChild!, 0);
handled = true;
}
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}
private splitWord = (selection: Selection, span: HTMLSpanElement, word: Word) => {
const wordRight = new Word(this);
wordRight.next = word.next;
word.next = wordRight;
wordRight.previous = word;
const lettersLeft = word.letters.slice(0, selection.anchorOffset);
const lettersRight = word.letters.slice(selection.anchorOffset);
word.letters = lettersLeft;
word.bump();
wordRight.letters = lettersRight;
const spanRight = this.makeSpan(wordIdToStr(wordRight.wordId), lettersRight);
span.replaceChildren(document.createTextNode(lettersLeft));
span.after(document.createTextNode("\u00A0"), spanRight);
spanRight.addEventListener('focus', (event: FocusEvent): void => {
selection.setBaseAndExtent(spanRight.firstChild!, 0, spanRight.firstChild!, 0);
}, { once: true });
spanRight.focus();
}
private mergeNodes = (selection: Selection, spanLeft: HTMLSpanElement, spanRight: HTMLSpanElement, wordLeft: Word, wordRight: Word) => {
const targetOffset = wordLeft.letters.length;
wordLeft.letters = wordLeft.letters + wordRight.letters;
wordLeft.next = wordRight.next;
wordLeft.bump();
if (wordRight.next !== undefined) {
wordRight.next.previous = wordLeft;
wordRight.next.bump();
}
spanRight.nextSibling?.remove();
spanRight.remove();
spanLeft.replaceChildren(document.createTextNode(wordLeft.letters));
spanLeft.addEventListener('focus', (event: FocusEvent): void => {
selection.setBaseAndExtent(spanLeft.firstChild!, targetOffset, spanLeft.firstChild!, targetOffset);
}, { once: true });
spanLeft.focus();
}
}
export class Word {
readonly document: Document;
readonly wordId: edist.IWordId;
version: edist.IVersion;
letters: string = "";
previous?: Word;
next?: Word;
isDirty: boolean = false;
constructor(document: Document, iword?: edist.IWord) {
this.document = document;
if (iword) {
this.wordId = iword.wordId;
this.version = iword.version;
this.letters = iword.letters;
} else {
this.wordId = document.nextWordId(this);
this.version = { counter: 1n, clientId: document.clientId };
this.isDirty = true;
document.setDirty();
}
document.words.set(wordIdToStr(this.wordId), this);
}
serialize = (): edist.IWord => {
return {
wordId: this.wordId,
version: { counter: this.version.counter, clientId: this.version.clientId },
letters: this.letters,
links: {
previous: this.previous?.wordId,
next: this.next?.wordId,
},
};
}
bump = (): void => {
if (this.isDirty) {
return;
}
this.isDirty = true;
this.document.setDirty();
versionBump(this.document.clientId, this.version);
}
}