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 = `
+
+ `
+ 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
+ * / |/ (_)______ __ _____
+ * / / / __(_- // (_-<
+ * /_/|_/_/\__/___/\_, /___/
+ * /___/
+ * production !
+ *
+ * Licensed under the MIT License:
+ * This code is free to use and modify,
+ * as long as the copyright notice and license are kept.
+ */
+class BZgrafloweditor extends Buildoz{
+ constructor(){
+ super()
+ this.defaultAttrs = { }
+window.debugEditor = this
+ }
+
+ async connectedCallback() {
+ await customElements.whenDefined('bz-graflow')
+ await customElements.whenDefined('bz-slidepane')
+ await customElements.whenDefined('bz-select')
+ super.connectedCallback()
+ const nodesUrl = this.getBZAttribute('nodes')
+ this.mainContainer = document.createElement('div')
+ this.mainContainer.classList.add('bzgfe-main-container')
+ this.nodesContainer = document.createElement('div')
+ this.nodesContainer.classList.add('bzgfe-nodes-container')
+ this.mainContainer.append(this.nodesContainer)
+ this.slidePane = document.createElement('bz-slidepane')
+ this.slidePane.setAttribute('side', 'right')
+ this.slidePane.setAttribute('data-output', 'console')
+ this.slidePane.setAttribute('maxwidth', '350')
+ this.slidePane.setAttribute('minwidth', '200')
+ this.fillconsole()
+ //this.mainContainer.append(this.slidePane)
+ this.graflow = document.createElement('bz-graflow')
+ this.graflow.setAttribute('nodes', nodesUrl)
+ this.graflow.setAttribute('edit', "nodesmove,editwires,dropnodes")
+ this.graflow.setAttribute('align', "center")
+ this.graflow.setAttribute('wiretype', "bezier")
+ this.graflow.setAttribute('tension', "30")
+ this.graflow.setAttribute('gapx', "80")
+ this.graflow.setAttribute('gapy', "80")
+ this.graflow.addEventListener('bz:graflow:domConnected', this.setupDropZone.bind(this))
+ this.graflow.append(this.slidePane)
+ this.mainContainer.append(this.graflow)
+ this.append(this.mainContainer)
+ document.querySelector('[data-trigger="onAutoplace1H"]').addEventListener('click',
+ (evt) => { 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 @@
-
+
+
+
+
+
-
+
diff --git a/graflow_examples/test.html b/graflow_examples/test.html
index fddcd92..1812668 100644
--- a/graflow_examples/test.html
+++ b/graflow_examples/test.html
@@ -44,7 +44,7 @@
| test1 |
- P42 |
+ Graflow P42 |
YES |
2 depths |
|
@@ -56,7 +56,7 @@
| test2 |
- Organigram |
+ Graflow Organigram |
YES |
|
|
@@ -68,7 +68,7 @@
| test3 |
- EIC simple |
+ Graflow EIC simple |
NO |
1 depth |
|
@@ -80,7 +80,7 @@
| test4 |
- EIC-ICMP |
+ Graflow EIC-ICMP |
NO |
|
X |
@@ -92,7 +92,7 @@
| test4.5 |
- EIC-ICMP II |
+ Graflow EIC-ICMP II |
NO |
|
|
@@ -104,7 +104,7 @@
| test5 |
- P42 |
+ Graflow P42 |
YES |
|
|
@@ -116,7 +116,7 @@
| test6 |
- 16 ports test |
+ Graflow 16 ports test |
NO |
|
|
@@ -127,6 +127,32 @@
|
+
+
+