Bring back the WYSIWYG editor

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-05-17 23:27:10 +02:00
parent 32db7c6fad
commit c4d085c767
20 changed files with 4059 additions and 187 deletions

View file

@ -55,12 +55,16 @@
"@fontsource/roboto-mono": "^5.0.0",
"@fontsource/tajawal": "^5.0.8",
"@gamestdio/websocket": "^0.3.2",
"@lexical/clipboard": "^0.14.2",
"@lexical/hashtag": "^0.14.2",
"@lexical/link": "^0.14.2",
"@lexical/react": "^0.14.2",
"@lexical/selection": "^0.14.2",
"@lexical/utils": "^0.14.2",
"@lexical/clipboard": "^0.14.5",
"@lexical/code": "^0.14.5",
"@lexical/hashtag": "^0.14.5",
"@lexical/link": "^0.14.5",
"@lexical/list": "^0.14.5",
"@lexical/react": "^0.14.5",
"@lexical/rich-text": "^0.14.5",
"@lexical/selection": "^0.14.5",
"@lexical/utils": "^0.14.5",
"@mkljczk/lexical-remark": "^0.4.0",
"@mkljczk/react-hotkeys": "^1.2.2",
"@popperjs/core": "^2.11.5",
"@reach/combobox": "^0.18.0",
@ -124,7 +128,7 @@
"intl-pluralrules": "^2.0.0",
"isomorphic-dompurify": "^2.3.0",
"leaflet": "^1.8.0",
"lexical": "^0.14.2",
"lexical": "^0.14.5",
"line-awesome": "^1.3.0",
"localforage": "^1.10.0",
"lodash": "^4.7.11",

View file

@ -367,6 +367,7 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) =>
}
const idempotencyKey = compose.idempotencyKey;
const contentType = compose.content_type === 'wysiwyg' ? 'text/markdown' : compose.content_type;
const params: Record<string, any> = {
status,
@ -376,7 +377,7 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) =>
sensitive: compose.sensitive,
spoiler_text: compose.spoiler_text,
visibility: compose.privacy,
content_type: compose.content_type,
content_type: contentType,
poll: compose.poll,
scheduled_at: compose.schedule,
language: compose.language,

View file

@ -6,7 +6,6 @@ import { Link, useHistory } from 'react-router-dom';
import { length } from 'stringz';
import {
changeCompose,
submitCompose,
clearComposeSuggestions,
fetchComposeSuggestions,
@ -140,7 +139,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
if (!canSubmit) return;
e?.preventDefault();
dispatch(changeCompose(id, text));
// dispatch(changeCompose(id, text));
dispatch(submitCompose(id, { history }));
editorRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);

View file

@ -10,6 +10,7 @@ const messages = defineMessages({
content_type_plaintext: { id: 'preferences.options.content_type_plaintext', defaultMessage: 'Plain text' },
content_type_markdown: { id: 'preferences.options.content_type_markdown', defaultMessage: 'Markdown' },
content_type_html: { id: 'preferences.options.content_type_html', defaultMessage: 'HTML' },
content_type_wysiwyg: { id: 'preferences.options.content_type_wysiwyf', defaultMessage: 'WYSIWYG' },
change_content_type: { id: 'compose_form.content_type.change', defaultMessage: 'Change content type' },
});
@ -40,6 +41,11 @@ const ContentTypeButton: React.FC<IContentTypeButton> = ({ composeId }) => {
text: intl.formatMessage(messages.content_type_html),
value: 'text/html',
},
{
icon: require('@tabler/icons/outline/text-caption.svg'),
text: intl.formatMessage(messages.content_type_wysiwyg),
value: 'wysiwyg',
},
];
const option = options.find(({ value }) => value === contentType);

View file

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

View file

