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").concat('\n(', file.name,')'),
371 i18n.t("picture_deleted").concat('\n(', file.name,')'),
372 i18n.t("fail_picture_deleted").concat('\n(', file.name,')'),
376 i.state.imageLoading = false;
378 toast(JSON.stringify(res), "danger");
382 i.state.imageLoading = false;
384 console.error(error);
385 toast(error, "danger");
390 if (this.props.onContentChange) {
391 this.props.onContentChange(toUndefined(this.state.content));
395 handleContentChange(i: MarkdownTextArea, event: any) {
396 i.state.content = Some(event.target.value);
401 handlePreviewToggle(i: MarkdownTextArea, event: any) {
402 event.preventDefault();
403 i.state.previewMode = !i.state.previewMode;
407 handleSubmit(i: MarkdownTextArea, event: any) {
408 event.preventDefault();
409 i.state.loading = true;
411 let msg = { val: toUndefined(i.state.content), formId: i.formId };
412 i.props.onSubmit(msg);
415 handleReplyCancel(i: MarkdownTextArea) {
416 i.props.onReplyCancel();
419 handleInsertLink(i: MarkdownTextArea, event: any) {
420 event.preventDefault();
422 let textarea: any = document.getElementById(i.id);
423 let start: number = textarea.selectionStart;
424 let end: number = textarea.selectionEnd;
426 if (i.state.content.isNone()) {
427 i.state.content = Some("");
430 let content = i.state.content.unwrap();
433 let selectedText = content.substring(start, end);
434 i.state.content = Some(
435 `${content.substring(0, start)}[${selectedText}]()${content.substring(
440 setTimeout(() => (textarea.selectionEnd = end + 3), 10);
442 i.state.content = Some(`${content} []()`);
444 setTimeout(() => (textarea.selectionEnd -= 1), 10);
450 simpleSurround(chars: string) {
451 this.simpleSurroundBeforeAfter(chars, chars);
454 simpleBeginningofLine(chars: string) {
455 this.simpleSurroundBeforeAfter(`${chars}`, "", "");
458 simpleSurroundBeforeAfter(
463 if (this.state.content.isNone()) {
464 this.state.content = Some("");
466 let textarea: any = document.getElementById(this.id);
467 let start: number = textarea.selectionStart;
468 let end: number = textarea.selectionEnd;
470 let content = this.state.content.unwrap();
473 let selectedText = content.substring(start, end);
474 this.state.content = Some(
475 `${content.substring(
478 )}${beforeChars}${selectedText}${afterChars}${content.substring(end)}`
481 this.state.content = Some(
482 `${content}${beforeChars}${emptyChars}${afterChars}`
485 this.contentChange();
486 this.setState(this.state);
491 textarea.setSelectionRange(
492 start + beforeChars.length,
493 end + afterChars.length
496 textarea.setSelectionRange(
497 start + beforeChars.length,
498 end + emptyChars.length + afterChars.length
503 autosize.update(textarea);
507 handleInsertBold(i: MarkdownTextArea, event: any) {
508 event.preventDefault();
509 i.simpleSurround("**");
512 handleInsertItalic(i: MarkdownTextArea, event: any) {
513 event.preventDefault();
514 i.simpleSurround("*");
517 handleInsertCode(i: MarkdownTextArea, event: any) {
518 event.preventDefault();
519 if (i.getSelectedText().split(/\r*\n/).length > 1) {
520 i.simpleSurroundBeforeAfter("```\n", "\n```");
522 i.simpleSurround("`");
526 handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
527 event.preventDefault();
528 i.simpleSurround("~~");
531 handleInsertList(i: MarkdownTextArea, event: any) {
532 event.preventDefault();
533 i.simpleBeginningofLine("-");
536 handleInsertQuote(i: MarkdownTextArea, event: any) {
537 event.preventDefault();
538 i.simpleBeginningofLine(">");
541 handleInsertHeader(i: MarkdownTextArea, event: any) {
542 event.preventDefault();
543 i.simpleBeginningofLine("#");
546 handleInsertSubscript(i: MarkdownTextArea, event: any) {
547 event.preventDefault();
548 i.simpleSurround("~");
551 handleInsertSuperscript(i: MarkdownTextArea, event: any) {
552 event.preventDefault();
553 i.simpleSurround("^");
556 simpleInsert(chars: string) {
557 if (this.state.content.isNone()) {
558 this.state.content = Some(`${chars} `);
560 this.state.content = Some(`${this.state.content.unwrap()}\n${chars} `);
563 let textarea: any = document.getElementById(this.id);
566 autosize.update(textarea);
568 this.contentChange();
569 this.setState(this.state);
572 handleInsertSpoiler(i: MarkdownTextArea, event: any) {
573 event.preventDefault();
574 let beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`;
575 let afterChars = "\n:::\n";
576 i.simpleSurroundBeforeAfter(beforeChars, afterChars);
580 let textarea: any = document.getElementById(this.id);
581 let selectedText = window.getSelection().toString();
587 .join("\n") + "\n\n";
588 if (this.state.content.isNone()) {
589 this.state.content = Some("");
591 this.state.content = Some(`${this.state.content.unwrap()}\n`);
593 this.state.content = Some(`${this.state.content.unwrap()}${quotedText}`);
594 this.contentChange();
595 this.setState(this.state);
596 // Not sure why this needs a delay
597 setTimeout(() => autosize.update(textarea), 10);
601 getSelectedText(): string {
602 let textarea: any = document.getElementById(this.id);
603 let start: number = textarea.selectionStart;
604 let end: number = textarea.selectionEnd;
606 ? this.state.content.unwrap().substring(start, end)