PageEditor.js 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import * as toastr from 'toastr';
  4. import { throttle, debounce } from 'throttle-debounce';
  5. import { EditorOptions, PreviewOptions } from './PageEditor/OptionsSelector';
  6. import Editor from './PageEditor/Editor';
  7. import Preview from './PageEditor/Preview';
  8. export default class PageEditor extends React.Component {
  9. constructor(props) {
  10. super(props);
  11. const config = this.props.crowi.getConfig();
  12. const isUploadable = config.upload.image || config.upload.file;
  13. const isUploadableFile = config.upload.file;
  14. const isMathJaxEnabled = !!config.env.MATHJAX;
  15. this.state = {
  16. revisionId: this.props.revisionId,
  17. markdown: this.props.markdown,
  18. isUploadable,
  19. isUploadableFile,
  20. isMathJaxEnabled,
  21. editorOptions: this.props.editorOptions,
  22. previewOptions: this.props.previewOptions,
  23. };
  24. this.setCaretLine = this.setCaretLine.bind(this);
  25. this.focusToEditor = this.focusToEditor.bind(this);
  26. this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
  27. this.onSave = this.onSave.bind(this);
  28. this.onUpload = this.onUpload.bind(this);
  29. this.onEditorScroll = this.onEditorScroll.bind(this);
  30. this.getMaxScrollTop = this.getMaxScrollTop.bind(this);
  31. this.getScrollTop = this.getScrollTop.bind(this);
  32. this.saveDraft = this.saveDraft.bind(this);
  33. this.clearDraft = this.clearDraft.bind(this);
  34. this.pageSavedHandler = this.pageSavedHandler.bind(this);
  35. this.apiErrorHandler = this.apiErrorHandler.bind(this);
  36. // create throttled function
  37. this.renderWithDebounce = debounce(50, throttle(100, this.renderPreview));
  38. this.saveDraftWithDebounce = debounce(300, this.saveDraft);
  39. }
  40. componentWillMount() {
  41. // restore draft
  42. this.restoreDraft();
  43. // initial rendering
  44. this.renderPreview(this.state.markdown);
  45. }
  46. focusToEditor() {
  47. this.refs.editor.forceToFocus();
  48. }
  49. /**
  50. * set caret position of editor
  51. * @param {number} line
  52. */
  53. setCaretLine(line) {
  54. this.refs.editor.setCaretLine(line);
  55. }
  56. /**
  57. * set options (used from the outside)
  58. * @param {object} editorOptions
  59. */
  60. setEditorOptions(editorOptions) {
  61. this.setState({ editorOptions });
  62. }
  63. /**
  64. * set options (used from the outside)
  65. * @param {object} previewOptions
  66. */
  67. setPreviewOptions(previewOptions) {
  68. this.setState({ previewOptions });
  69. }
  70. /**
  71. * the change event handler for `markdown` state
  72. * @param {string} value
  73. */
  74. onMarkdownChanged(value) {
  75. this.renderWithDebounce(value);
  76. this.saveDraftWithDebounce()
  77. }
  78. /**
  79. * the save event handler
  80. */
  81. onSave() {
  82. let endpoint;
  83. let data;
  84. // update
  85. if (this.props.pageId != null) {
  86. endpoint = '/pages.update';
  87. data = {
  88. page_id: this.props.pageId,
  89. revision_id: this.state.revisionId,
  90. body: this.state.markdown,
  91. };
  92. }
  93. // create
  94. else {
  95. endpoint = '/pages.create';
  96. data = {
  97. path: this.props.pagePath,
  98. body: this.state.markdown,
  99. };
  100. }
  101. this.props.crowi.apiPost(endpoint, data)
  102. .then((res) => {
  103. // show toastr
  104. toastr.success(undefined, 'Saved successfully', {
  105. closeButton: true,
  106. progressBar: true,
  107. newestOnTop: false,
  108. showDuration: "100",
  109. hideDuration: "100",
  110. timeOut: "1200",
  111. extendedTimeOut: "150",
  112. });
  113. this.pageSavedHandler(res.page);
  114. })
  115. .catch(this.apiErrorHandler)
  116. }
  117. /**
  118. * the upload event handler
  119. * @param {any} files
  120. */
  121. onUpload(file) {
  122. const endpoint = '/attachments.add';
  123. // create a FromData instance
  124. const formData = new FormData();
  125. formData.append('_csrf', this.props.crowi.csrfToken);
  126. formData.append('file', file);
  127. formData.append('path', this.props.pagePath);
  128. formData.append('page_id', this.props.pageId || 0);
  129. // post
  130. this.props.crowi.apiPost(endpoint, formData)
  131. .then((res) => {
  132. const url = res.url;
  133. const attachment = res.attachment;
  134. const fileName = attachment.originalName;
  135. let insertText = `[${fileName}](${url})`;
  136. // when image
  137. if (attachment.fileFormat.startsWith('image/')) {
  138. // modify to "![fileName](url)" syntax
  139. insertText = '!' + insertText;
  140. }
  141. this.refs.editor.insertText(insertText);
  142. // update page information if created
  143. if (res.pageCreated) {
  144. this.pageSavedHandler(res.page);
  145. }
  146. })
  147. .catch(this.apiErrorHandler)
  148. // finally
  149. .then(() => {
  150. this.refs.editor.terminateUploadingState();
  151. });
  152. }
  153. /**
  154. * the scroll event handler from codemirror
  155. * @param {any} data {left, top, width, height, clientWidth, clientHeight} object that represents the current scroll position, the size of the scrollable area, and the size of the visible area (minus scrollbars).
  156. * see https://codemirror.net/doc/manual.html#events
  157. */
  158. onEditorScroll(data) {
  159. const rate = data.top / (data.height - data.clientHeight)
  160. const top = this.getScrollTop(this.previewElement, rate);
  161. this.previewElement.scrollTop = top;
  162. }
  163. /**
  164. * transplanted from crowi-form.js -- 2018.01.21 Yuki Takei
  165. * @param {*} dom
  166. */
  167. getMaxScrollTop(dom) {
  168. var rect = dom.getBoundingClientRect();
  169. return dom.scrollHeight - rect.height;
  170. };
  171. /**
  172. * transplanted from crowi-form.js -- 2018.01.21 Yuki Takei
  173. * @param {*} dom
  174. */
  175. getScrollTop(dom, rate) {
  176. var maxScrollTop = this.getMaxScrollTop(dom);
  177. var top = maxScrollTop * rate;
  178. return top;
  179. };
  180. /*
  181. * methods for draft
  182. */
  183. restoreDraft() {
  184. // restore draft when the first time to edit
  185. const draft = this.props.crowi.findDraft(this.props.pagePath);
  186. if (!this.props.revisionId && draft != null) {
  187. this.setState({markdown: draft});
  188. }
  189. }
  190. saveDraft() {
  191. // only when the first time to edit
  192. if (!this.state.revisionId) {
  193. this.props.crowi.saveDraft(this.props.pagePath, this.state.markdown);
  194. }
  195. }
  196. clearDraft() {
  197. this.props.crowi.clearDraft(this.props.pagePath);
  198. }
  199. pageSavedHandler(page) {
  200. // update states
  201. this.setState({
  202. revisionId: page.revision._id,
  203. markdown: page.revision.body
  204. })
  205. // clear draft
  206. this.clearDraft();
  207. // dispatch onSaveSuccess event
  208. if (this.props.onSaveSuccess != null) {
  209. this.props.onSaveSuccess(page);
  210. }
  211. }
  212. apiErrorHandler(error) {
  213. console.error(error);
  214. toastr.error(error.message, 'Error occured', {
  215. closeButton: true,
  216. progressBar: true,
  217. newestOnTop: false,
  218. showDuration: "100",
  219. hideDuration: "100",
  220. timeOut: "3000",
  221. });
  222. }
  223. renderPreview(value) {
  224. const config = this.props.crowi.config;
  225. this.setState({ markdown: value });
  226. // render html
  227. var context = {
  228. markdown: this.state.markdown,
  229. dom: this.previewElement,
  230. currentPagePath: decodeURIComponent(location.pathname)
  231. };
  232. const interceptorManager = this.props.crowi.interceptorManager;
  233. interceptorManager.process('preRenderPreview', context)
  234. .then(() => interceptorManager.process('prePreProcess', context))
  235. .then(() => {
  236. context.markdown = crowiRenderer.preProcess(context.markdown);
  237. })
  238. .then(() => interceptorManager.process('postPreProcess', context))
  239. .then(() => {
  240. var parsedHTML = crowiRenderer.process(context.markdown);
  241. context['parsedHTML'] = parsedHTML;
  242. })
  243. .then(() => interceptorManager.process('prePostProcess', context))
  244. .then(() => {
  245. context.markdown = crowiRenderer.postProcess(context.parsedHTML, context.dom);
  246. })
  247. .then(() => interceptorManager.process('postPostProcess', context))
  248. .then(() => interceptorManager.process('preRenderPreviewHtml', context))
  249. .then(() => {
  250. this.setState({ html: context.parsedHTML });
  251. // set html to the hidden input (for submitting to save)
  252. $('#form-body').val(this.state.markdown);
  253. })
  254. // process interceptors for post rendering
  255. .then(() => interceptorManager.process('postRenderPreviewHtml', context));
  256. }
  257. render() {
  258. return (
  259. <div className="row">
  260. <div className="col-md-6 col-sm-12 page-editor-editor-container">
  261. <Editor ref="editor" value={this.state.markdown}
  262. editorOptions={this.state.editorOptions}
  263. isUploadable={this.state.isUploadable}
  264. isUploadableFile={this.state.isUploadableFile}
  265. onScroll={this.onEditorScroll}
  266. onChange={this.onMarkdownChanged}
  267. onSave={this.onSave}
  268. onUpload={this.onUpload}
  269. />
  270. </div>
  271. <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">
  272. <Preview html={this.state.html}
  273. inputRef={el => this.previewElement = el}
  274. isMathJaxEnabled={this.state.isMathJaxEnabled}
  275. renderMathJaxOnInit={false}
  276. previewOptions={this.state.previewOptions}
  277. />
  278. </div>
  279. </div>
  280. )
  281. }
  282. }
  283. PageEditor.propTypes = {
  284. crowi: PropTypes.object.isRequired,
  285. markdown: PropTypes.string.isRequired,
  286. pageId: PropTypes.string,
  287. revisionId: PropTypes.string,
  288. pagePath: PropTypes.string,
  289. onSaveSuccess: PropTypes.func,
  290. editorOptions: PropTypes.instanceOf(EditorOptions),
  291. previewOptions: PropTypes.instanceOf(PreviewOptions),
  292. };