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";
20 import { Icon, Spinner } from "./icon";
21 import { LanguageSelect } from "./language-select";
23 interface MarkdownTextAreaProps {
24 initialContent: Option<string>;
25 initialLanguageId: Option<number>;
26 placeholder: Option<string>;
27 buttonTitle: Option<string>;
28 maxLength: Option<number>;
33 showLanguage?: boolean;
34 hideNavigationWarnings?: boolean;
35 onContentChange?(val: string): any;
36 onReplyCancel?(): any;
40 languageId: Option<number>;
42 allLanguages: Language[];
45 interface MarkdownTextAreaState {
46 content: Option<string>;
47 languageId: Option<number>;
50 imageLoading: boolean;
53 export class MarkdownTextArea extends Component<
54 MarkdownTextAreaProps,
57 private id = `comment-textarea-${randomStr()}`;
58 private formId = `comment-form-${randomStr()}`;
60 private emptyState: MarkdownTextAreaState = {
61 content: this.props.initialContent,
62 languageId: this.props.initialLanguageId,
68 constructor(props: any, context: any) {
69 super(props, context);
71 this.handleLanguageChange = this.handleLanguageChange.bind(this);
74 this.tribute = setupTribute();
76 this.state = this.emptyState;
80 let textarea: any = document.getElementById(this.id);
83 this.tribute.attach(textarea);
84 textarea.addEventListener("tribute-replaced", () => {
85 this.setState({ content: Some(textarea.value) });
86 autosize.update(textarea);
91 if (this.props.focus) {
95 // TODO this is slow for some reason
100 componentDidUpdate() {
101 if (!this.props.hideNavigationWarnings && this.state.content.isSome()) {
102 window.onbeforeunload = () => true;
104 window.onbeforeunload = undefined;
108 componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
109 if (nextProps.finished) {
110 this.setState({ previewMode: false, loading: false, content: None });
111 if (this.props.replyType) {
112 this.props.onReplyCancel();
115 let textarea: any = document.getElementById(this.id);
116 let form: any = document.getElementById(this.formId);
118 setTimeout(() => autosize.update(textarea), 10);
122 componentWillUnmount() {
123 window.onbeforeunload = null;
128 <form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}>
131 !this.props.hideNavigationWarnings && this.state.content.isSome()
133 message={i18n.t("block_leaving")}
135 <div className="form-group row">
136 <div className={`col-sm-12`}>
139 className={`form-control ${this.state.previewMode && "d-none"}`}
140 value={toUndefined(this.state.content)}
141 onInput={linkEvent(this, this.handleContentChange)}
142 onPaste={linkEvent(this, this.handleImageUploadPaste)}
144 disabled={this.props.disabled}
146 maxLength={this.props.maxLength.unwrapOr(10000)}
147 placeholder={toUndefined(this.props.placeholder)}
149 {this.state.previewMode &&
150 this.state.content.match({
153 className="card border-secondary card-body md-div"
154 dangerouslySetInnerHTML={mdToHtml(content)}
160 <label className="sr-only" htmlFor={this.id}>
164 {this.props.showLanguage && (
165 <div className="row justify-content-end">
166 <div className="col-sm-8">
168 allLanguages={this.props.allLanguages}
169 selectedLanguageIds={this.state.languageId.map(Array.of)}
171 onChange={this.handleLanguageChange}
176 <div className="row">
177 <div className="col-sm-12 d-flex flex-wrap">
178 {this.props.buttonTitle.match({
179 some: buttonTitle => (
182 className="btn btn-sm btn-secondary mr-2"
183 disabled={this.props.disabled || this.state.loading}
185 {this.state.loading ? (
188 <span>{buttonTitle}</span>
194 {this.props.replyType && (
197 className="btn btn-sm btn-secondary mr-2"
198 onClick={linkEvent(this, this.handleReplyCancel)}
203 {this.state.content.isSome() && (
205 className={`btn btn-sm btn-secondary mr-2 ${
206 this.state.previewMode && "active"
208 onClick={linkEvent(this, this.handlePreviewToggle)}
213 {/* A flex expander */}
214 <div className="flex-grow-1"></div>
216 className="btn btn-sm text-muted"
217 data-tippy-content={i18n.t("bold")}
218 aria-label={i18n.t("bold")}
219 onClick={linkEvent(this, this.handleInsertBold)}
221 <Icon icon="bold" classes="icon-inline" />
224 className="btn btn-sm text-muted"
225 data-tippy-content={i18n.t("italic")}
226 aria-label={i18n.t("italic")}
227 onClick={linkEvent(this, this.handleInsertItalic)}
229 <Icon icon="italic" classes="icon-inline" />
232 className="btn btn-sm text-muted"
233 data-tippy-content={i18n.t("link")}
234 aria-label={i18n.t("link")}
235 onClick={linkEvent(this, this.handleInsertLink)}
237 <Icon icon="link" classes="icon-inline" />
239 <form className="btn btn-sm text-muted font-weight-bold">
241 htmlFor={`file-upload-${this.id}`}
243 UserService.Instance.myUserInfo.isSome() && "pointer"
245 data-tippy-content={i18n.t("upload_image")}
247 {this.state.imageLoading ? (
250 <Icon icon="image" classes="icon-inline" />
254 id={`file-upload-${this.id}`}
256 accept="image/*,video/*"
259 disabled={UserService.Instance.myUserInfo.isNone()}
260 onChange={linkEvent(this, this.handleImageUpload)}
264 className="btn btn-sm text-muted"
265 data-tippy-content={i18n.t("header")}
266 aria-label={i18n.t("header")}
267 onClick={linkEvent(this, this.handleInsertHeader)}
269 <Icon icon="header" classes="icon-inline" />
272 className="btn btn-sm text-muted"
273 data-tippy-content={i18n.t("strikethrough")}
274 aria-label={i18n.t("strikethrough")}
275 onClick={linkEvent(this, this.handleInsertStrikethrough)}
277 <Icon icon="strikethrough" classes="icon-inline" />
280 className="btn btn-sm text-muted"
281 data-tippy-content={i18n.t("quote")}
282 aria-label={i18n.t("quote")}
283 onClick={linkEvent(this, this.handleInsertQuote)}
285 <Icon icon="format_quote" classes="icon-inline" />
288 className="btn btn-sm text-muted"
289 data-tippy-content={i18n.t("list")}
290 aria-label={i18n.t("list")}
291 onClick={linkEvent(this, this.handleInsertList)}
293 <Icon icon="list" classes="icon-inline" />
296 className="btn btn-sm text-muted"
297 data-tippy-content={i18n.t("code")}
298 aria-label={i18n.t("code")}
299 onClick={linkEvent(this, this.handleInsertCode)}
301 <Icon icon="code" classes="icon-inline" />
304 className="btn btn-sm text-muted"
305 data-tippy-content={i18n.t("subscript")}
306 aria-label={i18n.t("subscript")}
307 onClick={linkEvent(this, this.handleInsertSubscript)}
309 <Icon icon="subscript" classes="icon-inline" />
312 className="btn btn-sm text-muted"
313 data-tippy-content={i18n.t("superscript")}
314 aria-label={i18n.t("superscript")}
315 onClick={linkEvent(this, this.handleInsertSuperscript)}
317 <Icon icon="superscript" classes="icon-inline" />
320 className="btn btn-sm text-muted"
321 data-tippy-content={i18n.t("spoiler")}
322 aria-label={i18n.t("spoiler")}
323 onClick={linkEvent(this, this.handleInsertSpoiler)}
325 <Icon icon="alert-triangle" classes="icon-inline" />
328 href={markdownHelpUrl}
329 className="btn btn-sm text-muted font-weight-bold"
330 title={i18n.t("formatting_help")}
333 <Icon icon="help-circle" classes="icon-inline" />
341 handleImageUploadPaste(i: MarkdownTextArea, event: any) {
342 let image = event.clipboardData.files[0];
344 i.handleImageUpload(i, image);
348 handleImageUpload(i: MarkdownTextArea, event: any) {
351 event.preventDefault();
352 file = event.target.files[0];
357 const formData = new FormData();
358 formData.append("images[]", file);
360 i.setState({ imageLoading: true });
366 .then(res => res.json())
368 console.log("pictrs upload:");
370 if (res.msg == "ok") {
371 let hash = res.files[0].file;
372 let url = `${pictrsUri}/${hash}`;
373 let deleteToken = res.files[0].delete_token;
374 let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`;
375 let imageMarkdown = `![](${url})`;
378 i.state.content.match({
379 some: content => `${content}\n${imageMarkdown}`,
386 let textarea: any = document.getElementById(i.id);
387 autosize.update(textarea);
389 `${i18n.t("click_to_delete_picture")}: ${file.name}`,
390 `${i18n.t("picture_deleted")}: ${file.name}`,
391 `${i18n.t("failed_to_delete_picture")}: ${file.name}`,
395 i.setState({ imageLoading: false });
396 toast(JSON.stringify(res), "danger");
400 i.setState({ imageLoading: false });
401 console.error(error);
402 toast(error, "danger");
407 if (this.props.onContentChange) {
408 this.props.onContentChange(toUndefined(this.state.content));
412 handleContentChange(i: MarkdownTextArea, event: any) {
413 i.setState({ content: Some(event.target.value) });
417 handlePreviewToggle(i: MarkdownTextArea, event: any) {
418 event.preventDefault();
419 i.setState({ previewMode: !i.state.previewMode });
422 handleLanguageChange(val: number[]) {
423 this.setState({ languageId: Some(val[0]) });
426 handleSubmit(i: MarkdownTextArea, event: any) {
427 event.preventDefault();
428 i.setState({ loading: true });
430 val: i.state.content,
432 languageId: i.state.languageId,
434 i.props.onSubmit(msg);
437 handleReplyCancel(i: MarkdownTextArea) {
438 i.props.onReplyCancel();
441 handleInsertLink(i: MarkdownTextArea, event: any) {
442 event.preventDefault();
444 let textarea: any = document.getElementById(i.id);
445 let start: number = textarea.selectionStart;
446 let end: number = textarea.selectionEnd;
448 if (i.state.content.isNone()) {
449 i.setState({ content: Some("") });
452 let content = i.state.content.unwrap();
455 let selectedText = content.substring(start, end);
458 `${content.substring(0, start)}[${selectedText}]()${content.substring(
464 setTimeout(() => (textarea.selectionEnd = end + 3), 10);
466 i.setState({ content: Some(`${content} []()`) });
468 setTimeout(() => (textarea.selectionEnd -= 1), 10);
473 simpleSurround(chars: string) {
474 this.simpleSurroundBeforeAfter(chars, chars);
477 simpleBeginningofLine(chars: string) {
478 this.simpleSurroundBeforeAfter(`${chars}`, "", "");
481 simpleSurroundBeforeAfter(
486 if (this.state.content.isNone()) {
487 this.setState({ content: Some("") });
489 let textarea: any = document.getElementById(this.id);
490 let start: number = textarea.selectionStart;
491 let end: number = textarea.selectionEnd;
493 let content = this.state.content.unwrap();
496 let selectedText = content.substring(start, end);
499 `${content.substring(
502 )}${beforeChars}${selectedText}${afterChars}${content.substring(end)}`
507 content: Some(`${content}${beforeChars}${emptyChars}${afterChars}`),
510 this.contentChange();
515 textarea.setSelectionRange(
516 start + beforeChars.length,
517 end + afterChars.length
520 textarea.setSelectionRange(
521 start + beforeChars.length,
522 end + emptyChars.length + afterChars.length
527 autosize.update(textarea);
531 handleInsertBold(i: MarkdownTextArea, event: any) {
532 event.preventDefault();
533 i.simpleSurround("**");
536 handleInsertItalic(i: MarkdownTextArea, event: any) {
537 event.preventDefault();
538 i.simpleSurround("*");
541 handleInsertCode(i: MarkdownTextArea, event: any) {
542 event.preventDefault();
543 if (i.getSelectedText().split(/\r*\n/).length > 1) {
544 i.simpleSurroundBeforeAfter("```\n", "\n```");
546 i.simpleSurround("`");
550 handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
551 event.preventDefault();
552 i.simpleSurround("~~");
555 handleInsertList(i: MarkdownTextArea, event: any) {
556 event.preventDefault();
557 i.simpleBeginningofLine("-");
560 handleInsertQuote(i: MarkdownTextArea, event: any) {
561 event.preventDefault();
562 i.simpleBeginningofLine(">");
565 handleInsertHeader(i: MarkdownTextArea, event: any) {
566 event.preventDefault();
567 i.simpleBeginningofLine("#");
570 handleInsertSubscript(i: MarkdownTextArea, event: any) {
571 event.preventDefault();
572 i.simpleSurround("~");
575 handleInsertSuperscript(i: MarkdownTextArea, event: any) {
576 event.preventDefault();
577 i.simpleSurround("^");
580 simpleInsert(chars: string) {
581 if (this.state.content.isNone()) {
582 this.setState({ content: Some(`${chars} `) });
585 content: Some(`${this.state.content.unwrap()}\n${chars} `),
589 let textarea: any = document.getElementById(this.id);
592 autosize.update(textarea);
594 this.contentChange();
597 handleInsertSpoiler(i: MarkdownTextArea, event: any) {
598 event.preventDefault();
599 let beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`;
600 let afterChars = "\n:::\n";
601 i.simpleSurroundBeforeAfter(beforeChars, afterChars);
605 let textarea: any = document.getElementById(this.id);
606 let selectedText = window.getSelection().toString();
612 .join("\n") + "\n\n";
613 if (this.state.content.isNone()) {
614 this.setState({ content: Some("") });
616 this.setState({ content: Some(`${this.state.content.unwrap()}\n`) });
619 content: Some(`${this.state.content.unwrap()}${quotedText}`),
621 this.contentChange();
622 // Not sure why this needs a delay
623 setTimeout(() => autosize.update(textarea), 10);
627 getSelectedText(): string {
628 let textarea: any = document.getElementById(this.id);
629 let start: number = textarea.selectionStart;
630 let end: number = textarea.selectionEnd;
632 ? this.state.content.unwrap().substring(start, end)