mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-30 09:48:57 +00:00
1813 lines
55 KiB
Vue
1813 lines
55 KiB
Vue
<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, onBeforeUnmount, ref, watch } from 'vue'
|
|
|
|
import AppIcon from '@/components/AppIcon.vue'
|
|
import HomeFolderIcon from '@/components/HomeFolderIcon.vue'
|
|
import HomeFolderOverlay from '@/components/HomeFolderOverlay.vue'
|
|
import SpringboardWidgetGrid from '@/components/SpringboardWidgetGrid.vue'
|
|
import WidgetConfigSheet from '@/components/WidgetConfigSheet.vue'
|
|
import WidgetPickerSheet from '@/components/WidgetPickerSheet.vue'
|
|
import { getPhoneAppLabel, PHONE_APPS } from '@/config/apps'
|
|
import { useAppStoreStore } from '@/stores/app-store'
|
|
import { usePhoneStore } from '@/stores/phone'
|
|
import { useWidgetsStore } from '@/stores/widgets'
|
|
import type { PhoneAppCategory, PhoneAppDefinition } from '@/types/apps'
|
|
import type { LaunchablePhoneAppId } from '@/types/apps'
|
|
import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets'
|
|
import {
|
|
deleteHomePage as previewHomePageDelete,
|
|
getHomeFolder,
|
|
HOME_GRID_PAGE_SIZE,
|
|
HOME_GRID_ROWS,
|
|
homeKeyboardTarget,
|
|
isHomeFolder,
|
|
MAX_HOME_GRID_PAGES,
|
|
type HomeArea,
|
|
type HomeFolder,
|
|
type HomeItem,
|
|
} from '@/utils/homeLayout'
|
|
import type { ReorderDirection } from '@/utils/keyboard'
|
|
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
|
|
import {
|
|
maximumRenderedWidgetPage,
|
|
resolveSpringboardEdgeTurn,
|
|
resolveSpringboardHomeEdgeTurn,
|
|
springboardSwipeIntent,
|
|
springboardViewportDeltaToLocal,
|
|
springboardViewportToLocal,
|
|
} from '@/utils/springboardDrag'
|
|
import {
|
|
deleteWidgetPage as previewWidgetPageDelete,
|
|
moveWidget as previewWidgetMove,
|
|
WIDGET_GRID_COLUMNS,
|
|
widgetKeyboardTarget,
|
|
widgetOccupiedCells,
|
|
} from '@/utils/widgetLayout'
|
|
|
|
const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [
|
|
'games',
|
|
'productivity',
|
|
'social',
|
|
'utilities',
|
|
'shopping',
|
|
]
|
|
const HOME_FOLDER_HOVER_DURATION = 900
|
|
const phone = usePhoneStore()
|
|
const appStore = useAppStoreStore()
|
|
const widgets = useWidgetsStore()
|
|
const wallpaperStyle = computed<Record<string, string>>(() => {
|
|
const style: Record<string, string> = {}
|
|
const imageUrl = phone.preferences.settings.wallpaperImageUrl
|
|
if (phone.preferences.settings.wallpaper === 'custom' && imageUrl) {
|
|
style['--phone-wallpaper-image'] = `url(${JSON.stringify(imageUrl)})`
|
|
}
|
|
return style
|
|
})
|
|
const searchQuery = ref('')
|
|
const searchFocused = ref(false)
|
|
const showAllApps = ref(false)
|
|
const editMode = ref(false)
|
|
const widgetPickerOpened = ref(false)
|
|
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 widgetDragPreview = ref<{
|
|
column: number
|
|
id: string
|
|
page: number
|
|
row: number
|
|
} | null>(null)
|
|
const dragOffset = ref(0)
|
|
const dragging = ref(false)
|
|
const pageTransitioning = ref(false)
|
|
const draggingHomeApp = ref<{
|
|
area: HomeArea
|
|
index: number
|
|
} | null>(null)
|
|
const homeDragLayer = ref<HTMLElement | null>(null)
|
|
const homeDragVisualActive = ref(false)
|
|
const temporaryHomePage = ref<number | null>(null)
|
|
const openedFolderId = ref<string | null>(null)
|
|
const renameFolderOnOpenId = ref<string | null>(null)
|
|
const folderHoverTarget = ref('')
|
|
let pointerStart = 0
|
|
let pointerStartY = 0
|
|
let pointerStartedAt = 0
|
|
let pointerPageStart = 0
|
|
let backgroundHoldTimer: number | undefined
|
|
let blankTapCandidate = false
|
|
let widgetPreviewTimer: number | undefined
|
|
let pendingWidgetPointer: { clientX: number; clientY: number } | null = null
|
|
let lastWidgetPointer: { clientX: number; clientY: number } | null = null
|
|
let edgePageTimer: number | undefined
|
|
let edgePageDirection = 0
|
|
let edgePageLocked = false
|
|
let folderHoverTimer: number | undefined
|
|
let lastHomePointer: { clientX: number; clientY: number } | null = null
|
|
let homeDragGhost: HTMLElement | null = null
|
|
let homeDragGrip: { x: number; y: number } | null = null
|
|
|
|
const installedApps = computed(() =>
|
|
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
|
|
)
|
|
const installedAppsById = computed(
|
|
() => new Map(installedApps.value.map((app) => [app.id, app])),
|
|
)
|
|
const hiddenApps = computed(() =>
|
|
installedApps.value.filter((app) =>
|
|
appStore.homeLayout.hidden.includes(app.id),
|
|
),
|
|
)
|
|
const previewWidgetLayout = computed(() => {
|
|
const preview = widgetDragPreview.value
|
|
return preview
|
|
? previewWidgetMove(
|
|
widgets.layout,
|
|
preview.id,
|
|
preview.page,
|
|
preview.column,
|
|
preview.row,
|
|
)
|
|
: 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 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,
|
|
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,
|
|
)
|
|
if (widgetDragPreview.value) {
|
|
for (const cell of widgetOccupiedCells(widgets.layout.instances, page)) {
|
|
occupied.add(cell)
|
|
}
|
|
}
|
|
occupiedByPage.set(page, occupied)
|
|
}
|
|
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)
|
|
const isAppPage = computed(
|
|
() => phone.currentPage > 0 && phone.currentPage < libraryPage.value,
|
|
)
|
|
const isEditablePage = computed(
|
|
() => phone.currentPage >= 0 && phone.currentPage < libraryPage.value,
|
|
)
|
|
const canAddHomePage = computed(
|
|
() =>
|
|
appStore.homeLayout.grid.length < HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES,
|
|
)
|
|
const addingHomePage = ref(false)
|
|
const persistedHomePageCount = computed(() =>
|
|
Math.max(1, Math.ceil(appStore.homeLayout.grid.length / HOME_GRID_PAGE_SIZE)),
|
|
)
|
|
const canDeleteCurrentPage = computed(() => {
|
|
if (phone.currentPage < 1 || persistedHomePageCount.value <= 1) return false
|
|
const remainingPages = persistedHomePageCount.value - 1
|
|
return (
|
|
previewHomePageDelete(appStore.homeLayout, phone.currentPage) !==
|
|
appStore.homeLayout &&
|
|
previewWidgetPageDelete(
|
|
widgets.layout,
|
|
phone.currentPage,
|
|
remainingPages,
|
|
) !== widgets.layout
|
|
)
|
|
})
|
|
const dockSlots = computed(() =>
|
|
appStore.homeLayout.dock.map((item, index) => ({
|
|
app:
|
|
typeof item === 'string'
|
|
? (installedAppsById.value.get(item) ?? null)
|
|
: null,
|
|
folder: isHomeFolder(item) ? item : null,
|
|
index,
|
|
})),
|
|
)
|
|
const openedFolder = computed(() =>
|
|
openedFolderId.value
|
|
? getHomeFolder(appStore.homeLayout, openedFolderId.value)
|
|
: null,
|
|
)
|
|
const openedFolderApps = computed(() =>
|
|
openedFolder.value
|
|
? openedFolder.value.apps.flatMap((appId, index) => {
|
|
const app = installedAppsById.value.get(appId)
|
|
return app ? [{ app, index }] : []
|
|
})
|
|
: [],
|
|
)
|
|
const filteredApps = computed(() => {
|
|
const query = searchQuery.value.trim().toLocaleLowerCase(phone.lang)
|
|
if (!query) return installedApps.value
|
|
return installedApps.value.filter((app) =>
|
|
getPhoneAppLabel(app, phone.t)
|
|
.toLocaleLowerCase(phone.lang)
|
|
.includes(query),
|
|
)
|
|
})
|
|
const appGroups = computed(() => {
|
|
const suggestions = [...installedApps.value]
|
|
.sort(
|
|
(a, b) =>
|
|
(appStore.launchCounts[b.id] ?? 0) -
|
|
(appStore.launchCounts[a.id] ?? 0) || a.gridOrder - b.gridOrder,
|
|
)
|
|
.slice(0, 7)
|
|
const recentlyAdded = [...installedApps.value]
|
|
.sort((a, b) => b.gridOrder - a.gridOrder)
|
|
.slice(0, 7)
|
|
const groups = [
|
|
{ apps: suggestions, key: 'suggestions' },
|
|
{ apps: recentlyAdded, key: 'recentlyAdded' },
|
|
...APP_LIBRARY_CATEGORIES.map((category) => ({
|
|
apps: installedApps.value.filter((app) => app.category === category),
|
|
key: category,
|
|
})),
|
|
]
|
|
return groups
|
|
.filter((group) => group.apps.length > 0)
|
|
.map((group) => ({
|
|
...group,
|
|
apps: group.apps.slice(0, 3),
|
|
moreApps: group.apps.slice(3),
|
|
}))
|
|
})
|
|
const alphabeticalGroups = computed(() => {
|
|
const groups: Array<{ apps: PhoneAppDefinition[]; letter: string }> = []
|
|
for (const app of [...filteredApps.value].sort((a, b) =>
|
|
getPhoneAppLabel(a, phone.t).localeCompare(
|
|
getPhoneAppLabel(b, phone.t),
|
|
phone.lang,
|
|
),
|
|
)) {
|
|
const letter = getPhoneAppLabel(app, phone.t)
|
|
.charAt(0)
|
|
.toLocaleUpperCase(phone.lang)
|
|
const group = groups.find((candidate) => candidate.letter === letter)
|
|
if (group) group.apps.push(app)
|
|
else groups.push({ apps: [app], letter })
|
|
}
|
|
return groups
|
|
})
|
|
const trackStyle = computed(() => ({
|
|
'--drag-offset': `${dragOffset.value}px`,
|
|
'--springboard-offset': `${(-phone.currentPage * 100) / pageCount.value}%`,
|
|
width: `${pageCount.value * 100}%`,
|
|
}))
|
|
const pageStyle = computed(() => ({ width: `${100 / pageCount.value}%` }))
|
|
const activeWidget = computed(() =>
|
|
widgets.layout.instances.find(
|
|
(instance) => instance.id === widgetActionId.value,
|
|
),
|
|
)
|
|
const configuredWidget = computed(
|
|
() =>
|
|
widgets.layout.instances.find(
|
|
(instance) => instance.id === widgetConfigId.value,
|
|
) ?? null,
|
|
)
|
|
|
|
function clearBackgroundHold(): void {
|
|
if (backgroundHoldTimer !== undefined)
|
|
window.clearTimeout(backgroundHoldTimer)
|
|
backgroundHoldTimer = undefined
|
|
}
|
|
|
|
function changePage(page: number): void {
|
|
const targetPage = Math.max(0, Math.min(page, pageCount.value - 1))
|
|
if (targetPage === phone.currentPage) {
|
|
pageTransitioning.value = false
|
|
return
|
|
}
|
|
pageTransitioning.value = true
|
|
phone.setCurrentPage(targetPage, pageCount.value)
|
|
}
|
|
|
|
function finishPageTransition(event: TransitionEvent): void {
|
|
if (event.propertyName !== 'transform') return
|
|
pageTransitioning.value = false
|
|
}
|
|
|
|
function onPointerDown(event: PointerEvent): void {
|
|
const target = event.target as HTMLElement
|
|
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
|
|
blankTapCandidate = false
|
|
return
|
|
}
|
|
pointerStart = event.clientX
|
|
pointerStartY = event.clientY
|
|
pointerStartedAt = Date.now()
|
|
pointerPageStart = phone.currentPage
|
|
blankTapCandidate = editMode.value
|
|
if (editMode.value) return
|
|
dragging.value = true
|
|
if (!pageSwipeSurface) {
|
|
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
|
}
|
|
clearBackgroundHold()
|
|
if (pageSwipeSurface) return
|
|
backgroundHoldTimer = window.setTimeout(() => {
|
|
enterEditMode()
|
|
backgroundHoldTimer = undefined
|
|
}, 520)
|
|
}
|
|
|
|
function onPointerMove(event: PointerEvent): void {
|
|
const distanceX = event.clientX - pointerStart
|
|
const distanceY = event.clientY - pointerStartY
|
|
const intent = springboardSwipeIntent(distanceX, distanceY)
|
|
if (intent === 'pending') return
|
|
clearBackgroundHold()
|
|
blankTapCandidate = false
|
|
if (!dragging.value) return
|
|
if (intent === 'vertical') {
|
|
dragging.value = false
|
|
dragOffset.value = 0
|
|
return
|
|
}
|
|
const atFirstPage = pointerPageStart === 0 && distanceX > 0
|
|
const atLastPage = pointerPageStart === pageCount.value - 1 && distanceX < 0
|
|
dragOffset.value = atFirstPage || atLastPage ? distanceX * 0.28 : distanceX
|
|
}
|
|
|
|
function finishPointer(event: PointerEvent): void {
|
|
clearBackgroundHold()
|
|
if (editMode.value && blankTapCandidate) {
|
|
blankTapCandidate = false
|
|
editMode.value = false
|
|
return
|
|
}
|
|
if (!dragging.value) return
|
|
const distance = event.clientX - pointerStart
|
|
const elapsed = Math.max(1, Date.now() - pointerStartedAt)
|
|
const velocity = Math.abs(distance) / elapsed
|
|
if (Math.abs(distance) > 48 || velocity > 0.45) {
|
|
changePage(pointerPageStart + (distance < 0 ? 1 : -1))
|
|
}
|
|
dragging.value = false
|
|
dragOffset.value = 0
|
|
}
|
|
|
|
function cancelPointer(): void {
|
|
clearBackgroundHold()
|
|
blankTapCandidate = false
|
|
dragging.value = false
|
|
dragOffset.value = 0
|
|
}
|
|
|
|
function enterEditMode(): void {
|
|
editMode.value = true
|
|
dragging.value = false
|
|
dragOffset.value = 0
|
|
}
|
|
|
|
function openWidgetMenu(id: string): void {
|
|
enterEditMode()
|
|
widgetActionId.value = id
|
|
}
|
|
|
|
function startWidgetDrag(id: string, event: PointerEvent): void {
|
|
const widget = Array.from(
|
|
document.querySelectorAll<HTMLElement>('[data-widget-id]'),
|
|
).find((candidate) => candidate.dataset.widgetId === id)
|
|
if (!widget) return
|
|
pageTransitioning.value = false
|
|
const bounds = widget.getBoundingClientRect()
|
|
draggingWidgetId.value = id
|
|
widgetDragGrip.value = {
|
|
x: event.clientX - bounds.left,
|
|
y: event.clientY - bounds.top,
|
|
}
|
|
}
|
|
|
|
function updateWidgetDragPreview(event: {
|
|
clientX: number
|
|
clientY: number
|
|
}): void {
|
|
const id = draggingWidgetId.value
|
|
const grip = widgetDragGrip.value
|
|
if (!id || !grip) return
|
|
const page = phone.currentPage
|
|
const grid = document.querySelector<HTMLElement>(
|
|
`[data-widget-page="${page}"]`,
|
|
)
|
|
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
|
const springboard = grid?.closest<HTMLElement>('.springboard')
|
|
if (!grid || !pageElement || !springboard) return
|
|
const renderedGridBounds = grid.getBoundingClientRect()
|
|
const pageBounds = pageElement.getBoundingClientRect()
|
|
const springboardBounds = springboard.getBoundingClientRect()
|
|
const gridLeft =
|
|
springboardBounds.left + (renderedGridBounds.left - pageBounds.left)
|
|
const gridStyle = getComputedStyle(grid)
|
|
const columnGap = Number.parseFloat(gridStyle.columnGap) || 0
|
|
const rowGap = Number.parseFloat(gridStyle.rowGap) || 0
|
|
const columnWidth =
|
|
(renderedGridBounds.width - columnGap * (WIDGET_GRID_COLUMNS - 1)) /
|
|
WIDGET_GRID_COLUMNS
|
|
const rowHeight = Number.parseFloat(gridStyle.gridAutoRows)
|
|
const column = Math.round(
|
|
(event.clientX - grip.x - gridLeft) / (columnWidth + columnGap),
|
|
)
|
|
const row = Math.round(
|
|
(event.clientY - grip.y - renderedGridBounds.top) / (rowHeight + rowGap),
|
|
)
|
|
if (Number.isFinite(rowHeight)) {
|
|
const current = widgetDragPreview.value
|
|
if (
|
|
!current ||
|
|
current.id !== id ||
|
|
current.page !== page ||
|
|
current.column !== column ||
|
|
current.row !== row
|
|
) {
|
|
widgetDragPreview.value = { column, id, page, row }
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearEdgePageTurn(): void {
|
|
if (edgePageTimer !== undefined) window.clearTimeout(edgePageTimer)
|
|
edgePageTimer = undefined
|
|
edgePageDirection = 0
|
|
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',
|
|
): void {
|
|
const springboard = document.querySelector<HTMLElement>('.springboard')
|
|
if (!springboard) return
|
|
const bounds = springboard.getBoundingClientRect()
|
|
const turn =
|
|
dragType === 'app'
|
|
? resolveHomeEdgeTurn(event)
|
|
: resolveSpringboardEdgeTurn(
|
|
event.clientX,
|
|
bounds.left,
|
|
bounds.right,
|
|
phone.currentPage,
|
|
0,
|
|
libraryPage.value - 1,
|
|
)
|
|
|
|
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 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)
|
|
}
|
|
edgePageTimer = undefined
|
|
edgePageDirection = direction
|
|
edgePageLocked = true
|
|
}, 520)
|
|
}
|
|
|
|
function queueWidgetDragPreview(event: PointerEvent): void {
|
|
queueEdgePageTurn(event, 'widget')
|
|
lastWidgetPointer = {
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
}
|
|
pendingWidgetPointer = lastWidgetPointer
|
|
if (widgetPreviewTimer !== undefined) return
|
|
widgetPreviewTimer = window.setTimeout(() => {
|
|
if (pendingWidgetPointer) updateWidgetDragPreview(pendingWidgetPointer)
|
|
pendingWidgetPointer = null
|
|
widgetPreviewTimer = undefined
|
|
}, 110)
|
|
}
|
|
|
|
function clearWidgetDragPreview(): void {
|
|
clearEdgePageTurn()
|
|
if (widgetPreviewTimer !== undefined) window.clearTimeout(widgetPreviewTimer)
|
|
widgetPreviewTimer = undefined
|
|
pendingWidgetPointer = null
|
|
lastWidgetPointer = null
|
|
widgetDragGrip.value = null
|
|
widgetDragPreview.value = null
|
|
}
|
|
|
|
function finishWidgetDrag(event: PointerEvent): void {
|
|
const id = draggingWidgetId.value
|
|
if (!id) return
|
|
lastWidgetPointer = { clientX: event.clientX, clientY: event.clientY }
|
|
updateWidgetDragPreview(lastWidgetPointer)
|
|
const preview = widgetDragPreview.value
|
|
if (preview?.id === id) {
|
|
widgets.move(id, preview.page, preview.column, preview.row)
|
|
}
|
|
draggingWidgetId.value = null
|
|
clearWidgetDragPreview()
|
|
}
|
|
|
|
function stopWidgetDrag(): void {
|
|
draggingWidgetId.value = null
|
|
clearWidgetDragPreview()
|
|
}
|
|
|
|
function reorderWidget(id: string, direction: ReorderDirection): void {
|
|
const instance = widgets.layout.instances.find((widget) => widget.id === id)
|
|
if (!instance) return
|
|
const target = widgetKeyboardTarget(instance, direction)
|
|
if (!target) return
|
|
widgets.move(id, instance.page, target.column, target.row)
|
|
}
|
|
|
|
function removeWidget(id: string): void {
|
|
widgets.remove(id)
|
|
if (widgetActionId.value === id) widgetActionId.value = null
|
|
if (widgetConfigId.value === id) widgetConfigId.value = null
|
|
}
|
|
|
|
async function addWidget(kind: WidgetKind, size: WidgetSize): Promise<void> {
|
|
const targetPage = isEditablePage.value ? phone.currentPage : 1
|
|
const addedId = widgets.add(kind, size, targetPage)
|
|
if (!addedId) return
|
|
widgetPickerOpened.value = false
|
|
editMode.value = true
|
|
await nextTick()
|
|
const added = widgets.layout.instances.find(
|
|
(instance) => instance.id === addedId,
|
|
)
|
|
if (added) changePage(added.page)
|
|
}
|
|
|
|
function openWidgetConfig(): void {
|
|
widgetConfigId.value = widgetActionId.value
|
|
widgetActionId.value = null
|
|
}
|
|
|
|
async function saveWidgetConfig(
|
|
size: WidgetSize,
|
|
settings: WidgetSettings,
|
|
): Promise<void> {
|
|
if (!widgetConfigId.value) return
|
|
const id = widgetConfigId.value
|
|
widgets.resize(id, size)
|
|
widgets.updateSettings(id, settings)
|
|
widgetConfigId.value = null
|
|
editMode.value = true
|
|
await nextTick()
|
|
const configured = widgets.layout.instances.find(
|
|
(instance) => instance.id === id,
|
|
)
|
|
if (configured) changePage(configured.page)
|
|
}
|
|
|
|
function homeDragLocalPoint(
|
|
clientX: number,
|
|
clientY: number,
|
|
): {
|
|
x: number
|
|
y: number
|
|
} | null {
|
|
const layer = homeDragLayer.value
|
|
if (!layer) return null
|
|
const bounds = layer.getBoundingClientRect()
|
|
return springboardViewportToLocal(
|
|
clientX,
|
|
clientY,
|
|
bounds.left,
|
|
bounds.top,
|
|
bounds.width,
|
|
bounds.height,
|
|
layer.offsetWidth,
|
|
layer.offsetHeight,
|
|
)
|
|
}
|
|
|
|
function clearHomeDragGhost(expectedGhost?: HTMLElement | null): void {
|
|
if (expectedGhost && homeDragGhost !== expectedGhost) return
|
|
homeDragGhost?.remove()
|
|
homeDragGhost = null
|
|
homeDragGrip = null
|
|
homeDragVisualActive.value = false
|
|
}
|
|
|
|
function updateHomeDragGhost(event: {
|
|
clientX: number
|
|
clientY: number
|
|
}): void {
|
|
const point = homeDragLocalPoint(event.clientX, event.clientY)
|
|
if (!homeDragGhost || !homeDragGrip || !point) return
|
|
homeDragGhost.style.transform = `translate3d(${point.x - homeDragGrip.x}px, ${point.y - homeDragGrip.y}px, 0)`
|
|
}
|
|
|
|
function createHomeDragGhost(event: PointerEvent): void {
|
|
clearHomeDragGhost()
|
|
const eventTarget =
|
|
event.currentTarget instanceof Element
|
|
? event.currentTarget
|
|
: event.target instanceof Element
|
|
? event.target
|
|
: null
|
|
const source = eventTarget?.closest<HTMLElement>('.app-icon-item')
|
|
const layer = homeDragLayer.value
|
|
if (!source || !layer) return
|
|
|
|
const layerBounds = layer.getBoundingClientRect()
|
|
const sourceBounds = source.getBoundingClientRect()
|
|
const sourceOrigin = springboardViewportToLocal(
|
|
sourceBounds.left,
|
|
sourceBounds.top,
|
|
layerBounds.left,
|
|
layerBounds.top,
|
|
layerBounds.width,
|
|
layerBounds.height,
|
|
layer.offsetWidth,
|
|
layer.offsetHeight,
|
|
)
|
|
const pointer = homeDragLocalPoint(event.clientX, event.clientY)
|
|
if (!pointer) return
|
|
|
|
const ghost = source.cloneNode(true) as HTMLElement
|
|
ghost.classList.remove(
|
|
'app-icon-item--dragging',
|
|
'app-icon-item--editing',
|
|
'app-icon-item--drag-source',
|
|
'app-icon-item--drop-settling',
|
|
'home-folder-item--dragging',
|
|
'home-item--folder-hover',
|
|
)
|
|
ghost.classList.add('home-drag-ghost')
|
|
ghost.removeAttribute('style')
|
|
ghost.setAttribute('aria-hidden', 'true')
|
|
ghost.querySelector('.app-icon-remove')?.remove()
|
|
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
|
|
element.removeAttribute('id')
|
|
for (const attribute of element.getAttributeNames()) {
|
|
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
|
|
}
|
|
}
|
|
for (const control of ghost.querySelectorAll<HTMLElement>(
|
|
'a, button, input, select, textarea, [tabindex]',
|
|
)) {
|
|
control.tabIndex = -1
|
|
}
|
|
|
|
const localScaleX =
|
|
layerBounds.width > 0 ? layer.offsetWidth / layerBounds.width : 1
|
|
const localScaleY =
|
|
layerBounds.height > 0 ? layer.offsetHeight / layerBounds.height : 1
|
|
ghost.style.width = `${sourceBounds.width * localScaleX}px`
|
|
ghost.style.height = `${sourceBounds.height * localScaleY}px`
|
|
homeDragGhost = ghost
|
|
homeDragGrip = {
|
|
x: pointer.x - sourceOrigin.x,
|
|
y: pointer.y - sourceOrigin.y,
|
|
}
|
|
layer.appendChild(ghost)
|
|
homeDragVisualActive.value = true
|
|
updateHomeDragGhost(event)
|
|
}
|
|
|
|
function startHomeDrag(
|
|
area: HomeArea,
|
|
index: number,
|
|
event: PointerEvent,
|
|
): void {
|
|
clearTemporaryHomePage()
|
|
lastHomePointer = null
|
|
draggingHomeApp.value = { area, index }
|
|
createHomeDragGhost(event)
|
|
}
|
|
|
|
function clearFolderHover(): void {
|
|
if (folderHoverTimer !== undefined) window.clearTimeout(folderHoverTimer)
|
|
folderHoverTimer = undefined
|
|
folderHoverTarget.value = ''
|
|
}
|
|
|
|
function queueFolderHover(event: PointerEvent): void {
|
|
const dragged = draggingHomeApp.value
|
|
if (!dragged) return
|
|
const sourceItem = appStore.homeLayout[dragged.area][dragged.index]
|
|
if (typeof sourceItem !== 'string') {
|
|
clearFolderHover()
|
|
return
|
|
}
|
|
|
|
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'))
|
|
const area = targetElement?.dataset.homeArea as HomeArea | undefined
|
|
const targetIndex = Number(targetElement?.dataset.homeIndex)
|
|
if (
|
|
(area !== 'grid' && area !== 'dock') ||
|
|
!Number.isInteger(targetIndex) ||
|
|
(area === dragged.area && targetIndex === dragged.index)
|
|
) {
|
|
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()
|
|
return
|
|
}
|
|
|
|
const targetKey = `${area}:${targetIndex}`
|
|
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 currentTarget = appStore.homeLayout[area][targetIndex]
|
|
if (typeof currentSource !== 'string') return
|
|
|
|
let folderId: string | null = null
|
|
if (typeof currentTarget === 'string') {
|
|
folderId = appStore.createHomeFolder(
|
|
currentDrag.area,
|
|
currentDrag.index,
|
|
area,
|
|
targetIndex,
|
|
phone.t('Home.folders.defaultName'),
|
|
)
|
|
if (folderId) renameFolderOnOpenId.value = folderId
|
|
} else if (isHomeFolder(currentTarget)) {
|
|
folderId = appStore.addHomeAppToFolder(
|
|
currentDrag.area,
|
|
currentDrag.index,
|
|
currentTarget.id,
|
|
)
|
|
? currentTarget.id
|
|
: null
|
|
}
|
|
if (folderId) {
|
|
draggingHomeApp.value = null
|
|
lastHomePointer = null
|
|
clearHomeDragGhost()
|
|
clearTemporaryHomePage()
|
|
openedFolderId.value = folderId
|
|
}
|
|
clearFolderHover()
|
|
}, HOME_FOLDER_HOVER_DURATION)
|
|
}
|
|
|
|
function moveHomeDrag(event: PointerEvent): void {
|
|
if (!draggingHomeApp.value) return
|
|
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
|
updateHomeDragGhost(event)
|
|
if (resolveHomeEdgeTurn(event)) {
|
|
clearFolderHover()
|
|
queueEdgePageTurn(event, 'app')
|
|
return
|
|
}
|
|
clearEdgePageTurn()
|
|
queueFolderHover(event)
|
|
}
|
|
|
|
async function animateHomeItemDrop(
|
|
item: HomeItem,
|
|
area: HomeArea,
|
|
index: number,
|
|
from: DOMRect,
|
|
): Promise<void> {
|
|
await nextTick()
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
|
|
|
const appElement = Array.from(
|
|
document.querySelectorAll<HTMLElement>('[data-home-item-key]'),
|
|
).find(
|
|
(element) =>
|
|
element.dataset.homeItemKey ===
|
|
(isHomeFolder(item) ? item.id : `app-${item}`) &&
|
|
element.dataset.homeArea === area &&
|
|
Number(element.dataset.homeIndex) === index,
|
|
)
|
|
if (!appElement) return
|
|
|
|
const target = appElement.getBoundingClientRect()
|
|
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
|
|
from.left - target.left,
|
|
from.top - target.top,
|
|
target.width,
|
|
target.height,
|
|
appElement.offsetWidth,
|
|
appElement.offsetHeight,
|
|
)
|
|
if (Math.hypot(offsetX, offsetY) < 1) return
|
|
|
|
appElement.style.setProperty('--home-app-drop-x', `${offsetX}px`)
|
|
appElement.style.setProperty('--home-app-drop-y', `${offsetY}px`)
|
|
let cleanedUp = false
|
|
const cleanUpAnimation = (event: AnimationEvent): void => {
|
|
if (
|
|
event.target !== appElement ||
|
|
event.animationName !== 'home-app-drop-settle'
|
|
) {
|
|
return
|
|
}
|
|
if (cleanedUp) return
|
|
cleanedUp = true
|
|
appElement.classList.remove('app-icon-item--drop-settling')
|
|
appElement.style.removeProperty('--home-app-drop-x')
|
|
appElement.style.removeProperty('--home-app-drop-y')
|
|
appElement.removeEventListener('animationend', cleanUpAnimation)
|
|
appElement.removeEventListener('animationcancel', cleanUpAnimation)
|
|
}
|
|
appElement.addEventListener('animationend', cleanUpAnimation)
|
|
appElement.addEventListener('animationcancel', cleanUpAnimation)
|
|
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()
|
|
const dragged = draggingHomeApp.value
|
|
if (!dragged) {
|
|
clearHomeDragGhost()
|
|
return
|
|
}
|
|
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
|
|
const dropGhost = homeDragGhost
|
|
const dropOrigin = dropGhost?.getBoundingClientRect()
|
|
const dockTarget = nearestDockDropTarget(event)
|
|
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
|
|
let dropArea = dragged.area
|
|
let dropIndex = dragged.index
|
|
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
|
|
lastHomePointer = null
|
|
clearTemporaryHomePage(moved && gridTarget !== null)
|
|
if (moved && draggedItem && dropOrigin) {
|
|
void animateHomeItemDrop(
|
|
draggedItem,
|
|
dropArea,
|
|
dropIndex,
|
|
dropOrigin,
|
|
).finally(() => clearHomeDragGhost(dropGhost))
|
|
} else {
|
|
clearHomeDragGhost()
|
|
}
|
|
}
|
|
|
|
function stopHomeDrag(): void {
|
|
clearEdgePageTurn()
|
|
clearFolderHover()
|
|
draggingHomeApp.value = null
|
|
lastHomePointer = null
|
|
clearHomeDragGhost()
|
|
clearTemporaryHomePage()
|
|
}
|
|
|
|
function reorderHomeApp(
|
|
area: HomeArea,
|
|
sourceIndex: number,
|
|
direction: ReorderDirection,
|
|
): void {
|
|
const item = appStore.homeLayout[area][sourceIndex]
|
|
const appElement = document.querySelector<HTMLElement>(
|
|
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
|
|
)
|
|
const moveOrigin = appElement?.getBoundingClientRect()
|
|
const targetIndex = homeKeyboardTarget(
|
|
appStore.homeLayout,
|
|
area,
|
|
sourceIndex,
|
|
direction,
|
|
)
|
|
if (targetIndex === null) return
|
|
appStore.moveHomeApp(area, sourceIndex, area, targetIndex)
|
|
if (item && moveOrigin) {
|
|
const movedIndex = findHomeItemIndex(item, area, targetIndex) ?? targetIndex
|
|
void animateHomeItemDrop(item, area, movedIndex, moveOrigin)
|
|
}
|
|
}
|
|
|
|
async function addHomePage(): Promise<void> {
|
|
if (addingHomePage.value) return
|
|
addingHomePage.value = true
|
|
try {
|
|
if (!appStore.addHomePage()) return
|
|
await nextTick()
|
|
changePage(appPages.value.length)
|
|
} finally {
|
|
addingHomePage.value = false
|
|
}
|
|
}
|
|
|
|
async function deleteCurrentPage(): Promise<void> {
|
|
if (!canDeleteCurrentPage.value) return
|
|
const deletedPage = phone.currentPage
|
|
const remainingPages = persistedHomePageCount.value - 1
|
|
widgets.deletePage(deletedPage, remainingPages)
|
|
appStore.deleteHomePage(deletedPage)
|
|
await nextTick()
|
|
changePage(Math.min(deletedPage, appPages.value.length))
|
|
}
|
|
|
|
function removeHomeApp(appId: LaunchablePhoneAppId): void {
|
|
appStore.removeHomeApp(appId)
|
|
}
|
|
|
|
function restoreHomeApp(appId: LaunchablePhoneAppId): void {
|
|
appStore.restoreHomeApp(appId)
|
|
}
|
|
|
|
function folderPreviewApps(folder: HomeFolder): PhoneAppDefinition[] {
|
|
const apps: PhoneAppDefinition[] = []
|
|
for (const appId of folder.apps) {
|
|
const app = installedAppsById.value.get(appId)
|
|
if (app) apps.push(app)
|
|
}
|
|
return apps
|
|
}
|
|
|
|
function openFolder(folderId: string): void {
|
|
pageTransitioning.value = false
|
|
openedFolderId.value = folderId
|
|
}
|
|
|
|
function closeFolder(): void {
|
|
openedFolderId.value = null
|
|
renameFolderOnOpenId.value = null
|
|
}
|
|
|
|
function renameOpenedFolder(name: string): void {
|
|
if (!openedFolderId.value) return
|
|
appStore.renameHomeFolder(openedFolderId.value, name)
|
|
}
|
|
|
|
function moveOpenedFolderApp(sourceIndex: number, targetIndex: number): void {
|
|
if (!openedFolderId.value) return
|
|
appStore.moveHomeFolderApp(openedFolderId.value, sourceIndex, targetIndex)
|
|
}
|
|
|
|
function extractOpenedFolderApp(
|
|
sourceIndex: number,
|
|
event: PointerEvent,
|
|
): void {
|
|
const folder = openedFolder.value
|
|
if (!folder) return
|
|
const appId = folder.apps[sourceIndex]
|
|
const sourceElement = (event.currentTarget as HTMLElement | null)?.closest(
|
|
'.app-icon-item',
|
|
)
|
|
const sourceBounds = sourceElement?.getBoundingClientRect()
|
|
const candidates = Array.from(
|
|
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
|
|
).filter((element) => {
|
|
const area = element.dataset.homeArea as HomeArea
|
|
const index = Number(element.dataset.homeIndex)
|
|
return (
|
|
(area === 'grid' || area === 'dock') &&
|
|
Number.isInteger(index) &&
|
|
appStore.homeLayout[area][index] === null
|
|
)
|
|
})
|
|
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
|
|
if (!closest) return candidate
|
|
const bounds = candidate.getBoundingClientRect()
|
|
const closestBounds = closest.getBoundingClientRect()
|
|
const distance = Math.hypot(
|
|
event.clientX - (bounds.left + bounds.width / 2),
|
|
event.clientY - (bounds.top + bounds.height / 2),
|
|
)
|
|
const closestDistance = Math.hypot(
|
|
event.clientX - (closestBounds.left + closestBounds.width / 2),
|
|
event.clientY - (closestBounds.top + closestBounds.height / 2),
|
|
)
|
|
return distance < closestDistance ? candidate : closest
|
|
}, null)
|
|
if (!target) return
|
|
|
|
const area = target.dataset.homeArea as HomeArea
|
|
const targetIndex = Number(target.dataset.homeIndex)
|
|
if (
|
|
!appStore.extractHomeFolderApp(folder.id, sourceIndex, area, targetIndex)
|
|
) {
|
|
return
|
|
}
|
|
if (sourceBounds) {
|
|
const extractedIndex = findHomeItemIndex(appId, area, targetIndex)
|
|
if (extractedIndex !== null) {
|
|
void animateHomeItemDrop(appId, area, extractedIndex, sourceBounds)
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearSearch(): void {
|
|
searchQuery.value = ''
|
|
searchFocused.value = false
|
|
showAllApps.value = false
|
|
}
|
|
|
|
function openAllApps(): void {
|
|
searchFocused.value = true
|
|
showAllApps.value = true
|
|
}
|
|
|
|
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
|
|
clearHomeDragGhost()
|
|
temporaryHomePage.value = null
|
|
draggingHomeApp.value = null
|
|
draggingWidgetId.value = null
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<section
|
|
class="springboard"
|
|
:class="[
|
|
`wallpaper--${phone.preferences.settings.wallpaper}`,
|
|
{
|
|
'springboard--dragging': dragging,
|
|
'springboard--editing': editMode,
|
|
'springboard--folder-open': openedFolder !== null,
|
|
'springboard--widget-dragging': draggingWidgetId !== null,
|
|
},
|
|
]"
|
|
:style="wallpaperStyle"
|
|
>
|
|
<div
|
|
class="springboard-track"
|
|
:style="trackStyle"
|
|
@pointerdown="onPointerDown"
|
|
@pointermove="onPointerMove"
|
|
@pointerup="finishPointer"
|
|
@pointercancel="cancelPointer"
|
|
@transitionend.self="finishPageTransition"
|
|
@transitioncancel.self="finishPageTransition"
|
|
>
|
|
<section
|
|
class="springboard-page springboard-page--widgets"
|
|
:style="pageStyle"
|
|
:aria-label="phone.t('Home.widgets.label')"
|
|
>
|
|
<div class="springboard-widget-page-scroll">
|
|
<SpringboardWidgetGrid
|
|
:page="0"
|
|
:dragging-widget-id="draggingWidgetId"
|
|
:edit-mode="editMode"
|
|
@dragcancel="stopWidgetDrag"
|
|
@dragend="finishWidgetDrag"
|
|
@dragmove="queueWidgetDragPreview"
|
|
@dragstart="startWidgetDrag"
|
|
@menu="openWidgetMenu"
|
|
@remove="removeWidget"
|
|
@reorder="reorderWidget"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
v-for="page in appPages"
|
|
:key="`apps-${page.page}`"
|
|
class="springboard-page springboard-page--apps"
|
|
:style="pageStyle"
|
|
:aria-label="phone.t('Home.apps')"
|
|
:data-home-page="page.page"
|
|
>
|
|
<SpringboardWidgetGrid
|
|
:page="page.page"
|
|
:dragging-widget-id="draggingWidgetId"
|
|
:edit-mode="editMode"
|
|
@dragcancel="stopWidgetDrag"
|
|
@dragend="finishWidgetDrag"
|
|
@dragmove="queueWidgetDragPreview"
|
|
@dragstart="startWidgetDrag"
|
|
@menu="openWidgetMenu"
|
|
@remove="removeWidget"
|
|
@reorder="reorderWidget"
|
|
/>
|
|
<TransitionGroup
|
|
:name="
|
|
draggingWidgetId && !pageTransitioning ? 'app-reflow' : undefined
|
|
"
|
|
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">
|
|
<AppIcon
|
|
v-if="cell.app && cell.sourceIndex !== null"
|
|
:app="cell.app"
|
|
data-home-area="grid"
|
|
: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"
|
|
:external-drag-visual="homeDragVisualActive"
|
|
:class="{
|
|
'home-item--folder-hover':
|
|
folderHoverTarget === `grid:${cell.sourceIndex}`,
|
|
}"
|
|
@dragcancel="stopHomeDrag"
|
|
@dragend="finishHomeDrag"
|
|
@dragmove="moveHomeDrag"
|
|
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
|
@edit="enterEditMode"
|
|
@remove="removeHomeApp(cell.app.id)"
|
|
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
|
/>
|
|
<HomeFolderIcon
|
|
v-else-if="cell.folder && cell.sourceIndex !== null"
|
|
:apps="folderPreviewApps(cell.folder)"
|
|
:default-name="phone.t('Home.folders.defaultName')"
|
|
data-home-area="grid"
|
|
: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"
|
|
:external-drag-visual="homeDragVisualActive"
|
|
:folder="cell.folder"
|
|
:class="{
|
|
'home-item--folder-hover':
|
|
folderHoverTarget === `grid:${cell.sourceIndex}`,
|
|
}"
|
|
@dragcancel="stopHomeDrag"
|
|
@dragend="finishHomeDrag"
|
|
@dragmove="moveHomeDrag"
|
|
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
|
@edit="enterEditMode"
|
|
@open="openFolder(cell.folder.id)"
|
|
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
|
/>
|
|
<div
|
|
v-else
|
|
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>
|
|
</TransitionGroup>
|
|
</section>
|
|
|
|
<section
|
|
class="springboard-page springboard-page--library"
|
|
:style="pageStyle"
|
|
:aria-label="phone.t('Home.appLibrary')"
|
|
>
|
|
<div
|
|
class="app-library-search"
|
|
:class="{ 'app-library-search--focused': searchFocused }"
|
|
>
|
|
<Search :size="16" aria-hidden="true" />
|
|
<input
|
|
v-model="searchQuery"
|
|
type="search"
|
|
:placeholder="phone.t('Home.appLibrarySearch')"
|
|
@focus="openAllApps"
|
|
/>
|
|
<button
|
|
v-if="searchQuery || searchFocused"
|
|
type="button"
|
|
:aria-label="phone.t('Common.cancel')"
|
|
@click="clearSearch"
|
|
>
|
|
<X :size="15" />
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
class="app-library-groups"
|
|
:class="{ 'app-library-groups--behind': showAllApps }"
|
|
>
|
|
<article
|
|
v-if="hiddenApps.length"
|
|
class="app-library-group app-library-group--removed"
|
|
>
|
|
<div class="app-library-group__icons">
|
|
<div
|
|
v-for="app in hiddenApps.slice(0, 4)"
|
|
:key="app.id"
|
|
class="app-library-restore-item"
|
|
>
|
|
<AppIcon :app="app" compact :show-label="false" />
|
|
<k-button
|
|
small
|
|
rounded
|
|
class="app-library-restore-button"
|
|
:aria-label="
|
|
phone.t('Home.addToHome', {
|
|
app: getPhoneAppLabel(app, phone.t),
|
|
})
|
|
"
|
|
@click.stop="restoreHomeApp(app.id)"
|
|
>
|
|
<Plus :size="13" :stroke-width="3" />
|
|
</k-button>
|
|
</div>
|
|
</div>
|
|
<span>{{ phone.t('Home.removedFromHome') }}</span>
|
|
</article>
|
|
<article
|
|
v-for="group in appGroups"
|
|
:key="group.key"
|
|
class="app-library-group"
|
|
>
|
|
<div class="app-library-group__icons">
|
|
<AppIcon
|
|
v-for="app in group.apps"
|
|
:key="app.id"
|
|
:app="app"
|
|
compact
|
|
:show-label="false"
|
|
/>
|
|
<button
|
|
v-if="group.moreApps.length"
|
|
class="app-library-more"
|
|
type="button"
|
|
:aria-label="phone.t('Home.allApps')"
|
|
@click="openAllApps"
|
|
>
|
|
<img
|
|
v-for="app in group.moreApps.slice(0, 4)"
|
|
:key="app.id"
|
|
:src="app.iconImage"
|
|
alt=""
|
|
draggable="false"
|
|
/>
|
|
</button>
|
|
</div>
|
|
<span>{{ phone.t(`Home.groups.${group.key}`) }}</span>
|
|
</article>
|
|
</div>
|
|
|
|
<Transition name="app-library-all">
|
|
<section
|
|
v-if="showAllApps"
|
|
class="app-library-all"
|
|
:aria-label="phone.t('Home.allApps')"
|
|
>
|
|
<div
|
|
v-for="group in alphabeticalGroups"
|
|
:key="group.letter"
|
|
class="app-library-letter"
|
|
>
|
|
<h2>{{ group.letter }}</h2>
|
|
<div
|
|
v-for="app in group.apps"
|
|
:key="app.id"
|
|
class="app-library-row"
|
|
>
|
|
<AppIcon :app="app" compact :show-label="false" />
|
|
<span>{{ getPhoneAppLabel(app, phone.t) }}</span>
|
|
<k-button
|
|
v-if="appStore.homeLayout.hidden.includes(app.id)"
|
|
small
|
|
rounded
|
|
class="app-library-row-restore"
|
|
:aria-label="
|
|
phone.t('Home.addToHome', {
|
|
app: getPhoneAppLabel(app, phone.t),
|
|
})
|
|
"
|
|
@click.stop="restoreHomeApp(app.id)"
|
|
>
|
|
<Plus :size="14" :stroke-width="3" />
|
|
</k-button>
|
|
</div>
|
|
</div>
|
|
<p v-if="filteredApps.length === 0" class="app-library-empty">
|
|
{{ phone.t('Home.noApps') }}
|
|
</p>
|
|
</section>
|
|
</Transition>
|
|
</section>
|
|
</div>
|
|
|
|
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
|
|
|
|
<Transition name="edit-done">
|
|
<k-glass
|
|
v-if="editMode && isEditablePage && !openedFolder"
|
|
component="button"
|
|
class="springboard-edit-add"
|
|
type="button"
|
|
:aria-label="phone.t('Home.widgetSystem.add')"
|
|
@click="widgetPickerOpened = true"
|
|
>
|
|
<Plus :size="20" :stroke-width="2.5" />
|
|
</k-glass>
|
|
</Transition>
|
|
|
|
<Transition name="edit-done">
|
|
<k-glass
|
|
v-if="editMode && isEditablePage && !openedFolder"
|
|
component="button"
|
|
class="springboard-edit-done"
|
|
type="button"
|
|
@click="editMode = false"
|
|
>
|
|
{{ phone.t('Common.done') }}
|
|
</k-glass>
|
|
</Transition>
|
|
|
|
<Transition name="dock">
|
|
<nav
|
|
v-if="isAppPage"
|
|
class="app-dock"
|
|
:class="{ 'app-dock--editing': editMode }"
|
|
:aria-label="phone.t('Home.dock')"
|
|
data-home-area="dock"
|
|
>
|
|
<template
|
|
v-for="slot in dockSlots"
|
|
:key="slot.folder?.id ?? slot.app?.id ?? `dock-empty-${slot.index}`"
|
|
>
|
|
<AppIcon
|
|
v-if="slot.app"
|
|
:app="slot.app"
|
|
data-home-area="dock"
|
|
:data-home-app-id="slot.app.id"
|
|
:data-home-item-key="`app-${slot.app.id}`"
|
|
:data-home-index="slot.index"
|
|
:edit-mode="editMode"
|
|
:external-drag-visual="homeDragVisualActive"
|
|
:show-label="false"
|
|
:class="{
|
|
'home-item--folder-hover':
|
|
folderHoverTarget === `dock:${slot.index}`,
|
|
}"
|
|
@dragcancel="stopHomeDrag"
|
|
@dragend="finishHomeDrag"
|
|
@dragmove="moveHomeDrag"
|
|
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
|
@edit="enterEditMode"
|
|
@remove="removeHomeApp(slot.app.id)"
|
|
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
|
/>
|
|
<HomeFolderIcon
|
|
v-else-if="slot.folder"
|
|
:apps="folderPreviewApps(slot.folder)"
|
|
:default-name="phone.t('Home.folders.defaultName')"
|
|
data-home-area="dock"
|
|
:data-home-folder-id="slot.folder.id"
|
|
:data-home-item-key="slot.folder.id"
|
|
:data-home-index="slot.index"
|
|
:edit-mode="editMode"
|
|
:external-drag-visual="homeDragVisualActive"
|
|
:folder="slot.folder"
|
|
:show-label="false"
|
|
:class="{
|
|
'home-item--folder-hover':
|
|
folderHoverTarget === `dock:${slot.index}`,
|
|
}"
|
|
@dragcancel="stopHomeDrag"
|
|
@dragend="finishHomeDrag"
|
|
@dragmove="moveHomeDrag"
|
|
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
|
@edit="enterEditMode"
|
|
@open="openFolder(slot.folder.id)"
|
|
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
|
/>
|
|
<div
|
|
v-else
|
|
class="app-dock-slot"
|
|
data-home-area="dock"
|
|
:data-home-index="slot.index"
|
|
aria-hidden="true"
|
|
></div>
|
|
</template>
|
|
</nav>
|
|
</Transition>
|
|
|
|
<nav
|
|
v-if="phone.currentPage < libraryPage"
|
|
class="page-indicator"
|
|
:class="{ 'page-indicator--without-dock': phone.currentPage === 0 }"
|
|
:aria-label="phone.t('Home.pages')"
|
|
>
|
|
<button
|
|
v-for="pageIndex in appPages.length + 1"
|
|
:key="pageIndex - 1"
|
|
type="button"
|
|
:class="{ active: phone.currentPage === pageIndex - 1 }"
|
|
:aria-label="`${phone.t('Home.page')} ${pageIndex}`"
|
|
@click="changePage(pageIndex - 1)"
|
|
></button>
|
|
<k-button
|
|
v-if="editMode && canAddHomePage"
|
|
small
|
|
rounded
|
|
class="springboard-page-add"
|
|
type="button"
|
|
:aria-label="phone.t('Home.addPage')"
|
|
@click="addHomePage"
|
|
>
|
|
<Plus :size="12" :stroke-width="2.8" />
|
|
</k-button>
|
|
<k-button
|
|
v-if="editMode && canDeleteCurrentPage"
|
|
small
|
|
rounded
|
|
class="springboard-page-delete"
|
|
type="button"
|
|
:aria-label="phone.t('Home.deletePage')"
|
|
@click="deleteCurrentPage"
|
|
>
|
|
<Trash2 :size="12" :stroke-width="2.4" />
|
|
</k-button>
|
|
</nav>
|
|
|
|
<Transition name="home-folder">
|
|
<HomeFolderOverlay
|
|
v-if="openedFolder"
|
|
:apps="openedFolderApps"
|
|
:edit-mode="editMode"
|
|
:folder="openedFolder"
|
|
:rename-on-open="renameFolderOnOpenId === openedFolder.id"
|
|
@close="closeFolder"
|
|
@edit="enterEditMode"
|
|
@extract="extractOpenedFolderApp"
|
|
@move="moveOpenedFolderApp"
|
|
@rename="renameOpenedFolder"
|
|
@rename-opened="renameFolderOnOpenId = null"
|
|
/>
|
|
</Transition>
|
|
|
|
<div class="widget-action-sheet">
|
|
<k-sheet
|
|
:opened="widgetActionId !== null"
|
|
@backdropclick="widgetActionId = null"
|
|
>
|
|
<div class="widget-sheet-handle" />
|
|
<h3>
|
|
{{
|
|
activeWidget
|
|
? phone.t(`Home.widgetSystem.${activeWidget.kind}.name`)
|
|
: phone.t('Home.widgets.label')
|
|
}}
|
|
</h3>
|
|
<k-list inset strong class="widget-action-list">
|
|
<k-list-item
|
|
link
|
|
link-component="button"
|
|
content-class="w-full"
|
|
:title="phone.t('Home.widgetSystem.editWidget')"
|
|
@click="openWidgetConfig"
|
|
>
|
|
<template #media><Pencil :size="20" /></template>
|
|
</k-list-item>
|
|
<k-list-item
|
|
link
|
|
link-component="button"
|
|
content-class="w-full"
|
|
class="widget-action-remove"
|
|
:title="phone.t('Home.widgetSystem.removeWidget')"
|
|
@click="activeWidget && removeWidget(activeWidget.id)"
|
|
>
|
|
<template #media><Trash2 :size="20" /></template>
|
|
</k-list-item>
|
|
</k-list>
|
|
<k-button
|
|
large
|
|
rounded
|
|
class="widget-action-cancel"
|
|
@click="widgetActionId = null"
|
|
>
|
|
{{ phone.t('Common.cancel') }}
|
|
</k-button>
|
|
</k-sheet>
|
|
</div>
|
|
|
|
<WidgetPickerSheet
|
|
:opened="widgetPickerOpened"
|
|
@add="addWidget"
|
|
@close="widgetPickerOpened = false"
|
|
/>
|
|
<WidgetConfigSheet
|
|
:instance="configuredWidget"
|
|
:opened="widgetConfigId !== null"
|
|
@close="widgetConfigId = null"
|
|
@save="saveWidgetConfig"
|
|
/>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.widget-action-sheet {
|
|
display: contents;
|
|
}
|
|
|
|
.widget-action-sheet :deep(.k-sheet) {
|
|
z-index: 105;
|
|
padding: 9px 0 30px;
|
|
border-radius: 25px 25px 0 0;
|
|
}
|
|
|
|
.widget-sheet-handle {
|
|
width: 36px;
|
|
height: 5px;
|
|
margin: 0 auto 9px;
|
|
border-radius: 999px;
|
|
background: rgb(142 142 147 / 45%);
|
|
}
|
|
|
|
.widget-action-sheet h3 {
|
|
margin: 3px 18px 9px;
|
|
color: inherit;
|
|
font-size: 16px;
|
|
font-weight: 650;
|
|
text-align: center;
|
|
}
|
|
|
|
.widget-action-list {
|
|
margin-top: 0;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.widget-action-remove :deep([class*='title']),
|
|
.widget-action-remove :deep(svg) {
|
|
color: #ff3b30 !important;
|
|
}
|
|
|
|
.widget-action-cancel {
|
|
width: calc(100% - 32px);
|
|
margin: 0 16px;
|
|
}
|
|
</style>
|