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