Skip to content

Commit 7a9c4be

Browse files
IlanPinnasschml
andcommitted
FEATURE: Delete a picture from a document (closes #187).
Co-authored-by: Nasschml <[email protected]>
1 parent 6078220 commit 7a9c4be

File tree

3 files changed

+319
-78
lines changed

3 files changed

+319
-78
lines changed
Lines changed: 279 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,103 +1,309 @@
1+
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
12
import '../styles/EditableText.css';
2-
3-
import { useState, useEffect, useCallback } from 'react';
43
import FormattedText from './FormattedText';
54
import DiscreeteDropdown from './DiscreeteDropdown';
65
import PictureUploadAction from '../menu-items/PictureUploadAction';
7-
import {v4 as uuid} from 'uuid';
6+
import { v4 as uuid } from 'uuid';
87

9-
function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment, setHighlightedText, setSelectedText, backend, setLastUpdate}) {
8+
export default function EditableText({
9+
id,
10+
text,
11+
rubric,
12+
isPartOf,
13+
links,
14+
fragment,
15+
setFragment,
16+
setHighlightedText,
17+
setSelectedText,
18+
backend,
19+
setLastUpdate
20+
}) {
1021
const [beingEdited, setBeingEdited] = useState(false);
11-
const [editedDocument, setEditedDocument] = useState();
12-
const [editedText, setEditedText] = useState();
13-
const PASSAGE = new RegExp(`\\{${rubric}} ?([^{]*)`);
22+
const [editedDocument, setEditedDocument] = useState(null);
23+
const [editedText, setEditedText] = useState('');
24+
const [showDeleteModal, setShowDeleteModal] = useState(false);
25+
const [deleteTarget, setDeleteTarget] = useState({ src: '', internal: false, name: '' });
26+
const containerRef = useRef(null);
1427

15-
let parsePassage = (rawText) => (rubric)
16-
? rawText.match(PASSAGE)[1]
17-
: rawText;
28+
// Memoized regex to extract our passage
29+
const PASSAGE = useMemo(() => new RegExp(`\\{${rubric}} ?([^\\{]*)`), [rubric]);
1830

19-
let parseFirstPassage = useCallback((rawText) => {
20-
const FIRST_PASSAGE = new RegExp('\\{[^}]+} ?([^{]*)');
21-
let parsed = rawText.match(FIRST_PASSAGE);
22-
return (parsed) ? parsed[1] : rawText;
31+
const parseFirstPassage = useCallback(raw => {
32+
const m = raw.match(/\{[^}]+} ?([^{]*)/);
33+
return m ? m[1] : raw;
2334
}, []);
2435

25-
let updateEditedDocument = useCallback(() => backend.getDocument(id)
26-
.then((x) => {
27-
x = x.error
28-
? {_id: uuid(), text: `{${rubric}}`, isPartOf, links}
29-
: x;
30-
setEditedDocument(x);
31-
return x;
32-
}), [backend, id, isPartOf, links, rubric]);
36+
const parsePassage = useCallback(raw => {
37+
if (!rubric) return raw;
38+
const m = raw.match(PASSAGE);
39+
return m ? m[1] : '';
40+
}, [PASSAGE, rubric]);
41+
42+
const updateEditedDocument = useCallback(() => {
43+
return backend.getDocument(id).then(doc => {
44+
if (doc.error) {
45+
doc = { _id: uuid(), text: `{${rubric}}`, isPartOf, links };
46+
}
47+
setEditedDocument(doc);
48+
return doc;
49+
});
50+
}, [backend, id, isPartOf, links, rubric]);
3351

52+
// incoming fragment
3453
useEffect(() => {
35-
if (fragment) {
36-
updateEditedDocument()
37-
.then((x) => {
38-
let existingText = parseFirstPassage(x.text);
39-
setEditedText((existingText && `${existingText}\n\n`) + fragment + '<COMMENT>');
40-
setBeingEdited(true);
41-
setFragment();
42-
});
43-
}
54+
if (!fragment) return;
55+
updateEditedDocument().then(doc => {
56+
const first = parseFirstPassage(doc.text);
57+
setEditedText((first ? `${first}\n\n` : '') + fragment + '<COMMENT>');
58+
setBeingEdited(true);
59+
setFragment();
60+
});
4461
}, [fragment, parseFirstPassage, setFragment, updateEditedDocument]);
4562

46-
let handleClick = () => {
63+
const handleClick = () => {
4764
setBeingEdited(true);
48-
updateEditedDocument()
49-
.then((x) => {
50-
setEditedText(parsePassage(x.text));
51-
});
65+
updateEditedDocument().then(doc => {
66+
setEditedText(parsePassage(doc.text));
67+
});
5268
};
5369

54-
let handleImageUrl = (imageTag) => {
55-
backend.getDocument(id).then((editedDocument) => {
56-
let parsedText = parsePassage(editedDocument.text) + imageTag;
57-
let text = (rubric)
58-
? editedDocument.text.replace(PASSAGE, `{${rubric}} ${parsedText}`)
59-
: parsedText;
60-
backend.putDocument({ ...editedDocument, text })
61-
.then(x => setLastUpdate(x.rev))
70+
const handleImageUrl = imageTag => {
71+
backend.getDocument(id).then(doc => {
72+
const parsed = parsePassage(doc.text) + imageTag;
73+
const updated = rubric
74+
? doc.text.replace(PASSAGE, `{${rubric}} ${parsed}`)
75+
: parsed;
76+
backend.putDocument({ ...doc, text: updated })
77+
.then(res => {
78+
setLastUpdate(res.rev);
79+
setEditedDocument({ ...doc, text: updated, _rev: res.rev });
80+
setEditedText(parsed);
81+
})
6282
.catch(console.error);
6383
});
6484
};
6585

66-
let handleChange = (event) => {
67-
setEditedText(event.target.value);
86+
const handleChange = e => {
87+
setEditedText(e.target.value);
6888
};
6989

70-
let handleBlur = () => {
71-
let parsedText = parseFirstPassage(editedText);
72-
let text = (rubric)
73-
? editedDocument.text.replace(PASSAGE, `{${rubric}} ${parsedText}`)
90+
const handleBlur = () => {
91+
const first = parseFirstPassage(editedText);
92+
const updated = rubric
93+
? editedDocument.text.replace(PASSAGE, `{${rubric}} ${first}`)
7494
: editedText;
75-
backend.putDocument({ ...editedDocument, text })
76-
.then(x => setLastUpdate(x.rev))
77-
.then(() => setHighlightedText())
78-
.then(() => setBeingEdited(false))
79-
.catch(console.error);
95+
backend.putDocument({ ...editedDocument, text: updated })
96+
.then(res => {
97+
setLastUpdate(res.rev);
98+
setEditedDocument({ ...editedDocument, text: updated, _rev: res.rev });
99+
setBeingEdited(false);
100+
setHighlightedText();
101+
})
102+
.catch(err => {
103+
if (err.message.includes('conflict')) {
104+
alert('Conflit de mise à jour. Rechargez.');
105+
} else {
106+
console.error(err);
107+
}
108+
});
80109
};
81110

82-
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>
88-
</div>
89-
<DiscreeteDropdown>
90-
<PictureUploadAction {... {id, backend, handleImageUrl}}/>
91-
</DiscreeteDropdown>
92-
</div>
93-
);
94-
return (
111+
// Attach trash icons on every render and re-attach when exiting edit mode
112+
useEffect(() => {
113+
const attachTrash = () => {
114+
const root = containerRef.current;
115+
if (!root) return;
116+
root.querySelectorAll('figure').forEach(fig => {
117+
if (fig.querySelector('.trash-overlay')) return;
118+
const img = fig.querySelector('img');
119+
if (!img) return;
120+
const trash = document.createElement('div');
121+
trash.className = 'trash-overlay';
122+
trash.innerHTML = `
123+
<svg viewBox="0 0 16 16">
124+
<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"/>
125+
<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"/>
126+
</svg>`;
127+
Object.assign(trash.style, {
128+
position: 'absolute',
129+
bottom: '10px',
130+
right: '10px',
131+
width: '24px',
132+
height: '24px',
133+
background: 'rgba(0,0,0,0.6)',
134+
display: 'flex',
135+
alignItems: 'center',
136+
justifyContent: 'center',
137+
cursor: 'pointer',
138+
opacity: '0',
139+
transition: 'opacity 0.2s ease',
140+
zIndex: '10'
141+
});
142+
fig.style.position = 'relative';
143+
fig.appendChild(trash);
144+
fig.addEventListener('mouseenter', () => (trash.style.opacity = '1'));
145+
fig.addEventListener('mouseleave', () => (trash.style.opacity = '0'));
146+
trash.addEventListener('click', () => {
147+
const src = img.src;
148+
const internal = src.includes(`/${id}/`);
149+
const name = internal
150+
? decodeURIComponent(src.split(`${id}/`)[1])
151+
: src;
152+
setDeleteTarget({ src, internal, name });
153+
setShowDeleteModal(true);
154+
});
155+
});
156+
};
157+
158+
const obs = new MutationObserver(attachTrash);
159+
if (containerRef.current) {
160+
obs.observe(containerRef.current, { childList: true, subtree: true });
161+
}
162+
attachTrash();
163+
return () => obs.disconnect();
164+
}, [backend, id, setLastUpdate, beingEdited]);
165+
166+
const confirmDelete = () => {
167+
const { src, internal, name } = deleteTarget;
168+
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
169+
const pattern = esc(src);
170+
const mdRx = new RegExp(`!?\\[.*?\\]\\(${pattern}\\)`, 'g');
171+
const clean = t => (t || '').replace(mdRx, '').replace(/\n{2,}/g, '\n\n').trim();
172+
173+
if (internal) {
174+
backend.deleteAttachment(id, name, res => {
175+
if (!res.ok) return alert('Erreur lors de la suppression.');
176+
backend.getDocument(id).then(doc => {
177+
const cleaned = clean(doc.text);
178+
backend.putDocument({ ...doc, text: cleaned }).then(r => {
179+
setEditedText(cleaned);
180+
setEditedDocument({ ...doc, text: cleaned, _rev: r.rev });
181+
setLastUpdate(r.rev);
182+
setShowDeleteModal(false);
183+
});
184+
});
185+
});
186+
} else {
187+
const cleaned = clean(editedText);
188+
setEditedText(cleaned);
189+
setEditedDocument(p => ({ ...p, text: cleaned }));
190+
setShowDeleteModal(false);
191+
}
192+
};
193+
194+
return beingEdited ? (
95195
<form>
96-
<textarea className="form-control" type="text" rows="5" autoFocus
97-
value={editedText} onChange={handleChange} onBlur={handleBlur}
196+
<textarea
197+
className="form-control"
198+
rows={5}
199+
autoFocus
200+
value={editedText}
201+
onChange={handleChange}
202+
onBlur={handleBlur}
98203
/>
204+
{showDeleteModal && (
205+
<div className="modal fade show d-block" tabIndex="-1" role="dialog">
206+
<div className="modal-dialog" role="document">
207+
<div className="modal-content">
208+
<div className="modal-header">
209+
<h5 className="modal-title">Confirmer la suppression</h5>
210+
<button
211+
type="button"
212+
className="btn-close"
213+
onClick={() => setShowDeleteModal(false)}
214+
/>
215+
</div>
216+
<div className="modal-body">
217+
<p>
218+
Supprimer l’image{' '}
219+
{deleteTarget.internal ? `"${deleteTarget.name}"` : 'externe'} ?
220+
</p>
221+
</div>
222+
<div className="modal-footer">
223+
<button
224+
type="button"
225+
className="btn btn-secondary"
226+
onClick={() => setShowDeleteModal(false)}
227+
>
228+
Annuler
229+
</button>
230+
<button
231+
type="button"
232+
className="btn btn-danger"
233+
onClick={confirmDelete}
234+
>
235+
Supprimer
236+
</button>
237+
</div>
238+
</div>
239+
</div>
240+
</div>
241+
)}
99242
</form>
243+
) : (
244+
<>
245+
<div
246+
className="editable content position-relative"
247+
title="Edit content…"
248+
>
249+
<div
250+
className="formatted-text"
251+
ref={containerRef}
252+
onClick={handleClick}
253+
>
254+
<FormattedText
255+
setHighlightedText={setHighlightedText}
256+
setSelectedText={setSelectedText}
257+
>
258+
{text || '\u00A0'}
259+
</FormattedText>
260+
</div>
261+
<DiscreeteDropdown>
262+
<PictureUploadAction
263+
id={id}
264+
backend={backend}
265+
handleImageUrl={handleImageUrl}
266+
/>
267+
</DiscreeteDropdown>
268+
</div>
269+
{showDeleteModal && (
270+
<div className="modal fade show d-block" tabIndex="-1" role="dialog">
271+
<div className="modal-dialog" role="document">
272+
<div className="modal-content">
273+
<div className="modal-header">
274+
<h5 className="modal-title">Confirmer la suppression</h5>
275+
<button
276+
type="button"
277+
className="btn-close"
278+
onClick={() => setShowDeleteModal(false)}
279+
/>
280+
</div>
281+
<div className="modal-body">
282+
<p>
283+
Supprimer l’image{' '}
284+
{deleteTarget.internal ? `"${deleteTarget.name}"` : 'externe'} ?
285+
</p>
286+
</div>
287+
<div className="modal-footer">
288+
<button
289+
type="button"
290+
className="btn btn-secondary"
291+
onClick={() => setShowDeleteModal(false)}
292+
>
293+
Annuler
294+
</button>
295+
<button
296+
type="button"
297+
className="btn btn-danger"
298+
onClick={confirmDelete}
299+
>
300+
Supprimer
301+
</button>
302+
</div>
303+
</div>
304+
</div>
305+
</div>
306+
)}
307+
</>
100308
);
101-
}
102-
103-
export default EditableText;
309+
}

0 commit comments

Comments
 (0)