Compare commits

..

6 commits

Author SHA1 Message Date
Magnus Åhall
e2b20816c2 Updated the native treenode to a custom HTML element 2026-04-29 14:03:08 +02:00
Magnus Åhall
d9c82868ab Better visual sync 2025-11-29 16:45:56 +01:00
Magnus Åhall
40a68d6ad0 Use pure CSS 2025-11-29 09:33:42 +01:00
Magnus Åhall
989542be91 First steps to creating a new node 2025-06-28 09:13:26 +02:00
Magnus Åhall
1ce8e29e37 Tree render and navigation with note rendering 2025-06-25 14:59:21 +02:00
Magnus Åhall
dd27be67b9 Tree expansion and keyboard navigation works 2025-06-16 21:30:46 +02:00
18 changed files with 1492 additions and 378 deletions

14
main.go
View file

@ -25,7 +25,7 @@ import (
const VERSION = "v1" const VERSION = "v1"
const CONTEXT_USER = 1 const CONTEXT_USER = 1
const SYNC_PAGINATION = 100 const SYNC_PAGINATION = 500
var ( var (
FlagGenerate bool FlagGenerate bool
@ -269,9 +269,11 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{
return return
} }
/*
Log.Debug("/sync/from_server", "num_nodes", len(nodes), "maxSeq", maxSeq) Log.Debug("/sync/from_server", "num_nodes", len(nodes), "maxSeq", maxSeq)
foo, _ := json.Marshal(nodes) foo, _ := json.Marshal(nodes)
os.WriteFile(fmt.Sprintf("/tmp/nodes-%d.json", offset), foo, 0644) os.WriteFile(fmt.Sprintf("/tmp/nodes-%d.json", offset), foo, 0644)
*/
j, _ := json.Marshal(struct { j, _ := json.Marshal(struct {
OK bool OK bool
@ -288,7 +290,6 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{
user := getUser(r) user := getUser(r)
changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
Log.Debug("FOO", "UUID", user.ClientUUID, "changedFrom", changedFrom)
count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID) count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID)
if err != nil { if err != nil {
Log.Error("/sync/from_server/count", "error", err) Log.Error("/sync/from_server/count", "error", err)
@ -334,9 +335,14 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
return return
} }
db.Exec(`CALL add_nodes($1, $2, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData) _, err = db.Exec(`CALL add_nodes($1, $2, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData)
if err != nil {
Log.Error("sync", "error", err)
httpError(w, err)
return
}
responseData(w, map[string]interface{}{ responseData(w, map[string]any{
"OK": true, "OK": true,
}) })
} // }}} } // }}}

View file

@ -1,16 +1 @@
CREATE TABLE public.node_history ( DROP INDEX public.node_uuid_idx;
id serial4 NOT NULL,
user_id int4 NOT NULL,
uuid bpchar(36) NOT NULL,
parents varchar[] NULL,
created timestamptz NOT NULL,
updated timestamptz NOT NULL,
name varchar(256) NOT NULL,
"content" text NOT NULL,
content_encrypted text NOT NULL,
markdown bool DEFAULT false NOT NULL,
client bpchar(36) DEFAULT ''::bpchar NOT NULL,
CONSTRAINT node_history_pk PRIMARY KEY (id),
CONSTRAINT node_history_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT
);
CREATE INDEX node_history_uuid_idx ON public.node USING btree (uuid);

View file

@ -1,11 +1,15 @@
@import "theme.css";
#app { #app {
display: grid; display: grid;
justify-items: center; justify-items: center;
margin-top: 128px; margin-top: 128px;
} }
#logo { #logo {
margin-bottom: 48px; margin-bottom: 48px;
} }
#box { #box {
display: grid; display: grid;
grid-gap: 16px 0; grid-gap: 16px 0;
@ -14,24 +18,27 @@
padding: 48px 0px; padding: 48px 0px;
background-color: #fff; background-color: #fff;
box-shadow: 0px 20px 52px -33px rgba(0,0,0,0.75); box-shadow: 0px 20px 52px -33px rgba(0,0,0,0.75);
border-left: 8px solid #666; border-left: 8px solid var(--color3);
}
#box input { input {
padding: 4px 8px; padding: 4px 8px;
font-size: 1em; font-size: 1em;
width: calc(100% - 64px); width: calc(100% - 64px);
border: 1px solid #aaa; border: 1px solid #aaa;
border-radius: 4px; border-radius: 4px;
} }
#box button {
button {
padding: 6px 16px; padding: 6px 16px;
font-size: 1em; font-size: 1em;
border-radius: 4px; border-radius: 4px;
border: none; border: none;
background-color: #fe5f55; background-color: var(--color1);
color: #fff; color: #fff;
} }
#box #error {
#error {
color: #c33; color: #c33;
margin-top: 16px; margin-top: 16px;
} }
}

View file

@ -1,23 +1,29 @@
@import "theme.css";
html { html {
box-sizing: border-box; box-sizing: border-box;
background: #efede8; background: var(--color2);
font-family: "Liberation Mono", monospace; font-family: "Liberation Mono", monospace;
font-size: 14px; font-size: 14px;
margin: 0px; margin: 0px;
padding: 0px; padding: 0px;
} }
body { body {
margin: 0px; margin: 0px;
padding: 0px; padding: 0px;
} }
*, *,
*:before, *:before,
*:after { *:after {
box-sizing: inherit; box-sizing: inherit;
} }
*:focus { *:focus {
outline: none; outline: none;
} }
[onClick] { [onClick] {
cursor: pointer; cursor: pointer;
} }

View file

