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