Skip to main content

Face Detection in Oracle Apex

Face Detection Blog - Embedded
Oracle APEX · AI Integration A Developer's Real Story

Building a Face Detection
Attendance System
in Oracle APEX

A real story of trial, error, and finally making face-api.js actually work inside an APEX app. No fluff — just what happened and what finally worked.

0.22.2
face-api.js Version
2
APEX Pages Built
4
AJAX Processes
face-api.js Oracle APEX JavaScript AI / ML Face Recognition CDN Attendance System PL/SQL

So here is how it all started

I was tasked with building an attendance system for our office. The usual punch-card and swipe-card systems felt old and honestly a bit boring. I thought — why not use face recognition? I had seen a few demos online and it looked simple enough.

I work mainly with Oracle APEX, so naturally I wanted to build this whole thing inside APEX itself. I started looking for JavaScript libraries that could handle face detection right in the browser, without sending data to any external server. That's when I found face-api.js.

What is face-api.js? face-api.js is a browser-based face recognition library built on top of TensorFlow.js. It can detect faces, recognize them, and match them against stored data — all running locally in the browser. No server calls needed for the actual detection part.
๐Ÿง 
Library
face-api.js
๐Ÿ—️
Platform
Oracle APEX
๐Ÿ“ฆ
CDN Version
0.22.2
๐ŸŽฏ
Pages Built
2 Pages

I tried a few things that just did not work

My first approach was to load face-api.js from the @vladmandic/face-api CDN. I added the URL to the Page JavaScript File URLs field in APEX. The page loaded, but every time I checked the console, faceapi was undefined.

Problem I faced The @vladmandic/face-api CDN was not loading inside APEX pages. The library object was simply not available by the time my code ran, and the face detection never started.

I also tried loading the models from a local APEX static files folder. That gave me CORS errors in the console. Then I tried using a different script tag approach inside the HTML region — that caused timing issues where the script ran before the DOM was fully ready.

Did Not Work

Using @vladmandic/face-api CDN — the library would not load in APEX at all. Console kept showing faceapi is not defined no matter what I tried.

Finally Worked

Switching to the jsDelivr CDN for face-api.js@0.22.2 and using GitHub raw URLs for the AI model weights. Clean, reliable, no CORS issues.

The real root cause was two things: the CDN URL was wrong, and I had no fallback for when the library hadn't finished loading.

What finally worked I wrote a waitForFaceApi polling function that checks every 200ms whether faceapi is available, and only then loads the models. Combined with the correct CDN, this fixed everything.

How to set this up, step by step

The system has two pages — one for registering employee faces, and one for marking attendance using the camera. Both pages need the same CDN loaded first.

1

Add the CDN to both APEX pages

Go to each page properties and find the JavaScript section. In the File URLs field, add the face-api.js CDN link. This must be done on both the Register page and the Attendance page.

2

Set up browser security settings

Go to Page Properties → Security → Browser Security. Set the Referrer Policy to origin. This helps the model files load from GitHub without getting blocked.

3

Create the HTML structure

Add a Static Content Region to each page and put the HTML for the video feed, buttons, and status messages. The canvas element overlays the video to draw the face detection box.

4

Set up the AJAX Callback processes

You need four AJAX processes: SAVE_FACE, GET_FACES, MARK_ATT, and GET_TODAY_ATT. These handle the database operations from PL/SQL.

5

Add JavaScript in Execute when Page Loads

The full JavaScript goes inside the Execute when Page Loads section. Wrap everything in an IIFE so it does not create variable conflicts with APEX globals.

Step 1: The CDN URL for both pages

This goes inside Page Properties → JavaScript → File URLs on both pages. One issue: I had accidentally left the old vladmandic URL still in the field. Make sure you completely remove whatever was there before adding this.

File URLs Field
https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js

To verify it loaded, open the browser console and run this. If you get "object", the library is ready. If you get "undefined", the CDN URL is not saved correctly.

Browser Console Check
console.log(typeof faceapi);

