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", "trackGain": "Track gain",
"trackPeak": "Track peak", "trackPeak": "Track peak",
"translation": "Translation", "translation": "Translation",
"undo": "Undo",
"unknown": "Unknown", "unknown": "Unknown",
"version": "Version", "version": "Version",
"year": "Year", "year": "Year",
@@ -96,12 +96,16 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
{editor.sortedFieldEntries.map(([key, value]) => ( {editor.sortedFieldEntries.map(([key, value]) => (
<TagFieldRow <TagFieldRow
favoriteValues={editor.favoriteValues[key] ?? []} favoriteValues={editor.favoriteValues[key] ?? []}
isDirty={key in editor.editedFields} isDirty={
key in editor.editedFields ||
editor.removedKeys.has(key)
}
isMixed={editor.mixedKeys.has(key)} isMixed={editor.mixedKeys.has(key)}
isMultiValue={ isMultiValue={
editor.multiValueKeys.has(key) || editor.multiValueKeys.has(key) ||
Array.isArray(value) Array.isArray(value)
} }
isRemoved={editor.removedKeys.has(key)}
key={key} key={key}
meta={editor.getFieldMeta(key)} meta={editor.getFieldMeta(key)}
mixedPlaceholder={ mixedPlaceholder={
@@ -114,6 +118,8 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
} }
onChange={(v) => editor.handleFieldChange(key, v)} onChange={(v) => editor.handleFieldChange(key, v)}
onRemove={() => editor.handleRemoveField(key)} onRemove={() => editor.handleRemoveField(key)}
onReset={() => editor.handleResetField(key)}
onRevert={() => editor.handleRevertField(key)}
tagKey={key} tagKey={key}
value={value} value={value}
/> />
@@ -10,3 +10,8 @@
.dirty-label { .dirty-label {
box-shadow: inset 2px 0 0 var(--mantine-primary-color-filled); 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 { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RiCloseLine } from 'react-icons/ri';
import type { KnownTag } from '../utils/known-tags'; import type { KnownTag } from '../utils/known-tags';
import styles from './tag-field-row.module.css'; 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 { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { NumberInput } from '/@/shared/components/number-input/number-input'; import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Table } from '/@/shared/components/table/table'; 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'; import { Textarea } from '/@/shared/components/textarea/textarea';
interface FavoriteTagsInputProps { interface FavoriteTagsInputProps {
disabled: boolean;
favoriteValues: string[]; favoriteValues: string[];
mixedPlaceholder?: string; mixedPlaceholder?: string;
onAddFavorite: (value: string) => void; onAddFavorite: (value: string) => void;
@@ -29,11 +29,14 @@ interface TagFieldRowProps {
isDirty?: boolean; isDirty?: boolean;
isMixed: boolean; isMixed: boolean;
isMultiValue: boolean; isMultiValue: boolean;
isRemoved: boolean;
meta: KnownTag; meta: KnownTag;
mixedPlaceholder?: string; mixedPlaceholder?: string;
onAddFavorite: (value: string) => void; onAddFavorite: (value: string) => void;
onChange: (value: TagValue) => void; onChange: (value: TagValue) => void;
onRemove: () => void; onRemove: () => void;
onReset: () => void;
onRevert: () => void;
tagKey: string; tagKey: string;
value: TagValue; value: TagValue;
} }
@@ -41,6 +44,7 @@ interface TagFieldRowProps {
const ADD_FAVORITE_PREFIX = '__feishin_add_favorite__:'; const ADD_FAVORITE_PREFIX = '__feishin_add_favorite__:';
const FavoriteTagsInput = ({ const FavoriteTagsInput = ({
disabled,
favoriteValues, favoriteValues,
mixedPlaceholder, mixedPlaceholder,
onAddFavorite, onAddFavorite,
@@ -73,6 +77,7 @@ const FavoriteTagsInput = ({
<TagsInput <TagsInput
clearable clearable
data={data} data={data}
disabled={disabled}
onChange={(values) => { onChange={(values) => {
const normalizedValues = values.map((item) => const normalizedValues = values.map((item) =>
item.startsWith(ADD_FAVORITE_PREFIX) item.startsWith(ADD_FAVORITE_PREFIX)
@@ -107,64 +112,94 @@ export const TagFieldRow = ({
isDirty, isDirty,
isMixed, isMixed,
isMultiValue, isMultiValue,
isRemoved,
meta, meta,
mixedPlaceholder, mixedPlaceholder,
onAddFavorite, onAddFavorite,
onChange, onChange,
onRemove, onRemove,
onReset,
onRevert,
tagKey, tagKey,
value, value,
}: TagFieldRowProps) => ( }: TagFieldRowProps) => {
<Table.Tr data-field-key={tagKey} key={tagKey}> const { t } = useTranslation();
<Table.Th className={isDirty ? styles.dirtyLabel : undefined}>{meta.label}</Table.Th>
<Table.Td> return (
{isMultiValue && tagKey !== 'lyrics' ? ( <Table.Tr
<FavoriteTagsInput className={isRemoved ? styles.removedRow : undefined}
favoriteValues={favoriteValues} data-field-key={tagKey}
mixedPlaceholder={mixedPlaceholder} key={tagKey}
onAddFavorite={onAddFavorite} >
onChange={onChange} <Table.Th className={isDirty ? styles.dirtyLabel : undefined}>{meta.label}</Table.Th>
value={Array.isArray(value) ? value : value ? [value] : []} <Table.Td>
/> {isMultiValue && tagKey !== 'lyrics' ? (
) : meta.type === 'textarea' ? ( <FavoriteTagsInput
<Textarea disabled={isRemoved}
autosize favoriteValues={favoriteValues}
maxRows={6} mixedPlaceholder={mixedPlaceholder}
minRows={2} onAddFavorite={onAddFavorite}
onChange={(e) => onChange(e.currentTarget.value)} onChange={onChange}
placeholder={mixedPlaceholder} value={Array.isArray(value) ? value : value ? [value] : []}
/>
) : meta.type === 'textarea' ? (
<Textarea
autosize
disabled={isRemoved}
maxRows={6}
minRows={2}
onChange={(e) => onChange(e.currentTarget.value)}
placeholder={mixedPlaceholder}
size="sm"
value={Array.isArray(value) ? value.join('\n\n') : value}
/>
) : 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)
}
/>
) : 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"
value={Array.isArray(value) ? value.join('; ') : value}
/>
)}
</Table.Td>
<Table.Td className={styles.removeCell}>
<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" size="sm"
value={Array.isArray(value) ? value.join('\n\n') : value} tooltip={{
label: isRemoved || isDirty ? t('common.undo') : t('common.delete'),
}}
variant="subtle"
/> />
) : meta.type === 'number' ? ( </Table.Td>
<NumberInput </Table.Tr>
onChange={(v) => onChange(v === undefined ? '' : String(v))} );
placeholder={mixedPlaceholder} };
size="sm"
value={
isMixed || value === '' || Array.isArray(value) ? undefined : Number(value)
}
/>
) : meta.type === 'boolean' ? (
<Checkbox
checked={!isMixed && value === '1'}
indeterminate={isMixed}
onChange={(e) => onChange(e.currentTarget.checked ? '1' : '0')}
size="sm"
/>
) : (
<TextInput
onChange={(e) => onChange(e.currentTarget.value)}
placeholder={mixedPlaceholder}
size="sm"
value={Array.isArray(value) ? value.join('; ') : value}
/>
)}
</Table.Td>
<Table.Td className={styles.removeCell}>
<Button className={styles.removeButton} onClick={onRemove} size="sm" variant="subtle">
<RiCloseLine size={16} />
</Button>
</Table.Td>
</Table.Tr>
);
@@ -87,7 +87,6 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
const allKeys = new Set<string>(); const allKeys = new Set<string>();
for (const k of Object.keys(tagSummary)) allKeys.add(k); for (const k of Object.keys(tagSummary)) allKeys.add(k);
for (const k of Object.keys(editedFields)) 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 displayFields: Record<string, TagValue> = {};
const mixedKeys = new Set<string>(); const mixedKeys = new Set<string>();
@@ -118,7 +117,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
}); });
return { displayFields, mixedKeys, sortedFieldEntries }; return { displayFields, mixedKeys, sortedFieldEntries };
}, [tagSummary, editedFields, multiValueKeys, removedKeys]); }, [tagSummary, editedFields, multiValueKeys]);
/** /**
* Runs once on mount: reads metadata for all songs in batch and populates * 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], [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) => { 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) => { setEditedFields((prev) => {
const next = { ...prev }; const next = { ...prev };
delete next[key]; delete next[key];
return next; 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. // 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; if (!key) return;
setEditedFields((prev) => { setEditedFields((prev) => {
const wasRemoved = removedKeysRef.current.has(key); const wasRemoved = removedKeysRef.current.has(key);
if (wasRemoved) return prev;
const alreadyVisible = (key in tagSummaryRef.current || key in prev) && !wasRemoved; const alreadyVisible = (key in tagSummaryRef.current || key in prev) && !wasRemoved;
if (alreadyVisible) return prev; if (alreadyVisible) return prev;
return { ...prev, [key]: multiValueKeys.has(key) ? [] : '' }; return { ...prev, [key]: multiValueKeys.has(key) ? [] : '' };
@@ -284,7 +302,10 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (resolvedSongs.length === 0) return; 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]) => { .filter(([key, value]) => {
const meta = KNOWN_TAG_MAP.get(key); const meta = KNOWN_TAG_MAP.get(key);
if (meta?.type === 'boolean') return false; if (meta?.type === 'boolean') return false;
@@ -309,7 +330,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
const writeResult = await withBatchProgress(utils, setLoadProgress, () => const writeResult = await withBatchProgress(utils, setLoadProgress, () =>
utils.writeSongTagsBatch( utils.writeSongTagsBatch(
paths, paths,
editedFields, activeEdits,
[...removedKeys], [...removedKeys],
artworkOp ?? undefined, artworkOp ?? undefined,
), ),
@@ -393,6 +414,8 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
handleFieldChange, handleFieldChange,
handleRemoveArtwork, handleRemoveArtwork,
handleRemoveField, handleRemoveField,
handleResetField,
handleRevertField,
handleSave, handleSave,
isLoading, isLoading,
isSaving, isSaving,
@@ -401,6 +424,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
mixedPlaceholder, mixedPlaceholder,
multiValueKeys, multiValueKeys,
readWarning, readWarning,
removedKeys,
rescan: triggerRescan, rescan: triggerRescan,
setRescan, setRescan,
showRemoveArtworkButton, showRemoveArtworkButton,
+2
View File
@@ -125,6 +125,7 @@ import {
LuTimerOff, LuTimerOff,
LuTrash, LuTrash,
LuTriangleAlert, LuTriangleAlert,
LuUndo2,
LuUpload, LuUpload,
LuUser, LuUser,
LuUserPen, LuUserPen,
@@ -361,6 +362,7 @@ export const AppIcon = {
themeDark: LuMoon, themeDark: LuMoon,
themeLight: LuSun, themeLight: LuSun,
track: LuMusic2, track: LuMusic2,
undo: LuUndo2,
unfavorite: LuHeartCrack, unfavorite: LuHeartCrack,
unpin: LuPinOff, unpin: LuPinOff,
upload: LuUpload, upload: LuUpload,