diff --git a/app/soapbox/features/compose/editor/index.tsx b/app/soapbox/features/compose/editor/index.tsx index 4dd273881..edc553817 100644 --- a/app/soapbox/features/compose/editor/index.tsx +++ b/app/soapbox/features/compose/editor/index.tsx @@ -34,6 +34,7 @@ const LINK_MATCHERS = [ 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 MentionPlugin from './plugins/mention-plugin'; @@ -188,6 +189,7 @@ const ComposeEditor = React.forwardRef(({ {features.richText && } {features.richText && floatingAnchorElem && ( <> + diff --git a/app/soapbox/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx b/app/soapbox/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx new file mode 100644 index 000000000..9f1b54aba --- /dev/null +++ b/app/soapbox/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx @@ -0,0 +1,211 @@ +/* +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +This source code is licensed under the MIT license found in the +LICENSE file in the /app/soapbox/features/compose/editor directory. +*/ + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $createHorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'; +import { mergeRegister } from '@lexical/utils'; +import { + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_LOW, + DEPRECATED_$isGridSelection, + LexicalEditor, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import * as React from 'react'; +import { createPortal } from 'react-dom'; + +import { setFloatingElemPosition } from '../utils/set-floating-elem-position'; + +import { ToolbarButton } from './floating-text-format-toolbar-plugin'; + +const BlockTypeFloatingToolbar = ({ + editor, + anchorElem, +}: { + editor: LexicalEditor + anchorElem: HTMLElement + }): JSX.Element => { + const popupCharStylesEditorRef = useRef(null); + + const updateBlockTypeFloatingToolbar = useCallback(() => { + const selection = $getSelection(); + + const popupCharStylesEditorElem = popupCharStylesEditorRef.current; + const nativeSelection = window.getSelection(); + + if (popupCharStylesEditorElem === null) { + return; + } + + const rootElement = editor.getRootElement(); + if ( + selection !== null && + nativeSelection !== null && + !nativeSelection.anchorNode?.textContent && + rootElement !== null && + rootElement.contains(nativeSelection.anchorNode) + ) { + setFloatingElemPosition((nativeSelection.focusNode as HTMLParagraphElement)?.getBoundingClientRect(), popupCharStylesEditorElem, anchorElem); + } + }, [editor, anchorElem]); + + useEffect(() => { + const scrollerElem = anchorElem.parentElement; + + const update = () => { + editor.getEditorState().read(() => { + updateBlockTypeFloatingToolbar(); + }); + }; + + window.addEventListener('resize', update); + if (scrollerElem) { + scrollerElem.addEventListener('scroll', update); + } + + return () => { + window.removeEventListener('resize', update); + if (scrollerElem) { + scrollerElem.removeEventListener('scroll', update); + } + }; + }, [editor, updateBlockTypeFloatingToolbar, anchorElem]); + + useEffect(() => { + editor.getEditorState().read(() => { + updateBlockTypeFloatingToolbar(); + }); + + return mergeRegister( + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + updateBlockTypeFloatingToolbar(); + }); + }), + + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + updateBlockTypeFloatingToolbar(); + return false; + }, + COMMAND_PRIORITY_LOW, + ), + ); + }, [editor, updateBlockTypeFloatingToolbar]); + + const createHorizontalLine = () => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) { + const selectionNode = selection.anchor.getNode(); + selectionNode.replace($createHorizontalRuleNode()); + } + }); + }; + + return ( +
+ {editor.isEditable() && ( + <> + + + )} +
+ ); +}; + +const useFloatingBlockTypeToolbar = ( + editor: LexicalEditor, + anchorElem: HTMLElement, +): JSX.Element | null => { + const [isEmptyBlock, setIsEmptyBlock] = useState(false); + + const updatePopup = useCallback(() => { + editor.getEditorState().read(() => { + // Should not to pop up the floating toolbar when using IME input + if (editor.isComposing()) { + return; + } + const selection = $getSelection(); + const nativeSelection = window.getSelection(); + const rootElement = editor.getRootElement(); + + if ( + nativeSelection !== null && + (!$isRangeSelection(selection) || + rootElement === null || + !rootElement.contains(nativeSelection.anchorNode)) + ) { + setIsEmptyBlock(false); + return; + } + + if (!$isRangeSelection(selection)) { + return; + } + + const anchorNode = selection.anchor.getNode(); + + setIsEmptyBlock(anchorNode.getType() === 'paragraph' && anchorNode.getTextContentSize() === 0); + }); + }, [editor]); + + useEffect(() => { + document.addEventListener('selectionchange', updatePopup); + return () => { + document.removeEventListener('selectionchange', updatePopup); + }; + }, [updatePopup]); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(() => { + updatePopup(); + }), + editor.registerRootListener(() => { + if (editor.getRootElement() === null) { + setIsEmptyBlock(false); + } + }), + ); + }, [editor, updatePopup]); + + if (!isEmptyBlock) { + return null; + } + + return createPortal( + , + anchorElem, + ); +}; + +const FloatingBlockTypeToolbarPlugin = ({ + anchorElem = document.body, +}: { + anchorElem?: HTMLElement + }): JSX.Element | null => { + const [editor] = useLexicalComposerContext(); + return useFloatingBlockTypeToolbar(editor, anchorElem); +}; + +export default FloatingBlockTypeToolbarPlugin; diff --git a/app/soapbox/features/compose/editor/plugins/floating-text-format-toolbar-plugin.tsx b/app/soapbox/features/compose/editor/plugins/floating-text-format-toolbar-plugin.tsx index 078f1822e..cf84684c2 100644 --- a/app/soapbox/features/compose/editor/plugins/floating-text-format-toolbar-plugin.tsx +++ b/app/soapbox/features/compose/editor/plugins/floating-text-format-toolbar-plugin.tsx @@ -86,7 +86,7 @@ interface IToolbarButton extends React.HTMLAttributes { icon: string } -const ToolbarButton: React.FC = ({ active, icon, ...props }) => ( +export const ToolbarButton: React.FC = ({ active, icon, ...props }) => (