Skip to content

Commit

Permalink
Merge pull request #23 from seafileltd/link-plugin
Browse files Browse the repository at this point in the history
Link plugin
  • Loading branch information
shuntian authored Oct 8, 2023
2 parents 7abe54b + 5479d5a commit a5e9760
Show file tree
Hide file tree
Showing 16 changed files with 730 additions and 3 deletions.
6 changes: 5 additions & 1 deletion public/locales/en/seafile-editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -216,4 +220,4 @@
},
"Select_field": "Select field",
"Font_style": "Font style"
}
}
2 changes: 2 additions & 0 deletions src/constants/event-types.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
1 change: 0 additions & 1 deletion src/extension/commons/menu/menu-item.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions src/extension/constants/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
3 changes: 3 additions & 0 deletions src/extension/plugins/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ 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,
ParagraphPlugin,
TextPlugin,
HeaderPlugin,
ImagePlugin,
LinkPlugin,

// put at the end
NodeIdPlugin,
Expand All @@ -23,6 +25,7 @@ export {
TextPlugin,
HeaderPlugin,
ImagePlugin,
LinkPlugin,

// put at the end
NodeIdPlugin,
Expand Down
161 changes: 161 additions & 0 deletions src/extension/plugins/link/helper.js
Original file line number Diff line number Diff line change
@@ -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,
});
};
14 changes: 14 additions & 0 deletions src/extension/plugins/link/index.js
Original file line number Diff line number Diff line change
@@ -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;
81 changes: 81 additions & 0 deletions src/extension/plugins/link/menu/index.js
Original file line number Diff line number Diff line change
@@ -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 (
<>
<MenuItem
isRichEditor={isRichEditor}
className={className}
disabled={isMenuDisabled(editor, readonly)}
isActive={isLinkActive}
onMouseDown={onMouseDown}

{...menuConfig}
/>
{isOpenLinkModal && (
<LinkModal
onCloseModal={onCloseModal}
editor={editor}
linkTitle={linkInfo.linkTitle}
linkUrl={linkInfo.linkUrl}
/>)}
</>
);
};

export default LinkMenu;
Loading

0 comments on commit a5e9760

Please sign in to comment.