From d5f7320a67d82314491c9f6bc83b3aefeb4d4d60 Mon Sep 17 00:00:00 2001 From: liuhongbo <916196375@qq.com> Date: Tue, 26 Sep 2023 10:29:55 +0800 Subject: [PATCH 1/6] feat: link-plugin 10% --- src/extension/plugins/link/helper.js | 16 ++++++++++ src/extension/plugins/link/index.js | 13 ++++++++ src/extension/plugins/link/menu/index.js | 31 +++++++++++++++++++ src/extension/plugins/link/menu/style.css | 0 src/extension/plugins/link/plugin.js | 8 +++++ src/extension/toolbar/header-toolbar/index.js | 2 ++ 6 files changed, 70 insertions(+) create mode 100644 src/extension/plugins/link/helper.js create mode 100644 src/extension/plugins/link/menu/index.js create mode 100644 src/extension/plugins/link/menu/style.css create mode 100644 src/extension/plugins/link/plugin.js diff --git a/src/extension/plugins/link/helper.js b/src/extension/plugins/link/helper.js new file mode 100644 index 00000000..faeba906 --- /dev/null +++ b/src/extension/plugins/link/helper.js @@ -0,0 +1,16 @@ +import { Editor } from 'slate' +import { getNodeType } from '../../core/queries'; +import { LINK } from '../../constants/element-types'; + +export const isDisabled = (editor: Editor,readonly) => { + if(readonly) return true; + return false; +} + +export const isActive = (editor:Editor) => { + return false; +} + +export const isLinkType = (node) => { + return getNodeType(node) === LINK; +} \ No newline at end of file diff --git a/src/extension/plugins/link/index.js b/src/extension/plugins/link/index.js index e69de29b..9e8c8be5 100644 --- a/src/extension/plugins/link/index.js +++ b/src/extension/plugins/link/index.js @@ -0,0 +1,13 @@ +import { LINK } from '../../constants/element-types'; +import LinkMenu from './menu'; +import withLink from './plugin'; + +const LinkPlugin = { + type: LINK, + nodeType: 'element', + editorMenus: [LinkMenu], + editorPlugin: withLink, + renderElements: [], +}; + +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..83bc7491 --- /dev/null +++ b/src/extension/plugins/link/menu/index.js @@ -0,0 +1,31 @@ +import React, { useMemo } from 'react' +import MenuItem from '../../../commons/menu/menu-item' +import { MENUS_CONFIG_MAP } from '../../../constants/menus-config' +import { LINK } from '../../../constants/element-types' +import { isActive, isDisabled } from '../helper' + +const menuConfig = MENUS_CONFIG_MAP[LINK] + +const LinkMenu = (props) => { + const { isRichEditor, className, readonly, editor } = props; + + const onMouseDown = (event) => { + console.log('LinkMenu.onMouseDown'); + // const active = isActive(editor); + // setBlockQuoteType(editor, active); + }; + + return ( + + ) +} + +export default LinkMenu; diff --git a/src/extension/plugins/link/menu/style.css b/src/extension/plugins/link/menu/style.css new file mode 100644 index 00000000..e69de29b diff --git a/src/extension/plugins/link/plugin.js b/src/extension/plugins/link/plugin.js new file mode 100644 index 00000000..f208e8ee --- /dev/null +++ b/src/extension/plugins/link/plugin.js @@ -0,0 +1,8 @@ +const withLink = (editor) => { + const { insertBreak, insertText, deleteBackward } = editor; + const newEditor = editor; + + return newEditor; +}; + +export default withLink; 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 }) => { + From 6249ef9cb7b3c68bae9b542e6c0ebce426917d33 Mon Sep 17 00:00:00 2001 From: liuhongbo <916196375@qq.com> Date: Wed, 27 Sep 2023 15:35:42 +0800 Subject: [PATCH 2/6] feat: link-plugin 80% --- public/locales/en/seafile-editor.json | 3 + src/extension/constants/index.js | 6 + src/extension/plugins/index.js | 3 + src/extension/plugins/link/helper.js | 152 ++++++++++++++++-- src/extension/plugins/link/index.js | 3 +- src/extension/plugins/link/menu/index.js | 89 +++++++--- src/extension/plugins/link/menu/link-modal.js | 110 +++++++++++++ src/extension/plugins/link/plugin.js | 54 ++++++- .../plugins/link/render-elem/index.js | 57 +++++++ .../plugins/link/render-elem/link-popover.js | 73 +++++++++ .../plugins/link/render-elem/style.css | 74 +++++++++ src/extension/render/render-element.js | 6 +- src/utils/common.js | 26 +++ src/utils/event-bus.js | 6 + 14 files changed, 627 insertions(+), 35 deletions(-) create mode 100644 src/extension/plugins/link/menu/link-modal.js create mode 100644 src/extension/plugins/link/render-elem/index.js create mode 100644 src/extension/plugins/link/render-elem/link-popover.js create mode 100644 src/extension/plugins/link/render-elem/style.css diff --git a/public/locales/en/seafile-editor.json b/public/locales/en/seafile-editor.json index caa50455..a0a33db5 100644 --- a/public/locales/en/seafile-editor.json +++ b/public/locales/en/seafile-editor.json @@ -137,6 +137,9 @@ "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", "userHelp": { "title": "Keyboard shortcuts", "userHelpData": [ 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 index faeba906..0c8b6578 100644 --- a/src/extension/plugins/link/helper.js +++ b/src/extension/plugins/link/helper.js @@ -1,16 +1,144 @@ -import { Editor } from 'slate' -import { getNodeType } from '../../core/queries'; -import { LINK } from '../../constants/element-types'; +import { Editor, Path, Range, Transforms } from 'slate'; +import slugid from 'slugid'; +import isUrl from 'is-url'; +import { findPath, getAboveNode, getEditorString, getNodeType } 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 isDisabled = (editor: Editor,readonly) => { - if(readonly) return true; +export const isMenuDisabled = (editor, readonly = false) => { + if (readonly) return true; return false; -} +}; -export const isActive = (editor:Editor) => { - 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; + 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); + Transforms.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] }); + return; + } + + if (!selection) 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(' '); + Transforms.insertNodes(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([{ id: slugid.nice(), text: ' ' }]); + 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 }); + Transforms.collapse(editor, { edge: 'end' }); + } + } + focusEditor(editor); +}; + +export const getLinkInfo = (editor) => { + const [match] = Editor.nodes(editor, { + match: n => getNodeType(n) === ELementTypes.LINK, + universal: true, + }); + if (!match) return null; + const [node, path] = match; + return { + linkUrl: node.url, + linkTitle: 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; -export const isLinkType = (node) => { - return getNodeType(node) === LINK; -} \ No newline at end of file + 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 9e8c8be5..450fedc0 100644 --- a/src/extension/plugins/link/index.js +++ b/src/extension/plugins/link/index.js @@ -1,13 +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: [], + renderElements: [renderLink], }; export default LinkPlugin; diff --git a/src/extension/plugins/link/menu/index.js b/src/extension/plugins/link/menu/index.js index 83bc7491..c6aeb0ad 100644 --- a/src/extension/plugins/link/menu/index.js +++ b/src/extension/plugins/link/menu/index.js @@ -1,31 +1,80 @@ -import React, { useMemo } from 'react' -import MenuItem from '../../../commons/menu/menu-item' -import { MENUS_CONFIG_MAP } from '../../../constants/menus-config' -import { LINK } from '../../../constants/element-types' -import { isActive, isDisabled } from '../helper' +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 { getLinkInfo, isLinkType, isMenuDisabled, unWrapLinkNode } from '../helper'; +import EventBus from '../../../../utils/event-bus'; +import LinkModal from './link-modal'; -const menuConfig = MENUS_CONFIG_MAP[LINK] +const menuConfig = MENUS_CONFIG_MAP[LINK]; const LinkMenu = (props) => { const { isRichEditor, className, readonly, editor } = props; + const [isOpenLinkModal, setIsOpenLinkModal] = useState(false); + const [linkInfo, setLinkInfo] = useState({ linkTitle: '', linkUrl: '' }); + const isLinkActive = useMemo(() => isLinkType(editor), [editor.selection]); + 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]); + + useEffect(() => { + const eventBus = EventBus.getInstance(); + eventBus.subscribe('openLinkModal', handleOpenLinkModal); + return () => { + const eventBus = EventBus.getInstance(); + eventBus.unSubscribe('openLinkModal') + console.log('eventBus.subscribers', eventBus.subscribers) + } + }, []) + + const handleOpenLinkModal = useCallback((linkInfo) => { + Reflect.ownKeys.length && setLinkInfo(linkInfo); + setIsOpenLinkModal(true); + }, [setIsOpenLinkModal, setLinkInfo]); const onMouseDown = (event) => { - console.log('LinkMenu.onMouseDown'); - // const active = isActive(editor); - // setBlockQuoteType(editor, active); + event.preventDefault(); + event.stopPropagation(); + if (isLinkActive) { + isLinkActive && unWrapLinkNode(editor); + return; + } + setIsOpenLinkModal(true); + document.getElementById(`seafile_${LINK}`).blur(); + }; + + const onCloseModal = () => { + 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..11023c88 --- /dev/null +++ b/src/extension/plugins/link/menu/link-modal.js @@ -0,0 +1,110 @@ +import React, { 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 { Editor } from 'slate'; +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 = () => { + 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 = (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 === 0) return Promise.reject('Link_title_required'); + } + return Promise.resolve(); + }; + + const onFormValueChange = (e) => { + const formItemName = e.target.name; + const formItemValue = e.target.value; + validateFormData(formItemName, formItemValue).then( + () => setValidatorErrorMessage({ ...validatorErrorMessage, [formItemName]: '' }), + (errMsg) => setValidatorErrorMessage({ ...validatorErrorMessage, [formItemName]: errMsg }) + ); + setFormData({ ...formData, [formItemName]: formItemValue }); + }; + + const onSubmit = (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(); + }; + + const onKeydown = (e) => { + if (e.key === 'Enter') { + onSubmit(e); + } + }; + + return ( + + {t('Insert_link')} + +
+ + + {/* `onChange={() => void 0}` to fix reactstrap error which need `onChange` when `value` setteled, (`onChange` has been listened at `` )*/} + void 0} value={formData.linkUrl} invalid={!!validatorErrorMessage.linkUrl} name='linkUrl' innerRef={linkAddressRef} type='url' id='linkUrl' /> + {t(validatorErrorMessage.linkUrl)} + + + + {/* `onChange={() => void 0}` to fix reactstrap error which need `onChange` when `value` setteled, (`onChange` has been listened at `` )*/} + 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 index f208e8ee..6b57b019 100644 --- a/src/extension/plugins/link/plugin.js +++ b/src/extension/plugins/link/plugin.js @@ -1,7 +1,59 @@ +import { Editor, Node, Range, Transforms } from 'slate'; +import slugid from 'slugid'; +import { generateDefaultText } from '../../core/utils'; +import { getNodeType, getSelectedNodeByType } from '../../core/queries'; +import { generateLinkNode } from './helper'; +import { LINK } from '../../constants/element-types'; +import { isImage, isUrl } from '../../../utils/common'; + const withLink = (editor) => { - const { insertBreak, insertText, deleteBackward } = editor; + const { normalizeNode, isInline, insertData, insertText } = editor; const newEditor = editor; + // Rewrite isInline + newEditor.isInline = elem => { + const { type } = elem; + if (type === 'link') return true; + return isInline(elem); + }; + + newEditor.insertText = (text) => { + const path = Editor.path(editor, editor.selection); + if (Range.isCollapsed(editor.selection) && getSelectedNodeByType(editor, LINK) && Editor.isEnd(editor, editor.selection.focus, path)) { + editor.insertFragment([generateDefaultText()]); + 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); + Transforms.insertNodes(newEditor, [link, { id: slugid.nice(), text: ' ' }]); + return; + } + insertData(data); + }; + + // 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; }; 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..e86ee876 --- /dev/null +++ b/src/extension/plugins/link/render-elem/index.js @@ -0,0 +1,57 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import React, { useCallback, useState } from 'react'; +import classNames from 'classnames'; +import LinkPopover from './link-popover'; + +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 onOpenPopover = (e) => { + e.stopPropagation(); + 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(); + }; + + const registerClickEvent = () => { + document.addEventListener('click', onClosePopover); + }; + + const unRegisterClickEvent = () => { + document.removeEventListener('click', onClosePopover); + }; + + 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..9629a1fe --- /dev/null +++ b/src/extension/plugins/link/render-elem/link-popover.js @@ -0,0 +1,73 @@ +import React, { 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 './style.css'; + +const LinkPopover = ({ linkUrl, onClosePopover, popoverPosition, editor }) => { + useEffect(() => { + return () => { + // unregister click event before unmount + onClosePopover(); + }; + }, [onClosePopover]); + + const onLinkClick = (e) => { + if (!isUrl(linkUrl)) { + e.preventDefault(); + } + }; + + const onUnwrapLink = (e) => { + e.stopPropagation(); + unWrapLinkNode(editor); + }; + + const onEditLink = (e) => { + e.stopPropagation(); + const { linkTitle, linkUrl } = getLinkInfo(editor); + const eventBus = EventBus.getInstance(); + eventBus.dispatch('openLinkModal', { linkTitle: linkTitle, linkUrl: linkUrl }); + }; + + 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..16387525 --- /dev/null +++ b/src/extension/plugins/link/render-elem/style.css @@ -0,0 +1,74 @@ + +.virtual-link { + color: #eb8205; +} + +.virtual-link:hover { + text-decoration: underline; +} + +.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; +} + +.link-op-menu .link-op-menu-triangle { + width: 8px; + height: 8px; + transform: rotate(45deg); + background: #fff; + border-right: 1px solid rgba(0, 40, 100, 0.12); + border-bottom: 1px solid rgba(0, 40, 100, 0.12); + position: absolute; + top: 31px; + right: 50%; + z-index: 1001; +} + +.link-op-menu-link { + font-size: 12px; + color: #212529; + padding: 0 5px; + border-radius: 2px; + line-height: 20px; +} + +.link-op-menu-link:hover { + color: #212529; + text-decoration: none; + background: #f1f1f1; +} + +.link-op-icons { + margin-left: 8px; + border-left: 1px solid #e5e5e5; +} + +.link-op-icon { + color: #999999; + padding: 4px; + border-radius: 2px; + margin-left: 8px; + display: flex; + align-items: center; +} + +.link-op-icon .sdocfont { + font-size: 12px; + color: #444; +} + +.link-op-icon:hover { + background: #f2f2f2; +} + +.seafile-ed-hovermenu-mouseclick { + background-color: #e5e5e5; +} 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/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; +}; diff --git a/src/utils/event-bus.js b/src/utils/event-bus.js index f70c85d7..cbb1525d 100644 --- a/src/utils/event-bus.js +++ b/src/utils/event-bus.js @@ -32,6 +32,12 @@ class EventBus { handlers.forEach(handler => handler(...data)); } } + + unSubscribe(type) { + if (this.subscribers[type]) { + delete this.subscribers[type]; + } + } } export default EventBus; From 8102cb630bc95f3b6e5cfd5324d08efbd96cedaa Mon Sep 17 00:00:00 2001 From: liuhongbo <916196375@qq.com> Date: Thu, 28 Sep 2023 15:21:14 +0800 Subject: [PATCH 3/6] feat: link-plugin --- public/locales/en/seafile-editor.json | 1 + src/extension/commons/menu/menu-item.js | 1 - src/extension/plugins/link/helper.js | 35 +++++++++++---- src/extension/plugins/link/menu/index.js | 22 ++++----- src/extension/plugins/link/menu/link-modal.js | 39 +++++++++++++--- src/extension/plugins/link/plugin.js | 45 ++++++++++++++----- .../plugins/link/render-elem/index.js | 14 ++++-- .../plugins/link/render-elem/link-popover.js | 9 +++- .../plugins/link/render-elem/style.css | 4 ++ 9 files changed, 128 insertions(+), 42 deletions(-) diff --git a/public/locales/en/seafile-editor.json b/public/locales/en/seafile-editor.json index a0a33db5..f8021fdb 100644 --- a/public/locales/en/seafile-editor.json +++ b/public/locales/en/seafile-editor.json @@ -140,6 +140,7 @@ "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": [ 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/plugins/link/helper.js b/src/extension/plugins/link/helper.js index 0c8b6578..d176768a 100644 --- a/src/extension/plugins/link/helper.js +++ b/src/extension/plugins/link/helper.js @@ -1,7 +1,6 @@ import { Editor, Path, Range, Transforms } from 'slate'; import slugid from 'slugid'; -import isUrl from 'is-url'; -import { findPath, getAboveNode, getEditorString, getNodeType } from '../../core/queries'; +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'; @@ -9,6 +8,17 @@ 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; }; @@ -32,7 +42,7 @@ export const generateLinkNode = (url, title) => { }; /** - * @param {Object} props + * @param {Object} props * @param {Object} props.editor * @param {String} props.url * @param {String} props.title @@ -45,6 +55,8 @@ export const insertLink = (props) => { 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) { @@ -53,7 +65,7 @@ export const insertLink = (props) => { if (slateNode && slateNode?.type === ELementTypes.LIST_ITEM) { path = findPath(editor, slateNode, []); const nextPath = Path.next(path); - Transforms.insertNodes(editor, linkNode, { at: nextPath }); + Editor.insertNodes(editor, linkNode, { at: nextPath }); return; } @@ -61,17 +73,19 @@ export const insertLink = (props) => { // 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; } - if (!selection) 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(' '); - Transforms.insertNodes(editor, linkNode); + 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([{ id: slugid.nice(), text: ' ' }]); + Editor.insertFragment(editor, [{ id: slugid.nice(), text: ' ' }]); + + focusEditor(editor); return; } else { const selectedText = Editor.string(editor, selection); // Selected text @@ -81,7 +95,7 @@ export const insertLink = (props) => { 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 }); + Transforms.wrapNodes(editor, linkNode, { split: true, at: selection }); Transforms.collapse(editor, { edge: 'end' }); } } @@ -89,15 +103,18 @@ export const insertLink = (props) => { }; 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: node.title, + linkTitle: showedText || node.title, path: path, }; }; diff --git a/src/extension/plugins/link/menu/index.js b/src/extension/plugins/link/menu/index.js index c6aeb0ad..0f0bcf63 100644 --- a/src/extension/plugins/link/menu/index.js +++ b/src/extension/plugins/link/menu/index.js @@ -3,17 +3,18 @@ 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 { getLinkInfo, isLinkType, isMenuDisabled, unWrapLinkNode } from '../helper'; +import { isLinkType, isMenuDisabled, unWrapLinkNode } from '../helper'; import EventBus from '../../../../utils/event-bus'; import LinkModal from './link-modal'; const menuConfig = MENUS_CONFIG_MAP[LINK]; -const LinkMenu = (props) => { - const { isRichEditor, className, readonly, editor } = props; +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(() => { if (isLinkType(editor)) { const newTitle = editor.selection && Editor.string(editor, editor.selection); @@ -27,10 +28,10 @@ const LinkMenu = (props) => { eventBus.subscribe('openLinkModal', handleOpenLinkModal); return () => { const eventBus = EventBus.getInstance(); - eventBus.unSubscribe('openLinkModal') - console.log('eventBus.subscribers', eventBus.subscribers) - } - }, []) + eventBus.unSubscribe('openLinkModal'); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const handleOpenLinkModal = useCallback((linkInfo) => { Reflect.ownKeys.length && setLinkInfo(linkInfo); @@ -44,6 +45,10 @@ const LinkMenu = (props) => { 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(); }; @@ -52,9 +57,6 @@ const LinkMenu = (props) => { setIsOpenLinkModal(false); setLinkInfo({ linkTitle: '', linkUrl: '' }); }; - - - return ( <> { @@ -35,14 +34,24 @@ const LinkModal = ({ editor, onCloseModal, linkTitle, linkUrl }) => { if (!isUrl(formItemValue)) return Promise.reject('Link_address_invalid'); } if (formItemName === 'linkTitle') { - if (formItemValue.length === 0) return Promise.reject('Link_title_required'); + 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 = (formItemName, formItemValue) => { + if (formItemName === 'linkUrl') { + return formItemValue.trim(); + } + return formItemValue; + }; + const onFormValueChange = (e) => { const formItemName = e.target.name; - const formItemValue = e.target.value; + 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 }) @@ -80,14 +89,30 @@ const LinkModal = ({ editor, onCloseModal, linkTitle, linkUrl }) => {
- {/* `onChange={() => void 0}` to fix reactstrap error which need `onChange` when `value` setteled, (`onChange` has been listened at `` )*/} - void 0} value={formData.linkUrl} invalid={!!validatorErrorMessage.linkUrl} name='linkUrl' innerRef={linkAddressRef} type='url' id='linkUrl' /> + 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)} - {/* `onChange={() => void 0}` to fix reactstrap error which need `onChange` when `value` setteled, (`onChange` has been listened at `` )*/} - void 0} value={formData.linkTitle} invalid={!!validatorErrorMessage.linkTitle} name='linkTitle' id='linkTitle' /> + 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)}
diff --git a/src/extension/plugins/link/plugin.js b/src/extension/plugins/link/plugin.js index 6b57b019..75bfa275 100644 --- a/src/extension/plugins/link/plugin.js +++ b/src/extension/plugins/link/plugin.js @@ -1,26 +1,32 @@ -import { Editor, Node, Range, Transforms } from 'slate'; +import { Editor, Node, Transforms, Range, Path } from 'slate'; import slugid from 'slugid'; -import { generateDefaultText } from '../../core/utils'; import { getNodeType, getSelectedNodeByType } from '../../core/queries'; -import { generateLinkNode } from './helper'; +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 { normalizeNode, isInline, insertData, insertText } = editor; + const { isInline, deleteBackward, insertText, normalizeNode, insertData } = editor; const newEditor = editor; // Rewrite isInline newEditor.isInline = elem => { const { type } = elem; - if (type === 'link') return true; + 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); - if (Range.isCollapsed(editor.selection) && getSelectedNodeByType(editor, LINK) && Editor.isEnd(editor, editor.selection.focus, path)) { - editor.insertFragment([generateDefaultText()]); + 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); @@ -31,16 +37,36 @@ const withLink = (editor) => { const text = data.getData('text/plain'); if (isUrl(text) && !isImage(text)) { const link = generateLinkNode(text, text); - Transforms.insertNodes(newEditor, [link, { id: slugid.nice(), 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 (type !== LINK) { // If the type is not link, perform default normalizeNode return normalizeNode([node, path]); } @@ -50,7 +76,6 @@ const withLink = (editor) => { if (str === '') { return Transforms.removeNodes(newEditor, { at: path }); } - return normalizeNode([node, path]); }; diff --git a/src/extension/plugins/link/render-elem/index.js b/src/extension/plugins/link/render-elem/index.js index e86ee876..7ce31a78 100644 --- a/src/extension/plugins/link/render-elem/index.js +++ b/src/extension/plugins/link/render-elem/index.js @@ -2,6 +2,8 @@ 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 './style.css'; @@ -17,6 +19,12 @@ const renderLink = ({ attributes, children, element }, editor) => { const onOpenPopover = (e) => { e.stopPropagation(); + // Only on popover can be open at the same time, close other popover and update new popover controller function. + const eventBus = EventBus.getInstance(); + eventBus.dispatch('closeLinkPopover'); + eventBus.subscribe('closeLinkPopover', () => 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); @@ -34,10 +42,10 @@ const renderLink = ({ attributes, children, element }, editor) => { }; return ( - + <> {children} @@ -50,7 +58,7 @@ const renderLink = ({ attributes, children, element }, editor) => { editor={editor} /> )} - + ); }; diff --git a/src/extension/plugins/link/render-elem/link-popover.js b/src/extension/plugins/link/render-elem/link-popover.js index 9629a1fe..9d275623 100644 --- a/src/extension/plugins/link/render-elem/link-popover.js +++ b/src/extension/plugins/link/render-elem/link-popover.js @@ -28,9 +28,14 @@ const LinkPopover = ({ linkUrl, onClosePopover, popoverPosition, editor }) => { const onEditLink = (e) => { e.stopPropagation(); - const { linkTitle, linkUrl } = getLinkInfo(editor); + const linkNode = getLinkInfo(editor); + if (!linkNode) { + onClosePopover(); + return; + } + const { linkTitle, linkUrl } = linkNode; const eventBus = EventBus.getInstance(); - eventBus.dispatch('openLinkModal', { linkTitle: linkTitle, linkUrl: linkUrl }); + eventBus.dispatch('openLinkModal', { linkTitle, linkUrl }); }; return ( diff --git a/src/extension/plugins/link/render-elem/style.css b/src/extension/plugins/link/render-elem/style.css index 16387525..ee5887ac 100644 --- a/src/extension/plugins/link/render-elem/style.css +++ b/src/extension/plugins/link/render-elem/style.css @@ -7,6 +7,10 @@ text-decoration: underline; } +.virtual-link.selected { + background-color: #e5e5e5; +} + .link-op-menu { height: 36px; padding: 7px 8px; From d2c0247d5423cf72584f6072cb7417d57fc37033 Mon Sep 17 00:00:00 2001 From: liuhongbo <916196375@qq.com> Date: Sat, 7 Oct 2023 14:52:06 +0800 Subject: [PATCH 4/6] refactor: optimize code --- public/locales/en/seafile-editor.json | 8 ++-- src/constants/event-types.js | 2 + src/extension/plugins/link/menu/index.js | 27 ++++++------- src/extension/plugins/link/menu/link-modal.js | 26 ++++++------ src/extension/plugins/link/menu/style.css | 0 .../plugins/link/render-elem/index.js | 31 +++++++------- .../plugins/link/render-elem/link-popover.js | 27 +++++++------ .../plugins/link/render-elem/style.css | 40 +++++-------------- src/utils/event-bus.js | 6 --- 9 files changed, 73 insertions(+), 94 deletions(-) delete mode 100644 src/extension/plugins/link/menu/style.css diff --git a/public/locales/en/seafile-editor.json b/public/locales/en/seafile-editor.json index f8021fdb..dd5f70b8 100644 --- a/public/locales/en/seafile-editor.json +++ b/public/locales/en/seafile-editor.json @@ -137,10 +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", + "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": [ diff --git a/src/constants/event-types.js b/src/constants/event-types.js index 99a5b1c1..c477964f 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/plugins/link/menu/index.js b/src/extension/plugins/link/menu/index.js index 0f0bcf63..0041519e 100644 --- a/src/extension/plugins/link/menu/index.js +++ b/src/extension/plugins/link/menu/index.js @@ -6,6 +6,7 @@ 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]; @@ -15,6 +16,13 @@ const LinkMenu = ({ isRichEditor, className, readonly, editor }) => { // eslint-disable-next-line react-hooks/exhaustive-deps const isLinkActive = useMemo(() => isLinkType(editor), [editor.selection]); + useEffect(() => { + const eventBus = EventBus.getInstance(); + const unsubscript = eventBus.subscribe(INTERNAL_EVENTS.ON_OPEN_LINK_POPOVER, handleOpenLinkModal); + return () => unsubscript(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { if (isLinkType(editor)) { const newTitle = editor.selection && Editor.string(editor, editor.selection); @@ -23,22 +31,12 @@ const LinkMenu = ({ isRichEditor, className, readonly, editor }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [editor.selection]); - useEffect(() => { - const eventBus = EventBus.getInstance(); - eventBus.subscribe('openLinkModal', handleOpenLinkModal); - return () => { - const eventBus = EventBus.getInstance(); - eventBus.unSubscribe('openLinkModal'); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const handleOpenLinkModal = useCallback((linkInfo) => { Reflect.ownKeys.length && setLinkInfo(linkInfo); setIsOpenLinkModal(true); }, [setIsOpenLinkModal, setLinkInfo]); - const onMouseDown = (event) => { + const onMouseDown = useCallback((event) => { event.preventDefault(); event.stopPropagation(); if (isLinkActive) { @@ -51,12 +49,13 @@ const LinkMenu = ({ isRichEditor, className, readonly, editor }) => { } setIsOpenLinkModal(true); document.getElementById(`seafile_${LINK}`).blur(); - }; + }, [editor, isLinkActive, linkInfo]); - const onCloseModal = () => { + const onCloseModal = useCallback(() => { setIsOpenLinkModal(false); setLinkInfo({ linkTitle: '', linkUrl: '' }); - }; + }, []); + return ( <> { return false; }, [formData, validatorErrorMessage]); - const onOpened = () => { + 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 = (formItemName, formItemValue) => { + 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'); @@ -38,16 +38,16 @@ const LinkModal = ({ editor, onCloseModal, linkTitle, linkUrl }) => { if (!formItemValue.trim().length) return Promise.reject('Blank_title_not_allowed'); } return Promise.resolve(); - }; + }, []); - const preProcessBeforeOnchange = (formItemName, formItemValue) => { + const preProcessBeforeOnchange = useCallback((formItemName, formItemValue) => { if (formItemName === 'linkUrl') { return formItemValue.trim(); } return formItemValue; - }; + }, []); - const onFormValueChange = (e) => { + const onFormValueChange = useCallback((e) => { const formItemName = e.target.name; let formItemValue = e.target.value; // pre-process form item value @@ -57,9 +57,9 @@ const LinkModal = ({ editor, onCloseModal, linkTitle, linkUrl }) => { (errMsg) => setValidatorErrorMessage({ ...validatorErrorMessage, [formItemName]: errMsg }) ); setFormData({ ...formData, [formItemName]: formItemValue }); - }; + }, [formData, preProcessBeforeOnchange, validateFormData, validatorErrorMessage]); - const onSubmit = (e) => { + const onSubmit = useCallback((e) => { // re-validate form data before submit Object.entries(formData) .forEach(([key, value]) => validateFormData(key, value) @@ -74,13 +74,13 @@ const LinkModal = ({ editor, onCloseModal, linkTitle, linkUrl }) => { } e.preventDefault(); e.stopPropagation(); - }; + }, [editor, formData, isSubmitDisabled, onCloseModal, validateFormData]); - const onKeydown = (e) => { + const onKeydown = useCallback((e) => { if (e.key === 'Enter') { onSubmit(e); } - }; + }, [onSubmit]); return ( diff --git a/src/extension/plugins/link/menu/style.css b/src/extension/plugins/link/menu/style.css deleted file mode 100644 index e69de29b..00000000 diff --git a/src/extension/plugins/link/render-elem/index.js b/src/extension/plugins/link/render-elem/index.js index 7ce31a78..d3ffd430 100644 --- a/src/extension/plugins/link/render-elem/index.js +++ b/src/extension/plugins/link/render-elem/index.js @@ -4,6 +4,7 @@ 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'; @@ -12,17 +13,25 @@ const renderLink = ({ attributes, children, element }, editor) => { const [popoverPosition, setPopoverPosition] = useState({ top: 0, left: 0 }); const onClosePopover = useCallback((e) => { - unRegisterClickEvent(); + unregisterClickEvent(); setIsShowPopover(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [setPopoverPosition]); - const onOpenPopover = (e) => { + const registerClickEvent = useCallback(() => { + document.addEventListener('click', onClosePopover); + }, [onClosePopover]); + + const unregisterClickEvent = useCallback(() => { + document.removeEventListener('click', onClosePopover); + }, [onClosePopover]); + + const onOpenPopover = useCallback((e) => { e.stopPropagation(); - // Only on popover can be open at the same time, close other popover and update new popover controller function. + // 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('closeLinkPopover'); - eventBus.subscribe('closeLinkPopover', () => setIsShowPopover(false)); + 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(); @@ -31,21 +40,13 @@ const renderLink = ({ attributes, children, element }, editor) => { setPopoverPosition({ top: popoverTop, left: popoverLeft }); setIsShowPopover(true); registerClickEvent(); - }; - - const registerClickEvent = () => { - document.addEventListener('click', onClosePopover); - }; - - const unRegisterClickEvent = () => { - document.removeEventListener('click', onClosePopover); - }; + }, [editor, registerClickEvent]); return ( <> {children} diff --git a/src/extension/plugins/link/render-elem/link-popover.js b/src/extension/plugins/link/render-elem/link-popover.js index 9d275623..f8a61164 100644 --- a/src/extension/plugins/link/render-elem/link-popover.js +++ b/src/extension/plugins/link/render-elem/link-popover.js @@ -1,9 +1,10 @@ -import React, { useEffect } from 'react'; +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'; @@ -15,18 +16,18 @@ const LinkPopover = ({ linkUrl, onClosePopover, popoverPosition, editor }) => { }; }, [onClosePopover]); - const onLinkClick = (e) => { + const onLinkClick = useCallback((e) => { if (!isUrl(linkUrl)) { e.preventDefault(); } - }; + }, [linkUrl]); - const onUnwrapLink = (e) => { + const onUnwrapLink = useCallback((e) => { e.stopPropagation(); unWrapLinkNode(editor); - }; + }, [editor]); - const onEditLink = (e) => { + const onEditLink = useCallback((e) => { e.stopPropagation(); const linkNode = getLinkInfo(editor); if (!linkNode) { @@ -35,15 +36,15 @@ const LinkPopover = ({ linkUrl, onClosePopover, popoverPosition, editor }) => { } const { linkTitle, linkUrl } = linkNode; const eventBus = EventBus.getInstance(); - eventBus.dispatch('openLinkModal', { linkTitle, linkUrl }); - }; + eventBus.dispatch(INTERNAL_EVENTS.ON_OPEN_LINK_POPOVER, { linkTitle, linkUrl }); + }, [editor, onClosePopover]); return ( <> {createPortal(