Skip to content

Commit b565a1b

Browse files
committed
Polish image drag and resize UX
1 parent fcbdd1c commit b565a1b

1 file changed

Lines changed: 146 additions & 39 deletions

File tree

components/rich-text/ImageNode.tsx

Lines changed: 146 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
CLICK_COMMAND,
1010
COMMAND_PRIORITY_LOW,
1111
DecoratorNode,
12+
DRAGEND_COMMAND,
13+
DRAGSTART_COMMAND,
1214
KEY_BACKSPACE_COMMAND,
1315
KEY_DELETE_COMMAND,
1416
SELECTION_CHANGE_COMMAND,
@@ -51,11 +53,15 @@ type ImageComponentProps = ImagePayload & {
5153
nodeKey: NodeKey;
5254
};
5355

56+
type ResizeDirection = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw';
57+
5458
type PointerState = {
5559
startX: number;
5660
startY: number;
5761
startWidth: number;
5862
startHeight: number;
63+
direction: ResizeDirection;
64+
aspectRatio: number;
5965
};
6066

6167
const ImageComponent: React.FC<ImageComponentProps> = ({ src, altText, width, height, nodeKey }) => {
@@ -113,7 +119,8 @@ const ImageComponent: React.FC<ImageComponentProps> = ({ src, altText, width, he
113119
return false;
114120
}
115121

116-
if (event.target === imageRef.current) {
122+
const target = event.target as HTMLElement | null;
123+
if (target && (target === imageRef.current || target.dataset.type === 'image-handle')) {
117124
if (event.shiftKey) {
118125
setSelected(!isSelected);
119126
return true;
@@ -129,6 +136,24 @@ const ImageComponent: React.FC<ImageComponentProps> = ({ src, altText, width, he
129136
[clearSelection, isSelected, setSelected],
130137
);
131138

139+
const onDragStart = useCallback(
140+
(event: DragEvent) => {
141+
if (!isEditable || !event.dataTransfer || !imageRef.current) {
142+
return false;
143+
}
144+
event.dataTransfer.setData('text/plain', '_lexical_image');
145+
event.dataTransfer.setDragImage(imageRef.current, imageRef.current.clientWidth / 2, imageRef.current.clientHeight / 2);
146+
event.dataTransfer.effectAllowed = 'move';
147+
return true;
148+
},
149+
[isEditable],
150+
);
151+
152+
const onDragEnd = useCallback(() => {
153+
setIsResizing(false);
154+
return false;
155+
}, []);
156+
132157
useEffect(
133158
() =>
134159
mergeRegister(
@@ -151,8 +176,10 @@ const ImageComponent: React.FC<ImageComponentProps> = ({ src, altText, width, he
151176
editor.registerCommand(CLICK_COMMAND, onClick, COMMAND_PRIORITY_LOW),
152177
editor.registerCommand(KEY_DELETE_COMMAND, onDelete, COMMAND_PRIORITY_LOW),
153178
editor.registerCommand(KEY_BACKSPACE_COMMAND, onDelete, COMMAND_PRIORITY_LOW),
179+
editor.registerCommand(DRAGSTART_COMMAND, onDragStart, COMMAND_PRIORITY_LOW),
180+
editor.registerCommand(DRAGEND_COMMAND, onDragEnd, COMMAND_PRIORITY_LOW),
154181
),
155-
[editor, isSelected, nodeKey, onClick, onDelete, setSelected],
182+
[editor, isSelected, nodeKey, onClick, onDelete, onDragEnd, onDragStart, setSelected],
156183
);
157184

158185
const resolvedWidth = useMemo(() => (typeof currentWidth === 'number' ? `${currentWidth}px` : currentWidth ?? 'auto'), [
@@ -169,29 +196,88 @@ const ImageComponent: React.FC<ImageComponentProps> = ({ src, altText, width, he
169196
return;
170197
}
171198

172-
const nextWidth = Math.max(MIN_DIMENSION, state.startWidth + (event.clientX - state.startX));
173-
const nextHeight = Math.max(MIN_DIMENSION, state.startHeight + (event.clientY - state.startY));
199+
const deltaX = event.clientX - state.startX;
200+
const deltaY = event.clientY - state.startY;
174201

175-
setCurrentWidth(nextWidth);
176-
setCurrentHeight(nextHeight);
177-
}, []);
202+
let nextWidth = state.startWidth;
203+
let nextHeight = state.startHeight;
178204

179-
const handlePointerUp = useCallback((event: PointerEvent) => {
180-
const state = pointerStateRef.current;
181-
if (state) {
182-
const nextWidth = Math.max(MIN_DIMENSION, state.startWidth + (event.clientX - state.startX));
183-
const nextHeight = Math.max(MIN_DIMENSION, state.startHeight + (event.clientY - state.startY));
184-
updateDimensions(nextWidth, nextHeight);
205+
if (state.direction.includes('e')) {
206+
nextWidth += deltaX;
207+
}
208+
if (state.direction.includes('w')) {
209+
nextWidth -= deltaX;
210+
}
211+
if (state.direction.includes('s')) {
212+
nextHeight += deltaY;
213+
}
214+
if (state.direction.includes('n')) {
215+
nextHeight -= deltaY;
185216
}
186217

187-
pointerStateRef.current = null;
188-
setIsResizing(false);
189-
document.removeEventListener('pointermove', handlePointerMove);
190-
document.removeEventListener('pointerup', handlePointerUp);
191-
}, [handlePointerMove, updateDimensions]);
218+
const lockAspect = event.shiftKey || state.direction.length === 2;
219+
if (lockAspect) {
220+
const widthBasedHeight = nextWidth / state.aspectRatio;
221+
const heightBasedWidth = nextHeight * state.aspectRatio;
222+
if (Math.abs(widthBasedHeight - nextHeight) > Math.abs(heightBasedWidth - nextWidth)) {
223+
nextHeight = widthBasedHeight;
224+
} else {
225+
nextWidth = heightBasedWidth;
226+
}
227+
}
228+
229+
setCurrentWidth(Math.max(MIN_DIMENSION, nextWidth));
230+
setCurrentHeight(Math.max(MIN_DIMENSION, nextHeight));
231+
}, []);
232+
233+
const handlePointerUp = useCallback(
234+
(event: PointerEvent) => {
235+
const state = pointerStateRef.current;
236+
if (state) {
237+
const deltaX = event.clientX - state.startX;
238+
const deltaY = event.clientY - state.startY;
239+
let nextWidth = state.startWidth;
240+
let nextHeight = state.startHeight;
241+
242+
if (state.direction.includes('e')) {
243+
nextWidth += deltaX;
244+
}
245+
if (state.direction.includes('w')) {
246+
nextWidth -= deltaX;
247+
}
248+
if (state.direction.includes('s')) {
249+
nextHeight += deltaY;
250+
}
251+
if (state.direction.includes('n')) {
252+
nextHeight -= deltaY;
253+
}
254+
255+
const lockAspect = event.shiftKey || state.direction.length === 2;
256+
if (lockAspect) {
257+
const widthBasedHeight = nextWidth / state.aspectRatio;
258+
const heightBasedWidth = nextHeight * state.aspectRatio;
259+
if (Math.abs(widthBasedHeight - nextHeight) > Math.abs(heightBasedWidth - nextWidth)) {
260+
nextHeight = widthBasedHeight;
261+
} else {
262+
nextWidth = heightBasedWidth;
263+
}
264+
}
265+
266+
nextWidth = Math.max(MIN_DIMENSION, nextWidth);
267+
nextHeight = Math.max(MIN_DIMENSION, nextHeight);
268+
updateDimensions(nextWidth, nextHeight);
269+
}
270+
271+
pointerStateRef.current = null;
272+
setIsResizing(false);
273+
document.removeEventListener('pointermove', handlePointerMove);
274+
document.removeEventListener('pointerup', handlePointerUp);
275+
},
276+
[handlePointerMove, updateDimensions],
277+
);
192278

193279
const handlePointerDown = useCallback(
194-
(event: React.PointerEvent<HTMLDivElement>) => {
280+
(event: React.PointerEvent<HTMLDivElement>, direction: ResizeDirection) => {
195281
if (!isEditable || !imageRef.current) {
196282
return;
197283
}
@@ -204,6 +290,8 @@ const ImageComponent: React.FC<ImageComponentProps> = ({ src, altText, width, he
204290
startY: event.clientY,
205291
startWidth: rect.width,
206292
startHeight: rect.height,
293+
direction,
294+
aspectRatio: rect.width / rect.height,
207295
};
208296

209297
setIsResizing(true);
@@ -220,36 +308,55 @@ const ImageComponent: React.FC<ImageComponentProps> = ({ src, altText, width, he
220308
};
221309
}, [handlePointerMove, handlePointerUp]);
222310

223-
const onDragStart = useCallback(
224-
(event: React.DragEvent) => {
225-
if (!isEditable || !event.dataTransfer) {
226-
return;
227-
}
228-
event.stopPropagation();
229-
event.dataTransfer.setData('text/plain', '_lexical_image');
230-
},
231-
[isEditable],
232-
);
233-
234311
const showHandles = isEditable && isSelected;
235312

236313
return (
237-
<span className="relative my-3 block w-full max-w-full" draggable={isEditable} onDragStart={onDragStart}>
314+
<span
315+
className={`group relative my-3 block w-full max-w-full ${isSelected ? 'cursor-move' : ''}`}
316+
draggable={isEditable}
317+
>
238318
<img
239319
ref={imageRef}
240320
src={src}
241321
alt={altText}
242322
style={{ width: resolvedWidth, height: resolvedHeight, maxWidth: '100%', borderRadius: '0.5rem', objectFit: 'contain' }}
243-
className={`block border border-border-color/60 bg-secondary ${showHandles ? 'ring-2 ring-primary' : ''}`}
244-
draggable={false}
323+
className={`block border border-border-color/60 bg-secondary transition-shadow duration-150 ${showHandles ? 'ring-2 ring-primary shadow-lg' : 'shadow-sm'}`}
324+
draggable={isEditable}
325+
onDragStart={(event) => {
326+
onDragStart(event.nativeEvent);
327+
}}
328+
onDragEnd={(event) => {
329+
event.preventDefault();
330+
onDragEnd();
331+
}}
245332
/>
246333
{showHandles ? (
247-
<div className="pointer-events-none absolute inset-0">
248-
<div
249-
role="presentation"
250-
className="pointer-events-auto absolute -bottom-2 -right-2 h-4 w-4 cursor-se-resize rounded-sm border border-primary bg-background"
251-
onPointerDown={handlePointerDown}
252-
/>
334+
<div className="pointer-events-none absolute inset-0 rounded-lg border border-primary/70 shadow-[0_0_0_1px_rgba(255,255,255,0.5)]">
335+
{(
336+
[
337+
['nw', '-top-2 -left-2 cursor-nw-resize'],
338+
['n', '-top-2 left-1/2 -translate-x-1/2 cursor-n-resize'],
339+
['ne', '-top-2 -right-2 cursor-ne-resize'],
340+
['e', 'top-1/2 -right-2 -translate-y-1/2 cursor-e-resize'],
341+
['se', '-bottom-2 -right-2 cursor-se-resize'],
342+
['s', '-bottom-2 left-1/2 -translate-x-1/2 cursor-s-resize'],
343+
['sw', '-bottom-2 -left-2 cursor-sw-resize'],
344+
['w', 'top-1/2 -left-2 -translate-y-1/2 cursor-w-resize'],
345+
] as const
346+
).map(([direction, positionClass]) => (
347+
<button
348+
key={direction}
349+
type="button"
350+
data-type="image-handle"
351+
className={`pointer-events-auto absolute h-3 w-3 rounded-full border border-primary bg-background transition hover:scale-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary ${positionClass}`}
352+
onPointerDown={(event) => handlePointerDown(event, direction)}
353+
/>
354+
))}
355+
<div className="pointer-events-none absolute left-2 top-2 rounded bg-background/90 px-2 py-1 text-xs font-medium text-foreground shadow-sm">
356+
{`${Math.round(typeof currentWidth === 'number' ? currentWidth : imageRef.current?.width ?? 0)} × ${Math.round(
357+
typeof currentHeight === 'number' ? currentHeight : imageRef.current?.height ?? 0,
358+
)} px`}
359+
</div>
253360
</div>
254361
) : null}
255362
{isResizing ? <div className="pointer-events-none absolute inset-0 rounded-md border-2 border-dashed border-primary" /> : null}

0 commit comments

Comments
 (0)