Skip to content

Commit

Permalink
lists: support user selection in lists, and adopt it in the debug con…
Browse files Browse the repository at this point in the history
…sole (#236534)

- Adds a `userSelection` option on lists that can be used to control behavior
- Uses the DND logic for handling scrolling near the top and bottom
- Preserved selected list elements while they're selected to ensure they
  can be accurately copied.
- The DOM events we get around selection are pretty poor. I support mouse
  here but I'm unclear if/how touch events should be handled.

![](https://memes.peet.io/img/24-12-f06c680d-f209-476b-8945-b6fc33efe502.mp4)

Fixes #228432, cc @joaomoreno
  • Loading branch information
connor4312 authored Dec 19, 2024
1 parent 03e9e31 commit dd86be1
Show file tree
Hide file tree
Showing 3 changed files with 94 additions and 6 deletions.
98 changes: 92 additions & 6 deletions src/vs/base/browser/ui/list/listView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import { DataTransfers, IDragAndDropData } from '../../dnd.js';
import { $, addDisposableListener, animate, Dimension, getContentHeight, getContentWidth, getTopLeftOffset, getWindow, isAncestor, isHTMLElement, isSVGElement, scheduleAtNextAnimationFrame } from '../../dom.js';
import { $, addDisposableListener, animate, Dimension, getContentHeight, getContentWidth, getDocument, getTopLeftOffset, getWindow, isAncestor, isHTMLElement, isSVGElement, scheduleAtNextAnimationFrame } from '../../dom.js';
import { DomEmitter } from '../../event.js';
import { IMouseWheelEvent } from '../../mouseEvent.js';
import { EventType as TouchEventType, Gesture, GestureEvent } from '../../touch.js';
Expand Down Expand Up @@ -82,6 +82,7 @@ export interface IListViewOptions<T> extends IListViewOptionsUpdate {
readonly setRowHeight?: boolean;
readonly supportDynamicHeights?: boolean;
readonly mouseSupport?: boolean;
readonly userSelection?: boolean;
readonly accessibilityProvider?: IListViewAccessibilityProvider<T>;
readonly transformOptimization?: boolean;
readonly alwaysConsumeMouseWheel?: boolean;
Expand All @@ -105,7 +106,7 @@ const DefaultOptions = {
horizontalScrolling: false,
transformOptimization: true,
alwaysConsumeMouseWheel: true,
};
} satisfies IListViewOptions<any>;

export class ElementsDragAndDropData<T, TContext = void> implements IDragAndDropData {

Expand Down Expand Up @@ -321,6 +322,8 @@ export class ListView<T> implements IListView<T> {
private currentDragFeedbackPosition: ListDragOverEffectPosition | undefined;
private currentDragFeedbackDisposable: IDisposable = Disposable.None;
private onDragLeaveTimeout: IDisposable = Disposable.None;
private currentSelectionDisposable: IDisposable = Disposable.None;
private currentSelectionBounds: IRange | undefined;

private readonly disposables: DisposableStore = new DisposableStore();

Expand Down Expand Up @@ -369,7 +372,7 @@ export class ListView<T> implements IListView<T> {
container: HTMLElement,
private virtualDelegate: IListVirtualDelegate<T>,
renderers: IListRenderer<any /* TODO@joao */, any>[],
options: IListViewOptions<T> = DefaultOptions as IListViewOptions<T>
options: IListViewOptions<T> = DefaultOptions
) {
if (options.horizontalScrolling && options.supportDynamicHeights) {
throw new Error('Horizontal scrolling and dynamic heights not supported simultaneously');
Expand Down Expand Up @@ -444,6 +447,12 @@ export class ListView<T> implements IListView<T> {
this.disposables.add(addDisposableListener(this.domNode, 'drop', e => this.onDrop(this.toDragEvent(e))));
this.disposables.add(addDisposableListener(this.domNode, 'dragleave', e => this.onDragLeave(this.toDragEvent(e))));
this.disposables.add(addDisposableListener(this.domNode, 'dragend', e => this.onDragEnd(e)));
if (options.userSelection) {
if (options.dnd) {
throw new Error('DND and user selection cannot be used simultaneously');
}
this.disposables.add(addDisposableListener(this.domNode, 'mousedown', e => this.onPotentialSelectionStart(e)));
}

this.setRowLineHeight = options.setRowLineHeight ?? DefaultOptions.setRowLineHeight;
this.setRowHeight = options.setRowHeight ?? DefaultOptions.setRowHeight;
Expand Down Expand Up @@ -768,7 +777,7 @@ export class ListView<T> implements IListView<T> {
}

get firstVisibleIndex(): number {
const range = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
const range = this.getVisibleRange(this.lastRenderTop, this.lastRenderHeight);
return range.start;
}

Expand Down Expand Up @@ -1168,6 +1177,73 @@ export class ListView<T> implements IListView<T> {
this.dnd.onDragStart?.(this.currentDragData, event);
}

private onPotentialSelectionStart(e: MouseEvent) {
this.currentSelectionDisposable.dispose();
const doc = getDocument(this.domNode);

// Set up both the 'movement store' for watching the mouse, and the
// 'selection store' which lasts as long as there's a selection, even
// after the usr has stopped modifying it.
const selectionStore = this.currentSelectionDisposable = new DisposableStore();
const movementStore = selectionStore.add(new DisposableStore());

// The selection events we get from the DOM are fairly limited and we lack a 'selection end' event.
// Selection events also don't tell us where the input doing the selection is. So, make a poor
// assumption that a user is using the mouse, and base our events on that.
movementStore.add(addDisposableListener(this.domNode, 'selectstart', () => {
this.setupDragAndDropScrollTopAnimation(e);

movementStore.add(addDisposableListener(doc, 'mousemove', e => this.setupDragAndDropScrollTopAnimation(e)));

// The selection is cleared either on mouseup if there's no selection, or on next mousedown
// when `this.currentSelectionDisposable` is reset.
selectionStore.add(toDisposable(() => {
const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
this.currentSelectionBounds = undefined;
this.render(previousRenderRange, this.lastRenderTop, this.lastRenderHeight, undefined, undefined);
}));
selectionStore.add(addDisposableListener(doc, 'selectionchange', () => {
const selection = doc.getSelection();
if (!selection) {
return;
}

let start = this.getIndexOfListElement(selection.anchorNode as HTMLElement);
let end = this.getIndexOfListElement(selection.focusNode as HTMLElement);
if (start !== undefined && end !== undefined) {
if (end < start) {
[start, end] = [end, start];
}
this.currentSelectionBounds = { start, end };
}
}));
}));

movementStore.add(addDisposableListener(doc, 'mouseup', () => {
movementStore.dispose();

if (doc.getSelection()?.isCollapsed !== false) {
selectionStore.dispose();
}
}));
}

private getIndexOfListElement(element: HTMLElement | null): number | undefined {
if (!element || !this.domNode.contains(element)) {
return undefined;
}

while (element && element !== this.domNode) {
if (element.dataset?.index) {
return Number(element.dataset.index);
}

element = element.parentElement;
}

return undefined;
}

private onDragOver(event: IListDragEvent<T>): boolean {
event.browserEvent.preventDefault(); // needed so that the drop event fires (https://stackoverflow.com/questions/21339924/drop-event-not-firing-in-chrome)

Expand Down Expand Up @@ -1327,7 +1403,7 @@ export class ListView<T> implements IListView<T> {

// DND scroll top animation

private setupDragAndDropScrollTopAnimation(event: DragEvent): void {
private setupDragAndDropScrollTopAnimation(event: DragEvent | MouseEvent): void {
if (!this.dragOverAnimationDisposable) {
const viewTop = getTopLeftOffset(this.domNode).top;
this.dragOverAnimationDisposable = animate(getWindow(this.domNode), this.animateDragAndDropScrollTop.bind(this, viewTop));
Expand Down Expand Up @@ -1401,13 +1477,23 @@ export class ListView<T> implements IListView<T> {
return undefined;
}

protected getRenderRange(renderTop: number, renderHeight: number): IRange {
private getVisibleRange(renderTop: number, renderHeight: number): IRange {
return {
start: this.rangeMap.indexAt(renderTop),
end: this.rangeMap.indexAfter(renderTop + renderHeight - 1)
};
}

protected getRenderRange(renderTop: number, renderHeight: number): IRange {
const range = this.getVisibleRange(renderTop, renderHeight);
if (this.currentSelectionBounds) {
range.start = Math.min(range.start, this.currentSelectionBounds.start);
range.end = Math.max(range.end, this.currentSelectionBounds.end + 1);
}

return range;
}

/**
* Given a stable rendered state, checks every rendered element whether it needs
* to be probed for dynamic height. Adjusts scroll height and top if necessary.
Expand Down
1 change: 1 addition & 0 deletions src/vs/base/browser/ui/list/listWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,7 @@ export interface IListOptions<T> extends IListOptionsUpdate {
readonly setRowHeight?: boolean;
readonly supportDynamicHeights?: boolean;
readonly mouseSupport?: boolean;
readonly userSelection?: boolean;
readonly horizontalScrolling?: boolean;
readonly scrollByPage?: boolean;
readonly transformOptimization?: boolean;
Expand Down
1 change: 1 addition & 0 deletions src/vs/workbench/contrib/debug/browser/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,7 @@ export class Repl extends FilterViewPane implements IHistoryNavigationWidget {
filter: this.filter,
accessibilityProvider: new ReplAccessibilityProvider(),
identityProvider,
userSelection: true,
mouseSupport: false,
findWidgetEnabled: true,
keyboardNavigationLabelProvider: { getKeyboardNavigationLabel: (e: IReplElement) => e.toString(true) },
Expand Down

0 comments on commit dd86be1

Please sign in to comment.