/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the Chameleon License found in the * LICENSE file in the root directory of this source tree. * */ import type { GridSelection, LexicalEditor, NodeKey, NodeSelection, RangeSelection, } from "lexical"; import "./ImageNode.css"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { useLexicalNodeSelection } from "@lexical/react/useLexicalNodeSelection"; import { mergeRegister } from "@lexical/utils"; import { $getNodeByKey, $getSelection, $isNodeSelection, $setSelection, CLICK_COMMAND, COMMAND_PRIORITY_LOW, 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 { $isImageNode } from "./ImageNode"; const imageCache = new Set(); 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, showCaption, caption, }: { altText: string; caption: LexicalEditor; height: "inherit" | number; maxWidth: number; nodeKey: NodeKey; showCaption: boolean; src: string; width: "inherit" | number; }): JSX.Element { const imageRef = useRef(null); const buttonRef = useRef(null); const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey); const [editor] = useLexicalComposerContext(); const [selection, setSelection] = useState< RangeSelection | NodeSelection | GridSelection | null >(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], ); useEffect(() => { let isMounted = true; 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, (payload) => { const event = payload; if (event.target === imageRef.current) { if (event.shiftKey) { setSelected(!isSelected); } else { clearSelection(); setSelected(true); } return true; } return false; }, 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, ), ); return () => { isMounted = false; unregister(); }; }, [ clearSelection, editor, isSelected, nodeKey, onDelete, onEnter, onEscape, setSelected, ]); const draggable = isSelected && $isNodeSelection(selection); const isFocused = isSelected; return ( <>
); }