In this blog post, we’ll explore how to enhance the Cases module in SuiteCRM by replacing a standard textarea field with a TinyMCE editor, and enabling the functionality to paste images directly into the editor. This is particularly useful for situations where detailed information, including screenshots or other visual elements, needs to be documented.
Step 1: Setting Up TinyMCE Editor
The first step is to replace the default textarea with the TinyMCE editor. This can be done by modifying the editviewdefs.php file, which defines the layout and fields of the Cases module’s edit view. By including the type of wysiwyg and initializing it on the desired textarea fields, you can enhance the editing experience with rich-text capabilities. We also need to add some javascript includes to the file.
If the file custom/modules/<module>/metadata/editviewdefs.php does not exist start by copying the original from modules/<module>/metadata/editviewdefs.php.
We first want to add some includes we will need. In particular, the CasePaste.js and paste.min.js. CasePaste.js is a custom file we will come back to and paste.min.js is a library to help with our intended functionality.
$viewdefs['Cases']['EditView'] = array (
'templateMeta' =>
array (
'includes' =>
array(
0 =>
array(
'file' => 'include/javascript/bindWithDelay.js',
),
1 =>
array(
'file' => 'modules/AOK_KnowledgeBase/AOK_KnowledgeBase_SuggestionBox.js',
),
2 =>
array(
'file' => 'include/javascript/qtip/jquery.qtip.min.js',
),
3 =>
array(
'file' => 'https://code.jquery.com/jquery-1.11.1.min.js',
),
4 =>
array(
'file' => 'https://code.jquery.com/ui/1.11.1/jquery-ui.min.js',
),
5 =>
array(
'file' => 'custom/modules/Cases/js/CasePaste.js',
),
6 =>
array(
'file' => 'custom/modules/Cases/js/paste.min.js',
),
),
'maxColumns' => '2',
We will also want to edit the fields we want to apply the html editing for. In SuiteCRM there is an existing field type we will utilize. In our example we will apply this to the description and resolution fields. All we need to do is add the proper type to these fields.
8 =>
array (
0 =>
array (
'name' => 'description',
'type' => 'wysiwyg',
),
),
9 =>
array (
0 =>
array (
'name' => 'resolution',
'type' => 'wysiwyg',
),
),
We also need to make some changes to the detailviewdefs.php file so the html changes show properly.
7 =>
array (
0 => array(
'name' => 'description',
'label' => 'LBL_DESCRIPTION',
'customCode' => '{$fields.description.value|html_entity_decode}',
),
),
8 =>
array (
0 => array (
'name' => 'resolution',
'label' => 'LBL_RESOLUTION',
'customCode' => '{$fields.resolution.value|html_entity_decode}',
// 'type' => 'html',
)
),
Step 2: Database update (optional)
These changes only apply to textarea fields. By default, these fields are created as text but which can only store a certain amount of characters. Due to the possible large amount of data in an HTML field I suggest converted the fields you are applying these changes to as either mediumtext or longtext.
Step 3: Include Javascript Files
If you do not want to allow image pasting then congratulations; you do not need to do anything else.
Next let’s grab the Paste.js library to allow image pasting directly into the TinyMCE editor. This library provides support for pasting images as Base64 data URLs. The library listens for paste events and processes any image data included in the clipboard.
The core functionality is to monitor specific elements for paste events and handle images appropriately:
Step 4: Handling Pasted Images with CasePaste.js
To handle pasted images, the CasePaste.js script is used. It captures paste events and uploads the pasted image to the server, where it’s stored and its URL is returned. This URL is then inserted into the TinyMCE editor at the cursor position.
Here’s a breakdown of how this works:
1. Capture Paste Event: The script binds to the paste event on the TinyMCE editor’s iframe body.
2. Extract Image Data: If the clipboard contains an image, it’s extracted as a file blob and read using a FileReader.
3. Upload Image: The image data is sent to the server using an AJAX call.
4. Insert Image URL: Once the server returns the image URL, it’s inserted into the TinyMCE editor as an <img> tag.
The key points below are the jQuery identifiers that relate to the fields we are converting. Also notice there is a custom endpoint that we send the image to for storing on the file system of our server. In the next step we will highlight the Entry Point to handle this.
// Get paste.js to watch our text area
ajax_data_url = function(data_url, file_name) {
var csrf = "{{ csrf_token() }}";
var payload = {
"data_url": data_url,
"file_name": file_name,
"csrf": csrf
};
$.ajax({
url: '/api/image-upload',
data: JSON.stringify(payload),
contentType: 'application/json',
dataType: 'json',
success: function(data) {
var post_content = $('#tinymce').val() + data['md'];
$('#tinymce').val(post_content);
},
error: function(jqXHR, textStatus, errorThrown) {
var responseJSON = jqXHR.responseJSON;
console.log("error saving image: " + responseJSON);
},
});
}
$(document).ready(function() {
function checkEditors() {
if (tinymce.editors.length !== 2) {
setTimeout(checkEditors, 500);
} else {
$("#description_ifr").contents().find("body").bind('paste', function(event) {
var items = (event.clipboardData || event.originalEvent.clipboardData).items;
console.log(JSON.stringify(items)); // might give you mime types
for (var index in items) {
var item = items[index];
if (item.kind === 'file') {
var blob = item.getAsFile();
var reader = new FileReader();
reader.onload = function (event) {
console.log(event.target.result); // data url!
sendFile(event.target.result, 'start','description')
};
reader.readAsDataURL(blob);
}
}
});
$("#resolution_ifr").contents().find("body").bind('paste', function(event) {
var items = (event.clipboardData || event.originalEvent.clipboardData).items;
console.log(JSON.stringify(items)); // might give you mime types
for (var index in items) {
var item = items[index];
if (item.kind === 'file') {
var blob = item.getAsFile();
var reader = new FileReader();
reader.onload = function (event) {
console.log(event.target.result); // data url!
sendFile(event.target.result, 'start','resolution')
};
reader.readAsDataURL(blob);
}
}
});
}
}
checkEditors();
// $("#description_ifr").contents().find("body").bind('paste', function() {
// console.log('pasted')
// });
const sendFile = (blobInfo, progress, field) => new Promise((resolve, reject) => {
debugger
const xhr = new XMLHttpRequest();
xhr.withCredentials = false;
xhr.open('POST', 'index.php?entryPoint=jackalStorePastedImage');
SUGAR.ajaxUI.showLoadingPanel();
xhr.onload = () => {
if (xhr.status === 403) {
reject({ message: 'HTTP Error: ' + xhr.status, remove: true });
return;
}
if (xhr.status < 200 || xhr.status >= 300) {
reject('HTTP Error: ' + xhr.status);
return;
}
const json = JSON.parse(xhr.responseText);
debugger;
if (!json || typeof json.url != 'string') {
reject('Invalid JSON: ' + xhr.responseText);
return;
}
// Get the TinyMCE editor instance
const editor = tinymce.get(field);
if (editor) {
// Create an img element with the uploaded image URL
const imgHtml = '<img src="' + json.url + '" alt="Pasted image">';
// Insert the image HTML at the current cursor position
editor.execCommand('mceInsertContent', false, imgHtml);
// Remove the original pasted image (if any)
const body = editor.getBody();
const images = body.getElementsByTagName('img');
for (let i = images.length - 1; i >= 0; i--) {
const img = images[i];
if (img.src.startsWith('data:')) {
img.parentNode.removeChild(img);
}
}
}
SUGAR.ajaxUI.hideLoadingPanel();
resolve(json.url);
};
xhr.onerror = () => {
SUGAR.ajaxUI.hideLoadingPanel();
reject('Image upload failed due to a XHR Transport error. Code: ' + xhr.status);
};
const formData = new FormData();
formData.append('imageData', blobInfo);
xhr.send(formData);
});
})
Step 5: Register Entry Point
Next, we need to register an Entry Point in SuiteCRM to handle the server-side image storage
<?php
//custom/Extension/application/Ext/EntryPointRegistry/store_images.php
$entry_point_registry['jackalStorePastedImage'] = array(
'file' => 'custom/include/JackalSoftware/JackalStoreImageData.php',
'auth' => true,
);
Create the file store handle image storage
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['imageData'])) {
$imageData = $_POST['imageData'];
// Extract the base64 encoded image data
list($type, $data) = explode(';', $imageData);
list(, $data) = explode(',', $data);
$imageData = base64_decode($data);
// Generate a unique filename
$filename = uniqid() . '.png';
// Define the upload directory
$uploadDir = 'public/images/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// Save the image file
$filePath = $uploadDir . $filename;
if (file_put_contents($filePath, $imageData)) {
// Generate the URL path
$urlPath = $GLOBALS['sugar_config']['site_url'] . '/' . $filePath;
// Return the URL path as JSON
header('Content-Type: application/json');
global $sugar_config;
echo json_encode(['url' => $urlPath]);
} else {
// Handle error
header('HTTP/1.1 500 Internal Server Error');
echo json_encode(['error' => 'Failed to save image']);
}
} else {
// Handle invalid request
header('HTTP/1.1 400 Bad Request');
echo json_encode(['error' => 'Invalid request']);
}