import cssVars from "@/css/vars.css?inline";
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Creates a new TinyMCE editor element.
*
* @example
* ```js
* const data = store({ value: "" })
*
* app.components.tinymce({
* value: () => data.value,
* onchange: (val) => data.value = val,
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.tinymce = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
name: undefined,
className: "",
value: "",
readonly: false,
disabled: false,
required: false,
convertURLs: false,
onchange: function(val) {},
onbeforeinit: function(opts) {},
onafterinit: function(editor) {},
});
const watchers = app.utils.extendStore(props, propsArg);
let editorRef;
let textarea;
let oldChange;
watchers.push(watch(() => props.value, setEditorContentValue));
watchers.push(watch(() => props.disabled || props.readonly, setDisabled));
watchers.push(watch(() => props.convertURLs, setConvertURLs));
watchers.push(watch(() => app.store.activeColorScheme, setEditorBodyColorScheme));
// generic error handling wrapper to prevent throws from tinymce API calls from crashing the UI
function catchError(fn) {
try {
fn();
} catch (err) {
console.warn("tinymce error:", err);
}
}
function setEditorContentValue() {
if (oldChange != props.value) {
catchError(() => {
editorRef?.setContent("" + (props.value || "")); // stringify and normalize
});
}
}
function setDisabled() {
catchError(() => {
// https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#readonly
editorRef?.mode?.set(props.disabled || props.readonly ? "readonly" : "design");
});
}
function setConvertURLs() {
catchError(() => {
editorRef?.options?.set("convert_urls", !!props.convertURLs);
});
}
function setEditorBodyColorScheme() {
catchError(() => {
editorRef?.getBody()?.setAttribute("data-color-scheme", app.store.activeColorScheme);
});
}
let changeTimeoutId;
function triggerOnchangeWithDebounce(debounce = 150) {
clearTimeout(changeTimeoutId);
changeTimeoutId = setTimeout(triggerOnchange, debounce);
}
function triggerOnchange() {
if (!editorRef) {
return;
}
clearTimeout(changeTimeoutId);
let content;
catchError(() => {
content = editorRef.getContent();
});
if (content == oldChange) {
return; // no change
}
oldChange = content;
props.onchange?.(content);
// trigger custom change event for clearing field errors
textarea?.dispatchEvent(
new CustomEvent("change", {
detail: { editor: editorRef, content: content },
bubbles: true,
}),
);
}
function destroyEditor() {
if (!editorRef) {
return; // already removed or not initialized yet
}
clearTimeout(changeTimeoutId);
catchError(() => {
// workaround for https://github.com/tinymce/tinymce/issues/9377
editorRef.dom?.unbind(document);
window.tinymce?.remove(editorRef);
});
editorRef = null;
oldChange = null;
}
async function initEditor(editorTarget) {
await loadTinyMCE();
destroyEditor();
// removed while loading
if (!editorTarget?.isConnected) {
return;
}
const opts = {
target: editorTarget,
content_style: cssVars,
branding: false,
promotion: false,
menubar: false,
resize: false,
min_height: 265,
height: 265,
max_height: 600,
sandbox_iframes: true,
convert_unsafe_embeds: true, // GHSA-5359
codesample_global_prismjs: true,
convert_urls: false,
relative_urls: false,
autoresize_bottom_margin: 30,
media_poster: false,
media_alt_source: false,
ui_mode: "split",
codesample_languages: [
{ text: "HTML/XML", value: "markup" },
{ text: "CSS", value: "css" },
{ text: "SQL", value: "sql" },
{ text: "JavaScript", value: "javascript" },
{ text: "Go", value: "go" },
{ text: "Dart", value: "dart" },
{ text: "Zig", value: "zig" },
{ text: "Rust", value: "rust" },
{ text: "Lua", value: "lua" },
{ text: "PHP", value: "php" },
{ text: "Ruby", value: "ruby" },
{ text: "Python", value: "python" },
{ text: "Java", value: "java" },
{ text: "C", value: "c" },
{ text: "C#", value: "csharp" },
{ text: "C++", value: "cpp" },
// other non-highlighted languages
{ text: "Markdown", value: "markdown" },
{ text: "Swift", value: "swift" },
{ text: "Kotlin", value: "kotlin" },
{ text: "Elixir", value: "elixir" },
{ text: "Scala", value: "scala" },
{ text: "Julia", value: "julia" },
{ text: "Haskell", value: "haskell" },
],
plugins: [
"autolink",
"autoresize",
"code",
"codesample",
"directionality",
"image",
"link",
"lists",
"media",
"table",
"wordcount",
],
toolbar:
"styles | alignleft aligncenter alignright | bold italic forecolor backcolor | bullist numlist | link media_picker table codesample | direction code",
paste_postprocess: (editor, args) => {
cleanupPastedNode(args.node);
},
// @see https://www.tiny.cloud/docs/tinymce/6/file-image-upload/#interactive-example
file_picker_types: "image",
file_picker_callback: (callback, value, meta) => {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("accept", "image/*");
input.addEventListener("change", (e) => {
const file = e.target.files[0];
const reader = new FileReader();
reader.addEventListener("load", () => {
if (!tinymce) {
return;
}
// We need to register the blob in TinyMCEs image blob registry.
// In future TinyMCE version this part will be handled internally.
const id = "blobid" + new Date().getTime();
const blobCache = tinymce.activeEditor.editorUpload.blobCache;
const base64 = reader.result.split(",")[1];
const blobInfo = blobCache.create(id, file, base64);
blobCache.add(blobInfo);
// call the callback and populate the Title field with the file name
callback(blobInfo.blobUri(), { title: file.name });
});
reader.readAsDataURL(file);
});
input.click();
},
setup: (editor) => {
editorRef = editor;
editor.on("init", (e) => {
props.onafterinit?.(editorRef);
setConvertURLs();
setDisabled();
setEditorBodyColorScheme();
setEditorContentValue();
});
// propagate save shortcut to the parent
editor.on("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.code == "KeyS" && editorTarget) {
e.preventDefault();
e.stopPropagation();
editorTarget.dispatchEvent(new KeyboardEvent("keydown", e));
}
});
editor.on("input", (e) => {
triggerOnchangeWithDebounce();
});
editor.on("change", (e) => {
triggerOnchange();
});
registerDirectionButton(editor);
registerMediaButton(editor);
},
};
if (props.readonly) {
opts.statusbar = false;
opts.min_height = 30;
opts.height = 30;
opts.max_height = 500;
opts.autoresize_bottom_margin = 5;
opts.resize = false;
opts.toolbar = false;
opts.plugins = ["autoresize", "codesample", "directionality"];
}
if (props.onbeforeinit) {
props.onbeforeinit(opts);
}
window.tinymce.init(opts);
}
textarea = t.textarea({
name: () => props.name,
onmount: (el) => {
initEditor(el).catch((err) => {
console.warn("tinymce init error:", err);
});
},
onunmount: destroyEditor,
});
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `pb-tinymce ${props.className}`,
"html-required": () => props.required || undefined, // set on the parent because the textarea will be hidden
onunmount: (el) => {
clearTimeout(changeTimeoutId);
watchers.forEach((w) => w?.unwatch());
textarea = null;
},
},
textarea,
);
};
function registerDirectionButton(editor) {
const lastDirectionKey = "pbTinymceLastDirection";
// load last used text direction for blank editors
editor.on("init", () => {
const lastDirection = window.localStorage.getItem(lastDirectionKey);
if (!editor.isDirty() && editor.getContent() == "" && lastDirection == "rtl") {
editor.execCommand("mceDirectionRTL");
}
});
// text direction dropdown
editor.ui.registry.addMenuButton("direction", {
icon: "visualchars",
tooltip: "Direction",
fetch: (callback) => {
const items = [
{
type: "menuitem",
text: "LTR content",
icon: "ltr",
onAction: () => {
window?.localStorage?.setItem(lastDirectionKey, "ltr");
editor.execCommand("mceDirectionLTR");
},
},
{
type: "menuitem",
text: "RTL content",
icon: "rtl",
onAction: () => {
window?.localStorage?.setItem(lastDirectionKey, "rtl");
editor.execCommand("mceDirectionRTL");
},
},
];
callback(items);
},
});
}
function registerMediaButton(editor) {
editor.ui.registry.addMenuButton("media_picker", {
tooltip: "Insert media",
icon: "embed",
fetch: (callback) => {
const items = [
{
type: "menuitem",
text: "Inline image (Base64)",
onAction: () => {
editor.execCommand("mceImage");
},
},
{
type: "menuitem",
text: "Media from collection",
onAction: () => {
app.modals.openRecordFilePicker({
fileTypes: ["image", "audio", "video"],
onselect: (selected) => {
const url = app.pb.files.getURL(selected.record, selected.name, {
thumb: selected.thumb || undefined,
});
// just an extra precaution in case the editor fail for whatever reason to sanitize the inserted raw htmls
const escapedName = app.utils.encodeEntities(selected.name);
const escapedUrl = app.utils.encodeEntities(url);
if (app.utils.hasImageExtension(selected.name)) {
editor?.execCommand("InsertImage", false, url);
} else if (app.utils.hasAudioExtension(selected.name)) {
editor?.execCommand(
"InsertHTML",
false,
``,
);
} else if (app.utils.hasVideoExtension(escapedName)) {
editor?.execCommand(
"InsertHTML",
false,
`
`,
);
}
},
});
},
},
{
type: "menuitem",
text: "Manual embed",
onAction: () => {
tinymce.activeEditor.execCommand("mceMedia");
},
},
];
callback(items);
},
});
}
const allowedPasteNodes = [
"DIV",
"P",
"A",
"EM",
"B",
"STRONG",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"TABLE",
"TR",
"TD",
"TH",
"TBODY",
"THEAD",
"TFOOT",
"BR",
"HR",
"Q",
"SUP",
"SUB",
"DEL",
"IMG",
"OL",
"UL",
"LI",
"CODE",
];
function cleanupPastedNode(node) {
if (!node) {
return; // nothing to cleanup
}
for (const child of node.children) {
cleanupPastedNode(child);
}
if (!allowedPasteNodes.includes(node.tagName)) {
unwrap(node);
} else {
node.removeAttribute("style");
node.removeAttribute("class");
}
}
function unwrap(node) {
let parent = node.parentNode;
// move children outside of the parent node
while (node.firstChild) {
parent.insertBefore(node.firstChild, node);
}
// remove the now empty parent element
parent.removeChild(node);
}
async function loadTinyMCE() {
// already loaded
if (typeof window.tinymce != "undefined") {
return;
}
const scriptId = "lazy-tinymce-js";
// in the process of being loaded
if (document.getElementById(scriptId)) {
return new Promise((resolve, reject) => {
function cleanup() {
document.removeEventListener("tinymceLoadSuccess", successHandler);
document.removeEventListener("tinymceLoadError", errorHandler);
}
const successHandler = function() {
cleanup();
resolve();
};
const errorHandler = function(e) {
cleanup();
reject(e?.details);
};
document.addEventListener("tinymceLoadSuccess", successHandler);
document.addEventListener("tinymceLoadError", errorHandler);
});
}
return new Promise((resolve, reject) => {
document.head.querySelector("#shablon-script").after(
t.script({
id: scriptId,
src: import.meta.env.BASE_URL + "libs/tinymce/tinymce.min.js",
onload: () => {
resolve();
},
onerror: (err) => {
console.warn("failed to load tinymce.min.js:", err);
reject(err);
},
}),
);
}).then(() => {
document.dispatchEvent(new CustomEvent("tinymceLoadSuccess"));
}).catch((err) => {
document.dispatchEvent(new CustomEvent("tinymceLoadError", { detail: err }));
});
}