@ -1,93 +1,150 @@
@import "theme.css";
html { html {
background-color: #fff; background-color: #fff;
} }
#notes2 { #notes2 {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
grid-template-areas: "tree crumbs" "tree sync" "tree name" "tree content" "tree blank"; grid-template-areas:
"tree crumbs"
"tree sync"
"tree name"
"tree content"
/*
"tree checklist"
"tree schedule"
"tree files"
*/
"tree blank"
;
grid-template-columns: min-content 1fr; grid-template-columns: min-content 1fr;
grid-template-rows: 48px 56px 48px min-content 1fr; grid-template-rows:
} 48px 56px 48px min-content 1fr;
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
#notes2 { grid-template-areas:
grid-template-areas: "crumbs" "sync" "name" "content" "blank"; "crumbs"
"sync"
"name"
"content"
/*
"checklist"
"schedule"
"files"
*/
"blank"
;
grid-template-columns: 1fr; grid-template-columns: 1fr;
}
#notes2 #tree { #tree {
display: none; display: none;
} }
} }
}
#tree { #tree {
grid-area: tree; grid-area: tree;
padding: 16px 32px; display: grid;
background-color: #333; padding: 16px 0px 16px 16px;
color: #ddd; color: #ddd;
z-index: 100; z-index: 100;
/* Over crumbs shadow */
border-left: 2px solid #333; border-left: 2px solid #333;
}
#tree:focus { &:focus {
border-left: 2px solid #FE5F55; border-left: 2px solid #FE5F55;
} }
#tree #logo {
#logo {
display: grid; display: grid;
position: relative; position: relative;
justify-items: center; justify-items: center;
margin-top: 8px;
margin-bottom: 8px; margin-bottom: 8px;
margin-left: 24px; margin-left: 24px;
margin-right: 24px; margin-right: 24px;
} cursor: pointer;
#tree #logo img {
img {
width: 128px; width: 128px;
left: -20px; left: -20px;
} }
#tree .icons { }
.icons {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-bottom: 32px; margin-bottom: 32px;
gap: 8px; gap: 8px;
} }
#tree .node {
.node {
display: grid; display: grid;
grid-template-columns: 24px min-content; grid-template-columns: 24px min-content;
grid-template-rows: min-content 1fr; grid-template-rows:
min-content 1fr;
margin-top: 12px; margin-top: 12px;
}
#tree .node .expand-toggle {
.expand-toggle {
user-select: none; user-select: none;
}
#tree .node .expand-toggle img { img {
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
#tree .node .name { }
.name {
white-space: nowrap; white-space: nowrap;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
&:hover {
color: var(--color1);
} }
#tree .node .name:hover {
color: #fe5f55; &.selected {
} color: var(--color1);
#tree .node .name.selected {
color: #fe5f55;
font-weight: bold; font-weight: bold;
} }
#tree .node .children {
}
.children {
padding-left: 24px; padding-left: 24px;
margin-left: 8px; margin-left: 8px;
border-left: 1px solid #444; border-left: 1px solid #444;
grid-column: 1 / -1; grid-column: 1 / -1;
}
#tree .node .children.collapsed { &.collapsed {
display: none; display: none;
} }
}
}
}
#tree-nodes {
padding: 16px 32px;
background-color: #333;
border-radius: 8px;
box-shadow: 5px 5px 10px -5px rgba(0, 0, 0, 0.75);
}
#crumbs { #crumbs {
grid-area: crumbs; grid-area: crumbs;
display: grid; display: grid;
align-items: start; align-items: start;
justify-items: center; justify-items: center;
margin: 0px 16px; margin: 16px 16px;
}
#crumbs .crumbs { .crumbs {
background: #e4e4e4;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
padding: 8px 16px; padding: 8px 16px;
@ -95,80 +152,115 @@ html {
color: #333; color: #333;
border-bottom-left-radius: 5px; border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px; border-bottom-right-radius: 5px;
&.node-modified {
background-color: var(--color1);
color: var(--color2);
.crumb:after {
color: var(--color2);
} }
#crumbs .crumbs.node-modified {
background-color: #fe5f55;
color: #efede8;
} }
#crumbs .crumbs.node-modified .crumb:after {
color: #efede8; .crumb {
}
#crumbs .crumbs .crumb {
margin-right: 8px; margin-right: 8px;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
a {
text-decoration: none;
color: inherit;
} }
#crumbs .crumbs .crumb:after { }
.crumb:after {
content: "•"; content: "•";
margin-left: 8px; margin-left: 8px;
color: #fe5f55; color: var(--color1)
} }
#crumbs .crumbs .crumb:last-child {
.crumb:last-child {
margin-right: 0; margin-right: 0;
} }
#crumbs .crumbs .crumb:last-child:after {
.crumb:last-child:after {
content: ''; content: '';
margin-left: 0px; margin-left: 0px;
} }
}
}
#sync-progress { #sync-progress {
--radius: 8px;
grid-area: sync; grid-area: sync;
display: grid; display: grid;
justify-items: center; justify-items: center;
align-items: center;
width: 100%; width: 100%;
height: 56px; height: 56px;
.container {
position: relative; position: relative;
}
#sync-progress progress { progress {
width: 100%; width: 900px;
padding: 0 7px; padding: 0 7px;
max-width: 900px; max-width: 900px;
height: 16px; height: 24px;
border-radius: 4px; border-radius: 8px;
} }
#sync-progress progress[value]::-webkit-progress-bar {
background-color: #eee; .count {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25) inset;
border-radius: 4px;
}
#sync-progress progress[value]::-moz-progress-bar {
background-color: #eee;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25) inset;
border-radius: 4px;
}
#sync-progress progress[value]::-webkit-progress-value {
background: #ba5f59;
background: linear-gradient(180deg, #ba5f59 0%, #fe5f55 50%, #ba5f59 100%);
border-radius: 4px;
}
#sync-progress progress[value]::-moz-progress-value {
background: #ba5f59;
background: linear-gradient(180deg, #ba5f59 0%, #fe5f55 50%, #ba5f59 100%);
border-radius: 4px;
}
#sync-progress .count {
width: min-content;
white-space: nowrap;
margin-top: 0px;
color: #888;
position: absolute; position: absolute;
top: 22px; top: 5px;
width: 100%;
white-space: nowrap;
color: #888;
text-align: center;
font-size: 12pt;
font-weight: bold;
} }
#sync-progress.hidden {
progress[value]::-webkit-progress-bar {
background-color: #eee;
box-shadow: 0 2px var(--radius) rgba(0, 0, 0, 0.25) inset;
border-radius: var(--radius);
}
progress[value]::-moz-progress-bar {
background-color: #eee;
box-shadow: 0 2px var(--radius) rgba(0, 0, 0, 0.25) inset;
border-radius: var(--radius);
}
progress[value]::-webkit-progress-value {
background: rgb(186, 95, 89);
background: linear-gradient(180deg, rgba(186, 95, 89, 1) 0%, rgba(254, 95, 85, 1) 50%, rgba(186, 95, 89, 1) 100%);
border-radius: var(--radius);
}
progress[value]::-moz-progress-value {
background: rgb(186, 95, 89);
background: linear-gradient(180deg, rgba(186, 95, 89, 1) 0%, rgba(254, 95, 85, 1) 50%, rgba(186, 95, 89, 1) 100%);
border-radius: var(--radius);
}
}
&.hidden {
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
transition: visibility 0s 500ms, opacity 500ms linear; transition: visibility 0s 500ms, opacity 500ms linear;
} }
}
#name { #name {
color: #333; color: #333;
font-weight: bold; font-weight: bold;
@ -177,6 +269,7 @@ html {
margin-top: 0px; margin-top: 0px;
margin-bottom: 16px; margin-bottom: 16px;
} }
/* ============================================================= * /* ============================================================= *
* Textarea replicates the height of an element expanding height * * Textarea replicates the height of an element expanding height *
* ============================================================= */ * ============================================================= */
@ -184,11 +277,13 @@ html {
/* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */ /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */
display: grid; display: grid;
grid-area: content; grid-area: content;
font-size: 1em; font-size: 1.0em;
} }
.grow-wrap::after { .grow-wrap::after {
/* Note the weird space! Needed to preventy jumpy behavior */ /* Note the weird space! Needed to preventy jumpy behavior */
content: attr(data-replicated-value) " "; content: attr(data-replicated-value) " ";
/* This is how textarea text behaves */ /* This is how textarea text behaves */
width: calc(100% - 32px); width: calc(100% - 32px);
max-width: 900px; max-width: 900px;
@ -196,24 +291,31 @@ html {
word-wrap: break-word; word-wrap: break-word;
background: rgba(0, 255, 255, 0.5); background: rgba(0, 255, 255, 0.5);
justify-self: center; justify-self: center;
/* Hidden from view, clicks, and screen readers */ /* Hidden from view, clicks, and screen readers */
visibility: hidden; visibility: hidden;
} }
.grow-wrap>textarea { .grow-wrap>textarea {
/* You could leave this, but after a user resizes, then it ruins the auto sizing */ /* You could leave this, but after a user resizes, then it ruins the auto sizing */
resize: none; resize: none;
/* Firefox shows scrollbar on growth, you can hide like this. */ /* Firefox shows scrollbar on growth, you can hide like this. */
overflow: hidden; overflow: hidden;
} }
.grow-wrap>textarea, .grow-wrap>textarea,
.grow-wrap::after { .grow-wrap::after {
/* Identical styling required!! */ /* Identical styling required!! */
padding: 0.5rem; padding: 0.5rem;
font: inherit; font: inherit;
/* Place on top of each other */ /* Place on top of each other */
grid-area: 1 / 1 / 2 / 2; grid-area: 1 / 1 / 2 / 2;
} }
/* ============================================================= */ /* ============================================================= */
#node-content { #node-content {
justify-self: center; justify-self: center;
word-wrap: break-word; word-wrap: break-word;
@ -224,41 +326,57 @@ html {
resize: none; resize: none;
border: none; border: none;
outline: none; outline: none;
}
#node-content:invalid { &:invalid {
background: #f5f5f5; background: #f5f5f5;
padding-top: 16px; padding-top: 16px;
} }
}
#blank { #blank {
grid-area: blank; grid-area: blank;
height: 32px; height: 32px;
} }
dialog.op::backdrop {
dialog.op {
&::backdrop {
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
} }
dialog.op .header {
.header {
font-weight: bold; font-weight: bold;
margin-top: 16px; margin-top: 16px;
}
dialog.op .header:first-child { &:first-child {
margin-top: 0px; margin-top: 0px;
} }
#op-search .results { }
}
#op-search {
.results {
display: grid; display: grid;
grid-template-columns: min-content min-content; grid-template-columns: min-content min-content;
grid-gap: 6px 16px; grid-gap: 6px 16px;
}
#op-search .results div { div {
white-space: nowrap; white-space: nowrap;
} }
#op-search .results .ancestors {
.ancestors {
display: flex; display: flex;
}
#op-search .results .ancestors .ancestor::after { .ancestor::after {
content: ">"; content: ">";
margin: 0px 8px; margin: 0px 8px;
color: #a00; color: #a00;
} }
#op-search .results .ancestors .ancestor:last-child::after {
.ancestor:last-child::after {
content: ""; content: "";
} }
}
}
}

