lexical: remove markdown support

environments/review-lexical-ujdd17/deployments/4007
Alex Gleason 1 year ago
parent e619ffffdd
commit 373fe3a77e
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7

@ -61,7 +61,6 @@
"@lexical/selection": "^0.11.3",
"@lexical/table": "^0.11.3",
"@lexical/utils": "^0.11.3",
"@mkljczk/lexical-remark": "^0.3.9",
"@popperjs/core": "^2.11.5",
"@reach/combobox": "^0.18.0",
"@reach/menu-button": "^0.18.0",

@ -248,7 +248,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
{features.privacyScopes && !group && !groupId && <PrivacyDropdown composeId={id} />}
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
{features.spoilers && <SpoilerButton composeId={id} />}
{!wysiwygEditor && features.richText && <MarkdownButton composeId={id} />}
{features.richText && <MarkdownButton composeId={id} />}
</HStack>
), [features, id]);

@ -1,11 +0,0 @@
import { $createImageNode } from '../nodes/image-node';
import type { ImportHandler } from '@mkljczk/lexical-remark';
import type { LexicalNode } from 'lexical';
const importImage: ImportHandler<any> /* TODO */ = (node: LexicalNode, parser) => {
const lexicalNode = $createImageNode({ altText: node.alt ?? '', src: node.url });
parser.append(lexicalNode);
};
export { importImage };

