]> Untitled Git - lemmy.git/blob - ui/src/components/markdown-textarea.tsx
Adding form_id to comment creates and edits.
[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 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('spoiler')}
273               onClick={linkEvent(this, this.handleInsertSpoiler)}
274             >
275               <svg class="icon icon-inline">
276                 <use xlinkHref="#icon-alert-triangle"></use>
277               </svg>
278             </button>
279             <a
280               href={markdownHelpUrl}
281               target="_blank"
282               class="btn btn-sm text-muted font-weight-bold"
283               title={i18n.t('formatting_help')}
284               rel="noopener"
285             >
286               <svg class="icon icon-inline">
287                 <use xlinkHref="#icon-help-circle"></use>
288               </svg>
289             </a>
290           </div>
291         </div>
292       </form>
293     );
294   }
295
296   handleImageUploadPaste(i: MarkdownTextArea, event: any) {
297     let image = event.clipboardData.files[0];
298     if (image) {
299       i.handleImageUpload(i, image);
300     }
301   }
302
303   handleImageUpload(i: MarkdownTextArea, event: any) {
304     let file: any;
305     if (event.target) {
306       event.preventDefault();
307       file = event.target.files[0];
308     } else {
309       file = event;
310     }
311
312     const imageUploadUrl = `/pictrs/image`;
313     const formData = new FormData();
314     formData.append('images[]', file);
315
316     i.state.imageLoading = true;
317     i.setState(i.state);
318
319     fetch(imageUploadUrl, {
320       method: 'POST',
321       body: formData,
322     })
323       .then(res => res.json())
324       .then(res => {
325         console.log('pictrs upload:');
326         console.log(res);
327         if (res.msg == 'ok') {
328           let hash = res.files[0].file;
329           let url = `${window.location.origin}/pictrs/image/${hash}`;
330           let deleteToken = res.files[0].delete_token;
331           let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
332           let imageMarkdown = `![](${url})`;
333           let content = i.state.content;
334           content = content ? `${content}\n${imageMarkdown}` : imageMarkdown;
335           i.state.content = content;
336           i.state.imageLoading = false;
337           i.setState(i.state);
338           let textarea: any = document.getElementById(i.id);
339           autosize.update(textarea);
340           pictrsDeleteToast(
341             i18n.t('click_to_delete_picture'),
342             i18n.t('picture_deleted'),
343             deleteUrl
344           );
345         } else {
346           i.state.imageLoading = false;
347           i.setState(i.state);
348           toast(JSON.stringify(res), 'danger');
349         }
350       })
351       .catch(error => {
352         i.state.imageLoading = false;
353         i.setState(i.state);
354         toast(error, 'danger');
355       });
356   }
357
358   handleContentChange(i: MarkdownTextArea, event: any) {
359     i.state.content = event.target.value;
360     i.setState(i.state);
361     if (i.props.onContentChange) {
362       i.props.onContentChange(i.state.content);
363     }
364   }
365
366   handlePreviewToggle(i: MarkdownTextArea, event: any) {
367     event.preventDefault();
368     i.state.previewMode = !i.state.previewMode;
369     i.setState(i.state);
370   }
371
372   handleSubmit(i: MarkdownTextArea, event: any) {
373     event.preventDefault();
374     i.state.loading = true;
375     i.setState(i.state);
376     let msg = { val: i.state.content, formId: i.formId };
377     i.props.onSubmit(msg);
378   }
379
380   handleReplyCancel(i: MarkdownTextArea) {
381     i.props.onReplyCancel();
382   }
383
384   handleInsertLink(i: MarkdownTextArea, event: any) {
385     event.preventDefault();
386     if (!i.state.content) {
387       i.state.content = '';
388     }
389     let textarea: any = document.getElementById(i.id);
390     let start: number = textarea.selectionStart;
391     let end: number = textarea.selectionEnd;
392
393     if (start !== end) {
394       let selectedText = i.state.content.substring(start, end);
395       i.state.content = `${i.state.content.substring(
396         0,
397         start
398       )} [${selectedText}]() ${i.state.content.substring(end)}`;
399       textarea.focus();
400       setTimeout(() => (textarea.selectionEnd = end + 4), 10);
401     } else {
402       i.state.content += '[]()';
403       textarea.focus();
404       setTimeout(() => (textarea.selectionEnd -= 1), 10);
405     }
406     i.setState(i.state);
407   }
408
409   simpleSurround(chars: string) {
410     this.simpleSurroundBeforeAfter(chars, chars);
411   }
412
413   simpleSurroundBeforeAfter(beforeChars: string, afterChars: string) {
414     if (!this.state.content) {
415       this.state.content = '';
416     }
417     let textarea: any = document.getElementById(this.id);
418     let start: number = textarea.selectionStart;
419     let end: number = textarea.selectionEnd;
420
421     if (start !== end) {
422       let selectedText = this.state.content.substring(start, end);
423       this.state.content = `${this.state.content.substring(
424         0,
425         start - 1
426       )} ${beforeChars}${selectedText}${afterChars} ${this.state.content.substring(
427         end + 1
428       )}`;
429     } else {
430       this.state.content += `${beforeChars}___${afterChars}`;
431     }
432     this.setState(this.state);
433     setTimeout(() => {
434       autosize.update(textarea);
435     }, 10);
436   }
437
438   handleInsertBold(i: MarkdownTextArea, event: any) {
439     event.preventDefault();
440     i.simpleSurround('**');
441   }
442
443   handleInsertItalic(i: MarkdownTextArea, event: any) {
444     event.preventDefault();
445     i.simpleSurround('*');
446   }
447
448   handleInsertCode(i: MarkdownTextArea, event: any) {
449     event.preventDefault();
450     i.simpleSurround('`');
451   }
452
453   handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
454     event.preventDefault();
455     i.simpleSurround('~~');
456   }
457
458   handleInsertList(i: MarkdownTextArea, event: any) {
459     event.preventDefault();
460     i.simpleInsert('-');
461   }
462
463   handleInsertQuote(i: MarkdownTextArea, event: any) {
464     event.preventDefault();
465     i.simpleInsert('>');
466   }
467
468   handleInsertHeader(i: MarkdownTextArea, event: any) {
469     event.preventDefault();
470     i.simpleInsert('#');
471   }
472
473   simpleInsert(chars: string) {
474     if (!this.state.content) {
475       this.state.content = `${chars} `;
476     } else {
477       this.state.content += `\n${chars} `;
478     }
479
480     let textarea: any = document.getElementById(this.id);
481     textarea.focus();
482     setTimeout(() => {
483       autosize.update(textarea);
484     }, 10);
485     this.setState(this.state);
486   }
487
488   handleInsertSpoiler(i: MarkdownTextArea, event: any) {
489     event.preventDefault();
490     let beforeChars = `\n::: spoiler ${i18n.t('spoiler')}\n`;
491     let afterChars = '\n:::\n';
492     i.simpleSurroundBeforeAfter(beforeChars, afterChars);
493   }
494
495   quoteInsert() {
496     let textarea: any = document.getElementById(this.id);
497     let selectedText = window.getSelection().toString();
498     if (selectedText) {
499       let quotedText =
500         selectedText
501           .split('\n')
502           .map(t => `> ${t}`)
503           .join('\n') + '\n\n';
504       this.state.content = quotedText;
505       this.setState(this.state);
506       // Not sure why this needs a delay
507       setTimeout(() => autosize.update(textarea), 10);
508     }
509   }
510 }