]> Untitled Git - lemmy.git/blob - ui/src/components/markdown-textarea.tsx
Merge remote-tracking branch 'github/main' into main
[lemmy.git] / ui / src / components / markdown-textarea.tsx
1 import { Component, linkEvent } from 'inferno';
2 import { Prompt } from 'inferno-router';
3 import {
4   mdToHtml,
5   randomStr,
6   markdownHelpUrl,
7   toast,
8   setupTribute,
9   pictrsDeleteToast,
10   setupTippy,
11 } from '../utils';
12 import { UserService } from '../services';
13 import autosize from 'autosize';
14 import Tribute from 'tributejs/src/Tribute.js';
15 import { i18n } from '../i18next';
16
17 interface MarkdownTextAreaProps {
18   initialContent: string;
19   finished?: boolean;
20   buttonTitle?: string;
21   replyType?: boolean;
22   focus?: boolean;
23   disabled?: boolean;
24   maxLength?: number;
25   onSubmit?(msg: { val: string; formId: string }): any;
26   onContentChange?(val: string): any;
27   onReplyCancel?(): any;
28 }
29
30 interface MarkdownTextAreaState {
31   content: string;
32   previewMode: boolean;
33   loading: boolean;
34   imageLoading: boolean;
35 }
36
37 export class MarkdownTextArea extends Component<
38   MarkdownTextAreaProps,
39   MarkdownTextAreaState
40 > {
41   private id = `comment-textarea-${randomStr()}`;
42   private formId = `comment-form-${randomStr()}`;
43   private tribute: Tribute;
44   private emptyState: MarkdownTextAreaState = {
45     content: this.props.initialContent,
46     previewMode: false,
47     loading: false,
48     imageLoading: false,
49   };
50
51   constructor(props: any, context: any) {
52     super(props, context);
53
54     this.tribute = setupTribute();
55     this.state = this.emptyState;
56   }
57
58   componentDidMount() {
59     let textarea: any = document.getElementById(this.id);
60     if (textarea) {
61       autosize(textarea);
62       this.tribute.attach(textarea);
63       textarea.addEventListener('tribute-replaced', () => {
64         this.state.content = textarea.value;
65         this.setState(this.state);
66         autosize.update(textarea);
67       });
68
69       this.quoteInsert();
70
71       if (this.props.focus) {
72         textarea.focus();
73       }
74
75       // TODO this is slow for some reason
76       setupTippy();
77     }
78   }
79
80   componentDidUpdate() {
81     if (this.state.content) {
82       window.onbeforeunload = () => true;
83     } else {
84       window.onbeforeunload = undefined;
85     }
86   }
87
88   componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
89     if (nextProps.finished) {
90       this.state.previewMode = false;
91       this.state.loading = false;
92       this.state.content = '';
93       this.setState(this.state);
94       if (this.props.replyType) {
95         this.props.onReplyCancel();
96       }
97
98       let textarea: any = document.getElementById(this.id);
99       let form: any = document.getElementById(this.formId);
100       form.reset();
101       setTimeout(() => autosize.update(textarea), 10);
102       this.setState(this.state);
103     }
104   }
105
106   componentWillUnmount() {
107     window.onbeforeunload = null;
108   }
109
110   render() {
111     return (
112       <form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}>
113         <Prompt when={this.state.content} message={i18n.t('block_leaving')} />
114         <div class="form-group row">
115           <div className={`col-sm-12`}>
116             <textarea
117               id={this.id}
118               className={`form-control ${this.state.previewMode && 'd-none'}`}
119               value={this.state.content}
120               onInput={linkEvent(this, this.handleContentChange)}
121               onPaste={linkEvent(this, this.handleImageUploadPaste)}
122               required
123               disabled={this.props.disabled}
124               rows={2}
125               maxLength={this.props.maxLength || 10000}
126             />
127             {this.state.previewMode && (
128               <div
129                 className="card bg-transparent border-secondary card-body md-div"
130                 dangerouslySetInnerHTML={mdToHtml(this.state.content)}
131               />
132             )}
133           </div>
134         </div>
135         <div class="row">
136           <div class="col-sm-12 d-flex flex-wrap">
137             {this.props.buttonTitle && (
138               <button
139                 type="submit"
140                 class="btn btn-sm btn-secondary mr-2"
141                 disabled={this.props.disabled || this.state.loading}
142               >
143                 {this.state.loading ? (
144                   <svg class="icon icon-spinner spin">
145                     <use xlinkHref="#icon-spinner"></use>
146                   </svg>
147                 ) : (
148                   <span>{this.props.buttonTitle}</span>
149                 )}
150               </button>
151             )}
152             {this.props.replyType && (
153               <button
154                 type="button"
155                 class="btn btn-sm btn-secondary mr-2"
156                 onClick={linkEvent(this, this.handleReplyCancel)}
157               >
158                 {i18n.t('cancel')}
159               </button>
160             )}
161             {this.state.content && (
162               <button
163                 className={`btn btn-sm btn-secondary mr-2 ${
164                   this.state.previewMode && 'active'
165                 }`}
166                 onClick={linkEvent(this, this.handlePreviewToggle)}
167               >
168                 {i18n.t('preview')}
169               </button>
170             )}
171             {/* A flex expander */}
172             <div class="flex-grow-1"></div>
173             <button
174               class="btn btn-sm text-muted"
175               data-tippy-content={i18n.t('bold')}
176               onClick={linkEvent(this, this.handleInsertBold)}
177             >
178               <svg class="icon icon-inline">
179                 <use xlinkHref="#icon-bold"></use>
180               </svg>
181             </button>
182             <button
183               class="btn btn-sm text-muted"
184               data-tippy-content={i18n.t('italic')}
185               onClick={linkEvent(this, this.handleInsertItalic)}
186             >
187               <svg class="icon icon-inline">
188                 <use xlinkHref="#icon-italic"></use>
189               </svg>
190             </button>
191             <button
192               class="btn btn-sm text-muted"
193               data-tippy-content={i18n.t('link')}
194               onClick={linkEvent(this, this.handleInsertLink)}
195             >
196               <svg class="icon icon-inline">
197                 <use xlinkHref="#icon-link"></use>
198               </svg>
199             </button>
200             <form class="btn btn-sm text-muted font-weight-bold">
201               <label
202                 htmlFor={`file-upload-${this.id}`}
203                 className={`mb-0 ${UserService.Instance.user && 'pointer'}`}
204                 data-tippy-content={i18n.t('upload_image')}
205               >
206                 {this.state.imageLoading ? (
207                   <svg class="icon icon-spinner spin">
208                     <use xlinkHref="#icon-spinner"></use>
209                   </svg>
210                 ) : (
211                   <svg class="icon icon-inline">
212                     <use xlinkHref="#icon-image"></use>
213                   </svg>
214                 )}
215               </label>
216               <input
217                 id={`file-upload-${this.id}`}
218                 type="file"
219                 accept="image/*,video/*"
220                 name="file"
221                 class="d-none"
222                 disabled={!UserService.Instance.user}
223                 onChange={linkEvent(this, this.handleImageUpload)}
224               />
225             </form>
226             <button
227               class="btn btn-sm text-muted"
228               data-tippy-content={i18n.t('header')}
229               onClick={linkEvent(this, this.handleInsertHeader)}
230             >
231               <svg class="icon icon-inline">
232                 <use xlinkHref="#icon-header"></use>
233               </svg>
234             </button>
235             <button
236               class="btn btn-sm text-muted"
237               data-tippy-content={i18n.t('strikethrough')}
238               onClick={linkEvent(this, this.handleInsertStrikethrough)}
239             >
240               <svg class="icon icon-inline">
241                 <use xlinkHref="#icon-strikethrough"></use>
242               </svg>
243             </button>
244             <button
245               class="btn btn-sm text-muted"
246               data-tippy-content={i18n.t('quote')}
247               onClick={linkEvent(this, this.handleInsertQuote)}
248             >
249               <svg class="icon icon-inline">
250                 <use xlinkHref="#icon-format_quote"></use>
251               </svg>
252             </button>
253             <button
254               class="btn btn-sm text-muted"
255               data-tippy-content={i18n.t('list')}
256               onClick={linkEvent(this, this.handleInsertList)}
257             >
258               <svg class="icon icon-inline">
259                 <use xlinkHref="#icon-list"></use>
260               </svg>
261             </button>
262             <button
263               class="btn btn-sm text-muted"
264               data-tippy-content={i18n.t('code')}
265               onClick={linkEvent(this, this.handleInsertCode)}
266             >
267               <svg class="icon icon-inline">
268                 <use xlinkHref="#icon-code"></use>
269               </svg>
270             </button>
271             <button
272               class="btn btn-sm text-muted"
273               data-tippy-content={i18n.t('subscript')}
274               onClick={linkEvent(this, this.handleInsertSubscript)}
275             >
276               <svg class="icon icon-inline">
277                 <use xlinkHref="#icon-subscript"></use>
278               </svg>
279             </button>
280             <button
281               class="btn btn-sm text-muted"
282               data-tippy-content={i18n.t('superscript')}
283               onClick={linkEvent(this, this.handleInsertSuperscript)}
284             >
285               <svg class="icon icon-inline">
286                 <use xlinkHref="#icon-superscript"></use>
287               </svg>
288             </button>
289             <button
290               class="btn btn-sm text-muted"
291               data-tippy-content={i18n.t('spoiler')}
292               onClick={linkEvent(this, this.handleInsertSpoiler)}
293             >
294               <svg class="icon icon-inline">
295                 <use xlinkHref="#icon-alert-triangle"></use>
296               </svg>
297             </button>
298             <a
299               href={markdownHelpUrl}
300               target="_blank"
301               class="btn btn-sm text-muted font-weight-bold"
302               title={i18n.t('formatting_help')}
303               rel="noopener"
304             >
305               <svg class="icon icon-inline">
306                 <use xlinkHref="#icon-help-circle"></use>
307               </svg>
308             </a>
309           </div>
310         </div>
311       </form>
312     );
313   }
314
315   handleImageUploadPaste(i: MarkdownTextArea, event: any) {
316     let image = event.clipboardData.files[0];
317     if (image) {
318       i.handleImageUpload(i, image);
319     }
320   }
321
322   handleImageUpload(i: MarkdownTextArea, event: any) {
323     let file: any;
324     if (event.target) {
325       event.preventDefault();
326       file = event.target.files[0];
327     } else {
328       file = event;
329     }
330
331     const imageUploadUrl = `/pictrs/image`;
332     const formData = new FormData();
333     formData.append('images[]', file);
334
335     i.state.imageLoading = true;
336     i.setState(i.state);
337
338     fetch(imageUploadUrl, {
339       method: 'POST',
340       body: formData,
341     })
342       .then(res => res.json())
343       .then(res => {
344         console.log('pictrs upload:');
345         console.log(res);
346         if (res.msg == 'ok') {
347           let hash = res.files[0].file;
348           let url = `${window.location.origin}/pictrs/image/${hash}`;
349           let deleteToken = res.files[0].delete_token;
350           let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
351           let imageMarkdown = `![](${url})`;
352           let content = i.state.content;
353           content = content ? `${content}\n${imageMarkdown}` : imageMarkdown;
354           i.state.content = content;
355           i.state.imageLoading = false;
356           i.setState(i.state);
357           let textarea: any = document.getElementById(i.id);
358           autosize.update(textarea);
359           pictrsDeleteToast(
360             i18n.t('click_to_delete_picture'),
361             i18n.t('picture_deleted'),
362             deleteUrl
363           );
364         } else {
365           i.state.imageLoading = false;
366           i.setState(i.state);
367           toast(JSON.stringify(res), 'danger');
368         }
369       })
370       .catch(error => {
371         i.state.imageLoading = false;
372         i.setState(i.state);
373         toast(error, 'danger');
374       });
375   }
376
377   handleContentChange(i: MarkdownTextArea, event: any) {
378     i.state.content = event.target.value;
379     i.setState(i.state);
380     if (i.props.onContentChange) {
381       i.props.onContentChange(i.state.content);
382     }
383   }
384
385   handlePreviewToggle(i: MarkdownTextArea, event: any) {
386     event.preventDefault();
387     i.state.previewMode = !i.state.previewMode;
388     i.setState(i.state);
389   }
390
391   handleSubmit(i: MarkdownTextArea, event: any) {
392     event.preventDefault();
393     i.state.loading = true;
394     i.setState(i.state);
395     let msg = { val: i.state.content, formId: i.formId };
396     i.props.onSubmit(msg);
397   }
398
399   handleReplyCancel(i: MarkdownTextArea) {
400     i.props.onReplyCancel();
401   }
402
403   handleInsertLink(i: MarkdownTextArea, event: any) {
404     event.preventDefault();
405     if (!i.state.content) {
406       i.state.content = '';
407     }
408     let textarea: any = document.getElementById(i.id);
409     let start: number = textarea.selectionStart;
410     let end: number = textarea.selectionEnd;
411
412     if (start !== end) {
413       let selectedText = i.state.content.substring(start, end);
414       i.state.content = `${i.state.content.substring(
415         0,
416         start
417       )} [${selectedText}]() ${i.state.content.substring(end)}`;
418       textarea.focus();
419       setTimeout(() => (textarea.selectionEnd = end + 4), 10);
420     } else {
421       i.state.content += '[]()';
422       textarea.focus();
423       setTimeout(() => (textarea.selectionEnd -= 1), 10);
424     }
425     i.setState(i.state);
426   }
427
428   simpleSurround(chars: string) {
429     this.simpleSurroundBeforeAfter(chars, chars);
430   }
431
432   simpleSurroundBeforeAfter(beforeChars: string, afterChars: string) {
433     if (!this.state.content) {
434       this.state.content = '';
435     }
436     let textarea: any = document.getElementById(this.id);
437     let start: number = textarea.selectionStart;
438     let end: number = textarea.selectionEnd;
439
440     if (start !== end) {
441       let selectedText = this.state.content.substring(start, end);
442       this.state.content = `${this.state.content.substring(
443         0,
444         start - 1
445       )} ${beforeChars}${selectedText}${afterChars} ${this.state.content.substring(
446         end + 1
447       )}`;
448     } else {
449       this.state.content += `${beforeChars}___${afterChars}`;
450     }
451     this.setState(this.state);
452     setTimeout(() => {
453       autosize.update(textarea);
454     }, 10);
455   }
456
457   handleInsertBold(i: MarkdownTextArea, event: any) {
458     event.preventDefault();
459     i.simpleSurround('**');
460   }
461
462   handleInsertItalic(i: MarkdownTextArea, event: any) {
463     event.preventDefault();
464     i.simpleSurround('*');
465   }
466
467   handleInsertCode(i: MarkdownTextArea, event: any) {
468     event.preventDefault();
469     i.simpleSurround('`');
470   }
471
472   handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
473     event.preventDefault();
474     i.simpleSurround('~~');
475   }
476
477   handleInsertList(i: MarkdownTextArea, event: any) {
478     event.preventDefault();
479     i.simpleInsert('-');
480   }
481
482   handleInsertQuote(i: MarkdownTextArea, event: any) {
483     event.preventDefault();
484     i.simpleInsert('>');
485   }
486
487   handleInsertHeader(i: MarkdownTextArea, event: any) {
488     event.preventDefault();
489     i.simpleInsert('#');
490   }
491
492   handleInsertSubscript(i: MarkdownTextArea, event: any) {
493     event.preventDefault();
494     i.simpleSurround('~');
495   }
496
497   handleInsertSuperscript(i: MarkdownTextArea, event: any) {
498     event.preventDefault();
499     i.simpleSurround('^');
500   }
501
502   simpleInsert(chars: string) {
503     if (!this.state.content) {
504       this.state.content = `${chars} `;
505     } else {
506       this.state.content += `\n${chars} `;
507     }
508
509     let textarea: any = document.getElementById(this.id);
510     textarea.focus();
511     setTimeout(() => {
512       autosize.update(textarea);
513     }, 10);
514     this.setState(this.state);
515   }
516
517   handleInsertSpoiler(i: MarkdownTextArea, event: any) {
518     event.preventDefault();
519     let beforeChars = `\n::: spoiler ${i18n.t('spoiler')}\n`;
520     let afterChars = '\n:::\n';
521     i.simpleSurroundBeforeAfter(beforeChars, afterChars);
522   }
523
524   quoteInsert() {
525     let textarea: any = document.getElementById(this.id);
526     let selectedText = window.getSelection().toString();
527     if (selectedText) {
528       let quotedText =
529         selectedText
530           .split('\n')
531           .map(t => `> ${t}`)
532           .join('\n') + '\n\n';
533       this.state.content = quotedText;
534       this.setState(this.state);
535       // Not sure why this needs a delay
536       setTimeout(() => autosize.update(textarea), 10);
537     }
538   }
539 }