diff --git a/common.ts b/common.ts index 143774f..1df8325 100644 --- a/common.ts +++ b/common.ts @@ -28,10 +28,13 @@ export interface LoomSettings { n: number; showSettings: boolean; + showSearchBar: boolean; showNodeBorders: boolean; showExport: boolean; } +export type SearchResultState = "result" | "ancestor" | "none" | null; + export interface Node { text: string; parentId: string | null; @@ -39,11 +42,13 @@ export interface Node { unread: boolean; bookmarked: boolean; lastVisited?: number; + searchResultState: SearchResultState; } export interface NoteState { current: string; hoisted: string[]; + searchTerm: string; nodes: Record; generating: string | null; } diff --git a/main.ts b/main.ts index d4f3f83..84ac54d 100644 --- a/main.ts +++ b/main.ts @@ -5,7 +5,14 @@ import { loomEditorPluginSpec, MakePromptFromPassagesModal, } from './views'; -import { PROVIDERS, Provider, LoomSettings, Node, NoteState } from './common'; +import { + PROVIDERS, + Provider, + LoomSettings, + SearchResultState, + Node, + NoteState +} from './common'; import { App, @@ -69,6 +76,7 @@ const DEFAULT_SETTINGS: LoomSettings = { n: 5, showSettings: false, + showSearchBar: false, showNodeBorders: false, showExport: false, }; @@ -153,6 +161,7 @@ export default class LoomPlugin extends Plugin { collapsed: false, unread, bookmarked: false, + searchResultState: null, }; return [id, node]; } @@ -162,6 +171,7 @@ export default class LoomPlugin extends Plugin { this.state[file.path] = { current: rootId, hoisted: [] as string[], + searchTerm: "", nodes: { [rootId]: root }, generating: null, }; @@ -582,6 +592,7 @@ export default class LoomPlugin extends Plugin { this.state[view.file.path] = { current, hoisted: [] as string[], + searchTerm: "", nodes: { [current]: node }, generating: null, }; @@ -920,6 +931,45 @@ export default class LoomPlugin extends Plugin { ) ); + this.registerEvent( + // @ts-expect-error + this.app.workspace.on("loom:search", (term: string) => this.withFile((file) => { + const state = this.state[file.path]; + + this.state[file.path].searchTerm = term; + if (!term) { + Object.keys(state.nodes).forEach((id) => { + this.state[file.path].nodes[id].searchResultState = null; + }); + this.save(); // don't re-render + return; + } + + const matches = Object.entries(state.nodes) + .filter(([, node]) => node.text.toLowerCase().includes(term.toLowerCase())) + .map(([id]) => id); + + let ancestors: string[] = []; + for (const id of matches) { + let parentId = state.nodes[id].parentId; + while (parentId !== null) { + ancestors.push(parentId); + parentId = state.nodes[parentId].parentId; + } + } + + Object.keys(state.nodes).forEach((id) => { + let searchResultState: SearchResultState; + if (matches.includes(id)) searchResultState = "result"; + else if (ancestors.includes(id)) searchResultState = "ancestor"; + else searchResultState = "none"; + this.state[file.path].nodes[id].searchResultState = searchResultState; + }); + + this.save(); + })) + ); + this.registerEvent( // @ts-expect-error this.app.workspace.on("loom:import", (path: string) => diff --git a/manifest.json b/manifest.json index 3f89ff5..2fa0c08 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "loom", "name": "Loom", - "version": "1.14.2", + "version": "1.15.0", "minAppVersion": "0.15.0", "description": "Loom in Obsidian", "author": "celeste", diff --git a/package.json b/package.json index 9b627bc..fe1e93a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-loom", - "version": "1.14.2", + "version": "1.15.0", "description": "Loom in Obsidian", "main": "main.js", "scripts": { diff --git a/styles.css b/styles.css index f67a0f7..8b404ab 100644 --- a/styles.css +++ b/styles.css @@ -61,7 +61,14 @@ body { flex-grow: 1; } .loom__alt-export-field button { - margin-left: 0.5em; + margin-left: 0.6em; +} + +/* search bar */ + +.loom__search-bar { + width: 100%; + margin-bottom: 0.8em; } /* settings */ @@ -131,6 +138,19 @@ body { margin-right: -0.3em; } +.loom__node-search-result { + background-color: rgba(255, 255, 0, 0.1); +} +.loom__node-search-result.is-active { + background-color: rgba(255, 255, 0, 0.2); +} +.loom__node-search-result:hover { + background-color: rgba(255, 255, 0, 0.2) !important; +} +.loom__node-search-result:not(:hover) { + padding-right: 0.3em; +} + .loom__node-unread { font-weight: bold !important; } diff --git a/views.ts b/views.ts index 9079a24..67320c5 100644 --- a/views.ts +++ b/views.ts @@ -15,6 +15,8 @@ export class LoomView extends ItemView { getNoteState: () => NoteState | null; getSettings: () => LoomSettings; + tree: HTMLElement; + constructor( leaf: WorkspaceLeaf, getNoteState: () => NoteState | null, @@ -42,6 +44,7 @@ export class LoomView extends ItemView { this.renderNavButtons(settings); const container = this.containerEl.createDiv({ cls: "outline" }); if (settings.showExport) this.renderAltExportInterface(container); + if (settings.showSearchBar) this.renderSearchBar(container, state); if (settings.showSettings) this.renderSettings(container, settings); if (!state) { @@ -49,7 +52,8 @@ export class LoomView extends ItemView { return; } this.renderBookmarks(container, state); - this.renderTree(container, state); + this.tree = container.createDiv(); + this.renderTree(this.tree, state); this.containerEl.scrollTop = scroll; } @@ -81,6 +85,12 @@ export class LoomView extends ItemView { "settings", "Show settings" ); + settingNavButton( + "showSearchBar", + settings.showSearchBar, + "search", + "Show search bar" + ); settingNavButton( "showNodeBorders", settings.showNodeBorders, @@ -142,6 +152,19 @@ export class LoomView extends ItemView { }); } + renderSearchBar(container: HTMLElement, state: NoteState | null) { + const searchBar = container.createEl("input", { + cls: "loom__search-bar", + value: state?.searchTerm || "", + attr: { type: "text", placeholder: "Search..." }, + }); + searchBar.addEventListener("input", () => { + const state = this.getNoteState(); + this.app.workspace.trigger("loom:search", searchBar.value); + if (state) this.renderTree(this.tree, state); + }); + } + renderSettings(container: HTMLElement, settings: LoomSettings) { const settingsContainer = container.createDiv({ cls: "loom__settings" }); @@ -230,12 +253,18 @@ export class LoomView extends ItemView { } renderTree(container: HTMLElement, state: NoteState) { + container.empty(); + const treeHeader = container.createDiv({ cls: "tree-item-self loom__tree-header" }); + let headerText; + if (state.searchTerm) headerText = "Search results"; + else if (state.hoisted.length > 0) headerText = "Hoisted node"; + else headerText = "All nodes"; treeHeader.createSpan({ cls: "tree-item-inner loom__tree-header-text", - text: state.hoisted.length > 0 ? "Hoisted node" : "All nodes" + text: headerText, }); if (state.hoisted.length > 0) @@ -257,6 +286,8 @@ export class LoomView extends ItemView { ) { const node = state.nodes[id]; + if (inTree && node.searchResultState === "none") return; + const branchContainer = container.createDiv({}); const nodeContainer = branchContainer.createDiv({ @@ -264,6 +295,8 @@ export class LoomView extends ItemView { attr: { id: inTree ? `loom__node-${id}` : null }, }); if (id === state.current) nodeContainer.addClass("is-active"); + if (node.searchResultState === "result") + nodeContainer.addClass("loom__node-search-result"); if (node.unread) nodeContainer.addClass("loom__node-unread"); const children = Object.entries(state.nodes)