View file

@ -0,0 +1,5 @@
:root {
--color1: #fe5f55;
--color2: #efede8;
--color3: #666;
}

316
static/js/app.mjs Normal file
View file

@ -0,0 +1,316 @@
import { ROOT_NODE } from 'node_store'
import { TreeNative } from 'tree'
import { NodeUINative, Node } from 'node'
import { SyncProgress } from 'sync'
export class App {
constructor() {// {{{
this.currentNode = null
this.treeNative = new TreeNative()
this.crumbs = new Crumbs()
this.crumbsElement = document.getElementById('crumbs')
this.nodeUI = new NodeUINative(document.getElementById('note'))
_mbus.subscribe('TREE_TRUNK_FETCHED', async () => {
document.getElementById('tree').append(this.treeNative.render())
document.getElementById('tree-nodes')?.focus()
const startNode = await this.getStartNode()
this.goToNode(startNode.UUID, false, false)
})
_mbus.subscribe('TREE_NODE_SELECTED', event => {
const node = event.detail.data
this.goToNode(node.UUID, false, false)
})
_mbus.subscribe('GO_TO_NODE', event => {
const node = event.detail.data
this.goToNode(node.nodeUUID, node.dontPush, node.dontExpand)
})
window.addEventListener('keydown', event => this.keyHandler(event))
document.getElementById('notes2').addEventListener('click', event => {
if (event.target.id === 'notes2')
document.getElementById('node-content')?.focus()
})
const syncProgress = document.getElementById('sync-progress')
new SyncProgress(syncProgress)
window._sync = new Sync()
window._sync.run()
}// }}}
keyHandler(event) {//{{{
let handled = true
// All keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees.
// Ctrl+S is the exception to using Alt+Shift, since it is overridable and in such widespread use for saving.
// Thus, the exception is acceptable to consequent use of alt+shift.
if (!(event.shiftKey && event.altKey) && !(event.key.toUpperCase() === 'S' && event.ctrlKey))
return
switch (event.key.toUpperCase()) {
case 'T':
if (document.activeElement.id === 'tree-nodes')
document.getElementById('node-content').focus()
else
document.getElementById('tree-nodes').focus()
break
case 'F':
_mbus.dispatch('op-search')
break
/*
case 'C':
this.showPage('node')
break
case 'E':
this.showPage('keys')
break
case 'M':
this.toggleMarkdown()
break
*/
case 'N':
this.createNode()
break
/*
case 'P':
this.showPage('node-properties')
break
*/
case 'S':
this.saveNode()
/*
else if (this.page.value === 'node-properties')
this.nodeProperties.current.save()
*/
break
/*
case 'U':
this.showPage('upload')
break
case 'F':
this.showPage('search')
break
*/
default:
handled = false
}
if (handled) {
event.preventDefault()
event.stopPropagation()
}
}//}}}
async getStartNode() {//{{{
let nodeUUID = ROOT_NODE
// Is a UUID provided on the URI as an anchor?
const parts = document.URL.split('#')
if (parts[1]?.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i))
nodeUUID = parts[1]
return await nodeStore.get(nodeUUID)
}//}}}
async saveNode() {//{{{
if (!this.currentNode.isModified())
return
/* The node history is a local store for node history.
* This could be provisioned from the server or cleared if
* deemed unnecessary.
*
* The send queue is what will be sent back to the server
* to have a recorded history of the notes.
*
* A setting to be implemented in the future could be to
* not save the history locally at all. */
const node = this.currentNode
// The node is still in its old state and will present
// the unmodified content to the node store.
const history = nodeStore.nodesHistory.add(node)
// Prepares the node object for saving.
// Sets Updated value to current date and time.
await node.save()
// Updated node is added to the send queue to be stored on server.
const sendQueue = nodeStore.sendQueue.add(node)
// Updated node is saved to the primary node store.
const nodeStoreAdding = nodeStore.add([node])
await Promise.all([history, sendQueue, nodeStoreAdding])
}//}}}
async createNode() {//{{{
let name = prompt("Name")
if (!name)
return
const nn = Node.create(name, this.currentNode.UUID)
nn.save()
nodeStore.sendQueue.add(nn)
nodeStore.add([nn])
}//}}}
async goToNode(nodeUUID, dontPush, dontExpand) {//{{{
if (nodeUUID === null || nodeUUID === undefined)
return
// Don't switch notes until saved.
if (this.nodeUI.isModified()) {
if (!confirm("Changes not saved. Do you want to discard changes?"))
return
}
if (!dontPush)
history.pushState({ nodeUUID }, '', `/notes2#${nodeUUID}`)
const node = nodeStore.node(nodeUUID)
node.reset() // any modifications are discarded.
this.currentNode = node
this.treeNative.setSelected(node, dontExpand)
const ancestors = await nodeStore.getNodeAncestry(node)
_mbus.dispatch('CRUMBS_SET', ancestors, () => this.crumbsElement.replaceChildren(this.crumbs.render()))
_mbus.dispatch('NODE_UI_OPEN', node)
_mbus.dispatch('NODE_UNMODIFIED')
// Scrolls node into view.
this.treeNative.makeVisible(node)
}//}}}
}
class Crumbs {
constructor() {// {{{
this.crumbs = []
this.crumbsDiv = document.createElement('div')
this.crumbsDiv.classList.add('crumbs')
_mbus.subscribe('CRUMBS_SET', event => {
this.crumbs = event.detail.data
})
}// }}}
render() {// {{{
const crumbs = this.crumbs.map(node =>
(new Crumb(
node.get('Name'),
node.UUID,
)).render()
)
const start = (new Crumb('Start', ROOT_NODE)).render()
crumbs.push(start)
this.crumbsDiv.replaceChildren(...crumbs.reverse())
return this.crumbsDiv
}// }}}
}
class Crumb {
constructor(label, uuid) {// {{{
this.label = label
this.uuid = uuid
}// }}}
render() {// {{{
const crumb = document.createElement('div')
crumb.classList.add('crumb')
const link = document.createElement('a')
link.href = `/notes2#${this.uuid}`
link.innerText = this.label
crumb.appendChild(link)
return crumb
}// }}}
}
function tmpl(html) {// {{{
const el = document.createElement('template')
el.innerHTML = html
return el.content.children
}// }}}
class Op {
constructor(id) {// {{{
this.id = id
_mbus.subscribe(this.id, p => this.render(p))
}// }}}
render(html) {// {{{
const op = document.getElementById('op')
const t = document.createElement('template')
t.innerHTML = `<dialog id="${this.id}" class="op">${html}</dialog>`
op.replaceChildren(t.content)
document.getElementById(this.id).showModal()
}// }}}
get(selector) {// {{{
return document.querySelector(`#${this.id} ${selector}`)
}// }}}
bind(selector, event, fn) {// {{{
this.get(selector).addEventListener(event, evt => fn(evt))
}// }}}
}
class OpSearch extends Op {
constructor() {// {{{
super('op-search')
}// }}}
render() {// {{{
super.render(`
<div class="header">Search</div>
<div>
<input type="text" />
</div>
<div class="header">Results</div>
<div class="results"></div>
`)
this.bind('input[type="text"]', 'keydown', evt => this.search(evt))
}// }}}
search(event) {// {{{
if (event.key !== 'Enter')
return
const searchFor = document.querySelector('#op-search input').value
nodeStore.search(searchFor, ROOT_NODE)
.then(res => this.displayResults(res))
}// }}}
displayResults(results) {// {{{
const rs = []
for (const r of results) {
const ancestors = r.ancestry.reverse().map(a => {
const div = tmpl(`<div class="ancestor">${a.data.Name}</div>`)
div[0].addEventListener('click', () => _notes2.current.goToNode(a.UUID))
return div[0]
})
const div = tmpl(`<div>${r.name}</div>`)
div[0].addEventListener('click', () => _notes2.current.goToNode(r.uuid))
rs.push(...div)
const ancDev = tmpl('<div class="ancestors"></div>')
ancDev[0].append(...ancestors)
rs.push(ancDev[0])
}
this.get('.results').replaceChildren(...rs)
}// }}}
}
// vim: foldmethod=marker

