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 { 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";
22 interface MarkdownTextAreaProps {
23 initialContent: Option<string>;
24 placeholder: Option<string>;
25 buttonTitle: Option<string>;
26 maxLength: Option<number>;
31 hideNavigationWarnings?: boolean;
32 onContentChange?(val: string): any;
33 onReplyCancel?(): any;
34 onSubmit?(msg: { val: string; formId: string }): any;
37 interface MarkdownTextAreaState {
38 content: Option<string>;
41 imageLoading: boolean;
44 export class MarkdownTextArea extends Component<
45 MarkdownTextAreaProps,
48 private id = `comment-textarea-${randomStr()}`;
49 private formId = `comment-form-${randomStr()}`;
51 private emptyState: MarkdownTextAreaState = {
52 content: this.props.initialContent,
58 constructor(props: any, context: any) {
59 super(props, context);
62 this.tribute = setupTribute();
64 this.state = this.emptyState;
68 let textarea: any = document.getElementById(this.id);
71 this.tribute.attach(textarea);
72 textarea.addEventListener("tribute-replaced", () => {
73 this.state.content = Some(textarea.value);
74 this.setState(this.state);
75 autosize.update(textarea);
80 if (this.props.focus) {
84 // TODO this is slow for some reason
89 componentDidUpdate() {
90 if (!this.props.hideNavigationWarnings && this.state.content.isSome()) {
91 window.onbeforeunload = () => true;
93 window.onbeforeunload = undefined;
97 componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
98 if (nextProps.finished) {
99 this.state.previewMode = false;
100 this.state.loading = false;
101 this.state.content = None;
102 this.setState(this.state);
103 if (this.props.replyType) {
104 this.props.onReplyCancel();
107 let textarea: any = document.getElementById(this.id);
108 let form: any = document.getElementById(this.formId);
110 setTimeout(() => autosize.update(textarea), 10);
111 this.setState(this.state);
115 componentWillUnmount() {
116 window.onbeforeunload = null;
121 <form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}>
124 !this.props.hideNavigationWarnings && this.state.content.isSome()
126 message={i18n.t("block_leaving")}
128 <div class="form-group row">
129 <div className={`col-sm-12`}>
132 className={`form-control ${this.state.previewMode && "d-none"}`}
133 value={toUndefined(this.state.content)}
134 onInput={linkEvent(this, this.handleContentChange)}
135 onPaste={linkEvent(this, this.handleImageUploadPaste)}
137 disabled={this.props.disabled}
139 maxLength={this.props.maxLength.unwrapOr(10000)}
140 placeholder={toUndefined(this.props.placeholder)}
142 {this.state.previewMode &&
143 this.state.content.match({
146 className="card border-secondary card-body md-div"
147 dangerouslySetInnerHTML={mdToHtml(content)}
153 <label class="sr-only" htmlFor={this.id}>
158 <div class="col-sm-12 d-flex flex-wrap">
159 {this.props.buttonTitle.match({
160 some: buttonTitle => (
163 class="btn btn-sm btn-secondary mr-2"
164 disabled={this.props.disabled || this.state.loading}
166 {this.state.loading ? (
169 <span>{buttonTitle}</span>
175 {this.props.replyType && (
178 class="btn btn-sm btn-secondary mr-2"
179 onClick={linkEvent(this, this.handleReplyCancel)}
184 {this.state.content.isSome() && (
186 className={`btn btn-sm btn-secondary mr-2 ${
187 this.state.previewMode && "active"
189 onClick={linkEvent(this, this.handlePreviewToggle)}
194 {/* A flex expander */}
195 <div class="flex-grow-1"></div>
197 class="btn btn-sm text-muted"
198 data-tippy-content={i18n.t("bold")}
199 aria-label={i18n.t("bold")}
200 onClick={linkEvent(this, this.handleInsertBold)}
202 <Icon icon="bold" classes="icon-inline" />
205 class="btn btn-sm text-muted"
206 data-tippy-content={i18n.t("italic")}
207 aria-label={i18n.t("italic")}
208 onClick={linkEvent(this, this.handleInsertItalic)}
210 <Icon icon="italic" classes="icon-inline" />
213 class="btn btn-sm text-muted"
214 data-tippy-content={i18n.t("link")}
215 aria-label={i18n.t("link")}
216 onClick={linkEvent(this, this.handleInsertLink)}
218 <Icon icon="link" classes="icon-inline" />
220 <form class="btn btn-sm text-muted font-weight-bold">
222 htmlFor={`file-upload-${this.id}`}
224 UserService.Instance.myUserInfo.isSome() && "pointer"
226 data-tippy-content={i18n.t("upload_image")}
228 {this.state.imageLoading ? (
231 <Icon icon="image" classes="icon-inline" />
235 id={`file-upload-${this.id}`}
237 accept="image/*,video/*"
240 disabled={UserService.Instance.myUserInfo.isNone()}
241 onChange={linkEvent(this, this.handleImageUpload)}
245 class="btn btn-sm text-muted"
246 data-tippy-content={i18n.t("header")}
247 aria-label={i18n.t("header")}
248 onClick={linkEvent(this, this.handleInsertHeader)}
250 <Icon icon="header" classes="icon-inline" />
253 class="btn btn-sm text-muted"
254 data-tippy-content={i18n.t("strikethrough")}
255 aria-label={i18n.t("strikethrough")}
256 onClick={linkEvent(this, this.handleInsertStrikethrough)}
258 <Icon icon="strikethrough" classes="icon-inline" />
261 class="btn btn-sm text-muted"
262 data-tippy-content={i18n.t("quote")}
263 aria-label={i18n.t("quote")}
264 onClick={linkEvent(this, this.handleInsertQuote)}
266 <Icon icon="format_quote" classes="icon-inline" />
269 class="btn btn-sm text-muted"
270 data-tippy-content={i18n.t("list")}
271 aria-label={i18n.t("list")}
272 onClick={linkEvent(this, this.handleInsertList)}
274 <Icon icon="list" classes="icon-inline" />
277 class="btn btn-sm text-muted"
278 data-tippy-content={i18n.t("code")}
279 aria-label={i18n.t("code")}
280 onClick={linkEvent(this, this.handleInsertCode)}
282 <Icon icon="code" classes="icon-inline" />
285 class="btn btn-sm text-muted"
286 data-tippy-content={i18n.t("subscript")}
287 aria-label={i18n.t("subscript")}
288 onClick={linkEvent(this, this.handleInsertSubscript)}
290 <Icon icon="subscript" classes="icon-inline" />
293 class="btn btn-sm text-muted"
294 data-tippy-content={i18n.t("superscript")}
295 aria-label={i18n.t("superscript")}
296 onClick={linkEvent(this, this.handleInsertSuperscript)}
298 <Icon icon="superscript" classes="icon-inline" />
301 class="btn btn-sm text-muted"
302 data-tippy-content={i18n.t("spoiler")}
303 aria-label={i18n.t("spoiler")}
304 onClick={linkEvent(this, this.handleInsertSpoiler)}
306 <Icon icon="alert-triangle" classes="icon-inline" />
309 href={markdownHelpUrl}
310 class="btn btn-sm text-muted font-weight-bold"
311 title={i18n.t("formatting_help")}
314 <Icon icon="help-circle" classes="icon-inline" />
322 handleImageUploadPaste(i: MarkdownTextArea, event: any) {
323 let image = event.clipboardData.files[0];
325 i.handleImageUpload(i, image);
329 handleImageUpload(i: MarkdownTextArea, event: any) {
332 event.preventDefault();
333 file = event.target.files[0];
338 const formData = new FormData();
339 formData.append("images[]", file);
341 i.state.imageLoading = true;
348 .then(res => res.json())
350 console.log("pictrs upload:");
352 if (res.msg == "ok") {
353 let hash = res.files[0].file;
354 let url = `${pictrsUri}/${hash}`;
355 let deleteToken = res.files[0].delete_token;
356 let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`;
357 let imageMarkdown = `![](${url})`;
358 i.state.content = Some(
359 i.state.content.match({
360 some: content => `${content}\n${imageMarkdown}`,
364 i.state.imageLoading = false;
367 let textarea: any = document.getElementById(i.id);
368 autosize.update(textarea);
370 i18n.t("click_to_delete_picture"),
371 i18n.t("picture_deleted"),
375 i.state.imageLoading = false;
377 toast(JSON.stringify(res), "danger");
381 i.state.imageLoading = false;
383 console.error(error);
384 toast(error, "danger");
389 if (this.props.onContentChange) {
390 this.props.onContentChange(toUndefined(this.state.content));
394 handleContentChange(i: MarkdownTextArea, event: any) {
395 i.state.content = Some(event.target.value);
400 handlePreviewToggle(i: MarkdownTextArea, event: any) {
401 event.preventDefault();
402 i.state.previewMode = !i.state.previewMode;
406 handleSubmit(i: MarkdownTextArea, event: any) {
407 event.preventDefault();
408 i.state.loading = true;
410 let msg = { val: toUndefined(i.state.content), formId: i.formId };
411 i.props.onSubmit(msg);
414 handleReplyCancel(i: MarkdownTextArea) {
415 i.props.onReplyCancel();
418 handleInsertLink(i: MarkdownTextArea, event: any) {
419 event.preventDefault();
421 let textarea: any = document.getElementById(i.id);
422 let start: number = textarea.selectionStart;
423 let end: number = textarea.selectionEnd;
425 if (i.state.content.isNone()) {
426 i.state.content = Some("");
429 let content = i.state.content.unwrap();
432 let selectedText = content.substring(start, end);
433 i.state.content = Some(
434 `${content.substring(0, start)}[${selectedText}]()${content.substring(
439 setTimeout(() => (textarea.selectionEnd = end + 3), 10);
441 i.state.content = Some(`${content} []()`);
443 setTimeout(() => (textarea.selectionEnd -= 1), 10);
449 simpleSurround(chars: string) {
450 this.simpleSurroundBeforeAfter(chars, chars);
453 simpleBeginningofLine(chars: string) {
454 this.simpleSurroundBeforeAfter(`${chars}`, "", "");
457 simpleSurroundBeforeAfter(
462 if (this.state.content.isNone()) {
463 this.state.content = Some("");
465 let textarea: any = document.getElementById(this.id);
466 let start: number = textarea.selectionStart;
467 let end: number = textarea.selectionEnd;
469 let content = this.state.content.unwrap();
472 let selectedText = content.substring(start, end);
473 this.state.content = Some(
474 `${content.substring(
477 )}${beforeChars}${selectedText}${afterChars}${content.substring(end)}`
480 this.state.content = Some(
481 `${content}${beforeChars}${emptyChars}${afterChars}`
484 this.contentChange();
485 this.setState(this.state);
490 textarea.setSelectionRange(
491 start + beforeChars.length,
492 end + afterChars.length
495 textarea.setSelectionRange(
496 start + beforeChars.length,
497 end + emptyChars.length + afterChars.length
502 autosize.update(textarea);
506 handleInsertBold(i: MarkdownTextArea, event: any) {
507 event.preventDefault();
508 i.simpleSurround("**");
511 handleInsertItalic(i: MarkdownTextArea, event: any) {
512 event.preventDefault();
513 i.simpleSurround("*");
516 handleInsertCode(i: MarkdownTextArea, event: any) {
517 event.preventDefault();
518 if (i.getSelectedText().split(/\r*\n/).length > 1) {
519 i.simpleSurroundBeforeAfter("```\n", "\n```");
521 i.simpleSurround("`");
525 handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
526 event.preventDefault();
527 i.simpleSurround("~~");
530 handleInsertList(i: MarkdownTextArea, event: any) {
531 event.preventDefault();
532 i.simpleBeginningofLine("-");
535 handleInsertQuote(i: MarkdownTextArea, event: any) {
536 event.preventDefault();
537 i.simpleBeginningofLine(">");
540 handleInsertHeader(i: MarkdownTextArea, event: any) {
541 event.preventDefault();
542 i.simpleBeginningofLine("#");
545 handleInsertSubscript(i: MarkdownTextArea, event: any) {
546 event.preventDefault();
547 i.simpleSurround("~");
550 handleInsertSuperscript(i: MarkdownTextArea, event: any) {
551 event.preventDefault();
552 i.simpleSurround("^");
555 simpleInsert(chars: string) {
556 if (this.state.content.isNone()) {
557 this.state.content = Some(`${chars} `);
559 this.state.content = Some(`${this.state.content.unwrap()}\n${chars} `);
562 let textarea: any = document.getElementById(this.id);
565 autosize.update(textarea);
567 this.contentChange();
568 this.setState(this.state);
571 handleInsertSpoiler(i: MarkdownTextArea, event: any) {
572 event.preventDefault();
573 let beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`;
574 let afterChars = "\n:::\n";
575 i.simpleSurroundBeforeAfter(beforeChars, afterChars);
579 let textarea: any = document.getElementById(this.id);
580 let selectedText = window.getSelection().toString();
586 .join("\n") + "\n\n";
587 if (this.state.content.isNone()) {
588 this.state.content = Some("");
590 this.state.content = Some(`${this.state.content.unwrap()}\n`);
592 this.state.content = Some(`${this.state.content.unwrap()}${quotedText}`);
593 this.contentChange();
594 this.setState(this.state);
595 // Not sure why this needs a delay
596 setTimeout(() => autosize.update(textarea), 10);
600 getSelectedText(): string {
601 let textarea: any = document.getElementById(this.id);
602 let start: number = textarea.selectionStart;
603 let end: number = textarea.selectionEnd;
605 ? this.state.content.unwrap().substring(start, end)