import { Editor } from '@editor' import { MessageBus } from '@mbus' export class App { constructor() {// {{{ window.mbus = new MessageBus() this.editor = null this.typesList = null this.currentNode = null this.currentNodeID = null this.types = [] this.currentPage = null this.tree = new Tree(document.getElementById('nodes')) const events = [ 'EDITOR_NODE_SAVE', 'MENU_ITEM_SELECTED', 'NODE_CREATE_DIALOG', 'NODE_DELETE', 'NODE_EDIT_NAME', 'NODE_MOVE', 'NODE_REMOVED', 'NODE_SELECTED', 'TREE_RELOAD_NODE', 'TYPES_LIST_FETCHED', ] for (const eventName of events) mbus.subscribe(eventName, event => this.eventHandler(event)) document.addEventListener('keydown', event => this.keyHandler(event)) mbus.dispatch('MENU_ITEM_SELECTED', 'node') }// }}} eventHandler(event) {// {{{ switch (event.type) { case 'MENU_ITEM_SELECTED': const item = document.querySelector(`#menu [data-section="${event.detail}"]`) this.page(item, event.detail) break case 'NODE_SELECTED': for (const n of document.querySelectorAll('#nodes .node.selected')) n.classList.remove('selected') if (event.detail !== null) for (const n of document.querySelectorAll(`#nodes .node[data-node-id="${event.detail}"]`)) n.classList?.add('selected') this.edit(event.detail) break case 'NODE_DELETE': if (!confirm('Are you sure you want to delete this node?')) return this.nodeDelete(this.currentNode.ID) break case 'NODE_MOVE': const nodes = this.tree.markedNodes() if (!confirm(`Are you sure you want to move ${nodes.length} nodes here?`)) return this.nodesMove(nodes, this.currentNode.ID) break case 'NODE_REMOVED': // Event dispatched when a tree node is removed after an update. if (this.currentNode.ID !== event.detail) return mbus.dispatch('NODE_SELECTED', null) break case 'EDITOR_NODE_SAVE': this.nodeUpdate() break case 'TYPES_LIST_FETCHED': const types = document.getElementById('types') types.replaceChildren(this.typesList.render()) break case 'NODE_CREATE_DIALOG': if (this.currentPage !== 'node' || this.currentNode === null) return new NodeCreateDialog(this.currentNode.ID) break case 'NODE_EDIT_NAME': const newName = prompt('Rename node', this.currentNode.Name) if (newName === null) return this.nodeRename(this.currentNode.ID, newName) .then(() => mbus.dispatch('TREE_RELOAD_NODE', { parentNodeID: this.currentNode.ParentID })) break case 'TREE_RELOAD_NODE': this.tree.updateNode(parseInt(event.detail.parentNodeID)) .then(() => { if (event.detail.callback) event.detail.callback() }) .catch(err => showError(err)) break default: alert(`Unhandled event: ${event.type}`) console.log(event) } }// }}} keyHandler(event) {// {{{ if (!event.shiftKey || !event.altKey) return let handled = true switch (event.key.toUpperCase()) { case 'D': mbus.dispatch('NODE_DELETE') break case 'M': mbus.dispatch('NODE_MOVE') break case 'N': mbus.dispatch('NODE_CREATE_DIALOG') break default: handled = false } if (handled) { event.stopPropagation() event.preventDefault() } }// }}} page(item, name) {// {{{ for (const el of document.querySelectorAll('#menu .item')) el.classList.remove('selected') item.classList.add('selected') for (const el of document.querySelectorAll('.page.show')) el.classList.remove('show') this.currentPage = name switch (name) { case 'node': document.getElementById('nodes').classList.add('show') document.getElementById('editor-node').classList.add('show') break case 'type': document.getElementById('types').classList.add('show') document.getElementById('editor-type-schema').classList.add('show') if (this.typesList === null) this.typesList = new TypesList() this.typesList.fetchTypes().then(() => { mbus.dispatch('TYPES_LIST_FETCHED') }) break } }// }}} edit(nodeID) {// {{{ if (nodeID === null) { document.getElementById('editor-node').style.display = 'none' this.currentNode = null return } fetch(`/nodes/${nodeID}`) .then(data => data.json()) .then(json => { if (!json.OK) { showError(json.Error) return } this.currentNode = json.Node // The JSON editor is created each time. Could probably be reused. const editorEl = document.querySelector('#editor-node .editor') this.editor = new Editor(json.Node.TypeSchema) if (json.Node.Data['x-new']) editorEl.replaceChildren(this.editor.render(null)) else editorEl.replaceChildren(this.editor.render(json.Node.Data)) // Name is separate from the JSON node. const name = document.getElementById('editor-node-name') name.innerText = json.Node.Name // The editor-node div is hidden from the start as a lot of the elements // doesn't make any sense before a node is selected. document.getElementById('editor-node').style.display = 'grid' }) }// }}} async nodeRename(nodeID, name) {// {{{ return new Promise((resolve, reject) => { name = name.trim() if (name.length === 0) { alert('A name must be provided.') return } fetch(`/nodes/rename/${nodeID}`, { method: 'POST', body: JSON.stringify({ Name: name, }), }) .then(data => data.json()) .then(json => { if (!json.OK) { showError(json.Error) return } this.edit(nodeID) resolve() }) .catch(err => reject(err)) }) }// }}} nodeUpdate() {// {{{ if (this.editor === null) return const btn = document.querySelector('#editor-node .controls button') btn.disabled = true const buttonPressed = Date.now() const nodeData = this.editor.data() fetch(`/nodes/update/${this.currentNode.ID}`, { method: 'POST', body: JSON.stringify(nodeData), }) .then(data => data.json()) .then(json => { if (!json.OK) { showError(json.Error) return } const timePassed = Date.now() - buttonPressed if (timePassed < 250) setTimeout(() => btn.disabled = false, 250 - timePassed) else btn.disabled = false }) }// }}} nodeDelete(nodeID) {// {{{ const node = this.tree.treeNodes.get(nodeID) const parentID = node.node.ParentID fetch(`/nodes/delete/${nodeID}`) .then(data => data.json()) .then(json => { if (!json.OK) { showError(json.Error) return } this.tree.updateNode(parseInt(parentID)) }) .catch(err => showError(err)) }// }}} nodesMove(nodes, newParentID) {// {{{ const req = { NewParentID: parseInt(newParentID), NodeIDs: nodes.map(n => n.ID), } fetch(`/nodes/move`, { method: 'POST', body: JSON.stringify(req), }) .then(data => data.json()) .then(async json => { if (!json.OK) { showError(json.Error) return } const updateParents = new Map() for (const n of nodes) updateParents.set(n.ParentID, true) updateParents.set(newParentID, true) for (const nodeID of updateParents.keys()) this.tree.updateNode(nodeID) }) .catch(err => showError(err)) }// }}} } class NodeCreateDialog { constructor(parentNodeID) {// {{{ this.parentNodeID = parentNodeID this.dialog = null this.types = null this.select = null this.input = null this.createElements() this.fetchTypes() .then(() => { const st = new SelectType(this.types) this.select.replaceChildren(st.render()) }) this.dialog.showModal() this.select.focus() }// }}} createElements() {// {{{ this.dialog = document.createElement('dialog') this.dialog.id = 'create-type' this.dialog.innerHTML = `
` this.dialog.querySelector('button').addEventListener('click', () => this.commit()) this.select = this.dialog.querySelector('select') this.input = this.dialog.querySelector('input') this.input.addEventListener('keydown', event => { if (event.key === 'Enter') this.commit() }) document.body.appendChild(this.dialog) }// }}} commit() {// {{{ if (this.input.value.trim().length === 0) { alert('Give a name.') return } const req = { ParentNodeID: this.parentNodeID, TypeID: parseInt(this.select.value), Name: this.input.value.trim(), } fetch('/nodes/create', { method: 'POST', body: JSON.stringify(req), }) .then(data => data.json()) .then(json => { if (!json.OK) { showError(json.Error) return } mbus.dispatch('TREE_RELOAD_NODE', { parentNodeID: this.parentNodeID, callback: () => mbus.dispatch('NODE_SELECTED', json.NodeID) }) this.dialog.close() }) .catch(err => showError(err)) }// }}} async fetchTypes() {// {{{ return new Promise((resolve, reject) => { fetch('/types/') .then(data => data.json()) .then(json => { if (!json.OK) { showError(json.Error) return } this.types = json.Types resolve() }) .catch(err => reject(err)) }) }// }}} } class SelectType { constructor(types) {// {{{ this.types = types }// }}} render() {// {{{ const tmpl = document.createElement('template') this.types.sort(typeSort) let prevGroup = null for (const t of this.types) { if (t.Name == 'root_node') continue if (t.Schema['x-group'] != prevGroup) { prevGroup = t.Schema['x-group'] const group = document.createElement('optgroup') group.setAttribute('label', t.Schema['x-group']) tmpl.content.appendChild(group) } const opt = document.createElement('option') opt.setAttribute('value', t.ID) opt.innerText = t.Schema.title || t.Name tmpl.content.appendChild(opt) } return tmpl.content }// }}} } export class Tree { constructor() {// {{{ this.treeNodes = new Map() const events = [ 'NODE_EXPAND', ] for (const e of events) mbus.subscribe(e, event => this.eventHandler(event)) // click on the empty tree list to unmark all nodes. const nodesEl = document.getElementById('nodes') nodesEl.addEventListener('click', event => { // To prevent accidentally removing all node marks, // shift is required to be unpressed, since it is required to // be pressed when marking nodes. if (event.shiftKey) return const markedElements = document.querySelectorAll('#nodes .node.marked') for (const e of markedElements) e.classList.remove('marked') }) // Fetch the top node to start this.fetchNodes(0) .then(node => { const top = document.getElementById('nodes') const topNode = this.treeNodes.get(0) topNode.expanded = true top.appendChild(topNode.render()) //this.updateNode(0) }) .catch(err => showError(err)) }// }}} eventHandler(event) {// {{{ switch (event.type) { case 'NODE_EXPAND': this.updateNode(event.detail.node.ID) break default: alert(`Unhandled event: ${event.type}`) console.log(event) } }// }}} async fetchNodes(topNode) {// {{{ return new Promise((resolve, reject) => { fetch(`/nodes/tree/${topNode}?depth=1`) .then(data => data.json()) .then(json => { if (!json.OK) { reject(json.Error) return } // Make sure treenodes are updated with the latest fetched data. // The parent node is processed after the children, since the render function needs to have the children already available. const nodes = [] nodes.push(...json.Nodes.Children) nodes.push(json.Nodes) for (const n of nodes) { let treenode = this.treeNodes.get(n.ID) if (treenode === undefined) { treenode = new TreeNode(this, n) treenode.render() this.treeNodes.set(n.ID, treenode) } else { // Since the depth is set to 1, the childrens' children array will be empty. // If children have been fetched, these should be kept. if (n.NumChildren > 0 && n.Children.length == 0) n.Children = treenode.node.Children treenode.node = n } } resolve(json.Nodes) }) }) }// }}} updateNode(nodeID) {// {{{ return new Promise((resolve, reject) => { // updateNode retrieves a node and its' immediate children. // Node and each child is found in the treeNodes map and the names are updated. // If not found, created and added. // // Newly created nodes are found and added, existing but renamed nodes are modified, and unchanged are left as is. this.fetchNodes(nodeID, true) .then(node => { const thisTreeNode = this.treeNodes.get(nodeID) thisTreeNode.render() for (const n of thisTreeNode.node.Children) this.treeNodes.get(n.ID)?.render() resolve() }) .catch(err => { showError(err); reject(err) }) }) }// }}} sortChildren(children) {// {{{ children.sort((a, b) => { if (a.TypeName < b.TypeName) return -1 if (a.TypeName > b.TypeName) return 1 if (a.Name < b.Name) return -1 if (a.Name > b.Name) return 1 return 0 }) }// }}} markedNodes() {// {{{ const markedElements = document.querySelectorAll('#nodes .node.marked') const marked = [] for (const n of markedElements) { const nodeID = n.getAttribute('data-node-id') marked.push(this.treeNodes.get(parseInt(nodeID)).node) } return marked }// }}} } export class TreeNode { constructor(tree, data) {// {{{ this.tree = tree this.node = data this.childrenFetched = false this.element = null this.children = null this.nameElement = null this.expandEventListenerAdded = false this.expanded = false }// }}} render() {// {{{ if (this.element === null) { this.element = document.createElement('div') this.element.classList.add('node') this.element.setAttribute('data-node-id', this.node.ID) const nodeHTML = `
` this.element.innerHTML = nodeHTML this.nameElement = this.element.querySelector('.name') this.children = this.element.querySelector('.children') this.expandImg = this.element.querySelector('.expand-status img') this.expandStatus = this.element.querySelector('.expand-status img') this.nameElement.addEventListener('click', event => this.clickNode(event)) } // data.NumChildren is set regardless of having fetched the children or not. this.updateExpandImages() if (this.node.TypeIcon) { const img = this.element.querySelector('.type-icon img') img.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/${this.node.TypeIcon}.svg`) } this.nameElement.innerText = this.name() this.tree.sortChildren(this.node.Children) const children = [] for (const c of this.node.Children) children.push(this.tree.treeNodes.get(c.ID).element) this.children.replaceChildren(...children) return this.element }// }}} name() {// {{{ if (this.node.TypeName === 'root_node') return 'Start' return this.node.Name }// }}} clickNode(event) {// {{{ if (!event.shiftKey) mbus.dispatch('NODE_SELECTED', this.node.ID) else this.element.classList.toggle('marked') event.stopPropagation() }// }}} hasChildren() {// {{{ return this.node.NumChildren > 0 }// }}} updateExpandImages() {// {{{ if (this.hasChildren()) { if (this.expanded) this.element.classList.add('expanded') else this.element.classList.remove('expanded') this.expandStatus.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/${this.expanded ? 'minus-box-outline.svg' : 'plus-box-outline.svg'}`) if (!this.expandEventListenerAdded) { this.expandStatus.addEventListener('click', () => this.toggleExpand()) this.expandEventListenerAdded = true } } else this.expandStatus.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/circle-medium.svg`) }// }}} toggleExpand(expanded) {// {{{ if (expanded === undefined) this.expanded = !this.expanded else this.expanded = expanded this.updateExpandImages() if (!this.childrenFetched && this.node.NumChildren > 0 && this.node.Children.length == 0) { mbus.dispatch('NODE_EXPAND', this) } }// }}} async fetchChildren() {// {{{ return new Promise((resolve, reject) => { fetch(`/nodes/tree/${this.node.ID}?depth=1`) .then(data => data.json()) .then(json => { if (json.OK) resolve(json.Nodes) else reject(json.Error) }) .catch(err => reject(err)) }) }// }}} } export class TypesList { constructur() {// {{{ this.types = [] }// }}} async fetchTypes() {// {{{ return new Promise((resolve, reject) => { fetch('/types/') .then(data => data.json()) .then(json => { if (!json.OK) { showError(json.Error) return } this.types = json.Types resolve() }) .catch(err => reject(err)) }) }// }}} render() {// {{{ const div = document.createElement('div') this.types.sort(typeSort) let prevGroup = null for (const t of this.types) { if (t.Name == 'root_node') continue if (t.Schema['x-group'] != prevGroup) { prevGroup = t.Schema['x-group'] const group = document.createElement('div') group.classList.add('group') group.innerText = t.Schema['x-group'] div.appendChild(group) } const tDiv = document.createElement('div') tDiv.classList.add('type') tDiv.innerHTML = `
${t.Schema.title || t.Name}
` div.appendChild(tDiv) } return div }// }}} } function typeSort(a, b) {// {{{ if (a.Schema['x-group'] === undefined) a.Schema['x-group'] = 'No group' if (b.Schema['x-group'] === undefined) b.Schema['x-group'] = 'No group' if (a.Schema['x-group'] < b.Schema['x-group']) return -1 if (a.Schema['x-group'] > b.Schema['x-group']) return 1 if ((a.Schema.title || a.Name) < (b.Schema.title || b.Name)) return -1 if ((a.Schema.title || a.Name) > (b.Schema.title || b.Name)) return 1 return 0 }// }}} // vim: foldmethod=marker