textinput_js.go 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. // Copyright 2023 The Ebitengine Authors
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. package textinput
  15. import (
  16. "fmt"
  17. "syscall/js"
  18. "github.com/hajimehoshi/ebiten/v2/internal/ui"
  19. )
  20. var (
  21. document = js.Global().Get("document")
  22. body = document.Get("body")
  23. )
  24. func init() {
  25. if !document.Truthy() {
  26. return
  27. }
  28. theTextInput.init()
  29. }
  30. type textInput struct {
  31. textareaElement js.Value
  32. session *session
  33. }
  34. var theTextInput textInput
  35. func (t *textInput) init() {
  36. t.textareaElement = document.Call("createElement", "textarea")
  37. t.textareaElement.Set("id", "ebitengine-textinput")
  38. t.textareaElement.Set("autocapitalize", "off")
  39. t.textareaElement.Set("spellcheck", false)
  40. t.textareaElement.Set("translate", "no")
  41. t.textareaElement.Set("wrap", "off")
  42. style := t.textareaElement.Get("style")
  43. style.Set("position", "absolute")
  44. style.Set("left", "0")
  45. style.Set("top", "0")
  46. style.Set("opacity", "0")
  47. style.Set("resize", "none")
  48. style.Set("cursor", "normal")
  49. style.Set("pointerEvents", "none")
  50. style.Set("overflow", "hidden")
  51. style.Set("tabindex", "-1")
  52. style.Set("width", "1px")
  53. style.Set("height", "1px")
  54. t.textareaElement.Call("addEventListener", "compositionend", js.FuncOf(func(this js.Value, args []js.Value) any {
  55. t.trySend(true)
  56. return nil
  57. }))
  58. t.textareaElement.Call("addEventListener", "focusout", js.FuncOf(func(this js.Value, args []js.Value) any {
  59. if t.session != nil {
  60. t.session.end()
  61. t.session = nil
  62. }
  63. return nil
  64. }))
  65. t.textareaElement.Call("addEventListener", "keydown", js.FuncOf(func(this js.Value, args []js.Value) any {
  66. e := args[0]
  67. if e.Get("code").String() == "Tab" {
  68. e.Call("preventDefault")
  69. }
  70. if ui.IsVirtualKeyboard() && (e.Get("code").String() == "Enter" || e.Get("key").String() == "Enter") {
  71. // Ignore Enter key to avoid ebiten.IsKeyPressed(ebiten.KeyEnter) unexpectedly becomes true.
  72. e.Call("preventDefault")
  73. ui.Get().UpdateInputFromEvent(e)
  74. t.trySend(true)
  75. return nil
  76. }
  77. if !e.Get("isComposing").Bool() {
  78. ui.Get().UpdateInputFromEvent(e)
  79. }
  80. return nil
  81. }))
  82. t.textareaElement.Call("addEventListener", "keyup", js.FuncOf(func(this js.Value, args []js.Value) any {
  83. e := args[0]
  84. if !e.Get("isComposing").Bool() {
  85. ui.Get().UpdateInputFromEvent(e)
  86. }
  87. return nil
  88. }))
  89. t.textareaElement.Call("addEventListener", "input", js.FuncOf(func(this js.Value, args []js.Value) any {
  90. e := args[0]
  91. // On iOS Safari, `isComposing` can be undefined.
  92. if e.Get("isComposing").IsUndefined() {
  93. t.trySend(false)
  94. return nil
  95. }
  96. if e.Get("isComposing").Bool() {
  97. t.trySend(false)
  98. return nil
  99. }
  100. if e.Get("inputType").String() == "insertLineBreak" {
  101. t.trySend(true)
  102. return nil
  103. }
  104. if e.Get("inputType").String() == "insertText" && e.Get("data").Equal(js.Null()) {
  105. // When a new line is inserted, the 'data' property might be null.
  106. t.trySend(true)
  107. return nil
  108. }
  109. // Though `isComposing` is false, send the text as being not committed for text completion with a virtual keyboard.
  110. if ui.IsVirtualKeyboard() {
  111. t.trySend(false)
  112. return nil
  113. }
  114. t.trySend(true)
  115. return nil
  116. }))
  117. t.textareaElement.Call("addEventListener", "change", js.FuncOf(func(this js.Value, args []js.Value) any {
  118. t.trySend(true)
  119. return nil
  120. }))
  121. body.Call("appendChild", t.textareaElement)
  122. js.Global().Call("eval", `
  123. // Process the textarea element under user-interaction events.
  124. // This is due to an iOS Safari restriction (#2898).
  125. let handler = (e) => {
  126. if (window._ebitengine_textinput_x === undefined || window._ebitengine_textinput_y === undefined) {
  127. return;
  128. }
  129. let textarea = document.getElementById("ebitengine-textinput");
  130. textarea.value = '';
  131. textarea.focus();
  132. textarea.style.left = _ebitengine_textinput_x + 'px';
  133. textarea.style.top = _ebitengine_textinput_y + 'px';
  134. window._ebitengine_textinput_x = undefined;
  135. window._ebitengine_textinput_y = undefined;
  136. window._ebitengine_textinput_ready = true;
  137. };
  138. let body = window.document.body;
  139. body.addEventListener("mouseup", handler);
  140. body.addEventListener("touchend", handler);
  141. body.addEventListener("keyup", handler);`)
  142. // TODO: What about other events like wheel?
  143. }
  144. func (t *textInput) Start(x, y int) (chan State, func()) {
  145. if !t.textareaElement.Truthy() {
  146. return nil, nil
  147. }
  148. if js.Global().Get("_ebitengine_textinput_ready").Truthy() {
  149. if t.session != nil {
  150. t.session.end()
  151. }
  152. s := newSession()
  153. t.session = s
  154. js.Global().Get("window").Set("_ebitengine_textinput_ready", js.Undefined())
  155. return s.ch, s.end
  156. }
  157. // If a textarea is focused, create a session immediately.
  158. // A virtual keyboard should already be shown on mobile browsers.
  159. if document.Get("activeElement").Equal(t.textareaElement) {
  160. t.textareaElement.Set("value", "")
  161. t.textareaElement.Call("focus")
  162. style := t.textareaElement.Get("style")
  163. style.Set("left", fmt.Sprintf("%dpx", x))
  164. style.Set("top", fmt.Sprintf("%dpx", y))
  165. if t.session == nil {
  166. s := newSession()
  167. t.session = s
  168. }
  169. return t.session.ch, func() {
  170. if t.session != nil {
  171. t.session.end()
  172. // Reset the session explictly, or a new session cannot be created above.
  173. t.session = nil
  174. }
  175. }
  176. }
  177. if t.session != nil {
  178. t.session.end()
  179. t.session = nil
  180. }
  181. // On iOS Safari, `focus` works only in user-interaction events (#2898).
  182. // Assuming Start is called every tick, defer the starting process to the next user-interaction event.
  183. js.Global().Get("window").Set("_ebitengine_textinput_x", x)
  184. js.Global().Get("window").Set("_ebitengine_textinput_y", y)
  185. return nil, nil
  186. }
  187. func (t *textInput) trySend(committed bool) {
  188. if t.session == nil {
  189. return
  190. }
  191. textareaValue := t.textareaElement.Get("value").String()
  192. if textareaValue == "" {
  193. return
  194. }
  195. start := t.textareaElement.Get("selectionStart").Int()
  196. end := t.textareaElement.Get("selectionEnd").Int()
  197. startInBytes := convertUTF16CountToByteCount(textareaValue, start)
  198. endInBytes := convertUTF16CountToByteCount(textareaValue, end)
  199. t.session.trySend(State{
  200. Text: textareaValue,
  201. CompositionSelectionStartInBytes: startInBytes,
  202. CompositionSelectionEndInBytes: endInBytes,
  203. Committed: committed,
  204. })
  205. if committed {
  206. if t.session != nil {
  207. t.session.end()
  208. t.session = nil
  209. }
  210. t.textareaElement.Set("value", "")
  211. }
  212. }