Skip to content

Commit beb492c

Browse files
authored
Merge pull request #261 from beNative/tisi/fix-insert-link-command-in-editor-1ybhfo
Ignore npm-test.png artifact
2 parents 8e003fb + 462c163 commit beb492c

3 files changed

Lines changed: 289 additions & 34 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ assets/icon.ico
1616
assets/icon.icns
1717
assets/favicon.ico
1818
assets/plantuml/plantuml.jar
19+
artifacts/*.png
20+
npm-test.png
1921

2022
# Editor directories and files
2123
.vscode/*

components/RichTextEditor.tsx

Lines changed: 284 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -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';
6167
import IconButton from './IconButton';
68+
import Button from './Button';
6269
import ContextMenuComponent, { type MenuItem as ContextMenuItem } from './ContextMenu';
6370
import { RedoIcon, UndoIcon } from './Icons';
6471
import {
@@ -83,6 +90,7 @@ import {
8390
UnderlineIcon,
8491
} from './rich-text/RichTextToolbarIcons';
8592
import { $createImageNode, ImageNode, INSERT_IMAGE_COMMAND, type ImagePayload } from './rich-text/ImageNode';
93+
import Modal from './Modal';
8694

8795
export 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+
120141
const 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

147168
const 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-zA-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+
149243
const 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

Comments
 (0)