├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── lint.js.yml │ ├── release.yml │ └── typecheck.js.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierrc.json ├── LICENSE.md ├── README.md ├── esbuild.config.mjs ├── manifest.json ├── package-lock.json ├── package.json ├── preview.gif ├── src ├── Backgrounder │ ├── Backgrounder.ts │ ├── BackgrounderLane.ts │ ├── index.ts │ └── types.ts ├── ImagePicker.ts ├── ImagePickerSettings.tsx ├── ImagePickerView.tsx ├── Indexer │ ├── Indexer.ts │ ├── IndexerDB.ts │ ├── index.ts │ └── types.ts ├── client │ ├── ImagePickerContext.tsx │ ├── ImagePickerView │ │ ├── ImagePickerView.tsx │ │ ├── Pagination.tsx │ │ ├── Search.tsx │ │ └── index.ts │ └── Thumbnail.tsx ├── constants.ts ├── main.tsx ├── styles.scss └── utils.ts ├── tsconfig.json ├── version-bump.mjs └── versions.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { 5 | "node": true 6 | }, 7 | "plugins": [ 8 | "@typescript-eslint", 9 | "import" 10 | ], 11 | "ignorePatterns": [ 12 | "node_modules/", 13 | "dist/" 14 | ], 15 | "extends": [ 16 | "eslint:recommended", 17 | "plugin:@typescript-eslint/eslint-recommended", 18 | "plugin:@typescript-eslint/recommended", 19 | "plugin:react-hooks/recommended" 20 | ], 21 | "parserOptions": { 22 | "sourceType": "module" 23 | }, 24 | "rules": { 25 | "no-unused-vars": "off", 26 | "@typescript-eslint/no-unused-vars": [ 27 | "warn", 28 | { 29 | "args": "none" 30 | } 31 | ], 32 | "@typescript-eslint/ban-ts-comment": "off", 33 | "@typescript-eslint/no-explicit-any": "warn", 34 | "no-prototype-builtins": "off", 35 | "@typescript-eslint/no-empty-function": "off", 36 | "import/order": [ 37 | "error", 38 | { 39 | "groups": [ 40 | "builtin", 41 | "external", 42 | "internal", 43 | "parent", 44 | "sibling", 45 | "index" 46 | ], 47 | "newlines-between": "always" 48 | } 49 | ] 50 | } 51 | } -------------------------------------------------------------------------------- /.github/workflows/lint.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Lint 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [22.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run lint 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Version to bump' 8 | required: true 9 | default: 'patch' 10 | type: choice 11 | options: 12 | - patch 13 | - minor 14 | - major 15 | 16 | jobs: 17 | release: 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v2 23 | 24 | - name: Set up Node.js 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: '22' 28 | 29 | - name: Install dependencies 30 | run: npm install 31 | 32 | - name: Bump version 33 | id: bump_version 34 | run: | 35 | CURRENT_VERSION=$(node -p "require('./package.json').version") 36 | npm version ${{ github.event.inputs.version }} 37 | NEW_VERSION=$(node -p "require('./package.json').version") 38 | echo "::set-output name=new_version::$NEW_VERSION" 39 | 40 | - name: Push changes 41 | run: | 42 | git push origin HEAD --follow-tags 43 | 44 | - name: Create GitHub release 45 | id: create_release 46 | uses: actions/create-release@v1 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | with: 50 | tag_name: v${{ steps.bump_version.outputs.new_version }} 51 | release_name: Release ${{ steps.bump_version.outputs.new_version }} 52 | draft: false 53 | prerelease: false 54 | 55 | - name: Build project 56 | run: npm run build 57 | 58 | - name: Upload release assets 59 | uses: actions/upload-release-asset@v1 60 | with: 61 | upload_url: ${{ steps.create_release.outputs.upload_url }} 62 | asset_path: ./dist/main.js 63 | asset_name: main.js 64 | asset_content_type: application/javascript 65 | 66 | - name: Upload styles 67 | uses: actions/upload-release-asset@v1 68 | with: 69 | upload_url: ${{ steps.create_release.outputs.upload_url }} 70 | asset_path: ./dist/styles.css 71 | asset_name: styles.css 72 | asset_content_type: text/css 73 | 74 | - name: Upload manifest 75 | uses: actions/upload-release-asset@v1 76 | with: 77 | upload_url: ${{ steps.create_release.outputs.upload_url }} 78 | asset_path: ./dist/manifest.json 79 | asset_name: manifest.json 80 | asset_content_type: application/json 81 | -------------------------------------------------------------------------------- /.github/workflows/typecheck.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Typecheck 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [22.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run typecheck 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled plugin file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | dist/ 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | 24 | .env -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22 -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "useTabs": false 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 ari.the.elk.wtf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📷 Image Picker 2 | 3 | Image Picker is a _blazingly_ fast way to browse and find media in your Obsidian vault. It's **extremely performant**, automatically generating thumbnails and background indexing your vault for instant search results. 4 | 5 | ![Image Picker Preview](./preview.gif) 6 | 7 | 🐙 [GitHub](https://github.com/AriTheElk/obsidian-image-picker) | 📖 [Docs](https://ari.the.elk.wtf/obsidian/plugins/image-picker) | 💝 [Donate](https://ari.the.elk.wtf/donate) 8 | 9 | # ⚙️ Settings 10 | 11 | - **Image Folder**: The highest root folder that you want Image Picker to search for images in. If you store your attachments in a specific folder, please select it for better performance. 12 | - **Animate Gifs**: Whether or not to animate gifs in the preview. Disabling this will improve performance if you have a lot of gifs in your vault. 13 | - **Debug Mode**: Enable this to see helpful debug logs in the console. Please use this if you're experiencing issues and want to report them. 14 | - **Reset Image Index**: Click this button to reset the image index and reload Obsidian. This will force Image Picker to re-index your vault, which is useful if you've changed your `Image Folder`. 15 | 16 | # 🗺️ Roadmap 17 | 18 | - [x] Performantly browse photos across your vault 19 | - [x] Search for photos by name and extension 20 | - [x] Background image indexing for instant search results 21 | - [x] Automatically generate thumbnails for images 22 | - [ ] Support for renaming images 23 | - [ ] Support local LLMs for generating searchable descriptions 24 | - [ ] Index and search for images by metadata, such as location, date, and more 25 | - [ ] Support for video and audio files 26 | 27 | # 🛠️ Contributing 28 | 29 | Quick start: 30 | 31 | ```bash 32 | # Clone the repository 33 | git clone https://github.com/AriTheElk/obsidian-image-picker 34 | cd obsidian-image-picker 35 | 36 | # Install the dependencies 37 | npm install 38 | 39 | # To build the plugin 40 | npm run build 41 | 42 | # If you want to build the plugin directly into your vault 43 | echo "/ABSOLUTE/PATH/TO/YOUR/VAULT" > .env 44 | npm run build:vault 45 | ``` 46 | 47 | This will clone the repository, install the dependencies, and build the plugin. If you've set the path correctly, the plugin should be available in your Obsidian vault. It copies to `{vault}/.obsidian/plugins/obsidian-image-picker-dev` immediately after building. 48 | 49 | If you'd like to help out, I'd recommend checking out the [help wanted](https://github.com/AriTheElk/obsidian-image-picker/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) tag 🫶🏻 50 | 51 | I'm eternally grateful for any help I get :) 52 | 53 | # 👩‍⚖️ License 54 | 55 | Obsidian Image Picker is licensed under the MIT License. See [LICENSE](LICENSE.md) for more information. 56 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import process from 'process' 2 | import { promises as fs } from 'fs' 3 | import path from 'path' 4 | import { readFileSync } from 'fs' 5 | 6 | import esbuild from 'esbuild' 7 | import builtins from 'builtin-modules' 8 | import { copy } from 'esbuild-plugin-copy' 9 | import { sassPlugin } from 'esbuild-sass-plugin' 10 | import { config } from 'dotenv' 11 | 12 | config() // Load .env file 13 | 14 | // Load package.json to get the package name 15 | const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8')) 16 | const packageName = packageJson.name 17 | 18 | const banner = `/* 19 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 20 | if you want to view the source, please visit the github repository of this plugin 21 | */ 22 | ` 23 | 24 | const prod = process.argv[2] === 'production' 25 | const move = process.argv[3] === 'move' 26 | 27 | const context = await esbuild.context({ 28 | banner: { 29 | js: banner, 30 | }, 31 | entryPoints: ['./src/main.tsx', './src/styles.scss'], 32 | bundle: true, 33 | external: [ 34 | 'obsidian', 35 | 'electron', 36 | '@codemirror/autocomplete', 37 | '@codemirror/collab', 38 | '@codemirror/commands', 39 | '@codemirror/language', 40 | '@codemirror/lint', 41 | '@codemirror/search', 42 | '@codemirror/state', 43 | '@codemirror/view', 44 | '@lezer/common', 45 | '@lezer/highlight', 46 | '@lezer/lr', 47 | ...builtins, 48 | ], 49 | format: 'cjs', 50 | target: 'es2018', 51 | logLevel: 'info', 52 | sourcemap: prod ? false : 'inline', 53 | treeShaking: true, 54 | outdir: 'dist', 55 | minify: prod, 56 | plugins: [ 57 | sassPlugin({ 58 | verbose: true, 59 | includePaths: ['./src'], 60 | }), 61 | copy({ 62 | assets: [ 63 | { 64 | from: ['manifest.json'], 65 | to: ['./manifest.json'], 66 | }, 67 | { 68 | from: ['versions.json'], 69 | to: ['./versions.json'], 70 | }, 71 | ], 72 | }), 73 | ], 74 | }) 75 | 76 | if (prod) { 77 | await context.rebuild() 78 | if (process.env.OBSIDIAN_VAULT_PATH && move) { 79 | const destPath = path.join( 80 | process.env.OBSIDIAN_VAULT_PATH, 81 | `.obsidian/plugins/${packageName}-dev` 82 | ) 83 | console.log(`Copying to ${destPath}`) 84 | await fs.mkdir(destPath, { recursive: true }) 85 | await fs.cp('dist', destPath, { recursive: true }) 86 | } 87 | process.exit(0) 88 | } else { 89 | await context.watch() 90 | } 91 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "image-picker", 3 | "name": "Image Picker", 4 | "version": "1.1.1", 5 | "minAppVersion": "0.15.0", 6 | "description": "Adds a UI panel for quickly selecting images that are in your vault.", 7 | "author": "ari.the.elk", 8 | "authorUrl": "https://ari.the.elk.wtf", 9 | "fundingUrl": "https://ari.the.elk.wtf/Donate", 10 | "isDesktopOnly": false 11 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "image-picker", 3 | "version": "1.1.1", 4 | "description": "Easily find any image inside your vault", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "typecheck": "tsc --noEmit", 8 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 9 | "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", 10 | "ci": "npm run typecheck && npm run lint", 11 | "dev": "node esbuild.config.mjs", 12 | "prebuild": "rimraf dist", 13 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 14 | "build:vault": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production move", 15 | "version": "node version-bump.mjs && git add manifest.json versions.json" 16 | }, 17 | "engines": { 18 | "node": ">=20.0.0" 19 | }, 20 | "keywords": [], 21 | "author": "", 22 | "license": "MIT", 23 | "devDependencies": { 24 | "@types/lodash": "^4.17.13", 25 | "@types/node": "^22.10.2", 26 | "@types/react": "^19.0.1", 27 | "@types/react-dom": "^19.0.2", 28 | "@types/react-lazyload": "^3.2.3", 29 | "@types/react-virtualized": "^9.22.0", 30 | "@typescript-eslint/eslint-plugin": "8.18.0", 31 | "@typescript-eslint/parser": "8.18.0", 32 | "builtin-modules": "4.0.0", 33 | "dotenv": "^16.4.7", 34 | "esbuild": "0.24.0", 35 | "esbuild-plugin-copy": "^2.1.1", 36 | "esbuild-sass-plugin": "^3.3.1", 37 | "eslint-plugin-import": "^2.31.0", 38 | "eslint-plugin-react-hooks": "^5.1.0", 39 | "rimraf": "^6.0.1", 40 | "tslib": "2.8.1", 41 | "typescript": "5.7.2" 42 | }, 43 | "dependencies": { 44 | "@misskey-dev/browser-image-resizer": "^2024.1.0", 45 | "@react-hook/size": "^2.1.2", 46 | "browser-image-resizer": "^2.4.1", 47 | "dexie": "^4.0.10", 48 | "lodash": "^4.17.21", 49 | "obsidian": "latest", 50 | "react": "^18.3.1", 51 | "react-dom": "^18.3.1", 52 | "uuid": "^11.0.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AriTheElk/obsidian-image-picker/2b50827bdea1f7732960f4d8a7c907510b37d754/preview.gif -------------------------------------------------------------------------------- /src/Backgrounder/Backgrounder.ts: -------------------------------------------------------------------------------- 1 | import { ImagePicker } from '../ImagePicker' 2 | 3 | import { BackgrounderLane } from './BackgrounderLane' 4 | import { BackgrounderLaneProps } from './types' 5 | 6 | /** 7 | * General purpose background job runner :) 8 | * 9 | * This is used mostly to alleviate the main thread from 10 | * doing too much work at once. Things like indexing, 11 | * image processing, or other long-running tasks can be 12 | * run in the background. 13 | * 14 | * It's a FIFO queue, so jobs are run in the order they 15 | * are enqueued. 16 | */ 17 | export class Backgrounder { 18 | public lanes: Record = {} 19 | 20 | constructor(public plugin: ImagePicker) {} 21 | 22 | log = (...args: unknown[]) => { 23 | this.plugin.log('Backgrounder -> ', ...args) 24 | } 25 | 26 | createLane = (lane: Omit) => { 27 | this.lanes[lane.type] = new BackgrounderLane(this.plugin, lane) 28 | } 29 | 30 | deleteLane = (type: string) => { 31 | delete this.lanes[type] 32 | } 33 | 34 | stop = (type: string) => { 35 | if (this.lanes[type]) { 36 | this.lanes[type].clear() 37 | this.log('Stopped lane:', type) 38 | } else { 39 | this.log('Lane not found:', type) 40 | } 41 | } 42 | 43 | stopAll = () => { 44 | for (const lane of Object.values(this.lanes)) { 45 | lane.clear() 46 | } 47 | this.log('Stopped all lanes') 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Backgrounder/BackgrounderLane.ts: -------------------------------------------------------------------------------- 1 | import ImagePicker from 'src/main' 2 | import { v4 } from 'uuid' 3 | 4 | import { 5 | BackgrounderQueue, 6 | BackgrounderLaneProps, 7 | BackgrounderJob, 8 | } from './types' 9 | 10 | export class BackgrounderLane { 11 | private plugin: ImagePicker 12 | public type: string 13 | public running: boolean = false 14 | public unique: boolean 15 | public uniqueIgnore: 'first' | 'last' 16 | private queue: BackgrounderQueue 17 | private sleepTime: number = 0 18 | 19 | log = (...args: unknown[]) => { 20 | this.plugin.log(`Lane [${this.type}] -> `, ...args) 21 | } 22 | 23 | constructor(plugin: ImagePicker, lane: Omit) { 24 | this.plugin = plugin 25 | this.type = lane.type 26 | this.unique = lane.unique 27 | this.uniqueIgnore = lane.uniqueKeep 28 | this.sleepTime = lane.sleep 29 | this.queue = [] 30 | } 31 | 32 | /** 33 | * Sleeps between jobs 34 | * 35 | * @returns A promise that resolves after the time has passed 36 | */ 37 | private sleep = () => 38 | new Promise((resolve) => setTimeout(resolve, this.sleepTime)) 39 | 40 | /** 41 | * Enqueues a job to be run in the background 42 | * 43 | * @param job The job to enqueue 44 | */ 45 | enqueue = async (job: Omit) => { 46 | const id = v4() 47 | if (this.unique) { 48 | if ( 49 | this.uniqueIgnore === 'first' && 50 | this.queue.some((j) => j.type === job.type) 51 | ) { 52 | this.log('Unique job already in queue, ignoring:', job.type) 53 | return 54 | } 55 | if ( 56 | this.uniqueIgnore === 'last' && 57 | this.queue.some((j) => j.type === job.type) 58 | ) { 59 | this.log('Unique job already in queue, removing existing:', job.type) 60 | this.queue = this.queue.filter((j) => j.type !== job.type) 61 | } 62 | } 63 | 64 | this.queue.push({ 65 | ...job, 66 | id, 67 | }) 68 | this.log('Enqueued:', job.type) 69 | 70 | this.run(job.eager && this.queue.length === 1) 71 | } 72 | 73 | /** 74 | * Runs the next job in the queue and immediately 75 | * starts the next one. 76 | */ 77 | run = async (immediate: boolean = false) => { 78 | if (this.running || this.queue.length === 0) return 79 | this.running = true 80 | 81 | if (!immediate) { 82 | this.log('Waiting to run:', this.queue[0].type) 83 | await this.sleep() 84 | } else { 85 | this.log('Running immediately:', this.queue[0].type) 86 | } 87 | 88 | // Verify that the queue hasn't been cleared while sleeping 89 | if (!this.running) return 90 | 91 | const job = this.queue.shift() 92 | this.log('Running:', job?.type) 93 | if (job) { 94 | await job.action() 95 | } 96 | 97 | this.log('Finished:', job?.type) 98 | this.running = false 99 | 100 | // Subsequent jobs should *never* run immediately 101 | this.run() 102 | } 103 | 104 | /** 105 | * Clears the queue 106 | */ 107 | clear = () => { 108 | this.queue = [] 109 | this.running = false 110 | this.log('Cleared queue') 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Backgrounder/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Backgrounder' 2 | export * from './types' 3 | -------------------------------------------------------------------------------- /src/Backgrounder/types.ts: -------------------------------------------------------------------------------- 1 | export interface BackgrounderJob { 2 | /** 3 | * Internally used UUID for the job 4 | */ 5 | id: string 6 | /** 7 | * The type of job, in a small string. 8 | * 9 | * This is used as a "unique" identifier for the job. 10 | * If you run it in a lane with `unique: true`, only 11 | * one job of this type will be in the queue at a time. 12 | */ 13 | type: string 14 | /** 15 | * The action that will be run when the job is executed 16 | */ 17 | action: () => void | Promise 18 | /** 19 | * If true, the job will be run immediately if the lane is free 20 | */ 21 | eager?: boolean 22 | } 23 | 24 | export type BackgrounderQueue = BackgrounderJob[] 25 | 26 | export interface BackgrounderLaneProps { 27 | type: string 28 | queue: BackgrounderQueue 29 | /** 30 | * The time to wait between jobs in milliseconds 31 | */ 32 | sleep: number 33 | /** 34 | * If true, only one job of this type can be in the queue at a time 35 | */ 36 | unique: boolean 37 | /** 38 | * Determines which job to keep when enqueuing a unique job 39 | * 40 | * 'first' keeps the existing job and ignores the new one 41 | * 'last' keeps the new job and removes the existing one 42 | * 43 | * @default 'first' 44 | */ 45 | uniqueKeep: 'first' | 'last' 46 | } 47 | -------------------------------------------------------------------------------- /src/ImagePicker.ts: -------------------------------------------------------------------------------- 1 | import { App, Plugin, PluginManifest, TFile, WorkspaceLeaf } from 'obsidian' 2 | import { pick } from 'lodash' 3 | 4 | import { Indexer } from './Indexer' 5 | import { 6 | ImagePickerSettings, 7 | ImagePickerSettingTab, 8 | } from './ImagePickerSettings' 9 | import { ImagePickerView } from './ImagePickerView' 10 | import { 11 | DEFAULT_SETTINGS, 12 | VALID_IMAGE_EXTENSIONS, 13 | VIEW_TYPE_IMAGE_PICKER, 14 | } from './constants' 15 | import { Backgrounder } from './Backgrounder' 16 | 17 | export class ImagePicker extends Plugin { 18 | settings: ImagePickerSettings 19 | images: TFile[] = [] 20 | indexer: Indexer = new Indexer(this) 21 | _backgrounder: Backgrounder 22 | get backgrounder() { 23 | return this._backgrounder || (this._backgrounder = new Backgrounder(this)) 24 | } 25 | 26 | constructor(app: App, manifest: PluginManifest) { 27 | super(app, manifest) 28 | } 29 | 30 | log = (...args: unknown[]) => { 31 | if (this.settings?.debugMode) { 32 | console.log('ImagePicker -> ', ...args) 33 | } 34 | } 35 | 36 | async onload() { 37 | await this.loadSettings() 38 | 39 | this.addSettingTab(new ImagePickerSettingTab(this.app, this)) 40 | 41 | this.addRibbonIcon('image', 'Open image picker', async () => { 42 | this.activateView() 43 | }) 44 | 45 | this.registerView( 46 | VIEW_TYPE_IMAGE_PICKER, 47 | (leaf) => new ImagePickerView(this, leaf) 48 | ) 49 | 50 | this.app.vault.on('create', this.onFileCreate) 51 | this.app.vault.on('modify', this.onFileChange) 52 | this.app.vault.on('delete', this.onFileDelete) 53 | 54 | this.backgrounder.createLane({ 55 | type: 'saveSettings', 56 | sleep: 2000, 57 | unique: true, 58 | uniqueKeep: 'last', 59 | }) 60 | } 61 | 62 | onunload() { 63 | this.app.vault.off('create', this.onFileCreate) 64 | this.app.vault.off('modify', this.onFileChange) 65 | this.app.vault.off('delete', this.onFileDelete) 66 | this.backgrounder.stopAll() 67 | } 68 | /** 69 | * When a file is created, add it to the index and 70 | * immediately notify subscribers. 71 | * 72 | * @param file the new file 73 | */ 74 | onFileCreate = async (file: TFile) => { 75 | if (file instanceof TFile) { 76 | if ( 77 | file.path.startsWith(this.settings.imageFolder) && 78 | VALID_IMAGE_EXTENSIONS.includes(file.extension) 79 | ) { 80 | this.log('onFileCreate:', file.path) 81 | this.indexer.setIndex({ 82 | [file.path]: { 83 | ...pick(file, ['basename', 'extension', 'stat', 'path', 'name']), 84 | uri: this.app.vault.getResourcePath(file), 85 | }, 86 | }) 87 | this.indexer.notifySubscribers() 88 | } 89 | } 90 | } 91 | 92 | /** 93 | * When a file is deleted, remove it from the index and 94 | * immediately notify subscribers. 95 | * @param file the deleted file 96 | */ 97 | onFileDelete = async (file: TFile) => { 98 | if (file instanceof TFile) { 99 | if ( 100 | file.path.startsWith(this.settings.imageFolder) && 101 | VALID_IMAGE_EXTENSIONS.includes(file.extension) 102 | ) { 103 | this.log('onFileDelete:', file.path) 104 | this.indexer.removeIndex(file.path) 105 | this.indexer.notifySubscribers() 106 | } 107 | } 108 | } 109 | 110 | /** 111 | * When a file is modified, update the index and 112 | * immediately notify subscribers. 113 | * @param file the modified file 114 | */ 115 | onFileChange = async (file: TFile) => { 116 | if (file instanceof TFile) { 117 | if ( 118 | file.path.startsWith(this.settings.imageFolder) && 119 | VALID_IMAGE_EXTENSIONS.includes(file.extension) 120 | ) { 121 | this.indexer.setIndex({ 122 | [file.path]: { 123 | ...pick(file, ['basename', 'extension', 'stat', 'path', 'name']), 124 | uri: this.app.vault.getResourcePath(file), 125 | }, 126 | }) 127 | this.indexer.notifySubscribers() 128 | } 129 | } 130 | } 131 | 132 | async activateView() { 133 | const { workspace } = this.app 134 | 135 | let leaf: WorkspaceLeaf | null = null 136 | const leaves = workspace.getLeavesOfType(VIEW_TYPE_IMAGE_PICKER) 137 | 138 | if (leaves.length > 0) { 139 | leaf = leaves[0] 140 | } else { 141 | leaf = workspace.getRightLeaf(false) 142 | await leaf?.setViewState({ type: VIEW_TYPE_IMAGE_PICKER, active: true }) 143 | } 144 | if (leaf) { 145 | workspace.revealLeaf(leaf) 146 | } 147 | } 148 | 149 | loadSettings = async () => { 150 | this.log('Loading settings...') 151 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()) 152 | } 153 | 154 | saveSettings = async () => { 155 | this.log('Saving settings:', this.settings) 156 | await this.saveData(this.settings) 157 | } 158 | 159 | sleepySaveSettings = async () => { 160 | await this.backgrounder.lanes.saveSettings.enqueue({ 161 | type: 'sleepySaveSettings', 162 | action: this.saveSettings, 163 | }) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/ImagePickerSettings.tsx: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting } from 'obsidian' 2 | 3 | import { ImagePicker } from './ImagePicker' 4 | 5 | export interface ImagePickerSettings { 6 | imageFolder: string 7 | animateGifs: boolean 8 | debugMode: boolean 9 | zoom: number 10 | } 11 | 12 | export class ImagePickerSettingTab extends PluginSettingTab { 13 | plugin: ImagePicker 14 | 15 | constructor(app: App, plugin: ImagePicker) { 16 | super(app, plugin) 17 | this.plugin = plugin 18 | } 19 | 20 | display(): void { 21 | const { containerEl } = this 22 | containerEl.empty() 23 | 24 | // Input for selecting the image folder 25 | new Setting(containerEl) 26 | .setName('Image folder') 27 | .setDesc( 28 | 'Image picker will look for images in this folder and its subfolders, by default it will look in the root of the vault' 29 | ) 30 | .addText((text) => 31 | text 32 | .setPlaceholder('Image folder') 33 | .setValue(this.plugin.settings.imageFolder) 34 | .onChange(async (value) => { 35 | this.plugin.settings.imageFolder = value || '' 36 | await this.plugin.saveSettings() 37 | }) 38 | ) 39 | 40 | // Button for resetting the image index 41 | new Setting(containerEl) 42 | .setName('Reset image index') 43 | .setDesc( 44 | 'Clears the image index and rebuilds it from the image folder. Obsidian will reload immediately after. Please run this after changing the image folder.' 45 | ) 46 | .addButton((button) => 47 | button.setButtonText('Reset index').onClick(async () => { 48 | this.plugin.images = [] 49 | // delete the database and rebuild it 50 | await this.plugin.indexer.resetDB() 51 | // reload obsidian 52 | // @ts-ignore 53 | this.app.commands.executeCommandById('app:reload') 54 | }) 55 | ) 56 | 57 | // Toggle whether gifs are animated 58 | new Setting(containerEl) 59 | .setName('Animate GIFs') 60 | .setDesc('Warning: large GIFs can slow down or crash Obsidian') 61 | .addToggle((toggle) => 62 | toggle 63 | .setValue(this.plugin.settings.animateGifs) 64 | .onChange(async (value) => { 65 | this.plugin.settings.animateGifs = value 66 | await this.plugin.saveSettings() 67 | }) 68 | ) 69 | 70 | // Toggle whether to log debug messages 71 | new Setting(containerEl) 72 | .setName('Debug mode') 73 | .setDesc('Log debug messages to the console') 74 | .addToggle((toggle) => 75 | toggle 76 | .setValue(this.plugin.settings.debugMode) 77 | .onChange(async (value) => { 78 | this.plugin.settings.debugMode = value 79 | await this.plugin.saveSettings() 80 | }) 81 | ) 82 | 83 | containerEl.createEl('hr') 84 | 85 | const credits = containerEl.createEl('div') 86 | 87 | credits 88 | .createEl('span', { 89 | text: 'Built with 💚 by ', 90 | }) 91 | .createEl('a', { 92 | text: 'ari.the.elk', 93 | href: 'https://ari.the.elk.wtf', 94 | }) 95 | 96 | credits.createEl('br') 97 | 98 | credits.createEl('a', { 99 | text: '📖 documentation', 100 | href: 'https://ari.the.elk.wtf/obsidian/plugins/image-picker', 101 | }) 102 | 103 | credits.createEl('br') 104 | 105 | credits.createEl('a', { 106 | text: '💝 donate', 107 | href: 'https://ari.the.elk.wtf/donate', 108 | }) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/ImagePickerView.tsx: -------------------------------------------------------------------------------- 1 | import { ItemView, WorkspaceLeaf } from 'obsidian' 2 | import { Root, createRoot } from 'react-dom/client' 3 | 4 | import { ImagePickerView as ReactImagePickerView } from './client/ImagePickerView' 5 | import { 6 | ImagePickerContext, 7 | ImagePickerContextType, 8 | } from './client/ImagePickerContext' 9 | import { ImagePicker } from './ImagePicker' 10 | import { VIEW_TYPE_IMAGE_PICKER } from './constants' 11 | 12 | /** 13 | * The main view for the image picker. 14 | */ 15 | export class ImagePickerView extends ItemView { 16 | root: Root | null = null 17 | 18 | constructor(public plugin: ImagePicker, leaf: WorkspaceLeaf) { 19 | super(leaf) 20 | } 21 | 22 | getViewType() { 23 | return VIEW_TYPE_IMAGE_PICKER 24 | } 25 | 26 | getDisplayText() { 27 | return 'Image picker' 28 | } 29 | 30 | getIcon(): string { 31 | return 'image' 32 | } 33 | 34 | createRoot = () => { 35 | this.root = createRoot(this.containerEl.children[1]) 36 | } 37 | 38 | destroyRoot = () => { 39 | this.root = null 40 | this.containerEl.children[1].empty() 41 | } 42 | 43 | mountReact = (context: ImagePickerContextType) => { 44 | this.root?.render( 45 | 46 | 47 | 48 | ) 49 | } 50 | 51 | unmountReact = () => { 52 | this.root?.unmount() 53 | } 54 | 55 | async onOpen() { 56 | this.plugin.log('Opening root:', this.plugin.images.length) 57 | this.createRoot() 58 | this.mountReact({ 59 | app: this.app, 60 | plugin: this.plugin, 61 | files: Object.values(await this.plugin.indexer.getIndex()), 62 | }) 63 | 64 | this.plugin.indexer.subscribe(async (newIndex) => { 65 | this.plugin.log('Rerendering root:', Object.keys(newIndex).length) 66 | this.mountReact({ 67 | app: this.app, 68 | plugin: this.plugin, 69 | files: Object.values(newIndex), 70 | }) 71 | }) 72 | } 73 | 74 | async onClose() { 75 | this.plugin.log('Closing root') 76 | this.unmountReact() 77 | this.destroyRoot() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Indexer/Indexer.ts: -------------------------------------------------------------------------------- 1 | import { debounce, merge } from 'lodash' 2 | import { v4 } from 'uuid' 3 | 4 | import { 5 | fetchImageFile, 6 | getImageFileSize, 7 | imageToArrayBuffer, 8 | makeThumbnail, 9 | } from '../utils' 10 | import { ImagePicker } from '../ImagePicker' 11 | 12 | import { 13 | IndexerNode, 14 | Thumbnail, 15 | IndexerRoot, 16 | AbstractIndexerRoot, 17 | AbstractIndexerNode, 18 | } from './types' 19 | import { IndexerDB } from './IndexerDB' 20 | 21 | export class Indexer { 22 | private memory: IndexerRoot = {} 23 | private db: IndexerDB = new IndexerDB() 24 | 25 | constructor(public plugin: ImagePicker) { 26 | this.getIndex().then((root) => { 27 | this.log('Loaded index:', root) 28 | }) 29 | // this.flush() 30 | 31 | this.plugin.backgrounder.createLane({ 32 | type: 'saveIndex', 33 | sleep: 2000, 34 | unique: true, 35 | uniqueKeep: 'first', 36 | }) 37 | } 38 | 39 | flush = async () => { 40 | this.memory = {} 41 | await this.db.index.clear() 42 | } 43 | 44 | resetDB = async () => { 45 | await this.db.delete() 46 | this.db = new IndexerDB() 47 | } 48 | 49 | log = (...args: unknown[]) => { 50 | this.plugin.log('Indexer -> ', ...args) 51 | } 52 | 53 | hasThumbnail = async (node: IndexerNode): Promise => { 54 | return (await this.db.index.get(node.path))?.thumbnail !== undefined 55 | } 56 | 57 | getThumbnail = async (node: IndexerNode): Promise => { 58 | if (node.extension === 'gif' && this.plugin.settings?.animateGifs) { 59 | return { 60 | id: 'gif', 61 | data: node.uri, 62 | } 63 | } 64 | 65 | const cachedThumbnail = 66 | node.thumbnail && 67 | (await this.db.thumbnails.where('id').equals(node.thumbnail).first()) 68 | 69 | if (cachedThumbnail) { 70 | this.log('Using cached thumbnail:', node.name) 71 | return cachedThumbnail 72 | } 73 | 74 | this.log('Generating thumbnail:', node.path) 75 | 76 | const id = v4() 77 | 78 | const data = await makeThumbnail(await fetchImageFile(node.uri)) 79 | this.db.thumbnails.put({ 80 | id, 81 | data, 82 | }) 83 | this.memory[node.path] = { ...node, thumbnail: id } 84 | this.log('Generated thumbnail:', id) 85 | 86 | this.plugin.backgrounder.lanes.saveIndex.enqueue({ 87 | type: 'saveLatestIndex', 88 | action: this.saveIndex, 89 | }) 90 | 91 | return { id, data } 92 | } 93 | 94 | needsThumbnail = async (node: IndexerNode): Promise => { 95 | const image = await fetchImageFile(node.uri) 96 | const data = await imageToArrayBuffer(image) 97 | const size = getImageFileSize(data) 98 | 99 | this.log('Image size:', size) 100 | 101 | return size > 100 102 | } 103 | 104 | saveIndex = debounce(async () => { 105 | try { 106 | const index = await this.getIndex() 107 | this.memory = {} 108 | await this.db.index.bulkPut(Object.values(index)) 109 | this.notifySubscribers() 110 | } catch (e) { 111 | console.error('Failed to save index:', e) 112 | } 113 | }, 1000) 114 | 115 | setIndex = async (root: IndexerRoot) => { 116 | const nodes = Object.values(root) 117 | const acc: IndexerNode[] = [] 118 | 119 | for (const node of nodes) { 120 | acc.push(node) 121 | } 122 | 123 | this.memory = merge({}, this.memory, root) 124 | this.plugin.backgrounder.lanes.saveIndex.enqueue({ 125 | type: 'saveLatestIndex', 126 | action: this.saveIndex, 127 | }) 128 | } 129 | 130 | /** 131 | * Immediately remove an index from the database. 132 | */ 133 | removeIndex = async (path: string) => { 134 | this.log('Removing index:', path) 135 | const node = await this.db.index.get(path) 136 | delete this.memory[path] 137 | await this.db.index.delete(path) 138 | if (node?.thumbnail) { 139 | await this.db.thumbnails.delete(node.thumbnail) 140 | } 141 | this.notifySubscribers() 142 | this.plugin.backgrounder.lanes.saveIndex.enqueue({ 143 | type: 'saveLatestIndex', 144 | action: this.saveIndex, 145 | }) 146 | } 147 | 148 | getIndex = async (): Promise => { 149 | const indexNodes = await this.db.index.toArray() 150 | const memoryNodes = Object.values(this.memory) 151 | let root: IndexerRoot = {} 152 | const nodes = [...indexNodes, ...memoryNodes] 153 | for (const node of nodes) { 154 | root = merge(root, { [node.path]: node }) 155 | } 156 | 157 | return root 158 | } 159 | 160 | getAbstractIndex = async (): Promise => { 161 | const nodes = await this.db.index.toArray() 162 | const root: AbstractIndexerRoot = {} 163 | 164 | for (const node of nodes) { 165 | const memoryNode = this.memory[node.path] || {} 166 | const combined = { ...node, ...memoryNode } 167 | root[node.path] = { 168 | ...combined, 169 | thumbnail: await this.getThumbnail(combined), 170 | } 171 | } 172 | 173 | return root 174 | } 175 | 176 | getAbstractNode = async (node: IndexerNode): Promise => { 177 | const cachedNode = this.memory[node.path] || {} 178 | const combined = { ...node, ...cachedNode } 179 | 180 | return { 181 | ...combined, 182 | thumbnail: await this.getThumbnail(combined), 183 | } 184 | } 185 | 186 | private subscribers: ((index: IndexerRoot) => void)[] = [] 187 | 188 | subscribe(callback: (index: IndexerRoot) => void) { 189 | this.subscribers = [callback] 190 | return () => { 191 | this.subscribers = this.subscribers.filter((cb) => cb !== callback) 192 | } 193 | } 194 | 195 | notifySubscribers = debounce((index?: IndexerRoot) => { 196 | this.log('Notifying subscribers:', this.subscribers.length) 197 | this.subscribers.forEach(async (callback) => 198 | callback(index || (await this.getIndex())) 199 | ) 200 | }, 250) 201 | } 202 | -------------------------------------------------------------------------------- /src/Indexer/IndexerDB.ts: -------------------------------------------------------------------------------- 1 | import Dexie from 'dexie' 2 | 3 | import { IndexerNode, Thumbnail } from './types' 4 | 5 | export class IndexerDB extends Dexie { 6 | index: Dexie.Table 7 | thumbnails: Dexie.Table 8 | 9 | constructor() { 10 | super('ImagePicker') 11 | this.version(1).stores({ 12 | index: 'path', 13 | thumbnails: 'id', 14 | }) 15 | this.index = this.table('index') 16 | this.thumbnails = this.table('thumbnails') 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Indexer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Indexer' 2 | export * from './types' 3 | -------------------------------------------------------------------------------- /src/Indexer/types.ts: -------------------------------------------------------------------------------- 1 | import { type TFile } from 'obsidian' 2 | 3 | export interface IndexerRoot { 4 | [path: string]: IndexerNode 5 | } 6 | 7 | export interface IndexerNode 8 | extends Pick { 9 | uri: string 10 | thumbnail?: string 11 | } 12 | 13 | export interface AbstractIndexerRoot { 14 | [path: string]: AbstractIndexerNode 15 | } 16 | 17 | export interface AbstractIndexerNode extends Omit { 18 | thumbnail: Thumbnail 19 | } 20 | 21 | export interface Thumbnail { 22 | id: string 23 | data: string 24 | } 25 | -------------------------------------------------------------------------------- /src/client/ImagePickerContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react' 2 | import { App } from 'obsidian' 3 | 4 | import ImagePicker from '../main' 5 | import { IndexerNode } from '../Indexer' 6 | 7 | export interface ImagePickerContextType { 8 | app: App 9 | plugin: ImagePicker 10 | files: IndexerNode[] 11 | } 12 | 13 | export const ImagePickerContext = createContext( 14 | {} as ImagePickerContextType 15 | ) 16 | 17 | export const usePlugin = () => { 18 | const context = useContext(ImagePickerContext) 19 | if (!context) { 20 | throw new Error( 21 | 'usePlugin must be used within an ImagePickerContext.Provider' 22 | ) 23 | } 24 | return context.plugin 25 | } 26 | 27 | export const useApp = () => { 28 | const context = useContext(ImagePickerContext) 29 | if (!context) { 30 | throw new Error('useApp must be used within an ImagePickerContext.Provider') 31 | } 32 | return context.app 33 | } 34 | 35 | export const useFiles = () => { 36 | const context = useContext(ImagePickerContext) 37 | if (!context) { 38 | throw new Error( 39 | 'useFiles must be used within an ImagePickerContext.Provider' 40 | ) 41 | } 42 | return context.files 43 | } 44 | -------------------------------------------------------------------------------- /src/client/ImagePickerView/ImagePickerView.tsx: -------------------------------------------------------------------------------- 1 | import { debounce, isEqual, throttle, truncate } from 'lodash' 2 | import { useEffect, useState, useRef, useCallback, useMemo } from 'react' 3 | import { Notice, Platform, TFile } from 'obsidian' 4 | 5 | import { 6 | MOBILE_MAX_FILE_SIZE, 7 | DESKTOP_MAX_FILE_SIZE, 8 | ROW_HEIGHT, 9 | DEFAULT_SETTINGS, 10 | } from '../../constants' 11 | import { 12 | calculateGrid, 13 | copyToClipboard, 14 | getSizeInKb, 15 | nodeToEmbed, 16 | setGridHeight, 17 | tokenizeSearchQuery, 18 | } from '../../utils' 19 | import { AbstractIndexerNode, IndexerNode } from '../../Indexer' 20 | import { useApp, useFiles, usePlugin } from '../ImagePickerContext' 21 | import { Thumbnail } from '../Thumbnail' 22 | 23 | import { Pagination } from './Pagination' 24 | import { Search } from './Search' 25 | 26 | export const ImagePickerView = () => { 27 | const plugin = usePlugin() 28 | const app = useApp() 29 | const images = useFiles() 30 | const cachedImages = useRef([]) 31 | const [searchQuery, setSearchQuery] = useState< 32 | ReturnType 33 | >({ 34 | queryTokens: [], 35 | remainingQuery: '', 36 | }) 37 | const [currentPage, setCurrentPage] = useState(1) 38 | const [itemsPerPage, setItemsPerPage] = useState(0) 39 | 40 | const prevColumns = useRef(0) 41 | const [columns, setColumns] = useState(0) 42 | const gridRef = useRef(null) 43 | 44 | const [imageQueue, setImageQueue] = useState([]) 45 | 46 | const hydratedCSS = useRef(false) 47 | const [zoom, setZoom] = useState( 48 | plugin.settings.zoom || DEFAULT_SETTINGS.zoom 49 | ) 50 | const [rowHeight, setRowHeight] = useState(zoom * ROW_HEIGHT) 51 | 52 | useEffect(() => { 53 | if (!hydratedCSS.current) { 54 | setGridHeight(zoom) 55 | hydratedCSS.current = true 56 | } 57 | }, [zoom]) 58 | 59 | const updateZoomSetting = useMemo( 60 | () => 61 | debounce((zoom: number) => { 62 | plugin.settings.zoom = zoom 63 | plugin.sleepySaveSettings() 64 | }, 500), 65 | [plugin] 66 | ) 67 | 68 | const updateVisualZoom = useMemo( 69 | () => 70 | throttle((zoom: number) => { 71 | setGridHeight(zoom) 72 | setRowHeight(zoom * ROW_HEIGHT) 73 | }, 50), 74 | [] 75 | ) 76 | 77 | const onZoom = useCallback( 78 | (zoom: number) => { 79 | setZoom(zoom) 80 | updateZoomSetting(zoom) 81 | updateVisualZoom(zoom) 82 | }, 83 | [updateVisualZoom, updateZoomSetting] 84 | ) 85 | 86 | useEffect(() => { 87 | if (columns !== prevColumns.current) { 88 | prevColumns.current = columns 89 | } 90 | }, [columns]) 91 | 92 | const trashNode = useCallback( 93 | async (file: IndexerNode | AbstractIndexerNode) => { 94 | try { 95 | await app.vault.trash( 96 | app.vault.getAbstractFileByPath(file.path)!, 97 | false 98 | ) 99 | await plugin.indexer.removeIndex(file.path) 100 | } catch (e) { 101 | console.error('Failed to trash node:', e) 102 | } 103 | }, 104 | [app.vault, plugin.indexer] 105 | ) 106 | 107 | const filterImages = useMemo( 108 | () => 109 | debounce((query: string) => { 110 | setSearchQuery(tokenizeSearchQuery(query)) 111 | setCurrentPage(1) 112 | }, 500), 113 | [] 114 | ) 115 | 116 | const filteredImages = useMemo(() => { 117 | const { queryTokens, remainingQuery } = searchQuery 118 | return images 119 | .filter((file) => { 120 | const tfile = app.vault.getAbstractFileByPath(file.path) 121 | const resource = app.vault.getResourcePath(tfile as TFile).toLowerCase() 122 | 123 | if (!resource.includes(remainingQuery.toLowerCase())) return false 124 | 125 | for (const token of queryTokens) { 126 | const [key, value] = token.split(':') 127 | switch (key) { 128 | case 'ext': 129 | if (file.extension.toLowerCase() !== value.toLowerCase()) 130 | return false 131 | break 132 | default: 133 | console.warn(`Unknown query token: ${key}`) 134 | break 135 | } 136 | } 137 | 138 | if ( 139 | getSizeInKb(file.stat.size) > 140 | (Platform.isMobile ? MOBILE_MAX_FILE_SIZE : DESKTOP_MAX_FILE_SIZE) 141 | ) 142 | return false 143 | 144 | return true 145 | }) 146 | .sort((a, b) => b.stat.ctime - a.stat.ctime) 147 | }, [images, app.vault, searchQuery]) 148 | 149 | const updateCalculations = useCallback( 150 | (container: HTMLDivElement) => { 151 | const height = container.clientHeight 152 | const width = container.clientWidth 153 | const [col, row] = calculateGrid(gridRef, [width, height], rowHeight) 154 | setColumns(col) 155 | setItemsPerPage(col * row) 156 | }, 157 | [rowHeight] 158 | ) 159 | 160 | useEffect(() => { 161 | const resizeObserver = new ResizeObserver(() => { 162 | if (gridRef.current) { 163 | updateCalculations(gridRef.current) 164 | } 165 | }) 166 | 167 | if (gridRef.current) { 168 | resizeObserver.observe(gridRef.current) 169 | } 170 | 171 | return () => { 172 | if (gridRef.current) { 173 | resizeObserver.unobserve(gridRef.current) 174 | } 175 | } 176 | }, [updateCalculations]) 177 | 178 | useEffect(() => { 179 | if (gridRef.current) { 180 | updateCalculations(gridRef.current) 181 | } 182 | }, [updateCalculations, filteredImages]) 183 | 184 | const paginatedImages = useMemo((): IndexerNode[] => { 185 | const startIndex = (currentPage - 1) * itemsPerPage 186 | const endIndex = startIndex + itemsPerPage 187 | return filteredImages.slice(startIndex, endIndex) 188 | }, [filteredImages, currentPage, itemsPerPage]) 189 | 190 | const totalPages = Math.ceil(filteredImages.length / itemsPerPage) 191 | 192 | const handlePrevPage = () => { 193 | setCurrentPage((prev) => Math.max(prev - 1, 1)) 194 | } 195 | 196 | const handleNextPage = () => { 197 | setCurrentPage((prev) => Math.min(prev + 1, totalPages)) 198 | } 199 | 200 | /** 201 | * When the search query changes, reset the current page 202 | */ 203 | useEffect(() => { 204 | setCurrentPage(1) 205 | }, [searchQuery]) 206 | 207 | const enqueueImage = useCallback( 208 | (node: IndexerNode) => { 209 | if (imageQueue.includes(node)) { 210 | return 211 | } 212 | setImageQueue((prev) => [...prev, node]) 213 | }, 214 | [imageQueue] 215 | ) 216 | 217 | const dequeueImage = useCallback((node: IndexerNode) => { 218 | setImageQueue((prev) => prev.filter((n) => n.path !== node.path)) 219 | }, []) 220 | 221 | /** 222 | * When the root images change, reset the loaded images 223 | * This needs done because currently there is no 224 | * reconciliation between the old and new images and 225 | * Image Picker doesn't know there are unloaded images. 226 | */ 227 | useEffect(() => { 228 | if (!isEqual(paginatedImages, cachedImages.current)) { 229 | cachedImages.current = paginatedImages 230 | } 231 | }, [enqueueImage, paginatedImages]) 232 | 233 | return ( 234 |
235 | 236 |
240 | {filteredImages.length ? ( 241 |
242 | {filteredImages.length} images found.{' '} 243 | {totalPages > 1 ? `Page ${currentPage} of ${totalPages}` : ''} 244 |
245 | ) : ( 246 |

No images found

247 | )} 248 |
249 |
{ 251 | if (!ref) return 252 | gridRef.current = ref 253 | updateCalculations(ref) 254 | }} 255 | className="image-picker-scroll-view" 256 | > 257 |
261 | {paginatedImages.map((file, i) => ( 262 |
270 | 300 | 307 |
308 | ))} 309 | {[...Array(itemsPerPage - paginatedImages.length)].map((_, i) => ( 310 |
318 |
319 |
320 | ))} 321 |
322 |
323 | 331 |
332 | ) 333 | } 334 | -------------------------------------------------------------------------------- /src/client/ImagePickerView/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { MAX_THUMBNAIL_ZOOM, MIN_THUMBNAIL_ZOOM } from 'src/constants' 3 | 4 | interface PaginationProps { 5 | total: number 6 | current: number 7 | zoom: number 8 | onNext: () => void 9 | onPrev: () => void 10 | onZoom: (zoom: number) => void 11 | } 12 | 13 | export const Pagination: FC = ({ 14 | total, 15 | current, 16 | onNext, 17 | onPrev, 18 | zoom, 19 | onZoom, 20 | }) => { 21 | return ( 22 |
23 | 26 | onZoom(parseFloat(e.target.value))} 33 | /> 34 | 37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/client/ImagePickerView/Search.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react' 2 | 3 | interface SearchProps { 4 | onSearch: (query: string) => void 5 | } 6 | 7 | export const Search: FC = ({ onSearch }) => { 8 | const [searchInput, setSearchInput] = useState('') 9 | 10 | return ( 11 |
12 | { 18 | setSearchInput(e.target.value) 19 | onSearch(e.target.value) 20 | }} 21 | /> 22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/client/ImagePickerView/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ImagePickerView' 2 | -------------------------------------------------------------------------------- /src/client/Thumbnail.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useCallback, useEffect, useRef, useState } from 'react' 2 | 3 | import { AbstractIndexerNode, IndexerNode } from '../Indexer' 4 | 5 | import { usePlugin } from './ImagePickerContext' 6 | 7 | interface ThumbnailProps { 8 | /** 9 | * The node to render the thumbnail for. 10 | */ 11 | node: IndexerNode 12 | /** 13 | * Callback to for when the thumbnail mounts. 14 | * @returns {void} 15 | */ 16 | enqueueImage: (node: IndexerNode) => void 17 | /** 18 | * Callback to for when the thumbnail should be dequeued. 19 | */ 20 | dequeueImage: (node: IndexerNode) => void 21 | /** 22 | * Whether or not the thumbnail should load. 23 | */ 24 | shouldLoad?: boolean 25 | 26 | /** 27 | * Callback to for when the thumbnail loads. 28 | * 29 | * @param {AbstractIndexerNode} file - The file that was loaded. 30 | * @returns {void} 31 | */ 32 | onLoad?: (node: AbstractIndexerNode) => void 33 | } 34 | 35 | export const Thumbnail: FC = ({ 36 | node, 37 | enqueueImage, 38 | dequeueImage, 39 | shouldLoad = false, 40 | onLoad, 41 | }) => { 42 | const [abstract, setAbstract] = useState(null) 43 | const [isLoading, setIsLoading] = useState(false) 44 | const plugin = usePlugin() 45 | 46 | const hasEnqueued = useRef(false) 47 | 48 | useEffect(() => { 49 | if (!hasEnqueued.current && node) { 50 | enqueueImage(node) 51 | hasEnqueued.current = true 52 | } 53 | }, [dequeueImage, enqueueImage, node]) 54 | 55 | useEffect(() => { 56 | return () => { 57 | dequeueImage(node) 58 | } 59 | }, [dequeueImage, node]) 60 | 61 | const loadImage = useCallback( 62 | async (node: IndexerNode) => { 63 | try { 64 | if (isLoading) return 65 | const file = await plugin.indexer.getAbstractNode(node) 66 | const img = new Image() 67 | img.src = file.thumbnail.data 68 | 69 | const handleLoad = () => { 70 | img.removeEventListener('load', handleLoad) 71 | setIsLoading(false) 72 | onLoad?.(file) 73 | dequeueImage(node) 74 | setAbstract(file) 75 | } 76 | 77 | img.addEventListener('load', handleLoad) 78 | } catch (error) { 79 | dequeueImage(node) 80 | setIsLoading(false) 81 | console.error('Failed to load image:', error) 82 | } 83 | }, 84 | [dequeueImage, isLoading, onLoad, plugin.indexer] 85 | ) 86 | 87 | useEffect(() => { 88 | if (shouldLoad && !isLoading && !abstract) { 89 | setIsLoading(true) 90 | loadImage(node) 91 | } 92 | }, [abstract, isLoading, loadImage, node, shouldLoad]) 93 | 94 | return abstract ? ( 95 | {node.name} 101 | ) : ( 102 |
103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { ImagePickerSettings } from './ImagePickerSettings' 2 | 3 | export const VALID_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'] 4 | 5 | /** 6 | * The view type for the image picker 7 | */ 8 | export const VIEW_TYPE_IMAGE_PICKER = 'image-picker-view' 9 | 10 | /** 11 | * Backgrounder: Time to sleep between jobs 12 | */ 13 | export const TIME_BETWEEN_JOBS = 5000 14 | 15 | /** 16 | * Fixed height for each row in the image picker 17 | */ 18 | export const ROW_HEIGHT = 100 19 | 20 | /** 21 | * Maximum image size to render in the image picker 22 | * on mobile devices. 23 | * 24 | * Images larger than this will be ignored. This is to 25 | * prevent Obsidian from reloading when loading a 26 | * large library. 27 | */ 28 | export const MOBILE_MAX_FILE_SIZE = 5000 29 | /** 30 | * Maximum image size to render in the image picker 31 | * on desktop devices. 32 | * 33 | * Images larger than this will be ignored. 34 | */ 35 | export const DESKTOP_MAX_FILE_SIZE = 5000 36 | 37 | /** 38 | * Query tokens to search for in the image picker 39 | */ 40 | export const queryTokens = ['ext'] 41 | 42 | /** 43 | * Default plugin settings 44 | */ 45 | export const DEFAULT_SETTINGS: ImagePickerSettings = { 46 | imageFolder: '', 47 | animateGifs: false, 48 | debugMode: false, 49 | zoom: 1, 50 | } 51 | 52 | /** 53 | * The min/max thumbnail zoom for the image picker 54 | * 55 | * The zoom is applied to the baseline ROW_HEIGHT 56 | * to determine the thumbnail size. 57 | */ 58 | export const MIN_THUMBNAIL_ZOOM = 0.5 59 | export const MAX_THUMBNAIL_ZOOM = 2 60 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { ImagePicker } from './ImagePicker' 2 | 3 | export default ImagePicker 4 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | $header-height: 60px; 2 | $footer-height: 40px; 3 | 4 | .image-picker-responsive-container { 5 | width: 100%; 6 | height: 100%; 7 | display: flex; 8 | flex-direction: column; 9 | } 10 | 11 | .image-picker-controls { 12 | width: 100%; 13 | justify-content: space-between; 14 | align-items: center; 15 | flex: 0 0 auto; 16 | margin-bottom: 0.5rem; 17 | 18 | } 19 | 20 | .image-picker-search { 21 | width: 100%; 22 | padding: 8px; 23 | font-size: 14px; 24 | } 25 | 26 | .image-picker-scroll-view { 27 | width: 100%; 28 | flex: 1 1 auto; 29 | overflow-y: auto; 30 | } 31 | 32 | .image-picker-grid { 33 | position: relative; 34 | height: 100%; 35 | display: grid; 36 | grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); 37 | grid-gap: 10px; 38 | } 39 | 40 | .image-picker-item { 41 | position: relative; 42 | display: flex; 43 | justify-content: center; 44 | align-items: center; 45 | border: 1px solid var(--background-modifier-border); 46 | border-radius: 4px; 47 | overflow: hidden; 48 | height: var(--image-picker-grid-height); 49 | min-height: var(--image-picker-grid-height); 50 | 51 | img { 52 | max-width: 100%; 53 | max-height: 100%; 54 | object-fit: contain; 55 | } 56 | 57 | /** 58 | * Invisible select dropdown that appears 59 | when the user clicks an image picker item 60 | */ 61 | select { 62 | position: absolute; 63 | top: 0; 64 | left: 0; 65 | width: 100%; 66 | height: 100%; 67 | opacity: 0; 68 | cursor: pointer; 69 | } 70 | } 71 | 72 | .image-picker-footer { 73 | width: 100%; 74 | height: $footer-height; 75 | display: flex; 76 | justify-content: space-between; 77 | align-items: center; 78 | flex: 0 0 auto; 79 | margin-top: 1rem; 80 | 81 | > *:first-child { 82 | margin-left: 0; 83 | } 84 | 85 | > *:last-child { 86 | margin-right: 0; 87 | } 88 | 89 | button { 90 | margin: 0 5px; 91 | padding: 5px 10px; 92 | font-size: 12px; 93 | cursor: pointer; 94 | flex: 0 0 auto; 95 | } 96 | 97 | input[type='range'] { 98 | flex: 1 1 auto; 99 | margin: 0 5px; 100 | } 101 | } 102 | 103 | .image-picker-lightbox-backdrop { 104 | position: fixed; 105 | top: 0; 106 | left: 0; 107 | width: 100%; 108 | height: 100%; 109 | background-color: rgba(0, 0, 0, 0.5); 110 | display: flex; 111 | justify-content: center; 112 | align-items: center; 113 | z-index: 1000; 114 | } 115 | 116 | .image-picker-lightbox { 117 | position: relative; 118 | width: 80%; 119 | height: 80%; 120 | background-color: white; 121 | border-radius: 4px; 122 | display: flex; 123 | justify-content: center; 124 | align-items: center; 125 | flex: 0 0 auto; 126 | } 127 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { readAndCompressImage } from 'browser-image-resizer' 2 | 3 | import { AbstractIndexerNode, IndexerNode } from './Indexer' 4 | import { queryTokens, ROW_HEIGHT } from './constants' 5 | 6 | export const getSizeInKb = (size: number): number => { 7 | return Math.round(size / 1024) 8 | } 9 | 10 | export const fetchImageFile = async (url: string): Promise => { 11 | const res = await fetch(url) 12 | const blob = await res.blob() 13 | return new File([blob], 'image.jpg') 14 | } 15 | 16 | /** 17 | * Saves an image to Base64 format 18 | */ 19 | export const imageToHash = async (file: File): Promise => { 20 | return new Promise((resolve, reject) => { 21 | const reader = new FileReader() 22 | reader.onload = () => resolve(reader.result as string) 23 | reader.onerror = reject 24 | reader.readAsDataURL(file) 25 | }) 26 | } 27 | 28 | /** 29 | * Converts a Base64 image to an HTMLImageElement 30 | */ 31 | export const hashToImage = (hash: string): HTMLImageElement => { 32 | const img = new Image() 33 | img.src = hash 34 | return img 35 | } 36 | 37 | export const imageToArrayBuffer = (file: File): Promise => { 38 | return new Promise((resolve, reject) => { 39 | const reader = new FileReader() 40 | reader.onload = () => resolve(reader.result as ArrayBuffer) 41 | reader.onerror = reject 42 | reader.readAsArrayBuffer(file) 43 | }) 44 | } 45 | 46 | export const getImageFileSize = (data: ArrayBuffer): number => { 47 | return getSizeInKb(data.byteLength) 48 | } 49 | 50 | export const resizeImage = async ( 51 | file: File, 52 | maxHeight: number 53 | ): Promise => { 54 | const resized = await readAndCompressImage(file, { maxHeight, quality: 0.7 }) 55 | return new File([resized], file.name) 56 | } 57 | 58 | export const makeThumbnail = async (file: File): Promise => { 59 | const resized = await resizeImage(file, ROW_HEIGHT * 2) 60 | const hash = await imageToHash(resized) 61 | return hash 62 | } 63 | 64 | export const copyToClipboard = (text: string): void => { 65 | navigator.clipboard.writeText(text) 66 | } 67 | 68 | export const nodeToEmbed = ( 69 | node: IndexerNode | AbstractIndexerNode 70 | ): string => { 71 | return `![[${node.path}]]` 72 | } 73 | 74 | export const truncate = (text: string, length: number): string => { 75 | return text.length > length ? `${text.substring(0, length)}...` : text 76 | } 77 | 78 | export const setGridHeight = (zoom: number): void => { 79 | document.documentElement.style.setProperty( 80 | '--image-picker-grid-height', 81 | ROW_HEIGHT * zoom + 'px' 82 | ) 83 | } 84 | 85 | /** 86 | * Returns the number of columns and rows that can fit in the container 87 | * 88 | * The height is always fixed, so we first calculate the rnumber of 89 | * columns that can fit in the container, then calculate the number of 90 | * rows based on the container size and the asset height. 91 | */ 92 | export const calculateGrid = ( 93 | gridRef: React.RefObject, 94 | containerSize: [number, number], 95 | assetHeight: number 96 | ): [number, number] => { 97 | if (gridRef.current) { 98 | const [containerWidth, containerHeight] = containerSize 99 | const computedStyle = window.getComputedStyle(gridRef.current) 100 | const gap = parseInt(computedStyle.getPropertyValue('gap'), 10) || 0 101 | const totalGapsWidth = 102 | containerWidth < assetHeight * 2 + gap 103 | ? 0 104 | : gap * (Math.floor(containerWidth / assetHeight) - 1) 105 | const columns = Math.floor((containerWidth - totalGapsWidth) / assetHeight) 106 | const rows = Math.floor(containerHeight / (assetHeight + gap)) 107 | return [columns, rows] 108 | } 109 | return [0, 0] 110 | } 111 | 112 | /** 113 | * Searches through a plaintext search query and 114 | * returns all of the tokens contained in the query. 115 | * Also returns the remaining query after removing 116 | * all of the tokens. 117 | */ 118 | export const tokenizeSearchQuery = (query: string) => { 119 | const tokens = query 120 | .split(' ') 121 | .map((token) => token.trim()) 122 | .filter( 123 | (token) => 124 | token.includes(':') && queryTokens.includes(token.split(':')[0]) 125 | ) 126 | let remainingQuery = '' 127 | 128 | for (const token of query.split(' ')) { 129 | if (!tokens.includes(token)) { 130 | remainingQuery += token + ' ' 131 | } 132 | } 133 | 134 | return { 135 | queryTokens: tokens, 136 | remainingQuery: remainingQuery.trim(), 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "dist", 5 | "inlineSourceMap": true, 6 | "inlineSources": true, 7 | "module": "ESNext", 8 | "target": "ES6", 9 | "jsx": "react-jsx", 10 | "allowJs": true, 11 | "noImplicitAny": true, 12 | "moduleResolution": "node", 13 | "importHelpers": true, 14 | "isolatedModules": true, 15 | "strictNullChecks": true, 16 | "allowSyntheticDefaultImports": true, 17 | "lib": [ 18 | "DOM", 19 | "ES5", 20 | "ES6", 21 | "ES7" 22 | ] 23 | }, 24 | "include": [ 25 | "**/*.ts", 26 | "**/*.tsx", 27 | "src/main.tsx" 28 | ], 29 | "ignore": [ 30 | "node_modules", 31 | "dist" 32 | ] 33 | } -------------------------------------------------------------------------------- /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 | "1.0.1": "0.15.0", 4 | "1.1.1": "0.15.0" 5 | } --------------------------------------------------------------------------------