allow undo changes and deleted rows

This commit is contained in:
jeffvli
2026-07-16 22:05:52 -07:00
parent aa5309c54f
commit b0e66fcf45
6 changed files with 134 additions and 61 deletions
+1
View File
@@ -161,6 +161,7 @@
"trackGain": "Track gain",
"trackPeak": "Track peak",
"translation": "Translation",
"undo": "Undo",
"unknown": "Unknown",
"version": "Version",
"year": "Year",
@@ -96,12 +96,16 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
{editor.sortedFieldEntries.map(([key, value]) => (
<TagFieldRow
favoriteValues={editor.favoriteValues[key] ?? []}
isDirty={key in editor.editedFields}
isDirty={
key in editor.editedFields ||
editor.removedKeys.has(key)
}
isMixed={editor.mixedKeys.has(key)}
isMultiValue={
editor.multiValueKeys.has(key) ||
Array.isArray(value)
}
isRemoved={editor.removedKeys.has(key)}
key={key}
meta={editor.getFieldMeta(key)}
mixedPlaceholder={
@@ -114,6 +118,8 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
}
onChange={(v) => editor.handleFieldChange(key, v)}
onRemove={() => editor.handleRemoveField(key)}
onReset={() => editor.handleResetField(key)}
onRevert={() => editor.handleRevertField(key)}
tagKey={key}
value={value}
/>
@@ -10,3 +10,8 @@
.dirty-label {
box-shadow: inset 2px 0 0 var(--mantine-primary-color-filled);
}
.removed-row {
opacity: 0.55;
transition: opacity 0.15s ease-in-out;
}
@@ -2,13 +2,12 @@ import type { TagValue } from '/@/shared/types/tag-editor';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RiCloseLine } from 'react-icons/ri';
import type { KnownTag } from '../utils/known-tags';
import styles from './tag-field-row.module.css';
import { Button } from '/@/shared/components/button/button';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Table } from '/@/shared/components/table/table';
@@ -17,6 +16,7 @@ import { TextInput } from '/@/shared/components/text-input/text-input';
import { Textarea } from '/@/shared/components/textarea/textarea';
interface FavoriteTagsInputProps {
disabled: boolean;
favoriteValues: string[];
mixedPlaceholder?: string;
onAddFavorite: (value: string) => void;
@@ -29,11 +29,14 @@ interface TagFieldRowProps {
isDirty?: boolean;
isMixed: boolean;
isMultiValue: boolean;
isRemoved: boolean;
meta: KnownTag;
mixedPlaceholder?: string;
onAddFavorite: (value: string) => void;
onChange: (value: TagValue) => void;
onRemove: () => void;
onReset: () => void;
onRevert: () => void;
tagKey: string;
value: TagValue;
}
@@ -41,6 +44,7 @@ interface TagFieldRowProps {
const ADD_FAVORITE_PREFIX = '__feishin_add_favorite__:';
const FavoriteTagsInput = ({
disabled,
favoriteValues,
mixedPlaceholder,
onAddFavorite,
@@ -73,6 +77,7 @@ const FavoriteTagsInput = ({
<TagsInput
clearable
data={data}
disabled={disabled}
onChange={(values) => {
const normalizedValues = values.map((item) =>
item.startsWith(ADD_FAVORITE_PREFIX)
@@ -107,19 +112,30 @@ export const TagFieldRow = ({
isDirty,
isMixed,
isMultiValue,
isRemoved,
meta,
mixedPlaceholder,
onAddFavorite,
onChange,
onRemove,
onReset,
onRevert,
tagKey,
value,
}: TagFieldRowProps) => (
<Table.Tr data-field-key={tagKey} key={tagKey}>
}: TagFieldRowProps) => {
const { t } = useTranslation();
return (
<Table.Tr
className={isRemoved ? styles.removedRow : undefined}
data-field-key={tagKey}
key={tagKey}
>
<Table.Th className={isDirty ? styles.dirtyLabel : undefined}>{meta.label}</Table.Th>
<Table.Td>
{isMultiValue && tagKey !== 'lyrics' ? (
<FavoriteTagsInput
disabled={isRemoved}
favoriteValues={favoriteValues}
mixedPlaceholder={mixedPlaceholder}
onAddFavorite={onAddFavorite}
@@ -129,6 +145,7 @@ export const TagFieldRow = ({
) : meta.type === 'textarea' ? (
<Textarea
autosize
disabled={isRemoved}
maxRows={6}
minRows={2}
onChange={(e) => onChange(e.currentTarget.value)}
@@ -138,22 +155,27 @@ export const TagFieldRow = ({
/>
) : meta.type === 'number' ? (
<NumberInput
disabled={isRemoved}
onChange={(v) => onChange(v === undefined ? '' : String(v))}
placeholder={mixedPlaceholder}
size="sm"
value={
isMixed || value === '' || Array.isArray(value) ? undefined : Number(value)
isMixed || value === '' || Array.isArray(value)
? undefined
: Number(value)
}
/>
) : meta.type === 'boolean' ? (
<Checkbox
checked={!isMixed && value === '1'}
disabled={isRemoved}
indeterminate={isMixed}
onChange={(e) => onChange(e.currentTarget.checked ? '1' : '0')}
size="sm"
/>
) : (
<TextInput
disabled={isRemoved}
onChange={(e) => onChange(e.currentTarget.value)}
placeholder={mixedPlaceholder}
size="sm"
@@ -162,9 +184,22 @@ export const TagFieldRow = ({
)}
</Table.Td>
<Table.Td className={styles.removeCell}>
<Button className={styles.removeButton} onClick={onRemove} size="sm" variant="subtle">
<RiCloseLine size={16} />
</Button>
<ActionIcon
aria-label={isRemoved || isDirty ? t('common.undo') : t('common.delete')}
className={styles.removeButton}
icon={isRemoved || isDirty ? 'undo' : 'x'}
iconProps={{
color: isRemoved ? 'error' : 'default',
size: 'md',
}}
onClick={isRemoved ? onReset : isDirty ? onRevert : onRemove}
size="sm"
tooltip={{
label: isRemoved || isDirty ? t('common.undo') : t('common.delete'),
}}
variant="subtle"
/>
</Table.Td>
</Table.Tr>
);
);
};
@@ -87,7 +87,6 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
const allKeys = new Set<string>();
for (const k of Object.keys(tagSummary)) allKeys.add(k);
for (const k of Object.keys(editedFields)) allKeys.add(k);
for (const k of removedKeys) allKeys.delete(k);
const displayFields: Record<string, TagValue> = {};
const mixedKeys = new Set<string>();
@@ -118,7 +117,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
});
return { displayFields, mixedKeys, sortedFieldEntries };
}, [tagSummary, editedFields, multiValueKeys, removedKeys]);
}, [tagSummary, editedFields, multiValueKeys]);
/**
* Runs once on mount: reads metadata for all songs in batch and populates
@@ -210,14 +209,32 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
[favoriteValues, setSettings],
);
/** Removes `key` from both `editedFields` and the display, marking it for deletion on save. */
/** Marks `key` for deletion while preserving its displayed value for undo. */
const handleRemoveField = useCallback((key: string) => {
setRemovedKeys((prev) => new Set(prev).add(key));
}, []);
/** Re-enables a field that was marked for deletion. */
const handleResetField = useCallback((key: string) => {
setRemovedKeys((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, []);
/** Restores a field to its original on-disk value and removal state. */
const handleRevertField = useCallback((key: string) => {
setEditedFields((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
setRemovedKeys((prev) => new Set(prev).add(key));
setRemovedKeys((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, []);
// Refs so the functional updater inside handleAddField can see latest values without needing them in the dependency array.
@@ -232,6 +249,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
if (!key) return;
setEditedFields((prev) => {
const wasRemoved = removedKeysRef.current.has(key);
if (wasRemoved) return prev;
const alreadyVisible = (key in tagSummaryRef.current || key in prev) && !wasRemoved;
if (alreadyVisible) return prev;
return { ...prev, [key]: multiValueKeys.has(key) ? [] : '' };
@@ -284,7 +302,10 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
const handleSave = useCallback(async () => {
if (resolvedSongs.length === 0) return;
const emptyFields = Object.entries(editedFields)
const activeEdits = Object.fromEntries(
Object.entries(editedFields).filter(([key]) => !removedKeys.has(key)),
) as Record<string, TagValue>;
const emptyFields = Object.entries(activeEdits)
.filter(([key, value]) => {
const meta = KNOWN_TAG_MAP.get(key);
if (meta?.type === 'boolean') return false;
@@ -309,7 +330,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
const writeResult = await withBatchProgress(utils, setLoadProgress, () =>
utils.writeSongTagsBatch(
paths,
editedFields,
activeEdits,
[...removedKeys],
artworkOp ?? undefined,
),
@@ -393,6 +414,8 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
handleFieldChange,
handleRemoveArtwork,
handleRemoveField,
handleResetField,
handleRevertField,
handleSave,
isLoading,
isSaving,
@@ -401,6 +424,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
mixedPlaceholder,
multiValueKeys,
readWarning,
removedKeys,
rescan: triggerRescan,
setRescan,
showRemoveArtworkButton,
+2
View File
@@ -125,6 +125,7 @@ import {
LuTimerOff,
LuTrash,
LuTriangleAlert,
LuUndo2,
LuUpload,
LuUser,
LuUserPen,
@@ -361,6 +362,7 @@ export const AppIcon = {
themeDark: LuMoon,
themeLight: LuSun,
track: LuMusic2,
undo: LuUndo2,
unfavorite: LuHeartCrack,
unpin: LuPinOff,
upload: LuUpload,