Update translations. Code dedup and cleanup

This commit is contained in:
Lam Wei Lun
2026-09-11 13:05:39 +08:00
parent 42bee12481
commit 7c2991d00c
47 changed files with 2559 additions and 1180 deletions
+30
View File
@@ -118,6 +118,36 @@ var LangText = {
orca10: "Not connected",
orca11: "Connected",
orca12: "Note: When Stealth Mode is enabled, your user profiles will not be backed up to Orca Cloud.",
sd_search: "Search actions",
sd_clear: "Clear",
sd_search_n: "Search %s actions",
sd_showing: "Showing",
sd_of: "of",
sd_actions: "actions",
sd_recent: "recent",
sd_matches: "matches",
sd_tabs: "tabs",
sd_no_match: "No actions match",
sd_total: "Total",
sd_no_actions: "No actions yet",
sd_no_tabs_match: "No tabs match",
sd_no_tabs: "No tabs",
sd_go_to_pct: "Go to %s% of the layer range",
sd_enter_pct: "Enter a layer percentage (0-100)",
sd_go_layer_ph: "Go to layer % (0-100)",
sd_go_tab_ph: "Go to tab",
sd_favs_full: "Favourites are full",
sd_max: "max",
sd_pin_fav: "Pin to favourites (Ctrl+B)",
sd_unpin_fav: "Unpin from favourites (Ctrl+B)",
sd_remove_fav: "Remove from favourites",
sd_favourite: "Favourite",
sd_move_left: "Move left",
sd_move_right: "Move right",
sd_unpin: "Unpin",
sd_mode_advanced: "Advanced",
sd_mode_expert: "Expert",
sd_mode_develop: "Developer",
},
ca_ES: {
t1: "Benvingut a Orca Slicer",
@@ -7,6 +7,7 @@
<link rel="stylesheet" href="./style.css" />
<link rel="stylesheet" type="text/css" href="../css/theme.css" />
<script type="text/javascript" src="../../include/globalapi.js"></script>
<script type="text/javascript" src="../../data/text.js"></script>
<script src="../../js/fuzzy-search.js"></script>
<script src="./speeddial.js"></script>
</head>
+143 -133
View File
@@ -2,9 +2,9 @@
// node vm can exercise the pure helpers (searchActions / filterTabs / actionLabel / nextSel / commandList).
// ---- state (populated by the C++ bridge via window.HandleStudio) ----
var ACTIONS = []; // [{id,title,source,group,input,shortcut}], already frecency-sorted by C++
var ACTIONS = []; // [{id,title,source,group,input,icon,mode}], already frecency-sorted by C++
var FAVS = []; // [id...]
var RECENTS = []; // [{id,title,source,group,input,shortcut}] - last-N launched
var RECENTS = []; // [{id,title,source,group,input,icon,mode}] - last-N launched
var query = "";
var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav'
var lastResizeHeight = 0;
@@ -15,6 +15,26 @@ var matchIndex = {};
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;
// Localized lookup for strings this page builds at runtime. text.js (loaded before this script)
// defines LangText; a missing entry falls back to the English literal. Extra args replace
// successive %s placeholders.
function T(key, fallback) {
var table = (typeof LangText !== "undefined" && LangText) || null;
var lang = "en";
try { lang = localStorage.getItem(LANG_COOKIE_NAME) || "en"; } catch (e) {}
var s = table && table[lang] && table[lang][key] !== undefined ? table[lang][key] :
table && table.en && table.en[key] !== undefined ? table.en[key] : fallback;
for (var i = 2; i < arguments.length; i++)
s = s.replace("%s", arguments[i]);
return s;
}
// ---- 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,
@@ -36,11 +56,11 @@ var searchNeedle = "";
var phase = "commands";
var tabOptions = []; // [{id,title}] - notebook pages, fetched on entering the tab phase
// why: the fuzzy matcher (NormText/FuzzyRangesNorm/WholeWordRangesNorm) lives in shared
// 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, headEl = null;
var qEl = null, listEl = null, favEl = null, clearEl = null, eyeEl = null, countEl = null;
// ---- pure helpers (no DOM; unit-tested) -------------------------------------
// Pre-normalized haystacks, cached on the action object. The fold is length-preserving (1:1 per
@@ -89,14 +109,20 @@ function fieldMatchScore(norm, wwRe) {
return { score: 1000 - r[0][0] * 10 - gaps * 10, ranges: r, contiguous: r.length === 1 && len === searchNeedle.length };
}
// Combine the per-field match scores into one comparable value. Ranking tiers, strongest first:
// Combine the per-field match scores into one comparable value, or null when no field matched.
// Ranking tiers, strongest first:
// tier (contiguous/perfect vs fuzzy) > field (title > group > source) > start/gaps.
// The additive weights keep every contiguous match above every fuzzy one regardless of field.
function scoreFields(t, g, s) {
var best = -1;
if (t) best = Math.max(best, (t.contiguous ? 100000 : 0) + 2000 + t.score);
if (g) best = Math.max(best, (g.contiguous ? 100000 : 0) + 1000 + g.score);
if (s) best = Math.max(best, (s.contiguous ? 100000 : 0) + s.score);
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);
return best;
}
@@ -125,7 +151,7 @@ function searchActions(actions, query) {
var g = fieldMatchScore(groupNorm(a), wwRe);
var s = fieldMatchScore(sourceNorm(a), wwRe);
var score = scoreFields(t, g, s);
if (score < 0) continue;
if (score === null) continue;
// 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] = {
@@ -198,7 +224,8 @@ function favIndexForDigit(d) {
}
function resultCountText(total, shown, query) {
return (query || "").trim() ? "Showing " + shown + " of " + total + " actions" : total + " actions";
var n = total + " " + T("sd_actions", "actions");
return (query || "").trim() ? T("sd_showing", "Showing") + " " + shown + " " + T("sd_of", "of") + " " + n : n;
}
// Display label for a notebook tab. Trim any stray whitespace; pages added with an empty title
@@ -259,7 +286,7 @@ function commandList(actions, recents, query) {
// 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, query) {
function selectedActionId(sel, actions, favIds) {
if (sel.zone === "fav")
return favIds[sel.i];
if (!actions || !actions.length)
@@ -286,9 +313,9 @@ function needsModeSwitch(item, userMode) {
// 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 "Developer";
if (item.mode === "expert") return "Expert";
if (item.mode === "advanced") return "Advanced";
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 "";
}
@@ -334,12 +361,7 @@ function stateFromPayload(payload) {
actions: payload.actions || [],
favourites: payload.favourites || [],
recent: payload.recent || [],
userMode: payload.user_mode || "simple",
query: "",
sel: { zone: "list", i: 0 },
lastResizeHeight: 0,
phase: "commands",
tabOptions: []
userMode: payload.user_mode || "simple"
};
}
@@ -395,19 +417,19 @@ window.HandleStudio = function (payload) {
FAVS = next.favourites;
RECENTS = next.recent;
USER_MODE = next.userMode;
query = next.query;
sel = next.sel;
lastResizeHeight = next.lastResizeHeight;
phase = next.phase;
tabOptions = next.tabOptions;
// 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 };
lastResizeHeight = 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 (headEl) headEl.hidden = false;
if (qEl) {
qEl.value = "";
qEl.placeholder = "Search " + ACTIONS.length + " actions";
qEl.placeholder = T("sd_search_n", "Search %s actions", ACTIONS.length);
syncClearButton();
}
render({ resize: true, resetScroll: true });
@@ -422,7 +444,7 @@ window.HandleStudio = function (payload) {
var fid = payload.id;
if (fid && FAVS.indexOf(fid) !== -1) FAVS.splice(FAVS.indexOf(fid), 1);
render({ resize: true, keepScroll: true });
flashHint("Favourites are full (" + (payload.limit || K_FAV_LIMIT) + " max)");
flashHint(T("sd_favs_full", "Favourites are full") + " (" + (payload.limit || K_FAV_LIMIT) + " " + T("sd_max", "max") + ")");
}
};
@@ -485,7 +507,7 @@ function pinSvg(on) {
function setPinState(pin, on) {
pin.classList.toggle("on", on);
pin.innerHTML = pinSvg(on);
pin.title = on ? "Unpin from favourites (Ctrl+B)" : "Pin to favourites (Ctrl+B)";
pin.title = on ? T("sd_unpin_fav", "Unpin from favourites (Ctrl+B)") : T("sd_pin_fav", "Pin to favourites (Ctrl+B)");
}
// ---- render ------------------------------------------------------------------
@@ -519,15 +541,16 @@ function renderFav() {
var badge = document.createElement("span");
badge.className = "fav-slot";
badge.textContent = slot;
badge.title = slot === "0" ? "Favourite 10 (Alt+0)" : "Favourite " + slot + " (Alt+" + slot + ")";
badge.title = slot === "0" ? T("sd_favourite", "Favourite") + " 10 (Alt+0)" :
T("sd_favourite", "Favourite") + " " + slot + " (Alt+" + 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 = "Remove from favourites";
unpin.setAttribute("aria-label", "Remove from favourites");
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);
@@ -567,9 +590,9 @@ function showFavMenu(x, y, id) {
favMenuEl.innerHTML = "";
var favs = currentVisibleFavs();
var vi = favs.indexOf(id);
addFavMenuItem("Move left", vi > 0, function () { moveFav(id, -1); });
addFavMenuItem("Move right", vi >= 0 && vi < favs.length - 1, function () { moveFav(id, 1); });
addFavMenuItem("Unpin", true, function () { toggleFav(id); });
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";
@@ -598,52 +621,47 @@ function updateFavEyebrow(favs) {
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 row = document.createElement("div");
row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : "");
row.setAttribute("aria-label", actionLabel(a, ACTIONS));
var tile = document.createElement("div");
tile.className = "tile";
fillTile(tile, a);
var left = document.createElement("div");
left.className = "row-left";
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;
var sourceEl = markedText("row-eyebrow", eyebrow, eyebrowMatch);
var line = document.createElement("div");
line.className = "row-line";
var name = markedText("row-name", a.title, mi ? mi.title : null);
line.appendChild(name);
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;
line.appendChild(tag);
shell.line.appendChild(tag);
}
if (a.shortcut) {
var sc = document.createElement("div");
sc.className = "row-sc";
a.shortcut.split("+").forEach(function (k) {
var key = document.createElement("kbd");
key.textContent = k;
sc.appendChild(key);
});
line.appendChild(sc);
}
left.appendChild(sourceEl);
left.appendChild(line);
row.appendChild(tile);
row.appendChild(left);
var pin = document.createElement("button");
pin.className = "pin";
@@ -651,11 +669,11 @@ function renderActionRow(a, i) {
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(); };
row.appendChild(pin);
shell.row.appendChild(pin);
row.onclick = function () { sel = { zone: "list", i: i }; render({ resize: true }); };
row.ondblclick = function () { sel = { zone: "list", i: i }; activateEntry(a); };
return row;
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.
@@ -731,6 +749,18 @@ function updatePins(list) {
}
}
// 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";
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;
@@ -742,14 +772,8 @@ function renderCommandsList() {
matchIndex = {};
if (!total) {
listEl.innerHTML = "";
spacerEl = null;
listEl.className = "dial-list empty";
if (countEl) countEl.hidden = true;
var empty = document.createElement("div");
empty.className = "dial-empty";
empty.textContent = showList ? ("No actions match (Total: " + ACTIONS.length + ")") : "No actions yet";
listEl.appendChild(empty);
renderEmpty(showList ? (T("sd_no_match", "No actions match") + " (" + T("sd_total", "Total") + ": " + ACTIONS.length + ")")
: T("sd_no_actions", "No actions yet"));
renderEnd = 0;
builtKey = buildKey() + "|0";
return;
@@ -767,7 +791,7 @@ function renderCommandsList() {
listEl.className = "dial-list";
if (countEl) {
countEl.hidden = false;
countEl.textContent = showList ? resultCountText(ACTIONS.length, total, query) : total + " recent";
countEl.textContent = showList ? resultCountText(ACTIONS.length, total, query) : total + " " + T("sd_recent", "recent");
}
updateSelection();
updatePins(list);
@@ -777,29 +801,14 @@ function renderCommandsList() {
// tabTitle so pages added with an empty text (e.g. Home) still show a label.
function renderTabRow(t, i) {
var label = tabTitle(t);
var row = document.createElement("div");
row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : "");
row.setAttribute("aria-label", label);
var tile = document.createElement("div");
tile.className = "tile";
fillTile(tile, t, true);
var left = document.createElement("div");
left.className = "row-left";
var line = document.createElement("div");
line.className = "row-line";
var shell = beginRow(t, i, true, label);
var name = document.createElement("div");
name.className = "row-name";
name.textContent = label;
line.appendChild(name);
left.appendChild(line);
row.appendChild(tile);
row.appendChild(left);
row.onclick = function () { sel = { zone: "list", i: i }; render({ resize: true }); };
row.ondblclick = function () { sel = { zone: "list", i: i }; jumpToTab(t); };
return row;
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() {
@@ -808,12 +817,7 @@ function renderTabList() {
listEl.innerHTML = "";
if (!list.length) {
listEl.className = "dial-list empty";
if (countEl) countEl.hidden = true;
var empty = document.createElement("div");
empty.className = "dial-empty";
empty.textContent = q ? "No tabs match" : "No tabs";
listEl.appendChild(empty);
renderEmpty(q ? T("sd_no_tabs_match", "No tabs match") : T("sd_no_tabs", "No tabs"));
return;
}
if (sel.zone === "list")
@@ -821,20 +825,14 @@ function renderTabList() {
listEl.className = "dial-list";
if (countEl) {
countEl.hidden = false;
countEl.textContent = q ? list.length + " matches" : list.length + " tabs";
countEl.textContent = q ? list.length + " " + T("sd_matches", "matches") : list.length + " " + T("sd_tabs", "tabs");
}
list.forEach(function (t, i) { listEl.appendChild(renderTabRow(t, i)); });
}
function renderPercentList() {
var q = (query || "").trim();
listEl.innerHTML = "";
listEl.className = "dial-list empty";
if (countEl) countEl.hidden = true;
var ph = document.createElement("div");
ph.className = "dial-empty";
ph.textContent = q ? ("Go to " + q + "% of the layer range") : "Enter a layer percentage (0-100)";
listEl.appendChild(ph);
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() {
@@ -936,7 +934,7 @@ function runSelected() {
return;
}
var list = currentList();
var id = selectedActionId(sel, list, currentVisibleFavs(), query);
var id = selectedActionId(sel, list, currentVisibleFavs());
if (id) activateEntry(byId(id));
}
@@ -950,38 +948,43 @@ function runJumpToLayer(pct) {
}
function jumpToTab(t) {
SendMessage({ command: "go_to_tab", id: t.id, title: tabTitle(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"; query = ""; qEl.value = "";
sel = { zone: "list", i: 0 };
qEl.placeholder = "Go to layer % (0-100)";
syncClearButton();
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 = []; query = ""; qEl.value = "";
sel = { zone: "list", i: 0 };
qEl.placeholder = "Go to tab";
syncClearButton();
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 = []; query = ""; qEl.value = "";
if (headEl) headEl.hidden = false;
sel = { zone: "list", i: 0 };
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 = "Search " + ACTIONS.length + " actions";
syncClearButton();
qEl.placeholder = T("sd_search_n", "Search %s actions", ACTIONS.length);
render({ resize: true, resetScroll: true });
qEl.focus();
}
@@ -991,13 +994,20 @@ function focusInput() { setTimeout(function () { if (qEl) qEl.focus(); }, 0); }
// ---- init --------------------------------------------------------------------
function OnInit() {
qEl = $("q"); listEl = $("list"); favEl = $("favBar"); clearEl = $("clear"); eyeEl = $("favEyebrow"); countEl = $("count");
headEl = document.querySelector(".dial-head");
// 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 () {
query = ""; qEl.value = ""; sel = { zone: "list", i: 0 };
render({ resize: true, resetScroll: true }); qEl.focus();
syncClearButton();
resetPhaseInput();
render({ resize: true, resetScroll: true });
qEl.focus();
};
qEl.addEventListener("input", function () {
query = qEl.value; sel = { zone: "list", i: 0 }; syncClearButton();
@@ -1026,7 +1036,7 @@ function OnInit() {
if (phase === "commands" && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey &&
e.key.toLowerCase() === "b") {
e.preventDefault();
var id = selectedActionId(sel, currentList(), currentVisibleFavs(), query);
var id = selectedActionId(sel, currentList(), currentVisibleFavs());
if (id) toggleFav(id);
return;
}
@@ -109,6 +109,14 @@ ctx.searchActions(orientPool, "orient");
assert.deepEqual(ctx.matchIndex.ao.title, [[5, 11]],
"a whole-word match highlights the full word, not a scattered fuzzy pick");
// A fuzzy source-only match with a very late start still counts, even though its score is negative;
// the old `score < 0` sentinel mistook it for "no field matched" and dropped the action.
const negativePool = [
{ id: "n", title: "Unrelated", source: "o" + "x".repeat(200) + "rnt", group: "", input: "" }
];
assert.equal(ctx.searchActions(negativePool, "ornt").length, 1,
"a low-score fuzzy match is not mistaken for no match");
// commandList (the main-phase list) delegates to the ranked search for a typed query and returns
// the mixed recents (no discrimination) for an empty query.
const mixed = [
@@ -122,22 +130,22 @@ assert.deepEqual(ctx.commandList(mixed, mixed.slice(0, 1), "").map(function (a)
// selectedActionId: resolves the active list (recents for an empty query, filtered list otherwise).
assert.equal(
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], ""), [], ""),
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], ""), []),
null,
"Enter with an empty query and no recents must not resolve to an action the list never showed"
);
assert.equal(
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], "rep"), [], "rep"),
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], "rep"), []),
"0123456789abcdef",
"a typed query resolves the list selection"
);
assert.equal(
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [duplicateActions[0]], ""), [], ""),
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [duplicateActions[0]], ""), []),
"0123456789abcdef",
"Enter with an empty query resolves the recent entry"
);
assert.equal(
ctx.selectedActionId({ zone: "fav", i: 0 }, duplicateActions, ["fedcba9876543210"], ""),
ctx.selectedActionId({ zone: "fav", i: 0 }, duplicateActions, ["fedcba9876543210"]),
"fedcba9876543210",
"favourites stay runnable with an empty query - the fav bar is always visible"
);
+18 -20
View File
@@ -19,18 +19,31 @@ function Norm(ch, caseSensitive) {
return caseSensitive ? folded : folded.toLowerCase(); // case-sensitivity is the only toggle
}
// Pre-normalize a whole haystack with the SAME per-char fold FuzzyRanges uses, so a caller can match
// it repeatedly against one cached string. The fold is 1:1 in length, so indices stay aligned to the
// ORIGINAL text - the highlight ranges that FuzzyRangesNorm returns slice the original correctly.
// Iterate by UTF-16 code unit (not Array.from code point) to mirror FuzzyRanges' own indexing exactly.
// Pre-normalize a whole haystack with a length-preserving fold, so a caller can match it repeatedly
// against one cached string. One input UTF-16 code unit always maps to one output code unit, so
// indices stay aligned to the ORIGINAL text - the highlight ranges that FuzzyRangesNorm returns slice
// the original correctly. Iterate by UTF-16 code unit (not Array.from code point) to mirror
// FuzzyRanges' own indexing exactly.
function NormText(text, caseSensitive) {
const src = text || "";
let out = "";
for (let i = 0; i < src.length; i++)
out += Norm(src[i], caseSensitive);
out += NormStable(src[i], caseSensitive);
return out;
}
// Length-preserving variant of Norm: NFD can expand a code unit (Hangul syllables become Jamo) or
// drop it (combining diacritics), and toLowerCase can expand one too (U+0130). Any of those would
// desync highlight offsets, so fall back to the original unit whenever the fold is not 1:1.
function NormStable(ch, caseSensitive) {
const folded = FoldChar(ch);
const stable = folded.length === 1 ? folded : ch;
if (caseSensitive)
return stable;
const lower = stable.toLowerCase();
return lower.length === 1 ? lower : stable;
}
// Match a PRE-normalized haystack against a PRE-normalized needle (both produced by NormText with the
// same caseSensitive flag). Skipping the per-character fold makes repeated matching (per keystroke over a
// cached pool) cheap. Returns ranges in original coordinates, or null on no match.
@@ -115,18 +128,3 @@ function WholeWordRanges(text, query, caseSensitive) {
ranges.push([match.index, match.index + match[0].length]);
return ranges.length > 0 ? ranges : null;
}
// Same whole-word (\b-bounded) match as WholeWordRanges, but against PRE-normalized haystack/needle
// (NormText output, so offsets stay length-aligned to the original text). Returns the first match as
// [[i, i+len]] in original coordinates, or null. Non-global so the caller can reuse one compiled regex
// across many fields without re-setting lastIndex. Skipping the per-char fold keeps the Speed Dial's
// per-keystroke scan over thousands of cached settings cheap.
function WholeWordRangesNorm(haystackNorm, needleNorm) {
const t = haystackNorm || "";
const needle = needleNorm || "";
if (!needle)
return null;
const re = new RegExp(`\\b${EscapeRegExp(needle)}\\b`);
const match = re.exec(t);
return match ? [[match.index, match.index + match[0].length]] : null;
}
+7 -4
View File
@@ -5,7 +5,7 @@ const vm = require("vm"), assert = require("assert"), fs = require("fs");
const ctx = {};
vm.createContext(ctx);
vm.runInContext(fs.readFileSync(__dirname + "/fuzzy-search.js", "utf8"), ctx);
const { FoldChar, Norm, NormText, EscapeRegExp, FuzzyRanges, WholeWordRanges, FuzzyRangesNorm, WholeWordRangesNorm } = ctx;
const { FoldChar, Norm, NormText, EscapeRegExp, FuzzyRanges, WholeWordRanges, FuzzyRangesNorm } = ctx;
// FoldChar / Norm: accents fold, case only folds when case-insensitive.
assert.equal(FoldChar("é"), "e");
@@ -41,8 +41,11 @@ assert.deepEqual(FuzzyRangesNorm(NormText("Auto-Orient", false), NormText("orien
assert.deepEqual(FuzzyRangesNorm(NormText("AutoOriented", false), NormText("orient", false)), [[4, 10]]);
assert.equal(FuzzyRangesNorm(NormText("Measure", false), NormText("xyz", false)), null); // no subsequence
// WholeWordRangesNorm: \b-bounded literal against a pre-normalized haystack, offsets in original coords.
assert.deepEqual(WholeWordRangesNorm(NormText("Auto-Orient", false), NormText("orient", false)), [[5, 11]]);
assert.equal(WholeWordRangesNorm(NormText("AutoOriented", false), NormText("orient", false)), null); // inside a word
// NormText stays 1:1 with the original even when NFD expands (Hangul syllables) or drops (combining
// diacritics, U+0130) a code unit, so highlight ranges remain aligned to the original string.
assert.equal(NormText("각x", false).length, "각x".length);
assert.equal(NormText("e\u0301x", false).length, "e\u0301x".length);
assert.equal(NormText("\u0130x", false).length, "\u0130x".length);
assert.equal(FuzzyRangesNorm(NormText("각abcdeXfgh", false), NormText("X", false))[0][0], 6);
console.log("ok");