mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-24 17:26:47 +00:00
1512 lines
67 KiB
JavaScript
1512 lines
67 KiB
JavaScript
// Speed Dial launcher page. Static-safe module: no DOM access at load time so a
|
|
// node vm can exercise the pure helpers (searchActions / filterTabs / actionLabel / nextSel /
|
|
// commandList / commandSections / actionCategory / groupActions / favDigitFromEvent / spacerHeight).
|
|
//
|
|
// Cross-boundary contracts (keep in sync; the C++ side pins its half in tests):
|
|
// - favourite cap 10 -> ActionRegistry::kFavLimit (K_FAV_LIMIT here)
|
|
// - mode rank simple<advanced<expert<develop -> ConfigOptionMode order (MODE_RANK here)
|
|
// - action.mode token -> ActionRegistry::mode_key / SpeedDialDialog::mode_label
|
|
// - action.input "percent"/"tab" -> NativeCommands catalog (phases handled in activateEntry)
|
|
// - action.icon SVG base name -> AppAction::icon / resources/images/<name>.svg
|
|
// - action.desc/wiki -> AppAction::tooltip / help_url (footer detail strip)
|
|
// - payload.tooltip_expanded -> ActionRegistry::tooltip_expanded (persisted footer state)
|
|
// - action list is frecency-sorted -> ActionRegistry::snapshot()
|
|
|
|
// ---- state (populated by the C++ bridge via window.HandleStudio) ----
|
|
var ACTIONS = []; // [{id,title,source,group,kind,input,icon,mode}], already frecency-sorted by C++
|
|
var FAVS = []; // [id...]
|
|
var RECENTS = []; // [{id,title,source,group,kind,input,icon,mode}] - last-N launched
|
|
var query = "";
|
|
var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav'
|
|
var matchIndex = {};
|
|
|
|
// Global tooltip expansion, seeded from C++ (persisted in the speed_dial config section). Collapsing
|
|
// hides the footer description + wiki link for every action; the arrow remains to expand again.
|
|
var TOOLTIP_EXPANDED = true;
|
|
|
|
// The user's current settings mode (from the C++ payload) plus the rank order of the modes. Each
|
|
// action carries the mode it requires, so "would this need a switch?" is a rank comparison.
|
|
var USER_MODE = "simple";
|
|
var MODE_RANK = { simple: 0, advanced: 1, expert: 2, develop: 3 };
|
|
|
|
// Search ranking weights: every contiguous match must outrank every fuzzy one regardless of field,
|
|
// and title must outrank group, which outranks source.
|
|
var SCORE_CONTIGUOUS = 100000;
|
|
var SCORE_TITLE = 2000;
|
|
var SCORE_GROUP = 1000;
|
|
// A whole-query match in a single field must outrank any multi-token match distributed across fields.
|
|
// Larger than the largest plausible sum of per-token scores (SCORE_CONTIGUOUS * token count).
|
|
var SCORE_PHRASE = 10000000;
|
|
|
|
// Localized lookup for strings this page builds at runtime. The host injects the translated table
|
|
// as a document-start user script (SpeedDialWebDialog::add_user_scripts); the English literal is a
|
|
// fallback for the node vm test / before the injection runs. Extra args replace successive %s
|
|
// placeholders; %% collapses to a literal % (the C++ table escapes percent signs for gettext).
|
|
var UI_STRINGS = (typeof ORCA_UI_STRINGS !== "undefined" && ORCA_UI_STRINGS) || {};
|
|
|
|
function T(key, fallback) {
|
|
var s = UI_STRINGS[key] !== undefined ? UI_STRINGS[key] : fallback;
|
|
for (var i = 2; i < arguments.length; i++)
|
|
s = s.replace("%s", arguments[i]);
|
|
return s.split("%%").join("%");
|
|
}
|
|
|
|
// Platform shortcut prefixes ("Alt+"/"⌥+", "Ctrl+"/"⌘+"), injected alongside the strings.
|
|
function shortcutAlt() { return UI_STRINGS.shortcut_alt || "Alt+"; }
|
|
function shortcutCtrl() { return UI_STRINGS.shortcut_ctrl || "Ctrl+"; }
|
|
|
|
// ---- windowed list render ----------------------------------------------------
|
|
// The command list is rendered in windows (append-on-scroll) so a huge settings pool doesn't build
|
|
// the whole DOM per keystroke. Rows are exactly ROW_H tall (matches .row min-height 44px; see --row-h,
|
|
// which is documented to stay in sync). `renderEnd` is the exclusive count of rows currently in the DOM;
|
|
// a bottom spacer fills the rest of the list so the scrollbar reflects the full match count and
|
|
// "scroll past the last rendered row" reveals the next window.
|
|
var K_ROWS = 50;
|
|
var ROW_H = 44;
|
|
var renderEnd = 0;
|
|
var builtKey = ""; // phase|query|total - when it changes, rows are rebuilt from the first window [0, K_ROWS)
|
|
var spacerEl = null; // the trailing height spacer, always the last child of listEl
|
|
|
|
// Section headers in the empty-query list ("Recent" then one per category). sectionStarts maps a flat
|
|
// action index to the header label that sits above it. sectionTotal/Rendered count headers so the
|
|
// bottom spacer reserves the same vertical space the not-yet-rendered headers will occupy.
|
|
var SECTION_H = 30; // MUST match .dial-section height (30px)
|
|
var sectionStarts = null;
|
|
var sectionTotal = 0;
|
|
var sectionRendered = 0;
|
|
|
|
// search-cache: the normalized (folded+lowercased) needle for the current query pass, plus the
|
|
// whitespace-separated tokens and their compiled whole-word regexes for the multi-token path.
|
|
var searchNeedle = "";
|
|
var searchTokens = [];
|
|
var searchTokenRes = [];
|
|
|
|
// Palette phase: 'commands' (one unified search over actions/commands/settings, recents on empty
|
|
// query), 'percent' ("Go to layer" second phase: enter a 0-100 percentage), 'tab' ("Go to tab..."
|
|
// second phase: pick a notebook tab).
|
|
var phase = "commands";
|
|
var tabOptions = []; // [{id,title}] - notebook pages, fetched on entering the tab phase
|
|
|
|
// why: the fuzzy matcher (NormText/FuzzyRangesNorm) lives in shared
|
|
// ../../js/fuzzy-search.js, loaded before this script. 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, detailEl = null;
|
|
var ghostEl = null, ghostTypedEl = null, ghostSuffixEl = null;
|
|
// The completion currently offered as ghost text ({suffix, word, id}), or null. Tab accepts it.
|
|
var activeCompletion = null;
|
|
|
|
// ---- pure helpers (no DOM; unit-tested) -------------------------------------
|
|
// Pre-normalized haystacks, cached on the action object. The fold is length-preserving (1:1 per
|
|
// char) so the ranges FuzzyRangesNorm returns slice the ORIGINAL title/group/source text correctly.
|
|
// The action objects arrive from C++ and are stable for the dialog's lifetime, so we compute these once.
|
|
function titleNorm(a) {
|
|
if (a._tn === undefined)
|
|
a._tn = NormText(a.title, false);
|
|
return a._tn;
|
|
}
|
|
// The eyebrow (header) line shows group when present, else source. Settings keep an empty group so
|
|
// their source path is the eyebrow; commands / plates / recents carry a non-empty group. Splitting the
|
|
// two lets a match range stay aligned to whichever string the eyebrow actually renders.
|
|
function groupNorm(a) {
|
|
if (a._gn === undefined)
|
|
a._gn = NormText(a.group || "", false);
|
|
return a._gn;
|
|
}
|
|
function sourceNorm(a) {
|
|
if (a._sn === undefined)
|
|
a._sn = NormText(a.source || "", false);
|
|
return a._sn;
|
|
}
|
|
// Search-only alias for the descriptive name when the title differs (e.g. "Reverse on even" vs
|
|
// "Overhang reversal"). Never rendered, so no highlight ranges.
|
|
function fullNorm(a) {
|
|
if (a._fn === undefined)
|
|
a._fn = NormText(a.full_label || "", false);
|
|
return a._fn;
|
|
}
|
|
|
|
// Match one pre-normalized field vs one pre-normalized needle. Returns {score, ranges, contiguous}
|
|
// when the needle is present, else null. wwRe is a compiled whole-word (\b-bounded) regex for the
|
|
// needle. A whole-word hit is preferred - it highlights the full word (e.g. "orient" in "Auto-Orient",
|
|
// not the stray "o" of "Auto") and marks a perfect match. Otherwise FuzzyRangesNorm (which prefers the
|
|
// most-contiguous run) is used. score is higher for an earlier start and fewer gaps; contiguous marks
|
|
// a perfect match - the whole needle landed as one unbroken run.
|
|
function fieldMatchScore(norm, needle, wwRe) {
|
|
if (!needle) return null;
|
|
if (wwRe) {
|
|
var m = wwRe.exec(norm || "");
|
|
if (m)
|
|
return { score: 1000 - m.index * 10, ranges: [[m.index, m.index + m[0].length]], contiguous: true };
|
|
}
|
|
var r = FuzzyRangesNorm(norm || "", needle);
|
|
if (!r) return null;
|
|
var gaps = 0, len = 0;
|
|
for (var i = 0; i < r.length; i++) {
|
|
if (i > 0)
|
|
gaps += r[i][0] - r[i - 1][1];
|
|
len += r[i][1] - r[i][0];
|
|
}
|
|
return { score: 1000 - r[0][0] * 10 - gaps * 10, ranges: r, contiguous: r.length === 1 && len === needle.length };
|
|
}
|
|
|
|
// Split a query into normalized (folded+lowercased) whitespace-separated tokens. Empty for a blank
|
|
// query. These drive the multi-token path: every token must match some field, but different tokens
|
|
// may match different fields (the title, the group, or the source breadcrumb).
|
|
function queryTokens(query) {
|
|
var norm = NormText(String(query || "").trim(), false);
|
|
return norm ? norm.split(/\s+/).filter(Boolean) : [];
|
|
}
|
|
|
|
// Whole-word regex for one normalized token, same shape fieldMatchScore expects.
|
|
function tokenWordRe(token) {
|
|
return new RegExp("\\b" + EscapeRegExp(token) + "\\b");
|
|
}
|
|
|
|
// One token's best match across an action's searchable fields, keeping per-field ranges for
|
|
// highlighting. Returns {score, title, group, source}, or null if no field matches.
|
|
function tokenMatch(a, token, wwRe) {
|
|
var t = fieldMatchScore(titleNorm(a), token, wwRe);
|
|
var g = fieldMatchScore(groupNorm(a), token, wwRe);
|
|
var s = fieldMatchScore(sourceNorm(a), token, wwRe);
|
|
var f = fieldMatchScore(fullNorm(a), token, wwRe);
|
|
if (!t && !g && !s && !f) return null;
|
|
var score = Math.max(
|
|
t ? (t.contiguous ? SCORE_CONTIGUOUS : 0) + SCORE_TITLE + t.score : -Infinity,
|
|
g ? (g.contiguous ? SCORE_CONTIGUOUS : 0) + SCORE_GROUP + g.score : -Infinity,
|
|
s ? (s.contiguous ? SCORE_CONTIGUOUS : 0) + s.score : -Infinity,
|
|
f ? (f.contiguous ? SCORE_CONTIGUOUS : 0) + f.score : -Infinity
|
|
);
|
|
return { score: score, title: t ? t.ranges : null, group: g ? g.ranges : null, source: s ? s.ranges : null };
|
|
}
|
|
|
|
// Merge per-field token ranges into sorted, coalesced ranges for highlighting. Overlapping or adjacent
|
|
// runs (the same word matched by two tokens) collapse to one span. Null when nothing matched.
|
|
function mergeRanges(ranges) {
|
|
var flat = [];
|
|
(ranges || []).forEach(function (rs) {
|
|
if (rs) rs.forEach(function (r) { flat.push([r[0], r[1]]); });
|
|
});
|
|
if (!flat.length) return null;
|
|
flat.sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; });
|
|
var out = [flat[0].slice()];
|
|
for (var i = 1; i < flat.length; i++) {
|
|
var last = out[out.length - 1];
|
|
if (flat[i][0] <= last[1])
|
|
last[1] = Math.max(last[1], flat[i][1]);
|
|
else
|
|
out.push(flat[i].slice());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Combine per-field scores into one value, or null when nothing matched.
|
|
// Ranking: contiguous > fuzzy, then title > group > source/full alias, then start/gaps.
|
|
function scoreFields(t, g, s, f) {
|
|
var best = null;
|
|
function consider(m, weight) {
|
|
if (!m) return;
|
|
var v = (m.contiguous ? SCORE_CONTIGUOUS : 0) + weight + m.score;
|
|
best = best === null ? v : Math.max(best, v);
|
|
}
|
|
consider(t, SCORE_TITLE);
|
|
consider(g, SCORE_GROUP);
|
|
consider(s, 0);
|
|
consider(f, 0);
|
|
return best;
|
|
}
|
|
|
|
// The unified main-phase search: every action (command/plugin/setting) matching the query, ranked
|
|
// by relevance (not by action type). Sets matchIndex so rows highlight their match ranges. The query
|
|
// is normalized ONCE per pass - FuzzyRangesNorm then runs against each action's pre-normalized
|
|
// haystack, so per-keystroke cost is a cheap scan (no per-char normalize/regex).
|
|
//
|
|
// Two match modes, phrase preferred:
|
|
// - phrase: the whole trimmed query as one ordered subsequence in a SINGLE field (as before).
|
|
// - tokens: every whitespace-separated word must match SOME field, but different words may match
|
|
// different fields. This is what lets "speed acceleration inner" find "Inner wall" whose path is
|
|
// "Process : Speed : Acceleration" (title + source breadcrumb together).
|
|
// full_label is searchable too but never highlighted, since it is not rendered.
|
|
// A phrase match always outranks a distributed token match.
|
|
function searchActions(actions, query) {
|
|
var q = (query || "").trim();
|
|
var list = actions || [];
|
|
matchIndex = {};
|
|
if (!q) { searchNeedle = ""; searchTokens = []; searchTokenRes = []; return list.slice(0); }
|
|
searchNeedle = NormText(q, false);
|
|
searchTokens = queryTokens(q);
|
|
searchTokenRes = searchTokens.map(tokenWordRe);
|
|
// Mode keywords ("advanced"/"expert"/"developer") are a union, not a filter: the normal text
|
|
// search still runs on the full query, and every setting requiring a named mode is appended.
|
|
var modes = modeFilterFromQuery(q);
|
|
// Compiled once per pass, reused over every field: non-global so no exec()/lastIndex state leaks
|
|
// between fields, and EscapeRegExp keeps regex metachars in the query literal.
|
|
var wwRe = new RegExp("\\b" + EscapeRegExp(searchNeedle) + "\\b");
|
|
|
|
var scored = [];
|
|
var seen = {};
|
|
for (var i = 0; i < list.length; i++) {
|
|
var a = list[i];
|
|
var t = fieldMatchScore(titleNorm(a), searchNeedle, wwRe);
|
|
var g = fieldMatchScore(groupNorm(a), searchNeedle, wwRe);
|
|
var s = fieldMatchScore(sourceNorm(a), searchNeedle, wwRe);
|
|
var f = fieldMatchScore(fullNorm(a), searchNeedle, wwRe);
|
|
var phrase = scoreFields(t, g, s, f);
|
|
var score, ranges;
|
|
if (phrase !== null) {
|
|
score = phrase + SCORE_PHRASE;
|
|
ranges = { title: t ? t.ranges : null, group: g ? g.ranges : null, source: s ? s.ranges : null };
|
|
} else {
|
|
// Require every token; a token that matches nothing drops the action immediately. Ranges
|
|
// from all matching tokens are merged per field so each matched word highlights.
|
|
var sum = 0, titleR = null, groupR = null, sourceR = null, all = true;
|
|
for (var k = 0; k < searchTokens.length; k++) {
|
|
var m = tokenMatch(a, searchTokens[k], searchTokenRes[k]);
|
|
if (!m) { all = false; break; }
|
|
sum += m.score;
|
|
if (m.title) (titleR || (titleR = [])).push(m.title);
|
|
if (m.group) (groupR || (groupR = [])).push(m.group);
|
|
if (m.source) (sourceR || (sourceR = [])).push(m.source);
|
|
}
|
|
if (!all) continue;
|
|
score = sum;
|
|
ranges = { title: mergeRanges(titleR), group: mergeRanges(groupR), source: mergeRanges(sourceR) };
|
|
}
|
|
// Ranges are per-field against the ACTUAL text drawn: title for the row-name, and group (or
|
|
// source when group is empty) for the eyebrow - so highlight offsets stay aligned to the label.
|
|
matchIndex[a.id] = {
|
|
title: ranges.title,
|
|
group: ranges.group,
|
|
source: ranges.source,
|
|
useEyebrowGroup: !!(a.group)
|
|
};
|
|
scored.push({ a: a, s: score });
|
|
seen[a.id] = true;
|
|
}
|
|
scored.sort(function (x, y) {
|
|
if (x.s !== y.s) return y.s - x.s;
|
|
if (x.a.title !== y.a.title) return x.a.title < y.a.title ? -1 : 1;
|
|
return x.a.id < y.a.id ? -1 : x.a.id > y.a.id ? 1 : 0;
|
|
});
|
|
var result = scored.map(function (e) { return e.a; });
|
|
if (modes.length) {
|
|
// Settings requiring a named mode, after the ranked text matches and without duplicates.
|
|
var extras = [];
|
|
for (var j = 0; j < list.length; j++) {
|
|
if (!seen[list[j].id] && modes.indexOf(list[j].mode) !== -1)
|
|
extras.push(list[j]);
|
|
}
|
|
extras.sort(function (x, y) {
|
|
if (x.title !== y.title) return x.title < y.title ? -1 : 1;
|
|
return x.id < y.id ? -1 : x.id > y.id ? 1 : 0;
|
|
});
|
|
result = result.concat(extras);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Candidate words for inline completion, in reading order. Whitespace and the breadcrumb separator
|
|
// split them, so "Process : Speed : Acceleration" yields Process/Speed/Acceleration. Pure.
|
|
function completionWords(text) {
|
|
return String(text || "").split(/[\s:]+/).filter(Boolean);
|
|
}
|
|
|
|
// The inline completion for the query's LAST token: scan the top-ranked results' name first, then
|
|
// their path breadcrumb, and return the first word that extends the typed token as a prefix. Headlines
|
|
// the most likely word without committing to a result. Returns {suffix, word, id} or null. Pure so the
|
|
// node-vm test can exercise it; the caller appends `suffix` to the input.
|
|
function completionFor(query, list) {
|
|
var raw = String(query || "");
|
|
var parts = raw.match(/(\S+)\s*$/);
|
|
if (!parts) return null;
|
|
var prefix = NormText(parts[1], false);
|
|
if (!prefix) return null;
|
|
var top = (list || []).slice(0, 10);
|
|
for (var i = 0; i < top.length; i++) {
|
|
var a = top[i];
|
|
var fields = [a.title, a.group, a.source, a.full_label];
|
|
for (var f = 0; f < fields.length; f++) {
|
|
var words = completionWords(fields[f]);
|
|
for (var w = 0; w < words.length; w++) {
|
|
var norm = NormText(words[w], false);
|
|
if (norm.length > prefix.length && norm.indexOf(prefix) === 0)
|
|
return { suffix: words[w].slice(prefix.length), word: words[w], id: a.id };
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Pure: how many rows must be materialized to cover the given starting index plus `size` more.
|
|
// Clamped to the total; used to decide "render the next window" on scroll / arrow-nav.
|
|
function revealTarget(total, fromIndex, size) {
|
|
return Math.min(total, Math.max(0, fromIndex) + size);
|
|
}
|
|
|
|
// Pure: the bottom spacer's height - the un-rendered row tail plus any not-yet-rendered section
|
|
// headers, so the scrollbar reflects the full list and the last rows stay reachable.
|
|
function spacerHeight(total, rendered, totalSections, renderedSections) {
|
|
return Math.max(0, total - rendered) * ROW_H + Math.max(0, totalSections - renderedSections) * SECTION_H;
|
|
}
|
|
|
|
// buildKey: the command-list signature that decides whether rows must be rebuilt (new search / phase)
|
|
// or just have their selection refreshed in place (arrow-nav / click). Cheap to compute.
|
|
function buildKey() { return phase + "|" + (query || "").trim(); }
|
|
|
|
function visibleFavourites(favourites, actions) {
|
|
// why: a fav whose id has no live action (plugin unloaded/disabled) renders a dead
|
|
// placeholder 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;
|
|
});
|
|
}
|
|
|
|
// Numbered quick-launch slots (mirrors ActionRegistry::kFavLimit). Pure so the node-vm test
|
|
// can exercise the digit<->slot mapping without a DOM.
|
|
var K_FAV_LIMIT = 10;
|
|
|
|
// Badge label for a 0-based fav-bar index: 0..8 -> "1".."9", index 9 (the 10th) -> "0".
|
|
function favSlotForIndex(i) {
|
|
if (i < 0 || i >= K_FAV_LIMIT) return null;
|
|
return i < 9 ? String(i + 1) : "0";
|
|
}
|
|
|
|
// Digit key -> 0-based fav-bar index (1..9 -> 0..8, 0 -> 9); -1 for anything else.
|
|
function favIndexForDigit(d) {
|
|
var c = String(d || "").charCodeAt(0);
|
|
if (c >= 49 && c <= 57) return c - 49;
|
|
if (c === 48) return 9;
|
|
return -1;
|
|
}
|
|
|
|
// Physical digit for a keydown event. Use e.code so macOS Option+digit (which composes to a symbol
|
|
// in e.key, e.g. Alt+1 -> "¡") still maps to the intended slot; fall back to e.key elsewhere.
|
|
function favDigitFromEvent(e) {
|
|
var m = /^(?:Digit|Numpad)([0-9])$/.exec((e && e.code) || "");
|
|
return m ? m[1] : ((e && e.key) || "");
|
|
}
|
|
|
|
function resultCountText(total, shown, query) {
|
|
return (query || "").trim() ?
|
|
T("sd_result_count", "Showing %s of %s actions", shown, total) :
|
|
T("sd_result_count_all", "%s actions", total);
|
|
}
|
|
|
|
// Display label for a notebook tab. Trim any stray whitespace; pages added with an empty title
|
|
// (Home, MainFrame adds TAB_ID_HOME with "") fall back to the title-cased id ("home" -> "Home").
|
|
function tabTitle(t) {
|
|
var title = (t && t.title) ? String(t.title).trim() : "";
|
|
return title || prettySource((t && t.id) || "");
|
|
}
|
|
|
|
// Filter the tab list by a fuzzy title/id match. Pure so the node-vm test can exercise it.
|
|
function filterTabs(tabs, query) {
|
|
var q = (query || "").trim();
|
|
if (!q)
|
|
return (tabs || []).slice(0);
|
|
return (tabs || []).filter(function (t) {
|
|
return FuzzyRanges(tabTitle(t), q, false) || FuzzyRanges(t.id, q, false);
|
|
});
|
|
}
|
|
|
|
function shouldRenderActionList(query) {
|
|
return !!((query || "").trim());
|
|
}
|
|
|
|
// Tile pictogram base path. The page lives at resources/web/dialog/SpeedDial/, so this climbs to
|
|
// resources/images/ where the same SVG icons the native GUI controls use are shipped.
|
|
var ICON_BASE = "../../../images/";
|
|
|
|
// SVG base name for an action's tile pictogram, or "" when it has none (commands without a GUI
|
|
// icon, plugins). Pure so the node-vm test can exercise it.
|
|
function actionIcon(a) {
|
|
return (a && a.icon) ? a.icon : "";
|
|
}
|
|
|
|
// Pictogram shown when an action carries none (plugins, icon-less commands/settings). A dedicated
|
|
// theme-neutral glyph (resources/images/action_default.svg: frame + ">_" prompt), so no tile is
|
|
// blank and no existing action's icon is borrowed.
|
|
var DEFAULT_ICON = "action_default";
|
|
|
|
// SVG base name a tile actually renders: the action's own icon, else the placeholder. Pure.
|
|
function tileIcon(a) {
|
|
return actionIcon(a) || DEFAULT_ICON;
|
|
}
|
|
|
|
// Put a pictogram into a tile (search row, favourites tile, or tab row). `mono` marks the white
|
|
// tab-strip glyphs, which the CSS recolors to the shared gray.
|
|
function fillTile(tile, a, mono) {
|
|
tile.textContent = "";
|
|
var icon = tileIcon(a);
|
|
var img = document.createElement("img");
|
|
img.className = mono ? "tile-icon tab-mono" : "tile-icon";
|
|
img.src = ICON_BASE + icon + ".svg";
|
|
img.alt = "";
|
|
img.setAttribute("aria-hidden", "true");
|
|
tile.appendChild(img);
|
|
}
|
|
|
|
// Category a row is grouped under in the empty-query list. Native commands and dynamic plate/recent
|
|
// actions carry a group; settings derive their top-level preset type from the source breadcrumb
|
|
// ("Process : Quality : Layers" -> "Process"); plugins all share one header.
|
|
function actionCategory(a) {
|
|
if (!a) return T("sd_other", "Other");
|
|
if (a.kind === "plugin") return T("sd_plugins", "Plugins");
|
|
if (a.group) return a.group;
|
|
var src = a.source || "";
|
|
var sep = src.indexOf(" : ");
|
|
var cat = sep === -1 ? src : src.slice(0, sep);
|
|
return cat || T("sd_other", "Other");
|
|
}
|
|
|
|
// Stable-bucket actions by category, then order the groups alphabetically. Within a group the incoming
|
|
// (frecency) order is kept. Pure so the node-vm test can exercise grouping.
|
|
function groupActions(list) {
|
|
var buckets = Object.create(null);
|
|
var order = [];
|
|
(list || []).forEach(function (a) {
|
|
var c = actionCategory(a);
|
|
if (!buckets[c]) { buckets[c] = []; order.push(c); }
|
|
buckets[c].push(a);
|
|
});
|
|
order.sort(function (x, y) {
|
|
var a = x.toLowerCase(), b = y.toLowerCase();
|
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
});
|
|
var out = [];
|
|
order.forEach(function (c) { out = out.concat(buckets[c]); });
|
|
return out;
|
|
}
|
|
|
|
// The active list for the main phase. A typed query ranks every action (commands/plugins/settings)
|
|
// by relevance; an empty query shows the recents first, then the rest grouped under category headers.
|
|
// The empty-query result is cached on the action/recents array identities so scrolling doesn't regroup.
|
|
var commandListCache = null;
|
|
// Typed-query result cache, keyed on the pool identity + query. searchActions populates the
|
|
// module-level matchIndex/searchNeedle; a hit restores both so a repeated call (keydown + input,
|
|
// or a scroll tick) skips the whole scan instead of recomputing it.
|
|
var searchCache = null;
|
|
function commandList(actions, recents, query) {
|
|
var all = actions || [];
|
|
if (shouldRenderActionList(query)) {
|
|
if (searchCache && searchCache.actions === all && searchCache.query === query) {
|
|
matchIndex = searchCache.matchIndex;
|
|
searchNeedle = searchCache.needle;
|
|
searchTokens = searchCache.tokens;
|
|
searchTokenRes = searchCache.tokenRes;
|
|
return searchCache.list;
|
|
}
|
|
var found = searchActions(all, query);
|
|
searchCache = { actions: all, query: query, list: found, matchIndex: matchIndex, needle: searchNeedle,
|
|
tokens: searchTokens, tokenRes: searchTokenRes };
|
|
return found;
|
|
}
|
|
var rec = recents || [];
|
|
if (commandListCache && commandListCache.actions === all && commandListCache.recents === rec)
|
|
return commandListCache.list;
|
|
var recentIds = {};
|
|
for (var i = 0; i < rec.length; i++)
|
|
recentIds[rec[i].id] = true;
|
|
var rest = all.filter(function (a) { return !recentIds[a.id]; });
|
|
var list = rec.concat(groupActions(rest));
|
|
commandListCache = { actions: all, recents: rec, list: list };
|
|
return list;
|
|
}
|
|
|
|
// Section headers for the main list: "Recent" (when recents exist) then one per category. The list is
|
|
// already grouped, so a header is emitted whenever the category changes. A typed query has no headers.
|
|
// Returns {startIndex: label}, where startIndex is the flat list index the header sits above.
|
|
function commandSections(list, recentsLen, query) {
|
|
if (shouldRenderActionList(query) || !list || !list.length)
|
|
return null;
|
|
var sections = {};
|
|
if (recentsLen > 0)
|
|
sections[0] = T("sd_recent", "Recent");
|
|
var prev = null;
|
|
for (var i = recentsLen; i < list.length; i++) {
|
|
var c = actionCategory(list[i]);
|
|
if (i === recentsLen || c !== prev)
|
|
sections[i] = c;
|
|
prev = c;
|
|
}
|
|
return sections;
|
|
}
|
|
|
|
// Resolve the selection cursor {zone,i} to the action id it points at: fav zone indexes the
|
|
// visible favourites, list zone the active commands list. `actions` must be the already-resolved
|
|
// list (recents for an empty query, the filtered list otherwise) - pure so runSelected() shares
|
|
// one lookup and the node-vm test can call it directly.
|
|
function selectedActionId(sel, actions, favIds) {
|
|
if (sel.zone === "fav")
|
|
return favIds[sel.i];
|
|
if (!actions || !actions.length)
|
|
return null;
|
|
var a = actions[sel.i];
|
|
return a && a.id;
|
|
}
|
|
|
|
function actionHasWiki(a) { return !!(a && a.wiki); }
|
|
|
|
// Whether the action has anything for the footer strip to show (a description or a wiki link).
|
|
function actionHasDetail(a) { return !!(a && ((a.desc && a.desc.length) || a.wiki)); }
|
|
|
|
// The expand/collapse arrow is offered for actions that carry a description. Collapsing is a global
|
|
// (persisted) preference, so even a short tooltip gets the control.
|
|
function detailToggleVisible(a) { return !!(a && a.desc && a.desc.length); }
|
|
|
|
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(); });
|
|
}
|
|
|
|
// True when the action's required mode is above the user's current mode, i.e. selecting it will
|
|
// prompt a mode switch. Unknown/empty modes are treated as "simple" so commands never flag.
|
|
function needsModeSwitch(item, userMode) {
|
|
var need = MODE_RANK[(item && item.mode) || "simple"] || 0;
|
|
var have = MODE_RANK[userMode || "simple"] || 0;
|
|
return need > have;
|
|
}
|
|
|
|
// Short mode tag for an action that needs a switch, or "" when it is already available.
|
|
function modeBadge(item, userMode) {
|
|
if (!needsModeSwitch(item, userMode)) return "";
|
|
if (item.mode === "develop") return T("sd_mode_develop", "Developer");
|
|
if (item.mode === "expert") return T("sd_mode_expert", "Expert");
|
|
if (item.mode === "advanced") return T("sd_mode_advanced", "Advanced");
|
|
return "";
|
|
}
|
|
|
|
// Search keywords that name a settings mode. "developer" (and the internal "develop") both select
|
|
// Developer; Simple is deliberately absent so it never floods the list with every command.
|
|
var MODE_WORDS = { advanced: "advanced", expert: "expert", developer: "develop", develop: "develop" };
|
|
|
|
// The mode values named as whole words in `query`, deduped. Case/diacritic-insensitive via Norm. A
|
|
// query with no mode keyword returns [] so the normal text search is completely unaffected.
|
|
function modeFilterFromQuery(query) {
|
|
var norm = NormText(String(query || "").trim(), false);
|
|
var found = [];
|
|
norm.split(/[^a-z0-9]+/).forEach(function (word) {
|
|
var mode = MODE_WORDS[word];
|
|
if (mode && found.indexOf(mode) === -1)
|
|
found.push(mode);
|
|
});
|
|
return found;
|
|
}
|
|
|
|
// Precomputed label parts for one action pool, keyed on the pool's array identity. The fold pass is
|
|
// O(N); doing it here instead of inside every actionLabel() call keeps row rendering O(rows), not O(rows*N).
|
|
var labelCache = null;
|
|
function labelParts(actions) {
|
|
if (labelCache && labelCache.actions === actions)
|
|
return labelCache;
|
|
var sig = {}, count = {};
|
|
(actions || []).forEach(function (o) {
|
|
var s = foldLabel(o.title) + "|" + foldLabel(o.source || o.group || "");
|
|
sig[o.id] = s;
|
|
count[s] = (count[s] || 0) + 1;
|
|
});
|
|
labelCache = { actions: actions, sig: sig, count: count };
|
|
return labelCache;
|
|
}
|
|
|
|
// 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 || action.group || "");
|
|
if (actions && actions.length) {
|
|
var cache = labelParts(actions);
|
|
var mine = cache.sig[action.id];
|
|
// mine is undefined only for an action outside the cached pool (e.g. a transient row); scan then.
|
|
var clash = mine !== undefined ? cache.count[mine] > 1 :
|
|
actions.some(function (o) {
|
|
return o.id !== action.id && foldLabel(o.title) + "|" + foldLabel(o.source || o.group || "") ===
|
|
foldLabel(action.title) + "|" + foldLabel(action.source || action.group || "");
|
|
});
|
|
if (clash)
|
|
label += " (" + action.id + ")";
|
|
}
|
|
return label;
|
|
}
|
|
|
|
function syncClearButton() {
|
|
if (clearEl)
|
|
clearEl.hidden = !query;
|
|
}
|
|
|
|
function stateFromPayload(payload) {
|
|
return {
|
|
actions: payload.actions || [],
|
|
favourites: payload.favourites || [],
|
|
recent: payload.recent || [],
|
|
userMode: payload.user_mode || "simple",
|
|
tooltipExpanded: payload.tooltip_expanded !== false
|
|
};
|
|
}
|
|
|
|
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 wraps at the bottom (last -> first).
|
|
// Up list wraps at the top (first -> last) only when there's no fav bar above; with a fav bar, Up at
|
|
// the list top goes to fav0 (unchanged). Left/Right clamp within fav. Returns a fresh {zone,i}.
|
|
function nextSel(sel, key, listLen, favLen) {
|
|
var zone = sel.zone, i = sel.i;
|
|
var last = Math.max(0, listLen - 1);
|
|
if (key === "ArrowDown") {
|
|
if (zone === "fav") return { zone: "list", i: 0 };
|
|
return { zone: "list", i: i >= last ? 0 : i + 1 };
|
|
}
|
|
if (key === "ArrowUp") {
|
|
if (zone === "list") {
|
|
if (i <= 0) return favLen ? { zone: "fav", i: 0 } : { zone: "list", i: last };
|
|
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.
|
|
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;
|
|
RECENTS = next.recent;
|
|
USER_MODE = next.userMode;
|
|
TOOLTIP_EXPANDED = next.tooltipExpanded;
|
|
// A fresh payload re-opens the main phase; C++ never rehydrates the transient phase/query state.
|
|
phase = "commands";
|
|
tabOptions = [];
|
|
query = "";
|
|
sel = { zone: "list", i: 0 };
|
|
// why: builtKey caches phase|query so renderCommandsList can skip a rebuild on arrow-nav.
|
|
// It survives a re-open (which never goes through exitPhase), so without a reset the cached
|
|
// empty-query key would skip the rebuild and leave stale list content.
|
|
builtKey = "";
|
|
if (qEl) {
|
|
qEl.value = "";
|
|
qEl.placeholder = T("sd_search_n", "Search %s actions", ACTIONS.length);
|
|
syncClearButton();
|
|
}
|
|
render({ resize: true, resetScroll: true });
|
|
focusInput();
|
|
} else if (payload.command === "tab_results") {
|
|
tabOptions = payload.tabs || [];
|
|
if (sel.zone === "list")
|
|
sel.i = Math.max(0, Math.min(sel.i, tabOptions.length - 1));
|
|
render({ resize: true });
|
|
} else if (payload.command === "favourite_full") {
|
|
// Favourites are at the quick-launch cap - undo the optimistic pin and flash a hint.
|
|
var fid = payload.id;
|
|
if (fid && FAVS.indexOf(fid) !== -1) FAVS.splice(FAVS.indexOf(fid), 1);
|
|
render({ resize: true, keepScroll: true });
|
|
flashHint(T("sd_favs_full", "Favourites are full (%s max)", (payload.limit || K_FAV_LIMIT)));
|
|
}
|
|
};
|
|
|
|
// ---- 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 findActionByInput(input) {
|
|
for (var i = 0; i < ACTIONS.length; i++) if (ACTIONS[i].input === input) return ACTIONS[i];
|
|
return null;
|
|
}
|
|
|
|
function currentVisibleFavs() { return visibleFavourites(FAVS, ACTIONS); }
|
|
|
|
// Active list for the current phase (drives list rendering + arrow nav).
|
|
function currentList() {
|
|
if (phase === "tab") return filterTabs(tabOptions, query);
|
|
if (phase === "commands") return commandList(ACTIONS, RECENTS, query);
|
|
return []; // percent - the input itself is the only field
|
|
}
|
|
|
|
// Build a <div class=className> with the search-match ranges wrapped in <mark>. 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;
|
|
}
|
|
|
|
// A bookmark glyph: outlined when unpinned, filled when saved to favourites.
|
|
function pinSvg(on) {
|
|
return '<svg width="15" height="15" viewBox="0 0 24 24" fill="' + (on ? "currentColor" : "none") +
|
|
'" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">' +
|
|
'<path d="M6 3.5A1.5 1.5 0 0 1 7.5 2h9A1.5 1.5 0 0 1 18 3.5V21l-6-4.2L6 21z"/></svg>';
|
|
}
|
|
|
|
// Sync one pin button to its favourite state. Shared by row construction and the in-place
|
|
// updatePins pass so the two can't drift.
|
|
function setPinState(pin, on) {
|
|
pin.classList.toggle("on", on);
|
|
pin.innerHTML = pinSvg(on);
|
|
pin.title = on ? T("sd_unpin_fav", "Unpin from favourites (%s)", shortcutCtrl() + "B") :
|
|
T("sd_pin_fav", "Pin to favourites (%s)", shortcutCtrl() + "B");
|
|
}
|
|
|
|
// ---- render ------------------------------------------------------------------
|
|
function renderFav() {
|
|
// Only the commands phase shows the pinned quick-bar.
|
|
if (phase !== "commands") {
|
|
if (favEl) { favEl.innerHTML = ""; favEl.hidden = true; }
|
|
if (eyeEl) eyeEl.hidden = true;
|
|
return;
|
|
}
|
|
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" : "");
|
|
fillTile(tile, a);
|
|
var tileBadge = modeBadge(a, USER_MODE);
|
|
tile.title = a.title + (tileBadge ? " (" + tileBadge + ")" : "");
|
|
tile.setAttribute("aria-label", actionLabel(a, ACTIONS));
|
|
tile.onclick = function () { sel = { zone: "fav", i: i }; activateEntry(a); };
|
|
// Numbered quick-launch badge (Alt/Option+digit), drawn on the corner.
|
|
var slot = favSlotForIndex(i);
|
|
if (slot) {
|
|
var badge = document.createElement("span");
|
|
badge.className = "fav-slot";
|
|
badge.textContent = slot;
|
|
// Slot "0" is the 10th favourite (Alt/Option+0).
|
|
var slot_num = slot === "0" ? "10" : slot;
|
|
badge.title = T("sd_fav_slot", "Favourite %s (%s)", slot_num, shortcutAlt() + slot);
|
|
tile.appendChild(badge);
|
|
}
|
|
// Direct removal: a hover-revealed ✕ in the tile's corner. click() stops propagation so it
|
|
// unpins without activating the action.
|
|
var unpin = document.createElement("button");
|
|
unpin.className = "fav-unpin";
|
|
unpin.title = T("sd_remove_fav", "Remove from favourites");
|
|
unpin.setAttribute("aria-label", T("sd_remove_fav", "Remove from favourites"));
|
|
unpin.innerHTML = '<svg width="9" height="9" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg>';
|
|
unpin.onclick = function (ev) { ev.stopPropagation(); toggleFav(id); };
|
|
tile.appendChild(unpin);
|
|
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(T("sd_move_left", "Move left"), vi > 0, function () { moveFav(id, -1); });
|
|
addFavMenuItem(T("sd_move_right", "Move right"), vi >= 0 && vi < favs.length - 1, function () { moveFav(id, 1); });
|
|
addFavMenuItem(T("sd_unpin", "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 the persisted order stable.
|
|
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;
|
|
}
|
|
|
|
// Row shell shared by action and tab rows: the row (selection class + aria label), the icon tile and
|
|
// the text column. Returns the pieces the caller fills in (eyebrow/name/badge/pin, handlers).
|
|
function beginRow(item, i, mono, ariaLabel) {
|
|
var row = document.createElement("div");
|
|
row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : "");
|
|
row.setAttribute("aria-label", ariaLabel);
|
|
|
|
var tile = document.createElement("div");
|
|
tile.className = "tile";
|
|
fillTile(tile, item, mono);
|
|
|
|
var left = document.createElement("div");
|
|
left.className = "row-left";
|
|
var line = document.createElement("div");
|
|
line.className = "row-line";
|
|
left.appendChild(line);
|
|
row.appendChild(tile);
|
|
row.appendChild(left);
|
|
return { row: row, left: left, line: line };
|
|
}
|
|
|
|
// A command/action row - used for search results, recents, and (because settings are actions now)
|
|
// the setting options too. All rows are pinnable, so every row carries a bookmark.
|
|
function renderActionRow(a, i) {
|
|
var on = FAVS.indexOf(a.id) !== -1;
|
|
var shell = beginRow(a, i, false, actionLabel(a, ACTIONS));
|
|
var mi = matchIndex[a.id];
|
|
// The eyebrow shows group when present, else source. Highlight with the ranges of whichever of the
|
|
// two the eyebrow actually renders (so a "Recent Projects"/"Object" header match lights up like a
|
|
// setting path does - the offsets are computed against the same string we are marking).
|
|
var eyebrow = a.group || a.source;
|
|
var eyebrowMatch = mi ? (mi.useEyebrowGroup ? mi.group : mi.source) : null;
|
|
shell.left.insertBefore(markedText("row-eyebrow", eyebrow, eyebrowMatch), shell.line);
|
|
shell.line.appendChild(markedText("row-name", a.title, mi ? mi.title : null));
|
|
var badge = modeBadge(a, USER_MODE);
|
|
if (badge) {
|
|
var tag = document.createElement("span");
|
|
tag.className = "row-mode";
|
|
tag.textContent = badge;
|
|
shell.line.appendChild(tag);
|
|
}
|
|
|
|
var pin = document.createElement("button");
|
|
pin.className = "pin";
|
|
setPinState(pin, on);
|
|
pin.onclick = function (ev) { ev.stopPropagation(); toggleFav(a.id); };
|
|
// why: two quick fav/unfav clicks must not dblclick-run the row
|
|
pin.ondblclick = function (ev) { ev.stopPropagation(); };
|
|
shell.row.appendChild(pin);
|
|
|
|
shell.row.onclick = function () { sel = { zone: "list", i: i }; render({ resize: true }); };
|
|
shell.row.ondblclick = function () { sel = { zone: "list", i: i }; activateEntry(a); };
|
|
return shell.row;
|
|
}
|
|
|
|
// Append rows [from, to) into listEl, always inserting before the bottom spacer so row order is
|
|
// preserved. A section header is inserted just before the first row of its section.
|
|
function appendActionRows(list, from, to) {
|
|
var spacer = spacerEl || ensureSpacer();
|
|
for (var i = from; i < to; i++) {
|
|
if (sectionStarts && sectionStarts[i] !== undefined) {
|
|
var header = document.createElement("div");
|
|
header.className = "dial-section";
|
|
header.textContent = sectionStarts[i];
|
|
listEl.insertBefore(header, spacer);
|
|
sectionRendered++;
|
|
}
|
|
var row = renderActionRow(list[i], i);
|
|
row.setAttribute("data-idx", i);
|
|
listEl.insertBefore(row, spacer);
|
|
}
|
|
}
|
|
|
|
// Set the section map for the current list and reset the rendered-header counters (a fresh build).
|
|
function setSections(sections) {
|
|
sectionStarts = sections || null;
|
|
sectionTotal = sectionStarts ? Object.keys(sectionStarts).length : 0;
|
|
sectionRendered = 0;
|
|
}
|
|
|
|
// Ensure the bottom spacer exists as the last child of listEl. It is (re)created on rebuild because
|
|
// listEl.innerHTML="" destroys the old node.
|
|
function ensureSpacer() {
|
|
if (!spacerEl || spacerEl.parentNode !== listEl) {
|
|
spacerEl = document.createElement("div");
|
|
spacerEl.className = "dial-spacer-bottom";
|
|
listEl.appendChild(spacerEl);
|
|
}
|
|
return spacerEl;
|
|
}
|
|
|
|
// Size the spacer to the un-rendered tail so the scrollbar reflects the full match count. Pending
|
|
// section headers reserve their own height too, so the last rows stay reachable.
|
|
function setBottomSpacer(total) {
|
|
ensureSpacer();
|
|
spacerEl.style.height = spacerHeight(total, renderEnd, sectionTotal, sectionRendered) + "px";
|
|
}
|
|
|
|
// Reveal rows up to `upto` (an exclusive index), appending without rebuilding the whole list. Used by
|
|
// the scroll handler (viewport + overscan) and by arrow-nav that runs off the end of the current window.
|
|
// The window is append-only and row indices are absolute, so jumping the selection to the very last row
|
|
// (ArrowUp wrap with no fav bar) necessarily materializes the whole list; the label/search caches above
|
|
// keep that one-off cost linear rather than quadratic.
|
|
function revealTo(list, upto) {
|
|
var need = Math.min(list.length, upto);
|
|
if (need <= renderEnd)
|
|
return;
|
|
appendActionRows(list, renderEnd, need);
|
|
renderEnd = need;
|
|
setBottomSpacer(list.length);
|
|
}
|
|
|
|
// Rebuild the list from the first window (new search / phase change), clearing stale rows.
|
|
function rebuildCommandsList(list) {
|
|
listEl.innerHTML = "";
|
|
listEl.className = "dial-list";
|
|
ensureSpacer();
|
|
renderEnd = 0;
|
|
sectionRendered = 0;
|
|
appendActionRows(list, 0, Math.min(list.length, K_ROWS));
|
|
renderEnd = Math.min(list.length, K_ROWS);
|
|
setBottomSpacer(list.length);
|
|
}
|
|
|
|
// Toggle the .sel class in place - arrow-nav/click don't rebuild the DOM, just re-highlight the row.
|
|
function updateSelection() {
|
|
var rows = listEl ? listEl.querySelectorAll(".row") : [];
|
|
for (var i = 0; i < rows.length; i++) {
|
|
var idx = parseInt(rows[i].getAttribute("data-idx"), 10);
|
|
rows[i].classList.toggle("sel", sel.zone === "list" && idx === sel.i);
|
|
}
|
|
}
|
|
|
|
// Sync the pin buttons in place when FAVS changes but the row set doesn't (fav toggle), so a
|
|
// bookmark fills/empties without a rebuild that would reset scroll. Rows carry data-idx into the
|
|
// active list.
|
|
function updatePins(list) {
|
|
var rows = listEl ? listEl.querySelectorAll(".row") : [];
|
|
for (var i = 0; i < rows.length; i++) {
|
|
var a = list[parseInt(rows[i].getAttribute("data-idx"), 10)];
|
|
var pin = rows[i].querySelector(".pin");
|
|
if (!a || !pin) continue;
|
|
var on = FAVS.indexOf(a.id) !== -1;
|
|
if (pin.classList.contains("on") !== on)
|
|
setPinState(pin, on);
|
|
}
|
|
}
|
|
|
|
// Replace the list with a single placeholder message (no matches / empty state). Shared by all phases.
|
|
function renderEmpty(text) {
|
|
listEl.innerHTML = "";
|
|
spacerEl = null;
|
|
listEl.className = "dial-list empty";
|
|
setSections(null);
|
|
if (countEl) countEl.hidden = true;
|
|
var empty = document.createElement("div");
|
|
empty.className = "dial-empty";
|
|
empty.textContent = text;
|
|
listEl.appendChild(empty);
|
|
}
|
|
|
|
function renderCommandsList() {
|
|
var list = currentList();
|
|
var total = list.length;
|
|
if (sel.zone === "list")
|
|
sel.i = Math.max(0, Math.min(sel.i, total - 1));
|
|
var showList = shouldRenderActionList(query);
|
|
// Recents are not filtered, so clear any stale match marks from a previous typed query.
|
|
if (!showList)
|
|
matchIndex = {};
|
|
|
|
if (!total) {
|
|
renderEmpty(showList ? T("sd_no_match_total", "No actions match (Total: %s)", ACTIONS.length)
|
|
: T("sd_no_actions", "No actions yet"));
|
|
renderEnd = 0;
|
|
builtKey = buildKey() + "|0";
|
|
return;
|
|
}
|
|
|
|
var key = buildKey() + "|" + total;
|
|
if (key !== builtKey) {
|
|
builtKey = key;
|
|
// Headers split the empty-query list into recents + category groups; typed queries have none.
|
|
setSections(showList ? null : commandSections(list, (RECENTS || []).length, query));
|
|
rebuildCommandsList(list);
|
|
} else if (sel.i >= renderEnd) {
|
|
// Arrow-nav walked past the rendered window - reveal enough to keep the selection visible.
|
|
revealTo(list, revealTarget(total, sel.i, K_ROWS));
|
|
}
|
|
|
|
listEl.className = "dial-list";
|
|
// The empty-query list is labelled by its section headers instead.
|
|
if (countEl) {
|
|
countEl.hidden = !showList;
|
|
if (showList)
|
|
countEl.textContent = resultCountText(ACTIONS.length, total, query);
|
|
}
|
|
updateSelection();
|
|
updatePins(list);
|
|
}
|
|
|
|
// A tab row: no pin/unpin (tabs aren't pinnable), and a tile that shows the page icon when the
|
|
// notebook has one (plugin pages often don't). Uses tabTitle so pages added with an empty text
|
|
// (e.g. Home) still show a label.
|
|
function renderTabRow(t, i) {
|
|
var label = tabTitle(t);
|
|
var shell = beginRow(t, i, true, label);
|
|
var name = document.createElement("div");
|
|
name.className = "row-name";
|
|
name.textContent = label;
|
|
shell.line.appendChild(name);
|
|
shell.row.onclick = function () { sel = { zone: "list", i: i }; render({ resize: true }); };
|
|
shell.row.ondblclick = function () { sel = { zone: "list", i: i }; jumpToTab(t); };
|
|
return shell.row;
|
|
}
|
|
|
|
function renderTabList() {
|
|
var q = (query || "").trim();
|
|
var list = currentList();
|
|
listEl.innerHTML = "";
|
|
spacerEl = null; // the tab list has no windowed spacer; rebuildCommandsList recreates it
|
|
|
|
if (!list.length) {
|
|
renderEmpty(q ? T("sd_no_tabs_match", "No tabs match") : T("sd_no_tabs", "No tabs"));
|
|
return;
|
|
}
|
|
if (sel.zone === "list")
|
|
sel.i = Math.max(0, Math.min(sel.i, list.length - 1));
|
|
listEl.className = "dial-list";
|
|
if (countEl) {
|
|
countEl.hidden = false;
|
|
countEl.textContent = q ? T("sd_tab_match_count", "%s matches", list.length) : T("sd_tab_count", "%s tabs", list.length);
|
|
}
|
|
list.forEach(function (t, i) { listEl.appendChild(renderTabRow(t, i)); });
|
|
}
|
|
|
|
function renderPercentList() {
|
|
var q = (query || "").trim();
|
|
renderEmpty(q ? T("sd_go_to_pct", "Go to %s% of the layer range", q) : T("sd_enter_pct", "Enter a layer percentage (0-100)"));
|
|
}
|
|
|
|
function renderList() {
|
|
if (phase === "tab")
|
|
renderTabList();
|
|
else if (phase === "percent")
|
|
renderPercentList();
|
|
else
|
|
renderCommandsList();
|
|
}
|
|
|
|
// The action the footer describes: the current selection resolved through the active list/fav bar.
|
|
function currentDetailAction() {
|
|
if (phase !== "commands") return null;
|
|
var id = selectedActionId(sel, currentList(), currentVisibleFavs());
|
|
return id ? byId(id) : null;
|
|
}
|
|
|
|
// Footer detail strip: the selected action's description, its wiki link and the expand/collapse
|
|
// arrow. Shown whenever the highlighted action has a description or a wiki page; expanding is a
|
|
// persisted global preference, so the arrow stays available to collapse/expand every tooltip.
|
|
function renderDetail() {
|
|
if (!detailEl) return;
|
|
var a = currentDetailAction();
|
|
var hasDesc = detailToggleVisible(a);
|
|
var show = phase === "commands" && actionHasDetail(a);
|
|
detailEl.hidden = !show;
|
|
detailEl.innerHTML = "";
|
|
if (!show) return;
|
|
if (hasDesc && TOOLTIP_EXPANDED) {
|
|
var desc = document.createElement("div");
|
|
desc.className = "detail-desc";
|
|
desc.textContent = a.desc;
|
|
detailEl.appendChild(desc);
|
|
}
|
|
// The wiki link is part of the expanded detail, so collapsing hides it too. An action with only a
|
|
// wiki (no description) has nothing to collapse, so its link always shows.
|
|
if (a.wiki && (!hasDesc || TOOLTIP_EXPANDED)) {
|
|
var link = document.createElement("button");
|
|
link.type = "button";
|
|
link.className = "detail-wiki";
|
|
link.textContent = T("sd_wiki_f1", "Wiki (F1)");
|
|
link.onclick = function (ev) { ev.stopPropagation(); SendMessage({ command: "open_wiki", id: a.id }); };
|
|
detailEl.appendChild(link);
|
|
}
|
|
if (hasDesc) {
|
|
var toggle = document.createElement("button");
|
|
toggle.type = "button";
|
|
toggle.className = "detail-toggle";
|
|
toggle.setAttribute("aria-expanded", TOOLTIP_EXPANDED ? "true" : "false");
|
|
var label = TOOLTIP_EXPANDED ? T("sd_hide_details", "Hide details") : T("sd_show_details", "Show details");
|
|
toggle.title = label;
|
|
toggle.setAttribute("aria-label", label);
|
|
toggle.innerHTML = '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" ' +
|
|
'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
'<polyline points="4,6 8,10 12,6"/></svg>';
|
|
toggle.onclick = function (ev) {
|
|
ev.stopPropagation();
|
|
TOOLTIP_EXPANDED = !TOOLTIP_EXPANDED;
|
|
SendMessage({ command: "set_tooltip_expanded", expanded: TOOLTIP_EXPANDED });
|
|
render({ resize: true });
|
|
};
|
|
detailEl.appendChild(toggle);
|
|
}
|
|
}
|
|
|
|
// Refresh the muted inline completion shown at the end of the search field. Only offered in the
|
|
// commands phase, with the caret at the end of a non-empty, non-trailing-space input that isn't
|
|
// scrolled (so the overlay lines up with the real caret). The ghost is the typed text (hidden, to
|
|
// reserve its width) followed by the suggested suffix, so it sits exactly after the caret.
|
|
function updateGhost() {
|
|
activeCompletion = null;
|
|
if (!ghostEl || !qEl) return;
|
|
var eligible = phase === "commands" && qEl.value && qEl.selectionStart === qEl.value.length &&
|
|
!/\s$/.test(qEl.value) && qEl.scrollWidth <= qEl.clientWidth;
|
|
var comp = eligible ? completionFor(query, currentList()) : null;
|
|
if (!comp) { ghostEl.hidden = true; return; }
|
|
ghostTypedEl.textContent = qEl.value;
|
|
ghostSuffixEl.textContent = comp.suffix;
|
|
ghostEl.hidden = false;
|
|
activeCompletion = comp;
|
|
}
|
|
|
|
function render(opts) {
|
|
renderFav();
|
|
renderList();
|
|
renderDetail();
|
|
updateGhost();
|
|
// Pin toggles don't move the selection, so they pass keepScroll to avoid snapping the list
|
|
// back to a row that is currently off-screen.
|
|
if (!(opts && opts.keepScroll))
|
|
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;
|
|
// Include any overflow (WebKit can report a -webkit-box border box a fraction short of its
|
|
// content), so the window never clips the footer's last line.
|
|
var height = Math.ceil(Math.max(launcher.getBoundingClientRect().height, launcher.scrollHeight));
|
|
if (!height)
|
|
return;
|
|
// Always (re)send rather than caching: a resize can be measured but dropped (e.g. while the
|
|
// window is being shown) and an unchanged-size cache would then suppress every retry. C++
|
|
// SetClientSize is a no-op on an unchanged size, so this is cheap.
|
|
SendMessage({ command: "resize", height: height });
|
|
}, 0);
|
|
}
|
|
|
|
// Transient inline hint pinned to the top of the launcher (e.g. "favourites are full").
|
|
function flashHint(text) {
|
|
if (!document.body) return;
|
|
var launcher = document.querySelector(".launcher");
|
|
if (!launcher) return;
|
|
var hint = document.createElement("div");
|
|
hint.className = "dial-flash";
|
|
hint.textContent = text;
|
|
launcher.insertBefore(hint, launcher.firstChild);
|
|
setTimeout(function () {
|
|
if (hint && hint.parentNode) {
|
|
hint.parentNode.removeChild(hint);
|
|
requestResize(); // reclaim the hint's height so the popup doesn't stay tall
|
|
}
|
|
}, 2500);
|
|
}
|
|
|
|
// ---- 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, keepScroll: true });
|
|
}
|
|
|
|
// Fire a command/plugin 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, param: "" });
|
|
}
|
|
|
|
// Activate an entry in the main phase. Two-phase commands switch the palette to their input phase
|
|
// instead of running; everything else (including a setting jump, which is a plain action now) runs.
|
|
function activateEntry(a) {
|
|
if (!a) return;
|
|
if (a.input === "percent") { enterPercentPhase(); return; }
|
|
if (a.input === "tab") { enterTabsPhase(); return; }
|
|
run(a);
|
|
}
|
|
|
|
function runSelected() {
|
|
if (phase === "percent") {
|
|
runJumpToLayer(query.trim());
|
|
return;
|
|
}
|
|
if (phase === "tab") {
|
|
var t = currentList()[sel.i];
|
|
if (t) jumpToTab(t);
|
|
return;
|
|
}
|
|
var list = currentList();
|
|
var id = selectedActionId(sel, list, currentVisibleFavs());
|
|
if (id) activateEntry(byId(id));
|
|
}
|
|
|
|
function runJumpToLayer(pct) {
|
|
if (pct === "") return;
|
|
var n = parseFloat(pct);
|
|
if (!isFinite(n) || n < 0 || n > 100) return;
|
|
var a = findActionByInput("percent");
|
|
if (!a) return;
|
|
SendMessage({ command: "run_action", id: a.id, title: a.title, param: String(n) });
|
|
}
|
|
|
|
function jumpToTab(t) {
|
|
var a = findActionByInput("tab");
|
|
if (!a || !t) return;
|
|
SendMessage({ command: "run_action", id: a.id, title: a.title, param: t.id });
|
|
}
|
|
|
|
// Clear the shared query/cursor state when entering or leaving a phase. Callers set the
|
|
// phase-specific placeholder, then render.
|
|
function resetPhaseInput() {
|
|
query = ""; qEl.value = ""; sel = { zone: "list", i: 0 };
|
|
syncClearButton();
|
|
}
|
|
|
|
function enterPercentPhase() {
|
|
phase = "percent";
|
|
resetPhaseInput();
|
|
qEl.placeholder = T("sd_go_layer_ph", "Go to layer % (0-100)");
|
|
render({ resize: true, resetScroll: true });
|
|
qEl.focus();
|
|
}
|
|
|
|
function enterTabsPhase() {
|
|
phase = "tab"; tabOptions = [];
|
|
resetPhaseInput();
|
|
qEl.placeholder = T("sd_go_tab_ph", "Go to tab");
|
|
render({ resize: true, resetScroll: true });
|
|
qEl.focus();
|
|
SendMessage({ command: "search_tabs" });
|
|
}
|
|
|
|
function exitPhase() {
|
|
phase = "commands"; tabOptions = [];
|
|
resetPhaseInput();
|
|
// why: builtKey caches phase|query so renderCommandsList can skip a rebuild on arrow-nav/click.
|
|
// It survives a second-phase exit (which never goes through exitPhase from the commands view),
|
|
// so without a reset the cached empty-query key would skip the rebuild and leave stale content.
|
|
builtKey = "";
|
|
qEl.placeholder = T("sd_search_n", "Search %s actions", ACTIONS.length);
|
|
render({ resize: true, resetScroll: true });
|
|
qEl.focus();
|
|
}
|
|
|
|
function focusInput() { setTimeout(function () { if (qEl) qEl.focus(); }, 0); }
|
|
|
|
// ---- init --------------------------------------------------------------------
|
|
function OnInit() {
|
|
qEl = $("q"); listEl = $("list"); favEl = $("favBar"); clearEl = $("clear"); eyeEl = $("favEyebrow"); countEl = $("count"); detailEl = $("detail");
|
|
ghostEl = $("ghost"); ghostTypedEl = $("ghostTyped"); ghostSuffixEl = $("ghostSuffix");
|
|
// text.js's TranslatePage() targets jQuery `.trans` nodes; this page has none and defines its own
|
|
// `$`, so don't call it. Runtime strings go through T() instead.
|
|
qEl.placeholder = T("sd_search", "Search actions");
|
|
qEl.setAttribute("aria-label", T("sd_search", "Search actions"));
|
|
if (clearEl) {
|
|
clearEl.title = T("sd_clear", "Clear");
|
|
clearEl.setAttribute("aria-label", T("sd_clear", "Clear"));
|
|
}
|
|
syncClearButton();
|
|
|
|
$("clear").onclick = function () {
|
|
resetPhaseInput();
|
|
render({ resize: true, resetScroll: true });
|
|
qEl.focus();
|
|
};
|
|
qEl.addEventListener("input", function () {
|
|
query = qEl.value; sel = { zone: "list", i: 0 }; syncClearButton();
|
|
render({ resize: true, resetScroll: true });
|
|
});
|
|
// Caret moves without a value change (click / arrow keys) can enable or invalidate the ghost.
|
|
qEl.addEventListener("keyup", updateGhost);
|
|
qEl.addEventListener("click", updateGhost);
|
|
// Windowed reveal: as the list scrolls, materialize the next window (append-only, no rebuild) so the
|
|
// DOM stays bounded to what's near the viewport. Guarded to the commands phase (tabs/percent are tiny).
|
|
listEl.addEventListener("scroll", function () {
|
|
if (phase !== "commands")
|
|
return;
|
|
var list = currentList();
|
|
if (renderEnd >= list.length)
|
|
return;
|
|
var firstVisible = Math.max(0, Math.floor(listEl.scrollTop / ROW_H));
|
|
// Reveal the viewport + a full window of lookahead so fast scrolling doesn't hit a blank tail.
|
|
revealTo(list, revealTarget(list.length, firstVisible, 2 * K_ROWS));
|
|
});
|
|
|
|
// 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; }
|
|
// F1 opens the selected setting's wiki page. Settings without one flash a hint instead.
|
|
if (e.key === "F1") {
|
|
e.preventDefault();
|
|
if (phase !== "commands") return;
|
|
var help = currentDetailAction();
|
|
if (actionHasWiki(help)) SendMessage({ command: "open_wiki", id: help.id });
|
|
else flashHint(T("sd_no_wiki", "No wiki page for this action"));
|
|
return;
|
|
}
|
|
// Accept the inline completion: append the suggested word's suffix. Text only - the list
|
|
// selection is left where it is; Enter still runs the highlighted result. Shift+Tab is left
|
|
// alone so keyboard focus traversal still works.
|
|
if (e.key === "Tab" && phase === "commands" && activeCompletion &&
|
|
!e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
|
e.preventDefault();
|
|
qEl.value += activeCompletion.suffix;
|
|
query = qEl.value;
|
|
syncClearButton();
|
|
render({ resize: true, resetScroll: true });
|
|
qEl.focus();
|
|
return;
|
|
}
|
|
// Pin/unpin the highlighted action: Ctrl/Cmd+B. Commands phase only (tabs/percent aren't pinnable).
|
|
if (phase === "commands" && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey &&
|
|
e.key.toLowerCase() === "b") {
|
|
e.preventDefault();
|
|
var id = selectedActionId(sel, currentList(), currentVisibleFavs());
|
|
if (id) toggleFav(id);
|
|
return;
|
|
}
|
|
// Quick-launch a numbered favourite: Alt/Option + digit (0 = the 10th). Only in the
|
|
// commands phase, where the pinned bar is shown.
|
|
if (phase === "commands" && e.altKey && !e.ctrlKey && !e.metaKey) {
|
|
var slotIdx = favIndexForDigit(favDigitFromEvent(e));
|
|
var favIds = currentVisibleFavs();
|
|
if (slotIdx >= 0 && slotIdx < favIds.length) {
|
|
e.preventDefault();
|
|
var fav = byId(favIds[slotIdx]);
|
|
if (fav) activateEntry(fav);
|
|
return;
|
|
}
|
|
}
|
|
var list = currentList();
|
|
// why: fav bar only exists in the commands phase; other phases are list-only, so an
|
|
// ArrowUp at the top must not jump into a hidden fav zone.
|
|
var favs = (phase === "commands") ? 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")) {
|
|
// In the percent phase the input control owns the caret - arrows edit text, not rows.
|
|
if (phase === "percent") return;
|
|
e.preventDefault();
|
|
sel = nextSel(sel, e.key, list.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.
|
|
render({ resize: true });
|
|
} else if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
if (phase === "percent") runJumpToLayer(query.trim());
|
|
else runSelected();
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
if (phase !== "commands") { exitPhase(); }
|
|
else if (query) { query = ""; qEl.value = ""; sel = { zone: "list", i: 0 }; syncClearButton(); render({ resize: true, resetScroll: true }); qEl.focus(); }
|
|
else SendMessage({ command: "close_page" });
|
|
}
|
|
});
|
|
|
|
// Keep the dialog sized to the content: any reflow that lands after a render (tooltip
|
|
// expand/collapse or clamped-box settling, font metrics, list reveal) re-measures. Without this
|
|
// a later reflow left the window a few pixels short and clipped the footer's last line.
|
|
if (typeof ResizeObserver !== "undefined") {
|
|
new ResizeObserver(function () { requestResize(); }).observe(document.querySelector(".launcher"));
|
|
}
|
|
// Font metrics can swap after first layout; re-measure once they settle.
|
|
if (document.fonts && document.fonts.ready && document.fonts.ready.then)
|
|
document.fonts.ready.then(function () { requestResize(); });
|
|
|
|
SendMessage({ command: "request_actions" });
|
|
}
|