mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
FIX - enable cross-page Home Screen dragging
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user