[APP-1859] pinned feed drag n drop (#9893)
Co-authored-by: Samuel Newman <mozzius@protonmail.com> Co-authored-by: vineyardbovines <spencerfpope@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
import {useLayoutEffect, useRef} from 'react'
|
||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
type AnimatedRef,
|
||||
measure,
|
||||
runOnJS,
|
||||
scrollTo,
|
||||
type SharedValue,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useFrameCallback,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid'
|
||||
import {IS_IOS} from '#/env'
|
||||
|
||||
/**
|
||||
* Drag-to-reorder list. Items are absolutely positioned in a fixed-height
|
||||
* container and animated via Reanimated shared values on the UI thread.
|
||||
*
|
||||
* All positioning is driven by a `slots` map (key → index) and translateY
|
||||
* (no discrete `top` changes). On drag end the new slot assignment is
|
||||
* computed on the UI thread first, then React state is updated via runOnJS.
|
||||
*
|
||||
* See SortableList.web.tsx for the web implementation using pointer events.
|
||||
*/
|
||||
|
||||
interface SortableListProps<T> {
|
||||
data: T[]
|
||||
keyExtractor: (item: T) => string
|
||||
renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode
|
||||
onReorder: (data: T[]) => void
|
||||
onDragStart?: () => void
|
||||
onDragEnd?: () => void
|
||||
/** Fixed row height used for position math. */
|
||||
itemHeight: number
|
||||
/** Ref to the parent Animated.ScrollView for auto-scroll. */
|
||||
scrollRef?: AnimatedRef<Animated.ScrollView>
|
||||
/** Scroll offset shared value from useScrollViewOffset. */
|
||||
scrollOffset?: SharedValue<number>
|
||||
}
|
||||
|
||||
const AUTO_SCROLL_THRESHOLD = 50
|
||||
const AUTO_SCROLL_SPEED = 4
|
||||
|
||||
/**
|
||||
* Bundled into a single shared value so all fields update atomically
|
||||
* in one set() call on the UI thread.
|
||||
*/
|
||||
interface DragState {
|
||||
/** Maps each item key to its current slot index. */
|
||||
slots: Record<string, number>
|
||||
/** Key of the item being dragged, or '' when idle. */
|
||||
activeKey: string
|
||||
/** Slot the active item started in. */
|
||||
dragStartSlot: number
|
||||
}
|
||||
|
||||
export function SortableList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
onReorder,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
itemHeight,
|
||||
scrollRef,
|
||||
scrollOffset,
|
||||
}: SortableListProps<T>) {
|
||||
const t = useTheme()
|
||||
const state = useSharedValue<DragState>({
|
||||
slots: Object.fromEntries(data.map((item, i) => [keyExtractor(item), i])),
|
||||
activeKey: '',
|
||||
dragStartSlot: -1,
|
||||
})
|
||||
const dragY = useSharedValue(0)
|
||||
|
||||
// Auto-scroll shared values
|
||||
const scrollCompensation = useSharedValue(0)
|
||||
const isGestureActive = useSharedValue(false)
|
||||
// We track scroll position ourselves because scrollOffset.get() lags
|
||||
// by one frame after scrollTo(), causing a feedback loop where the
|
||||
// frame callback keeps thinking the item is at the edge.
|
||||
const trackedScrollY = useSharedValue(0)
|
||||
|
||||
// For measuring list position within scroll content
|
||||
const listRef = useAnimatedRef<Animated.View>()
|
||||
const listContentOffset = useSharedValue(0)
|
||||
const viewportHeight = useSharedValue(0)
|
||||
const measureDone = useSharedValue(false)
|
||||
|
||||
// Sync slots when data changes externally (e.g. pin/unpin).
|
||||
// Skip after our own reorder — the worklet already set correct slots
|
||||
// on the UI thread, and a redundant JS-side set() would be wasteful.
|
||||
const skipNextSync = useRef(false)
|
||||
const currentKeys = data.map(item => keyExtractor(item)).join(',')
|
||||
useLayoutEffect(() => {
|
||||
if (skipNextSync.current) {
|
||||
skipNextSync.current = false
|
||||
return
|
||||
}
|
||||
const nextSlots: Record<string, number> = {}
|
||||
data.forEach((item, i) => {
|
||||
nextSlots[keyExtractor(item)] = i
|
||||
})
|
||||
state.set({slots: nextSlots, activeKey: '', dragStartSlot: -1})
|
||||
dragY.set(0)
|
||||
}, [currentKeys, data, keyExtractor, state, dragY])
|
||||
|
||||
const handleReorder = (sortedKeys: string[]) => {
|
||||
skipNextSync.current = true
|
||||
const byKey = new Map(data.map(item => [keyExtractor(item), item]))
|
||||
onReorder(sortedKeys.map(key => byKey.get(key)!))
|
||||
onDragEnd?.()
|
||||
}
|
||||
|
||||
// Auto-scroll: runs every frame while a gesture is active.
|
||||
useFrameCallback(() => {
|
||||
if (!isGestureActive.get()) return
|
||||
if (!scrollRef || !scrollOffset) return
|
||||
|
||||
const s = state.get()
|
||||
if (s.activeKey === '') return
|
||||
|
||||
// Measure list and scroll view on first frame of drag.
|
||||
// Use scrollOffset here (only once) since no lag has occurred yet.
|
||||
if (!measureDone.get()) {
|
||||
const scrollM = measure(
|
||||
scrollRef as unknown as AnimatedRef<Animated.View>,
|
||||
)
|
||||
const listM = measure(listRef)
|
||||
if (!scrollM || !listM) return
|
||||
trackedScrollY.set(scrollOffset.get())
|
||||
listContentOffset.set(listM.pageY - scrollM.pageY + trackedScrollY.get())
|
||||
viewportHeight.set(scrollM.height)
|
||||
measureDone.set(true)
|
||||
}
|
||||
|
||||
const startSlot = s.dragStartSlot
|
||||
const currentDragY = dragY.get()
|
||||
|
||||
// Use trackedScrollY (not scrollOffset) to avoid the one-frame lag
|
||||
// after scrollTo() that causes a feedback loop.
|
||||
const scrollY = trackedScrollY.get()
|
||||
|
||||
// Item position relative to scroll viewport top.
|
||||
const itemContentY =
|
||||
listContentOffset.get() + startSlot * itemHeight + currentDragY
|
||||
const itemViewportY = itemContentY - scrollY
|
||||
const itemBottomViewportY = itemViewportY + itemHeight
|
||||
|
||||
let scrollDelta = 0
|
||||
if (itemViewportY < AUTO_SCROLL_THRESHOLD) {
|
||||
scrollDelta = -AUTO_SCROLL_SPEED
|
||||
} else if (
|
||||
itemBottomViewportY >
|
||||
viewportHeight.get() - AUTO_SCROLL_THRESHOLD
|
||||
) {
|
||||
scrollDelta = AUTO_SCROLL_SPEED
|
||||
}
|
||||
|
||||
if (scrollDelta === 0) return
|
||||
|
||||
// Don't scroll if the item is already at a list boundary.
|
||||
const effectiveSlotPos =
|
||||
(startSlot * itemHeight + currentDragY) / itemHeight
|
||||
if (scrollDelta < 0 && effectiveSlotPos <= 0) return
|
||||
if (scrollDelta > 0 && effectiveSlotPos >= data.length - 1) return
|
||||
|
||||
// Don't scroll past the top.
|
||||
if (scrollDelta < 0 && scrollY <= 0) return
|
||||
|
||||
const newScrollY = Math.max(0, scrollY + scrollDelta)
|
||||
scrollTo(scrollRef, 0, newScrollY, false)
|
||||
trackedScrollY.set(newScrollY)
|
||||
scrollCompensation.set(scrollCompensation.get() + (newScrollY - scrollY))
|
||||
})
|
||||
|
||||
// Render in stable key order so React never reorders native views.
|
||||
// On Android, native ViewGroup child reordering causes a visual flash.
|
||||
const sortedData = [...data].sort((a, b) => {
|
||||
const ka = keyExtractor(a)
|
||||
const kb = keyExtractor(b)
|
||||
return ka < kb ? -1 : ka > kb ? 1 : 0
|
||||
})
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
ref={listRef}
|
||||
style={[{height: data.length * itemHeight}, t.atoms.bg_contrast_25]}>
|
||||
{sortedData.map(item => {
|
||||
const key = keyExtractor(item)
|
||||
return (
|
||||
<SortableItem
|
||||
key={key}
|
||||
item={item}
|
||||
itemKey={key}
|
||||
itemCount={data.length}
|
||||
itemHeight={itemHeight}
|
||||
state={state}
|
||||
dragY={dragY}
|
||||
scrollCompensation={scrollCompensation}
|
||||
isGestureActive={isGestureActive}
|
||||
measureDone={measureDone}
|
||||
renderItem={renderItem}
|
||||
onCommitReorder={handleReorder}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableItem<T>({
|
||||
item,
|
||||
itemKey,
|
||||
itemCount,
|
||||
itemHeight,
|
||||
state,
|
||||
dragY,
|
||||
scrollCompensation,
|
||||
isGestureActive,
|
||||
measureDone,
|
||||
renderItem,
|
||||
onCommitReorder,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}: {
|
||||
item: T
|
||||
itemKey: string
|
||||
itemCount: number
|
||||
itemHeight: number
|
||||
state: Animated.SharedValue<DragState>
|
||||
dragY: Animated.SharedValue<number>
|
||||
scrollCompensation: SharedValue<number>
|
||||
isGestureActive: SharedValue<boolean>
|
||||
measureDone: SharedValue<boolean>
|
||||
renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode
|
||||
onCommitReorder: (sortedKeys: string[]) => void
|
||||
onDragStart?: () => void
|
||||
onDragEnd?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const playHaptic = useHaptics()
|
||||
|
||||
const lastHapticSlot = useSharedValue(-1)
|
||||
|
||||
const gesture = Gesture.Pan()
|
||||
.onStart(() => {
|
||||
'worklet'
|
||||
const s = state.get()
|
||||
const mySlot = s.slots[itemKey]
|
||||
state.set({...s, activeKey: itemKey, dragStartSlot: mySlot})
|
||||
dragY.set(0)
|
||||
scrollCompensation.set(0)
|
||||
isGestureActive.set(true)
|
||||
measureDone.set(false)
|
||||
lastHapticSlot.set(mySlot)
|
||||
if (onDragStart) {
|
||||
runOnJS(onDragStart)()
|
||||
}
|
||||
runOnJS(playHaptic)()
|
||||
})
|
||||
.onChange(e => {
|
||||
'worklet'
|
||||
const startSlot = state.get().dragStartSlot
|
||||
const minY = -startSlot * itemHeight
|
||||
const maxY = (itemCount - 1 - startSlot) * itemHeight
|
||||
// Include scroll compensation so the item tracks with auto-scroll.
|
||||
const effectiveY = e.translationY + scrollCompensation.get()
|
||||
const clampedY = Math.max(minY, Math.min(effectiveY, maxY))
|
||||
dragY.set(clampedY)
|
||||
|
||||
const currentSlot = Math.round(
|
||||
(startSlot * itemHeight + clampedY) / itemHeight,
|
||||
)
|
||||
const clampedSlot = Math.max(0, Math.min(currentSlot, itemCount - 1))
|
||||
if (IS_IOS && clampedSlot !== lastHapticSlot.get()) {
|
||||
lastHapticSlot.set(clampedSlot)
|
||||
runOnJS(playHaptic)('Light')
|
||||
}
|
||||
})
|
||||
.onEnd(() => {
|
||||
'worklet'
|
||||
// Stop auto-scroll BEFORE the snap animation.
|
||||
isGestureActive.set(false)
|
||||
const startSlot = state.get().dragStartSlot
|
||||
const rawNewSlot = Math.round(
|
||||
(startSlot * itemHeight + dragY.get()) / itemHeight,
|
||||
)
|
||||
const newSlot = Math.max(0, Math.min(rawNewSlot, itemCount - 1))
|
||||
const snapOffset = (newSlot - startSlot) * itemHeight
|
||||
|
||||
// Animate to the target slot, then commit.
|
||||
dragY.set(
|
||||
withTiming(snapOffset, {duration: 200}, finished => {
|
||||
if (finished) {
|
||||
if (newSlot !== startSlot) {
|
||||
// Compute new slots on the UI thread so animated styles
|
||||
// reflect final positions before React re-renders.
|
||||
const cur = state.get()
|
||||
const sorted: string[] = new Array(itemCount)
|
||||
for (const key in cur.slots) {
|
||||
sorted[cur.slots[key]] = key
|
||||
}
|
||||
const movedKey = sorted[startSlot]
|
||||
sorted.splice(startSlot, 1)
|
||||
sorted.splice(newSlot, 0, movedKey)
|
||||
|
||||
const nextSlots: Record<string, number> = {}
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
nextSlots[sorted[i]] = i
|
||||
}
|
||||
|
||||
state.set({
|
||||
slots: nextSlots,
|
||||
activeKey: '',
|
||||
dragStartSlot: -1,
|
||||
})
|
||||
dragY.set(0)
|
||||
runOnJS(onCommitReorder)(sorted)
|
||||
} else {
|
||||
const s = state.get()
|
||||
state.set({...s, activeKey: '', dragStartSlot: -1})
|
||||
dragY.set(0)
|
||||
if (onDragEnd) {
|
||||
runOnJS(onDragEnd)()
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
// Reset if the gesture is cancelled without onEnd firing.
|
||||
.onFinalize(() => {
|
||||
'worklet'
|
||||
isGestureActive.set(false)
|
||||
if (state.get().activeKey === itemKey && dragY.get() === 0) {
|
||||
const s = state.get()
|
||||
state.set({...s, activeKey: '', dragStartSlot: -1})
|
||||
if (onDragEnd) {
|
||||
runOnJS(onDragEnd)()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// All vertical positioning is via translateY (no `top`). This avoids
|
||||
// discrete jumps when slots change — Reanimated smoothly animates from
|
||||
// the current translateY to the new target on every state transition.
|
||||
// On first mount we skip the animation so items appear instantly.
|
||||
const isFirstRender = useSharedValue(true)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const s = state.get()
|
||||
const mySlot = s.slots[itemKey]
|
||||
if (mySlot === undefined) {
|
||||
return {}
|
||||
}
|
||||
const baseY = mySlot * itemHeight
|
||||
|
||||
// Active item: follow the finger with a slight scale-up and shadow.
|
||||
if (s.activeKey === itemKey) {
|
||||
return {
|
||||
transform: [
|
||||
{translateY: s.dragStartSlot * itemHeight + dragY.get()},
|
||||
{scale: withSpring(1.03)},
|
||||
],
|
||||
zIndex: 999,
|
||||
...(IS_IOS
|
||||
? {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {width: 0, height: 1},
|
||||
shadowOpacity: withSpring(0.08),
|
||||
shadowRadius: withSpring(4),
|
||||
}
|
||||
: {
|
||||
elevation: withSpring(3),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Reset for non-active states. Without this, shadow props
|
||||
// set during dragging linger on the native view.
|
||||
const inactive = {
|
||||
...(IS_IOS
|
||||
? {
|
||||
shadowOpacity: withSpring(0),
|
||||
shadowRadius: withSpring(0),
|
||||
}
|
||||
: {
|
||||
elevation: withSpring(0),
|
||||
}),
|
||||
}
|
||||
|
||||
// Another item is being dragged — shift to make room.
|
||||
if (s.activeKey !== '') {
|
||||
isFirstRender.set(false)
|
||||
const currentDragPos = Math.round(
|
||||
(s.dragStartSlot * itemHeight + dragY.get()) / itemHeight,
|
||||
)
|
||||
const clampedPos = Math.max(0, Math.min(currentDragPos, itemCount - 1))
|
||||
|
||||
let offset = 0
|
||||
if (
|
||||
s.dragStartSlot < clampedPos &&
|
||||
mySlot > s.dragStartSlot &&
|
||||
mySlot <= clampedPos
|
||||
) {
|
||||
offset = -itemHeight
|
||||
} else if (
|
||||
s.dragStartSlot > clampedPos &&
|
||||
mySlot < s.dragStartSlot &&
|
||||
mySlot >= clampedPos
|
||||
) {
|
||||
offset = itemHeight
|
||||
}
|
||||
|
||||
return {
|
||||
transform: [
|
||||
{translateY: withTiming(baseY + offset, {duration: 200})},
|
||||
{scale: withSpring(1)},
|
||||
],
|
||||
zIndex: 0,
|
||||
...inactive,
|
||||
}
|
||||
}
|
||||
|
||||
// Idle: sit at our slot. On first render use a direct value so items
|
||||
// don't animate from y=0. After any drag, use withTiming so the
|
||||
// shift→idle transition is smooth (no discrete jump).
|
||||
if (isFirstRender.get()) {
|
||||
isFirstRender.set(false)
|
||||
return {
|
||||
transform: [{translateY: baseY}, {scale: 1}],
|
||||
zIndex: 0,
|
||||
...inactive,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
transform: [{translateY: withTiming(baseY, {duration: 200})}, {scale: 1}],
|
||||
zIndex: 0,
|
||||
...inactive,
|
||||
}
|
||||
})
|
||||
|
||||
const dragHandle = (
|
||||
<GestureDetector gesture={gesture}>
|
||||
<Animated.View
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.px_sm,
|
||||
a.py_md,
|
||||
web({cursor: 'grab'}),
|
||||
]}
|
||||
hitSlop={{top: 8, bottom: 8, left: 8, right: 8}}>
|
||||
<GripIcon
|
||||
size="lg"
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
style={web({pointerEvents: 'none'})}
|
||||
/>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
)
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: itemHeight,
|
||||
},
|
||||
animatedStyle,
|
||||
]}>
|
||||
{renderItem(item, dragHandle)}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {useTheme} from '#/alf'
|
||||
import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid'
|
||||
|
||||
/**
|
||||
* Web implementation of SortableList using pointer events.
|
||||
* See SortableList.tsx for the native version using gesture-handler + Reanimated.
|
||||
*/
|
||||
|
||||
interface SortableListProps<T> {
|
||||
data: T[]
|
||||
keyExtractor: (item: T) => string
|
||||
renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode
|
||||
onReorder: (data: T[]) => void
|
||||
onDragStart?: () => void
|
||||
onDragEnd?: () => void
|
||||
/** Fixed row height used for position math. */
|
||||
itemHeight: number
|
||||
}
|
||||
|
||||
export function SortableList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
onReorder,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
itemHeight,
|
||||
}: SortableListProps<T>) {
|
||||
const t = useTheme()
|
||||
const [dragState, setDragState] = useState<{
|
||||
activeIndex: number
|
||||
currentY: number
|
||||
startY: number
|
||||
} | null>(null)
|
||||
|
||||
const getNewPosition = (state: {
|
||||
activeIndex: number
|
||||
currentY: number
|
||||
startY: number
|
||||
}) => {
|
||||
const translationY = state.currentY - state.startY
|
||||
const rawNewPos = Math.round(
|
||||
(state.activeIndex * itemHeight + translationY) / itemHeight,
|
||||
)
|
||||
return Math.max(0, Math.min(rawNewPos, data.length - 1))
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
if (!dragState) return
|
||||
e.preventDefault()
|
||||
setDragState(prev => (prev ? {...prev, currentY: e.clientY} : null))
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (!dragState) return
|
||||
const newPos = getNewPosition(dragState)
|
||||
if (newPos !== dragState.activeIndex) {
|
||||
const next = [...data]
|
||||
const [moved] = next.splice(dragState.activeIndex, 1)
|
||||
next.splice(newPos, 0, moved)
|
||||
onReorder(next)
|
||||
}
|
||||
setDragState(null)
|
||||
onDragEnd?.()
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent, index: number) => {
|
||||
e.preventDefault()
|
||||
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
setDragState({activeIndex: index, currentY: e.clientY, startY: e.clientY})
|
||||
onDragStart?.()
|
||||
}
|
||||
|
||||
const newPos = dragState ? getNewPosition(dragState) : -1
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{height: data.length * itemHeight, position: 'relative'},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
// @ts-expect-error web-only pointer events
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}>
|
||||
{data.map((item, index) => {
|
||||
const isActive = dragState?.activeIndex === index
|
||||
|
||||
// Clamp translation so the item stays within list bounds.
|
||||
const rawTranslationY = isActive
|
||||
? dragState.currentY - dragState.startY
|
||||
: 0
|
||||
const translationY = isActive
|
||||
? Math.max(
|
||||
-index * itemHeight,
|
||||
Math.min(rawTranslationY, (data.length - 1 - index) * itemHeight),
|
||||
)
|
||||
: 0
|
||||
|
||||
// Non-dragged items shift to make room for the dragged item.
|
||||
let offset = 0
|
||||
if (dragState && !isActive) {
|
||||
const orig = dragState.activeIndex
|
||||
if (orig < newPos && index > orig && index <= newPos) {
|
||||
offset = -itemHeight
|
||||
} else if (orig > newPos && index < orig && index >= newPos) {
|
||||
offset = itemHeight
|
||||
}
|
||||
}
|
||||
|
||||
const dragHandle = (
|
||||
<div
|
||||
onPointerDown={(e: React.PointerEvent<HTMLDivElement>) =>
|
||||
handlePointerDown(e, index)
|
||||
}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
cursor: isActive ? 'grabbing' : 'grab',
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
<GripIcon
|
||||
size="lg"
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
style={{pointerEvents: 'none'} as any}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
key={keyExtractor(item)}
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
top: index * itemHeight,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: itemHeight,
|
||||
transform: [{translateY: isActive ? translationY : offset}],
|
||||
scale: isActive ? 1.03 : 1,
|
||||
zIndex: isActive ? 999 : 0,
|
||||
boxShadow: isActive ? '0 2px 12px rgba(0,0,0,0.06)' : 'none',
|
||||
// Animate scale/shadow on pickup, and transform for
|
||||
// non-dragged items shifting into place.
|
||||
transition: isActive
|
||||
? 'box-shadow 200ms ease, scale 200ms ease'
|
||||
: dragState
|
||||
? 'transform 200ms ease'
|
||||
: 'none',
|
||||
} as any,
|
||||
]}>
|
||||
{renderItem(item, dragHandle)}
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {type Shadow} from '#/state/cache/post-shadow'
|
||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
|
||||
|
||||
@@ -9,7 +9,7 @@ import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
|
||||
import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
|
||||
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
||||
import {hasReachedReactionLimit} from './util'
|
||||
|
||||
@@ -24,7 +24,7 @@ import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
||||
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
|
||||
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
|
||||
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
import {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {useSession} from '#/state/session'
|
||||
import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker'
|
||||
import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji'
|
||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {type TriggerProps} from '#/components/Menu/types'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const DotGrid_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
export const DotGrid3x1_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M2 12a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm16 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm-6-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
|
||||
})
|
||||
|
||||
export const DotGrid2x3_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M9 17a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm-6-7a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4ZM9 3a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user