|
|
|
@@ -30,6 +30,7 @@ class BZgraflow extends Buildoz{
|
|
|
|
|
this.stagedNodes = { }
|
|
|
|
|
this.stagedWires = { }
|
|
|
|
|
this.arrowDefs = null
|
|
|
|
|
this.arrowMarkerId = `arrow-${crypto.randomUUID()}`
|
|
|
|
|
this.currentOrientation = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -98,6 +99,28 @@ class BZgraflow extends Buildoz{
|
|
|
|
|
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.querySelector('.graflow-error')?.remove()
|
|
|
|
|
const errorEl = document.createElement('div')
|
|
|
|
@@ -132,7 +155,7 @@ class BZgraflow extends Buildoz{
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
} else if(typeof source == 'object') {
|
|
|
|
|
flowObj = source
|
|
|
|
|
flowObj = structuredClone(source)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if(!flowObj.nodesFile){
|
|
|
|
@@ -142,7 +165,10 @@ class BZgraflow extends Buildoz{
|
|
|
|
|
await this.loadNodes(flowObj.nodesFile)
|
|
|
|
|
this.flow = flowObj.flow
|
|
|
|
|
this.refresh()
|
|
|
|
|
this.fireEvent('flowLoaded', { url: source instanceof Blob ? null : source, blob: source instanceof Blob ? source : null })
|
|
|
|
|
this.fireEvent('flowLoaded', {
|
|
|
|
|
parentNodeId: null,
|
|
|
|
|
component: this,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
initFlow(){
|
|
|
|
@@ -159,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')
|
|
|
|
@@ -214,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)) {
|
|
|
|
@@ -223,6 +252,7 @@ class BZgraflow extends Buildoz{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enterSubflow(id){
|
|
|
|
|
if(this.disabled || this.hasAttribute('disabled')) return
|
|
|
|
|
const nodeEl = this.stagedNodes[id]
|
|
|
|
|
if(!nodeEl) return
|
|
|
|
|
|
|
|
|
@@ -230,26 +260,15 @@ 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('bz:graflow:flowLoaded', (e) => {
|
|
|
|
|
for(const portLink of flowNode.subflow.portLinks){
|
|
|
|
@@ -257,7 +276,7 @@ class BZgraflow extends Buildoz{
|
|
|
|
|
childEl.addNode({
|
|
|
|
|
"nodeType": portLink.refNodeType,
|
|
|
|
|
"id": nid,
|
|
|
|
|
"markup": { "parentport": portLink.parentPort }
|
|
|
|
|
"markup": { ...portLink }
|
|
|
|
|
})
|
|
|
|
|
if(portLink.direction=='in') {
|
|
|
|
|
childEl.addWire({
|
|
|
|
@@ -271,10 +290,29 @@ class BZgraflow extends Buildoz{
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
childEl.autoPlace(this.currentOrientation, parseInt(this.getBZAttribute('gapx')) || 80, parseInt(this.getBZAttribute('gapy')) || 80)
|
|
|
|
|
// 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'
|
|
|
|
@@ -299,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;
|
|
|
|
@@ -316,7 +354,10 @@ class BZgraflow extends Buildoz{
|
|
|
|
|
childEl.style.transform = 'none' // Important for nested subflows to position correctly
|
|
|
|
|
childEl.style.willChange = ''
|
|
|
|
|
childEl.style.overflow = 'auto'
|
|
|
|
|
this.fireEvent('subflowLoaded', { subflow: childEl })
|
|
|
|
|
this.fireEvent('subflowLoaded', {
|
|
|
|
|
parentNodeId: id,
|
|
|
|
|
component: childEl
|
|
|
|
|
})
|
|
|
|
|
}, { once:true })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -391,7 +432,9 @@ class BZgraflow extends Buildoz{
|
|
|
|
|
this.hostContainer.style.opacity = '1'
|
|
|
|
|
this.hostContainer.style.visibility = 'visible'
|
|
|
|
|
childEl.style.willChange = ''
|
|
|
|
|
this.fireEvent('subflowExited', { subflow: childEl })
|
|
|
|
|
this.fireEvent('subflowExited', {
|
|
|
|
|
component: this
|
|
|
|
|
})
|
|
|
|
|
}, { once:true })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -407,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
|
|
|
|
@@ -415,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
|
|
|
|
@@ -460,6 +507,13 @@ class BZgraflow extends Buildoz{
|
|
|
|
|
}
|
|
|
|
|
if(forceAutoplace) this.autoPlace(this.currentOrientation, parseInt(this.getBZAttribute('gapx')) || 80, parseInt(this.getBZAttribute('gapy')) || 80)
|
|
|
|
|
this.fireEvent('refreshed', { })
|
|
|
|
|
if(this.hasAttribute('autofit')){
|
|
|
|
|
const autofitAttr = this.getAttribute('autofit')
|
|
|
|
|
const autofitPercent = (autofitAttr !== null && autofitAttr !== '' && !Number.isNaN(parseFloat(autofitAttr)))
|
|
|
|
|
? parseFloat(autofitAttr)
|
|
|
|
|
: undefined
|
|
|
|
|
this.autofit(autofitPercent)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert viewport (client) coordinates to this instance's SVG local coordinates.
|
|
|
|
@@ -649,7 +703,8 @@ class BZgraflow extends Buildoz{
|
|
|
|
|
return(path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
autoPlace(orientation = 'horizontal', gapx = null, gapy = null, tween = null, align = null){
|
|
|
|
|
autoPlace(orientation = null, gapx = null, gapy = null, tween = null, align = 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
|
|
|
|
@@ -1011,10 +1066,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 = []
|
|
|
|
@@ -1031,7 +1090,7 @@ class BZgraflow extends Buildoz{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
@@ -1170,15 +1229,50 @@ class BZgraflow extends Buildoz{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
autofit(percent=100){
|
|
|
|
|
if(!this.parentElement) return
|
|
|
|
|
|
|
|
|
|
const prevTransformOrigin = this.style.transformOrigin
|
|
|
|
|
this.style.transform = 'none'
|
|
|
|
|
this.style.transformOrigin = 'top left'
|
|
|
|
|
|
|
|
|
|
// Measure real content by unioning viewport-space bounding boxes.
|
|
|
|
|
// This is robust with overflow:auto and absolute-positioned layers.
|
|
|
|
|
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 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 gapx = parseInt(this.getBZAttribute('gapx')) || 80
|
|
|
|
|
const gapy = parseInt(this.getBZAttribute('gapy')) || 80
|
|
|
|
|
const rawW = Number.isFinite(left) && Number.isFinite(right) ? Math.max(right - left, 1) : Math.max(this.mainContainer?.clientWidth || this.offsetWidth || 1, 1)
|
|
|
|
|
const rawH = Number.isFinite(top) && Number.isFinite(bottom) ? Math.max(bottom - top, 1) : Math.max(this.mainContainer?.clientHeight || this.offsetHeight || 1, 1)
|
|
|
|
|
const contentW = rawW + (2 * gapx)
|
|
|
|
|
const contentH = rawH + (2 * gapy)
|
|
|
|
|
const sx = parentBB.width / contentW
|
|
|
|
|
const sy = parentBB.height / contentH
|
|
|
|
|
const scale = Math.min(sx, sy)*(percent/100) // uniform scale to fit inside parent
|
|
|
|
|
this.style.transformOrigin = 'top left'
|
|
|
|
|
this.style.transform = `scale(${scale})`
|
|
|
|
|
const tx = Number.isFinite(left) ? (-left + gapx) : gapx
|
|
|
|
|
const ty = Number.isFinite(top) ? (-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)
|
|
|
|
@@ -1435,7 +1529,7 @@ class EditWires{
|
|
|
|
|
return('')
|
|
|
|
|
}
|
|
|
|
|
this.graflow.addWire({ from: [idNode1, idPort1], to: [idNode2, idPort2] })
|
|
|
|
|
this.graflow.fireEvent('wireAdded', { from: [idNode1, idPort1], to: [idNode2, idPort2], id: `${idNode1}_${idNode2}` })
|
|
|
|
|
this.graflow.fireEvent('wireAdded', { from: [idNode1, idPort1], to: [idNode2, idPort2], id: this.graflow.makeWireId(idNode1, idNode2) })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onSelectWire(e){
|
|
|
|
@@ -1454,7 +1548,7 @@ class EditWires{
|
|
|
|
|
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 : (link.from[0] + '_' + link.to[0] !== wireId)
|
|
|
|
|
linkToRemove ? link !== linkToRemove : (this.graflow.makeWireId(link.from[0], link.to[0]) !== wireId)
|
|
|
|
|
)
|
|
|
|
|
this.graflow.stagedWires[wireId]?.remove()
|
|
|
|
|
delete this.graflow.stagedWires[wireId]
|
|
|
|
|