textAreaHandler.ts 20.0 KB
Newer Older
E
Erich Gamma 已提交
1 2 3 4 5 6
/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/
'use strict';

7
import 'vs/css!./textAreaHandler';
8
import * as platform from 'vs/base/common/platform';
A
Alex Dima 已提交
9
import * as browser from 'vs/base/browser/browser';
10
import * as strings from 'vs/base/common/strings';
11
import { TextAreaInput, ITextAreaInputHost, IPasteData, ICompositionData } from 'vs/editor/browser/controller/textAreaInput';
12
import { ISimpleModel, ITypeData, TextAreaState, PagedScreenReaderStrategy } from 'vs/editor/browser/controller/textAreaState';
J
Johannes Rieken 已提交
13
import { Range } from 'vs/editor/common/core/range';
14 15
import { Selection } from 'vs/editor/common/core/selection';
import { Position } from 'vs/editor/common/core/position';
J
Johannes Rieken 已提交
16 17
import { Configuration } from 'vs/editor/browser/config/configuration';
import { ViewContext } from 'vs/editor/common/view/viewContext';
18
import { HorizontalRange, RenderingContext, RestrictedRenderingContext } from 'vs/editor/common/view/renderingContext';
19
import * as viewEvents from 'vs/editor/common/view/viewEvents';
20
import { FastDomNode, createFastDomNode } from 'vs/base/browser/fastDomNode';
21
import { ViewController } from 'vs/editor/browser/view/viewController';
22
import { ScrollType } from 'vs/editor/common/editorCommon';
23 24 25 26 27
import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent';
import { PartFingerprints, PartFingerprint, ViewPart } from 'vs/editor/browser/view/viewPart';
import { Margin } from 'vs/editor/browser/viewParts/margin/margin';
import { LineNumbersOverlay } from 'vs/editor/browser/viewParts/lineNumbers/lineNumbers';
import { BareFontInfo } from 'vs/editor/common/config/fontInfo';
28
import { RenderLineNumbersType } from 'vs/editor/common/config/editorOptions';
A
Alex Dima 已提交
29
import { EndOfLinePreference } from 'vs/editor/common/model';
30
import { getMapForWordSeparators, WordCharacterClass } from 'vs/editor/common/controller/wordCharacterClassifier';
31

32
export interface ITextAreaHandlerHelper {
33
	visibleRangeForPositionRelativeToEditor(lineNumber: number, column: number): HorizontalRange;
34
}
35

36
class VisibleTextAreaData {
37
	_visibleTextAreaBrand: void;
38 39 40

	public readonly top: number;
	public readonly left: number;
41
	public readonly width: number;
42

43
	constructor(top: number, left: number, width: number) {
44 45
		this.top = top;
		this.left = left;
46 47 48
		this.width = width;
	}

49 50
	public setWidth(width: number): VisibleTextAreaData {
		return new VisibleTextAreaData(this.top, this.left, width);
51 52
	}
}
53

54 55
const canUseZeroSizeTextarea = (browser.isEdgeOrIE || browser.isFirefox);

56 57 58
interface LocalClipboardMetadata {
	lastCopiedValue: string;
	isFromEmptySelection: boolean;
59
	multicursorText: string[];
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89
}

/**
 * Every time we write to the clipboard, we record a bit of extra metadata here.
 * Every time we read from the cipboard, if the text matches our last written text,
 * we can fetch the previous metadata.
 */
class LocalClipboardMetadataManager {
	public static INSTANCE = new LocalClipboardMetadataManager();

	private _lastState: LocalClipboardMetadata;

	constructor() {
		this._lastState = null;
	}

	public set(state: LocalClipboardMetadata): void {
		this._lastState = state;
	}

	public get(pastedText: string): LocalClipboardMetadata {
		if (this._lastState && this._lastState.lastCopiedValue === pastedText) {
			// match!
			return this._lastState;
		}
		this._lastState = null;
		return null;
	}
}

