Bring back the WYSIWYG editor
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
32db7c6fad
commit
c4d085c767
20 changed files with 4059 additions and 187 deletions
18
package.json
18
package.json
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
10
src/features/compose/editor/handlers/image.ts
Normal file
10
src/features/compose/editor/handlers/image.ts
Normal 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 };
|
|
@ -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 />
|
||||
|
|
357
src/features/compose/editor/nodes/image-component.tsx
Normal file
357
src/features/compose/editor/nodes/image-component.tsx
Normal 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;
|
178
src/features/compose/editor/nodes/image-node.tsx
Normal file
178
src/features/compose/editor/nodes/image-node.tsx
Normal 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,
|
||||
};
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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));
|
||||
|
|
28
src/features/compose/editor/utils/get-dom-range-rect.ts
Normal file
28
src/features/compose/editor/utils/get-dom-range-rect.ts
Normal 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;
|
||||
};
|
26
src/features/compose/editor/utils/get-selected-node.ts
Normal file
26
src/features/compose/editor/utils/get-selected-node.ts
Normal 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;
|
||||
}
|
||||
};
|
|
@ -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)`;
|
||||
};
|
32
src/features/compose/editor/utils/url.ts
Normal file
32
src/features/compose/editor/utils/url.ts
Normal 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);
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue