Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Link plugin #23

Merged
merged 6 commits into from
Oct 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading