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:
@@ -63,13 +63,19 @@ const getStylesFromRect = (options: {
|
|||||||
side: PopoverSide
|
side: PopoverSide
|
||||||
align: PopoverAlignment
|
align: PopoverAlignment
|
||||||
disableMobileFullscreenTakeover?: boolean
|
disableMobileFullscreenTakeover?: boolean
|
||||||
|
disableApplyingMobileWidth?: boolean
|
||||||
maxHeight?: number | 'none'
|
maxHeight?: number | 'none'
|
||||||
offset?: number
|
offset?: number
|
||||||
}): PopoverCSSProperties => {
|
}): PopoverCSSProperties => {
|
||||||
const { rect, disableMobileFullscreenTakeover = false, maxHeight = 'none' } = options
|
const {
|
||||||
|
rect,
|
||||||
|
disableMobileFullscreenTakeover = false,
|
||||||
|
disableApplyingMobileWidth = false,
|
||||||
|
maxHeight = 'none',
|
||||||
|
} = options
|
||||||
|
|
||||||
const canApplyMaxHeight = maxHeight !== 'none' && (!isMobileScreen() || disableMobileFullscreenTakeover)
|
const canApplyMaxHeight = maxHeight !== 'none' && (!isMobileScreen() || disableMobileFullscreenTakeover)
|
||||||
const shouldApplyMobileWidth = isMobileScreen() && disableMobileFullscreenTakeover
|
const shouldApplyMobileWidth = isMobileScreen() && disableMobileFullscreenTakeover && !disableApplyingMobileWidth
|
||||||
const marginForMobile = percentOf(10, window.innerWidth)
|
const marginForMobile = percentOf(10, window.innerWidth)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -96,6 +102,7 @@ type Options = {
|
|||||||
popoverRect?: DOMRect
|
popoverRect?: DOMRect
|
||||||
side: PopoverSide
|
side: PopoverSide
|
||||||
disableMobileFullscreenTakeover?: boolean
|
disableMobileFullscreenTakeover?: boolean
|
||||||
|
disableApplyingMobileWidth?: boolean
|
||||||
maxHeightFunction?: (calculatedMaxHeight: number) => number | 'none'
|
maxHeightFunction?: (calculatedMaxHeight: number) => number | 'none'
|
||||||
offset?: number
|
offset?: number
|
||||||
}
|
}
|
||||||
@@ -107,6 +114,7 @@ export const getPositionedPopoverStyles = ({
|
|||||||
popoverRect,
|
popoverRect,
|
||||||
side,
|
side,
|
||||||
disableMobileFullscreenTakeover,
|
disableMobileFullscreenTakeover,
|
||||||
|
disableApplyingMobileWidth,
|
||||||
maxHeightFunction,
|
maxHeightFunction,
|
||||||
offset,
|
offset,
|
||||||
}: Options): PopoverCSSProperties | null => {
|
}: Options): PopoverCSSProperties | null => {
|
||||||
@@ -159,6 +167,7 @@ export const getPositionedPopoverStyles = ({
|
|||||||
side: sideWithLessOverflows,
|
side: sideWithLessOverflows,
|
||||||
align: finalAlignment,
|
align: finalAlignment,
|
||||||
disableMobileFullscreenTakeover,
|
disableMobileFullscreenTakeover,
|
||||||
|
disableApplyingMobileWidth,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
offset,
|
offset,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { classNames } from '@standardnotes/snjs'
|
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 { Tooltip, TooltipAnchor, TooltipOptions, useTooltipStore } from '@ariakit/react'
|
||||||
import { Slot } from '@radix-ui/react-slot'
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
|
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
|
||||||
import { getPositionedPopoverStyles } from '../Popover/GetPositionedPopoverStyles'
|
import { getPositionedPopoverStyles } from '../Popover/GetPositionedPopoverStyles'
|
||||||
import { getAdjustedStylesForNonPortalPopover } from '../Popover/Utils/getAdjustedStylesForNonPortal'
|
import { getAdjustedStylesForNonPortalPopover } from '../Popover/Utils/getAdjustedStylesForNonPortal'
|
||||||
|
import { useLongPressEvent } from '@/Hooks/useLongPress'
|
||||||
|
|
||||||
const StyledTooltip = ({
|
const StyledTooltip = ({
|
||||||
children,
|
children,
|
||||||
@@ -24,13 +25,43 @@ const StyledTooltip = ({
|
|||||||
} & Partial<TooltipOptions>) => {
|
} & Partial<TooltipOptions>) => {
|
||||||
const [forceOpen, setForceOpen] = useState<boolean | undefined>()
|
const [forceOpen, setForceOpen] = useState<boolean | undefined>()
|
||||||
|
|
||||||
|
const isMobile = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
|
||||||
const tooltip = useTooltipStore({
|
const tooltip = useTooltipStore({
|
||||||
timeout: 500,
|
timeout: isMobile && showOnMobile ? 100 : 500,
|
||||||
hideTimeout: 0,
|
hideTimeout: 0,
|
||||||
skipTimeout: 0,
|
skipTimeout: 0,
|
||||||
open: forceOpen,
|
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) {
|
if (isMobile && !showOnMobile) {
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
@@ -39,7 +70,8 @@ const StyledTooltip = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TooltipAnchor
|
<TooltipAnchor
|
||||||
onClick={() => setForceOpen(false)}
|
ref={anchorRef}
|
||||||
|
{...clickProps}
|
||||||
onBlur={() => setForceOpen(undefined)}
|
onBlur={() => setForceOpen(undefined)}
|
||||||
store={tooltip}
|
store={tooltip}
|
||||||
as={Slot}
|
as={Slot}
|
||||||
@@ -53,6 +85,7 @@ const StyledTooltip = ({
|
|||||||
store={tooltip}
|
store={tooltip}
|
||||||
className={classNames(
|
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',
|
'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,
|
className,
|
||||||
)}
|
)}
|
||||||
updatePosition={() => {
|
updatePosition={() => {
|
||||||
@@ -79,6 +112,7 @@ const StyledTooltip = ({
|
|||||||
popoverRect,
|
popoverRect,
|
||||||
documentRect,
|
documentRect,
|
||||||
disableMobileFullscreenTakeover: true,
|
disableMobileFullscreenTakeover: true,
|
||||||
|
disableApplyingMobileWidth: true,
|
||||||
offset: props.gutter ? props.gutter : 6,
|
offset: props.gutter ? props.gutter : 6,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import AutoEmbedPlugin from './Plugins/AutoEmbedPlugin'
|
|||||||
import CollapsiblePlugin from './Plugins/CollapsiblePlugin'
|
import CollapsiblePlugin from './Plugins/CollapsiblePlugin'
|
||||||
import DraggableBlockPlugin from './Plugins/DraggableBlockPlugin'
|
import DraggableBlockPlugin from './Plugins/DraggableBlockPlugin'
|
||||||
import CodeHighlightPlugin from './Plugins/CodeHighlightPlugin'
|
import CodeHighlightPlugin from './Plugins/CodeHighlightPlugin'
|
||||||
import FloatingTextFormatToolbarPlugin from './Plugins/FloatingTextFormatToolbarPlugin'
|
import FloatingTextFormatToolbarPlugin from './Plugins/ToolbarPlugins/FloatingTextFormatToolbarPlugin'
|
||||||
import { TabIndentationPlugin } from './Plugins/TabIndentationPlugin'
|
import { TabIndentationPlugin } from './Plugins/TabIndentationPlugin'
|
||||||
import { handleEditorChange } from './Utils'
|
import { handleEditorChange } from './Utils'
|
||||||
import { SuperEditorContentId } from './Constants'
|
import { SuperEditorContentId } from './Constants'
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { LexicalEditor } from 'lexical'
|
import { LexicalEditor } from 'lexical'
|
||||||
import { INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list'
|
import { INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list'
|
||||||
|
|
||||||
export function GetBulletedListBlock(editor: LexicalEditor) {
|
export function GetBulletedListBlock(editor: LexicalEditor, isActive = false) {
|
||||||
return {
|
return {
|
||||||
name: 'Bulleted List',
|
name: 'Bulleted List',
|
||||||
iconName: 'list-bulleted',
|
iconName: 'list-bulleted',
|
||||||
keywords: ['bulleted list', 'unordered list', 'ul'],
|
keywords: ['bulleted list', 'unordered list', 'ul'],
|
||||||
onSelect: () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined),
|
onSelect: () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined),
|
||||||
|
active: isActive,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { LexicalEditor } from 'lexical'
|
import { LexicalEditor } from 'lexical'
|
||||||
import { INSERT_ORDERED_LIST_COMMAND } from '@lexical/list'
|
import { INSERT_ORDERED_LIST_COMMAND } from '@lexical/list'
|
||||||
|
|
||||||
export function GetNumberedListBlock(editor: LexicalEditor) {
|
export function GetNumberedListBlock(editor: LexicalEditor, isActive = false) {
|
||||||
return {
|
return {
|
||||||
name: 'Numbered List',
|
name: 'Numbered List',
|
||||||
iconName: 'list-numbered',
|
iconName: 'list-numbered',
|
||||||
keywords: ['numbered list', 'ordered list', 'ol'],
|
keywords: ['numbered list', 'ordered list', 'ol'],
|
||||||
onSelect: () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined),
|
onSelect: () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined),
|
||||||
|
active: isActive,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -6,19 +6,15 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { $isCodeHighlightNode } from '@lexical/code'
|
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
|
||||||
import { $isLinkNode, $isAutoLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
|
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||||
import { mergeRegister, $findMatchingParent, $getNearestNodeOfType } from '@lexical/utils'
|
import { mergeRegister } from '@lexical/utils'
|
||||||
import {
|
import {
|
||||||
$getSelection,
|
$getSelection,
|
||||||
$isRangeSelection,
|
$isRangeSelection,
|
||||||
$isTextNode,
|
|
||||||
FORMAT_TEXT_COMMAND,
|
FORMAT_TEXT_COMMAND,
|
||||||
LexicalEditor,
|
LexicalEditor,
|
||||||
SELECTION_CHANGE_COMMAND,
|
SELECTION_CHANGE_COMMAND,
|
||||||
$isRootOrShadowRoot,
|
|
||||||
COMMAND_PRIORITY_CRITICAL,
|
|
||||||
COMMAND_PRIORITY_LOW,
|
COMMAND_PRIORITY_LOW,
|
||||||
RangeSelection,
|
RangeSelection,
|
||||||
GridSelection,
|
GridSelection,
|
||||||
@@ -27,17 +23,9 @@ import {
|
|||||||
COMMAND_PRIORITY_NORMAL,
|
COMMAND_PRIORITY_NORMAL,
|
||||||
createCommand,
|
createCommand,
|
||||||
} from 'lexical'
|
} from 'lexical'
|
||||||
import { $isHeadingNode } from '@lexical/rich-text'
|
import { INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND } from '@lexical/list'
|
||||||
import {
|
|
||||||
INSERT_UNORDERED_LIST_COMMAND,
|
|
||||||
REMOVE_LIST_COMMAND,
|
|
||||||
$isListNode,
|
|
||||||
ListNode,
|
|
||||||
INSERT_ORDERED_LIST_COMMAND,
|
|
||||||
} from '@lexical/list'
|
|
||||||
import { ComponentPropsWithoutRef, useCallback, useEffect, useRef, useState } from 'react'
|
import { ComponentPropsWithoutRef, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
|
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
|
||||||
import {
|
import {
|
||||||
BoldIcon,
|
BoldIcon,
|
||||||
@@ -56,24 +44,11 @@ import { classNames } from '@standardnotes/snjs'
|
|||||||
import { getDOMRangeRect } from '../../Lexical/Utils/getDOMRangeRect'
|
import { getDOMRangeRect } from '../../Lexical/Utils/getDOMRangeRect'
|
||||||
import { getPositionedPopoverStyles } from '@/Components/Popover/GetPositionedPopoverStyles'
|
import { getPositionedPopoverStyles } from '@/Components/Popover/GetPositionedPopoverStyles'
|
||||||
import { getAdjustedStylesForNonPortalPopover } from '@/Components/Popover/Utils/getAdjustedStylesForNonPortal'
|
import { getAdjustedStylesForNonPortalPopover } from '@/Components/Popover/Utils/getAdjustedStylesForNonPortal'
|
||||||
import LinkEditor from '../FloatingLinkEditorPlugin/LinkEditor'
|
import LinkEditor from '../LinkEditor/LinkEditor'
|
||||||
import LinkTextEditor, { $isLinkTextNode } from '../FloatingLinkEditorPlugin/LinkTextEditor'
|
import LinkTextEditor, { $isLinkTextNode } from '../LinkEditor/LinkTextEditor'
|
||||||
import { URL_REGEX } from '@/Constants/Constants'
|
import { URL_REGEX } from '@/Constants/Constants'
|
||||||
|
import { useSelectedTextFormatInfo } from './useSelectedTextFormatInfo'
|
||||||
const blockTypeToBlockName = {
|
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|
||||||
const IconSize = 15
|
const IconSize = 15
|
||||||
|
|
||||||
@@ -464,138 +439,26 @@ function TextFormatFloatingToolbar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useFloatingTextFormatToolbar(editor: LexicalEditor, anchorElem: HTMLElement): JSX.Element | null {
|
function useFloatingTextFormatToolbar(editor: LexicalEditor, anchorElem: HTMLElement): JSX.Element | null {
|
||||||
const [activeEditor, setActiveEditor] = useState(editor)
|
const {
|
||||||
const [isText, setIsText] = useState(false)
|
isText,
|
||||||
const [isLink, setIsLink] = useState(false)
|
isLink,
|
||||||
const [isAutoLink, setIsAutoLink] = useState(false)
|
isLinkText,
|
||||||
const [isLinkText, setIsLinkText] = useState(false)
|
isAutoLink,
|
||||||
const [isBold, setIsBold] = useState(false)
|
isBold,
|
||||||
const [isItalic, setIsItalic] = useState(false)
|
isItalic,
|
||||||
const [isUnderline, setIsUnderline] = useState(false)
|
isStrikethrough,
|
||||||
const [isStrikethrough, setIsStrikethrough] = useState(false)
|
isSubscript,
|
||||||
const [isSubscript, setIsSubscript] = useState(false)
|
isSuperscript,
|
||||||
const [isSuperscript, setIsSuperscript] = useState(false)
|
isUnderline,
|
||||||
const [isCode, setIsCode] = useState(false)
|
isCode,
|
||||||
const [blockType, setBlockType] = useState<keyof typeof blockTypeToBlockName>('paragraph')
|
blockType,
|
||||||
|
} = useSelectedTextFormatInfo()
|
||||||
|
|
||||||
const updatePopup = useCallback(() => {
|
const isMobile = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
|
||||||
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 = window.matchMedia('(max-width: 768px)').matches
|
if (isMobile) {
|
||||||
|
return null
|
||||||
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 (!isText && !isLink) {
|
if (!isText && !isLink) {
|
||||||
return null
|
return null
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import Icon from '@/Components/Icon/Icon'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||||
import useModal from '../../Lexical/Hooks/useModal'
|
import useModal from '../../Lexical/Hooks/useModal'
|
||||||
import { InsertTableDialog } from '../../Plugins/TablePlugin'
|
import { InsertTableDialog } from '../TablePlugin'
|
||||||
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
|
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
|
||||||
import {
|
import {
|
||||||
$getSelection,
|
$getSelection,
|
||||||
@@ -41,8 +41,10 @@ import { SUPER_TOGGLE_SEARCH } from '@standardnotes/ui-services'
|
|||||||
import { useApplication } from '@/Components/ApplicationProvider'
|
import { useApplication } from '@/Components/ApplicationProvider'
|
||||||
import { GetRemoteImageBlock } from '../Blocks/RemoteImage'
|
import { GetRemoteImageBlock } from '../Blocks/RemoteImage'
|
||||||
import { InsertRemoteImageDialog } from '../RemoteImagePlugin/RemoteImagePlugin'
|
import { InsertRemoteImageDialog } from '../RemoteImagePlugin/RemoteImagePlugin'
|
||||||
import LinkEditor from '../FloatingLinkEditorPlugin/LinkEditor'
|
import LinkEditor from '../LinkEditor/LinkEditor'
|
||||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||||
|
import { useSelectedTextFormatInfo } from './useSelectedTextFormatInfo'
|
||||||
|
import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
|
||||||
|
|
||||||
const MobileToolbarPlugin = () => {
|
const MobileToolbarPlugin = () => {
|
||||||
const application = useApplication()
|
const application = useApplication()
|
||||||
@@ -75,6 +77,10 @@ const MobileToolbarPlugin = () => {
|
|||||||
}
|
}
|
||||||
}, [editor])
|
}, [editor])
|
||||||
|
|
||||||
|
const { isBold, isItalic, isUnderline, isSubscript, isSuperscript, isStrikethrough, blockType } =
|
||||||
|
useSelectedTextFormatInfo()
|
||||||
|
const [isSelectionLink, setIsSelectionLink] = useState(false)
|
||||||
|
|
||||||
const [canUndo, setCanUndo] = useState(false)
|
const [canUndo, setCanUndo] = useState(false)
|
||||||
const [canRedo, setCanRedo] = useState(false)
|
const [canRedo, setCanRedo] = useState(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -103,6 +109,7 @@ const MobileToolbarPlugin = () => {
|
|||||||
name: string
|
name: string
|
||||||
iconName: string
|
iconName: string
|
||||||
keywords?: string[]
|
keywords?: string[]
|
||||||
|
active?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
}[] => [
|
}[] => [
|
||||||
@@ -128,6 +135,7 @@ const MobileToolbarPlugin = () => {
|
|||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
|
||||||
},
|
},
|
||||||
|
active: isBold,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Italic',
|
name: 'Italic',
|
||||||
@@ -135,6 +143,7 @@ const MobileToolbarPlugin = () => {
|
|||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
|
||||||
},
|
},
|
||||||
|
active: isItalic,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Underline',
|
name: 'Underline',
|
||||||
@@ -142,6 +151,7 @@ const MobileToolbarPlugin = () => {
|
|||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')
|
||||||
},
|
},
|
||||||
|
active: isUnderline,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Strikethrough',
|
name: 'Strikethrough',
|
||||||
@@ -149,6 +159,7 @@ const MobileToolbarPlugin = () => {
|
|||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
|
||||||
},
|
},
|
||||||
|
active: isStrikethrough,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Subscript',
|
name: 'Subscript',
|
||||||
@@ -156,6 +167,7 @@ const MobileToolbarPlugin = () => {
|
|||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript')
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript')
|
||||||
},
|
},
|
||||||
|
active: isSubscript,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Superscript',
|
name: 'Superscript',
|
||||||
@@ -163,6 +175,7 @@ const MobileToolbarPlugin = () => {
|
|||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript')
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript')
|
||||||
},
|
},
|
||||||
|
active: isSuperscript,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Link',
|
name: 'Link',
|
||||||
@@ -172,6 +185,7 @@ const MobileToolbarPlugin = () => {
|
|||||||
insertLink()
|
insertLink()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
active: isSelectionLink,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Search',
|
name: 'Search',
|
||||||
@@ -189,8 +203,8 @@ const MobileToolbarPlugin = () => {
|
|||||||
GetRemoteImageBlock(() => {
|
GetRemoteImageBlock(() => {
|
||||||
showModal('Insert image from URL', (onClose) => <InsertRemoteImageDialog onClose={onClose} />)
|
showModal('Insert image from URL', (onClose) => <InsertRemoteImageDialog onClose={onClose} />)
|
||||||
}),
|
}),
|
||||||
GetNumberedListBlock(editor),
|
GetNumberedListBlock(editor, blockType === 'number'),
|
||||||
GetBulletedListBlock(editor),
|
GetBulletedListBlock(editor, blockType === 'bullet'),
|
||||||
GetChecklistBlock(editor),
|
GetChecklistBlock(editor),
|
||||||
GetQuoteBlock(editor),
|
GetQuoteBlock(editor),
|
||||||
GetCodeBlock(editor),
|
GetCodeBlock(editor),
|
||||||
@@ -201,7 +215,22 @@ const MobileToolbarPlugin = () => {
|
|||||||
GetCollapsibleBlock(editor),
|
GetCollapsibleBlock(editor),
|
||||||
...GetEmbedsBlocks(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(() => {
|
useEffect(() => {
|
||||||
@@ -272,8 +301,6 @@ const MobileToolbarPlugin = () => {
|
|||||||
linkEditor?.removeEventListener('blur', handleLinkEditorBlur)
|
linkEditor?.removeEventListener('blur', handleLinkEditorBlur)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const [isSelectionLink, setIsSelectionLink] = useState(false)
|
|
||||||
const [isSelectionAutoLink, setIsSelectionAutoLink] = useState(false)
|
const [isSelectionAutoLink, setIsSelectionAutoLink] = useState(false)
|
||||||
const [linkUrl, setLinkUrl] = useState('')
|
const [linkUrl, setLinkUrl] = useState('')
|
||||||
const [isLinkEditMode, setIsLinkEditMode] = useState(false)
|
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 className="flex w-full flex-shrink-0 border-t border-border bg-contrast">
|
||||||
<div
|
<div
|
||||||
tabIndex={-1}
|
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}
|
ref={toolbarRef}
|
||||||
>
|
>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<StyledTooltip showOnMobile showOnHover label={item.name} key={item.name}>
|
||||||
className="flex items-center justify-center rounded px-3 py-3 disabled:opacity-50"
|
<button
|
||||||
aria-label={item.name}
|
className="flex items-center justify-center rounded p-0.5 disabled:opacity-50 select-none hover:bg-default"
|
||||||
onClick={item.onSelect}
|
aria-label={item.name}
|
||||||
key={item.name}
|
onMouseDown={(event) => {
|
||||||
disabled={item.disabled}
|
event.preventDefault()
|
||||||
>
|
item.onSelect()
|
||||||
<Icon type={item.iconName} size="medium" className="!text-current [&>path]:!text-current" />
|
}}
|
||||||
</button>
|
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>
|
</div>
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ import ReadonlyPlugin from './Plugins/ReadonlyPlugin/ReadonlyPlugin'
|
|||||||
import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context'
|
import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context'
|
||||||
import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin'
|
import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin'
|
||||||
import ModalOverlay from '@/Components/Modal/ModalOverlay'
|
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 CodeOptionsPlugin from './Plugins/CodeOptionsPlugin/CodeOptions'
|
||||||
import RemoteImagePlugin from './Plugins/RemoteImagePlugin/RemoteImagePlugin'
|
import RemoteImagePlugin from './Plugins/RemoteImagePlugin/RemoteImagePlugin'
|
||||||
import NotEntitledBanner from '../ComponentView/NotEntitledBanner'
|
import NotEntitledBanner from '../ComponentView/NotEntitledBanner'
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useLongPressEvent } from './useLongPress'
|
|||||||
import { isIOS } from '@standardnotes/ui-services'
|
import { isIOS } from '@standardnotes/ui-services'
|
||||||
|
|
||||||
export const useContextMenuEvent = (elementRef: RefObject<HTMLElement>, listener: (x: number, y: number) => void) => {
|
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(
|
const handleContextMenuEvent = useCallback(
|
||||||
(event: MouseEvent) => {
|
(event: MouseEvent) => {
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ const ReactNativeLongpressDelay = 370
|
|||||||
export const useLongPressEvent = (
|
export const useLongPressEvent = (
|
||||||
elementRef: RefObject<HTMLElement>,
|
elementRef: RefObject<HTMLElement>,
|
||||||
listener: (x: number, y: number) => void,
|
listener: (x: number, y: number) => void,
|
||||||
|
clearOnPointerMove = false,
|
||||||
delay = ReactNativeLongpressDelay,
|
delay = ReactNativeLongpressDelay,
|
||||||
) => {
|
) => {
|
||||||
const longPressTimeout = useRef<number>()
|
const longPressTimeout = useRef<number>()
|
||||||
|
const pointerPosition = useRef<{ x: number; y: number }>()
|
||||||
|
|
||||||
const clearLongPressTimeout = useCallback(() => {
|
const clearLongPressTimeout = useCallback(() => {
|
||||||
if (longPressTimeout.current) {
|
if (longPressTimeout.current) {
|
||||||
@@ -19,14 +21,36 @@ export const useLongPressEvent = (
|
|||||||
const createLongPressTimeout = useCallback(
|
const createLongPressTimeout = useCallback(
|
||||||
(event: PointerEvent) => {
|
(event: PointerEvent) => {
|
||||||
clearLongPressTimeout()
|
clearLongPressTimeout()
|
||||||
|
pointerPosition.current = { x: event.clientX, y: event.clientY }
|
||||||
longPressTimeout.current = window.setTimeout(() => {
|
longPressTimeout.current = window.setTimeout(() => {
|
||||||
|
elementRef.current?.addEventListener(
|
||||||
|
'mousedown',
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
},
|
||||||
|
{ once: true, capture: true },
|
||||||
|
)
|
||||||
|
|
||||||
const x = event.clientX
|
const x = event.clientX
|
||||||
const y = event.clientY
|
const y = event.clientY
|
||||||
|
|
||||||
listener(x, y)
|
listener(x, y)
|
||||||
}, delay)
|
}, 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(() => {
|
const attachEvents = useCallback(() => {
|
||||||
@@ -35,10 +59,12 @@ export const useLongPressEvent = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
elementRef.current.addEventListener('pointerdown', createLongPressTimeout)
|
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('pointercancel', clearLongPressTimeout)
|
||||||
elementRef.current.addEventListener('pointerup', clearLongPressTimeout)
|
elementRef.current.addEventListener('pointerup', clearLongPressTimeout)
|
||||||
}, [clearLongPressTimeout, createLongPressTimeout, elementRef])
|
}, [clearLongPressTimeout, clearLongPressTimeoutIfMoved, clearOnPointerMove, createLongPressTimeout, elementRef])
|
||||||
|
|
||||||
const cleanupEvents = useCallback(() => {
|
const cleanupEvents = useCallback(() => {
|
||||||
if (!elementRef.current) {
|
if (!elementRef.current) {
|
||||||
@@ -46,10 +72,12 @@ export const useLongPressEvent = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
elementRef.current.removeEventListener('pointerdown', createLongPressTimeout)
|
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('pointercancel', clearLongPressTimeout)
|
||||||
elementRef.current.removeEventListener('pointerup', clearLongPressTimeout)
|
elementRef.current.removeEventListener('pointerup', clearLongPressTimeout)
|
||||||
}, [clearLongPressTimeout, createLongPressTimeout, elementRef])
|
}, [clearLongPressTimeout, clearLongPressTimeoutIfMoved, clearOnPointerMove, createLongPressTimeout, elementRef])
|
||||||
|
|
||||||
const memoizedReturn = useMemo(
|
const memoizedReturn = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user