@kennofizet/apphub-frontend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +84 -0
  2. package/package.json +31 -0
  3. package/src/api/coreApi.js +25 -0
  4. package/src/api/index.js +80 -0
  5. package/src/composables/createZoneContext.js +156 -0
  6. package/src/composables/useAppHubHostApi.js +24 -0
  7. package/src/composables/useAppHubZoneContext.js +11 -0
  8. package/src/composables/useDevOriginToggle.js +40 -0
  9. package/src/i18n/index.js +16 -0
  10. package/src/i18n/resolveLang.js +6 -0
  11. package/src/i18n/resolveTheme.js +30 -0
  12. package/src/i18n/translations/en.js +303 -0
  13. package/src/i18n/translations/vi.js +302 -0
  14. package/src/index.js +427 -0
  15. package/src/moduleStore.js +10 -0
  16. package/src/modules/app-store/components/AppHubAppStoreApp.vue +210 -0
  17. package/src/modules/app-store/components/AppHubAppStoreCard.vue +88 -0
  18. package/src/modules/app-store/components/AppHubAppStoreSettingsPanel.vue +266 -0
  19. package/src/modules/app-store/components/AppHubAppVersionHistory.vue +77 -0
  20. package/src/modules/app-store/components/AppHubDevReviewPanel.vue +206 -0
  21. package/src/modules/app-store/components/AppHubDraftStoreApp.vue +184 -0
  22. package/src/modules/app-store/components/AppHubDraftStoreCard.vue +116 -0
  23. package/src/modules/app-store/composables/useAppStore.js +206 -0
  24. package/src/modules/app-store/composables/useCatalogInfiniteScroll.js +47 -0
  25. package/src/modules/app-store/constants/catalogModes.js +2 -0
  26. package/src/modules/app-store/data/defaultCatalog.js +19 -0
  27. package/src/modules/app-store/index.js +9 -0
  28. package/src/modules/app-store/utils/normalizeCatalogApp.js +37 -0
  29. package/src/modules/desktop/components/AppHubDesktop.vue +1510 -0
  30. package/src/modules/desktop/components/AppHubDesktopDevOriginBar.vue +57 -0
  31. package/src/modules/desktop/components/AppHubDesktopDropLayer.vue +15 -0
  32. package/src/modules/desktop/components/AppHubDesktopDropTarget.vue +32 -0
  33. package/src/modules/desktop/components/AppHubDesktopIconContextMenu.vue +74 -0
  34. package/src/modules/desktop/components/AppHubDesktopIconFolder.vue +60 -0
  35. package/src/modules/desktop/components/AppHubDesktopIconGroup.vue +58 -0
  36. package/src/modules/desktop/components/AppHubDesktopIconInfoDialog.vue +33 -0
  37. package/src/modules/desktop/components/AppHubDesktopIconRenameDialog.vue +62 -0
  38. package/src/modules/desktop/components/AppHubDesktopSettings.vue +28 -0
  39. package/src/modules/desktop/components/AppHubDropInstallBadge.vue +65 -0
  40. package/src/modules/desktop/components/AppHubDuplicateAppDialog.vue +38 -0
  41. package/src/modules/desktop/components/AppHubGuideApp.vue +278 -0
  42. package/src/modules/desktop/components/AppHubOriginBlockScreen.vue +105 -0
  43. package/src/modules/desktop/components/AppHubOriginLoadingScreen.vue +23 -0
  44. package/src/modules/desktop/components/AppHubPlaceholderApp.vue +14 -0
  45. package/src/modules/desktop/components/AppHubSettingsApp.vue +319 -0
  46. package/src/modules/desktop/components/AppHubStartButton.vue +24 -0
  47. package/src/modules/desktop/components/AppHubStartMenu.vue +182 -0
  48. package/src/modules/desktop/components/AppHubTaskbarPins.vue +23 -0
  49. package/src/modules/desktop/components/settings/AppHubSettingsKeyboardPanel.vue +82 -0
  50. package/src/modules/desktop/components/settings/AppHubSettingsScreenPanel.vue +41 -0
  51. package/src/modules/desktop/components/settings/AppHubSettingsStartMenuPanel.vue +95 -0
  52. package/src/modules/desktop/composables/simulateInstallProgress.js +15 -0
  53. package/src/modules/desktop/composables/useDesktopDropInstall.js +272 -0
  54. package/src/modules/desktop/composables/useDesktopHubSettings.js +51 -0
  55. package/src/modules/desktop/composables/useDesktopIconDrag.js +207 -0
  56. package/src/modules/desktop/composables/useDesktopShell.js +335 -0
  57. package/src/modules/desktop/data/builtinApps.js +77 -0
  58. package/src/modules/desktop/index.js +12 -0
  59. package/src/modules/desktop/styles/desktop.css +3104 -0
  60. package/src/modules/desktop/styles/theme.css +616 -0
  61. package/src/modules/desktop/utils/desktopGrid.js +43 -0
  62. package/src/modules/desktop/utils/desktopIconGroups.js +103 -0
  63. package/src/modules/desktop/utils/desktopSession.js +40 -0
  64. package/src/modules/desktop/utils/desktopSettings.js +37 -0
  65. package/src/modules/desktop/utils/dropPackageParser.js +140 -0
  66. package/src/modules/desktop/utils/duplicateAppUtils.js +28 -0
  67. package/src/modules/desktop/utils/hubKeyboardSettings.js +63 -0
  68. package/src/modules/desktop/utils/recentApps.js +148 -0
  69. package/src/modules/desktop/utils/startMenuFavorites.js +100 -0
  70. package/src/modules/desktop/utils/startMenuPins.js +90 -0
  71. package/src/modules/notifications/components/AppHubDesktopNotifications.vue +54 -0
  72. package/src/modules/notifications/composables/createDesktopNotifications.js +86 -0
  73. package/src/modules/notifications/index.js +9 -0
  74. package/src/modules/notifications/styles/notifications.css +118 -0
  75. package/src/modules/notifications/utils/parseApiError.js +29 -0
  76. package/src/modules/runner/components/AppHubRunner.vue +292 -0
  77. package/src/modules/runner/index.js +1 -0
  78. package/src/modules/window-manager/components/AppHubWindowFrame.vue +224 -0
  79. package/src/modules/window-manager/composables/useWindowManager.js +652 -0
  80. package/src/modules/window-manager/index.js +7 -0
  81. package/src/modules/window-manager/utils/sessionLayout.js +28 -0
  82. package/src/modules/window-manager/utils/windowLayout.js +236 -0
  83. package/src/modules/window-manager/utils/windowSnap.js +146 -0
  84. package/src/utils/bootstrapCache.js +47 -0
  85. package/src/utils/devOriginSettings.js +22 -0
  86. package/src/utils/launchUrl.js +111 -0
  87. package/src/utils/originSafety.js +267 -0
  88. package/src/utils/safeStorage.js +191 -0
  89. package/src/utils/semver.js +30 -0
  90. package/src/utils/zoneContext.js +38 -0
