From 65b5bb983f0d944e3a22e5cc6f80cfbc857d642b Mon Sep 17 00:00:00 2001 From: aria Date: Mon, 16 Dec 2024 20:17:11 -0600 Subject: [PATCH] 1.1.0 (#10) * chore: move backgrounder to main plugin class * feat: add thumbnail zoom setting * feat: improve zoom * chore: refactor image loading queue * feat: make thumbnails self-loading with a global queue * fix: add missing function * fix: ts config includes * chore: add responsive wrapper * chore: improve styles, improve thumbnail queueing --- src/ImagePicker.ts | 7 +- src/ImagePickerSettings.tsx | 1 + src/backend/Backgrounder.ts | 8 + src/backend/Indexer.ts | 10 +- .../ImagePickerView/ImagePickerView.tsx | 239 ++++++++---------- src/client/ImagePickerView/Pagination.tsx | 18 +- src/client/ImagePickerView/Search.tsx | 4 +- src/client/Thumbnail.tsx | 104 ++++++++ src/constants.ts | 10 + src/styles.scss | 39 ++- src/utils.ts | 64 ++++- tsconfig.json | 1 + 12 files changed, 349 insertions(+), 156 deletions(-) create mode 100644 src/client/Thumbnail.tsx diff --git a/src/ImagePicker.ts b/src/ImagePicker.ts index 851ceba..9c6cfaf 100644 --- a/src/ImagePicker.ts +++ b/src/ImagePicker.ts @@ -12,11 +12,13 @@ import { VALID_IMAGE_EXTENSIONS, VIEW_TYPE_IMAGE_PICKER, } from './constants' +import { Backgrounder } from './backend/Backgrounder' export class ImagePicker extends Plugin { settings: ImagePickerSettings images: TFile[] = [] indexer: Indexer = new Indexer(this) + backgrounder: Backgrounder = new Backgrounder(this) log = (...args: any[]) => { if (this.settings?.debugMode) { @@ -47,6 +49,7 @@ export class ImagePicker extends Plugin { this.app.vault.off('create', this.onFileCreate) this.app.vault.off('modify', this.onFileChange) this.app.vault.off('delete', this.onFileDelete) + this.backgrounder.clear() } /** * When a file is created, add it to the index and @@ -129,12 +132,12 @@ export class ImagePicker extends Plugin { } } - async loadSettings() { + loadSettings = async () => { this.log('Loading settings...') this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()) } - async saveSettings() { + saveSettings = async () => { this.log('Saving settings:', this.settings) await this.saveData(this.settings) } diff --git a/src/ImagePickerSettings.tsx b/src/ImagePickerSettings.tsx index 262031b..c6a6482 100644 --- a/src/ImagePickerSettings.tsx +++ b/src/ImagePickerSettings.tsx @@ -6,6 +6,7 @@ export interface ImagePickerSettings { imageFolder: string animateGifs: boolean debugMode: boolean + zoom: number } export class ImagePickerSettingTab extends PluginSettingTab { diff --git a/src/backend/Backgrounder.ts b/src/backend/Backgrounder.ts index 90bcb14..638c7b8 100644 --- a/src/backend/Backgrounder.ts +++ b/src/backend/Backgrounder.ts @@ -95,4 +95,12 @@ export class Backgrounder { this.running = false this.run() } + + /** + * Clears the queue + */ + clear = () => { + this.queue = [] + this.log('Cleared queue') + } } diff --git a/src/backend/Indexer.ts b/src/backend/Indexer.ts index eca56c7..068e4e9 100644 --- a/src/backend/Indexer.ts +++ b/src/backend/Indexer.ts @@ -11,8 +11,6 @@ import { } from '../utils' import ImagePicker from '../main' -import { Backgrounder } from './Backgrounder' - export interface IndexerRoot { [path: string]: IndexerNode } @@ -54,10 +52,8 @@ class IndexerDB extends Dexie { export class Indexer { private memory: IndexerRoot = {} private db: IndexerDB = new IndexerDB() - private backgrounder: Backgrounder constructor(public plugin: ImagePicker) { - this.backgrounder = new Backgrounder(this.plugin) this.getIndex().then((root) => { this.log('Loaded index:', root) }) @@ -111,7 +107,7 @@ export class Indexer { this.memory[node.path] = { ...node, thumbnail: id } this.log('Generated thumbnail:', id) - this.backgrounder.enqueue({ + this.plugin.backgrounder.enqueue({ type: 'saveIndex', disableDoubleQueue: true, action: this.saveIndex, @@ -151,7 +147,7 @@ export class Indexer { } this.memory = merge({}, this.memory, root) - this.backgrounder.enqueue({ + this.plugin.backgrounder.enqueue({ type: 'saveIndex', disableDoubleQueue: true, action: this.saveIndex, @@ -170,7 +166,7 @@ export class Indexer { await this.db.thumbnails.delete(node.thumbnail) } this.notifySubscribers() - this.backgrounder.enqueue({ + this.plugin.backgrounder.enqueue({ type: 'saveIndex', disableDoubleQueue: true, action: this.saveIndex, diff --git a/src/client/ImagePickerView/ImagePickerView.tsx b/src/client/ImagePickerView/ImagePickerView.tsx index 580776e..0808b7c 100644 --- a/src/client/ImagePickerView/ImagePickerView.tsx +++ b/src/client/ImagePickerView/ImagePickerView.tsx @@ -1,55 +1,34 @@ -import { debounce, isEqual, truncate } from 'lodash' +import { debounce, isEqual, throttle, truncate } from 'lodash' import { useEffect, useState, useRef, useCallback, useMemo } from 'react' import { Notice, TFile } from 'obsidian' import { - queryTokens, MOBILE_MAX_FILE_SIZE, DESKTOP_MAX_FILE_SIZE, ROW_HEIGHT, + DEFAULT_SETTINGS, } from '../../constants' -import { copyToClipboard, getSizeInKb, nodeToEmbed } from '../../utils' +import { + calculateGrid, + copyToClipboard, + getSizeInKb, + nodeToEmbed, + setGridHeight, + tokenizeSearchQuery, +} from '../../utils' import { AbstractIndexerNode, IndexerNode } from '../../backend/Indexer' import { useApp, useFiles, usePlugin } from '../ImagePickerContext' +import { Thumbnail } from '../Thumbnail' import { Pagination } from './Pagination' import { Search } from './Search' -/** - * Searches through a plaintext search query and - * returns all of the tokens contained in the query. - * Also returns the remaining query after removing - * all of the tokens. - */ -const tokenizeSearchQuery = (query: string) => { - const tokens = query - .split(' ') - .map((token) => token.trim()) - .filter( - (token) => - token.includes(':') && queryTokens.includes(token.split(':')[0]) - ) - let remainingQuery = '' - - for (const token of query.split(' ')) { - if (!tokens.includes(token)) { - remainingQuery += token + ' ' - } - } - - return { - queryTokens: tokens, - remainingQuery: remainingQuery.trim(), - } -} - export const ImagePickerView = () => { const IS_MOBILE = useRef(document.querySelector('.is-mobile') !== null) const plugin = usePlugin() const app = useApp() const images = useFiles() const cachedImages = useRef([]) - const [searchInput, setSearchInput] = useState('') const [searchQuery, setSearchQuery] = useState< ReturnType >({ @@ -63,12 +42,53 @@ export const ImagePickerView = () => { const [columns, setColumns] = useState(0) const gridRef = useRef(null) - const [loadedImages, setLoadedImages] = useState>({}) - const [nextImageIndex, setNextImageIndex] = useState(0) + const [imageQueue, setImageQueue] = useState([]) + + const hydratedCSS = useRef(false) + const [zoom, setZoom] = useState( + plugin.settings.zoom || DEFAULT_SETTINGS.zoom + ) + const [rowHeight, setRowHeight] = useState(zoom * ROW_HEIGHT) + + useEffect(() => { + if (!hydratedCSS.current) { + setGridHeight(zoom) + hydratedCSS.current = true + } + }, [zoom]) + + const updateZoomSetting = useMemo( + () => + debounce((zoom: number) => { + plugin.settings.zoom = zoom + plugin.backgrounder.enqueue({ + type: 'saveSettings', + disableDoubleQueue: true, + action: plugin.saveSettings, + }) + }, 500), + [plugin.backgrounder, plugin.saveSettings, plugin.settings] + ) + + const updateVisualZoom = useCallback( + throttle((zoom: number) => { + setGridHeight(zoom) + setRowHeight(zoom * ROW_HEIGHT) + }, 50), + [] + ) + + const onZoom = useCallback( + (zoom: number) => { + setZoom(zoom) + updateZoomSetting(zoom) + updateVisualZoom(zoom) + }, + [updateVisualZoom, updateZoomSetting] + ) useEffect(() => { if (columns !== prevColumns.current) { - setLoadedImages({}) prevColumns.current = columns } }, [columns]) @@ -93,16 +113,10 @@ export const ImagePickerView = () => { debounce((query: string) => { setSearchQuery(tokenizeSearchQuery(query)) setCurrentPage(1) - setNextImageIndex(0) - setLoadedImages({}) }, 500), [] ) - const handleImageClick = useCallback((filePath: string) => { - console.log(`Image clicked: ${filePath}`) - }, []) - const filteredImages = useMemo(() => { const { queryTokens, remainingQuery } = searchQuery return images @@ -120,7 +134,7 @@ export const ImagePickerView = () => { return false break default: - // throw new Error(`Unknown query token: ${key}`) + console.warn(`Unknown query token: ${key}`) break } } @@ -136,37 +150,15 @@ export const ImagePickerView = () => { .sort((a, b) => b.stat.ctime - a.stat.ctime) }, [images, app.vault, searchQuery]) - const calculateGrid = useCallback( - (containerSize: number, assetSize: number) => { - if (gridRef.current) { - const computedStyle = window.getComputedStyle(gridRef.current) - const gap = parseInt(computedStyle.getPropertyValue('gap'), 10) || 0 - const totalGapsWidth = - containerSize < assetSize * 2 + gap - ? 0 - : gap * (Math.floor(containerSize / assetSize) - 1) - const newColumns = Math.floor( - (containerSize - totalGapsWidth) / assetSize - ) - return newColumns - } - return 0 - }, - [] - ) - const updateCalculations = useCallback( (container: HTMLDivElement) => { const height = container.clientHeight const width = container.clientWidth - - const newRows = Math.floor(height / ROW_HEIGHT) - const newColumns = calculateGrid(width, 100) - - setColumns(newColumns) - setItemsPerPage(newRows * newColumns) + const [col, row] = calculateGrid(gridRef, [width, height], rowHeight) + setColumns(col) + setItemsPerPage(col * row) }, - [calculateGrid] + [rowHeight] ) useEffect(() => { @@ -202,14 +194,10 @@ export const ImagePickerView = () => { const totalPages = Math.ceil(filteredImages.length / itemsPerPage) const handlePrevPage = () => { - setNextImageIndex(0) - setLoadedImages({}) setCurrentPage((prev) => Math.max(prev - 1, 1)) } const handleNextPage = () => { - setNextImageIndex(0) - setLoadedImages({}) setCurrentPage((prev) => Math.min(prev + 1, totalPages)) } @@ -220,48 +208,19 @@ export const ImagePickerView = () => { setCurrentPage(1) }, [searchQuery]) - /** - * Recursively load the next image in the list - * until all images are loaded - */ - const loadNextImage = useCallback( - async (imageIndex: number) => { - try { - if (imageIndex < paginatedImages.length) { - const file = await plugin.indexer.getAbstractNode( - paginatedImages[imageIndex] - ) - const img = new Image() - img.src = file.thumbnail.data - - const onLoad = () => { - setLoadedImages((prev) => ({ - ...prev, - [file.path]: file.thumbnail.data, - })) - loadNextImage(imageIndex + 1) - } - - img.addEventListener('load', onLoad) - } - } catch (_) { - console.warn('FAILED:', paginatedImages[imageIndex]) - plugin.indexer.removeIndex(paginatedImages[imageIndex].path) - setNextImageIndex((prev) => prev + 1) - loadNextImage(imageIndex + 1) + const enqueueImage = useCallback( + (node: IndexerNode) => { + if (imageQueue.includes(node)) { + return } + setImageQueue((prev) => [...prev, node]) }, - [paginatedImages, plugin.indexer] + [imageQueue] ) - /** - * Load the first image when the component mounts - */ - useEffect(() => { - if (Object.keys(loadedImages).length === 0) { - loadNextImage(nextImageIndex) - } - }, [loadNextImage, loadedImages, nextImageIndex]) + const dequeueImage = useCallback((node: IndexerNode) => { + setImageQueue((prev) => prev.filter((n) => n.path !== node.path)) + }, []) /** * When the root images change, reset the loaded images @@ -270,18 +229,27 @@ export const ImagePickerView = () => { * Image Picker doesn't know there are unloaded images. */ useEffect(() => { - if (!isEqual(images, cachedImages.current)) { - console.log('Images changed:', images.length) - setLoadedImages({}) - setNextImageIndex(0) - setCurrentPage(1) - cachedImages.current = images + if (!isEqual(paginatedImages, cachedImages.current)) { + cachedImages.current = paginatedImages } - }, [images]) + }, [enqueueImage, paginatedImages]) return ( - <> +
+
+ {filteredImages.length ? ( +
+ {filteredImages.length} images found.{' '} + {totalPages > 1 ? `Page ${currentPage} of ${totalPages}` : ''} +
+ ) : ( +

No images found

+ )} +
{ if (!ref) return @@ -298,7 +266,6 @@ export const ImagePickerView = () => {
handleImageClick(file.path)} style={{ gridRow: Math.floor(i / columns) + 1, gridColumn: (i % columns) + 1, @@ -334,17 +301,25 @@ export const ImagePickerView = () => { - {Object.keys(loadedImages).includes(file.path) ? ( - {file.name} - ) : ( - // TODO: add a self-queueing system for images in this state -
- )} + +
+ ))} + {[...Array(itemsPerPage - paginatedImages.length)].map((_, i) => ( +
+
))}
@@ -354,7 +329,9 @@ export const ImagePickerView = () => { current={currentPage} onNext={handleNextPage} onPrev={handlePrevPage} + zoom={zoom} + onZoom={onZoom} /> - +
) } diff --git a/src/client/ImagePickerView/Pagination.tsx b/src/client/ImagePickerView/Pagination.tsx index 279447b..2498649 100644 --- a/src/client/ImagePickerView/Pagination.tsx +++ b/src/client/ImagePickerView/Pagination.tsx @@ -1,10 +1,13 @@ import React, { FC } from 'react' +import { MAX_THUMBNAIL_ZOOM, MIN_THUMBNAIL_ZOOM } from 'src/constants' interface PaginationProps { total: number current: number + zoom: number onNext: () => void onPrev: () => void + onZoom: (zoom: number) => void } export const Pagination: FC = ({ @@ -12,15 +15,22 @@ export const Pagination: FC = ({ current, onNext, onPrev, + zoom, + onZoom, }) => { return ( -
+
- - Page {current} of {total || 1} - + onZoom(parseFloat(e.target.value))} + /> diff --git a/src/client/ImagePickerView/Search.tsx b/src/client/ImagePickerView/Search.tsx index 2fc789a..7efbfdb 100644 --- a/src/client/ImagePickerView/Search.tsx +++ b/src/client/ImagePickerView/Search.tsx @@ -8,9 +8,9 @@ export const Search: FC = ({ onSearch }) => { const [searchInput, setSearchInput] = useState('') return ( -
+
void + /** + * Callback to for when the thumbnail should be dequeued. + */ + dequeueImage: (node: IndexerNode) => void + /** + * Whether or not the thumbnail should load. + */ + shouldLoad?: boolean + + /** + * Callback to for when the thumbnail loads. + * + * @param {AbstractIndexerNode} file - The file that was loaded. + * @returns {void} + */ + onLoad?: (node: AbstractIndexerNode) => void +} + +export const Thumbnail: FC = ({ + node, + enqueueImage, + dequeueImage, + shouldLoad = false, + onLoad, +}) => { + const [abstract, setAbstract] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const plugin = usePlugin() + + const hasEnqueued = useRef(false) + + useEffect(() => { + if (!hasEnqueued.current && node) { + enqueueImage(node) + hasEnqueued.current = true + } + }, [dequeueImage, enqueueImage, node]) + + useEffect(() => { + return () => { + dequeueImage(node) + } + }, [dequeueImage, node]) + + const loadImage = useCallback( + async (node: IndexerNode) => { + try { + if (isLoading) return + const file = await plugin.indexer.getAbstractNode(node) + const img = new Image() + img.src = file.thumbnail.data + + const handleLoad = () => { + img.removeEventListener('load', handleLoad) + setIsLoading(false) + onLoad?.(file) + dequeueImage(node) + setAbstract(file) + } + + img.addEventListener('load', handleLoad) + } catch (error) { + dequeueImage(node) + setIsLoading(false) + console.error('Failed to load image:', error) + } + }, + [dequeueImage, isLoading, onLoad, plugin.indexer] + ) + + useEffect(() => { + if (shouldLoad && !isLoading && !abstract) { + setIsLoading(true) + loadImage(node) + } + }, [abstract, isLoading, loadImage, node, shouldLoad]) + + return abstract ? ( + {node.name} + ) : ( +
+ ) +} diff --git a/src/constants.ts b/src/constants.ts index 30eaa1b..c8b7198 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -46,4 +46,14 @@ export const DEFAULT_SETTINGS: ImagePickerSettings = { imageFolder: '', animateGifs: false, debugMode: false, + zoom: 1, } + +/** + * The min/max thumbnail zoom for the image picker + * + * The zoom is applied to the baseline ROW_HEIGHT + * to determine the thumbnail size. + */ +export const MIN_THUMBNAIL_ZOOM = 0.5 +export const MAX_THUMBNAIL_ZOOM = 2 diff --git a/src/styles.scss b/src/styles.scss index 5ec206d..dab0657 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1,34 +1,40 @@ $header-height: 60px; $footer-height: 40px; -.image-picker-controls { +.image-picker-responsive-container { width: 100%; - height: $header-height; + height: 100%; display: flex; + flex-direction: column; +} + +.image-picker-controls { + width: 100%; justify-content: space-between; align-items: center; flex: 0 0 auto; + margin-bottom: 0.5rem; + } .image-picker-search { width: 100%; padding: 8px; - margin-bottom: 12px; font-size: 14px; } .image-picker-scroll-view { width: 100%; - height: calc(100% - #{$header-height} - #{$footer-height}); + flex: 1 1 auto; overflow-y: auto; } .image-picker-grid { position: relative; + height: 100%; display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); grid-gap: 10px; - padding: 0 10px; } .image-picker-item { @@ -39,7 +45,8 @@ $footer-height: 40px; border: 1px solid var(--background-modifier-border); border-radius: 4px; overflow: hidden; - height: 100px; + height: var(--image-picker-grid-height); + min-height: var(--image-picker-grid-height); img { max-width: 100%; @@ -62,20 +69,34 @@ $footer-height: 40px; } } -.image-picker-pagination { +.image-picker-footer { width: 100%; height: $footer-height; display: flex; justify-content: space-between; - padding: 0 0.5rem; align-items: center; flex: 0 0 auto; + margin-top: 1rem; + + > *:first-child { + margin-left: 0; + } + + > *:last-child { + margin-right: 0; + } button { margin: 0 5px; padding: 5px 10px; - font-size: 14px; + font-size: 12px; cursor: pointer; + flex: 0 0 auto; + } + + input[type='range'] { + flex: 1 1 auto; + margin: 0 5px; } } diff --git a/src/utils.ts b/src/utils.ts index 1ae95f1..99f5646 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,7 @@ import { readAndCompressImage } from 'browser-image-resizer' import { AbstractIndexerNode, IndexerNode } from './backend/Indexer' -import { ROW_HEIGHT } from './constants' +import { queryTokens, ROW_HEIGHT } from './constants' export const getSizeInKb = (size: number): number => { return Math.round(size / 1024) @@ -74,3 +74,65 @@ export const nodeToEmbed = ( export const truncate = (text: string, length: number): string => { return text.length > length ? `${text.substring(0, length)}...` : text } + +export const setGridHeight = (zoom: number): void => { + document.documentElement.style.setProperty( + '--image-picker-grid-height', + ROW_HEIGHT * zoom + 'px' + ) +} + +/** + * Returns the number of columns and rows that can fit in the container + * + * The height is always fixed, so we first calculate the rnumber of + * columns that can fit in the container, then calculate the number of + * rows based on the container size and the asset height. + */ +export const calculateGrid = ( + gridRef: React.RefObject, + containerSize: [number, number], + assetHeight: number +): [number, number] => { + if (gridRef.current) { + const [containerWidth, containerHeight] = containerSize + const computedStyle = window.getComputedStyle(gridRef.current) + const gap = parseInt(computedStyle.getPropertyValue('gap'), 10) || 0 + const totalGapsWidth = + containerWidth < assetHeight * 2 + gap + ? 0 + : gap * (Math.floor(containerWidth / assetHeight) - 1) + const columns = Math.floor((containerWidth - totalGapsWidth) / assetHeight) + const rows = Math.floor(containerHeight / (assetHeight + gap)) + return [columns, rows] + } + return [0, 0] +} + +/** + * Searches through a plaintext search query and + * returns all of the tokens contained in the query. + * Also returns the remaining query after removing + * all of the tokens. + */ +export const tokenizeSearchQuery = (query: string) => { + const tokens = query + .split(' ') + .map((token) => token.trim()) + .filter( + (token) => + token.includes(':') && queryTokens.includes(token.split(':')[0]) + ) + let remainingQuery = '' + + for (const token of query.split(' ')) { + if (!tokens.includes(token)) { + remainingQuery += token + ' ' + } + } + + return { + queryTokens: tokens, + remainingQuery: remainingQuery.trim(), + } +} diff --git a/tsconfig.json b/tsconfig.json index 0046e36..60a30b8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ }, "include": [ "**/*.ts", + "**/*.tsx", "src/main.tsx" ], "ignore": [