diff --git a/main.go b/main.go index a9ac728..e608601 100644 --- a/main.go +++ b/main.go @@ -25,7 +25,7 @@ import ( const VERSION = "v1" const CONTEXT_USER = 1 -const SYNC_PAGINATION = 100 +const SYNC_PAGINATION = 500 var ( FlagGenerate bool @@ -269,9 +269,11 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{ return } + /* Log.Debug("/sync/from_server", "num_nodes", len(nodes), "maxSeq", maxSeq) foo, _ := json.Marshal(nodes) os.WriteFile(fmt.Sprintf("/tmp/nodes-%d.json", offset), foo, 0644) + */ j, _ := json.Marshal(struct { OK bool @@ -288,7 +290,6 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{ user := getUser(r) changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) - Log.Debug("FOO", "UUID", user.ClientUUID, "changedFrom", changedFrom) count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID) if err != nil { Log.Error("/sync/from_server/count", "error", err) @@ -334,9 +335,14 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ 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, }) } // }}} diff --git a/sql/00006.sql b/sql/00006.sql index 6b0ea9b..453b260 100644 --- a/sql/00006.sql +++ b/sql/00006.sql @@ -1,16 +1 @@ -CREATE TABLE public.node_history ( - 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); +DROP INDEX public.node_uuid_idx; diff --git a/static/css/login.css b/static/css/login.css index 88a9140..7e19cb8 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -1,37 +1,44 @@ +@import "theme.css"; + #app { - display: grid; - justify-items: center; - margin-top: 128px; + display: grid; + justify-items: center; + margin-top: 128px; } + #logo { - margin-bottom: 48px; + margin-bottom: 48px; } + #box { - display: grid; - grid-gap: 16px 0; - justify-items: center; - width: 300px; - padding: 48px 0px; - background-color: #fff; - box-shadow: 0px 20px 52px -33px rgba(0, 0, 0, 0.75); - border-left: 8px solid #666; -} -#box input { - padding: 4px 8px; - font-size: 1em; - width: calc(100% - 64px); - border: 1px solid #aaa; - border-radius: 4px; -} -#box button { - padding: 6px 16px; - font-size: 1em; - border-radius: 4px; - border: none; - background-color: #fe5f55; - color: #fff; -} -#box #error { - color: #c33; - margin-top: 16px; + display: grid; + grid-gap: 16px 0; + justify-items: center; + width: 300px; + padding: 48px 0px; + background-color: #fff; + box-shadow: 0px 20px 52px -33px rgba(0,0,0,0.75); + border-left: 8px solid var(--color3); + + input { + padding: 4px 8px; + font-size: 1em; + width: calc(100% - 64px); + border: 1px solid #aaa; + border-radius: 4px; + } + + button { + padding: 6px 16px; + font-size: 1em; + border-radius: 4px; + border: none; + background-color: var(--color1); + color: #fff; + } + + #error { + color: #c33; + margin-top: 16px; + } } diff --git a/static/css/main.css b/static/css/main.css index 75f1925..a8924d9 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1,23 +1,29 @@ +@import "theme.css"; + html { - box-sizing: border-box; - background: #efede8; - font-family: "Liberation Mono", monospace; - font-size: 14px; - margin: 0px; - padding: 0px; + box-sizing: border-box; + background: var(--color2); + font-family: "Liberation Mono", monospace; + font-size: 14px; + margin: 0px; + padding: 0px; } + body { - margin: 0px; - padding: 0px; + margin: 0px; + padding: 0px; } + *, *:before, *:after { - box-sizing: inherit; + box-sizing: inherit; } + *:focus { - outline: none; + outline: none; } + [onClick] { - cursor: pointer; + cursor: pointer; } diff --git a/static/css/notes2.css b/static/css/notes2.css index b6b0963..adfa51d 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -1,264 +1,382 @@ +@import "theme.css"; + html { - background-color: #fff; + background-color: #fff; } + #notes2 { - min-height: 100vh; - display: grid; - grid-template-areas: "tree crumbs" "tree sync" "tree name" "tree content" "tree blank"; - grid-template-columns: min-content 1fr; - grid-template-rows: 48px 56px 48px min-content 1fr; -} -@media only screen and (max-width: 600px) { - #notes2 { - grid-template-areas: "crumbs" "sync" "name" "content" "blank"; - grid-template-columns: 1fr; - } - #notes2 #tree { - display: none; - } + min-height: 100vh; + + display: grid; + 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-rows: + 48px 56px 48px min-content 1fr; + + + @media only screen and (max-width: 600px) { + grid-template-areas: + "crumbs" + "sync" + "name" + "content" + /* + "checklist" + "schedule" + "files" + */ + "blank" + ; + grid-template-columns: 1fr; + + #tree { + display: none; + } + } } + #tree { - grid-area: tree; - padding: 16px 32px; - background-color: #333; - color: #ddd; - z-index: 100; - border-left: 2px solid #333; + grid-area: tree; + display: grid; + padding: 16px 0px 16px 16px; + color: #ddd; + z-index: 100; + /* Over crumbs shadow */ + border-left: 2px solid #333; + + &:focus { + border-left: 2px solid #FE5F55; + } + + #logo { + display: grid; + position: relative; + justify-items: center; + margin-top: 8px; + margin-bottom: 8px; + margin-left: 24px; + margin-right: 24px; + cursor: pointer; + + img { + width: 128px; + left: -20px; + + } + } + + .icons { + display: flex; + justify-content: center; + margin-bottom: 32px; + gap: 8px; + } + + .node { + display: grid; + grid-template-columns: 24px min-content; + grid-template-rows: + min-content 1fr; + margin-top: 12px; + + + .expand-toggle { + user-select: none; + + img { + width: 16px; + height: 16px; + } + } + + .name { + white-space: nowrap; + cursor: pointer; + user-select: none; + + &:hover { + color: var(--color1); + } + + &.selected { + color: var(--color1); + font-weight: bold; + } + + } + + .children { + padding-left: 24px; + margin-left: 8px; + border-left: 1px solid #444; + grid-column: 1 / -1; + + &.collapsed { + display: none; + } + } + } } -#tree:focus { - border-left: 2px solid #FE5F55; -} -#tree #logo { - display: grid; - position: relative; - justify-items: center; - margin-bottom: 8px; - margin-left: 24px; - margin-right: 24px; -} -#tree #logo img { - width: 128px; - left: -20px; -} -#tree .icons { - display: flex; - justify-content: center; - margin-bottom: 32px; - gap: 8px; -} -#tree .node { - display: grid; - grid-template-columns: 24px min-content; - grid-template-rows: min-content 1fr; - margin-top: 12px; -} -#tree .node .expand-toggle { - user-select: none; -} -#tree .node .expand-toggle img { - width: 16px; - height: 16px; -} -#tree .node .name { - white-space: nowrap; - cursor: pointer; - user-select: none; -} -#tree .node .name:hover { - color: #fe5f55; -} -#tree .node .name.selected { - color: #fe5f55; - font-weight: bold; -} -#tree .node .children { - padding-left: 24px; - margin-left: 8px; - border-left: 1px solid #444; - grid-column: 1 / -1; -} -#tree .node .children.collapsed { - 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 { - grid-area: crumbs; - display: grid; - align-items: start; - justify-items: center; - margin: 0px 16px; -} -#crumbs .crumbs { - display: flex; - flex-wrap: wrap; - padding: 8px 16px; - background: #e4e4e4; - color: #333; - border-bottom-left-radius: 5px; - border-bottom-right-radius: 5px; -} -#crumbs .crumbs.node-modified { - background-color: #fe5f55; - color: #efede8; -} -#crumbs .crumbs.node-modified .crumb:after { - color: #efede8; -} -#crumbs .crumbs .crumb { - margin-right: 8px; - cursor: pointer; - user-select: none; - -webkit-tap-highlight-color: transparent; -} -#crumbs .crumbs .crumb:after { - content: "•"; - margin-left: 8px; - color: #fe5f55; -} -#crumbs .crumbs .crumb:last-child { - margin-right: 0; -} -#crumbs .crumbs .crumb:last-child:after { - content: ''; - margin-left: 0px; + grid-area: crumbs; + display: grid; + align-items: start; + justify-items: center; + margin: 16px 16px; + + .crumbs { + background: #e4e4e4; + display: flex; + flex-wrap: wrap; + padding: 8px 16px; + background: #e4e4e4; + color: #333; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + + &.node-modified { + background-color: var(--color1); + color: var(--color2); + + .crumb:after { + color: var(--color2); + } + } + + .crumb { + margin-right: 8px; + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; + + a { + text-decoration: none; + color: inherit; + } + } + + .crumb:after { + content: "•"; + margin-left: 8px; + color: var(--color1) + } + + .crumb:last-child { + margin-right: 0; + } + + .crumb:last-child:after { + content: ''; + margin-left: 0px; + } + + } + } + #sync-progress { - grid-area: sync; - display: grid; - justify-items: center; - width: 100%; - height: 56px; - position: relative; -} -#sync-progress progress { - width: 100%; - padding: 0 7px; - max-width: 900px; - height: 16px; - border-radius: 4px; -} -#sync-progress progress[value]::-webkit-progress-bar { - background-color: #eee; - 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; - top: 22px; -} -#sync-progress.hidden { - visibility: hidden; - opacity: 0; - transition: visibility 0s 500ms, opacity 500ms linear; + --radius: 8px; + + grid-area: sync; + display: grid; + justify-items: center; + align-items: center; + + width: 100%; + height: 56px; + + .container { + position: relative; + + progress { + width: 900px; + padding: 0 7px; + max-width: 900px; + height: 24px; + border-radius: 8px; + } + + .count { + position: absolute; + top: 5px; + width: 100%; + white-space: nowrap; + color: #888; + text-align: center; + font-size: 12pt; + font-weight: bold; + } + + 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; + opacity: 0; + transition: visibility 0s 500ms, opacity 500ms linear; + } + } + #name { - color: #333; - font-weight: bold; - text-align: center; - font-size: 1.15em; - margin-top: 0px; - margin-bottom: 16px; + color: #333; + font-weight: bold; + text-align: center; + font-size: 1.15em; + margin-top: 0px; + margin-bottom: 16px; } + /* ============================================================= * * Textarea replicates the height of an element expanding height * * ============================================================= */ .grow-wrap { - /* 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; - grid-area: content; - font-size: 1em; + /* 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; + grid-area: content; + font-size: 1.0em; } + .grow-wrap::after { - /* Note the weird space! Needed to preventy jumpy behavior */ - content: attr(data-replicated-value) " "; - /* This is how textarea text behaves */ - width: calc(100% - 32px); - max-width: 900px; - white-space: pre-wrap; - word-wrap: break-word; - background: rgba(0, 255, 255, 0.5); - justify-self: center; - /* Hidden from view, clicks, and screen readers */ - visibility: hidden; + /* Note the weird space! Needed to preventy jumpy behavior */ + content: attr(data-replicated-value) " "; + + /* This is how textarea text behaves */ + width: calc(100% - 32px); + max-width: 900px; + white-space: pre-wrap; + word-wrap: break-word; + background: rgba(0, 255, 255, 0.5); + justify-self: center; + + /* Hidden from view, clicks, and screen readers */ + visibility: hidden; } -.grow-wrap > textarea { - /* You could leave this, but after a user resizes, then it ruins the auto sizing */ - resize: none; - /* Firefox shows scrollbar on growth, you can hide like this. */ - overflow: hidden; + +.grow-wrap>textarea { + /* You could leave this, but after a user resizes, then it ruins the auto sizing */ + resize: none; + + /* Firefox shows scrollbar on growth, you can hide like this. */ + overflow: hidden; } -.grow-wrap > textarea, + +.grow-wrap>textarea, .grow-wrap::after { - /* Identical styling required!! */ - padding: 0.5rem; - font: inherit; - /* Place on top of each other */ - grid-area: 1 / 1 / 2 / 2; + /* Identical styling required!! */ + padding: 0.5rem; + font: inherit; + + /* Place on top of each other */ + grid-area: 1 / 1 / 2 / 2; } + /* ============================================================= */ + #node-content { - justify-self: center; - word-wrap: break-word; - font-family: monospace; - color: #333; - width: calc(100% - 32px); - max-width: 900px; - resize: none; - border: none; - outline: none; -} -#node-content:invalid { - background: #f5f5f5; - padding-top: 16px; + justify-self: center; + word-wrap: break-word; + font-family: monospace; + color: #333; + width: calc(100% - 32px); + max-width: 900px; + resize: none; + border: none; + outline: none; + + &:invalid { + background: #f5f5f5; + padding-top: 16px; + } } + #blank { - grid-area: blank; - height: 32px; + grid-area: blank; + height: 32px; } -dialog.op::backdrop { - background: rgba(0, 0, 0, 0.5); + +dialog.op { + &::backdrop { + background: rgba(0, 0, 0, 0.5); + } + + .header { + font-weight: bold; + margin-top: 16px; + + &:first-child { + margin-top: 0px; + } + } + } -dialog.op .header { - font-weight: bold; - margin-top: 16px; -} -dialog.op .header:first-child { - margin-top: 0px; -} -#op-search .results { - display: grid; - grid-template-columns: min-content min-content; - grid-gap: 6px 16px; -} -#op-search .results div { - white-space: nowrap; -} -#op-search .results .ancestors { - display: flex; -} -#op-search .results .ancestors .ancestor::after { - content: ">"; - margin: 0px 8px; - color: #a00; -} -#op-search .results .ancestors .ancestor:last-child::after { - content: ""; + +#op-search { + .results { + display: grid; + grid-template-columns: min-content min-content; + grid-gap: 6px 16px; + + div { + white-space: nowrap; + } + + + .ancestors { + display: flex; + + .ancestor::after { + content: ">"; + margin: 0px 8px; + color: #a00; + } + + .ancestor:last-child::after { + content: ""; + } + } + } } diff --git a/static/css/theme.css b/static/css/theme.css index e69de29..b9c47ed 100644 --- a/static/css/theme.css +++ b/static/css/theme.css @@ -0,0 +1,5 @@ +:root { + --color1: #fe5f55; + --color2: #efede8; + --color3: #666; +} diff --git a/static/js/app.mjs b/static/js/app.mjs new file mode 100644 index 0000000..7926368 --- /dev/null +++ b/static/js/app.mjs @@ -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 = `${html}` + 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(` +
Search
+
+ +
+
Results
+
+ `) + + 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(`
${a.data.Name}
`) + div[0].addEventListener('click', () => _notes2.current.goToNode(a.UUID)) + return div[0] + }) + + + const div = tmpl(`
${r.name}
`) + div[0].addEventListener('click', () => _notes2.current.goToNode(r.uuid)) + rs.push(...div) + + const ancDev = tmpl('
') + ancDev[0].append(...ancestors) + rs.push(ancDev[0]) + } + this.get('.results').replaceChildren(...rs) + }// }}} +} + +// vim: foldmethod=marker diff --git a/static/js/lib/custom_html_element.mjs b/static/js/lib/custom_html_element.mjs new file mode 100644 index 0000000..dedb5d8 --- /dev/null +++ b/static/js/lib/custom_html_element.mjs @@ -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) + }// }}} +} diff --git a/static/js/mbus.mjs b/static/js/mbus.mjs index da5f098..0daa63c 100644 --- a/static/js/mbus.mjs +++ b/static/js/mbus.mjs @@ -1,17 +1,48 @@ export class MessageBus { constructor() { + this.log = false this.bus = new EventTarget() } 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) { + if (this.log) { + console.groupCollapsed('MBUS unsubscribe - ', eventName); + console.trace(); // hidden in collapsed group + console.groupEnd(); + } + this.bus.removeEventListener(eventName, fn) } - dispatch(eventName, data) { - this.bus.dispatchEvent(new CustomEvent(eventName, { detail: data })) + dispatch(eventName, data, callback) { + 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) } } diff --git a/static/js/node.mjs b/static/js/node.mjs index 92c8879..3820941 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -54,7 +54,7 @@ export class NodeUI extends Component { ${crumbDivs} - <${SyncProgress} ref=${this.syncProgress} /> +
${node.get('Name')}
<${NodeContent} key=${node.UUID} node=${node} ref=${this.nodeContent} />
@@ -156,8 +156,12 @@ export class NodeUI extends Component { ` }//}}} async componentDidMount() {//{{{ + console.log('hum') _notes2.current.goToNode(this.props.startNode.UUID, true) _notes2.current.tree.expandToTrunk(this.props.startNode) + + const syncProgressEl = document.getElementById('#sync-progress') + console.log(syncProgressEl) }//}}} setNode(node) {//{{{ 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 = ` +
+ + ` + + 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 { static sort(a, b) {//{{{ if (a.data.Name < b.data.Name) return -1 if (a.data.Name > b.data.Name) 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) {//{{{ + this.Level = level this.data = nodeData - this.UUID = nodeData.UUID // Toplevel nodes are normalized to have the ROOT_NODE as parent. @@ -354,13 +425,12 @@ export class Node { this.Children = [] this.Ancestors = [] - this._content = this.data.Content - this._modified = false - this._sibling_before = null this._sibling_after = null this._parent = null + this.reset() + /* this.RenderMarkdown = signal(nodeData.RenderMarkdown) this.Markdown = false @@ -377,6 +447,10 @@ export class Node { */ }//}}} + reset() {// {{{ + this._content = this.data.Content + this._modified = false + }// }}} get(prop) {//{{{ return this.data[prop] }//}}} @@ -384,6 +458,9 @@ export class Node { // '2024-12-17T17:33:48.85939Z return new Date(Date.parse(this.data.Updated)) }//}}} + isModified() {// {{{ + return this._modified + }// }}} hasFetchedChildren() {//{{{ return this._children_fetched }//}}} @@ -408,7 +485,8 @@ export class Node { } // 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 }//}}} @@ -435,12 +513,12 @@ export class Node { if (this.CryptoKeyID != 0 && !this._decrypted) this.#decrypt() */ - this.modified = true return this._content }//}}} - setContent(new_content) {//{{{ this._content = new_content + this._modified = true + _mbus.dispatch('NODE_MODIFIED') /* TODO - implement crypto if (this.CryptoKeyID == 0) // Logic behind plaintext not being decrypted is that @@ -455,6 +533,8 @@ export class Node { this.data.Updated = new Date().toISOString() this._modified = false + _mbus.dispatch('NODE_UNMODIFIED') + // When stored into database and ancestry was changed, // the ancestry path could be interesting. 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 diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index 80aa758..ef43233 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -159,6 +159,7 @@ export class NodeStore { }) }//}}} + /* TODO - Remove? async storeNode(node) {//{{{ return new Promise((resolve, reject) => { const t = this.db.transaction('nodes', 'readwrite') @@ -181,6 +182,7 @@ export class NodeStore { } }) }//}}} + */ async upsertNodeRecords(records) {//{{{ return new Promise((resolve, reject) => { diff --git a/static/js/notes2.mjs b/static/js/notes2.mjs index d73ef53..7bef2ad 100644 --- a/static/js/notes2.mjs +++ b/static/js/notes2.mjs @@ -3,6 +3,7 @@ import { signal } from 'preact/signals' import htm from 'htm' import { Node, NodeUI } from 'node' import { ROOT_NODE } from 'node_store' +import { TreeNative } from 'tree' const html = htm.bind(h) export class Notes2 extends Component { @@ -14,6 +15,7 @@ export class Notes2 extends Component { startNode: null, } this.op = signal('') + this.treeNative = new TreeNative() window._sync = new Sync() window._sync.run() @@ -76,6 +78,7 @@ export class Notes2 extends Component { this.nodeUI.current.setNode(node) this.nodeUI.current.setCrumbs(ancestors) this.tree.setSelected(node, dontExpand) + this.treeNative.setSelected(node, dontExpand) }//}}} logout() {//{{{ localStorage.removeItem('session.UUID') @@ -107,6 +110,7 @@ class Tree extends Component { 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`
@@ -118,7 +122,7 @@ class Tree extends Component {
` }//}}} 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. const node = _notes2.current?.nodeUI.current?.node.value @@ -538,13 +542,13 @@ class OpSearch extends Op { for (const r of results) { const ancestors = r.ancestry.reverse().map(a => { const div = tmpl(`
${a.data.Name}
`) - div[0].addEventListener('click', ()=>_notes2.current.goToNode(a.UUID)) + div[0].addEventListener('click', () => _notes2.current.goToNode(a.UUID)) return div[0] }) const div = tmpl(`
${r.name}
`) - div[0].addEventListener('click', ()=>_notes2.current.goToNode(r.uuid)) + div[0].addEventListener('click', () => _notes2.current.goToNode(r.uuid)) rs.push(...div) const ancDev = tmpl('
') diff --git a/static/js/sync.mjs b/static/js/sync.mjs index d66e27b..edc93ea 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -1,31 +1,11 @@ import { API } from 'api' 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 { constructor() {//{{{ this.listeners = [] 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() {//{{{ try { @@ -38,8 +18,8 @@ export class Sync { let nodeCount = await this.getNodeCount(oldMax) 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) .then(durationNodes => { @@ -49,7 +29,7 @@ export class Sync { await this.nodesToServer() } finally { - this.pushMessage({ op: SYNC_DONE }) + _mbus.dispatch('SYNC_DONE') } }//}}} async getNodeCount(oldMax) {//{{{ @@ -60,6 +40,7 @@ export class Sync { async nodesFromServer(oldMax) {//{{{ const syncStart = Date.now() let syncEnd + let handled = 0 try { let currMax = oldMax let offset = 0 @@ -86,9 +67,14 @@ export class Sync { for (const i in res.Nodes) { backendNode = new Node(res.Nodes[i], -1) await window._sync.handleNode(backendNode) + + handled++ + if (handled % 100 === 0) + _mbus.dispatch('SYNC_HANDLED', { handled }) } } while (res.Continue) + _mbus.dispatch('SYNC_HANDLED', { handled }) nodeStore.setAppState('latest_sync_node', currMax) } catch (e) { @@ -130,7 +116,7 @@ export class Sync { } catch (e) { console.error(e) } finally { - this.pushMessage({ op: SYNC_HANDLED, count: 1 }) + //_mbus.dispatch('SYNC_HANDLED', { count: 1 }) } }//}}} async nodesToServer() {//{{{ @@ -149,7 +135,7 @@ export class Sync { const res = await API.query('POST', '/sync/to_server', request) if (!res.OK) { // TODO - implement better error management here. - console.log(res) + console.error(res) alert(res) return } @@ -157,7 +143,7 @@ export class Sync { // Nodes are archived on server and can now be deleted from the send queue. const keys = nodesToSend.map(node => node.ClientSequence) await nodeStore.sendQueue.delete(keys) - this.pushMessage({ op: SYNC_HANDLED, count: nodesToSend.length }) + _mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length }) } catch (e) { console.trace(e) @@ -168,11 +154,28 @@ export class Sync { }//}}} } -export class SyncProgress extends Component { - constructor() {//{{{ - super() +export class SyncProgress { + constructor(parentEl) {//{{{ 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 = ` + +
0 / 0
+ ` + + this.elProgress = div.querySelector('progress') + this.elCount = div.querySelector('.count') + return div + } reset() {//{{{ this.forceUpdateRequest = null this.state = { @@ -182,9 +185,6 @@ export class SyncProgress extends Component { } document.getElementById('sync-progress')?.classList.remove('hidden') }//}}} - componentDidMount() {//{{{ - window._sync.addListener(msg => this.progressHandler(msg), true) - }//}}} getSnapshotBeforeUpdate(_, prevState) {//{{{ if (!prevState.syncedDone && this.state.syncedDone) setTimeout(() => document.getElementById('sync-progress')?.classList.add('hidden'), 750) @@ -202,19 +202,22 @@ export class SyncProgress extends Component { ) } }//}}} - progressHandler(msg) {//{{{ - switch (msg.op) { - case SYNC_COUNT: - this.setState({ nodesToSync: msg.count }) + progressHandler(event) {//{{{ + const eventData = event.detail.data + switch (event.type) { + case 'SYNC_COUNT': + console.log(eventData.count) + this.state.nodesToSync = eventData.count break - case SYNC_HANDLED: - this.state.nodesSynced += msg.count + case 'SYNC_HANDLED': + console.log('sync handled') + this.state.nodesSynced = eventData.handled break - case SYNC_DONE: + case 'SYNC_DONE': // Hides the progress bar. - this.setState({ syncedDone: true }) + this.state.syncedDone = true // Don't update anything if nothing was synced. if (this.state.nodesSynced === 0) @@ -227,17 +230,19 @@ export class SyncProgress extends Component { } 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) return html`
` + */ + - return html` -
- -
${nodesSynced} / ${nodesToSync}
-
- ` }//}}} } diff --git a/static/js/tree.mjs b/static/js/tree.mjs new file mode 100644 index 0000000..43649cc --- /dev/null +++ b/static/js/tree.mjs @@ -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 = ` +
+ +
+ + +
+
` + + 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 = ` +
+ +
+
+
+ ` + }// }}} + + 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 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 diff --git a/static/less/notes2.less b/static/less/notes2.less index a1ae783..2c57f53 100644 --- a/static/less/notes2.less +++ b/static/less/notes2.less @@ -48,8 +48,8 @@ html { #tree { grid-area: tree; - padding: 16px 32px; - background-color: #333; + display: grid; + padding: 16px 0px 16px 16px; color: #ddd; z-index: 100; // Over crumbs shadow border-left: 2px solid #333; @@ -62,9 +62,12 @@ html { display: grid; position: relative; justify-items: center; + margin-top: 8px; margin-bottom: 8px; margin-left: 24px; margin-right: 24px; + cursor: pointer; + img { width: 128px; 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 { grid-area: crumbs; display: grid; align-items: start; justify-items: center; - margin: 0px 16px; + margin: 16px 16px; .crumbs { background: #e4e4e4; @@ -154,6 +164,11 @@ html { cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; + + a { + text-decoration: none; + color: inherit; + } } .crumb:after { diff --git a/static/less/theme.less b/static/less/theme.less index 255a49c..b9c47ed 100644 --- a/static/less/theme.less +++ b/static/less/theme.less @@ -1,3 +1,5 @@ -@color1: #fe5f55; -@color2: #efede8; -@color3: #666; +:root { + --color1: #fe5f55; + --color2: #efede8; + --color3: #666; +} diff --git a/views/layouts/main.gotmpl b/views/layouts/main.gotmpl index 123fc68..470cfe5 100644 --- a/views/layouts/main.gotmpl +++ b/views/layouts/main.gotmpl @@ -4,16 +4,6 @@ - - + + diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index f633692..1a82a90 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,5 +1,16 @@ {{ define "page" }} -
+
+
+
+
+ +
0 / 1
+
+ +
+ +
+