mirror of
https://github.com/ClipFusion-org/clipfusion.git
synced 2025-08-03 18:05:09 +00:00
improved swipe to delete component
This commit is contained in:
parent
2631fb79bb
commit
85a497c830
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, Dispatch, ReactNode, SetStateAction, useContext, useState } from "react";
|
import React, { createContext, Dispatch, ReactNode, SetStateAction, useContext, useRef, useState } from "react";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import { addProject, db, deleteProject } from "@/lib/db";
|
import { addProject, db, deleteProject } from "@/lib/db";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@ -408,6 +408,10 @@ const ProjectContainer = ({
|
|||||||
project: Project
|
project: Project
|
||||||
}): ReactNode => {
|
}): ReactNode => {
|
||||||
const { selecting, selectedProjects, setSelectedProjects } = useSelectContext();
|
const { selecting, selectedProjects, setSelectedProjects } = useSelectContext();
|
||||||
|
const [container, setContainer] = useState<HTMLDivElement | undefined>();
|
||||||
|
const containerRef = (node: HTMLDivElement) => {
|
||||||
|
setContainer(node);
|
||||||
|
};
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const date = new Date(project.editDate);
|
const date = new Date(project.editDate);
|
||||||
@ -451,14 +455,20 @@ const ProjectContainer = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return isMobile && !selecting ? (
|
return isMobile && !selecting ? (
|
||||||
<div className=" w-[100% + 5 * var(--spacing)] -mx-5 overflow-hidden">
|
<div className="w-[100% + 5 * var(--spacing)] -mx-5 overflow-hidden">
|
||||||
<SwipeToDelete onDelete={() => deleteProject(project.uuid)}>
|
<SwipeToDelete height={container ? Math.floor(container.getBoundingClientRect().height) : 210} onDelete={() => deleteProject(project.uuid)}>
|
||||||
<div className="w-screen bg-background px-5 py-2">
|
<div ref={containerRef} className="w-full bg-background px-5 py-2">
|
||||||
{projectComponent}
|
{projectComponent}
|
||||||
</div>
|
</div>
|
||||||
</SwipeToDelete>
|
</SwipeToDelete>
|
||||||
</div>
|
</div>
|
||||||
) : projectComponent;
|
) : (
|
||||||
|
<div className="w-[100% + 5 * var(--spacing)] -mx-5 overflow-hidden">
|
||||||
|
<div ref={containerRef} className="w-full bg-background px-5 py-2">
|
||||||
|
{projectComponent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Home(): ReactNode {
|
export default function Home(): ReactNode {
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useRef, useState, useEffect, FC, ReactNode } from 'react'
|
import React, { useRef, useState, useEffect, FC, ReactNode } from 'react'
|
||||||
|
|
||||||
type SwipeToDeleteProps = {
|
type SwipeToDeleteProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
height?: number ;
|
height?: number | string;
|
||||||
backgroundClass?: string;
|
backgroundClass?: string;
|
||||||
deleteText?: string;
|
deleteText?: string;
|
||||||
fadeOnDeletion?: boolean;
|
fadeOnDeletion?: boolean | `${boolean}`;
|
||||||
|
useBoldDeleteFont?: boolean | `${boolean}`;
|
||||||
|
threshold?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SwipeToDelete: FC<SwipeToDeleteProps> = ({
|
const SwipeToDelete: FC<SwipeToDeleteProps> = ({
|
||||||
@ -16,7 +18,9 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
|
|||||||
height = 60,
|
height = 60,
|
||||||
backgroundClass = 'oklch(63.7% 0.237 25.331)',
|
backgroundClass = 'oklch(63.7% 0.237 25.331)',
|
||||||
deleteText = 'Delete',
|
deleteText = 'Delete',
|
||||||
fadeOnDeletion = true
|
fadeOnDeletion = true,
|
||||||
|
useBoldDeleteFont = true,
|
||||||
|
threshold = 70
|
||||||
}) => {
|
}) => {
|
||||||
const container = useRef<HTMLDivElement>(null);
|
const container = useRef<HTMLDivElement>(null);
|
||||||
const content = useRef<HTMLDivElement>(null);
|
const content = useRef<HTMLDivElement>(null);
|
||||||
@ -27,16 +31,17 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
|
|||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [startX, setStartX] = useState(0);
|
const [startX, setStartX] = useState(0);
|
||||||
const [velocity, setVelocity] = useState(0);
|
const [velocity, setVelocity] = useState(0);
|
||||||
|
const [allowOverscroll, setAllowOverscroll] = useState(false);
|
||||||
|
const [isCollapsing, setIsCollapsing] = useState(false);
|
||||||
|
const [forceTransparentBackground, setForceTransparentBackground] = useState(false);
|
||||||
const lastTimeRef = useRef<number>(0);
|
const lastTimeRef = useRef<number>(0);
|
||||||
const lastXRef = useRef<number>(0);
|
const lastXRef = useRef<number>(0);
|
||||||
|
const lastYRef = useRef<number>(0);
|
||||||
|
|
||||||
// collapse state
|
|
||||||
const [isCollapsing, setIsCollapsing] = useState(false);
|
|
||||||
|
|
||||||
// measure width and thresholds
|
// measure width and thresholds
|
||||||
const width = container.current?.offsetWidth ?? window.innerWidth;
|
const width = container.current?.offsetWidth ?? window.innerWidth;
|
||||||
const threshold = width / 2;
|
const rubberMax = width * threshold / 100;
|
||||||
const rubberMax = width * 0.7;
|
|
||||||
|
|
||||||
// rubber-band effect
|
// rubber-band effect
|
||||||
const rubber = (delta: number, customRubberMax?: number) => {
|
const rubber = (delta: number, customRubberMax?: number) => {
|
||||||
@ -47,10 +52,8 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
|
|||||||
return sign * (max + Math.sqrt(abs - max));
|
return sign * (max + Math.sqrt(abs - max));
|
||||||
}
|
}
|
||||||
|
|
||||||
// do we show the sticky delete inside content?
|
|
||||||
const isSticky = dragX < -rubberMax;
|
const isSticky = dragX < -rubberMax;
|
||||||
|
|
||||||
// pointer start
|
|
||||||
const handleStart = (pageX: number) => {
|
const handleStart = (pageX: number) => {
|
||||||
if (isCollapsing) return;
|
if (isCollapsing) return;
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
@ -60,19 +63,24 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
|
|||||||
content.current?.classList.remove('ios-ease');
|
content.current?.classList.remove('ios-ease');
|
||||||
};
|
};
|
||||||
|
|
||||||
// pointer move
|
const handleMove = (pageX: number, pageY: number) => {
|
||||||
const handleMove = (pageX: number) => {
|
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
const dt = now - lastTimeRef.current;
|
const dt = now - lastTimeRef.current;
|
||||||
const dx = pageX - lastXRef.current;
|
const dx = pageX - lastXRef.current;
|
||||||
|
const dy = pageY - lastYRef.current;
|
||||||
setVelocity(dx / dt * 1000);
|
setVelocity(dx / dt * 1000);
|
||||||
lastTimeRef.current = now;
|
lastTimeRef.current = now;
|
||||||
lastXRef.current = pageX;
|
lastXRef.current = pageX;
|
||||||
|
lastYRef.current = pageY;
|
||||||
|
if (Math.abs(dy) > 5 && dragX == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const raw = pageX - startX;
|
const raw = pageX - startX;
|
||||||
const x = dragX < 0 ? rubber(raw) : rubber(raw, width * 0.1);
|
const x = dragX < 0 ? rubber(raw) : rubber(raw, width * 0.1);
|
||||||
setDragX(x);
|
if (x < 0) setAllowOverscroll(true);
|
||||||
|
if (x <= 0 || (allowOverscroll && x >= 0)) setDragX(x);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
@ -80,25 +88,34 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
|
|||||||
content.current?.classList.add('ios-ease');
|
content.current?.classList.add('ios-ease');
|
||||||
setDragX(-width);
|
setDragX(-width);
|
||||||
|
|
||||||
// collapse after a slight delay (via CSS)
|
|
||||||
setIsCollapsing(true);
|
setIsCollapsing(true);
|
||||||
setTimeout(onDelete, 300); // matches the CSS timings below
|
setTimeout(onDelete, 300); // matches the CSS timings below
|
||||||
};
|
};
|
||||||
|
|
||||||
// pointer end
|
let transparencyTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
const handleEnd = () => {
|
const handleEnd = () => {
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
setDragging(false);
|
setDragging(false);
|
||||||
|
|
||||||
const shouldDelete =
|
const shouldDelete =
|
||||||
Math.abs(dragX) > threshold ||
|
isSticky ||
|
||||||
velocity < -1000;
|
velocity < -1000;
|
||||||
|
setAllowOverscroll(false);
|
||||||
if (!shouldDelete) {
|
if (!shouldDelete) {
|
||||||
content.current?.classList.add('ios-ease');
|
content.current?.classList.add('ios-ease');
|
||||||
text.current?.classList.add('ios-ease');
|
text.current?.classList.add('ios-ease');
|
||||||
const textWidth = text.current ? text.current.getBoundingClientRect().width : 0;
|
const textWidth = text.current ? text.current.getBoundingClientRect().width : 0;
|
||||||
if (dragX < -50 && text.current) setDragX(-textWidth * 1.5);
|
if ((velocity < 0 || dragX < -textWidth * 1.5 && velocity > 0) && text.current) {
|
||||||
else setDragX(0);
|
setDragX(-textWidth * 1.5);
|
||||||
|
} else if (allowOverscroll && dragX > 0) {
|
||||||
|
setDragX(0);
|
||||||
|
if (transparencyTimeout) {
|
||||||
|
clearTimeout(transparencyTimeout);
|
||||||
|
}
|
||||||
|
setForceTransparentBackground(true);
|
||||||
|
transparencyTimeout = setTimeout(() => setForceTransparentBackground(false), 150);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,42 +123,92 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = container.current
|
const node = window;
|
||||||
if (!node) return
|
if (!node || !container.current) return;
|
||||||
const onDown = (e: PointerEvent) => {
|
|
||||||
node.setPointerCapture(e.pointerId)
|
const eventOutsideOfContainer = (e: Event) => {
|
||||||
handleStart(e.pageX)
|
return !(container.current?.contains(e.target as Node));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: TouchEvent) => {
|
||||||
|
if (eventOutsideOfContainer(e)) {
|
||||||
|
setDragX(0);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const onMove = (e: PointerEvent) => handleMove(e.pageX)
|
// e.preventDefault();
|
||||||
const onUp = (e: PointerEvent) => {
|
handleStart(e.touches[0].pageX);
|
||||||
handleEnd()
|
};
|
||||||
node.releasePointerCapture(e.pointerId)
|
|
||||||
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
if (eventOutsideOfContainer(e)) {
|
||||||
|
setDragX(0);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
node.addEventListener('pointerdown', onDown)
|
// e.preventDefault();
|
||||||
node.addEventListener('pointermove', onMove)
|
handleMove(e.touches[0].pageX, e.touches[0].pageY);
|
||||||
node.addEventListener('pointerup', onUp)
|
};
|
||||||
|
|
||||||
|
const handleMouseStart = (e: MouseEvent) => {
|
||||||
|
if (eventOutsideOfContainer(e)) {
|
||||||
|
setDragX(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// e.preventDefault();
|
||||||
|
handleStart(e.pageX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (eventOutsideOfContainer(e)) {
|
||||||
|
setDragX(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// e.preventDefault();
|
||||||
|
handleMove(e.pageX, e.pageY);
|
||||||
|
};
|
||||||
|
|
||||||
|
node.addEventListener("touchstart", handleTouchStart);
|
||||||
|
node.addEventListener("touchmove", handleTouchMove);
|
||||||
|
node.addEventListener("touchend", handleEnd);
|
||||||
|
|
||||||
|
node.addEventListener("mousedown", handleMouseStart);
|
||||||
|
node.addEventListener("mousemove", handleMouseMove);
|
||||||
|
node.addEventListener("mouseup", handleEnd);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
node.removeEventListener('pointerdown', onDown)
|
node.removeEventListener("touchstart", handleTouchStart);
|
||||||
node.removeEventListener('pointermove', onMove)
|
node.removeEventListener("touchmove", handleTouchMove);
|
||||||
node.removeEventListener('pointerup', onUp)
|
node.removeEventListener("touchend", handleEnd);
|
||||||
|
|
||||||
|
node.removeEventListener("mousedown", handleMouseStart);
|
||||||
|
node.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
node.removeEventListener("mouseup", handleEnd);
|
||||||
}
|
}
|
||||||
}, [dragging, dragX, velocity])
|
}, [dragging, dragX, velocity, container]);
|
||||||
|
|
||||||
const deleteTransform = isCollapsing
|
const deleteTransform = isCollapsing
|
||||||
? `translateX(calc(${dragX}px + 5rem))`
|
? `translateX(calc(${dragX}px + 5rem))`
|
||||||
: (isSticky ? `translateX(calc(${dragX}px + 5rem))` : `translateX(max(0rem, calc(${dragX}px + 5rem)))`);
|
: (isSticky ? `translateX(calc(${dragX}px + 5rem))` : `translateX(max(0rem, calc(${dragX}px + 5rem)))`);
|
||||||
|
|
||||||
const opacityTransparent = fadeOnDeletion ? 0 : 1;
|
const opacityTransparent = fadeOnDeletion ? 0 : 1;
|
||||||
|
const backgroundTransparent = opacityTransparent == 0 ? 'transparent' : backgroundClass;
|
||||||
|
|
||||||
const background = dragX < 0
|
let background = backgroundClass;
|
||||||
? isCollapsing ? (opacityTransparent == 0 ? 'transparent' : backgroundClass) : backgroundClass
|
|
||||||
: 'transparent';
|
if (isCollapsing) {
|
||||||
|
background = isCollapsing ? backgroundTransparent : backgroundClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowOverscroll && dragX > 1 || forceTransparentBackground) {
|
||||||
|
background = 'transparent';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={container}
|
ref={container}
|
||||||
className="relative overflow-hidden select-none"
|
|
||||||
style={{
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
userSelect: 'none',
|
||||||
height: isCollapsing ? 0 : height,
|
height: isCollapsing ? 0 : height,
|
||||||
transition: isCollapsing
|
transition: isCollapsing
|
||||||
? 'height 300ms cubic-bezier(0.24, 1.04, 0.56, 1)'
|
? 'height 300ms cubic-bezier(0.24, 1.04, 0.56, 1)'
|
||||||
@ -150,15 +217,22 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
|
|||||||
>
|
>
|
||||||
{/* Fixed red background + delete text */}
|
{/* Fixed red background + delete text */}
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 flex items-center justify-end pr-4`}
|
className={`inset-0 flex items-center justify-end`}
|
||||||
style={{
|
style={{
|
||||||
background: background,
|
background: background,
|
||||||
|
position: 'absolute',
|
||||||
|
marginTop: '1px',
|
||||||
|
marginBottom: '1px',
|
||||||
|
paddingRight: '1rem',
|
||||||
transition: dragX > 1 ? '' : 'background 300ms'
|
transition: dragX > 1 ? '' : 'background 300ms'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button className="text-white font-semibold h-full" style={{
|
<button style={{
|
||||||
transform: deleteTransform,
|
transform: deleteTransform,
|
||||||
transition: 'transform 300ms cubic-bezier(0.24, 1.04, 0.56, 1), opacity 300ms',
|
transition: 'transform 300ms cubic-bezier(0.24, 1.04, 0.56, 1), opacity 300ms',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: useBoldDeleteFont ? 600 : '',
|
||||||
|
height: '100%',
|
||||||
opacity: isCollapsing ? opacityTransparent : 1
|
opacity: isCollapsing ? opacityTransparent : 1
|
||||||
}} onClick={handleDelete} ref={text}>
|
}} onClick={handleDelete} ref={text}>
|
||||||
{deleteText}
|
{deleteText}
|
||||||
@ -168,11 +242,11 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
|
|||||||
{/* Swipeable content */}
|
{/* Swipeable content */}
|
||||||
<div
|
<div
|
||||||
ref={content}
|
ref={content}
|
||||||
className="relative ios-ease"
|
className="ios-ease"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'relative',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
transform: `translateX(${dragX}px)`,
|
transform: `translateX(calc(${dragX}px - 0.5px))`,
|
||||||
transition: dragging
|
transition: dragging
|
||||||
? ''
|
? ''
|
||||||
: 'transform 300ms cubic-bezier(0.24, 1.04, 0.56, 1)',
|
: 'transform 300ms cubic-bezier(0.24, 1.04, 0.56, 1)',
|
||||||
|
@ -2,7 +2,13 @@
|
|||||||
.ios-ease {
|
.ios-ease {
|
||||||
transition-timing-function: cubic-bezier(0.24, 1.04, 0.56, 1);
|
transition-timing-function: cubic-bezier(0.24, 1.04, 0.56, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rubber-band “resistance”: smaller drag as you pull harder */
|
/* Rubber-band “resistance”: smaller drag as you pull harder */
|
||||||
[data-rubber] {
|
[data-rubber] {
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.font-sharpen {
|
||||||
|
-webkit-transform: translateZ(0);
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user