From 937a2681309a111a80822e46468e89e39ae1b460 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Tue, 25 Nov 2025 19:46:15 +0100 Subject: [PATCH 1/3] Restore selection when inserting links --- components/RichTextEditor.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/RichTextEditor.tsx b/components/RichTextEditor.tsx index 0c00670..8533a27 100644 --- a/components/RichTextEditor.tsx +++ b/components/RichTextEditor.tsx @@ -290,6 +290,12 @@ const ToolbarPlugin: React.FC<{ if (readOnly) { return; } + + // Ensure the editor regains focus so the user's selection is still available + // when the link command runs. Without this, focus remains on the toolbar button + // or prompt dialog and the command receives no selection to wrap. + editor.focus(); + if (isLink) { editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); return; From 45447a93c22d0dda2d0a3ee7ff19d758ce0a9055 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Tue, 25 Nov 2025 19:56:07 +0100 Subject: [PATCH 2/3] Fix link insertion in rich text editor --- components/RichTextEditor.tsx | 41 ++++++++++++++++++++--------------- services/repository.ts | 6 ++--- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/components/RichTextEditor.tsx b/components/RichTextEditor.tsx index 8533a27..90bfac8 100644 --- a/components/RichTextEditor.tsx +++ b/components/RichTextEditor.tsx @@ -152,6 +152,11 @@ const ToolbarButton: React.FC = ({ label, icon: Icon, isAct tooltip={label} size="xs" variant="ghost" + onMouseDown={event => { + // Prevent the toolbar button from stealing focus, which would clear the + // user's selection in the editor before the command executes. + event.preventDefault(); + }} onClick={onClick} disabled={disabled} aria-pressed={isActive} @@ -291,26 +296,28 @@ const ToolbarPlugin: React.FC<{ return; } - // Ensure the editor regains focus so the user's selection is still available - // when the link command runs. Without this, focus remains on the toolbar button - // or prompt dialog and the command receives no selection to wrap. - editor.focus(); + const promptFn = typeof window.prompt === 'function' ? window.prompt.bind(window) : null; - if (isLink) { - editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); - return; - } + const dispatchLink = () => { + if (isLink) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + return; + } - const promptFn = typeof window.prompt === 'function' ? window.prompt.bind(window) : null; - if (!promptFn) { - console.warn('Link insertion prompt is unavailable in this environment.'); - return; - } + if (!promptFn) { + console.warn('Link insertion prompt is unavailable in this environment.'); + return; + } - const url = promptFn('Enter URL'); - if (url) { - editor.dispatchCommand(TOGGLE_LINK_COMMAND, url); - } + const url = promptFn('Enter URL'); + if (url) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, url); + } + }; + + // Focus the editor before running the command so the user's selection is + // preserved when the prompt opens and the link is applied. + editor.focus(() => dispatchLink()); }, [editor, isLink, readOnly]); const insertImage = useCallback( diff --git a/services/repository.ts b/services/repository.ts index 7fefe0e..5fa9ba0 100644 --- a/services/repository.ts +++ b/services/repository.ts @@ -70,15 +70,15 @@ const createSampleBrowserState = (): BrowserState => { const versionId = 1; const shellVersionId = 2; const powershellVersionId = 3; - const sampleContent = '# Welcome to DocForge\n\nThis is a static dataset provided for browser preview mode.'; + const sampleContent = '

Welcome to DocForge

This is a static dataset provided for browser preview mode.

'; const shellContent = '#!/bin/bash\n\necho "DocForge shell quickstart"\nls -la'; const powershellContent = 'Write-Host "DocForge PowerShell quickstart"\nGet-ChildItem'; const document: Document = { document_id: documentId, node_id: documentNodeId, - doc_type: 'prompt', - language_hint: 'markdown', + doc_type: 'rich_text', + language_hint: 'html', default_view_mode: 'split-vertical', language_source: 'user', doc_type_source: 'user', From 250c160d81c190c72378c0dd6eea597b7728f2c0 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Tue, 25 Nov 2025 20:23:35 +0100 Subject: [PATCH 3/3] Add modal workflow for inserting links --- components/RichTextEditor.tsx | 243 ++++++++++++++++++++++++++++------ 1 file changed, 203 insertions(+), 40 deletions(-) diff --git a/components/RichTextEditor.tsx b/components/RichTextEditor.tsx index 90bfac8..905a625 100644 --- a/components/RichTextEditor.tsx +++ b/components/RichTextEditor.tsx @@ -56,9 +56,13 @@ import { UNDO_COMMAND, type EditorState, type LexicalEditor, + type NodeSelection, + type RangeSelection, $createTextNode, + $setSelection, } from 'lexical'; import IconButton from './IconButton'; +import Button from './Button'; import ContextMenuComponent, { type MenuItem as ContextMenuItem } from './ContextMenu'; import { RedoIcon, UndoIcon } from './Icons'; import { @@ -83,6 +87,7 @@ import { UnderlineIcon, } from './rich-text/RichTextToolbarIcons'; import { $createImageNode, ImageNode, INSERT_IMAGE_COMMAND, type ImagePayload } from './rich-text/ImageNode'; +import Modal from './Modal'; export interface RichTextEditorHandle { focus: () => void; @@ -146,6 +151,79 @@ const RICH_TEXT_THEME = { const Placeholder: React.FC = () => null; +const normalizeUrl = (url: string): string => { + const trimmed = url.trim(); + if (!trimmed) { + return ''; + } + + if (/^[a-zA-Z][\w+.-]*:/.test(trimmed)) { + return trimmed; + } + + return `https://${trimmed}`; +}; + +const LinkModal: React.FC<{ + isOpen: boolean; + initialUrl: string; + onSubmit: (url: string) => void; + onRemove: () => void; + onClose: () => void; +}> = ({ isOpen, initialUrl, onSubmit, onRemove, onClose }) => { + const inputRef = useRef(null); + const [url, setUrl] = useState(initialUrl); + + useEffect(() => { + setUrl(initialUrl); + }, [initialUrl]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + onSubmit(url); + }; + + if (!isOpen) { + return null; + } + + return ( + +
+
+ + setUrl(event.target.value)} + 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" + placeholder="https://example.com" + /> +

