FIX - enable cross-page Home Screen dragging

This commit is contained in:
DerEchteAlec
2026-08-13 21:21:14 +02:00
parent 36672c69e0
commit 477b61e0cb
15 changed files with 1635 additions and 334 deletions
+4 -1
View File
@@ -6555,7 +6555,10 @@ button {
display: none;
}
.app-grid {
grid-template-rows: repeat(6, var(--springboard-grid-row));
grid-template-rows: repeat(
var(--home-grid-rows, 6),
var(--springboard-grid-row)
);
gap: var(--springboard-grid-row-gap) var(--springboard-grid-column-gap);
}
.app-grid-slot {
+23 -5
View File
@@ -16,6 +16,10 @@ import {
reorderDirectionFromKeyboard,
type ReorderDirection,
} from '@/utils/keyboard'
import {
springboardPageDragCompensation,
springboardSwipeIntent,
} from '@/utils/springboardDrag'
const props = withDefaults(
defineProps<{
@@ -56,8 +60,14 @@ let dragPageWidth = 0
const dragStyle = computed(() =>
isDragging.value
? {
transform: `translateX(${(phone.currentPage - dragStartPage) * dragPageWidth}px)`,
translate: `${dragOffset.value.x}px ${dragOffset.value.y}px`,
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
}
: undefined,
)
const dragPointerStyle = computed(() =>
isDragging.value
? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
}
: undefined,
)
@@ -142,6 +152,7 @@ function clearHold(): void {
function onPointerDown(event: PointerEvent): void {
if (props.compact || event.button !== 0) return
suppressClick.value = false
pointerTarget = event.currentTarget as HTMLElement
pointerId = event.pointerId
pointerTarget.setPointerCapture(pointerId)
@@ -169,9 +180,12 @@ function onPointerMove(event: PointerEvent): void {
return
}
if (
Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >
8
springboardSwipeIntent(
event.clientX - pointerStart.x,
event.clientY - pointerStart.y,
) !== 'pending'
) {
suppressClick.value = true
clearHold()
}
}
@@ -190,9 +204,11 @@ function onPointerUp(event: PointerEvent): void {
clearHold()
if (isDragging.value) {
suppressClick.value = true
emit('dragend', event)
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
releasePointerCapture()
emit('dragend', event)
return
}
releasePointerCapture()
}
@@ -260,6 +276,7 @@ onBeforeUnmount(() => {
:aria-keyshortcuts="
editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
"
:style="dragPointerStyle"
@click="launch"
@contextmenu.prevent
@keydown="onKeydown"
@@ -319,6 +336,7 @@ onBeforeUnmount(() => {
app: getPhoneAppLabel(app, phone.t),
})
"
:style="dragPointerStyle"
@click.stop="emit('remove')"
@pointerdown.stop
>
+22 -5
View File
@@ -8,6 +8,10 @@ import {
reorderDirectionFromKeyboard,
type ReorderDirection,
} from '@/utils/keyboard'
import {
springboardPageDragCompensation,
springboardSwipeIntent,
} from '@/utils/springboardDrag'
const props = withDefaults(
defineProps<{
@@ -47,8 +51,14 @@ const folderName = computed(() => props.folder.name || props.defaultName)
const dragStyle = computed(() =>
isDragging.value
? {
transform: `translateX(${(phone.currentPage - dragStartPage) * dragPageWidth}px)`,
translate: `${dragOffset.value.x}px ${dragOffset.value.y}px`,
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
}
: undefined,
)
const dragPointerStyle = computed(() =>
isDragging.value
? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
}
: undefined,
)
@@ -82,6 +92,7 @@ function beginPointerDrag(event: PointerEvent): void {
function onPointerDown(event: PointerEvent): void {
if (event.button !== 0) return
suppressClick.value = false
pointerTarget = event.currentTarget as HTMLElement
pointerId = event.pointerId
pointerTarget.setPointerCapture(pointerId)
@@ -109,9 +120,12 @@ function onPointerMove(event: PointerEvent): void {
return
}
if (
Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >
8
springboardSwipeIntent(
event.clientX - pointerStart.x,
event.clientY - pointerStart.y,
) !== 'pending'
) {
suppressClick.value = true
clearHold()
}
}
@@ -120,9 +134,11 @@ function onPointerUp(event: PointerEvent): void {
clearHold()
if (isDragging.value) {
suppressClick.value = true
emit('dragend', event)
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
releasePointerCapture()
emit('dragend', event)
return
}
releasePointerCapture()
}
@@ -176,6 +192,7 @@ onBeforeUnmount(() => {
:aria-keyshortcuts="
editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
"
:style="dragPointerStyle"
@click="openFolder"
@contextmenu.prevent
@keydown="onKeydown"
+85 -6
View File
@@ -3,7 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import { getHomeFolder, removeHomeApp } from '@/utils/homeLayout'
import {
getHomeFolder,
HOME_GRID_PAGE_SIZE,
removeHomeApp,
} from '@/utils/homeLayout'
const mocks = vi.hoisted(() => ({
phone: {
@@ -45,6 +49,64 @@ describe('app store', () => {
})
})
it('keeps external app state while migrating version 4 layouts', () => {
const apps = useAppStoreStore()
const grid = Array.from({ length: 24 }, () => null as string | null)
grid[20] = 'external-radio'
apps.hydrate({
claimedApps: ['external-radio'],
homeLayout: { dock: [], grid, hidden: [], version: 4 },
launchCounts: { 'external-radio': 3 },
})
expect(apps.claimedApps).toEqual(['external-radio'])
expect(apps.homeLayout.version).toBe(5)
expect(apps.homeLayout.grid).toContain('external-radio')
const pageAppCount = apps.homeLayout.grid
.slice(0, HOME_GRID_PAGE_SIZE)
.filter((appId) => appId !== null).length
expect(apps.homeLayout.grid.slice(0, pageAppCount)).not.toContain(null)
expect(apps.launchCounts).toEqual({ 'external-radio': 3 })
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('keeps external app state from current version 5 layouts', () => {
const apps = useAppStoreStore()
apps.hydrate({
claimedApps: ['external-radio'],
homeLayout: {
dock: [],
grid: ['external-radio'],
hidden: [],
version: 5,
},
launchCounts: { 'external-radio': 3 },
})
expect(apps.claimedApps).toEqual(['external-radio'])
expect(apps.homeLayout.grid).toContain('external-radio')
expect(apps.launchCounts).toEqual({ 'external-radio': 3 })
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
})
it('persists a page-aware version 3 to version 5 migration once', () => {
const apps = useAppStoreStore()
const grid = Array.from({ length: 40 }, () => null as string | null)
grid[20] = 'external-page-two'
apps.hydrate({
claimedApps: ['external-page-two'],
homeLayout: { dock: [], grid, hidden: [], version: 3 },
})
expect(apps.homeLayout.version).toBe(5)
expect(apps.homeLayout.grid[20]).not.toBe('external-page-two')
expect(apps.homeLayout.grid[24]).toBe('external-page-two')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('installs an app after showing a three second loading state', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
@@ -160,6 +222,23 @@ describe('app store', () => {
})
})
it('persists a cross-page drop exactly once', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
const sourceIndex = apps.homeLayout.grid.indexOf('phone')
expect(sourceIndex).toBeGreaterThanOrEqual(0)
expect(
apps.moveHomeAppToGridPage('grid', sourceIndex, 2, 0, [
HOME_GRID_PAGE_SIZE,
HOME_GRID_PAGE_SIZE,
]),
).toBe(true)
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('phone')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('persists the complete folder lifecycle through store actions', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
@@ -167,7 +246,6 @@ describe('app store', () => {
const notesIndex = apps.homeLayout.grid.indexOf('notes')
const clockIndex = apps.homeLayout.grid.indexOf('clock')
const mailIndex = apps.homeLayout.grid.indexOf('mail')
const folderId = apps.createHomeFolder(
'grid',
notesIndex,
@@ -181,6 +259,7 @@ describe('app store', () => {
'clock',
'notes',
])
const mailIndex = apps.homeLayout.grid.indexOf('mail')
expect(apps.addHomeAppToFolder('grid', mailIndex, folderId!)).toBe(true)
apps.moveHomeFolderApp(folderId!, 2, 0)
apps.renameHomeFolder(folderId!, 'Work')
@@ -190,10 +269,10 @@ describe('app store', () => {
})
const emptyIndex = apps.homeLayout.grid.indexOf(null)
expect(
apps.extractHomeFolderApp(folderId!, 0, 'grid', emptyIndex),
).toBe(true)
expect(apps.homeLayout.grid[emptyIndex]).toBe('mail')
expect(apps.extractHomeFolderApp(folderId!, 0, 'grid', emptyIndex)).toBe(
true,
)
expect(apps.homeLayout.grid).toContain('mail')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(5)
})
+38 -9
View File
@@ -20,6 +20,7 @@ import {
extractHomeFolderApp,
moveHomeFolderApp,
moveHomeApp,
moveHomeAppToGridPage,
parseHomeLayout,
renameHomeFolder,
removeHomeApp,
@@ -192,14 +193,14 @@ export const useAppStoreStore = defineStore('app-store', {
data?.homeLayout && typeof data.homeLayout === 'object'
? (data.homeLayout as { version?: unknown }).version
: undefined
const supportsPersistedExternalApps =
layoutVersion === 3 || layoutVersion === 4 || layoutVersion === 5
this.claimedApps = Array.isArray(data?.claimedApps)
? data.claimedApps.filter(
(id): id is LaunchablePhoneAppId =>
typeof id === 'string' &&
(isPhoneAppId(id) ||
((layoutVersion === 3 ||
layoutVersion === 4 ||
layoutVersion === 5) &&
(supportsPersistedExternalApps &&
isValidExternalPhoneAppId(id))),
)
: []
@@ -226,9 +227,7 @@ export const useAppStoreStore = defineStore('app-store', {
for (const [appId, count] of Object.entries(data.launchCounts)) {
if (
(isPhoneAppId(appId) ||
((layoutVersion === 3 ||
layoutVersion === 4 ||
layoutVersion === 5) &&
(supportsPersistedExternalApps &&
isValidExternalPhoneAppId(appId))) &&
typeof count === 'number' &&
Number.isFinite(count) &&
@@ -239,7 +238,14 @@ export const useAppStoreStore = defineStore('app-store', {
}
}
this.hydrated = true
if (protectedHiddenAppIds.length) this.persist()
if (
protectedHiddenAppIds.length ||
layoutVersion === 2 ||
layoutVersion === 3 ||
layoutVersion === 4
) {
this.persist()
}
},
isInstalled(appId: LaunchablePhoneAppId): boolean {
if (this.claimedApps.includes(appId)) return true
@@ -280,15 +286,38 @@ export const useAppStoreStore = defineStore('app-store', {
sourceIndex: number,
to: HomeArea,
targetIndex: number,
): void {
this.homeLayout = moveHomeApp(
): boolean {
const next = moveHomeApp(
this.homeLayout,
from,
sourceIndex,
to,
targetIndex,
)
if (next === this.homeLayout) return false
this.homeLayout = next
this.persist()
return true
},
moveHomeAppToGridPage(
from: HomeArea,
sourceIndex: number,
targetPage: number,
targetOffset: number,
capacities: readonly number[],
): boolean {
const next = moveHomeAppToGridPage(
this.homeLayout,
from,
sourceIndex,
targetPage,
targetOffset,
capacities,
)
if (next === this.homeLayout) return false
this.homeLayout = next
this.persist()
return true
},
createHomeFolder(
from: HomeArea,
+296 -91
View File
@@ -1,22 +1,26 @@
import { describe, expect, it } from 'vitest'
import type { LaunchablePhoneAppId } from '@/types/apps'
import {
addHomeAppToFolder,
addHomePage,
createHomeFolder,
createDefaultHomeLayout,
createHomeFolder,
deleteHomePage,
extractHomeFolderApp,
getHomeFolder,
HOME_GRID_COLUMNS,
HOME_GRID_PAGE_SIZE,
HOME_GRID_ROWS,
homeKeyboardTarget,
isHomeFolder,
MAX_HOME_GRID_PAGES,
moveHomeFolderApp,
moveHomeApp,
moveHomeAppToGridPage,
moveHomeFolderApp,
parseHomeLayout,
renameHomeFolder,
removeHomeApp,
renameHomeFolder,
restoreHomeApp,
type HomeLayout,
} from '@/utils/homeLayout'
@@ -28,10 +32,36 @@ const defaults = createDefaultHomeLayout(
['phone', 'messages', 'clock'],
)
function generatedApp(index: number): LaunchablePhoneAppId {
return `generated-app-${index}` as LaunchablePhoneAppId
}
function pageLayout(pageCounts: readonly number[]): HomeLayout {
const grid: HomeLayout['grid'] = Array.from(
{ length: pageCounts.length * HOME_GRID_PAGE_SIZE },
() => null,
)
let appIndex = 0
for (const [pageIndex, count] of pageCounts.entries()) {
for (let offset = 0; offset < count; offset += 1) {
grid[pageIndex * HOME_GRID_PAGE_SIZE + offset] = generatedApp(appIndex)
appIndex += 1
}
}
return {
dock: [generatedApp(999), null, null, null],
grid,
hidden: [],
version: 5,
}
}
describe('home layout', () => {
it('uses fixed grid and dock slots for the registry arrangement', () => {
it('uses six-row grid pages and fixed dock slots', () => {
const layout = parseHomeLayout(undefined, defaults, [...installed])
expect(HOME_GRID_PAGE_SIZE).toBe(HOME_GRID_COLUMNS * HOME_GRID_ROWS)
expect(HOME_GRID_ROWS).toBe(6)
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE)
expect(layout.grid.slice(0, 6)).toEqual([
'phone',
@@ -62,11 +92,8 @@ describe('home layout', () => {
expect(layout.version).toBe(5)
})
it('preserves explicit gaps in versioned layouts', () => {
const grid: HomeLayout['grid'] = Array.from(
{ length: 20 },
() => null,
)
it('closes gaps within a versioned page without moving apps between pages', () => {
const grid: HomeLayout['grid'] = Array.from({ length: 20 }, () => null)
grid[0] = 'phone'
grid[7] = 'mail'
@@ -81,88 +108,219 @@ describe('home layout', () => {
[...installed],
)
expect(layout.grid[0]).toBe('phone')
expect(layout.grid[1]).toBeNull()
expect(layout.grid[7]).toBe('mail')
expect(layout.grid.slice(0, 3)).toEqual(['phone', 'mail', null])
expect(layout.dock).toEqual(['messages', null, 'clock', null])
})
it('keeps valid custom-app tombstones in version 3 layouts', () => {
const grid: HomeLayout['grid'] = Array.from(
{ length: 20 },
() => null,
)
it('keeps valid custom-app tombstones while migrating version 3 layouts', () => {
const grid: HomeLayout['grid'] = Array.from({ length: 20 }, () => null)
grid[6] = 'temporarily-missing' as HomeLayout['hidden'][number]
const layout = parseHomeLayout(
{
dock: ['phone', null, null, null],
grid,
hidden: [],
version: 3,
},
{ dock: ['phone'], grid, hidden: [], version: 3 },
defaults,
[...installed],
)
expect(layout.grid[6]).toBe('temporarily-missing')
expect(layout.grid).toContain('temporarily-missing')
const pageItemCount = layout.grid
.slice(0, HOME_GRID_PAGE_SIZE)
.filter((item) => item !== null).length
expect(layout.grid.slice(0, pageItemCount)).not.toContain(null)
expect(layout.version).toBe(5)
})
it('expands version 3 capacity without compacting persisted gaps', () => {
it('preserves page membership while expanding version 3 pages', () => {
const grid: HomeLayout['grid'] = Array.from({ length: 40 }, () => null)
grid[19] = 'mail'
grid[20] = 'notes'
grid[0] = 'phone'
grid[19] = 'messages'
grid[20] = 'mail'
grid[39] = 'clock'
const layout = parseHomeLayout(
{
dock: ['phone', null, null, null],
grid,
hidden: [],
version: 3,
},
{ dock: [], grid, hidden: [], version: 3 },
defaults,
[...installed],
)
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
expect(layout.grid[19]).toBe('mail')
expect(layout.grid[20]).toBe('notes')
expect(layout.grid.slice(21, 24)).toEqual([null, null, null])
expect(layout.grid.slice(0, 3)).toEqual(['phone', 'messages', 'notes'])
expect(layout.grid[HOME_GRID_PAGE_SIZE]).toBe('mail')
expect(layout.grid[HOME_GRID_PAGE_SIZE + 1]).toBe('clock')
expect(layout.version).toBe(5)
})
it('reads version 4 pages directly before migrating the schema', () => {
const grid: HomeLayout['grid'] = Array.from(
{ length: HOME_GRID_PAGE_SIZE * 2 },
() => null,
)
grid[20] = 'phone'
grid[HOME_GRID_PAGE_SIZE] = 'messages'
const layout = parseHomeLayout(
{ dock: [], grid, hidden: [], version: 4 },
defaults,
[...installed],
)
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
expect(layout.grid[0]).toBe('phone')
expect(layout.grid[HOME_GRID_PAGE_SIZE]).toBe('messages')
expect(layout.version).toBe(5)
})
it('preserves folders and page membership in version 5 layouts', () => {
const grid: HomeLayout['grid'] = Array.from(
{ length: HOME_GRID_PAGE_SIZE * 2 },
() => null,
)
grid[8] = {
apps: ['mail', 'notes'],
id: 'folder-work-123456',
name: 'Work',
type: 'folder',
}
grid[HOME_GRID_PAGE_SIZE + 7] = 'clock'
const layout = parseHomeLayout(
{ dock: ['phone'], grid, hidden: [], version: 5 },
defaults,
[...installed],
)
expect(getHomeFolder(layout, 'folder-work-123456')?.apps).toEqual([
'mail',
'notes',
])
expect(layout.grid[HOME_GRID_PAGE_SIZE]).toBe('clock')
expect(layout.version).toBe(5)
})
it('preserves independently positioned shortcuts for the same app', () => {
const grid: HomeLayout['grid'] = Array.from(
{ length: 20 },
() => null,
)
const grid: HomeLayout['grid'] = Array.from({ length: 20 }, () => null)
grid[0] = 'phone'
grid[5] = 'phone'
const layout = parseHomeLayout(
{
dock: ['phone', null, null, null],
grid,
hidden: [],
version: 2,
},
{ dock: ['phone'], grid, hidden: [], version: 2 },
defaults,
[...installed],
)
expect(layout.grid[0]).toBe('phone')
expect(layout.grid[5]).toBe('phone')
expect(layout.grid[1]).toBe('phone')
expect(layout.dock[0]).toBe('phone')
})
it('moves to an exact empty slot without compacting other apps', () => {
it('inserts into an empty area and closes the source gap', () => {
const moved = moveHomeApp(defaults, 'grid', 2, 'grid', 12)
expect(moved.grid[2]).toBeNull()
expect(moved.grid[12]).toBe('mail')
expect(moved.grid[0]).toBe('phone')
expect(moved.grid[4]).toBe('notes')
expect(moved.grid.slice(0, 6)).toEqual([
'phone',
'messages',
'clock',
'notes',
'mail',
null,
])
})
it('extends the layout when moving an item onto an empty new page', () => {
const targetIndex = HOME_GRID_PAGE_SIZE + 20
const moved = moveHomeApp(defaults, 'grid', 0, 'grid', targetIndex)
expect(moved.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
expect(moved.grid.slice(0, 5)).toEqual([
'messages',
'mail',
'clock',
'notes',
null,
])
expect(moved.grid[HOME_GRID_PAGE_SIZE]).toBe('phone')
})
it.each([20, 21, 23])(
'fills a target page containing %s apps without swapping one backwards',
(targetCount) => {
const layout = pageLayout([2, targetCount])
const dragged = layout.grid[0]
const originalTarget = layout.grid
.slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2)
.filter((item) => item !== null)
const moved = moveHomeAppToGridPage(layout, 'grid', 0, 2, targetCount)
expect(moved.grid[0]).toBe(layout.grid[1])
expect(
moved.grid
.slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2)
.filter((item) => item !== null),
).toEqual([...originalTarget, dragged])
},
)
it('cascades a full target page forward into a newly created page', () => {
const layout = pageLayout([2, HOME_GRID_PAGE_SIZE])
const dragged = layout.grid[0]
const displaced = layout.grid[HOME_GRID_PAGE_SIZE * 2 - 1]
const moved = moveHomeAppToGridPage(layout, 'grid', 0, 2, 0)
expect(moved.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 3)
expect(moved.grid.slice(0, 2)).toEqual([layout.grid[1], null])
expect(moved.grid[HOME_GRID_PAGE_SIZE]).toBe(dragged)
expect(moved.grid[HOME_GRID_PAGE_SIZE * 2]).toBe(displaced)
})
it('uses a later source-page gap for forward overflow', () => {
const layout = pageLayout([HOME_GRID_PAGE_SIZE, 2])
const draggedIndex = HOME_GRID_PAGE_SIZE
const dragged = layout.grid[draggedIndex]
const displaced = layout.grid[HOME_GRID_PAGE_SIZE - 1]
const remainingSecondPageItem = layout.grid[draggedIndex + 1]
const moved = moveHomeAppToGridPage(layout, 'grid', draggedIndex, 1, 4)
expect(moved.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
expect(moved.grid[4]).toBe(dragged)
expect(moved.grid[HOME_GRID_PAGE_SIZE]).toBe(displaced)
expect(moved.grid[HOME_GRID_PAGE_SIZE + 1]).toBe(remainingSecondPageItem)
})
it('rejects a dock insertion atomically when all pages are full', () => {
const layout = pageLayout(
Array.from({ length: MAX_HOME_GRID_PAGES }, () => HOME_GRID_PAGE_SIZE),
)
expect(moveHomeAppToGridPage(layout, 'dock', 0, 1, 0)).toBe(layout)
})
it('moves a dock app onto another page and preserves every other item', () => {
const layout = pageLayout([3, 2])
const dockApp = layout.dock[0]
const before = [...layout.grid.filter(Boolean), dockApp].sort()
const moved = moveHomeAppToGridPage(layout, 'dock', 0, 2, 1)
expect(moved.dock[0]).toBeNull()
expect(moved.grid[HOME_GRID_PAGE_SIZE + 1]).toBe(dockApp)
expect(moved.grid.filter(Boolean).sort()).toEqual(before)
})
it('respects widget-reduced capacities while cascading forward', () => {
const layout = pageLayout([HOME_GRID_PAGE_SIZE])
const moved = moveHomeAppToGridPage(layout, 'grid', 0, 1, 0, [
20,
HOME_GRID_PAGE_SIZE,
])
expect(moved.grid.slice(0, 20).filter(Boolean)).toHaveLength(20)
expect(
moved.grid
.slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2)
.filter(Boolean),
).toHaveLength(4)
expect(moved.grid.filter(Boolean).sort()).toEqual(
layout.grid.filter(Boolean).sort(),
)
})
it('provides bounded keyboard reorder targets without wrapping rows', () => {
@@ -174,33 +332,40 @@ describe('home layout', () => {
expect(homeKeyboardTarget(defaults, 'dock', 1, 'down')).toBeNull()
})
it('swaps occupied grid slots without moving unrelated apps', () => {
it('shifts occupied grid slots instead of replacing their items', () => {
const reordered = moveHomeApp(defaults, 'grid', 2, 'grid', 0)
expect(reordered.grid.slice(0, 5)).toEqual([
'mail',
'messages',
'phone',
'messages',
'clock',
'notes',
])
})
it('swaps an occupied dock slot with the exact grid source', () => {
it('shifts an occupied dock slot toward its gap', () => {
const docked = moveHomeApp(defaults, 'grid', 2, 'dock', 2)
expect(docked.dock).toEqual(['phone', 'messages', 'mail', null])
expect(docked.grid[2]).toBe('clock')
expect(docked.dock).toEqual(['phone', 'messages', 'mail', 'clock'])
expect(docked.grid.slice(0, 5)).toEqual([
'phone',
'messages',
'clock',
'notes',
null,
])
})
it('swaps with an app in a full dock without shifting the dock', () => {
it('moves a dock item displaced from a full dock into the source slot', () => {
const layout: HomeLayout = {
...defaults,
dock: ['phone', 'messages', 'clock', 'notes'],
}
const docked = moveHomeApp(layout, 'grid', 2, 'dock', 1)
expect(docked.dock).toEqual(['phone', 'mail', 'clock', 'notes'])
expect(docked.grid[2]).toBe('messages')
expect(docked.dock).toEqual(['phone', 'mail', 'messages', 'clock'])
expect(docked.grid[2]).toBe('notes')
})
it('moves shortcuts between the dock and grid independently', () => {
@@ -211,21 +376,29 @@ describe('home layout', () => {
expect(movedToGrid.grid[5]).toBe('phone')
const movedToDock = moveHomeApp(movedToGrid, 'grid', 1, 'dock', 3)
expect(movedToDock.grid[1]).toBeNull()
expect(movedToDock.grid.slice(0, 6)).toEqual([
'phone',
'mail',
'clock',
'notes',
'phone',
null,
])
expect(movedToDock.dock[1]).toBe('messages')
expect(movedToDock.dock[3]).toBe('messages')
})
it('removes shortcuts without closing gaps and restores the first gap', () => {
const layout: HomeLayout = moveHomeApp(defaults, 'grid', 0, 'grid', 10)
it('removes and restores shortcuts without leaving a page gap', () => {
const layout = moveHomeApp(defaults, 'grid', 0, 'grid', 10)
const removed = removeHomeApp(layout, 'phone')
expect(removed.grid[0]).toBeNull()
expect(removed.grid[10]).toBeNull()
expect(removed.grid).not.toContain('phone')
expect(removed.dock[0]).toBeNull()
expect(removed.hidden).toContain('phone')
const restored = restoreHomeApp(removed, 'phone')
expect(restored.grid[0]).toBe('phone')
expect(restored.grid[4]).toBe('phone')
expect(restored.grid.slice(0, 5)).not.toContain(null)
expect(restored.hidden).not.toContain('phone')
})
@@ -239,7 +412,7 @@ describe('home layout', () => {
expect(addHomePage(layout)).toBe(layout)
})
it('deletes a page and moves its apps into remaining empty slots', () => {
it('deletes a page and moves its items into remaining empty slots', () => {
let layout = addHomePage(defaults)
layout = moveHomeApp(layout, 'grid', 0, 'grid', HOME_GRID_PAGE_SIZE)
const deleted = deleteHomePage(layout, 2)
@@ -249,7 +422,7 @@ describe('home layout', () => {
expect(deleteHomePage(deleted, 1)).toBe(deleted)
})
it('creates, renames, and persists a folder without moving other slots', () => {
it('creates, renames, and parses a folder with compact pages', () => {
const folderLayout = createHomeFolder(
defaults,
'grid',
@@ -259,21 +432,21 @@ describe('home layout', () => {
'folder-work-123456',
'Work',
)
const folder = folderLayout.grid[4]
const folder = getHomeFolder(folderLayout, 'folder-work-123456')
expect(folderLayout.grid[2]).toBeNull()
expect(isHomeFolder(folder) && folder.apps).toEqual(['notes', 'mail'])
expect(folderLayout.grid[0]).toBe('phone')
expect(folder?.apps).toEqual(['notes', 'mail'])
expect(folderLayout.grid.slice(0, 4)).toEqual([
'phone',
'messages',
'clock',
folder,
])
const renamed = renameHomeFolder(
folderLayout,
'folder-work-123456',
' Dienstprogramme ',
)
expect(getHomeFolder(renamed, 'folder-work-123456')?.name).toBe(
'Dienstprogramme',
)
const parsed = parseHomeLayout(renamed, defaults, [...installed])
expect(parsed.version).toBe(5)
expect(getHomeFolder(parsed, 'folder-work-123456')).toEqual({
@@ -284,7 +457,7 @@ describe('home layout', () => {
})
})
it('adds apps to a folder and swaps apps inside its 3x3 pages', () => {
it('adds apps to a folder and swaps apps inside it', () => {
const folderLayout = createHomeFolder(
defaults,
'grid',
@@ -294,25 +467,20 @@ describe('home layout', () => {
'folder-tools-123456',
'Tools',
)
const clockIndex = folderLayout.grid.indexOf('clock')
const expanded = addHomeAppToFolder(
folderLayout,
'grid',
3,
clockIndex,
'folder-tools-123456',
)
expect(expanded.grid[3]).toBeNull()
expect(getHomeFolder(expanded, 'folder-tools-123456')?.apps).toEqual([
'notes',
'mail',
'clock',
])
const reordered = moveHomeFolderApp(
expanded,
'folder-tools-123456',
2,
0,
)
const reordered = moveHomeFolderApp(expanded, 'folder-tools-123456', 2, 0)
expect(getHomeFolder(reordered, 'folder-tools-123456')?.apps).toEqual([
'clock',
'mail',
@@ -320,7 +488,7 @@ describe('home layout', () => {
])
})
it('extracts a folder app into an exact gap and dissolves one-app folders', () => {
it('extracts a folder app and dissolves one-app folders', () => {
const folderLayout = createHomeFolder(
defaults,
'grid',
@@ -338,12 +506,17 @@ describe('home layout', () => {
12,
)
expect(extracted.grid[12]).toBe('mail')
expect(extracted.grid[4]).toBe('notes')
expect(extracted.grid.slice(0, 5)).toEqual([
'phone',
'messages',
'clock',
'notes',
'mail',
])
expect(getHomeFolder(extracted, 'folder-social-123456')).toBeNull()
})
it('removes hidden apps from folders and dissolves the folder when needed', () => {
it('removes hidden apps from folders and dissolves the folder', () => {
const folderLayout = createHomeFolder(
defaults,
'grid',
@@ -355,7 +528,39 @@ describe('home layout', () => {
)
const removed = removeHomeApp(folderLayout, 'mail')
expect(removed.grid[4]).toBe('notes')
expect(removed.grid.slice(0, 4)).toEqual([
'phone',
'messages',
'clock',
'notes',
])
expect(removed.hidden).toContain('mail')
})
it('moves folders as one item without crossing page boundaries', () => {
const folderLayout = createHomeFolder(
defaults,
'grid',
2,
'grid',
4,
'folder-page-123456',
'Page two',
)
const folderIndex = folderLayout.grid.findIndex(isHomeFolder)
const moved = moveHomeApp(
folderLayout,
'grid',
folderIndex,
'grid',
HOME_GRID_PAGE_SIZE + 8,
)
expect(moved.grid.slice(0, 4)).toEqual(['phone', 'messages', 'clock', null])
const movedFolder = moved.grid[HOME_GRID_PAGE_SIZE]
expect(isHomeFolder(movedFolder)).toBe(true)
expect(isHomeFolder(movedFolder) && movedFolder.id).toBe(
'folder-page-123456',
)
})
})
+351 -48
View File
@@ -3,10 +3,12 @@ import type { ReorderDirection } from '@/utils/keyboard'
export const HOME_DOCK_CAPACITY = 4
export const HOME_GRID_COLUMNS = 4
export const HOME_GRID_PAGE_SIZE = 24
export const HOME_GRID_ROWS = 6
export const HOME_GRID_PAGE_SIZE = HOME_GRID_COLUMNS * HOME_GRID_ROWS
export const HOME_FOLDER_PAGE_SIZE = 9
export const HOME_FOLDER_NAME_MAX_LENGTH = 32
export const MAX_HOME_GRID_PAGES = 5
export const HOME_LAYOUT_VERSION = 5
const LEGACY_HOME_GRID_PAGE_SIZE = 20
export type HomeArea = 'dock' | 'grid'
@@ -25,9 +27,11 @@ export type HomeLayout = {
dock: HomeSlot[]
grid: HomeSlot[]
hidden: LaunchablePhoneAppId[]
version: 5
version: typeof HOME_LAYOUT_VERSION
}
export type HomeGridPageCapacities = readonly number[]
export function isHomeFolder(value: unknown): value is HomeFolder {
if (!value || typeof value !== 'object') return false
const folder = value as Partial<HomeFolder>
@@ -77,11 +81,6 @@ function getGridCapacity(itemCount: number): number {
)
}
function migrateLegacyGrid(value: unknown): unknown[] {
if (!Array.isArray(value)) return []
return value.slice(0, LEGACY_HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
}
function cloneItem(item: HomeSlot): HomeSlot {
return isHomeFolder(item) ? { ...item, apps: [...item.apps] } : item
}
@@ -91,7 +90,7 @@ function cloneLayout(layout: HomeLayout): HomeLayout {
dock: layout.dock.map(cloneItem),
grid: layout.grid.map(cloneItem),
hidden: [...layout.hidden],
version: 5,
version: HOME_LAYOUT_VERSION,
}
}
@@ -105,6 +104,7 @@ function readItem(
value: unknown,
availableIds: Set<LaunchablePhoneAppId>,
folderIds: Set<string>,
allowFolders: boolean,
): HomeSlot {
if (
typeof value === 'string' &&
@@ -112,8 +112,14 @@ function readItem(
) {
return value as LaunchablePhoneAppId
}
if (!isHomeFolder(value) || !isPersistableFolderId(value.id)) return null
if (folderIds.has(value.id)) return null
if (
!allowFolders ||
!isHomeFolder(value) ||
!isPersistableFolderId(value.id) ||
folderIds.has(value.id)
) {
return null
}
const apps = readAppIds(value.apps, availableIds)
if (apps.length === 0) return null
@@ -132,12 +138,45 @@ function readSlots(
availableIds: Set<LaunchablePhoneAppId>,
length: number,
folderIds: Set<string>,
allowFolders: boolean,
): HomeSlot[] {
const slots = createSlots(length)
if (!Array.isArray(value)) return slots
for (let index = 0; index < Math.min(value.length, length); index += 1) {
slots[index] = readItem(value[index], availableIds, folderIds)
slots[index] = readItem(value[index], availableIds, folderIds, allowFolders)
}
return slots
}
function migrateLegacyGrid(
value: unknown,
availableIds: Set<LaunchablePhoneAppId>,
): HomeSlot[] {
if (!Array.isArray(value)) return createSlots(HOME_GRID_PAGE_SIZE)
const legacyLength = Math.min(
value.length,
LEGACY_HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES,
)
const pageCount = Math.max(
1,
Math.ceil(legacyLength / LEGACY_HOME_GRID_PAGE_SIZE),
)
const slots = createSlots(pageCount * HOME_GRID_PAGE_SIZE)
for (let index = 0; index < legacyLength; index += 1) {
const valueId = value[index]
if (
typeof valueId !== 'string' ||
!availableIds.has(valueId as LaunchablePhoneAppId)
) {
continue
}
const page = Math.floor(index / LEGACY_HOME_GRID_PAGE_SIZE)
const pageIndex = index % LEGACY_HOME_GRID_PAGE_SIZE
slots[page * HOME_GRID_PAGE_SIZE + pageIndex] =
valueId as LaunchablePhoneAppId
}
return slots
}
@@ -153,6 +192,42 @@ function placeInFirstEmptySlot(slots: HomeSlot[], item: HomeItem): void {
slots[slots.length - HOME_GRID_PAGE_SIZE] = cloneItem(item)
}
function insertIntoSlot(
slots: HomeSlot[],
targetIndex: number,
item: HomeItem,
): HomeSlot {
if (slots[targetIndex] === null) {
slots[targetIndex] = cloneItem(item)
return null
}
const emptyAfter = slots.indexOf(null, targetIndex + 1)
if (emptyAfter !== -1) {
for (let index = emptyAfter; index > targetIndex; index -= 1) {
slots[index] = slots[index - 1]
}
slots[targetIndex] = cloneItem(item)
return null
}
const emptyBefore = slots.lastIndexOf(null, targetIndex - 1)
if (emptyBefore !== -1) {
for (let index = emptyBefore; index < targetIndex; index += 1) {
slots[index] = slots[index + 1]
}
slots[targetIndex] = cloneItem(item)
return null
}
const displacedItem = cloneItem(slots.at(-1) ?? null)
for (let index = slots.length - 1; index > targetIndex; index -= 1) {
slots[index] = slots[index - 1]
}
slots[targetIndex] = cloneItem(item)
return displacedItem
}
function itemContainsApp(item: HomeSlot, appId: LaunchablePhoneAppId): boolean {
return isHomeFolder(item) ? item.apps.includes(appId) : item === appId
}
@@ -180,6 +255,177 @@ export function getHomeFolder(
return isHomeFolder(item) ? item : null
}
function readPageItems(slots: HomeSlot[], pageStart: number): HomeItem[] {
return slots
.slice(pageStart, pageStart + HOME_GRID_PAGE_SIZE)
.filter((item): item is HomeItem => item !== null)
.map((item) => cloneItem(item) as HomeItem)
}
function writePageItems(
slots: HomeSlot[],
pageStart: number,
items: HomeItem[],
): void {
for (let offset = 0; offset < HOME_GRID_PAGE_SIZE; offset += 1) {
slots[pageStart + offset] = cloneItem(items[offset] ?? null)
}
}
function compactGridPages(slots: HomeSlot[]): HomeSlot[] {
const compacted = slots.map(cloneItem)
for (
let pageStart = 0;
pageStart < compacted.length;
pageStart += HOME_GRID_PAGE_SIZE
) {
writePageItems(compacted, pageStart, readPageItems(compacted, pageStart))
}
return compacted
}
type IndexedHomeItem = {
item: HomeItem
sourceIndex: number
}
function gridPageCapacity(
capacities: HomeGridPageCapacities,
page: number,
): number {
const capacity = capacities[page - 1]
return Number.isFinite(capacity)
? Math.max(0, Math.min(HOME_GRID_PAGE_SIZE, Math.trunc(capacity)))
: HOME_GRID_PAGE_SIZE
}
function distributeGridPages(
slots: readonly HomeSlot[],
capacities: HomeGridPageCapacities,
minimumPageCount: number,
): IndexedHomeItem[][] | null {
const persistedPageCount = Math.max(
1,
Math.ceil(slots.length / HOME_GRID_PAGE_SIZE),
)
const pages: IndexedHomeItem[][] = []
let incoming: IndexedHomeItem[] = []
let pageCount = Math.max(persistedPageCount, minimumPageCount)
for (let page = 1; page <= pageCount; page += 1) {
const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE
const persisted = slots
.slice(pageStart, pageStart + HOME_GRID_PAGE_SIZE)
.flatMap((item, offset) =>
item === null
? []
: [
{
item: cloneItem(item) as HomeItem,
sourceIndex: pageStart + offset,
},
],
)
const available = [...incoming, ...persisted]
const capacity = gridPageCapacity(capacities, page)
pages.push(available.slice(0, capacity))
incoming = available.slice(capacity)
if (
page === pageCount &&
incoming.length &&
pageCount < MAX_HOME_GRID_PAGES
) {
pageCount += 1
}
}
return incoming.length ? null : pages
}
function serializeGridPages(pages: readonly IndexedHomeItem[][]): HomeSlot[] {
const slots = createSlots(Math.max(1, pages.length) * HOME_GRID_PAGE_SIZE)
for (const [pageIndex, entries] of pages.entries()) {
const pageStart = pageIndex * HOME_GRID_PAGE_SIZE
for (const [offset, entry] of entries.entries()) {
slots[pageStart + offset] = cloneItem(entry.item)
}
}
return slots
}
export function moveHomeAppToGridPage(
layout: HomeLayout,
from: HomeArea,
sourceIndex: number,
targetPage: number,
targetOffset: number,
capacities: HomeGridPageCapacities = [],
): HomeLayout {
const sourceSlots = layout[from]
const sourceItem = sourceSlots[sourceIndex]
if (
sourceItem === null ||
sourceItem === undefined ||
sourceIndex < 0 ||
sourceIndex >= sourceSlots.length ||
!Number.isInteger(targetPage) ||
targetPage < 1 ||
targetPage > MAX_HOME_GRID_PAGES ||
!Number.isFinite(targetOffset) ||
gridPageCapacity(capacities, targetPage) === 0
) {
return layout
}
const pages = distributeGridPages(layout.grid, capacities, targetPage)
if (!pages) return layout
let sourcePage = -1
if (from === 'grid') {
for (const [pageIndex, entries] of pages.entries()) {
const entryIndex = entries.findIndex(
(entry) => entry.sourceIndex === sourceIndex,
)
if (entryIndex !== -1) {
sourcePage = pageIndex + 1
entries.splice(entryIndex, 1)
break
}
}
if (sourcePage === -1) return layout
}
while (pages.length < targetPage) pages.push([])
const targetEntries = pages[targetPage - 1]
const requestedOffset = Math.max(0, Math.trunc(targetOffset))
targetEntries.splice(Math.min(requestedOffset, targetEntries.length), 0, {
item: cloneItem(sourceItem) as HomeItem,
sourceIndex,
})
for (let page = targetPage; page <= pages.length; page += 1) {
const entries = pages[page - 1]
const capacity = gridPageCapacity(capacities, page)
if (entries.length <= capacity) continue
const overflow = entries.splice(capacity)
if (page >= MAX_HOME_GRID_PAGES) return layout
if (!pages[page]) pages.push([])
pages[page].unshift(...overflow)
}
const next = cloneLayout(layout)
next.grid = serializeGridPages(pages)
if (from === 'dock') next.dock[sourceIndex] = null
if (
JSON.stringify(next.grid) === JSON.stringify(layout.grid) &&
JSON.stringify(next.dock) === JSON.stringify(layout.dock)
) {
return layout
}
return next
}
export function createDefaultHomeLayout(
installedIds: LaunchablePhoneAppId[],
defaultGridIds: LaunchablePhoneAppId[],
@@ -200,7 +446,7 @@ export function createDefaultHomeLayout(
dock[index] = id
}
return { dock, grid, hidden: [], version: 5 }
return { dock, grid, hidden: [], version: HOME_LAYOUT_VERSION }
}
export function parseHomeLayout(
@@ -227,12 +473,16 @@ export function parseHomeLayout(
}
const hidden = readAppIds(source.hidden, availableIds)
const hiddenIds = new Set(hidden)
const persistedGrid =
source.version === 2 || source.version === 3
? migrateLegacyGrid(source.grid)
: source.grid
const persistedGridLength = Array.isArray(persistedGrid)
? Math.min(persistedGrid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
const isLegacyVersionedLayout = source.version === 2 || source.version === 3
const persistedGridLength = Array.isArray(source.grid)
? isLegacyVersionedLayout
? Math.ceil(
Math.min(
source.grid.length,
LEGACY_HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES,
) / LEGACY_HOME_GRID_PAGE_SIZE,
) * HOME_GRID_PAGE_SIZE
: Math.min(source.grid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
: 0
const gridLength = Math.max(
defaults.grid.length,
@@ -241,15 +491,35 @@ export function parseHomeLayout(
let grid: HomeSlot[]
let dock: HomeSlot[]
if (
source.version === 2 ||
source.version === 3 ||
source.version === 4 ||
source.version === 5
) {
if (isLegacyVersionedLayout) {
grid = migrateLegacyGrid(source.grid, availableIds)
if (grid.length < gridLength) {
grid.push(...createSlots(gridLength - grid.length))
}
dock = readSlots(
source.dock,
availableIds,
HOME_DOCK_CAPACITY,
new Set(),
false,
)
} else if (source.version === 4 || source.version === 5) {
const folderIds = new Set<string>()
grid = readSlots(persistedGrid, availableIds, gridLength, folderIds)
dock = readSlots(source.dock, availableIds, HOME_DOCK_CAPACITY, folderIds)
const allowFolders = source.version === 5
grid = readSlots(
source.grid,
availableIds,
gridLength,
folderIds,
allowFolders,
)
dock = readSlots(
source.dock,
availableIds,
HOME_DOCK_CAPACITY,
folderIds,
allowFolders,
)
} else {
grid = createSlots(gridLength)
for (const id of readAppIds(source.grid, availableIds)) {
@@ -271,8 +541,9 @@ export function parseHomeLayout(
apps: item.apps.filter((appId) => !hiddenIds.has(appId)),
})
}
grid = grid.map(removeHidden)
grid = compactGridPages(grid.map(removeHidden))
dock = dock.map(removeHidden)
const placedIds = new Set<LaunchablePhoneAppId>(hidden)
for (const item of [...grid, ...dock]) {
if (typeof item === 'string') placedIds.add(item)
@@ -288,7 +559,12 @@ export function parseHomeLayout(
}
}
return { dock, grid, hidden, version: 5 }
return {
dock: dock.map(cloneItem),
grid: compactGridPages(grid),
hidden,
version: HOME_LAYOUT_VERSION,
}
}
export function removeHomeApp(
@@ -297,7 +573,7 @@ export function removeHomeApp(
): HomeLayout {
const removeFromItem = (item: HomeSlot): HomeSlot => {
if (item === appId) return null
if (!isHomeFolder(item)) return item
if (!isHomeFolder(item)) return cloneItem(item)
return normalizeFolder({
...item,
apps: item.apps.filter((folderAppId) => folderAppId !== appId),
@@ -305,11 +581,11 @@ export function removeHomeApp(
}
return {
dock: layout.dock.map(removeFromItem),
grid: layout.grid.map(removeFromItem),
grid: compactGridPages(layout.grid.map(removeFromItem)),
hidden: layout.hidden.includes(appId)
? [...layout.hidden]
: [...layout.hidden, appId],
version: 5,
version: HOME_LAYOUT_VERSION,
}
}
@@ -327,9 +603,9 @@ export function restoreHomeApp(
placeInFirstEmptySlot(grid, appId)
return {
dock: layout.dock.map(cloneItem),
grid,
grid: compactGridPages(grid),
hidden: layout.hidden.filter((id) => id !== appId),
version: 5,
version: HOME_LAYOUT_VERSION,
}
}
@@ -342,7 +618,7 @@ export function addHomePage(layout: HomeLayout): HomeLayout {
dock: layout.dock.map(cloneItem),
grid: [...layout.grid.map(cloneItem), ...createSlots(HOME_GRID_PAGE_SIZE)],
hidden: [...layout.hidden],
version: 5,
version: HOME_LAYOUT_VERSION,
}
}
@@ -362,9 +638,9 @@ export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout {
return {
dock: layout.dock.map(cloneItem),
grid,
grid: compactGridPages(grid),
hidden: [...layout.hidden],
version: 5,
version: HOME_LAYOUT_VERSION,
}
}
@@ -375,23 +651,46 @@ export function moveHomeApp(
to: HomeArea,
targetIndex: number,
): HomeLayout {
const next = cloneLayout(layout)
const source = next[from]
const target = next[to]
const item = source[sourceIndex]
const sourceSlots = layout[from]
const item = sourceSlots[sourceIndex]
const maximumTargetIndex =
to === 'grid'
? HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES
: HOME_DOCK_CAPACITY
if (
!item ||
item === null ||
item === undefined ||
sourceIndex < 0 ||
sourceIndex >= source.length ||
sourceIndex >= sourceSlots.length ||
targetIndex < 0 ||
targetIndex >= target.length
targetIndex >= maximumTargetIndex ||
(from === to && sourceIndex === targetIndex)
) {
return layout
}
if (from === to && sourceIndex === targetIndex) return layout
source[sourceIndex] = cloneItem(target[targetIndex])
target[targetIndex] = item
if (to === 'grid') {
return moveHomeAppToGridPage(
layout,
from,
sourceIndex,
Math.floor(targetIndex / HOME_GRID_PAGE_SIZE) + 1,
targetIndex % HOME_GRID_PAGE_SIZE,
)
}
const next = cloneLayout(layout)
const source = next[from]
const target = next[to]
if (from === to) {
source[sourceIndex] = null
insertIntoSlot(source, targetIndex, item)
return next
}
source[sourceIndex] = insertIntoSlot(target, targetIndex, item)
next.grid = compactGridPages(next.grid)
return next
}
@@ -404,8 +703,9 @@ export function createHomeFolder(
folderId: string,
name: string,
): HomeLayout {
if (!isPersistableFolderId(folderId)) return layout
if (folderLocation(layout, folderId)) return layout
if (!isPersistableFolderId(folderId) || folderLocation(layout, folderId)) {
return layout
}
const sourceItem = layout[from][sourceIndex]
const targetItem = layout[to][targetIndex]
if (
@@ -424,6 +724,7 @@ export function createHomeFolder(
name: name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH),
type: 'folder',
}
next.grid = compactGridPages(next.grid)
return next
}
@@ -443,6 +744,7 @@ export function addHomeAppToFolder(
if (!isHomeFolder(folder)) return layout
next[from][sourceIndex] = null
folder.apps.push(sourceItem)
next.grid = compactGridPages(next.grid)
return next
}
@@ -522,6 +824,7 @@ export function extractHomeFolderApp(
const [appId] = nextFolder.apps.splice(sourceIndex, 1)
next[to][targetIndex] = appId
next[location.area][location.index] = normalizeFolder(nextFolder)
next.grid = compactGridPages(next.grid)
return next
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import {
maximumRenderedWidgetPage,
resolveSpringboardEdgeTurn,
resolveSpringboardHomeEdgeTurn,
springboardEdgeDirection,
springboardPageDragCompensation,
springboardSwipeIntent,
} from '@/utils/springboardDrag'
describe('springboard widget drag', () => {
it('turns pages from the reachable phone edge zones', () => {
expect(springboardEdgeDirection(113, 100, 468)).toBe(-1)
expect(springboardEdgeDirection(455, 100, 468)).toBe(1)
expect(springboardEdgeDirection(284, 100, 468)).toBe(0)
})
it('resolves reachable page destinations without crossing page bounds', () => {
expect(resolveSpringboardEdgeTurn(455, 100, 468, 1, 0, 3)).toEqual({
destination: 2,
direction: 1,
})
expect(resolveSpringboardEdgeTurn(113, 100, 468, 2, 0, 3)).toEqual({
destination: 1,
direction: -1,
})
expect(resolveSpringboardEdgeTurn(113, 100, 468, 0, 0, 3)).toBeNull()
expect(resolveSpringboardEdgeTurn(455, 100, 468, 3, 0, 3)).toBeNull()
})
it('previews one new trailing page without crossing the home-page limit', () => {
expect(
resolveSpringboardHomeEdgeTurn(455, 100, 468, 2, 2, 5, false),
).toEqual({ destination: 3, direction: 1, previewsPage: true })
expect(
resolveSpringboardHomeEdgeTurn(455, 100, 468, 2, 3, 5, true),
).toEqual({ destination: 3, direction: 1, previewsPage: false })
expect(
resolveSpringboardHomeEdgeTurn(455, 100, 468, 5, 5, 5, false),
).toBeNull()
})
it('keeps the original widget page mounted during a drag preview', () => {
expect(maximumRenderedWidgetPage([1, 3], [1, 2])).toBe(3)
expect(maximumRenderedWidgetPage([], [])).toBe(1)
})
it('distinguishes a page swipe from a tap or vertical gesture', () => {
expect(springboardSwipeIntent(5, 2)).toBe('pending')
expect(springboardSwipeIntent(30, 8)).toBe('horizontal')
expect(springboardSwipeIntent(8, 30)).toBe('vertical')
})
it('keeps a dragged item under the pointer while the track changes pages', () => {
expect(springboardPageDragCompensation(3, 2, 368)).toBe(-368)
expect(springboardPageDragCompensation(2, 3, 368)).toBe(368)
expect(springboardPageDragCompensation(2, 2, 368)).toBe(0)
})
})
+91
View File
@@ -0,0 +1,91 @@
export type PageTurnDirection = -1 | 0 | 1
export type SpringboardEdgeTurn = {
destination: number
direction: Exclude<PageTurnDirection, 0>
}
export type SpringboardHomeEdgeTurn = SpringboardEdgeTurn & {
previewsPage: boolean
}
export type SpringboardSwipeIntent = 'horizontal' | 'pending' | 'vertical'
export function springboardPageDragCompensation(
startPage: number,
currentPage: number,
pageWidth: number,
): number {
return (currentPage - startPage) * Math.max(0, pageWidth)
}
export function springboardSwipeIntent(
deltaX: number,
deltaY: number,
threshold = 8,
): SpringboardSwipeIntent {
if (Math.hypot(deltaX, deltaY) <= threshold) return 'pending'
return Math.abs(deltaX) > Math.abs(deltaY) ? 'horizontal' : 'vertical'
}
export function springboardEdgeDirection(
clientX: number,
left: number,
right: number,
): PageTurnDirection {
const width = Math.max(0, right - left)
const edgeSize = Math.min(42, width * 0.12)
if (clientX <= left + edgeSize) return -1
if (clientX >= right - edgeSize) return 1
return 0
}
export function resolveSpringboardEdgeTurn(
clientX: number,
left: number,
right: number,
currentPage: number,
minimumPage: number,
maximumPage: number,
): SpringboardEdgeTurn | null {
const direction = springboardEdgeDirection(clientX, left, right)
if (direction === 0) return null
const destination = currentPage + direction
if (destination < minimumPage || destination > maximumPage) return null
return { destination, direction }
}
export function resolveSpringboardHomeEdgeTurn(
clientX: number,
left: number,
right: number,
currentPage: number,
renderedPageCount: number,
maximumPageCount: number,
previewPageActive: boolean,
): SpringboardHomeEdgeTurn | null {
const canPreviewPage =
!previewPageActive && renderedPageCount < maximumPageCount
const maximumPage = Math.min(
maximumPageCount,
renderedPageCount + (canPreviewPage ? 1 : 0),
)
const turn = resolveSpringboardEdgeTurn(
clientX,
left,
right,
currentPage,
1,
maximumPage,
)
return turn
? { ...turn, previewsPage: turn.destination > renderedPageCount }
: null
}
export function maximumRenderedWidgetPage(
persistedPages: readonly number[],
previewPages: readonly number[],
): number {
return Math.max(1, ...persistedPages, ...previewPages)
}
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest'
import type { LaunchablePhoneAppId } from '@/types/apps'
import { HOME_GRID_PAGE_SIZE, type HomeSlot } from '@/utils/homeLayout'
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
function app(id: string): LaunchablePhoneAppId {
return id as LaunchablePhoneAppId
}
describe('springboard app page layout', () => {
it('closes visual gaps per page while keeping page membership', () => {
const grid = new Array<LaunchablePhoneAppId | null>(
HOME_GRID_PAGE_SIZE * 2,
).fill(null)
grid[20] = app('row-six')
grid[24] = app('page-two')
const pages = layoutSpringboardHomePages(grid, new Map(), 2)
expect(pages).toHaveLength(2)
expect(pages[0]?.cells[0]).toMatchObject({
item: 'row-six',
sourceIndex: 20,
})
expect(pages[0]?.cells[1]).toMatchObject({ item: null, sourceIndex: 1 })
expect(pages[1]?.cells[0]).toMatchObject({
item: 'page-two',
sourceIndex: 24,
})
})
it('reflows apps forward around widgets without swapping their order', () => {
const grid = new Array<LaunchablePhoneAppId | null>(
HOME_GRID_PAGE_SIZE,
).fill(null)
for (let index = 0; index < 8; index += 1) grid[index] = app(`app-${index}`)
const pages = layoutSpringboardHomePages(
grid,
new Map([[1, new Set([0, 1, 4, 5])]]),
1,
)
expect(
pages[0]?.cells.slice(0, 12).map((cell) => cell?.item ?? null),
).toEqual([
null,
null,
'app-0',
'app-1',
null,
null,
'app-2',
'app-3',
'app-4',
'app-5',
'app-6',
'app-7',
])
})
it('reflows folders as one item and keeps their source index', () => {
const grid = new Array<HomeSlot>(HOME_GRID_PAGE_SIZE).fill(null)
grid[0] = app('mail')
grid[1] = {
apps: [app('clock'), app('notes')],
id: 'folder-tools-123456',
name: 'Tools',
type: 'folder',
}
const pages = layoutSpringboardHomePages(
grid,
new Map([[1, new Set([0, 1])]]),
1,
)
expect(pages[0]?.cells[2]).toMatchObject({
item: 'mail',
sourceIndex: 0,
})
expect(pages[0]?.cells[3]).toMatchObject({
item: {
apps: ['clock', 'notes'],
id: 'folder-tools-123456',
type: 'folder',
},
sourceIndex: 1,
})
})
it('maps widget overflow to the visible destination page and offset', () => {
const grid = Array.from({ length: HOME_GRID_PAGE_SIZE }, (_, index) =>
app(`app-${index}`),
)
const pages = layoutSpringboardHomePages(
grid,
new Map([[1, new Set([0, 1, 2, 3])]]),
2,
)
expect(pages[1]?.cells[0]).toMatchObject({
item: 'app-20',
sourceIndex: 20,
targetOffset: 0,
targetPage: 2,
})
expect(pages[1]?.cells[1]).toMatchObject({
item: 'app-21',
sourceIndex: 21,
targetOffset: 1,
targetPage: 2,
})
})
})
+97
View File
@@ -0,0 +1,97 @@
import {
HOME_GRID_PAGE_SIZE,
isHomeFolder,
MAX_HOME_GRID_PAGES,
type HomeItem,
type HomeSlot,
} from '@/utils/homeLayout'
export type SpringboardHomeCell = {
item: HomeItem | null
renderKey: string
sourceIndex: number | null
targetOffset: number
targetPage: number
}
export type SpringboardHomePage = {
cells: Array<SpringboardHomeCell | null>
page: number
}
type PendingHomeCell = Pick<
SpringboardHomeCell,
'item' | 'renderKey' | 'sourceIndex'
>
function homeItemRenderKey(
item: HomeItem,
appOccurrences: Map<string, number>,
): string {
if (isHomeFolder(item)) return `grid-folder-${item.id}`
const occurrence = appOccurrences.get(item) ?? 0
appOccurrences.set(item, occurrence + 1)
return `grid-app-${item}-${occurrence}`
}
export function layoutSpringboardHomePages(
grid: readonly HomeSlot[],
occupiedByPage: ReadonlyMap<number, ReadonlySet<number>>,
minimumPageCount: number,
): SpringboardHomePage[] {
const pageCount = Math.max(
1,
Math.min(
MAX_HOME_GRID_PAGES,
Math.max(minimumPageCount, Math.ceil(grid.length / HOME_GRID_PAGE_SIZE)),
),
)
const pages: SpringboardHomePage[] = []
const pending: PendingHomeCell[] = []
const appOccurrences = new Map<string, number>()
const createPage = (page: number): SpringboardHomePage => {
const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE
for (let offset = 0; offset < HOME_GRID_PAGE_SIZE; offset += 1) {
const sourceIndex = pageStart + offset
const item = grid[sourceIndex]
if (!item) continue
pending.push({
item,
renderKey: homeItemRenderKey(item, appOccurrences),
sourceIndex,
})
}
const occupied = occupiedByPage.get(page) ?? new Set<number>()
const cells = new Array<SpringboardHomeCell | null>(
HOME_GRID_PAGE_SIZE,
).fill(null)
let targetOffset = 0
for (let cell = 0; cell < HOME_GRID_PAGE_SIZE; cell += 1) {
if (occupied.has(cell)) continue
const pendingCell = pending.shift()
cells[cell] = pendingCell
? { ...pendingCell, targetOffset, targetPage: page }
: {
item: null,
renderKey: `grid-empty-${pageStart + targetOffset}`,
sourceIndex: pageStart + targetOffset,
targetOffset,
targetPage: page,
}
targetOffset += 1
}
return { cells, page }
}
for (let page = 1; page <= pageCount; page += 1) {
pages.push(createPage(page))
}
while (pending.length && pages.length < MAX_HOME_GRID_PAGES) {
pages.push(createPage(pages.length + 1))
}
return pages
}
+37
View File
@@ -4,6 +4,7 @@ import {
addWidget,
createDefaultWidgetLayout,
deleteWidgetPage,
MAX_WIDGET_HOME_PAGES,
moveWidget,
parseWidgetLayout,
removeWidget,
@@ -75,6 +76,42 @@ describe('widget layout', () => {
expect(widgetOccupiedCells(moved.instances, 2).has(7)).toBe(false)
})
it('moves a widget across pages into the sixth widget row', () => {
const layout = createDefaultWidgetLayout()
const moved = moveWidget(layout, 'home-clock', 2, 2, 4)
expect(
moved.instances.find((instance) => instance.id === 'home-clock'),
).toMatchObject({ column: 2, page: 2, row: 4 })
expect([...widgetOccupiedCells(moved.instances, 2)]).toEqual([
18, 19, 22, 23,
])
})
it('bounds persisted and requested widget pages to the home page limit', () => {
const parsed = parseWidgetLayout({
instances: [
{
column: 0,
id: 'clock-too-far',
kind: 'clock',
page: 999,
row: 0,
settings: {},
size: 'small',
},
],
version: 1,
})
const moved = moveWidget(parsed, 'clock-too-far', 999, 0, 4)
expect(parsed.instances[0]?.page).toBe(MAX_WIDGET_HOME_PAGES)
expect(moved.instances[0]).toMatchObject({
page: MAX_WIDGET_HOME_PAGES,
row: 4,
})
})
it('keeps the original layout when displaced widgets cannot be reflowed', () => {
const layout = createDefaultWidgetLayout()
const packed = {
+22 -10
View File
@@ -6,12 +6,17 @@ import type {
WidgetSettings,
WidgetSize,
} from '@/types/widgets'
import {
HOME_GRID_COLUMNS,
HOME_GRID_ROWS,
MAX_HOME_GRID_PAGES,
} from '@/utils/homeLayout'
import type { ReorderDirection } from '@/utils/keyboard'
export const WIDGET_GRID_COLUMNS = 4
export const WIDGET_HOME_ROWS = 6
export const WIDGET_GRID_COLUMNS = HOME_GRID_COLUMNS
export const WIDGET_HOME_ROWS = HOME_GRID_ROWS
export const WIDGET_PAGE_ROWS = 10
const MAX_WIDGET_HOME_PAGES = 5
export const MAX_WIDGET_HOME_PAGES = MAX_HOME_GRID_PAGES
export const WIDGET_SPANS: Record<
WidgetSize,
@@ -26,6 +31,13 @@ function rowsForPage(page: number): number {
return page === 0 ? WIDGET_PAGE_ROWS : WIDGET_HOME_ROWS
}
function normalizePage(page: number): number {
return Math.max(
0,
Math.min(Math.floor(Number(page) || 0), MAX_WIDGET_HOME_PAGES),
)
}
function overlaps(left: WidgetInstance, right: WidgetInstance): boolean {
if (left.page !== right.page) return false
const leftSpan = WIDGET_SPANS[left.size]
@@ -221,7 +233,7 @@ export function parseWidgetLayout(value: unknown): WidgetLayout {
column: Math.floor(Number(candidate.column) || 0),
id: candidate.id,
kind: candidate.kind as WidgetKind,
page: Math.max(0, Math.floor(Number(candidate.page) || 0)),
page: normalizePage(Number(candidate.page)),
row: Math.floor(Number(candidate.row) || 0),
settings: normalizeSettings(candidate.settings),
size,
@@ -248,7 +260,7 @@ export function addWidget(
column: 0,
id: `${kind}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
kind,
page,
page: normalizePage(page),
row: 0,
settings: {
...(kind === 'clock' ? { showDate: true } : {}),
@@ -256,7 +268,7 @@ export function addWidget(
},
size,
}
const positioned = findPosition(instance, layout.instances, page)
const positioned = findPosition(instance, layout.instances, instance.page)
if (!positioned) return layout
return { instances: [...layout.instances, positioned], version: 1 }
}
@@ -278,11 +290,12 @@ export function moveWidget(
const moving = layout.instances.find((instance) => instance.id === id)
if (!moving) return layout
const span = WIDGET_SPANS[moving.size]
const targetPage = normalizePage(page)
const requested: WidgetInstance = {
...moving,
column: Math.max(0, Math.min(column, WIDGET_GRID_COLUMNS - span.columns)),
page,
row: Math.max(0, Math.min(row, rowsForPage(page) - span.rows)),
page: targetPage,
row: Math.max(0, Math.min(row, rowsForPage(targetPage) - span.rows)),
}
const placed: WidgetInstance[] = [requested]
for (const instance of layout.instances) {
@@ -309,8 +322,7 @@ export function widgetKeyboardTarget(
instance.column +
(direction === 'left' ? -1 : direction === 'right' ? 1 : 0),
row:
instance.row +
(direction === 'up' ? -1 : direction === 'down' ? 1 : 0),
instance.row + (direction === 'up' ? -1 : direction === 'down' ? 1 : 0),
}
if (
target.column < 0 ||
@@ -0,0 +1,66 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const viewSource = readFileSync(
new URL('./SpringboardView.vue', import.meta.url),
'utf8',
)
const appIconSource = readFileSync(
new URL('../components/AppIcon.vue', import.meta.url),
'utf8',
)
const folderIconSource = readFileSync(
new URL('../components/HomeFolderIcon.vue', import.meta.url),
'utf8',
)
describe('Springboard page swipe contract', () => {
it('allows page swipes to start on home apps and widget surfaces', () => {
expect(viewSource).toContain(
'.springboard-page--apps .app-icon-button, .home-widget',
)
expect(viewSource).toContain('springboardSwipeIntent')
expect(viewSource).not.toContain(
"target.closest('button, input, .home-widget-shell')",
)
})
it('suppresses app and folder activation after pointer movement', () => {
expect(appIconSource).toContain('springboardSwipeIntent')
expect(folderIconSource).toContain('springboardSwipeIntent')
expect(appIconSource).toContain('suppressClick.value = true')
expect(folderIconSource).toContain('suppressClick.value = true')
})
it('separates animated page compensation from immediate pointer movement', () => {
expect(appIconSource).toContain('springboardPageDragCompensation')
expect(folderIconSource).toContain('springboardPageDragCompensation')
expect(appIconSource).toContain(':style="dragPointerStyle"')
expect(folderIconSource).toContain(':style="dragPointerStyle"')
})
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 < springboardBounds.left')
expect(viewSource).toContain('event.clientY > springboardBounds.bottom')
})
it('resolves occupied dock slots from the dock bounds rather than the event target', () => {
expect(viewSource).toContain("querySelector<HTMLElement>('.app-dock')")
expect(viewSource).toContain('\'[data-home-area="dock"][data-home-index]\'')
expect(viewSource).not.toContain(
'.closest<HTMLElement>(\'[data-home-area="dock"]\')',
)
})
it('keeps a new trailing page temporary until a valid drop', () => {
expect(viewSource).toContain('temporaryHomePage')
expect(viewSource).toContain('clearTemporaryHomePage')
expect(viewSource).toContain('resolveSpringboardHomeEdgeTurn')
expect(viewSource).toContain('onBeforeUnmount')
})
})
+327 -159
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Pencil, Plus, Search, Trash2, X } from 'lucide-vue-next'
import { kButton, kGlass, kList, kListItem, kSheet } from 'konsta/vue'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import HomeFolderIcon from '@/components/HomeFolderIcon.vue'
@@ -20,6 +20,7 @@ import {
deleteHomePage as previewHomePageDelete,
getHomeFolder,
HOME_GRID_PAGE_SIZE,
HOME_GRID_ROWS,
homeKeyboardTarget,
isHomeFolder,
MAX_HOME_GRID_PAGES,
@@ -28,6 +29,13 @@ import {
type HomeItem,
} from '@/utils/homeLayout'
import type { ReorderDirection } from '@/utils/keyboard'
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
import {
maximumRenderedWidgetPage,
resolveSpringboardEdgeTurn,
resolveSpringboardHomeEdgeTurn,
springboardSwipeIntent,
} from '@/utils/springboardDrag'
import {
deleteWidgetPage as previewWidgetPageDelete,
moveWidget as previewWidgetMove,
@@ -56,7 +64,6 @@ const widgetActionId = ref<string | null>(null)
const widgetConfigId = ref<string | null>(null)
const draggingWidgetId = ref<string | null>(null)
const widgetDragGrip = ref<{ x: number; y: number } | null>(null)
const widgetDragSize = ref<{ height: number; width: number } | null>(null)
const widgetDragPreview = ref<{
column: number
id: string
@@ -70,6 +77,7 @@ const draggingHomeApp = ref<{
area: HomeArea
index: number
} | null>(null)
const temporaryHomePage = ref<number | null>(null)
const openedFolderId = ref<string | null>(null)
const renameFolderOnOpenId = ref<string | null>(null)
const folderHoverTarget = ref('')
@@ -86,6 +94,7 @@ let edgePageTimer: number | undefined
let edgePageDirection = 0
let edgePageLocked = false
let folderHoverTimer: number | undefined
let lastHomePointer: { clientX: number; clientY: number } | null = null
const installedApps = computed(() =>
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
@@ -98,25 +107,6 @@ const hiddenApps = computed(() =>
appStore.homeLayout.hidden.includes(app.id),
),
)
const gridSlots = computed(() => {
const appOccurrences = new Map<string, number>()
return appStore.homeLayout.grid.map((item, sourceIndex) => {
const appId = typeof item === 'string' ? item : null
const folder = isHomeFolder(item) ? item : null
const occurrence = appId ? (appOccurrences.get(appId) ?? 0) : 0
if (appId) appOccurrences.set(appId, occurrence + 1)
return {
app: appId ? (installedAppsById.value.get(appId) ?? null) : null,
folder,
renderKey: folder
? `grid-folder-${folder.id}`
: appId
? `grid-app-${appId}-${occurrence}`
: `grid-empty-${sourceIndex}`,
sourceIndex,
}
})
})
const previewWidgetLayout = computed(() => {
const preview = widgetDragPreview.value
return preview
@@ -129,29 +119,31 @@ const previewWidgetLayout = computed(() => {
)
: widgets.layout
})
const homeGridCapacities = computed(() =>
Array.from({ length: MAX_HOME_GRID_PAGES }, (_, pageIndex) =>
Math.max(
0,
HOME_GRID_PAGE_SIZE -
widgetOccupiedCells(widgets.layout.instances, pageIndex + 1).size,
),
),
)
const appPages = computed(() => {
const slots = [...gridSlots.value]
const pages: Array<{
cells: Array<{
app: PhoneAppDefinition | null
folder: HomeFolder | null
renderKey: string
sourceIndex: number | null
}>
page: number
}> = []
const maxWidgetPage = Math.max(
1,
...previewWidgetLayout.value.instances.map((instance) => instance.page),
const maxWidgetPage = maximumRenderedWidgetPage(
widgets.layout.instances.map((instance) => instance.page),
previewWidgetLayout.value.instances.map((instance) => instance.page),
)
const persistedHomePages = Math.max(
1,
Math.ceil(appStore.homeLayout.grid.length / HOME_GRID_PAGE_SIZE),
)
const lastHomePage = Math.max(maxWidgetPage, persistedHomePages)
let page = 1
let slotIndex = 0
while (slotIndex < slots.length || page <= lastHomePage) {
const lastHomePage = Math.max(
maxWidgetPage,
persistedHomePages,
temporaryHomePage.value ?? 1,
)
const occupiedByPage = new Map<number, Set<number>>()
for (let page = 1; page <= lastHomePage; page += 1) {
const occupied = widgetOccupiedCells(
previewWidgetLayout.value.instances,
page,
@@ -161,40 +153,38 @@ const appPages = computed(() => {
occupied.add(cell)
}
}
const cells: Array<{
app: PhoneAppDefinition | null
folder: HomeFolder | null
renderKey: string
sourceIndex: number | null
}> = Array(HOME_GRID_PAGE_SIZE)
.fill(null)
.map((_, cell) => ({
app: null,
folder: null,
renderKey: `grid-occupied-${page}-${cell}`,
sourceIndex: null,
}))
for (let cell = 0; cell < HOME_GRID_PAGE_SIZE; cell += 1) {
if (occupied.has(cell) || slotIndex >= slots.length) continue
cells[cell] = slots[slotIndex]
slotIndex += 1
}
pages.push({ cells, page })
page += 1
occupiedByPage.set(page, occupied)
}
return pages.length
? pages
: [
{
cells: Array.from({ length: HOME_GRID_PAGE_SIZE }, (_, sourceIndex) => ({
app: null,
folder: null,
renderKey: `grid-empty-${sourceIndex}`,
sourceIndex,
})),
page: 1,
},
]
return layoutSpringboardHomePages(
appStore.homeLayout.grid,
occupiedByPage,
lastHomePage,
).map((page) => ({
...page,
cells: page.cells.map((cell, cellIndex) => {
if (!cell) {
return {
app: null,
folder: null,
renderKey: `grid-occupied-${page.page}-${cellIndex}`,
sourceIndex: null,
targetOffset: null,
targetPage: page.page,
}
}
return {
app:
typeof cell.item === 'string'
? (installedAppsById.value.get(cell.item) ?? null)
: null,
folder: isHomeFolder(cell.item) ? cell.item : null,
renderKey: cell.renderKey,
sourceIndex: cell.sourceIndex,
targetOffset: cell.targetOffset,
targetPage: cell.targetPage,
}
}),
}))
})
const pageCount = computed(() => appPages.value.length + 2)
const libraryPage = computed(() => pageCount.value - 1)
@@ -342,7 +332,13 @@ function finishPageTransition(event: TransitionEvent): void {
function onPointerDown(event: PointerEvent): void {
const target = event.target as HTMLElement
if (target.closest('button, input, .home-widget-shell')) {
const pageSwipeSurface = target.closest(
'.springboard-page--apps .app-icon-button, .home-widget',
)
const blockedControl =
target.closest('input, textarea, select, [data-widget-control]') ||
(target.closest('button, a') && !pageSwipeSurface)
if (blockedControl || (editMode.value && pageSwipeSurface)) {
clearBackgroundHold()
dragging.value = false
dragOffset.value = 0
@@ -356,8 +352,11 @@ function onPointerDown(event: PointerEvent): void {
blankTapCandidate = editMode.value
if (editMode.value) return
dragging.value = true
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
if (!pageSwipeSurface) {
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
clearBackgroundHold()
if (pageSwipeSurface) return
backgroundHoldTimer = window.setTimeout(() => {
enterEditMode()
backgroundHoldTimer = undefined
@@ -367,12 +366,12 @@ function onPointerDown(event: PointerEvent): void {
function onPointerMove(event: PointerEvent): void {
const distanceX = event.clientX - pointerStart
const distanceY = event.clientY - pointerStartY
if (Math.hypot(distanceX, distanceY) > 8) {
clearBackgroundHold()
blankTapCandidate = false
}
const intent = springboardSwipeIntent(distanceX, distanceY)
if (intent === 'pending') return
clearBackgroundHold()
blankTapCandidate = false
if (!dragging.value) return
if (Math.abs(distanceY) > Math.abs(distanceX)) {
if (intent === 'vertical') {
dragging.value = false
dragOffset.value = 0
return
@@ -430,7 +429,6 @@ function startWidgetDrag(id: string, event: PointerEvent): void {
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
}
widgetDragSize.value = { height: bounds.height, width: bounds.width }
}
function updateWidgetDragPreview(event: {
@@ -486,6 +484,34 @@ function clearEdgePageTurn(): void {
edgePageLocked = false
}
function clearTemporaryHomePage(keepCurrentPage = false): void {
if (temporaryHomePage.value === null) return
temporaryHomePage.value = null
if (keepCurrentPage) return
const lastPage = Math.max(
1,
Math.ceil(appStore.homeLayout.grid.length / HOME_GRID_PAGE_SIZE),
...widgets.layout.instances.map((instance) => instance.page),
)
if (phone.currentPage > lastPage) changePage(lastPage)
}
function resolveHomeEdgeTurn(event: { clientX: number }) {
const springboard = document.querySelector<HTMLElement>('.springboard')
if (!springboard) return null
const bounds = springboard.getBoundingClientRect()
const renderedLastPage = appPages.value.length
return resolveSpringboardHomeEdgeTurn(
event.clientX,
bounds.left,
bounds.right,
phone.currentPage,
renderedLastPage,
MAX_HOME_GRID_PAGES,
temporaryHomePage.value !== null,
)
}
function queueEdgePageTurn(
event: PointerEvent,
dragType: 'app' | 'widget',
@@ -493,39 +519,49 @@ function queueEdgePageTurn(
const springboard = document.querySelector<HTMLElement>('.springboard')
if (!springboard) return
const bounds = springboard.getBoundingClientRect()
const edgeSize = Math.min(42, bounds.width * 0.12)
let direction = 0
if (dragType === 'widget' && widgetDragGrip.value && widgetDragSize.value) {
const widgetLeft = event.clientX - widgetDragGrip.value.x
const widgetRight = widgetLeft + widgetDragSize.value.width
const overhang = Math.min(22, widgetDragSize.value.width * 0.14)
if (widgetLeft <= bounds.left - overhang) direction = -1
else if (widgetRight >= bounds.right + overhang) direction = 1
} else if (event.clientX <= bounds.left + edgeSize) direction = -1
else if (event.clientX >= bounds.right - edgeSize) direction = 1
const minimumPage = dragType === 'widget' ? 0 : 1
const maximumPage = libraryPage.value - 1
const destination = phone.currentPage + direction
const turn =
dragType === 'app'
? resolveHomeEdgeTurn(event)
: resolveSpringboardEdgeTurn(
event.clientX,
bounds.left,
bounds.right,
phone.currentPage,
0,
libraryPage.value - 1,
)
if (
direction === 0 ||
destination < minimumPage ||
destination > maximumPage
) {
if (!turn) {
if (edgePageTimer !== undefined) window.clearTimeout(edgePageTimer)
edgePageTimer = undefined
edgePageDirection = 0
edgePageLocked = false
return
}
const { destination, direction } = turn
if (edgePageLocked && edgePageDirection === direction) return
if (edgePageLocked) edgePageLocked = false
if (edgePageTimer !== undefined && edgePageDirection === direction) return
if (edgePageTimer !== undefined) window.clearTimeout(edgePageTimer)
edgePageDirection = direction
edgePageTimer = window.setTimeout(() => {
const targetPage = phone.currentPage + direction
changePage(targetPage)
const liveTurn =
dragType === 'app' && lastHomePointer
? resolveHomeEdgeTurn(lastHomePointer)
: { destination, direction }
if (!liveTurn || liveTurn.direction !== direction) {
clearEdgePageTurn()
return
}
clearFolderHover()
if (
dragType === 'app' &&
'previewsPage' in liveTurn &&
liveTurn.previewsPage
) {
temporaryHomePage.value = liveTurn.destination
}
changePage(liveTurn.destination)
if (dragType === 'widget' && lastWidgetPointer) {
updateWidgetDragPreview(lastWidgetPointer)
}
@@ -557,7 +593,6 @@ function clearWidgetDragPreview(): void {
pendingWidgetPointer = null
lastWidgetPointer = null
widgetDragGrip.value = null
widgetDragSize.value = null
widgetDragPreview.value = null
}
@@ -629,6 +664,8 @@ async function saveWidgetConfig(
}
function startHomeDrag(area: HomeArea, index: number): void {
clearTemporaryHomePage()
lastHomePointer = null
draggingHomeApp.value = { area, index }
}
@@ -650,10 +687,7 @@ function queueFolderHover(event: PointerEvent): void {
const targetElement = document
.elementsFromPoint(event.clientX, event.clientY)
.map((element) => element.closest<HTMLElement>('[data-home-index]'))
.find(
(element) =>
element && !element.closest('.app-icon-item--dragging'),
)
.find((element) => element && !element.closest('.app-icon-item--dragging'))
const area = targetElement?.dataset.homeArea as HomeArea | undefined
const targetIndex = Number(targetElement?.dataset.homeIndex)
if (
@@ -664,6 +698,16 @@ function queueFolderHover(event: PointerEvent): void {
clearFolderHover()
return
}
if (
area === 'grid' &&
Number(
targetElement?.closest<HTMLElement>('[data-home-page]')?.dataset.homePage,
) !== phone.currentPage
) {
clearFolderHover()
return
}
const targetItem = appStore.homeLayout[area][targetIndex]
if (typeof targetItem !== 'string' && !isHomeFolder(targetItem)) {
clearFolderHover()
@@ -671,13 +715,18 @@ function queueFolderHover(event: PointerEvent): void {
}
const targetKey = `${area}:${targetIndex}`
if (folderHoverTarget.value === targetKey) return
if (folderHoverTarget.value === targetKey) {
clearEdgePageTurn()
return
}
clearEdgePageTurn()
clearFolderHover()
folderHoverTarget.value = targetKey
folderHoverTimer = window.setTimeout(() => {
const currentDrag = draggingHomeApp.value
if (!currentDrag || folderHoverTarget.value !== targetKey) return
const currentSource = appStore.homeLayout[currentDrag.area][currentDrag.index]
const currentSource =
appStore.homeLayout[currentDrag.area][currentDrag.index]
const currentTarget = appStore.homeLayout[area][targetIndex]
if (typeof currentSource !== 'string') return
@@ -702,6 +751,8 @@ function queueFolderHover(event: PointerEvent): void {
}
if (folderId) {
draggingHomeApp.value = null
lastHomePointer = null
clearTemporaryHomePage()
openedFolderId.value = folderId
}
clearFolderHover()
@@ -709,9 +760,14 @@ function queueFolderHover(event: PointerEvent): void {
}
function moveHomeDrag(event: PointerEvent): void {
if (draggingHomeApp.value?.area === 'grid') {
if (!draggingHomeApp.value) return
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
if (resolveHomeEdgeTurn(event)) {
clearFolderHover()
queueEdgePageTurn(event, 'app')
return
}
clearEdgePageTurn()
queueFolderHover(event)
}
@@ -763,6 +819,103 @@ async function animateHomeItemDrop(
appElement.classList.add('app-icon-item--drop-settling')
}
function findHomeItemIndex(
item: HomeItem,
area: HomeArea,
preferredIndex: number,
): number | null {
const matches = appStore.homeLayout[area].flatMap((candidate, index) => {
const matchesItem = isHomeFolder(item)
? isHomeFolder(candidate) && candidate.id === item.id
: candidate === item
return matchesItem ? [index] : []
})
if (!matches.length) return null
return matches.reduce((closest, index) =>
Math.abs(index - preferredIndex) < Math.abs(closest - preferredIndex)
? index
: closest,
)
}
function nearestGridDropTarget(event: {
clientX: number
clientY: number
}): { page: number; targetOffset: number } | null {
const page = phone.currentPage
if (page < 1 || page > appPages.value.length) return null
const grid = document.querySelector<HTMLElement>(
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
const springboard = grid?.closest<HTMLElement>('.springboard')
if (!grid || !pageElement || !springboard) return null
const pageBounds = pageElement.getBoundingClientRect()
const springboardBounds = springboard.getBoundingClientRect()
if (
event.clientX < springboardBounds.left ||
event.clientX > springboardBounds.right ||
event.clientY < springboardBounds.top ||
event.clientY > springboardBounds.bottom
) {
return null
}
const slots = Array.from(
grid.querySelectorAll<HTMLElement>('[data-home-target-offset]'),
)
const distanceToSlot = (slot: HTMLElement): number => {
const bounds = slot.getBoundingClientRect()
const centerX =
springboardBounds.left +
(bounds.left - pageBounds.left) +
bounds.width / 2
const centerY =
springboardBounds.top + (bounds.top - pageBounds.top) + bounds.height / 2
return Math.hypot(event.clientX - centerX, event.clientY - centerY)
}
const closest = slots.reduce<HTMLElement | null>((nearest, slot) => {
if (!nearest) return slot
return distanceToSlot(slot) < distanceToSlot(nearest) ? slot : nearest
}, null)
const targetOffset = Number(closest?.dataset.homeTargetOffset)
return Number.isInteger(targetOffset) ? { page, targetOffset } : null
}
function nearestDockDropTarget(event: {
clientX: number
clientY: number
}): HTMLElement | null {
const dock = document.querySelector<HTMLElement>('.app-dock')
if (!dock) return null
const dockBounds = dock.getBoundingClientRect()
if (
event.clientX < dockBounds.left ||
event.clientX > dockBounds.right ||
event.clientY < dockBounds.top ||
event.clientY > dockBounds.bottom
) {
return null
}
return Array.from(
dock.querySelectorAll<HTMLElement>(
'[data-home-area="dock"][data-home-index]',
),
).reduce<HTMLElement | null>((nearest, slot) => {
if (!nearest) return slot
const bounds = slot.getBoundingClientRect()
const nearestBounds = nearest.getBoundingClientRect()
const distance = Math.hypot(
event.clientX - (bounds.left + bounds.width / 2),
event.clientY - (bounds.top + bounds.height / 2),
)
const nearestDistance = Math.hypot(
event.clientX - (nearestBounds.left + nearestBounds.width / 2),
event.clientY - (nearestBounds.top + nearestBounds.height / 2),
)
return distance < nearestDistance ? slot : nearest
}, null)
}
function finishHomeDrag(event: PointerEvent): void {
clearEdgePageTurn()
clearFolderHover()
@@ -773,47 +926,43 @@ function finishHomeDrag(event: PointerEvent): void {
'.app-icon-item',
)
const dropOrigin = draggedElement?.getBoundingClientRect()
const target = document
.elementsFromPoint(event.clientX, event.clientY)
.find(
(element) =>
!element.closest('.app-icon-item--dragging') &&
(element.closest('[data-home-index]') ||
element.closest('[data-home-area]')),
)
const targetArea = target?.closest<HTMLElement>('[data-home-area]')
let targetItem = target?.closest<HTMLElement>('[data-home-index]')
if (!targetItem && targetArea) {
const slotItems = Array.from(
targetArea.querySelectorAll<HTMLElement>('[data-home-index]'),
)
targetItem = slotItems.reduce<HTMLElement | undefined>((closest, slot) => {
if (!closest) return slot
const slotBounds = slot.getBoundingClientRect()
const closestBounds = closest.getBoundingClientRect()
const slotDistance = Math.hypot(
event.clientX - (slotBounds.left + slotBounds.width / 2),
event.clientY - (slotBounds.top + slotBounds.height / 2),
)
const closestDistance = Math.hypot(
event.clientX - (closestBounds.left + closestBounds.width / 2),
event.clientY - (closestBounds.top + closestBounds.height / 2),
)
return slotDistance < closestDistance ? slot : closest
}, undefined)
}
const area = (targetItem?.dataset.homeArea ??
targetArea?.dataset.homeArea) as HomeArea | undefined
const dockTarget = nearestDockDropTarget(event)
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
let dropArea = dragged.area
let dropIndex = dragged.index
if ((area === 'grid' || area === 'dock') && targetItem) {
const targetIndex = Number.parseInt(targetItem.dataset.homeIndex ?? '', 10)
appStore.moveHomeApp(dragged.area, dragged.index, area, targetIndex)
dropArea = area
dropIndex = targetIndex
let moved = false
if (dockTarget) {
const targetIndex = Number.parseInt(dockTarget.dataset.homeIndex ?? '', 10)
moved = appStore.moveHomeApp(
dragged.area,
dragged.index,
'dock',
targetIndex,
)
dropArea = 'dock'
dropIndex = draggedItem
? (findHomeItemIndex(draggedItem, 'dock', targetIndex) ?? targetIndex)
: targetIndex
} else if (gridTarget) {
moved = appStore.moveHomeAppToGridPage(
dragged.area,
dragged.index,
gridTarget.page,
gridTarget.targetOffset,
homeGridCapacities.value,
)
dropArea = 'grid'
const preferredIndex =
(gridTarget.page - 1) * HOME_GRID_PAGE_SIZE + gridTarget.targetOffset
dropIndex = draggedItem
? (findHomeItemIndex(draggedItem, 'grid', preferredIndex) ??
preferredIndex)
: preferredIndex
}
draggingHomeApp.value = null
if (draggedItem && dropOrigin) {
lastHomePointer = null
clearTemporaryHomePage(moved && gridTarget !== null)
if (moved && draggedItem && dropOrigin) {
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
}
}
@@ -822,6 +971,8 @@ function stopHomeDrag(): void {
clearEdgePageTurn()
clearFolderHover()
draggingHomeApp.value = null
lastHomePointer = null
clearTemporaryHomePage()
}
function reorderHomeApp(
@@ -843,7 +994,8 @@ function reorderHomeApp(
if (targetIndex === null) return
appStore.moveHomeApp(area, sourceIndex, area, targetIndex)
if (item && moveOrigin) {
void animateHomeItemDrop(item, area, targetIndex, moveOrigin)
const movedIndex = findHomeItemIndex(item, area, targetIndex) ?? targetIndex
void animateHomeItemDrop(item, area, movedIndex, moveOrigin)
}
}
@@ -947,17 +1099,15 @@ function extractOpenedFolderApp(
const area = target.dataset.homeArea as HomeArea
const targetIndex = Number(target.dataset.homeIndex)
if (
!appStore.extractHomeFolderApp(
folder.id,
sourceIndex,
area,
targetIndex,
)
!appStore.extractHomeFolderApp(folder.id, sourceIndex, area, targetIndex)
) {
return
}
if (sourceBounds) {
void animateHomeItemDrop(appId, area, targetIndex, sourceBounds)
const extractedIndex = findHomeItemIndex(appId, area, targetIndex)
if (extractedIndex !== null) {
void animateHomeItemDrop(appId, area, extractedIndex, sourceBounds)
}
}
}
@@ -975,9 +1125,27 @@ function openAllApps(): void {
watch(isEditablePage, (visible) => {
if (!visible) editMode.value = false
})
watch(editMode, (editing) => {
if (editing) return
stopHomeDrag()
stopWidgetDrag()
})
watch(openedFolder, (folder) => {
if (!folder) closeFolder()
})
onBeforeUnmount(() => {
clearBackgroundHold()
clearEdgePageTurn()
clearFolderHover()
if (widgetPreviewTimer !== undefined) window.clearTimeout(widgetPreviewTimer)
widgetPreviewTimer = undefined
pendingWidgetPointer = null
lastWidgetPointer = null
lastHomePointer = null
temporaryHomePage.value = null
draggingHomeApp.value = null
draggingWidgetId.value = null
})
</script>
<template>
@@ -1030,6 +1198,7 @@ watch(openedFolder, (folder) => {
class="springboard-page springboard-page--apps"
:style="pageStyle"
:aria-label="phone.t('Home.apps')"
:data-home-page="page.page"
>
<SpringboardWidgetGrid
:page="page.page"
@@ -1049,12 +1218,10 @@ watch(openedFolder, (folder) => {
"
tag="div"
class="app-grid"
:style="{ '--home-grid-rows': HOME_GRID_ROWS }"
data-home-area="grid"
>
<template
v-for="cell in page.cells"
:key="cell.renderKey"
>
<template v-for="cell in page.cells" :key="cell.renderKey">
<AppIcon
v-if="cell.app && cell.sourceIndex !== null"
:app="cell.app"
@@ -1062,6 +1229,7 @@ watch(openedFolder, (folder) => {
:data-home-app-id="cell.app.id"
:data-home-item-key="`app-${cell.app.id}`"
:data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset"
:edit-mode="editMode"
:class="{
'home-item--folder-hover':
@@ -1083,6 +1251,7 @@ watch(openedFolder, (folder) => {
:data-home-folder-id="cell.folder.id"
:data-home-item-key="cell.folder.id"
:data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset"
:edit-mode="editMode"
:folder="cell.folder"
:class="{
@@ -1102,6 +1271,7 @@ watch(openedFolder, (folder) => {
class="app-grid-slot"
:data-home-area="cell.sourceIndex === null ? undefined : 'grid'"
:data-home-index="cell.sourceIndex ?? undefined"
:data-home-target-offset="cell.targetOffset"
aria-hidden="true"
></div>
</template>
@@ -1277,9 +1447,7 @@ watch(openedFolder, (folder) => {
>
<template
v-for="slot in dockSlots"
:key="
slot.folder?.id ?? slot.app?.id ?? `dock-empty-${slot.index}`
"
:key="slot.folder?.id ?? slot.app?.id ?? `dock-empty-${slot.index}`"
>
<AppIcon
v-if="slot.app"