1+ import { useState , useEffect , useCallback , useMemo , useRef } from 'react' ;
12import '../styles/EditableText.css' ;
2-
3- import { useState , useEffect , useCallback } from 'react' ;
43import FormattedText from './FormattedText' ;
54import DiscreeteDropdown from './DiscreeteDropdown' ;
65import 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 || ' ' }
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