Step 2: Register page HTML and styles

The register page lets you add an employee by entering their name, code, and department. The camera shows a live feed and draws a green box when a face is detected. Once detected, you click Capture to save the face descriptor to the database.

๐ŸŽจ HTML Header — CSS Styles

Add this in Page → HTML Header on the Register page:

register-styles.html
<style>
#faceRegWrap * { box-sizing: border-box; }
#faceRegWrap { max-width:680px; margin:auto; font-family:Arial,sans-serif; }
.frm-box { background:#1a1a2e; padding:22px; border-radius:14px; }
.frm-box h2 { color:#e94560; text-align:center; margin:0 0 16px; }
.inp-row { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; }
.inp-row input { flex:1; min-width:130px; padding:10px; border-radius:8px;
  border:1.5px solid #e94560; background:#16213e; color:#fff; font-size:14px; }
.vid-wrap { position:relative; width:100%; background:#000;
  border-radius:10px; overflow:hidden; }
.vid-wrap video { width:100%; display:block; border-radius:10px; }
.vid-wrap canvas { position:absolute; top:0; left:0; width:100%; height:100%; }
.btn-row { display:flex; gap:10px; justify-content:center;
  margin-top:14px; flex-wrap:wrap; }
.btn-primary { background:#e94560; color:#fff; padding:11px 26px;
  border:none; border-radius:8px; cursor:pointer; font-size:15px; font-weight:bold; }
.btn-secondary { background:#0f3460; color:#fff; padding:11px 26px;
  border:none; border-radius:8px; cursor:pointer; font-size:15px; font-weight:bold; }
.btn-primary:disabled,.btn-secondary:disabled { opacity:0.5; cursor:not-allowed; }
#regStatus { margin-top:12px; text-align:center; font-size:14px;
  color:#a0ffa0; padding:8px; min-height:28px; }
</style>

๐Ÿ—️ Static Content Region HTML

register-html.html
<div id="faceRegWrap">
  <div class="frm-box">
    <h2>Register Employee Face</h2>
    <div class="inp-row">
      <input id="regEmpName" type="text" placeholder="Employee Name *">
      <input id="regEmpCode" type="text" placeholder="Employee Code *">
      <input id="regEmpDept" type="text" placeholder="Department">
    </div>
    <div class="vid-wrap">
      <video id="regVideo" autoplay muted playsinline></video>
      <canvas id="regCanvas"></canvas>
    </div>
    <div class="btn-row">
      <button id="btnStartCam" class="btn-primary">Start Camera</button>
      <button id="btnCapture" class="btn-secondary" disabled>
        Capture &amp; Register
      </button>
    </div>
    <div id="regStatus">Initialising...</div>
  </div>
</div>

Step 3: Full JavaScript for the register page

Add this in Page → JavaScript → Execute when Page Loads. The key part is the waitForFaceApi function — it keeps checking every 200ms whether faceapi is available, and only then proceeds. Without this, everything was breaking.

register-page-js.js
(function () {

  var MODEL_URL = 'https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights';

  var stream            = null;
  var detectionTimer    = null;
  var currentDescriptor = null;
  var modelsReady       = false;

  var video      = document.getElementById('regVideo');
  var canvas     = document.getElementById('regCanvas');
  var btnStart   = document.getElementById('btnStartCam');
  var btnCapture = document.getElementById('btnCapture');
  var statusDiv  = document.getElementById('regStatus');

  function setStatus(msg, color) {
    statusDiv.innerHTML = msg;
    statusDiv.style.color = color || '#a0ffa0';
  }

  function waitForFaceApi(cb) {
    if (typeof faceapi !== 'undefined') { cb(); return; }
    var t = 0;
    var iv = setInterval(function () {
      t += 200;
      if (typeof faceapi !== 'undefined') { clearInterval(iv); cb(); }
      else if (t > 10000) {
        clearInterval(iv);
        setStatus('face-api.js failed to load. Check File URLs setting.', '#ff6666');
      }
    }, 200);
  }

  function loadModels() {
    setStatus('Loading AI models...');
    waitForFaceApi(function () {
      Promise.all([
        faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
        faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL),
        faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL)
      ]).then(function () {
        modelsReady = true;
        btnStart.disabled = false;
        setStatus('Ready. Click Start Camera.');
      }).catch(function (err) {
        setStatus('Model error: ' + err.message, '#ff6666');
        console.error('Model load error', err);
      });
    });
  }

  function startCamera() {
    if (!modelsReady) { setStatus('Models still loading...', '#ffcc00'); return; }
    btnStart.disabled = true;
    setStatus('Requesting camera...', '#ffcc00');
    navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } })
      .then(function (s) {
        stream = s;
        video.srcObject = s;
        video.onloadedmetadata = function () {
          canvas.width  = video.videoWidth;
          canvas.height = video.videoHeight;
          video.play();
          btnCapture.disabled = false;
          setStatus('Camera live. Position your face in the box.');
          startDetection();
        };
      })
      .catch(function (err) {
        btnStart.disabled = false;
        setStatus('Camera denied: ' + err.message, '#ff6666');
      });
  }

  function startDetection() {
    var ctx = canvas.getContext('2d');
    detectionTimer = setInterval(function () {
      if (video.readyState !== 4) return;
      canvas.width  = video.videoWidth;
      canvas.height = video.videoHeight;
      faceapi.detectSingleFace(video, new faceapi.TinyFaceDetectorOptions())
        .withFaceLandmarks()
        .withFaceDescriptor()
        .then(function (det) {
          ctx.clearRect(0, 0, canvas.width, canvas.height);
          if (det) {
            currentDescriptor = det.descriptor;
            var b = det.detection.box;
            ctx.strokeStyle = '#00ff88';
            ctx.lineWidth   = 3;
            ctx.strokeRect(b.x, b.y, b.width, b.height);
            ctx.fillStyle   = '#00ff88';
            ctx.font        = 'bold 13px Arial';
            ctx.fillText('Face Detected', b.x + 2, b.y > 18 ? b.y - 6 : b.y + 18);
          } else {
            currentDescriptor = null;
          }
        });
    }, 400);
  }

  function captureFace() {
    var name = document.getElementById('regEmpName').value.trim();
    var code = document.getElementById('regEmpCode').value.trim();
    var dept = document.getElementById('regEmpDept').value.trim();
    if (!name || !code) { setStatus('Name and Code are required', '#ffcc00'); return; }
    if (!currentDescriptor) { setStatus('No face detected.', '#ffcc00'); return; }
    btnCapture.disabled = true;
    setStatus('Saving to database...', '#ffcc00');
    apex.server.process('SAVE_FACE', {
      x01: name, x02: code, x03: dept,
      x04: JSON.stringify(Array.from(currentDescriptor))
    }, {
      success: function (data) {
        setStatus(name + ' registered successfully!');
        document.getElementById('regEmpName').value = '';
        document.getElementById('regEmpCode').value = '';
        document.getElementById('regEmpDept').value = '';
        btnCapture.disabled = false;
      },
      error: function (xhr, status, err) {
        setStatus('DB save failed: ' + (err || status), '#ff6666');
        btnCapture.disabled = false;
      }
    });
  }

  btnStart.disabled = true;
  btnStart.addEventListener('click',   startCamera);
  btnCapture.addEventListener('click', captureFace);
  loadModels();

}());

