Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/base/browser/ui/iconLabel/iconLabel.ts
5243 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 './iconlabel.css';
7
import * as dom from '../../dom.js';
8
import * as css from '../../cssValue.js';
9
import { HighlightedLabel } from '../highlightedlabel/highlightedLabel.js';
10
import { IHoverDelegate } from '../hover/hoverDelegate.js';
11
import { IMatch } from '../../../common/filters.js';
12
import { Disposable, IDisposable } from '../../../common/lifecycle.js';
13
import { equals } from '../../../common/objects.js';
14
import { Range } from '../../../common/range.js';
15
import { getDefaultHoverDelegate } from '../hover/hoverDelegateFactory.js';
16
import type { IManagedHoverTooltipMarkdownString } from '../hover/hover.js';
17
import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js';
18
import { URI } from '../../../common/uri.js';
19
import { ThemeIcon } from '../../../common/themables.js';
20
21
export interface IIconLabelCreationOptions {
22
readonly supportHighlights?: boolean;
23
readonly supportDescriptionHighlights?: boolean;
24
readonly supportIcons?: boolean;
25
readonly hoverDelegate?: IHoverDelegate;
26
readonly hoverTargetOverride?: HTMLElement;
27
}
28
29
export interface IIconLabelValueOptions {
30
title?: string | IManagedHoverTooltipMarkdownString;
31
descriptionTitle?: string | IManagedHoverTooltipMarkdownString;
32
suffix?: string;
33
hideIcon?: boolean;
34
extraClasses?: readonly string[];
35
bold?: boolean;
36
italic?: boolean;
37
strikethrough?: boolean;
38
matches?: readonly IMatch[];
39
labelEscapeNewLines?: boolean;
40
descriptionMatches?: readonly IMatch[];
41
disabledCommand?: boolean;
42
readonly separator?: string;
43
readonly domId?: string;
44
iconPath?: URI | ThemeIcon;
45
supportIcons?: boolean;
46
}
47
48
class FastLabelNode {
49
private disposed: boolean | undefined;
50
private _textContent: string | undefined;
51
private _classNames: string[] | undefined;
52
private _empty: boolean | undefined;
53
54
constructor(private _element: HTMLElement) {
55
}
56
57
get element(): HTMLElement {
58
return this._element;
59
}
60
61
set textContent(content: string) {
62
if (this.disposed || content === this._textContent) {
63
return;
64
}
65
66
this._textContent = content;
67
this._element.textContent = content;
68
}
69
70
set classNames(classNames: string[]) {
71
if (this.disposed || equals(classNames, this._classNames)) {
72
return;
73
}
74
75
this._classNames = classNames;
76
this._element.classList.value = '';
77
this._element.classList.add(...classNames);
78
}
79
80
set empty(empty: boolean) {
81
if (this.disposed || empty === this._empty) {
82
return;
83
}
84
85
this._empty = empty;
86
this._element.style.marginLeft = empty ? '0' : '';
87
}
88
89
dispose(): void {
90
this.disposed = true;
91
}
92
}
93
94
export class IconLabel extends Disposable {
95
96
private readonly creationOptions?: IIconLabelCreationOptions;
97
98
private readonly domNode: FastLabelNode;
99
private readonly nameContainer: HTMLElement;
100
private readonly nameNode: Label | LabelWithHighlights;
101
102
private descriptionNode: FastLabelNode | HighlightedLabel | undefined;
103
private suffixNode: FastLabelNode | undefined;
104
105
private readonly labelContainer: HTMLElement;
106
107
private readonly hoverDelegate: IHoverDelegate;
108
private readonly customHovers: Map<HTMLElement, IDisposable> = new Map();
109
110
constructor(container: HTMLElement, options?: IIconLabelCreationOptions) {
111
super();
112
this.creationOptions = options;
113
114
this.domNode = this._register(new FastLabelNode(dom.append(container, dom.$('.monaco-icon-label'))));
115
116
this.labelContainer = dom.append(this.domNode.element, dom.$('.monaco-icon-label-container'));
117
118
this.nameContainer = dom.append(this.labelContainer, dom.$('span.monaco-icon-name-container'));
119
120
if (options?.supportHighlights || options?.supportIcons) {
121
this.nameNode = this._register(new LabelWithHighlights(this.nameContainer, !!options.supportIcons));
122
} else {
123
this.nameNode = new Label(this.nameContainer);
124
}
125
126
this.hoverDelegate = options?.hoverDelegate ?? getDefaultHoverDelegate('mouse');
127
}
128
129
get element(): HTMLElement {
130
return this.domNode.element;
131
}
132
133
setLabel(label: string | string[], description?: string, options?: IIconLabelValueOptions): void {
134
const labelClasses = ['monaco-icon-label'];
135
const containerClasses = ['monaco-icon-label-container'];
136
let ariaLabel: string = '';
137
if (options) {
138
if (options.extraClasses) {
139
labelClasses.push(...options.extraClasses);
140
}
141
142
if (options.bold) {
143
labelClasses.push('bold');
144
}
145
146
if (options.italic) {
147
labelClasses.push('italic');
148
}
149
150
if (options.strikethrough) {
151
labelClasses.push('strikethrough');
152
}
153
154
if (options.disabledCommand) {
155
containerClasses.push('disabled');
156
}
157
if (options.title) {
158
if (typeof options.title === 'string') {
159
ariaLabel += options.title;
160
} else {
161
ariaLabel += label;
162
}
163
}
164
}
165
166
// eslint-disable-next-line no-restricted-syntax
167
const existingIconNode = this.domNode.element.querySelector('.monaco-icon-label-iconpath');
168
if (options?.iconPath) {
169
let iconNode;
170
if (!existingIconNode || !(dom.isHTMLElement(existingIconNode))) {
171
iconNode = dom.$('.monaco-icon-label-iconpath');
172
this.domNode.element.prepend(iconNode);
173
} else {
174
iconNode = existingIconNode;
175
}
176
if (ThemeIcon.isThemeIcon(options.iconPath)) {
177
const iconClass = ThemeIcon.asClassName(options.iconPath);
178
iconNode.className = `monaco-icon-label-iconpath ${iconClass}`;
179
iconNode.style.backgroundImage = '';
180
} else {
181
iconNode.style.backgroundImage = css.asCSSUrl(options?.iconPath);
182
}
183
iconNode.style.backgroundRepeat = 'no-repeat';
184
iconNode.style.backgroundPosition = 'center';
185
iconNode.style.backgroundSize = 'contain';
186
187
} else if (existingIconNode) {
188
existingIconNode.remove();
189
}
190
191
this.domNode.classNames = labelClasses;
192
this.domNode.element.setAttribute('aria-label', ariaLabel);
193
this.labelContainer.classList.value = '';
194
this.labelContainer.classList.add(...containerClasses);
195
this.setupHover(options?.descriptionTitle ? this.labelContainer : this.element, options?.title);
196
197
this.nameNode.setLabel(label, options);
198
199
if (description || this.descriptionNode) {
200
const descriptionNode = this.getOrCreateDescriptionNode();
201
if (descriptionNode instanceof HighlightedLabel) {
202
const supportIcons = options?.supportIcons ?? this.creationOptions?.supportIcons;
203
descriptionNode.set(description || '', options ? options.descriptionMatches : undefined, undefined, options?.labelEscapeNewLines, supportIcons);
204
this.setupHover(descriptionNode.element, options?.descriptionTitle);
205
} else {
206
descriptionNode.textContent = description && options?.labelEscapeNewLines ? HighlightedLabel.escapeNewLines(description, []) : (description || '');
207
this.setupHover(descriptionNode.element, options?.descriptionTitle || '');
208
descriptionNode.empty = !description;
209
}
210
}
211
212
if (options?.suffix || this.suffixNode) {
213
const suffixNode = this.getOrCreateSuffixNode();
214
suffixNode.textContent = options?.suffix ?? '';
215
}
216
}
217
218
private setupHover(htmlElement: HTMLElement, tooltip: string | IManagedHoverTooltipMarkdownString | undefined): void {
219
const previousCustomHover = this.customHovers.get(htmlElement);
220
if (previousCustomHover) {
221
previousCustomHover.dispose();
222
this.customHovers.delete(htmlElement);
223
}
224
225
if (!tooltip) {
226
htmlElement.removeAttribute('title');
227
return;
228
}
229
230
let hoverTarget = htmlElement;
231
if (this.creationOptions?.hoverTargetOverride) {
232
if (!dom.isAncestor(htmlElement, this.creationOptions.hoverTargetOverride)) {
233
throw new Error('hoverTargetOverrride must be an ancestor of the htmlElement');
234
}
235
hoverTarget = this.creationOptions.hoverTargetOverride;
236
}
237
238
const hoverDisposable = getBaseLayerHoverDelegate().setupManagedHover(this.hoverDelegate, hoverTarget, tooltip);
239
if (hoverDisposable) {
240
this.customHovers.set(htmlElement, hoverDisposable);
241
}
242
}
243
244
public override dispose() {
245
super.dispose();
246
for (const disposable of this.customHovers.values()) {
247
disposable.dispose();
248
}
249
this.customHovers.clear();
250
}
251
252
private getOrCreateSuffixNode() {
253
if (!this.suffixNode) {
254
const suffixContainer = this._register(new FastLabelNode(dom.after(this.nameContainer, dom.$('span.monaco-icon-suffix-container'))));
255
this.suffixNode = this._register(new FastLabelNode(dom.append(suffixContainer.element, dom.$('span.label-suffix'))));
256
}
257
258
return this.suffixNode;
259
}
260
261
private getOrCreateDescriptionNode() {
262
if (!this.descriptionNode) {
263
const descriptionContainer = this._register(new FastLabelNode(dom.append(this.labelContainer, dom.$('span.monaco-icon-description-container'))));
264
if (this.creationOptions?.supportDescriptionHighlights) {
265
this.descriptionNode = this._register(new HighlightedLabel(dom.append(descriptionContainer.element, dom.$('span.label-description'))));
266
} else {
267
this.descriptionNode = this._register(new FastLabelNode(dom.append(descriptionContainer.element, dom.$('span.label-description'))));
268
}
269
}
270
271
return this.descriptionNode;
272
}
273
}
274
275
class Label {
276
277
private label: string | string[] | undefined = undefined;
278
private singleLabel: HTMLElement | undefined = undefined;
279
private options: IIconLabelValueOptions | undefined;
280
281
constructor(private container: HTMLElement) { }
282
283
setLabel(label: string | string[], options?: IIconLabelValueOptions): void {
284
if (this.label === label && equals(this.options, options)) {
285
return;
286
}
287
288
this.label = label;
289
this.options = options;
290
291
if (typeof label === 'string') {
292
if (!this.singleLabel) {
293
this.container.textContent = '';
294
this.container.classList.remove('multiple');
295
this.singleLabel = dom.append(this.container, dom.$('a.label-name', { id: options?.domId }));
296
}
297
298
this.singleLabel.textContent = label;
299
} else {
300
this.container.textContent = '';
301
this.container.classList.add('multiple');
302
this.singleLabel = undefined;
303
304
for (let i = 0; i < label.length; i++) {
305
const l = label[i];
306
const id = options?.domId && `${options?.domId}_${i}`;
307
308
dom.append(this.container, dom.$('a.label-name', { id, 'data-icon-label-count': label.length, 'data-icon-label-index': i, 'role': 'treeitem' }, l));
309
310
if (i < label.length - 1) {
311
dom.append(this.container, dom.$('span.label-separator', undefined, options?.separator || '/'));
312
}
313
}
314
}
315
}
316
}
317
318
function splitMatches(labels: string[], separator: string, matches: readonly IMatch[] | undefined): IMatch[][] | undefined {
319
if (!matches) {
320
return undefined;
321
}
322
323
let labelStart = 0;
324
325
return labels.map(label => {
326
const labelRange = { start: labelStart, end: labelStart + label.length };
327
328
const result = matches
329
.map(match => Range.intersect(labelRange, match))
330
.filter(range => !Range.isEmpty(range))
331
.map(({ start, end }) => ({ start: start - labelStart, end: end - labelStart }));
332
333
labelStart = labelRange.end + separator.length;
334
return result;
335
});
336
}
337
338
class LabelWithHighlights extends Disposable {
339
340
private label: string | string[] | undefined = undefined;
341
private singleLabel: HighlightedLabel | undefined = undefined;
342
private options: IIconLabelValueOptions | undefined;
343
344
constructor(private container: HTMLElement, private supportIcons: boolean) {
345
super();
346
}
347
348
setLabel(label: string | string[], options?: IIconLabelValueOptions): void {
349
if (this.label === label && equals(this.options, options)) {
350
return;
351
}
352
353
this.label = label;
354
this.options = options;
355
356
// Determine supportIcons: use option if provided, otherwise use constructor value
357
const supportIcons = options?.supportIcons ?? this.supportIcons;
358
359
if (typeof label === 'string') {
360
if (!this.singleLabel) {
361
this.container.textContent = '';
362
this.container.classList.remove('multiple');
363
this.singleLabel = this._register(new HighlightedLabel(dom.append(this.container, dom.$('a.label-name', { id: options?.domId }))));
364
}
365
366
this.singleLabel.set(label, options?.matches, undefined, options?.labelEscapeNewLines, supportIcons);
367
} else {
368
this.container.textContent = '';
369
this.container.classList.add('multiple');
370
this.singleLabel = undefined;
371
372
const separator = options?.separator || '/';
373
const matches = splitMatches(label, separator, options?.matches);
374
375
for (let i = 0; i < label.length; i++) {
376
const l = label[i];
377
const m = matches ? matches[i] : undefined;
378
const id = options?.domId && `${options?.domId}_${i}`;
379
380
const name = dom.$('a.label-name', { id, 'data-icon-label-count': label.length, 'data-icon-label-index': i, 'role': 'treeitem' });
381
const highlightedLabel = this._register(new HighlightedLabel(dom.append(this.container, name)));
382
highlightedLabel.set(l, m, undefined, options?.labelEscapeNewLines, supportIcons);
383
384
if (i < label.length - 1) {
385
dom.append(name, dom.$('span.label-separator', undefined, separator));
386
}
387
}
388
}
389
}
390
}
391
392