Skip to content

Commit caddc1a

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

File tree

3 files changed

+128
-1
lines changed

3 files changed

+128
-1
lines changed

frontend/src/components/EditableText.jsx

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import '../styles/EditableText.css';
2+
import { Trash } from 'react-bootstrap-icons';
23

34
import { useState, useEffect, useCallback } from 'react';
45
import FormattedText from './FormattedText';
@@ -10,6 +11,8 @@ function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment,
1011
const [beingEdited, setBeingEdited] = useState(false);
1112
const [editedDocument, setEditedDocument] = useState();
1213
const [editedText, setEditedText] = useState();
14+
const [showDeleteModal, setShowDeleteModal] = useState(false);
15+
const [deleteTarget, setDeleteTarget] = useState({ src: '', alt: '', internal: false, name: '' });
1316
const PASSAGE = new RegExp(`\\{${rubric}} ?([^{]*)`);
1417

1518
let parsePassage = (rawText) => (rubric)
@@ -79,16 +82,99 @@ function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment,
7982
.catch(console.error);
8083
};
8184

85+
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
86+
let images = [];
87+
let cleanedimage = text || '';
88+
let match;
89+
while ((match = imageRegex.exec(cleanedimage)) !== null) {
90+
images.push({ alt: match[1], src: match[2] });
91+
}
92+
cleanedimage = cleanedimage.replace(imageRegex, '');
93+
94+
const confirmDelete = () => {
95+
const { src, alt, internal, name } = deleteTarget;
96+
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
97+
const mdRx = new RegExp(`!\\[${esc(alt)}\\]\\(${esc(src)}\\)`, 'g');
98+
const clean = t => (t || '').replace(mdRx, '').replace(/\n{2,}/g, '\n\n').trim();
99+
if (internal) {
100+
backend.deleteAttachment(id => {
101+
backend.getDocument(id).then(doc => {
102+
const cleaned = clean(doc.text);
103+
backend.putDocument({ ...doc, text: cleaned }).then(r => {
104+
setEditedText(cleaned);
105+
setEditedDocument({ ...doc, text: cleaned, _rev: r.rev });
106+
setLastUpdate(r.rev);
107+
setShowDeleteModal(false);
108+
});
109+
});
110+
});
111+
} else {
112+
const cleaned = clean(editedText);
113+
setEditedText(cleaned);
114+
setEditedDocument(p => ({ ...p, text: cleaned }));
115+
setShowDeleteModal(false);
116+
}
117+
};
118+
82119
if (!beingEdited) return (
83120
<div className="editable content position-relative" title="Edit content...">
84121
<div className="formatted-text" onClick={handleClick}>
85122
<FormattedText {...{setHighlightedText, setSelectedText}}>
86-
{text || '&nbsp;'}
123+
{(images.length > 0 ? cleanedimage : text) || '&nbsp;'}
87124
</FormattedText>
125+
{images.map(({ src, alt }) => (
126+
<figure
127+
key={src + alt}
128+
className="has-trash-overlay"
129+
style={{ position: 'relative', display: 'inline-block', margin: 0 }}
130+
>
131+
<img
132+
src={src}
133+
alt={alt}
134+
className="img-fluid rounded editable-image"
135+
/>
136+
<button
137+
className="trash-overlay"
138+
type="button"
139+
aria-label={`Delete image ${alt || src}`}
140+
title={`Delete image ${alt || src}`}
141+
onClick={e => {
142+
e.stopPropagation();
143+
const internal = src.includes(`/${id}/`);
144+
const name = internal
145+
? decodeURIComponent(src.split(`${id}/`)[1])
146+
: src;
147+
setDeleteTarget({ src, alt, internal, name });
148+
setShowDeleteModal(true);
149+
}}
150+
>
151+
<Trash />
152+
</button>
153+
</figure>
154+
))}
88155
</div>
89156
<DiscreeteDropdown>
90157
<PictureUploadAction {... {id, backend, handleImageUrl}}/>
91158
</DiscreeteDropdown>
159+
{showDeleteModal && (
160+
<div className="modal fade show d-block" tabIndex="-1" role="dialog">
161+
<div className="modal-dialog" role="document">
162+
<div className="modal-content">
163+
<div className="modal-header">
164+
<h5 className="modal-title">Confirm deletion</h5>
165+
<button type="button" className="btn-close" onClick={() => setShowDeleteModal(false)} />
166+
</div>
167+
<div className="modal-body">
168+
<p>Delete image {deleteTarget.internal ? `"${deleteTarget.name}"` : 'external'}?</p>
169+
</div>
170+
<div className="modal-footer">
171+
<button type="button" className="btn btn-secondary" onClick={() => setShowDeleteModal(false)}>Cancel</button>
172+
<button type="button" className="btn btn-danger" onClick={confirmDelete}>Delete</button>
173+
</div>
174+
</div>
175+
</div>
176+
</div>
177+
)}
92178
</div>
93179
);
94180
return (

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)