diff --git a/buildoz.code-workspace b/buildoz.code-workspace new file mode 100644 index 0000000..362d7c2 --- /dev/null +++ b/buildoz.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "." + } + ] +} \ No newline at end of file diff --git a/buildoz.css b/buildoz.css index 798db8f..0162ad6 100644 --- a/buildoz.css +++ b/buildoz.css @@ -1,3 +1,34 @@ +dialog.bz-modal-dialog{ + padding: 0; + border: 2px solid #050; + border-radius: 5px; + min-width: 15em; +} + +dialog.bz-modal-dialog header{ + background: #050; + text-align: center; + color: white; + padding: .5em; +} + +dialog.bz-modal-dialog section{ + padding: .5em; +} + +dialog.bz-modal-dialog footer{ + border-top: 1px solid #CCC; + padding: .5em; + justify-content: center; + display: grid; + grid-template-columns: auto auto; + grid-gap: 2em; +} + +dialog.bz-modal-dialog footer button{ + min-width: 5em; +} + bz-select { display: block; margin: .5em 0 .5em 0; @@ -23,7 +54,7 @@ bz-select > button::after { content: "\00BB"; transform: rotate(90deg); position: absolute; - right: 0.5em; + right: clamp(-1em, calc(100% - 1em), 0.5em); top: 0; pointer-events: none; font-size: 1.5em; @@ -196,7 +227,7 @@ bz-slidepane[side="right"] div.handle { top: 50%; width: 11px; height: 40px; - background: repeating-linear-gradient( to right, rgba(255,255,255,1) 0, rgba(255,255,255,1) 2px, rgba(0,0,0,0.2) 3px, rgba(0,0,0,0.2) 4px ); + background: repeating-linear-gradient( to right, rgba(255,255,255,1) 0, rgba(255,255,255,1) 2px, rgba(0,0,0,0.5) 3px, rgba(0,0,0,0.5) 4px ); transform: translateY(-50%); cursor: ew-resize; } @@ -207,14 +238,16 @@ bz-graflow { position: relative; display: block; width: 100vw; - height: 50vh; + height: 100vh; box-sizing: border-box; + overflow: hidden; } bz-graflow .bzgf-main-container{ width: 100%; height: 100%; position: relative; box-sizing: border-box; + overflow: auto; } /* BZGRAFLOW_CORE_START */ @@ -233,7 +266,7 @@ bz-graflow .bzgf-nodes-container{ /* used to keep the nodes container pointer-ev } bz-graflow .bzgf-nodes-container > * { /* allow the nodes to be moved ! */ pointer-events: auto; - } +} bz-graflow .bzgf-nodes-container .bzgf-node{ position:absolute; } bz-graflow .bzgf-nodes-container .bzgf-fake-node{ @@ -271,5 +304,70 @@ bz-graflow .bzgf-nodes-container .port.selectable:hover{ border: 5px solid #FF08!important; cursor: pointer; } +bz-graflow .graflow-error{ background:red;color:black;position: absolute;top: 0;left: 50%;transform: translateX(-50%); } /* BZGRAFLOW_CORE_END */ + +bz-grafloweditor { + width: 98vw; + height: 98vh; + margin: auto; +} +bz-grafloweditor .bzgfe-main-container{ + height: 100%; + width: 100%; + overflow: hidden; + display: grid; + grid-template-columns: 15vw auto; + grid-gap: 1px; + background: #FFF; +} + +bz-grafloweditor .bzgfe-nodes-container{ + overflow: auto; + border: 2px solid #000; + display: grid; + grid-auto-flow: row; + justify-items: center; +} + +bz-grafloweditor bz-graflow{ + height: 100%; + width: 100%; +} + +bz-grafloweditor .bzgfe-nodes-container .bzgf-node{ + position: relative; + margin: 5px auto; +} +bz-grafloweditor bz-slidepane { z-index: 10; background-color: #0008!important;} +bz-grafloweditor bz-slidepane .inner-console{ + padding: 5px; + background: #FFF; +} +bz-grafloweditor .inner-console section{ + display: grid; + grid-auto-flow: row; + grid-gap: 5px; + background-color: #DDD; + padding: 5px; + margin: 5px auto; +} + +bz-grafloweditor .inner-console section .cols-2{ + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 1em; + background-color: #CCC; + min-height: 2.5em; +} + +bz-grafloweditor .inner-console section .cols-2 label{ + text-align: right; + align-self: center; +} + +bz-grafloweditor .inner-console section .cols-2 input{ + max-height: 2em; + align-self: center; +} diff --git a/buildoz.js b/buildoz.js index d3b66e8..9881927 100644 --- a/buildoz.js +++ b/buildoz.js @@ -11,10 +11,60 @@ * as long as the copyright notice and license are kept. */ +function BZModalDialog(title, message) { + const getFields = (dlg) => { + const form = dlg.querySelector('form') + if (!form) return {} + const fd = new FormData(form) + const out = {} + for (const [key, value] of fd.entries()) { + if (Object.prototype.hasOwnProperty.call(out, key)) { + out[key] = Array.isArray(out[key]) ? [...out[key], value] : [out[key], value] + } else { + out[key] = value + } + } + return out + } + return new Promise(resolve => { + const dlg = document.createElement('dialog') + dlg.classList.add('bz-modal-dialog') + dlg.innerHTML = ` +
+
${title}
+
${message}
+ +
+ ` + dlg.addEventListener('close', () => { + const ok = dlg.returnValue.toLowerCase() === 'ok' + if(ok) { + resolve(getFields(dlg)) + } else { + resolve(ok) + } + dlg.remove() + }) + document.body.appendChild(dlg) + dlg.showModal() + }) +} + class Buildoz extends HTMLElement { + + // static is evaluated when the class is defined, therefore while buildoz.js is executing. + // therefore document.currentScript refers to buildoz.js (but not later!!) + static _buildozUrl = document.currentScript?.src ?? '' + constructor(){ super() // always call super() first! this.attrs = {} + + // Usefull for relative dependencies, to keep lib fully portable + this.buildozUrl = Buildoz._buildozUrl // was defined in the past } static get observedAttributes(){ //observable attributes triggering attributeChangedCallback @@ -48,6 +98,17 @@ class Buildoz extends HTMLElement { getBZAttribute(attrName){ // Little helper for defaults return(this.getAttribute(attrName) || this.defaultAttrs[attrName] ) } + + fireEvent(eventName, detail){ + let myname = this.tagName.toLocaleLowerCase() + myname = myname.substring(myname.indexOf('-')+1) + const eventFullName = `bz:${myname}:${eventName}` + this.dispatchEvent(new CustomEvent(eventFullName, { + detail, + bubbles: true, + composed: true, + })) + } } class BZselect extends Buildoz { @@ -365,6 +426,8 @@ class BZslidePane extends Buildoz { } this.dragMove = this.dragMove.bind(this) this.dragEnd = this.dragEnd.bind(this) + this.lastClientX = 0 + this.lastClientY = 0 // Fill with innerHTML or other DOM manip should not allow coating to be removed this._observer = new MutationObserver(muts => { this.coat() }) } @@ -394,37 +457,62 @@ class BZslidePane extends Buildoz { dragStart(evt){ evt.target.setPointerCapture(evt.pointerId) this.dragStartX = evt.clientX - this.dragStartY = evt.clientY + this.dragStartY = evt.clientY + this.lastClientX = evt.clientX + this.lastClientY = evt.clientY this.handle.addEventListener('pointermove', this.dragMove) this.handle.addEventListener('pointerup', this.dragEnd) } dragMove(evt){ const box = this.getBoundingClientRect() - const parentBox = this.parentElement.getBoundingClientRect() - let width, height + const boundaryEl = this.offsetParent || this.parentElement + const parentBox = boundaryEl.getBoundingClientRect() + let width, height, min, max switch(this.getAttribute('side')){ case 'top': - height = (evt.clientY > box.top) ? (evt.clientY - box.top) : 0 - if(height>(parentBox.height/2)) height = Math.floor(parentBox.height/2) + min = parseInt(this.getBZAttribute('minheight')) || 0 + if(evt.clientY > (box.top + min)) height = (evt.clientY - box.top) + else if(evt.clientY < this.lastClientY) height = min + else if(evt.clientY > this.lastClientY) height = 0 + else break + max = parseInt(this.getBZAttribute('maxheight')) || Math.floor(parentBox.height/2) + height = Math.min(height, parentBox.height, max) this.style.height = height+'px' break case 'bottom': - height = (evt.clientY < box.bottom) ? (box.bottom - evt.clientY) : 0 - if(height>(parentBox.height/2)) height = Math.floor(parentBox.height/2) + min = parseInt(this.getBZAttribute('minheight')) || 0 + if(evt.clientY < (box.bottom - min)) height = (box.bottom - evt.clientY) + else if(evt.clientY > this.lastClientY) height = min + else if(evt.clientY < this.lastClientY) height = 0 + else break + max = parseInt(this.getBZAttribute('maxheight')) || Math.floor(parentBox.height/2) + height = Math.min(height, parentBox.height, max) this.style.height = height+'px' break case 'left': - width = (evt.clientX > box.left) ? (evt.clientX - box.left) : 0 - if(width>(parentBox.width/2)) width = Math.floor(parentBox.width/2) + min = parseInt(this.getBZAttribute('minwidth')) || 0 + if(evt.clientX < (box.left + min)) width = (evt.clientX - box.left) + else if(evt.clientX > this.lastClientX) width = min + else if(evt.clientX < this.lastClientX) width = 0 + else break + max = parseInt(this.getBZAttribute('maxwidth')) || Math.floor(parentBox.width/2) + width = Math.min(width, parentBox.width, max) this.style.width = width+'px' break - case'right': - width = (evt.clientX < box.right) ? (box.right - evt.clientX) : 0 - if(width>(parentBox.width/2)) width = Math.floor(parentBox.width/2) + case 'right': + min = parseInt(this.getBZAttribute('minwidth')) || 0 + if(evt.clientX < (box.right - min)) width = (box.right - evt.clientX) + else if(evt.clientX < this.lastClientX) width = min + else if(evt.clientX > this.lastClientX) width = 0 + else break + max = parseInt(this.getBZAttribute('maxwidth')) || Math.floor(parentBox.width/2) + width = Math.min(width, parentBox.width, max) this.style.width = width+'px' break } + this.lastClientX = evt.clientX + this.lastClientY = evt.clientY } dragEnd(evt){ @@ -434,4 +522,3 @@ class BZslidePane extends Buildoz { } } Buildoz.define('slidepane', BZslidePane) - diff --git a/bzGraflow-editor.js b/bzGraflow-editor.js new file mode 100644 index 0000000..aadce20 --- /dev/null +++ b/bzGraflow-editor.js @@ -0,0 +1,193 @@ +/** + * _ ___ Another + * / |/ (_)______ __ _____ + * / / / __(_- { this.graflow.autoPlace('horizontal', null, null, 1000, null) } + ) + document.querySelector('[data-trigger="onAutoplace1V"]').addEventListener('click', + (evt) => { this.graflow.autoPlace('vertical', null, null, 1000, null) } + ) + document.querySelector('input[name="tension"]').addEventListener('change', + (evt) => { this.graflow.setAttribute('tension', evt.target.value); this.graflow.refresh() } + ) + document.querySelector('input[name="gapx"]').addEventListener('change', + (evt) => { this.graflow.setAttribute('gapx', evt.target.value); this.graflow.refresh() } + ) + document.querySelector('input[name="gapy"]').addEventListener('change', + (evt) => { this.graflow.setAttribute('gapy', evt.target.value); this.graflow.refresh() } + ) + document.querySelector('bz-select[name="wiretype"]').addEventListener('change', + (evt) => { this.graflow.setAttribute('wiretype', evt.target.value); this.graflow.refresh() } + ) + document.querySelector('bz-select[name="align"]').addEventListener('change', + (evt) => { this.graflow.setAttribute('align', evt.target.value); this.graflow.refresh() } + ) + this.graflow.addEventListener('bz:graflow:nodesLoaded', this.refreshNodes.bind(this)) + this.graflow.loadNodes(nodesUrl) + } + + fillconsole(){ + this.slidePane.innerHTML = ` +
+
+ + +
+
+
+ + + + + + + +
+
+ + + + + + +
+
+
+
+ + + +
+
+ ` + this.onImportFlow = this.onImportFlow.bind(this) + this.onExportFlow = this.onExportFlow.bind(this) + this.slidePane.querySelector('button[data-trigger="onExportFlow"]').addEventListener('click', (e) => this.onExportFlow(e)) // indirect so override works ! + this.slidePane.querySelector('button[data-trigger="onImportFlow"]').addEventListener('click', (e) => this.onImportFlow(e))// indirect so override works ! + } + + refreshNodes(e){ + for(const nodeType in this.graflow.nodesRegistry){ + const nodeDef = this.graflow.nodesRegistry[nodeType] + if(nodeDef.dataset.editor=='exclude') continue + const node = nodeDef.cloneNode(true) + this.makeNodeDraggable(node) + this.nodesContainer.append(node) + } + } + + makeNodeDraggable(node){ + node.draggable = true + node.style.cursor = 'pointer' + node.addEventListener('dragstart', (evt) => { + evt.dataTransfer.setData('text/plain', node.dataset.nodetype) + evt.dataTransfer.effectAllowed = 'copy' + evt.dataTransfer.setDragImage(node, evt.offsetX, evt.offsetY) + }) + } + + onNodeDragEnd(evt){ + console.log('drag end', evt) + evt.dataTransfer.clearData() + } + + setupDropZone(){ + const dropZone = this.graflow.wiresContainer + const nodesContainer = this.graflow.nodesContainer + dropZone.addEventListener('dragover', (evt) => { + evt.preventDefault() + evt.dataTransfer.dropEffect = 'copy' + }) + dropZone.addEventListener('drop', (evt) => { + evt.preventDefault() + const nodeType = evt.dataTransfer.getData('text/plain') + if(!nodeType || !(nodeType in this.graflow.nodesRegistry)) return + const rect = nodesContainer.getBoundingClientRect() + const x = evt.clientX - rect.left + nodesContainer.scrollLeft + const y = evt.clientY - rect.top + nodesContainer.scrollTop + const id = 'n' + crypto.randomUUID().replace(/-/g, '').slice(0, 8) + this.graflow.addNode({ id, nodeType, coords: { x, y } }) + for(const node of this.graflow.flow.nodes){ + if(node.id == id){ + node.coords.x = x + node.coords.y = y + break + } + } + this.graflow.refresh() + }) + } + + onImportFlow(e){ + const fileInput = this.slidePane.querySelector('input[name="importFlow"]') + fileInput.addEventListener('change', (evt) => { + const file = evt.target.files[0] + if(file) this.graflow.loadFlow(file) + fileInput.value = '' + }, { once: true }) + fileInput.click() + } + + onExportFlow(e){ + const flowDeep = JSON.parse(JSON.stringify(this.graflow.flow)) + delete flowDeep.longLinks + const exportObj = { + nodesFile: this.getBZAttribute('nodes'), + flow: flowDeep + } + const flowJson = JSON.stringify(exportObj, null, 2) + const flowBlob = new Blob([flowJson], { type: 'application/json' }) + const flowUrl = URL.createObjectURL(flowBlob) + const flowLink = document.createElement('a') + flowLink.href = flowUrl + flowLink.download = 'flow.json' + flowLink.click() + } +} +Buildoz.define('grafloweditor', BZgrafloweditor) \ No newline at end of file diff --git a/bzGraflow.js b/bzGraflow.js index 616af67..352a92e 100644 --- a/bzGraflow.js +++ b/bzGraflow.js @@ -10,8 +10,6 @@ * This code is free to use and modify, * as long as the copyright notice and license are kept. */ -const scriptUrl = document.currentScript.src - class BZgraflow extends Buildoz{ dirVect = { n: { x: 0, y: -1 }, @@ -32,15 +30,16 @@ class BZgraflow extends Buildoz{ this.stagedNodes = { } this.stagedWires = { } this.arrowDefs = null + this.arrowMarkerId = `arrow-${crypto.randomUUID()}` this.currentOrientation = null } static _coreCssPromise = null - static async getCoreCss(){ + async getCoreCss(){ if(BZgraflow._coreCssPromise) return(await BZgraflow._coreCssPromise) BZgraflow._coreCssPromise = (async() => { - const url = new URL('./buildoz.css', scriptUrl) + const url = new URL('./buildoz.css', this.buildozUrl) const res = await fetch(url) const css = await res.text() const m = css.match(/\/\*\s*BZGRAFLOW_CORE_START\s*\*\/([\s\S]*?)\/\*\s*BZGRAFLOW_CORE_END\s*\*\//) @@ -54,7 +53,7 @@ class BZgraflow extends Buildoz{ if(!this.hasAttribute('isolated')) return if(this._isolatedCoreInjected) return this._isolatedCoreInjected = true - const core = await BZgraflow.getCoreCss() + const core = await this.getCoreCss() // Convert light-dom selectors (`bz-graflow ...`) to shadow-dom selectors (`:host ...`) const shadowCss = core.replaceAll('bz-graflow', ':host') const style = document.createElement('style') @@ -68,10 +67,6 @@ class BZgraflow extends Buildoz{ connectedCallback() { super.connectedCallback() - const flowUrl = this.getBZAttribute('flow') - if(!flowUrl) return // Be tolerant: maybe injected later from JS above - // If attribute "isolated" is present, render inside a shadow root. - // Otherwise, render in light DOM (no shadow DOM). this.hostContainer = document.createElement('div') this.hostContainer.classList.add('bzgf-main-container') this.mainContainer = this.hasAttribute('isolated') @@ -98,26 +93,71 @@ class BZgraflow extends Buildoz{ this.NodesReceiver = new DroppingNodes(this, '.bzgf-node') } } - this.loadFlow(flowUrl) + this.fireEvent('domConnected', { graflow: this }) + const flowUrl = this.getBZAttribute('flow') + if(flowUrl) this.loadFlow(flowUrl) + else this.initFlow() + } + + static get observedAttributes(){ + return([...super.observedAttributes, 'disabled']) + } + + attributeChangedCallback(name, oldValue, newValue) { + super.attributeChangedCallback(name, oldValue, newValue) + if(name == 'disabled'){ + if(newValue === null) { + this.disabled = false + this.style.opacity = 1 + this.style.pointerEvents = 'auto' + } else { + this.disabled = true + this.style.opacity = 0.5 + this.style.pointerEvents = 'none' + } + this.querySelectorAll('.bzgf-zoom-in, .bzgf-zoom-out').forEach((btn) => { + btn.disabled = this.disabled + }) + } } error(msg, err){ - this.innerHTML = `
${msg}
` + this.querySelector('.graflow-error')?.remove() + const errorEl = document.createElement('div') + errorEl.classList.add('graflow-error') + errorEl.innerHTML = `${msg}` + this.appendChild(errorEl) if(err) console.error(msg, err) else console.error(msg) } - async loadFlow(url){ - const res = await fetch(url+'?'+crypto.randomUUID()) - const buf = await res.text() - let flowObj - try{ - flowObj = JSON.parse(buf) - } catch(err){ - this.error('Could not parse flow JSON!?', err) - - return + async loadFlow(source){ + let buf, flowObj + if(source instanceof Blob){ + buf = await source.text() + try{ + flowObj = JSON.parse(buf) + } catch(err){ + this.error('Could not parse flow JSON!?', err) + return + } + } else if(typeof source == 'string') { + const url = source + const fetchUrl = (typeof url === 'string' && !url.startsWith('blob:') && !url.startsWith('data:')) + ? (url + '?' + crypto.randomUUID()) + : url + const res = await fetch(fetchUrl) + buf = await res.text() + try{ + flowObj = JSON.parse(buf) + } catch(err){ + this.error('Could not parse flow JSON!?', err) + return + } + } else if(typeof source == 'object') { + flowObj = structuredClone(source) } + if(!flowObj.nodesFile){ this.error('No nodesFile in JSON!?') return @@ -125,11 +165,14 @@ class BZgraflow extends Buildoz{ await this.loadNodes(flowObj.nodesFile) this.flow = flowObj.flow this.refresh() - this.dispatchEvent(new CustomEvent('flowLoaded', { - detail: { url }, - bubbles: true, - composed: true, - })) + this.fireEvent('flowLoaded', { + parentNodeId: null, + component: this, + }) + } + + initFlow(){ + this.flow = { nodes: [], links: [] } } async loadNodes(url) { @@ -142,6 +185,8 @@ class BZgraflow extends Buildoz{ for(const tpl of doc.querySelectorAll('template')){ if(tpl.id=='svg-arrows'){ this.arrowDefs = tpl.querySelector('defs').cloneNode(true) + const defaultArrow = this.arrowDefs.querySelector('#arrow') + if(defaultArrow) defaultArrow.id = this.arrowMarkerId this.wiresContainer.appendChild(this.arrowDefs) } else { const rootEl = tpl.content.querySelector('.bzgf-node') @@ -172,11 +217,7 @@ class BZgraflow extends Buildoz{ BZgraflow._loadedNodeStyles.add(url) } } - this.dispatchEvent(new CustomEvent('nodesLoaded', { - detail: { url }, - bubbles: true, - composed: true, - })) + this.fireEvent('nodesLoaded', { url: url }) } addNode(node){ @@ -201,6 +242,7 @@ class BZgraflow extends Buildoz{ this.enterSubflow(id) }) this.stagedNodes[id].appendChild(btnEnterSubflow) + this.stagedNodes[id].dataset.subflow = true } this.nodesContainer.append(this.stagedNodes[id]) if(!this.flow.nodes.find(n => n.id === id)) { @@ -210,6 +252,7 @@ class BZgraflow extends Buildoz{ } enterSubflow(id){ + if(this.disabled || this.hasAttribute('disabled')) return const nodeEl = this.stagedNodes[id] if(!nodeEl) return @@ -217,34 +260,23 @@ class BZgraflow extends Buildoz{ // scaled down so it fits exactly inside the clicked node, then animate it to full size. const nodeBB = nodeEl.getBoundingClientRect() const parentBB = this.getBoundingClientRect() - const flowNode = this.flow?.nodes?.find(n => n.id === id) - const flowUrl = flowNode.subflow.url const childEl = document.createElement('bz-graflow') childEl.isSubflow = true childEl.currentOrientation = this.currentOrientation - childEl.setAttribute('flow', flowUrl) - childEl.setAttribute('tension', this.getBZAttribute('tension') || '60') - // Remember which node we "came from" so exitSubflow() can animate back to it. - childEl.dataset.enterNodeId = id - const btnExitSubflow = document.createElement('button') - btnExitSubflow.classList.add('bzgf-zoom-out') - this.addIcon(btnExitSubflow, 'zoomout') - btnExitSubflow.addEventListener('click', () => { - this.exitSubflow(childEl) - }) - // Put the child in the exact same viewport rect as the parent - this.invade(this, childEl) - childEl.hostContainer.appendChild(btnExitSubflow) + const inheritedAttrs = ['orientation', 'gapx', 'gapy', 'tween', 'align', 'tension', 'wiretype', 'edit', 'autofit'] // ! Not 'isolated' ! + for(const attrName of inheritedAttrs){ + if(this.hasAttribute(attrName)) childEl.setAttribute(attrName, this.getAttribute(attrName)) + } - childEl.addEventListener('flowLoaded', (e) => { + childEl.addEventListener('bz:graflow:flowLoaded', (e) => { for(const portLink of flowNode.subflow.portLinks){ const nid = crypto.randomUUID() childEl.addNode({ "nodeType": portLink.refNodeType, "id": nid, - "markup": { "parentport": portLink.parentPort } + "markup": { ...portLink } }) if(portLink.direction=='in') { childEl.addWire({ @@ -258,10 +290,29 @@ class BZgraflow extends Buildoz{ }) } } - - childEl.autoPlace(this.currentOrientation, 60, 60) + // Rebuild once refNodes are injected so the final refresh/autofit includes them. + childEl.refresh() }, { once:true }) + if(flowNode.subflow.url) childEl.setAttribute('flow', flowNode.subflow.url) + else { + childEl.addEventListener('bz:graflow:domConnected', async (e) => { + await childEl.loadFlow(flowNode.subflow.flow) + }) + } + + // Remember which node we "came from" so exitSubflow() can animate back to it. + childEl.dataset.enterNodeId = id + const btnExitSubflow = document.createElement('button') + btnExitSubflow.classList.add('bzgf-zoom-out') + this.addIcon(btnExitSubflow, 'zoomout') + btnExitSubflow.addEventListener('click', () => { + this.exitSubflow(childEl) + }) + // Put the child in the exact same viewport rect as the parent + this.invade(this, childEl) + childEl.hostContainer.appendChild(btnExitSubflow) + // Fade out the current (host) graflow while the child scales up this.hostContainer.style.opacity = '1' this.hostContainer.style.transition = 'opacity 1000ms ease-in-out' @@ -286,7 +337,7 @@ class BZgraflow extends Buildoz{ // Force style flush, then animate back to identity (full parent size) childEl.getBoundingClientRect() - childEl.style.transition = 'transform 1000ms ease-in-out' + childEl.style.transition = `transform ${parseInt(this.getBZAttribute('tween')) || 500}ms ease-in-out` requestAnimationFrame(() => { childEl.style.top = 0; childEl.style.left = 0; @@ -302,12 +353,11 @@ class BZgraflow extends Buildoz{ this.hostContainer.style.visibility = 'hidden' childEl.style.transform = 'none' // Important for nested subflows to position correctly childEl.style.willChange = '' - newEl.style.overflow = 'auto' - this.dispatchEvent(new CustomEvent('subflowLoaded', { - detail: { subflow: childEl }, - bubbles: true, - composed: true, - })) + childEl.style.overflow = 'auto' + this.fireEvent('subflowLoaded', { + parentNodeId: id, + component: childEl + }) }, { once:true }) } @@ -382,11 +432,9 @@ class BZgraflow extends Buildoz{ this.hostContainer.style.opacity = '1' this.hostContainer.style.visibility = 'visible' childEl.style.willChange = '' - this.dispatchEvent(new CustomEvent('subflowExited', { - detail: { subflow: childEl }, - bubbles: true, - composed: true, - })) + this.fireEvent('subflowExited', { + component: this + }) }, { once:true }) } @@ -402,6 +450,10 @@ class BZgraflow extends Buildoz{ return(this.stagedNodes[nid]) } + makeWireId(nid1, nid2){ + return(`${encodeURIComponent(nid1)}|${encodeURIComponent(nid2)}`) + } + addWire(link){ const [idNode1, idPort1] = link.from const [idNode2, idPort2] = link.to @@ -410,12 +462,12 @@ class BZgraflow extends Buildoz{ return } const path = this.linkNodes(idNode1, idPort1, idNode2, idPort2) - const id = `${idNode1}_${idNode2}` + const id = this.makeWireId(idNode1, idNode2) this.stagedWires[id] = document.createElementNS('http://www.w3.org/2000/svg', 'path') this.stagedWires[id].setAttribute('d', path) this.stagedWires[id].setAttribute('fill', 'none') - if(this.arrowDefs && link.endArrow) this.stagedWires[id].setAttribute('marker-end','url(#arrow)') - if(this.arrowDefs && link.startArrow) this.stagedWires[id].setAttribute('marker-start','url(#arrow)') + if(this.arrowDefs && link.endArrow) this.stagedWires[id].setAttribute('marker-end',`url(#${this.arrowMarkerId})`) + if(this.arrowDefs && link.startArrow) this.stagedWires[id].setAttribute('marker-start',`url(#${this.arrowMarkerId})`) this.stagedWires[id].classList.add('bzgf-wire') this.stagedWires[id].dataset.id = id this.stagedWires[id].link = link @@ -445,16 +497,105 @@ class BZgraflow extends Buildoz{ this.addWire(link) } if(!this.currentOrientation) { - const bb=this.getBoundingClientRect() - if(bb.width > bb.height) this.currentOrientation = 'horizontal' - else this.currentOrientation = 'vertical' + if(this.getBZAttribute('orientation')) { + this.currentOrientation = this.getBZAttribute('orientation') + } else { + const bb=this.getBoundingClientRect() + if(bb.width > bb.height) this.currentOrientation = 'horizontal' + else this.currentOrientation = 'vertical' + } } - if(forceAutoplace) this.autoPlace(this.currentOrientation) - this.dispatchEvent(new CustomEvent('refreshed', { - detail: { }, - bubbles: true, - composed: true, - })) + const gapx = parseInt(this.getBZAttribute('gapx')) || 80 + const gapy = parseInt(this.getBZAttribute('gapy')) || 80 + const finishRefresh = () => { + this.fireEvent('refreshed', { }) + if(this.hasAttribute('autofit')){ + const autofitAttr = this.getAttribute('autofit') + const autofitPercent = (autofitAttr !== null && autofitAttr !== '' && !Number.isNaN(parseFloat(autofitAttr))) + ? parseFloat(autofitAttr) + : undefined + if(this._canRunAutoPlace()) this.autofit(autofitPercent) + else this._scheduleLayoutWhenReady(() => this.autofit(autofitPercent)) + } + } + const onLayoutComplete = () => { + this.fireEvent('layoutComplete', { }) + finishRefresh() + } + if(forceAutoplace) this._scheduleAutoPlaceWhenReady(this.currentOrientation, gapx, gapy, onLayoutComplete) + else onLayoutComplete() + } + + disconnectedCallback(){ + this._disconnectLayoutObserver() + super.disconnectedCallback?.() + } + + _disconnectLayoutObserver(){ + if(this._layoutObserver) { + this._layoutObserver.disconnect() + this._layoutObserver = null + } + } + + _canRunAutoPlace(){ + let nodesHaveLayoutSize = false + const ids = Object.keys(this.stagedNodes || {}) + if(ids.length === 0) return(true) + for(const nid of ids){ + if(nid.startsWith('longLinkPlaceHolder_')) continue + const el = this.stagedNodes[nid] + if(el && (el.offsetWidth > 0 || el.offsetHeight > 0)) { + nodesHaveLayoutSize=true + break + } + } + return((this.clientWidth > 0 && this.clientHeight > 0) && nodesHaveLayoutSize) + } + + /** + * autoPlace uses offsetWidth/offsetHeight; when the host is hidden (display:none / off-screen view) + * those are 0 and layout is wrong. Defer until the element is measurable. + */ + _scheduleLayoutWhenReady(callback){ + this._layoutScheduleToken = (this._layoutScheduleToken || 0) + 1 + const token = this._layoutScheduleToken + this._disconnectLayoutObserver() + + const attempt = () => { + if(token !== this._layoutScheduleToken) return(true) + if(!this._canRunAutoPlace()) return(false) + this._disconnectLayoutObserver() + callback() + return(true) + } + + if(attempt()) return + + this._layoutObserver = new ResizeObserver(() => { attempt() }) + this._layoutObserver.observe(this) + + let frames = 0 + const rafPoll = () => { + if(token !== this._layoutScheduleToken) return + if(attempt()) return + if(++frames < 12) requestAnimationFrame(rafPoll) + } + requestAnimationFrame(rafPoll) + } + + _scheduleAutoPlaceWhenReady(orientation, gapx, gapy, onLayoutComplete){ + this._scheduleLayoutWhenReady(() => { + this.autoPlace(orientation, gapx, gapy, null, null, onLayoutComplete) + }) + } + + _maybeFireLayoutComplete(){ + if(this._layoutMovePending > 0) return + if(!this._layoutCompleteHandler) return + const fn = this._layoutCompleteHandler + this._layoutCompleteHandler = null + fn() } // Convert viewport (client) coordinates to this instance's SVG local coordinates. @@ -644,32 +785,36 @@ class BZgraflow extends Buildoz{ return(path) } - autoPlace(orientation = 'horizontal', gapx = null, gapy = null, tween = null, align = null){ - if(gapx == null) gapx = parseInt(this.getBZAttribute('gapx')) || 80 - if(gapy == null) gapy = parseInt(this.getBZAttribute('gapy')) || 80 - if(tween == null) tween = parseInt(this.getBZAttribute('tween')) || 500 - if(align == null) align = this.getBZAttribute('align') || 'center' + autoPlace(orientation = null, gapx = null, gapy = null, tween = null, align = null, onLayoutComplete = null){ + if(orientation == null) orientation = this.getBZAttribute('orientation') || this.currentOrientation || 'horizontal' + if(gapx == null) gapx = parseInt(this.getBZAttribute('gapx')) || 80 + if(gapy == null) gapy = parseInt(this.getBZAttribute('gapy')) || 80 + if(tween == null) tween = parseInt(this.getBZAttribute('tween')) || 500 + if(align == null) align = this.getBZAttribute('align') || 'center' this.currentOrientation = orientation // Cancel any previous autoPlace() animations by bumping a token. // moveNode() checks this token each frame and will no-op if superseded. this._autoPlaceToken = (this._autoPlaceToken || 0) + 1 const token = this._autoPlaceToken + this._layoutMovePending = 0 + this._layoutCompleteToken = token + this._layoutCompleteHandler = (typeof onLayoutComplete === 'function') ? onLayoutComplete : null // Cleanup placeholders from previous autoPlace() runs. // Each run creates new longLinkPlaceHolder_* IDs; without cleanup they accumulate in the DOM. this.clearFakeNodes() - + let links = Object.values(this.stagedWires).map(w => w?.link).filter(Boolean) + links = links.length ? links : (this.flow?.links || []) // Loops create infinite recursion in dfs for getting parents & adjacency lists: Remove them ! let linksWithoutBackEdges - if(this.hasAnyLoop(this.flow.nodes, this.flow.links)){ - const backEdges = this.findBackEdges(this.flow.nodes, this.flow.links) - linksWithoutBackEdges = this.flow.links.filter((link, idx) => (!backEdges.includes(idx)) && (link.from[0] != link.to[0])) + if(this.hasAnyLoop(this.flow.nodes, links)){ + const backEdges = this.findBackEdges(this.flow.nodes, links) + linksWithoutBackEdges = links.filter((link, idx) => (!backEdges.includes(idx)) && (link.from[0] != link.to[0])) } else { - linksWithoutBackEdges = this.flow.links + linksWithoutBackEdges = links } const { parents, adj } = this.buildGraphStructures(this.flow.nodes, linksWithoutBackEdges) - const layers = this.computeLayers(this.flow.nodes, parents) // Layer-0 nodes have no parents, so reorderLayers() (which uses parent ordering) cannot @@ -717,11 +862,10 @@ class BZgraflow extends Buildoz{ if(totWidth>maxWidth) maxWidth = totWidth layerWidths.push(totWidth) } - // If any long-links, create placeholders for skipped layers this._virtualLinks = new Map() - this.flow.longLinks = this.findLongLinks(this.flow.links) + this.flow.longLinks = this.findLongLinks(links) for(const llink of this.flow.longLinks){ let fakeParent = llink.link.from[0] for(const layerIdx of llink.skippedLayers){ @@ -779,7 +923,7 @@ class BZgraflow extends Buildoz{ y = Math.max(parentsY[parents[nid][0]], y) //TODO handle multiple parents with avg } placedY = y - this.moveNode(nid, x, y, orientation, tween, null, token) + this.moveNode(nid, x, y, orientation, tween, token) if((align == 'parent') && (nid in parents) && (parents[nid][0] in parentsY)) { parentsY[parents[nid][0]] += gapy + nodeHeight } else { @@ -792,10 +936,10 @@ class BZgraflow extends Buildoz{ } placedY = y this.addFakeNode(nid, x, y, wMax*0.75, fakeNodeHeight) - this.moveNode(nid, x, y, orientation, tween, null, token) + this.moveNode(nid, x, y, orientation, tween, token) // Never increment parentsY for fake nodes: they're placeholders and must not disalign real children y = Math.max(y, placedY + gapy + fakeNodeHeight) - } + } parentsY[nid] = placedY nodeY[nid] = placedY nodeX[nid] = x @@ -813,7 +957,7 @@ class BZgraflow extends Buildoz{ !nid.startsWith('longLinkPlaceHolder_') && nid in parents && parents[nid][0] === pid ) if(firstRealChild && nodeY[pid] !== nodeY[firstRealChild]){ - this.moveNode(pid, nodeX[pid], nodeY[firstRealChild], orientation, tween, null, token) + this.moveNode(pid, nodeX[pid], nodeY[firstRealChild], orientation, tween, token) nodeY[pid] = nodeY[firstRealChild] } } @@ -842,17 +986,18 @@ class BZgraflow extends Buildoz{ for(const nid of layer){ if(!nid.startsWith('longLinkPlaceHolder_')){ const bb = this.stagedNodes[nid].getBoundingClientRect() - this.moveNode(nid, x, y, orientation, tween, null, token) + this.moveNode(nid, x, y, orientation, tween, token) x += gapx + (this.stagedNodes[nid].offsetWidth || bb.width) } else { this.addFakeNode(nid, x, y, fakeNodeWidth, hMax*0.75) - this.moveNode(nid, x, y, orientation, tween, null, token) + this.moveNode(nid, x, y, orientation, tween, token) x += gapx + fakeNodeWidth } } y += hMax + gapy } } + this._maybeFireLayoutComplete() } clearFakeNodes(){ @@ -972,17 +1117,34 @@ class BZgraflow extends Buildoz{ } moveNode(nid, destx, desty, orientation, duration = 200, autoPlaceToken = null) { - const t0 = performance.now() const el0 = this.stagedNodes?.[nid] if(!el0) return + let layoutTracked = false + if(autoPlaceToken != null && autoPlaceToken === this._layoutCompleteToken) { + layoutTracked = true + this._layoutMovePending++ + } + const finishLayoutMove = () => { + if(!layoutTracked) return + layoutTracked = false + this._layoutMovePending = Math.max(0, this._layoutMovePending - 1) + this._maybeFireLayoutComplete() + } + const t0 = performance.now() const bb = el0.getBoundingClientRect() const parentbb = el0.parentElement.getBoundingClientRect() const x0=bb.x - parentbb.x const y0 = bb.y - parentbb.y function frame(t) { - if(autoPlaceToken && autoPlaceToken !== this._autoPlaceToken) return + if(autoPlaceToken && autoPlaceToken !== this._autoPlaceToken) { + finishLayoutMove() + return + } const el = this.stagedNodes?.[nid] - if(!el) return + if(!el) { + finishLayoutMove() + return + } const p = Math.min((t - t0) / duration, 1) const k = p * p * (3 - 2 * p) // smoothstep const x = x0 + (destx - x0) * k @@ -993,11 +1155,14 @@ class BZgraflow extends Buildoz{ if(p < 1) requestAnimationFrame(frame.bind(this)) else{ - this.dispatchEvent(new CustomEvent('nodeMoved', { - detail: { nid, x, y }, - bubbles: true, - composed: true, - })) + const flowNode = this.flow?.nodes?.find(n => n.id === nid) + if(flowNode) { + if(!flowNode.coords) flowNode.coords = {} + flowNode.coords.x = x + flowNode.coords.y = y + } + this.fireEvent('nodeMoved', { nid, x, y }) + finishLayoutMove() } } requestAnimationFrame(frame.bind(this)) @@ -1005,10 +1170,14 @@ class BZgraflow extends Buildoz{ updateWires(nid, orientation, LondLinkfix = false){ const wires = Object.keys(this.stagedWires) - .filter(id => (id.startsWith(nid+'_')||id.endsWith('_'+nid))) .map(id => this.stagedWires[id]) + .filter(wire => { + const lnk = wire?.link + return(lnk && (lnk.from?.[0] == nid || lnk.to?.[0] == nid)) + }) for(const wire of wires){ - const [nid1, nid2] = wire.dataset.id.split('_') + const nid1 = wire.link.from[0] + const nid2 = wire.link.to[0] const lnk = this.getLink(nid1, nid2) if(!lnk) continue if(!this.flow?.longLinks) this.flow.longLinks = [] @@ -1021,15 +1190,11 @@ class BZgraflow extends Buildoz{ wire.setAttribute('d', path) } } - this.dispatchEvent(new CustomEvent('wiresUpdated', { - detail: { nid, orientation, LondLinkfix }, - bubbles: true, - composed: true, - })) + this.fireEvent('wiresUpdated', { nid, orientation, LondLinkfix }) } getLink(nid1, nid2){ - const wire = this.stagedWires[`${nid1}_${nid2}`] + const wire = this.stagedWires[this.makeWireId(nid1, nid2)] if(wire?.link) return wire.link return this._virtualLinks?.get(`${nid1}__${nid2}`) ?? null } @@ -1138,11 +1303,11 @@ class BZgraflow extends Buildoz{ findLongLinks(links) { let linksWithoutBackEdges - if(this.hasAnyLoop(this.flow.nodes, this.flow.links)){ - const backEdges = this.findBackEdges(this.flow.nodes, this.flow.links) - linksWithoutBackEdges = this.flow.links.filter((link, idx) => (!backEdges.includes(idx)) && (link.from[0] != link.to[0])) + if(this.hasAnyLoop(this.flow.nodes, links)){ + const backEdges = this.findBackEdges(this.flow.nodes, links) + linksWithoutBackEdges = links.filter((link, idx) => (!backEdges.includes(idx)) && (link.from[0] != link.to[0])) } else { - linksWithoutBackEdges = this.flow.links + linksWithoutBackEdges = links } /// Yes that means we ignore long & back links ! const { parents } = this.buildGraphStructures(this.flow.nodes, linksWithoutBackEdges) @@ -1167,27 +1332,80 @@ class BZgraflow extends Buildoz{ return(crossLayerLinks) } - autofit(){ + /** + * Union bounding boxes of nodes and wires (viewport coords). + * Same measurement strategy as autofit(). Call with transform cleared for stable sizes. + */ + getContentSize(){ + let left = Infinity + let top = Infinity + let right = -Infinity + let bottom = -Infinity + + const includeBB = (bb) => { + if(!bb) return + left = Math.min(left, bb.left) + top = Math.min(top, bb.top) + right = Math.max(right, bb.right) + bottom = Math.max(bottom, bb.bottom) + } + + this.nodesContainer?.querySelectorAll?.('.bzgf-node').forEach(nodeEl => includeBB(nodeEl.getBoundingClientRect())) + this.wiresContainer?.querySelectorAll?.('path.bzgf-wire').forEach(path => includeBB(path.getBoundingClientRect())) + + const gapx = parseInt(this.getBZAttribute('gapx')) || 80 + const gapy = parseInt(this.getBZAttribute('gapy')) || 80 + const hasBounds = Number.isFinite(left) && Number.isFinite(right) && Number.isFinite(top) && Number.isFinite(bottom) + const rawWidth = hasBounds ? Math.max(right - left, 1) : Math.max(this.mainContainer?.clientWidth || this.offsetWidth || 1, 1) + const rawHeight = hasBounds ? Math.max(bottom - top, 1) : Math.max(this.mainContainer?.clientHeight || this.offsetHeight || 1, 1) + + return({ + left: hasBounds ? left : null, + top: hasBounds ? top : null, + right: hasBounds ? right : null, + bottom: hasBounds ? bottom : null, + rawWidth, + rawHeight, + width: rawWidth + (2 * gapx), + height: rawHeight + (2 * gapy), + gapx, + gapy, + }) + } + + autofit(percent=100){ + if(!this.parentElement) return + + const prevTransformOrigin = this.style.transformOrigin + this.style.transform = 'none' + this.style.transformOrigin = 'top left' + + const { left, top, width: contentW, height: contentH, gapx, gapy } = this.getContentSize() const parentBB = this.parentElement.getBoundingClientRect() - // Use scroll dimensions for actual content extent (nodes can extend beyond element bounds) - const contentW = Math.max(this.scrollWidth || this.offsetWidth || 1, 1) - const contentH = Math.max(this.scrollHeight || this.offsetHeight || 1, 1) const sx = parentBB.width / contentW const sy = parentBB.height / contentH - const scale = Math.min(sx, sy) // uniform scale to fit inside parent - this.style.transformOrigin = 'top left' - this.style.transform = `scale(${scale})` + const scale = Math.min(sx, sy)*(percent/100) // uniform scale to fit inside parent + const tx = left != null ? (-left + gapx) : gapx + const ty = top != null ? (-top + gapy) : gapy + if(!this.isSubflow) { + this.style.transformOrigin = prevTransformOrigin || 'top left' + this.style.transform = `scale(${scale}) translate(${tx}px, ${ty}px)` + } else { + this.style.transform = `scale(${scale})` + this.style.width = `calc(100% / ${scale})` // means 100% of the parent node DESPITE the scaling + this.style.height = `calc(100% / ${scale})` // means 100% of the parent node DESPITE the scaling + } } } Buildoz.define('graflow', BZgraflow) class MovingNodes{ + constructor(graflow, itemSelector, handleSelector = itemSelector){ this.graflow = graflow this.itemSelector = itemSelector this.handleSelector = handleSelector this.nodesContainer = this.graflow.mainContainer.querySelector('.bzgf-nodes-container') - this.state = null this.interactiveElementsSelector = ` .port, @@ -1197,7 +1415,10 @@ class MovingNodes{ button, a[href] ` - this.graflow.addEventListener('refreshed', this.enableMovingNodes.bind(this)) + this._boundPointerDown = this.pointerDown.bind(this) + this._boundPointerMove = this.pointerMove.bind(this) + this._boundPointerUp = this.pointerUp.bind(this) + this.graflow.addEventListener('bz:graflow:refreshed', this.enableMovingNodes.bind(this)) } enableMovingNodes() { @@ -1210,21 +1431,21 @@ class MovingNodes{ } this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item => - item.addEventListener('pointerdown', this.pointerDown.bind(this)) + item.addEventListener('pointerdown', this._boundPointerDown) ) - this.nodesContainer.addEventListener('pointermove', this.pointerMove.bind(this)) + this.nodesContainer.addEventListener('pointermove', this._boundPointerMove) this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item => - item.addEventListener('pointerup', this.pointerUp.bind(this)) + item.addEventListener('pointerup', this._boundPointerUp) ) } disableMovingNodes(){ this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item => - item.removeEventListener('pointerdown', this.pointerDown.bind(this)) + item.removeEventListener('pointerdown', this._boundPointerDown) ) - this.nodesContainer.removeEventListener('pointermove', this.pointerMove.bind(this)) + this.nodesContainer.removeEventListener('pointermove', this._boundPointerMove) this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item => - item.removeEventListener('pointerup', this.pointerUp.bind(this)) + item.removeEventListener('pointerup', this._boundPointerUp) ) } @@ -1243,18 +1464,18 @@ class MovingNodes{ handle = node.querySelector(this.handleSelector) if(e.target != handle) return } - - - + const rect = node.getBoundingClientRect() - + const parentBB = this.nodesContainer.getBoundingClientRect() + const offsetX = rect.left - parentBB.left + this.nodesContainer.scrollLeft + const offsetY = rect.top - parentBB.top + this.nodesContainer.scrollTop this.state = { node, handle, startX: e.clientX, startY: e.clientY, - offsetX: rect.left, - offsetY: rect.top + offsetX, + offsetY } const x = e.clientX - this.state.startX + this.state.offsetX const y = e.clientY - this.state.startY + this.state.offsetY @@ -1263,7 +1484,7 @@ class MovingNodes{ node.style.left = `${x}px` node.style.top = `${y}px` node.style.margin = '0' - node.style.zIndex = '9999' + node.style.zIndex = '3' node.style.pointerEvents = 'none' } @@ -1279,8 +1500,19 @@ class MovingNodes{ pointerUp(e){ if(!this.state) return - this.state.node.releasePointerCapture(e.pointerId) - this.state.node.style.pointerEvents = '' + const { node, startX, startY, offsetX, offsetY } = this.state + const x = e.clientX - startX + offsetX + const y = e.clientY - startY + offsetY + node.releasePointerCapture(e.pointerId) + node.style.pointerEvents = '' + for(const n of this.graflow.flow.nodes){ + if(n.id == node.dataset.id){ + n.coords.x = x + n.coords.y = y + break + } + } + this.graflow.fireEvent('nodeMoved', { nodeId: node.dataset.id, x, y }) this.state = null } } @@ -1291,10 +1523,10 @@ class EditWires{ this.nodesContainer = this.graflow.mainContainer.querySelector('.bzgf-nodes-container') this.state = null this.graflow.tabIndex = 0 // Make keyboard reactive - - this.graflow.addEventListener('refreshed', this.enableEditWires.bind(this)) - this.graflow.addEventListener('refreshed', this.enableSelectPorts.bind(this)) - this.graflow.addEventListener('wiresUpdated', this.enableEditWires.bind(this)) + this._boundPointerMove = this.pointerMove.bind(this) + this.graflow.addEventListener('bz:graflow:refreshed', this.enableEditWires.bind(this)) + this.graflow.addEventListener('bz:graflow:refreshed', this.enableSelectPorts.bind(this)) + this.graflow.addEventListener('bz:graflow:wiresUpdated', this.enableEditWires.bind(this)) this.graflow.addEventListener('keyup', this.onKeyUp.bind(this)) } @@ -1310,31 +1542,103 @@ class EditWires{ } enableSelectPorts(){ + this.currentlyHoveredPort = null const portEls = this.graflow.nodesContainer.querySelectorAll('.port') for(const port of portEls){ port.addEventListener('click', this.onSelectPort.bind(this)) + port.addEventListener('pointerenter', this._onPortPointerEnter.bind(this)) + port.addEventListener('pointerleave', this._onPortPointerLeave.bind(this)) port.classList.add('selectable') } } + + _onPortPointerEnter(e){ + this.currentlyHoveredPort = e.target.closest('.port') + } + + _onPortPointerLeave(e){ + if(this.currentlyHoveredPort === e.target.closest('.port')) this.currentlyHoveredPort = null + } onSelectPort(e){ const port = e.target if(this.currentlySelectedPort == port) { this.currentlySelectedPort.style.removeProperty('border') this.currentlySelectedPort = null + this.state = null + this._setWirecoatsPointerEvents('') + this.graflow.mainContainer.removeEventListener('pointermove', this._boundPointerMove) + if(this.tempwire) this.tempwire.remove() return } if(this.currentlySelectedPort) { + this.tempwire.remove() + this.tempwire = null this.makeWireBetweenPorts(this.currentlySelectedPort, port) this.enableEditWires() this.currentlySelectedPort.style.removeProperty('border') this.currentlySelectedPort = null + this.state = null + this._setWirecoatsPointerEvents('') + this.graflow.mainContainer.removeEventListener('pointermove', this._boundPointerMove) } else { + this.tension = parseInt(this.graflow.getBZAttribute('tension')) || 60 + this.wireType = this.graflow.getBZAttribute('wiretype') || 'bezier' this.currentlySelectedPort = port port.style.setProperty('border', '5px solid #FF0', 'important') + this.state = { + startX: e.clientX, + startY: e.clientY, + port + } + this.tempwire = document.createElementNS('http://www.w3.org/2000/svg', 'path') + this.tempwire.setAttribute('fill', 'none') + this.tempwire.style.pointerEvents = 'none' + this.graflow.wiresContainer.appendChild(this.tempwire) + this.tempwire.classList.add('bzgf-wire') + this._setWirecoatsPointerEvents('none') + this.graflow.mainContainer.addEventListener('pointermove', this._boundPointerMove) } } + _setWirecoatsPointerEvents(value){ + this.graflow.wiresContainer.querySelectorAll('.bzgf-wirecoat').forEach(el => { el.style.pointerEvents = value }) + } + + pointerMove(e){ + if(!this.state) return + const { port } = this.state + const bb = port.getBoundingClientRect() + const p1 = this.graflow.clientToSvg(bb.x + bb.width / 2, bb.y + bb.height / 2) + const p2 = this.graflow.clientToSvg(e.clientX, e.clientY) + const x1 = Math.floor(p1.x) + const y1 = Math.floor(p1.y) + const x2 = Math.floor(p2.x) + const y2 = Math.floor(p2.y) + const dir1 = port.dataset.direction + const oppositeDir = { n: 's', s: 'n', e: 'w', w: 'e' } + const hovered = this.currentlyHoveredPort + + const dir2 = (hovered && hovered !== port) ? hovered.dataset.direction : oppositeDir[dir1] + const c1x = x1 + this.tension * this.graflow.dirVect[dir1].x + const c1y = y1 + this.tension * this.graflow.dirVect[dir1].y + const c2x = x2 + this.tension * this.graflow.dirVect[dir2].x + const c2y = y2 + this.tension * this.graflow.dirVect[dir2].y + const node1 = port.closest('.bzgf-node') + const node2 = hovered?.closest('.bzgf-node') ?? { offsetWidth: 0, offsetHeight: 0 } + const seg = this.graflow.buildSegment( + x1, y1, + c1x, c1y, + c2x, c2y, + x2, y2, + this.wireType, + node1, node2, + dir1, dir2, + this.tension) + if(!seg) return + this.tempwire.setAttribute('d', `M ${x1} ${y1} ${seg}`) + } + makeWireBetweenPorts(port1, port2){ const node1 = port1.closest('.bzgf-node') const node2 = port2.closest('.bzgf-node') @@ -1347,6 +1651,7 @@ class EditWires{ return('') } this.graflow.addWire({ from: [idNode1, idPort1], to: [idNode2, idPort2] }) + this.graflow.fireEvent('wireAdded', { from: [idNode1, idPort1], to: [idNode2, idPort2], id: this.graflow.makeWireId(idNode1, idNode2) }) } onSelectWire(e){ @@ -1362,16 +1667,32 @@ class EditWires{ onKeyUp(e){ if((e.key == 'Delete') && this.currentlySelectedWire) { - this.graflow.flow.links = this.graflow.flow.links.filter(link => link.id != this.currentlySelectedWire.dataset.id) - this.graflow.stagedWires[this.currentlySelectedWire.dataset.id].remove() - delete(this.graflow.stagedWires[this.currentlySelectedWire.dataset.id]) + const wireId = this.currentlySelectedWire.dataset.id + const linkToRemove = this.graflow.stagedWires[wireId]?.link + this.graflow.flow.links = this.graflow.flow.links.filter(link => + linkToRemove ? link !== linkToRemove : (this.graflow.makeWireId(link.from[0], link.to[0]) !== wireId) + ) + this.graflow.stagedWires[wireId]?.remove() + delete this.graflow.stagedWires[wireId] this.currentlySelectedWire.remove() this.currentlySelectedWire = null + this.graflow.fireEvent('wireRemoved', { wireId }) return } if(e.key == 'Escape') { - if(this.currentlySelectedWire) this.currentlySelectedWire.style.setProperty('stroke', '#0000', 'important') - this.currentlySelectedWire = null + if(this.currentlySelectedWire) { + this.currentlySelectedWire.style.setProperty('stroke', '#0000', 'important') + this.currentlySelectedWire = null + } + if(this.currentlySelectedPort) { + this.currentlySelectedPort.style.removeProperty('border') + this.currentlySelectedPort = null + } + if(this.tempwire) { + this.tempwire.remove() + this.tempwire = null + this.graflow.mainContainer.removeEventListener('pointermove', this._boundPointerMove) + } return } } diff --git a/graflow_examples/etest1.html b/graflow_examples/etest1.html new file mode 100644 index 0000000..2bd39cb --- /dev/null +++ b/graflow_examples/etest1.html @@ -0,0 +1,46 @@ + + + + graflow + + + + + + + + + + + + + + + + diff --git a/graflow_examples/etest2.html b/graflow_examples/etest2.html new file mode 100644 index 0000000..cc44b4c --- /dev/null +++ b/graflow_examples/etest2.html @@ -0,0 +1,60 @@ + + + + graflow + + + + + + + + + + + + + + + + + diff --git a/graflow_examples/flows/testFlow1.json b/graflow_examples/flows/testFlow1.json index 04a49a1..4c76651 100644 --- a/graflow_examples/flows/testFlow1.json +++ b/graflow_examples/flows/testFlow1.json @@ -33,6 +33,10 @@ { "nodeType": "console", "id": "9999", "coords": { "x": 800, "y": 350} + }, + { "nodeType": "square", + "id": "prng", + "coords": { "x": 250, "y": 400} } ], "links": [ diff --git a/graflow_examples/flows/testFlowEvent1.json b/graflow_examples/flows/testFlowEvent1.json new file mode 100644 index 0000000..caf880a --- /dev/null +++ b/graflow_examples/flows/testFlowEvent1.json @@ -0,0 +1,98 @@ +{ + "nodesFile": "./nodesLib/nodesEIC.html", + "flow": { + "nodes":[ + { "nodeType": "eicBasic", + "id": "config", + "ncoords": { "x": 50, "y": 120}, + "markup": { + "title": "Configure event", + "subtitle": "", + "severity": "warning" + } + }, + { "nodeType": "eicBasic", + "id": "mailinvit", + "ncoords": { "x": 100, "y": 220}, + "markup": { + "title": "Candidates invitation mailing", + "subtitle": "", + "severity": "success" + } + }, + { "nodeType": "eicBasic", + "id": "applicationsurvey", + "ncoords": { "x": 150, "y": 320}, + "markup": { + "title": "Application survey", + "subtitle": "", + "severity": "success" + } + }, + { "nodeType": "eicBasic", + "id": "enrollment", + "ncoords": { "x": 150, "y": 320}, + "markup": { + "title": "Candidates enrollment", + "subtitle": "", + "severity": "secondary" + } + }, + { "nodeType": "eicBasic", + "id": "mailwelcome", + "ncoords": { "x": 150, "y": 320}, + "markup": { + "title": "Welcome mailing", + "subtitle": "", + "severity": "warning" + } + }, + { "nodeType": "eicBasic", + "id": "mailrejection", + "ncoords": { "x": 150, "y": 320}, + "markup": { + "title": "Rejection mailing", + "subtitle": "", + "severity": "warning" + } + }, + { "nodeType": "eicBasic", + "id": "event", + "ncoords": { "x": 150, "y": 320}, + "markup": { + "title": "Event", + "subtitle": "", + "severity": "secondary" + } + }, + { "nodeType": "eicBasic", + "id": "mailstatisfaction", + "ncoords": { "x": 150, "y": 320}, + "markup": { + "title": "Satisfaction survey mailing", + "subtitle": "", + "severity": "success" + } + }, + { "nodeType": "eicBasic", + "id": "satisfactionsurvey", + "ncoords": { "x": 150, "y": 320}, + "markup": { + "title": "Satisfaction survey", + "subtitle": "", + "severity": "warning" + } + } + ], + "links": [ + { "from": ["config", "out1"], "to": ["mailinvit", "in1"] }, + { "from": ["mailinvit", "out1"], "to": ["applicationsurvey", "in1"] }, + { "from": ["applicationsurvey", "out1"], "to": ["enrollment", "in1"] }, + { "from": ["enrollment", "out1"], "to": ["mailwelcome", "in1"] }, + { "from": ["enrollment", "out1"], "to": ["mailrejection", "in1"] }, + { "from": ["mailwelcome", "out1"], "to": ["event", "in1"] }, + { "from": ["event", "out1"], "to": ["mailstatisfaction", "in1"] }, + { "from": ["mailstatisfaction", "out1"], "to": ["satisfactionsurvey", "in1"] } + ] + } +} \ No newline at end of file diff --git a/graflow_examples/nodesLib/nodesTest1.html b/graflow_examples/nodesLib/nodesTest1.html index 07533b6..564721a 100644 --- a/graflow_examples/nodesLib/nodesTest1.html +++ b/graflow_examples/nodesLib/nodesTest1.html @@ -96,10 +96,21 @@ .bzgf-node[data-nodetype="input"] .title, .bzgf-node[data-nodetype="console"] .title{ background: #555; } + + + .bzgf-node[data-nodetype="square"]{ + background: #FAA; + border-color: #A00; + width: 100px; + height: 100px; + } + .bzgf-node[data-nodetype="square"] .title{ background: #555; } + .bzgf-node[data-nodetype="refnodein"], .bzgf-node[data-nodetype="refnodeout"] { width:3em; height:3em; padding: 2px; + border: none; } .bzgf-node[data-nodetype="refnodein"] .body, .bzgf-node[data-nodetype="refnodeout"] .body{ border-radius: 50%; @@ -108,6 +119,10 @@ display: flex; align-items: center; justify-content: center; + margin:0; + } + .bzgf-node[data-nodetype="refnodein"] .port, .bzgf-node[data-nodetype="refnodeout"] .port{ + top: 50%; } .bzgf-node[data-nodetype="refnodein"] .body{ background: #0F0; } .bzgf-node[data-nodetype="refnodeout"] .body{ background: #FF0; } @@ -189,14 +204,25 @@ + +