@ -11,17 +11,24 @@ import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
import { HashtagPlugin } from '@lexical/react/LexicalHashtagPlugin';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { $createRemarkExport, $createRemarkImport } from '@mkljczk/lexical-remark';
import clsx from 'clsx';
import { $createParagraphNode, $createTextNode, $getRoot, type LexicalEditor } from 'lexical';
import { $createParagraphNode, $createTextNode, $getRoot, type EditorState, type LexicalEditor } from 'lexical';
import React, { useMemo, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useAppDispatch } from 'soapbox/hooks';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import { importImage } from './handlers/image';
import { useNodes } from './nodes';
import AutosuggestPlugin from './plugins/autosuggest-plugin';
import FloatingBlockTypeToolbarPlugin from './plugins/floating-block-type-toolbar-plugin';
import FloatingLinkEditorPlugin from './plugins/floating-link-editor-plugin';
import FloatingTextFormatToolbarPlugin from './plugins/floating-text-format-toolbar-plugin';
import FocusPlugin from './plugins/focus-plugin';
import RefPlugin from './plugins/ref-plugin';
import StatePlugin from './plugins/state-plugin';
@ -83,7 +90,8 @@ const ComposeEditor = React.forwardRef<LexicalEditor, IComposeEditor>(({
placeholder,
}, ref) => {
const dispatch = useAppDispatch();
const nodes = useNodes();
const isWysiwyg = useCompose(composeId).content_type === 'wysiwyg';
const nodes = useNodes(isWysiwyg);
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
@ -103,17 +111,34 @@ const ComposeEditor = React.forwardRef<LexicalEditor, IComposeEditor>(({
}
return () => {
const paragraph = $createParagraphNode();
const textNode = $createTextNode(compose.text);
if (isWysiwyg) {
$createRemarkImport({
handlers: {
image: importImage,
},
})(compose.text);
} else {
const paragraph = $createParagraphNode();
const textNode = $createTextNode(compose.text);
paragraph.append(textNode);
paragraph.append(textNode);
$getRoot()
.clear()
.append(paragraph);
$getRoot()
.clear()
.append(paragraph);
}
};
}),
}), []);
}), [isWysiwyg]);
const [floatingAnchorElem, setFloatingAnchorElem] =
useState<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) {
@ -122,6 +147,17 @@ const ComposeEditor = React.forwardRef<LexicalEditor, IComposeEditor>(({
}
};
const handleChange = (_: EditorState, editor: LexicalEditor) => {
if (onChange) {
onChange(editor.getEditorState().read($createRemarkExport({
handlers: {
hashtag: (node) => ({ type: 'text', value: node.getTextContent() }),
mention: (node) => ({ type: 'text', value: node.getTextContent() }),
},
})));
}
};
let textareaPlaceholder = placeholder || <FormattedMessage id='compose_form.placeholder' defaultMessage="What's on your mind?" />;
if (eventDiscussion) {
@ -131,11 +167,11 @@ const ComposeEditor = React.forwardRef<LexicalEditor, IComposeEditor>(({
}
return (
<LexicalComposer initialConfig={initialConfig}>
<LexicalComposer key={isWysiwyg ? 'wysiwyg' : ''} initialConfig={initialConfig}>
<div className={clsx('relative', className)}>
<PlainTextPlugin
<RichTextPlugin
contentEditable={
<div onFocus={onFocus} onPaste={handlePaste}>
<div onFocus={onFocus} onPaste={handlePaste} ref={onRef}>
<ContentEditable
className={clsx('relative z-10 text-[1rem] outline-none transition-[min-height] motion-reduce:transition-none', {
'min-h-[39px]': condensed,
@ -156,15 +192,21 @@ const ComposeEditor = React.forwardRef<LexicalEditor, IComposeEditor>(({
)}
ErrorBoundary={LexicalErrorBoundary}
/>
<OnChangePlugin onChange={(_, editor) => {
onChange?.(editor.getEditorState().read(() => $getRoot().getTextContent()));
}}
/>
<OnChangePlugin onChange={handleChange} />
<HistoryPlugin />
<HashtagPlugin />
<AutosuggestPlugin composeId={composeId} suggestionsHidden={suggestionsHidden} setSuggestionsHidden={setSuggestionsHidden} />
<AutoLinkPlugin matchers={LINK_MATCHERS} />
<StatePlugin composeId={composeId} />
{isWysiwyg && <LinkPlugin />}
{isWysiwyg && <ListPlugin />}
{isWysiwyg && floatingAnchorElem && (
<>
<FloatingBlockTypeToolbarPlugin anchorElem={floatingAnchorElem} />
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
<FloatingLinkEditorPlugin anchorElem={floatingAnchorElem} />
</>
)}
<StatePlugin composeId={composeId} isWysiwyg={isWysiwyg} />
<SubmitPlugin composeId={composeId} handleSubmit={handleSubmit} />
<FocusPlugin autoFocus={autoFocus} />
<ClearEditorPlugin />

View file

@ -0,0 +1,357 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
import { mergeRegister } from '@lexical/utils';
import clsx from 'clsx';
import { List as ImmutableList } from 'immutable';
import {
$getNodeByKey,
$getSelection,
$isNodeSelection,
$setSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
DRAGSTART_COMMAND,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
KEY_ENTER_COMMAND,
KEY_ESCAPE_COMMAND,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import * as React from 'react';
import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import { HStack, IconButton } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import { normalizeAttachment } from 'soapbox/normalizers';
import { $isImageNode } from './image-node';
import type {
BaseSelection,
LexicalEditor,
NodeKey,
} from 'lexical';
const messages = defineMessages({
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
});
const imageCache = new Set();
const useSuspenseImage = (src: string) => {
if (!imageCache.has(src)) {
throw new Promise((resolve) => {
const img = new Image();
img.src = src;
img.onload = () => {
imageCache.add(src);
resolve(null);
};
});
}
};
const LazyImage = ({
altText,
className,
imageRef,
src,
}: {
altText: string;
className: string | null;
imageRef: {current: null | HTMLImageElement};
src: string;
}): JSX.Element => {
useSuspenseImage(src);
return (
<img
className={className || undefined}
src={src}
alt={altText}
ref={imageRef}
draggable='false'
/>
);
};
const ImageComponent = ({
src,
altText,
nodeKey,
}: {
altText: string;
nodeKey: NodeKey;
src: string;
}): JSX.Element => {
const intl = useIntl();
const dispatch = useAppDispatch();
const imageRef = useRef<null | HTMLImageElement>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey);
const [editor] = useLexicalComposerContext();
const [selection, setSelection] = useState<
BaseSelection | null
>(null);
const activeEditorRef = useRef<LexicalEditor | null>(null);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [dirtyDescription, setDirtyDescription] = useState<string | null>(null);
const deleteNode = useCallback(
() => {
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if ($isImageNode(node)) {
node.remove();
}
});
},
[nodeKey],
);
const previewImage = () => {
const image = normalizeAttachment({
type: 'image',
url: src,
altText,
});
dispatch(openModal('MEDIA', { media: ImmutableList.of(image), index: 0 }));
};
const onDelete = useCallback(
(payload: KeyboardEvent) => {
if (isSelected && $isNodeSelection($getSelection())) {
const event: KeyboardEvent = payload;
event.preventDefault();
deleteNode();
}
return false;
},
[isSelected, nodeKey],
);
const onEnter = useCallback(
(event: KeyboardEvent) => {
const latestSelection = $getSelection();
const buttonElem = buttonRef.current;
if (isSelected && $isNodeSelection(latestSelection) && latestSelection.getNodes().length === 1) {
if (buttonElem !== null && buttonElem !== document.activeElement) {
event.preventDefault();
buttonElem.focus();
return true;
}
}
return false;
},
[isSelected],
);
const onEscape = useCallback(
(event: KeyboardEvent) => {
if (buttonRef.current === event.target) {
$setSelection(null);
editor.update(() => {
setSelected(true);
const parentRootElement = editor.getRootElement();
if (parentRootElement !== null) {
parentRootElement.focus();
}
});
return true;
}
return false;
},
[editor, setSelected],
);
const handleKeyDown: React.KeyboardEventHandler = (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
handleInputBlur();
}
};
const handleInputBlur = () => {
setFocused(false);
if (dirtyDescription !== null) {
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if ($isImageNode(node)) {
node.setAltText(dirtyDescription);
}
setDirtyDescription(null);
});
}
};
const handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement> = e => {
setDirtyDescription(e.target.value);
};
const handleMouseEnter = () => {
setHovered(true);
};
const handleMouseLeave = () => {
setHovered(false);
};
const handleInputFocus = () => {
setFocused(true);
};
const handleClick = () => {
setFocused(true);
};
useEffect(() => {
let isMounted = true;
const unregister = mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
if (isMounted) {
setSelection(editorState.read(() => $getSelection()));
}
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_, activeEditor) => {
activeEditorRef.current = activeEditor;
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<MouseEvent>(
CLICK_COMMAND,
(payload) => {
const event = payload;
if (event.target === imageRef.current) {
if (event.shiftKey) {
setSelected(!isSelected);
} else {
clearSelection();
setSelected(true);
}
return true;
}
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
DRAGSTART_COMMAND,
(event) => {
if (event.target === imageRef.current) {
// TODO This is just a temporary workaround for FF to behave like other browsers.
// Ideally, this handles drag & drop too (and all browsers).
event.preventDefault();
return true;
}
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_DELETE_COMMAND,
onDelete,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_BACKSPACE_COMMAND,
onDelete,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(KEY_ENTER_COMMAND, onEnter, COMMAND_PRIORITY_LOW),
editor.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_LOW,
),
);
return () => {
isMounted = false;
unregister();
};
}, [
clearSelection,
editor,
isSelected,
nodeKey,
onDelete,
onEnter,
onEscape,
setSelected,
]);
const active = hovered || focused;
const description = dirtyDescription || (dirtyDescription !== '' && altText) || '';
const draggable = isSelected && $isNodeSelection(selection);
return (
<Suspense fallback={null}>
<>
<div className='relative' draggable={draggable} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={handleClick} role='button'>
<HStack className='absolute right-2 top-2 z-10' space={2}>
<IconButton
onClick={previewImage}
src={require('@tabler/icons/outline/zoom-in.svg')}
theme='dark'
className='!p-1.5 hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
/>
<IconButton
onClick={deleteNode}
src={require('@tabler/icons/outline/x.svg')}
theme='dark'
className='!p-1.5 hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
/>
</HStack>
<div className={clsx('compose-form__upload-description', { active })}>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
<textarea
placeholder={intl.formatMessage(messages.description)}
value={description}
onFocus={handleInputFocus}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
/>
</label>
</div>
<LazyImage
className={
clsx('cursor-default', {
'select-none': isSelected,
'cursor-grab active:cursor-grabbing': isSelected && $isNodeSelection(selection),
})
}
src={src}
altText={altText}
imageRef={imageRef}
/>
</div>
</>
</Suspense>
);
};
export default ImageComponent;

View file

@ -0,0 +1,178 @@
/**
* 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 { $applyNodeReplacement, DecoratorNode } from 'lexical';
import React from 'react';
import type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from 'lexical';
const ImageComponent = React.lazy(() => import('./image-component'));
interface ImagePayload {
altText?: string;
key?: NodeKey;
src: string;
}
const convertImageElement = (domNode: Node): null | DOMConversionOutput => {
if (domNode instanceof HTMLImageElement) {
const { alt: altText, src } = domNode;
const node = $createImageNode({ altText, src });
return { node };
}
return null;
};
type SerializedImageNode = Spread<
{
altText: string;
src: string;
},
SerializedLexicalNode
>;
class ImageNode extends DecoratorNode<JSX.Element> {
__src: string;
__altText: string;
static getType(): string {
return 'image';
}
static clone(node: ImageNode): ImageNode {
return new ImageNode(
node.__src,
node.__altText,
node.__key,
);
}
static importJSON(serializedNode: SerializedImageNode): ImageNode {
const { altText, src } =
serializedNode;
const node = $createImageNode({
altText,
src,
});
return node;
}
exportDOM(): DOMExportOutput {
const element = document.createElement('img');
element.setAttribute('src', this.__src);
element.setAttribute('alt', this.__altText);
return { element };
}
static importDOM(): DOMConversionMap | null {
return {
img: (node: Node) => ({
conversion: convertImageElement,
priority: 0,
}),
};
}
constructor(
src: string,
altText: string,
key?: NodeKey,
) {
super(key);
this.__src = src;
this.__altText = altText;
}
exportJSON(): SerializedImageNode {
return {
altText: this.getAltText(),
src: this.getSrc(),
type: 'image',
version: 1,
};
}
// View
createDOM(config: EditorConfig): HTMLElement {
const span = document.createElement('span');
const theme = config.theme;
const className = theme.image;
if (className !== undefined) {
span.className = className;
}
return span;
}
updateDOM(): false {
return false;
}
getSrc(): string {
return this.__src;
}
getAltText(): string {
return this.__altText;
}
setAltText(altText: string): void {
const writable = this.getWritable();
if (altText !== undefined) {
writable.__altText = altText;
}
}
decorate(): JSX.Element {
return (
// <Suspense fallback={null}>
<ImageComponent
src={this.__src}
altText={this.__altText}
nodeKey={this.getKey()}
/>
// </Suspense>
);
}
}
const $createImageNode = ({
altText = '',
src,
key,
}: ImagePayload): ImageNode => {
return $applyNodeReplacement(
new ImageNode(
src,
altText,
key,
),
);
};
const $isImageNode = (
node: LexicalNode | null | undefined,
): node is ImageNode => node instanceof ImageNode;
export {
type ImagePayload,
type SerializedImageNode,
ImageNode,
$createImageNode,
$isImageNode,
};

View file

@ -4,15 +4,24 @@
* LICENSE file in the /src/features/compose/editor directory.
*/
import { CodeHighlightNode, CodeNode } from '@lexical/code';
import { HashtagNode } from '@lexical/hashtag';
import { AutoLinkNode } from '@lexical/link';
import { AutoLinkNode, LinkNode } from '@lexical/link';
import { ListItemNode, ListNode } from '@lexical/list';
import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode';
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import { useInstance } from 'soapbox/hooks';
import { EmojiNode } from './emoji-node';
import { ImageNode } from './image-node';
import { MentionNode } from './mention-node';
import type { Klass, LexicalNode } from 'lexical';
const useNodes = () => {
const useNodes = (isWysiwyg?: boolean) => {
const instance = useInstance();
const nodes: Array<Klass<LexicalNode>> = [
AutoLinkNode,
HashtagNode,
@ -20,6 +29,21 @@ const useNodes = () => {
MentionNode,
];
if (isWysiwyg) {
nodes.push(
CodeHighlightNode,
CodeNode,
HorizontalRuleNode,
LinkNode,
ListItemNode,
ListNode,
QuoteNode,
);
}
if (instance.pleroma.metadata.markup.allow_headings) nodes.push(HeadingNode);
if (instance.pleroma.metadata.markup.allow_inline_images) nodes.push(ImageNode);
return nodes;
};

View file

@ -0,0 +1,294 @@
/**
* 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,
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';
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.media_attachments.supported_mime_types;
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/outline/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/')).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.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)) {
const selectionNode = selection.anchor.getNode();
selectionNode.replace($createHorizontalRuleNode());
}
});
};
const createImage = (src: string) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(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/outline/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

@ -0,0 +1,274 @@
/**
* 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 {
type BaseSelection,
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_CRITICAL,
COMMAND_PRIORITY_LOW,
LexicalEditor,
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<BaseSelection | 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/outline/x.svg')} />
</div>
</>
) : (
<>
<a className='mr-8 block truncate 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/outline/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;

View file

@ -0,0 +1,565 @@
/**
* 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,
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/outline/list.svg'),
check: require('@tabler/icons/outline/list-check.svg'),
code: require('@tabler/icons/outline/code.svg'),
h1: require('@tabler/icons/outline/h-1.svg'),
h2: require('@tabler/icons/outline/h-2.svg'),
h3: require('@tabler/icons/outline/h-3.svg'),
h4: require('@tabler/icons/outline/h-4.svg'),
h5: require('@tabler/icons/outline/h-5.svg'),
h6: require('@tabler/icons/outline/h-6.svg'),
number: require('@tabler/icons/outline/list-numbers.svg'),
paragraph: require('@tabler/icons/outline/align-left.svg'),
quote: require('@tabler/icons/outline/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 dark:hover: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)) {
$setBlocksType(selection, () => $createParagraphNode());
}
});
};
const formatHeading = (headingSize: HeadingTagType) => {
if (blockType !== headingSize) {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(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)) {
$setBlocksType(selection, () => $createQuoteNode());
}
});
}
};
const formatCode = () => {
if (blockType !== 'code') {
editor.update(() => {
let selection = $getSelection();
if ($isRangeSelection(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 dark:hover:bg-primary-700'
aria-label=''
type='button'
>
<Icon src={icon} />
<Icon src={require('@tabler/icons/outline/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.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/outline/bold.svg')}
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
}}
active={isItalic}
aria-label={intl.formatMessage(messages.formatItalic)}
icon={require('@tabler/icons/outline/italic.svg')}
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
}}
active={isUnderline}
aria-label={intl.formatMessage(messages.formatUnderline)}
icon={require('@tabler/icons/outline/underline.svg')}
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
}}
active={isStrikethrough}
aria-label={intl.formatMessage(messages.formatStrikethrough)}
icon={require('@tabler/icons/outline/strikethrough.svg')}
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
}}
active={isCode}
aria-label={intl.formatMessage(messages.insertCodeBlock)}
icon={require('@tabler/icons/outline/code.svg')}
/>
<ToolbarButton
onClick={insertLink}
active={isLink}
aria-label={intl.formatMessage(messages.insertLink)}
icon={require('@tabler/icons/outline/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;

View file

@ -1,4 +1,5 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $createRemarkExport } from '@mkljczk/lexical-remark';
import { $getRoot } from 'lexical';
import debounce from 'lodash/debounce';
import { useCallback, useEffect } from 'react';
@ -10,9 +11,10 @@ import { getStatusIdsFromLinksInContent } from 'soapbox/utils/status';
interface IStatePlugin {
composeId: string;
isWysiwyg?: boolean;
}
const StatePlugin: React.FC<IStatePlugin> = ({ composeId }) => {
const StatePlugin: React.FC<IStatePlugin> = ({ composeId, isWysiwyg }) => {
const dispatch = useAppDispatch();
const [editor] = useLexicalComposerContext();
const features = useFeatures();
@ -50,7 +52,17 @@ const StatePlugin: React.FC<IStatePlugin> = ({ composeId }) => {
useEffect(() => {
editor.registerUpdateListener(({ editorState }) => {
const text = editorState.read(() => $getRoot().getTextContent());
let text;
if (isWysiwyg) {
text = editorState.read($createRemarkExport({
handlers: {
hashtag: (node) => ({ type: 'text', value: node.getTextContent() }),
mention: (node) => ({ type: 'text', value: node.getTextContent() }),
},
}));
} else {
text = editorState.read(() => $getRoot().getTextContent());
}
const isEmpty = text === '';
const data = isEmpty ? null : JSON.stringify(editorState.toJSON());
dispatch(setEditorState(composeId, data, text));

View file

@ -0,0 +1,28 @@
/**
* 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 /src/features/compose/editor directory.
*/
/* eslint-disable eqeqeq */
export const getDOMRangeRect = (
nativeSelection: Selection,
rootElement: HTMLElement,
): DOMRect => {
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();
}
return rect;
};

View file

@ -0,0 +1,26 @@
/**
* 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 /src/features/compose/editor directory.
*/
import { $isAtNodeEnd } from '@lexical/selection';
import { ElementNode, RangeSelection, TextNode } from 'lexical';
export const getSelectedNode = (
selection: RangeSelection,
): TextNode | ElementNode => {
const anchor = selection.anchor;
const focus = selection.focus;
const anchorNode = selection.anchor.getNode();
const focusNode = selection.focus.getNode();
if (anchorNode === focusNode) {
return anchorNode;
}
const isBackward = selection.isBackward();
if (isBackward) {
return $isAtNodeEnd(focus) ? anchorNode : focusNode;
} else {
return $isAtNodeEnd(anchor) ? focusNode : anchorNode;
}
};

View file

@ -0,0 +1,45 @@
/**
* 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 /src/features/compose/editor directory.
*/
const VERTICAL_GAP = 10;
const HORIZONTAL_OFFSET = 5;
export const setFloatingElemPosition = (
targetRect: ClientRect | null,
floatingElem: HTMLElement,
anchorElem: HTMLElement,
verticalGap: number = VERTICAL_GAP,
horizontalOffset: number = HORIZONTAL_OFFSET,
): void => {
const scrollerElem = anchorElem.parentElement;
if (targetRect === null || !scrollerElem) {
floatingElem.style.opacity = '0';
floatingElem.style.transform = 'translate(-10000px, -10000px)';
return;
}
const floatingElemRect = floatingElem.getBoundingClientRect();
const anchorElementRect = anchorElem.getBoundingClientRect();
const editorScrollerRect = scrollerElem.getBoundingClientRect();
let top = targetRect.top - floatingElemRect.height - verticalGap;
let left = targetRect.left - horizontalOffset;
if (top < editorScrollerRect.top) {
top += floatingElemRect.height + targetRect.height + verticalGap * 2;
}
if (left + floatingElemRect.width > editorScrollerRect.right) {
left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset;
}
top -= anchorElementRect.top;
left -= anchorElementRect.left;
floatingElem.style.opacity = '1';
floatingElem.style.transform = `translate(${left}px, ${top}px)`;
};

View file

@ -0,0 +1,32 @@
/**
* 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 /src/features/compose/editor directory.
*/
export const sanitizeUrl = (url: string): string => {
/** A pattern that matches safe URLs. */
const SAFE_URL_PATTERN =
/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi;
/** A pattern that matches safe data URLs. */
const DATA_URL_PATTERN =
/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i;
url = String(url).trim();
if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url;
return 'https://';
};
// Source: https://stackoverflow.com/a/8234912/2013580
const urlRegExp = new RegExp(
/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/,
);
export const validateUrl = (url: string): boolean => {
// TODO Fix UI for link insertion; it should never default to an invalid URL such as https://.
// Maybe show a dialog where they user can type the URL before inserting it.
return url === 'https://' || urlRegExp.test(url);
};

View file

@ -446,6 +446,14 @@
"compose_form.direct_message_warning": "This post will only be sent to the mentioned users.",
"compose_form.event_placeholder": "Post to this event",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",
"compose_form.lexical.create_horizontal_line": "Create horizontal line",
"compose_form.lexical.format_bold": "Format bold",
"compose_form.lexical.format_italic": "Format italic",
"compose_form.lexical.format_strikethrough": "Format strikethrough",
"compose_form.lexical.format_underline": "Format underline",
"compose_form.lexical.insert_code_block": "Insert code block",
"compose_form.lexical.insert_link": "Insert link",
"compose_form.lexical.upload_media": "Upload media",
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
"compose_form.lock_disclaimer.lock": "locked",
"compose_form.message": "Message",
@ -1166,6 +1174,7 @@
"preferences.options.content_type_html": "HTML",
"preferences.options.content_type_markdown": "Markdown",
"preferences.options.content_type_plaintext": "Plain text",
"preferences.options.content_type_wysiwyf": "WYSIWYG",
"preferences.options.privacy_followers_only": "Followers-only",
"preferences.options.privacy_public": "Public",
"preferences.options.privacy_unlisted": "Unlisted",

View file

@ -97,6 +97,10 @@ const pleromaSchema = coerceObject({
name_length: z.number().nonnegative().catch(255),
value_length: z.number().nonnegative().catch(2047),
}),
markup: coerceObject({
allow_headings: z.boolean().catch(false),
allow_inline_images: z.boolean().catch(false),
}),
migration_cooldown_period: z.number().optional().catch(undefined),
multitenancy: coerceObject({
domains: z

2270
yarn.lock

File diff suppressed because it is too large Load diff