1/** 2 * Slider-UX wiring shared across the editor: 3 * 4 * 1. `is-using` class while a slider is being dragged (eraser-rows 5 * expand to a wider track when in use). Cleared 0.5s after 6 * pointerup so a quick click doesn't snap back instantly. 7 * 2. Floating value bubble above the thumb during drag. 8 * Desktop: only the layer-opacity slider gets a bubble (the 9 * eraser-row sliders already show a value chip on the right). 10 * Mobile: every slider in the editor gets a bubble. 11 * 3. Click the value chip to type a number directly â replaces 12 * the span with an inline input until blur/Enter. 13 * 14 * Wired ONCE on editor open; the listeners stay alive for the whole 15 * session via state.container delegation. 16 * 17 * @param {{ 18 * registerDocClickAway: (handler: (e: Event) => void) => void, 19 * }} deps 20 */ 21import { state } from './state.js'; 22 23export function wireSliderUx({ registerDocClickAway }) { 24 const container = state.container; 25 if (!container) return; 26 27 // ââ Floating bubble ââ 28 const sliderBubble = document.createElement('div'); 29 sliderBubble.className = 'ge-slider-bubble'; 30 sliderBubble.hidden = true; 31 let sliderBubbleSlider = null; 32 33 // Find the container row for any slider â works for ge-eraser-row 34 // sliders AND the layer-opacity slider on each layer item. 35 function bubbleRowFor(slider) { 36 return slider.closest('.ge-eraser-row, .ge-layer-item, .ge-control-row, .ge-adj-row'); 37 } 38 function bubbleText(slider) { 39 const row = bubbleRowFor(slider); 40 // Pulled-out value chip (after the slider) wins; fall back to 41 // the various `<label> <span>` styles used across the editor. 42 const chip = row?.querySelector('.ge-slider-value') 43 || row?.querySelector('label > span[id$="-label"]') 44 || row?.querySelector('label > .ge-size-label') 45 || row?.querySelector('.ge-adj-value'); 46 if (chip) return chip.textContent; 47 if (slider.classList.contains('ge-layer-opacity')) { 48 return Math.round(parseFloat(slider.value)) + '%'; 49 } 50 return slider.value; 51 } 52 function bubblePos(slider, cursorX) { 53 // Bubble is fixed-positioned on document.body so it escapes any 54 // overflow:hidden / overflow:auto on the row's ancestors. The 55 // bubble's X is CLAMPED to the slider's track so it can't follow 56 // a finger that drags way past either end. 57 const sliderRect = slider.getBoundingClientRect(); 58 const minX = sliderRect.left + 8; 59 const maxX = sliderRect.right - 8; 60 const x = Math.max(minX, Math.min(maxX, cursorX)); 61 sliderBubble.style.left = x + 'px'; 62 sliderBubble.style.top = (sliderRect.top - 8) + 'px'; 63 } 64 function showSliderBubble(slider, e) { 65 if (sliderBubble.parentElement !== document.body) document.body.appendChild(sliderBubble); 66 sliderBubble.textContent = bubbleText(slider); 67 bubblePos(slider, e ? e.clientX : slider.getBoundingClientRect().left + slider.offsetWidth / 2); 68 sliderBubble.hidden = false; 69 sliderBubble.classList.add('visible'); 70 sliderBubbleSlider = slider; 71 } 72 function hideSliderBubble() { 73 sliderBubble.classList.remove('visible'); 74 sliderBubble.hidden = true; 75 sliderBubbleSlider = null; 76 } 77 78 const slidingTimers = new WeakMap(); 79 // Desktop: only the layer-opacity slider gets the bubble (eraser- 80 // rows have their own chip). Mobile: every slider gets one. 81 const isMobileSliders = window.matchMedia('(max-width: 820px)').matches; 82 const SLIDER_SEL = isMobileSliders 83 ? '.ge-layer-opacity, .ge-eraser-row input[type="range"], .ge-control-row input[type="range"], .ge-adj-row input[type="range"]' 84 : '.ge-layer-opacity'; 85 86 container.addEventListener('pointerdown', (e) => { 87 const slider = e.target.closest(SLIDER_SEL); 88 if (!slider) return; 89 const t = slidingTimers.get(slider); 90 if (t) { clearTimeout(t); slidingTimers.delete(slider); } 91 slider.classList.add('is-using'); 92 showSliderBubble(slider, e); 93 // Compensate for the leftward-expanding eraser sliders so the 94 // thumb lands at the cursor's X on the new (wider) track. Layer- 95 // opacity doesn't shift left when it grows, so it uses the 96 // browser default. 97 if (slider.matches('.ge-eraser-row input[type="range"]')) { 98 const rect = slider.getBoundingClientRect(); 99 const valFrac = Math.max(0, Math.min(1, 1 - (rect.right - e.clientX) / 140)); 100 const min = parseFloat(slider.min) || 0; 101 const max = parseFloat(slider.max) || 100; 102 const step = parseFloat(slider.step) || 1; 103 const raw = min + valFrac * (max - min); 104 const stepped = Math.round(raw / step) * step; 105 requestAnimationFrame(() => {
106 slider.value = String(stepped); 107 slider.dispatchEvent(new Event('input', { bubbles: true })); 108 sliderBubble.textContent = bubbleText(slider); 109 }); 110 } else { 111 requestAnimationFrame(() => { 112 sliderBubble.textContent = bubbleText(slider); 113 }); 114 } 115 }, true); 116 document.addEventListener('pointermove', (e) => { 117 if (!sliderBubbleSlider) return; 118 bubblePos(sliderBubbleSlider, e.clientX); 119 sliderBubble.textContent = bubbleText(sliderBubbleSlider); 120 }); 121 const scheduleSliderRelease = (slider) => { 122 if (!slider) return; 123 const old = slidingTimers.get(slider); 124 if (old) clearTimeout(old); 125 const t = setTimeout(() => { 126 slider.classList.remove('is-using'); 127 slidingTimers.delete(slider); 128 }, 500); 129 slidingTimers.set(slider, t); 130 }; 131 document.addEventListener('pointerup', () => { 132 container.querySelectorAll('input[type="range"].is-using').forEach(scheduleSliderRelease); 133 hideSliderBubble(); 134 }); 135 136 // ââ Click value chip to type a number ââ 137 // Replaces the chip with a tiny inline input until blur/Enter, 138 // then writes back to the slider and dispatches `input` so 139 // previews react. Matches the legacy chip AND the pulled-out 140 // `.ge-slider-value` chip so every slider row in the editor is 141 // click-to-type editable. 142 registerDocClickAway((e) => { 143 const chip = e.target.closest( 144 '.ge-eraser-row .ge-slider-value, ' + 145 '.ge-eraser-row label > span[id$="-label"], ' + 146 '.ge-eraser-row > span[id$="-label"], ' + 147 '.ge-adj-row .ge-adj-value' 148 ); 149 if (!chip) return; 150 const row = chip.closest('.ge-eraser-row, .ge-adj-row'); 151 const slider = row?.querySelector('input[type="range"]'); 152 if (!slider) return; 153 e.preventDefault(); 154 e.stopPropagation(); 155 const numeric = (slider.value ?? '').toString(); 156 const inp = document.createElement('input'); 157 inp.type = 'text'; 158 inp.value = numeric; 159 inp.className = 'ge-slider-edit'; 160 chip.style.visibility = 'hidden'; 161 row.appendChild(inp); 162 // Position the input over where the chip sits. 163 const crect = chip.getBoundingClientRect(); 164 const rrect = row.getBoundingClientRect(); 165 inp.style.left = (crect.left - rrect.left) + 'px'; 166 inp.style.top = (crect.top - rrect.top - 1) + 'px'; 167 inp.style.width = Math.max(40, crect.width + 8) + 'px'; 168 inp.focus(); 169 inp.select(); 170 const commit = () => { 171 const v = parseFloat(inp.value); 172 if (!Number.isNaN(v)) { 173 const min = parseFloat(slider.min) || 0; 174 const max = parseFloat(slider.max) || 100; 175 const clamped = Math.max(min, Math.min(max, v)); 176 slider.value = String(clamped); 177 slider.dispatchEvent(new Event('input', { bubbles: true })); 178 } 179 cleanup(); 180 }; 181 const cleanup = () => { 182 inp.remove(); 183 chip.style.visibility = ''; 184 }; 185 inp.addEventListener('blur', commit); 186 inp.addEventListener('keydown', (ev) => { 187 if (ev.key === 'Enter') { ev.preventDefault(); commit(); } 188 if (ev.key === 'Escape') { ev.preventDefault(); cleanup(); } 189 }); 190 }); 191}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.