feat: Link and formatting toolbars for super are now unified on web/desktop to allow formatting existing links and updated link editor on mobile (#2342)

This commit is contained in:
Aman Harwara
2023-07-02 14:45:49 +05:30
committed by GitHub
parent f354a50c26
commit 929642195d
8 changed files with 315 additions and 448 deletions

View File

@@ -1,8 +1,12 @@
import { PopoverCSSProperties } from '../GetPositionedPopoverStyles'
import { getAbsolutePositionedParent } from './getAbsolutePositionedParent'
export const getAdjustedStylesForNonPortalPopover = (popoverElement: HTMLElement, styles: PopoverCSSProperties) => {
const absoluteParent = getAbsolutePositionedParent(popoverElement)
export const getAdjustedStylesForNonPortalPopover = (
popoverElement: HTMLElement,
styles: PopoverCSSProperties,
parent?: HTMLElement,
) => {
const absoluteParent = parent || getAbsolutePositionedParent(popoverElement)
const translateXProperty = styles?.['--translate-x']
const translateYProperty = styles?.['--translate-y']

View File

@@ -8,7 +8,7 @@ export const IconComponent = ({
paddingTop?: number
}) => {
return (
<span className="svg-icon" style={{ width: size, height: size, paddingTop }}>
<span className="svg-icon [&>svg]:fill-current" style={{ width: size, height: size, paddingTop }}>
{children}
</span>
)

View File

@@ -1,69 +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.
*
*
*/
@keyframes glimmer-animation {
0% {
background: #f9f9f9;
}
.50% {
background: #eeeeee;
}
.100% {
background: #f9f9f9;
}
}
.LinkPreview__container {
padding-bottom: 12px;
}
.LinkPreview__imageWrapper {
text-align: center;
}
.LinkPreview__image {
max-width: 100%;
max-height: 250px;
margin: auto;
}
.LinkPreview__title {
margin-left: 12px;
margin-right: 12px;
margin-top: 4px;
}
.LinkPreview__description {
color: #999;
font-size: 90%;
margin-left: 12px;
margin-right: 12px;
margin-top: 4px;
}
.LinkPreview__domain {
color: #999;
font-size: 90%;
margin-left: 12px;
margin-right: 12px;
margin-top: 4px;
}
.LinkPreview__glimmer {
background: #f9f9f9;
border-radius: 8px;
height: 18px;
margin-bottom: 8px;
margin-left: 12px;
margin-right: 12px;
animation-duration: 3s;
animation-iteration-count: infinite;
animation-timing-function: linear;
animation-name: glimmer-animation;
}

View File

@@ -1,106 +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 './LinkPreview.css'
import { CSSProperties, Suspense } from 'react'
type Preview = {
title: string
description: string
img: string
domain: string
} | null
// Cached responses or running request promises
const PREVIEW_CACHE: Record<string, Promise<Preview> | { preview: Preview }> = {}
const URL_MATCHER =
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
function useSuspenseRequest(url: string) {
let cached = PREVIEW_CACHE[url]
if (!url.match(URL_MATCHER)) {
return { preview: null }
}
if (!cached) {
cached = PREVIEW_CACHE[url] = fetch(`/api/link-preview?url=${encodeURI(url)}`)
.then((response) => response.json())
.then((preview) => {
PREVIEW_CACHE[url] = preview
return preview
})
.catch(() => {
PREVIEW_CACHE[url] = { preview: null }
})
}
if (cached instanceof Promise) {
throw cached
}
return cached
}
function LinkPreviewContent({
url,
}: Readonly<{
url: string
}>): JSX.Element | null {
const { preview } = useSuspenseRequest(url)
if (preview === null) {
return null
}
return (
<div className="LinkPreview__container">
{preview.img && (
<div className="LinkPreview__imageWrapper">
<img src={preview.img} alt={preview.title} className="LinkPreview__image" />
</div>
)}
{preview.domain && <div className="LinkPreview__domain">{preview.domain}</div>}
{preview.title && <div className="LinkPreview__title">{preview.title}</div>}
{preview.description && <div className="LinkPreview__description">{preview.description}</div>}
</div>
)
}
function Glimmer(props: { style: CSSProperties; index: number }): JSX.Element {
return (
<div
className="LinkPreview__glimmer"
{...props}
style={{
animationDelay: String((props.index || 0) * 300),
...(props.style || {}),
}}
/>
)
}
export default function LinkPreview({
url,
}: Readonly<{
url: string
}>): JSX.Element {
return (
<Suspense
fallback={
<>
<Glimmer style={{ height: '80px' }} index={0} />
<Glimmer style={{ width: '60%' }} index={1} />
<Glimmer style={{ width: '80%' }} index={2} />
</>
}
>
<LinkPreviewContent url={url} />
</Suspense>
)
}

View File

@@ -0,0 +1,120 @@
import Icon from '@/Components/Icon/Icon'
import { CloseIcon, CheckIcon, PencilFilledIcon, TrashFilledIcon } from '@standardnotes/icons'
import { KeyboardKey } from '@standardnotes/ui-services'
import { IconComponent } from '../../Lexical/Theme/IconComponent'
import { sanitizeUrl } from '../../Lexical/Utils/sanitizeUrl'
import { TOGGLE_LINK_COMMAND } from '@lexical/link'
import { useCallback, useState } from 'react'
import { GridSelection, LexicalEditor, NodeSelection, RangeSelection } from 'lexical'
type Props = {
linkUrl: string
isEditMode: boolean
setEditMode: (isEditMode: boolean) => void
editor: LexicalEditor
lastSelection: RangeSelection | GridSelection | NodeSelection | null
}
const LinkEditor = ({ linkUrl, isEditMode, setEditMode, editor, lastSelection }: Props) => {
const [editedLinkUrl, setEditedLinkUrl] = useState('')
const handleLinkSubmission = () => {
if (lastSelection !== null) {
if (linkUrl !== '') {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(editedLinkUrl))
}
setEditMode(false)
}
}
const focusInput = useCallback((input: HTMLInputElement | null) => {
if (input) {
input.focus()
}
}, [])
return isEditMode ? (
<div className="flex items-center gap-2">
<input
id="link-input"
ref={focusInput}
value={editedLinkUrl}
onChange={(event) => {
setEditedLinkUrl(event.target.value)
}}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Enter) {
event.preventDefault()
handleLinkSubmission()
} else if (event.key === KeyboardKey.Escape) {
event.preventDefault()
setEditMode(false)
}
}}
className="flex-grow rounded-sm bg-contrast p-1 text-text sm:min-w-[40ch]"
/>
<button
className="flex rounded-lg p-3 hover:bg-contrast hover:text-text disabled:cursor-not-allowed"
onClick={() => {
setEditMode(false)
editor.focus()
}}
aria-label="Cancel editing link"
onMouseDown={(event) => event.preventDefault()}
>
<IconComponent size={15}>
<CloseIcon />
</IconComponent>
</button>
<button
className="flex rounded-lg p-3 hover:bg-contrast hover:text-text disabled:cursor-not-allowed"
onClick={handleLinkSubmission}
aria-label="Save link"
onMouseDown={(event) => event.preventDefault()}
>
<IconComponent size={15}>
<CheckIcon />
</IconComponent>
</button>
</div>
) : (
<div className="flex items-center gap-1">
<a
className="mr-1 flex flex-grow items-center gap-2 overflow-hidden whitespace-nowrap underline"
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
>
<Icon type="open-in" className="ml-1 flex-shrink-0" />
<div className="max-w-[35ch] overflow-hidden text-ellipsis">{linkUrl}</div>
</a>
<button
className="flex rounded-lg p-3 hover:bg-contrast hover:text-text disabled:cursor-not-allowed"
onClick={() => {
setEditedLinkUrl(linkUrl)
setEditMode(true)
}}
aria-label="Edit link"
onMouseDown={(event) => event.preventDefault()}
>
<IconComponent size={15}>
<PencilFilledIcon />
</IconComponent>
</button>
<button
className="flex rounded-lg p-3 hover:bg-contrast hover:text-text disabled:cursor-not-allowed"
onClick={() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
}}
aria-label="Remove link"
onMouseDown={(event) => event.preventDefault()}
>
<IconComponent size={15}>
<TrashFilledIcon />
</IconComponent>
</button>
</div>
)
}
export default LinkEditor

