@_sh/strapi-plugin-ckeditor
Version:
Integrates CKEditor 5 into your Strapi project as a fully customizable custom field. (Community Edition)
627 lines (623 loc) • 21.9 kB
JavaScript
import { jsx, jsxs, Fragment } from "react/jsx-runtime";
import React, { createContext, useContext, useState, useEffect, useMemo, useRef, useCallback, useImperativeHandle } from "react";
import { Flex, IconButton, Modal, Field as Field$1, Loader, Box } from "@strapi/design-system";
import { styled, css, createGlobalStyle } from "styled-components";
import { useStrapiApp, useField } from "@strapi/strapi/admin";
import { ClassicEditor } from "ckeditor5";
import { CKEditor } from "@ckeditor/ckeditor5-react";
import "ckeditor5/ckeditor5.css";
import { g as getPluginConfig, p as prefixFileUrlWithBackendUrl, i as isImageResponsive } from "./index-D6brYb_3.mjs";
import "sanitize-html";
import { Collapse, Expand } from "@strapi/icons";
const STORAGE_KEYS = {
TOKEN: "jwtToken",
PREFERED_LANGUAGE: "strapi-admin-language",
PROFILE_THEME: "STRAPI_THEME"
};
function getCookieValue(name) {
let result = null;
const cookieArray = document.cookie.split(";");
cookieArray.forEach((cookie) => {
const [key, value] = cookie.split("=").map((item) => item.trim());
if (key === name) {
result = decodeURIComponent(value);
}
});
return result;
}
function getStoredToken() {
const tokenFromStorage = localStorage.getItem(STORAGE_KEYS.TOKEN) ?? sessionStorage.getItem(STORAGE_KEYS.TOKEN);
if (tokenFromStorage) {
return JSON.parse(tokenFromStorage);
}
const tokenFromCookie = getCookieValue(STORAGE_KEYS.TOKEN);
return tokenFromCookie;
}
function getPreferedLanguage() {
const language = localStorage.getItem(STORAGE_KEYS.PREFERED_LANGUAGE)?.replace(/^"|"$/g, "") || "en";
return language;
}
function getProfileTheme() {
const theme = localStorage.getItem(STORAGE_KEYS.PROFILE_THEME);
return theme;
}
const TRANSLATIONS = {};
async function setUpLanguage(config, isFieldLocalized) {
if (typeof config.language !== "object") {
config.language = {
ui: config.language,
content: config.language,
textPartLanguage: void 0
};
}
config.language.ui ||= getPreferedLanguage();
if (isFieldLocalized) {
config.language.content = detecti18n();
}
if (config.language.ui !== "en") {
await importLang(config, config.language.ui);
}
}
async function importLang(config, language) {
if (TRANSLATIONS[language]) {
config.translations = TRANSLATIONS[language];
} else if (translationImports[language]) {
const translation = await translationImports[language]();
TRANSLATIONS[language] = translation.default;
config.translations = translation.default;
} else {
console.warn(`No CKEditor translation found for language: ${language}`);
}
}
function detecti18n() {
const urlSearchParams = new URLSearchParams(window.location.search);
const params = Object.fromEntries(urlSearchParams.entries());
const i18n = params["plugins[i18n][locale]"];
return i18n && i18n.split("-")[0];
}
const translationImports = {
af: () => import("ckeditor5/translations/af.js"),
ar: () => import("ckeditor5/translations/ar.js"),
ast: () => import("ckeditor5/translations/ast.js"),
az: () => import("ckeditor5/translations/az.js"),
bg: () => import("ckeditor5/translations/bg.js"),
bn: () => import("ckeditor5/translations/bn.js"),
bs: () => import("ckeditor5/translations/bs.js"),
ca: () => import("ckeditor5/translations/ca.js"),
cs: () => import("ckeditor5/translations/cs.js"),
da: () => import("ckeditor5/translations/da.js"),
"de-ch": () => import("ckeditor5/translations/de-ch.js"),
de: () => import("ckeditor5/translations/de.js"),
el: () => import("ckeditor5/translations/el.js"),
"en-au": () => import("ckeditor5/translations/en-au.js"),
"en-gb": () => import("ckeditor5/translations/en-gb.js"),
en: () => import("ckeditor5/translations/en.js"),
eo: () => import("ckeditor5/translations/eo.js"),
"es-co": () => import("ckeditor5/translations/es-co.js"),
es: () => import("ckeditor5/translations/es.js"),
et: () => import("ckeditor5/translations/et.js"),
eu: () => import("ckeditor5/translations/eu.js"),
fa: () => import("ckeditor5/translations/fa.js"),
fi: () => import("ckeditor5/translations/fi.js"),
fr: () => import("ckeditor5/translations/fr.js"),
gl: () => import("ckeditor5/translations/gl.js"),
gu: () => import("ckeditor5/translations/gu.js"),
he: () => import("ckeditor5/translations/he.js"),
hi: () => import("ckeditor5/translations/hi.js"),
hr: () => import("ckeditor5/translations/hr.js"),
hu: () => import("ckeditor5/translations/hu.js"),
hy: () => import("ckeditor5/translations/hy.js"),
id: () => import("ckeditor5/translations/id.js"),
it: () => import("ckeditor5/translations/it.js"),
ja: () => import("ckeditor5/translations/ja.js"),
jv: () => import("ckeditor5/translations/jv.js"),
kk: () => import("ckeditor5/translations/kk.js"),
km: () => import("ckeditor5/translations/km.js"),
kn: () => import("ckeditor5/translations/kn.js"),
ko: () => import("ckeditor5/translations/ko.js"),
ku: () => import("ckeditor5/translations/ku.js"),
lt: () => import("ckeditor5/translations/lt.js"),
lv: () => import("ckeditor5/translations/lv.js"),
ms: () => import("ckeditor5/translations/ms.js"),
nb: () => import("ckeditor5/translations/nb.js"),
ne: () => import("ckeditor5/translations/ne.js"),
nl: () => import("ckeditor5/translations/nl.js"),
no: () => import("ckeditor5/translations/no.js"),
oc: () => import("ckeditor5/translations/oc.js"),
pl: () => import("ckeditor5/translations/pl.js"),
"pt-br": () => import("ckeditor5/translations/pt-br.js"),
pt: () => import("ckeditor5/translations/pt.js"),
ro: () => import("ckeditor5/translations/ro.js"),
ru: () => import("ckeditor5/translations/ru.js"),
si: () => import("ckeditor5/translations/si.js"),
sk: () => import("ckeditor5/translations/sk.js"),
sl: () => import("ckeditor5/translations/sl.js"),
sq: () => import("ckeditor5/translations/sq.js"),
sr: () => import("ckeditor5/translations/sr.js"),
"sr-latn": () => import("ckeditor5/translations/sr-latn.js"),
sv: () => import("ckeditor5/translations/sv.js"),
th: () => import("ckeditor5/translations/th.js"),
ti: () => import("ckeditor5/translations/ti.js"),
tk: () => import("ckeditor5/translations/tk.js"),
tr: () => import("ckeditor5/translations/tr.js"),
tt: () => import("ckeditor5/translations/tt.js"),
ug: () => import("ckeditor5/translations/ug.js"),
uk: () => import("ckeditor5/translations/uk.js"),
ur: () => import("ckeditor5/translations/ur.js"),
uz: () => import("ckeditor5/translations/uz.js"),
vi: () => import("ckeditor5/translations/vi.js"),
"zh-cn": () => import("ckeditor5/translations/zh-cn.js"),
zh: () => import("ckeditor5/translations/zh.js")
};
const EditorContext = createContext(null);
function useEditorContext() {
const context = useContext(EditorContext);
if (!context) {
throw new Error("The useEditorContext hook must be used within the EditorProvider.");
}
return context;
}
function EditorProvider({
name,
disabled,
error,
placeholder,
hint,
label,
labelAction,
required,
presetName,
wordsLimit,
charsLimit,
children,
isFieldLocalized
}) {
const [preset, setPreset] = useState(null);
useEffect(() => {
(async () => {
const { presets } = getPluginConfig();
const currentPreset = clonePreset(presets[presetName]);
await setUpLanguage(currentPreset.editorConfig, isFieldLocalized);
if (placeholder) {
currentPreset.editorConfig.placeholder = placeholder;
}
setPreset(currentPreset);
})();
}, [presetName, placeholder, isFieldLocalized]);
const EditorContextValue = useMemo(
() => ({
name,
disabled,
placeholder,
hint,
label,
labelAction,
required,
presetName,
preset,
error,
wordsLimit,
charsLimit,
isFieldLocalized
}),
[
name,
disabled,
placeholder,
hint,
label,
labelAction,
required,
presetName,
wordsLimit,
charsLimit,
preset,
error,
isFieldLocalized
]
);
return /* @__PURE__ */ jsx(EditorContext.Provider, { value: EditorContextValue, children });
}
function clonePreset(preset) {
const clonedPreset = {
...preset,
editorConfig: {
...preset.editorConfig
}
};
Object.entries(clonedPreset.editorConfig).forEach(([key, val]) => {
if (val && typeof val === "object" && Object.getPrototypeOf(val) === Object.prototype) {
clonedPreset.editorConfig[key] = { ...val };
}
});
return clonedPreset;
}
function MediaLib({ isOpen = false, toggle, handleChangeAssets }) {
const components = useStrapiApp("MediaLib", (state) => state.components);
const MediaLibraryDialog = components["media-library"];
function handleSelectAssets(files) {
const formattedFiles = files.map((f) => ({
name: f.name,
alt: f.alternativeText || f.name,
url: prefixFileUrlWithBackendUrl(f.url),
mime: f.mime,
formats: f.formats,
width: f.width,
height: f.height
}));
const newElems = getNewElems(formattedFiles);
handleChangeAssets(newElems);
}
function getNewElems(assets) {
let newElems = "";
assets.forEach(({ name, url, alt, formats, mime, width, height }) => {
if (mime.includes("image")) {
if (formats && isImageResponsive(formats)) {
const set = formSrcSet(formats);
newElems += `<img src="${url}" alt="${alt}" width="${width}" height="${height}" srcset="${set}" />`;
} else {
newElems += `<img src="${url}" alt="${alt}" width="${width}" height="${height}" />`;
}
} else if (mime.includes("video")) {
newElems += `
<video class="video" controls width="500px">
<source src="${url}" type="${mime}" />
</video>`;
} else {
newElems += `<a href="${url}">${name || "Open document"}</a>`;
}
});
return newElems;
}
function formSrcSet(formats) {
let set = "";
const keys = Object.keys(formats).sort((a, b) => formats[a].width - formats[b].width);
keys.forEach((k) => {
set += `${prefixFileUrlWithBackendUrl(formats[k].url)} ${formats[k].width}w,`;
});
return set;
}
if (!isOpen) {
return null;
}
return /* @__PURE__ */ jsx(MediaLibraryDialog, { onClose: toggle, onSelectAssets: handleSelectAssets });
}
const MemoizedMediaLib = React.memo(MediaLib);
const CKEReact = React.forwardRef((_, forwardedRef) => {
const [mediaLibVisible, setMediaLibVisible] = useState(false);
const [editorInstance, setEditorInstance] = useState(null);
const [isWordsMax, setIsWordsMax] = useState(false);
const [isCharsMax, setIsCharsMax] = useState(false);
const { name, disabled, preset, wordsLimit, charsLimit } = useEditorContext();
const { onChange: fieldOnChange, value: fieldValue } = useField(name);
const wordCounterRef = useRef(null);
const debounceTimeout = useRef(null);
const onEditorReady = (editor) => {
setUpPlugins(editor);
setEditorInstance(editor);
};
const onEditorChange = (_e, editor) => {
if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current);
}
const data = editor.getData();
debounceTimeout.current = setTimeout(() => {
fieldOnChange(name, data);
debounceTimeout.current = null;
}, 300);
};
const toggleMediaLib = useCallback(() => setMediaLibVisible((prev) => !prev), [setMediaLibVisible]);
const handleChangeAssets = useCallback(
(newElems) => {
if (!editorInstance) {
throw new Error("The editor instance has not been initialized.");
}
const viewFragment = editorInstance.data.processor.toView(newElems);
const modelFragment = editorInstance.data.toModel(viewFragment);
editorInstance?.model.insertContent(modelFragment);
toggleMediaLib();
},
[toggleMediaLib, editorInstance]
);
useEffect(() => {
const ckWrapper = document.querySelector(".ck-body-wrapper");
const listener = ckWrapper?.addEventListener("pointerdown", (e) => e.stopPropagation(), true);
return () => {
if (listener) {
ckWrapper?.removeEventListener("pointerdown", listener);
}
};
}, [editorInstance]);
useImperativeHandle(
forwardedRef,
() => ({
focus() {
editorInstance?.focus();
}
}),
[editorInstance]
);
if (!preset) {
return null;
}
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(
CKEditor,
{
editor: ClassicEditor,
config: preset.editorConfig,
disabled,
data: fieldValue ?? "",
onReady: onEditorReady,
onChange: onEditorChange
}
),
/* @__PURE__ */ jsx(WordCounter, { ref: wordCounterRef, $isWordsMax: isWordsMax, $isCharsMax: isCharsMax }),
/* @__PURE__ */ jsx(
MemoizedMediaLib,
{
isOpen: mediaLibVisible,
toggle: toggleMediaLib,
handleChangeAssets
}
)
] });
function setUpPlugins(editor) {
const pluginsToSetup = {
WordCount: setUpWordCount,
ImageUploadEditing: setUpImageUploadEditing,
StrapiMediaLib: setUpStrapiMediaLib,
StrapiUploadAdapter: setUpStrapiUploadAdapter
};
Object.entries(pluginsToSetup).forEach(([pluginName, setUpFn]) => {
if (editor.plugins.has(pluginName)) {
try {
setUpFn(editor);
} catch (err) {
console.error(`Failed to set up the ${pluginName} plugin `, err);
}
}
});
}
function setUpWordCount(editor) {
const wordCountPlugin = editor.plugins.get("WordCount");
if (wordsLimit || charsLimit) {
wordCountPlugin.on("update", (_e, stats) => validateInputLength(stats));
const { words, characters } = wordCountPlugin;
validateInputLength({ words, characters });
}
wordCounterRef.current?.appendChild(wordCountPlugin.wordCountContainer);
}
function setUpImageUploadEditing(editor) {
const imageUploadEditingPlugin = editor.plugins.get("ImageUploadEditing");
const setAltAttribute = (_e, { data, imageElement }) => {
editor.model.change((writer) => {
writer.setAttribute("alt", data.alt, imageElement);
});
};
imageUploadEditingPlugin.on("uploadComplete", setAltAttribute);
}
function setUpStrapiMediaLib(editor) {
const strapiMediaLibPlugin = editor.plugins.get("StrapiMediaLib");
strapiMediaLibPlugin.connect(toggleMediaLib);
}
function setUpStrapiUploadAdapter(editor) {
const StrapiUploadAdapterPlugin = editor.plugins.get(
"StrapiUploadAdapter"
);
const token = getStoredToken();
const config = {
uploadUrl: prefixFileUrlWithBackendUrl("/upload"),
headers: { Authorization: `Bearer ${token}` }
};
StrapiUploadAdapterPlugin.initAdapter(config);
}
function validateInputLength(stats) {
if (wordsLimit) {
setIsWordsMax(stats.words > wordsLimit);
}
if (charsLimit) {
setIsCharsMax(stats.characters > charsLimit);
}
}
});
const WordCounter = styled(Flex)`
${({ theme, $isWordsMax, $isCharsMax }) => css`
.ck-word-count__words {
color: ${$isWordsMax ? theme.colors.danger600 : theme.colors.neutral400};
}
.ck-word-count__characters {
color: ${$isCharsMax ? theme.colors.danger600 : theme.colors.neutral400};
}
`}
`;
function EditorLayout({ children }) {
const { error, preset } = useEditorContext();
const [isExpandedMode, setIsExpandedMode] = useState(false);
const handleToggleExpand = (open) => {
if (open) {
setTimeout(() => {
const ckPopupsWrapper = document.querySelector(".ck-body-wrapper");
const ckEditorModal = document.getElementById("ck-editor-modal");
if (ckPopupsWrapper && ckEditorModal) {
ckEditorModal.appendChild(ckPopupsWrapper);
}
document.querySelector(".ck-editor__expanded .ck-editor__editable")?.focus();
}, 0);
} else {
const ckPopupsWrapper = document.querySelector(".ck-body-wrapper");
if (ckPopupsWrapper) {
document.body.appendChild(ckPopupsWrapper);
}
}
setIsExpandedMode(open);
};
useEffect(() => {
if (isExpandedMode) {
document.body.classList.add("lock-body-scroll");
}
return () => {
document.body.classList.remove("lock-body-scroll");
};
}, [isExpandedMode]);
if (isExpandedMode) {
return /* @__PURE__ */ jsx(Modal.Root, { open: isExpandedMode, onOpenChange: handleToggleExpand, children: /* @__PURE__ */ jsx(Content, { id: "ck-editor-modal", children: /* @__PURE__ */ jsx(
Flex,
{
height: "90dvh",
width: "90dvw",
maxWidth: "100%",
direction: "column",
alignItems: "flex-start",
background: "neutral100",
children: /* @__PURE__ */ jsxs(
EditorWrapper,
{
$presetStyles: preset?.styles,
$isExpanded: isExpandedMode,
$hasError: Boolean(error),
className: "ck-editor__expanded",
children: [
children,
/* @__PURE__ */ jsx(
CollapseButton,
{
tabIndex: "-1",
label: "Collapse",
onClick: () => handleToggleExpand(false),
children: /* @__PURE__ */ jsx(Collapse, {})
}
)
]
}
)
}
) }) });
}
return /* @__PURE__ */ jsxs(
EditorWrapper,
{
$presetStyles: preset?.styles,
$isExpanded: isExpandedMode,
$hasError: Boolean(error),
children: [
children,
/* @__PURE__ */ jsx(ExpandButton, { label: "Expand", onClick: () => handleToggleExpand(true), children: /* @__PURE__ */ jsx(Expand, {}) })
]
}
);
}
const EditorWrapper = styled("div")`
position: relative;
width: 100%;
${({ $presetStyles, theme, $hasError = false, $isExpanded }) => css`
height: ${$isExpanded ? "100%" : "auto"};
border-radius: ${theme.borderRadius};
outline: none;
box-shadow: 0;
transition-property: border-color, box-shadow, fill;
transition-duration: 0.2s;
border: 1px solid ${$hasError ? theme.colors.danger600 : theme.colors.neutral200};
border-radius: ${theme.borderRadius};
&:focus-within {
border: 1px solid ${$isExpanded ? theme.colors.neutral200 : theme.colors.primary600};
border-color: ${$hasError && theme.colors.danger600};
box-shadow: ${$hasError ? theme.colors.danger600 : theme.colors.primary600} 0px 0px 0px 2px;
}
${$presetStyles}
`}
`;
const ExpandButton = styled(IconButton)`
position: absolute;
bottom: 1.4rem;
right: 1.2rem;
z-index: 2;
box-shadow: ${({ theme }) => theme.shadows.filterShadow};
`;
const CollapseButton = styled(IconButton)`
position: absolute;
bottom: 2.5rem;
right: 1.2rem;
z-index: 2;
box-shadow: ${({ theme }) => theme.shadows.filterShadow};
`;
const Content = styled(Modal.Content)`
max-width: var(--ck-editor-full-screen-box-max-width);
width: unset;
overflow: visible;
`;
const GlobalStyle = createGlobalStyle`
${({ $editortTheme, $variant }) => $editortTheme && css`
${$editortTheme.common}
${$editortTheme[$variant]}
${$editortTheme.additional}
`}
`;
const getSystemColorScheme = () => window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
function GlobalStyling() {
const { theme } = getPluginConfig();
const profileTheme = getProfileTheme();
const variant = profileTheme && profileTheme !== "system" ? profileTheme : getSystemColorScheme();
return /* @__PURE__ */ jsx(GlobalStyle, { $editortTheme: theme, $variant: variant });
}
const MemoizedGlobalStyling = React.memo(GlobalStyling);
const Editor = React.forwardRef((_, forwardedRef) => {
const { name, hint, required, labelAction, label, error, preset } = useEditorContext();
return /* @__PURE__ */ jsx(Field$1.Root, { id: name, name, error, hint, required, children: /* @__PURE__ */ jsxs(Flex, { direction: "column", alignItems: "stretch", gap: 1, children: [
/* @__PURE__ */ jsx(Field$1.Label, { action: labelAction, children: label }),
preset ? /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(MemoizedGlobalStyling, {}),
/* @__PURE__ */ jsx(EditorLayout, { children: /* @__PURE__ */ jsx(CKEReact, { ref: forwardedRef }) })
] }) : /* @__PURE__ */ jsx(LoaderBox, { hasRadius: true, background: "neutral100", children: /* @__PURE__ */ jsx(Loader, { children: "Loading..." }) }),
/* @__PURE__ */ jsx(Field$1.Hint, {}),
/* @__PURE__ */ jsx(Field$1.Error, {})
] }) });
});
const LoaderBox = styled(Box)`
display: flex;
justify-content: center;
align-items: center;
height: 200px;
width: 100%;
`;
const Field = React.forwardRef(
({
name,
hint,
error,
placeholder,
label,
attribute,
labelAction = null,
disabled = false,
required = false
}, forwardedRef) => {
const { preset, maxLengthWords, maxLengthCharacters } = attribute.options;
const isFieldLocalized = attribute?.pluginOptions?.i18n?.localized ?? false;
return /* @__PURE__ */ jsx(
EditorProvider,
{
name,
error,
disabled,
required,
placeholder,
hint,
label,
labelAction,
presetName: preset,
wordsLimit: maxLengthWords,
charsLimit: maxLengthCharacters,
isFieldLocalized,
children: /* @__PURE__ */ jsx(Editor, { ref: forwardedRef })
}
);
}
);
function compare(oldProps, newProps) {
return oldProps.error === newProps.error && oldProps.labelAction === newProps.labelAction;
}
const MemoizedField = React.memo(Field, compare);
export {
MemoizedField as Field
};