{
+ handlePointerDown(event, Direction.south | Direction.west);
+ }}
+ />
+
{
+ handlePointerDown(event, Direction.west);
+ }}
+ />
+
{
+ handlePointerDown(event, Direction.north | Direction.west);
+ }}
+ />
+
+ );
+}
diff --git a/src/editor/plugins/DragDropPastePlugin.tsx b/src/editor/plugins/DragDropPastePlugin.tsx
new file mode 100644
index 0000000..cceb6d4
--- /dev/null
+++ b/src/editor/plugins/DragDropPastePlugin.tsx
@@ -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;
+}
diff --git a/src/editor/plugins/ImagesPlugin.tsx b/src/editor/plugins/ImagesPlugin.tsx
new file mode 100644
index 0000000..d15afc3
--- /dev/null
+++ b/src/editor/plugins/ImagesPlugin.tsx
@@ -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
;
+
+export const INSERT_IMAGE_COMMAND: LexicalCommand =
+ 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(
+ 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;
+}
diff --git a/src/editor/plugins/MarkdownTransformers.ts b/src/editor/plugins/MarkdownTransformers.ts
index 7a939b4..c08b1b1 100644
--- a/src/editor/plugins/MarkdownTransformers.ts
+++ b/src/editor/plugins/MarkdownTransformers.ts
@@ -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 = [
TABLE,
HR,
+ IMAGE,
CHECK_LIST,
...ELEMENT_TRANSFORMERS,
...MULTILINE_ELEMENT_TRANSFORMERS,
diff --git a/src/editor/styles/editor.css b/src/editor/styles/editor.css
index 703cce0..c83edf1 100644
--- a/src/editor/styles/editor.css
+++ b/src/editor/styles/editor.css
@@ -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;
+}
diff --git a/src/editor/themes/EditorTheme.ts b/src/editor/themes/EditorTheme.ts
index dc32e65..278497e 100644
--- a/src/editor/themes/EditorTheme.ts
+++ b/src/editor/themes/EditorTheme.ts
@@ -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;
diff --git a/src/editor/ui/ImageResizer.tsx b/src/editor/ui/ImageResizer.tsx
new file mode 100644
index 0000000..b8ca26f
--- /dev/null
+++ b/src/editor/ui/ImageResizer.tsx
@@ -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(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,
+ 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 (
+
+
{
+ handlePointerDown(event, Direction.north);
+ }}
+ />
+
{
+ handlePointerDown(event, Direction.north | Direction.east);
+ }}
+ />
+
{
+ handlePointerDown(event, Direction.east);
+ }}
+ />
+
{
+ handlePointerDown(event, Direction.south | Direction.east);
+ }}
+ />
+
{
+ handlePointerDown(event, Direction.south);
+ }}
+ />
+
{
+ handlePointerDown(event, Direction.south | Direction.west);
+ }}
+ />
+
{
+ handlePointerDown(event, Direction.west);
+ }}
+ />
+
{
+ handlePointerDown(event, Direction.north | Direction.west);
+ }}
+ />
+
+ );
+}