@@ -0,0 +1,95 @@
1
+ <template>
2
+ <div class="apphub-settings-panel">
3
+ <h3 class="apphub-settings-panel__title">{{ labels.page_title }}</h3>
4
+ <p class="apphub-settings-panel__hint">{{ labels.page_hint }}</p>
5
+
6
+ <p class="apphub-settings-panel__section-label">{{ labels.pins_section }}</p>
7
+ <p v-if="!pinnedRows.length" class="apphub-settings-panel__msg">{{ labels.pins_empty }}</p>
8
+
9
+ <ul v-else class="apphub-settings-panel__pin-list">
10
+ <li v-for="row in pinnedRows" :key="`pin-${row.id}`" class="apphub-settings-panel__pin-item">
11
+ <span class="apphub-settings-panel__pin-icon" aria-hidden="true">{{ row.icon }}</span>
12
+ <span class="apphub-settings-panel__pin-name">{{ row.name }}</span>
13
+ <label class="apphub-settings-panel__pin-toggle">
14
+ <input
15
+ type="checkbox"
16
+ :checked="row.visible"
17
+ @change="onPinVisibleChange(row.id, $event.target.checked)"
18
+ />
19
+ <span>{{ labels.pins_show }}</span>
20
+ </label>
21
+ </li>
22
+ </ul>
23
+
24
+ <p class="apphub-settings-panel__section-label apphub-settings-panel__section-label--spaced">{{ labels.favorites_section }}</p>
25
+ <p v-if="!favoriteRows.length" class="apphub-settings-panel__msg">{{ labels.favorites_empty }}</p>
26
+
27
+ <ul v-else class="apphub-settings-panel__pin-list">
28
+ <li v-for="row in favoriteRows" :key="`fav-${row.id}`" class="apphub-settings-panel__pin-item">
29
+ <span class="apphub-settings-panel__pin-icon" aria-hidden="true">{{ row.icon }}</span>
30
+ <span class="apphub-settings-panel__pin-name">{{ row.name }}</span>
31
+ <label class="apphub-settings-panel__pin-toggle">
32
+ <input
33
+ type="checkbox"
34
+ :checked="row.visible"
35
+ @change="onFavoriteVisibleChange(row.id, $event.target.checked)"
36
+ />
37
+ <span>{{ labels.favorites_show }}</span>
38
+ </label>
39
+ </li>
40
+ </ul>
41
+
42
+ <p class="apphub-settings-panel__note">{{ labels.page_note }}</p>
43
+ </div>
44
+ </template>
45
+
46
+ <script setup>
47
+ import { computed, inject } from 'vue'
48
+ import { t } from '../../../../i18n/index.js'
49
+ import { resolveLang } from '../../../../i18n/resolveLang.js'
50
+ import {
51
+ useDesktopHubSettings,
52
+ useHubFavoriteRows,
53
+ useHubPinnedRows,
54
+ } from '../../composables/useDesktopHubSettings.js'
55
+
56
+ const hub = useDesktopHubSettings()
57
+ const pinnedRows = useHubPinnedRows(hub)
58
+ const favoriteRows = useHubFavoriteRows(hub)
59
+ const lang = computed(() => resolveLang(inject('apphubOptions', {})?.language, 'vi'))
60
+
61
+ const labels = computed(() => ({
62
+ page_title: t('hub_settings_pin_favorite_title', lang.value),
63
+ page_hint: t('hub_settings_pin_favorite_hint', lang.value),
64
+ pins_section: t('hub_settings_pins_section', lang.value),
65
+ pins_empty: t('hub_settings_start_empty', lang.value),
66
+ pins_show: t('hub_settings_start_show', lang.value),
67
+ favorites_section: t('start_menu_favorites', lang.value),
68
+ favorites_empty: t('hub_settings_start_favorites_empty', lang.value),
69
+ favorites_show: t('hub_settings_start_favorites_show', lang.value),
70
+ page_note: t('hub_settings_pin_favorite_note', lang.value),
71
+ }))
72
+
73
+ function onPinVisibleChange(appId, visible) {
74
+ hub.setPinVisible?.(appId, visible)
75
+ }
76
+
77
+ function onFavoriteVisibleChange(appId, visible) {
78
+ hub.setFavoriteVisible?.(appId, visible)
79
+ }
80
+ </script>
81
+
82
+ <style scoped>
83
+ .apphub-settings-panel__section-label {
84
+ margin: 20px 0 10px;
85
+ font-size: 0.7rem;
86
+ font-weight: 600;
87
+ letter-spacing: 0.06em;
88
+ text-transform: uppercase;
89
+ color: var(--ah-text-muted, rgba(255, 255, 255, 0.38));
90
+ }
91
+
92
+ .apphub-settings-panel__section-label--spaced {
93
+ margin-top: 28px;
94
+ }
95
+ </style>
@@ -0,0 +1,15 @@
1
+ export function simulateInstallProgress(job, onTick) {
2
+ const steps = job.method === 'appstore' ? 28 : 22
3
+ const delay = job.method === 'appstore' ? 45 : 55
4
+ return new Promise((resolve) => {
5
+ let step = 0
6
+ const timer = setInterval(() => {
7
+ step += 1
8
+ onTick(Math.min(100, Math.round((step / steps) * 100)))
9
+ if (step >= steps) {
10
+ clearInterval(timer)
11
+ resolve()
12
+ }
13
+ }, delay)
14
+ })
15
+ }
@@ -0,0 +1,272 @@
1
+ import { reactive } from 'vue'
2
+ import { defaultAppStoreCatalog } from '../../app-store/data/defaultCatalog.js'
3
+ import { normalizeCatalogApp } from '../../app-store/utils/normalizeCatalogApp.js'
4
+ import { parseApiError } from '../../notifications/utils/parseApiError.js'
5
+ import { parseDropFiles } from '../utils/dropPackageParser.js'
6
+ import { simulateInstallProgress } from './simulateInstallProgress.js'
7
+
8
+ let jobSeq = 0
9
+
10
+ /**
11
+ * Desktop drag-drop — local install, app store slug, or hosted publish (zip → draft API).
12
+ */
13
+ export function createDesktopDropInstall(options = {}) {
14
+ const onInstalled = options.onInstalled ?? (() => {})
15
+ const onPersist = options.onPersist ?? (() => {})
16
+ const onNotify = options.onNotify ?? (() => {})
17
+ const getLabels = options.getLabels ?? (() => ({}))
18
+ const getAppStore = options.getAppStore ?? (() => null)
19
+ const getHostApi = options.getHostApi ?? (() => null)
20
+
21
+ const state = reactive({
22
+ dragActive: false,
23
+ dragDepth: 0,
24
+ jobs: [],
25
+ })
26
+
27
+ function label(key, fallback = '') {
28
+ const labels = getLabels()
29
+ return labels[key] ?? fallback
30
+ }
31
+
32
+ function notify(payload) {
33
+ onNotify(payload)
34
+ }
35
+
36
+ function failJob(job, err) {
37
+ const message = parseApiError(err, label('errorGeneric', 'Something went wrong.'))
38
+ job.status = 'error'
39
+ job.errorMessage = message
40
+ notify({
41
+ type: 'error',
42
+ title: job.name || label('errorTitle', 'App Hub'),
43
+ message,
44
+ })
45
+ setTimeout(() => {
46
+ const idx = state.jobs.findIndex((j) => j.id === job.id)
47
+ if (idx !== -1) state.jobs.splice(idx, 1)
48
+ }, 2000)
49
+ }
50
+
51
+ function canAcceptDrop(hasOpenWindows) {
52
+ return !hasOpenWindows
53
+ }
54
+
55
+ function onDragEnter(event, hasOpenWindows) {
56
+ if (!canAcceptDrop(hasOpenWindows) || !hasFiles(event)) return
57
+ event.preventDefault()
58
+ state.dragDepth += 1
59
+ state.dragActive = true
60
+ }
61
+
62
+ function onDragLeave(event, hasOpenWindows) {
63
+ if (!canAcceptDrop(hasOpenWindows)) return
64
+ const root = event.currentTarget
65
+ if (event.relatedTarget && root?.contains?.(event.relatedTarget)) return
66
+ state.dragDepth = Math.max(0, state.dragDepth - 1)
67
+ if (state.dragDepth === 0) state.dragActive = false
68
+ }
69
+
70
+ function resetDrag() {
71
+ state.dragDepth = 0
72
+ state.dragActive = false
73
+ }
74
+
75
+ function onDragOver(event, hasOpenWindows) {
76
+ if (!canAcceptDrop(hasOpenWindows) || !hasFiles(event)) return
77
+ event.preventDefault()
78
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
79
+ state.dragActive = true
80
+ }
81
+
82
+ async function onDrop(event, hasOpenWindows, pointer) {
83
+ state.dragDepth = 0
84
+ state.dragActive = false
85
+ if (!canAcceptDrop(hasOpenWindows)) return
86
+ event.preventDefault()
87
+
88
+ const intent = await parseDropFiles(event.dataTransfer)
89
+ if (!intent) return
90
+
91
+ const job = {
92
+ id: `drop-${++jobSeq}`,
93
+ x: pointer.x,
94
+ y: pointer.y,
95
+ progress: 0,
96
+ status: 'installing',
97
+ icon: intent.icon,
98
+ name: intent.name,
99
+ method: intent.method,
100
+ intent,
101
+ }
102
+ state.jobs.push(job)
103
+ await runInstall(job)
104
+ }
105
+
106
+ async function submitHostedPublish(job) {
107
+ const api = getHostApi()
108
+ if (!api?.registerApp) {
109
+ const err = new Error('no_api')
110
+ err.code = 'no_api'
111
+ throw err
112
+ }
113
+
114
+ const body = new FormData()
115
+ body.append('bundle', job.intent.zipFile)
116
+
117
+ const res = await api.registerApp(body)
118
+ return res?.data?.data ?? null
119
+ }
120
+
121
+ async function runInstall(job) {
122
+ const appStore = getAppStore()
123
+ try {
124
+ if (job.method === 'publish') {
125
+ await simulateInstallProgress(job, (value) => {
126
+ job.progress = value
127
+ })
128
+
129
+ const registered = await submitHostedPublish(job)
130
+ const catalogApp = normalizeCatalogApp(registered) ?? {
131
+ slug: registered?.slug ?? job.intent.slug,
132
+ name: registered?.name ?? job.intent.name,
133
+ icon: registered?.icon ?? job.intent.icon,
134
+ description: job.intent.description ?? '',
135
+ status: registered?.status ?? 'draft',
136
+ runtime_type: registered?.runtime_type ?? 'hosted',
137
+ entry_url: null,
138
+ healthcheck_url: null,
139
+ }
140
+
141
+ appStore?.installApp?.(catalogApp.slug)
142
+
143
+ const result = await onInstalled(
144
+ {
145
+ slug: catalogApp.slug,
146
+ name: catalogApp.name,
147
+ icon: catalogApp.icon,
148
+ description: catalogApp.description,
149
+ status: catalogApp.status,
150
+ runtime_type: catalogApp.runtime_type,
151
+ version: catalogApp.version,
152
+ entry_url: catalogApp.entry_url,
153
+ healthcheck_url: catalogApp.healthcheck_url,
154
+ },
155
+ { x: job.x, y: job.y, method: 'publish' },
156
+ )
157
+
158
+ if (result === 'cancelled') {
159
+ const message = label('installCancelled', 'Install cancelled.')
160
+ job.status = 'error'
161
+ job.errorMessage = message
162
+ notify({
163
+ type: 'warning',
164
+ title: catalogApp.name,
165
+ message,
166
+ })
167
+ setTimeout(() => {
168
+ const idx = state.jobs.findIndex((j) => j.id === job.id)
169
+ if (idx !== -1) state.jobs.splice(idx, 1)
170
+ }, 800)
171
+ return
172
+ }
173
+
174
+ job.status = 'done'
175
+ job.progress = 100
176
+ job.name = catalogApp.name
177
+ job.icon = catalogApp.icon
178
+ job.publishSubmitted = true
179
+
180
+ notify({
181
+ type: 'success',
182
+ title: catalogApp.name,
183
+ message: result === 'updated'
184
+ ? label('publishUpgradeSuccess', 'New version submitted. Update from App Store when you want to run it.')
185
+ : label('publishSuccess', 'Draft submitted and installed on your desktop.'),
186
+ })
187
+
188
+ setTimeout(() => {
189
+ const idx = state.jobs.findIndex((j) => j.id === job.id)
190
+ if (idx !== -1) state.jobs.splice(idx, 1)
191
+ onPersist()
192
+ }, 2200)
193
+ return
194
+ }
195
+
196
+ await simulateInstallProgress(job, (value) => {
197
+ job.progress = value
198
+ })
199
+
200
+ let app
201
+ if (job.method === 'appstore') {
202
+ const catalogItem = defaultAppStoreCatalog.find((a) => a.slug === job.intent.slug)
203
+ ?? appStore?.findCatalogItem?.(job.intent.slug)
204
+ if (catalogItem) {
205
+ appStore?.installApp?.(catalogItem.slug)
206
+ app = catalogItem
207
+ } else {
208
+ app = {
209
+ slug: job.intent.slug,
210
+ name: job.intent.name,
211
+ icon: job.intent.icon,
212
+ description: job.intent.description ?? '',
213
+ }
214
+ }
215
+ } else {
216
+ app = {
217
+ slug: job.intent.slug,
218
+ name: job.intent.name,
219
+ icon: job.intent.icon,
220
+ description: job.intent.description ?? '',
221
+ local: true,
222
+ }
223
+ }
224
+
225
+ job.status = 'done'
226
+ job.progress = 100
227
+ job.name = app.name
228
+ job.icon = app.icon
229
+
230
+ const result = await onInstalled(app, { x: job.x, y: job.y, method: job.method })
231
+ if (result === 'cancelled') {
232
+ const message = label('installCancelled', 'Install cancelled.')
233
+ job.status = 'error'
234
+ job.errorMessage = message
235
+ notify({
236
+ type: 'warning',
237
+ title: app.name,
238
+ message,
239
+ })
240
+ setTimeout(() => {
241
+ const idx = state.jobs.findIndex((j) => j.id === job.id)
242
+ if (idx !== -1) state.jobs.splice(idx, 1)
243
+ }, 800)
244
+ return
245
+ }
246
+
247
+ setTimeout(() => {
248
+ const idx = state.jobs.findIndex((j) => j.id === job.id)
249
+ if (idx !== -1) state.jobs.splice(idx, 1)
250
+ onPersist()
251
+ }, 1200)
252
+ } catch (err) {
253
+ failJob(job, err)
254
+ }
255
+ }
256
+
257
+ return {
258
+ state,
259
+ onDragEnter,
260
+ onDragLeave,
261
+ onDragOver,
262
+ onDrop,
263
+ canAcceptDrop,
264
+ resetDrag,
265
+ }
266
+ }
267
+
268
+ function hasFiles(event) {
269
+ if (!event.dataTransfer) return false
270
+ const types = [...event.dataTransfer.types]
271
+ return types.includes('Files') || types.includes('application/x-moz-file')
272
+ }
@@ -0,0 +1,51 @@
1
+ import { computed, inject, toValue } from 'vue'
2
+
3
+ export const DESKTOP_HUB_SETTINGS_KEY = 'apphubDesktopHubSettings'
4
+
5
+ export function useDesktopHubSettings() {
6
+ const hub = inject(DESKTOP_HUB_SETTINGS_KEY, null)
7
+ if (!hub) {
8
+ throw new Error('useDesktopHubSettings() must be used inside AppHubDesktop')
9
+ }
10
+ return hub
11
+ }
12
+
13
+ /** Catalog apps — reactive across install, rename, and session restore. */
14
+ export function useHubCatalogApps(hub = null) {
15
+ const ctx = hub ?? useDesktopHubSettings()
16
+ return computed(() => {
17
+ const apps = toValue(ctx.desktopApps)
18
+ if (Array.isArray(apps)) return apps
19
+ return ctx.getDesktopApps?.() ?? []
20
+ })
21
+ }
22
+
23
+ function buildPinFavoriteRows(catalog, store, visibleKey = 'visible') {
24
+ const byId = new Map(catalog.map((app) => [app.id, app]))
25
+ return Object.keys(store)
26
+ .map((id) => {
27
+ const app = byId.get(id)
28
+ if (!app) return null
29
+ return {
30
+ id: app.id,
31
+ name: app.name,
32
+ icon: app.icon ?? '📦',
33
+ visible: store[id]?.[visibleKey] !== false,
34
+ }
35
+ })
36
+ .filter(Boolean)
37
+ }
38
+
39
+ /** Pinned apps for settings — tracks add/remove and visibility toggles live. */
40
+ export function useHubPinnedRows(hub = null) {
41
+ const ctx = hub ?? useDesktopHubSettings()
42
+ const catalog = useHubCatalogApps(ctx)
43
+ return computed(() => buildPinFavoriteRows(catalog.value, ctx.startMenuPins ?? {}))
44
+ }
45
+
46
+ /** Favorite apps for settings — tracks add/remove and visibility toggles live. */
47
+ export function useHubFavoriteRows(hub = null) {
48
+ const ctx = hub ?? useDesktopHubSettings()
49
+ const catalog = useHubCatalogApps(ctx)
50
+ return computed(() => buildPinFavoriteRows(catalog.value, ctx.startMenuFavorites ?? {}))
51
+ }
@@ -0,0 +1,207 @@
1
+ import { ref } from 'vue'
2
+ import { clampPointToLayer, snapPoint } from '../utils/desktopGrid.js'
3
+ import { findAppsAtCell, moveAppsToCell, previewGroupAtCell } from '../utils/desktopIconGroups.js'
4
+
5
+ const DRAG_THRESHOLD = 4
6
+ const HOLD_MS = 380
7
+
8
+ /**
9
+ * Pointer drag for desktop app icons with iPhone-style grouping.
10
+ * Single icons drag immediately; group icons need a brief hold (tap opens folder).
11
+ */
12
+ export function useDesktopIconDrag(options) {
13
+ const drag = ref(null)
14
+ const dropTarget = ref(null)
15
+ const lastWasDrag = ref(false)
16
+
17
+ let holdTimer = null
18
+
19
+ function getLayerRect() {
20
+ return options.getLayerEl()?.getBoundingClientRect() ?? null
21
+ }
22
+
23
+ function resolvePosition(x, y) {
24
+ const layer = options.getLayerEl()
25
+ if (!layer) return { x, y }
26
+ const clamped = clampPointToLayer(x, y, layer.clientWidth, layer.clientHeight)
27
+ return snapPoint(clamped.x, clamped.y, options.getSnapToGrid())
28
+ }
29
+
30
+ function getApps(ids) {
31
+ return ids.map((id) => options.findApp(id)).filter(Boolean)
32
+ }
33
+
34
+ function ensurePlaced(app, event) {
35
+ if (app.desktopX != null && app.desktopY != null) return
36
+ const layerRect = getLayerRect()
37
+ const el = event.currentTarget
38
+ if (!layerRect || !el) return
39
+ const rect = el.getBoundingClientRect()
40
+ const pos = resolvePosition(rect.left - layerRect.left, rect.top - layerRect.top)
41
+ app.desktopX = pos.x
42
+ app.desktopY = pos.y
43
+ }
44
+
45
+ function clearHoldTimer() {
46
+ if (holdTimer) {
47
+ clearTimeout(holdTimer)
48
+ holdTimer = null
49
+ }
50
+ }
51
+
52
+ function removeListeners() {
53
+ window.removeEventListener('mousemove', onPointerMove)
54
+ window.removeEventListener('mouseup', onPointerUp)
55
+ }
56
+
57
+ function updateDropTarget(pos, draggedApps) {
58
+ if (!drag.value?.moved) {
59
+ dropTarget.value = null
60
+ return
61
+ }
62
+
63
+ const allApps = options.getDesktopApps?.() ?? []
64
+ const draggedIds = draggedApps.map((a) => a.id)
65
+ const atCell = findAppsAtCell(allApps, pos.x, pos.y, draggedIds)
66
+
67
+ if (atCell.length > 0) {
68
+ const preview = previewGroupAtCell(allApps, draggedApps, pos.x, pos.y)
69
+ dropTarget.value = { x: pos.x, y: pos.y, apps: preview, merging: true }
70
+ return
71
+ }
72
+
73
+ if (draggedApps.length >= 2) {
74
+ dropTarget.value = { x: pos.x, y: pos.y, apps: draggedApps }
75
+ return
76
+ }
77
+
78
+ dropTarget.value = null
79
+ }
80
+
81
+ /**
82
+ * @param {object|object[]} appOrApps - single app or all apps in a group
83
+ * @param {object} meta - { mode: 'single'|'group'|'folder', onTap?: fn }
84
+ */
85
+ function onPointerDown(appOrApps, event, meta = {}) {
86
+ const apps = Array.isArray(appOrApps) ? appOrApps : [appOrApps]
87
+ const primary = apps[0]
88
+ if (!primary || event.button !== 0) return
89
+
90
+ for (const app of apps) ensurePlaced(app, event)
91
+
92
+ const ids = apps.map((a) => a.id)
93
+ const mode = meta.mode ?? 'single'
94
+ const requiresHold = mode === 'group'
95
+
96
+ drag.value = {
97
+ ids,
98
+ mode,
99
+ requiresHold,
100
+ startX: event.clientX,
101
+ startY: event.clientY,
102
+ anchorX: primary.desktopX ?? 0,
103
+ anchorY: primary.desktopY ?? 0,
104
+ moved: false,
105
+ ready: !requiresHold,
106
+ onTap: meta.onTap ?? null,
107
+ }
108
+
109
+ if (requiresHold) {
110
+ holdTimer = setTimeout(() => {
111
+ if (drag.value) drag.value.ready = true
112
+ }, HOLD_MS)
113
+ }
114
+
115
+ window.addEventListener('mousemove', onPointerMove)
116
+ window.addEventListener('mouseup', onPointerUp)
117
+ }
118
+
119
+ function onPointerMove(event) {
120
+ if (!drag.value?.ready) return
121
+
122
+ const dx = event.clientX - drag.value.startX
123
+ const dy = event.clientY - drag.value.startY
124
+ const dist = Math.abs(dx) + Math.abs(dy)
125
+
126
+ if (!drag.value.moved && dist < DRAG_THRESHOLD) return
127
+
128
+ drag.value.moved = true
129
+ const layerRect = getLayerRect()
130
+ if (!layerRect) return
131
+
132
+ const rawX = drag.value.anchorX + dx
133
+ const rawY = drag.value.anchorY + dy
134
+ const pos = resolvePosition(rawX, rawY)
135
+ const draggedApps = getApps(drag.value.ids)
136
+
137
+ for (const app of draggedApps) {
138
+ app.desktopX = pos.x
139
+ app.desktopY = pos.y
140
+ }
141
+
142
+ updateDropTarget(pos, draggedApps)
143
+ }
144
+
145
+ function finalizeDrop() {
146
+ if (!drag.value?.moved) return
147
+
148
+ const draggedApps = getApps(drag.value.ids)
149
+ if (!draggedApps.length) return
150
+
151
+ const primary = draggedApps[0]
152
+ const pos = resolvePosition(primary.desktopX ?? 0, primary.desktopY ?? 0)
153
+ moveAppsToCell(draggedApps, pos.x, pos.y)
154
+ }
155
+
156
+ function onPointerUp() {
157
+ clearHoldTimer()
158
+ removeListeners()
159
+
160
+ const session = drag.value
161
+ const wasDrag = session?.moved
162
+ lastWasDrag.value = !!wasDrag
163
+
164
+ if (wasDrag) {
165
+ finalizeDrop()
166
+ const draggedApps = getApps(session.ids)
167
+ const primary = draggedApps[0]
168
+ options.onMoved?.({
169
+ fromCell: { x: session.anchorX, y: session.anchorY },
170
+ toCell: primary
171
+ ? { x: primary.desktopX, y: primary.desktopY }
172
+ : null,
173
+ })
174
+ } else if (session?.onTap) {
175
+ session.onTap()
176
+ }
177
+
178
+ drag.value = null
179
+ dropTarget.value = null
180
+ }
181
+
182
+ function isDragging(appId) {
183
+ return drag.value?.ids?.includes(appId) && drag.value.moved
184
+ }
185
+
186
+ function isHolding(appId) {
187
+ return drag.value?.ids?.includes(appId) && drag.value.requiresHold && drag.value.ready && !drag.value.moved
188
+ }
189
+
190
+ function cleanup() {
191
+ clearHoldTimer()
192
+ removeListeners()
193
+ drag.value = null
194
+ dropTarget.value = null
195
+ }
196
+
197
+ return {
198
+ drag,
199
+ dropTarget,
200
+ lastWasDrag,
201
+ onPointerDown,
202
+ isDragging,
203
+ isHolding,
204
+ cleanup,
205
+ resolvePosition,
206
+ }
207
+ }