diff --git a/forest-web/about.html b/forest-web/about.html new file mode 100644 index 0000000..704d4f0 --- /dev/null +++ b/forest-web/about.html @@ -0,0 +1,67 @@ + + + + + Forest - About + + + +
+ +

Forest

+

Description

+

+ Forest is a project based around interacting with trees of + plain-text nodes. It has an API that is intentionally kept + simple. Writing your own clients or bots is explicitly + encouraged! +

+

+ At the moment, there are a server and a terminal-based client + written in haskell, and the web-based client you're using right + now, made with (vanilla) javascript. The web-based client is + heavily based on the terminal-based client, both in look and + behaviour. The color scheme is just my terminal's current color + scheme. +

+ +

Code and docs

+
    +
  1. Server and terminal-based client
  2. +
  3. Web-based client (coming soon)
  4. +
  5. API documentation
  6. +
+ +

Usage

+

Controls

+
+tab           - fold/unfold current node
+arrow keys/jk - move cursor
+            
+

Permissions

+

+ A node's permissions are displayed at the right side of the + screen, like this: + (edra). + If a permission is set, its character is displayed. Otherwise, a + dash is displayed in its place. Only when a permission is set + can its action be performed. +

+
+e (edit)   - edit a node's text
+d (delete) - delete a node
+r (reply)  - reply to a node
+a (act)    - perform a node-specific action
+            
+

Colors

+

+ The cursor position is marked by a + blue background. + If a node is colored + yellow, + it has child nodes. +

