From c4d085c767ca6917438f2298701fa6af1879cb64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 17 May 2024 23:27:10 +0200 Subject: [PATCH] Bring back the WYSIWYG editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- package.json | 18 +- src/actions/compose.ts | 3 +- .../compose/components/compose-form.tsx | 3 +- .../components/content-type-button.tsx | 6 + src/features/compose/editor/handlers/image.ts | 10 + src/features/compose/editor/index.tsx | 80 +- .../compose/editor/nodes/image-component.tsx | 357 +++ .../compose/editor/nodes/image-node.tsx | 178 ++ src/features/compose/editor/nodes/index.ts | 28 +- .../floating-block-type-toolbar-plugin.tsx | 294 +++ .../plugins/floating-link-editor-plugin.tsx | 274 ++ .../floating-text-format-toolbar-plugin.tsx | 565 ++++ .../compose/editor/plugins/state-plugin.tsx | 16 +- .../editor/utils/get-dom-range-rect.ts | 28 + .../compose/editor/utils/get-selected-node.ts | 26 + .../utils/set-floating-elem-position.ts | 45 + src/features/compose/editor/utils/url.ts | 32 + src/locales/en.json | 9 + src/schemas/instance.ts | 4 + yarn.lock | 2270 +++++++++++++++-- 20 files changed, 4059 insertions(+), 187 deletions(-) create mode 100644 src/features/compose/editor/handlers/image.ts create mode 100644 src/features/compose/editor/nodes/image-component.tsx create mode 100644 src/features/compose/editor/nodes/image-node.tsx create mode 100644 src/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx create mode 100644 src/features/compose/editor/plugins/floating-link-editor-plugin.tsx create mode 100644 src/features/compose/editor/plugins/floating-text-format-toolbar-plugin.tsx create mode 100644 src/features/compose/editor/utils/get-dom-range-rect.ts create mode 100644 src/features/compose/editor/utils/get-selected-node.ts create mode 100644 src/features/compose/editor/utils/set-floating-elem-position.ts create mode 100644 src/features/compose/editor/utils/url.ts diff --git a/package.json b/package.json index 4750bc864..292a83633 100644 --- a/package.json +++ b/package.json @@ -55,12 +55,16 @@ "@fontsource/roboto-mono": "^5.0.0", "@fontsource/tajawal": "^5.0.8", "@gamestdio/websocket": "^0.3.2", - "@lexical/clipboard": "^0.14.2", - "@lexical/hashtag": "^0.14.2", - "@lexical/link": "^0.14.2", - "@lexical/react": "^0.14.2", - "@lexical/selection": "^0.14.2", - "@lexical/utils": "^0.14.2", + "@lexical/clipboard": "^0.14.5", + "@lexical/code": "^0.14.5", + "@lexical/hashtag": "^0.14.5", + "@lexical/link": "^0.14.5", + "@lexical/list": "^0.14.5", + "@lexical/react": "^0.14.5", + "@lexical/rich-text": "^0.14.5", + "@lexical/selection": "^0.14.5", + "@lexical/utils": "^0.14.5", + "@mkljczk/lexical-remark": "^0.4.0", "@mkljczk/react-hotkeys": "^1.2.2", "@popperjs/core": "^2.11.5", "@reach/combobox": "^0.18.0", @@ -124,7 +128,7 @@ "intl-pluralrules": "^2.0.0", "isomorphic-dompurify": "^2.3.0", "leaflet": "^1.8.0", - "lexical": "^0.14.2", + "lexical": "^0.14.5", "line-awesome": "^1.3.0", "localforage": "^1.10.0", "lodash": "^4.7.11", diff --git a/src/actions/compose.ts b/src/actions/compose.ts index b2e1b4066..ba736151e 100644 --- a/src/actions/compose.ts +++ b/src/actions/compose.ts @@ -367,6 +367,7 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) => } const idempotencyKey = compose.idempotencyKey; + const contentType = compose.content_type === 'wysiwyg' ? 'text/markdown' : compose.content_type; const params: Record = { status, @@ -376,7 +377,7 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) => sensitive: compose.sensitive, spoiler_text: compose.spoiler_text, visibility: compose.privacy, - content_type: compose.content_type, + content_type: contentType, poll: compose.poll, scheduled_at: compose.schedule, language: compose.language, diff --git a/src/features/compose/components/compose-form.tsx b/src/features/compose/components/compose-form.tsx index 3e76705aa..d4c98b7cc 100644 --- a/src/features/compose/components/compose-form.tsx +++ b/src/features/compose/components/compose-form.tsx @@ -6,7 +6,6 @@ import { Link, useHistory } from 'react-router-dom'; import { length } from 'stringz'; import { - changeCompose, submitCompose, clearComposeSuggestions, fetchComposeSuggestions, @@ -140,7 +139,7 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab if (!canSubmit) return; e?.preventDefault(); - dispatch(changeCompose(id, text)); + // dispatch(changeCompose(id, text)); dispatch(submitCompose(id, { history })); editorRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined); diff --git a/src/features/compose/components/content-type-button.tsx b/src/features/compose/components/content-type-button.tsx index 01fac99eb..c5773b5c1 100644 --- a/src/features/compose/components/content-type-button.tsx +++ b/src/features/compose/components/content-type-button.tsx @@ -10,6 +10,7 @@ const messages = defineMessages({ content_type_plaintext: { id: 'preferences.options.content_type_plaintext', defaultMessage: 'Plain text' }, content_type_markdown: { id: 'preferences.options.content_type_markdown', defaultMessage: 'Markdown' }, content_type_html: { id: 'preferences.options.content_type_html', defaultMessage: 'HTML' }, + content_type_wysiwyg: { id: 'preferences.options.content_type_wysiwyf', defaultMessage: 'WYSIWYG' }, change_content_type: { id: 'compose_form.content_type.change', defaultMessage: 'Change content type' }, }); @@ -40,6 +41,11 @@ const ContentTypeButton: React.FC = ({ composeId }) => { text: intl.formatMessage(messages.content_type_html), value: 'text/html', }, + { + icon: require('@tabler/icons/outline/text-caption.svg'), + text: intl.formatMessage(messages.content_type_wysiwyg), + value: 'wysiwyg', + }, ]; const option = options.find(({ value }) => value === contentType); diff --git a/src/features/compose/editor/handlers/image.ts b/src/features/compose/editor/handlers/image.ts new file mode 100644 index 000000000..55aae734d --- /dev/null +++ b/src/features/compose/editor/handlers/image.ts @@ -0,0 +1,10 @@ +import { $createImageNode } from '../nodes/image-node'; + +import type { ImportHandler } from '@mkljczk/lexical-remark'; + +const importImage: ImportHandler /* TODO */ = (node, parser) => { + const lexicalNode = $createImageNode({ altText: node.alt ?? '', src: node.url }); + parser.append(lexicalNode); +}; + +export { importImage }; diff --git a/src/features/compose/editor/index.tsx b/src/features/compose/editor/index.tsx index 9daf19b9d..e540e652d 100644 --- a/src/features/compose/editor/index.tsx +++ b/src/features/compose/editor/index.tsx @@ -11,17 +11,24 @@ import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'; import { HashtagPlugin } from '@lexical/react/LexicalHashtagPlugin'; import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; +import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'; +import { ListPlugin } from '@lexical/react/LexicalListPlugin'; import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; -import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin'; +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; +import { $createRemarkExport, $createRemarkImport } from '@mkljczk/lexical-remark'; import clsx from 'clsx'; -import { $createParagraphNode, $createTextNode, $getRoot, type LexicalEditor } from 'lexical'; +import { $createParagraphNode, $createTextNode, $getRoot, type EditorState, type LexicalEditor } from 'lexical'; import React, { useMemo, useState } from 'react'; import { FormattedMessage } from 'react-intl'; -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useCompose } 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'; +import FloatingLinkEditorPlugin from './plugins/floating-link-editor-plugin'; +import FloatingTextFormatToolbarPlugin from './plugins/floating-text-format-toolbar-plugin'; import FocusPlugin from './plugins/focus-plugin'; import RefPlugin from './plugins/ref-plugin'; import StatePlugin from './plugins/state-plugin'; @@ -83,7 +90,8 @@ const ComposeEditor = React.forwardRef(({ placeholder, }, ref) => { const dispatch = useAppDispatch(); - const nodes = useNodes(); + const isWysiwyg = useCompose(composeId).content_type === 'wysiwyg'; + const nodes = useNodes(isWysiwyg); const [suggestionsHidden, setSuggestionsHidden] = useState(true); @@ -103,17 +111,34 @@ const ComposeEditor = React.forwardRef(({ } return () => { - const paragraph = $createParagraphNode(); - const textNode = $createTextNode(compose.text); + if (isWysiwyg) { + $createRemarkImport({ + handlers: { + image: importImage, + }, + })(compose.text); + } else { + const paragraph = $createParagraphNode(); + const textNode = $createTextNode(compose.text); - paragraph.append(textNode); + paragraph.append(textNode); - $getRoot() - .clear() - .append(paragraph); + $getRoot() + .clear() + .append(paragraph); + } }; }), - }), []); + }), [isWysiwyg]); + + const [floatingAnchorElem, setFloatingAnchorElem] = + useState(null); + + const onRef = (_floatingAnchorElem: HTMLDivElement) => { + if (_floatingAnchorElem !== null) { + setFloatingAnchorElem(_floatingAnchorElem); + } + }; const handlePaste: React.ClipboardEventHandler = (e) => { if (onPaste && e.clipboardData && e.clipboardData.files.length === 1) { @@ -122,6 +147,17 @@ const ComposeEditor = React.forwardRef(({ } }; + const handleChange = (_: EditorState, editor: LexicalEditor) => { + if (onChange) { + onChange(editor.getEditorState().read($createRemarkExport({ + handlers: { + hashtag: (node) => ({ type: 'text', value: node.getTextContent() }), + mention: (node) => ({ type: 'text', value: node.getTextContent() }), + }, + }))); + } + }; + let textareaPlaceholder = placeholder || ; if (eventDiscussion) { @@ -131,11 +167,11 @@ const ComposeEditor = React.forwardRef(({ } return ( - +
- +
(({ )} ErrorBoundary={LexicalErrorBoundary} /> - { - onChange?.(editor.getEditorState().read(() => $getRoot().getTextContent())); - }} - /> + - + {isWysiwyg && } + {isWysiwyg && } + {isWysiwyg && floatingAnchorElem && ( + <> + + + + + )} + diff --git a/src/features/compose/editor/nodes/image-component.tsx b/src/features/compose/editor/nodes/image-component.tsx new file mode 100644 index 000000000..f27a97a5f --- /dev/null +++ b/src/features/compose/editor/nodes/image-component.tsx @@ -0,0 +1,357 @@ +/** + * 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 { + BaseSelection, + LexicalEditor, + NodeKey, +} 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 ( + {altText} + ); +}; + +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< + BaseSelection | 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 ( + + <> +
+ + + + + +
+