FIX - allow movement outside phone text fields (#14)

This commit is contained in:
Leon.Schmidt
2026-08-20 15:47:39 +02:00
committed by GitHub
parent daa67cfb1e
commit 5b5e9fe914
8 changed files with 135 additions and 28 deletions
+28
View File
@@ -77,6 +77,7 @@ import { nuiCall } from '@/utils/nui'
import { formatTimer } from '@/utils/clock'
import { parsePhonePreferences } from '@/utils/preferences'
import { getHairlinePixelStyle } from '@/utils/rendering'
import { isTextInputElement } from '@/utils/textInputFocus'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
@@ -278,6 +279,7 @@ const isDevelopment =
developmentParameters.get('apiBase')?.startsWith('/') === true
const developmentLockScreenPreview =
isDevelopment && developmentParameters.has('lockScreenPreview')
let textInputFocused = false
const phone = usePhoneStore()
const account = useAccountStore()
@@ -1404,7 +1406,29 @@ function unlockCamera(): void {
window.setTimeout(() => void router.push('/apps/camera'), 0)
}
function updateTextInputFocus(active: boolean): void {
if (textInputFocused === active) return
textInputFocused = active
void nuiCall('ui:input-focus', { active })
}
function onFocusIn(event: FocusEvent): void {
const target = event.target
updateTextInputFocus(
target instanceof HTMLElement && isTextInputElement(target),
)
}
function onFocusOut(event: FocusEvent): void {
const nextTarget = event.relatedTarget
updateTextInputFocus(
nextTarget instanceof HTMLElement && isTextInputElement(nextTarget),
)
}
onMounted(() => {
document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut)
window.addEventListener('message', onMessage)
window.addEventListener('keydown', onKeydown)
window.addEventListener('resize', updateViewportScale)
@@ -1510,6 +1534,7 @@ watch(
(isOpen) => {
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
if (!isOpen) {
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad()
appStore.cancelPendingInstalls()
activitySuspended.value = false
@@ -1567,6 +1592,7 @@ watch(
)
onBeforeUnmount(() => {
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad()
weather.stop()
if (clockTicker) clearInterval(clockTicker)
@@ -1581,6 +1607,8 @@ onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('resize', updateViewportScale)
document.removeEventListener('focusin', onFocusIn)
document.removeEventListener('focusout', onFocusOut)
systemColorScheme.removeEventListener('change', onSystemColorSchemeChange)
})
</script>
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { isTextInputElement } from '@/utils/textInputFocus'
function element(
tagName: string,
attributes: Record<string, string> = {},
isContentEditable = false,
) {
return {
getAttribute(name: string) {
return attributes[name] ?? null
},
isContentEditable,
tagName,
}
}
describe('text input focus', () => {
it('recognizes fields that accept typed text', () => {
expect(isTextInputElement(element('INPUT'))).toBe(true)
expect(isTextInputElement(element('INPUT', { type: 'number' }))).toBe(true)
expect(isTextInputElement(element('TEXTAREA'))).toBe(true)
expect(isTextInputElement(element('DIV', {}, true))).toBe(true)
expect(isTextInputElement(element('DIV', { role: 'textbox' }))).toBe(true)
})
it('ignores non-text and read-only controls', () => {
expect(isTextInputElement(element('INPUT', { type: 'checkbox' }))).toBe(
false,
)
expect(isTextInputElement(element('INPUT', { type: 'range' }))).toBe(false)
expect(isTextInputElement(element('INPUT', { readonly: '' }))).toBe(false)
expect(isTextInputElement(element('BUTTON'))).toBe(false)
})
})
+34
View File
@@ -0,0 +1,34 @@
const nonTextInputTypes = new Set([
'button',
'checkbox',
'color',
'file',
'hidden',
'image',
'radio',
'range',
'reset',
'submit',
])
type FocusableElement = Pick<
HTMLElement,
'getAttribute' | 'isContentEditable' | 'tagName'
>
export function isTextInputElement(element: FocusableElement): boolean {
if (
element.getAttribute('readonly') !== null ||
element.getAttribute('aria-readonly') === 'true'
) {
return false
}
if (element.isContentEditable || element.getAttribute('role') === 'textbox') {
return true
}
if (element.tagName === 'TEXTAREA') return true
if (element.tagName !== 'INPUT') return false
const inputType = element.getAttribute('type')?.toLowerCase() ?? 'text'
return !nonTextInputTypes.has(inputType)
}
+1
View File
@@ -22,6 +22,7 @@ const lifecycleEndpoints = new Set([
'device:notification-open',
'notification:focus',
'sim:picker-close',
'ui:input-focus',
'ui:opened',
'ui:ready',
])
+1
View File
@@ -1156,6 +1156,7 @@ async function main() {
'device:notification-open',
'notification:focus',
'sim:picker-close',
'ui:input-focus',
'ui:opened',
'ui:ready',
]
+4 -4
View File
@@ -1,6 +1,6 @@
SkyPhoneFocus = {}
local blocked_phone_controls = { 19, 24, 140, 141, 142, 257, 263, 264 }
local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
local focused_control_groups = { 0, 1, 2 }
@@ -39,10 +39,10 @@ function SkyPhoneFocus.Resolve(state)
or state.payphone_focus
or state.sim_picker_open
or (state.camera_active and state.camera_nui_focused)
local cursor = focused and not (game_input and state.cursor_disabled)
local cursor = focused
return {
block_game = cursor,
block_look = game_input and not state.cursor_disabled,
block_game = cursor and (not game_input or state.text_input_focused),
block_look = game_input,
cursor = cursor,
focused = focused,
game_input = game_input,
+15 -10
View File
@@ -15,7 +15,7 @@ local activity_suspended = false
local phone_block_game = false
local phone_block_look = false
local phone_game_input = false
local phone_cursor_disabled = false
local phone_text_input_focused = false
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsOpen", function()
return is_open
@@ -327,20 +327,17 @@ local function update_nui_focus()
call_focus = call_focus,
camera_active = camera_active,
camera_nui_focused = camera_nui_focused,
cursor_disabled = phone_cursor_disabled,
is_open = is_open,
notification_focus = notification_focus,
payphone_focus = payphone_focus,
sim_picker_open = sim_picker_open,
text_input_focused = phone_text_input_focused,
})
SetNuiFocus(focus.focused, focus.cursor)
SetNuiFocusKeepInput(focus.keep_input)
phone_block_game = focus.block_game == true
phone_block_look = focus.block_look == true
phone_game_input = focus.game_input
if not phone_game_input and not phone_block_game then
phone_cursor_disabled = false
end
TriggerEvent("sky_phone:client:cameraFocusApplied", {
active = camera_active,
cursor = focus.cursor,
@@ -357,10 +354,6 @@ CreateThread(function()
else
SkyPhoneFocus.ApplyGameInputControls(phone_block_look)
end
if IsDisabledControlJustPressed(0, 19) then
phone_cursor_disabled = not phone_cursor_disabled
update_nui_focus()
end
Wait(0)
else
Wait(250)
@@ -418,6 +411,7 @@ local function close_phone(close_device_session)
open_requested = false
call_focus = false
activity_suspended = false
phone_text_input_focused = false
TriggerEvent("sky_phone:animation:phone", false)
is_open = false
if was_open then
@@ -500,6 +494,7 @@ RegisterNUICallback("ui:ready", function(data, cb)
-- Browser state is recreated on a CEF reload. A notification focus claim
-- cannot survive unless its notification is replayed as part of this handshake.
notification_focus = false
phone_text_input_focused = false
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
SkyPhoneApps.SendCatalog()
if open_requested and device_payload then
@@ -555,6 +550,16 @@ RegisterNUICallback("ui:opened", function(data, cb)
cb({ success = true })
end)
RegisterNUICallback("ui:input-focus", function(data, cb)
if type(data) ~= "table" or type(data.active) ~= "boolean" then
cb({ success = false, error = "invalid_request" })
return
end
phone_text_input_focused = data.active and (is_open or open_requested)
update_nui_focus()
cb({ success = true })
end)
RegisterNUICallback("close", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
@@ -1086,7 +1091,7 @@ AddEventHandler("onResourceStop", function(resource_name)
active_call_payload = nil
activity_suspended = false
phone_game_input = false
phone_cursor_disabled = false
phone_text_input_focused = false
SetNuiFocusKeepInput(false)
SetNuiFocus(false, false)
+16 -14
View File
@@ -29,11 +29,11 @@ local function resolve(overrides)
call_focus = false,
camera_active = false,
camera_nui_focused = true,
cursor_disabled = false,
is_open = false,
notification_focus = false,
payphone_focus = false,
sim_picker_open = false,
text_input_focused = false,
}
for key, value in pairs(overrides or {}) do
state[key] = value
@@ -71,22 +71,23 @@ assert(
and movable_phone.focused
and movable_phone.keep_input
and movable_phone.game_input
and movable_phone.block_game,
"an open phone with the cursor active must block GTA hotkeys while keeping NUI input"
and not movable_phone.block_game
and movable_phone.block_look,
"an open phone must allow movement without hiding the NUI cursor"
)
local cursor_disabled_phone = resolve({
local typing_phone = resolve({
allow_movement = true,
cursor_disabled = true,
is_open = true,
text_input_focused = true,
})
assert(
not cursor_disabled_phone.cursor
and cursor_disabled_phone.focused
and cursor_disabled_phone.keep_input
and cursor_disabled_phone.game_input
and not cursor_disabled_phone.block_game,
"toggling Alt must release the NUI cursor while preserving phone and GTA input"
typing_phone.cursor
and typing_phone.focused
and typing_phone.keep_input
and typing_phone.game_input
and typing_phone.block_game,
"a focused phone text input must block GTA controls without hiding the NUI cursor"
)
SkyPhoneFocus.ApplyFocusedControls()
@@ -98,9 +99,10 @@ assert(firing_disabled, "focused phone cursor must block attacks while typing")
all_controls_disabled = {}
firing_disabled = false
SkyPhoneFocus.ApplyGameInputControls(true)
for _, control in ipairs({ 19, 24, 140, 141, 142, 257, 263, 264 }) do
for _, control in ipairs({ 24, 140, 141, 142, 257, 263, 264 }) do
assert(disabled_controls[control], ("phone control %d must remain disabled"):format(control))
end
assert(not disabled_controls[19], "Alt must remain available while no phone text input is focused")
for _, control in ipairs({ 1, 2, 3, 4, 5, 6 }) do
assert(disabled_controls[control], ("look control %d must be disabled while the phone cursor is active"):format(control))
end
@@ -111,8 +113,8 @@ assert(firing_disabled, "player attacks must remain disabled while the phone is
disabled_controls = {}
firing_disabled = false
SkyPhoneFocus.ApplyGameInputControls(false)
assert(not disabled_controls[1] and not disabled_controls[2], "Alt cursor toggle must restore camera look")
assert(firing_disabled, "player attacks must remain disabled after the cursor is toggled off")
assert(not disabled_controls[1] and not disabled_controls[2], "camera passthrough must preserve camera look")
assert(firing_disabled, "player attacks must remain disabled during camera passthrough")
local movable_notification = resolve({ allow_movement = true, notification_focus = true })
assert(