Compare commits

..

10 Commits

Author SHA1 Message Date
STEINNI
2d3a4631c8 graflow: examples tuning, nodemove debugging 2026-03-08 13:34:10 +00:00
STEINNI
ae173f5b92 graflow: autofit OK 2026-03-07 18:36:01 +00:00
STEINNI
20523ce0aa graflow: improved subflow zoom-in 2026-03-07 15:15:07 +00:00
STEINNI
56c2052f40 graflow: improved parent align: correction on parents if fakenodes 2026-03-07 14:22:45 +00:00
STEINNI
6408d3377b graflow: align parent 2026-03-06 20:14:35 +00:00
STEINNI
bcb76e197e graflow: improved ortho walkarounds 2026-03-06 19:11:53 +00:00
STEINNI
6ffdb79001 graflow: improving ortho 2026-03-04 20:48:50 +00:00
STEINNI
fc16a1840f graflow: test for all directions 2026-03-02 21:27:33 +00:00
STEINNI
4b107e772c graflow: refacto & facto of buildSegment 2026-03-02 20:45:55 +00:00
STEINNI
85a03f7c1d graflow: small fix for loop always bezier 2026-03-02 20:37:58 +00:00
2 changed files with 248 additions and 82 deletions

View File

@@ -206,7 +206,9 @@ bz-graflow .bzgf-main-container{
}
/* BZGRAFLOW_CORE_START */
/* bz-graflow internal layout rules (used in light DOM, and injected into shadow DOM when isolated) */
/* 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;
@@ -214,6 +216,13 @@ bz-graflow .bzgf-nodes-container{
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;

View File

@@ -85,9 +85,21 @@ class BZgraflow extends Buildoz{
this.mainContainer.append(this.nodesContainer)
this.append(this.hostContainer)
this.loadFlow(flowUrl).then(() => {
if((this.getBZAttribute('edit')=='move') || (this.getBZAttribute('edit')=='full')){
this.dnd = new MovingNodes(this)
this.dnd.enableMovingNodes('.bzgf-node')
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')
}
}
})
}
@@ -225,7 +237,7 @@ class BZgraflow extends Buildoz{
btnExitSubflow.addEventListener('click', () => {
this.exitSubflow(childEl)
})
// 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
this.invade(this, childEl)
childEl.hostContainer.appendChild(btnExitSubflow)
@@ -266,9 +278,9 @@ class BZgraflow extends Buildoz{
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.transition = 'transform 1000ms ease-in-out'
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')
@@ -277,7 +289,10 @@ 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'
requestAnimationFrame(() => {
childEl.style.top = 0;
childEl.style.left = 0;
childEl.style.setProperty('--tx', '0px')
childEl.style.setProperty('--ty', '0px')
childEl.style.setProperty('--sx', 1)
@@ -290,6 +305,7 @@ class BZgraflow extends Buildoz{
this.hostContainer.style.visibility = 'hidden'
childEl.style.transform = 'none' // Important for nested subflows to position correctly
childEl.style.willChange = ''
newEl.style.overflow = 'auto'
this.dispatchEvent(new CustomEvent('subflowLoaded', {
detail: { subflow: childEl },
bubbles: true,
@@ -299,16 +315,14 @@ class BZgraflow extends Buildoz{
}
invade(oldEl, newEl){
// Scroll-proof overlay: position inside oldEl so it follows oldEl scrolling.
// Ensure oldEl is a positioning context.
const pos = getComputedStyle(oldEl).position
if(pos === 'static') oldEl.style.position = 'relative'
newEl.style.position = 'absolute'
newEl.style.inset = '0'
// Override bz-graflow's default width/height (100vw/50vh) when used as an embedded overlay
newEl.style.width = '100%'
newEl.style.height = '100%'
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)
}
@@ -459,6 +473,60 @@ class BZgraflow extends Buildoz{
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<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 return(`V ${mediany} H ${c2x} V ${y2} H ${x2}`)
} else {
return(`V ${c1y} H ${medianx} V ${c2y} H ${x2} V ${y2}`)
}
}
else {
if( ((dir1 == 'e') && (c1x <medianx)) || ((dir1 == 'w') && (c1x > 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<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 return(`H ${medianx} V ${c2y} H ${x2} V ${y2}`)
} else {
return(`H ${c1x} V ${mediany} H ${c2x} V ${y2} H ${x2}`)
}
}
}
return('')
}
linkNodes(idNode1, idPort1, idNode2, idPort2) {
const tension = parseInt(this.getBZAttribute('tension')) || 60
const wireType = this.getBZAttribute('wiretype') || 'bezier'
@@ -494,24 +562,11 @@ class BZgraflow extends Buildoz{
c2y -= 1*tension
}
}
if(wireType === 'bezier') {
return(`M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`)
}
if(wireType === 'straight') {
return(`M ${x1} ${y1} L ${c1x} ${c1y} L ${c2x} ${c2y} L ${x2} ${y2}`)
}
if(wireType === 'ortho') {
let path = `M ${x1} ${y1} `
if(['n', 's'].includes(port1.direction)) {
path += `V ${(c1y+c2y)/2} H ${c2x} V ${y2}`
} else {
path += `H ${(c1x+c2x)/2} V ${c2y} H ${x2}`
}
return(path)
}
return('')
const seg = this.buildSegment(x1, y1, c1x, c1y, c2x, c2y, x2, y2, wireType, node1, node2, port1.direction, port2.direction, tension, loop)
if(!seg) return('')
return(`M ${x1} ${y1} ${seg}`)
}
linkInterNodes(idNode1, idPort1, idNode2, idPort2, interNodes, orientation='horizontal') {
const tension = parseInt(this.getBZAttribute('tension')) || 60
const wireType = this.getBZAttribute('wiretype') || 'bezier'
@@ -524,38 +579,12 @@ class BZgraflow extends Buildoz{
return('')
}
const makeSegment = (x1, y1, x2, y2, orientation1, orientation2) => {
let c1x, c1y, c2x, c2y
if(orientation1=='horizontal') {
c1x = Math.floor(x1 + tension)
c1y = y1
} else {
c1x = x1
c1y = Math.floor(y1 + tension)
}
if(orientation2=='horizontal') {
c2x = Math.floor(x2 - tension)
c2y = y2
} else {
c2x = x2
c2y = Math.floor(y2 - tension)
}
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') {
let path = `M ${x1} ${y1} `
if(['n', 's'].includes(port1.direction)) {
path += `V ${(c1y+c2y)/2} H ${c2x} V ${y2}`
} else {
path += `H ${(c1x+c2x)/2} V ${c2y} H ${x2}`
}
return(path)
}
return(``)
const makeSegment = (x1, y1, x2, y2, node1, node2, dir1, dir2, tension) => {
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)
@@ -569,6 +598,8 @@ class BZgraflow extends Buildoz{
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()
@@ -587,10 +618,10 @@ class BZgraflow extends Buildoz{
let y2 = Math.floor(entry.y)
if(firstPort){
path += makeSegment(x1, y1, x2, y2, ['w','e'].includes(firstPort.direction) ? 'horizontal' : 'vertical', orientation)
path += makeSegment(x1, y1, x2, y2, node1, node2, firstPort.direction, entryDir, tension)
firstPort = null
} else {
path += makeSegment(x1, y1, x2, y2, orientation, orientation)
path += makeSegment(x1, y1, x2, y2, node1, node2, exitDir, entryDir, tension)
}
const x3 = Math.floor(exit.x)
@@ -600,7 +631,7 @@ class BZgraflow extends Buildoz{
x1 = x3
y1 = y3
}
path += ' ' + makeSegment(x1, y1, xEnd, yEnd, orientation, orientation)
path += ' ' + makeSegment(x1, y1, xEnd, yEnd, node1, node2, exitDir, port2.direction, tension)
return(path)
}
@@ -610,8 +641,6 @@ class BZgraflow extends Buildoz{
if(tween == null) tween = parseInt(this.getBZAttribute('tween')) || 500
if(align == null) align = this.getBZAttribute('align') || 'center'
console.log('autoPlace', orientation, gapx, gapy, tween, align)
this.currentOrientation = orientation
// Cancel any previous autoPlace() animations by bumping a token.
// moveNode() checks this token each frame and will no-op if superseded.
@@ -711,44 +740,97 @@ class BZgraflow extends Buildoz{
// 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':
case 'center':
y = ((maxHeight - layerHeights[idx]) / 2) + gapy
break
case'first':
case 'first':
y = gapy
break
case'last':
case 'last':
y = maxHeight - layerHeights[idx] + gapy
break
case 'auto':
//TODO
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)
y += gapy + (this.stagedNodes[nid].offsetHeight || bb.height)
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)
y += gapy + fakeNodeHeight
// 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 = ((maxWidth - layerWidths[idx]) / 2) + gapx
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()
@@ -1075,6 +1157,17 @@ class BZgraflow extends Buildoz{
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)
@@ -1083,6 +1176,14 @@ class MovingNodes{
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) {
@@ -1095,15 +1196,43 @@ class MovingNodes{
this._handleCursorStyle = style
}
this.nodesContainer.addEventListener('pointerdown', this.pointerDown.bind(this))
this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item =>
item.addEventListener('pointerdown', this.pointerDown.bind(this))
)
this.nodesContainer.addEventListener('pointermove', this.pointerMove.bind(this))
this.nodesContainer.addEventListener('pointerup', this.pointerUp.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()
const node = (e.target.classList.contains(this.itemSelector)) ? e.target : e.target.closest(this.itemSelector)
const handle = (node.classList.contains(this.handleSelector)) ? node : node.querySelector(this.handleSelector)
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()
@@ -1123,6 +1252,7 @@ class MovingNodes{
node.style.top = `${y}px`
node.style.margin = '0'
node.style.zIndex = '9999'
node.style.pointerEvents = 'none'
}
pointerMove(e){
@@ -1138,6 +1268,33 @@ class MovingNodes{
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
}
}