import { ROOT_NODE, uuidv7, StoreFile } 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', () => { document.querySelector('#crumbs .crumbs')?.classList.add('node-modified') this.elIconSave.src = `/images/${_VERSION}/icon_save.svg` this.renderName() }) _mbus.subscribe('NODE_UNMODIFIED', () => { document.querySelector('#crumbs .crumbs')?.classList.remove('node-modified') this.elIconSave.src = `/images/${_VERSION}/icon_save_disabled.svg` }) _mbus.subscribe('MARKDOWN_TOGGLE', () => this.showMarkdown(!this.showMarkdown())) _mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data)) this.elName.addEventListener('click', () => { const name = prompt('Change title', this.node.data.Name) if (name === null) return try { this.node.setName(name) } catch (err) { console.error(err) alert(err) } }) 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.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() } else this.elNodeContent.focus() }// }}} 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.classList.add('show-markdown') break case false: this.elIconMarkdown.src = `/images/${_VERSION}/icon_markdown_hollow.svg` 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'); break default: alert(`Unknown paste type of '${item.kind}'`) } } } // Example usage: Displaying the image or preparing it for upload handleImageBlob(blob) { // 1. Create a local URL to preview it in an tag if needed const localUrl = URL.createObjectURL(blob) console.log('Local preview URL:', localUrl) // 2. Or prepare it for a FormData upload const formData = new FormData() formData.append('image', blob, 'pasted-image.png') // fetch('/upload', { method: 'POST', body: formData }) } editMarkdown(data) {// {{{ this.showMarkdown(false) this.elNodeContent.selectionStart = data.position.start this.elNodeContent.selectionEnd = data.position.end this.elNodeContent.focus() }// }}} } customElements.define('n2-nodeui', N2PageNodeUI) export class Node { static sort(a, b) {//{{{ if (a.data.Name < b.data.Name) return -1 if (a.data.Name > b.data.Name) return 0 return 0 }//}}} static create(name, parentUUID) {// {{{ return new Node({ UUID: uuidv7(), Created: (new Date()).toISOString(), Content: '', Name: name, ParentUUID: parentUUID, Markdown: false, History: false, }) }// }}} constructor(nodeData, level) {//{{{ this.Level = level this.data = nodeData this.UUID = nodeData.UUID // Toplevel nodes are normalized to have the ROOT_NODE as parent. 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.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 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 } // Notify the tree that all children are fetched and ready to process. //_notes2.current.tree.fetchChildrenOn(this.UUID) _mbus.dispatch(`NODE_CHILDREN_FETCHED_${this.UUID}`) return this.Children }//}}} hasChildren() {//{{{ return this.Children.length > 0 }//}}} getSiblingBefore() {// {{{ return this._sibling_before }// }}} getSiblingAfter() {// {{{ return this._sibling_after }// }}} getParent() {//{{{ return this._parent }//}}} 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() {//{{{ this.data.Content = this._content 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) this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse() }//}}} } // vim: foldmethod=marker