First commit

This commit is contained in:
2025-06-21 13:22:54 +02:00
commit 42df9465cf
75 changed files with 14291 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts"></script>
<span class="suggestion-flair" aria-label="Not created yet, select to create"
><svg viewBox="0 0 100 100" class="add-note-glyph" width="16" height="16"
><path
fill="currentColor"
stroke="currentColor"
d="M23.3,6.7c-3.7,0-6.7,3-6.7,6.7v73.3c0,3.7,3,6.7,6.7,6.7h28.4c-3.2-4.8-5.1-10.5-5.1-16.7c0-16.6,13.4-30,30-30 c2.3,0,4.5,0.3,6.7,0.8V31.7c0-0.9-0.3-1.7-1-2.4L60.7,7.6c-0.6-0.6-1.5-1-2.4-1L23.3,6.7z M56.7,13L77,33.3H60 c-1.8,0-3.3-1.5-3.3-3.3L56.7,13z M76.7,53.3c-12.9,0-23.3,10.4-23.3,23.3S63.8,100,76.7,100S100,89.6,100,76.7 S89.6,53.3,76.7,53.3z M76.7,63.3c1.8,0,3.3,1.5,3.3,3.3v6.7h6.7c1.8,0,3.3,1.5,3.3,3.3c0,1.8-1.5,3.3-3.3,3.3H80v6.7 c0,1.8-1.5,3.3-3.3,3.3c-1.8,0-3.3-1.5-3.3-3.3V80h-6.7c-1.8,0-3.3-1.5-3.3-3.3s1.5-3.3,3.3-3.3h6.7v-6.7 C73.3,64.8,74.8,63.3,76.7,63.3L76.7,63.3z" /></svg
></span>
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts">
import { debounce, Platform } from 'obsidian'
import { toggleInputComposition } from '../globals'
import { createEventDispatcher, tick } from 'svelte'
import type LocatorPlugin from '../main'
import { wait } from '../tools/utils'
export let initialValue = ''
export let placeholder = ''
export let plugin: LocatorPlugin
let initialSet = false
let value = ''
let elInput: HTMLInputElement
const dispatch = createEventDispatcher()
export function setInputValue(v: string): void {
value = v
}
function watchInitialValue(v: string): void {
if (v && !initialSet && !value) {
initialSet = true
value = v
selectInput()
}
}
$: watchInitialValue(initialValue)
function selectInput(_?: HTMLElement): void {
tick()
.then(async () => {
if (Platform.isMobileApp) await wait(200)
elInput.focus()
return tick()
})
.then(async () => {
if (Platform.isMobileApp) await wait(200)
elInput.select()
})
}
const debouncedOnInput = debounce(() => {
// If typing a query and not executing it,
// the next time we open the modal, the search field will be empty
plugin.searchHistory.addToHistory('')
dispatch('input', value)
}, 300)
</script>
<div class="omnisearch-input-container">
<div class="omnisearch-input-field">
<input
bind:this="{elInput}"
bind:value="{value}"
class="prompt-input"
on:compositionend="{_ => toggleInputComposition(false)}"
on:compositionstart="{_ => toggleInputComposition(true)}"
on:input="{debouncedOnInput}"
placeholder="{placeholder}"
spellcheck="false"
type="text"
use:selectInput />
</div>
<slot />
</div>
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts">
</script>
<div class="prompt-results" on:mousedown={e => e.preventDefault()}>
<slot />
</div>
+223
View File
@@ -0,0 +1,223 @@
<script lang="ts">
import InputSearch from './InputSearch.svelte'
import {
Action,
eventBus,
excerptAfter,
type ResultNote,
type SearchMatch,
} from '../globals'
import { getCtrlKeyLabel, loopIndex } from '../tools/utils'
import { onDestroy, onMount, tick } from 'svelte'
import { MarkdownView, Platform } from 'obsidian'
import ModalContainer from './ModalContainer.svelte'
import {
LocatorInFileModal,
LocatorVaultModal,
} from '../components/modals'
import ResultItemInFile from './ResultItemInFile.svelte'
import { Query } from '../search/query'
import { openNote } from '../tools/notes'
import type LocatorPlugin from '../main'
export let plugin: LocatorPlugin
export let modal: LocatorInFileModal
export let parent: LocatorVaultModal | null = null
export let singleFilePath = ''
export let previousQuery: string | undefined
let searchQuery: string
let groupedOffsets: number[] = []
let selectedIndex = 0
let note: ResultNote | undefined
let query: Query
$: searchQuery = previousQuery ?? ''
onMount(() => {
eventBus.enable('infile')
eventBus.on('infile', Action.Enter, openSelection)
eventBus.on('infile', Action.OpenInNewPane, openSelectionInNewTab)
eventBus.on('infile', Action.ArrowUp, () => moveIndex(-1))
eventBus.on('infile', Action.ArrowDown, () => moveIndex(1))
eventBus.on('infile', Action.Tab, switchToVaultModal)
})
onDestroy(() => {
eventBus.disable('infile')
})
$: (async () => {
if (searchQuery) {
query = new Query(searchQuery, {
ignoreDiacritics: plugin.settings.ignoreDiacritics,
ignoreArabicDiacritics: plugin.settings.ignoreArabicDiacritics,
})
note =
(
await plugin.searchEngine.getSuggestions(query, {
singleFilePath,
})
)[0] ?? null
}
selectedIndex = 0
await scrollIntoView()
})()
$: {
if (note) {
let groups = getGroups(note.matches)
// If there are quotes in the search,
// only show results that match at least one of the quotes
const exactTerms = query.getExactTerms()
if (exactTerms.length) {
groups = groups.filter(group =>
exactTerms.every(exact =>
group.some(match => match.match.includes(exact))
)
)
}
groupedOffsets = groups.map(group => Math.round(group.first()!.offset))
}
}
/**
* Group together close matches to reduce the number of results
*/
function getGroups(matches: SearchMatch[]): SearchMatch[][] {
const groups: SearchMatch[][] = []
let lastOffset = -1
let count = 0 // Avoid infinite loops
while (++count < 100) {
const group = getGroupedMatches(matches, lastOffset, excerptAfter)
if (!group.length) break
lastOffset = group.last()!.offset
groups.push(group)
}
return groups
}
function getGroupedMatches(
matches: SearchMatch[],
offsetFrom: number,
maxLen: number
): SearchMatch[] {
const first = matches.find(m => m.offset > offsetFrom)
if (!first) return []
return matches.filter(
m => m.offset > offsetFrom && m.offset <= first.offset + maxLen
)
}
function moveIndex(dir: 1 | -1): void {
selectedIndex = loopIndex(selectedIndex + dir, groupedOffsets.length)
scrollIntoView()
}
async function scrollIntoView(): Promise<void> {
await tick()
const elem = document.querySelector(`[data-result-id="${selectedIndex}"]`)
elem?.scrollIntoView({ behavior: 'auto', block: 'nearest' })
}
async function openSelectionInNewTab(): Promise<void> {
return openSelection(true)
}
async function openSelection(newTab = false): Promise<void> {
if (note) {
modal.close()
if (parent) parent.close()
// Open (or switch focus to) the note
const reg = plugin.textProcessor.stringsToRegex(note.foundWords)
reg.exec(note.content)
await openNote(plugin.app, note, reg.lastIndex, newTab)
// Move cursor to the match
const view = plugin.app.workspace.getActiveViewOfType(MarkdownView)
if (!view) {
// Not an editable document, so no cursor to place
return
// throw new Error('OmniSearch - No active MarkdownView')
}
const offset = groupedOffsets[selectedIndex] ?? 0
const pos = view.editor.offsetToPos(offset)
pos.ch = 0
view.editor.setCursor(pos)
view.editor.scrollIntoView({
from: { line: pos.line - 10, ch: 0 },
to: { line: pos.line + 10, ch: 0 },
})
}
}
function switchToVaultModal(): void {
new LocatorVaultModal(plugin, searchQuery ?? previousQuery).open()
modal.close()
}
</script>
<InputSearch
plugin="{plugin}"
on:input="{e => (searchQuery = e.detail)}"
placeholder="Locator - File"
initialValue="{previousQuery}">
<div class="omnisearch-input-container__buttons">
{#if Platform.isMobile}
<button on:click="{switchToVaultModal}">Vault search</button>
{/if}
</div>
</InputSearch>
<ModalContainer>
{#if groupedOffsets.length && note}
{#each groupedOffsets as offset, i}
<ResultItemInFile
{plugin}
offset="{offset}"
note="{note}"
index="{i}"
selected="{i === selectedIndex}"
on:mousemove="{_e => (selectedIndex = i)}"
on:click="{evt => openSelection(evt.ctrlKey)}"
on:auxclick="{evt => {
if (evt.button == 1) openSelection(true)
}}" />
{/each}
{:else}
<div style="text-align: center;">
We found 0 results for your search here.
</div>
{/if}
</ModalContainer>
<div class="prompt-instructions">
<div class="prompt-instruction">
<span class="prompt-instruction-command">↑↓</span><span>to navigate</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">↵</span><span>to open</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">tab</span>
<span>to switch to Vault Search</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">esc</span>
{#if !!parent}
<span>to go back to Vault Search</span>
{:else}
<span>to close</span>
{/if}
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{getCtrlKeyLabel()} ↵</span>
<span>to open in a new pane</span>
</div>
</div>
+428
View File
@@ -0,0 +1,428 @@
<script lang="ts">
import { MarkdownView, Notice, Platform, TFile } from 'obsidian'
import { onDestroy, onMount, tick } from 'svelte'
import InputSearch from './InputSearch.svelte'
import ModalContainer from './ModalContainer.svelte'
import {
eventBus,
indexingStep,
IndexingStepType,
type ResultNote,
SPACE_OR_PUNCTUATION,
Action,
} from '../globals'
import { createNote, openNote } from '../tools/notes'
import {
getCtrlKeyLabel,
getAltKeyLabel,
getExtension,
isFilePDF,
loopIndex,
} from '../tools/utils'
import {
LocatorInFileModal,
type LocatorVaultModal,
} from '../components/modals'
import ResultItemVault from './ResultItemVault.svelte'
import { Query } from '../search/query'
import { cancelable, CancelablePromise } from 'cancelable-promise'
import { debounce } from 'lodash-es'
import type LocatorPlugin from '../main'
import LazyLoader from './lazy-loader/LazyLoader.svelte'
let {
modal,
previousQuery,
plugin,
}: {
modal: LocatorVaultModal
previousQuery?: string | undefined
plugin: LocatorPlugin
} = $props()
let selectedIndex = $state(0)
let historySearchIndex = 0
let searchQuery = $state(previousQuery ?? '')
let resultNotes: ResultNote[] = $state([])
let query: Query
let indexingStepDesc = $state('')
let searching = $state(true)
let refInput: InputSearch | undefined
let openInNewPaneKey: string = $state('')
let openInCurrentPaneKey: string = $state('')
let createInNewPaneKey: string = $state('')
let createInCurrentPaneKey: string = $state('')
let openInNewLeafKey: string = `${getCtrlKeyLabel()} ${getAltKeyLabel()} ↵`
const selectedNote = $derived(resultNotes[selectedIndex])
$effect(() => {
if (plugin.settings.openInNewPane) {
openInNewPaneKey = '↵'
openInCurrentPaneKey = getCtrlKeyLabel() + ' ↵'
createInNewPaneKey = 'Shift ↵'
createInCurrentPaneKey = getCtrlKeyLabel() + ' Shift ↵'
} else {
openInNewPaneKey = getCtrlKeyLabel() + ' ↵'
openInCurrentPaneKey = '↵'
createInNewPaneKey = getCtrlKeyLabel() + ' Shift ↵'
createInCurrentPaneKey = 'Shift ↵'
}
})
$effect(() => {
if (searchQuery) {
updateResultsDebounced()
} else {
searching = false
resultNotes = []
}
})
$effect(() => {
switch ($indexingStep) {
case IndexingStepType.LoadingCache:
indexingStepDesc = 'Loading cache...'
break
case IndexingStepType.ReadingFiles:
indexingStepDesc = 'Reading files...'
break
case IndexingStepType.IndexingFiles:
indexingStepDesc = 'Indexing files...'
break
case IndexingStepType.WritingCache:
updateResultsDebounced()
indexingStepDesc = 'Updating cache...'
break
default:
updateResultsDebounced()
indexingStepDesc = ''
break
}
})
onMount(async () => {
eventBus.enable('vault')
eventBus.on('vault', Action.Enter, openNoteAndCloseModal)
eventBus.on('vault', Action.OpenInBackground, openNoteInBackground)
eventBus.on('vault', Action.CreateNote, createNoteAndCloseModal)
eventBus.on('vault', Action.OpenInNewPane, openNoteInNewPane)
eventBus.on('vault', Action.InsertLink, insertLink)
eventBus.on('vault', Action.Tab, switchToInFileModal)
eventBus.on('vault', Action.ArrowUp, () => moveIndex(-1))
eventBus.on('vault', Action.ArrowDown, () => moveIndex(1))
eventBus.on('vault', Action.PrevSearchHistory, prevSearchHistory)
eventBus.on('vault', Action.NextSearchHistory, nextSearchHistory)
eventBus.on('vault', Action.OpenInNewLeaf, openNoteInNewLeaf)
await plugin.notesIndexer.refreshIndex()
await updateResultsDebounced()
})
onDestroy(() => {
eventBus.disable('vault')
})
async function prevSearchHistory() {
// Filter out the empty string, if it's there
const history = (await plugin.searchHistory.getHistory()).filter(s => s)
if (++historySearchIndex >= history.length) {
historySearchIndex = 0
}
searchQuery = history[historySearchIndex]
refInput?.setInputValue(searchQuery ?? '')
}
async function nextSearchHistory() {
const history = (await plugin.searchHistory.getHistory()).filter(s => s)
if (--historySearchIndex < 0) {
historySearchIndex = history.length ? history.length - 1 : 0
}
searchQuery = history[historySearchIndex]
refInput?.setInputValue(searchQuery ?? '')
}
let cancelableQuery: CancelablePromise<ResultNote[]> | null = null
async function updateResults() {
searching = true
// If search is already in progress, cancel it and start a new one
if (cancelableQuery) {
cancelableQuery.cancel()
cancelableQuery = null
}
query = new Query(searchQuery, {
ignoreDiacritics: plugin.settings.ignoreDiacritics,
ignoreArabicDiacritics: plugin.settings.ignoreArabicDiacritics,
})
cancelableQuery = cancelable(
new Promise(resolve => {
resolve(plugin.searchEngine.getSuggestions(query))
})
)
resultNotes = await cancelableQuery
selectedIndex = 0
await scrollIntoView()
searching = false
}
// Debounce this function to avoid multiple calls caused by Svelte reactivity
const updateResultsDebounced = debounce(updateResults, 0)
function onClick(evt?: MouseEvent | KeyboardEvent) {
if (!selectedNote) return
if (evt?.ctrlKey) {
openNoteInNewPane()
} else {
openNoteAndCloseModal()
}
modal.close()
}
function openNoteAndCloseModal(): void {
if (!selectedNote) return
openSearchResult(selectedNote)
modal.close()
}
function openNoteInBackground(): void {
if (!selectedNote) return
openSearchResult(selectedNote, true)
}
function openNoteInNewPane(): void {
if (!selectedNote) return
openSearchResult(selectedNote, true)
modal.close()
}
function openNoteInNewLeaf(): void {
if (!selectedNote) return
openSearchResult(selectedNote, true, true)
modal.close()
}
function saveCurrentQuery() {
if (searchQuery) {
plugin.searchHistory.addToHistory(searchQuery)
}
}
function openSearchResult(
note: ResultNote,
newPane = false,
newLeaf = false
) {
saveCurrentQuery()
const offset = note.matches?.[0]?.offset ?? 0
openNote(plugin.app, note, offset, newPane, newLeaf)
}
async function onClickCreateNote(_e: MouseEvent) {
await createNoteAndCloseModal()
}
async function createNoteAndCloseModal(opt?: {
newLeaf: boolean
}): Promise<void> {
if (searchQuery) {
try {
await createNote(plugin.app, searchQuery, opt?.newLeaf)
} catch (e) {
new Notice((e as Error).message)
return
}
modal.close()
}
}
function insertLink(): void {
if (!selectedNote) return
const file = plugin.app.vault
.getMarkdownFiles()
.find(f => f.path === selectedNote.path)
const active = plugin.app.workspace.getActiveFile()
const view = plugin.app.workspace.getActiveViewOfType(MarkdownView)
if (!view?.editor) {
new Notice('Locator - Error - No active editor', 3000)
return
}
// Generate link
let link: string
if (file && active) {
link = plugin.app.fileManager.generateMarkdownLink(
file,
active.path,
'',
selectedNote.displayTitle
)
} else {
const maybeDisplayTitle =
selectedNote.displayTitle === '' ? '' : `|${selectedNote.displayTitle}`
link = `[[${selectedNote.basename}.${getExtension(
selectedNote.path
)}${maybeDisplayTitle}]]`
}
// Inject link
const cursor = view.editor.getCursor()
view.editor.replaceRange(link, cursor, cursor)
cursor.ch += link.length
view.editor.setCursor(cursor)
modal.close()
}
function switchToInFileModal(): void {
// Do nothing if the selectedNote is a PDF,
// or if there is 0 match (e.g indexing in progress)
if (
selectedNote &&
(isFilePDF(selectedNote?.path) || !selectedNote?.matches.length)
) {
return
}
saveCurrentQuery()
modal.close()
if (selectedNote) {
// Open in-file modal for selected search result
const file = plugin.app.vault.getAbstractFileByPath(selectedNote.path)
if (file && file instanceof TFile) {
new LocatorInFileModal(plugin, file, searchQuery).open()
}
} else {
// Open in-file modal for active file
const view = plugin.app.workspace.getActiveViewOfType(MarkdownView)
if (view?.file) {
new LocatorInFileModal(plugin, view.file, searchQuery).open()
}
}
}
function moveIndex(dir: 1 | -1): void {
selectedIndex = loopIndex(selectedIndex + dir, resultNotes.length)
scrollIntoView()
}
async function scrollIntoView(): Promise<void> {
await tick()
if (selectedNote) {
const elem = activeWindow.document.querySelector(
`[data-result-id="${selectedNote.path}"]`
)
elem?.scrollIntoView({ behavior: 'auto', block: 'nearest' })
}
}
</script>
<InputSearch
bind:this={refInput}
{plugin}
initialValue={searchQuery}
on:input={e => (searchQuery = e.detail)}
placeholder="Locator - Vault">
<div class="omnisearch-input-container__buttons">
{#if plugin.settings.showCreateButton}
<button on:click={onClickCreateNote}>Create note</button>
{/if}
{#if Platform.isMobile}
<button on:click={switchToInFileModal}>In-File search</button>
{/if}
</div>
</InputSearch>
{#if indexingStepDesc}
<div style="text-align: center; color: var(--text-accent); margin-top: 10px">
⏳ Work in progress: {indexingStepDesc}
</div>
{/if}
<ModalContainer>
{#each resultNotes as result, i}
<LazyLoader
height={100}
offset={500}
keep={true}
fadeOption={{ delay: 0, duration: 0 }}>
<ResultItemVault
{plugin}
selected={i === selectedIndex}
note={result}
on:mousemove={_ => (selectedIndex = i)}
on:click={onClick}
on:auxclick={evt => {
if (evt.button == 1) openNoteInNewPane()
}} />
</LazyLoader>
{/each}
<div style="text-align: center;">
{#if !resultNotes.length && searchQuery && !searching}
We found 0 results for your search here.
{#if plugin.settings.simpleSearch && searchQuery
.split(SPACE_OR_PUNCTUATION)
.some(w => w.length < 3)}
<br />
<span style="color: var(--text-accent); font-size: small">
You have enabled "Simpler Search" in the settings, try to type more
characters.
</span>
{/if}
{:else if searching}
Searching...
{/if}
</div>
</ModalContainer>
<div class="prompt-instructions">
<div class="prompt-instruction">
<span class="prompt-instruction-command">↑↓</span><span>to navigate</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{getAltKeyLabel()} ↑↓</span>
<span>to cycle history</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{openInCurrentPaneKey}</span>
<span>to open</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">Tab</span>
<span>to switch to In-File Search</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{openInNewPaneKey}</span>
<span>to open in a new pane</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{openInNewLeafKey}</span>
<span>to open in a new split</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{getCtrlKeyLabel()} o</span>
<span>to open in the background</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{createInCurrentPaneKey}</span>
<span>to create</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{createInNewPaneKey}</span>
<span>to create in a new pane</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{getAltKeyLabel()} ↵</span>
<span>to insert a link</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{getCtrlKeyLabel()} g</span>
<span>to toggle excerpts</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">Esc</span><span>to close</span>
</div>
</div>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import GlyphAddNote from './GlyphAddNote.svelte'
export let id: string
export let selected = false
export let glyph = false
export let cssClass = ''
</script>
<div
data-result-id={id}
class="suggestion-item omnisearch-result {cssClass}"
class:is-selected={selected}
on:mousemove
on:click
on:keypress
on:auxclick>
{#if glyph}
<GlyphAddNote />
{/if}
<slot />
</div>
+24
View File
@@ -0,0 +1,24 @@
<script lang="ts">
import type { ResultNote } from '../globals'
import ResultItemContainer from './ResultItemContainer.svelte'
import type LocatorPlugin from '../main'
export let plugin: LocatorPlugin
export let offset: number
export let note: ResultNote
export let index = 0
export let selected = false
$: cleanedContent = plugin.textProcessor.makeExcerpt(note?.content ?? '', offset)
</script>
<ResultItemContainer
id="{index.toString()}"
on:auxclick
on:click
on:mousemove
selected="{selected}">
<div class="omnisearch-result__body">
{@html plugin.textProcessor.highlightText(cleanedContent, note.matches)}
</div>
</ResultItemContainer>
+227
View File
@@ -0,0 +1,227 @@
<script lang="ts">
import { showExcerpt } from '../settings/index'
import type { ResultNote } from '../globals'
import {
getExtension,
isFileCanvas,
isFileExcalidraw,
isFileImage,
isFilePDF,
pathWithoutFilename,
} from '../tools/utils'
import ResultItemContainer from './ResultItemContainer.svelte'
import type LocatorPlugin from '../main'
import { setIcon, TFile } from 'obsidian'
import { onMount } from 'svelte'
// Import icon utility functions
import {
loadIconData,
initializeIconPacks,
getIconNameForPath,
loadIconSVG,
getDefaultIconSVG,
} from '../tools/icon-utils'
export let selected = false
export let note: ResultNote
export let plugin: LocatorPlugin
let imagePath: string | null = null
let title = ''
let notePath = ''
let iconData = {}
let folderIconSVG: string | null = null
let fileIconSVG: string | null = null
let prefixToIconPack: { [prefix: string]: string } = {}
let iconsPath: string
let iconDataLoaded = false // Flag to indicate iconData is loaded
// Initialize icon data and icon packs once when the component mounts
onMount(async () => {
iconData = await loadIconData(plugin)
const iconPacks = await initializeIconPacks(plugin)
prefixToIconPack = iconPacks.prefixToIconPack
iconsPath = iconPacks.iconsPath
iconDataLoaded = true // Set the flag after iconData is loaded
})
// Reactive statement to call loadIcons() whenever the note changes and iconData is loaded
$: if (note && note.path && iconDataLoaded) {
;(async () => {
// Update title and notePath before loading icons
title = note.displayTitle || note.basename
notePath = pathWithoutFilename(note.path)
await loadIcons()
})()
}
async function loadIcons() {
// Load folder icon
const folderIconName = getIconNameForPath(notePath, iconData)
if (folderIconName) {
folderIconSVG = await loadIconSVG(
folderIconName,
plugin,
iconsPath,
prefixToIconPack
)
} else {
// Fallback to default folder icon
folderIconSVG = getDefaultIconSVG('folder', plugin)
}
// Load file icon
const fileIconName = getIconNameForPath(note.path, iconData)
if (fileIconName) {
fileIconSVG = await loadIconSVG(
fileIconName,
plugin,
iconsPath,
prefixToIconPack
)
} else {
// Fallback to default icons based on file type
fileIconSVG = getDefaultIconSVG(note.path, plugin)
}
}
// Svelte action to render SVG content with dynamic updates
function renderSVG(node: HTMLElement, svgContent: string) {
node.innerHTML = svgContent
return {
update(newSvgContent: string) {
node.innerHTML = newSvgContent
},
destroy() {
node.innerHTML = ''
},
}
}
let elFolderPathIcon: HTMLElement | null = null
let elFilePathIcon: HTMLElement | null = null
let elEmbedIcon: HTMLElement | null = null
$: {
imagePath = null
if (isFileImage(note.path)) {
const file = plugin.app.vault.getAbstractFileByPath(note.path)
if (file instanceof TFile) {
imagePath = plugin.app.vault.getResourcePath(file)
}
}
}
$: matchesTitle = plugin.textProcessor.getMatches(title, note.foundWords)
$: matchesNotePath = plugin.textProcessor.getMatches(
notePath,
note.foundWords
)
$: cleanedContent = plugin.textProcessor.makeExcerpt(
note.content,
note.matches[0]?.offset ?? -1
)
$: glyph = false //cacheManager.getLiveDocument(note.path)?.doesNotExist
$: {
title = note.displayTitle || note.basename
notePath = pathWithoutFilename(note.path)
// Icons
if (elFolderPathIcon) {
setIcon(elFolderPathIcon, 'folder-open')
}
if (elFilePathIcon) {
if (isFileImage(note.path)) {
setIcon(elFilePathIcon, 'image')
} else if (isFilePDF(note.path)) {
setIcon(elFilePathIcon, 'file-text')
} else if (isFileCanvas(note.path) || isFileExcalidraw(note.path)) {
setIcon(elFilePathIcon, 'layout-dashboard')
} else {
setIcon(elFilePathIcon, 'file')
}
}
if (elEmbedIcon) {
setIcon(elEmbedIcon, 'corner-down-right')
}
}
</script>
<ResultItemContainer
glyph="{glyph}"
id="{note.path}"
cssClass=" {note.isEmbed ? 'omnisearch-result__embed' : ''}"
on:auxclick
on:click
on:mousemove
selected="{selected}">
<div>
<div class="omnisearch-result__title-container">
<span class="omnisearch-result__title">
{#if note.isEmbed}
<span
bind:this="{elEmbedIcon}"
title="The document above is embedded in this note"></span>
{:else}
<!-- File Icon -->
{#if fileIconSVG}
<span class="omnisearch-result__icon" use:renderSVG="{fileIconSVG}"
></span>
{/if}
{/if}
<span>
{@html plugin.textProcessor.highlightText(title, matchesTitle)}
</span>
{#if !note.displayTitle}
<span class="omnisearch-result__extension">
.{getExtension(note.path)}
</span>
{/if}
<!-- Counter -->
{#if note.matches.length > 0}
<span class="omnisearch-result__counter">
{note.matches.length}&nbsp;{note.matches.length > 1
? 'matches'
: 'match'}
</span>
{/if}
</span>
</div>
<!-- Folder path -->
{#if notePath}
<div class="omnisearch-result__folder-path">
<!-- Folder Icon -->
{#if folderIconSVG}
<span class="omnisearch-result__icon" use:renderSVG="{folderIconSVG}"
></span>
{/if}
<span>
{@html plugin.textProcessor.highlightText(notePath, matchesNotePath)}
</span>
</div>
{/if}
<!-- Do not display the excerpt for embedding references -->
{#if !note.isEmbed}
<div style="display: flex; flex-direction: row;">
{#if $showExcerpt}
<div class="omnisearch-result__body">
{@html plugin.textProcessor.highlightText(
cleanedContent,
note.matches
)}
</div>
{/if}
<!-- Image -->
{#if imagePath}
<div class="omnisearch-result__image-container">
<img style="width: 100px" src="{imagePath}" alt="" />
</div>
{/if}
</div>
{/if}
</div>
</ResultItemContainer>
@@ -0,0 +1,208 @@
<div use:load class={rootClass} style="height: {rootInitialHeight}">
{#if loaded}
<div
in:fade={fadeOption || {}}
class={contentClass}
style={contentStyle}
>
<slot>Lazy load content</slot>
</div>
{#if !contentShow && placeholder}
<Placeholder {placeholder} {placeholderProps} />
{/if}
{:else if placeholder}
<Placeholder {placeholder} {placeholderProps} />
{/if}
</div>
<script>
// https://github.com/leafOfTree/svelte-lazy
import { fade } from 'svelte/transition';
import Placeholder from './Placeholder.svelte';
export let keep = false;
export let height = 0;
export let offset = 150;
export let fadeOption = {
delay: 0,
duration: 400,
};
export let resetHeightDelay = 0;
export let onload = null;
export let placeholder = null;
export let placeholderProps = null;
let className = '';
export { className as class };
const rootClass = 'svelte-lazy'
+ (className ? ' ' + className : '');
const contentClass = 'svelte-lazy-content';
const rootInitialHeight = getStyleHeight();
let loaded = false;
let contentShow = true;
$: contentStyle = !contentShow ? 'display: none' : '';
function load(node) {
setHeight(node);
const handler = createHandler(node);
addListeners(handler);
setTimeout(() => {
handler();
});
const observer = observeNode(node, handler);
return {
destroy: () => {
removeListeners(handler);
observer.unobserve(node);
},
};
}
function createHandler(node) {
const handler = throttle(e => {
const nodeTop = node.getBoundingClientRect().top;
const nodeBottom = node.getBoundingClientRect().bottom;
const expectedTop = getContainerHeight(e) + offset;
if (nodeTop <= expectedTop && nodeBottom > 0) {
loadNode(node);
} else if (!keep) {
unload(node)
}
}, 200);
return handler;
}
function observeNode(node, handler) {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadNode(node);
}
})
observer.observe(node);
return observer;
}
function unload(node) {
setHeight(node);
loaded = false
}
function loadNode(node, handler) {
if (loaded) {
return;
}
loaded = true;
resetHeight(node);
if (onload) {
onload(node);
}
}
function addListeners(handler) {
document.addEventListener('scroll', handler, true);
window.addEventListener('resize', handler);
}
function removeListeners(handler) {
document.removeEventListener('scroll', handler, true);
window.removeEventListener('resize', handler);
}
function getStyleHeight() {
return (typeof height === 'number')
? height + 'px'
: height;
}
function setHeight(node) {
if (height) {
node.style.height = getStyleHeight();
}
}
function resetHeight(node) {
setTimeout(() => {
const isLoading = checkImgLoadingStatus(node);
if (!isLoading) {
node.style.height = 'auto';
}
// Add a delay to wait for remote resources like images to load
}, resetHeightDelay);
}
function checkImgLoadingStatus(node) {
const img = node.querySelector('img');
if (!img) {
return false
}
if (!img.complete) {
contentShow = false;
node.addEventListener('load', () => {
// Use auto height if loading successfully
contentShow = true;
node.style.height = 'auto';
}, { capture: true, once: true });
node.addEventListener('error', () => {
// Show content with fixed height if there is error
contentShow = true;
}, { capture: true, once: true });
return true;
}
if (img.naturalHeight === 0) {
// Use fixed height if img has zero height
return true;
}
return false;
}
function getContainerHeight(e) {
if (e?.target?.getBoundingClientRect) {
return e.target.getBoundingClientRect().bottom;
} else {
return window.innerHeight;
}
}
// From underscore souce code
function throttle(func, wait, options) {
let context, args, result;
let timeout = null;
let previous = 0;
if (!options) options = {};
const later = function() {
previous = options.leading === false ? 0 : new Date();
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function(event) {
const now = new Date();
if (!previous && options.leading === false) previous = now;
const remaining = wait - (now - previous);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
timeout = setTimeout(later, remaining);
}
return result;
};
}
</script>
@@ -0,0 +1,15 @@
{#if placeholder}
<div class={placeholderClass}>
{#if typeof placeholder === 'string'}
<div>{placeholder}</div>
{:else if ['function', 'object'].includes(typeof placeholder)}
<svelte:component this={placeholder} {...placeholderProps} />
{/if}
</div>
{/if}
<script>
export let placeholder = null;
export let placeholderProps = null;
const placeholderClass = 'svelte-lazy-placeholder';
</script>
+223
View File
@@ -0,0 +1,223 @@
import { MarkdownView, Modal, TFile } from 'obsidian'
import type { Modifier } from 'obsidian'
import ModalVault from './ModalVault.svelte'
import ModalInFile from './ModalInFile.svelte'
import { Action, eventBus, EventNames, isInputComposition } from '../globals'
import type LocatorPlugin from '../main'
import { mount, unmount } from 'svelte'
abstract class LocatorModal extends Modal {
protected constructor(plugin: LocatorPlugin) {
super(plugin.app)
const settings = plugin.settings
// Remove all the default modal's children
// so that we can more easily customize it
// const closeEl = this.containerEl.find('.modal-close-button')
this.modalEl.replaceChildren()
// this.modalEl.append(closeEl)
this.modalEl.addClass('locator-modal', 'prompt')
this.modalEl.removeClass('modal')
this.modalEl.tabIndex = -1
// Setup events that can be listened through the event bus
// #region Up/Down navigation
this.scope.register([], 'ArrowDown', e => {
e.preventDefault()
eventBus.emit(Action.ArrowDown)
})
this.scope.register([], 'ArrowUp', e => {
e.preventDefault()
eventBus.emit(Action.ArrowUp)
})
// Ctrl+j/k
for (const key of [
{ k: 'J', dir: 'down' },
{ k: 'K', dir: 'up' },
] as const) {
for (const modifier of ['Ctrl', 'Mod'] as const) {
this.scope.register([modifier], key.k, _e => {
if (settings.vimLikeNavigationShortcut) {
// e.preventDefault()
eventBus.emit('arrow-' + key.dir)
}
})
}
}
// Ctrl+n/p
for (const key of [
{ k: 'N', dir: 'down' },
{ k: 'P', dir: 'up' },
] as const) {
for (const modifier of ['Ctrl', 'Mod'] as const) {
this.scope.register([modifier], key.k, _e => {
if (settings.vimLikeNavigationShortcut) {
// e.preventDefault()
eventBus.emit('arrow-' + key.dir)
}
})
}
}
// #endregion Up/Down navigation
let openInCurrentPaneKey: Modifier[]
let openInNewPaneKey: Modifier[]
let createInCurrentPaneKey: Modifier[]
let createInNewPaneKey: Modifier[]
let openInNewLeafKey: Modifier[] = ['Mod', 'Alt']
if (settings.openInNewPane) {
openInCurrentPaneKey = ['Mod']
openInNewPaneKey = []
createInCurrentPaneKey = ['Mod', 'Shift']
createInNewPaneKey = ['Shift']
} else {
openInCurrentPaneKey = []
openInNewPaneKey = ['Mod']
createInCurrentPaneKey = ['Shift']
createInNewPaneKey = ['Mod', 'Shift']
}
// Open in new pane
this.scope.register(openInNewPaneKey, 'Enter', e => {
e.preventDefault()
eventBus.emit(Action.OpenInNewPane)
})
// Open in a new leaf
this.scope.register(openInNewLeafKey, 'Enter', e => {
e.preventDefault()
eventBus.emit(Action.OpenInNewLeaf)
})
// Insert link
this.scope.register(['Alt'], 'Enter', e => {
e.preventDefault()
eventBus.emit(Action.InsertLink)
})
// Create a new note
this.scope.register(createInCurrentPaneKey, 'Enter', e => {
e.preventDefault()
eventBus.emit(Action.CreateNote)
})
this.scope.register(createInNewPaneKey, 'Enter', e => {
e.preventDefault()
eventBus.emit(Action.CreateNote, { newLeaf: true })
})
// Open in current pane
this.scope.register(openInCurrentPaneKey, 'Enter', e => {
if (!isInputComposition()) {
// Check if the user is still typing
e.preventDefault()
eventBus.emit(Action.Enter)
}
})
// Open in background
this.scope.register(['Mod'], 'O', e => {
if (!isInputComposition()) {
// Check if the user is still typing
e.preventDefault()
eventBus.emit(Action.OpenInBackground)
}
})
this.scope.register([], 'Tab', e => {
e.preventDefault()
eventBus.emit(Action.Tab) // Switch context
})
// Search history
this.scope.register(['Alt'], 'ArrowDown', e => {
e.preventDefault()
eventBus.emit(Action.NextSearchHistory)
})
this.scope.register(['Alt'], 'ArrowUp', e => {
e.preventDefault()
eventBus.emit(Action.PrevSearchHistory)
})
// Context
this.scope.register(['Mod'], 'G', _e => {
eventBus.emit(EventNames.ToggleExcerpts)
})
}
}
export class LocatorVaultModal extends LocatorModal {
/**
* Instanciate the Locator vault modal
* @param plugin
* @param query The query to pre-fill the search field with
*/
constructor(plugin: LocatorPlugin, query?: string) {
super(plugin)
// Selected text in the editor
const selectedText = plugin.app.workspace
.getActiveViewOfType(MarkdownView)
?.editor.getSelection()
plugin.searchHistory.getHistory().then(history => {
// Previously searched query (if enabled in settings)
const previous = plugin.settings.showPreviousQueryResults
? history[0]
: null
// Instantiate and display the Svelte component
const cmp = mount(ModalVault, {
target: this.modalEl,
props: {
plugin,
modal: this,
previousQuery: query || selectedText || previous || '',
},
})
this.onClose = () => {
// Since the component is manually created,
// we also need to manually destroy it
unmount(cmp)
}
})
}
}
export class LocatorInFileModal extends LocatorModal {
constructor(
plugin: LocatorPlugin,
file: TFile,
searchQuery: string = '',
parent?: LocatorModal
) {
super(plugin)
const cmp = mount(ModalInFile, {
target: this.modalEl,
props: {
plugin,
modal: this,
singleFilePath: file.path,
parent: parent,
previousQuery: searchQuery,
},
})
if (parent) {
// Hide the parent vault modal, and show it back when this one is closed
parent.containerEl.toggleVisibility(false)
}
this.onClose = () => {
if (parent) {
parent.containerEl.toggleVisibility(true)
}
unmount(cmp)
}
}
}