]> Untitled Git - lemmy.git/blob - ui/src/components/markdown-textarea.tsx
Changing location of sub and superscript buttons.
[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?(val: 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 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     i.props.onSubmit(i.state.content);
395   }
396
397   handleReplyCancel(i: MarkdownTextArea) {
398     i.props.onReplyCancel();
399   }
400
401   handleInsertLink(i: MarkdownTextArea, event: any) {
402     event.preventDefault();
403     if (!i.state.content) {
404       i.state.content = '';
405     }
406     let textarea: any = document.getElementById(i.id);
407     let start: number = textarea.selectionStart;
408     let end: number = textarea.selectionEnd;
409
410     if (start !== end) {
411       let selectedText = i.state.content.substring(start, end);
412       i.state.content = `${i.state.content.substring(
413         0,
414         start
415       )} [${selectedText}]() ${i.state.content.substring(end)}`;
416       textarea.focus();
417       setTimeout(() => (textarea.selectionEnd = end + 4), 10);
418     } else {
419       i.state.content += '[]()';
420       textarea.focus();
421       setTimeout(() => (textarea.selectionEnd -= 1), 10);
422     }
423     i.setState(i.state);
424   }
425
426   simpleSurround(chars: string) {
427     this.simpleSurroundBeforeAfter(chars, chars);
428   }
429
430   simpleSurroundBeforeAfter(beforeChars: string, afterChars: string) {
431     if (!this.state.content) {
432       this.state.content = '';
433     }
434     let textarea: any = document.getElementById(this.id);
435     let start: number = textarea.selectionStart;
436     let end: number = textarea.selectionEnd;
437
438     if (start !== end) {
439       let selectedText = this.state.content.substring(start, end);
440       this.state.content = `${this.state.content.substring(
441         0,
442         start - 1
443       )} ${beforeChars}${selectedText}${afterChars} ${this.state.content.substring(
444         end + 1
445       )}`;
446     } else {
447       this.state.content += `${beforeChars}___${afterChars}`;
448     }
449     this.setState(this.state);
450     setTimeout(() => {
451       autosize.update(textarea);
452     }, 10);
453   }
454
455   handleInsertBold(i: MarkdownTextArea, event: any) {
456     event.preventDefault();
457     i.simpleSurround('**');
458   }
459
460   handleInsertItalic(i: MarkdownTextArea, event: any) {
461     event.preventDefault();
462     i.simpleSurround('*');
463   }
464
465   handleInsertCode(i: MarkdownTextArea, event: any) {
466     event.preventDefault();
467     i.simpleSurround('`');
468   }
469
470   handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
471     event.preventDefault();
472     i.simpleSurround('~~');
473   }
474
475   handleInsertList(i: MarkdownTextArea, event: any) {
476     event.preventDefault();
477     i.simpleInsert('-');
478   }
479
480   handleInsertQuote(i: MarkdownTextArea, event: any) {
481     event.preventDefault();
482     i.simpleInsert('>');
483   }
484
485   handleInsertHeader(i: MarkdownTextArea, event: any) {
486     event.preventDefault();
487     i.simpleInsert('#');
488   }
489
490   handleInsertSubscript(i: MarkdownTextArea, event: any) {
491     event.preventDefault();
492     i.simpleSurround('~');
493   }
494
495   handleInsertSuperscript(i: MarkdownTextArea, event: any) {
496     event.preventDefault();
497     i.simpleSurround('^');
498   }
499
500   simpleInsert(chars: string) {
501     if (!this.state.content) {
502       this.state.content = `${chars} `;
503     } else {
504       this.state.content += `\n${chars} `;
505     }
506
507     let textarea: any = document.getElementById(this.id);
508     textarea.focus();
509     setTimeout(() => {
510       autosize.update(textarea);
511     }, 10);
512     this.setState(this.state);
513   }
514
515   handleInsertSpoiler(i: MarkdownTextArea, event: any) {
516     event.preventDefault();
517     let beforeChars = `\n::: spoiler ${i18n.t('spoiler')}\n`;
518     let afterChars = '\n:::\n';
519     i.simpleSurroundBeforeAfter(beforeChars, afterChars);
520   }
521
522   quoteInsert() {
523     let textarea: any = document.getElementById(this.id);
524     let selectedText = window.getSelection().toString();
525     if (selectedText) {
526       let quotedText =
527         selectedText
528           .split('\n')
529           .map(t => `> ${t}`)
530           .join('\n') + '\n\n';
531       this.state.content = quotedText;
532       this.setState(this.state);
533       // Not sure why this needs a delay
534       setTimeout(() => autosize.update(textarea), 10);
535     }
536   }
537 }