527 lines
16 KiB
JavaScript
527 lines
16 KiB
JavaScript
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 = `
|
|
<style>
|
|
.el-functions {
|
|
display: grid;
|
|
grid-template-columns: 1fr repeat(5, min-content);
|
|
grid-gap: 8px;
|
|
align-items: center;
|
|
justify-items: end;
|
|
cursor: pointer;
|
|
|
|
img {
|
|
height: 24px;
|
|
}
|
|
}
|
|
</style>
|
|
<div data-el="name"></div>
|
|
|
|
<textarea data-el="node-content" required rows=1></textarea>
|
|
<div data-el="node-markdown" tabindex=1></div>
|
|
|
|
<div data-el="functions">
|
|
<img data-el="icon-save" src="/images/${_VERSION}/icon_save_disabled.svg">
|
|
<img data-el="icon-markdown">
|
|
<img data-el="icon-table-format" class="colorize" src="/images/${_VERSION}/icon_table.svg">
|
|
<img data-el="icon-history" class="colorize" src="/images/${_VERSION}/icon_history.svg">
|
|
<img data-el="icon-new-document" class="colorize" src="/images/${_VERSION}/icon_new_document.svg">
|
|
<img data-el="icon-menu" class="colorize" src="/images/${_VERSION}/icon_menu.svg" popovertarget="node-menu">
|
|
</div>
|
|
`
|
|
}// }}}
|
|
|
|
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
|
|
|
|
|
|
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))
|
|
|
|
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(``, 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) {// {{{
|
|
if (this.UUID === newParentUUID)
|
|
throw new Error("New parent UUID is the same as node UUID. Can't be your own parent.")
|
|
|
|
this.ParentUUID = newParentUUID
|
|
this.data.ParentUUID = newParentUUID
|
|
this._modified = true
|
|
}// }}}
|
|
isLastSibling() {//{{{
|
|
return this._sibling_after === null
|
|
}//}}}
|
|
isFirstSibling() {//{{{
|
|
return this._sibling_before === null
|
|
}//}}}
|
|
isSpecial() {// {{{
|
|
return this.data.Special
|
|
}// }}}
|
|
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
|
|
}//}}}
|
|
}
|
|
|
|
class N2Menu extends CustomHTMLElement {
|
|
static {// {{{
|
|
this.tmpl = document.createElement('template')
|
|
this.tmpl.innerHTML = `
|
|
<div id="node-menu" popover>Popover content</div>
|
|
`
|
|
}// }}}
|
|
constructor() {// {{{
|
|
super()
|
|
}// }}}
|
|
}
|
|
customElements.define('n2-menu', N2Menu)
|
|
|
|
// vim: foldmethod=marker
|