diff --git a/app/thirdparty/buildoz/buildoz.css b/app/thirdparty/buildoz/buildoz.css
deleted file mode 100644
index 6f358e7..0000000
--- a/app/thirdparty/buildoz/buildoz.css
+++ /dev/null
@@ -1,250 +0,0 @@
-bz-select {
- display: block;
- margin: .5em 0 .5em 0;
- position:relative;
-}
-bz-select[disabled] > button{
- opacity: 0.6;
- cursor: not-allowed;
- pointer-events: none;
- filter: grayscale(30%);
-}
-bz-select > button{
- width:100%;
- text-align: left;
- font-family: sans;
- font-size: .9em;
- border-radius: 1em;
- border: none;
- padding: 0.2em .2em .3em .5em;
- background: linear-gradient( to bottom, #555, #aaa 15%, #ccc 50%, #aaa 85%, #555 );
-}
-bz-select > button::after {
- content: "\00BB";
- transform: rotate(90deg);
- position: absolute;
- right: 0.5em;
- top: 0;
- pointer-events: none;
- font-size: 1.5em;
- color: #444;
-}
-bz-select > div.options-container{
- pointer-events: none;
- position: absolute;
- top: 1.8em;
- left: 0;
- width: 100%;
- z-index: 99;
- max-height: 0;
- overflow: auto;
- transition: max-height 0.4s ease;
-}
-bz-select > div.options-container.open{ pointer-events: auto; max-height: 10em;}
-bz-select option{
- background-color: #DDD;
- border: 1px solid black;
- color: #000;
- padding: 0.2em .2em .5em .5em;
- margin: -1em 0 0 0;
- border-radius: 1em;
- height: 1em;
- font-family: sans;
- font-size: .9em;
- opacity: 0;
- pointer-events: none;
- transition:
- margin-top 0.3s ease,
- opacity 0.3s ease;
-}
-bz-select option.open{
- margin: 0;
- opacity: 1;
- pointer-events: auto;
-}
-bz-select option:hover{
- background-color: #44F;
- color: #FFF;
-}
-
-/************************************************************************************/
-
-bz-toggler{
- outline: none;
- min-height: 1.5em;
- padding: 0.75em;
- display: inline-table;
- max-width: fit-content;
- transition: all 0.5s;
-}
-bz-toggler div.toggle-label-left{
- padding-right: 0.3em;
- display: inline;
- vertical-align: middle;
- text-align: right;
- font-size:.8em;
-}
-bz-toggler div.toggle-label-right{
- padding-left: 0.3em;
- display: inline;
- vertical-align: middle;
- text-align: left;
- font-size:.8em;
-}
-bz-toggler div.toggle-switch{
- user-select: none;
- height: inherit;
- cursor: pointer;
- display: inline;
- vertical-align: middle;
- text-align: center;
-}
-bz-toggler div.toggle-switch span.toggle-bar {
- display: inline-block;
- position: relative;
- background-color: #CCC;
-}
-
-bz-toggler div.toggle-switch span.toggle-thumb {
- display: inline-block;
- border-radius: 50%;
- background-color: white;
- position: absolute;
- z-index: 1;
-}
-
-bz-toggler div.toggle-switch span.toggle-thumb:not(.turned-on) {
- left: 0;
- transition: all 0.4s;
-}
-bz-toggler div.toggle-switch span.toggle-thumb.turned-on {
- transition: all 0.4s;
-}
-bz-toggler div.toggle-switch span.toggle-bar {
- width: 2em;
- height: 0.75em;
- border-radius: .75em;
-}
-
-bz-toggler div.toggle-switch span.toggle-thumb {
- height: 1em;
- width: 1em;
- top: 50%;
- transform: translateY(-50%);
-}
-
-bz-toggler div.toggle-switch span.toggle-thumb.turned-on {
- left : 1em;
-}
-
-/************************************************************************************/
-
-bz-slidepane {
- display: block;
- position: absolute;
- background-color: #000A;
-}
-bz-slidepane[side="top"] { top:0; left:0; width: 100%; height:0; border-bottom: 2px solid #DDD; }
-bz-slidepane[side="bottom"] { bottom:0; left:0; width: 100%; height:0; border-top: 2px solid #DDD;}
-bz-slidepane[side="left"] { top:0; left:0; height:100%; width:0; border-right: 2px solid #DDD;}
-bz-slidepane[side="right"] { top:0; right:0; height:100%; width:0; border-left: 2px solid #DDD; }
-bz-slidepane[side="top"] div.handle {
- position: absolute;
- bottom: -12px;
- left: 50%;
- width: 40px;
- height: 11px;
- background: repeating-linear-gradient( to top, 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 );
- transform: translateX(-50%);
- cursor: ns-resize;
-}
-bz-slidepane[side="bottom"] div.handle {
- position: absolute;
- top: -12px;
- left: 50%;
- width: 40px;
- height: 11px;
- background: repeating-linear-gradient( to bottom, 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 );
- transform: translateX(-50%);
- cursor: ns-resize;
-}
-bz-slidepane[side="left"] div.handle {
- position: absolute;
- right: -12px;
- top: 50%;
- width: 11px;
- height: 40px;
- background: repeating-linear-gradient( to left, 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 );
- transform: translateY(-50%);
- cursor: ew-resize;
-}
-bz-slidepane[side="right"] div.handle {
- position: absolute;
- left: -12px;
- 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 );
- transform: translateY(-50%);
- cursor: ew-resize;
-}
-
-/************************************************************************************/
-
-bz-graflow {
- position: relative;
- display: block;
- width: 100vw;
- height: 50vh;
- box-sizing: border-box;
-}
-bz-graflow .bzgf-main-container{
- width: 100%;
- height: 100%;
- position: relative;
- box-sizing: border-box;
-}
-
-/* BZGRAFLOW_CORE_START */
-/* Keep this commented section !
- bz-graflow internal layout rules (used in light DOM, and injected into shadow DOM when isolated)
-*/
-bz-graflow .bzgf-wires-container,
-bz-graflow .bzgf-nodes-container{
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
-}
-bz-graflow .bzgf-nodes-container{ /* used to keep the nodes container pointer-events: none, but allow the nodes to be moved ! */
- pointer-events: none;
-}
-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{
- position: absolute;
- width: 5px;
- height: 5px;
- background: transparent;
- border-style: none;
-}
-bz-graflow .bzgf-nodes-container button.bzgf-zoom-in{
- z-index: 999;
- position: absolute;
- top: -0.5em;
- right: -1em;
- color: black;
- width: 2em;
- height: 2em;
- padding: 0;
-}
-bz-graflow button.bzgf-zoom-out{
- z-index: 999;
- position: absolute;
- left: 5px;
- top: 5px;
-}
-/* BZGRAFLOW_CORE_END */
diff --git a/app/thirdparty/buildoz/buildoz.js b/app/thirdparty/buildoz/buildoz.js
deleted file mode 100644
index 5718f0d..0000000
--- a/app/thirdparty/buildoz/buildoz.js
+++ /dev/null
@@ -1,343 +0,0 @@
-/**
- * _ ___ 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 Buildoz extends HTMLElement {
- constructor(){
- super() // always call super() first!
- this.attrs = {}
- }
-
- static get observedAttributes(){ //observable attributes triggering attributeChangedCallback
- // anything added here will be observed for all buildoz tags
- // in your child, add you local 'color' observable attr with :
- // return([...super.observedAttributes, 'color'])
- return(['name'])
- }
-
- static define(name, cls){
- const tag = `bz-${name}`
- if(!customElements.get(tag)) { // no wild redefinition
- customElements.define(tag, cls)
- }
- return cls
- }
-
- connectedCallback(){ // added to the DOM
- this.classList.add('buildoz')
- }
-
- disconnectedCallback(){ // removed from the DOM
-
- }
-
- attributeChangedCallback(name, oldValue, newValue) {
- this.attrs[name] = newValue
- if(name=='name') this.name = newValue
- }
-
- getBZAttribute(attrName){ // Little helper for defaults
- return(this.getAttribute(attrName) || this.defaultAttrs[attrName] )
- }
-}
-
-class BZselect extends Buildoz {
- #value
- #fillFromMarkup = true
-
- constructor(){
- super()
- this.value = null
- this.open = false
- this.value = null
- this.generalClickEvent = null
- this.defaultAttrs = {
- label: 'Select...',
- }
- }
-
- connectedCallback() {
- super.connectedCallback()
- this.button = document.createElement('button')
- this.button.textContent = this.getBZAttribute('label')
- this.prepend(this.button)
- this.button.addEventListener('click', this.toggle.bind(this))
- if(!this.optionscontainer) this.optionscontainer = document.createElement('div')
- this.optionscontainer.classList.add('options-container')
- this.append(this.optionscontainer)
- this.options = this.querySelectorAll('option')
- if(this.#fillFromMarkup){ //can only do it once and only if fillOptions was not already called !!
- for(const opt of this.options){
- this.optionscontainer.append(opt) // Will move is to the right parent
- opt.addEventListener('click', this.onClick.bind(this))
- if(opt.getAttribute('selected') !== null) this.onOption(opt.value, true)
- }
- this.#fillFromMarkup = false
- }
-
- }
-
- // static get observedAttributes(){ // Only if you want actions on attr change
- // return([...super.observedAttributes, 'disabled'])
- // }
-
- // attributeChangedCallback(name, oldValue, newValue) {
- // super.attributeChangedCallback(name, oldValue, newValue)
- // }
-
- get value(){ return(this.#value) }
-
- set value(v) {
- this.#value = v
- if(this.options && this.options.length>0) {
- const opt = Array.from(this.options).find(opt => opt.value==v)
- if(v || (opt && opt.textContent)) this.button.textContent = opt.textContent
- else this.button.textContent = this.getBZAttribute('label')
- this.dispatchEvent(new Event('change', {
- bubbles: true,
- composed: false,
- cancelable: false
- }))
- }
- }
-
- toggle(){
- for(const opt of this.options){
- if(this.open) {
- opt.classList.remove('open')
- this.optionscontainer.classList.remove('open')
- } else {
- document.querySelectorAll('bz-select').forEach((sel) => {
- if((sel!==this) && sel.open) sel.toggle()
- })
- opt.classList.add('open')
- this.optionscontainer.classList.add('open')
- }
- }
- this.open = !this.open
- }
-
- onClick(evt){
- evt.stopPropagation()
- const opt = evt.target.closest('option')
- if(opt && opt.value) this.onOption(opt.value)
- }
-
- onOption(value, silent=false){
- if(this.getAttribute('disabled') !== null) return
- this.value = value
- if(!silent) this.toggle()
- }
-
- addOption(value, markup){
- // Caution: you cannot count on connectedCallback to have run already, because one might fill before adding to the DOM
- const opt = document.createElement('option')
- opt.setAttribute('value', value)
- opt.innerHTML = markup
- opt.addEventListener('click',this.onClick.bind(this))
- if(!this.optionscontainer) this.optionscontainer = document.createElement('div')
- this.optionscontainer.append(opt)
- this.options = this.querySelectorAll('option')
- this.#fillFromMarkup = false
- }
-
- fillOptions(opts, erase = true){
- // Caution: you cannot count on connectedCallback to have run already, because one might fill before adding to the DOM
- if(erase){
- this.options = this.querySelectorAll('option')
- this.options.forEach(node => { node.remove() })
- this.options = this.querySelectorAll('option')
- this.onOption('', true) // unselect last
- }
- for(const opt of opts) this.addOption(opt.value, opt.markup)
- }
-}
-Buildoz.define('select', BZselect)
-
-
-class BZtoggler extends Buildoz {
- #value
-
- constructor(){
- super()
- this.open = false
- this.defaultAttrs = {
- labelLeft:'',
- labelRight:'',
- trueValue: 'yes',
- falseValue: 'no',
- classOn:'',
- classOff:'',
- tabindex:0,
- disabled:false
- }
- }
-
- connectedCallback(){
- super.connectedCallback()
-
- this.labelRight = document.createElement('div')
- this.labelRight.classList.add('toggle-label-right')
- this.labelRight.innerHTML = this.getBZAttribute('labelRight')
-
- this.labelLeft = document.createElement('div')
- this.labelLeft.classList.add('toggle-label-left')
- this.labelLeft.innerHTML = this.getBZAttribute('labelLeft')
-
- this.switch = document.createElement('div')
- this.switch.classList.add('toggle-switch')
-
- this.toggleBar = document.createElement('span')
- this.toggleBar.classList.add('toggle-bar')
-
- this.thumb = document.createElement('span')
- this.thumb.classList.add('toggle-thumb')
- this.toggleBar.append(this.thumb)
-
- this.switch.append(this.toggleBar)
-
- this.appendChild(this.labelLeft)
- this.appendChild(this.switch)
- this.appendChild(this.labelRight)
- this.setAttribute('tabindex', this.getBZAttribute('tabindex'))
-
- this.switch.addEventListener('click', this.toggle.bind(this))
-
- }
-
- turnOn() {
- if(this.getBZAttribute('classOff')) this.switch.classList.remove(this.getBZAttribute('classOff'))
- if(this.getBZAttribute('classOn')) this.switch.classList.add(this.getBZAttribute('classOn'))
- this.thumb.classList.add('turned-on')
- this.#value = this.getBZAttribute('trueValue')
- this.focus()
- }
-
- turnOff() {
- if(this.getBZAttribute('classOn')) this.switch.classList.remove(this.getBZAttribute('classOn'))
- if(this.getBZAttribute('classOff'))this.switch.classList.add(this.getBZAttribute('classOff'))
- this.thumb.classList.remove('turned-on')
- this.#value = this.getBZAttribute('falseValue')
- this.focus()
- }
-
- toggle(event) {
- if(event) { event.preventDefault(); event.stopPropagation(); }
- if(this.getBZAttribute('disabled')) return
- if(this.#value == this.getBZAttribute('trueValue')) this.turnOff()
- else this.turnOn()
- this.dispatchEvent(new Event('change', {
- bubbles: true,
- composed: false,
- cancelable: false
- }))
- }
-
- get value(){ return(this.#value) }
-
- set value(v) {
- this.#value = v
- if(this.defaultAttrs){
- if(this.#value == this.getBZAttribute('trueValue')) this.turnOn()
- else this.turnOff()
- this.dispatchEvent(new Event('change', {
- bubbles: true,
- composed: false,
- cancelable: false
- }))
- }
- }
-}
-Buildoz.define('toggler', BZtoggler)
-
-
-
-class BZslidePane extends Buildoz {
-
- constructor(){
- super()
- this.open = false
- this.defaultAttrs = {
- side: 'bottom'
- }
- this.dragMove = this.dragMove.bind(this)
- this.dragEnd = this.dragEnd.bind(this)
- // Fill with innerHTML or other DOM manip should not allow coating to be removed
- this._observer = new MutationObserver(muts => { this.coat() })
- }
-
- connectedCallback(){
- super.connectedCallback()
- this.coat()
- this._observer.observe(this, { childList: true }) // Do this last
- }
-
- disconnectedCallback() {
- super.disconnectedCallback()
- this._observer.disconnect()
- }
-
- coat(){
- if(this.handle && this.querySelector(this.dispatchEvent.handle)) return
- this._observer.disconnect()
- if(this.querySelector(this.dispatchEvent.handle)) this.querySelector(this.dispatchEvent.handle).remove()
- this.handle = document.createElement('div')
- this.handle.classList.add('handle')
- this.prepend(this.handle)
- this.handle.addEventListener('pointerdown', this.dragStart.bind(this))
- this._observer.observe(this, { childList: true })
- }
-
- dragStart(evt){
- evt.target.setPointerCapture(evt.pointerId)
- this.dragStartX = evt.clientX
- this.dragStartY = 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
- 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)
- 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)
- 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)
- 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)
- this.style.width = width+'px'
- break
- }
- }
-
- dragEnd(evt){
- evt.target.releasePointerCapture(evt.pointerId)
- this.handle.removeEventListener('pointermove', this.dragMove)
- this.handle.removeEventListener('pointerup', this.dragEnd)
- }
-}
-Buildoz.define('slidepane', BZslidePane)
-
diff --git a/app/thirdparty/buildoz/bzGraflow.js b/app/thirdparty/buildoz/bzGraflow.js
deleted file mode 100644
index 3a48ff1..0000000
--- a/app/thirdparty/buildoz/bzGraflow.js
+++ /dev/null
@@ -1,1300 +0,0 @@
-/**
- * _ ___ 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 BZgraflow extends Buildoz{
- dirVect = {
- n: { x: 0, y: -1 },
- s: { x: 0, y: 1 },
- e: { x: 1, y: 0 },
- w: { x: -1, y: 0 },
- }
- btnIcons = {
- zoomin:'M992.262 88.604l-242.552 206.294c-25.074 22.566-51.89 32.926-73.552 31.926 57.256 67.068 91.842 154.078 91.842 249.176 0 212.078-171.922 384-384 384-212.076 0-384-171.922-384-384s171.922-384 384-384c95.098 0 182.108 34.586 249.176 91.844-1-21.662 9.36-48.478 31.926-73.552l206.294-242.552c35.322-39.246 93.022-42.554 128.22-7.356s31.892 92.898-7.354 128.22zM384 320c-141.384 0-256 114.616-256 256s114.616 256 256 256 256-114.616 256-256-114.614-256-256-256zM448 768h-128v-128h-128v-128h128v-128h128v128h128v128h-128z',
- zoomout:'M992.262 88.604l-242.552 206.294c-25.074 22.566-51.89 32.926-73.552 31.926 57.256 67.068 91.842 154.078 91.842 249.176 0 212.078-171.922 384-384 384-212.076 0-384-171.922-384-384s171.922-384 384-384c95.098 0 182.108 34.586 249.176 91.844-1-21.662 9.36-48.478 31.926-73.552l206.294-242.552c35.322-39.246 93.022-42.554 128.22-7.356s31.892 92.898-7.354 128.22zM384 320c-141.384 0-256 114.616-256 256s114.616 256 256 256 256-114.616 256-256-114.614-256-256-256zM320 380v384h128V448z',
- }
-
- static _loadedNodeStyles = new Set() // Allow multi instances or re-loadNodes, but avoid reinjecting same styles !
-
- constructor(){
- super()
- this.defaultAttrs = { tension: 100 }
- this.stagedNodes = { }
- this.stagedWires = { }
- this.arrowDefs = null
- this.currentOrientation = null
- }
-
- static _coreCssPromise = null
-
- static async getCoreCss(){
- if(BZgraflow._coreCssPromise) return(await BZgraflow._coreCssPromise)
- BZgraflow._coreCssPromise = (async() => {
- const res = await fetch('/app/thirdparty/buildoz/buildoz.css')
- const css = await res.text()
- const m = css.match(/\/\*\s*BZGRAFLOW_CORE_START\s*\*\/([\s\S]*?)\/\*\s*BZGRAFLOW_CORE_END\s*\*\//)
- const core = m ? m[1] : ''
- return(core)
- })()
- return(await BZgraflow._coreCssPromise)
- }
-
- async ensureIsolatedCoreStyles(){
- if(!this.hasAttribute('isolated')) return
- if(this._isolatedCoreInjected) return
- this._isolatedCoreInjected = true
- const core = await BZgraflow.getCoreCss()
- // Convert light-dom selectors (`bz-graflow ...`) to shadow-dom selectors (`:host ...`)
- const shadowCss = core.replaceAll('bz-graflow', ':host')
- const style = document.createElement('style')
- style.textContent = shadowCss
- this.mainContainer.appendChild(style)
- }
-
- addIcon(el, name) {
- el.innerHTML = ``
- }
-
- 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')
- ? this.hostContainer.attachShadow({ mode: 'open' })
- : this.hostContainer
- this.ensureIsolatedCoreStyles()
- this.nodesContainer = document.createElement('div')
- this.nodesContainer.classList.add('bzgf-nodes-container')
- this.wiresContainer = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
- this.wiresContainer.setAttribute('overflow','visible')
- this.wiresContainer.classList.add('bzgf-wires-container')
- this.mainContainer.append(this.wiresContainer)
- this.mainContainer.append(this.nodesContainer)
- this.append(this.hostContainer)
- this.loadFlow(flowUrl).then(() => {
- if(this.getBZAttribute('edit')){
- const edit = this.getBZAttribute('edit').split(',')
- if(edit.includes('nodesmove')){
- this.nodesMover = new MovingNodes(this)
- this.nodesMover.enableMovingNodes('.bzgf-node')
- }
- if(edit.includes('wires')){
- this.WiresEditor = new EditWires(this)
- this.WiresEditor.enableEditWires()
- //this.WiresEditor.enableMovingNodes('.bzgf-wire')
- }
- if(edit.includes('dropnodes')){
- this.NodesReceiver = new DroppingNodes(this)
- //this.NodesReceiver.enableDroppingNodes('.bzgf-node')
- }
- }
- })
- }
-
- error(msg, err){
- this.innerHTML = `
${msg}
`
- 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
- }
- if(!flowObj.nodesFile){
- this.error('No nodesFile in JSON!?')
- return
- }
- await this.loadNodes(flowObj.nodesFile)
- this.flow = flowObj.flow
- this.refresh()
- this.dispatchEvent(new CustomEvent('flowLoaded', {
- detail: { url },
- bubbles: true,
- composed: true,
- }))
- }
-
- async loadNodes(url) {
- const res = await fetch(url+'?'+crypto.randomUUID())
- const html = await res.text()
-
- // Get nodes
- const doc = new DOMParser().parseFromString(html, 'text/html')
- this.nodesRegistry = {}
- for(const tpl of doc.querySelectorAll('template')){
- if(tpl.id=='svg-arrows'){
- this.arrowDefs = tpl.querySelector('defs').cloneNode(true)
- this.wiresContainer.appendChild(this.arrowDefs)
- } else {
- const rootEl = tpl.content.querySelector('.bzgf-node')
- if(!rootEl) continue
- this.nodesRegistry[rootEl.dataset.nodetype] = rootEl
- }
- }
-
- // Now load styles
- const isIsolated = this.hasAttribute('isolated')
- const styles = doc.querySelectorAll('style')
- if(isIsolated) {
- // Shadow DOM: styles are per-instance
- styles.forEach(styleEl => {
- const style = document.createElement('style')
- style.textContent = styleEl.textContent
- this.mainContainer.appendChild(style)
- })
- } else {
- // Light DOM: inject into document.head once per nodesFile url
- if(!BZgraflow._loadedNodeStyles.has(url)) {
- styles.forEach(styleEl => {
- const style = document.createElement('style')
- style.textContent = styleEl.textContent
- style.dataset.bzgfNodesStyle = url
- document.head.appendChild(style)
- })
- BZgraflow._loadedNodeStyles.add(url)
- }
- }
- this.dispatchEvent(new CustomEvent('nodesLoaded', {
- detail: { url },
- bubbles: true,
- composed: true,
- }))
- }
-
- addNode(node){
- const id = node.id
- if(!(node.nodeType in this.nodesRegistry)){ console.warn(`Unknown node type (${node.nodeType})`); return(null)}
- const nodeDef = this.nodesRegistry[node.nodeType]
- this.stagedNodes[id] = nodeDef.cloneNode(true)
- for(const token in node.markup){
- this.stagedNodes[id].innerHTML = this.stagedNodes[id].innerHTML.replace(new RegExp(`\{${token}\}`, 'g'), node.markup[token])
- }
- if(typeof(node.data)=='object') Object.assign(this.stagedNodes[id].dataset, node.data)
- const portEls = this.stagedNodes[id].querySelectorAll('.port')
- this.stagedNodes[id].style.left = (node.coords && node.coords.x) ? `${node.coords.x}px` : '10%'
- this.stagedNodes[id].style.top = (node.coords && node.coords.y) ? `${node.coords.y}px` : '10%'
- this.stagedNodes[id].dataset.id = id
- this.stagedNodes[id].ports = Object.fromEntries(Array.from(portEls).map(item => ([item.dataset.id, { ...item.dataset, el:item }])))
- if(node.subflow) {
- const btnEnterSubflow = document.createElement('button')
- btnEnterSubflow.classList.add('bzgf-zoom-in')
- this.addIcon(btnEnterSubflow, 'zoomin')
- btnEnterSubflow.addEventListener('click', () => {
- this.enterSubflow(id)
- })
- this.stagedNodes[id].appendChild(btnEnterSubflow)
- }
- this.nodesContainer.append(this.stagedNodes[id])
- if(!this.flow.nodes.find(n => n.id === id)) {
- this.flow.nodes.push(node)
- }
- return(this.stagedNodes[id])
- }
-
- enterSubflow(id){
- const nodeEl = this.stagedNodes[id]
- if(!nodeEl) return
-
- // Create the child graflow first, place it above (foreground) the current graflow,
- // 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)
-
- childEl.addEventListener('flowLoaded', (e) => {
- for(const portLink of flowNode.subflow.portLinks){
- const nid = crypto.randomUUID()
- childEl.addNode({
- "nodeType": portLink.refNodeType,
- "id": nid,
- "markup": { "parentport": portLink.parentPort }
- })
- if(portLink.direction=='in') {
- childEl.addWire({
- "from": [nid, portLink.refnodePort],
- "to": [portLink.subflowNode, portLink.subflowPort]
- })
- } else if(portLink.direction=='out') {
- childEl.addWire({
- "from": [portLink.subflowNode, portLink.subflowPort],
- "to": [nid, portLink.refnodePort]
- })
- }
- }
-
- childEl.autoPlace(this.currentOrientation, 60, 60)
- }, { once:true })
-
- // 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'
-
- // Initial transform so the full-size child "fits" inside the node
- const sx0 = nodeBB.width / parentBB.width
- const sy0 = nodeBB.height / parentBB.height
- // When the host graflow is scrollable, nodeBB is viewport-relative while the invading child
- // is positioned inside `this` (absolute/inset=0). Add scroll offsets to keep coordinates consistent.
- const tx0 = (nodeBB.left - parentBB.left) + (this.scrollLeft || 0)
- const ty0 = (nodeBB.top - parentBB.top) + (this.scrollTop || 0)
-
- // Inline "scaler" (shadow styles don't apply to the child element)
- childEl.style.border = 'none'
- childEl.style.transformOrigin = 'top left'
- childEl.style.willChange = 'transform'
- childEl.style.transform = 'translate(var(--tx, 0px), var(--ty, 0px)) scale(var(--sx, 1), var(--sy, 1))'
- childEl.style.setProperty('--tx', tx0 + 'px')
- childEl.style.setProperty('--ty', ty0 + 'px')
- childEl.style.setProperty('--sx', sx0)
- childEl.style.setProperty('--sy', sy0)
-
- // Force style flush, then animate back to identity (full parent size)
- childEl.getBoundingClientRect()
- childEl.style.transition = 'transform 1000ms ease-in-out'
- requestAnimationFrame(() => {
- childEl.style.top = 0;
- childEl.style.left = 0;
- childEl.style.setProperty('--tx', '0px')
- childEl.style.setProperty('--ty', '0px')
- childEl.style.setProperty('--sx', 1)
- childEl.style.setProperty('--sy', 1)
- this.hostContainer.style.opacity = '0'
- })
-
- childEl.addEventListener('transitionend', (e) => {
- if(e.propertyName !== 'transform') return
- 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,
- }))
- }, { once:true })
- }
-
- invade(oldEl, newEl){
- newEl.style.position = 'absolute'
- const bbox = oldEl.getBoundingClientRect()
- newEl.style.left = `${bbox.left+bbox.width/2}px`
- newEl.style.top = `${bbox.top+bbox.height/2}px`
- newEl.style.width = `${bbox.width}px`
- newEl.style.height = `${bbox.height}px`
- newEl.style.display = 'block'
- newEl.style.overflow = 'hidden'
- oldEl.appendChild(newEl)
- }
-
- exitSubflow(childEl){
- if(!childEl) return
-
- const enterNodeId = childEl.dataset?.enterNodeId
- const nodeEl = enterNodeId ? this.stagedNodes?.[enterNodeId] : null
- if(!nodeEl){
- // Fallback: no context => just restore parent & remove child
- this.hostContainer.style.opacity = '1'
- this.hostContainer.style.visibility = 'visible'
- if(childEl.parentNode === this) this.removeChild(childEl)
- return
- }
-
- // Compute target transform from full-size back to node rect (inverse of EnterSubflow)
- const nodeBB = nodeEl.getBoundingClientRect()
- const parentBB = this.getBoundingClientRect()
- const sx0 = nodeBB.width / parentBB.width
- const sy0 = nodeBB.height / parentBB.height
- const tx0 = (nodeBB.left - parentBB.left) + (this.scrollLeft || 0)
- const ty0 = (nodeBB.top - parentBB.top) + (this.scrollTop || 0)
-
- // Try to match duration to the child's transform transition (default 1000ms)
- const transitionStr = childEl.style.transition || ''
- const msMatch = transitionStr.match(/(\d+(?:\.\d+)?)ms/)
- const durMs = msMatch ? parseFloat(msMatch[1]) : 1000
-
- // Ensure parent is visible but faded-in during the shrink animation
- this.hostContainer.style.visibility = 'visible'
- this.hostContainer.style.opacity = '0'
- this.hostContainer.style.transition = `opacity ${durMs}ms ease-in-out`
-
- // Ensure child animates (it may have had transform cleared after enter)
- childEl.style.transformOrigin = 'top left'
- childEl.style.willChange = 'transform'
- childEl.style.transform = 'translate(var(--tx, 0px), var(--ty, 0px)) scale(var(--sx, 1), var(--sy, 1))'
- childEl.style.setProperty('--tx', '0px')
- childEl.style.setProperty('--ty', '0px')
- childEl.style.setProperty('--sx', 1)
- childEl.style.setProperty('--sy', 1)
- childEl.style.transition = `transform ${durMs}ms ease-in-out`
- childEl.getBoundingClientRect() // flush
-
- requestAnimationFrame(() => {
- // Shrink/move the child back into the original node
- childEl.style.setProperty('--tx', tx0 + 'px')
- childEl.style.setProperty('--ty', ty0 + 'px')
- childEl.style.setProperty('--sx', sx0)
- childEl.style.setProperty('--sy', sy0)
- // Fade the parent back in
- this.hostContainer.style.opacity = '1'
- })
-
- childEl.addEventListener('transitionend', (e) => {
- if(e.propertyName !== 'transform') return
- if(childEl.parentNode === this) this.removeChild(childEl)
- // Cleanup: ensure parent is fully visible and no longer hidden
- 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,
- }))
- }, { once:true })
- }
-
- addFakeNode(nid, x, y, w, h){
- this.stagedNodes[nid] = document.createElement('div')
- this.stagedNodes[nid].classList.add('bzgf-fake-node')
- this.stagedNodes[nid].style.left = `${x}px`
- this.stagedNodes[nid].style.top = `${y}px`
- this.stagedNodes[nid].style.width = `${w}px`
- this.stagedNodes[nid].style.height = `${h}px`
- this.stagedNodes[nid].dataset.id = nid
- this.nodesContainer.append(this.stagedNodes[nid])
- return(this.stagedNodes[nid])
- }
-
- addWire(link){
- const [idNode1, idPort1] = link.from
- const [idNode2, idPort2] = link.to
- const path = this.linkNodes(idNode1, idPort1, idNode2, idPort2)
- const id = `${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)')
- this.stagedWires[id].classList.add('bzgf-wire')
- this.stagedWires[id].dataset.id = id
- this.wiresContainer.append(this.stagedWires[id])
- if(!this.flow.links.find(l => l.from[0] === idNode1 && l.from[1] === idPort1 && l.to[0] === idNode2 && l.to[1] === idPort2)) {
- this.flow.links.push(link)
- }
- return(this.stagedWires[id])
- }
-
- clear(){
- this.nodesContainer.innerHTML = ''
- this.wiresContainer.innerHTML = ''
- if(this.arrowDefs) this.wiresContainer.appendChild(this.arrowDefs)
- }
-
- refresh(){
- this.clear()
- let forceAutoplace = false
- for(const node of this.flow.nodes){
- if((!node.coords) || (!node.coords.x) ||(!node.coords.y)) forceAutoplace=true
- this.addNode(node)
- }
- for(const link of this.flow.links){
- this.addWire(link)
- }
- if(!this.currentOrientation) {
- const bb=this.getBoundingClientRect()
- if(bb.width > bb.height) this.currentOrientation = 'horizontal'
- else this.currentOrientation = 'vertical'
- }
- if(forceAutoplace) this.autoPlace(this.currentOrientation)
- }
-
- // Convert viewport (client) coordinates to this instance's SVG local coordinates.
- // Required when the whole graflow is CSS-transformed (scale/translate), otherwise wire paths
- // will be computed in the wrong coordinate space.
- clientToSvg(x, y){
- const svg = this.wiresContainer
- const ctm = svg?.getScreenCTM?.()
- if(ctm && ctm.inverse){
- const inv = ctm.inverse()
- if(svg?.createSVGPoint){
- const pt = svg.createSVGPoint()
- pt.x = x
- pt.y = y
- const p = pt.matrixTransform(inv)
- return({ x: p.x, y: p.y })
- }
- if(typeof DOMPoint !== 'undefined'){
- const p = new DOMPoint(x, y).matrixTransform(inv)
- return({ x: p.x, y: p.y })
- }
- }
- // Fallback: approximate using boundingClientRect (works only at scale=1)
- const r = svg.getBoundingClientRect()
- return({ x: x - r.left, y: y - r.top })
- }
-
- buildSegment(x1, y1, c1x, c1y, c2x, c2y, x2, y2, wireType, node1, node2, dir1, dir2, tension, loop=false){
- if(loop) wireType = 'bezier' // loops only use bezier to look good
-
- const startAxis = ['n', 's'].includes(dir1) ? 'v' : 'h'
- if(wireType == 'bezier'){
- return(`C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`)
- }
- if(wireType == 'straight'){
- return(`L ${c1x} ${c1y} L ${c2x} ${c2y} L ${x2} ${y2}`)
- }
- if(wireType == 'ortho'){
- const medianx = (x1 + x2) / 2
- const mediany = (y1 + y2) / 2
- if(startAxis == 'v') {
- if( ((dir1 == 's') && (c1y < mediany)) || ((dir1 == 'n') && (c1y > mediany)) ){
- if( (dir2=='e') && (c2x > x1) || (dir2=='w') && (c2x < x1)) return(`V ${mediany} H ${c2x} V ${y2} H ${x2}`)
- else if((dir2=='e') || (dir2=='w')) return(`V ${y2} H ${x2}`)
- else if(dir2 == dir1) { // walk-around node
- const deviation = node2.offsetWidth / 2
- if(x1>x2) {
- if(x1>x2+deviation+tension) return(`V ${c2y} H ${x2} V ${y2}`)
- else return(`V ${c1y} H ${x1+deviation+tension} V ${c2y} H ${x2} V ${y2}`)
- } else {
- if(x1 medianx)) ){
- if( (dir2=='s') && (c2y > y1) || (dir2=='n') && (c2y < y1)) return(`H ${medianx} V ${c2y} H ${x2} V ${y2}`)
- else if((dir2=='n') || (dir2=='s')) return(`H ${x2} V ${y2}`)
- else if(dir2 == dir1) { // walk-around node
- const deviation = node2.offsetHeight / 2
- if(y1>y2) {
- if(y1>y2+deviation+tension) return(`H ${c2x} V ${y2} H ${x2}`)
- else return(`H ${c1x} V ${y1+deviation+tension} H ${c2x} V ${y2} H ${x2}`)
- } else {
- if(y1 {
- const c1x = Math.floor(x1 + (this.dirVect[dir1].x * tension))
- const c1y = Math.floor(y1 + (this.dirVect[dir1].y * tension))
- const c2x = Math.floor(x2 + (this.dirVect[dir2].x * tension))
- const c2y = Math.floor(y2 + (this.dirVect[dir2].y * tension))
- return(this.buildSegment(x1, y1, c1x, c1y, c2x, c2y, x2, y2, wireType, node1, node2, dir1, dir2, tension, false))
- }
-
- // Start/end points in SVG coords (works for both bezier and line)
- const bb1 = port1.el.getBoundingClientRect()
- const bb2 = port2.el.getBoundingClientRect()
- const sp = this.clientToSvg(bb1.x + (bb1.width/2), bb1.y + (bb1.height/2))
- const ep = this.clientToSvg(bb2.x + (bb2.width/2), bb2.y + (bb2.height/2))
- let x1 = Math.floor(sp.x)
- let y1 = Math.floor(sp.y)
- const xEnd = Math.floor(ep.x)
- const yEnd = Math.floor(ep.y)
- let path = `M ${x1} ${y1} `
-
- const entryDir = (orientation == 'horizontal') ? 'w' : 'n'
- const exitDir = (orientation == 'horizontal') ? 'e' : 's'
- let firstPort = port1
- for(const interNode of interNodes){
- const bb = this.stagedNodes[interNode].getBoundingClientRect()
- // Entry/exit points on the placeholder box, converted to SVG coords (handles CSS transforms)
- let entryClient; let exitClient
- if(orientation=='horizontal'){
- entryClient = { x: bb.left, y: bb.top + (bb.height/2) }
- exitClient = { x: bb.right, y: bb.top + (bb.height/2) }
- } else {
- entryClient = { x: bb.left + (bb.width/2), y: bb.top }
- exitClient = { x: bb.left + (bb.width/2), y: bb.bottom }
- }
- const entry = this.clientToSvg(entryClient.x, entryClient.y)
- const exit = this.clientToSvg(exitClient.x, exitClient.y)
- let x2 = Math.floor(entry.x)
- let y2 = Math.floor(entry.y)
-
- if(firstPort){
- path += makeSegment(x1, y1, x2, y2, node1, node2, firstPort.direction, entryDir, tension)
- firstPort = null
- } else {
- path += makeSegment(x1, y1, x2, y2, node1, node2, exitDir, entryDir, tension)
- }
-
- const x3 = Math.floor(exit.x)
- const y3 = Math.floor(exit.y)
- path += ` L ${x3} ${y3} `
-
- x1 = x3
- y1 = y3
- }
- path += ' ' + makeSegment(x1, y1, xEnd, yEnd, node1, node2, exitDir, port2.direction, tension)
- 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'
-
- 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
-
- // Cleanup placeholders from previous autoPlace() runs.
- // Each run creates new longLinkPlaceHolder_* IDs; without cleanup they accumulate in the DOM.
- this.clearFakeNodes()
-
- // 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]))
- } else {
- linksWithoutBackEdges = this.flow.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
- // improve their order. This shows up strongly for "entry reference nodes" that are added
- // programmatically: they all end up in layer 0 and keep insertion order, creating crossings.
- // Pre-sort layer 0 by the average position of their children in layer 1.
- if(layers.length > 1){
- const next = layers[1]
- const nextPos = Object.fromEntries(next.map((nid, idx) => ([nid, idx])))
- const curPos0 = Object.fromEntries(layers[0].map((nid, idx) => ([nid, idx])))
- const key0 = (nid) => {
- const kids = (adj?.[nid] || []).filter(k => (k in nextPos))
- if(kids.length === 0) return(curPos0[nid] ?? 0)
- const sum = kids.reduce((acc, k) => acc + nextPos[k], 0)
- return(sum / kids.length)
- }
- layers[0].sort((a, b) => {
- const ka = key0(a), kb = key0(b)
- if(ka !== kb) return(ka - kb)
- return((curPos0[a] ?? 0) - (curPos0[b] ?? 0))
- })
- }
-
- // Compute indexes for each layer (int part) & add sub-index for ports
- // Also compute max width/height for each layer
- let maxHeight = 0
- let maxWidth = 0
- const layerHeights = []
- const layerWidths = []
- const indexes = {} // indexes[nid] = { base: , ports: { [portId]: } }
- for(const layer of layers){
- let totHeight = 0
- let totWidth = 0
- for(const [idx, nid] of layer.entries()){
- // Use offset* (not impacted by CSS transforms) to keep autoPlace stable during zoom animations.
- const bb = this.stagedNodes[nid].getBoundingClientRect()
- const h = this.stagedNodes[nid].offsetHeight || bb.height
- const w = this.stagedNodes[nid].offsetWidth || bb.width
- totHeight += h + gapy
- totWidth += w + gapx
- indexes[nid] = { base: idx, ports: this.computePortOffsets(nid, orientation) }
- }
- if(totHeight>maxHeight) maxHeight = totHeight
- layerHeights.push(totHeight)
- 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)
- for(const llink of this.flow.longLinks){
- let fakeParent = llink.link.from[0]
- for(const layerIdx of llink.skippedLayers){
- const nid = `longLinkPlaceHolder_${crypto.randomUUID()}`
- layers[layerIdx].push(nid)
- llink.interNodes.push(nid)
- // Placeholders are added after initial index computation; give them an index
- // so reorderLayers() can take them into account (otherwise they default to base=0).
- indexes[nid] = { base: layers[layerIdx].length - 1, ports: {} }
- // Virtual link: treat placeholder as receiving the same "from port" as the original long-link.
- // (Child port doesn't matter for placeholders since they have no ports.)
- this._virtualLinks.set(`${fakeParent}__${nid}`, {
- from: [fakeParent, llink.link.from[1]],
- to: [nid, llink.link.to[1]],
- })
- parents[nid] = [fakeParent]
- fakeParent = nid
- }
- }
-
- // Reorder layers to avoid crossings thanks to indexes
- this.reorderLayers(layers, parents, indexes, orientation)
- delete this._virtualLinks
-
- // Finally place everything
- if(orientation=='horizontal'){
- const fakeNodeHeight = 10
- const parentsY = {}
- const nodeY = {}
- const nodeX = {}
- let x = gapx
- for(const [idx, layer] of layers.entries()){
- let wMax = this.getMaxWidth(layer)
- let y = 0
- switch(align){
- case 'center':
- y = ((maxHeight - layerHeights[idx]) / 2) + gapy
- break
- case 'first':
- y = gapy
- break
- case 'last':
- y = maxHeight - layerHeights[idx] + gapy
- break
- case 'parent': // y will be absolutely positioned by the parent(s) but have fallback for 1st layer
- y = ((maxHeight - layerHeights[idx]) / 2) + gapy
- break
- }
- for(const nid of layer){
- let placedY
- if(!nid.startsWith('longLinkPlaceHolder_')) {
- const bb = this.stagedNodes[nid].getBoundingClientRect()
- const nodeHeight = this.stagedNodes[nid].offsetHeight || bb.height
- if((align == 'parent') && (nid in parents) && (parents[nid][0] in parentsY)) {
- y = Math.max(parentsY[parents[nid][0]], y) //TODO handle multiple parents with avg
- console.log('parent', nid, parents[nid], parentsY[parents[nid][0]])
- }
- placedY = y
- this.moveNode(nid, x, y, orientation, tween, null, token)
- if((align == 'parent') && (nid in parents) && (parents[nid][0] in parentsY)) {
- parentsY[parents[nid][0]] += gapy + nodeHeight
- } else {
- y += gapy + nodeHeight
- }
- y = Math.max(y, placedY + gapy + nodeHeight)
- } else {
- if((align == 'parent') && (nid in parents) && (parents[nid][0] in parentsY)) {
- y = Math.max(parentsY[parents[nid][0]], y)
- }
- placedY = y
- this.addFakeNode(nid, x, y, wMax*0.75, fakeNodeHeight)
- this.moveNode(nid, x, y, orientation, tween, null, 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
- }
- x += wMax + gapx
- }
- // Correct parent positions: when fake nodes pushed children down, align parents with their first real child
- if(align == 'parent'){
- for(let idx = 1; idx < layers.length; idx++){
- const layer = layers[idx]
- const prevLayer = layers[idx - 1]
- for(const pid of prevLayer){
- if(pid.startsWith('longLinkPlaceHolder_')) continue
- const firstRealChild = layer.find(nid =>
- !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)
- nodeY[pid] = nodeY[firstRealChild]
- }
- }
- }
- }
- } else if(orientation=='vertical'){
- const fakeNodeWidth = 10
- let y = gapy
- for(const [idx, layer] of layers.entries()){
- let hMax = this.getMaxHeight(layer)
- let x = 0
- switch(align){
- case 'center':
- x = ((maxWidth - layerWidths[idx]) / 2) + gapx
- break
- case 'first':
- x = gapx
- break
- case 'last':
- x = maxWidth - layerWidths[idx] + gapx
- break
- case 'parent': // x will be absolutely positioned by the parent(s)
- //TODO
- break
- }
- for(const nid of layer){
- if(!nid.startsWith('longLinkPlaceHolder_')){
- const bb = this.stagedNodes[nid].getBoundingClientRect()
- this.moveNode(nid, x, y, orientation, tween, null, 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)
- x += gapx + fakeNodeWidth
- }
- }
- y += hMax + gapy
- }
- }
- }
-
- clearFakeNodes(){
- for(const nid of Object.keys(this.stagedNodes || {})){
- if(!nid.startsWith('longLinkPlaceHolder_')) continue
- const el = this.stagedNodes[nid]
- if(el?.parentNode) el.parentNode.removeChild(el)
- delete this.stagedNodes[nid]
- }
- }
-
- getMaxWidth(layer){
- return(layer.filter(nid =>
- !nid.startsWith('longLinkPlaceHolder_'))
- // Use offsetWidth (not impacted by CSS transforms) to keep autoPlace stable during zoom animations.
- .map(nid => (this.stagedNodes[nid].offsetWidth || this.stagedNodes[nid].getBoundingClientRect().width))
- .reduce((a, b) => a > b ? a : b, 0)
- )
- }
-
- getMaxHeight(layer){
- return(layer.filter(nid =>
- !nid.startsWith('longLinkPlaceHolder_'))
- // Use offsetHeight (not impacted by CSS transforms) to keep autoPlace stable during zoom animations.
- .map(nid => (this.stagedNodes[nid].offsetHeight || this.stagedNodes[nid].getBoundingClientRect().height))
- .reduce((a, b) => a > b ? a : b, 0)
- )
- }
-
- computePortOffsets(nid, orientation = 'horizontal'){
- const node = this.stagedNodes[nid]
- if(!node || !node.ports) return({})
- const nodeRect = node.getBoundingClientRect()
- const ports = Object.entries(node.ports)
- .map(([pid, p]) => {
- const r = p.el.getBoundingClientRect()
- const pos = (orientation == 'vertical')
- ? (r.left + (r.width / 2) - nodeRect.left)
- : (r.top + (r.height / 2) - nodeRect.top)
- return({ pid, pos })
- })
- .sort((a, b) => a.pos - b.pos) // smaller pos => "higher/left" => smaller offset
-
- const denom = ports.length + 1
- const offsets = {}
- for(const [rank, item] of ports.entries()){
- offsets[item.pid] = rank / denom // always < 1
- }
- return(offsets)
- }
-
- reorderLayers(layers, parents, indexes, orientation = 'horizontal'){
- const swap = (vect, todo) => {
- for(const s of todo){
- [vect[s[0]], vect[s[1]]] = [vect[s[1]], vect[s[0]]]
- }
- }
- const adjIndex = (nid, portId) => {
- const info = indexes?.[nid]
- const base = (info && info.base !== undefined) ? info.base : 0
- const off = (portId && info?.ports?.[portId] !== undefined) ? info.ports[portId] : 0
- return(base + off)
- }
- // For nodes with multiple parents in the previous layer (common for "exit" ref nodes),
- // using only the first parent can produce bad swaps. Use the average index over all
- // parents that are actually in the previous layer.
- const avgParentIndex = (nid, prevLayer) => {
- const ps = (parents?.[nid] || []).filter(p => prevLayer.includes(p))
- if(ps.length === 0) return(adjIndex(nid))
- const sum = ps.reduce((acc, p) => {
- const lnk = this.getLink(p, nid)
- return(acc + adjIndex(p, lnk?.from?.[1]))
- }, 0)
- return(sum / ps.length)
- }
- const avgChildIndex = (nid, prevLayer) => {
- const ps = (parents?.[nid] || []).filter(p => prevLayer.includes(p))
- if(ps.length === 0) return(adjIndex(nid))
- const sum = ps.reduce((acc, p) => {
- const lnk = this.getLink(p, nid)
- return(acc + adjIndex(nid, lnk?.to?.[1]))
- }, 0)
- return(sum / ps.length)
- }
- for(const [lidx, layer] of layers.entries()){
- if(lidx==0) continue
- const prevLayer = layers[lidx-1]
-
- // Single-pass swapping is very sensitive to insertion order (especially with long-link
- // placeholders and reference nodes). Do a few relaxation passes until stable.
- const maxPasses = 8
- for(let pass = 0; pass < maxPasses; pass++){
- const toSwap = []
- for(let i=0; i (id.startsWith(nid+'_')||id.endsWith('_'+nid)))
- .map(id => this.stagedWires[id])
- for(const wire of wires){
- const [nid1, nid2] = wire.dataset.id.split('_')
- const lnk = this.getLink(nid1, nid2)
- if(!lnk) continue
- if(!this.flow?.longLinks) this.flow.longLinks = []
- const longLink = this.flow.longLinks.find(item => (item.link.from[0] == lnk.from[0] && item.link.from[1] == lnk.from[1] && item.link.to[0] == lnk.to[0] && item.link.to[1] == lnk.to[1]))
- if(longLink && LondLinkfix) {
- const path = this.linkInterNodes(nid1, lnk.from[1], nid2, lnk.to[1], longLink.interNodes, orientation)
- wire.setAttribute('d', path)
- } else {
- const path = this.linkNodes(nid1, lnk.from[1], nid2, lnk.to[1])
- wire.setAttribute('d', path)
- }
- }
- }
-
- getLink(nid1, nid2){
- let lnk = null
- lnk = this.flow.links.find(item => ((item.from[0]==nid1) && (item.to[0]==nid2)))
- if(!lnk) {
- lnk = this._virtualLinks?.get(`${nid1}__${nid2}`)
- }
- return(lnk)
- }
-
- buildGraphStructures(nodes, links, includeLinkIndexes = false) {
- const parents = {}
- const adj = {}
- nodes.forEach(n => {
- parents[n.id] = []
- adj[n.id] = []
- })
-
- links.forEach((link, idx) => {
- const from = link.from[0]
- const to = link.to[0]
- if(link.from[0] !== link.to[0]) { // Skip self-loops
- parents[to].push(from)
- if(includeLinkIndexes) {
- adj[from].push({ to, linkIdx: idx })
- } else {
- adj[from].push(to)
- }
- }
- })
- return({ parents, adj })
- }
-
- computeLayers(nodes, parents) {
- const layer = {}
- const dfs = (id) => {
- if(layer[id] !== undefined) return(layer[id])
-
- if(parents[id].length === 0) {
- layer[id] = 0
- } else {
- layer[id] = 1 + Math.max(...parents[id].map(dfs))
- }
- return(layer[id])
- }
- // Compute all layer indices
- nodes.forEach(n => dfs(n.id))
-
- // Build layers in the same order as `nodes` to keep results stable when nodes are appended
- // programmatically (e.g. reference nodes). Avoid relying on object key enumeration order.
- const t = []
- nodes.forEach(n => {
- const l = layer[n.id]
- if(l === undefined) return
- if(!t[l]) t[l] = []
- t[l].push(n.id)
- })
- return(t)
- }
-
- hasAnyLoop(nodes, links) {
- if(links.some(l => l.from[0] === l.to[0])) return(true) // self-loops
-
- const { adj } = this.buildGraphStructures(nodes, links)
- const visiting = new Set()
- const visited = new Set()
- const dfs = (nid) => {
- if(visiting.has(nid)) {
- return(true)
- }
-
- if(visited.has(nid)) return(false)
- visiting.add(nid)
- for(const m of adj[nid]) {
- if(dfs(m)) {
- return(true)
- }
- }
- visiting.delete(nid)
- visited.add(nid)
- return(false)
- }
- return(nodes.map(n => n.id).some(dfs))
- }
-
- findBackEdges(nodes, links) {
- const { adj } = this.buildGraphStructures(nodes, links, true)
- const color = {}
- nodes.forEach(n => color[n.id] = 'white')
- const backEdges = []
-
- function dfs(u) {
- color[u] = 'gray'
- for (const neighbor of adj[u]) {
- const v = neighbor.to
- const linkIdx = neighbor.linkIdx
- if(color[v] === 'gray') {
- backEdges.push(linkIdx) // 👈 cycle edge - return link index
- } else if(color[v] === 'white') {
- dfs(v)
- }
- }
- color[u] = 'black'
- }
-
- nodes.forEach(n => {
- if(color[n.id] === 'white') dfs(n.id)
- })
-
- return(backEdges)
- }
-
- 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]))
- } else {
- linksWithoutBackEdges = this.flow.links
- }
- /// Yes that means we ignore long & back links !
- const { parents } = this.buildGraphStructures(this.flow.nodes, linksWithoutBackEdges)
- const layers = this.computeLayers(this.flow.nodes, parents)
- const crossLayerLinks = []
- for(const link of links){
- const from = link.from[0]
- const to = link.to[0]
- const idx1 = layers.findIndex(layer => layer.includes(from))
- const idx2 = layers.findIndex(layer => layer.includes(to))
- if(Math.abs(idx1-idx2)>1) {
- const lowerIdx = idx1idx2 ? idx1 : idx2
- crossLayerLinks.push({
- link,
- linkIdx: link.linkIdx,
- interNodes: [],
- skippedLayers: Array.from({ length: higherIdx - lowerIdx - 1 }, (_, i) => lowerIdx + i + 1),
- })
- }
- }
- return(crossLayerLinks)
- }
-
- autofit(){
- 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})`
- }
-}
-Buildoz.define('graflow', BZgraflow)
-
-class MovingNodes{
- constructor(graflow){
- this.graflow = graflow
- this.nodesContainer = this.graflow.mainContainer.querySelector('.bzgf-nodes-container')
- this.state = null
-
- this.interactiveElementsSelector = `
- input,
- textarea,
- select,
- button,
- a[href]
- `
- }
-
- enableMovingNodes(itemSelector, handleSelector = itemSelector) {
- this.itemSelector = itemSelector
- this.handleSelector = handleSelector
- if(!this._handleCursorStyle){
- const style = document.createElement('style')
- style.textContent = `${handleSelector}{ cursor: move }`
- this.nodesContainer.appendChild(style)
- this._handleCursorStyle = style
- }
-
- this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item =>
- item.addEventListener('pointerdown', this.pointerDown.bind(this))
- )
- this.nodesContainer.addEventListener('pointermove', this.pointerMove.bind(this))
- this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item =>
- item.addEventListener('pointerup', this.pointerUp.bind(this))
- )
- }
-
- disableMovingNodes(){
- this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item =>
- item.removeEventListener('pointerdown', this.pointerDown.bind(this))
- )
- this.nodesContainer.removeEventListener('pointermove', this.pointerMove.bind(this))
- this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item =>
- item.removeEventListener('pointerup', this.pointerUp.bind(this))
- )
- }
-
- pointerDown(e){
- this.graflow.clearFakeNodes()
- console.log('=====> interactive element', e.target)
-
- const node = e.target.closest(this.itemSelector)
- if(!node) return
-
- let handle
- if(this.handleSelector == this.itemSelector) {
- handle = node
- if(e.target.closest(this.interactiveElementsSelector)) return
- e.preventDefault()
- } else { // If defined handle, then no need to care about interactive elements
- handle = node.querySelector(this.handleSelector)
- if(e.target != handle) return
- }
-
-
-
- const rect = node.getBoundingClientRect()
-
- this.state = {
- node,
- handle,
- startX: e.clientX,
- startY: e.clientY,
- offsetX: rect.left,
- offsetY: rect.top
- }
- const x = e.clientX - this.state.startX + this.state.offsetX
- const y = e.clientY - this.state.startY + this.state.offsetY
- node.setPointerCapture(e.pointerId)
- node.style.position = 'absolute'
- node.style.left = `${x}px`
- node.style.top = `${y}px`
- node.style.margin = '0'
- node.style.zIndex = '9999'
- node.style.pointerEvents = 'none'
- }
-
- pointerMove(e){
- if(!this.state) return
- const { node, startX, startY, offsetX, offsetY } = this.state
- const x = e.clientX - startX + offsetX
- const y = e.clientY - startY + offsetY
- node.style.left = `${x}px`
- node.style.top = `${y}px`
- this.graflow.updateWires(node.dataset.id, this.graflow.currentOrientation, false)
- }
-
- pointerUp(e){
- if(!this.state) return
- this.state.node.releasePointerCapture(e.pointerId)
- this.state.node.style.pointerEvents = ''
- this.state = null
- }
-}
-
-class EditWires{
- constructor(graflow){
- this.graflow = graflow
- this.nodesContainer = this.graflow.mainContainer.querySelector('.bzgf-nodes-container')
- this.state = null
- }
- enableEditWires(){
- for(const ref in this.graflow.stagedWires ){
- this.graflow.stagedWires[ref].addEventListener('click', this.onSelectWire.bind(this))
- }
- }
- onSelectWire(e){
- const wire = e.target
- console.log('wire', wire)
- }
-}
-
-class DroppingNodes{
- constructor(graflow){
- this.graflow = graflow
- this.nodesContainer = this.graflow.mainContainer.querySelector('.bzgf-nodes-container')
- this.state = null
- }
-
-}
\ No newline at end of file