diff --git a/main.js b/main.js index 0506a3f..b8c71d8 100644 --- a/main.js +++ b/main.js @@ -1,92 +1,234 @@ -const obsidian = require('obsidian'); -const { Plugin, ItemView, Modal, Menu, Notice, addIcon, TFolder, TextComponent, normalizePath } = obsidian; +const obsidian = require("obsidian"); +const { + Plugin, + ItemView, + Modal, + Menu, + Notice, + addIcon, + TFolder, + TextComponent, + normalizePath, + sanitizeHTMLToDom, +} = obsidian; const ICON_ID = "scoped-search"; const VIEW_TYPE = "scoped-search-view"; function listAllFolders(app) { - const res = []; const root = app.vault.getRoot(); - const walk = (folder) => { for (const child of folder.children) if (child instanceof TFolder) { res.push(child); walk(child); } }; - walk(root); return { root, folders: res }; + const res = []; + const root = app.vault.getRoot(); + const walk = (folder) => { + for (const child of folder.children) + if (child instanceof TFolder) { + res.push(child); + walk(child); + } + }; + walk(root); + return { root, folders: res }; } class FolderBrowseModal extends Modal { - constructor(app, onChoose){ super(app); this.onChoose = onChoose; this.activeTab = "list"; this.query = ""; this.collapsed = new Set(); this.setTitle("Choose a folder"); } - onOpen(){ - const { contentEl } = this; contentEl.empty(); contentEl.addClass("scoped-folder-picker"); + constructor(app, onChoose) { + super(app); + this.onChoose = onChoose; + this.activeTab = "list"; + this.query = ""; + this.collapsed = new Set(); + this.setTitle("Choose a folder"); + } + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("scoped-folder-picker"); const header = contentEl.createDiv({ cls: "sfp-header" }); const tabs = header.createDiv({ cls: "sfp-tabs" }); - this.tabList = tabs.createDiv({ cls: "sfp-tab active" }); this.tabList.setText("List"); - this.tabTree = tabs.createDiv({ cls: "sfp-tab" }); this.tabTree.setText("Tree"); - this.tabList.addEventListener("click", () => { this.activeTab="list"; this.refreshTabs(); this.renderBody(); }); - this.tabTree.addEventListener("click", () => { this.activeTab="tree"; this.refreshTabs(); this.renderBody(); }); + this.tabList = tabs.createDiv({ cls: "sfp-tab active" }); + this.tabList.setText("List"); + this.tabTree = tabs.createDiv({ cls: "sfp-tab" }); + this.tabTree.setText("Tree"); + this.tabList.addEventListener("click", () => { + this.activeTab = "list"; + this.refreshTabs(); + this.renderBody(); + }); + this.tabTree.addEventListener("click", () => { + this.activeTab = "tree"; + this.refreshTabs(); + this.renderBody(); + }); const searchWrap = header.createDiv({ cls: "sfp-search" }); - this.search = new TextComponent(searchWrap); this.search.setPlaceholder("Filter folders…"); - this.search.inputEl.addEventListener("input", () => { this.query = this.search.getValue().trim().toLowerCase(); this.renderBody(); }); + this.search = new TextComponent(searchWrap); + this.search.setPlaceholder("Filter folders…"); + this.search.inputEl.addEventListener("input", () => { + this.query = this.search.getValue().trim().toLowerCase(); + this.renderBody(); + }); this.body = contentEl.createDiv({ cls: "sfp-body" }); - const { root, folders } = listAllFolders(this.app); this.rootFolder = root; this.allFolders = folders; - this.collapsed = new Set(["/"]); for (const f of this.allFolders) this.collapsed.add(f.path || "/"); - this.refreshTabs(); this.renderBody(); + const { root, folders } = listAllFolders(this.app); + this.rootFolder = root; + this.allFolders = folders; + this.collapsed = new Set(["/"]); + for (const f of this.allFolders) this.collapsed.add(f.path || "/"); + this.refreshTabs(); + this.renderBody(); } - refreshTabs(){ this.tabList.toggleClass("active", this.activeTab==="list"); this.tabTree.toggleClass("active", this.activeTab==="tree"); } - matches(folder){ if (!this.query) return true; const p = (folder?.path || "/").toLowerCase(); return p.includes(this.query); } - renderBody(){ this.body.empty(); if (this.activeTab==="list") this.renderList(); else this.renderTree(); } - renderList(){ - const rootRow = this.body.createDiv({ cls: "sfp-list-item sfp-root" }); rootRow.setText("/"); rootRow.addEventListener("click", ()=> this.choose("/")); - const items = this.allFolders.filter(f=>this.matches(f)).sort((a,b)=>a.path.localeCompare(b.path)); - for (const f of items){ const row = this.body.createDiv({ cls:"sfp-list-item" }); row.setText(f.path); row.addEventListener("click", ()=> this.choose(f.path || "/")); } + refreshTabs() { + this.tabList.toggleClass("active", this.activeTab === "list"); + this.tabTree.toggleClass("active", this.activeTab === "tree"); } - renderTree(){ + matches(folder) { + if (!this.query) return true; + const p = (folder?.path || "/").toLowerCase(); + return p.includes(this.query); + } + renderBody() { + this.body.empty(); + if (this.activeTab === "list") this.renderList(); + else this.renderTree(); + } + renderList() { + const rootRow = this.body.createDiv({ cls: "sfp-list-item sfp-root" }); + rootRow.setText("/"); + rootRow.addEventListener("click", () => this.choose("/")); + const items = this.allFolders + .filter((f) => this.matches(f)) + .sort((a, b) => a.path.localeCompare(b.path)); + for (const f of items) { + const row = this.body.createDiv({ cls: "sfp-list-item" }); + row.setText(f.path); + row.addEventListener("click", () => this.choose(f.path || "/")); + } + } + renderTree() { const treeWrap = this.body.createDiv({ cls: "sfp-tree" }); const buildNode = (folder, ul) => { - const children = folder.children.filter(c => c instanceof TFolder); + const children = folder.children.filter((c) => c instanceof TFolder); const visibleChildren = []; - for (const ch of children){ const cm = this.matches(ch); const hd = this.query ? this.hasMatchingDescendant(ch) : true; if (!this.query || cm || hd) visibleChildren.push(ch); } - const thisVisible = !this.query || this.matches(folder) || visibleChildren.length>0; - if (!thisVisible && folder!==this.rootFolder) return; - const li = ul.createEl("li"); const row = li.createDiv({ cls:"row" }); - const key = folder===this.rootFolder ? "/" : (folder.path || "/"); const folded = this.collapsed.has(key); - const toggle = row.createSpan({ cls:"sfp-toggle" }); toggle.setText(visibleChildren.length>0 ? (folded?"▶":"▼") : "•"); - const name = row.createSpan({ cls:"sfp-folder-name" }); name.setText(folder===this.rootFolder?"/":(folder.path || "/")); if (folder===this.rootFolder) name.addClass("sfp-root"); - row.addEventListener("click", (e)=>{ const isToggle = (e.target===toggle); if (visibleChildren.length>0 && isToggle){ if (this.collapsed.has(key)) this.collapsed.delete(key); else this.collapsed.add(key); this.renderBody(); } else { this.choose(key); }}); - if (visibleChildren.length>0 && !folded){ const childUL = li.createEl("ul"); for (const ch of visibleChildren) buildNode(ch, childUL); } + for (const ch of children) { + const cm = this.matches(ch); + const hd = this.query ? this.hasMatchingDescendant(ch) : true; + if (!this.query || cm || hd) visibleChildren.push(ch); + } + const thisVisible = + !this.query || this.matches(folder) || visibleChildren.length > 0; + if (!thisVisible && folder !== this.rootFolder) return; + const li = ul.createEl("li"); + const row = li.createDiv({ cls: "row" }); + const key = folder === this.rootFolder ? "/" : folder.path || "/"; + const folded = this.collapsed.has(key); + const toggle = row.createSpan({ cls: "sfp-toggle" }); + toggle.setText(visibleChildren.length > 0 ? (folded ? "▶" : "▼") : "•"); + const name = row.createSpan({ cls: "sfp-folder-name" }); + name.setText(folder === this.rootFolder ? "/" : folder.path || "/"); + if (folder === this.rootFolder) name.addClass("sfp-root"); + row.addEventListener("click", (e) => { + const isToggle = e.target === toggle; + if (visibleChildren.length > 0 && isToggle) { + if (this.collapsed.has(key)) this.collapsed.delete(key); + else this.collapsed.add(key); + this.renderBody(); + } else { + this.choose(key); + } + }); + if (visibleChildren.length > 0 && !folded) { + const childUL = li.createEl("ul"); + for (const ch of visibleChildren) buildNode(ch, childUL); + } }; - const ul = treeWrap.createEl("ul"); buildNode(this.rootFolder, ul); + const ul = treeWrap.createEl("ul"); + buildNode(this.rootFolder, ul); + } + hasMatchingDescendant(folder) { + for (const ch of folder.children) { + if (ch instanceof TFolder) { + if (this.matches(ch)) return true; + if (this.hasMatchingDescendant(ch)) return true; + } + } + return false; + } + choose(path) { + try { + this.onChoose && this.onChoose(path); + } finally { + this.close(); + } } - hasMatchingDescendant(folder){ for (const ch of folder.children){ if (ch instanceof TFolder){ if (this.matches(ch)) return true; if (this.hasMatchingDescendant(ch)) return true; } } return false; } - choose(path){ try{ this.onChoose && this.onChoose(path); } finally { this.close(); } } } -function createUI(app, containerEl, config){ - const state = { folders:[...config.folders], active:new Set(), index:[], searchTimer:null, selectedIdx:-1, includeNonMd:!!config.includeNonMd, allowedExts:(config.allowedExts||"mp3,wav,flac").toLowerCase(), dom:{} }; - const preset = (config.sessionPreset||[]).filter(f=>state.folders.includes(f)); - if (preset.length) preset.forEach(f=>state.active.add(f)); else for (const f of state.folders) if ((config.defaults && config.defaults[f]) !== false) state.active.add(f); +function createUI(app, containerEl, config) { + const state = { + folders: [...config.folders], + active: new Set(), + index: [], + searchTimer: null, + selectedIdx: -1, + includeNonMd: !!config.includeNonMd, + allowedExts: (config.allowedExts || "mp3,wav,flac").toLowerCase(), + dom: {}, + }; + const preset = (config.sessionPreset || []).filter((f) => + state.folders.includes(f), + ); + if (preset.length) preset.forEach((f) => state.active.add(f)); + else + for (const f of state.folders) + if ((config.defaults && config.defaults[f]) !== false) + state.active.add(f); - containerEl.empty(); containerEl.addClass("scoped-search-modal"); containerEl.addClass("chips-" + (config.chipSize || "medium")); - const inputWrap = containerEl.createDiv({ cls:"scoped-search-input" }); - state.dom.input = new TextComponent(inputWrap); state.dom.input.setPlaceholder("Search in selected folders…"); - const iconSpan = inputWrap.createSpan({ attr:{ style:"margin-left:6px;opacity:.7" } }); obsidian.setIcon(iconSpan, "search"); + containerEl.empty(); + containerEl.addClass("scoped-search-modal"); + containerEl.addClass("chips-" + (config.chipSize || "medium")); + const inputWrap = containerEl.createDiv({ cls: "scoped-search-input" }); + state.dom.input = new TextComponent(inputWrap); + state.dom.input.setPlaceholder("Search in selected folders…"); + const iconSpan = inputWrap.createSpan({ cls: "scoped-search-icon" }); + obsidian.setIcon(iconSpan, "search"); // Clear (x) button inside the search input - const clearBtn = inputWrap.createSpan({ cls:"scoped-input-clear", attr:{ "aria-label":"Clear search", role:"button", title:"Clear search" } }); + const clearBtn = inputWrap.createSpan({ + cls: "scoped-input-clear", + attr: { + "aria-label": "Clear search", + role: "button", + title: "Clear search", + }, + }); // Show/hide the clear button based on input content - const toggleClearBtn = () => { const has = !!(state.dom.input && state.dom.input.getValue && state.dom.input.getValue()); clearBtn.toggleClass("is-disabled", !has); }; - if (state.dom.input && state.dom.input.onChange) state.dom.input.onChange(toggleClearBtn); - if (state.dom.input && state.dom.input.inputEl) state.dom.input.inputEl.addEventListener("input", toggleClearBtn); + const toggleClearBtn = () => { + const has = !!( + state.dom.input && + state.dom.input.getValue && + state.dom.input.getValue() + ); + clearBtn.toggleClass("is-disabled", !has); + }; + if (state.dom.input && state.dom.input.onChange) + state.dom.input.onChange(toggleClearBtn); + if (state.dom.input && state.dom.input.inputEl) + state.dom.input.inputEl.addEventListener("input", toggleClearBtn); toggleClearBtn(); obsidian.setIcon(clearBtn, "x"); - clearBtn.addEventListener("click", (e)=>{ + clearBtn.addEventListener("click", (e) => { e.preventDefault(); state.dom.input.setValue(""); state.selectedIdx = -1; runSearch(); - if (typeof toggleClearBtn === 'function') toggleClearBtn(); + if (typeof toggleClearBtn === "function") toggleClearBtn(); state.dom.input.inputEl.focus(); }); - state.dom.helpDot = inputWrap.createSpan({ cls:"scoped-help-dot", text:"?" }); - let helpPop = null; let helpHover = false; let dotHover = false; + state.dom.helpDot = inputWrap.createSpan({ + cls: "scoped-help-dot", + text: "?", + }); + let helpPop = null; + let helpHover = false; + let dotHover = false; const helpHTML = ` -
"find example help search" = exact phrase search."find+example+help+search" = all four words anywhere."find|example" = either word anywhere."find example help search" = exact phrase search."find+example+help+search" = all four words anywhere. find example "help&search" = both find and example anywhere, and both help & search anywhere."find|example" = either word anywhere.Notes: non‑Markdown files match by filename only; Markdown matches filename and content. Search is case‑insensitive.
+Notes: non‑Markdown files match by filename only; Markdown matches filename and content. Search is case‑insensitive.
`; -const dirCard = containerEl.createDiv({ cls:"scoped-card" }); - new obsidian.Setting(dirCard).setName("Directories").setDesc("Add one or more folders to scope your searches.") - .addButton((btn)=> btn.setButtonText("Add folder…").setCta().onClick(()=>{ - const modal = new FolderBrowseModal(this.app, (folderPath)=>{ - if (!folderPath) return; - const dirs = this.plugin.settings.directories; - if (dirs.includes(folderPath)) { new obsidian.Notice("Folder already added."); return; } - dirs.push(folderPath); - this.plugin.settings.defaultSelected = this.plugin.settings.defaultSelected || {}; - this.plugin.settings.defaultSelected[folderPath] = true; - this.plugin.saveSettings(); - this.display(); - }); modal.open(); - })); - const list = dirCard.createDiv({ cls:"scoped-folder-list" }); - if (!this.plugin.settings.directories || this.plugin.settings.directories.length===0){ list.createEl("p", { text:"No folders added yet." }); } - else { - for (const dir of this.plugin.settings.directories){ - const row = new obsidian.Setting(list).setName(dir).setDesc("Selected by default"); - row.addToggle((tg)=>{ - const def = (this.plugin.settings.defaultSelected && this.plugin.settings.defaultSelected[dir]); + const helpFrag = sanitizeHTMLToDom(helpHtml); + help.appendChild(helpFrag); + const dirCard = containerEl.createDiv({ cls: "scoped-card" }); + new obsidian.Setting(dirCard) + .setName("Directories") + .setDesc("Add one or more folders to scope your searches.") + .addButton((btn) => + btn + .setButtonText("Add folder…") + .setCta() + .onClick(() => { + const modal = new FolderBrowseModal(this.app, (folderPath) => { + if (!folderPath) return; + const dirs = this.plugin.settings.directories; + if (dirs.includes(folderPath)) { + new obsidian.Notice("Folder already added."); + return; + } + dirs.push(folderPath); + this.plugin.settings.defaultSelected = + this.plugin.settings.defaultSelected || {}; + this.plugin.settings.defaultSelected[folderPath] = true; + this.plugin.saveSettings(); + this.display(); + }); + modal.open(); + }), + ); + const list = dirCard.createDiv({ cls: "scoped-folder-list" }); + if ( + !this.plugin.settings.directories || + this.plugin.settings.directories.length === 0 + ) { + list.createEl("p", { text: "No folders added yet." }); + } else { + for (const dir of this.plugin.settings.directories) { + const row = new obsidian.Setting(list) + .setName(dir) + .setDesc("Selected by default"); + row.addToggle((tg) => { + const def = + this.plugin.settings.defaultSelected && + this.plugin.settings.defaultSelected[dir]; tg.setValue(def !== false); - tg.onChange((v)=>{ this.plugin.settings.defaultSelected = this.plugin.settings.defaultSelected || {}; this.plugin.settings.defaultSelected[dir] = v; this.plugin.saveSettings(); }); + tg.onChange((v) => { + this.plugin.settings.defaultSelected = + this.plugin.settings.defaultSelected || {}; + this.plugin.settings.defaultSelected[dir] = v; + this.plugin.saveSettings(); + }); }); - row.addExtraButton((btn)=> btn.setIcon("folder").setTooltip("Browse to replace").onClick(()=>{ - const modal = new FolderBrowseModal(this.app, (folderPath)=>{ - if (!folderPath) return; - const i = this.plugin.settings.directories.indexOf(dir); - if (i>=0){ - const prev = (this.plugin.settings.defaultSelected && this.plugin.settings.defaultSelected[dir]); - this.plugin.settings.directories[i] = folderPath; - this.plugin.settings.defaultSelected = this.plugin.settings.defaultSelected || {}; - this.plugin.settings.defaultSelected[folderPath] = (prev !== false); - if (this.plugin.settings.defaultSelected[dir] !== undefined) delete this.plugin.settings.defaultSelected[dir]; - } - this.plugin.saveSettings(); this.display(); - }); modal.open(); - })).addExtraButton((btn)=> btn.setIcon("trash").setTooltip("Remove").onClick(()=>{ - this.plugin.settings.directories = this.plugin.settings.directories.filter((d)=> d !== dir); - if (this.plugin.settings.defaultSelected) delete this.plugin.settings.defaultSelected[dir]; - this.plugin.saveSettings(); this.display(); - })); + row + .addExtraButton((btn) => + btn + .setIcon("folder") + .setTooltip("Browse to replace") + .onClick(() => { + const modal = new FolderBrowseModal(this.app, (folderPath) => { + if (!folderPath) return; + const i = this.plugin.settings.directories.indexOf(dir); + if (i >= 0) { + const prev = + this.plugin.settings.defaultSelected && + this.plugin.settings.defaultSelected[dir]; + this.plugin.settings.directories[i] = folderPath; + this.plugin.settings.defaultSelected = + this.plugin.settings.defaultSelected || {}; + this.plugin.settings.defaultSelected[folderPath] = + prev !== false; + if (this.plugin.settings.defaultSelected[dir] !== undefined) + delete this.plugin.settings.defaultSelected[dir]; + } + this.plugin.saveSettings(); + this.display(); + }); + modal.open(); + }), + ) + .addExtraButton((btn) => + btn + .setIcon("trash") + .setTooltip("Remove") + .onClick(() => { + this.plugin.settings.directories = + this.plugin.settings.directories.filter((d) => d !== dir); + if (this.plugin.settings.defaultSelected) + delete this.plugin.settings.defaultSelected[dir]; + this.plugin.saveSettings(); + this.display(); + }), + ); // Reorder buttons (stacked up/down) const moveWrap = row.controlEl.createDiv({ cls: "scoped-move-wrap" }); const upBtn = moveWrap.createEl("button", { cls: "scoped-move-btn" }); obsidian.setIcon(upBtn, "chevron-up"); - upBtn.setAttr("aria-label","Move up"); - upBtn.addEventListener("click", ()=>{ + upBtn.setAttr("aria-label", "Move up"); + upBtn.addEventListener("click", () => { const idx = this.plugin.settings.directories.indexOf(dir); - if (idx > 0){ + if (idx > 0) { const dirs = this.plugin.settings.directories; - [dirs[idx-1], dirs[idx]] = [dirs[idx], dirs[idx-1]]; - this.plugin.saveSettings(); this.display(); + [dirs[idx - 1], dirs[idx]] = [dirs[idx], dirs[idx - 1]]; + this.plugin.saveSettings(); + this.display(); } }); const downBtn = moveWrap.createEl("button", { cls: "scoped-move-btn" }); obsidian.setIcon(downBtn, "chevron-down"); - downBtn.setAttr("aria-label","Move down"); - downBtn.addEventListener("click", ()=>{ + downBtn.setAttr("aria-label", "Move down"); + downBtn.addEventListener("click", () => { const idx = this.plugin.settings.directories.indexOf(dir); const dirs = this.plugin.settings.directories; - if (idx >= 0 && idx < dirs.length - 1){ - [dirs[idx], dirs[idx+1]] = [dirs[idx+1], dirs[idx]]; - this.plugin.saveSettings(); this.display(); + if (idx >= 0 && idx < dirs.length - 1) { + [dirs[idx], dirs[idx + 1]] = [dirs[idx + 1], dirs[idx]]; + this.plugin.saveSettings(); + this.display(); } }); - } } - containerEl.createEl("h3", { text:"File Types" }); - new obsidian.Setting(containerEl).setName("Include non-Markdown files").setDesc("Search filenames for non-Markdown files (e.g., MP3). Content search remains MD-only.") - .addToggle((tg)=> tg.setValue(!!this.plugin.settings.includeNonMd).onChange((v)=>{ this.plugin.settings.includeNonMd = v; this.plugin.saveSettings(); })); - new obsidian.Setting(containerEl).setName("Allowed extensions").setDesc("Comma-separated list (no dots). Example: mp3,wav,flac,pdf,png,jpg") - .addText((txt)=>{ txt.setPlaceholder("mp3,wav,flac"); txt.setValue(this.plugin.settings.allowedExts || "mp3,wav,flac"); txt.onChange((v)=>{ this.plugin.settings.allowedExts = v; this.plugin.saveSettings(); }); }); + new obsidian.Setting(containerEl).setName("File types").setHeading(); + new obsidian.Setting(containerEl) + .setName("Include non-Markdown files") + .setDesc( + "Search filenames for non-Markdown files (e.g., MP3). Content search remains MD-only.", + ) + .addToggle((tg) => + tg.setValue(!!this.plugin.settings.includeNonMd).onChange((v) => { + this.plugin.settings.includeNonMd = v; + this.plugin.saveSettings(); + }), + ); + new obsidian.Setting(containerEl) + .setName("Allowed extensions") + .setDesc( + "Comma-separated list (no dots). Example: mp3,wav,flac,pdf,png,jpg", + ) + .addText((txt) => { + txt.setPlaceholder("mp3,wav,flac"); + txt.setValue(this.plugin.settings.allowedExts || "mp3,wav,flac"); + txt.onChange((v) => { + this.plugin.settings.allowedExts = v; + this.plugin.saveSettings(); + }); + }); } } module.exports = class ScopedSearchPlugin extends Plugin { - async onload(){ - addIcon(ICON_ID, ''); - const saved = await this.loadData(); - const _saved = await this.loadData(); const _defaults = { directories:[], uiMode:"modal", inlinePreview:true, previewHeight:420 }; + async onload() { + addIcon( + ICON_ID, + '', + ); + const _saved = await this.loadData(); + const _defaults = { + directories: [], + uiMode: "modal", + inlinePreview: true, + previewHeight: 420, + }; this.settings = Object.assign({}, _defaults, _saved || {}); - this.registerView(VIEW_TYPE, (leaf)=> new ScopedSearchView(leaf, this)); - -// Ribbon: single click handler, supports ctrl/cmd/shift to force Tab in Modal view -(function(){ - const ribbon = this.addRibbonIcon("scan-search", "Open Scoped Search", (e) => { - if (e && e.button === 2) { return; } const mode = (this.settings.uiMode || "modal"); - const forceTab = !!(e && (e.ctrlKey || e.metaKey || e.shiftKey)); - if (forceTab && mode === "modal") { if (e){ e.preventDefault(); e.stopPropagation(); } this.openSearchTab(); return; } - if (mode === "tab") this.openSearchTab(); else this.openSearchModal(); - }); - ribbon.addClass("scoped-search-ribbon"); - // Extra safety: capture-phase listener attached ONCE (no stacking) to handle modifiers if callback has no event - this.registerDomEvent(ribbon, "click", (e)=>{ if (e && e.button !== 0) { return; } - const mode = (this.settings.uiMode || "modal"); - const forceTab = !!(e && (e.ctrlKey || e.metaKey || e.shiftKey)); - if (forceTab && mode === "modal") { e.preventDefault(); e.stopPropagation(); this.openSearchTab(); } - }, {capture:true}); - // Right-click context menu on ribbon icon - ribbon.addEventListener("contextmenu", (e)=>{ - e.preventDefault(); e.stopPropagation(); e.preventDefault(); - const menu = new obsidian.Menu(); - menu.addItem(mi=> mi.setTitle("Open in new tab").onClick(()=> this.openSearchTab())); - menu.addItem(mi=> mi.setTitle("Open modal").onClick(()=> this.openSearchModal())); - menu.showAtMouseEvent(e); - }); -}).call(this); + this.registerView(VIEW_TYPE, (leaf) => new ScopedSearchView(leaf, this)); - - this.addCommand({ id:"open-scoped-search", name:"Open Scoped Search", callback: ()=>{ - const mode = (this.settings.uiMode || "modal"); - if (mode === "tab") this.openSearchTab(); - else this.openSearchModal(); - }}); + // Ribbon: single click handler, supports ctrl/cmd/shift to force Tab in Modal view + (function () { + const ribbon = this.addRibbonIcon( + "scan-search", + "Open Scoped Search", + (e) => { + if (e && e.button === 2) { + return; + } + const mode = this.settings.uiMode || "modal"; + const forceTab = !!(e && (e.ctrlKey || e.metaKey || e.shiftKey)); + if (forceTab && mode === "modal") { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + this.openSearchTab(); + return; + } + if (mode === "tab") this.openSearchTab(); + else this.openSearchModal(); + }, + ); + ribbon.addClass("scoped-search-ribbon"); + // Extra safety: capture-phase listener attached ONCE (no stacking) to handle modifiers if callback has no event + this.registerDomEvent( + ribbon, + "click", + (e) => { + if (e && e.button !== 0) { + return; + } + const mode = this.settings.uiMode || "modal"; + const forceTab = !!(e && (e.ctrlKey || e.metaKey || e.shiftKey)); + if (forceTab && mode === "modal") { + e.preventDefault(); + e.stopPropagation(); + this.openSearchTab(); + } + }, + { capture: true }, + ); + // Right-click context menu on ribbon icon + ribbon.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + e.preventDefault(); + const menu = new obsidian.Menu(); + menu.addItem((mi) => + mi.setTitle("Open in new tab").onClick(() => this.openSearchTab()), + ); + menu.addItem((mi) => + mi.setTitle("Open modal").onClick(() => this.openSearchModal()), + ); + menu.showAtMouseEvent(e); + }); + }).call(this); + + this.addCommand({ + id: "open-scoped-search", + name: "Open Scoped Search", + callback: () => { + const mode = this.settings.uiMode || "modal"; + if (mode === "tab") this.openSearchTab(); + else this.openSearchModal(); + }, + }); this.addSettingTab(new ScopedSearchSettingTab(this.app, this)); } - -async openSearch(){ - if (!this.settings.directories || this.settings.directories.length===0){ new obsidian.Notice("Add at least one folder in settings to use Scoped Search."); return; } - const mode = (this.settings.uiMode || "modal"); - if (mode === "tab") { - return this.openSearchTab(); - } else if (mode === "modal") { - // Left-click default is modal - return this.openSearchModal(); - } else { - return this.openSearchModal(); - } -} -async openSearchModal(){ - if (!this.settings.directories || this.settings.directories.length===0){ new obsidian.Notice("Add at least one folder in settings to use Scoped Search."); return; } - const modal = new obsidian.Modal(this.app); modal.setTitle("Scoped Search"); modal.containerEl && modal.containerEl.classList && modal.containerEl.classList.add('scoped-modal');modal.containerEl && modal.containerEl.classList && modal.containerEl.classList.add('scoped-modal');let ui = null; - modal.onOpen = ()=>{ - const preset = (this.settings.sessionActive && this.settings.sessionActive.modal) || []; - ui = createUI(this.app, modal.contentEl, { plugin:this, previewHeight:this.settings?.previewHeight || 420, plugin:this, folders:this.settings.directories, includeNonMd:this.settings.includeNonMd, allowedExts:this.settings.allowedExts, defaults:this.settings.defaultSelected, openTarget:this.settings.openTarget, chipSize:this.settings.chipSize, mode:"modal", sessionPreset:preset, onSessionSave:(mode,active)=>{ this.settings.sessionActive = this.settings.sessionActive || {}; this.settings.sessionActive[mode] = active; this.saveSettings(); } }); - }; - modal.onClose = ()=>{ ui && ui.destroy(); ui = null; }; - modal.open(); -} -async openSearchTab(){ - if (!this.settings.directories || this.settings.directories.length===0){ new obsidian.Notice("Add at least one folder in settings to use Scoped Search."); return; } - const leaf = this.app.workspace.getLeaf(true); - await leaf.setViewState({ type: VIEW_TYPE, active: true }); - this.app.workspace.revealLeaf(leaf); -} -async saveSettings(){ await this.saveData(this.settings); } + async openSearch() { + if (!this.settings.directories || this.settings.directories.length === 0) { + new obsidian.Notice( + "Add at least one folder in settings to use Scoped Search.", + ); + return; + } + const mode = this.settings.uiMode || "modal"; + if (mode === "tab") { + return this.openSearchTab(); + } else if (mode === "modal") { + // Left-click default is modal + return this.openSearchModal(); + } else { + return this.openSearchModal(); + } + } + + async openSearchModal() { + if (!this.settings.directories || this.settings.directories.length === 0) { + new obsidian.Notice( + "Add at least one folder in settings to use Scoped Search.", + ); + return; + } + const modal = new obsidian.Modal(this.app); + modal.setTitle("Scoped Search"); + modal.containerEl && + modal.containerEl.classList && + modal.containerEl.classList.add("scoped-modal"); + modal.containerEl && + modal.containerEl.classList && + modal.containerEl.classList.add("scoped-modal"); + let ui = null; + modal.onOpen = () => { + const preset = + (this.settings.sessionActive && this.settings.sessionActive.modal) || + []; + ui = createUI(this.app, modal.contentEl, { + plugin: this, + previewHeight: this.settings?.previewHeight || 420, + plugin: this, + folders: this.settings.directories, + includeNonMd: this.settings.includeNonMd, + allowedExts: this.settings.allowedExts, + defaults: this.settings.defaultSelected, + openTarget: this.settings.openTarget, + chipSize: this.settings.chipSize, + mode: "modal", + sessionPreset: preset, + onSessionSave: (mode, active) => { + this.settings.sessionActive = this.settings.sessionActive || {}; + this.settings.sessionActive[mode] = active; + this.saveSettings(); + }, + }); + }; + modal.onClose = () => { + ui && ui.destroy(); + ui = null; + }; + modal.open(); + } + async openSearchTab() { + if (!this.settings.directories || this.settings.directories.length === 0) { + new obsidian.Notice( + "Add at least one folder in settings to use Scoped Search.", + ); + return; + } + const leaf = this.app.workspace.getLeaf(true); + await leaf.setViewState({ type: VIEW_TYPE, active: true }); + this.app.workspace.revealLeaf(leaf); + } + async saveSettings() { + await this.saveData(this.settings); + } }; diff --git a/manifest.json b/manifest.json index 7cb2a58..6224249 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,9 @@ -{ - "id": "scoped-search", - "name": "Scoped Search", - "version": "2.0.7", - "minAppVersion": "1.5.0", - "description": "Scoped Search is a robust search modal/tab that only searches within the folders you choose. Includes enhanced folder picker by List or Tree, configurable extensions, and syntax support.", - "author": "Ragetrip", - "authorUrl": "https://github.com/ragetrip", - "fundingUrl": "https://buymeacoffee.com/ragetrip", - "isDesktopOnly": false -} \ No newline at end of file +{"id":"scoped-search", +"name":"Scoped Search", +"version":"2.1.1", +"minAppVersion":"1.5.0", +"description":"A robust search modal/tab that only searches within the folders you choose. Includes an enhanced folder picker by List or Tree, configurable extensions, and syntax support.", +"author":"Ragetrip", +"authorUrl":"https://github.com/ragetrip", +"fundingUrl":"https://buymeacoffee.com/ragetrip", +"isDesktopOnly":false} \ No newline at end of file diff --git a/styles.css b/styles.css index 83a6800..c0e1099 100644 --- a/styles.css +++ b/styles.css @@ -1,30 +1,116 @@ - -.scoped-search-modal { padding: 8px 12px; } -.scoped-search-input { width: 100%; } -.scoped-chipbar { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; } -.scoped-chip { border:1px solid var(--background-modifier-border); border-radius:999px; padding:2px 8px; cursor:pointer; user-select:none; } -.scoped-chip.active { background: var(--interactive-accent); color: var(--text-on-accent); } -.scoped-chip.select-all { font-weight: 600; } -.scoped-results { margin-top: 8px; max-height: 55vh; overflow: auto; } -.scoped-result-item { padding:6px 4px; border-bottom:1px solid var(--background-modifier-border); } -.scoped-result-item:hover, .scoped-result-item.is-selected { background: var(--background-modifier-hover); } -.scoped-snippet { opacity:.8; font-size:.9em; } +.scoped-search-modal { + padding: 8px 12px; +} +.scoped-search-input { + width: 100%; +} +.scoped-chipbar { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} +.scoped-chip { + border: 1px solid var(--background-modifier-border); + border-radius: 999px; + padding: 2px 8px; + cursor: pointer; + user-select: none; +} +.scoped-chip.active { + background: var(--interactive-accent); + color: var(--text-on-accent); +} +.scoped-chip.select-all { + font-weight: 600; +} +.scoped-results { + margin-top: 8px; + max-height: 55vh; + overflow: auto; +} +.scoped-result-item { + padding: 6px 4px; + border-bottom: 1px solid var(--background-modifier-border); +} +.scoped-result-item:hover, +.scoped-result-item.is-selected { + background: var(--background-modifier-hover); +} +.scoped-snippet { + opacity: 0.8; + font-size: 0.9em; +} /* picker */ -.scoped-folder-picker { min-width: 520px; } -.sfp-header { display:flex; gap:8px; align-items:center; margin-bottom:8px; } -.sfp-tabs { display:flex; gap:6px; } -.sfp-tab { padding:4px 8px; border:1px solid var(--background-modifier-border); border-radius:8px; cursor:pointer; } -.sfp-tab.active { background: var(--interactive-accent); color: var(--text-on-accent); } -.sfp-search { flex:1; } -.sfp-body { border:1px solid var(--background-modifier-border); border-radius:10px; max-height: 50vh; overflow:auto; padding:6px; } -.sfp-list-item { padding:4px 6px; border-radius:6px; cursor:pointer; } -.sfp-list-item:hover { background: var(--background-modifier-hover); } -.sfp-tree ul { list-style:none; padding-left: 16px; margin: 4px 0; } -.sfp-tree .row { display:flex; gap:6px; align-items:center; padding:2px 6px; border-radius:6px; cursor:pointer; } -.sfp-tree .row:hover { background: var(--background-modifier-hover); } -.sfp-toggle { width: 1em; text-align:center; opacity: .8; cursor:pointer; } -.sfp-folder-name { flex: 1; } -.sfp-root { font-weight: 600; } +.scoped-folder-picker { + min-width: 520px; +} +.sfp-header { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 8px; +} +.sfp-tabs { + display: flex; + gap: 6px; +} +.sfp-tab { + padding: 4px 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + cursor: pointer; +} +.sfp-tab.active { + background: var(--interactive-accent); + color: var(--text-on-accent); +} +.sfp-search { + flex: 1; +} +.sfp-body { + border: 1px solid var(--background-modifier-border); + border-radius: 10px; + max-height: 50vh; + overflow: auto; + padding: 6px; +} +.sfp-list-item { + padding: 4px 6px; + border-radius: 6px; + cursor: pointer; +} +.sfp-list-item:hover { + background: var(--background-modifier-hover); +} +.sfp-tree ul { + list-style: none; + padding-left: 16px; + margin: 4px 0; +} +.sfp-tree .row { + display: flex; + gap: 6px; + align-items: center; + padding: 2px 6px; + border-radius: 6px; + cursor: pointer; +} +.sfp-tree .row:hover { + background: var(--background-modifier-hover); +} +.sfp-toggle { + width: 1em; + text-align: center; + opacity: 0.8; + cursor: pointer; +} +.sfp-folder-name { + flex: 1; +} +.sfp-root { + font-weight: 600; +} .scoped-card { border: 1px solid var(--background-modifier-border); @@ -35,12 +121,23 @@ } /* chip size presets */ -.scoped-search-modal.chips-small .scoped-chip { font-size: 0.8em; padding: 1px 6px; } -.scoped-search-modal.chips-medium .scoped-chip { font-size: 0.9em; padding: 2px 8px; } -.scoped-search-modal.chips-large .scoped-chip { font-size: 1.05em; padding: 3px 10px; } +.scoped-search-modal.chips-small .scoped-chip { + font-size: 0.8em; + padding: 1px 6px; +} +.scoped-search-modal.chips-medium .scoped-chip { + font-size: 0.9em; + padding: 2px 8px; +} +.scoped-search-modal.chips-large .scoped-chip { + font-size: 1.05em; + padding: 3px 10px; +} /* helper popover styling */ -.scoped-search-input { position: relative; } +.scoped-search-input { + position: relative; +} .scoped-help-pop { position: absolute; z-index: 9999; @@ -56,97 +153,225 @@ font-size: 0.95em; line-height: 1.5; } -.scoped-help-pop ul { margin-top: 6px; margin-bottom: 0; } -.scoped-help-pop li { margin: 6px 0; } +.scoped-help-pop ul { + margin-top: 6px; + margin-bottom: 0; +} +.scoped-help-pop li { + margin: 6px 0; +} .scoped-help-pop code { background: var(--background-modifier-form-field); border: 1px solid var(--background-modifier-border); border-radius: 6px; padding: 0 6px; font-family: var(--font-monospace); - font-size: .95em; + font-size: 0.95em; } .scoped-help-dot { display: inline-flex; align-items: center; justify-content: center; - width: 17px; height: 17px; + width: 17px; + height: 17px; border-radius: 50%; border: 1px solid var(--background-modifier-border); margin-left: 6px; font-size: 11px; - opacity: .9; + opacity: 0.9; cursor: default; } -.scoped-help-dot:hover { background: var(--background-modifier-hover); } +.scoped-help-dot:hover { + background: var(--background-modifier-hover); +} /* stacked move buttons for directories */ -.scoped-move-wrap{ - display:inline-flex; flex-direction:column; gap:4px; margin-left:6px; +.scoped-move-wrap { + display: inline-flex; + flex-direction: column; + gap: 4px; + margin-left: 6px; } -.scoped-move-btn{ - width:22px; height:22px; display:inline-flex; align-items:center; justify-content:center; - border:1px solid var(--background-modifier-border); +.scoped-move-btn { + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--background-modifier-border); background: var(--background-modifier-form-field); - border-radius:6px; padding:0; + border-radius: 6px; + padding: 0; +} +.scoped-move-btn:hover { + background: var(--background-modifier-hover); +} +.scoped-move-btn svg { + width: 14px; + height: 14px; } -.scoped-move-btn:hover{ background: var(--background-modifier-hover); } -.scoped-move-btn svg{ width:14px; height:14px; } - /* Settings - Search Syntax helper tweaks */ -.scoped-syntax-card { font-size: 0.95em; } /* slightly smaller */ -.scoped-syntax-card h3 { margin-bottom: 6px; } -.scoped-syntax-card .scoped-syntax-list { margin: 6px 0 0 8px; padding-left: 18px; } -.scoped-syntax-card .scoped-syntax-list li { margin: 6px 0; } +.scoped-syntax-card { + font-size: 0.95em; +} /* slightly smaller */ +.scoped-syntax-card h3 { + margin-bottom: 6px; +} +.scoped-syntax-card .scoped-syntax-list { + margin: 6px 0 0 8px; + padding-left: 18px; +} +.scoped-syntax-card .scoped-syntax-list li { + margin: 6px 0; +} /* Scoped Search: small icon chip for Add Folder */ -.scoped-chip.scoped-chip-icon{ - display:inline-flex; - align-items:center; - justify-content:center; - padding:2px 6px; - gap:4px; +.scoped-chip.scoped-chip-icon { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 6px; + gap: 4px; +} +.scoped-chip-icon-inner svg { + width: 14px; + height: 14px; } -.scoped-chip-icon-inner svg{ width:14px; height:14px; } - /* Full Height Tab View */ -.view-content .scoped-search-modal{ display:flex; flex-direction:column; height:100%; } -.view-content .scoped-results{ flex:1 1 auto; overflow:auto; max-height:none !important; height:auto; min-height:0; } - - +.view-content .scoped-search-modal { + display: flex; + flex-direction: column; + height: 100%; +} +.view-content .scoped-results { + flex: 1 1 auto; + overflow: auto; + max-height: none !important; + height: auto; + min-height: 0; +} /* Clear (x) button in the search input */ -.scoped-search-input{ position: relative; } -.scoped-search-input .scoped-input-clear{ +.scoped-search-input { + position: relative; +} +.scoped-search-input .scoped-input-clear { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); cursor: pointer; - opacity: .65; + opacity: 0.65; +} +.scoped-search-input .scoped-input-clear:hover { + opacity: 1; +} +.scoped-search-input .scoped-input-clear svg { + width: 14px; + height: 14px; +} +.scoped-search-input input[type="text"] { + padding-right: 26px; } -.scoped-search-input .scoped-input-clear:hover{ opacity: 1; } -.scoped-search-input .scoped-input-clear svg{ width: 14px; height: 14px; } -.scoped-search-input input[type="text"]{ padding-right: 26px; } - - /* inline clear button placement (leave it in for now) */ -.scoped-search-input{ display:flex; align-items:center; gap:6px; position: relative; } -.scoped-search-input input[type="text"]{ flex:1 1 auto; padding-right: 0 !important; } -.scoped-search-input .scoped-input-clear{ position: static !important; order: 2; cursor: pointer; opacity:.7; } -.scoped-search-input .scoped-input-clear:hover{ opacity: 1; } - -.scoped-search-input > span:not(.scoped-input-clear){ order: 3; } /* the search icon */ -.scoped-search-input .scoped-input-clear svg{ width:14px; height:14px; } - +.scoped-search-input { + display: flex; + align-items: center; + gap: 6px; + position: relative; +} +.scoped-search-input input[type="text"] { + flex: 1 1 auto; + padding-right: 0 !important; +} +.scoped-search-input .scoped-input-clear { + position: static !important; + order: 2; + cursor: pointer; + opacity: 0.7; +} +.scoped-search-input .scoped-input-clear:hover { + opacity: 1; +} +.scoped-search-input > span:not(.scoped-input-clear) { + order: 3; +} /* the search icon */ +.scoped-search-input .scoped-input-clear svg { + width: 14px; + height: 14px; +} /* Clear button: always visible; disable when empty */ -.scoped-search-input .scoped-input-clear.is-disabled{ - opacity: .35; +.scoped-search-input .scoped-input-clear.is-disabled { + opacity: 0.35; pointer-events: none; } +/* Scoped Search: icon span next to input */ +.scoped-search-icon { + margin-left: 6px; + opacity: 0.7; +} + +/* Quick syntax title inside popover */ +.scoped-help-quick-title { + font-weight: 600; + margin-bottom: 4px; +} + +/* Syntax helper note paragraph */ +.scoped-syntax-note { + margin: 8px 0 0 0; + opacity: 0.85; +} + +/* Result path line under snippet */ +.scoped-result-path { + opacity: 0.6; + font-size: 0.85em; +} + + +/* Highlight result that has an open inline preview */ +.scoped-result-item.has-inline-preview { + border-left: 3px solid var(--text-accent); + padding-left: 6px; +} + +/* Inline preview container styling (border separator / groove card) - 8 votes groove, 3 votes solid */ +.scoped-inline-preview { + border: 3px groove var(--background-modifier-border); + margin-top: 6px; + padding: 6px; +} + +/* Highlighted terms within inline preview */ +.scoped-inline-preview mark { + background-color: var(--text-highlight-bg); +} + +/* Each individual hit snippet inside a result */ +.scoped-snippet-hit { + padding: 2px 0; + margin: 2px 0; + border-bottom: 1px solid var(--background-modifier-border); +} +.scoped-snippet-hit:last-child { + border-bottom: none; +} + +/* Only the highlighted term looks clickable */ +.scoped-snippet-hit mark { + cursor: pointer; +} + +/* Active selected hit mark (snippet + preview) */ +.scoped-snippet-hit mark.is-active-hit, +.scoped-inline-preview mark.is-active-hit { + background-color: #3399ff; + color: var(--background-primary); +}