From 77f72ff7b67a5dab502d1db95dc512e3bf348541 Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Wed, 16 Aug 2023 21:52:41 +0530 Subject: [PATCH] feat: Super formatting toolbar on mobile now shows whether an option is active in the selection, and also shows hints when an option is long-pressed (#2432) --- .../Popover/GetPositionedPopoverStyles.ts | 13 +- .../StyledTooltip/StyledTooltip.tsx | 42 +++- .../Components/SuperEditor/BlocksEditor.tsx | 2 +- .../Plugins/Blocks/BulletedList.tsx | 3 +- .../Plugins/Blocks/NumberedList.tsx | 3 +- .../FloatingLinkEditorPlugin/index.tsx | 231 ------------------ .../LinkEditor.tsx | 0 .../LinkTextEditor.tsx | 0 .../FloatingTextFormatToolbarPlugin.tsx} | 187 ++------------ .../MobileToolbarPlugin.tsx | 76 ++++-- .../useSelectedTextFormatInfo.ts | 177 ++++++++++++++ .../Components/SuperEditor/SuperEditor.tsx | 2 +- .../javascripts/Hooks/useContextMenuEvent.tsx | 2 +- .../src/javascripts/Hooks/useLongPress.tsx | 38 ++- 14 files changed, 350 insertions(+), 426 deletions(-) delete mode 100644 packages/web/src/javascripts/Components/SuperEditor/Plugins/FloatingLinkEditorPlugin/index.tsx rename packages/web/src/javascripts/Components/SuperEditor/Plugins/{FloatingLinkEditorPlugin => LinkEditor}/LinkEditor.tsx (100%) rename packages/web/src/javascripts/Components/SuperEditor/Plugins/{FloatingLinkEditorPlugin => LinkEditor}/LinkTextEditor.tsx (100%) rename packages/web/src/javascripts/Components/SuperEditor/Plugins/{FloatingTextFormatToolbarPlugin/index.tsx => ToolbarPlugins/FloatingTextFormatToolbarPlugin.tsx} (73%) rename packages/web/src/javascripts/Components/SuperEditor/Plugins/{MobileToolbarPlugin => ToolbarPlugins}/MobileToolbarPlugin.tsx (84%) create mode 100644 packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugins/useSelectedTextFormatInfo.ts diff --git a/packages/web/src/javascripts/Components/Popover/GetPositionedPopoverStyles.ts b/packages/web/src/javascripts/Components/Popover/GetPositionedPopoverStyles.ts index 7f66278c9..66299af48 100644 --- a/packages/web/src/javascripts/Components/Popover/GetPositionedPopoverStyles.ts +++ b/packages/web/src/javascripts/Components/Popover/GetPositionedPopoverStyles.ts @@ -63,13 +63,19 @@ const getStylesFromRect = (options: { side: PopoverSide align: PopoverAlignment disableMobileFullscreenTakeover?: boolean + disableApplyingMobileWidth?: boolean maxHeight?: number | 'none' offset?: number }): PopoverCSSProperties => { - const { rect, disableMobileFullscreenTakeover = false, maxHeight = 'none' } = options + const { + rect, + disableMobileFullscreenTakeover = false, + disableApplyingMobileWidth = false, + maxHeight = 'none', + } = options const canApplyMaxHeight = maxHeight !== 'none' && (!isMobileScreen() || disableMobileFullscreenTakeover) - const shouldApplyMobileWidth = isMobileScreen() && disableMobileFullscreenTakeover + const shouldApplyMobileWidth = isMobileScreen() && disableMobileFullscreenTakeover && !disableApplyingMobileWidth const marginForMobile = percentOf(10, window.innerWidth) return { @@ -96,6 +102,7 @@ type Options = { popoverRect?: DOMRect side: PopoverSide disableMobileFullscreenTakeover?: boolean + disableApplyingMobileWidth?: boolean maxHeightFunction?: (calculatedMaxHeight: number) => number | 'none' offset?: number } @@ -107,6 +114,7 @@ export const getPositionedPopoverStyles = ({ popoverRect, side, disableMobileFullscreenTakeover, + disableApplyingMobileWidth, maxHeightFunction, offset, }: Options): PopoverCSSProperties | null => { @@ -159,6 +167,7 @@ export const getPositionedPopoverStyles = ({ side: sideWithLessOverflows, align: finalAlignment, disableMobileFullscreenTakeover, + disableApplyingMobileWidth, maxHeight, offset, }) diff --git a/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx b/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx index 7cd14c332..a3b642494 100644 --- a/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx +++ b/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx @@ -1,10 +1,11 @@ import { classNames } from '@standardnotes/snjs' -import { ReactNode, useState } from 'react' +import { ReactNode, useState, useRef, useEffect } from 'react' import { Tooltip, TooltipAnchor, TooltipOptions, useTooltipStore } from '@ariakit/react' import { Slot } from '@radix-ui/react-slot' import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' import { getPositionedPopoverStyles } from '../Popover/GetPositionedPopoverStyles' import { getAdjustedStylesForNonPortalPopover } from '../Popover/Utils/getAdjustedStylesForNonPortal' +import { useLongPressEvent } from '@/Hooks/useLongPress' const StyledTooltip = ({ children, @@ -24,13 +25,43 @@ const StyledTooltip = ({ } & Partial) => { const [forceOpen, setForceOpen] = useState() + const isMobile = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm) const tooltip = useTooltipStore({ - timeout: 500, + timeout: isMobile && showOnMobile ? 100 : 500, hideTimeout: 0, skipTimeout: 0, open: forceOpen, + animated: true, }) - const isMobile = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm) + + const anchorRef = useRef(null) + const { attachEvents: attachLongPressEvents, cleanupEvents: cleanupLongPressEvents } = useLongPressEvent( + anchorRef, + () => { + tooltip.show() + setTimeout(() => { + tooltip.hide() + }, 2000) + }, + ) + + useEffect(() => { + if (!isMobile || !showOnMobile) { + return + } + + attachLongPressEvents() + + return () => { + cleanupLongPressEvents() + } + }, [attachLongPressEvents, cleanupLongPressEvents, isMobile, showOnMobile]) + + const clickProps = isMobile + ? {} + : { + onClick: () => setForceOpen(false), + } if (isMobile && !showOnMobile) { return <>{children} @@ -39,7 +70,8 @@ const StyledTooltip = ({ return ( <> setForceOpen(false)} + ref={anchorRef} + {...clickProps} onBlur={() => setForceOpen(undefined)} store={tooltip} as={Slot} @@ -53,6 +85,7 @@ const StyledTooltip = ({ store={tooltip} className={classNames( 'z-tooltip max-w-max rounded border border-border translucent-ui:border-[--popover-border-color] bg-contrast translucent-ui:bg-[--popover-background-color] [backdrop-filter:var(--popover-backdrop-filter)] px-3 py-1.5 text-sm text-foreground shadow', + 'opacity-60 [&[data-enter]]:opacity-100 [&[data-leave]]:opacity-60 transition-opacity duration-75', className, )} updatePosition={() => { @@ -79,6 +112,7 @@ const StyledTooltip = ({ popoverRect, documentRect, disableMobileFullscreenTakeover: true, + disableApplyingMobileWidth: true, offset: props.gutter ? props.gutter : 6, }) diff --git a/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx b/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx index 708d81b7a..c95662dca 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx @@ -19,7 +19,7 @@ import AutoEmbedPlugin from './Plugins/AutoEmbedPlugin' import CollapsiblePlugin from './Plugins/CollapsiblePlugin' import DraggableBlockPlugin from './Plugins/DraggableBlockPlugin' import CodeHighlightPlugin from './Plugins/CodeHighlightPlugin' -import FloatingTextFormatToolbarPlugin from './Plugins/FloatingTextFormatToolbarPlugin' +import FloatingTextFormatToolbarPlugin from './Plugins/ToolbarPlugins/FloatingTextFormatToolbarPlugin' import { TabIndentationPlugin } from './Plugins/TabIndentationPlugin' import { handleEditorChange } from './Utils' import { SuperEditorContentId } from './Constants' diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/Blocks/BulletedList.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/Blocks/BulletedList.tsx index cbbe19350..cecb8b984 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/Blocks/BulletedList.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/Blocks/BulletedList.tsx @@ -1,11 +1,12 @@ import { LexicalEditor } from 'lexical' import { INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list' -export function GetBulletedListBlock(editor: LexicalEditor) { +export function GetBulletedListBlock(editor: LexicalEditor, isActive = false) { return { name: 'Bulleted List', iconName: 'list-bulleted', keywords: ['bulleted list', 'unordered list', 'ul'], onSelect: () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined), + active: isActive, } } diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/Blocks/NumberedList.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/Blocks/NumberedList.tsx index a0f16fe8d..f61a9da2e 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/Blocks/NumberedList.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/Blocks/NumberedList.tsx @@ -1,11 +1,12 @@ import { LexicalEditor } from 'lexical' import { INSERT_ORDERED_LIST_COMMAND } from '@lexical/list' -export function GetNumberedListBlock(editor: LexicalEditor) { +export function GetNumberedListBlock(editor: LexicalEditor, isActive = false) { return { name: 'Numbered List', iconName: 'list-numbered', keywords: ['numbered list', 'ordered list', 'ol'], onSelect: () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined), + active: isActive, } } diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/FloatingLinkEditorPlugin/index.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/FloatingLinkEditorPlugin/index.tsx deleted file mode 100644 index 2da420ed9..000000000 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/FloatingLinkEditorPlugin/index.tsx +++ /dev/null @@ -1,231 +0,0 @@ -/** - * 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 } 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 { createPortal } from 'react-dom' - -import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode' -import { getDOMRangeRect } from '../../Lexical/Utils/getDOMRangeRect' -import { getPositionedPopoverStyles } from '@/Components/Popover/GetPositionedPopoverStyles' -import { getAdjustedStylesForNonPortalPopover } from '@/Components/Popover/Utils/getAdjustedStylesForNonPortal' -import LinkEditor from './LinkEditor' - -function FloatingLinkEditor({ - editor, - anchorElem, - isAutoLink, -}: { - editor: LexicalEditor - anchorElem: HTMLElement - isAutoLink: boolean -}): JSX.Element { - const editorRef = useRef(null) - const [linkUrl, setLinkUrl] = useState('') - const [isEditMode, setEditMode] = useState(false) - const [lastSelection, setLastSelection] = useState(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) - ) { - setLastSelection(selection) - - const rect = getDOMRangeRect(nativeSelection, rootElement) - - const editorRect = editorElem.getBoundingClientRect() - const rootElementRect = rootElement.getBoundingClientRect() - - const calculatedStyles = getPositionedPopoverStyles({ - align: 'start', - side: 'top', - anchorRect: rect, - popoverRect: editorRect, - documentRect: rootElementRect, - offset: 8, - disableMobileFullscreenTakeover: true, - }) - - if (calculatedStyles) { - Object.assign(editorElem.style, calculatedStyles) - const adjustedStyles = getAdjustedStylesForNonPortalPopover(editorElem, calculatedStyles, rootElement) - editorElem.style.setProperty('--translate-x', adjustedStyles['--translate-x']) - editorElem.style.setProperty('--translate-y', adjustedStyles['--translate-y']) - } - } else if (!activeElement || activeElement.id !== 'link-input') { - setLastSelection(null) - setEditMode(false) - } - - return true - }, [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]) - - return ( -
- -
- ) -} - -function useFloatingLinkEditorToolbar(editor: LexicalEditor, anchorElem: HTMLElement): JSX.Element | null { - const [activeEditor, setActiveEditor] = useState(editor) - const [isLink, setIsLink] = useState(false) - const [isAutoLink, setIsAutoLink] = 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) - - if (linkParent != null) { - setIsLink(true) - } else { - setIsLink(false) - } - - if (autoLinkParent != null) { - setIsAutoLink(true) - } else { - setIsAutoLink(false) - } - } - }, []) - - useEffect(() => { - return mergeRegister( - editor.registerUpdateListener(({ editorState }) => { - editorState.read(() => { - updateToolbar() - }) - }), - editor.registerCommand( - SELECTION_CHANGE_COMMAND, - (_payload, newEditor) => { - updateToolbar() - setActiveEditor(newEditor) - return false - }, - COMMAND_PRIORITY_CRITICAL, - ), - ) - }, [editor, updateToolbar]) - - return isLink - ? createPortal( - , - anchorElem, - ) - : null -} - -export default function FloatingLinkEditorPlugin({ - anchorElem = document.body, -}: { - anchorElem?: HTMLElement -}): JSX.Element | null { - const [editor] = useLexicalComposerContext() - return useFloatingLinkEditorToolbar(editor, anchorElem) -} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/FloatingLinkEditorPlugin/LinkEditor.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/LinkEditor/LinkEditor.tsx similarity index 100% rename from packages/web/src/javascripts/Components/SuperEditor/Plugins/FloatingLinkEditorPlugin/LinkEditor.tsx rename to packages/web/src/javascripts/Components/SuperEditor/Plugins/LinkEditor/LinkEditor.tsx diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/FloatingLinkEditorPlugin/LinkTextEditor.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/LinkEditor/LinkTextEditor.tsx similarity index 100% rename from packages/web/src/javascripts/Components/SuperEditor/Plugins/FloatingLinkEditorPlugin/LinkTextEditor.tsx rename to packages/web/src/javascripts/Components/SuperEditor/Plugins/LinkEditor/LinkTextEditor.tsx diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/FloatingTextFormatToolbarPlugin/index.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugins/FloatingTextFormatToolbarPlugin.tsx similarity index 73% rename from packages/web/src/javascripts/Components/SuperEditor/Plugins/FloatingTextFormatToolbarPlugin/index.tsx rename to packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugins/FloatingTextFormatToolbarPlugin.tsx index 616723b04..7b9b0e8eb 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/FloatingTextFormatToolbarPlugin/index.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugins/FloatingTextFormatToolbarPlugin.tsx @@ -6,19 +6,15 @@ * */ -import { $isCodeHighlightNode } from '@lexical/code' -import { $isLinkNode, $isAutoLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link' +import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' -import { mergeRegister, $findMatchingParent, $getNearestNodeOfType } from '@lexical/utils' +import { mergeRegister } from '@lexical/utils' import { $getSelection, $isRangeSelection, - $isTextNode, FORMAT_TEXT_COMMAND, LexicalEditor, SELECTION_CHANGE_COMMAND, - $isRootOrShadowRoot, - COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_LOW, RangeSelection, GridSelection, @@ -27,17 +23,9 @@ import { COMMAND_PRIORITY_NORMAL, createCommand, } from 'lexical' -import { $isHeadingNode } from '@lexical/rich-text' -import { - INSERT_UNORDERED_LIST_COMMAND, - REMOVE_LIST_COMMAND, - $isListNode, - ListNode, - INSERT_ORDERED_LIST_COMMAND, -} from '@lexical/list' +import { INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND } from '@lexical/list' import { ComponentPropsWithoutRef, useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' - import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode' import { BoldIcon, @@ -56,24 +44,11 @@ import { classNames } from '@standardnotes/snjs' import { getDOMRangeRect } from '../../Lexical/Utils/getDOMRangeRect' import { getPositionedPopoverStyles } from '@/Components/Popover/GetPositionedPopoverStyles' import { getAdjustedStylesForNonPortalPopover } from '@/Components/Popover/Utils/getAdjustedStylesForNonPortal' -import LinkEditor from '../FloatingLinkEditorPlugin/LinkEditor' -import LinkTextEditor, { $isLinkTextNode } from '../FloatingLinkEditorPlugin/LinkTextEditor' +import LinkEditor from '../LinkEditor/LinkEditor' +import LinkTextEditor, { $isLinkTextNode } from '../LinkEditor/LinkTextEditor' import { URL_REGEX } from '@/Constants/Constants' - -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', -} +import { useSelectedTextFormatInfo } from './useSelectedTextFormatInfo' +import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' const IconSize = 15 @@ -464,138 +439,26 @@ function TextFormatFloatingToolbar({ } function useFloatingTextFormatToolbar(editor: LexicalEditor, anchorElem: HTMLElement): JSX.Element | null { - const [activeEditor, setActiveEditor] = useState(editor) - const [isText, setIsText] = useState(false) - const [isLink, setIsLink] = useState(false) - const [isAutoLink, setIsAutoLink] = useState(false) - const [isLinkText, setIsLinkText] = 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 [blockType, setBlockType] = useState('paragraph') + const { + isText, + isLink, + isLinkText, + isAutoLink, + isBold, + isItalic, + isStrikethrough, + isSubscript, + isSuperscript, + isUnderline, + isCode, + blockType, + } = useSelectedTextFormatInfo() - 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() + const isMobile = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm) - const isMobile = window.matchMedia('(max-width: 768px)').matches - - if (isMobile) { - return - } - - 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 = activeEditor.getElementByKey(elementKey) - - if (elementDOM !== null) { - if ($isListNode(element)) { - const parentList = $getNearestNodeOfType(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) - } - } - } - - 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 ($isAutoLinkNode(parent) || $isAutoLinkNode(node)) { - setIsAutoLink(true) - } else { - setIsAutoLink(false) - } - if ($isLinkTextNode(node, selection)) { - setIsLinkText(true) - } else { - setIsLinkText(false) - } - - if (!$isCodeHighlightNode(selection.anchor.getNode()) && selection.getTextContent() !== '') { - setIsText($isTextNode(node)) - } else { - setIsText(false) - } - }) - }, [editor, activeEditor]) - - useEffect(() => { - return editor.registerCommand( - SELECTION_CHANGE_COMMAND, - (_payload, newEditor) => { - setActiveEditor(newEditor) - updatePopup() - return false - }, - COMMAND_PRIORITY_CRITICAL, - ) - }, [editor, updatePopup]) - - useEffect(() => { - return mergeRegister( - editor.registerUpdateListener(() => { - updatePopup() - }), - editor.registerRootListener(() => { - if (editor.getRootElement() === null) { - setIsText(false) - } - }), - ) - }, [editor, updatePopup]) + if (isMobile) { + return null + } if (!isText && !isLink) { return null diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/MobileToolbarPlugin/MobileToolbarPlugin.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugins/MobileToolbarPlugin.tsx similarity index 84% rename from packages/web/src/javascripts/Components/SuperEditor/Plugins/MobileToolbarPlugin/MobileToolbarPlugin.tsx rename to packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugins/MobileToolbarPlugin.tsx index adb52b239..81db5119b 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/MobileToolbarPlugin/MobileToolbarPlugin.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugins/MobileToolbarPlugin.tsx @@ -1,7 +1,7 @@ import Icon from '@/Components/Icon/Icon' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import useModal from '../../Lexical/Hooks/useModal' -import { InsertTableDialog } from '../../Plugins/TablePlugin' +import { InsertTableDialog } from '../TablePlugin' import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode' import { $getSelection, @@ -41,8 +41,10 @@ import { SUPER_TOGGLE_SEARCH } from '@standardnotes/ui-services' import { useApplication } from '@/Components/ApplicationProvider' import { GetRemoteImageBlock } from '../Blocks/RemoteImage' import { InsertRemoteImageDialog } from '../RemoteImagePlugin/RemoteImagePlugin' -import LinkEditor from '../FloatingLinkEditorPlugin/LinkEditor' +import LinkEditor from '../LinkEditor/LinkEditor' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants' +import { useSelectedTextFormatInfo } from './useSelectedTextFormatInfo' +import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip' const MobileToolbarPlugin = () => { const application = useApplication() @@ -75,6 +77,10 @@ const MobileToolbarPlugin = () => { } }, [editor]) + const { isBold, isItalic, isUnderline, isSubscript, isSuperscript, isStrikethrough, blockType } = + useSelectedTextFormatInfo() + const [isSelectionLink, setIsSelectionLink] = useState(false) + const [canUndo, setCanUndo] = useState(false) const [canRedo, setCanRedo] = useState(false) useEffect(() => { @@ -103,6 +109,7 @@ const MobileToolbarPlugin = () => { name: string iconName: string keywords?: string[] + active?: boolean disabled?: boolean onSelect: () => void }[] => [ @@ -128,6 +135,7 @@ const MobileToolbarPlugin = () => { onSelect: () => { editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold') }, + active: isBold, }, { name: 'Italic', @@ -135,6 +143,7 @@ const MobileToolbarPlugin = () => { onSelect: () => { editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic') }, + active: isItalic, }, { name: 'Underline', @@ -142,6 +151,7 @@ const MobileToolbarPlugin = () => { onSelect: () => { editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline') }, + active: isUnderline, }, { name: 'Strikethrough', @@ -149,6 +159,7 @@ const MobileToolbarPlugin = () => { onSelect: () => { editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough') }, + active: isStrikethrough, }, { name: 'Subscript', @@ -156,6 +167,7 @@ const MobileToolbarPlugin = () => { onSelect: () => { editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript') }, + active: isSubscript, }, { name: 'Superscript', @@ -163,6 +175,7 @@ const MobileToolbarPlugin = () => { onSelect: () => { editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript') }, + active: isSuperscript, }, { name: 'Link', @@ -172,6 +185,7 @@ const MobileToolbarPlugin = () => { insertLink() }) }, + active: isSelectionLink, }, { name: 'Search', @@ -189,8 +203,8 @@ const MobileToolbarPlugin = () => { GetRemoteImageBlock(() => { showModal('Insert image from URL', (onClose) => ) }), - GetNumberedListBlock(editor), - GetBulletedListBlock(editor), + GetNumberedListBlock(editor, blockType === 'number'), + GetBulletedListBlock(editor, blockType === 'bullet'), GetChecklistBlock(editor), GetQuoteBlock(editor), GetCodeBlock(editor), @@ -201,7 +215,22 @@ const MobileToolbarPlugin = () => { GetCollapsibleBlock(editor), ...GetEmbedsBlocks(editor), ], - [application.keyboardService, canRedo, canUndo, editor, insertLink, showModal], + [ + application.keyboardService, + blockType, + canRedo, + canUndo, + editor, + insertLink, + isBold, + isItalic, + isSelectionLink, + isStrikethrough, + isSubscript, + isSuperscript, + isUnderline, + showModal, + ], ) useEffect(() => { @@ -272,8 +301,6 @@ const MobileToolbarPlugin = () => { linkEditor?.removeEventListener('blur', handleLinkEditorBlur) } }, []) - - const [isSelectionLink, setIsSelectionLink] = useState(false) const [isSelectionAutoLink, setIsSelectionAutoLink] = useState(false) const [linkUrl, setLinkUrl] = useState('') const [isLinkEditMode, setIsLinkEditMode] = useState(false) @@ -372,20 +399,35 @@ const MobileToolbarPlugin = () => {
{items.map((item) => { return ( - + + + ) })}
diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugins/useSelectedTextFormatInfo.ts b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugins/useSelectedTextFormatInfo.ts new file mode 100644 index 000000000..15ed7a834 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugins/useSelectedTextFormatInfo.ts @@ -0,0 +1,177 @@ +import { $isCodeHighlightNode } from '@lexical/code' +import { $isLinkNode, $isAutoLinkNode } from '@lexical/link' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { mergeRegister, $findMatchingParent, $getNearestNodeOfType } from '@lexical/utils' +import { + $getSelection, + $isRangeSelection, + $isTextNode, + SELECTION_CHANGE_COMMAND, + $isRootOrShadowRoot, + COMMAND_PRIORITY_CRITICAL, +} from 'lexical' +import { $isHeadingNode } from '@lexical/rich-text' +import { $isListNode, ListNode } from '@lexical/list' +import { useCallback, useEffect, useState } from 'react' +import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode' +import { $isLinkTextNode } from '../LinkEditor/LinkTextEditor' + +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', +} + +export function useSelectedTextFormatInfo() { + const [editor] = useLexicalComposerContext() + const [activeEditor, setActiveEditor] = useState(editor) + const [isText, setIsText] = useState(false) + const [isLink, setIsLink] = useState(false) + const [isAutoLink, setIsAutoLink] = useState(false) + const [isLinkText, setIsLinkText] = 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 [blockType, setBlockType] = useState('paragraph') + + const updateTextFormatInfo = 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 = activeEditor.getElementByKey(elementKey) + + if (elementDOM !== null) { + if ($isListNode(element)) { + const parentList = $getNearestNodeOfType(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) + } + } + } + + 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 ($isAutoLinkNode(parent) || $isAutoLinkNode(node)) { + setIsAutoLink(true) + } else { + setIsAutoLink(false) + } + if ($isLinkTextNode(node, selection)) { + setIsLinkText(true) + } else { + setIsLinkText(false) + } + + if (!$isCodeHighlightNode(selection.anchor.getNode()) && selection.getTextContent() !== '') { + setIsText($isTextNode(node)) + } else { + setIsText(false) + } + }) + }, [editor, activeEditor]) + + useEffect(() => { + return editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_payload, newEditor) => { + setActiveEditor(newEditor) + updateTextFormatInfo() + return false + }, + COMMAND_PRIORITY_CRITICAL, + ) + }, [editor, updateTextFormatInfo]) + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(() => { + updateTextFormatInfo() + }), + editor.registerRootListener(() => { + if (editor.getRootElement() === null) { + setIsText(false) + } + }), + ) + }, [editor, updateTextFormatInfo]) + + return { + isText, + isLink, + isAutoLink, + isLinkText, + isBold, + isItalic, + isUnderline, + isStrikethrough, + isSubscript, + isSuperscript, + isCode, + blockType, + } +} diff --git a/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx b/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx index 4154f870a..596efefef 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx @@ -44,7 +44,7 @@ import ReadonlyPlugin from './Plugins/ReadonlyPlugin/ReadonlyPlugin' import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context' import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin' import ModalOverlay from '@/Components/Modal/ModalOverlay' -import MobileToolbarPlugin from './Plugins/MobileToolbarPlugin/MobileToolbarPlugin' +import MobileToolbarPlugin from './Plugins/ToolbarPlugins/MobileToolbarPlugin' import CodeOptionsPlugin from './Plugins/CodeOptionsPlugin/CodeOptions' import RemoteImagePlugin from './Plugins/RemoteImagePlugin/RemoteImagePlugin' import NotEntitledBanner from '../ComponentView/NotEntitledBanner' diff --git a/packages/web/src/javascripts/Hooks/useContextMenuEvent.tsx b/packages/web/src/javascripts/Hooks/useContextMenuEvent.tsx index cd57cee63..d20483b4d 100644 --- a/packages/web/src/javascripts/Hooks/useContextMenuEvent.tsx +++ b/packages/web/src/javascripts/Hooks/useContextMenuEvent.tsx @@ -3,7 +3,7 @@ import { useLongPressEvent } from './useLongPress' import { isIOS } from '@standardnotes/ui-services' export const useContextMenuEvent = (elementRef: RefObject, listener: (x: number, y: number) => void) => { - const { attachEvents, cleanupEvents } = useLongPressEvent(elementRef, listener) + const { attachEvents, cleanupEvents } = useLongPressEvent(elementRef, listener, true) const handleContextMenuEvent = useCallback( (event: MouseEvent) => { diff --git a/packages/web/src/javascripts/Hooks/useLongPress.tsx b/packages/web/src/javascripts/Hooks/useLongPress.tsx index 840835bce..b07f2b470 100644 --- a/packages/web/src/javascripts/Hooks/useLongPress.tsx +++ b/packages/web/src/javascripts/Hooks/useLongPress.tsx @@ -6,9 +6,11 @@ const ReactNativeLongpressDelay = 370 export const useLongPressEvent = ( elementRef: RefObject, listener: (x: number, y: number) => void, + clearOnPointerMove = false, delay = ReactNativeLongpressDelay, ) => { const longPressTimeout = useRef() + const pointerPosition = useRef<{ x: number; y: number }>() const clearLongPressTimeout = useCallback(() => { if (longPressTimeout.current) { @@ -19,14 +21,36 @@ export const useLongPressEvent = ( const createLongPressTimeout = useCallback( (event: PointerEvent) => { clearLongPressTimeout() + pointerPosition.current = { x: event.clientX, y: event.clientY } longPressTimeout.current = window.setTimeout(() => { + elementRef.current?.addEventListener( + 'mousedown', + (event) => { + event.preventDefault() + event.stopPropagation() + }, + { once: true, capture: true }, + ) + const x = event.clientX const y = event.clientY listener(x, y) }, delay) }, - [clearLongPressTimeout, delay, listener], + [clearLongPressTimeout, delay, elementRef, listener], + ) + + const clearLongPressTimeoutIfMoved = useCallback( + (event: PointerEvent) => { + if ( + pointerPosition.current && + (event.clientX !== pointerPosition.current.x || event.clientY !== pointerPosition.current.y) + ) { + clearLongPressTimeout() + } + }, + [clearLongPressTimeout], ) const attachEvents = useCallback(() => { @@ -35,10 +59,12 @@ export const useLongPressEvent = ( } elementRef.current.addEventListener('pointerdown', createLongPressTimeout) - elementRef.current.addEventListener('pointermove', clearLongPressTimeout) + if (clearOnPointerMove) { + elementRef.current.addEventListener('pointermove', clearLongPressTimeoutIfMoved) + } elementRef.current.addEventListener('pointercancel', clearLongPressTimeout) elementRef.current.addEventListener('pointerup', clearLongPressTimeout) - }, [clearLongPressTimeout, createLongPressTimeout, elementRef]) + }, [clearLongPressTimeout, clearLongPressTimeoutIfMoved, clearOnPointerMove, createLongPressTimeout, elementRef]) const cleanupEvents = useCallback(() => { if (!elementRef.current) { @@ -46,10 +72,12 @@ export const useLongPressEvent = ( } elementRef.current.removeEventListener('pointerdown', createLongPressTimeout) - elementRef.current.removeEventListener('pointermove', clearLongPressTimeout) + if (clearOnPointerMove) { + elementRef.current.removeEventListener('pointermove', clearLongPressTimeoutIfMoved) + } elementRef.current.removeEventListener('pointercancel', clearLongPressTimeout) elementRef.current.removeEventListener('pointerup', clearLongPressTimeout) - }, [clearLongPressTimeout, createLongPressTimeout, elementRef]) + }, [clearLongPressTimeout, clearLongPressTimeoutIfMoved, clearOnPointerMove, createLongPressTimeout, elementRef]) const memoizedReturn = useMemo( () => ({