From 1055404dc0344cfebff5c7004bf4ebc331fad26f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Sat, 13 Jun 2026 07:52:07 +0200 Subject: [PATCH 01/27] Fixed ctrl+s --- static/js/app.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/js/app.mjs b/static/js/app.mjs index 756f486..baefe87 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -85,6 +85,7 @@ export class App { // Most 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. + const CTRL = !event.shiftKey && event.ctrlKey && !event.altKey const SHIFT_ALT = event.shiftKey && !event.ctrlKey && event.altKey const SHIFT_CTRL_ALT = event.shiftKey && event.ctrlKey && event.altKey @@ -121,7 +122,7 @@ export class App { break case 'S': - if (!SHIFT_ALT) { handled = false; break } + if (!CTRL) { handled = false; break } this.nodeUI.saveNode() break From 61b0ba9adad5aad8e110351fc80e03fe1ca9fd5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Sun, 14 Jun 2026 14:36:28 +0200 Subject: [PATCH 02/27] Initial work on drag-and-drop --- sql/00006.sql | 119 ++++++++++++++++++++++++++ static/css/notes2.css | 2 + static/images/icon_drag.svg | 71 ++++++++++++++++ static/images/icon_drag_ok.svg | 75 +++++++++++++++++ static/images/icon_drag_source.svg | 49 +++++++++++ static/js/app.mjs | 57 ++++++++++++- static/js/node_store.mjs | 2 + static/js/page_node.mjs | 15 +++- static/js/sidebar.mjs | 129 ++++++++++++++++++++++++++++- views/pages/notes2.gotmpl | 3 + 10 files changed, 514 insertions(+), 8 deletions(-) create mode 100644 sql/00006.sql create mode 100644 static/images/icon_drag.svg create mode 100644 static/images/icon_drag_ok.svg create mode 100644 static/images/icon_drag_source.svg diff --git a/sql/00006.sql b/sql/00006.sql new file mode 100644 index 0000000..56f2acb --- /dev/null +++ b/sql/00006.sql @@ -0,0 +1,119 @@ +CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb) + LANGUAGE plpgsql +AS $procedure$ + +DECLARE + node_data jsonb; + node_updated timestamptz; + db_updated timestamptz; + db_uuid uuid; + db_client uuid; + db_history_uuid uuid; + node_uuid uuid; + node_parent_uuid uuid; + node_history_uuid uuid; + +BEGIN + FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) + LOOP + node_uuid = (node_data->>'UUID')::uuid; + node_history_uuid = (node_data->>'HistoryUUID')::uuid; + node_updated = (node_data->>'Updated')::timestamptz; + + + + -- Frontend is using an all-zero UUID to define the root node. + -- Database is using NULL. + IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN + node_parent_uuid = NULL; + ELSE + node_parent_uuid = (node_data->>'ParentUUID')::uuid; + END IF; + + + + -- Every jode has a new history UUID to keep the history entry uniquely identifiable + -- across clients. A history entry could potentially be sent again, but should be + -- safe to ignore as every change to a node should have a new history UUID. + -- + -- The current node is also stored as history. + INSERT INTO node_history( + user_id, "uuid", "history_uuid", parents, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, -- combined key + node_uuid, -- combined key + node_history_uuid, -- combined key + (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ) + ON CONFLICT ("user_id", "uuid", "history_uuid") + DO NOTHING; + + + + -- Retrieve the current modified timestamp for this node from the database. + SELECT + uuid, updated, client + INTO + db_uuid, db_updated, db_client + FROM public."node" + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + + + + -- Is the node not in database? It needs to be created. + IF db_uuid IS NULL THEN + RAISE NOTICE '01 New node %', node_uuid; + + INSERT INTO public."node" ( + user_id, "uuid", parent_uuid, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, + node_uuid, + node_parent_uuid, + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ); + + CONTINUE; + + END IF; + + + + -- Update the public node as well if it was older than incoming node. + IF node_updated > db_updated THEN + UPDATE public."node" + SET + updated = (node_data->>'Updated')::timestamptz, + updated_seq = nextval('node_updates'), + parent_uuid = (node_data->>'ParentUUID')::uuid, + name = (node_data->>'Name')::varchar, + content = (node_data->>'Content')::text, + client = p_client_uuid + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + END IF; + + END LOOP; +END +$procedure$ +; diff --git a/static/css/notes2.css b/static/css/notes2.css index 04c9f68..079e3c1 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -418,6 +418,8 @@ n2-nodeui { font-size: 1.75em; margin-top: 8px; margin-bottom: 0px; + white-space: nowrap; + width: min-content; } .el-functions { diff --git a/static/images/icon_drag.svg b/static/images/icon_drag.svg new file mode 100644 index 0000000..02d628e --- /dev/null +++ b/static/images/icon_drag.svg @@ -0,0 +1,71 @@ + + + +folder-openfolder-open-outlinenotebook-outlinetext-box-outline diff --git a/static/images/icon_drag_ok.svg b/static/images/icon_drag_ok.svg new file mode 100644 index 0000000..94ba949 --- /dev/null +++ b/static/images/icon_drag_ok.svg @@ -0,0 +1,75 @@ + + + +folder-openfolder-open-outlinenotebook-outlinetext-box-outlinetext-box-check-outline diff --git a/static/images/icon_drag_source.svg b/static/images/icon_drag_source.svg new file mode 100644 index 0000000..6378ed9 --- /dev/null +++ b/static/images/icon_drag_source.svg @@ -0,0 +1,49 @@ + + + + + + + + drag-variant + + + diff --git a/static/js/app.mjs b/static/js/app.mjs index baefe87..8fc43df 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -12,6 +12,7 @@ export class App { this.crumbs = new N2Crumbs() this.crumbsElement = document.getElementById('crumbs') this.nodeUI = document.getElementById('note') + this.dragIcon = new N2DragIcon() this.sidebar.render().then(sidebar => { document.getElementById('tree').append(sidebar) @@ -68,6 +69,7 @@ export class App { }) document.querySelector('#page-root .create').addEventListener('click', () => this.createNode()) + document.body.append(this.dragIcon) _mbus.dispatch('SHOW_PAGE', { page: 'node' }) @@ -78,7 +80,6 @@ export class App { // There a slight delay to initiate sync seems reasonable. setTimeout(() => window._sync.run(), 1000) }// }}} - keyHandler(event) {//{{{ let handled = true @@ -151,6 +152,10 @@ export class App { async saveNode() {//{{{ }//}}} + async moveNode(node, targetNodeUUID) {// {{{ + node.moveToParent(targetNodeUUID) + await node.save() + }// }}} async createNode(createUnderUUID) {//{{{ const parentUUID = createUnderUUID ? createUnderUUID : this.currentNode.UUID const p = createUnderUUID ? 'Name for sibling document' : 'Name for sub-document' @@ -239,7 +244,6 @@ class N2Crumbs extends CustomHTMLElement { return this }// }}} } -customElements.define('n2-crumbs', N2Crumbs) class N2Crumb extends CustomHTMLElement { static {// {{{ @@ -270,7 +274,6 @@ class N2Crumb extends CustomHTMLElement { this.elLink.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: this.uuid, dontPush: false, dontExpand: true })) }// }}} } -customElements.define('n2-crumb', N2Crumb) function tmpl(html) {// {{{ const el = document.createElement('template') @@ -344,4 +347,52 @@ class OpSearch extends Op { }// }}} } +class N2DragIcon extends CustomHTMLElement { + static {// {{{ + this.tmpl = document.createElement('template') + this.tmpl.innerHTML = ` + + + ` + }// }}} + constructor() {// {{{ + super(true) + + document.addEventListener('dragover', e => { + this.style.left = `${e.clientX + 8}px` + this.style.top = `${e.clientY}px` + }) + + this.dragTarget = null + }// }}} + start() {// {{{ + this.style.display = 'block' + }// }}} + end() {// {{{ + this.style.display = 'none' + }// }}} + icon(name) {// {{{ + if (name != '') + name = '_' + name + this.elIcon.setAttribute('src', `/images/${_VERSION}/icon_drag${name}.svg`) + }// }}} + setTarget(t) {// {{{ + this.dragTarget = t + }// }}} + getTarget() {// {{{ + return this.dragTarget + }// }}} +} + +customElements.define('n2-crumbs', N2Crumbs) +customElements.define('n2-crumb', N2Crumb) +customElements.define('n2-dragicon', N2DragIcon) + // vim: foldmethod=marker diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index f31a4b6..324f004 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -247,6 +247,7 @@ export class NodeStore { nodeStore = t.objectStore('nodes') t.oncomplete = (_event) => { + console.log('complete') resolve() } @@ -358,6 +359,7 @@ class SimpleNodeStore { // Node to be moved is first stored in the new queue. const req = store.put(node.data) req.onsuccess = () => { + console.log('here') resolve() } req.onerror = (event) => { diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index 834e22f..f65afb9 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -422,6 +422,11 @@ export class Node { getParent() {//{{{ return this._parent }//}}} + moveToParent(newParentUUID) {// {{{ + this.ParentUUID = newParentUUID + this.data.ParentUUID = newParentUUID + this._modified = true + }// }}} isLastSibling() {//{{{ return this._sibling_after === null }//}}} @@ -463,9 +468,10 @@ export class Node { // When stored into database and ancestry was changed, // the ancestry path could be interesting. + /* const ancestors = await nodeStore.getNodeAncestry(this) this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse() - + */ /* The node history is a local store for node history. * This could be provisioned from the server or cleared if * deemed unnecessary. @@ -481,12 +487,17 @@ export class Node { const history = nodeStore.nodesHistory.add(this) // Updated node is added to the send queue to be stored on server. + const sendQueue = nodeStore.sendQueue.add(this) // Updated node is saved to the primary node store. const nodeStoreAdding = nodeStore.add([this]) - return Promise.all([history, sendQueue, nodeStoreAdding]) + console.log('waiting') + await Promise.all([history, sendQueue, nodeStoreAdding]) + console.log('waiting done') + + return }//}}} } diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index 7d73d6a..561de8d 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -449,15 +449,20 @@ export class N2Sidebar extends CustomHTMLElement { treenode?.scrollIntoView({ block: 'nearest' }) }// }}} } -customElements.define('n2-sidebar', N2Sidebar) export class N2TreeNode extends CustomHTMLElement { + static DRAG_ICON = new Image() + static DRAG_ICON_OK = new Image() + static {// {{{ + N2TreeNode.DRAG_ICON.src = `/images/${_VERSION}/leaf.svg` + N2TreeNode.DRAG_ICON_OK.src = `/images/${_VERSION}/expanded.svg` + this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
- +
@@ -490,6 +545,7 @@ export class N2TreeNode extends CustomHTMLElement { constructor(sidebar, node, parent) {//{{{ super() + this.setAttribute('draggable', 'true') this.classList.add('node') this.sidebar = sidebar @@ -498,6 +554,7 @@ export class N2TreeNode extends CustomHTMLElement { this.children_populated = false this.rendered = false + this.dragNode = null this.elExpandToggle.addEventListener('click', () => this.sidebar.setNodeExpanded(this.node, !this.sidebar.getNodeExpanded(this.node.UUID))) this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node)) @@ -505,6 +562,70 @@ export class N2TreeNode extends CustomHTMLElement { _mbus.subscribe(`NODE_EXPAND_${node.UUID}`, _state => { this.render(true) }) + + // Drag-and-dropping of nodes + this.addEventListener('dragstart', event => this.dragStart(event)) + this.addEventListener('dragend', event => this.dragEnd(event)) + this.addEventListener('dragover', event => this.dragOver(event)) + this.addEventListener('drop', event => this.dragDrop(event)) + this.elName.addEventListener('dragenter', event => this.dragEnter(event)) + this.elName.addEventListener('dragleave', event => this.dragLeave(event)) + }// }}} + dragStart(e) {// {{{ + if (this.node.isModified()) { + alert('Save note before moving it.') + e.stopPropagation() + e.preventDefault() + return + } + + this.classList.add('drag-source') + const blankPixel = new Image() + blankPixel.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' + e.dataTransfer.setDragImage(blankPixel, 0, 0) + e.dataTransfer.allowedEffects = 'none' + e.stopPropagation() + _app.dragIcon.start() + }// }}} + dragEnd(e) {// {{{ + this.classList.remove('drag-source') + _app.dragIcon.end() + e.stopPropagation() + }// }}} + dragOver(e) {// {{{ + e.dataTransfer.dropEffect = 'move' + e.preventDefault() + }// }}} + async dragDrop(e) {// {{{ + e.stopPropagation() + const moveToNode = _app.dragIcon.getTarget() + await _app.moveNode(this.node, moveToNode.node.UUID) + return + + _app.sidebar.setNodeExpanded(moveToNode, true) + await this.render(true, true) + await moveToNode.render(true, true) + + this.dragLeave(e) + }// }}} + dragEnter(e) {// {{{ + const targetNode = e.target.closest('n2-treenode') + if (targetNode.classList.contains('drag-source')) + return + e.stopPropagation() + _app.dragIcon.icon('ok') + this.classList.add('drag-target') + + _app.dragIcon.setTarget(this) + }// }}} + dragLeave(e) {// {{{ + e.stopPropagation() + e.dataTransfer.dropEffect = 'none' + e.dataTransfer.setDragImage(N2TreeNode.DRAG_ICON, -16, 8) + _app.dragIcon.icon('') + this.classList.remove('drag-target') + + _app.dragIcon.setTarget(null) }// }}} async fetchChildren(force_fetch) {//{{{ if (this.children_populated && !force_fetch) @@ -575,6 +696,8 @@ export class N2TreeNode extends CustomHTMLElement { img.setAttribute('src', newSrc) }// }}} } + +customElements.define('n2-sidebar', N2Sidebar) customElements.define('n2-treenode', N2TreeNode) // vim: foldmethod=marker diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index abec2b0..381d672 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,4 +1,7 @@ {{ define "page" }} + + +
>
From 53d8d16086d8941b35b348ab4008a08513535171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Sun, 14 Jun 2026 14:36:52 +0200 Subject: [PATCH 03/27] Initial work on node menu --- static/css/notes2.css | 2 +- static/js/page_node.mjs | 17 ++++++++++++++++- views/pages/notes2.gotmpl | 2 ++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/static/css/notes2.css b/static/css/notes2.css index 079e3c1..dfe4156 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -9,7 +9,7 @@ --line-color: #ccc; --tree-expander: 0px; - --functions-width: 180px; + --functions-width: 216px; } html { diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index f65afb9..f6aa681 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -9,7 +9,7 @@ export class N2PageNodeUI extends CustomHTMLElement { +
+ + + + +
+ ` + }// }}} + constructor() {// {{{ + super() + }// }}} +} +customElements.define('n2-nodemenu', N2NodeMenu) + export class N2PageNodeUI extends CustomHTMLElement { static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
@@ -28,11 +92,13 @@ export class N2PageNodeUI extends CustomHTMLElement {
- - - + +
+ ` }// }}} @@ -41,7 +107,6 @@ export class N2PageNodeUI extends CustomHTMLElement { this.node = null this.style.display = 'contents' - this.classList.add('show-markdown') // TODO Should probably be moved to settings. this.marked = new MarkedPosition() _mbus.subscribe('NODE_UI_OPEN', event => { @@ -70,11 +135,27 @@ export class N2PageNodeUI extends CustomHTMLElement { _mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data)) _mbus.subscribe('MARKDOWN_CHANGE_CHECKBOX', ({ detail }) => this.checkboxUpdated(detail.data)) + // Binding the node rename handler. this.elName.addEventListener('click', async () => this.renameNode()) + + // Bind handlers for content keyboard input and paste. this.elNodeContent.addEventListener('input', event => this.contentChanged(event)) this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event)) + + // Bind node icon handlers. + this.elIconSave.addEventListener('click', () => this.saveNode()) this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown())) - this.elIconTableFormat.addEventListener('click', event => { + this.elIconNewDocument.addEventListener('click', event => { + if (event.shiftKey) + _app.createNode(this.node.ParentUUID) + else + _app.createNode() + }) + + // Bind node menu items to handlers. + this.elNodeMenu.elFormatTables.addEventListener('click', event => { + this.elNodeMenu.hidePopover() + if (!event.shiftKey) this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value) else { @@ -88,15 +169,12 @@ export class N2PageNodeUI extends CustomHTMLElement { this.node.setContent(this.elNodeContent.value) }) - this.elIconHistory.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'history' })) - this.elIconSave.addEventListener('click', () => this.saveNode()) - this.elIconNewDocument.addEventListener('click', event => { - if (event.shiftKey) - _app.createNode(this.node.ParentUUID) - else - _app.createNode() + this.elNodeMenu.elHistory.addEventListener('click', () => { + _mbus.dispatch('SHOW_PAGE', { page: 'history' }) }) + // Default is to always show markdown. + this.classList.add('show-markdown') // TODO Should probably be moved to settings. this.showMarkdown(true) }// }}} renderName() {// {{{ @@ -309,7 +387,7 @@ export class N2PageNodeUI extends CustomHTMLElement { // Node is modified with the new value. User has to save manually, otherwise other changes could be saved // when a save wasn't expected. - const newValue =`[${checkbox.checked ? 'x' : ' '}] ` + const newValue = `[${checkbox.checked ? 'x' : ' '}] ` const modifiedContent = this.node.content().slice(0, pos.start) + newValue + this.node.content().slice(pos.end) this.node.setContent(modifiedContent) @@ -506,22 +584,10 @@ export class Node { console.log('waiting') await Promise.all([history, sendQueue, nodeStoreAdding]) console.log('waiting done') - + return }//}}} } -class N2Menu extends CustomHTMLElement { - static {// {{{ - this.tmpl = document.createElement('template') - this.tmpl.innerHTML = ` -
Popover content
- ` - }// }}} - constructor() {// {{{ - super() - }// }}} -} -customElements.define('n2-menu', N2Menu) // vim: foldmethod=marker diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index a726451..381d672 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -6,8 +6,6 @@
>
- -
Popover content
From dbd3872f0fd90c9f672fcc64d572a56f080c8316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 16 Jun 2026 08:35:58 +0200 Subject: [PATCH 13/27] Fixed flashing history page --- views/pages/notes2.gotmpl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index 381d672..d54c71d 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,9 +1,12 @@ {{ define "page" }} + + + -
+
>
@@ -37,9 +40,6 @@
- - -