@ -1,7 +1,7 @@
/**
* 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.
* LICENSE file in the `/src/features/compose/editor` directory.
*/
import { AutoLinkPlugin, createLinkMatcherWithRegExp } from '@lexical/react/LexicalAutoLinkPlugin';
@ -10,24 +10,17 @@ 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 { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { $createRemarkExport, $createRemarkImport } from '@mkljczk/lexical-remark';
import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin';
import clsx from 'clsx';
import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical';
import React, { useMemo, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
import { useAppDispatch } 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 MentionPlugin from './plugins/mention-plugin';
import StatePlugin from './plugins/state-plugin';
@ -53,9 +46,10 @@ interface IComposeEditor {
placeholder?: JSX.Element | string
}
const theme = {
const theme: InitialConfigType['theme'] = {
hashtag: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue',
mention: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue',
link: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue',
text: {
bold: 'font-bold',
code: 'font-mono',
@ -85,12 +79,11 @@ const ComposeEditor = React.forwardRef<string, IComposeEditor>(({
placeholder,
}, editorStateRef) => {
const dispatch = useAppDispatch();
const features = useFeatures();
const nodes = useNodes();
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
const initialConfig: InitialConfigType = useMemo(() => ({
const initialConfig = useMemo<InitialConfigType>(() => ({
namespace: 'ComposeForm',
onError: console.error,
nodes,
@ -106,35 +99,18 @@ const ComposeEditor = React.forwardRef<string, IComposeEditor>(({
}
return () => {
if (compose.content_type === 'text/markdown') {
$createRemarkImport({
handlers: {
image: importImage,
},
})(compose.text);
} else {
const paragraph = $createParagraphNode();
const textNode = $createTextNode(compose.text);
paragraph.append(textNode);
$getRoot()
.clear()
.append(paragraph);
}
const paragraph = $createParagraphNode();
const textNode = $createTextNode(compose.text);
paragraph.append(textNode);
$getRoot()
.clear()
.append(paragraph);
};
}),
}), []);
const [floatingAnchorElem, setFloatingAnchorElem] =
useState<HTMLDivElement | null>(null);
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
if (_floatingAnchorElem !== null) {
setFloatingAnchorElem(_floatingAnchorElem);
}
};
const handlePaste: React.ClipboardEventHandler<HTMLDivElement> = (e) => {
if (onPaste && e.clipboardData && e.clipboardData.files.length === 1) {
onPaste(e.clipboardData.files);
@ -152,12 +128,12 @@ const ComposeEditor = React.forwardRef<string, IComposeEditor>(({
return (
<LexicalComposer initialConfig={initialConfig}>
<div className={clsx('relative', className)} data-markup>
<RichTextPlugin
<div className={clsx('relative', className)}>
<PlainTextPlugin
contentEditable={
<div ref={onRef} onFocus={onFocus} onPaste={handlePaste}>
<div onFocus={onFocus} onPaste={handlePaste}>
<ContentEditable
className={clsx('outline-none transition-[min-height] motion-reduce:transition-none', {
className={clsx('text-[1rem] outline-none transition-[min-height] motion-reduce:transition-none', {
'min-h-[39px]': condensed,
'min-h-[99px]': !condensed,
})}
@ -177,7 +153,9 @@ const ComposeEditor = React.forwardRef<string, IComposeEditor>(({
ErrorBoundary={LexicalErrorBoundary}
/>
<OnChangePlugin onChange={(_, editor) => {
if (editorStateRef) (editorStateRef as any).current = editor.getEditorState().read($createRemarkExport());
if (editorStateRef && typeof editorStateRef !== 'function') {
editorStateRef.current = editor.getEditorState().read(() => $getRoot().getTextContent());
}
}}
/>
<HistoryPlugin />
@ -185,15 +163,6 @@ const ComposeEditor = React.forwardRef<string, IComposeEditor>(({
<MentionPlugin />
<AutosuggestPlugin composeId={composeId} suggestionsHidden={suggestionsHidden} setSuggestionsHidden={setSuggestionsHidden} />
<AutoLinkPlugin matchers={LINK_MATCHERS} />
{features.richText && <LinkPlugin />}
{features.richText && <ListPlugin />}
{features.richText && floatingAnchorElem && (
<>
<FloatingBlockTypeToolbarPlugin anchorElem={floatingAnchorElem} />
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
<FloatingLinkEditorPlugin anchorElem={floatingAnchorElem} />
</>
)}
<StatePlugin composeId={composeId} handleSubmit={handleSubmit} />
<FocusPlugin autoFocus={autoFocus} />
</div>

@ -1,297 +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 { $createHorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode';
import { $wrapNodeInElement, mergeRegister } from '@lexical/utils';
import {
$createParagraphNode,
$getSelection,
$insertNodes,
$isRangeSelection,
$isRootOrShadowRoot,
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 { defineMessages, useIntl } from 'react-intl';
import { uploadFile } from 'soapbox/actions/compose';
import { useAppDispatch, useInstance } from 'soapbox/hooks';
import { $createImageNode } from '../nodes/image-node';
import { setFloatingElemPosition } from '../utils/set-floating-elem-position';
import { ToolbarButton } from './floating-text-format-toolbar-plugin';
import type { List as ImmutableList } from 'immutable';
const messages = defineMessages({
createHorizontalLine: { id: 'compose_form.lexical.create_horizontal_line', defaultMessage: 'Create horizontal line' },
uploadMedia: { id: 'compose_form.lexical.upload_media', defaultMessage: 'Upload media' },
});
interface IUploadButton {
onSelectFile: (src: string) => void
}
const UploadButton: React.FC<IUploadButton> = ({ onSelectFile }) => {
const intl = useIntl();
const { configuration } = useInstance();
const dispatch = useAppDispatch();
const [disabled, setDisabled] = useState(false);
const fileElement = useRef<HTMLInputElement>(null);
const attachmentTypes = configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>;
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
if (e.target.files?.length) {
setDisabled(true);
// @ts-ignore
dispatch(uploadFile(
e.target.files.item(0) as File,
intl,
({ url }) => {
onSelectFile(url);
setDisabled(false);
},
() => setDisabled(false),
));
}
};
const handleClick = () => {
fileElement.current?.click();
};
const src = require('@tabler/icons/photo.svg');
return (
<label>
<ToolbarButton
onClick={handleClick}
aria-label={intl.formatMessage(messages.uploadMedia)}
icon={src}
/>
<input
ref={fileElement}
type='file'
multiple
accept={attachmentTypes ? attachmentTypes.filter(type => type.startsWith('image/')).toArray().join(',') : 'image/*'}
onChange={handleChange}
disabled={disabled}
className='hidden'
/>
</label>
);
};
const BlockTypeFloatingToolbar = ({
editor,
anchorElem,
}: {
editor: LexicalEditor
anchorElem: HTMLElement
}): JSX.Element => {
const intl = useIntl();
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null);
const instance = useInstance();
const allowInlineImages = instance.pleroma.getIn(['metadata', 'markup', 'allow_inline_images']);
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());
}
});
};
const createImage = (src: string) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
const imageNode = $createImageNode({ altText: '', src });
$insertNodes([imageNode]);
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd();
}
}
});
};
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() && (
<>
{allowInlineImages && <UploadButton onSelectFile={createImage} />}
<ToolbarButton
onClick={createHorizontalLine}
aria-label={intl.formatMessage(messages.createHorizontalLine)}
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;

@ -1,278 +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 { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $findMatchingParent, mergeRegister } from '@lexical/utils';
import {
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_CRITICAL,
COMMAND_PRIORITY_LOW,
GridSelection,
LexicalEditor,
NodeSelection,
RangeSelection,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { Icon } from 'soapbox/components/ui';
import { getSelectedNode } from '../utils/get-selected-node';
import { setFloatingElemPosition } from '../utils/set-floating-elem-position';
import { sanitizeUrl } from '../utils/url';
const FloatingLinkEditor = ({
editor,
anchorElem,
}: {
editor: LexicalEditor
anchorElem: HTMLElement
}): JSX.Element => {
const editorRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [linkUrl, setLinkUrl] = useState('');
const [isEditMode, setEditMode] = useState(false);
const [lastSelection, setLastSelection] = useState<
RangeSelection | GridSelection | NodeSelection | null
>(null);
const updateLinkEditor = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const node = getSelectedNode(selection);
const parent = node.getParent();
if ($isLinkNode(parent)) {
setLinkUrl(parent.getURL());
} else if ($isLinkNode(node)) {
setLinkUrl(node.getURL());
} else {
setLinkUrl('');
}
}
const editorElem = editorRef.current;
const nativeSelection = window.getSelection();
const activeElement = document.activeElement;
if (editorElem === null) {
return;
}
const rootElement = editor.getRootElement();
if (
selection !== null &&
nativeSelection !== null &&
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode)
) {
const domRange = nativeSelection.getRangeAt(0);
let rect;
if (nativeSelection.anchorNode === rootElement) {
let inner = rootElement;
while (inner.firstElementChild !== null) {
inner = inner.firstElementChild as HTMLElement;
}
rect = inner.getBoundingClientRect();
} else {
rect = domRange.getBoundingClientRect();
}
setFloatingElemPosition(rect, editorElem, anchorElem);
setLastSelection(selection);
} else if (!activeElement || activeElement.className !== 'link-input') {
if (rootElement !== null) {
setFloatingElemPosition(null, editorElem, anchorElem);
}
setLastSelection(null);
setEditMode(false);
setLinkUrl('');
}
return true;
}, [anchorElem, editor]);
useEffect(() => {
const scrollerElem = anchorElem.parentElement;
const update = () => {
editor.getEditorState().read(() => {
updateLinkEditor();
});
};
window.addEventListener('resize', update);
if (scrollerElem) {
scrollerElem.addEventListener('scroll', update);
}
return () => {
window.removeEventListener('resize', update);
if (scrollerElem) {
scrollerElem.removeEventListener('scroll', update);
}
};
}, [anchorElem.parentElement, editor, updateLinkEditor]);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateLinkEditor();
});
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateLinkEditor();
return true;
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, updateLinkEditor]);
useEffect(() => {
editor.getEditorState().read(() => {
updateLinkEditor();
});
}, [editor, updateLinkEditor]);
useEffect(() => {
if (isEditMode && inputRef.current) {
inputRef.current.focus();
}
}, [isEditMode]);
return (
<div
ref={editorRef}
className='absolute left-0 top-0 z-10 w-full max-w-sm rounded-lg bg-white opacity-0 shadow-md transition-opacity will-change-transform dark:bg-gray-900'
>
<div className='relative mx-3 my-2 box-border block rounded-2xl border-0 bg-gray-100 px-3 py-2 text-sm text-gray-800 outline-0 dark:bg-gray-800 dark:text-gray-100'>
{isEditMode ? (
<>
<input
className='-mx-3 -my-2 w-full border-0 bg-transparent px-3 py-2 text-sm text-gray-900 outline-0 dark:text-gray-100'
ref={inputRef}
value={linkUrl}
onChange={(event) => {
setLinkUrl(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
if (lastSelection !== null) {
if (linkUrl !== '') {
editor.dispatchCommand(
TOGGLE_LINK_COMMAND,
sanitizeUrl(linkUrl),
);
} else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}
setEditMode(false);
}
} else if (event.key === 'Escape') {
event.preventDefault();
setEditMode(false);
}
}}
/>
<div
className='absolute inset-y-0 right-0 flex w-9 cursor-pointer items-center justify-center'
role='button'
tabIndex={0}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}}
>
<Icon className='h-5 w-5' src={require('@tabler/icons/x.svg')} />
</div>
</>
) : (
<>
<a className='mr-8 block overflow-hidden text-ellipsis whitespace-nowrap text-primary-600 no-underline hover:underline dark:text-accent-blue' href={linkUrl} target='_blank' rel='noopener noreferrer'>
{linkUrl}
</a>
<div
className='absolute inset-y-0 right-0 flex w-9 cursor-pointer items-center justify-center'
role='button'
tabIndex={0}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setEditMode(true);
}}
>
<Icon className='h-5 w-5' src={require('@tabler/icons/pencil.svg')} />
</div>
</>
)}
</div>
</div>
);
};
const useFloatingLinkEditorToolbar = (
editor: LexicalEditor,
anchorElem: HTMLElement,
): JSX.Element | null => {
const [activeEditor, setActiveEditor] = useState(editor);
const [isLink, setIsLink] = useState(false);
const updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const node = getSelectedNode(selection);
const linkParent = $findMatchingParent(node, $isLinkNode);
const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode);
// We don't want this menu to open for auto links.
if (linkParent !== null && autoLinkParent === null) {
setIsLink(true);
} else {
setIsLink(false);
}
}
}, []);
useEffect(() => {
return editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => {
updateToolbar();
setActiveEditor(newEditor);
return false;
},
COMMAND_PRIORITY_CRITICAL,
);
}, [editor, updateToolbar]);
return isLink
? createPortal(
<FloatingLinkEditor editor={activeEditor} anchorElem={anchorElem} />,
anchorElem,
)
: null;
};
const FloatingLinkEditorPlugin = ({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement
}): JSX.Element | null => {
const [editor] = useLexicalComposerContext();
return useFloatingLinkEditorToolbar(editor, anchorElem);
};
export default FloatingLinkEditorPlugin;

