UNPKG

@_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
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 };