extends Node const FOCUS_BORDER_COLOR := Color(0.12, 0.49, 0.82, 1.0) const CARET_COLOR := Color(0.04, 0.31, 0.60, 1.0) const SELECTION_COLOR := Color(0.25, 0.61, 0.91, 0.30) const STYLED_META := &"whaletown_input_focus_styled" const INITIAL_PLACEHOLDER_META := &"whaletown_initial_placeholder" const FOCUS_PLACEHOLDER_META := &"whaletown_focus_placeholder" const NATIVE_INPUT_ID := "whaletown-native-input" var _javascript_callback: Variant var _native_inputs: Dictionary = {} var _active_native_input: Control var _bridge_initialized := false func _ready() -> void: get_tree().node_added.connect(_on_node_added) call_deferred("_initialize_javascript_bridge") call_deferred("_decorate_existing_inputs") func _process(_delta: float) -> void: if not _bridge_initialized: _initialize_javascript_bridge() func _initialize_javascript_bridge() -> void: if _bridge_initialized or OS.get_name() != "Web": return var window: Variant = JavaScriptBridge.get_interface("window") if window == null: return _javascript_callback = JavaScriptBridge.create_callback(_on_native_input_event) window["__whaletownNativeInputCallback"] = _javascript_callback _bridge_initialized = true func _exit_tree() -> void: _close_native_input() if _bridge_initialized: var window: Variant = JavaScriptBridge.get_interface("window") if window != null: window["__whaletownNativeInputCallback"] = null func _on_node_added(node: Node) -> void: if node is LineEdit or node is TextEdit: _decorate_input.call_deferred(node) func _decorate_existing_inputs() -> void: _decorate_branch(get_tree().root) func _decorate_branch(node: Node) -> void: if node is LineEdit or node is TextEdit: _decorate_input(node) for child in node.get_children(): _decorate_branch(child) func _decorate_input(input: Control) -> void: if not is_instance_valid(input) or input.has_meta(STYLED_META): return input.set_meta(STYLED_META, true) input.add_theme_stylebox_override("focus", _create_focus_style(input)) input.add_theme_color_override("caret_color", CARET_COLOR) input.add_theme_color_override("selection_color", SELECTION_COLOR) input.add_theme_constant_override("caret_width", 2) input.set_meta(INITIAL_PLACEHOLDER_META, input.placeholder_text) input.focus_entered.connect(_on_input_focus_entered.bind(input)) input.focus_exited.connect(_on_input_focus_exited.bind(input)) if input is LineEdit: (input as LineEdit).text_changed.connect(func(_value: String) -> void: _sync_native_input(input)) else: (input as TextEdit).text_changed.connect(func() -> void: _sync_native_input(input)) func _on_input_focus_entered(input: Control) -> void: if not is_instance_valid(input): return if input.text.strip_edges().is_empty(): input.set_meta(FOCUS_PLACEHOLDER_META, input.placeholder_text) input.placeholder_text = "" _open_native_input(input) func _on_input_focus_exited(input: Control) -> void: if not is_instance_valid(input) or not input.text.strip_edges().is_empty(): return var placeholder := str(input.get_meta(FOCUS_PLACEHOLDER_META, input.get_meta(INITIAL_PLACEHOLDER_META, ""))) if not placeholder.is_empty(): input.placeholder_text = placeholder func _open_native_input(input: Control) -> void: if not _bridge_initialized or not is_instance_valid(input): return _close_native_input() _active_native_input = input _native_inputs[input.get_instance_id()] = input var callback_name := "window.__whaletownNativeInputCallback" var input_id := str(input.get_instance_id()) var initial_text: String = input.text var multiline := input is TextEdit var secret := input is LineEdit and (input as LineEdit).secret var rect := input.get_global_rect() var viewport_size := get_viewport().get_visible_rect().size var script := """ (() => { const callback = %s; if (typeof callback !== 'function') return false; const previous = document.getElementById('%s'); if (previous) previous.remove(); const target = document.createElement(%s ? 'textarea' : 'input'); target.id = '%s'; target.value = %s; target.spellcheck = false; target.autocomplete = 'off'; if (%s) target.type = 'password'; const rect = {x: %f, y: %f, width: %f, height: %f}; const viewport = {width: %f, height: %f}; const canvas = document.getElementById('canvas'); const position = () => { const bounds = canvas.getBoundingClientRect(); const sx = bounds.width / viewport.width; const sy = bounds.height / viewport.height; target.style.left = (bounds.left + rect.x * sx) + 'px'; target.style.top = (bounds.top + rect.y * sy) + 'px'; target.style.width = (rect.width * sx) + 'px'; target.style.height = (rect.height * sy) + 'px'; }; Object.assign(target.style, { position: 'fixed', zIndex: '10000', opacity: '0.01', color: 'transparent', caretColor: 'transparent', background: 'transparent', border: '0', outline: '0', padding: '0', margin: '0', resize: 'none', WebkitTextFillColor: 'transparent' }); const send = (kind) => callback(kind, %s, target.value, target.selectionStart || 0, target.selectionEnd || 0); target.addEventListener('input', () => send('input')); target.addEventListener('blur', () => send('blur')); target.addEventListener('keydown', (event) => { if (!%s && event.key === 'Enter') { event.preventDefault(); send('submit'); } if (event.key === 'Escape') { event.preventDefault(); send('cancel'); } }); document.querySelectorAll('.ime').forEach((element) => element.remove()); document.body.appendChild(target); position(); window.addEventListener('resize', position, {passive: true}); target.__whaletownPosition = position; target.focus({preventScroll: true}); target.setSelectionRange(target.value.length, target.value.length); return true; })() """ % [callback_name, NATIVE_INPUT_ID, str(multiline).to_lower(), NATIVE_INPUT_ID, JSON.stringify(initial_text), str(secret).to_lower(), rect.position.x, rect.position.y, rect.size.x, rect.size.y, viewport_size.x, viewport_size.y, JSON.stringify(input_id), str(multiline).to_lower()] var opened: Variant = JavaScriptBridge.eval(script, true) if opened != true: _native_inputs.erase(input.get_instance_id()) _active_native_input = null func _close_native_input() -> void: if _bridge_initialized: JavaScriptBridge.eval("(() => { const el = document.getElementById('%s'); if (el) { if (el.__whaletownPosition) window.removeEventListener('resize', el.__whaletownPosition); el.remove(); } })()" % NATIVE_INPUT_ID, true) _native_inputs.clear() _active_native_input = null func _sync_native_input(input: Control) -> void: if not _bridge_initialized or input != _active_native_input or not is_instance_valid(input): return var value: String = input.text var script := "(() => { const el = document.getElementById('%s'); if (el && el.value !== %s) el.value = %s; })()" % [NATIVE_INPUT_ID, JSON.stringify(value), JSON.stringify(value)] JavaScriptBridge.eval(script, true) func _on_native_input_event(arguments: Array) -> void: if arguments.size() < 3: return var event_kind := str(arguments[0]) var input_id := int(arguments[1]) var input := _native_inputs.get(input_id) as Control if not is_instance_valid(input): return var value := str(arguments[2]) if input.text != value: input.text = value if input is LineEdit: (input as LineEdit).set_caret_column(value.length()) match event_kind: "submit": if input is LineEdit: (input as LineEdit).text_submitted.emit(input.text) "cancel": _close_native_input() func _create_focus_style(input: Control) -> StyleBoxFlat: var focusStyle := StyleBoxFlat.new() focusStyle.bg_color = Color.TRANSPARENT focusStyle.border_color = FOCUS_BORDER_COLOR focusStyle.set_border_width_all(2) var normalStyle := input.get_theme_stylebox("normal") as StyleBoxFlat if normalStyle != null: focusStyle.corner_radius_top_left = normalStyle.corner_radius_top_left focusStyle.corner_radius_top_right = normalStyle.corner_radius_top_right focusStyle.corner_radius_bottom_right = normalStyle.corner_radius_bottom_right focusStyle.corner_radius_bottom_left = normalStyle.corner_radius_bottom_left else: focusStyle.set_corner_radius_all(6) return focusStyle