Open ghost opened 1 year ago
Is it possible? (yes)
Is it easy ? (Depends on your knowledge about the library and the requirement of your project)
Simplest approach would be:
Code snippets I have used:
Component.tsx
const { buttonStyles } = useContext(ExampleCtx);
const leftHandleCtx = useContext(LeftHandleCtx);
const rightHandleCtx = useContext(RightHandleCtx);
leftHandleCtx.useButtonState(buttonStyles);
rightHandleCtx.useButtonState(buttonStyles);
useEffect(() => {
const resizeLeftElement = document.getElementById("resize-left");
const resizeRightElement = document.getElementById("resize-right");
const handleMouseDownRight = (event: MouseEvent) => {
event.stopPropagation();
rightHandleCtx.setResizing(true, event);
};
const handleMouseDownLeft = (event: MouseEvent) => {
event.stopPropagation();
leftHandleCtx.setResizing(true, event);
};
resizeRightElement?.addEventListener("mousedown", handleMouseDownRight);
resizeLeftElement?.addEventListener("mousedown", handleMouseDownLeft);
return () => {
resizeRightElement?.removeEventListener(
"mousedown",
handleMouseDownRight
);
resizeLeftElement?.removeEventListener(
"mousedown",
handleMouseDownLeft
);
};
}, []);
Mobx Store
class LeftHandleStore {
initialMousePos = 0;
initialWidth = 0;
initialMarginLeft = 0;
constructor() {
makeAutoObservable(this);
autorun(() => {
if (this.isResizing) {
window.addEventListener("mousemove", this.handleMouseMove);
window.addEventListener("mouseup", this.handleMouseUp);
} else {
window.removeEventListener("mousemove", this.handleMouseMove);
window.removeEventListener("mouseup", this.handleMouseUp);
}
});
}
@observable buttonStyles: any;
@action useButtonState = (buttonStyles: any) => {
this.buttonStyles = buttonStyles;
};
@observable isResizing = false;
@action setStyle = (key: ButtonStyleKey, val: number) => {
this.buttonStyles[key] = val;
};
@action setResizing = (val: boolean, event?: MouseEvent) => {
this.isResizing = val;
if (event) {
this.initialMousePos = event.clientX;
this.initialWidth = this.buttonStyles.width;
this.initialMarginLeft = this.buttonStyles.marginLeft;
}
};
@action handleMouseUp = () => {
this.setResizing(false);
};
@action handleMouseMove = (event: MouseEvent) => {
if (!this.isResizing) return;
// Calculate the new width and marginLeft based on mouse movement
const deltaX = event.clientX - this.initialMousePos;
const newWidth = this.initialWidth - deltaX;
const newMarginLeft = this.initialMarginLeft + deltaX;
// Update the width and marginLeft in the state
this.setStyle("width", newWidth);
this.setStyle("marginLeft", newMarginLeft);
};
}
export const LeftHandleCtx = createContext(new LeftHandleStore());
Hopefully above, gave you any ideas how you could implement resize functionality within your project.
Hello I'm having an hard time understanding your example. Do you have one without contexts and store? Thanks in advance!
Hey @diegonogaretti is this still an issue?
Hey @diegonogaretti is this still an issue?
Hi! Yes it's still an issue. It would be awesome if dnd-kit could offer this feature too!
I needed something similar.
transform: scale(0.5)
on parent)I was able to add all features without any issues. Sadly trhere is no really good library out there that works well in this context so I decided to implement it myself.
Currently I'm handling all aspects myself but I guess it could be refactored to make use of dnd-kit utilities.
If this is still nedded by anyone, I can try to extract the custom
Draggable
component into a codesandbox. Adding height resize could be added pretty easily, but I needed width only The component itself is rather simple, but my version is slightly complex since I need to handle it in up/down-scaled views
https://github.com/clauderic/dnd-kit/assets/1148334/f1477131-8457-4b7c-88cd-df3e4d7ddafe
Hey @pixelass that sounds like you built a solution many would find value from. Could you share the source for this component?
@CodyBontecou Sure, I need to extract it first though. Right now it's still entangled with business logic. But I'll post a sandbox when I cleaned my room 😉
Thanks @pixelass, it would be awesome! Hope this would be included in a future dnd-kit's release aswell. Waiting for you, thanks again!
Likewise, would love to see!
@pixelass hi! I am going to build a similar thing for a work project - a draggable element that needs to be also resizeable in width. I use dnd-kit for drag-and-drop implementation and wanted to use it for resize as well. Have you had a chance to remove the business logic from your code? It doesn't have to be working and compiling, I'm more interested in general approach.
Pretty mauch hardcoded into a custom component using joy-ui (MUI successor)
I'm not sure if or how much this helps.
We have 2 custom hooks for keyboard and mouse/touch/pointer handling. We also added a scale factor since we needed this in a sclaed down/up context. It takes care of unscaling the interacive UI elements.
But to KISS, We jsut added a new handle and added the resize there. Most of the logic e.g. bounding box is handled in our hooks (which are also custom to this component) I think it should be rather easy to extract this into a full resize hook, but , you know... time...
function Draggable({
children,
id,
x,
y,
width,
scaleFactor,
}: {
children?: ReactNode;
id: string;
x: number;
y: number;
width: number;
scaleFactor: number;
modal: ReactElement;
}) {
const reference = useRef<HTMLDivElement | null>(null);
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id,
});
const resizeActive = useRef(false);
const handleMouseDown = useResizeHandlers(id, { x, width, scaleFactor, ref: reference });
const handleKeyDown = useKeyboardHandler(id, {
x,
width,
scaleFactor,
ref: reference,
activeRef: resizeActive,
});
function handleBlur(event: ReactFocusEvent) {
resizeActive.current = false;
(event.currentTarget as HTMLButtonElement)?.setAttribute("aria-pressed", "false");
}
const style: CSSProperties & {
"--boxShadow": string;
} = {
transform: transform
? CSS.Translate.toString({
scaleX: 1,
scaleY: 1,
x: transform.x / scaleFactor,
y: transform.y / scaleFactor,
})
: undefined,
top: y,
left: x,
width,
"--boxShadow": `0 0 0 ${2 / scaleFactor}px ${getCssVariable(`palette-neutral-500`)}`,
};
return (
<Box
ref={element => {
setNodeRef(element);
reference.current = element;
}}
style={style}
sx={{
position: "absolute",
display: "flex",
touchAction: "none",
".MuiIconButton-root": {
opacity: 0,
},
"&:focus-within, &:hover": {
boxShadow: "var(--boxShadow)",
".MuiIconButton-root": {
opacity: 1,
},
},
}}
>
{/* ... the drag handle and other things that are not content */}
<Box sx={{ position: "relative", flex: 1, textAlign: "center", width: "100%" }}>
{children}
<StyledResizeHandle
variant="plain"
color="neutral"
size="sm"
style={{
transform: `scaleX(${1 / scaleFactor}) translateX(50%)`,
}}
onPointerDown={handleMouseDown}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
</Box>
</Box>
);
}
ChatGPT to the rescue! I was able to adjust this to be resizable like so:
import React, { forwardRef, useCallback, useEffect, useState } from 'react'
import classNames from 'classnames'
import type { DraggableSyntheticListeners } from '@dnd-kit/core'
import type { Transform } from '@dnd-kit/utilities'
import { Handle } from '../Item/components/Handle'
import {
draggable,
draggableHorizontal,
draggableVertical,
} from './draggable-svg'
import styles from './Draggable.module.scss'
export enum Axis {
All,
Vertical,
Horizontal,
}
interface Props {
axis?: Axis
dragOverlay?: boolean
dragging?: boolean
handle?: boolean
label?: string
listeners?: DraggableSyntheticListeners
style?: React.CSSProperties
buttonStyle?: React.CSSProperties
transform?: Transform | null
resizable?: boolean
}
export const Draggable = forwardRef<HTMLButtonElement, Props>(
function Draggable(
{
axis,
dragOverlay,
dragging,
handle,
label,
listeners,
transform,
style,
buttonStyle,
resizable,
...props
},
ref
) {
const [size, setSize] = useState({ width: 200, height: 200 })
const [isResizing, setIsResizing] = useState(false)
const [startSize, setStartSize] = useState({ width: 0, height: 0 })
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
const handleMouseDown = e => {
e.preventDefault()
setIsResizing(true)
setStartSize(size)
setStartPos({ x: e.clientX, y: e.clientY })
}
const handleMouseMove = useCallback(
e => {
if (!isResizing) return
const newWidth = startSize.width + e.clientX - startPos.x
const newHeight = startSize.height + e.clientY - startPos.y
setSize({ width: newWidth, height: newHeight })
},
[isResizing, startSize, startPos]
)
const handleMouseUp = () => {
setIsResizing(false)
}
useEffect(() => {
if (isResizing) {
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
} else {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
}
return () => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
}
}, [isResizing, handleMouseMove])
return (
<div
className={classNames(
styles.Draggable,
dragOverlay && styles.dragOverlay,
dragging && styles.dragging,
handle && styles.handle
)}
style={
{
...style,
width: `${size.width}px`,
height: `${size.height}px`,
'--translate-x': `${transform?.x ?? 0}px`,
'--translate-y': `${transform?.y ?? 0}px`,
} as React.CSSProperties
}
>
<button
{...props}
aria-label="Draggable"
data-cypress="draggable-item"
{...(handle ? {} : listeners)}
tabIndex={handle ? -1 : undefined}
ref={ref}
style={{
...buttonStyle,
width: `${size.width}px`,
height: `${size.height}px`,
}}
>
{axis === Axis.Vertical
? draggableVertical
: axis === Axis.Horizontal
? draggableHorizontal
: draggable}
{handle ? <Handle {...(handle ? listeners : {})} /> : null}
</button>
{resizable && (
<div
className="resize-handle"
onMouseDown={handleMouseDown}
style={{
position: 'absolute',
bottom: 0,
right: 0,
width: '10px',
height: '10px',
backgroundColor: 'grey',
cursor: 'nwse-resize',
}}
></div>
)}
</div>
)
}
)
looks like you're missing touch and keyboard listeners, but nice start. :)
Pretty mauch hardcoded into a custom component using joy-ui (MUI successor)
I'm not sure if or how much this helps.
We have 2 custom hooks for keyboard and mouse/touch/pointer handling. We also added a scale factor since we needed this in a sclaed down/up context. It takes care of unscaling the interacive UI elements.
But to KISS, We jsut added a new handle and added the resize there. Most of the logic e.g. bounding box is handled in our hooks (which are also custom to this component) I think it should be rather easy to extract this into a full resize hook, but , you know... time...
function Draggable({ children, id, x, y, width, scaleFactor, }: { children?: ReactNode; id: string; x: number; y: number; width: number; scaleFactor: number; modal: ReactElement; }) { const reference = useRef<HTMLDivElement | null>(null); const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id, }); const resizeActive = useRef(false); const handleMouseDown = useResizeHandlers(id, { x, width, scaleFactor, ref: reference }); const handleKeyDown = useKeyboardHandler(id, { x, width, scaleFactor, ref: reference, activeRef: resizeActive, }); function handleBlur(event: ReactFocusEvent) { resizeActive.current = false; (event.currentTarget as HTMLButtonElement)?.setAttribute("aria-pressed", "false"); } const style: CSSProperties & { "--boxShadow": string; } = { transform: transform ? CSS.Translate.toString({ scaleX: 1, scaleY: 1, x: transform.x / scaleFactor, y: transform.y / scaleFactor, }) : undefined, top: y, left: x, width, "--boxShadow": `0 0 0 ${2 / scaleFactor}px ${getCssVariable(`palette-neutral-500`)}`, }; return ( <Box ref={element => { setNodeRef(element); reference.current = element; }} style={style} sx={{ position: "absolute", display: "flex", touchAction: "none", ".MuiIconButton-root": { opacity: 0, }, "&:focus-within, &:hover": { boxShadow: "var(--boxShadow)", ".MuiIconButton-root": { opacity: 1, }, }, }} > {/* ... the drag handle and other things that are not content */} <Box sx={{ position: "relative", flex: 1, textAlign: "center", width: "100%" }}> {children} <StyledResizeHandle variant="plain" color="neutral" size="sm" style={{ transform: `scaleX(${1 / scaleFactor}) translateX(50%)`, }} onPointerDown={handleMouseDown} onKeyDown={handleKeyDown} onBlur={handleBlur} /> </Box> </Box> ); }
Hi @pixelass
Could you please share also the useResizeHandlers
code? I'm using MUI too btw :)
Thanks again for your help!
@diegonogaretti pretty much hardcoded using Jotai for global state. (BTW we use Joy UI)
This should be made into a reusable component as it currently only allows a single instance. THis was a really lazy implementation which works well for our use-case but does not scale at all.
import { useAtom } from "jotai";
import type {
MouseEvent as ReactMouseEvent,
MutableRefObject,
TouchEvent as ReactTouchEvent,
} from "react";
import { useCallback } from "react";
import { textElementsAtom } from "@/ions/atoms";
export function useResizeHandlers(
id: string,
{
scaleFactor,
x,
width,
ref,
}: {
scaleFactor: number;
x: number;
width: number;
ref: MutableRefObject<HTMLDivElement | null>;
}
) {
const [, setTextElements] = useAtom(textElementsAtom);
return useCallback(
(event: ReactMouseEvent | ReactTouchEvent) => {
const startX = Object.hasOwn(event, "touches")
? (event as ReactTouchEvent).touches[0].pageX
: (event as ReactMouseEvent).pageX;
function handleTouchMove(event: TouchEvent) {
// Calculate the difference
const { pageX } = event.touches[0];
const deltaX = pageX - startX;
const parentWidth = ref.current?.parentElement?.offsetWidth ?? width;
const maxWidth = parentWidth - x;
// Calculate the new size
const newWidth = Math.min(maxWidth, width + deltaX / scaleFactor);
// Update the size state
setTextElements(previousState =>
previousState.map(textElement =>
textElement.id === id ? { ...textElement, width: newWidth } : textElement
)
);
}
function handleMouseMove(event: MouseEvent) {
// Calculate the difference
const { pageX } = event;
const deltaX = pageX - startX;
const parentWidth = ref.current?.parentElement?.offsetWidth ?? width;
const maxWidth = parentWidth - x;
// Calculate the new size
const newWidth = Math.min(maxWidth, width + deltaX / scaleFactor);
// Update the size state
setTextElements(previousState =>
previousState.map(textElement =>
textElement.id === id ? { ...textElement, width: newWidth } : textElement
)
);
}
function handleMouseUp() {
// Remove the event listeners when the mouse is released
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("touchend", handleMouseUp);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
}
// Add the event listeners to the document
document.addEventListener("mousemove", handleMouseMove, { passive: true });
document.addEventListener("touchmove", handleTouchMove, { passive: true });
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("touchend", handleMouseUp);
},
[id, ref, scaleFactor, setTextElements, width, x]
);
}
The same counts for the keyboardhandlers: Very hardcoded.
It aims to mimic dnd-kit's sensor behavior
import { useAtom } from "jotai";
import type { KeyboardEvent as ReactKeyboardEvent, MutableRefObject } from "react";
import { useCallback } from "react";
import { textElementsAtom } from "@/ions/atoms";
export function useKeyboardHandler(
id: string,
{
scaleFactor,
x,
width,
ref,
activeRef,
}: {
x: number;
width: number;
scaleFactor: number;
activeRef: MutableRefObject<boolean>;
ref: MutableRefObject<HTMLDivElement | null>;
}
) {
const [, setTextElements] = useAtom(textElementsAtom);
return useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (["Space", "Enter"].includes(event.code)) {
activeRef.current = !activeRef.current;
(event.currentTarget as HTMLButtonElement)?.setAttribute(
"aria-pressed",
activeRef.current ? "true" : "false"
);
} else if (["ArrowLeft", "ArrowRight"].includes(event.code) && activeRef.current) {
const parentWidth = ref.current?.parentElement?.offsetWidth ?? width;
const maxWidth = parentWidth - x;
switch (event.code) {
case "ArrowLeft": {
setTextElements(previousState =>
previousState.map(textElement =>
textElement.id === id
? {
...textElement,
width: Math.max(
0,
textElement.width! - 10 / scaleFactor
),
}
: textElement
)
);
break;
}
case "ArrowRight": {
setTextElements(previousState =>
previousState.map(textElement =>
textElement.id === id
? {
...textElement,
width: Math.min(
maxWidth,
textElement.width! + 10 / scaleFactor
),
}
: textElement
)
);
break;
}
default: {
break;
}
}
}
},
[activeRef, id, ref, scaleFactor, setTextElements, width, x]
);
}
Thanks a lot @pixelass!
Awesome code samples, thanks a lot for a good starting point for me @pixelass!
@pixelass @CodyBontecou I am wondering if it would be better to use the built in CSS resize property. Then their is the resizeObserver property which you could use to identify when to change the size of the element vs when to drag the element.
ChatGPT to the rescue! I was able to adjust this to be resizable like so:
import React, { forwardRef, useCallback, useEffect, useState } from 'react' import classNames from 'classnames' import type { DraggableSyntheticListeners } from '@dnd-kit/core' import type { Transform } from '@dnd-kit/utilities' import { Handle } from '../Item/components/Handle' import { draggable, draggableHorizontal, draggableVertical, } from './draggable-svg' import styles from './Draggable.module.scss' export enum Axis { All, Vertical, Horizontal, } interface Props { axis?: Axis dragOverlay?: boolean dragging?: boolean handle?: boolean label?: string listeners?: DraggableSyntheticListeners style?: React.CSSProperties buttonStyle?: React.CSSProperties transform?: Transform | null resizable?: boolean } export const Draggable = forwardRef<HTMLButtonElement, Props>( function Draggable( { axis, dragOverlay, dragging, handle, label, listeners, transform, style, buttonStyle, resizable, ...props }, ref ) { const [size, setSize] = useState({ width: 200, height: 200 }) const [isResizing, setIsResizing] = useState(false) const [startSize, setStartSize] = useState({ width: 0, height: 0 }) const [startPos, setStartPos] = useState({ x: 0, y: 0 }) const handleMouseDown = e => { e.preventDefault() setIsResizing(true) setStartSize(size) setStartPos({ x: e.clientX, y: e.clientY }) } const handleMouseMove = useCallback( e => { if (!isResizing) return const newWidth = startSize.width + e.clientX - startPos.x const newHeight = startSize.height + e.clientY - startPos.y setSize({ width: newWidth, height: newHeight }) }, [isResizing, startSize, startPos] ) const handleMouseUp = () => { setIsResizing(false) } useEffect(() => { if (isResizing) { window.addEventListener('mousemove', handleMouseMove) window.addEventListener('mouseup', handleMouseUp) } else { window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('mouseup', handleMouseUp) } return () => { window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('mouseup', handleMouseUp) } }, [isResizing, handleMouseMove]) return ( <div className={classNames( styles.Draggable, dragOverlay && styles.dragOverlay, dragging && styles.dragging, handle && styles.handle )} style={ { ...style, width: `${size.width}px`, height: `${size.height}px`, '--translate-x': `${transform?.x ?? 0}px`, '--translate-y': `${transform?.y ?? 0}px`, } as React.CSSProperties } > <button {...props} aria-label="Draggable" data-cypress="draggable-item" {...(handle ? {} : listeners)} tabIndex={handle ? -1 : undefined} ref={ref} style={{ ...buttonStyle, width: `${size.width}px`, height: `${size.height}px`, }} > {axis === Axis.Vertical ? draggableVertical : axis === Axis.Horizontal ? draggableHorizontal : draggable} {handle ? <Handle {...(handle ? listeners : {})} /> : null} </button> {resizable && ( <div className="resize-handle" onMouseDown={handleMouseDown} style={{ position: 'absolute', bottom: 0, right: 0, width: '10px', height: '10px', backgroundColor: 'grey', cursor: 'nwse-resize', }} ></div> )} </div> ) } )
The resize handle is absolute which means it moves away from the actual element. @CodyBontecou did you find a better way to render the resize component in the bottom corner?
@KuzonFyre My implementation was a quick MVP which only requires resizing on the x axis. Please don't see my code as a reusable or recommended approach. My main focus was an accessible interface to solve ONE problem.
I use re-resizable and dnd-kit to get the effect you want
So should the recommended approach be to use re-resizable?
I use re-resizable and dnd-kit to get the effect you want
This looks neat. Any chance you can share some code on how to get this to work? Having some trouble figuring out ref binding and which component to wrap with which. thanks!
So should the recommended approach be to use re-resizable?
I chose to not use it because of its missing a11y support (no-keyboard interaction). Besides that it it seems to be a solid solution.
Simplest approach would be:
@awesomezao Mind sharing your code please?
I use re-resizable and dnd-kit to get the effect you want
Can you share the code? I try re-resizeable, it doesn't work for me.
Hello
Looking to implement a resize functionality using dnd-kit. I already have implemented drag & drop. Is it possilble at all? I would like not to use another library just for resize.
Thanks!