diff --git a/platform/javascript/godot_js.h b/platform/javascript/godot_js.h index 3a41f63fa39..b7b0aabaa4c 100644 --- a/platform/javascript/godot_js.h +++ b/platform/javascript/godot_js.h @@ -60,6 +60,12 @@ extern void godot_js_input_touch_cb(void (*p_callback)(int p_type, int p_count), extern void godot_js_input_key_cb(void (*p_callback)(int p_type, int p_repeat, int p_modifiers), char r_code[32], char r_key[32]); extern void godot_js_input_vibrate_handheld(int p_duration_ms); +// IME +extern void godot_js_set_ime_active(int p_active); +extern void godot_js_set_ime_position(int p_x, int p_y); +extern void godot_js_set_ime_cb(void (*p_input)(int p_type, const char *p_text), void (*p_callback)(int p_type, int p_repeat, int p_modifiers), char r_code[32], char r_key[32]); +extern int godot_js_is_ime_focused(); + // Input gamepad extern void godot_js_input_gamepad_cb(void (*p_on_change)(int p_index, int p_connected, const char *p_id, const char *p_guid)); extern int godot_js_input_gamepad_sample(); diff --git a/platform/javascript/js/libs/library_godot_input.js b/platform/javascript/js/libs/library_godot_input.js index 1b221e78b37..cd460482460 100644 --- a/platform/javascript/js/libs/library_godot_input.js +++ b/platform/javascript/js/libs/library_godot_input.js @@ -28,6 +28,119 @@ /* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /**************************************************************************/ +/* + * IME API helper. + */ + +const GodotIME = { + $GodotIME__deps: ['$GodotRuntime', '$GodotEventListeners'], + $GodotIME__postset: 'GodotOS.atexit(function(resolve, reject) { GodotIME.clear(); resolve(); });', + $GodotIME: { + ime: null, + active: false, + + getModifiers: function (evt) { + return (evt.shiftKey + 0) + ((evt.altKey + 0) << 1) + ((evt.ctrlKey + 0) << 2) + ((evt.metaKey + 0) << 3); + }, + + ime_active: function (active) { + function focus_timer() { + GodotIME.active = true; + GodotIME.ime.focus(); + } + + if (GodotIME.ime) { + if (active) { + GodotIME.ime.style.display = 'block'; + setInterval(focus_timer, 100); + } else { + GodotIME.ime.style.display = 'none'; + GodotConfig.canvas.focus(); + GodotIME.active = false; + } + } + }, + + ime_position: function (x, y) { + if (GodotIME.ime) { + const canvas = GodotConfig.canvas; + const rect = canvas.getBoundingClientRect(); + const rw = canvas.width / rect.width; + const rh = canvas.height / rect.height; + const clx = (x / rw) + rect.x; + const cly = (y / rh) + rect.y; + + GodotIME.ime.style.left = `${clx}px`; + GodotIME.ime.style.top = `${cly}px`; + } + }, + + init: function (ime_cb, key_cb, code, key) { + function key_event_cb(pressed, evt) { + const modifiers = GodotIME.getModifiers(evt); + GodotRuntime.stringToHeap(evt.code, code, 32); + GodotRuntime.stringToHeap(evt.key, key, 32); + key_cb(pressed, evt.repeat, modifiers); + evt.preventDefault(); + } + function ime_event_cb(event) { + if (GodotIME.ime) { + if (event.type === 'compositionstart') { + ime_cb(0, null); + GodotIME.ime.innerHTML = ''; + } else if (event.type === 'compositionupdate') { + const ptr = GodotRuntime.allocString(event.data); + ime_cb(1, ptr); + GodotRuntime.free(ptr); + } else if (event.type === 'compositionend') { + const ptr = GodotRuntime.allocString(event.data); + ime_cb(2, ptr); + GodotRuntime.free(ptr); + GodotIME.ime.innerHTML = ''; + } + } + } + + const ime = document.createElement('div'); + ime.className = 'ime'; + ime.style.background = 'none'; + ime.style.opacity = 0.0; + ime.style.position = 'fixed'; + ime.style.textAlign = 'left'; + ime.style.fontSize = '1px'; + ime.style.left = '0px'; + ime.style.top = '0px'; + ime.style.width = '100%'; + ime.style.height = '40px'; + ime.style.display = 'none'; + ime.contentEditable = 'true'; + + GodotEventListeners.add(ime, 'compositionstart', ime_event_cb, false); + GodotEventListeners.add(ime, 'compositionupdate', ime_event_cb, false); + GodotEventListeners.add(ime, 'compositionend', ime_event_cb, false); + GodotEventListeners.add(ime, 'keydown', key_event_cb.bind(null, 1), false); + GodotEventListeners.add(ime, 'keyup', key_event_cb.bind(null, 0), false); + + ime.onblur = function () { + this.style.display = 'none'; + GodotConfig.canvas.focus(); + GodotIME.active = false; + }; + + GodotConfig.canvas.parentElement.appendChild(ime); + GodotIME.ime = ime; + }, + + clear: function () { + if (GodotIME.ime) { + GodotIME.ime.remove(); + GodotIME.ime = null; + } + }, + }, +}; +mergeInto(LibraryManager.library, GodotIME); + /* * Gamepad API helper. */ @@ -338,7 +451,7 @@ mergeInto(LibraryManager.library, GodotInputDragDrop); * Godot exposed input functions. */ const GodotInput = { - $GodotInput__deps: ['$GodotRuntime', '$GodotConfig', '$GodotEventListeners', '$GodotInputGamepads', '$GodotInputDragDrop'], + $GodotInput__deps: ['$GodotRuntime', '$GodotConfig', '$GodotEventListeners', '$GodotInputGamepads', '$GodotInputDragDrop', '$GodotIME'], $GodotInput: { getModifiers: function (evt) { return (evt.shiftKey + 0) + ((evt.altKey + 0) << 1) + ((evt.ctrlKey + 0) << 2) + ((evt.metaKey + 0) << 3); @@ -456,6 +569,35 @@ const GodotInput = { GodotEventListeners.add(GodotConfig.canvas, 'keyup', key_cb.bind(null, 0), false); }, + /* + * IME API + */ + godot_js_set_ime_active__proxy: 'sync', + godot_js_set_ime_active__sig: 'vi', + godot_js_set_ime_active: function (p_active) { + GodotIME.ime_active(p_active); + }, + + godot_js_set_ime_position__proxy: 'sync', + godot_js_set_ime_position__sig: 'vii', + godot_js_set_ime_position: function (p_x, p_y) { + GodotIME.ime_position(p_x, p_y); + }, + + godot_js_set_ime_cb__proxy: 'sync', + godot_js_set_ime_cb__sig: 'viiii', + godot_js_set_ime_cb: function (p_ime_cb, p_key_cb, code, key) { + const ime_cb = GodotRuntime.get_func(p_ime_cb); + const key_cb = GodotRuntime.get_func(p_key_cb); + GodotIME.init(ime_cb, key_cb, code, key); + }, + + godot_js_is_ime_focused__proxy: 'sync', + godot_js_is_ime_focused__sig: 'i', + godot_js_is_ime_focused: function () { + return GodotIME.active; + }, + /* * Gamepad API */ diff --git a/platform/javascript/os_javascript.cpp b/platform/javascript/os_javascript.cpp index e3db424bb0a..7bf0fac3b19 100644 --- a/platform/javascript/os_javascript.cpp +++ b/platform/javascript/os_javascript.cpp @@ -177,6 +177,9 @@ void OS_JavaScript::send_notification_callback(int p_notification) { if (p_notification == MainLoop::NOTIFICATION_WM_MOUSE_ENTER || p_notification == MainLoop::NOTIFICATION_WM_MOUSE_EXIT) { os->cursor_inside_canvas = p_notification == MainLoop::NOTIFICATION_WM_MOUSE_ENTER; } + if (godot_js_is_ime_focused() && (p_notification == MainLoop::NOTIFICATION_WM_FOCUS_IN || p_notification == MainLoop::NOTIFICATION_WM_FOCUS_OUT)) { + return; + } MainLoop *loop = os->get_main_loop(); if (loop) { loop->notification(p_notification); @@ -283,22 +286,39 @@ static void dom2godot_mod(Ref ev, int p_mod) { void OS_JavaScript::key_callback(int p_pressed, int p_repeat, int p_modifiers) { OS_JavaScript *os = get_singleton(); JSKeyEvent &key_event = os->key_event; + + const String code = String::utf8(key_event.code); + const String key = String::utf8(key_event.key); + // Resume audio context after input in case autoplay was denied. os->resume_audio(); - Ref ev; - ev.instance(); - ev->set_echo(p_repeat); - ev->set_scancode(dom_code2godot_scancode(key_event.code, key_event.key, false)); - ev->set_physical_scancode(dom_code2godot_scancode(key_event.code, key_event.key, true)); - ev->set_pressed(p_pressed); - dom2godot_mod(ev, p_modifiers); - - String unicode = String::utf8(key_event.key); - if (unicode.length() == 1) { - ev->set_unicode(unicode[0]); + if (os->ime_started) { + return; } - os->input->parse_input_event(ev); + + wchar_t c = 0x00; + String unicode = key; + if (unicode.length() == 1) { + c = unicode[0]; + } + uint32_t keycode = dom_code2godot_scancode(code.utf8().get_data(), key.utf8().get_data(), false); + uint32_t scancode = dom_code2godot_scancode(code.utf8().get_data(), key.utf8().get_data(), true); + + OS_JavaScript::KeyEvent ke; + + ke.pressed = p_pressed; + ke.echo = p_repeat; + ke.raw = true; + ke.keycode = keycode; + ke.physical_keycode = scancode; + ke.unicode = c; + ke.mod = p_modifiers; + + if (os->key_event_pos >= os->key_event_buffer.size()) { + os->key_event_buffer.resize(1 + os->key_event_pos); + } + os->key_event_buffer.write[os->key_event_pos++] = ke; // Make sure to flush all events so we can call restricted APIs inside the event. os->input->flush_buffered_events(); @@ -579,7 +599,7 @@ OS::MouseMode OS_JavaScript::get_mouse_mode() const { int OS_JavaScript::mouse_wheel_callback(double p_delta_x, double p_delta_y) { OS_JavaScript *os = get_singleton(); - if (!godot_js_display_canvas_is_focused()) { + if (!godot_js_display_canvas_is_focused() && !godot_js_is_ime_focused()) { if (os->cursor_inside_canvas) { godot_js_display_canvas_focus(); } else { @@ -670,6 +690,90 @@ void OS_JavaScript::touch_callback(int p_type, int p_count) { } } +// IME. +void OS_JavaScript::ime_callback(int p_type, const char *p_text) { + OS_JavaScript *os = get_singleton(); + + // Resume audio context after input in case autoplay was denied. + os->resume_audio(); + + switch (p_type) { + case 0: { + // IME start. + os->ime_text = String(); + os->ime_selection = Vector2i(); + for (int i = os->key_event_pos - 1; i >= 0; i--) { + // Delete last raw keydown event from query. + if (os->key_event_buffer[i].pressed && os->key_event_buffer[i].raw) { + os->key_event_buffer.remove(i); + os->key_event_pos--; + break; + } + } + os->get_main_loop()->notification(MainLoop::NOTIFICATION_OS_IME_UPDATE); + os->ime_started = true; + } break; + case 1: { + // IME update. + if (os->ime_active && os->ime_started) { + os->ime_text = String::utf8(p_text); + os->ime_selection = Vector2i(os->ime_text.length(), os->ime_text.length()); + os->get_main_loop()->notification(MainLoop::NOTIFICATION_OS_IME_UPDATE); + } + } break; + case 2: { + // IME commit. + if (os->ime_active && os->ime_started) { + os->ime_started = false; + + os->ime_text = String(); + os->ime_selection = Vector2i(); + os->get_main_loop()->notification(MainLoop::NOTIFICATION_OS_IME_UPDATE); + + String text = String::utf8(p_text); + for (int i = 0; i < text.length(); i++) { + OS_JavaScript::KeyEvent ke; + + ke.pressed = true; + ke.echo = false; + ke.raw = false; + ke.keycode = 0; + ke.physical_keycode = 0; + ke.unicode = text[i]; + ke.mod = 0; + + if (os->key_event_pos >= os->key_event_buffer.size()) { + os->key_event_buffer.resize(1 + os->key_event_pos); + } + os->key_event_buffer.write[os->key_event_pos++] = ke; + } + } + } break; + default: + break; + } + + os->process_keys(); + os->input->flush_buffered_events(); +} + +void OS_JavaScript::set_ime_active(const bool p_active) { + ime_active = p_active; + godot_js_set_ime_active(p_active); +} + +void OS_JavaScript::set_ime_position(const Point2 &p_pos) { + godot_js_set_ime_position(p_pos.x, p_pos.y); +} + +Point2 OS_JavaScript::get_ime_selection() const { + return ime_selection; +} + +String OS_JavaScript::get_ime_text() const { + return ime_text; +} + // Gamepad void OS_JavaScript::gamepad_callback(int p_index, int p_connected, const char *p_id, const char *p_guid) { InputDefault *input = get_singleton()->input; @@ -707,6 +811,26 @@ void OS_JavaScript::process_joypads() { } } +void OS_JavaScript::process_keys() { + for (int i = 0; i < key_event_pos; i++) { + const OS_JavaScript::KeyEvent &ke = key_event_buffer[i]; + + Ref ev; + ev.instance(); + ev->set_pressed(ke.pressed); + ev->set_echo(ke.echo); + ev->set_scancode(ke.keycode); + ev->set_physical_scancode(ke.physical_keycode); + ev->set_unicode(ke.unicode); + if (ke.raw) { + dom2godot_mod(ev, ke.mod); + } + + input->parse_input_event(ev); + } + key_event_pos = 0; +} + bool OS_JavaScript::is_joy_known(int p_device) { return input->is_joy_mapped(p_device); } @@ -859,6 +983,7 @@ Error OS_JavaScript::initialize(const VideoMode &p_desired, int p_video_driver, godot_js_input_gamepad_cb(&OS_JavaScript::gamepad_callback); godot_js_input_paste_cb(&OS_JavaScript::update_clipboard_callback); godot_js_input_drop_files_cb(&OS_JavaScript::drop_files_callback); + godot_js_set_ime_cb(&OS_JavaScript::ime_callback, &OS_JavaScript::key_callback, key_event.code, key_event.key); // JS Display interface (js/libs/library_godot_display.js) godot_js_display_fullscreen_cb(&OS_JavaScript::fullscreen_change_callback); @@ -940,8 +1065,8 @@ bool OS_JavaScript::main_loop_iterate() { godot_js_os_fs_sync(&OS_JavaScript::fs_sync_callback); } + process_keys(); input->flush_buffered_events(); - if (godot_js_input_gamepad_sample() == OK) { process_joypads(); } diff --git a/platform/javascript/os_javascript.h b/platform/javascript/os_javascript.h index 1d20367c90a..c7a7c69ac25 100644 --- a/platform/javascript/os_javascript.h +++ b/platform/javascript/os_javascript.h @@ -54,6 +54,24 @@ private: }; JSKeyEvent key_event; + bool ime_active = false; + bool ime_started = false; + String ime_text; + Vector2 ime_selection; + + struct KeyEvent { + bool pressed = false; + bool echo = false; + bool raw = false; + uint32_t keycode = 0; + uint32_t physical_keycode = 0; + uint32_t unicode = 0; + int mod = 0; + }; + + Vector key_event_buffer; + int key_event_pos = 0; + VideoMode video_mode; bool transparency_enabled; @@ -94,6 +112,7 @@ private: static void gamepad_callback(int p_index, int p_connected, const char *p_id, const char *p_guid); static void input_text_callback(const char *p_text, int p_cursor); void process_joypads(); + void process_keys(); static void file_access_close_callback(const String &p_file, int p_flags); @@ -105,6 +124,7 @@ private: static void update_clipboard_callback(const char *p_text); static void update_pwa_state_callback(); static void _js_utterance_callback(int p_event, int p_id, int p_pos); + static void ime_callback(int p_type, const char *p_text); static void update_voices_callback(int p_size, const char **p_voice); protected: @@ -161,6 +181,12 @@ public: virtual float get_screen_scale(int p_screen = -1) const; virtual float get_screen_max_scale() const; + virtual void set_ime_active(const bool p_active); + virtual void set_ime_position(const Point2 &p_pos); + + virtual Point2 get_ime_selection() const; + virtual String get_ime_text() const; + virtual Point2 get_mouse_position() const; virtual int get_mouse_button_state() const; virtual void set_cursor_shape(CursorShape p_shape);