412 lines
12 KiB
JavaScript
412 lines
12 KiB
JavaScript
import { ROOT_NODE } from 'node_store'
|
|
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
|
import { N2Sidebar } from 'sidebar'
|
|
import { Node } from 'node'
|
|
import { N2PreferenceSet } from './page_preferences.mjs'
|
|
|
|
export class App {
|
|
static PAGES = ['node', 'history', 'storage']
|
|
|
|
constructor() {// {{{
|
|
this.currentNode = null
|
|
this.sidebar = new N2Sidebar()
|
|
this.crumbs = new N2Crumbs()
|
|
this.crumbsElement = document.getElementById('crumbs')
|
|
this.nodeUI = document.getElementById('note')
|
|
this.dragIcon = new N2DragIcon()
|
|
|
|
this.preferences = this.getPreferences()
|
|
|
|
this.sidebar.render().then(sidebar => {
|
|
document.getElementById('tree').append(sidebar)
|
|
document.getElementById('tree-nodes')?.focus()
|
|
})
|
|
|
|
// Start node shows a system-wide page instead of node editing
|
|
// since the start node is kind of magic and doesn't fit into
|
|
// the syncing system.
|
|
const determineNodePage = uuid => {
|
|
const el = document.getElementById('notes2')
|
|
if (uuid == ROOT_NODE)
|
|
el.classList.add('root-node-override')
|
|
else
|
|
el.classList.remove('root-node-override')
|
|
}
|
|
|
|
_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()
|
|
determineNodePage(startNode.UUID)
|
|
this.goToNode(startNode.UUID, false, false)
|
|
})
|
|
|
|
_mbus.subscribe('TREE_NODE_SELECTED', event => {
|
|
const node = event.detail.data
|
|
determineNodePage(node.UUID)
|
|
this.goToNode(node.UUID, false, false)
|
|
})
|
|
|
|
_mbus.subscribe('GO_TO_NODE', event => {
|
|
const node = event.detail.data
|
|
determineNodePage(node.nodeUUID)
|
|
this.goToNode(node.nodeUUID, node.dontPush, node.dontExpand)
|
|
})
|
|
|
|
_mbus.subscribe('SHOW_PAGE', ({ detail: { data: { page } } }) => {
|
|
const classList = document.getElementById('notes2').classList
|
|
classList.forEach(e => {
|
|
if (e.startsWith('page-'))
|
|
classList.remove(e)
|
|
})
|
|
classList.add('page-' + page)
|
|
})
|
|
|
|
_mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', ()=>{
|
|
this.preferences = this.getPreferences()
|
|
console.log(this.preferences.data)
|
|
})
|
|
|
|
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()
|
|
})
|
|
|
|
document.querySelector('#page-root .create').addEventListener('click', () => this.createNode())
|
|
document.body.append(this.dragIcon)
|
|
|
|
_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
|
|
|
|
// Most 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.
|
|
const CTRL = !event.shiftKey && event.ctrlKey && !event.altKey
|
|
const SHIFT_ALT = event.shiftKey && !event.ctrlKey && event.altKey
|
|
const SHIFT_CTRL_ALT = event.shiftKey && event.ctrlKey && event.altKey
|
|
|
|
switch (event.key.toUpperCase()) {
|
|
case 'F2':
|
|
this.nodeUI.renameNode()
|
|
break
|
|
case 'T':
|
|
if (!SHIFT_ALT) { handled = false; break }
|
|
if (document.activeElement.id === 'tree-nodes')
|
|
this.nodeUI.takeFocus()
|
|
else
|
|
this.sidebar.focus()
|
|
break
|
|
|
|
case 'F':
|
|
if (!SHIFT_ALT) { handled = false; break }
|
|
_mbus.dispatch('op-search')
|
|
break
|
|
|
|
case 'M':
|
|
if (!SHIFT_ALT) { handled = false; break }
|
|
globalThis._mbus.dispatch('MARKDOWN_TOGGLE')
|
|
break
|
|
|
|
case 'N':
|
|
if (SHIFT_ALT)
|
|
this.createNode()
|
|
else if (SHIFT_CTRL_ALT) {
|
|
this.createNode(this.currentNode?.ParentUUID)
|
|
} else {
|
|
handled = false
|
|
}
|
|
break
|
|
|
|
case 'S':
|
|
if (!CTRL) { handled = false; break }
|
|
this.nodeUI.saveNode()
|
|
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 moveNode(node, targetNodeUUID) {// {{{
|
|
node.moveToParent(targetNodeUUID)
|
|
await node.save()
|
|
}// }}}
|
|
async createNode(createUnderUUID) {//{{{
|
|
const parentUUID = createUnderUUID ? createUnderUUID : this.currentNode.UUID
|
|
const p = createUnderUUID ? 'Name for sibling document' : 'Name for sub-document'
|
|
|
|
let name = prompt(p)
|
|
if (!name)
|
|
return
|
|
|
|
const nn = Node.create(name, parentUUID)
|
|
await nn.save()
|
|
|
|
// Treenode is forcefully rerendered and children refetched to both show the new node
|
|
// and to get it resorted.
|
|
const parentTreenode = this.sidebar.getTreeNode(parentUUID)
|
|
await parentTreenode.render(true, true)
|
|
_mbus.dispatch('GO_TO_NODE', { nodeUUID: nn.UUID })
|
|
}//}}}
|
|
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)
|
|
}// }}}
|
|
getPreferences() {// {{{
|
|
const devPrefSet = localStorage.getItem('device_preference_set') || 'default'
|
|
const userData = localStorage.getItem('user') || '{"default": {}}'
|
|
const user = JSON.parse(userData)
|
|
return new N2PreferenceSet(devPrefSet, user.Preferences[devPrefSet])
|
|
}// }}}
|
|
}
|
|
|
|
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
|
|
}// }}}
|
|
}
|
|
|
|
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 }))
|
|
}// }}}
|
|
}
|
|
|
|
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)
|
|
}// }}}
|
|
}
|
|
|
|
class N2DragIcon extends CustomHTMLElement {
|
|
static {// {{{
|
|
this.tmpl = document.createElement('template')
|
|
this.tmpl.innerHTML = `
|
|
<style>
|
|
:host {
|
|
display: none;
|
|
position: absolute;
|
|
z-index: 16384;
|
|
pointer-events: none;
|
|
}
|
|
</style>
|
|
<img data-el="icon" src="/images/${_VERSION}/icon_drag.svg">
|
|
`
|
|
}// }}}
|
|
constructor() {// {{{
|
|
super(true)
|
|
|
|
document.addEventListener('dragover', e => {
|
|
this.style.left = `${e.clientX + 8}px`
|
|
this.style.top = `${e.clientY}px`
|
|
})
|
|
|
|
this.dragSource = null
|
|
}// }}}
|
|
start() {// {{{
|
|
this.style.display = 'block'
|
|
}// }}}
|
|
end() {// {{{
|
|
this.style.display = 'none'
|
|
}// }}}
|
|
icon(name) {// {{{
|
|
if (name != '')
|
|
name = '_' + name
|
|
this.elIcon.setAttribute('src', `/images/${_VERSION}/icon_drag${name}.svg`)
|
|
}// }}}
|
|
setSource(s) {// {{{
|
|
this.dragSource = s
|
|
}// }}}
|
|
getSource() {// {{{
|
|
return this.dragSource
|
|
}// }}}
|
|
}
|
|
|
|
customElements.define('n2-crumbs', N2Crumbs)
|
|
customElements.define('n2-crumb', N2Crumb)
|
|
customElements.define('n2-dragicon', N2DragIcon)
|
|
|
|
// vim: foldmethod=marker
|