diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e6bccd5d5..999525b04 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -75,6 +75,7 @@ "react-native-tab-view": "^3.4.0", "react-native-url-polyfill": "^1.3.0", "react-native-webview": "13.6.4", + "rfdc": "^1.3.1", "sentry-expo": "~7.2.0", "siwe": "^2.1.4", "swr": "^2.1.1", diff --git a/apps/mobile/src/components/GalleryEditor/GalleryEditorNavbar.tsx b/apps/mobile/src/components/GalleryEditor/GalleryEditorNavbar.tsx index 3977b766a..dd7ebd368 100644 --- a/apps/mobile/src/components/GalleryEditor/GalleryEditorNavbar.tsx +++ b/apps/mobile/src/components/GalleryEditor/GalleryEditorNavbar.tsx @@ -77,17 +77,34 @@ function DebuggerBottomSheet({ activeRowId, activeSectionId, sections }: Debugge {section.name || 'Empty'} - {section.dbid} - {section.rows.map((row, index) => { - return ( - - - {index + 1}. {row.id} - columns ({row.columns}) - - - ); - })} + + {section.rows.map((row, index) => { + return ( + + + {index + 1}. {row.id} - columns ({row.columns}) + + + {row.items.map((item) => { + return ( + + {item.id} + + ); + })} + + + ); + })} + ); })} diff --git a/apps/mobile/src/components/GalleryEditor/GalleryEditorRow.tsx b/apps/mobile/src/components/GalleryEditor/GalleryEditorRow.tsx index 6836bdc10..2edda43a2 100644 --- a/apps/mobile/src/components/GalleryEditor/GalleryEditorRow.tsx +++ b/apps/mobile/src/components/GalleryEditor/GalleryEditorRow.tsx @@ -1,7 +1,8 @@ +import { FlashList } from '@shopify/flash-list'; import clsx from 'clsx'; import React, { useCallback } from 'react'; -import { GestureResponderEvent, View, ViewProps } from 'react-native'; -import Animated from 'react-native-reanimated'; +import { GestureResponderEvent, View } from 'react-native'; +import Animated, { AnimatedRef, SharedValue } from 'react-native-reanimated'; import { graphql, useFragment } from 'react-relay'; import { useGalleryEditorActions } from '~/contexts/GalleryEditor/GalleryEditorContext'; @@ -10,17 +11,26 @@ import { GalleryEditorRowFragment$key } from '~/generated/GalleryEditorRowFragme import { GalleryTouchableOpacity } from '../GalleryTouchableOpacity'; import { GalleryEditorActiveActions } from './GalleryEditorActiveActions'; -import { GalleryEditorTokenPreview } from './GalleryEditorTokenPreview'; +import { ListItemType } from './GalleryEditorRenderer'; +import { SortableTokenGrid } from './SortableTokenGrid/SortableTokenGrid'; import { useWidthPerToken } from './useWidthPerToken'; type Props = { sectionId: string; row: StagedRow; - style?: ViewProps['style']; queryRef: GalleryEditorRowFragment$key; + + scrollContentOffsetY: SharedValue; + scrollViewRef: AnimatedRef>; }; -export function GalleryEditorRow({ sectionId, row, style, queryRef }: Props) { +export function GalleryEditorRow({ + sectionId, + row, + queryRef, + scrollContentOffsetY, + scrollViewRef, +}: Props) { const query = useFragment( graphql` fragment GalleryEditorRowFragment on Query { @@ -30,7 +40,7 @@ export function GalleryEditorRow({ sectionId, row, style, queryRef }: Props) { queryRef ); - const { activateRow, activeRowId } = useGalleryEditorActions(); + const { activateRow, activeRowId, moveItem } = useGalleryEditorActions(); const widthPerToken = useWidthPerToken(row.columns); @@ -42,37 +52,42 @@ export function GalleryEditorRow({ sectionId, row, style, queryRef }: Props) { [activateRow, sectionId, row.id] ); + const handleDragStart = useCallback(() => { + activateRow(sectionId, row.id); + }, [activateRow, sectionId, row.id]); + + const handleDragEnd = useCallback( + (newPositionsById: string[]) => { + moveItem(row.id, newPositionsById); + }, + [moveItem, row.id] + ); + return ( - + - - - {row.items.map((item) => { - if (item.kind === 'whitespace') { - return ; - } else { - return ( - - - - ); - } - })} + + + {/* this component is responsible for rendering the grid of tokens. */} + {activeRowId === row.id && } @@ -80,12 +95,3 @@ export function GalleryEditorRow({ sectionId, row, style, queryRef }: Props) { ); } - -type WhiteSpaceProps = { - size: number; - style?: ViewProps['style']; -}; - -function WhiteSpace({ size, style }: WhiteSpaceProps) { - return ; -} diff --git a/apps/mobile/src/components/GalleryEditor/GalleryEditorSection.tsx b/apps/mobile/src/components/GalleryEditor/GalleryEditorSection.tsx index 1e6f41ee6..a675a3822 100644 --- a/apps/mobile/src/components/GalleryEditor/GalleryEditorSection.tsx +++ b/apps/mobile/src/components/GalleryEditor/GalleryEditorSection.tsx @@ -4,7 +4,6 @@ import { useCallback, useMemo } from 'react'; import { View } from 'react-native'; import { AnimatedRef, SharedValue } from 'react-native-reanimated'; import { graphql, useFragment } from 'react-relay'; -import { DragIcon } from 'src/icons/DragIcon'; import { useGalleryEditorActions } from '~/contexts/GalleryEditor/GalleryEditorContext'; import { StagedSection } from '~/contexts/GalleryEditor/types'; @@ -71,13 +70,6 @@ export function GalleryEditorSection({ 'border-activeBlue': highlightedSection, })} > - {highlightedSection && ( - - - - - - )} {section.name} diff --git a/apps/mobile/src/components/GalleryEditor/SortableRow.tsx b/apps/mobile/src/components/GalleryEditor/SortableRow.tsx index 774bbaf92..6e964609b 100644 --- a/apps/mobile/src/components/GalleryEditor/SortableRow.tsx +++ b/apps/mobile/src/components/GalleryEditor/SortableRow.tsx @@ -12,7 +12,6 @@ import Animated, { useAnimatedStyle, useDerivedValue, useSharedValue, - withSpring, withTiming, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -247,12 +246,7 @@ export function SortableRow({ left: 0, right: 0, zIndex: zIndex, - transform: [ - { translateY: translateY.value ?? 0 }, - { - scale: withSpring(isGestureActive.value ? 1.05 : 1), - }, - ], + transform: [{ translateY: translateY.value ?? 0 }], }; }, []); diff --git a/apps/mobile/src/components/GalleryEditor/SortableRowList.tsx b/apps/mobile/src/components/GalleryEditor/SortableRowList.tsx index 997d22351..2c6a374e8 100644 --- a/apps/mobile/src/components/GalleryEditor/SortableRowList.tsx +++ b/apps/mobile/src/components/GalleryEditor/SortableRowList.tsx @@ -98,7 +98,13 @@ export function SortableRowList({ scrollViewRef={scrollViewRef} onDragEnd={onDragEnd} > - + ); })} diff --git a/apps/mobile/src/components/GalleryEditor/SortableTokenGrid/SortableToken.tsx b/apps/mobile/src/components/GalleryEditor/SortableTokenGrid/SortableToken.tsx new file mode 100644 index 000000000..68656c386 --- /dev/null +++ b/apps/mobile/src/components/GalleryEditor/SortableTokenGrid/SortableToken.tsx @@ -0,0 +1,222 @@ +import { FlashList } from '@shopify/flash-list'; +import { useCallback } from 'react'; +import { useWindowDimensions } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { trigger } from 'react-native-haptic-feedback'; +import Animated, { + AnimatedRef, + Easing, + runOnJS, + scrollTo, + SharedValue, + useAnimatedReaction, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { ListItemType } from '../GalleryEditorRenderer'; +import { getOrder, getPosition, Positions } from './utils'; + +type Props = { + children: React.ReactNode; + id: string; + positions: SharedValue; + animatedId: SharedValue; + size: number; + columns: number; + + scrollContentOffsetY: SharedValue; + scrollViewRef: AnimatedRef>; + + onDragStart: () => void; + onDragEnd: (data: string[]) => void; +}; + +export function SortableToken({ + children, + columns, + positions, + animatedId, + size, + id, + scrollContentOffsetY, + scrollViewRef, + onDragStart, + onDragEnd, +}: Props) { + const position = getPosition(positions.value[id] || 0, columns, size); + + // Shared values for gesture handling + const contextX = useSharedValue(0); + const contextY = useSharedValue(0); + + const translateX = useSharedValue(position.x); + const translateY = useSharedValue(position.y); + + // Shared values for tracking gesture and animation state + const wasLastActiveId = useSharedValue(false); + + // Animated reaction to update last active index + useAnimatedReaction( + () => animatedId.value, + (currentActiveId) => { + if (currentActiveId) { + wasLastActiveId.value = currentActiveId === id; + } + } + ); + + // Derived value to check if the gesture is active + const isGestureActive = useDerivedValue(() => { + return animatedId.value === id; + }, [id]); + + // Get safe area insets and window dimensions + const inset = useSafeAreaInsets(); + const { height: windowHeight } = useWindowDimensions(); + const containerHeight = windowHeight - inset.top; + + useAnimatedReaction( + () => positions.value[id], + (newOrder) => { + const newPosition = getPosition(newOrder || 0, columns, size); + translateX.value = withTiming(newPosition.x, { + duration: 350, + easing: Easing.inOut(Easing.ease), + }); + translateY.value = withTiming(newPosition.y, { + duration: 350, + easing: Easing.inOut(Easing.ease), + }); + } + ); + + // Callback to handle edge cases while scrolling + // (when the user drags the item to the top or bottom of the list) + // Since we need to update the scroll position of the scroll view (scrollTo) + const scrollLogic = useCallback( + ({ absoluteY }: { absoluteY: number }) => { + 'worklet'; + + const lowerBound = 1.5 * size; + const upperBound = scrollContentOffsetY.value + containerHeight; + const scrollSpeed = size * 0.1; + + if (absoluteY <= lowerBound) { + // while scrolling to the top of the list + const nextPosition = scrollContentOffsetY.value - scrollSpeed; + scrollTo(scrollViewRef, 0, Math.max(nextPosition, 0), false); + } else if (absoluteY + scrollContentOffsetY.value >= upperBound) { + // while scrolling to the bottom of the list + const nextPosition = scrollContentOffsetY.value + scrollSpeed; + scrollTo(scrollViewRef, 0, Math.max(nextPosition, 0), false); + } + }, + [containerHeight, scrollContentOffsetY, scrollViewRef, size] + ); + + // Need to keep track of the previous positions to check if the positions have changed + // This is needed to trigger the onDragEnd callback + // const prevPositions = useSharedValue({}); + const prevPositions = useSharedValue({} as Record); + + const panGesture = Gesture.Pan() + .activateAfterLongPress(300) + .onStart(() => { + // Store the previous positions (before the gesture starts) + prevPositions.value = Object.assign({}, positions.value); + + animatedId.value = id; + + contextX.value = translateX.value; + contextY.value = translateY.value - scrollContentOffsetY.value; + + runOnJS(trigger)('impactLight'); + + runOnJS(onDragStart)(); + }) + .onUpdate((event) => { + const { translationX, translationY, absoluteY } = event; + + translateX.value = contextX.value + translationX; + translateY.value = contextY.value + translationY + scrollContentOffsetY.value; + + const oldOrder = positions.value[id]; + const newOrder = getOrder(translateX.value, translateY.value, columns, size); + + if (oldOrder !== newOrder) { + const idToSwap = Object.keys(positions.value).find( + (key) => positions.value[key] === newOrder + ); + + if (idToSwap) { + const newPositions = JSON.parse(JSON.stringify(positions.value)); + newPositions[id] = newOrder; + newPositions[idToSwap] = oldOrder; + positions.value = newPositions; + } + } + + scrollLogic({ absoluteY }); + }) + .onFinalize(() => { + const newPosition = getPosition(positions.value[id]! || 0, columns, size); + + translateX.value = withTiming(newPosition.x, { + easing: Easing.inOut(Easing.ease), + duration: 350, + }); + + translateY.value = withTiming( + newPosition.y, + { + easing: Easing.inOut(Easing.ease), + duration: 350, + }, + (isFinished) => { + // Check if the positions have changed to trigger the onDragEnd callback + const positionsHaveChanged = Object.keys(prevPositions.value).some((key) => { + return positions.value[key] !== prevPositions.value[key]; + }); + + if (isFinished && onDragEnd && positionsHaveChanged) { + const sortedIds = Object.keys(positions.value).sort((a, b) => { + const prev = positions.value[a] || 0; + const next = positions.value[b] || 0; + + return prev - next; + }); + + runOnJS(onDragEnd)(sortedIds); + } + + // Reset the animated id + animatedId.value = null; + } + ); + }); + + const style = useAnimatedStyle(() => { + const zIndex = isGestureActive.value ? 100 : 0; + const scale = isGestureActive.value ? 1.05 : 1; + + return { + position: 'absolute', + top: 0, + left: 0, + width: size, + height: size, + zIndex, + transform: [{ translateX: translateX.value }, { translateY: translateY.value }, { scale }], + }; + }); + + return ( + + {children} + + ); +} diff --git a/apps/mobile/src/components/GalleryEditor/SortableTokenGrid/SortableTokenGrid.tsx b/apps/mobile/src/components/GalleryEditor/SortableTokenGrid/SortableTokenGrid.tsx new file mode 100644 index 000000000..54c613936 --- /dev/null +++ b/apps/mobile/src/components/GalleryEditor/SortableTokenGrid/SortableTokenGrid.tsx @@ -0,0 +1,99 @@ +import { FlashList } from '@shopify/flash-list'; +import { useEffect, useMemo } from 'react'; +import { View, ViewProps } from 'react-native'; +import { AnimatedRef, SharedValue, useSharedValue } from 'react-native-reanimated'; + +import { StagedItem } from '~/contexts/GalleryEditor/types'; + +import { ListItemType } from '../GalleryEditorRenderer'; +import { GalleryEditorTokenPreview } from '../GalleryEditorTokenPreview'; +import { SortableToken } from './SortableToken'; +import { Positions } from './utils'; + +type Props = { + columns: number; + items: StagedItem[]; + size: number; + + scrollContentOffsetY: SharedValue; + scrollViewRef: AnimatedRef>; + + onDragStart: () => void; + onDragEnd: (data: string[]) => void; +}; + +const GAP = 8; + +export function SortableTokenGrid({ + columns, + items, + size, + scrollContentOffsetY, + scrollViewRef, + onDragStart, + onDragEnd, +}: Props) { + const positions = useSharedValue( + Object.assign({}, ...items.map((item, index) => ({ [item.id]: index }))) + ); + + const animatedId = useSharedValue(null); + + useEffect(() => { + positions.value = Object.assign({}, ...items.map((item, index) => ({ [item.id]: index }))); + }, [items, positions]); + + const containerHeight = useMemo(() => { + const rows = Math.ceil(items.length / columns); + return rows * (size + GAP) - GAP - rows * GAP; // Subtract GAP to remove the extra gap after the first and last row + }, [items.length, columns, size]); + + const style = useMemo(() => { + return { + height: containerHeight, + }; + }, [containerHeight]); + + return ( + + {items.map((item) => ( + + + {item.kind === 'whitespace' ? ( + + ) : ( + + + + )} + + + ))} + + ); +} + +type WhiteSpaceProps = { + size: number; + style?: ViewProps['style']; +}; + +function WhiteSpace({ size, style }: WhiteSpaceProps) { + return ; +} diff --git a/apps/mobile/src/components/GalleryEditor/SortableTokenGrid/utils.ts b/apps/mobile/src/components/GalleryEditor/SortableTokenGrid/utils.ts new file mode 100644 index 000000000..3617a89a9 --- /dev/null +++ b/apps/mobile/src/components/GalleryEditor/SortableTokenGrid/utils.ts @@ -0,0 +1,21 @@ +export type Positions = { + [id: string]: number; +}; + +export const getPosition = (order: number, columns: number, size: number) => { + 'worklet'; + + return { + x: (order % columns) * size, + y: Math.floor(order / columns) * size, + }; +}; + +export const getOrder = (x: number, y: number, columns: number, size: number) => { + 'worklet'; + + const col = Math.round(x / size); + const row = Math.round(y / size); + + return row * columns + col; +}; diff --git a/apps/mobile/src/contexts/GalleryEditor/GalleryEditorContext.tsx b/apps/mobile/src/contexts/GalleryEditor/GalleryEditorContext.tsx index 33b185eb2..4557b5b5b 100644 --- a/apps/mobile/src/contexts/GalleryEditor/GalleryEditorContext.tsx +++ b/apps/mobile/src/contexts/GalleryEditor/GalleryEditorContext.tsx @@ -1,5 +1,6 @@ import { createContext, SetStateAction, useCallback, useContext, useMemo, useState } from 'react'; import { graphql, useFragment } from 'react-relay'; +import rfdc from 'rfdc'; import { useTrack } from 'shared/contexts/AnalyticsContext'; import { useReportError } from 'shared/contexts/ErrorReportingContext'; import { ErrorWithSentryMetadata } from 'shared/errors/ErrorWithSentryMetadata'; @@ -19,6 +20,8 @@ import { getInitialCollectionsFromServer } from './getInitialCollectionsFromServ import { StagedItem, StagedRow, StagedSection, StagedSectionList } from './types'; import { arrayMove } from './util'; +const deepClone = rfdc(); + type GalleryEditorActions = { galleryId: string; galleryName: string; @@ -39,6 +42,8 @@ type GalleryEditorActions = { clearActiveRow: () => void; moveRow: (sectionId: string, newOrderByIndex: string[]) => void; + moveItem: (rowId: string, newOrderByIds: string[]) => void; + incrementColumns: (rowId: string) => void; decrementColumns: (rowId: string) => void; @@ -313,6 +318,22 @@ const GalleryEditorProvider = ({ children, queryRef }: Props) => { [updateSection] ); + // TODO: Add support for moving items between rows + const moveItem = useCallback( + (rowId: string, newOrderByIds: string[]) => { + updateRow(rowId, (previousRow) => { + const clonedItems = deepClone(previousRow.items); + + const newItems = newOrderByIds + .map((id) => clonedItems.find((item) => item.id === id)) + .filter((item): item is StagedItem => item !== undefined); + + return { ...previousRow, items: newItems }; + }); + }, + [updateRow] + ); + const toggleTokensStaged = useCallback( (tokenIds: string[]) => { if (!activeRowId) { @@ -522,6 +543,7 @@ const GalleryEditorProvider = ({ children, queryRef }: Props) => { clearActiveRow, moveRow, + moveItem, toggleTokensStaged, saveGallery, @@ -550,6 +572,8 @@ const GalleryEditorProvider = ({ children, queryRef }: Props) => { clearActiveRow, moveRow, + moveItem, + toggleTokensStaged, saveGallery, ] diff --git a/yarn.lock b/yarn.lock index 75316b625..b6e7bcd93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26284,6 +26284,7 @@ __metadata: react-native-url-polyfill: ^1.3.0 react-native-webview: 13.6.4 relay-compiler: ^14.1.0 + rfdc: ^1.3.1 sentry-expo: ~7.2.0 siwe: ^2.1.4 swr: ^2.1.1 @@ -30464,6 +30465,13 @@ __metadata: languageName: node linkType: hard +"rfdc@npm:^1.3.1": + version: 1.3.1 + resolution: "rfdc@npm:1.3.1" + checksum: d5d1e930aeac7e0e0a485f97db1356e388bdbeff34906d206fe524dd5ada76e95f186944d2e68307183fdc39a54928d4426bbb6734851692cfe9195efba58b79 + languageName: node + linkType: hard + "rimraf@npm:^2.6.2": version: 2.7.1 resolution: "rimraf@npm:2.7.1"