FIX - allow movement outside phone text fields

This commit is contained in:
Leon.Schmidt
2026-08-20 15:10:20 +02:00
parent daa67cfb1e
commit b361922e2a
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 { formatTimer } from '@/utils/clock'
import { parsePhonePreferences } from '@/utils/preferences' import { parsePhonePreferences } from '@/utils/preferences'
import { getHairlinePixelStyle } from '@/utils/rendering' import { getHairlinePixelStyle } from '@/utils/rendering'
import { isTextInputElement } from '@/utils/textInputFocus'
import { isTrustedRootMessageSource } from '@/utils/windowMessages' import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue' import SpringboardView from '@/views/SpringboardView.vue'
@@ -278,6 +279,7 @@ const isDevelopment =
developmentParameters.get('apiBase')?.startsWith('/') === true developmentParameters.get('apiBase')?.startsWith('/') === true
const developmentLockScreenPreview = const developmentLockScreenPreview =
isDevelopment && developmentParameters.has('lockScreenPreview') isDevelopment && developmentParameters.has('lockScreenPreview')
let textInputFocused = false
const phone = usePhoneStore() const phone = usePhoneStore()
const account = useAccountStore() const account = useAccountStore()
@@ -1404,7 +1406,29 @@ function unlockCamera(): void {
window.setTimeout(() => void router.push('/apps/camera'), 0) 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(() => { onMounted(() => {
document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut)
window.addEventListener('message', onMessage) window.addEventListener('message', onMessage)
window.addEventListener('keydown', onKeydown) window.addEventListener('keydown', onKeydown)
window.addEventListener('resize', updateViewportScale) window.addEventListener('resize', updateViewportScale)
@@ -1510,6 +1534,7 @@ watch(
(isOpen) => { (isOpen) => {
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
if (!isOpen) { if (!isOpen) {
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad() cancelUnlockedPhoneDataLoad()
appStore.cancelPendingInstalls() appStore.cancelPendingInstalls()
activitySuspended.value = false activitySuspended.value = false
@@ -1567,6 +1592,7 @@ watch(
) )
onBeforeUnmount(() => { onBeforeUnmount(() => {
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad() cancelUnlockedPhoneDataLoad()
weather.stop() weather.stop()
if (clockTicker) clearInterval(clockTicker) if (clockTicker) clearInterval(clockTicker)
@@ -1581,6 +1607,8 @@ onBeforeUnmount(() => {
window.removeEventListener('message', onMessage) window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown) window.removeEventListener('keydown', onKeydown)
window.removeEventListener('resize', updateViewportScale) window.removeEventListener('resize', updateViewportScale)
document.removeEventListener('focusin', onFocusIn)
document.removeEventListener('focusout', onFocusOut)
systemColorScheme.removeEventListener('change', onSystemColorSchemeChange) systemColorScheme.removeEventListener('change', onSystemColorSchemeChange)
}) })
</script> </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', 'device:notification-open',
'notification:focus', 'notification:focus',
'sim:picker-close', 'sim:picker-close',
'ui:input-focus',
'ui:opened', 'ui:opened',
'ui:ready', 'ui:ready',
]) ])
+1
View File
@@ -1156,6 +1156,7 @@ async function main() {
'device:notification-open', 'device:notification-open',
'notification:focus', 'notification:focus',
'sim:picker-close', 'sim:picker-close',
'ui:input-focus',
'ui:opened', 'ui:opened',
'ui:ready', 'ui:ready',
] ]
+4 -4
View File
@@ -1,6 +1,6 @@
SkyPhoneFocus = {} 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 blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
local focused_control_groups = { 0, 1, 2 } local focused_control_groups = { 0, 1, 2 }
@@ -39,10 +39,10 @@ function SkyPhoneFocus.Resolve(state)
or state.payphone_focus or state.payphone_focus
or state.sim_picker_open or state.sim_picker_open
or (state.camera_active and state.camera_nui_focused) or (state.camera_active and state.camera_nui_focused)
local cursor = focused and not (game_input and state.cursor_disabled) local cursor = focused
return { return {
block_game = cursor, block_game = cursor and (not game_input or state.text_input_focused),
block_look = game_input and not state.cursor_disabled, block_look = game_input,
cursor = cursor, cursor = cursor,
focused = focused, focused = focused,
game_input = game_input, game_input = game_input,
+15 -10
View File
@@ -15,7 +15,7 @@ local activity_suspended = false
local phone_block_game = false local phone_block_game = false
local phone_block_look = false local phone_block_look = false
local phone_game_input = false local phone_game_input = false
local phone_cursor_disabled = false local phone_text_input_focused = false
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsOpen", function() SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsOpen", function()
return is_open return is_open
@@ -327,20 +327,17 @@ local function update_nui_focus()
call_focus = call_focus, call_focus = call_focus,
camera_active = camera_active, camera_active = camera_active,
camera_nui_focused = camera_nui_focused, camera_nui_focused = camera_nui_focused,
cursor_disabled = phone_cursor_disabled,
is_open = is_open, is_open = is_open,
notification_focus = notification_focus, notification_focus = notification_focus,
payphone_focus = payphone_focus, payphone_focus = payphone_focus,
sim_picker_open = sim_picker_open, sim_picker_open = sim_picker_open,
text_input_focused = phone_text_input_focused,
}) })
SetNuiFocus(focus.focused, focus.cursor) SetNuiFocus(focus.focused, focus.cursor)
SetNuiFocusKeepInput(focus.keep_input) SetNuiFocusKeepInput(focus.keep_input)
phone_block_game = focus.block_game == true phone_block_game = focus.block_game == true
phone_block_look = focus.block_look == true phone_block_look = focus.block_look == true
phone_game_input = focus.game_input 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", { TriggerEvent("sky_phone:client:cameraFocusApplied", {
active = camera_active, active = camera_active,
cursor = focus.cursor, cursor = focus.cursor,
@@ -357,10 +354,6 @@ CreateThread(function()
else else
SkyPhoneFocus.ApplyGameInputControls(phone_block_look) SkyPhoneFocus.ApplyGameInputControls(phone_block_look)
end end
if IsDisabledControlJustPressed(0, 19) then
phone_cursor_disabled = not phone_cursor_disabled
update_nui_focus()
end
Wait(0) Wait(0)
else else
Wait(250) Wait(250)
@@ -418,6 +411,7 @@ local function close_phone(close_device_session)
open_requested = false open_requested = false
call_focus = false call_focus = false
activity_suspended = false activity_suspended = false
phone_text_input_focused = false
TriggerEvent("sky_phone:animation:phone", false) TriggerEvent("sky_phone:animation:phone", false)
is_open = false is_open = false
if was_open then 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 -- Browser state is recreated on a CEF reload. A notification focus claim
-- cannot survive unless its notification is replayed as part of this handshake. -- cannot survive unless its notification is replayed as part of this handshake.
notification_focus = false notification_focus = false
phone_text_input_focused = false
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true }) Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
SkyPhoneApps.SendCatalog() SkyPhoneApps.SendCatalog()
if open_requested and device_payload then if open_requested and device_payload then
@@ -555,6 +550,16 @@ RegisterNUICallback("ui:opened", function(data, cb)
cb({ success = true }) cb({ success = true })
end) 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) RegisterNUICallback("close", function(data, cb)
if type(data) ~= "table" then if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" }) cb({ success = false, error = "invalid_request" })
@@ -1086,7 +1091,7 @@ AddEventHandler("onResourceStop", function(resource_name)
active_call_payload = nil active_call_payload = nil
activity_suspended = false activity_suspended = false
phone_game_input = false phone_game_input = false
phone_cursor_disabled = false phone_text_input_focused = false
SetNuiFocusKeepInput(false) SetNuiFocusKeepInput(false)
SetNuiFocus(false, false) SetNuiFocus(false, false)
+16 -14
View File
@@ -29,11 +29,11 @@ local function resolve(overrides)
call_focus = false, call_focus = false,
camera_active = false, camera_active = false,
camera_nui_focused = true, camera_nui_focused = true,
cursor_disabled = false,
is_open = false, is_open = false,
notification_focus = false, notification_focus = false,
payphone_focus = false, payphone_focus = false,
sim_picker_open = false, sim_picker_open = false,
text_input_focused = false,
} }
for key, value in pairs(overrides or {}) do for key, value in pairs(overrides or {}) do
state[key] = value state[key] = value
@@ -71,22 +71,23 @@ assert(
and movable_phone.focused and movable_phone.focused
and movable_phone.keep_input and movable_phone.keep_input
and movable_phone.game_input and movable_phone.game_input
and movable_phone.block_game, and not movable_phone.block_game
"an open phone with the cursor active must block GTA hotkeys while keeping NUI input" 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, allow_movement = true,
cursor_disabled = true,
is_open = true, is_open = true,
text_input_focused = true,
}) })
assert( assert(
not cursor_disabled_phone.cursor typing_phone.cursor
and cursor_disabled_phone.focused and typing_phone.focused
and cursor_disabled_phone.keep_input and typing_phone.keep_input
and cursor_disabled_phone.game_input and typing_phone.game_input
and not cursor_disabled_phone.block_game, and typing_phone.block_game,
"toggling Alt must release the NUI cursor while preserving phone and GTA input" "a focused phone text input must block GTA controls without hiding the NUI cursor"
) )
SkyPhoneFocus.ApplyFocusedControls() SkyPhoneFocus.ApplyFocusedControls()
@@ -98,9 +99,10 @@ assert(firing_disabled, "focused phone cursor must block attacks while typing")
all_controls_disabled = {} all_controls_disabled = {}
firing_disabled = false firing_disabled = false
SkyPhoneFocus.ApplyGameInputControls(true) 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)) assert(disabled_controls[control], ("phone control %d must remain disabled"):format(control))
end 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 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)) assert(disabled_controls[control], ("look control %d must be disabled while the phone cursor is active"):format(control))
end end
@@ -111,8 +113,8 @@ assert(firing_disabled, "player attacks must remain disabled while the phone is
disabled_controls = {} disabled_controls = {}
firing_disabled = false firing_disabled = false
SkyPhoneFocus.ApplyGameInputControls(false) SkyPhoneFocus.ApplyGameInputControls(false)
assert(not disabled_controls[1] and not disabled_controls[2], "Alt cursor toggle must restore camera look") assert(not disabled_controls[1] and not disabled_controls[2], "camera passthrough must preserve camera look")
assert(firing_disabled, "player attacks must remain disabled after the cursor is toggled off") assert(firing_disabled, "player attacks must remain disabled during camera passthrough")
local movable_notification = resolve({ allow_movement = true, notification_focus = true }) local movable_notification = resolve({ allow_movement = true, notification_focus = true })
assert( assert(