View file

@ -0,0 +1,57 @@
export class CustomHTMLElement extends HTMLElement {
constructor() {// {{{
super()
this.appendChild(this.constructor.tmpl.content.cloneNode(true))
this.querySelectorAll('*').forEach(el => {
const field = el.dataset.field
if (field !== undefined) {
const fieldName = this.toElementName('field', field)
this[fieldName] = el
}
const name = el.dataset.el
if (name !== undefined) {
const elName = this.toElementName('el', name)
this[elName] = el
el.classList.add('el-' + name)
}
})
}// }}}
toElementName(prefix, str) {// {{{
str = prefix + '-' + str
return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', ''))
}// }}}
}
export class StupidPreactCustomHTMLElement extends HTMLElement {
constructor() {// {{{
super()
// Stupid stuff because of Preact.
this.clonedNodes = this.constructor.tmpl.content.cloneNode(true)
this.clonedNodes.querySelectorAll('*').forEach(el => {
const field = el.dataset.field
if (field !== undefined) {
const fieldName = this.toElementName('field', field)
this[fieldName] = el
}
const name = el.dataset.el
if (name !== undefined) {
const elName = this.toElementName('el', name)
this[elName] = el
el.classList.add('el-' + name)
}
})
}// }}}
toElementName(prefix, str) {// {{{
str = prefix + '-' + str
return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', ''))
}// }}}
connectedCallback() {// {{{
// Stupid stuff because of Preact.
this.appendChild(this.clonedNodes)
}// }}}
}

View file

@ -1,17 +1,48 @@
export class MessageBus { export class MessageBus {
constructor() { constructor() {
this.log = false
this.bus = new EventTarget() this.bus = new EventTarget()
} }
subscribe(eventName, fn) { subscribe(eventName, fn) {
this.bus.addEventListener(eventName, fn) if (this.log) {
console.groupCollapsed('MBUS subscribe - ', eventName);
console.trace(); // hidden in collapsed group
console.groupEnd();
}
this.bus.addEventListener(eventName, event=>{
fn(event)
if (event.detail.callback !== undefined)
event.detail.callback(event)
})
} }
unsubscribe(eventName, fn) { unsubscribe(eventName, fn) {
if (this.log) {
console.groupCollapsed('MBUS unsubscribe - ', eventName);
console.trace(); // hidden in collapsed group
console.groupEnd();
}
this.bus.removeEventListener(eventName, fn) this.bus.removeEventListener(eventName, fn)
} }
dispatch(eventName, data) { dispatch(eventName, data, callback) {
this.bus.dispatchEvent(new CustomEvent(eventName, { detail: data })) if (this.log) {
console.groupCollapsed('MBUS dispatch - ', eventName);
console.log('data', data);
console.trace(); // hidden in collapsed group
console.groupEnd();
}
const event = new CustomEvent(eventName, {
detail: {
data,
callback,
}
})
this.bus.dispatchEvent(event)
} }
} }

View file