90
export class TextAreaHandler extends ViewPart {
91

92
	private readonly _viewController: ViewController;
93
	private readonly _viewHelper: ITextAreaHandlerHelper;
94
	private _accessibilitySupport: platform.AccessibilitySupport;
95 96
	private _contentLeft: number;
	private _contentWidth: number;
97
	private _contentHeight: number;
98 99
	private _scrollLeft: number;
	private _scrollTop: number;
100 101
	private _fontInfo: BareFontInfo;
	private _lineHeight: number;
102
	private _emptySelectionClipboard: boolean;
103 104 105 106

	/**
	 * Defined only when the text area is visible (composition case).
	 */
107
	private _visibleTextArea: VisibleTextAreaData;
108
	private _selections: Selection[];
A
Alex Dima 已提交
109

110 111
	public readonly textArea: FastDomNode<HTMLTextAreaElement>;
	public readonly textAreaCover: FastDomNode<HTMLElement>;
112
	private readonly _textAreaInput: TextAreaInput;
113

114
	constructor(context: ViewContext, viewController: ViewController, viewHelper: ITextAreaHandlerHelper) {
115
		super(context);
E
Erich Gamma 已提交
116

117 118
		this._viewController = viewController;
		this._viewHelper = viewHelper;
119

120 121
		const conf = this._context.configuration.editor;

122
		this._accessibilitySupport = conf.accessibilitySupport;
123 124 125
		this._contentLeft = conf.layoutInfo.contentLeft;
		this._contentWidth = conf.layoutInfo.contentWidth;
		this._contentHeight = conf.layoutInfo.contentHeight;
126 127
		this._scrollLeft = 0;
		this._scrollTop = 0;
128 129
		this._fontInfo = conf.fontInfo;
		this._lineHeight = conf.lineHeight;
130
		this._emptySelectionClipboard = conf.emptySelectionClipboard;
131

132
		this._visibleTextArea = null;
133
		this._selections = [new Selection(1, 1, 1, 1)];
A
Alex Dima 已提交
134

135 136 137 138 139 140 141
		// Text Area (The focus will always be in the textarea when the cursor is blinking)
		this.textArea = createFastDomNode(document.createElement('textarea'));
		PartFingerprints.write(this.textArea, PartFingerprint.TextArea);
		this.textArea.setClassName('inputarea');
		this.textArea.setAttribute('wrap', 'off');
		this.textArea.setAttribute('autocorrect', 'off');
		this.textArea.setAttribute('autocapitalize', 'off');
142
		this.textArea.setAttribute('autocomplete', 'off');
143
		this.textArea.setAttribute('spellcheck', 'false');
144
		this.textArea.setAttribute('aria-label', conf.viewInfo.ariaLabel);
145 146 147 148 149 150 151 152
		this.textArea.setAttribute('role', 'textbox');
		this.textArea.setAttribute('aria-multiline', 'true');
		this.textArea.setAttribute('aria-haspopup', 'false');
		this.textArea.setAttribute('aria-autocomplete', 'both');

		this.textAreaCover = createFastDomNode(document.createElement('div'));
		this.textAreaCover.setPosition('absolute');

153 154 155 156 157 158 159 160 161 162 163 164
		const simpleModel: ISimpleModel = {
			getLineCount: (): number => {
				return this._context.model.getLineCount();
			},
			getLineMaxColumn: (lineNumber: number): number => {
				return this._context.model.getLineMaxColumn(lineNumber);
			},
			getValueInRange: (range: Range, eol: EndOfLinePreference): string => {
				return this._context.model.getValueInRange(range, eol);
			}
		};

165
		const textAreaInputHost: ITextAreaInputHost = {
A
Alex Dima 已提交
166
			getPlainTextToCopy: (): string => {
167
				const rawWhatToCopy = this._context.model.getPlainTextToCopy(this._selections, this._emptySelectionClipboard, platform.isWindows);
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186
				const newLineCharacter = this._context.model.getEOL();

				const isFromEmptySelection = (this._emptySelectionClipboard && this._selections.length === 1 && this._selections[0].isEmpty());
				const multicursorText = (Array.isArray(rawWhatToCopy) ? rawWhatToCopy : null);
				const whatToCopy = (Array.isArray(rawWhatToCopy) ? rawWhatToCopy.join(newLineCharacter) : rawWhatToCopy);

				let metadata: LocalClipboardMetadata = null;
				if (isFromEmptySelection || multicursorText) {
					// Only store the non-default metadata

					// When writing "LINE\r\n" to the clipboard and then pasting,
					// Firefox pastes "LINE\n", so let's work around this quirk
					const lastCopiedValue = (browser.isFirefox ? whatToCopy.replace(/\r\n/g, '\n') : whatToCopy);
					metadata = {
						lastCopiedValue: lastCopiedValue,
						isFromEmptySelection: (this._emptySelectionClipboard && this._selections.length === 1 && this._selections[0].isEmpty()),
						multicursorText: multicursorText
					};
				}
A
Alex Dima 已提交
187

188
				LocalClipboardMetadataManager.INSTANCE.set(metadata);
A
Alex Dima 已提交
189 190 191

				return whatToCopy;
			},
192

A
Alex Dima 已提交
193
			getHTMLToCopy: (): string => {
194
				return this._context.model.getHTMLToCopy(this._selections, this._emptySelectionClipboard);
A
Alex Dima 已提交
195
			},
196 197 198 199 200 201 202 203

			getScreenReaderContent: (currentState: TextAreaState): TextAreaState => {

				if (browser.isIPad) {
					// Do not place anything in the textarea for the iPad
					return TextAreaState.EMPTY;
				}

204 205
				if (this._accessibilitySupport === platform.AccessibilitySupport.Disabled) {
					// We know for a fact that a screen reader is not attached
206
					// On OSX, we write the character before the cursor to allow for "long-press" composition
207
					// Also on OSX, we write the word before the cursor to allow for the Accessibility Keyboard to give good hints
208 209 210 211
					if (platform.isMacintosh) {
						const selection = this._selections[0];
						if (selection.isEmpty()) {
							const position = selection.getStartPosition();
212 213 214 215 216 217 218 219

							let textBefore = this._getWordBeforePosition(position);
							if (textBefore.length === 0) {
								textBefore = this._getCharacterBeforePosition(position);
							}

							if (textBefore.length > 0) {
								return new TextAreaState(textBefore, textBefore.length, textBefore.length, position, position);
220 221 222
							}
						}
					}
223 224 225
					return TextAreaState.EMPTY;
				}

226
				return PagedScreenReaderStrategy.fromEditorSelection(currentState, simpleModel, this._selections[0], this._accessibilitySupport === platform.AccessibilitySupport.Unknown);
227 228 229 230
			},

			deduceModelPosition: (viewAnchorPosition: Position, deltaOffset: number, lineFeedCnt: number): Position => {
				return this._context.model.deduceModelPositionRelativeToViewPosition(viewAnchorPosition, deltaOffset, lineFeedCnt);
A
Alex Dima 已提交
231 232
			}
		};
233

234
		this._textAreaInput = this._register(new TextAreaInput(textAreaInputHost, this.textArea));
235 236 237 238 239 240 241 242 243 244

		this._register(this._textAreaInput.onKeyDown((e: IKeyboardEvent) => {
			this._viewController.emitKeyDown(e);
		}));

		this._register(this._textAreaInput.onKeyUp((e: IKeyboardEvent) => {
			this._viewController.emitKeyUp(e);
		}));

		this._register(this._textAreaInput.onPaste((e: IPasteData) => {
245 246
			const metadata = LocalClipboardMetadataManager.INSTANCE.get(e.text);

A
Alex Dima 已提交
247
			let pasteOnNewLine = false;
248
			let multicursorText: string[] = null;
249 250
			if (metadata) {
				pasteOnNewLine = (this._emptySelectionClipboard && metadata.isFromEmptySelection);
251
				multicursorText = metadata.multicursorText;
A
Alex Dima 已提交
252
			}
253
			this._viewController.paste('keyboard', e.text, pasteOnNewLine, multicursorText);
254 255 256 257
		}));

		this._register(this._textAreaInput.onCut(() => {
			this._viewController.cut('keyboard');
A
Alex Dima 已提交
258
		}));
259 260

		this._register(this._textAreaInput.onType((e: ITypeData) => {
261
			if (e.replaceCharCnt) {
262
				this._viewController.replacePreviousChar('keyboard', e.text, e.replaceCharCnt);
263
			} else {
264
				this._viewController.type('keyboard', e.text);
265 266
			}
		}));
267

268 269 270 271
		this._register(this._textAreaInput.onSelectionChangeRequest((modelSelection: Selection) => {
			this._viewController.setSelection('keyboard', modelSelection);
		}));

272
		this._register(this._textAreaInput.onCompositionStart(() => {
A
Alex Dima 已提交
273 274
			const lineNumber = this._selections[0].startLineNumber;
			const column = this._selections[0].startColumn;
275

276
			this._context.privateViewEventBus.emit(new viewEvents.ViewRevealRangeRequestEvent(
A
Alex Dima 已提交
277
				new Range(lineNumber, column, lineNumber, column),
278
				viewEvents.VerticalRevealType.Simple,
279 280
				true,
				ScrollType.Immediate
A
Alex Dima 已提交
281
			));
282 283

			// Find range pixel position
284
			const visibleRange = this._viewHelper.visibleRangeForPositionRelativeToEditor(lineNumber, column);
285 286

			if (visibleRange) {
287 288
				this._visibleTextArea = new VisibleTextAreaData(
					this._context.viewLayout.getVerticalOffsetForLineNumber(lineNumber),
289 290 291
					visibleRange.left,
					canUseZeroSizeTextarea ? 0 : 1
				);
292
				this._render();
293 294 295
			}

			// Show the textarea
296
			this.textArea.setClassName('inputarea ime-input');
297

298
			this._viewController.compositionStart('keyboard');
299
		}));
300

301
		this._register(this._textAreaInput.onCompositionUpdate((e: ICompositionData) => {
302 303 304
			if (browser.isEdgeOrIE) {
				// Due to isEdgeOrIE (where the textarea was not cleared initially)
				// we cannot assume the text consists only of the composited text
305
				this._visibleTextArea = this._visibleTextArea.setWidth(0);
306 307
			} else {
				// adjust width by its size
308
				this._visibleTextArea = this._visibleTextArea.setWidth(measureText(e.data, this._fontInfo));
309
			}
310
			this._render();
311 312
		}));

313
		this._register(this._textAreaInput.onCompositionEnd(() => {
314

315 316
			this._visibleTextArea = null;
			this._render();
317

318
			this.textArea.setClassName('inputarea');
319
			this._viewController.compositionEnd('keyboard');
320
		}));
321

322 323 324 325 326 327 328
		this._register(this._textAreaInput.onFocus(() => {
			this._context.privateViewEventBus.emit(new viewEvents.ViewFocusChangedEvent(true));
		}));

		this._register(this._textAreaInput.onBlur(() => {
			this._context.privateViewEventBus.emit(new viewEvents.ViewFocusChangedEvent(false));
		}));
E
Erich Gamma 已提交
329 330
	}

331
	public dispose(): void {
A
Alex Dima 已提交
332
		super.dispose();
333 334
	}

335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363
	private _getWordBeforePosition(position: Position): string {
		const lineContent = this._context.model.getLineContent(position.lineNumber);
		const wordSeparators = getMapForWordSeparators(this._context.configuration.editor.wordSeparators);

		let column = position.column;
		let distance = 0;
		while (column > 1) {
			const charCode = lineContent.charCodeAt(column - 2);
			const charClass = wordSeparators.get(charCode);
			if (charClass !== WordCharacterClass.Regular || distance > 50) {
				return lineContent.substring(column - 1, position.column - 1);
			}
			distance++;
			column--;
		}
		return lineContent.substring(0, position.column - 1);
	}

