Allow creating horizontal lines

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2023-07-05 13:48:50 +02:00
parent eedd894ba8
commit cc65ec387a
3 changed files with 217 additions and 13 deletions

View File

@ -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<string, IComposeEditor>(({
{features.richText && <ListPlugin />}
{features.richText && floatingAnchorElem && (
<>
<FloatingBlockTypeToolbarPlugin anchorElem={floatingAnchorElem} />
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
<FloatingLinkEditorPlugin anchorElem={floatingAnchorElem} />
</>

View File

@ -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<HTMLDivElement | null>(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 (
<div
ref={popupCharStylesEditorRef}
className='absolute left-0 top-0 z-10 flex h-[38px] gap-0.5 rounded-lg bg-white p-1 opacity-0 shadow-lg transition-[opacity] dark:bg-gray-900'
>
{editor.isEditable() && (
<>
<ToolbarButton
onClick={createHorizontalLine}
aria-label='Insert horizontal line'
icon={require('@tabler/icons/line-dashed.svg')}
/>
</>
)}
</div>
);
};
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(
<BlockTypeFloatingToolbar
editor={editor}
anchorElem={anchorElem}
/>,
anchorElem,
);
};
const FloatingBlockTypeToolbarPlugin = ({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement
}): JSX.Element | null => {
const [editor] = useLexicalComposerContext();
return useFloatingBlockTypeToolbar(editor, anchorElem);
};
export default FloatingBlockTypeToolbarPlugin;

View File

@ -86,7 +86,7 @@ interface IToolbarButton extends React.HTMLAttributes<HTMLButtonElement> {
icon: string
}
const ToolbarButton: React.FC<IToolbarButton> = ({ active, icon, ...props }) => (
export const ToolbarButton: React.FC<IToolbarButton> = ({ active, icon, ...props }) => (
<button
className={clsx(
'flex cursor-pointer rounded-lg border-0 bg-none p-1 align-middle hover:bg-gray-100 disabled:cursor-not-allowed disabled:hover:bg-none hover:dark:bg-primary-700',
@ -125,10 +125,7 @@ const BlockTypeDropdown = ({ editor, anchorElem, blockType, icon }: {
if (blockType !== headingSize) {
editor.update(() => {
const selection = $getSelection();
if (
$isRangeSelection(selection) ||
DEPRECATED_$isGridSelection(selection)
) {
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
$setBlocksType(selection, () => $createHeadingNode(headingSize));
}
});
@ -155,10 +152,7 @@ const BlockTypeDropdown = ({ editor, anchorElem, blockType, icon }: {
if (blockType !== 'quote') {
editor.update(() => {
const selection = $getSelection();
if (
$isRangeSelection(selection) ||
DEPRECATED_$isGridSelection(selection)
) {
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
$setBlocksType(selection, () => $createQuoteNode());
}
});
@ -170,10 +164,7 @@ const BlockTypeDropdown = ({ editor, anchorElem, blockType, icon }: {
editor.update(() => {
let selection = $getSelection();
if (
$isRangeSelection(selection) ||
DEPRECATED_$isGridSelection(selection)
) {
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
if (selection.isCollapsed()) {
$setBlocksType(selection, () => $createCodeNode());
} else {