grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add drag-to-reorder for photo thumbnails in gallery creation

Long-press (200ms) activates drag mode with pointer events. Dragged
thumb follows the pointer while neighbors shift to show the insertion
point. Uses frozen geometry snapshot for stable hit detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+132 -6
+132 -6
app/routes/create/+page.svelte
··· 81 81 if (photos.length === 0) step = 1 82 82 } 83 83 84 + // ─── Drag to Reorder ──────────────────────────────────────────────── 85 + 86 + let dragIndex: number | null = $state(null) 87 + let dragOverIndex: number | null = $state(null) 88 + let dragOffsetX = $state(0) 89 + let dropping = $state(false) 90 + let longPressTimer: ReturnType<typeof setTimeout> | null = null 91 + let dragStartX = 0 92 + let stripEl: HTMLDivElement | undefined = $state(undefined) 93 + let slotMidpoints: number[] = [] 94 + 95 + function snapshotGeometry() { 96 + if (!stripEl) return 97 + const thumbs = stripEl.querySelectorAll('.photo-thumb') as NodeListOf<HTMLElement> 98 + // Remove transforms temporarily to get clean layout positions 99 + const prev = Array.from(thumbs).map((t) => t.style.transform) 100 + thumbs.forEach((t) => (t.style.transform = 'none')) 101 + slotMidpoints = Array.from(thumbs).map((t) => { 102 + const rect = t.getBoundingClientRect() 103 + return rect.left + rect.width / 2 104 + }) 105 + thumbs.forEach((t, i) => (t.style.transform = prev[i])) 106 + } 107 + 108 + function indexFromPointerX(px: number): number { 109 + for (let i = 0; i < slotMidpoints.length; i++) { 110 + if (px < slotMidpoints[i]) return i 111 + } 112 + return slotMidpoints.length - 1 113 + } 114 + 115 + function onThumbPointerDown(e: PointerEvent, index: number) { 116 + if (photos.length < 2) return 117 + const target = e.currentTarget as HTMLElement 118 + dragStartX = e.clientX 119 + 120 + longPressTimer = setTimeout(() => { 121 + dragIndex = index 122 + dragOverIndex = index 123 + snapshotGeometry() 124 + target.setPointerCapture(e.pointerId) 125 + }, 200) 126 + } 127 + 128 + function onThumbPointerMove(e: PointerEvent) { 129 + if (dragIndex === null && longPressTimer && Math.abs(e.clientX - dragStartX) > 10) { 130 + clearTimeout(longPressTimer) 131 + longPressTimer = null 132 + return 133 + } 134 + if (dragIndex === null) return 135 + e.preventDefault() 136 + dragOffsetX = e.clientX - dragStartX 137 + 138 + // Use the dragged item's displaced midpoint against the frozen snapshot 139 + const draggedMidX = slotMidpoints[dragIndex] + dragOffsetX 140 + dragOverIndex = indexFromPointerX(draggedMidX) 141 + } 142 + 143 + function onThumbPointerUp() { 144 + if (longPressTimer) { 145 + clearTimeout(longPressTimer) 146 + longPressTimer = null 147 + } 148 + if (dragIndex !== null && dragOverIndex !== null && dragIndex !== dragOverIndex) { 149 + // Suppress transitions during the reorder so there's no snap-back 150 + dropping = true 151 + const moved = photos[dragIndex] 152 + const next = photos.filter((_, i) => i !== dragIndex) 153 + next.splice(dragOverIndex, 0, moved) 154 + photos = next 155 + // Re-enable transitions after Svelte renders the new order 156 + requestAnimationFrame(() => { 157 + dropping = false 158 + }) 159 + } 160 + dragIndex = null 161 + dragOverIndex = null 162 + dragOffsetX = 0 163 + } 164 + 165 + function thumbShift(index: number): number { 166 + if (dragIndex === null || dragOverIndex === null) return 0 167 + if (index === dragIndex) return 0 168 + const slot = 80 // thumb width (72) + gap (8) 169 + if (dragIndex < dragOverIndex) { 170 + if (index > dragIndex && index <= dragOverIndex) return -slot 171 + } else if (dragIndex > dragOverIndex) { 172 + if (index < dragIndex && index >= dragOverIndex) return slot 173 + } 174 + return 0 175 + } 176 + 84 177 // ─── Step 2: Metadata ────────────────────────────────────────────── 85 178 86 179 const canProceed = $derived(title.trim().length > 0 && photos.length > 0) ··· 254 347 255 348 <!-- Step 2: Metadata --> 256 349 {#if step === 2} 257 - <div class="photo-strip"> 350 + <div 351 + class="photo-strip" 352 + class:dragging={dragIndex !== null} 353 + class:dropping 354 + bind:this={stripEl} 355 + onpointermove={onThumbPointerMove} 356 + onpointerup={onThumbPointerUp} 357 + onpointercancel={onThumbPointerUp} 358 + > 258 359 {#each photos as photo, i} 259 - <div class="photo-thumb"> 260 - <img src={photo.dataUrl} alt="Photo {i + 1}" /> 261 - <button class="remove-btn" onclick={() => removePhoto(i)}> 262 - <X size={12} /> 263 - </button> 360 + {@const shift = thumbShift(i)} 361 + <div 362 + class="photo-thumb" 363 + class:drag-active={dragIndex === i} 364 + style={dragIndex === i ? `transform: translateX(${dragOffsetX}px) scale(1.08)` : shift ? `transform: translateX(${shift}px)` : ''} 365 + onpointerdown={(e) => onThumbPointerDown(e, i)} 366 + > 367 + <img src={photo.dataUrl} alt="Photo {i + 1}" draggable="false" /> 368 + {#if dragIndex === null} 369 + <button class="remove-btn" onclick={() => removePhoto(i)}> 370 + <X size={12} /> 371 + </button> 372 + {/if} 264 373 </div> 265 374 {/each} 266 375 </div> ··· 363 472 overflow-x: auto; 364 473 border-bottom: 1px solid var(--border); 365 474 } 475 + .photo-strip.dragging { 476 + touch-action: none; 477 + overflow-x: hidden; 478 + } 479 + .photo-strip.dropping .photo-thumb { 480 + transition: none !important; 481 + } 366 482 .photo-thumb { 367 483 position: relative; 368 484 flex-shrink: 0; 485 + transition: transform 150ms ease; 486 + user-select: none; 487 + touch-action: none; 488 + } 489 + .photo-thumb.drag-active { 490 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); 491 + z-index: 10; 492 + opacity: 0.9; 493 + transition: none; 369 494 } 370 495 .photo-thumb img { 371 496 width: 72px; 372 497 height: 72px; 373 498 object-fit: cover; 374 499 border-radius: 6px; 500 + pointer-events: none; 375 501 } 376 502 .remove-btn { 377 503 position: absolute;