+ +
+ + diff --git a/forest-web/init.html b/forest-web/init.html new file mode 100644 index 0000000..1cde6ea --- /dev/null +++ b/forest-web/init.html @@ -0,0 +1,39 @@ + + + + + Forest + + + + + + + + +
+
+
+ Please enable javascript. + (----) +
+
+
+
+ +
+ +
+
+ + +
+
+
+ +
+ About +
+ + + diff --git a/forest-web/main.css b/forest-web/main.css new file mode 100644 index 0000000..dd992a8 --- /dev/null +++ b/forest-web/main.css @@ -0,0 +1,76 @@ +html { + /* My terminal's color scheme */ + --background: #000000; + --foreground: #babdb6; + --black: #2e3436; + --bright-black: #555753; + --red: #cc0000; + --bright-red: #ef2929; + --green: #4e9a06; + --bright-green: #8ae234; + --yellow: #c4a000; + --bright-yellow: #fce94f; + --blue: #3465a4; + --bright-blue: #729fcf; + --magenta: #75507b; + --bright-magenta: #ad7fa8; + --cyan: #06989a; + --bright-cyan: #34e2e2; + --white: #d3d7cf; + --bright-white: #eeeeec; + + font-family: monospace; + font-size: 16px; + color: var(--foreground); + background-color: var(--background); +} +body { + max-width: 1024px; + margin: 0 auto; + padding: 2em; +} +h1, h2, h3, h4, h5, h6 { + color: var(--white); + margin-top: 1.5em; +} +h1 { + margin-top: 0; + font-size: 2em; +} +h2 { + text-decoration: underline; +} +a { + color: var(--bright-blue); +} +a:visited { + color: var(--bright-magenta); +} +/* Input elements */ +input[type="checkbox"] { + display: none; +} +input[type="checkbox"] + label::before { + content: "[_] "; + font-weight: bold; +} +input[type="checkbox"]:checked + label::before { + content: "[X] "; +} +button, textarea { + font-family: inherit; + font-size: inherit; + color: inherit; + background-color: inherit; + margin: 0; + padding: 0; + border: none; + outline: none; +} +button { + font-weight: bold; +} +textarea { + color: var(--foreground); + background-color: var(--bright-black); +} diff --git a/forest-web/node.css b/forest-web/node.css new file mode 100644 index 0000000..e02c175 --- /dev/null +++ b/forest-web/node.css @@ -0,0 +1,71 @@ +.node-line { + display: flex; +} +.node-text { + flex-grow: 1; +} +.node-permissions { + color: var(--bright-black); + margin-left: 1ch; +} +.node textarea { + width: 100%; + resize: none; +} + +/* Special display states a node can be in */ +.has-children > .node-line > .node-text { + font-weight: bold; + color: var(--yellow); +} +.has-cursor > .node-line > .node-text { + background-color: var(--blue); +} +.has-editor > .node-line { + display: none; +} +.is-folded > .node-children { + display: none; +} + +/* Fancy tree lines */ +.node, .node::before { + border-color: var(--bright-black); + border-width: 2px; +} +.node-children > .node { + position: relative; /* .node is containing block for its .node::before */ + margin-left: calc(0.5ch - 1px); + padding-left: calc(1.5ch - 1px); + border-left-style: solid; +} +.node-children > .node:last-child { + padding-left: calc(1.5ch + 1px); + border-left-style: none; +} +.node-children > .node::before { + content: ""; + position: absolute; + left: 0; + top: 0; + width: calc(1ch - 1px); + height: calc(0.6em - 1px); + border-bottom-style: solid; +} +.node-children > .node:last-child::before { + border-left-style: solid; + transition: all 0.4s; +} + +/* Curvy lines */ +.curvy .node:last-child::before { + border-bottom-left-radius: 6px; +} + +/* About link in bottom right corner */ +#about { + position: fixed; + bottom: 0; + right: 0; + margin: 1ch; +} diff --git a/forest-web/node.js b/forest-web/node.js new file mode 100644 index 0000000..33e722c --- /dev/null +++ b/forest-web/node.js @@ -0,0 +1,469 @@ +"use strict"; + +/* + * Utility functions + */ + +// 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(...nodeIds) { + this.elements = nodeIds; + } + + get length() { + return this.elements.length; + } + + get last() { + return this.elements[this.length - 1]; + } + + get parent() { + if (this.length === 0) return undefined; + return new Path(...this.elements.slice(0, this.length - 1)); + } + + append(nodeId) { + return new Path(...this.elements.concat([nodeId])); + } + + concat(otherPath) { + return new Path(...this.elements.concat(otherPath.elements)); + } +} + +class NodeElements { + constructor() { + this.text = newElement("span", "node-text"); + this.permissions = newElement("span", "node-permissions"); + this.children = newElement("div", "node-children"); + + let line = newElement("div", "node-line", this.text, this.permissions); + this.main = newElement("div", ["node", "is-folded"], line, this.children); + } + + removeAllChildren() { + while (this.children.firstChild) { + this.children.removeChild(this.children.lastChild); + } + } +} + +class Node { + constructor(nodeJson) { + this.elements = undefined; + + this.text = nodeJson.text; + + // Permissions + this.edit = nodeJson.edit; + this.delete = nodeJson.delete; + this.reply = nodeJson.reply; + this.act = 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); + }); + } + + getPermissionText() { + return [ + "(", + this.edit ? "e" : "-", + this.delete ? "d" : "-", + this.reply ? "r" : "-", + this.act ? "a" : "-", + ")" + ].join(""); + } + + hasChildren() { + return this.order.length > 0; + } + + isFolded() { + if (this.elements === undefined) return undefined; + return this.elements.main.classList.contains("is-folded"); + } + + setFolded(folded) { + if (this.elements === undefined) return; + this.elements.main.classList.toggle("is-folded", folded); + } + + toggleFolded() { + this.setFolded(!this.isFolded()); + } + + // 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.elements === undefined) { + // Obtain DOM elements because we don't yet have any + if (oldNode === undefined || oldNode.elements === undefined) { + this.elements = new NodeElements(); + } else { + this.elements = oldNode.elements; + } + } + + this.elements.text.textContent = this.text; + this.elements.permissions.textContent = this.getPermissionText(); + this.elements.main.classList.toggle("has-children", this.hasChildren()); + + let oldChildren = (oldNode === undefined) ? + new Map() : oldNode.children; + + this.elements.removeAllChildren(); + this.order.forEach(childId => { + let oldChild = oldChildren.get(childId); // May be undefined + let child = this.children.get(childId); + child.obtainElements(oldChild); + this.elements.children.appendChild(child.elements.main); + }); + } +} + +class NodeTree { + constructor(rootNodeContainer, rootNode) { + this.rootNodeContainer = rootNodeContainer; + this.rootNode = rootNode; + + // Prepare root node container + rootNode.obtainElements(); + while (rootNodeContainer.firstChild) { + rootNodeContainer.removeChild(rootNodeContainer.lastChild); + } + rootNodeContainer.appendChild(rootNode.elements.main); + } + + at(path) { + let node = this.rootNode; + for (let childId of path.elements) { + node = node.children.get(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); + let oldNode = parentNode.children.get(path.last); + if (oldNode === undefined) return; + newNode.obtainElements(oldNode); + parentNode.children.set(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.isFolded()) return prevPath; + + let childPath = this.getLastChild(prevPath); + if (childPath === undefined) return prevPath; + + prevPath = childPath; + } + } + + getNodeBelow(path) { + let node = this.at(path); + if (!node.isFolded()) { + 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 element of this.path.elements) { + let newPath = path.append(element); + let newNode = this.nodeTree.at(newPath); + if (newNode === undefined) break; + if (newNode.isFolded()) break; + path = newPath; + } + this.path = path; + } + + _set(visible) { + this.getSelectedNode().elements.main.classList.toggle("has-cursor", 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.textarea = newElement("textarea"); + this.textarea.addEventListener("input", event => this._updateTextAreaHeight()); + + this.path = undefined; + this.asChild = false; + } + + _updateTextAreaHeight() { + this.textarea.style.height = 0; + this.textarea.style.height = this.textarea.scrollHeight + "px"; + } + + _getAttachedNode() { + if (this.path === undefined) return undefined; + return this.nodeTree.at(this.path); + } + + _detach(node, asChild) { + if (!asChild) { + node.elements.main.classList.remove("has-editor"); + } + + this.textarea.parentNode.removeChild(this.textarea); + } + + _attachTo(node, asChild) { + if (asChild) { + node.elements.children.appendChild(this.textarea); + } else { + node.elements.main.classList.add("has-editor"); + node.elements.main.insertBefore(this.textarea, node.elements.children); + } + this._updateTextAreaHeight(); + } + + restore() { + if (this.textarea.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; + } +} + +/* + * 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); + +// TODO Replace this testing node with the real websocket code +const testNode = new Node({text: "Forest", children: [ + {text: "Test", children: [ + {text: "Bla", children: [], order: []}, + ], order: [0]}, + {text: "Sandbox", edit: true, delete: true, reply: true, act: true, children: [], order: []}, + {text: "About", children: [ + {text: "This project is an experiment in tree-based interaction.", children: [], order: []}, + {text: "Motivation", children: [], order: []}, + {text: "Inspirations", children: [], order: []}, + ], order: [0, 1, 2]} +], order: [0, 1, 2]}); +nodeTree.updateAt(new Path(), testNode); + +document.addEventListener("keydown", event => { + console.log(event); + if (event.code === "Escape") { + editor.detach(); + } else if (document.activeElement === editor.textarea) { + if (event.code === "Enter" && !event.shiftKey) { + editor.detach(); + } + } 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") { + let node = cursor.getSelectedNode(); + editor.content = node.text; + editor.attachTo(cursor.path, false); + event.preventDefault(); + } +}); diff --git a/forest-web/settings.css b/forest-web/settings.css new file mode 100644 index 0000000..817bcab --- /dev/null +++ b/forest-web/settings.css @@ -0,0 +1,16 @@ +#settings { + position: fixed; + bottom: 0; + transition: all 0.2s ease-out; + transform: translateY(100%); +} +#settings a { + color: var(--white); +} +#settings > button, #settings > form { + padding: 1ch; + background-color: var(--magenta); +} +#settings > button { + font-weight: bold; +} diff --git a/forest-web/settings.js b/forest-web/settings.js new file mode 100644 index 0000000..2bcc331 --- /dev/null +++ b/forest-web/settings.js @@ -0,0 +1,35 @@ +"use strict"; + +const settingsDiv = document.getElementById("settings"); +const settingsButton = settingsDiv.querySelector("button"); +const settingsForm = settingsDiv.querySelector("form"); +let settingsMenuState; +settingsButton.addEventListener("click", event => setSettingsMenuState(!settingsMenuState)); +window.addEventListener("load", event => setSettingsMenuState(false)); + +function setSettingsMenuState(open) { + settingsMenuState = open; + if (open) { + settingsDiv.style.transform = "none"; + } else { + let height = settingsButton.offsetHeight; + settingsDiv.style.transform = `translateY(calc(100% - ${height}px))`; + } +} + +const curvyLinesCheckbox = document.getElementById("curvy-lines-checkbox"); +curvyLinesCheckbox.addEventListener("change", event => setCurvyLines(event.target.checked)); +window.addEventListener("load", event => { + let curvy = window.localStorage.getItem("curvy"); + curvyLinesCheckbox.checked = curvy; + setCurvyLines(curvy); +}); + +function setCurvyLines(curvy) { + document.body.classList.toggle("curvy", curvy); + if (curvy) { + window.localStorage.setItem("curvy", "yes"); + } else { + window.localStorage.removeItem("curvy"); + } +}