From ddc9b33e1aebc972bb15a53f90c51cfb2e4afe0d Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Mon, 26 Sep 2022 10:10:46 +0100 Subject: [PATCH] add input and composition events to input helper. --- src/Web/Avalonia.Web/Interop/InputHelper.cs | 10 +- .../Avalonia.Web/webapp/modules/avalonia.ts | 4 +- .../webapp/modules/avalonia/CaretHelper.ts | 149 ++++++++++++++++++ .../webapp/modules/avalonia/input.ts | 75 +++++++-- 4 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 src/Web/Avalonia.Web/webapp/modules/avalonia/CaretHelper.ts diff --git a/src/Web/Avalonia.Web/Interop/InputHelper.cs b/src/Web/Avalonia.Web/Interop/InputHelper.cs index 7fb2ba35f4..b894aaabfa 100644 --- a/src/Web/Avalonia.Web/Interop/InputHelper.cs +++ b/src/Web/Avalonia.Web/Interop/InputHelper.cs @@ -12,7 +12,15 @@ internal static partial class InputHelper [JSMarshalAs>] Func keyDown, [JSMarshalAs>] - Func keyUp); + Func keyUp, + [JSMarshalAs] + Func onInput, + [JSMarshalAs] + Func onCompositionStart, + [JSMarshalAs] + Func onCompositionUpdate, + [JSMarshalAs] + Func onCompositionEnd) [JSImport("InputHelper.subscribePointerEvents", "avalonia.ts")] public static partial void SubscribePointerEvents( diff --git a/src/Web/Avalonia.Web/webapp/modules/avalonia.ts b/src/Web/Avalonia.Web/webapp/modules/avalonia.ts index 9e5cec838f..4b85434656 100644 --- a/src/Web/Avalonia.Web/webapp/modules/avalonia.ts +++ b/src/Web/Avalonia.Web/webapp/modules/avalonia.ts @@ -4,6 +4,7 @@ import { DpiWatcher } from "./avalonia/canvas"; import { Canvas } from "./avalonia/canvas"; import { InputHelper } from "./avalonia/input"; import { AvaloniaDOM } from "./avalonia/dom"; +import { CaretHelper } from "./avalonia/CaretHelper" export async function createAvaloniaRuntime(api: RuntimeAPI): Promise { api.setModuleImports("avalonia.ts", { @@ -11,6 +12,7 @@ export async function createAvaloniaRuntime(api: RuntimeAPI): Promise { InputHelper, SizeWatcher, DpiWatcher, - AvaloniaDOM + AvaloniaDOM, + CaretHelper }); } diff --git a/src/Web/Avalonia.Web/webapp/modules/avalonia/CaretHelper.ts b/src/Web/Avalonia.Web/webapp/modules/avalonia/CaretHelper.ts new file mode 100644 index 0000000000..5709854087 --- /dev/null +++ b/src/Web/Avalonia.Web/webapp/modules/avalonia/CaretHelper.ts @@ -0,0 +1,149 @@ +// Based on https://github.com/component/textarea-caret-position/blob/master/index.js +export class CaretHelper { + public static getCaretCoordinates( + element: HTMLInputElement | HTMLTextAreaElement, + position: number, + options?: { debug: boolean } + ) { + if (!isBrowser) { + throw new Error( + "textarea-caret-position#getCaretCoordinates should only be called in a browser" + ); + } + + const debug = (options && options.debug) || false; + if (debug) { + const el = document.querySelector( + "#input-textarea-caret-position-mirror-div" + ); + if (el) el.parentNode?.removeChild(el); + } + + // The mirror div will replicate the textarea's style + const div = document.createElement("div"); + div.id = "input-textarea-caret-position-mirror-div"; + document.body.appendChild(div); + + const style = div.style; + const computed = window.getComputedStyle + ? window.getComputedStyle(element) + : ((element as any)["currentStyle"] as CSSStyleDeclaration); // currentStyle for IE < 9 + const isInput = element.nodeName === "INPUT"; + + // Default textarea styles + style.whiteSpace = "pre-wrap"; + if (!isInput) style.wordWrap = "break-word"; // only for textarea-s + + // Position off-screen + style.position = "absolute"; // required to return coordinates properly + if (!debug) style.visibility = "hidden"; // not 'display: none' because we want rendering + + // Transfer the element's properties to the div + properties.forEach((prop: string) => { + if (isInput && prop === "lineHeight") { + // Special case for s because text is rendered centered and line height may be != height + if (computed.boxSizing === "border-box") { + const height = parseInt(computed.height); + const outerHeight = + parseInt(computed.paddingTop) + + parseInt(computed.paddingBottom) + + parseInt(computed.borderTopWidth) + + parseInt(computed.borderBottomWidth); + const targetHeight = outerHeight + parseInt(computed.lineHeight); + if (height > targetHeight) { + style.lineHeight = height - outerHeight + "px"; + } else if (height === targetHeight) { + style.lineHeight = computed.lineHeight; + } else { + style.lineHeight = "0"; + } + } else { + style.lineHeight = computed.height; + } + } else { + (style as any)[prop] = (computed as any)[prop]; + } + }); + + if (isFirefox) { + // Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275 + if (element.scrollHeight > parseInt(computed.height)) + style.overflowY = "scroll"; + } else { + style.overflow = "hidden"; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll' + } + + div.textContent = element.value.substring(0, position); + // The second special handling for input type="text" vs textarea: + // spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037 + if (isInput) div.textContent = div.textContent.replace(/\s/g, "\u00a0"); + + const span = document.createElement("span"); + // Wrapping must be replicated *exactly*, including when a long word gets + // onto the next line, with whitespace at the end of the line before (#7). + // The *only* reliable way to do that is to copy the *entire* rest of the + // textarea's content into the created at the caret position. + // For inputs, just '.' would be enough, but no need to bother. + span.textContent = element.value.substring(position) || "."; // || because a completely empty faux span doesn't render at all + div.appendChild(span); + + const coordinates = { + top: span.offsetTop + parseInt(computed["borderTopWidth"]), + left: span.offsetLeft + parseInt(computed["borderLeftWidth"]), + height: parseInt(computed["lineHeight"]), + }; + + if (debug) { + span.style.backgroundColor = "#aaa"; + } else { + document.body.removeChild(div); + } + + return coordinates; + } +} + + +var properties = [ + "direction", // RTL support + "boxSizing", + "width", // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does + "height", + "overflowX", + "overflowY", // copy the scrollbar for IE + + "borderTopWidth", + "borderRightWidth", + "borderBottomWidth", + "borderLeftWidth", + "borderStyle", + + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + + // https://developer.mozilla.org/en-US/docs/Web/CSS/font + "fontStyle", + "fontVariant", + "fontWeight", + "fontStretch", + "fontSize", + "fontSizeAdjust", + "lineHeight", + "fontFamily", + + "textAlign", + "textTransform", + "textIndent", + "textDecoration", // might not make a difference, but better be safe + + "letterSpacing", + "wordSpacing", + + "tabSize", + "MozTabSize", +]; + +const isBrowser = typeof window !== "undefined"; +const isFirefox = isBrowser && (window as any).mozInnerScreenX != null; diff --git a/src/Web/Avalonia.Web/webapp/modules/avalonia/input.ts b/src/Web/Avalonia.Web/webapp/modules/avalonia/input.ts index 75bdecd71c..2aaa25592b 100644 --- a/src/Web/Avalonia.Web/webapp/modules/avalonia/input.ts +++ b/src/Web/Avalonia.Web/webapp/modules/avalonia/input.ts @@ -1,3 +1,5 @@ +import { CaretHelper } from "./CaretHelper" + enum RawInputModifiers { None = 0, @@ -23,7 +25,11 @@ export class InputHelper { element: HTMLInputElement, keyDownCallback: (code: string, key: string, modifiers: RawInputModifiers) => boolean, keyUpCallback: (code: string, key: string, modifiers: RawInputModifiers) => boolean, - ) { + inputCallback: (args: InputEvent) => boolean, + compositionStartCallback: (args: CompositionEvent) => boolean, + compositionUpdateCallback: (args: CompositionEvent) => boolean, + compositionEndCallback: (args: CompositionEvent) => boolean) + { const keyDownHandler = (args: KeyboardEvent) => { if (keyDownCallback(args.code, args.key, this.getModifiers(args))) { args.preventDefault(); @@ -38,10 +44,48 @@ export class InputHelper { }; element.addEventListener("keyup", keyUpHandler); + const inputHandler = (args: Event) => { + var inputEvent = args as InputEvent; + + // todo check cast + if (inputCallback(inputEvent)) { + args.preventDefault(); + } + }; + element.addEventListener("input", inputHandler); + + const compositionStartHandler = (args: CompositionEvent) => { + if (compositionStartCallback(args)) { + args.preventDefault(); + } + }; + element.addEventListener("compositionstart", compositionStartHandler); + + const compositionUpdateHandler = (args: CompositionEvent) => { + if (compositionUpdateCallback(args)) { + args.preventDefault(); + } + }; + element.addEventListener("compositionupdate", compositionUpdateHandler); + + const compositionEndHandler = (args: CompositionEvent) => { + if (compositionEndCallback(args)) { + args.preventDefault(); + } + }; + element.addEventListener("compositionend", compositionEndHandler); + return () => { element.removeEventListener("keydown", keyDownHandler); element.removeEventListener("keyup", keyUpHandler); + element.removeEventListener("compositionstart", compositionStartHandler); + element.removeEventListener("compositionupdate", compositionUpdateHandler); + element.removeEventListener("compositionend", compositionEndHandler); }; + + + + } public static subscribePointerEvents( @@ -114,22 +158,24 @@ export class InputHelper { inputElement.value = ""; } - public static isInputElement(element: HTMLInputElement | HTMLElement): element is HTMLInputElement { - return (element as HTMLInputElement).setSelectionRange !== undefined; - } - public static focusElement(inputElement: HTMLElement) { inputElement.focus(); - - if (this.isInputElement(inputElement)) { - (inputElement as HTMLInputElement).setSelectionRange(0, 0); - } } public static setCursor(inputElement: HTMLInputElement, kind: string) { inputElement.style.cursor = kind; } + public static setBounds(inputElement: HTMLInputElement, x: number, y: number, caretWidth: number, caretHeight: number, caret: number) { + inputElement.style.left = (x).toFixed(0) + "px"; + inputElement.style.top = (y).toFixed(0) + "px"; + + let { height, left, top } = CaretHelper.getCaretCoordinates(inputElement, caret); + + inputElement.style.left = (x - left).toFixed(0) + "px"; + inputElement.style.top = (y - top).toFixed(0) + "px"; + } + public static hide(inputElement: HTMLInputElement) { inputElement.style.display = 'none'; } @@ -138,6 +184,17 @@ export class InputHelper { inputElement.style.display = 'block'; } + public static setSurroundingText(inputElement: HTMLInputElement, text: string, start: number, end: number) { + if (!inputElement) { + return; + } + + inputElement.value = text; + inputElement.setSelectionRange(start, end); + inputElement.style.width = "20px"; + inputElement.style.width = inputElement.scrollWidth + "px"; + } + private static getModifiers(args: KeyboardEvent): RawInputModifiers { var modifiers = RawInputModifiers.None;