View File

@@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*
*/
import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
import { $isAutoLinkNode, $isLinkNode } from '@lexical/link'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
import {
@@ -22,18 +22,14 @@ import {
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import LinkPreview from '../../Lexical/UI/LinkPreview'
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
import { sanitizeUrl } from '../../Lexical/Utils/sanitizeUrl'
import { setFloatingElemPosition } from '../../Lexical/Utils/setFloatingElemPosition'
import { LexicalPencilFill } from '@standardnotes/icons'
import { IconComponent } from '../../Lexical/../Lexical/Theme/IconComponent'
import { getDOMRangeRect } from '../../Lexical/Utils/getDOMRangeRect'
import { KeyboardKey } from '@standardnotes/ui-services'
import { getPositionedPopoverStyles } from '@/Components/Popover/GetPositionedPopoverStyles'
import { getAdjustedStylesForNonPortalPopover } from '@/Components/Popover/Utils/getAdjustedStylesForNonPortal'
import LinkEditor from './LinkEditor'
function FloatingLinkEditor({ editor, anchorElem }: { editor: LexicalEditor; anchorElem: HTMLElement }): JSX.Element {
const editorRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const [linkUrl, setLinkUrl] = useState('')
const [isEditMode, setEditMode] = useState(false)
const [lastSelection, setLastSelection] = useState<RangeSelection | GridSelection | NodeSelection | null>(null)
@@ -67,21 +63,36 @@ function FloatingLinkEditor({ editor, anchorElem }: { editor: LexicalEditor; anc
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode)
) {
setLastSelection(selection)
const rect = getDOMRangeRect(nativeSelection, rootElement)
setFloatingElemPosition(rect, editorElem, anchorElem)
setLastSelection(selection)
} else if (!activeElement || activeElement.className !== 'link-input') {
if (rootElement !== null) {
setFloatingElemPosition(null, editorElem, anchorElem)
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)
setLinkUrl('')
}
return true
}, [anchorElem, editor])
}, [editor])
useEffect(() => {
const scrollerElem = anchorElem.parentElement
@@ -132,60 +143,18 @@ function FloatingLinkEditor({ editor, anchorElem }: { editor: LexicalEditor; anc
})
}, [editor, updateLinkEditor])
useEffect(() => {
if (isEditMode && inputRef.current) {
inputRef.current.focus()
}
}, [isEditMode])
return (
<div ref={editorRef} className="link-editor">
{isEditMode ? (
<input
ref={inputRef}
className="link-input"
value={linkUrl}
onChange={(event) => {
setLinkUrl(event.target.value)
}}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Enter) {
event.preventDefault()
if (lastSelection !== null) {
if (linkUrl !== '') {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(linkUrl))
}
setEditMode(false)
}
} else if (event.key === KeyboardKey.Escape) {
event.preventDefault()
setEditMode(false)
}
}}
/>
) : (
<>
<div className="link-input">
<a href={linkUrl} target="_blank" rel="noopener noreferrer">
{linkUrl}
</a>
<div
className="link-edit"
role="button"
tabIndex={0}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setEditMode(true)
}}
>
<IconComponent size={15}>
<LexicalPencilFill />
</IconComponent>
</div>
</div>
<LinkPreview url={linkUrl} />
</>
)}
<div
ref={editorRef}
className="absolute top-0 left-0 max-w-[100vw] rounded-lg border border-border bg-default py-1 px-2 shadow shadow-contrast md:hidden"
>
<LinkEditor
linkUrl={linkUrl}
isEditMode={isEditMode}
setEditMode={setEditMode}
editor={editor}
lastSelection={lastSelection}
/>
</div>
)
}
@@ -210,14 +179,21 @@ function useFloatingLinkEditorToolbar(editor: LexicalEditor, anchorElem: HTMLEle
}, [])
useEffect(() => {
return editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => {
updateToolbar()
setActiveEditor(newEditor)
return false
},
COMMAND_PRIORITY_CRITICAL,
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])

