mirror of
https://github.com/ClipFusion-org/clipfusion.git
synced 2025-08-03 14:45:09 +00:00
improved ux of swipe to delete (you can't click on the project for now tho)
This commit is contained in:
parent
c52cd12b3d
commit
2631fb79bb
@ -36,7 +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";
|
||||
import SwipeToDelete from "@/components/swipe-to-delete";
|
||||
|
||||
type SortingType = "byCreationDate"
|
||||
| "byEditDate"
|
||||
@ -450,10 +450,10 @@ const ProjectContainer = ({
|
||||
</AspectRatio>
|
||||
);
|
||||
|
||||
return isMobile ? (
|
||||
<div className=" w-[100% + 5 * var(--spacing)] -mx-5 origin-center overflow-hidden">
|
||||
<SwipeToDelete onDelete={() => deleteProject(project.uuid)} transitionDuration={200} deleteThreshold={50}>
|
||||
<div className="w-screen bg-background px-5 py-2 -mt-[1px] h-[calc(100% + 2px)]">
|
||||
return isMobile && !selecting ? (
|
||||
<div className=" w-[100% + 5 * var(--spacing)] -mx-5 overflow-hidden">
|
||||
<SwipeToDelete onDelete={() => deleteProject(project.uuid)}>
|
||||
<div className="w-screen bg-background px-5 py-2">
|
||||
{projectComponent}
|
||||
</div>
|
||||
</SwipeToDelete>
|
||||
@ -463,7 +463,6 @@ const ProjectContainer = ({
|
||||
|
||||
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);
|
||||
|
@ -6,7 +6,6 @@ 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",
|
||||
@ -44,9 +43,7 @@ export default async function RootLayout({
|
||||
</head>
|
||||
<body className={`${geist.variable} ${geistMono.variable} antialiased`}>
|
||||
<ThemeProvider>
|
||||
<SwipeToDeleteContextProvider>
|
||||
{children}
|
||||
</SwipeToDeleteContextProvider>
|
||||
{children}
|
||||
<Toaster/>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
@ -1,226 +1,192 @@
|
||||
'use client';
|
||||
import React, { createContext, Dispatch, ReactNode, SetStateAction, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import "./styles.css";
|
||||
'use client'
|
||||
import { useRef, useState, useEffect, FC, ReactNode } from 'react'
|
||||
|
||||
export interface Props {
|
||||
type SwipeToDeleteProps = {
|
||||
children: ReactNode;
|
||||
onDelete: () => void;
|
||||
onDeleteConfirm?: (onSuccess: () => void, onCancel: () => void) => void;
|
||||
deleteComponent?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
height?: number;
|
||||
transitionDuration?: number;
|
||||
deleteWidth?: number;
|
||||
deleteThreshold?: number;
|
||||
showDeleteAction?: boolean;
|
||||
deleteColor?: string;
|
||||
height?: number ;
|
||||
backgroundClass?: string;
|
||||
deleteText?: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
rtl?: boolean;
|
||||
children?: React.ReactNode;
|
||||
fadeOnDeletion?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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,
|
||||
const SwipeToDelete: FC<SwipeToDeleteProps> = ({
|
||||
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);
|
||||
onDelete,
|
||||
height = 60,
|
||||
backgroundClass = 'oklch(63.7% 0.237 25.331)',
|
||||
deleteText = 'Delete',
|
||||
fadeOnDeletion = true
|
||||
}) => {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const containerWidth: number = container.current?.getBoundingClientRect().width || 0;
|
||||
const deleteWithoutConfirmThreshold: number = containerWidth * (deleteThreshold / 100);
|
||||
const content = useRef<HTMLDivElement>(null);
|
||||
const text = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const onStart = useCallback(
|
||||
(event: React.TouchEvent | React.MouseEvent) => {
|
||||
if (disabled) return;
|
||||
if (touching) return;
|
||||
startTouchPosition.current = cursorPosition(event);
|
||||
initTranslate.current = translate;
|
||||
setTouching(true);
|
||||
setDeleting(true);
|
||||
},
|
||||
[disabled, touching, translate, deleting, setDeleting]
|
||||
);
|
||||
// drag state
|
||||
const [dragX, setDragX] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [startX, setStartX] = useState(0);
|
||||
const [velocity, setVelocity] = useState(0);
|
||||
const lastTimeRef = useRef<number>(0);
|
||||
const lastXRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
// setDeleting(touching);
|
||||
}, [touching]);
|
||||
// collapse state
|
||||
const [isCollapsing, setIsCollapsing] = useState(false);
|
||||
|
||||
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]);
|
||||
// measure width and thresholds
|
||||
const width = container.current?.offsetWidth ?? window.innerWidth;
|
||||
const threshold = width / 2;
|
||||
const rubberMax = width * 0.7;
|
||||
|
||||
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]);
|
||||
// rubber-band effect
|
||||
const rubber = (delta: number, customRubberMax?: number) => {
|
||||
const max = customRubberMax ?? rubberMax;
|
||||
const sign = delta < 0 ? -1 : 1;
|
||||
const abs = Math.abs(delta);
|
||||
if (abs <= max) return delta;
|
||||
return sign * (max + Math.sqrt(abs - max));
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
[rtl, touching]
|
||||
);
|
||||
// do we show the sticky delete inside content?
|
||||
const isSticky = dragX < -rubberMax;
|
||||
|
||||
// pointer start
|
||||
const handleStart = (pageX: number) => {
|
||||
if (isCollapsing) return;
|
||||
setDragging(true);
|
||||
setStartX(pageX - dragX);
|
||||
lastTimeRef.current = performance.now();
|
||||
lastXRef.current = pageX;
|
||||
content.current?.classList.remove('ios-ease');
|
||||
};
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function (event: MouseEvent): any {
|
||||
onMove(event);
|
||||
},
|
||||
[onMove]
|
||||
);
|
||||
// pointer move
|
||||
const handleMove = (pageX: number) => {
|
||||
if (!dragging) return;
|
||||
const now = performance.now();
|
||||
const dt = now - lastTimeRef.current;
|
||||
const dx = pageX - lastXRef.current;
|
||||
setVelocity(dx / dt * 1000);
|
||||
lastTimeRef.current = now;
|
||||
lastXRef.current = pageX;
|
||||
|
||||
const raw = pageX - startX;
|
||||
const x = dragX < 0 ? rubber(raw) : rubber(raw, width * 0.1);
|
||||
setDragX(x);
|
||||
};
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function (event: TouchEvent): any {
|
||||
onMove(event);
|
||||
},
|
||||
[onMove]
|
||||
);
|
||||
const handleDelete = () => {
|
||||
// slide away
|
||||
content.current?.classList.add('ios-ease');
|
||||
setDragX(-width);
|
||||
|
||||
const onDeleteConfirmed = useCallback(() => {
|
||||
setInternalDeleting(() => true);
|
||||
window.setTimeout(onDelete, transitionDuration);
|
||||
}, [onDelete, transitionDuration]);
|
||||
// collapse after a slight delay (via CSS)
|
||||
setIsCollapsing(true);
|
||||
setTimeout(onDelete, 300); // matches the CSS timings below
|
||||
};
|
||||
|
||||
const onDeleteCancel = useCallback(() => {
|
||||
setTouching(() => false);
|
||||
setTranslate(() => 0);
|
||||
setInternalDeleting(() => false);
|
||||
startTouchPosition.current = 0;
|
||||
initTranslate.current = 0;
|
||||
}, [onDelete, transitionDuration]);
|
||||
// pointer end
|
||||
const handleEnd = () => {
|
||||
if (!dragging) return;
|
||||
setDragging(false);
|
||||
|
||||
const onDeleteClick = useCallback(() => {
|
||||
if (onDeleteConfirm) {
|
||||
onDeleteConfirm(onDeleteConfirmed, onDeleteCancel);
|
||||
} else {
|
||||
onDeleteConfirmed();
|
||||
const shouldDelete =
|
||||
Math.abs(dragX) > threshold ||
|
||||
velocity < -1000;
|
||||
if (!shouldDelete) {
|
||||
content.current?.classList.add('ios-ease');
|
||||
text.current?.classList.add('ios-ease');
|
||||
const textWidth = text.current ? text.current.getBoundingClientRect().width : 0;
|
||||
if (dragX < -50 && text.current) setDragX(-textWidth * 1.5);
|
||||
else setDragX(0);
|
||||
return;
|
||||
}
|
||||
}, [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);
|
||||
setDeleting(false);
|
||||
if (deleteWithoutConfirm) onDeleteClick();
|
||||
},
|
||||
[containerWidth, deleteWidth, deleteWithoutConfirmThreshold, onDeleteClick, rtl, translate, deleting, setDeleting]
|
||||
);
|
||||
handleDelete();
|
||||
}
|
||||
|
||||
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);
|
||||
const node = container.current
|
||||
if (!node) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
node.setPointerCapture(e.pointerId)
|
||||
handleStart(e.pageX)
|
||||
}
|
||||
const onMove = (e: PointerEvent) => handleMove(e.pageX)
|
||||
const onUp = (e: PointerEvent) => {
|
||||
handleEnd()
|
||||
node.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
node.addEventListener('pointerdown', onDown)
|
||||
node.addEventListener('pointermove', onMove)
|
||||
node.addEventListener('pointerup', onUp)
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("touchmove", onTouchMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
window.removeEventListener("touchend", onMouseUp);
|
||||
};
|
||||
}, [onMouseMove, onMouseUp, onTouchMove, touching]);
|
||||
node.removeEventListener('pointerdown', onDown)
|
||||
node.removeEventListener('pointermove', onMove)
|
||||
node.removeEventListener('pointerup', onUp)
|
||||
}
|
||||
}, [dragging, dragX, velocity])
|
||||
|
||||
const deleteTransform = isCollapsing
|
||||
? `translateX(calc(${dragX}px + 5rem))`
|
||||
: (isSticky ? `translateX(calc(${dragX}px + 5rem))` : `translateX(max(0rem, calc(${dragX}px + 5rem)))`);
|
||||
|
||||
const opacityTransparent = fadeOnDeletion ? 0 : 1;
|
||||
|
||||
const background = dragX < 0
|
||||
? isCollapsing ? (opacityTransparent == 0 ? 'transparent' : backgroundClass) : backgroundClass
|
||||
: 'transparent';
|
||||
|
||||
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
|
||||
ref={container}
|
||||
className="relative overflow-hidden select-none"
|
||||
style={{
|
||||
height: isCollapsing ? 0 : height,
|
||||
transition: isCollapsing
|
||||
? 'height 300ms cubic-bezier(0.24, 1.04, 0.56, 1)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{/* Fixed red background + delete text */}
|
||||
<div
|
||||
className={`content${internalDeleting ? " deleting" : ""}${!touching ? " transition" : ""}`}
|
||||
onMouseDown={onStart}
|
||||
onTouchStart={onStart}>
|
||||
className={`absolute inset-0 flex items-center justify-end pr-4`}
|
||||
style={{
|
||||
background: background,
|
||||
transition: dragX > 1 ? '' : 'background 300ms'
|
||||
}}
|
||||
>
|
||||
<button className="text-white font-semibold h-full" style={{
|
||||
transform: deleteTransform,
|
||||
transition: 'transform 300ms cubic-bezier(0.24, 1.04, 0.56, 1), opacity 300ms',
|
||||
opacity: isCollapsing ? opacityTransparent : 1
|
||||
}} onClick={handleDelete} ref={text}>
|
||||
{deleteText}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Swipeable content */}
|
||||
<div
|
||||
ref={content}
|
||||
className="relative ios-ease"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
transform: `translateX(${dragX}px)`,
|
||||
transition: dragging
|
||||
? ''
|
||||
: 'transform 300ms cubic-bezier(0.24, 1.04, 0.56, 1)',
|
||||
touchAction: 'none',
|
||||
pointerEvents: 'none',
|
||||
willChange: 'transform',
|
||||
backfaceVisibility: 'hidden',
|
||||
transformStyle: 'preserve-3d'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export default SwipeToDelete;
|
@ -1,7 +1,3 @@
|
||||
import { SwipeToDelete, SwipeToDeleteContextProvider, useSwipeToDeleteContext } from "./SwipeToDelete";
|
||||
import SwipeToDelete from "./SwipeToDelete";
|
||||
|
||||
export default SwipeToDelete;
|
||||
export {
|
||||
SwipeToDeleteContextProvider,
|
||||
useSwipeToDeleteContext
|
||||
};
|
||||
export default SwipeToDelete;
|
@ -1,78 +1,8 @@
|
||||
/* 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;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overscroll-behavior: none;
|
||||
overflow: hidden;
|
||||
/* iOS-style timing */
|
||||
.ios-ease {
|
||||
transition-timing-function: cubic-bezier(0.24, 1.04, 0.56, 1);
|
||||
}
|
||||
|
||||
.rstdi *,
|
||||
.rstdi *:before,
|
||||
.rstdi *:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rstdi.deleting {
|
||||
transition: all var(--rstdiTransitionDuration) cubic-bezier(0.33, 1, 0.68, 1);
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.rstdi .content {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
position: relative;
|
||||
transform: translateX(var(--rstdiTranslate));
|
||||
}
|
||||
|
||||
.rstdi .content.transition {
|
||||
transition: all var(--rstdiTransitionDuration) cubic-bezier(0.33, 1, 0.68, 1);
|
||||
}
|
||||
|
||||
.rstdi .content.deleting {
|
||||
height: 0%;
|
||||
width: auto;
|
||||
position: relative;
|
||||
transform: translateX(-100%);
|
||||
transition: all var(--rstdiTransitionDuration) cubic-bezier(0.33, 1, 0.68, 1);
|
||||
}
|
||||
|
||||
.rstdi .delete {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-top: 0px;
|
||||
height: 100%;
|
||||
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) cubic-bezier(0.33, 1, 0.68, 1);
|
||||
transform: translateX(var(--rstdiButtonMarginLeft));
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
/* Rubber-band “resistance”: smaller drag as you pull harder */
|
||||
[data-rubber] {
|
||||
touch-action: none;
|
||||
}
|
Loading…
Reference in New Issue
Block a user