Step 4: The attendance marking page

The attendance page loads all registered face descriptors from the database, builds a FaceMatcher object, and continuously looks for known faces through the camera. When the user clicks Mark IN or Mark OUT, the next recognized face gets recorded.

One issue I faced here The match threshold matters a lot. I had it set to 0.6 initially and kept getting false positives. Dropping it to 0.5 made it much more accurate. If you still get issues, try 0.45 — but stricter matching means the person needs better lighting.

๐ŸŽจ Attendance Page CSS

attendance-styles.html
<style>
#attWrap { max-width:720px; margin:auto; font-family:Arial,sans-serif; }
.att-box { background:#0d0d1a; padding:22px; border-radius:14px; color:#fff; }
.att-box h2 { color:#00d4ff; text-align:center; margin:0 0 16px; }
.vid-wrap { position:relative; width:100%; background:#000; border-radius:10px; overflow:hidden; }
.vid-wrap video { width:100%; display:block; }
.vid-wrap canvas { position:absolute; top:0; left:0; width:100%; height:100%; }
.att-btns { display:flex; gap:10px; justify-content:center; margin-top:14px; flex-wrap:wrap; }
.btn-cam  { background:#00d4ff; color:#000; padding:11px 22px; border:none;
  border-radius:8px; cursor:pointer; font-weight:bold; font-size:14px; }
.btn-in   { background:#00c853; color:#fff; padding:11px 22px; border:none;
  border-radius:8px; cursor:pointer; font-weight:bold; font-size:14px; }
.btn-out  { background:#ff5722; color:#fff; padding:11px 22px; border:none;
  border-radius:8px; cursor:pointer; font-weight:bold; font-size:14px; }
.btn-cam:disabled,.btn-in:disabled,.btn-out:disabled { opacity:0.5; cursor:not-allowed; }
#attStatus { margin-top:12px; text-align:center; font-size:14px; padding:8px; min-height:28px; }
#attLog table { width:100%; border-collapse:collapse; font-size:13px; margin-top:10px; }
#attLog th { background:#1a1a3a; padding:7px; color:#00d4ff; }
#attLog td { padding:6px 7px; border-bottom:1px solid #222; }
</style>

๐Ÿ—️ Attendance Page HTML Region

attendance-html.html
<div id="attWrap">
  <div class="att-box">
    <h2>Face Attendance System</h2>
    <div class="vid-wrap">
      <video id="attVideo" autoplay muted playsinline></video>
      <canvas id="attCanvas"></canvas>
    </div>
    <div class="att-btns">
      <button id="btnAttCam" class="btn-cam" disabled>Start Camera</button>
      <button id="btnAttIn"  class="btn-in"  disabled>Mark IN</button>
      <button id="btnAttOut" class="btn-out" disabled>Mark OUT</button>
    </div>
    <div id="attStatus">Loading models...</div>
    <div id="attLog"></div>
  </div>
</div>

Step 5: Full attendance page JavaScript

This goes in Execute when Page Loads on the Attendance page. It loads registered faces from the database, builds the face matcher, and marks attendance when a known face is recognized after pressing a button.

attendance-page-js.js
(function () {

  var MODEL_URL = 'https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights';

  var stream         = null;
  var faceMatcher    = null;
  var modelsReady    = false;
  var pendingAttType = null;
  var processing     = false;

  var video     = document.getElementById('attVideo');
  var canvas    = document.getElementById('attCanvas');
  var btnCam    = document.getElementById('btnAttCam');
  var btnIn     = document.getElementById('btnAttIn');
  var btnOut    = document.getElementById('btnAttOut');
  var statusDiv = document.getElementById('attStatus');

  function setStatus(msg, color) {
    statusDiv.innerHTML  = msg;
    statusDiv.style.color = color || '#a0ffa0';
  }

  function waitForFaceApi(cb) {
    if (typeof faceapi !== 'undefined') { cb(); return; }
    var t = 0;
    var iv = setInterval(function () {
      t += 200;
      if (typeof faceapi !== 'undefined') { clearInterval(iv); cb(); }
      else if (t > 10000) {
        clearInterval(iv);
        setStatus('face-api.js not loaded. Check File URLs.', '#ff6666');
      }
    }, 200);
  }

  function loadModels() {
    setStatus('Loading AI models...');
    waitForFaceApi(function () {
      Promise.all([
        faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
        faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL),
        faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL)
      ]).then(function () {
        modelsReady = true;
        return loadRegisteredFaces();
      }).catch(function (err) {
        setStatus('Model load failed: ' + err.message, '#ff6666');
        console.error(err);
      });
    });
  }

  function loadRegisteredFaces() {
    setStatus('Loading registered faces from DB...', '#ffcc00');
    apex.server.process('GET_FACES', {}, {
      success: function (data) {
        var faces;
        try { faces = (typeof data === 'string') ? JSON.parse(data) : data; }
        catch (e) { setStatus('Bad JSON from GET_FACES', '#ff6666'); return; }
        if (!faces || faces.length === 0) {
          setStatus('No registered faces. Please register first.', '#ffcc00');
          btnCam.disabled = false;
          return;
        }
        var labeled = faces.map(function (f) {
          var arr = (typeof f.FACE_DESCRIPTOR === 'string')
                    ? JSON.parse(f.FACE_DESCRIPTOR) : f.FACE_DESCRIPTOR;
          return new faceapi.LabeledFaceDescriptors(
            f.EMP_ID + '|' + f.EMP_CODE + '|' + f.EMP_NAME,
            [new Float32Array(arr)]
          );
        });
        faceMatcher = new faceapi.FaceMatcher(labeled, 0.5);
        btnCam.disabled = false;
        setStatus(faces.length + ' face(s) loaded. Click Start Camera.');
        loadTodayLog();
      },
      error: function (xhr, status, err) {
        setStatus('GET_FACES failed: ' + (err || status), '#ff6666');
      }
    });
  }

  function startCamera() {
    btnCam.disabled = true;
    setStatus('Starting camera...', '#ffcc00');
    navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } })
      .then(function (s) {
        stream = s; video.srcObject = s;
        video.onloadedmetadata = function () {
          canvas.width = video.videoWidth; canvas.height = video.videoHeight;
          video.play();
          btnIn.disabled = false; btnOut.disabled = false;
          setStatus('Camera live. Click Mark IN or Mark OUT then look at camera.');
          startDetectionLoop();
        };
      })
      .catch(function (err) {
        btnCam.disabled = false;
        setStatus('Camera error: ' + err.message, '#ff6666');
      });
  }

  function startDetectionLoop() {
    var ctx = canvas.getContext('2d');
    setInterval(function () {
      if (video.readyState !== 4) return;
      canvas.width = video.videoWidth; canvas.height = video.videoHeight;
      faceapi.detectAllFaces(video, new faceapi.TinyFaceDetectorOptions())
        .withFaceLandmarks().withFaceDescriptors()
        .then(function (detections) {
          ctx.clearRect(0, 0, canvas.width, canvas.height);
          detections.forEach(function (det) {
            var b     = det.detection.box;
            var match = faceMatcher ? faceMatcher.findBestMatch(det.descriptor) : null;
            var known = match && match.label !== 'unknown';
            var conf  = match ? ((1 - match.distance) * 100).toFixed(1) : 0;
            ctx.strokeStyle = known ? '#00ff88' : '#ff4444';
            ctx.lineWidth = 3;
            ctx.strokeRect(b.x, b.y, b.width, b.height);
            ctx.fillStyle = known ? '#00ff88' : '#ff4444';
            ctx.font = 'bold 13px Arial';
            var label = known ? (match.label.split('|')[2] + ' (' + conf + '%)') : 'Unknown';
            ctx.fillText(label, b.x, b.y > 18 ? b.y - 6 : b.y + 18);
            if (known && pendingAttType && !processing) {
              processing = true;
              var parts = match.label.split('|');
              saveAttendance(parts[0], parts[1], parts[2], conf, pendingAttType);
              pendingAttType = null;
            }
          });
        });
    }, 500);
  }

  function saveAttendance(empId, empCode, empName, conf, attType) {
    setStatus('Marking ' + attType + ' for ' + empName + '...', '#ffcc00');
    btnIn.disabled = true; btnOut.disabled = true;
    apex.server.process('MARK_ATT', {
      x01: empId, x02: empCode, x03: empName, x04: attType, x05: conf
    }, {
      success: function () {
        setStatus(empName + ' marked ' + attType + ' (' + conf + '% match)');
        btnIn.disabled = false; btnOut.disabled = false;
        processing = false; loadTodayLog();
      },
      error: function (xhr, status, err) {
        setStatus('Save error: ' + (err || status), '#ff6666');
        btnIn.disabled = false; btnOut.disabled = false; processing = false;
      }
    });
  }

  function loadTodayLog() {
    apex.server.process('GET_TODAY_ATT', {}, {
      success: function (data) {
        var rows;
        try { rows = (typeof data === 'string') ? JSON.parse(data) : data; }
        catch (e) { return; }
        if (!rows || rows.length === 0) {
          document.getElementById('attLog').innerHTML =
            '<p style="color:#666;text-align:center;margin-top:10px;">No attendance recorded today.</p>';
          return;
        }
        var html = '<table><tr><th>Name</th><th>Code</th><th>Time</th><th>Type</th><th>Confidence</th></tr>';
        rows.forEach(function (r) {
          html += '<tr><td>' + r.EMP_NAME + '</td><td>' + r.EMP_CODE +
            '</td><td>' + r.ATT_TIME + '</td><td style="color:' +
            (r.ATT_TYPE==='IN' ? '#00ff88' : '#ff5555') + ';font-weight:bold;">' +
            r.ATT_TYPE + '</td><td>' + r.CONFIDENCE + '%</td></tr>';
        });
        html += '</table>';
        document.getElementById('attLog').innerHTML = html;
      }
    });
  }

  btnCam.addEventListener('click', startCamera);
  btnIn.addEventListener('click',  function () { pendingAttType = 'IN';  setStatus('Look at camera for IN mark...', '#ffcc00'); });
  btnOut.addEventListener('click', function () { pendingAttType = 'OUT'; setStatus('Look at camera for OUT mark...', '#ffcc00'); });

  loadModels();

}());

