Skip to content

Commit

Permalink
feat: Improved link editing and creation in Super notes (#2382) [skip…
Browse files Browse the repository at this point in the history
… e2e]
  • Loading branch information
amanharwara committed Aug 5, 2023
1 parent 07c23d2 commit 4b24b58
Show file tree
Hide file tree
Showing 6 changed files with 224 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import {
RangeSelection,
GridSelection,
NodeSelection,
KEY_MODIFIER_COMMAND,
COMMAND_PRIORITY_NORMAL,
createCommand,
} from 'lexical'
import { $isHeadingNode } from '@lexical/rich-text'
import {
Expand All @@ -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',
Expand All @@ -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
Expand All @@ -93,6 +99,7 @@ function TextFormatFloatingToolbar({
anchorElem,
isText,
isLink,
isLinkText,
isAutoLink,
isBold,
isItalic,
Expand All @@ -111,6 +118,7 @@ function TextFormatFloatingToolbar({
isCode: boolean
isItalic: boolean
isLink: boolean
isLinkText: boolean
isAutoLink: boolean
isStrikethrough: boolean
isSubscript: boolean
Expand All @@ -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])

Expand Down Expand Up @@ -169,6 +190,11 @@ function TextFormatFloatingToolbar({
} else {
setLinkUrl('')
}
if ($isLinkTextNode(node, selection)) {
setLinkText(node.getTextContent())
} else {
setLinkText('')
}
}

const toolbarElement = toolbarRef.current
Expand Down Expand Up @@ -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])
Expand All @@ -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}
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -530,6 +609,7 @@ function useFloatingTextFormatToolbar(editor: LexicalEditor, anchorElem: HTMLEle
anchorElem={anchorElem}
isText={isText}
isLink={isLink}
isLinkText={isLinkText}
isAutoLink={isAutoLink}
isBold={isBold}
isItalic={isItalic}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit 4b24b58

Please sign in to comment.