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" }} + + +
>