Skip to content

fix(web): contenteditable div for multi-line text wrapping #19422

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
8 changes: 4 additions & 4 deletions e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ test.describe('Detail Panel', () => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);

const textarea = page.getByRole('textbox', { name: 'Add a description' });
const textarea = page.getByTestId('contenteditable-text');
await page.getByRole('button', { name: 'Info' }).click();
await expect(textarea).toBeVisible();
await expect(textarea).not.toBeDisabled();
Expand All @@ -71,16 +71,16 @@ test.describe('Detail Panel', () => {
await page.waitForSelector('#immich-asset-viewer');

await page.getByRole('button', { name: 'Info' }).click();
const textarea = page.getByRole('textbox', { name: 'Add a description' });
const textarea = page.getByTestId('contenteditable-text');
await textarea.fill('new description');
await expect(textarea).toHaveValue('new description');
await expect(textarea).toHaveText('new description', { useInnerText: true });

await page.getByRole('button', { name: 'Info' }).click();
await expect(textarea).not.toBeVisible();
await page.getByRole('button', { name: 'Info' }).click();
await expect(textarea).toBeVisible();

await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
await expect(textarea).toHaveValue('new description');
await expect(textarea).toHaveText('new description', { useInnerText: true });
});
});
12 changes: 6 additions & 6 deletions web/src/lib/components/album-page/album-description.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import { render, screen } from '@testing-library/svelte';
import { describe } from 'vitest';

