forest/forest-web/node.js
Joscha 3f8057490f Commit unstaged changes
Coming back to this project after a while, these changes were still unstaged. In
order not to lose them I'm committing them here, even though I don't remember
what they're for. They might not even work properly.
2020-06-19 13:51:59 +00:00

684 lines
17 KiB
JavaScript

"use strict";
/*
* Utility functions
*/
function removeAllChildren(element) {
while (element.firstChild) {
element.removeChild(element.lastChild);
}
}
// Create a new DOM element.
// 'classes' can either be a string or a list of strings.
// A child can either be a string or a DOM element.
function newElement(type, classes, ...children) {
let e = document.createElement(type);
if (classes !== undefined) {
if (typeof classes == "string") {
e.classList.add(classes);
} else if (classes instanceof Array) {
e.classList.add(...classes);
}
}
children.forEach(child => {
if (typeof child == "string") {
e.appendChild(document.createTextNode(child));
} else {
e.appendChild(child);
}
});
return e;
}
/*
* Classes
*/
// Enum representing useful positions relative to a node.
const RelPos = Object.freeze({
FIRST_CHILD: 1,
NEXT_SIBLING: 2,
});
class Path {
constructor(...components) {
this._components = components.slice();
}
get components() {
return this._components.slice();
}
get length() {
return this._components.length;
}
get last() {
return this._components[this.length - 1];
}
get parent() {
if (this.length === 0) return undefined;
return new Path(...this._components.slice(0, this.length - 1));
}
append(nodeId) {
return new Path(...this._components.concat([nodeId]));
}
concat(otherPath) {
return new Path(...this._components.concat(otherPath._components));
}
}
class NodeElements {
constructor() {
this._elText = newElement("span", "node-text");
this._elPermissions = newElement("span", "node-permissions");
this._elChildren = newElement("div", "node-children");
let line = newElement("div", "node-line", this._elText, this._elPermissions);
this._elMain = newElement("div", ["node", "is-folded"], line, this._elChildren);
}
get text() {
return this._elText.textContent;
}
set text(text) {
this._elText.textContent = text;
}
set permissions(perms) {
this._elPermissions.textContent = perms.asText;
}
get hasChildren() {
return this._elMain.classList.contains("has-children");
}
set hasChildren(flag) {
return this._elMain.classList.toggle("has-children", flag);
}
removeAllChildren() {
removeAllChildren(this._elChildren);
}
addChild(child) {
this._elChildren.appendChild(child._elMain);
}
appendTo(element) {
element.appendChild(this._elMain);
}
get folded() {
return this._elMain.classList.contains("is-folded");
}
set folded(flag) {
this._elMain.classList.toggle("is-folded", flag);
}
toggleFolded() {
this.folded = !this.folded;
}
get hasCursor() {
return this._elMain.classList.contains("has-cursor");
}
set hasCursor(flag) {
return this._elMain.classList.toggle("has-cursor", flag);
}
get hasEditor() {
return this._elMain.classList.contains("has-editor");
}
set hasEditor(flag) {
return this._elMain.classList.toggle("has-editor", flag);
}
}
class NodePermissions {
constructor(edit, delete_, reply, act) {
this._edit = edit;
this._delete = delete_;
this._reply = reply;
this._act = act;
}
get edit() {
return this._edit;
}
get delete() {
return this._delete;
}
get reply() {
return this._reply;
}
get act() {
return this._act;
}
get asText() {
return [
"(",
this.edit ? "e" : "-",
this.delete ? "d" : "-",
this.reply ? "r" : "-",
this.act ? "a" : "-",
")"
].join("");
}
}
class Node {
constructor(nodeJson) {
this._el = undefined;
this._text = nodeJson.text;
this._permissions = new NodePermissions(
nodeJson.edit,
nodeJson.delete,
nodeJson.reply,
nodeJson.act,
);
this._children = new Map();
this._order = nodeJson.order;
this._order.forEach(childId => {
let childJson = nodeJson.children[childId];
let childNode = new Node(childJson);
this._children.set(childId, childNode);
});
}
child(childId) {
return this._children.get(childId);
}
get order() {
return this._order.slice();
}
// Only replaces existing children. Does not add new children.
replaceChild(childId, newChild) {
let oldChild = this.child(childId);
if (oldChild === undefined) return;
newChild.obtainElements(oldChild);
this._children.set(childId, newChild);
}
// Obtain and update this node's DOM elements. After this call, this.el
// represents the current node's contents.
//
// This function may optionally be called with an old node. If that node or
// its children already has existing DOM elements, they are repurposed.
// Otherwise, new DOM elements are created.
obtainElements(oldNode) {
if (this._el === undefined) {
// Obtain DOM elements because we don't yet have any
if (oldNode === undefined || oldNode._el === undefined) {
this._el = new NodeElements();
} else {
this._el = oldNode._el;
}
}
this._el.text = this._text;
this._el.permissions = this._permissions;
this._el.hasChildren = this.order.length > 0;
this._el.removeAllChildren();
let oldChildren = (oldNode === undefined) ? new Map() : oldNode._children;
this._order.forEach(childId => {
let oldChild = oldChildren.get(childId); // May be undefined
let child = this._children.get(childId); // Not undefined
child.obtainElements(oldChild);
this._el.addChild(child._el);
});
}
// Wrapper functions for this._el
appendTo(element) {
if (this._el === undefined) this.obtainElements();
this._el.appendTo(element);
}
get folded() {
if (this._el === undefined) return undefined;
return this._el.folded;
}
set folded(flag) {
if (this._el === undefined) return;
this._el.folded = flag;
}
toggleFolded() {
if (this._el === undefined) return;
this._el.toggleFolded();
}
get hasCursor() {
if (this._el === undefined) return undefined;
return this._el.hasCursor;
}
set hasCursor(flag) {
if (this._el === undefined) return;
this._el.hasCursor = flag;
}
get hasEditor() {
if (this._el === undefined) return undefined;
return this._el.hasEditor;
}
set hasEditor(flag) {
if (this._el === undefined) return;
this._el.hasEditor = flag;
}
}
class NodeTree {
constructor(rootNodeContainer, rootNode) {
this._rootNodeContainer = rootNodeContainer;
this._rootNode = rootNode;
// Prepare root node container
removeAllChildren(this._rootNodeContainer);
this._rootNode.appendTo(this._rootNodeContainer);
}
at(path) {
let node = this._rootNode;
for (let childId of path.components) {
node = node.child(childId);
if (node === undefined) break;
}
return node;
}
updateAt(path, newNode) {
if (path.length === 0) {
newNode.obtainElements(this._rootNode);
this._rootNode = newNode;
} else {
let parentNode = this.at(path.parent);
parentNode.replaceChild(path.last, newNode);
}
}
getChildWith(path, f) {
let node = this.at(path);
if (node === undefined) return undefined;
let index = f(node.order.length);
if (index === undefined) return undefined;
let childId = node.order[index];
if (childId === undefined) return undefined;
return path.append(childId);
}
getFirstChild(path) {
return this.getChildWith(path, l => 0);
}
getLastChild(path) {
return this.getChildWith(path, l => l - 1);
}
getSiblingWith(path, f) {
if (path.parent === undefined) return undefined;
let parentNode = this.at(path.parent);
if (parentNode === undefined) return undefined;
let index = parentNode.order.indexOf(path.last);
if (index === undefined) return undefined;
let newIndex = f(index);
if (newIndex === undefined) return undefined;
let siblingId = parentNode.order[newIndex];
if (siblingId === undefined) return undefined;
return path.parent.append(siblingId);
}
getPrevSibling(path) {
return this.getSiblingWith(path, i => i - 1);
}
getNextSibling(path) {
return this.getSiblingWith(path, i => i + 1);
}
getNodeAbove(path) {
let prevPath = this.getPrevSibling(path);
if (prevPath === undefined) return path.parent;
// Get last child of previous path
while (true) {
let prevNode = this.at(prevPath);
if (prevNode.folded) return prevPath;
let childPath = this.getLastChild(prevPath);
if (childPath === undefined) return prevPath;
prevPath = childPath;
}
}
getNodeBelow(path) {
let node = this.at(path);
if (!node.folded) {
let childPath = this.getFirstChild(path);
if (childPath !== undefined) return childPath;
}
while (path !== undefined) {
let nextPath = this.getNextSibling(path);
if (nextPath !== undefined) return nextPath;
path = path.parent;
}
return undefined;
}
}
class Cursor {
constructor(nodeTree) {
this._nodeTree = nodeTree;
this._path = new Path();
this._relPos = null; // Either null or a RelPos value
this.restore();
}
getSelectedNode() {
return this._nodeTree.at(this._path);
}
_applyRelPos() {
if (this._relPos === null) return;
let newPath;
if (this._relPos === RelPos.FIRST_CHILD) {
newPath = this._nodeTree.getFirstChild(this._path);
} else if (this._relPos === RelPos.NEXT_SIBLING) {
newPath = this._nodeTree.getNextSibling(this._path);
}
if (newPath !== undefined) {
this._path = newPath;
this._relPos = null;
}
}
_moveToNearestValidNode() {
// TODO Maybe select a sibling instead of going to nearest visible parent
let path = new Path();
for (let component of this._path.components) {
let newPath = path.append(component);
let newNode = this._nodeTree.at(newPath);
if (newNode === undefined) break;
if (newNode.folded) break;
path = newPath;
}
this._path = path;
}
_set(visible) {
this.getSelectedNode().hasCursor = visible;
}
restore() {
this._applyRelPos();
this._moveToNearestValidNode();
this._set(true);
}
moveTo(path) {
if (path === undefined) return;
this._set(false);
this._path = path;
this._set(true);
}
moveUp() {
this.moveTo(this._nodeTree.getNodeAbove(this._path));
}
moveDown() {
this.moveTo(this._nodeTree.getNodeBelow(this._path));
}
}
class Editor {
constructor(nodeTree) {
this._nodeTree = nodeTree;
this._elTextarea = newElement("textarea");
this._elTextarea.addEventListener("input", event => this._updateTextAreaHeight());
this._elMain = newElement("div", "node-editor", this.textarea);
this._path = undefined;
this._asChild = false;
}
_updateTextAreaHeight() {
this._elTextarea.style.height = 0;
this._elTextarea.style.height = this._elTextarea.scrollHeight + "px";
}
_getAttachedNode() {
if (this._path === undefined) return undefined;
return this._nodeTree.at(this._path);
}
_detach(node, asChild) {
if (!asChild) {
node.hasEditor = false;
}
this._elMain.parentNode.removeChild(this._elMain);
}
_attachTo(node, asChild) {
if (asChild) {
node._el._elChildren.appendChild(this.element);
node.folded = false;
} else {
node._el._elMain.classList.add("has-editor");
node._el._elMain.insertBefore(this.element, node._el._elChildren);
}
this._updateTextAreaHeight();
}
restore() {
if (this.element.parentNode !== null) return; // Already attached
let node = this._getAttachedNode();
if (node === undefined) return; // Nowhere to attach
this._attachTo(node, this.asChild);
}
attachTo(path, asChild) {
this.detach();
this.path = path;
this.asChild = asChild;
this.restore();
this.textarea.focus();
let length = this.textarea.value.length;
this.textarea.setSelectionRange(length, length);
}
detach() {
let node = this._getAttachedNode();
if (node === undefined) return;
this._detach(node, this.asChild);
this.path = undefined;
}
set content(text) {
this.textarea.value = text;
}
get content() {
return this.textarea.value;
}
}
class Connection {
constructor(nodeTree, cursor, editor, url) {
this.nodeTree = nodeTree;
this.cursor = cursor;
this.editor = editor;
this.url = url;
this.ws = new WebSocket(this.url);
this.ws.addEventListener("message", msg => this.onMessage(msg));
this.ws.addEventListener("open", _ => this.sendHello());
}
onMessage(msg) {
let content = JSON.parse(msg.data);
if (content.type === "hello") {
this.onHello(content);
} else if (content.type === "update") {
this.onUpdate(content);
}
}
onHello(content) {
this.nodeTree.updateAt(new Path(), new Node(content.node));
this.cursor.restore();
this.editor.restore();
}
onUpdate(content) {
this.nodeTree.updateAt(new Path(...content.path), new Node(content.node));
this.cursor.restore();
this.editor.restore();
}
_send(thing) {
this.ws.send(JSON.stringify(thing));
}
sendHello() {
this._send({type: "hello", extensions: []});
}
sendEdit(path, text) {
this._send({type: "edit", path: path.components, text: text});
}
sendDelete(path) {
this._send({type: "delete", path: path.components});
}
sendReply(path, text) {
this._send({type: "reply", path: path.components, text: text});
}
sendAct(path) {
this._send({type: "act", path: path.components});
}
}
/*
* The main application
*/
const rootNodeContainer = document.getElementById("root-node-container");
const loadingNode = new Node({text: "Connecting...", children: {}, order: []});
const nodeTree = new NodeTree(rootNodeContainer, loadingNode);
const cursor = new Cursor(nodeTree);
const editor = new Editor(nodeTree);
const conn = new Connection(nodeTree, cursor, editor, "ws://127.0.0.1:8080/");
function beginEdit() {
let node = cursor.getSelectedNode();
editor.content = node.text;
editor.attachTo(cursor.path, false);
}
function beginDirectReply() {
editor.content = "";
editor.attachTo(cursor.path, true);
}
function beginIndirectReply() {
let path = cursor.path.parent;
if (path === undefined) return;
editor.content = "";
editor.attachTo(path, true);
}
function cancelEdit() {
editor.detach();
}
function completeEdit() {
let path = editor.path;
let text = editor.textarea.value;
if (editor.asChild) {
conn.sendReply(path, text);
} else {
conn.sendEdit(path, text);
}
editor.detach();
}
document.addEventListener("keydown", event => {
if (event.code === "Escape") {
cancelEdit();
event.preventDefault();
} else if (event.code === "Enter" && !event.shiftKey) {
completeEdit();
event.preventDefault();
} else if (document.activeElement.tagName === "TEXTAREA") {
return; // Do nothing special
} else if (event.code === "Tab") {
cursor.getSelectedNode().toggleFolded();
event.preventDefault();
} else if (event.code === "KeyK" || event.code === "ArrowUp") {
cursor.moveUp();
event.preventDefault();
} else if (event.code === "KeyJ" || event.code === "ArrowDown") {
cursor.moveDown();
event.preventDefault();
} else if (event.code === "KeyE") {
beginEdit();
event.preventDefault();
} else if (event.code === "KeyR") {
if (event.shiftKey) {
console.log("indirect");
beginIndirectReply();
} else {
console.log("direct");
beginDirectReply();
}
event.preventDefault();
} else if (event.code === "KeyD") {
conn.sendDelete(cursor.path);
event.preventDefault();
} else if (event.code === "KeyA") {
conn.sendAct(cursor.path);
event.preventDefault();
}
});