diff --git a/src/editor/RichTextEditor.tsx b/src/editor/RichTextEditor.tsx index a16e35f..0169156 100644 --- a/src/editor/RichTextEditor.tsx +++ b/src/editor/RichTextEditor.tsx @@ -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() { + + diff --git a/src/editor/nodes/ImageComponent.tsx b/src/editor/nodes/ImageComponent.tsx new file mode 100644 index 0000000..fe7d7c6 --- /dev/null +++ b/src/editor/nodes/ImageComponent.tsx @@ -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 | 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((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 ( + Broken image + ); + } + + return ( + {altText} + ); +} + +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); + const [isSelected, setSelected, clearSelection] = + useLexicalNodeSelection(nodeKey); + const [isResizing, setIsResizing] = useState(false); + const [editor] = useLexicalComposerContext(); + const [isLoadError, setIsLoadError] = useState(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( + 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 ( + <> +
+ {isLoadError ? ( + Broken image + ) : ( + setIsLoadError(true)} + /> + )} +
+ + {resizable && isSelected && isFocused && ( + + )} + + ); +} diff --git a/src/editor/nodes/ImageNode.tsx b/src/editor/nodes/ImageNode.tsx new file mode 100644 index 0000000..898c9fc --- /dev/null +++ b/src/editor/nodes/ImageNode.tsx @@ -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 { + __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 ( + + + + ); + } +} + +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; +} diff --git a/src/editor/nodes/ImageResizer.tsx b/src/editor/nodes/ImageResizer.tsx new file mode 100644 index 0000000..b8ca26f --- /dev/null +++ b/src/editor/nodes/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); + }} + /> +
+ ); +} 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 ![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 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 (![alt](url)) 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); + }} + /> +
+ ); +}