Svelte refactoring ok

This commit is contained in:
Simon Cambier
2022-04-16 17:24:04 +02:00
parent 40aba85729
commit 832e782ba8
9 changed files with 300 additions and 230 deletions

View File

@@ -34,7 +34,7 @@ module.exports = {
// 'simple-import-sort/exports': 'warn', // 'simple-import-sort/exports': 'warn',
'@typescript-eslint/func-call-spacing': ['error'], '@typescript-eslint/func-call-spacing': ['error'],
'@typescript-eslint/explicit-function-return-type': [ '@typescript-eslint/explicit-function-return-type': [
'error', 'warn',
{ {
allowExpressions: true, allowExpressions: true,
allowTypedFunctionExpressions: true, allowTypedFunctionExpressions: true,

View File

@@ -12,21 +12,16 @@ onMount(async () => {
input.select() input.select()
}) })
selectedNote.subscribe((note) => {
const elem = document.querySelector(`[data-note-id="${note?.path}"]`)
elem?.scrollIntoView({ behavior: "auto", block: "nearest" })
})
// const throttledMoveNoteSelection = throttle(moveNoteSelection, 75) // const throttledMoveNoteSelection = throttle(moveNoteSelection, 75)
function moveNoteSelection(ev: KeyboardEvent): void { function moveNoteSelection(ev: KeyboardEvent): void {
switch (ev.key) { switch (ev.key) {
case "ArrowDown": case "ArrowDown":
selectedNote.next()
ev.preventDefault() ev.preventDefault()
selectedNote.next()
break break
case "ArrowUp": case "ArrowUp":
selectedNote.previous()
ev.preventDefault() ev.preventDefault()
selectedNote.previous()
break break
case "ArrowLeft": case "ArrowLeft":
break break
@@ -34,6 +29,7 @@ function moveNoteSelection(ev: KeyboardEvent): void {
break break
case "Enter": case "Enter":
ev.preventDefault()
if (ev.ctrlKey || ev.metaKey) { if (ev.ctrlKey || ev.metaKey) {
// Open in a new pane // Open in a new pane
dispatch("ctrl-enter", $selectedNote) dispatch("ctrl-enter", $selectedNote)
@@ -46,6 +42,14 @@ function moveNoteSelection(ev: KeyboardEvent): void {
} }
break break
} }
// Scroll selected note into view when selecting with keyboard
if ($selectedNote) {
const elem = document.querySelector(
`[data-note-id="${$selectedNote.path}"]`
)
elem?.scrollIntoView({ behavior: "auto", block: "nearest" })
}
} }
</script> </script>

View File

@@ -1,16 +1,17 @@
<script lang="ts"> <script lang="ts">
import { MarkdownView, TFile } from "obsidian"
import { tick } from "svelte" import { tick } from "svelte"
import CmpInput from "./CmpInput.svelte" import CmpInput from "./CmpInput.svelte"
import CmpNoteResult from "./CmpNoteResult.svelte" import CmpNoteResult from "./CmpNoteResult.svelte"
import type { ResultNote } from "./globals" import type { ResultNote } from "./globals"
import type OmnisearchPlugin from "./main" import { openNote } from "./notes"
import type { OmnisearchModal } from "./modal" import { getSuggestions } from "./search"
import { resultNotes, searchQuery, selectedNote } from "./stores" import {
import { escapeHTML, escapeRegex, getAllIndexes, highlighter } from "./utils" modal,
plugin,
export let plugin: OmnisearchPlugin resultNotes,
export let modal: OmnisearchModal searchQuery,
selectedNote,
} from "./stores"
searchQuery.subscribe(async (q) => { searchQuery.subscribe(async (q) => {
const results = getSuggestions(q) const results = getSuggestions(q)
@@ -21,97 +22,13 @@ searchQuery.subscribe(async (q) => {
} }
}) })
function getSuggestions(query: string): ResultNote[] {
const results = plugin.minisearch
.search(query, {
prefix: true,
fuzzy: (term) => (term.length > 4 ? 0.2 : false),
combineWith: "AND",
boost: {
basename: 2,
headings1: 1.5,
headings2: 1.3,
headings3: 1.1,
},
})
.sort((a, b) => b.score - a.score)
.slice(0, 50)
// console.log(`Omnisearch - Results for "${query}"`)
// console.log(results)
const suggestions = results.map((result) => {
let note = plugin.indexedNotes[result.id]
let basename = escapeHTML(note.basename)
let content = escapeHTML(note.content)
// Sort the terms from smaller to larger
// and highlight them in the title and body
const terms = result.terms.sort((a, b) => a.length - b.length)
const reg = new RegExp(terms.map(escapeRegex).join("|"), "gi")
const matches = getAllIndexes(content, reg)
// If the body contains a searched term, find its position
// and trim the text around it
const pos = content.toLowerCase().indexOf(result.terms[0])
const surroundLen = 180
if (pos > -1) {
const from = Math.max(0, pos - surroundLen)
const to = Math.min(content.length - 1, pos + surroundLen)
content =
(from > 0 ? "…" : "") +
content.slice(from, to).trim() +
(to < content.length - 1 ? "…" : "")
}
// console.log(matches)
content = content.replace(reg, highlighter)
basename = basename.replace(reg, highlighter)
const resultNote: ResultNote = {
content,
basename,
path: note.path,
matches,
occurence: 0,
}
return resultNote
})
return suggestions
}
async function openNote(item: ResultNote, newPane = false): Promise<void> {
const file = plugin.app.vault.getAbstractFileByPath(item.path) as TFile
// const fileCache = this.app.metadataCache.getFileCache(file)
// console.log(fileCache)
const content = (await plugin.app.vault.cachedRead(file)).toLowerCase()
const offset = content.indexOf(
item.matches[item.occurence].match.toLowerCase()
)
await plugin.app.workspace.openLinkText(item.path, "", newPane)
const view = plugin.app.workspace.getActiveViewOfType(MarkdownView)
if (!view) {
throw new Error("OmniSearch - No active MarkdownView")
}
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 },
})
}
async function createOrOpenNote(item: ResultNote): Promise<void> { async function createOrOpenNote(item: ResultNote): Promise<void> {
try { try {
const file = await plugin.app.vault.create( const file = await $plugin.app.vault.create(
$searchQuery + ".md", $searchQuery + ".md",
"# " + $searchQuery "# " + $searchQuery
) )
await plugin.app.workspace.openLinkText(file.path, "") await $plugin.app.workspace.openLinkText(file.path, "")
} catch (e) { } catch (e) {
if (e instanceof Error && e.message === "File already exists.") { if (e instanceof Error && e.message === "File already exists.") {
// Open the existing file instead of creating it // Open the existing file instead of creating it
@@ -124,17 +41,17 @@ async function createOrOpenNote(item: ResultNote): Promise<void> {
function onInputEnter(event: CustomEvent<ResultNote>): void { function onInputEnter(event: CustomEvent<ResultNote>): void {
openNote(event.detail) openNote(event.detail)
modal.close() $modal.close()
} }
function onInputCtrlEnter(event: CustomEvent<ResultNote>): void { function onInputCtrlEnter(event: CustomEvent<ResultNote>): void {
openNote(event.detail, true) openNote(event.detail, true)
modal.close() $modal.close()
} }
function onInputShiftEnter(event: CustomEvent<ResultNote>): void { function onInputShiftEnter(event: CustomEvent<ResultNote>): void {
createOrOpenNote(event.detail) createOrOpenNote(event.detail)
modal.close() $modal.close()
} }
</script> </script>

View File

@@ -1,14 +1,35 @@
<script lang="ts"> <script lang="ts">
import type { ResultNote } from "./globals" import type { ResultNote } from "./globals"
import { openNote } from "./notes"
import { modal, selectedNote } from "./stores"
import { escapeHTML } from "./utils"
export let selected = false export let selected = false
export let note: ResultNote export let note: ResultNote
$: cleanContent = (() => {
const content = escapeHTML(note.content)
const tmp = document.createElement("div")
tmp.innerHTML = content
return tmp.textContent
})()
function onHover() {
$selectedNote = note
}
function onClick() {
openNote(note)
$modal.close()
}
</script> </script>
<div <div
data-note-id={note.path} data-note-id={note.path}
class="suggestion-item omnisearch-result" class="suggestion-item omnisearch-result"
class:is-selected={selected} class:is-selected={selected}
on:mouseover={onHover}
on:focus={onHover}
on:click={onClick}
> >
<span class="omnisearch-result__title"> <span class="omnisearch-result__title">
{@html note.basename} {@html note.basename}
@@ -17,6 +38,6 @@ export let note: ResultNote
{note.matches.length}&nbsp;{note.matches.length > 1 ? "matches" : "match"} {note.matches.length}&nbsp;{note.matches.length > 1 ? "matches" : "match"}
</span> </span>
<div class="omnisearch-result__body"> <div class="omnisearch-result__body">
{@html note.content} {@html cleanContent}
</div> </div>
</div> </div>

View File

@@ -1,16 +1,18 @@
import { Notice, Plugin, TAbstractFile, TFile } from 'obsidian' import { Plugin, TFile } from 'obsidian'
import MiniSearch from 'minisearch'
import type { IndexedNote } from './globals'
import { escapeHTML, extractHeadingsFromCache, wait } from './utils'
import { OmnisearchModal } from './modal' import { OmnisearchModal } from './modal'
import { plugin } from './stores'
import {
addToIndex,
instantiateMinisearch,
removeFromIndex,
removeFromIndexByPath,
} from './search'
export default class OmnisearchPlugin extends Plugin { export default class OmnisearchPlugin extends Plugin {
minisearch!: MiniSearch<IndexedNote>
lastSearch?: string
indexedNotes: Record<string, IndexedNote> = {}
async onload(): Promise<void> { async onload(): Promise<void> {
await this.instantiateMinisearch() plugin.set(this)
await instantiateMinisearch()
// Commands to display Omnisearch modal // Commands to display Omnisearch modal
this.addCommand({ this.addCommand({
@@ -25,114 +27,27 @@ export default class OmnisearchPlugin extends Plugin {
// Listeners to keep the search index up-to-date // Listeners to keep the search index up-to-date
this.registerEvent( this.registerEvent(
this.app.vault.on('create', file => { this.app.vault.on('create', file => {
this.addToIndex(file) addToIndex(file)
}), }),
) )
this.registerEvent( this.registerEvent(
this.app.vault.on('delete', file => { this.app.vault.on('delete', file => {
this.removeFromIndex(file) removeFromIndex(file)
}), }),
) )
this.registerEvent( this.registerEvent(
this.app.vault.on('modify', async file => { this.app.vault.on('modify', async file => {
this.removeFromIndex(file) removeFromIndex(file)
await this.addToIndex(file) await addToIndex(file)
}), }),
) )
this.registerEvent( this.registerEvent(
this.app.vault.on('rename', async (file, oldPath) => { this.app.vault.on('rename', async (file, oldPath) => {
if (file instanceof TFile && file.path.endsWith('.md')) { if (file instanceof TFile && file.path.endsWith('.md')) {
this.removeFromIndexByPath(oldPath) removeFromIndexByPath(oldPath)
await this.addToIndex(file) await addToIndex(file)
} }
}), }),
) )
} }
async instantiateMinisearch(): Promise<void> {
this.indexedNotes = {}
this.minisearch = new MiniSearch({
idField: 'path',
fields: ['basename', 'content', 'headings1', 'headings2', 'headings3'],
})
// Index files that are already present
const start = new Date().getTime()
const files = this.app.vault.getMarkdownFiles()
// This is basically the same behavior as MiniSearch's `addAllAsync()`.
// We index files by batches of 10
if (files.length) {
console.log('Omnisearch - indexing ' + files.length + ' files')
}
for (let i = 0; i < files.length; ++i) {
if (i % 10 === 0) await wait(0)
const file = files[i]
// console.log(file.path)
await this.addToIndex(file)
}
if (files.length > 0) {
new Notice(
`Omnisearch - Indexed ${files.length} notes in ${
new Date().getTime() - start
}ms`,
)
}
}
async addToIndex(file: TAbstractFile): Promise<void> {
if (!(file instanceof TFile) || file.extension !== 'md') return
try {
// console.log(`Omnisearch - adding ${file.path} to index`)
const fileCache = this.app.metadataCache.getFileCache(file)
// console.log(fileCache)
if (this.indexedNotes[file.path]) {
throw new Error(`${file.basename} is already indexed`)
}
// Fetch content from the cache,
// trim the markdown, remove embeds and clear wikilinks
const content = escapeHTML(await this.app.vault.cachedRead(file))
// Purge HTML before indexing
const tmp = document.createElement('div')
tmp.innerHTML = content
// Make the document and index it
const note: IndexedNote = {
basename: file.basename,
content: tmp.innerText, // content,
path: file.path,
headings1: fileCache
? extractHeadingsFromCache(fileCache, 1).join(' ')
: '',
headings2: fileCache
? extractHeadingsFromCache(fileCache, 2).join(' ')
: '',
headings3: fileCache
? extractHeadingsFromCache(fileCache, 3).join(' ')
: '',
}
this.minisearch.add(note)
this.indexedNotes[file.path] = note
}
catch (e) {
console.trace('Error while indexing ' + file.basename)
console.error(e)
}
}
removeFromIndex(file: TAbstractFile): void {
if (file instanceof TFile && file.path.endsWith('.md')) {
// console.log(`Omnisearch - removing ${file.path} from index`)
return this.removeFromIndexByPath(file.path)
}
}
removeFromIndexByPath(path: string): void {
const note = this.indexedNotes[path]
this.minisearch.remove(note)
delete this.indexedNotes[path]
}
} }

View File

@@ -1,25 +1,18 @@
import { Modal } from "obsidian"; import { Modal } from 'obsidian'
import type OmnisearchPlugin from "./main"; import type OmnisearchPlugin from './main'
import CmpModal from "./CmpModal.svelte"; import CmpModal from './CmpModal.svelte'
import { modal } from './stores'
export class OmnisearchModal extends Modal { export class OmnisearchModal extends Modal {
constructor(plugin: OmnisearchPlugin) { constructor(plugin: OmnisearchPlugin) {
super(plugin.app); super(plugin.app)
this.modalEl.addClass("omnisearch-modal", "prompt"); this.modalEl.addClass('omnisearch-modal', 'prompt')
this.modalEl.replaceChildren(); // Remove all the default Modal's children this.modalEl.replaceChildren() // Remove all the default Modal's children
modal.set(this)
new CmpModal({ new CmpModal({
target: this.modalEl, target: this.modalEl,
props: { })
modal: this,
plugin,
},
});
}
onOpen(): void {
// this.containerEl.style.border = '1px solid red'
// this.modalEl.style.border = '1px solid blue'
// this.contentEl.style.border = '1px solid green'
} }
} }

32
src/notes.ts Normal file
View File

@@ -0,0 +1,32 @@
import { MarkdownView, TFile } from 'obsidian'
import { get } from 'svelte/store'
import type { ResultNote } from './globals'
import { plugin } from './stores'
export async function openNote(
item: ResultNote,
newPane = false,
): Promise<void> {
const app = get(plugin).app
const file = app.vault.getAbstractFileByPath(item.path) as TFile
// const fileCache = app.metadataCache.getFileCache(file)
// console.log(fileCache)
const content = (await app.vault.cachedRead(file)).toLowerCase()
const offset = content.indexOf(
item.matches[item.occurence].match.toLowerCase(),
)
await app.workspace.openLinkText(item.path, '', newPane)
const view = app.workspace.getActiveViewOfType(MarkdownView)
if (!view) {
throw new Error('OmniSearch - No active MarkdownView')
}
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 },
})
}

162
src/search.ts Normal file
View File

@@ -0,0 +1,162 @@
import { Notice, TFile, type TAbstractFile } from 'obsidian'
import MiniSearch from 'minisearch'
import type { IndexedNote, ResultNote } from './globals'
import { indexedNotes, plugin } from './stores'
import { get } from 'svelte/store'
import {
escapeHTML,
escapeRegex,
extractHeadingsFromCache,
getAllIndexes,
highlighter,
wait,
} from './utils'
let minisearch: MiniSearch<IndexedNote>
export async function instantiateMinisearch(): Promise<void> {
indexedNotes.set({})
minisearch = new MiniSearch({
idField: 'path',
fields: ['basename', 'content', 'headings1', 'headings2', 'headings3'],
})
// Index files that are already present
const start = new Date().getTime()
const files = get(plugin).app.vault.getMarkdownFiles()
// This is basically the same behavior as MiniSearch's `addAllAsync()`.
// We index files by batches of 10
if (files.length) {
console.log('Omnisearch - indexing ' + files.length + ' files')
}
for (let i = 0; i < files.length; ++i) {
if (i % 10 === 0) await wait(0)
await addToIndex(files[i])
}
if (files.length > 0) {
new Notice(
`Omnisearch - Indexed ${files.length} notes in ${
new Date().getTime() - start
}ms`,
)
}
}
export function getSuggestions(query: string): ResultNote[] {
const results = minisearch
.search(query, {
prefix: true,
fuzzy: term => (term.length > 4 ? 0.2 : false),
combineWith: 'AND',
boost: {
basename: 2,
headings1: 1.5,
headings2: 1.3,
headings3: 1.1,
},
})
.sort((a, b) => b.score - a.score)
.slice(0, 50)
// console.log(`Omnisearch - Results for "${query}"`)
// console.log(results)
const suggestions = results.map(result => {
const note = indexedNotes.get(result.id)
if (!note) {
throw new Error(`Note "${result.id}" not indexed`)
}
let basename = escapeHTML(note.basename)
let content = escapeHTML(note.content)
// Sort the terms from smaller to larger
// and highlight them in the title and body
const terms = result.terms.sort((a, b) => a.length - b.length)
const reg = new RegExp(terms.map(escapeRegex).join('|'), 'gi')
const matches = getAllIndexes(content, reg)
// If the body contains a searched term, find its position
// and trim the text around it
const pos = content.toLowerCase().indexOf(result.terms[0])
const surroundLen = 180
if (pos > -1) {
const from = Math.max(0, pos - surroundLen)
const to = Math.min(content.length - 1, pos + surroundLen)
content =
(from > 0 ? '…' : '') +
content.slice(from, to).trim() +
(to < content.length - 1 ? '…' : '')
}
// console.log(matches)
content = content.replace(reg, highlighter)
basename = basename.replace(reg, highlighter)
const resultNote: ResultNote = {
content,
basename,
path: note.path,
matches,
occurence: 0,
}
return resultNote
})
return suggestions
}
export async function addToIndex(file: TAbstractFile): Promise<void> {
if (!(file instanceof TFile) || file.extension !== 'md') return
try {
const app = get(plugin).app
// console.log(`Omnisearch - adding ${file.path} to index`)
const fileCache = app.metadataCache.getFileCache(file)
// console.log(fileCache)
if (indexedNotes.get(file.path)) {
throw new Error(`${file.basename} is already indexed`)
}
// Fetch content from the cache to index it as-is
const content = await app.vault.cachedRead(file)
// Make the document and index it
const note: IndexedNote = {
basename: file.basename,
content,
path: file.path,
headings1: fileCache
? extractHeadingsFromCache(fileCache, 1).join(' ')
: '',
headings2: fileCache
? extractHeadingsFromCache(fileCache, 2).join(' ')
: '',
headings3: fileCache
? extractHeadingsFromCache(fileCache, 3).join(' ')
: '',
}
minisearch.add(note)
indexedNotes.add(note)
}
catch (e) {
console.trace('Error while indexing ' + file.basename)
console.error(e)
}
}
export function removeFromIndex(file: TAbstractFile): void {
if (file instanceof TFile && file.path.endsWith('.md')) {
// console.log(`Omnisearch - removing ${file.path} from index`)
return removeFromIndexByPath(file.path)
}
}
export function removeFromIndexByPath(path: string): void {
const note = indexedNotes.get(path)
if (note) {
minisearch.remove(note)
indexedNotes.remove(path)
}
}

View File

@@ -1,12 +1,33 @@
import { get, writable } from 'svelte/store' import { get, writable } from 'svelte/store'
import type { ResultNote } from './globals' import type { IndexedNote, ResultNote } from './globals'
import type OmnisearchPlugin from './main'
import type { OmnisearchModal } from './modal'
// export const selectedNoteId = writable<string>('') function createIndexedNotes() {
export const searchQuery = writable<string>('') const { subscribe, set, update } = writable<Record<string, IndexedNote>>({})
export const resultNotes = writable<ResultNote[]>([]) return {
subscribe,
set,
add(note: IndexedNote) {
update(notes => {
notes[note.path] = note
return notes
})
},
remove(path: string) {
update(notes => {
delete notes[path]
return notes
})
},
get(path: string): IndexedNote | undefined {
return get(indexedNotes)[path]
},
}
}
function createSelectedNote() { function createSelectedNote() {
const { subscribe, set, update } = writable<ResultNote|null>(null) const { subscribe, set, update } = writable<ResultNote | null>(null)
return { return {
subscribe, subscribe,
set, set,
@@ -31,4 +52,9 @@ function createSelectedNote() {
} }
} }
export const searchQuery = writable<string>('')
export const resultNotes = writable<ResultNote[]>([])
export const plugin = writable<OmnisearchPlugin>()
export const modal = writable<OmnisearchModal>()
export const selectedNote = createSelectedNote() export const selectedNote = createSelectedNote()
export const indexedNotes = createIndexedNotes()