feat: add image handling capabilities to rich text editor with drag-and-drop support, resizing, and markdown integration
This commit is contained in:
@@ -15,8 +15,11 @@ import { LinkNode, AutoLinkNode } from '@lexical/link';
|
||||
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
||||
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin';
|
||||
|
||||
import { ImageNode } from './nodes/ImageNode';
|
||||
import ToolbarPlugin from './plugins/ToolbarPlugin';
|
||||
import MarkdownPlugin from './plugins/MarkdownShortcutPlugin';
|
||||
import ImagesPlugin from './plugins/ImagesPlugin';
|
||||
import DragDropPastePlugin from './plugins/DragDropPastePlugin';
|
||||
import editorTheme from './themes/EditorTheme';
|
||||
import './styles/editor.css';
|
||||
|
||||
@@ -74,6 +77,7 @@ const editorConfig = {
|
||||
TableCellNode,
|
||||
LinkNode,
|
||||
AutoLinkNode,
|
||||
ImageNode,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -98,6 +102,8 @@ export default function RichTextEditor() {
|
||||
<LinkPlugin />
|
||||
<AutoLinkPlugin matchers={MATCHERS} />
|
||||
<TablePlugin />
|
||||
<ImagesPlugin />
|
||||
<DragDropPastePlugin />
|
||||
<MarkdownPlugin />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
275
src/editor/nodes/ImageComponent.tsx
Normal file
275
src/editor/nodes/ImageComponent.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import type { NodeKey } from 'lexical';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
|
||||
import { mergeRegister } from '@lexical/utils';
|
||||
import {
|
||||
$getNodeByKey,
|
||||
$setSelection,
|
||||
CLICK_COMMAND,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
DRAGSTART_COMMAND,
|
||||
KEY_ESCAPE_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import ImageResizer from './ImageResizer';
|
||||
import { $isImageNode } from './ImageNode';
|
||||
|
||||
type ImageStatus =
|
||||
| { error: true }
|
||||
| { error: false; width: number; height: number };
|
||||
|
||||
const imageCache = new Map<string, Promise<ImageStatus> | ImageStatus>();
|
||||
|
||||
function useSuspenseImage(src: string): ImageStatus {
|
||||
let cached = imageCache.get(src);
|
||||
if (cached && 'error' in cached && typeof cached.error === 'boolean') {
|
||||
return cached;
|
||||
} else if (!cached) {
|
||||
cached = new Promise<ImageStatus>((resolve) => {
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
img.onload = () =>
|
||||
resolve({
|
||||
error: false,
|
||||
height: img.naturalHeight,
|
||||
width: img.naturalWidth,
|
||||
});
|
||||
img.onerror = () => resolve({ error: true });
|
||||
}).then((rval) => {
|
||||
imageCache.set(src, rval);
|
||||
return rval;
|
||||
});
|
||||
imageCache.set(src, cached);
|
||||
throw cached;
|
||||
}
|
||||
throw cached;
|
||||
}
|
||||
|
||||
function LazyImage({
|
||||
altText,
|
||||
className,
|
||||
imageRef,
|
||||
src,
|
||||
width,
|
||||
height,
|
||||
maxWidth,
|
||||
onError,
|
||||
}: {
|
||||
altText: string;
|
||||
className: string | null;
|
||||
height: 'inherit' | number;
|
||||
imageRef: { current: null | HTMLImageElement };
|
||||
maxWidth: number;
|
||||
src: string;
|
||||
width: 'inherit' | number;
|
||||
onError: () => void;
|
||||
}): JSX.Element {
|
||||
const status = useSuspenseImage(src);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.error) {
|
||||
onError();
|
||||
}
|
||||
}, [status.error, onError]);
|
||||
|
||||
if (status.error) {
|
||||
return (
|
||||
<img
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect fill='%23f0f0f0' width='200' height='200'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23999' font-family='sans-serif'%3EImage not found%3C/text%3E%3C/svg%3E"
|
||||
style={{
|
||||
height: 200,
|
||||
opacity: 0.3,
|
||||
width: 200,
|
||||
}}
|
||||
draggable="false"
|
||||
alt="Broken image"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className={className || undefined}
|
||||
src={src}
|
||||
alt={altText}
|
||||
ref={imageRef}
|
||||
style={{
|
||||
height: height === 'inherit' ? 'auto' : height,
|
||||
maxWidth,
|
||||
width: width === 'inherit' ? '100%' : width,
|
||||
}}
|
||||
onError={onError}
|
||||
draggable="false"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImageComponent({
|
||||
src,
|
||||
altText,
|
||||
nodeKey,
|
||||
width,
|
||||
height,
|
||||
maxWidth,
|
||||
resizable,
|
||||
}: {
|
||||
altText: string;
|
||||
height: 'inherit' | number;
|
||||
maxWidth: number;
|
||||
nodeKey: NodeKey;
|
||||
resizable: boolean;
|
||||
src: string;
|
||||
width: 'inherit' | number;
|
||||
}): JSX.Element {
|
||||
const imageRef = useRef<null | HTMLImageElement>(null);
|
||||
const [isSelected, setSelected, clearSelection] =
|
||||
useLexicalNodeSelection(nodeKey);
|
||||
const [isResizing, setIsResizing] = useState<boolean>(false);
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [isLoadError, setIsLoadError] = useState<boolean>(false);
|
||||
|
||||
const $onEscape = useCallback(
|
||||
() => {
|
||||
if (isSelected) {
|
||||
$setSelection(null);
|
||||
editor.update(() => {
|
||||
setSelected(true);
|
||||
const parentRootElement = editor.getRootElement();
|
||||
if (parentRootElement !== null) {
|
||||
parentRootElement.focus();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[editor, isSelected, setSelected],
|
||||
);
|
||||
|
||||
const onClick = useCallback(
|
||||
(payload: MouseEvent) => {
|
||||
const event = payload;
|
||||
|
||||
if (isResizing) {
|
||||
return true;
|
||||
}
|
||||
if (event.target === imageRef.current) {
|
||||
if (event.shiftKey) {
|
||||
setSelected(!isSelected);
|
||||
} else {
|
||||
clearSelection();
|
||||
setSelected(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[isResizing, isSelected, setSelected, clearSelection],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
DRAGSTART_COMMAND,
|
||||
(event) => {
|
||||
if (event.target === imageRef.current) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand<MouseEvent>(
|
||||
CLICK_COMMAND,
|
||||
onClick,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
$onEscape,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
);
|
||||
}, [clearSelection, editor, isResizing, isSelected, onClick, $onEscape]);
|
||||
|
||||
const onResizeEnd = (
|
||||
nextWidth: 'inherit' | number,
|
||||
nextHeight: 'inherit' | number,
|
||||
) => {
|
||||
setTimeout(() => {
|
||||
setIsResizing(false);
|
||||
}, 200);
|
||||
|
||||
editor.update(() => {
|
||||
const node = $getNodeByKey(nodeKey);
|
||||
if ($isImageNode(node)) {
|
||||
node.setWidthAndHeight(nextWidth, nextHeight);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onResizeStart = () => {
|
||||
setIsResizing(true);
|
||||
};
|
||||
|
||||
const draggable = isSelected && !isResizing;
|
||||
const isFocused = isSelected || isResizing;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div draggable={draggable}>
|
||||
{isLoadError ? (
|
||||
<img
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect fill='%23f0f0f0' width='200' height='200'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23999' font-family='sans-serif'%3EImage not found%3C/text%3E%3C/svg%3E"
|
||||
style={{
|
||||
height: 200,
|
||||
opacity: 0.3,
|
||||
width: 200,
|
||||
}}
|
||||
draggable="false"
|
||||
alt="Broken image"
|
||||
/>
|
||||
) : (
|
||||
<LazyImage
|
||||
className={isFocused ? 'focused' : null}
|
||||
src={src}
|
||||
altText={altText}
|
||||
imageRef={imageRef}
|
||||
width={width}
|
||||
height={height}
|
||||
maxWidth={maxWidth}
|
||||
onError={() => setIsLoadError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{resizable && isSelected && isFocused && (
|
||||
<ImageResizer
|
||||
editor={editor}
|
||||
imageRef={imageRef}
|
||||
maxWidth={maxWidth}
|
||||
onResizeStart={onResizeStart}
|
||||
onResizeEnd={onResizeEnd}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
207
src/editor/nodes/ImageNode.tsx
Normal file
207
src/editor/nodes/ImageNode.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedLexicalNode,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
DecoratorNode,
|
||||
} from 'lexical';
|
||||
import { Suspense, lazy } from 'react';
|
||||
|
||||
const ImageComponent = lazy(() => import('./ImageComponent'));
|
||||
|
||||
export interface ImagePayload {
|
||||
altText: string;
|
||||
height?: number;
|
||||
key?: NodeKey;
|
||||
maxWidth?: number;
|
||||
src: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
function $convertImageElement(domNode: Node): null | DOMConversionOutput {
|
||||
const img = domNode as HTMLImageElement;
|
||||
const src = img.getAttribute('src');
|
||||
if (!src || src.startsWith('file:///')) {
|
||||
return null;
|
||||
}
|
||||
const { alt: altText, width, height } = img;
|
||||
const node = $createImageNode({ altText, height, src, width });
|
||||
return { node };
|
||||
}
|
||||
|
||||
export type SerializedImageNode = Spread<
|
||||
{
|
||||
altText: string;
|
||||
height?: number;
|
||||
maxWidth: number;
|
||||
src: string;
|
||||
width?: number;
|
||||
},
|
||||
SerializedLexicalNode
|
||||
>;
|
||||
|
||||
export class ImageNode extends DecoratorNode<JSX.Element> {
|
||||
__src: string;
|
||||
__altText: string;
|
||||
__width: 'inherit' | number;
|
||||
__height: 'inherit' | number;
|
||||
__maxWidth: number;
|
||||
|
||||
static getType(): string {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
static clone(node: ImageNode): ImageNode {
|
||||
return new ImageNode(
|
||||
node.__src,
|
||||
node.__altText,
|
||||
node.__maxWidth,
|
||||
node.__width,
|
||||
node.__height,
|
||||
node.__key,
|
||||
);
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedImageNode): ImageNode {
|
||||
const { altText, height, width, maxWidth, src } = serializedNode;
|
||||
return $createImageNode({
|
||||
altText,
|
||||
height,
|
||||
maxWidth,
|
||||
src,
|
||||
width,
|
||||
});
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement('img');
|
||||
element.setAttribute('src', this.__src);
|
||||
element.setAttribute('alt', this.__altText);
|
||||
if (this.__width !== 'inherit') {
|
||||
element.setAttribute('width', this.__width.toString());
|
||||
}
|
||||
if (this.__height !== 'inherit') {
|
||||
element.setAttribute('height', this.__height.toString());
|
||||
}
|
||||
return { element };
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
img: () => ({
|
||||
conversion: $convertImageElement,
|
||||
priority: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
constructor(
|
||||
src: string,
|
||||
altText: string,
|
||||
maxWidth: number,
|
||||
width?: 'inherit' | number,
|
||||
height?: 'inherit' | number,
|
||||
key?: NodeKey,
|
||||
) {
|
||||
super(key);
|
||||
this.__src = src;
|
||||
this.__altText = altText;
|
||||
this.__maxWidth = maxWidth;
|
||||
this.__width = width || 'inherit';
|
||||
this.__height = height || 'inherit';
|
||||
}
|
||||
|
||||
exportJSON(): SerializedImageNode {
|
||||
return {
|
||||
altText: this.getAltText(),
|
||||
height: this.__height === 'inherit' ? 0 : this.__height,
|
||||
maxWidth: this.__maxWidth,
|
||||
src: this.getSrc(),
|
||||
type: 'image',
|
||||
version: 1,
|
||||
width: this.__width === 'inherit' ? 0 : this.__width,
|
||||
};
|
||||
}
|
||||
|
||||
setWidthAndHeight(
|
||||
width: 'inherit' | number,
|
||||
height: 'inherit' | number,
|
||||
): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__width = width;
|
||||
writable.__height = height;
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
const theme = config.theme;
|
||||
const className = theme.image;
|
||||
if (className !== undefined) {
|
||||
span.className = className;
|
||||
}
|
||||
return span;
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
getSrc(): string {
|
||||
return this.__src;
|
||||
}
|
||||
|
||||
getAltText(): string {
|
||||
return this.__altText;
|
||||
}
|
||||
|
||||
decorate(): JSX.Element {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ImageComponent
|
||||
src={this.__src}
|
||||
altText={this.__altText}
|
||||
width={this.__width}
|
||||
height={this.__height}
|
||||
maxWidth={this.__maxWidth}
|
||||
nodeKey={this.getKey()}
|
||||
resizable={true}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function $createImageNode({
|
||||
altText,
|
||||
height,
|
||||
maxWidth = 800,
|
||||
src,
|
||||
width,
|
||||
key,
|
||||
}: ImagePayload): ImageNode {
|
||||
return $applyNodeReplacement(
|
||||
new ImageNode(
|
||||
src,
|
||||
altText,
|
||||
maxWidth,
|
||||
width,
|
||||
height,
|
||||
key,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function $isImageNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is ImageNode {
|
||||
return node instanceof ImageNode;
|
||||
}
|
||||
292
src/editor/nodes/ImageResizer.tsx
Normal file
292
src/editor/nodes/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>
|
||||
);
|
||||
}
|
||||
49
src/editor/plugins/DragDropPastePlugin.tsx
Normal file
49
src/editor/plugins/DragDropPastePlugin.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { DRAG_DROP_PASTE } from '@lexical/rich-text';
|
||||
import { isMimeType, mediaFileReader } from '@lexical/utils';
|
||||
import { COMMAND_PRIORITY_LOW } from 'lexical';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { INSERT_IMAGE_COMMAND } from './ImagesPlugin';
|
||||
|
||||
const ACCEPTABLE_IMAGE_TYPES = [
|
||||
'image/',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/svg+xml',
|
||||
];
|
||||
|
||||
export default function DragDropPastePlugin(): null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
DRAG_DROP_PASTE,
|
||||
(files) => {
|
||||
(async () => {
|
||||
const filesResult = await mediaFileReader(
|
||||
files,
|
||||
[ACCEPTABLE_IMAGE_TYPES].flatMap((x) => x),
|
||||
);
|
||||
for (const { file, result } of filesResult) {
|
||||
if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
|
||||
editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
|
||||
altText: file.name,
|
||||
src: result,
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
return null;
|
||||
}
|
||||
47
src/editor/plugins/ImagesPlugin.tsx
Normal file
47
src/editor/plugins/ImagesPlugin.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { LexicalCommand } from 'lexical';
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { $wrapNodeInElement, mergeRegister } from '@lexical/utils';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$insertNodes,
|
||||
$isRootOrShadowRoot,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
createCommand,
|
||||
} from 'lexical';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { $createImageNode, ImageNode, type ImagePayload } from '../nodes/ImageNode';
|
||||
|
||||
export type InsertImagePayload = Readonly<ImagePayload>;
|
||||
|
||||
export const INSERT_IMAGE_COMMAND: LexicalCommand<InsertImagePayload> =
|
||||
createCommand('INSERT_IMAGE_COMMAND');
|
||||
|
||||
export default function ImagesPlugin(): null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([ImageNode])) {
|
||||
throw new Error('ImagesPlugin: ImageNode not registered on editor');
|
||||
}
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand<InsertImagePayload>(
|
||||
INSERT_IMAGE_COMMAND,
|
||||
(payload) => {
|
||||
const imageNode = $createImageNode(payload);
|
||||
$insertNodes([imageNode]);
|
||||
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
|
||||
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ElementTransformer, Transformer } from '@lexical/markdown';
|
||||
import type { ElementTransformer, TextMatchTransformer, Transformer } from '@lexical/markdown';
|
||||
import {
|
||||
CHECK_LIST,
|
||||
ELEMENT_TRANSFORMERS,
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
type LexicalNode,
|
||||
} from 'lexical';
|
||||
|
||||
import { $createImageNode, $isImageNode } from '../nodes/ImageNode';
|
||||
|
||||
// Horizontal Rule transformer (---, ***, ___)
|
||||
export const HR: ElementTransformer = {
|
||||
dependencies: [],
|
||||
@@ -49,6 +51,31 @@ export const HR: ElementTransformer = {
|
||||
type: 'element',
|
||||
};
|
||||
|
||||
// Image transformer 
|
||||
export const IMAGE: TextMatchTransformer = {
|
||||
dependencies: [],
|
||||
export: (node) => {
|
||||
if (!$isImageNode(node)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `})`;
|
||||
},
|
||||
importRegExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))/,
|
||||
regExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))$/,
|
||||
replace: (textNode, match) => {
|
||||
const [, altText, src] = match;
|
||||
const imageNode = $createImageNode({
|
||||
altText,
|
||||
maxWidth: 800,
|
||||
src,
|
||||
});
|
||||
textNode.replace(imageNode);
|
||||
},
|
||||
trigger: ')',
|
||||
type: 'text-match',
|
||||
};
|
||||
|
||||
// Table transformer
|
||||
const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/;
|
||||
const TABLE_ROW_DIVIDER_REG_EXP = /^(\| ?:?-*:? ?)+\|\s?$/;
|
||||
@@ -217,10 +244,12 @@ export const TABLE: ElementTransformer = {
|
||||
// - Code blocks (```)
|
||||
// - Horizontal rules (---, ***, ___)
|
||||
// - Tables (| col1 | col2 |)
|
||||
// - Images ()
|
||||
|
||||
export const EDITOR_TRANSFORMERS: Array<Transformer> = [
|
||||
TABLE,
|
||||
HR,
|
||||
IMAGE,
|
||||
CHECK_LIST,
|
||||
...ELEMENT_TRANSFORMERS,
|
||||
...MULTILINE_ELEMENT_TRANSFORMERS,
|
||||
|
||||
@@ -246,3 +246,91 @@
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
/* Image */
|
||||
.editor-image {
|
||||
cursor: default;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.editor-image img {
|
||||
max-width: 100%;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.editor-image img.focused {
|
||||
outline: 2px solid rgb(60, 132, 244);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.editor-image img.focused.draggable {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.editor-image img.focused.draggable:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.image-control-wrapper--resizing {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Image Resizer */
|
||||
.image-resizer {
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
position: absolute;
|
||||
background-color: rgb(60, 132, 244);
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.image-resizer.image-resizer-n {
|
||||
top: -6px;
|
||||
left: 48%;
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.image-resizer.image-resizer-ne {
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
cursor: ne-resize;
|
||||
}
|
||||
|
||||
.image-resizer.image-resizer-e {
|
||||
bottom: 48%;
|
||||
right: -6px;
|
||||
cursor: e-resize;
|
||||
}
|
||||
|
||||
.image-resizer.image-resizer-se {
|
||||
bottom: -2px;
|
||||
right: -6px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.image-resizer.image-resizer-s {
|
||||
bottom: -2px;
|
||||
left: 48%;
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
.image-resizer.image-resizer-sw {
|
||||
bottom: -2px;
|
||||
left: -6px;
|
||||
cursor: sw-resize;
|
||||
}
|
||||
|
||||
.image-resizer.image-resizer-w {
|
||||
bottom: 48%;
|
||||
left: -6px;
|
||||
cursor: w-resize;
|
||||
}
|
||||
|
||||
.image-resizer.image-resizer-nw {
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
cursor: nw-resize;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ const theme: EditorThemeClasses = {
|
||||
tableCell: 'editor-table-cell',
|
||||
tableCellHeader: 'editor-table-cell-header',
|
||||
hr: 'editor-hr',
|
||||
image: 'editor-image',
|
||||
};
|
||||
|
||||
export default theme;
|
||||
|
||||
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