diff --git a/apps/phone/package.json b/apps/phone/package.json index 6b43c25c0..b430007d3 100644 --- a/apps/phone/package.json +++ b/apps/phone/package.json @@ -7,6 +7,9 @@ "homepage": "/dist/html", "dependencies": { "@babel/runtime": "^7.17.2", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/css": "^11.10.5", "@emotion/react": "^11.7.0", "@emotion/styled": "^11.6.0", diff --git a/apps/phone/src/apps/home/Sortable.tsx b/apps/phone/src/apps/home/Sortable.tsx new file mode 100644 index 000000000..7d01be42d --- /dev/null +++ b/apps/phone/src/apps/home/Sortable.tsx @@ -0,0 +1,282 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { + Active, + Announcements, + closestCenter, + CollisionDetection, + DragOverlay, + DndContext, + DropAnimation, + KeyboardSensor, + KeyboardCoordinateGetter, + Modifiers, + MouseSensor, + MeasuringConfiguration, + PointerActivationConstraint, + ScreenReaderInstructions, + TouchSensor, + UniqueIdentifier, + useSensor, + useSensors, + defaultDropAnimationSideEffects, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + SortingStrategy, + rectSortingStrategy, + AnimateLayoutChanges, + NewIndexGetter, + useSortable, +} from '@dnd-kit/sortable'; + +import { useApps } from '@os/apps/hooks/useApps'; +import { IApp } from '@os/apps/config/apps'; +import { CSS } from '@dnd-kit/utilities'; +import { AppIcon } from '@ui/components'; + +const defaultInitializer = (index: number) => index; + +export function createRange( + length: number, + initializer: (index: number) => any = defaultInitializer, +): T[] { + return [...new Array(length)].map((_, index) => initializer(index)); +} + +export interface Props { + activationConstraint?: PointerActivationConstraint; + animateLayoutChanges?: AnimateLayoutChanges; + adjustScale?: boolean; + collisionDetection?: CollisionDetection; + coordinateGetter?: KeyboardCoordinateGetter; + Container?: any; // To-do: Fix me + dropAnimation?: DropAnimation | null; + getNewIndex?: NewIndexGetter; + handle?: boolean; + itemCount?: number; + items?: UniqueIdentifier[]; + measuring?: MeasuringConfiguration; + modifiers?: Modifiers; + renderItem?: any; + removable?: boolean; + reorderItems?: typeof arrayMove; + strategy?: SortingStrategy; + style?: React.CSSProperties; + useDragOverlay?: boolean; + + getItemStyles?(args: { + id: UniqueIdentifier; + index: number; + isSorting: boolean; + isDragOverlay: boolean; + overIndex: number; + isDragging: boolean; + }): React.CSSProperties; + + wrapperStyle?(args: { + active: Pick | null; + index: number; + isDragging: boolean; + id: UniqueIdentifier; + }): React.CSSProperties; + + isDisabled?(id: UniqueIdentifier): boolean; +} + +const dropAnimationConfig: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: '0.5', + }, + }, + }), +}; + +const screenReaderInstructions: ScreenReaderInstructions = { + draggable: ` + To pick up a sortable item, press the space bar. + While sorting, use the arrow keys to move the item. + Press space again to drop the item in its new position, or press escape to cancel. + `, +}; + +export function SortableApps({ + activationConstraint, + collisionDetection = closestCenter, + coordinateGetter = sortableKeyboardCoordinates, + itemCount = 16, + items: initialItems, + measuring, + modifiers, + removable, + reorderItems = arrayMove, + strategy = rectSortingStrategy, +}: Props) { + const [items, setItems] = useState( + () => initialItems ?? createRange(itemCount, (index) => index + 1), + ); + + const { apps, setApps } = useApps(); + + const [activeId, setActiveId] = useState(null); + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint, + }), + useSensor(TouchSensor, { + activationConstraint, + }), + useSensor(KeyboardSensor, { + // Disable smooth scrolling in Cypress automated tests + scrollBehavior: 'Cypress' in window ? 'auto' : undefined, + coordinateGetter, + }), + ); + const isFirstAnnouncement = useRef(true); + const getIndex = (id: UniqueIdentifier) => items.indexOf(id); + const getPosition = (id: UniqueIdentifier) => getIndex(id) + 1; + const activeIndex = activeId ? getIndex(activeId) : -1; + const handleRemove = removable + ? (id: UniqueIdentifier) => setItems((items) => items.filter((item) => item !== id)) + : undefined; + const announcements: Announcements = { + onDragStart({ active: { id } }) { + return `Picked up sortable item ${String( + id, + )}. Sortable item ${id} is in position ${getPosition(id)} of ${items.length}`; + }, + onDragOver({ active, over }) { + // In this specific use-case, the picked up item's `id` is always the same as the first `over` id. + // The first `onDragOver` event therefore doesn't need to be announced, because it is called + // immediately after the `onDragStart` announcement and is redundant. + if (isFirstAnnouncement.current === true) { + isFirstAnnouncement.current = false; + return; + } + + if (over) { + return `Sortable item ${active.id} was moved into position ${getPosition(over.id)} of ${ + items.length + }`; + } + + return; + }, + onDragEnd({ active, over }) { + if (over) { + return `Sortable item ${active.id} was dropped at position ${getPosition(over.id)} of ${ + items.length + }`; + } + + return; + }, + onDragCancel({ active: { id } }) { + return `Sorting was cancelled. Sortable item ${id} was dropped and returned to position ${getPosition( + id, + )} of ${items.length}.`; + }, + }; + + useEffect(() => { + if (!activeId) { + isFirstAnnouncement.current = true; + } + }, [activeId]); + + return ( + { + if (!active) { + return; + } + + setActiveId(active.id); + }} + onDragEnd={({ over }) => { + setActiveId(null); + + if (over) { + const overIndex = getIndex(over.id); + if (activeIndex !== overIndex) { + setItems((items) => reorderItems(items, activeIndex, overIndex)); + } + } + }} + onDragCancel={() => setActiveId(null)} + measuring={measuring} + modifiers={modifiers} + > +
+ +
+ {apps.map((value, index) => ( + <> + + + ))} +
+
+
+
+ ); +} + +const SortableAppItem = ({ + app: IApp, + disabled, + animateLayoutChanges, + getNewIndex, + handle, + id, + index, + onRemove, + style, + renderItem, + useDragOverlay, + wrapperStyle, +}) => { + /*const { transition, transform, attributes, setNodeRef, listeners } = useSortable({ + id: app.id, + });*/ + + const { + active, + attributes, + isDragging, + isSorting, + listeners, + overIndex, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + } = useSortable({ + id, + animateLayoutChanges, + disabled, + getNewIndex, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ +
+ ); +}; diff --git a/apps/phone/src/apps/home/components/Home.tsx b/apps/phone/src/apps/home/components/Home.tsx index 40d749e9b..d0031545d 100644 --- a/apps/phone/src/apps/home/components/Home.tsx +++ b/apps/phone/src/apps/home/components/Home.tsx @@ -1,33 +1,117 @@ import React from 'react'; -import { AppWrapper } from '@ui/components'; -import { Box } from '@mui/material'; -import { GridMenu } from '@ui/components/GridMenu'; +import { AppIcon, AppWrapper } from '@ui/components'; import { useApps } from '@os/apps/hooks/useApps'; import { useExternalApps } from '@common/hooks/useExternalApps'; +import { Link } from 'react-router-dom'; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, + rectSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, + useSortable, +} from '@dnd-kit/sortable'; +import { IApp } from '@os/apps/config/apps'; +import { CSS } from '@dnd-kit/utilities'; export const HomeApp: React.FC = () => { - const { apps } = useApps(); + const { apps, setApps } = useApps(); + const [activeId, setActiveId] = React.useState(null); const externalApps = useExternalApps(); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + function handleDragEnd(event) { + const { active, over } = event; + + if (activeId !== over.id) { + setApps((items) => { + const oldIndex = items.findIndex((item) => item.id === active.id); + const newIndex = items.findIndex((item) => item.id === over.id); + return arrayMove(items, oldIndex, newIndex); + }); + } + } + return ( - - {apps && } - - - {/*
-
- {apps && - apps.slice(0, 4).map((app) => ( -
-
-
- {app.icon} +
+ { + if (!active) { + return; + } + + setActiveId(active.id); + }} + onDragCancel={() => setActiveId(null)} + > +
+

12.43

+
+ + +
+ {apps && + [...apps, ...externalApps].map((app) => )} +
+
+ + {/*
+
+ {apps && + apps.slice(0, 4).map((app) => ( +
+
+ + + +
-
-
- ))} -
-
*/} + ))} +
+
*/} + +
); }; + +const SortableAppItem = (app: IApp) => { + const { transition, transform, attributes, setNodeRef, listeners } = useSortable({ + id: app.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ + + +
+ ); +}; diff --git a/apps/phone/src/os/apps/hooks/useApps.tsx b/apps/phone/src/os/apps/hooks/useApps.tsx index 8def3ed47..3156717a0 100644 --- a/apps/phone/src/os/apps/hooks/useApps.tsx +++ b/apps/phone/src/os/apps/hooks/useApps.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useNotifications } from '@os/notifications/hooks/useNotifications'; import { createLazyAppIcon } from '../utils/createLazyAppIcon'; import { APPS, IApp } from '../config/apps'; @@ -17,7 +17,7 @@ export const useApps = () => { const externalApps = useRecoilValue(phoneState.extApps); const { ResourceConfig } = usePhone(); - const apps: IApp[] = useMemo(() => { + const defaultApps: IApp[] = useMemo(() => { return APPS.map((app) => { const SvgIcon = React.lazy(() => import(`../icons/${curIconSet.name}/svg/${app.id}.tsx`).catch( @@ -58,6 +58,8 @@ export const useApps = () => { }); }, [icons, curIconSet, theme]); + const [apps, setApps] = useState(defaultApps); + const allApps = useMemo(() => [...apps, ...externalApps], [apps, externalApps]); const getApp = useCallback( (id: string): IApp => { @@ -67,7 +69,7 @@ export const useApps = () => { ); const filteredApps = apps.filter((app) => !ResourceConfig?.disabledApps.includes(app.id)); - return { apps: filteredApps, getApp }; + return { apps: filteredApps, getApp, setApps }; }; export const useApp = (id: string): IApp => { diff --git a/apps/phone/src/ui/components/AppIcon.tsx b/apps/phone/src/ui/components/AppIcon.tsx index 4fbd5f91a..8bf02bd3e 100644 --- a/apps/phone/src/ui/components/AppIcon.tsx +++ b/apps/phone/src/ui/components/AppIcon.tsx @@ -11,7 +11,6 @@ const useStyles = makeStyles( root: { padding: 0, background: 'transparent', - marginTop: theme.spacing(3), }, avatar: { '&:hover': { @@ -68,7 +67,7 @@ export const AppIcon: React.FC = ({ }); return ( -