diff --git a/icons/border-48.png b/icons/border-48.png new file mode 100644 index 0000000..90687de Binary files /dev/null and b/icons/border-48.png differ diff --git a/sciezka.css b/sciezka.css new file mode 100644 index 0000000..35e9c50 --- /dev/null +++ b/sciezka.css @@ -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); +} diff --git a/sciezka.html b/sciezka.html new file mode 100644 index 0000000..06bcd92 --- /dev/null +++ b/sciezka.html @@ -0,0 +1,28 @@ + + + + + + + +
+ +
+
+ +
+ + + diff --git a/src/sciezka.ts b/src/sciezka.ts new file mode 100644 index 0000000..0b6aed9 --- /dev/null +++ b/src/sciezka.ts @@ -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 = { + 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 | 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 { + 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 = `${MODE_LABELS[mode]}${i + 1}`; + 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) ? `${escapeHtml(c)}` : escapeHtml(c))) + .join(""); +} + +function escapeHtml(s: string): string { + return s.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 = `
No results found
`; + 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 = ` + ${item.favIconUrl ? `` : typeIcon(item.type)} + + ${titleHtml} + ${urlHtml} + + ${MODE_LABELS[item.type]} + `; + + 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 { + 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(); +}); diff --git a/src/search.ts b/src/search.ts new file mode 100644 index 0000000..623a4dd --- /dev/null +++ b/src/search.ts @@ -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; +}