	private _getCharacterBeforePosition(position: Position): string {
		if (position.column > 1) {
			const lineContent = this._context.model.getLineContent(position.lineNumber);
			const charBefore = lineContent.charAt(position.column - 2);
			if (!strings.isHighSurrogate(charBefore.charCodeAt(0))) {
				return charBefore;
			}
		}
		return '';
	}

364 365
	// --- begin event handlers

A
Alex Dima 已提交
366
	public onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): boolean {
367 368
		const conf = this._context.configuration.editor;

369
		if (e.fontInfo) {
370
			this._fontInfo = conf.fontInfo;
371
		}
A
Alex Dima 已提交
372
		if (e.viewInfo) {
373
			this.textArea.setAttribute('aria-label', conf.viewInfo.ariaLabel);
374
		}
A
Alex Dima 已提交
375
		if (e.layoutInfo) {
376 377 378
			this._contentLeft = conf.layoutInfo.contentLeft;
			this._contentWidth = conf.layoutInfo.contentWidth;
			this._contentHeight = conf.layoutInfo.contentHeight;
A
Alex Dima 已提交
379
		}
380
		if (e.lineHeight) {
381
			this._lineHeight = conf.lineHeight;
382
		}
383 384 385 386
		if (e.accessibilitySupport) {
			this._accessibilitySupport = conf.accessibilitySupport;
			this._textAreaInput.writeScreenReaderContent('strategy changed');
		}
387 388 389
		if (e.emptySelectionClipboard) {
			this._emptySelectionClipboard = conf.emptySelectionClipboard;
		}
390

391
		return true;
392
	}
