mirror of
https://github.com/fnsign/embedded-omnisearch.git
synced 2026-07-22 06:58:08 +00:00
first commit
This commit is contained in:
commit
94d987466f
4 changed files with 1000 additions and 0 deletions
149
README.md
Normal file
149
README.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# Embedded-Omnisearch
|
||||
|
||||
An Obsidian plugin that renders a compact, inline search UI directly inside a note. It is powered by the [Omnisearch](https://github.com/scambier/obsidian-omnisearch) API and includes plugin settings for default page size and highlight appearance.
|
||||
|
||||
## Features
|
||||
|
||||
- Inline vault search inside any note via a fenced code block.
|
||||
- Automatically switches notes containing an embedded search block into preview mode when needed.
|
||||
- Accent-insensitive term matching with configurable highlight color and opacity.
|
||||
- Paginated results with configurable page size.
|
||||
- Results rendered as a compact table with file name, relevance score, and excerpt preview.
|
||||
- Opens clicked results in preview mode.
|
||||
- Keyboard shortcuts: `Enter` to search, `Escape` to clear.
|
||||
- Clear button with dynamic visibility.
|
||||
- Hold `Alt` over a result row to show the full file path as a popover.
|
||||
- Debounced search input (350 ms).
|
||||
- Plugin settings page for default results per page, highlight color, and highlight opacity.
|
||||
- Code-block-level `pageSize` override that takes precedence over the global default.
|
||||
- Proper lifecycle management with registered views refreshed when settings change.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Obsidian](https://obsidian.md/) v1.0.0 or later.
|
||||
- [Omnisearch](https://github.com/scambier/obsidian-omnisearch) community plugin, installed and enabled.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copy this folder (`manifest.json`, `main.js`, `styles.css`) into your vault at:
|
||||
|
||||
```
|
||||
<vault>/.obsidian/plugins/embedded-omnisearch/
|
||||
```
|
||||
|
||||
2. In Obsidian, open **Settings -> Community plugins** and enable **Embedded-Omnisearch**.
|
||||
|
||||
3. Make sure Omnisearch is also enabled and has indexed your vault.
|
||||
|
||||
4. Open **Settings -> Community plugins -> Embedded-Omnisearch** to configure defaults.
|
||||
|
||||
## Usage
|
||||
|
||||
Add a fenced code block with the language identifier `embedded-omnisearch` to any note:
|
||||
|
||||
````
|
||||
```embedded-omnisearch
|
||||
```
|
||||
````
|
||||
|
||||
Open the note in **Reading View** or **Live Preview**. The code block is replaced by a search field.
|
||||
|
||||
If you open a markdown note containing an `embedded-omnisearch` code block, the plugin will also try to switch that leaf to preview mode automatically so the embedded UI becomes visible.
|
||||
|
||||
### Configuration
|
||||
|
||||
You can set options inside the code block. Currently supported:
|
||||
|
||||
| Option | Default | Description |
|
||||
| ---------- | ------- | ---------------------------------- |
|
||||
| `pageSize` | Plugin setting | Number of search results per page. |
|
||||
|
||||
Example:
|
||||
|
||||
````
|
||||
```embedded-omnisearch
|
||||
pageSize: 20
|
||||
```
|
||||
````
|
||||
|
||||
### Plugin Settings
|
||||
|
||||
The plugin settings page provides defaults for the embedded search UI:
|
||||
|
||||
| Setting | Default | Description |
|
||||
| ------- | ------- | ----------- |
|
||||
| `Results per page` | `10` | Default page size for embedded searches. |
|
||||
| `Highlight color` | `#cca300` | Base color used for highlighted matches. |
|
||||
| `Highlight opacity` | `35%` | Opacity of the highlight background. |
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The plugin registers a **Markdown code block processor** for the language `embedded-omnisearch`.
|
||||
2. When Obsidian renders such a block, the plugin creates a `SearchView` component attached to the block's DOM element.
|
||||
3. The plugin watches active markdown leaves and switches notes containing an embedded search block to preview mode when needed.
|
||||
4. On each query the view calls `globalThis.omnisearch.search(query)` and renders the results as a styled table.
|
||||
5. Each result shows the linked file name, a rounded relevance score, and an excerpt with accent-insensitive highlighted matches.
|
||||
6. Highlight color and opacity are exposed through plugin settings and applied via the `--eo-highlight-color` CSS variable.
|
||||
7. Results are split into pages; `<` / `>` buttons navigate between pages.
|
||||
8. Clicking a result link opens the file in preview mode.
|
||||
9. Holding `Alt` while hovering a result row displays the full vault path in a small popover.
|
||||
10. All DOM event listeners use Obsidian's `registerDomEvent` and are cleaned up when the component unloads.
|
||||
|
||||
## Customization
|
||||
|
||||
### Styles
|
||||
|
||||
The plugin ships its own `styles.css` using Obsidian CSS variables (`--background-secondary`, `--text-normal`, `--text-muted`, `--background-modifier-border`, etc.), so it adapts to any theme automatically.
|
||||
|
||||
You can override any class in an Obsidian CSS snippet. Key classes:
|
||||
|
||||
- `.eo-wrap` — outer wrapper
|
||||
- `.eo-input-wrap` — search field container
|
||||
- `.omnisearch-input-field` — search input field
|
||||
- `.eo-clear` — clear button
|
||||
- `.eo-status` — status / result count line
|
||||
- `.eo-results` — results container
|
||||
- `.eo-results-table` — result table
|
||||
- `.eo-results-row` / `.eo-results-cell` — result rows and cells
|
||||
- `.eo-results-link` — clickable note link
|
||||
- `.eo-page-bar` / `.eo-page-btn` / `.eo-page-info` — pagination
|
||||
- `.eo-popover` — Alt+hover path popover
|
||||
|
||||
### Highlight Appearance
|
||||
|
||||
The plugin applies highlight color and opacity through the `--eo-highlight-color` CSS variable, which is updated from the plugin settings page. You can still override that variable in a custom CSS snippet if needed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### The code block shows raw text instead of the search UI
|
||||
|
||||
- Make sure the plugin is enabled in **Settings → Community plugins**.
|
||||
- The note must be in **Reading View** or **Live Preview**. The plugin will try to switch notes with embedded search blocks out of Source mode automatically, but it can only do that for normal markdown leaves.
|
||||
|
||||
### "Omnisearch API not available"
|
||||
|
||||
- Omnisearch must be installed and enabled.
|
||||
- Wait for Obsidian to finish loading all plugins on startup.
|
||||
|
||||
### Some notes do not appear in results
|
||||
|
||||
- Omnisearch indexing may not be complete yet.
|
||||
- Check Omnisearch exclusion settings.
|
||||
- The notes must actually contain the searched terms.
|
||||
|
||||
### Path popover does not appear
|
||||
|
||||
- Hold the `Alt` key while hovering over a result row.
|
||||
- Results must already be rendered.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
| --------------- | -------------------------------------------------------------- |
|
||||
| `manifest.json` | Plugin metadata (id, version, description). |
|
||||
| `main.js` | Plugin code: embedded search UI, settings, highlighting, pagination, preview enforcement, and popover behavior. |
|
||||
| `styles.css` | CSS using Obsidian variables for automatic theme compatibility. |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
626
main.js
Normal file
626
main.js
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
'use strict';
|
||||
|
||||
var obsidian = require('obsidian');
|
||||
|
||||
/* === Pre-compiled patterns === */
|
||||
var RE_HTML = /[&<>"']/g;
|
||||
var HTML_MAP = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
||||
var RE_DIACRITICS = /[\u0300-\u036f]/g;
|
||||
var RE_WS = /\s+/;
|
||||
var RE_EMBEDDED_BLOCK = /^```embedded-omnisearch(?:\s|$)/m;
|
||||
var DEFAULT_SETTINGS = { pageSize: 10, highlightColor: "#cca300", highlightOpacity: 0.35 };
|
||||
|
||||
/* === Helpers === */
|
||||
function escHtml(s) { return String(s).replace(RE_HTML, function (c) { return HTML_MAP[c]; }); }
|
||||
|
||||
function norm(s) {
|
||||
return String(s == null ? "" : s).trim().normalize("NFD")
|
||||
.replace(RE_DIACRITICS, "").replace(/ß/g, "ss").toLowerCase();
|
||||
}
|
||||
|
||||
function bname(p) { return String(p || "").replace(/^.*[\\/]/, ""); }
|
||||
|
||||
function getApi() {
|
||||
var a = globalThis.omnisearch;
|
||||
return a && typeof a.search === "function" ? a : null;
|
||||
}
|
||||
|
||||
function clampPageSize(value) {
|
||||
var n = parseInt(value, 10);
|
||||
if (!isFinite(n) || isNaN(n)) return DEFAULT_SETTINGS.pageSize;
|
||||
if (n < 1) return 1;
|
||||
if (n > 100) return 100;
|
||||
return n;
|
||||
}
|
||||
|
||||
function normalizeHexColor(value) {
|
||||
var color = String(value == null ? "" : value).trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(color)) return color.toLowerCase();
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(color)) {
|
||||
return ("#" + color[1] + color[1] + color[2] + color[2] + color[3] + color[3]).toLowerCase();
|
||||
}
|
||||
return DEFAULT_SETTINGS.highlightColor;
|
||||
}
|
||||
|
||||
function clampOpacity(value) {
|
||||
var n = parseFloat(value);
|
||||
if (!isFinite(n) || isNaN(n)) return DEFAULT_SETTINGS.highlightOpacity;
|
||||
if (n < 0) return 0;
|
||||
if (n > 1) return 1;
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
var color = normalizeHexColor(hex);
|
||||
var r = parseInt(color.slice(1, 3), 16);
|
||||
var g = parseInt(color.slice(3, 5), 16);
|
||||
var b = parseInt(color.slice(5, 7), 16);
|
||||
return "rgba(" + r + "," + g + "," + b + "," + alpha + ")";
|
||||
}
|
||||
|
||||
/* === Accent-insensitive fold === */
|
||||
function foldWithMap(str) {
|
||||
var src = String(str), starts = [], ends = [], folded = "", i = 0;
|
||||
for (var ch of src) {
|
||||
var start = i, end = (i += ch.length);
|
||||
var n = ch.normalize("NFD");
|
||||
for (var j = 0; j < n.length; j++) {
|
||||
var code = n.charCodeAt(j);
|
||||
if (code >= 0x0300 && code <= 0x036f) continue;
|
||||
if (code === 0xDF) {
|
||||
folded += "ss"; starts.push(start, start); ends.push(end, end);
|
||||
} else {
|
||||
folded += n[j].toLowerCase(); starts.push(start); ends.push(end);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { f: folded, s: starts, e: ends, o: src };
|
||||
}
|
||||
|
||||
var stripEl = document.createElement("span");
|
||||
|
||||
function highlight(html, terms) {
|
||||
stripEl.innerHTML = String(html != null ? html : "");
|
||||
var plain = stripEl.textContent || "";
|
||||
if (!plain) return "";
|
||||
var m = foldWithMap(plain), f = m.f, s = m.s, e = m.e, o = m.o;
|
||||
if (!f || !terms.length) return plain;
|
||||
var ranges = [];
|
||||
for (var t of terms) {
|
||||
if (!t) continue;
|
||||
var pos = 0, idx;
|
||||
while ((idx = f.indexOf(t, pos)) >= 0) {
|
||||
ranges.push([s[idx], e[idx + t.length - 1]]);
|
||||
pos = idx + t.length;
|
||||
}
|
||||
}
|
||||
if (!ranges.length) return plain;
|
||||
ranges.sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; });
|
||||
var merged = [ranges[0]];
|
||||
for (var i = 1; i < ranges.length; i++) {
|
||||
var last = merged[merged.length - 1], a = ranges[i][0], b = ranges[i][1];
|
||||
if (a > last[1]) merged.push([a, b]);
|
||||
else last[1] = Math.max(last[1], b);
|
||||
}
|
||||
var out = "", cur = 0;
|
||||
for (var r of merged) {
|
||||
out += escHtml(o.slice(cur, r[0]));
|
||||
out += '<mark class="omnisearch-highlight" style="background-color:var(--eo-highlight-color, rgba(204,163,0,0.35));color:var(--text-normal);padding:0 .15em;border-radius:3px">'
|
||||
+ escHtml(o.slice(r[0], r[1])) + '</mark>';
|
||||
cur = r[1];
|
||||
}
|
||||
var span = document.createElement("span");
|
||||
span.innerHTML = out + escHtml(o.slice(cur));
|
||||
return span;
|
||||
}
|
||||
|
||||
/* === Plugin === */
|
||||
class EmbeddedOmnisearchPlugin extends obsidian.Plugin {
|
||||
async onload() {
|
||||
this.previewCheckToken = 0;
|
||||
this.views = new Set();
|
||||
await this.loadSettings();
|
||||
this.applyHighlightColor();
|
||||
this.addSettingTab(new EmbeddedOmnisearchSettingTab(this.app, this));
|
||||
this.registerEvent(this.app.workspace.on("active-leaf-change", (leaf) => {
|
||||
this.enforceLeafPreview(leaf);
|
||||
}));
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
this.enforceLeafPreview(this.app.workspace.activeLeaf);
|
||||
});
|
||||
|
||||
this.registerMarkdownCodeBlockProcessor("embedded-omnisearch", (source, el, ctx) => {
|
||||
ctx.addChild(new SearchView(this, el, this.parseConfig(source)));
|
||||
});
|
||||
}
|
||||
|
||||
onunload() {
|
||||
if (typeof document !== "undefined" && document.documentElement) {
|
||||
document.documentElement.style.removeProperty("--eo-highlight-color");
|
||||
}
|
||||
if (this.views) this.views.clear();
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
var data = {};
|
||||
try {
|
||||
data = await this.loadData() || {};
|
||||
} catch (e) {}
|
||||
this.settings = {
|
||||
pageSize: clampPageSize(data.pageSize),
|
||||
highlightColor: normalizeHexColor(data.highlightColor),
|
||||
highlightOpacity: clampOpacity(data.highlightOpacity)
|
||||
};
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
this.applyHighlightColor();
|
||||
this.refreshViews();
|
||||
}
|
||||
|
||||
applyHighlightColor() {
|
||||
if (typeof document === "undefined" || !document.documentElement) return;
|
||||
document.documentElement.style.setProperty("--eo-highlight-color", hexToRgba(this.settings.highlightColor, this.settings.highlightOpacity));
|
||||
}
|
||||
|
||||
refreshViews() {
|
||||
if (!this.views) return;
|
||||
for (var view of this.views) {
|
||||
if (view && typeof view.onSettingsChanged === "function") view.onSettingsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
registerSearchView(view) {
|
||||
this.views.add(view);
|
||||
}
|
||||
|
||||
unregisterSearchView(view) {
|
||||
this.views.delete(view);
|
||||
}
|
||||
|
||||
getLeafMarkdown(leaf) {
|
||||
if (!leaf || !leaf.view || leaf.view.getViewType() !== "markdown") return "";
|
||||
if (typeof leaf.view.getViewData === "function") {
|
||||
try {
|
||||
return String(leaf.view.getViewData() || "");
|
||||
} catch (e) {}
|
||||
}
|
||||
if (typeof leaf.view.data === "string") return leaf.view.data;
|
||||
return "";
|
||||
}
|
||||
|
||||
noteContainsEmbeddedSearch(text) {
|
||||
return RE_EMBEDDED_BLOCK.test(String(text || ""));
|
||||
}
|
||||
|
||||
setLeafPreview(leaf) {
|
||||
if (!leaf || !leaf.view || leaf.view.getViewType() !== "markdown") return false;
|
||||
var vs = leaf.getViewState ? leaf.getViewState() || {} : {};
|
||||
if (vs.state && vs.state.mode === "preview") return false;
|
||||
leaf.setViewState({
|
||||
type: vs.type || "markdown",
|
||||
state: Object.assign({}, vs.state || {}, { mode: "preview" })
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async enforceLeafPreview(leaf) {
|
||||
if (!leaf || !leaf.view || leaf.view.getViewType() !== "markdown") return;
|
||||
var file = leaf.view.file;
|
||||
if (!file) return;
|
||||
|
||||
var markdown = this.getLeafMarkdown(leaf);
|
||||
if (this.noteContainsEmbeddedSearch(markdown)) {
|
||||
this.setLeafPreview(leaf);
|
||||
return;
|
||||
}
|
||||
|
||||
var token = ++this.previewCheckToken;
|
||||
var cached = "";
|
||||
try {
|
||||
cached = await this.app.vault.cachedRead(file);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
if (token !== this.previewCheckToken) return;
|
||||
if (!this.noteContainsEmbeddedSearch(cached)) return;
|
||||
this.setLeafPreview(leaf);
|
||||
}
|
||||
|
||||
parseConfig(source) {
|
||||
var cfg = { pageSize: this.settings.pageSize, hasPageSizeOverride: false };
|
||||
for (var line of (source || "").split("\n")) {
|
||||
var m = line.match(/^\s*pageSize\s*:\s*(\d+)/);
|
||||
if (m) {
|
||||
cfg.pageSize = clampPageSize(m[1]);
|
||||
cfg.hasPageSizeOverride = true;
|
||||
}
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Search View Component === */
|
||||
class SearchView extends obsidian.Component {
|
||||
constructor(plugin, el, cfg) {
|
||||
super();
|
||||
this.plugin = plugin;
|
||||
this.app = plugin.app;
|
||||
this.rootEl = el;
|
||||
this.cfg = cfg;
|
||||
this.results = [];
|
||||
this.page = 0;
|
||||
this.pageSize = this.getPageSize();
|
||||
this.terms = [];
|
||||
this.timer = null;
|
||||
this.altHeld = false;
|
||||
this.hoverRow = null;
|
||||
this.mx = 0;
|
||||
this.my = 0;
|
||||
}
|
||||
|
||||
onload() {
|
||||
this.plugin.registerSearchView(this);
|
||||
this.build();
|
||||
this.bind();
|
||||
}
|
||||
|
||||
onunload() {
|
||||
clearTimeout(this.timer);
|
||||
if (this.popover && this.popover.parentNode) this.popover.remove();
|
||||
this.plugin.unregisterSearchView(this);
|
||||
}
|
||||
|
||||
getPageSize() {
|
||||
if (this.cfg && this.cfg.hasPageSizeOverride) return clampPageSize(this.cfg.pageSize);
|
||||
return clampPageSize(this.plugin.settings.pageSize);
|
||||
}
|
||||
|
||||
onSettingsChanged() {
|
||||
var nextPageSize = this.getPageSize();
|
||||
if (nextPageSize === this.pageSize) return;
|
||||
this.pageSize = nextPageSize;
|
||||
if (!this.results.length) return;
|
||||
var total = Math.ceil(this.results.length / this.pageSize);
|
||||
if (this.page > total - 1) this.page = Math.max(0, total - 1);
|
||||
this.renderPage();
|
||||
}
|
||||
|
||||
/* --- Build UI --- */
|
||||
build() {
|
||||
var root = this.rootEl;
|
||||
root.empty();
|
||||
root.addClass("omnisearch-modal", "eo-wrap");
|
||||
|
||||
/* Input */
|
||||
var inputWrap = root.createDiv({ cls: "omnisearch-input-container eo-input-wrap" });
|
||||
this.input = inputWrap.createEl("input", {
|
||||
cls: "omnisearch-input-field",
|
||||
attr: { type: "text", placeholder: "Find with Omnisearch...", spellcheck: "false", autocomplete: "off" }
|
||||
});
|
||||
this.input.style.fontStyle = "italic";
|
||||
|
||||
this.clearBtn = inputWrap.createEl("button", {
|
||||
cls: "eo-clear",
|
||||
text: "\u2715",
|
||||
attr: { type: "button", "aria-label": "clear" }
|
||||
});
|
||||
this.clearBtn.style.opacity = "0";
|
||||
this.clearBtn.style.pointerEvents = "none";
|
||||
|
||||
/* Status */
|
||||
this.statusEl = root.createDiv({ cls: "eo-status" });
|
||||
|
||||
/* Results list */
|
||||
this.resultsEl = root.createDiv({ cls: "eo-results" });
|
||||
|
||||
/* Pagination */
|
||||
this.pageBar = root.createDiv({ cls: "eo-page-bar" });
|
||||
this.pageBar.style.display = "none";
|
||||
this.prevBtn = this.pageBar.createEl("button", { cls: "eo-page-btn", text: "<", attr: { type: "button" } });
|
||||
this.pageInfo = this.pageBar.createEl("span", { cls: "eo-page-info" });
|
||||
this.nextBtn = this.pageBar.createEl("button", { cls: "eo-page-btn", text: ">", attr: { type: "button" } });
|
||||
|
||||
/* Popover */
|
||||
this.popover = createEl("div", { cls: "eo-popover" });
|
||||
this.popover.style.display = "none";
|
||||
document.body.appendChild(this.popover);
|
||||
}
|
||||
|
||||
/* --- Bind events --- */
|
||||
bind() {
|
||||
/* Input */
|
||||
this.registerDomEvent(this.input, "input", () => {
|
||||
var v = this.input.value.trim();
|
||||
var has = !!v;
|
||||
this.clearBtn.style.opacity = has ? "1" : "0";
|
||||
this.clearBtn.style.pointerEvents = has ? "auto" : "none";
|
||||
this.input.classList.toggle("has-value", has);
|
||||
this.input.style.fontStyle = has ? "normal" : "italic";
|
||||
clearTimeout(this.timer);
|
||||
if (!has) {
|
||||
this.statusEl.textContent = "";
|
||||
this.resultsEl.empty();
|
||||
this.results = [];
|
||||
this.page = 0;
|
||||
this.pageBar.style.display = "none";
|
||||
return;
|
||||
}
|
||||
this.timer = setTimeout(() => this.search(this.input.value), 350);
|
||||
});
|
||||
|
||||
this.registerDomEvent(this.input, "keydown", (e) => {
|
||||
if (e.key === "Enter") this.search(this.input.value);
|
||||
if (e.key === "Escape") this.clearAll();
|
||||
});
|
||||
|
||||
/* Clear */
|
||||
this.registerDomEvent(this.clearBtn, "click", (e) => {
|
||||
e.preventDefault();
|
||||
this.clearAll();
|
||||
});
|
||||
|
||||
/* Pagination */
|
||||
this.registerDomEvent(this.prevBtn, "click", (e) => {
|
||||
e.preventDefault();
|
||||
if (this.page > 0) { this.page--; this.renderPage(); }
|
||||
});
|
||||
this.registerDomEvent(this.nextBtn, "click", (e) => {
|
||||
e.preventDefault();
|
||||
var total = Math.ceil(this.results.length / this.pageSize);
|
||||
if (this.page < total - 1) { this.page++; this.renderPage(); }
|
||||
});
|
||||
|
||||
/* Result clicks — open in preview */
|
||||
this.registerDomEvent(this.resultsEl, "click", async (ev) => {
|
||||
var a = ev.target.closest("a.internal-link");
|
||||
if (!a) return;
|
||||
ev.preventDefault();
|
||||
var href = a.dataset.href || a.getAttribute("href");
|
||||
if (!href) return;
|
||||
var file = this.app.vault.getAbstractFileByPath(href);
|
||||
if (!file) return;
|
||||
var leaf = this.app.workspace.getLeaf(false);
|
||||
await leaf.openFile(file, { state: { mode: "preview" } });
|
||||
});
|
||||
|
||||
/* Popover — mousemove / mouseleave on results */
|
||||
this.registerDomEvent(this.resultsEl, "mousemove", (ev) => {
|
||||
this.mx = ev.clientX;
|
||||
this.my = ev.clientY;
|
||||
this.hoverRow = ev.target.closest(".eo-results-row") || null;
|
||||
this.showPopover();
|
||||
});
|
||||
this.registerDomEvent(this.resultsEl, "mouseleave", () => {
|
||||
this.hoverRow = null;
|
||||
this.popover.style.display = "none";
|
||||
});
|
||||
|
||||
/* Alt key — global */
|
||||
this.registerDomEvent(document, "keydown", (e) => {
|
||||
if (e.key === "Alt") { this.altHeld = true; this.showPopover(); }
|
||||
});
|
||||
this.registerDomEvent(document, "keyup", (e) => {
|
||||
if (e.key === "Alt") { this.altHeld = false; this.popover.style.display = "none"; }
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Popover --- */
|
||||
showPopover() {
|
||||
var fp = this.hoverRow && this.hoverRow.dataset && this.hoverRow.dataset.filepath;
|
||||
if (!fp || !this.altHeld) { this.popover.style.display = "none"; return; }
|
||||
this.popover.textContent = fp;
|
||||
this.popover.style.display = "block";
|
||||
this.popover.style.left = (this.mx + 14) + "px";
|
||||
this.popover.style.top = (this.my + 14) + "px";
|
||||
}
|
||||
|
||||
/* --- Clear --- */
|
||||
clearAll() {
|
||||
this.input.value = "";
|
||||
this.statusEl.textContent = "";
|
||||
this.resultsEl.empty();
|
||||
this.results = [];
|
||||
this.page = 0;
|
||||
this.terms = [];
|
||||
this.pageBar.style.display = "none";
|
||||
this.clearBtn.style.opacity = "0";
|
||||
this.clearBtn.style.pointerEvents = "none";
|
||||
this.input.classList.remove("has-value");
|
||||
this.input.style.fontStyle = "italic";
|
||||
this.input.focus();
|
||||
}
|
||||
|
||||
/* --- Search --- */
|
||||
async search(raw) {
|
||||
var q = String(raw || "").trim();
|
||||
this.resultsEl.empty();
|
||||
this.pageBar.style.display = "none";
|
||||
this.results = [];
|
||||
this.page = 0;
|
||||
this.terms = [];
|
||||
if (!q) { this.statusEl.textContent = ""; return; }
|
||||
|
||||
var api = getApi();
|
||||
if (!api) { this.statusEl.textContent = "\u274c Omnisearch API not available"; return; }
|
||||
|
||||
this.statusEl.textContent = "Searching\u2026";
|
||||
var res = await api.search(q);
|
||||
if (!res || !res.length) { this.statusEl.textContent = 'No results for "' + q + '"'; return; }
|
||||
|
||||
this.results = res;
|
||||
this.pageSize = this.getPageSize();
|
||||
this.terms = q.split(RE_WS).filter(Boolean).map(norm);
|
||||
this.page = 0;
|
||||
this.renderPage();
|
||||
}
|
||||
|
||||
/* --- Render one page --- */
|
||||
renderPage() {
|
||||
this.resultsEl.empty();
|
||||
var ps = this.pageSize;
|
||||
var total = Math.ceil(this.results.length / ps);
|
||||
var start = this.page * ps;
|
||||
var items = this.results.slice(start, start + ps);
|
||||
if (!items.length) return;
|
||||
|
||||
this.statusEl.textContent = this.results.length + " results \u2014 page " + (this.page + 1) + " of " + total;
|
||||
|
||||
var tbl = this.resultsEl.createEl("table", { cls: "eo-results-table" });
|
||||
var colgroup = tbl.createEl("colgroup");
|
||||
colgroup.createEl("col", { cls: "eo-col-file" });
|
||||
colgroup.createEl("col", { cls: "eo-col-score" });
|
||||
colgroup.createEl("col", { cls: "eo-col-preview" });
|
||||
|
||||
var thead = tbl.createEl("thead", { cls: "eo-results-head" });
|
||||
var headRow = thead.createEl("tr", { cls: "eo-results-head-row" });
|
||||
headRow.createEl("th", { cls: "eo-results-head-cell eo-results-head-file", text: "File" });
|
||||
headRow.createEl("th", { cls: "eo-results-head-cell eo-results-head-score", text: "Score" });
|
||||
headRow.createEl("th", { cls: "eo-results-head-cell eo-results-head-preview", text: "Preview" });
|
||||
|
||||
var tbody = tbl.createEl("tbody", { cls: "eo-results-body" });
|
||||
|
||||
for (var r of items) {
|
||||
var tr = tbody.createEl("tr", { cls: "eo-results-row" });
|
||||
tr.dataset.filepath = r.path || "";
|
||||
|
||||
var tdFile = tr.createEl("td", { cls: "eo-results-cell eo-results-file" });
|
||||
tdFile.createEl("a", {
|
||||
cls: "internal-link eo-results-link",
|
||||
text: bname(r.path),
|
||||
attr: { "data-href": r.path, href: r.path }
|
||||
});
|
||||
|
||||
tr.createEl("td", {
|
||||
cls: "eo-results-cell eo-results-score",
|
||||
text: String(Math.round(r.score || 0))
|
||||
});
|
||||
|
||||
var tdBody = tr.createEl("td", { cls: "eo-results-cell eo-results-preview" });
|
||||
var uniq = Array.from(new Set(
|
||||
this.terms.concat((r.foundWords || []).map(norm)).filter(Boolean)
|
||||
)).sort(function (a, b) { return b.length - a.length; });
|
||||
|
||||
var hl = highlight(r.excerpt, uniq);
|
||||
if (typeof hl === "string") tdBody.textContent = hl;
|
||||
else tdBody.appendChild(hl);
|
||||
}
|
||||
|
||||
if (total > 1) {
|
||||
this.pageBar.style.display = "flex";
|
||||
this.prevBtn.disabled = this.page === 0;
|
||||
this.prevBtn.style.opacity = this.page === 0 ? "0.35" : "1";
|
||||
this.nextBtn.disabled = this.page >= total - 1;
|
||||
this.nextBtn.style.opacity = this.page >= total - 1 ? "0.35" : "1";
|
||||
this.pageInfo.textContent = (this.page + 1) + " / " + total;
|
||||
} else {
|
||||
this.pageBar.style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EmbeddedOmnisearchSettingTab extends obsidian.PluginSettingTab {
|
||||
constructor(app, plugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display() {
|
||||
var containerEl = this.containerEl;
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl("h2", { text: "Embedded-Omnisearch Settings" });
|
||||
|
||||
new obsidian.Setting(containerEl)
|
||||
.setName("Results per page")
|
||||
.setDesc("Default pageSize value. A code block with pageSize: ... overrides this setting.")
|
||||
.addText((text) => {
|
||||
var commitPageSize = async () => {
|
||||
var normalized = clampPageSize(text.getValue());
|
||||
if (text.getValue() !== String(normalized)) text.setValue(String(normalized));
|
||||
if (this.plugin.settings.pageSize === normalized) return;
|
||||
this.plugin.settings.pageSize = normalized;
|
||||
await this.plugin.saveSettings();
|
||||
};
|
||||
|
||||
text.setPlaceholder(String(DEFAULT_SETTINGS.pageSize));
|
||||
text.setValue(String(this.plugin.settings.pageSize));
|
||||
text.inputEl.type = "number";
|
||||
text.inputEl.min = "1";
|
||||
text.inputEl.max = "100";
|
||||
text.inputEl.step = "1";
|
||||
text.inputEl.addEventListener("change", commitPageSize);
|
||||
text.inputEl.addEventListener("blur", commitPageSize);
|
||||
});
|
||||
|
||||
var colorSetting = new obsidian.Setting(containerEl)
|
||||
.setName("Highlight color")
|
||||
.setDesc("Color used to highlight matching terms.");
|
||||
|
||||
if (typeof colorSetting.addColorPicker === "function") {
|
||||
colorSetting.addColorPicker((picker) => {
|
||||
picker.setValue(this.plugin.settings.highlightColor);
|
||||
picker.onChange(async (value) => {
|
||||
var normalized = normalizeHexColor(value);
|
||||
if (normalized !== value) picker.setValue(normalized);
|
||||
if (this.plugin.settings.highlightColor === normalized) return;
|
||||
this.plugin.settings.highlightColor = normalized;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
colorSetting.addText((text) => {
|
||||
text.setValue(this.plugin.settings.highlightColor);
|
||||
text.inputEl.type = "color";
|
||||
text.inputEl.addEventListener("change", async () => {
|
||||
var normalized = normalizeHexColor(text.getValue());
|
||||
text.setValue(normalized);
|
||||
if (this.plugin.settings.highlightColor === normalized) return;
|
||||
this.plugin.settings.highlightColor = normalized;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var opacitySetting = new obsidian.Setting(containerEl)
|
||||
.setName("Highlight opacity")
|
||||
.setDesc("Opacity of the highlight background from 0% to 100%.");
|
||||
|
||||
if (typeof opacitySetting.addSlider === "function") {
|
||||
opacitySetting.addSlider((slider) => {
|
||||
slider.setLimits(0, 100, 5);
|
||||
slider.setValue(Math.round(this.plugin.settings.highlightOpacity * 100));
|
||||
if (typeof slider.setDynamicTooltip === "function") slider.setDynamicTooltip();
|
||||
slider.onChange(async (value) => {
|
||||
var normalized = clampOpacity(value / 100);
|
||||
if (this.plugin.settings.highlightOpacity === normalized) return;
|
||||
this.plugin.settings.highlightOpacity = normalized;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
opacitySetting.addText((text) => {
|
||||
var commitOpacity = async () => {
|
||||
var normalized = clampOpacity(parseFloat(text.getValue()) / 100);
|
||||
var displayValue = String(Math.round(normalized * 100));
|
||||
if (text.getValue() !== displayValue) text.setValue(displayValue);
|
||||
if (this.plugin.settings.highlightOpacity === normalized) return;
|
||||
this.plugin.settings.highlightOpacity = normalized;
|
||||
await this.plugin.saveSettings();
|
||||
};
|
||||
|
||||
text.setPlaceholder(String(Math.round(DEFAULT_SETTINGS.highlightOpacity * 100)));
|
||||
text.setValue(String(Math.round(this.plugin.settings.highlightOpacity * 100)));
|
||||
text.inputEl.type = "number";
|
||||
text.inputEl.min = "0";
|
||||
text.inputEl.max = "100";
|
||||
text.inputEl.step = "5";
|
||||
text.inputEl.addEventListener("change", commitOpacity);
|
||||
text.inputEl.addEventListener("blur", commitOpacity);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EmbeddedOmnisearchPlugin;
|
||||
9
manifest.json
Normal file
9
manifest.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"id": "embedded-omnisearch",
|
||||
"name": "Embedded-Omnisearch",
|
||||
"version": "1.1.0",
|
||||
"minAppVersion": "1.0.0",
|
||||
"description": "Inline search UI powered by Omnisearch, with configurable page size, highlight color, and highlight opacity.",
|
||||
"author": "obsidian-snippets",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
216
styles.css
Normal file
216
styles.css
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
/* === Embedded Omnisearch – Plugin Styles === */
|
||||
|
||||
/* --- Wrapper --- */
|
||||
.eo-wrap {
|
||||
margin: 16px 0 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--font-interface);
|
||||
}
|
||||
|
||||
/* --- Input --- */
|
||||
.eo-input-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.eo-wrap input.omnisearch-input-field {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 44px 10px 12px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-normal);
|
||||
font-size: 0.8rem;
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.eo-wrap input.omnisearch-input-field:focus,
|
||||
.eo-wrap input.omnisearch-input-field.has-value {
|
||||
box-shadow: inset 0 0 0 3px var(--background-modifier-border-hover) !important;
|
||||
}
|
||||
|
||||
.eo-wrap input.omnisearch-input-field::selection {
|
||||
background: var(--text-selection);
|
||||
}
|
||||
|
||||
/* --- Clear button --- */
|
||||
.eo-clear {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.eo-clear:hover {
|
||||
color: var(--text-normal);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* --- Status --- */
|
||||
.eo-status {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- Results --- */
|
||||
.eo-results {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* --- Results Table --- */
|
||||
.eo-results-table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.eo-results-table,
|
||||
.eo-results-table thead,
|
||||
.eo-results-table tbody,
|
||||
.eo-results-table tr,
|
||||
.eo-results-table th,
|
||||
.eo-results-table td,
|
||||
.eo-results-table colgroup,
|
||||
.eo-results-table col {
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.eo-col-file {
|
||||
width: 24%;
|
||||
}
|
||||
|
||||
.eo-col-score {
|
||||
width: 8%;
|
||||
}
|
||||
|
||||
.eo-col-preview {
|
||||
width: 68%;
|
||||
}
|
||||
|
||||
.eo-results-head-cell,
|
||||
.eo-results-cell {
|
||||
padding: 8px 10px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.eo-results-head-cell {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 8px;
|
||||
text-align: left;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.eo-results-row:hover,
|
||||
.eo-results-row:hover .eo-results-cell {
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.eo-results-file,
|
||||
.eo-results-preview {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.eo-results-link {
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.eo-results-link:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.eo-results-score,
|
||||
.eo-results-head-score {
|
||||
text-align: left;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.eo-results-score {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.eo-results-preview {
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- Pagination --- */
|
||||
.eo-page-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.eo-page-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.eo-page-btn:hover:not(:disabled) {
|
||||
border-color: var(--background-modifier-border-hover);
|
||||
}
|
||||
|
||||
.eo-page-info {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* --- Popover --- */
|
||||
.eo-popover {
|
||||
position: fixed;
|
||||
padding: 4px 10px;
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
max-width: 500px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
Loading…
Reference in a new issue