From f94048449825576749fbbe0117d40c27e10be931 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 16:25:32 -0800 Subject: [PATCH] Read the section index only when the groups change, and keep its label on screen Live counts repaint the rows many times a second; the index now reads its headings at most once a frame, and only when a heading changes. The label goes before the tick when there is no room after it, as in a right-to-left language, and the ticks sit at the panel's inline end. Co-authored-by: Manjunath Paparaju --- src/ui/styles/layer-section-index.css | 7 +++- src/ui/views/panels/LayerSectionIndex.vue | 42 ++++++++++++++++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/ui/styles/layer-section-index.css b/src/ui/styles/layer-section-index.css index 1046f153e..0e3283ae3 100644 --- a/src/ui/styles/layer-section-index.css +++ b/src/ui/styles/layer-section-index.css @@ -6,7 +6,7 @@ .layer-section-index { position: absolute; top: 64px; - right: 2px; + inset-inline-end: 2px; bottom: 24px; z-index: 2; display: flex; @@ -80,6 +80,11 @@ animation: layer-section-index-label 120ms ease both; } +/* Drawn before the tick when there is no room after it. */ +.layer-section-index__label--before { + transform: translate(-100%, -50%); +} + @keyframes layer-section-index-label { from { opacity: 0; diff --git a/src/ui/views/panels/LayerSectionIndex.vue b/src/ui/views/panels/LayerSectionIndex.vue index 79f5b6262..6dbd76055 100644 --- a/src/ui/views/panels/LayerSectionIndex.vue +++ b/src/ui/views/panels/LayerSectionIndex.vue @@ -17,9 +17,33 @@ function headings() { return list ? [...list.querySelectorAll('.data-layer-group-heading:not(.is-filtered)')] : []; } function readSections() { - sections.value = headings().map((h, i) => ({ i, label: h.textContent.trim() })); + const next = headings().map((h, i) => ({ i, label: h.textContent.trim() })); + // Live counts repaint the rows all the time; keep the ticks unless the + // groups themselves changed. + const same = + next.length === sections.value.length && + next.every((s, i) => s.label === sections.value[i].label); + if (!same) sections.value = next; syncCurrent(); } +// Only headings matter: a heading added, removed, renamed (a language change) +// or filtered out. Rows' counts and states change many times a second. +const isHeading = (node) => + node?.classList?.contains('data-layer-group-heading') || + node?.parentElement?.classList?.contains('data-layer-group-heading'); +let frame = 0; +function onMutations(mutations) { + if (frame) return; + const relevant = mutations.some( + (m) => + (m.type === 'childList' && m.target === list) || isHeading(m.target), + ); + if (!relevant) return; + frame = requestAnimationFrame(() => { + frame = 0; + readSections(); + }); +} function syncCurrent() { if (!list) return; const top = list.getBoundingClientRect().top + 8; @@ -38,7 +62,15 @@ function syncCurrent() { const tip = ref(null); function showTip(event, section) { const box = event.currentTarget.getBoundingClientRect(); - tip.value = { label: section.label, left: `${Math.round(box.right + 10)}px`, top: `${Math.round(box.top + box.height / 2)}px` }; + // Beside the tick on whichever side has room: the stack sits on the right + // in a right-to-left language. + const before = window.innerWidth - box.right < 180; + tip.value = { + label: section.label, + before, + left: `${Math.round(before ? box.left - 10 : box.right + 10)}px`, + top: `${Math.round(box.top + box.height / 2)}px`, + }; } const hideTip = () => { tip.value = null; @@ -52,7 +84,7 @@ function jump(i) { onMounted(() => { list = document.getElementById('data-toggles'); if (!list) return; - observer = new MutationObserver(readSections); + observer = new MutationObserver(onMutations); observer.observe(list, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] }); list.addEventListener('scroll', syncCurrent, { passive: true }); // The list is hidden while the panel is shut; read again when it opens. @@ -63,6 +95,7 @@ onMounted(() => { onBeforeUnmount(() => { observer?.disconnect(); resize?.disconnect(); + cancelAnimationFrame(frame); list?.removeEventListener('scroll', syncCurrent); }); @@ -84,7 +117,8 @@ onBeforeUnmount(() => { @blur="hideTip" > - +