You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

592 lines
18 KiB

1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
  1. import React, { Component } from 'react';
  2. import { Prompt } from 'react-router-dom';
  3. import {
  4. mdToHtml,
  5. randomStr,
  6. markdownHelpUrl,
  7. setupTribute,
  8. setupTippy,
  9. // emojiPicker,
  10. } from '../utils';
  11. import { UserService } from '../services';
  12. import autosize from 'autosize';
  13. import Tribute from 'tributejs/src/Tribute.js';
  14. import { i18n } from '../i18next';
  15. import emojiShortName from 'emoji-short-name';
  16. import { Icon } from './icon';
  17. import { linkEvent } from '../linkEvent';
  18. import 'emoji-mart/css/emoji-mart.css';
  19. import { Picker } from 'emoji-mart';
  20. import { customEmojis } from '../custom-emojis';
  21. interface MarkdownTextAreaProps {
  22. initialContent: string;
  23. finished?: boolean;
  24. buttonTitle?: string;
  25. replyType?: boolean;
  26. focus?: boolean;
  27. disabled?: boolean;
  28. onSubmit?(val: string, event: any): any;
  29. onContentChange?(val: string): any;
  30. onReplyCancel?(): any;
  31. }
  32. interface MarkdownTextAreaState {
  33. content: string;
  34. previewMode: boolean;
  35. loading: boolean;
  36. imageLoading: boolean;
  37. showEmojiPicker: boolean;
  38. }
  39. export class MarkdownTextArea extends Component<
  40. MarkdownTextAreaProps,
  41. MarkdownTextAreaState
  42. > {
  43. private id = `comment-textarea-${randomStr()}`;
  44. private formId = `comment-form-${randomStr()}`;
  45. // @ts-ignore
  46. private tribute: Tribute;
  47. private emptyState: MarkdownTextAreaState = {
  48. content: this.props.initialContent || '',
  49. previewMode: false,
  50. loading: false,
  51. imageLoading: false,
  52. showEmojiPicker: false,
  53. };
  54. constructor(props: any, context: any) {
  55. super(props, context);
  56. this.tribute = setupTribute();
  57. // this.setupEmojiPicker();
  58. this.state = this.emptyState;
  59. }
  60. componentDidMount() {
  61. let textarea: any = document.getElementById(this.id);
  62. if (textarea) {
  63. autosize(textarea);
  64. this.tribute.attach(textarea);
  65. textarea.addEventListener('tribute-replaced', () => {
  66. this.setState({ content: textarea.value });
  67. autosize.update(textarea);
  68. });
  69. this.quoteInsert();
  70. const isDesktop = window.innerWidth > 768;
  71. if (this.props.focus || (this.props.focus && isDesktop)) {
  72. textarea.focus();
  73. }
  74. // TODO this is slow for some reason
  75. setupTippy();
  76. }
  77. }
  78. componentDidUpdate(prevProps) {
  79. if (this.state.content) {
  80. window.onbeforeunload = () => true;
  81. } else {
  82. window.onbeforeunload = undefined;
  83. }
  84. if (this.props.finished && !prevProps.finished) {
  85. let prevState = { ...this.state };
  86. prevState.content = '';
  87. prevState.loading = false;
  88. prevState.previewMode = false;
  89. this.setState(prevState);
  90. if (this.props.replyType) {
  91. this.props.onReplyCancel();
  92. }
  93. }
  94. }
  95. // @TODO:This was likely introducing the bug that cleared your replies, but keep an eye on it
  96. // UNSAFE_componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
  97. // if (nextProps.finished) {
  98. // this.state.previewMode = false;
  99. // this.state.loading = false;
  100. // this.state.content = '';
  101. // this.setState(this.state);
  102. // if (this.props.replyType) {
  103. // this.props.onReplyCancel();
  104. // }
  105. // let textarea: any = document.getElementById(this.id);
  106. // let form: any = document.getElementById(this.formId);
  107. // form.reset();
  108. // setTimeout(() => autosize.update(textarea), 10);
  109. // this.setState(this.state);
  110. // }
  111. // }
  112. componentWillUnmount() {
  113. window.onbeforeunload = null;
  114. }
  115. render() {
  116. return (
  117. <form id={this.formId} onSubmit={this.handleSubmit}>
  118. <Prompt when={!!this.state.content} message={i18n.t('block_leaving')} />
  119. <div className="form-group row">
  120. <div className="col-sm-12">
  121. <textarea
  122. id={this.id}
  123. className={`form-control ${this.state.previewMode && 'd-none'}`}
  124. value={this.state.content}
  125. onChange={this.handleContentChange}
  126. onKeyDown={this.handleKeydown}
  127. // onPaste={linkEvent(this, this.handleImageUploadPaste)}
  128. required
  129. disabled={this.props.disabled}
  130. rows={2}
  131. maxLength={10000}
  132. />
  133. {this.state.previewMode && (
  134. <div
  135. className="card card-body md-div"
  136. dangerouslySetInnerHTML={mdToHtml(this.state.content)}
  137. />
  138. )}
  139. </div>
  140. </div>
  141. <div className="row">
  142. <div className="col-sm-12 d-flex flex-wrap">
  143. {this.props.buttonTitle && (
  144. <button
  145. type="submit"
  146. className="btn btn-sm btn-secondary mr-2"
  147. disabled={this.props.disabled || this.state.loading}
  148. >
  149. {this.state.loading ? (
  150. <svg className="icon icon-spinner spin">
  151. <use xlinkHref="#icon-spinner" />
  152. </svg>
  153. ) : (
  154. <span>{this.props.buttonTitle}</span>
  155. )}
  156. </button>
  157. )}
  158. {this.props.replyType && (
  159. <button
  160. type="button"
  161. className="btn btn-sm btn-secondary mr-2"
  162. onClick={linkEvent(this, this.handleReplyCancel)}
  163. >
  164. {i18n.t('cancel')}
  165. </button>
  166. )}
  167. {this.state.content && (
  168. <button
  169. className={`btn btn-sm btn-secondary mr-2 ${
  170. this.state.previewMode && 'active'
  171. }`}
  172. onClick={this.handlePreviewToggle}
  173. >
  174. {i18n.t('preview')}
  175. </button>
  176. )}
  177. {/* A flex expander */}
  178. <div className="flex-grow-1" />
  179. <button
  180. className="btn btn-sm text-muted"
  181. data-tippy-content={i18n.t('bold')}
  182. onClick={linkEvent(this, this.handleInsertBold)}
  183. >
  184. <svg className="icon icon-inline">
  185. <use xlinkHref="#icon-bold" />
  186. </svg>
  187. </button>
  188. <button
  189. className="btn btn-sm text-muted"
  190. data-tippy-content={i18n.t('italic')}
  191. onClick={linkEvent(this, this.handleInsertItalic)}
  192. >
  193. <svg className="icon icon-inline">
  194. <use xlinkHref="#icon-italic" />
  195. </svg>
  196. </button>
  197. <button
  198. className="btn btn-sm text-muted"
  199. data-tippy-content={i18n.t('link')}
  200. onClick={this.handleInsertLink}
  201. >
  202. <svg className="icon icon-inline">
  203. <use xlinkHref="#icon-link" />
  204. </svg>
  205. </button>
  206. {/* <form className="btn btn-sm text-muted font-weight-bold">
  207. <label
  208. htmlFor={`file-upload-${this.id}`}
  209. className={`mb-0 ${UserService.Instance.user && 'pointer'}`}
  210. data-tippy-content={i18n.t('upload_image')}
  211. >
  212. {this.state.imageLoading ? (
  213. <svg className="icon icon-spinner spin">
  214. <use xlinkHref="#icon-spinner"></use>
  215. </svg>
  216. ) : (
  217. <svg className="icon icon-inline">
  218. <use xlinkHref="#icon-image"></use>
  219. </svg>
  220. )}
  221. </label>
  222. <input
  223. id={`file-upload-${this.id}`}
  224. type="file"
  225. accept="image/*,video/*"
  226. name="file"
  227. className="d-none"
  228. disabled={!UserService.Instance.user}
  229. // onChange={linkEvent(this, this.handleImageUpload)}
  230. />
  231. </form> */}
  232. <span style={{ position: 'relative' }}>
  233. <button
  234. onClick={this.toggleEmojiPicker}
  235. className="btn btn-sm text-muted"
  236. data-tippy-content={i18n.t('emoji_picker')}
  237. type="button"
  238. >
  239. <svg className="icon icon-inline">
  240. <use xlinkHref="#icon-smile" />
  241. </svg>
  242. </button>
  243. {this.state.showEmojiPicker && (
  244. <>
  245. <div className="emoji-picker-container">
  246. <Picker
  247. custom={customEmojis}
  248. onSelect={this.handleInsertEmoji}
  249. theme="auto"
  250. />
  251. </div>
  252. <div
  253. onClick={this.toggleEmojiPicker}
  254. className="click-away-container"
  255. />
  256. </>
  257. )}
  258. </span>
  259. <button
  260. className="btn btn-sm text-muted"
  261. data-tippy-content={i18n.t('header')}
  262. onClick={linkEvent(this, this.handleInsertHeader)}
  263. >
  264. <svg className="icon icon-inline">
  265. <use xlinkHref="#icon-header" />
  266. </svg>
  267. </button>
  268. <button
  269. className="btn btn-sm text-muted"
  270. data-tippy-content={i18n.t('strikethrough')}
  271. onClick={linkEvent(this, this.handleInsertStrikethrough)}
  272. >
  273. <svg className="icon icon-inline">
  274. <use xlinkHref="#icon-strikethrough" />
  275. </svg>
  276. </button>
  277. <button
  278. className="btn btn-sm text-muted"
  279. data-tippy-content={i18n.t('quote')}
  280. onClick={linkEvent(this, this.handleInsertQuote)}
  281. >
  282. <svg className="icon icon-inline">
  283. <use xlinkHref="#icon-format_quote" />
  284. </svg>
  285. </button>
  286. <button
  287. className="btn btn-sm text-muted"
  288. data-tippy-content={i18n.t('list')}
  289. onClick={linkEvent(this, this.handleInsertList)}
  290. >
  291. <svg className="icon icon-inline">
  292. <use xlinkHref="#icon-list" />
  293. </svg>
  294. </button>
  295. <button
  296. className="btn btn-sm text-muted"
  297. data-tippy-content={i18n.t('code')}
  298. onClick={linkEvent(this, this.handleInsertCode)}
  299. >
  300. <svg className="icon icon-inline">
  301. <use xlinkHref="#icon-code" />
  302. </svg>
  303. </button>
  304. <button
  305. className="btn btn-sm text-muted"
  306. data-tippy-content={i18n.t('spoiler')}
  307. onClick={this.handleInsertSpoiler}
  308. >
  309. <svg className="icon icon-inline">
  310. <use xlinkHref="#icon-alert-triangle" />
  311. </svg>
  312. </button>
  313. <a
  314. href={markdownHelpUrl}
  315. target="_blank"
  316. className="btn btn-sm text-muted font-weight-bold"
  317. title={i18n.t('formatting_help')}
  318. rel="noopener"
  319. >
  320. <Icon name="help" />
  321. </a>
  322. </div>
  323. </div>
  324. </form>
  325. );
  326. }
  327. // setupEmojiPicker() {
  328. // emojiPicker.on('emoji', twemojiHtmlStr => {
  329. // if (this.state.content == null) {
  330. // this.state.content = '';
  331. // }
  332. // var el = document.createElement('div');
  333. // el.innerHTML = twemojiHtmlStr;
  334. // let nativeUnicode = (el.childNodes[0] as HTMLElement).getAttribute('alt');
  335. // let shortName = `:${emojiShortName[nativeUnicode]}:`;
  336. // this.state.content += shortName;
  337. // this.setState(this.state);
  338. // });
  339. // }
  340. // handleImageUploadPaste(i: MarkdownTextArea, event: any) {
  341. // let image = event.clipboardData.files[0];
  342. // if (image) {
  343. // i.handleImageUpload(i, image);
  344. // }
  345. // }
  346. // @TODO: Disabled until
  347. // handleImageUpload(i: MarkdownTextArea, event: any) {
  348. // let file: any;
  349. // if (event.target) {
  350. // event.preventDefault();
  351. // file = event.target.files[0];
  352. // } else {
  353. // file = event;
  354. // }
  355. // const imageUploadUrl = `/pictrs/image`;
  356. // const formData = new FormData();
  357. // formData.append('images[]', file);
  358. // i.state.imageLoading = true;
  359. // i.setState(i.state);
  360. // fetch(imageUploadUrl, {
  361. // method: 'POST',
  362. // body: formData,
  363. // })
  364. // .then(res => res.json())
  365. // .then(res => {
  366. // console.log('pictrs upload:');
  367. // console.log(res);
  368. // if (res.msg == 'ok') {
  369. // let hash = res.files[0].file;
  370. // let url = `${window.location.origin}/pictrs/image/${hash}`;
  371. // let deleteToken = res.files[0].delete_token;
  372. // let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
  373. // let imageMarkdown = `![](${url})`;
  374. // let content = i.state.content;
  375. // content = content ? `${content}\n${imageMarkdown}` : imageMarkdown;
  376. // i.state.content = content;
  377. // i.state.imageLoading = false;
  378. // i.setState(i.state);
  379. // let textarea: any = document.getElementById(i.id);
  380. // autosize.update(textarea);
  381. // pictrsDeleteToast(
  382. // i18n.t('click_to_delete_picture'),
  383. // i18n.t('picture_deleted'),
  384. // deleteUrl
  385. // );
  386. // } else {
  387. // i.state.imageLoading = false;
  388. // i.setState(i.state);
  389. // toast(JSON.stringify(res), 'danger');
  390. // }
  391. // })
  392. // .catch(error => {
  393. // i.state.imageLoading = false;
  394. // i.setState(i.state);
  395. // toast(error, 'danger');
  396. // });
  397. // }
  398. // handleEmojiPickerClick(_i: MarkdownTextArea, event: any) {
  399. // event.preventDefault();
  400. // emojiPicker.togglePicker(event.target);
  401. // }
  402. toggleEmojiPicker = () => {
  403. this.setState({ showEmojiPicker: !this.state.showEmojiPicker });
  404. };
  405. handleContentChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
  406. this.setState({ content: event.target.value });
  407. if (this.props.onContentChange) {
  408. this.props.onContentChange(this.state.content);
  409. }
  410. };
  411. handlePreviewToggle = (event: React.SyntheticEvent) => {
  412. event.preventDefault();
  413. this.setState({ previewMode: !this.state.previewMode });
  414. };
  415. handleSubmit = (event: React.SyntheticEvent) => {
  416. event.preventDefault();
  417. this.setState({ loading: true });
  418. this.props.onSubmit(this.state.content, event);
  419. };
  420. handleKeydown = (event: React.KeyboardEvent) => {
  421. // if enter was pressed
  422. if (event.keyCode === 13) {
  423. // while command (mac) or ctrl is pressed
  424. if (event.metaKey || event.ctrlKey) {
  425. // submit comment
  426. this.setState({ loading: true });
  427. this.props.onSubmit(this.state.content, event);
  428. }
  429. }
  430. };
  431. handleReplyCancel(i: MarkdownTextArea) {
  432. i.props.onReplyCancel();
  433. }
  434. handleInsertEmoji = ({ colons: shortcode }: { colons: string }) => {
  435. const { content } = this.state;
  436. // pad the emoji with spaces
  437. this.setState({ content: `${content} ${shortcode} ` });
  438. this.toggleEmojiPicker();
  439. };
  440. handleInsertLink = (event: any) => {
  441. event.preventDefault();
  442. let content = this.state.content || '';
  443. let textarea: any = document.getElementById(this.id);
  444. let start: number = textarea.selectionStart;
  445. let end: number = textarea.selectionEnd;
  446. if (start !== end) {
  447. let selectedText = this.state.content.substring(start, end);
  448. content = `${this.state.content.substring(
  449. 0,
  450. start
  451. )} [${selectedText}]() ${this.state.content.substring(end)}`;
  452. textarea.focus();
  453. setTimeout(() => (textarea.selectionEnd = end + 4), 10);
  454. } else {
  455. content += '[]()';
  456. textarea.focus();
  457. setTimeout(() => (textarea.selectionEnd -= 1), 10);
  458. }
  459. this.setState({ content });
  460. };
  461. simpleSurround = (chars: string) => {
  462. this.simpleSurroundBeforeAfter(chars, chars);
  463. };
  464. simpleSurroundBeforeAfter = (beforeChars: string, afterChars: string) => {
  465. let content = this.state.content || '';
  466. let textarea: any = document.getElementById(this.id);
  467. let start: number = textarea.selectionStart;
  468. let end: number = textarea.selectionEnd;
  469. if (start !== end) {
  470. let selectedText = this.state.content.substring(start, end);
  471. content = `${this.state.content.substring(
  472. 0,
  473. start - 1
  474. )} ${beforeChars}${selectedText}${afterChars} ${this.state.content.substring(
  475. end + 1
  476. )}`;
  477. } else {
  478. content += `${beforeChars}___${afterChars}`;
  479. }
  480. this.setState({ content });
  481. setTimeout(() => {
  482. autosize.update(textarea);
  483. }, 10);
  484. };
  485. handleInsertBold(i: MarkdownTextArea, event: any) {
  486. event.preventDefault();
  487. i.simpleSurround('**');
  488. }
  489. handleInsertItalic(i: MarkdownTextArea, event: any) {
  490. event.preventDefault();
  491. i.simpleSurround('*');
  492. }
  493. handleInsertCode(i: MarkdownTextArea, event: any) {
  494. event.preventDefault();
  495. i.simpleSurround('`');
  496. }
  497. handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
  498. event.preventDefault();
  499. i.simpleSurround('~~');
  500. }
  501. handleInsertList(i: MarkdownTextArea, event: any) {
  502. event.preventDefault();
  503. i.simpleInsert('-');
  504. }
  505. handleInsertQuote(i: MarkdownTextArea, event: any) {
  506. event.preventDefault();
  507. i.simpleInsert('>');
  508. }
  509. handleInsertHeader(i: MarkdownTextArea, event: any) {
  510. event.preventDefault();
  511. i.simpleInsert('#');
  512. }
  513. simpleInsert = (chars: string) => {
  514. const content = !this.state.content ? `${chars} ` : `\n${chars} `;
  515. let textarea: any = document.getElementById(this.id);
  516. textarea.focus();
  517. setTimeout(() => {
  518. autosize.update(textarea);
  519. }, 10);
  520. this.setState({ content });
  521. };
  522. handleInsertSpoiler = (event: React.SyntheticEvent) => {
  523. event.preventDefault();
  524. let beforeChars = `\n::: spoiler ${i18n.t('spoiler')}\n`;
  525. let afterChars = '\n:::\n';
  526. this.simpleSurroundBeforeAfter(beforeChars, afterChars);
  527. };
  528. quoteInsert() {
  529. let textarea: any = document.getElementById(this.id);
  530. let selectedText = window.getSelection().toString();
  531. if (selectedText) {
  532. let quotedText =
  533. selectedText
  534. .split('\n')
  535. .map(t => `> ${t}`)
  536. .join('\n') + '\n\n';
  537. this.setState({
  538. content: quotedText,
  539. });
  540. // Not sure why this needs a delay
  541. setTimeout(() => autosize.update(textarea), 10);
  542. }
  543. }
  544. }