Skip to content

Commit 8d18b11

Browse files
IlanPincnxsolutions
andcommitted
FEATURE: Delete a picture from a document (closes #187).
Co-authored-by: Nasschml <nassim.chemlal@utt.fr>
1 parent 2d621df commit 8d18b11

3 files changed

Lines changed: 191 additions & 24 deletions

File tree

frontend/src/components/EditableText.jsx

Lines changed: 151 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import '../styles/EditableText.css';
22

3-
import { useState, useEffect, useCallback } from 'react';
3+
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
44
import FormattedText from './FormattedText';
55
import DiscreeteDropdown from './DiscreeteDropdown';
66
import PictureUploadAction from '../menu-items/PictureUploadAction';
@@ -10,13 +10,22 @@ function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment,
1010
const [beingEdited, setBeingEdited] = useState(false);
1111
const [editedDocument, setEditedDocument] = useState();
1212
const [editedText, setEditedText] = useState();
13-
const PASSAGE = new RegExp(`\\{${rubric}} ?([^{]*)`);
13+
const [showDeleteModal, setShowDeleteModal] = useState(false);
14+
const [deleteTarget, setDeleteTarget] = useState({ src: '', internal: false, name: '' });
15+
const containerRef = useRef(null);
16+
const PASSAGE = useMemo(() => new RegExp(`\\{${rubric}} ?([^\\{]*)`), [rubric]);
1417

15-
let parsePassage = (rawText) => (rubric)
16-
? rawText.match(PASSAGE)[1]
17-
: rawText;
18+
let parsePassage = (rawText) => {
19+
if (!rawText) return '';
20+
if (rubric) {
21+
const m = rawText.match(PASSAGE);
22+
return m ? m[1] : '';
23+
}
24+
return rawText;
25+
};
1826

1927
let parseFirstPassage = useCallback((rawText) => {
28+
if (!rawText) return '';
2029
const FIRST_PASSAGE = new RegExp('\\{[^}]+} ?([^{]*)');
2130
let parsed = rawText.match(FIRST_PASSAGE);
2231
return (parsed) ? parsed[1] : rawText;
@@ -79,24 +88,147 @@ function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment,
7988
.catch(console.error);
8089
};
8190

91+
useEffect(() => {
92+
const attachTrash = () => {
93+
const root = containerRef.current;
94+
if (!root) return;
95+
root.querySelectorAll('figure').forEach(fig => {
96+
if (fig.querySelector('.trash-overlay')) return;
97+
const img = fig.querySelector('img');
98+
if (!img) return;
99+
const trash = document.createElement('div');
100+
trash.className = 'trash-overlay';
101+
trash.innerHTML = `
102+
<svg viewBox="0 0 16 16">
103+
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z"/>
104+
<path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1z"/>
105+
</svg>`;
106+
Object.assign(trash.style, {
107+
position: 'absolute',
108+
bottom: '10px',
109+
right: '10px',
110+
width: '24px',
111+
height: '24px',
112+
background: 'rgba(0,0,0,0.6)',
113+
display: 'flex',
114+
alignItems: 'center',
115+
justifyContent: 'center',
116+
cursor: 'pointer',
117+
opacity: '0',
118+
transition: 'opacity 0.2s ease',
119+
zIndex: '10'
120+
});
121+
fig.style.position = 'relative';
122+
fig.appendChild(trash);
123+
fig.addEventListener('mouseenter', () => (trash.style.opacity = '1'));
124+
fig.addEventListener('mouseleave', () => (trash.style.opacity = '0'));
125+
trash.addEventListener('click', () => {
126+
const src = img.src;
127+
const internal = src.includes(`/${id}/`);
128+
const name = internal
129+
? decodeURIComponent(src.split(`${id}/`)[1])
130+
: src;
131+
setDeleteTarget({ src, internal, name });
132+
setShowDeleteModal(true);
133+
});
134+
});
135+
};
136+
137+
const obs = new MutationObserver(attachTrash);
138+
if (containerRef.current) {
139+
obs.observe(containerRef.current, { childList: true, subtree: true });
140+
}
141+
attachTrash();
142+
return () => obs.disconnect();
143+
}, [backend, id, setLastUpdate, beingEdited]);
144+
145+
const confirmDelete = () => {
146+
const { src, internal, name } = deleteTarget;
147+
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
148+
const pattern = esc(src);
149+
const mdRx = new RegExp(`!?\\[[^\\]]*\\]\\(${pattern}\\)`, 'g');
150+
const clean = t => (t || '').replace(mdRx, '').replace(/\n{2,}/g, '\n\n').trim();
151+
152+
if (internal) {
153+
backend.deleteAttachment(id, name, res => {
154+
if (!res.ok) return alert('Erreur lors de la suppression.');
155+
backend.getDocument(id).then(doc => {
156+
const cleaned = clean(doc.text);
157+
backend.putDocument({ ...doc, text: cleaned }).then(r => {
158+
setEditedText(cleaned);
159+
setEditedDocument({ ...doc, text: cleaned, _rev: r.rev });
160+
setLastUpdate(r.rev);
161+
setShowDeleteModal(false);
162+
});
163+
});
164+
});
165+
} else {
166+
const cleaned = clean(editedText);
167+
setEditedText(cleaned);
168+
setEditedDocument(p => ({ ...p, text: cleaned }));
169+
setShowDeleteModal(false);
170+
}
171+
};
172+
82173
if (!beingEdited) return (
83-
<div className="editable content position-relative" title="Edit content...">
84-
<div className="formatted-text" onClick={handleClick}>
85-
<FormattedText {...{setHighlightedText, setSelectedText}}>
86-
{text || '&nbsp;'}
87-
</FormattedText>
174+
<>
175+
<div className="editable content position-relative" title="Edit content...">
176+
<div className="formatted-text" onClick={handleClick} ref={containerRef}>
177+
<FormattedText {...{setHighlightedText, setSelectedText}}>
178+
{text || '&nbsp;'}
179+
</FormattedText>
180+
</div>
181+
<DiscreeteDropdown>
182+
<PictureUploadAction {...{id, backend, handleImageUrl}}/>
183+
</DiscreeteDropdown>
88184
</div>
89-
<DiscreeteDropdown>
90-
<PictureUploadAction {... {id, backend, handleImageUrl}}/>
91-
</DiscreeteDropdown>
92-
</div>
185+
{showDeleteModal && (
186+
<div className="modal fade show d-block" tabIndex="-1" role="dialog">
187+
<div className="modal-dialog" role="document">
188+
<div className="modal-content">
189+
<div className="modal-header">
190+
<h5 className="modal-title">Confirmer la suppression</h5>
191+
<button type="button" className="btn-close" onClick={() => setShowDeleteModal(false)} />
192+
</div>
193+
<div className="modal-body">
194+
<p>Supprimer l’image {deleteTarget.internal ? `"${deleteTarget.name}"` : 'externe'} ?</p>
195+
</div>
196+
<div className="modal-footer">
197+
<button type="button" className="btn btn-secondary" onClick={() => setShowDeleteModal(false)}>Annuler</button>
198+
<button type="button" className="btn btn-danger" onClick={confirmDelete}>Supprimer</button>
199+
</div>
200+
</div>
201+
</div>
202+
</div>
203+
)}
204+
</>
93205
);
206+
94207
return (
95-
<form>
96-
<textarea className="form-control" type="text" rows="5" autoFocus
97-
value={editedText} onChange={handleChange} onBlur={handleBlur}
98-
/>
99-
</form>
208+
<>
209+
<form>
210+
<textarea className="form-control" rows={5} autoFocus value={editedText} onChange={handleChange} onBlur={handleBlur} />
211+
</form>
212+
{showDeleteModal && (
213+
<div className="modal fade show d-block" tabIndex="-1" role="dialog">
214+
<div className="modal-dialog" role="document">
215+
<div className="modal-content">
216+
<div className="modal-header">
217+
<h5 className="modal-title">Confirmer la suppression</h5>
218+
<button type="button" className="btn-close" onClick={() => setShowDeleteModal(false)} />
219+
</div>
220+
<div className="modal-body">
221+
<p>Supprimer l’image {deleteTarget.internal ? `"${deleteTarget.name}"` : 'externe'} ?</p>
222+
</div>
223+
<div className="modal-footer">
224+
<button type="button" className="btn btn-secondary" onClick={() => setShowDeleteModal(false)}>Annuler</button>
225+
<button type="button" className="btn btn-danger" onClick={confirmDelete}>Supprimer</button>
226+
</div>
227+
</div>
228+
</div>
229+
</div>
230+
)}
231+
</>
100232
);
101233
}
102234

frontend/src/hyperglosae.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ function Hyperglosae(logger) {
3333
return x;
3434
});
3535

