diff --git a/apps/front-end/src/plugins/ToolbarPlugin/index.tsx b/apps/front-end/src/plugins/ToolbarPlugin/index.tsx index 16de20b..a63825e 100644 --- a/apps/front-end/src/plugins/ToolbarPlugin/index.tsx +++ b/apps/front-end/src/plugins/ToolbarPlugin/index.tsx @@ -1,7 +1,20 @@ +import useMatchMedia from "@/hooks/useMatchMedia"; +import { $isCodeHighlightNode } from "@lexical/code"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import { useEffect, useState } from "react"; +import { + $getSelection, + $isParagraphNode, + $isRangeSelection, + $isTextNode, + BaseSelection, + COMMAND_PRIORITY_EDITOR, + LexicalEditor, + SELECTION_CHANGE_COMMAND, +} from "lexical"; +import { useCallback, useEffect, useState } from "react"; +import useLexicalCommand from "../useLexicalCommand"; import Toolbar from "./Toolbar"; -import useMatchMedia from "@/hooks/useMatchMedia"; +import { getSelectedNode } from "./utils"; type ToolbarPluginProps = { /** @@ -14,32 +27,55 @@ type ToolbarPluginProps = { function ToolbarPlugin(props: ToolbarPluginProps) { const [editor] = useLexicalComposerContext(); - const [editorHasSelection, setEditorHasSelection] = useState(false); + const [show, setShow] = useState(false); const isMobileScreen = useMatchMedia("(max-width: 1023px)"); - useEffect(() => { - const updateToolbarVisibility = () => { - const selection = window.getSelection(); - const editorRoot = editor.getRootElement(); - - const hasSelection = selection && !selection.isCollapsed; - const isSelectionInsideEditor = - hasSelection && editorRoot && editorRoot.contains(selection.anchorNode); + const updateToolbarVisibility = useCallback(() => { + editor.getEditorState().read(() => { + const selection = $getSelection(); + setShow(isSelectionInside(editor) && isTextualSelection(selection)); + }); + }, []); - setEditorHasSelection(Boolean(isSelectionInsideEditor)); - }; + useLexicalCommand( + SELECTION_CHANGE_COMMAND, + () => { + updateToolbarVisibility(); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ); + useEffect(() => { document.addEventListener("selectionchange", updateToolbarVisibility); return () => { document.removeEventListener("selectionchange", updateToolbarVisibility); }; - }, [editor]); + }, [editor, updateToolbarVisibility]); - if (!editorHasSelection && !isMobileScreen) { + if (!show && !isMobileScreen) { return null; } - return ; + return ; +} + +function isSelectionInside(editor: LexicalEditor) { + const selection = window.getSelection(); + const editorRoot = editor.getRootElement(); + const hasSelection = selection && !selection.isCollapsed; + return Boolean(hasSelection && editorRoot?.contains(selection.anchorNode)); +} + +function isTextualSelection(selection: BaseSelection | null): boolean { + if (!$isRangeSelection(selection)) { + return false; + } + const selectedNode = getSelectedNode(selection); + if ($isCodeHighlightNode(selectedNode)) { + return false; + } + return $isParagraphNode(selectedNode) || $isTextNode(selectedNode); } export default ToolbarPlugin; diff --git a/apps/front-end/src/plugins/ToolbarPlugin/utils.ts b/apps/front-end/src/plugins/ToolbarPlugin/utils.ts index 1c72d6f..a68ecfe 100644 --- a/apps/front-end/src/plugins/ToolbarPlugin/utils.ts +++ b/apps/front-end/src/plugins/ToolbarPlugin/utils.ts @@ -1,10 +1,13 @@ -import { $setBlocksType } from "@lexical/selection"; +import { $isAtNodeEnd, $setBlocksType } from "@lexical/selection"; import { $findMatchingParent } from "@lexical/utils"; import { $getSelection, $isRangeSelection, $isRootOrShadowRoot, + ElementNode, LexicalEditor, + RangeSelection, + TextNode, } from "lexical"; /** @@ -35,7 +38,7 @@ export function $getSelectionParentNode() { */ export function formatSelectionAs( editor: LexicalEditor, - createElement: Parameters[1] + createElement: Parameters[1], ) { editor.update(() => { const selection = $getSelection(); @@ -44,3 +47,44 @@ export function formatSelectionAs( } }); } + +/** + MIT License + + Copyright (c) Meta Platforms, Inc. and affiliates. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + */ +export function 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) ? anchorNode : focusNode; + } +}