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