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 { openModal } from 'soapbox/actions/modals';
import Blurhash from 'soapbox/components/blurhash'; import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon'; 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 Motion from 'soapbox/features/ui/util/optional-motion';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import { Attachment } from 'soapbox/types/entities'; import { Attachment } from 'soapbox/types/entities';
@ -159,7 +159,7 @@ const Upload: React.FC<IUpload> = ({
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined, backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : 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)) && ( {(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && (
<IconButton <IconButton
onClick={handleOpenModal} onClick={handleOpenModal}
@ -180,7 +180,7 @@ const Upload: React.FC<IUpload> = ({
title={intl.formatMessage(messages.delete)} title={intl.formatMessage(messages.delete)}
/> />
)} )}
</div> </HStack>
{onDescriptionChange && ( {onDescriptionChange && (
<div className={clsx('compose-form__upload-description', { active })}> <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 { useAppDispatch, useFeatures } from 'soapbox/hooks';
import { importImage } from './handlers/image';
import { useNodes } from './nodes'; import { useNodes } from './nodes';
import AutosuggestPlugin from './plugins/autosuggest-plugin'; import AutosuggestPlugin from './plugins/autosuggest-plugin';
import FloatingBlockTypeToolbarPlugin from './plugins/floating-block-type-toolbar-plugin'; import FloatingBlockTypeToolbarPlugin from './plugins/floating-block-type-toolbar-plugin';
@ -106,7 +107,11 @@ const ComposeEditor = React.forwardRef<string, IComposeEditor>(({
return () => { return () => {
if (compose.content_type === 'text/markdown') { if (compose.content_type === 'text/markdown') {
$createRemarkImport({})(compose.text); $createRemarkImport({
handlers: {
image: importImage,
},
})(compose.text);
} else { } else {
const paragraph = $createParagraphNode(); const paragraph = $createParagraphNode();
const textNode = $createTextNode(compose.text); const textNode = $createTextNode(compose.text);

View file

@ -28,6 +28,7 @@ import {
} from 'lexical'; } from 'lexical';
import * as React from 'react'; import * as React from 'react';
import { Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { HStack, IconButton } from 'soapbox/components/ui'; import { HStack, IconButton } from 'soapbox/components/ui';
@ -44,6 +45,9 @@ import type {
RangeSelection, RangeSelection,
} from 'lexical'; } from 'lexical';
const messages = defineMessages({
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
});
const imageCache = new Set(); const imageCache = new Set();
@ -92,6 +96,7 @@ const ImageComponent = ({
nodeKey: NodeKey nodeKey: NodeKey
src: string src: string
}): JSX.Element => { }): JSX.Element => {
const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const imageRef = useRef<null | HTMLImageElement>(null); const imageRef = useRef<null | HTMLImageElement>(null);
@ -104,6 +109,10 @@ const ImageComponent = ({
>(null); >(null);
const activeEditorRef = useRef<LexicalEditor | null>(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( const deleteNode = useCallback(
() => { () => {
editor.update(() => { editor.update(() => {
@ -179,6 +188,47 @@ const ImageComponent = ({
[editor, setSelected], [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(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
const unregister = mergeRegister( const unregister = mergeRegister(
@ -259,12 +309,14 @@ const ImageComponent = ({
setSelected, setSelected,
]); ]);
const active = hovered || focused;
const description = dirtyDescription || (dirtyDescription !== '' && altText) || '';
const draggable = isSelected && $isNodeSelection(selection); const draggable = isSelected && $isNodeSelection(selection);
const isFocused = isSelected;
return ( return (
<Suspense fallback={null}> <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}> <HStack className='absolute right-2 top-2 z-10' space={2}>
<IconButton <IconButton
onClick={previewImage} onClick={previewImage}
@ -281,11 +333,27 @@ const ImageComponent = ({
iconClassName='h-5 w-5' iconClassName='h-5 w-5'
/> />
</HStack> </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 <LazyImage
className={ className={
clsx('cursor-default', { clsx('cursor-default', {
'select-none': isFocused, 'select-none': isSelected,
'cursor-grab active:cursor-grabbing': isFocused && $isNodeSelection(selection), 'cursor-grab active:cursor-grabbing': isSelected && $isNodeSelection(selection),
}) })
} }
src={src} src={src}

View file

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