mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 23:01:37 +00:00
FIX - allow movement outside phone text fields
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const lifecycleEndpoints = new Set([
|
||||
'device:notification-open',
|
||||
'notification:focus',
|
||||
'sim:picker-close',
|
||||
'ui:input-focus',
|
||||
'ui:opened',
|
||||
'ui:ready',
|
||||
])
|
||||
|
||||
@@ -1156,6 +1156,7 @@ async function main() {
|
||||
'device:notification-open',
|
||||
'notification:focus',
|
||||
'sim:picker-close',
|
||||
'ui:input-focus',
|
||||
'ui:opened',
|
||||
'ui:ready',
|
||||
]
|
||||
|
||||
@@ -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,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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user