fix: insert pasted and dropped images without a dialog, with an optional Add description chip

The image description tests now open the dialog from the chip on the image, since a drop no longer opens it by itself.
This commit is contained in:
Linus Rath
2026-11-04 08:36:25 +01:00
parent ad0b238e3d
commit 472ee53d88
34 changed files with 125 additions and 37 deletions
@@ -164,8 +164,11 @@ describe('RichTextEditor image descriptions (MA-02)', () => {
const altDialog = () => screen.getByRole('dialog', { name: 'title' });
const html = (editor: Editor) => editor.getHTML();
it('asks for a description when an image is dropped in, and never uses the file name', async () => {
it('offers a description for a dropped image, and never uses the file name', async () => {
const { editor } = await dropImage();
// Nothing asks on the drop; the chip on the image does, when clicked.
expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull();
fireEvent.click(await screen.findByTestId('image-alt-add'));
const dialog = await waitFor(() => altDialog());
expect(html(editor)).not.toContain('IMG_2041');
expect(html(editor)).not.toMatch(/alt=/);
@@ -178,6 +181,9 @@ describe('RichTextEditor image descriptions (MA-02)', () => {
it('marks an image decorative with an empty alt', async () => {
const { editor } = await dropImage();
// Nothing asks on the drop; the chip on the image does, when clicked.
expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull();
fireEvent.click(await screen.findByTestId('image-alt-add'));
const dialog = await waitFor(() => altDialog());
fireEvent.click(within(dialog).getByLabelText('decorative'));
expect(within(dialog).getByLabelText('label')).toBeDisabled();
@@ -1,4 +1,4 @@
import { act, render, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import React from 'react';
import type { Editor } from '@tiptap/core';
@@ -72,4 +72,33 @@ describe('RichTextEditor file drop', () => {
expect(editor.getText()).toBe('Hello');
expect(onDrop).not.toHaveBeenCalled();
});
// A pasted or dropped image asks nothing: an optional chip on it offers a description.
it('opens no dialog for a dropped image, and offers "Add description" on it', async () => {
const { editor } = await setup(async () => ({ src: 'data:image/png;base64,AAAA' }));
const file = new File(['png'], 'photo.png', { type: 'image/png' });
act(() => { editor.view.dom.dispatchEvent(fileDrop(file)); });
const chip = await screen.findByTestId('image-alt-add');
expect(chip).toHaveTextContent('add_description');
expect(screen.queryByRole('dialog')).toBeNull();
fireEvent.click(chip);
expect(await screen.findByRole('dialog')).toBeInTheDocument();
});
it('shows no chip on an image marked decorative', async () => {
let editor: Editor | null = null;
render(
<RichTextEditor
content={'<p>Hi <img src="data:image/png;base64,AAAA" alt=""></p>'}
onChange={() => {}}
onImageUpload={async () => null}
onEditorReady={(ed) => { editor = ed as unknown as Editor; }}
/>
);
await waitFor(() => expect(editor).not.toBeNull());
expect(screen.queryByTestId('image-alt-add')).toBeNull();
});
});
+23 -2
View File
@@ -12,10 +12,14 @@ export interface ResizableImageOptions {
onEditAlt?: (pos: number) => void;
/** The name of the button that does it ("Image description"). */
editAltLabel?: string;
/** The chip on an image that has no description yet ("Add description"). */
addAltLabel?: string;
}
function ResizableImageView({ node, updateAttributes, selected, extension, getPos }: NodeViewProps) {
const { onEditAlt, editAltLabel } = extension.options as ResizableImageOptions;
const { onEditAlt, editAltLabel, addAltLabel } = extension.options as ResizableImageOptions;
// No description and not marked decorative (""): an optional chip asks for one.
const undescribed = node.attrs.alt === null || node.attrs.alt === undefined;
const imgRef = useRef<HTMLImageElement>(null);
const [resizing, setResizing] = useState(false);
// A remote image (a draft from another client, pasted web content) stays
@@ -93,6 +97,23 @@ function ResizableImageView({ node, updateAttributes, selected, extension, getPo
style={{ width: "100%", height: "auto", display: "block" }}
draggable={false}
/>
{undescribed && onEditAlt && addAltLabel && !selected && (
// Optional, never in the way: pasting or dropping an image asks nothing.
<button
type="button"
contentEditable={false}
data-testid="image-alt-add"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
const pos = typeof getPos === "function" ? getPos() : undefined;
if (typeof pos === "number") onEditAlt(pos);
}}
className="absolute bottom-1 start-1 rounded-full bg-background/90 px-2 py-0.5 text-xs font-medium text-foreground shadow border border-border hover:bg-background"
>
{addAltLabel}
</button>
)}
{selected && onEditAlt && (
// Edit what the image says to screen readers. Reachable with Tab
// while the image is selected, and from the toolbar.
@@ -140,7 +161,7 @@ export const ResizableImage = Node.create<ResizableImageOptions>({
name: "image",
addOptions() {
return { onEditAlt: undefined, editAltLabel: undefined };
return { onEditAlt: undefined, editAltLabel: undefined, addAltLabel: undefined };
},
group: "inline",
inline: true,
+5 -3
View File
@@ -471,6 +471,7 @@ export function RichTextEditor({
}: RichTextEditorProps) {
const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
const tComposer = useTranslations("email_composer");
const tUx = useTranslations("compose_ux");
const tQuote = useTranslations("email_viewer");
const onImageUploadRef = React.useRef(onImageUpload);
onImageUploadRef.current = onImageUpload;
@@ -521,6 +522,7 @@ export function RichTextEditor({
ResizableImage.configure({
onEditAlt: (pos: number) => setAltQueue((queue) => [...queue, { pos }]),
editAltLabel: tComposer("image_alt.edit"),
addAltLabel: tUx("add_description"),
}),
Placeholder.configure({
placeholder,
@@ -615,11 +617,11 @@ export function RichTextEditor({
if (result) {
const { state } = view;
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
// No alt yet (never the file name): the user is asked next.
// No alt yet (never the file name).
const node = state.schema.nodes.image.create({ src: result.src, alt: null, cid: result.cid });
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
view.dispatch(tr);
setAltQueue((queue) => [...queue, { src: result.src }]);
// No dialog: the image offers "Add description" itself.
}
});
}
@@ -638,7 +640,7 @@ export function RichTextEditor({
const node = state.schema.nodes.image.create({ src: result.src, alt: null, cid: result.cid });
const tr = state.tr.replaceSelectionWith(node);
view.dispatch(tr);
setAltQueue((queue) => [...queue, { src: result.src }]);
// No dialog: the image offers "Add description" itself.
}
});
}
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "تراجع",
"discard_draft": "تجاهل المسودة",
"save_failed_close": "تعذّر حفظ المسودة. حاول مرة أخرى أو تجاهلها.",
"try_again": "حاول مرة أخرى"
"try_again": "حاول مرة أخرى",
"add_description": "إضافة وصف"
},
"confirm_dialog": {
"confirm": "تأكيد",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Desfés",
"discard_draft": "Descarta l'esborrany",
"save_failed_close": "No s'ha pogut desar l'esborrany. Torneu-ho a provar o descarteu-lo.",
"try_again": "Torna-ho a provar"
"try_again": "Torna-ho a provar",
"add_description": "Afegeix una descripció"
},
"confirm_dialog": {
"confirm": "Confirma",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Zpět",
"discard_draft": "Zahodit koncept",
"save_failed_close": "Koncept se nepodařilo uložit. Zkuste to znovu, nebo ho zahoďte.",
"try_again": "Zkusit znovu"
"try_again": "Zkusit znovu",
"add_description": "Přidat popis"
},
"confirm_dialog": {
"confirm": "Potvrdit",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Fortryd",
"discard_draft": "Kassér kladde",
"save_failed_close": "Kladden kunne ikke gemmes. Prøv igen, eller kassér den.",
"try_again": "Prøv igen"
"try_again": "Prøv igen",
"add_description": "Tilføj beskrivelse"
},
"confirm_dialog": {
"confirm": "Bekræft",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Rückgängig",
"discard_draft": "Entwurf verwerfen",
"save_failed_close": "Der Entwurf konnte nicht gespeichert werden. Versuchen Sie es erneut oder verwerfen Sie ihn.",
"try_again": "Erneut versuchen"
"try_again": "Erneut versuchen",
"add_description": "Beschreibung hinzufügen"
},
"confirm_dialog": {
"confirm": "Bestätigen",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Undo",
"discard_draft": "Discard draft",
"save_failed_close": "The draft could not be saved. Try again, or discard it.",
"try_again": "Try again"
"try_again": "Try again",
"add_description": "Add description"
},
"confirm_dialog": {
"confirm": "Confirm",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Deshacer",
"discard_draft": "Descartar borrador",
"save_failed_close": "No se pudo guardar el borrador. Inténtelo de nuevo o descártelo.",
"try_again": "Reintentar"
"try_again": "Reintentar",
"add_description": "Añadir descripción"
},
"confirm_dialog": {
"confirm": "Confirmar",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Võta tagasi",
"discard_draft": "Loobu mustandist",
"save_failed_close": "Mustandit ei saanud salvestada. Proovige uuesti või loobuge sellest.",
"try_again": "Proovi uuesti"
"try_again": "Proovi uuesti",
"add_description": "Lisa kirjeldus"
},
"confirm_dialog": {
"confirm": "Kinnita",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "واگرد",
"discard_draft": "دور انداختن پیش‌نویس",
"save_failed_close": "پیش‌نویس ذخیره نشد. دوباره تلاش کنید یا آن را دور بیندازید.",
"try_again": "تلاش دوباره"
"try_again": "تلاش دوباره",
"add_description": "افزودن توضیح"
},
"confirm_dialog": {
"confirm": "تأیید",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Annuler",
"discard_draft": "Supprimer le brouillon",
"save_failed_close": "Le brouillon n'a pas pu être enregistré. Réessayez ou supprimez-le.",
"try_again": "Réessayer"
"try_again": "Réessayer",
"add_description": "Ajouter une description"
},
"confirm_dialog": {
"confirm": "Confirmer",
+2 -1
View File
@@ -1160,7 +1160,8 @@
"undo": "ביטול",
"discard_draft": "מחיקת הטיוטה",
"save_failed_close": "לא ניתן היה לשמור את הטיוטה. נסו שוב או מחקו אותה.",
"try_again": "ניסיון נוסף"
"try_again": "ניסיון נוסף",
"add_description": "הוספת תיאור"
},
"confirm_dialog": {
"confirm": "אשר",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Visszavonás",
"discard_draft": "Piszkozat elvetése",
"save_failed_close": "A piszkozatot nem sikerült menteni. Próbálja újra, vagy vesse el.",
"try_again": "Újra"
"try_again": "Újra",
"add_description": "Leírás hozzáadása"
},
"confirm_dialog": {
"confirm": "Megerősítés",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Annulla",
"discard_draft": "Elimina bozza",
"save_failed_close": "Impossibile salvare la bozza. Riprova o eliminala.",
"try_again": "Riprova"
"try_again": "Riprova",
"add_description": "Aggiungi descrizione"
},
"confirm_dialog": {
"confirm": "Conferma",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "元に戻す",
"discard_draft": "下書きを破棄",
"save_failed_close": "下書きを保存できませんでした。もう一度試すか、破棄してください。",
"try_again": "再試行"
"try_again": "再試行",
"add_description": "説明を追加"
},
"confirm_dialog": {
"confirm": "確認",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "실행 취소",
"discard_draft": "임시 보관 메일 삭제",
"save_failed_close": "임시 보관 메일을 저장하지 못했습니다. 다시 시도하거나 삭제하세요.",
"try_again": "다시 시도"
"try_again": "다시 시도",
"add_description": "설명 추가"
},
"confirm_dialog": {
"confirm": "확인",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Atsaukt",
"discard_draft": "Atmest melnrakstu",
"save_failed_close": "Melnrakstu neizdevās saglabāt. Mēģiniet vēlreiz vai atmetiet to.",
"try_again": "Mēģināt vēlreiz"
"try_again": "Mēģināt vēlreiz",
"add_description": "Pievienot aprakstu"
},
"confirm_dialog": {
"confirm": "Apstiprināt",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Буцаах",
"discard_draft": "Ноорог устгах",
"save_failed_close": "Ноорогийг хадгалж чадсангүй. Дахин оролдох эсвэл устгана уу.",
"try_again": "Дахин оролдох"
"try_again": "Дахин оролдох",
"add_description": "Тайлбар нэмэх"
},
"confirm_dialog": {
"confirm": "Баталгаажуулах",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Angre",
"discard_draft": "Forkast utkast",
"save_failed_close": "Utkastet kunne ikke lagres. Prøv igjen, eller forkast det.",
"try_again": "Prøv igjen"
"try_again": "Prøv igjen",
"add_description": "Legg til beskrivelse"
},
"confirm_dialog": {
"confirm": "Bekreft",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Ongedaan maken",
"discard_draft": "Concept verwijderen",
"save_failed_close": "Het concept kon niet worden opgeslagen. Probeer het opnieuw of verwijder het.",
"try_again": "Opnieuw proberen"
"try_again": "Opnieuw proberen",
"add_description": "Beschrijving toevoegen"
},
"confirm_dialog": {
"confirm": "Bevestigen",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Cofnij",
"discard_draft": "Odrzuć wersję roboczą",
"save_failed_close": "Nie udało się zapisać wersji roboczej. Spróbuj ponownie lub ją odrzuć.",
"try_again": "Spróbuj ponownie"
"try_again": "Spróbuj ponownie",
"add_description": "Dodaj opis"
},
"confirm_dialog": {
"confirm": "Potwierdź",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Desfazer",
"discard_draft": "Descartar rascunho",
"save_failed_close": "Não foi possível guardar o rascunho. Tente novamente ou descarte-o.",
"try_again": "Tentar novamente"
"try_again": "Tentar novamente",
"add_description": "Adicionar descrição"
},
"confirm_dialog": {
"confirm": "Confirmar",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Anulează",
"discard_draft": "Renunță la ciornă",
"save_failed_close": "Ciorna nu a putut fi salvată. Încercați din nou sau renunțați la ea.",
"try_again": "Încearcă din nou"
"try_again": "Încearcă din nou",
"add_description": "Adaugă o descriere"
},
"confirm_dialog": {
"confirm": "Confirmare",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Отменить",
"discard_draft": "Удалить черновик",
"save_failed_close": "Не удалось сохранить черновик. Попробуйте ещё раз или удалите его.",
"try_again": "Повторить"
"try_again": "Повторить",
"add_description": "Добавить описание"
},
"confirm_dialog": {
"confirm": "Подтвердить",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Späť",
"discard_draft": "Zahodiť koncept",
"save_failed_close": "Koncept sa nepodarilo uložiť. Skúste to znova alebo ho zahoďte.",
"try_again": "Skúsiť znova"
"try_again": "Skúsiť znova",
"add_description": "Pridať popis"
},
"confirm_dialog": {
"confirm": "Potvrdiť",
+2 -1
View File
@@ -1565,7 +1565,8 @@
"undo": "เลิกทำ",
"discard_draft": "ทิ้งฉบับร่าง",
"save_failed_close": "บันทึกฉบับร่างไม่ได้ ลองอีกครั้งหรือทิ้งไป",
"try_again": "ลองอีกครั้ง"
"try_again": "ลองอีกครั้ง",
"add_description": "เพิ่มคำอธิบาย"
},
"email_viewer": {
"read_receipt": {
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Geri al",
"discard_draft": "Taslağı sil",
"save_failed_close": "Taslak kaydedilemedi. Yeniden deneyin ya da silin.",
"try_again": "Yeniden dene"
"try_again": "Yeniden dene",
"add_description": "Açıklama ekle"
},
"confirm_dialog": {
"confirm": "Onayla",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "Скасувати",
"discard_draft": "Видалити чернетку",
"save_failed_close": "Не вдалося зберегти чернетку. Спробуйте ще раз або видаліть її.",
"try_again": "Спробувати ще раз"
"try_again": "Спробувати ще раз",
"add_description": "Додати опис"
},
"confirm_dialog": {
"confirm": "Підтвердити",
+2 -1
View File
@@ -1229,7 +1229,8 @@
"undo": "Hoàn tác",
"discard_draft": "Hủy bản nháp",
"save_failed_close": "Không lưu được bản nháp. Hãy thử lại hoặc hủy bản nháp.",
"try_again": "Thử lại"
"try_again": "Thử lại",
"add_description": "Thêm mô tả"
},
"confirm_dialog": {
"confirm": "Xác nhận",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "復原",
"discard_draft": "捨棄草稿",
"save_failed_close": "無法儲存草稿。請再試一次或捨棄草稿。",
"try_again": "再試一次"
"try_again": "再試一次",
"add_description": "新增描述"
},
"confirm_dialog": {
"confirm": "確認",
+2 -1
View File
@@ -1217,7 +1217,8 @@
"undo": "撤销",
"discard_draft": "舍弃草稿",
"save_failed_close": "无法保存草稿。请重试或舍弃草稿。",
"try_again": "重试"
"try_again": "重试",
"add_description": "添加描述"
},
"confirm_dialog": {
"confirm": "确认",