react-text-editor-kit
Version:
React Text Editor Kit is a customizable rich text editor component for React applications. It provides a flexible and easy-to-use interface for users to create and edit content with various formatting options. And also easy to integrate in react applicati
1,466 lines (1,340 loc) • 87.2 kB
JSX
import React, { useEffect, useRef, useState } from "react";
import "./css/style.css";
import Styles from "./css/style.module.css";
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
BoldIcon,
ClearFormatting,
CodeIcon,
DecreaseIndentIcon,
FullscreenExit,
FullscreenIcon,
HorizontalLineIcon,
ImageIcon,
IncreaseIndentIcon,
ItalicIcon,
LTRIcon,
LinkIcon,
OrderdList,
RTLIcon,
RedoIcon,
SelectAll,
SpecialCharIcon,
SubscriptIcon,
SuperscriptIcon,
UnderlineIcon,
UndoIcon,
UnorderdList,
VideoIcon,
} from "./components";
import SpecialChars from "./components/SpecialChars";
import SelectFormat from "./components/SelectFormat";
import ButtonFunction from "./components/ButtonFunction";
import SelectInsert from "./components/SelectInsert";
import SelectView from "./components/SelectView";
import SelectFileOptions from "./components/SelectFileOptions";
import ViewSourceModel from "./components/ViewSourceModel";
import PreviewModel from "./components/PreviewModel";
import SelectFormations from "./components/SelectFormations";
import ManageColors from "./components/ManageColors";
import SimpleButton from "./components/SimpleButton";
import {
NAVBAR_ITEMS,
remove_resizer,
TOOLBAR_ITEMS,
transformHTML,
} from "./components/constant";
import ViewLoadingModel from "./components/ViewLoadingModel";
import PasteIcon from "./components/SVGImages/PasteIcon";
import CutIcon from "./components/SVGImages/CutIcon";
import CopyIcon from "./components/SVGImages/CopyIcon";
import LinkModal from "./components/LinkModal";
import ImageModal from "./components/ImageModal";
import MediaModal from "./components/MediaModal";
import Modal from "./components/Model";
import RightClickLinkPopup from "./components/RightClickLinkPopup";
import SelectFontFamily from "./components/SelectFontFamily";
import AlignmentOptions from "./components/AlignmentOptions";
import FontSize from "./components/FontSize";
import LineHeight from "./components/LineHeight";
import "react-image-crop/dist/ReactCrop.css";
import { CheckAccessDataApi } from "./DAL/CheckAcces";
import { getBaseDomain } from "./utils/Constants";
import { use } from "react";
const show_final_options = (options, remove, all_options) => {
if (!options) {
options = all_options;
}
if (remove) {
options = options.filter((item) => {
if (typeof item === "string") {
return !remove.includes(item);
} else {
return !remove.includes(item.name);
}
});
// remove | dublication
options = options.filter((item, index) => {
return item !== "|" || index === 0 || options[index - 1] !== "|";
});
}
return options;
};
const isValidURL = (str) => {
try {
// Try to create a URL object - most reliable method
new URL(str);
return true;
} catch (e) {
// If URL constructor fails, try regex pattern
const pattern = new RegExp(
"^(https?://)?" + // protocol
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name
"((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address
"(:\\d+)?(/[-a-z\\d%_.~+]*)*" + // port and path
"(\\?[;&a-z\\d%_.~+=-]*)?" + // query string
"(\\#[-a-z\\d_]*)?$",
"i",
);
return pattern.test(str);
}
};
export default function ReactEditorKit(props) {
let {
theme_config,
toolbar,
navbar,
value,
onChange,
getEditorRef,
mainProps,
placeholder,
image_handler,
handleFullScreen,
remove_from_toolbar,
remove_from_navbar,
style,
apiKey,
height,
...others
} = props;
const editorRef = useRef(null);
const [viewSource, setViewSource] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [openPreview, setOpenPreview] = useState(false);
const [init, setInit] = useState(false);
const [sourceCode, setSourceCode] = useState(null);
const [isFullScreen, setIsFullScreen] = useState(false);
const [imageUrl, setImageUrl] = useState("");
const [isOpenModel, setIsOpenModel] = useState("");
const [targetElement, setTargetElement] = useState(null);
const [targetElementType, setTargetElementType] = useState(null);
const [previewContent, setPreviewContent] = useState("");
const [isDisable, setIsDisable] = useState(false);
const [allowPaste, setAllowPaste] = useState(true);
const [selectedData, setSelectedData] = useState({
link: "",
height: "",
width: "",
type: "general",
text: "",
open_new_tab: false,
});
const [selectedEvent, setSelectedEvent] = useState(null);
const [isPlaceholder, setIsPlaceholder] = useState(true);
const [selectedItem, setSelectedItem] = useState({});
const [selectedRange, setSelectedRange] = useState(null);
const [showHR1, setShowHR1] = useState(false);
const [showHR2, setShowHR2] = useState(false);
const [showHR3, setShowHR3] = useState(false);
const checkIfImageExists = () => {
const editor = editorRef?.current;
if (editor) {
const imgTag = editor.querySelector("img");
if (imgTag) {
return true;
} else {
return false;
}
}
return false;
};
const handleInput = () => {
console.log("Input event triggered");
//if isDisable is true set editor content to empty and contentEditable to false
if (isDisable && editorRef.current) {
editorRef.current.innerHTML = "";
editorRef.current.setAttribute("contentEditable", "false");
return;
}
setInit(true);
const editor = editorRef?.current;
if (!editor) return;
let isImageExists = checkIfImageExists();
if (!isImageExists) {
let element = document.querySelector(".resizeImageWrapper");
if (element) {
element.parentNode.removeChild(element);
}
setSelectedEvent(null);
}
// --- CAREFUL WRAPPING OF FIRST LINE ---
const hasOnlyTextOrBR = [...editor.childNodes].every(
(node) =>
node.nodeType === Node.TEXT_NODE ||
(node.nodeType === Node.ELEMENT_NODE && node.nodeName === "BR"),
);
if (hasOnlyTextOrBR && editor.textContent.trim() !== "") {
const selection = window.getSelection();
const range = selection?.getRangeAt(0);
// Save caret position relative to start
const preCaretRange = range?.cloneRange();
preCaretRange?.selectNodeContents(editor);
preCaretRange?.setEnd(range.startContainer, range.startOffset);
const caretOffset = preCaretRange?.toString().length || 0;
// Wrap text in a <p>
const wrapper = document.createElement("p");
while (editor.firstChild) {
wrapper.appendChild(editor.firstChild);
}
editor.appendChild(wrapper);
// Restore caret
let newNode = wrapper.firstChild;
let offset = caretOffset;
// Traverse to the correct offset
const setCaret = (node, offset) => {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(node, offset);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
};
const traverse = (node, depth = 0) => {
// Prevent infinite recursion
if (depth > 100) {
console.warn("Maximum traversal depth reached");
return;
}
if (node.nodeType === Node.TEXT_NODE) {
if (offset <= node.length) {
setCaret(node, offset);
throw "done"; // stop traversal
} else {
offset -= node.length;
}
} else {
for (let i = 0; i < node.childNodes.length; i++) {
traverse(node.childNodes[i], depth + 1);
}
}
};
try {
traverse(wrapper);
} catch (e) {}
}
function clearEditorFormatting(editor, tempDiv) {
// Always clear to truly empty string
editor.setAttribute("data-mlx-editor-empty", "true");
// Save current selection
const selection = window.getSelection();
const range = document.createRange();
// Completely clear the editor
range.selectNodeContents(editor);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
editor.innerHTML = "";
document.execCommand("removeFormat", false, null);
// Reset any formatting
// Optional: Reset any CSS styles
editor.style.cssText = "";
}
// Usage:
let content = editor.innerHTML;
content = transformHTML(content);
const tempDiv = document.createElement("div");
tempDiv.innerHTML = content;
let checkEditorIsEmptyAndGetTagName = isEditorEmpty(content);
// First check if the content is "effectively empty"
// Allow spaces at the beginning - don't strip them when checking for empty content
const hasOnlySpaces =
tempDiv.textContent &&
tempDiv.textContent.replace(/[\s\u00A0]/g, "").length === 0 &&
tempDiv.textContent.length > 0;
const isEffectivelyEmpty =
(!tempDiv.textContent || tempDiv.textContent.length === 0) &&
checkEditorIsEmptyAndGetTagName.isEmpty;
if (isEffectivelyEmpty) {
clearEditorFormatting(editor, checkEditorIsEmptyAndGetTagName.tempDiv);
onChange?.("");
} else {
editor.removeAttribute("data-mlx-editor-empty");
onChange?.(tempDiv.innerHTML);
}
// Update placeholder state after content changes
handlePlaceholder();
};
function isEditorEmpty(html) {
// Create a temporary DOM element to parse and inspect the structure
const temp = document.createElement("div");
temp.innerHTML = html;
// Remove only completely empty text nodes, but preserve those with spaces
temp.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE && node.textContent === "") {
temp.removeChild(node);
}
});
// Case 1: Only a single <br> or empty
if (
temp.children.length === 0 ||
temp.innerHTML === "<br>" ||
temp.innerHTML === ""
) {
let pTagDiv = document.createElement("P");
pTagDiv.innerHTML = "<br>";
return { tempDiv: pTagDiv, isEmpty: true };
}
// Case 2: Only a <p> with a <br> inside (ignore styles)
if (
temp.children.length === 1 &&
temp.children[0].children.length === 1 &&
temp.children[0].children[0].tagName === "BR"
) {
return { tempDiv: temp.children[0], isEmpty: true };
}
// Case 3: Only a <p> </p>, <p> </p> (unicode nbsp), or <p> </p> (treat as empty)
if (temp.children.length === 1 && temp.children[0].tagName === "P") {
const inner = temp.children[0].innerHTML;
// Check for HTML , unicode nbsp, or only whitespace
if (inner === " " || inner === "\u00A0") {
let pTagDiv = document.createElement("P");
pTagDiv.innerHTML = "<br>";
return { tempDiv: pTagDiv, isEmpty: true };
}
}
return { isEmpty: false };
}
// Helper function to clone formatting from a text node's parent elements
const cloneFormatting = (textNode, textContent) => {
const formattingElements = [
"B",
"STRONG",
"I",
"EM",
"U",
"S",
"STRIKE",
"SUB",
"SUP",
"SPAN",
];
let currentNode = textNode.parentNode;
let formattedElement = document.createTextNode(textContent);
// Traverse up the DOM tree to find formatting elements
while (currentNode && currentNode !== editorRef.current) {
if (formattingElements.includes(currentNode.nodeName)) {
const clonedElement = currentNode.cloneNode(false); // Clone without children
clonedElement.appendChild(formattedElement);
formattedElement = clonedElement;
}
currentNode = currentNode.parentNode;
}
return formattedElement;
};
const handleEditorKeyDown = (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
const editor = editorRef.current;
const selection = window.getSelection();
if (!selection.rangeCount || !editor) return;
const range = selection.getRangeAt(0);
let currentNode = range.startContainer;
let listItemNode = null;
// Traverse up to find the nearest LI element
while (currentNode && currentNode !== editor) {
if (currentNode.nodeName === "LI") {
listItemNode = currentNode;
break;
}
currentNode = currentNode.parentNode;
}
if (listItemNode) {
// Create new LI
const newLI = document.createElement("li");
newLI.appendChild(document.createElement("br"));
// Insert after current LI
if (listItemNode.nextSibling) {
listItemNode.parentNode.insertBefore(newLI, listItemNode.nextSibling);
} else {
listItemNode.parentNode.appendChild(newLI);
}
// Move cursor into new LI
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(newLI);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return;
}
// Step 1: Find the containing block element (P, H1, H2, etc.)
let currentBlock = null;
let container = range.startContainer;
// Traverse up to find existing block element
while (container && container !== editor) {
if (
container.nodeName === "P" ||
container.nodeName === "H1" ||
container.nodeName === "H2" ||
container.nodeName === "H3" ||
container.nodeName === "H4" ||
container.nodeName === "H5" ||
container.nodeName === "H6" ||
container.nodeName === "DIV"
) {
currentBlock = container;
break;
}
container = container.parentNode;
}
// If no block element found, create a paragraph
if (!currentBlock) {
currentBlock = document.createElement("p");
// Move all content into the paragraph
while (editor.firstChild) {
currentBlock.appendChild(editor.firstChild);
}
editor.appendChild(currentBlock);
// Set cursor at the end of the new paragraph
const newRange = document.createRange();
newRange.selectNodeContents(currentBlock);
newRange.collapse(false);
selection.removeAllRanges();
selection.addRange(newRange);
return;
}
// Step 2: Determine what tag to create for the new line
let newTagName = "p"; // Default to paragraph
// Check if current block is a heading tag
if (
currentBlock.nodeName === "H1" ||
currentBlock.nodeName === "H2" ||
currentBlock.nodeName === "H3" ||
currentBlock.nodeName === "H4" ||
currentBlock.nodeName === "H5" ||
currentBlock.nodeName === "H6"
) {
// Check if there's text after the cursor position
const offset = range.startOffset;
const textContainer = range.startContainer;
let hasTextAfterCursor = false;
if (textContainer.nodeType === Node.TEXT_NODE) {
const text = textContainer.nodeValue;
const afterText = text.slice(offset);
hasTextAfterCursor = afterText.trim() !== "";
} else {
// If cursor is at element boundary, check if there are more text nodes after
let nextNode = textContainer.nextSibling;
while (nextNode) {
if (
nextNode.nodeType === Node.TEXT_NODE &&
nextNode.textContent.trim() !== ""
) {
hasTextAfterCursor = true;
break;
}
nextNode = nextNode.nextSibling;
}
}
if (hasTextAfterCursor) {
// If there's text after cursor, create same type of heading
newTagName = currentBlock.nodeName.toLowerCase();
} else {
// If no text after cursor, always create paragraph
newTagName = "p";
}
}
// Step 3: Split the block at cursor position
const offset = range.startOffset;
const textContainer = range.startContainer;
// Create new block element for the split content
const newBlock = document.createElement(newTagName);
if (textContainer.nodeType === Node.TEXT_NODE) {
const text = textContainer.nodeValue;
const beforeText = text.slice(0, offset);
const afterText = text.slice(offset);
// Update current text node with text before cursor
textContainer.nodeValue = beforeText;
// Add text after cursor to new block with preserved formatting
if (afterText.trim()) {
// Clone the formatting from the current text node's parent elements
const formattedText = cloneFormatting(textContainer, afterText);
newBlock.appendChild(formattedText);
} else {
newBlock.appendChild(document.createElement("br"));
}
} else {
// If cursor is at element boundary, just add a br
newBlock.appendChild(document.createElement("br"));
}
// Step 4: Insert new block after currecnt one
const parent = currentBlock.parentNode;
if (parent && parent.contains(currentBlock)) {
if (currentBlock.nextSibling) {
parent.insertBefore(newBlock, currentBlock.nextSibling);
} else {
parent.appendChild(newBlock);
}
}
// Step 5: Position cursor at start of new block
const newRange = document.createRange();
if (
newBlock.firstChild &&
newBlock.firstChild.nodeType === Node.TEXT_NODE
) {
newRange.setStart(newBlock.firstChild, 0);
} else {
newRange.setStart(newBlock, 0);
}
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
}
};
const handleOpenModel = (e, type, item) => {
if (isDisable) {
return;
}
e.preventDefault();
setIsOpenModel(type);
setSelectedItem(item);
};
const handleCloseModel = (e) => {
if (e) {
e.preventDefault();
}
setImageUrl("");
setTargetElement("");
setIsOpenModel("");
setSelectedData({});
setSelectedEvent(null);
};
const handleSaveSource = (e) => {
e.preventDefault();
if (editorRef?.current) {
const trimmedSourceCode = sourceCode
.replace(/\n\s*\n/g, "\n")
.replace(/\s+/g, " ")
.trim();
editorRef.current.innerHTML = trimmedSourceCode;
setViewSource(false);
if (onChange) {
onChange(trimmedSourceCode);
}
// Update placeholder after setting content (ensure DOM is updated first)
setTimeout(() => {
handlePlaceholder();
}, 1000);
}
};
const handleSelectAll = (e) => {
if (isDisable) {
return;
}
e.preventDefault();
if (!editorRef?.current) {
console.warn("Editor not available for select all");
return;
}
try {
const selection = window.getSelection();
if (!selection.toString()) {
const range = document.createRange();
range.selectNodeContents(editorRef.current);
selection.removeAllRanges();
selection.addRange(range);
} else {
selection.removeAllRanges();
}
} catch (error) {
console.warn("Select all failed:", error);
}
};
const handleInsertHRClick = () => {
if (isDisable) {
return;
}
handleFocusEditor();
document.execCommand("insertHorizontalRule");
};
const handleFocusEditor = () => {
const editor = editorRef?.current;
if (editor && selectedRange) {
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(selectedRange);
editor.focus();
}
};
const handleLinkInsert = (props) => {
let { text, link, open_new_tab, link_type } = props;
if (!text) {
text = link;
}
let linkHTML = `<a href="${link}"`;
if (open_new_tab && open_new_tab !== "false") {
linkHTML += ' target="_blank"';
}
if (link_type === "image" && imageUrl) {
// Use selectedEvent if available; otherwise, fall back to default image HTML
if (selectedEvent) {
if (selectedEvent.tagName === "IMG") {
let src = selectedEvent.src;
if (src === imageUrl) {
text = selectedEvent.outerHTML;
} else {
text = `<img src="${imageUrl}" alt="ImageLink" />`;
}
} else if (selectedEvent.tagName === "A") {
let childNode = selectedEvent.firstChild;
if (
childNode &&
childNode.nodeType === Node.ELEMENT_NODE &&
childNode.tagName === "IMG" &&
childNode.src === imageUrl
) {
text = childNode.outerHTML;
} else {
text = `<img src="${imageUrl}" alt="ImageLink" />`;
}
} else {
text = `<img src="${imageUrl}" alt="ImageLink" />`;
}
} else {
text = `<img src="${imageUrl}" alt="ImageLink" />`;
}
} else if (link_type === "button") {
text = `<button contentEditable=false>${text}</button>`;
}
linkHTML += `>${text}</a>`;
if (selectedEvent && selectedEvent.parentElement) {
selectedEvent.parentElement.removeChild(selectedEvent);
} else {
restoreSelection();
}
handleFocusEditor();
document.execCommand("insertHTML", false, linkHTML);
handleCloseModel();
};
const handleRemoveLink = () => {
let linkHTML = `${selectedEvent.textContent.trim()}`;
if (selectedEvent.tagName === "IMG") {
linkHTML = `<img src="${selectedEvent.src}" alt="ImageLink" />`;
}
if (selectedEvent) {
const parentElement = selectedEvent.parentElement;
if (parentElement) {
parentElement.removeChild(selectedEvent);
}
}
handleFocusEditor();
document.execCommand("insertHTML", false, linkHTML);
handleCloseModel();
};
const handleImageInsert = (data) => {
let { link, height, width } = data;
if (selectedEvent && selectedData) {
selectedEvent.src = link;
if (height) {
selectedEvent.style.height = `${height}px`;
} else {
selectedEvent.style.height = null;
}
if (width) {
selectedEvent.style.width = `${width}px`;
} else {
selectedEvent.style.width = null;
}
setTimeout(() => {
setIsLoading(false);
handleCloseModel();
// Update placeholder after image update
handlePlaceholder();
}, 0);
} else {
handleFocusEditor();
let imgElement = `<img src="${link}" alt="Image" style="`;
if (height) {
imgElement += `height:${height}px;`;
}
if (width) {
imgElement += `width:${width}px;`;
}
imgElement += `"/>`;
document.execCommand("insertHTML", false, imgElement);
// Ensure editor picks up the change
const editorElement = document.querySelector('[contenteditable="true"]');
if (editorElement) {
editorElement.dispatchEvent(new Event("input", { bubbles: true }));
}
setIsLoading(false);
handleCloseModel();
// Update placeholder after image insertion
handlePlaceholder();
}
};
const handleMediaInsert = (data, targetElement) => {
let { link, height, width, type, embed_code } = data;
const editorNode = editorRef.current;
let iframeHTML = "";
if (type === "general") {
// Check if it's a direct video link
if (link.match(/\.(mp4|mov|avi|wmv|webm|mkv|flv)$/i)) {
iframeHTML = `<video width="${width || "640"}" height="${
height || "360"
}" controls><source src="${link}" type="video/mp4"></video>`;
} else {
// Check for specific video platforms
const youtubeRegex =
/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:[^\/\n\s]+\/\S+\/|(?:v|e(?:mbed)?)\/|\S*?[?&]v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
const vimeoRegex = /(?:https?:\/\/)?(?:www\.)?vimeo.com\/(\d+)/;
const dailymotionRegex =
/(?:https?:\/\/)?(?:www\.)?dailymotion\.com\/video\/([a-z0-9]+)/i;
const twitchRegex =
/(?:https?:\/\/)?(?:www\.)?twitch\.tv\/([a-zA-Z0-9_]+)/;
if (link.match(youtubeRegex)) {
const videoId = link.match(youtubeRegex)[1];
iframeHTML = `<iframe width="${width || "640"}" height="${
height || "360"
}" src="https://www.youtube.com/embed/${videoId}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
} else if (link.match(vimeoRegex)) {
const videoId = link.match(vimeoRegex)[1];
iframeHTML = `<iframe src="https://player.vimeo.com/video/${videoId}" width="${
width || "640"
}" height="${
height || "360"
}" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
} else if (link.match(dailymotionRegex)) {
const videoId = link.match(dailymotionRegex)[1];
iframeHTML = `<iframe width="${width || "640"}" height="${
height || "360"
}" src="https://www.dailymotion.com/embed/video/${videoId}" frameborder="0" allow="autoplay; fullscreen" allowfullscreen></iframe>`;
} else if (link.match(twitchRegex)) {
const channelName = link.match(twitchRegex)[1];
iframeHTML = `<iframe src="https://twitch.tv/embed/${channelName}/chat?parent=example.com" width="${width || "640"}" height="${
height || "360"
}" frameborder="0" allow="autoplay; fullscreen" allowfullscreen></iframe>`;
} else if (isValidURL(link)) {
// For any other valid URL, create an iframe
iframeHTML = `<iframe width="${width || "640"}" height="${
height || "360"
}" src="${link}" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
} else {
// Insert custom embed code if available
iframeHTML = embed_code || "";
}
}
} else if (type === "embed" && embed_code && editorNode) {
iframeHTML = embed_code;
}
if (targetElement && editorNode && iframeHTML) {
handleFocusEditor();
targetElement.parentNode.setAttribute("data-mtl-link-type", type);
targetElement.outerHTML = iframeHTML;
// Trigger change event after updating content
setTimeout(() => {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = editorNode.innerHTML;
onChange?.(tempDiv.innerHTML);
handlePlaceholder();
}, 0);
} else if (editorNode && iframeHTML) {
const wrapper = createIframeWrapperWithSettings(
iframeHTML,
type,
(wrapper) => {
let iframe_element = wrapper.querySelector("iframe");
setTargetElement(iframe_element);
setTargetElementType(
wrapper.getAttribute("data-mtl-link-type") || "general",
);
setIsOpenModel("video");
},
);
handleFocusEditor();
// document.execCommand("insertHTML", false, wrapper.outerHTML);
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(wrapper);
// Move cursor after the inserted element
range.setStartAfter(wrapper);
range.setEndAfter(wrapper);
selection.removeAllRanges();
selection.addRange(range);
// Trigger change event after inserting content
setTimeout(() => {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = editorNode.innerHTML;
onChange?.(tempDiv.innerHTML);
handlePlaceholder();
}, 0);
}
setTargetElement(null);
setIsOpenModel(""); // Assuming this is setting some state related to the modal
};
function createIframeWrapperWithSettings(iframeHTML, type, onSettingsClick) {
const iframeElement = document.createElement("div");
iframeElement.innerHTML = iframeHTML;
// Create wrapper
const wrapper = document.createElement("div");
wrapper.className = "iframe-wrapper";
wrapper.contentEditable = "true";
wrapper.setAttribute("data-mtl-link-type", type);
const overlay = document.createElement("div");
overlay.className = "iframe-overlay";
const settingsBtn = document.createElement("button");
settingsBtn.className = "iframe-settings-btn";
settingsBtn.contentEditable = "false";
settingsBtn.innerHTML = `<div style="display: flex; align-items: center;"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M10.275 22q-.425 0-.75-.275t-.375-.7l-.3-2.225q-.325-.125-.612-.3t-.563-.375l-1.55.65q-.625.275-1.25.05t-.975-.8l-1.175-2.05q-.35-.575-.2-1.225t.675-1.075l1.325-1Q4.5 12.5 4.5 12.337v-.675q0-.162.025-.337l-1.325-1Q2.675 9.9 2.525 9.25t.2-1.225L3.9 5.975q.35-.575.975-.8t1.25.05l1.55.65q.275-.2.575-.375t.6-.3l.2-1.65q.075-.675.575-1.113T10.8 2h2.4q.675 0 1.175.438t.575 1.112l.2 1.65q.325.125.613.3t.562.375l1.5-.65q.625-.275 1.263-.05t.987.8l1.175 2.05q.35.575.213 1.225t-.663 1.075L19.125 11.6q-.275.2-.562.3t-.638.1h-2.35q0-1.45-1.037-2.475T12.05 8.5q-1.475 0-2.488 1.013T8.55 12q0 1.2.688 2.1T11 15.35v5.8q0 .35-.2.6t-.525.25M20 22h-6q-.425 0-.712-.288T13 21v-6q0-.425.288-.712T14 14h6q.425 0 .713.288T21 15v2l1.575-1.575q.125-.125.275-.062t.15.237v4.8q0 .175-.15.238t-.275-.063L21 19v2q0 .425-.288.713T20 22"/></svg> <span style="margin-inline: 8px;">Settings</span></div>`;
settingsBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (typeof onSettingsClick === "function") {
onSettingsClick(wrapper);
}
});
// Build DOM structure
wrapper.appendChild(overlay);
wrapper.appendChild(settingsBtn);
// Add null check for iframe element
const iframeChild = iframeElement.childNodes[0];
if (iframeChild) {
wrapper.appendChild(iframeChild);
} else {
console.warn("No iframe element found to append");
}
return wrapper;
}
const handlePrint = () => {
if (!editorRef.current) {
console.warn("Editor not available for printing");
return;
}
let data = editorRef.current.innerHTML;
const iframe = document.createElement("iframe");
iframe.style.display = "none";
document.body.appendChild(iframe);
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
try {
iframeDoc.write(data);
iframeDoc.close();
iframe.contentWindow.print();
} catch (error) {
console.error("Print failed:", error);
}
setTimeout(() => {
if (document.body.contains(iframe)) {
document.body.removeChild(iframe);
}
}, 100);
};
const handleCharSelect = (e, char) => {
e.preventDefault();
if (editorRef?.current !== null) {
handleFocusEditor();
document.execCommand("insertHTML", false, char);
setTargetElement(null);
setIsOpenModel("");
// Update placeholder after character insertion
handlePlaceholder();
}
};
const cleanHTML = (html) => {
// Create a temporary div to parse HTML
const tempDiv = document.createElement("div");
tempDiv.innerHTML = html;
// Function to clean styles from an element
const cleanStyles = (element) => {
if (element.style) {
// Get computed styles
const color = element.style.color;
const fontWeight = element.style.fontWeight;
const fontStyle = element.style.fontStyle;
const textDecoration = element.style.textDecoration;
const fontSize = element.style.fontSize;
const fontFamily = element.style.fontFamily;
// Clear all styles
element.removeAttribute("style");
// Re-apply only the styles we want to keep
if (color) element.style.color = color;
if (fontWeight) element.style.fontWeight = fontWeight;
if (fontStyle) element.style.fontStyle = fontStyle;
if (textDecoration) element.style.textDecoration = textDecoration;
if (fontSize) element.style.fontSize = fontSize;
if (fontFamily) element.style.fontFamily = fontFamily;
}
// Recursively clean child elements
Array.from(element.children).forEach((child) => cleanStyles(child));
};
// Clean all elements
Array.from(tempDiv.children).forEach((element) => cleanStyles(element));
return tempDiv.innerHTML;
};
const onPaste = (event) => {
event.preventDefault();
// Check if modern clipboard API is available and in secure context
if (
navigator.clipboard &&
navigator.clipboard.read &&
window.isSecureContext
) {
navigator.clipboard
.read()
.then((clipboardItems) => {
clipboardItems.forEach((item) => {
if (
item.types.includes("image/png") ||
item.types.includes("image/jpeg")
) {
item
.getType(item.types[0])
.then((imageBlob) => {
const imgElement = `<img src="${URL.createObjectURL(
imageBlob,
)}" alt="Image">`;
document.execCommand("insertHTML", false, imgElement);
})
.catch((error) => {
console.error("Error reading image content:", error);
});
} else if (item.types.includes("text/html")) {
item
.getType("text/html")
.then((htmlBlob) => {
htmlBlob
.text()
.then((htmlContent) => {
const cleanedHTML = cleanHTML(htmlContent);
const withoutComments = cleanedHTML.replace(
/<!--[\s\S]*?-->/g,
"",
);
document.execCommand(
"insertHTML",
false,
withoutComments,
);
})
.catch((error) => {
console.error("Error reading HTML content:", error);
});
})
.catch((error) => {
console.error(
"Error getting HTML type from ClipboardItem:",
error,
);
});
} else if (item.types.includes("text/plain")) {
item
.getType("text/plain")
.then((textBlob) => {
textBlob
.text()
.then((text) => {
if (isValidURL(text)) {
// Insert the URL as a link
const linkElement = `<a href="${text}" target="_blank">${text}</a>`;
document.execCommand("insertHTML", false, linkElement);
} else {
// Insert plain text
document.execCommand("insertText", false, text);
}
})
.catch((error) => {
console.error("Error reading text content:", error);
});
})
.catch((error) => {
console.error(
"Error getting text type from ClipboardItem:",
error,
);
});
}
});
})
.catch((error) => {
console.error("Error reading clipboard:", error);
// Fallback to traditional paste handling
fallbackPasteHandler(event);
});
} else {
// Fallback for non-secure contexts or when modern API is not available
fallbackPasteHandler(event);
}
};
const fallbackPasteHandler = (event) => {
try {
// Use the traditional clipboardData API as fallback
const clipboardData = event.clipboardData || window.clipboardData;
if (clipboardData) {
// Check for text data
const text =
clipboardData.getData("text/plain") || clipboardData.getData("text");
const html = clipboardData.getData("text/html");
if (html) {
// Process HTML content
const cleanedHTML = cleanHTML(html);
const withoutComments = cleanedHTML.replace(/<!--[\s\S]*?-->/g, "");
document.execCommand("insertHTML", false, withoutComments);
} else if (text) {
// Process plain text
if (isValidURL(text)) {
// Insert the URL as a link
const linkElement = `<a href="${text}" target="_blank">${text}</a>`;
document.execCommand("insertHTML", false, linkElement);
} else {
// Insert plain text
document.execCommand("insertText", false, text);
}
}
// Check for files (images)
const files = clipboardData.files;
if (files && files.length > 0) {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith("image/")) {
const imgElement = `<img src="${URL.createObjectURL(
file,
)}" alt="Image">`;
document.execCommand("insertHTML", false, imgElement);
}
}
}
} else {
console.warn(
"No clipboard data available, trying navigator.clipboard.readText as fallback",
);
// Try navigator.clipboard.readText() for text-only content
if (navigator.clipboard && navigator.clipboard.readText) {
navigator.clipboard
.readText()
.then((text) => {
if (text) {
if (isValidURL(text)) {
const linkElement = `<a href="${text}" target="_blank">${text}</a>`;
document.execCommand("insertHTML", false, linkElement);
} else {
document.execCommand("insertText", false, text);
}
}
})
.catch((error) => {
console.warn("navigator.clipboard.readText failed:", error);
});
}
}
} catch (error) {
console.warn("Fallback paste handler failed:", error);
// Try one more fallback with readText
if (navigator.clipboard && navigator.clipboard.readText) {
navigator.clipboard
.readText()
.then((text) => {
if (text) {
if (isValidURL(text)) {
const linkElement = `<a href="${text}" target="_blank">${text}</a>`;
document.execCommand("insertHTML", false, linkElement);
} else {
document.execCommand("insertText", false, text);
}
}
})
.catch((err) => {
console.warn("All paste methods failed:", err);
});
}
}
};
const handleBlur = () => {
handleSelection();
};
const handleNewDocument = () => {
if (editorRef.current) {
editorRef.current.innerHTML = "";
// Update placeholder after clearing content
handlePlaceholder();
}
};
const handlePreview = () => {
setPreviewContent(editorRef?.current.innerHTML);
setOpenPreview(true);
};
const handleViewSource = () => {
if (isDisable) {
return;
}
if (!viewSource && editorRef?.current) {
const content = editorRef?.current.innerHTML;
const formattedContent = transformHTML(content);
setSourceCode(formattedContent);
} else {
setSourceCode("");
}
setViewSource(!viewSource);
};
const toggleFullScreen = () => {
if (isDisable) {
return;
}
setIsFullScreen(!isFullScreen);
};
const handleKeyDown = (event) => {
if (event.key === "Escape") {
setIsFullScreen(false);
}
};
const handlePlaceholder = () => {
const editor = editorRef?.current;
if (!editor) {
return;
}
// Check both innerText and innerHTML to handle all content types
const hasTextContent = editor.innerText.trim() !== "";
const hasHTMLContent =
editor.innerHTML.trim() !== "" &&
editor.innerHTML.trim() !== "<br>" &&
editor.innerHTML.trim() !== "<p><br></p>";
const hasContent = hasTextContent || hasHTMLContent;
if (!hasContent) {
editor.classList.add("empty");
setIsPlaceholder(true);
} else {
editor.classList.remove("empty");
setIsPlaceholder(false);
}
};
const handleSelection = () => {
try {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
setSelectedRange(selection.getRangeAt(0));
}
} catch (error) {
console.warn("Failed to handle selection:", error);
}
};
const restoreSelection = () => {
if (selectedRange) {
try {
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(selectedRange);
} catch (error) {
console.warn("Failed to restore selection:", error);
// Clear invalid selection
setSelectedRange(null);
}
}
};
useEffect(() => {
const handleFullScreenChange = () => {
setIsFullScreen(document.fullscreenElement !== null);
};
handlePlaceholder();
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("input", handlePlaceholder);
document.addEventListener("fullscreenchange", handleFullScreenChange);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("input", handlePlaceholder);
document.removeEventListener("fullscreenchange", handleFullScreenChange);
};
}, []);
const model_component = () => {
if (isOpenModel === "link") {
return {
component: (
<LinkModal
onLinkInsert={handleLinkInsert}
item={selectedItem}
setIsOpenModel={setIsOpenModel}
selectedData={selectedData}
imageUrl={imageUrl}
setImageUrl={setImageUrl}
image_handler={image_handler}
setIsLoading={setIsLoading}
/>
),
title: `${selectedData?.link ? "Update" : "Insert"} Link`,
};
} else if (isOpenModel === "image") {
return {
component: (
<ImageModal
onImageInsert={handleImageInsert}
item={selectedItem}
setIsLoading={setIsLoading}
setIsOpenModel={setIsOpenModel}
image_handler={image_handler}
selectedData={selectedData}
/>
),
title: `${selectedData?.link ? "Update" : "Insert"} Image`,
};
} else if (isOpenModel === "video") {
return {
component: (
<MediaModal
onMediaInsert={handleMediaInsert}
targetElement={targetElement}
targetElementType={targetElementType}
/>
),
title: `${selectedData?.link ? "Update" : "Insert"} Video`,
};
} else if (isOpenModel === "special_char") {
return {
component: <SpecialChars handleCharSelect={handleCharSelect} />,
title: "Insert Special Characters",
};
}
};
if (theme_config && Object.keys(theme_config).length > 0) {
Object.keys(theme_config).forEach(function (key, index) {
document.documentElement.style.setProperty(
`--editor-${key}`,
theme_config[key],
);
});
}
toolbar = show_final_options(toolbar, remove_from_toolbar, TOOLBAR_ITEMS);
navbar = show_final_options(navbar, remove_from_navbar, NAVBAR_ITEMS);
useEffect(() => {
if (!init) {
if (editorRef.current && value) {
editorRef.current.innerHTML = value;
setInit(true);
// Update placeholder after setting initial content
setTimeout(() => handlePlaceholder(), 0);
}
}
if (!value && editorRef.current) {
editorRef.current.innerHTML = "";
// Update placeholder when content is cleared
setTimeout(() => handlePlaceholder(), 0);
}
if (getEditorRef) {
getEditorRef(editorRef);
}
}, [isFullScreen, editorRef, value]);
const handlePaste = (e) => {
if (isDisable) {
return;
}
e.preventDefault();
if (!editorRef.current) {
setTimeout(() => {
if (editorRef.current) {
editorRef.current.focus();
}
}, 0);
} else {
restoreSelection();
}
// Check if modern clipboard API is available and in secure context
if (
navigator.clipboard &&
navigator.clipboard.read &&
window.isSecureContext
) {
navigator.clipboard
.read()
.then((clipboardItems) => {
clipboardItems.forEach((item) => {
if (
item.types.includes("image/png") ||
item.types.includes("image/jpeg")
) {
item
.getType(item.types[0])
.then((imageBlob) => {
const imgElement = `<img src="${URL.createObjectURL(
imageBlob,
)}" alt="Image">`;
document.execCommand("insertHTML", false, imgElement);
})
.catch((error) => {
console.error("Error reading image content:", error);
});
} else if (item.types.includes("text/html")) {
item
.getType("text/html")
.then((htmlBlob) => {
htmlBlob
.text()
.then((htmlContent) => {
const cleanedHTML = cleanHTML(htmlContent);
const withoutComments = cleanedHTML.replace(
/<!--[\s\S]*?-->/g,
"",
);
document.execCommand(
"insertHTML",
false,
withoutComments,
);
})
.catch((error) => {
console.error("Error reading HTML content:", error);
});
})
.catch((error) => {
console.error(
"Error getting HTML type from ClipboardItem:",
error,
);
});
} else if (item.types.includes("text/plain")) {
item
.getType("text/plain")
.then((textBlob) => {
textBlob
.text()
.then((text) => {
if (isValidURL(text)) {
// Insert the URL as a link
const linkElement = `<a href="${text}" target="_blank">${text}</a>`;
document.execCommand("insertHTML", false, linkElement);
} else {
// Insert plain text
document.execCommand("insertText", false, text);
}
})
.catch((error) => {
console.error("Error reading text content:", error);
});
})
.catch((error) => {
console.error(
"Error getting text type from ClipboardItem:",
error,
);
});
}
});
})
.catch((error) => {
console.error("Error reading clipboard:", error);
// Fallback to traditional paste handling
fallbackPasteHandler(e);
});
} else {
// Fallback for non-secure contexts or when modern API is not available
fallbackPasteHandler(e)