diff --git a/bzGraflow.js b/bzGraflow.js index 843d77e..73f6e2b 100644 --- a/bzGraflow.js +++ b/bzGraflow.js @@ -18,6 +18,11 @@ class BZgraflow extends Buildoz{ 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(){ @@ -28,6 +33,10 @@ class BZgraflow extends Buildoz{ this.arrowDefs = null } + addIcon(el, name) { + el.innerHTML = `` + } + connectedCallback() { super.connectedCallback() const flowUrl = this.getBZAttribute('flow') @@ -45,7 +54,7 @@ class BZgraflow extends Buildoz{ const style = document.createElement('style') //TODO kick this wart somewhere under a carpet style.textContent = ` - @import '/app/assets/styles/icons.css'; + /*@import '/app/assets/styles/icons.css';*/ .bzgf-wires-container, .bzgf-nodes-container{ position: absolute; inset: 0; width: 100%; height: 100%; } .bzgf-nodes-container .bzgf-node{ position:absolute; } @@ -57,13 +66,20 @@ class BZgraflow extends Buildoz{ border-style: none; } .bzgf-nodes-container button.bzgf-zoom-in{ - width: 2em; - height: 2em; z-index: 999; position: absolute; - top: -1em; + top: -0.5em; 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) @@ -102,6 +118,11 @@ 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, + })) } async loadNodes(url) { @@ -135,6 +156,11 @@ class BZgraflow extends Buildoz{ // In isolated (shadow DOM) mode, styles must be injected per instance. if(!isIsolated) BZgraflow._loadedNodeStyles.add(url) } + this.dispatchEvent(new CustomEvent('nodesLoaded', { + detail: { url }, + bubbles: true, + composed: true, + })) } 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 }]))) if(node.subflow) { 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', () => { - this.EnterSubflow(id) + 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){ + enterSubflow(id){ const nodeEl = this.stagedNodes[id] if(!nodeEl) return @@ -178,14 +208,45 @@ class BZgraflow extends Buildoz{ const childEl = document.createElement('bz-graflow') 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 // Match the clicked node's border so the transition feels like we're "expanding" it. const nodeStyle = getComputedStyle(nodeEl) childEl.style.border = nodeStyle.border childEl.style.borderRadius = nodeStyle.borderRadius 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) - 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 this.hostContainer.style.opacity = '1' @@ -220,10 +281,15 @@ class BZgraflow extends Buildoz{ childEl.addEventListener('transitionend', (e) => { if(e.propertyName !== 'transform') return this.hostContainer.style.visibility = 'hidden' + this.dispatchEvent(new CustomEvent('subflowLoaded', { + detail: { flowUrl }, + bubbles: true, + composed: true, + })) }, { once:true }) } - Invade(oldEl, newEl){ + invade(oldEl, newEl){ const r = oldEl.getBoundingClientRect() newEl.style.position = 'fixed' newEl.style.left = r.left + 'px' @@ -234,14 +300,58 @@ class BZgraflow extends Buildoz{ oldEl.appendChild(newEl) } - Evade(oldEl, newEl){ - oldEl.style.visibility = 'visible' - oldEl.style.position = 'absolute' - oldEl.style.left = '0' - oldEl.style.top = '0' - oldEl.style.width = '0' - oldEl.style.height = '0' - newEl.parentNode.removeChild(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 + 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){ @@ -269,10 +379,12 @@ class BZgraflow extends Buildoz{ 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 = '' @@ -670,15 +782,14 @@ class BZgraflow extends Buildoz{ } } } - - getLink(nid1, nid2){ - const real = this.flow.links.find(item => ((item.from[0]==nid1) && (item.to[0]==nid2))) - if(real) return(real) - const v = this._virtualLinks?.get(`${nid1}__${nid2}`) - if(v) return(v) - return(null) + 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) {