mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-27 19:01:02 +00:00
Tab autocomplete. Better search integration of categories and action names
This commit is contained in:
@@ -19,7 +19,10 @@
|
|||||||
<span class="plugin-search-icon" aria-hidden="true">
|
<span class="plugin-search-icon" aria-hidden="true">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/></svg>
|
||||||
</span>
|
</span>
|
||||||
<input class="plugin-search-input" id="q" type="text" placeholder="Search actions" spellcheck="false" autocomplete="off" aria-label="Search actions" />
|
<span class="plugin-search-field">
|
||||||
|
<span class="search-ghost" id="ghost" hidden aria-hidden="true"><span class="search-ghost-typed" id="ghostTyped"></span><span class="search-ghost-suffix" id="ghostSuffix"></span></span>
|
||||||
|
<input class="plugin-search-input" id="q" type="text" placeholder="Search actions" spellcheck="false" autocomplete="off" aria-label="Search actions" />
|
||||||
|
</span>
|
||||||
<button class="plugin-search-clear" id="clear" type="button" title="Clear" aria-label="Clear search" hidden>
|
<button class="plugin-search-clear" id="clear" type="button" title="Clear" aria-label="Clear search" hidden>
|
||||||
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><line x1="5" y1="5" x2="11" y2="11"/><line x1="11" y1="5" x2="5" y2="11"/></svg>
|
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><line x1="5" y1="5" x2="11" y2="11"/><line x1="11" y1="5" x2="5" y2="11"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ var MODE_RANK = { simple: 0, advanced: 1, expert: 2, develop: 3 };
|
|||||||
var SCORE_CONTIGUOUS = 100000;
|
var SCORE_CONTIGUOUS = 100000;
|
||||||
var SCORE_TITLE = 2000;
|
var SCORE_TITLE = 2000;
|
||||||
var SCORE_GROUP = 1000;
|
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
|
// 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
|
// as a document-start user script (SpeedDialWebDialog::add_user_scripts); the English literal is a
|
||||||
@@ -68,8 +71,11 @@ var sectionStarts = null;
|
|||||||
var sectionTotal = 0;
|
var sectionTotal = 0;
|
||||||
var sectionRendered = 0;
|
var sectionRendered = 0;
|
||||||
|
|
||||||
// search-cache: the normalized (folded+lowercased) needle for the current query pass.
|
// 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 searchNeedle = "";
|
||||||
|
var searchTokens = [];
|
||||||
|
var searchTokenRes = [];
|
||||||
|
|
||||||
// Palette phase: 'commands' (one unified search over actions/commands/settings, recents on empty
|
// 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..."
|
// query), 'percent' ("Go to layer" second phase: enter a 0-100 percentage), 'tab' ("Go to tab..."
|
||||||
@@ -82,6 +88,9 @@ var tabOptions = []; // [{id,title}] - notebook pages, fetched on entering
|
|||||||
|
|
||||||
// element handles, assigned in OnInit (kept null so load-time touches no DOM)
|
// 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 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) -------------------------------------
|
// ---- pure helpers (no DOM; unit-tested) -------------------------------------
|
||||||
// Pre-normalized haystacks, cached on the action object. The fold is length-preserving (1:1 per
|
// Pre-normalized haystacks, cached on the action object. The fold is length-preserving (1:1 per
|
||||||
@@ -106,20 +115,20 @@ function sourceNorm(a) {
|
|||||||
return a._sn;
|
return a._sn;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match one pre-normalized field vs the current needle. Returns {score, ranges, contiguous} when the
|
// Match one pre-normalized field vs one pre-normalized needle. Returns {score, ranges, contiguous}
|
||||||
// needle is present, else null. wwRe is a compiled whole-word (\b-bounded) regex for the current needle.
|
// when the needle is present, else null. wwRe is a compiled whole-word (\b-bounded) regex for the
|
||||||
// A whole-word hit is preferred - it highlights the full word (e.g. "orient" in "Auto-Orient", not the
|
// needle. A whole-word hit is preferred - it highlights the full word (e.g. "orient" in "Auto-Orient",
|
||||||
// stray "o" of "Auto") and marks a perfect match. Otherwise FuzzyRangesNorm (which now prefers the
|
// 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
|
// 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.
|
// a perfect match - the whole needle landed as one unbroken run.
|
||||||
function fieldMatchScore(norm, wwRe) {
|
function fieldMatchScore(norm, needle, wwRe) {
|
||||||
if (!searchNeedle) return null;
|
if (!needle) return null;
|
||||||
if (wwRe) {
|
if (wwRe) {
|
||||||
var m = wwRe.exec(norm || "");
|
var m = wwRe.exec(norm || "");
|
||||||
if (m)
|
if (m)
|
||||||
return { score: 1000 - m.index * 10, ranges: [[m.index, m.index + m[0].length]], contiguous: true };
|
return { score: 1000 - m.index * 10, ranges: [[m.index, m.index + m[0].length]], contiguous: true };
|
||||||
}
|
}
|
||||||
var r = FuzzyRangesNorm(norm || "", searchNeedle);
|
var r = FuzzyRangesNorm(norm || "", needle);
|
||||||
if (!r) return null;
|
if (!r) return null;
|
||||||
var gaps = 0, len = 0;
|
var gaps = 0, len = 0;
|
||||||
for (var i = 0; i < r.length; i++) {
|
for (var i = 0; i < r.length; i++) {
|
||||||
@@ -127,7 +136,57 @@ function fieldMatchScore(norm, wwRe) {
|
|||||||
gaps += r[i][0] - r[i - 1][1];
|
gaps += r[i][0] - r[i - 1][1];
|
||||||
len += r[i][1] - r[i][0];
|
len += r[i][1] - r[i][0];
|
||||||
}
|
}
|
||||||
return { score: 1000 - r[0][0] * 10 - gaps * 10, ranges: r, contiguous: r.length === 1 && len === searchNeedle.length };
|
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 three fields, keeping the per-field ranges so the caller
|
||||||
|
// can highlight each matched word. Returns {score, title, group, source} (ranges or null per field), or
|
||||||
|
// null when no field contains the token. Score mirrors scoreFields' tiers: contiguous > fuzzy, then
|
||||||
|
// title > group > source.
|
||||||
|
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);
|
||||||
|
if (!t && !g && !s) 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
|
||||||
|
);
|
||||||
|
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 the per-field match scores into one comparable value, or null when no field matched.
|
// Combine the per-field match scores into one comparable value, or null when no field matched.
|
||||||
@@ -151,12 +210,21 @@ function scoreFields(t, g, s) {
|
|||||||
// by relevance (not by action type). Sets matchIndex so rows highlight their match ranges. The query
|
// 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
|
// 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).
|
// 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).
|
||||||
|
// A phrase match always outranks a distributed token match.
|
||||||
function searchActions(actions, query) {
|
function searchActions(actions, query) {
|
||||||
var q = (query || "").trim();
|
var q = (query || "").trim();
|
||||||
var list = actions || [];
|
var list = actions || [];
|
||||||
matchIndex = {};
|
matchIndex = {};
|
||||||
if (!q) { searchNeedle = ""; return list.slice(0); }
|
if (!q) { searchNeedle = ""; searchTokens = []; searchTokenRes = []; return list.slice(0); }
|
||||||
searchNeedle = NormText(q, false);
|
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
|
// 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.
|
// search still runs on the full query, and every setting requiring a named mode is appended.
|
||||||
var modes = modeFilterFromQuery(q);
|
var modes = modeFilterFromQuery(q);
|
||||||
@@ -168,17 +236,36 @@ function searchActions(actions, query) {
|
|||||||
var seen = {};
|
var seen = {};
|
||||||
for (var i = 0; i < list.length; i++) {
|
for (var i = 0; i < list.length; i++) {
|
||||||
var a = list[i];
|
var a = list[i];
|
||||||
var t = fieldMatchScore(titleNorm(a), wwRe);
|
var t = fieldMatchScore(titleNorm(a), searchNeedle, wwRe);
|
||||||
var g = fieldMatchScore(groupNorm(a), wwRe);
|
var g = fieldMatchScore(groupNorm(a), searchNeedle, wwRe);
|
||||||
var s = fieldMatchScore(sourceNorm(a), wwRe);
|
var s = fieldMatchScore(sourceNorm(a), searchNeedle, wwRe);
|
||||||
var score = scoreFields(t, g, s);
|
var phrase = scoreFields(t, g, s);
|
||||||
if (score === null) continue;
|
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
|
// 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.
|
// source when group is empty) for the eyebrow - so highlight offsets stay aligned to the label.
|
||||||
matchIndex[a.id] = {
|
matchIndex[a.id] = {
|
||||||
title: t ? t.ranges : null,
|
title: ranges.title,
|
||||||
group: g ? g.ranges : null,
|
group: ranges.group,
|
||||||
source: s ? s.ranges : null,
|
source: ranges.source,
|
||||||
useEyebrowGroup: !!(a.group)
|
useEyebrowGroup: !!(a.group)
|
||||||
};
|
};
|
||||||
scored.push({ a: a, s: score });
|
scored.push({ a: a, s: score });
|
||||||
@@ -206,6 +293,38 @@ function searchActions(actions, query) {
|
|||||||
return result;
|
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];
|
||||||
|
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.
|
// 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.
|
// Clamped to the total; used to decide "render the next window" on scroll / arrow-nav.
|
||||||
function revealTarget(total, fromIndex, size) {
|
function revealTarget(total, fromIndex, size) {
|
||||||
@@ -361,12 +480,15 @@ function commandList(actions, recents, query) {
|
|||||||
var all = actions || [];
|
var all = actions || [];
|
||||||
if (shouldRenderActionList(query)) {
|
if (shouldRenderActionList(query)) {
|
||||||
if (searchCache && searchCache.actions === all && searchCache.query === query) {
|
if (searchCache && searchCache.actions === all && searchCache.query === query) {
|
||||||
matchIndex = searchCache.matchIndex;
|
matchIndex = searchCache.matchIndex;
|
||||||
searchNeedle = searchCache.needle;
|
searchNeedle = searchCache.needle;
|
||||||
|
searchTokens = searchCache.tokens;
|
||||||
|
searchTokenRes = searchCache.tokenRes;
|
||||||
return searchCache.list;
|
return searchCache.list;
|
||||||
}
|
}
|
||||||
var found = searchActions(all, query);
|
var found = searchActions(all, query);
|
||||||
searchCache = { actions: all, query: query, list: found, matchIndex: matchIndex, needle: searchNeedle };
|
searchCache = { actions: all, query: query, list: found, matchIndex: matchIndex, needle: searchNeedle,
|
||||||
|
tokens: searchTokens, tokenRes: searchTokenRes };
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
var rec = recents || [];
|
var rec = recents || [];
|
||||||
@@ -1049,10 +1171,28 @@ function renderDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
function render(opts) {
|
||||||
renderFav();
|
renderFav();
|
||||||
renderList();
|
renderList();
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
updateGhost();
|
||||||
// Pin toggles don't move the selection, so they pass keepScroll to avoid snapping the list
|
// 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.
|
// back to a row that is currently off-screen.
|
||||||
if (!(opts && opts.keepScroll))
|
if (!(opts && opts.keepScroll))
|
||||||
@@ -1203,6 +1343,7 @@ function focusInput() { setTimeout(function () { if (qEl) qEl.focus(); }, 0); }
|
|||||||
// ---- init --------------------------------------------------------------------
|
// ---- init --------------------------------------------------------------------
|
||||||
function OnInit() {
|
function OnInit() {
|
||||||
qEl = $("q"); listEl = $("list"); favEl = $("favBar"); clearEl = $("clear"); eyeEl = $("favEyebrow"); countEl = $("count"); detailEl = $("detail");
|
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
|
// 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.
|
// `$`, so don't call it. Runtime strings go through T() instead.
|
||||||
qEl.placeholder = T("sd_search", "Search actions");
|
qEl.placeholder = T("sd_search", "Search actions");
|
||||||
@@ -1222,6 +1363,9 @@ function OnInit() {
|
|||||||
query = qEl.value; sel = { zone: "list", i: 0 }; syncClearButton();
|
query = qEl.value; sel = { zone: "list", i: 0 }; syncClearButton();
|
||||||
render({ resize: true, resetScroll: true });
|
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
|
// 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).
|
// DOM stays bounded to what's near the viewport. Guarded to the commands phase (tabs/percent are tiny).
|
||||||
listEl.addEventListener("scroll", function () {
|
listEl.addEventListener("scroll", function () {
|
||||||
@@ -1250,6 +1394,19 @@ function OnInit() {
|
|||||||
else flashHint(T("sd_no_wiki", "No wiki page for this action"));
|
else flashHint(T("sd_no_wiki", "No wiki page for this action"));
|
||||||
return;
|
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).
|
// 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 &&
|
if (phase === "commands" && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey &&
|
||||||
e.key.toLowerCase() === "b") {
|
e.key.toLowerCase() === "b") {
|
||||||
|
|||||||
@@ -117,6 +117,55 @@ const negativePool = [
|
|||||||
assert.equal(ctx.searchActions(negativePool, "ornt").length, 1,
|
assert.equal(ctx.searchActions(negativePool, "ornt").length, 1,
|
||||||
"a low-score fuzzy match is not mistaken for no match");
|
"a low-score fuzzy match is not mistaken for no match");
|
||||||
|
|
||||||
|
// Multi-token cross-field search: each whitespace-separated word must match SOME searchable field,
|
||||||
|
// but different words may match different fields. "inner" is the title while "speed" and
|
||||||
|
// "acceleration" live in the source breadcrumb, so the query as a whole is never contiguous in one
|
||||||
|
// field - the old single-needle match found nothing for this.
|
||||||
|
const crossPool = [
|
||||||
|
{ id: "acc", title: "Inner wall", source: "Process : Speed : Acceleration", group: "", input: "" },
|
||||||
|
{ id: "spd", title: "Inner wall", source: "Process : Speed : Other layers speed", group: "", input: "" },
|
||||||
|
{ id: "other", title: "Outer wall", source: "Process : Quality : Walls", group: "", input: "" }
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
ctx.searchActions(crossPool, "speed acceleration inner").map(function (a) { return a.id; }),
|
||||||
|
["acc"],
|
||||||
|
"words may match different fields and every word is required"
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
ctx.searchActions(crossPool, "speed inner").map(function (a) { return a.id; }).sort(),
|
||||||
|
["acc", "spd"],
|
||||||
|
"a two-word title+source query matches every setting under Speed"
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
ctx.searchActions(crossPool, "quality inner").map(function (a) { return a.id; }),
|
||||||
|
[],
|
||||||
|
"an action is dropped when any one word matches no field"
|
||||||
|
);
|
||||||
|
// Highlighting merges the per-field ranges the tokens produced.
|
||||||
|
ctx.searchActions(crossPool, "speed acceleration inner");
|
||||||
|
assert.deepEqual(ctx.matchIndex.acc.title, [[0, 5]], "the title token highlights in the title");
|
||||||
|
assert.deepEqual(ctx.matchIndex.acc.source, [[10, 15], [18, 30]], "each path token highlights in the breadcrumb");
|
||||||
|
|
||||||
|
assert.deepEqual(ctx.queryTokens(" Speed Acceleration "), ["speed", "acceleration"],
|
||||||
|
"a query splits into normalized whitespace-separated tokens");
|
||||||
|
assert.deepEqual(ctx.queryTokens(""), [], "an empty query has no tokens");
|
||||||
|
|
||||||
|
// Inline completion: the LAST token is completed to a word in the top-ranked result, name first then
|
||||||
|
// breadcrumb. Pure - the caller appends `suffix`.
|
||||||
|
const compPool = [
|
||||||
|
{ id: "acc", title: "Inner wall", source: "Process : Speed : Acceleration", group: "", input: "" },
|
||||||
|
{ id: "smooth", title: "Smooth", source: "Process : Speed : Other layers speed", group: "", input: "" }
|
||||||
|
];
|
||||||
|
assert.equal(ctx.completionFor("speed acc", ctx.searchActions(compPool, "speed acc")).suffix, "eleration",
|
||||||
|
"the last token completes to the next word in the breadcrumb");
|
||||||
|
assert.equal(ctx.completionFor("inner w", ctx.searchActions(compPool, "inner w")).suffix, "all",
|
||||||
|
"the title is preferred over the breadcrumb for completion");
|
||||||
|
assert.equal(ctx.completionFor("inner wall", ctx.searchActions(compPool, "inner wall")), null,
|
||||||
|
"an already-complete word has nothing to add");
|
||||||
|
assert.equal(ctx.completionFor("", compPool), null, "an empty query has no completion");
|
||||||
|
assert.equal(ctx.completionFor("zzz", ctx.searchActions(compPool, "zzz")), null,
|
||||||
|
"a query with no match has no completion");
|
||||||
|
|
||||||
// actionCategory: a command/dynamic action's group is its category; a setting uses the top-level
|
// actionCategory: a command/dynamic action's group is its category; a setting uses the top-level
|
||||||
// source segment; every plugin shares one header; a category-less action falls back to "Other".
|
// source segment; every plugin shares one header; a category-less action falls back to "Other".
|
||||||
assert.equal(ctx.actionCategory({ id: "c", group: "Help", source: "OrcaSlicer", kind: "command" }), "Help",
|
assert.equal(ctx.actionCategory({ id: "c", group: "Help", source: "OrcaSlicer", kind: "command" }), "Help",
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ body {
|
|||||||
.plugin-search-input {
|
.plugin-search-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
@@ -232,6 +233,40 @@ body {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wraps the input so the ghost completion can sit exactly at the caret. The ghost mirrors the typed
|
||||||
|
text in an invisible run (reserving its width) followed by the muted suggested suffix. */
|
||||||
|
.plugin-search-field {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-ghost {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: pre;
|
||||||
|
font: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-ghost[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-ghost-typed {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-ghost-suffix {
|
||||||
|
color: var(--muted, var(--orca-muted, #6b7280));
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
|
|
||||||
.plugin-search-clear {
|
.plugin-search-clear {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user