Building a Sticky Notes
Widget in Oracle APEX
How I built a fully draggable, color-coded, per-user sticky notes board using jQuery UI, APEX Ajax callbacks, and a bit of patience.
Why I Built This
I have been building internal tools on Oracle APEX for a while now, and one thing I always felt was missing was a place where users could quickly jot down thoughts without leaving the page. Think of it like a personal scratchpad that lives right inside the app.
I had seen sticky note UIs in some Google products and I thought, how hard can this be in APEX? It turned out to be more interesting than I expected. There were a few wrong turns before I got to something I was actually happy with.
The finished widget in action
Setting Up the Database Table
The first thing I did was create a simple table to store the notes. Each note belongs to a user, has a color, text, and an X/Y position on the board so it remembers where you left it.
CREATE TABLE sticky_notes (
note_id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
note_text VARCHAR2(4000),
color VARCHAR2(20) DEFAULT 'yellow',
pos_x NUMBER DEFAULT 60,
pos_y NUMBER DEFAULT 60,
created_by VARCHAR2(255),
created_date DATE DEFAULT SYSDATE
);
GENERATED ALWAYS AS IDENTITY so I get a clean auto-increment primary key without needing a separate sequence. This is available from Oracle 12c onward.
The created_by column stores the APEX session user, so each person only ever sees their own notes. This was important from the start because this was meant to be a shared app with personal note spaces.
The Four Ajax Callbacks
I set up four Ajax Callback processes on the APEX page. These handle everything from loading notes to saving position changes when you drag a note around.
| Process Name | What It Does | Parameters |
|---|---|---|
| LOAD_NOTES | Returns all notes for the current user as a JSON array | None |
| ADD_NOTE | Inserts a new note and returns the generated ID | x01=text, x02=color, x03=x, x04=y |
| UPDATE_NOTE | Saves note text and position changes | x01=id, x02=text, x03=x, x04=y |
| DELETE_NOTE | Deletes a note by ID, only if owned by the user | x01=id |
I used the standard APEX APEX_APPLICATION.G_X01 through G_X04 variables to pass parameters. Nothing fancy, just the simplest approach that works.
pageItems in the apex.server.process call. It worked, but it felt messy because the page item values were sitting there visibly in the session state. Switching to G_X01-style parameters was much cleaner.
The LOAD_NOTES Callback
This one was the most important to get right. It needed to return proper JSON that the front end could parse without issues.
DECLARE
BEGIN
OWA_UTIL.MIME_HEADER('application/json', FALSE);
OWA_UTIL.HTTP_HEADER_CLOSE;
APEX_JSON.OPEN_ARRAY;
FOR r IN (
SELECT note_id,
note_text,
color,
pos_x,
pos_y,
TO_CHAR(created_date, 'DD Mon YYYY HH24:MI') AS created_date
FROM sticky_notes
WHERE created_by = SYS_CONTEXT('APEX$SESSION','APP_USER')
ORDER BY created_date
) LOOP
APEX_JSON.OPEN_OBJECT;
APEX_JSON.WRITE('id', r.note_id);
APEX_JSON.WRITE('text', NVL(r.note_text, ''));
APEX_JSON.WRITE('color', NVL(r.color, 'yellow'));
APEX_JSON.WRITE('x', NVL(r.pos_x, 60));
APEX_JSON.WRITE('y', NVL(r.pos_y, 60));
APEX_JSON.WRITE('created_date', NVL(r.created_date, ''));
APEX_JSON.CLOSE_OBJECT;
END LOOP;
APEX_JSON.CLOSE_ARRAY;
END;
OWA_UTIL.MIME_HEADER and closing it with OWA_UTIL.HTTP_HEADER_CLOSE fixed it completely.
The ADD_NOTE Callback
DECLARE
v_id NUMBER;
BEGIN
INSERT INTO sticky_notes (note_text, color, pos_x, pos_y, created_by, created_date)
VALUES (
APEX_APPLICATION.G_X01,
NVL(APEX_APPLICATION.G_X02, 'yellow'),
TO_NUMBER(NVL(APEX_APPLICATION.G_X03, '60')),
TO_NUMBER(NVL(APEX_APPLICATION.G_X04, '60')),
SYS_CONTEXT('APEX$SESSION','APP_USER'),
SYSDATE
) RETURNING note_id INTO v_id;
COMMIT;
HTP.P(v_id);
END;
The key thing here is the RETURNING note_id INTO v_id clause. The JavaScript needs the ID of the newly created note right away so it can render it on the board and wire up drag-and-drop properly.
The UPDATE_NOTE Callback
BEGIN
UPDATE sticky_notes
SET note_text = APEX_APPLICATION.G_X02,
pos_x = TO_NUMBER(NVL(APEX_APPLICATION.G_X03, '60')),
pos_y = TO_NUMBER(NVL(APEX_APPLICATION.G_X04, '60'))
WHERE note_id = TO_NUMBER(APEX_APPLICATION.G_X01)
AND created_by = SYS_CONTEXT('APEX$SESSION','APP_USER');
COMMIT;
END;
The DELETE_NOTE Callback
BEGIN
DELETE FROM sticky_notes
WHERE note_id = TO_NUMBER(APEX_APPLICATION.G_X01)
AND created_by = SYS_CONTEXT('APEX$SESSION','APP_USER');
COMMIT;
END;
The HTML Static Region
I added a static HTML region on the page which contains the toolbar with the color buttons and the board area where notes get dropped in dynamically.
<div id="note-toolbar">
<span class="toolbar-label">
<strong>Click Any Color to Add Sticky Note</strong>
</span>
<div class="color-btns">
<button class="btn-color" data-color="yellow" title="Yellow" style="background:#fff475;"></button>
<button class="btn-color" data-color="pink" title="Pink" style="background:#f28b82;"></button>
<button class="btn-color" data-color="blue" title="Blue" style="background:#cbf0f8;"></button>
<button class="btn-color" data-color="green" title="Green" style="background:#ccff90;"></button>
<button class="btn-color" data-color="purple" title="Purple" style="background:#d7aefb;"></button>
<button class="btn-color" data-color="orange" title="Orange" style="background:#fbbc04;"></button>
</div>
</div>
<div id="notes-board"></div>
The color buttons each have a data-color attribute. In the JavaScript, I listen for clicks on .btn-color and pass that color value to the addNote() function.
The CSS for the Board and Notes
I put all the styles in the Inline CSS section of the APEX page. The board uses a dotted grid background to give that corkboard-like feel.
#notes-board {
position : relative;
min-height : 620px;
width : 100%;
background : #000000;
background-image:
radial-gradient(circle, #c8c4be 1px, transparent 1px);
background-size : 28px 28px;
border-radius : 10px;
border : 1px solid #ddd;
overflow : hidden;
}
#note-toolbar {
display : flex;
align-items : center;
gap : 12px;
padding : 0 4px 12px;
flex-wrap : wrap;
}
.btn-color {
width : 28px;
height : 28px;
border-radius : 50%;
border : 2.5px solid transparent;
cursor : pointer;
transition : transform .15s, border-color .15s, box-shadow .15s;
padding : 0;
}
.btn-color:hover {
transform : scale(1.2);
border-color : rgba(0,0,0,0.25);
box-shadow : 0 2px 8px rgba(0,0,0,0.18);
}
.sticky-note {
position : absolute;
width : 215px;
min-height : 185px;
border-radius : 6px 6px 5px 5px;
box-shadow : 3px 4px 14px rgba(0,0,0,0.22),
0 1px 3px rgba(0,0,0,0.12);
display : flex;
flex-direction : column;
z-index : 10;
font-family : "Segoe UI", "Helvetica Neue", sans-serif;
font-size : 13.5px;
transition : box-shadow .15s;
}
.sticky-note:focus-within {
z-index : 50;
box-shadow : 5px 6px 22px rgba(0,0,0,0.32),
0 2px 6px rgba(0,0,0,0.15);
}
.sticky-note .note-header {
display : flex;
justify-content : space-between;
align-items : center;
padding : 6px 8px 5px;
cursor : move;
border-radius : 6px 6px 0 0;
background : rgba(0,0,0,0.10);
user-select : none;
flex-shrink : 0;
}
.note-delete {
width : 20px;
height : 20px;
border-radius : 50%;
border : none;
background : transparent;
cursor : pointer;
display : flex;
align-items : center;
justify-content: center;
font-size : 13px;
color : rgba(0,0,0,0.35);
padding : 0;
transition : background .12s, color .12s;
}
.note-delete:hover {
background : rgba(0,0,0,0.15);
color : #b00;
}
.sticky-note .note-body {
flex : 1;
padding : 9px 11px 11px;
outline : none;
cursor : text;
min-height : 120px;
word-wrap : break-word;
white-space : pre-wrap;
line-height : 1.55;
color : #222;
}
.sticky-note .note-body:empty:before {
content : "Type your note here...";
color : rgba(0,0,0,0.30);
pointer-events : none;
}
.sticky-note.yellow { background: #fff475; }
.sticky-note.pink { background: #f28b82; }
.sticky-note.blue { background: #cbf0f8; }
.sticky-note.green { background: #ccff90; }
.sticky-note.purple { background: #d7aefb; }
.sticky-note.orange { background: #fbbc04; }
.note-timestamp {
font-size : 10px;
color : rgba(0,0,0,0.35);
padding : 0 11px 6px;
text-align : right;
flex-shrink: 0;
}
@keyframes noteIn {
from { opacity: 0; transform: scale(0.85) rotate(-2deg); }
to { opacity: 1; transform: scale(1) rotate(0deg); }
}
.sticky-note { animation: noteIn 0.2s ease-out forwards; }
:focus-within pseudo-class was something I discovered while debugging the z-index stacking. When you click inside a note to type, the note needs to rise above all others. Using :focus-within to bump the z-index automatically handled this without any extra JavaScript.
The JavaScript Functions
All the JavaScript went into the Function and Global Variable Declaration section. There are four main functions: renderNote, loadNotes, addNote, saveNote, and deleteNote.
renderNote Function
This is the big one. It builds the entire note DOM structure, sets up jQuery UI draggable, and hooks up all the event listeners for editing, saving on blur, and deleting.
function renderNote(id, text, color, x, y, createdDate) {
$('[data-id="' + id + '"]').remove();
var safeColor = ['yellow','pink','blue','green','purple','orange'].indexOf(color) > -1
? color : 'yellow';
var $note = $('<div>')
.addClass('sticky-note ' + safeColor)
.attr('data-id', id)
.css({ left: x + 'px', top: y + 'px' });
var $header = $('<div>').addClass('note-header');
var $dots = $('<div>').addClass('note-drag-dots')
.append('<span></span><span></span><span></span><span></span><span></span><span></span>');
var $delBtn = $('<button>').addClass('note-delete')
.attr('title', 'Delete note')
.attr('type', 'button')
.html('✕');
$header.append($dots).append($delBtn);
var $body = $('<div>')
.addClass('note-body')
.attr('contenteditable', 'true')
.attr('spellcheck', 'false')
.attr('role', 'textbox')
.attr('aria-label', 'Sticky note text')
.attr('aria-multiline', 'true');
if (text && text !== 'New note...') {
$body.text(text);
}
var $ts = $('<div>').addClass('note-timestamp');
if (createdDate) { $ts.text(createdDate); }
$note.append($header).append($body).append($ts);
$('#notes-board').append($note);
$note.draggable({
containment : '#notes-board',
handle : '.note-header',
cancel : '.note-body, .note-timestamp',
scroll : false,
start: function() {
$(this).css('z-index', 100);
},
stop: function(e, ui) {
$(this).css('z-index', 50);
saveNote(
id,
$('[data-id="' + id + '"] .note-body').text(),
Math.round(ui.position.left),
Math.round(ui.position.top)
);
}
});
$body.on('click', function(e) {
e.stopPropagation();
$(this).focus();
if (document.caretRangeFromPoint) {
var range = document.caretRangeFromPoint(e.clientX, e.clientY);
if (range) {
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
}
});
$body.on('mousedown', function(e) { e.stopPropagation(); });
$body.on('keydown', function(e) { e.stopPropagation(); });
$body.on('blur', function() {
saveNote(
id,
$(this).text(),
Math.round(parseInt($note.css('left'))),
Math.round(parseInt($note.css('top')))
);
});
$delBtn.on('click', function(e) {
e.stopPropagation();
$note.css({ opacity: '0', transform: 'scale(0.8)' });
setTimeout(function() { deleteNote(id, $note); }, 180);
});
$note.on('mousedown', function() {
$('.sticky-note').css('z-index', 10);
$(this).css('z-index', 50);
});
}
handle. That meant clicking anywhere on the note to type would start a drag instead. Setting handle: '.note-header' and cancel: '.note-body' fixed it so the text area behaves like normal and drag only starts from the header.
e.stopPropagation() on both mousedown and keydown inside the body to stop jQuery UI from swallowing those events.
loadNotes Function
function loadNotes() {
$('#notes-board').empty();
apex.server.process('LOAD_NOTES', {}, {
dataType: 'text',
success: function(data) {
var trimmed = $.trim(data);
if (!trimmed || trimmed.length === 0) { return; }
var notes;
try {
notes = JSON.parse(trimmed);
} catch(e) {
console.error('LOAD_NOTES parse error:', e, '| Raw:', trimmed);
apex.message.showErrors([{
type : 'error',
location: 'page',
message : 'Could not load notes. Check browser console for details.'
}]);
return;
}
if (!Array.isArray(notes)) { return; }
notes.forEach(function(n) {
renderNote(
n.id,
n.text || '',
n.color || 'yellow',
n.x || 60,
n.y || 60,
n.created_date || ''
);
});
},
error: function(xhr, status, err) {
console.error('LOAD_NOTES AJAX error:', status, err);
}
});
}
addNote, saveNote, and deleteNote Functions
function addNote(color) {
var offset = ($('.sticky-note').length * 22) % 300;
var posX = 60 + offset;
var posY = 60 + offset;
apex.server.process('ADD_NOTE', {
x01: '',
x02: color || 'yellow',
x03: String(posX),
x04: String(posY)
}, {
dataType: 'text',
success: function(data) {
var newId = $.trim(data);
if (newId && !isNaN(newId)) {
renderNote(parseInt(newId), '', color || 'yellow', posX, posY, 'Just now');
setTimeout(function() {
$('[data-id="' + newId + '"] .note-body').focus();
}, 250);
} else {
console.error('ADD_NOTE: unexpected response:', data);
}
},
error: function(xhr, s, e) { console.error('addNote error:', s, e); }
});
}
function deleteNote(id, $el) {
apex.server.process('DELETE_NOTE', { x01: String(id) }, {
dataType: 'text',
success: function() { $el.remove(); },
error: function(xhr, s, e) { console.error('deleteNote error:', s, e); }
});
}
function saveNote(id, text, x, y) {
apex.server.process('UPDATE_NOTE', {
x01: String(id),
x02: text || '',
x03: String(x),
x04: String(y)
}, {
dataType: 'text',
error: function(xhr, s, e) { console.error('saveNote error:', s, e); }
});
}
Wiring the Toolbar Buttons
This part was quick. I put a small script in the Execute When Page Loads section to attach the click handler to the color buttons and trigger the initial load.
$(document).on('click', '.btn-color', function() {
var color = $(this).data('color');
addNote(color);
});
loadNotes();
That is really it. Two lines in Execute When Page Loads and all six color buttons are wired up. The loadNotes() call fires on page load and populates the board with whatever notes that user has saved.
How It All Comes Together
Things I Learned Along the Way
.html(text) to set the note content. That is a big XSS problem because if someone stored a script tag in the note text, it would execute. I switched to .text(text) which treats the content as plain text and escapes everything automatically.
document.caretRangeFromPoint(e.clientX, e.clientY) API places the cursor exactly at the click position. This made the editing experience feel much more natural.
AND created_by = SYS_CONTEXT('APEX$SESSION','APP_USER') in their WHERE clause. This means a user cannot delete or edit someone else's note even if they somehow got hold of the note ID. The database enforces the ownership check, not just the UI.
Comments
Post a Comment