// Speed Dial launcher page. Static-safe module: no DOM access at load time so a // node vm can exercise the pure helpers (filterActions / actionLabel / nextSel). // ---- state (populated by the C++ bridge via window.HandleStudio) ---- var ACTIONS = []; // [{id,title,source,shortcut}], already frecency-sorted by C++ var FAVS = []; // [id...] var query = ""; var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav' var lastResizeHeight = 0; var matchIndex = {}; // why: fuzzy matcher (FoldChar/Norm/FuzzyRanges) lives in shared ../../js/fuzzy-search.js, loaded before // this script - it is shared with the Plugins dialog. Speed dial search is always case-insensitive. // element handles, assigned in OnInit (kept null so load-time touches no DOM) var qEl = null, listEl = null, favEl = null, clearEl = null, eyeEl = null, countEl = null; // ---- pure helpers (no DOM; unit-tested) ------------------------------------- function filterActions(actions, query) { var q = (query || "").trim(); var list = actions || []; matchIndex = {}; if (!q) return list.slice(0); var out = []; for (var i = 0; i < list.length; i++) { var a = list[i]; var titleMatch = FuzzyRanges(a.title, q, false); var sourceMatch = FuzzyRanges(a.source, q, false); if (!titleMatch && !sourceMatch) continue; matchIndex[a.id] = { title: titleMatch, source: sourceMatch, useTitle: !!titleMatch }; out.push(a); } return out; } function visibleFavourites(favourites, actions) { // why: a fav whose id has no live action (plugin unloaded/disabled) renders a dead // monogram tile whose click run()s to a silent no-op; drop it from the quick-bar. var seen = {}; (actions || []).forEach(function (a) { seen[a.id] = true; }); return (favourites || []).filter(function (id, i, arr) { return seen[id] && arr.indexOf(id) === i; }); } function resultCountText(total, shown, query) { return (query || "").trim() ? "Showing " + shown + " of " + total + " actions" : total + " actions"; } function shouldRenderActionList(query) { return !!(query || "").trim(); } // Resolve the selection cursor {zone,i} to the action id it points at: fav zone indexes the // visible favourites, list zone the filtered actions. Pure so runSelected() shares one lookup. function selectedActionId(sel, actions, favIds, query) { if (sel.zone === "fav") return favIds[sel.i]; // why: search-first keeps the list blank until the user types; an empty query still // filters to ALL actions, so without this gate Enter fires an action never shown. if (!shouldRenderActionList(query)) return null; var a = actions[sel.i]; return a && a.id; } function foldLabel(s) { return String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, ""); } // Title-case a source for display: "GCODE OPTIMIZER"/"iRoNiNg pRo" -> "Gcode Optimizer"/"Ironing Pro". function prettySource(source) { return String(source || "").toLowerCase().replace(/\b\w/g, function (c) { return c.toUpperCase(); }); } // Accessible label "Title from Pretty Source", disambiguated with the opaque action id when another // action shares the same title+source (case/separator-insensitive) - so two rows never read out identically. function actionLabel(action, actions) { var label = action.title + " from " + prettySource(action.source); if (actions && actions.length) { var mine = foldLabel(action.title) + "|" + foldLabel(action.source); var clash = actions.some(function (o) { return o.id !== action.id && foldLabel(o.title) + "|" + foldLabel(o.source) === mine; }); if (clash) label += " (" + action.id + ")"; } return label; } // Monogram code for a tile: title initial, escalated on collision by PREPENDING the source // initial (pi+ti, e.g. "GC"), then a 1-based ordinal - so same-titled actions stay distinct. // why: ordinal is assigned by id, not by ACTIONS order - ACTIONS is frecency-sorted and // reshuffles as usage changes, which would otherwise flip who's "1" and who's "2" across runs. function tileCode(action, actions) { var list = actions || []; var ti = (action.title || " ").charAt(0).toUpperCase(); var sameTitle = list.filter(function (o) { return (o.title || " ").charAt(0).toUpperCase() === ti; }); if (sameTitle.length <= 1) return ti; var pi = (action.source || " ").charAt(0).toUpperCase(); var sameSource = sameTitle.filter(function (o) { return (o.source || " ").charAt(0).toUpperCase() === pi; }); if (sameSource.length <= 1) return pi + ti; sameSource.sort(function (a, b) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }); for (var i = 0; i < sameSource.length; i++) if (sameSource[i].id === action.id) return pi + ti + (i + 1); return pi + ti; } function syncClearButton() { if (clearEl) clearEl.hidden = !query; } function stateFromPayload(payload) { return { actions: payload.actions || [], favourites: payload.favourites || [], query: "", sel: { zone: "list", i: 0 }, lastResizeHeight: 0 }; } function resetScrollPositions(list, doc) { if (list) list.scrollTop = 0; if (doc && doc.scrollingElement) doc.scrollingElement.scrollTop = 0; if (doc && doc.documentElement) doc.documentElement.scrollTop = 0; if (doc && doc.body) doc.body.scrollTop = 0; } // nextSel: pure arrow-nav transition. Down fav->list0; Down list->clamp; Up list@0->fav0; // Up list->i-1; Left/Right clamp within fav. Returns a fresh {zone,i}. function nextSel(sel, key, listLen, favLen) { var zone = sel.zone, i = sel.i; if (key === "ArrowDown") { if (zone === "fav") return { zone: "list", i: 0 }; return { zone: "list", i: Math.min(i + 1, Math.max(0, listLen - 1)) }; } if (key === "ArrowUp") { if (zone === "list") { if (i <= 0) return favLen ? { zone: "fav", i: 0 } : { zone: "list", i: 0 }; return { zone: "list", i: i - 1 }; } return { zone: zone, i: i }; } if (key === "ArrowLeft" && zone === "fav") return { zone: "fav", i: Math.max(0, i - 1) }; if (key === "ArrowRight" && zone === "fav") return { zone: "fav", i: Math.min(favLen - 1, i + 1) }; return { zone: zone, i: i }; } // ---- bridge ------------------------------------------------------------------ function SendMessage(msg) { if (typeof SendWXMessage !== "function") return; if (typeof msg === "string") msg = { command: msg }; if (msg.sequence_id === undefined) msg.sequence_id = Date.now(); SendWXMessage(JSON.stringify(msg)); } // C++ pushes payloads here. Only list_actions is handled; it (re)seeds all state. window.HandleStudio = function (payload) { if (!payload) return; if (typeof payload === "string") { try { payload = JSON.parse(payload); } catch (e) { return; } } if (payload.command === "list_actions") { var next = stateFromPayload(payload); ACTIONS = next.actions; FAVS = next.favourites; query = next.query; sel = next.sel; lastResizeHeight = next.lastResizeHeight; if (qEl) { qEl.value = ""; qEl.placeholder = "Search " + ACTIONS.length + " actions"; syncClearButton(); } render({ resize: true, resetScroll: true }); focusInput(); } }; // ---- DOM helpers ------------------------------------------------------------- function $(id) { return document.getElementById(id); } function byId(id) { for (var i = 0; i < ACTIONS.length; i++) if (ACTIONS[i].id === id) return ACTIONS[i]; return null; } function currentVisibleFavs() { return visibleFavourites(FAVS, ACTIONS); } function hue(id) { var h = 0; for (var i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0; return h % 360; } // Build a
with the search-match ranges wrapped in . Used for both the // title and the source eyebrow. Pure (only touches the document factory), so the node-vm test never // calls it and load-time stays DOM-free. function markedText(className, text, match) { var node = document.createElement("div"); node.className = className; node.title = text; if (!match || !match.length) { node.textContent = text; return node; } var last = 0; for (var i = 0; i < match.length; i++) { var range = match[i]; if (range[0] > last) node.appendChild(document.createTextNode(text.slice(last, range[0]))); var m = document.createElement("mark"); m.textContent = text.slice(range[0], range[1]); node.appendChild(m); last = range[1]; } if (last < text.length) node.appendChild(document.createTextNode(text.slice(last))); return node; } function starSvg(on) { return '' + ''; } // ---- render ------------------------------------------------------------------ function renderFav() { favEl.innerHTML = ""; var favs = currentVisibleFavs(); favEl.hidden = favs.length === 0; if (!favs.length && sel.zone === "fav") sel = { zone: "list", i: 0 }; else if (sel.zone === "fav") sel.i = Math.max(0, Math.min(sel.i, favs.length - 1)); updateFavEyebrow(favs); favs.forEach(function (id, i) { var a = byId(id); var tile = document.createElement("button"); tile.className = "fav-tile" + (sel.zone === "fav" && sel.i === i ? " sel" : ""); tile.style.setProperty("--h", hue(id)); tile.textContent = tileCode(a, ACTIONS); tile.title = a.title; tile.setAttribute("aria-label", actionLabel(a, ACTIONS)); tile.onclick = function () { sel = { zone: "fav", i: i }; run(a); }; tile.oncontextmenu = function (ev) { ev.preventDefault(); // why: selecting shows the eyebrow, which grows the launcher - resize so the popup // isn't clipped (mirrors arrow-nav). requestResize no-ops when height is unchanged. sel = { zone: "fav", i: i }; render({ resize: true }); showFavMenu(ev.clientX, ev.clientY, id); }; favEl.appendChild(tile); }); } // ---- favourite context menu (right-click a tile) ----------------------------- var favMenuEl = null; function hideFavMenu() { if (favMenuEl) favMenuEl.hidden = true; } function addFavMenuItem(label, enabled, fn) { var item = document.createElement("button"); item.className = "ctx-item"; item.textContent = label; item.disabled = !enabled; item.onclick = function () { hideFavMenu(); fn(); }; favMenuEl.appendChild(item); } // One reused menu node (Move left/right + Unpin), positioned at the cursor and clamped // to the viewport. Native browser context menus can't add items, so we roll our own tiny one. function showFavMenu(x, y, id) { if (!favMenuEl) { favMenuEl = document.createElement("div"); favMenuEl.className = "ctx-menu"; document.body.appendChild(favMenuEl); } favMenuEl.innerHTML = ""; var favs = currentVisibleFavs(); var vi = favs.indexOf(id); addFavMenuItem("Move left", vi > 0, function () { moveFav(id, -1); }); addFavMenuItem("Move right", vi >= 0 && vi < favs.length - 1, function () { moveFav(id, 1); }); addFavMenuItem("Unpin", true, function () { toggleFav(id); }); favMenuEl.hidden = false; favMenuEl.style.left = Math.max(0, Math.min(x, window.innerWidth - favMenuEl.offsetWidth - 4)) + "px"; favMenuEl.style.top = Math.max(0, Math.min(y, window.innerHeight - favMenuEl.offsetHeight - 4)) + "px"; } // Swap a favourite with its visible neighbour (dir -1/+1) and persist the new order. Swapping // by id inside FAVS (not the visible slice) keeps any hidden pins (no live action) in place. function moveFav(id, dir) { var favs = currentVisibleFavs(); var vi = favs.indexOf(id); var ni = vi + dir; if (vi === -1 || ni < 0 || ni >= favs.length) return; var a = FAVS.indexOf(id), b = FAVS.indexOf(favs[ni]); if (a === -1 || b === -1) return; FAVS[a] = favs[ni]; FAVS[b] = id; SendMessage({ command: "reorder_favourites", ids: FAVS.slice() }); sel = { zone: "fav", i: ni }; render({ resize: true }); } // Name of the selected favourite, shown above the bar; hidden unless a fav is selected. function updateFavEyebrow(favs) { if (!eyeEl) return; var a = sel.zone === "fav" && favs.length ? byId(favs[sel.i]) : null; eyeEl.textContent = a ? a.title : ""; eyeEl.hidden = !a; } function renderList() { var q = (query || "").trim(); var arr = filterActions(ACTIONS, query); if (sel.zone === "list") sel.i = Math.max(0, Math.min(sel.i, arr.length - 1)); listEl.innerHTML = ""; // why: search-first - the list stays blank until the user types; only a // non-empty query with zero hits earns the "No actions match" message. if (!shouldRenderActionList(query)) { listEl.className = "dial-list empty"; if (countEl) countEl.hidden = true; return; } listEl.className = "dial-list" + (!arr.length ? " empty" : ""); if (!arr.length) { if (countEl) countEl.hidden = true; var empty = document.createElement("div"); empty.className = "dial-empty"; empty.textContent = "No actions match (Total: " + ACTIONS.length + ")"; listEl.appendChild(empty); return; } if (countEl) { countEl.hidden = false; countEl.textContent = resultCountText(ACTIONS.length, arr.length, query); } arr.forEach(function (a, i) { var on = FAVS.indexOf(a.id) !== -1; var row = document.createElement("div"); row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : ""); row.setAttribute("aria-label", actionLabel(a, ACTIONS)); var tile = document.createElement("div"); tile.className = "tile"; tile.style.setProperty("--h", hue(a.id)); tile.textContent = tileCode(a, ACTIONS); var left = document.createElement("div"); left.className = "row-left"; var mi = matchIndex[a.id]; var sourceEl = markedText("row-eyebrow", a.source, mi ? mi.source : null); var line = document.createElement("div"); line.className = "row-line"; var name = markedText("row-name", a.title, mi ? mi.title : null); line.appendChild(name); if (a.shortcut) { var sc = document.createElement("div"); sc.className = "row-sc"; a.shortcut.split("+").forEach(function (k) { var key = document.createElement("kbd"); key.textContent = k; sc.appendChild(key); }); line.appendChild(sc); } left.appendChild(sourceEl); left.appendChild(line); row.appendChild(tile); row.appendChild(left); var star = document.createElement("button"); star.className = "star" + (on ? " on" : ""); star.innerHTML = starSvg(on); star.title = on ? "Unpin from favourites" : "Pin to favourites"; star.onclick = function (ev) { ev.stopPropagation(); toggleFav(a.id); }; // why: two quick fav/unfav clicks must not dblclick-run the row star.ondblclick = function (ev) { ev.stopPropagation(); }; row.appendChild(star); row.onclick = function () { sel = { zone: "list", i: i }; render(); }; row.ondblclick = function () { sel = { zone: "list", i: i }; run(a); }; listEl.appendChild(row); }); } function render(opts) { renderFav(); renderList(); scrollSelectedIntoView(); if (opts && opts.resetScroll) resetScrollPositions(listEl, document); if (opts && opts.resize) requestResize(); } // Keep the selected item in view as arrows move it: the list scrolls vertically, the fav bar // horizontally (arrow nav "pushes" the scrollable fav row to follow the selection). function scrollSelectedIntoView() { var el = null; if (sel.zone === "list" && listEl) el = listEl.querySelector(".row.sel"); else if (sel.zone === "fav" && favEl) el = favEl.querySelector(".fav-tile.sel"); if (el && el.scrollIntoView) el.scrollIntoView({ block: "nearest", inline: "nearest" }); } function requestResize() { if (!document.body) return; setTimeout(function () { var launcher = document.querySelector(".launcher"); if (!launcher) return; var height = Math.ceil(launcher.getBoundingClientRect().height); if (!height || height === lastResizeHeight) return; lastResizeHeight = height; SendMessage({ command: "resize", height: height }); }, 0); } // ---- actions ----------------------------------------------------------------- function toggleFav(id) { var k = FAVS.indexOf(id); var newState = k === -1; if (newState) FAVS.push(id); else FAVS.splice(k, 1); SendMessage({ command: "toggle_favourite", id: id, fav: newState }); render({ resize: true }); } // Fire the action; C++ owns the run-confirm (native dialog) + suppression, then closes the popup + toasts. function run(a) { if (!a) return; SendMessage({ command: "run_action", id: a.id, title: a.title }); } function runSelected() { var id = selectedActionId(sel, filterActions(ACTIONS, query), currentVisibleFavs(), query); if (id) run(byId(id)); } function focusInput() { setTimeout(function () { qEl.focus(); }, 0); } // ---- init -------------------------------------------------------------------- function OnInit() { qEl = $("q"); listEl = $("list"); favEl = $("favBar"); clearEl = $("clear"); eyeEl = $("favEyebrow"); countEl = $("count"); syncClearButton(); $("clear").onclick = function () { query = ""; qEl.value = ""; sel = { zone: "list", i: 0 }; render({ resize: true, resetScroll: true }); qEl.focus(); syncClearButton(); }; qEl.addEventListener("input", function () { query = qEl.value; sel = { zone: "list", i: 0 }; syncClearButton(); render({ resize: true, resetScroll: true }); }); // why: dismiss the fav context menu on any click/scroll away from it (capture scroll to catch nested scrollers). document.addEventListener("click", hideFavMenu); document.addEventListener("scroll", hideFavMenu, true); document.addEventListener("keydown", function (e) { if (favMenuEl && !favMenuEl.hidden && e.key === "Escape") { e.preventDefault(); hideFavMenu(); return; } var arr = filterActions(ACTIONS, query); var favs = currentVisibleFavs(); // why: Up/Down always navigate; Left/Right only navigate the fav bar. In the list zone, // let Left/Right fall through so they move the caret in the focused search field. var lr = e.key === "ArrowLeft" || e.key === "ArrowRight"; if (e.key === "ArrowDown" || e.key === "ArrowUp" || (lr && sel.zone === "fav")) { e.preventDefault(); sel = nextSel(sel, e.key, arr.length, favs.length); // why: entering/leaving the fav zone toggles the eyebrow line, changing launcher height; // resize so the popup grows/shrinks instead of clipping. requestResize no-ops when unchanged. render({ resize: true }); } else if (e.key === "Enter") { e.preventDefault(); runSelected(); } else if (e.key === "Escape") { e.preventDefault(); if (query) { query = ""; qEl.value = ""; sel = { zone: "list", i: 0 }; syncClearButton(); render({ resize: true, resetScroll: true }); } else SendMessage({ command: "close_page" }); } }); SendMessage({ command: "request_actions" }); }