import { ROOT_NODE, uuidv7 } from 'node_store' import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { MarkedPosition } from './marked_position.mjs' export class N2PageNodeUI extends CustomHTMLElement { static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
` }// }}} constructor() {// {{{ super() 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 => { this.node = event.detail.data this.showMarkdown(true) this.render() }) _mbus.subscribe('NODE_MODIFIED', () => { this.classList.add('node-modified') this.elIconSave.src = `/images/${_VERSION}/icon_save.svg` this.elIconSave.classList.add('colorize') this.renderName() }) _mbus.subscribe('NODE_UNMODIFIED', () => { this.classList.remove('node-modified') this.elIconSave.src = `/images/${_VERSION}/icon_save_disabled.svg` this.elIconSave.classList.remove('colorize') }) _mbus.subscribe('MARKDOWN_TOGGLE', () => this.showMarkdown(!this.showMarkdown())) _mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data)) _mbus.subscribe('MARKDOWN_CHANGE_CHECKBOX', ({ detail }) => this.checkboxUpdated(detail.data)) this.elName.addEventListener('click', async () => this.renameNode()) this.elNodeContent.addEventListener('input', event => this.contentChanged(event)) this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event)) this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown())) this.elIconTableFormat.addEventListener('click', event => { if (!event.shiftKey) this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value) else { const from = this.elNodeContent.selectionStart const to = this.elNodeContent.selectionEnd const text = this.elNodeContent.value.slice(from, to) const formatted = this.formatAllTables(text) this.elNodeContent.setRangeText(formatted, from, to, 'select'); } 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.showMarkdown(true) }// }}} renderName() {// {{{ this.elName.innerText = this.node?.get('Name') ?? '' }// }}} render() {// {{{ this.elName.innerText = this.node?.get('Name') ?? '' this.elNodeContent.value = this.node?.get('Content') ?? '' this.elNodeMarkdown.innerHTML = this.marked.parse(this.elNodeContent.value) }// }}} takeFocus() {// {{{ if (this.showMarkdown()) { this.elNodeMarkdown.focus({ preventScroll: true }) } else this.elNodeContent.focus({ preventScroll: true }) }// }}} async renameNode() {// {{{ const name = prompt('Change title', this.node.data.Name) if (name === null) return try { // Document isn't only renamed, but also saved at once. // Not really correct, but good enough to not have to implement // a separate way to only rename the document. Since history is // preserved it shouldn't be that horrible. this.node.setName(name) await this.node.save() // Re-render the parent treenode forcefully to sort it again. const parentUUID = this.node.ParentUUID if (!parentUUID) return const parentTreeNode = _app.sidebar.getTreeNode(parentUUID) parentTreeNode?.render(true, true) } catch (err) { console.error(err) alert(err) } }// }}} async saveNode() {// {{{ if (!this.node.isModified()) return // node.save takes care of both "nodes" and "nodes_history" stores, also adds it to send queue. // Sets "Updated" value to current date and time and generates a new history UUID. await this.node.save() }// }}} contentChanged(event) {//{{{ this.node.setContent(event.target.value) }//}}} isModified() {// {{{ return this.node?.isModified() }// }}} showMarkdown(state) {// {{{ // No point in showing markdown if there is no data. // If there is no data, it will show a blank page regardless, and the user will most // likely want to edit content, which can't be done in markdown. const show = this.node?.content().trim() !== '' && state switch (show) { case true: this.elNodeMarkdown.innerHTML = this.marked.parse(this.elNodeContent.value) this.elIconMarkdown.src = `/images/${_VERSION}/icon_markdown.svg` this.elIconMarkdown.classList.add('colorize') this.classList.add('show-markdown') break case false: this.elIconMarkdown.src = `/images/${_VERSION}/icon_markdown_hollow.svg` this.elIconMarkdown.classList.remove('colorize') this.classList.remove('show-markdown') break case null: case undefined: return this.classList.contains('show-markdown') } }// }}} async pasteHandler(event) {// {{{ const clipboardItems = event.clipboardData?.items if (!clipboardItems) return for (const item of clipboardItems) { switch (item.kind) { case 'string': continue case 'file': const file = item.getAsFile() if (!file) throw new Error("Couldn't convert image to file object.") const uuid = uuidv7() await globalThis.nodeStore.files.add({ data: { UUID: uuid, file: file } }) const [start, end] = [this.elNodeContent.selectionStart, this.elNodeContent.selectionEnd] this.elNodeContent.setRangeText(`![${file.name}](db://${uuid})`, start, end, 'select'); // Editing the textarea programatically doesn't generate the events it usually gets when edited interactively. this.node.setContent(this.elNodeContent.value) break default: alert(`Unknown paste type of '${item.kind}'`) } } }// }}} editMarkdown(data) {// {{{ this.showMarkdown(false) this.elNodeContent.selectionStart = data.position.start this.elNodeContent.selectionEnd = data.position.end this.elNodeContent.focus() }// }}} findTables(lines) {// {{{ let tables = [] let curr = { from: -1, to: -1 } for (let i = 0; i < lines.length; i++) { const linecols = lines[i].split('|').length - 2 // Gives empty value in front of first pipe and after last one. if (linecols >= 1) { if (curr.from == -1) curr.from = i curr.to = i } else if (linecols < 1 && curr.to > -1) { tables.push(curr) curr = { from: -1, to: -1 } } } if (curr.from > -1) tables.push(curr) return tables }// }}} formatAllTables(text) {// {{{ const lines = text.split(/\r?\n/) const tables = this.findTables(lines) for (const table of tables) { const formattedLines = this.formatTable(lines.slice(table.from, table.to + 1)) lines.splice(table.from, formattedLines.length, ...formattedLines) } return lines.join("\n") }// }}} formatTable(lines) {// {{{ let numColumns = 0 let colwidth = [] for (let i = 0; i < lines.length; i++) { // -1 for split, -1 because number of columns are one less than number of pipes. const columns = lines[i].split('|').slice(1) const linecols = columns.length - 2 numColumns = Math.max(numColumns, linecols) // Keep count of column width. for (let j = 0; j < columns.length - 1; j++) { colwidth[j] = Math.max(colwidth[j] || 0, columns[j].trim().length) } } // Build up each line correct. let extendHeader for (let i = 0; i < lines.length; i++) { // Build lines with columns. const cols = lines[i].split('|').slice(1, -1) // Second line should be headers. if (i === 1) { extendHeader = true for (let j = 0; j < colwidth.length; j++) { extendHeader &= ((cols[j] || '').match(/^\s*[-]*\s*$/) !== null) } } if (i === 1 && extendHeader) { for (let j = 0; j < colwidth.length; j++) cols[j] = '-'.repeat(colwidth[j]) } else { for (let j = 0; j < colwidth.length; j++) { cols[j] = (cols[j] || '').trim() const cw = colwidth[j] const padWidth = cw - (cols[j]?.length || 0) // may be a column that doesn't exist on this line. cols[j] = cols[j] + ' '.repeat(padWidth > 0 ? padWidth : 0) } } lines[i] = '| ' + cols.join(' | ') + ' |' } return lines }// }}} // "marked" sends a messagebus event when checking/unchecking a checkbox. // Updates node and content textarea. checkboxUpdated(eventData) {// {{{ const checkbox = eventData.checkbox const pos = eventData.position const content = this.node.content() // Basic validation to verify that Marked does what is known and expected at this writing. const mdCheckboxStr = content.slice(pos.start, pos.end) if (!mdCheckboxStr.match(/^\[[ xX]\] $/)) { alert(`Checkbox string didn't pass validation: '${mdCheckboxStr}'`) console.error(`Checkbox string didn't pass validation: '${mdCheckboxStr}'`) } // 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 modifiedContent = this.node.content().slice(0, pos.start) + newValue + this.node.content().slice(pos.end) this.node.setContent(modifiedContent) // Also update the textarea since the node model doesn't know about it. this.elNodeContent.setRangeText(newValue, pos.start, pos.end, 'select') }// }}} } customElements.define('n2-nodeui', N2PageNodeUI) export class Node { static sort(a, b) {//{{{ // Nodes with children ("folders") are sorted first. if (a._has_children && !b._has_children) return -1 if (!a._has_children && b._has_children) return 1 // Otherwise sort by lowercased name. const an = a.data.Name.toLowerCase() const bn = b.data.Name.toLowerCase() if (an < bn) return -1 if (an > bn) return 1 return 0 }//}}} static create(name, parentUUID) {// {{{ const node = new Node({ UUID: uuidv7(), Created: (new Date()).toISOString(), Content: '', Name: name, ParentUUID: parentUUID, Markdown: false, }) // Newly created node (not constructed from existing data) is considered modified // since node.save returns early if it isn't modified. node._modified = true return 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. if (nodeData.UUID !== ROOT_NODE && nodeData.ParentUUID === '') { this.ParentUUID = ROOT_NODE this.data.ParentUUID = ROOT_NODE } else this.ParentUUID = nodeData.ParentUUID this._children_fetched = false this._has_children = null // this will be set by nodeStore.getTreeNodes this.Children = [] this.Ancestors = [] this._sibling_before = null this._sibling_after = null this._parent = null this.reset() }//}}} reset() {// {{{ this._content = this.data.Content this._modified = false }// }}} get(prop) {//{{{ return this.data[prop] }//}}} updated() {//{{{ // '2024-12-17T17:33:48.85939Z return new Date(Date.parse(this.data.Updated)) }//}}} isModified() {// {{{ return this._modified }// }}} hasFetchedChildren() {//{{{ return this._children_fetched }//}}} async fetchChildren() {//{{{ this.Children = await nodeStore.getTreeNodes(this.UUID, this.Level + 1) this._children_fetched = true // Children are sorted to allow for storing siblings befare and after. // These are used with keyboard navigation in the tree. this.Children.sort(Node.sort) const numChildren = this.Children.length this.setHasChildren(numChildren > 0) for (let i = 0; i < numChildren; i++) { if (i > 0) this.Children[i]._sibling_before = this.Children[i - 1] if (i < numChildren - 1) this.Children[i]._sibling_after = this.Children[i + 1] this.Children[i]._parent = this } return this.Children }//}}} setHasChildren(v) {// {{{ this._has_children = v }// }}} hasChildren() {//{{{ return this._has_children }//}}} getSiblingBefore() {// {{{ return this._sibling_before }// }}} getSiblingAfter() {// {{{ return this._sibling_after }// }}} getParent() {//{{{ return this._parent }//}}} moveToParent(newParentUUID) {// {{{ this.ParentUUID = newParentUUID this.data.ParentUUID = newParentUUID this._modified = true }// }}} isLastSibling() {//{{{ return this._sibling_after === null }//}}} isFirstSibling() {//{{{ return this._sibling_before === null }//}}} content() {//{{{ /* TODO - implement crypto if (this.CryptoKeyID != 0 && !this._decrypted) this.#decrypt() */ return this._content }//}}} setContent(new_content) {//{{{ this._content = new_content this._modified = true _mbus.dispatch('NODE_MODIFIED', { node: this }) }//}}} setName(new_name) {// {{{ if (new_name.trim() === '') throw new Error(`The name can't be empty`) this.data.Name = new_name this._modified = true _mbus.dispatch('NODE_MODIFIED', { node: this }) }// }}} async save() {//{{{ // Just safeguarding not using the root node, // which sort of exist but isn't supposed to communicate to server. if (this.UUID == ROOT_NODE) return this.data.Content = this._content this.data.Updated = new Date().toISOString() this.data.HistoryUUID = uuidv7() // every time the node is saved a new history UUID identifies the changed node. 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) 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. * * 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. */ // Current node is added to history. It will be duplicated with the "nodes" store // for simplicity, to hopefully avoid bugs. 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]) console.log('waiting') await Promise.all([history, sendQueue, nodeStoreAdding]) console.log('waiting done') return }//}}} } // vim: foldmethod=marker