/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ import type { BaseSelection, LexicalCommand, LexicalEditor, NodeKey, } from 'lexical'; import './ImageNode.css'; import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin'; import {useCollaborationContext} from '@lexical/react/LexicalCollaborationContext'; import {CollaborationPlugin} from '@lexical/react/LexicalCollaborationPlugin'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary'; import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin'; import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin'; import {LexicalNestedComposer} from '@lexical/react/LexicalNestedComposer'; import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection'; import {mergeRegister} from '@lexical/utils'; import { $getNodeByKey, $getSelection, $isNodeSelection, $isRangeSelection, $setSelection, CLICK_COMMAND, COMMAND_PRIORITY_LOW, createCommand, DRAGSTART_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, SELECTION_CHANGE_COMMAND, } from 'lexical'; import * as React from 'react'; import {Suspense, useCallback, useEffect, useRef, useState} from 'react'; import {createWebsocketProvider} from '../collaboration'; import {useSettings} from '../context/SettingsContext'; import {useSharedHistoryContext} from '../context/SharedHistoryContext'; import EmojisPlugin from '../plugins/EmojisPlugin'; import KeywordsPlugin from '../plugins/KeywordsPlugin'; import LinkPlugin from '../plugins/LinkPlugin'; import MentionsPlugin from '../plugins/MentionsPlugin'; import TreeViewPlugin from '../plugins/TreeViewPlugin'; import ContentEditable from '../ui/ContentEditable'; import ImageResizer from '../ui/ImageResizer'; import Placeholder from '../ui/Placeholder'; import {$isImageNode} from './ImageNode'; const imageCache = new Set(); export const RIGHT_CLICK_IMAGE_COMMAND: LexicalCommand = createCommand('RIGHT_CLICK_IMAGE_COMMAND'); function useSuspenseImage(src: string) { if (!imageCache.has(src)) { throw new Promise((resolve) => { const img = new Image(); img.src = src; img.onload = () => { imageCache.add(src); resolve(null); }; }); } } function LazyImage({ altText, className, imageRef, src, width, height, maxWidth, }: { altText: string; className: string | null; height: 'inherit' | number; imageRef: {current: null | HTMLImageElement}; maxWidth: number; src: string; width: 'inherit' | number; }): JSX.Element { useSuspenseImage(src); return ( {altText} ); } export default function ImageComponent({ src, altText, nodeKey, width, height, maxWidth, resizable, showCaption, caption, captionsEnabled, }: { altText: string; caption: LexicalEditor; height: 'inherit' | number; maxWidth: number; nodeKey: NodeKey; resizable: boolean; showCaption: boolean; src: string; width: 'inherit' | number; captionsEnabled: boolean; }): JSX.Element { const imageRef = useRef(null); const buttonRef = useRef(null); const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey); const [isResizing, setIsResizing] = useState(false); const {isCollabActive} = useCollaborationContext(); const [editor] = useLexicalComposerContext(); const [selection, setSelection] = useState(null); const activeEditorRef = useRef(null); const onDelete = useCallback( (payload: KeyboardEvent) => { if (isSelected && $isNodeSelection($getSelection())) { const event: KeyboardEvent = payload; event.preventDefault(); const node = $getNodeByKey(nodeKey); if ($isImageNode(node)) { node.remove(); } } return false; }, [isSelected, nodeKey], ); const onEnter = useCallback( (event: KeyboardEvent) => { const latestSelection = $getSelection(); const buttonElem = buttonRef.current; if ( isSelected && $isNodeSelection(latestSelection) && latestSelection.getNodes().length === 1 ) { if (showCaption) { // Move focus into nested editor $setSelection(null); event.preventDefault(); caption.focus(); return true; } else if ( buttonElem !== null && buttonElem !== document.activeElement ) { event.preventDefault(); buttonElem.focus(); return true; } } return false; }, [caption, isSelected, showCaption], ); const onEscape = useCallback( (event: KeyboardEvent) => { if ( activeEditorRef.current === caption || buttonRef.current === event.target ) { $setSelection(null); editor.update(() => { setSelected(true); const parentRootElement = editor.getRootElement(); if (parentRootElement !== null) { parentRootElement.focus(); } }); return true; } return false; }, [caption, editor, 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], ); const onRightClick = useCallback( (event: MouseEvent): void => { editor.getEditorState().read(() => { const latestSelection = $getSelection(); const domElement = event.target as HTMLElement; if ( domElement.tagName === 'IMG' && $isRangeSelection(latestSelection) && latestSelection.getNodes().length === 1 ) { editor.dispatchCommand( RIGHT_CLICK_IMAGE_COMMAND, event as MouseEvent, ); } }); }, [editor], ); useEffect(() => { let isMounted = true; const rootElement = editor.getRootElement(); const unregister = mergeRegister( editor.registerUpdateListener(({editorState}) => { if (isMounted) { setSelection(editorState.read(() => $getSelection())); } }), editor.registerCommand( SELECTION_CHANGE_COMMAND, (_, activeEditor) => { activeEditorRef.current = activeEditor; return false; }, COMMAND_PRIORITY_LOW, ), editor.registerCommand( CLICK_COMMAND, onClick, COMMAND_PRIORITY_LOW, ), editor.registerCommand( RIGHT_CLICK_IMAGE_COMMAND, onClick, COMMAND_PRIORITY_LOW, ), editor.registerCommand( DRAGSTART_COMMAND, (event) => { if (event.target === imageRef.current) { // TODO This is just a temporary workaround for FF to behave like other browsers. // Ideally, this handles drag & drop too (and all browsers). event.preventDefault(); return true; } return false; }, COMMAND_PRIORITY_LOW, ), editor.registerCommand( KEY_DELETE_COMMAND, onDelete, COMMAND_PRIORITY_LOW, ), editor.registerCommand( KEY_BACKSPACE_COMMAND, onDelete, COMMAND_PRIORITY_LOW, ), editor.registerCommand(KEY_ENTER_COMMAND, onEnter, COMMAND_PRIORITY_LOW), editor.registerCommand( KEY_ESCAPE_COMMAND, onEscape, COMMAND_PRIORITY_LOW, ), ); rootElement?.addEventListener('contextmenu', onRightClick); return () => { isMounted = false; unregister(); rootElement?.removeEventListener('contextmenu', onRightClick); }; }, [ clearSelection, editor, isResizing, isSelected, nodeKey, onDelete, onEnter, onEscape, onClick, onRightClick, setSelected, ]); const setShowCaption = () => { editor.update(() => { const node = $getNodeByKey(nodeKey); if ($isImageNode(node)) { node.setShowCaption(true); } }); }; const onResizeEnd = ( nextWidth: 'inherit' | number, nextHeight: 'inherit' | number, ) => { // Delay hiding the resize bars for click case setTimeout(() => { setIsResizing(false); }, 200); editor.update(() => { const node = $getNodeByKey(nodeKey); if ($isImageNode(node)) { node.setWidthAndHeight(nextWidth, nextHeight); } }); }; const onResizeStart = () => { setIsResizing(true); }; const {historyState} = useSharedHistoryContext(); const { settings: {showNestedEditorTreeView}, } = useSettings(); const draggable = isSelected && $isNodeSelection(selection) && !isResizing; const isFocused = isSelected || isResizing; return ( <>
{showCaption && (
{isCollabActive ? ( ) : ( )} } placeholder={ Enter a caption... } ErrorBoundary={LexicalErrorBoundary} /> {showNestedEditorTreeView === true ? : null}
)} {resizable && $isNodeSelection(selection) && isFocused && ( )}
); }