From bcd958a473d567ff4020edb184c97f62b53717bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Mon, 31 Jul 2023 18:29:18 +0200 Subject: [PATCH] Lexical: Allow setting inline image alt text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/upload.tsx | 6 +- app/soapbox/features/compose/editor/index.tsx | 7 +- .../compose/editor/nodes/image-component.tsx | 76 ++++++++++++++++++- .../compose/editor/nodes/image-node.tsx | 8 ++ 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/app/soapbox/components/upload.tsx b/app/soapbox/components/upload.tsx index 24a83d860..72c11ddf5 100644 --- a/app/soapbox/components/upload.tsx +++ b/app/soapbox/components/upload.tsx @@ -7,7 +7,7 @@ import { spring } from 'react-motion'; import { openModal } from 'soapbox/actions/modals'; import Blurhash from 'soapbox/components/blurhash'; import Icon from 'soapbox/components/icon'; -import { IconButton } from 'soapbox/components/ui'; +import { HStack, IconButton } from 'soapbox/components/ui'; import Motion from 'soapbox/features/ui/util/optional-motion'; import { useAppDispatch } from 'soapbox/hooks'; import { Attachment } from 'soapbox/types/entities'; @@ -159,7 +159,7 @@ const Upload: React.FC = ({ backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined, backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : undefined }} > -
+ {(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && ( = ({ title={intl.formatMessage(messages.delete)} /> )} -
+ {onDescriptionChange && (
diff --git a/app/soapbox/features/compose/editor/index.tsx b/app/soapbox/features/compose/editor/index.tsx index 611225728..0ffbded35 100644 --- a/app/soapbox/features/compose/editor/index.tsx +++ b/app/soapbox/features/compose/editor/index.tsx @@ -24,6 +24,7 @@ import { FormattedMessage } from 'react-intl'; import { useAppDispatch, useFeatures } from 'soapbox/hooks'; +import { importImage } from './handlers/image'; import { useNodes } from './nodes'; import AutosuggestPlugin from './plugins/autosuggest-plugin'; import FloatingBlockTypeToolbarPlugin from './plugins/floating-block-type-toolbar-plugin'; @@ -106,7 +107,11 @@ const ComposeEditor = React.forwardRef(({ return () => { if (compose.content_type === 'text/markdown') { - $createRemarkImport({})(compose.text); + $createRemarkImport({ + handlers: { + image: importImage, + }, + })(compose.text); } else { const paragraph = $createParagraphNode(); const textNode = $createTextNode(compose.text); diff --git a/app/soapbox/features/compose/editor/nodes/image-component.tsx b/app/soapbox/features/compose/editor/nodes/image-component.tsx index 9d45c6adf..654685919 100644 --- a/app/soapbox/features/compose/editor/nodes/image-component.tsx +++ b/app/soapbox/features/compose/editor/nodes/image-component.tsx @@ -28,6 +28,7 @@ import { } 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'; @@ -44,6 +45,9 @@ import type { RangeSelection, } from 'lexical'; +const messages = defineMessages({ + description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, +}); const imageCache = new Set(); @@ -92,6 +96,7 @@ const ImageComponent = ({ nodeKey: NodeKey src: string }): JSX.Element => { + const intl = useIntl(); const dispatch = useAppDispatch(); const imageRef = useRef(null); @@ -104,6 +109,10 @@ const ImageComponent = ({ >(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(() => { @@ -179,6 +188,47 @@ const ImageComponent = ({ [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( @@ -259,12 +309,14 @@ const ImageComponent = ({ setSelected, ]); + const active = hovered || focused; + const description = dirtyDescription || (dirtyDescription !== '' && altText) || ''; const draggable = isSelected && $isNodeSelection(selection); - const isFocused = isSelected; + return ( <> -
+
+ +
+