import { styled } from '@linaria/react'; import { type NodeViewProps, NodeViewWrapper } from '@tiptap/react'; import React, { useCallback, useRef, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { themeCssVariables } from 'twenty-ui/theme-constants'; const IMAGE_MIN_WIDTH = 32; const IMAGE_MAX_WIDTH = 600; const StyledNodeViewWrapperContainer = styled.div<{ align?: string; }>` display: flex; height: 100%; justify-content: ${({ align }) => align === 'center' ? 'center' : align === 'right' ? 'flex-end' : 'flex-start'}; `; const StyledImageWrapper = styled.div<{ width?: number }>` height: 100%; `; const StyledImageContainer = styled.div` max-width: 100%; position: relative; `; const StyledImage = styled.img` height: auto; max-width: 100%; `; const StyledImageHandle = styled.div<{ handle: 'left' | 'right' }>` background-color: ${themeCssVariables.background.primaryInverted}; border: 1px solid ${themeCssVariables.background.primary}; border-radius: ${themeCssVariables.border.radius.md}; cursor: col-resize; height: ${themeCssVariables.spacing[8]}; left: ${({ handle }) => handle === 'left' ? themeCssVariables.spacing[1] : 'auto'}; position: absolute; right: ${({ handle }) => handle === 'right' ? themeCssVariables.spacing[1] : 'auto'}; top: 50%; transform: translateY(-50%); width: ${themeCssVariables.spacing[2]}; z-index: 1; `; type ResizeParams = { initialWidth: number; initialClientX: number; handleUsed: 'left' | 'right'; }; type ResizableImageViewProps = NodeViewProps; export const ResizableImageView = (props: ResizableImageViewProps) => { const { editor, node, updateAttributes } = props; const { width: initialWidth, align = 'left', src, alt } = node.attrs; const imageWrapperRef = useRef(null); // Controls visibility of resize handles when hovering over the image const [isHovering, setIsHovering] = useState(false); const [width, setWidth] = useState(initialWidth || 0); // Controls actual resize operation state (null = not resizing, object = actively resizing) const [resizeParams, setResizeParams] = useState(null); // Create stable event handlers using a closure approach const createMouseHandlers = useCallback(() => { let currentResizeParams: ResizeParams | null = null; const handleMouseMove = (event: MouseEvent) => { const imageWrapper = imageWrapperRef.current; if (!isDefined(currentResizeParams) || !isDefined(imageWrapper)) { return; } const deltaX = event.clientX - currentResizeParams.initialClientX; const { initialWidth, handleUsed } = currentResizeParams; let newWidth = align === 'center' ? initialWidth + (handleUsed === 'left' ? -deltaX * 2 : deltaX * 2) : initialWidth + (handleUsed === 'left' ? -deltaX : deltaX); const maxWidth = editor.view.dom.firstElementChild?.clientWidth || IMAGE_MAX_WIDTH; newWidth = Math.min(Math.max(newWidth, IMAGE_MIN_WIDTH), maxWidth); setWidth(newWidth); imageWrapper.style.width = `${newWidth}px`; }; const handleMouseUp = (event: MouseEvent) => { const imageWrapper = imageWrapperRef.current; if (!isDefined(imageWrapper) || !isDefined(currentResizeParams)) { return; } if ( (!event.target || !imageWrapper.contains(event.target as Node)) && isHovering ) { setIsHovering(false); return; } const finalWidth = imageWrapper.clientWidth; currentResizeParams = null; setResizeParams(null); updateAttributes({ width: finalWidth }); window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; const startResize = (resizeParams: ResizeParams) => { currentResizeParams = resizeParams; setResizeParams(resizeParams); window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); }; return { startResize }; }, [editor, align, isHovering, updateAttributes]); const { startResize } = createMouseHandlers(); const handleImageHandleMouseDown = useCallback( (handle: 'left' | 'right', event: React.MouseEvent) => { event.preventDefault(); const resizeParams = { initialWidth: imageWrapperRef.current?.clientWidth ?? IMAGE_MAX_WIDTH, initialClientX: event.clientX, handleUsed: handle, }; startResize(resizeParams); }, [startResize], ); const handleImageHover = useCallback(() => { if (!editor.isEditable) { return; } setIsHovering(true); }, [editor.isEditable]); const handleImageHoverEnd = useCallback(() => { setIsHovering(false); }, []); return ( {(isHovering || isDefined(resizeParams)) && ( <> handleImageHandleMouseDown('left', e)} /> handleImageHandleMouseDown('right', e)} /> )} ); };