Skip to content

Commit fcbdd1c

Browse files
committed
Make rich text images selectable and resizable
1 parent 6e640e8 commit fcbdd1c

1 file changed

Lines changed: 226 additions & 18 deletions

File tree

components/rich-text/ImageNode.tsx

Lines changed: 226 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
1-
import React from 'react';
1+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
3+
import { useLexicalEditable } from '@lexical/react/useLexicalEditable';
4+
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
25
import {
6+
$getNodeByKey,
7+
$getSelection,
8+
$isNodeSelection,
9+
CLICK_COMMAND,
10+
COMMAND_PRIORITY_LOW,
311
DecoratorNode,
12+
KEY_BACKSPACE_COMMAND,
13+
KEY_DELETE_COMMAND,
14+
SELECTION_CHANGE_COMMAND,
415
createCommand,
516
type DOMConversionMap,
617
type DOMConversionOutput,
718
type LexicalCommand,
19+
type LexicalEditor,
820
type LexicalNode,
921
type NodeKey,
1022
type Spread,
1123
type SerializedLexicalNode,
1224
} from 'lexical';
25+
import { mergeRegister } from '@lexical/utils';
1326

1427
export type ImagePayload = {
1528
src: string;
@@ -32,29 +45,217 @@ export type SerializedImageNode = Spread<
3245

3346
export const INSERT_IMAGE_COMMAND: LexicalCommand<ImagePayload> = createCommand('INSERT_IMAGE_COMMAND');
3447

35-
class ImageComponent extends React.Component<ImagePayload> {
36-
render(): React.ReactNode {
37-
const { src, altText, width, height } = this.props;
38-
const resolvedWidth = typeof width === 'number' ? `${width}px` : width ?? 'auto';
39-
const resolvedHeight = typeof height === 'number' ? `${height}px` : height ?? 'auto';
48+
const MIN_DIMENSION = 64;
4049

41-
return (
50+
type ImageComponentProps = ImagePayload & {
51+
nodeKey: NodeKey;
52+
};
53+
54+
type PointerState = {
55+
startX: number;
56+
startY: number;
57+
startWidth: number;
58+
startHeight: number;
59+
};
60+
61+
const ImageComponent: React.FC<ImageComponentProps> = ({ src, altText, width, height, nodeKey }) => {
62+
const [editor] = useLexicalComposerContext();
63+
const isEditable = useLexicalEditable();
64+
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
65+
const [isResizing, setIsResizing] = useState(false);
66+
const imageRef = useRef<HTMLImageElement>(null);
67+
const pointerStateRef = useRef<PointerState | null>(null);
68+
const [currentWidth, setCurrentWidth] = useState<number | 'inherit'>(width ?? 'inherit');
69+
const [currentHeight, setCurrentHeight] = useState<number | 'inherit'>(height ?? 'inherit');
70+
71+
useEffect(() => {
72+
setCurrentWidth(width ?? 'inherit');
73+
}, [width]);
74+
75+
useEffect(() => {
76+
setCurrentHeight(height ?? 'inherit');
77+
}, [height]);
78+
79+
const updateDimensions = useCallback(
80+
(nextWidth: number | 'inherit', nextHeight: number | 'inherit') => {
81+
setCurrentWidth(nextWidth);
82+
setCurrentHeight(nextHeight);
83+
editor.update(() => {
84+
const node = $getNodeByKey(nodeKey);
85+
if ($isImageNode(node)) {
86+
node.setWidthAndHeight(nextWidth, nextHeight);
87+
}
88+
});
89+
},
90+
[editor, nodeKey],
91+
);
92+
93+
const onDelete = useCallback(
94+
(event: KeyboardEvent) => {
95+
if (isSelected && $isNodeSelection($getSelection())) {
96+
event.preventDefault();
97+
editor.update(() => {
98+
const node = $getNodeByKey(nodeKey);
99+
if ($isImageNode(node)) {
100+
node.remove();
101+
}
102+
});
103+
return true;
104+
}
105+
return false;
106+
},
107+
[editor, isSelected, nodeKey],
108+
);
109+
110+
const onClick = useCallback(
111+
(event: MouseEvent) => {
112+
if (!imageRef.current) {
113+
return false;
114+
}
115+
116+
if (event.target === imageRef.current) {
117+
if (event.shiftKey) {
118+
setSelected(!isSelected);
119+
return true;
120+
}
121+
122+
clearSelection();
123+
setSelected(true);
124+
return true;
125+
}
126+
127+
return false;
128+
},
129+
[clearSelection, isSelected, setSelected],
130+
);
131+
132+
useEffect(
133+
() =>
134+
mergeRegister(
135+
editor.registerCommand(
136+
SELECTION_CHANGE_COMMAND,
137+
(_payload, _newEditor: LexicalEditor) => {
138+
const selection = $getSelection();
139+
if ($isNodeSelection(selection)) {
140+
const isNodeSelected = selection.has(nodeKey);
141+
setSelected(isNodeSelected);
142+
return false;
143+
}
144+
if (isSelected) {
145+
setSelected(false);
146+
}
147+
return false;
148+
},
149+
COMMAND_PRIORITY_LOW,
150+
),
151+
editor.registerCommand(CLICK_COMMAND, onClick, COMMAND_PRIORITY_LOW),
152+
editor.registerCommand(KEY_DELETE_COMMAND, onDelete, COMMAND_PRIORITY_LOW),
153+
editor.registerCommand(KEY_BACKSPACE_COMMAND, onDelete, COMMAND_PRIORITY_LOW),
154+
),
155+
[editor, isSelected, nodeKey, onClick, onDelete, setSelected],
156+
);
157+
158+
const resolvedWidth = useMemo(() => (typeof currentWidth === 'number' ? `${currentWidth}px` : currentWidth ?? 'auto'), [
159+
currentWidth,
160+
]);
161+
const resolvedHeight = useMemo(
162+
() => (typeof currentHeight === 'number' ? `${currentHeight}px` : currentHeight ?? 'auto'),
163+
[currentHeight],
164+
);
165+
166+
const handlePointerMove = useCallback((event: PointerEvent) => {
167+
const state = pointerStateRef.current;
168+
if (!state) {
169+
return;
170+
}
171+
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));
174+
175+
setCurrentWidth(nextWidth);
176+
setCurrentHeight(nextHeight);
177+
}, []);
178+
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);
185+
}
186+
187+
pointerStateRef.current = null;
188+
setIsResizing(false);
189+
document.removeEventListener('pointermove', handlePointerMove);
190+
document.removeEventListener('pointerup', handlePointerUp);
191+
}, [handlePointerMove, updateDimensions]);
192+
193+
const handlePointerDown = useCallback(
194+
(event: React.PointerEvent<HTMLDivElement>) => {
195+
if (!isEditable || !imageRef.current) {
196+
return;
197+
}
198+
event.preventDefault();
199+
event.stopPropagation();
200+
201+
const rect = imageRef.current.getBoundingClientRect();
202+
pointerStateRef.current = {
203+
startX: event.clientX,
204+
startY: event.clientY,
205+
startWidth: rect.width,
206+
startHeight: rect.height,
207+
};
208+
209+
setIsResizing(true);
210+
document.addEventListener('pointermove', handlePointerMove);
211+
document.addEventListener('pointerup', handlePointerUp);
212+
},
213+
[handlePointerMove, handlePointerUp, isEditable],
214+
);
215+
216+
useEffect(() => {
217+
return () => {
218+
document.removeEventListener('pointermove', handlePointerMove);
219+
document.removeEventListener('pointerup', handlePointerUp);
220+
};
221+
}, [handlePointerMove, handlePointerUp]);
222+
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+
234+
const showHandles = isEditable && isSelected;
235+
236+
return (
237+
<span className="relative my-3 block w-full max-w-full" draggable={isEditable} onDragStart={onDragStart}>
42238
<img
239+
ref={imageRef}
43240
src={src}
44241
alt={altText}
45-
style={{
46-
width: resolvedWidth,
47-
height: resolvedHeight,
48-
maxWidth: '100%',
49-
borderRadius: '0.5rem',
50-
objectFit: 'contain',
51-
}}
52-
className="block border border-border-color/60 bg-secondary"
242+
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' : ''}`}
53244
draggable={false}
54245
/>
55-
);
56-
}
57-
}
246+
{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+
/>
253+
</div>
254+
) : null}
255+
{isResizing ? <div className="pointer-events-none absolute inset-0 rounded-md border-2 border-dashed border-primary" /> : null}
256+
</span>
257+
);
258+
};
58259

59260
export class ImageNode extends DecoratorNode<JSX.Element> {
60261
__src: string;
@@ -96,6 +297,7 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
96297
altText={this.__altText}
97298
width={this.__width}
98299
height={this.__height}
300+
nodeKey={this.__key}
99301
/>
100302
);
101303
}
@@ -134,6 +336,12 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
134336
return { element };
135337
}
136338

339+
setWidthAndHeight(width?: number | 'inherit', height?: number | 'inherit') {
340+
const writable = this.getWritable();
341+
writable.__width = width;
342+
writable.__height = height;
343+
}
344+
137345
static importDOM(): DOMConversionMap | null {
138346
return {
139347
img: (domNode: Node) => {

0 commit comments

Comments
 (0)