Skip to content

Commit f417483

Browse files
committed
feat: support reordering photos/notes on mobile
1 parent 978a8ff commit f417483

6 files changed

Lines changed: 260 additions & 7 deletions

File tree

src/components/Notes/NoteCard.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
class="note-card"
44
:class="{ 'note-card--dragging': isDragging }"
55
:style="cardStyle"
6+
:data-drag-id="note.id"
67
draggable="true"
78
@dragstart="onDragStart"
89
@dragend="onDragEnd"

src/components/Photos/FolderStack.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<div
33
class="folder-stack"
44
:class="{ 'folder-stack--drag-over': isDragOver }"
5+
:data-drag-id="'folder-' + folder.id"
56
draggable="true"
67
@dragstart="onDragStart"
78
@dragend="onDragEnd"

src/components/Photos/PhotoCard.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<div
33
class="photo-card"
44
:class="{ 'photo-card--dragging': isDragging }"
5+
:data-drag-id="photo.id"
56
draggable="true"
67
@dragstart="onDragStart"
78
@dragend="onDragEnd"

src/composables/useTouchReorder.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
2+
3+
export interface TouchReorderCallbacks {
4+
onDragStart: (_id: number) => void
5+
onReorderOver: (_hoveredId: number, _clientX: number, _clientY: number) => void
6+
onDrop: () => void
7+
onCancel: () => void
8+
}
9+
10+
const LONG_PRESS_MS = 300
11+
const MOVE_THRESHOLD = 8
12+
const SCROLL_EDGE = 40
13+
const SCROLL_SPEED = 8
14+
15+
/**
16+
* Adds touch-based drag-and-drop reorder to a container.
17+
*
18+
* Children must have `[data-drag-id]` attributes to be considered draggable.
19+
* The composable creates a floating ghost clone during drag and calls the same
20+
* callbacks the existing HTML5 DnD system uses.
21+
*/
22+
export function useTouchReorder(
23+
containerRef: Ref<HTMLElement | null>,
24+
callbacks: TouchReorderCallbacks,
25+
) {
26+
const isTouchDragging = ref(false)
27+
28+
let longPressTimer: ReturnType<typeof setTimeout> | null = null
29+
let scrollTimer: ReturnType<typeof setInterval> | null = null
30+
let ghost: HTMLElement | null = null
31+
let dragId: number | null = null
32+
let startX = 0
33+
let startY = 0
34+
35+
function findDraggable(el: HTMLElement): HTMLElement | null {
36+
return el.closest<HTMLElement>('[data-drag-id]')
37+
}
38+
39+
function getDragId(el: HTMLElement): number | null {
40+
const val = el.dataset.dragId
41+
return val != null ? Number(val) : null
42+
}
43+
44+
function createGhost(source: HTMLElement, x: number, y: number) {
45+
const rect = source.getBoundingClientRect()
46+
ghost = source.cloneNode(true) as HTMLElement
47+
ghost.style.position = 'fixed'
48+
ghost.style.width = rect.width + 'px'
49+
ghost.style.height = rect.height + 'px'
50+
ghost.style.left = x - rect.width / 2 + 'px'
51+
ghost.style.top = y - rect.height / 2 + 'px'
52+
ghost.style.zIndex = '999999'
53+
ghost.style.opacity = '0.85'
54+
ghost.style.pointerEvents = 'none'
55+
ghost.style.transition = 'none'
56+
ghost.style.transform = 'scale(1.05)'
57+
ghost.style.boxShadow = '0 8px 24px rgba(0,0,0,0.3)'
58+
document.body.appendChild(ghost)
59+
}
60+
61+
function moveGhost(x: number, y: number) {
62+
if (!ghost) return
63+
const w = ghost.offsetWidth
64+
const h = ghost.offsetHeight
65+
ghost.style.left = x - w / 2 + 'px'
66+
ghost.style.top = y - h / 2 + 'px'
67+
}
68+
69+
function removeGhost() {
70+
if (ghost) {
71+
ghost.remove()
72+
ghost = null
73+
}
74+
}
75+
76+
function autoScroll(clientY: number) {
77+
if (scrollTimer) {
78+
clearInterval(scrollTimer)
79+
scrollTimer = null
80+
}
81+
82+
const vh = window.innerHeight
83+
if (clientY < SCROLL_EDGE) {
84+
scrollTimer = setInterval(() => window.scrollBy(0, -SCROLL_SPEED), 16)
85+
} else if (clientY > vh - SCROLL_EDGE) {
86+
scrollTimer = setInterval(() => window.scrollBy(0, SCROLL_SPEED), 16)
87+
}
88+
}
89+
90+
function stopAutoScroll() {
91+
if (scrollTimer) {
92+
clearInterval(scrollTimer)
93+
scrollTimer = null
94+
}
95+
}
96+
97+
function hitTest(x: number, y: number): HTMLElement | null {
98+
if (ghost) ghost.style.display = 'none'
99+
const el = document.elementFromPoint(x, y) as HTMLElement | null
100+
if (ghost) ghost.style.display = ''
101+
return el ? findDraggable(el) : null
102+
}
103+
104+
function clearLongPress() {
105+
if (longPressTimer) {
106+
clearTimeout(longPressTimer)
107+
longPressTimer = null
108+
}
109+
}
110+
111+
function cancelDrag() {
112+
clearLongPress()
113+
stopAutoScroll()
114+
removeGhost()
115+
if (isTouchDragging.value) {
116+
isTouchDragging.value = false
117+
callbacks.onCancel()
118+
}
119+
dragId = null
120+
}
121+
122+
function onTouchStart(e: TouchEvent) {
123+
const touch = e.touches[0]
124+
if (!touch) return
125+
126+
const target = e.target as HTMLElement
127+
if (target.closest('button, a, input, textarea, select, .nc-actions')) return
128+
129+
const draggable = findDraggable(target)
130+
if (!draggable) return
131+
132+
const id = getDragId(draggable)
133+
if (id === null) return
134+
135+
startX = touch.clientX
136+
startY = touch.clientY
137+
dragId = id
138+
139+
longPressTimer = setTimeout(() => {
140+
longPressTimer = null
141+
isTouchDragging.value = true
142+
callbacks.onDragStart(id)
143+
createGhost(draggable, startX, startY)
144+
}, LONG_PRESS_MS)
145+
}
146+
147+
function onTouchMove(e: TouchEvent) {
148+
const touch = e.touches[0]
149+
if (!touch) return
150+
151+
const dx = touch.clientX - startX
152+
const dy = touch.clientY - startY
153+
154+
// If moved before long press triggers, cancel — user is scrolling
155+
if (!isTouchDragging.value) {
156+
if (Math.abs(dx) > MOVE_THRESHOLD || Math.abs(dy) > MOVE_THRESHOLD) {
157+
clearLongPress()
158+
}
159+
return
160+
}
161+
162+
// We're dragging — prevent scroll
163+
e.preventDefault()
164+
165+
moveGhost(touch.clientX, touch.clientY)
166+
autoScroll(touch.clientY)
167+
168+
const hovered = hitTest(touch.clientX, touch.clientY)
169+
if (hovered) {
170+
const hoveredId = getDragId(hovered)
171+
if (hoveredId !== null && hoveredId !== dragId) {
172+
callbacks.onReorderOver(hoveredId, touch.clientX, touch.clientY)
173+
}
174+
}
175+
}
176+
177+
function onTouchEnd() {
178+
clearLongPress()
179+
stopAutoScroll()
180+
removeGhost()
181+
182+
if (isTouchDragging.value) {
183+
isTouchDragging.value = false
184+
callbacks.onDrop()
185+
}
186+
dragId = null
187+
}
188+
189+
onMounted(() => {
190+
const el = containerRef.value
191+
if (!el) return
192+
el.addEventListener('touchstart', onTouchStart, { passive: false })
193+
el.addEventListener('touchmove', onTouchMove, { passive: false })
194+
el.addEventListener('touchend', onTouchEnd)
195+
el.addEventListener('touchcancel', cancelDrag)
196+
})
197+
198+
onBeforeUnmount(() => {
199+
cancelDrag()
200+
const el = containerRef.value
201+
if (!el) return
202+
el.removeEventListener('touchstart', onTouchStart)
203+
el.removeEventListener('touchmove', onTouchMove)
204+
el.removeEventListener('touchend', onTouchEnd)
205+
el.removeEventListener('touchcancel', cancelDrag)
206+
})
207+
208+
return { isTouchDragging }
209+
}

