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+
5458type PointerState = {
5559 startX : number ;
5660 startY : number ;
5761 startWidth : number ;
5862 startHeight : number ;
63+ direction : ResizeDirection ;
64+ aspectRatio : number ;
5965} ;
6066
6167const 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