mirror of
https://github.com/ClipFusion-org/clipfusion.git
synced 2025-08-03 15:55:07 +00:00
implemented swiping to delete projects (like on ios)
This commit is contained in:
parent
f05545b570
commit
f04761345e
@ -36,6 +36,7 @@ import Link from "next/link";
|
||||
import StickyTopContainer from "@/components/sticky-top-container";
|
||||
import { useRouter } from "next/navigation";
|
||||
import truncate from "@/lib/truncate";
|
||||
import SwipeToDelete, { useSwipeToDeleteContext } from "@/components/swipe-to-delete";
|
||||
|
||||
type SortingType = "byCreationDate"
|
||||
| "byEditDate"
|
||||
@ -274,9 +275,9 @@ const ProjectDropdown = ({
|
||||
{selected ? <Grid2X2XIcon /> : <Grid2X2CheckIcon />}{selected ? "Deselect" : "Select"}
|
||||
</Button>
|
||||
</SheetClose>
|
||||
<Button variant="ghost" className="justify-start w-full" onClick={handleEdit}>
|
||||
<EditIcon /> Edit
|
||||
</Button>
|
||||
<Button variant="ghost" className="justify-start w-full" onClick={handleEdit}>
|
||||
<EditIcon /> Edit
|
||||
</Button>
|
||||
<SheetClose asChild>
|
||||
<Button variant="ghost" className="justify-start w-full" onClick={handleDuplicate}>
|
||||
<CopyIcon /> Duplicate
|
||||
@ -402,10 +403,10 @@ const ProjectContainer = ({
|
||||
project: Project
|
||||
}): ReactNode => {
|
||||
const { selecting, selectedProjects, setSelectedProjects } = useSelectContext();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const date = new Date(project.editDate);
|
||||
|
||||
// TODO: data-selectable is a really dirty way of deciding whether to trigger selection or not should be reworked in the future
|
||||
const handleCheck = (e: React.MouseEvent<HTMLDivElement | HTMLAnchorElement>) => {
|
||||
const index = selectedProjects.indexOf(project.uuid);
|
||||
if (index >= 0) {
|
||||
@ -444,11 +445,20 @@ const ProjectContainer = ({
|
||||
</AspectRatio>
|
||||
);
|
||||
|
||||
return projectComponent;
|
||||
return isMobile ? (
|
||||
<div className="h-full w-[100% + 5 * var(--spacing)] -mx-5 origin-center">
|
||||
<SwipeToDelete onDelete={() => deleteProject(project.uuid)} transitionDuration={200} height={210.38} deleteThreshold={45}>
|
||||
<div className="w-screen bg-background px-5">
|
||||
{projectComponent}
|
||||
</div>
|
||||
</SwipeToDelete>
|
||||
</div>
|
||||
) : projectComponent;
|
||||
};
|
||||
|
||||
export default function Home(): ReactNode {
|
||||
const isMobile = useIsMobile();
|
||||
const { deleting } = useSwipeToDeleteContext();
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebounce(search, 300);
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
@ -502,7 +512,7 @@ export default function Home(): ReactNode {
|
||||
|
||||
return (
|
||||
<SelectContext.Provider value={context}>
|
||||
<div className="flex flex-col justify-between h-screen">
|
||||
<div className="flex flex-col justify-between h-screen overscroll-x-none overflow-x-hidden" style={{ touchAction: deleting ? "none" : "pan-y pinch-zoom" }}>
|
||||
<div className="p-5">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<StaticSidebarTrigger />
|
||||
|
@ -6,6 +6,7 @@ import ThemeProvider from "./theme-provider";
|
||||
import Analytics from "./analytics";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import PWAHead from "./pwa-head";
|
||||
import { SwipeToDeleteContextProvider } from "@/components/swipe-to-delete";
|
||||
|
||||
const geist = Geist({
|
||||
variable: "--font-geist",
|
||||
@ -43,7 +44,9 @@ export default async function RootLayout({
|
||||
</head>
|
||||
<body className={`${geist.variable} ${geistMono.variable} antialiased`}>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<SwipeToDeleteContextProvider>
|
||||
{children}
|
||||
</SwipeToDeleteContextProvider>
|
||||
<Toaster/>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
218
src/components/swipe-to-delete/SwipeToDelete.tsx
Normal file
218
src/components/swipe-to-delete/SwipeToDelete.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
import React, { createContext, Dispatch, ReactNode, SetStateAction, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import "./styles.css";
|
||||
|
||||
export interface Props {
|
||||
onDelete: Function;
|
||||
onDeleteConfirm?: Function;
|
||||
deleteComponent?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
height?: number;
|
||||
transitionDuration?: number;
|
||||
deleteWidth?: number;
|
||||
deleteThreshold?: number;
|
||||
showDeleteAction?: boolean;
|
||||
deleteColor?: string;
|
||||
deleteText?: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
rtl?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const cursorPosition = (event: any) => {
|
||||
if (event?.touches?.[0]?.clientX) return event.touches[0].clientX;
|
||||
if (event?.clientX) return event?.clientX;
|
||||
if (event?.nativeEvent?.touches?.[0]?.clientX) return event.nativeEvent.touches[0].clientX;
|
||||
return event?.nativeEvent?.clientX;
|
||||
};
|
||||
|
||||
interface SwipeToDeleteContextProps {
|
||||
deleting: boolean;
|
||||
setDeleting: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const SwipeToDeleteContext = createContext<SwipeToDeleteContextProps | undefined>(undefined);
|
||||
|
||||
export const useSwipeToDeleteContext = (): SwipeToDeleteContextProps => {
|
||||
const data = useContext(SwipeToDeleteContext);
|
||||
if (!data) throw new Error("SwipeToDeleteContext is not defined");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const SwipeToDeleteContextProvider = ({
|
||||
children
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
return (
|
||||
<SwipeToDeleteContext.Provider value={{deleting: deleting, setDeleting: setDeleting}}>
|
||||
{children}
|
||||
</SwipeToDeleteContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const SwipeToDelete = ({
|
||||
onDelete,
|
||||
onDeleteConfirm,
|
||||
deleteComponent,
|
||||
disabled = false,
|
||||
height = 50,
|
||||
transitionDuration = 250,
|
||||
deleteWidth = 75,
|
||||
deleteThreshold = 75,
|
||||
showDeleteAction = true,
|
||||
deleteColor = "rgba(252, 58, 48, 1.00)",
|
||||
deleteText = "Delete",
|
||||
className = "",
|
||||
id = "",
|
||||
rtl = false,
|
||||
children,
|
||||
}: Props) => {
|
||||
const { deleting, setDeleting } = useSwipeToDeleteContext();
|
||||
const [touching, setTouching] = useState(false);
|
||||
const [translate, setTranslate] = useState(0);
|
||||
const [internalDeleting, setInternalDeleting] = useState(false);
|
||||
|
||||
const startTouchPosition = useRef(0);
|
||||
const initTranslate = useRef(0);
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const containerWidth: number = container.current?.getBoundingClientRect().width || 0;
|
||||
const deleteWithoutConfirmThreshold: number = containerWidth * (deleteThreshold / 100);
|
||||
|
||||
const onStart = useCallback(
|
||||
(event: React.TouchEvent | React.MouseEvent) => {
|
||||
if (disabled) return;
|
||||
if (touching) return;
|
||||
startTouchPosition.current = cursorPosition(event);
|
||||
initTranslate.current = translate;
|
||||
setTouching(true);
|
||||
if (!deleting) setDeleting(true);
|
||||
},
|
||||
[disabled, touching, translate, deleting, setDeleting]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = container.current;
|
||||
root?.style.setProperty("--rstdiHeight", height + "px");
|
||||
root?.style.setProperty("--rstdiTransitionDuration", transitionDuration + "ms");
|
||||
root?.style.setProperty("--rstdiIsRtl", rtl ? "1" : "-1");
|
||||
root?.style.setProperty("--rstdiDeleteColor", deleteColor);
|
||||
root?.style.setProperty("--rstdiDeleteWidth", deleteWidth + "px");
|
||||
}, [deleteColor, deleteWidth, height, rtl, transitionDuration, deleting]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = container.current;
|
||||
root?.style.setProperty("--rstdiTranslate", translate * (rtl ? -1 : 1) + "px");
|
||||
const shiftDelete = -translate >= deleteWithoutConfirmThreshold;
|
||||
root?.style.setProperty(
|
||||
`--rstdiButtonMargin${rtl ? "Right" : "Left"}`,
|
||||
(shiftDelete ? containerWidth + translate : containerWidth - deleteWidth) + "px"
|
||||
);
|
||||
}, [translate, deleteWidth, containerWidth, rtl, deleteWithoutConfirmThreshold]);
|
||||
|
||||
const onMove = useCallback(
|
||||
function (event: TouchEvent | MouseEvent) {
|
||||
if (!touching) return;
|
||||
if (!rtl && cursorPosition(event) > startTouchPosition.current - initTranslate.current)
|
||||
return setTranslate(0);
|
||||
if (rtl && cursorPosition(event) < startTouchPosition.current - initTranslate.current)
|
||||
return setTranslate(0);
|
||||
setTranslate(cursorPosition(event) - startTouchPosition.current + initTranslate.current);
|
||||
if (!deleting) setDeleting(true);
|
||||
},
|
||||
[rtl, touching, deleting, setDeleting]
|
||||
);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
function (event: MouseEvent): any {
|
||||
onMove(event);
|
||||
},
|
||||
[onMove]
|
||||
);
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
function (event: TouchEvent): any {
|
||||
onMove(event);
|
||||
},
|
||||
[onMove]
|
||||
);
|
||||
|
||||
const onDeleteConfirmed = useCallback(() => {
|
||||
setInternalDeleting(() => true);
|
||||
window.setTimeout(onDelete, transitionDuration);
|
||||
}, [onDelete, transitionDuration]);
|
||||
|
||||
const onDeleteCancel = useCallback(() => {
|
||||
setTouching(() => false);
|
||||
setTranslate(() => 0);
|
||||
setInternalDeleting(() => false);
|
||||
startTouchPosition.current = 0;
|
||||
initTranslate.current = 0;
|
||||
}, [onDelete, transitionDuration]);
|
||||
|
||||
const onDeleteClick = useCallback(() => {
|
||||
if (onDeleteConfirm) {
|
||||
onDeleteConfirm(onDeleteConfirmed, onDeleteCancel);
|
||||
} else {
|
||||
onDeleteConfirmed();
|
||||
}
|
||||
}, [onDeleteConfirm, onDeleteConfirmed]);
|
||||
|
||||
const onMouseUp = useCallback(
|
||||
function () {
|
||||
startTouchPosition.current = 0;
|
||||
const acceptableMove = -deleteWidth * 0.7;
|
||||
const showDelete = showDeleteAction ? (rtl ? -1 : 1) * translate < acceptableMove : false;
|
||||
const notShowDelete = showDeleteAction ? (rtl ? -1 : 1) * translate >= acceptableMove : true;
|
||||
const deleteWithoutConfirm = (rtl ? 1 : -1) * translate >= deleteWithoutConfirmThreshold;
|
||||
if (deleteWithoutConfirm) {
|
||||
setTranslate(() => -containerWidth);
|
||||
} else if (notShowDelete) {
|
||||
setTranslate(() => 0);
|
||||
} else if (showDelete && !deleteWithoutConfirm) {
|
||||
setTranslate(() => (rtl ? 1 : -1) * deleteWidth);
|
||||
}
|
||||
setTouching(() => false);
|
||||
if (deleting) setDeleting(false);
|
||||
if (deleteWithoutConfirm) onDeleteClick();
|
||||
},
|
||||
[containerWidth, deleteWidth, deleteWithoutConfirmThreshold, onDeleteClick, rtl, translate, deleting, setDeleting]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (touching) {
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("touchmove", onTouchMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
window.addEventListener("touchend", onMouseUp);
|
||||
} else {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("touchmove", onTouchMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
window.removeEventListener("touchend", onMouseUp);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("touchmove", onTouchMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
window.removeEventListener("touchend", onMouseUp);
|
||||
};
|
||||
}, [onMouseMove, onMouseUp, onTouchMove, touching]);
|
||||
|
||||
return (
|
||||
<div id={id} className={`rstdi${internalDeleting ? " deleting" : ""} ${className}`} ref={container}>
|
||||
<div className={`delete${internalDeleting ? " deleting" : ""}`}>
|
||||
<button onClick={onDeleteClick}>{deleteComponent ? deleteComponent : deleteText}</button>
|
||||
</div>
|
||||
<div
|
||||
className={`content${internalDeleting ? " deleting" : ""}${!touching ? " transition" : ""}`}
|
||||
onMouseDown={onStart}
|
||||
onTouchStart={onStart}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
7
src/components/swipe-to-delete/index.ts
Normal file
7
src/components/swipe-to-delete/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { SwipeToDelete, SwipeToDeleteContextProvider, useSwipeToDeleteContext } from "./SwipeToDelete";
|
||||
|
||||
export default SwipeToDelete;
|
||||
export {
|
||||
SwipeToDeleteContextProvider,
|
||||
useSwipeToDeleteContext
|
||||
};
|
77
src/components/swipe-to-delete/styles.css
Normal file
77
src/components/swipe-to-delete/styles.css
Normal file
@ -0,0 +1,77 @@
|
||||
/* rstdi = react-swipe-to-delete-ios */
|
||||
|
||||
.rstdi {
|
||||
--rstdiHeight: 30px;
|
||||
--rstdiTransitionDuration: 250ms;
|
||||
--rstdiTranslate: 0px;
|
||||
--rstdiIsRtl: 0;
|
||||
--rstdiDeleteColor: rgba(252, 58, 48, 1);
|
||||
--rstdiDeleteWidth: 75px;
|
||||
--rstdiButtonMarginRight: 0px;
|
||||
--rstdiButtonMarginLeft: 0px;
|
||||
|
||||
width: auto;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.rstdi *,
|
||||
.rstdi *:before,
|
||||
.rstdi *:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rstdi.deleting {
|
||||
transition: all var(--rstdiTransitionDuration) ease-out;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.rstdi .content {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
position: relative;
|
||||
transform: translateX(var(--rstdiTranslate));
|
||||
}
|
||||
|
||||
.rstdi .content.transition {
|
||||
transition: all var(--rstdiTransitionDuration) ease-out;
|
||||
}
|
||||
|
||||
.rstdi .content.deleting {
|
||||
height: 0%;
|
||||
width: auto;
|
||||
position: relative;
|
||||
transform: translateX(-100%);
|
||||
transition: all var(--rstdiTransitionDuration) ease-out;
|
||||
}
|
||||
|
||||
.rstdi .delete {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-top: 1px;
|
||||
height: calc(100% - 3px);
|
||||
width: 100%;
|
||||
top: 0;
|
||||
background: var(--rstdiDeleteColor);
|
||||
font-weight: 400;
|
||||
display: inline-flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rstdi .delete button {
|
||||
width: var(--rstdiDeleteWidth);
|
||||
transition: transform var(--rstdiTransitionDuration) ease-out;
|
||||
transform: translateX(var(--rstdiButtonMarginLeft));
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
Loading…
Reference in New Issue
Block a user