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')} + +
+ + + void 0}` to fix reactstrap error which need `onChange` when `value` setteled, (`onChange` has been listened at `` ) + onChange={() => void 0} + value={formData.linkUrl} + invalid={!!validatorErrorMessage.linkUrl} + name='linkUrl' + innerRef={linkAddressRef} + type='url' + id='linkUrl' + /> + {t(validatorErrorMessage.linkUrl)} + + + + void 0}` to fix reactstrap error which need `onChange` when `value` setteled, (`onChange` has been listened at `` ) + onChange={() => void 0} + value={formData.linkTitle} + invalid={!!validatorErrorMessage.linkTitle} + name='linkTitle' + id='linkTitle' + /> + {t(validatorErrorMessage.linkTitle)} + +
+
+ + + + +
+ ); +}; + +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; +};