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
This commit is contained in:
Dariusz L
2026-07-28 01:25:07 +02:00
parent 643e198737
commit 8b2f9e81cb
4 changed files with 199 additions and 77 deletions
@@ -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;
+13 -1
View File
@@ -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;
+50 -13
View File
@@ -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
});
}
}
};
}
+45 -63
View File
@@ -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';