Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,7 @@ function resolveOptions(opts: ScrollableElementCreationOptions): ScrollableEleme
verticalScrollbarSize: (typeof opts.verticalScrollbarSize !== 'undefined' ? opts.verticalScrollbarSize : globalDefaultScrollbarSize),
verticalHasArrows: (typeof opts.verticalHasArrows !== 'undefined' ? opts.verticalHasArrows : false),
verticalSliderSize: (typeof opts.verticalSliderSize !== 'undefined' ? opts.verticalSliderSize : 0),
verticalScrollbarTopInset: (typeof opts.verticalScrollbarTopInset !== 'undefined' ? opts.verticalScrollbarTopInset : 0),

scrollByPage: (typeof opts.scrollByPage !== 'undefined' ? opts.scrollByPage : false)
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ export interface ScrollableElementCreationOptions {
* Defaults to false.
*/
verticalHasArrows?: boolean;
/**
* Space (in px) left above the vertical scrollbar, e.g. for a pinned header.
* Defaults to 0.
*/
verticalScrollbarTopInset?: number;
/**
* Scroll gutter clicks move by page vs. jump to position.
* Defaults to false.
Expand Down Expand Up @@ -166,5 +171,6 @@ export interface ScrollableElementResolvedOptions {
verticalScrollbarSize: number;
verticalSliderSize: number;
verticalHasArrows: boolean;
verticalScrollbarTopInset: number;
scrollByPage: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export class ScrollbarState {
*/
private readonly _arrowSize: number;

/**
* Space before the track, which starts this far into the scrollable area.
*/
private readonly _leadingInset: number;

// --- variables
/**
* For the vertical scrollbar: the viewport height.
Expand Down Expand Up @@ -62,7 +67,8 @@ export class ScrollbarState {
private _computedSliderRatio: number;
private _computedSliderPosition: number;

constructor(arrowSize: number, scrollbarSize: number, oppositeScrollbarSize: number, visibleSize: number, scrollSize: number, scrollPosition: number) {
constructor(arrowSize: number, scrollbarSize: number, oppositeScrollbarSize: number, visibleSize: number, scrollSize: number, scrollPosition: number, leadingInset: number = 0) {
this._leadingInset = Math.round(leadingInset);
this._scrollbarSize = Math.round(scrollbarSize);
this._oppositeScrollbarSize = Math.round(oppositeScrollbarSize);
this._arrowSize = Math.round(arrowSize);
Expand All @@ -81,7 +87,7 @@ export class ScrollbarState {
}

public clone(): ScrollbarState {
return new ScrollbarState(this._arrowSize, this._scrollbarSize, this._oppositeScrollbarSize, this._visibleSize, this._scrollSize, this._scrollPosition);
return new ScrollbarState(this._arrowSize, this._scrollbarSize, this._oppositeScrollbarSize, this._visibleSize, this._scrollSize, this._scrollPosition, this._leadingInset);
}

public setVisibleSize(visibleSize: number): boolean {
Expand Down Expand Up @@ -122,8 +128,8 @@ export class ScrollbarState {
this._oppositeScrollbarSize = Math.round(oppositeScrollbarSize);
}

private static _computeValues(oppositeScrollbarSize: number, arrowSize: number, visibleSize: number, scrollSize: number, scrollPosition: number) {
const computedAvailableSize = Math.max(0, visibleSize - oppositeScrollbarSize);
private static _computeValues(oppositeScrollbarSize: number, arrowSize: number, visibleSize: number, scrollSize: number, scrollPosition: number, leadingInset: number) {
const computedAvailableSize = Math.max(0, visibleSize - oppositeScrollbarSize - leadingInset);
const computedRepresentableSize = Math.max(0, computedAvailableSize - 2 * arrowSize);
const computedIsNeeded = (scrollSize > 0 && scrollSize > visibleSize);

Expand Down Expand Up @@ -156,14 +162,18 @@ export class ScrollbarState {
}

private _refreshComputedValues(): void {
const r = ScrollbarState._computeValues(this._oppositeScrollbarSize, this._arrowSize, this._visibleSize, this._scrollSize, this._scrollPosition);
const r = ScrollbarState._computeValues(this._oppositeScrollbarSize, this._arrowSize, this._visibleSize, this._scrollSize, this._scrollPosition, this._leadingInset);
this._computedAvailableSize = r.computedAvailableSize;
this._computedIsNeeded = r.computedIsNeeded;
this._computedSliderSize = r.computedSliderSize;
this._computedSliderRatio = r.computedSliderRatio;
this._computedSliderPosition = r.computedSliderPosition;
}

public getLeadingInset(): number {
return this._leadingInset;
}

public getArrowSize(): number {
return this._arrowSize;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export class VerticalScrollbar extends AbstractScrollbar {
0,
scrollDimensions.height,
scrollDimensions.scrollHeight,
scrollPosition.scrollTop
scrollPosition.scrollTop,
options.verticalScrollbarTopInset
),
visibility: options.vertical,
extraScrollbarClassName: 'vertical',
Expand Down Expand Up @@ -77,7 +78,7 @@ export class VerticalScrollbar extends AbstractScrollbar {
this.domNode.setWidth(smallSize);
this.domNode.setHeight(largeSize);
this.domNode.setRight(0);
this.domNode.setTop(0);
this.domNode.setTop(this._scrollbarState.getLeadingInset());
}

public onDidScroll(e: ScrollEvent): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ICodeEditor } from '../../editorBrowser.js';
import { ObservableElementSizeObserver } from '../diffEditor/utils.js';
import { DiffEditorItemTemplate, TemplateData } from './diffEditorItemTemplate.js';
import { IDocumentDiffItem } from './model.js';
import { MULTI_DIFF_RESOURCE_HEADER_HEIGHT } from './multiDiffEditorResourceHeader.js';
import { DocumentDiffItemViewModel, MultiDiffEditorViewModel } from './multiDiffEditorViewModel.js';
import { RevealOptions } from './multiDiffEditorWidget.js';
import { ObjectPool } from './objectPool.js';
Expand Down Expand Up @@ -102,11 +103,14 @@ export class MultiDiffEditorWidgetImpl extends Disposable {
smoothScrollDuration: 100,
}));
this._scrollableElement = this._register(new SmoothScrollableElement(this._scrollableElements.root, {
vertical: ScrollbarVisibility.Auto,
vertical: this._workbenchUIElementFactory.alwaysShowScrollbars ? ScrollbarVisibility.Visible : ScrollbarVisibility.Auto,
horizontal: this._workbenchUIElementFactory.horizontalScrollbar === 'hidden'
? ScrollbarVisibility.Hidden
: ScrollbarVisibility.Auto,
: this._workbenchUIElementFactory.alwaysShowScrollbars ? ScrollbarVisibility.Visible : ScrollbarVisibility.Auto,
useShadows: false,
verticalScrollbarTopInset: this._workbenchUIElementFactory.scrollbarBelowResourceHeader && !this._workbenchUIElementFactory.hideResourceHeader
? MULTI_DIFF_RESOURCE_HEADER_HEIGHT
: 0,
}, this._scrollable));
this._elements = h('div.monaco-component.multiDiffEditor', {}, [
h('div', {}, [this._scrollableElement.getDomNode()]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export interface IWorkbenchUIElementFactory {

/** Controls the outer multi-diff scroller for compact embedded hosts. */
readonly horizontalScrollbar?: 'auto' | 'hidden';
/** Keep needed scrollbars visible even when the pointer is elsewhere. */
readonly alwaysShowScrollbars?: boolean;
/** Start the vertical scrollbar below a resource header pinned at the top. */
readonly scrollbarBelowResourceHeader?: boolean;

/**
* External host for the inner editors' overflowing widgets (hover,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,9 @@ export class ReviewFilesDiffView extends Disposable {
false,
undefined,
);
factory.alwaysShowScrollbars = Boolean(document);
// A document embed shows one file, so its header stays pinned at the top.
factory.scrollbarBelowResourceHeader = Boolean(document);
this.headerFactory = factory;

this.widget = this._register(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export class ReviewMultiDiffUIElementFactory
implements IWorkbenchUIElementFactory
{

alwaysShowScrollbars = false;
scrollbarBelowResourceHeader = false;

get headerClickToCollapse(): boolean {
return !this.hideResourceHeader;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/review/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from "./debug-settings";
import { DiffLayoutControl } from "./diff-layout-control";
import { ReviewDiffView } from "./DiffView";
import { useDocumentEmbedScroll } from "./document-embed-scroll";
import { useReviewSession } from "./host/review-session";
import { DiscordIcon, MarkerUnderline, SettingsSlidersIcon } from "./icons";
import { ReviewPanelHost } from "./review-components";
Expand Down Expand Up @@ -370,6 +371,7 @@ function ReviewLayoutContent({
containerRef: appRef,
});

useDocumentEmbedScroll(scrollRegionRef);
const viewStateSync = useReviewViewStateSync({ scrollRegionRef, panelStore });
const hasChangeRange = range.baseCommit !== range.headCommit;

Expand Down
123 changes: 123 additions & 0 deletions packages/review/app/src/document-embed-scroll.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { afterEach, expect, it, vi } from "vitest";

import { routeDocumentEmbedScroll } from "./document-embed-scroll";

let dispose: (() => void) | undefined;

afterEach(() => dispose?.());

function mount(embedClass: string, inDocument = true) {
const region = document.createElement("section");
region.style.cssText =
"width:300px;height:200px;overflow:auto;line-height:20px";
region.innerHTML = `<article class="${inDocument ? "review-document" : "diagram-tour-stage"}" style="height:2000px;width:700px">
<div class="${embedClass}" style="width:200px;height:100px;overflow:auto">
<div style="height:600px;width:900px">Content</div>
</div>
</article>`;
document.body.append(region);
dispose = routeDocumentEmbedScroll(region);
const embed = region.querySelector<HTMLElement>(`.${embedClass}`)!;
const content = embed.firstElementChild!;

return { region, embed, content };
}

it.each(["sequence-diagram", "flow-diagram", "database-lens", "code-peek"])(
"scrolls the document in both directions over %s without scrolling the embed",
(embedClass) => {
const { region, embed, content } = mount(embedClass);
const embeddedWheel = vi.fn<(event: WheelEvent) => void>();
embed.addEventListener("wheel", embeddedWheel);
region.scrollTop = 300;
embed.scrollTop = 50;
embed.scrollLeft = 60;

content.dispatchEvent(
new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
deltaY: 120,
deltaX: 30,
}),
);
expect(region.scrollTop).toBe(420);
expect(region.scrollLeft).toBe(30);
content.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -80 }),
);
expect(region.scrollTop).toBe(340);
expect(embed.scrollTop).toBe(50);
expect(embed.scrollLeft).toBe(60);
expect(embeddedWheel).not.toHaveBeenCalled();
},
);

it("normalizes line and page wheel deltas", () => {
const { region, content } = mount("code-peek");
content.dispatchEvent(
new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
deltaY: 3,
deltaMode: WheelEvent.DOM_DELTA_LINE,
}),
);
expect(region.scrollTop).toBe(60);
content.dispatchEvent(
new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
deltaY: 1,
deltaMode: WheelEvent.DOM_DELTA_PAGE,
}),
);
expect(region.scrollTop).toBe(60 + region.clientHeight);
});

it("leaves fullscreen tours and pinch-to-zoom alone", () => {
const { region, content } = mount("sequence-diagram", false);

const wheel = new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
deltaY: 100,
});

content.dispatchEvent(wheel);
expect(wheel.defaultPrevented).toBe(false);
expect(region.scrollTop).toBe(0);
region.firstElementChild!.className = "review-document";

const pinch = new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
deltaY: 100,
ctrlKey: true,
});

content.dispatchEvent(pinch);
expect(pinch.defaultPrevented).toBe(false);
expect(region.scrollTop).toBe(0);
});

it("continues scrolling the document when the embed fits and removes the handler on cleanup", () => {
const { region, embed, content } = mount("sequence-diagram");
(content as HTMLElement).style.cssText = "height:20px;width:20px";
expect(embed.scrollHeight).toBe(embed.clientHeight);
content.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 80 }),
);
expect(region.scrollTop).toBe(80);
dispose?.();

const wheel = new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
deltaY: 100,
});

content.dispatchEvent(wheel);
expect(wheel.defaultPrevented).toBe(false);
expect(region.scrollTop).toBe(80);
});
51 changes: 51 additions & 0 deletions packages/review/app/src/document-embed-scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { type RefObject, useEffect } from "react";

/** Embedded content is scrolled with its scrollbars; wheel gestures belong
* to the document, even when an embedded native editor handles wheel input. */
export function useDocumentEmbedScroll(
regionRef: RefObject<HTMLElement | null>,
) {
useEffect(() => {
const region = regionRef.current;

if (!region) return;

return routeDocumentEmbedScroll(region);
}, [regionRef]);
}

export function routeDocumentEmbedScroll(region: HTMLElement) {
const onWheel = (event: WheelEvent) => {
if (event.ctrlKey || !(event.target instanceof Element)) return;

const embed = event.target.closest(
".sequence-diagram, .flow-diagram, .database-lens, .code-peek",
);

if (!embed?.closest(".review-document") || !region.contains(embed)) return;

// Capture before React Flow/Monaco or native overflow scrolling can consume
// the gesture. Preserve trackpad deltas and normalize mouse line/page units.
event.preventDefault();
event.stopPropagation();
const lineHeight = parseFloat(getComputedStyle(region).lineHeight) || 20;
const unit = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? lineHeight : 1;
region.scrollBy({
left:
event.deltaX *
(event.deltaMode === WheelEvent.DOM_DELTA_PAGE
? region.clientWidth
: unit),
top:
event.deltaY *
(event.deltaMode === WheelEvent.DOM_DELTA_PAGE
? region.clientHeight
: unit),
behavior: "instant",
});
};

region.addEventListener("wheel", onWheel, { capture: true, passive: false });

return () => region.removeEventListener("wheel", onWheel, { capture: true });
}
Loading
Loading