FIX - preserve Home Screen drag across pages

This commit is contained in:
DerEchteAlec
2026-08-13 22:08:06 +02:00
parent 31e2676c82
commit a90ef7819d
7 changed files with 269 additions and 26 deletions
+23 -1
View File
@@ -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;
+6 -3
View File
@@ -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()
})
</script>
@@ -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,
}"
+6 -3
View File
@@ -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()
})
</script>
@@ -179,6 +181,7 @@ onBeforeUnmount(() => {
<div
class="home-folder-item app-icon-item"
:class="{
'app-icon-item--drag-source': isDragging && externalDragVisual,
'app-icon-item--dragging': isDragging,
'app-icon-item--editing': editMode,
'home-folder-item--dragging': isDragging,
@@ -7,6 +7,8 @@ import {
springboardEdgeDirection,
springboardPageDragCompensation,
springboardSwipeIntent,
springboardViewportDeltaToLocal,
springboardViewportToLocal,
} from '@/utils/springboardDrag'
describe('springboard widget drag', () => {
@@ -57,4 +59,16 @@ describe('springboard widget drag', () => {
expect(springboardPageDragCompensation(2, 3, 368)).toBe(368)
expect(springboardPageDragCompensation(2, 2, 368)).toBe(0)
})
it('converts viewport coordinates into local phone coordinates under zoom', () => {
expect(
springboardViewportToLocal(169, 238, 100, 100, 253.92, 582.36, 368, 844),
).toEqual({ x: 100, y: 200 })
expect(
springboardViewportToLocal(200, 250, 100, 50, 0, 0, 368, 844),
).toEqual({ x: 100, y: 200 })
expect(
springboardViewportDeltaToLocal(69, 138, 253.92, 582.36, 368, 844),
).toEqual({ x: 100, y: 200 })
})
})
+36
View File
@@ -11,6 +11,42 @@ export type SpringboardHomeEdgeTurn = SpringboardEdgeTurn & {
export type SpringboardSwipeIntent = 'horizontal' | 'pending' | 'vertical'
export type SpringboardLocalPoint = {
x: number
y: number
}
export function springboardViewportToLocal(
clientX: number,
clientY: number,
viewportLeft: number,
viewportTop: number,
viewportWidth: number,
viewportHeight: number,
layoutWidth: number,
layoutHeight: number,
): SpringboardLocalPoint {
const scaleX = viewportWidth > 0 ? layoutWidth / viewportWidth : 1
const scaleY = viewportHeight > 0 ? layoutHeight / viewportHeight : 1
return {
x: (clientX - viewportLeft) * scaleX,
y: (clientY - viewportTop) * scaleY,
}
}
export function springboardViewportDeltaToLocal(
deltaX: number,
deltaY: number,
viewportWidth: number,
viewportHeight: number,
layoutWidth: number,
layoutHeight: number,
): SpringboardLocalPoint {
const scaleX = viewportWidth > 0 ? layoutWidth / viewportWidth : 1
const scaleY = viewportHeight > 0 ? layoutHeight / viewportHeight : 1
return { x: deltaX * scaleX, y: deltaY * scaleY }
}
export function springboardPageDragCompensation(
startPage: number,
currentPage: number,
@@ -43,15 +43,39 @@ describe('Springboard page swipe contract', () => {
expect(folderIconSource).toContain(':style="dragPointerStyle"')
})
it('keeps the held app visible while its source page moves away', () => {
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('</div>')
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', () => {
+155 -14
View File
@@ -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<HTMLElement | null>(null)
const homeDragVisualActive = ref(false)
const temporaryHomePage = ref<number | null>(null)
const openedFolderId = ref<string | null>(null)
const renameFolderOnOpenId = ref<string | null>(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<HTMLElement>('.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<HTMLElement>(
'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(() => {
</section>
</div>
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
<Transition name="edit-done">
<k-glass
v-if="editMode && isEditablePage && !openedFolder"
@@ -1458,6 +1597,7 @@ 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)"