mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
fix: autogrow textarea bugs during animation (#24481)
This commit is contained in:
@@ -1,19 +0,0 @@
|
|||||||
import { tick } from 'svelte';
|
|
||||||
import type { Action } from 'svelte/action';
|
|
||||||
|
|
||||||
type Parameters = {
|
|
||||||
height?: string;
|
|
||||||
value: string; // added to enable reactivity
|
|
||||||
};
|
|
||||||
|
|
||||||
export const autoGrowHeight: Action<HTMLTextAreaElement, Parameters> = (textarea, { height = 'auto' }) => {
|
|
||||||
const update = () => {
|
|
||||||
void tick().then(() => {
|
|
||||||
textarea.style.height = height;
|
|
||||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
update();
|
|
||||||
return { update };
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { updateAlbumInfo } from '@immich/sdk';
|
import { shortcut } from '$lib/actions/shortcut';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
|
import { updateAlbumInfo } from '@immich/sdk';
|
||||||
|
import { Textarea } from '@immich/ui';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { fromAction } from 'svelte/attachments';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,27 +14,34 @@
|
|||||||
|
|
||||||
let { id, description = $bindable(), isOwned }: Props = $props();
|
let { id, description = $bindable(), isOwned }: Props = $props();
|
||||||
|
|
||||||
const handleUpdateDescription = async (newDescription: string) => {
|
const handleFocusOut = async () => {
|
||||||
try {
|
try {
|
||||||
await updateAlbumInfo({
|
await updateAlbumInfo({
|
||||||
id,
|
id,
|
||||||
updateAlbumDto: {
|
updateAlbumDto: {
|
||||||
description: newDescription,
|
description,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_save_album'));
|
handleError(error, $t('errors.unable_to_save_album'));
|
||||||
}
|
}
|
||||||
description = newDescription;
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOwned}
|
{#if isOwned}
|
||||||
<AutogrowTextarea
|
<Textarea
|
||||||
content={description}
|
bind:value={description}
|
||||||
class="w-full mt-2 text-black dark:text-white border-b-2 border-transparent border-gray-500 bg-transparent text-base outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:focus:border-immich-dark-primary hover:border-gray-400"
|
class="outline-none border-b border-gray-500 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
|
||||||
onContentUpdate={handleUpdateDescription}
|
rows={1}
|
||||||
|
grow
|
||||||
|
shape="rectangle"
|
||||||
|
onfocusout={handleFocusOut}
|
||||||
placeholder={$t('add_a_description')}
|
placeholder={$t('add_a_description')}
|
||||||
|
data-testid="autogrow-textarea"
|
||||||
|
{@attach fromAction(shortcut, () => ({
|
||||||
|
shortcut: { key: 'Enter', ctrl: true },
|
||||||
|
onShortcut: (e) => e.currentTarget.blur(),
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
{:else if description}
|
{:else if description}
|
||||||
<p class="break-words whitespace-pre-line w-full text-black dark:text-white text-base">
|
<p class="break-words whitespace-pre-line w-full text-black dark:text-white text-base">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { autoGrowHeight } from '$lib/actions/autogrow';
|
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { shortcut } from '$lib/actions/shortcut';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
@@ -12,10 +11,11 @@
|
|||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { isTenMinutesApart } from '$lib/utils/timesince';
|
import { isTenMinutesApart } from '$lib/utils/timesince';
|
||||||
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk';
|
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk';
|
||||||
import { Icon, IconButton, LoadingSpinner, toastManager } from '@immich/ui';
|
import { Icon, IconButton, LoadingSpinner, Textarea, toastManager } from '@immich/ui';
|
||||||
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiSend, mdiThumbUp } from '@mdi/js';
|
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiSend, mdiThumbUp } from '@mdi/js';
|
||||||
import * as luxon from 'luxon';
|
import * as luxon from 'luxon';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { fromAction } from 'svelte/attachments';
|
||||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||||
|
|
||||||
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
|
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
|
||||||
@@ -245,19 +245,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<form class="flex w-full max-h-56 gap-1" {onsubmit}>
|
<form class="flex w-full max-h-56 gap-1" {onsubmit}>
|
||||||
<div class="flex w-full items-center gap-4">
|
<div class="flex w-full items-center gap-4">
|
||||||
<textarea
|
<Textarea
|
||||||
{disabled}
|
{disabled}
|
||||||
bind:value={message}
|
bind:value={message}
|
||||||
use:autoGrowHeight={{ height: '5px', value: message }}
|
rows={1}
|
||||||
|
grow
|
||||||
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
|
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
|
||||||
use:shortcut={{
|
{@attach fromAction(shortcut, () => ({
|
||||||
shortcut: { key: 'Enter' },
|
shortcut: { key: 'Enter' },
|
||||||
onShortcut: () => handleSendComment(),
|
onShortcut: () => handleSendComment(),
|
||||||
}}
|
}))}
|
||||||
class="h-4.5 {disabled
|
class="h-4.5 {disabled
|
||||||
? 'cursor-not-allowed'
|
? 'cursor-not-allowed'
|
||||||
: ''} w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
|
: ''} w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200 dark:bg-gray-200"
|
||||||
></textarea>
|
></Textarea>
|
||||||
</div>
|
</div>
|
||||||
{#if isSendingMessage}
|
{#if isSendingMessage}
|
||||||
<div class="flex items-end place-items-center pb-2 ms-0">
|
<div class="flex items-end place-items-center pb-2 ms-0">
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
|
import { shortcut } from '$lib/actions/shortcut';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { toastManager } from '@immich/ui';
|
import { Textarea, toastManager } from '@immich/ui';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { fromAction } from 'svelte/attachments';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
@@ -12,15 +13,17 @@
|
|||||||
|
|
||||||
let { asset, isOwner }: Props = $props();
|
let { asset, isOwner }: Props = $props();
|
||||||
|
|
||||||
let description = $derived(asset.exifInfo?.description || '');
|
let currentDescription = asset.exifInfo?.description ?? '';
|
||||||
|
let draftDescription = $state(currentDescription);
|
||||||
|
|
||||||
const handleFocusOut = async (newDescription: string) => {
|
const handleFocusOut = async () => {
|
||||||
|
if (draftDescription === currentDescription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await updateAsset({ id: asset.id, updateAssetDto: { description: newDescription } });
|
await updateAsset({ id: asset.id, updateAssetDto: { description: draftDescription } });
|
||||||
|
|
||||||
asset.exifInfo = { ...asset.exifInfo, description: newDescription };
|
|
||||||
|
|
||||||
toastManager.success($t('asset_description_updated'));
|
toastManager.success($t('asset_description_updated'));
|
||||||
|
currentDescription = draftDescription;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('cannot_update_the_description'));
|
handleError(error, $t('cannot_update_the_description'));
|
||||||
}
|
}
|
||||||
@@ -29,15 +32,23 @@
|
|||||||
|
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<section class="px-4 mt-10">
|
<section class="px-4 mt-10">
|
||||||
<AutogrowTextarea
|
<Textarea
|
||||||
content={description}
|
bind:value={draftDescription}
|
||||||
class="max-h-125 w-full border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary immich-scrollbar"
|
class="max-h-40 outline-none border-b border-gray-500 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
|
||||||
onContentUpdate={handleFocusOut}
|
rows={1}
|
||||||
|
grow
|
||||||
|
shape="rectangle"
|
||||||
|
onfocusout={handleFocusOut}
|
||||||
placeholder={$t('add_a_description')}
|
placeholder={$t('add_a_description')}
|
||||||
|
data-testid="autogrow-textarea"
|
||||||
|
{@attach fromAction(shortcut, () => ({
|
||||||
|
shortcut: { key: 'Enter', ctrl: true },
|
||||||
|
onShortcut: (e) => e.currentTarget.blur(),
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
{:else if description}
|
{:else if draftDescription}
|
||||||
<section class="px-4 mt-6">
|
<section class="px-4 mt-6">
|
||||||
<p class="wrap-break-word whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
|
<p class="wrap-break-word whitespace-pre-line w-full text-black dark:text-white text-base">{draftDescription}</p>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/svelte';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
|
|
||||||
describe('AutogrowTextarea component', () => {
|
|
||||||
const getTextarea = () => screen.getByTestId('autogrow-textarea') as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
it('should render correctly', () => {
|
|
||||||
render(AutogrowTextarea);
|
|
||||||
const textarea = getTextarea();
|
|
||||||
expect(textarea).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show the content passed to the component', () => {
|
|
||||||
render(AutogrowTextarea, { content: 'stuff' });
|
|
||||||
const textarea = getTextarea();
|
|
||||||
expect(textarea.value).toBe('stuff');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show the placeholder passed to the component', () => {
|
|
||||||
render(AutogrowTextarea, { placeholder: 'asdf' });
|
|
||||||
const textarea = getTextarea();
|
|
||||||
expect(textarea.placeholder).toBe('asdf');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should execute the passed callback on blur', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const update = vi.fn();
|
|
||||||
render(AutogrowTextarea, { content: 'existing', onContentUpdate: update });
|
|
||||||
const textarea = getTextarea();
|
|
||||||
await user.click(textarea);
|
|
||||||
await user.keyboard('extra');
|
|
||||||
textarea.blur();
|
|
||||||
await waitFor(() => expect(update).toHaveBeenCalledWith('existingextra'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should execute the passed callback when pressing ctrl+enter in the textarea', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const update = vi.fn();
|
|
||||||
render(AutogrowTextarea, { onContentUpdate: update });
|
|
||||||
const textarea = getTextarea();
|
|
||||||
await user.click(textarea);
|
|
||||||
const string = 'content';
|
|
||||||
await user.keyboard(string);
|
|
||||||
await user.keyboard('{Control>}{Enter}{/Control}');
|
|
||||||
await waitFor(() => expect(update).toHaveBeenCalledWith(string));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not execute the passed callback if the text has not changed', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const update = vi.fn();
|
|
||||||
render(AutogrowTextarea, { content: 'initial', onContentUpdate: update });
|
|
||||||
const textarea = getTextarea();
|
|
||||||
await user.click(textarea);
|
|
||||||
await user.clear(textarea);
|
|
||||||
await user.keyboard('initial');
|
|
||||||
await user.keyboard('{Control>}{Enter}{/Control}');
|
|
||||||
await waitFor(() => expect(update).not.toHaveBeenCalled());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { autoGrowHeight } from '$lib/actions/autogrow';
|
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
content?: string;
|
|
||||||
class?: string;
|
|
||||||
onContentUpdate?: (newContent: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { content = '', class: className = '', onContentUpdate = () => null, placeholder = '' }: Props = $props();
|
|
||||||
|
|
||||||
let newContent = $derived(content);
|
|
||||||
|
|
||||||
const updateContent = () => {
|
|
||||||
if (content === newContent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onContentUpdate(newContent);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
bind:value={newContent}
|
|
||||||
class="resize-none {className}"
|
|
||||||
onfocusout={updateContent}
|
|
||||||
{placeholder}
|
|
||||||
use:shortcut={{
|
|
||||||
shortcut: { key: 'Enter', ctrl: true },
|
|
||||||
onShortcut: (e) => e.currentTarget.blur(),
|
|
||||||
}}
|
|
||||||
use:autoGrowHeight={{ value: newContent }}
|
|
||||||
data-testid="autogrow-textarea">{content}</textarea
|
|
||||||
>
|
|
||||||
Reference in New Issue
Block a user