├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmrc ├── CLAUDE.md ├── LICENSE ├── README.md ├── esbuild.config.mjs ├── main.ts ├── manifest.json ├── modal-styles.css ├── ocr-service.ts ├── package.json ├── src ├── modals │ └── VaultSearchModal.ts ├── styles │ └── styleManager.ts └── types.ts ├── styles.css ├── styles └── modal-reset.css ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | 5 | # Temporary files 6 | 2 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | pnpm-lock.yaml 11 | 12 | # Don't include the compiled main.js file in the repo. 13 | # They should be uploaded to GitHub releases instead. 14 | main.js 15 | 16 | # Exclude sourcemaps 17 | *.map 18 | 19 | # obsidian 20 | data.json 21 | 22 | # OCR index file (contains processed data) 23 | ocr-index.json 24 | 25 | # Temporary files 26 | 2 27 | 28 | # Exclude macOS Finder (System Explorer) View States 29 | .DS_Store 30 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # Claude Development Guide 2 | 3 | ## Building Process 4 | 5 | To build the plugin after making changes: 6 | 7 | ```bash 8 | npm run build 9 | ``` 10 | 11 | This command will: 12 | 1. Run TypeScript type checking (`tsc -noEmit -skipLibCheck`) 13 | 2. Bundle the plugin using esbuild for production 14 | 15 | Always run the build command after making code changes to ensure the plugin compiles correctly. 16 | 17 | ## Testing 18 | 19 | After building, reload Obsidian or disable/enable the plugin to test your changes. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020-2025 by Dynalist Inc. 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Image Gallery Plugin 2 | 3 | An Obsidian plugin that displays all images in your vault in a beautiful gallery view with OCR text search capabilities. Supports both local and remote images. 4 | 5 | ## Features 6 | 7 | - **Gallery View**: Display all images from your vault in an organized gallery layout 8 | - **OCR Text Recognition**: Automatically extract text from images using macOS Vision framework 9 | - **Smart Search**: Search images by their OCR content or contextual information 10 | - **Support for Multiple Formats**: Works with both local images and remote image URLs 11 | - **Context-Aware**: Shows which notes reference each image and surrounding content 12 | - **Batch Processing**: Efficiently process multiple images with configurable concurrency 13 | 14 | ## Installation 15 | 16 | ### Manual Installation 17 | 18 | 1. Download the latest release files (`main.js`, `manifest.json`, `styles.css`) 19 | 2. Copy them to your vault's plugin folder: `VaultFolder/.obsidian/plugins/image-gallery-plugin/` 20 | 3. Reload Obsidian and enable the plugin in Settings → Community Plugins 21 | 22 | ### Development Setup 23 | 24 | 1. Clone this repository to your vault's plugin folder 25 | 2. Install dependencies: `npm i` 26 | 3. Build the plugin: `npm run build` 27 | 4. Reload Obsidian and enable the plugin 28 | 29 | ## Usage 30 | 31 | The plugin automatically scans your vault for images and provides: 32 | 33 | - A gallery view accessible through the ribbon icon or command palette 34 | - OCR text extraction from images (macOS only) 35 | - Search functionality to find images by their text content 36 | - Context information showing which notes reference each image 37 | 38 | ## OCR Features 39 | 40 | The OCR functionality uses macOS's Vision framework and supports: 41 | 42 | - **Multi-language recognition**: Optimized for Chinese (Simplified & Traditional) and English 43 | - **Automatic language detection** 44 | - **Caching**: Results are cached to avoid re-processing unchanged images 45 | - **Context extraction**: Captures surrounding text from notes that reference images 46 | - **Advanced search**: Support for phrases, negation, and OR operators 47 | 48 | ### Search Syntax 49 | 50 | - `"exact phrase"`: Search for exact phrases 51 | - `-word`: Exclude images containing this word 52 | - `word1 OR word2`: Find images containing either word 53 | - `word1 word2`: Find images containing both words 54 | 55 | ## Development 56 | 57 | ### Building 58 | 59 | ```bash 60 | npm run dev # Development mode with file watching 61 | npm run build # Production build 62 | ``` 63 | 64 | ### Project Structure 65 | 66 | - `main.ts`: Main plugin entry point 67 | - `ocr-service.ts`: OCR functionality and image text processing 68 | - `styles.css`: Plugin styling 69 | - `manifest.json`: Plugin metadata 70 | 71 | ## Requirements 72 | 73 | - Obsidian v0.15.0+ 74 | - macOS (for OCR functionality) 75 | 76 | ## Contributing 77 | 78 | 1. Fork the repository 79 | 2. Create a feature branch 80 | 3. Make your changes 81 | 4. Test thoroughly 82 | 5. Submit a pull request 83 | 84 | ## License 85 | 86 | MIT License - see LICENSE file for details. 87 | 88 | ## Author 89 | 90 | Julian Zhang ([@forrestchang](https://github.com/forrestchang)) -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | minify: prod, 42 | }); 43 | 44 | if (prod) { 45 | await context.rebuild(); 46 | process.exit(0); 47 | } else { 48 | await context.watch(); 49 | } 50 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Notice, Plugin, TFile, getAllTags, MarkdownView, DropdownComponent, TextComponent, ButtonComponent, PluginSettingTab, Setting } from 'obsidian'; 2 | import { OCRService } from './ocr-service'; 3 | import { VaultSearchModal } from './src/modals/VaultSearchModal'; 4 | 5 | interface ImageInfo { 6 | path: string; 7 | file?: TFile; 8 | isLocal: boolean; 9 | displayName: string; 10 | createdTime?: number; 11 | modifiedTime?: number; 12 | ocrText?: string; 13 | } 14 | 15 | interface ImageGallerySettings { 16 | enableOCRDebug: boolean; 17 | ocrConcurrency: number; 18 | contextParagraphs: number; 19 | enableFolderFilter: boolean; 20 | searchExcludeFolders: string[]; 21 | searchMinimalMode: boolean; 22 | searchIncludeImages: boolean; 23 | galleryCardSize: number; 24 | } 25 | 26 | const DEFAULT_SETTINGS: ImageGallerySettings = { 27 | enableOCRDebug: false, 28 | ocrConcurrency: 4, 29 | contextParagraphs: 3, 30 | enableFolderFilter: true, 31 | searchExcludeFolders: [], 32 | searchMinimalMode: false, 33 | searchIncludeImages: true, 34 | galleryCardSize: 200 35 | } 36 | 37 | export default class ImageGalleryPlugin extends Plugin { 38 | ocrService: OCRService; 39 | settings: ImageGallerySettings; 40 | 41 | async onload() { 42 | console.log('Loading Image Gallery plugin'); 43 | 44 | // Load settings 45 | await this.loadSettings(); 46 | 47 | // Initialize OCR service 48 | this.ocrService = new OCRService(this.app); 49 | await this.ocrService.loadIndex(); 50 | 51 | // Add ribbon icon for Image Gallery 52 | const ribbonIconEl = this.addRibbonIcon('image', 'Image Gallery', (evt: MouseEvent) => { 53 | this.openImageGallery(); 54 | }); 55 | 56 | // Add ribbon icon for Search+ 57 | const searchRibbonIconEl = this.addRibbonIcon('search', 'Search+', (evt: MouseEvent) => { 58 | this.openVaultSearch(); 59 | }); 60 | 61 | // Add command to open gallery 62 | this.addCommand({ 63 | id: 'open-image-gallery', 64 | name: 'Open Image Gallery', 65 | callback: () => { 66 | this.openImageGallery(); 67 | } 68 | }); 69 | 70 | // Add command to open Search+ 71 | this.addCommand({ 72 | id: 'open-vault-search', 73 | name: 'Open Search+', 74 | callback: () => { 75 | this.openVaultSearch(); 76 | } 77 | }); 78 | 79 | // Add command to rebuild OCR index 80 | this.addCommand({ 81 | id: 'rebuild-ocr-index', 82 | name: 'Rebuild OCR Index for Images', 83 | callback: async () => { 84 | const startTime = Date.now(); 85 | const notice = new Notice('Building OCR index...', 0); 86 | const images = await this.getAllImages(); 87 | 88 | await this.ocrService.indexAllImages(images, (current, total) => { 89 | const elapsed = Math.round((Date.now() - startTime) / 1000); 90 | const remaining = total - current; 91 | const rate = current / elapsed || 0; 92 | const eta = remaining > 0 && rate > 0 ? Math.round(remaining / rate) : 0; 93 | 94 | notice.setMessage(`Indexing images: ${current}/${total} (${elapsed}s elapsed, ${eta}s remaining)`); 95 | }, this.settings.ocrConcurrency); 96 | 97 | const totalTime = Math.round((Date.now() - startTime) / 1000); 98 | notice.setMessage(`OCR index complete! (${totalTime}s total)`); 99 | setTimeout(() => notice.hide(), 3000); 100 | } 101 | }); 102 | 103 | // Add command to debug OCR for current image 104 | this.addCommand({ 105 | id: 'debug-ocr-current-image', 106 | name: 'Debug OCR for Current Image', 107 | callback: async () => { 108 | if (!this.settings.enableOCRDebug) { 109 | new Notice('OCR debug is disabled. Enable it in plugin settings first.'); 110 | return; 111 | } 112 | 113 | const activeFile = this.app.workspace.getActiveFile(); 114 | if (!activeFile) { 115 | new Notice('No active file selected.'); 116 | return; 117 | } 118 | 119 | const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp']; 120 | if (!imageExtensions.includes(activeFile.extension.toLowerCase())) { 121 | new Notice('Active file is not an image.'); 122 | return; 123 | } 124 | 125 | new OCRDebugModal(this.app, activeFile, this.ocrService).open(); 126 | } 127 | }); 128 | 129 | // This adds a settings tab so the user can configure various aspects of the plugin 130 | this.addSettingTab(new ImageGallerySettingTab(this.app, this)); 131 | } 132 | 133 | async loadSettings() { 134 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 135 | } 136 | 137 | async saveSettings() { 138 | await this.saveData(this.settings); 139 | } 140 | 141 | async openImageGallery() { 142 | const images = await this.getAllImages(); 143 | new ImageGalleryModal(this.app, images, this.ocrService).open(); 144 | } 145 | 146 | openVaultSearch() { 147 | new VaultSearchModal(this.app, this).open(); 148 | } 149 | 150 | async getAllImages(): Promise { 151 | const images: ImageInfo[] = []; 152 | const addedPaths = new Set(); // Track added file paths to avoid duplicates 153 | 154 | // Get all files in vault 155 | const files = this.app.vault.getFiles(); 156 | 157 | // Filter image files (local images) 158 | const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp']; 159 | for (const file of files) { 160 | const extension = file.extension.toLowerCase(); 161 | if (imageExtensions.includes(extension)) { 162 | images.push({ 163 | path: file.path, 164 | file: file, 165 | isLocal: true, 166 | displayName: file.name, 167 | createdTime: file.stat.ctime, 168 | modifiedTime: file.stat.mtime 169 | }); 170 | addedPaths.add(file.path); // Track this file path 171 | } 172 | } 173 | 174 | // Scan markdown files for embedded images and URLs 175 | const markdownFiles = files.filter(f => f.extension === 'md'); 176 | for (const mdFile of markdownFiles) { 177 | const content = await this.app.vault.read(mdFile); 178 | 179 | // Find wiki-style embeds: ![[image.png]] or ![[image]] (without extension) 180 | const wikiEmbedRegex = /!\[\[([^\]]+)\]\]/gi; 181 | let match; 182 | while ((match = wikiEmbedRegex.exec(content)) !== null) { 183 | const imagePath = match[1]; 184 | 185 | // Check if it looks like an image (has extension or common image name) 186 | const hasImageExtension = /\.(png|jpg|jpeg|gif|bmp|svg|webp)$/i.test(imagePath); 187 | const imageFile = this.app.metadataCache.getFirstLinkpathDest(imagePath, mdFile.path); 188 | 189 | // Only add if it's an actual file that exists 190 | if (imageFile && !addedPaths.has(imageFile.path)) { 191 | // Verify it's an image file 192 | const extension = imageFile.extension.toLowerCase(); 193 | const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp']; 194 | 195 | if (imageExtensions.includes(extension)) { 196 | images.push({ 197 | path: imageFile.path, 198 | file: imageFile, 199 | isLocal: true, 200 | displayName: imageFile.name, 201 | createdTime: imageFile.stat.ctime, 202 | modifiedTime: imageFile.stat.mtime 203 | }); 204 | addedPaths.add(imageFile.path); 205 | } 206 | } else if (!imageFile && hasImageExtension) { 207 | // Log missing images for debugging 208 | console.warn(`Image not found: ${imagePath} referenced in ${mdFile.path}`); 209 | } 210 | } 211 | 212 | // Find markdown-style embeds: ![](image.png) or ![](http://...) 213 | const markdownEmbedRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; 214 | while ((match = markdownEmbedRegex.exec(content)) !== null) { 215 | const imagePath = match[2]; 216 | 217 | // Check if it's a URL 218 | if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) { 219 | // Skip placeholder URLs that are commonly used in examples 220 | if (imagePath.includes('via.placeholder.com') || 221 | imagePath.includes('placeholder.') || 222 | imagePath.includes('example.com')) { 223 | continue; 224 | } 225 | 226 | // Check if we haven't already added this URL 227 | if (!addedPaths.has(imagePath)) { 228 | images.push({ 229 | path: imagePath, 230 | isLocal: false, 231 | displayName: match[1] || imagePath.split('/').pop() || 'Remote Image' 232 | }); 233 | addedPaths.add(imagePath); 234 | } 235 | } else { 236 | // Local image reference 237 | const imageFile = this.app.metadataCache.getFirstLinkpathDest(imagePath, mdFile.path); 238 | if (imageFile && !addedPaths.has(imageFile.path)) { 239 | // Verify it's an image file 240 | const extension = imageFile.extension.toLowerCase(); 241 | const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp']; 242 | 243 | if (imageExtensions.includes(extension)) { 244 | images.push({ 245 | path: imageFile.path, 246 | file: imageFile, 247 | isLocal: true, 248 | displayName: imageFile.name, 249 | createdTime: imageFile.stat.ctime, 250 | modifiedTime: imageFile.stat.mtime 251 | }); 252 | addedPaths.add(imageFile.path); 253 | } 254 | } 255 | } 256 | } 257 | 258 | // Find HTML img tags: 259 | const htmlImgRegex = /]+src=["']([^"']+)["'][^>]*>/g; 260 | while ((match = htmlImgRegex.exec(content)) !== null) { 261 | const imagePath = match[1]; 262 | 263 | // Check if it's a URL 264 | if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) { 265 | // Skip placeholder URLs that are commonly used in examples 266 | if (imagePath.includes('via.placeholder.com') || 267 | imagePath.includes('placeholder.') || 268 | imagePath.includes('example.com')) { 269 | continue; 270 | } 271 | 272 | if (!addedPaths.has(imagePath)) { 273 | images.push({ 274 | path: imagePath, 275 | isLocal: false, 276 | displayName: imagePath.split('/').pop() || 'Remote Image' 277 | }); 278 | addedPaths.add(imagePath); 279 | } 280 | } 281 | } 282 | } 283 | 284 | return images; 285 | } 286 | 287 | onunload() { 288 | console.log('Unloading Image Gallery plugin'); 289 | 290 | // Clean up all plugin styles on unload 291 | const styleIds = [ 292 | 'image-preview-modal-styles', 293 | 'image-gallery-modal-styles', 294 | 'ocr-debug-modal-styles', 295 | 'confirm-modal-styles' 296 | ]; 297 | 298 | styleIds.forEach(id => { 299 | const style = document.getElementById(id); 300 | if (style) { 301 | style.remove(); 302 | } 303 | }); 304 | 305 | // Also clean up any remaining plugin styles 306 | const styles = document.querySelectorAll('style'); 307 | styles.forEach(style => { 308 | if (style.textContent && ( 309 | style.textContent.includes('.image-preview-container') || 310 | style.textContent.includes('.image-gallery-container') || 311 | style.textContent.includes('.ocr-debug-') || 312 | style.textContent.includes('.confirm-') 313 | )) { 314 | style.remove(); 315 | } 316 | }); 317 | } 318 | } 319 | 320 | class ImagePreviewModal extends Modal { 321 | imageInfo: ImageInfo; 322 | currentIndex: number; 323 | allImages: ImageInfo[]; 324 | app: App; 325 | zoomLevel: number = 1; 326 | minZoom: number = 0.5; 327 | maxZoom: number = 5; 328 | imageEl: HTMLImageElement | null = null; 329 | imageContainer: HTMLElement | null = null; 330 | isPanning: boolean = false; 331 | startX: number = 0; 332 | startY: number = 0; 333 | translateX: number = 0; 334 | translateY: number = 0; 335 | 336 | constructor(app: App, imageInfo: ImageInfo, allImages: ImageInfo[], currentIndex: number) { 337 | super(app); 338 | this.app = app; 339 | this.imageInfo = imageInfo; 340 | this.allImages = allImages; 341 | this.currentIndex = currentIndex; 342 | } 343 | 344 | onOpen() { 345 | const { contentEl } = this; 346 | contentEl.empty(); 347 | 348 | // Reset zoom and pan when changing images 349 | this.zoomLevel = 1; 350 | this.translateX = 0; 351 | this.translateY = 0; 352 | 353 | // Clean up any existing styles first 354 | this.cleanupStyles(); 355 | 356 | // Add custom class for styling 357 | this.modalEl.addClass('mod-image-preview'); 358 | 359 | // Create container 360 | const container = contentEl.createDiv({ cls: 'image-preview-container' }); 361 | 362 | // Add top controls container - compact menu bar 363 | const topControls = container.createDiv({ cls: 'image-preview-top-controls' }); 364 | 365 | // Navigation controls (left side) 366 | const navControls = topControls.createDiv({ cls: 'image-preview-nav-controls' }); 367 | 368 | // Add navigation buttons if there are multiple images 369 | if (this.allImages.length > 1) { 370 | // Previous button 371 | const prevBtn = navControls.createEl('button', { 372 | text: '◀', 373 | cls: 'image-preview-control-btn', 374 | title: 'Previous (←)' 375 | }); 376 | prevBtn.onclick = () => this.navigate(-1); 377 | 378 | // Image counter 379 | navControls.createEl('span', { 380 | text: `${this.currentIndex + 1} / ${this.allImages.length}`, 381 | cls: 'image-preview-counter' 382 | }); 383 | 384 | // Next button 385 | const nextBtn = navControls.createEl('button', { 386 | text: '▶', 387 | cls: 'image-preview-control-btn', 388 | title: 'Next (→)' 389 | }); 390 | nextBtn.onclick = () => this.navigate(1); 391 | 392 | // Random button 393 | const randomBtn = navControls.createEl('button', { 394 | text: '🎲', 395 | cls: 'image-preview-control-btn', 396 | title: 'Random Image (Space)' 397 | }); 398 | randomBtn.onclick = () => this.navigateRandom(); 399 | } 400 | 401 | // Action controls (right side) 402 | const actionControls = topControls.createDiv({ cls: 'image-preview-action-controls' }); 403 | 404 | // Zoom controls 405 | const zoomOutBtn = actionControls.createEl('button', { 406 | text: '−', 407 | cls: 'image-preview-control-btn', 408 | title: 'Zoom Out (-)' 409 | }); 410 | zoomOutBtn.onclick = () => this.zoom(-0.25); 411 | 412 | // Zoom level display 413 | const zoomLevelEl = actionControls.createEl('span', { 414 | text: '100%', 415 | cls: 'image-preview-zoom-level' 416 | }); 417 | 418 | const zoomInBtn = actionControls.createEl('button', { 419 | text: '+', 420 | cls: 'image-preview-control-btn', 421 | title: 'Zoom In (+)' 422 | }); 423 | zoomInBtn.onclick = () => this.zoom(0.25); 424 | 425 | const resetZoomBtn = actionControls.createEl('button', { 426 | text: 'Reset', 427 | cls: 'image-preview-control-btn', 428 | title: 'Reset Zoom (R)' 429 | }); 430 | resetZoomBtn.onclick = () => this.resetZoom(); 431 | 432 | const fitBtn = actionControls.createEl('button', { 433 | text: 'Fit', 434 | cls: 'image-preview-control-btn', 435 | title: 'Fit to Window (F)' 436 | }); 437 | fitBtn.onclick = () => this.fitToWindow(); 438 | 439 | // Add OCR debug button for local images if debug is enabled 440 | if (this.imageInfo.isLocal && this.imageInfo.file) { 441 | const ocrDebugBtn = actionControls.createEl('button', { 442 | text: '🔍', 443 | cls: 'image-preview-control-btn', 444 | title: 'Debug OCR' 445 | }); 446 | 447 | ocrDebugBtn.onclick = () => { 448 | const plugin = (this.app as any).plugins.getPlugin('image-gallery-plugin'); 449 | if (!plugin) { 450 | new Notice('Plugin not found'); 451 | return; 452 | } 453 | 454 | if (!plugin.settings.enableOCRDebug) { 455 | new Notice('OCR debug is disabled. Enable it in plugin settings first.'); 456 | return; 457 | } 458 | 459 | new OCRDebugModal(this.app, this.imageInfo.file!, plugin.ocrService).open(); 460 | }; 461 | } 462 | 463 | // Image container 464 | this.imageContainer = container.createDiv({ cls: 'image-preview-image-container' }); 465 | this.imageEl = this.imageContainer.createEl('img', { cls: 'image-preview-img' }); 466 | 467 | if (this.imageInfo.isLocal && this.imageInfo.file) { 468 | const resourcePath = this.app.vault.getResourcePath(this.imageInfo.file); 469 | this.imageEl.src = resourcePath; 470 | } else { 471 | this.imageEl.src = this.imageInfo.path; 472 | } 473 | 474 | this.imageEl.alt = this.imageInfo.displayName; 475 | 476 | // Add mouse wheel zoom 477 | this.imageContainer.addEventListener('wheel', (e: WheelEvent) => { 478 | e.preventDefault(); 479 | const delta = e.deltaY > 0 ? -0.1 : 0.1; 480 | this.zoom(delta); 481 | }); 482 | 483 | // Add pan functionality 484 | this.imageEl.addEventListener('mousedown', (e: MouseEvent) => { 485 | if (this.zoomLevel > 1) { 486 | this.isPanning = true; 487 | this.startX = e.clientX - this.translateX; 488 | this.startY = e.clientY - this.translateY; 489 | this.imageEl!.style.cursor = 'grabbing'; 490 | e.preventDefault(); 491 | } 492 | }); 493 | 494 | document.addEventListener('mousemove', (e: MouseEvent) => { 495 | if (this.isPanning && this.imageEl) { 496 | this.translateX = e.clientX - this.startX; 497 | this.translateY = e.clientY - this.startY; 498 | this.updateImageTransform(); 499 | } 500 | }); 501 | 502 | document.addEventListener('mouseup', () => { 503 | if (this.isPanning && this.imageEl) { 504 | this.isPanning = false; 505 | this.imageEl.style.cursor = this.zoomLevel > 1 ? 'grab' : 'default'; 506 | } 507 | }); 508 | 509 | // Update zoom level display 510 | const updateZoomDisplay = () => { 511 | zoomLevelEl.textContent = `${Math.round(this.zoomLevel * 100)}%`; 512 | }; 513 | 514 | // Store reference to zoom display element for updates 515 | (this.imageEl as any).zoomDisplayEl = zoomLevelEl; 516 | 517 | // Compact bottom metadata bar 518 | const bottomBar = container.createDiv({ cls: 'image-preview-bottom-bar' }); 519 | 520 | // Build metadata string parts 521 | const metadataParts: string[] = []; 522 | metadataParts.push(this.imageInfo.displayName); 523 | 524 | if (this.imageInfo.createdTime) { 525 | const createdDate = new Date(this.imageInfo.createdTime); 526 | metadataParts.push(`Created: ${createdDate.toLocaleDateString()}`); 527 | } 528 | 529 | // Add referencing notes info for local images 530 | if (this.imageInfo.isLocal && this.imageInfo.file) { 531 | const plugin = (this.app as any).plugins.getPlugin('image-gallery-plugin'); 532 | if (plugin && plugin.ocrService) { 533 | const ocrResult = plugin.ocrService.getCachedResult(this.imageInfo.file.path); 534 | if (ocrResult && ocrResult.context && ocrResult.context.referencingNotes.length > 0) { 535 | const notesTitles = ocrResult.context.referencingNotes.map((note: any) => note.title); 536 | if (notesTitles.length > 0) { 537 | if (notesTitles.length === 1) { 538 | metadataParts.push(`Referenced in: ${notesTitles[0]}`); 539 | } else { 540 | metadataParts.push(`Referenced in ${notesTitles.length} notes: ${notesTitles.slice(0, 2).join(', ')}${notesTitles.length > 2 ? '...' : ''}`); 541 | } 542 | } 543 | 544 | // Add clickable referencing notes section (expandable) 545 | const referencingBtn = actionControls.createEl('button', { 546 | text: '📝', 547 | cls: 'image-preview-control-btn', 548 | title: `Jump to referencing notes (${ocrResult.context.referencingNotes.length})` 549 | }); 550 | 551 | referencingBtn.onclick = () => { 552 | // Create a quick selector modal for referencing notes 553 | const notes = ocrResult.context!.referencingNotes; 554 | if (notes.length === 1) { 555 | // If only one note, open it directly 556 | const file = this.app.vault.getAbstractFileByPath(notes[0].path); 557 | if (file instanceof TFile) { 558 | const leaf = this.app.workspace.getLeaf('tab'); 559 | leaf.openFile(file); 560 | this.close(); 561 | new Notice(`Opened: ${notes[0].title}`); 562 | } 563 | } else { 564 | // Create a simple selection modal 565 | const suggester = new (this as any).app.plugins.plugins.quickswitcher?.QuickSwitcherModal || null; 566 | if (suggester) { 567 | // Use existing quick switcher if available 568 | notes.forEach(async (note: any) => { 569 | const file = this.app.vault.getAbstractFileByPath(note.path); 570 | if (file instanceof TFile) { 571 | const leaf = this.app.workspace.getLeaf('tab'); 572 | await leaf.openFile(file); 573 | } 574 | }); 575 | } else { 576 | // Simple notice with first note 577 | const file = this.app.vault.getAbstractFileByPath(notes[0].path); 578 | if (file instanceof TFile) { 579 | const leaf = this.app.workspace.getLeaf('tab'); 580 | leaf.openFile(file); 581 | this.close(); 582 | new Notice(`Opened: ${notes[0].title}`); 583 | } 584 | } 585 | } 586 | }; 587 | } 588 | } 589 | } 590 | 591 | // Join all metadata parts with separators 592 | bottomBar.createEl('div', { 593 | text: metadataParts.join(' • '), 594 | cls: 'image-preview-metadata' 595 | }); 596 | 597 | // Add CSS styles with unique ID and proper isolation 598 | const style = document.createElement('style'); 599 | style.id = 'image-preview-modal-styles'; 600 | // Use data attribute to increase specificity and prevent conflicts 601 | style.textContent = ` 602 | /* Scoped styles for image preview modal only */ 603 | .modal.mod-image-preview { 604 | width: 90vw; 605 | max-width: 90vw; 606 | height: 90vh; 607 | max-height: 90vh; 608 | } 609 | .modal.mod-image-preview .modal-content { 610 | max-width: none; 611 | height: 100%; 612 | padding: 16px; 613 | } 614 | .modal.mod-image-preview .image-preview-container { 615 | display: flex; 616 | flex-direction: column; 617 | height: 100%; 618 | gap: 8px; 619 | } 620 | 621 | /* Compact top controls bar */ 622 | .modal.mod-image-preview .image-preview-top-controls { 623 | display: flex; 624 | justify-content: space-between; 625 | align-items: center; 626 | padding: 6px 12px; 627 | background: var(--background-secondary); 628 | border-radius: 6px; 629 | min-height: 36px; 630 | } 631 | 632 | /* Navigation controls (left side) */ 633 | .modal.mod-image-preview .image-preview-nav-controls { 634 | display: flex; 635 | align-items: center; 636 | gap: 8px; 637 | } 638 | 639 | /* Action controls (right side) */ 640 | .modal.mod-image-preview .image-preview-action-controls { 641 | display: flex; 642 | align-items: center; 643 | gap: 6px; 644 | } 645 | 646 | /* Unified button styles */ 647 | .modal.mod-image-preview .image-preview-control-btn { 648 | padding: 4px 8px; 649 | background: var(--interactive-normal); 650 | color: var(--text-normal); 651 | border: 1px solid var(--background-modifier-border); 652 | border-radius: 4px; 653 | cursor: pointer; 654 | font-size: 13px; 655 | min-width: 28px; 656 | height: 28px; 657 | display: flex; 658 | align-items: center; 659 | justify-content: center; 660 | } 661 | .modal.mod-image-preview .image-preview-control-btn:hover { 662 | background: var(--interactive-hover); 663 | } 664 | 665 | .modal.mod-image-preview .image-preview-counter { 666 | font-weight: 500; 667 | font-size: 12px; 668 | color: var(--text-muted); 669 | min-width: 60px; 670 | text-align: center; 671 | } 672 | 673 | .modal.mod-image-preview .image-preview-zoom-level { 674 | min-width: 40px; 675 | text-align: center; 676 | font-weight: 500; 677 | font-size: 12px; 678 | color: var(--text-muted); 679 | } 680 | 681 | /* Main image container */ 682 | .modal.mod-image-preview .image-preview-image-container { 683 | flex: 1; 684 | display: flex; 685 | justify-content: center; 686 | align-items: center; 687 | overflow: hidden; 688 | background: var(--background-secondary); 689 | border-radius: 6px; 690 | position: relative; 691 | } 692 | .modal.mod-image-preview .image-preview-img { 693 | max-width: 100%; 694 | max-height: 100%; 695 | object-fit: contain; 696 | transition: transform 0.1s ease; 697 | transform-origin: center center; 698 | user-select: none; 699 | -webkit-user-drag: none; 700 | } 701 | .modal.mod-image-preview .image-preview-img.zoomed { 702 | cursor: grab; 703 | } 704 | .modal.mod-image-preview .image-preview-img.zoomed:active { 705 | cursor: grabbing; 706 | } 707 | 708 | /* Compact bottom metadata bar */ 709 | .modal.mod-image-preview .image-preview-bottom-bar { 710 | padding: 6px 12px; 711 | background: var(--background-secondary); 712 | border-radius: 6px; 713 | } 714 | .modal.mod-image-preview .image-preview-metadata { 715 | font-size: 11px; 716 | color: var(--text-muted); 717 | text-align: center; 718 | overflow: hidden; 719 | text-overflow: ellipsis; 720 | white-space: nowrap; 721 | } 722 | `; 723 | // Append style to modal element instead of document.head to limit scope 724 | // This helps prevent conflicts with other plugins 725 | this.modalEl.appendChild(style); 726 | 727 | // Keyboard navigation and zoom 728 | this.modalEl.addEventListener('keydown', (e: KeyboardEvent) => { 729 | if (e.key === 'ArrowLeft') { 730 | this.navigate(-1); 731 | } else if (e.key === 'ArrowRight') { 732 | this.navigate(1); 733 | } else if (e.key === ' ') { 734 | e.preventDefault(); // Prevent page scroll 735 | this.navigateRandom(); 736 | } else if (e.key === '+' || e.key === '=') { 737 | this.zoom(0.25); 738 | } else if (e.key === '-') { 739 | this.zoom(-0.25); 740 | } else if (e.key === 'r' || e.key === 'R') { 741 | this.resetZoom(); 742 | } else if (e.key === 'f' || e.key === 'F') { 743 | this.fitToWindow(); 744 | } 745 | }); 746 | } 747 | 748 | zoom(delta: number) { 749 | this.zoomLevel = Math.max(this.minZoom, Math.min(this.maxZoom, this.zoomLevel + delta)); 750 | this.updateImageTransform(); 751 | 752 | // Update zoom level display 753 | const zoomDisplayEl = (this.imageEl as any)?.zoomDisplayEl; 754 | if (zoomDisplayEl) { 755 | zoomDisplayEl.textContent = `${Math.round(this.zoomLevel * 100)}%`; 756 | } 757 | 758 | // Update cursor 759 | if (this.imageEl) { 760 | this.imageEl.style.cursor = this.zoomLevel > 1 ? 'grab' : 'default'; 761 | if (this.zoomLevel > 1) { 762 | this.imageEl.classList.add('zoomed'); 763 | } else { 764 | this.imageEl.classList.remove('zoomed'); 765 | // Reset pan when zoom is 1 or less 766 | this.translateX = 0; 767 | this.translateY = 0; 768 | } 769 | } 770 | } 771 | 772 | resetZoom() { 773 | this.zoomLevel = 1; 774 | this.translateX = 0; 775 | this.translateY = 0; 776 | this.updateImageTransform(); 777 | 778 | // Update zoom level display 779 | const zoomDisplayEl = (this.imageEl as any)?.zoomDisplayEl; 780 | if (zoomDisplayEl) { 781 | zoomDisplayEl.textContent = `${Math.round(this.zoomLevel * 100)}%`; 782 | } 783 | 784 | if (this.imageEl) { 785 | this.imageEl.style.cursor = 'default'; 786 | this.imageEl.classList.remove('zoomed'); 787 | } 788 | } 789 | 790 | fitToWindow() { 791 | if (!this.imageEl || !this.imageContainer) return; 792 | 793 | const containerRect = this.imageContainer.getBoundingClientRect(); 794 | const imgWidth = this.imageEl.naturalWidth; 795 | const imgHeight = this.imageEl.naturalHeight; 796 | 797 | const scaleX = containerRect.width / imgWidth; 798 | const scaleY = containerRect.height / imgHeight; 799 | 800 | this.zoomLevel = Math.min(scaleX, scaleY, 1); 801 | this.translateX = 0; 802 | this.translateY = 0; 803 | this.updateImageTransform(); 804 | 805 | // Update zoom level display 806 | const zoomDisplayEl = (this.imageEl as any)?.zoomDisplayEl; 807 | if (zoomDisplayEl) { 808 | zoomDisplayEl.textContent = `${Math.round(this.zoomLevel * 100)}%`; 809 | } 810 | 811 | this.imageEl.style.cursor = 'default'; 812 | this.imageEl.classList.remove('zoomed'); 813 | } 814 | 815 | updateImageTransform() { 816 | if (this.imageEl) { 817 | this.imageEl.style.transform = `scale(${this.zoomLevel}) translate(${this.translateX / this.zoomLevel}px, ${this.translateY / this.zoomLevel}px)`; 818 | } 819 | } 820 | 821 | navigate(direction: number) { 822 | this.currentIndex = (this.currentIndex + direction + this.allImages.length) % this.allImages.length; 823 | this.imageInfo = this.allImages[this.currentIndex]; 824 | this.onOpen(); // Re-render with new image 825 | } 826 | 827 | navigateRandom() { 828 | if (this.allImages.length <= 1) return; 829 | 830 | // Generate a random index different from current 831 | let randomIndex; 832 | do { 833 | randomIndex = Math.floor(Math.random() * this.allImages.length); 834 | } while (randomIndex === this.currentIndex && this.allImages.length > 1); 835 | 836 | this.currentIndex = randomIndex; 837 | this.imageInfo = this.allImages[this.currentIndex]; 838 | this.onOpen(); // Re-render with new image 839 | } 840 | 841 | cleanupStyles() { 842 | // Styles are now in modal element, so they get cleaned up automatically 843 | // This method is kept for compatibility but no longer needed 844 | } 845 | 846 | onClose() { 847 | const { contentEl } = this; 848 | contentEl.empty(); 849 | 850 | // Clean up styles 851 | this.cleanupStyles(); 852 | } 853 | } 854 | 855 | class ImageGalleryModal extends Modal { 856 | images: ImageInfo[]; 857 | sortedImages: ImageInfo[]; 858 | filteredImages: ImageInfo[]; 859 | currentSort: string = 'created-new'; 860 | currentSearch: string = ''; 861 | currentFolderFilter: string = ''; 862 | galleryContainer: HTMLElement; 863 | ocrService: OCRService; 864 | searchInput: TextComponent; 865 | folderFilterSelect?: DropdownComponent; 866 | statsContainer: HTMLElement; 867 | indexStatusEl: HTMLElement; 868 | private searchTimeout?: NodeJS.Timeout; 869 | private settings: ImageGallerySettings; 870 | private currentCardSize: number; 871 | 872 | constructor(app: App, images: ImageInfo[], ocrService: OCRService) { 873 | super(app); 874 | this.images = images; 875 | this.sortedImages = [...images]; 876 | this.filteredImages = [...images]; 877 | this.ocrService = ocrService; 878 | 879 | // Get plugin settings 880 | const plugin = (this.app as any).plugins.getPlugin('image-gallery-plugin'); 881 | this.settings = plugin?.settings || DEFAULT_SETTINGS; 882 | this.currentCardSize = this.settings.galleryCardSize || 200; 883 | 884 | this.sortImages('created-new'); 885 | } 886 | 887 | sortImages(sortType: string) { 888 | this.currentSort = sortType; 889 | 890 | switch(sortType) { 891 | case 'name-asc': 892 | this.filteredImages.sort((a, b) => a.displayName.localeCompare(b.displayName)); 893 | break; 894 | case 'name-desc': 895 | this.filteredImages.sort((a, b) => b.displayName.localeCompare(a.displayName)); 896 | break; 897 | case 'created-new': 898 | this.filteredImages.sort((a, b) => { 899 | const aTime = a.createdTime || 0; 900 | const bTime = b.createdTime || 0; 901 | return bTime - aTime; 902 | }); 903 | break; 904 | case 'created-old': 905 | this.filteredImages.sort((a, b) => { 906 | const aTime = a.createdTime || 0; 907 | const bTime = b.createdTime || 0; 908 | return aTime - bTime; 909 | }); 910 | break; 911 | case 'modified-new': 912 | this.filteredImages.sort((a, b) => { 913 | const aTime = a.modifiedTime || 0; 914 | const bTime = b.modifiedTime || 0; 915 | return bTime - aTime; 916 | }); 917 | break; 918 | case 'modified-old': 919 | this.filteredImages.sort((a, b) => { 920 | const aTime = a.modifiedTime || 0; 921 | const bTime = b.modifiedTime || 0; 922 | return aTime - bTime; 923 | }); 924 | break; 925 | case 'type': 926 | this.filteredImages.sort((a, b) => { 927 | if (a.isLocal === b.isLocal) { 928 | return a.displayName.localeCompare(b.displayName); 929 | } 930 | return a.isLocal ? -1 : 1; 931 | }); 932 | break; 933 | } 934 | } 935 | 936 | /** 937 | * Get folders that contain images referenced in their notes 938 | */ 939 | async getFoldersWithImageReferences(): Promise { 940 | const folderSet = new Set(); 941 | 942 | // Add "All Folders" option 943 | folderSet.add(''); 944 | 945 | for (const image of this.images) { 946 | if (!image.file) continue; 947 | 948 | const ocrResult = this.ocrService.getCachedResult(image.file.path); 949 | if (ocrResult && ocrResult.context && ocrResult.context.referencingNotes.length > 0) { 950 | for (const note of ocrResult.context.referencingNotes) { 951 | const file = this.app.vault.getAbstractFileByPath(note.path); 952 | if (file && file.parent) { 953 | folderSet.add(file.parent.path); 954 | } 955 | } 956 | } 957 | } 958 | 959 | return Array.from(folderSet).sort(); 960 | } 961 | 962 | /** 963 | * Filter images by folder 964 | */ 965 | async filterByFolder(folderPath: string) { 966 | this.currentFolderFilter = folderPath; 967 | 968 | if (!folderPath) { 969 | // Show all images 970 | await this.searchImages(this.currentSearch); 971 | return; 972 | } 973 | 974 | // Filter images that are referenced in notes from the selected folder 975 | const filteredByFolder: ImageInfo[] = []; 976 | 977 | for (const image of this.sortedImages) { 978 | if (!image.file) { 979 | // For remote images, check if they pass the search filter 980 | if (this.currentSearch) { 981 | const searchLower = this.currentSearch.toLowerCase(); 982 | if (image.displayName.toLowerCase().includes(searchLower) || 983 | image.path.toLowerCase().includes(searchLower)) { 984 | filteredByFolder.push(image); 985 | } 986 | } else { 987 | filteredByFolder.push(image); 988 | } 989 | continue; 990 | } 991 | 992 | const ocrResult = this.ocrService.getCachedResult(image.file.path); 993 | let isInFolder = false; 994 | 995 | if (ocrResult && ocrResult.context && ocrResult.context.referencingNotes.length > 0) { 996 | for (const note of ocrResult.context.referencingNotes) { 997 | const file = this.app.vault.getAbstractFileByPath(note.path); 998 | if (file && file.parent && file.parent.path === folderPath) { 999 | isInFolder = true; 1000 | break; 1001 | } 1002 | } 1003 | } 1004 | 1005 | if (isInFolder) { 1006 | // Also apply text search if active 1007 | if (this.currentSearch) { 1008 | const searchLower = this.currentSearch.toLowerCase(); 1009 | if (image.displayName.toLowerCase().includes(searchLower) || 1010 | image.path.toLowerCase().includes(searchLower)) { 1011 | filteredByFolder.push(image); 1012 | } else if (ocrResult) { 1013 | const ocrMatches = this.ocrService.searchImages(this.currentSearch); 1014 | if (ocrMatches.has(image.file.path)) { 1015 | filteredByFolder.push(image); 1016 | } 1017 | } 1018 | } else { 1019 | filteredByFolder.push(image); 1020 | } 1021 | } 1022 | } 1023 | 1024 | this.filteredImages = filteredByFolder; 1025 | this.renderGallery(); 1026 | this.updateStats(); 1027 | } 1028 | 1029 | async searchImages(query: string) { 1030 | this.currentSearch = query.toLowerCase(); 1031 | 1032 | // Start with all images or folder-filtered images 1033 | let baseImages = this.sortedImages; 1034 | if (this.currentFolderFilter) { 1035 | baseImages = await this.getImagesFromFolder(this.currentFolderFilter); 1036 | } 1037 | 1038 | if (!query) { 1039 | // No search query, show all images (or folder-filtered) 1040 | this.filteredImages = [...baseImages]; 1041 | } else { 1042 | // First, filter by filename 1043 | this.filteredImages = baseImages.filter(img => 1044 | img.displayName.toLowerCase().includes(this.currentSearch) 1045 | ); 1046 | 1047 | // Then, add images that match OCR content 1048 | if (this.ocrService) { 1049 | const ocrMatches = this.ocrService.searchImages(query); 1050 | 1051 | for (const img of baseImages) { 1052 | if (img.file && ocrMatches.has(img.file.path)) { 1053 | // Add if not already in filtered list 1054 | if (!this.filteredImages.includes(img)) { 1055 | this.filteredImages.push(img); 1056 | } 1057 | } 1058 | } 1059 | } 1060 | } 1061 | 1062 | // Re-apply current sort 1063 | this.sortImages(this.currentSort); 1064 | this.renderGallery(); 1065 | this.updateStats(); 1066 | } 1067 | 1068 | /** 1069 | * Helper method to get images from a specific folder 1070 | */ 1071 | private async getImagesFromFolder(folderPath: string): Promise { 1072 | const filteredByFolder: ImageInfo[] = []; 1073 | 1074 | for (const image of this.sortedImages) { 1075 | if (!image.file) { 1076 | // Include remote images when no folder filter is applied 1077 | continue; 1078 | } 1079 | 1080 | const ocrResult = this.ocrService.getCachedResult(image.file.path); 1081 | if (ocrResult && ocrResult.context && ocrResult.context.referencingNotes.length > 0) { 1082 | for (const note of ocrResult.context.referencingNotes) { 1083 | const file = this.app.vault.getAbstractFileByPath(note.path); 1084 | if (file && file.parent && file.parent.path === folderPath) { 1085 | filteredByFolder.push(image); 1086 | break; 1087 | } 1088 | } 1089 | } 1090 | } 1091 | 1092 | return filteredByFolder; 1093 | } 1094 | 1095 | /** 1096 | * Populate folder filter options 1097 | */ 1098 | private async populateFolderOptions() { 1099 | if (!this.folderFilterSelect) return; 1100 | 1101 | const folders = await this.getFoldersWithImageReferences(); 1102 | 1103 | // Clear existing options 1104 | this.folderFilterSelect.selectEl.empty(); 1105 | 1106 | // Add options 1107 | this.folderFilterSelect.addOption('', 'All Folders'); 1108 | 1109 | for (const folder of folders) { 1110 | if (folder) { // Skip empty string (already added as "All Folders") 1111 | const displayName = folder || 'Root'; 1112 | this.folderFilterSelect.addOption(folder, `📁 ${displayName}`); 1113 | } 1114 | } 1115 | 1116 | // Set initial value and onChange handler 1117 | this.folderFilterSelect.setValue(this.currentFolderFilter); 1118 | this.folderFilterSelect.onChange(async (value) => { 1119 | await this.filterByFolder(value); 1120 | }); 1121 | } 1122 | 1123 | updateStats() { 1124 | if (!this.statsContainer) return; 1125 | 1126 | const localImages = this.filteredImages.filter(img => img.isLocal).length; 1127 | const remoteImages = this.filteredImages.filter(img => !img.isLocal).length; 1128 | 1129 | // Update stats display 1130 | const statElements = this.statsContainer.querySelectorAll('.image-gallery-stat'); 1131 | if (statElements[0]) { 1132 | statElements[0].innerHTML = `Showing: ${this.filteredImages.length}/${this.images.length}`; 1133 | } 1134 | if (statElements[1]) { 1135 | statElements[1].innerHTML = `Local: ${localImages}`; 1136 | } 1137 | if (statElements[2]) { 1138 | statElements[2].innerHTML = `Remote: ${remoteImages}`; 1139 | } 1140 | } 1141 | 1142 | updateCardSize() { 1143 | // Update CSS variable for card size 1144 | if (this.galleryContainer) { 1145 | this.galleryContainer.style.setProperty('--card-size', `${this.currentCardSize}px`); 1146 | } 1147 | 1148 | // Update all image heights 1149 | const images = this.galleryContainer.querySelectorAll('.image-gallery-item img'); 1150 | images.forEach((img: HTMLElement) => { 1151 | img.style.height = `${this.currentCardSize}px`; 1152 | }); 1153 | } 1154 | 1155 | async performIncrementalUpdate() { 1156 | try { 1157 | // Get plugin settings for concurrency 1158 | const plugin = (this.app as any).plugins.getPlugin('image-gallery-plugin'); 1159 | const concurrency = plugin?.settings?.ocrConcurrency || 4; 1160 | 1161 | // Perform incremental update 1162 | const result = await this.ocrService.incrementalUpdate(this.images, undefined, concurrency); 1163 | 1164 | // Show subtle notification if new images were indexed 1165 | if (result.indexed > 0) { 1166 | new Notice(`Updated OCR index: ${result.indexed} new/modified images processed`, 3000); 1167 | 1168 | // Update index status if element exists 1169 | if (this.indexStatusEl) { 1170 | const stats = this.ocrService.getIndexStats(); 1171 | this.indexStatusEl.textContent = `OCR Index: ${stats.total} images`; 1172 | } 1173 | } 1174 | } catch (error) { 1175 | console.error('Incremental OCR update failed:', error); 1176 | // Don't show error to user as this is a background operation 1177 | } 1178 | } 1179 | 1180 | renderGallery() { 1181 | this.galleryContainer.empty(); 1182 | 1183 | // Use DocumentFragment for better performance 1184 | const fragment = document.createDocumentFragment(); 1185 | 1186 | // Display images 1187 | for (const imageInfo of this.filteredImages) { 1188 | const itemContainer = document.createElement('div'); 1189 | itemContainer.className = 'image-gallery-item'; 1190 | 1191 | const img = document.createElement('img'); 1192 | 1193 | if (imageInfo.isLocal && imageInfo.file) { 1194 | // For local images, use Obsidian's resource path 1195 | const resourcePath = this.app.vault.getResourcePath(imageInfo.file); 1196 | img.src = resourcePath; 1197 | } else { 1198 | // For remote images, use the URL directly 1199 | img.src = imageInfo.path; 1200 | } 1201 | 1202 | img.alt = imageInfo.displayName; 1203 | img.loading = 'lazy'; // Native lazy loading for performance 1204 | 1205 | // Add error handling - remove the item if image fails to load 1206 | img.onerror = () => { 1207 | // Remove this item from the gallery 1208 | itemContainer.remove(); 1209 | }; 1210 | 1211 | // Add title with date info 1212 | const titleEl = document.createElement('div'); 1213 | titleEl.className = 'image-gallery-item-title'; 1214 | titleEl.textContent = imageInfo.displayName; 1215 | 1216 | // Add tooltip with creation time if available 1217 | if (imageInfo.createdTime) { 1218 | const createdDate = new Date(imageInfo.createdTime); 1219 | titleEl.title = `Created: ${createdDate.toLocaleString()}`; 1220 | } 1221 | 1222 | // Add click handler to open image preview 1223 | itemContainer.addEventListener('click', () => { 1224 | const currentIndex = this.filteredImages.indexOf(imageInfo); 1225 | new ImagePreviewModal(this.app, imageInfo, this.filteredImages, currentIndex).open(); 1226 | }); 1227 | 1228 | itemContainer.appendChild(img); 1229 | itemContainer.appendChild(titleEl); 1230 | fragment.appendChild(itemContainer); 1231 | } 1232 | 1233 | // Append all items at once 1234 | this.galleryContainer.appendChild(fragment); 1235 | 1236 | // Add message if no images found 1237 | if (this.filteredImages.length === 0) { 1238 | this.galleryContainer.createEl('p', { 1239 | text: 'No images found in the vault.', 1240 | cls: 'image-gallery-empty' 1241 | }); 1242 | } 1243 | } 1244 | 1245 | async onOpen() { 1246 | const { contentEl } = this; 1247 | contentEl.empty(); 1248 | 1249 | // Add custom class to modal for styling 1250 | this.modalEl.addClass('mod-image-gallery'); 1251 | 1252 | // Add title 1253 | const titleEl = contentEl.createEl('h2', { text: `Image Gallery (${this.images.length} images)` }); 1254 | 1255 | // Perform incremental OCR index update in background 1256 | this.performIncrementalUpdate(); 1257 | 1258 | // Create compact header container 1259 | const headerContainer = contentEl.createDiv({ cls: 'image-gallery-header' }); 1260 | 1261 | // Left section: Search input 1262 | const searchSection = headerContainer.createDiv({ cls: 'search-section' }); 1263 | this.searchInput = new TextComponent(searchSection); 1264 | this.searchInput.setPlaceholder('Search: "exact phrase", word1 word2, word1 OR word2, -exclude'); 1265 | this.searchInput.inputEl.addClass('image-gallery-search-input'); 1266 | this.searchInput.onChange(async (value) => { 1267 | // Debounce search for better performance 1268 | if (this.searchTimeout) { 1269 | clearTimeout(this.searchTimeout); 1270 | } 1271 | 1272 | this.searchTimeout = setTimeout(async () => { 1273 | await this.searchImages(value); 1274 | }, 300); // 300ms debounce 1275 | }); 1276 | 1277 | // Center section: OCR status and controls 1278 | const ocrSection = headerContainer.createDiv({ cls: 'ocr-section' }); 1279 | 1280 | // Index status (compact) 1281 | const indexStats = this.ocrService.getIndexStats(); 1282 | this.indexStatusEl = ocrSection.createEl('span', { 1283 | cls: 'ocr-index-status', 1284 | text: `${indexStats.total}` 1285 | }); 1286 | this.indexStatusEl.title = 'Images indexed for OCR search'; 1287 | 1288 | // Index button (compact) 1289 | const indexBtn = new ButtonComponent(ocrSection); 1290 | indexBtn.setButtonText('Index'); 1291 | indexBtn.setTooltip('Build OCR index for text search'); 1292 | indexBtn.onClick(async () => { 1293 | indexBtn.setDisabled(true); 1294 | indexBtn.setButtonText('...'); 1295 | 1296 | const startTime = Date.now(); 1297 | const notice = new Notice('Building OCR index...', 0); 1298 | let indexedCount = 0; 1299 | 1300 | // Get plugin settings for concurrency 1301 | const plugin = (this.app as any).plugins.getPlugin('image-gallery-plugin'); 1302 | const concurrency = plugin?.settings?.ocrConcurrency || 4; 1303 | 1304 | await this.ocrService.indexAllImages(this.images, (current, total) => { 1305 | indexedCount = current; 1306 | const elapsed = Math.round((Date.now() - startTime) / 1000); 1307 | const remaining = total - current; 1308 | const rate = current / elapsed || 0; 1309 | const eta = remaining > 0 && rate > 0 ? Math.round(remaining / rate) : 0; 1310 | 1311 | notice.setMessage(`Indexing: ${current}/${total} (${eta}s remaining)`); 1312 | indexBtn.setButtonText(`${current}/${total}`); 1313 | }, concurrency); 1314 | 1315 | const totalTime = Math.round((Date.now() - startTime) / 1000); 1316 | notice.setMessage(`OCR index complete! ${indexedCount} images (${totalTime}s)`); 1317 | setTimeout(() => notice.hide(), 3000); 1318 | 1319 | // Update status 1320 | const newStats = this.ocrService.getIndexStats(); 1321 | this.indexStatusEl.textContent = `${newStats.total}`; 1322 | 1323 | indexBtn.setButtonText('Index'); 1324 | indexBtn.setDisabled(false); 1325 | 1326 | // Re-run search if there's a query 1327 | if (this.currentSearch) { 1328 | await this.searchImages(this.currentSearch); 1329 | } 1330 | }); 1331 | 1332 | // Folder filter section (if enabled) 1333 | if (this.settings.enableFolderFilter) { 1334 | const folderSection = headerContainer.createDiv({ cls: 'folder-section' }); 1335 | this.folderFilterSelect = new DropdownComponent(folderSection); 1336 | 1337 | // Populate folder options 1338 | this.populateFolderOptions(); 1339 | } 1340 | 1341 | // Right section: Card size slider and Sort dropdown 1342 | const rightSection = headerContainer.createDiv({ cls: 'right-section' }); 1343 | 1344 | // Card size controls 1345 | const cardSizeContainer = rightSection.createDiv({ cls: 'card-size-container' }); 1346 | cardSizeContainer.createEl('span', { text: '📐', cls: 'card-size-icon', title: 'Card Size' }); 1347 | 1348 | // Card size slider 1349 | const slider = cardSizeContainer.createEl('input', { 1350 | type: 'range', 1351 | cls: 'card-size-slider' 1352 | }); 1353 | slider.min = '100'; 1354 | slider.max = '400'; 1355 | slider.step = '20'; 1356 | slider.value = this.currentCardSize.toString(); 1357 | 1358 | // Card size value display 1359 | const sizeDisplay = cardSizeContainer.createEl('span', { 1360 | text: `${this.currentCardSize}px`, 1361 | cls: 'card-size-value' 1362 | }); 1363 | 1364 | // Update card size on slider change 1365 | slider.addEventListener('input', (e) => { 1366 | const newSize = parseInt((e.target as HTMLInputElement).value); 1367 | this.currentCardSize = newSize; 1368 | sizeDisplay.textContent = `${newSize}px`; 1369 | this.updateCardSize(); 1370 | 1371 | // Save preference to settings 1372 | const plugin = (this.app as any).plugins.getPlugin('image-gallery-plugin'); 1373 | if (plugin) { 1374 | plugin.settings.galleryCardSize = newSize; 1375 | plugin.saveSettings(); 1376 | } 1377 | }); 1378 | 1379 | // Sort dropdown 1380 | const sortSection = rightSection.createDiv({ cls: 'sort-section' }); 1381 | const dropdown = new DropdownComponent(sortSection); 1382 | dropdown.addOption('name-asc', 'Name (A-Z)'); 1383 | dropdown.addOption('name-desc', 'Name (Z-A)'); 1384 | dropdown.addOption('created-new', 'Created (Newest First)'); 1385 | dropdown.addOption('created-old', 'Created (Oldest First)'); 1386 | dropdown.addOption('modified-new', 'Modified (Newest First)'); 1387 | dropdown.addOption('modified-old', 'Modified (Oldest First)'); 1388 | dropdown.addOption('type', 'Type (Local/Remote)'); 1389 | 1390 | dropdown.setValue(this.currentSort); 1391 | dropdown.onChange((value) => { 1392 | this.sortImages(value); 1393 | this.renderGallery(); 1394 | }); 1395 | 1396 | // Create gallery container 1397 | this.galleryContainer = contentEl.createDiv({ cls: 'image-gallery-container' }); 1398 | 1399 | // Add statistics after gallery (more compact at bottom) 1400 | const localImages = this.filteredImages.filter(img => img.isLocal).length; 1401 | const remoteImages = this.filteredImages.filter(img => !img.isLocal).length; 1402 | 1403 | this.statsContainer = contentEl.createDiv({ cls: 'image-gallery-stats' }); 1404 | this.statsContainer.createDiv({ cls: 'image-gallery-stat' }).innerHTML = 1405 | `Showing: ${this.filteredImages.length}/${this.images.length}`; 1406 | this.statsContainer.createDiv({ cls: 'image-gallery-stat' }).innerHTML = 1407 | `Local: ${localImages}`; 1408 | this.statsContainer.createDiv({ cls: 'image-gallery-stat' }).innerHTML = 1409 | `Remote: ${remoteImages}`; 1410 | 1411 | // Add CSS styles with unique ID and proper isolation 1412 | const style = document.createElement('style'); 1413 | style.id = 'image-gallery-modal-styles'; 1414 | style.textContent = ` 1415 | .modal.mod-image-gallery { 1416 | width: 80vw; 1417 | max-width: 80vw; 1418 | } 1419 | .modal.mod-image-gallery .modal-content { 1420 | max-width: none; 1421 | } 1422 | .modal.mod-image-gallery .image-gallery-header { 1423 | display: flex; 1424 | align-items: center; 1425 | gap: 12px; 1426 | padding: 6px 0; 1427 | border-bottom: 1px solid var(--background-modifier-border); 1428 | margin-bottom: 10px; 1429 | } 1430 | .modal.mod-image-gallery .search-section { 1431 | flex: 1; 1432 | min-width: 200px; 1433 | } 1434 | .modal.mod-image-gallery .image-gallery-search-input { 1435 | width: 100%; 1436 | background: var(--background-secondary); 1437 | border: 1px solid var(--background-modifier-border); 1438 | border-radius: 4px; 1439 | padding: 6px 10px; 1440 | font-size: 13px; 1441 | color: var(--text-normal); 1442 | } 1443 | .modal.mod-image-gallery .image-gallery-search-input:focus { 1444 | border-color: var(--interactive-accent); 1445 | box-shadow: 0 0 0 1px var(--interactive-accent-alpha); 1446 | outline: none; 1447 | } 1448 | .modal.mod-image-gallery .image-gallery-search-input::placeholder { 1449 | color: var(--text-muted); 1450 | } 1451 | .modal.mod-image-gallery .ocr-section { 1452 | display: flex; 1453 | align-items: center; 1454 | gap: 6px; 1455 | flex-shrink: 0; 1456 | } 1457 | .modal.mod-image-gallery .ocr-index-status { 1458 | color: var(--text-accent); 1459 | font-size: 11px; 1460 | font-weight: 500; 1461 | background: var(--background-modifier-hover); 1462 | padding: 2px 6px; 1463 | border-radius: 3px; 1464 | cursor: help; 1465 | } 1466 | .modal.mod-image-gallery .folder-section { 1467 | flex-shrink: 0; 1468 | display: flex; 1469 | align-items: center; 1470 | } 1471 | .modal.mod-image-gallery .folder-section .dropdown { 1472 | font-size: 12px; 1473 | min-width: 120px; 1474 | } 1475 | .modal.mod-image-gallery .right-section { 1476 | display: flex; 1477 | align-items: center; 1478 | gap: 12px; 1479 | flex-shrink: 0; 1480 | } 1481 | .modal.mod-image-gallery .card-size-container { 1482 | display: flex; 1483 | align-items: center; 1484 | gap: 6px; 1485 | } 1486 | .modal.mod-image-gallery .card-size-icon { 1487 | font-size: 14px; 1488 | cursor: help; 1489 | } 1490 | .modal.mod-image-gallery .card-size-slider { 1491 | width: 100px; 1492 | cursor: pointer; 1493 | } 1494 | .modal.mod-image-gallery .card-size-value { 1495 | font-size: 11px; 1496 | color: var(--text-muted); 1497 | min-width: 40px; 1498 | text-align: right; 1499 | } 1500 | .modal.mod-image-gallery .sort-section { 1501 | flex-shrink: 0; 1502 | display: flex; 1503 | align-items: center; 1504 | } 1505 | .modal.mod-image-gallery .sort-section .dropdown { 1506 | font-size: 12px; 1507 | } 1508 | .modal.mod-image-gallery .ocr-section .clickable-icon, 1509 | .modal.mod-image-gallery .ocr-section button { 1510 | padding: 4px 8px; 1511 | font-size: 11px; 1512 | border-radius: 3px; 1513 | background: var(--background-secondary); 1514 | border: 1px solid var(--background-modifier-border); 1515 | } 1516 | .modal.mod-image-gallery .ocr-section .clickable-icon:hover, 1517 | .modal.mod-image-gallery .ocr-section button:hover { 1518 | background: var(--background-modifier-hover); 1519 | } 1520 | .modal.mod-image-gallery .image-gallery-container { 1521 | display: grid; 1522 | grid-template-columns: repeat(auto-fill, minmax(var(--card-size, 200px), 1fr)); 1523 | gap: 15px; 1524 | padding: 10px 0 15px 0; 1525 | max-height: 75vh; 1526 | overflow-y: auto; 1527 | --card-size: ${this.currentCardSize}px; 1528 | } 1529 | .modal.mod-image-gallery .image-gallery-stats { 1530 | display: flex; 1531 | justify-content: center; 1532 | gap: 20px; 1533 | padding: 8px 0; 1534 | border-top: 1px solid var(--background-modifier-border); 1535 | margin-top: 5px; 1536 | font-size: 11px; 1537 | color: var(--text-muted); 1538 | } 1539 | .modal.mod-image-gallery .image-gallery-stat-label { 1540 | font-weight: 500; 1541 | margin-right: 4px; 1542 | } 1543 | .modal.mod-image-gallery .image-gallery-item { 1544 | position: relative; 1545 | border: 1px solid var(--background-modifier-border); 1546 | border-radius: 8px; 1547 | overflow: hidden; 1548 | cursor: pointer; 1549 | transition: transform 0.2s; 1550 | background: var(--background-secondary); 1551 | } 1552 | .modal.mod-image-gallery .image-gallery-item:hover { 1553 | transform: scale(1.05); 1554 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); 1555 | } 1556 | .modal.mod-image-gallery .image-gallery-item img { 1557 | width: 100%; 1558 | height: var(--card-size, 200px); 1559 | object-fit: cover; 1560 | display: block; 1561 | } 1562 | .modal.mod-image-gallery .image-gallery-item-title { 1563 | padding: 8px; 1564 | font-size: 12px; 1565 | text-align: center; 1566 | white-space: nowrap; 1567 | overflow: hidden; 1568 | text-overflow: ellipsis; 1569 | background: var(--background-primary); 1570 | border-top: 1px solid var(--background-modifier-border); 1571 | } 1572 | .modal.mod-image-gallery .image-gallery-stats { 1573 | padding: 10px; 1574 | background: var(--background-secondary); 1575 | border-radius: 8px; 1576 | margin-bottom: 10px; 1577 | display: flex; 1578 | gap: 20px; 1579 | } 1580 | .modal.mod-image-gallery .image-gallery-stat { 1581 | display: flex; 1582 | gap: 5px; 1583 | } 1584 | .modal.mod-image-gallery .image-gallery-stat-label { 1585 | font-weight: bold; 1586 | } 1587 | `; 1588 | // Append style to modal element instead of document.head to limit scope 1589 | // This helps prevent conflicts with other plugins 1590 | this.modalEl.appendChild(style); 1591 | 1592 | 1593 | // Initial render of gallery 1594 | this.renderGallery(); 1595 | 1596 | // Apply initial card size 1597 | this.updateCardSize(); 1598 | } 1599 | 1600 | onClose() { 1601 | const { contentEl } = this; 1602 | contentEl.empty(); 1603 | 1604 | // Clean up timeout 1605 | if (this.searchTimeout) { 1606 | clearTimeout(this.searchTimeout); 1607 | } 1608 | 1609 | // Styles are now in modal element, so they get cleaned up automatically 1610 | } 1611 | } 1612 | 1613 | class OCRDebugModal extends Modal { 1614 | file: TFile; 1615 | ocrService: OCRService; 1616 | isProcessing: boolean = false; 1617 | 1618 | constructor(app: App, file: TFile, ocrService: OCRService) { 1619 | super(app); 1620 | this.file = file; 1621 | this.ocrService = ocrService; 1622 | } 1623 | 1624 | onOpen() { 1625 | const { contentEl } = this; 1626 | contentEl.empty(); 1627 | 1628 | this.modalEl.addClass('mod-ocr-debug'); 1629 | 1630 | // Title 1631 | contentEl.createEl('h2', { text: 'OCR Debug' }); 1632 | 1633 | // Image info 1634 | const imageInfo = contentEl.createDiv({ cls: 'ocr-debug-image-info' }); 1635 | imageInfo.createEl('p', { text: `File: ${this.file.name}` }); 1636 | imageInfo.createEl('p', { text: `Path: ${this.file.path}` }); 1637 | 1638 | // Image preview 1639 | const imageContainer = contentEl.createDiv({ cls: 'ocr-debug-image-container' }); 1640 | const img = imageContainer.createEl('img', { cls: 'ocr-debug-image' }); 1641 | const resourcePath = this.app.vault.getResourcePath(this.file); 1642 | img.src = resourcePath; 1643 | img.alt = this.file.name; 1644 | 1645 | // Results container 1646 | const resultsContainer = contentEl.createDiv({ cls: 'ocr-debug-results' }); 1647 | resultsContainer.createEl('h3', { text: 'OCR Results' }); 1648 | 1649 | // Cached result 1650 | const cachedResult = this.ocrService.getCachedResult(this.file.path); 1651 | if (cachedResult) { 1652 | const cachedSection = resultsContainer.createDiv({ cls: 'ocr-debug-section' }); 1653 | cachedSection.createEl('h4', { text: 'Cached Result:' }); 1654 | const cachedDate = new Date(cachedResult.timestamp).toLocaleString(); 1655 | cachedSection.createEl('p', { text: `Cached on: ${cachedDate}`, cls: 'ocr-debug-timestamp' }); 1656 | 1657 | // OCR text 1658 | cachedSection.createEl('h5', { text: 'OCR Text:' }); 1659 | const cachedTextEl = cachedSection.createEl('div', { cls: 'ocr-debug-text' }); 1660 | cachedTextEl.textContent = cachedResult.text || '(empty)'; 1661 | 1662 | // Context information 1663 | if (cachedResult.context) { 1664 | cachedSection.createEl('h5', { text: 'Context Information:' }); 1665 | 1666 | // Referencing notes 1667 | if (cachedResult.context.referencingNotes.length > 0) { 1668 | cachedSection.createEl('h6', { text: 'Referenced in Notes:' }); 1669 | const notesEl = cachedSection.createEl('div', { cls: 'ocr-debug-context' }); 1670 | const notesList = cachedResult.context.referencingNotes.map(note => `• ${note.title}`).join('\n'); 1671 | notesEl.textContent = notesList; 1672 | } 1673 | 1674 | // Nearby content 1675 | if (cachedResult.context.nearbyContent) { 1676 | cachedSection.createEl('h6', { text: 'Nearby Content:' }); 1677 | const contextEl = cachedSection.createEl('div', { cls: 'ocr-debug-context' }); 1678 | contextEl.textContent = cachedResult.context.nearbyContent; 1679 | } 1680 | } 1681 | } 1682 | 1683 | // Fresh result section 1684 | const freshSection = resultsContainer.createDiv({ cls: 'ocr-debug-section' }); 1685 | freshSection.createEl('h4', { text: 'Fresh OCR Result:' }); 1686 | const freshResultEl = freshSection.createDiv({ cls: 'ocr-debug-text' }); 1687 | freshResultEl.textContent = 'Click "Run OCR" to get fresh result'; 1688 | 1689 | // Container for fresh context (will be populated when OCR runs) 1690 | const freshContextContainer = freshSection.createDiv({ cls: 'fresh-context-container' }); 1691 | 1692 | // Debug info section 1693 | const debugSection = resultsContainer.createDiv({ cls: 'ocr-debug-section' }); 1694 | debugSection.createEl('h4', { text: 'Debug Information:' }); 1695 | const debugInfo = debugSection.createEl('pre', { cls: 'ocr-debug-info' }); 1696 | 1697 | // Run OCR button 1698 | const buttonContainer = contentEl.createDiv({ cls: 'ocr-debug-buttons' }); 1699 | const runButton = buttonContainer.createEl('button', { text: 'Run OCR', cls: 'mod-cta' }); 1700 | 1701 | runButton.onclick = async () => { 1702 | if (this.isProcessing) return; 1703 | 1704 | this.isProcessing = true; 1705 | runButton.textContent = 'Processing...'; 1706 | runButton.disabled = true; 1707 | 1708 | try { 1709 | const absolutePath = (this.app.vault.adapter as any).getFullPath(this.file.path); 1710 | debugInfo.textContent = `Running OCR on: ${absolutePath}\n\nProcessing...`; 1711 | 1712 | // Get fresh OCR result with debug info 1713 | const result = await this.ocrService.performOCRWithDebug(absolutePath); 1714 | 1715 | // Update results 1716 | freshResultEl.textContent = result.text || '(empty)'; 1717 | 1718 | // Extract context information for fresh result 1719 | const context = await this.ocrService.extractImageContext(this.file); 1720 | 1721 | // Update debug info 1722 | let debugText = `File: ${absolutePath}\n`; 1723 | debugText += `Timestamp: ${new Date().toLocaleString()}\n`; 1724 | debugText += `Result length: ${result.text.length} characters\n`; 1725 | debugText += `Context notes: ${context.referencingNotes.length}\n`; 1726 | debugText += `Context content length: ${context.nearbyContent.length} characters\n`; 1727 | if (result.error) { 1728 | debugText += `Error: ${result.error}\n`; 1729 | } 1730 | if (result.stderr) { 1731 | debugText += `stderr: ${result.stderr}\n`; 1732 | } 1733 | debugText += `Command executed successfully: ${!result.error}\n`; 1734 | debugInfo.textContent = debugText; 1735 | 1736 | // Clear previous context and display new context information 1737 | freshContextContainer.empty(); 1738 | if (context.referencingNotes.length > 0 || context.nearbyContent) { 1739 | freshContextContainer.createEl('h5', { text: 'Fresh Context Information:' }); 1740 | 1741 | // Referencing notes 1742 | if (context.referencingNotes.length > 0) { 1743 | freshContextContainer.createEl('h6', { text: 'Referenced in Notes:' }); 1744 | const notesEl = freshContextContainer.createEl('div', { cls: 'ocr-debug-context' }); 1745 | const notesList = context.referencingNotes.map(note => `• ${note.title}`).join('\n'); 1746 | notesEl.textContent = notesList; 1747 | } 1748 | 1749 | // Nearby content 1750 | if (context.nearbyContent) { 1751 | freshContextContainer.createEl('h6', { text: 'Nearby Content:' }); 1752 | const contextEl = freshContextContainer.createEl('div', { cls: 'ocr-debug-context' }); 1753 | contextEl.textContent = context.nearbyContent; 1754 | } 1755 | } else { 1756 | freshContextContainer.createEl('p', { 1757 | text: 'No context information found (image not referenced in any notes)', 1758 | cls: 'ocr-debug-no-context' 1759 | }); 1760 | } 1761 | 1762 | } catch (error) { 1763 | freshResultEl.textContent = `Error: ${error}`; 1764 | debugInfo.textContent = `Error occurred: ${error}`; 1765 | } finally { 1766 | this.isProcessing = false; 1767 | runButton.textContent = 'Run OCR'; 1768 | runButton.disabled = false; 1769 | } 1770 | }; 1771 | 1772 | // Add styles with unique ID and proper isolation 1773 | const style = document.createElement('style'); 1774 | style.id = 'ocr-debug-modal-styles'; 1775 | style.textContent = ` 1776 | .modal.mod-ocr-debug { 1777 | width: 80vw; 1778 | max-width: 900px; 1779 | height: 80vh; 1780 | } 1781 | .modal.mod-ocr-debug .modal-content { 1782 | height: 100%; 1783 | display: flex; 1784 | flex-direction: column; 1785 | } 1786 | .modal.mod-ocr-debug .ocr-debug-image-info { 1787 | margin-bottom: 15px; 1788 | } 1789 | .modal.mod-ocr-debug .ocr-debug-image-container { 1790 | text-align: center; 1791 | margin-bottom: 20px; 1792 | } 1793 | .modal.mod-ocr-debug .ocr-debug-image { 1794 | max-width: 100%; 1795 | max-height: 200px; 1796 | border: 1px solid var(--background-modifier-border); 1797 | border-radius: 8px; 1798 | } 1799 | .modal.mod-ocr-debug .ocr-debug-results { 1800 | flex: 1; 1801 | overflow-y: auto; 1802 | } 1803 | .modal.mod-ocr-debug .ocr-debug-section { 1804 | margin-bottom: 20px; 1805 | padding: 15px; 1806 | background: var(--background-secondary); 1807 | border-radius: 8px; 1808 | } 1809 | .modal.mod-ocr-debug .ocr-debug-section h4 { 1810 | margin: 0 0 10px 0; 1811 | color: var(--text-accent); 1812 | } 1813 | .modal.mod-ocr-debug .ocr-debug-text { 1814 | background: var(--background-primary); 1815 | padding: 10px; 1816 | border-radius: 4px; 1817 | border: 1px solid var(--background-modifier-border); 1818 | font-family: var(--font-monospace); 1819 | white-space: pre-wrap; 1820 | word-wrap: break-word; 1821 | min-height: 60px; 1822 | } 1823 | .modal.mod-ocr-debug .ocr-debug-info { 1824 | background: var(--background-primary); 1825 | padding: 10px; 1826 | border-radius: 4px; 1827 | border: 1px solid var(--background-modifier-border); 1828 | margin: 0; 1829 | font-size: 12px; 1830 | } 1831 | .modal.mod-ocr-debug .ocr-debug-timestamp { 1832 | font-size: 12px; 1833 | color: var(--text-muted); 1834 | margin: 5px 0; 1835 | } 1836 | .modal.mod-ocr-debug .ocr-debug-context { 1837 | background: var(--background-primary); 1838 | padding: 8px; 1839 | border-radius: 4px; 1840 | border: 1px solid var(--background-modifier-border); 1841 | font-family: var(--font-monospace); 1842 | font-size: 11px; 1843 | white-space: pre-wrap; 1844 | word-wrap: break-word; 1845 | margin: 5px 0; 1846 | max-height: 100px; 1847 | overflow-y: auto; 1848 | } 1849 | .modal.mod-ocr-debug .ocr-debug-section h5 { 1850 | margin: 15px 0 5px 0; 1851 | font-size: 13px; 1852 | font-weight: 600; 1853 | color: var(--text-normal); 1854 | } 1855 | .modal.mod-ocr-debug .ocr-debug-section h6 { 1856 | margin: 10px 0 3px 0; 1857 | font-size: 11px; 1858 | font-weight: 500; 1859 | color: var(--text-muted); 1860 | } 1861 | .modal.mod-ocr-debug .fresh-context-container { 1862 | margin-top: 15px; 1863 | } 1864 | .modal.mod-ocr-debug .ocr-debug-no-context { 1865 | color: var(--text-muted); 1866 | font-style: italic; 1867 | font-size: 12px; 1868 | margin: 10px 0; 1869 | } 1870 | .modal.mod-ocr-debug .ocr-debug-buttons { 1871 | text-align: center; 1872 | margin-top: 15px; 1873 | } 1874 | `; 1875 | // Append style to modal element instead of document.head to limit scope 1876 | // This helps prevent conflicts with other plugins 1877 | this.modalEl.appendChild(style); 1878 | } 1879 | 1880 | onClose() { 1881 | // Styles are now in modal element, so they get cleaned up automatically 1882 | } 1883 | } 1884 | 1885 | class ImageGallerySettingTab extends PluginSettingTab { 1886 | plugin: ImageGalleryPlugin; 1887 | 1888 | constructor(app: App, plugin: ImageGalleryPlugin) { 1889 | super(app, plugin); 1890 | this.plugin = plugin; 1891 | } 1892 | 1893 | display(): void { 1894 | const { containerEl } = this; 1895 | 1896 | containerEl.empty(); 1897 | 1898 | containerEl.createEl('h2', { text: 'Image Gallery Settings' }); 1899 | 1900 | new Setting(containerEl) 1901 | .setName('Enable OCR Debug') 1902 | .setDesc('Enable debugging features for OCR functionality. This adds a command to test OCR on individual images.') 1903 | .addToggle(toggle => toggle 1904 | .setValue(this.plugin.settings.enableOCRDebug) 1905 | .onChange(async (value) => { 1906 | this.plugin.settings.enableOCRDebug = value; 1907 | await this.plugin.saveSettings(); 1908 | })); 1909 | 1910 | new Setting(containerEl) 1911 | .setName('OCR Concurrency') 1912 | .setDesc('Number of images to process simultaneously during OCR indexing. Higher values are faster but use more system resources.') 1913 | .addSlider(slider => slider 1914 | .setLimits(1, 8, 1) 1915 | .setValue(this.plugin.settings.ocrConcurrency) 1916 | .setDynamicTooltip() 1917 | .onChange(async (value) => { 1918 | this.plugin.settings.ocrConcurrency = value; 1919 | await this.plugin.saveSettings(); 1920 | })); 1921 | 1922 | new Setting(containerEl) 1923 | .setName('Context Lines') 1924 | .setDesc('Number of text lines to include as context around each image (before and after). More lines provide richer context but increase index size.') 1925 | .addSlider(slider => slider 1926 | .setLimits(1, 8, 1) 1927 | .setValue(this.plugin.settings.contextParagraphs) 1928 | .setDynamicTooltip() 1929 | .onChange(async (value) => { 1930 | this.plugin.settings.contextParagraphs = value; 1931 | await this.plugin.saveSettings(); 1932 | })); 1933 | 1934 | new Setting(containerEl) 1935 | .setName('Enable Folder Filter') 1936 | .setDesc('Show a folder filter dropdown in the gallery to view images referenced in specific folders. Useful for organizing images by project or topic.') 1937 | .addToggle(toggle => toggle 1938 | .setValue(this.plugin.settings.enableFolderFilter) 1939 | .onChange(async (value) => { 1940 | this.plugin.settings.enableFolderFilter = value; 1941 | await this.plugin.saveSettings(); 1942 | })); 1943 | 1944 | new Setting(containerEl) 1945 | .setName('Default Gallery Card Size') 1946 | .setDesc('Default size for image cards in the gallery view (in pixels). You can also adjust this with the slider in the gallery window.') 1947 | .addSlider(slider => slider 1948 | .setLimits(100, 400, 20) 1949 | .setValue(this.plugin.settings.galleryCardSize) 1950 | .setDynamicTooltip() 1951 | .onChange(async (value) => { 1952 | this.plugin.settings.galleryCardSize = value; 1953 | await this.plugin.saveSettings(); 1954 | })); 1955 | 1956 | // Search+ Settings Section 1957 | containerEl.createEl('h3', { text: 'Search+ Settings' }); 1958 | 1959 | new Setting(containerEl) 1960 | .setName('Exclude Folders from Search') 1961 | .setDesc('List of folder paths to exclude from Search+ results. Enter one folder path per line (e.g., "Templates" or "Archive/Old Notes").') 1962 | .addTextArea(text => { 1963 | text.setPlaceholder('Templates\nArchive\nPrivate') 1964 | .setValue(this.plugin.settings.searchExcludeFolders.join('\n')) 1965 | .onChange(async (value) => { 1966 | // Split by lines and filter empty lines 1967 | this.plugin.settings.searchExcludeFolders = value 1968 | .split('\n') 1969 | .map(line => line.trim()) 1970 | .filter(line => line.length > 0); 1971 | await this.plugin.saveSettings(); 1972 | }); 1973 | text.inputEl.rows = 4; 1974 | text.inputEl.cols = 50; 1975 | }); 1976 | 1977 | new Setting(containerEl) 1978 | .setName('Minimal Mode') 1979 | .setDesc('Enable minimal mode for Search+ to show only search results without extra UI elements.') 1980 | .addToggle(toggle => toggle 1981 | .setValue(this.plugin.settings.searchMinimalMode) 1982 | .onChange(async (value) => { 1983 | this.plugin.settings.searchMinimalMode = value; 1984 | await this.plugin.saveSettings(); 1985 | })); 1986 | 1987 | new Setting(containerEl) 1988 | .setName('Include Image Results') 1989 | .setDesc('Enable image search in Search+ to show images containing text that matches your query.') 1990 | .addToggle(toggle => toggle 1991 | .setValue(this.plugin.settings.searchIncludeImages) 1992 | .onChange(async (value) => { 1993 | this.plugin.settings.searchIncludeImages = value; 1994 | await this.plugin.saveSettings(); 1995 | })); 1996 | 1997 | // OCR Index Management Section 1998 | containerEl.createEl('h3', { text: 'OCR Index Management' }); 1999 | 2000 | const indexStats = this.plugin.ocrService.getIndexStats(); 2001 | const statsEl = containerEl.createEl('p', { 2002 | text: `Current index contains ${indexStats.total} images (${(indexStats.size / 1024).toFixed(1)}KB)`, 2003 | cls: 'setting-item-description' 2004 | }); 2005 | 2006 | new Setting(containerEl) 2007 | .setName('Clear and Rebuild OCR Index') 2008 | .setDesc('Clear the existing OCR index and rebuild it from scratch. This will re-process all images and may take several minutes.') 2009 | .addButton(button => button 2010 | .setButtonText('Clear & Rebuild') 2011 | .setClass('mod-warning') 2012 | .onClick(async () => { 2013 | // Confirm action 2014 | const confirmed = await this.showConfirmDialog( 2015 | 'Clear and Rebuild OCR Index', 2016 | 'This will delete the existing OCR index and rebuild it from scratch. All cached OCR results will be lost and images will be re-processed. This may take several minutes.\n\nAre you sure you want to continue?' 2017 | ); 2018 | 2019 | if (!confirmed) return; 2020 | 2021 | button.setDisabled(true); 2022 | button.setButtonText('Processing...'); 2023 | 2024 | try { 2025 | // Clear existing index 2026 | await this.plugin.ocrService.clearIndex(); 2027 | 2028 | // Get all images 2029 | const images = await this.plugin.getAllImages(); 2030 | 2031 | // Show progress notice 2032 | const startTime = Date.now(); 2033 | const notice = new Notice('Rebuilding OCR index...', 0); 2034 | 2035 | // Rebuild index 2036 | await this.plugin.ocrService.indexAllImages(images, (current, total) => { 2037 | const elapsed = Math.round((Date.now() - startTime) / 1000); 2038 | const remaining = total - current; 2039 | const rate = current / elapsed || 0; 2040 | const eta = remaining > 0 && rate > 0 ? Math.round(remaining / rate) : 0; 2041 | 2042 | notice.setMessage(`Rebuilding OCR index: ${current}/${total} (${elapsed}s elapsed, ${eta}s remaining)`); 2043 | }, this.plugin.settings.ocrConcurrency); 2044 | 2045 | const totalTime = Math.round((Date.now() - startTime) / 1000); 2046 | notice.setMessage(`OCR index rebuilt successfully! (${totalTime}s total)`); 2047 | setTimeout(() => notice.hide(), 3000); 2048 | 2049 | // Update stats display 2050 | const newStats = this.plugin.ocrService.getIndexStats(); 2051 | statsEl.textContent = `Current index contains ${newStats.total} images (${(newStats.size / 1024).toFixed(1)}KB)`; 2052 | 2053 | new Notice('OCR index has been completely rebuilt!'); 2054 | 2055 | } catch (error) { 2056 | console.error('Failed to rebuild OCR index:', error); 2057 | new Notice('Failed to rebuild OCR index. Check console for details.', 5000); 2058 | } finally { 2059 | button.setDisabled(false); 2060 | button.setButtonText('Clear & Rebuild'); 2061 | } 2062 | })); 2063 | 2064 | new Setting(containerEl) 2065 | .setName('Clear OCR Index Only') 2066 | .setDesc('Only clear the OCR index without rebuilding. Use this to free up space or reset the index.') 2067 | .addButton(button => button 2068 | .setButtonText('Clear Index') 2069 | .setClass('mod-warning') 2070 | .onClick(async () => { 2071 | const confirmed = await this.showConfirmDialog( 2072 | 'Clear OCR Index', 2073 | 'This will permanently delete all cached OCR results. You can rebuild the index later if needed.\n\nAre you sure you want to continue?' 2074 | ); 2075 | 2076 | if (!confirmed) return; 2077 | 2078 | try { 2079 | await this.plugin.ocrService.clearIndex(); 2080 | 2081 | // Update stats display 2082 | const newStats = this.plugin.ocrService.getIndexStats(); 2083 | statsEl.textContent = `Current index contains ${newStats.total} images (${(newStats.size / 1024).toFixed(1)}KB)`; 2084 | 2085 | new Notice('OCR index cleared successfully!'); 2086 | } catch (error) { 2087 | console.error('Failed to clear OCR index:', error); 2088 | new Notice('Failed to clear OCR index. Check console for details.', 5000); 2089 | } 2090 | })); 2091 | } 2092 | 2093 | async showConfirmDialog(title: string, message: string): Promise { 2094 | return new Promise((resolve) => { 2095 | const modal = new ConfirmModal(this.app, title, message, resolve); 2096 | modal.open(); 2097 | }); 2098 | } 2099 | } 2100 | 2101 | class ConfirmModal extends Modal { 2102 | private title: string; 2103 | private message: string; 2104 | private resolve: (value: boolean) => void; 2105 | 2106 | constructor(app: App, title: string, message: string, resolve: (value: boolean) => void) { 2107 | super(app); 2108 | this.title = title; 2109 | this.message = message; 2110 | this.resolve = resolve; 2111 | } 2112 | 2113 | onOpen() { 2114 | const { contentEl } = this; 2115 | contentEl.empty(); 2116 | 2117 | this.modalEl.addClass('mod-confirm'); 2118 | 2119 | // Title 2120 | contentEl.createEl('h2', { text: this.title }); 2121 | 2122 | // Message 2123 | const messageEl = contentEl.createDiv({ cls: 'confirm-message' }); 2124 | this.message.split('\n').forEach(line => { 2125 | messageEl.createEl('p', { text: line }); 2126 | }); 2127 | 2128 | // Buttons 2129 | const buttonContainer = contentEl.createDiv({ cls: 'confirm-buttons' }); 2130 | 2131 | const cancelBtn = buttonContainer.createEl('button', { 2132 | text: 'Cancel', 2133 | cls: 'mod-cancel' 2134 | }); 2135 | cancelBtn.onclick = () => { 2136 | this.resolve(false); 2137 | this.close(); 2138 | }; 2139 | 2140 | const confirmBtn = buttonContainer.createEl('button', { 2141 | text: 'Continue', 2142 | cls: 'mod-cta mod-warning' 2143 | }); 2144 | confirmBtn.onclick = () => { 2145 | this.resolve(true); 2146 | this.close(); 2147 | }; 2148 | 2149 | // Add styles with unique ID and proper isolation 2150 | const style = document.createElement('style'); 2151 | style.id = 'confirm-modal-styles'; 2152 | style.textContent = ` 2153 | .modal.mod-confirm { 2154 | width: 400px; 2155 | max-width: 90vw; 2156 | } 2157 | .modal.mod-confirm .confirm-message { 2158 | margin: 20px 0; 2159 | } 2160 | .modal.mod-confirm .confirm-message p { 2161 | margin: 10px 0; 2162 | color: var(--text-normal); 2163 | } 2164 | .modal.mod-confirm .confirm-buttons { 2165 | display: flex; 2166 | gap: 10px; 2167 | justify-content: flex-end; 2168 | margin-top: 20px; 2169 | } 2170 | .modal.mod-confirm .confirm-buttons button { 2171 | padding: 8px 16px; 2172 | border: none; 2173 | border-radius: 4px; 2174 | cursor: pointer; 2175 | } 2176 | .modal.mod-confirm .confirm-buttons .mod-cancel { 2177 | background: var(--interactive-normal); 2178 | color: var(--text-normal); 2179 | } 2180 | .modal.mod-confirm .confirm-buttons .mod-cancel:hover { 2181 | background: var(--interactive-hover); 2182 | } 2183 | .modal.mod-confirm .confirm-buttons .mod-cta.mod-warning { 2184 | background: var(--color-red); 2185 | color: white; 2186 | } 2187 | .modal.mod-confirm .confirm-buttons .mod-cta.mod-warning:hover { 2188 | background: var(--color-red); 2189 | opacity: 0.8; 2190 | } 2191 | `; 2192 | // Append style to modal element instead of document.head to limit scope 2193 | // This helps prevent conflicts with other plugins 2194 | this.modalEl.appendChild(style); 2195 | 2196 | // Focus the cancel button by default 2197 | cancelBtn.focus(); 2198 | 2199 | // Handle escape key 2200 | this.modalEl.addEventListener('keydown', (e) => { 2201 | if (e.key === 'Escape') { 2202 | this.resolve(false); 2203 | this.close(); 2204 | } else if (e.key === 'Enter') { 2205 | this.resolve(true); 2206 | this.close(); 2207 | } 2208 | }); 2209 | } 2210 | 2211 | onClose() { 2212 | // Styles are now in modal element, so they get cleaned up automatically 2213 | } 2214 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "image-gallery-plugin", 3 | "name": "Image Gallery", 4 | "version": "1.0.0", 5 | "minAppVersion": "0.15.0", 6 | "description": "Display all images in your vault in a beautiful gallery view. Supports both local and remote images.", 7 | "author": "Julian Zhang", 8 | "authorUrl": "https://github.com/forrestchang", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /modal-styles.css: -------------------------------------------------------------------------------- 1 | /* Image Gallery Plugin Modal Styles with Isolation */ 2 | 3 | /* Use CSS layers to prevent conflicts with other plugins */ 4 | @layer image-gallery-plugin { 5 | /* Image Preview Modal Styles */ 6 | .modal.mod-image-preview { 7 | width: 90vw !important; 8 | max-width: 90vw !important; 9 | height: 90vh !important; 10 | max-height: 90vh !important; 11 | } 12 | 13 | .modal.mod-image-preview .modal-content { 14 | max-width: none !important; 15 | height: 100% !important; 16 | padding: 16px !important; 17 | } 18 | 19 | .modal.mod-image-preview .image-preview-container { 20 | display: flex !important; 21 | flex-direction: column !important; 22 | height: 100% !important; 23 | gap: 8px !important; 24 | } 25 | 26 | /* Reset any global styles that might be inherited */ 27 | .modal.mod-image-preview * { 28 | all: revert; 29 | box-sizing: border-box !important; 30 | } 31 | 32 | /* Image Gallery Modal Styles */ 33 | .modal.mod-image-gallery { 34 | width: 80vw !important; 35 | max-width: 80vw !important; 36 | } 37 | 38 | .modal.mod-image-gallery .modal-content { 39 | max-width: none !important; 40 | } 41 | 42 | /* Reset any global styles for gallery modal */ 43 | .modal.mod-image-gallery * { 44 | all: revert; 45 | box-sizing: border-box !important; 46 | } 47 | 48 | /* OCR Debug Modal Styles */ 49 | .modal.mod-ocr-debug { 50 | width: 80vw !important; 51 | max-width: 900px !important; 52 | height: 80vh !important; 53 | } 54 | 55 | .modal.mod-ocr-debug .modal-content { 56 | height: 100% !important; 57 | display: flex !important; 58 | flex-direction: column !important; 59 | } 60 | 61 | /* Reset any global styles for OCR modal */ 62 | .modal.mod-ocr-debug * { 63 | all: revert; 64 | box-sizing: border-box !important; 65 | } 66 | 67 | /* Confirm Modal Styles */ 68 | .modal.mod-confirm { 69 | width: 400px !important; 70 | max-width: 90vw !important; 71 | } 72 | 73 | /* Reset any global styles for confirm modal */ 74 | .modal.mod-confirm * { 75 | all: revert; 76 | box-sizing: border-box !important; 77 | } 78 | } -------------------------------------------------------------------------------- /ocr-service.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import { promisify } from 'util'; 3 | import { TFile } from 'obsidian'; 4 | 5 | const execAsync = promisify(exec); 6 | 7 | export interface OCRResult { 8 | text: string; 9 | confidence?: number; 10 | timestamp: number; 11 | context?: { 12 | referencingNotes: Array<{ 13 | title: string; 14 | path: string; 15 | }>; 16 | nearbyContent: string; 17 | }; 18 | // Pre-computed search content for performance 19 | _searchableContent?: string; 20 | } 21 | 22 | interface SearchTerm { 23 | text: string; 24 | isNegated: boolean; 25 | isPhrase: boolean; 26 | alternatives: SearchTerm[]; 27 | } 28 | 29 | export interface OCRDebugResult { 30 | text: string; 31 | error?: string; 32 | stderr?: string; 33 | timestamp: number; 34 | } 35 | 36 | export interface OCRIndex { 37 | [filePath: string]: OCRResult; 38 | } 39 | 40 | export class OCRService { 41 | private index: OCRIndex = {}; 42 | private indexPath: string; 43 | private app: any; 44 | private fileContentCache = new Map(); 45 | private regexCache = new Map(); 46 | 47 | constructor(app: any) { 48 | this.app = app; 49 | this.indexPath = '.obsidian/plugins/image-gallery-plugin/ocr-index.json'; 50 | } 51 | 52 | /** 53 | * Load existing OCR index from storage 54 | */ 55 | async loadIndex(): Promise { 56 | try { 57 | const adapter = this.app.vault.adapter; 58 | if (await adapter.exists(this.indexPath)) { 59 | const data = await adapter.read(this.indexPath); 60 | this.index = JSON.parse(data); 61 | console.log(`Loaded OCR index with ${Object.keys(this.index).length} entries`); 62 | } 63 | } catch (error) { 64 | console.error('Failed to load OCR index:', error); 65 | this.index = {}; 66 | } 67 | } 68 | 69 | /** 70 | * Save OCR index to storage 71 | */ 72 | async saveIndex(): Promise { 73 | try { 74 | const adapter = this.app.vault.adapter; 75 | const dir = this.indexPath.substring(0, this.indexPath.lastIndexOf('/')); 76 | 77 | // Ensure directory exists 78 | if (!(await adapter.exists(dir))) { 79 | await adapter.mkdir(dir); 80 | } 81 | 82 | await adapter.write(this.indexPath, JSON.stringify(this.index, null, 2)); 83 | console.log(`Saved OCR index with ${Object.keys(this.index).length} entries`); 84 | } catch (error) { 85 | console.error('Failed to save OCR index:', error); 86 | } 87 | } 88 | 89 | /** 90 | * Perform OCR on a single image using macOS Vision framework via Swift script 91 | */ 92 | async performOCR(filePath: string): Promise { 93 | // Create a Swift script that uses Vision framework for OCR 94 | const swiftScript = ` 95 | import Vision 96 | import AppKit 97 | import Foundation 98 | 99 | guard CommandLine.arguments.count > 1 else { 100 | print("Error: No image path provided") 101 | exit(1) 102 | } 103 | 104 | let imagePath = CommandLine.arguments[1] 105 | let imageURL = URL(fileURLWithPath: imagePath) 106 | 107 | guard let image = NSImage(contentsOf: imageURL) else { 108 | print("Error: Could not load image") 109 | exit(1) 110 | } 111 | 112 | guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { 113 | print("Error: Could not convert to CGImage") 114 | exit(1) 115 | } 116 | 117 | let semaphore = DispatchSemaphore(value: 0) 118 | var recognizedText = "" 119 | 120 | let request = VNRecognizeTextRequest { request, error in 121 | guard let observations = request.results as? [VNRecognizedTextObservation], error == nil else { 122 | semaphore.signal() 123 | return 124 | } 125 | 126 | let text = observations.compactMap { observation in 127 | observation.topCandidates(1).first?.string 128 | }.joined(separator: "\\n") 129 | 130 | recognizedText = text 131 | semaphore.signal() 132 | } 133 | 134 | // 专门针对中文优化的设置 135 | request.recognitionLevel = .accurate 136 | request.recognitionLanguages = ["zh-Hans", "zh-Hant", "en-US"] // 优先中文 137 | request.usesLanguageCorrection = true 138 | 139 | // 设置自动语言检测 140 | request.automaticallyDetectsLanguage = true 141 | 142 | let requestHandler = VNImageRequestHandler(cgImage: cgImage, options: [:]) 143 | 144 | do { 145 | try requestHandler.perform([request]) 146 | semaphore.wait() 147 | print(recognizedText) 148 | } catch { 149 | print("Error: OCR failed - \\(error)") 150 | exit(1) 151 | } 152 | `; 153 | 154 | try { 155 | // Write Swift script to temporary file 156 | const tempScriptPath = `/tmp/ocr_${Date.now()}.swift`; 157 | 158 | // Use Node.js fs to write the file instead of Obsidian's adapter 159 | const fs = require('fs'); 160 | fs.writeFileSync(tempScriptPath, swiftScript); 161 | 162 | // Execute Swift script 163 | const { stdout, stderr } = await execAsync(`swift "${tempScriptPath}" "${filePath}"`); 164 | 165 | // Clean up temp file 166 | try { 167 | fs.unlinkSync(tempScriptPath); 168 | } catch (cleanupError) { 169 | console.warn('Failed to cleanup temp script:', cleanupError); 170 | } 171 | 172 | if (stderr) { 173 | console.error('OCR stderr:', stderr); 174 | } 175 | 176 | return stdout.trim(); 177 | } catch (error) { 178 | console.error('OCR failed for', filePath, error); 179 | 180 | // Fallback: Try using the shortcuts command if available 181 | try { 182 | const { stdout } = await execAsync(`shortcuts run "Extract Text from Image" --input-path "${filePath}" 2>/dev/null`); 183 | return stdout.trim(); 184 | } catch (fallbackError) { 185 | console.error('Fallback OCR also failed:', fallbackError); 186 | return ''; 187 | } 188 | } 189 | } 190 | 191 | /** 192 | * Get OCR text for an image, using cache if available 193 | */ 194 | async getOCRText(file: TFile): Promise { 195 | const filePath = file.path; 196 | 197 | // Check if we have a cached result that's not too old (30 days) 198 | if (this.index[filePath]) { 199 | const cachedResult = this.index[filePath]; 200 | const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; 201 | 202 | if (Date.now() - cachedResult.timestamp < thirtyDaysMs) { 203 | // Also check if file hasn't been modified since indexing 204 | if (file.stat.mtime <= cachedResult.timestamp) { 205 | return cachedResult.text; 206 | } 207 | } 208 | } 209 | 210 | // Perform OCR and extract context 211 | const absolutePath = (this.app.vault.adapter as any).getFullPath(filePath); 212 | const text = await this.performOCR(absolutePath); 213 | 214 | // Extract context information 215 | const context = await this.extractImageContext(file); 216 | 217 | this.index[filePath] = { 218 | text, 219 | timestamp: Date.now(), 220 | context 221 | }; 222 | 223 | // Save index after each new OCR (could be optimized with debouncing) 224 | await this.saveIndex(); 225 | 226 | return text; 227 | } 228 | 229 | /** 230 | * Index all images in the vault with parallel processing 231 | */ 232 | async indexAllImages( 233 | images: Array<{ file?: TFile; isLocal: boolean }>, 234 | onProgress?: (current: number, total: number) => void, 235 | concurrencyLimit: number = 4 236 | ): Promise { 237 | const localImages = images.filter(img => img.isLocal && img.file); 238 | let processed = 0; 239 | 240 | // Split images into chunks for parallel processing 241 | const chunks = []; 242 | for (let i = 0; i < localImages.length; i += concurrencyLimit) { 243 | chunks.push(localImages.slice(i, i + concurrencyLimit)); 244 | } 245 | 246 | for (const chunk of chunks) { 247 | // Process current chunk in parallel 248 | const promises = chunk.map(async (img) => { 249 | if (img.file) { 250 | try { 251 | await this.getOCRTextWithoutSave(img.file); // Don't save after each OCR 252 | processed++; 253 | 254 | if (onProgress) { 255 | onProgress(processed, localImages.length); 256 | } 257 | return true; 258 | } catch (error) { 259 | console.error(`Failed to index ${img.file.path}:`, error); 260 | processed++; 261 | 262 | if (onProgress) { 263 | onProgress(processed, localImages.length); 264 | } 265 | return false; 266 | } 267 | } 268 | return false; 269 | }); 270 | 271 | // Wait for current chunk to complete before processing next chunk 272 | await Promise.all(promises); 273 | 274 | // Save index after each chunk to avoid data loss 275 | await this.saveIndex(); 276 | } 277 | 278 | // Final save to ensure all data is persisted 279 | await this.saveIndex(); 280 | } 281 | 282 | /** 283 | * Get OCR text without saving index (for batch processing) 284 | */ 285 | async getOCRTextWithoutSave(file: TFile): Promise { 286 | const filePath = file.path; 287 | 288 | // Check if we have a cached result that's not too old (30 days) 289 | if (this.index[filePath]) { 290 | const cachedResult = this.index[filePath]; 291 | const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; 292 | 293 | if (Date.now() - cachedResult.timestamp < thirtyDaysMs) { 294 | // Also check if file hasn't been modified since indexing 295 | if (file.stat.mtime <= cachedResult.timestamp) { 296 | return cachedResult.text; 297 | } 298 | } 299 | } 300 | 301 | // Perform OCR and extract context 302 | const absolutePath = (this.app.vault.adapter as any).getFullPath(filePath); 303 | const text = await this.performOCR(absolutePath); 304 | 305 | // Extract context information 306 | const context = await this.extractImageContext(file); 307 | 308 | this.index[filePath] = { 309 | text, 310 | timestamp: Date.now(), 311 | context 312 | }; 313 | 314 | // Don't save index here - will be saved in batch 315 | return text; 316 | } 317 | 318 | /** 319 | * Search images by OCR content and context 320 | */ 321 | searchImages(query: string): Set { 322 | const results = new Set(); 323 | 324 | if (!query.trim()) { 325 | return results; 326 | } 327 | 328 | // Parse the search query into structured search terms 329 | const searchTerms = this.parseSearchQuery(query); 330 | 331 | for (const [filePath, ocrResult] of Object.entries(this.index)) { 332 | // Use pre-computed searchable content or compute on demand 333 | const searchableContent = this.getSearchableContent(ocrResult); 334 | 335 | // Evaluate the search terms against the content 336 | if (this.evaluateSearchTerms(searchTerms, searchableContent)) { 337 | results.add(filePath); 338 | } 339 | } 340 | 341 | return results; 342 | } 343 | 344 | /** 345 | * Get or compute searchable content for an OCR result 346 | */ 347 | private getSearchableContent(ocrResult: OCRResult): string { 348 | if (!ocrResult._searchableContent) { 349 | ocrResult._searchableContent = [ 350 | ocrResult.text, 351 | ...(ocrResult.context?.referencingNotes.map(note => note.title) || []), 352 | ocrResult.context?.nearbyContent || '' 353 | ].join(' ').toLowerCase(); 354 | } 355 | return ocrResult._searchableContent; 356 | } 357 | 358 | /** 359 | * Parse search query into structured terms supporting various syntax 360 | */ 361 | private parseSearchQuery(query: string): SearchTerm[] { 362 | const terms: SearchTerm[] = []; 363 | const tokens = this.tokenizeQuery(query); 364 | 365 | let i = 0; 366 | while (i < tokens.length) { 367 | const token = tokens[i]; 368 | 369 | if (token.toLowerCase() === 'or') { 370 | // Handle OR operator - modify the previous term 371 | if (terms.length > 0) { 372 | const prevTerm = terms[terms.length - 1]; 373 | if (i + 1 < tokens.length) { 374 | const nextToken = tokens[i + 1]; 375 | const nextTerm = this.parseToken(nextToken); 376 | prevTerm.alternatives = prevTerm.alternatives || []; 377 | prevTerm.alternatives.push(nextTerm); 378 | i += 2; // Skip the OR and next token 379 | continue; 380 | } 381 | } 382 | } else { 383 | const term = this.parseToken(token); 384 | terms.push(term); 385 | } 386 | i++; 387 | } 388 | 389 | return terms; 390 | } 391 | 392 | /** 393 | * Tokenize query while respecting quoted phrases 394 | */ 395 | private tokenizeQuery(query: string): string[] { 396 | const tokens: string[] = []; 397 | let current = ''; 398 | let inQuotes = false; 399 | let quoteChar = ''; 400 | 401 | for (let i = 0; i < query.length; i++) { 402 | const char = query[i]; 403 | 404 | if ((char === '"' || char === "'") && !inQuotes) { 405 | // Start of quoted phrase 406 | inQuotes = true; 407 | quoteChar = char; 408 | current += char; 409 | } else if (char === quoteChar && inQuotes) { 410 | // End of quoted phrase 411 | inQuotes = false; 412 | current += char; 413 | tokens.push(current.trim()); 414 | current = ''; 415 | quoteChar = ''; 416 | } else if (char === ' ' && !inQuotes) { 417 | // Space outside quotes - end current token 418 | if (current.trim()) { 419 | tokens.push(current.trim()); 420 | current = ''; 421 | } 422 | } else { 423 | current += char; 424 | } 425 | } 426 | 427 | // Add final token 428 | if (current.trim()) { 429 | tokens.push(current.trim()); 430 | } 431 | 432 | return tokens.filter(token => token.length > 0); 433 | } 434 | 435 | /** 436 | * Parse individual token into search term 437 | */ 438 | private parseToken(token: string): SearchTerm { 439 | const term: SearchTerm = { 440 | text: '', 441 | isNegated: false, 442 | isPhrase: false, 443 | alternatives: [] 444 | }; 445 | 446 | // Check for negation 447 | if (token.startsWith('-')) { 448 | term.isNegated = true; 449 | token = token.substring(1); 450 | } 451 | 452 | // Check for quoted phrase 453 | if ((token.startsWith('"') && token.endsWith('"')) || 454 | (token.startsWith("'") && token.endsWith("'"))) { 455 | term.isPhrase = true; 456 | term.text = token.slice(1, -1).toLowerCase(); 457 | } else { 458 | term.text = token.toLowerCase(); 459 | } 460 | 461 | return term; 462 | } 463 | 464 | /** 465 | * Evaluate search terms against content 466 | */ 467 | private evaluateSearchTerms(terms: SearchTerm[], content: string): boolean { 468 | for (const term of terms) { 469 | const matches = this.evaluateSingleTerm(term, content); 470 | 471 | if (term.isNegated) { 472 | // For negated terms, if ANY match, this fails 473 | if (matches) { 474 | return false; 475 | } 476 | } else { 477 | // For positive terms, ALL must match 478 | if (!matches) { 479 | return false; 480 | } 481 | } 482 | } 483 | return true; 484 | } 485 | 486 | /** 487 | * Evaluate a single search term 488 | */ 489 | private evaluateSingleTerm(term: SearchTerm, content: string): boolean { 490 | // Check the main term 491 | const mainMatch = term.isPhrase 492 | ? content.includes(term.text) 493 | : this.matchesKeywords([term.text], content); 494 | 495 | // Check alternatives (OR logic) 496 | if (term.alternatives && term.alternatives.length > 0) { 497 | const alternativeMatches = term.alternatives.some(alt => 498 | alt.isPhrase 499 | ? content.includes(alt.text) 500 | : this.matchesKeywords([alt.text], content) 501 | ); 502 | return mainMatch || alternativeMatches; 503 | } 504 | 505 | return mainMatch; 506 | } 507 | 508 | /** 509 | * Check if content matches keywords (supports multi-word AND logic) 510 | */ 511 | private matchesKeywords(keywords: string[], content: string): boolean { 512 | return keywords.every(keyword => { 513 | // Split keyword into individual words and check all are present 514 | const words = keyword.split(/\s+/).filter(word => word.length > 0); 515 | return words.every(word => content.includes(word)); 516 | }); 517 | } 518 | 519 | /** 520 | * Clear the entire index 521 | */ 522 | async clearIndex(): Promise { 523 | this.index = {}; 524 | // Clear performance caches 525 | this.fileContentCache.clear(); 526 | this.regexCache.clear(); 527 | await this.saveIndex(); 528 | } 529 | 530 | /** 531 | * Clean up performance caches periodically 532 | */ 533 | cleanupCaches(): void { 534 | // Clear file content cache if it gets too large 535 | if (this.fileContentCache.size > 500) { 536 | const entries = Array.from(this.fileContentCache.entries()); 537 | // Sort by mtime and keep only the 250 most recent 538 | entries.sort((a, b) => b[1].mtime - a[1].mtime); 539 | this.fileContentCache.clear(); 540 | entries.slice(0, 250).forEach(([path, data]) => { 541 | this.fileContentCache.set(path, data); 542 | }); 543 | } 544 | 545 | // Clear regex cache if it gets too large 546 | if (this.regexCache.size > 100) { 547 | this.regexCache.clear(); 548 | } 549 | } 550 | 551 | /** 552 | * Get index statistics 553 | */ 554 | getIndexStats(): { total: number; size: number } { 555 | const total = Object.keys(this.index).length; 556 | const size = JSON.stringify(this.index).length; 557 | return { total, size }; 558 | } 559 | 560 | /** 561 | * Perform incremental update - only index new or modified images 562 | */ 563 | async incrementalUpdate( 564 | images: Array<{ file?: TFile; isLocal: boolean }>, 565 | onProgress?: (current: number, total: number) => void, 566 | concurrencyLimit: number = 4 567 | ): Promise<{ indexed: number; skipped: number }> { 568 | const localImages = images.filter(img => img.isLocal && img.file); 569 | const imagesToIndex = []; 570 | 571 | // Check which images need indexing 572 | for (const img of localImages) { 573 | if (!img.file) continue; 574 | 575 | const filePath = img.file.path; 576 | const existingResult = this.index[filePath]; 577 | 578 | if (!existingResult) { 579 | // New image - needs indexing 580 | imagesToIndex.push(img); 581 | } else { 582 | // Check if file was modified since last indexing 583 | if (img.file.stat.mtime > existingResult.timestamp) { 584 | imagesToIndex.push(img); 585 | } 586 | } 587 | } 588 | 589 | if (imagesToIndex.length === 0) { 590 | return { indexed: 0, skipped: localImages.length }; 591 | } 592 | 593 | // Index only the images that need it 594 | await this.indexAllImages(imagesToIndex, onProgress, concurrencyLimit); 595 | 596 | return { 597 | indexed: imagesToIndex.length, 598 | skipped: localImages.length - imagesToIndex.length 599 | }; 600 | } 601 | 602 | /** 603 | * Extract context information for an image 604 | */ 605 | async extractImageContext(file: TFile): Promise<{ referencingNotes: Array<{ title: string; path: string }>; nearbyContent: string }> { 606 | const referencingNotes: Array<{ title: string; path: string }> = []; 607 | let nearbyContent = ''; 608 | 609 | // Get all markdown files 610 | const markdownFiles = this.app.vault.getMarkdownFiles(); 611 | const imageName = file.name; 612 | const imagePath = file.path; 613 | const imageBasename = file.basename; 614 | 615 | // Get context lines setting once 616 | const plugin = (this.app as any).plugins.getPlugin('image-gallery-plugin'); 617 | const contextLines = plugin?.settings?.contextParagraphs || 3; 618 | 619 | // Use Promise.all to read files in parallel, but limit concurrency 620 | const batchSize = 10; 621 | for (let i = 0; i < markdownFiles.length; i += batchSize) { 622 | const batch = markdownFiles.slice(i, i + batchSize); 623 | 624 | await Promise.all(batch.map(async (mdFile: TFile) => { 625 | try { 626 | const content = await this.getCachedFileContent(mdFile); 627 | 628 | // Quick check with multiple patterns 629 | if (!this.fileReferencesImage(content, imageName, imagePath, imageBasename)) { 630 | return; 631 | } 632 | 633 | // Add to referencing notes 634 | referencingNotes.push({ 635 | title: mdFile.basename, 636 | path: mdFile.path 637 | }); 638 | 639 | // Extract nearby content 640 | const contextFromFile = this.extractNearbyContent(content, imageName, imagePath, imageBasename, contextLines); 641 | if (contextFromFile) { 642 | const separator = nearbyContent ? '\n---\n' : ''; 643 | nearbyContent += separator + `${mdFile.basename}: ${contextFromFile}`; 644 | } 645 | } catch (error) { 646 | console.error(`Failed to read file ${mdFile.path}:`, error); 647 | } 648 | })); 649 | } 650 | 651 | return { referencingNotes, nearbyContent }; 652 | } 653 | 654 | /** 655 | * Get cached file content with mtime check 656 | */ 657 | private async getCachedFileContent(file: TFile): Promise { 658 | const cached = this.fileContentCache.get(file.path); 659 | 660 | if (cached && cached.mtime >= file.stat.mtime) { 661 | return cached.content; 662 | } 663 | 664 | const content = await this.app.vault.read(file); 665 | this.fileContentCache.set(file.path, { 666 | content, 667 | mtime: file.stat.mtime 668 | }); 669 | 670 | return content; 671 | } 672 | 673 | /** 674 | * Quick check if file references an image 675 | */ 676 | private fileReferencesImage(content: string, imageName: string, imagePath: string, imageBasename: string): boolean { 677 | // Use indexOf for better performance than includes 678 | return content.indexOf(imageName) !== -1 || 679 | content.indexOf(imagePath) !== -1 || 680 | content.indexOf(imageBasename) !== -1; 681 | } 682 | 683 | /** 684 | * Get cached regex patterns for image matching 685 | */ 686 | private getImagePatterns(imageName: string, imageBasename: string): RegExp[] { 687 | const cacheKey = `${imageName}|${imageBasename}`; 688 | 689 | if (this.regexCache.has(cacheKey)) { 690 | return this.regexCache.get(cacheKey)!; 691 | } 692 | 693 | const patterns = [ 694 | new RegExp(`!\\[\\[${this.escapeRegex(imageName)}\\]\\]`, 'i'), 695 | new RegExp(`!\\[.*?\\]\\(.*?${this.escapeRegex(imageName)}.*?\\)`, 'i'), 696 | new RegExp(` { 723 | const trimmed = line.trim(); 724 | return trimmed === '' || // Empty lines 725 | trimmed.startsWith('#') || // Headers 726 | trimmed.startsWith('![[') || // Wiki image links 727 | trimmed.startsWith('![') || // Markdown images 728 | trimmed.includes(' pattern.test(line)); 739 | 740 | if (hasImageRef) { 741 | const contextLines: string[] = []; 742 | 743 | // Collect previous context lines 744 | let prevCount = 0; 745 | for (let j = i - 1; j >= 0 && prevCount < maxContextLines; j--) { 746 | const prevLine = lines[j].trim(); 747 | if (!shouldSkipLine(prevLine)) { 748 | contextLines.unshift(prevLine); 749 | prevCount++; 750 | } 751 | } 752 | 753 | // Collect next context lines 754 | let nextCount = 0; 755 | for (let j = i + 1; j < lines.length && nextCount < maxContextLines; j++) { 756 | const nextLine = lines[j].trim(); 757 | if (!shouldSkipLine(nextLine)) { 758 | contextLines.push(nextLine); 759 | nextCount++; 760 | } 761 | } 762 | 763 | if (contextLines.length > 0) { 764 | contexts.push(contextLines.join(' | ')); 765 | } 766 | } 767 | } 768 | 769 | return contexts.join(' | '); 770 | } 771 | 772 | /** 773 | * Get cached OCR result for debugging 774 | */ 775 | getCachedResult(filePath: string): OCRResult | null { 776 | return this.index[filePath] || null; 777 | } 778 | 779 | /** 780 | * Perform OCR with detailed debug information 781 | */ 782 | async performOCRWithDebug(filePath: string): Promise { 783 | // Create a Swift script that uses Vision framework for OCR 784 | const swiftScript = ` 785 | import Vision 786 | import AppKit 787 | import Foundation 788 | 789 | guard CommandLine.arguments.count > 1 else { 790 | print("Error: No image path provided") 791 | exit(1) 792 | } 793 | 794 | let imagePath = CommandLine.arguments[1] 795 | let imageURL = URL(fileURLWithPath: imagePath) 796 | 797 | guard let image = NSImage(contentsOf: imageURL) else { 798 | print("Error: Could not load image") 799 | exit(1) 800 | } 801 | 802 | guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { 803 | print("Error: Could not convert to CGImage") 804 | exit(1) 805 | } 806 | 807 | let semaphore = DispatchSemaphore(value: 0) 808 | var recognizedText = "" 809 | 810 | let request = VNRecognizeTextRequest { request, error in 811 | guard let observations = request.results as? [VNRecognizedTextObservation], error == nil else { 812 | if let error = error { 813 | print("OCR Error: \\(error.localizedDescription)") 814 | } 815 | semaphore.signal() 816 | return 817 | } 818 | 819 | let text = observations.compactMap { observation in 820 | observation.topCandidates(1).first?.string 821 | }.joined(separator: "\\n") 822 | 823 | recognizedText = text 824 | semaphore.signal() 825 | } 826 | 827 | // 专门针对中文优化的设置 828 | request.recognitionLevel = .accurate 829 | request.recognitionLanguages = ["zh-Hans", "zh-Hant", "en-US"] // 优先中文 830 | request.usesLanguageCorrection = true 831 | 832 | // 设置自动语言检测 833 | request.automaticallyDetectsLanguage = true 834 | 835 | let requestHandler = VNImageRequestHandler(cgImage: cgImage, options: [:]) 836 | 837 | do { 838 | try requestHandler.perform([request]) 839 | semaphore.wait() 840 | print(recognizedText) 841 | } catch { 842 | print("OCR Processing Error: \\(error.localizedDescription)") 843 | exit(1) 844 | } 845 | `; 846 | 847 | try { 848 | // Write Swift script to temporary file 849 | const tempScriptPath = `/tmp/ocr_debug_${Date.now()}.swift`; 850 | 851 | // Use Node.js fs to write the file instead of Obsidian's adapter 852 | const fs = require('fs'); 853 | fs.writeFileSync(tempScriptPath, swiftScript); 854 | 855 | // Execute Swift script 856 | const { stdout, stderr } = await execAsync(`swift "${tempScriptPath}" "${filePath}"`); 857 | 858 | // Clean up temp file 859 | try { 860 | fs.unlinkSync(tempScriptPath); 861 | } catch (cleanupError) { 862 | console.warn('Failed to cleanup temp script:', cleanupError); 863 | } 864 | 865 | return { 866 | text: stdout.trim(), 867 | stderr: stderr || undefined, 868 | timestamp: Date.now() 869 | }; 870 | } catch (error) { 871 | console.error('OCR failed for', filePath, error); 872 | 873 | // Try fallback method 874 | try { 875 | const { stdout, stderr } = await execAsync(`shortcuts run "Extract Text from Image" --input-path "${filePath}" 2>/dev/null`); 876 | return { 877 | text: stdout.trim(), 878 | stderr: stderr || undefined, 879 | timestamp: Date.now() 880 | }; 881 | } catch (fallbackError) { 882 | return { 883 | text: '', 884 | error: error instanceof Error ? error.message : String(error), 885 | stderr: undefined, 886 | timestamp: Date.now() 887 | }; 888 | } 889 | } 890 | } 891 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-image-gallery-plugin", 3 | "version": "1.0.0", 4 | "description": "Display all images in your vault in a beautiful gallery view. Supports both local and remote images.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": ["obsidian", "plugin", "image", "gallery", "photos"], 12 | "author": "Jiayuan", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "5.29.0", 17 | "@typescript-eslint/parser": "5.29.0", 18 | "builtin-modules": "3.3.0", 19 | "esbuild": "0.17.3", 20 | "obsidian": "latest", 21 | "tslib": "2.4.0", 22 | "typescript": "4.7.4" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/modals/VaultSearchModal.ts: -------------------------------------------------------------------------------- 1 | import { Modal, App, TFile, TextComponent, ButtonComponent, Notice, MarkdownView } from 'obsidian'; 2 | import { StyleManager } from '../styles/styleManager'; 3 | import { ImageGallerySettings } from '../types'; 4 | import { OCRService } from '../../ocr-service'; 5 | 6 | interface ImageInfo { 7 | path: string; 8 | file?: TFile; 9 | isLocal: boolean; 10 | displayName: string; 11 | createdTime?: number; 12 | modifiedTime?: number; 13 | ocrText?: string; 14 | } 15 | 16 | interface BlockSearchResult { 17 | file: TFile; 18 | blockContent: string; 19 | blockStartLine: number; 20 | blockEndLine: number; 21 | matchedTerms: string[]; 22 | score: number; 23 | context: string; 24 | isTitle: boolean; 25 | isImage?: boolean; 26 | imagePreview?: string; 27 | } 28 | 29 | export class VaultSearchModal extends Modal { 30 | private searchInput: HTMLInputElement; 31 | private searchResultsContainer: HTMLElement; 32 | private searchMetadataEl: HTMLElement; 33 | private styleManager: StyleManager; 34 | private searchResults: BlockSearchResult[] = []; 35 | private searchTimeout?: NodeJS.Timeout; 36 | private currentSearchTerms: string[] = []; 37 | private selectedResultIndex: number = -1; 38 | private hoveredResultIndex: number = -1; 39 | private static lastSearchQuery: string = ''; 40 | private settings: ImageGallerySettings; 41 | private ocrService: OCRService; 42 | 43 | constructor(app: App, private plugin: any) { 44 | super(app); 45 | this.styleManager = new StyleManager(); 46 | this.ocrService = plugin.ocrService; 47 | this.settings = plugin.settings; 48 | } 49 | 50 | /** 51 | * Check if a file should be excluded from search 52 | */ 53 | private isFileExcluded(file: TFile): boolean { 54 | if (!this.settings?.searchExcludeFolders || this.settings.searchExcludeFolders.length === 0) { 55 | return false; 56 | } 57 | 58 | const filePath = file.path; 59 | return this.settings.searchExcludeFolders.some(excludePath => { 60 | // Check if file path starts with excluded folder path 61 | return filePath.startsWith(excludePath + '/') || filePath === excludePath; 62 | }); 63 | } 64 | 65 | /** 66 | * Parse search query into individual terms 67 | * Supports: "quoted phrases", word1 word2 (all must match) 68 | */ 69 | private parseSearchQuery(query: string): string[] { 70 | const terms: string[] = []; 71 | const quotedPhrases = query.match(/"[^"]+"/g) || []; 72 | 73 | // Extract quoted phrases 74 | quotedPhrases.forEach(phrase => { 75 | terms.push(phrase.replace(/"/g, '').toLowerCase()); 76 | query = query.replace(phrase, ''); 77 | }); 78 | 79 | // Extract individual words 80 | const words = query.trim().split(/\s+/).filter(word => word.length > 0); 81 | words.forEach(word => { 82 | if (word.length > 0) { 83 | terms.push(word.toLowerCase()); 84 | } 85 | }); 86 | 87 | return terms; 88 | } 89 | 90 | /** 91 | * Check if a line is a bullet point 92 | */ 93 | private isBulletPoint(line: string): boolean { 94 | const trimmed = line.trim(); 95 | // Check for various bullet point formats 96 | return /^[-*+•]\s/.test(trimmed) || /^\d+\.\s/.test(trimmed) || /^[a-zA-Z]\.\s/.test(trimmed); 97 | } 98 | 99 | /** 100 | * Check if a line is a title/heading 101 | */ 102 | private isTitle(line: string): boolean { 103 | const trimmed = line.trim(); 104 | // Check for markdown headings (# ## ### etc.) 105 | return /^#{1,6}\s/.test(trimmed); 106 | } 107 | 108 | /** 109 | * Split content into blocks (paragraphs, bullet points, and titles) 110 | */ 111 | private splitContentIntoBlocks(content: string): Array<{content: string; startLine: number; endLine: number; isTitle: boolean}> { 112 | const lines = content.split('\n'); 113 | const blocks: Array<{content: string; startLine: number; endLine: number; isTitle: boolean}> = []; 114 | 115 | let currentBlock = ''; 116 | let blockStartLine = 0; 117 | let currentLine = 0; 118 | 119 | for (const line of lines) { 120 | const trimmedLine = line.trim(); 121 | 122 | // If this is a title, treat it as a high-priority single-line block 123 | if (this.isTitle(line)) { 124 | // Save any existing block first 125 | if (currentBlock.trim()) { 126 | blocks.push({ 127 | content: currentBlock.trim(), 128 | startLine: blockStartLine + 1, 129 | endLine: currentLine, 130 | isTitle: false 131 | }); 132 | currentBlock = ''; 133 | } 134 | 135 | // Add title as its own block with high priority 136 | blocks.push({ 137 | content: line, 138 | startLine: currentLine + 1, 139 | endLine: currentLine + 1, 140 | isTitle: true 141 | }); 142 | 143 | blockStartLine = currentLine + 1; 144 | } 145 | // If this is a bullet point, treat it as a single-line block 146 | else if (this.isBulletPoint(line)) { 147 | // Save any existing block first 148 | if (currentBlock.trim()) { 149 | blocks.push({ 150 | content: currentBlock.trim(), 151 | startLine: blockStartLine + 1, 152 | endLine: currentLine, 153 | isTitle: false 154 | }); 155 | currentBlock = ''; 156 | } 157 | 158 | // Add bullet point as its own block 159 | blocks.push({ 160 | content: line, 161 | startLine: currentLine + 1, 162 | endLine: currentLine + 1, 163 | isTitle: false 164 | }); 165 | 166 | blockStartLine = currentLine + 1; 167 | } 168 | // Check if this is an empty line (paragraph delimiter) 169 | else if (trimmedLine === '') { 170 | // If we have content in current block, save it 171 | if (currentBlock.trim()) { 172 | blocks.push({ 173 | content: currentBlock.trim(), 174 | startLine: blockStartLine + 1, // 1-based line numbers 175 | endLine: currentLine, 176 | isTitle: false 177 | }); 178 | } 179 | 180 | // Reset for next block 181 | currentBlock = ''; 182 | blockStartLine = currentLine + 1; 183 | } else { 184 | // Add line to current block 185 | if (currentBlock) { 186 | currentBlock += '\n' + line; 187 | } else { 188 | currentBlock = line; 189 | blockStartLine = currentLine; 190 | } 191 | } 192 | 193 | currentLine++; 194 | } 195 | 196 | // Don't forget the last block if there's content 197 | if (currentBlock.trim()) { 198 | blocks.push({ 199 | content: currentBlock.trim(), 200 | startLine: blockStartLine + 1, // 1-based line numbers 201 | endLine: currentLine, 202 | isTitle: false 203 | }); 204 | } 205 | 206 | return blocks; 207 | } 208 | 209 | /** 210 | * Check if a block contains all search terms 211 | */ 212 | private blockContainsAllTerms(blockContent: string, terms: string[]): {matches: boolean; matchedTerms: string[]} { 213 | const blockLower = blockContent.toLowerCase(); 214 | const matchedTerms: string[] = []; 215 | 216 | for (const term of terms) { 217 | if (blockLower.includes(term)) { 218 | matchedTerms.push(term); 219 | } 220 | } 221 | 222 | return { 223 | matches: matchedTerms.length === terms.length, 224 | matchedTerms 225 | }; 226 | } 227 | 228 | /** 229 | * Calculate block relevance score with title priority 230 | */ 231 | private calculateBlockScore(blockContent: string, terms: string[], isTitle: boolean = false): number { 232 | const blockLower = blockContent.toLowerCase(); 233 | let score = 0; 234 | 235 | // HUGE bonus for titles - highest priority 236 | if (isTitle) { 237 | score += 1000; 238 | } 239 | 240 | // Base score for each term occurrence 241 | for (const term of terms) { 242 | const termMatches = (blockLower.match(new RegExp(this.escapeRegex(term), 'g')) || []).length; 243 | score += termMatches * (isTitle ? 100 : 10); // Higher score for title matches 244 | } 245 | 246 | // Bonus for shorter blocks (more focused) - but not for titles 247 | if (!isTitle) { 248 | const blockLength = blockContent.length; 249 | if (blockLength < 200) { 250 | score += 20; 251 | } else if (blockLength < 500) { 252 | score += 10; 253 | } 254 | } 255 | 256 | // Bonus for terms appearing close together 257 | if (terms.length > 1) { 258 | for (let i = 0; i < terms.length - 1; i++) { 259 | for (let j = i + 1; j < terms.length; j++) { 260 | const term1Index = blockLower.indexOf(terms[i]); 261 | const term2Index = blockLower.indexOf(terms[j]); 262 | 263 | if (term1Index !== -1 && term2Index !== -1) { 264 | const distance = Math.abs(term2Index - term1Index); 265 | if (distance < 50) { 266 | score += isTitle ? 100 : 30; // Higher bonus for titles 267 | } else if (distance < 100) { 268 | score += isTitle ? 50 : 15; 269 | } else if (distance < 200) { 270 | score += isTitle ? 25 : 5; 271 | } 272 | } 273 | } 274 | } 275 | } 276 | 277 | return score; 278 | } 279 | 280 | /** 281 | * Check if query is a special command (TODO/DONE) 282 | */ 283 | private isSpecialCommand(query: string): { isTodo: boolean; isDone: boolean; remainingQuery: string } { 284 | const trimmed = query.trim().toUpperCase(); 285 | if (trimmed.startsWith('TODO')) { 286 | return { isTodo: true, isDone: false, remainingQuery: query.slice(4).trim() }; 287 | } 288 | if (trimmed.startsWith('DONE')) { 289 | return { isTodo: false, isDone: true, remainingQuery: query.slice(4).trim() }; 290 | } 291 | return { isTodo: false, isDone: false, remainingQuery: query }; 292 | } 293 | 294 | /** 295 | * Search for TODO/DONE blocks specifically 296 | */ 297 | private async searchTodoBlocks(isDone: boolean, additionalQuery: string): Promise { 298 | const results: BlockSearchResult[] = []; 299 | const files = this.app.vault.getMarkdownFiles().filter(file => !this.isFileExcluded(file)); 300 | 301 | const pattern = isDone ? /^- \[x\]/i : /^- \[ \]/i; 302 | 303 | for (const file of files) { 304 | try { 305 | const content = await this.app.vault.read(file); 306 | const lines = content.split('\n'); 307 | 308 | for (let i = 0; i < lines.length; i++) { 309 | const line = lines[i]; 310 | const trimmedLine = line.trim(); 311 | 312 | if (pattern.test(trimmedLine)) { 313 | // Check if additional query matches if provided 314 | if (additionalQuery && additionalQuery.length > 0) { 315 | const lineLower = line.toLowerCase(); 316 | const queryLower = additionalQuery.toLowerCase(); 317 | if (!lineLower.includes(queryLower)) { 318 | continue; 319 | } 320 | } 321 | 322 | // Calculate score based on line content 323 | let score = 100; // Base score for todo items 324 | if (additionalQuery) { 325 | const occurrences = (line.toLowerCase().match(new RegExp(this.escapeRegex(additionalQuery.toLowerCase()), 'g')) || []).length; 326 | score += occurrences * 50; 327 | } 328 | 329 | results.push({ 330 | file, 331 | blockContent: line, 332 | blockStartLine: i + 1, 333 | blockEndLine: i + 1, 334 | matchedTerms: additionalQuery ? [additionalQuery.toLowerCase()] : [], 335 | score, 336 | context: line, 337 | isTitle: false 338 | }); 339 | } 340 | } 341 | } catch (error) { 342 | console.error(`Error reading file ${file.path}:`, error); 343 | } 344 | } 345 | 346 | return results; 347 | } 348 | 349 | /** 350 | * Search images by OCR content 351 | */ 352 | private async searchImages(query: string): Promise { 353 | console.log('🖼️ searchImages function called with query:', query); 354 | if (!query.trim() || query.trim().length < 2) { 355 | console.log('🖼️ searchImages: query too short, returning empty'); 356 | return []; 357 | } 358 | 359 | const results: BlockSearchResult[] = []; 360 | 361 | try { 362 | // Get all images from the main plugin 363 | const allImages: ImageInfo[] = await this.plugin.getAllImages(); 364 | 365 | // Filter to local images only 366 | const localImages = allImages.filter(img => img.isLocal && img.file); 367 | 368 | // Search OCR content for matching images 369 | const imagePaths = this.ocrService.searchImages(query); 370 | console.log('Image search for query:', query, 'Found paths:', imagePaths); 371 | 372 | for (const imagePath of imagePaths) { 373 | // Find the ImageInfo object for this path 374 | const imageInfo = localImages.find(img => img.path === imagePath); 375 | if (!imageInfo || !imageInfo.file) { 376 | continue; 377 | } 378 | 379 | // Check if this image file should be excluded 380 | if (this.isFileExcluded(imageInfo.file)) { 381 | continue; 382 | } 383 | 384 | // Get OCR result for context and text 385 | const ocrResult = this.ocrService.getCachedResult(imagePath); 386 | if (!ocrResult) { 387 | continue; 388 | } 389 | 390 | // Create image preview URL using the correct vault method 391 | const imagePreview = this.app.vault.getResourcePath(imageInfo.file); 392 | 393 | // Format OCR text by joining lines with spaces 394 | const formattedOcrText = this.formatOcrText(ocrResult.text); 395 | 396 | // Calculate score based on OCR content relevance 397 | const score = this.calculateImageScore(ocrResult.text, this.currentSearchTerms) + 500; // Bonus for images 398 | 399 | results.push({ 400 | file: imageInfo.file, 401 | blockContent: formattedOcrText || 'No text detected', 402 | blockStartLine: 0, 403 | blockEndLine: 0, 404 | matchedTerms: this.currentSearchTerms, 405 | score, 406 | context: ocrResult.context?.nearbyContent || '', 407 | isTitle: false, 408 | isImage: true, 409 | imagePreview 410 | }); 411 | } 412 | } catch (error) { 413 | console.error('Error searching images:', error); 414 | } 415 | 416 | return results; 417 | } 418 | 419 | /** 420 | * Format OCR text by joining lines with spaces and cleaning up 421 | */ 422 | private formatOcrText(ocrText: string): string { 423 | if (!ocrText || ocrText.trim() === '') { 424 | return ''; 425 | } 426 | 427 | // Split by newlines and filter out empty lines 428 | const lines = ocrText.split('\n') 429 | .map(line => line.trim()) 430 | .filter(line => line.length > 0); 431 | 432 | // Join with spaces and clean up multiple spaces 433 | return lines.join(' ').replace(/\s+/g, ' ').trim(); 434 | } 435 | 436 | /** 437 | * Calculate relevance score for image OCR content 438 | */ 439 | private calculateImageScore(ocrText: string, terms: string[]): number { 440 | const textLower = ocrText.toLowerCase(); 441 | let score = 0; 442 | 443 | for (const term of terms) { 444 | const termMatches = (textLower.match(new RegExp(this.escapeRegex(term), 'g')) || []).length; 445 | score += termMatches * 20; // Base score for OCR matches 446 | } 447 | 448 | // Bonus for shorter OCR text (more focused) 449 | const textLength = ocrText.length; 450 | if (textLength > 0 && textLength < 100) { 451 | score += 30; 452 | } else if (textLength < 300) { 453 | score += 15; 454 | } 455 | 456 | return score; 457 | } 458 | 459 | /** 460 | * Search vault content at block level 461 | */ 462 | private async searchVaultContent(query: string) { 463 | console.log('=== Search+ searchVaultContent called with query:', query); 464 | const trimmedQuery = query.trim(); 465 | 466 | if (!trimmedQuery) { 467 | this.searchResults = []; 468 | this.currentSearchTerms = []; 469 | this.renderSearchResults(); 470 | return; 471 | } 472 | 473 | // Check for special commands first 474 | const specialCommand = this.isSpecialCommand(trimmedQuery); 475 | 476 | if (specialCommand.isTodo || specialCommand.isDone) { 477 | // Handle TODO/DONE search 478 | this.currentSearchTerms = specialCommand.remainingQuery ? [specialCommand.remainingQuery] : []; 479 | this.searchResults = await this.searchTodoBlocks(specialCommand.isDone, specialCommand.remainingQuery); 480 | this.searchResults.sort((a, b) => { 481 | const aModTime = a.file.stat.mtime; 482 | const bModTime = b.file.stat.mtime; 483 | 484 | // If files have different modification times, sort by time (most recent first) 485 | if (aModTime !== bModTime) { 486 | return bModTime - aModTime; 487 | } 488 | 489 | // If same file or same modification time, sort by relevance score 490 | return b.score - a.score; 491 | }); 492 | this.selectedResultIndex = -1; 493 | this.renderSearchResults(); 494 | return; 495 | } 496 | 497 | // Regular search - require at least 2 characters 498 | if (trimmedQuery.length < 2) { 499 | this.searchResults = []; 500 | this.currentSearchTerms = []; 501 | this.renderSearchResults(); 502 | return; 503 | } 504 | 505 | // Parse search terms 506 | this.currentSearchTerms = this.parseSearchQuery(trimmedQuery); 507 | if (this.currentSearchTerms.length === 0) { 508 | this.searchResults = []; 509 | this.renderSearchResults(); 510 | return; 511 | } 512 | 513 | const results: BlockSearchResult[] = []; 514 | 515 | // Get all markdown files and filter out excluded folders 516 | const files = this.app.vault.getMarkdownFiles().filter(file => !this.isFileExcluded(file)); 517 | 518 | // Search images using OCR if enabled in settings 519 | if (this.settings?.searchIncludeImages) { 520 | console.log('=== Calling searchImages with query:', trimmedQuery); 521 | const imageResults = await this.searchImages(trimmedQuery); 522 | console.log('=== searchImages returned', imageResults.length, 'results'); 523 | results.push(...imageResults); 524 | } else { 525 | console.log('=== Image search disabled in settings, skipping image results'); 526 | } 527 | 528 | for (const file of files) { 529 | try { 530 | // First check if file name matches search terms 531 | const fileNameLower = file.basename.toLowerCase(); 532 | let fileNameMatches = true; 533 | for (const term of this.currentSearchTerms) { 534 | if (!fileNameLower.includes(term)) { 535 | fileNameMatches = false; 536 | break; 537 | } 538 | } 539 | 540 | // If file name matches, add as a special result 541 | if (fileNameMatches) { 542 | results.push({ 543 | file, 544 | blockContent: `File: ${file.basename}`, 545 | blockStartLine: 0, 546 | blockEndLine: 0, 547 | matchedTerms: this.currentSearchTerms, 548 | score: 2000, // Very high score for file name matches 549 | context: file.path, 550 | isTitle: true 551 | }); 552 | } 553 | 554 | // Then search content 555 | const content = await this.app.vault.read(file); 556 | 557 | // Split content into blocks 558 | const blocks = this.splitContentIntoBlocks(content); 559 | 560 | // Search each block 561 | for (const block of blocks) { 562 | const termCheck = this.blockContainsAllTerms(block.content, this.currentSearchTerms); 563 | 564 | if (termCheck.matches) { 565 | const score = this.calculateBlockScore(block.content, this.currentSearchTerms, block.isTitle); 566 | 567 | // Create context (show a bit more around the block if available) 568 | const allLines = content.split('\n'); 569 | const contextStart = Math.max(0, block.startLine - 2); 570 | const contextEnd = Math.min(allLines.length, block.endLine + 1); 571 | const context = allLines.slice(contextStart, contextEnd).join('\n'); 572 | 573 | results.push({ 574 | file, 575 | blockContent: block.content, 576 | blockStartLine: block.startLine, 577 | blockEndLine: block.endLine, 578 | matchedTerms: termCheck.matchedTerms, 579 | score, 580 | context, 581 | isTitle: block.isTitle 582 | }); 583 | } 584 | } 585 | } catch (error) { 586 | console.error(`Error reading file ${file.path}:`, error); 587 | } 588 | } 589 | 590 | // Sort results by file edit time first (most recent first), then by relevance score 591 | results.sort((a, b) => { 592 | const aModTime = a.file.stat.mtime; 593 | const bModTime = b.file.stat.mtime; 594 | 595 | // If files have different modification times, sort by time (most recent first) 596 | if (aModTime !== bModTime) { 597 | return bModTime - aModTime; 598 | } 599 | 600 | // If same file or same modification time, sort by relevance score 601 | return b.score - a.score; 602 | }); 603 | 604 | // Limit total results to prevent overwhelming UI 605 | this.searchResults = results.slice(0, 50); 606 | this.selectedResultIndex = -1; // Reset selection 607 | this.hoveredResultIndex = -1; // Reset hover 608 | this.renderSearchResults(); 609 | } 610 | 611 | /** 612 | * Navigate through search results with keyboard 613 | */ 614 | private navigateResults(direction: 'up' | 'down') { 615 | if (this.searchResults.length === 0) return; 616 | 617 | // Clear hover state during keyboard navigation 618 | this.hoveredResultIndex = -1; 619 | 620 | if (direction === 'down') { 621 | this.selectedResultIndex = Math.min(this.selectedResultIndex + 1, this.searchResults.length - 1); 622 | } else { 623 | this.selectedResultIndex = Math.max(this.selectedResultIndex - 1, -1); 624 | } 625 | 626 | this.updateResultSelection(); 627 | } 628 | 629 | /** 630 | * Update visual selection of results 631 | */ 632 | private updateResultSelection() { 633 | const blockItems = this.searchResultsContainer.querySelectorAll('.search-block-item'); 634 | 635 | blockItems.forEach((item, index) => { 636 | // Remove all classes first 637 | item.removeClass('selected'); 638 | item.removeClass('hovered'); 639 | 640 | // Add appropriate class - selected takes priority over hovered 641 | if (index === this.selectedResultIndex) { 642 | item.addClass('selected'); 643 | } else if (index === this.hoveredResultIndex && index !== this.selectedResultIndex) { 644 | item.addClass('hovered'); 645 | } 646 | }); 647 | 648 | // Scroll selected item into view (no animation for efficiency) 649 | if (this.selectedResultIndex >= 0) { 650 | const selectedItem = blockItems[this.selectedResultIndex] as HTMLElement; 651 | if (selectedItem) { 652 | selectedItem.scrollIntoView({ block: 'nearest' }); 653 | } 654 | } 655 | } 656 | 657 | /** 658 | * Open selected result and navigate to specific block 659 | */ 660 | private async openSelectedResult() { 661 | if (this.selectedResultIndex >= 0 && this.selectedResultIndex < this.searchResults.length) { 662 | const result = this.searchResults[this.selectedResultIndex]; 663 | 664 | // Handle image results differently 665 | if (result.isImage) { 666 | // Open image file directly or in image gallery 667 | const leaf = this.app.workspace.getLeaf('tab'); 668 | await leaf.openFile(result.file); 669 | this.close(); 670 | return; 671 | } 672 | 673 | // Open file in new tab and navigate to line 674 | const leaf = this.app.workspace.getLeaf('tab'); 675 | await leaf.openFile(result.file); 676 | 677 | // Navigate to the specific line if available 678 | if (result.blockStartLine > 0) { 679 | const view = leaf.view as MarkdownView; 680 | if (view && view.editor) { 681 | // Navigate to the line 682 | const lineNumber = Math.max(0, result.blockStartLine - 1); // Convert to 0-based 683 | view.editor.setCursor({ line: lineNumber, ch: 0 }); 684 | view.editor.scrollIntoView({ from: { line: lineNumber, ch: 0 }, to: { line: lineNumber, ch: 0 } }, true); 685 | } 686 | } 687 | 688 | this.close(); 689 | } 690 | } 691 | 692 | 693 | /** 694 | * Highlight search terms in text 695 | */ 696 | private highlightTerms(text: string): string { 697 | let highlighted = text; 698 | 699 | // Sort terms by length (longest first) to avoid partial replacements 700 | const sortedTerms = [...this.currentSearchTerms].sort((a, b) => b.length - a.length); 701 | 702 | for (const term of sortedTerms) { 703 | const regex = new RegExp(`(${this.escapeRegex(term)})`, 'gi'); 704 | highlighted = highlighted.replace(regex, '$1'); 705 | } 706 | 707 | return highlighted; 708 | } 709 | 710 | /** 711 | * Escape special regex characters 712 | */ 713 | private escapeRegex(str: string): string { 714 | return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 715 | } 716 | 717 | /** 718 | * Format relative time 719 | */ 720 | private formatRelativeTime(timestamp: number): string { 721 | const now = Date.now(); 722 | const diff = now - timestamp; 723 | const seconds = Math.floor(diff / 1000); 724 | const minutes = Math.floor(seconds / 60); 725 | const hours = Math.floor(minutes / 60); 726 | const days = Math.floor(hours / 24); 727 | const weeks = Math.floor(days / 7); 728 | const months = Math.floor(days / 30); 729 | 730 | if (seconds < 60) return 'just now'; 731 | if (minutes < 60) return minutes === 1 ? '1 min ago' : `${minutes} mins ago`; 732 | if (hours < 24) return hours === 1 ? '1 hour ago' : `${hours} hours ago`; 733 | if (days < 7) return days === 1 ? 'yesterday' : `${days} days ago`; 734 | if (weeks < 4) return weeks === 1 ? '1 week ago' : `${weeks} weeks ago`; 735 | if (months < 12) return months === 1 ? '1 month ago' : `${months} months ago`; 736 | return 'long ago'; 737 | } 738 | 739 | /** 740 | * Update search metadata display 741 | */ 742 | private updateSearchMetadata() { 743 | if (!this.searchMetadataEl) return; 744 | 745 | this.searchMetadataEl.empty(); 746 | 747 | if (this.searchResults.length > 0) { 748 | // Count unique files and image results 749 | const uniqueFiles = new Set(this.searchResults.map(r => r.file.path)).size; 750 | const imageResults = this.searchResults.filter(r => r.isImage).length; 751 | const blockResults = this.searchResults.length - imageResults; 752 | 753 | // Get search type 754 | const currentQuery = this.searchInput?.value || ''; 755 | const specialCommand = this.isSpecialCommand(currentQuery); 756 | 757 | let metadataText = ''; 758 | if (specialCommand.isTodo || specialCommand.isDone) { 759 | const taskType = specialCommand.isTodo ? 'TODO' : 'DONE'; 760 | metadataText = `${this.searchResults.length} ${taskType} items in ${uniqueFiles} files`; 761 | } else { 762 | const parts = []; 763 | if (blockResults > 0) parts.push(`${blockResults} blocks`); 764 | if (imageResults > 0) parts.push(`${imageResults} images`); 765 | metadataText = `${parts.join(', ')} in ${uniqueFiles} files`; 766 | } 767 | 768 | if (this.currentSearchTerms.length > 0) { 769 | metadataText += ' • Terms: ' + this.currentSearchTerms.join(', '); 770 | } 771 | 772 | this.searchMetadataEl.createEl('span', { 773 | text: metadataText, 774 | cls: 'search-metadata-text' 775 | }); 776 | } 777 | } 778 | 779 | /** 780 | * Render search results 781 | */ 782 | private renderSearchResults() { 783 | if (!this.searchResultsContainer) return; 784 | 785 | this.searchResultsContainer.empty(); 786 | 787 | // Update metadata display 788 | this.updateSearchMetadata(); 789 | 790 | if (this.searchResults.length === 0) { 791 | const currentQuery = this.searchInput?.value || ''; 792 | const specialCommand = this.isSpecialCommand(currentQuery); 793 | 794 | if (currentQuery.trim()) { 795 | if (currentQuery.trim().length < 2 && !specialCommand.isTodo && !specialCommand.isDone) { 796 | this.searchResultsContainer.createEl('p', { 797 | text: 'Type at least 2 characters', 798 | cls: 'search-empty-state' 799 | }); 800 | } else { 801 | this.searchResultsContainer.createEl('p', { 802 | text: 'No results', 803 | cls: 'search-empty-state' 804 | }); 805 | } 806 | } else { 807 | this.searchResultsContainer.createEl('p', { 808 | text: 'Start typing to search', 809 | cls: 'search-empty-state' 810 | }); 811 | } 812 | return; 813 | } 814 | 815 | // Group results by file 816 | const resultsByFile = new Map(); 817 | for (const result of this.searchResults) { 818 | const filePath = result.file.path; 819 | if (!resultsByFile.has(filePath)) { 820 | resultsByFile.set(filePath, []); 821 | } 822 | resultsByFile.get(filePath)!.push(result); 823 | } 824 | 825 | // Create results list 826 | const resultsList = this.searchResultsContainer.createEl('div', { 827 | cls: 'search-results-list' 828 | }); 829 | 830 | // Display grouped results 831 | let displayIndex = 0; // Index for UI display elements 832 | for (const [filePath, fileResults] of resultsByFile) { 833 | const fileGroup = resultsList.createEl('div', { 834 | cls: 'search-file-group' 835 | }); 836 | 837 | // Check if any result is a file name match 838 | const hasFileNameMatch = fileResults.some(r => r.blockContent.startsWith('File: ')); 839 | 840 | // Check if this file group contains image results 841 | const hasImageResults = fileResults.some(r => r.isImage); 842 | 843 | // File header (only once per file) 844 | let headerClass = 'search-file-header'; 845 | if (hasFileNameMatch) headerClass += ' file-name-match'; 846 | if (hasImageResults) headerClass += ' image-file'; 847 | 848 | const fileHeader = fileGroup.createEl('div', { 849 | cls: headerClass 850 | }); 851 | 852 | const fileName = fileHeader.createEl('span', { 853 | text: fileResults[0].file.basename, 854 | cls: 'search-file-name' 855 | }); 856 | 857 | if (hasFileNameMatch) { 858 | fileName.innerHTML = this.highlightTerms(fileResults[0].file.basename); 859 | } 860 | 861 | fileHeader.createEl('span', { 862 | text: '·', 863 | cls: 'search-file-separator' 864 | }); 865 | 866 | fileHeader.createEl('span', { 867 | text: this.formatRelativeTime(fileResults[0].file.stat.mtime), 868 | cls: 'search-file-time' 869 | }); 870 | 871 | fileHeader.createEl('span', { 872 | text: `(${fileResults.length} ${fileResults.length === 1 ? 'match' : 'matches'})`, 873 | cls: 'search-file-count' 874 | }); 875 | 876 | // Click file header to open file 877 | fileHeader.addEventListener('click', async () => { 878 | // Open file in new tab 879 | const leaf = this.app.workspace.getLeaf('tab'); 880 | await leaf.openFile(fileResults[0].file); 881 | this.close(); 882 | }); 883 | 884 | // Display blocks for this file 885 | const blocksContainer = fileGroup.createEl('div', { 886 | cls: 'search-blocks-container' 887 | }); 888 | 889 | for (let i = 0; i < fileResults.length; i++) { 890 | const result = fileResults[i]; 891 | 892 | // Skip file name matches in block display (they're shown in header) 893 | if (result.blockContent.startsWith('File: ')) { 894 | continue; 895 | } 896 | 897 | const blockItem = blocksContainer.createEl('div', { 898 | cls: 'search-block-item' 899 | }); 900 | 901 | // Find the actual index in searchResults array 902 | const actualResultIndex = this.searchResults.findIndex(r => 903 | r.file.path === result.file.path && 904 | r.blockContent === result.blockContent && 905 | r.blockStartLine === result.blockStartLine 906 | ); 907 | 908 | const currentDisplayIndex = displayIndex++; 909 | 910 | // Add click handler to open file 911 | blockItem.addEventListener('click', async () => { 912 | // Use the actual result index from searchResults array 913 | this.selectedResultIndex = actualResultIndex; 914 | await this.openSelectedResult(); 915 | }); 916 | 917 | // Add hover effect 918 | blockItem.addEventListener('mouseenter', () => { 919 | this.hoveredResultIndex = currentDisplayIndex; 920 | this.updateResultSelection(); 921 | }); 922 | 923 | // Clear hover when mouse leaves 924 | blockItem.addEventListener('mouseleave', () => { 925 | this.hoveredResultIndex = -1; 926 | this.updateResultSelection(); 927 | }); 928 | 929 | // Block content 930 | const blockContentEl = blockItem.createEl('div', { 931 | cls: result.isImage ? 'search-block-content search-image-content' : 'search-block-content' 932 | }); 933 | 934 | // Add image preview if this is an image result 935 | if (result.isImage && result.imagePreview) { 936 | console.log('Rendering image result:', result.file.path, 'Preview URL:', result.imagePreview); 937 | 938 | const imagePreviewEl = blockContentEl.createEl('div', { 939 | cls: 'search-image-preview' 940 | }); 941 | 942 | const imageEl = imagePreviewEl.createEl('img', { 943 | cls: 'search-image-thumbnail' 944 | }); 945 | imageEl.src = result.imagePreview; 946 | imageEl.alt = result.file.name; 947 | 948 | // Add error handling for image loading 949 | imageEl.addEventListener('error', () => { 950 | console.error('Failed to load image:', result.imagePreview); 951 | imageEl.style.display = 'none'; 952 | }); 953 | 954 | imageEl.addEventListener('load', () => { 955 | console.log('Image loaded successfully:', result.imagePreview); 956 | }); 957 | 958 | // Content area next to image 959 | const contentAreaEl = blockContentEl.createEl('div', { 960 | cls: 'search-image-content-area' 961 | }); 962 | 963 | // OCR text with label 964 | if (result.blockContent && result.blockContent !== 'No text detected') { 965 | const ocrSection = contentAreaEl.createEl('div', { 966 | cls: 'search-image-ocr-section' 967 | }); 968 | 969 | const ocrLabel = ocrSection.createEl('div', { 970 | cls: 'search-image-ocr-label', 971 | text: 'Image text:' 972 | }); 973 | 974 | const ocrText = ocrSection.createEl('div', { 975 | cls: 'search-image-ocr-text' 976 | }); 977 | ocrText.innerHTML = this.highlightTerms(result.blockContent); 978 | } else { 979 | const noTextEl = contentAreaEl.createEl('div', { 980 | cls: 'search-image-no-text', 981 | text: 'No text detected in image' 982 | }); 983 | } 984 | 985 | // Context section removed for cleaner interface 986 | 987 | // File info 988 | const fileInfo = contentAreaEl.createEl('div', { 989 | cls: 'search-image-file-info', 990 | text: `📷 ${result.file.name}` 991 | }); 992 | 993 | } else { 994 | blockContentEl.innerHTML = this.highlightTerms(result.blockContent); 995 | } 996 | } 997 | } 998 | 999 | // Update selection after rendering 1000 | this.updateResultSelection(); 1001 | } 1002 | 1003 | async onOpen() { 1004 | const { contentEl } = this; 1005 | contentEl.empty(); 1006 | 1007 | // Add custom class to modal for styling 1008 | this.modalEl.addClass('mod-vault-search'); 1009 | this.modalEl.setAttribute('data-vault-search-modal', 'true'); 1010 | 1011 | // Add minimal mode class if enabled 1012 | if (this.settings?.searchMinimalMode) { 1013 | this.modalEl.addClass('mod-minimal'); 1014 | } 1015 | 1016 | // Hide the close button 1017 | const closeButton = this.modalEl.querySelector('.modal-close-button'); 1018 | if (closeButton) { 1019 | (closeButton as HTMLElement).style.display = 'none'; 1020 | } 1021 | 1022 | // Add title (hidden in minimal mode) 1023 | if (!this.settings?.searchMinimalMode) { 1024 | contentEl.createEl('h2', { text: '🔍 Search+ - Vault Content Search' }); 1025 | } 1026 | 1027 | // Search header 1028 | const searchHeader = contentEl.createDiv({ cls: 'search-header' }); 1029 | 1030 | // Custom search input container - completely independent 1031 | const searchInputContainer = searchHeader.createDiv({ cls: 'custom-search-container' }); 1032 | 1033 | this.searchInput = document.createElement('input'); 1034 | this.searchInput.type = 'text'; 1035 | this.searchInput.placeholder = 'Search blocks and images... (min 2 chars, or type TODO/DONE)'; 1036 | this.searchInput.className = 'custom-search-input'; 1037 | searchInputContainer.appendChild(this.searchInput); 1038 | 1039 | // Restore last search query 1040 | if (VaultSearchModal.lastSearchQuery) { 1041 | this.searchInput.value = VaultSearchModal.lastSearchQuery; 1042 | } 1043 | 1044 | // Auto-focus search input and select text if there's a previous search 1045 | this.searchInput.focus(); 1046 | if (VaultSearchModal.lastSearchQuery) { 1047 | this.searchInput.select(); 1048 | } 1049 | 1050 | // Search on input with debounce 1051 | this.searchInput.addEventListener('input', async (e) => { 1052 | const value = (e.target as HTMLInputElement).value; 1053 | // Save search query 1054 | VaultSearchModal.lastSearchQuery = value; 1055 | 1056 | if (this.searchTimeout) { 1057 | clearTimeout(this.searchTimeout); 1058 | } 1059 | 1060 | this.searchTimeout = setTimeout(async () => { 1061 | await this.searchVaultContent(value); 1062 | }, 300); 1063 | }); 1064 | 1065 | // Keyboard navigation 1066 | this.searchInput.addEventListener('keydown', async (e) => { 1067 | if (e.key === 'Enter') { 1068 | e.preventDefault(); 1069 | if (this.selectedResultIndex >= 0) { 1070 | // Open selected result 1071 | this.openSelectedResult(); 1072 | } else { 1073 | // Search immediately 1074 | await this.searchVaultContent(this.searchInput.value); 1075 | } 1076 | } 1077 | // Navigation shortcuts 1078 | else if ((e.ctrlKey || e.metaKey) && e.key === 'p') { 1079 | e.preventDefault(); 1080 | this.navigateResults('up'); 1081 | } 1082 | else if ((e.ctrlKey || e.metaKey) && e.key === 'n') { 1083 | e.preventDefault(); 1084 | this.navigateResults('down'); 1085 | } 1086 | else if ((e.ctrlKey || e.metaKey) && e.key === 'j') { 1087 | e.preventDefault(); 1088 | this.navigateResults('down'); 1089 | } 1090 | else if ((e.ctrlKey || e.metaKey) && e.key === 'k') { 1091 | e.preventDefault(); 1092 | this.navigateResults('up'); 1093 | } 1094 | else if (e.key === 'ArrowDown') { 1095 | e.preventDefault(); 1096 | this.navigateResults('down'); 1097 | } 1098 | else if (e.key === 'ArrowUp') { 1099 | e.preventDefault(); 1100 | this.navigateResults('up'); 1101 | } 1102 | }); 1103 | 1104 | // Search metadata display 1105 | this.searchMetadataEl = searchHeader.createEl('div', { 1106 | cls: 'search-metadata' 1107 | }); 1108 | 1109 | // Help text (hidden in minimal mode) 1110 | if (!this.settings?.searchMinimalMode) { 1111 | const helpText = searchHeader.createEl('div', { 1112 | text: 'Search blocks and images (2+ chars): Use spaces for multiple words, "quotes" for exact phrases. Type TODO/DONE for task items.', 1113 | cls: 'search-help-text' 1114 | }); 1115 | } 1116 | 1117 | // Results container 1118 | this.searchResultsContainer = contentEl.createDiv({ cls: 'search-results-container' }); 1119 | 1120 | // Always show placeholder initially to avoid UI flashing 1121 | this.renderSearchResults(); 1122 | 1123 | // If there was a previous query, trigger search after UI is stable 1124 | if (VaultSearchModal.lastSearchQuery) { 1125 | // Use setTimeout to avoid UI flashing during modal opening 1126 | setTimeout(async () => { 1127 | await this.searchVaultContent(VaultSearchModal.lastSearchQuery); 1128 | }, 100); 1129 | } 1130 | 1131 | // Add styles 1132 | this.addModalStyles(); 1133 | } 1134 | 1135 | private addModalStyles() { 1136 | const styles = ` 1137 | [data-vault-search-modal="true"].modal.mod-vault-search { 1138 | width: 80vw !important; 1139 | max-width: 1000px !important; 1140 | height: 80vh !important; 1141 | max-height: 80vh !important; 1142 | } 1143 | 1144 | /* Minimal mode styles */ 1145 | [data-vault-search-modal="true"].modal.mod-vault-search.mod-minimal { 1146 | width: 70vw !important; 1147 | max-width: 800px !important; 1148 | height: 70vh !important; 1149 | } 1150 | 1151 | [data-vault-search-modal="true"].mod-minimal .modal-content { 1152 | padding: 12px !important; 1153 | } 1154 | 1155 | /* Hide elements in minimal mode */ 1156 | [data-vault-search-modal="true"].mod-minimal .search-help-text, 1157 | [data-vault-search-modal="true"].mod-minimal .search-results-header, 1158 | [data-vault-search-modal="true"].mod-minimal .search-result-file-path, 1159 | [data-vault-search-modal="true"].mod-minimal .search-result-stats { 1160 | display: none !important; 1161 | } 1162 | 1163 | /* Simplified search input in minimal mode */ 1164 | [data-vault-search-modal="true"].mod-minimal .search-header { 1165 | margin-bottom: 12px !important; 1166 | } 1167 | 1168 | [data-vault-search-modal="true"].mod-minimal .search-result-item { 1169 | padding: 8px !important; 1170 | margin-bottom: 4px !important; 1171 | } 1172 | 1173 | [data-vault-search-modal="true"] .modal-content { 1174 | height: 100% !important; 1175 | display: flex !important; 1176 | flex-direction: column !important; 1177 | padding: 20px !important; 1178 | } 1179 | 1180 | [data-vault-search-modal="true"] h2 { 1181 | margin-bottom: 20px !important; 1182 | color: var(--text-normal) !important; 1183 | } 1184 | 1185 | /* Search header */ 1186 | [data-vault-search-modal="true"] .search-header { 1187 | margin-bottom: 20px !important; 1188 | } 1189 | 1190 | /* Custom search input - completely independent */ 1191 | [data-vault-search-modal="true"] .custom-search-container { 1192 | margin-bottom: 10px !important; 1193 | position: relative !important; 1194 | width: 100% !important; 1195 | } 1196 | 1197 | [data-vault-search-modal="true"] .custom-search-input { 1198 | width: 100% !important; 1199 | padding: 12px 16px !important; 1200 | background: var(--background-secondary) !important; 1201 | border: 2px solid var(--background-modifier-border) !important; 1202 | border-radius: 6px !important; 1203 | font-size: 14px !important; 1204 | color: var(--text-normal) !important; 1205 | font-family: var(--font-interface) !important; 1206 | box-sizing: border-box !important; 1207 | transition: border-color 0.15s ease !important; 1208 | /* Reset any inherited styles */ 1209 | background-image: none !important; 1210 | background-repeat: no-repeat !important; 1211 | background-position: left center !important; 1212 | appearance: none !important; 1213 | -webkit-appearance: none !important; 1214 | -moz-appearance: none !important; 1215 | } 1216 | 1217 | [data-vault-search-modal="true"] .custom-search-input:focus { 1218 | border-color: var(--interactive-accent) !important; 1219 | box-shadow: 0 0 0 2px var(--interactive-accent-alpha) !important; 1220 | outline: none !important; 1221 | background-image: none !important; 1222 | } 1223 | 1224 | [data-vault-search-modal="true"] .custom-search-input::placeholder { 1225 | color: var(--text-muted) !important; 1226 | opacity: 1 !important; 1227 | } 1228 | 1229 | /* Search metadata display */ 1230 | [data-vault-search-modal="true"] .search-metadata { 1231 | margin: 8px 0 !important; 1232 | padding: 6px 12px !important; 1233 | background: var(--background-secondary) !important; 1234 | border-radius: 4px !important; 1235 | min-height: 24px !important; 1236 | } 1237 | 1238 | [data-vault-search-modal="true"] .search-metadata:empty { 1239 | display: none !important; 1240 | } 1241 | 1242 | [data-vault-search-modal="true"] .search-metadata-text { 1243 | font-size: 12px !important; 1244 | color: var(--text-muted) !important; 1245 | font-weight: 500 !important; 1246 | } 1247 | 1248 | [data-vault-search-modal="true"] .search-help-text { 1249 | font-size: 12px !important; 1250 | color: var(--text-muted) !important; 1251 | font-style: italic !important; 1252 | } 1253 | 1254 | 1255 | /* Results container */ 1256 | [data-vault-search-modal="true"] .search-results-container { 1257 | flex: 1 !important; 1258 | overflow-y: auto !important; 1259 | border: 1px solid var(--background-modifier-border) !important; 1260 | border-radius: 8px !important; 1261 | padding: 16px !important; 1262 | background: var(--background-primary) !important; 1263 | } 1264 | 1265 | [data-vault-search-modal="true"] .search-placeholder, 1266 | [data-vault-search-modal="true"] .search-no-results { 1267 | text-align: center !important; 1268 | color: var(--text-muted) !important; 1269 | padding: 60px 20px !important; 1270 | font-size: 14px !important; 1271 | line-height: 1.6 !important; 1272 | } 1273 | 1274 | 1275 | /* Minimalist results list */ 1276 | [data-vault-search-modal="true"] .search-results-list { 1277 | display: flex !important; 1278 | flex-direction: column !important; 1279 | gap: 24px !important; 1280 | } 1281 | 1282 | /* File group - no decoration */ 1283 | [data-vault-search-modal="true"] .search-file-group { 1284 | /* Intentionally empty - no styling needed */ 1285 | } 1286 | 1287 | /* File header - minimal, text only */ 1288 | [data-vault-search-modal="true"] .search-file-header { 1289 | display: flex !important; 1290 | align-items: baseline !important; 1291 | gap: 8px !important; 1292 | margin-bottom: 8px !important; 1293 | cursor: pointer !important; 1294 | padding: 6px 8px !important; 1295 | margin: 0 -8px 8px -8px !important; 1296 | border-radius: 4px !important; 1297 | transition: background 0.15s ease !important; 1298 | } 1299 | 1300 | [data-vault-search-modal="true"] .search-file-header:hover { 1301 | background: var(--background-modifier-hover) !important; 1302 | } 1303 | 1304 | /* File name - only visual emphasis */ 1305 | [data-vault-search-modal="true"] .search-file-name { 1306 | font-weight: 600 !important; 1307 | color: var(--text-normal) !important; 1308 | font-size: 13px !important; 1309 | } 1310 | 1311 | /* File name match - subtle highlight */ 1312 | [data-vault-search-modal="true"] .search-file-header.file-name-match .search-file-name { 1313 | color: var(--text-accent) !important; 1314 | } 1315 | 1316 | [data-vault-search-modal="true"] .search-file-separator { 1317 | color: var(--text-muted) !important; 1318 | opacity: 0.4 !important; 1319 | font-size: 12px !important; 1320 | } 1321 | 1322 | [data-vault-search-modal="true"] .search-file-time { 1323 | color: var(--text-muted) !important; 1324 | font-size: 12px !important; 1325 | opacity: 0.6 !important; 1326 | } 1327 | 1328 | [data-vault-search-modal="true"] .search-file-count { 1329 | display: none !important; /* Remove match count - unnecessary */ 1330 | } 1331 | 1332 | /* Blocks container - simple indentation */ 1333 | [data-vault-search-modal="true"] .search-blocks-container { 1334 | margin-left: 16px !important; 1335 | } 1336 | 1337 | /* Block items - minimal styling with clear interaction */ 1338 | [data-vault-search-modal="true"] .search-block-item { 1339 | padding: 8px 12px !important; 1340 | margin: 4px -12px !important; 1341 | cursor: pointer !important; 1342 | border-radius: 4px !important; 1343 | transition: background 0.15s ease !important; 1344 | } 1345 | 1346 | [data-vault-search-modal="true"] .search-block-item.selected { 1347 | background: var(--interactive-accent) !important; 1348 | color: var(--text-on-accent) !important; 1349 | } 1350 | 1351 | [data-vault-search-modal="true"] .search-block-item.hovered { 1352 | background: var(--background-modifier-hover) !important; 1353 | } 1354 | 1355 | /* Ensure selected content is readable */ 1356 | [data-vault-search-modal="true"] .search-block-item.selected .search-block-content { 1357 | color: var(--text-on-accent) !important; 1358 | } 1359 | 1360 | [data-vault-search-modal="true"] .search-block-item.selected .search-block-content mark { 1361 | color: var(--text-on-accent) !important; 1362 | font-weight: 600 !important; 1363 | } 1364 | 1365 | /* Block content - clean text */ 1366 | [data-vault-search-modal="true"] .search-block-content { 1367 | font-size: 13px !important; 1368 | color: var(--text-muted) !important; 1369 | line-height: 1.6 !important; 1370 | white-space: pre-wrap !important; 1371 | word-break: break-word !important; 1372 | } 1373 | 1374 | /* Minimal highlight - just color, no decoration */ 1375 | [data-vault-search-modal="true"] .search-block-content mark { 1376 | background: transparent !important; 1377 | color: var(--text-accent) !important; 1378 | font-weight: 500 !important; 1379 | } 1380 | 1381 | /* Image search result styles */ 1382 | [data-vault-search-modal="true"] .search-image-content { 1383 | display: flex !important; 1384 | gap: 16px !important; 1385 | align-items: flex-start !important; 1386 | padding: 8px !important; 1387 | background: var(--background-secondary) !important; 1388 | border-radius: 8px !important; 1389 | margin: 4px 0 !important; 1390 | } 1391 | 1392 | [data-vault-search-modal="true"] .search-image-preview { 1393 | flex-shrink: 0 !important; 1394 | } 1395 | 1396 | [data-vault-search-modal="true"] .search-image-thumbnail { 1397 | width: 120px !important; 1398 | height: 90px !important; 1399 | object-fit: cover !important; 1400 | border-radius: 6px !important; 1401 | border: 2px solid var(--background-modifier-border) !important; 1402 | transition: all 0.2s ease !important; 1403 | cursor: pointer !important; 1404 | } 1405 | 1406 | [data-vault-search-modal="true"] .search-image-thumbnail:hover { 1407 | border-color: var(--interactive-accent) !important; 1408 | transform: scale(1.02) !important; 1409 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important; 1410 | } 1411 | 1412 | [data-vault-search-modal="true"] .search-image-content-area { 1413 | flex: 1 !important; 1414 | display: flex !important; 1415 | flex-direction: column !important; 1416 | gap: 8px !important; 1417 | } 1418 | 1419 | [data-vault-search-modal="true"] .search-image-ocr-section { 1420 | margin-bottom: 6px !important; 1421 | } 1422 | 1423 | [data-vault-search-modal="true"] .search-image-ocr-label { 1424 | font-size: 11px !important; 1425 | font-weight: 600 !important; 1426 | color: var(--text-accent) !important; 1427 | margin-bottom: 3px !important; 1428 | text-transform: uppercase !important; 1429 | letter-spacing: 0.5px !important; 1430 | } 1431 | 1432 | [data-vault-search-modal="true"] .search-image-ocr-text { 1433 | font-size: 13px !important; 1434 | color: var(--text-normal) !important; 1435 | line-height: 1.4 !important; 1436 | background: var(--background-primary) !important; 1437 | padding: 6px 8px !important; 1438 | border-radius: 4px !important; 1439 | border-left: 3px solid var(--interactive-accent) !important; 1440 | } 1441 | 1442 | [data-vault-search-modal="true"] .search-image-no-text { 1443 | font-size: 12px !important; 1444 | color: var(--text-faint) !important; 1445 | font-style: italic !important; 1446 | padding: 6px 8px !important; 1447 | } 1448 | 1449 | 1450 | [data-vault-search-modal="true"] .search-image-file-info { 1451 | font-size: 11px !important; 1452 | color: var(--text-muted) !important; 1453 | margin-top: auto !important; 1454 | padding-top: 6px !important; 1455 | border-top: 1px solid var(--background-modifier-border) !important; 1456 | font-weight: 500 !important; 1457 | } 1458 | 1459 | /* Image result file header styling */ 1460 | [data-vault-search-modal="true"] .search-file-header.image-file .search-file-name { 1461 | color: var(--color-orange) !important; 1462 | } 1463 | 1464 | [data-vault-search-modal="true"] .search-file-header.image-file::before { 1465 | content: "🖼️ " !important; 1466 | font-size: 12px !important; 1467 | } 1468 | `; 1469 | 1470 | this.styleManager.addModalStyles(this.modalEl, 'vault-search-modal-styles', styles); 1471 | } 1472 | 1473 | onClose() { 1474 | const { contentEl } = this; 1475 | contentEl.empty(); 1476 | 1477 | // Clean up timeout 1478 | if (this.searchTimeout) { 1479 | clearTimeout(this.searchTimeout); 1480 | } 1481 | 1482 | // Clean up styles 1483 | this.styleManager.cleanup(); 1484 | 1485 | // Remove data attribute 1486 | this.modalEl.removeAttribute('data-vault-search-modal'); 1487 | } 1488 | } -------------------------------------------------------------------------------- /src/styles/styleManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * StyleManager - Handles all CSS injection and cleanup for the plugin 3 | */ 4 | export class StyleManager { 5 | private styles: Map = new Map(); 6 | 7 | /** 8 | * Inject reset styles to prevent conflicts with other plugins 9 | */ 10 | injectResetStyles(container: HTMLElement): void { 11 | const resetStyle = document.createElement('style'); 12 | resetStyle.textContent = ` 13 | /* Reset all inherited CSS custom properties and styles */ 14 | .modal.mod-image-preview, 15 | .modal.mod-image-gallery, 16 | .modal.mod-ocr-debug, 17 | .modal.mod-confirm { 18 | /* Reset Tailwind variables that might be injected by other plugins */ 19 | --tw-border-spacing-x: initial !important; 20 | --tw-border-spacing-y: initial !important; 21 | --tw-translate-x: initial !important; 22 | --tw-translate-y: initial !important; 23 | --tw-rotate: initial !important; 24 | --tw-skew-x: initial !important; 25 | --tw-skew-y: initial !important; 26 | --tw-scale-x: initial !important; 27 | --tw-scale-y: initial !important; 28 | --tw-pan-x: initial !important; 29 | --tw-pan-y: initial !important; 30 | --tw-pinch-zoom: initial !important; 31 | 32 | /* Ensure our modals use standard box model */ 33 | box-sizing: border-box !important; 34 | } 35 | 36 | /* Reset children to prevent interference */ 37 | .modal.mod-image-preview *, 38 | .modal.mod-image-gallery *, 39 | .modal.mod-ocr-debug *, 40 | .modal.mod-confirm * { 41 | /* Reset Tailwind transform variables */ 42 | --tw-translate-x: initial !important; 43 | --tw-translate-y: initial !important; 44 | --tw-rotate: initial !important; 45 | --tw-scale-x: initial !important; 46 | --tw-scale-y: initial !important; 47 | } 48 | `; 49 | 50 | // Insert at the beginning of the container 51 | container.insertBefore(resetStyle, container.firstChild); 52 | } 53 | 54 | /** 55 | * Add styles to a modal with proper isolation 56 | */ 57 | addModalStyles(modalEl: HTMLElement, styleId: string, styles: string): void { 58 | // Remove existing style if any 59 | this.removeStyles(styleId); 60 | 61 | // First inject reset styles 62 | this.injectResetStyles(modalEl); 63 | 64 | // Then inject the modal-specific styles 65 | const style = document.createElement('style'); 66 | style.id = styleId; 67 | style.textContent = styles; 68 | 69 | modalEl.appendChild(style); 70 | this.styles.set(styleId, style); 71 | } 72 | 73 | /** 74 | * Remove styles by ID 75 | */ 76 | removeStyles(styleId: string): void { 77 | const style = this.styles.get(styleId); 78 | if (style) { 79 | style.remove(); 80 | this.styles.delete(styleId); 81 | } 82 | 83 | // Also check document for any orphaned styles 84 | const orphaned = document.getElementById(styleId); 85 | if (orphaned) { 86 | orphaned.remove(); 87 | } 88 | } 89 | 90 | /** 91 | * Clean up all managed styles 92 | */ 93 | cleanup(): void { 94 | this.styles.forEach((style, id) => { 95 | style.remove(); 96 | }); 97 | this.styles.clear(); 98 | } 99 | } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { TFile } from 'obsidian'; 2 | 3 | export interface ImageInfo { 4 | path: string; 5 | file?: TFile; 6 | isLocal: boolean; 7 | displayName: string; 8 | createdTime?: number; 9 | modifiedTime?: number; 10 | ocrText?: string; 11 | } 12 | 13 | export interface ImageGallerySettings { 14 | enableOCRDebug: boolean; 15 | ocrConcurrency: number; 16 | contextParagraphs: number; 17 | enableFolderFilter: boolean; 18 | searchExcludeFolders: string[]; 19 | searchMinimalMode: boolean; 20 | searchIncludeImages: boolean; 21 | } 22 | 23 | export const DEFAULT_SETTINGS: ImageGallerySettings = { 24 | enableOCRDebug: false, 25 | ocrConcurrency: 4, 26 | contextParagraphs: 3, 27 | enableFolderFilter: true, 28 | searchExcludeFolders: [], 29 | searchMinimalMode: false, 30 | searchIncludeImages: true 31 | } -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This CSS file will be included with your plugin, and 4 | available in the app when your plugin is enabled. 5 | 6 | If your plugin does not need CSS, delete this file. 7 | 8 | */ 9 | -------------------------------------------------------------------------------- /styles/modal-reset.css: -------------------------------------------------------------------------------- 1 | /* Reset styles to prevent conflicts with other plugins (especially Tailwind-based ones) */ 2 | 3 | /* Reset all inherited CSS custom properties and styles for our modals */ 4 | .modal.mod-image-preview, 5 | .modal.mod-image-gallery, 6 | .modal.mod-ocr-debug, 7 | .modal.mod-confirm { 8 | /* Reset all inherited styles */ 9 | all: revert; 10 | 11 | /* Reset all CSS custom properties that might be injected by other plugins */ 12 | --tw-border-spacing-x: initial; 13 | --tw-border-spacing-y: initial; 14 | --tw-translate-x: initial; 15 | --tw-translate-y: initial; 16 | --tw-rotate: initial; 17 | --tw-skew-x: initial; 18 | --tw-skew-y: initial; 19 | --tw-scale-x: initial; 20 | --tw-scale-y: initial; 21 | 22 | /* Ensure our modals use standard box model */ 23 | box-sizing: border-box !important; 24 | } 25 | 26 | /* Reset all children elements to prevent Tailwind interference */ 27 | .modal.mod-image-preview *, 28 | .modal.mod-image-gallery *, 29 | .modal.mod-ocr-debug *, 30 | .modal.mod-confirm * { 31 | /* Reset Tailwind custom properties */ 32 | --tw-border-spacing-x: initial; 33 | --tw-border-spacing-y: initial; 34 | --tw-translate-x: initial; 35 | --tw-translate-y: initial; 36 | --tw-rotate: initial; 37 | --tw-skew-x: initial; 38 | --tw-skew-y: initial; 39 | --tw-scale-x: initial; 40 | --tw-scale-y: initial; 41 | 42 | /* Use standard box model */ 43 | box-sizing: border-box !important; 44 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0" 3 | } 4 | --------------------------------------------------------------------------------