From 912354ae3f5bc81b44a64540b6f1f8bf12b29515 Mon Sep 17 00:00:00 2001
From: Gaagul Gigi <70290286+gaagul@users.noreply.github.com>
Date: Wed, 18 Oct 2023 02:21:14 +0530
Subject: [PATCH] Created a popover for adding links to editor content (#904)
* Created a popover for adding links to editor content
* Removed unwanted translations
* Optimized function calls
* Fixed all styling related issues
* Fixed bugs
* Reduced length of link edit input
* Updated z index and ui gaps
* Updated positioning logic
* Updated ousideClick logic for link view popOver
---
src/components/Editor/LinkPopOver.jsx | 16 +-
.../Editor/Menu/Bubble/TableOption.jsx | 6 +-
.../Editor/Menu/Fixed/LinkAddPopOver.jsx | 178 +++++++++++++++
.../Editor/Menu/Fixed/LinkOption.jsx | 137 ------------
.../Editor/Menu/Fixed/TableActions.jsx | 6 +-
src/components/Editor/Menu/Fixed/constants.js | 192 -----------------
src/components/Editor/Menu/Fixed/index.jsx | 14 +-
src/components/Editor/Menu/Fixed/utils.jsx | 203 +++++++++++++++++-
src/components/Editor/Menu/Headless/utils.js | 166 +++++++++++++-
src/components/Editor/utils.js | 14 ++
src/icons/FileAttachments.jsx | 26 ---
src/styles/editor/_link-popover.scss | 38 ++--
src/translations/en.json | 6 +-
13 files changed, 597 insertions(+), 405 deletions(-)
create mode 100644 src/components/Editor/Menu/Fixed/LinkAddPopOver.jsx
delete mode 100644 src/components/Editor/Menu/Fixed/LinkOption.jsx
delete mode 100644 src/icons/FileAttachments.jsx
diff --git a/src/components/Editor/LinkPopOver.jsx b/src/components/Editor/LinkPopOver.jsx
index ad220996..bca041d3 100644
--- a/src/components/Editor/LinkPopOver.jsx
+++ b/src/components/Editor/LinkPopOver.jsx
@@ -1,10 +1,11 @@
import React, { useEffect, useState, useRef } from "react";
+import { useOnClickOutside } from "neetocommons/react-utils";
import { Button, Input } from "neetoui";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
-import { URL_REGEXP } from "src/common/constants";
+import { validateAndFormatUrl } from "./utils";
const LinkPopOver = ({ editor }) => {
const [popoverPosition, setPopoverPosition] = useState({ top: 0, left: 0 });
@@ -48,12 +49,14 @@ const LinkPopOver = ({ editor }) => {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
const handleLink = () => {
- if (URL_REGEXP.test(urlString)) {
+ const formattedUrl = validateAndFormatUrl(urlString);
+
+ if (formattedUrl) {
editor
.chain()
.focus()
.extendMarkRange("link")
- .setLink({ href: urlString })
+ .setLink({ href: formattedUrl })
.run();
setIsEditing(false);
} else {
@@ -79,6 +82,8 @@ const LinkPopOver = ({ editor }) => {
setIsLinkActive(false);
};
+ useOnClickOutside(popOverRef, removePopover);
+
useEffect(() => {
window.addEventListener("resize", removePopover);
window.addEventListener("wheel", removePopover);
@@ -103,7 +108,6 @@ const LinkPopOver = ({ editor }) => {
position: "fixed",
top: popoverPosition.top,
left: popoverPosition.left,
- zIndex: 999,
transform: `translateY(52px) translateX(${isEditing ? "8px" : "3px"})`,
};
@@ -114,7 +118,7 @@ const LinkPopOver = ({ editor }) => {
{...{ error }}
label={t("menu.link")}
placeholder={t("placeholders.url")}
- style={{ width: "400px" }}
+ style={{ width: "250px" }}
value={urlString}
onChange={({ target: { value } }) => setUrlString(value)}
onFocus={() => setError("")}
@@ -163,7 +167,7 @@ const LinkPopOver = ({ editor }) => {
isLinkActive ? (
diff --git a/src/components/Editor/Menu/Bubble/TableOption.jsx b/src/components/Editor/Menu/Bubble/TableOption.jsx
index 38f9b3f7..10ea6605 100644
--- a/src/components/Editor/Menu/Bubble/TableOption.jsx
+++ b/src/components/Editor/Menu/Bubble/TableOption.jsx
@@ -5,7 +5,7 @@ import { Check, Close } from "neetoicons";
import { Button, Dropdown } from "neetoui";
import { useTranslation } from "react-i18next";
-import { TABLE_ACTIONS } from "../Fixed/constants";
+import { tableActions } from "../Fixed/utils";
const TableOption = ({ editor, handleClose }) => {
const { t } = useTranslation();
@@ -65,8 +65,8 @@ const TableOption = ({ editor, handleClose }) => {
>
) : (
)}
diff --git a/src/components/Editor/Menu/Fixed/LinkAddPopOver.jsx b/src/components/Editor/Menu/Fixed/LinkAddPopOver.jsx
new file mode 100644
index 00000000..a194b952
--- /dev/null
+++ b/src/components/Editor/Menu/Fixed/LinkAddPopOver.jsx
@@ -0,0 +1,178 @@
+import React, { useState, useEffect, useRef } from "react";
+
+import { useOnClickOutside } from "neetocommons/react-utils";
+import { Button, Input } from "neetoui";
+import { createPortal } from "react-dom";
+import { useTranslation } from "react-i18next";
+
+import { validateAndFormatUrl } from "components/Editor/utils";
+import { URL_REGEXP } from "src/common/constants";
+import { isNilOrEmpty } from "utils/common";
+
+const LinkAddPopOver = ({ isAddLinkActive, setIsAddLinkActive, editor }) => {
+ const { from, to } = editor.state.selection;
+ const text = editor.state.doc.textBetween(from, to, "");
+
+ const [linkText, setLinkText] = useState(text);
+ const [linkUrl, setLinkUrl] = useState("");
+ const [error, setError] = useState("");
+ const [popoverPosition, setPopoverPosition] = useState({ top: 0, left: 0 });
+ const [arrowPosition, setArrowPosition] = useState({ top: 0, left: 0 });
+
+ const popOverRef = useRef(null);
+
+ const { t } = useTranslation();
+
+ const isLinkTextPresent = !isNilOrEmpty(linkText);
+ const isLinlUrlPresent = !isNilOrEmpty(linkUrl);
+ const isSubmitDisabled = !isLinkTextPresent || !isLinlUrlPresent;
+
+ const popoverStyle = {
+ display: "block",
+ position: "fixed",
+ top: popoverPosition.top,
+ left: popoverPosition.left,
+ transform: "translateY(52px) translateX(8px)",
+ };
+
+ const handleAddLink = () => {
+ const { state, dispatch } = editor.view;
+ const { from, to } = state.selection;
+ const formattedUrl = validateAndFormatUrl(linkUrl);
+
+ if (!URL_REGEXP.test(formattedUrl)) {
+ setError(t("error.invalidUrl"));
+
+ return;
+ }
+ const attrs = { href: formattedUrl };
+
+ const linkMark = state.schema.marks.link.create(attrs);
+ const linkTextWithMark = state.schema.text(linkText, [linkMark]);
+
+ const tr = state.tr.replaceWith(from, to, linkTextWithMark);
+ dispatch(tr);
+ removePopover();
+ };
+
+ const removePopover = () => {
+ editor.view.focus();
+ setIsAddLinkActive(false);
+ };
+
+ const handleKeyDown = e => {
+ e.stopPropagation();
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleAddLink();
+ } else if (e.key === "Escape") {
+ removePopover();
+ }
+ };
+
+ const updatePopoverPosition = () => {
+ if (popOverRef.current) {
+ const newPos = editor.view.coordsAtPos(
+ editor.view.state.selection.$to.pos
+ );
+
+ setArrowPosition({
+ top: `${newPos.top + 20}px`,
+ left: `${newPos.left - 10}px`,
+ });
+
+ const popoverRect = popOverRef.current?.getBoundingClientRect();
+
+ const screenWidth = window.innerWidth;
+ const screenHeight = window.innerHeight;
+
+ const maxLeft = screenWidth - popoverRect.width;
+ const maxTop = screenHeight - popoverRect.height - 50;
+
+ const adjustedLeft = newPos?.left
+ ? Math.min(newPos.left - 50, maxLeft)
+ : 0;
+ const adjustedTop = newPos?.top ? Math.min(newPos.top - 22, maxTop) : 0;
+
+ setPopoverPosition({
+ top: `${adjustedTop}px`,
+ left: `${adjustedLeft}px`,
+ });
+ }
+ };
+
+ useOnClickOutside(popOverRef, removePopover);
+
+ useEffect(() => {
+ if (editor && isAddLinkActive) {
+ updatePopoverPosition();
+ }
+ window.addEventListener("resize", removePopover);
+ window.addEventListener("wheel", removePopover);
+
+ return () => {
+ window.removeEventListener("resize", removePopover);
+ window.removeEventListener("wheel", removePopover);
+ };
+ }, []);
+
+ return isAddLinkActive
+ ? createPortal(
+ <>
+
+
+ >,
+ document.body
+ )
+ : null;
+};
+
+export default LinkAddPopOver;
diff --git a/src/components/Editor/Menu/Fixed/LinkOption.jsx b/src/components/Editor/Menu/Fixed/LinkOption.jsx
deleted file mode 100644
index e03d6c77..00000000
--- a/src/components/Editor/Menu/Fixed/LinkOption.jsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import React, { useEffect, useState, useRef } from "react";
-
-import { Link } from "neetoicons";
-import { Button, Dropdown, Input } from "neetoui";
-import { min } from "ramda";
-import { useTranslation } from "react-i18next";
-
-import { URL_REGEXP } from "common/constants";
-
-const { Menu } = Dropdown;
-
-const LinkOption = ({ editor, tooltipContent, menuRef }) => {
- const [error, setError] = useState("");
- const [urlString, setUrlString] = useState("");
- const [isOpen, setIsOpen] = useState(false);
-
- const { t } = useTranslation();
-
- const linkOptionRef = useRef(null);
-
- const isActive = editor.isActive("link");
-
- const handleDropDownClick = () => {
- setUrlString(editor.getAttributes("link").href);
- setIsOpen(open => !open);
- };
-
- const handleClose = () => {
- setIsOpen(false);
- setUrlString("");
- setError("");
- };
-
- const handleKeyDown = event => {
- if (event.key === "Escape") {
- handleClose();
- } else if (event.key === "Enter") {
- handleLink();
- }
- };
-
- const handleLink = () => {
- if (URL_REGEXP.test(urlString)) {
- editor
- .chain()
- .focus()
- .extendMarkRange("link")
- .setLink({ href: urlString })
- .run();
- handleClose();
- } else {
- setError(t("error.invalidUrl"));
- }
- };
-
- const handleUnlink = () => {
- editor.chain().focus().extendMarkRange("link").unsetLink().run();
- setUrlString("");
- handleClose();
- };
-
- const handleWidthChange = () => {
- if (!linkOptionRef.current) return;
-
- linkOptionRef.current.style.width = "auto";
- linkOptionRef.current.style.width = `${min(
- 550,
- min(
- menuRef.current?.offsetWidth * 0.5,
- linkOptionRef.current?.scrollWidth
- )
- )}px`;
- };
-
- useEffect(() => {
- editor.commands.setHighlightInternal("#ACCEF7");
-
- if (!isOpen) {
- editor.commands.unsetHighlightInternal();
- editor.commands.removeEmptyTextStyle();
- }
- }, [isOpen, editor]);
-
- useEffect(() => {
- isOpen && handleWidthChange();
- }, [linkOptionRef.current]);
-
- return (
-
-
-
- );
-};
-
-export default LinkOption;
diff --git a/src/components/Editor/Menu/Fixed/TableActions.jsx b/src/components/Editor/Menu/Fixed/TableActions.jsx
index 91dc89e3..a5d66b90 100644
--- a/src/components/Editor/Menu/Fixed/TableActions.jsx
+++ b/src/components/Editor/Menu/Fixed/TableActions.jsx
@@ -3,7 +3,7 @@ import React from "react";
import { Settings } from "neetoicons";
import { Button, Dropdown } from "neetoui";
-import { TABLE_ACTIONS } from "./constants";
+import { tableActions } from "./utils";
const { Menu } = Dropdown;
@@ -25,10 +25,10 @@ const TableActions = ({ editor, tooltipContent }) => {
}}
>
);
};
diff --git a/src/components/Editor/Menu/Fixed/utils.jsx b/src/components/Editor/Menu/Fixed/utils.jsx
index 8b213259..553409fa 100644
--- a/src/components/Editor/Menu/Fixed/utils.jsx
+++ b/src/components/Editor/Menu/Fixed/utils.jsx
@@ -1,11 +1,204 @@
import React from "react";
+import { t } from "i18next";
+import {
+ TextBold,
+ TextItalic,
+ Underline,
+ TextCross,
+ Highlight,
+ Code,
+ ListDot,
+ ListNumber,
+ ImageUpload,
+ Quote,
+ Undo,
+ Redo,
+ MediaVideo,
+ Video,
+ CodeBlock,
+ Attachment,
+ Link,
+} from "neetoicons";
import { Button } from "neetoui";
-import { fromPairs } from "ramda";
+import { fromPairs, not, assoc } from "ramda";
import { generateFocusProps } from "utils/focusHighlighter";
-import { MENU_OPTIONS } from "./constants";
+export const tableActions = ({ editor }) => [
+ {
+ label: t("table.insertRow"),
+ command: () => editor.commands.addRowAfter(),
+ },
+ {
+ label: t("table.insertColumn"),
+ command: () => editor.commands.addColumnAfter(),
+ },
+ {
+ label: t("table.deleteRow"),
+ command: () => editor.chain().focus().deleteRow().run(),
+ },
+ {
+ label: t("table.deleteColumn"),
+ command: () => editor.chain().focus().deleteColumn().run(),
+ },
+ {
+ label: t("table.mergeSplit"),
+ command: () => editor.chain().focus().mergeOrSplit().run(),
+ },
+ {
+ label: t("table.toggleHeaderRow"),
+ command: () => editor.chain().focus().toggleHeaderRow().run(),
+ },
+ {
+ label: t("table.toggleHeaderColumn"),
+ command: () => editor.chain().focus().toggleHeaderColumn().run(),
+ },
+ {
+ label: t("table.delete"),
+ command: () => editor.commands.deleteTable(),
+ },
+];
+
+export const createMenuOptions = ({
+ tooltips,
+ editor,
+ setMediaUploader,
+ handleUploadAttachments,
+ setIsEmbedModalOpen,
+ setIsAddLinkActive,
+}) => ({
+ font: [
+ {
+ Icon: TextBold,
+ command: () => editor.chain().focus().toggleBold().run(),
+ active: editor.isActive("bold"),
+ optionName: "bold",
+ tooltip: tooltips.bold || t("menu.bold"),
+ },
+ {
+ Icon: TextItalic,
+ command: () => editor.chain().focus().toggleItalic().run(),
+ active: editor.isActive("italic"),
+ optionName: "italic",
+ tooltip: tooltips.italic || t("menu.italic"),
+ },
+ {
+ Icon: Underline,
+ command: () => editor.chain().focus().toggleUnderline().run(),
+ active: editor.isActive("underline"),
+ optionName: "underline",
+ tooltip: tooltips.underline || t("menu.underline"),
+ },
+ {
+ Icon: TextCross,
+ command: () => editor.chain().focus().toggleStrike().run(),
+ active: editor.isActive("strike"),
+ optionName: "strike",
+ tooltip: tooltips.strike || t("menu.strike"),
+ },
+ {
+ Icon: Highlight,
+ command: () => editor.chain().focus().toggleHighlight().run(),
+ active: editor.isActive("highlight"),
+ optionName: "highlight",
+ tooltip: tooltips.highlight || t("menu.highlight"),
+ },
+ ],
+ block: [
+ {
+ Icon: Quote,
+ command: () => editor.chain().focus().toggleBlockquote().run(),
+ active: editor.isActive("blockquote"),
+ optionName: "block-quote",
+ highlight: true,
+ tooltip: tooltips.blockQuote || t("menu.blockQuote"),
+ },
+ {
+ Icon: Code,
+ command: () => editor.chain().focus().toggleCode().run(),
+ active: editor.isActive("code"),
+ optionName: "code",
+ tooltip: tooltips.code || t("menu.code"),
+ },
+ {
+ Icon: CodeBlock,
+ command: () => editor.chain().focus().toggleCodeBlock().run(),
+ active: editor.isActive("codeBlock"),
+ optionName: "code-block",
+ tooltip: tooltips.codeBlock || t("menu.codeBlock"),
+ },
+ ],
+ list: [
+ {
+ Icon: ListDot,
+ command: () => editor.chain().focus().toggleBulletList().run(),
+ active: editor.isActive("bulletList"),
+ optionName: "bullet-list",
+ highlight: true,
+ tooltip: tooltips.bulletList || t("menu.bulletedList"),
+ },
+ {
+ Icon: ListNumber,
+ command: () => editor.chain().focus().toggleOrderedList().run(),
+ active: editor.isActive("orderedList"),
+ optionName: "ordered-list",
+ highlight: true,
+ tooltip: tooltips.orderedList || t("menu.orderedList"),
+ },
+ ],
+ misc: [
+ {
+ Icon: Link,
+ command: () => setIsAddLinkActive(not),
+ optionName: "link",
+ tooltip: "Link",
+ },
+ {
+ Icon: Attachment,
+ command: handleUploadAttachments,
+ active: false,
+ optionName: "attachments",
+ tooltip: tooltips.attachments || t("menu.attachments"),
+ },
+ {
+ Icon: ImageUpload,
+ command: () => setMediaUploader(assoc("image", true)),
+ optionName: "image-upload",
+ tooltip: tooltips.imageUpload || t("menu.imageUpload"),
+ },
+ {
+ Icon: Video,
+ command: () => setMediaUploader(assoc("video", true)),
+ optionName: "video-upload",
+ tooltip: tooltips.videoUpload || t("menu.videoUpload"),
+ },
+ {
+ Icon: MediaVideo,
+ command: () => setIsEmbedModalOpen(true),
+ optionName: "video-embed",
+ tooltip: tooltips.videoEmbed || t("menu.videoEmbed"),
+ },
+ ],
+ right: [
+ {
+ Icon: Undo,
+ command: () => editor.chain().focus().undo().run(),
+ active: false,
+ disabled: !editor.can().undo(),
+ optionName: "undo",
+ tooltip: tooltips.undo || t("menu.undo"),
+ },
+ {
+ Icon: Redo,
+ command: () => editor.chain().focus().redo().run(),
+ active: false,
+ disabled: !editor.can().redo(),
+ optionName: "redo",
+ tooltip: tooltips.redo || t("menu.redo"),
+ },
+ ],
+});
export const buildMenuOptions = ({
tooltips,
@@ -14,13 +207,15 @@ export const buildMenuOptions = ({
setMediaUploader,
handleUploadAttachments,
setIsEmbedModalOpen,
+ setIsAddLinkActive,
}) => {
- const menuOptions = MENU_OPTIONS({
+ const menuOptions = createMenuOptions({
tooltips,
editor,
setMediaUploader,
handleUploadAttachments,
setIsEmbedModalOpen,
+ setIsAddLinkActive,
});
return fromPairs(
@@ -43,7 +238,7 @@ export const renderOptionButton = ({