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