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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user