mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-31 10:18:57 +00:00
858 lines
24 KiB
TypeScript
858 lines
24 KiB
TypeScript
import type { LaunchablePhoneAppId } from '@/types/apps'
|
|
import type { ReorderDirection } from '@/utils/keyboard'
|
|
|
|
export const HOME_DOCK_CAPACITY = 4
|
|
export const HOME_GRID_COLUMNS = 4
|
|
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'
|
|
|
|
export type HomeFolder = {
|
|
apps: LaunchablePhoneAppId[]
|
|
id: string
|
|
name: string
|
|
type: 'folder'
|
|
}
|
|
|
|
export type HomeItem = HomeFolder | LaunchablePhoneAppId
|
|
export type HomeSlot = HomeItem | null
|
|
|
|
export type HomeLayout = {
|
|
dock: HomeSlot[]
|
|
grid: HomeSlot[]
|
|
hidden: LaunchablePhoneAppId[]
|
|
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>
|
|
return (
|
|
folder.type === 'folder' &&
|
|
typeof folder.id === 'string' &&
|
|
typeof folder.name === 'string' &&
|
|
Array.isArray(folder.apps)
|
|
)
|
|
}
|
|
|
|
function isPersistableAppId(value: unknown): value is LaunchablePhoneAppId {
|
|
return typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{1,63}$/.test(value)
|
|
}
|
|
|
|
function isPersistableFolderId(value: unknown): value is string {
|
|
return typeof value === 'string' && /^folder-[a-z0-9-]{6,80}$/.test(value)
|
|
}
|
|
|
|
function readAppIds(
|
|
value: unknown,
|
|
availableIds: Set<LaunchablePhoneAppId>,
|
|
): LaunchablePhoneAppId[] {
|
|
if (!Array.isArray(value)) return []
|
|
|
|
const ids: LaunchablePhoneAppId[] = []
|
|
for (const valueId of value) {
|
|
if (
|
|
typeof valueId === 'string' &&
|
|
availableIds.has(valueId as LaunchablePhoneAppId) &&
|
|
!ids.includes(valueId as LaunchablePhoneAppId)
|
|
) {
|
|
ids.push(valueId as LaunchablePhoneAppId)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
function createSlots(length: number): HomeSlot[] {
|
|
return Array.from({ length }, () => null)
|
|
}
|
|
|
|
function getGridCapacity(itemCount: number): number {
|
|
return Math.max(
|
|
HOME_GRID_PAGE_SIZE,
|
|
Math.ceil(itemCount / HOME_GRID_PAGE_SIZE) * HOME_GRID_PAGE_SIZE,
|
|
)
|
|
}
|
|
|
|
function cloneItem(item: HomeSlot): HomeSlot {
|
|
return isHomeFolder(item) ? { ...item, apps: [...item.apps] } : item
|
|
}
|
|
|
|
function cloneLayout(layout: HomeLayout): HomeLayout {
|
|
return {
|
|
dock: layout.dock.map(cloneItem),
|
|
grid: layout.grid.map(cloneItem),
|
|
hidden: [...layout.hidden],
|
|
version: HOME_LAYOUT_VERSION,
|
|
}
|
|
}
|
|
|
|
function normalizeFolder(folder: HomeFolder): HomeSlot {
|
|
if (folder.apps.length === 0) return null
|
|
if (folder.apps.length === 1) return folder.apps[0]
|
|
return folder
|
|
}
|
|
|
|
function readItem(
|
|
value: unknown,
|
|
availableIds: Set<LaunchablePhoneAppId>,
|
|
folderIds: Set<string>,
|
|
allowFolders: boolean,
|
|
): HomeSlot {
|
|
if (
|
|
typeof value === 'string' &&
|
|
availableIds.has(value as LaunchablePhoneAppId)
|
|
) {
|
|
return value as LaunchablePhoneAppId
|
|
}
|
|
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
|
|
if (apps.length === 1) return apps[0]
|
|
folderIds.add(value.id)
|
|
return {
|
|
apps,
|
|
id: value.id,
|
|
name: value.name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH),
|
|
type: 'folder',
|
|
}
|
|
}
|
|
|
|
function readSlots(
|
|
value: unknown,
|
|
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, 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
|
|
}
|
|
|
|
function placeInFirstEmptySlot(slots: HomeSlot[], item: HomeItem): void {
|
|
const emptyIndex = slots.indexOf(null)
|
|
if (emptyIndex !== -1) {
|
|
slots[emptyIndex] = cloneItem(item)
|
|
return
|
|
}
|
|
|
|
slots.push(...createSlots(HOME_GRID_PAGE_SIZE))
|
|
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
|
|
}
|
|
|
|
function folderLocation(
|
|
layout: HomeLayout,
|
|
folderId: string,
|
|
): { area: HomeArea; index: number } | null {
|
|
for (const area of ['grid', 'dock'] as const) {
|
|
const index = layout[area].findIndex(
|
|
(item) => isHomeFolder(item) && item.id === folderId,
|
|
)
|
|
if (index !== -1) return { area, index }
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function getHomeFolder(
|
|
layout: HomeLayout,
|
|
folderId: string,
|
|
): HomeFolder | null {
|
|
const location = folderLocation(layout, folderId)
|
|
if (!location) return null
|
|
const item = layout[location.area][location.index]
|
|
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[],
|
|
defaultDockIds: LaunchablePhoneAppId[],
|
|
): HomeLayout {
|
|
const installed = new Set(installedIds)
|
|
const gridIds = defaultGridIds.filter((id) => installed.has(id))
|
|
const grid = createSlots(getGridCapacity(gridIds.length))
|
|
for (let index = 0; index < gridIds.length; index += 1) {
|
|
grid[index] = gridIds[index]
|
|
}
|
|
|
|
const dock = createSlots(HOME_DOCK_CAPACITY)
|
|
for (const [index, id] of defaultDockIds
|
|
.filter((id) => installed.has(id))
|
|
.slice(0, HOME_DOCK_CAPACITY)
|
|
.entries()) {
|
|
dock[index] = id
|
|
}
|
|
|
|
return { dock, grid, hidden: [], version: HOME_LAYOUT_VERSION }
|
|
}
|
|
|
|
export function parseHomeLayout(
|
|
value: unknown,
|
|
defaults: HomeLayout,
|
|
installedIds: LaunchablePhoneAppId[],
|
|
): HomeLayout {
|
|
if (!value || typeof value !== 'object') return defaults
|
|
|
|
const source = value as Partial<Record<keyof HomeLayout, unknown>>
|
|
const availableIds = new Set(installedIds)
|
|
if (source.version === 3 || source.version === 4 || source.version === 5) {
|
|
for (const collection of [source.dock, source.grid, source.hidden]) {
|
|
if (!Array.isArray(collection)) continue
|
|
for (const item of collection) {
|
|
if (isPersistableAppId(item)) availableIds.add(item)
|
|
if (source.version === 5 && isHomeFolder(item)) {
|
|
for (const appId of item.apps) {
|
|
if (isPersistableAppId(appId)) availableIds.add(appId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const hidden = readAppIds(source.hidden, availableIds)
|
|
const hiddenIds = new Set(hidden)
|
|
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,
|
|
getGridCapacity(persistedGridLength),
|
|
)
|
|
let grid: HomeSlot[]
|
|
let dock: HomeSlot[]
|
|
|
|
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>()
|
|
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)) {
|
|
placeInFirstEmptySlot(grid, id)
|
|
}
|
|
dock = createSlots(HOME_DOCK_CAPACITY)
|
|
for (const [index, id] of readAppIds(source.dock, availableIds)
|
|
.slice(0, HOME_DOCK_CAPACITY)
|
|
.entries()) {
|
|
dock[index] = id
|
|
}
|
|
}
|
|
|
|
const removeHidden = (item: HomeSlot): HomeSlot => {
|
|
if (typeof item === 'string') return hiddenIds.has(item) ? null : item
|
|
if (!isHomeFolder(item)) return null
|
|
return normalizeFolder({
|
|
...item,
|
|
apps: item.apps.filter((appId) => !hiddenIds.has(appId)),
|
|
})
|
|
}
|
|
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)
|
|
if (isHomeFolder(item)) {
|
|
for (const appId of item.apps) placedIds.add(appId)
|
|
}
|
|
}
|
|
|
|
for (const item of defaults.grid) {
|
|
if (typeof item === 'string' && !placedIds.has(item)) {
|
|
placeInFirstEmptySlot(grid, item)
|
|
placedIds.add(item)
|
|
}
|
|
}
|
|
|
|
return {
|
|
dock: dock.map(cloneItem),
|
|
grid: compactGridPages(grid),
|
|
hidden,
|
|
version: HOME_LAYOUT_VERSION,
|
|
}
|
|
}
|
|
|
|
export function removeHomeApp(
|
|
layout: HomeLayout,
|
|
appId: LaunchablePhoneAppId,
|
|
): HomeLayout {
|
|
const removeFromItem = (item: HomeSlot): HomeSlot => {
|
|
if (item === appId) return null
|
|
if (!isHomeFolder(item)) return cloneItem(item)
|
|
return normalizeFolder({
|
|
...item,
|
|
apps: item.apps.filter((folderAppId) => folderAppId !== appId),
|
|
})
|
|
}
|
|
return {
|
|
dock: layout.dock.map(removeFromItem),
|
|
grid: compactGridPages(layout.grid.map(removeFromItem)),
|
|
hidden: layout.hidden.includes(appId)
|
|
? [...layout.hidden]
|
|
: [...layout.hidden, appId],
|
|
version: HOME_LAYOUT_VERSION,
|
|
}
|
|
}
|
|
|
|
export function restoreHomeApp(
|
|
layout: HomeLayout,
|
|
appId: LaunchablePhoneAppId,
|
|
): HomeLayout {
|
|
if (
|
|
layout.grid.some((item) => itemContainsApp(item, appId)) ||
|
|
layout.dock.some((item) => itemContainsApp(item, appId))
|
|
) {
|
|
return layout
|
|
}
|
|
const grid = layout.grid.map(cloneItem)
|
|
placeInFirstEmptySlot(grid, appId)
|
|
return {
|
|
dock: layout.dock.map(cloneItem),
|
|
grid: compactGridPages(grid),
|
|
hidden: layout.hidden.filter((id) => id !== appId),
|
|
version: HOME_LAYOUT_VERSION,
|
|
}
|
|
}
|
|
|
|
export function addHomePage(layout: HomeLayout): HomeLayout {
|
|
if (layout.grid.length >= HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) {
|
|
return layout
|
|
}
|
|
|
|
return {
|
|
dock: layout.dock.map(cloneItem),
|
|
grid: [...layout.grid.map(cloneItem), ...createSlots(HOME_GRID_PAGE_SIZE)],
|
|
hidden: [...layout.hidden],
|
|
version: HOME_LAYOUT_VERSION,
|
|
}
|
|
}
|
|
|
|
export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout {
|
|
const pageCount = Math.ceil(layout.grid.length / HOME_GRID_PAGE_SIZE)
|
|
if (pageCount <= 1 || page < 1 || page > pageCount) return layout
|
|
|
|
const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE
|
|
const grid = layout.grid.map(cloneItem)
|
|
const removedItems = grid
|
|
.splice(pageStart, HOME_GRID_PAGE_SIZE)
|
|
.filter((item): item is HomeItem => item !== null)
|
|
if (removedItems.length > grid.filter((item) => item === null).length) {
|
|
return layout
|
|
}
|
|
for (const item of removedItems) placeInFirstEmptySlot(grid, item)
|
|
|
|
return {
|
|
dock: layout.dock.map(cloneItem),
|
|
grid: compactGridPages(grid),
|
|
hidden: [...layout.hidden],
|
|
version: HOME_LAYOUT_VERSION,
|
|
}
|
|
}
|
|
|
|
export function moveHomeApp(
|
|
layout: HomeLayout,
|
|
from: HomeArea,
|
|
sourceIndex: number,
|
|
to: HomeArea,
|
|
targetIndex: number,
|
|
): HomeLayout {
|
|
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 === null ||
|
|
item === undefined ||
|
|
sourceIndex < 0 ||
|
|
sourceIndex >= sourceSlots.length ||
|
|
targetIndex < 0 ||
|
|
targetIndex >= maximumTargetIndex ||
|
|
(from === to && sourceIndex === targetIndex)
|
|
) {
|
|
return layout
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
export function createHomeFolder(
|
|
layout: HomeLayout,
|
|
from: HomeArea,
|
|
sourceIndex: number,
|
|
to: HomeArea,
|
|
targetIndex: number,
|
|
folderId: string,
|
|
name: string,
|
|
): HomeLayout {
|
|
if (!isPersistableFolderId(folderId) || folderLocation(layout, folderId)) {
|
|
return layout
|
|
}
|
|
const sourceItem = layout[from][sourceIndex]
|
|
const targetItem = layout[to][targetIndex]
|
|
if (
|
|
typeof sourceItem !== 'string' ||
|
|
typeof targetItem !== 'string' ||
|
|
(from === to && sourceIndex === targetIndex)
|
|
) {
|
|
return layout
|
|
}
|
|
|
|
const next = cloneLayout(layout)
|
|
next[from][sourceIndex] = null
|
|
next[to][targetIndex] = {
|
|
apps: [targetItem, sourceItem],
|
|
id: folderId,
|
|
name: name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH),
|
|
type: 'folder',
|
|
}
|
|
next.grid = compactGridPages(next.grid)
|
|
return next
|
|
}
|
|
|
|
export function addHomeAppToFolder(
|
|
layout: HomeLayout,
|
|
from: HomeArea,
|
|
sourceIndex: number,
|
|
folderId: string,
|
|
): HomeLayout {
|
|
const sourceItem = layout[from][sourceIndex]
|
|
const location = folderLocation(layout, folderId)
|
|
if (typeof sourceItem !== 'string' || !location) return layout
|
|
if (location.area === from && location.index === sourceIndex) return layout
|
|
|
|
const next = cloneLayout(layout)
|
|
const folder = next[location.area][location.index]
|
|
if (!isHomeFolder(folder)) return layout
|
|
next[from][sourceIndex] = null
|
|
folder.apps.push(sourceItem)
|
|
next.grid = compactGridPages(next.grid)
|
|
return next
|
|
}
|
|
|
|
export function renameHomeFolder(
|
|
layout: HomeLayout,
|
|
folderId: string,
|
|
name: string,
|
|
): HomeLayout {
|
|
const location = folderLocation(layout, folderId)
|
|
if (!location) return layout
|
|
const next = cloneLayout(layout)
|
|
const folder = next[location.area][location.index]
|
|
if (!isHomeFolder(folder)) return layout
|
|
const nextName = name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH)
|
|
if (folder.name === nextName) return layout
|
|
folder.name = nextName
|
|
return next
|
|
}
|
|
|
|
export function moveHomeFolderApp(
|
|
layout: HomeLayout,
|
|
folderId: string,
|
|
sourceIndex: number,
|
|
targetIndex: number,
|
|
): HomeLayout {
|
|
const location = folderLocation(layout, folderId)
|
|
if (!location) return layout
|
|
const folder = layout[location.area][location.index]
|
|
if (
|
|
!isHomeFolder(folder) ||
|
|
sourceIndex < 0 ||
|
|
sourceIndex >= folder.apps.length ||
|
|
targetIndex < 0 ||
|
|
targetIndex >= folder.apps.length ||
|
|
sourceIndex === targetIndex
|
|
) {
|
|
return layout
|
|
}
|
|
|
|
const next = cloneLayout(layout)
|
|
const nextFolder = next[location.area][location.index]
|
|
if (!isHomeFolder(nextFolder)) return layout
|
|
const sourceApp = nextFolder.apps[sourceIndex]
|
|
nextFolder.apps[sourceIndex] = nextFolder.apps[targetIndex]
|
|
nextFolder.apps[targetIndex] = sourceApp
|
|
return next
|
|
}
|
|
|
|
export function extractHomeFolderApp(
|
|
layout: HomeLayout,
|
|
folderId: string,
|
|
sourceIndex: number,
|
|
to: HomeArea,
|
|
targetIndex: number,
|
|
): HomeLayout {
|
|
const location = folderLocation(layout, folderId)
|
|
const target = layout[to][targetIndex]
|
|
if (
|
|
!location ||
|
|
target !== null ||
|
|
(location.area === to && location.index === targetIndex)
|
|
) {
|
|
return layout
|
|
}
|
|
const folder = layout[location.area][location.index]
|
|
if (
|
|
!isHomeFolder(folder) ||
|
|
sourceIndex < 0 ||
|
|
sourceIndex >= folder.apps.length
|
|
) {
|
|
return layout
|
|
}
|
|
|
|
const next = cloneLayout(layout)
|
|
const nextFolder = next[location.area][location.index]
|
|
if (!isHomeFolder(nextFolder)) return layout
|
|
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
|
|
}
|
|
|
|
export function homeKeyboardTarget(
|
|
layout: HomeLayout,
|
|
area: HomeArea,
|
|
sourceIndex: number,
|
|
direction: ReorderDirection,
|
|
): number | null {
|
|
const source = layout[area]
|
|
if (!source[sourceIndex]) return null
|
|
|
|
if (area === 'dock') {
|
|
if (direction !== 'left' && direction !== 'right') return null
|
|
const targetIndex = sourceIndex + (direction === 'left' ? -1 : 1)
|
|
return targetIndex >= 0 && targetIndex < source.length ? targetIndex : null
|
|
}
|
|
|
|
const column = sourceIndex % HOME_GRID_COLUMNS
|
|
if (direction === 'left' && column === 0) return null
|
|
if (direction === 'right' && column === HOME_GRID_COLUMNS - 1) return null
|
|
const deltas: Record<ReorderDirection, number> = {
|
|
down: HOME_GRID_COLUMNS,
|
|
left: -1,
|
|
right: 1,
|
|
up: -HOME_GRID_COLUMNS,
|
|
}
|
|
const targetIndex = sourceIndex + deltas[direction]
|
|
return targetIndex >= 0 && targetIndex < source.length ? targetIndex : null
|
|
}
|