Notes2/static/js/app.mjs
2026-06-10 16:37:33 +02:00

342 lines
9.1 KiB
JavaScript

import { ROOT_NODE } from 'node_store'
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { N2Sidebar } from 'sidebar'
import { Node } from 'node'
export class App {
constructor() {// {{{
this.currentNode = null
this.sidebar = new N2Sidebar()
this.crumbs = new N2Crumbs()
this.crumbsElement = document.getElementById('crumbs')
this.nodeUI = document.getElementById('note')
this.sidebar.render().then(sidebar => {
document.getElementById('tree').append(sidebar)
document.getElementById('tree-nodes')?.focus()
})
_mbus.subscribe('TREE_RENDERED', async () => {
// Subscribing to the start node existing after the tree trunk is
// fetched since the NODE_COMPONENT_EXIST message isn't sent for the
// root node itself, and the root node should be selected in the tree
// after it is rendered when the site is shown without UUID in the URL.
const startNode = await this.getStartNode()
if (startNode.UUID == ROOT_NODE)
this.goToNode(startNode.UUID, false, false)
else
this.goToNode(startNode.UUID, false, false)
})
_mbus.subscribe('TREE_NODE_SELECTED', event => {
const node = event.detail.data
this.goToNode(node.UUID, false, false)
})
_mbus.subscribe('GO_TO_NODE', event => {
const node = event.detail.data
this.goToNode(node.nodeUUID, node.dontPush, node.dontExpand)
})
_mbus.subscribe('SHOW_PAGE', ({ detail: { data: { page } } }) => {
let classList = document.querySelector('#main-page').classList
classList.forEach(e =>
classList.remove(e)
)
classList.add(page)
classList = document.querySelector('#notes2').classList
classList.forEach(e => {
if (e.startsWith('page-'))
classList.remove(e)
})
classList.add('page-' + page)
})
window.addEventListener('keydown', event => this.keyHandler(event))
window.addEventListener('popstate', event => this.popState(event))
document.getElementById('notes2').addEventListener('click', event => {
if (event.target.id === 'notes2')
document.getElementById('node-content')?.focus()
})
_mbus.dispatch('SHOW_PAGE', { page: 'node' })
window._sync = new Sync()
// I think it is uncomfortable having the sync running as soon as the page load.
// I haven't gotten the time to look at the page before stuff jumps around.
// There a slight delay to initiate sync seems reasonable.
setTimeout(() => window._sync.run(), 1000)
}// }}}
keyHandler(event) {//{{{
let handled = true
// All keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees.
// Ctrl+S is the exception to using Alt+Shift, since it is overridable and in such widespread use for saving.
// Thus, the exception is acceptable to consequent use of alt+shift.
if (!(event.shiftKey && event.altKey) && !(event.key.toUpperCase() === 'S' && event.ctrlKey))
return
switch (event.key.toUpperCase()) {
case 'T':
if (document.activeElement.id === 'tree-nodes') {
this.nodeUI.takeFocus()
} else {
this.sidebar.focus()
}
break
case 'F':
_mbus.dispatch('op-search')
break
/*
case 'C':
this.showPage('node')
break
case 'E':
this.showPage('keys')
break
*/
case 'M':
globalThis._mbus.dispatch('MARKDOWN_TOGGLE')
break
case 'N':
this.createNode()
break
/*
case 'P':
this.showPage('node-properties')
break
*/
case 'S':
this.nodeUI.saveNode()
break
/*
case 'U':
this.showPage('upload')
break
case 'F':
this.showPage('search')
break
*/
default:
handled = false
}
if (handled) {
event.preventDefault()
event.stopPropagation()
}
}//}}}
popState(event) {// {{{
_mbus.dispatch("GO_TO_NODE", { nodeUUID: event.state.nodeUUID, dontPush: true, dontExpand: true })
}// }}}
async getStartNode() {//{{{
let nodeUUID = ROOT_NODE
// Is a UUID provided on the URI as an anchor?
const parts = document.URL.split('#')
if (parts[1]?.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i))
nodeUUID = parts[1]
return await nodeStore.get(nodeUUID)
}//}}}
async saveNode() {//{{{
}//}}}
async createNode() {//{{{
let name = prompt("Name")
if (!name)
return
const nn = Node.create(name, this.currentNode.UUID)
nn.save()
}//}}}
async goToNode(nodeUUID, dontPush, dontExpand) {//{{{
if (nodeUUID === null || nodeUUID === undefined)
return
// Don't switch notes until saved.
if (this.nodeUI.isModified()) {
if (!confirm("Changes not saved. Do you want to discard changes?"))
return
}
if (!dontPush)
history.pushState({ nodeUUID }, '', `/#${nodeUUID}`)
const node = nodeStore.node(nodeUUID)
node.reset() // any modifications are discarded.
this.currentNode = node
this.sidebar.setSelected(node, dontExpand)
const ancestors = await nodeStore.getNodeAncestry(node)
// Scrolls node into view.
// makeVisible normally expands all ancestor nodes to make the whole chain visible.
// This is a bad idea when quickly navigating the tree, since the arrow navigation
// has collapsed nodes which the event calling goToNode can come to undo, if the
// event processing lags behind.
await this.sidebar.makeVisible(node, ancestors, dontExpand)
_mbus.dispatch('CRUMBS_SET', ancestors, () => this.crumbsElement.replaceChildren(this.crumbs.render()))
_mbus.dispatch('NODE_UI_OPEN', node)
_mbus.dispatch('TREE_EXPANSION', { expand: false, when: 'narrow' })
_mbus.dispatch('NODE_UNMODIFIED')
_mbus.dispatch('SHOW_PAGE', { page: 'node' })
}//}}}
pageIsVisible(page) {// {{{
let classList = document.querySelector('#main-page').classList
return classList.contains(page)
}// }}}
}
class N2Crumbs extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
`
}// }}}
constructor() {// {{{
super()
this.classList.add('crumbs')
this.crumbs = []
_mbus.subscribe('CRUMBS_SET', event => {
this.crumbs = event.detail.data
})
}// }}}
render() {// {{{
const crumbs = this.crumbs.map(node =>
new N2Crumb(
node.get('Name'),
node.UUID,
)
)
const start = new N2Crumb('', ROOT_NODE)
crumbs.push(start)
this.replaceChildren(...crumbs.reverse())
return this
}// }}}
}
customElements.define('n2-crumbs', N2Crumbs)
class N2Crumb extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<a data-el="link"></a>
`
}// }}}
constructor(label, uuid) {// {{{
super()
// The house makes it a bit more graphical than just a bunch of text.
if (uuid === ROOT_NODE) {
const start = document.createElement('div')
start.innerHTML = `<img src="/images/${_VERSION}/icon_home.svg">`
start.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: ROOT_NODE, dontPush: false, dontExpand: true }))
this.classList.add('home')
this.replaceChildren(start)
return
}
this.classList.add('crumb')
this.label = label
this.uuid = uuid
this.elLink.href = `/#${this.uuid}`
this.elLink.innerText = this.label
this.elLink.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: this.uuid, dontPush: false, dontExpand: true }))
}// }}}
}
customElements.define('n2-crumb', N2Crumb)
function tmpl(html) {// {{{
const el = document.createElement('template')
el.innerHTML = html
return el.content.children
}// }}}
class Op {
constructor(id) {// {{{
this.id = id
_mbus.subscribe(this.id, p => this.render(p))
}// }}}
render(html) {// {{{
const op = document.getElementById('op')
const t = document.createElement('template')
t.innerHTML = `<dialog id="${this.id}" class="op">${html}</dialog>`
op.replaceChildren(t.content)
document.getElementById(this.id).showModal()
}// }}}
get(selector) {// {{{
return document.querySelector(`#${this.id} ${selector}`)
}// }}}
bind(selector, event, fn) {// {{{
this.get(selector).addEventListener(event, evt => fn(evt))
}// }}}
}
class OpSearch extends Op {
constructor() {// {{{
super('op-search')
}// }}}
render() {// {{{
super.render(`
<div class="header">Search</div>
<div>
<input type="text" />
</div>
<div class="header">Results</div>
<div class="results"></div>
`)
this.bind('input[type="text"]', 'keydown', evt => this.search(evt))
}// }}}
search(event) {// {{{
if (event.key !== 'Enter')
return
const searchFor = document.querySelector('#op-search input').value
nodeStore.search(searchFor, ROOT_NODE)
.then(res => this.displayResults(res))
}// }}}
displayResults(results) {// {{{
const rs = []
for (const r of results) {
const ancestors = r.ancestry.reverse().map(a => {
const div = tmpl(`<div class="ancestor">${a.data.Name}</div>`)
div[0].addEventListener('click', () => _notes2.current.goToNode(a.UUID))
return div[0]
})
const div = tmpl(`<div>${r.name}</div>`)
div[0].addEventListener('click', () => _notes2.current.goToNode(r.uuid))
rs.push(...div)
const ancDev = tmpl('<div class="ancestors"></div>')
ancDev[0].append(...ancestors)
rs.push(ancDev[0])
}
this.get('.results').replaceChildren(...rs)
}// }}}
}
// vim: foldmethod=marker