describe('AlbumDescription component', () => {
it('shows an AutogrowTextarea component when isOwned is true', () => {
it('shows an ContenteditableText component when isOwned is true', () => {
render(AlbumDescription, { isOwned: true, id: '', description: '' });
const autogrowTextarea = screen.getByTestId('autogrow-textarea');
expect(autogrowTextarea).toBeInTheDocument();
const contenteditableText = screen.getByTestId('contenteditable-text');
expect(contenteditableText).toBeInTheDocument();
});

it('does not show an AutogrowTextarea component when isOwned is false', () => {
it('does not show an ContenteditableText component when isOwned is false', () => {
render(AlbumDescription, { isOwned: false, id: '', description: '' });
const autogrowTextarea = screen.queryByTestId('autogrow-textarea');
expect(autogrowTextarea).not.toBeInTheDocument();
const contenteditableText = screen.queryByTestId('contenteditable-text');
expect(contenteditableText).not.toBeInTheDocument();
});
});
11 changes: 6 additions & 5 deletions web/src/lib/components/album-page/album-description.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import { updateAlbumInfo } from '@immich/sdk';
import ContenteditableText from '$lib/components/shared-components/contenteditable-text.svelte';
import { handleError } from '$lib/utils/handle-error';
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
import { updateAlbumInfo } from '@immich/sdk';
import { t } from 'svelte-i18n';

interface Props {
Expand All @@ -28,11 +28,12 @@
</script>

{#if isOwned}
<AutogrowTextarea
content={description}
<ContenteditableText
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"
onContentUpdate={handleUpdateDescription}
onUpdate={handleUpdateDescription}
placeholder={$t('add_a_description')}
isCtrlEnter={true}
/>
{:else if description}
<p class="break-words whitespace-pre-line w-full text-black dark:text-white text-base">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
<script lang="ts">
import ContenteditableText from '$lib/components/shared-components/contenteditable-text.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
import { t } from 'svelte-i18n';

interface Props {
Expand Down Expand Up @@ -35,11 +35,12 @@

{#if isOwner}
<section class="px-4 mt-10">
<AutogrowTextarea
content={description}
<ContenteditableText
value={description}
class="max-h-[500px] 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"
onContentUpdate={handleFocusOut}
onUpdate={handleFocusOut}
placeholder={$t('add_a_description')}
isCtrlEnter={true}
/>
</section>
{:else if description}
Expand Down
60 changes: 0 additions & 60 deletions web/src/lib/components/shared-components/autogrow-textarea.spec.ts

This file was deleted.

35 changes: 0 additions & 35 deletions web/src/lib/components/shared-components/autogrow-textarea.svelte

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import ContenteditableText from '$lib/components/shared-components/contenteditable-text.svelte';
import { render, screen } from '@testing-library/svelte';

describe('ContenteditableText component', () => {
it('should render correctly', () => {
render(ContenteditableText);
const textarea = screen.getByTestId('contenteditable-text');
expect(textarea).toBeInTheDocument();
});

it('should display given placeholder', () => {
render(ContenteditableText, { placeholder: 'enter some text' });
const textarea = screen.getByTestId('contenteditable-text');
expect(textarea).toHaveAttribute('placeholder', 'enter some text');
});

// note: jsdom does not support contenteditable API, additional tests are done in e2e
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import type { HTMLAttributes } from 'svelte/elements';

interface Props extends HTMLAttributes<HTMLDivElement> {
placeholder?: string;
value?: string;
isCtrlEnter?: boolean;
disabled?: boolean;
onUpdate?: (newValue: string) => void;
}

let { isCtrlEnter = false, value = $bindable(), disabled = false, onUpdate = () => null, ...props }: Props = $props();

let lastValue = value;

$effect(() => {
// https://bugzilla.mozilla.org/show_bug.cgi?id=1615852
if (value === '\n') {
value = '';
}
});
</script>

{#if disabled}
<div class="cursor-not-allowed {props.class}" role="textbox" aria-disabled={true} data-testid="contenteditable-text">
{props.placeholder}
</div>
{:else}
<div
bind:innerText={value}
{...props}
use:shortcut={{
shortcut: { key: 'Enter', ctrl: isCtrlEnter },
onShortcut: (e) => e.currentTarget.blur(),
}}
contenteditable="plaintext-only"
class={props.class}
role="textbox"
aria-placeholder={props.placeholder}
aria-multiline={isCtrlEnter}
onkeydown={(e) => e.stopImmediatePropagation()}
onfocusout={(e) => {
if (lastValue !== value) {
lastValue = value;
onUpdate?.(value ?? '');
}
props.onfocusout?.(e);
}}
data-testid="contenteditable-text"
tabindex="0"
></div>
{/if}

<style>
[contenteditable='plaintext-only']:empty:not(:focus):before {
content: attr(placeholder);
pointer-events: none;
display: block;
color: #555;
}
</style>
2 changes: 1 addition & 1 deletion web/src/routes/(user)/explore/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
<Icon path={mdiHeart} size="24" class="text-white" />
</div>
{/if}
<p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p>
<p class="mt-2 text-ellipsis text-sm font-medium dark:text-white break-words">{person.name}</p>
</a>
{/each}
{/snippet}
Expand Down
17 changes: 4 additions & 13 deletions web/src/routes/(user)/people/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
import { page } from '$app/stores';
import { focusTrap } from '$lib/actions/focus-trap';
import { scrollMemory } from '$lib/actions/scroll-memory';
import { shortcut } from '$lib/actions/shortcut';
import Icon from '$lib/components/elements/icon.svelte';
import ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte';
import PeopleCard from '$lib/components/faces-page/people-card.svelte';
import PeopleInfiniteScroll from '$lib/components/faces-page/people-infinite-scroll.svelte';
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ContenteditableText from '$lib/components/shared-components/contenteditable-text.svelte';
import {
notificationController,
NotificationType,
Expand Down Expand Up @@ -287,12 +287,6 @@
}
};

const onNameChangeInputUpdate = (event: Event) => {
if (event.target) {
newName = (event.target as HTMLInputElement).value;
}
};

const updateName = async (id: string, name: string) => {
await updatePerson({
id,
Expand Down Expand Up @@ -374,15 +368,12 @@
onToggleFavorite={() => handleToggleFavorite(person)}
/>

<input
type="text"
class=" bg-white dark:bg-immich-dark-gray border-gray-100 placeholder-gray-400 text-center dark:border-gray-900 w-full rounded-2xl mt-2 py-2 text-sm text-immich-primary dark:text-immich-dark-primary"
<ContenteditableText
class="bg-white dark:bg-immich-dark-gray border-gray-100 placeholder-gray-400 text-center dark:border-gray-900 w-full rounded-2xl mt-2 p-2 text-sm text-immich-primary dark:text-immich-dark-primary"
value={person.name}
placeholder={$t('add_a_name')}
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
onfocusin={() => onNameChangeInputFocus(person)}
onfocusout={() => onNameChangeSubmit(newName, person)}
oninput={(event) => onNameChangeInputUpdate(event)}
onUpdate={(newValue) => onNameChangeSubmit(newValue, person)}
/>
</div>
{/snippet}
Expand Down
Loading