Skip to main content

Sticky Notes Widget Inside Oracle APEX

Oracle APEX Project

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.

Oracle APEX jQuery UI PL/SQL Ajax Callbacks JavaScript CSS

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.

x
Review Q4 reports before the standup
09 May 2025 10:30
x
Follow up with the infra team on DB access
10 May 2025 09:15
x
Check the new APEX 24.2 features list
11 May 2025 14:00
x
Deploy hotfix to production tonight
11 May 2025 16:45

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.

SQL
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
);
💡 Tip I used 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.

⚠️ I tried this first... My first attempt used page items (P44_NOTE_ID, P44_NOTE_TEXT etc.) to pass data to the callbacks using 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.

PL/SQL
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;
🔴 One issue I faced When I first ran this, the browser console showed a JSON parse error. It turned out APEX was adding some extra whitespace or response headers before my JSON. Setting the MIME header manually with OWA_UTIL.MIME_HEADER and closing it with OWA_UTIL.HTTP_HEADER_CLOSE fixed it completely.

The ADD_NOTE Callback

PL/SQL
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

PL/SQL
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

PL/SQL
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.

HTML
<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.

CSS
#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; }
Finally this worked The :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.

JavaScript
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);
  });
}
⚠️ I tried this first... My original draggable setup did not set a 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.
🔴 One issue I faced When typing inside the note, jQuery UI was intercepting keydown events and causing weird behavior in some browsers. I had to add e.stopPropagation() on both mousedown and keydown inside the body to stop jQuery UI from swallowing those events.

loadNotes Function

JavaScript
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

JavaScript
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); }
  });
}
Finally this approach worked For staggering new notes so they do not all pile up at the same position, I used a simple offset calculation based on how many notes already exist. Multiplying the note count by 22 pixels and wrapping at 300 gives a nice cascading effect when you add multiple notes in a row.

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.

JavaScript
$(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

1
Page Loads loadNotes() fires, calls the LOAD_NOTES Ajax callback, gets back a JSON array and renders each note on the board at its saved position.
2
User Clicks a Color Button addNote(color) is called. It inserts a row in the DB via ADD_NOTE and immediately renders the note using the returned ID. The note body gets auto-focused so the user can start typing right away.
3
User Types and Clicks Away The blur event on the note body triggers saveNote(), which calls UPDATE_NOTE to save the text and current position.
4
User Drags a Note jQuery UI updates the left/top CSS. On drag stop, saveNote() is called with the new coordinates so the position is persisted.
5
User Deletes a Note The note fades out with a CSS opacity and scale transition, then DELETE_NOTE fires to remove it from the database, and the element is removed from the DOM.

Things I Learned Along the Way

🔴 The XSS Risk At first I was using .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.
⚠️ I tried contenteditable with innerHTML I tried using innerHTML to set the initial note content when rendering, thinking it would preserve line breaks. It caused the XSS issue mentioned above and also created inconsistencies between what was stored and what was displayed. Using .text() for set and .text() for read kept everything consistent.
The caretRangeFromPoint trick Clicking on a contenteditable div normally puts the cursor at the start instead of where you actually clicked. The document.caretRangeFromPoint(e.clientX, e.clientY) API places the cursor exactly at the click position. This made the editing experience feel much more natural.
💡 Security via created_by Both DELETE_NOTE and UPDATE_NOTE include 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

Popular posts from this blog

Screen Recorder in Oracle APEX (Single Page)

Oracle APEX Tutorial Screen Recorder in Oracle APEX : Single Page Build a fully functional browser-based screen recorder inside Oracle APEX using just one Static Content region and native JavaScript. No plugins, no external libraries, no server uploads required. ✍️ Why I Built This I was working on a client project where the support team needed to record screen issues and share them directly from the APEX application, without switching to any external tool. Installing third-party software was not an option on their machines, and every screen recorder extension required IT approval. That is when I thought: the browser already has everything we need. Why not build it right inside APEX? That idea turned into this. &#127916; Start / Stop Recording &#128065; Instant Preview ⬇️ One-click Download &#128266; Audio + Video ...

IG Drag Fill Series

Excel Drag Fill in Oracle APEX Interactive Grid Oracle APEX · Interactive Grid Excel-Like Drag & Fill in APEX IG "I spent way too long trying to get this working… and then one weird jQuery trick changed everything." Oracle APEX Interactive Grid jQuery JavaScript PL/SQL CSS Live Demo GitHub scroll The Backstory If you've used Excel for even ten minutes, you already know that little green handle at the corner of a selected cell. You drag it down, and boom, values fill across every row automatically. It's one of those features users love so much they don't even think about it. They just expect it everywhere. So when a ...