Merge pull request #5 from ccmdi/feat/thumbnail-cache

Thumbnail cache
This commit is contained in:
ccmdi 2025-10-20 13:13:08 -04:00 committed by GitHub
commit d67c0e1260
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 563 additions and 31 deletions

View file

@ -1,7 +1,7 @@
{
"name": "obsidian-sample-plugin",
"version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"name": "mapplus",
"version": "1.0.2",
"description": "A performant and customizable map inside Bases",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",

View file

@ -2,9 +2,11 @@ import { Plugin } from 'obsidian';
import { MapBasesView } from './views/map-bases-view';
import { MapTagSettings, DEFAULT_MAP_TAG_SETTINGS } from './settings/map-tag-settings';
import { MapSettingTab, MapPluginSettings, DEFAULT_SETTINGS } from './settings/map-settings';
import { ThumbnailCacheManager } from './thumbnail-cache';
export default class MapPlugin extends Plugin {
settings: MapPluginSettings;
thumbnailCache: ThumbnailCacheManager;
get tagSettings(): MapTagSettings {
return this.settings.tagSettings;
@ -13,6 +15,16 @@ export default class MapPlugin extends Plugin {
async onload() {
await this.loadSettings();
this.thumbnailCache = new ThumbnailCacheManager(this, this.app);
if (this.settings.enableThumbnailCache) {
await this.thumbnailCache.loadCache();
setTimeout(() => {
this.thumbnailCache.generatePendingThumbnails();
}, 2000);
}
this.registerBasesView('map', {
name: 'Map',
icon: 'lucide-map',

View file

@ -8,6 +8,7 @@ import { MapView as MapViewType } from '@deck.gl/core';
import { MapTagSettings } from './settings/map-tag-settings';
import type MapPlugin from './main';
import type { ThumbnailCacheManager } from './thumbnail-cache';
function easeCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
@ -52,6 +53,7 @@ export interface MapRendererOptions {
app: App;
settings: MapPlugin['settings'];
tagSettings?: MapTagSettings;
thumbnailCache?: ThumbnailCacheManager;
options: {
center?: [number, number];
zoom?: number;
@ -60,8 +62,6 @@ export interface MapRendererOptions {
markerSize?: number;
markerColor?: string;
tileLayer?: string;
showSearch?: boolean;
showTags?: boolean;
autoCenter?: boolean;
onMarkerClick?: (point: MapPoint, event: MjolnirEvent) => void;
onTilesLoaded?: () => void;
@ -418,15 +418,20 @@ export async function createMapRenderer(config: MapRendererOptions): Promise<Dec
// show cover image
if (point.cover && point.file) {
const coverFile = app.metadataCache.getFirstLinkpathDest(point.cover, point.file.path);
if (coverFile) {
let thumbnailSrc: string | null = null;
if (config.thumbnailCache && settings.enableThumbnailCache) {
thumbnailSrc = await config.thumbnailCache.getThumbnail(point.cover, point.file);
}
// Check if this update is still valid after async operation
if (thisUpdateId !== tooltipUpdateId) return;
if (thumbnailSrc) {
const img = new Image();
img.classList.add('map-tooltip-cover-image');
img.src = thumbnailSrc;
const src = app.vault.getResourcePath(coverFile);
img.src = src;
// Decode image off main thread, then append to DOM
const imagePromise = img.decode()
.then(() => {
if (thisUpdateId === tooltipUpdateId) {
@ -440,6 +445,29 @@ export async function createMapRenderer(config: MapRendererOptions): Promise<Dec
});
renderPromises.push(imagePromise);
} else {
const coverFile = app.metadataCache.getFirstLinkpathDest(point.cover, point.file.path);
if (coverFile) {
const img = new Image();
img.classList.add('map-tooltip-cover-image');
const src = app.vault.getResourcePath(coverFile);
img.src = src;
const imagePromise = img.decode()
.then(() => {
if (thisUpdateId === tooltipUpdateId) {
tooltip.insertBefore(img, tooltip.firstChild);
}
})
.catch(() => {
if (thisUpdateId === tooltipUpdateId) {
tooltip.insertBefore(img, tooltip.firstChild);
}
});
renderPromises.push(imagePromise);
}
}
}
@ -451,11 +479,13 @@ export async function createMapRenderer(config: MapRendererOptions): Promise<Dec
const propsContainer = tooltip.createEl('div', { cls: 'map-tooltip-property-container' });
point.properties.forEach(prop => {
if (prop.name.toLowerCase() === 'tags') return;
const propEl = propsContainer.createEl('div', { cls: 'map-tooltip-property' });
const labelEl = propEl.createEl('span', { cls: 'map-tooltip-property-label' });
labelEl.textContent = prop.name + ':';
const valueEl = propEl.createEl('span', { cls: 'map-tooltip-property-value' });
// render value
@ -473,14 +503,13 @@ export async function createMapRenderer(config: MapRendererOptions): Promise<Dec
});
}
//todo
if (options.showTags && point.tags && point.tags.length > 0) {
// show tags
if (point.tags && point.tags.length > 0) {
const tagsEl = tooltip.createEl('div', { cls: 'map-tooltip-tags' });
point.tags.forEach((tag) => {
const tagEl = tagsEl.createEl('a', { cls: 'tag' });
tagEl.textContent = `#${tag}`;
});
tooltip.appendChild(tagsEl);
}
// Wait for all images to load before showing tooltip

View file

@ -9,6 +9,8 @@ export interface MapPluginSettings {
iconFill: boolean;
autoCenter: boolean;
transitionDuration: number;
enableThumbnailCache: boolean;
thumbnailTargetSize: number;
tagSettings: MapTagSettings;
}
@ -19,6 +21,8 @@ export const DEFAULT_SETTINGS: MapPluginSettings = {
iconFill: false,
autoCenter: true,
transitionDuration: 1000,
enableThumbnailCache: false,
thumbnailTargetSize: 25,
tagSettings: DEFAULT_MAP_TAG_SETTINGS
};
@ -46,6 +50,9 @@ export class MapSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.latKey = value;
await this.plugin.saveSettings();
setTimeout(() => {
this.plugin.refreshAllMapViews();
}, 1000);
}));
new Setting(containerEl)
@ -57,6 +64,9 @@ export class MapSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.lngKey = value;
await this.plugin.saveSettings();
setTimeout(() => {
this.plugin.refreshAllMapViews();
}, 1000);
}));
new Setting(containerEl)
@ -68,7 +78,9 @@ export class MapSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.strokeWidth = value;
await this.plugin.saveSettings();
this.plugin.refreshAllMapViews();
setTimeout(() => {
this.plugin.refreshAllMapViews();
}, 250);
}));
new Setting(containerEl)
@ -103,6 +115,106 @@ export class MapSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
}));
containerEl.createEl('h3', { text: 'Performance' });
const thumbnailToggle = new Setting(containerEl)
.setName('Enable thumbnail cache')
.setDesc('Cache thumbnails of location cover images - instant tooltips')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableThumbnailCache)
.onChange(async (value) => {
this.plugin.settings.enableThumbnailCache = value;
await this.plugin.saveSettings();
if (value) {
await this.plugin.thumbnailCache.loadCache();
setTimeout(() => {
this.plugin.thumbnailCache.generatePendingThumbnails();
}, 500);
}
this.display();
}));
if (this.plugin.settings.enableThumbnailCache) {
new Setting(containerEl)
.setName('Thumbnail target size')
.setDesc('Target file size for cached thumbnails (in KB)')
.addSlider(slider => slider
.setLimits(10, 50, 5)
.setValue(this.plugin.settings.thumbnailTargetSize)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.thumbnailTargetSize = value;
await this.plugin.saveSettings();
}));
const stats = this.plugin.thumbnailCache.getCacheStats();
const sizeKB = (stats.totalSize / 1024).toFixed(1);
const isGenerating = this.plugin.thumbnailCache.isGenerating();
const statusSetting = new Setting(containerEl)
.setName('Thumbnail cache status');
const updateStatus = () => {
const currentStats = this.plugin.thumbnailCache.getCacheStats();
const currentSizeKB = (currentStats.totalSize / 1024).toFixed(1);
if (currentStats.pending > 0) {
statusSetting.setDesc(`${currentStats.count} thumbnails cached (${currentSizeKB} KB), ${currentStats.pending} pending`);
} else {
statusSetting.setDesc(`${currentStats.count} thumbnails cached (${currentSizeKB} KB)`);
}
};
updateStatus();
if (stats.pending > 0 || isGenerating) {
const refreshInterval = window.setInterval(() => {
updateStatus();
if (this.plugin.thumbnailCache.getCacheStats().pending === 0 && !this.plugin.thumbnailCache.isGenerating()) {
clearInterval(refreshInterval);
this.display();
}
}, 500);
} else {
statusSetting.addButton(button => button
.setButtonText('Rebuild cache')
.onClick(async () => {
button.setDisabled(true);
button.setButtonText('Rebuilding...');
const progressInterval = window.setInterval(() => {
updateStatus();
}, 500);
// force refresh markers for cover context
this.plugin.refreshAllMapViews();
await new Promise(resolve => setTimeout(resolve, 1000));
await this.plugin.thumbnailCache.rebuildCache((current, total) => {
button.setButtonText(`Rebuilding ${current}/${total}...`);
});
clearInterval(progressInterval);
button.setButtonText('Rebuild cache');
button.setDisabled(false);
this.display();
}));
}
new Setting(containerEl)
.setName('Clear thumbnail cache')
.setDesc('Delete all cached thumbnails')
.addButton(button => button
.setButtonText('Clear cache')
.setWarning()
.onClick(async () => {
await this.plugin.thumbnailCache.clearCache();
this.display();
}));
}
renderTagCustomizations(containerEl, this.app, this.plugin);
}
}

