Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/base/browser/ui/list/listView.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { DataTransfers, IDragAndDropData } from '../../dnd.js';
7
import { addDisposableListener, animate, Dimension, getActiveElement, getContentHeight, getContentWidth, getDocument, getTopLeftOffset, getWindow, isAncestor, isHTMLElement, isSVGElement, scheduleAtNextAnimationFrame } from '../../dom.js';
8
import { DomEmitter } from '../../event.js';
9
import { IMouseWheelEvent } from '../../mouseEvent.js';
10
import { EventType as TouchEventType, Gesture, GestureEvent } from '../../touch.js';
11
import { SmoothScrollableElement } from '../scrollbar/scrollableElement.js';
12
import { distinct, equals, splice } from '../../../common/arrays.js';
13
import { Delayer, disposableTimeout } from '../../../common/async.js';
14
import { memoize } from '../../../common/decorators.js';
15
import { Emitter, Event, IValueWithChangeEvent } from '../../../common/event.js';
16
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../common/lifecycle.js';
17
import { IRange, Range } from '../../../common/range.js';
18
import { INewScrollDimensions, Scrollable, ScrollbarVisibility, ScrollEvent } from '../../../common/scrollable.js';
19
import { ISpliceable } from '../../../common/sequence.js';
20
import { IListDragAndDrop, IListDragEvent, IListGestureEvent, IListMouseEvent, IListRenderer, IListTouchEvent, IListVirtualDelegate, ListDragOverEffectPosition, ListDragOverEffectType } from './list.js';
21
import { IRangeMap, RangeMap, shift } from './rangeMap.js';
22
import { IRow, RowCache } from './rowCache.js';
23
import { BugIndicatingError } from '../../../common/errors.js';
24
import { AriaRole } from '../aria/aria.js';
25
import { ScrollableElementChangeOptions } from '../scrollbar/scrollableElementOptions.js';
26
import { clamp } from '../../../common/numbers.js';
27
import { applyDragImage } from '../dnd/dnd.js';
28
29
interface IItem<T> {
30
readonly id: string;
31
readonly element: T;
32
readonly templateId: string;
33
row: IRow | null;
34
size: number;
35
width: number | undefined;
36
hasDynamicHeight: boolean;
37
lastDynamicHeightWidth: number | undefined;
38
uri: string | undefined;
39
dropTarget: boolean;
40
dragStartDisposable: IDisposable;
41
checkedDisposable: IDisposable;
42
stale: boolean;
43
}
44
45
const StaticDND = {
46
CurrentDragAndDropData: undefined as IDragAndDropData | undefined
47
};
48
49
export interface IListViewDragAndDrop<T> extends IListDragAndDrop<T> {
50
getDragElements(element: T): T[];
51
}
52
53
export const enum ListViewTargetSector {
54
// drop position relative to the top of the item
55
TOP = 0, // [0%-25%)
56
CENTER_TOP = 1, // [25%-50%)
57
CENTER_BOTTOM = 2, // [50%-75%)
58
BOTTOM = 3 // [75%-100%)
59
}
60
61
export interface IListViewAccessibilityProvider<T> {
62
getSetSize?(element: T, index: number, listLength: number): number;
63
getPosInSet?(element: T, index: number): number;
64
getRole?(element: T): AriaRole | undefined;
65
isChecked?(element: T): boolean | IValueWithChangeEvent<boolean> | undefined;
66
}
67
68
export interface IListViewOptionsUpdate {
69
readonly smoothScrolling?: boolean;
70
readonly horizontalScrolling?: boolean;
71
readonly scrollByPage?: boolean;
72
readonly mouseWheelScrollSensitivity?: number;
73
readonly fastScrollSensitivity?: number;
74
readonly paddingTop?: number;
75
readonly paddingBottom?: number;
76
}
77
78
export interface IListViewOptions<T> extends IListViewOptionsUpdate {
79
readonly dnd?: IListViewDragAndDrop<T>;
80
readonly useShadows?: boolean;
81
readonly verticalScrollMode?: ScrollbarVisibility;
82
readonly setRowLineHeight?: boolean;
83
readonly setRowHeight?: boolean;
84
readonly supportDynamicHeights?: boolean;
85
readonly mouseSupport?: boolean;
86
readonly userSelection?: boolean;
87
readonly accessibilityProvider?: IListViewAccessibilityProvider<T>;
88
readonly transformOptimization?: boolean;
89
readonly alwaysConsumeMouseWheel?: boolean;
90
readonly initialSize?: Dimension;
91
readonly scrollToActiveElement?: boolean;
92
}
93
94
const DefaultOptions = {
95
useShadows: true,
96
verticalScrollMode: ScrollbarVisibility.Auto,
97
setRowLineHeight: true,
98
setRowHeight: true,
99
supportDynamicHeights: false,
100
dnd: {
101
getDragElements<T>(e: T) { return [e]; },
102
getDragURI() { return null; },
103
onDragStart(): void { },
104
onDragOver() { return false; },
105
drop() { },
106
dispose() { }
107
},
108
horizontalScrolling: false,
109
transformOptimization: true,
110
alwaysConsumeMouseWheel: true,
111
} satisfies IListViewOptions<any>;
112
113
export class ElementsDragAndDropData<T, TContext = void> implements IDragAndDropData {
114
115
readonly elements: T[];
116
117
private _context: TContext | undefined;
118
public get context(): TContext | undefined {
119
return this._context;
120
}
121
public set context(value: TContext | undefined) {
122
this._context = value;
123
}
124
125
constructor(elements: T[]) {
126
this.elements = elements;
127
}
128
129
update(): void { }
130
131
getData(): T[] {
132
return this.elements;
133
}
134
}
135
136
export class ExternalElementsDragAndDropData<T> implements IDragAndDropData {
137
138
readonly elements: T[];
139
140
constructor(elements: T[]) {
141
this.elements = elements;
142
}
143
144
update(): void { }
145
146
getData(): T[] {
147
return this.elements;
148
}
149
}
150
151
export class NativeDragAndDropData implements IDragAndDropData {
152
153
readonly types: any[];
154
readonly files: any[];
155
156
constructor() {
157
this.types = [];
158
this.files = [];
159
}
160
161
update(dataTransfer: DataTransfer): void {
162
if (dataTransfer.types) {
163
this.types.splice(0, this.types.length, ...dataTransfer.types);
164
}
165
166
if (dataTransfer.files) {
167
this.files.splice(0, this.files.length);
168
169
for (let i = 0; i < dataTransfer.files.length; i++) {
170
const file = dataTransfer.files.item(i);
171
172
if (file && (file.size || file.type)) {
173
this.files.push(file);
174
}
175
}
176
}
177
}
178
179
getData() {
180
return {
181
types: this.types,
182
files: this.files
183
};
184
}
185
}
186
187
function equalsDragFeedback(f1: number[] | undefined, f2: number[] | undefined): boolean {
188
if (Array.isArray(f1) && Array.isArray(f2)) {
189
return equals(f1, f2);
190
}
191
192
return f1 === f2;
193
}
194
195
class ListViewAccessibilityProvider<T> implements Required<IListViewAccessibilityProvider<T>> {
196
197
readonly getSetSize: (element: T, index: number, listLength: number) => number;
198
readonly getPosInSet: (element: T, index: number) => number;
199
readonly getRole: (element: T) => AriaRole | undefined;
200
readonly isChecked: (element: T) => boolean | IValueWithChangeEvent<boolean> | undefined;
201
202
constructor(accessibilityProvider?: IListViewAccessibilityProvider<T>) {
203
if (accessibilityProvider?.getSetSize) {
204
this.getSetSize = accessibilityProvider.getSetSize.bind(accessibilityProvider);
205
} else {
206
this.getSetSize = (e, i, l) => l;
207
}
208
209
if (accessibilityProvider?.getPosInSet) {
210
this.getPosInSet = accessibilityProvider.getPosInSet.bind(accessibilityProvider);
211
} else {
212
this.getPosInSet = (e, i) => i + 1;
213
}
214
215
if (accessibilityProvider?.getRole) {
216
this.getRole = accessibilityProvider.getRole.bind(accessibilityProvider);
217
} else {
218
this.getRole = _ => 'listitem';
219
}
220
221
if (accessibilityProvider?.isChecked) {
222
this.isChecked = accessibilityProvider.isChecked.bind(accessibilityProvider);
223
} else {
224
this.isChecked = _ => undefined;
225
}
226
}
227
}
228
229
export interface IListView<T> extends ISpliceable<T>, IDisposable {
230
readonly domId: string;
231
readonly domNode: HTMLElement;
232
readonly containerDomNode: HTMLElement;
233
readonly scrollableElementDomNode: HTMLElement;
234
readonly length: number;
235
readonly contentHeight: number;
236
readonly contentWidth: number;
237
readonly onDidChangeContentHeight: Event<number>;
238
readonly onDidChangeContentWidth: Event<number>;
239
readonly renderHeight: number;
240
readonly scrollHeight: number;
241
readonly firstVisibleIndex: number;
242
readonly firstMostlyVisibleIndex: number;
243
readonly lastVisibleIndex: number;
244
onDidScroll: Event<ScrollEvent>;
245
onWillScroll: Event<ScrollEvent>;
246
onMouseClick: Event<IListMouseEvent<T>>;
247
onMouseDblClick: Event<IListMouseEvent<T>>;
248
onMouseMiddleClick: Event<IListMouseEvent<T>>;
249
onMouseUp: Event<IListMouseEvent<T>>;
250
onMouseDown: Event<IListMouseEvent<T>>;
251
onMouseOver: Event<IListMouseEvent<T>>;
252
onMouseMove: Event<IListMouseEvent<T>>;
253
onMouseOut: Event<IListMouseEvent<T>>;
254
onContextMenu: Event<IListMouseEvent<T>>;
255
onTouchStart: Event<IListTouchEvent<T>>;
256
onTap: Event<IListGestureEvent<T>>;
257
element(index: number): T;
258
domElement(index: number): HTMLElement | null;
259
getElementDomId(index: number): string;
260
elementHeight(index: number): number;
261
elementTop(index: number): number;
262
indexOf(element: T): number;
263
indexAt(position: number): number;
264
indexAfter(position: number): number;
265
updateOptions(options: IListViewOptionsUpdate): void;
266
getScrollTop(): number;
267
setScrollTop(scrollTop: number, reuseAnimation?: boolean): void;
268
getScrollLeft(): number;
269
setScrollLeft(scrollLeft: number): void;
270
delegateScrollFromMouseWheelEvent(browserEvent: IMouseWheelEvent): void;
271
delegateVerticalScrollbarPointerDown(browserEvent: PointerEvent): void;
272
updateWidth(index: number): void;
273
updateElementHeight(index: number, size: number | undefined, anchorIndex: number | null): void;
274
rerender(): void;
275
layout(height?: number, width?: number): void;
276
}
277
278
/**
279
* The {@link ListView} is a virtual scrolling engine.
280
*
281
* Given that it only renders elements within its viewport, it can hold large
282
* collections of elements and stay very performant. The performance bottleneck
283
* usually lies within the user's rendering code for each element.
284
*
285
* @remarks It is a low-level widget, not meant to be used directly. Refer to the
286
* List widget instead.
287
*/
288
export class ListView<T> implements IListView<T> {
289
290
private static InstanceCount = 0;
291
readonly domId = `list_id_${++ListView.InstanceCount}`;
292
293
readonly domNode: HTMLElement;
294
295
private items: IItem<T>[];
296
private itemId: number;
297
protected rangeMap: IRangeMap;
298
private cache: RowCache<T>;
299
private renderers = new Map<string, IListRenderer<any /* TODO@joao */, any>>();
300
protected lastRenderTop: number;
301
protected lastRenderHeight: number;
302
private renderWidth = 0;
303
private rowsContainer: HTMLElement;
304
private scrollable: Scrollable;
305
private scrollableElement: SmoothScrollableElement;
306
private _scrollHeight: number = 0;
307
private scrollableElementUpdateDisposable: IDisposable | null = null;
308
private scrollableElementWidthDelayer = new Delayer<void>(50);
309
private splicing = false;
310
private dragOverAnimationDisposable: IDisposable | undefined;
311
private dragOverAnimationStopDisposable: IDisposable = Disposable.None;
312
private dragOverMouseY: number = 0;
313
private setRowLineHeight: boolean;
314
private setRowHeight: boolean;
315
private supportDynamicHeights: boolean;
316
private paddingBottom: number;
317
private accessibilityProvider: ListViewAccessibilityProvider<T>;
318
private scrollWidth: number | undefined;
319
320
private dnd: IListViewDragAndDrop<T>;
321
private canDrop: boolean = false;
322
private currentDragData: IDragAndDropData | undefined;
323
private currentDragFeedback: number[] | undefined;
324
private currentDragFeedbackPosition: ListDragOverEffectPosition | undefined;
325
private currentDragFeedbackDisposable: IDisposable = Disposable.None;
326
private onDragLeaveTimeout: IDisposable = Disposable.None;
327
private currentSelectionDisposable: IDisposable = Disposable.None;
328
private currentSelectionBounds: IRange | undefined;
329
private activeElement: HTMLElement | undefined;
330
331
private readonly disposables: DisposableStore = new DisposableStore();
332
333
private readonly _onDidChangeContentHeight = new Emitter<number>();
334
private readonly _onDidChangeContentWidth = new Emitter<number>();
335
readonly onDidChangeContentHeight: Event<number> = Event.latch(this._onDidChangeContentHeight.event, undefined, this.disposables);
336
readonly onDidChangeContentWidth: Event<number> = Event.latch(this._onDidChangeContentWidth.event, undefined, this.disposables);
337
get contentHeight(): number { return this.rangeMap.size; }
338
get contentWidth(): number { return this.scrollWidth ?? 0; }
339
340
get onDidScroll(): Event<ScrollEvent> { return this.scrollableElement.onScroll; }
341
get onWillScroll(): Event<ScrollEvent> { return this.scrollableElement.onWillScroll; }
342
get containerDomNode(): HTMLElement { return this.rowsContainer; }
343
get scrollableElementDomNode(): HTMLElement { return this.scrollableElement.getDomNode(); }
344
345
private _horizontalScrolling: boolean = false;
346
private get horizontalScrolling(): boolean { return this._horizontalScrolling; }
347
private set horizontalScrolling(value: boolean) {
348
if (value === this._horizontalScrolling) {
349
return;
350
}
351
352
if (value && this.supportDynamicHeights) {
353
throw new Error('Horizontal scrolling and dynamic heights not supported simultaneously');
354
}
355
356
this._horizontalScrolling = value;
357
this.domNode.classList.toggle('horizontal-scrolling', this._horizontalScrolling);
358
359
if (this._horizontalScrolling) {
360
for (const item of this.items) {
361
this.measureItemWidth(item);
362
}
363
364
this.updateScrollWidth();
365
this.scrollableElement.setScrollDimensions({ width: getContentWidth(this.domNode) });
366
this.rowsContainer.style.width = `${Math.max(this.scrollWidth || 0, this.renderWidth)}px`;
367
} else {
368
this.scrollableElementWidthDelayer.cancel();
369
this.scrollableElement.setScrollDimensions({ width: this.renderWidth, scrollWidth: this.renderWidth });
370
this.rowsContainer.style.width = '';
371
}
372
}
373
374
constructor(
375
container: HTMLElement,
376
private virtualDelegate: IListVirtualDelegate<T>,
377
renderers: IListRenderer<any /* TODO@joao */, any>[],
378
options: IListViewOptions<T> = DefaultOptions
379
) {
380
if (options.horizontalScrolling && options.supportDynamicHeights) {
381
throw new Error('Horizontal scrolling and dynamic heights not supported simultaneously');
382
}
383
384
this.items = [];
385
this.itemId = 0;
386
this.rangeMap = this.createRangeMap(options.paddingTop ?? 0);
387
388
for (const renderer of renderers) {
389
this.renderers.set(renderer.templateId, renderer);
390
}
391
392
this.cache = this.disposables.add(new RowCache(this.renderers));
393
394
this.lastRenderTop = 0;
395
this.lastRenderHeight = 0;
396
397
this.domNode = document.createElement('div');
398
this.domNode.className = 'monaco-list';
399
400
this.domNode.classList.add(this.domId);
401
this.domNode.tabIndex = 0;
402
403
this.domNode.classList.toggle('mouse-support', typeof options.mouseSupport === 'boolean' ? options.mouseSupport : true);
404
405
this._horizontalScrolling = options.horizontalScrolling ?? DefaultOptions.horizontalScrolling;
406
this.domNode.classList.toggle('horizontal-scrolling', this._horizontalScrolling);
407
408
this.paddingBottom = typeof options.paddingBottom === 'undefined' ? 0 : options.paddingBottom;
409
410
this.accessibilityProvider = new ListViewAccessibilityProvider(options.accessibilityProvider);
411
412
this.rowsContainer = document.createElement('div');
413
this.rowsContainer.className = 'monaco-list-rows';
414
415
const transformOptimization = options.transformOptimization ?? DefaultOptions.transformOptimization;
416
if (transformOptimization) {
417
this.rowsContainer.style.transform = 'translate3d(0px, 0px, 0px)';
418
this.rowsContainer.style.overflow = 'hidden';
419
this.rowsContainer.style.contain = 'strict';
420
}
421
422
this.disposables.add(Gesture.addTarget(this.rowsContainer));
423
424
this.scrollable = this.disposables.add(new Scrollable({
425
forceIntegerValues: true,
426
smoothScrollDuration: (options.smoothScrolling ?? false) ? 125 : 0,
427
scheduleAtNextAnimationFrame: cb => scheduleAtNextAnimationFrame(getWindow(this.domNode), cb)
428
}));
429
this.scrollableElement = this.disposables.add(new SmoothScrollableElement(this.rowsContainer, {
430
alwaysConsumeMouseWheel: options.alwaysConsumeMouseWheel ?? DefaultOptions.alwaysConsumeMouseWheel,
431
horizontal: ScrollbarVisibility.Auto,
432
vertical: options.verticalScrollMode ?? DefaultOptions.verticalScrollMode,
433
useShadows: options.useShadows ?? DefaultOptions.useShadows,
434
mouseWheelScrollSensitivity: options.mouseWheelScrollSensitivity,
435
fastScrollSensitivity: options.fastScrollSensitivity,
436
scrollByPage: options.scrollByPage
437
}, this.scrollable));
438
439
this.domNode.appendChild(this.scrollableElement.getDomNode());
440
container.appendChild(this.domNode);
441
442
this.scrollableElement.onScroll(this.onScroll, this, this.disposables);
443
this.disposables.add(addDisposableListener(this.rowsContainer, TouchEventType.Change, e => this.onTouchChange(e as GestureEvent)));
444
445
this.disposables.add(addDisposableListener(this.scrollableElement.getDomNode(), 'scroll', e => {
446
// Make sure the active element is scrolled into view
447
const element = (e.target as HTMLElement);
448
const scrollValue = element.scrollTop;
449
element.scrollTop = 0;
450
if (options.scrollToActiveElement) {
451
this.setScrollTop(this.scrollTop + scrollValue);
452
}
453
}));
454
455
this.disposables.add(addDisposableListener(this.domNode, 'dragover', e => this.onDragOver(this.toDragEvent(e))));
456
this.disposables.add(addDisposableListener(this.domNode, 'drop', e => this.onDrop(this.toDragEvent(e))));
457
this.disposables.add(addDisposableListener(this.domNode, 'dragleave', e => this.onDragLeave(this.toDragEvent(e))));
458
this.disposables.add(addDisposableListener(this.domNode, 'dragend', e => this.onDragEnd(e)));
459
if (options.userSelection) {
460
if (options.dnd) {
461
throw new Error('DND and user selection cannot be used simultaneously');
462
}
463
this.disposables.add(addDisposableListener(this.domNode, 'mousedown', e => this.onPotentialSelectionStart(e)));
464
}
465
466
this.setRowLineHeight = options.setRowLineHeight ?? DefaultOptions.setRowLineHeight;
467
this.setRowHeight = options.setRowHeight ?? DefaultOptions.setRowHeight;
468
this.supportDynamicHeights = options.supportDynamicHeights ?? DefaultOptions.supportDynamicHeights;
469
this.dnd = options.dnd ?? this.disposables.add(DefaultOptions.dnd);
470
471
this.layout(options.initialSize?.height, options.initialSize?.width);
472
if (options.scrollToActiveElement) {
473
this._setupFocusObserver(container);
474
}
475
}
476
477
private _setupFocusObserver(container: HTMLElement): void {
478
this.disposables.add(addDisposableListener(container, 'focus', () => {
479
const element = getActiveElement() as HTMLElement | null;
480
if (this.activeElement !== element && element !== null) {
481
this.activeElement = element;
482
this._scrollToActiveElement(this.activeElement, container);
483
}
484
}, true));
485
}
486
487
private _scrollToActiveElement(element: HTMLElement, container: HTMLElement) {
488
// The scroll event on the list only fires when scrolling down.
489
// If the active element is above the viewport, we need to scroll up.
490
const containerRect = container.getBoundingClientRect();
491
const elementRect = element.getBoundingClientRect();
492
493
const topOffset = elementRect.top - containerRect.top;
494
495
if (topOffset < 0) {
496
// Scroll up
497
this.setScrollTop(this.scrollTop + topOffset);
498
}
499
}
500
501
updateOptions(options: IListViewOptionsUpdate) {
502
if (options.paddingBottom !== undefined) {
503
this.paddingBottom = options.paddingBottom;
504
this.scrollableElement.setScrollDimensions({ scrollHeight: this.scrollHeight });
505
}
506
507
if (options.smoothScrolling !== undefined) {
508
this.scrollable.setSmoothScrollDuration(options.smoothScrolling ? 125 : 0);
509
}
510
511
if (options.horizontalScrolling !== undefined) {
512
this.horizontalScrolling = options.horizontalScrolling;
513
}
514
515
let scrollableOptions: ScrollableElementChangeOptions | undefined;
516
517
if (options.scrollByPage !== undefined) {
518
scrollableOptions = { ...(scrollableOptions ?? {}), scrollByPage: options.scrollByPage };
519
}
520
521
if (options.mouseWheelScrollSensitivity !== undefined) {
522
scrollableOptions = { ...(scrollableOptions ?? {}), mouseWheelScrollSensitivity: options.mouseWheelScrollSensitivity };
523
}
524
525
if (options.fastScrollSensitivity !== undefined) {
526
scrollableOptions = { ...(scrollableOptions ?? {}), fastScrollSensitivity: options.fastScrollSensitivity };
527
}
528
529
if (scrollableOptions) {
530
this.scrollableElement.updateOptions(scrollableOptions);
531
}
532
533
if (options.paddingTop !== undefined && options.paddingTop !== this.rangeMap.paddingTop) {
534
// trigger a rerender
535
const lastRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
536
const offset = options.paddingTop - this.rangeMap.paddingTop;
537
this.rangeMap.paddingTop = options.paddingTop;
538
539
this.render(lastRenderRange, Math.max(0, this.lastRenderTop + offset), this.lastRenderHeight, undefined, undefined, true);
540
this.setScrollTop(this.lastRenderTop);
541
542
this.eventuallyUpdateScrollDimensions();
543
544
if (this.supportDynamicHeights) {
545
this._rerender(this.lastRenderTop, this.lastRenderHeight);
546
}
547
}
548
}
549
550
delegateScrollFromMouseWheelEvent(browserEvent: IMouseWheelEvent) {
551
this.scrollableElement.delegateScrollFromMouseWheelEvent(browserEvent);
552
}
553
554
delegateVerticalScrollbarPointerDown(browserEvent: PointerEvent) {
555
this.scrollableElement.delegateVerticalScrollbarPointerDown(browserEvent);
556
}
557
558
updateElementHeight(index: number, size: number | undefined, anchorIndex: number | null): void {
559
if (index < 0 || index >= this.items.length) {
560
return;
561
}
562
563
const originalSize = this.items[index].size;
564
565
if (typeof size === 'undefined') {
566
if (!this.supportDynamicHeights) {
567
console.warn('Dynamic heights not supported', new Error().stack);
568
return;
569
}
570
571
this.items[index].lastDynamicHeightWidth = undefined;
572
size = originalSize + this.probeDynamicHeight(index);
573
}
574
575
if (originalSize === size) {
576
return;
577
}
578
579
const lastRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
580
581
let heightDiff = 0;
582
583
if (index < lastRenderRange.start) {
584
// do not scroll the viewport if resized element is out of viewport
585
heightDiff = size - originalSize;
586
} else {
587
if (anchorIndex !== null && anchorIndex > index && anchorIndex < lastRenderRange.end) {
588
// anchor in viewport
589
// resized element in viewport and above the anchor
590
heightDiff = size - originalSize;
591
} else {
592
heightDiff = 0;
593
}
594
}
595
596
this.rangeMap.splice(index, 1, [{ size: size }]);
597
this.items[index].size = size;
598
599
this.render(lastRenderRange, Math.max(0, this.lastRenderTop + heightDiff), this.lastRenderHeight, undefined, undefined, true);
600
this.setScrollTop(this.lastRenderTop);
601
602
this.eventuallyUpdateScrollDimensions();
603
604
if (this.supportDynamicHeights) {
605
this._rerender(this.lastRenderTop, this.lastRenderHeight);
606
} else {
607
this._onDidChangeContentHeight.fire(this.contentHeight); // otherwise fired in _rerender()
608
}
609
}
610
611
protected createRangeMap(paddingTop: number): IRangeMap {
612
return new RangeMap(paddingTop);
613
}
614
615
splice(start: number, deleteCount: number, elements: readonly T[] = []): T[] {
616
if (this.splicing) {
617
throw new Error('Can\'t run recursive splices.');
618
}
619
620
this.splicing = true;
621
622
try {
623
return this._splice(start, deleteCount, elements);
624
} finally {
625
this.splicing = false;
626
this._onDidChangeContentHeight.fire(this.contentHeight);
627
}
628
}
629
630
private _splice(start: number, deleteCount: number, elements: readonly T[] = []): T[] {
631
const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
632
const deleteRange = { start, end: start + deleteCount };
633
const removeRange = Range.intersect(previousRenderRange, deleteRange);
634
635
// try to reuse rows, avoid removing them from DOM
636
const rowsToDispose = new Map<string, IRow[]>();
637
for (let i = removeRange.end - 1; i >= removeRange.start; i--) {
638
const item = this.items[i];
639
item.dragStartDisposable.dispose();
640
item.checkedDisposable.dispose();
641
642
if (item.row) {
643
let rows = rowsToDispose.get(item.templateId);
644
645
if (!rows) {
646
rows = [];
647
rowsToDispose.set(item.templateId, rows);
648
}
649
650
const renderer = this.renderers.get(item.templateId);
651
652
if (renderer && renderer.disposeElement) {
653
renderer.disposeElement(item.element, i, item.row.templateData, { height: item.size });
654
}
655
656
rows.unshift(item.row);
657
}
658
659
item.row = null;
660
item.stale = true;
661
}
662
663
const previousRestRange: IRange = { start: start + deleteCount, end: this.items.length };
664
const previousRenderedRestRange = Range.intersect(previousRestRange, previousRenderRange);
665
const previousUnrenderedRestRanges = Range.relativeComplement(previousRestRange, previousRenderRange);
666
667
const inserted = elements.map<IItem<T>>(element => ({
668
id: String(this.itemId++),
669
element,
670
templateId: this.virtualDelegate.getTemplateId(element),
671
size: this.virtualDelegate.getHeight(element),
672
width: undefined,
673
hasDynamicHeight: !!this.virtualDelegate.hasDynamicHeight && this.virtualDelegate.hasDynamicHeight(element),
674
lastDynamicHeightWidth: undefined,
675
row: null,
676
uri: undefined,
677
dropTarget: false,
678
dragStartDisposable: Disposable.None,
679
checkedDisposable: Disposable.None,
680
stale: false
681
}));
682
683
let deleted: IItem<T>[];
684
685
// TODO@joao: improve this optimization to catch even more cases
686
if (start === 0 && deleteCount >= this.items.length) {
687
this.rangeMap = this.createRangeMap(this.rangeMap.paddingTop);
688
this.rangeMap.splice(0, 0, inserted);
689
deleted = this.items;
690
this.items = inserted;
691
} else {
692
this.rangeMap.splice(start, deleteCount, inserted);
693
deleted = splice(this.items, start, deleteCount, inserted);
694
}
695
696
const delta = elements.length - deleteCount;
697
const renderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
698
const renderedRestRange = shift(previousRenderedRestRange, delta);
699
const updateRange = Range.intersect(renderRange, renderedRestRange);
700
701
for (let i = updateRange.start; i < updateRange.end; i++) {
702
this.updateItemInDOM(this.items[i], i);
703
}
704
705
const removeRanges = Range.relativeComplement(renderedRestRange, renderRange);
706
707
for (const range of removeRanges) {
708
for (let i = range.start; i < range.end; i++) {
709
this.removeItemFromDOM(i);
710
}
711
}
712
713
const unrenderedRestRanges = previousUnrenderedRestRanges.map(r => shift(r, delta));
714
const elementsRange = { start, end: start + elements.length };
715
const insertRanges = [elementsRange, ...unrenderedRestRanges].map(r => Range.intersect(renderRange, r)).reverse();
716
717
for (const range of insertRanges) {
718
for (let i = range.end - 1; i >= range.start; i--) {
719
const item = this.items[i];
720
const rows = rowsToDispose.get(item.templateId);
721
const row = rows?.pop();
722
this.insertItemInDOM(i, row);
723
}
724
}
725
726
for (const rows of rowsToDispose.values()) {
727
for (const row of rows) {
728
this.cache.release(row);
729
}
730
}
731
732
this.eventuallyUpdateScrollDimensions();
733
734
if (this.supportDynamicHeights) {
735
this._rerender(this.scrollTop, this.renderHeight);
736
}
737
738
return deleted.map(i => i.element);
739
}
740
741
protected eventuallyUpdateScrollDimensions(): void {
742
this._scrollHeight = this.contentHeight;
743
this.rowsContainer.style.height = `${this._scrollHeight}px`;
744
745
if (!this.scrollableElementUpdateDisposable) {
746
this.scrollableElementUpdateDisposable = scheduleAtNextAnimationFrame(getWindow(this.domNode), () => {
747
this.scrollableElement.setScrollDimensions({ scrollHeight: this.scrollHeight });
748
this.updateScrollWidth();
749
this.scrollableElementUpdateDisposable = null;
750
});
751
}
752
}
753
754
private eventuallyUpdateScrollWidth(): void {
755
if (!this.horizontalScrolling) {
756
this.scrollableElementWidthDelayer.cancel();
757
return;
758
}
759
760
this.scrollableElementWidthDelayer.trigger(() => this.updateScrollWidth());
761
}
762
763
private updateScrollWidth(): void {
764
if (!this.horizontalScrolling) {
765
return;
766
}
767
768
let scrollWidth = 0;
769
770
for (const item of this.items) {
771
if (typeof item.width !== 'undefined') {
772
scrollWidth = Math.max(scrollWidth, item.width);
773
}
774
}
775
776
this.scrollWidth = scrollWidth;
777
this.scrollableElement.setScrollDimensions({ scrollWidth: scrollWidth === 0 ? 0 : (scrollWidth + 10) });
778
this._onDidChangeContentWidth.fire(this.scrollWidth);
779
}
780
781
updateWidth(index: number): void {
782
if (!this.horizontalScrolling || typeof this.scrollWidth === 'undefined') {
783
return;
784
}
785
786
const item = this.items[index];
787
this.measureItemWidth(item);
788
789
if (typeof item.width !== 'undefined' && item.width > this.scrollWidth) {
790
this.scrollWidth = item.width;
791
this.scrollableElement.setScrollDimensions({ scrollWidth: this.scrollWidth + 10 });
792
this._onDidChangeContentWidth.fire(this.scrollWidth);
793
}
794
}
795
796
rerender(): void {
797
if (!this.supportDynamicHeights) {
798
return;
799
}
800
801
for (const item of this.items) {
802
item.lastDynamicHeightWidth = undefined;
803
}
804
805
this._rerender(this.lastRenderTop, this.lastRenderHeight);
806
}
807
808
get length(): number {
809
return this.items.length;
810
}
811
812
get renderHeight(): number {
813
const scrollDimensions = this.scrollableElement.getScrollDimensions();
814
return scrollDimensions.height;
815
}
816
817
get firstVisibleIndex(): number {
818
const range = this.getVisibleRange(this.lastRenderTop, this.lastRenderHeight);
819
return range.start;
820
}
821
822
get firstMostlyVisibleIndex(): number {
823
const firstVisibleIndex = this.firstVisibleIndex;
824
const firstElTop = this.rangeMap.positionAt(firstVisibleIndex);
825
const nextElTop = this.rangeMap.positionAt(firstVisibleIndex + 1);
826
if (nextElTop !== -1) {
827
const firstElMidpoint = (nextElTop - firstElTop) / 2 + firstElTop;
828
if (firstElMidpoint < this.scrollTop) {
829
return firstVisibleIndex + 1;
830
}
831
}
832
833
return firstVisibleIndex;
834
}
835
836
get lastVisibleIndex(): number {
837
const range = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
838
return range.end - 1;
839
}
840
841
element(index: number): T {
842
return this.items[index].element;
843
}
844
845
indexOf(element: T): number {
846
return this.items.findIndex(item => item.element === element);
847
}
848
849
domElement(index: number): HTMLElement | null {
850
const row = this.items[index].row;
851
return row && row.domNode;
852
}
853
854
elementHeight(index: number): number {
855
return this.items[index].size;
856
}
857
858
elementTop(index: number): number {
859
return this.rangeMap.positionAt(index);
860
}
861
862
indexAt(position: number): number {
863
return this.rangeMap.indexAt(position);
864
}
865
866
indexAfter(position: number): number {
867
return this.rangeMap.indexAfter(position);
868
}
869
870
layout(height?: number, width?: number): void {
871
const scrollDimensions: INewScrollDimensions = {
872
height: typeof height === 'number' ? height : getContentHeight(this.domNode)
873
};
874
875
if (this.scrollableElementUpdateDisposable) {
876
this.scrollableElementUpdateDisposable.dispose();
877
this.scrollableElementUpdateDisposable = null;
878
scrollDimensions.scrollHeight = this.scrollHeight;
879
}
880
881
this.scrollableElement.setScrollDimensions(scrollDimensions);
882
883
if (typeof width !== 'undefined') {
884
this.renderWidth = width;
885
886
if (this.supportDynamicHeights) {
887
this._rerender(this.scrollTop, this.renderHeight);
888
}
889
}
890
891
if (this.horizontalScrolling) {
892
this.scrollableElement.setScrollDimensions({
893
width: typeof width === 'number' ? width : getContentWidth(this.domNode)
894
});
895
}
896
}
897
898
// Render
899
900
protected render(previousRenderRange: IRange, renderTop: number, renderHeight: number, renderLeft: number | undefined, scrollWidth: number | undefined, updateItemsInDOM: boolean = false, onScroll: boolean = false): void {
901
const renderRange = this.getRenderRange(renderTop, renderHeight);
902
903
const rangesToInsert = Range.relativeComplement(renderRange, previousRenderRange).reverse();
904
const rangesToRemove = Range.relativeComplement(previousRenderRange, renderRange);
905
906
if (updateItemsInDOM) {
907
const rangesToUpdate = Range.intersect(previousRenderRange, renderRange);
908
909
for (let i = rangesToUpdate.start; i < rangesToUpdate.end; i++) {
910
this.updateItemInDOM(this.items[i], i);
911
}
912
}
913
914
this.cache.transact(() => {
915
for (const range of rangesToRemove) {
916
for (let i = range.start; i < range.end; i++) {
917
this.removeItemFromDOM(i, onScroll);
918
}
919
}
920
921
for (const range of rangesToInsert) {
922
for (let i = range.end - 1; i >= range.start; i--) {
923
this.insertItemInDOM(i);
924
}
925
}
926
});
927
928
if (renderLeft !== undefined) {
929
this.rowsContainer.style.left = `-${renderLeft}px`;
930
}
931
932
this.rowsContainer.style.top = `-${renderTop}px`;
933
934
if (this.horizontalScrolling && scrollWidth !== undefined) {
935
this.rowsContainer.style.width = `${Math.max(scrollWidth, this.renderWidth)}px`;
936
}
937
938
this.lastRenderTop = renderTop;
939
this.lastRenderHeight = renderHeight;
940
}
941
942
// DOM operations
943
944
private insertItemInDOM(index: number, row?: IRow): void {
945
const item = this.items[index];
946
947
if (!item.row) {
948
if (row) {
949
item.row = row;
950
item.stale = true;
951
} else {
952
const result = this.cache.alloc(item.templateId);
953
item.row = result.row;
954
item.stale ||= result.isReusingConnectedDomNode;
955
}
956
}
957
958
const role = this.accessibilityProvider.getRole(item.element) || 'listitem';
959
item.row.domNode.setAttribute('role', role);
960
961
const checked = this.accessibilityProvider.isChecked(item.element);
962
963
if (typeof checked === 'boolean') {
964
item.row.domNode.setAttribute('aria-checked', String(!!checked));
965
} else if (checked) {
966
const update = (checked: boolean) => item.row!.domNode.setAttribute('aria-checked', String(!!checked));
967
update(checked.value);
968
item.checkedDisposable = checked.onDidChange(() => update(checked.value));
969
}
970
971
if (item.stale || !item.row.domNode.parentElement) {
972
const referenceNode = this.items.at(index + 1)?.row?.domNode ?? null;
973
if (item.row.domNode.parentElement !== this.rowsContainer || item.row.domNode.nextElementSibling !== referenceNode) {
974
this.rowsContainer.insertBefore(item.row.domNode, referenceNode);
975
}
976
item.stale = false;
977
}
978
979
this.updateItemInDOM(item, index);
980
981
const renderer = this.renderers.get(item.templateId);
982
983
if (!renderer) {
984
throw new Error(`No renderer found for template id ${item.templateId}`);
985
}
986
987
renderer?.renderElement(item.element, index, item.row.templateData, { height: item.size });
988
989
const uri = this.dnd.getDragURI(item.element);
990
item.dragStartDisposable.dispose();
991
item.row.domNode.draggable = !!uri;
992
993
if (uri) {
994
item.dragStartDisposable = addDisposableListener(item.row.domNode, 'dragstart', event => this.onDragStart(item.element, uri, event));
995
}
996
997
if (this.horizontalScrolling) {
998
this.measureItemWidth(item);
999
this.eventuallyUpdateScrollWidth();
1000
}
1001
}
1002
1003
private measureItemWidth(item: IItem<T>): void {
1004
if (!item.row || !item.row.domNode) {
1005
return;
1006
}
1007
1008
item.row.domNode.style.width = 'fit-content';
1009
item.width = getContentWidth(item.row.domNode);
1010
const style = getWindow(item.row.domNode).getComputedStyle(item.row.domNode);
1011
1012
if (style.paddingLeft) {
1013
item.width += parseFloat(style.paddingLeft);
1014
}
1015
1016
if (style.paddingRight) {
1017
item.width += parseFloat(style.paddingRight);
1018
}
1019
1020
item.row.domNode.style.width = '';
1021
}
1022
1023
private updateItemInDOM(item: IItem<T>, index: number): void {
1024
item.row!.domNode.style.top = `${this.elementTop(index)}px`;
1025
1026
if (this.setRowHeight) {
1027
item.row!.domNode.style.height = `${item.size}px`;
1028
}
1029
1030
if (this.setRowLineHeight) {
1031
item.row!.domNode.style.lineHeight = `${item.size}px`;
1032
}
1033
1034
item.row!.domNode.setAttribute('data-index', `${index}`);
1035
item.row!.domNode.setAttribute('data-last-element', index === this.length - 1 ? 'true' : 'false');
1036
item.row!.domNode.setAttribute('data-parity', index % 2 === 0 ? 'even' : 'odd');
1037
item.row!.domNode.setAttribute('aria-setsize', String(this.accessibilityProvider.getSetSize(item.element, index, this.length)));
1038
item.row!.domNode.setAttribute('aria-posinset', String(this.accessibilityProvider.getPosInSet(item.element, index)));
1039
item.row!.domNode.setAttribute('id', this.getElementDomId(index));
1040
1041
item.row!.domNode.classList.toggle('drop-target', item.dropTarget);
1042
}
1043
1044
private removeItemFromDOM(index: number, onScroll?: boolean): void {
1045
const item = this.items[index];
1046
item.dragStartDisposable.dispose();
1047
item.checkedDisposable.dispose();
1048
1049
if (item.row) {
1050
const renderer = this.renderers.get(item.templateId);
1051
1052
if (renderer && renderer.disposeElement) {
1053
renderer.disposeElement(item.element, index, item.row.templateData, { height: item.size, onScroll });
1054
}
1055
1056
this.cache.release(item.row);
1057
item.row = null;
1058
}
1059
1060
if (this.horizontalScrolling) {
1061
this.eventuallyUpdateScrollWidth();
1062
}
1063
}
1064
1065
getScrollTop(): number {
1066
const scrollPosition = this.scrollableElement.getScrollPosition();
1067
return scrollPosition.scrollTop;
1068
}
1069
1070
setScrollTop(scrollTop: number, reuseAnimation?: boolean): void {
1071
if (this.scrollableElementUpdateDisposable) {
1072
this.scrollableElementUpdateDisposable.dispose();
1073
this.scrollableElementUpdateDisposable = null;
1074
this.scrollableElement.setScrollDimensions({ scrollHeight: this.scrollHeight });
1075
}
1076
1077
this.scrollableElement.setScrollPosition({ scrollTop, reuseAnimation });
1078
}
1079
1080
getScrollLeft(): number {
1081
const scrollPosition = this.scrollableElement.getScrollPosition();
1082
return scrollPosition.scrollLeft;
1083
}
1084
1085
setScrollLeft(scrollLeft: number): void {
1086
if (this.scrollableElementUpdateDisposable) {
1087
this.scrollableElementUpdateDisposable.dispose();
1088
this.scrollableElementUpdateDisposable = null;
1089
this.scrollableElement.setScrollDimensions({ scrollWidth: this.scrollWidth });
1090
}
1091
1092
this.scrollableElement.setScrollPosition({ scrollLeft });
1093
}
1094
1095
1096
get scrollTop(): number {
1097
return this.getScrollTop();
1098
}
1099
1100
set scrollTop(scrollTop: number) {
1101
this.setScrollTop(scrollTop);
1102
}
1103
1104
get scrollHeight(): number {
1105
return this._scrollHeight + (this.horizontalScrolling ? 10 : 0) + this.paddingBottom;
1106
}
1107
1108
// Events
1109
1110
@memoize get onMouseClick(): Event<IListMouseEvent<T>> { return Event.map(this.disposables.add(new DomEmitter(this.domNode, 'click')).event, e => this.toMouseEvent(e), this.disposables); }
1111
@memoize get onMouseDblClick(): Event<IListMouseEvent<T>> { return Event.map(this.disposables.add(new DomEmitter(this.domNode, 'dblclick')).event, e => this.toMouseEvent(e), this.disposables); }
1112
@memoize get onMouseMiddleClick(): Event<IListMouseEvent<T>> { return Event.filter(Event.map(this.disposables.add(new DomEmitter(this.domNode, 'auxclick')).event, e => this.toMouseEvent(e as MouseEvent), this.disposables), e => e.browserEvent.button === 1, this.disposables); }
1113
@memoize get onMouseUp(): Event<IListMouseEvent<T>> { return Event.map(this.disposables.add(new DomEmitter(this.domNode, 'mouseup')).event, e => this.toMouseEvent(e), this.disposables); }
1114
@memoize get onMouseDown(): Event<IListMouseEvent<T>> { return Event.map(this.disposables.add(new DomEmitter(this.domNode, 'mousedown')).event, e => this.toMouseEvent(e), this.disposables); }
1115
@memoize get onMouseOver(): Event<IListMouseEvent<T>> { return Event.map(this.disposables.add(new DomEmitter(this.domNode, 'mouseover')).event, e => this.toMouseEvent(e), this.disposables); }
1116
@memoize get onMouseMove(): Event<IListMouseEvent<T>> { return Event.map(this.disposables.add(new DomEmitter(this.domNode, 'mousemove')).event, e => this.toMouseEvent(e), this.disposables); }
1117
@memoize get onMouseOut(): Event<IListMouseEvent<T>> { return Event.map(this.disposables.add(new DomEmitter(this.domNode, 'mouseout')).event, e => this.toMouseEvent(e), this.disposables); }
1118
@memoize get onContextMenu(): Event<IListMouseEvent<T> | IListGestureEvent<T>> { return Event.any<IListMouseEvent<any> | IListGestureEvent<any>>(Event.map(this.disposables.add(new DomEmitter(this.domNode, 'contextmenu')).event, e => this.toMouseEvent(e), this.disposables), Event.map(this.disposables.add(new DomEmitter(this.domNode, TouchEventType.Contextmenu)).event as Event<GestureEvent>, e => this.toGestureEvent(e), this.disposables)); }
1119
@memoize get onTouchStart(): Event<IListTouchEvent<T>> { return Event.map(this.disposables.add(new DomEmitter(this.domNode, 'touchstart')).event, e => this.toTouchEvent(e), this.disposables); }
1120
@memoize get onTap(): Event<IListGestureEvent<T>> { return Event.map(this.disposables.add(new DomEmitter(this.rowsContainer, TouchEventType.Tap)).event, e => this.toGestureEvent(e as GestureEvent), this.disposables); }
1121
1122
private toMouseEvent(browserEvent: MouseEvent): IListMouseEvent<T> {
1123
const index = this.getItemIndexFromEventTarget(browserEvent.target || null);
1124
const item = typeof index === 'undefined' ? undefined : this.items[index];
1125
const element = item && item.element;
1126
return { browserEvent, index, element };
1127
}
1128
1129
private toTouchEvent(browserEvent: TouchEvent): IListTouchEvent<T> {
1130
const index = this.getItemIndexFromEventTarget(browserEvent.target || null);
1131
const item = typeof index === 'undefined' ? undefined : this.items[index];
1132
const element = item && item.element;
1133
return { browserEvent, index, element };
1134
}
1135
1136
private toGestureEvent(browserEvent: GestureEvent): IListGestureEvent<T> {
1137
const index = this.getItemIndexFromEventTarget(browserEvent.initialTarget || null);
1138
const item = typeof index === 'undefined' ? undefined : this.items[index];
1139
const element = item && item.element;
1140
return { browserEvent, index, element };
1141
}
1142
1143
private toDragEvent(browserEvent: DragEvent): IListDragEvent<T> {
1144
const index = this.getItemIndexFromEventTarget(browserEvent.target || null);
1145
const item = typeof index === 'undefined' ? undefined : this.items[index];
1146
const element = item && item.element;
1147
const sector = this.getTargetSector(browserEvent, index);
1148
return { browserEvent, index, element, sector };
1149
}
1150
1151
private onScroll(e: ScrollEvent): void {
1152
try {
1153
const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
1154
this.render(previousRenderRange, e.scrollTop, e.height, e.scrollLeft, e.scrollWidth, undefined, true);
1155
1156
if (this.supportDynamicHeights) {
1157
this._rerender(e.scrollTop, e.height, e.inSmoothScrolling);
1158
}
1159
} catch (err) {
1160
console.error('Got bad scroll event:', e);
1161
throw err;
1162
}
1163
}
1164
1165
private onTouchChange(event: GestureEvent): void {
1166
event.preventDefault();
1167
event.stopPropagation();
1168
1169
this.scrollTop -= event.translationY;
1170
}
1171
1172
// DND
1173
1174
private onDragStart(element: T, uri: string, event: DragEvent): void {
1175
if (!event.dataTransfer) {
1176
return;
1177
}
1178
1179
const elements = this.dnd.getDragElements(element);
1180
1181
event.dataTransfer.effectAllowed = 'copyMove';
1182
event.dataTransfer.setData(DataTransfers.TEXT, uri);
1183
1184
let label: string | undefined;
1185
if (this.dnd.getDragLabel) {
1186
label = this.dnd.getDragLabel(elements, event);
1187
}
1188
if (typeof label === 'undefined') {
1189
label = String(elements.length);
1190
}
1191
1192
applyDragImage(event, this.domNode, label, [this.domId /* add domId to get list specific styling */]);
1193
1194
this.domNode.classList.add('dragging');
1195
this.currentDragData = new ElementsDragAndDropData(elements);
1196
StaticDND.CurrentDragAndDropData = new ExternalElementsDragAndDropData(elements);
1197
1198
this.dnd.onDragStart?.(this.currentDragData, event);
1199
}
1200
1201
private onPotentialSelectionStart(e: MouseEvent) {
1202
this.currentSelectionDisposable.dispose();
1203
const doc = getDocument(this.domNode);
1204
1205
// Set up both the 'movement store' for watching the mouse, and the
1206
// 'selection store' which lasts as long as there's a selection, even
1207
// after the usr has stopped modifying it.
1208
const selectionStore = this.currentSelectionDisposable = new DisposableStore();
1209
const movementStore = selectionStore.add(new DisposableStore());
1210
1211
// The selection events we get from the DOM are fairly limited and we lack a 'selection end' event.
1212
// Selection events also don't tell us where the input doing the selection is. So, make a poor
1213
// assumption that a user is using the mouse, and base our events on that.
1214
movementStore.add(addDisposableListener(this.domNode, 'selectstart', () => {
1215
movementStore.add(addDisposableListener(doc, 'mousemove', e => {
1216
if (doc.getSelection()?.isCollapsed === false) {
1217
this.setupDragAndDropScrollTopAnimation(e);
1218
}
1219
}));
1220
1221
// The selection is cleared either on mouseup if there's no selection, or on next mousedown
1222
// when `this.currentSelectionDisposable` is reset.
1223
selectionStore.add(toDisposable(() => {
1224
const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
1225
this.currentSelectionBounds = undefined;
1226
this.render(previousRenderRange, this.lastRenderTop, this.lastRenderHeight, undefined, undefined);
1227
}));
1228
selectionStore.add(addDisposableListener(doc, 'selectionchange', () => {
1229
const selection = doc.getSelection();
1230
// if the selection changed _after_ mouseup, it's from clearing the list or similar, so teardown
1231
if (!selection || selection.isCollapsed) {
1232
if (movementStore.isDisposed) {
1233
selectionStore.dispose();
1234
}
1235
return;
1236
}
1237
1238
let start = this.getIndexOfListElement(selection.anchorNode as HTMLElement);
1239
let end = this.getIndexOfListElement(selection.focusNode as HTMLElement);
1240
if (start !== undefined && end !== undefined) {
1241
if (end < start) {
1242
[start, end] = [end, start];
1243
}
1244
this.currentSelectionBounds = { start, end };
1245
}
1246
}));
1247
}));
1248
1249
movementStore.add(addDisposableListener(doc, 'mouseup', () => {
1250
movementStore.dispose();
1251
this.teardownDragAndDropScrollTopAnimation();
1252
1253
if (doc.getSelection()?.isCollapsed !== false) {
1254
selectionStore.dispose();
1255
}
1256
}));
1257
}
1258
1259
private getIndexOfListElement(element: HTMLElement | null): number | undefined {
1260
if (!element || !this.domNode.contains(element)) {
1261
return undefined;
1262
}
1263
1264
while (element && element !== this.domNode) {
1265
if (element.dataset?.index) {
1266
return Number(element.dataset.index);
1267
}
1268
1269
element = element.parentElement;
1270
}
1271
1272
return undefined;
1273
}
1274
1275
private onDragOver(event: IListDragEvent<T>): boolean {
1276
event.browserEvent.preventDefault(); // needed so that the drop event fires (https://stackoverflow.com/questions/21339924/drop-event-not-firing-in-chrome)
1277
1278
this.onDragLeaveTimeout.dispose();
1279
1280
if (StaticDND.CurrentDragAndDropData && StaticDND.CurrentDragAndDropData.getData() === 'vscode-ui') {
1281
return false;
1282
}
1283
1284
this.setupDragAndDropScrollTopAnimation(event.browserEvent);
1285
1286
if (!event.browserEvent.dataTransfer) {
1287
return false;
1288
}
1289
1290
// Drag over from outside
1291
if (!this.currentDragData) {
1292
if (StaticDND.CurrentDragAndDropData) {
1293
// Drag over from another list
1294
this.currentDragData = StaticDND.CurrentDragAndDropData;
1295
1296
} else {
1297
// Drag over from the desktop
1298
if (!event.browserEvent.dataTransfer.types) {
1299
return false;
1300
}
1301
1302
this.currentDragData = new NativeDragAndDropData();
1303
}
1304
}
1305
1306
const result = this.dnd.onDragOver(this.currentDragData, event.element, event.index, event.sector, event.browserEvent);
1307
this.canDrop = typeof result === 'boolean' ? result : result.accept;
1308
1309
if (!this.canDrop) {
1310
this.currentDragFeedback = undefined;
1311
this.currentDragFeedbackDisposable.dispose();
1312
return false;
1313
}
1314
1315
event.browserEvent.dataTransfer.dropEffect = (typeof result !== 'boolean' && result.effect?.type === ListDragOverEffectType.Copy) ? 'copy' : 'move';
1316
1317
let feedback: number[];
1318
1319
if (typeof result !== 'boolean' && result.feedback) {
1320
feedback = result.feedback;
1321
} else {
1322
if (typeof event.index === 'undefined') {
1323
feedback = [-1];
1324
} else {
1325
feedback = [event.index];
1326
}
1327
}
1328
1329
// sanitize feedback list
1330
feedback = distinct(feedback).filter(i => i >= -1 && i < this.length).sort((a, b) => a - b);
1331
feedback = feedback[0] === -1 ? [-1] : feedback;
1332
1333
let dragOverEffectPosition = typeof result !== 'boolean' && result.effect && result.effect.position ? result.effect.position : ListDragOverEffectPosition.Over;
1334
1335
if (equalsDragFeedback(this.currentDragFeedback, feedback) && this.currentDragFeedbackPosition === dragOverEffectPosition) {
1336
return true;
1337
}
1338
1339
this.currentDragFeedback = feedback;
1340
this.currentDragFeedbackPosition = dragOverEffectPosition;
1341
this.currentDragFeedbackDisposable.dispose();
1342
1343
if (feedback[0] === -1) { // entire list feedback
1344
this.domNode.classList.add(dragOverEffectPosition);
1345
this.rowsContainer.classList.add(dragOverEffectPosition);
1346
this.currentDragFeedbackDisposable = toDisposable(() => {
1347
this.domNode.classList.remove(dragOverEffectPosition);
1348
this.rowsContainer.classList.remove(dragOverEffectPosition);
1349
});
1350
} else {
1351
1352
if (feedback.length > 1 && dragOverEffectPosition !== ListDragOverEffectPosition.Over) {
1353
throw new Error('Can\'t use multiple feedbacks with position different than \'over\'');
1354
}
1355
1356
// Make sure there is no flicker when moving between two items
1357
// Always use the before feedback if possible
1358
if (dragOverEffectPosition === ListDragOverEffectPosition.After) {
1359
if (feedback[0] < this.length - 1) {
1360
feedback[0] += 1;
1361
dragOverEffectPosition = ListDragOverEffectPosition.Before;
1362
}
1363
}
1364
1365
for (const index of feedback) {
1366
const item = this.items[index]!;
1367
item.dropTarget = true;
1368
1369
item.row?.domNode.classList.add(dragOverEffectPosition);
1370
}
1371
1372
this.currentDragFeedbackDisposable = toDisposable(() => {
1373
for (const index of feedback) {
1374
const item = this.items[index]!;
1375
item.dropTarget = false;
1376
1377
item.row?.domNode.classList.remove(dragOverEffectPosition);
1378
}
1379
});
1380
}
1381
1382
return true;
1383
}
1384
1385
private onDragLeave(event: IListDragEvent<T>): void {
1386
this.onDragLeaveTimeout.dispose();
1387
this.onDragLeaveTimeout = disposableTimeout(() => this.clearDragOverFeedback(), 100, this.disposables);
1388
if (this.currentDragData) {
1389
this.dnd.onDragLeave?.(this.currentDragData, event.element, event.index, event.browserEvent);
1390
}
1391
}
1392
1393
private onDrop(event: IListDragEvent<T>): void {
1394
if (!this.canDrop) {
1395
return;
1396
}
1397
1398
const dragData = this.currentDragData;
1399
this.teardownDragAndDropScrollTopAnimation();
1400
this.clearDragOverFeedback();
1401
this.domNode.classList.remove('dragging');
1402
this.currentDragData = undefined;
1403
StaticDND.CurrentDragAndDropData = undefined;
1404
1405
if (!dragData || !event.browserEvent.dataTransfer) {
1406
return;
1407
}
1408
1409
event.browserEvent.preventDefault();
1410
dragData.update(event.browserEvent.dataTransfer);
1411
this.dnd.drop(dragData, event.element, event.index, event.sector, event.browserEvent);
1412
}
1413
1414
private onDragEnd(event: DragEvent): void {
1415
this.canDrop = false;
1416
this.teardownDragAndDropScrollTopAnimation();
1417
this.clearDragOverFeedback();
1418
this.domNode.classList.remove('dragging');
1419
this.currentDragData = undefined;
1420
StaticDND.CurrentDragAndDropData = undefined;
1421
1422
this.dnd.onDragEnd?.(event);
1423
}
1424
1425
private clearDragOverFeedback(): void {
1426
this.currentDragFeedback = undefined;
1427
this.currentDragFeedbackPosition = undefined;
1428
this.currentDragFeedbackDisposable.dispose();
1429
this.currentDragFeedbackDisposable = Disposable.None;
1430
}
1431
1432
// DND scroll top animation
1433
1434
private setupDragAndDropScrollTopAnimation(event: DragEvent | MouseEvent): void {
1435
if (!this.dragOverAnimationDisposable) {
1436
const viewTop = getTopLeftOffset(this.domNode).top;
1437
this.dragOverAnimationDisposable = animate(getWindow(this.domNode), this.animateDragAndDropScrollTop.bind(this, viewTop));
1438
}
1439
1440
this.dragOverAnimationStopDisposable.dispose();
1441
this.dragOverAnimationStopDisposable = disposableTimeout(() => {
1442
if (this.dragOverAnimationDisposable) {
1443
this.dragOverAnimationDisposable.dispose();
1444
this.dragOverAnimationDisposable = undefined;
1445
}
1446
}, 1000, this.disposables);
1447
1448
this.dragOverMouseY = event.pageY;
1449
}
1450
1451
private animateDragAndDropScrollTop(viewTop: number): void {
1452
if (this.dragOverMouseY === undefined) {
1453
return;
1454
}
1455
1456
const diff = this.dragOverMouseY - viewTop;
1457
const upperLimit = this.renderHeight - 35;
1458
1459
if (diff < 35) {
1460
this.scrollTop += Math.max(-14, Math.floor(0.3 * (diff - 35)));
1461
} else if (diff > upperLimit) {
1462
this.scrollTop += Math.min(14, Math.floor(0.3 * (diff - upperLimit)));
1463
}
1464
}
1465
1466
private teardownDragAndDropScrollTopAnimation(): void {
1467
this.dragOverAnimationStopDisposable.dispose();
1468
1469
if (this.dragOverAnimationDisposable) {
1470
this.dragOverAnimationDisposable.dispose();
1471
this.dragOverAnimationDisposable = undefined;
1472
}
1473
}
1474
1475
// Util
1476
1477
private getTargetSector(browserEvent: DragEvent, targetIndex: number | undefined): ListViewTargetSector | undefined {
1478
if (targetIndex === undefined) {
1479
return undefined;
1480
}
1481
1482
const relativePosition = browserEvent.offsetY / this.items[targetIndex].size;
1483
const sector = Math.floor(relativePosition / 0.25);
1484
return clamp(sector, 0, 3);
1485
}
1486
1487
private getItemIndexFromEventTarget(target: EventTarget | null): number | undefined {
1488
const scrollableElement = this.scrollableElement.getDomNode();
1489
let element: HTMLElement | SVGElement | null = target as (HTMLElement | SVGElement | null);
1490
1491
while ((isHTMLElement(element) || isSVGElement(element)) && element !== this.rowsContainer && scrollableElement.contains(element)) {
1492
const rawIndex = element.getAttribute('data-index');
1493
1494
if (rawIndex) {
1495
const index = Number(rawIndex);
1496
1497
if (!isNaN(index)) {
1498
return index;
1499
}
1500
}
1501
1502
element = element.parentElement;
1503
}
1504
1505
return undefined;
1506
}
1507
1508
private getVisibleRange(renderTop: number, renderHeight: number): IRange {
1509
return {
1510
start: this.rangeMap.indexAt(renderTop),
1511
end: this.rangeMap.indexAfter(renderTop + renderHeight - 1)
1512
};
1513
}
1514
1515
protected getRenderRange(renderTop: number, renderHeight: number): IRange {
1516
const range = this.getVisibleRange(renderTop, renderHeight);
1517
if (this.currentSelectionBounds) {
1518
const max = this.rangeMap.count;
1519
range.start = Math.min(range.start, this.currentSelectionBounds.start, max);
1520
range.end = Math.min(Math.max(range.end, this.currentSelectionBounds.end + 1), max);
1521
}
1522
1523
return range;
1524
}
1525
1526
/**
1527
* Given a stable rendered state, checks every rendered element whether it needs
1528
* to be probed for dynamic height. Adjusts scroll height and top if necessary.
1529
*/
1530
protected _rerender(renderTop: number, renderHeight: number, inSmoothScrolling?: boolean): void {
1531
const previousRenderRange = this.getRenderRange(renderTop, renderHeight);
1532
1533
// Let's remember the second element's position, this helps in scrolling up
1534
// and preserving a linear upwards scroll movement
1535
let anchorElementIndex: number | undefined;
1536
let anchorElementTopDelta: number | undefined;
1537
1538
if (renderTop === this.elementTop(previousRenderRange.start)) {
1539
anchorElementIndex = previousRenderRange.start;
1540
anchorElementTopDelta = 0;
1541
} else if (previousRenderRange.end - previousRenderRange.start > 1) {
1542
anchorElementIndex = previousRenderRange.start + 1;
1543
anchorElementTopDelta = this.elementTop(anchorElementIndex) - renderTop;
1544
}
1545
1546
let heightDiff = 0;
1547
1548
while (true) {
1549
const renderRange = this.getRenderRange(renderTop, renderHeight);
1550
1551
let didChange = false;
1552
1553
for (let i = renderRange.start; i < renderRange.end; i++) {
1554
const diff = this.probeDynamicHeight(i);
1555
1556
if (diff !== 0) {
1557
this.rangeMap.splice(i, 1, [this.items[i]]);
1558
}
1559
1560
heightDiff += diff;
1561
didChange = didChange || diff !== 0;
1562
}
1563
1564
if (!didChange) {
1565
if (heightDiff !== 0) {
1566
this.eventuallyUpdateScrollDimensions();
1567
}
1568
1569
const unrenderRanges = Range.relativeComplement(previousRenderRange, renderRange);
1570
1571
for (const range of unrenderRanges) {
1572
for (let i = range.start; i < range.end; i++) {
1573
if (this.items[i].row) {
1574
this.removeItemFromDOM(i);
1575
}
1576
}
1577
}
1578
1579
const renderRanges = Range.relativeComplement(renderRange, previousRenderRange).reverse();
1580
1581
for (const range of renderRanges) {
1582
for (let i = range.end - 1; i >= range.start; i--) {
1583
this.insertItemInDOM(i);
1584
}
1585
}
1586
1587
for (let i = renderRange.start; i < renderRange.end; i++) {
1588
if (this.items[i].row) {
1589
this.updateItemInDOM(this.items[i], i);
1590
}
1591
}
1592
1593
if (typeof anchorElementIndex === 'number') {
1594
// To compute a destination scroll top, we need to take into account the current smooth scrolling
1595
// animation, and then reuse it with a new target (to avoid prolonging the scroll)
1596
// See https://github.com/microsoft/vscode/issues/104144
1597
// See https://github.com/microsoft/vscode/pull/104284
1598
// See https://github.com/microsoft/vscode/issues/107704
1599
const deltaScrollTop = this.scrollable.getFutureScrollPosition().scrollTop - renderTop;
1600
const newScrollTop = this.elementTop(anchorElementIndex) - anchorElementTopDelta! + deltaScrollTop;
1601
this.setScrollTop(newScrollTop, inSmoothScrolling);
1602
}
1603
1604
this._onDidChangeContentHeight.fire(this.contentHeight);
1605
return;
1606
}
1607
}
1608
}
1609
1610
private probeDynamicHeight(index: number): number {
1611
const item = this.items[index];
1612
1613
if (!!this.virtualDelegate.getDynamicHeight) {
1614
const newSize = this.virtualDelegate.getDynamicHeight(item.element);
1615
if (newSize !== null) {
1616
const size = item.size;
1617
item.size = newSize;
1618
item.lastDynamicHeightWidth = this.renderWidth;
1619
return newSize - size;
1620
}
1621
}
1622
1623
if (!item.hasDynamicHeight || item.lastDynamicHeightWidth === this.renderWidth) {
1624
return 0;
1625
}
1626
1627
if (!!this.virtualDelegate.hasDynamicHeight && !this.virtualDelegate.hasDynamicHeight(item.element)) {
1628
return 0;
1629
}
1630
1631
const size = item.size;
1632
1633
if (item.row) {
1634
item.row.domNode.style.height = '';
1635
item.size = item.row.domNode.offsetHeight;
1636
if (item.size === 0 && !isAncestor(item.row.domNode, getWindow(item.row.domNode).document.body)) {
1637
console.warn('Measuring item node that is not in DOM! Add ListView to the DOM before measuring row height!', new Error().stack);
1638
}
1639
item.lastDynamicHeightWidth = this.renderWidth;
1640
return item.size - size;
1641
}
1642
1643
const { row } = this.cache.alloc(item.templateId);
1644
row.domNode.style.height = '';
1645
this.rowsContainer.appendChild(row.domNode);
1646
1647
const renderer = this.renderers.get(item.templateId);
1648
1649
if (!renderer) {
1650
throw new BugIndicatingError('Missing renderer for templateId: ' + item.templateId);
1651
}
1652
1653
renderer.renderElement(item.element, index, row.templateData);
1654
item.size = row.domNode.offsetHeight;
1655
renderer.disposeElement?.(item.element, index, row.templateData);
1656
1657
this.virtualDelegate.setDynamicHeight?.(item.element, item.size);
1658
1659
item.lastDynamicHeightWidth = this.renderWidth;
1660
row.domNode.remove();
1661
this.cache.release(row);
1662
1663
return item.size - size;
1664
}
1665
1666
getElementDomId(index: number): string {
1667
return `${this.domId}_${index}`;
1668
}
1669
1670
// Dispose
1671
1672
dispose() {
1673
for (const item of this.items) {
1674
item.dragStartDisposable.dispose();
1675
item.checkedDisposable.dispose();
1676
1677
if (item.row) {
1678
const renderer = this.renderers.get(item.row.templateId);
1679
if (renderer) {
1680
renderer.disposeElement?.(item.element, -1, item.row.templateData, undefined);
1681
renderer.disposeTemplate(item.row.templateData);
1682
}
1683
}
1684
}
1685
1686
this.items = [];
1687
1688
this.domNode?.remove();
1689
1690
this.dragOverAnimationDisposable?.dispose();
1691
this.disposables.dispose();
1692
}
1693
}
1694
1695