- Modular block files: split into blocks-algo, blocks-data, blocks-proc - New modules: sections.js (labelled canvas areas), proc-browser.js (procedure tree modal), lib-loader.js (lazy library loading), templates.js (project templates), errno-data.js (errno table) - Test infrastructure: browser test suite (28 tests) and Node pure-function tests (75 tests) - Warning system: unified .block-warn for disconnected inputs, unconnected error hooks, and zero-exit-in-error-path - Error path highlighting: red flow paths and block borders for error-only subgraphs - Input hooks: hidden by default, shown during linking; outputs always at bottom - Block editor modal: pencil icon, colour picker for accent, compact field labels - Doc generation: standalone node script extracts JSDoc -> docs/API.md (124 declarations) - Persistence: save/restore sections, errno selection, block colours - Fixes: _segmentsIntersect bug, WCEnd crash from closest(this), drawpath guard on detached elements
361 lines
11 KiB
JavaScript
361 lines
11 KiB
JavaScript
// ── Procedure Browser Modal ────────────────────────────────────────────────
|
|
//
|
|
// A modal popup that displays a searchable, collapsible tree of all available
|
|
// procedures (user-defined and library-provided). Replaces the old dropdown
|
|
// <select> approach for selecting procedures in WCProcCall blocks.
|
|
//
|
|
// Depends on: dom.js ($, $id), helpers.js (switchLibTab, getLibraryIndex)
|
|
|
|
/** @type {((qname:string|null)=>void)|null} */
|
|
let _procBrowserCallback = null;
|
|
|
|
/**
|
|
* Builds the tree data structure from all wc-procedure blocks and the library index.
|
|
* @returns {Array<{label:string, items:Array<{qname:string, naturalName:string, techName:string, readonly:boolean}>, userDefined:boolean}>}
|
|
*/
|
|
function _buildTreeData() {
|
|
const procs = document.querySelectorAll("wc-procedure");
|
|
const libIndex = getLibraryIndex();
|
|
const libMap = {};
|
|
for (const entry of libIndex) {
|
|
libMap[entry.id] = entry;
|
|
}
|
|
|
|
const userDefined = [];
|
|
const libGroups = {};
|
|
|
|
for (const proc of procs) {
|
|
const qname = proc.getQualifiedName();
|
|
if (!qname) continue;
|
|
const naturalName = proc.querySelector(".proc-natural-name").value || "";
|
|
const techName = proc.querySelector(".proc-tech-name").textContent || "";
|
|
const isReadonly = !!proc.dataset.readonly;
|
|
|
|
const item = { qname, naturalName, techName, readonly: isReadonly };
|
|
|
|
if (!isReadonly) {
|
|
userDefined.push(item);
|
|
} else {
|
|
const libId = proc.getLibrary();
|
|
if (!libGroups[libId]) {
|
|
const entry = libMap[libId];
|
|
libGroups[libId] = {
|
|
label: entry ? entry.name : libId,
|
|
items: []
|
|
};
|
|
}
|
|
libGroups[libId].items.push(item);
|
|
}
|
|
}
|
|
|
|
const groups = [];
|
|
|
|
if (userDefined.length > 0) {
|
|
userDefined.sort((a, b) => a.naturalName.localeCompare(b.naturalName) || a.qname.localeCompare(b.qname));
|
|
groups.push({ label: "User-defined", items: userDefined, userDefined: true });
|
|
}
|
|
|
|
const libIds = Object.keys(libGroups).sort((a, b) => {
|
|
const ga = libMap[a] ? libMap[a].group : "";
|
|
const gb = libMap[b] ? libMap[b].group : "";
|
|
if (ga !== gb) return ga.localeCompare(gb);
|
|
return (libMap[a] ? libMap[a].name : a).localeCompare(libMap[b] ? libMap[b].name : b);
|
|
});
|
|
|
|
for (const libId of libIds) {
|
|
libGroups[libId].items.sort((a, b) => a.techName.localeCompare(b.techName) || a.qname.localeCompare(b.qname));
|
|
groups.push({
|
|
label: libGroups[libId].label,
|
|
items: libGroups[libId].items,
|
|
userDefined: false
|
|
});
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
/**
|
|
* Creates a collapsible group DOM element in the procedure browser tree.
|
|
* @param {{label:string, items:Array<any>, userDefined:boolean}} group
|
|
* @param {string} [selectedQname] QName to pre-select
|
|
* @returns {HTMLElement}
|
|
*/
|
|
function _createGroupElement(group, selectedQname) {
|
|
const groupDiv = document.createElement("div");
|
|
groupDiv.className = "proc-browser-tree-group";
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "proc-browser-tree-group-header";
|
|
if (group.userDefined) {
|
|
header.classList.add("user-defined");
|
|
}
|
|
|
|
const arrow = document.createElement("span");
|
|
arrow.className = "proc-browser-tree-arrow";
|
|
arrow.textContent = group.userDefined ? "\u25BC" : "\u25B6";
|
|
header.appendChild(arrow);
|
|
|
|
const name = document.createElement("span");
|
|
name.className = "proc-browser-tree-group-name";
|
|
name.textContent = group.label;
|
|
header.appendChild(name);
|
|
|
|
const count = document.createElement("span");
|
|
count.className = "proc-browser-tree-group-count";
|
|
count.textContent = group.items.length.toString();
|
|
header.appendChild(count);
|
|
|
|
groupDiv.appendChild(header);
|
|
|
|
const itemsContainer = document.createElement("div");
|
|
itemsContainer.className = "proc-browser-tree-group-items";
|
|
if (!group.userDefined) {
|
|
itemsContainer.style.display = "none";
|
|
}
|
|
|
|
for (const item of group.items) {
|
|
const leaf = document.createElement("div");
|
|
leaf.className = "proc-browser-tree-leaf";
|
|
leaf.dataset.qname = item.qname;
|
|
|
|
if (item.qname === selectedQname) {
|
|
leaf.classList.add("selected");
|
|
}
|
|
|
|
const nameSpan = document.createElement("span");
|
|
nameSpan.className = "proc-browser-tree-leaf-name";
|
|
nameSpan.textContent = item.naturalName || item.techName || item.qname;
|
|
leaf.appendChild(nameSpan);
|
|
|
|
const qnameSpan = document.createElement("span");
|
|
qnameSpan.className = "proc-browser-tree-leaf-qname";
|
|
qnameSpan.textContent = item.qname;
|
|
leaf.appendChild(qnameSpan);
|
|
|
|
leaf.addEventListener("click", function () {
|
|
const sel = this.closest(".proc-browser-tree").querySelector(".proc-browser-tree-leaf.selected");
|
|
if (sel) sel.classList.remove("selected");
|
|
this.classList.add("selected");
|
|
});
|
|
|
|
leaf.addEventListener("dblclick", function () {
|
|
_confirmSelection(this.dataset.qname);
|
|
});
|
|
|
|
itemsContainer.appendChild(leaf);
|
|
}
|
|
|
|
header.addEventListener("click", function () {
|
|
const isCollapsed = arrow.textContent === "\u25B6";
|
|
arrow.textContent = isCollapsed ? "\u25BC" : "\u25B6";
|
|
itemsContainer.style.display = isCollapsed ? "" : "none";
|
|
});
|
|
|
|
groupDiv.appendChild(itemsContainer);
|
|
return groupDiv;
|
|
}
|
|
|
|
/**
|
|
* Filters the procedure tree by a search query, showing/hiding groups and items.
|
|
* @param {string} query
|
|
* @param {HTMLElement} overlay The procedure browser overlay element
|
|
*/
|
|
function _applySearchFilter(query, overlay) {
|
|
query = query.toLowerCase().trim();
|
|
const treeEl = overlay.querySelector(".proc-browser-tree");
|
|
const emptyEl = overlay.querySelector(".proc-browser-empty");
|
|
|
|
if (!treeEl) return;
|
|
|
|
if (!query) {
|
|
for (const groupEl of treeEl.querySelectorAll(".proc-browser-tree-group")) {
|
|
const header = groupEl.querySelector(".proc-browser-tree-group-header");
|
|
const items = groupEl.querySelector(".proc-browser-tree-group-items");
|
|
const arrow = groupEl.querySelector(".proc-browser-tree-arrow");
|
|
const isUserDefined = header.classList.contains("user-defined");
|
|
items.style.display = isUserDefined ? "" : "none";
|
|
arrow.textContent = isUserDefined ? "\u25BC" : "\u25B6";
|
|
for (const leaf of items.querySelectorAll(".proc-browser-tree-leaf")) {
|
|
leaf.style.display = "";
|
|
}
|
|
groupEl.style.display = "";
|
|
}
|
|
emptyEl.style.display = "none";
|
|
emptyEl.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
let hasVisible = false;
|
|
for (const groupEl of treeEl.querySelectorAll(".proc-browser-tree-group")) {
|
|
const items = groupEl.querySelector(".proc-browser-tree-group-items");
|
|
const arrow = groupEl.querySelector(".proc-browser-tree-arrow");
|
|
let groupHasMatch = false;
|
|
|
|
for (const leaf of items.querySelectorAll(".proc-browser-tree-leaf")) {
|
|
const nameEl = leaf.querySelector(".proc-browser-tree-leaf-name");
|
|
const qnameEl = leaf.querySelector(".proc-browser-tree-leaf-qname");
|
|
const name = nameEl ? nameEl.textContent.toLowerCase() : "";
|
|
const qname = qnameEl ? qnameEl.textContent.toLowerCase() : "";
|
|
if (name.includes(query) || qname.includes(query)) {
|
|
leaf.style.display = "";
|
|
groupHasMatch = true;
|
|
} else {
|
|
leaf.style.display = "none";
|
|
}
|
|
}
|
|
|
|
if (groupHasMatch) {
|
|
groupEl.style.display = "";
|
|
items.style.display = "";
|
|
arrow.textContent = "\u25BC";
|
|
hasVisible = true;
|
|
} else {
|
|
groupEl.style.display = "none";
|
|
}
|
|
}
|
|
|
|
if (hasVisible) {
|
|
emptyEl.style.display = "none";
|
|
} else {
|
|
emptyEl.style.display = "block";
|
|
emptyEl.innerHTML = "No procedures match '<strong>" + _escapeHtml(query) + "</strong>'.";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Escapes HTML special characters for safe innerHTML insertion.
|
|
* @param {string} str
|
|
* @returns {string}
|
|
*/
|
|
function _escapeHtml(str) {
|
|
const div = document.createElement("div");
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* Confirms a procedure selection, closes the browser, and invokes the callback.
|
|
* @param {string} qname Qualified procedure name
|
|
*/
|
|
function _confirmSelection(qname) {
|
|
const overlay = $("#proc-browser-overlay");
|
|
if (overlay) overlay.classList.remove("active");
|
|
if (_procBrowserCallback) {
|
|
_procBrowserCallback(qname);
|
|
_procBrowserCallback = null;
|
|
}
|
|
}
|
|
|
|
/** Cancels procedure selection, closes the browser, invokes callback with null. */
|
|
function _cancelBrowser() {
|
|
const overlay = $("#proc-browser-overlay");
|
|
if (overlay) overlay.classList.remove("active");
|
|
if (_procBrowserCallback) {
|
|
_procBrowserCallback(null);
|
|
_procBrowserCallback = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opens the procedure browser modal, builds the tree, and wires up search/cancel/select.
|
|
* @param {string} selectedQname
|
|
* @param {(qname:string|null)=>void} callback
|
|
*/
|
|
function showProcedureBrowser(selectedQname, callback) {
|
|
_procBrowserCallback = callback;
|
|
|
|
const overlay = $("#proc-browser-overlay");
|
|
if (!overlay) return;
|
|
|
|
const groups = _buildTreeData();
|
|
const treeEl = overlay.querySelector(".proc-browser-tree");
|
|
const emptyEl = overlay.querySelector(".proc-browser-empty");
|
|
const searchInput = overlay.querySelector(".proc-browser-search");
|
|
|
|
treeEl.innerHTML = "";
|
|
searchInput.value = "";
|
|
|
|
if (groups.length === 0) {
|
|
emptyEl.style.display = "block";
|
|
emptyEl.innerHTML =
|
|
"No procedures available. " +
|
|
'<a href="#" class="proc-browser-goto-procedures">Go to the Procedures tab</a> ' +
|
|
"to define or load libraries.";
|
|
} else {
|
|
emptyEl.style.display = "none";
|
|
for (const group of groups) {
|
|
treeEl.appendChild(_createGroupElement(group, selectedQname));
|
|
}
|
|
}
|
|
|
|
_applySearchFilter("", overlay);
|
|
|
|
overlay.classList.add("active");
|
|
setTimeout(function () { searchInput.focus(); }, 100);
|
|
}
|
|
|
|
// Self-initialising IIFE — wires up the overlay event listeners at load time
|
|
(function () {
|
|
const overlay = $("#proc-browser-overlay");
|
|
if (!overlay) return;
|
|
|
|
overlay.addEventListener("click", function (e) {
|
|
if (e.target === overlay) _cancelBrowser();
|
|
});
|
|
|
|
const cancelBtn = overlay.querySelector(".proc-browser-cancel");
|
|
if (cancelBtn) {
|
|
cancelBtn.addEventListener("click", _cancelBrowser);
|
|
}
|
|
|
|
const selectBtn = overlay.querySelector(".proc-browser-select");
|
|
if (selectBtn) {
|
|
selectBtn.addEventListener("click", function () {
|
|
let selected = overlay.querySelector(".proc-browser-tree-leaf.selected");
|
|
if (!selected) {
|
|
for (const leaf of overlay.querySelectorAll(".proc-browser-tree-leaf")) {
|
|
if (leaf.offsetParent !== null) {
|
|
selected = leaf;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (selected) {
|
|
_confirmSelection(selected.dataset.qname);
|
|
}
|
|
});
|
|
}
|
|
|
|
const searchInput = overlay.querySelector(".proc-browser-search");
|
|
if (searchInput) {
|
|
searchInput.addEventListener("input", function () {
|
|
_applySearchFilter(this.value, overlay);
|
|
});
|
|
|
|
searchInput.addEventListener("keydown", function (e) {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
const selected = overlay.querySelector(".proc-browser-tree-leaf.selected");
|
|
if (selected) {
|
|
_confirmSelection(selected.dataset.qname);
|
|
}
|
|
}
|
|
if (e.key === "Escape") {
|
|
_cancelBrowser();
|
|
}
|
|
});
|
|
}
|
|
|
|
const emptyEl = overlay.querySelector(".proc-browser-empty");
|
|
if (emptyEl) {
|
|
emptyEl.addEventListener("click", function (e) {
|
|
const link = e.target.closest(".proc-browser-goto-procedures");
|
|
if (link) {
|
|
e.preventDefault();
|
|
_cancelBrowser();
|
|
switchTab("procedures");
|
|
}
|
|
});
|
|
}
|
|
})();
|