feat: add image handling capabilities to rich text editor with drag-and-drop support, resizing, and markdown integration

This commit is contained in:
2025-10-22 17:38:06 +08:00
parent 3491ae339d
commit 79fe02e307
10 changed files with 1287 additions and 1 deletions

View File

@@ -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>

View 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}
/>
)}
</>
);
}

View 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;
}

View 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>
);
}

View 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;
}

View 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;
}

View File

@@ -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 ![alt](url)
export const IMAGE: TextMatchTransformer = {
dependencies: [],
export: (node) => {
if (!$isImageNode(node)) {
return null;
}
return `![${node.getAltText()}](${node.getSrc()})`;
},
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 (![alt](url))
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,

View File

@@ -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;
}

View File

@@ -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;

View 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>
);
}