Lexical: Allow setting inline image alt text
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
fa5529fc15
commit
bcd958a473
4 changed files with 89 additions and 8 deletions
|
@ -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 })}>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}>
|
||||
|
|
Loading…
Reference in a new issue