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' ;
25import {
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
1427export type ImagePayload = {
1528 src : string ;
@@ -32,29 +45,217 @@ export type SerializedImageNode = Spread<
3245
3346export 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
59260export 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