mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1217,7 +1217,8 @@
|
||||
"undo": "تراجع",
|
||||
"discard_draft": "تجاهل المسودة",
|
||||
"save_failed_close": "تعذّر حفظ المسودة. حاول مرة أخرى أو تجاهلها.",
|
||||
"try_again": "حاول مرة أخرى"
|
||||
"try_again": "حاول مرة أخرى",
|
||||
"add_description": "إضافة وصف"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "تأكيد",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1217,7 +1217,8 @@
|
||||
"undo": "واگرد",
|
||||
"discard_draft": "دور انداختن پیشنویس",
|
||||
"save_failed_close": "پیشنویس ذخیره نشد. دوباره تلاش کنید یا آن را دور بیندازید.",
|
||||
"try_again": "تلاش دوباره"
|
||||
"try_again": "تلاش دوباره",
|
||||
"add_description": "افزودن توضیح"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "تأیید",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1160,7 +1160,8 @@
|
||||
"undo": "ביטול",
|
||||
"discard_draft": "מחיקת הטיוטה",
|
||||
"save_failed_close": "לא ניתן היה לשמור את הטיוטה. נסו שוב או מחקו אותה.",
|
||||
"try_again": "ניסיון נוסף"
|
||||
"try_again": "ניסיון נוסף",
|
||||
"add_description": "הוספת תיאור"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "אשר",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1217,7 +1217,8 @@
|
||||
"undo": "元に戻す",
|
||||
"discard_draft": "下書きを破棄",
|
||||
"save_failed_close": "下書きを保存できませんでした。もう一度試すか、破棄してください。",
|
||||
"try_again": "再試行"
|
||||
"try_again": "再試行",
|
||||
"add_description": "説明を追加"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "確認",
|
||||
|
||||
@@ -1217,7 +1217,8 @@
|
||||
"undo": "실행 취소",
|
||||
"discard_draft": "임시 보관 메일 삭제",
|
||||
"save_failed_close": "임시 보관 메일을 저장하지 못했습니다. 다시 시도하거나 삭제하세요.",
|
||||
"try_again": "다시 시도"
|
||||
"try_again": "다시 시도",
|
||||
"add_description": "설명 추가"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "확인",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1217,7 +1217,8 @@
|
||||
"undo": "Буцаах",
|
||||
"discard_draft": "Ноорог устгах",
|
||||
"save_failed_close": "Ноорогийг хадгалж чадсангүй. Дахин оролдох эсвэл устгана уу.",
|
||||
"try_again": "Дахин оролдох"
|
||||
"try_again": "Дахин оролдох",
|
||||
"add_description": "Тайлбар нэмэх"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Баталгаажуулах",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ź",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1217,7 +1217,8 @@
|
||||
"undo": "Отменить",
|
||||
"discard_draft": "Удалить черновик",
|
||||
"save_failed_close": "Не удалось сохранить черновик. Попробуйте ещё раз или удалите его.",
|
||||
"try_again": "Повторить"
|
||||
"try_again": "Повторить",
|
||||
"add_description": "Добавить описание"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Подтвердить",
|
||||
|
||||
@@ -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ť",
|
||||
|
||||
@@ -1565,7 +1565,8 @@
|
||||
"undo": "เลิกทำ",
|
||||
"discard_draft": "ทิ้งฉบับร่าง",
|
||||
"save_failed_close": "บันทึกฉบับร่างไม่ได้ ลองอีกครั้งหรือทิ้งไป",
|
||||
"try_again": "ลองอีกครั้ง"
|
||||
"try_again": "ลองอีกครั้ง",
|
||||
"add_description": "เพิ่มคำอธิบาย"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1217,7 +1217,8 @@
|
||||
"undo": "Скасувати",
|
||||
"discard_draft": "Видалити чернетку",
|
||||
"save_failed_close": "Не вдалося зберегти чернетку. Спробуйте ще раз або видаліть її.",
|
||||
"try_again": "Спробувати ще раз"
|
||||
"try_again": "Спробувати ще раз",
|
||||
"add_description": "Додати опис"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Підтвердити",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1217,7 +1217,8 @@
|
||||
"undo": "復原",
|
||||
"discard_draft": "捨棄草稿",
|
||||
"save_failed_close": "無法儲存草稿。請再試一次或捨棄草稿。",
|
||||
"try_again": "再試一次"
|
||||
"try_again": "再試一次",
|
||||
"add_description": "新增描述"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "確認",
|
||||
|
||||
@@ -1217,7 +1217,8 @@
|
||||
"undo": "撤销",
|
||||
"discard_draft": "舍弃草稿",
|
||||
"save_failed_close": "无法保存草稿。请重试或舍弃草稿。",
|
||||
"try_again": "重试"
|
||||
"try_again": "重试",
|
||||
"add_description": "添加描述"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "确认",
|
||||
|
||||
Reference in New Issue
Block a user