// ==UserScript==
// @name Oracle APEX Object Browser in Page Designer
// @run-at document-idle
// @namespace https://github.com/lufcmattylad
// @version 26.1.1
// @description Adds an "Object Browser" tab to the right of the Layout tab in the APEX 26.1 Page Designer. The tab embeds the SQL Workshop Object Browser (#main only) in an iframe using the current builder session, so you can browse tables and other objects without leaving the page you are editing.
// @author Matt Mulvaney - @Matt_Mulvaney
// @match *://*/ords/*
// @match *://*/pls/*
// @tag orclapex
// @grant none
// @downloadURL https://raw.githubusercontent.com/lufcmattylad/oracle-apex-userscripts/refs/heads/main/scripts/oracle-apex-obj-browser-in-pd/script.js
// ==/UserScript==
(function () {
'use strict';
/**
* DISCLAIMER:
* This code is unofficial and is not supported by Oracle APEX.
* It is provided "as is" without warranty of any kind, either express or implied.
* Use of this code is at your own risk. The authors and distributors accept no responsibility
* for any consequences arising from its use.
*/
const PANEL_ID = 'object_browser';
const ANCHOR_ID = 'pd-ob-tab-anchor';
const TAB_MARKER = 'js-pd-ob-tab'; // stable class on our
, used to find it
let $; // assigned once apex.jQuery is available
let obResizeObserver = null; // tracks editor size so the panel/iframe fill it
let injectedTab = null; // the default tab we injected, dropped if APEX restores its own
let isDragging = false; // true while a jQuery UI sortable drag is in progress
// CSS injected into the iframe to strip everything except the Object Browser itself.
const IFRAME_HIDE_CSS =
'header.b-Header{display:none !important;}' +
'.b-Content > .a-ControlBar .a-Breadcrumb{display:none !important;}';
// Page Designer is application 4000, page 4500. Require APEX 26.1+.
function isPageDesigner() {
if (typeof apex === 'undefined' || !apex.env) return false;
if (apex.env.APP_ID !== '4000' || apex.env.APP_PAGE_ID !== '4500') return false;
const version = apex.env.APEX_VERSION || '';
const major = parseInt(version.split('.')[0], 10);
const minor = parseInt(version.split('.')[1], 10);
return major > 26 || (major === 26 && minor >= 1);
}
function objectBrowserUrl() {
return location.origin +
'/ords/r/apex/sql-workshop/ob?session=' + encodeURIComponent(apex.env.APP_SESSION);
}
// Trim the embedded page down to #main once it has loaded, then force it to lay
// out to the full iframe height. Same-origin, so we can reach into the iframe.
function trimIframe(iframe) {
try {
const win = iframe.contentWindow;
const doc = iframe.contentDocument;
if (!doc || !doc.head) return;
let style = doc.getElementById('pd-ob-embed-style');
if (!style) {
style = doc.createElement('style');
style.id = 'pd-ob-embed-style';
doc.head.appendChild(style);
}
style.textContent = IFRAME_HIDE_CSS;
// The Object Browser uses an a-Splitter. When the iframe is hidden (its tab
// is not active) its viewport collapses to 0 and APEX throws "Splitter needs
// to be in a component with size" from its resize handler. Harmless, so
// swallow just that message.
if (win && !win.__obSplitterGuard) {
win.__obSplitterGuard = true;
win.addEventListener('error', function (e) {
if (e && e.message && e.message.indexOf('Splitter needs to be in a component with size') !== -1) {
e.preventDefault();
}
});
}
// Re-lay out to the full height now the header is hidden; a delayed second
// nudge wins against APEX's own late layout pass. Only while visible.
if (win && iframe.offsetParent !== null) {
win.dispatchEvent(new Event('resize'));
setTimeout(function () {
try {
if (iframe.offsetParent !== null && iframe.contentWindow) {
iframe.contentWindow.dispatchEvent(new Event('resize'));
}
} catch (e) { /* ignore */ }
}, 200);
}
} catch (e) {
// Cross-origin or timing issue - leave the page as-is.
}
}
// Tell the embedded Object Browser to re-lay out to the current iframe size.
function nudgeIframeLayout() {
const iframe = document.getElementById('pd-ob-iframe');
if (iframe && iframe.dataset.loaded && iframe.offsetParent !== null && iframe.contentWindow) {
try { iframe.contentWindow.dispatchEvent(new Event('resize')); } catch (e) { /* ignore */ }
}
}
function buildPanel() {
const panel = document.createElement('div');
panel.id = PANEL_ID;
// A real jQuery UI tab panel, but deliberately NOT a ".resize" region: we size
// it ourselves (sizePanel) and keep it out of APEX's resize cascade.
panel.className = 'ui-corner-bottom ui-widget-content ui-tabs-panel';
panel.setAttribute('role', 'tabpanel');
// min-width/height:0 lets the surrounding splitter shrink the editor column
// below the iframe's intrinsic size - without it the column can only grow.
panel.style.cssText = 'overflow:hidden;padding:0;min-width:0;min-height:0;';
const iframe = document.createElement('iframe');
iframe.id = 'pd-ob-iframe';
iframe.title = 'Object Browser';
iframe.style.cssText = 'border:0;width:100%;height:100%;display:block;min-width:0;min-height:0;';
iframe.addEventListener('load', function () { trimIframe(iframe); });
panel.appendChild(iframe);
return panel;
}
function buildTab(layoutTab) {
const tab = layoutTab.cloneNode(true);
tab.classList.remove('ui-tabs-active', 'ui-state-active');
tab.classList.add(TAB_MARKER); // stable hook so we can find/restore our tab
tab.setAttribute('aria-selected', 'false');
tab.setAttribute('aria-controls', PANEL_ID);
tab.setAttribute('tabindex', '-1');
const anchor = tab.querySelector('a.ui-tabs-anchor');
anchor.id = ANCHOR_ID;
anchor.setAttribute('href', '#' + PANEL_ID); // hash => jQuery UI binds it to our panel
anchor.setAttribute('tabindex', '-1');
tab.setAttribute('aria-labelledby', ANCHOR_ID);
// Cloning the Layout tab keeps the responsive icon-only behaviour; we just
// swap the glyph. icon-database is the icon the Object Browser itself uses.
const icon = anchor.querySelector('.a-Icon');
if (icon) {
icon.className = 'a-Icon icon-database sf-hidden';
icon.setAttribute('title', 'Object Browser');
}
const label = anchor.querySelector('.a-Tabs-label');
if (label) label.textContent = 'Object Browser';
return tab;
}
// After a tab is dragged to a new position, APEX re-renders the tab bar from its
// internal model and blanks our (unknown) tab. Re-apply the label/icon/href in
// place, found via our marker class. Idempotent, so it won't loop when the
// MutationObserver sees our own change.
function ensureTabIntegrity() {
const tab = document.querySelector('li.' + TAB_MARKER);
if (!tab) return;
if (tab.getAttribute('aria-controls') !== PANEL_ID) tab.setAttribute('aria-controls', PANEL_ID);
const anchor = tab.querySelector('a.ui-tabs-anchor');
if (!anchor) return;
if (anchor.id !== ANCHOR_ID) anchor.id = ANCHOR_ID;
if (anchor.getAttribute('href') !== '#' + PANEL_ID) anchor.setAttribute('href', '#' + PANEL_ID);
if (tab.getAttribute('aria-labelledby') !== ANCHOR_ID) tab.setAttribute('aria-labelledby', ANCHOR_ID);
let icon = anchor.querySelector('.a-Icon');
if (!icon) {
icon = document.createElement('span');
icon.setAttribute('aria-hidden', 'true');
anchor.insertBefore(icon, anchor.firstChild);
}
if (icon.className !== 'a-Icon icon-database sf-hidden') icon.className = 'a-Icon icon-database sf-hidden';
if (icon.getAttribute('title') !== 'Object Browser') icon.setAttribute('title', 'Object Browser');
let label = anchor.querySelector('.a-Tabs-label');
if (!label) {
label = document.createElement('span');
label.className = 'a-Tabs-label';
anchor.appendChild(label);
}
if (label.textContent !== 'Object Browser') label.textContent = 'Object Browser';
}
// Size our panel to the full editor content area (tab container minus the tab
// bar), matching the height the built-in panels get from APEX's resize manager.
function sizePanel(tabs, panel) {
const toolbar = tabs.querySelector(':scope > .a-Tabs-toolbar');
const h = tabs.clientHeight - (toolbar ? toolbar.offsetHeight : 0);
if (h > 0) panel.style.height = h + 'px';
}
function refreshSize(tabs) {
const panel = document.getElementById(PANEL_ID);
if (panel && panel.offsetParent !== null) { // only when our tab is active/visible
sizePanel(tabs, panel);
nudgeIframeLayout();
}
}
function loadAndSize(tabs) {
const panel = document.getElementById(PANEL_ID);
const iframe = document.getElementById('pd-ob-iframe');
if (iframe && !iframe.dataset.loaded) {
iframe.dataset.loaded = '1';
iframe.src = objectBrowserUrl(); // first layout handled by trimIframe on load
}
if (panel) sizePanel(tabs, panel);
nudgeIframeLayout();
}
function bindHandlers(tabs) {
// jQuery UI handles tab switching, panel visibility, active state, ARIA and
// keyboard. We only lazy-load the iframe and keep it sized.
$(tabs).off('tabsactivate.obpd');
$(tabs).on('tabsactivate.obpd', function (event, ui) {
if (ui.newPanel && ui.newPanel[0] && ui.newPanel[0].id === PANEL_ID) loadAndSize(tabs);
});
$(window).off('resize.obpd').on('resize.obpd', function () { refreshSize(tabs); });
// The column splitter resizes via APEX's internal layout (no window resize),
// so a ResizeObserver on the tab container is what actually tracks it.
if (!obResizeObserver && window.ResizeObserver) {
obResizeObserver = new ResizeObserver(function () { refreshSize(tabs); });
obResizeObserver.observe(tabs);
}
}
// Keep exactly one Object Browser tab and one panel. The tab is freely draggable
// (incl. into other tab bars, where it shows blank as its panel lives here). The
// panel/iframe is created once and never re-parented. On reload APEX restores the
// tab at the position you left it; if our default copy also got injected, we drop
// ours and keep APEX's, so there is never a duplicate.
function ensure() {
const tabs = document.getElementById('editor_tabs');
if (!tabs) return false;
const tablist = tabs.querySelector('ul[role="tablist"]');
const layoutTab = tablist && tablist.querySelector('li[aria-controls="grid_layout"]');
const layoutPanel = document.getElementById('grid_layout');
if (!tablist || !layoutTab || !layoutPanel) return false;
let structureChanged = false;
// Exactly one panel (ids must be unique; remove any stray duplicates).
const panels = document.querySelectorAll('[id="' + PANEL_ID + '"]');
for (let i = 1; i < panels.length; i++) { panels[i].remove(); structureChanged = true; }
if (!document.getElementById(PANEL_ID)) {
layoutPanel.insertAdjacentElement('afterend', buildPanel());
structureChanged = true;
}
// Exactly one tab. Inject a default (next to Layout) only when none exists;
// when more than one exists, keep the copy that ISN'T the one we injected
// (that is APEX's, at the position you chose) and drop ours.
const obTabs = Array.prototype.slice.call(document.querySelectorAll('li.' + TAB_MARKER));
if (obTabs.length === 0) {
injectedTab = buildTab(layoutTab);
layoutTab.insertAdjacentElement('afterend', injectedTab);
structureChanged = true;
} else if (obTabs.length > 1) {
let keeper = null;
obTabs.forEach(function (li) { if (!keeper && li !== injectedTab) keeper = li; });
if (!keeper) keeper = obTabs[0];
obTabs.forEach(function (li) {
if (li !== keeper) {
if (li === injectedTab) injectedTab = null;
li.remove();
structureChanged = true;
}
});
}
const keeperTab = document.querySelector('li.' + TAB_MARKER);
if (structureChanged) {
const keeperWidget = keeperTab && keeperTab.closest('.ui-tabs');
try { $(tabs).tabs('refresh'); } catch (e) { /* widget not ready */ }
if (keeperWidget && keeperWidget !== tabs) {
try { $(keeperWidget).tabs('refresh'); } catch (e) { /* not a tabs widget */ }
}
bindHandlers(tabs);
}
ensureTabIntegrity();
return true;
}
// While our tab is active, APEX's Grid Layout View resizes the hidden Layout grid
// and reads a model that isn't there, throwing "Cannot read properties of undefined
// (reading 'id')". It is harmless (the grid is not shown), so swallow just that.
function bindGlvErrorGuard() {
window.addEventListener('error', function (e) {
const msg = (e && e.message) || '';
const src = (e && e.filename) || '';
if (msg.indexOf("reading 'id'") !== -1 && /glv|f4000_p4500/.test(src)) {
e.preventDefault();
}
});
}
// While a Page Designer splitter is dragged, the cursor passes over our iframe,
// which would otherwise swallow the mousemove events and freeze the drag. Turn off
// the iframe's pointer events for the duration of the drag.
function bindDragGuard() {
function setIframePointerEvents(value) {
const iframe = document.getElementById('pd-ob-iframe');
if (iframe) iframe.style.pointerEvents = value;
}
document.addEventListener('mousedown', function (e) {
if (!e.target.closest || !e.target.closest('.a-Splitter-bar')) return;
setIframePointerEvents('none');
const release = function () {
setIframePointerEvents('');
document.removeEventListener('mouseup', release, true);
};
document.addEventListener('mouseup', release, true);
}, true);
}
// Wait until the APEX runtime (and apex.env) is available. In the 26.1 friendly-URL
// Page Designer this can populate slightly after document-idle.
function whenApexReady(cb) {
if (typeof apex !== 'undefined' && apex.env && apex.env.APP_ID && apex.jQuery) {
cb();
return;
}
let attempts = 0;
const timer = setInterval(function () {
if (typeof apex !== 'undefined' && apex.env && apex.env.APP_ID && apex.jQuery) {
clearInterval(timer);
cb();
} else if (++attempts > 80) {
clearInterval(timer);
}
}, 250);
}
function start() {
if (!isPageDesigner()) return;
$ = apex.jQuery;
bindGlvErrorGuard();
bindDragGuard();
// Try immediately, then poll, then keep watching: the Page Designer can rebuild
// its tab bar, which would drop the injected tab.
if (ensure()) return startObserver();
let attempts = 0;
const timer = setInterval(function () {
if (ensure() || ++attempts > 80) {
clearInterval(timer);
startObserver();
}
}, 250);
}
// Keep the tab/panel present and intact whenever APEX rebuilds its tab bar.
// Suppressed while a jQuery UI sortable drag is active — sortable inserts a
// placeholder clone that looks like a duplicate tab, which would confuse ensure().
function startObserver() {
$(document).on('sortstart.obpd', function () { isDragging = true; });
$(document).on('sortstop.obpd', function () {
isDragging = false;
setTimeout(ensure, 50); // let APEX finish its own sortstop handlers first
});
const observer = new MutationObserver(function () {
if (!isDragging) ensure();
});
observer.observe(document.body, { childList: true, subtree: true });
}
whenApexReady(start);
})();