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.setState({ content: Some(textarea.value) });
74 autosize.update(textarea);
79 if (this.props.focus) {
83 // TODO this is slow for some reason
88 componentDidUpdate() {
89 if (!this.props.hideNavigationWarnings && this.state.content.isSome()) {
90 window.onbeforeunload = () => true;
92 window.onbeforeunload = undefined;
96 componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
97 if (nextProps.finished) {
98 this.setState({ previewMode: false, loading: false, content: None });
99 if (this.props.replyType) {
100 this.props.onReplyCancel();
103 let textarea: any = document.getElementById(this.id);
104 let form: any = document.getElementById(this.formId);
106 setTimeout(() => autosize.update(textarea), 10);
110 componentWillUnmount() {
111 window.onbeforeunload = null;
116 <form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}>
119 !this.props.hideNavigationWarnings && this.state.content.isSome()
121 message={i18n.t("block_leaving")}
123 <div className="form-group row">
124 <div className={`col-sm-12`}>
127 className={`form-control ${this.state.previewMode && "d-none"}`}
128 value={toUndefined(this.state.content)}
129 onInput={linkEvent(this, this.handleContentChange)}
130 onPaste={linkEvent(this, this.handleImageUploadPaste)}
132 disabled={this.props.disabled}
134 maxLength={this.props.maxLength.unwrapOr(10000)}
135 placeholder={toUndefined(this.props.placeholder)}
137 {this.state.previewMode &&
138 this.state.content.match({
141 className="card border-secondary card-body md-div"
142 dangerouslySetInnerHTML={mdToHtml(content)}
148 <label className="sr-only" htmlFor={this.id}>
152 <div className="row">
153 <div className="col-sm-12 d-flex flex-wrap">
154 {this.props.buttonTitle.match({
155 some: buttonTitle => (
158 className="btn btn-sm btn-secondary mr-2"
159 disabled={this.props.disabled || this.state.loading}
161 {this.state.loading ? (
164 <span>{buttonTitle}</span>
170 {this.props.replyType && (
173 className="btn btn-sm btn-secondary mr-2"
174 onClick={linkEvent(this, this.handleReplyCancel)}
179 {this.state.content.isSome() && (
181 className={`btn btn-sm btn-secondary mr-2 ${
182 this.state.previewMode && "active"
184 onClick={linkEvent(this, this.handlePreviewToggle)}
189 {/* A flex expander */}
190 <div className="flex-grow-1"></div>
192 className="btn btn-sm text-muted"
193 data-tippy-content={i18n.t("bold")}
194 aria-label={i18n.t("bold")}
195 onClick={linkEvent(this, this.handleInsertBold)}
197 <Icon icon="bold" classes="icon-inline" />
200 className="btn btn-sm text-muted"
201 data-tippy-content={i18n.t("italic")}
202 aria-label={i18n.t("italic")}
203 onClick={linkEvent(this, this.handleInsertItalic)}
205 <Icon icon="italic" classes="icon-inline" />
208 className="btn btn-sm text-muted"
209 data-tippy-content={i18n.t("link")}
210 aria-label={i18n.t("link")}
211 onClick={linkEvent(this, this.handleInsertLink)}
213 <Icon icon="link" classes="icon-inline" />
215 <form className="btn btn-sm text-muted font-weight-bold">
217 htmlFor={`file-upload-${this.id}`}
219 UserService.Instance.myUserInfo.isSome() && "pointer"
221 data-tippy-content={i18n.t("upload_image")}
223 {this.state.imageLoading ? (
226 <Icon icon="image" classes="icon-inline" />
230 id={`file-upload-${this.id}`}
232 accept="image/*,video/*"
235 disabled={UserService.Instance.myUserInfo.isNone()}
236 onChange={linkEvent(this, this.handleImageUpload)}
240 className="btn btn-sm text-muted"
241 data-tippy-content={i18n.t("header")}
242 aria-label={i18n.t("header")}
243 onClick={linkEvent(this, this.handleInsertHeader)}
245 <Icon icon="header" classes="icon-inline" />
248 className="btn btn-sm text-muted"
249 data-tippy-content={i18n.t("strikethrough")}
250 aria-label={i18n.t("strikethrough")}
251 onClick={linkEvent(this, this.handleInsertStrikethrough)}
253 <Icon icon="strikethrough" classes="icon-inline" />
256 className="btn btn-sm text-muted"
257 data-tippy-content={i18n.t("quote")}
258 aria-label={i18n.t("quote")}
259 onClick={linkEvent(this, this.handleInsertQuote)}
261 <Icon icon="format_quote" classes="icon-inline" />
264 className="btn btn-sm text-muted"
265 data-tippy-content={i18n.t("list")}
266 aria-label={i18n.t("list")}
267 onClick={linkEvent(this, this.handleInsertList)}
269 <Icon icon="list" classes="icon-inline" />
272 className="btn btn-sm text-muted"
273 data-tippy-content={i18n.t("code")}
274 aria-label={i18n.t("code")}
275 onClick={linkEvent(this, this.handleInsertCode)}
277 <Icon icon="code" classes="icon-inline" />
280 className="btn btn-sm text-muted"
281 data-tippy-content={i18n.t("subscript")}
282 aria-label={i18n.t("subscript")}
283 onClick={linkEvent(this, this.handleInsertSubscript)}
285 <Icon icon="subscript" classes="icon-inline" />
288 className="btn btn-sm text-muted"
289 data-tippy-content={i18n.t("superscript")}
290 aria-label={i18n.t("superscript")}
291 onClick={linkEvent(this, this.handleInsertSuperscript)}
293 <Icon icon="superscript" classes="icon-inline" />
296 className="btn btn-sm text-muted"
297 data-tippy-content={i18n.t("spoiler")}
298 aria-label={i18n.t("spoiler")}
299 onClick={linkEvent(this, this.handleInsertSpoiler)}
301 <Icon icon="alert-triangle" classes="icon-inline" />
304 href={markdownHelpUrl}
305 className="btn btn-sm text-muted font-weight-bold"
306 title={i18n.t("formatting_help")}
309 <Icon icon="help-circle" classes="icon-inline" />
317 handleImageUploadPaste(i: MarkdownTextArea, event: any) {
318 let image = event.clipboardData.files[0];
320 i.handleImageUpload(i, image);
324 handleImageUpload(i: MarkdownTextArea, event: any) {
327 event.preventDefault();
328 file = event.target.files[0];
333 const formData = new FormData();
334 formData.append("images[]", file);
336 i.setState({ imageLoading: true });
342 .then(res => res.json())
344 console.log("pictrs upload:");
346 if (res.msg == "ok") {
347 let hash = res.files[0].file;
348 let url = `${pictrsUri}/${hash}`;
349 let deleteToken = res.files[0].delete_token;
350 let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`;
351 let imageMarkdown = `![](${url})`;
354 i.state.content.match({
355 some: content => `${content}\n${imageMarkdown}`,
362 let textarea: any = document.getElementById(i.id);
363 autosize.update(textarea);
365 i18n.t("click_to_delete_picture"),
366 i18n.t("picture_deleted"),
370 i.setState({ imageLoading: false });
371 toast(JSON.stringify(res), "danger");
375 i.setState({ imageLoading: false });
376 console.error(error);
377 toast(error, "danger");
382 if (this.props.onContentChange) {
383 this.props.onContentChange(toUndefined(this.state.content));
387 handleContentChange(i: MarkdownTextArea, event: any) {
388 i.setState({ content: Some(event.target.value) });
392 handlePreviewToggle(i: MarkdownTextArea, event: any) {
393 event.preventDefault();
394 i.setState({ previewMode: !i.state.previewMode });
397 handleSubmit(i: MarkdownTextArea, event: any) {
398 event.preventDefault();
399 i.setState({ loading: true });
400 let msg = { val: toUndefined(i.state.content), formId: i.formId };
401 i.props.onSubmit(msg);
404 handleReplyCancel(i: MarkdownTextArea) {
405 i.props.onReplyCancel();
408 handleInsertLink(i: MarkdownTextArea, event: any) {
409 event.preventDefault();
411 let textarea: any = document.getElementById(i.id);
412 let start: number = textarea.selectionStart;
413 let end: number = textarea.selectionEnd;
415 if (i.state.content.isNone()) {
416 i.setState({ content: Some("") });
419 let content = i.state.content.unwrap();
422 let selectedText = content.substring(start, end);
425 `${content.substring(0, start)}[${selectedText}]()${content.substring(
431 setTimeout(() => (textarea.selectionEnd = end + 3), 10);
433 i.setState({ content: Some(`${content} []()`) });
435 setTimeout(() => (textarea.selectionEnd -= 1), 10);
440 simpleSurround(chars: string) {
441 this.simpleSurroundBeforeAfter(chars, chars);
444 simpleBeginningofLine(chars: string) {
445 this.simpleSurroundBeforeAfter(`${chars}`, "", "");
448 simpleSurroundBeforeAfter(
453 if (this.state.content.isNone()) {
454 this.setState({ content: Some("") });
456 let textarea: any = document.getElementById(this.id);
457 let start: number = textarea.selectionStart;
458 let end: number = textarea.selectionEnd;
460 let content = this.state.content.unwrap();
463 let selectedText = content.substring(start, end);
466 `${content.substring(
469 )}${beforeChars}${selectedText}${afterChars}${content.substring(end)}`
474 content: Some(`${content}${beforeChars}${emptyChars}${afterChars}`),
477 this.contentChange();
482 textarea.setSelectionRange(
483 start + beforeChars.length,
484 end + afterChars.length
487 textarea.setSelectionRange(
488 start + beforeChars.length,
489 end + emptyChars.length + afterChars.length
494 autosize.update(textarea);
498 handleInsertBold(i: MarkdownTextArea, event: any) {
499 event.preventDefault();
500 i.simpleSurround("**");
503 handleInsertItalic(i: MarkdownTextArea, event: any) {
504 event.preventDefault();
505 i.simpleSurround("*");
508 handleInsertCode(i: MarkdownTextArea, event: any) {
509 event.preventDefault();
510 if (i.getSelectedText().split(/\r*\n/).length > 1) {
511 i.simpleSurroundBeforeAfter("```\n", "\n```");
513 i.simpleSurround("`");
517 handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
518 event.preventDefault();
519 i.simpleSurround("~~");
522 handleInsertList(i: MarkdownTextArea, event: any) {
523 event.preventDefault();
524 i.simpleBeginningofLine("-");
527 handleInsertQuote(i: MarkdownTextArea, event: any) {
528 event.preventDefault();
529 i.simpleBeginningofLine(">");
532 handleInsertHeader(i: MarkdownTextArea, event: any) {
533 event.preventDefault();
534 i.simpleBeginningofLine("#");
537 handleInsertSubscript(i: MarkdownTextArea, event: any) {
538 event.preventDefault();
539 i.simpleSurround("~");
542 handleInsertSuperscript(i: MarkdownTextArea, event: any) {
543 event.preventDefault();
544 i.simpleSurround("^");
547 simpleInsert(chars: string) {
548 if (this.state.content.isNone()) {
549 this.setState({ content: Some(`${chars} `) });
552 content: Some(`${this.state.content.unwrap()}\n${chars} `),
556 let textarea: any = document.getElementById(this.id);
559 autosize.update(textarea);
561 this.contentChange();
564 handleInsertSpoiler(i: MarkdownTextArea, event: any) {
565 event.preventDefault();
566 let beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`;
567 let afterChars = "\n:::\n";
568 i.simpleSurroundBeforeAfter(beforeChars, afterChars);
572 let textarea: any = document.getElementById(this.id);
573 let selectedText = window.getSelection().toString();
579 .join("\n") + "\n\n";
580 if (this.state.content.isNone()) {
581 this.setState({ content: Some("") });
583 this.setState({ content: Some(`${this.state.content.unwrap()}\n`) });
586 content: Some(`${this.state.content.unwrap()}${quotedText}`),
588 this.contentChange();
589 // Not sure why this needs a delay
590 setTimeout(() => autosize.update(textarea), 10);
594 getSelectedText(): string {
595 let textarea: any = document.getElementById(this.id);
596 let start: number = textarea.selectionStart;
597 let end: number = textarea.selectionEnd;
599 ? this.state.content.unwrap().substring(start, end)