lexical: remove unused nodes, upgrade all deps See merge request soapbox-pub/soapbox!2748environments/review-main-yi2y9f/deployments/4032
commit
936972779d
@ -1,359 +0,0 @@
|
||||
/**
|
||||
* This source code is derived from code from Meta Platforms, Inc.
|
||||
* and affiliates, licensed under the MIT license located in the
|
||||
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||
*/
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
|
||||
import { mergeRegister } from '@lexical/utils';
|
||||
import clsx from 'clsx';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import {
|
||||
$getNodeByKey,
|
||||
$getSelection,
|
||||
$isNodeSelection,
|
||||
$setSelection,
|
||||
CLICK_COMMAND,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
DRAGSTART_COMMAND,
|
||||
KEY_BACKSPACE_COMMAND,
|
||||
KEY_DELETE_COMMAND,
|
||||
KEY_ENTER_COMMAND,
|
||||
KEY_ESCAPE_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} 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';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||
|
||||
import { $isImageNode } from './image-node';
|
||||
|
||||
import type {
|
||||
GridSelection,
|
||||
LexicalEditor,
|
||||
NodeKey,
|
||||
NodeSelection,
|
||||
RangeSelection,
|
||||
} from 'lexical';
|
||||
|
||||
const messages = defineMessages({
|
||||
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
|
||||
});
|
||||
|
||||
const imageCache = new Set();
|
||||
|
||||
const useSuspenseImage = (src: string) => {
|
||||
if (!imageCache.has(src)) {
|
||||
throw new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
img.onload = () => {
|
||||
imageCache.add(src);
|
||||
resolve(null);
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const LazyImage = ({
|
||||
altText,
|
||||
className,
|
||||
imageRef,
|
||||
src,
|
||||
}: {
|
||||
altText: string
|
||||
className: string | null
|
||||
imageRef: {current: null | HTMLImageElement}
|
||||
src: string
|
||||
}): JSX.Element => {
|
||||
useSuspenseImage(src);
|
||||
return (
|
||||
<img
|
||||
className={className || undefined}
|
||||
src={src}
|
||||
alt={altText}
|
||||
ref={imageRef}
|
||||
draggable='false'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ImageComponent = ({
|
||||
src,
|
||||
altText,
|
||||
nodeKey,
|
||||
}: {
|
||||
altText: string
|
||||
nodeKey: NodeKey
|
||||
src: string
|
||||
}): JSX.Element => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const imageRef = useRef<null | HTMLImageElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [isSelected, setSelected, clearSelection] =
|
||||
useLexicalNodeSelection(nodeKey);
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [selection, setSelection] = useState<
|
||||
RangeSelection | NodeSelection | GridSelection | 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(
|
||||
() => {
|
||||
editor.update(() => {
|
||||
const node = $getNodeByKey(nodeKey);
|
||||
if ($isImageNode(node)) {
|
||||
node.remove();
|
||||
}
|
||||
});
|
||||
},
|
||||
[nodeKey],
|
||||
);
|
||||
|
||||
const previewImage = () => {
|
||||
const image = normalizeAttachment({
|
||||
type: 'image',
|
||||
url: src,
|
||||
altText,
|
||||
});
|
||||
|
||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(image), index: 0 }));
|
||||
};
|
||||
|
||||
const onDelete = useCallback(
|
||||
(payload: KeyboardEvent) => {
|
||||
if (isSelected && $isNodeSelection($getSelection())) {
|
||||
const event: KeyboardEvent = payload;
|
||||
event.preventDefault();
|
||||
deleteNode();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[isSelected, nodeKey],
|
||||
);
|
||||
|
||||
const onEnter = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const latestSelection = $getSelection();
|
||||
const buttonElem = buttonRef.current;
|
||||
if (isSelected && $isNodeSelection(latestSelection) && latestSelection.getNodes().length === 1) {
|
||||
if (buttonElem !== null && buttonElem !== document.activeElement) {
|
||||
event.preventDefault();
|
||||
buttonElem.focus();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[isSelected],
|
||||
);
|
||||
|
||||
const onEscape = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (buttonRef.current === event.target) {
|
||||
$setSelection(null);
|
||||
editor.update(() => {
|
||||
setSelected(true);
|
||||
const parentRootElement = editor.getRootElement();
|
||||
if (parentRootElement !== null) {
|
||||
parentRootElement.focus();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[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(
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
if (isMounted) {
|
||||
setSelection(editorState.read(() => $getSelection()));
|
||||
}
|
||||
}),
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_, activeEditor) => {
|
||||
activeEditorRef.current = activeEditor;
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand<MouseEvent>(
|
||||
CLICK_COMMAND,
|
||||
(payload) => {
|
||||
const event = payload;
|
||||
|
||||
if (event.target === imageRef.current) {
|
||||
if (event.shiftKey) {
|
||||
setSelected(!isSelected);
|
||||
} else {
|
||||
clearSelection();
|
||||
setSelected(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
DRAGSTART_COMMAND,
|
||||
(event) => {
|
||||
if (event.target === imageRef.current) {
|
||||
// TODO This is just a temporary workaround for FF to behave like other browsers.
|
||||
// Ideally, this handles drag & drop too (and all browsers).
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_DELETE_COMMAND,
|
||||
onDelete,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_BACKSPACE_COMMAND,
|
||||
onDelete,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(KEY_ENTER_COMMAND, onEnter, COMMAND_PRIORITY_LOW),
|
||||
editor.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
onEscape,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
);
|
||||
return () => {
|
||||
isMounted = false;
|
||||
unregister();
|
||||
};
|
||||
}, [
|
||||
clearSelection,
|
||||
editor,
|
||||
isSelected,
|
||||
nodeKey,
|
||||
onDelete,
|
||||
onEnter,
|
||||
onEscape,
|
||||
setSelected,
|
||||
]);
|
||||
|
||||
const active = hovered || focused;
|
||||
const description = dirtyDescription || (dirtyDescription !== '' && altText) || '';
|
||||
const draggable = isSelected && $isNodeSelection(selection);
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<>
|
||||
<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}
|
||||
src={require('@tabler/icons/zoom-in.svg')}
|
||||
theme='dark'
|
||||
className='!p-1.5 hover:scale-105 hover:bg-gray-900'
|
||||
iconClassName='h-5 w-5'
|
||||
/>
|
||||
<IconButton
|
||||
onClick={deleteNode}
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
theme='dark'
|
||||
className='!p-1.5 hover:scale-105 hover:bg-gray-900'
|
||||
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': isSelected,
|
||||
'cursor-grab active:cursor-grabbing': isSelected && $isNodeSelection(selection),
|
||||
})
|
||||
}
|
||||
src={src}
|
||||
altText={altText}
|
||||
imageRef={imageRef}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageComponent;
|
@ -1,179 +0,0 @@
|
||||
/**
|
||||
* This source code is derived from code from Meta Platforms, Inc.
|
||||
* and affiliates, licensed under the MIT license located in the
|
||||
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||
*/
|
||||
|
||||
import { $applyNodeReplacement, DecoratorNode } from 'lexical';
|
||||
import * as React from 'react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedLexicalNode,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
|
||||
const ImageComponent = React.lazy(() => import('./image-component'));
|
||||
|
||||
interface ImagePayload {
|
||||
altText?: string
|
||||
key?: NodeKey
|
||||
src: string
|
||||
}
|
||||
|
||||
const convertImageElement = (domNode: Node): null | DOMConversionOutput => {
|
||||
if (domNode instanceof HTMLImageElement) {
|
||||
const { alt: altText, src } = domNode;
|
||||
const node = $createImageNode({ altText, src });
|
||||
return { node };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type SerializedImageNode = Spread<
|
||||
{
|
||||
altText: string
|
||||
src: string
|
||||
},
|
||||
SerializedLexicalNode
|
||||
>;
|
||||
|
||||
class ImageNode extends DecoratorNode<JSX.Element> {
|
||||
|
||||
__src: string;
|
||||
__altText: string;
|
||||
|
||||
static getType(): string {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
static clone(node: ImageNode): ImageNode {
|
||||
return new ImageNode(
|
||||
node.__src,
|
||||
node.__altText,
|
||||
node.__key,
|
||||
);
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedImageNode): ImageNode {
|
||||
const { altText, src } =
|
||||
serializedNode;
|
||||
const node = $createImageNode({
|
||||
altText,
|
||||
src,
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement('img');
|
||||
element.setAttribute('src', this.__src);
|
||||
element.setAttribute('alt', this.__altText);
|
||||
return { element };
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
img: (node: Node) => ({
|
||||
conversion: convertImageElement,
|
||||
priority: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
constructor(
|
||||
src: string,
|
||||
altText: string,
|
||||
key?: NodeKey,
|
||||
) {
|
||||
super(key);
|
||||
this.__src = src;
|
||||
this.__altText = altText;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedImageNode {
|
||||
return {
|
||||
altText: this.getAltText(),
|
||||
src: this.getSrc(),
|
||||
type: 'image',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// View
|
||||
|
||||
createDOM(config: EditorConfig): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
const theme = config.theme;
|
||||
const className = theme.image;
|
||||
if (className !== undefined) {
|
||||
span.className = className;
|
||||
}
|
||||
return span;
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
getSrc(): string {
|
||||
return this.__src;
|
||||
}
|
||||
|
||||
getAltText(): string {
|
||||
return this.__altText;
|
||||
}
|
||||
|
||||
setAltText(altText: string): void {
|
||||
const writable = this.getWritable();
|
||||
|
||||
if (altText !== undefined) {
|
||||
writable.__altText = altText;
|
||||
}
|
||||
}
|
||||
|
||||
decorate(): JSX.Element {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ImageComponent
|
||||
src={this.__src}
|
||||
altText={this.__altText}
|
||||
nodeKey={this.getKey()}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const $createImageNode = ({
|
||||
altText = '',
|
||||
src,
|
||||
key,
|
||||
}: ImagePayload): ImageNode => {
|
||||
return $applyNodeReplacement(
|
||||
new ImageNode(
|
||||
src,
|
||||
altText,
|
||||
key,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const $isImageNode = (
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is ImageNode => node instanceof ImageNode;
|
||||
|
||||
export {
|
||||
type ImagePayload,
|
||||
type SerializedImageNode,
|
||||
ImageNode,
|
||||
$createImageNode,
|
||||
$isImageNode,
|
||||
};
|
Loading…
Reference in new issue