Viewing File: /usr/local/cpanel/base/frontend/jupiter/filemanager/editors/html_editor.js
/*
# Copyright 2025 WebPros International, LLC
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited
*/
/* --------------------------*/
/* DEFINE GLOBALS FOR LINT */
/*--------------------------*/
/* global CPANEL: true */
/* global YAHOO: true */
/* global Jodit: true */
/* --------------------------*/
// Ensure CPANEL exists
if (typeof CPANEL === 'undefined') {
window.CPANEL = {};
}
CPANEL.joditEditor = (function () {
/* --------------------*/
/* Application State */
/* --------------------*/
var appData;
var joditInstance;
/**
* Initialize Jodit editor
*
* @method initialize
* @param {Object} config configuration object for Jodit
* @param {Object} data additional data for editor (file metadata, etc.)
*/
var initialize = function (config, data) {
appData = data;
// Get the textarea element
var textareaEl = document.getElementById(config.elementId || 'jodit-editor');
if (!textareaEl) {
console.error('Jodit editor element not found:', config.elementId || 'jodit-editor');
return;
}
// Clear the textarea first to ensure clean state
textareaEl.value = '';
joditInstance = Jodit.make(textareaEl, config.joditOptions || {});
// Set the content from appData after Jodit is fully initialized
setTimeout(async function () {
if (appData && appData.fileContentInfo && appData.fileContentInfo.content) {
var content = appData.fileContentInfo.content;
content = injectBaseTag(content, appData.baseUrl);
// Fetch CSS files via UAPI and inline them into the content string
// BEFORE passing to Jodit so styles survive any iframe re-renders.
content = await inlineCSSIntoContent(
content,
appData.baseUrl,
appData.baseDir,
appData.securityToken
);
// Set content using a single, reliable method to avoid duplication
try {
// Ensure editor is ready and clear any existing content
if (joditInstance && joditInstance.isReady) {
joditInstance.value = content;
} else {
// If editor is not ready, wait a bit more and try again
var delayedContent = content;
setTimeout(function () {
if (joditInstance) {
joditInstance.value = delayedContent;
}
}, 200);
}
} catch (e) {
console.error('Error setting content:', e);
// Fallback: try setting via textarea
var textarea = document.getElementById(config.elementId || 'jodit-editor');
if (textarea) {
textarea.value = content;
}
}
} else {
// Set empty content if no data is available
joditInstance.value = '';
}
}, 150);
// Set up event handlers
setupEventHandlers();
// Show the form if it was hidden
showEditorForm();
};
/**
* Set up event handlers for the Jodit editor
*
* @method setupEventHandlers
*/
function setupEventHandlers() {
if (!joditInstance) return;
// Set up keyboard shortcuts
joditInstance.e.on('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveFile();
}
});
// Track dirty state when content changes
joditInstance.__originalContent = joditInstance.value;
joditInstance.__isDirty = false;
joditInstance.__saveInProgress = false;
joditInstance.__reEnabling = false;
// Use a debounced change handler to avoid false positives
var changeTimeout;
var changeHandler = function () {
// Ignore changes during save operations or re-enabling
if (joditInstance && !joditInstance.__saveInProgress && !joditInstance.__reEnabling) {
// Clear any existing timeout
if (changeTimeout) {
clearTimeout(changeTimeout);
}
// Set a small delay to avoid marking dirty during save operations
changeTimeout = setTimeout(function () {
if (!joditInstance.__saveInProgress && !joditInstance.__reEnabling) {
joditInstance.__isDirty = true;
}
}, 100);
}
};
// Store the change handler for later use
joditInstance.__changeHandler = changeHandler;
joditInstance.e.on('change', changeHandler);
}
/**
* Show the editor form
*
* @method showEditorForm
*/
function showEditorForm() {
var form = document.getElementById(appData.formID || 'htmlEditorForm');
if (form) {
form.className = form.className.replace('cpanelHide', 'cpanelShow');
}
}
/**
* Disable the editor during save operations
*
* @method disableEditor
*/
function disableEditor() {
if (!joditInstance) return;
try {
// Method 1: Set readonly mode
joditInstance.setReadOnly(true);
// Method 2: Disable the editor container
var editorContainer = joditInstance.container;
if (editorContainer) {
editorContainer.style.pointerEvents = 'none';
editorContainer.style.opacity = '0.6';
editorContainer.setAttribute('data-saving', 'true');
}
// Method 3: Disable toolbar buttons
var toolbar = joditInstance.toolbar;
if (toolbar && toolbar.container) {
toolbar.container.style.pointerEvents = 'none';
}
} catch (e) {
// Could not disable editor
}
}
/**
* Enable the editor after save operations
*
* @method enableEditor
*/
function enableEditor() {
if (!joditInstance) {
return;
}
try {
// Temporarily remove change event handler to prevent false dirty marking
if (joditInstance.__changeHandler) {
joditInstance.e.off('change', joditInstance.__changeHandler);
}
// Temporarily disable change event handling during re-enable
joditInstance.__reEnabling = true;
// Method 1: Remove readonly mode
joditInstance.setReadOnly(false);
// Method 2: Re-enable the editor container
var editorContainer = joditInstance.container;
if (editorContainer) {
editorContainer.style.pointerEvents = '';
editorContainer.style.opacity = '';
editorContainer.removeAttribute('data-saving');
}
// Method 3: Re-enable toolbar buttons
var toolbar = joditInstance.toolbar;
if (toolbar && toolbar.container) {
toolbar.container.style.pointerEvents = '';
}
// Re-add the change handler and clear flags after a delay to allow Jodit to settle
setTimeout(function () {
if (joditInstance) {
joditInstance.__reEnabling = false;
// Re-add the change event handler
if (joditInstance.__changeHandler) {
joditInstance.e.on('change', joditInstance.__changeHandler);
}
}
}, 300);
} catch (e) {
// Make sure to clear the flag and restore handler even on error
if (joditInstance) {
joditInstance.__reEnabling = false;
if (joditInstance.__changeHandler) {
joditInstance.e.on('change', joditInstance.__changeHandler);
}
}
}
}
/**
* Save the file content
*
* @method saveFile
*/
function saveFile() {
if (window.mixpanel) {
window.mixpanel.track('HTML-Editor-file-save');
}
var Dynamic_Notice = CPANEL.ajax.Dynamic_Notice;
if (!joditInstance) {
notice = new Dynamic_Notice({
level: 'error',
content: LOCALE.maketext('Editor not initialized.')
});
return;
}
// Set save in progress flag to prevent dirty marking during save
joditInstance.__saveInProgress = true;
// Disable the editor during save.
// **NOTE**: We must do this BEFORE inspecting/copying content from the Jodit instance,
// otherwise, the content will have 'contenteditable=true' attributes injected (CPANEL-50329).
disableEditor();
var content = joditInstance.value;
if (!content || content.trim() === '') {
notice = new Dynamic_Notice({
level: 'warning',
content: LOCALE.maketext('No content to save.')
});
return;
}
// Show saving status
notice = new Dynamic_Notice({
level: 'info',
content: LOCALE.maketext('Saving file …')
});
updateSaveButton(true, LOCALE.maketext('Saving...'));
// Call the API to save the file with restored content
_saveFile(content);
}
/**
* API call to save the file content
*
* @method _saveFile
* @private
* @param {String} content File content
*/
function _saveFile(content) {
var Dynamic_Notice = CPANEL.ajax.Dynamic_Notice;
var fileMetaData = appData.fileMetaData;
var callback = {
success: function (response) {
_saveSuccess(response);
},
failure: function (response) {
_saveFailure(response);
}
};
// Add a timeout to prevent getting stuck
var saveTimeout = setTimeout(function () {
notice = new Dynamic_Notice({
level: 'error',
content: LOCALE.maketext('Save operation timed out.')
});
enableEditor();
updateSaveButton(false, LOCALE.maketext('Save File'));
if (joditInstance) {
joditInstance.__saveInProgress = false;
}
}, 30000);
// Store timeout so we can clear it on success/failure
joditInstance.__saveTimeout = saveTimeout;
// Check if CPANEL.api is available
if (typeof CPANEL === 'undefined' || typeof CPANEL.api !== 'function') {
clearTimeout(saveTimeout);
_saveFailure({ error: 'CPANEL.api is not available' });
return;
}
try {
if (appData._baseTagInjected) {
content = stripInjectedBaseTag(content, appData.baseUrl);
}
content = stripInjectedStyles(content);
CPANEL.api({
version: 3,
module: "Fileman",
func: "save_file_content",
data: {
dir: fileMetaData.dirPath,
file: fileMetaData.fileName,
content: content,
to_charset: fileMetaData.charset,
fallback: true,
html: true
},
callback: callback
});
} catch (e) {
clearTimeout(saveTimeout);
_saveFailure({ error: 'API call failed: ' + e.message });
}
}
/**
* Success callback for save operation
*
* @method _saveSuccess
* @private
* @param {Object} o Response object
*/
function _saveSuccess(o) {
try {
var Dynamic_Notice = CPANEL.ajax.Dynamic_Notice;
// Clear the save timeout
if (joditInstance && joditInstance.__saveTimeout) {
clearTimeout(joditInstance.__saveTimeout);
joditInstance.__saveTimeout = null;
}
updateSaveButton(false, LOCALE.maketext('Save File'));
if (o && o.cpanel_status) {
notice = new Dynamic_Notice({
level: 'success',
content: LOCALE.maketext('File saved successfully!')
});
// Mark as clean and clear save-in-progress flag BEFORE re-enabling
if (joditInstance) {
// Clear save in progress flag first
joditInstance.__saveInProgress = false;
// Reset our custom dirty flag (this is what we actually check)
joditInstance.__isDirty = false;
// Update original content to current content
joditInstance.__originalContent = joditInstance.value;
}
// Re-enable the editor AFTER clearing dirty state
enableEditor();
} else {
var errorMsg = LOCALE.maketext('Failed to save file.');
if (o.cpanel_error) {
errorMsg += ': ' + o.cpanel_error;
}
notice = new Dynamic_Notice({
level: 'error',
content: errorMsg
});
enableEditor(); // Re-enable editor on error too
}
} catch (e) {
notice = new Dynamic_Notice({
level: 'error',
content: LOCALE.maketext('Error processing save response.')
});
enableEditor(); // Re-enable editor on error too
}
}
/**
* Failure callback for save operation
*
* @method _saveFailure
* @private
* @param {Object} o Response object
*/
function _saveFailure() {
var Dynamic_Notice = CPANEL.ajax.Dynamic_Notice;
// Clear the save timeout
if (joditInstance && joditInstance.__saveTimeout) {
clearTimeout(joditInstance.__saveTimeout);
joditInstance.__saveTimeout = null;
}
updateSaveButton(false, LOCALE.maketext('Save File'));
notice = new Dynamic_Notice({
level: 'error',
content: LOCALE.maketext('Network error while saving file. Please try again.')
});
// Re-enable the editor even on failure
enableEditor();
// Clear save in progress flag even on failure
if (joditInstance) {
joditInstance.__saveInProgress = false;
}
}
/**
* Preview the file content
*
* @method previewFile
*/
function previewFile() {
var Dynamic_Notice = CPANEL.ajax.Dynamic_Notice;
if (!joditInstance) {
notice = new Dynamic_Notice({
level: 'error',
content: LOCALE.maketext('Editor not initialized.')
});
return;
}
var content = joditInstance.value;
var previewWindow = window.open('', '_blank', 'width=800,height=600,scrollbars=yes,resizable=yes');
if (previewWindow) {
previewWindow.document.write(content);
previewWindow.document.close();
} else {
notice = new Dynamic_Notice({
level: 'error',
content: LOCALE.maketext('Could not open preview window. Please check popup blocker settings.')
});
}
}
/**
* Update save button state
*
* @method updateSaveButton
* @param {Boolean} disabled Whether the button should be disabled
* @param {String} text The button text
*/
function updateSaveButton(disabled, text) {
var saveButton = document.getElementById('save-button');
if (saveButton) {
saveButton.disabled = disabled;
saveButton.textContent = text;
}
}
/**
* Handle page unload to warn about unsaved changes
*
* @method handleBeforeUnload
*/
function handleBeforeUnload(e) {
// Only warn if we have our custom dirty flag set
// Ignore Jodit's internal dirty state as it can be unreliable
if (joditInstance && joditInstance.__isDirty === true) {
var confirmationMessage = 'You have unsaved changes. Are you sure you want to leave?';
e.returnValue = confirmationMessage;
return confirmationMessage;
}
// If no custom dirty flag, allow navigation without warning
}
// Set up beforeunload handler
window.addEventListener('beforeunload', handleBeforeUnload);
/**
* Force recovery from stuck save state
*
* @method forceRecovery
*/
function forceRecovery() {
var Dynamic_Notice = CPANEL.ajax.Dynamic_Notice;
if (joditInstance) {
// Clear all save-related flags
joditInstance.__saveInProgress = false;
joditInstance.__reEnabling = false;
// Clear any timeouts
if (joditInstance.__saveTimeout) {
clearTimeout(joditInstance.__saveTimeout);
joditInstance.__saveTimeout = null;
}
// Force enable the editor
enableEditor();
// Reset button state
updateSaveButton(false, LOCALE.maketext('Save File'));
notice = new Dynamic_Notice({
level: 'info',
content: LOCALE.maketext('Editor state reset.')
});
}
}
// Add UX helper methods to CPANEL.joditEditor
/**
* Inject a <base> tag into the HTML content so the browser resolves all
* relative URLs (CSS images, fonts) against the file's actual web location
* rather than the cPanel editor URL.
*
* Only injects when the content has a valid HTTPS base URL and no existing
* <base> tag. Sets appData._baseTagInjected = true so the tag can be
* stripped on save.
*
* @param {string} content HTML content to modify
* @param {string} baseUrl Web-accessible URL of the directory containing the file
* @returns {string} Modified HTML content
*/
function injectBaseTag(content, baseUrl) {
if (!baseUrl || !/^https?:\/\//i.test(baseUrl) || !content) {
return content;
}
// Leave any author-supplied <base> tag alone.
if (/<base\b[^>]*>/i.test(content)) {
return content;
}
var baseTag = '<base href="' + baseUrl + '">';
appData._baseTagInjected = true;
// Prefer inserting right after the opening <head> tag.
if (/<head\b[^>]*>/i.test(content)) {
return content.replace(/(<head\b[^>]*>)/i, '$1\n ' + baseTag);
}
// No <head> tag present — prepend to the document.
return baseTag + '\n' + content;
}
/**
* Remove the <base> tag that was injected by injectBaseTag() before saving
* so the file on disk is not modified beyond what the user intended.
*
* @param {string} content HTML content containing the injected base tag
* @param {string} baseUrl The href value that was injected
* @returns {string} Content with the injected base tag removed
*/
function stripInjectedBaseTag(content, baseUrl) {
if (!baseUrl || !content) {
return content;
}
var escaped = baseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return content.replace(new RegExp('[ \\t]*<base href="' + escaped + '">\\n?', 'i'), '');
}
/**
* Remove any <style> elements that were injected by fixIframeStyles() before
* saving so the on-disk file retains its original <link> stylesheet elements.
*
* @param {string} content HTML content that may contain injected <style> blocks
* @returns {string} Content with injected <style> blocks removed
*/
function stripInjectedStyles(content) {
if (!content) {
return content;
}
return content.replace(/<style\b[^>]*\bdata-cpanel-injected\b[^>]*>[\s\S]*?<\/style>\n?/gi, '');
}
/**
* Fetch each <link rel="stylesheet"> from the user's domain via the cPanel
* Fileman API and inline the CSS as a <style> block in the HTML content
* string, immediately after each <link> tag.
*
* This works around cross-origin failures that occur when the user's website
* domain is not reachable from the browser (e.g., the domain only resolves
* within the hosting server's network). All UAPI requests go to cPanel on
* the same origin, so no CORS headers are needed.
*
* Operating on the content string (rather than the live iframe DOM) ensures
* the inlined styles survive Jodit's internal afterChange re-renders.
*
* Injected <style> blocks carry a data-cpanel-injected attribute so that
* stripInjectedStyles() can remove them before the file is written to disk,
* preserving the original <link> tags.
*
* @param {string} content HTML content string
* @param {string} baseUrl Website base URL (e.g. https://example.com/subdir/)
* @param {string} baseDir Filesystem document root (e.g. /home/user/public_html)
* @param {string} secToken cPanel security token (e.g. /cpsessXXXXXX)
* @returns {Promise<string>} HTML content with inlined CSS
*/
async function inlineCSSIntoContent(content, baseUrl, baseDir, secToken) {
if (!content || !baseUrl || !/^https?:\/\//i.test(baseUrl) || !baseDir || !secToken) {
return content;
}
var baseUrlParsed;
var domainOrigin;
try {
baseUrlParsed = new URL(baseUrl);
domainOrigin = baseUrlParsed.origin; // e.g. https://cptest.tld
} catch (e) {
return content;
}
// baseUrl may have a subpath when the website lives in a subdirectory
// (e.g. https://cptest.tld/test.cptest.tld/). CSS that uses root-relative
// paths (/webcard/static/…) is relative to that site root, not the domain
// root, so we compute an explicit siteRoot for those paths.
var basePathname = baseUrlParsed.pathname; // e.g. "/" or "/test.cptest.tld/"
var siteRoot = baseDir + basePathname.replace(/\/$/, ''); // e.g. /home/user/public_html/test.cptest.tld
// Find all <link rel="stylesheet" href="..."> tags in the HTML string.
var linkRegex = /<link\b[^>]*\brel=["']stylesheet["'][^>]*>/gi;
var items = [];
var match;
while ( (match = linkRegex.exec(content)) !== null ) {
var hrefMatch = match[0].match(/\bhref=["']([^"']+)["']/i);
if (hrefMatch) {
items.push({ tag: match[0], href: hrefMatch[1] });
}
}
for (var i = 0; i < items.length; i++) {
var item = items[i];
var href = item.href;
if (/^(data:|blob:|javascript:)/i.test(href)) {
continue;
}
// Resolve to absolute URL and confirm it is on the user's domain.
var absoluteUrl;
try {
absoluteUrl = new URL(href, baseUrl).href;
} catch (e) {
continue;
}
if (!absoluteUrl.startsWith(domainOrigin + '/')) {
continue;
}
// Strip query string — we need the real filesystem path.
var urlPath = absoluteUrl.slice(domainOrigin.length).split('?')[0];
// Use baseDir for paths already scoped under basePathname (relative
// URLs resolved against baseUrl), otherwise use siteRoot so that
// root-relative paths (/webcard/…) resolve inside the site directory.
var fsBase = urlPath.startsWith(basePathname) ? baseDir : siteRoot;
var fsPath = fsBase + urlPath;
var lastSlash = fsPath.lastIndexOf('/');
var fsDir = fsPath.substring(0, lastSlash);
var fsFile = fsPath.substring(lastSlash + 1);
// Skip filenames the UAPI would reject.
if (!fsFile || /[/<>;]/.test(fsFile)) {
continue;
}
try {
var apiUrl = secToken + '/execute/Fileman/get_file_content?' +
'dir=' + encodeURIComponent(fsDir) +
'&file=' + encodeURIComponent(fsFile) +
'&charset=utf-8' +
'&update_html_document_encoding=0';
var response = await fetch(apiUrl);
if (!response.ok) {
continue;
}
var json = await response.json();
if (!json || json.status !== 1 || !json.data || typeof json.data.content !== 'string') {
continue;
}
// Insert a <style> block immediately after the original <link>.
// Use replace with a function to avoid '$' special replacement issues.
var styleBlock = '<style data-cpanel-injected="' + fsFile + '">\n' + json.data.content + '\n</style>';
content = content.replace(item.tag, function () { return item.tag + '\n' + styleBlock; });
} catch (e) {
// Network or parse error — leave the original <link> element.
}
}
// Append a small bottom-padding rule so the last line of content is
// always fully visible in the editor (not clipped by the viewport edge).
// The data-cpanel-injected attribute ensures stripInjectedStyles() removes
// this block before the file is written to disk.
var paddingBlock = '<style data-cpanel-injected="cpanel-editor-padding">\nbody { padding-bottom: 80px !important; }\n</style>';
content = content.replace(/<\/body>/i, paddingBlock + '\n</body>');
return content;
}
function showUploadProgress(files) {
if (typeof JoditUXHelpers !== 'undefined') {
return JoditUXHelpers.showProgress(files);
} else {
console.warn('JoditUXHelpers not available');
return [];
}
}
function updateUploadProgress(fileId, progress, status) {
if (typeof JoditUXHelpers !== 'undefined') {
JoditUXHelpers.updateProgress(fileId, progress, status);
} else {
console.warn('JoditUXHelpers not available');
}
}
function hideUploadProgress() {
if (typeof JoditUXHelpers !== 'undefined') {
JoditUXHelpers.hideProgress();
} else {
console.warn('JoditUXHelpers not available');
}
}
function showUploadToast(message, type) {
if (typeof JoditUXHelpers !== 'undefined') {
JoditUXHelpers.showToast(message, type);
} else {
console.warn('JoditUXHelpers not available');
}
}
// Public API
return {
initialize: initialize,
saveFile: saveFile,
previewFile: previewFile,
disableEditor: disableEditor,
enableEditor: enableEditor,
forceRecovery: forceRecovery,
showUploadProgress: showUploadProgress,
updateUploadProgress: updateUploadProgress,
hideUploadProgress: hideUploadProgress,
showUploadToast: showUploadToast,
getEditor: function () {
return joditInstance;
},
isDirty: function () {
return joditInstance && joditInstance.__isDirty === true;
},
};
})();
Back to Directory