diff --git a/package.json b/package.json index 05bff6853..bc74cba3e 100644 --- a/package.json +++ b/package.json @@ -51,15 +51,10 @@ "@fontsource/roboto-mono": "^5.0.0", "@gamestdio/websocket": "^0.3.2", "@lexical/clipboard": "^0.11.3", - "@lexical/code": "^0.11.3", "@lexical/hashtag": "^0.11.3", - "@lexical/html": "^0.11.3", "@lexical/link": "^0.11.3", - "@lexical/list": "^0.11.3", "@lexical/react": "^0.11.3", - "@lexical/rich-text": "^0.11.3", "@lexical/selection": "^0.11.3", - "@lexical/table": "^0.11.3", "@lexical/utils": "^0.11.3", "@popperjs/core": "^2.11.5", "@reach/combobox": "^0.18.0", diff --git a/src/features/compose/editor/nodes/image-component.tsx b/src/features/compose/editor/nodes/image-component.tsx deleted file mode 100644 index 0bc917686..000000000 --- a/src/features/compose/editor/nodes/image-component.tsx +++ /dev/null @@ -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 ( - - ); -}; - -const ImageComponent = ({ - src, - altText, - nodeKey, -}: { - altText: string - nodeKey: NodeKey - src: string -}): JSX.Element => { - const intl = useIntl(); - const dispatch = useAppDispatch(); - - const imageRef = useRef(null); - const buttonRef = useRef(null); - const [isSelected, setSelected, clearSelection] = - useLexicalNodeSelection(nodeKey); - const [editor] = useLexicalComposerContext(); - const [selection, setSelection] = useState< - RangeSelection | NodeSelection | GridSelection | null - >(null); - const activeEditorRef = useRef(null); - - const [hovered, setHovered] = useState(false); - const [focused, setFocused] = useState(false); - const [dirtyDescription, setDirtyDescription] = useState(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 = 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( - 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 ( - - <> - - - - - - - - - {intl.formatMessage(messages.description)} - - - - - - - - > - - ); -}; - -export default ImageComponent; diff --git a/src/features/compose/editor/nodes/image-node.tsx b/src/features/compose/editor/nodes/image-node.tsx deleted file mode 100644 index 01bf9ad62..000000000 --- a/src/features/compose/editor/nodes/image-node.tsx +++ /dev/null @@ -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 { - - __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 ( - - - - ); - } - -} - -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, -}; diff --git a/src/features/compose/editor/nodes/index.ts b/src/features/compose/editor/nodes/index.ts index 10c197217..de1102a76 100644 --- a/src/features/compose/editor/nodes/index.ts +++ b/src/features/compose/editor/nodes/index.ts @@ -4,25 +4,15 @@ * LICENSE file in the /app/soapbox/features/compose/editor directory. */ -import { CodeHighlightNode, CodeNode } from '@lexical/code'; import { HashtagNode } from '@lexical/hashtag'; -import { AutoLinkNode, LinkNode } from '@lexical/link'; -import { ListItemNode, ListNode } from '@lexical/list'; -import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'; -import { HeadingNode, QuoteNode } from '@lexical/rich-text'; - -import { useFeatures, useInstance } from 'soapbox/hooks'; +import { AutoLinkNode } from '@lexical/link'; import { EmojiNode } from './emoji-node'; -import { ImageNode } from './image-node'; import { MentionNode } from './mention-node'; import type { Klass, LexicalNode } from 'lexical'; const useNodes = () => { - const features = useFeatures(); - const instance = useInstance(); - const nodes: Array> = [ AutoLinkNode, HashtagNode, @@ -30,21 +20,6 @@ const useNodes = () => { MentionNode, ]; - if (features.richText) { - nodes.push( - CodeHighlightNode, - CodeNode, - HorizontalRuleNode, - LinkNode, - ListItemNode, - ListNode, - QuoteNode, - ); - } - - if (instance.pleroma.getIn(['metadata', 'markup', 'allow_headings'])) nodes.push(HeadingNode); - if (instance.pleroma.getIn(['metadata', 'markup', 'allow_inline_images'])) nodes.push(ImageNode); - return nodes; }; diff --git a/yarn.lock b/yarn.lock index 24c98a3ed..006441867 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1709,7 +1709,7 @@ "@lexical/selection" "0.11.3" "@lexical/utils" "0.11.3" -"@lexical/code@0.11.3", "@lexical/code@^0.11.3": +"@lexical/code@0.11.3": version "0.11.3" resolved "https://registry.yarnpkg.com/@lexical/code/-/code-0.11.3.tgz#4a5ef193655557859c63dd4a54012c78580585fa" integrity sha512-BIMPd2op65iP4N9SkKIUVodZoWeSsnk6skNJ8UHBO/Rg0ZxyAqxLpnBhEgHq2QOoTBbEW6OEFtkc7/+f9LINZg== @@ -1736,7 +1736,7 @@ dependencies: "@lexical/utils" "0.11.3" -"@lexical/html@0.11.3", "@lexical/html@^0.11.3": +"@lexical/html@0.11.3": version "0.11.3" resolved "https://registry.yarnpkg.com/@lexical/html/-/html-0.11.3.tgz#c02b38f512eb808726922c8215dd2374df6b77cc" integrity sha512-+8AYnxxml9PneZLkGfdTenqDjE2yD1ZfCmQLrD/L1TEn22OjZh4uvKVHb13wEhgUZTuLKF0PNdnuecko9ON/aQ== @@ -1750,7 +1750,7 @@ dependencies: "@lexical/utils" "0.11.3" -"@lexical/list@0.11.3", "@lexical/list@^0.11.3": +"@lexical/list@0.11.3": version "0.11.3" resolved "https://registry.yarnpkg.com/@lexical/list/-/list-0.11.3.tgz#d158ac4b4b42d772b30a1cd2a42ca0462a5a154e" integrity sha512-Cs9071wDfqi4j1VgodceiR1jTHj13eCoEJDhr3e/FW0x5we7vfbTMtWlOWbveIoryAh+rQNgiD5e8SrAm6Zs3g== @@ -1815,7 +1815,7 @@ "@lexical/yjs" "0.11.3" react-error-boundary "^3.1.4" -"@lexical/rich-text@0.11.3", "@lexical/rich-text@^0.11.3": +"@lexical/rich-text@0.11.3": version "0.11.3" resolved "https://registry.yarnpkg.com/@lexical/rich-text/-/rich-text-0.11.3.tgz#9501789bfe9671c220da7e95bf8589fa92cecf8c" integrity sha512-fBFs6wMS7GFLbk+mzIWtwpP+EmnTZZ5bHpveuQ5wXONBuUuLcsYF5KO7UhLxXNLmiViV6lxatZPavEzgZdW7oQ== @@ -1825,7 +1825,7 @@ resolved "https://registry.yarnpkg.com/@lexical/selection/-/selection-0.11.3.tgz#f7250fae305a84c6e264a413f5feab056aeabfa3" integrity sha512-15lQpcKT/vd7XZ5pnF1nb+kpKb72e9Yi1dVqieSxTeXkzt1cAZFKP3NB4RlhOKCv1N+glSBnjSxRwgsFfbD+NQ== -"@lexical/table@0.11.3", "@lexical/table@^0.11.3": +"@lexical/table@0.11.3": version "0.11.3" resolved "https://registry.yarnpkg.com/@lexical/table/-/table-0.11.3.tgz#9b1980d828d7a588aaffa4cb8c6bb61401729034" integrity sha512-EyRnN39CSPsMceADBR7Kf+sBHNpNQlPEkn/52epeDSnakR6s80woyrA3kIzKo6mLB4afvoqdYc7RfR96M9JLIA==