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