FIX - support folder dragging across scaling (#41)

This commit is contained in:
DerEchteAlec
2026-08-24 01:39:43 +02:00
committed by GitHub
parent 00067543cb
commit 7708ed725b
8 changed files with 662 additions and 89 deletions
@@ -26,6 +26,10 @@ const mainCss = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
const openedFolderAppBlocks =
overlaySource.match(/<AppIcon\b[\s\S]*?\/>/g) ?? []
const homeFolderIconBlocks =
springboardSource.match(/<HomeFolderIcon\b[\s\S]*?\/>/g) ?? []
describe('Home folder interaction contract', () => {
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')
})
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('viewportWidth: bounds.width')
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', () => {
expect(overlaySource).toContain('<SkyField')
expect(overlaySource).toContain('home-folder-heading--editing')
@@ -63,8 +121,9 @@ describe('Home folder interaction contract', () => {
expect(springboardSource).toContain(
'@drag-outside-change="folderDraggingOutside = $event"',
)
expect(overlaySource).toContain('if (draggingIndex.value === null) return')
expect(overlaySource).toContain(
'if (draggingIndex.value === null || draggingOutside.value) return',
'if (!draggingOutside.value && !isPointerInsidePanel(event))',
)
expect(overlaySource).toContain(
'draggingOutside.value || !isPointerInsidePanel(event)',
+92 -26
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
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 { usePhoneStore } from '@/stores/phone'
@@ -11,20 +11,35 @@ import {
HOME_FOLDER_PAGE_SIZE,
type HomeFolder,
} from '@/utils/homeLayout'
import {
phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
type FolderAppEntry = {
app: PhoneAppDefinition
index: number
}
const props = defineProps<{
apps: FolderAppEntry[]
editMode: boolean
folder: HomeFolder
renameOnOpen: boolean
}>()
const props = withDefaults(
defineProps<{
apps: FolderAppEntry[]
editMode: boolean
externalDragVisual?: boolean
folder: HomeFolder
renameOnOpen: boolean
}>(),
{
externalDragVisual: false,
},
)
const emit = defineEmits<{
close: []
dragcancel: []
dragmove: [event: PointerEvent]
dragstart: [index: number, event: PointerEvent]
'drag-outside-change': [outside: boolean]
edit: []
extract: [index: number, event: PointerEvent]
@@ -43,6 +58,7 @@ const renameOpened = ref(false)
const renameDraft = ref('')
let pagePointerStart = 0
let pagePointerId: number | null = null
let dragGeometry: PhoneViewportGeometry | null = null
const folderName = computed(
() => props.folder.name || phone.t('Home.folders.defaultName'),
@@ -113,25 +129,48 @@ function saveRename(): void {
renameOpened.value = false
}
function startFolderAppDrag(index: number): void {
function startFolderAppDrag(index: number, event: PointerEvent): void {
draggingIndex.value = index
dragGeometry = readPhoneViewportGeometry(panelElement.value)
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 {
const panelBounds = panelElement.value?.getBoundingClientRect()
if (!panelBounds) return false
const panel = panelElement.value
return (
event.clientX >= panelBounds.left &&
event.clientX <= panelBounds.right &&
event.clientY >= panelBounds.top &&
event.clientY <= panelBounds.bottom
panel !== null &&
phoneViewportRectContainsPoint(
folderDragViewportRect(panel),
event.clientX,
event.clientY,
)
)
}
function moveFolderAppDrag(event: PointerEvent): void {
if (draggingIndex.value === null || draggingOutside.value) return
if (!isPointerInsidePanel(event)) setDraggingOutside(true)
if (draggingIndex.value === null) return
if (!draggingOutside.value && !isPointerInsidePanel(event)) {
setDraggingOutside(true)
}
emit('dragmove', event)
}
function setDraggingOutside(outside: boolean): void {
@@ -145,31 +184,52 @@ function finishFolderAppDrag(event: PointerEvent): void {
const shouldExtract = draggingOutside.value || !isPointerInsidePanel(event)
draggingIndex.value = null
if (sourceIndex === null) {
dragGeometry = null
setDraggingOutside(false)
emit('dragcancel')
return
}
if (shouldExtract) {
emit('extract', sourceIndex, event)
dragGeometry = null
setDraggingOutside(false)
return
}
setDraggingOutside(false)
const target = document
.elementsFromPoint(event.clientX, event.clientY)
.map((element) => element.closest<HTMLElement>('[data-folder-app-index]'))
.find(
(element) =>
element && Number(element.dataset.folderAppIndex) !== sourceIndex,
)
if (!target) return
const target = Array.from(
panelElement.value?.querySelectorAll<HTMLElement>(
'[data-folder-app-index]',
) ?? [],
).find(
(element) =>
Number(element.dataset.folderAppIndex) !== sourceIndex &&
phoneViewportRectContainsPoint(
folderDragViewportRect(element),
event.clientX,
event.clientY,
),
)
if (!target) {
dragGeometry = null
emit('dragcancel')
return
}
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 {
const wasDragging = draggingIndex.value !== null
draggingIndex.value = null
dragGeometry = null
setDraggingOutside(false)
if (wasDragging) emit('dragcancel')
}
function goToPage(page: number): void {
@@ -195,6 +255,11 @@ function finishPageSwipe(event: PointerEvent): void {
}
pagePointerId = null
}
onBeforeUnmount(() => {
stopFolderAppDrag()
pagePointerId = null
})
</script>
<template>
@@ -290,10 +355,11 @@ function finishPageSwipe(event: PointerEvent): void {
:app="entry.app"
:data-folder-app-index="entry.index"
:edit-mode="editMode"
:external-drag-visual="externalDragVisual"
@dragcancel="stopFolderAppDrag"
@dragend="finishFolderAppDrag"
@dragmove="moveFolderAppDrag"
@dragstart="startFolderAppDrag(entry.index)"
@dragstart="startFolderAppDrag(entry.index, $event)"
@edit="emit('edit')"
/>
</div>
+35
View File
@@ -474,6 +474,41 @@ describe('app store', () => {
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', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
+12 -2
View File
@@ -19,6 +19,7 @@ import {
createDefaultHomeLayout,
deleteHomePage,
extractHomeFolderApp,
HOME_GRID_PAGE_SIZE,
moveHomeFolderApp,
moveHomeApp,
moveHomeAppToGridPage,
@@ -519,14 +520,23 @@ export const useAppStoreStore = defineStore('app-store', {
to: HomeArea,
targetIndex: number,
): 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(
this.homeLayout,
sourceLayout,
folderId,
sourceIndex,
to,
targetIndex,
)
if (next === this.homeLayout) return false
if (next === sourceLayout) return false
this.homeLayout = next
this.persist()
return true
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
normalizePhoneViewportRect,
phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportRect,
} 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', () => {
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', () => {
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
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', () => {
const fixture = createGeometryFixture({
canvasOffsetHeight: 844,
@@ -7,6 +7,14 @@ export type PhoneViewportRect = {
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<
DOMRectReadOnly,
'height' | 'left' | 'top' | 'width'
@@ -31,6 +31,22 @@ const builtInWallpaperCss = mainCss.slice(
mainCss.indexOf('.wallpaper--midnight'),
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', () => {
it('keeps app and widget labels on the larger shared home typography', () => {
@@ -136,7 +152,7 @@ describe('Springboard page swipe contract', () => {
)
expect(
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
).toHaveLength(4)
).toHaveLength(5)
expect(viewSource).toContain('updateHomeDragGhost(event)')
expect(viewSource).toContain('const dropGhost = homeDragGhost')
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', () => {
expect(
viewSource.match(
+236 -58
View File
@@ -42,6 +42,7 @@ import {
} from '@/utils/homeLayout'
import type { ReorderDirection } from '@/utils/keyboard'
import {
phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportGeometry,
type PhoneViewportRect,
@@ -111,6 +112,11 @@ const draggingHomeApp = ref<{
area: HomeArea
index: number
} | null>(null)
const draggingFolderApp = ref<{
appId: LaunchablePhoneAppId
folderId: string
sourceIndex: number
} | null>(null)
const homeDragLayer = ref<HTMLElement | null>(null)
const homeDragVisualActive = ref(false)
const temporaryHomePage = ref<number | null>(null)
@@ -610,7 +616,11 @@ function queueEdgePageTurn(
edgePageTimer = undefined
edgePageDirection = direction
edgePageLocked = dragType === 'widget'
if (dragType === 'app' && lastHomePointer && draggingHomeApp.value) {
if (
dragType === 'app' &&
lastHomePointer &&
(draggingHomeApp.value || draggingFolderApp.value)
) {
queueEdgePageTurn(lastHomePointer, 'app')
}
}, HOME_EDGE_PAGE_TURN_DELAY)
@@ -814,7 +824,7 @@ function updateHomeDragGhost(event: {
)
}
function createHomeDragGhost(event: PointerEvent): void {
function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
clearHomeDragGhost()
const eventTarget =
event.currentTarget instanceof Element
@@ -827,7 +837,7 @@ function createHomeDragGhost(event: PointerEvent): void {
const springboard = source?.closest<HTMLElement>('.springboard')
const portal = layer?.closest<HTMLElement>('.phone-home-drag-portal')
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 clipBounds = geometry.rect(springboard)
@@ -838,7 +848,7 @@ function createHomeDragGhost(event: PointerEvent): void {
sourceBounds.width <= 0 ||
sourceBounds.height <= 0
) {
return
return null
}
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('*'))]) {
element.removeAttribute('id')
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>(
@@ -894,6 +909,7 @@ function createHomeDragGhost(event: PointerEvent): void {
layer.appendChild(position)
homeDragVisualActive.value = true
updateHomeDragGhost(event)
return position
}
function startHomeDrag(
@@ -1190,6 +1206,98 @@ function nearestDockDropTarget(event: {
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 {
clearEdgePageTurn()
clearFolderHover()
@@ -1324,16 +1432,83 @@ function folderPreviewApps(folder: HomeFolder): PhoneAppDefinition[] {
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 {
stopOpenedFolderAppDrag()
pageTransitioning.value = false
folderDraggingOutside.value = false
openedFolderId.value = folderId
}
function closeFolder(): void {
folderDraggingOutside.value = false
openedFolderId.value = null
renameFolderOnOpenId.value = null
stopOpenedFolderAppDrag()
resetOpenedFolderState()
}
function renameOpenedFolder(name: string): void {
@@ -1342,71 +1517,68 @@ function renameOpenedFolder(name: string): void {
}
function moveOpenedFolderApp(sourceIndex: number, targetIndex: number): void {
if (!openedFolderId.value) return
appStore.moveHomeFolderApp(openedFolderId.value, sourceIndex, targetIndex)
const session = currentOpenedFolderDragSession(sourceIndex)
stopOpenedFolderAppDrag()
if (!session) return
appStore.moveHomeFolderApp(session.folderId, sourceIndex, targetIndex)
}
function extractOpenedFolderApp(
sourceIndex: number,
event: PointerEvent,
): void {
const folder = openedFolder.value
if (!folder) return
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) {
const session = currentOpenedFolderDragSession(sourceIndex)
if (!session) {
closeFolder()
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 targetIndex = Number(target.dataset.homeIndex)
const { area, index: targetIndex } = target
if (
!appStore.extractHomeFolderApp(folder.id, sourceIndex, area, targetIndex)
!appStore.extractHomeFolderApp(
session.folderId,
session.sourceIndex,
area,
targetIndex,
)
) {
closeFolder()
return
}
closeFolder()
if (sourceBounds) {
const extractedIndex = findHomeItemIndex(appId, area, targetIndex)
if (extractedIndex !== null) {
void animateHomeItemDrop(appId, area, extractedIndex, sourceBounds)
}
clearEdgePageTurn()
draggingFolderApp.value = null
lastHomePointer = null
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 {
@@ -1426,11 +1598,12 @@ watch(isEditablePage, (visible) => {
watch(editMode, (editing) => {
emit('editModeChange', editing)
if (editing) return
stopOpenedFolderAppDrag()
stopHomeDrag()
stopWidgetDrag()
})
watch(openedFolder, (folder) => {
if (!folder) closeFolder()
if (!folder && openedFolderId.value !== null) closeFolder()
})
onBeforeUnmount(() => {
emit('editModeChange', false)
@@ -1445,6 +1618,7 @@ onBeforeUnmount(() => {
clearHomeDragGhost()
temporaryHomePage.value = null
draggingHomeApp.value = null
draggingFolderApp.value = null
draggingWidgetId.value = null
})
</script>
@@ -1858,9 +2032,13 @@ onBeforeUnmount(() => {
v-if="openedFolder"
:apps="openedFolderApps"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="openedFolder"
:rename-on-open="renameFolderOnOpenId === openedFolder.id"
@close="closeFolder"
@dragcancel="stopOpenedFolderAppDrag"
@dragmove="moveOpenedFolderAppDrag"
@dragstart="startOpenedFolderAppDrag"
@drag-outside-change="folderDraggingOutside = $event"
@edit="enterEditMode"
@extract="extractOpenedFolderApp"