import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { MarkedPosition } from './marked_position.mjs' class N2NodeMenu extends CustomHTMLElement { static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
` }// }}} constructor() {// {{{ super() }// }}} } customElements.define('n2-nodemenu', N2NodeMenu) export class N2PageNodeUI extends CustomHTMLElement { static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
` }// }}} constructor() {// {{{ super() this.node = null this.style.display = 'contents' this.marked = new MarkedPosition() _mbus.subscribe('NODE_UI_OPEN', event => { this.node = event.detail.data if (!this.node.isSpecial()) 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)) // 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.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 { 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.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() {// {{{ 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 fileMetadata = await globalThis.nodeStore.storeFile(file) const [start, end] = [this.elNodeContent.selectionStart, this.elNodeContent.selectionEnd] this.elNodeContent.setRangeText(`![${file.name}](db://${fileMetadata.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) // vim: foldmethod=marker