Compare commits

...

15 Commits

Author SHA1 Message Date
STEINNI dfb1190e7b Merge branch 'main' of https://gitea.internike.com/nike/buildoz 2026-06-22 11:55:06 +00:00
STEINNI 6292a64d82 Merge branch 'main' of https://gitea.internike.com/nike/buildoz 2026-06-22 10:12:40 +00:00
STEINNI e397ee3b2d Merge branch 'main' of https://gitea.internike.com/nike/buildoz 2026-06-22 10:11:28 +00:00
STEINNI 9690401dad Merge branch 'main' of https://gitea.internike.com/nike/buildoz 2026-06-05 15:16:04 +00:00
STEINNI 63f894f526 portal trick on bz-select options 2026-06-05 15:16:01 +00:00
STEINNI 345be1aa23 Merge branch 'main' of https://gitea.internike.com/nike/buildoz 2026-06-04 11:27:24 +00:00
STEINNI e14b0c0214 Merge branch 'main' of https://gitea.internike.com/nike/buildoz 2026-06-04 11:26:51 +00:00
STEINNI d073a2e804 better layout complete events 2026-06-04 11:22:39 +00:00
STEINNI 8f41a39a6e Merge branch 'main' of https://gitea.internike.com/nike/buildoz 2026-05-22 09:17:50 +00:00
STEINNI df6ba88040 Merge branch 'main' of https://gitea.internike.com/nike/buildoz 2026-05-22 09:16:32 +00:00
STEINNI 7f4e13c5e0 Merge branch 'main' of https://gitea.internike.com/nike/buildoz 2026-03-11 22:07:45 +00:00
STEINNI f39f27efee Merge branch 'main' of https://gitea.internike.com/nike/buildoz 2026-03-10 21:18:42 +00:00
STEINNI bd43063230 Merge branch 'main' of https://gitea.internike.com/nike/buildoz 2026-03-10 21:17:25 +00:00
STEINNI 7d10993b66 Merge branch 'main' of https://gitea.internike.com/nike/buildoz 2026-03-10 21:15:02 +00:00
STEINNI fd270d27da Merge branch 'main' of https://gitea.internike.com/nike/buildoz 2026-03-10 21:09:25 +00:00
3 changed files with 205 additions and 48 deletions
+15 -3
View File
@@ -72,7 +72,17 @@ bz-select > div.options-container{
transition: max-height 0.4s ease;
}
bz-select > div.options-container.open{ pointer-events: auto; max-height: 10em;}
bz-select option{
div.options-container.portaled{
pointer-events: none;
position: fixed;
z-index: 10000;
max-height: 0;
overflow: auto;
transition: max-height 0.4s ease;
}
div.options-container.portaled.open{ pointer-events: auto; max-height: 10em;}
bz-select option,
div.options-container option{
background-color: #DDD;
border: 1px solid black;
color: #000;
@@ -88,12 +98,14 @@ bz-select option{
margin-top 0.3s ease,
opacity 0.3s ease;
}
bz-select option.open{
bz-select option.open,
div.options-container option.open{
margin: 0;
opacity: 1;
pointer-events: auto;
}
bz-select option:hover{
bz-select option:hover,
div.options-container option:hover{
background-color: #44F;
color: #FFF;
}
+109 -15
View File
@@ -135,7 +135,7 @@ class BZselect extends Buildoz {
if(!this.optionscontainer) this.optionscontainer = document.createElement('div')
this.optionscontainer.classList.add('options-container')
this.append(this.optionscontainer)
this.options = this.querySelectorAll('option')
this.syncOptions()
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
@@ -146,6 +146,15 @@ class BZselect extends Buildoz {
}
}
disconnectedCallback() {
if(this.open || this._closing) this.closeDropdown(true)
}
syncOptions() {
if(this.optionscontainer) this.options = this.optionscontainer.querySelectorAll('option')
else this.options = []
}
// static get observedAttributes(){ // Only if you want actions on attr change
// return([...super.observedAttributes, 'disabled'])
@@ -172,19 +181,104 @@ class BZselect extends Buildoz {
}
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')
if(this.open) this.closeDropdown()
else this.openDropdown()
}
openDropdown() {
document.querySelectorAll('bz-select').forEach((sel) => {
if((sel!==this) && sel.open) sel.closeDropdown(true)
})
this.optionscontainer.classList.add('portaled')
document.body.appendChild(this.optionscontainer)
this.positionPortal()
this.bindPortalListeners()
this.open = true
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if(!this.open) return
this.optionscontainer.classList.add('open')
for(const opt of this.options) opt.classList.add('open')
})
})
}
closeDropdown(immediate = false) {
if(!this.open) return
this.unbindPortalListeners()
for(const opt of this.options) opt.classList.remove('open')
this.optionscontainer.classList.remove('open')
if(immediate) {
this.clearCloseTransition()
this.finishCloseDropdown()
return
}
this._closing = true
this._onTransitionEnd = (evt) => {
if(evt.target !== this.optionscontainer) return
if(evt.propertyName !== 'max-height') return
this.finishCloseDropdownAfterTransition()
}
this.optionscontainer.addEventListener('transitionend', this._onTransitionEnd)
this._closeTimer = setTimeout(() => this.finishCloseDropdownAfterTransition(), 450)
}
finishCloseDropdownAfterTransition() {
if(!this._closing) return
this.clearCloseTransition()
this.finishCloseDropdown()
}
clearCloseTransition() {
this._closing = false
if(this._closeTimer) clearTimeout(this._closeTimer)
this._closeTimer = null
if(this._onTransitionEnd) {
this.optionscontainer.removeEventListener('transitionend', this._onTransitionEnd)
this._onTransitionEnd = null
}
}
finishCloseDropdown() {
this.optionscontainer.classList.remove('portaled')
this.clearPortalStyles()
if(this.optionscontainer.parentElement !== this) this.append(this.optionscontainer)
this.open = false
}
positionPortal() {
const rect = this.button.getBoundingClientRect()
this.optionscontainer.style.top = `${rect.bottom}px`
this.optionscontainer.style.left = `${rect.left}px`
this.optionscontainer.style.width = `${rect.width}px`
}
clearPortalStyles() {
this.optionscontainer.style.top = ''
this.optionscontainer.style.left = ''
this.optionscontainer.style.width = ''
}
bindPortalListeners() {
this._repositionBound = this.positionPortal.bind(this)
window.addEventListener('scroll', this._repositionBound, true)
window.addEventListener('resize', this._repositionBound)
this._outsideClickBound = (evt) => {
if(this.open && !this.contains(evt.target) && !this.optionscontainer.contains(evt.target)) {
this.closeDropdown()
}
}
this.open = !this.open
this._outsideClickTimer = setTimeout(() => {
document.addEventListener('click', this._outsideClickBound, true)
}, 0)
}
unbindPortalListeners() {
if(this._repositionBound) window.removeEventListener('scroll', this._repositionBound, true)
if(this._repositionBound) window.removeEventListener('resize', this._repositionBound)
if(this._outsideClickBound) document.removeEventListener('click', this._outsideClickBound, true)
if(this._outsideClickTimer) clearTimeout(this._outsideClickTimer)
this._outsideClickTimer = null
}
onClick(evt){
@@ -196,7 +290,7 @@ class BZselect extends Buildoz {
onOption(value, silent=false){
if(this.getAttribute('disabled') !== null) return
this.value = value
if(!silent) this.toggle()
if(!silent && this.open) this.closeDropdown()
}
addOption(value, markup){
@@ -207,16 +301,16 @@ class BZselect extends Buildoz {
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.syncOptions()
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.syncOptions()
this.options.forEach(node => { node.remove() })
this.options = this.querySelectorAll('option')
this.syncOptions()
this.onOption('', true) // unselect last
}
for(const opt of opts) this.addOption(opt.value, opt.markup)
+81 -30
View File
@@ -518,8 +518,12 @@ class BZgraflow extends Buildoz{
else this._scheduleLayoutWhenReady(() => this.autofit(autofitPercent))
}
}
if(forceAutoplace) this._scheduleAutoPlaceWhenReady(this.currentOrientation, gapx, gapy, finishRefresh)
else finishRefresh()
const onLayoutComplete = () => {
this.fireEvent('layoutComplete', { })
finishRefresh()
}
if(forceAutoplace) this._scheduleAutoPlaceWhenReady(this.currentOrientation, gapx, gapy, onLayoutComplete)
else onLayoutComplete()
}
disconnectedCallback(){
@@ -580,13 +584,20 @@ class BZgraflow extends Buildoz{
requestAnimationFrame(rafPoll)
}
_scheduleAutoPlaceWhenReady(orientation, gapx, gapy, onDone){
_scheduleAutoPlaceWhenReady(orientation, gapx, gapy, onLayoutComplete){
this._scheduleLayoutWhenReady(() => {
this.autoPlace(orientation, gapx, gapy)
if(typeof onDone === 'function') onDone()
this.autoPlace(orientation, gapx, gapy, null, null, onLayoutComplete)
})
}
_maybeFireLayoutComplete(){
if(this._layoutMovePending > 0) return
if(!this._layoutCompleteHandler) return
const fn = this._layoutCompleteHandler
this._layoutCompleteHandler = null
fn()
}
// 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.
@@ -774,7 +785,7 @@ class BZgraflow extends Buildoz{
return(path)
}
autoPlace(orientation = null, gapx = null, gapy = null, tween = null, align = null){
autoPlace(orientation = null, gapx = null, gapy = null, tween = null, align = null, onLayoutComplete = 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
@@ -786,6 +797,9 @@ class BZgraflow extends Buildoz{
// moveNode() checks this token each frame and will no-op if superseded.
this._autoPlaceToken = (this._autoPlaceToken || 0) + 1
const token = this._autoPlaceToken
this._layoutMovePending = 0
this._layoutCompleteToken = token
this._layoutCompleteHandler = (typeof onLayoutComplete === 'function') ? onLayoutComplete : null
// Cleanup placeholders from previous autoPlace() runs.
// Each run creates new longLinkPlaceHolder_* IDs; without cleanup they accumulate in the DOM.
@@ -909,7 +923,7 @@ class BZgraflow extends Buildoz{
y = Math.max(parentsY[parents[nid][0]], y) //TODO handle multiple parents with avg
}
placedY = y
this.moveNode(nid, x, y, orientation, tween, null, token)
this.moveNode(nid, x, y, orientation, tween, token)
if((align == 'parent') && (nid in parents) && (parents[nid][0] in parentsY)) {
parentsY[parents[nid][0]] += gapy + nodeHeight
} else {
@@ -922,7 +936,7 @@ class BZgraflow extends Buildoz{
}
placedY = y
this.addFakeNode(nid, x, y, wMax*0.75, fakeNodeHeight)
this.moveNode(nid, x, y, orientation, tween, null, token)
this.moveNode(nid, x, y, orientation, tween, token)
// Never increment parentsY for fake nodes: they're placeholders and must not disalign real children
y = Math.max(y, placedY + gapy + fakeNodeHeight)
}
@@ -943,7 +957,7 @@ class BZgraflow extends Buildoz{
!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)
this.moveNode(pid, nodeX[pid], nodeY[firstRealChild], orientation, tween, token)
nodeY[pid] = nodeY[firstRealChild]
}
}
@@ -972,17 +986,18 @@ class BZgraflow extends Buildoz{
for(const nid of layer){
if(!nid.startsWith('longLinkPlaceHolder_')){
const bb = this.stagedNodes[nid].getBoundingClientRect()
this.moveNode(nid, x, y, orientation, tween, null, token)
this.moveNode(nid, x, y, orientation, tween, 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)
this.moveNode(nid, x, y, orientation, tween, token)
x += gapx + fakeNodeWidth
}
}
y += hMax + gapy
}
}
this._maybeFireLayoutComplete()
}
clearFakeNodes(){
@@ -1102,17 +1117,34 @@ class BZgraflow extends Buildoz{
}
moveNode(nid, destx, desty, orientation, duration = 200, autoPlaceToken = null) {
const t0 = performance.now()
const el0 = this.stagedNodes?.[nid]
if(!el0) return
let layoutTracked = false
if(autoPlaceToken != null && autoPlaceToken === this._layoutCompleteToken) {
layoutTracked = true
this._layoutMovePending++
}
const finishLayoutMove = () => {
if(!layoutTracked) return
layoutTracked = false
this._layoutMovePending = Math.max(0, this._layoutMovePending - 1)
this._maybeFireLayoutComplete()
}
const t0 = performance.now()
const bb = el0.getBoundingClientRect()
const parentbb = el0.parentElement.getBoundingClientRect()
const x0=bb.x - parentbb.x
const y0 = bb.y - parentbb.y
function frame(t) {
if(autoPlaceToken && autoPlaceToken !== this._autoPlaceToken) return
if(autoPlaceToken && autoPlaceToken !== this._autoPlaceToken) {
finishLayoutMove()
return
}
const el = this.stagedNodes?.[nid]
if(!el) return
if(!el) {
finishLayoutMove()
return
}
const p = Math.min((t - t0) / duration, 1)
const k = p * p * (3 - 2 * p) // smoothstep
const x = x0 + (destx - x0) * k
@@ -1130,6 +1162,7 @@ class BZgraflow extends Buildoz{
flowNode.coords.y = y
}
this.fireEvent('nodeMoved', { nid, x, y })
finishLayoutMove()
}
}
requestAnimationFrame(frame.bind(this))
@@ -1299,15 +1332,11 @@ class BZgraflow extends Buildoz{
return(crossLayerLinks)
}
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.
/**
* Union bounding boxes of nodes and wires (viewport coords).
* Same measurement strategy as autofit(). Call with transform cleared for stable sizes.
*/
getContentSize(){
let left = Infinity
let top = Infinity
let right = -Infinity
@@ -1324,18 +1353,40 @@ class BZgraflow extends Buildoz{
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()
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 hasBounds = Number.isFinite(left) && Number.isFinite(right) && Number.isFinite(top) && Number.isFinite(bottom)
const rawWidth = hasBounds ? Math.max(right - left, 1) : Math.max(this.mainContainer?.clientWidth || this.offsetWidth || 1, 1)
const rawHeight = hasBounds ? Math.max(bottom - top, 1) : Math.max(this.mainContainer?.clientHeight || this.offsetHeight || 1, 1)
return({
left: hasBounds ? left : null,
top: hasBounds ? top : null,
right: hasBounds ? right : null,
bottom: hasBounds ? bottom : null,
rawWidth,
rawHeight,
width: rawWidth + (2 * gapx),
height: rawHeight + (2 * gapy),
gapx,
gapy,
})
}
autofit(percent=100){
if(!this.parentElement) return
const prevTransformOrigin = this.style.transformOrigin
this.style.transform = 'none'
this.style.transformOrigin = 'top left'
const { left, top, width: contentW, height: contentH, gapx, gapy } = this.getContentSize()
const parentBB = this.parentElement.getBoundingClientRect()
const sx = parentBB.width / contentW
const sy = parentBB.height / contentH
const scale = Math.min(sx, sy)*(percent/100) // uniform scale to fit inside parent
const tx = Number.isFinite(left) ? (-left + gapx) : gapx
const ty = Number.isFinite(top) ? (-top + gapy) : gapy
const tx = left != null ? (-left + gapx) : gapx
const ty = top != null ? (-top + gapy) : gapy
if(!this.isSubflow) {
this.style.transformOrigin = prevTransformOrigin || 'top left'
this.style.transform = `scale(${scale}) translate(${tx}px, ${ty}px)`