393 394
	public onCursorStateChanged(e: viewEvents.ViewCursorStateChangedEvent): boolean {
		this._selections = e.selections.slice(0);
395
		this._textAreaInput.writeScreenReaderContent('selection changed');
396
		return true;
397
	}
398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413
	public onDecorationsChanged(e: viewEvents.ViewDecorationsChangedEvent): boolean {
		// true for inline decorations that can end up relayouting text
		return true;
	}
	public onFlushed(e: viewEvents.ViewFlushedEvent): boolean {
		return true;
	}
	public onLinesChanged(e: viewEvents.ViewLinesChangedEvent): boolean {
		return true;
	}
	public onLinesDeleted(e: viewEvents.ViewLinesDeletedEvent): boolean {
		return true;
	}
	public onLinesInserted(e: viewEvents.ViewLinesInsertedEvent): boolean {
		return true;
	}
414
	public onScrollChanged(e: viewEvents.ViewScrollChangedEvent): boolean {
415 416
		this._scrollLeft = e.scrollLeft;
		this._scrollTop = e.scrollTop;
417 418 419
		return true;
	}
	public onZonesChanged(e: viewEvents.ViewZonesChangedEvent): boolean {
420
		return true;
421 422
	}

423 424
	// --- end event handlers

425 426
	// --- begin view API

