1 import { None, Option, Some } from "@sniptt/monads";
2 import autosize from "autosize";
3 import { Component, linkEvent } from "inferno";
4 import { Prompt } from "inferno-router";
5 import { Language, toUndefined } from "lemmy-js-client";
6 import { pictrsUri } from "../../env";
7 import { i18n } from "../../i18next";
8 import { UserService } from "../../services";
11 markdownFieldCharacterLimit,
21 import { Icon, Spinner } from "./icon";
22 import { LanguageSelect } from "./language-select";
24 interface MarkdownTextAreaProps {
25 initialContent: Option<string>;
26 initialLanguageId: Option<number>;
27 placeholder: Option<string>;
28 buttonTitle: Option<string>;
29 maxLength: Option<number>;
34 showLanguage?: boolean;
35 hideNavigationWarnings?: boolean;
36 onContentChange?(val: string): any;
37 onReplyCancel?(): any;
41 languageId: Option<number>;
43 allLanguages: Language[];
44 siteLanguages: number[];
47 interface MarkdownTextAreaState {
48 content: Option<string>;
49 languageId: Option<number>;
52 imageLoading: boolean;
55 export class MarkdownTextArea extends Component<
56 MarkdownTextAreaProps,
59 private id = `comment-textarea-${randomStr()}`;
60 private formId = `comment-form-${randomStr()}`;
62 private emptyState: MarkdownTextAreaState = {
63 content: this.props.initialContent,
64 languageId: this.props.initialLanguageId,
70 constructor(props: any, context: any) {
71 super(props, context);
73 this.handleLanguageChange = this.handleLanguageChange.bind(this);
76 this.tribute = setupTribute();
78 this.state = this.emptyState;
82 let textarea: any = document.getElementById(this.id);
85 this.tribute.attach(textarea);
86 textarea.addEventListener("tribute-replaced", () => {
87 this.setState({ content: Some(textarea.value) });
88 autosize.update(textarea);
93 if (this.props.focus) {
97 // TODO this is slow for some reason
102 componentDidUpdate() {
103 if (!this.props.hideNavigationWarnings && this.state.content.isSome()) {
104 window.onbeforeunload = () => true;
106 window.onbeforeunload = undefined;
110 componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
111 if (nextProps.finished) {
112 this.setState({ previewMode: false, loading: false, content: None });
113 if (this.props.replyType) {
114 this.props.onReplyCancel();
117 let textarea: any = document.getElementById(this.id);
118 let form: any = document.getElementById(this.formId);
120 setTimeout(() => autosize.update(textarea), 10);
124 componentWillUnmount() {
125 window.onbeforeunload = null;
130 <form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}>
133 !this.props.hideNavigationWarnings && this.state.content.isSome()
135 message={i18n.t("block_leaving")}
137 <div className="form-group row">
138 <div className={`col-sm-12`}>
141 className={`form-control ${this.state.previewMode && "d-none"}`}
142 value={toUndefined(this.state.content)}
143 onInput={linkEvent(this, this.handleContentChange)}
144 onPaste={linkEvent(this, this.handleImageUploadPaste)}
146 disabled={this.props.disabled}
148 maxLength={this.props.maxLength.unwrapOr(
149 markdownFieldCharacterLimit
151 placeholder={toUndefined(this.props.placeholder)}
153 {this.state.previewMode &&
154 this.state.content.match({
157 className="card border-secondary card-body md-div"
158 dangerouslySetInnerHTML={mdToHtml(content)}
164 <label className="sr-only" htmlFor={this.id}>
168 <div className="row">
169 <div className="col-sm-12 d-flex flex-wrap">
170 {this.props.buttonTitle.match({
171 some: buttonTitle => (
174 className="btn btn-sm btn-secondary mr-2"
175 disabled={this.props.disabled || this.state.loading}
177 {this.state.loading ? (
180 <span>{buttonTitle}</span>
186 {this.props.replyType && (
189 className="btn btn-sm btn-secondary mr-2"
190 onClick={linkEvent(this, this.handleReplyCancel)}
195 {this.state.content.isSome() && (
197 className={`btn btn-sm btn-secondary mr-2 ${
198 this.state.previewMode && "active"
200 onClick={linkEvent(this, this.handlePreviewToggle)}
205 {/* A flex expander */}
206 <div className="flex-grow-1"></div>
208 {this.props.showLanguage && (
211 allLanguages={this.props.allLanguages}
212 selectedLanguageIds={this.state.languageId.map(Array.of)}
213 siteLanguages={this.props.siteLanguages}
215 onChange={this.handleLanguageChange}
219 className="btn btn-sm text-muted"
220 data-tippy-content={i18n.t("bold")}
221 aria-label={i18n.t("bold")}
222 onClick={linkEvent(this, this.handleInsertBold)}
224 <Icon icon="bold" classes="icon-inline" />
227 className="btn btn-sm text-muted"
228 data-tippy-content={i18n.t("italic")}
229 aria-label={i18n.t("italic")}
230 onClick={linkEvent(this, this.handleInsertItalic)}
232 <Icon icon="italic" classes="icon-inline" />
235 className="btn btn-sm text-muted"
236 data-tippy-content={i18n.t("link")}
237 aria-label={i18n.t("link")}
238 onClick={linkEvent(this, this.handleInsertLink)}
240 <Icon icon="link" classes="icon-inline" />
242 <form className="btn btn-sm text-muted font-weight-bold">
244 htmlFor={`file-upload-${this.id}`}
246 UserService.Instance.myUserInfo.isSome() && "pointer"
248 data-tippy-content={i18n.t("upload_image")}
250 {this.state.imageLoading ? (
253 <Icon icon="image" classes="icon-inline" />
257 id={`file-upload-${this.id}`}
259 accept="image/*,video/*"
262 disabled={UserService.Instance.myUserInfo.isNone()}
263 onChange={linkEvent(this, this.handleImageUpload)}
267 className="btn btn-sm text-muted"
268 data-tippy-content={i18n.t("header")}
269 aria-label={i18n.t("header")}
270 onClick={linkEvent(this, this.handleInsertHeader)}
272 <Icon icon="header" classes="icon-inline" />
275 className="btn btn-sm text-muted"
276 data-tippy-content={i18n.t("strikethrough")}
277 aria-label={i18n.t("strikethrough")}
278 onClick={linkEvent(this, this.handleInsertStrikethrough)}
280 <Icon icon="strikethrough" classes="icon-inline" />
283 className="btn btn-sm text-muted"
284 data-tippy-content={i18n.t("quote")}
285 aria-label={i18n.t("quote")}
286 onClick={linkEvent(this, this.handleInsertQuote)}
288 <Icon icon="format_quote" classes="icon-inline" />
291 className="btn btn-sm text-muted"
292 data-tippy-content={i18n.t("list")}
293 aria-label={i18n.t("list")}
294 onClick={linkEvent(this, this.handleInsertList)}
296 <Icon icon="list" classes="icon-inline" />
299 className="btn btn-sm text-muted"
300 data-tippy-content={i18n.t("code")}
301 aria-label={i18n.t("code")}
302 onClick={linkEvent(this, this.handleInsertCode)}
304 <Icon icon="code" classes="icon-inline" />
307 className="btn btn-sm text-muted"
308 data-tippy-content={i18n.t("subscript")}
309 aria-label={i18n.t("subscript")}
310 onClick={linkEvent(this, this.handleInsertSubscript)}
312 <Icon icon="subscript" classes="icon-inline" />
315 className="btn btn-sm text-muted"
316 data-tippy-content={i18n.t("superscript")}
317 aria-label={i18n.t("superscript")}
318 onClick={linkEvent(this, this.handleInsertSuperscript)}
320 <Icon icon="superscript" classes="icon-inline" />
323 className="btn btn-sm text-muted"
324 data-tippy-content={i18n.t("spoiler")}
325 aria-label={i18n.t("spoiler")}
326 onClick={linkEvent(this, this.handleInsertSpoiler)}
328 <Icon icon="alert-triangle" classes="icon-inline" />
331 href={markdownHelpUrl}
332 className="btn btn-sm text-muted font-weight-bold"
333 title={i18n.t("formatting_help")}
336 <Icon icon="help-circle" classes="icon-inline" />
344 handleImageUploadPaste(i: MarkdownTextArea, event: any) {
345 let image = event.clipboardData.files[0];
347 i.handleImageUpload(i, image);
351 handleImageUpload(i: MarkdownTextArea, event: any) {
354 event.preventDefault();
355 file = event.target.files[0];
360 const formData = new FormData();
361 formData.append("images[]", file);
363 i.setState({ imageLoading: true });
369 .then(res => res.json())
371 console.log("pictrs upload:");
373 if (res.msg == "ok") {
374 let hash = res.files[0].file;
375 let url = `${pictrsUri}/${hash}`;
376 let deleteToken = res.files[0].delete_token;
377 let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`;
378 let imageMarkdown = `![](${url})`;
381 i.state.content.match({
382 some: content => `${content}\n${imageMarkdown}`,
389 let textarea: any = document.getElementById(i.id);
390 autosize.update(textarea);
392 `${i18n.t("click_to_delete_picture")}: ${file.name}`,
393 `${i18n.t("picture_deleted")}: ${file.name}`,
394 `${i18n.t("failed_to_delete_picture")}: ${file.name}`,
398 i.setState({ imageLoading: false });
399 toast(JSON.stringify(res), "danger");
403 i.setState({ imageLoading: false });
404 console.error(error);
405 toast(error, "danger");
410 if (this.props.onContentChange) {
411 this.props.onContentChange(toUndefined(this.state.content));
415 handleContentChange(i: MarkdownTextArea, event: any) {
416 i.setState({ content: Some(event.target.value) });
420 handlePreviewToggle(i: MarkdownTextArea, event: any) {
421 event.preventDefault();
422 i.setState({ previewMode: !i.state.previewMode });
425 handleLanguageChange(val: number[]) {
426 this.setState({ languageId: Some(val[0]) });
429 handleSubmit(i: MarkdownTextArea, event: any) {
430 event.preventDefault();
431 i.setState({ loading: true });
433 val: i.state.content,
435 languageId: i.state.languageId,
437 i.props.onSubmit(msg);
440 handleReplyCancel(i: MarkdownTextArea) {
441 i.props.onReplyCancel();
444 handleInsertLink(i: MarkdownTextArea, event: any) {
445 event.preventDefault();
447 let textarea: any = document.getElementById(i.id);
448 let start: number = textarea.selectionStart;
449 let end: number = textarea.selectionEnd;
451 if (i.state.content.isNone()) {
452 i.setState({ content: Some("") });
455 let content = i.state.content.unwrap();
458 let selectedText = content.substring(start, end);
461 `${content.substring(0, start)}[${selectedText}]()${content.substring(
467 setTimeout(() => (textarea.selectionEnd = end + 3), 10);
469 i.setState({ content: Some(`${content} []()`) });
471 setTimeout(() => (textarea.selectionEnd -= 1), 10);
476 simpleSurround(chars: string) {
477 this.simpleSurroundBeforeAfter(chars, chars);
480 simpleBeginningofLine(chars: string) {
481 this.simpleSurroundBeforeAfter(`${chars}`, "", "");
484 simpleSurroundBeforeAfter(
489 if (this.state.content.isNone()) {
490 this.setState({ content: Some("") });
492 let textarea: any = document.getElementById(this.id);
493 let start: number = textarea.selectionStart;
494 let end: number = textarea.selectionEnd;
496 let content = this.state.content.unwrap();
499 let selectedText = content.substring(start, end);
502 `${content.substring(
505 )}${beforeChars}${selectedText}${afterChars}${content.substring(end)}`
510 content: Some(`${content}${beforeChars}${emptyChars}${afterChars}`),
513 this.contentChange();
518 textarea.setSelectionRange(
519 start + beforeChars.length,
520 end + afterChars.length
523 textarea.setSelectionRange(
524 start + beforeChars.length,
525 end + emptyChars.length + afterChars.length
530 autosize.update(textarea);
534 handleInsertBold(i: MarkdownTextArea, event: any) {
535 event.preventDefault();
536 i.simpleSurround("**");
539 handleInsertItalic(i: MarkdownTextArea, event: any) {
540 event.preventDefault();
541 i.simpleSurround("*");
544 handleInsertCode(i: MarkdownTextArea, event: any) {
545 event.preventDefault();
546 if (i.getSelectedText().split(/\r*\n/).length > 1) {
547 i.simpleSurroundBeforeAfter("```\n", "\n```");
549 i.simpleSurround("`");
553 handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
554 event.preventDefault();
555 i.simpleSurround("~~");
558 handleInsertList(i: MarkdownTextArea, event: any) {
559 event.preventDefault();
560 i.simpleBeginningofLine("-");
563 handleInsertQuote(i: MarkdownTextArea, event: any) {
564 event.preventDefault();
565 i.simpleBeginningofLine(">");
568 handleInsertHeader(i: MarkdownTextArea, event: any) {
569 event.preventDefault();
570 i.simpleBeginningofLine("#");
573 handleInsertSubscript(i: MarkdownTextArea, event: any) {
574 event.preventDefault();
575 i.simpleSurround("~");
578 handleInsertSuperscript(i: MarkdownTextArea, event: any) {
579 event.preventDefault();
580 i.simpleSurround("^");
583 simpleInsert(chars: string) {
584 if (this.state.content.isNone()) {
585 this.setState({ content: Some(`${chars} `) });
588 content: Some(`${this.state.content.unwrap()}\n${chars} `),
592 let textarea: any = document.getElementById(this.id);
595 autosize.update(textarea);
597 this.contentChange();
600 handleInsertSpoiler(i: MarkdownTextArea, event: any) {
601 event.preventDefault();
602 let beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`;
603 let afterChars = "\n:::\n";
604 i.simpleSurroundBeforeAfter(beforeChars, afterChars);
608 let textarea: any = document.getElementById(this.id);
609 let selectedText = window.getSelection().toString();
615 .join("\n") + "\n\n";
616 if (this.state.content.isNone()) {
617 this.setState({ content: Some("") });
619 this.setState({ content: Some(`${this.state.content.unwrap()}\n`) });
622 content: Some(`${this.state.content.unwrap()}${quotedText}`),
624 this.contentChange();
625 // Not sure why this needs a delay
626 setTimeout(() => autosize.update(textarea), 10);
630 getSelectedText(): string {
631 let textarea: any = document.getElementById(this.id);
632 let start: number = textarea.selectionStart;
633 let end: number = textarea.selectionEnd;
635 ? this.state.content.unwrap().substring(start, end)