phx-react
Version:
PHX REACT
186 lines • 9.04 kB
JavaScript
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