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 { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
||||||
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin';
|
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin';
|
||||||
|
|
||||||
|
import { ImageNode } from './nodes/ImageNode';
|
||||||
import ToolbarPlugin from './plugins/ToolbarPlugin';
|
import ToolbarPlugin from './plugins/ToolbarPlugin';
|
||||||
import MarkdownPlugin from './plugins/MarkdownShortcutPlugin';
|
import MarkdownPlugin from './plugins/MarkdownShortcutPlugin';
|
||||||
|
import ImagesPlugin from './plugins/ImagesPlugin';
|
||||||
|
import DragDropPastePlugin from './plugins/DragDropPastePlugin';
|
||||||
import editorTheme from './themes/EditorTheme';
|
import editorTheme from './themes/EditorTheme';
|
||||||
import './styles/editor.css';
|
import './styles/editor.css';
|
||||||
|
|
||||||
@@ -74,6 +77,7 @@ const editorConfig = {
|
|||||||
TableCellNode,
|
TableCellNode,
|
||||||
LinkNode,
|
LinkNode,
|
||||||
AutoLinkNode,
|
AutoLinkNode,
|
||||||
|
ImageNode,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,6 +102,8 @@ export default function RichTextEditor() {
|
|||||||
<LinkPlugin />
|
<LinkPlugin />
|
||||||
<AutoLinkPlugin matchers={MATCHERS} />
|
<AutoLinkPlugin matchers={MATCHERS} />
|
||||||
<TablePlugin />
|
<TablePlugin />
|
||||||
|
<ImagesPlugin />
|
||||||
|
<DragDropPastePlugin />
|
||||||
<MarkdownPlugin />
|
<MarkdownPlugin />
|
||||||
</div>
|
</div>
|
||||||
</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 {
|
import {
|
||||||
CHECK_LIST,
|
CHECK_LIST,
|
||||||
ELEMENT_TRANSFORMERS,
|
ELEMENT_TRANSFORMERS,
|
||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
type LexicalNode,
|
type LexicalNode,
|
||||||
} from 'lexical';
|
} from 'lexical';
|
||||||
|
|
||||||
|
import { $createImageNode, $isImageNode } from '../nodes/ImageNode';
|
||||||
|
|
||||||
// Horizontal Rule transformer (---, ***, ___)
|
// Horizontal Rule transformer (---, ***, ___)
|
||||||
export const HR: ElementTransformer = {
|
export const HR: ElementTransformer = {
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
@@ -49,6 +51,31 @@ export const HR: ElementTransformer = {
|
|||||||
type: 'element',
|
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
|
// Table transformer
|
||||||
const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/;
|
const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/;
|
||||||
const TABLE_ROW_DIVIDER_REG_EXP = /^(\| ?:?-*:? ?)+\|\s?$/;
|
const TABLE_ROW_DIVIDER_REG_EXP = /^(\| ?:?-*:? ?)+\|\s?$/;
|
||||||
@@ -217,10 +244,12 @@ export const TABLE: ElementTransformer = {
|
|||||||
// - Code blocks (```)
|
// - Code blocks (```)
|
||||||
// - Horizontal rules (---, ***, ___)
|
// - Horizontal rules (---, ***, ___)
|
||||||
// - Tables (| col1 | col2 |)
|
// - Tables (| col1 | col2 |)
|
||||||
|
// - Images ()
|
||||||
|
|
||||||
export const EDITOR_TRANSFORMERS: Array<Transformer> = [
|
export const EDITOR_TRANSFORMERS: Array<Transformer> = [
|
||||||
TABLE,
|
TABLE,
|
||||||
HR,
|
HR,
|
||||||
|
IMAGE,
|
||||||
CHECK_LIST,
|
CHECK_LIST,
|
||||||
...ELEMENT_TRANSFORMERS,
|
...ELEMENT_TRANSFORMERS,
|
||||||
...MULTILINE_ELEMENT_TRANSFORMERS,
|
...MULTILINE_ELEMENT_TRANSFORMERS,
|
||||||
|
|||||||
@@ -246,3 +246,91 @@
|
|||||||
padding-left: 24px;
|
padding-left: 24px;
|
||||||
padding-right: 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',
|
tableCell: 'editor-table-cell',
|
||||||
tableCellHeader: 'editor-table-cell-header',
|
tableCellHeader: 'editor-table-cell-header',
|
||||||
hr: 'editor-hr',
|
hr: 'editor-hr',
|
||||||
|
image: 'editor-image',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default theme;
|
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