36-
this.deleteDocument = ({_id, _rev}) =>
36+
this.deleteDocument = ({ _id, _rev }) =>
3737
fetch(`${service}/${_id}?rev=${_rev}`, {
3838
method: 'DELETE',
3939
})
@@ -70,12 +70,23 @@ function Hyperglosae(logger) {
7070
};
7171
});
7272

73+
this.deleteAttachment = (id, attachmentName, callback) =>
74+
this.getDocumentMetadata(id).then(headRes => {
75+
fetch(`${service}/${id}/${encodeURIComponent(attachmentName)}`, {
76+
method: 'DELETE',
77+
headers: {
78+
'If-Match': headRes.headers.get('ETag'),
79+
'Accept': 'application/json'
80+
}
81+
}).then(response => callback(response));
82+
});
83+
7384
this.getSession = () =>
7485
fetch(`${service}/_session`)
7586
.then(x => x.json())
7687
.then(x => x.userCtx?.name);
7788

78-
this.postSession = ({name, password}) =>
89+
this.postSession = ({ name, password }) =>
7990
fetch(`${service}/_session`, {
8091
method: 'POST',
8192
headers: {
@@ -92,7 +103,7 @@ function Hyperglosae(logger) {
92103
});
93104

94105
this.refreshMetadata = (id, callback) => {
95-
this.getView({view: 'metadata', id, options: ['include_docs']})
106+
this.getView({ view: 'metadata', id, options: ['include_docs'] })
96107
.then(
97108
(rows) => {
98109
let documents = rows.map(x => x.doc).filter(x => x);
@@ -102,7 +113,7 @@ function Hyperglosae(logger) {
102113
};
103114

104115
this.refreshContent = (id, callback) => {
105-
this.getView({view: 'content', id, options: ['include_docs']})
116+
this.getView({ view: 'content', id, options: ['include_docs'] })
106117
.then(
107118
(rows) => {
108119
callback(rows);
@@ -114,7 +125,7 @@ function Hyperglosae(logger) {
114125
};
115126

116127
this.getAllDocuments = (user) =>
117-
this.getView({view: 'all_documents', id: user || 'PUBLIC', options: ['include_docs']})
128+
this.getView({ view: 'all_documents', id: user || 'PUBLIC', options: ['include_docs'] })
118129
.then((rows) => rows.map(x => x.doc));
119130

120131
return this;

frontend/src/styles/EditableText.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,28 @@
1212
border-color: black;
1313
}
1414

15+
.trash-overlay {
16+
position: absolute;
17+
bottom: 10px;
18+
right: 10px;
19+
background-color: rgba(0, 0, 0, 0.6);
20+
width: 24px;
21+
height: 24px;
22+
display: flex;
23+
align-items: center;
24+
justify-content: center;
25+
cursor: pointer;
26+
opacity: 0;
27+
transition: opacity 0.2s ease;
28+
z-index: 10;
29+
}
1530

31+
figure.has-trash-overlay:hover .trash-overlay {
32+
opacity: 1;
33+
}
34+
35+
.trash-overlay svg {
36+
width: 16px;
37+
height: 16px;
38+
fill: white;
39+
}

0 commit comments

Comments
 (0)