@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
text/typescript
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;
}
private textComponent?: Text;
private placeholder?: Text;
onValueChanged?: EventList<any>;
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;
// }
// }
// }
}