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 (
+
+
+ );
+ }
+
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 }),