From aabaded54eb9161bb1977c5a8806b04a6680adcf Mon Sep 17 00:00:00 2001 From: Rob Dekker Date: Mon, 4 May 2026 13:25:52 +0200 Subject: [PATCH] feat: add link support to RichTextInput (PFR-1051) Adds inline hyperlink functionality to the RichTextInput component: - Toolbar button (Ctrl/Cmd+K) opens an inline popover for inserting or editing a link without leaving the editor - URLs are auto-detected on paste and wrapped as links automatically - Existing links can be edited in-place; the popover pre-fills the current URL and open-in-new-tab state - "Open in new tab" checkbox adds target="_blank" rel="noreferrer" to the rendered anchor - Typing at the end of a link node moves the cursor outside the link so subsequent text is not included in the link - URL normalization: bare domains like "google.nl" are stored and serialized as "https://google.nl" - Popover labels (Save, Remove, Open in new tab) are rendered in the user's preferred browser language (EN, NL, DE, FR, ES, PT) --- src/components/richTextInput.js | 419 +++++++++++++++++- .../structures/RichTextInput/options/index.ts | 2 + 2 files changed, 416 insertions(+), 5 deletions(-) diff --git a/src/components/richTextInput.js b/src/components/richTextInput.js index e3b3c5fb4..52b8d5f95 100644 --- a/src/components/richTextInput.js +++ b/src/components/richTextInput.js @@ -14,6 +14,7 @@ 'Element', 'Transforms', 'Node', + 'Range', ], }, { @@ -24,7 +25,7 @@ { label: 'SlateReact', package: 'npm:slate-react@0.94.0', - imports: ['Editable', 'withReact', 'Slate', 'useSlate'], + imports: ['Editable', 'withReact', 'Slate', 'useSlate', 'ReactEditor'], }, { label: 'SlateHyperscript', @@ -47,13 +48,14 @@ 'FormatUnderlined', 'StrikethroughS', 'FirstPage', + 'InsertLink', ], }, ], jsx: (() => { const { - Slate: { createEditor, Editor, Text, Element, Transforms, Node }, - SlateReact: { Editable, withReact, Slate, useSlate }, + Slate: { createEditor, Editor, Text, Element, Transforms, Node, Range }, + SlateReact: { Editable, withReact, Slate, useSlate, ReactEditor }, SlateHistory: { withHistory }, SlateHyperscript: { jsx }, MuiExtraIcons: { @@ -69,6 +71,7 @@ FormatUnderlined, StrikethroughS, FirstPage, + InsertLink, }, } = dependencies; const { Icons } = window.MaterialUI; @@ -85,6 +88,7 @@ FormatUnderlined, StrikethroughS, FirstPage, + InsertLink, }; const allIcons = { ...Icons, ...extraIcons }; const { FormHelperText, InputLabel, SvgIcon } = window.MaterialUI.Core; @@ -106,6 +110,7 @@ showCodeBlock, showNumberedList, showBulletedList, + showLink, showLeftAlign, showCenterAlign, showRightAlign, @@ -116,6 +121,11 @@ const [valueKey, setValueKey] = useState(0); const labelText = useText(label); const [showDropdown, setShowDropdown] = useState(false); + const [linkPopoverOpen, setLinkPopoverOpen] = useState(false); + const [linkUrlInput, setLinkUrlInput] = useState(''); + const [linkNewTabInput, setLinkNewTabInput] = useState(false); + const savedSelectionRef = useRef(null); + const linkPathRef = useRef(null); const placeholderText = useText(placeholder); const helperTextResolved = useText(helperText); const { current: labelControlRef } = useRef(generateUUID()); @@ -133,6 +143,109 @@ setValueKey(valueKey + 1); }, [optionValue]); + const normalizeUrl = (input) => { + const trimmed = input.trim(); + return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + }; + + const isUrl = (string) => { + try { + const url = new URL(normalizeUrl(string)); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch (e) { + return false; + } + }; + + const isLinkActive = (editor) => { + const [link] = Array.from( + Editor.nodes(editor, { + match: (n) => + !Editor.isEditor(n) && Element.isElement(n) && n.type === 'link', + }), + ); + return !!link; + }; + + const unwrapLink = (editor) => { + Transforms.unwrapNodes(editor, { + match: (n) => + !Editor.isEditor(n) && Element.isElement(n) && n.type === 'link', + }); + }; + + const wrapLink = (editor, url, newTab = false) => { + if (isLinkActive(editor)) { + unwrapLink(editor); + } + const { selection } = editor; + const isCollapsed = selection && Range.isCollapsed(selection); + const normalizedUrl = normalizeUrl(url); + const link = { + type: 'link', + url: normalizedUrl, + newTab, + children: isCollapsed ? [{ text: normalizedUrl }] : [], + }; + if (isCollapsed) { + Transforms.insertNodes(editor, link); + } else { + Transforms.wrapNodes(editor, link, { split: true }); + Transforms.collapse(editor, { edge: 'end' }); + } + }; + + const withLinks = (slateEditor) => { + const { isInline, insertData, insertText } = slateEditor; + + // eslint-disable-next-line no-param-reassign + slateEditor.isInline = (element) => + element.type === 'link' ? true : isInline(element); + + // When a URL is pasted, wrap selected text in a link or insert a link node + // eslint-disable-next-line no-param-reassign + slateEditor.insertData = (data) => { + const text = data.getData('text/plain'); + if (text && isUrl(text)) { + wrapLink(slateEditor, text); + } else { + insertData(data); + } + }; + + // When cursor is at the end of a link, move outside before inserting text + // eslint-disable-next-line no-param-reassign + slateEditor.insertText = (text) => { + const { selection } = slateEditor; + if (selection && Range.isCollapsed(selection)) { + const [linkEntry] = Array.from( + Editor.nodes(slateEditor, { + match: (n) => + !Editor.isEditor(n) && + Element.isElement(n) && + n.type === 'link', + }), + ); + if (linkEntry) { + const [, linkPath] = linkEntry; + const linkEnd = Editor.end(slateEditor, linkPath); + if ( + selection.anchor.path.join() === linkEnd.path.join() && + selection.anchor.offset === linkEnd.offset + ) { + const after = Editor.after(slateEditor, linkPath); + if (after) { + Transforms.select(slateEditor, after); + } + } + } + } + insertText(text); + }; + + return slateEditor; + }; + const KeyCode = { Digit1: 49, Digit2: 50, @@ -286,13 +399,21 @@ return `${children}`; case 'code': return `${children}`; + case 'link': { + const target = node.newTab ? ' target="_blank" rel="noreferrer"' : ''; + return `${children}`; + } default: return children; } }; const ELEMENT_TAGS = { - A: (el) => ({ type: 'link', url: el.getAttribute('href') }), + A: (el) => ({ + type: 'link', + url: el.getAttribute('href'), + newTab: el.getAttribute('target') === '_blank', + }), BLOCKQUOTE: () => ({ type: 'quote' }), H1: (el) => ({ type: 'heading-one', align: el.getAttribute('align') }), H2: (el) => ({ type: 'heading-two', align: el.getAttribute('align') }), @@ -440,7 +561,10 @@ }; const renderLeaf = useCallback((props) => , []); - const [editor] = useState(() => withReact(withHistory(createEditor()))); + const editor = React.useMemo( + () => withLinks(withHistory(withReact(createEditor()))), + [], + ); const onChangeHandler = (value) => { const serializedValue = value.map((row) => serialize(row)).join(''); @@ -450,6 +574,58 @@ B.triggerEvent('onChange', newCurrentValue); }; + + const openLinkPopover = () => { + savedSelectionRef.current = editor.selection; + if (isLinkActive(editor)) { + const [linkEntry] = Array.from( + Editor.nodes(editor, { + match: (n) => + !Editor.isEditor(n) && Element.isElement(n) && n.type === 'link', + }), + ); + if (linkEntry) { + const [linkNode, linkPath] = linkEntry; + linkPathRef.current = linkPath; + setLinkUrlInput(linkNode.url || ''); + setLinkNewTabInput(!!linkNode.newTab); + } + } else { + linkPathRef.current = null; + setLinkUrlInput(''); + setLinkNewTabInput(false); + } + setLinkPopoverOpen((prev) => !prev); + }; + + const applyLink = () => { + if (savedSelectionRef.current) { + ReactEditor.focus(editor); + Transforms.select(editor, savedSelectionRef.current); + } + if (!linkUrlInput.trim()) { + if (isLinkActive(editor)) unwrapLink(editor); + } else if (linkPathRef.current) { + Transforms.setNodes( + editor, + { url: normalizeUrl(linkUrlInput), newTab: linkNewTabInput }, + { at: linkPathRef.current }, + ); + } else { + wrapLink(editor, linkUrlInput, linkNewTabInput); + } + setLinkPopoverOpen(false); + }; + + const removeLink = () => { + if (savedSelectionRef.current) { + ReactEditor.focus(editor); + Transforms.select(editor, savedSelectionRef.current); + } + unwrapLink(editor); + setLinkPopoverOpen(false); + }; + const parsed = new DOMParser().parseFromString(optionValue, 'text/html'); const fragment = deserialize(parsed.body); @@ -648,6 +824,11 @@ if (showUnderlined) toggleMark(editor, 'underline'); break; } + case 'k': { + event.preventDefault(); + if (showLink) openLinkPopover(); + break; + } case 'Backspace': { event.preventDefault(); break; @@ -713,6 +894,20 @@ ); } + function LinkElement({ attributes, children, element }) { + return ( + + {children} + + ); + } + const renderElement = useCallback((props) => { switch (props.element.type) { case 'code': @@ -735,6 +930,8 @@ return HeadingElement(props, 'h5'); case 'heading-six': return HeadingElement(props, 'h6'); + case 'link': + return LinkElement(props); case 'paragraph': default: return DefaultElement(props); @@ -805,6 +1002,144 @@ ); } + const linkI18n = { + en: { + openInNewTab: 'Open in new tab', + save: 'Save', + remove: 'Remove', + }, + nl: { + openInNewTab: 'Openen in nieuw tabblad', + save: 'Opslaan', + remove: 'Verwijderen', + }, + de: { + openInNewTab: 'In neuem Tab öffnen', + save: 'Speichern', + remove: 'Entfernen', + }, + fr: { + openInNewTab: 'Ouvrir dans un nouvel onglet', + save: 'Enregistrer', + remove: 'Supprimer', + }, + es: { + openInNewTab: 'Abrir en nueva pestaña', + save: 'Guardar', + remove: 'Eliminar', + }, + pt: { + openInNewTab: 'Abrir em nova aba', + save: 'Guardar', + remove: 'Remover', + }, + }; + const linkT = (() => { + const langs = navigator.languages || [navigator.language || 'en']; + const match = langs.find( + (lang) => linkI18n[lang.split('-')[0].toLowerCase()], + ); + return match ? linkI18n[match.split('-')[0].toLowerCase()] : linkI18n.en; + })(); + + function LinkControl() { + const ownEditor = useSlate(); + const controlRef = useRef(); + const urlInputRef = useRef(); + + useEffect(() => { + if (linkPopoverOpen && urlInputRef.current) { + urlInputRef.current.focus(); + } + }, [linkPopoverOpen]); + + useEffect(() => { + const handler = (event) => { + if ( + linkPopoverOpen && + controlRef.current && + !controlRef.current.contains(event.target) + ) { + setLinkPopoverOpen(false); + } + }; + document.addEventListener('mousedown', handler); + document.addEventListener('touchstart', handler); + return () => { + document.removeEventListener('mousedown', handler); + document.removeEventListener('touchstart', handler); + }; + }, [linkPopoverOpen]); + + return ( +
+ + {linkPathRef.current && ( + + )} +
+ + )} + + ); + } + const HistoryButton = React.forwardRef( ({ action, icon, ...props }, ref) => { const IconButton = allIcons[icon]; @@ -1056,6 +1391,11 @@ )} )} + {showLink && ( +
+ +
+ )}
{showNumberedList && ( [ + style.getColor(buttonActiveColor), + ], + textDecoration: 'underline', + cursor: 'pointer', + }, + linkControl: { + position: 'relative', + }, + linkPopover: { + position: 'absolute', + top: 'calc(100% + 4px)', + left: 0, + zIndex: 9999, + display: 'flex', + flexDirection: 'column', + gap: '8px', + minWidth: '240px', + padding: '12px', + backgroundColor: '#fff', + borderRadius: '6px', + boxShadow: + 'rgb(9 30 66 / 25%) 0px 4px 8px -2px, rgb(9 30 66 / 31%) 0px 0px 1px', + }, + linkInput: { + width: '100%', + padding: '6px 8px', + fontSize: '0.875rem', + border: '1px solid #ccc', + borderRadius: '4px', + boxSizing: 'border-box', + outline: 'none', + '&:focus': { + borderColor: ({ options: { borderFocusColor } }) => + style.getColor(borderFocusColor), + }, + }, + linkCheckboxLabel: { + display: 'flex', + alignItems: 'center', + gap: '6px', + fontSize: '0.875rem', + cursor: 'pointer', + userSelect: 'none', + }, + linkActions: { + display: 'flex', + gap: '8px', + }, + linkSaveButton: { + padding: '4px 14px', + fontSize: '0.875rem', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + backgroundColor: ({ options: { buttonActiveColor } }) => + style.getColor(buttonActiveColor), + color: '#fff', + }, + linkRemoveButton: { + padding: '4px 14px', + fontSize: '0.875rem', + border: '1px solid currentColor', + borderRadius: '4px', + cursor: 'pointer', + backgroundColor: 'transparent', + color: ({ options: { errorColor } }) => style.getColor(errorColor), + }, }; }, }))(); diff --git a/src/prefabs/structures/RichTextInput/options/index.ts b/src/prefabs/structures/RichTextInput/options/index.ts index fef0fbc34..50107fc1b 100644 --- a/src/prefabs/structures/RichTextInput/options/index.ts +++ b/src/prefabs/structures/RichTextInput/options/index.ts @@ -22,6 +22,7 @@ export const categories = [ 'showStrikethrough', 'showCodeInline', 'showCodeBlock', + 'showLink', 'showNumberedList', 'showBulletedList', 'showLeftAlign', @@ -92,6 +93,7 @@ export const richTextOptions = { showStrikethrough: toggle('Strikethrough', { value: true }), showCodeInline: toggle('Code inline', { value: true }), showCodeBlock: toggle('Code block', { value: true }), + showLink: toggle('Link', { value: true }), showNumberedList: toggle('NumberedList', { value: true }), showBulletedList: toggle('BulletedList', { value: true }), showLeftAlign: toggle('Left alignment', { value: true }),