A
Alex Dima 已提交
427 428 429 430 431 432 433 434
	public isFocused(): boolean {
		return this._textAreaInput.isFocused();
	}

	public focusTextArea(): void {
		this._textAreaInput.focusTextArea();
	}

435
	// --- end view API
436

437 438
	private _primaryCursorVisibleRange: HorizontalRange = null;

439
	public prepareRender(ctx: RenderingContext): void {
440 441 442 443 444 445 446 447
		if (this._accessibilitySupport === platform.AccessibilitySupport.Enabled) {
			// Do not move the textarea with the cursor, as this generates accessibility events that might confuse screen readers
			// See https://github.com/Microsoft/vscode/issues/26730
			this._primaryCursorVisibleRange = null;
		} else {
			const primaryCursorPosition = new Position(this._selections[0].positionLineNumber, this._selections[0].positionColumn);
			this._primaryCursorVisibleRange = ctx.visibleRangeForPosition(primaryCursorPosition);
		}
448 449 450
	}

	public render(ctx: RestrictedRenderingContext): void {
451
		this._textAreaInput.writeScreenReaderContent('render');
452 453 454 455 456 457
		this._render();
	}

	private _render(): void {
		if (this._visibleTextArea) {
			// The text area is visible for composition reasons
458 459 460 461
			this._renderInsideEditor(
				this._visibleTextArea.top - this._scrollTop,
				this._contentLeft + this._visibleTextArea.left - this._scrollLeft,
				this._visibleTextArea.width,
462 463
				this._lineHeight,
				true
464 465 466
			);
			return;
		}
467

468 469 470 471 472
		if (!this._primaryCursorVisibleRange) {
			// The primary cursor is outside the viewport => place textarea to the top left
			this._renderAtTopLeft();
			return;
		}
473

474 475 476 477 478 479 480
		const left = this._contentLeft + this._primaryCursorVisibleRange.left - this._scrollLeft;
		if (left < this._contentLeft || left > this._contentLeft + this._contentWidth) {
			// cursor is outside the viewport
			this._renderAtTopLeft();
			return;
		}

481
		const top = this._context.viewLayout.getVerticalOffsetForLineNumber(this._selections[0].positionLineNumber) - this._scrollTop;
482 483 484 485 486 487 488
		if (top < 0 || top > this._contentHeight) {
			// cursor is outside the viewport
			this._renderAtTopLeft();
			return;
		}

		// The primary cursor is in the viewport (at least vertically) => place textarea on the cursor
489 490 491 492 493
		this._renderInsideEditor(
			top, left,
			canUseZeroSizeTextarea ? 0 : 1, canUseZeroSizeTextarea ? 0 : 1,
			false
		);
494 495
	}

