diff --git a/src/components/Card.svelte b/src/components/Card.svelte index fe32117..482f56c 100644 --- a/src/components/Card.svelte +++ b/src/components/Card.svelte @@ -7,13 +7,14 @@ TFile, } from "obsidian"; import { onMount } from "svelte"; - import { skipNextTransition, app, view, settings } from "./store"; + import { fade } from "svelte/transition"; + import { app, view, settings } from "./store"; import { TitleDisplayMode } from "../settings"; import { assert, is } from "tsafe"; interface Props { file: TFile; - updateLayoutNextTick: (transition: boolean) => void; + updateLayoutNextTick: () => Promise; } let { file, updateLayoutNextTick }: Props = $props(); @@ -21,6 +22,7 @@ let pinned: boolean = $derived($settings.pinnedFiles.includes(file.path)); // This will depend both on the settings and the content of the file let displayFilename: boolean = $state(true); + let translateTransition: boolean = $state(false); function postProcessor( element: HTMLElement, @@ -110,16 +112,17 @@ const togglePin = async (e: Event) => { e.stopPropagation(); + e.preventDefault(); $settings.pinnedFiles = pinned ? $settings.pinnedFiles.filter((f) => f !== file.path) : [...$settings.pinnedFiles, file.path]; - updateLayoutNextTick(true); + await updateLayoutNextTick(); }; const trashFile = async (e: Event) => { e.stopPropagation(); await file.vault.trash(file, true); - updateLayoutNextTick(true); + await updateLayoutNextTick(); }; const openFile = async () => @@ -132,15 +135,17 @@ onMount(() => { (async () => { await renderFile(contentDiv); - updateLayoutNextTick(false); + await updateLayoutNextTick(); + translateTransition = true; })(); - return () => updateLayoutNextTick(false); + return () => updateLayoutNextTick(); });
{ @@ -72,38 +71,40 @@ }; }); - let layoutTimeout: NodeJS.Timeout | null = null; + let lastLayout: Date = new Date(); + let pendingLayout: NodeJS.Timeout | null = null; const debouncedLayout = () => { // If there has been a relayout call in the last 100ms, - // we schedule another one in 100ms to avoid layout thrashing - // If one is already schedule, we cancel it and schedule a new one - if (layoutTimeout) { - clearTimeout(layoutTimeout); - layoutTimeout = setTimeout(() => { - notesGrid.layout(); - if (layoutTimeout) clearTimeout(layoutTimeout); - }, 100); - return; - } + // we schedule another one 100ms later to avoid layout thrashing + return new Promise((resolve) => { + if ( + lastLayout.getTime() + 100 > new Date().getTime() && + pendingLayout === null + ) { + pendingLayout = setTimeout( + () => { + notesGrid.layout(); + lastLayout = new Date(); + pendingLayout = null; + resolve(); + }, + lastLayout.getTime() + 100 - new Date().getTime(), + ); + return; + } - // Otherwise, relayout immediately - notesGrid.layout(); - layoutTimeout = setTimeout(() => { - if (layoutTimeout) clearTimeout(layoutTimeout); - }, 100); + // Otherwise, relayout immediately + notesGrid.layout(); + lastLayout = new Date(); + resolve(); + }); }; - const updateLayoutNextTick = (transition = false) => { - if (!$viewIsVisible) { - $skipNextTransition = true; - return; - } else { - $skipNextTransition = !transition; - } - - tick().then(debouncedLayout); - $skipNextTransition = false; + const updateLayoutNextTick = async () => { + await tick(); + return await debouncedLayout(); }; + displayedFiles.subscribe(updateLayoutNextTick);
@@ -121,6 +122,10 @@ spellcheck="false" bind:value={$searchQuery} /> +
($searchQuery = "")} @@ -259,6 +264,27 @@ box-shadow: calc(0px - var(--size-4-5)) 0 var(--size-2-3) var(--size-2-3) var(--background-primary); } + + .search-input-container { + background-color: var(--background-primary); + z-index: 0; + + & .loading-bar { + z-index: 1; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: var(--loading); + background-color: var(--background-secondary); + } + + & input { + background: transparent; + position: relative; + z-index: 2; + } + } } .cards-container { diff --git a/src/components/store.ts b/src/components/store.ts index baac732..322078c 100644 --- a/src/components/store.ts +++ b/src/components/store.ts @@ -15,33 +15,45 @@ export enum Sort { Modified = "mtime", } +/** + * Stores for obsidian data (updated mainly from main.ts) + */ + export const app = writable(); export const view = writable(); -export const settings = writable(); export const appCache = writable(); export const files = writable([]); +/** + * Stores for user input (updated mainly from settings.ts, view.ts, Root.svelte) + */ + +export const settings = writable(); export const sort = writable(Sort.Modified); -export const sortedFiles = derived( - [sort, files, settings], - ([$sort, $files, $settings]) => +const pinnedFiles = derived(settings, ($settings) => $settings?.pinnedFiles); +const sortedFiles = derived( + [sort, files, pinnedFiles], + ([$sort, $files, $pinnedFiles]) => [...$files].sort( (a: TFile, b: TFile) => - ($settings.pinnedFiles.includes(b.path) ? 1 : 0) - - ($settings.pinnedFiles.includes(a.path) ? 1 : 0) || + ($pinnedFiles.includes(b.path) ? 1 : 0) - + ($pinnedFiles.includes(a.path) ? 1 : 0) || b.stat[$sort] - a.stat[$sort], ), [] as TFile[], ); - export const searchQuery = writable(""); export const searchCaseSensitive = writable(false); + +/** + * Search logic + */ + +const baseQuery = derived(settings, ($settings) => $settings?.baseQuery); const searchFilter = derived( - [settings, searchQuery], - ([$settings, $searchQuery]) => { - const query = $settings.baseQuery - ? $settings.baseQuery + " " + $searchQuery - : $searchQuery; + [baseQuery, searchQuery], + ([$baseQuery, $searchQuery]) => { + const query = $baseQuery ? $baseQuery + " " + $searchQuery : $searchQuery; if (query === "") { return null; @@ -50,67 +62,154 @@ const searchFilter = derived( return generateFilter(query); }, ); -export const searchResultFiles = derived( - [searchFilter, searchCaseSensitive, sortedFiles, appCache, app], - ( - [$searchFilter, $searchCaseSensitive, $sortedFiles, $appCache, $app], - set, - ) => { - if ($searchFilter === null) { - set($sortedFiles); - return; +const searchResultCache = writable>(new Map()); +searchFilter.subscribe(() => searchResultCache.set(new Map())); +const cacheKey = (file: TFile) => file.path + file.stat.mtime; +/** + * This is not derived from searchResultCache because we want incremental update + * and not full invalidation when cache is invalidated + */ +const searchResultsExcluded = writable>(new Set()); +export const searchResultLoadingState = writable(1); + +async function updateSearchResults() { + const $sortedFiles = get(sortedFiles); + const $searchQuery = get(searchQuery); + const $searchFilter = get(searchFilter); + const $appCache = get(appCache); + const $app = get(app); + const $searchCaseSensitive = get(searchCaseSensitive); + if ($searchFilter === null) { + searchResultsExcluded.set(new Set()); + searchResultLoadingState.set(1); + return; + } + + let batch: Map = new Map(); + let lastBatch = { date: new Date(), index: 0 }; + for (let i = 0; i < $sortedFiles.length; i++) { + if ($searchQuery !== get(searchQuery)) return; + + const file = $sortedFiles[i]; + const cachedResult = get(searchResultCache).get(cacheKey($sortedFiles[i])); + if (cachedResult !== undefined) { + batch.set(file, cachedResult); + } else { + const content = await file.vault.cachedRead(file); + const tags = ( + getAllTags($appCache.getFileCache(file) as CachedMetadata) || [] + ).map((t) => t.replace(/^#/, "")); + let frontmatter; + await $app.fileManager.processFrontMatter(file, (fm) => { + frontmatter = fm; + }); + + const match = await $searchFilter({ + file, + content, + tags, + frontmatter, + caseSensitive: $searchCaseSensitive, + }); + batch.set(file, match); + searchResultCache.update((cache) => cache.set(cacheKey(file), match)); } - Promise.all( - $sortedFiles.map(async (file) => { - const content = await file.vault.cachedRead(file); - const tags = ( - getAllTags($appCache.getFileCache(file) as CachedMetadata) || [] - ).map((t) => t.replace(/^#/, "")); - - let frontmatter; - await $app.fileManager.processFrontMatter(file, (fm) => { - frontmatter = fm; - }); - return $searchFilter({ - file, - content, - tags, - frontmatter, - caseSensitive: $searchCaseSensitive, - }); - }), - ).then((searchResults) => { - // avoid race conditions - if ($searchFilter !== get(searchFilter)) { - return; - } - set($sortedFiles.filter((_, index) => searchResults[index])); - }); - }, - get(sortedFiles), -); + if (i % 10 === 0) { + searchResultLoadingState.set(i / $sortedFiles.length); + } -export const displayedCount = writable(50); -export const displayedFiles = derived( - [searchResultFiles, displayedCount], - ([$searchResultFiles, $displayedCount]) => - $searchResultFiles.slice(0, $displayedCount), -); + if ( + lastBatch.date.getTime() + 200 < new Date().getTime() || + i === $sortedFiles.length - 1 + ) { + searchResultLoadingState.set((i + 1) / $sortedFiles.length); + searchResultsExcluded.update((set) => { + batch.forEach((match, file) => + match ? set.delete(file) : set.add(file), + ); + return set; + }); + batch = new Map(); + lastBatch = { date: new Date(), index: i + 1 }; + } + } +} +searchFilter.subscribe(() => updateSearchResults()); +files.subscribe(() => updateSearchResults()); +searchCaseSensitive.subscribe(() => updateSearchResults()); + +/** + * Display logic + */ + +export const displayedCount = writable(0); +export const displayedFiles = writable([]); +const lastDisplayed = derived(displayedFiles, ($displayedFiles) => { + const lastFile = $displayedFiles.last(); + return lastFile ? get(sortedFiles).indexOf(lastFile) : 0; +}); +searchResultsExcluded.subscribe((excludedFiles) => { + // When the search results changes, and we have less files to display, we want + // to keep the same file as current end of infinite scroll + // event if it means that we reduce the number of files displayed. + // This is to avoid the infinite scroll to be triggered when the user + // is typing in search + const $sortedFiles = get(sortedFiles); + const $lastDisplayed = get(lastDisplayed); + const beforeCurrentLast = $sortedFiles.slice(0, $lastDisplayed + 1); + const filteredBeforeCurrentLast = beforeCurrentLast.filter( + (f) => !excludedFiles.has(f), + ); + + if (filteredBeforeCurrentLast.length > 50) { + // We only want to keep same last file if the list is shorter, + // if it is longer, we keep same length + displayedFiles.set( + filteredBeforeCurrentLast.slice(0, get(displayedFiles).length), + ); + return; + } -export const viewIsVisible = writable(false); -export const skipNextTransition = writable(true); + // If we have not enough files to reach 50, we add some new ones + const filteredAfterCurrentLast = $sortedFiles + .slice( + $lastDisplayed, + Math.floor($sortedFiles.length * get(searchResultLoadingState)) - + $lastDisplayed - + 1, + ) // If search results are updating, exclude files which have not been filtered yet + .filter((f) => !excludedFiles.has(f)); + const toAdd = 50 - filteredBeforeCurrentLast.length; + displayedFiles.set([ + ...filteredBeforeCurrentLast, + ...filteredAfterCurrentLast.slice(0, toAdd), + ]); +}); +// When the user scrolls, we add more files to the display +displayedCount.subscribe((count) => { + displayedFiles.set( + get(sortedFiles) + .filter((f) => !get(searchResultsExcluded).has(f)) + .slice(0, count), + ); +}); +// When sort order changes, we want to reset display with same number of files +sortedFiles.subscribe(($sortedFiles) => { + displayedFiles.set( + $sortedFiles + .filter((f) => !get(searchResultsExcluded).has(f)) + .slice(0, get(displayedFiles).length), + ); +}); export default { files, sort, searchQuery, searchCaseSensitive, - searchResultFiles, displayedCount, displayedFiles, - viewIsVisible, - skipNextTransition, app, view, settings, diff --git a/src/main.ts b/src/main.ts index 6eb3311..744687d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -71,7 +71,6 @@ export default class CardsViewPlugin extends Plugin { } await leaf.setViewState({ type: VIEW_TYPE, active: true }); - store.viewIsVisible.set(true); } async saveSettings() { diff --git a/src/view.ts b/src/view.ts index 96c66a4..b9f8147 100644 --- a/src/view.ts +++ b/src/view.ts @@ -71,22 +71,12 @@ export class CardsViewPluginView extends ItemView { viewContent.scrollTop + viewContent.clientHeight > viewContent.scrollHeight - 500 ) { - store.skipNextTransition.set(true); store.displayedCount.set(get(store.displayedFiles).length + 50); } }); - - this.app.workspace.on("active-leaf-change", () => { - // check our leaf is visible - const rootLeaf = this.app.workspace.getMostRecentLeaf( - this.app.workspace.rootSplit, - ); - store.viewIsVisible.set(rootLeaf?.view?.getViewType() === VIEW_TYPE); - }); } async onClose() { - store.viewIsVisible.set(false); store.searchQuery.set(""); store.displayedCount.set(50); store.sort.set(Sort.Modified);