1 import autosize from "autosize";
2 import { Component, linkEvent } from "inferno";
3 import { Prompt } from "inferno-router";
4 import { pictrsUri } from "../../env";
5 import { i18n } from "../../i18next";
6 import { UserService } from "../../services";
18 import { Icon, Spinner } from "./icon";
20 interface MarkdownTextAreaProps {
21 initialContent?: string;
28 onSubmit?(msg: { val: string; formId: string }): any;
29 onContentChange?(val: string): any;
30 onReplyCancel?(): any;
31 hideNavigationWarnings?: boolean;
35 interface MarkdownTextAreaState {
39 imageLoading: boolean;
42 export class MarkdownTextArea extends Component<
43 MarkdownTextAreaProps,
46 private id = `comment-textarea-${randomStr()}`;
47 private formId = `comment-form-${randomStr()}`;
49 private emptyState: MarkdownTextAreaState = {
50 content: this.props.initialContent,
56 constructor(props: any, context: any) {
57 super(props, context);
60 this.tribute = setupTribute();
62 this.state = this.emptyState;
66 let textarea: any = document.getElementById(this.id);
69 this.tribute.attach(textarea);
70 textarea.addEventListener("tribute-replaced", () => {
71 this.state.content = textarea.value;
72 this.setState(this.state);
73 autosize.update(textarea);
78 if (this.props.focus) {
82 // TODO this is slow for some reason
87 componentDidUpdate() {
88 if (!this.props.hideNavigationWarnings && this.state.content) {
89 window.onbeforeunload = () => true;
91 window.onbeforeunload = undefined;
95 componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
96 if (nextProps.finished) {
97 this.state.previewMode = false;
98 this.state.loading = false;
99 this.state.content = "";
100 this.setState(this.state);
101 if (this.props.replyType) {
102 this.props.onReplyCancel();
105 let textarea: any = document.getElementById(this.id);
106 let form: any = document.getElementById(this.formId);
108 setTimeout(() => autosize.update(textarea), 10);
109 this.setState(this.state);
113 componentWillUnmount() {
114 window.onbeforeunload = null;
119 <form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}>
121 when={!this.props.hideNavigationWarnings && this.state.content}
122 message={i18n.t("block_leaving")}
124 <div class="form-group row">
125 <div className={`col-sm-12`}>
128 className={`form-control ${this.state.previewMode && "d-none"}`}
129 value={this.state.content}
130 onInput={linkEvent(this, this.handleContentChange)}
131 onPaste={linkEvent(this, this.handleImageUploadPaste)}
133 disabled={this.props.disabled}
135 maxLength={this.props.maxLength || 10000}
136 placeholder={this.props.placeholder}
138 {this.state.previewMode && (
140 className="card border-secondary card-body md-div"
141 dangerouslySetInnerHTML={mdToHtml(this.state.content)}
145 <label class="sr-only" htmlFor={this.id}>
150 <div class="col-sm-12 d-flex flex-wrap">
151 {this.props.buttonTitle && (
154 class="btn btn-sm btn-secondary mr-2"
155 disabled={this.props.disabled || this.state.loading}
157 {this.state.loading ? (
160 <span>{this.props.buttonTitle}</span>
164 {this.props.replyType && (
167 class="btn btn-sm btn-secondary mr-2"
168 onClick={linkEvent(this, this.handleReplyCancel)}
173 {this.state.content && (
175 className={`btn btn-sm btn-secondary mr-2 ${
176 this.state.previewMode && "active"
178 onClick={linkEvent(this, this.handlePreviewToggle)}
183 {/* A flex expander */}
184 <div class="flex-grow-1"></div>
186 class="btn btn-sm text-muted"
187 data-tippy-content={i18n.t("bold")}
188 aria-label={i18n.t("bold")}
189 onClick={linkEvent(this, this.handleInsertBold)}
191 <Icon icon="bold" classes="icon-inline" />
194 class="btn btn-sm text-muted"
195 data-tippy-content={i18n.t("italic")}
196 aria-label={i18n.t("italic")}
197 onClick={linkEvent(this, this.handleInsertItalic)}
199 <Icon icon="italic" classes="icon-inline" />
202 class="btn btn-sm text-muted"
203 data-tippy-content={i18n.t("link")}
204 aria-label={i18n.t("link")}
205 onClick={linkEvent(this, this.handleInsertLink)}
207 <Icon icon="link" classes="icon-inline" />
209 <form class="btn btn-sm text-muted font-weight-bold">
211 htmlFor={`file-upload-${this.id}`}
213 UserService.Instance.myUserInfo && "pointer"
215 data-tippy-content={i18n.t("upload_image")}
217 {this.state.imageLoading ? (
220 <Icon icon="image" classes="icon-inline" />
224 id={`file-upload-${this.id}`}
226 accept="image/*,video/*"
229 disabled={!UserService.Instance.myUserInfo}
230 onChange={linkEvent(this, this.handleImageUpload)}
234 class="btn btn-sm text-muted"
235 data-tippy-content={i18n.t("header")}
236 aria-label={i18n.t("header")}
237 onClick={linkEvent(this, this.handleInsertHeader)}
239 <Icon icon="header" classes="icon-inline" />
242 class="btn btn-sm text-muted"
243 data-tippy-content={i18n.t("strikethrough")}
244 aria-label={i18n.t("strikethrough")}
245 onClick={linkEvent(this, this.handleInsertStrikethrough)}
247 <Icon icon="strikethrough" classes="icon-inline" />
250 class="btn btn-sm text-muted"
251 data-tippy-content={i18n.t("quote")}
252 aria-label={i18n.t("quote")}
253 onClick={linkEvent(this, this.handleInsertQuote)}
255 <Icon icon="format_quote" classes="icon-inline" />
258 class="btn btn-sm text-muted"
259 data-tippy-content={i18n.t("list")}
260 aria-label={i18n.t("list")}
261 onClick={linkEvent(this, this.handleInsertList)}
263 <Icon icon="list" classes="icon-inline" />
266 class="btn btn-sm text-muted"
267 data-tippy-content={i18n.t("code")}
268 aria-label={i18n.t("code")}
269 onClick={linkEvent(this, this.handleInsertCode)}
271 <Icon icon="code" classes="icon-inline" />
274 class="btn btn-sm text-muted"
275 data-tippy-content={i18n.t("subscript")}
276 aria-label={i18n.t("subscript")}
277 onClick={linkEvent(this, this.handleInsertSubscript)}
279 <Icon icon="subscript" classes="icon-inline" />
282 class="btn btn-sm text-muted"
283 data-tippy-content={i18n.t("superscript")}
284 aria-label={i18n.t("superscript")}
285 onClick={linkEvent(this, this.handleInsertSuperscript)}
287 <Icon icon="superscript" classes="icon-inline" />
290 class="btn btn-sm text-muted"
291 data-tippy-content={i18n.t("spoiler")}
292 aria-label={i18n.t("spoiler")}
293 onClick={linkEvent(this, this.handleInsertSpoiler)}
295 <Icon icon="alert-triangle" classes="icon-inline" />
298 href={markdownHelpUrl}
299 class="btn btn-sm text-muted font-weight-bold"
300 title={i18n.t("formatting_help")}
303 <Icon icon="help-circle" classes="icon-inline" />
311 handleImageUploadPaste(i: MarkdownTextArea, event: any) {
312 let image = event.clipboardData.files[0];
314 i.handleImageUpload(i, image);
318 handleImageUpload(i: MarkdownTextArea, event: any) {
321 event.preventDefault();
322 file = event.target.files[0];
327 const formData = new FormData();
328 formData.append("images[]", file);
330 i.state.imageLoading = true;
337 .then(res => res.json())
339 console.log("pictrs upload:");
341 if (res.msg == "ok") {
342 let hash = res.files[0].file;
343 let url = `${pictrsUri}/${hash}`;
344 let deleteToken = res.files[0].delete_token;
345 let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`;
346 let imageMarkdown = `![](${url})`;
347 let content = i.state.content;
348 content = content ? `${content}\n${imageMarkdown}` : imageMarkdown;
349 i.state.content = content;
350 i.state.imageLoading = false;
353 let textarea: any = document.getElementById(i.id);
354 autosize.update(textarea);
356 i18n.t("click_to_delete_picture"),
357 i18n.t("picture_deleted"),
361 i.state.imageLoading = false;
363 toast(JSON.stringify(res), "danger");
367 i.state.imageLoading = false;
369 console.error(error);
370 toast(error, "danger");
375 if (this.props.onContentChange) {
376 this.props.onContentChange(this.state.content);
380 handleContentChange(i: MarkdownTextArea, event: any) {
381 i.state.content = event.target.value;
386 handlePreviewToggle(i: MarkdownTextArea, event: any) {
387 event.preventDefault();
388 i.state.previewMode = !i.state.previewMode;
392 handleSubmit(i: MarkdownTextArea, event: any) {
393 event.preventDefault();
394 i.state.loading = true;
396 let msg = { val: i.state.content, formId: i.formId };
397 i.props.onSubmit(msg);
400 handleReplyCancel(i: MarkdownTextArea) {
401 i.props.onReplyCancel();
404 handleInsertLink(i: MarkdownTextArea, event: any) {
405 event.preventDefault();
406 if (!i.state.content) {
407 i.state.content = "";
409 let textarea: any = document.getElementById(i.id);
410 let start: number = textarea.selectionStart;
411 let end: number = textarea.selectionEnd;
414 let selectedText = i.state.content.substring(start, end);
415 i.state.content = `${i.state.content.substring(
418 )}[${selectedText}]()${i.state.content.substring(end)}`;
420 setTimeout(() => (textarea.selectionEnd = end + 3), 10);
422 i.state.content += "[]()";
424 setTimeout(() => (textarea.selectionEnd -= 1), 10);
430 simpleSurround(chars: string) {
431 this.simpleSurroundBeforeAfter(chars, chars);
434 simpleBeginningofLine(chars: string) {
435 this.simpleSurroundBeforeAfter(`${chars} `, "", "");
438 simpleSurroundBeforeAfter(
443 if (!this.state.content) {
444 this.state.content = "";
446 let textarea: any = document.getElementById(this.id);
447 let start: number = textarea.selectionStart;
448 let end: number = textarea.selectionEnd;
451 let selectedText = this.state.content.substring(start, end);
452 this.state.content = `${this.state.content.substring(
455 )}${beforeChars}${selectedText}${afterChars}${this.state.content.substring(
459 this.state.content += `${beforeChars}${emptyChars}${afterChars}`;
461 this.contentChange();
462 this.setState(this.state);
467 textarea.setSelectionRange(
468 start + beforeChars.length,
469 end + afterChars.length
472 textarea.setSelectionRange(
473 start + beforeChars.length,
474 end + emptyChars.length + afterChars.length
479 autosize.update(textarea);
483 handleInsertBold(i: MarkdownTextArea, event: any) {
484 event.preventDefault();
485 i.simpleSurround("**");
488 handleInsertItalic(i: MarkdownTextArea, event: any) {
489 event.preventDefault();
490 i.simpleSurround("*");
493 handleInsertCode(i: MarkdownTextArea, event: any) {
494 event.preventDefault();
495 if (i.getSelectedText().split(/\r*\n/).length > 1) {
496 i.simpleSurroundBeforeAfter("```\n", "\n```");
498 i.simpleSurround("`");
502 handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
503 event.preventDefault();
504 i.simpleSurround("~~");
507 handleInsertList(i: MarkdownTextArea, event: any) {
508 event.preventDefault();
509 i.simpleBeginningofLine("-");
512 handleInsertQuote(i: MarkdownTextArea, event: any) {
513 event.preventDefault();
514 i.simpleBeginningofLine(">");
517 handleInsertHeader(i: MarkdownTextArea, event: any) {
518 event.preventDefault();
519 i.simpleBeginningofLine("#");
522 handleInsertSubscript(i: MarkdownTextArea, event: any) {
523 event.preventDefault();
524 i.simpleSurround("~");
527 handleInsertSuperscript(i: MarkdownTextArea, event: any) {
528 event.preventDefault();
529 i.simpleSurround("^");
532 simpleInsert(chars: string) {
533 if (!this.state.content) {
534 this.state.content = `${chars} `;
536 this.state.content += `\n${chars} `;
539 let textarea: any = document.getElementById(this.id);
542 autosize.update(textarea);
544 this.contentChange();
545 this.setState(this.state);
548 handleInsertSpoiler(i: MarkdownTextArea, event: any) {
549 event.preventDefault();
550 let beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`;
551 let afterChars = "\n:::\n";
552 i.simpleSurroundBeforeAfter(beforeChars, afterChars);
556 let textarea: any = document.getElementById(this.id);
557 let selectedText = window.getSelection().toString();
563 .join("\n") + "\n\n";
564 if (this.state.content == null) {
565 this.state.content = "";
567 this.state.content += "\n";
569 this.state.content += quotedText;
570 this.contentChange();
571 this.setState(this.state);
572 // Not sure why this needs a delay
573 setTimeout(() => autosize.update(textarea), 10);
577 getSelectedText(): string {
578 let textarea: any = document.getElementById(this.id);
579 let start: number = textarea.selectionStart;
580 let end: number = textarea.selectionEnd;
581 return start !== end ? this.state.content.substring(start, end) : "";