@ -54,7 +54,7 @@ export class NodeUI extends Component {
${crumbDivs} ${crumbDivs}
</div> </div>
</div> </div>
<${SyncProgress} ref=${this.syncProgress} /> <div id="sync-progress"></div>
<div id="name">${node.get('Name')}</div> <div id="name">${node.get('Name')}</div>
<${NodeContent} key=${node.UUID} node=${node} ref=${this.nodeContent} /> <${NodeContent} key=${node.UUID} node=${node} ref=${this.nodeContent} />
<div id="blank"></div> <div id="blank"></div>
@ -156,8 +156,12 @@ export class NodeUI extends Component {
` `
}//}}} }//}}}
async componentDidMount() {//{{{ async componentDidMount() {//{{{
console.log('hum')
_notes2.current.goToNode(this.props.startNode.UUID, true) _notes2.current.goToNode(this.props.startNode.UUID, true)
_notes2.current.tree.expandToTrunk(this.props.startNode) _notes2.current.tree.expandToTrunk(this.props.startNode)
const syncProgressEl = document.getElementById('#sync-progress')
console.log(syncProgressEl)
}//}}} }//}}}
setNode(node) {//{{{ setNode(node) {//{{{
this.nodeModified.value = false this.nodeModified.value = false
@ -331,16 +335,83 @@ class NodeContent extends Component {
}//}}} }//}}}
} }
export class NodeUINative {
constructor(parentElement) {// {{{
this.node = null
this.parent = parentElement
this.parent.replaceChildren(this.createElements())
_mbus.subscribe('NODE_UI_OPEN', event => {
this.node = event.detail.data
this.render()
})
_mbus.subscribe('NODE_MODIFIED', () => {
document.querySelector('#crumbs .crumbs')?.classList.add('node-modified')
})
_mbus.subscribe('NODE_UNMODIFIED', () => {
document.querySelector('#crumbs .crumbs')?.classList.remove('node-modified')
})
}// }}}
createElements() {// {{{
const tmpl = document.createElement('template')
tmpl.innerHTML = `
<div id="name"></div>
<div class="grow-wrap" style="display: none">
<textarea id="node-content" class="node-content" required rows=1></textarea>
</div>
`
tmpl.content.querySelector('#node-content').addEventListener('input', event => this.contentChanged(event))
return tmpl.content
}// }}}
render() {// {{{
this.parent.querySelector('.grow-wrap').style.display = (this.node === null ? 'none' : 'grid')
this.parent.querySelector('#name').innerText = this.node?.get('Name') ?? ''
this.parent.querySelector('#node-content').value = this.node?.get('Content') ?? ''
this.resize()
return this.parent
}// }}}
resize() {//{{{
const textarea = this.parent.querySelector('#node-content')
textarea.parentNode.dataset.replicatedValue = textarea.value
}//}}}
contentChanged(event) {//{{{
this.node.setContent(event.target.value)
this.resize()
}//}}}
isModified() {// {{{
return this.node?.isModified()
}// }}}
}
export class Node { export class Node {
static sort(a, b) {//{{{ static sort(a, b) {//{{{
if (a.data.Name < b.data.Name) return -1 if (a.data.Name < b.data.Name) return -1
if (a.data.Name > b.data.Name) return 0 if (a.data.Name > b.data.Name) return 0
return 0 return 0
}//}}} }//}}}
static create(name, parentUUID) {
return new Node({
UUID: uuidv7(),
Created: (new Date()).toISOString(),
Content: '',
Name: name,
ParentUUID: parentUUID,
Markdown: false,
History: false,
})
}
constructor(nodeData, level) {//{{{ constructor(nodeData, level) {//{{{
this.Level = level this.Level = level
this.data = nodeData this.data = nodeData
this.UUID = nodeData.UUID this.UUID = nodeData.UUID
// Toplevel nodes are normalized to have the ROOT_NODE as parent. // Toplevel nodes are normalized to have the ROOT_NODE as parent.
@ -354,13 +425,12 @@ export class Node {
this.Children = [] this.Children = []
this.Ancestors = [] this.Ancestors = []
this._content = this.data.Content
this._modified = false
this._sibling_before = null this._sibling_before = null
this._sibling_after = null this._sibling_after = null
this._parent = null this._parent = null
this.reset()
/* /*
this.RenderMarkdown = signal(nodeData.RenderMarkdown) this.RenderMarkdown = signal(nodeData.RenderMarkdown)
this.Markdown = false this.Markdown = false
@ -377,6 +447,10 @@ export class Node {
*/ */
}//}}} }//}}}
reset() {// {{{
this._content = this.data.Content
this._modified = false
}// }}}
get(prop) {//{{{ get(prop) {//{{{
return this.data[prop] return this.data[prop]
}//}}} }//}}}
@ -384,6 +458,9 @@ export class Node {
// '2024-12-17T17:33:48.85939Z // '2024-12-17T17:33:48.85939Z
return new Date(Date.parse(this.data.Updated)) return new Date(Date.parse(this.data.Updated))
}//}}} }//}}}
isModified() {// {{{
return this._modified
}// }}}
hasFetchedChildren() {//{{{ hasFetchedChildren() {//{{{
return this._children_fetched return this._children_fetched
}//}}} }//}}}
@ -408,7 +485,8 @@ export class Node {
} }
// Notify the tree that all children are fetched and ready to process. // Notify the tree that all children are fetched and ready to process.
_notes2.current.tree.fetchChildrenOn(this.UUID) //_notes2.current.tree.fetchChildrenOn(this.UUID)
_mbus.dispatch(`NODE_CHILDREN_FETCHED_${this.UUID}`)
return this.Children return this.Children
}//}}} }//}}}
@ -435,12 +513,12 @@ export class Node {
if (this.CryptoKeyID != 0 && !this._decrypted) if (this.CryptoKeyID != 0 && !this._decrypted)
this.#decrypt() this.#decrypt()
*/ */
this.modified = true
return this._content return this._content
}//}}} }//}}}
setContent(new_content) {//{{{ setContent(new_content) {//{{{
this._content = new_content this._content = new_content
this._modified = true
_mbus.dispatch('NODE_MODIFIED')
/* TODO - implement crypto /* TODO - implement crypto
if (this.CryptoKeyID == 0) if (this.CryptoKeyID == 0)
// Logic behind plaintext not being decrypted is that // Logic behind plaintext not being decrypted is that
@ -455,6 +533,8 @@ export class Node {
this.data.Updated = new Date().toISOString() this.data.Updated = new Date().toISOString()
this._modified = false this._modified = false
_mbus.dispatch('NODE_UNMODIFIED')
// When stored into database and ancestry was changed, // When stored into database and ancestry was changed,
// the ancestry path could be interesting. // the ancestry path could be interesting.
const ancestors = await nodeStore.getNodeAncestry(this) const ancestors = await nodeStore.getNodeAncestry(this)
@ -462,4 +542,30 @@ export class Node {
}//}}} }//}}}
} }
function uuidv7() {
// random bytes
const value = new Uint8Array(16)
crypto.getRandomValues(value)
// current timestamp in ms
const timestamp = BigInt(Date.now())
// timestamp
value[0] = Number((timestamp >> 40n) & 0xffn)
value[1] = Number((timestamp >> 32n) & 0xffn)
value[2] = Number((timestamp >> 24n) & 0xffn)
value[3] = Number((timestamp >> 16n) & 0xffn)
value[4] = Number((timestamp >> 8n) & 0xffn)
value[5] = Number(timestamp & 0xffn)
// version and variant
value[6] = (value[6] & 0x0f) | 0x70
value[8] = (value[8] & 0x3f) | 0x80
const str = Array.from(value)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}`
}
// vim: foldmethod=marker // vim: foldmethod=marker

View file

@ -159,6 +159,7 @@ export class NodeStore {
}) })
}//}}} }//}}}
/* TODO - Remove?
async storeNode(node) {//{{{ async storeNode(node) {//{{{
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const t = this.db.transaction('nodes', 'readwrite') const t = this.db.transaction('nodes', 'readwrite')
@ -181,6 +182,7 @@ export class NodeStore {
} }
}) })
}//}}} }//}}}
*/
async upsertNodeRecords(records) {//{{{ async upsertNodeRecords(records) {//{{{
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View file

@ -3,6 +3,7 @@ import { signal } from 'preact/signals'
import htm from 'htm' import htm from 'htm'
import { Node, NodeUI } from 'node' import { Node, NodeUI } from 'node'
import { ROOT_NODE } from 'node_store' import { ROOT_NODE } from 'node_store'
import { TreeNative } from 'tree'
const html = htm.bind(h) const html = htm.bind(h)
export class Notes2 extends Component { export class Notes2 extends Component {
@ -14,6 +15,7 @@ export class Notes2 extends Component {
startNode: null, startNode: null,
} }
this.op = signal('') this.op = signal('')
this.treeNative = new TreeNative()
window._sync = new Sync() window._sync = new Sync()
window._sync.run() window._sync.run()
@ -76,6 +78,7 @@ export class Notes2 extends Component {
this.nodeUI.current.setNode(node) this.nodeUI.current.setNode(node)
this.nodeUI.current.setCrumbs(ancestors) this.nodeUI.current.setCrumbs(ancestors)
this.tree.setSelected(node, dontExpand) this.tree.setSelected(node, dontExpand)
this.treeNative.setSelected(node, dontExpand)
}//}}} }//}}}
logout() {//{{{ logout() {//{{{
localStorage.removeItem('session.UUID') localStorage.removeItem('session.UUID')
@ -107,6 +110,7 @@ class Tree extends Component {
this.treeNodeComponents[node.UUID] = createRef() this.treeNodeComponents[node.UUID] = createRef()
return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${this} node=${node} ref=${this.treeNodeComponents[node.UUID]} selected=${node.UUID === app.state.startNode?.UUID} />` return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${this} node=${node} ref=${this.treeNodeComponents[node.UUID]} selected=${node.UUID === app.state.startNode?.UUID} />`
}) })
return html` return html`
<div id="tree" ref=${this.treeDiv} tabindex="0"> <div id="tree" ref=${this.treeDiv} tabindex="0">
<div id="logo" onclick=${() => _notes2.current.goToNode(ROOT_NODE)}><img src="/images/${_VERSION}/logo.svg" /></div> <div id="logo" onclick=${() => _notes2.current.goToNode(ROOT_NODE)}><img src="/images/${_VERSION}/logo.svg" /></div>
@ -118,7 +122,7 @@ class Tree extends Component {
</div>` </div>`
}//}}} }//}}}
componentDidMount() {//{{{ componentDidMount() {//{{{
this.treeDiv.current.addEventListener('keydown', event => this.keyHandler(event)) //this.treeDiv.current.addEventListener('keydown', event => this.keyHandler(event))
// This will show and select the treenode that is selected in the node UI. // This will show and select the treenode that is selected in the node UI.
const node = _notes2.current?.nodeUI.current?.node.value const node = _notes2.current?.nodeUI.current?.node.value

View file

@ -1,31 +1,11 @@
import { API } from 'api' import { API } from 'api'
import { Node } from 'node' import { Node } from 'node'
import { h, Component } from 'preact'
import htm from 'htm'
const html = htm.bind(h)
const SYNC_COUNT = 1
const SYNC_HANDLED = 2
const SYNC_DONE = 3
export class Sync { export class Sync {
constructor() {//{{{ constructor() {//{{{
this.listeners = [] this.listeners = []
this.messagesReceived = [] this.messagesReceived = []
}//}}} }//}}}
addListener(fn, runMessageQueue) {//{{{
// Some handlers won't be added until a time after sync messages have been added to the queue.
// This is an opportunity for the handler to receive the old messages in order.
if (runMessageQueue)
for (const msg of this.messagesReceived)
fn(msg)
this.listeners.push(fn)
}//}}}
pushMessage(msg) {//{{{
this.messagesReceived.push(msg)
for (const fn of this.listeners)
fn(msg)
}//}}}
async run() {//{{{ async run() {//{{{
try { try {
@ -38,8 +18,8 @@ export class Sync {
let nodeCount = await this.getNodeCount(oldMax) let nodeCount = await this.getNodeCount(oldMax)
nodeCount += await nodeStore.sendQueue.count() nodeCount += await nodeStore.sendQueue.count()
const msg = { op: SYNC_COUNT, count: nodeCount }
this.pushMessage(msg) _mbus.dispatch('SYNC_COUNT', { count: nodeCount })
await this.nodesFromServer(oldMax) await this.nodesFromServer(oldMax)
.then(durationNodes => { .then(durationNodes => {
@ -49,7 +29,7 @@ export class Sync {
await this.nodesToServer() await this.nodesToServer()
} finally { } finally {
this.pushMessage({ op: SYNC_DONE }) _mbus.dispatch('SYNC_DONE')
} }
}//}}} }//}}}
async getNodeCount(oldMax) {//{{{ async getNodeCount(oldMax) {//{{{
@ -60,6 +40,7 @@ export class Sync {
async nodesFromServer(oldMax) {//{{{ async nodesFromServer(oldMax) {//{{{
const syncStart = Date.now() const syncStart = Date.now()
let syncEnd let syncEnd
let handled = 0
try { try {
let currMax = oldMax let currMax = oldMax
let offset = 0 let offset = 0
@ -86,9 +67,14 @@ export class Sync {
for (const i in res.Nodes) { for (const i in res.Nodes) {
backendNode = new Node(res.Nodes[i], -1) backendNode = new Node(res.Nodes[i], -1)
await window._sync.handleNode(backendNode) await window._sync.handleNode(backendNode)
handled++
if (handled % 100 === 0)
_mbus.dispatch('SYNC_HANDLED', { handled })
} }
} while (res.Continue) } while (res.Continue)
_mbus.dispatch('SYNC_HANDLED', { handled })
nodeStore.setAppState('latest_sync_node', currMax) nodeStore.setAppState('latest_sync_node', currMax)
} catch (e) { } catch (e) {
@ -130,7 +116,7 @@ export class Sync {
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} finally { } finally {
this.pushMessage({ op: SYNC_HANDLED, count: 1 }) //_mbus.dispatch('SYNC_HANDLED', { count: 1 })
} }
}//}}} }//}}}
async nodesToServer() {//{{{ async nodesToServer() {//{{{
@ -149,7 +135,7 @@ export class Sync {
const res = await API.query('POST', '/sync/to_server', request) const res = await API.query('POST', '/sync/to_server', request)
if (!res.OK) { if (!res.OK) {
// TODO - implement better error management here. // TODO - implement better error management here.
console.log(res) console.error(res)
alert(res) alert(res)
return return
} }
@ -157,7 +143,7 @@ export class Sync {
// Nodes are archived on server and can now be deleted from the send queue. // Nodes are archived on server and can now be deleted from the send queue.
const keys = nodesToSend.map(node => node.ClientSequence) const keys = nodesToSend.map(node => node.ClientSequence)
await nodeStore.sendQueue.delete(keys) await nodeStore.sendQueue.delete(keys)
this.pushMessage({ op: SYNC_HANDLED, count: nodesToSend.length }) _mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length })
} catch (e) { } catch (e) {
console.trace(e) console.trace(e)
@ -168,11 +154,28 @@ export class Sync {
}//}}} }//}}}
} }
export class SyncProgress extends Component { export class SyncProgress {
constructor() {//{{{ constructor(parentEl) {//{{{
super()
this.reset() this.reset()
_mbus.subscribe('SYNC_COUNT', event => this.progressHandler(event))
_mbus.subscribe('SYNC_HANDLED', event => this.progressHandler(event))
_mbus.subscribe('SYNC_DONE', event => this.progressHandler(event))
this.el = this.createElements()
parentEl.replaceChildren(this.el)
}//}}} }//}}}
createElements() {
const div = document.createElement('div')
div.classList.add('container')
div.innerHTML = `
<progress min=0 max=137 value=0></progress>
<div class="count">0 / 0</div>
`
this.elProgress = div.querySelector('progress')
this.elCount = div.querySelector('.count')
return div
}
reset() {//{{{ reset() {//{{{
this.forceUpdateRequest = null this.forceUpdateRequest = null
this.state = { this.state = {
@ -182,9 +185,6 @@ export class SyncProgress extends Component {
} }
document.getElementById('sync-progress')?.classList.remove('hidden') document.getElementById('sync-progress')?.classList.remove('hidden')
}//}}} }//}}}
componentDidMount() {//{{{
window._sync.addListener(msg => this.progressHandler(msg), true)
}//}}}
getSnapshotBeforeUpdate(_, prevState) {//{{{ getSnapshotBeforeUpdate(_, prevState) {//{{{
if (!prevState.syncedDone && this.state.syncedDone) if (!prevState.syncedDone && this.state.syncedDone)
setTimeout(() => document.getElementById('sync-progress')?.classList.add('hidden'), 750) setTimeout(() => document.getElementById('sync-progress')?.classList.add('hidden'), 750)
@ -202,19 +202,22 @@ export class SyncProgress extends Component {
) )
} }
}//}}} }//}}}
progressHandler(msg) {//{{{ progressHandler(event) {//{{{
switch (msg.op) { const eventData = event.detail.data
case SYNC_COUNT: switch (event.type) {
this.setState({ nodesToSync: msg.count }) case 'SYNC_COUNT':
console.log(eventData.count)
this.state.nodesToSync = eventData.count
break break
case SYNC_HANDLED: case 'SYNC_HANDLED':
this.state.nodesSynced += msg.count console.log('sync handled')
this.state.nodesSynced = eventData.handled
break break
case SYNC_DONE: case 'SYNC_DONE':
// Hides the progress bar. // Hides the progress bar.
this.setState({ syncedDone: true }) this.state.syncedDone = true
// Don't update anything if nothing was synced. // Don't update anything if nothing was synced.
if (this.state.nodesSynced === 0) if (this.state.nodesSynced === 0)
@ -227,17 +230,19 @@ export class SyncProgress extends Component {
} }
break break
} }
this.render()
}//}}} }//}}}
render(_, { nodesToSync, nodesSynced }) {//{{{ render() {//{{{
console.log('render', this.state.nodesToSync)
this.elProgress.max = this.state.nodesToSync
this.elProgress.value = this.state.nodesSynced
this.elCount.innerText = `${this.state.nodesSynced} / ${this.state.nodesToSync}`
/*
if (nodesToSync === 0) if (nodesToSync === 0)
return html`<div id="sync-progress"></div>` return html`<div id="sync-progress"></div>`
*/
return html`
<div id="sync-progress">
<progress min=0 max=${nodesToSync} value=${nodesSynced}></progress>
<div class="count">${nodesSynced} / ${nodesToSync}</div>
</div>
`
}//}}} }//}}}
} }

438
static/js/tree.mjs Normal file
View file

@ -0,0 +1,438 @@
import { ROOT_NODE } from 'node_store'
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
export class TreeNative {
constructor() {// {{{
this.treeNodeComponents = {}
this.treeTrunk = []
this.expandedNodes = {} // keyed on UUID
this.selectedNode = null
this.rendered = false
this.populateFirstLevel()
}// }}}
render() {// {{{
if (this.rendered)
alert('Tree should only be rendered once.')
const tmpl = document.createElement('template')
tmpl.innerHTML = `
<div id="tree-nodes" tabindex=0>
<div id="logo"><img src="/images/${_VERSION}/logo.svg" /></div>
<div class="icons">
<img class='search' src="/images/${_VERSION}/icon_search.svg" style="height: 22px" />
<img class='sync' src="/images/${_VERSION}/icon_refresh.svg" />
</div>
<div>`
const treeEl = tmpl.content.getElementById('tree-nodes')
treeEl.addEventListener('keydown', event => this.keyHandler(event))
tmpl.content.querySelector('.icons .search').addEventListener('click', () => _mbus.dispatch('op-search'))
tmpl.content.querySelector('.icons .sync').addEventListener('click', () => _sync.run())
tmpl.content.getElementById('logo').addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false))
for (const node of this.treeTrunk) {
const treenode = new N2TreeNode(this, node)
this.treeNodeComponents[node.UUID] = treenode
treeEl.appendChild(treenode.render())
}
this.rendered = true
return tmpl.content
}// }}}
populateFirstLevel() {//{{{
nodeStore.get(ROOT_NODE)
.then(node => node.fetchChildren())
.then(children => {
this.treeNodeComponents = {}
this.treeTrunk = []
for (const node of children) {
// The root node isn't supposed to be shown in the tree.
if (node.UUID === ROOT_NODE)
continue
if (node.ParentUUID === ROOT_NODE)
this.treeTrunk.push(node)
}
_mbus.dispatch('TREE_TRUNK_FETCHED')
})
.catch(e => { console.log(e); console.log(e.type, e.error); alert(e.error) })
}//}}}
getNodeExpanded(UUID) {//{{{
if (this.expandedNodes[UUID] === undefined)
this.expandedNodes[UUID] = false
return this.expandedNodes[UUID]
}//}}}
setNodeExpanded(node, value) {//{{{
let expanded = this.expandedNodes[node.UUID]
if (expanded === undefined) {
this.expandedNodes[node.UUID] = false
expanded = false
}
if (expanded === value)
return
this.expandedNodes[node.UUID] = value
_mbus.dispatch(`NODE_EXPAND_${node.UUID}`, value)
}//}}}
setSelected(node, dontExpand) {//{{{
if (node === undefined)
return
// The previously selected node, if any, needs to be rerendered
// to not retain its 'selected' class.
const prevUUID = this.selectedNode?.UUID
this.selectedNode = node
if (prevUUID)
this.treeNodeComponents[prevUUID]?.render(true)
// And now the newly selected node is rerendered.
this.treeNodeComponents[node.UUID]?.render(true)
if (!dontExpand)
this.setNodeExpanded(node, true)
}//}}}
isSelected(node) {//{{{
return this.selectedNode?.UUID === node.UUID
}//}}}
async keyHandler(event) {//{{{
let handled = true
const n = this.selectedNode
const Space = ' '
// This handler would otherwise react to stuff like Ctrl+L.
if (event.ctrlKey || event.altKey)
return
switch (event.key) {
// Space and enter is toggling expansion.
// Holding shift down does it recursively.
case Space:
case 'Enter':
const expanded = this.getNodeExpanded(n.UUID)
if (event.shiftKey) {
this.recursiveExpand(n, !expanded)
} else {
this.setNodeExpanded(n, !expanded)
}
break
case 'g':
case 'Home':
this.navigateTop()
break
case 'G':
case 'End':
this.navigateBottom()
break
case 'j':
case 'ArrowDown':
await this.navigateDown(this.selectedNode)
break
case 'k':
case 'ArrowUp':
await this.navigateUp(this.selectedNode)
break
case 'h':
case 'ArrowLeft':
await this.navigateLeft(this.selectedNode)
break
case 'l':
case 'ArrowRight':
await this.navigateRight(this.selectedNode)
break
default:
// nonsole.log(event.key)
handled = false
}
if (handled) {
event.preventDefault()
event.stopPropagation()
}
}//}}}
async navigateLeft(n) {//{{{
if (n === null || n === undefined)
return
const expanded = this.getNodeExpanded(n.UUID)
if (expanded && n.hasChildren()) {
this.setNodeExpanded(n, false)
return
}
if (n.isFirstSibling() && n.getParent().UUID !== ROOT_NODE) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getParent()?.UUID, dontPush: true, dontExpand: true })
return
}
const siblingBefore = n.getSiblingBefore()
const siblingExpanded = this.getNodeExpanded(siblingBefore?.UUID)
if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
const siblingAbove = this.getLastExpandedNode(siblingBefore)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingAbove?.UUID, dontPush: true, dontExpand: true })
return
}
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingBefore()?.UUID, dontPush: true, dontExpand: true })
}//}}}
async navigateRight(n) {//{{{
if (n === null || n === undefined)
return
const siblingAfter = n.getSiblingAfter()
const expanded = this.getNodeExpanded(n.UUID)
if (!expanded && n.hasChildren()) {
this.setNodeExpanded(n, true)
return
}
if (expanded && n.hasChildren()) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0]?.UUID, dontPush: true, dontExpand: true })
return
}
if (n.isLastSibling()) {
const nextNode = this.getParentWithNextSibling(n)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: nextNode?.UUID, dontPush: true, dontExpand: true })
return
}
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: true, dontExpand: true })
}//}}}
async navigateUp(n) {//{{{
if (n === null || n === undefined)
return
let parent = null
const siblingBefore = n.getSiblingBefore()
let siblingExpanded = false
if (siblingBefore !== null)
siblingExpanded = this.getNodeExpanded(siblingBefore.UUID)
if (n.isFirstSibling()) {
parent = n.getParent()
if (parent?.UUID === ROOT_NODE)
return
_mbus.dispatch("GO_TO_NODE", { nodeUUID: parent?.UUID, dontPush: true, dontExpand: true })
return
}
if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, dontPush: true, dontExpand: true })
return
}
if (siblingBefore) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.UUID, dontPush: true, dontExpand: true })
return
}
}//}}}
async navigateDown(n) {//{{{
if (n === null || n === undefined)
return
const nodeExpanded = this.getNodeExpanded(n.UUID)
// Last node, not expanded, so it matters not whether it has children or not.
// Traverse upward to nearest parent with next sibling.
if (!nodeExpanded && n.isLastSibling()) {
const wantedNode = this.getParentWithNextSibling(n)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: true, dontExpand: true })
return
}
if (nodeExpanded && n.isLastSibling() && !n.hasChildren()) {
const wantedNode = this.getParentWithNextSibling(n)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: true, dontExpand: true })
return
}
// Node not expanded. Go to this node's next sibling.
// GoToNode will abort if given null.
if (!nodeExpanded || !n.hasChildren()) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: true, dontExpand: true })
return
}
// Node is expanded.
// Children will be visually beneath this node, if any.
if (nodeExpanded && n.hasChildren()) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0].UUID, dontPush: true, dontExpand: true })
return
}
}//}}}
async navigateTop() {//{{{
const root = await nodeStore.get(ROOT_NODE)
if (root.Children.length === 0)
return
_mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[0]?.UUID, dontPush: true, dontExpand: true })
}//}}}
async navigateBottom() {//{{{
const root = await nodeStore.get(ROOT_NODE)
if (root.Children.length === 0)
return
const toplevel = root.Children[root.Children.length - 1]
const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID)
if (toplevelExpanded) {
const lastnode = this.getLastExpandedNode(toplevel)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: true, dontExpand: true })
} else
_mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[root.Children.length - 1]?.UUID, dontPush: true, dontExpand: true })
}//}}}
getParentWithNextSibling(node) {//{{{
let currNode = node
while (currNode !== null && currNode.UUID !== ROOT_NODE && currNode.getSiblingAfter() === null) {
currNode = currNode.getParent()
}
return currNode?.getSiblingAfter()
}//}}}
getLastExpandedNode(node) {//{{{
let currNode = node
while (this.getNodeExpanded(currNode.UUID) && currNode.hasChildren()) {
currNode = currNode.Children[currNode.Children.length - 1]
}
return currNode
}//}}}
async recursiveExpand(node, state) {//{{{
if (state)
await this.setNodeExpanded(node, true)
for (const child of node.Children)
await this.recursiveExpand(child, state)
if (!state)
await this.setNodeExpanded(node, false)
}//}}}
async makeVisible(node) {// {{{
const treenode = this.treeNodeComponents[node.UUID]
const ancestors = await nodeStore.getNodeAncestry(node)
for (const ancestor of ancestors.reverse()) {
this.setNodeExpanded(ancestor, true)
}
// The ROOT_NODE for example hasn't got a treenode.
treenode?.scrollIntoView({ block: 'nearest' })
}// }}}
}
export class N2TreeNode extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<div data-el="expand-toggle" class="expand-toggle">
<img data-el="expand">
</div>
<div data-el="name" class="name"></div>
<div data-el="children" class="children"></div>
`
}// }}}
constructor(tree, node, parent) {//{{{
super()
this.classList.add('node')
this.tree = tree
this.node = node
this.parent = parent
this.children_populated = false
this.rendered = false
this.elExpandToggle.addEventListener('click', () => this.tree.setNodeExpanded(this.node, !this.tree.getNodeExpanded(this.node.UUID)))
this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node))
_mbus.subscribe(`NODE_CHILDREN_FETCHED_${node.UUID}`, () => {
this.render(true)
})
_mbus.subscribe(`NODE_EXPAND_${node.UUID}`, state => {
this.render(true)
})
if (this.node.Level === 0 || this.tree.getNodeExpanded(this.node.UUID))
this.fetchChildren()
}// }}}
async fetchChildren() {//{{{
await this.node.fetchChildren()
this.children_populated = true
}//}}}
render(force_update) {//{{{
if (this.rendered && force_update !== true)
return this
// Fetch the next level of children if the parent tree node is expanded and our children thus will be visible.
const expanded = this.node.Children.length > 0 && this.tree.getNodeExpanded(this.node.UUID)
if (!this.children_populated && this.tree.getNodeExpanded(this.parent?.node.UUID)) {
this.node.fetchChildren().then(() => this.children_populated = true)
}
// Update the name and selected status
this.elName.innerText = this.node.get('Name')
if (this.tree.isSelected(this.node))
this.elName.classList.add('selected')
else
this.elName.classList.remove('selected')
// Update expansion state
if (expanded) {
this.elChildren.classList.add('expanded')
this.elChildren.classList.remove('collapsed')
} else {
this.elChildren.classList.remove('expanded')
this.elChildren.classList.add('collapsed')
}
// The expand icon <img> is only changed to not get a flickering when re-rendering.
if (this.node.Children.length === 0)
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf.svg`)
else if (this.tree.getNodeExpanded(this.node.UUID))
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/expanded.svg`)
else
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/collapsed.svg`)
// Should children be rendered?
this.elChildren.innerHTML = ''
let children = []
if (expanded)
children = this.node.Children.map(node => {
let treenode = this.tree.treeNodeComponents[node.UUID]
if (treenode === undefined) {
treenode = new N2TreeNode(this.tree, node, this)
this.tree.treeNodeComponents[node.UUID] = treenode
}
return treenode
})
for (const c of children)
this.elChildren.appendChild(c.render())
this.rendered = true
return this
}//}}}
setImgSrc(img, newSrc) {// {{{
if (img.getAttribute('src') === newSrc)
return
img.setAttribute('src', newSrc)
}// }}}
}
customElements.define('n2-treenode', N2TreeNode)
// vim: foldmethod=marker

