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"