Skip to content

Commit 2f61b7a

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

File tree

3 files changed

+140
-6
lines changed

3 files changed

+140
-6
lines changed

frontend/src/components/EditableText.jsx

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import '../styles/EditableText.css';
2+
import { Trash } from 'react-bootstrap-icons';
3+
import { createRoot } from 'react-dom/client';
24

3-
import { useState, useEffect, useCallback } from 'react';
5+
import { useState, useEffect, useCallback, useRef } from 'react';
46
import FormattedText from './FormattedText';
57
import DiscreeteDropdown from './DiscreeteDropdown';
68
import PictureUploadAction from '../menu-items/PictureUploadAction';
@@ -10,6 +12,9 @@ function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment,
1012
const [beingEdited, setBeingEdited] = useState(false);
1113
const [editedDocument, setEditedDocument] = useState();
1214
const [editedText, setEditedText] = useState();
15+
const [showDeleteModal, setShowDeleteModal] = useState(false);
16+
const [deleteTarget, setDeleteTarget] = useState({ src: '', internal: false, name: '' });
17+
const containerRef = useRef(null);
1318
const PASSAGE = new RegExp(`\\{${rubric}} ?([^{]*)`);
1419

1520
let parsePassage = (rawText) => (rubric)
@@ -79,25 +84,113 @@ function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment,
7984
.catch(console.error);
8085
};
8186

87+
useEffect(() => {
88+
const attachTrash = () => {
89+
const root = containerRef.current;
90+
if (!root) return;
91+
root.querySelectorAll('figure').forEach(fig => {
92+
if (fig.querySelector('.trash-overlay')) return;
93+
const img = fig.querySelector('img');
94+
if (!img) return;
95+
const trash = document.createElement('div');
96+
trash.className = 'trash-overlay';
97+
trash.setAttribute('data-img', img.src);
98+
trash.setAttribute('tabindex', 0);
99+
fig.style.position = 'relative';
100+
fig.appendChild(trash);
101+
createRoot(trash).render(<Trash />);
102+
});
103+
};
104+
105+
const obs = new MutationObserver(attachTrash);
106+
if (containerRef.current) {
107+
obs.observe(containerRef.current, { childList: true, subtree: true });
108+
}
109+
attachTrash();
110+
return () => obs.disconnect();
111+
}, [backend, id, setLastUpdate, beingEdited]);
112+
113+
const confirmDelete = () => {
114+
const { src, internal, name } = deleteTarget;
115+
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116+
const mdRx = new RegExp(`!?\\[[^\\]]*\\]\\(${esc(src)}\\)`, 'g');
117+
const clean = t => (t || '').replace(mdRx, '').replace(/\n{2,}/g, '\n\n').trim();
118+
119+
if (internal) {
120+
backend.deleteAttachment(id, name, res => {
121+
if (!res.ok) return alert('Error deleting attachment.');
122+
backend.getDocument(id).then(doc => {
123+
const cleaned = clean(doc.text);
124+
backend.putDocument({ ...doc, text: cleaned }).then(r => {
125+
setEditedText(cleaned);
126+
setEditedDocument({ ...doc, text: cleaned, _rev: r.rev });
127+
setLastUpdate(r.rev);
128+
setShowDeleteModal(false);
129+
});
130+
});
131+
});
132+
} else {
133+
const cleaned = clean(editedText);
134+
setEditedText(cleaned);
135+
setEditedDocument(p => ({ ...p, text: cleaned }));
136+
setShowDeleteModal(false);
137+
}
138+
};
139+
82140
if (!beingEdited) return (
83141
<div className="editable content position-relative" title="Edit content...">
84-
<div className="formatted-text" onClick={handleClick}>
142+
<div className="formatted-text" onClick={e => {
143+
if (!beingEdited) {
144+
const trash = e.target.closest('.trash-overlay');
145+
if (trash) {
146+
const src = trash.getAttribute('data-img');
147+
const internal = src.includes(`/${id}/`);
148+
const name = internal
149+
? decodeURIComponent(src.split(`${id}/`)[1])
150+
: src;
151+
setDeleteTarget({ src, internal, name });
152+
setShowDeleteModal(true);
153+
return;
154+
}
155+
handleClick();
156+
}
157+
}}
158+
ref={containerRef}
159+
>
85160
<FormattedText {...{setHighlightedText, setSelectedText}}>
86161
{text || '&nbsp;'}
87162
</FormattedText>
88163
</div>
89164
<DiscreeteDropdown>
90-
<PictureUploadAction {... {id, backend, handleImageUrl}}/>
165+
<PictureUploadAction {...{id, backend, handleImageUrl}}/>
91166
</DiscreeteDropdown>
167+
{showDeleteModal && (
168+
<div className="modal fade show d-block" tabIndex="-1" role="dialog">
169+
<div className="modal-dialog" role="document">
170+
<div className="modal-content">
171+
<div className="modal-header">
172+
<h5 className="modal-title">Confirm deletion</h5>
173+
<button type="button" className="btn-close" onClick={() => setShowDeleteModal(false)} />
174+
</div>
175+
<div className="modal-body">
176+
<p>Delete image {deleteTarget.internal ? `"${deleteTarget.name}"` : 'external'}?</p>
177+
</div>
178+
<div className="modal-footer">
179+
<button type="button" className="btn btn-secondary" onClick={() => setShowDeleteModal(false)}>Cancel</button>
180+
<button type="button" className="btn btn-danger" onClick={confirmDelete}>Delete</button>
181+
</div>
182+
</div>
183+
</div>
184+
</div>
185+
)}
92186
</div>
93187
);
94188
return (
95189
<form>
96-
<textarea className="form-control" type="text" rows="5" autoFocus
97-
value={editedText} onChange={handleChange} onBlur={handleBlur}
98-
/>
190+
<textarea className="form-control" rows="5" autoFocus value={editedText} onChange={handleChange} onBlur={handleBlur} />
99191
</form>
100192
);
101193
}
102194

103195
export default EditableText;
196+

frontend/src/hyperglosae.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@ 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())

frontend/src/styles/EditableText.css

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,34 @@
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+
}
30+
31+
figure.has-trash-overlay:hover .trash-overlay {
32+
opacity: 1;
33+
}
1534

35+
.trash-overlay .bi-trash {
36+
font-size: 22px;
37+
color: white;
38+
display: block;
39+
}
40+
41+
42+
figure:hover .trash-overlay,
43+
.trash-overlay:focus {
44+
opacity: 1;
45+
}

0 commit comments

Comments
 (0)