/** * This source code is derived from code from Meta Platforms, Inc. * and affiliates, licensed under the MIT license located in the * LICENSE file in the /app/soapbox/features/compose/editor directory. */ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'; import { mergeRegister } from '@lexical/utils'; import clsx from 'clsx'; import { List as ImmutableList } from 'immutable'; 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 { defineMessages, useIntl } from 'react-intl'; import { openModal } from 'soapbox/actions/modals'; import { HStack, IconButton } from 'soapbox/components/ui'; import { useAppDispatch } from 'soapbox/hooks'; import { normalizeAttachment } from 'soapbox/normalizers'; import { $isImageNode } from './image-node'; import type { GridSelection, LexicalEditor, NodeKey, NodeSelection, RangeSelection, } from 'lexical'; const messages = defineMessages({ description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, }); const imageCache = new Set(); const 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); }; }); } }; const LazyImage = ({ altText, className, imageRef, src, }: { altText: string className: string | null imageRef: {current: null | HTMLImageElement} src: string }): JSX.Element => { useSuspenseImage(src); return ( {altText} ); }; const ImageComponent = ({ src, altText, nodeKey, }: { altText: string nodeKey: NodeKey src: string }): JSX.Element => { const intl = useIntl(); const dispatch = useAppDispatch(); 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 [hovered, setHovered] = useState(false); const [focused, setFocused] = useState(false); const [dirtyDescription, setDirtyDescription] = useState(null); const deleteNode = useCallback( () => { editor.update(() => { const node = $getNodeByKey(nodeKey); if ($isImageNode(node)) { node.remove(); } }); }, [nodeKey], ); const previewImage = () => { const image = normalizeAttachment({ type: 'image', url: src, altText, }); dispatch(openModal('MEDIA', { media: ImmutableList.of(image), index: 0 })); }; const onDelete = useCallback( (payload: KeyboardEvent) => { if (isSelected && $isNodeSelection($getSelection())) { const event: KeyboardEvent = payload; event.preventDefault(); deleteNode(); } 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 (buttonElem !== null && buttonElem !== document.activeElement) { event.preventDefault(); buttonElem.focus(); return true; } } return false; }, [isSelected], ); const onEscape = useCallback( (event: KeyboardEvent) => { if (buttonRef.current === event.target) { $setSelection(null); editor.update(() => { setSelected(true); const parentRootElement = editor.getRootElement(); if (parentRootElement !== null) { parentRootElement.focus(); } }); return true; } return false; }, [editor, setSelected], ); const handleKeyDown: React.KeyboardEventHandler = (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { handleInputBlur(); } }; const handleInputBlur = () => { setFocused(false); if (dirtyDescription !== null) { editor.update(() => { const node = $getNodeByKey(nodeKey); if ($isImageNode(node)) { node.setAltText(dirtyDescription); } setDirtyDescription(null); }); } }; const handleInputChange: React.ChangeEventHandler = e => { setDirtyDescription(e.target.value); }; const handleMouseEnter = () => { setHovered(true); }; const handleMouseLeave = () => { setHovered(false); }; const handleInputFocus = () => { setFocused(true); }; const handleClick = () => { setFocused(true); }; 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 active = hovered || focused; const description = dirtyDescription || (dirtyDescription !== '' && altText) || ''; const draggable = isSelected && $isNodeSelection(selection); return ( <>