From 7e30c63686309cc5e113bca67a87152e374a15a2 Mon Sep 17 00:00:00 2001 From: RafaUC Date: Fri, 22 Aug 2025 22:10:19 -0600 Subject: [PATCH 1/7] Feature: Differentiate between explicitly assigned, inherited, partially assigned, and unassigned tags in the tag selector's suggestions. The checkbox indicator follows these rules: - Checked: the tag is assigned (explicitly or inherited), and all selected files have the same assignation of that tag. - Highlighted: the tag is explicitly assigned in at least one of the selected files. --- resources/style/controls/combobox.scss | 10 +++++-- resources/style/tag-editor.scss | 9 ------ src/frontend/components/FileTagsEditor.tsx | 5 ++-- src/frontend/components/TagSelector.tsx | 35 ++++++++++++++++------ widgets/combobox/Grid.tsx | 3 ++ 5 files changed, 40 insertions(+), 22 deletions(-) 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..ac39cee3 100644 --- a/src/frontend/components/FileTagsEditor.tsx +++ b/src/frontend/components/FileTagsEditor.tsx @@ -17,6 +17,7 @@ import { createGetTabMatchTagCallback, createTagRowRenderer, GetTabMatchTag, + isTagSelected, useTabTagAutocomplete, } from './TagSelector'; import { useStore } from '../contexts/StoreContext'; @@ -347,14 +348,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], ); 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/widgets/combobox/Grid.tsx b/widgets/combobox/Grid.tsx index 1c298b90..301ed4fd 100644 --- a/widgets/combobox/Grid.tsx +++ b/widgets/combobox/Grid.tsx @@ -407,6 +407,7 @@ export interface RowProps { index?: number; value: string | JSX.Element; selected?: boolean; + highlightCheck?: boolean; /** The icon on the right side of the label because on the left is the checkmark already. */ icon?: JSX.Element; onClick?: (event: React.MouseEvent) => void; @@ -423,6 +424,7 @@ export const Row = ({ index, value, selected, + highlightCheck = selected, onClick, icon, tooltip, @@ -436,6 +438,7 @@ export const Row = ({ id={id} role="row" aria-selected={selected} + data-highlight-check={highlightCheck} onClick={onClick} tabIndex={-1} // Important for focus handling! data-tooltip={tooltip} From c93405c6baf3da0973c7e65533b8179b4d1afbb1 Mon Sep 17 00:00:00 2001 From: RafaUC Date: Sat, 23 Aug 2025 01:01:22 -0600 Subject: [PATCH 2/7] Feature: Allow to select the file name text in gallery thumbnails --- resources/style/content.scss | 4 ++++ src/frontend/containers/ContentView/GalleryItem.tsx | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/resources/style/content.scss b/resources/style/content.scss index 50d9326c..ad3b80d1 100644 --- a/resources/style/content.scss +++ b/resources/style/content.scss @@ -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; } } } diff --git a/src/frontend/containers/ContentView/GalleryItem.tsx b/src/frontend/containers/ContentView/GalleryItem.tsx index 30b77ab7..be6ec719 100644 --- a/src/frontend/containers/ContentView/GalleryItem.tsx +++ b/src/frontend/containers/ContentView/GalleryItem.tsx @@ -350,7 +350,7 @@ const ThumbnailOverlay = ({ }, ${humanFileSize(file.size)}`; return ( -
+
{showFilename && (
{file.name} @@ -365,3 +365,10 @@ const ThumbnailOverlay = ({
); }; + +const deselect = () => { + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + } +}; From f051285fdf23f82d7499983762db68c3c26118fb Mon Sep 17 00:00:00 2001 From: RafaUC Date: Sun, 24 Aug 2025 02:52:20 -0600 Subject: [PATCH 3/7] Feature: Automatically add the merged tag name and aliases to the selected tag when merging tags. - Added a checkbox in the merging dialog to toggle this behavior. --- .../Outliner/TagsPanel/TagMerge.tsx | 21 ++++++++++++++++--- src/frontend/entities/Tag.ts | 4 +++- src/frontend/stores/TagStore.ts | 21 ++++++++++++++++++- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/frontend/containers/Outliner/TagsPanel/TagMerge.tsx b/src/frontend/containers/Outliner/TagsPanel/TagMerge.tsx index 9df6ae70..ccf3ddeb 100644 --- a/src/frontend/containers/Outliner/TagsPanel/TagMerge.tsx +++ b/src/frontend/containers/Outliner/TagsPanel/TagMerge.tsx @@ -1,23 +1,32 @@ import { observer } from 'mobx-react-lite'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; -import { Button, IconSet, Tag } from 'widgets'; +import { Button, Checkbox, IconSet, Tag } from 'widgets'; import { Dialog } from 'widgets/popovers'; import { useStore } from '../../../contexts/StoreContext'; import { ClientTag } from '../../../entities/Tag'; import { Placement } from '@floating-ui/core'; import { TagSelector } from 'src/frontend/components/TagSelector'; +import { Menu, MenuCheckboxItem } from 'widgets/menus'; interface TagMergeProps { tag: ClientTag; onClose: () => 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'} + />
+ ); }); diff --git a/src/frontend/containers/ContentView/GalleryItem.tsx b/src/frontend/containers/ContentView/GalleryItem.tsx index be6ec719..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, diff --git a/widgets/tag.tsx b/widgets/tag.tsx index b861cf72..53ef5344 100644 --- a/widgets/tag.tsx +++ b/widgets/tag.tsx @@ -33,9 +33,7 @@ const Tag = (props: TagProps) => { style={style} onContextMenu={props.onContextMenu} > - - {text} - + {text} {onRemove ? ( Date: Fri, 12 Sep 2025 20:32:31 -0600 Subject: [PATCH 7/7] Thumbnail tags overlay now grows only when hovering tags, not the whole thumbnail. --- resources/style/content.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/style/content.scss b/resources/style/content.scss index ad3b80d1..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) @@ -562,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); } }