@@ -56,9 +56,16 @@ import {
5656 UNDO_COMMAND ,
5757 type EditorState ,
5858 type LexicalEditor ,
59+ type NodeSelection ,
60+ type RangeSelection ,
61+ $createNodeSelection ,
62+ $createRangeSelection ,
5963 $createTextNode ,
64+ $getNodeByKey ,
65+ $setSelection ,
6066} from 'lexical' ;
6167import IconButton from './IconButton' ;
68+ import Button from './Button' ;
6269import ContextMenuComponent , { type MenuItem as ContextMenuItem } from './ContextMenu' ;
6370import { RedoIcon , UndoIcon } from './Icons' ;
6471import {
@@ -83,6 +90,7 @@ import {
8390 UnderlineIcon ,
8491} from './rich-text/RichTextToolbarIcons' ;
8592import { $createImageNode , ImageNode , INSERT_IMAGE_COMMAND , type ImagePayload } from './rich-text/ImageNode' ;
93+ import Modal from './Modal' ;
8694
8795export interface RichTextEditorHandle {
8896 focus : ( ) => void ;
@@ -117,6 +125,19 @@ interface ContextMenuState {
117125 visible : boolean ;
118126}
119127
128+ type SelectionSnapshot =
129+ | {
130+ type : 'range' ;
131+ anchorKey : string ;
132+ anchorOffset : number ;
133+ anchorType : 'text' | 'element' ;
134+ focusKey : string ;
135+ focusOffset : number ;
136+ focusType : 'text' | 'element' ;
137+ }
138+ | { type : 'node' ; keys : string [ ] }
139+ | null ;
140+
120141const RICH_TEXT_THEME = {
121142 paragraph : 'mb-3 text-base leading-7 text-text-main' ,
122143 heading : {
@@ -146,12 +167,90 @@ const RICH_TEXT_THEME = {
146167
147168const Placeholder : React . FC = ( ) => null ;
148169
170+ const normalizeUrl = ( url : string ) : string => {
171+ const trimmed = url . trim ( ) ;
172+ if ( ! trimmed ) {
173+ return '' ;
174+ }
175+
176+ if ( / ^ [ a - z A - Z ] [ \w + . - ] * : / . test ( trimmed ) ) {
177+ return trimmed ;
178+ }
179+
180+ return `https://${ trimmed } ` ;
181+ } ;
182+
183+ const LinkModal : React . FC < {
184+ isOpen : boolean ;
185+ initialUrl : string ;
186+ onSubmit : ( url : string ) => void ;
187+ onRemove : ( ) => void ;
188+ onClose : ( ) => void ;
189+ } > = ( { isOpen, initialUrl, onSubmit, onRemove, onClose } ) => {
190+ const inputRef = useRef < HTMLInputElement > ( null ) ;
191+ const [ url , setUrl ] = useState ( initialUrl ) ;
192+
193+ useEffect ( ( ) => {
194+ setUrl ( initialUrl ) ;
195+ } , [ initialUrl ] ) ;
196+
197+ const handleSubmit = ( event : React . FormEvent ) => {
198+ event . preventDefault ( ) ;
199+ onSubmit ( url ) ;
200+ } ;
201+
202+ if ( ! isOpen ) {
203+ return null ;
204+ }
205+
206+ return (
207+ < Modal onClose = { onClose } title = "Insert link" initialFocusRef = { inputRef } >
208+ < form onSubmit = { handleSubmit } >
209+ < div className = "p-6 space-y-3" >
210+ < label className = "block text-sm font-semibold text-text-main" htmlFor = "link-url-input" >
211+ Link URL
212+ </ label >
213+ < input
214+ id = "link-url-input"
215+ ref = { inputRef }
216+ type = "text"
217+ inputMode = "url"
218+ autoComplete = "url"
219+ required
220+ value = { url }
221+ onChange = { event => setUrl ( event . target . value ) }
222+ className = "w-full rounded-md border border-border-color bg-background px-3 py-2 text-sm text-text-main focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30"
223+ placeholder = "https://example.com"
224+ />
225+ < p className = "text-xs text-text-secondary" >
226+ Enter a valid URL. If you omit the protocol, https:// will be added automatically.
227+ </ p >
228+ </ div >
229+ < div className = "flex justify-end gap-3 px-6 py-4 bg-background/50 border-t border-border-color rounded-b-lg" >
230+ < Button type = "button" variant = "secondary" onClick = { onClose } >
231+ Cancel
232+ </ Button >
233+ < Button type = "button" variant = "secondary" onClick = { onRemove } >
234+ Remove link
235+ </ Button >
236+ < Button type = "submit" > Save link</ Button >
237+ </ div >
238+ </ form >
239+ </ Modal >
240+ ) ;
241+ } ;
242+
149243const ToolbarButton : React . FC < ToolbarButtonConfig > = ( { label, icon : Icon , isActive = false , disabled = false , onClick } ) => (
150244 < IconButton
151245 type = "button"
152246 tooltip = { label }
153247 size = "xs"
154248 variant = "ghost"
249+ onMouseDown = { event => {
250+ // Prevent the toolbar button from stealing focus, which would clear the
251+ // user's selection in the editor before the command executes.
252+ event . preventDefault ( ) ;
253+ } }
155254 onClick = { onClick }
156255 disabled = { disabled }
157256 aria-pressed = { isActive }
@@ -181,6 +280,47 @@ const ToolbarPlugin: React.FC<{
181280 const [ alignment , setAlignment ] = useState < 'left' | 'center' | 'right' | 'justify' > ( 'left' ) ;
182281 const [ canUndo , setCanUndo ] = useState ( false ) ;
183282 const [ canRedo , setCanRedo ] = useState ( false ) ;
283+ const [ isLinkModalOpen , setIsLinkModalOpen ] = useState ( false ) ;
284+ const [ linkDraftUrl , setLinkDraftUrl ] = useState ( '' ) ;
285+ const pendingLinkSelectionRef = useRef < SelectionSnapshot > ( null ) ;
286+ const closeLinkModal = useCallback ( ( ) => {
287+ setIsLinkModalOpen ( false ) ;
288+ } , [ ] ) ;
289+ const dismissLinkModal = useCallback ( ( ) => {
290+ pendingLinkSelectionRef . current = null ;
291+ closeLinkModal ( ) ;
292+ } , [ closeLinkModal ] ) ;
293+
294+ const restoreSelectionFromSnapshot = useCallback (
295+ ( snapshot : SelectionSnapshot = pendingLinkSelectionRef . current ) => {
296+ if ( ! snapshot ) {
297+ return null ;
298+ }
299+
300+ if ( snapshot . type === 'range' ) {
301+ const selection = $createRangeSelection ( ) ;
302+ const anchorNode = $getNodeByKey ( snapshot . anchorKey ) ;
303+ const focusNode = $getNodeByKey ( snapshot . focusKey ) ;
304+
305+ if ( ! anchorNode || ! focusNode ) {
306+ return null ;
307+ }
308+
309+ selection . anchor . set ( snapshot . anchorKey , snapshot . anchorOffset , snapshot . anchorType ) ;
310+ selection . focus . set ( snapshot . focusKey , snapshot . focusOffset , snapshot . focusType ) ;
311+ return selection ;
312+ }
313+
314+ const selection = $createNodeSelection ( ) ;
315+ snapshot . keys . forEach ( key => {
316+ const node = $getNodeByKey ( key ) ;
317+ if ( node ) {
318+ selection . add ( node . getKey ( ) ) ;
319+ }
320+ } ) ;
321+
322+ return selection . getNodes ( ) . length > 0 ? selection : null ;
323+ } , [ ] ) ;
184324
185325 const updateToolbar = useCallback ( ( ) => {
186326 const selection = $getSelection ( ) ;
@@ -286,26 +426,130 @@ const ToolbarPlugin: React.FC<{
286426 } ) ;
287427 } , [ editor ] ) ;
288428
289- const toggleLink = useCallback ( ( ) => {
290- if ( readOnly ) {
429+ const captureLinkState = useCallback ( ( ) => {
430+ let detectedUrl = '' ;
431+
432+ editor . getEditorState ( ) . read ( ( ) => {
433+ const selection = $getSelection ( ) ;
434+ if ( $isRangeSelection ( selection ) ) {
435+ pendingLinkSelectionRef . current = {
436+ type : 'range' ,
437+ anchorKey : selection . anchor . key ,
438+ anchorOffset : selection . anchor . offset ,
439+ anchorType : selection . anchor . type ,
440+ focusKey : selection . focus . key ,
441+ focusOffset : selection . focus . offset ,
442+ focusType : selection . focus . type ,
443+ } ;
444+
445+ const selectionNodes = selection . getNodes ( ) ;
446+ if ( selectionNodes . length === 0 ) {
447+ return ;
448+ }
449+
450+ const firstNode = selectionNodes [ 0 ] ;
451+ const linkNode = $isLinkNode ( firstNode )
452+ ? firstNode
453+ : $isLinkNode ( firstNode . getParent ( ) )
454+ ? firstNode . getParent ( )
455+ : null ;
456+
457+ if ( $isLinkNode ( linkNode ) ) {
458+ detectedUrl = linkNode . getURL ( ) ;
459+ }
291460 return ;
292461 }
293- if ( isLink ) {
294- editor . dispatchCommand ( TOGGLE_LINK_COMMAND , null ) ;
295- return ;
462+
463+ if ( $isNodeSelection ( selection ) ) {
464+ const nodes = selection . getNodes ( ) ;
465+ pendingLinkSelectionRef . current = { type : 'node' , keys : nodes . map ( node => node . getKey ( ) ) } ;
466+ } else {
467+ pendingLinkSelectionRef . current = null ;
296468 }
469+ } ) ;
470+
471+ if ( ! pendingLinkSelectionRef . current ) {
472+ return false ;
473+ }
297474
298- const promptFn = typeof window . prompt === 'function' ? window . prompt . bind ( window ) : null ;
299- if ( ! promptFn ) {
300- console . warn ( 'Link insertion prompt is unavailable in this environment.' ) ;
475+ setLinkDraftUrl ( detectedUrl ) ;
476+ setIsLinkModalOpen ( true ) ;
477+ return true ;
478+ } , [ editor ] ) ;
479+
480+ const applyLink = useCallback (
481+ ( url : string ) => {
482+ closeLinkModal ( ) ;
483+
484+ const selectionSnapshot = pendingLinkSelectionRef . current ;
485+ pendingLinkSelectionRef . current = null ;
486+
487+ const normalizedUrl = normalizeUrl ( url ) ;
488+ if ( ! normalizedUrl ) {
489+ editor . focus ( ) ;
301490 return ;
302491 }
303492
304- const url = promptFn ( 'Enter URL' ) ;
305- if ( url ) {
306- editor . dispatchCommand ( TOGGLE_LINK_COMMAND , url ) ;
493+ editor . update ( ( ) => {
494+ const selectionFromSnapshot = restoreSelectionFromSnapshot ( selectionSnapshot ) ;
495+ const selectionToUse = selectionFromSnapshot ?? ( ( ) => {
496+ const activeSelection = $getSelection ( ) ;
497+ if ( $isRangeSelection ( activeSelection ) || $isNodeSelection ( activeSelection ) ) {
498+ return activeSelection ;
499+ }
500+ const root = $getRoot ( ) ;
501+ return root . selectEnd ( ) ;
502+ } ) ( ) ;
503+
504+ if ( ! selectionToUse ) {
505+ return ;
506+ }
507+
508+ $setSelection ( selectionToUse ) ;
509+ editor . dispatchCommand ( TOGGLE_LINK_COMMAND , normalizedUrl ) ;
510+ } ) ;
511+ editor . focus ( ) ;
512+ } ,
513+ [ closeLinkModal , editor , restoreSelectionFromSnapshot ] ,
514+ ) ;
515+
516+ const removeLink = useCallback ( ( ) => {
517+ closeLinkModal ( ) ;
518+
519+ const selectionSnapshot = pendingLinkSelectionRef . current ;
520+ pendingLinkSelectionRef . current = null ;
521+
522+ editor . update ( ( ) => {
523+ const selectionFromSnapshot = restoreSelectionFromSnapshot ( selectionSnapshot ) ;
524+ const selectionToUse = selectionFromSnapshot ?? ( ( ) => {
525+ const activeSelection = $getSelection ( ) ;
526+ if ( $isRangeSelection ( activeSelection ) || $isNodeSelection ( activeSelection ) ) {
527+ return activeSelection ;
528+ }
529+ const root = $getRoot ( ) ;
530+ return root . selectEnd ( ) ;
531+ } ) ( ) ;
532+
533+ if ( ! selectionToUse ) {
534+ return ;
307535 }
308- } , [ editor , isLink , readOnly ] ) ;
536+
537+ $setSelection ( selectionToUse ) ;
538+ editor . dispatchCommand ( TOGGLE_LINK_COMMAND , null ) ;
539+ } ) ;
540+ editor . focus ( ) ;
541+ } , [ closeLinkModal , editor , restoreSelectionFromSnapshot ] ) ;
542+
543+ const toggleLink = useCallback ( ( ) => {
544+ if ( readOnly ) {
545+ return ;
546+ }
547+
548+ const hasSelection = captureLinkState ( ) ;
549+ if ( ! hasSelection ) {
550+ editor . focus ( ) ;
551+ }
552+ } , [ captureLinkState , editor , readOnly ] ) ;
309553
310554 const insertImage = useCallback (
311555 ( payload : ImagePayload ) => {
@@ -473,7 +717,7 @@ const ToolbarPlugin: React.FC<{
473717 } ,
474718 {
475719 id : 'link' ,
476- label : isLink ? 'Remove Link' : 'Insert Link' ,
720+ label : isLink ? 'Edit or Remove Link' : 'Insert Link' ,
477721 icon : ToolbarLinkIcon ,
478722 group : 'insert' ,
479723 isActive : isLink ,
@@ -596,25 +840,34 @@ const ToolbarPlugin: React.FC<{
596840 ) ;
597841
598842 return (
599- < div
600- className = "flex flex-wrap content-center items-center gap-x-0.5 gap-y-0.5 border-b border-border-color bg-secondary/50 backdrop-blur-sm px-2 py-0.5 overflow-hidden sticky top-0 z-10"
601- style = { { minHeight : '28px' } }
602- >
603- { renderedToolbarElements . map ( element =>
604- 'type' in element ? (
605- < div key = { element . id } className = "mx-1 h-3 w-px bg-border-color" />
606- ) : (
607- < ToolbarButton key = { element . id } { ...element } />
608- ) ,
609- ) }
610- < input
611- ref = { fileInputRef }
612- type = "file"
613- accept = "image/*"
614- className = "hidden"
615- onChange = { handleImageFileChange }
843+ < >
844+ < div
845+ className = "flex flex-wrap content-center items-center gap-x-0.5 gap-y-0.5 border-b border-border-color bg-secondary/50 backdrop-blur-sm px-2 py-0.5 overflow-hidden sticky top-0 z-10"
846+ style = { { minHeight : '28px' } }
847+ >
848+ { renderedToolbarElements . map ( element =>
849+ 'type' in element ? (
850+ < div key = { element . id } className = "mx-1 h-3 w-px bg-border-color" />
851+ ) : (
852+ < ToolbarButton key = { element . id } { ...element } />
853+ ) ,
854+ ) }
855+ < input
856+ ref = { fileInputRef }
857+ type = "file"
858+ accept = "image/*"
859+ className = "hidden"
860+ onChange = { handleImageFileChange }
861+ />
862+ </ div >
863+ < LinkModal
864+ isOpen = { isLinkModalOpen }
865+ initialUrl = { linkDraftUrl }
866+ onSubmit = { applyLink }
867+ onRemove = { removeLink }
868+ onClose = { dismissLinkModal }
616869 />
617- </ div >
870+ </ >
618871 ) ;
619872} ;
620873
0 commit comments