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 { 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 })}>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
Loading…
Reference in a new issue