feat: Improved link editing and creation in Super notes (#2382) [skip e2e]
This commit is contained in:
@@ -6,14 +6,9 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { URL_REGEX, EMAIL_REGEX } from '@/Constants/Constants'
|
||||
import { AutoLinkPlugin, createLinkMatcherWithRegExp } from '@lexical/react/LexicalAutoLinkPlugin'
|
||||
|
||||
const URL_REGEX =
|
||||
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
|
||||
|
||||
const EMAIL_REGEX =
|
||||
/(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/
|
||||
|
||||
const MATCHERS = [
|
||||
createLinkMatcherWithRegExp(URL_REGEX, (text) => {
|
||||
return text.startsWith('http') ? text : `https://${text}`
|
||||
|
||||
@@ -4,7 +4,7 @@ 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, useRef } from 'react'
|
||||
import { useCallback, useState, useRef, useEffect } from 'react'
|
||||
import { GridSelection, LexicalEditor, NodeSelection, RangeSelection } from 'lexical'
|
||||
import { classNames } from '@standardnotes/snjs'
|
||||
|
||||
@@ -36,6 +36,10 @@ const LinkEditor = ({ linkUrl, isEditMode, setEditMode, editor, lastSelection, i
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setEditedLinkUrl(linkUrl)
|
||||
}, [linkUrl])
|
||||
|
||||
return isEditMode ? (
|
||||
<div className="flex items-center gap-2" ref={editModeContainer}>
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { CloseIcon, CheckIcon, PencilFilledIcon } from '@standardnotes/icons'
|
||||
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||
import { IconComponent } from '../../Lexical/Theme/IconComponent'
|
||||
import { $isRangeSelection, $isTextNode, GridSelection, LexicalEditor, NodeSelection, RangeSelection } from 'lexical'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { VisuallyHidden } from '@ariakit/react'
|
||||
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
|
||||
import { $isLinkNode } from '@lexical/link'
|
||||
|
||||
type Props = {
|
||||
linkText: string
|
||||
editor: LexicalEditor
|
||||
lastSelection: RangeSelection | GridSelection | NodeSelection | null
|
||||
}
|
||||
|
||||
export const $isLinkTextNode = (node: ReturnType<typeof getSelectedNode>, selection: RangeSelection) => {
|
||||
const parent = node.getParent()
|
||||
return $isLinkNode(parent) && $isTextNode(node) && selection.anchor.getNode() === selection.focus.getNode()
|
||||
}
|
||||
|
||||
const LinkTextEditor = ({ linkText, editor, lastSelection }: Props) => {
|
||||
const [editedLinkText, setEditedLinkText] = useState(() => linkText)
|
||||
const [isEditMode, setEditMode] = useState(false)
|
||||
const editModeContainer = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setEditedLinkText(linkText)
|
||||
}, [linkText])
|
||||
|
||||
const focusInput = useCallback((input: HTMLInputElement | null) => {
|
||||
if (input) {
|
||||
input.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLinkTextSubmission = () => {
|
||||
editor.update(() => {
|
||||
if ($isRangeSelection(lastSelection)) {
|
||||
const node = getSelectedNode(lastSelection)
|
||||
if (!$isLinkTextNode(node, lastSelection)) {
|
||||
return
|
||||
}
|
||||
node.setTextContent(editedLinkText)
|
||||
}
|
||||
})
|
||||
setEditMode(false)
|
||||
}
|
||||
|
||||
return isEditMode ? (
|
||||
<div className="flex items-center gap-2" ref={editModeContainer}>
|
||||
<input
|
||||
id="link-input"
|
||||
ref={focusInput}
|
||||
value={editedLinkText}
|
||||
onChange={(event) => {
|
||||
setEditedLinkText(event.target.value)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.Enter) {
|
||||
event.preventDefault()
|
||||
handleLinkTextSubmission()
|
||||
} else if (event.key === KeyboardKey.Escape) {
|
||||
event.preventDefault()
|
||||
setEditMode(false)
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (!editModeContainer.current?.contains(event.relatedTarget as Node)) {
|
||||
setEditMode(false)
|
||||
}
|
||||
}}
|
||||
className="flex-grow rounded-sm bg-contrast p-1 text-text sm:min-w-[20ch]"
|
||||
/>
|
||||
<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={handleLinkTextSubmission}
|
||||
aria-label="Save link"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<IconComponent size={15}>
|
||||
<CheckIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon type="plain-text" className="ml-1 mr-1 flex-shrink-0" />
|
||||
<div className="flex-grow max-w-[35ch] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<VisuallyHidden>Link text:</VisuallyHidden>
|
||||
{linkText}
|
||||
</div>
|
||||
<button
|
||||
className="flex rounded-lg p-3 hover:bg-contrast hover:text-text disabled:cursor-not-allowed ml-auto"
|
||||
onClick={() => {
|
||||
setEditedLinkText(linkText)
|
||||
setEditMode(true)
|
||||
}}
|
||||
aria-label="Edit link"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<IconComponent size={15}>
|
||||
<PencilFilledIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LinkTextEditor
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
RangeSelection,
|
||||
GridSelection,
|
||||
NodeSelection,
|
||||
KEY_MODIFIER_COMMAND,
|
||||
COMMAND_PRIORITY_NORMAL,
|
||||
createCommand,
|
||||
} from 'lexical'
|
||||
import { $isHeadingNode } from '@lexical/rich-text'
|
||||
import {
|
||||
@@ -49,13 +52,14 @@ import {
|
||||
ListNumbered,
|
||||
} 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'
|
||||
import { movePopoverToFitInsideRect } from '@/Components/Popover/Utils/movePopoverToFitInsideRect'
|
||||
import LinkTextEditor, { $isLinkTextNode } from '../FloatingLinkEditorPlugin/LinkTextEditor'
|
||||
import { URL_REGEX } from '@/Constants/Constants'
|
||||
|
||||
const blockTypeToBlockName = {
|
||||
bullet: 'Bulleted List',
|
||||
@@ -74,6 +78,8 @@ const blockTypeToBlockName = {
|
||||
|
||||
const IconSize = 15
|
||||
|
||||
const TOGGLE_LINK_AND_EDIT_COMMAND = createCommand<string | null>('TOGGLE_LINK_AND_EDIT_COMMAND')
|
||||
|
||||
const ToolbarButton = ({ active, ...props }: { active?: boolean } & ComponentPropsWithoutRef<'button'>) => {
|
||||
return (
|
||||
<button
|
||||
@@ -93,6 +99,7 @@ function TextFormatFloatingToolbar({
|
||||
anchorElem,
|
||||
isText,
|
||||
isLink,
|
||||
isLinkText,
|
||||
isAutoLink,
|
||||
isBold,
|
||||
isItalic,
|
||||
@@ -111,6 +118,7 @@ function TextFormatFloatingToolbar({
|
||||
isCode: boolean
|
||||
isItalic: boolean
|
||||
isLink: boolean
|
||||
isLinkText: boolean
|
||||
isAutoLink: boolean
|
||||
isStrikethrough: boolean
|
||||
isSubscript: boolean
|
||||
@@ -122,22 +130,35 @@ function TextFormatFloatingToolbar({
|
||||
const toolbarRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [linkText, setLinkText] = useState('')
|
||||
const [isLinkEditMode, setIsLinkEditMode] = useState(false)
|
||||
const [lastSelection, setLastSelection] = useState<RangeSelection | GridSelection | NodeSelection | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
TOGGLE_LINK_AND_EDIT_COMMAND,
|
||||
(payload) => {
|
||||
if (payload === null) {
|
||||
return editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||||
} else if (typeof payload === 'string') {
|
||||
const dispatched = editor.dispatchCommand(TOGGLE_LINK_COMMAND, payload)
|
||||
setLinkUrl(payload)
|
||||
setIsLinkEditMode(true)
|
||||
return dispatched
|
||||
}
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
if (!isLink) {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
const textContent = selection?.getTextContent()
|
||||
if (!textContent) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://')
|
||||
return
|
||||
}
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(textContent))
|
||||
editor.dispatchCommand(TOGGLE_LINK_AND_EDIT_COMMAND, '')
|
||||
})
|
||||
} else {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||||
editor.dispatchCommand(TOGGLE_LINK_AND_EDIT_COMMAND, null)
|
||||
}
|
||||
}, [editor, isLink])
|
||||
|
||||
@@ -169,6 +190,11 @@ function TextFormatFloatingToolbar({
|
||||
} else {
|
||||
setLinkUrl('')
|
||||
}
|
||||
if ($isLinkTextNode(node, selection)) {
|
||||
setLinkText(node.getTextContent())
|
||||
} else {
|
||||
setLinkText('')
|
||||
}
|
||||
}
|
||||
|
||||
const toolbarElement = toolbarRef.current
|
||||
@@ -266,6 +292,42 @@ function TextFormatFloatingToolbar({
|
||||
)
|
||||
}, [editor, updateToolbar])
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
KEY_MODIFIER_COMMAND,
|
||||
(payload) => {
|
||||
const event: KeyboardEvent = payload
|
||||
const { code, ctrlKey, metaKey } = event
|
||||
|
||||
if (code === 'KeyK' && (ctrlKey || metaKey)) {
|
||||
event.preventDefault()
|
||||
if ('readText' in navigator.clipboard) {
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => {
|
||||
if (URL_REGEX.test(text)) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, text)
|
||||
} else {
|
||||
throw new Error('Not a valid URL')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
editor.dispatchCommand(TOGGLE_LINK_AND_EDIT_COMMAND, '')
|
||||
setIsLinkEditMode(true)
|
||||
})
|
||||
} else {
|
||||
editor.dispatchCommand(TOGGLE_LINK_AND_EDIT_COMMAND, '')
|
||||
setIsLinkEditMode(true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_NORMAL,
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
editor.getEditorState().read(() => updateToolbar())
|
||||
}, [editor, isLink, isText, updateToolbar])
|
||||
@@ -277,8 +339,17 @@ function TextFormatFloatingToolbar({
|
||||
return (
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className="absolute left-0 top-0 rounded-lg border border-border bg-contrast translucent-ui:bg-[--popover-background-color] translucent-ui:[backdrop-filter:var(--popover-backdrop-filter)] px-2 py-1 shadow-sm shadow-contrast"
|
||||
className="absolute left-0 top-0 rounded-lg border border-border bg-contrast translucent-ui:bg-[--popover-background-color] translucent-ui:[backdrop-filter:var(--popover-backdrop-filter)] translucent-ui:border-[--popover-border-color] px-2 py-1 shadow-sm shadow-contrast"
|
||||
>
|
||||
{isLinkText && !isAutoLink && (
|
||||
<>
|
||||
<LinkTextEditor linkText={linkText} editor={editor} lastSelection={lastSelection} />
|
||||
<div
|
||||
role="presentation"
|
||||
className="mb-1.5 mt-0.5 h-px bg-border translucent-ui:bg-[--popover-border-color]"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isLink && (
|
||||
<LinkEditor
|
||||
linkUrl={linkUrl}
|
||||
@@ -289,7 +360,9 @@ function TextFormatFloatingToolbar({
|
||||
lastSelection={lastSelection}
|
||||
/>
|
||||
)}
|
||||
{isText && isLink && <div role="presentation" className="mb-1.5 mt-0.5 h-px bg-border" />}
|
||||
{isText && isLink && (
|
||||
<div role="presentation" className="mb-1.5 mt-0.5 h-px bg-border translucent-ui:bg-[--popover-border-color]" />
|
||||
)}
|
||||
{isText && (
|
||||
<div className="flex gap-1">
|
||||
<ToolbarButton
|
||||
@@ -397,6 +470,7 @@ function useFloatingTextFormatToolbar(editor: LexicalEditor, anchorElem: HTMLEle
|
||||
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)
|
||||
@@ -486,6 +560,11 @@ function useFloatingTextFormatToolbar(editor: LexicalEditor, anchorElem: HTMLEle
|
||||
} else {
|
||||
setIsAutoLink(false)
|
||||
}
|
||||
if ($isLinkTextNode(node, selection)) {
|
||||
setIsLinkText(true)
|
||||
} else {
|
||||
setIsLinkText(false)
|
||||
}
|
||||
|
||||
if (!$isCodeHighlightNode(selection.anchor.getNode()) && selection.getTextContent() !== '') {
|
||||
setIsText($isTextNode(node))
|
||||
@@ -530,6 +609,7 @@ function useFloatingTextFormatToolbar(editor: LexicalEditor, anchorElem: HTMLEle
|
||||
anchorElem={anchorElem}
|
||||
isText={isText}
|
||||
isLink={isLink}
|
||||
isLinkText={isLinkText}
|
||||
isAutoLink={isAutoLink}
|
||||
isBold={isBold}
|
||||
isItalic={isItalic}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
|
||||
import useModal from '../../Lexical/Hooks/useModal'
|
||||
import { InsertTableDialog } from '../../Plugins/TablePlugin'
|
||||
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
|
||||
import { sanitizeUrl } from '../../Lexical/Utils/sanitizeUrl'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
@@ -69,13 +68,7 @@ const MobileToolbarPlugin = () => {
|
||||
const isLink = $isLinkNode(parent) || $isLinkNode(node)
|
||||
if (!isLink) {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
const textContent = selection?.getTextContent()
|
||||
if (!textContent) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://')
|
||||
return
|
||||
}
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(textContent))
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://')
|
||||
})
|
||||
} else {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||||
|
||||
@@ -3,6 +3,9 @@ import { IconType } from '@standardnotes/snjs'
|
||||
export const PANEL_NAME_NOTES = 'notes'
|
||||
export const PANEL_NAME_NAVIGATION = 'navigation'
|
||||
|
||||
export const URL_REGEX =
|
||||
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
|
||||
|
||||
export const EMAIL_REGEX =
|
||||
/^([a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user