mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
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:
@@ -1778,6 +1778,11 @@ onBeforeUnmount(() => {
|
|||||||
"
|
"
|
||||||
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
|
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
id="phone-home-drag-portal"
|
||||||
|
class="phone-home-drag-portal"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
<div class="phone-resolution-canvas phone-resolution-canvas--primary">
|
<div class="phone-resolution-canvas phone-resolution-canvas--primary">
|
||||||
<section
|
<section
|
||||||
class="phone-device"
|
class="phone-device"
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ describe('browser development preview contract', () => {
|
|||||||
|
|
||||||
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
|
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
|
||||||
expect(source).toContain('phone-resolution-canvas--primary')
|
expect(source).toContain('phone-resolution-canvas--primary')
|
||||||
|
expect(source).toMatch(
|
||||||
|
/phone-resolution-wrapper--primary[\s\S]*?id="phone-home-drag-portal"[\s\S]*?phone-resolution-canvas--primary/,
|
||||||
|
)
|
||||||
|
expect(source.match(/id="phone-home-drag-portal"/g)).toHaveLength(1)
|
||||||
expect(source).toContain("'--phone-rendered-height'")
|
expect(source).toContain("'--phone-rendered-height'")
|
||||||
expect(source).toContain("'--phone-rendered-width'")
|
expect(source).toContain("'--phone-rendered-width'")
|
||||||
expect(mainCss).toMatch(
|
expect(mainCss).toMatch(
|
||||||
|
|||||||
@@ -295,6 +295,18 @@ button {
|
|||||||
height: var(--phone-rendered-height, 844px);
|
height: var(--phone-rendered-height, 844px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.phone-home-drag-portal {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 70;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-home-drag-portal * {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.phone-resolution-canvas {
|
.phone-resolution-canvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -2622,8 +2634,30 @@ button {
|
|||||||
.springboard--widget-dragging .springboard-widget-page-scroll {
|
.springboard--widget-dragging .springboard-widget-page-scroll {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.springboard--home-dragging .springboard-page--apps {
|
.home-drag-layer {
|
||||||
overflow: visible;
|
position: absolute;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.home-drag-layer * {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
.home-drag-position {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.home-drag-ghost {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 1;
|
||||||
|
transition: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
.app-grid {
|
.app-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -2658,6 +2692,9 @@ button {
|
|||||||
transition: transform var(--springboard-page-duration)
|
transition: transform var(--springboard-page-duration)
|
||||||
var(--springboard-page-easing);
|
var(--springboard-page-easing);
|
||||||
}
|
}
|
||||||
|
.app-icon-item--drag-source {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
.app-icon-remove {
|
.app-icon-remove {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
|
|||||||
@@ -30,11 +30,13 @@ const props = withDefaults(
|
|||||||
app: PhoneAppDefinition
|
app: PhoneAppDefinition
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
editMode?: boolean
|
editMode?: boolean
|
||||||
|
externalDragVisual?: boolean
|
||||||
showLabel?: boolean
|
showLabel?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
compact: false,
|
compact: false,
|
||||||
editMode: false,
|
editMode: false,
|
||||||
|
externalDragVisual: false,
|
||||||
showLabel: true,
|
showLabel: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -63,14 +65,14 @@ let dragStartPage = 0
|
|||||||
let dragPageWidth = 0
|
let dragPageWidth = 0
|
||||||
let dragPageMetrics: SpringboardDragMetrics | null = null
|
let dragPageMetrics: SpringboardDragMetrics | null = null
|
||||||
const dragStyle = computed(() =>
|
const dragStyle = computed(() =>
|
||||||
isDragging.value
|
isDragging.value && !props.externalDragVisual
|
||||||
? {
|
? {
|
||||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
const dragPointerStyle = computed(() =>
|
const dragPointerStyle = computed(() =>
|
||||||
isDragging.value
|
isDragging.value && !props.externalDragVisual
|
||||||
? {
|
? {
|
||||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||||
}
|
}
|
||||||
@@ -300,6 +302,7 @@ onBeforeUnmount(() => {
|
|||||||
class="app-icon-item"
|
class="app-icon-item"
|
||||||
:class="{
|
:class="{
|
||||||
'app-icon-item--compact': compact,
|
'app-icon-item--compact': compact,
|
||||||
|
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||||
'app-icon-item--dragging': isDragging,
|
'app-icon-item--dragging': isDragging,
|
||||||
'app-icon-item--editing': editMode,
|
'app-icon-item--editing': editMode,
|
||||||
}"
|
}"
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ const props = withDefaults(
|
|||||||
apps: PhoneAppDefinition[]
|
apps: PhoneAppDefinition[]
|
||||||
defaultName: string
|
defaultName: string
|
||||||
editMode?: boolean
|
editMode?: boolean
|
||||||
|
externalDragVisual?: boolean
|
||||||
folder: HomeFolder
|
folder: HomeFolder
|
||||||
showLabel?: boolean
|
showLabel?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
editMode: false,
|
editMode: false,
|
||||||
|
externalDragVisual: false,
|
||||||
showLabel: true,
|
showLabel: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -55,14 +57,14 @@ let stopPointerSession: (() => void) | null = null
|
|||||||
|
|
||||||
const folderName = computed(() => props.folder.name || props.defaultName)
|
const folderName = computed(() => props.folder.name || props.defaultName)
|
||||||
const dragStyle = computed(() =>
|
const dragStyle = computed(() =>
|
||||||
isDragging.value
|
isDragging.value && !props.externalDragVisual
|
||||||
? {
|
? {
|
||||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
const dragPointerStyle = computed(() =>
|
const dragPointerStyle = computed(() =>
|
||||||
isDragging.value
|
isDragging.value && !props.externalDragVisual
|
||||||
? {
|
? {
|
||||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||||
}
|
}
|
||||||
@@ -232,6 +234,7 @@ onBeforeUnmount(() => {
|
|||||||
<div
|
<div
|
||||||
class="home-folder-item app-icon-item"
|
class="home-folder-item app-icon-item"
|
||||||
:class="{
|
:class="{
|
||||||
|
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||||
'app-icon-item--dragging': isDragging,
|
'app-icon-item--dragging': isDragging,
|
||||||
'app-icon-item--editing': editMode,
|
'app-icon-item--editing': editMode,
|
||||||
'home-folder-item--dragging': isDragging,
|
'home-folder-item--dragging': isDragging,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { runInNewContext } from 'node:vm'
|
||||||
|
|
||||||
import type { ExternalPhoneAppDefinition } from '@/types/apps'
|
import type { ExternalPhoneAppDefinition } from '@/types/apps'
|
||||||
import {
|
import {
|
||||||
@@ -139,6 +140,79 @@ describe('LB Phone app bridge', () => {
|
|||||||
expect(() => new Function(runtime)).not.toThrow()
|
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', () => {
|
it('persists isolated LB localStorage snapshots without app changes', () => {
|
||||||
const values = new Map<string, string>()
|
const values = new Map<string, string>()
|
||||||
const storage = {
|
const storage = {
|
||||||
|
|||||||
@@ -130,6 +130,25 @@ function applySettings(nextSettings) {
|
|||||||
if (document.body) document.body.dataset.theme = theme;
|
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.resourceName = config.resourceName;
|
||||||
globalThis.appName = config.appName;
|
globalThis.appName = config.appName;
|
||||||
globalThis.components = globalThis.components ?? {};
|
globalThis.components = globalThis.components ?? {};
|
||||||
@@ -189,6 +208,11 @@ globalThis.getSettings = async () => globalThis.settings;
|
|||||||
|
|
||||||
globalThis.addEventListener('message', (event) => {
|
globalThis.addEventListener('message', (event) => {
|
||||||
const message = event.data;
|
const message = event.data;
|
||||||
|
if (message === 'componentsLoaded') {
|
||||||
|
globalThis.componentsLoaded = true;
|
||||||
|
prepareDocument();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!message || typeof message !== 'object') return;
|
if (!message || typeof message !== 'object') return;
|
||||||
|
|
||||||
if (message.type === 'sky-phone:lb-settings') {
|
if (message.type === 'sky-phone:lb-settings') {
|
||||||
@@ -210,7 +234,10 @@ globalThis.addEventListener('message', (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
applySettings(config.settings);
|
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 {
|
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.x).toBeCloseTo(100)
|
||||||
expect(delta.y).toBeCloseTo(200)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { readPhoneViewportGeometry } from '@/utils/phoneViewportGeometry'
|
||||||
|
|
||||||
export type PageTurnDirection = -1 | 0 | 1
|
export type PageTurnDirection = -1 | 0 | 1
|
||||||
|
|
||||||
export type SpringboardEdgeTurn = {
|
export type SpringboardEdgeTurn = {
|
||||||
@@ -39,7 +41,9 @@ export function readSpringboardDragMetrics(
|
|||||||
'.springboard-page, .home-folder-panel',
|
'.springboard-page, .home-folder-panel',
|
||||||
)
|
)
|
||||||
if (!surface) return null
|
if (!surface) return null
|
||||||
const bounds = surface.getBoundingClientRect()
|
const bounds =
|
||||||
|
readPhoneViewportGeometry(surface)?.rect(surface) ??
|
||||||
|
surface.getBoundingClientRect()
|
||||||
return {
|
return {
|
||||||
layoutHeight: surface.offsetHeight,
|
layoutHeight: surface.offsetHeight,
|
||||||
layoutWidth: surface.offsetWidth,
|
layoutWidth: surface.offsetWidth,
|
||||||
|
|||||||
@@ -94,36 +94,99 @@ describe('Springboard page swipe contract', () => {
|
|||||||
expect(folderIconSource).toContain(':style="dragPointerStyle"')
|
expect(folderIconSource).toContain(':style="dragPointerStyle"')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the same scale-aware in-phone drag path as widgets', () => {
|
it('renders the home drag visual through the unzoomed phone portal', () => {
|
||||||
expect(viewSource).not.toContain('source.cloneNode(true)')
|
expect(viewSource).toContain('source.cloneNode(true)')
|
||||||
expect(viewSource).not.toContain('<Teleport to="body">')
|
|
||||||
expect(viewSource).not.toContain(':external-drag-visual')
|
|
||||||
expect(viewSource).toContain(
|
expect(viewSource).toContain(
|
||||||
"'springboard--home-dragging': draggingHomeApp !== null",
|
'<Teleport defer to="#phone-home-drag-portal">',
|
||||||
)
|
)
|
||||||
expect(viewSource).toContain('draggedElement?.getBoundingClientRect()')
|
expect(viewSource).toContain(
|
||||||
expect(appIconSource).not.toContain('externalDragVisual')
|
'<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>',
|
||||||
expect(folderIconSource).not.toContain('externalDragVisual')
|
)
|
||||||
expect(mainCss).not.toContain('.home-drag-layer')
|
expect(viewSource).toContain('readPhoneViewportGeometry')
|
||||||
expect(mainCss).not.toContain('.home-drag-ghost')
|
expect(viewSource).toMatch(/const sourceBounds = \w+\.rect\(source\)/)
|
||||||
expect(mainCss).not.toContain('.app-icon-item--drag-source')
|
expect(viewSource).toMatch(/const clipBounds = \w+\.rect\(\w+\)/)
|
||||||
|
expect(viewSource).not.toContain('homeDragLocalPoint')
|
||||||
|
expect(viewSource).not.toContain('springboardViewportToLocal')
|
||||||
|
expect(viewSource).toContain("position.className = 'home-drag-position'")
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/homeDragGrip = \{\s*x: event\.clientX - sourceBounds\.left,\s*y: event\.clientY - sourceBounds\.top,/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/ghost\.style\.transform = `scale\(\$\{\w+\.scaleX\}, \$\{\w+\.scaleY\}\)`/,
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
|
||||||
|
).toHaveLength(4)
|
||||||
|
expect(viewSource).toContain('updateHomeDragGhost(event)')
|
||||||
|
expect(viewSource).toContain('const dropGhost = homeDragGhost')
|
||||||
|
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
|
||||||
|
expect(viewSource).not.toContain('dropGhost?.getBoundingClientRect()')
|
||||||
|
expect(viewSource).toContain('clearHomeDragGhost(dropGhost)')
|
||||||
|
expect(viewSource).not.toContain('draggedElement?.getBoundingClientRect()')
|
||||||
|
expect(viewSource).not.toContain('springboard--home-dragging')
|
||||||
|
expect(appIconSource).toContain('externalDragVisual?: boolean')
|
||||||
|
expect(folderIconSource).toContain('externalDragVisual?: boolean')
|
||||||
|
expect(appIconSource).toContain('app-icon-item--drag-source')
|
||||||
|
expect(folderIconSource).toContain('app-icon-item--drag-source')
|
||||||
expect(mainCss).toMatch(
|
expect(mainCss).toMatch(
|
||||||
/\.springboard--home-dragging \.springboard-page--apps\s*\{[^}]*overflow:\s*visible;/s,
|
/\.phone-home-drag-portal\s*\{[^}]*pointer-events:\s*none;/s,
|
||||||
)
|
)
|
||||||
|
expect(mainCss).toContain('.home-drag-position')
|
||||||
|
expect(mainCss).toContain('.home-drag-ghost')
|
||||||
|
expect(mainCss).toContain('.app-icon-item--drag-source')
|
||||||
expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/)
|
expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/)
|
||||||
expect(mainCss).toMatch(
|
expect(mainCss).toMatch(
|
||||||
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s,
|
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('cleans up the external drag visual on every terminal path', () => {
|
||||||
|
expect(
|
||||||
|
viewSource.match(
|
||||||
|
/@dragstart="startHomeDrag\('(grid|dock)', [^,]+, \$event\)"/g,
|
||||||
|
),
|
||||||
|
).toHaveLength(4)
|
||||||
|
expect(viewSource.match(/@dragcancel="stopHomeDrag"/g)).toHaveLength(4)
|
||||||
|
expect(viewSource).toContain(
|
||||||
|
'if (expectedGhost && homeDragGhost !== expectedGhost) return',
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/if \(!dragged\) \{\s*clearHomeDragGhost\(\)\s*return/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/animateHomeItemDrop\(\s*draggedItem,\s*dropArea,\s*dropIndex,\s*dropOrigin,?\s*\)\.finally\(/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/function stopHomeDrag[\s\S]*?clearHomeDragGhost\(\)/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/if \(folderId\) \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('targets the active visual page and lets grid or dock drags turn pages', () => {
|
it('targets the active visual page and lets grid or dock drags turn pages', () => {
|
||||||
expect(viewSource).toContain(':data-home-page="page.page"')
|
expect(viewSource).toContain(':data-home-page="page.page"')
|
||||||
expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"')
|
expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"')
|
||||||
expect(viewSource).toContain('nearestGridDropTarget')
|
expect(viewSource).toContain('nearestGridDropTarget')
|
||||||
expect(viewSource).toContain('moveHomeAppToGridPage')
|
expect(viewSource).toContain('moveHomeAppToGridPage')
|
||||||
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
|
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
|
||||||
expect(viewSource).toContain('event.clientX < pageBounds.left')
|
expect(viewSource).toContain('event.clientX < springboardBounds.left')
|
||||||
expect(viewSource).toContain('event.clientY > pageBounds.bottom')
|
expect(viewSource).toContain('event.clientY > springboardBounds.bottom')
|
||||||
|
expect(viewSource).toContain('function homeDragViewportRect(')
|
||||||
|
expect(viewSource).toContain('if (geometry) return geometry.rect(element)')
|
||||||
|
expect(viewSource).toContain('homeDragViewportRect(pageElement, geometry)')
|
||||||
|
expect(viewSource).toContain('homeDragViewportRect(springboard, geometry)')
|
||||||
|
expect(viewSource).toContain('homeDragViewportRect(slot, geometry)')
|
||||||
|
expect(viewSource).toContain('homeDragViewportRect(dock, geometry)')
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/homeDragViewportRect\(\s*appElement,\s*readPhoneViewportGeometry\(appElement\),?\s*\)/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toContain(
|
||||||
|
'springboardBounds.left + (bounds.left - pageBounds.left)',
|
||||||
|
)
|
||||||
expect(viewSource).toContain('nearestSpringboardRectIndex')
|
expect(viewSource).toContain('nearestSpringboardRectIndex')
|
||||||
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
|
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
|
||||||
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
|
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ import {
|
|||||||
type HomeItem,
|
type HomeItem,
|
||||||
} from '@/utils/homeLayout'
|
} from '@/utils/homeLayout'
|
||||||
import type { ReorderDirection } from '@/utils/keyboard'
|
import type { ReorderDirection } from '@/utils/keyboard'
|
||||||
|
import {
|
||||||
|
readPhoneViewportGeometry,
|
||||||
|
type PhoneViewportGeometry,
|
||||||
|
type PhoneViewportRect,
|
||||||
|
} from '@/utils/phoneViewportGeometry'
|
||||||
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
|
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
|
||||||
import {
|
import {
|
||||||
maximumRenderedWidgetPage,
|
maximumRenderedWidgetPage,
|
||||||
@@ -106,6 +111,8 @@ const draggingHomeApp = ref<{
|
|||||||
area: HomeArea
|
area: HomeArea
|
||||||
index: number
|
index: number
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
const homeDragLayer = ref<HTMLElement | null>(null)
|
||||||
|
const homeDragVisualActive = ref(false)
|
||||||
const temporaryHomePage = ref<number | null>(null)
|
const temporaryHomePage = ref<number | null>(null)
|
||||||
const openedFolderId = ref<string | null>(null)
|
const openedFolderId = ref<string | null>(null)
|
||||||
const folderDraggingOutside = ref(false)
|
const folderDraggingOutside = ref(false)
|
||||||
@@ -125,6 +132,11 @@ let edgePageDirection = 0
|
|||||||
let edgePageLocked = false
|
let edgePageLocked = false
|
||||||
let folderHoverTimer: number | undefined
|
let folderHoverTimer: number | undefined
|
||||||
let lastHomePointer: { clientX: number; clientY: number } | null = null
|
let lastHomePointer: { clientX: number; clientY: number } | null = null
|
||||||
|
let homeDragGhost: HTMLElement | null = null
|
||||||
|
let homeDragGrip: { x: number; y: number } | null = null
|
||||||
|
let homeDragGeometry: PhoneViewportGeometry | null = null
|
||||||
|
let homeDragClipBounds: PhoneViewportRect | null = null
|
||||||
|
let homeDragPreviewBounds: PhoneViewportRect | null = null
|
||||||
|
|
||||||
const installedApps = computed(() =>
|
const installedApps = computed(() =>
|
||||||
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
|
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
|
||||||
@@ -453,7 +465,7 @@ function startWidgetDrag(id: string, event: PointerEvent): void {
|
|||||||
).find((candidate) => candidate.dataset.widgetId === id)
|
).find((candidate) => candidate.dataset.widgetId === id)
|
||||||
if (!widget) return
|
if (!widget) return
|
||||||
pageTransitioning.value = false
|
pageTransitioning.value = false
|
||||||
const bounds = widget.getBoundingClientRect()
|
const bounds = homeDragViewportRect(widget)
|
||||||
draggingWidgetId.value = id
|
draggingWidgetId.value = id
|
||||||
widgetDragGrip.value = {
|
widgetDragGrip.value = {
|
||||||
x: event.clientX - bounds.left,
|
x: event.clientX - bounds.left,
|
||||||
@@ -474,7 +486,7 @@ function updateWidgetDragPreview(event: {
|
|||||||
)
|
)
|
||||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||||
if (!grid || !pageElement) return
|
if (!grid || !pageElement) return
|
||||||
const renderedGridBounds = grid.getBoundingClientRect()
|
const renderedGridBounds = homeDragViewportRect(grid)
|
||||||
const gridStyle = getComputedStyle(grid)
|
const gridStyle = getComputedStyle(grid)
|
||||||
const scaleX =
|
const scaleX =
|
||||||
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
|
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
|
||||||
@@ -529,7 +541,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void {
|
|||||||
function resolveHomeEdgeTurn(event: { clientX: number }) {
|
function resolveHomeEdgeTurn(event: { clientX: number }) {
|
||||||
const springboard = document.querySelector<HTMLElement>('.springboard')
|
const springboard = document.querySelector<HTMLElement>('.springboard')
|
||||||
if (!springboard) return null
|
if (!springboard) return null
|
||||||
const bounds = springboard.getBoundingClientRect()
|
const bounds = homeDragViewportRect(springboard)
|
||||||
const renderedLastPage = appPages.value.length
|
const renderedLastPage = appPages.value.length
|
||||||
return resolveSpringboardHomeEdgeTurn(
|
return resolveSpringboardHomeEdgeTurn(
|
||||||
event.clientX,
|
event.clientX,
|
||||||
@@ -548,7 +560,7 @@ function queueEdgePageTurn(
|
|||||||
): void {
|
): void {
|
||||||
const springboard = document.querySelector<HTMLElement>('.springboard')
|
const springboard = document.querySelector<HTMLElement>('.springboard')
|
||||||
if (!springboard) return
|
if (!springboard) return
|
||||||
const bounds = springboard.getBoundingClientRect()
|
const bounds = homeDragViewportRect(springboard)
|
||||||
const turn =
|
const turn =
|
||||||
dragType === 'app'
|
dragType === 'app'
|
||||||
? resolveHomeEdgeTurn(event)
|
? resolveHomeEdgeTurn(event)
|
||||||
@@ -733,10 +745,166 @@ async function saveWidgetConfig(
|
|||||||
if (configured) changePage(configured.page)
|
if (configured) changePage(configured.page)
|
||||||
}
|
}
|
||||||
|
|
||||||
function startHomeDrag(area: HomeArea, index: number): void {
|
function homeDragViewportRect(
|
||||||
|
element: Element,
|
||||||
|
geometry = homeDragGeometry ?? readPhoneViewportGeometry(element),
|
||||||
|
): PhoneViewportRect {
|
||||||
|
if (geometry) return geometry.rect(element)
|
||||||
|
const bounds = element.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
bottom: bounds.bottom,
|
||||||
|
height: bounds.height,
|
||||||
|
left: bounds.left,
|
||||||
|
right: bounds.right,
|
||||||
|
top: bounds.top,
|
||||||
|
width: bounds.width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewportRectAt(
|
||||||
|
left: number,
|
||||||
|
top: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): PhoneViewportRect {
|
||||||
|
return {
|
||||||
|
bottom: top + height,
|
||||||
|
height,
|
||||||
|
left,
|
||||||
|
right: left + width,
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHomeDragGhost(expectedGhost?: HTMLElement | null): void {
|
||||||
|
if (expectedGhost && homeDragGhost !== expectedGhost) return
|
||||||
|
homeDragGhost?.remove()
|
||||||
|
homeDragGhost = null
|
||||||
|
homeDragGrip = null
|
||||||
|
homeDragGeometry = null
|
||||||
|
homeDragClipBounds = null
|
||||||
|
homeDragPreviewBounds = null
|
||||||
|
homeDragVisualActive.value = false
|
||||||
|
const layer = homeDragLayer.value
|
||||||
|
layer?.style.removeProperty('left')
|
||||||
|
layer?.style.removeProperty('top')
|
||||||
|
layer?.style.removeProperty('width')
|
||||||
|
layer?.style.removeProperty('height')
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHomeDragGhost(event: {
|
||||||
|
clientX: number
|
||||||
|
clientY: number
|
||||||
|
}): void {
|
||||||
|
const ghost = homeDragGhost
|
||||||
|
const grip = homeDragGrip
|
||||||
|
const clipBounds = homeDragClipBounds
|
||||||
|
const previewBounds = homeDragPreviewBounds
|
||||||
|
if (!ghost || !grip || !clipBounds || !previewBounds) return
|
||||||
|
|
||||||
|
const left = event.clientX - grip.x
|
||||||
|
const top = event.clientY - grip.y
|
||||||
|
ghost.style.transform = `translate3d(${left - clipBounds.left}px, ${top - clipBounds.top}px, 0)`
|
||||||
|
homeDragPreviewBounds = viewportRectAt(
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
previewBounds.width,
|
||||||
|
previewBounds.height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHomeDragGhost(event: PointerEvent): void {
|
||||||
|
clearHomeDragGhost()
|
||||||
|
const eventTarget =
|
||||||
|
event.currentTarget instanceof Element
|
||||||
|
? event.currentTarget
|
||||||
|
: event.target instanceof Element
|
||||||
|
? event.target
|
||||||
|
: null
|
||||||
|
const source = eventTarget?.closest<HTMLElement>('.app-icon-item')
|
||||||
|
const layer = homeDragLayer.value
|
||||||
|
const springboard = source?.closest<HTMLElement>('.springboard')
|
||||||
|
const portal = layer?.closest<HTMLElement>('.phone-home-drag-portal')
|
||||||
|
const geometry = readPhoneViewportGeometry(source ?? null)
|
||||||
|
if (!source || !layer || !springboard || !portal || !geometry) return
|
||||||
|
|
||||||
|
const portalBounds = portal.getBoundingClientRect()
|
||||||
|
const clipBounds = geometry.rect(springboard)
|
||||||
|
const sourceBounds = geometry.rect(source)
|
||||||
|
if (
|
||||||
|
clipBounds.width <= 0 ||
|
||||||
|
clipBounds.height <= 0 ||
|
||||||
|
sourceBounds.width <= 0 ||
|
||||||
|
sourceBounds.height <= 0
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.style.left = `${clipBounds.left - portalBounds.left}px`
|
||||||
|
layer.style.top = `${clipBounds.top - portalBounds.top}px`
|
||||||
|
layer.style.width = `${clipBounds.width}px`
|
||||||
|
layer.style.height = `${clipBounds.height}px`
|
||||||
|
|
||||||
|
const ghost = source.cloneNode(true) as HTMLElement
|
||||||
|
ghost.classList.remove(
|
||||||
|
'app-icon-item--dragging',
|
||||||
|
'app-icon-item--editing',
|
||||||
|
'app-icon-item--drag-source',
|
||||||
|
'app-icon-item--drop-settling',
|
||||||
|
'home-folder-item--dragging',
|
||||||
|
'home-item--folder-hover',
|
||||||
|
)
|
||||||
|
ghost.classList.add('home-drag-ghost')
|
||||||
|
ghost.removeAttribute('style')
|
||||||
|
ghost
|
||||||
|
.querySelector<HTMLElement>('.app-icon-button')
|
||||||
|
?.style.removeProperty('transform')
|
||||||
|
ghost.setAttribute('aria-hidden', 'true')
|
||||||
|
ghost.querySelector('.app-icon-remove')?.remove()
|
||||||
|
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
|
||||||
|
element.removeAttribute('id')
|
||||||
|
for (const attribute of element.getAttributeNames()) {
|
||||||
|
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const control of ghost.querySelectorAll<HTMLElement>(
|
||||||
|
'a, button, input, select, textarea, [tabindex]',
|
||||||
|
)) {
|
||||||
|
control.tabIndex = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = document.createElement('div')
|
||||||
|
position.className = 'home-drag-position'
|
||||||
|
position.style.width = `${sourceBounds.width}px`
|
||||||
|
position.style.height = `${sourceBounds.height}px`
|
||||||
|
ghost.style.width = `${source.offsetWidth}px`
|
||||||
|
ghost.style.height = `${source.offsetHeight}px`
|
||||||
|
ghost.style.transform = `scale(${geometry.scaleX}, ${geometry.scaleY})`
|
||||||
|
position.appendChild(ghost)
|
||||||
|
|
||||||
|
homeDragGhost = position
|
||||||
|
homeDragGrip = {
|
||||||
|
x: event.clientX - sourceBounds.left,
|
||||||
|
y: event.clientY - sourceBounds.top,
|
||||||
|
}
|
||||||
|
homeDragGeometry = geometry
|
||||||
|
homeDragClipBounds = clipBounds
|
||||||
|
homeDragPreviewBounds = sourceBounds
|
||||||
|
layer.appendChild(position)
|
||||||
|
homeDragVisualActive.value = true
|
||||||
|
updateHomeDragGhost(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHomeDrag(
|
||||||
|
area: HomeArea,
|
||||||
|
index: number,
|
||||||
|
event: PointerEvent,
|
||||||
|
): void {
|
||||||
clearTemporaryHomePage()
|
clearTemporaryHomePage()
|
||||||
lastHomePointer = null
|
lastHomePointer = null
|
||||||
draggingHomeApp.value = { area, index }
|
draggingHomeApp.value = { area, index }
|
||||||
|
createHomeDragGhost(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFolderHover(): void {
|
function clearFolderHover(): void {
|
||||||
@@ -754,10 +922,40 @@ function queueFolderHover(event: PointerEvent): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetElement = document
|
const targetElement = Array.from(
|
||||||
.elementsFromPoint(event.clientX, event.clientY)
|
document.querySelectorAll<HTMLElement>('[data-home-area][data-home-index]'),
|
||||||
.map((element) => element.closest<HTMLElement>('[data-home-index]'))
|
)
|
||||||
.find((element) => element && !element.closest('.app-icon-item--dragging'))
|
.filter((element) => {
|
||||||
|
const area = element.dataset.homeArea as HomeArea | undefined
|
||||||
|
const index = Number(element.dataset.homeIndex)
|
||||||
|
if (
|
||||||
|
(area !== 'grid' && area !== 'dock') ||
|
||||||
|
!Number.isInteger(index) ||
|
||||||
|
(area === dragged.area && index === dragged.index)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
area === 'grid' &&
|
||||||
|
Number(
|
||||||
|
element.closest<HTMLElement>('[data-home-page]')?.dataset.homePage,
|
||||||
|
) !== phone.currentPage
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const bounds = homeDragViewportRect(element)
|
||||||
|
return (
|
||||||
|
event.clientX >= bounds.left &&
|
||||||
|
event.clientX <= bounds.right &&
|
||||||
|
event.clientY >= bounds.top &&
|
||||||
|
event.clientY <= bounds.bottom
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftIsDock = left.dataset.homeArea === 'dock'
|
||||||
|
const rightIsDock = right.dataset.homeArea === 'dock'
|
||||||
|
return Number(rightIsDock) - Number(leftIsDock)
|
||||||
|
})[0]
|
||||||
const area = targetElement?.dataset.homeArea as HomeArea | undefined
|
const area = targetElement?.dataset.homeArea as HomeArea | undefined
|
||||||
const targetIndex = Number(targetElement?.dataset.homeIndex)
|
const targetIndex = Number(targetElement?.dataset.homeIndex)
|
||||||
if (
|
if (
|
||||||
@@ -822,6 +1020,7 @@ function queueFolderHover(event: PointerEvent): void {
|
|||||||
if (folderId) {
|
if (folderId) {
|
||||||
draggingHomeApp.value = null
|
draggingHomeApp.value = null
|
||||||
lastHomePointer = null
|
lastHomePointer = null
|
||||||
|
clearHomeDragGhost()
|
||||||
clearTemporaryHomePage()
|
clearTemporaryHomePage()
|
||||||
openedFolderId.value = folderId
|
openedFolderId.value = folderId
|
||||||
}
|
}
|
||||||
@@ -832,6 +1031,7 @@ function queueFolderHover(event: PointerEvent): void {
|
|||||||
function moveHomeDrag(event: PointerEvent): void {
|
function moveHomeDrag(event: PointerEvent): void {
|
||||||
if (!draggingHomeApp.value) return
|
if (!draggingHomeApp.value) return
|
||||||
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
||||||
|
updateHomeDragGhost(event)
|
||||||
if (resolveHomeEdgeTurn(event)) {
|
if (resolveHomeEdgeTurn(event)) {
|
||||||
clearFolderHover()
|
clearFolderHover()
|
||||||
queueEdgePageTurn(event, 'app')
|
queueEdgePageTurn(event, 'app')
|
||||||
@@ -845,7 +1045,7 @@ async function animateHomeItemDrop(
|
|||||||
item: HomeItem,
|
item: HomeItem,
|
||||||
area: HomeArea,
|
area: HomeArea,
|
||||||
index: number,
|
index: number,
|
||||||
from: DOMRect,
|
from: PhoneViewportRect,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||||
@@ -861,7 +1061,10 @@ async function animateHomeItemDrop(
|
|||||||
)
|
)
|
||||||
if (!appElement) return
|
if (!appElement) return
|
||||||
|
|
||||||
const target = appElement.getBoundingClientRect()
|
const target = homeDragViewportRect(
|
||||||
|
appElement,
|
||||||
|
readPhoneViewportGeometry(appElement),
|
||||||
|
)
|
||||||
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
|
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
|
||||||
from.left - target.left,
|
from.left - target.left,
|
||||||
from.top - target.top,
|
from.top - target.top,
|
||||||
@@ -924,13 +1127,16 @@ function nearestGridDropTarget(event: {
|
|||||||
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
|
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
|
||||||
)
|
)
|
||||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||||
if (!grid || !pageElement) return null
|
const springboard = grid?.closest<HTMLElement>('.springboard')
|
||||||
const pageBounds = pageElement.getBoundingClientRect()
|
if (!grid || !pageElement || !springboard) return null
|
||||||
|
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(springboard)
|
||||||
|
const pageBounds = homeDragViewportRect(pageElement, geometry)
|
||||||
|
const springboardBounds = homeDragViewportRect(springboard, geometry)
|
||||||
if (
|
if (
|
||||||
event.clientX < pageBounds.left ||
|
event.clientX < springboardBounds.left ||
|
||||||
event.clientX > pageBounds.right ||
|
event.clientX > springboardBounds.right ||
|
||||||
event.clientY < pageBounds.top ||
|
event.clientY < springboardBounds.top ||
|
||||||
event.clientY > pageBounds.bottom
|
event.clientY > springboardBounds.bottom
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -940,7 +1146,15 @@ function nearestGridDropTarget(event: {
|
|||||||
const closestIndex = nearestSpringboardRectIndex(
|
const closestIndex = nearestSpringboardRectIndex(
|
||||||
event.clientX,
|
event.clientX,
|
||||||
event.clientY,
|
event.clientY,
|
||||||
slots.map((slot) => slot.getBoundingClientRect()),
|
slots.map((slot) => {
|
||||||
|
const bounds = homeDragViewportRect(slot, geometry)
|
||||||
|
return {
|
||||||
|
height: bounds.height,
|
||||||
|
left: springboardBounds.left + (bounds.left - pageBounds.left),
|
||||||
|
top: springboardBounds.top + (bounds.top - pageBounds.top),
|
||||||
|
width: bounds.width,
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
const closest = closestIndex === null ? null : slots[closestIndex]
|
const closest = closestIndex === null ? null : slots[closestIndex]
|
||||||
const targetOffset = Number(closest?.dataset.homeTargetOffset)
|
const targetOffset = Number(closest?.dataset.homeTargetOffset)
|
||||||
@@ -953,7 +1167,8 @@ function nearestDockDropTarget(event: {
|
|||||||
}): HTMLElement | null {
|
}): HTMLElement | null {
|
||||||
const dock = document.querySelector<HTMLElement>('.app-dock')
|
const dock = document.querySelector<HTMLElement>('.app-dock')
|
||||||
if (!dock) return null
|
if (!dock) return null
|
||||||
const dockBounds = dock.getBoundingClientRect()
|
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(dock)
|
||||||
|
const dockBounds = homeDragViewportRect(dock, geometry)
|
||||||
if (
|
if (
|
||||||
event.clientX < dockBounds.left ||
|
event.clientX < dockBounds.left ||
|
||||||
event.clientX > dockBounds.right ||
|
event.clientX > dockBounds.right ||
|
||||||
@@ -970,7 +1185,7 @@ function nearestDockDropTarget(event: {
|
|||||||
const closestIndex = nearestSpringboardRectIndex(
|
const closestIndex = nearestSpringboardRectIndex(
|
||||||
event.clientX,
|
event.clientX,
|
||||||
event.clientY,
|
event.clientY,
|
||||||
slots.map((slot) => slot.getBoundingClientRect()),
|
slots.map((slot) => homeDragViewportRect(slot, geometry)),
|
||||||
)
|
)
|
||||||
return closestIndex === null ? null : slots[closestIndex]
|
return closestIndex === null ? null : slots[closestIndex]
|
||||||
}
|
}
|
||||||
@@ -979,12 +1194,14 @@ function finishHomeDrag(event: PointerEvent): void {
|
|||||||
clearEdgePageTurn()
|
clearEdgePageTurn()
|
||||||
clearFolderHover()
|
clearFolderHover()
|
||||||
const dragged = draggingHomeApp.value
|
const dragged = draggingHomeApp.value
|
||||||
if (!dragged) return
|
if (!dragged) {
|
||||||
|
clearHomeDragGhost()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateHomeDragGhost(event)
|
||||||
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
|
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
|
||||||
const draggedElement = document.querySelector<HTMLElement>(
|
const dropGhost = homeDragGhost
|
||||||
`[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`,
|
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
|
||||||
)
|
|
||||||
const dropOrigin = draggedElement?.getBoundingClientRect()
|
|
||||||
const dockTarget = nearestDockDropTarget(event)
|
const dockTarget = nearestDockDropTarget(event)
|
||||||
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
|
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
|
||||||
let dropArea = dragged.area
|
let dropArea = dragged.area
|
||||||
@@ -1022,7 +1239,14 @@ function finishHomeDrag(event: PointerEvent): void {
|
|||||||
lastHomePointer = null
|
lastHomePointer = null
|
||||||
clearTemporaryHomePage(moved && gridTarget !== null)
|
clearTemporaryHomePage(moved && gridTarget !== null)
|
||||||
if (moved && draggedItem && dropOrigin) {
|
if (moved && draggedItem && dropOrigin) {
|
||||||
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
|
void animateHomeItemDrop(
|
||||||
|
draggedItem,
|
||||||
|
dropArea,
|
||||||
|
dropIndex,
|
||||||
|
dropOrigin,
|
||||||
|
).finally(() => clearHomeDragGhost(dropGhost))
|
||||||
|
} else {
|
||||||
|
clearHomeDragGhost()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1031,6 +1255,7 @@ function stopHomeDrag(): void {
|
|||||||
clearFolderHover()
|
clearFolderHover()
|
||||||
draggingHomeApp.value = null
|
draggingHomeApp.value = null
|
||||||
lastHomePointer = null
|
lastHomePointer = null
|
||||||
|
clearHomeDragGhost()
|
||||||
clearTemporaryHomePage()
|
clearTemporaryHomePage()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,7 +1268,9 @@ function reorderHomeApp(
|
|||||||
const appElement = document.querySelector<HTMLElement>(
|
const appElement = document.querySelector<HTMLElement>(
|
||||||
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
|
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
|
||||||
)
|
)
|
||||||
const moveOrigin = appElement?.getBoundingClientRect()
|
const moveOrigin = appElement
|
||||||
|
? homeDragViewportRect(appElement, readPhoneViewportGeometry(appElement))
|
||||||
|
: null
|
||||||
const targetIndex = homeKeyboardTarget(
|
const targetIndex = homeKeyboardTarget(
|
||||||
appStore.homeLayout,
|
appStore.homeLayout,
|
||||||
area,
|
area,
|
||||||
@@ -1131,7 +1358,10 @@ function extractOpenedFolderApp(
|
|||||||
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
|
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
|
||||||
)
|
)
|
||||||
?.closest<HTMLElement>('.app-icon-item')
|
?.closest<HTMLElement>('.app-icon-item')
|
||||||
const sourceBounds = sourceElement?.getBoundingClientRect()
|
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
|
||||||
|
const sourceBounds = sourceElement
|
||||||
|
? homeDragViewportRect(sourceElement, geometry)
|
||||||
|
: null
|
||||||
const candidates = Array.from(
|
const candidates = Array.from(
|
||||||
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
|
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
|
||||||
).filter((element) => {
|
).filter((element) => {
|
||||||
@@ -1145,8 +1375,8 @@ function extractOpenedFolderApp(
|
|||||||
})
|
})
|
||||||
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
|
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
|
||||||
if (!closest) return candidate
|
if (!closest) return candidate
|
||||||
const bounds = candidate.getBoundingClientRect()
|
const bounds = homeDragViewportRect(candidate, geometry)
|
||||||
const closestBounds = closest.getBoundingClientRect()
|
const closestBounds = homeDragViewportRect(closest, geometry)
|
||||||
const distance = Math.hypot(
|
const distance = Math.hypot(
|
||||||
event.clientX - (bounds.left + bounds.width / 2),
|
event.clientX - (bounds.left + bounds.width / 2),
|
||||||
event.clientY - (bounds.top + bounds.height / 2),
|
event.clientY - (bounds.top + bounds.height / 2),
|
||||||
@@ -1212,6 +1442,7 @@ onBeforeUnmount(() => {
|
|||||||
pendingWidgetPointer = null
|
pendingWidgetPointer = null
|
||||||
lastWidgetPointer = null
|
lastWidgetPointer = null
|
||||||
lastHomePointer = null
|
lastHomePointer = null
|
||||||
|
clearHomeDragGhost()
|
||||||
temporaryHomePage.value = null
|
temporaryHomePage.value = null
|
||||||
draggingHomeApp.value = null
|
draggingHomeApp.value = null
|
||||||
draggingWidgetId.value = null
|
draggingWidgetId.value = null
|
||||||
@@ -1227,7 +1458,6 @@ onBeforeUnmount(() => {
|
|||||||
'springboard--dragging': dragging,
|
'springboard--dragging': dragging,
|
||||||
'springboard--editing': editMode,
|
'springboard--editing': editMode,
|
||||||
'springboard--folder-open': folderOverlayVisible,
|
'springboard--folder-open': folderOverlayVisible,
|
||||||
'springboard--home-dragging': draggingHomeApp !== null,
|
|
||||||
'springboard--widget-dragging': draggingWidgetId !== null,
|
'springboard--widget-dragging': draggingWidgetId !== null,
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
@@ -1303,6 +1533,7 @@ onBeforeUnmount(() => {
|
|||||||
:data-home-index="cell.sourceIndex"
|
:data-home-index="cell.sourceIndex"
|
||||||
:data-home-target-offset="cell.targetOffset"
|
:data-home-target-offset="cell.targetOffset"
|
||||||
:edit-mode="editMode"
|
:edit-mode="editMode"
|
||||||
|
:external-drag-visual="homeDragVisualActive"
|
||||||
:class="{
|
:class="{
|
||||||
'home-item--folder-hover':
|
'home-item--folder-hover':
|
||||||
folderHoverTarget === `grid:${cell.sourceIndex}`,
|
folderHoverTarget === `grid:${cell.sourceIndex}`,
|
||||||
@@ -1310,7 +1541,7 @@ onBeforeUnmount(() => {
|
|||||||
@dragcancel="stopHomeDrag"
|
@dragcancel="stopHomeDrag"
|
||||||
@dragend="finishHomeDrag"
|
@dragend="finishHomeDrag"
|
||||||
@dragmove="moveHomeDrag"
|
@dragmove="moveHomeDrag"
|
||||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
||||||
@edit="enterEditMode"
|
@edit="enterEditMode"
|
||||||
@remove="removeHomeApp(cell.app.id)"
|
@remove="removeHomeApp(cell.app.id)"
|
||||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||||
@@ -1325,6 +1556,7 @@ onBeforeUnmount(() => {
|
|||||||
:data-home-index="cell.sourceIndex"
|
:data-home-index="cell.sourceIndex"
|
||||||
:data-home-target-offset="cell.targetOffset"
|
:data-home-target-offset="cell.targetOffset"
|
||||||
:edit-mode="editMode"
|
:edit-mode="editMode"
|
||||||
|
:external-drag-visual="homeDragVisualActive"
|
||||||
:folder="cell.folder"
|
:folder="cell.folder"
|
||||||
:class="{
|
:class="{
|
||||||
'home-item--folder-hover':
|
'home-item--folder-hover':
|
||||||
@@ -1333,7 +1565,7 @@ onBeforeUnmount(() => {
|
|||||||
@dragcancel="stopHomeDrag"
|
@dragcancel="stopHomeDrag"
|
||||||
@dragend="finishHomeDrag"
|
@dragend="finishHomeDrag"
|
||||||
@dragmove="moveHomeDrag"
|
@dragmove="moveHomeDrag"
|
||||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
||||||
@edit="enterEditMode"
|
@edit="enterEditMode"
|
||||||
@open="openFolder(cell.folder.id)"
|
@open="openFolder(cell.folder.id)"
|
||||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||||
@@ -1484,6 +1716,10 @@ onBeforeUnmount(() => {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Teleport defer to="#phone-home-drag-portal">
|
||||||
|
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<Transition name="edit-done">
|
<Transition name="edit-done">
|
||||||
<k-glass
|
<k-glass
|
||||||
v-if="editMode && isEditablePage && !folderOverlayVisible"
|
v-if="editMode && isEditablePage && !folderOverlayVisible"
|
||||||
@@ -1530,6 +1766,7 @@ onBeforeUnmount(() => {
|
|||||||
:data-home-item-key="`app-${slot.app.id}`"
|
:data-home-item-key="`app-${slot.app.id}`"
|
||||||
:data-home-index="slot.index"
|
:data-home-index="slot.index"
|
||||||
:edit-mode="editMode"
|
:edit-mode="editMode"
|
||||||
|
:external-drag-visual="homeDragVisualActive"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:class="{
|
:class="{
|
||||||
'home-item--folder-hover':
|
'home-item--folder-hover':
|
||||||
@@ -1538,7 +1775,7 @@ onBeforeUnmount(() => {
|
|||||||
@dragcancel="stopHomeDrag"
|
@dragcancel="stopHomeDrag"
|
||||||
@dragend="finishHomeDrag"
|
@dragend="finishHomeDrag"
|
||||||
@dragmove="moveHomeDrag"
|
@dragmove="moveHomeDrag"
|
||||||
@dragstart="startHomeDrag('dock', slot.index)"
|
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
||||||
@edit="enterEditMode"
|
@edit="enterEditMode"
|
||||||
@remove="removeHomeApp(slot.app.id)"
|
@remove="removeHomeApp(slot.app.id)"
|
||||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||||
@@ -1552,6 +1789,7 @@ onBeforeUnmount(() => {
|
|||||||
:data-home-item-key="slot.folder.id"
|
:data-home-item-key="slot.folder.id"
|
||||||
:data-home-index="slot.index"
|
:data-home-index="slot.index"
|
||||||
:edit-mode="editMode"
|
:edit-mode="editMode"
|
||||||
|
:external-drag-visual="homeDragVisualActive"
|
||||||
:folder="slot.folder"
|
:folder="slot.folder"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:class="{
|
:class="{
|
||||||
@@ -1561,7 +1799,7 @@ onBeforeUnmount(() => {
|
|||||||
@dragcancel="stopHomeDrag"
|
@dragcancel="stopHomeDrag"
|
||||||
@dragend="finishHomeDrag"
|
@dragend="finishHomeDrag"
|
||||||
@dragmove="moveHomeDrag"
|
@dragmove="moveHomeDrag"
|
||||||
@dragstart="startHomeDrag('dock', slot.index)"
|
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
||||||
@edit="enterEditMode"
|
@edit="enterEditMode"
|
||||||
@open="openFolder(slot.folder.id)"
|
@open="openFolder(slot.folder.id)"
|
||||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||||
|
|||||||
Reference in New Issue
Block a user