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 => {
console.log(event.detail.data.eventSequence, _app.showNodeEventSequence.current())
this.node = event.detail.data.node
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))
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.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.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 saveNode() {// {{{
if (!this.node.isModified())
return
/* 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. */
// The node is still in its old state and will present
// the unmodified content to the node store.
const history = nodeStore.nodesHistory.add(this.node)
// Prepares the node object for saving.
// Sets Updated value to current date and time.
await this.node.save()
// Updated node is added to the send queue to be stored on server.
const sendQueue = nodeStore.sendQueue.add(this.node)
// Updated node is saved to the primary node store.
const nodeStoreAdding = nodeStore.add([this.node])
await Promise.all([history, sendQueue, nodeStoreAdding])
}// }}}
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(``, 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
}// }}}
}
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) {// {{{
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._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
}//}}}
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