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"),
390 i18n.t("picture_deleted"),
394 i.setState({ imageLoading: false });
395 toast(JSON.stringify(res), "danger");
399 i.setState({ imageLoading: false });
400 console.error(error);
401 toast(error, "danger");
406 if (this.props.onContentChange) {
407 this.props.onContentChange(toUndefined(this.state.content));
411 handleContentChange(i: MarkdownTextArea, event: any) {
412 i.setState({ content: Some(event.target.value) });
416 handlePreviewToggle(i: MarkdownTextArea, event: any) {
417 event.preventDefault();
418 i.setState({ previewMode: !i.state.previewMode });
421 handleLanguageChange(val: number[]) {
422 this.setState({ languageId: Some(val[0]) });
425 handleSubmit(i: MarkdownTextArea, event: any) {
426 event.preventDefault();
427 i.setState({ loading: true });
429 val: i.state.content,
431 languageId: i.state.languageId,
433 i.props.onSubmit(msg);
436 handleReplyCancel(i: MarkdownTextArea) {
437 i.props.onReplyCancel();
440 handleInsertLink(i: MarkdownTextArea, event: any) {
441 event.preventDefault();
443 let textarea: any = document.getElementById(i.id);
444 let start: number = textarea.selectionStart;
445 let end: number = textarea.selectionEnd;
447 if (i.state.content.isNone()) {
448 i.setState({ content: Some("") });
451 let content = i.state.content.unwrap();
454 let selectedText = content.substring(start, end);
457 `${content.substring(0, start)}[${selectedText}]()${content.substring(
463 setTimeout(() => (textarea.selectionEnd = end + 3), 10);
465 i.setState({ content: Some(`${content} []()`) });
467 setTimeout(() => (textarea.selectionEnd -= 1), 10);
472 simpleSurround(chars: string) {
473 this.simpleSurroundBeforeAfter(chars, chars);
476 simpleBeginningofLine(chars: string) {
477 this.simpleSurroundBeforeAfter(`${chars}`, "", "");
480 simpleSurroundBeforeAfter(
485 if (this.state.content.isNone()) {
486 this.setState({ content: Some("") });
488 let textarea: any = document.getElementById(this.id);
489 let start: number = textarea.selectionStart;
490 let end: number = textarea.selectionEnd;
492 let content = this.state.content.unwrap();
495 let selectedText = content.substring(start, end);
498 `${content.substring(
501 )}${beforeChars}${selectedText}${afterChars}${content.substring(end)}`
506 content: Some(`${content}${beforeChars}${emptyChars}${afterChars}`),
509 this.contentChange();
514 textarea.setSelectionRange(
515 start + beforeChars.length,
516 end + afterChars.length
519 textarea.setSelectionRange(
520 start + beforeChars.length,
521 end + emptyChars.length + afterChars.length
526 autosize.update(textarea);
530 handleInsertBold(i: MarkdownTextArea, event: any) {
531 event.preventDefault();
532 i.simpleSurround("**");
535 handleInsertItalic(i: MarkdownTextArea, event: any) {
536 event.preventDefault();
537 i.simpleSurround("*");
540 handleInsertCode(i: MarkdownTextArea, event: any) {
541 event.preventDefault();
542 if (i.getSelectedText().split(/\r*\n/).length > 1) {
543 i.simpleSurroundBeforeAfter("```\n", "\n```");
545 i.simpleSurround("`");
549 handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
550 event.preventDefault();
551 i.simpleSurround("~~");
554 handleInsertList(i: MarkdownTextArea, event: any) {
555 event.preventDefault();
556 i.simpleBeginningofLine("-");
559 handleInsertQuote(i: MarkdownTextArea, event: any) {
560 event.preventDefault();
561 i.simpleBeginningofLine(">");
564 handleInsertHeader(i: MarkdownTextArea, event: any) {
565 event.preventDefault();
566 i.simpleBeginningofLine("#");
569 handleInsertSubscript(i: MarkdownTextArea, event: any) {
570 event.preventDefault();
571 i.simpleSurround("~");
574 handleInsertSuperscript(i: MarkdownTextArea, event: any) {
575 event.preventDefault();
576 i.simpleSurround("^");
579 simpleInsert(chars: string) {
580 if (this.state.content.isNone()) {
581 this.setState({ content: Some(`${chars} `) });
584 content: Some(`${this.state.content.unwrap()}\n${chars} `),
588 let textarea: any = document.getElementById(this.id);
591 autosize.update(textarea);
593 this.contentChange();
596 handleInsertSpoiler(i: MarkdownTextArea, event: any) {
597 event.preventDefault();
598 let beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`;
599 let afterChars = "\n:::\n";
600 i.simpleSurroundBeforeAfter(beforeChars, afterChars);
604 let textarea: any = document.getElementById(this.id);
605 let selectedText = window.getSelection().toString();
611 .join("\n") + "\n\n";
612 if (this.state.content.isNone()) {
613 this.setState({ content: Some("") });
615 this.setState({ content: Some(`${this.state.content.unwrap()}\n`) });
618 content: Some(`${this.state.content.unwrap()}${quotedText}`),
620 this.contentChange();
621 // Not sure why this needs a delay
622 setTimeout(() => autosize.update(textarea), 10);
626 getSelectedText(): string {
627 let textarea: any = document.getElementById(this.id);
628 let start: number = textarea.selectionStart;
629 let end: number = textarea.selectionEnd;
631 ? this.state.content.unwrap().substring(start, end)