Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/scenarios/delete_image_in_document.feature
Original file line number Diff line number Diff line change
@@ -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 "<IMAGE DESCRIPTION>"
Alors je ne vois plus l'image "<IMAGE DESCRIPTION>" dans la glose
Et je vois l'image "graphique" dans la glose
74 changes: 73 additions & 1 deletion frontend/src/components/EditableText.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 (
<div className="editable content position-relative" title="Edit content...">
<div className="formatted-text" onClick={handleClick}>
<FormattedText {...{setHighlightedText, setSelectedText}}>
{text || '&nbsp;'}
{textWithoutInternalImages || '&nbsp;'}
</FormattedText>
<ImagesWithDelete
id={id}
images={images}
setDeleteTarget={setDeleteTarget}
setShowDeleteModal={setShowDeleteModal}
/>
</div>
<DiscreeteDropdown>
<PictureUploadAction {... {id, backend, handleImageUrl}}/>
</DiscreeteDropdown>
{showDeleteModal && (
<div className="modal fade show d-block" tabIndex="-1" role="dialog">
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Confirm deletion</h5>
<button type="button" className="btn-close" onClick={() => setShowDeleteModal(false)} />
</div>
<div className="modal-body">
<p>Delete image {deleteTarget.internal ? `"${deleteTarget.name}"` : 'external'}?</p>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowDeleteModal(false)}>Cancel</button>
<button type="button" className="btn btn-danger" onClick={confirmDelete}>Delete</button>
</div>
</div>
</div>
</div>
)}
</div>
);
return (
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/components/ImageDisplay.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Trash } from 'react-bootstrap-icons';

function ImagesWithDelete({ id, images, setDeleteTarget, setShowDeleteModal }) {
return (
<>
{images.map(({ src, alt }) => (
<figure
key={src + alt}
className="has-trash-overlay"
style={{ position: 'relative', display: 'inline-block' }}
>
<img
src={src}
alt={alt}
className="img-fluid rounded editable-image"
/>
<button
className="trash-overlay"
type="button"
aria-label={`Delete image ${alt || src}`}
title={`Delete image ${alt || src}`}
onClick={e => {
e.stopPropagation();
const internal = src.includes(`/${id}/`);
const name = internal
? decodeURIComponent(src.split(`${id}/`)[1])
: src;
setDeleteTarget({ src, alt, internal, name });
setShowDeleteModal(true);
}}
>
<Trash />
</button>
</figure>
))}
</>
);
}

export default ImagesWithDelete;
11 changes: 11 additions & 0 deletions frontend/src/hyperglosae.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/styles/EditableText.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
42 changes: 41 additions & 1 deletion frontend/tests/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

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="<IMAGE DESCRIPTION>"]')
.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="<IMAGE DESCRIPTION>"]')
.should('be.visible')
cy.sign_out()
});

18 changes: 17 additions & 1 deletion frontend/tests/event.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

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="<IMAGE DESCRIPTION>"]',{ 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();
});

7 changes: 6 additions & 1 deletion frontend/tests/outcome.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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}']`);
});