From a90ef7819d92c725740d7643fdcd75160cdcb218 Mon Sep 17 00:00:00 2001 From: DerEchteAlec Date: Thu, 13 Aug 2026 22:08:06 +0200 Subject: [PATCH] FIX - preserve Home Screen drag across pages --- frontend/src/assets/main.css | 24 ++- frontend/src/components/AppIcon.vue | 9 +- frontend/src/components/HomeFolderIcon.vue | 9 +- frontend/src/utils/springboardDrag.test.ts | 14 ++ frontend/src/utils/springboardDrag.ts | 36 ++++ .../views/SpringboardView.contract.test.ts | 34 +++- frontend/src/views/SpringboardView.vue | 169 ++++++++++++++++-- 7 files changed, 269 insertions(+), 26 deletions(-) diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index c3a0271..60c0a19 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -2351,12 +2351,31 @@ button { padding-top: 82px; overflow: hidden; } -.springboard--home-dragging .springboard-page--apps, .springboard--widget-dragging .springboard-page--apps, .springboard--widget-dragging .springboard-page--widgets, .springboard--widget-dragging .springboard-widget-page-scroll { overflow: visible; } +.home-drag-layer { + position: absolute; + z-index: 70; + inset: 0; + overflow: hidden; + pointer-events: none; +} +.home-drag-layer * { + pointer-events: none !important; +} +.home-drag-ghost { + position: absolute; + top: 0; + left: 0; + margin: 0; + opacity: 1; + transition: none !important; + animation: none !important; + will-change: transform; +} .app-grid { display: grid; grid-template-columns: repeat(4, 1fr); @@ -2390,6 +2409,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 52050f8..6485deb 100644 --- a/frontend/src/components/AppIcon.vue +++ b/frontend/src/components/AppIcon.vue @@ -26,11 +26,13 @@ const props = withDefaults( app: PhoneAppDefinition compact?: boolean editMode?: boolean + externalDragVisual?: boolean showLabel?: boolean }>(), { compact: false, editMode: false, + externalDragVisual: false, showLabel: true, }, ) @@ -58,14 +60,14 @@ const dragOffset = ref({ x: 0, y: 0 }) let dragStartPage = 0 let dragPageWidth = 0 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)`, } @@ -253,7 +255,7 @@ onMounted(() => { onBeforeUnmount(() => { clearHold() if (calendarTimer !== undefined) window.clearInterval(calendarTimer) - releasePointerCapture() + cancelPointerDrag() }) @@ -262,6 +264,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 adf3cb7..6b4c2bb 100644 --- a/frontend/src/components/HomeFolderIcon.vue +++ b/frontend/src/components/HomeFolderIcon.vue @@ -18,11 +18,13 @@ const props = withDefaults( apps: PhoneAppDefinition[] defaultName: string editMode?: boolean + externalDragVisual?: boolean folder: HomeFolder showLabel?: boolean }>(), { editMode: false, + externalDragVisual: false, showLabel: true, }, ) @@ -49,14 +51,14 @@ let pointerId: number | 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)`, } @@ -171,7 +173,7 @@ function onKeydown(event: KeyboardEvent): void { onBeforeUnmount(() => { clearHold() - releasePointerCapture() + cancelPointerDrag() }) @@ -179,6 +181,7 @@ onBeforeUnmount(() => {
{ - expect(viewSource).toContain( - "'springboard--home-dragging': draggingHomeApp !== null", + it('keeps a phone-wide drag ghost outside the moving page track', () => { + const trackStart = viewSource.indexOf('class="springboard-track"') + const dragLayerStart = viewSource.indexOf('ref="homeDragLayer"') + + expect(trackStart).toBeGreaterThan(-1) + expect(dragLayerStart).toBeGreaterThan(trackStart) + expect(viewSource.slice(trackStart, dragLayerStart)).toContain('
') + expect(viewSource).toContain('source.cloneNode(true)') + expect(viewSource).toContain("ghost.classList.add('home-drag-ghost')") + expect(viewSource).toContain('springboardViewportToLocal') + expect(viewSource).toContain('springboardViewportDeltaToLocal') + expect(viewSource).toContain('event.target instanceof Element') + expect(viewSource).toContain('dropGhost?.getBoundingClientRect()') + expect(viewSource.match(/:external-drag-visual/g)).toHaveLength(4) + expect(viewSource.match(/startHomeDrag\([^\n]+\$event\)/g)).toHaveLength(4) + 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( + /\.home-drag-layer\s*\{[^}]*z-index:\s*70;[^}]*pointer-events:\s*none;/s, ) expect(mainCss).toMatch( - /\.springboard--home-dragging\s+\.springboard-page--apps,?[\s\S]*?\{\s*overflow:\s*visible;/, + /\.home-drag-ghost\s*\{[^}]*position:\s*absolute;[^}]*will-change:\s*transform;/s, ) + expect(mainCss).toMatch( + /\.app-icon-item--drag-source\s*\{[^}]*opacity:\s*0;/s, + ) + expect(mainCss).not.toContain('.springboard--home-dragging') expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/) - expect(mainCss).toMatch(/\.app-icon-item--dragging\s*\{[^}]*opacity:\s*1;/) + expect(mainCss).toMatch( + /\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s, + ) }) it('targets the active visual page and lets grid or dock drags turn pages', () => { diff --git a/frontend/src/views/SpringboardView.vue b/frontend/src/views/SpringboardView.vue index 3e75adc..5ce5913 100644 --- a/frontend/src/views/SpringboardView.vue +++ b/frontend/src/views/SpringboardView.vue @@ -35,6 +35,8 @@ import { resolveSpringboardEdgeTurn, resolveSpringboardHomeEdgeTurn, springboardSwipeIntent, + springboardViewportDeltaToLocal, + springboardViewportToLocal, } from '@/utils/springboardDrag' import { deleteWidgetPage as previewWidgetPageDelete, @@ -77,6 +79,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 renameFolderOnOpenId = ref(null) @@ -95,6 +99,8 @@ 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 const installedApps = computed(() => PHONE_APPS.filter((app) => appStore.isInstalled(app.id)), @@ -663,10 +669,122 @@ async function saveWidgetConfig( if (configured) changePage(configured.page) } -function startHomeDrag(area: HomeArea, index: number): void { +function homeDragLocalPoint( + clientX: number, + clientY: number, +): { + x: number + y: number +} | null { + const layer = homeDragLayer.value + if (!layer) return null + const bounds = layer.getBoundingClientRect() + return springboardViewportToLocal( + clientX, + clientY, + bounds.left, + bounds.top, + bounds.width, + bounds.height, + layer.offsetWidth, + layer.offsetHeight, + ) +} + +function clearHomeDragGhost(expectedGhost?: HTMLElement | null): void { + if (expectedGhost && homeDragGhost !== expectedGhost) return + homeDragGhost?.remove() + homeDragGhost = null + homeDragGrip = null + homeDragVisualActive.value = false +} + +function updateHomeDragGhost(event: { + clientX: number + clientY: number +}): void { + const point = homeDragLocalPoint(event.clientX, event.clientY) + if (!homeDragGhost || !homeDragGrip || !point) return + homeDragGhost.style.transform = `translate3d(${point.x - homeDragGrip.x}px, ${point.y - homeDragGrip.y}px, 0)` +} + +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 + if (!source || !layer) return + + const layerBounds = layer.getBoundingClientRect() + const sourceBounds = source.getBoundingClientRect() + const sourceOrigin = springboardViewportToLocal( + sourceBounds.left, + sourceBounds.top, + layerBounds.left, + layerBounds.top, + layerBounds.width, + layerBounds.height, + layer.offsetWidth, + layer.offsetHeight, + ) + const pointer = homeDragLocalPoint(event.clientX, event.clientY) + if (!pointer) return + + 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.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 localScaleX = + layerBounds.width > 0 ? layer.offsetWidth / layerBounds.width : 1 + const localScaleY = + layerBounds.height > 0 ? layer.offsetHeight / layerBounds.height : 1 + ghost.style.width = `${sourceBounds.width * localScaleX}px` + ghost.style.height = `${sourceBounds.height * localScaleY}px` + homeDragGhost = ghost + homeDragGrip = { + x: pointer.x - sourceOrigin.x, + y: pointer.y - sourceOrigin.y, + } + layer.appendChild(ghost) + 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 { @@ -752,6 +870,7 @@ function queueFolderHover(event: PointerEvent): void { if (folderId) { draggingHomeApp.value = null lastHomePointer = null + clearHomeDragGhost() clearTemporaryHomePage() openedFolderId.value = folderId } @@ -762,6 +881,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') @@ -792,8 +912,14 @@ async function animateHomeItemDrop( if (!appElement) return const target = appElement.getBoundingClientRect() - const offsetX = from.left - target.left - const offsetY = from.top - target.top + const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal( + from.left - target.left, + from.top - target.top, + target.width, + target.height, + appElement.offsetWidth, + appElement.offsetHeight, + ) if (Math.hypot(offsetX, offsetY) < 1) return appElement.style.setProperty('--home-app-drop-x', `${offsetX}px`) @@ -920,12 +1046,13 @@ function finishHomeDrag(event: PointerEvent): void { clearEdgePageTurn() clearFolderHover() const dragged = draggingHomeApp.value - if (!dragged) return + if (!dragged) { + clearHomeDragGhost() + return + } const draggedItem = appStore.homeLayout[dragged.area][dragged.index] - const draggedElement = (event.currentTarget as HTMLElement | null)?.closest( - '.app-icon-item', - ) - const dropOrigin = draggedElement?.getBoundingClientRect() + const dropGhost = homeDragGhost + const dropOrigin = dropGhost?.getBoundingClientRect() const dockTarget = nearestDockDropTarget(event) const gridTarget = dockTarget ? null : nearestGridDropTarget(event) let dropArea = dragged.area @@ -963,7 +1090,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() } } @@ -972,6 +1106,7 @@ function stopHomeDrag(): void { clearFolderHover() draggingHomeApp.value = null lastHomePointer = null + clearHomeDragGhost() clearTemporaryHomePage() } @@ -1142,6 +1277,7 @@ onBeforeUnmount(() => { pendingWidgetPointer = null lastWidgetPointer = null lastHomePointer = null + clearHomeDragGhost() temporaryHomePage.value = null draggingHomeApp.value = null draggingWidgetId.value = null @@ -1157,7 +1293,6 @@ onBeforeUnmount(() => { 'springboard--dragging': dragging, 'springboard--editing': editMode, 'springboard--folder-open': openedFolder !== null, - 'springboard--home-dragging': draggingHomeApp !== null, 'springboard--widget-dragging': draggingWidgetId !== null, }, ]" @@ -1232,6 +1367,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}`, @@ -1239,7 +1375,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)" @@ -1254,6 +1390,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': @@ -1262,7 +1399,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)" @@ -1413,6 +1550,8 @@ 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': @@ -1466,7 +1606,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)" @@ -1480,6 +1620,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="{ @@ -1489,7 +1630,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)"