View File

@@ -1,128 +0,0 @@
.floating-text-format-popup {
display: flex;
vertical-align: middle;
position: absolute;
top: 0;
left: 0;
z-index: 10;
opacity: 0;
background-color: var(--sn-stylekit-contrast-background-color);
box-shadow: 0px 5px 10px var(--sn-stylekit-shadow-color);
border-radius: 8px;
transition: opacity 0.5s;
will-change: transform;
}
.floating-text-format-popup button.popup-item {
border: 0;
display: flex;
background: none;
border-radius: 10px;
padding: 12px;
cursor: pointer;
vertical-align: middle;
}
.floating-text-format-popup button.popup-item:disabled {
cursor: not-allowed;
}
.floating-text-format-popup button.popup-item.spaced {
margin-right: 2px;
}
.floating-text-format-popup button.popup-item i.format {
background-size: contain;
display: inline-block;
height: 18px;
width: 18px;
margin-top: 2px;
vertical-align: -0.25em;
display: flex;
opacity: 0.6;
}
.floating-text-format-popup button.popup-item:disabled i.format {
opacity: 0.2;
}
.floating-text-format-popup button.popup-item.active {
background-color: rgba(223, 232, 250, 0.3);
}
.floating-text-format-popup button.popup-item.active i {
opacity: 1;
}
.floating-text-format-popup .popup-item:hover:not([disabled]) {
background-color: var(--sn-stylekit-info-color);
color: var(--sn-stylekit-info-contrast-color);
}
.floating-text-format-popup select.popup-item {
border: 0;
display: flex;
background: none;
border-radius: 10px;
padding: 8px;
vertical-align: middle;
-webkit-appearance: none;
-moz-appearance: none;
width: 70px;
font-size: 14px;
color: #777;
text-overflow: ellipsis;
}
.floating-text-format-popup select.code-language {
text-transform: capitalize;
width: 130px;
}
.floating-text-format-popup .popup-item .text {
display: flex;
line-height: 20px;
width: 200px;
vertical-align: middle;
font-size: 14px;
color: #777;
text-overflow: ellipsis;
width: 70px;
overflow: hidden;
height: 20px;
text-align: left;
}
.floating-text-format-popup .popup-item .icon {
display: flex;
width: 20px;
height: 20px;
user-select: none;
margin-right: 8px;
line-height: 16px;
background-size: contain;
}
.floating-text-format-popup i.chevron-down {
margin-top: 3px;
width: 16px;
height: 16px;
display: flex;
user-select: none;
}
.floating-text-format-popup i.chevron-down.inside {
width: 16px;
height: 16px;
display: flex;
margin-left: -25px;
margin-top: 11px;
margin-right: 10px;
pointer-events: none;
}
.floating-text-format-popup .divider {
width: 1px;
background-color: #eee;
margin: 0 4px;
}

