Signed-off-by: marcin mikołajczak <git@mkljczk.pl>environments/review-lexical-ujdd17/deployments/2895
parent
f6169b9cf0
commit
31f340282f
@ -0,0 +1,72 @@
|
||||
import {
|
||||
$convertToMarkdownString,
|
||||
TRANSFORMERS,
|
||||
} from '@lexical/markdown';
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
||||
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
||||
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import nodes from './nodes';
|
||||
import FloatingLinkEditorPlugin from './plugins/floating-link-editor-plugin';
|
||||
import FloatingTextFormatToolbarPlugin from './plugins/floating-text-format-toolbar-plugin';
|
||||
|
||||
// import type { EditorState } from 'lexical';
|
||||
|
||||
const initialConfig = {
|
||||
namespace: 'ComposeForm',
|
||||
onError: console.error,
|
||||
nodes,
|
||||
};
|
||||
|
||||
const ComposeEditor = React.forwardRef<string, any>((_, editorStateRef) => {
|
||||
const [floatingAnchorElem, setFloatingAnchorElem] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
|
||||
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
|
||||
if (_floatingAnchorElem !== null) {
|
||||
setFloatingAnchorElem(_floatingAnchorElem);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<div className='relative' data-markup>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<div className='editor' ref={onRef}>
|
||||
<ContentEditable className='outline-none py-2 min-h-[100px]' />
|
||||
</div>
|
||||
}
|
||||
placeholder={(
|
||||
<div className='absolute top-2 text-gray-600 dark:placeholder:text-gray-600 pointer-events-none select-none'>
|
||||
<FormattedMessage id='compose_form.placeholder' defaultMessage="What's on your mind" />
|
||||
</div>
|
||||
)}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<OnChangePlugin onChange={(_, editor) => {
|
||||
editor.update(() => {
|
||||
if (editorStateRef) (editorStateRef as any).current = $convertToMarkdownString(TRANSFORMERS);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<HistoryPlugin />
|
||||
<LinkPlugin />
|
||||
{floatingAnchorElem ? (
|
||||
<>
|
||||
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
|
||||
<FloatingLinkEditorPlugin anchorElem={floatingAnchorElem} />
|
||||
</>
|
||||
) : ''}
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
);
|
||||
});
|
||||
|
||||
export default ComposeEditor;
|
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
|
||||
import { CodeHighlightNode, CodeNode } from '@lexical/code';
|
||||
// import { HashtagNode } from '@lexical/hashtag';
|
||||
import { AutoLinkNode, LinkNode } from '@lexical/link';
|
||||
// import { ListItemNode, ListNode } from '@lexical/list';
|
||||
// import { MarkNode } from '@lexical/mark';
|
||||
// import { OverflowNode } from '@lexical/overflow';
|
||||
import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode';
|
||||
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
|
||||
// import { TableCellNode, TableNode, TableRowNode } from '@lexical/table';
|
||||
|
||||
// import { CollapsibleContainerNode } from '../plugins/CollapsiblePlugin/CollapsibleContainerNode';
|
||||
// import { CollapsibleContentNode } from '../plugins/CollapsiblePlugin/CollapsibleContentNode';
|
||||
// import { CollapsibleTitleNode } from '../plugins/CollapsiblePlugin/CollapsibleTitleNode';
|
||||
|
||||
// import { AutocompleteNode } from './AutocompleteNode';
|
||||
// import { EmojiNode } from './EmojiNode';
|
||||
// import { EquationNode } from './EquationNode';
|
||||
// import { ExcalidrawNode } from './ExcalidrawNode';
|
||||
// import { FigmaNode } from './FigmaNode';
|
||||
// import { ImageNode } from './ImageNode';
|
||||
// import { KeywordNode } from './KeywordNode';
|
||||
// import { MentionNode } from './MentionNode';
|
||||
// import { PollNode } from './PollNode';
|
||||
// import { StickyNode } from './StickyNode';
|
||||
// import { TableNode as NewTableNode } from './TableNode';
|
||||
// import { TweetNode } from './TweetNode';
|
||||
// import { YouTubeNode } from './YouTubeNode';
|
||||
|
||||
import type { Klass, LexicalNode } from 'lexical';
|
||||
|
||||
const PlaygroundNodes: Array<Klass<LexicalNode>> = [
|
||||
HeadingNode,
|
||||
// ListNode,
|
||||
// ListItemNode,
|
||||
QuoteNode,
|
||||
CodeNode,
|
||||
// NewTableNode,
|
||||
// TableNode,
|
||||
// TableCellNode,
|
||||
// TableRowNode,
|
||||
// HashtagNode,
|
||||
CodeHighlightNode,
|
||||
AutoLinkNode,
|
||||
LinkNode,
|
||||
// OverflowNode,
|
||||
// PollNode,
|
||||
// StickyNode,
|
||||
// ImageNode,
|
||||
// MentionNode,
|
||||
// EmojiNode,
|
||||
// ExcalidrawNode,
|
||||
// EquationNode,
|
||||
// AutocompleteNode,
|
||||
// KeywordNode,
|
||||
HorizontalRuleNode,
|
||||
// TweetNode,
|
||||
// YouTubeNode,
|
||||
// FigmaNode,
|
||||
// MarkNode,
|
||||
// CollapsibleContainerNode,
|
||||
// CollapsibleContentNode,
|
||||
// CollapsibleTitleNode,
|
||||
];
|
||||
|
||||
export default PlaygroundNodes;
|
@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { $findMatchingParent, mergeRegister } from '@lexical/utils';
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
GridSelection,
|
||||
LexicalEditor,
|
||||
NodeSelection,
|
||||
RangeSelection,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Icon } from 'soapbox/components/ui';
|
||||
|
||||
import { getSelectedNode } from '../utils/get-selected-node';
|
||||
import { setFloatingElemPosition } from '../utils/set-floating-elem-position';
|
||||
import { sanitizeUrl } from '../utils/url';
|
||||
|
||||
const FloatingLinkEditor = ({
|
||||
editor,
|
||||
anchorElem,
|
||||
}: {
|
||||
editor: LexicalEditor;
|
||||
anchorElem: HTMLElement;
|
||||
}): JSX.Element => {
|
||||
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [isEditMode, setEditMode] = useState(false);
|
||||
const [lastSelection, setLastSelection] = useState<
|
||||
RangeSelection | GridSelection | NodeSelection | null
|
||||
>(null);
|
||||
|
||||
const updateLinkEditor = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection);
|
||||
const parent = node.getParent();
|
||||
if ($isLinkNode(parent)) {
|
||||
setLinkUrl(parent.getURL());
|
||||
} else if ($isLinkNode(node)) {
|
||||
setLinkUrl(node.getURL());
|
||||
} else {
|
||||
setLinkUrl('');
|
||||
}
|
||||
}
|
||||
const editorElem = editorRef.current;
|
||||
const nativeSelection = window.getSelection();
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
if (editorElem === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootElement = editor.getRootElement();
|
||||
|
||||
if (
|
||||
selection !== null &&
|
||||
nativeSelection !== null &&
|
||||
rootElement !== null &&
|
||||
rootElement.contains(nativeSelection.anchorNode)
|
||||
) {
|
||||
const domRange = nativeSelection.getRangeAt(0);
|
||||
let rect;
|
||||
if (nativeSelection.anchorNode === rootElement) {
|
||||
let inner = rootElement;
|
||||
while (inner.firstElementChild != null) {
|
||||
inner = inner.firstElementChild as HTMLElement;
|
||||
}
|
||||
rect = inner.getBoundingClientRect();
|
||||
} else {
|
||||
rect = domRange.getBoundingClientRect();
|
||||
}
|
||||
|
||||
setFloatingElemPosition(rect, editorElem, anchorElem);
|
||||
setLastSelection(selection);
|
||||
} else if (!activeElement || activeElement.className !== 'link-input') {
|
||||
if (rootElement !== null) {
|
||||
setFloatingElemPosition(null, editorElem, anchorElem);
|
||||
}
|
||||
setLastSelection(null);
|
||||
setEditMode(false);
|
||||
setLinkUrl('');
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [anchorElem, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollerElem = anchorElem.parentElement;
|
||||
|
||||
const update = () => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateLinkEditor();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('resize', update);
|
||||
|
||||
if (scrollerElem) {
|
||||
scrollerElem.addEventListener('scroll', update);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
|
||||
if (scrollerElem) {
|
||||
scrollerElem.removeEventListener('scroll', update);
|
||||
}
|
||||
};
|
||||
}, [anchorElem.parentElement, editor, updateLinkEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
updateLinkEditor();
|
||||
});
|
||||
}),
|
||||
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
updateLinkEditor();
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
);
|
||||
}, [editor, updateLinkEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateLinkEditor();
|
||||
});
|
||||
}, [editor, updateLinkEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isEditMode]);
|
||||
|
||||
return (
|
||||
<div ref={editorRef} className='link-editor'>
|
||||
<div className='link-input'>
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<input
|
||||
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='link-edit'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
|
||||
}}
|
||||
>
|
||||
<Icon className='h-5 w-5' src={require('@tabler/icons/x.svg')} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<a href={linkUrl} target='_blank' rel='noopener noreferrer'>
|
||||
{linkUrl}
|
||||
</a>
|
||||
<div
|
||||
className='link-edit'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setEditMode(true);
|
||||
}}
|
||||
>
|
||||
<Icon className='h-5 w-5' src={require('@tabler/icons/pencil.svg')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useFloatingLinkEditorToolbar = (
|
||||
editor: LexicalEditor,
|
||||
anchorElem: HTMLElement,
|
||||
): JSX.Element | null => {
|
||||
const [activeEditor, setActiveEditor] = useState(editor);
|
||||
const [isLink, setIsLink] = useState(false);
|
||||
|
||||
const updateToolbar = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection);
|
||||
const linkParent = $findMatchingParent(node, $isLinkNode);
|
||||
const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode);
|
||||
|
||||
// We don't want this menu to open for auto links.
|
||||
if (linkParent != null && autoLinkParent == null) {
|
||||
setIsLink(true);
|
||||
} else {
|
||||
setIsLink(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_payload, newEditor) => {
|
||||
updateToolbar();
|
||||
setActiveEditor(newEditor);
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
);
|
||||
}, [editor, updateToolbar]);
|
||||
|
||||
return isLink
|
||||
? createPortal(
|
||||
<FloatingLinkEditor editor={activeEditor} anchorElem={anchorElem} />,
|
||||
anchorElem,
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const FloatingLinkEditorPlugin = ({
|
||||
anchorElem = document.body,
|
||||
}: {
|
||||
anchorElem?: HTMLElement;
|
||||
}): JSX.Element | null => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
return useFloatingLinkEditorToolbar(editor, anchorElem);
|
||||
};
|
||||
|
||||
export default FloatingLinkEditorPlugin;
|
@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
// import './index.css';
|
||||
|
||||
import { $isCodeHighlightNode } from '@lexical/code';
|
||||
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { mergeRegister } from '@lexical/utils';
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$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 { Icon } from 'soapbox/components/ui';
|
||||
|
||||
import { getDOMRangeRect } from '../utils/get-dom-range-rect';
|
||||
import { getSelectedNode } from '../utils/get-selected-node';
|
||||
import { setFloatingElemPosition } from '../utils/set-floating-elem-position';
|
||||
|
||||
const TextFormatFloatingToolbar = ({
|
||||
editor,
|
||||
anchorElem,
|
||||
isLink,
|
||||
isBold,
|
||||
isItalic,
|
||||
isUnderline,
|
||||
isCode,
|
||||
isStrikethrough,
|
||||
}: {
|
||||
editor: LexicalEditor;
|
||||
anchorElem: HTMLElement;
|
||||
isBold: boolean;
|
||||
isCode: boolean;
|
||||
isItalic: boolean;
|
||||
isLink: boolean;
|
||||
isStrikethrough: boolean;
|
||||
isUnderline: boolean;
|
||||
}): JSX.Element => {
|
||||
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='floating-text-format-popup'>
|
||||
{editor.isEditable() && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
|
||||
}}
|
||||
className={'popup-item spaced ' + (isBold ? 'active' : '')}
|
||||
aria-label='Format text as bold'
|
||||
type='button'
|
||||
>
|
||||
<Icon src={require('@tabler/icons/bold.svg')} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
|
||||
}}
|
||||
className={'popup-item spaced ' + (isItalic ? 'active' : '')}
|
||||
aria-label='Format text as italics'
|
||||
type='button'
|
||||
>
|
||||
<Icon src={require('@tabler/icons/italic.svg')} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
|
||||
}}
|
||||
className={'popup-item spaced ' + (isUnderline ? 'active' : '')}
|
||||
aria-label='Format text to underlined'
|
||||
type='button'
|
||||
>
|
||||
<Icon src={require('@tabler/icons/underline.svg')} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
|
||||
}}
|
||||
className={'popup-item spaced ' + (isStrikethrough ? 'active' : '')}
|
||||
aria-label='Format text with a strikethrough'
|
||||
type='button'
|
||||
>
|
||||
<Icon src={require('@tabler/icons/strikethrough.svg')} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
|
||||
}}
|
||||
className={'popup-item spaced ' + (isCode ? 'active' : '')}
|
||||
aria-label='Insert code block'
|
||||
type='button'
|
||||
>
|
||||
<Icon src={require('@tabler/icons/code.svg')} />
|
||||
</button>
|
||||
<button
|
||||
onClick={insertLink}
|
||||
className={'popup-item spaced ' + (isLink ? 'active' : '')}
|
||||
aria-label='Insert link'
|
||||
type='button'
|
||||
>
|
||||
<Icon src={require('@tabler/icons/link.svg')} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useFloatingTextFormatToolbar = (
|
||||
editor: LexicalEditor,
|
||||
anchorElem: HTMLElement,
|
||||
): JSX.Element | null => {
|
||||
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 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'));
|
||||
|
||||
// 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}
|
||||
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;
|
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import { LinkPlugin as LexicalLinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
||||
import * as React from 'react';
|
||||
|
||||
import { validateUrl } from '../utils/url';
|
||||
|
||||
const LinkPlugin = (): JSX.Element => {
|
||||
return <LexicalLinkPlugin validateUrl={validateUrl} />;
|
||||
};
|
||||
|
||||
export default LinkPlugin;
|
@ -0,0 +1,28 @@
|
||||
/* eslint-disable eqeqeq */
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
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;
|
||||
};
|
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
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,46 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
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)`;
|
||||
};
|
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
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);
|
||||
};
|
Loading…
Reference in new issue