496
	private _renderInsideEditor(top: number, left: number, width: number, height: number, useEditorFont: boolean): void {
497 498 499
		const ta = this.textArea;
		const tac = this.textAreaCover;

500 501 502 503
		if (useEditorFont) {
			Configuration.applyFontInfo(ta, this._fontInfo);
		} else {
			ta.setFontSize(1);
504
			ta.setLineHeight(this._fontInfo.lineHeight);
505 506
		}

507 508 509 510 511 512 513 514 515 516 517 518 519 520 521
		ta.setTop(top);
		ta.setLeft(left);
		ta.setWidth(width);
		ta.setHeight(height);

		tac.setTop(0);
		tac.setLeft(0);
		tac.setWidth(0);
		tac.setHeight(0);
	}

	private _renderAtTopLeft(): void {
		const ta = this.textArea;
		const tac = this.textAreaCover;

522
		Configuration.applyFontInfo(ta, this._fontInfo);
523 524 525 526 527 528 529 530 531 532 533 534
		ta.setTop(0);
		ta.setLeft(0);
		tac.setTop(0);
		tac.setLeft(0);

		if (canUseZeroSizeTextarea) {
			ta.setWidth(0);
			ta.setHeight(0);
			tac.setWidth(0);
			tac.setHeight(0);
			return;
		}
535

536 537
		// (in WebKit the textarea is 1px by 1px because it cannot handle input to a 0x0 textarea)
		// specifically, when doing Korean IME, setting the textare to 0x0 breaks IME badly.
538

539 540 541 542 543 544
		ta.setWidth(1);
		ta.setHeight(1);
		tac.setWidth(1);
		tac.setHeight(1);

		if (this._context.configuration.editor.viewInfo.glyphMargin) {
A
Alex Dima 已提交
545
			tac.setClassName('monaco-editor-background textAreaCover ' + Margin.OUTER_CLASS_NAME);
546
		} else {
547
			if (this._context.configuration.editor.viewInfo.renderLineNumbers !== RenderLineNumbersType.Off) {
548
				tac.setClassName('monaco-editor-background textAreaCover ' + LineNumbersOverlay.CLASS_NAME);
549
			} else {
550
				tac.setClassName('monaco-editor-background textAreaCover');
551 552 553
			}
		}
	}
554
}
A
Alex Dima 已提交
555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580

function measureText(text: string, fontInfo: BareFontInfo): number {
	// adjust width by its size
	const canvasElem = <HTMLCanvasElement>document.createElement('canvas');
	const context = canvasElem.getContext('2d');
	context.font = createFontString(fontInfo);
	const metrics = context.measureText(text);

	if (browser.isFirefox) {
		return metrics.width + 2; // +2 for Japanese...
	} else {
		return metrics.width;
	}
}

function createFontString(bareFontInfo: BareFontInfo): string {
	return doCreateFontString('normal', bareFontInfo.fontWeight, bareFontInfo.fontSize, bareFontInfo.lineHeight, bareFontInfo.fontFamily);
}

function doCreateFontString(fontStyle: string, fontWeight: string, fontSize: number, lineHeight: number, fontFamily: string): string {
	// The full font syntax is:
	// style | variant | weight | stretch | size/line-height | fontFamily
	// (https://developer.mozilla.org/en-US/docs/Web/CSS/font)
	// But it appears Edge and IE11 cannot properly parse `stretch`.
	return `${fontStyle} normal ${fontWeight} ${fontSize}px / ${lineHeight}px ${fontFamily}`;
}