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