mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-18 22:42:37 +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">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -30,6 +30,9 @@ var MODE_RANK = { simple: 0, advanced: 1, expert: 2, develop: 3 };
|
||||
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
|
||||
@@ -68,8 +71,11 @@ var sectionStarts = null;
|
||||
var sectionTotal = 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 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..."
|
||||
@@ -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)
|
||||
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
|
||||
@@ -106,20 +115,20 @@ function sourceNorm(a) {
|
||||
return a._sn;
|
||||
}
|
||||
|
||||
// Match one pre-normalized field vs the current needle. Returns {score, ranges, contiguous} when the
|
||||
// needle is present, else null. wwRe is a compiled whole-word (\b-bounded) regex for the current 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 now prefers the
|
||||
// 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, wwRe) {
|
||||
if (!searchNeedle) return null;
|
||||
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 || "", searchNeedle);
|
||||
var r = FuzzyRangesNorm(norm || "", needle);
|
||||
if (!r) return null;
|
||||
var gaps = 0, len = 0;
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
@@ -127,7 +136,57 @@ function fieldMatchScore(norm, wwRe) {
|
||||
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 === 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.
|
||||
@@ -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
|
||||
// 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).
|
||||
// A phrase match always outranks a distributed token match.
|
||||
function searchActions(actions, query) {
|
||||
var q = (query || "").trim();
|
||||
var list = actions || [];
|
||||
matchIndex = {};
|
||||
if (!q) { searchNeedle = ""; return list.slice(0); }
|
||||
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);
|
||||
@@ -168,17 +236,36 @@ function searchActions(actions, query) {
|
||||
var seen = {};
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var a = list[i];
|
||||
var t = fieldMatchScore(titleNorm(a), wwRe);
|
||||
var g = fieldMatchScore(groupNorm(a), wwRe);
|
||||
var s = fieldMatchScore(sourceNorm(a), wwRe);
|
||||
var score = scoreFields(t, g, s);
|
||||
if (score === null) continue;
|
||||
var t = fieldMatchScore(titleNorm(a), searchNeedle, wwRe);
|
||||
var g = fieldMatchScore(groupNorm(a), searchNeedle, wwRe);
|
||||
var s = fieldMatchScore(sourceNorm(a), searchNeedle, wwRe);
|
||||
var phrase = scoreFields(t, g, s);
|
||||
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: t ? t.ranges : null,
|
||||
group: g ? g.ranges : null,
|
||||
source: s ? s.ranges : null,
|
||||
title: ranges.title,
|
||||
group: ranges.group,
|
||||
source: ranges.source,
|
||||
useEyebrowGroup: !!(a.group)
|
||||
};
|
||||
scored.push({ a: a, s: score });
|
||||
@@ -206,6 +293,38 @@ function searchActions(actions, query) {
|
||||
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.
|
||||
// Clamped to the total; used to decide "render the next window" on scroll / arrow-nav.
|
||||
function revealTarget(total, fromIndex, size) {
|
||||
@@ -361,12 +480,15 @@ 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;
|
||||
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 };
|
||||
searchCache = { actions: all, query: query, list: found, matchIndex: matchIndex, needle: searchNeedle,
|
||||
tokens: searchTokens, tokenRes: searchTokenRes };
|
||||
return found;
|
||||
}
|
||||
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) {
|
||||
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))
|
||||
@@ -1203,6 +1343,7 @@ 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");
|
||||
@@ -1222,6 +1363,9 @@ function OnInit() {
|
||||
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 () {
|
||||
@@ -1250,6 +1394,19 @@ function OnInit() {
|
||||
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") {
|
||||
|
||||
@@ -117,6 +117,55 @@ const negativePool = [
|
||||
assert.equal(ctx.searchActions(negativePool, "ornt").length, 1,
|
||||
"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
|
||||
// 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",
|
||||
|
||||
@@ -225,6 +225,7 @@ body {
|
||||
.plugin-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
@@ -232,6 +233,40 @@ body {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user