diff --git a/frontend/src/components/HomeFolderInteraction.contract.test.ts b/frontend/src/components/HomeFolderInteraction.contract.test.ts
index 72bc7b1..c969450 100644
--- a/frontend/src/components/HomeFolderInteraction.contract.test.ts
+++ b/frontend/src/components/HomeFolderInteraction.contract.test.ts
@@ -26,6 +26,10 @@ const mainCss = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
+const openedFolderAppBlocks =
+ overlaySource.match(//g) ?? []
+const homeFolderIconBlocks =
+ springboardSource.match(//g) ?? []
describe('Home folder interaction contract', () => {
it('opens folders from edit mode and starts dragging only after movement', () => {
@@ -36,7 +40,7 @@ describe('Home folder interaction contract', () => {
expect(iconSource).not.toContain('props.editMode || suppressClick.value')
})
- it('keeps folder app dragging aligned at every phone scale', () => {
+ it('keeps whole-folder dragging aligned at every phone scale', () => {
expect(dragSource).toContain("'.springboard-page, .home-folder-panel'")
expect(dragSource).toContain('viewportWidth: bounds.width')
expect(dragSource).not.toContain("getPropertyValue('zoom')")
@@ -47,6 +51,60 @@ describe('Home folder interaction contract', () => {
)
})
+ it('routes whole folders from both the grid and dock through the shared drag portal', () => {
+ expect(homeFolderIconBlocks).toHaveLength(2)
+ for (const area of ['grid', 'dock'] as const) {
+ const block = homeFolderIconBlocks.find((candidate) =>
+ candidate.includes(`data-home-area="${area}"`),
+ )
+
+ expect(block).toBeDefined()
+ expect(block).toContain(':external-drag-visual="homeDragVisualActive"')
+ expect(block).toContain('@dragcancel="stopHomeDrag"')
+ expect(block).toContain('@dragend="finishHomeDrag"')
+ expect(block).toContain('@dragmove="moveHomeDrag"')
+ expect(block).toContain(`@dragstart="startHomeDrag('${area}',`)
+ }
+ })
+
+ it('uses the external viewport visual for apps dragged from an opened folder', () => {
+ expect(openedFolderAppBlocks).toHaveLength(1)
+ expect(overlaySource).toMatch(/externalDragVisual\??:\s*boolean/)
+ expect(openedFolderAppBlocks[0]).toContain(
+ ':external-drag-visual="externalDragVisual"',
+ )
+ expect(appIconSource).toContain('externalDragVisual?: boolean')
+ expect(appIconSource).toContain('app-icon-item--drag-source')
+ })
+
+ it('forwards opened-folder pointer start, move, and cancellation to the parent session', () => {
+ expect(overlaySource).toMatch(
+ /function startFolderAppDrag\(index: number, event: PointerEvent\)/,
+ )
+ expect(overlaySource).toContain("emit('dragstart', index, event)")
+ expect(overlaySource).toContain("emit('dragmove', event)")
+ expect(overlaySource).toContain("emit('dragcancel')")
+ expect(openedFolderAppBlocks[0]).toContain(
+ '@dragstart="startFolderAppDrag(entry.index, $event)"',
+ )
+ expect(openedFolderAppBlocks[0]).toContain('@dragmove="moveFolderAppDrag"')
+ expect(openedFolderAppBlocks[0]).toContain(
+ '@dragcancel="stopFolderAppDrag"',
+ )
+ })
+
+ it('hit-tests the opened folder panel and app targets in normalized viewport coordinates', () => {
+ expect(overlaySource).toContain('readPhoneViewportGeometry')
+ expect(overlaySource).toContain('phoneViewportRectContainsPoint')
+ expect(overlaySource).toContain('folderDragViewportRect(panel)')
+ expect(overlaySource).toContain('folderDragViewportRect(element)')
+ expect(overlaySource).toMatch(/geometry\?\.rect\(element\)/)
+ expect(overlaySource).not.toContain(
+ 'panelElement.value?.getBoundingClientRect()',
+ )
+ expect(overlaySource).not.toContain('document.elementsFromPoint')
+ })
+
it('edits the folder name inline with Sky UI controls', () => {
expect(overlaySource).toContain(' {
expect(springboardSource).toContain(
'@drag-outside-change="folderDraggingOutside = $event"',
)
+ expect(overlaySource).toContain('if (draggingIndex.value === null) return')
expect(overlaySource).toContain(
- 'if (draggingIndex.value === null || draggingOutside.value) return',
+ 'if (!draggingOutside.value && !isPointerInsidePanel(event))',
)
expect(overlaySource).toContain(
'draggingOutside.value || !isPointerInsidePanel(event)',
diff --git a/frontend/src/components/HomeFolderOverlay.vue b/frontend/src/components/HomeFolderOverlay.vue
index e2cecb0..6ae0294 100644
--- a/frontend/src/components/HomeFolderOverlay.vue
+++ b/frontend/src/components/HomeFolderOverlay.vue
@@ -1,6 +1,6 @@
@@ -290,10 +355,11 @@ function finishPageSwipe(event: PointerEvent): void {
:app="entry.app"
:data-folder-app-index="entry.index"
:edit-mode="editMode"
+ :external-drag-visual="externalDragVisual"
@dragcancel="stopFolderAppDrag"
@dragend="finishFolderAppDrag"
@dragmove="moveFolderAppDrag"
- @dragstart="startFolderAppDrag(entry.index)"
+ @dragstart="startFolderAppDrag(entry.index, $event)"
@edit="emit('edit')"
/>
diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts
index 159c1b0..b82a21f 100644
--- a/frontend/src/stores/app-store.test.ts
+++ b/frontend/src/stores/app-store.test.ts
@@ -474,6 +474,41 @@ describe('app store', () => {
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(5)
})
+ it('materializes a trailing page only for a successful folder extraction', () => {
+ const apps = useAppStoreStore()
+ apps.hydrate(null)
+
+ const notesIndex = apps.homeLayout.grid.indexOf('notes')
+ const settingsIndex = apps.homeLayout.grid.indexOf('settings')
+ const folderId = apps.createHomeFolder(
+ 'grid',
+ notesIndex,
+ 'grid',
+ settingsIndex,
+ 'Utilities',
+ )
+ expect(folderId).toBeTruthy()
+
+ const originalPageCount = apps.homeLayout.pageCount
+ const targetPage = originalPageCount + 1
+ const targetIndex = originalPageCount * HOME_GRID_PAGE_SIZE
+ expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
+
+ expect(
+ apps.extractHomeFolderApp('missing-folder', 0, 'grid', targetIndex),
+ ).toBe(false)
+ expect(apps.homeLayout.pageCount).toBe(originalPageCount)
+ expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
+
+ mocks.phone.saveDeviceNamespace.mockClear()
+ expect(apps.extractHomeFolderApp(folderId!, 0, 'grid', targetIndex)).toBe(
+ true,
+ )
+ expect(apps.homeLayout.pageCount).toBe(targetPage)
+ expect(apps.homeLayout.grid[targetIndex]).toBe('settings')
+ expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
+ })
+
it('does not commit an installation to a different phone', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
diff --git a/frontend/src/stores/app-store.ts b/frontend/src/stores/app-store.ts
index 95acb5c..81404f0 100644
--- a/frontend/src/stores/app-store.ts
+++ b/frontend/src/stores/app-store.ts
@@ -19,6 +19,7 @@ import {
createDefaultHomeLayout,
deleteHomePage,
extractHomeFolderApp,
+ HOME_GRID_PAGE_SIZE,
moveHomeFolderApp,
moveHomeApp,
moveHomeAppToGridPage,
@@ -519,14 +520,23 @@ export const useAppStoreStore = defineStore('app-store', {
to: HomeArea,
targetIndex: number,
): boolean {
+ let sourceLayout = this.homeLayout
+ if (to === 'grid' && Number.isInteger(targetIndex) && targetIndex >= 0) {
+ const targetPage = Math.floor(targetIndex / HOME_GRID_PAGE_SIZE) + 1
+ while (sourceLayout.pageCount < targetPage) {
+ const expanded = addHomePage(sourceLayout)
+ if (expanded === sourceLayout) return false
+ sourceLayout = expanded
+ }
+ }
const next = extractHomeFolderApp(
- this.homeLayout,
+ sourceLayout,
folderId,
sourceIndex,
to,
targetIndex,
)
- if (next === this.homeLayout) return false
+ if (next === sourceLayout) return false
this.homeLayout = next
this.persist()
return true
diff --git a/frontend/src/utils/phoneViewportGeometry.test.ts b/frontend/src/utils/phoneViewportGeometry.test.ts
index 48e0bf9..8310f17 100644
--- a/frontend/src/utils/phoneViewportGeometry.test.ts
+++ b/frontend/src/utils/phoneViewportGeometry.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
normalizePhoneViewportRect,
+ phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
@@ -63,7 +64,78 @@ function createGeometryFixture(options: {
}
}
+const PHONE_BASE_ZOOM = 0.69 * 1.2
+const PHONE_HEIGHT = 844
+const PHONE_WIDTH = 390
+const REFERENCE_VIEWPORT_HEIGHT = 1080
+const REFERENCE_VIEWPORT_WIDTH = 1920
+
+const displayCases = [
+ { height: 1080, label: '1080p 16:9', width: 1920 },
+ { height: 2160, label: '4K 16:9', width: 3840 },
+ { height: 1440, label: '21:9', width: 3440 },
+ { height: 1440, label: '32:9', width: 5120 },
+ { height: 1600, label: '16:10', width: 2560 },
+] as const
+
+const phoneScales = [80, 100, 120] as const
+
+function productionPhoneZoom(
+ viewportWidth: number,
+ viewportHeight: number,
+ phoneScale: number,
+): number {
+ const viewportScale = Math.min(
+ viewportWidth / REFERENCE_VIEWPORT_WIDTH,
+ viewportHeight / REFERENCE_VIEWPORT_HEIGHT,
+ )
+ const preferred = PHONE_BASE_ZOOM * viewportScale * (phoneScale / 100)
+ const edgeGap = 24 * viewportScale
+ const viewportMaximum = Math.max(
+ 0,
+ Math.min(
+ (viewportWidth - edgeGap) / PHONE_WIDTH,
+ (viewportHeight - edgeGap) / PHONE_HEIGHT,
+ ),
+ )
+
+ return Math.min(viewportMaximum, Math.max(260 / PHONE_WIDTH, preferred))
+}
+
+const cefDisplayMatrix = displayCases.flatMap((display) =>
+ phoneScales.map((phoneScale) => ({ display, phoneScale })),
+)
+
describe('phone viewport geometry', () => {
+ it('treats every rectangle boundary as inside and rejects points beyond it', () => {
+ const rect: PhoneViewportRect = {
+ bottom: 260,
+ height: 160,
+ left: 120,
+ right: 360,
+ top: 100,
+ width: 240,
+ }
+
+ expect(phoneViewportRectContainsPoint(rect, 240, 180)).toBe(true)
+ expect(phoneViewportRectContainsPoint(rect, rect.left, rect.top)).toBe(true)
+ expect(phoneViewportRectContainsPoint(rect, rect.right, rect.bottom)).toBe(
+ true,
+ )
+ expect(phoneViewportRectContainsPoint(rect, rect.left - 0.001, 180)).toBe(
+ false,
+ )
+ expect(phoneViewportRectContainsPoint(rect, rect.right + 0.001, 180)).toBe(
+ false,
+ )
+ expect(phoneViewportRectContainsPoint(rect, 240, rect.top - 0.001)).toBe(
+ false,
+ )
+ expect(phoneViewportRectContainsPoint(rect, 240, rect.bottom + 0.001)).toBe(
+ false,
+ )
+ })
+
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)
@@ -124,6 +196,76 @@ describe('phone viewport geometry', () => {
},
)
+ it.each(cefDisplayMatrix)(
+ 'keeps an opened folder panel hit-test exact at $phoneScale% on $display.label',
+ ({ display, phoneScale }) => {
+ const viewportScale = Math.min(
+ display.width / REFERENCE_VIEWPORT_WIDTH,
+ display.height / REFERENCE_VIEWPORT_HEIGHT,
+ )
+ const zoom = productionPhoneZoom(
+ display.width,
+ display.height,
+ phoneScale,
+ )
+ const edgeGap = 24 * viewportScale
+ const wrapper = measuredRect(
+ display.width - edgeGap - PHONE_WIDTH * zoom,
+ display.height - edgeGap - PHONE_HEIGHT * zoom,
+ PHONE_WIDTH * zoom,
+ PHONE_HEIGHT * zoom,
+ )
+ // Live CEF 103 can report the zoomed canvas in a different coordinate
+ // space from pointer events. Preserve that mismatch in this fixture.
+ const rawCanvas = measuredRect(
+ display.width + 117.375,
+ 83.625,
+ PHONE_WIDTH,
+ PHONE_HEIGHT,
+ )
+ const rawPanel = measuredRect(
+ rawCanvas.left + 23.25,
+ rawCanvas.top + 246.75,
+ 343.5,
+ 324.25,
+ )
+ const panel = normalizePhoneViewportRect(rawPanel, rawCanvas, wrapper)
+ const expected = {
+ bottom: wrapper.top + (246.75 + 324.25) * zoom,
+ height: 324.25 * zoom,
+ left: wrapper.left + 23.25 * zoom,
+ right: wrapper.left + (23.25 + 343.5) * zoom,
+ top: wrapper.top + 246.75 * zoom,
+ width: 343.5 * zoom,
+ }
+
+ expectRectClose(panel, expected)
+ expect(
+ phoneViewportRectContainsPoint(
+ panel,
+ panel.left + panel.width / 2,
+ panel.top + panel.height / 2,
+ ),
+ ).toBe(true)
+ expect(phoneViewportRectContainsPoint(panel, panel.left, panel.top)).toBe(
+ true,
+ )
+ expect(
+ phoneViewportRectContainsPoint(panel, panel.right, panel.bottom),
+ ).toBe(true)
+ expect(
+ phoneViewportRectContainsPoint(panel, panel.left - 0.25, panel.top),
+ ).toBe(false)
+ expect(
+ phoneViewportRectContainsPoint(panel, panel.right + 0.25, panel.bottom),
+ ).toBe(false)
+ expect(panel.left).toBeGreaterThanOrEqual(0)
+ expect(panel.right).toBeLessThanOrEqual(display.width)
+ expect(panel.top).toBeGreaterThanOrEqual(0)
+ expect(panel.bottom).toBeLessThanOrEqual(display.height)
+ },
+ )
+
it('reads visual scale and normalized element rects from a canvas anchor', () => {
const fixture = createGeometryFixture({
canvasOffsetHeight: 844,
diff --git a/frontend/src/utils/phoneViewportGeometry.ts b/frontend/src/utils/phoneViewportGeometry.ts
index eab9038..2aaf46c 100644
--- a/frontend/src/utils/phoneViewportGeometry.ts
+++ b/frontend/src/utils/phoneViewportGeometry.ts
@@ -7,6 +7,14 @@ export type PhoneViewportRect = {
width: number
}
+export function phoneViewportRectContainsPoint(
+ rect: PhoneViewportRect,
+ x: number,
+ y: number,
+): boolean {
+ return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
+}
+
type RectMeasurement = Pick<
DOMRectReadOnly,
'height' | 'left' | 'top' | 'width'
diff --git a/frontend/src/views/SpringboardView.contract.test.ts b/frontend/src/views/SpringboardView.contract.test.ts
index 9106442..9c6be10 100644
--- a/frontend/src/views/SpringboardView.contract.test.ts
+++ b/frontend/src/views/SpringboardView.contract.test.ts
@@ -31,6 +31,22 @@ const builtInWallpaperCss = mainCss.slice(
mainCss.indexOf('.wallpaper--midnight'),
mainCss.indexOf('.wallpaper--custom'),
)
+const folderOverlayBlock =
+ viewSource.match(//)?.[0] ?? ''
+
+function handlerName(block: string, event: string): string {
+ return (
+ block.match(new RegExp(`@${event}="([A-Za-z][A-Za-z0-9_]*)"`))?.[1] ?? ''
+ )
+}
+
+function functionSource(name: string): string {
+ if (!name) return ''
+ const start = viewSource.indexOf(`function ${name}(`)
+ if (start < 0) return ''
+ const next = viewSource.indexOf('\nfunction ', start + 1)
+ return viewSource.slice(start, next < 0 ? viewSource.length : next)
+}
describe('Springboard page swipe contract', () => {
it('keeps app and widget labels on the larger shared home typography', () => {
@@ -136,7 +152,7 @@ describe('Springboard page swipe contract', () => {
)
expect(
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
- ).toHaveLength(4)
+ ).toHaveLength(5)
expect(viewSource).toContain('updateHomeDragGhost(event)')
expect(viewSource).toContain('const dropGhost = homeDragGhost')
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
@@ -160,6 +176,65 @@ describe('Springboard page swipe contract', () => {
)
})
+ it('owns one external drag session for an app leaving an opened folder', () => {
+ expect(folderOverlayBlock).toContain(
+ ':external-drag-visual="homeDragVisualActive"',
+ )
+
+ const startSource = functionSource(
+ handlerName(folderOverlayBlock, 'dragstart'),
+ )
+ const moveSource = functionSource(
+ handlerName(folderOverlayBlock, 'dragmove'),
+ )
+ const cancelSource = functionSource(
+ handlerName(folderOverlayBlock, 'dragcancel'),
+ )
+
+ expect(startSource).toContain('createHomeDragGhost(event)')
+ expect(startSource).toMatch(/\.value\s*=\s*\{[\s\S]*?sourceIndex/)
+ expect(moveSource).toContain('updateHomeDragGhost(event)')
+ expect(moveSource).toContain('folderDraggingOutside.value')
+ expect(moveSource).toContain('resolveHomeEdgeTurn(event)')
+ expect(moveSource).toContain("queueEdgePageTurn(event, 'app')")
+ expect(viewSource).toContain(
+ '(draggingHomeApp.value || draggingFolderApp.value)',
+ )
+ expect(cancelSource).toContain('clearHomeDragGhost()')
+ expect(cancelSource).toContain('clearTemporaryHomePage()')
+ })
+
+ it('settles an extracted folder app from the tracked viewport preview', () => {
+ const extractSource = functionSource('extractOpenedFolderApp')
+ const targetSource = functionSource('folderExtractionDropTarget')
+
+ expect(extractSource).toContain('const dropGhost = homeDragGhost')
+ expect(extractSource).toContain('homeDragPreviewBounds')
+ expect(targetSource).toContain('page > appStore.homeLayout.pageCount')
+ expect(extractSource).not.toContain('sourceElement')
+ expect(extractSource).not.toContain('getBoundingClientRect()')
+ expect(extractSource).toMatch(
+ /animateHomeItemDrop\([\s\S]*?dropOrigin[\s\S]*?\)\.finally\(/,
+ )
+ expect(extractSource).toContain('clearHomeDragGhost(dropGhost)')
+ expect(extractSource).toMatch(
+ /if \(!target\) \{[\s\S]*?clearHomeDragGhost\(\)/,
+ )
+ })
+
+ it('cleans the opened-folder drag preview after an internal move, cancellation, or unmount', () => {
+ const moveSource = functionSource('moveOpenedFolderApp')
+ const cancelSource = functionSource(
+ handlerName(folderOverlayBlock, 'dragcancel'),
+ )
+
+ expect(moveSource).toContain('stopOpenedFolderAppDrag()')
+ expect(cancelSource).toContain('clearHomeDragGhost()')
+ expect(viewSource).toMatch(
+ /onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
+ )
+ })
+
it('cleans up the external drag visual on every terminal path', () => {
expect(
viewSource.match(
diff --git a/frontend/src/views/SpringboardView.vue b/frontend/src/views/SpringboardView.vue
index 9bb3598..920bbca 100644
--- a/frontend/src/views/SpringboardView.vue
+++ b/frontend/src/views/SpringboardView.vue
@@ -42,6 +42,7 @@ import {
} from '@/utils/homeLayout'
import type { ReorderDirection } from '@/utils/keyboard'
import {
+ phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportGeometry,
type PhoneViewportRect,
@@ -111,6 +112,11 @@ const draggingHomeApp = ref<{
area: HomeArea
index: number
} | null>(null)
+const draggingFolderApp = ref<{
+ appId: LaunchablePhoneAppId
+ folderId: string
+ sourceIndex: number
+} | null>(null)
const homeDragLayer = ref(null)
const homeDragVisualActive = ref(false)
const temporaryHomePage = ref(null)
@@ -610,7 +616,11 @@ function queueEdgePageTurn(
edgePageTimer = undefined
edgePageDirection = direction
edgePageLocked = dragType === 'widget'
- if (dragType === 'app' && lastHomePointer && draggingHomeApp.value) {
+ if (
+ dragType === 'app' &&
+ lastHomePointer &&
+ (draggingHomeApp.value || draggingFolderApp.value)
+ ) {
queueEdgePageTurn(lastHomePointer, 'app')
}
}, HOME_EDGE_PAGE_TURN_DELAY)
@@ -814,7 +824,7 @@ function updateHomeDragGhost(event: {
)
}
-function createHomeDragGhost(event: PointerEvent): void {
+function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
clearHomeDragGhost()
const eventTarget =
event.currentTarget instanceof Element
@@ -827,7 +837,7 @@ function createHomeDragGhost(event: PointerEvent): void {
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
+ if (!source || !layer || !springboard || !portal || !geometry) return null
const portalBounds = portal.getBoundingClientRect()
const clipBounds = geometry.rect(springboard)
@@ -838,7 +848,7 @@ function createHomeDragGhost(event: PointerEvent): void {
sourceBounds.width <= 0 ||
sourceBounds.height <= 0
) {
- return
+ return null
}
layer.style.left = `${clipBounds.left - portalBounds.left}px`
@@ -865,7 +875,12 @@ function createHomeDragGhost(event: PointerEvent): void {
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)
+ if (
+ attribute.startsWith('data-home-') ||
+ attribute.startsWith('data-folder-')
+ ) {
+ element.removeAttribute(attribute)
+ }
}
}
for (const control of ghost.querySelectorAll(
@@ -894,6 +909,7 @@ function createHomeDragGhost(event: PointerEvent): void {
layer.appendChild(position)
homeDragVisualActive.value = true
updateHomeDragGhost(event)
+ return position
}
function startHomeDrag(
@@ -1190,6 +1206,98 @@ function nearestDockDropTarget(event: {
return closestIndex === null ? null : slots[closestIndex]
}
+function folderExtractionDropTarget(event: {
+ clientX: number
+ clientY: number
+}):
+ | { area: 'dock'; index: number }
+ | { area: 'grid'; index: number; page: number }
+ | null {
+ const geometry = homeDragGeometry
+ if (!geometry) return null
+
+ const dock = document.querySelector('.app-dock')
+ if (dock) {
+ const dockBounds = homeDragViewportRect(dock, geometry)
+ if (
+ phoneViewportRectContainsPoint(dockBounds, event.clientX, event.clientY)
+ ) {
+ const slots = Array.from(
+ dock.querySelectorAll(
+ '[data-home-area="dock"][data-home-index]',
+ ),
+ ).filter((slot) => {
+ const index = Number(slot.dataset.homeIndex)
+ return (
+ Number.isInteger(index) && appStore.homeLayout.dock[index] === null
+ )
+ })
+ const closestIndex = nearestSpringboardRectIndex(
+ event.clientX,
+ event.clientY,
+ slots.map((slot) => homeDragViewportRect(slot, geometry)),
+ )
+ if (closestIndex === null) return null
+ const index = Number(slots[closestIndex]?.dataset.homeIndex)
+ return Number.isInteger(index) ? { area: 'dock', index } : null
+ }
+ }
+
+ const page = phone.currentPage
+ if (page < 1 || page > appPages.value.length) return null
+ const grid = document.querySelector(
+ `.springboard-page--apps[data-home-page="${page}"] .app-grid`,
+ )
+ const pageElement = grid?.closest('.springboard-page')
+ const springboard = grid?.closest('.springboard')
+ if (!grid || !pageElement || !springboard) return null
+
+ const pageBounds = homeDragViewportRect(pageElement, geometry)
+ const springboardBounds = homeDragViewportRect(springboard, geometry)
+ const rawGridBounds = homeDragViewportRect(grid, geometry)
+ const gridBounds = viewportRectAt(
+ springboardBounds.left + (rawGridBounds.left - pageBounds.left),
+ springboardBounds.top + (rawGridBounds.top - pageBounds.top),
+ rawGridBounds.width,
+ rawGridBounds.height,
+ )
+ if (
+ !phoneViewportRectContainsPoint(gridBounds, event.clientX, event.clientY)
+ ) {
+ return null
+ }
+
+ const slots = Array.from(
+ grid.querySelectorAll(
+ '[data-home-area="grid"][data-home-index]',
+ ),
+ ).filter((slot) => {
+ const index = Number(slot.dataset.homeIndex)
+ const item = appStore.homeLayout.grid[index]
+ return (
+ Number.isInteger(index) &&
+ (item === null ||
+ (item === undefined && page > appStore.homeLayout.pageCount))
+ )
+ })
+ const closestIndex = nearestSpringboardRectIndex(
+ event.clientX,
+ event.clientY,
+ 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,
+ }
+ }),
+ )
+ if (closestIndex === null) return null
+ const index = Number(slots[closestIndex]?.dataset.homeIndex)
+ return Number.isInteger(index) ? { area: 'grid', index, page } : null
+}
+
function finishHomeDrag(event: PointerEvent): void {
clearEdgePageTurn()
clearFolderHover()
@@ -1324,16 +1432,83 @@ function folderPreviewApps(folder: HomeFolder): PhoneAppDefinition[] {
return apps
}
+function currentOpenedFolderDragSession(sourceIndex?: number) {
+ const session = draggingFolderApp.value
+ const folder = openedFolder.value
+ if (
+ !session ||
+ !folder ||
+ folder.id !== session.folderId ||
+ (sourceIndex !== undefined && sourceIndex !== session.sourceIndex) ||
+ folder.apps[session.sourceIndex] !== session.appId
+ ) {
+ return null
+ }
+ return session
+}
+
+function startOpenedFolderAppDrag(
+ sourceIndex: number,
+ event: PointerEvent,
+): void {
+ stopOpenedFolderAppDrag()
+ const folder = openedFolder.value
+ if (
+ !folder ||
+ !Number.isInteger(sourceIndex) ||
+ sourceIndex < 0 ||
+ sourceIndex >= folder.apps.length
+ ) {
+ return
+ }
+ const appId = folder.apps[sourceIndex]
+ if (!appId || !installedAppsById.value.has(appId)) return
+ if (!createHomeDragGhost(event)) {
+ clearHomeDragGhost()
+ return
+ }
+ draggingFolderApp.value = { appId, folderId: folder.id, sourceIndex }
+ lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
+}
+
+function moveOpenedFolderAppDrag(event: PointerEvent): void {
+ if (!currentOpenedFolderDragSession()) {
+ stopOpenedFolderAppDrag()
+ return
+ }
+ lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
+ updateHomeDragGhost(event)
+ if (folderDraggingOutside.value && resolveHomeEdgeTurn(event)) {
+ queueEdgePageTurn(event, 'app')
+ return
+ }
+ clearEdgePageTurn()
+}
+
+function stopOpenedFolderAppDrag(): void {
+ clearEdgePageTurn()
+ draggingFolderApp.value = null
+ lastHomePointer = null
+ clearHomeDragGhost()
+ clearTemporaryHomePage()
+}
+
+function resetOpenedFolderState(): void {
+ folderDraggingOutside.value = false
+ openedFolderId.value = null
+ renameFolderOnOpenId.value = null
+}
+
function openFolder(folderId: string): void {
+ stopOpenedFolderAppDrag()
pageTransitioning.value = false
folderDraggingOutside.value = false
openedFolderId.value = folderId
}
function closeFolder(): void {
- folderDraggingOutside.value = false
- openedFolderId.value = null
- renameFolderOnOpenId.value = null
+ stopOpenedFolderAppDrag()
+ resetOpenedFolderState()
}
function renameOpenedFolder(name: string): void {
@@ -1342,71 +1517,68 @@ function renameOpenedFolder(name: string): void {
}
function moveOpenedFolderApp(sourceIndex: number, targetIndex: number): void {
- if (!openedFolderId.value) return
- appStore.moveHomeFolderApp(openedFolderId.value, sourceIndex, targetIndex)
+ const session = currentOpenedFolderDragSession(sourceIndex)
+ stopOpenedFolderAppDrag()
+ if (!session) return
+ appStore.moveHomeFolderApp(session.folderId, sourceIndex, targetIndex)
}
function extractOpenedFolderApp(
sourceIndex: number,
event: PointerEvent,
): void {
- const folder = openedFolder.value
- if (!folder) return
- const appId = folder.apps[sourceIndex]
- const sourceElement = document
- .querySelector(
- `.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
- )
- ?.closest('.app-icon-item')
- 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) => {
- const area = element.dataset.homeArea as HomeArea
- const index = Number(element.dataset.homeIndex)
- return (
- (area === 'grid' || area === 'dock') &&
- Number.isInteger(index) &&
- appStore.homeLayout[area][index] === null
- )
- })
- const target = candidates.reduce((closest, candidate) => {
- if (!closest) return candidate
- 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),
- )
- const closestDistance = Math.hypot(
- event.clientX - (closestBounds.left + closestBounds.width / 2),
- event.clientY - (closestBounds.top + closestBounds.height / 2),
- )
- return distance < closestDistance ? candidate : closest
- }, null)
- if (!target) {
+ const session = currentOpenedFolderDragSession(sourceIndex)
+ if (!session) {
closeFolder()
return
}
+ updateHomeDragGhost(event)
+ const dropGhost = homeDragGhost
+ const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
+ if (!dropGhost || !dropOrigin) {
+ closeFolder()
+ return
+ }
+ const target = folderExtractionDropTarget(event)
+ if (!target) {
+ clearEdgePageTurn()
+ draggingFolderApp.value = null
+ lastHomePointer = null
+ clearHomeDragGhost()
+ clearTemporaryHomePage()
+ resetOpenedFolderState()
+ return
+ }
- const area = target.dataset.homeArea as HomeArea
- const targetIndex = Number(target.dataset.homeIndex)
+ const { area, index: targetIndex } = target
if (
- !appStore.extractHomeFolderApp(folder.id, sourceIndex, area, targetIndex)
+ !appStore.extractHomeFolderApp(
+ session.folderId,
+ session.sourceIndex,
+ area,
+ targetIndex,
+ )
) {
closeFolder()
return
}
- closeFolder()
- if (sourceBounds) {
- const extractedIndex = findHomeItemIndex(appId, area, targetIndex)
- if (extractedIndex !== null) {
- void animateHomeItemDrop(appId, area, extractedIndex, sourceBounds)
- }
+
+ clearEdgePageTurn()
+ draggingFolderApp.value = null
+ lastHomePointer = null
+ clearTemporaryHomePage(area === 'grid')
+ resetOpenedFolderState()
+ const extractedIndex = findHomeItemIndex(session.appId, area, targetIndex)
+ if (extractedIndex === null) {
+ clearHomeDragGhost(dropGhost)
+ return
}
+ void animateHomeItemDrop(
+ session.appId,
+ area,
+ extractedIndex,
+ dropOrigin,
+ ).finally(() => clearHomeDragGhost(dropGhost))
}
function clearSearch(): void {
@@ -1426,11 +1598,12 @@ watch(isEditablePage, (visible) => {
watch(editMode, (editing) => {
emit('editModeChange', editing)
if (editing) return
+ stopOpenedFolderAppDrag()
stopHomeDrag()
stopWidgetDrag()
})
watch(openedFolder, (folder) => {
- if (!folder) closeFolder()
+ if (!folder && openedFolderId.value !== null) closeFolder()
})
onBeforeUnmount(() => {
emit('editModeChange', false)
@@ -1445,6 +1618,7 @@ onBeforeUnmount(() => {
clearHomeDragGhost()
temporaryHomePage.value = null
draggingHomeApp.value = null
+ draggingFolderApp.value = null
draggingWidgetId.value = null
})
@@ -1858,9 +2032,13 @@ onBeforeUnmount(() => {
v-if="openedFolder"
:apps="openedFolderApps"
:edit-mode="editMode"
+ :external-drag-visual="homeDragVisualActive"
:folder="openedFolder"
:rename-on-open="renameFolderOnOpenId === openedFolder.id"
@close="closeFolder"
+ @dragcancel="stopOpenedFolderAppDrag"
+ @dragmove="moveOpenedFolderAppDrag"
+ @dragstart="startOpenedFolderAppDrag"
@drag-outside-change="folderDraggingOutside = $event"
@edit="enterEditMode"
@extract="extractOpenedFolderApp"