UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

322 lines (278 loc) • 12.1 kB
import { serializable } from "../../engine/engine_serialization_decorator.js"; import { FrameEvent } from "../../engine/engine_setup.js"; import { DeviceUtilities, getParam } from "../../engine/engine_utils.js"; import { Behaviour, GameObject } from "../Component.js"; import { EventList } from "../EventList.js"; import { type IPointerEventHandler,PointerEventData } from "./PointerEvents.js"; import { Text } from "./Text.js"; import { tryGetUIComponent } from "./Utils.js"; const debug = getParam("debuginputfield"); /** * @category User Interface * @group Components */ export class InputField extends Behaviour implements IPointerEventHandler { get text(): string { return this.textComponent?.text ?? ""; } set text(value: string) { if (this.textComponent) { this.textComponent.text = value; if (this.placeholder) { if (value.length > 0) this.placeholder.gameObject.visible = false; else this.placeholder.gameObject.visible = true; } } } get isFocused() { return InputField.active === this; } @serializable(Text) private textComponent?: Text; @serializable(Text) private placeholder?: Text; @serializable(EventList) onValueChanged?: EventList<any>; @serializable(EventList) onEndEdit?: EventList<any>; private static active: InputField | null = null; private static activeTime: number = -1; private static htmlField: HTMLInputElement | null = null; private static htmlFieldFocused: boolean = false; private inputEventFn: any; private _iosEventFn: any; start() { if (debug) console.log(this.name, this); } onEnable() { if (!InputField.htmlField) { InputField.htmlField = document.createElement("input"); // we can not use visibility or display because it won't receive input then InputField.htmlField.style.width = "0px"; InputField.htmlField.style.height = "0px"; InputField.htmlField.style.padding = "0px"; InputField.htmlField.style.border = "none"; InputField.htmlField.style.overflow = "hidden"; InputField.htmlField.style.caretColor = "transparent"; InputField.htmlField.style.outline = "none"; InputField.htmlField.classList.add("ar"); InputField.htmlField.onfocus = () => InputField.htmlFieldFocused = true; InputField.htmlField.onblur = () => InputField.htmlFieldFocused = false; // TODO: instead of this we should add it to the shadowdom? document.body.append(InputField.htmlField); } if (!this.inputEventFn) { this.inputEventFn = this.onInput.bind(this); } // InputField.htmlField.addEventListener("input", this.mobileInputEventListener); InputField.htmlField.addEventListener("keyup", this.inputEventFn); // InputField.htmlField.addEventListener("change", this.inputEventFn); if (this.placeholder && this.textComponent?.text.length) { GameObject.setActive(this.placeholder.gameObject, false); } if (DeviceUtilities.isiOS()) { this._iosEventFn = this.processInputOniOS.bind(this); window.addEventListener("click", this._iosEventFn); } } onDisable() { // InputField.htmlField?.removeEventListener("input", this.mobileInputEventListener); InputField.htmlField?.removeEventListener("keyup", this.inputEventFn); // InputField.htmlField?.removeEventListener("change", this.inputEventFn); this.onDeselected(); if (this._iosEventFn) { window.removeEventListener("click", this._iosEventFn); } } /** Clear the input field if it's currently active */ clear() { if (InputField.active === this && InputField.htmlField) { InputField.htmlField.value = ""; this.setTextFromInputField(); } else { if(this.textComponent) this.textComponent.text = ""; if(this.placeholder) GameObject.setActive(this.placeholder.gameObject, true); } } /** Select the input field, set it active to receive keyboard input */ select() { this.onSelected(); } /** Deselect the input field, stop receiving keyboard input */ deselect() { this.onDeselected(); } onPointerEnter(_args: PointerEventData) { const canSetCursor = _args.event.pointerType === "mouse" && _args.button === 0; if(canSetCursor) this.context.input.setCursor("text"); } onPointerExit(_args: PointerEventData) { this.context.input.unsetCursor("text") } onPointerClick(_args) { if (debug) console.log("CLICK", _args, InputField.active); InputField.activeTime = this.context.time.time; if (InputField.active !== this) { this.startCoroutine(this.activeLoop(), FrameEvent.LateUpdate); } this.selectInputField(); } private *activeLoop() { this.onSelected(); while (InputField.active === this) { if (this.context.input.getPointerClicked(0)) { if (this.context.time.time - InputField.activeTime > 0.2) { break; } } this.setTextFromInputField(); yield; } this.onDeselected(); } private onSelected() { if (InputField.active === this) return; if (debug) console.log("Select", this.name, this, InputField.htmlField, this.context.isInXR, this.context.arOverlayElement, this.textComponent?.text, InputField.htmlField?.value); InputField.active?.onDeselected(); InputField.active = this; if (this.placeholder) GameObject.setActive(this.placeholder.gameObject, false); if (InputField.htmlField) { InputField.htmlField.value = this.textComponent?.text || ""; if (debug) console.log("set input field value", InputField.htmlField.value); if (this.context.isInXR) { const overlay = this.context.arOverlayElement; if (overlay) { overlay.append(InputField.htmlField) } } this.selectInputField(); } } private onDeselected() { if (InputField.active !== this) return; InputField.active = null; if (debug) console.log("Deselect", this.name, this); if (InputField.htmlField) { InputField.htmlField.blur(); document.body.append(InputField.htmlField); } if (this.placeholder && (!this.textComponent || this.textComponent.text.length <= 0)) GameObject.setActive(this.placeholder.gameObject, true); if (InputField.htmlField) this.onEndEdit?.invoke(InputField.htmlField.value); } // @Marwie, I can provide this fix. But the issue seems to comes from Raycaster+EventSystem // As we rollout InputField, and no others elements is behind raycast, // ThreeMeshUI.update is not called. update() { if (InputField.active === this) { this.textComponent?.markDirty(); } } private onInput(evt: KeyboardEvent) { if (InputField.active !== this) return; if (debug) console.log(evt.code, evt, InputField.htmlField?.value, this.textComponent?.text); if (evt.code === "Escape" || evt.code === "Enter") { this.onDeselected(); return; } if (InputField.htmlField) { if (this.textComponent) { this.setTextFromInputField(); if (this.placeholder) { GameObject.setActive(this.placeholder.gameObject, this.textComponent.text.length <= 0); } } this.selectInputField(); } // switch (evt.inputType) { // case "insertCompositionText": // this.appendLetter(evt.data?.charAt(evt.data.length - 1) || null); // break; // case "insertText": // console.log(evt.data); // this.appendLetter(evt.data); // break; // case "deleteContentBackward": // this.deleteLetter(); // break; // } } private setTextFromInputField() { if (this.textComponent && InputField.htmlField) { const oldValue = this.textComponent.text; const newValue = InputField.htmlField.value; const changed = this.textComponent.text !== InputField.htmlField.value; this.textComponent.text = InputField.htmlField.value; if (changed) { if (debug) console.log("[InputField] value changed:", newValue, oldValue); this.onValueChanged?.invoke(newValue, oldValue); } } } private selectInputField() { if (InputField.htmlField) { if (debug) console.log("Focus Inputfield", InputField.htmlFieldFocused) InputField.htmlField.setSelectionRange(InputField.htmlField.value.length, InputField.htmlField.value.length); if (DeviceUtilities.isiOS()) InputField.htmlField.focus({ preventScroll: true }); else { // on Andoid if we don't focus in a timeout the keyboard will close the second time we click the InputField setTimeout(() => InputField.htmlField?.focus(), 1); } } } private processInputOniOS() { // focus() on safari ios doesnt open the keyboard when not processed from dom event // so we try in a touch end event if this is hit const hits = this.context.physics.raycast(); if (!hits.length) return; const hit = hits[0]; const obj = hit.object; const component = tryGetUIComponent(obj); if (component?.gameObject === this.gameObject || component?.gameObject.parent === this.gameObject) this.selectInputField(); } // private static _lastDeletionTime: number = 0; // private static _lastKeyInputTime: number = 0; // TODO: support modifiers, refactor to not use backspace as string etc // private handleKey(key: string | null) { // if (!this.textComponent) return; // if (!key) return; // InputField._lastKey = key || ""; // const text = this.textComponent.text; // if (debug) // console.log(key, text); // switch (key) { // case "Backspace": // this.deleteLetter(); // break; // default: // this.appendLetter(key); // break; // } // } // private appendLetter(key: string | null) { // if (this.textComponent && key) { // const timeSinceLastInput = this.context.time.time - InputField._lastKeyInputTime; // if (key.length === 1 && (this.context.input.getKeyDown() === key || timeSinceLastInput > .1)) { // this.textComponent.text += key; // InputField._lastKeyInputTime = this.context.time.time; // } // } // } // private deleteLetter() { // if (this.textComponent) { // const text = this.textComponent.text; // if (text.length > 0 && this.context.time.time - InputField._lastDeletionTime > 0.05) { // this.textComponent.text = text.slice(0, -1); // InputField._lastDeletionTime = this.context.time.time; // } // } // } }