src/views/NotesView.vue

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import PlusIcon from '@icons/Plus.vue'
8888
import NoteIcon from '@icons/Note.vue'
8989
import type { Note } from '@/api/types'
9090
import { useNotes } from '@/composables/useNotes'
91+
import { useTouchReorder } from '@/composables/useTouchReorder'
9192
9293
const props = defineProps<{ houseId: string }>()
9394
@@ -132,24 +133,27 @@ function onDragStart(noteId: number) {
132133
dropIndex.value = null
133134
}
134135
135-
function onReorderOver(hoveredNoteId: number, e: MouseEvent) {
136+
function computeDropIndex(hoveredNoteId: number, clientX: number, target: HTMLElement | null) {
136137
const dragId = draggingNoteId.value
137138
if (!dragId || dragId === hoveredNoteId) return
138139
139140
const without = notes.value.filter((n) => n.id !== dragId)
140141
const idx = without.findIndex((n) => n.id === hoveredNoteId)
141142
if (idx === -1) return
142143
143-
const target = e.currentTarget as HTMLElement | null
144144
if (target) {
145145
const rect = target.getBoundingClientRect()
146-
const past = e.clientX > rect.left + rect.width / 2
146+
const past = clientX > rect.left + rect.width / 2
147147
dropIndex.value = past ? idx + 1 : idx
148148
} else {
149149
dropIndex.value = idx
150150
}
151151
}
152152
153+
function onReorderOver(hoveredNoteId: number, e: MouseEvent) {
154+
computeDropIndex(hoveredNoteId, e.clientX, e.currentTarget as HTMLElement | null)
155+
}
156+
153157
function onPlaceholderDrop() {
154158
commitReorder()
155159
}
@@ -191,6 +195,20 @@ onBeforeUnmount(() => {
191195
wallRef.value?.removeEventListener('dragend', onDragEndCapture, true)
192196
})
193197
198+
// ----- Touch reorder -----
199+
useTouchReorder(wallRef, {
200+
onDragStart: onDragStart,
201+
onReorderOver(hoveredId, clientX) {
202+
const el = wallRef.value?.querySelector<HTMLElement>(`[data-drag-id="${hoveredId}"]`)
203+
computeDropIndex(hoveredId, clientX, el)
204+
},
205+
onDrop: commitReorder,
206+
onCancel() {
207+
draggingNoteId.value = null
208+
dropIndex.value = null
209+
},
210+
})
211+
194212
// ----- Create / Edit -----
195213
196214
const showDialog = ref(false)

src/views/PhotosView.vue

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ import ArrowLeftIcon from '@icons/ArrowLeft.vue'
251251
import FolderPlusIcon from '@icons/FolderPlus.vue'
252252
import type { Photo, PhotoFolder } from '@/api/types'
253253
import { usePhotos, type UploadEntry } from '@/composables/usePhotos'
254+
import { useTouchReorder } from '@/composables/useTouchReorder'
254255
255256
const props = defineProps<{ houseId: string; folderId?: string }>()
256257
const router = useRouter()
@@ -358,25 +359,32 @@ function onPhotoDragStart(photoId: number) {
358359
dropIndex.value = null
359360
}
360361
361-
function onReorderOver(hoveredPhotoId: number, source: Photo[], e: MouseEvent) {
362+
function computePhotoDropIndex(
363+
hoveredPhotoId: number,
364+
source: Photo[],
365+
clientX: number,
366+
target: HTMLElement | null,
367+
) {
362368
const dragId = draggingPhotoId.value
363369
if (!dragId || dragId === hoveredPhotoId) return
364370
365371
const without = source.filter((p) => p.id !== dragId)
366372
const idx = without.findIndex((p) => p.id === hoveredPhotoId)
367373
if (idx === -1) return
368374
369-
// Determine if cursor is in the left or right half of the hovered card
370-
const target = e.currentTarget as HTMLElement | null
371375
if (target) {
372376
const rect = target.getBoundingClientRect()
373-
const past = e.clientX > rect.left + rect.width / 2
377+
const past = clientX > rect.left + rect.width / 2
374378
dropIndex.value = past ? idx + 1 : idx
375379
} else {
376380
dropIndex.value = idx
377381
}
378382
}
379383
384+
function onReorderOver(hoveredPhotoId: number, source: Photo[], e: MouseEvent) {
385+
computePhotoDropIndex(hoveredPhotoId, source, e.clientX, e.currentTarget as HTMLElement | null)
386+
}
387+
380388
function onPlaceholderDrop() {
381389
commitReorder()
382390
}
@@ -425,6 +433,21 @@ onBeforeUnmount(() => {
425433
boardRef.value?.removeEventListener('dragend', onDragEndCapture, true)
426434
})
427435
436+
// ----- Touch reorder -----
437+
useTouchReorder(boardRef, {
438+
onDragStart: onPhotoDragStart,
439+
onReorderOver(hoveredId, clientX) {
440+
const source = activeFolderId.value ? activeFolderPhotos.value : rootPhotos.value
441+
const el = boardRef.value?.querySelector<HTMLElement>(`[data-drag-id="${hoveredId}"]`)
442+
computePhotoDropIndex(hoveredId, source, clientX, el)
443+
},
444+
onDrop: commitReorder,
445+
onCancel() {
446+
draggingPhotoId.value = null
447+
dropIndex.value = null
448+
},
449+
})
450+
428451
// Folder dialog
429452
const showFolderDialog = ref(false)
430453
const renamingFolder = ref<PhotoFolder | null>(null)

0 commit comments

Comments
 (0)