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)"