@ -1,566 +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 { $createCodeNode, $isCodeHighlightNode } from '@lexical/code';
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
import {
$isListNode,
INSERT_ORDERED_LIST_COMMAND,
INSERT_UNORDERED_LIST_COMMAND,
ListNode,
REMOVE_LIST_COMMAND,
} from '@lexical/list';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import {
$createHeadingNode,
$createQuoteNode,
$isHeadingNode,
HeadingTagType,
} from '@lexical/rich-text';
import {
$setBlocksType,
} from '@lexical/selection';
import { $findMatchingParent, $getNearestNodeOfType, mergeRegister } from '@lexical/utils';
import clsx from 'clsx';
import {
$createParagraphNode,
$getSelection,
$isRangeSelection,
$isRootOrShadowRoot,
$isTextNode,
COMMAND_PRIORITY_LOW,
DEPRECATED_$isGridSelection,
FORMAT_TEXT_COMMAND,
LexicalEditor,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { defineMessages, useIntl } from 'react-intl';
import { Icon } from 'soapbox/components/ui';
import { useInstance } from 'soapbox/hooks';
import { getDOMRangeRect } from '../utils/get-dom-range-rect';
import { getSelectedNode } from '../utils/get-selected-node';
import { setFloatingElemPosition } from '../utils/set-floating-elem-position';
const messages = defineMessages({
formatBold: { id: 'compose_form.lexical.format_bold', defaultMessage: 'Format bold' },
formatItalic: { id: 'compose_form.lexical.format_italic', defaultMessage: 'Format italic' },
formatUnderline: { id: 'compose_form.lexical.format_underline', defaultMessage: 'Format underline' },
formatStrikethrough: { id: 'compose_form.lexical.format_strikethrough', defaultMessage: 'Format strikethrough' },
insertCodeBlock: { id: 'compose_form.lexical.insert_code_block', defaultMessage: 'Insert code block' },
insertLink: { id: 'compose_form.lexical.insert_link', defaultMessage: 'Insert link' },
});
const blockTypeToIcon = {
bullet: require('@tabler/icons/list.svg'),
check: require('@tabler/icons/list-check.svg'),
code: require('@tabler/icons/code.svg'),
h1: require('@tabler/icons/h-1.svg'),
h2: require('@tabler/icons/h-2.svg'),
h3: require('@tabler/icons/h-3.svg'),
h4: require('@tabler/icons/h-4.svg'),
h5: require('@tabler/icons/h-5.svg'),
h6: require('@tabler/icons/h-6.svg'),
number: require('@tabler/icons/list-numbers.svg'),
paragraph: require('@tabler/icons/align-left.svg'),
quote: require('@tabler/icons/blockquote.svg'),
};
const blockTypeToBlockName = {
bullet: 'Bulleted List',
check: 'Check List',
code: 'Code Block',
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
h4: 'Heading 4',
h5: 'Heading 5',
h6: 'Heading 6',
number: 'Numbered List',
paragraph: 'Normal',
quote: 'Quote',
};
interface IToolbarButton extends React.HTMLAttributes<HTMLButtonElement> {
active?: boolean
icon: string
}
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',
{ 'bg-gray-100/30 dark:bg-gray-800/30': active },
)}
type='button'
{...props}
>
<Icon className='h-5 w-5' src={icon} />
</button>
);
const BlockTypeDropdown = ({ editor, anchorElem, blockType, icon }: {
editor: LexicalEditor
anchorElem: HTMLElement
blockType: keyof typeof blockTypeToBlockName
icon: string
}) => {
const instance = useInstance();
const [showDropDown, setShowDropDown] = useState(false);
const formatParagraph = () => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
$setBlocksType(selection, () => $createParagraphNode());
}
});
};
const formatHeading = (headingSize: HeadingTagType) => {
if (blockType !== headingSize) {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
$setBlocksType(selection, () => $createHeadingNode(headingSize));
}
});
}
};
const formatBulletList = () => {
if (blockType !== 'bullet') {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
}
};
const formatNumberedList = () => {
if (blockType !== 'number') {
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
}
};
const formatQuote = () => {
if (blockType !== 'quote') {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
$setBlocksType(selection, () => $createQuoteNode());
}
});
}
};
const formatCode = () => {
if (blockType !== 'code') {
editor.update(() => {
let selection = $getSelection();
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
if (selection.isCollapsed()) {
$setBlocksType(selection, () => $createCodeNode());
} else {
const textContent = selection.getTextContent();
const codeNode = $createCodeNode();
selection.insertNodes([codeNode]);
selection = $getSelection();
if ($isRangeSelection(selection))
selection.insertRawText(textContent);
}
}
});
}
};
return (
<>
<button
onClick={() => setShowDropDown(!showDropDown)}
className='relative 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'
aria-label=''
type='button'
>
<Icon src={icon} />
<Icon src={require('@tabler/icons/chevron-down.svg')} className='-bottom-2 h-4 w-4' />
{showDropDown && (
<div
className='absolute left-0 top-9 z-10 flex h-[38px] gap-0.5 rounded-lg bg-white p-1 shadow-lg transition-[opacity] dark:bg-gray-900'
>
<ToolbarButton
onClick={formatParagraph}
active={blockType === 'paragraph'}
icon={blockTypeToIcon.paragraph}
/>
{instance.pleroma.getIn(['metadata', 'markup', 'allow_headings']) === true && (
<>
<ToolbarButton
onClick={() => formatHeading('h1')}
active={blockType === 'h1'}
icon={blockTypeToIcon.h1}
/>
<ToolbarButton
onClick={() => formatHeading('h2')}
active={blockType === 'h2'}
icon={blockTypeToIcon.h2}
/>
<ToolbarButton
onClick={() => formatHeading('h3')}
active={blockType === 'h3'}
icon={blockTypeToIcon.h3}
/>
</>
)}
<ToolbarButton
onClick={formatBulletList}
active={blockType === 'bullet'}
icon={blockTypeToIcon.bullet}
/>
<ToolbarButton
onClick={formatNumberedList}
active={blockType === 'number'}
icon={blockTypeToIcon.number}
/>
<ToolbarButton
onClick={formatQuote}
active={blockType === 'quote'}
icon={blockTypeToIcon.quote}
/>
<ToolbarButton
onClick={formatCode}
active={blockType === 'code'}
icon={blockTypeToIcon.code}
/>
</div>
)}
</button>
</>
);
};
const TextFormatFloatingToolbar = ({
editor,
anchorElem,
blockType,
isLink,
isBold,
isItalic,
isUnderline,
isCode,
isStrikethrough,
}: {
editor: LexicalEditor
anchorElem: HTMLElement
blockType: keyof typeof blockTypeToBlockName
isBold: boolean
isCode: boolean
isItalic: boolean
isLink: boolean
isStrikethrough: boolean
isUnderline: boolean
}): JSX.Element => {
const intl = useIntl();
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null);
const insertLink = useCallback(() => {
if (!isLink) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://');
} else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}
}, [editor, isLink]);
const updateTextFormatFloatingToolbar = 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.isCollapsed &&
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode)
) {
const rangeRect = getDOMRangeRect(nativeSelection, rootElement);
setFloatingElemPosition(rangeRect, popupCharStylesEditorElem, anchorElem);
}
}, [editor, anchorElem]);
useEffect(() => {
const scrollerElem = anchorElem.parentElement;
const update = () => {
editor.getEditorState().read(() => {
updateTextFormatFloatingToolbar();
});
};
window.addEventListener('resize', update);
if (scrollerElem) {
scrollerElem.addEventListener('scroll', update);
}
return () => {
window.removeEventListener('resize', update);
if (scrollerElem) {
scrollerElem.removeEventListener('scroll', update);
}
};
}, [editor, updateTextFormatFloatingToolbar, anchorElem]);
useEffect(() => {
editor.getEditorState().read(() => {
updateTextFormatFloatingToolbar();
});
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateTextFormatFloatingToolbar();
});
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateTextFormatFloatingToolbar();
return false;
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, updateTextFormatFloatingToolbar]);
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() && (
<>
<BlockTypeDropdown
editor={editor}
anchorElem={anchorElem}
blockType={blockType}
icon={blockTypeToIcon[blockType]}
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
}}
active={isBold}
aria-label={intl.formatMessage(messages.formatBold)}
icon={require('@tabler/icons/bold.svg')}
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
}}
active={isItalic}
aria-label={intl.formatMessage(messages.formatItalic)}
icon={require('@tabler/icons/italic.svg')}
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
}}
active={isUnderline}
aria-label={intl.formatMessage(messages.formatUnderline)}
icon={require('@tabler/icons/underline.svg')}
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
}}
active={isStrikethrough}
aria-label={intl.formatMessage(messages.formatStrikethrough)}
icon={require('@tabler/icons/strikethrough.svg')}
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
}}
active={isCode}
aria-label={intl.formatMessage(messages.insertCodeBlock)}
icon={require('@tabler/icons/code.svg')}
/>
<ToolbarButton
onClick={insertLink}
active={isLink}
aria-label={intl.formatMessage(messages.insertLink)}
icon={require('@tabler/icons/link.svg')}
/>
</>
)}
</div>
);
};
const useFloatingTextFormatToolbar = (
editor: LexicalEditor,
anchorElem: HTMLElement,
): JSX.Element | null => {
const [blockType, setBlockType] =
useState<keyof typeof blockTypeToBlockName>('paragraph');
const [isText, setIsText] = useState(false);
const [isLink, setIsLink] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isCode, setIsCode] = 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))
) {
setIsText(false);
return;
}
if (!$isRangeSelection(selection)) {
return;
}
const anchorNode = selection.anchor.getNode();
let element =
anchorNode.getKey() === 'root'
? anchorNode
: $findMatchingParent(anchorNode, (e) => {
const parent = e.getParent();
return parent !== null && $isRootOrShadowRoot(parent);
});
if (element === null) {
element = anchorNode.getTopLevelElementOrThrow();
}
const elementKey = element.getKey();
const elementDOM = editor.getElementByKey(elementKey);
const node = getSelectedNode(selection);
// Update text format
setIsBold(selection.hasFormat('bold'));
setIsItalic(selection.hasFormat('italic'));
setIsUnderline(selection.hasFormat('underline'));
setIsStrikethrough(selection.hasFormat('strikethrough'));
setIsCode(selection.hasFormat('code'));
if (elementDOM !== null) {
if ($isListNode(element)) {
const parentList = $getNearestNodeOfType<ListNode>(
anchorNode,
ListNode,
);
const type = parentList
? parentList.getListType()
: element.getListType();
setBlockType(type);
} else {
const type = $isHeadingNode(element)
? element.getTag()
: element.getType();
if (type in blockTypeToBlockName) {
setBlockType(type as keyof typeof blockTypeToBlockName);
}
}
}
// Update links
const parent = node.getParent();
if ($isLinkNode(parent) || $isLinkNode(node)) {
setIsLink(true);
} else {
setIsLink(false);
}
if (!$isCodeHighlightNode(selection.anchor.getNode()) && selection.getTextContent() !== '') {
setIsText($isTextNode(node));
} else {
setIsText(false);
}
});
}, [editor]);
useEffect(() => {
document.addEventListener('selectionchange', updatePopup);
return () => {
document.removeEventListener('selectionchange', updatePopup);
};
}, [updatePopup]);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(() => {
updatePopup();
}),
editor.registerRootListener(() => {
if (editor.getRootElement() === null) {
setIsText(false);
}
}),
);
}, [editor, updatePopup]);
if (!isText || isLink) {
return null;
}
return createPortal(
<TextFormatFloatingToolbar
editor={editor}
anchorElem={anchorElem}
blockType={blockType}
isLink={isLink}
isBold={isBold}
isItalic={isItalic}
isStrikethrough={isStrikethrough}
isUnderline={isUnderline}
isCode={isCode}
/>,
anchorElem,
);
};
const FloatingTextFormatToolbarPlugin = ({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement
}): JSX.Element | null => {
const [editor] = useLexicalComposerContext();
return useFloatingTextFormatToolbar(editor, anchorElem);
};
export default FloatingTextFormatToolbarPlugin;

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save