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:
@@ -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']
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user