368
src/thumbnail-cache.ts Normal file
View file

@ -0,0 +1,368 @@
import { App, TFile, normalizePath } from 'obsidian';
import type MapPlugin from './main';
export interface ThumbnailCacheEntry {
thumbnailPath: string;
sourceModified: number;
size: number;
}
export interface ThumbnailCacheMetadata {
entries: Record<string, ThumbnailCacheEntry>;
pendingGeneration: string[];
}
const THUMBNAIL_WIDTH = 300;
const THUMBNAIL_HEIGHT = 200;
const CACHE_DIR = '.obsidian/plugins/mapplus/.cache';
export class ThumbnailCacheManager {
private plugin: MapPlugin;
private app: App;
private cache: ThumbnailCacheMetadata;
private cacheLoaded: boolean = false;
private generationInProgress: boolean = false;
private cacheDir: string;
constructor(plugin: MapPlugin, app: App) {
this.plugin = plugin;
this.app = app;
this.cache = {
entries: {},
pendingGeneration: []
};
this.cacheDir = normalizePath(CACHE_DIR);
}
async loadCache(): Promise<void> {
if (this.cacheLoaded) return;
try {
await this.ensureCacheDir();
const metadataPath = normalizePath(`${this.cacheDir}/metadata.json`);
const exists = await this.app.vault.adapter.exists(metadataPath);
if (exists) {
const content = await this.app.vault.adapter.read(metadataPath);
this.cache = JSON.parse(content);
}
this.cacheLoaded = true;
} catch (e) {
console.error('Failed to load thumbnail cache:', e);
this.cache = {
entries: {},
pendingGeneration: []
};
this.cacheLoaded = true;
}
}
async saveCache(): Promise<void> {
try {
await this.ensureCacheDir();
const metadataPath = normalizePath(`${this.cacheDir}/metadata.json`);
await this.app.vault.adapter.write(metadataPath, JSON.stringify(this.cache, null, 2));
} catch (e) {
console.error('Failed to save thumbnail cache:', e);
}
}
private async ensureCacheDir(): Promise<void> {
const exists = await this.app.vault.adapter.exists(this.cacheDir);
if (!exists) {
await this.app.vault.adapter.mkdir(this.cacheDir);
}
}
private getThumbnailFilename(sourcePath: string): string {
const hash = this.hashString(sourcePath);
return `thumb_${hash}.jpg`;
}
private hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(36);
}
async getThumbnail(coverPath: string, sourceFile: TFile): Promise<string | null> {
if (!this.plugin.settings.enableThumbnailCache) {
return null;
}
await this.loadCache();
const coverFile = this.app.metadataCache.getFirstLinkpathDest(coverPath, sourceFile.path);
if (!coverFile) return null;
const cacheKey = coverFile.path;
const cached = this.cache.entries[cacheKey];
if (cached) {
if (cached.sourceModified === coverFile.stat.mtime) {
const thumbnailExists = await this.app.vault.adapter.exists(cached.thumbnailPath);
if (thumbnailExists) {
const arrayBuffer = await this.app.vault.adapter.readBinary(cached.thumbnailPath);
const blob = new Blob([arrayBuffer], { type: 'image/jpeg' });
return URL.createObjectURL(blob);
}
}
delete this.cache.entries[cacheKey];
await this.saveCache();
}
return null;
}
private async generateThumbnailFromFile(file: TFile): Promise<{ thumbnailPath: string; size: number } | null> {
try {
const targetSize = this.plugin.settings.thumbnailTargetSize * 1000;
const arrayBuffer = await this.app.vault.readBinary(file);
const blob = new Blob([arrayBuffer]);
const bitmap = await createImageBitmap(blob);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const aspectRatio = bitmap.width / bitmap.height;
let width = THUMBNAIL_WIDTH;
let height = THUMBNAIL_HEIGHT;
if (aspectRatio > width / height) {
height = width / aspectRatio;
} else {
width = height * aspectRatio;
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
// Start with max quality and only reduce if needed
let quality = 1.0;
const dataUrl = canvas.toDataURL('image/jpeg', quality);
const base64Data = dataUrl.split(',')[1];
const binaryData = atob(base64Data);
let bytes = new Uint8Array(binaryData.length);
for (let i = 0; i < binaryData.length; i++) {
bytes[i] = binaryData.charCodeAt(i);
}
// Only reduce quality if we exceed the target
while (bytes.length > targetSize && quality > 0.1) {
quality -= 0.05;
const dataUrl = canvas.toDataURL('image/jpeg', quality);
const base64Data = dataUrl.split(',')[1];
const binaryData = atob(base64Data);
bytes = new Uint8Array(binaryData.length);
for (let i = 0; i < binaryData.length; i++) {
bytes[i] = binaryData.charCodeAt(i);
}
}
const thumbnailFilename = this.getThumbnailFilename(file.path);
const thumbnailPath = normalizePath(`${this.cacheDir}/${thumbnailFilename}`);
await this.ensureCacheDir();
await this.app.vault.adapter.writeBinary(thumbnailPath, bytes.buffer);
return {
thumbnailPath,
size: bytes.length
};
} catch (e) {
console.error('Failed to generate thumbnail:', e);
return null;
}
}
async generateThumbnail(coverPath: string, sourceFile: TFile): Promise<string | null> {
const coverFile = this.app.metadataCache.getFirstLinkpathDest(coverPath, sourceFile.path);
if (!coverFile) return null;
const result = await this.generateThumbnailFromFile(coverFile);
if (!result) return null;
const cacheKey = coverFile.path;
this.cache.entries[cacheKey] = {
thumbnailPath: result.thumbnailPath,
sourceModified: coverFile.stat.mtime,
size: result.size
};
await this.saveCache();
const arrayBuffer = await this.app.vault.adapter.readBinary(result.thumbnailPath);
const blob = new Blob([arrayBuffer], { type: 'image/jpeg' });
return URL.createObjectURL(blob);
}
async markForGeneration(coverPath: string, sourceFile: TFile): Promise<void> {
const coverFile = this.app.metadataCache.getFirstLinkpathDest(coverPath, sourceFile.path);
if (!coverFile) return;
await this.loadCache();
const cacheKey = coverFile.path;
if (!this.cache.pendingGeneration.includes(cacheKey) && !this.cache.entries[cacheKey]) {
this.cache.pendingGeneration.push(cacheKey);
await this.saveCache();
}
}
async generatePendingThumbnails(onProgress?: (current: number, total: number) => void): Promise<void> {
if (this.generationInProgress || !this.plugin.settings.enableThumbnailCache) {
return;
}
await this.loadCache();
if (this.cache.pendingGeneration.length === 0) {
return;
}
this.generationInProgress = true;
const pending = [...this.cache.pendingGeneration];
const total = pending.length;
for (let i = 0; i < pending.length; i++) {
const imagePath = pending[i];
const file = this.app.vault.getAbstractFileByPath(imagePath);
if (file instanceof TFile) {
const result = await this.generateThumbnailFromFile(file);
if (result) {
this.cache.entries[imagePath] = {
thumbnailPath: result.thumbnailPath,
sourceModified: file.stat.mtime,
size: result.size
};
}
}
const idx = this.cache.pendingGeneration.indexOf(imagePath);
if (idx > -1) {
this.cache.pendingGeneration.splice(idx, 1);
}
await this.saveCache();
if (onProgress) {
onProgress(i + 1, total);
}
}
this.generationInProgress = false;
}
async rebuildCache(onProgress?: (current: number, total: number) => void): Promise<void> {
if (this.generationInProgress || !this.plugin.settings.enableThumbnailCache) {
return;
}
await this.loadCache();
const allImagePaths = new Set<string>([
...Object.keys(this.cache.entries),
...this.cache.pendingGeneration
]);
if (allImagePaths.size === 0) {
return;
}
this.generationInProgress = true;
const imagePaths = Array.from(allImagePaths);
const total = imagePaths.length;
for (let i = 0; i < imagePaths.length; i++) {
const imagePath = imagePaths[i];
const file = this.app.vault.getAbstractFileByPath(imagePath);
if (file instanceof TFile) {
const oldEntry = this.cache.entries[imagePath];
if (oldEntry) {
try {
const exists = await this.app.vault.adapter.exists(oldEntry.thumbnailPath);
if (exists) {
await this.app.vault.adapter.remove(oldEntry.thumbnailPath);
}
} catch (e) {
console.error(`Failed to remove old thumbnail ${oldEntry.thumbnailPath}:`, e);
}
}
const result = await this.generateThumbnailFromFile(file);
if (result) {
this.cache.entries[imagePath] = {
thumbnailPath: result.thumbnailPath,
sourceModified: file.stat.mtime,
size: result.size
};
}
}
const pendingIdx = this.cache.pendingGeneration.indexOf(imagePath);
if (pendingIdx > -1) {
this.cache.pendingGeneration.splice(pendingIdx, 1);
}
await this.saveCache();
if (onProgress) {
onProgress(i + 1, total);
}
}
this.generationInProgress = false;
}
async clearCache(): Promise<void> {
await this.loadCache();
const thumbnailPaths = Object.values(this.cache.entries).map(entry => entry.thumbnailPath);
for (const path of thumbnailPaths) {
try {
const exists = await this.app.vault.adapter.exists(path);
if (exists) {
await this.app.vault.adapter.remove(path);
}
} catch (e) {
console.error(`Failed to remove thumbnail ${path}:`, e);
}
}
this.cache = {
entries: {},
pendingGeneration: []
};
await this.saveCache();
}
getCacheStats(): { count: number; totalSize: number; pending: number } {
const entries = Object.values(this.cache.entries);
return {
count: entries.length,
totalSize: entries.reduce((sum, entry) => sum + entry.size, 0),
pending: this.cache.pendingGeneration.length
};
}
isGenerating(): boolean {
return this.generationInProgress;
}
}

