From dd27be67b94c866c9c8e23b96828b1a537977988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Mon, 16 Jun 2025 21:30:46 +0200 Subject: [PATCH 01/11] Tree expansion and keyboard navigation works --- static/css/notes2.css | 5 + static/js/node.mjs | 3 +- static/js/notes2.mjs | 6 +- static/js/tree.mjs | 397 ++++++++++++++++++++++++++++++++++++++ static/less/notes2.less | 6 + views/layouts/main.gotmpl | 23 +-- views/pages/notes2.gotmpl | 6 + 7 files changed, 433 insertions(+), 13 deletions(-) create mode 100644 static/js/tree.mjs diff --git a/static/css/notes2.css b/static/css/notes2.css index b6b0963..74b6392 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -17,6 +17,11 @@ html { display: none; } } +/* +#tree-native { + grid-area: tree; +} +*/ #tree { grid-area: tree; padding: 16px 32px; diff --git a/static/js/node.mjs b/static/js/node.mjs index 92c8879..bd4dadd 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -408,7 +408,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 }//}}} diff --git a/static/js/notes2.mjs b/static/js/notes2.mjs index d73ef53..22820f0 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, TreeNodeNative } 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 diff --git a/static/js/tree.mjs b/static/js/tree.mjs new file mode 100644 index 0000000..3c0a1ff --- /dev/null +++ b/static/js/tree.mjs @@ -0,0 +1,397 @@ +import { ROOT_NODE } from 'node_store' + +export class TreeNative { + constructor() {// {{{ + this.treeNodeComponents = {} + this.treeTrunk = [] + this.expandedNodes = {} // keyed on UUID + this.selectedNode = null + this.rendered = false + + window._mbus.subscribe('TREE_TRUNK_FETCHED', ()=>{ + document.getElementById('tree').append(this.render()) + }) + + 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)) + + for (const node of this.treeTrunk) { + const treenode = new TreeNodeNative(this, node) + this.treeNodeComponents[node.UUID] = treenode + treeEl.appendChild(treenode.render()) + + //return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${this} node=${node} ref=${this.treeNodeComponents[node.UUID]} selected=${node.UUID === app.state.startNode?.UUID} />` + } + + 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) {//{{{ + // Creating a default value if it doesn't exist already. + this.getNodeExpanded(node.UUID) + this.expandedNodes[node.UUID] = value + _mbus.dispatch(`NODE_EXPAND_${node.UUID}`, value) + }//}}} + setSelected(node, dontExpand) {//{{{ + // 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) + return + + const expanded = this.getNodeExpanded(n.UUID) + if (expanded && n.hasChildren()) { + this.setNodeExpanded(n, false) + return + } + + if (n.isFirstSibling() && n.getParent().UUID !== ROOT_NODE) { + await _notes2.current.goToNode(n.getParent()?.UUID, true, true) + return + } + + const siblingBefore = n.getSiblingBefore() + const siblingExpanded = this.getNodeExpanded(siblingBefore?.UUID) + if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) { + const siblingAbove = this.getLastExpandedNode(siblingBefore) + await _notes2.current.goToNode(siblingAbove?.UUID, true, true) + return + } + + await _notes2.current.goToNode(n.getSiblingBefore()?.UUID, true, true) + }//}}} + async navigateRight(n) {//{{{ + if (n === null) + return + + const siblingAfter = n.getSiblingAfter() + const expanded = this.getNodeExpanded(n.UUID) + + if (!expanded && n.hasChildren()) { + this.setNodeExpanded(n, true) + return + } + + if (expanded && n.hasChildren()) { + await _notes2.current.goToNode(n.Children[0]?.UUID, true, true) + return + } + + if (n.isLastSibling()) { + const nextNode = this.getParentWithNextSibling(n) + await _notes2.current.goToNode(nextNode?.UUID, true, true) + return + } + + await _notes2.current.goToNode(n.getSiblingAfter()?.UUID, true, true) + }//}}} + async navigateUp(n) {//{{{ + if (n === null) + 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 + await _notes2.current.goToNode(parent?.UUID, true, true) + return + } + + if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) { + await _notes2.current.goToNode(siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, true, true) + return + } + + if (siblingBefore) { + await _notes2.current.goToNode(siblingBefore.UUID, true, true) + return + } + }//}}} + async navigateDown(n) {//{{{ + if (n === null) + 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) + await _notes2.current.goToNode(wantedNode?.UUID, true, true) + return + } + + if (nodeExpanded && n.isLastSibling() && !n.hasChildren()) { + const wantedNode = this.getParentWithNextSibling(n) + await _notes2.current.goToNode(wantedNode?.UUID, true, true) + return + } + + // Node not expanded. Go to this node's next sibling. + // GoToNode will abort if given null. + if (!nodeExpanded || !n.hasChildren()) { + await _notes2.current.goToNode(n.getSiblingAfter()?.UUID, true, true) + return + } + + // Node is expanded. + // Children will be visually beneath this node, if any. + if (nodeExpanded && n.hasChildren()) { + await _notes2.current.goToNode(n.Children[0].UUID, true, true) + return + } + }//}}} + async navigateTop() {//{{{ + const root = await nodeStore.get(ROOT_NODE) + if (root.Children.length === 0) + return + await _notes2.current.goToNode(root.Children[0]?.UUID, true, 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) + await _notes2.current.goToNode(lastnode?.UUID, true, true) + } else + await _notes2.current.goToNode(root.Children[root.Children.length - 1]?.UUID, true, 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) + }//}}} +} + +export class TreeNodeNative { + constructor(tree, node, parent) {//{{{ + this.tree = tree + this.node = node + this.parent = parent + + this.element = document.createElement('div') + this.element.classList.add('node') + this.icon_expand = document.createElement('img') + + this.children_populated = false + + this.createElements() + + _mbus.subscribe(`NODE_CHILDREN_FETCHED_${node.UUID}`, ()=>{ + this.render(true) + }) + + _mbus.subscribe(`NODE_EXPAND_${node.UUID}`, state=>{ + this.render(true) + }) + + this.rendered = false + }//}}} + createElements() {// {{{ + this.element.innerHTML = ` +
+
+
+ ` + + this.element.children[0].addEventListener('click', ()=>this.tree.setNodeExpanded(this.node, !this.tree.getNodeExpanded(this.node.UUID))) + this.element.children[0].appendChild(this.icon_expand) + this.element.children[1].addEventListener('click', ()=>window._notes2.current.goToNode(this.node.UUID)) + }// }}} + render(force_update) {//{{{ + if (this.rendered && force_update !== true) + return this.element + + // 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) + const selected = this.tree.isSelected(this.node) ? 'selected' : '' + + 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.element.children[1].innerText = this.node.get('Name') + this.element.children[1].className = `name ${selected}` + + // Update expansion state + this.element.children[2].className = `children ${expanded ? 'expanded' : 'collapsed'}` + + // The expand icon is cached to not get a flickering when re-rendering. + if (this.icon_expand === null) + this.icon_expand = document.createElement('img') + + if (this.node.Children.length === 0) + this.setImgSrc(this.icon_expand, `/images/${window._VERSION}/leaf.svg`) + else if (this.tree.getNodeExpanded(this.node.UUID)) + this.setImgSrc(this.icon_expand, `/images/${window._VERSION}/expanded.svg`) + else + this.setImgSrc(this.icon_expand, `/images/${window._VERSION}/collapsed.svg`) + + // Should children be rendered? + this.element.children[2].innerHTML = '' + let children = [] + if (expanded) + children = this.node.Children.map(node => { + let treenode = this.tree.treeNodeComponents[node.UUID] + if (treenode === undefined) { + treenode = new TreeNodeNative(this.tree, node, this) + this.tree.treeNodeComponents[node.UUID] = treenode + } + return treenode + //return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${tree} node=${node} parent=${this} ref=${tree.treeNodeComponents[node.UUID]} selected=${node.UUID === tree.props.app.startNode?.UUID} />` + }) + + for(const c of children) + this.element.children[2].appendChild(c.render()) + + this.rendered = true + return this.element + }//}}} + + setImgSrc(img, newSrc) {// {{{ + if (img.getAttribute('src') === newSrc) + return + img.setAttribute('src', newSrc) + }// }}} +} + +// vim: foldmethod=marker diff --git a/static/less/notes2.less b/static/less/notes2.less index a1ae783..a5778ed 100644 --- a/static/less/notes2.less +++ b/static/less/notes2.less @@ -46,6 +46,12 @@ html { } } +/* +#tree-native { + grid-area: tree; +} +*/ + #tree { grid-area: tree; padding: 16px 32px; 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..4a828cd 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,4 +1,10 @@ {{ define "page" }} +
+
+
+
+
+
From 1ce8e29e376ed61c032c731bfeb570364b4fe13e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Wed, 25 Jun 2025 14:59:21 +0200 Subject: [PATCH 02/11] Tree render and navigation with note rendering --- static/css/notes2.css | 23 ++- static/js/app.mjs | 298 ++++++++++++++++++++++++++++++++++++++ static/js/mbus.mjs | 37 ++++- static/js/node.mjs | 74 +++++++++- static/js/tree.mjs | 98 +++++++++---- static/less/notes2.less | 27 ++-- views/pages/notes2.gotmpl | 20 ++- 7 files changed, 515 insertions(+), 62 deletions(-) create mode 100644 static/js/app.mjs diff --git a/static/css/notes2.css b/static/css/notes2.css index 74b6392..0a23116 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -17,15 +17,10 @@ html { display: none; } } -/* -#tree-native { - grid-area: tree; -} -*/ #tree { grid-area: tree; - padding: 16px 32px; - background-color: #333; + display: grid; + padding: 16px 0px 16px 16px; color: #ddd; z-index: 100; border-left: 2px solid #333; @@ -37,9 +32,11 @@ html { display: grid; position: relative; justify-items: center; + margin-top: 8px; margin-bottom: 8px; margin-left: 24px; margin-right: 24px; + cursor: pointer; } #tree #logo img { width: 128px; @@ -85,12 +82,18 @@ html { #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; + margin: 16px 16px; } #crumbs .crumbs { display: flex; @@ -114,6 +117,10 @@ html { user-select: none; -webkit-tap-highlight-color: transparent; } +#crumbs .crumbs .crumb a { + text-decoration: none; + color: inherit; +} #crumbs .crumbs .crumb:after { content: "•"; margin-left: 8px; diff --git a/static/js/app.mjs b/static/js/app.mjs new file mode 100644 index 0000000..c6529fc --- /dev/null +++ b/static/js/app.mjs @@ -0,0 +1,298 @@ +import { ROOT_NODE } from 'node_store' +import { TreeNative } from 'tree' +import { NodeUINative } from 'node' + +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)) + + 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': + console.log(document.activeElement.id) + 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 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 + + }// }}} +} + +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)) + } +} + +function tmpl(html) { + const el = document.createElement('template') + el.innerHTML = html + return el.content.children +} + +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/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 bd4dadd..7c2d64e 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -331,6 +331,61 @@ 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 @@ -340,7 +395,6 @@ export class Node { 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 +408,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 +430,10 @@ export class Node { */ }//}}} + reset() {// {{{ + this._content = this.data.Content + this._modified = false + }// }}} get(prop) {//{{{ return this.data[prop] }//}}} @@ -384,6 +441,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 }//}}} @@ -436,12 +496,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 @@ -456,6 +516,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) diff --git a/static/js/tree.mjs b/static/js/tree.mjs index 3c0a1ff..2bf9f7a 100644 --- a/static/js/tree.mjs +++ b/static/js/tree.mjs @@ -8,10 +8,6 @@ export class TreeNative { this.selectedNode = null this.rendered = false - window._mbus.subscribe('TREE_TRUNK_FETCHED', ()=>{ - document.getElementById('tree').append(this.render()) - }) - this.populateFirstLevel() }// }}} render() {// {{{ @@ -19,10 +15,25 @@ export class TreeNative { alert('Tree should only be rendered once.') const tmpl = document.createElement('template') - tmpl.innerHTML = '
' + tmpl.innerHTML = ` +
+ +
+ + +
+
` + + /* + onclick=${() => _mbus.dispatch('op-search')} + onclick=${() => _sync.run()} + */ + const treeEl = tmpl.content.getElementById('tree-nodes') treeEl.addEventListener('keydown', event=>this.keyHandler(event)) + tmpl.content.getElementById('logo').addEventListener('click', ()=>_app.goToNode(ROOT_NODE, false, false)) + for (const node of this.treeTrunk) { const treenode = new TreeNodeNative(this, node) this.treeNodeComponents[node.UUID] = treenode @@ -57,12 +68,23 @@ export class TreeNative { return this.expandedNodes[UUID] }//}}} setNodeExpanded(node, value) {//{{{ - // Creating a default value if it doesn't exist already. - this.getNodeExpanded(node.UUID) + 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 @@ -143,7 +165,7 @@ export class TreeNative { } }//}}} async navigateLeft(n) {//{{{ - if (n === null) + if (n === null || n === undefined) return const expanded = this.getNodeExpanded(n.UUID) @@ -153,7 +175,7 @@ export class TreeNative { } if (n.isFirstSibling() && n.getParent().UUID !== ROOT_NODE) { - await _notes2.current.goToNode(n.getParent()?.UUID, true, true) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getParent()?.UUID, dontPush: true, dontExpand: true }) return } @@ -161,14 +183,14 @@ export class TreeNative { const siblingExpanded = this.getNodeExpanded(siblingBefore?.UUID) if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) { const siblingAbove = this.getLastExpandedNode(siblingBefore) - await _notes2.current.goToNode(siblingAbove?.UUID, true, true) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingAbove?.UUID, dontPush: true, dontExpand: true }) return } - await _notes2.current.goToNode(n.getSiblingBefore()?.UUID, true, true) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingBefore()?.UUID, dontPush: true, dontExpand: true }) }//}}} async navigateRight(n) {//{{{ - if (n === null) + if (n === null || n === undefined) return const siblingAfter = n.getSiblingAfter() @@ -180,20 +202,20 @@ export class TreeNative { } if (expanded && n.hasChildren()) { - await _notes2.current.goToNode(n.Children[0]?.UUID, true, true) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0]?.UUID, dontPush: true, dontExpand: true }) return } if (n.isLastSibling()) { const nextNode = this.getParentWithNextSibling(n) - await _notes2.current.goToNode(nextNode?.UUID, true, true) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: nextNode?.UUID, dontPush: true, dontExpand: true }) return } - await _notes2.current.goToNode(n.getSiblingAfter()?.UUID, true, true) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: true, dontExpand: true }) }//}}} async navigateUp(n) {//{{{ - if (n === null) + if (n === null || n === undefined) return let parent = null @@ -206,22 +228,22 @@ export class TreeNative { parent = n.getParent() if (parent?.UUID === ROOT_NODE) return - await _notes2.current.goToNode(parent?.UUID, true, true) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: parent?.UUID, dontPush: true, dontExpand: true }) return } if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) { - await _notes2.current.goToNode(siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, true, true) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, dontPush: true, dontExpand: true }) return } if (siblingBefore) { - await _notes2.current.goToNode(siblingBefore.UUID, true, true) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.UUID, dontPush: true, dontExpand: true }) return } }//}}} async navigateDown(n) {//{{{ - if (n === null) + if (n === null || n === undefined) return const nodeExpanded = this.getNodeExpanded(n.UUID) @@ -230,27 +252,27 @@ export class TreeNative { // Traverse upward to nearest parent with next sibling. if (!nodeExpanded && n.isLastSibling()) { const wantedNode = this.getParentWithNextSibling(n) - await _notes2.current.goToNode(wantedNode?.UUID, true, true) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: true, dontExpand: true }) return } if (nodeExpanded && n.isLastSibling() && !n.hasChildren()) { const wantedNode = this.getParentWithNextSibling(n) - await _notes2.current.goToNode(wantedNode?.UUID, true, true) + _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()) { - await _notes2.current.goToNode(n.getSiblingAfter()?.UUID, true, true) + _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()) { - await _notes2.current.goToNode(n.Children[0].UUID, true, true) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0].UUID, dontPush: true, dontExpand: true }) return } }//}}} @@ -258,7 +280,7 @@ export class TreeNative { const root = await nodeStore.get(ROOT_NODE) if (root.Children.length === 0) return - await _notes2.current.goToNode(root.Children[0]?.UUID, true, true) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[0]?.UUID, dontPush: true, dontExpand: true }) }//}}} async navigateBottom() {//{{{ const root = await nodeStore.get(ROOT_NODE) @@ -270,9 +292,9 @@ export class TreeNative { if (toplevelExpanded) { const lastnode = this.getLastExpandedNode(toplevel) - await _notes2.current.goToNode(lastnode?.UUID, true, true) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: true, dontExpand: true }) } else - await _notes2.current.goToNode(root.Children[root.Children.length - 1]?.UUID, true, true) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[root.Children.length - 1]?.UUID, dontPush: true, dontExpand: true }) }//}}} getParentWithNextSibling(node) {//{{{ @@ -299,6 +321,17 @@ export class TreeNative { 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?.element.scrollIntoView({ block: 'nearest' }) + }// }}} } export class TreeNodeNative { @@ -312,6 +345,7 @@ export class TreeNodeNative { this.icon_expand = document.createElement('img') this.children_populated = false + this.rendered = false this.createElements() @@ -323,7 +357,8 @@ export class TreeNodeNative { this.render(true) }) - this.rendered = false + if (this.node.Level === 0 || this.tree.getNodeExpanded(this.node.UUID)) + this.fetchChildren() }//}}} createElements() {// {{{ this.element.innerHTML = ` @@ -334,8 +369,13 @@ export class TreeNodeNative { this.element.children[0].addEventListener('click', ()=>this.tree.setNodeExpanded(this.node, !this.tree.getNodeExpanded(this.node.UUID))) this.element.children[0].appendChild(this.icon_expand) - this.element.children[1].addEventListener('click', ()=>window._notes2.current.goToNode(this.node.UUID)) + + this.element.children[1].addEventListener('click', ()=>_mbus.dispatch('TREE_NODE_SELECTED', this.node)) }// }}} + async fetchChildren() {//{{{ + await this.node.fetchChildren() + this.children_populated = true + }//}}} render(force_update) {//{{{ if (this.rendered && force_update !== true) return this.element diff --git a/static/less/notes2.less b/static/less/notes2.less index a5778ed..2c57f53 100644 --- a/static/less/notes2.less +++ b/static/less/notes2.less @@ -46,16 +46,10 @@ html { } } -/* -#tree-native { - grid-area: tree; -} -*/ - #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; @@ -68,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; @@ -130,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; @@ -160,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/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index 4a828cd..52d128a 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,11 +1,18 @@ {{ define "page" }} -
-
-
+
+
+
+
+
+ +
+
-
{{ end }} From 454d065baa1d2d26a59643d30d8e3e195cb4fef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Sun, 3 May 2026 09:51:48 +0200 Subject: [PATCH 10/11] push/popState is handled better --- static/js/app.mjs | 6 +++++- static/js/tree.mjs | 32 ++++++++++++++++---------------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/static/js/app.mjs b/static/js/app.mjs index c68475e..16dab2b 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -30,6 +30,7 @@ export class App { }) window.addEventListener('keydown', event => this.keyHandler(event)) + window.addEventListener('popstate', event => this.popState(event)) document.getElementById('notes2').addEventListener('click', event => { if (event.target.id === 'notes2') document.getElementById('node-content')?.focus() @@ -114,6 +115,9 @@ export class App { event.stopPropagation() } }//}}} + popState(event) {// {{{ + _mbus.dispatch("GO_TO_NODE", { nodeUUID: event.state.nodeUUID, dontPush: true, dontExpand: true }) + }// }}} async getStartNode() {//{{{ let nodeUUID = ROOT_NODE @@ -181,7 +185,6 @@ export class App { history.pushState({ nodeUUID }, '', `/notes2#${nodeUUID}`) const node = nodeStore.node(nodeUUID) - node.reset() // any modifications are discarded. this.currentNode = node @@ -246,6 +249,7 @@ class N2Crumb extends CustomHTMLElement { this.elLink.href = `/notes2#${this.uuid}` this.elLink.innerText = this.label + this.elLink.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: this.uuid, dontPush: false, dontExpand: true })) }// }}} } customElements.define('n2-crumb', N2Crumb) diff --git a/static/js/tree.mjs b/static/js/tree.mjs index c76369f..d7a11ee 100644 --- a/static/js/tree.mjs +++ b/static/js/tree.mjs @@ -175,7 +175,7 @@ export class N2Tree extends CustomHTMLElement { } if (n.isFirstSibling() && n.getParent().UUID !== ROOT_NODE) { - _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getParent()?.UUID, dontPush: true, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getParent()?.UUID, dontPush: false, dontExpand: true }) return } @@ -183,11 +183,11 @@ export class N2Tree extends CustomHTMLElement { 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 }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingAbove?.UUID, dontPush: false, dontExpand: true }) return } - _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingBefore()?.UUID, dontPush: true, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingBefore()?.UUID, dontPush: false, dontExpand: true }) }//}}} async navigateRight(n) {//{{{ if (n === null || n === undefined) @@ -202,17 +202,17 @@ export class N2Tree extends CustomHTMLElement { } if (expanded && n.hasChildren()) { - _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0]?.UUID, dontPush: true, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0]?.UUID, dontPush: false, dontExpand: true }) return } if (n.isLastSibling()) { const nextNode = this.getParentWithNextSibling(n) - _mbus.dispatch("GO_TO_NODE", { nodeUUID: nextNode?.UUID, dontPush: true, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: nextNode?.UUID, dontPush: false, dontExpand: true }) return } - _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: true, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: false, dontExpand: true }) }//}}} async navigateUp(n) {//{{{ if (n === null || n === undefined) @@ -228,17 +228,17 @@ export class N2Tree extends CustomHTMLElement { parent = n.getParent() if (parent?.UUID === ROOT_NODE) return - _mbus.dispatch("GO_TO_NODE", { nodeUUID: parent?.UUID, dontPush: true, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: parent?.UUID, dontPush: false, 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 }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) return } if (siblingBefore) { - _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.UUID, dontPush: true, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.UUID, dontPush: false, dontExpand: true }) return } }//}}} @@ -252,27 +252,27 @@ export class N2Tree extends CustomHTMLElement { // 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 }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: false, 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 }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: false, 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 }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: false, 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 }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0].UUID, dontPush: false, dontExpand: true }) return } }//}}} @@ -280,7 +280,7 @@ export class N2Tree extends CustomHTMLElement { 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 }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[0]?.UUID, dontPush: false, dontExpand: true }) }//}}} async navigateBottom() {//{{{ const root = await nodeStore.get(ROOT_NODE) @@ -292,9 +292,9 @@ export class N2Tree extends CustomHTMLElement { if (toplevelExpanded) { const lastnode = this.getLastExpandedNode(toplevel) - _mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: true, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: false, dontExpand: true }) } else - _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[root.Children.length - 1]?.UUID, dontPush: true, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[root.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) }//}}} getParentWithNextSibling(node) {//{{{ From 26ca510785717de7ffcce1503a43b35461671c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Sun, 3 May 2026 19:45:39 +0200 Subject: [PATCH 11/11] Fixed tree reset after sync, optimized sync with IndexedDB --- static/js/app.mjs | 8 ++--- static/js/node.mjs | 7 ++-- static/js/node_store.mjs | 70 +++++++++++++++++++++++----------------- static/js/sync.mjs | 28 +++++++++------- static/js/tree.mjs | 11 ++++++- 5 files changed, 72 insertions(+), 52 deletions(-) diff --git a/static/js/app.mjs b/static/js/app.mjs index 16dab2b..45cd85b 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -6,13 +6,13 @@ import { Node } from 'node' export class App { constructor() {// {{{ this.currentNode = null - this.treeNative = new N2Tree() + this.tree = new N2Tree() this.crumbs = new N2Crumbs() this.crumbsElement = document.getElementById('crumbs') this.nodeUI = document.getElementById('note') _mbus.subscribe('TREE_TRUNK_FETCHED', async () => { - document.getElementById('tree').append(this.treeNative.render()) + document.getElementById('tree').append(this.tree.render()) document.getElementById('tree-nodes')?.focus() const startNode = await this.getStartNode() @@ -188,7 +188,7 @@ export class App { node.reset() // any modifications are discarded. this.currentNode = node - this.treeNative.setSelected(node, dontExpand) + this.tree.setSelected(node, dontExpand) const ancestors = await nodeStore.getNodeAncestry(node) _mbus.dispatch('CRUMBS_SET', ancestors, () => this.crumbsElement.replaceChildren(this.crumbs.render())) @@ -196,7 +196,7 @@ export class App { _mbus.dispatch('NODE_UNMODIFIED') // Scrolls node into view. - this.treeNative.makeVisible(node) + this.tree.makeVisible(node) }//}}} } diff --git a/static/js/node.mjs b/static/js/node.mjs index 949724c..d611c64 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -54,7 +54,7 @@ export class Node { if (a.data.Name > b.data.Name) return 0 return 0 }//}}} - static create(name, parentUUID) { + static create(name, parentUUID) {// {{{ return new Node({ UUID: uuidv7(), Created: (new Date()).toISOString(), @@ -64,7 +64,7 @@ export class Node { Markdown: false, History: false, }) - } + }// }}} constructor(nodeData, level) {//{{{ @@ -123,9 +123,6 @@ export class Node { return this._children_fetched }//}}} async fetchChildren() {//{{{ - if (this._children_fetched) - return this.Children - this.Children = await nodeStore.getTreeNodes(this.UUID, this.Level + 1) this._children_fetched = true diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index 98642d1..e849e29 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -159,6 +159,7 @@ export class NodeStore { }) }//}}} + /* upsertNodeRecords(records) {//{{{ return new Promise((resolve, reject) => { const t = this.db.transaction('nodes', 'readwrite') @@ -187,16 +188,10 @@ export class NodeStore { record.modified = 0 addReq = nodeStore.put(record) } - addReq.onsuccess = () => { - console.debug(`${op} ${record.UUID} (${record.Name})`) - } - addReq.onerror = (event) => { - console.log(`error ${op} ${record.UUID}`, event.target.error) - reject(event.target.error) - } } }) }//}}} + */ getTreeNodes(parent, newLevel) {//{{{ return new Promise((resolve, reject) => { // Parent of toplevel nodes is ROOT_NODE in indexedDB. @@ -219,7 +214,7 @@ export class NodeStore { req.onerror = (event) => reject(event.target.error) }) }//}}} - async search(searchfor, parent) {//{{{ + search(searchfor, parent) {//{{{ return new Promise((resolve, reject) => { const trx = this.db.transaction('nodes', 'readonly') const nodeStore = trx.objectStore('nodes') @@ -249,43 +244,55 @@ export class NodeStore { }) }//}}} - add(records) {//{{{ + add(records, objstore) {//{{{ return new Promise((resolve, reject) => { try { - const t = this.db.transaction('nodes', 'readwrite') - const nodeStore = t.objectStore('nodes') - t.onerror = (event) => { - console.error('transaction error', event.target.error) - reject(event.target.error) + // A nodestore can be provided in order to + // avoid creating new transactions. + let nodeStore = objstore + let t + + if (nodeStore === undefined) { + t = this.db.transaction('nodes', 'readwrite') + nodeStore = t.objectStore('nodes') + + t.oncomplete = (_event) => { + resolve() + } + + t.onerror = (event) => { + console.error('transaction error', event.target.error) + reject(event.target.error) + } } // records is an object, not an array. - const promises = [] for (const recordIdx in records) { const record = records[recordIdx] - const addReq = nodeStore.put(record.data) - - const promise = new Promise((resolve, reject) => { - addReq.onsuccess = () => resolve() - addReq.onerror = (event) => { - console.error('Error!', event.target.error, record.ID) - reject(event.target.error) - } - }) - promises.push(promise) + nodeStore.put(record.data) } - Promise.all(promises).then(() => resolve()) + resolve() } catch (e) { - console.log(e) + console.error(e) + reject(e) } }) }//}}} - get(uuid) {//{{{ + get(uuid, suppliedNodestore) {//{{{ return new Promise((resolve, reject) => { - const trx = this.db.transaction('nodes', 'readonly') - const nodeStore = trx.objectStore('nodes') + // A nodestore can be provided in order to + // avoid creating new transactions. + let trx + let nodeStore = suppliedNodestore + + if (nodeStore === undefined) { + trx = this.db.transaction('nodes', 'readonly') + nodeStore = trx.objectStore('nodes') + } + const getRequest = nodeStore.get(uuid) + getRequest.onsuccess = (event) => { // Node not found in IndexedDB. if (event.target.result === undefined) { @@ -328,6 +335,9 @@ export class NodeStore { }) }//}}} + newTransaction(objectStore, mode) {// {{{ + return this.db.transaction(objectStore, mode) + }// }}} nodeCount() {//{{{ return new Promise((resolve, reject) => { diff --git a/static/js/sync.mjs b/static/js/sync.mjs index fd606f3..9b58cf7 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -65,9 +65,16 @@ export class Sync { * sync be preserved in the backend. */ let backendNode = null + + // Create a single transaction to be used in the chain of + // this sync. Otherwise it would take more time to create + // transactions for each node. + const trx = nodeStore.newTransaction('nodes', 'readwrite') + const objstore = trx.objectStore('nodes') + for (const i in res.Nodes) { backendNode = new Node(res.Nodes[i], -1) - await window._sync.handleNode(backendNode) + await this.handleNode(backendNode, objstore) handled++ if (handled % 100 === 0) @@ -88,16 +95,16 @@ export class Sync { } return (syncEnd - syncStart) }//}}} - async handleNode(backendNode) {//{{{ + async handleNode(backendNode, objstore) {//{{{ try { /* Retrieving the local copy of this node from IndexedDB. * The backend node can be discarded if it is older than * the local copy since it is considered history preserved * in the backend. */ - return nodeStore.get(backendNode.UUID) - .then(async localNode => { + return nodeStore.get(backendNode.UUID, objstore) + .then(localNode => { if (localNode.updated() >= backendNode.updated()) { - console.log(`History from backend: ${backendNode.UUID}`) + console.debug(`History from backend: ${backendNode.UUID}`) return } @@ -107,12 +114,12 @@ export class Sync { * * If the local node has seen change, the change is already * placed into the send_queue anyway. */ - return nodeStore.add([backendNode]) + return nodeStore.add([backendNode], objstore) }) - .catch(async () => { + .catch(() => { // Not found in IndexedDB - OK to just insert since it only exists in backend. - return nodeStore.add([backendNode]) + return nodeStore.add([backendNode], objstore) }) } catch (e) { console.error(e) @@ -198,10 +205,7 @@ export class N2SyncProgress extends CustomHTMLElement { break // Reload the tree nodes to reflect the new/updated nodes. - if (window._notes2?.current?.reloadTree.value !== null) { - nodeStore.purgeCache() - window._notes2.current.reloadTree.value = window._notes2.current.reloadTree.value + 1 - } + window._app.tree.reset() break } this.render() diff --git a/static/js/tree.mjs b/static/js/tree.mjs index d7a11ee..1da5dee 100644 --- a/static/js/tree.mjs +++ b/static/js/tree.mjs @@ -10,6 +10,7 @@ export class N2Tree extends CustomHTMLElement {
+
` }// }}} @@ -39,12 +40,20 @@ export class N2Tree extends CustomHTMLElement { for (const node of this.treeTrunk) { const treenode = new N2TreeNode(this, node) this.treeNodeComponents[node.UUID] = treenode - this.appendChild(treenode.render()) + this.elTreenodes.appendChild(treenode.render()) } this.rendered = true return this }// }}} + reset() { + console.log('tree reset') + this.treeNodeComponents = {} + this.treeTrunk = [] + this.rendered = false + this.elTreenodes.replaceChildren() + this.populateFirstLevel() + } populateFirstLevel() {//{{{ nodeStore.get(ROOT_NODE) .then(node => node.fetchChildren())