|  | /** | 
|  | * @license | 
|  | * Copyright 2024 Google LLC | 
|  | * SPDX-License-Identifier: Apache-2.0 | 
|  | */ | 
|  | import {LitElement, html, css} from 'lit'; | 
|  | import {customElement, property, query, queryAsync} from 'lit/decorators.js'; | 
|  | import {classMap} from 'lit/directives/class-map.js'; | 
|  | import {ifDefined} from 'lit/directives/if-defined.js'; | 
|  | import { | 
|  | GrTextarea as GrTextareaApi, | 
|  | HintAppliedEventDetail, | 
|  | HintShownEventDetail, | 
|  | HintDismissedEventDetail, | 
|  | CursorPositionChangeEventDetail, | 
|  | } from '../api/embed'; | 
|  |  | 
|  | /** | 
|  | * Waits for the next animation frame. | 
|  | */ | 
|  | async function animationFrame(): Promise<void> { | 
|  | return new Promise(resolve => { | 
|  | requestAnimationFrame(() => { | 
|  | resolve(); | 
|  | }); | 
|  | }); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Whether the current browser supports `plaintext-only` for contenteditable | 
|  | * https://caniuse.com/mdn-html_global_attributes_contenteditable_plaintext-only | 
|  | */ | 
|  | function supportsPlainTextEditing() { | 
|  | const div = document.createElement('div'); | 
|  | try { | 
|  | div.contentEditable = 'PLAINTEXT-ONLY'; | 
|  | return div.contentEditable === 'plaintext-only'; | 
|  | } catch (e) { | 
|  | return false; | 
|  | } | 
|  | } | 
|  |  | 
|  | /** Class for autocomplete hint */ | 
|  | export const AUTOCOMPLETE_HINT_CLASS = 'autocomplete-hint'; | 
|  |  | 
|  | const ACCEPT_PLACEHOLDER_HINT_LABEL = | 
|  | 'Press TAB to accept the placeholder hint.'; | 
|  |  | 
|  | /** | 
|  | * A custom textarea component which allows autocomplete functionality. | 
|  | * This component is only supported in Chrome. Other browsers are not supported. | 
|  | * | 
|  | * Example usage: | 
|  | * <gr-textarea></gr-textarea> | 
|  | */ | 
|  | @customElement('gr-textarea') | 
|  | export class GrTextarea extends LitElement implements GrTextareaApi { | 
|  | // editableDivElement is available right away where it may be undefined. This | 
|  | // is used for calls for scrollTop as if it is undefined then we can fallback | 
|  | // to 0. For other usecases use editableDiv. | 
|  | @query('.editableDiv') | 
|  | private readonly editableDivElement?: HTMLDivElement; | 
|  |  | 
|  | @queryAsync('.editableDiv') | 
|  | private readonly editableDiv?: Promise<HTMLDivElement>; | 
|  |  | 
|  | @property({type: Boolean, reflect: true}) disabled = false; | 
|  |  | 
|  | @property({type: String, reflect: true}) placeholder: string | undefined; | 
|  |  | 
|  | /** | 
|  | * The hint is shown as a autocomplete string which can be added by pressing | 
|  | * TAB. | 
|  | * | 
|  | * The hint is shown | 
|  | *  1. At the cursor position, only when cursor position is at the end of | 
|  | *     textarea content. | 
|  | *  2. When textarea has focus. | 
|  | *  3. When selection inside the textarea is collapsed. | 
|  | * | 
|  | * When hint is applied listen for hintApplied event and remove the hint | 
|  | * as component property to avoid showing the hint again. | 
|  | */ | 
|  | @property({type: String}) | 
|  | set hint(newHint) { | 
|  | if (this.hint !== newHint) { | 
|  | this.innerHint = newHint; | 
|  | this.updateHintInDomIfRendered(); | 
|  | } | 
|  | } | 
|  |  | 
|  | get hint() { | 
|  | return this.innerHint; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Show hint is shown as placeholder which people can autocomplete to. | 
|  | * | 
|  | * This takes precedence over hint property. | 
|  | * It is shown even when textarea has no focus. | 
|  | * This is shown only when textarea is blank. | 
|  | */ | 
|  | @property({type: String}) placeholderHint: string | undefined; | 
|  |  | 
|  | /** | 
|  | * Sets the value for textarea and also renders it in dom if it is different | 
|  | * from last rendered value. | 
|  | * | 
|  | * To prevent cursor position from jumping to front of text even when value | 
|  | * remains same, Check existing value before triggering the update and only | 
|  | * update when there is a change. | 
|  | * | 
|  | * Also .innerText binding can't be used for security reasons. | 
|  | */ | 
|  | @property({type: String}) | 
|  | set value(newValue) { | 
|  | if (this.ignoreValue && this.ignoreValue === newValue) { | 
|  | return; | 
|  | } | 
|  | const oldVal = this.value; | 
|  | if (oldVal !== newValue) { | 
|  | this.innerValue = newValue; | 
|  | this.updateValueInDom(); | 
|  | } | 
|  | } | 
|  |  | 
|  | get value() { | 
|  | return this.innerValue; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * This value will be ignored by textarea and is not set. | 
|  | */ | 
|  | @property({type: String}) ignoreValue: string | undefined; | 
|  |  | 
|  | /** | 
|  | * Sets cursor at the end of content on focus. | 
|  | */ | 
|  | @property({type: Boolean}) putCursorAtEndOnFocus = false; | 
|  |  | 
|  | /** | 
|  | * Enables save shortcut. | 
|  | * | 
|  | * On S key down with control or meta key enabled is exposed with output event | 
|  | * 'saveShortcut'. | 
|  | */ | 
|  | @property({type: Boolean}) enableSaveShortcut = false; | 
|  |  | 
|  | /* | 
|  | * Is textarea focused. This is a readonly property. | 
|  | */ | 
|  | get isFocused(): boolean { | 
|  | return this.focused; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Native element for editable div. | 
|  | */ | 
|  | get nativeElement() { | 
|  | return this.editableDivElement; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Scroll Top for editable div. | 
|  | */ | 
|  | override get scrollTop() { | 
|  | return this.editableDivElement?.scrollTop ?? 0; | 
|  | } | 
|  |  | 
|  | private innerValue: string | undefined; | 
|  |  | 
|  | private innerHint: string | undefined; | 
|  |  | 
|  | private focused = false; | 
|  |  | 
|  | private readonly isPlaintextOnlySupported = supportsPlainTextEditing(); | 
|  |  | 
|  | static override get styles() { | 
|  | return [ | 
|  | css` | 
|  | :host { | 
|  | display: inline-block; | 
|  | position: relative; | 
|  | width: 100%; | 
|  | } | 
|  |  | 
|  | :host([disabled]) { | 
|  | .editableDiv { | 
|  | background-color: var(--input-field-disabled-bg, lightgrey); | 
|  | color: var(--text-disabled, black); | 
|  | cursor: default; | 
|  | } | 
|  | } | 
|  |  | 
|  | .editableDiv { | 
|  | background-color: var(--input-field-bg, white); | 
|  | border: var(--gr-textarea-border-width, 2px) solid | 
|  | var(--gr-textarea-border-color, white); | 
|  | border-radius: 4px; | 
|  | box-sizing: border-box; | 
|  | color: var(--text-default, black); | 
|  | max-height: var(--gr-textarea-max-height, 16em); | 
|  | min-height: var(--gr-textarea-min-height, 4em); | 
|  | overflow-x: auto; | 
|  | padding: var(--gr-textarea-padding, 12px); | 
|  | white-space: pre-wrap; | 
|  | width: 100%; | 
|  |  | 
|  | &:focus-visible { | 
|  | border-color: var(--gr-textarea-focus-outline-color, black); | 
|  | outline: none; | 
|  | } | 
|  |  | 
|  | &:empty::before { | 
|  | content: attr(data-placeholder); | 
|  | color: var(--text-secondary, lightgrey); | 
|  | display: inline; | 
|  | pointer-events: none; | 
|  | } | 
|  |  | 
|  | &.hintShown:empty::after, | 
|  | .autocomplete-hint:empty::after { | 
|  | background-color: var(--secondary-bg-color, white); | 
|  | border: 1px solid var(--text-secondary, lightgrey); | 
|  | border-radius: 2px; | 
|  | content: 'tab'; | 
|  | color: var(--text-secondary, lightgrey); | 
|  | display: inline; | 
|  | pointer-events: none; | 
|  | font-size: 10px; | 
|  | line-height: 10px; | 
|  | margin-left: 4px; | 
|  | padding: 1px 3px; | 
|  | } | 
|  |  | 
|  | .autocomplete-hint { | 
|  | &:empty::before { | 
|  | content: attr(data-hint); | 
|  | color: var(--text-secondary, lightgrey); | 
|  | } | 
|  | } | 
|  | } | 
|  | `, | 
|  | ]; | 
|  | } | 
|  |  | 
|  | override render() { | 
|  | const isHintShownAsPlaceholder = | 
|  | (!this.disabled && this.placeholderHint) ?? false; | 
|  |  | 
|  | const placeholder = isHintShownAsPlaceholder | 
|  | ? this.placeholderHint | 
|  | : this.placeholder; | 
|  | const ariaPlaceholder = isHintShownAsPlaceholder | 
|  | ? (this.placeholderHint ?? '') + ACCEPT_PLACEHOLDER_HINT_LABEL | 
|  | : placeholder; | 
|  |  | 
|  | const classes = classMap({ | 
|  | editableDiv: true, | 
|  | hintShown: isHintShownAsPlaceholder, | 
|  | }); | 
|  |  | 
|  | // Chrome supports non-standard "contenteditable=plaintext-only", | 
|  | // which prevents HTML from being inserted into a contenteditable element. | 
|  | // https://github.com/w3c/editing/issues/162 | 
|  | return html`<div | 
|  | aria-disabled=${this.disabled} | 
|  | aria-multiline="true" | 
|  | aria-placeholder=${ifDefined(ariaPlaceholder)} | 
|  | data-placeholder=${ifDefined(placeholder)} | 
|  | class=${classes} | 
|  | contenteditable=${this.contentEditableAttributeValue} | 
|  | dir="ltr" | 
|  | role="textbox" | 
|  | @input=${this.onInput} | 
|  | @focus=${this.onFocus} | 
|  | @blur=${this.onBlur} | 
|  | @keydown=${this.handleKeyDown} | 
|  | @keyup=${this.handleKeyUp} | 
|  | @mouseup=${this.handleMouseUp} | 
|  | @scroll=${this.handleScroll} | 
|  | ></div>`; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Focuses the textarea. | 
|  | */ | 
|  | override async focus() { | 
|  | const editableDivElement = await this.editableDiv; | 
|  | const isFocused = this.isFocused; | 
|  | editableDivElement?.focus?.(); | 
|  | // If already focused, do not change the cursor position. | 
|  | if (this.putCursorAtEndOnFocus && !isFocused) { | 
|  | await this.putCursorAtEnd(); | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Puts the cursor at the end of existing content. | 
|  | * Scrolls the content of textarea towards the end. | 
|  | */ | 
|  | async putCursorAtEnd() { | 
|  | const editableDivElement = await this.editableDiv; | 
|  | const selection = this.getSelection(); | 
|  |  | 
|  | if (!editableDivElement || !selection) { | 
|  | return; | 
|  | } | 
|  |  | 
|  | const range = document.createRange(); | 
|  | editableDivElement.focus(); | 
|  | range.setStart(editableDivElement, editableDivElement.childNodes.length); | 
|  | range.collapse(true); | 
|  | selection.removeAllRanges(); | 
|  | selection.addRange(range); | 
|  |  | 
|  | this.scrollToCursorPosition(range); | 
|  |  | 
|  | range.detach(); | 
|  |  | 
|  | this.onCursorPositionChange(); | 
|  | } | 
|  |  | 
|  | public setCursorPosition(position: number) { | 
|  | this.setCursorPositionForDiv(position, this.editableDivElement); | 
|  | } | 
|  |  | 
|  | public async setCursorPositionAsync(position: number) { | 
|  | const editableDivElement = await this.editableDiv; | 
|  | this.setCursorPositionForDiv(position, editableDivElement); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Sets cursor position to given position and scrolls the content to cursor | 
|  | * position. | 
|  | * | 
|  | * If position is out of bounds of value of textarea then cursor is places at | 
|  | * end of content of textarea. | 
|  | */ | 
|  | private setCursorPositionForDiv( | 
|  | position: number, | 
|  | editableDivElement?: HTMLDivElement | 
|  | ) { | 
|  | // This will keep track of remaining offset to place the cursor. | 
|  | let remainingOffset = position; | 
|  | let isOnFreshLine = true; | 
|  | let nodeToFocusOn: Node | null = null; | 
|  | const selection = this.getSelection(); | 
|  |  | 
|  | if (!editableDivElement || !selection) { | 
|  | return; | 
|  | } | 
|  | editableDivElement.focus(); | 
|  | const findNodeToFocusOn = (childNodes: Node[]) => { | 
|  | for (let i = 0; i < childNodes.length; i++) { | 
|  | const childNode = childNodes[i]; | 
|  | let currentNodeLength = 0; | 
|  |  | 
|  | if (childNode.nodeType === Node.COMMENT_NODE) { | 
|  | continue; | 
|  | } | 
|  |  | 
|  | if (childNode.nodeName === 'BR') { | 
|  | currentNodeLength++; | 
|  | isOnFreshLine = true; | 
|  | } | 
|  |  | 
|  | if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) { | 
|  | currentNodeLength++; | 
|  | } | 
|  |  | 
|  | isOnFreshLine = false; | 
|  |  | 
|  | if (childNode.nodeType === Node.TEXT_NODE && childNode.textContent) { | 
|  | currentNodeLength += childNode.textContent.length; | 
|  | } | 
|  |  | 
|  | if (remainingOffset <= currentNodeLength) { | 
|  | nodeToFocusOn = childNode; | 
|  | break; | 
|  | } else { | 
|  | remainingOffset -= currentNodeLength; | 
|  | } | 
|  |  | 
|  | if (childNode.childNodes?.length > 0) { | 
|  | findNodeToFocusOn(Array.from(childNode.childNodes)); | 
|  | } | 
|  | } | 
|  | }; | 
|  |  | 
|  | findNodeToFocusOn(Array.from(editableDivElement.childNodes)); | 
|  |  | 
|  | this.setFocusOnNode( | 
|  | selection, | 
|  | editableDivElement, | 
|  | nodeToFocusOn, | 
|  | remainingOffset | 
|  | ); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Replaces text from start and end cursor position. | 
|  | */ | 
|  | setRangeText(replacement: string, start: number, end: number) { | 
|  | const pre = this.value?.substring(0, start) ?? ''; | 
|  | const post = this.value?.substring(end, this.value?.length ?? 0) ?? ''; | 
|  |  | 
|  | this.value = pre + replacement + post; | 
|  | this.setCursorPosition(pre.length + replacement.length); | 
|  | } | 
|  |  | 
|  | private get contentEditableAttributeValue() { | 
|  | return this.disabled | 
|  | ? 'false' | 
|  | : this.isPlaintextOnlySupported | 
|  | ? ('plaintext-only' as unknown as 'true') | 
|  | : 'true'; | 
|  | } | 
|  |  | 
|  | private setFocusOnNode( | 
|  | selection: Selection, | 
|  | editableDivElement: Node, | 
|  | nodeToFocusOn: Node | null, | 
|  | remainingOffset: number | 
|  | ) { | 
|  | const range = document.createRange(); | 
|  | // If node is null or undefined then fallback to focus event which will put | 
|  | // cursor at the end of content. | 
|  | if (nodeToFocusOn === null) { | 
|  | range.setStart(editableDivElement, editableDivElement.childNodes.length); | 
|  | } | 
|  | // If node to focus is BR then focus offset is number of nodes. | 
|  | else if (nodeToFocusOn.nodeName === 'BR') { | 
|  | const nextNode = nodeToFocusOn.nextSibling ?? nodeToFocusOn; | 
|  | range.setEnd(nextNode, 0); | 
|  | } else { | 
|  | range.setStart(nodeToFocusOn, remainingOffset); | 
|  | } | 
|  |  | 
|  | range.collapse(true); | 
|  | selection.removeAllRanges(); | 
|  | selection.addRange(range); | 
|  |  | 
|  | // Scroll the content to cursor position. | 
|  | this.scrollToCursorPosition(range); | 
|  |  | 
|  | range.detach(); | 
|  |  | 
|  | this.onCursorPositionChange(); | 
|  | } | 
|  |  | 
|  | private async onInput(event: Event) { | 
|  | event.preventDefault(); | 
|  | event.stopImmediatePropagation(); | 
|  |  | 
|  | const value = await this.getValue(); | 
|  | this.innerValue = value; | 
|  |  | 
|  | this.fire('input', {value: this.value}); | 
|  | } | 
|  |  | 
|  | private onFocus() { | 
|  | this.focused = true; | 
|  | this.onCursorPositionChange(); | 
|  | } | 
|  |  | 
|  | private onBlur() { | 
|  | this.focused = false; | 
|  | this.removeHintSpanIfShown(); | 
|  | this.onCursorPositionChange(); | 
|  | } | 
|  |  | 
|  | private async handleKeyDown(event: KeyboardEvent) { | 
|  | if ( | 
|  | event.key === 'Tab' && | 
|  | !event.shiftKey && | 
|  | !event.ctrlKey && | 
|  | !event.metaKey | 
|  | ) { | 
|  | await this.handleTabKeyPress(event); | 
|  | return; | 
|  | } | 
|  | if ( | 
|  | this.enableSaveShortcut && | 
|  | event.key === 's' && | 
|  | (event.ctrlKey || event.metaKey) | 
|  | ) { | 
|  | event.preventDefault(); | 
|  | this.fire('saveShortcut'); | 
|  | } | 
|  | await this.toggleHintVisibilityIfAny(); | 
|  | } | 
|  |  | 
|  | private handleKeyUp() { | 
|  | this.onCursorPositionChange(); | 
|  | } | 
|  |  | 
|  | private async handleMouseUp() { | 
|  | this.onCursorPositionChange(); | 
|  | await this.toggleHintVisibilityIfAny(); | 
|  | } | 
|  |  | 
|  | private handleScroll() { | 
|  | this.fire('scroll'); | 
|  | } | 
|  |  | 
|  | private fire<T>(type: string, detail?: T) { | 
|  | this.dispatchEvent( | 
|  | new CustomEvent(type, {detail, bubbles: true, composed: true}) | 
|  | ); | 
|  | } | 
|  |  | 
|  | private async handleTabKeyPress(event: KeyboardEvent) { | 
|  | const oldValue = this.value; | 
|  | if (this.placeholderHint && !oldValue) { | 
|  | event.preventDefault(); | 
|  | await this.appendHint(this.placeholderHint, event); | 
|  | } else if (this.hasHintSpan()) { | 
|  | event.preventDefault(); | 
|  | await this.appendHint(this.hint!, event); | 
|  | } | 
|  | } | 
|  |  | 
|  | private async appendHint(hint: string, event: Event) { | 
|  | const oldValue = this.value ?? ''; | 
|  | const newValue = oldValue + hint; | 
|  |  | 
|  | this.value = newValue; | 
|  | await this.putCursorAtEnd(); | 
|  | await this.onInput(event); | 
|  |  | 
|  | this.fire('hintApplied', {hint, oldValue}); | 
|  | } | 
|  |  | 
|  | private async toggleHintVisibilityIfAny() { | 
|  | // Wait for the next animation frame so that entered key is processed and | 
|  | // available in dom. | 
|  | await animationFrame(); | 
|  |  | 
|  | const editableDivElement = await this.editableDiv; | 
|  | const currentValue = (await this.getValue()) ?? ''; | 
|  | const cursorPosition = await this.getCursorPositionAsync(); | 
|  | if ( | 
|  | !editableDivElement || | 
|  | (this.placeholderHint && !currentValue) || | 
|  | !this.hint || | 
|  | !this.isFocused || | 
|  | cursorPosition !== currentValue.length | 
|  | ) { | 
|  | this.removeHintSpanIfShown(); | 
|  | return; | 
|  | } | 
|  |  | 
|  | const hintSpan = this.hintSpan(); | 
|  | if (!hintSpan) { | 
|  | this.addHintSpanAtEndOfContent(editableDivElement, this.hint || ''); | 
|  | return; | 
|  | } | 
|  |  | 
|  | const oldHint = (hintSpan as HTMLElement).dataset['hint']; | 
|  | if (oldHint !== this.hint) { | 
|  | this.removeHintSpanIfShown(); | 
|  | this.addHintSpanAtEndOfContent(editableDivElement, this.hint || ''); | 
|  | } | 
|  | } | 
|  |  | 
|  | private addHintSpanAtEndOfContent(editableDivElement: Node, hint: string) { | 
|  | const oldValue = this.value ?? ''; | 
|  | const hintSpan = document.createElement('span'); | 
|  | hintSpan.classList.add(AUTOCOMPLETE_HINT_CLASS); | 
|  | hintSpan.setAttribute('role', 'alert'); | 
|  | hintSpan.setAttribute( | 
|  | 'aria-label', | 
|  | 'Suggestion: ' + hint + ' Press TAB to accept it.' | 
|  | ); | 
|  | hintSpan.dataset['hint'] = hint; | 
|  | editableDivElement.appendChild(hintSpan); | 
|  | this.fire('hintShown', {hint, oldValue}); | 
|  | } | 
|  |  | 
|  | private removeHintSpanIfShown() { | 
|  | const hintSpan = this.hintSpan(); | 
|  | if (hintSpan) { | 
|  | hintSpan.remove(); | 
|  | this.fire('hintDismissed', { | 
|  | hint: (hintSpan as HTMLElement).dataset['hint'], | 
|  | }); | 
|  | } | 
|  | } | 
|  |  | 
|  | private hasHintSpan() { | 
|  | return !!this.hintSpan(); | 
|  | } | 
|  |  | 
|  | private hintSpan() { | 
|  | return this.shadowRoot?.querySelector('.' + AUTOCOMPLETE_HINT_CLASS); | 
|  | } | 
|  |  | 
|  | private onCursorPositionChange() { | 
|  | this.fire('cursorPositionChange', {position: this.getCursorPosition()}); | 
|  | } | 
|  |  | 
|  | private async updateValueInDom() { | 
|  | const editableDivElement = | 
|  | this.editableDivElement ?? (await this.editableDiv); | 
|  | if (editableDivElement) { | 
|  | editableDivElement.innerText = this.value || ''; | 
|  | } | 
|  | } | 
|  |  | 
|  | private async updateHintInDomIfRendered() { | 
|  | // Wait for editable div to render then process the hint. | 
|  | await this.editableDiv; | 
|  | await this.toggleHintVisibilityIfAny(); | 
|  | } | 
|  |  | 
|  | private async getValue() { | 
|  | const editableDivElement = await this.editableDiv; | 
|  | if (editableDivElement) { | 
|  | const [output] = this.parseText(editableDivElement, false, true); | 
|  | return output; | 
|  | } | 
|  | return ''; | 
|  | } | 
|  |  | 
|  | private parseText( | 
|  | node: Node, | 
|  | isLastBr: boolean, | 
|  | isFirst: boolean | 
|  | ): [string, boolean] { | 
|  | let textValue = ''; | 
|  | let output = ''; | 
|  | if (node.nodeName === 'BR') { | 
|  | return ['\n', true]; | 
|  | } | 
|  |  | 
|  | if (node.nodeType === Node.TEXT_NODE && node.textContent) { | 
|  | return [node.textContent, false]; | 
|  | } | 
|  |  | 
|  | if (node.nodeName === 'DIV' && !isLastBr && !isFirst) { | 
|  | textValue = '\n'; | 
|  | } | 
|  |  | 
|  | isLastBr = false; | 
|  |  | 
|  | for (let i = 0; i < node.childNodes?.length; i++) { | 
|  | [output, isLastBr] = this.parseText( | 
|  | node.childNodes[i], | 
|  | isLastBr, | 
|  | i === 0 | 
|  | ); | 
|  | textValue += output; | 
|  | } | 
|  | return [textValue, isLastBr]; | 
|  | } | 
|  |  | 
|  | public getCursorPosition() { | 
|  | return this.getCursorPositionForDiv(this.editableDivElement); | 
|  | } | 
|  |  | 
|  | public async getCursorPositionAsync() { | 
|  | const editableDivElement = await this.editableDiv; | 
|  | return this.getCursorPositionForDiv(editableDivElement); | 
|  | } | 
|  |  | 
|  | private getCursorPositionForDiv(editableDivElement?: HTMLDivElement) { | 
|  | const selection = this.getSelection(); | 
|  |  | 
|  | // Cursor position is -1 (not available) if | 
|  | // | 
|  | // If textarea is not rendered. | 
|  | // If textarea is not focused | 
|  | // There is no accessible selection object. | 
|  | // This is not a collapsed selection. | 
|  | if ( | 
|  | !editableDivElement || | 
|  | !this.focused || | 
|  | !selection || | 
|  | selection.focusNode === null || | 
|  | !selection.isCollapsed | 
|  | ) { | 
|  | return -1; | 
|  | } | 
|  |  | 
|  | let cursorPosition = 0; | 
|  | let isOnFreshLine = true; | 
|  |  | 
|  | const findCursorPosition = (childNodes: Node[]) => { | 
|  | for (let i = 0; i < childNodes.length; i++) { | 
|  | const childNode = childNodes[i]; | 
|  |  | 
|  | if (childNode.nodeName === 'BR') { | 
|  | cursorPosition++; | 
|  | isOnFreshLine = true; | 
|  | continue; | 
|  | } | 
|  |  | 
|  | if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) { | 
|  | cursorPosition++; | 
|  | } | 
|  |  | 
|  | isOnFreshLine = false; | 
|  |  | 
|  | if (childNode === selection.focusNode) { | 
|  | cursorPosition += selection.focusOffset; | 
|  | break; | 
|  | } else if (childNode.nodeType === 3 && childNode.textContent) { | 
|  | cursorPosition += childNode.textContent.length; | 
|  | } | 
|  |  | 
|  | if (childNode.childNodes?.length > 0) { | 
|  | findCursorPosition(Array.from(childNode.childNodes)); | 
|  | } | 
|  | } | 
|  | }; | 
|  |  | 
|  | if (editableDivElement === selection.focusNode) { | 
|  | // If focus node is the top textarea then focusOffset is the number of | 
|  | // child nodes before the cursor position. | 
|  | const partOfNodes = Array.from(editableDivElement.childNodes).slice( | 
|  | 0, | 
|  | selection.focusOffset | 
|  | ); | 
|  | findCursorPosition(partOfNodes); | 
|  | } else { | 
|  | findCursorPosition(Array.from(editableDivElement.childNodes)); | 
|  | } | 
|  |  | 
|  | return cursorPosition; | 
|  | } | 
|  |  | 
|  | /** Gets the current selection, preferring the shadow DOM selection. */ | 
|  | private getSelection(): Selection | undefined | null { | 
|  | // TODO: Use something similar to gr-diff's getShadowOrDocumentSelection() | 
|  | return this.shadowRoot?.getSelection?.(); | 
|  | } | 
|  |  | 
|  | private scrollToCursorPosition(range: Range) { | 
|  | const tempAnchorEl = document.createElement('br'); | 
|  | range.insertNode(tempAnchorEl); | 
|  |  | 
|  | tempAnchorEl.scrollIntoView({behavior: 'smooth', block: 'nearest'}); | 
|  |  | 
|  | tempAnchorEl.remove(); | 
|  | } | 
|  | } | 
|  |  | 
|  | declare global { | 
|  | interface HTMLElementTagNameMap { | 
|  | 'gr-textarea': GrTextarea; | 
|  | } | 
|  | interface HTMLElementEventMap { | 
|  | // prettier-ignore | 
|  | 'saveShortcut': CustomEvent<{}>; | 
|  | // prettier-ignore | 
|  | 'hintApplied': CustomEvent<HintAppliedEventDetail>; | 
|  | // prettier-ignore | 
|  | 'hintShown': CustomEvent<HintShownEventDetail>; | 
|  | // prettier-ignore | 
|  | 'hintDismissed': CustomEvent<HintDismissedEventDetail>; | 
|  | // prettier-ignore | 
|  | 'cursorPositionChange': CustomEvent<CursorPositionChangeEventDetail>; | 
|  | } | 
|  | } |