View file

@ -146,12 +146,11 @@ export class MapBasesView extends BasesView {
}
private async renderMap(): Promise<void> {
// Need either coordinates property OR global lat/lng keys
if (!this.data || (!this.coordinatesProp && (!this.plugin.settings.latKey || !this.plugin.settings.lngKey))) {
if (!this.data) {
this.containerEl.removeClass('is-loading');
return;
}
const loadingOverlay = this.containerEl.createDiv({ cls: 'map-loading-overlay' });
const spinner = loadingOverlay.createDiv();
@ -228,13 +227,13 @@ export class MapBasesView extends BasesView {
app: this.app,
settings: settings,
tagSettings: tagSettings,
thumbnailCache: this.plugin.thumbnailCache,
options: {
center: centerToUse,
zoom: zoomToUse,
height: height,
markerType: this.markerType,
tileLayer: this.tileLayer,
showSearch: false,
onTilesLoaded: () => {
tilesLoaded = true;
hideOverlay();
@ -320,13 +319,17 @@ export class MapBasesView extends BasesView {
const coverVal = entry.getValue(this.coverProp);
if (coverVal) {
point.cover = coverVal.toString();
if (this.plugin.settings.enableThumbnailCache) {
this.plugin.thumbnailCache.markForGeneration(point.cover, entry.file);
}
}
}
const properties: Array<{ name: string; value: string }> = [];
if (this.data.properties) {
for (const prop of this.data.properties.slice(0, 20)) {
if (prop === this.coordinatesProp || prop === this.coverProp) continue;
if (prop === this.coordinatesProp) continue;
try {
const value = entry.getValue(prop);

View file

@ -69,22 +69,25 @@
position: absolute;
left: var(--tooltip-x, 0);
top: var(--tooltip-y, 0);
padding: 8px 12px;
padding: 10px 14px;
background: var(--background-primary);
color: var(--text-normal);
border-radius: 4px;
border-radius: 6px;
pointer-events: none;
opacity: 0;
z-index: 1000;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
max-width: 250px;
font-size: 13px;
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
width: max-content;
max-width: 400px;
transition: opacity 0.15s ease;
.map-tooltip-property {
display: flex;
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
margin-top: 4px;
align-items: baseline;
}
.map-tooltip-tags {
@ -97,12 +100,15 @@
.map-tooltip-property-label {
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
}
.map-tooltip-property-value {
font-size: 12px;
word-break: break-word;
overflow-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.map-tooltip-cover-image {
@ -115,7 +121,9 @@
}
.map-tooltip-title {
font-weight: bold;
font-weight: 600;
font-size: 14px;
margin-bottom: 2px;
}
.map-tooltip-property-container {