diff --git a/public/locales/en/seafile-editor.json b/public/locales/en/seafile-editor.json
index caa50455..9b888965 100644
--- a/public/locales/en/seafile-editor.json
+++ b/public/locales/en/seafile-editor.json
@@ -137,6 +137,10 @@
"Clear_format": "Clear format",
"Image_address_invalid": "Image address invalid",
"Shortcut_help": "Shortcut help",
+ "Link_address_required": "Link address required",
+ "Link_address_invalid": "Link address invalid",
+ "Link_title_required": "Link title required",
+ "Blank_title_not_allowed": "Blank title not allowed",
"userHelp": {
"title": "Keyboard shortcuts",
"userHelpData": [
@@ -216,4 +220,4 @@
},
"Select_field": "Select field",
"Font_style": "Font style"
-}
\ No newline at end of file
+}
diff --git a/src/constants/event-types.js b/src/constants/event-types.js
index 99a5b1c1..343edd4a 100644
--- a/src/constants/event-types.js
+++ b/src/constants/event-types.js
@@ -1,6 +1,8 @@
export const INTERNAL_EVENTS = {
ON_ARTICLE_INFO_TOGGLE: 'on_article_info_toggle',
ON_MOUSE_ENTER_BLOCK: 'on_mouse_enter_block',
+ ON_OPEN_LINK_POPOVER: 'on_open_link_popover',
+ ON_CLOSE_LINK_POPOVER: 'on_close_link_popover',
};
export const EXTERNAL_EVENTS = {
diff --git a/src/extension/commons/menu/menu-item.js b/src/extension/commons/menu/menu-item.js
index c7607739..24ccbc10 100644
--- a/src/extension/commons/menu/menu-item.js
+++ b/src/extension/commons/menu/menu-item.js
@@ -7,7 +7,6 @@ import Tooltip from '../tooltip';
const MenuItem = ({ disabled, isActive, isRichEditor, type, onMouseDown, className, iconClass, id, text }) => {
const { t } = useTranslation();
-
const onClick = useCallback((event) => {
if (disabled) return;
onMouseDown(event, type);
diff --git a/src/extension/constants/index.js b/src/extension/constants/index.js
index 37c9f9c6..fb763e0a 100644
--- a/src/extension/constants/index.js
+++ b/src/extension/constants/index.js
@@ -23,3 +23,9 @@ export const HEADER_TITLE_MAP = {
};
export const LIST_TYPE_ARRAY = ['unordered_list', 'ordered_list'];
+
+export const INSERT_POSITION = {
+ BEFORE: 'before',
+ CURRENT: 'current',
+ AFTER: 'after',
+};
diff --git a/src/extension/plugins/index.js b/src/extension/plugins/index.js
index c7cb398e..eb410c85 100644
--- a/src/extension/plugins/index.js
+++ b/src/extension/plugins/index.js
@@ -4,6 +4,7 @@ import TextPlugin from './text-style';
import HeaderPlugin from './header';
import ImagePlugin from './image';
import NodeIdPlugin from './node-id';
+import LinkPlugin from './link';
const Plugins = [
BlockquotePlugin,
@@ -11,6 +12,7 @@ const Plugins = [
TextPlugin,
HeaderPlugin,
ImagePlugin,
+ LinkPlugin,
// put at the end
NodeIdPlugin,
@@ -23,6 +25,7 @@ export {
TextPlugin,
HeaderPlugin,
ImagePlugin,
+ LinkPlugin,
// put at the end
NodeIdPlugin,
diff --git a/src/extension/plugins/link/helper.js b/src/extension/plugins/link/helper.js
new file mode 100644
index 00000000..d176768a
--- /dev/null
+++ b/src/extension/plugins/link/helper.js
@@ -0,0 +1,161 @@
+import { Editor, Path, Range, Transforms } from 'slate';
+import slugid from 'slugid';
+import { findPath, getAboveNode, getEditorString, getNodeType, getSelectedElems } from '../../core/queries';
+import { focusEditor } from '../../core/transforms/focus-editor';
+import { ELementTypes, INSERT_POSITION } from '../../constants';
+import { generateDefaultText, generateEmptyElement } from '../../core/utils';
+import { replaceNodeChildren } from '../../core/transforms/replace-node-children';
+
+export const isMenuDisabled = (editor, readonly = false) => {
+ if (readonly) return true;
+ const { selection } = editor;
+ if (!selection) return false;
+ const selectedElems = getSelectedElems(editor);
+ // Check if the selected element is illegal
+ const isSelectedIllegalElement = selectedElems.some(elem => {
+ const { type } = elem;
+ if (editor.isVoid(elem)) return true;
+ if ([ELementTypes.CODE_BLOCK, ELementTypes.CODE_LINE].includes(type)) return true;
+ return false;
+ });
+ if (isSelectedIllegalElement) return true;
+ return false;
+};
+
+export const isLinkType = (editor) => {
+ const [match] = Editor.nodes(editor, {
+ match: n => getNodeType(n) === ELementTypes.LINK,
+ universal: true,
+ });
+ return !!match;
+};
+
+export const generateLinkNode = (url, title) => {
+ const linkNode = {
+ type: ELementTypes.LINK,
+ url: url,
+ title: title,
+ id: slugid.nice(),
+ children: [{ id: slugid.nice(), text: title || '' }],
+ };
+ return linkNode;
+};
+
+/**
+ * @param {Object} props
+ * @param {Object} props.editor
+ * @param {String} props.url
+ * @param {String} props.title
+ * @param {InsertPosition} props.insertPosition
+ * @param {Object | undefined} props.slateNode
+ */
+export const insertLink = (props) => {
+ const { editor, url, title, insertPosition = INSERT_POSITION.CURRENT, slateNode } = props;
+ const { selection } = editor;
+ if (insertPosition === INSERT_POSITION.CURRENT && isMenuDisabled(editor)) return;
+ // We had validated in modal,here we do it again for safety
+ if (!title || !url) return;
+ if (!selection) return;
+
+ const linkNode = generateLinkNode(url, title);
+
+ if (insertPosition === INSERT_POSITION.AFTER) {
+ let path = Editor.path(editor, selection);
+
+ if (slateNode && slateNode?.type === ELementTypes.LIST_ITEM) {
+ path = findPath(editor, slateNode, []);
+ const nextPath = Path.next(path);
+ Editor.insertNodes(editor, linkNode, { at: nextPath });
+ return;
+ }
+
+ const linkNodeWrapper = generateEmptyElement(ELementTypes.PARAGRAPH);
+ // LinkNode should be wrapped by p and within text nodes in order to be editable
+ linkNodeWrapper.children.push(linkNode, generateDefaultText());
+ Transforms.insertNodes(editor, linkNodeWrapper, { at: [path[0] + 1] });
+ focusEditor(editor);
+ return;
+ }
+
+ const isCollapsed = Range.isCollapsed(selection);
+ if (isCollapsed) {
+ // If selection is collapsed, we insert a space and then insert link node that help operation easier
+ editor.insertText(' ');
+ Editor.insertFragment(editor, [linkNode]);
+ // Using insertText directly causes the added Spaces to be added to the linked text, as in the issue above, so replaced by insertFragment
+ Editor.insertFragment(editor, [{ id: slugid.nice(), text: ' ' }]);
+
+ focusEditor(editor);
+ return;
+ } else {
+ const selectedText = Editor.string(editor, selection); // Selected text
+ if (selectedText !== title) {
+ // Replace the selected text with the link node if the selected text is different from the entered text
+ editor.deleteFragment();
+ Transforms.insertNodes(editor, linkNode);
+ } else {
+ // Wrap the selected text with the link node if the selected text is the same as the entered text
+ Transforms.wrapNodes(editor, linkNode, { split: true, at: selection });
+ Transforms.collapse(editor, { edge: 'end' });
+ }
+ }
+ focusEditor(editor);
+};
+
+export const getLinkInfo = (editor) => {
+ const isLinkNode = isLinkType(editor);
+ if (!isLinkNode) return null;
+ const [match] = Editor.nodes(editor, {
+ match: n => getNodeType(n) === ELementTypes.LINK,
+ universal: true,
+ });
+ if (!match) return null;
+ const [node, path] = match;
+ const showedText = getEditorString(editor, path);
+ return {
+ linkUrl: node.url,
+ linkTitle: showedText || node.title,
+ path: path,
+ };
+};
+
+export const updateLink = (editor, newUrl, newText) => {
+ const linkAbove = getAboveNode(editor, { match: { type: ELementTypes.LINK } });
+ if (!linkAbove) return;
+ const { href: oldUrl, title: oldText } = linkAbove[0] || {};
+ if (oldUrl !== newUrl || oldText !== newText) {
+ Transforms.setNodes(editor, { url: newUrl, title: newText }, { at: linkAbove[1] });
+ }
+ upsertLinkText(editor, { text: newText });
+};
+
+export const upsertLinkText = (editor, { text }) => {
+ const newLink = getAboveNode(editor, { match: { type: ELementTypes.LINK } });
+ if (!newLink) return;
+ const [newLInkNode, newLinkPath] = newLink;
+ if ((text && text.length) && text !== getEditorString(editor, newLinkPath)) {
+ const firstText = newLInkNode.children[0];
+ replaceNodeChildren(editor, {
+ at: newLinkPath,
+ nodes: { ...firstText, text },
+ insertOptions: {
+ select: true
+ }
+ });
+ }
+};
+
+export const unWrapLinkNode = async (editor) => {
+ if (editor.selection == null) return;
+
+ const [linkNode] = Editor.nodes(editor, {
+ match: n => getNodeType(n) === ELementTypes.LINK,
+ universal: true,
+ });
+ // Check selection is link node
+ if (!linkNode || !linkNode[0]) return;
+
+ Transforms.unwrapNodes(editor, {
+ match: n => getNodeType(n) === ELementTypes.LINK,
+ });
+};
diff --git a/src/extension/plugins/link/index.js b/src/extension/plugins/link/index.js
index e69de29b..450fedc0 100644
--- a/src/extension/plugins/link/index.js
+++ b/src/extension/plugins/link/index.js
@@ -0,0 +1,14 @@
+import { LINK } from '../../constants/element-types';
+import LinkMenu from './menu';
+import withLink from './plugin';
+import renderLink from './render-elem';
+
+const LinkPlugin = {
+ type: LINK,
+ nodeType: 'element',
+ editorMenus: [LinkMenu],
+ editorPlugin: withLink,
+ renderElements: [renderLink],
+};
+
+export default LinkPlugin;
diff --git a/src/extension/plugins/link/menu/index.js b/src/extension/plugins/link/menu/index.js
new file mode 100644
index 00000000..3114b815
--- /dev/null
+++ b/src/extension/plugins/link/menu/index.js
@@ -0,0 +1,81 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { Editor } from 'slate';
+import MenuItem from '../../../commons/menu/menu-item';
+import { MENUS_CONFIG_MAP } from '../../../constants/menus-config';
+import { LINK } from '../../../constants/element-types';
+import { isLinkType, isMenuDisabled, unWrapLinkNode } from '../helper';
+import EventBus from '../../../../utils/event-bus';
+import LinkModal from './link-modal';
+import { INTERNAL_EVENTS } from '../../../../constants/event-types';
+
+const menuConfig = MENUS_CONFIG_MAP[LINK];
+
+const LinkMenu = ({ isRichEditor, className, readonly, editor }) => {
+ const [isOpenLinkModal, setIsOpenLinkModal] = useState(false);
+ const [linkInfo, setLinkInfo] = useState({ linkTitle: '', linkUrl: '' });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const isLinkActive = useMemo(() => isLinkType(editor), [editor.selection]);
+
+ useEffect(() => {
+ const eventBus = EventBus.getInstance();
+ const unsubscribe = eventBus.subscribe(INTERNAL_EVENTS.ON_OPEN_LINK_POPOVER, handleOpenLinkModal);
+ return () => unsubscribe();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (isLinkType(editor)) {
+ const newTitle = editor.selection && Editor.string(editor, editor.selection);
+ newTitle && setLinkInfo({ ...linkInfo, linkTitle: newTitle });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [editor.selection]);
+
+ const handleOpenLinkModal = useCallback((linkInfo) => {
+ Reflect.ownKeys.length && setLinkInfo(linkInfo);
+ setIsOpenLinkModal(true);
+ }, [setIsOpenLinkModal, setLinkInfo]);
+
+ const onMouseDown = useCallback((event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (isLinkActive) {
+ isLinkActive && unWrapLinkNode(editor);
+ return;
+ }
+ if (editor.selection) {
+ const selectedText = Editor.string(editor, editor.selection);
+ setLinkInfo({ ...linkInfo, linkTitle: selectedText });
+ }
+ setIsOpenLinkModal(true);
+ document.getElementById(`seafile_${LINK}`).blur();
+ }, [editor, isLinkActive, linkInfo]);
+
+ const onCloseModal = useCallback(() => {
+ setIsOpenLinkModal(false);
+ setLinkInfo({ linkTitle: '', linkUrl: '' });
+ }, []);
+
+ return (
+ <>
+
+ {isOpenLinkModal && (
+ )}
+ >
+ );
+};
+
+export default LinkMenu;
diff --git a/src/extension/plugins/link/menu/link-modal.js b/src/extension/plugins/link/menu/link-modal.js
new file mode 100644
index 00000000..c5e8a3ac
--- /dev/null
+++ b/src/extension/plugins/link/menu/link-modal.js
@@ -0,0 +1,135 @@
+import React, { useCallback, useMemo, useRef, useState } from 'react';
+import { Button, Form, FormFeedback, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+import { insertLink, isLinkType, updateLink } from '../helper';
+import { isUrl } from '../../../../utils/common';
+
+const LinkModal = ({ editor, onCloseModal, linkTitle, linkUrl }) => {
+ const [formData, setFormData] = useState({ linkUrl: linkUrl ?? '', linkTitle: linkTitle ?? '', });
+ const [validatorErrorMessage, setValidatorErrorMessage] = useState({ linkUrl: '', linkTitle: '', });
+ const linkAddressRef = useRef(null);
+ const { t } = useTranslation();
+
+ const isSubmitDisabled = useMemo(() => {
+ const isFormdataEmpty = Object.values(formData).some((value) => value.length === 0);
+ if (isFormdataEmpty) return true;
+ const isValidatorErrorMessage = Object.values(validatorErrorMessage).some((value) => value.length > 0);
+ if (isValidatorErrorMessage) return true;
+ return false;
+ }, [formData, validatorErrorMessage]);
+
+ const onOpened = useCallback(() => {
+ linkAddressRef.current?.focus();
+ }, []);
+
+ /**
+ * @param {String} formItemName form item name
+ * @param {String} formItemValue form item value
+ * @returns if validate passed, return Promise.resolve(); else return Promise.reject(error message);
+ */
+ const validateFormData = useCallback((formItemName, formItemValue) => {
+ if (formItemName === 'linkUrl') {
+ if (formItemValue.length === 0) return Promise.reject('Link_address_required');
+ if (!isUrl(formItemValue)) return Promise.reject('Link_address_invalid');
+ }
+ if (formItemName === 'linkTitle') {
+ if (!formItemValue.length) return Promise.reject('Link_title_required');
+ if (!formItemValue.trim().length) return Promise.reject('Blank_title_not_allowed');
+ }
+ return Promise.resolve();
+ }, []);
+
+ const preProcessBeforeOnchange = useCallback((formItemName, formItemValue) => {
+ if (formItemName === 'linkUrl') {
+ return formItemValue.trim();
+ }
+ return formItemValue;
+ }, []);
+
+ const onFormValueChange = useCallback((e) => {
+ const formItemName = e.target.name;
+ let formItemValue = e.target.value;
+ // pre-process form item value
+ formItemValue = preProcessBeforeOnchange(formItemName, formItemValue);
+ validateFormData(formItemName, formItemValue).then(
+ () => setValidatorErrorMessage({ ...validatorErrorMessage, [formItemName]: '' }),
+ (errMsg) => setValidatorErrorMessage({ ...validatorErrorMessage, [formItemName]: errMsg })
+ );
+ setFormData({ ...formData, [formItemName]: formItemValue });
+ }, [formData, preProcessBeforeOnchange, validateFormData, validatorErrorMessage]);
+
+ const onSubmit = useCallback((e) => {
+ // re-validate form data before submit
+ Object.entries(formData)
+ .forEach(([key, value]) => validateFormData(key, value)
+ .catch((errMsg) => setValidatorErrorMessage(prev => ({ ...prev, [key]: errMsg })))
+ );
+ if (!isSubmitDisabled) {
+ const isLinkActive = isLinkType(editor);
+ isLinkActive
+ ? updateLink(editor, formData.linkUrl, formData.linkTitle)
+ : insertLink({ editor, url: formData.linkUrl, title: formData.linkTitle });
+ onCloseModal();
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ }, [editor, formData, isSubmitDisabled, onCloseModal, validateFormData]);
+
+ const onKeydown = useCallback((e) => {
+ if (e.key === 'Enter') {
+ onSubmit(e);
+ }
+ }, [onSubmit]);
+
+ return (
+
+ {t('Insert_link')}
+
+
+
+
+
+
+
+
+ );
+};
+
+LinkModal.propTypes = {
+ editor: PropTypes.object.isRequired,
+ onCloseModal: PropTypes.func.isRequired,
+ linkTitle: PropTypes.string,
+ linkUrl: PropTypes.string,
+};
+
+export default LinkModal;
diff --git a/src/extension/plugins/link/plugin.js b/src/extension/plugins/link/plugin.js
new file mode 100644
index 00000000..75bfa275
--- /dev/null
+++ b/src/extension/plugins/link/plugin.js
@@ -0,0 +1,85 @@
+import { Editor, Node, Transforms, Range, Path } from 'slate';
+import slugid from 'slugid';
+import { getNodeType, getSelectedNodeByType } from '../../core/queries';
+import { generateLinkNode, getLinkInfo, isLinkType } from './helper';
+import { LINK } from '../../constants/element-types';
+import { isImage, isUrl } from '../../../utils/common';
+import { focusEditor } from '../../core/transforms/focus-editor';
+
+const withLink = (editor) => {
+ const { isInline, deleteBackward, insertText, normalizeNode, insertData } = editor;
+ const newEditor = editor;
+
+ // Rewrite isInline
+ newEditor.isInline = elem => {
+ const { type } = elem;
+ if (type === LINK) {
+ return true;
+ }
+ return isInline(elem);
+ };
+
+ // ! bug: insertFragment will insert the same character twice in LinkNode, so we need to delete the first character
+ newEditor.insertText = (text) => {
+ const isCollapsed = Range.isCollapsed(editor.selection);
+ const path = Editor.path(editor, editor.selection);
+ const isLinkNode = getSelectedNodeByType(editor, LINK);
+ const isFocusAtLinkEnd = Editor.isEnd(editor, editor.selection.focus, path);
+ if (isCollapsed && isLinkNode && isFocusAtLinkEnd) {
+ Editor.insertFragment(newEditor, [{ id: slugid.nice(), text: text }]);
+ return;
+ }
+ return insertText(text);
+ };
+
+ newEditor.insertData = (data) => {
+ // Paste link content
+ const text = data.getData('text/plain');
+ if (isUrl(text) && !isImage(text)) {
+ const link = generateLinkNode(text, text);
+ Editor.insertFragment(newEditor, [link], { select: true });
+ return;
+ }
+ insertData(data);
+ };
+
+ newEditor.deleteBackward = (unit) => {
+ const { selection } = newEditor;
+ if (!selection) return deleteBackward(unit);
+ // Delete link node
+ const isDeletingLinkNode = isLinkType(editor);
+ if (isDeletingLinkNode) {
+ const linkNodeInfo = getLinkInfo(editor);
+ const next = Editor.next(editor);
+ const nextPath = Path.next(linkNodeInfo.path);
+ const nextNode = Editor.node(editor, nextPath);
+ focusEditor(editor, next[1]);
+ Transforms.select(editor, nextNode[1]);
+ if (linkNodeInfo && linkNodeInfo.linkTitle.length === 1) {
+ Transforms.delete(newEditor, { at: linkNodeInfo.path });
+ return;
+ }
+ }
+ return deleteBackward(unit);
+ };
+
+ // Rewrite normalizeNode
+ newEditor.normalizeNode = ([node, path]) => {
+ const type = getNodeType(node);
+ if (type !== LINK) {
+ // If the type is not link, perform default normalizeNode
+ return normalizeNode([node, path]);
+ }
+
+ // If the link is empty, delete it
+ const str = Node.string(node);
+ if (str === '') {
+ return Transforms.removeNodes(newEditor, { at: path });
+ }
+ return normalizeNode([node, path]);
+ };
+
+ return newEditor;
+};
+
+export default withLink;
diff --git a/src/extension/plugins/link/render-elem/index.js b/src/extension/plugins/link/render-elem/index.js
new file mode 100644
index 00000000..d3ffd430
--- /dev/null
+++ b/src/extension/plugins/link/render-elem/index.js
@@ -0,0 +1,66 @@
+/* eslint-disable react-hooks/rules-of-hooks */
+import React, { useCallback, useState } from 'react';
+import classNames from 'classnames';
+import LinkPopover from './link-popover';
+import { getLinkInfo } from '../helper';
+import EventBus from '../../../../utils/event-bus';
+import { INTERNAL_EVENTS } from '../../../../constants/event-types';
+
+import './style.css';
+
+const renderLink = ({ attributes, children, element }, editor) => {
+ const [isShowPopover, setIsShowPopover] = useState(false);
+ const [popoverPosition, setPopoverPosition] = useState({ top: 0, left: 0 });
+
+ const onClosePopover = useCallback((e) => {
+ unregisterClickEvent();
+ setIsShowPopover(false);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [setPopoverPosition]);
+
+ const registerClickEvent = useCallback(() => {
+ document.addEventListener('click', onClosePopover);
+ }, [onClosePopover]);
+
+ const unregisterClickEvent = useCallback(() => {
+ document.removeEventListener('click', onClosePopover);
+ }, [onClosePopover]);
+
+ const onOpenPopover = useCallback((e) => {
+ e.stopPropagation();
+ // Only one popover can be open at the same time, close other popover and update new popover controller function.
+ const eventBus = EventBus.getInstance();
+ eventBus.dispatch(INTERNAL_EVENTS.ON_CLOSE_LINK_POPOVER);
+ eventBus.subscribe(INTERNAL_EVENTS.ON_CLOSE_LINK_POPOVER, () => setIsShowPopover(false));
+ const linkInfo = getLinkInfo(editor);
+ if (!linkInfo) return;
+ const { top, left, width } = e.target.getBoundingClientRect();
+ const popoverTop = top - 42;
+ const popoverLeft = left - (140 / 2) + (width / 2);
+ setPopoverPosition({ top: popoverTop, left: popoverLeft });
+ setIsShowPopover(true);
+ registerClickEvent();
+ }, [editor, registerClickEvent]);
+
+ return (
+ <>
+
+ {children}
+
+ {isShowPopover && (
+
+ )}
+ >
+ );
+};
+
+export default renderLink;
diff --git a/src/extension/plugins/link/render-elem/link-popover.js b/src/extension/plugins/link/render-elem/link-popover.js
new file mode 100644
index 00000000..f8a61164
--- /dev/null
+++ b/src/extension/plugins/link/render-elem/link-popover.js
@@ -0,0 +1,79 @@
+import React, { useCallback, useEffect } from 'react';
+import { createPortal } from 'react-dom';
+import PropTypes from 'prop-types';
+import EventBus from '../../../../utils/event-bus';
+import { getLinkInfo, unWrapLinkNode } from '../helper';
+import { isUrl } from '../../../../utils/common';
+import { INTERNAL_EVENTS } from '../../../../constants/event-types';
+
+import './style.css';
+
+const LinkPopover = ({ linkUrl, onClosePopover, popoverPosition, editor }) => {
+ useEffect(() => {
+ return () => {
+ // unregister click event before unmount
+ onClosePopover();
+ };
+ }, [onClosePopover]);
+
+ const onLinkClick = useCallback((e) => {
+ if (!isUrl(linkUrl)) {
+ e.preventDefault();
+ }
+ }, [linkUrl]);
+
+ const onUnwrapLink = useCallback((e) => {
+ e.stopPropagation();
+ unWrapLinkNode(editor);
+ }, [editor]);
+
+ const onEditLink = useCallback((e) => {
+ e.stopPropagation();
+ const linkNode = getLinkInfo(editor);
+ if (!linkNode) {
+ onClosePopover();
+ return;
+ }
+ const { linkTitle, linkUrl } = linkNode;
+ const eventBus = EventBus.getInstance();
+ eventBus.dispatch(INTERNAL_EVENTS.ON_OPEN_LINK_POPOVER, { linkTitle, linkUrl });
+ }, [editor, onClosePopover]);
+
+ return (
+ <>
+ {createPortal(
+
,
+ document.body
+ )}
+ >
+ );
+};
+
+LinkPopover.propTypes = {
+ linkUrl: PropTypes.string.isRequired,
+ popoverPosition: PropTypes.object.isRequired,
+ onClosePopover: PropTypes.func.isRequired,
+};
+
+export default LinkPopover;
diff --git a/src/extension/plugins/link/render-elem/style.css b/src/extension/plugins/link/render-elem/style.css
new file mode 100644
index 00000000..54e62e28
--- /dev/null
+++ b/src/extension/plugins/link/render-elem/style.css
@@ -0,0 +1,60 @@
+.sf-virtual-link {
+ color: #eb8205;
+}
+
+.sf-virtual-link:hover {
+ text-decoration: underline;
+}
+
+.sf-virtual-link.selected {
+ background-color: #e5e5e5;
+}
+
+.sf-link-op-menu {
+ height: 36px;
+ padding: 7px 8px;
+ display: flex;
+ position: absolute;
+ background-color: #fff;
+ border: 1px solid #e5e5e5;
+ border-radius: 3px;
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08);
+ z-index: 1000;
+}
+
+.sf-link-op-menu-link {
+ font-size: 12px;
+ color: #212529;
+ padding: 0 5px;
+ border-radius: 2px;
+ line-height: 20px;
+}
+
+.sf-link-op-menu-link:hover {
+ color: #212529;
+ text-decoration: none;
+ background: #f1f1f1;
+}
+
+.sf-link-op-icons {
+ margin-left: 8px;
+ border-left: 1px solid #e5e5e5;
+}
+
+.sf-link-op-icon {
+ color: #999999;
+ padding: 4px;
+ border-radius: 2px;
+ margin-left: 8px;
+ display: flex;
+ align-items: center;
+}
+
+.sf-link-op-icon {
+ font-size: 12px;
+ color: #444;
+}
+
+.sf-link-op-icon:hover {
+ background: #f2f2f2;
+}
diff --git a/src/extension/render/render-element.js b/src/extension/render/render-element.js
index 5a7f0b55..8b559111 100644
--- a/src/extension/render/render-element.js
+++ b/src/extension/render/render-element.js
@@ -1,7 +1,7 @@
import React from 'react';
import { useSlateStatic } from 'slate-react';
import * as ElementType from '../constants/element-types';
-import { BlockquotePlugin, HeaderPlugin, ParagraphPlugin, ImagePlugin } from '../plugins';
+import { BlockquotePlugin, HeaderPlugin, ParagraphPlugin, ImagePlugin, LinkPlugin } from '../plugins';
const SlateElement = (props) => {
const { element } = props;
@@ -26,6 +26,10 @@ const SlateElement = (props) => {
const [renderImage] = ImagePlugin.renderElements;
return renderImage(props);
}
+ case ElementType.LINK: {
+ const [renderLink] = LinkPlugin.renderElements;
+ return renderLink(props, editor);
+ }
default: {
const [renderParagraph] = ParagraphPlugin.renderElements;
return renderParagraph(props);
diff --git a/src/extension/toolbar/header-toolbar/index.js b/src/extension/toolbar/header-toolbar/index.js
index 809b6bee..75de6262 100644
--- a/src/extension/toolbar/header-toolbar/index.js
+++ b/src/extension/toolbar/header-toolbar/index.js
@@ -8,6 +8,7 @@ import { MenuGroup } from '../../commons';
import QuoteMenu from '../../plugins/blockquote/menu';
import HeaderMenu from '../../plugins/header/menu';
import TextStyleMenu from '../../plugins/text-style/menu';
+import LinkMenu from '../../plugins/link/menu';
import { TEXT_STYLE_MAP } from '../../constants';
import ImageMenu from '../../plugins/image/menu';
@@ -50,6 +51,7 @@ const Toolbar = ({ editor, readonly = false }) => {
+
diff --git a/src/utils/common.js b/src/utils/common.js
index 780d796e..989acc5f 100644
--- a/src/utils/common.js
+++ b/src/utils/common.js
@@ -1,4 +1,30 @@
+import checkIsUrl from 'is-url';
+
export const isMac = () => {
const platform = navigator.platform;
return (platform === 'Mac68K') || (platform === 'MacPPC') || (platform === 'Macintosh') || (platform === 'MacIntel');
};
+
+export const IMAGE_TYPES = [
+ 'png',
+ 'jpg',
+ 'gif',
+];
+
+export const isImage = (url) => {
+ if (!url) return false;
+
+ if (!isUrl(url)) return false;
+
+ const suffix = url.split('.')[1]; // http://xx/mm/*.png
+ if (!suffix) return false;
+
+ return IMAGE_TYPES.includes(suffix.toLowerCase());
+};
+
+export const isUrl = (url) => {
+ if (!url) return false;
+ if (!url.startsWith('http')) return false;
+ if (!checkIsUrl(url)) return false;
+ return true;
+};