mirror of
https://github.com/facebook/lexical.git
synced 2025-08-24 09:11:13 +08:00

* Add an experimental Table component in React Add an experimental Table component in React Add an experimental Table component in React WIP Fix Fix Fix Fix Fix Ensure editor states are cloned if read only Fix Fix Fix Fix Fix HMR Add table controls More fixes Many fixes WIP Fixes Fix bugs + prettier * Fix TS issue * Ensure table priority is higher * Revert image selection changes * Add sorting * Fix sort algo
335 lines
9.7 KiB
TypeScript
335 lines
9.7 KiB
TypeScript
/**
|
|
* 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 {getDOMRangeRect} from '../../utils/getDOMRangeRect';
|
|
import {getSelectedNode} from '../../utils/getSelectedNode';
|
|
import {setFloatingElemPosition} from '../../utils/setFloatingElemPosition';
|
|
import {INSERT_INLINE_COMMAND} from '../CommentPlugin';
|
|
|
|
function TextFormatFloatingToolbar({
|
|
editor,
|
|
anchorElem,
|
|
isLink,
|
|
isBold,
|
|
isItalic,
|
|
isUnderline,
|
|
isCode,
|
|
isStrikethrough,
|
|
isSubscript,
|
|
isSuperscript,
|
|
}: {
|
|
editor: LexicalEditor;
|
|
anchorElem: HTMLElement;
|
|
isBold: boolean;
|
|
isCode: boolean;
|
|
isItalic: boolean;
|
|
isLink: boolean;
|
|
isStrikethrough: boolean;
|
|
isSubscript: boolean;
|
|
isSuperscript: 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 insertComment = () => {
|
|
editor.dispatchCommand(INSERT_INLINE_COMMAND, undefined);
|
|
};
|
|
|
|
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">
|
|
<i className="format bold" />
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
|
|
}}
|
|
className={'popup-item spaced ' + (isItalic ? 'active' : '')}
|
|
aria-label="Format text as italics">
|
|
<i className="format italic" />
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
|
|
}}
|
|
className={'popup-item spaced ' + (isUnderline ? 'active' : '')}
|
|
aria-label="Format text to underlined">
|
|
<i className="format underline" />
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
|
|
}}
|
|
className={'popup-item spaced ' + (isStrikethrough ? 'active' : '')}
|
|
aria-label="Format text with a strikethrough">
|
|
<i className="format strikethrough" />
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript');
|
|
}}
|
|
className={'popup-item spaced ' + (isSubscript ? 'active' : '')}
|
|
title="Subscript"
|
|
aria-label="Format Subscript">
|
|
<i className="format subscript" />
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript');
|
|
}}
|
|
className={'popup-item spaced ' + (isSuperscript ? 'active' : '')}
|
|
title="Superscript"
|
|
aria-label="Format Superscript">
|
|
<i className="format superscript" />
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
|
|
}}
|
|
className={'popup-item spaced ' + (isCode ? 'active' : '')}
|
|
aria-label="Insert code block">
|
|
<i className="format code" />
|
|
</button>
|
|
<button
|
|
onClick={insertLink}
|
|
className={'popup-item spaced ' + (isLink ? 'active' : '')}
|
|
aria-label="Insert link">
|
|
<i className="format link" />
|
|
</button>
|
|
</>
|
|
)}
|
|
<button
|
|
onClick={insertComment}
|
|
className={'popup-item spaced'}
|
|
aria-label="Insert comment">
|
|
<i className="format add-comment" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function 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 [isSubscript, setIsSubscript] = useState(false);
|
|
const [isSuperscript, setIsSuperscript] = 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'));
|
|
setIsSubscript(selection.hasFormat('subscript'));
|
|
setIsSuperscript(selection.hasFormat('superscript'));
|
|
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}
|
|
isSubscript={isSubscript}
|
|
isSuperscript={isSuperscript}
|
|
isUnderline={isUnderline}
|
|
isCode={isCode}
|
|
/>,
|
|
anchorElem,
|
|
);
|
|
}
|
|
|
|
export default function FloatingTextFormatToolbarPlugin({
|
|
anchorElem = document.body,
|
|
}: {
|
|
anchorElem?: HTMLElement;
|
|
}): JSX.Element | null {
|
|
const [editor] = useLexicalComposerContext();
|
|
return useFloatingTextFormatToolbar(editor, anchorElem);
|
|
}
|