UNPKG

phx-react

Version:

PHX REACT

186 lines • 9.04 kB
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; 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, $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 FloatingLinkEditorPlugin from '../plugins/FloatingLinkEditorPlugin'; import FloatingTextFormatToolbarPlugin from '../plugins/FloatingTextFormatToolbarPlugin'; import LinkPlugin from '../plugins/LinkPlugin'; import ContentEditable from '../ui/ContentEditable'; import ImageResizer from '../ui/ImageResizer'; import Placeholder from '../ui/Placeholder'; import { $isInlineImageNode } from './InlineImageNode'; import { LexicalErrorBoundary } from '../shared/LexicalErrorBoundary'; const imageCache = new Set(); /** * Suspends rendering until an image source has loaded. * @param src Image source URL to preload. */ function useSuspenseImage(src) { if (!imageCache.has(src)) { throw new Promise((resolve) => { const img = new Image(); img.src = src; img.onload = () => { imageCache.add(src); resolve(null); }; }); } } /** * Renders an image after ensuring its source is loaded. * @param props Lazy image render props. * @returns Rendered image element. */ function LazyImage({ altText, className, height, imageRef, position, src, width, }) { useSuspenseImage(src); return (React.createElement("img", { ref: imageRef, alt: altText, className: className || undefined, "data-position": position, draggable: 'false', src: src, style: { display: 'block', height, width, } })); } /** * Renders an inline image node with selection, resize, and caption behavior. * @param props Inline image component props. * @returns Inline image decorator element. */ export default function InlineImageComponent({ altText, caption, height, nodeKey, position, showCaption, src, width, }) { const imageRef = useRef(null); const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey); const [isResizing, setIsResizing] = useState(false); const [editor] = useLexicalComposerContext(); const [selection, setSelection] = useState(null); const activeEditorRef = useRef(null); /** * Removes the selected inline image when delete or backspace is pressed. * @param payload Keyboard event from the editor command. * @returns False to allow other command handlers to continue. */ const onDelete = useCallback((payload) => { if (isSelected && $isNodeSelection($getSelection())) { const event = payload; event.preventDefault(); const node = $getNodeByKey(nodeKey); if ($isInlineImageNode(node)) { node.remove(); } } return false; }, [isSelected, nodeKey]); /** * Moves focus into the caption editor when enter is pressed on a selected image. * @param event Keyboard event from the editor command. * @returns True when focus moved into the caption editor. */ const onEnter = useCallback((event) => { const latestSelection = $getSelection(); if (isSelected && showCaption && $isNodeSelection(latestSelection) && latestSelection.getNodes().length === 1) { // Move focus into nested editor $setSelection(null); event.preventDefault(); caption.focus(); return true; } return false; }, [caption, isSelected, showCaption]); /** * Restores focus from the caption editor back to the inline image node. * @returns True when focus was restored to the parent editor. */ const onEscape = useCallback(() => { if (activeEditorRef.current === caption) { $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 (isResizing) { return true; } 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, isResizing, isSelected, nodeKey, onDelete, onEnter, onEscape, setSelected]); /** * Persists resized image dimensions back to the Lexical node. * @param nextWidth Next image width. * @param nextHeight Next image height. */ const onResizeEnd = (nextWidth, nextHeight) => { editor.update(() => { const node = $getNodeByKey(nodeKey); if ($isInlineImageNode(node)) { node.setWidthAndHeight(nextWidth, nextHeight); } }); setIsResizing(false); }; /** * Marks the image as actively resizing. */ const onResizeStart = () => { setIsResizing(true); }; const draggable = isSelected && $isNodeSelection(selection) && !isResizing; const isFocused = isSelected || isResizing; return (React.createElement(Suspense, { fallback: null }, React.createElement(React.Fragment, null, React.createElement("div", { draggable: draggable }, React.createElement(LazyImage, { altText: altText, className: isFocused ? `focused ${$isNodeSelection(selection) ? 'draggable' : ''}` : null, height: height, imageRef: imageRef, position: position, src: src, width: width })), showCaption && (React.createElement("div", { className: 'image-caption-container' }, React.createElement(LexicalNestedComposer, { initialEditor: caption }, React.createElement(AutoFocusPlugin, null), React.createElement(LinkPlugin, null), React.createElement(FloatingLinkEditorPlugin, { isLinkEditMode: false, setIsLinkEditMode: () => { console.log('FloatingLinkEditorPlugin'); } }), React.createElement(FloatingTextFormatToolbarPlugin, null), React.createElement(RichTextPlugin, { contentEditable: React.createElement(ContentEditable, { className: 'InlineImageNode__contentEditable' }), ErrorBoundary: LexicalErrorBoundary, placeholder: React.createElement(Placeholder, { className: 'InlineImageNode__placeholder' }, "Enter a caption...") })))), $isNodeSelection(selection) && isFocused && (React.createElement(ImageResizer, { editor: editor, imageRef: imageRef, onResizeEnd: onResizeEnd, onResizeStart: onResizeStart }))))); } //# sourceMappingURL=InlineImageComponent.js.map