Oracle Apex Version: 24.2
Database: Oracle Cloud 23ai (Apex Workload)
Hello, i want to share my two working plugin for free for everyone. You can add them in your code and use. My plugins are for ckeditor5 which is Oracle's current rich text editor. You can start step by step if you have questions you can comment it.
We have two plugins
- Implementing a mathfield by using mathlive plugin.
- We can write latex formulas visually by directly using mathfield input with its all functionality.
- We can show it in editor by using mathlive markup converter. (We cant use mathml conversion since ckeditor can't show it)
- Markup converter generates a long html about 2-3 kb for a short formula. Mathml converter generates 200-300 bytes only. So that's why our code is saving the html (dataDowncast) as mathml but showing it as markup html (editingDowncast).


- Implementing a general file uploading.
- It supports inline pdf , video , image. Other types would be saved as a link text (depends on user input too)


Js Libraries
First of all we need to have these;
https://cdn.jsdelivr.net/npm/mathlive@0.104.0/dist/mathlive.min.js
https://cdn.jsdelivr.net/npm/@cortex-js/compute-engine@0.28.0/dist/compute-engine.min.js
https://cdn.jsdelivr.net/npm/mathlive@0.104.1/dist/mathlive-static.min.css
Create a rich text editor item.

JS Init Code
function(options){
return ckeditor_settings(options);
}
Main Code
function ckeditor_settings(options) {
class MathFieldPlugin {
constructor(editor) {
this.editor = editor;
}
init() {
const editor = this.editor;
editor.model.schema.register('math', {
inheritAllFrom: '$inlineObject',
allowAttributes: ['data-mathml', 'data-latex']
});
editor.conversion.for('dataDowncast').elementToElement({
model: 'math',
view: (modelElement, { writer }) => {
const latex = modelElement.getAttribute('data-latex');
const mathMl = MathLive.convertLatexToMathMl(latex);
const container = writer.createContainerElement('span', {
class: 'math-widget',
'data-latex': latex,
style: 'display: inline-block;max-width: 100%;max-height:100%;white-space: nowrap;overflow-x: auto;overflow-y: hidden;'
});
writer.insert(
writer.createPositionAt(container, 0),
writer.createRawElement('math', {}, function(domElement) {
domElement.innerHTML = mathMl;
})
);
return container;
}
});
editor.conversion.for('editingDowncast').elementToElement({
model: 'math',
view: (modelElement, { writer }) => {
const latex = modelElement.getAttribute('data-latex');
const mathMl = MathLive.convertLatexToMarkup(latex);
const container = writer.createContainerElement('span', {
class: 'math-widget ck-widget',
'data-latex': latex,
style: 'display:inline-block;',
contenteditable: 'false'
});
writer.insert(
writer.createPositionAt(container, 0),
writer.createRawElement('span', {}, function(domElement) {
domElement.innerHTML = mathMl;
})
);
return ckeditor5.toWidget(container, writer, { label: 'Math formula widget' });
}
});
editor.conversion.for('editingDowncast').add(dispatcher => {
dispatcher.on('attribute:data-latex:math', (evt, data, conversionApi) => {
const viewElement = conversionApi.mapper.toViewElement(data.item);
if (viewElement) {
const writer = conversionApi.writer;
writer.remove(writer.createRangeIn(viewElement));
const latex = data.item.getAttribute('data-latex');
const mathMl = MathLive.convertLatexToMarkup(latex);
writer.insert(
writer.createPositionAt(viewElement, 0),
writer.createRawElement('span', {}, function(domElement) {
domElement.innerHTML = mathMl;
})
);
}
});
});
editor.conversion.for('upcast').elementToElement({
view: {
name: 'span',
classes: 'math-widget'
},
model: (viewElement, { writer }) => {
const latex = viewElement.getAttribute('data-latex');
return writer.createElement('math', { 'data-latex': latex });
}
});
editor.ui.componentFactory.add('insertMath', locale => {
const button = new ckeditor5.ButtonView(locale);
button.set({
label: '',
withText: false,
tooltip: 'Insert Formula'
});
button.extendTemplate({
attributes: {
class: 'fa fa-function'
}
});
button.on('execute', () => {
openMathPopup(editor);
});
return button;
});
editor.editing.view.document.on('click', (evt, data) => {
if ( data.domEvent.detail === 2 ) {
let widgetElement = data.target;
if ( !widgetElement.hasClass('math-widget') ) {
widgetElement = widgetElement.findAncestor(element => element.hasClass('math-widget'));
}
if ( widgetElement ) {
const modelElement = editor.editing.mapper.toModelElement(widgetElement);
openMathPopup(editor, modelElement);
}
}
});
}
}
const mfe = new MathfieldElement({
virtualKeyboardToggle: true,
mathVirtualKeyboardPolicy: 'manual'
});
mfe.style.minWidth = '250px';
mfe.style.display = 'block';
mfe.id = 'math-input';
function openMathPopup(editor, modelElement) {
const textView = new ckeditor5.View(editor.locale);
textView.setTemplate({
tag: 'div',
attributes: {
style: {
'padding': 'var(--ck-spacing-large)',
'min-width':'250px',
'display':'block',
'tabindex': -1,
'user-select':'text'
},
id:'math-field'
}
});
editor.plugins.get('Dialog').show({
isModal: false,
hasCloseButton: false,
title: 'Insert Formula',
content: textView,
actionButtons: [
{
label: 'Insert',
class: 'ck-button-action',
withText: true,
onExecute: () => {
const latex = document.getElementById('math-input').value;
if (latex) {
editor.model.change(writer => {
let mathElement;
if (modelElement) {
mathElement = modelElement;
writer.setAttribute('data-latex', latex, mathElement);
} else {
mathElement = writer.createElement('math', { 'data-latex': latex});
const selection = editor.model.document.selection;
writer.insert(mathElement, selection.getFirstPosition());
}
});
}
document.getElementById('math-input').value = "";
mathVirtualKeyboard.hide()
editor.plugins.get('Dialog').hide();
}
},
{
label: 'Cancel',
class: 'ck-button-cancel',
withText: true,
onExecute: () => {
document.getElementById('math-input').value = "";
mathVirtualKeyboard.hide()
editor.plugins.get('Dialog').hide();
}
}
]
});
document.querySelector('#math-field').appendChild(mfe);
if (modelElement) {
const currentLatex = modelElement.getAttribute('data-latex');
document.getElementById('math-input').value = currentLatex;
}
}
class InsertFilePlugin {
constructor(editor) {
this.editor = editor;
}
init() {
const editor = this.editor;
editor.ui.componentFactory.add('insertFile', locale => {
const button = new ckeditor5.ButtonView(locale);
button.set({
label: '',
withText: false,
tooltip: 'Insert File'
});
button.extendTemplate({
attributes: {
class: 'fa fa-upload'
}
});
button.on('execute', () => {
openFilePopup(editor);
});
return button;
});
}
}
function openFilePopup(editor) {
const fileView = new ckeditor5.View(editor.locale);
fileView.setTemplate({
tag: 'div',
attributes: {
style: {
'padding': 'var(--ck-spacing-large)',
'min-width': '300px',
'display': 'block'
},
id: 'file-upload-field'
},
children: [
{
tag: 'input',
attributes: {
type: 'file',
id: 'ckeditor-file-input',
style: 'display:block;margin-bottom:10px;'
}
},
{
tag: 'input',
attributes: {
type: 'text',
id: 'file-link-text',
placeholder: 'Enter link text (if not image/video/pdf)',
style: 'display:block;width:100%;margin-bottom:10px;'
}
}
]
});
editor.plugins.get('Dialog').show({
isModal: true,
hasCloseButton: true,
title: 'Upload File',
content: fileView,
actionButtons: [
{
label: 'Insert',
class: 'ck-button-action',
withText: true,
onExecute: () => {
var lSpinner$ = apex.util.showSpinner();
$('.ck-button-action').attr('disabled','disabled');
$('.ck-button-cancel').attr('disabled','disabled');
const fileInput = document.getElementById('ckeditor-file-input');
if (fileInput.files.length === 0) {
alert(apex.lang.getMessage("CKEDITOR_SELECT_FILE_VALIDATION"));
return;
}
const file = fileInput.files[0];
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const isPDF = file.type.startsWith('application/pdf');
if (file) {
var reader = new FileReader();
reader.onload = function(e) {
var base64Data = e.target.result.split(',')[1];
apex.server.process("AP_UPLOAD_FILE", {
p_clob_01: base64Data,
x01: file.name,
x02: file.type,
x03: isImage || isVideo || isPDF ? 'inline':'attachment'
}, {
success: function(pData) {
const fileUrl = pData.url;
editor.model.change(writer => {
if(isImage){
editor.execute( 'insertImage', { source: fileUrl } );
}
else if (isVideo || isPDF) {
editor.execute( 'mediaEmbed', fileUrl );
} else {
let linkText = document.getElementById('file-link-text').value;
if (!linkText) {
linkText = 'Download File';
}
const insertPosition = editor.model.document.selection.getFirstPosition();
const textElement = writer.createText(linkText, { linkHref: fileUrl });
writer.insert(textElement, insertPosition);
}
});
lSpinner$.remove();
editor.plugins.get('Dialog').hide();
},
error: function(jqXHR, textStatus, errorThrown) {
lSpinner$.remove();
editor.plugins.get('Dialog').hide();
}
});
};
reader.readAsDataURL(file);
} else {
alert(apex.lang.getMessage("CKEDITOR_SELECT_FILE_VALIDATION"));
}
}
},
{
label: 'Cancel',
withText: true,
class: 'ck-button-cancel',
onExecute: () => {
editor.plugins.get('Dialog').hide();
}
}
]
});
}
options.editorOptions.mediaEmbed = {
previewsInData: true,
providers: [
{
name: 'youtube',
url: /^https?:\/\/(www\.)?youtube\.com\/watch\?v=([\w-]+)/,
html: match => {
const videoId = match[2];
return `<iframe width="100%" height="300px" src="https://www.youtube.com/embed/${videoId}" frameborder="0" allowfullscreen></iframe>`;
}
},
{
name: 'youtube-short',
url: /^https?:\/\/youtu\.be\/([\w-]+)/,
html: match => {
const videoId = match[1];
return `<iframe width="100%" height="300px" src="https://www.youtube.com/embed/${videoId}" frameborder="0" allowfullscreen></iframe>`;
}
},
{
name: 'vimeo',
url: /^https?:\/\/(www\.)?vimeo\.com\/(\d+)/,
html: match => {
const videoId = match[2];
return `<iframe width="100%" height="300px" src="https://player.vimeo.com/video/${videoId}" frameborder="0" allowfullscreen></iframe>`;
}
},
{
name: 'dailymotion',
url: /^https?:\/\/(www\.)?dailymotion\.com\/video\/([\w-]+)/,
html: match => {
const videoId = match[2];
return `<iframe width="100%" height="300px" src="https://www.dailymotion.com/embed/video/${videoId}" frameborder="0" allowfullscreen></iframe>`;
}
}
]
};
options.editorOptions.extraPlugins = [
ckeditor5.MediaEmbed,
ckeditor5.ImageResizeEditing,
ckeditor5.ImageResizeHandles,
ckeditor5.Widget,
ckeditor5.Base64UploadAdapter,
ckeditor5.MediaEmbedEditing,
MathFieldPlugin,
InsertFilePlugin
];
options.editorOptions.toolbar = [
"heading",
"|",
"undo",
"redo",
"|",
"bold",
"italic",
"underline",
"strikethrough",
"subScript",
"superScript",
"code",
"removeFormat",
"|",
"fontSize",
"fontFamily",
"fontColor",
"fontBackgroundColor",
"highlight",
"|",
"alignment",
"indent",
"outdent",
"|",
"bulletedList",
"numberedList",
"todoList",
"|",
"specialCharacters",
"insertMath",
"insertFile" ,
"insertImageViaUrl",
"mediaEmbed",
"link",
"blockQuote",
"pageBreak",
"horizontalLine",
"insertTable",
"codeBlock"
];
return options;
}
Application Process for uploading file & returning URL
I'm using global page for P0_FILE_ID & P0_DISPOSITION and both has checksum - application based in order to keep the links working and secure within application.

declare
l_base64 clob := apex_application.g_clob_01;
l_file_name app_ses.bucket_file.file_name%Type := apex_application.g_x01;
l_mime_type app_ses.bucket_file.mime_type%Type := apex_application.g_x02;
l_disposition Varchar2(10 Char) := apex_application.g_x03;
l_blob blob;
v_file_id number;
v_url Varchar2(500 Char);
begin
l_blob := APEX_WEB_SERVICE.CLOBBASE642BLOB (p_clob => l_base64);
upload(
p_blob_file => l_blob,
p_content_type => l_mime_type,
p_file_name => l_file_name,
p_file_id => v_file_id
);
v_url := APEX_PAGE.GET_URL (
p_page => 0,
p_application => 123,
p_request => 'APPLICATION_PROCESS=AP_GET_OBJECT',
p_items => 'P0_FILE_ID,P0_DISPOSITION',
p_values => v_file_id||','||l_disposition,
p_plain_url => true,
p_absolute_url => true );
apex_json.open_object();
apex_json.write('status', 'success');
apex_json.write('url', v_url);
apex_json.close_object();
end;
Application Process 2 - AP_GET_OBJECT
Declare
v_file_name bucket_file.file_name%type;
v_object_name bucket_file.bucket_path%type;
v_mime_type bucket_file.mime_type%type;
v_disposition Varchar2(10 Char);
begin
Select file_name, bucket_path, mime_type
Into v_file_name, v_object_name, v_mime_type
From bucket_file
Where id = :P0_FILE_ID;
Case :P0_DISPOSITION
When 'auto' Then
Case
When v_mime_type like 'image/%' or v_mime_type like 'video/%' or v_mime_type = 'application/pdf' Then
v_disposition := 'inline';
Else
v_disposition := 'attachment';
End Case;
Else
v_disposition := :P0_DISPOSITION;
End Case;
pkg_bucket.p_get_file(
p_object_name => v_object_name,
p_file_name => v_file_name,
p_disposition => :P0_DISPOSITION);
end;
P_GET_FILE Procedure →
I'm using oracle object storage for file storing. But the point is you need to have mimetype, filename & blob content. So you can adjust your code same by changing storage with your blob column or object storage or aws doesnt matter.
Procedure p_get_file (
p_object_name Varchar2,
p_file_name Varchar2,
p_disposition Varchar2 Default 'attachment'
) Is
l_file dbms_cloud_oci_obs_object_storage_get_object_response_t ;
v_region Varchar2(500 Char);
v_bucket_namespace Varchar2(500 Char);
v_bucket_name Varchar2(500 Char);
Begin
Select value into v_bucket_name from parameters where name = 'BUCKET_NAME';
Select value into v_bucket_namespace from parameters where name = 'BUCKET_NAMESPACE';
Select value into v_region from parameters where name = 'CLOUD_REGION_NAME';
l_file := dbms_cloud_oci_obs_object_storage.get_object(
namespace_name => v_bucket_namespace,
bucket_name => v_bucket_name,
object_name => p_object_name,
region => v_region,
credential_name => gv_credential_name );
owa_util.mime_header(l_file.headers.get_String('Content-Type'), false);
htp.p('Cache-Control: public, max-age=86400, immutable');
htp.p('Content-Disposition: '|| p_disposition || '; filename="'||p_file_name||'"');
OWA_UTIL.HTTP_HEADER_CLOSE;
WPG_DOCLOAD.DOWNLOAD_FILE(p_blob => l_file.response_body);
End p_get_file;
Thank you for reading my post ^_^ .