feat: add image handling capabilities to rich text editor with drag-and-drop support, resizing, and markdown integration
This commit is contained in:
292
src/editor/ui/ImageResizer.tsx
Normal file
292
src/editor/ui/ImageResizer.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import type { LexicalEditor } from 'lexical';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
import { calculateZoomLevel } from '@lexical/utils';
|
||||
import { useRef } from 'react';
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
const Direction = {
|
||||
east: 1 << 0,
|
||||
north: 1 << 3,
|
||||
south: 1 << 1,
|
||||
west: 1 << 2,
|
||||
};
|
||||
|
||||
export default function ImageResizer({
|
||||
onResizeStart,
|
||||
onResizeEnd,
|
||||
imageRef,
|
||||
maxWidth,
|
||||
editor,
|
||||
}: {
|
||||
editor: LexicalEditor;
|
||||
imageRef: { current: null | HTMLElement };
|
||||
maxWidth?: number;
|
||||
onResizeEnd: (width: 'inherit' | number, height: 'inherit' | number) => void;
|
||||
onResizeStart: () => void;
|
||||
}): JSX.Element {
|
||||
const controlWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const userSelect = useRef({
|
||||
priority: '',
|
||||
value: 'default',
|
||||
});
|
||||
const positioningRef = useRef<{
|
||||
currentHeight: 'inherit' | number;
|
||||
currentWidth: 'inherit' | number;
|
||||
direction: number;
|
||||
isResizing: boolean;
|
||||
ratio: number;
|
||||
startHeight: number;
|
||||
startWidth: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
}>({
|
||||
currentHeight: 0,
|
||||
currentWidth: 0,
|
||||
direction: 0,
|
||||
isResizing: false,
|
||||
ratio: 0,
|
||||
startHeight: 0,
|
||||
startWidth: 0,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
});
|
||||
|
||||
const editorRootElement = editor.getRootElement();
|
||||
const maxWidthContainer = maxWidth
|
||||
? maxWidth
|
||||
: editorRootElement !== null
|
||||
? editorRootElement.getBoundingClientRect().width - 20
|
||||
: 100;
|
||||
const maxHeightContainer =
|
||||
editorRootElement !== null
|
||||
? editorRootElement.getBoundingClientRect().height - 20
|
||||
: 100;
|
||||
|
||||
const minWidth = 100;
|
||||
const minHeight = 100;
|
||||
|
||||
const setStartCursor = (direction: number) => {
|
||||
const ew = direction === Direction.east || direction === Direction.west;
|
||||
const ns = direction === Direction.north || direction === Direction.south;
|
||||
const nwse =
|
||||
(direction & Direction.north && direction & Direction.west) ||
|
||||
(direction & Direction.south && direction & Direction.east);
|
||||
|
||||
const cursorDir = ew ? 'ew' : ns ? 'ns' : nwse ? 'nwse' : 'nesw';
|
||||
|
||||
if (editorRootElement !== null) {
|
||||
editorRootElement.style.setProperty(
|
||||
'cursor',
|
||||
`${cursorDir}-resize`,
|
||||
'important',
|
||||
);
|
||||
}
|
||||
if (document.body !== null) {
|
||||
document.body.style.setProperty(
|
||||
'cursor',
|
||||
`${cursorDir}-resize`,
|
||||
'important',
|
||||
);
|
||||
userSelect.current.value = document.body.style.getPropertyValue(
|
||||
'-webkit-user-select',
|
||||
);
|
||||
userSelect.current.priority = document.body.style.getPropertyPriority(
|
||||
'-webkit-user-select',
|
||||
);
|
||||
document.body.style.setProperty(
|
||||
'-webkit-user-select',
|
||||
`none`,
|
||||
'important',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const setEndCursor = () => {
|
||||
if (editorRootElement !== null) {
|
||||
editorRootElement.style.setProperty('cursor', 'text');
|
||||
}
|
||||
if (document.body !== null) {
|
||||
document.body.style.setProperty('cursor', 'default');
|
||||
document.body.style.setProperty(
|
||||
'-webkit-user-select',
|
||||
userSelect.current.value,
|
||||
userSelect.current.priority,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
direction: number,
|
||||
) => {
|
||||
if (!editor.isEditable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const image = imageRef.current;
|
||||
const controlWrapper = controlWrapperRef.current;
|
||||
|
||||
if (image !== null && controlWrapper !== null) {
|
||||
event.preventDefault();
|
||||
const { width, height } = image.getBoundingClientRect();
|
||||
const zoom = calculateZoomLevel(image);
|
||||
const positioning = positioningRef.current;
|
||||
positioning.startWidth = width;
|
||||
positioning.startHeight = height;
|
||||
positioning.ratio = width / height;
|
||||
positioning.currentWidth = width;
|
||||
positioning.currentHeight = height;
|
||||
positioning.startX = event.clientX / zoom;
|
||||
positioning.startY = event.clientY / zoom;
|
||||
positioning.isResizing = true;
|
||||
positioning.direction = direction;
|
||||
|
||||
setStartCursor(direction);
|
||||
onResizeStart();
|
||||
|
||||
controlWrapper.classList.add('image-control-wrapper--resizing');
|
||||
image.style.height = `${height}px`;
|
||||
image.style.width = `${width}px`;
|
||||
|
||||
document.addEventListener('pointermove', handlePointerMove);
|
||||
document.addEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const image = imageRef.current;
|
||||
const positioning = positioningRef.current;
|
||||
|
||||
const isHorizontal =
|
||||
positioning.direction & (Direction.east | Direction.west);
|
||||
const isVertical =
|
||||
positioning.direction & (Direction.south | Direction.north);
|
||||
|
||||
if (image !== null && positioning.isResizing) {
|
||||
const zoom = calculateZoomLevel(image);
|
||||
if (isHorizontal && isVertical) {
|
||||
let diff = Math.floor(positioning.startX - event.clientX / zoom);
|
||||
diff = positioning.direction & Direction.east ? -diff : diff;
|
||||
|
||||
const width = clamp(
|
||||
positioning.startWidth + diff,
|
||||
minWidth,
|
||||
maxWidthContainer,
|
||||
);
|
||||
|
||||
const height = width / positioning.ratio;
|
||||
image.style.width = `${width}px`;
|
||||
image.style.height = `${height}px`;
|
||||
positioning.currentHeight = height;
|
||||
positioning.currentWidth = width;
|
||||
} else if (isVertical) {
|
||||
let diff = Math.floor(positioning.startY - event.clientY / zoom);
|
||||
diff = positioning.direction & Direction.south ? -diff : diff;
|
||||
|
||||
const height = clamp(
|
||||
positioning.startHeight + diff,
|
||||
minHeight,
|
||||
maxHeightContainer,
|
||||
);
|
||||
|
||||
image.style.height = `${height}px`;
|
||||
positioning.currentHeight = height;
|
||||
} else {
|
||||
let diff = Math.floor(positioning.startX - event.clientX / zoom);
|
||||
diff = positioning.direction & Direction.east ? -diff : diff;
|
||||
|
||||
const width = clamp(
|
||||
positioning.startWidth + diff,
|
||||
minWidth,
|
||||
maxWidthContainer,
|
||||
);
|
||||
|
||||
image.style.width = `${width}px`;
|
||||
positioning.currentWidth = width;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
const image = imageRef.current;
|
||||
const positioning = positioningRef.current;
|
||||
const controlWrapper = controlWrapperRef.current;
|
||||
if (image !== null && controlWrapper !== null && positioning.isResizing) {
|
||||
const width = positioning.currentWidth;
|
||||
const height = positioning.currentHeight;
|
||||
positioning.startWidth = 0;
|
||||
positioning.startHeight = 0;
|
||||
positioning.ratio = 0;
|
||||
positioning.startX = 0;
|
||||
positioning.startY = 0;
|
||||
positioning.currentWidth = 0;
|
||||
positioning.currentHeight = 0;
|
||||
positioning.isResizing = false;
|
||||
|
||||
controlWrapper.classList.remove('image-control-wrapper--resizing');
|
||||
|
||||
setEndCursor();
|
||||
onResizeEnd(width, height);
|
||||
|
||||
document.removeEventListener('pointermove', handlePointerMove);
|
||||
document.removeEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={controlWrapperRef}>
|
||||
<div
|
||||
className="image-resizer image-resizer-n"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.north);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-ne"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.north | Direction.east);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-e"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.east);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-se"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.south | Direction.east);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-s"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.south);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-sw"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.south | Direction.west);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-w"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.west);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-nw"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.north | Direction.west);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user