View File

@@ -6,8 +6,6 @@
*
*/
import './index.css'
import { $isCodeHighlightNode } from '@lexical/code'
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
@@ -16,12 +14,15 @@ import {
$getSelection,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_LOW,
FORMAT_TEXT_COMMAND,
LexicalEditor,
SELECTION_CHANGE_COMMAND,
$isRootOrShadowRoot,
COMMAND_PRIORITY_CRITICAL,
COMMAND_PRIORITY_LOW,
RangeSelection,
GridSelection,
NodeSelection,
} from 'lexical'
import { $isHeadingNode } from '@lexical/rich-text'
import {
@@ -31,12 +32,10 @@ import {
ListNode,
INSERT_ORDERED_LIST_COMMAND,
} from '@lexical/list'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ComponentPropsWithoutRef, useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { getDOMRangeRect } from '../../Lexical/Utils/getDOMRangeRect'
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
import { setFloatingElemPosition } from '../../Lexical/Utils/setFloatingElemPosition'
import {
BoldIcon,
ItalicIcon,
@@ -51,6 +50,11 @@ import {
} from '@standardnotes/icons'
import { IconComponent } from '../../Lexical/Theme/IconComponent'
import { sanitizeUrl } from '../../Lexical/Utils/sanitizeUrl'
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'
const blockTypeToBlockName = {
bullet: 'Bulleted List',
@@ -69,9 +73,24 @@ const blockTypeToBlockName = {
const IconSize = 15
const ToolbarButton = ({ active, ...props }: { active?: boolean } & ComponentPropsWithoutRef<'button'>) => {
return (
<button
className={classNames(
'flex rounded-lg p-3 hover:bg-contrast hover:text-text disabled:cursor-not-allowed',
active && 'bg-info text-info-contrast',
)}
{...props}
>
{props.children}
</button>
)
}
function TextFormatFloatingToolbar({
editor,
anchorElem,
isText,
isLink,
isBold,
isItalic,
@@ -85,6 +104,7 @@ function TextFormatFloatingToolbar({
}: {
editor: LexicalEditor
anchorElem: HTMLElement
isText: boolean
isBold: boolean
isCode: boolean
isItalic: boolean
@@ -95,8 +115,12 @@ function TextFormatFloatingToolbar({
isUnderline: boolean
isBulletedList: boolean
isNumberedList: boolean
}): JSX.Element {
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null)
}) {
const toolbarRef = useRef<HTMLDivElement | null>(null)
const [linkUrl, setLinkUrl] = useState('')
const [isLinkEditMode, setIsLinkEditMode] = useState(false)
const [lastSelection, setLastSelection] = useState<RangeSelection | GridSelection | NodeSelection | null>(null)
const insertLink = useCallback(() => {
if (!isLink) {
@@ -130,36 +154,72 @@ function TextFormatFloatingToolbar({
}
}, [editor, isNumberedList])
const updateTextFormatFloatingToolbar = useCallback(() => {
const updateToolbar = 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 popupCharStylesEditorElem = popupCharStylesEditorRef.current
const nativeSelection = window.getSelection()
const toolbarElement = toolbarRef.current
if (popupCharStylesEditorElem === null) {
if (!toolbarElement) {
return
}
const nativeSelection = window.getSelection()
const activeElement = document.activeElement
const rootElement = editor.getRootElement()
if (
selection !== null &&
nativeSelection !== null &&
!nativeSelection.isCollapsed &&
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode)
) {
const rangeRect = getDOMRangeRect(nativeSelection, rootElement)
setLastSelection(selection)
setFloatingElemPosition(rangeRect, popupCharStylesEditorElem, anchorElem)
const rangeRect = getDOMRangeRect(nativeSelection, rootElement)
const toolbarRect = toolbarElement.getBoundingClientRect()
const rootElementRect = rootElement.getBoundingClientRect()
const calculatedStyles = getPositionedPopoverStyles({
align: 'start',
side: 'top',
anchorRect: rangeRect,
popoverRect: toolbarRect,
documentRect: rootElementRect,
offset: 8,
})
if (calculatedStyles) {
Object.assign(toolbarElement.style, calculatedStyles)
const adjustedStyles = getAdjustedStylesForNonPortalPopover(toolbarElement, calculatedStyles, rootElement)
toolbarElement.style.setProperty('--translate-x', adjustedStyles['--translate-x'])
toolbarElement.style.setProperty('--translate-y', adjustedStyles['--translate-y'])
}
} else if (!activeElement || activeElement.id !== 'link-input') {
setLastSelection(null)
setIsLinkEditMode(false)
setLinkUrl('')
}
}, [editor, anchorElem])
return true
}, [editor])
useEffect(() => {
const scrollerElem = anchorElem.parentElement
const update = () => {
editor.getEditorState().read(() => {
updateTextFormatFloatingToolbar()
updateToolbar()
})
}
@@ -174,141 +234,150 @@ function TextFormatFloatingToolbar({
scrollerElem.removeEventListener('scroll', update)
}
}
}, [editor, updateTextFormatFloatingToolbar, anchorElem])
}, [editor, anchorElem, updateToolbar])
useEffect(() => {
editor.getEditorState().read(() => {
updateTextFormatFloatingToolbar()
updateToolbar()
})
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateTextFormatFloatingToolbar()
updateToolbar()
})
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateTextFormatFloatingToolbar()
updateToolbar()
return false
},
COMMAND_PRIORITY_LOW,
),
)
}, [editor, updateTextFormatFloatingToolbar])
}, [editor, updateToolbar])
useEffect(() => {
editor.getEditorState().read(() => updateToolbar())
}, [editor, isLink, isText, updateToolbar])
if (!editor.isEditable()) {
return null
}
return (
<div ref={popupCharStylesEditorRef} className="floating-text-format-popup">
{editor.isEditable() && (
<>
<button
<div
ref={toolbarRef}
className="absolute top-0 left-0 rounded-lg border border-border bg-default py-1 px-2 shadow shadow-contrast"
>
{isLink && (
<LinkEditor
linkUrl={linkUrl}
isEditMode={isLinkEditMode}
setEditMode={setIsLinkEditMode}
editor={editor}
lastSelection={lastSelection}
/>
)}
{isText && isLink && <div role="presentation" className="mt-0.5 mb-1.5 h-px bg-border" />}
{isText && (
<div className="flex gap-1">
<ToolbarButton
active={isBold}
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
}}
className={'popup-item spaced ' + (isBold ? 'active' : '')}
aria-label="Format text as bold"
>
<IconComponent size={IconSize}>
<BoldIcon />
</IconComponent>
</button>
<button
</ToolbarButton>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
}}
className={'popup-item spaced ' + (isItalic ? 'active' : '')}
active={isItalic}
aria-label="Format text as italics"
>
<IconComponent size={IconSize}>
<ItalicIcon />
</IconComponent>
</button>
<button
</ToolbarButton>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')
}}
className={'popup-item spaced ' + (isUnderline ? 'active' : '')}
active={isUnderline}
aria-label="Format text to underlined"
>
<IconComponent size={IconSize + 1}>
<UnderlineIcon />
</IconComponent>
</button>
<button
</ToolbarButton>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
}}
className={'popup-item spaced ' + (isStrikethrough ? 'active' : '')}
active={isStrikethrough}
aria-label="Format text with a strikethrough"
>
<IconComponent size={IconSize}>
<StrikethroughIcon />
</IconComponent>
</button>
<button
</ToolbarButton>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript')
}}
className={'popup-item spaced ' + (isSubscript ? 'active' : '')}
active={isSubscript}
title="Subscript"
aria-label="Format Subscript"
>
<IconComponent paddingTop={4} size={IconSize - 2}>
<SubscriptIcon />
</IconComponent>
</button>
<button
</ToolbarButton>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript')
}}
className={'popup-item spaced ' + (isSuperscript ? 'active' : '')}
active={isSuperscript}
title="Superscript"
aria-label="Format Superscript"
>
<IconComponent paddingTop={1} size={IconSize - 2}>
<SuperscriptIcon />
</IconComponent>
</button>
<button
</ToolbarButton>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')
}}
className={'popup-item spaced ' + (isCode ? 'active' : '')}
active={isCode}
aria-label="Insert code block"
>
<IconComponent size={IconSize}>
<CodeIcon />
</IconComponent>
</button>
<button
onClick={insertLink}
className={'popup-item spaced ' + (isLink ? 'active' : '')}
aria-label="Insert link"
>
</ToolbarButton>
<ToolbarButton onClick={insertLink} active={isLink} aria-label="Insert link">
<IconComponent size={IconSize}>
<LinkIcon />
</IconComponent>
</button>
<button
onClick={formatBulletList}
className={'popup-item spaced ' + (isBulletedList ? 'active' : '')}
aria-label="Insert bulleted list"
>
</ToolbarButton>
<ToolbarButton onClick={formatBulletList} active={isBulletedList} aria-label="Insert bulleted list">
<IconComponent size={IconSize}>
<ListBulleted />
</IconComponent>
</button>
<button
onClick={formatNumberedList}
className={'popup-item spaced ' + (isNumberedList ? 'active' : '')}
aria-label="Insert numbered list"
>
</ToolbarButton>
<ToolbarButton onClick={formatNumberedList} active={isNumberedList} aria-label="Insert numbered list">
<IconComponent size={IconSize}>
<ListNumbered />
</IconComponent>
</button>
</>
</ToolbarButton>
</div>
)}
</div>
)
@@ -436,7 +505,7 @@ function useFloatingTextFormatToolbar(editor: LexicalEditor, anchorElem: HTMLEle
)
}, [editor, updatePopup])
if (!isText || isLink) {
if (!isText && !isLink) {
return null
}
@@ -444,6 +513,7 @@ function useFloatingTextFormatToolbar(editor: LexicalEditor, anchorElem: HTMLEle
<TextFormatFloatingToolbar
editor={editor}
anchorElem={anchorElem}
isText={isText}
isLink={isLink}
isBold={isBold}
isItalic={isItalic}