Step 6: The AJAX Callback processes you need

Without these, the JavaScript calls to apex.server.process will fail silently or return errors. Go to the Processing tab on each page and verify these exist as Ajax Callback type processes.

PageProcess NameTypePurpose
Register PageSAVE_FACEAjax CallbackSaves employee name, code, dept, and face descriptor JSON to the database
Attendance PageGET_FACESAjax CallbackReturns all registered face records as JSON for the FaceMatcher to load
Attendance PageMARK_ATTAjax CallbackInserts a new attendance record with employee ID, type, confidence, and timestamp
Attendance PageGET_TODAY_ATTAjax CallbackReturns today's attendance records to show in the table below the camera
Quick check After setting up the processes, open the browser Network tab and trigger a camera start. You should see XHR requests going to the APEX page with the process name. If you see 404 or errors, the process name does not match exactly what the JavaScript is calling.

What I learned from all this

Honestly, this project took me longer than I expected — mostly because of the CDN issue. The moment I switched to the correct jsDelivr URL and added the waitForFaceApi polling function, everything started working. The models load from GitHub raw URLs which is reliable and free.

The face matching accuracy is pretty good with proper lighting. I found that fluorescent office lighting actually works better than natural window light for this. Avoid backlighting — do not sit with a window behind you.

What ended up working well Using pure ES5 promise chains instead of async/await made the code much more compatible with APEX's JavaScript environment. Wrapping everything in an IIFE prevented any variable conflicts with APEX globals. And loading the models from GitHub raw URLs avoided all the CORS issues I had with local static files.

If you build something similar or run into issues, the GitHub repo below has the full code. Feel free to raise an issue there. The live demo is running on my APEX workspace so you can actually test the face registration flow yourself.

Ready to try it yourself?

The live demo is fully functional — test face registration right in your browser.

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

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. Live Demo GitHub Repo Oracle APEX jQuery UI PL/SQL Ajax Callbacks JavaScript CSS Introduction 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 tu...

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