diff --git a/frontend/scenarios/delete_image_in_document.feature b/frontend/scenarios/delete_image_in_document.feature new file mode 100644 index 00000000..b603cc41 --- /dev/null +++ b/frontend/scenarios/delete_image_in_document.feature @@ -0,0 +1,11 @@ +#language: fr + +Fonctionnalité: Supprimer à l'intérieur d'un document une image + + Scénario: + + Soit le document contenant l'image "graphique" affiché comme document principal + Et une session active avec mon compte + Quand j'essaie de supprimer l'image "" + Alors je ne vois plus l'image "" dans la glose + Et je vois l'image "graphique" dans la glose \ No newline at end of file diff --git a/features/upload_image_in_document.feature b/frontend/scenarios/upload_image_in_document.feature similarity index 100% rename from features/upload_image_in_document.feature rename to frontend/scenarios/upload_image_in_document.feature diff --git a/frontend/src/components/EditableText.jsx b/frontend/src/components/EditableText.jsx index cd151a32..2d2fff03 100644 --- a/frontend/src/components/EditableText.jsx +++ b/frontend/src/components/EditableText.jsx @@ -5,11 +5,14 @@ import FormattedText from './FormattedText'; import DiscreeteDropdown from './DiscreeteDropdown'; import PictureUploadAction from '../menu-items/PictureUploadAction'; import {v4 as uuid} from 'uuid'; +import ImagesWithDelete from './ImageDisplay'; function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment, setHighlightedText, setSelectedText, backend, setLastUpdate}) { const [beingEdited, setBeingEdited] = useState(false); const [editedDocument, setEditedDocument] = useState(); const [editedText, setEditedText] = useState(); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deleteTarget, setDeleteTarget] = useState({ src: '', alt: '', internal: false, name: '' }); const PASSAGE = new RegExp(`\\{${rubric}} ?([^{]*)`); let parsePassage = (rawText) => (rubric) @@ -79,16 +82,85 @@ function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment, .catch(console.error); }; + const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; + let images = []; + let textWithoutInternalImages = text || ''; + let match; + + while ((match = imageRegex.exec(text || '')) !== null) { + const alt = match[1]; + const src = match[2]; + const isInternal = src.includes(`/${id}/`); + if (isInternal) { + images.push({ alt, src }); + const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const mdRx = new RegExp(`!\\[${esc(alt)}\\]\\(${esc(src)}\\)`, 'g'); + textWithoutInternalImages = textWithoutInternalImages.replace(mdRx, ''); + } + } + textWithoutInternalImages = textWithoutInternalImages.replace(/\n{2,}/g, '\n\n').trim(); + + const confirmDelete = () => { + const { src, alt, internal, name } = deleteTarget; + const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const mdRx = new RegExp(`!\\[${esc(alt)}\\]\\(${esc(src)}\\)`, 'g'); + const clean = t => (t || '').replace(mdRx, '').replace(/\n{2,}/g, '\n\n').trim(); + if (internal) { + backend.deleteAttachment(id, name, res => { + if (!res.ok) return; + backend.getDocument(id).then(doc => { + const cleaned = clean(doc.text); + backend.putDocument({ ...doc, text: cleaned }).then(r => { + setEditedText(cleaned); + setEditedDocument({ ...doc, text: cleaned, _rev: r.rev }); + setLastUpdate(r.rev); + setShowDeleteModal(false); + }); + }); + }); + } else { + const cleaned = clean(editedText); + setEditedText(cleaned); + setEditedDocument(p => ({ ...p, text: cleaned })); + setShowDeleteModal(false); + } + }; + if (!beingEdited) return (
- {text || ' '} + {textWithoutInternalImages || ' '} +
+ {showDeleteModal && ( +
+
+
+
+
Confirm deletion
+
+
+

Delete image {deleteTarget.internal ? `"${deleteTarget.name}"` : 'external'}?

+
+
+ + +
+
+
+
+ )}
); return ( diff --git a/frontend/src/components/ImageDisplay.jsx b/frontend/src/components/ImageDisplay.jsx new file mode 100644 index 00000000..3b2ebbfd --- /dev/null +++ b/frontend/src/components/ImageDisplay.jsx @@ -0,0 +1,40 @@ +import { Trash } from 'react-bootstrap-icons'; + +function ImagesWithDelete({ id, images, setDeleteTarget, setShowDeleteModal }) { + return ( + <> + {images.map(({ src, alt }) => ( +
+ {alt} + +
+ ))} + + ); +} + +export default ImagesWithDelete; diff --git a/frontend/src/hyperglosae.js b/frontend/src/hyperglosae.js index 6d24c800..80d70437 100644 --- a/frontend/src/hyperglosae.js +++ b/frontend/src/hyperglosae.js @@ -70,6 +70,17 @@ function Hyperglosae(logger) { }; }); + this.deleteAttachment = (id, attachmentName, callback) => + this.getDocumentMetadata(id).then(headRes => { + fetch(`${service}/${id}/${encodeURIComponent(attachmentName)}`, { + method: 'DELETE', + headers: { + 'If-Match': headRes.headers.get('ETag'), + 'Accept': 'application/json' + } + }).then(response => callback(response)); + }); + this.getSession = () => fetch(`${service}/_session`) .then(x => x.json()) diff --git a/frontend/src/styles/EditableText.css b/frontend/src/styles/EditableText.css index 5632ffd2..b74d5853 100644 --- a/frontend/src/styles/EditableText.css +++ b/frontend/src/styles/EditableText.css @@ -12,4 +12,34 @@ border-color: black; } +.trash-overlay { + position: absolute; + bottom: 10px; + right: 10px; + background-color: rgba(0, 0, 0, 0.6); + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 10; +} + +figure.has-trash-overlay:hover .trash-overlay { + opacity: 1; +} +.trash-overlay .bi-trash { + font-size: 22px; + color: white; + display: block; +} + + +figure:hover .trash-overlay, +.trash-overlay:focus { + opacity: 1; +} diff --git a/frontend/tests/context.js b/frontend/tests/context.js index dd8135a0..577d7bc3 100644 --- a/frontend/tests/context.js +++ b/frontend/tests/context.js @@ -102,4 +102,44 @@ Soit ("qui n'a pas de document source", () => { Soit ("qui a un document source", () => { cy.get('.sources').find('.card-body').should('exist'); -}); \ No newline at end of file +}); + +Soit("le document contenant l'image {string} affiché comme document principal", (alt) => { + cy.sign_in('alice','/'); + cy.create_glose(); + + context = cy.get('.scholium').eq(1); + cy.click_on_contextual_menu_item(context, 'Add a picture...'); + cy.get('[id="image-input"]').selectFile('../docs/component_bookshelf.png', { + force: true, + }) + + cy.get('img[alt=""]') + .should('not.be.visible') + + cy.click_on_text('content'); + cy.get('textarea') + .should('be.visible') + .should(($textarea) => { + expect($textarea.val().trim()).not.to.be.empty; + }) + .invoke('val') + .then((text) => { + const updatedText = text.replace(/\!\[.*?\]/, `![${alt}]`); + cy.get('textarea').clear().type(updatedText); + }); + cy.get('body').click(0, 0); + + cy.get('img[alt="graphique"]') + .should('be.visible') + + context = cy.get('.scholium').eq(1); + cy.click_on_contextual_menu_item(context, 'Add a picture...'); + cy.get('[id="image-input"]').selectFile('../docs/architecture.png', { + force: true, + }); + cy.get('img[alt=""]') + .should('be.visible') + cy.sign_out() +}); + diff --git a/frontend/tests/event.js b/frontend/tests/event.js index 2633156d..01f534f4 100644 --- a/frontend/tests/event.js +++ b/frontend/tests/event.js @@ -87,4 +87,20 @@ Quand("j'essaie d'ouvrir l'URI {string} reçue par courriel", (uri) => { Quand("je souhaite modifier le contenu du document principal", () => { cy.get('.icon.edit').click(); cy.click_on_text('content'); -}); \ No newline at end of file +}); + +Quand("j'essaye d'ajouter une image à une glose", () => { + context = cy.get('.scholium').eq(1); + cy.click_on_contextual_menu_item(context, 'Add a picture...'); + cy.get('[id="image-input"]').selectFile('../docs/architecture.png', { + force: true, + }); + cy.get('img[alt=""]',{ timeout: 10000 }) + .should('be.visible') +}); + +Quand("j'essaie de supprimer l'image {string}", (alt) => { + cy.get(`button.trash-overlay[aria-label="Delete image ${alt}"]`).scrollIntoView().click({ force: true }); + cy.contains('button', 'Delete').click(); +}); + diff --git a/frontend/tests/outcome.js b/frontend/tests/outcome.js index 325cdde0..18708fb7 100644 --- a/frontend/tests/outcome.js +++ b/frontend/tests/outcome.js @@ -13,7 +13,8 @@ Alors("je peux lire {string}", (text) => { }); Alors("je vois l'image {string} dans la glose", (alternative_text) => { - cy.get('.row:not(.runningHead)>.main').should('have.descendants', `img[alt='${alternative_text}']`); + cy.get(`img[alt="${alternative_text}"]`) + .should('be.visible'); }); Alors("{string} est le document principal", (title) => { @@ -100,4 +101,8 @@ Alors("la rubrique {string} est associée au passage {string}", (rubric, text) = Alors("{string} est la glose ouverte en mode édition", (title) => { cy.get('.runningHead .scholium').should('contain', title); cy.get('.scholium').should('have.descendants', 'form'); +}); + +Alors("je ne vois plus l'image {string} dans la glose", (alt) => { + cy.get('.row:not(.runningHead)>.scholium').should('not.have.descendants', `img[alt='${alt}']`); }); \ No newline at end of file