View file

@ -48,8 +48,8 @@ html {
#tree { #tree {
grid-area: tree; grid-area: tree;
padding: 16px 32px; display: grid;
background-color: #333; padding: 16px 0px 16px 16px;
color: #ddd; color: #ddd;
z-index: 100; // Over crumbs shadow z-index: 100; // Over crumbs shadow
border-left: 2px solid #333; border-left: 2px solid #333;
@ -62,9 +62,12 @@ html {
display: grid; display: grid;
position: relative; position: relative;
justify-items: center; justify-items: center;
margin-top: 8px;
margin-bottom: 8px; margin-bottom: 8px;
margin-left: 24px; margin-left: 24px;
margin-right: 24px; margin-right: 24px;
cursor: pointer;
img { img {
width: 128px; width: 128px;
left: -20px; left: -20px;
@ -124,12 +127,19 @@ html {
} }
} }
#tree-nodes {
padding: 16px 32px;
background-color: #333;
border-radius: 8px;
box-shadow: 5px 5px 10px -5px rgba(0,0,0,0.75);
}
#crumbs { #crumbs {
grid-area: crumbs; grid-area: crumbs;
display: grid; display: grid;
align-items: start; align-items: start;
justify-items: center; justify-items: center;
margin: 0px 16px; margin: 16px 16px;
.crumbs { .crumbs {
background: #e4e4e4; background: #e4e4e4;
@ -154,6 +164,11 @@ html {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
a {
text-decoration: none;
color: inherit;
}
} }
.crumb:after { .crumb:after {

View file

@ -1,3 +1,5 @@
@color1: #fe5f55; :root {
@color2: #efede8; --color1: #fe5f55;
@color3: #666; --color2: #efede8;
--color3: #666;
}

View file

@ -4,16 +4,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="initial-scale=1.0, user-scalable=yes" /> <meta name="viewport" content="initial-scale=1.0, user-scalable=yes" />
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/main.css"> <link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/main.css">
<script>
window._VERSION = "{{ .VERSION }}"
if (navigator.serviceWorker)
navigator.serviceWorker.register('/service_worker.js')
</script>
<script type="module" defer>
import { MessageBus } from '/js/{{ .VERSION }}/mbus.mjs'
window._mbus = new MessageBus()
</script>
<script type="importmap"> <script type="importmap">
{ {
"imports": { "imports": {
@ -33,7 +23,8 @@
"checklist": "/js/{{ .VERSION }}/checklist.mjs", "checklist": "/js/{{ .VERSION }}/checklist.mjs",
"crypto": "/js/{{ .VERSION }}/crypto.mjs", "crypto": "/js/{{ .VERSION }}/crypto.mjs",
"node_store": "/js/{{ .VERSION }}/node_store.mjs", "node_store": "/js/{{ .VERSION }}/node_store.mjs",
"node": "/js/{{ .VERSION }}/node.mjs" "node": "/js/{{ .VERSION }}/node.mjs",
"tree": "/js/{{ .VERSION }}/tree.mjs"
{{/* {{/*
"session": "/js/{{ .VERSION }}/session.mjs", "session": "/js/{{ .VERSION }}/session.mjs",
"ws": "/_js/{{ .VERSION }}/websocket.mjs" "ws": "/_js/{{ .VERSION }}/websocket.mjs"
@ -41,6 +32,16 @@
} }
} }
</script> </script>
<script>
window._VERSION = "{{ .VERSION }}"
if (navigator.serviceWorker)
navigator.serviceWorker.register('/service_worker.js')
</script>
<script type="module" defer>
import { MessageBus } from '/js/{{ .VERSION }}/mbus.mjs'
window._mbus = new MessageBus()
</script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/sjcl.js"></script> <script type="text/javascript" src="/js/{{ .VERSION }}/lib/sjcl.js"></script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/node_modules/marked/marked.min.js"></script> <script type="text/javascript" src="/js/{{ .VERSION }}/lib/node_modules/marked/marked.min.js"></script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/fullcalendar.min.js"></script> <script type="text/javascript" src="/js/{{ .VERSION }}/lib/fullcalendar.min.js"></script>

View file

@ -1,5 +1,16 @@
{{ define "page" }} {{ define "page" }}
<div id="notes2"></div> <div id="notes2">
<div id="tree" tabindex=0></div>
<div id="crumbs"></div>
<div id="sync-progress">
<progress min=0 max=1 value=0></progress>
<div class="count">0 / 1</div>
</div>
<div id="note"></div>
<!--div id="blank"></div-->
</div>
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/notes2.css"> <link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/notes2.css">
<script type="module"> <script type="module">
@ -10,7 +21,7 @@ import 'preact/debug'
import 'preact/devtools' import 'preact/devtools'
{{- end }} {{- end }}
import { NodeStore } from 'node_store' import { NodeStore } from 'node_store'
import { Notes2 } from "/js/{{ .VERSION }}/notes2.mjs" import { App } from "/js/{{ .VERSION }}/app.mjs"
import { API } from 'api' import { API } from 'api'
import { Sync } from 'sync' import { Sync } from 'sync'
@ -23,8 +34,7 @@ if (!API.hasAuthenticationToken()) {
try { try {
window.nodeStore = new NodeStore() window.nodeStore = new NodeStore()
window.nodeStore.initializeDB().then(() => { window.nodeStore.initializeDB().then(() => {
window._notes2 = createRef() window._app = new App()
render(html`<${Notes2} ref=${window._notes2} />`, document.getElementById('notes2'))
}) })
} catch (e) { } catch (e) {
alert(e) alert(e)