+ Enter a valid URL. If you omit the protocol, https:// will be added automatically. +

+
+
+ + + +
+
+
+ ); +}; + const ToolbarButton: React.FC = ({ label, icon: Icon, isActive = false, disabled = false, onClick }) => ( ('left'); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); + const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); + const [linkDraftUrl, setLinkDraftUrl] = useState(''); + const pendingLinkSelectionRef = useRef(null); + const closeLinkModal = useCallback(() => { + setIsLinkModalOpen(false); + }, []); + const dismissLinkModal = useCallback(() => { + pendingLinkSelectionRef.current = null; + closeLinkModal(); + }, [closeLinkModal]); const updateToolbar = useCallback(() => { const selection = $getSelection(); @@ -291,34 +379,100 @@ const ToolbarPlugin: React.FC<{ }); }, [editor]); - const toggleLink = useCallback(() => { - if (readOnly) { + const captureLinkState = useCallback(() => { + let detectedUrl = ''; + + editor.getEditorState().read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) { + pendingLinkSelectionRef.current = null; return; } - const promptFn = typeof window.prompt === 'function' ? window.prompt.bind(window) : null; + pendingLinkSelectionRef.current = selection.clone(); - const dispatchLink = () => { - if (isLink) { - editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); - return; - } + const selectionNodes = selection.getNodes(); + if (selectionNodes.length === 0) { + return; + } - if (!promptFn) { - console.warn('Link insertion prompt is unavailable in this environment.'); - return; - } + const firstNode = selectionNodes[0]; + const linkNode = $isLinkNode(firstNode) + ? firstNode + : $isLinkNode(firstNode.getParent()) + ? firstNode.getParent() + : null; - const url = promptFn('Enter URL'); - if (url) { - editor.dispatchCommand(TOGGLE_LINK_COMMAND, url); - } - }; + if ($isLinkNode(linkNode)) { + detectedUrl = linkNode.getURL(); + } + }); + + if (!pendingLinkSelectionRef.current) { + return false; + } + + setLinkDraftUrl(detectedUrl); + setIsLinkModalOpen(true); + return true; + }, [editor]); + + const applyLink = useCallback( + (url: string) => { + closeLinkModal(); - // Focus the editor before running the command so the user's selection is - // preserved when the prompt opens and the link is applied. - editor.focus(() => dispatchLink()); - }, [editor, isLink, readOnly]); + const selection = pendingLinkSelectionRef.current; + pendingLinkSelectionRef.current = null; + + const normalizedUrl = normalizeUrl(url); + if (!normalizedUrl) { + return; + } + + if (!selection) { + editor.focus(); + return; + } + + editor.update(() => { + $setSelection(selection.clone()); + }); + + editor.dispatchCommand(TOGGLE_LINK_COMMAND, normalizedUrl); + editor.focus(); + }, + [closeLinkModal, editor], + ); + + const removeLink = useCallback(() => { + closeLinkModal(); + + const selection = pendingLinkSelectionRef.current; + pendingLinkSelectionRef.current = null; + + if (!selection) { + editor.focus(); + return; + } + + editor.update(() => { + $setSelection(selection.clone()); + }); + + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + editor.focus(); + }, [closeLinkModal, editor]); + + const toggleLink = useCallback(() => { + if (readOnly) { + return; + } + + const hasSelection = captureLinkState(); + if (!hasSelection) { + editor.focus(); + } + }, [captureLinkState, editor, readOnly]); const insertImage = useCallback( (payload: ImagePayload) => { @@ -486,7 +640,7 @@ const ToolbarPlugin: React.FC<{ }, { id: 'link', - label: isLink ? 'Remove Link' : 'Insert Link', + label: isLink ? 'Edit or Remove Link' : 'Insert Link', icon: ToolbarLinkIcon, group: 'insert', isActive: isLink, @@ -609,25 +763,34 @@ const ToolbarPlugin: React.FC<{ ); return ( -
- {renderedToolbarElements.map(element => - 'type' in element ? ( -
- ) : ( - - ), - )} - +
+ {renderedToolbarElements.map(element => + 'type' in element ? ( +
+ ) : ( + + ), + )} + +
+ -
+ ); };