From 8b2f9e81cb82629c8a1597c68eaf569403bb021c Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Tue, 28 Jul 2026 01:25:07 +0200 Subject: [PATCH] refactor: improve missing browser splitter drag with compositor preview and threshold - Add drag threshold and layout frame stride to splitter drag utility - Use onPreview callback to animate splitter offset via translate3d during drag - Simplify resize class and cleanup logic in missing browser methods - Replace deferred release UI cleanup with direct activation/cleanup calls - Update CSS to add resizing overlay and remove transition during resize - Add test covering compositor preview behavior and CSS constraints --- tests/test_downloads_tab_workflow_route.mjs | 91 +++++++++++++++ web/css/resolver-main.css | 14 ++- web/resolver/utils/splitter_drag.js | 63 +++++++--- web/resolver/views/missing_browser_methods.js | 108 ++++++++---------- 4 files changed, 199 insertions(+), 77 deletions(-) diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index d9051fd..0777437 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -13,6 +13,7 @@ import { matchesWorkflowModelReference, toResolverContextModel, } from '../web/resolver/node_context_menu.js'; +import { startSplitterDrag } from '../web/resolver/utils/splitter_drag.js'; const projectRoot = path.resolve(import.meta.dirname, '..'); const queueMethodsSource = fs.readFileSync( @@ -240,6 +241,96 @@ test('floating dialog drag stays on the compositor without forced style reads', } }); +test('missing browser splitter keeps a compositor preview between live layout frames', () => { + const previousDocument = globalThis.document; + const previousRequestAnimationFrame = globalThis.requestAnimationFrame; + const previousCancelAnimationFrame = globalThis.cancelAnimationFrame; + const listeners = new Map(); + const frames = []; + const previews = []; + const layouts = []; + let endState = null; + + globalThis.document = { + addEventListener(type, handler) { + listeners.set(type, handler); + }, + removeEventListener(type, handler) { + if (listeners.get(type) === handler) listeners.delete(type); + }, + }; + globalThis.requestAnimationFrame = callback => { + frames.push(callback); + return frames.length; + }; + globalThis.cancelAnimationFrame = () => {}; + + try { + startSplitterDrag({ + type: 'pointerdown', + button: 0, + clientX: 100, + preventDefault() {}, + stopPropagation() {}, + }, { + anchor: 'left', + startWidth: 300, + bounds: { min: 200, max: 500 }, + dragThreshold: 4, + layoutFrameStride: 2, + onPreview: (pending, applied) => previews.push([pending, applied]), + onDrag: width => layouts.push(width), + onEnd: (width, state) => { + endState = { width, ...state }; + }, + }); + + listeners.get('pointermove')({ + clientX: 102, + preventDefault() {}, + stopPropagation() {}, + }); + assert.equal(frames.length, 0, 'movement below the threshold must remain a cheap click'); + + listeners.get('pointermove')({ + clientX: 112, + preventDefault() {}, + stopPropagation() {}, + }); + frames.shift()(); + assert.deepEqual(layouts, []); + assert.deepEqual(previews.at(-1), [312, 300]); + + frames.shift()(); + assert.deepEqual(layouts, [312]); + assert.deepEqual(previews.at(-1), [312, 312]); + + listeners.get('pointerup')({ + preventDefault() {}, + stopPropagation() {}, + }); + assert.equal(endState.width, 312); + assert.equal(endState.didDrag, true); + } finally { + globalThis.document = previousDocument; + globalThis.requestAnimationFrame = previousRequestAnimationFrame; + globalThis.cancelAnimationFrame = previousCancelAnimationFrame; + } + + assert.match(missingBrowserMethodsSource, /dragThreshold:\s*4/); + assert.match(missingBrowserMethodsSource, /layoutFrameStride:\s*2/); + assert.match(missingBrowserMethodsSource, /translate3d\(/); + assert.match( + resolverMainCssSource, + /\.mr-missing-browser\.is-resizing::after\s*\{[^}]*pointer-events:\s*auto/s + ); + assert.doesNotMatch( + resolverMainCssSource, + /\.mr-missing-browser\.is-resizing \.mr-missing-(?:list|detail)-pane/ + ); + assert.doesNotMatch(missingBrowserMethodsSource, /deferMissingBrowserSplitReleaseUi/); +}); + test('local and loaded model tooltips include preview image routes above full names', () => { const getModelPreviewTooltipAttrs = eval(`(${extractMethod(renderFormatMethodsSource, 'getModelPreviewTooltipAttrs')})`); const previousApi = globalThis.api; diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index ba05695..ea216b5 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -504,6 +504,7 @@ --mr-missing-radius: 6px; --mr-missing-splitter-track: 10px; --mr-missing-list-track: calc(100% - var(--mr-missing-splitter-track) - var(--mr-missing-detail-track, 42%)); + position: relative; display: flex; gap: 0; height: 100%; @@ -525,7 +526,7 @@ } .mr-missing-list-pane { - flex: 0 0 var(--mr-missing-list-track); + flex: 1 1 0; display: grid; grid-template-rows: auto minmax(0, 1fr); border-right: 0; @@ -534,6 +535,7 @@ .mr-missing-browser-splitter { position: relative; + z-index: 2; flex: 0 0 var(--mr-missing-splitter-track); min-width: var(--mr-missing-splitter-track); width: var(--mr-missing-splitter-track); @@ -564,6 +566,7 @@ .mr-missing-browser-splitter.is-resizing::before { background: rgba(114, 211, 138, 0.78); box-shadow: 0 0 0 3px rgba(114, 211, 138, 0.12); + transition: none; } .mr-missing-list-toolbar { @@ -728,6 +731,15 @@ background: var(--mr-missing-bg); } +.mr-missing-browser.is-resizing::after { + content: ""; + position: absolute; + z-index: 1; + inset: 0; + cursor: col-resize; + pointer-events: auto; +} + #model-resolver-modal .mr-missing-list-row { content-visibility: auto; contain-intrinsic-size: 70px; diff --git a/web/resolver/utils/splitter_drag.js b/web/resolver/utils/splitter_drag.js index 768c53b..25bce46 100644 --- a/web/resolver/utils/splitter_drag.js +++ b/web/resolver/utils/splitter_drag.js @@ -6,7 +6,10 @@ export function startSplitterDrag(event, { anchor = 'right', startWidth, bounds = { min: 100, max: 800 }, + dragThreshold = 0, + layoutFrameStride = 1, onBeforeDrag = null, + onPreview = null, onDrag = () => {}, onEnd = () => {} }) { @@ -15,21 +18,51 @@ export function startSplitterDrag(event, { event?.stopPropagation?.(); const startX = event.clientX; + const movementThreshold = Math.max(0, Number(dragThreshold) || 0); + const frameStride = Math.max(1, Math.round(Number(layoutFrameStride) || 1)); let pendingWidth = startWidth; let appliedWidth = startWidth; let animationFrame = null; let isDragging = true; + let didDrag = false; + let frameCount = 0; const moveEvent = event?.type === 'pointerdown' ? 'pointermove' : 'mousemove'; const upEvent = event?.type === 'pointerdown' ? 'pointerup' : 'mouseup'; const cancelEvent = event?.type === 'pointerdown' ? 'pointercancel' : null; + const scheduleFrame = () => { + if (animationFrame) return; + animationFrame = requestAnimationFrame(() => { + animationFrame = null; + if (!isDragging) return; + + frameCount += 1; + const shouldApplyLayout = frameCount % frameStride === 0; + if (shouldApplyLayout && pendingWidth !== appliedWidth) { + onDrag(pendingWidth); + appliedWidth = pendingWidth; + } + + onPreview?.(pendingWidth, appliedWidth, { + didApplyLayout: shouldApplyLayout, + final: false + }); + + if (pendingWidth !== appliedWidth) { + scheduleFrame(); + } + }); + }; + const handleMove = (e) => { if (!isDragging) return; e?.preventDefault?.(); e?.stopPropagation?.(); const dx = e.clientX - startX; + if (!didDrag && Math.abs(dx) < movementThreshold) return; + didDrag = true; let newWidth = anchor === 'right' ? startWidth - dx : startWidth + dx; if (onBeforeDrag) { @@ -39,18 +72,10 @@ export function startSplitterDrag(event, { if (newWidth > bounds.max) newWidth = bounds.max; } - pendingWidth = Math.round(newWidth); - - if (pendingWidth === appliedWidth) return; - - if (!animationFrame) { - animationFrame = requestAnimationFrame(() => { - animationFrame = null; - if (!isDragging) return; - onDrag(pendingWidth); - appliedWidth = pendingWidth; - }); - } + const nextWidth = Math.round(newWidth); + if (nextWidth === pendingWidth) return; + pendingWidth = nextWidth; + scheduleFrame(); }; const handleUp = (e) => { @@ -71,7 +96,13 @@ export function startSplitterDrag(event, { animationFrame = null; } - onEnd(pendingWidth || appliedWidth); + if (didDrag) { + onPreview?.(pendingWidth, pendingWidth, { + didApplyLayout: pendingWidth === appliedWidth, + final: true + }); + } + onEnd(pendingWidth, { didDrag, appliedWidth }); }; document.addEventListener(moveEvent, handleMove, true); @@ -92,6 +123,12 @@ export function startSplitterDrag(event, { cancelAnimationFrame(animationFrame); animationFrame = null; } + if (didDrag) { + onPreview?.(appliedWidth, appliedWidth, { + cancelled: true, + final: true + }); + } } }; } diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index 933f3b8..9859b88 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -1105,7 +1105,6 @@ export const missingBrowserMethods = { event.preventDefault(); event.stopPropagation?.(); this.cancelMissingBrowserPrewarmFrame(); - this.flushMissingBrowserSplitReleaseUi(); const detailPane = panes.detailPane instanceof HTMLElement ? panes.detailPane @@ -1139,6 +1138,9 @@ export const missingBrowserMethods = { this._missingBrowserSplitListPane = listPane; this._missingBrowserSplitDetailPane = detailPane; this._missingBrowserSplitSplitter = releaseSplitter; + if (releaseSplitter instanceof HTMLElement) { + releaseSplitter.style.willChange = 'transform'; + } this._missingBrowserSplitDragging = true; this._missingBrowserSplitStart = { x: event.clientX, @@ -1155,6 +1157,19 @@ export const missingBrowserMethods = { anchor: 'right', startWidth: detailWidth, bounds, + dragThreshold: 4, + layoutFrameStride: 2, + onPreview: (pendingWidth, appliedWidth) => { + this._pendingMissingBrowserSplitWidth = pendingWidth; + this.activateMissingBrowserSplitUi(); + if (!(releaseSplitter instanceof HTMLElement)) return; + + const visualOffset = Math.round(appliedWidth - pendingWidth); + releaseSplitter.style.transform = visualOffset + ? `translate3d(${visualOffset}px, 0, 0)` + : ''; + releaseSplitter.style.willChange = visualOffset ? 'transform' : ''; + }, onDrag: (width) => { this._pendingMissingBrowserSplitWidth = width; this.activateMissingBrowserSplitUi(); @@ -1166,14 +1181,17 @@ export const missingBrowserMethods = { }); this._appliedMissingBrowserSplitWidth = width; }, - onEnd: (finalWidth) => { - const settlingNow = typeof performance === 'object' && typeof performance.now === 'function' - ? performance.now() - : Date.now(); - this._missingBrowserSplitSettlingUntil = settlingNow + 350; + onEnd: (finalWidth, { didDrag = false } = {}) => { this._missingBrowserSplitDragging = false; - if (detailPane && finalWidth) { + if (didDrag) { + const settlingNow = typeof performance === 'object' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); + this._missingBrowserSplitSettlingUntil = settlingNow + 350; + } + + if (didDrag && detailPane && finalWidth) { this.applyMissingBrowserDetailWidth(detailPane, finalWidth, { browserWidth, availableWidth: bounds.available, @@ -1181,10 +1199,13 @@ export const missingBrowserMethods = { }); this._appliedMissingBrowserSplitWidth = finalWidth; } - if (finalWidth) { + if (didDrag && finalWidth) { this.persistMissingBrowserSplitWidth(finalWidth, { delay: 600 }); } - this.deferMissingBrowserSplitReleaseUi(releaseSplitter, !!this._missingBrowserSplitUiActive); + this.applyMissingBrowserSplitReleaseCleanup({ + browser, + splitter: releaseSplitter + }); this._missingBrowserSplitBrowser = null; this._missingBrowserSplitListPane = null; @@ -1202,56 +1223,17 @@ export const missingBrowserMethods = { activateMissingBrowserSplitUi() { if (this._missingBrowserSplitUiActive) return; this._missingBrowserSplitUiActive = true; - if (this._missingBrowserResizeClassFrame) { - cancelAnimationFrame(this._missingBrowserResizeClassFrame); - this._missingBrowserResizeClassFrame = null; - } - this._missingBrowserResizeClassFrame = requestAnimationFrame(() => { - this._missingBrowserResizeClassFrame = requestAnimationFrame(() => { - this._missingBrowserResizeClassFrame = null; - if (this._missingBrowserSplitDragging) { - this._missingBrowserSplitSplitter?.classList?.add('is-resizing'); - } - }); - }); + this._missingBrowserSplitBrowser?.classList?.add('is-resizing'); + this._missingBrowserSplitSplitter?.classList?.add('is-resizing'); }, applyMissingBrowserSplitReleaseCleanup(cleanup) { if (!cleanup) return; + cleanup.browser?.classList?.remove('is-resizing'); cleanup.splitter?.classList?.remove('is-resizing'); - }, - - flushMissingBrowserSplitReleaseUi() { - if (this._missingBrowserReleaseCleanupFrame) { - cancelAnimationFrame(this._missingBrowserReleaseCleanupFrame); - this._missingBrowserReleaseCleanupFrame = null; - } - const cleanup = this._missingBrowserPendingReleaseCleanup; - this._missingBrowserPendingReleaseCleanup = null; - this.applyMissingBrowserSplitReleaseCleanup(cleanup); - }, - - deferMissingBrowserSplitReleaseUi(splitter, shouldCleanup = true) { - if (this._missingBrowserResizeClassFrame) { - cancelAnimationFrame(this._missingBrowserResizeClassFrame); - this._missingBrowserResizeClassFrame = null; - } - this._missingBrowserPendingReleaseCleanup = { - splitter, - shouldCleanup - }; - const release = () => { - this._missingBrowserReleaseCleanupFrame = null; - const cleanup = this._missingBrowserPendingReleaseCleanup; - this._missingBrowserPendingReleaseCleanup = null; - this.applyMissingBrowserSplitReleaseCleanup(cleanup); - }; - if (typeof requestAnimationFrame === 'function') { - this._missingBrowserReleaseCleanupFrame = requestAnimationFrame(() => { - this._missingBrowserReleaseCleanupFrame = requestAnimationFrame(release); - }); - } else { - release(); + if (cleanup.splitter?.style) { + cleanup.splitter.style.transform = ''; + cleanup.splitter.style.willChange = ''; } }, @@ -1264,7 +1246,7 @@ export const missingBrowserMethods = { this.setMissingBrowserDetailWidth(browser, currentWidth + delta); }, - applyMissingBrowserDetailWidth(target, width, { skipIfUnchanged = false, browserWidth = null, availableWidth = null, splitBounds = null } = {}) { + applyMissingBrowserDetailWidth(target, width, { skipIfUnchanged = false, browserWidth = null, splitBounds = null } = {}) { const detailPane = target instanceof HTMLElement && target.classList.contains('mr-missing-detail-pane') ? target : target?.querySelector?.('.mr-missing-detail-pane'); @@ -1282,26 +1264,26 @@ export const missingBrowserMethods = { : Number.isFinite(measuredWidth) && measuredWidth > 0 ? this.getMissingBrowserSplitBoundsForWidth(measuredWidth) : this.getMissingBrowserSplitBounds(browser); - const available = Number.isFinite(Number(availableWidth)) && Number(availableWidth) > 0 - ? Number(availableWidth) - : bounds.available; const nextWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, width))); - const listWidth = Math.max(1, Math.round(available - nextWidth)); const detailValue = `${nextWidth}px`; - const listValue = `${listWidth}px`; + const listValue = '0px'; if ( skipIfUnchanged && detailPane.style.flexBasis === detailValue - && (!(listPane instanceof HTMLElement) || listPane.style.flexBasis === listValue) + && (!(listPane instanceof HTMLElement) || ( + listPane.style.flexBasis === listValue + && listPane.style.flexGrow === '1' + && listPane.style.flexShrink === '1' + )) ) { return false; } if (listPane instanceof HTMLElement) { if (listPane.style.flexBasis !== listValue) listPane.style.flexBasis = listValue; - if (listPane.style.flexGrow !== '0') listPane.style.flexGrow = '0'; - if (listPane.style.flexShrink !== '0') listPane.style.flexShrink = '0'; + if (listPane.style.flexGrow !== '1') listPane.style.flexGrow = '1'; + if (listPane.style.flexShrink !== '1') listPane.style.flexShrink = '1'; } if (detailPane.style.flexBasis !== detailValue) detailPane.style.flexBasis = detailValue; if (detailPane.style.flexGrow !== '0') detailPane.style.flexGrow = '0';