FIX - stabilize homescreen dragging across scaling (#18)

* FIX - stabilize homescreen dragging across scaling

Render the home drag preview in an unzoomed viewport portal and normalize FiveM CEF DOM geometry before hit testing and drop settling. Cover Chrome 103, fractional scaling, 4K, ultrawide, and non-16:9 viewport behavior.

* FIX - Implement LB guest app layout and lifecycle bridge

Ensure custom LB apps are properly sized and visible by applying the required CSS contract on initialization and upon receiving the 'componentsLoaded' signal.

* FIX - match uppercase script tags in LB bridge test
This commit is contained in:
DerEchteAlec
2026-08-20 19:14:00 +02:00
committed by GitHub
parent 36b859cabb
commit dca0868753
13 changed files with 814 additions and 57 deletions
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { runInNewContext } from 'node:vm'
import type { ExternalPhoneAppDefinition } from '@/types/apps'
import {
@@ -139,6 +140,79 @@ describe('LB Phone app bridge', () => {
expect(() => new Function(runtime)).not.toThrow()
})
it('applies the LB iframe layout contract before revealing vendor apps', () => {
const document = createLbPhoneFrameDocument(
'<!doctype html><html><head></head><body style="visibility:hidden"></body></html>',
{
appName: 'radio-app',
localStorage: {},
resourceName: 'lb-radioapp',
settings: createLbPhoneHostSettings({
deviceName: 'Main phone',
isDarkMode: true,
language: 'en',
preferences: DEFAULT_PHONE_PREFERENCES,
securityEnabled: false,
}),
ui: 'https://cfx-nui-lb-radioapp/ui/dist/index.html',
},
)
const runtime = /<script>([\s\S]*?)<\/script>/i.exec(document)?.[1]
expect(runtime).toBeTruthy()
const messageListeners: Array<(event: { data: unknown }) => void> = []
const readyListeners: Array<() => void> = []
const documentElement = { dataset: {}, style: {} }
const body = {
dataset: {},
style: { visibility: 'hidden' },
}
const sandbox = {
addEventListener(
eventName: string,
listener: (event: { data: unknown }) => void,
) {
if (eventName === 'message') messageListeners.push(listener)
},
componentsLoaded: undefined as boolean | undefined,
console,
document: {
addEventListener(eventName: string, listener: () => void) {
if (eventName === 'DOMContentLoaded') readyListeners.push(listener)
},
body,
documentElement,
},
parent: { postMessage() {} },
}
runInNewContext(runtime ?? '', sandbox)
expect(body.style.visibility).toBe('hidden')
expect(readyListeners).toHaveLength(1)
readyListeners[0]?.()
expect(documentElement.style).toMatchObject({
height: '100%',
margin: '0',
padding: '0',
width: '100%',
})
expect(body.dataset).toMatchObject({ device: 'phone', theme: 'dark' })
expect(body.style).toMatchObject({
height: '100%',
margin: '0',
padding: '0',
visibility: 'visible',
width: '100%',
})
body.style.visibility = 'hidden'
expect(messageListeners).toHaveLength(1)
messageListeners[0]?.({ data: 'componentsLoaded' })
expect(body.style.visibility).toBe('visible')
expect(sandbox.componentsLoaded).toBe(true)
})
it('persists isolated LB localStorage snapshots without app changes', () => {
const values = new Map<string, string>()
const storage = {
+28 -1
View File
@@ -130,6 +130,25 @@ function applySettings(nextSettings) {
if (document.body) document.body.dataset.theme = theme;
}
function prepareDocument() {
Object.assign(document.documentElement.style, {
height: '100%',
margin: '0',
padding: '0',
width: '100%'
});
if (!document.body) return;
document.body.dataset.device = 'phone';
Object.assign(document.body.style, {
height: '100%',
margin: '0',
padding: '0',
visibility: 'visible',
width: '100%'
});
}
globalThis.resourceName = config.resourceName;
globalThis.appName = config.appName;
globalThis.components = globalThis.components ?? {};
@@ -189,6 +208,11 @@ globalThis.getSettings = async () => globalThis.settings;
globalThis.addEventListener('message', (event) => {
const message = event.data;
if (message === 'componentsLoaded') {
globalThis.componentsLoaded = true;
prepareDocument();
return;
}
if (!message || typeof message !== 'object') return;
if (message.type === 'sky-phone:lb-settings') {
@@ -210,7 +234,10 @@ globalThis.addEventListener('message', (event) => {
});
applySettings(config.settings);
document.addEventListener('DOMContentLoaded', () => applySettings(globalThis.settings), { once: true });
document.addEventListener('DOMContentLoaded', () => {
prepareDocument();
applySettings(globalThis.settings);
}, { once: true });
`
function escapeAttribute(value: string): string {
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import {
normalizePhoneViewportRect,
readPhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
type RectMeasurement = Pick<
DOMRectReadOnly,
'height' | 'left' | 'top' | 'width'
>
function measuredRect(
left: number,
top: number,
width: number,
height: number,
): RectMeasurement {
return { height, left, top, width }
}
function expectRectClose(
actual: PhoneViewportRect,
expected: PhoneViewportRect,
): void {
for (const key of [
'bottom',
'height',
'left',
'right',
'top',
'width',
] as const) {
expect(actual[key]).toBeCloseTo(expected[key], 6)
}
}
function createGeometryFixture(options: {
canvasOffsetHeight: number
canvasOffsetWidth: number
rawCanvasRect: RectMeasurement
wrapperRect: RectMeasurement
}): { anchor: Element; element: (rect: RectMeasurement) => Element } {
const wrapper = {
getBoundingClientRect: () => options.wrapperRect,
} as unknown as HTMLElement
const canvas = {
closest: (selector: string) =>
selector === '.phone-resolution-wrapper' ? wrapper : null,
getBoundingClientRect: () => options.rawCanvasRect,
offsetHeight: options.canvasOffsetHeight,
offsetWidth: options.canvasOffsetWidth,
} as unknown as HTMLElement
return {
anchor: {
closest: (selector: string) =>
selector === '.phone-resolution-canvas' ? canvas : null,
} as unknown as Element,
element: (rect) =>
({ getBoundingClientRect: () => rect }) as unknown as Element,
}
}
describe('phone viewport geometry', () => {
it('leaves modern Chrome measurements unchanged when the canvas BCR is already rendered', () => {
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
const layer = measuredRect(1589.783336, 177.75, 289.533328, 603.2)
expectRectClose(normalizePhoneViewportRect(layer, wrapper, wrapper), {
bottom: layer.top + layer.height,
height: layer.height,
left: layer.left,
right: layer.left + layer.width,
top: layer.top,
width: layer.width,
})
})
it('calibrates live CEF 103 BCRs back inside the visible wrapper', () => {
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
const rawCanvas = measuredRect(1899.87, 152.5, 389.98, 844)
const rawLayer = measuredRect(1920.03, 214.25, 349.66, 728.5)
const corrected = normalizePhoneViewportRect(rawLayer, rawCanvas, wrapper)
expect(corrected.left).toBeCloseTo(1589.7833360685163, 6)
expect(corrected.width).toBeCloseTo(289.5333278629674, 6)
expect(corrected.right).toBeCloseTo(1879.3166639314836, 6)
expect(corrected.left).toBeGreaterThan(wrapper.left)
expect(corrected.right).toBeLessThan(wrapper.left + wrapper.width)
})
it.each([
['80%', 0.6624],
['100%', 0.828],
['120%', 0.9936],
])(
'normalizes fractional positions, sizes, and deltas at %s scaling',
(_label, zoom) => {
const rawCanvas = measuredRect(1900.125, 212.75, 390, 844)
const wrapper = measuredRect(1530.5, 250.25, 390 * zoom, 844 * zoom)
const rawStart = measuredRect(
rawCanvas.left + 20.125,
rawCanvas.top + 50.375,
349.75,
73.125,
)
const rawEnd = measuredRect(
rawStart.left + 73.25,
rawStart.top - 41.75,
rawStart.width,
rawStart.height,
)
const start = normalizePhoneViewportRect(rawStart, rawCanvas, wrapper)
const end = normalizePhoneViewportRect(rawEnd, rawCanvas, wrapper)
expect(start.left).toBeCloseTo(wrapper.left + 20.125 * zoom, 6)
expect(start.top).toBeCloseTo(wrapper.top + 50.375 * zoom, 6)
expect(start.width).toBeCloseTo(349.75 * zoom, 6)
expect(start.height).toBeCloseTo(73.125 * zoom, 6)
expect(end.left - start.left).toBeCloseTo(73.25 * zoom, 6)
expect(end.top - start.top).toBeCloseTo(-41.75 * zoom, 6)
},
)
it('reads visual scale and normalized element rects from a canvas anchor', () => {
const fixture = createGeometryFixture({
canvasOffsetHeight: 844,
canvasOffsetWidth: 390,
rawCanvasRect: measuredRect(1899.87, 152.5, 389.98, 844),
wrapperRect: measuredRect(1573.09, 126.25, 322.92, 698.832),
})
const geometry = readPhoneViewportGeometry(fixture.anchor)
const layer = fixture.element(measuredRect(1920.03, 214.25, 349.66, 728.5))
expect(geometry).not.toBeNull()
expect(geometry?.scaleX).toBeCloseTo(0.828, 6)
expect(geometry?.scaleY).toBeCloseTo(0.828, 6)
expect(geometry?.rect(layer).left).toBeCloseTo(1589.7833360685163, 6)
})
it('uses finite identity fallbacks for zero geometry and missing anchors', () => {
const rawCanvas = measuredRect(100, 50, 0, 0)
const corrected = normalizePhoneViewportRect(
measuredRect(112.5, 58.25, 40, 20),
rawCanvas,
measuredRect(500, 300, 0, 0),
)
const fixture = createGeometryFixture({
canvasOffsetHeight: 0,
canvasOffsetWidth: 0,
rawCanvasRect: rawCanvas,
wrapperRect: measuredRect(500, 300, 0, 0),
})
const geometry = readPhoneViewportGeometry(fixture.anchor)
expect(corrected).toEqual({
bottom: 328.25,
height: 20,
left: 512.5,
right: 552.5,
top: 308.25,
width: 40,
})
expect(Object.values(corrected).every(Number.isFinite)).toBe(true)
expect(geometry?.scaleX).toBe(1)
expect(geometry?.scaleY).toBe(1)
expect(readPhoneViewportGeometry(null)).toBeNull()
expect(
readPhoneViewportGeometry({ closest: () => null } as unknown as Element),
).toBeNull()
})
})
@@ -0,0 +1,73 @@
export type PhoneViewportRect = {
bottom: number
height: number
left: number
right: number
top: number
width: number
}
type RectMeasurement = Pick<
DOMRectReadOnly,
'height' | 'left' | 'top' | 'width'
>
export type PhoneViewportGeometry = {
readonly scaleX: number
readonly scaleY: number
rect(element: Element): PhoneViewportRect
}
function positiveRatio(numerator: number, denominator: number): number {
return Number.isFinite(numerator) &&
Number.isFinite(denominator) &&
numerator > 0 &&
denominator > 0
? numerator / denominator
: 1
}
export function normalizePhoneViewportRect(
rawRect: RectMeasurement,
rawCanvasRect: RectMeasurement,
wrapperRect: RectMeasurement,
): PhoneViewportRect {
const factorX = positiveRatio(wrapperRect.width, rawCanvasRect.width)
const factorY = positiveRatio(wrapperRect.height, rawCanvasRect.height)
const left = wrapperRect.left + (rawRect.left - rawCanvasRect.left) * factorX
const top = wrapperRect.top + (rawRect.top - rawCanvasRect.top) * factorY
const width = rawRect.width * factorX
const height = rawRect.height * factorY
return {
bottom: top + height,
height,
left,
right: left + width,
top,
width,
}
}
export function readPhoneViewportGeometry(
anchor: Element | null,
): PhoneViewportGeometry | null {
const canvas = anchor?.closest<HTMLElement>('.phone-resolution-canvas')
const wrapper = canvas?.closest<HTMLElement>('.phone-resolution-wrapper')
if (!canvas || !wrapper) return null
const rawCanvasRect = canvas.getBoundingClientRect()
const wrapperRect = wrapper.getBoundingClientRect()
return {
scaleX: positiveRatio(wrapperRect.width, canvas.offsetWidth),
scaleY: positiveRatio(wrapperRect.height, canvas.offsetHeight),
rect(element: Element): PhoneViewportRect {
return normalizePhoneViewportRect(
element.getBoundingClientRect(),
rawCanvasRect,
wrapperRect,
)
},
}
}
@@ -140,4 +140,56 @@ describe('springboard widget drag', () => {
expect(delta.x).toBeCloseTo(100)
expect(delta.y).toBeCloseTo(200)
})
it.each([
['80% preview', 0.6624],
['80% production clamp', 2 / 3],
['100%', 0.828],
['120%', 0.9936],
])('keeps the rendered drag delta 1:1 at %s zoom', (_label, zoom) => {
const layoutWidth = 350
const layoutHeight = 808
const viewportLeft = 128.25
const viewportTop = 64.5
const viewportWidth = layoutWidth * zoom
const viewportHeight = layoutHeight * zoom
const pointerStart = {
x: viewportLeft + 48.5 * zoom,
y: viewportTop + 132.25 * zoom,
}
const viewportDelta = { x: 73.25, y: -41.75 }
const start = springboardViewportToLocal(
pointerStart.x,
pointerStart.y,
viewportLeft,
viewportTop,
viewportWidth,
viewportHeight,
layoutWidth,
layoutHeight,
)
const end = springboardViewportToLocal(
pointerStart.x + viewportDelta.x,
pointerStart.y + viewportDelta.y,
viewportLeft,
viewportTop,
viewportWidth,
viewportHeight,
layoutWidth,
layoutHeight,
)
const localDelta = springboardViewportDeltaToLocal(
viewportDelta.x,
viewportDelta.y,
viewportWidth,
viewportHeight,
layoutWidth,
layoutHeight,
)
expect((end.x - start.x) * zoom).toBeCloseTo(viewportDelta.x, 6)
expect((end.y - start.y) * zoom).toBeCloseTo(viewportDelta.y, 6)
expect(localDelta.x * zoom).toBeCloseTo(viewportDelta.x, 6)
expect(localDelta.y * zoom).toBeCloseTo(viewportDelta.y, 6)
})
})
+5 -1
View File
@@ -1,3 +1,5 @@
import { readPhoneViewportGeometry } from '@/utils/phoneViewportGeometry'
export type PageTurnDirection = -1 | 0 | 1
export type SpringboardEdgeTurn = {
@@ -39,7 +41,9 @@ export function readSpringboardDragMetrics(
'.springboard-page, .home-folder-panel',
)
if (!surface) return null
const bounds = surface.getBoundingClientRect()
const bounds =
readPhoneViewportGeometry(surface)?.rect(surface) ??
surface.getBoundingClientRect()
return {
layoutHeight: surface.offsetHeight,
layoutWidth: surface.offsetWidth,