diff --git a/cypress/e2e/filtering.cy.js b/cypress/e2e/filtering.cy.js new file mode 100644 index 0000000..9443380 --- /dev/null +++ b/cypress/e2e/filtering.cy.js @@ -0,0 +1,24 @@ +/**Testing whether the Accepted components excists or not */ +describe('Accepted filtering', () => { + it('test accepted filter button', function () { + cy.visit('http://localhost:5173/#') + cy.get('#doAcceptedFilter').click() + }) +}) + +/**Testing whether the Elevation components excists or not */ +describe('Elevation Control', () => { + it('test elevation control', function () { + cy.visit('http://localhost:5173/#') + cy.get('#btnDoElevationControl').click() + + cy.get( + '#elevation_gradient_scheme_selection span:nth-child(8) rect' + ).click() + cy.get( + '#elevation_gradient_scheme_selection span:nth-child(3) rect' + ).click() + + cy.get('#sldHeightRange').click() + }) +}) diff --git a/index.html b/index.html index 192e6ef..2f98510 100644 --- a/index.html +++ b/index.html @@ -39,6 +39,7 @@ href="/src/MeasurementControl/measurementsPanel.css" /> + diff --git a/src/AcceptedFiltering/threePanels.css b/src/AcceptedFiltering/threePanels.css new file mode 100644 index 0000000..e83b9e6 --- /dev/null +++ b/src/AcceptedFiltering/threePanels.css @@ -0,0 +1,149 @@ +/* ---------- Buttons (shared look) ---------- */ +/* Reuse your accepted button style for all four */ +#btnDoElevationControl, +#doAcceptedFiltering, +#btnTHU, +#btnTVU, +#btnTHUFilter { + display: flex; + width: 100%; + margin: 6px 0 10px; + padding: 10px 10px; + font-size: 13px; + font-weight: 500; + background-color: #636262; + color: #ffffff; + border: 1px solid #555; + border-radius: 4px; + cursor: pointer; + transition: + background-color 0.2s ease, + transform 0.1s ease; +} + +#btnDoElevationControl:hover, +#doAcceptedFiltering:hover, +#btnTHU:hover, +#btnTVU:hover, +#btnTHUFilter:hover { + background-color: #8f8f8f; +} + +#btnDoElevationControl:active, +#doAcceptedFiltering:active, +#btnTHU:active, +#btnTVU:active, +#btnTHUFilter:active { + transform: scale(0.97); + background-color: #a8a6a6; +} + +/* Optional: “active mode” outline if you toggle a class via JS */ +#btnDoElevationControl.active, +#doAcceptedFiltering.active, +#btnTHU.active, +#btnTVU.active, +#btnTHUFilter:active { + outline: 2px solid #7ba8ff; + outline-offset: 1px; +} + +/* THU/TVU side-by-side */ +#thu_tvu_list .thu-tvu-row { + display: flex; + gap: 6px; +} +#thu_tvu_list .thu-tvu-row > button { + flex: 1 1 50%; + margin: 0; +} + +/* ---------- Panels / moved containers ---------- */ +/* Keep Potree’s moved subtrees neat and full-width inside our panels */ +#elevation2_list [id='materials.elevation_container'], +#thu_tvu_list [id='materials.extra_container'] { + width: 100%; + box-sizing: border-box; + padding: 6px 8px; /* small breathing room since we moved it out of Appearance */ +} + +/* Slight spacing inside our panel lists (under the button) */ +#elevation2_list, +#accepted_list_host, +#thu_tvu_list { + display: block; + padding: 4px 0; +} + +/* A small notice style used when an attribute/panel isn’t available */ +.panel-notice { + font-size: 13px; + color: #ddd; + background: rgba(255, 255, 255, 0.04); + border: 1px dashed #666; + border-radius: 4px; + padding: 8px; + margin: 6px 0; +} + +/* ---------- Elevation label + slider tweaks ---------- */ +#lblHeightRange { + display: inline-block; + font-size: 12px; + color: #ddd; + margin: 4px 0 6px 2px; +} + +/* Make sure the jQuery-UI slider has a little vertical room */ +#sldHeightRange { + margin: 6px 4px 10px 4px; +} + +/* ---------- Accordions / headers (light touch) ---------- */ +/* Don’t fight jQuery-UI’s theme. Just small spacing adjustments. */ +#menu_elevation2 + .pv-menu-list, +#menu_accepted + .pv-menu-list, +#menu_thu_tvu + .pv-menu-list { + padding-top: 6px; +} + +/* Optional: header label color alignment with dark UI */ +#menu_elevation2 span, +#menu_accepted span, +#menu_thu_tvu span { + color: #e6e6e6; + font-weight: 600; + letter-spacing: 0.2px; +} + +/* Legend container */ +#accepted_legend { + margin-top: 10px; + padding-left: 4px; +} + +/* Legend rows */ +#accepted_legend .legend-row { + display: flex; + align-items: center; + margin: 3px 0; + font-size: 13px; + color: #ddd; /* visible text */ +} + +/* Color boxes */ +#accepted_legend .legend-color { + width: 16px; + height: 16px; + border: 1px solid #777; + margin-right: 8px; + border-radius: 2px; +} + +#accepted_legend .legend-color.accepted { + background-color: #fff; +} + +#accepted_legend .legend-color.not-accepted { + background-color: #000; +} diff --git a/src/AcceptedFiltering/threePanels.js b/src/AcceptedFiltering/threePanels.js new file mode 100644 index 0000000..2ab84f4 --- /dev/null +++ b/src/AcceptedFiltering/threePanels.js @@ -0,0 +1,440 @@ +// Three Potree sidebar sections with buttons + panel bodies: +// • Elevation → moves #materials.elevation_container into our Elevation body +// • Accepted → custom UI fully defined here (no external module) + +const byId = (id) => document.getElementById(id) + +/** + * Refreshes the jQuery-UI accordion after DOM mutations. + */ +function accordionRefresh() { + const menu = byId('potree_menu') + if (window.$ && menu && window.$(menu).accordion) { + try { + window.$(menu).accordion('refresh') + } catch {} + } +} + +/** + * Inserts a sidebar section with a header and a panel container. + * Places it before Appearance/Filters if present, otherwise at the end. + * @param {{headerId:string, headerText:string, listId:string}} + */ +function insertSection({ headerId, headerText, listId }) { + if (byId(listId)) return + + const menu = byId('potree_menu') + if (!menu) return + + const header = document.createElement('h3') + header.id = headerId + header.innerHTML = `${headerText}` + + const panel = document.createElement('div') + panel.className = 'pv-menu-list' + panel.innerHTML = `
` + + const before = byId('menu_appearance') || byId('menu_filters') || null + if (before) { + menu.insertBefore(panel, before) + menu.insertBefore(header, panel) + } else { + menu.appendChild(header) + menu.appendChild(panel) + } + + accordionRefresh() +} + +/** + * Ensures buttons row and a body container to new panel + * @param {string} listId + * @returns {{list:HTMLElement|null, btns:HTMLElement|null, body:HTMLElement|null}} + */ +function ensurePanelScaffold(listId) { + const list = byId(listId) + if (!list) return { list: null, btns: null, body: null } + + let btns = list.querySelector('.panel-buttons') + let body = list.querySelector('.panel-body') + + if (!btns) { + btns = document.createElement('div') + btns.className = 'panel-buttons' + list.insertBefore(btns, list.firstChild) + } + if (!body) { + body = document.createElement('div') + body.className = 'panel-body' + list.appendChild(body) + } + return { list, btns, body } +} + +/** + * Shows only the requested body to make for a better visual experience by the user (buttons remain visible at all times). + * @param {'elevation'|'accepted'} key + */ +function showOnly(key) { + const elevBody = byId('elevation2_list')?.querySelector('.panel-body') + const accBody = byId('accepted_list_host')?.querySelector('.panel-body') + + if (elevBody) elevBody.style.display = key === 'elevation' ? '' : 'none' + if (accBody) accBody.style.display = key === 'accepted' ? '' : 'none' +} + +/** + * Clicks a button only once (used for auto-activation of elevation upon opening the application). + * @param {string} id + */ +function clickOnce(id) { + const btn = byId(id) + if (btn && !btn.dataset.autoClicked) { + btn.dataset.autoClicked = 'true' + btn.click() + } +} + +/** + * Opens the Scene accordion so the Properties/Attributes UI renders and can be moved to customized sections. + */ +function openScenePane() { + const menu = byId('potree_menu') + const header = byId('menu_scene') + if (!menu || !header) return + + if (window.$ && window.$(menu).accordion) { + try { + const headers = [...menu.querySelectorAll(':scope > h3')] + const idx = headers.findIndex((h) => h.id === 'menu_scene') + if (idx >= 0) window.$(menu).accordion('option', 'active', idx) + } catch {} + } else { + header.dispatchEvent(new MouseEvent('click', { bubbles: true })) + } +} + +/** + * Ensures a cloud node is selected so the Properties of the Scene renders. + * @param {{selectCloudOnce?:Function}} hooks + */ +function selectCloudNode(hooks) { + if (typeof hooks?.selectCloudOnce === 'function') { + try { + hooks.selectCloudOnce() + return + } catch {} + } + const icon = document.querySelector( + '#scene_objects i.jstree-themeicon-custom' + ) + if (icon) icon.dispatchEvent(new MouseEvent('click', { bubbles: true })) +} + +/** + * Polls the DOM for an element id so that wverything is ensured displayed. + * @param {string} id + * @param {number} softMs + * @param {number} pollEvery + * @returns {Promise} + */ +async function waitForOrPoll(id, softMs = 1400, pollEvery = 120) { + const start = performance.now() + while (performance.now() - start < softMs) { + const el = byId(id) + if (el) return el + await new Promise((r) => setTimeout(r, pollEvery)) + } + return null +} + +/* ───────────────────────────── Elevation panel ───────────────────────────── */ +/** + * Inserts the Elevation panel section and scaffold. + */ +function createElevationPanel() { + insertSection({ + headerId: 'menu_elevation', + headerText: 'Elevation Control', + listId: 'elevation2_list' + }) + ensurePanelScaffold('elevation2_list') +} + +/** + * Ensures the Elevation action button is present and wired. + * @param {{onActivateElevation?:Function}} hooks + */ +function ensureElevationButton(hooks) { + const { btns } = ensurePanelScaffold('elevation2_list') + if (!btns || byId('btnDoElevationControl')) return + + const btn = document.createElement('button') + btn.id = 'btnDoElevationControl' + btn.type = 'button' + btn.textContent = 'Activate elevation control' + btn.addEventListener('click', () => { + switchMode('elevation', hooks?.onActivateElevation, hooks) + }) + btns.appendChild(btn) +} + +/** + * Reconnects the Elevation slider label to reflect the current range. + * Assumes #sldHeightRange and #lblHeightRange exist. + */ +function rebindElevationLabel() { + const $ = window.jQuery || window.$ + const slider = $ ? $('#sldHeightRange') : null + const label = byId('lblHeightRange') + if (!slider || !slider.length || !label) return + + const update = () => { + const low = slider.slider('values', 0) + const high = slider.slider('values', 1) + label.textContent = `${low.toFixed(2)} to ${high.toFixed(2)}` + } + + slider.slider({ min: -10000, max: 0, values: [-10000, 0] }) + slider.off('slide.custom slidestop.custom change.custom') + slider.on('slide.custom', 'slidestop.custom change.custom', update) + update() +} + +/** + * Moves Potree's Elevation container under the Elevation panel body and rebinds label. + * @returns {boolean} true if moved or already in place + */ +function moveElevationContainer() { + const { body } = ensurePanelScaffold('elevation2_list') + const src = byId('materials.elevation_container') + if (!body || !src) return false + + if (src.parentNode !== body) { + body.appendChild(src) + rebindElevationLabel() + accordionRefresh() + } + return true +} + +/** + * Initializes Elevation section and passive self-healing for rebuilds. + * @param {{onActivateElevation?:Function}} hooks + */ +function initElevationControls(hooks) { + createElevationPanel() + ensureElevationButton(hooks) + + const root = byId('potree_menu') || document.body + const obs = new MutationObserver(() => { + if (byId('materials.elevation_container')) moveElevationContainer() + }) + obs.observe(root, { childList: true, subtree: true }) +} + +/* ───────────────────────────── Accepted panel ───────────────────────────── */ + +/** + * Inserts the Accepted section and an inner host container. + */ +function createAcceptedPanel() { + insertSection({ + headerId: 'menu_accepted', + headerText: 'Accepted Filter', + listId: 'accepted_list_host' + }) + const { body } = ensurePanelScaffold('accepted_list_host') + + if (body && !byId('accepted_list')) { + const wrap = document.createElement('div') + wrap.id = 'accepted_list' + body.appendChild(wrap) + } +} + +/** + * Ensures the Accepted action button is present. + * @param {{onActivateAccepted?:Function}} hooks + */ +function ensureAcceptedButton(hooks) { + const { btns } = ensurePanelScaffold('accepted_list_host') + if (!btns || byId('doAcceptedFiltering')) return + + const btn = document.createElement('button') + btn.id = 'doAcceptedFiltering' + btn.type = 'button' + btn.textContent = 'Activate accepted filter' + btn.addEventListener('click', () => { + switchMode('accepted', hooks?.onActivateAccepted, hooks) + }) + btns.appendChild(btn) +} + +/** + * Creates legend under the Accepted list for accepted and not accepted indication. + * @returns {HTMLElement|null} + */ +function ensureAcceptedLegend() { + const list = byId('accepted_list') + if (!list) return null + + let legend = list.querySelector('#accepted_legend') + if (!legend) { + legend = document.createElement('div') + legend.id = 'accepted_legend' + legend.style.display = 'none' + legend.innerHTML = ` +
+
+ Accepted points +
+
+
+ Not accepted points +
+ ` + list.appendChild(legend) + } + return legend +} + +/** + * Shows/hides legend based on the hook (if the button is clicked or not) + * + * @param {*} show + */ +export function toggleAcceptedLegend(show) { + const legend = byId('accepted_legend') + if (legend) legend.style.display = show ? 'block' : 'none' +} + +/** + * Ensures a UL list host exists inside the Accepted section (Does appearnlty not work without this). + * @returns {HTMLElement|null} + */ +function ensureAcceptedListUL() { + const host = byId('accepted_list') + if (!host) return null + + let ul = host.querySelector('#accepted_ui') + if (!ul) { + ul = document.createElement('ul') + ul.id = 'accepted_ui' + ul.className = 'pv-menu-list' + host.appendChild(ul) + } + return ul +} + +/** + * Initializes the Accepted section. + * @param {{onActivateAccepted?:Function}} hooks + */ +function initAcceptedControlsInline(hooks) { + createAcceptedPanel() + ensureAcceptedButton(hooks) + ensureAcceptedLegend() + ensureAcceptedListUL() +} + +/** + * Ensures the correct Potree panel is present and moved for the requested filtering + * @param {'elevation'|'accepted'} mode + * @param {object} hooks + */ +async function ensurePanelCaptured(mode, hooks) { + openScenePane() + selectCloudNode(hooks) + + if (mode === 'elevation') { + let src = + byId('materials.elevation_container') || + (await waitForOrPoll('materials.elevation_container')) + if (!src && typeof hooks?.onActivateElevation === 'function') { + // nudge: switch away and back if needed to force Potree to build the UI + hooks.onActivateAccepted?.() + openScenePane() + selectCloudNode(hooks) + hooks.onActivateElevation() + openScenePane() + selectCloudNode(hooks) + src = await waitForOrPoll('materials.elevation_container', 1800) + } + if (src) moveElevationContainer() + return + } +} + +/** + * Central mode switcher. Calls the provided hook, captures/moves the panel, + * toggles Accepted legend, and reveals the correct panel body. + * @param {'elevation'|'accepted'} mode + * @param {Function|undefined} hook + * @param {object} hooksBag + */ +let switching = false +async function switchMode(mode, hook, hooksBag = {}) { + if (switching) return + switching = true + try { + if (typeof hook === 'function') hook() + await ensurePanelCaptured(mode, hooksBag) + toggleAcceptedLegend(mode === 'accepted') + if (mode === 'elevation') showOnly('elevation') + else if (mode === 'accepted') showOnly('accepted') + } finally { + switching = false + } +} + +/** + * Observes the sidebar DOM and re-moves containers if Potree rebuilds them. + * @param {()=>'elevation'|'accepted'} activeGetter + * @returns {MutationObserver} + */ +function attachSelfHealing(activeGetter) { + const root = byId('potree_menu') || document.body + const obs = new MutationObserver(() => { + const mode = activeGetter() + if (mode === 'elevation') { + const src = byId('materials.elevation_container') + const { body } = ensurePanelScaffold('elevation2_list') + if (src && body && src.parentNode !== body) moveElevationContainer() + } + }) + obs.observe(root, { childList: true, subtree: true }) + return obs +} + +/* ───────────────────────────── Initiation ───────────────────────────── */ +/** + * Public entrypoint: builds Elevation and Accepted panels and wires behavior. + * @param {object} viewer Potree viewer (not used directly here but available to hooks) + * @param {{onActivateElevation?:Function, onActivateAccepted?:Function, selectCloudOnce?:Function}} hooks + */ +export function initThreePanels(viewer, hooks = {}) { + // Build sections + initElevationControls(hooks) + initAcceptedControlsInline(hooks) + + // Track active section for self-healing + let active = 'elevation' + const setActive = (m) => { + active = m + } + const getActive = () => active + + byId('btnDoElevationControl')?.addEventListener('click', () => + setActive('elevation') + ) + byId('doAcceptedFiltering')?.addEventListener('click', () => + setActive('accepted') + ) + + attachSelfHealing(getActive) + + // Default: auto-activate Elevation once + clickOnce('btnDoElevationControl') +} diff --git a/src/ElevationControl/elevationControl.js b/src/ElevationControl/elevationControl.js deleted file mode 100644 index 5636e8a..0000000 --- a/src/ElevationControl/elevationControl.js +++ /dev/null @@ -1,137 +0,0 @@ -//Cerating a customized section "Elevation Control" -window.createElevationPanel = function createElevationPanel(viewer) { - const container = document.getElementById('elevation_list') - let targetContainer = container - if (!targetContainer) { - // Create a new accordion section for Elevation Control - const menu = document.getElementById('potree_menu') - if (menu) { - const header = document.createElement('h3') - header.id = 'menu_elevation' - header.innerHTML = 'Elevation Control' - const panel = document.createElement('div') - panel.className = 'pv-menu-list' - panel.innerHTML = '
' - const about = document.getElementById('menu_appearance') - if (about) { - menu.insertBefore(panel, about) - menu.insertBefore(header, panel) - } else { - menu.appendChild(header) - menu.appendChild(panel) - } - // Activate accordion behavior if jQuery UI accordion already initialized - if ($(menu).accordion) { - try { - $(menu).accordion('refresh') - } catch (e) {} - } - // Toggle on header click if not managed by accordion refresh - header.addEventListener( - 'click', - () => - (panel.style.display = panel.style.display === 'none' ? '' : 'none') - ) - targetContainer = panel.querySelector('#elevation_list') - } - } -} - -/** - * Select the fist pointcloud in the sidebar so that the Elevation section is built - */ -function autoSelectFirstPointCloud() { - const cloudIcon = document.querySelector( - '#scene_objects i.jstree-themeicon-custom' - ) - if (cloudIcon) { - cloudIcon.dispatchEvent(new MouseEvent('click', { bubbles: true })) - return true - } - return false -} - -/** - * Disable any further clicks of the pointcloud icon in the sidebar - */ -function disableFirstPointCloudNode() { - // find the
  • that holds the first cloud icon - const icon = document.querySelector( - '#scene_objects i.jstree-themeicon-custom' - ) - const li = icon ? icon.closest('li') : null - if (!li) return - //visually/DOM disable anchor clicks - const a = li.querySelector('a') - if (a) { - a.style.pointerEvents = 'none' - a.style.opacity = 0.5 - a.classList.remove('jstree-clicked') - } -} - -//(re)connect the elevation labels to the slider after the container is moved (was not handled by default) -function rebindElevationLabel() { - const slider = window.jQuery ? window.jQuery('#sldHeightRange') : null - const label = document.getElementById('lblHeightRange') - if (!slider || !slider.length || !label) return - - const update = () => { - const low = slider.slider('values', 0) - const high = slider.slider('values', 1) - label.textContent = `${low.toFixed(2)} to ${high.toFixed(2)}` - } - - // Adjust slider limits - slider.slider({ - min: -10000, - max: 0, - values: [-10000, 0] - }) - - //clear any old namespaced handlers and attach fresh ones - slider.off('slide.custom slidestop.custom change.custom') - slider.on('slide.custom', update) - slider.on('slidestop.custom change.custom', update) - update() -} - -//Move the elevation range section to the customised "Elevation Control" section -function moveElevationContainer() { - const target = document.getElementById('elevation_list') - const elevationContainer = document.querySelector( - '#materials\\.elevation_container' - ) - if (!elevationContainer) return false - target.appendChild(elevationContainer) - rebindElevationLabel() - return true -} - -//initiate and orchestrate all funcitons to render the Evelation control section of the sidebar propperly -export function initElevationControls(viewer) { - //Creates the section - createElevationPanel(viewer) - - //Only move the ElevationContainer if the source container to exist - const menu = - document.getElementById('potree_menu') || - document.getElementById('menu') || - document.body - const observer = new MutationObserver(() => { - const found = document.querySelector('#materials\\.elevation_container') - if (found) { - observer.disconnect() - //Move and rebind once it exists - const ok = moveElevationContainer() - if (!ok) console.warn('[Elevation] moveElevationContainer failed') - } - }) - observer.observe(menu, { childList: true, subtree: true }) - - //Trigger Potree to build Materials UI by selecting the first point cloud (if nothing selected yet) - if (autoSelectFirstPointCloud()) { - //Prevent multiple clicks on the cloud icon - disableFirstPointCloudNode() - } -} diff --git a/src/potreeViewer.js b/src/potreeViewer.js index b8c773c..3d80cf0 100644 --- a/src/potreeViewer.js +++ b/src/potreeViewer.js @@ -1,6 +1,9 @@ import { initAnnotationsPanel } from './AnnotationControl/annotationPanel.js' -import { initElevationControls } from './ElevationControl/elevationControl.js' import { initMeasurementsPanel } from './MeasurementControl/measurementsPanel.js' +import { + initThreePanels, + toggleAcceptedLegend +} from './AcceptedFiltering/threePanels.js' import { ecef } from './config.js' /** @@ -36,7 +39,29 @@ export async function createPotreeViewer(containerId, pointcloudUrl, settings) { $('#menu_filters').next().show() viewer.toggleSidebar() - initElevationControls(viewer) + initThreePanels(viewer, { + onActivateElevation: () => { + if (!pc) return + pc.material.activeAttributeName = 'elevation' + pc.material.gradient = Potree.Gradients['VIRIDIS'] + suppressSidebarAutoScroll(clickCloudIconOnce) + }, + onActivateAccepted: () => { + if (!pc) return + pc.material.activeAttributeName = 'accepted' + pc.material.gradient = Potree.Gradients['GRAYSCALE'] + toggleAcceptedLegend(true) + suppressSidebarAutoScroll(clickCloudIconOnce) + } + }) + // // // helper + function clickCloudIconOnce() { + const icon = document.querySelector( + '#scene_objects i.jstree-themeicon-custom' + ) + if (icon) icon.dispatchEvent(new MouseEvent('click', { bubbles: true })) + } + initMeasurementsPanel(viewer) initAnnotationsPanel(viewer) }) @@ -56,12 +81,12 @@ export async function createPotreeViewer(containerId, pointcloudUrl, settings) { pc.material.pointSizeType = Potree.PointSizeType.ADAPTIVE pc.material.shape = Potree.PointShape.CIRCLE overrideShaderForGradient(pc) + + //The default activeAttributeName is set to elevation and the color gradient to VIRIDIS for good visualization pc.material.elevationRange = [-10000, 0] pc.material.activeAttributeName = 'elevation' pc.material.gradient = Potree.Gradients['VIRIDIS'] - e.pointcloud.projection = ecef - // Initialize camera position and target point (manually chosen) viewer.scene.view.setView( [3961574.044, 1494736.334, 8348318.575], // Initial camera position @@ -136,3 +161,101 @@ function overrideShaderForGradient(pc) { this.needsUpdate = true } } + +/** + * Freeze all scrollable ancestors of a given root during an action (e.g., jsTree select) + * Need this so that when Elevation control or Accepted filter is activated the sidebar doesn't scroll down to the Scene panel + * + * @param {*} action + */ +function suppressSidebarAutoScroll(action, holdMs = 350) { + // anchor on the tree root; fall back to the menu if not found + const treeRoot = + document.querySelector('#scene_objects') || + document.querySelector('#potree_menu') || + document.body + + // collect ALL scrollable ancestors (including the tree UL itself) + const scrollers = [] + let el = treeRoot + while (el) { + const sh = el.scrollHeight + const ch = el.clientHeight + const canScroll = sh && ch && sh > ch + if (canScroll) scrollers.push(el) + el = el.parentElement + } + if (!scrollers.length) { + action() + return + } + + // snapshot state for each scroller + const states = scrollers.map((s) => ({ + el: s, + top: s.scrollTop, + left: s.scrollLeft, + overflow: s.style.overflow + })) + + // guard scroll by snapping back; also hide overflow to avoid flicker + const handlers = new Map() + states.forEach(({ el, top, left }) => { + const onScroll = () => { + el.scrollTop = top + el.scrollLeft = left + } + el.addEventListener('scroll', onScroll, { passive: true }) + el.style.overflow = 'hidden' + handlers.set(el, onScroll) + }) + + // temporarily neutralize scrollIntoView/focus scrolling + const origScrollIntoView = Element.prototype.scrollIntoView + Element.prototype.scrollIntoView = function () { + /* no-op */ + } + + const origFocus = HTMLElement.prototype.focus + HTMLElement.prototype.focus = function (opts) { + // force preventScroll behavior even if caller didn't ask + try { + origFocus.call(this, { ...(opts || {}), preventScroll: true }) + } catch { + origFocus.call(this) + } + } + try { + action() + } finally { + const until = performance.now() + holdMs + + const restoreLoop = () => { + // keep snapping until the selection animations/handlers settle + states.forEach(({ el, top, left }) => { + el.scrollTop = top + el.scrollLeft = left + }) + + // if a jsTree node grabbed focus, blur it so it won't re-scroll later + const active = document.activeElement + if (active && active.closest && active.closest('#scene_objects')) { + active.blur() + } + + if (performance.now() < until) { + requestAnimationFrame(restoreLoop) + } else { + // full restore + Element.prototype.scrollIntoView = origScrollIntoView + HTMLElement.prototype.focus = origFocus + states.forEach(({ el, overflow }) => { + const h = handlers.get(el) + if (h) el.removeEventListener('scroll', h) + el.style.overflow = overflow + }) + } + } + requestAnimationFrame(restoreLoop) + } +}