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 StickyTopContainer from "@/components/sticky-top-container";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import truncate from "@/lib/truncate";
|
import truncate from "@/lib/truncate";
|
||||||
|
import SwipeToDelete, { useSwipeToDeleteContext } from "@/components/swipe-to-delete";
|
||||||
|
|
||||||
type SortingType = "byCreationDate"
|
type SortingType = "byCreationDate"
|
||||||
| "byEditDate"
|
| "byEditDate"
|
||||||
@ -274,9 +275,9 @@ const ProjectDropdown = ({
|
|||||||
{selected ? <Grid2X2XIcon /> : <Grid2X2CheckIcon />}{selected ? "Deselect" : "Select"}
|
{selected ? <Grid2X2XIcon /> : <Grid2X2CheckIcon />}{selected ? "Deselect" : "Select"}
|
||||||
</Button>
|
</Button>
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
<Button variant="ghost" className="justify-start w-full" onClick={handleEdit}>
|
<Button variant="ghost" className="justify-start w-full" onClick={handleEdit}>
|
||||||
<EditIcon /> Edit
|
<EditIcon /> Edit
|
||||||
</Button>
|
</Button>
|
||||||
<SheetClose asChild>
|
<SheetClose asChild>
|
||||||
<Button variant="ghost" className="justify-start w-full" onClick={handleDuplicate}>
|
<Button variant="ghost" className="justify-start w-full" onClick={handleDuplicate}>
|
||||||
<CopyIcon /> Duplicate
|
<CopyIcon /> Duplicate
|
||||||
@ -402,10 +403,10 @@ const ProjectContainer = ({
|
|||||||
project: Project
|
project: Project
|
||||||
}): ReactNode => {
|
}): ReactNode => {
|
||||||
const { selecting, selectedProjects, setSelectedProjects } = useSelectContext();
|
const { selecting, selectedProjects, setSelectedProjects } = useSelectContext();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const date = new Date(project.editDate);
|
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 handleCheck = (e: React.MouseEvent<HTMLDivElement | HTMLAnchorElement>) => {
|
||||||
const index = selectedProjects.indexOf(project.uuid);
|
const index = selectedProjects.indexOf(project.uuid);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
@ -444,11 +445,20 @@ const ProjectContainer = ({
|
|||||||
</AspectRatio>
|
</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 {
|
export default function Home(): ReactNode {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const { deleting } = useSwipeToDeleteContext();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debouncedSearch] = useDebounce(search, 300);
|
const [debouncedSearch] = useDebounce(search, 300);
|
||||||
const [selecting, setSelecting] = useState(false);
|
const [selecting, setSelecting] = useState(false);
|
||||||
@ -502,7 +512,7 @@ export default function Home(): ReactNode {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectContext.Provider value={context}>
|
<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="p-5">
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<StaticSidebarTrigger />
|
<StaticSidebarTrigger />
|
||||||
@ -596,7 +606,7 @@ export default function Home(): ReactNode {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<Toggle variant="default" pressed={selecting} onPressedChange={(pressed: boolean) => {
|
<Toggle variant="default" pressed={selecting} onPressedChange={(pressed: boolean) => {
|
||||||
setSelecting(pressed);
|
setSelecting(pressed);
|
||||||
setSelectedProjects([]);
|
setSelectedProjects([]);
|
||||||
}}>
|
}}>
|
||||||
|
@ -6,6 +6,7 @@ import ThemeProvider from "./theme-provider";
|
|||||||
import Analytics from "./analytics";
|
import Analytics from "./analytics";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import PWAHead from "./pwa-head";
|
import PWAHead from "./pwa-head";
|
||||||
|
import { SwipeToDeleteContextProvider } from "@/components/swipe-to-delete";
|
||||||
|
|
||||||
const geist = Geist({
|
const geist = Geist({
|
||||||
variable: "--font-geist",
|
variable: "--font-geist",
|
||||||
@ -43,7 +44,9 @@ export default async function RootLayout({
|
|||||||
</head>
|
</head>
|
||||||
<body className={`${geist.variable} ${geistMono.variable} antialiased`}>
|
<body className={`${geist.variable} ${geistMono.variable} antialiased`}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
{children}
|
<SwipeToDeleteContextProvider>
|
||||||
|
{children}
|
||||||
|
</SwipeToDeleteContextProvider>
|
||||||
<Toaster/>
|
<Toaster/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</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