Distributed Concurrent Editor

Artifact [80c0ee2295]
Login

Artifact 80c0ee2295251d32bbda2f6d3f37733affc07828c7f350ea5057c081ebdbafe3:


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);
    }
}