From e26b61fe9da27564dc49ef12587e2b94ffd92271 Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Tue, 31 Dec 2024 17:06:34 +0100 Subject: [PATCH 1/9] Copy the SmartTextArea WebComponent --- src/Core.Scripts/package-lock.json | 22 +- src/Core.Scripts/package.json | 3 +- .../src/Components/SmartTextArea/CaretUtil.ts | 42 ++++ .../SmartTextArea/InlineSuggestionDisplay.ts | 135 ++++++++++++ .../SmartTextArea/OverlaySuggestionDisplay.ts | 137 +++++++++++++ .../Components/SmartTextArea/SmartTextArea.ts | 192 ++++++++++++++++++ .../SmartTextArea/SuggestionDisplay.ts | 10 + .../src/Components/SmartTextArea/notes.md | 94 +++++++++ 8 files changed, 626 insertions(+), 9 deletions(-) create mode 100644 src/Core.Scripts/src/Components/SmartTextArea/CaretUtil.ts create mode 100644 src/Core.Scripts/src/Components/SmartTextArea/InlineSuggestionDisplay.ts create mode 100644 src/Core.Scripts/src/Components/SmartTextArea/OverlaySuggestionDisplay.ts create mode 100644 src/Core.Scripts/src/Components/SmartTextArea/SmartTextArea.ts create mode 100644 src/Core.Scripts/src/Components/SmartTextArea/SuggestionDisplay.ts create mode 100644 src/Core.Scripts/src/Components/SmartTextArea/notes.md diff --git a/src/Core.Scripts/package-lock.json b/src/Core.Scripts/package-lock.json index 487826f21..d960c43de 100644 --- a/src/Core.Scripts/package-lock.json +++ b/src/Core.Scripts/package-lock.json @@ -8,7 +8,8 @@ "name": "microsoft.fluentui.aspnetcore.components.assets", "license": "ISC", "dependencies": { - "@fluentui/web-components": "3.0.0-beta.74" + "@fluentui/web-components": "3.0.0-beta.74", + "caret-pos": "^2.0.0" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^7.6.0", @@ -986,6 +987,11 @@ "node": ">=6" } }, + "node_modules/caret-pos": { + "version": "2.0.0", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/caret-pos/-/caret-pos-2.0.0.tgz", + "integrity": "sha1-9KIioUlRofX6ZUPXP3nsaJIiMyY=" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1039,9 +1045,9 @@ "dev": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha1-ilj+ePANzXDDcEUXWd+/rwPo7p8=", "dev": true, "dependencies": { "path-key": "^3.1.0", @@ -1886,12 +1892,12 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha1-1m+hjzpHB2eJMgubGvMr2G2fogI=", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { diff --git a/src/Core.Scripts/package.json b/src/Core.Scripts/package.json index 5e1211534..6bbfe5dda 100644 --- a/src/Core.Scripts/package.json +++ b/src/Core.Scripts/package.json @@ -25,6 +25,7 @@ "typescript": "^5.4.4" }, "dependencies": { - "@fluentui/web-components": "3.0.0-beta.74" + "@fluentui/web-components": "3.0.0-beta.74", + "caret-pos": "^2.0.0" } } diff --git a/src/Core.Scripts/src/Components/SmartTextArea/CaretUtil.ts b/src/Core.Scripts/src/Components/SmartTextArea/CaretUtil.ts new file mode 100644 index 000000000..c6fccb2f1 --- /dev/null +++ b/src/Core.Scripts/src/Components/SmartTextArea/CaretUtil.ts @@ -0,0 +1,42 @@ +import * as caretPos from 'caret-pos'; + +export namespace Microsoft.FluentUI.Blazor.Components.SmartTextArea { + + export function scrollTextAreaDownToCaretIfNeeded(textArea: HTMLTextAreaElement) { + // Note that this only scrolls *down*, because that's the only scenario after a suggestion is accepted + const pos = caretPos.position(textArea); + const lineHeightInPixels = parseFloat(window.getComputedStyle(textArea).lineHeight); + if (pos.top > textArea.clientHeight + textArea.scrollTop - lineHeightInPixels) { + textArea.scrollTop = pos.top - textArea.clientHeight + lineHeightInPixels; + } + } + + export function getCaretOffsetFromOffsetParent(elem: HTMLTextAreaElement): { top: number, left: number, height: number, elemStyle: CSSStyleDeclaration } { + const elemStyle = window.getComputedStyle(elem); + const pos = caretPos.position(elem); + + return { + top: pos.top + parseFloat(elemStyle.borderTopWidth) + elem.offsetTop - elem.scrollTop, + left: pos.left + parseFloat(elemStyle.borderLeftWidth) + elem.offsetLeft - elem.scrollLeft - 0.25, + height: pos.height, + elemStyle: elemStyle, + } + } + + export function insertTextAtCaretPosition(textArea: HTMLTextAreaElement, text: string) { + // Even though document.execCommand is deprecated, it's still the best way to insert text, because it's + // the only way that interacts correctly with the undo buffer. If we have to fall back on mutating + // the .value property directly, it works but erases the undo buffer. + if (document.execCommand) { + document.execCommand('insertText', false, text); + } else { + let caretPos = textArea.selectionStart; + textArea.value = textArea.value.substring(0, caretPos) + + text + + textArea.value.substring(textArea.selectionEnd); + caretPos += text.length; + textArea.setSelectionRange(caretPos, caretPos); + } + } + +} diff --git a/src/Core.Scripts/src/Components/SmartTextArea/InlineSuggestionDisplay.ts b/src/Core.Scripts/src/Components/SmartTextArea/InlineSuggestionDisplay.ts new file mode 100644 index 000000000..2bc5f869d --- /dev/null +++ b/src/Core.Scripts/src/Components/SmartTextArea/InlineSuggestionDisplay.ts @@ -0,0 +1,135 @@ +import { Microsoft as SuggestionDisplayFile } from './SuggestionDisplay'; +import { Microsoft as SmartTextAreaFile } from './SmartTextArea'; +import { Microsoft as CaretUtilFile } from './CaretUtil'; + +export namespace Microsoft.FluentUI.Blazor.Components.SmartTextArea { + + import SuggestionDisplay = SuggestionDisplayFile.FluentUI.Blazor.Components.SmartTextArea.SuggestionDisplay; + import SmartTextArea = SmartTextAreaFile.FluentUI.Blazor.Components.SmartTextArea.SmartTextArea; + import CaretUtil = CaretUtilFile.FluentUI.Blazor.Components.SmartTextArea; + + export class InlineSuggestionDisplay implements SuggestionDisplay { + latestSuggestionText: string = ''; + suggestionStartPos: number | null = null; + suggestionEndPos: number | null = null; + fakeCaret: FakeCaret | null = null; + originalValueProperty: PropertyDescriptor; + + constructor(private owner: SmartTextArea, private textArea: HTMLTextAreaElement) { + // When any other JS code asks for the value of the textarea, we want to return the value + // without any pending suggestion, otherwise it will break things like bindings + this.originalValueProperty = findPropertyRecursive(textArea, 'value'); + const self: any = this; + Object.defineProperty(textArea, 'value', { + get() { + const trueValue = self.originalValueProperty.get.call(textArea); + return self.isShowing() + ? trueValue.substring(0, self.suggestionStartPos) + trueValue.substring(self.suggestionEndPos) + : trueValue; + }, + set(v) { + self.originalValueProperty.set.call(textArea, v); + } + }); + } + + get valueIncludingSuggestion() { + return (this as any).originalValueProperty.get.call(this.textArea); + } + + set valueIncludingSuggestion(val: string) { + (this as any).originalValueProperty.set.call(this.textArea, val); + } + + isShowing(): boolean { + return this.suggestionStartPos !== null; + } + + show(suggestion: string): void { + this.latestSuggestionText = suggestion; + this.suggestionStartPos = this.textArea.selectionStart; + this.suggestionEndPos = this.suggestionStartPos + suggestion.length; + + this.textArea.setAttribute('data-suggestion-visible', ''); + this.valueIncludingSuggestion = this.valueIncludingSuggestion.substring(0, this.suggestionStartPos) + suggestion + this.valueIncludingSuggestion.substring(this.suggestionStartPos); + this.textArea.setSelectionRange(this.suggestionStartPos, this.suggestionEndPos); + + this.fakeCaret ??= new FakeCaret(this.owner, this.textArea); + this.fakeCaret.show(); + } + + get currentSuggestion() { + return this.latestSuggestionText; + } + + accept(): void { + this.textArea.setSelectionRange(this.suggestionEndPos, this.suggestionEndPos); + this.suggestionStartPos = null; + this.suggestionEndPos = null; + this.fakeCaret?.hide(); + this.textArea.removeAttribute('data-suggestion-visible'); + + // The newly-inserted text could be so long that the new caret position is off the bottom of the textarea. + // It won't scroll to the new caret position by default + CaretUtil.scrollTextAreaDownToCaretIfNeeded(this.textArea); + } + + reject(): void { + if (!this.isShowing()) { + return; // No suggestion is shown + } + + const prevSelectionStart = this.textArea.selectionStart; + const prevSelectionEnd = this.textArea.selectionEnd; + this.valueIncludingSuggestion = this.valueIncludingSuggestion.substring(0, (this as any).suggestionStartPos) + this.valueIncludingSuggestion.substring((this as any).suggestionEndPos); + + if (this.suggestionStartPos === prevSelectionStart && this.suggestionEndPos === prevSelectionEnd) { + // For most interactions we don't need to do anything to preserve the cursor position, but for + // 'scroll' events we do (because the interaction isn't going to set a cursor position naturally) + this.textArea.setSelectionRange(prevSelectionStart, prevSelectionStart /* not 'end' because we removed the suggestion */); + } + + this.suggestionStartPos = null; + this.suggestionEndPos = null; + this.textArea.removeAttribute('data-suggestion-visible'); + this.fakeCaret?.hide(); + } + } + + class FakeCaret { + readonly caretDiv: HTMLDivElement; + + constructor(owner: SmartTextArea, private textArea: HTMLTextAreaElement) { + this.caretDiv = document.createElement('div'); + this.caretDiv.classList.add('smart-textarea-caret'); + owner.appendChild(this.caretDiv); + } + + show() { + const caretOffset = CaretUtil.getCaretOffsetFromOffsetParent(this.textArea); + const style = this.caretDiv.style; + style.display = 'block'; + style.top = caretOffset.top + 'px'; + style.left = caretOffset.left + 'px'; + style.height = caretOffset.height + 'px'; + style.zIndex = this.textArea.style.zIndex; + style.backgroundColor = caretOffset.elemStyle.caretColor; + } + + hide() { + this.caretDiv.style.display = 'none'; + } + } + + function findPropertyRecursive(obj: any, propName: string): PropertyDescriptor { + while (obj) { + const descriptor = Object.getOwnPropertyDescriptor(obj, propName); + if (descriptor) { + return descriptor; + } + obj = Object.getPrototypeOf(obj); + } + + throw new Error(`Property ${propName} not found on object or its prototype chain`); + } +} diff --git a/src/Core.Scripts/src/Components/SmartTextArea/OverlaySuggestionDisplay.ts b/src/Core.Scripts/src/Components/SmartTextArea/OverlaySuggestionDisplay.ts new file mode 100644 index 000000000..c7d18e843 --- /dev/null +++ b/src/Core.Scripts/src/Components/SmartTextArea/OverlaySuggestionDisplay.ts @@ -0,0 +1,137 @@ +import { Microsoft as SuggestionDisplayFile } from './SuggestionDisplay'; +import { Microsoft as SmartTextAreaFile } from './SmartTextArea'; +import { Microsoft as CaretUtilFile } from './CaretUtil'; + +export namespace Microsoft.FluentUI.Blazor.Components.SmartTextArea { + + import SuggestionDisplay = SuggestionDisplayFile.FluentUI.Blazor.Components.SmartTextArea.SuggestionDisplay; + import SmartTextArea = SmartTextAreaFile.FluentUI.Blazor.Components.SmartTextArea.SmartTextArea; + import CaretUtil = CaretUtilFile.FluentUI.Blazor.Components.SmartTextArea; + + + export class OverlaySuggestionDisplay implements SuggestionDisplay { + latestSuggestionText: string = ''; + suggestionElement: HTMLDivElement; + suggestionPrefixElement: HTMLSpanElement; + suggestionTextElement: HTMLSpanElement; + showing: boolean; + + constructor(owner: SmartTextArea, private textArea: HTMLTextAreaElement) { + this.showing = false; + this.suggestionElement = document.createElement('div'); + this.suggestionElement.classList.add('smart-textarea-suggestion-overlay'); + this.suggestionElement.addEventListener('mousedown', e => this.handleSuggestionClicked(e)); + this.suggestionElement.addEventListener('touchend', e => this.handleSuggestionClicked(e)); + + this.suggestionPrefixElement = document.createElement('span'); + this.suggestionTextElement = document.createElement('span'); + this.suggestionElement.appendChild(this.suggestionPrefixElement); + this.suggestionElement.appendChild(this.suggestionTextElement); + + this.suggestionPrefixElement.style.opacity = '0.3'; + + const computedStyle = window.getComputedStyle(this.textArea); + this.suggestionElement.style.font = computedStyle.font; + this.suggestionElement.style.marginTop = (parseFloat(computedStyle.fontSize) * 1.4) + 'px'; + + owner.appendChild(this.suggestionElement); + } + + get currentSuggestion() { + return this.latestSuggestionText; + } + + show(suggestion: string): void { + this.latestSuggestionText = suggestion; + + this.suggestionPrefixElement.textContent = suggestion[0] != ' ' ? getCurrentIncompleteWord(this.textArea, 20) : ''; + this.suggestionTextElement.textContent = suggestion; + + const caretOffset = CaretUtil.getCaretOffsetFromOffsetParent(this.textArea); + const style = this.suggestionElement.style; + style.minWidth = ''; + this.suggestionElement.classList.add('smart-textarea-suggestion-overlay-visible'); + style.zIndex = this.textArea.style.zIndex; + style.top = caretOffset.top + 'px'; + + // If the horizontal position is already close enough, leave it alone. Otherwise it + // can jiggle annoyingly due to inaccuracies in measuring the caret position. + const newLeftPos = caretOffset.left - this.suggestionPrefixElement.offsetWidth; + if (!style.left || Math.abs(parseFloat(style.left) - newLeftPos) > 10) { + style.left = newLeftPos + 'px'; + } + + this.showing = true; + + + // Normally we're happy for the overlay to take up as much width as it can up to the edge of the page. + // However, if it's too narrow (because the edge of the page is already too close), it will wrap onto + // many lines. In this case we'll force it to get wider, and then we have to move it further left to + // avoid spilling off the screen. + const suggestionComputedStyle = window.getComputedStyle(this.suggestionElement); + const numLinesOfText = Math.round((this.suggestionElement.offsetHeight - parseFloat(suggestionComputedStyle.paddingTop) - parseFloat(suggestionComputedStyle.paddingBottom)) + / parseFloat(suggestionComputedStyle.lineHeight)); + if (numLinesOfText > 2) { + const oldWidth = this.suggestionElement.offsetWidth; + style.minWidth = `calc(min(70vw, ${(numLinesOfText * oldWidth / 2)}px))`; // Aim for 2 lines, but don't get wider than 70% of the screen + } + + // If the suggestion is too far to the right, move it left so it's not off the screen + const suggestionClientRect = this.suggestionElement.getBoundingClientRect(); + if (suggestionClientRect.right > document.body.clientWidth - 20) { + style.left = `calc(${parseFloat(style.left) - (suggestionClientRect.right - document.body.clientWidth)}px - 2rem)`; + } + } + + accept(): void { + if (!this.showing) { + return; + } + + CaretUtil.insertTextAtCaretPosition(this.textArea, this.currentSuggestion); + + // The newly-inserted text could be so long that the new caret position is off the bottom of the textarea. + // It won't scroll to the new caret position by default + CaretUtil.scrollTextAreaDownToCaretIfNeeded(this.textArea); + + this.hide(); + } + + reject(): void { + this.hide(); + } + + hide(): void { + if (this.showing) { + this.showing = false; + this.suggestionElement.classList.remove('smart-textarea-suggestion-overlay-visible'); + } + } + + isShowing(): boolean { + return this.showing; + } + + handleSuggestionClicked(event: Event) { + event.preventDefault(); + event.stopImmediatePropagation(); + this.accept(); + } + } + + function getCurrentIncompleteWord(textArea: HTMLTextAreaElement, maxLength: number) { + const text = textArea.value; + const caretPos = textArea.selectionStart; + + // Not all languages have words separated by spaces. Imposing the maxlength rule + // means we'll not show the prefix for those languages if you're in the middle + // of longer text (and ensures we don't search through a long block), which is ideal. + for (let i = caretPos - 1; i > caretPos - maxLength; i--) { + if (i < 0 || text[i].match(/\s/)) { + return text.substring(i + 1, caretPos); + } + } + + return ''; + } +} diff --git a/src/Core.Scripts/src/Components/SmartTextArea/SmartTextArea.ts b/src/Core.Scripts/src/Components/SmartTextArea/SmartTextArea.ts new file mode 100644 index 000000000..519d86459 --- /dev/null +++ b/src/Core.Scripts/src/Components/SmartTextArea/SmartTextArea.ts @@ -0,0 +1,192 @@ +import { Microsoft as SuggestionDisplayFile } from './SuggestionDisplay'; +import { Microsoft as InlineSuggestionDisplayFile } from './InlineSuggestionDisplay'; +import { Microsoft as OverlaySuggestionDisplayFile } from './OverlaySuggestionDisplay'; +import { Microsoft as CaretUtilFile } from './CaretUtil'; + +export namespace Microsoft.FluentUI.Blazor.Components.SmartTextArea { + + import SuggestionDisplay = SuggestionDisplayFile.FluentUI.Blazor.Components.SmartTextArea.SuggestionDisplay; + import InlineSuggestionDisplay = InlineSuggestionDisplayFile.FluentUI.Blazor.Components.SmartTextArea.InlineSuggestionDisplay; + import OverlaySuggestionDisplay = OverlaySuggestionDisplayFile.FluentUI.Blazor.Components.SmartTextArea.OverlaySuggestionDisplay; + import CaretUtil = CaretUtilFile.FluentUI.Blazor.Components.SmartTextArea; + + export function registerSmartTextAreaCustomElement() { + customElements.define('smart-textarea', SmartTextArea); + } + + export class SmartTextArea extends HTMLElement { + typingDebounceTimeout: number | null = null; + textArea!: HTMLTextAreaElement; + suggestionDisplay!: SuggestionDisplay; + pendingSuggestionAbortController?: AbortController; + + connectedCallback() { + if (!(this.previousElementSibling instanceof HTMLTextAreaElement)) { + throw new Error('smart-textarea must be rendered immediately after a textarea element'); + } + + this.textArea = this.previousElementSibling as HTMLTextAreaElement; + this.suggestionDisplay = shouldUseInlineSuggestions(this.textArea) + ? new InlineSuggestionDisplay(this, this.textArea) + : new OverlaySuggestionDisplay(this, this.textArea); + + this.textArea.addEventListener('keydown', e => this.handleKeyDown(e)); + this.textArea.addEventListener('keyup', e => this.handleKeyUp(e)); + this.textArea.addEventListener('mousedown', () => this.removeExistingOrPendingSuggestion()); + this.textArea.addEventListener('focusout', () => this.removeExistingOrPendingSuggestion()); + + // If you scroll, we don't need to kill any pending suggestion request, but we do need to hide + // any suggestion that's already visible because the fake cursor will now be in the wrong place + this.textArea.addEventListener('scroll', () => this.suggestionDisplay.reject(), { passive: true }); + } + + handleKeyDown(event: KeyboardEvent) { + switch (event.key) { + case 'Tab': + if (this.suggestionDisplay.isShowing()) { + this.suggestionDisplay.accept(); + event.preventDefault(); + } + break; + case 'Alt': + case 'Control': + case 'Shift': + case 'Command': + break; + default: + const keyMatchesExistingSuggestion = this.suggestionDisplay.isShowing() + && this.suggestionDisplay.currentSuggestion.startsWith(event.key); + if (keyMatchesExistingSuggestion) { + // Let the typing happen, but without side-effects like removing the existing selection + CaretUtil.insertTextAtCaretPosition(this.textArea, event.key); + event.preventDefault(); + + // Update the existing suggestion to match the new text + this.suggestionDisplay.show(this.suggestionDisplay.currentSuggestion.substring(event.key.length)); + CaretUtil.scrollTextAreaDownToCaretIfNeeded(this.textArea); + } else { + this.removeExistingOrPendingSuggestion(); + } + break; + } + } + + keyMatchesExistingSuggestion(key: string): boolean { + return false; + } + + // If this was changed to a 'keypress' event instead, we'd only initiate suggestions after + // the user types a visible character, not pressing another key (e.g., arrows, or ctrl+c). + // However for now I think it is desirable to show suggestions after cursor movement. + handleKeyUp(event: KeyboardEvent) { + // If a suggestion is already visible, it must match the current keystroke or it would + // already have been removed during keydown. So we only start the timeout process if + // there's no visible suggestion. + if (!this.suggestionDisplay.isShowing()) { + clearTimeout(this.typingDebounceTimeout ?? undefined); + this.typingDebounceTimeout = setTimeout(() => this.handleTypingPaused(), 350); + } + } + + handleTypingPaused() { + if (document.activeElement !== this.textArea) { + return; + } + + // We only show a suggestion if the cursor is at the end of the current line. Inserting suggestions in + // the middle of a line is confusing (things move around in unusual ways). + // TODO: You could also allow the case where all remaining text on the current line is whitespace + const isAtEndOfCurrentLine = this.textArea.selectionStart === this.textArea.selectionEnd + && (this.textArea.selectionStart === this.textArea.value.length || this.textArea.value[this.textArea.selectionStart] === '\n'); + if (!isAtEndOfCurrentLine) { + return; + } + + this.requestSuggestionAsync(); + } + + removeExistingOrPendingSuggestion() { + clearTimeout(this.typingDebounceTimeout ?? undefined); + + this.pendingSuggestionAbortController?.abort(); + this.pendingSuggestionAbortController = undefined; + + this.suggestionDisplay.reject(); + } + + async requestSuggestionAsync() { + this.pendingSuggestionAbortController?.abort(); + this.pendingSuggestionAbortController = new AbortController(); + + const snapshot = { + abortSignal: this.pendingSuggestionAbortController.signal, + textAreaValue: this.textArea.value, + cursorPosition: this.textArea.selectionStart, + }; + + const body = { + // TODO: Limit the amount of text we send, e.g., to 100 characters before and after the cursor + textBefore: snapshot.textAreaValue.substring(0, snapshot.cursorPosition), + textAfter: snapshot.textAreaValue.substring(snapshot.cursorPosition), + config: this.getAttribute('data-config'), + }; + + //const antiforgeryName = this.getAttribute('data-antiforgery-name'); + //if (antiforgeryName) { + // body[antiforgeryName] = this.getAttribute('data-antiforgery-value'); + //} + + //const requestInit: RequestInit = { + // method: 'post', + // headers: { + // 'content-type': 'application/x-www-form-urlencoded', + // }, + // body: new URLSearchParams(body), + // signal: snapshot.abortSignal, + //}; + + let suggestionText: string | null = ''; + try { + // We rely on the URL being pathbase-relative for Blazor, or a ~/... URL that would already + // be resolved on the server for MVC + // const httpResponse = await fetch(this.getAttribute('data-url'), requestInit); + suggestionText = 'TODO'; // TODO:httpResponse.ok ? await httpResponse.text() : null; + } catch (ex) { + if (ex instanceof DOMException && ex.name === 'AbortError') { + return; + } + } + + // Normally if the user has made further edits in the textarea, our HTTP request would already + // have been aborted so we wouldn't get here. But if something else (e.g., some other JS code) + // mutates the textarea, we would still get here. It's important we don't apply the suggestion + // if the textarea value or cursor position has changed, so compare against our snapshot. + if (suggestionText != null && suggestionText !== '' + && snapshot.textAreaValue === this.textArea.value + && snapshot.cursorPosition === this.textArea.selectionStart) { + if (!suggestionText.endsWith(' ')) { + suggestionText += ' '; + } + + this.suggestionDisplay.show(suggestionText); + } + } + } + + function shouldUseInlineSuggestions(textArea: HTMLTextAreaElement): boolean { + // Allow the developer to specify this explicitly if they want + const explicitConfig = textArea.getAttribute('data-inline-suggestions'); + if (explicitConfig) { + return explicitConfig.toLowerCase() === 'true'; + } + + // ... but by default, we use overlay on touch devices, inline on non-touch devices + // That's because: + // - Mobile devices will be touch, and most mobile users don't have a "tab" key by which to accept inline suggestions + // - Mobile devices such as iOS will display all kinds of extra UI around selected text (e.g., selection handles), + // which would look completely wrong + // In general, the overlay approach is the risk-averse one that works everywhere, even though it's not as attractive. + const isTouch = 'ontouchstart' in window; // True for any mobile. Usually not true for desktop. + return !isTouch; + } +} diff --git a/src/Core.Scripts/src/Components/SmartTextArea/SuggestionDisplay.ts b/src/Core.Scripts/src/Components/SmartTextArea/SuggestionDisplay.ts new file mode 100644 index 000000000..0551d1347 --- /dev/null +++ b/src/Core.Scripts/src/Components/SmartTextArea/SuggestionDisplay.ts @@ -0,0 +1,10 @@ +export namespace Microsoft.FluentUI.Blazor.Components.SmartTextArea { + export interface SuggestionDisplay { + show(suggestion: string): void; + accept(): void; + reject(): void; + isShowing(): boolean; + + get currentSuggestion(): string; + } +} diff --git a/src/Core.Scripts/src/Components/SmartTextArea/notes.md b/src/Core.Scripts/src/Components/SmartTextArea/notes.md new file mode 100644 index 000000000..691874b8e --- /dev/null +++ b/src/Core.Scripts/src/Components/SmartTextArea/notes.md @@ -0,0 +1,94 @@ + `