lexical: remove markdown support
This commit is contained in:
parent
e619ffffdd
commit
373fe3a77e
8 changed files with 50 additions and 2317 deletions
|
@ -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);
|
||||
const paragraph = $createParagraphNode();
|
||||
const textNode = $createTextNode(compose.text);
|
||||
|
||||
paragraph.append(textNode);
|
||||
paragraph.append(textNode);
|
||||
|
||||
$getRoot()
|
||||
.clear()
|
||||
.append(paragraph);
|
||||
}
|
||||
$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;
|
Loading…
Reference in a new issue