From 33e6b1aa46f3a3a03edf4e7dc27f24dc50506a56 Mon Sep 17 00:00:00 2001 From: DerEchteAlec Date: Thu, 20 Aug 2026 18:55:46 +0200 Subject: [PATCH] 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. --- frontend/src/App.vue | 5 + .../AppDevelopmentPreview.contract.test.ts | 4 + frontend/src/assets/main.css | 41 ++- frontend/src/components/AppIcon.vue | 7 +- frontend/src/components/HomeFolderIcon.vue | 7 +- .../src/utils/phoneViewportGeometry.test.ts | 174 ++++++++++ frontend/src/utils/phoneViewportGeometry.ts | 73 +++++ frontend/src/utils/springboardDrag.test.ts | 52 +++ frontend/src/utils/springboardDrag.ts | 6 +- .../views/SpringboardView.contract.test.ts | 91 +++++- frontend/src/views/SpringboardView.vue | 308 ++++++++++++++++-- 11 files changed, 712 insertions(+), 56 deletions(-) create mode 100644 frontend/src/utils/phoneViewportGeometry.test.ts create mode 100644 frontend/src/utils/phoneViewportGeometry.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 78ebe16..7855d0d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1778,6 +1778,11 @@ onBeforeUnmount(() => { " class="phone-resolution-wrapper phone-resolution-wrapper--primary" > +
{ it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => { 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-width'") expect(mainCss).toMatch( diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 1eb36f7..b1b735a 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -295,6 +295,18 @@ button { 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 { position: absolute; top: 0; @@ -2622,8 +2634,30 @@ button { .springboard--widget-dragging .springboard-widget-page-scroll { overflow: visible; } -.springboard--home-dragging .springboard-page--apps { - overflow: visible; +.home-drag-layer { + 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 { display: grid; @@ -2658,6 +2692,9 @@ button { transition: transform var(--springboard-page-duration) var(--springboard-page-easing); } +.app-icon-item--drag-source { + opacity: 0; +} .app-icon-remove { position: absolute; z-index: 4; diff --git a/frontend/src/components/AppIcon.vue b/frontend/src/components/AppIcon.vue index 230fb3a..d3b447c 100644 --- a/frontend/src/components/AppIcon.vue +++ b/frontend/src/components/AppIcon.vue @@ -30,11 +30,13 @@ const props = withDefaults( app: PhoneAppDefinition compact?: boolean editMode?: boolean + externalDragVisual?: boolean showLabel?: boolean }>(), { compact: false, editMode: false, + externalDragVisual: false, showLabel: true, }, ) @@ -63,14 +65,14 @@ let dragStartPage = 0 let dragPageWidth = 0 let dragPageMetrics: SpringboardDragMetrics | null = null const dragStyle = computed(() => - isDragging.value + isDragging.value && !props.externalDragVisual ? { transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`, } : undefined, ) const dragPointerStyle = computed(() => - isDragging.value + isDragging.value && !props.externalDragVisual ? { transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`, } @@ -300,6 +302,7 @@ onBeforeUnmount(() => { class="app-icon-item" :class="{ 'app-icon-item--compact': compact, + 'app-icon-item--drag-source': isDragging && externalDragVisual, 'app-icon-item--dragging': isDragging, 'app-icon-item--editing': editMode, }" diff --git a/frontend/src/components/HomeFolderIcon.vue b/frontend/src/components/HomeFolderIcon.vue index bb25b93..552262c 100644 --- a/frontend/src/components/HomeFolderIcon.vue +++ b/frontend/src/components/HomeFolderIcon.vue @@ -22,11 +22,13 @@ const props = withDefaults( apps: PhoneAppDefinition[] defaultName: string editMode?: boolean + externalDragVisual?: boolean folder: HomeFolder showLabel?: boolean }>(), { editMode: false, + externalDragVisual: false, showLabel: true, }, ) @@ -55,14 +57,14 @@ let stopPointerSession: (() => void) | null = null const folderName = computed(() => props.folder.name || props.defaultName) const dragStyle = computed(() => - isDragging.value + isDragging.value && !props.externalDragVisual ? { transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`, } : undefined, ) const dragPointerStyle = computed(() => - isDragging.value + isDragging.value && !props.externalDragVisual ? { transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`, } @@ -232,6 +234,7 @@ onBeforeUnmount(() => {
{ - expect(viewSource).not.toContain('source.cloneNode(true)') - expect(viewSource).not.toContain('') - expect(viewSource).not.toContain(':external-drag-visual') + it('renders the home drag visual through the unzoomed phone portal', () => { + expect(viewSource).toContain('source.cloneNode(true)') expect(viewSource).toContain( - "'springboard--home-dragging': draggingHomeApp !== null", + '', ) - expect(viewSource).toContain('draggedElement?.getBoundingClientRect()') - expect(appIconSource).not.toContain('externalDragVisual') - expect(folderIconSource).not.toContain('externalDragVisual') - expect(mainCss).not.toContain('.home-drag-layer') - expect(mainCss).not.toContain('.home-drag-ghost') - expect(mainCss).not.toContain('.app-icon-item--drag-source') + expect(viewSource).toContain( + '', + ) + expect(viewSource).toContain('readPhoneViewportGeometry') + expect(viewSource).toMatch(/const sourceBounds = \w+\.rect\(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( - /\.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-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', () => { expect(viewSource).toContain(':data-home-page="page.page"') expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"') expect(viewSource).toContain('nearestGridDropTarget') expect(viewSource).toContain('moveHomeAppToGridPage') expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'") - expect(viewSource).toContain('event.clientX < pageBounds.left') - expect(viewSource).toContain('event.clientY > pageBounds.bottom') + expect(viewSource).toContain('event.clientX < springboardBounds.left') + 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("queueEdgePageTurn(lastHomePointer, 'app')") expect(viewSource).toContain("edgePageLocked = dragType === 'widget'") diff --git a/frontend/src/views/SpringboardView.vue b/frontend/src/views/SpringboardView.vue index 683ac1a..9bb3598 100644 --- a/frontend/src/views/SpringboardView.vue +++ b/frontend/src/views/SpringboardView.vue @@ -41,6 +41,11 @@ import { type HomeItem, } from '@/utils/homeLayout' import type { ReorderDirection } from '@/utils/keyboard' +import { + readPhoneViewportGeometry, + type PhoneViewportGeometry, + type PhoneViewportRect, +} from '@/utils/phoneViewportGeometry' import { layoutSpringboardHomePages } from '@/utils/springboardLayout' import { maximumRenderedWidgetPage, @@ -106,6 +111,8 @@ const draggingHomeApp = ref<{ area: HomeArea index: number } | null>(null) +const homeDragLayer = ref(null) +const homeDragVisualActive = ref(false) const temporaryHomePage = ref(null) const openedFolderId = ref(null) const folderDraggingOutside = ref(false) @@ -125,6 +132,11 @@ let edgePageDirection = 0 let edgePageLocked = false let folderHoverTimer: number | undefined 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(() => 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) if (!widget) return pageTransitioning.value = false - const bounds = widget.getBoundingClientRect() + const bounds = homeDragViewportRect(widget) draggingWidgetId.value = id widgetDragGrip.value = { x: event.clientX - bounds.left, @@ -474,7 +486,7 @@ function updateWidgetDragPreview(event: { ) const pageElement = grid?.closest('.springboard-page') if (!grid || !pageElement) return - const renderedGridBounds = grid.getBoundingClientRect() + const renderedGridBounds = homeDragViewportRect(grid) const gridStyle = getComputedStyle(grid) const scaleX = grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1 @@ -529,7 +541,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void { function resolveHomeEdgeTurn(event: { clientX: number }) { const springboard = document.querySelector('.springboard') if (!springboard) return null - const bounds = springboard.getBoundingClientRect() + const bounds = homeDragViewportRect(springboard) const renderedLastPage = appPages.value.length return resolveSpringboardHomeEdgeTurn( event.clientX, @@ -548,7 +560,7 @@ function queueEdgePageTurn( ): void { const springboard = document.querySelector('.springboard') if (!springboard) return - const bounds = springboard.getBoundingClientRect() + const bounds = homeDragViewportRect(springboard) const turn = dragType === 'app' ? resolveHomeEdgeTurn(event) @@ -733,10 +745,166 @@ async function saveWidgetConfig( 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('.app-icon-item') + const layer = homeDragLayer.value + const springboard = source?.closest('.springboard') + const portal = layer?.closest('.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('.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( + '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() lastHomePointer = null draggingHomeApp.value = { area, index } + createHomeDragGhost(event) } function clearFolderHover(): void { @@ -754,10 +922,40 @@ function queueFolderHover(event: PointerEvent): void { return } - const targetElement = document - .elementsFromPoint(event.clientX, event.clientY) - .map((element) => element.closest('[data-home-index]')) - .find((element) => element && !element.closest('.app-icon-item--dragging')) + const targetElement = Array.from( + document.querySelectorAll('[data-home-area][data-home-index]'), + ) + .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('[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 targetIndex = Number(targetElement?.dataset.homeIndex) if ( @@ -822,6 +1020,7 @@ function queueFolderHover(event: PointerEvent): void { if (folderId) { draggingHomeApp.value = null lastHomePointer = null + clearHomeDragGhost() clearTemporaryHomePage() openedFolderId.value = folderId } @@ -832,6 +1031,7 @@ function queueFolderHover(event: PointerEvent): void { function moveHomeDrag(event: PointerEvent): void { if (!draggingHomeApp.value) return lastHomePointer = { clientX: event.clientX, clientY: event.clientY } + updateHomeDragGhost(event) if (resolveHomeEdgeTurn(event)) { clearFolderHover() queueEdgePageTurn(event, 'app') @@ -845,7 +1045,7 @@ async function animateHomeItemDrop( item: HomeItem, area: HomeArea, index: number, - from: DOMRect, + from: PhoneViewportRect, ): Promise { await nextTick() if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return @@ -861,7 +1061,10 @@ async function animateHomeItemDrop( ) if (!appElement) return - const target = appElement.getBoundingClientRect() + const target = homeDragViewportRect( + appElement, + readPhoneViewportGeometry(appElement), + ) const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal( from.left - target.left, from.top - target.top, @@ -924,13 +1127,16 @@ function nearestGridDropTarget(event: { `.springboard-page--apps[data-home-page="${page}"] .app-grid`, ) const pageElement = grid?.closest('.springboard-page') - if (!grid || !pageElement) return null - const pageBounds = pageElement.getBoundingClientRect() + const springboard = grid?.closest('.springboard') + if (!grid || !pageElement || !springboard) return null + const geometry = homeDragGeometry ?? readPhoneViewportGeometry(springboard) + const pageBounds = homeDragViewportRect(pageElement, geometry) + const springboardBounds = homeDragViewportRect(springboard, geometry) if ( - event.clientX < pageBounds.left || - event.clientX > pageBounds.right || - event.clientY < pageBounds.top || - event.clientY > pageBounds.bottom + event.clientX < springboardBounds.left || + event.clientX > springboardBounds.right || + event.clientY < springboardBounds.top || + event.clientY > springboardBounds.bottom ) { return null } @@ -940,7 +1146,15 @@ function nearestGridDropTarget(event: { const closestIndex = nearestSpringboardRectIndex( event.clientX, 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 targetOffset = Number(closest?.dataset.homeTargetOffset) @@ -953,7 +1167,8 @@ function nearestDockDropTarget(event: { }): HTMLElement | null { const dock = document.querySelector('.app-dock') if (!dock) return null - const dockBounds = dock.getBoundingClientRect() + const geometry = homeDragGeometry ?? readPhoneViewportGeometry(dock) + const dockBounds = homeDragViewportRect(dock, geometry) if ( event.clientX < dockBounds.left || event.clientX > dockBounds.right || @@ -970,7 +1185,7 @@ function nearestDockDropTarget(event: { const closestIndex = nearestSpringboardRectIndex( event.clientX, event.clientY, - slots.map((slot) => slot.getBoundingClientRect()), + slots.map((slot) => homeDragViewportRect(slot, geometry)), ) return closestIndex === null ? null : slots[closestIndex] } @@ -979,12 +1194,14 @@ function finishHomeDrag(event: PointerEvent): void { clearEdgePageTurn() clearFolderHover() const dragged = draggingHomeApp.value - if (!dragged) return + if (!dragged) { + clearHomeDragGhost() + return + } + updateHomeDragGhost(event) const draggedItem = appStore.homeLayout[dragged.area][dragged.index] - const draggedElement = document.querySelector( - `[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`, - ) - const dropOrigin = draggedElement?.getBoundingClientRect() + const dropGhost = homeDragGhost + const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null const dockTarget = nearestDockDropTarget(event) const gridTarget = dockTarget ? null : nearestGridDropTarget(event) let dropArea = dragged.area @@ -1022,7 +1239,14 @@ function finishHomeDrag(event: PointerEvent): void { lastHomePointer = null clearTemporaryHomePage(moved && gridTarget !== null) 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() draggingHomeApp.value = null lastHomePointer = null + clearHomeDragGhost() clearTemporaryHomePage() } @@ -1043,7 +1268,9 @@ function reorderHomeApp( const appElement = document.querySelector( `[data-home-area="${area}"][data-home-index="${sourceIndex}"]`, ) - const moveOrigin = appElement?.getBoundingClientRect() + const moveOrigin = appElement + ? homeDragViewportRect(appElement, readPhoneViewportGeometry(appElement)) + : null const targetIndex = homeKeyboardTarget( appStore.homeLayout, area, @@ -1131,7 +1358,10 @@ function extractOpenedFolderApp( `.home-folder-panel [data-folder-app-index="${sourceIndex}"]`, ) ?.closest('.app-icon-item') - const sourceBounds = sourceElement?.getBoundingClientRect() + const geometry = readPhoneViewportGeometry(sourceElement ?? null) + const sourceBounds = sourceElement + ? homeDragViewportRect(sourceElement, geometry) + : null const candidates = Array.from( document.querySelectorAll('[data-home-index][data-home-area]'), ).filter((element) => { @@ -1145,8 +1375,8 @@ function extractOpenedFolderApp( }) const target = candidates.reduce((closest, candidate) => { if (!closest) return candidate - const bounds = candidate.getBoundingClientRect() - const closestBounds = closest.getBoundingClientRect() + const bounds = homeDragViewportRect(candidate, geometry) + const closestBounds = homeDragViewportRect(closest, geometry) const distance = Math.hypot( event.clientX - (bounds.left + bounds.width / 2), event.clientY - (bounds.top + bounds.height / 2), @@ -1212,6 +1442,7 @@ onBeforeUnmount(() => { pendingWidgetPointer = null lastWidgetPointer = null lastHomePointer = null + clearHomeDragGhost() temporaryHomePage.value = null draggingHomeApp.value = null draggingWidgetId.value = null @@ -1227,7 +1458,6 @@ onBeforeUnmount(() => { 'springboard--dragging': dragging, 'springboard--editing': editMode, 'springboard--folder-open': folderOverlayVisible, - 'springboard--home-dragging': draggingHomeApp !== null, 'springboard--widget-dragging': draggingWidgetId !== null, }, ]" @@ -1303,6 +1533,7 @@ onBeforeUnmount(() => { :data-home-index="cell.sourceIndex" :data-home-target-offset="cell.targetOffset" :edit-mode="editMode" + :external-drag-visual="homeDragVisualActive" :class="{ 'home-item--folder-hover': folderHoverTarget === `grid:${cell.sourceIndex}`, @@ -1310,7 +1541,7 @@ onBeforeUnmount(() => { @dragcancel="stopHomeDrag" @dragend="finishHomeDrag" @dragmove="moveHomeDrag" - @dragstart="startHomeDrag('grid', cell.sourceIndex)" + @dragstart="startHomeDrag('grid', cell.sourceIndex, $event)" @edit="enterEditMode" @remove="removeHomeApp(cell.app.id)" @reorder="reorderHomeApp('grid', cell.sourceIndex, $event)" @@ -1325,6 +1556,7 @@ onBeforeUnmount(() => { :data-home-index="cell.sourceIndex" :data-home-target-offset="cell.targetOffset" :edit-mode="editMode" + :external-drag-visual="homeDragVisualActive" :folder="cell.folder" :class="{ 'home-item--folder-hover': @@ -1333,7 +1565,7 @@ onBeforeUnmount(() => { @dragcancel="stopHomeDrag" @dragend="finishHomeDrag" @dragmove="moveHomeDrag" - @dragstart="startHomeDrag('grid', cell.sourceIndex)" + @dragstart="startHomeDrag('grid', cell.sourceIndex, $event)" @edit="enterEditMode" @open="openFolder(cell.folder.id)" @reorder="reorderHomeApp('grid', cell.sourceIndex, $event)" @@ -1484,6 +1716,10 @@ onBeforeUnmount(() => {
+ + + + { :data-home-item-key="`app-${slot.app.id}`" :data-home-index="slot.index" :edit-mode="editMode" + :external-drag-visual="homeDragVisualActive" :show-label="false" :class="{ 'home-item--folder-hover': @@ -1538,7 +1775,7 @@ onBeforeUnmount(() => { @dragcancel="stopHomeDrag" @dragend="finishHomeDrag" @dragmove="moveHomeDrag" - @dragstart="startHomeDrag('dock', slot.index)" + @dragstart="startHomeDrag('dock', slot.index, $event)" @edit="enterEditMode" @remove="removeHomeApp(slot.app.id)" @reorder="reorderHomeApp('dock', slot.index, $event)" @@ -1552,6 +1789,7 @@ onBeforeUnmount(() => { :data-home-item-key="slot.folder.id" :data-home-index="slot.index" :edit-mode="editMode" + :external-drag-visual="homeDragVisualActive" :folder="slot.folder" :show-label="false" :class="{ @@ -1561,7 +1799,7 @@ onBeforeUnmount(() => { @dragcancel="stopHomeDrag" @dragend="finishHomeDrag" @dragmove="moveHomeDrag" - @dragstart="startHomeDrag('dock', slot.index)" + @dragstart="startHomeDrag('dock', slot.index, $event)" @edit="enterEditMode" @open="openFolder(slot.folder.id)" @reorder="reorderHomeApp('dock', slot.index, $event)"