diff --git a/resources/style/content.scss b/resources/style/content.scss index 50d9326c..0bd4ee8d 100644 --- a/resources/style/content.scss +++ b/resources/style/content.scss @@ -468,7 +468,7 @@ } // Note: this feature has an ugly exception, when filename is also shown. See the #gallery-item[data-show-overlay] rule below - &:hover .thumbnail-tags { + .thumbnail-tags:hover { max-height: 100%; overflow-y: overlay; // overlay scrollbar so it doesn't push tags away @@ -490,7 +490,7 @@ bottom: 0; overflow: hidden; transition: 0.25s $pt-transition-cubic2; - max-height: 2.8em; + max-height: 2.5em; max-width: 100%; // Allow dragging of images to elsewhere here too, just like the .thumbnail itself (drag-export) @@ -535,12 +535,16 @@ &:hover { .thumbnail-overlay { background-color: rgba(0, 0, 0, .75); + // Allow text selection around the name for better ux + user-select: text; &:hover { // Expand filename on hover if it was truncated > :first-child { overflow: inherit; text-overflow: inherit; + // Allow text selection + user-select: text; } } } @@ -558,7 +562,7 @@ // move the tags up by the height of the filename element (23.59px) #gallery-content[data-show-overlay='true'] .masonry { [data-masonrycell] { - &:hover .thumbnail-tags { + .thumbnail-tags:hover { max-height: calc(100% - 1.5em - 0.25rem); } } diff --git a/resources/style/controls/combobox.scss b/resources/style/controls/combobox.scss index a7d247c9..42d68eb1 100644 --- a/resources/style/controls/combobox.scss +++ b/resources/style/controls/combobox.scss @@ -89,17 +89,23 @@ } &[aria-selected='false']::before { - background-color: var(--text-color-muted); mask-image: url(~resources/icons/select-all.svg); -webkit-mask-image: url(~resources/icons/select-all.svg); } &[aria-selected='true']::before { - background-color: var(--text-color); mask-image: url(~resources/icons/select-all-checked.svg); -webkit-mask-image: url(~resources/icons/select-all-checked.svg); } + &[data-highlight-check="false"]::before { + background-color: var(--text-color-muted); + } + + &[data-highlight-check="true"]::before { + background-color: var(--text-color); + } + &:not([aria-selected])::before { display: none; } diff --git a/resources/style/tag-editor.scss b/resources/style/tag-editor.scss index f507566c..3e0ca52c 100644 --- a/resources/style/tag-editor.scss +++ b/resources/style/tag-editor.scss @@ -70,12 +70,3 @@ white-space: nowrap; text-overflow: ellipsis; } - -[role='row'].tag-option { - &:not([aria-selected])::before { - display: initial; - background-color: var(--text-color-muted); - mask-image: url(~resources/icons/select-all-checked.svg); - -webkit-mask-image: url(~resources/icons/select-all-checked.svg); - } -} diff --git a/src/frontend/components/FileTagsEditor.tsx b/src/frontend/components/FileTagsEditor.tsx index 3b60084e..3966fa8e 100644 --- a/src/frontend/components/FileTagsEditor.tsx +++ b/src/frontend/components/FileTagsEditor.tsx @@ -1,6 +1,14 @@ import { action, computed, IComputedValue, runInAction } from 'mobx'; import { observer } from 'mobx-react-lite'; -import React, { ForwardedRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { + ForwardedRef, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { debounce } from 'common/timeout'; import { Tag } from 'widgets'; @@ -17,6 +25,7 @@ import { createGetTabMatchTagCallback, createTagRowRenderer, GetTabMatchTag, + isTagSelected, useTabTagAutocomplete, } from './TagSelector'; import { useStore } from '../contexts/StoreContext'; @@ -50,14 +59,15 @@ export const FileTagsEditor = observer(() => { }, [debounceSetDebQuery, inputText]); const counter = useComputed(() => { - const fileSelection = uiStore.fileSelection; + const fileSelection = Array.from(uiStore.fileSelection); + const isTooMany = fileSelection.length > 1000; // Count how often tags are used // Aded last bool value indicating if is an explicit tag -> should show delete button; const counter = new Map(); for (const file of fileSelection) { const explicitTags = file.tags; - const inheritedTags = file.inheritedTags; - for (let j = 0; j < inheritedTags.length; j++) { - const tag = inheritedTags[j]; + // Compute inherited tags only when the selection is not too large to avoid UI blocking + const inheritedTags = isTooMany ? [] : file.inheritedTags; + for (const tag of isTooMany ? explicitTags : inheritedTags) { const counterEntry = counter.get(tag); if (counterEntry) { counterEntry[0]++; @@ -347,14 +357,14 @@ const MatchingTagsList = observer( [resetTextBox], ); - const isSelected = useCallback( + const isSelected: isTagSelected = useCallback( // If all selected files have the tag mark it as selected, // else if partially in selected files return undefined, else mark it as not selected. (tag: ClientTag) => { const tagRecord = counter.get().get(tag); const isExplicit = tagRecord?.[1] ?? false; const isPartial = tagRecord?.[0] !== uiStore.fileSelection.size; - return isExplicit ? (isPartial ? undefined : true) : false; + return [tagRecord !== undefined && !isPartial, isExplicit]; }, [counter, uiStore], ); @@ -469,7 +479,6 @@ interface TagSummaryProps { const TagSummary = observer(({ counter, removeTag, onContextMenu }: TagSummaryProps) => { const { uiStore } = useStore(); - const sortedTags: ClientTag[] = Array.from(counter.get().entries()) // Sort based on count .sort((a, b) => b[1][0] - a[1][0]) @@ -477,22 +486,89 @@ const TagSummary = observer(({ counter, removeTag, onContextMenu }: TagSummaryPr return (
e.preventDefault()}> - {sortedTags.map((t) => ( + 1 ? 5 : 100} + /> + {sortedTags.length === 0 && No tags added yet // eslint-disable-line prettier/prettier + } +
+ ); +}); + +interface IncrementalTagItemsProps { + tags: ClientTag[]; + counter?: IComputedValue>; + removeTag?: (tag: ClientTag) => void; + onContextMenu?: (e: React.MouseEvent, tag: ClientTag) => void; + chunkSize?: number; +} + +export const IncrementalTagItems = observer((props: IncrementalTagItemsProps) => { + const { uiStore } = useStore(); + const isMultiSelection = uiStore.fileSelection.size > 1; + const { tags, counter, removeTag, onContextMenu, chunkSize = 5 } = props; + + const [visibleTags, setVisibleTags] = useState([]); + + useLayoutEffect(() => { + let index = 0; + let cancel = false; + setVisibleTags([]); + + const step = () => { + if (cancel) { + return; + } + const start = index; + const end = Math.min(start + chunkSize, tags.length); + if (end > start) { + setVisibleTags((prev) => [...prev, ...tags.slice(start, end)]); + index = end; + } + if (index < tags.length) { + requestAnimationFrame(step); + } + }; + + step(); + + return () => { + cancel = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tags]); + + const RenderTag = useMemo( + () => + observer(({ tag }: { tag: ClientTag }) => ( 1 ? ` (${counter.get().get(t)?.[0]})` : '' + text={`${tag.name}${ + counter && isMultiSelection ? ` (${counter.get().get(tag)?.[0]})` : '' }`} - color={t.viewColor} - isHeader={t.isHeader} - //Only show remove button in those tags that are actually assigned to the file(s) and not only inherited - onRemove={counter.get().get(t)?.[1] ? () => removeTag(t) : undefined} - onContextMenu={onContextMenu !== undefined ? (e) => onContextMenu(e, t) : undefined} + color={tag.viewColor} + isHeader={tag.isHeader} + tooltip={tag.path + .map((v) => (v.startsWith('#') ? ' ' + v.slice(1) + ' ' : v)) + .join(' › ')} + onRemove={ + counter && removeTag && counter.get().get(tag)?.[1] ? () => removeTag(tag) : undefined + } + onContextMenu={onContextMenu ? (e) => onContextMenu(e, tag) : undefined} /> + )), + [counter, isMultiSelection, onContextMenu, removeTag], + ); + + return ( + <> + {visibleTags.map((t) => ( + ))} - {sortedTags.length === 0 && No tags added yet // eslint-disable-line prettier/prettier - } - + ); }); diff --git a/src/frontend/components/TagSelector.tsx b/src/frontend/components/TagSelector.tsx index 893e0f9a..46539f47 100644 --- a/src/frontend/components/TagSelector.tsx +++ b/src/frontend/components/TagSelector.tsx @@ -431,8 +431,11 @@ const SuggestedTagsList = observer( } }, [getTabMatchTagRef, suggestions]); - const isSelected = useCallback( - (tag: ClientTag) => selectionMap.get(tag) ?? false, + const isSelected: isTagSelected = useCallback( + (tag: ClientTag) => { + const value = selectionMap.get(tag); + return [value !== undefined, value ?? false]; + }, [selectionMap], ); const TagRow = useMemo( @@ -474,9 +477,11 @@ const SuggestedTagsList = observer( }), ); +export type isTagSelected = (tag: ClientTag) => [assigned: boolean, explicit: boolean]; + interface VirtualizableTagOption { id?: string; - isSelected: (tag: ClientTag) => boolean | undefined; + isSelected: isTagSelected; toggleSelection: (isSelected: boolean, tag: ClientTag) => void; onContextMenu?: (e: React.MouseEvent, tag: ClientTag) => void; } @@ -489,7 +494,7 @@ export function createTagRowRenderer({ }: VirtualizableTagOption) { const RowRenderer = ({ index, style, data, id: sub_id }: VirtualizedGridRowProps) => { const tag = data[index]; - const selected = isSelected(tag); + const [assigned, explicit] = isSelected(tag); return ( @@ -510,14 +516,24 @@ interface TagOptionProps { id?: string; index?: number; tag: ClientTag; - selected?: boolean; + assigned?: boolean; + explicit?: boolean; style?: React.CSSProperties; toggleSelection: (isSelected: boolean, tag: ClientTag) => void; onContextMenu?: (e: React.MouseEvent, tag: ClientTag) => void; } export const TagOption = observer( - ({ id, index, tag, selected, toggleSelection, onContextMenu, style }: TagOptionProps) => { + ({ + id, + index, + tag, + assigned, + explicit, + toggleSelection, + onContextMenu, + style, + }: TagOptionProps) => { const [path, hint] = useComputed(() => { const path = tag.path .map((v) => (v.startsWith('#') ? ' ' + v.slice(1) + ' ' : v)) @@ -534,9 +550,10 @@ export const TagOption = observer( id={id} index={index} value={tag.isHeader ? {tag.matchName} : tag.matchName} - selected={selected} + selected={assigned} + highlightCheck={explicit} icon={{IconSet.TAG}} - onClick={() => toggleSelection(selected ?? false, tag)} + onClick={() => toggleSelection((assigned && explicit) ?? false, tag)} tooltip={path} onContextMenu={onContextMenu !== undefined ? (e) => onContextMenu(e, tag) : undefined} style={style} diff --git a/src/frontend/components/Toaster.tsx b/src/frontend/components/Toaster.tsx index cb93d9b3..e694bcb8 100644 --- a/src/frontend/components/Toaster.tsx +++ b/src/frontend/components/Toaster.tsx @@ -1,6 +1,6 @@ import { action, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react-lite'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Button } from 'widgets/button'; import { Toast } from 'widgets/notifications'; @@ -87,8 +87,30 @@ export const Toaster = observer(() => ( )); const SavingIndicator = observer(() => { + const [isInLayout, setIsInLayout] = useState(false); const { fileStore: { isSaving }, } = useStore(); - return <>{isSaving &&
}; + + // Remove from layout with a delay to avoid annoying layout jumps in toasts + useEffect(() => { + const timeout = setTimeout( + () => { + setIsInLayout(isSaving); + }, + isSaving ? 0 : 800, + ); + return () => clearTimeout(timeout); + }, [isSaving]); + + return ( + <> + {isInLayout && ( +
+ )} + + ); }); diff --git a/src/frontend/containers/ContentView/GalleryItem.tsx b/src/frontend/containers/ContentView/GalleryItem.tsx index 30b77ab7..0dfec052 100644 --- a/src/frontend/containers/ContentView/GalleryItem.tsx +++ b/src/frontend/containers/ContentView/GalleryItem.tsx @@ -5,15 +5,15 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ellipsize, humanFileSize } from 'common/fmt'; import { encodeFilePath, isFileExtensionVideo } from 'common/fs'; -import { IconButton, IconSet, Tag } from 'widgets'; +import { IconButton, IconSet } from 'widgets'; import { useStore } from '../../contexts/StoreContext'; import { ClientFile } from '../../entities/File'; -import { ClientTag } from '../../entities/Tag'; import { usePromise } from '../../hooks/usePromise'; -import { CommandDispatcher, MousePointerEvent } from './Commands'; +import { CommandDispatcher } from './Commands'; import { ITransform } from './Masonry/layout-helpers'; import { GalleryVideoPlaybackMode } from 'src/frontend/stores/UiStore'; import { thumbnailMaxSize } from 'common/config'; +import { IncrementalTagItems } from 'src/frontend/components/FileTagsEditor'; interface ItemProps { file: ClientFile; @@ -306,14 +306,16 @@ export const ThumbnailTags = observer( onDrop={eventManager.drop} onDragEnd={eventManager.dragEnd} > - {file.sortedInheritedTags.map((tag) => ( - - ))} + ); }, ); +/* const TagWithHint = observer( ({ tag, @@ -335,6 +337,7 @@ const TagWithHint = observer( ); }, ); +*/ const ThumbnailOverlay = ({ file, @@ -350,7 +353,7 @@ const ThumbnailOverlay = ({ }, ${humanFileSize(file.size)}`; return ( -
+
{showFilename && (
{file.name} @@ -365,3 +368,10 @@ const ThumbnailOverlay = ({
); }; + +const deselect = () => { + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + } +}; diff --git a/src/frontend/containers/ContentView/Masonry/MasonryWorkerAdapter.tsx b/src/frontend/containers/ContentView/Masonry/MasonryWorkerAdapter.tsx index b510c4d9..603d6a5f 100644 --- a/src/frontend/containers/ContentView/Masonry/MasonryWorkerAdapter.tsx +++ b/src/frontend/containers/ContentView/Masonry/MasonryWorkerAdapter.tsx @@ -24,12 +24,12 @@ export class MasonryWorkerAdapter implements Layouter { private prevNumImgs: number = 0; async initialize(numItems: number) { - this.prevNumImgs = numItems; - if (this.memory !== undefined && this.worker !== undefined) { return; } + this.prevNumImgs = numItems; + console.debug('initializing masonry worker...'); const wasm = await init(); this.memory = wasm.memory; diff --git a/src/frontend/containers/ContentView/menu-items.tsx b/src/frontend/containers/ContentView/menu-items.tsx index f2ed2ae7..5801aad1 100644 --- a/src/frontend/containers/ContentView/menu-items.tsx +++ b/src/frontend/containers/ContentView/menu-items.tsx @@ -19,6 +19,7 @@ import { LocationTreeItemRevealer } from '../Outliner/LocationsPanel'; import { TagsTreeItemRevealer } from '../Outliner/TagsPanel/TagsTree'; import { ClientExtraProperty } from 'src/frontend/entities/ExtraProperty'; import { isFileExtensionVideo } from 'common/fs'; +import { runInAction } from 'mobx'; export const MissingFileMenuItems = observer(() => { const { uiStore, fileStore } = useStore(); @@ -37,7 +38,16 @@ export const MissingFileMenuItems = observer(() => { }); export const FileViewerMenuItems = ({ file }: { file: ClientFile }) => { - const { uiStore, locationStore } = useStore(); + const { uiStore, locationStore, fileStore } = useStore(); + + const handleClearSelectedFileTags = () => { + // Currently copy tags to clipboard as backup in case of error by the user + // ToDo: add a confirm dialog? + uiStore.copyTagsToClipboard(); + runInAction(() => { + uiStore.fileSelection.forEach((f) => f.clearTags()); + }); + }; const handleViewFullSize = () => { uiStore.selectFile(file, true); @@ -81,7 +91,28 @@ export const FileViewerMenuItems = ({ file }: { file: ClientFile }) => { text="Open In Preview Window" icon={IconSet.PREVIEW} /> - + + + + + + void; } +const ADD_AS_ALIAS_ID = 'merge-add-as-alias'; const FALLBACK_PLACEMENTS: Placement[] = ['bottom']; /** this component is only shown when all tags in the context do not have child-tags */ export const TagMerge = observer(({ tag, onClose }: TagMergeProps) => { const { tagStore, uiStore } = useStore(); + const [addAsAliasEnabled, setAddAsAlias] = useState( + localStorage.getItem(ADD_AS_ALIAS_ID) == 'true', + ); + + useEffect(() => { + localStorage.setItem(ADD_AS_ALIAS_ID, JSON.stringify(addAsAliasEnabled)); + }, [addAsAliasEnabled]); const ctxTags = uiStore.getTagContextItems(tag.id); @@ -28,7 +37,7 @@ export const TagMerge = observer(({ tag, onClose }: TagMergeProps) => { const merge = () => { if (selectedTag !== undefined && !mergingWithSelf) { for (const ctxTag of ctxTags) { - tagStore.merge(ctxTag, selectedTag); + tagStore.merge(ctxTag, selectedTag, addAsAliasEnabled); } onClose(); } @@ -57,6 +66,12 @@ export const TagMerge = observer(({ tag, onClose }: TagMergeProps) => { ))}
+
+ setAddAsAlias(!addAsAliasEnabled)} + text={'Add merged tag as alias to the selected tag'} + />