diff --git a/src/main.ts b/src/main.ts index a4a354b..f923f34 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,13 +19,10 @@ export default class MapPlugin extends Plugin { if (this.settings.enableThumbnailCache) { await this.thumbnailCache.loadCache(); - const stats = this.thumbnailCache.getCacheStats(); - if (stats.pending > 0) { - setTimeout(() => { - this.thumbnailCache.generatePendingThumbnails(); - }, 2000); - } + setTimeout(() => { + this.thumbnailCache.generatePendingThumbnails(); + }, 2000); } this.registerBasesView('map', { diff --git a/src/settings/map-settings.ts b/src/settings/map-settings.ts index f4b2e9c..be48a59 100644 --- a/src/settings/map-settings.ts +++ b/src/settings/map-settings.ts @@ -10,6 +10,7 @@ export interface MapPluginSettings { autoCenter: boolean; transitionDuration: number; enableThumbnailCache: boolean; + thumbnailTargetSize: number; tagSettings: MapTagSettings; } @@ -21,6 +22,7 @@ export const DEFAULT_SETTINGS: MapPluginSettings = { autoCenter: true, transitionDuration: 1000, enableThumbnailCache: false, + thumbnailTargetSize: 25, tagSettings: DEFAULT_MAP_TAG_SETTINGS }; @@ -119,27 +121,40 @@ export class MapSettingTab extends PluginSettingTab { })); 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 cacheInfo = new Setting(containerEl) .setName('Thumbnail cache status') .setDesc(`${stats.count} thumbnails cached (${sizeKB} KB)`); - if (stats.pending > 0) { - cacheInfo.setDesc(`${stats.count} thumbnails cached (${sizeKB} KB), ${stats.pending} pending generation`); - + if (stats.pending > 0 || isGenerating) { + cacheInfo.setDesc(`${stats.count} thumbnails cached (${sizeKB} KB), ${stats.pending} pending`); + } else if (stats.count > 0) { cacheInfo.addButton(button => button - .setButtonText('Generate thumbnails') + .setButtonText('Refresh cache') .onClick(async () => { button.setDisabled(true); - button.setButtonText('Generating...'); + button.setButtonText('Refreshing...'); - await this.plugin.thumbnailCache.generatePendingThumbnails((current, total) => { - button.setButtonText(`Generating ${current}/${total}...`); + await this.plugin.thumbnailCache.rebuildCache((current, total) => { + button.setButtonText(`Refreshing ${current}/${total}...`); }); - button.setButtonText('Generate thumbnails'); + button.setButtonText('Refresh cache'); button.setDisabled(false); this.display(); })); diff --git a/src/thumbnail-cache.ts b/src/thumbnail-cache.ts index c73f7e2..9d06f8b 100644 --- a/src/thumbnail-cache.ts +++ b/src/thumbnail-cache.ts @@ -12,7 +12,6 @@ export interface ThumbnailCacheMetadata { pendingGeneration: string[]; } -const THUMBNAIL_MAX_SIZE = 25000; // 25kb target const THUMBNAIL_WIDTH = 300; const THUMBNAIL_HEIGHT = 200; const CACHE_DIR = '.obsidian/plugins/mapplus/.cache'; @@ -123,12 +122,10 @@ export class ThumbnailCacheManager { return null; } - async generateThumbnail(coverPath: string, sourceFile: TFile): Promise { - const coverFile = this.app.metadataCache.getFirstLinkpathDest(coverPath, sourceFile.path); - if (!coverFile) return null; - + private async generateThumbnailFromFile(file: TFile): Promise<{ thumbnailPath: string; size: number } | null> { try { - const arrayBuffer = await this.app.vault.readBinary(coverFile); + 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); @@ -155,7 +152,7 @@ export class ThumbnailCacheManager { let quality = 0.7; let dataUrl = canvas.toDataURL('image/jpeg', quality); - while (dataUrl.length > THUMBNAIL_MAX_SIZE && quality > 0.1) { + while (dataUrl.length > targetSize && quality > 0.1) { quality -= 0.1; dataUrl = canvas.toDataURL('image/jpeg', quality); } @@ -167,29 +164,43 @@ export class ThumbnailCacheManager { bytes[i] = binaryData.charCodeAt(i); } - const thumbnailFilename = this.getThumbnailFilename(coverFile.path); + const thumbnailFilename = this.getThumbnailFilename(file.path); const thumbnailPath = normalizePath(`${this.cacheDir}/${thumbnailFilename}`); await this.ensureCacheDir(); await this.app.vault.adapter.writeBinary(thumbnailPath, bytes.buffer); - const cacheKey = coverFile.path; - this.cache.entries[cacheKey] = { + return { thumbnailPath, - sourceModified: coverFile.stat.mtime, size: bytes.length }; - - await this.saveCache(); - - const resultBlob = new Blob([bytes], { type: 'image/jpeg' }); - return URL.createObjectURL(resultBlob); } catch (e) { console.error('Failed to generate thumbnail:', e); return null; } } + async generateThumbnail(coverPath: string, sourceFile: TFile): Promise { + 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 { const coverFile = this.app.metadataCache.getFirstLinkpathDest(coverPath, sourceFile.path); if (!coverFile) return; @@ -224,59 +235,13 @@ export class ThumbnailCacheManager { const file = this.app.vault.getAbstractFileByPath(imagePath); if (file instanceof TFile) { - try { - 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) continue; - - 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(); - - let quality = 0.7; - let dataUrl = canvas.toDataURL('image/jpeg', quality); - - while (dataUrl.length > THUMBNAIL_MAX_SIZE && quality > 0.1) { - quality -= 0.1; - dataUrl = canvas.toDataURL('image/jpeg', quality); - } - - const base64Data = dataUrl.split(',')[1]; - const binaryData = atob(base64Data); - const bytes = new Uint8Array(binaryData.length); - for (let j = 0; j < binaryData.length; j++) { - bytes[j] = binaryData.charCodeAt(j); - } - - const thumbnailFilename = this.getThumbnailFilename(imagePath); - const thumbnailPath = normalizePath(`${this.cacheDir}/${thumbnailFilename}`); - - await this.ensureCacheDir(); - await this.app.vault.adapter.writeBinary(thumbnailPath, bytes.buffer); - + const result = await this.generateThumbnailFromFile(file); + if (result) { this.cache.entries[imagePath] = { - thumbnailPath, + thumbnailPath: result.thumbnailPath, sourceModified: file.stat.mtime, - size: bytes.length + size: result.size }; - } catch (e) { - console.error(`Failed to generate thumbnail for ${imagePath}:`, e); } } @@ -295,6 +260,59 @@ export class ThumbnailCacheManager { this.generationInProgress = false; } + async rebuildCache(onProgress?: (current: number, total: number) => void): Promise { + if (this.generationInProgress || !this.plugin.settings.enableThumbnailCache) { + return; + } + + await this.loadCache(); + + const allImagePaths = Object.keys(this.cache.entries); + if (allImagePaths.length === 0) { + return; + } + + this.generationInProgress = true; + + const total = allImagePaths.length; + + for (let i = 0; i < allImagePaths.length; i++) { + const imagePath = allImagePaths[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 + }; + } + } + + await this.saveCache(); + + if (onProgress) { + onProgress(i + 1, total); + } + } + + this.generationInProgress = false; + } + async clearCache(): Promise { await this.loadCache(); diff --git a/styles.css b/styles.css index 62be9d6..e49ee71 100644 --- a/styles.css +++ b/styles.css @@ -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 {