strapi-plugin-jodit-editor
Version:
A powerful rich text editor plugin for Strapi v5 using the Jodit Editor with advanced formatting capabilities and seamless integration with Strapi's media library.
620 lines (619 loc) • 22.2 kB
JavaScript
import { jsx, jsxs } from "react/jsx-runtime";
import { memo, useRef, useState, useCallback, useMemo } from "react";
import styled from "styled-components";
import { useIntl } from "react-intl";
import { Field, Loader, Flex } from "@strapi/design-system";
import { useFetchClient, useStrapiApp } from "@strapi/strapi/admin";
import JoditEditor from "jodit-react";
import { Pencil } from "@strapi/icons";
const __variableDynamicImportRuntimeHelper = (glob, path, segs) => {
const v = glob[path];
if (v) {
return typeof v === "function" ? v() : Promise.resolve(v);
}
return new Promise((_, reject) => {
(typeof queueMicrotask === "function" ? queueMicrotask : setTimeout)(
reject.bind(
null,
new Error(
"Unknown variable dynamic import: " + path + (path.split("/").length !== segs ? ". Note that variables only represent file names one level deep." : "")
)
)
);
});
};
const PLUGIN_ID = "jodit-editor";
const STRAPI_MEDIA_BUTTON_NAME = "strapiMedia";
const DEFAULT_BUTTONS = `source, bold, italic, underline, strikethrough, superscript, subscript, eraser, font, fontsize, brush, paragraph, classSpan, |, ul, ol, indent, outdent, left, center, right, justify, |, link, unlink, ${STRAPI_MEDIA_BUTTON_NAME}, image, file, video, table, hr, symbols, lineHeight, |, copy, cut, paste, copyformat, selectall, undo, redo, fullsize, print, preview, find, spellcheck, about`;
const cursorPlaceholder = `current_cursor_placeholder`;
const cursorPlaceholderContent = `<${cursorPlaceholder}></${cursorPlaceholder}>`;
const JoditContainer = styled.div`
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
}
h1 {
font-size: 4rem;
margin-bottom: 1rem;
}
h2 {
font-size: 3.5rem;
margin-bottom: 0.75rem;
}
h3 {
font-size: 3rem;
margin-bottom: 0.5rem;
}
h4, h5, h6 {
font-size: 2rem;
margin-bottom: 0.25rem;
}
p {
margin-bottom: 1rem;
line-height: 1.6;
}
ul, ol {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
ul li, ol li {
margin-bottom: 0.5rem;
}
blockquote {
margin: 1rem 0;
padding-left: 1rem;
border-left: 4px solid #ccc;
color: #666;
}
.jodit-toolbar-button_strapiMedia .jodit-icon {
height: 19px;
width: 19px;
}
`;
const prefixFileUrlWithBackendUrl = (url) => {
return url.startsWith("/") ? `${window.location.origin}${url}` : url;
};
const generateMediaHtml = (file) => {
const { url, alt = "", mime, name = "media" } = file;
if (mime.startsWith("image/")) {
return `<img src="${url}" alt="${alt || name}" />`;
} else if (mime.startsWith("video/")) {
return `<video controls style="max-width: 100%;">
<source src="${url}" type="${mime}">
Your browser does not support the video tag.
</video>`;
} else if (mime.startsWith("audio/")) {
return `<audio controls>
<source src="${url}" type="${mime}">
Your browser does not support the audio tag.
</audio>`;
} else {
return `<a href="${url}" download="${name}" target="_blank">${alt || name}</a>`;
}
};
const IMAGE_SCHEMA_FIELDS = [
"name",
"alternativeText",
"url",
"caption",
"width",
"height",
"formats",
"hash",
"ext",
"mime",
"size",
"previewUrl",
"provider",
"provider_metadata",
"createdAt",
"updatedAt"
];
const pick = (object, keys) => {
const entries = keys.map((key) => [key, object[key]]);
return Object.fromEntries(entries);
};
const MediaLib = ({ isOpen = false, onChange = () => {
}, onToggle = () => {
} }) => {
console.log(useStrapiApp("ImageDialog", (state) => state));
const components = useStrapiApp("ImageDialog", (state) => state.components);
console.log(components);
const ImageDialog = components["media-library"];
console.log(ImageDialog);
const handleSelectAssets = (files) => {
const formattedFiles = files.map((f) => {
const expectedFile = pick(f, IMAGE_SCHEMA_FIELDS);
const nodeFile = {
...expectedFile,
alternativeText: expectedFile.alternativeText || expectedFile.name,
url: prefixFileUrlWithBackendUrl(f.url),
mime: f.mime,
name: f.name
};
return nodeFile;
});
console.log("📎 Jodit: Media library assets selected", formattedFiles);
onChange(formattedFiles);
};
if (!isOpen) {
return null;
}
return /* @__PURE__ */ jsx(
ImageDialog,
{
onClose: onToggle,
onSelectAssets: handleSelectAssets,
allowedTypes: [
"files",
"images",
"videos",
"audios"
]
}
);
};
const JoditInput = ({
name,
value,
onChange,
required = false,
disabled = false,
error,
description,
intlLabel,
attribute,
label,
hint,
placeholder,
fieldSchema,
metadatas
}) => {
const mediaLibButton = {
name: "strapiMedia",
iconURL: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgd2lkdGg9IjMycHgiIGhlaWdodD0iMzJweCIgZmlsbD0iIzIxMjEzNCI+PHBhdGggZD0iTTI3IDVIOWEyIDIgMCAwIDAtMiAydjJINWEyIDIgMCAwIDAtMiAydjE0YTIgMiAwIDAgMCAyIDJoMThhMiAyIDAgMCAwIDItMnYtMmgyYTIgMiAwIDAgMCAyLTJWN2EyIDIgMCAwIDAtMi0ybS01LjUgNGExLjUgMS41IDAgMSAxIDAgMyAxLjUgMS41IDAgMCAxIDAtM00yMyAyNUg1VjExaDJ2MTBhMiAyIDAgMCAwIDIgMmgxNHptNC00SDl2LTQuNWw0LjUtNC41IDYuMjA4IDYuMjA4YTEgMSAwIDAgMCAxLjQxMyAwTDI0LjMzIDE1IDI3IDE3LjY3MnoiPjwvcGF0aD48L3N2Zz4=",
tooltip: "Strapi Media Library",
exec: function(jodit) {
console.log(`📎 Jodit: Open Strapi media library`);
jodit.selection.insertHTML(cursorPlaceholderContent);
const newContent = jodit.value;
onChange({ target: { name, value: newContent.split(cursorPlaceholderContent).join("").trim() } });
toggleMediaLib();
}
};
const { formatMessage } = useIntl();
const { post } = useFetchClient();
const editorRef = useRef(null);
const [mediaLibVisible, setMediaLibVisible] = useState(false);
const [initialValue] = useState(value || "");
const [isLoading, setIsLoading] = useState(false);
const toggleMediaLib = useCallback(() => {
setMediaLibVisible((prev) => !prev);
}, []);
const fileToMediaObject = async (file, handleFileUpload2, webpEnabled2 = []) => {
return new Promise(async (resolve) => {
if (handleFileUpload2) {
try {
console.log("📎 Jodit: Uploading image to media library...");
setIsLoading(true);
let uploadedUrl = await handleFileUpload2(file);
if (uploadedUrl) {
if (webpEnabled2.includes(file.type)) {
console.log("📎 Jodit: WebP conversion enabled, converting image...", file.type);
uploadedUrl = uploadedUrl.replace(`.${file.type.replace("image/", "")}`, ".webp");
}
resolve({
url: uploadedUrl,
alt: file.name,
mime: file.type,
name: file.name
});
setIsLoading(false);
return;
} else {
console.warn("📎 Jodit: Upload failed, falling back to base64");
}
} catch (error2) {
console.error("📎 Jodit: Upload error, falling back to base64:", error2);
}
setIsLoading(false);
}
});
};
const handleMediaLibChange = async (files) => {
const mediaToInsert = files.length ? files.filter((file) => file.mime?.startsWith("image/") || file.mime?.startsWith("video/") || file.mime?.startsWith("audio/")).map((file) => generateMediaHtml(file)).join("") : "";
const jodit = editorRef.current;
const nodeToSelect = jodit?.editor.querySelector(cursorPlaceholder);
if (nodeToSelect) {
jodit?.selection?.setCursorBefore(nodeToSelect);
jodit?.selection.removeNode(nodeToSelect);
}
jodit?.selection.insertHTML(mediaToInsert, true);
setMediaLibVisible(false);
};
const options = attribute?.options || {};
const height = options.height || 400;
const buttons = options.buttons ? options.buttons.split(",").map((btn) => btn.trim()) : DEFAULT_BUTTONS.split(",").map((btn) => btn.trim());
const mediaLibButtonIndex = buttons.findIndex((btn) => btn === STRAPI_MEDIA_BUTTON_NAME);
if (mediaLibButtonIndex !== -1) {
buttons[mediaLibButtonIndex] = mediaLibButton;
}
const removeButtons = options.removeButtons ? options.removeButtons.split(",").map((btn) => btn.trim()) : [];
const showToolbar = options.toolbar !== false;
const fonts = options.fonts ? options.fonts.split("\n").reduce((acc, font) => {
acc[`${font.trim()}`] = font.split(",")[0].trim();
return acc;
}, {}) : {};
const webpEnabled = options.webp !== "" ? options.webp?.split(",") : [];
const handleFileUpload = useCallback(async (file) => {
try {
const formData = new FormData();
formData.append("files", file);
const response = await post("/upload", formData, {
headers: {
"Content-Type": "multipart/form-data"
}
});
if (response.data && response.data.length > 0) {
const uploadedFile = response.data[0];
console.log("Jodit file uploaded successfully:", uploadedFile);
return prefixFileUrlWithBackendUrl(uploadedFile.url);
}
return null;
} catch (error2) {
console.error("Jodit file upload error:", error2);
return null;
}
}, [post]);
const config = useMemo(() => ({
readonly: disabled || options.readonly || false,
height,
toolbar: showToolbar,
placeholder: formatMessage({
id: placeholder || "jodit-editor.placeholder",
defaultMessage: "Start typing..."
}),
style: {
fontFamily: "Helvetica",
fontSize: "14px",
h1: {
fontSize: "24px",
fontWeight: "bold"
}
},
// Toolbar configuration
buttons,
removeButtons,
controls: {
font: {
list: Object.keys(fonts).length > 0 ? fonts : {}
}
},
// Event handlers
events: {
afterInit: function(jodit) {
console.log("📎 Jodit: Editor initialized, storing instance:", jodit);
},
beforeOpen: () => {
console.log("📎 Jodit: Editor opened");
},
// Handle paste events for images, videos, and audio
paste: async (e) => {
const items = e.clipboardData?.items;
const jodit = editorRef.current;
jodit?.selection.insertHTML(cursorPlaceholderContent);
if (items) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith("image/") || item.type.startsWith("video/") || item.type.startsWith("audio/")) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
const mediaObject = await fileToMediaObject(file, handleFileUpload, webpEnabled);
const mediaHtml = generateMediaHtml(mediaObject);
const jodit2 = editorRef.current;
const nodeToSelect = jodit2?.editor.querySelector(cursorPlaceholder);
if (nodeToSelect) {
jodit2?.selection?.setCursorBefore(nodeToSelect);
jodit2?.selection.removeNode(nodeToSelect);
}
jodit2?.selection.insertHTML(mediaHtml);
const newContent = jodit2?.value || "";
onChange({ target: { name, value: newContent.split(cursorPlaceholderContent).join("").trim() } });
}
break;
}
}
}
},
// Handle drag and drop for images, videos, and audio
drop: async (e) => {
const jodit = editorRef.current;
jodit?.selection.insertHTML(cursorPlaceholderContent);
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
e.preventDefault();
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith("image/") || file.type.startsWith("video/") || file.type.startsWith("audio/")) {
const mediaObject = await fileToMediaObject(file, handleFileUpload, webpEnabled);
const mediaHtml = generateMediaHtml(mediaObject);
const jodit2 = editorRef.current;
const nodeToSelect = jodit2?.editor.querySelector(cursorPlaceholder);
if (nodeToSelect) {
jodit2?.selection?.setCursorBefore(nodeToSelect);
jodit2?.selection.removeNode(nodeToSelect);
}
jodit2?.selection.insertHTML(mediaHtml);
const newContent = jodit2?.value || "";
onChange({ target: { name, value: newContent.split(cursorPlaceholderContent).join("").trim() } });
}
}
}
}
},
// Language configuration
language: "en",
// Theme
theme: "default",
// Additional Strapi-specific settings
beautifyHTML: true,
allowTabNavigation: true,
askBeforePasteHTML: false,
askBeforePasteFromWord: false,
cleanHTML: {
removeUnknownDOMElements: false
}
}), [
disabled,
height,
showToolbar,
removeButtons,
placeholder,
formatMessage,
toggleMediaLib,
handleFileUpload
]);
const displayLabel = label || fieldSchema?.displayName || metadatas?.label || formatMessage(intlLabel);
const displayDescription = description || fieldSchema?.description || metadatas?.description;
const displayHint = hint;
return /* @__PURE__ */ jsx(JoditContainer, { children: /* @__PURE__ */ jsxs(
Field.Root,
{
name,
id: name,
required,
error,
hint: displayHint,
style: { position: "relative" },
children: [
/* @__PURE__ */ jsx(Field.Label, { children: displayLabel }),
/* @__PURE__ */ jsx("div", { style: { position: "relative" }, children: /* @__PURE__ */ jsx(
JoditEditor,
{
value: initialValue,
ref: editorRef,
editorRef: (editor) => {
editorRef.current = editor;
},
config,
onBlur: (newContent) => {
console.log("📎 Jodit: Content changed", newContent?.length || 0, "characters");
const jodit = editorRef.current;
jodit?.selection.save();
onChange({ target: { name, value: newContent.split(cursorPlaceholderContent).join("").trim() } });
},
onChange: (newContent) => {
console.log("📎 Jodit: Content changed", newContent?.length || 0, "characters");
const jodit = editorRef.current;
jodit?.selection.save();
onChange({ target: { name, value: newContent.split(cursorPlaceholderContent).join("").trim() } });
}
}
) }),
displayDescription ? /* @__PURE__ */ jsx(Field.Hint, { children: displayDescription }) : null,
/* @__PURE__ */ jsx(Field.Hint, {}),
/* @__PURE__ */ jsx(Field.Error, {}),
/* @__PURE__ */ jsx(
MediaLib,
{
isOpen: mediaLibVisible,
onChange: handleMediaLibChange,
onToggle: toggleMediaLib
}
),
isLoading ? /* @__PURE__ */ jsx(
"div",
{
style: {
position: "absolute",
top: 0,
right: 0,
width: "100%",
height: "100%",
background: "rgba(255,255,255,0.5)"
},
children: /* @__PURE__ */ jsx(
"div",
{
style: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(255,255,255,0.5)"
},
children: /* @__PURE__ */ jsx(Loader, {})
}
)
}
) : null
]
}
) });
};
const JoditInput$1 = memo(JoditInput, (prevProps, nextProps) => {
return prevProps.name === nextProps.name && prevProps.required === nextProps.required && prevProps.disabled === nextProps.disabled && prevProps.error === nextProps.error;
});
const JoditInput$2 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
__proto__: null,
default: JoditInput$1
}, Symbol.toStringTag, { value: "Module" }));
const IconBox = styled(Flex)`
padding: 6px;
background-color: #f0f0ff; /* primary100 */
border: 1px solid #d9d8ff; /* primary200 */
svg > path {
fill: #4945ff; /* primary600 */
}
`;
const PluginIcon = () => /* @__PURE__ */ jsx(IconBox, { justifyContent: "center", alignItems: "center", hasRadius: true, children: /* @__PURE__ */ jsx(Pencil, {}) });
const index = {
register(app) {
console.log("🎯 Jodit Editor plugin - ADMIN REGISTER function called!");
app.customFields.register({
name: "jodit",
pluginId: PLUGIN_ID,
type: "richtext",
icon: PluginIcon,
intlLabel: {
id: `${PLUGIN_ID}.jodit.label`,
defaultMessage: "Jodit Editor"
},
intlDescription: {
id: `${PLUGIN_ID}.jodit.description`,
defaultMessage: "Rich text editor powered by Jodit with advanced formatting options"
},
components: {
Input: async () => Promise.resolve().then(() => JoditInput$2)
},
options: {
advanced: [
{
sectionTitle: {
id: `${PLUGIN_ID}.jodit.options.advanced.settings`,
defaultMessage: "Editor Settings"
},
items: [
{
name: "options.height",
type: "number",
intlLabel: {
id: `${PLUGIN_ID}.jodit.options.height.label`,
defaultMessage: "Editor Height (px)"
},
description: {
id: `${PLUGIN_ID}.jodit.options.height.description`,
defaultMessage: "Set the height of the editor in pixels"
},
defaultValue: 400
},
{
name: "options.readonly",
type: "checkbox",
intlLabel: {
id: `${PLUGIN_ID}.jodit.options.readonly.label`,
defaultMessage: "Read Only"
},
description: {
id: `${PLUGIN_ID}.jodit.options.readonly.description`,
defaultMessage: "Make the editor read-only"
},
defaultValue: false
},
{
name: "options.buttons",
type: "textarea",
intlLabel: {
id: `${PLUGIN_ID}.jodit.options.buttons.label`,
defaultMessage: "Toolbar Buttons (comma-separated)"
},
description: {
id: `${PLUGIN_ID}.jodit.options.buttons.description`,
defaultMessage: `Specify which buttons to include in the toolbar. Default: ${DEFAULT_BUTTONS} (${STRAPI_MEDIA_BUTTON_NAME} for Strapi's media library)`
},
defaultValue: DEFAULT_BUTTONS
},
{
name: "options.removeButtons",
type: "textarea",
intlLabel: {
id: `${PLUGIN_ID}.jodit.options.removeButtons.label`,
defaultMessage: "Remove Buttons (comma-separated)"
},
description: {
id: `${PLUGIN_ID}.jodit.options.removeButtons.description`,
defaultMessage: "Specify which buttons to remove from the toolbar. Example: bold,italic,underline"
}
},
{
name: "options.toolbar",
type: "checkbox",
intlLabel: {
id: `${PLUGIN_ID}.jodit.options.toolbar.label`,
defaultMessage: "Show Toolbar"
},
description: {
id: `${PLUGIN_ID}.jodit.options.toolbar.description`,
defaultMessage: "Whether to show the editor toolbar"
},
defaultValue: true
},
{
name: "options.fonts",
type: "textarea",
intlLabel: {
id: `${PLUGIN_ID}.jodit.options.fonts.label`,
defaultMessage: "Custom Fonts (one line per value)"
},
description: {
id: `${PLUGIN_ID}.jodit.options.fonts.description`,
defaultMessage: "Set the available fonts for the editor. Example: Arial, Helvetica, sans-serif"
},
defaultValue: ""
},
{
name: "options.webp",
type: "textarea",
intlLabel: {
id: `${PLUGIN_ID}.jodit.options.webp.label`,
defaultMessage: "WebP Conversion Settings"
},
description: {
id: `${PLUGIN_ID}.jodit.options.webp.description`,
defaultMessage: "Set Mime Types separated by commas for WebP conversion. Example: image/jpeg,image/jpg,image/png,image/bmp"
},
defaultValue: ""
}
]
}
]
}
});
console.log("🎯 Jodit Editor custom field registered successfully!");
},
async registerTrads({ locales }) {
return Promise.all(
locales.map(async (locale) => {
try {
const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => import("../_chunks/en-eJbSYHI0.mjs") }), `./translations/${locale}.json`, 3);
return { data, locale };
} catch {
return { data: {}, locale };
}
})
);
}
};
export {
index as default
};