blob: 9b88e9cd949a5e8b9bdb5fe78101596a062354a8 [file] [log] [blame]
Ben Rohlfse8654b32024-04-18 10:06:05 +02001/**
2 * @license
3 * Copyright 2024 Google LLC
4 * SPDX-License-Identifier: Apache-2.0
5 */
6import {LitElement, html, css} from 'lit';
7import {customElement, property, query, queryAsync} from 'lit/decorators.js';
8import {classMap} from 'lit/directives/class-map.js';
9import {ifDefined} from 'lit/directives/if-defined.js';
Ben Rohlfs9a756962024-04-22 09:15:50 +020010import {
11 GrTextarea as GrTextareaApi,
12 HintAppliedEventDetail,
13 HintShownEventDetail,
14 HintDismissedEventDetail,
15 CursorPositionChangeEventDetail,
16} from '../api/embed';
Ben Rohlfse8654b32024-04-18 10:06:05 +020017
18/**
19 * Waits for the next animation frame.
20 */
21async function animationFrame(): Promise<void> {
22 return new Promise(resolve => {
23 requestAnimationFrame(() => {
24 resolve();
25 });
26 });
27}
28
29/**
30 * Whether the current browser supports `plaintext-only` for contenteditable
31 * https://caniuse.com/mdn-html_global_attributes_contenteditable_plaintext-only
32 */
33function supportsPlainTextEditing() {
34 const div = document.createElement('div');
35 try {
36 div.contentEditable = 'PLAINTEXT-ONLY';
37 return div.contentEditable === 'plaintext-only';
38 } catch (e) {
39 return false;
40 }
41}
42
Ben Rohlfse8654b32024-04-18 10:06:05 +020043/** Class for autocomplete hint */
44export const AUTOCOMPLETE_HINT_CLASS = 'autocomplete-hint';
45
46const ACCEPT_PLACEHOLDER_HINT_LABEL =
47 'Press TAB to accept the placeholder hint.';
48
49/**
50 * A custom textarea component which allows autocomplete functionality.
51 * This component is only supported in Chrome. Other browsers are not supported.
52 *
53 * Example usage:
54 * <gr-textarea></gr-textarea>
55 */
56@customElement('gr-textarea')
Ben Rohlfs9a756962024-04-22 09:15:50 +020057export class GrTextarea extends LitElement implements GrTextareaApi {
Ben Rohlfse8654b32024-04-18 10:06:05 +020058 // editableDivElement is available right away where it may be undefined. This
59 // is used for calls for scrollTop as if it is undefined then we can fallback
60 // to 0. For other usecases use editableDiv.
61 @query('.editableDiv')
62 private readonly editableDivElement?: HTMLDivElement;
63
64 @queryAsync('.editableDiv')
65 private readonly editableDiv?: Promise<HTMLDivElement>;
66
67 @property({type: Boolean, reflect: true}) disabled = false;
68
69 @property({type: String, reflect: true}) placeholder: string | undefined;
70
71 /**
72 * The hint is shown as a autocomplete string which can be added by pressing
73 * TAB.
74 *
75 * The hint is shown
76 * 1. At the cursor position, only when cursor position is at the end of
77 * textarea content.
78 * 2. When textarea has focus.
79 * 3. When selection inside the textarea is collapsed.
80 *
81 * When hint is applied listen for hintApplied event and remove the hint
82 * as component property to avoid showing the hint again.
83 */
84 @property({type: String})
85 set hint(newHint) {
86 if (this.hint !== newHint) {
87 this.innerHint = newHint;
88 this.updateHintInDomIfRendered();
89 }
90 }
91
92 get hint() {
93 return this.innerHint;
94 }
95
96 /**
97 * Show hint is shown as placeholder which people can autocomplete to.
98 *
99 * This takes precedence over hint property.
100 * It is shown even when textarea has no focus.
101 * This is shown only when textarea is blank.
102 */
103 @property({type: String}) placeholderHint: string | undefined;
104
105 /**
106 * Sets the value for textarea and also renders it in dom if it is different
107 * from last rendered value.
108 *
109 * To prevent cursor position from jumping to front of text even when value
110 * remains same, Check existing value before triggering the update and only
111 * update when there is a change.
112 *
113 * Also .innerText binding can't be used for security reasons.
114 */
115 @property({type: String})
116 set value(newValue) {
117 if (this.ignoreValue && this.ignoreValue === newValue) {
118 return;
119 }
120 const oldVal = this.value;
121 if (oldVal !== newValue) {
122 this.innerValue = newValue;
123 this.updateValueInDom();
124 }
125 }
126
127 get value() {
128 return this.innerValue;
129 }
130
131 /**
132 * This value will be ignored by textarea and is not set.
133 */
134 @property({type: String}) ignoreValue: string | undefined;
135
136 /**
137 * Sets cursor at the end of content on focus.
138 */
139 @property({type: Boolean}) putCursorAtEndOnFocus = false;
140
141 /**
142 * Enables save shortcut.
143 *
144 * On S key down with control or meta key enabled is exposed with output event
145 * 'saveShortcut'.
146 */
147 @property({type: Boolean}) enableSaveShortcut = false;
148
149 /*
150 * Is textarea focused. This is a readonly property.
151 */
152 get isFocused(): boolean {
153 return this.focused;
154 }
155
156 /**
157 * Native element for editable div.
158 */
159 get nativeElement() {
160 return this.editableDivElement;
161 }
162
163 /**
164 * Scroll Top for editable div.
165 */
166 override get scrollTop() {
167 return this.editableDivElement?.scrollTop ?? 0;
168 }
169
170 private innerValue: string | undefined;
171
172 private innerHint: string | undefined;
173
174 private focused = false;
175
176 private readonly isPlaintextOnlySupported = supportsPlainTextEditing();
177
178 static override get styles() {
179 return [
180 css`
181 :host {
182 display: inline-block;
183 position: relative;
184 width: 100%;
185 }
186
187 :host([disabled]) {
188 .editableDiv {
189 background-color: var(--input-field-disabled-bg, lightgrey);
190 color: var(--text-disabled, black);
191 cursor: default;
192 }
193 }
194
195 .editableDiv {
196 background-color: var(--input-field-bg, white);
Ben Rohlfs7efc9052024-04-22 12:24:58 +0200197 border: var(--gr-textarea-border-width, 2px) solid
198 var(--gr-textarea-border-color, white);
Ben Rohlfse8654b32024-04-18 10:06:05 +0200199 border-radius: 4px;
200 box-sizing: border-box;
201 color: var(--text-default, black);
Ben Rohlfs7efc9052024-04-22 12:24:58 +0200202 max-height: var(--gr-textarea-max-height, 16em);
203 min-height: var(--gr-textarea-min-height, 4em);
Ben Rohlfse8654b32024-04-18 10:06:05 +0200204 overflow-x: auto;
Ben Rohlfs7efc9052024-04-22 12:24:58 +0200205 padding: var(--gr-textarea-padding, 12px);
Ben Rohlfse8654b32024-04-18 10:06:05 +0200206 white-space: pre-wrap;
207 width: 100%;
208
209 &:focus-visible {
Ben Rohlfs7efc9052024-04-22 12:24:58 +0200210 border-color: var(--gr-textarea-focus-outline-color, black);
Ben Rohlfse8654b32024-04-18 10:06:05 +0200211 outline: none;
212 }
213
214 &:empty::before {
215 content: attr(data-placeholder);
216 color: var(--text-secondary, lightgrey);
217 display: inline;
218 pointer-events: none;
219 }
220
221 &.hintShown:empty::after,
222 .autocomplete-hint:empty::after {
223 background-color: var(--secondary-bg-color, white);
224 border: 1px solid var(--text-secondary, lightgrey);
225 border-radius: 2px;
226 content: 'tab';
227 color: var(--text-secondary, lightgrey);
228 display: inline;
229 pointer-events: none;
230 font-size: 10px;
231 line-height: 10px;
232 margin-left: 4px;
233 padding: 1px 3px;
234 }
235
236 .autocomplete-hint {
237 &:empty::before {
238 content: attr(data-hint);
239 color: var(--text-secondary, lightgrey);
240 }
241 }
242 }
243 `,
244 ];
245 }
246
247 override render() {
248 const isHintShownAsPlaceholder =
249 (!this.disabled && this.placeholderHint) ?? false;
250
251 const placeholder = isHintShownAsPlaceholder
252 ? this.placeholderHint
253 : this.placeholder;
254 const ariaPlaceholder = isHintShownAsPlaceholder
255 ? (this.placeholderHint ?? '') + ACCEPT_PLACEHOLDER_HINT_LABEL
256 : placeholder;
257
258 const classes = classMap({
259 editableDiv: true,
260 hintShown: isHintShownAsPlaceholder,
261 });
262
263 // Chrome supports non-standard "contenteditable=plaintext-only",
264 // which prevents HTML from being inserted into a contenteditable element.
265 // https://github.com/w3c/editing/issues/162
266 return html`<div
267 aria-disabled=${this.disabled}
268 aria-multiline="true"
269 aria-placeholder=${ifDefined(ariaPlaceholder)}
270 data-placeholder=${ifDefined(placeholder)}
271 class=${classes}
272 contenteditable=${this.contentEditableAttributeValue}
273 dir="ltr"
274 role="textbox"
275 @input=${this.onInput}
276 @focus=${this.onFocus}
277 @blur=${this.onBlur}
278 @keydown=${this.handleKeyDown}
279 @keyup=${this.handleKeyUp}
280 @mouseup=${this.handleMouseUp}
281 @scroll=${this.handleScroll}
282 ></div>`;
283 }
284
285 /**
286 * Focuses the textarea.
287 */
288 override async focus() {
289 const editableDivElement = await this.editableDiv;
290 const isFocused = this.isFocused;
291 editableDivElement?.focus?.();
292 // If already focused, do not change the cursor position.
293 if (this.putCursorAtEndOnFocus && !isFocused) {
294 await this.putCursorAtEnd();
295 }
296 }
297
298 /**
299 * Puts the cursor at the end of existing content.
300 * Scrolls the content of textarea towards the end.
301 */
302 async putCursorAtEnd() {
303 const editableDivElement = await this.editableDiv;
304 const selection = this.getSelection();
305
306 if (!editableDivElement || !selection) {
307 return;
308 }
309
310 const range = document.createRange();
311 editableDivElement.focus();
312 range.setStart(editableDivElement, editableDivElement.childNodes.length);
313 range.collapse(true);
314 selection.removeAllRanges();
315 selection.addRange(range);
316
317 this.scrollToCursorPosition(range);
318
319 range.detach();
320
Ben Rohlfs694894f2024-04-22 12:22:30 +0200321 this.onCursorPositionChange(null);
322 }
323
324 public setCursorPosition(position: number) {
325 this.setCursorPositionForDiv(position, this.editableDivElement);
326 }
327
328 public async setCursorPositionAsync(position: number) {
329 const editableDivElement = await this.editableDiv;
330 this.setCursorPositionForDiv(position, editableDivElement);
Ben Rohlfse8654b32024-04-18 10:06:05 +0200331 }
332
333 /**
334 * Sets cursor position to given position and scrolls the content to cursor
335 * position.
336 *
337 * If position is out of bounds of value of textarea then cursor is places at
338 * end of content of textarea.
339 */
Ben Rohlfs694894f2024-04-22 12:22:30 +0200340 private setCursorPositionForDiv(
341 position: number,
342 editableDivElement?: HTMLDivElement
343 ) {
Ben Rohlfse8654b32024-04-18 10:06:05 +0200344 // This will keep track of remaining offset to place the cursor.
345 let remainingOffset = position;
346 let isOnFreshLine = true;
347 let nodeToFocusOn: Node | null = null;
Ben Rohlfse8654b32024-04-18 10:06:05 +0200348 const selection = this.getSelection();
349
350 if (!editableDivElement || !selection) {
351 return;
352 }
353 editableDivElement.focus();
354 const findNodeToFocusOn = (childNodes: Node[]) => {
355 for (let i = 0; i < childNodes.length; i++) {
356 const childNode = childNodes[i];
357 let currentNodeLength = 0;
358
Ben Rohlfs694894f2024-04-22 12:22:30 +0200359 if (childNode.nodeType === Node.COMMENT_NODE) {
360 continue;
361 }
362
Ben Rohlfse8654b32024-04-18 10:06:05 +0200363 if (childNode.nodeName === 'BR') {
364 currentNodeLength++;
365 isOnFreshLine = true;
366 }
367
368 if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
369 currentNodeLength++;
370 }
371
372 isOnFreshLine = false;
373
374 if (childNode.nodeType === Node.TEXT_NODE && childNode.textContent) {
375 currentNodeLength += childNode.textContent.length;
376 }
377
378 if (remainingOffset <= currentNodeLength) {
379 nodeToFocusOn = childNode;
380 break;
381 } else {
382 remainingOffset -= currentNodeLength;
383 }
384
385 if (childNode.childNodes?.length > 0) {
386 findNodeToFocusOn(Array.from(childNode.childNodes));
387 }
388 }
389 };
390
Ben Rohlfse8654b32024-04-18 10:06:05 +0200391 findNodeToFocusOn(Array.from(editableDivElement.childNodes));
392
Ben Rohlfs694894f2024-04-22 12:22:30 +0200393 this.setFocusOnNode(
Ben Rohlfse8654b32024-04-18 10:06:05 +0200394 selection,
395 editableDivElement,
396 nodeToFocusOn,
397 remainingOffset
398 );
399 }
400
401 /**
402 * Replaces text from start and end cursor position.
403 */
404 setRangeText(replacement: string, start: number, end: number) {
405 const pre = this.value?.substring(0, start) ?? '';
406 const post = this.value?.substring(end, this.value?.length ?? 0) ?? '';
407
408 this.value = pre + replacement + post;
409 this.setCursorPosition(pre.length + replacement.length);
410 }
411
412 private get contentEditableAttributeValue() {
413 return this.disabled
414 ? 'false'
415 : this.isPlaintextOnlySupported
416 ? ('plaintext-only' as unknown as 'true')
417 : 'true';
418 }
419
Ben Rohlfs694894f2024-04-22 12:22:30 +0200420 private setFocusOnNode(
Ben Rohlfse8654b32024-04-18 10:06:05 +0200421 selection: Selection,
422 editableDivElement: Node,
423 nodeToFocusOn: Node | null,
424 remainingOffset: number
425 ) {
426 const range = document.createRange();
427 // If node is null or undefined then fallback to focus event which will put
428 // cursor at the end of content.
429 if (nodeToFocusOn === null) {
430 range.setStart(editableDivElement, editableDivElement.childNodes.length);
431 }
432 // If node to focus is BR then focus offset is number of nodes.
433 else if (nodeToFocusOn.nodeName === 'BR') {
434 const nextNode = nodeToFocusOn.nextSibling ?? nodeToFocusOn;
435 range.setEnd(nextNode, 0);
436 } else {
437 range.setStart(nodeToFocusOn, remainingOffset);
438 }
439
440 range.collapse(true);
441 selection.removeAllRanges();
442 selection.addRange(range);
443
444 // Scroll the content to cursor position.
445 this.scrollToCursorPosition(range);
446
447 range.detach();
448
Ben Rohlfs694894f2024-04-22 12:22:30 +0200449 this.onCursorPositionChange(null);
Ben Rohlfse8654b32024-04-18 10:06:05 +0200450 }
451
452 private async onInput(event: Event) {
453 event.preventDefault();
454 event.stopImmediatePropagation();
455
456 const value = await this.getValue();
457 this.innerValue = value;
458
Ben Rohlfsdd88cd92024-05-08 13:29:39 +0200459 this.fire('input', {value: this.value});
Ben Rohlfse8654b32024-04-18 10:06:05 +0200460 }
461
Ben Rohlfs694894f2024-04-22 12:22:30 +0200462 private onFocus(event: Event) {
Ben Rohlfse8654b32024-04-18 10:06:05 +0200463 this.focused = true;
Ben Rohlfs694894f2024-04-22 12:22:30 +0200464 this.onCursorPositionChange(event);
Ben Rohlfse8654b32024-04-18 10:06:05 +0200465 }
466
Ben Rohlfs694894f2024-04-22 12:22:30 +0200467 private onBlur(event: Event) {
Ben Rohlfse8654b32024-04-18 10:06:05 +0200468 this.focused = false;
469 this.removeHintSpanIfShown();
Ben Rohlfs694894f2024-04-22 12:22:30 +0200470 this.onCursorPositionChange(event);
Ben Rohlfse8654b32024-04-18 10:06:05 +0200471 }
472
473 private async handleKeyDown(event: KeyboardEvent) {
474 if (
475 event.key === 'Tab' &&
476 !event.shiftKey &&
477 !event.ctrlKey &&
478 !event.metaKey
479 ) {
480 await this.handleTabKeyPress(event);
481 return;
482 }
483 if (
484 this.enableSaveShortcut &&
485 event.key === 's' &&
486 (event.ctrlKey || event.metaKey)
487 ) {
488 event.preventDefault();
Ben Rohlfsdd88cd92024-05-08 13:29:39 +0200489 this.fire('saveShortcut');
Ben Rohlfse8654b32024-04-18 10:06:05 +0200490 }
491 await this.toggleHintVisibilityIfAny();
492 }
493
Ben Rohlfs694894f2024-04-22 12:22:30 +0200494 private handleKeyUp(event: KeyboardEvent) {
495 this.onCursorPositionChange(event);
Ben Rohlfse8654b32024-04-18 10:06:05 +0200496 }
497
498 private async handleMouseUp(event: MouseEvent) {
Ben Rohlfs694894f2024-04-22 12:22:30 +0200499 this.onCursorPositionChange(event);
Ben Rohlfse8654b32024-04-18 10:06:05 +0200500 await this.toggleHintVisibilityIfAny();
501 }
502
503 private handleScroll() {
Ben Rohlfsdd88cd92024-05-08 13:29:39 +0200504 this.fire('scroll');
505 }
506
507 private fire<T>(type: string, detail?: T) {
508 this.dispatchEvent(
509 new CustomEvent(type, {detail, bubbles: true, composed: true})
510 );
Ben Rohlfse8654b32024-04-18 10:06:05 +0200511 }
512
513 private async handleTabKeyPress(event: KeyboardEvent) {
514 const oldValue = this.value;
515 if (this.placeholderHint && !oldValue) {
516 event.preventDefault();
517 await this.appendHint(this.placeholderHint, event);
518 } else if (this.hasHintSpan()) {
519 event.preventDefault();
520 await this.appendHint(this.hint!, event);
521 }
522 }
523
524 private async appendHint(hint: string, event: Event) {
525 const oldValue = this.value ?? '';
526 const newValue = oldValue + hint;
527
528 this.value = newValue;
529 await this.putCursorAtEnd();
530 await this.onInput(event);
531
Ben Rohlfsdd88cd92024-05-08 13:29:39 +0200532 this.fire('hintApplied', {hint, oldValue});
Ben Rohlfse8654b32024-04-18 10:06:05 +0200533 }
534
535 private async toggleHintVisibilityIfAny() {
536 // Wait for the next animation frame so that entered key is processed and
537 // available in dom.
538 await animationFrame();
539
540 const editableDivElement = await this.editableDiv;
541 const currentValue = (await this.getValue()) ?? '';
Ben Rohlfs694894f2024-04-22 12:22:30 +0200542 const cursorPosition = await this.getCursorPositionAsync();
Ben Rohlfse8654b32024-04-18 10:06:05 +0200543 if (
544 !editableDivElement ||
545 (this.placeholderHint && !currentValue) ||
546 !this.hint ||
547 !this.isFocused ||
548 cursorPosition !== currentValue.length
549 ) {
550 this.removeHintSpanIfShown();
551 return;
552 }
553
554 const hintSpan = this.hintSpan();
555 if (!hintSpan) {
556 this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
557 return;
558 }
559
560 const oldHint = (hintSpan as HTMLElement).dataset['hint'];
561 if (oldHint !== this.hint) {
562 this.removeHintSpanIfShown();
563 this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
564 }
565 }
566
567 private addHintSpanAtEndOfContent(editableDivElement: Node, hint: string) {
Ben Rohlfsdd88cd92024-05-08 13:29:39 +0200568 const oldValue = this.value ?? '';
Ben Rohlfse8654b32024-04-18 10:06:05 +0200569 const hintSpan = document.createElement('span');
570 hintSpan.classList.add(AUTOCOMPLETE_HINT_CLASS);
571 hintSpan.setAttribute('role', 'alert');
572 hintSpan.setAttribute(
573 'aria-label',
574 'Suggestion: ' + hint + ' Press TAB to accept it.'
575 );
576 hintSpan.dataset['hint'] = hint;
577 editableDivElement.appendChild(hintSpan);
Ben Rohlfsdd88cd92024-05-08 13:29:39 +0200578 this.fire('hintShown', {hint, oldValue});
Ben Rohlfse8654b32024-04-18 10:06:05 +0200579 }
580
581 private removeHintSpanIfShown() {
582 const hintSpan = this.hintSpan();
583 if (hintSpan) {
584 hintSpan.remove();
Ben Rohlfsdd88cd92024-05-08 13:29:39 +0200585 this.fire('hintDismissed', {
586 hint: (hintSpan as HTMLElement).dataset['hint'],
587 });
Ben Rohlfse8654b32024-04-18 10:06:05 +0200588 }
589 }
590
591 private hasHintSpan() {
592 return !!this.hintSpan();
593 }
594
595 private hintSpan() {
596 return this.shadowRoot?.querySelector('.' + AUTOCOMPLETE_HINT_CLASS);
597 }
598
Ben Rohlfs694894f2024-04-22 12:22:30 +0200599 private onCursorPositionChange(event: Event | null) {
Ben Rohlfse8654b32024-04-18 10:06:05 +0200600 event?.preventDefault();
601 event?.stopImmediatePropagation();
602
Ben Rohlfsdd88cd92024-05-08 13:29:39 +0200603 this.fire('cursorPositionChange', {position: this.getCursorPosition()});
Ben Rohlfse8654b32024-04-18 10:06:05 +0200604 }
605
606 private async updateValueInDom() {
Ben Rohlfs694894f2024-04-22 12:22:30 +0200607 const editableDivElement =
608 this.editableDivElement ?? (await this.editableDiv);
Ben Rohlfse8654b32024-04-18 10:06:05 +0200609 if (editableDivElement) {
610 editableDivElement.innerText = this.value || '';
611 }
612 }
613
614 private async updateHintInDomIfRendered() {
615 // Wait for editable div to render then process the hint.
616 await this.editableDiv;
617 await this.toggleHintVisibilityIfAny();
618 }
619
620 private async getValue() {
621 const editableDivElement = await this.editableDiv;
622 if (editableDivElement) {
623 const [output] = this.parseText(editableDivElement, false, true);
624 return output;
625 }
626 return '';
627 }
628
629 private parseText(
630 node: Node,
631 isLastBr: boolean,
632 isFirst: boolean
633 ): [string, boolean] {
634 let textValue = '';
635 let output = '';
636 if (node.nodeName === 'BR') {
637 return ['\n', true];
638 }
639
640 if (node.nodeType === Node.TEXT_NODE && node.textContent) {
641 return [node.textContent, false];
642 }
643
644 if (node.nodeName === 'DIV' && !isLastBr && !isFirst) {
645 textValue = '\n';
646 }
647
648 isLastBr = false;
649
650 for (let i = 0; i < node.childNodes?.length; i++) {
651 [output, isLastBr] = this.parseText(
652 node.childNodes[i],
653 isLastBr,
654 i === 0
655 );
656 textValue += output;
657 }
658 return [textValue, isLastBr];
659 }
660
Ben Rohlfs694894f2024-04-22 12:22:30 +0200661 public getCursorPosition() {
662 return this.getCursorPositionForDiv(this.editableDivElement);
663 }
664
665 public async getCursorPositionAsync() {
Ben Rohlfse8654b32024-04-18 10:06:05 +0200666 const editableDivElement = await this.editableDiv;
Ben Rohlfs694894f2024-04-22 12:22:30 +0200667 return this.getCursorPositionForDiv(editableDivElement);
668 }
669
670 private getCursorPositionForDiv(editableDivElement?: HTMLDivElement) {
671 const selection = this.getSelection();
Ben Rohlfse8654b32024-04-18 10:06:05 +0200672
673 // Cursor position is -1 (not available) if
674 //
675 // If textarea is not rendered.
676 // If textarea is not focused
677 // There is no accessible selection object.
678 // This is not a collapsed selection.
679 if (
680 !editableDivElement ||
681 !this.focused ||
682 !selection ||
683 selection.focusNode === null ||
684 !selection.isCollapsed
685 ) {
686 return -1;
687 }
688
689 let cursorPosition = 0;
690 let isOnFreshLine = true;
691
692 const findCursorPosition = (childNodes: Node[]) => {
693 for (let i = 0; i < childNodes.length; i++) {
694 const childNode = childNodes[i];
695
696 if (childNode.nodeName === 'BR') {
697 cursorPosition++;
698 isOnFreshLine = true;
699 continue;
700 }
701
702 if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
703 cursorPosition++;
704 }
705
706 isOnFreshLine = false;
707
708 if (childNode === selection.focusNode) {
709 cursorPosition += selection.focusOffset;
710 break;
711 } else if (childNode.nodeType === 3 && childNode.textContent) {
712 cursorPosition += childNode.textContent.length;
713 }
714
715 if (childNode.childNodes?.length > 0) {
716 findCursorPosition(Array.from(childNode.childNodes));
717 }
718 }
719 };
720
721 if (editableDivElement === selection.focusNode) {
722 // If focus node is the top textarea then focusOffset is the number of
723 // child nodes before the cursor position.
724 const partOfNodes = Array.from(editableDivElement.childNodes).slice(
725 0,
726 selection.focusOffset
727 );
728 findCursorPosition(partOfNodes);
729 } else {
730 findCursorPosition(Array.from(editableDivElement.childNodes));
731 }
732
733 return cursorPosition;
734 }
735
736 /** Gets the current selection, preferring the shadow DOM selection. */
737 private getSelection(): Selection | undefined | null {
738 // TODO: Use something similar to gr-diff's getShadowOrDocumentSelection()
739 return this.shadowRoot?.getSelection?.();
740 }
741
742 private scrollToCursorPosition(range: Range) {
743 const tempAnchorEl = document.createElement('br');
744 range.insertNode(tempAnchorEl);
745
746 tempAnchorEl.scrollIntoView({behavior: 'smooth', block: 'nearest'});
747
748 tempAnchorEl.remove();
749 }
750}
751
752declare global {
753 interface HTMLElementTagNameMap {
754 'gr-textarea': GrTextarea;
755 }
756 interface HTMLElementEventMap {
757 // prettier-ignore
758 'saveShortcut': CustomEvent<{}>;
759 // prettier-ignore
760 'hintApplied': CustomEvent<HintAppliedEventDetail>;
761 // prettier-ignore
762 'hintShown': CustomEvent<HintShownEventDetail>;
763 // prettier-ignore
764 'hintDismissed': CustomEvent<HintDismissedEventDetail>;
765 // prettier-ignore
766 'cursorPositionChange': CustomEvent<CursorPositionChangeEventDetail>;
767 }
768}