[web] Move web client to this repo
This commit is contained in:
parent
4b8d0ee4a4
commit
f8fd5b3c3e
7 changed files with 773 additions and 0 deletions
67
forest-web/about.html
Normal file
67
forest-web/about.html
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Forest - About</title>
|
||||
<link rel="stylesheet" href="main.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<h1>Forest</h1>
|
||||
<h2>Description</h2>
|
||||
<p>
|
||||
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!
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>Code and docs</h2>
|
||||
<ol>
|
||||
<li><a href="https://github.com/Garmelon/forest">Server and terminal-based client</a></li>
|
||||
<li>Web-based client (coming soon)</li>
|
||||
<li><a href="https://github.com/Garmelon/forest/blob/master/docs/API.md#api">API documentation</a></li>
|
||||
</ol>
|
||||
|
||||
<h2>Usage</h2>
|
||||
<h3>Controls</h3>
|
||||
<pre>
|
||||
tab - fold/unfold current node
|
||||
arrow keys/jk - move cursor
|
||||
</pre>
|
||||
<h3>Permissions</h3>
|
||||
<p>
|
||||
A node's permissions are displayed at the right side of the
|
||||
screen, like this:
|
||||
<span style="color: var(--bright-black);">(edra)</span>.
|
||||
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.
|
||||
</p>
|
||||
<pre>
|
||||
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
|
||||
</pre>
|
||||
<h3>Colors</h3>
|
||||
<p>
|
||||
The cursor position is marked by a
|
||||
<span style="background-color: var(--blue);">blue background</span>.
|
||||
If a node is colored
|
||||
<span style="color: var(--yellow); font-weight: bold;">yellow</span>,
|
||||
it has child nodes.
|
||||
</p>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
39
forest-web/init.html
Normal file
39
forest-web/init.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Forest</title>
|
||||
<link rel="stylesheet" href="main.css">
|
||||
<link rel="stylesheet" href="node.css">
|
||||
<link rel="stylesheet" href="settings.css">
|
||||
<script defer src="node.js"></script>
|
||||
<script defer src="settings.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="root-node-container">
|
||||
<div class="node">
|
||||
<div class="node-line">
|
||||
<span class="node-text">Please enable javascript.</span>
|
||||
<span class="node-permissions">(----)</span>
|
||||
</div>
|
||||
<div class="node-children"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settings">
|
||||
<button>Settings</button>
|
||||
<form>
|
||||
<div>
|
||||
<input type="checkbox" id="curvy-lines-checkbox">
|
||||
<label for="curvy-lines-checkbox" title="Make the end bits of the tree lines curved">Curvy lines</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="about">
|
||||
<a href="about.html">About</a>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
76
forest-web/main.css
Normal file
76
forest-web/main.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
71
forest-web/node.css
Normal file
71
forest-web/node.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
469
forest-web/node.js
Normal file
469
forest-web/node.js
Normal file
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
16
forest-web/settings.css
Normal file
16
forest-web/settings.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
35
forest-web/settings.js
Normal file
35
forest-web/settings.js
Normal file
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue