Lexical: Allow setting inline image alt text

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2023-07-31 18:29:18 +02:00
parent fa5529fc15
commit bcd958a473
4 changed files with 89 additions and 8 deletions

View file

@ -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<IUpload> = ({
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : undefined }}
>
<div className={clsx('compose-form__upload__actions', { active })}>
<HStack className='absolute right-2 top-2 z-10' space={2}>
{(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && (
<IconButton
onClick={handleOpenModal}
@ -180,7 +180,7 @@ const Upload: React.FC<IUpload> = ({
title={intl.formatMessage(messages.delete)}
/>
)}
</div>
</HStack>
{onDescriptionChange && (
<div className={clsx('compose-form__upload-description', { active })}>

View file

@ -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<string, IComposeEditor>(({
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);

View file

@ -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 | HTMLImageElement>(null);
@ -104,6 +109,10 @@ const ImageComponent = ({
>(null);
const activeEditorRef = useRef<LexicalEditor | null>(null);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [dirtyDescription, setDirtyDescription] = useState<string | null>(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<HTMLTextAreaElement> = 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 (
<Suspense fallback={null}>
<>
<div className='relative' draggable={draggable}>
<div className='relative' draggable={draggable} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={handleClick} role='button'>
<HStack className='absolute right-2 top-2 z-10' space={2}>
<IconButton
onClick={previewImage}
@ -281,11 +333,27 @@ const ImageComponent = ({
iconClassName='h-5 w-5'
/>
</HStack>
<div className={clsx('compose-form__upload-description', { active })}>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
<textarea
placeholder={intl.formatMessage(messages.description)}
value={description}
onFocus={handleInputFocus}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
/>
</label>
</div>
<LazyImage
className={
clsx('cursor-default', {
'select-none': isFocused,
'cursor-grab active:cursor-grabbing': isFocused && $isNodeSelection(selection),
'select-none': isSelected,
'cursor-grab active:cursor-grabbing': isSelected && $isNodeSelection(selection),
})
}
src={src}

View file

@ -132,6 +132,14 @@ class ImageNode extends DecoratorNode<JSX.Element> {
return this.__altText;
}
setAltText(altText: string): void {
const writable = this.getWritable();
if (altText !== undefined) {
writable.__altText = altText;
}
}
decorate(): JSX.Element {
return (
<Suspense fallback={null}>