graflow: subflow reference nodes & exit subflow

This commit is contained in:
STEINNI
2026-02-25 18:25:33 +00:00
parent fbea601183
commit c3e87e28b4

View File

@@ -18,6 +18,11 @@ class BZgraflow extends Buildoz{
e: { x: 1, y: 0 }, e: { x: 1, y: 0 },
w: { 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 ! static _loadedNodeStyles = new Set() // Allow multi instances or re-loadNodes, but avoid reinjecting same styles !
constructor(){ constructor(){
@@ -28,6 +33,10 @@ class BZgraflow extends Buildoz{
this.arrowDefs = null this.arrowDefs = null
} }
addIcon(el, name) {
el.innerHTML = `<svg viewBox="0 0 1024 1024" width="20" height="20" fill="#000000"><g transform="rotate(90 512 512)"><path d="${this.btnIcons[name]}"/></g></svg>`
}
connectedCallback() { connectedCallback() {
super.connectedCallback() super.connectedCallback()
const flowUrl = this.getBZAttribute('flow') const flowUrl = this.getBZAttribute('flow')
@@ -45,7 +54,7 @@ class BZgraflow extends Buildoz{
const style = document.createElement('style') const style = document.createElement('style')
//TODO kick this wart somewhere under a carpet //TODO kick this wart somewhere under a carpet
style.textContent = ` style.textContent = `
@import '/app/assets/styles/icons.css'; /*@import '/app/assets/styles/icons.css';*/
.bzgf-wires-container, .bzgf-wires-container,
.bzgf-nodes-container{ position: absolute; inset: 0; width: 100%; height: 100%; } .bzgf-nodes-container{ position: absolute; inset: 0; width: 100%; height: 100%; }
.bzgf-nodes-container .bzgf-node{ position:absolute; } .bzgf-nodes-container .bzgf-node{ position:absolute; }
@@ -57,13 +66,20 @@ class BZgraflow extends Buildoz{
border-style: none; border-style: none;
} }
.bzgf-nodes-container button.bzgf-zoom-in{ .bzgf-nodes-container button.bzgf-zoom-in{
width: 2em;
height: 2em;
z-index: 999; z-index: 999;
position: absolute; position: absolute;
top: -1em; top: -0.5em;
right: -1em; right: -1em;
color: #A00; color: black;
width: 2em;
height: 2em;
padding: 0;
}
bz-graflow button.bzgf-zoom-out{
z-index: 999;
position: absolute;
left: 5px;
top: 5px;
} }
` `
this.mainContainer.appendChild(style) this.mainContainer.appendChild(style)
@@ -102,6 +118,11 @@ class BZgraflow extends Buildoz{
await this.loadNodes(flowObj.nodesFile) await this.loadNodes(flowObj.nodesFile)
this.flow = flowObj.flow this.flow = flowObj.flow
this.refresh() this.refresh()
this.dispatchEvent(new CustomEvent('flowLoaded', {
detail: { url },
bubbles: true,
composed: true,
}))
} }
async loadNodes(url) { async loadNodes(url) {
@@ -135,6 +156,11 @@ class BZgraflow extends Buildoz{
// In isolated (shadow DOM) mode, styles must be injected per instance. // In isolated (shadow DOM) mode, styles must be injected per instance.
if(!isIsolated) BZgraflow._loadedNodeStyles.add(url) if(!isIsolated) BZgraflow._loadedNodeStyles.add(url)
} }
this.dispatchEvent(new CustomEvent('nodesLoaded', {
detail: { url },
bubbles: true,
composed: true,
}))
} }
addNode(node){ addNode(node){
@@ -153,17 +179,21 @@ class BZgraflow extends Buildoz{
this.stagedNodes[id].ports = Object.fromEntries(Array.from(portEls).map(item => ([item.dataset.id, { ...item.dataset, el:item }]))) this.stagedNodes[id].ports = Object.fromEntries(Array.from(portEls).map(item => ([item.dataset.id, { ...item.dataset, el:item }])))
if(node.subflow) { if(node.subflow) {
const btnEnterSubflow = document.createElement('button') const btnEnterSubflow = document.createElement('button')
btnEnterSubflow.classList.add('bzgf-zoom-in', 'icon-copy') btnEnterSubflow.classList.add('bzgf-zoom-in')
this.addIcon(btnEnterSubflow, 'zoomin')
btnEnterSubflow.addEventListener('click', () => { btnEnterSubflow.addEventListener('click', () => {
this.EnterSubflow(id) this.enterSubflow(id)
}) })
this.stagedNodes[id].appendChild(btnEnterSubflow) this.stagedNodes[id].appendChild(btnEnterSubflow)
} }
this.nodesContainer.append(this.stagedNodes[id]) this.nodesContainer.append(this.stagedNodes[id])
if(!this.flow.nodes.find(n => n.id === id)) {
this.flow.nodes.push(node)
}
return(this.stagedNodes[id]) return(this.stagedNodes[id])
} }
EnterSubflow(id){ enterSubflow(id){
const nodeEl = this.stagedNodes[id] const nodeEl = this.stagedNodes[id]
if(!nodeEl) return if(!nodeEl) return
@@ -178,14 +208,45 @@ class BZgraflow extends Buildoz{
const childEl = document.createElement('bz-graflow') const childEl = document.createElement('bz-graflow')
childEl.setAttribute('flow', flowUrl) childEl.setAttribute('flow', flowUrl)
childEl.setAttribute('tension', this.getBZAttribute('tension') || '60') childEl.setAttribute('tension', this.getBZAttribute('tension') || '60')
// Remember which node we "came from" so exitSubflow() can animate back to it.
childEl.dataset.enterNodeId = id
// Match the clicked node's border so the transition feels like we're "expanding" it. // Match the clicked node's border so the transition feels like we're "expanding" it.
const nodeStyle = getComputedStyle(nodeEl) const nodeStyle = getComputedStyle(nodeEl)
childEl.style.border = nodeStyle.border childEl.style.border = nodeStyle.border
childEl.style.borderRadius = nodeStyle.borderRadius childEl.style.borderRadius = nodeStyle.borderRadius
childEl.style.backgroundColor = nodeStyle.backgroundColor childEl.style.backgroundColor = nodeStyle.backgroundColor
const btnExitSubflow = document.createElement('button')
btnExitSubflow.classList.add('bzgf-zoom-out')
this.addIcon(btnExitSubflow, 'zoomout')
btnExitSubflow.addEventListener('click', () => {
this.exitSubflow(childEl)
})
childEl.appendChild(btnExitSubflow)
// Put the child in the exact same viewport rect as the parent (fixed overlay) // Put the child in the exact same viewport rect as the parent (fixed overlay)
this.Invade(this, childEl) this.invade(this, childEl)
childEl.addEventListener('flowLoaded', (e) => {
for(const portLink of flowNode.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('horizontal', 60, 60)
})
// Fade out the current (host) graflow while the child scales up // Fade out the current (host) graflow while the child scales up
this.hostContainer.style.opacity = '1' this.hostContainer.style.opacity = '1'
@@ -220,10 +281,15 @@ class BZgraflow extends Buildoz{
childEl.addEventListener('transitionend', (e) => { childEl.addEventListener('transitionend', (e) => {
if(e.propertyName !== 'transform') return if(e.propertyName !== 'transform') return
this.hostContainer.style.visibility = 'hidden' this.hostContainer.style.visibility = 'hidden'
this.dispatchEvent(new CustomEvent('subflowLoaded', {
detail: { flowUrl },
bubbles: true,
composed: true,
}))
}, { once:true }) }, { once:true })
} }
Invade(oldEl, newEl){ invade(oldEl, newEl){
const r = oldEl.getBoundingClientRect() const r = oldEl.getBoundingClientRect()
newEl.style.position = 'fixed' newEl.style.position = 'fixed'
newEl.style.left = r.left + 'px' newEl.style.left = r.left + 'px'
@@ -234,14 +300,58 @@ class BZgraflow extends Buildoz{
oldEl.appendChild(newEl) oldEl.appendChild(newEl)
} }
Evade(oldEl, newEl){ exitSubflow(childEl){
oldEl.style.visibility = 'visible' if(!childEl) return
oldEl.style.position = 'absolute'
oldEl.style.left = '0' const enterNodeId = childEl.dataset?.enterNodeId
oldEl.style.top = '0' const nodeEl = enterNodeId ? this.stagedNodes?.[enterNodeId] : null
oldEl.style.width = '0' if(!nodeEl){
oldEl.style.height = '0' // Fallback: no context => just restore parent & remove child
newEl.parentNode.removeChild(newEl) 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
const ty0 = nodeBB.top - parentBB.top
// 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 should already have the transform transition set)
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'
}, { once:true })
} }
addFakeNode(nid, x, y, w, h){ addFakeNode(nid, x, y, w, h){
@@ -269,10 +379,12 @@ class BZgraflow extends Buildoz{
this.stagedWires[id].classList.add('bzgf-wire') this.stagedWires[id].classList.add('bzgf-wire')
this.stagedWires[id].dataset.id = id this.stagedWires[id].dataset.id = id
this.wiresContainer.append(this.stagedWires[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]) return(this.stagedWires[id])
} }
clear(){ clear(){
this.nodesContainer.innerHTML = '' this.nodesContainer.innerHTML = ''
this.wiresContainer.innerHTML = '' this.wiresContainer.innerHTML = ''
@@ -670,15 +782,14 @@ class BZgraflow extends Buildoz{
} }
} }
} }
getLink(nid1, nid2){ getLink(nid1, nid2){
const real = this.flow.links.find(item => ((item.from[0]==nid1) && (item.to[0]==nid2))) let lnk = null
if(real) return(real) lnk = this.flow.links.find(item => ((item.from[0]==nid1) && (item.to[0]==nid2)))
const v = this._virtualLinks?.get(`${nid1}__${nid2}`) if(!lnk) {
if(v) return(v) lnk = this._virtualLinks?.get(`${nid1}__${nid2}`)
return(null) }
return(lnk)
} }
buildGraphStructures(nodes, links, includeLinkIndexes = false) { buildGraphStructures(nodes, links, includeLinkIndexes = false) {