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)

This commit is contained in:
Aman Harwara
2023-08-16 21:52:41 +05:30
committed by GitHub
parent d508425b34
commit 77f72ff7b6
14 changed files with 350 additions and 426 deletions

View File

@@ -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,
})

View File

@@ -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<TooltipOptions>) => {
const [forceOpen, setForceOpen] = useState<boolean | undefined>()
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<HTMLElement>(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 (
<>
<TooltipAnchor
onClick={() => 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,
})

View File

@@ -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'

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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<HTMLDivElement | null>(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)
) {
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 (
<div
ref={editorRef}
className="absolute left-0 top-0 max-w-[100vw] rounded-lg border border-border bg-default px-2 py-1 shadow shadow-contrast md:hidden"
>
<LinkEditor
linkUrl={linkUrl}
isEditMode={isEditMode}
setEditMode={setEditMode}
editor={editor}
lastSelection={lastSelection}
isAutoLink={isAutoLink}
/>
</div>
)
}
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(
<FloatingLinkEditor editor={activeEditor} anchorElem={anchorElem} isAutoLink={isAutoLink} />,
anchorElem,
)
: null
}
export default function FloatingLinkEditorPlugin({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement
}): JSX.Element | null {
const [editor] = useLexicalComposerContext()
return useFloatingLinkEditorToolbar(editor, anchorElem)
}

View File

@@ -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<keyof typeof blockTypeToBlockName>('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<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)
}
}
}
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

View File

@@ -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) => <InsertRemoteImageDialog onClose={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 = () => {
<div className="flex w-full flex-shrink-0 border-t border-border bg-contrast">
<div
tabIndex={-1}
className={classNames('flex items-center gap-1 overflow-x-auto', '[&::-webkit-scrollbar]:h-0')}
className="flex items-center gap-1 overflow-x-auto pl-1 [&::-webkit-scrollbar]:h-0"
ref={toolbarRef}
>
{items.map((item) => {
return (
<button
className="flex items-center justify-center rounded px-3 py-3 disabled:opacity-50"
aria-label={item.name}
onClick={item.onSelect}
key={item.name}
disabled={item.disabled}
>
<Icon type={item.iconName} size="medium" className="!text-current [&>path]:!text-current" />
</button>
<StyledTooltip showOnMobile showOnHover label={item.name} key={item.name}>
<button
className="flex items-center justify-center rounded p-0.5 disabled:opacity-50 select-none hover:bg-default"
aria-label={item.name}
onMouseDown={(event) => {
event.preventDefault()
item.onSelect()
}}
onContextMenu={(event) => {
editor.focus()
event.preventDefault()
}}
disabled={item.disabled}
>
<div
className={classNames(
'flex items-center justify-center p-2 rounded transition-colors duration-75',
item.active && 'bg-info text-info-contrast',
)}
>
<Icon type={item.iconName} size="medium" className="!text-current [&>path]:!text-current" />
</div>
</button>
</StyledTooltip>
)
})}
</div>

View File

@@ -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<keyof typeof blockTypeToBlockName>('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<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)
}
}
}
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,
}
}

View File

@@ -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'

View File

@@ -3,7 +3,7 @@ import { useLongPressEvent } from './useLongPress'
import { isIOS } from '@standardnotes/ui-services'
export const useContextMenuEvent = (elementRef: RefObject<HTMLElement>, listener: (x: number, y: number) => void) => {
const { attachEvents, cleanupEvents } = useLongPressEvent(elementRef, listener)
const { attachEvents, cleanupEvents } = useLongPressEvent(elementRef, listener, true)
const handleContextMenuEvent = useCallback(
(event: MouseEvent) => {

View File

@@ -6,9 +6,11 @@ const ReactNativeLongpressDelay = 370
export const useLongPressEvent = (
elementRef: RefObject<HTMLElement>,
listener: (x: number, y: number) => void,
clearOnPointerMove = false,
delay = ReactNativeLongpressDelay,
) => {
const longPressTimeout = useRef<number>()
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(
() => ({