Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions resources/style/content.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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;
}
}
}
Expand All @@ -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);
}
}
Expand Down
10 changes: 8 additions & 2 deletions resources/style/controls/combobox.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
9 changes: 0 additions & 9 deletions resources/style/tag-editor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
116 changes: 96 additions & 20 deletions src/frontend/components/FileTagsEditor.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,6 +25,7 @@ import {
createGetTabMatchTagCallback,
createTagRowRenderer,
GetTabMatchTag,
isTagSelected,
useTabTagAutocomplete,
} from './TagSelector';
import { useStore } from '../contexts/StoreContext';
Expand Down Expand Up @@ -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<ClientTag, [number, boolean]>();
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]++;
Expand Down Expand Up @@ -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],
);
Expand Down Expand Up @@ -469,30 +479,96 @@ 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])
.map((pair) => pair[0]);

return (
<div className="config-scrollbar" onMouseDown={(e) => e.preventDefault()}>
{sortedTags.map((t) => (
<IncrementalTagItems
tags={sortedTags}
counter={counter}
removeTag={removeTag}
onContextMenu={onContextMenu}
chunkSize={uiStore.fileSelection.size > 1 ? 5 : 100}
/>
{sortedTags.length === 0 && <i><b>No tags added yet</b></i> // eslint-disable-line prettier/prettier
}
</div>
);
});

interface IncrementalTagItemsProps {
tags: ClientTag[];
counter?: IComputedValue<Map<ClientTag, [number, boolean]>>;
removeTag?: (tag: ClientTag) => void;
onContextMenu?: (e: React.MouseEvent<HTMLElement>, 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<ClientTag[]>([]);

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 }) => (
<Tag
key={t.id}
text={`${t.name}${
uiStore.fileSelection.size > 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('#') ? '&nbsp;<b>' + v.slice(1) + '</b>&nbsp;' : 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) => (
<RenderTag key={t.id} tag={t} />
))}
{sortedTags.length === 0 && <i><b>No tags added yet</b></i> // eslint-disable-line prettier/prettier
}
</div>
</>
);
});

Expand Down
35 changes: 26 additions & 9 deletions src/frontend/components/TagSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<HTMLElement>, tag: ClientTag) => void;
}
Expand All @@ -489,15 +494,16 @@ export function createTagRowRenderer({
}: VirtualizableTagOption) {
const RowRenderer = ({ index, style, data, id: sub_id }: VirtualizedGridRowProps<ClientTag>) => {
const tag = data[index];
const selected = isSelected(tag);
const [assigned, explicit] = isSelected(tag);
return (
<TagOption
id={`${id}-${tag.id}-${sub_id}`}
index={index}
style={style}
key={tag.id}
tag={tag}
selected={selected}
assigned={assigned}
explicit={explicit}
toggleSelection={toggleSelection}
onContextMenu={onContextMenu}
/>
Expand All @@ -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<HTMLElement>, 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('#') ? '&nbsp;<b>' + v.slice(1) + '</b>&nbsp;' : v))
Expand All @@ -534,9 +550,10 @@ export const TagOption = observer(
id={id}
index={index}
value={tag.isHeader ? <b>{tag.matchName}</b> : tag.matchName}
selected={selected}
selected={assigned}
highlightCheck={explicit}
icon={<span style={{ color: tag.viewColor }}>{IconSet.TAG}</span>}
onClick={() => toggleSelection(selected ?? false, tag)}
onClick={() => toggleSelection((assigned && explicit) ?? false, tag)}
tooltip={path}
onContextMenu={onContextMenu !== undefined ? (e) => onContextMenu(e, tag) : undefined}
style={style}
Expand Down
26 changes: 24 additions & 2 deletions src/frontend/components/Toaster.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -87,8 +87,30 @@ export const Toaster = observer(() => (
));

const SavingIndicator = observer(() => {
const [isInLayout, setIsInLayout] = useState(false);
const {
fileStore: { isSaving },
} = useStore();
return <>{isSaving && <div className="saving-indicator"></div>}</>;

// 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 && (
<div
className="saving-indicator"
style={isSaving ? undefined : { visibility: 'hidden' }}
></div>
)}
</>
);
});
Loading
Loading