mirror of
https://github.com/avinal/sciezka.git
synced 2026-07-03 23:30:09 +05:30
feat: fuzzy search engine and Spotlight-style UI
Custom fzy algorithm with fulltext and prefix modes, boxy dark-aware UI with keyboard navigation, mode switching, match highlighting, and auto-resizing iframe overlay. Assisted-by: Claude Code Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 225 B |
+339
@@ -0,0 +1,339 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color tokens */
|
||||||
|
:root {
|
||||||
|
--bg: #fff;
|
||||||
|
--bg-alt: #fafafa;
|
||||||
|
--border: #e8e8e8;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--text-secondary: #999;
|
||||||
|
--text-muted: #aaa;
|
||||||
|
--text-dim: #777;
|
||||||
|
--icon: #999;
|
||||||
|
--badge-bg: #f0f0f0;
|
||||||
|
--kbd-bg: #e8e8e8;
|
||||||
|
--kbd-text: #666;
|
||||||
|
--hover: #f7f7f7;
|
||||||
|
--selected: #eff6ff;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--mark-bg: #fde68a;
|
||||||
|
--mark-text: inherit;
|
||||||
|
--scrollbar: #ddd;
|
||||||
|
--scrollbar-hover: #ccc;
|
||||||
|
--badge-tabs-bg: #dbeafe;
|
||||||
|
--badge-tabs-text: #2563eb;
|
||||||
|
--badge-history-bg: #fef3c7;
|
||||||
|
--badge-history-text: #d97706;
|
||||||
|
--badge-bookmarks-bg: #dcfce7;
|
||||||
|
--badge-bookmarks-text: #16a34a;
|
||||||
|
--badge-closed-bg: #f3e8ff;
|
||||||
|
--badge-closed-text: #9333ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--bg: #1e1e1e;
|
||||||
|
--bg-alt: #252525;
|
||||||
|
--border: #333;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-secondary: #666;
|
||||||
|
--text-muted: #555;
|
||||||
|
--text-dim: #888;
|
||||||
|
--icon: #666;
|
||||||
|
--badge-bg: #333;
|
||||||
|
--kbd-bg: #3a3a3a;
|
||||||
|
--kbd-text: #888;
|
||||||
|
--hover: #272727;
|
||||||
|
--selected: #1e3a5f;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--mark-bg: #854d0e;
|
||||||
|
--mark-text: #fef3c7;
|
||||||
|
--scrollbar: #444;
|
||||||
|
--scrollbar-hover: #555;
|
||||||
|
--badge-tabs-bg: #1e3a5f;
|
||||||
|
--badge-tabs-text: #60a5fa;
|
||||||
|
--badge-history-bg: #422006;
|
||||||
|
--badge-history-text: #fbbf24;
|
||||||
|
--badge-bookmarks-bg: #052e16;
|
||||||
|
--badge-bookmarks-text: #4ade80;
|
||||||
|
--badge-closed-bg: #2e1065;
|
||||||
|
--badge-closed-text: #c084fc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: #1e1e1e;
|
||||||
|
--bg-alt: #252525;
|
||||||
|
--border: #333;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-secondary: #666;
|
||||||
|
--text-muted: #555;
|
||||||
|
--text-dim: #888;
|
||||||
|
--icon: #666;
|
||||||
|
--badge-bg: #333;
|
||||||
|
--kbd-bg: #3a3a3a;
|
||||||
|
--kbd-text: #888;
|
||||||
|
--hover: #272727;
|
||||||
|
--selected: #1e3a5f;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--mark-bg: #854d0e;
|
||||||
|
--mark-text: #fef3c7;
|
||||||
|
--scrollbar: #444;
|
||||||
|
--scrollbar-hover: #555;
|
||||||
|
--badge-tabs-bg: #1e3a5f;
|
||||||
|
--badge-tabs-text: #60a5fa;
|
||||||
|
--badge-history-bg: #422006;
|
||||||
|
--badge-history-text: #fbbf24;
|
||||||
|
--badge-bookmarks-bg: #052e16;
|
||||||
|
--badge-bookmarks-text: #4ade80;
|
||||||
|
--badge-closed-bg: #2e1065;
|
||||||
|
--badge-closed-text: #c084fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sciezka-root {
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search bar */
|
||||||
|
#search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 0;
|
||||||
|
font-size: 17px;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#method-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--badge-bg);
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode bar */
|
||||||
|
#mode-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn kbd {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--kbd-bg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn.active kbd {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Results */
|
||||||
|
#results {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 7px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 10px;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: background 0.08s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row.selected {
|
||||||
|
background: var(--selected);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-icon img {
|
||||||
|
border-radius: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-url {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-tabs { background: var(--badge-tabs-bg); color: var(--badge-tabs-text); }
|
||||||
|
.badge-history { background: var(--badge-history-bg); color: var(--badge-history-text); }
|
||||||
|
.badge-bookmarks { background: var(--badge-bookmarks-bg); color: var(--badge-bookmarks-text); }
|
||||||
|
.badge-closed { background: var(--badge-closed-bg); color: var(--badge-closed-text); }
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
background: var(--mark-bg);
|
||||||
|
color: var(--mark-text);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
#footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-alt);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint kbd {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--kbd-bg);
|
||||||
|
color: var(--kbd-text);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
#results::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--scrollbar-hover);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="stylesheet" href="sciezka.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="sciezka-root">
|
||||||
|
<div id="search-bar">
|
||||||
|
<svg id="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
|
||||||
|
</svg>
|
||||||
|
<input id="search-input" type="text" placeholder="Search tabs, history, bookmarks..." autofocus>
|
||||||
|
<span id="method-badge">fuzzy</span>
|
||||||
|
</div>
|
||||||
|
<div id="mode-bar"></div>
|
||||||
|
<div id="results"></div>
|
||||||
|
<div id="footer">
|
||||||
|
<span class="hint"><kbd>↑↓</kbd> navigate</span>
|
||||||
|
<span class="hint"><kbd>Enter</kbd> open</span>
|
||||||
|
<span class="hint"><kbd>Tab</kbd> mode</span>
|
||||||
|
<span class="hint"><kbd>Ctrl+F</kbd> search type</span>
|
||||||
|
<span class="hint"><kbd>Esc</kbd> close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="dist/sciezka.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
import { search } from "./search";
|
||||||
|
import type { SearchItem, SearchMode, SearchMethod, SearchResult, Message } from "./types";
|
||||||
|
|
||||||
|
const MODES: SearchMode[] = ["tabs", "history", "bookmarks", "closed", "all"];
|
||||||
|
const MODE_LABELS: Record<SearchMode, string> = {
|
||||||
|
tabs: "Tabs",
|
||||||
|
history: "History",
|
||||||
|
bookmarks: "Bookmarks",
|
||||||
|
closed: "Closed",
|
||||||
|
all: "All",
|
||||||
|
};
|
||||||
|
const METHODS: SearchMethod[] = ["fuzzy", "fulltext", "prefix"];
|
||||||
|
|
||||||
|
let currentMode: SearchMode = "tabs";
|
||||||
|
let currentMethod: SearchMethod = "fuzzy";
|
||||||
|
let results: SearchResult[] = [];
|
||||||
|
let selectedIndex = 0;
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const input = document.getElementById("search-input") as HTMLInputElement;
|
||||||
|
const resultsContainer = document.getElementById("results") as HTMLDivElement;
|
||||||
|
const modeBar = document.getElementById("mode-bar") as HTMLDivElement;
|
||||||
|
const methodBadge = document.getElementById("method-badge") as HTMLSpanElement;
|
||||||
|
const root = document.getElementById("sciezka-root") as HTMLDivElement;
|
||||||
|
|
||||||
|
function notifyResize(): void {
|
||||||
|
const height = Math.min(root.scrollHeight, 520);
|
||||||
|
window.parent.postMessage({ type: "resize", height }, "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage(msg: Message): Promise<unknown> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
window.parent.postMessage(msg, "*");
|
||||||
|
const handler = (event: MessageEvent) => {
|
||||||
|
if (event.source === window.parent) {
|
||||||
|
window.removeEventListener("message", handler);
|
||||||
|
resolve(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("message", handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModeBar(): void {
|
||||||
|
modeBar.innerHTML = "";
|
||||||
|
for (let i = 0; i < MODES.length; i++) {
|
||||||
|
const mode = MODES[i];
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = `mode-btn${mode === currentMode ? " active" : ""}`;
|
||||||
|
btn.innerHTML = `<span class="mode-label">${MODE_LABELS[mode]}</span><kbd>${i + 1}</kbd>`;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
currentMode = mode;
|
||||||
|
renderModeBar();
|
||||||
|
doSearch();
|
||||||
|
});
|
||||||
|
modeBar.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMethodBadge(): void {
|
||||||
|
methodBadge.textContent = currentMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightText(text: string, positions: number[], offset: number): string {
|
||||||
|
const chars = text.split("");
|
||||||
|
const posSet = new Set(positions.map((p) => p - offset).filter((p) => p >= 0 && p < text.length));
|
||||||
|
return chars
|
||||||
|
.map((c, i) => (posSet.has(i) ? `<mark>${escapeHtml(c)}</mark>` : escapeHtml(c)))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeIcon(type: SearchMode): string {
|
||||||
|
switch (type) {
|
||||||
|
case "tabs": return "📄";
|
||||||
|
case "history": return "🕒";
|
||||||
|
case "bookmarks": return "⭐";
|
||||||
|
case "closed": return "🚪";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(): void {
|
||||||
|
resultsContainer.innerHTML = "";
|
||||||
|
if (results.length === 0 && input.value) {
|
||||||
|
resultsContainer.innerHTML = `<div class="no-results">No results found</div>`;
|
||||||
|
notifyResize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (results.length === 0 && !input.value) {
|
||||||
|
notifyResize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = results.slice(0, 50);
|
||||||
|
for (let i = 0; i < visible.length; i++) {
|
||||||
|
const { item, positions } = visible[i];
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = `result-row${i === selectedIndex ? " selected" : ""}`;
|
||||||
|
row.dataset.index = String(i);
|
||||||
|
|
||||||
|
const titleHtml = highlightText(item.title, positions, 0);
|
||||||
|
const urlHtml = highlightText(item.url, positions, item.title.length + 1);
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="result-icon">${item.favIconUrl ? `<img src="${escapeHtml(item.favIconUrl)}" width="16" height="16" alt="">` : typeIcon(item.type)}</span>
|
||||||
|
<span class="result-text">
|
||||||
|
<span class="result-title">${titleHtml}</span>
|
||||||
|
<span class="result-url">${urlHtml}</span>
|
||||||
|
</span>
|
||||||
|
<span class="result-badge badge-${item.type}">${MODE_LABELS[item.type]}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
row.addEventListener("click", () => activateResult(i));
|
||||||
|
row.addEventListener("mouseenter", () => {
|
||||||
|
selectedIndex = i;
|
||||||
|
updateSelection();
|
||||||
|
});
|
||||||
|
resultsContainer.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToSelected();
|
||||||
|
notifyResize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelection(): void {
|
||||||
|
const rows = resultsContainer.querySelectorAll(".result-row");
|
||||||
|
rows.forEach((row, i) => {
|
||||||
|
row.classList.toggle("selected", i === selectedIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToSelected(): void {
|
||||||
|
const selected = resultsContainer.querySelector(".selected");
|
||||||
|
selected?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateResult(index: number): void {
|
||||||
|
const result = results[index];
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
const { item } = result;
|
||||||
|
let action: string;
|
||||||
|
if (item.type === "tabs") {
|
||||||
|
action = "switch";
|
||||||
|
} else if (item.type === "closed") {
|
||||||
|
action = "restore";
|
||||||
|
} else {
|
||||||
|
action = "open";
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg: Message = item.type === "tabs" || item.type === "closed"
|
||||||
|
? { type: "action", action: action as "switch" | "restore", id: item.id }
|
||||||
|
: { type: "action", action: "open", id: item.url };
|
||||||
|
|
||||||
|
sendMessage(msg);
|
||||||
|
window.parent.postMessage({ type: "closeSaka" }, "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSearch(): Promise<void> {
|
||||||
|
const query = input.value;
|
||||||
|
const response = await sendMessage({
|
||||||
|
type: "search",
|
||||||
|
query,
|
||||||
|
mode: currentMode,
|
||||||
|
method: currentMethod,
|
||||||
|
} satisfies Message);
|
||||||
|
|
||||||
|
const data = response as { type: string; results: SearchItem[] };
|
||||||
|
const items = data.results ?? [];
|
||||||
|
results = search(items, query, currentMethod);
|
||||||
|
selectedIndex = 0;
|
||||||
|
renderResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(doSearch, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
window.parent.postMessage({ type: "closeSaka" }, "*");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "j")) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
||||||
|
updateSelection();
|
||||||
|
scrollToSelected();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "k")) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = Math.max(selectedIndex - 1, 0);
|
||||||
|
updateSelection();
|
||||||
|
scrollToSelected();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.ctrlKey && e.shiftKey) {
|
||||||
|
const result = results[selectedIndex];
|
||||||
|
if (result && result.item.type !== "tabs") {
|
||||||
|
sendMessage({ type: "action", action: "open", id: result.item.url, newTab: true });
|
||||||
|
window.parent.postMessage({ type: "closeSaka" }, "*");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
activateResult(selectedIndex);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
const dir = e.shiftKey ? -1 : 1;
|
||||||
|
const idx = MODES.indexOf(currentMode);
|
||||||
|
currentMode = MODES[(idx + dir + MODES.length) % MODES.length];
|
||||||
|
renderModeBar();
|
||||||
|
doSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.ctrlKey && e.key === "f") {
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = METHODS.indexOf(currentMethod);
|
||||||
|
currentMethod = METHODS[(idx + 1) % METHODS.length];
|
||||||
|
renderMethodBadge();
|
||||||
|
doSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.ctrlKey && e.key === "d") {
|
||||||
|
e.preventDefault();
|
||||||
|
const result = results[selectedIndex];
|
||||||
|
if (result && result.item.type === "tabs") {
|
||||||
|
sendMessage({ type: "action", action: "close", id: result.item.id });
|
||||||
|
results.splice(selectedIndex, 1);
|
||||||
|
if (selectedIndex >= results.length) selectedIndex = Math.max(results.length - 1, 0);
|
||||||
|
renderResults();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.ctrlKey && e.key >= "1" && e.key <= "5") {
|
||||||
|
e.preventDefault();
|
||||||
|
const modeIdx = parseInt(e.key, 10) - 1;
|
||||||
|
if (modeIdx < MODES.length) {
|
||||||
|
currentMode = MODES[modeIdx];
|
||||||
|
renderModeBar();
|
||||||
|
doSearch();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
renderModeBar();
|
||||||
|
renderMethodBadge();
|
||||||
|
input.focus();
|
||||||
|
doSearch();
|
||||||
|
});
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
import type { SearchItem, SearchMethod, SearchResult } from "./types";
|
||||||
|
|
||||||
|
const SCORE_GAP_LEADING = -0.005;
|
||||||
|
const SCORE_GAP_TRAILING = -0.005;
|
||||||
|
const SCORE_GAP_INNER = -0.01;
|
||||||
|
const SCORE_MATCH_CONSECUTIVE = 1.0;
|
||||||
|
const SCORE_MATCH_SLASH = 0.9;
|
||||||
|
const SCORE_MATCH_WORD = 0.8;
|
||||||
|
const SCORE_MATCH_CAPITAL = 0.7;
|
||||||
|
const SCORE_MATCH_DOT = 0.6;
|
||||||
|
const BONUS_FIRST_CHAR = 0.6;
|
||||||
|
|
||||||
|
function isSlash(c: string): boolean {
|
||||||
|
return c === "/" || c === "\\";
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeBonus(prev: string, curr: string): number {
|
||||||
|
if (isSlash(prev)) return SCORE_MATCH_SLASH;
|
||||||
|
if (prev === "_" || prev === "-" || prev === " ") return SCORE_MATCH_WORD;
|
||||||
|
if (prev === ".") return SCORE_MATCH_DOT;
|
||||||
|
if (prev === prev.toLowerCase() && curr === curr.toUpperCase()) return SCORE_MATCH_CAPITAL;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fuzzyMatch(needle: string, haystack: string): { score: number; positions: number[] } | null {
|
||||||
|
const n = needle.length;
|
||||||
|
const m = haystack.length;
|
||||||
|
if (n === 0) return { score: 0, positions: [] };
|
||||||
|
if (n > m) return null;
|
||||||
|
|
||||||
|
const needleLower = needle.toLowerCase();
|
||||||
|
const haystackLower = haystack.toLowerCase();
|
||||||
|
|
||||||
|
let ni = 0;
|
||||||
|
for (let hi = 0; hi < m; hi++) {
|
||||||
|
if (needleLower[ni] === haystackLower[hi]) ni++;
|
||||||
|
if (ni === n) break;
|
||||||
|
}
|
||||||
|
if (ni < n) return null;
|
||||||
|
|
||||||
|
const D: number[][] = Array.from({ length: n }, () => new Array(m).fill(0));
|
||||||
|
const M: number[][] = Array.from({ length: n }, () => new Array(m).fill(0));
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
let prevScore = -Infinity;
|
||||||
|
let gapScore = i === n - 1 ? SCORE_GAP_TRAILING : SCORE_GAP_INNER;
|
||||||
|
|
||||||
|
for (let j = 0; j < m; j++) {
|
||||||
|
if (needleLower[i] === haystackLower[j]) {
|
||||||
|
let score = 0;
|
||||||
|
if (i === 0) {
|
||||||
|
score = j * SCORE_GAP_LEADING;
|
||||||
|
if (j === 0) score += BONUS_FIRST_CHAR;
|
||||||
|
else score += computeBonus(haystack[j - 1], haystack[j]);
|
||||||
|
} else if (j > 0) {
|
||||||
|
const bonus = computeBonus(haystack[j - 1], haystack[j]);
|
||||||
|
score = Math.max(
|
||||||
|
M[i - 1][j - 1] + bonus,
|
||||||
|
D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
D[i][j] = score;
|
||||||
|
M[i][j] = Math.max(score, prevScore + gapScore);
|
||||||
|
} else {
|
||||||
|
D[i][j] = -Infinity;
|
||||||
|
M[i][j] = prevScore + gapScore;
|
||||||
|
}
|
||||||
|
prevScore = M[i][j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestJ = 0;
|
||||||
|
for (let j = 1; j < m; j++) {
|
||||||
|
if (M[n - 1][j] > M[n - 1][bestJ]) bestJ = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions: number[] = new Array(n);
|
||||||
|
let i = n - 1;
|
||||||
|
let j = bestJ;
|
||||||
|
while (i >= 0 && j >= 0) {
|
||||||
|
if (D[i][j] !== -Infinity && (i === 0 || j === 0 || D[i][j] >= M[i][j])) {
|
||||||
|
positions[i] = j;
|
||||||
|
i--;
|
||||||
|
j--;
|
||||||
|
} else {
|
||||||
|
j--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { score: M[n - 1][bestJ], positions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fulltextMatch(needle: string, haystack: string): { score: number; positions: number[] } | null {
|
||||||
|
const idx = haystack.toLowerCase().indexOf(needle.toLowerCase());
|
||||||
|
if (idx === -1) return null;
|
||||||
|
|
||||||
|
const positions = Array.from({ length: needle.length }, (_, i) => idx + i);
|
||||||
|
let score = 1.0;
|
||||||
|
if (idx === 0) score += 0.5;
|
||||||
|
score -= idx * 0.01;
|
||||||
|
return { score, positions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefixMatch(needle: string, haystack: string): { score: number; positions: number[] } | null {
|
||||||
|
const words = haystack.split(/[\s\-_/.:]+/);
|
||||||
|
const needleLower = needle.toLowerCase();
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
if (word.toLowerCase().startsWith(needleLower)) {
|
||||||
|
const start = haystack.toLowerCase().indexOf(word.toLowerCase(), offset);
|
||||||
|
const positions = Array.from({ length: needle.length }, (_, i) => start + i);
|
||||||
|
let score = 1.0;
|
||||||
|
if (start === 0) score += 0.5;
|
||||||
|
return { score, positions };
|
||||||
|
}
|
||||||
|
offset += word.length + 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function search(items: SearchItem[], query: string, method: SearchMethod): SearchResult[] {
|
||||||
|
if (!query) {
|
||||||
|
return items.map((item) => ({ item, score: 0, positions: [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchFn = method === "fuzzy" ? fuzzyMatch
|
||||||
|
: method === "fulltext" ? fulltextMatch
|
||||||
|
: prefixMatch;
|
||||||
|
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const haystack = `${item.title} ${item.url}`;
|
||||||
|
const match = matchFn(query, haystack);
|
||||||
|
if (match) {
|
||||||
|
results.push({ item, score: match.score, positions: match.positions });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort((a, b) => b.score - a.score);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user