CodeMirrorEditor.jsx 34 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166
  1. import React, { useCallback } from 'react';
  2. import { createValidator } from '@growi/codemirror-textlint';
  3. import { commands } from 'codemirror';
  4. import { JSHINT } from 'jshint';
  5. import * as loadCssSync from 'load-css-file';
  6. import PropTypes from 'prop-types';
  7. import { Button } from 'reactstrap';
  8. import * as loadScript from 'simple-load-script';
  9. import { throttle, debounce } from 'throttle-debounce';
  10. import urljoin from 'url-join';
  11. import InterceptorManager from '~/services/interceptor-manager';
  12. import { useDrawioModal } from '~/stores/modal';
  13. import loggerFactory from '~/utils/logger';
  14. import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
  15. import AbstractEditor from './AbstractEditor';
  16. import CommentMentionHelper from './CommentMentionHelper';
  17. import { DrawioModal } from './DrawioModal';
  18. import EditorIcon from './EditorIcon';
  19. import EmojiPicker from './EmojiPicker';
  20. import EmojiPickerHelper from './EmojiPickerHelper';
  21. import GridEditModal from './GridEditModal';
  22. import geu from './GridEditorUtil';
  23. // import HandsontableModal from './HandsontableModal';
  24. import LinkEditModal from './LinkEditModal';
  25. import mdu from './MarkdownDrawioUtil';
  26. import markdownLinkUtil from './MarkdownLinkUtil';
  27. import markdownListUtil from './MarkdownListUtil';
  28. import MarkdownTableInterceptor from './MarkdownTableInterceptor';
  29. import mtu from './MarkdownTableUtil';
  30. import pasteHelper from './PasteHelper';
  31. import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
  32. import SimpleCheatsheet from './SimpleCheatsheet';
  33. import styles from './CodeMirrorEditor.module.scss';
  34. // Textlint
  35. window.JSHINT = JSHINT;
  36. window.kuromojin = { dicPath: '/static/dict' };
  37. require('codemirror/addon/display/placeholder');
  38. require('codemirror/addon/edit/matchbrackets');
  39. require('codemirror/addon/edit/matchtags');
  40. require('codemirror/addon/edit/closetag');
  41. require('codemirror/addon/edit/continuelist');
  42. require('codemirror/addon/hint/show-hint');
  43. require('codemirror/addon/search/searchcursor');
  44. require('codemirror/addon/search/match-highlighter');
  45. require('codemirror/addon/selection/active-line');
  46. require('codemirror/addon/scroll/annotatescrollbar');
  47. require('codemirror/addon/scroll/scrollpastend');
  48. require('codemirror/addon/fold/foldcode');
  49. require('codemirror/addon/fold/foldgutter');
  50. require('codemirror/addon/fold/markdown-fold');
  51. require('codemirror/addon/fold/brace-fold');
  52. require('codemirror/addon/display/placeholder');
  53. require('codemirror/addon/lint/lint');
  54. require('~/client/util/codemirror/autorefresh.ext');
  55. require('~/client/util/codemirror/drawio-fold.ext');
  56. require('~/client/util/codemirror/gfm-growi.mode');
  57. // import modes to highlight
  58. require('codemirror/mode/clike/clike');
  59. require('codemirror/mode/css/css');
  60. require('codemirror/mode/django/django');
  61. require('codemirror/mode/erlang/erlang');
  62. require('codemirror/mode/gfm/gfm');
  63. require('codemirror/mode/go/go');
  64. require('codemirror/mode/javascript/javascript');
  65. require('codemirror/mode/jsx/jsx');
  66. require('codemirror/mode/mathematica/mathematica');
  67. require('codemirror/mode/nginx/nginx');
  68. require('codemirror/mode/perl/perl');
  69. require('codemirror/mode/php/php');
  70. require('codemirror/mode/python/python');
  71. require('codemirror/mode/r/r');
  72. require('codemirror/mode/ruby/ruby');
  73. require('codemirror/mode/rust/rust');
  74. require('codemirror/mode/sass/sass');
  75. require('codemirror/mode/shell/shell');
  76. require('codemirror/mode/sql/sql');
  77. require('codemirror/mode/stex/stex');
  78. require('codemirror/mode/stylus/stylus');
  79. require('codemirror/mode/swift/swift');
  80. require('codemirror/mode/toml/toml');
  81. require('codemirror/mode/vb/vb');
  82. require('codemirror/mode/vue/vue');
  83. require('codemirror/mode/xml/xml');
  84. require('codemirror/mode/yaml/yaml');
  85. const MARKDOWN_TABLE_ACTIVATED_CLASS = 'markdown-table-activated';
  86. const MARKDOWN_LINK_ACTIVATED_CLASS = 'markdown-link-activated';
  87. class CodeMirrorEditor extends AbstractEditor {
  88. constructor(props) {
  89. super(props);
  90. this.logger = loggerFactory('growi:PageEditor:CodeMirrorEditor');
  91. this.state = {
  92. isGfmMode: this.props.isGfmMode,
  93. isLoadingKeymap: false,
  94. isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value?.length === 0,
  95. isCheatsheetModalShown: false,
  96. additionalClassSet: new Set(),
  97. isEmojiPickerShown: false,
  98. emojiSearchText: '',
  99. startPosWithEmojiPickerModeTurnedOn: null,
  100. isEmojiPickerMode: false,
  101. };
  102. this.cm = React.createRef();
  103. this.gridEditModal = React.createRef();
  104. this.linkEditModal = React.createRef();
  105. this.handsontableModal = React.createRef();
  106. this.drawioModal = React.createRef();
  107. this.init();
  108. this.getCodeMirror = this.getCodeMirror.bind(this);
  109. this.getBol = this.getBol.bind(this);
  110. this.getEol = this.getEol.bind(this);
  111. this.loadTheme = this.loadTheme.bind(this);
  112. this.loadKeymapMode = this.loadKeymapMode.bind(this);
  113. this.setKeymapMode = this.setKeymapMode.bind(this);
  114. this.handleEnterKey = this.handleEnterKey.bind(this);
  115. this.handleCtrlEnterKey = this.handleCtrlEnterKey.bind(this);
  116. this.scrollCursorIntoViewHandler = this.scrollCursorIntoViewHandler.bind(this);
  117. this.pasteHandler = this.pasteHandler.bind(this);
  118. this.cursorHandler = this.cursorHandler.bind(this);
  119. this.changeHandler = this.changeHandler.bind(this);
  120. this.turnOnEmojiPickerMode = this.turnOnEmojiPickerMode.bind(this);
  121. this.turnOffEmojiPickerMode = this.turnOffEmojiPickerMode.bind(this);
  122. this.windowClickHandler = this.windowClickHandler.bind(this);
  123. this.keyDownHandler = this.keyDownHandler.bind(this);
  124. this.keyDownHandlerForEmojiPicker = this.keyDownHandlerForEmojiPicker.bind(this);
  125. this.keyDownHandlerForEmojiPickerThrottled = throttle(400, this.keyDownHandlerForEmojiPicker);
  126. this.showEmojiPicker = this.showEmojiPicker.bind(this);
  127. this.keyPressHandlerForEmojiPicker = this.keyPressHandlerForEmojiPicker.bind(this);
  128. this.keyPressHandlerForEmojiPickerThrottled = debounce(50, throttle(200, this.keyPressHandlerForEmojiPicker));
  129. this.keyPressHandler = this.keyPressHandler.bind(this);
  130. this.updateCheatsheetStates = this.updateCheatsheetStates.bind(this);
  131. this.renderLoadingKeymapOverlay = this.renderLoadingKeymapOverlay.bind(this);
  132. this.renderCheatsheetModalButton = this.renderCheatsheetModalButton.bind(this);
  133. this.makeHeaderHandler = this.makeHeaderHandler.bind(this);
  134. this.showGridEditorHandler = this.showGridEditorHandler.bind(this);
  135. this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
  136. this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
  137. this.foldDrawioSection = this.foldDrawioSection.bind(this);
  138. this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
  139. }
  140. init() {
  141. this.cmCdnRoot = 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0';
  142. this.cmNoCdnScriptRoot = '/static/js/cdn';
  143. this.cmNoCdnStyleRoot = '/static/styles/cdn';
  144. this.interceptorManager = new InterceptorManager();
  145. this.interceptorManager.addInterceptors([
  146. new PreventMarkdownListInterceptor(),
  147. new MarkdownTableInterceptor(),
  148. ]);
  149. this.loadedThemeSet = new Set(['eclipse', 'elegant']); // themes imported in _vendor.scss
  150. this.loadedKeymapSet = new Set();
  151. }
  152. componentDidMount() {
  153. // ensure to be able to resolve 'this' to use 'codemirror.commands.save'
  154. this.getCodeMirror().codeMirrorEditor = this;
  155. // mark clean
  156. this.getCodeMirror().getDoc().markClean();
  157. // fold drawio section
  158. this.foldDrawioSection();
  159. // initialize commentMentionHelper if comment editor is opened
  160. if (this.props.isComment) {
  161. this.commentMentionHelper = new CommentMentionHelper(this.getCodeMirror());
  162. }
  163. this.emojiPickerHelper = new EmojiPickerHelper(this.getCodeMirror());
  164. // HACKME: Find a better way to handle onClick for Editor
  165. document.addEventListener('click', this.windowClickHandler);
  166. }
  167. componentWillUnmount() {
  168. // HACKME: Find a better way to handle onClick for Editor
  169. document.removeEventListener('click', this.windowClickHandler);
  170. }
  171. componentWillReceiveProps(nextProps) {
  172. this.initializeEditorSettings(nextProps.editorSettings);
  173. this.initializeTextlint(nextProps.isTextlintEnabled, nextProps.editorSettings);
  174. // fold drawio section
  175. this.foldDrawioSection();
  176. }
  177. initializeEditorSettings(editorSettings) {
  178. if (editorSettings == null) {
  179. return;
  180. }
  181. // load theme
  182. const theme = editorSettings.theme;
  183. if (theme != null) {
  184. this.loadTheme(theme);
  185. }
  186. // set keymap
  187. const keymapMode = editorSettings.keymapMode;
  188. if (keymapMode != null) {
  189. this.setKeymapMode(keymapMode);
  190. }
  191. }
  192. async initializeTextlint(isTextlintEnabled, editorSettings) {
  193. if (!isTextlintEnabled || editorSettings == null) {
  194. return;
  195. }
  196. const textlintRules = editorSettings.textlintSettings?.textlintRules;
  197. // If database has empty array, pass null instead to enable all default rules
  198. const rulesForValidator = (textlintRules == null || textlintRules.length === 0) ? null : textlintRules;
  199. this.textlintValidator = createValidator(rulesForValidator);
  200. this.codemirrorLintConfig = { getAnnotations: this.textlintValidator, async: true };
  201. }
  202. getCodeMirror() {
  203. return this.cm.current?.editor;
  204. }
  205. /**
  206. * @inheritDoc
  207. */
  208. forceToFocus() {
  209. // use setInterval with reluctance -- 2018.01.11 Yuki Takei
  210. const intervalId = setInterval(() => {
  211. const editor = this.getCodeMirror();
  212. editor.focus();
  213. if (editor.hasFocus()) {
  214. clearInterval(intervalId);
  215. // refresh
  216. editor.refresh();
  217. }
  218. }, 100);
  219. }
  220. /**
  221. * @inheritDoc
  222. */
  223. setValue(newValue) {
  224. this.getCodeMirror().getDoc().setValue(newValue);
  225. // mark clean
  226. this.getCodeMirror().getDoc().markClean();
  227. }
  228. /**
  229. * @inheritDoc
  230. */
  231. setGfmMode(bool) {
  232. // update state
  233. this.setState({
  234. isGfmMode: bool,
  235. });
  236. this.updateCheatsheetStates(bool, null);
  237. // update CodeMirror option
  238. const mode = bool ? 'gfm' : undefined;
  239. this.getCodeMirror().setOption('mode', mode);
  240. }
  241. /**
  242. * @inheritDoc
  243. */
  244. setCaretLine(line) {
  245. if (Number.isNaN(line)) {
  246. return;
  247. }
  248. const editor = this.getCodeMirror();
  249. const linePosition = Math.max(0, line - 1);
  250. editor.setCursor({ line: linePosition }); // leave 'ch' field as null/undefined to indicate the end of line
  251. setTimeout(() => {
  252. this.setScrollTopByLine(linePosition);
  253. }, 100);
  254. }
  255. /**
  256. * @inheritDoc
  257. */
  258. setScrollTopByLine(line) {
  259. if (Number.isNaN(line)) {
  260. return;
  261. }
  262. const editor = this.getCodeMirror();
  263. // get top position of the line
  264. const top = editor.charCoords({ line: line - 1, ch: 0 }, 'local').top;
  265. editor.scrollTo(null, top);
  266. }
  267. /**
  268. * @inheritDoc
  269. */
  270. getStrFromBol() {
  271. const editor = this.getCodeMirror();
  272. const curPos = editor.getCursor();
  273. return editor.getDoc().getRange(this.getBol(), curPos);
  274. }
  275. /**
  276. * @inheritDoc
  277. */
  278. getStrToEol() {
  279. const editor = this.getCodeMirror();
  280. const curPos = editor.getCursor();
  281. return editor.getDoc().getRange(curPos, this.getEol());
  282. }
  283. /**
  284. * @inheritDoc
  285. */
  286. getStrFromBolToSelectedUpperPos() {
  287. const editor = this.getCodeMirror();
  288. const pos = this.selectUpperPos(editor.getCursor('from'), editor.getCursor('to'));
  289. return editor.getDoc().getRange(this.getBol(), pos);
  290. }
  291. /**
  292. * @inheritDoc
  293. */
  294. replaceBolToCurrentPos(text) {
  295. const editor = this.getCodeMirror();
  296. const pos = this.selectLowerPos(editor.getCursor('from'), editor.getCursor('to'));
  297. editor.getDoc().replaceRange(text, this.getBol(), pos);
  298. }
  299. /**
  300. * @inheritDoc
  301. */
  302. replaceLine(text) {
  303. const editor = this.getCodeMirror();
  304. editor.getDoc().replaceRange(text, this.getBol(), this.getEol());
  305. }
  306. /**
  307. * @inheritDoc
  308. */
  309. insertText(text) {
  310. const editor = this.getCodeMirror();
  311. editor.getDoc().replaceSelection(text);
  312. }
  313. /**
  314. * return the postion of the BOL(beginning of line)
  315. */
  316. getBol() {
  317. const editor = this.getCodeMirror();
  318. const curPos = editor.getCursor();
  319. return { line: curPos.line, ch: 0 };
  320. }
  321. /**
  322. * return the postion of the EOL(end of line)
  323. */
  324. getEol() {
  325. const editor = this.getCodeMirror();
  326. const curPos = editor.getCursor();
  327. const lineLength = editor.getDoc().getLine(curPos.line).length;
  328. return { line: curPos.line, ch: lineLength };
  329. }
  330. /**
  331. * select the upper position of pos1 and pos2
  332. * @param {{line: number, ch: number}} pos1
  333. * @param {{line: number, ch: number}} pos2
  334. */
  335. selectUpperPos(pos1, pos2) {
  336. // if both is in same line
  337. if (pos1.line === pos2.line) {
  338. return (pos1.ch < pos2.ch) ? pos1 : pos2;
  339. }
  340. return (pos1.line < pos2.line) ? pos1 : pos2;
  341. }
  342. /**
  343. * select the lower position of pos1 and pos2
  344. * @param {{line: number, ch: number}} pos1
  345. * @param {{line: number, ch: number}} pos2
  346. */
  347. selectLowerPos(pos1, pos2) {
  348. // if both is in same line
  349. if (pos1.line === pos2.line) {
  350. return (pos1.ch < pos2.ch) ? pos2 : pos1;
  351. }
  352. return (pos1.line < pos2.line) ? pos2 : pos1;
  353. }
  354. loadCss(source) {
  355. return new Promise((resolve) => {
  356. loadCssSync(source);
  357. resolve();
  358. });
  359. }
  360. /**
  361. * load Theme
  362. * @see https://codemirror.net/doc/manual.html#config
  363. *
  364. * @param {string} theme
  365. */
  366. loadTheme(theme) {
  367. if (!this.loadedThemeSet.has(theme)) {
  368. const url = this.props.noCdn
  369. ? urljoin(this.cmNoCdnStyleRoot, `codemirror-theme-${theme}.css`)
  370. : urljoin(this.cmCdnRoot, `theme/${theme}.min.css`);
  371. this.loadCss(url);
  372. // update Set
  373. this.loadedThemeSet.add(theme);
  374. }
  375. }
  376. /**
  377. * load assets for Key Maps
  378. * @param {*} keymapMode 'default' or 'vim' or 'emacs' or 'sublime'
  379. */
  380. loadKeymapMode(keymapMode) {
  381. const loadCss = this.loadCss;
  382. const scriptList = [];
  383. const cssList = [];
  384. // add dependencies
  385. if (this.loadedKeymapSet.size === 0) {
  386. const dialogScriptUrl = this.props.noCdn
  387. ? urljoin(this.cmNoCdnScriptRoot, 'codemirror-dialog.js')
  388. : urljoin(this.cmCdnRoot, 'addon/dialog/dialog.min.js');
  389. const dialogStyleUrl = this.props.noCdn
  390. ? urljoin(this.cmNoCdnStyleRoot, 'codemirror-dialog.css')
  391. : urljoin(this.cmCdnRoot, 'addon/dialog/dialog.min.css');
  392. scriptList.push(loadScript(dialogScriptUrl));
  393. cssList.push(loadCss(dialogStyleUrl));
  394. }
  395. // load keymap
  396. if (!this.loadedKeymapSet.has(keymapMode)) {
  397. const keymapScriptUrl = this.props.noCdn
  398. ? urljoin(this.cmNoCdnScriptRoot, `codemirror-keymap-${keymapMode}.js`)
  399. : urljoin(this.cmCdnRoot, `keymap/${keymapMode}.min.js`);
  400. scriptList.push(loadScript(keymapScriptUrl));
  401. // update Set
  402. this.loadedKeymapSet.add(keymapMode);
  403. }
  404. // set loading state
  405. this.setState({ isLoadingKeymap: true });
  406. return Promise.all(scriptList.concat(cssList))
  407. .then(() => {
  408. this.setState({ isLoadingKeymap: false });
  409. });
  410. }
  411. /**
  412. * set Key Maps
  413. * @see https://codemirror.net/doc/manual.html#keymaps
  414. *
  415. * @param {string} keymapMode 'default' or 'vim' or 'emacs' or 'sublime'
  416. */
  417. setKeymapMode(keymapMode) {
  418. if (!keymapMode.match(/^(vim|emacs|sublime)$/)) {
  419. // reset
  420. this.getCodeMirror().setOption('keyMap', 'default');
  421. return;
  422. }
  423. this.loadKeymapMode(keymapMode)
  424. .then(() => {
  425. let errorCount = 0;
  426. const timer = setInterval(() => {
  427. if (errorCount > 10) { // cancel over 3000ms
  428. this.logger.error(`Timeout to load keyMap '${keymapMode}'`);
  429. clearInterval(timer);
  430. }
  431. try {
  432. this.getCodeMirror().setOption('keyMap', keymapMode);
  433. clearInterval(timer);
  434. }
  435. catch (e) {
  436. this.logger.info(`keyMap '${keymapMode}' has not been initialized. retry..`);
  437. // continue if error occured
  438. errorCount++;
  439. }
  440. }, 300);
  441. });
  442. }
  443. /**
  444. * handle ENTER key
  445. */
  446. handleEnterKey() {
  447. if (!this.state.isGfmMode) {
  448. commands.newlineAndIndent(this.getCodeMirror());
  449. return;
  450. }
  451. const context = {
  452. handlers: [], // list of handlers which process enter key
  453. editor: this,
  454. autoFormatMarkdownTable: this.props.editorSettings.autoFormatMarkdownTable,
  455. };
  456. const interceptorManager = this.interceptorManager;
  457. interceptorManager.process('preHandleEnter', context)
  458. .then(() => {
  459. if (context.handlers.length === 0) {
  460. markdownListUtil.newlineAndIndentContinueMarkdownList(this);
  461. }
  462. });
  463. }
  464. /**
  465. * handle Ctrl+ENTER key
  466. */
  467. handleCtrlEnterKey() {
  468. if (this.props.onCtrlEnter != null) {
  469. this.props.onCtrlEnter();
  470. }
  471. }
  472. scrollCursorIntoViewHandler(editor, event) {
  473. if (this.props.onScrollCursorIntoView != null) {
  474. const line = editor.getCursor().line;
  475. this.props.onScrollCursorIntoView(line);
  476. }
  477. }
  478. cursorHandler(editor, event) {
  479. const { additionalClassSet } = this.state;
  480. const hasCustomClass = additionalClassSet.has(MARKDOWN_TABLE_ACTIVATED_CLASS);
  481. const hasLinkClass = additionalClassSet.has(MARKDOWN_LINK_ACTIVATED_CLASS);
  482. const isInTable = mtu.isInTable(editor);
  483. const isInLink = markdownLinkUtil.isInLink(editor);
  484. if (!hasCustomClass && isInTable) {
  485. additionalClassSet.add(MARKDOWN_TABLE_ACTIVATED_CLASS);
  486. this.setState({ additionalClassSet });
  487. }
  488. if (hasCustomClass && !isInTable) {
  489. additionalClassSet.delete(MARKDOWN_TABLE_ACTIVATED_CLASS);
  490. this.setState({ additionalClassSet });
  491. }
  492. if (!hasLinkClass && isInLink) {
  493. additionalClassSet.add(MARKDOWN_LINK_ACTIVATED_CLASS);
  494. this.setState({ additionalClassSet });
  495. }
  496. if (hasLinkClass && !isInLink) {
  497. additionalClassSet.delete(MARKDOWN_LINK_ACTIVATED_CLASS);
  498. this.setState({ additionalClassSet });
  499. }
  500. }
  501. changeHandler(editor, data, value) {
  502. if (this.props.onChange != null) {
  503. const isClean = data.origin == null || editor.isClean();
  504. this.props.onChange(value, isClean);
  505. }
  506. this.updateCheatsheetStates(null, value);
  507. // Show username hint on comment editor
  508. if (this.props.isComment) {
  509. this.commentMentionHelper.showUsernameHint();
  510. }
  511. }
  512. turnOnEmojiPickerMode(pos) {
  513. this.setState({
  514. isEmojiPickerMode: true,
  515. startPosWithEmojiPickerModeTurnedOn: pos,
  516. });
  517. }
  518. turnOffEmojiPickerMode() {
  519. this.setState({
  520. isEmojiPickerMode: false,
  521. });
  522. }
  523. showEmojiPicker(initialSearchingText) {
  524. // show emoji picker with a stored word
  525. this.setState({
  526. isEmojiPickerShown: true,
  527. emojiSearchText: initialSearchingText ?? '',
  528. });
  529. const resetStartPos = initialSearchingText == null;
  530. if (resetStartPos) {
  531. this.setState({ startPosWithEmojiPickerModeTurnedOn: null });
  532. }
  533. this.turnOffEmojiPickerMode();
  534. }
  535. keyPressHandlerForEmojiPicker(editor, event) {
  536. const char = event.key;
  537. const isEmojiPickerMode = this.state.isEmojiPickerMode;
  538. // evaluate whether emoji picker mode to be turned on
  539. if (!isEmojiPickerMode) {
  540. const startPos = this.emojiPickerHelper.shouldModeTurnOn(char);
  541. if (startPos == null) {
  542. return;
  543. }
  544. this.turnOnEmojiPickerMode(startPos);
  545. return;
  546. }
  547. // evaluate whether EmojiPicker to be opened
  548. const startPos = this.state.startPosWithEmojiPickerModeTurnedOn;
  549. if (this.emojiPickerHelper.shouldOpen(startPos)) {
  550. const initialSearchingText = this.emojiPickerHelper.getInitialSearchingText(startPos);
  551. this.showEmojiPicker(initialSearchingText);
  552. return;
  553. }
  554. this.turnOffEmojiPickerMode();
  555. }
  556. keyPressHandler(editor, event) {
  557. this.keyPressHandlerForEmojiPickerThrottled(editor, event);
  558. }
  559. keyDownHandlerForEmojiPicker(editor, event) {
  560. const key = event.key;
  561. if (!this.state.isEmojiPickerMode) {
  562. return;
  563. }
  564. if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'BackSpace'].includes(key)) {
  565. this.turnOffEmojiPickerMode();
  566. }
  567. }
  568. keyDownHandler(editor, event) {
  569. this.keyDownHandlerForEmojiPickerThrottled(editor, event);
  570. }
  571. windowClickHandler() {
  572. this.turnOffEmojiPickerMode();
  573. }
  574. /**
  575. * CodeMirror paste event handler
  576. * see: https://codemirror.net/doc/manual.html#events
  577. * @param {any} editor An editor instance of CodeMirror
  578. * @param {any} event
  579. */
  580. pasteHandler(editor, event) {
  581. const types = event.clipboardData.types;
  582. // files
  583. if (types.includes('Files')) {
  584. event.preventDefault();
  585. this.dispatchPasteFiles(event);
  586. }
  587. // text
  588. else if (types.includes('text/plain')) {
  589. pasteHelper.pasteText(this, event);
  590. }
  591. }
  592. /**
  593. * update states which related to cheatsheet
  594. * @param {boolean} isGfmModeTmp (use state.isGfmMode if null is set)
  595. * @param {string} valueTmp (get value from codemirror if null is set)
  596. */
  597. updateCheatsheetStates(isGfmModeTmp, valueTmp) {
  598. const isGfmMode = isGfmModeTmp || this.state.isGfmMode;
  599. const value = valueTmp || this.getCodeMirror().getDoc().getValue();
  600. // update isSimpleCheatsheetShown
  601. const isSimpleCheatsheetShown = isGfmMode && value.length === 0;
  602. this.setState({ isSimpleCheatsheetShown });
  603. }
  604. markdownHelpButtonClickedHandler() {
  605. if (this.props.onMarkdownHelpButtonClicked != null) {
  606. this.props.onMarkdownHelpButtonClicked();
  607. }
  608. }
  609. renderLoadingKeymapOverlay() {
  610. // centering
  611. const style = {
  612. top: 0,
  613. right: 0,
  614. bottom: 0,
  615. left: 0,
  616. };
  617. return this.state.isLoadingKeymap
  618. ? (
  619. <div className="overlay overlay-loading-keymap">
  620. <span style={style} className="overlay-content">
  621. <div className="speeding-wheel d-inline-block"></div> Loading Keymap ...
  622. </span>
  623. </div>
  624. )
  625. : '';
  626. }
  627. renderCheatsheetModalButton() {
  628. return (
  629. <button type="button" className="btn-link gfm-cheatsheet-modal-link small" onClick={() => { this.markdownHelpButtonClickedHandler() }}>
  630. <i className="icon-question" /> Markdown
  631. </button>
  632. );
  633. }
  634. renderCheatsheetOverlay() {
  635. const cheatsheetModalButton = this.renderCheatsheetModalButton();
  636. return (
  637. <div className="overlay overlay-gfm-cheatsheet mt-1 p-3">
  638. { this.state.isSimpleCheatsheetShown
  639. ? (
  640. <div className="text-right">
  641. {cheatsheetModalButton}
  642. <div className="mb-2 d-none d-md-block">
  643. <SimpleCheatsheet />
  644. </div>
  645. </div>
  646. )
  647. : (
  648. <div className="mr-4 mb-2">
  649. {cheatsheetModalButton}
  650. </div>
  651. )
  652. }
  653. </div>
  654. );
  655. }
  656. renderEmojiPicker() {
  657. const { emojiSearchText } = this.state;
  658. return this.state.isEmojiPickerShown
  659. ? (
  660. <div className="text-left">
  661. <div className="mb-2 d-none d-md-block">
  662. <EmojiPicker
  663. onClose={() => this.setState({ isEmojiPickerShown: false })}
  664. onSelected={emoji => this.emojiPickerHelper.addEmoji(emoji, this.state.startPosWithEmojiPickerModeTurnedOn)}
  665. emojiSearchText={emojiSearchText}
  666. emojiPickerHelper={this.emojiPickerHelper}
  667. isOpen={this.state.isEmojiPickerShown}
  668. />
  669. </div>
  670. </div>
  671. )
  672. : '';
  673. }
  674. /**
  675. * return a function to replace a selected range with prefix + selection + suffix
  676. *
  677. * The cursor after replacing is inserted between the selection and the suffix.
  678. */
  679. createReplaceSelectionHandler(prefix, suffix) {
  680. return () => {
  681. const cm = this.getCodeMirror();
  682. const selection = cm.getDoc().getSelection();
  683. const curStartPos = cm.getCursor('from');
  684. const curEndPos = cm.getCursor('to');
  685. const curPosAfterReplacing = {};
  686. curPosAfterReplacing.line = curEndPos.line;
  687. if (curStartPos.line === curEndPos.line) {
  688. curPosAfterReplacing.ch = curEndPos.ch + prefix.length;
  689. }
  690. else {
  691. curPosAfterReplacing.ch = curEndPos.ch;
  692. }
  693. cm.getDoc().replaceSelection(prefix + selection + suffix);
  694. cm.setCursor(curPosAfterReplacing);
  695. cm.focus();
  696. };
  697. }
  698. /**
  699. * return a function to add prefix to selected each lines
  700. *
  701. * The cursor after editing is inserted between the end of the selection.
  702. */
  703. createAddPrefixToEachLinesHandler(prefix) {
  704. return () => {
  705. const cm = this.getCodeMirror();
  706. const startLineNum = cm.getCursor('from').line;
  707. const endLineNum = cm.getCursor('to').line;
  708. const lines = [];
  709. for (let i = startLineNum; i <= endLineNum; i++) {
  710. lines.push(prefix + cm.getDoc().getLine(i));
  711. }
  712. const replacement = `${lines.join('\n')}\n`;
  713. cm.getDoc().replaceRange(replacement, { line: startLineNum, ch: 0 }, { line: endLineNum + 1, ch: 0 });
  714. cm.setCursor(endLineNum, cm.getDoc().getLine(endLineNum).length);
  715. cm.focus();
  716. };
  717. }
  718. /**
  719. * make a selected line a header
  720. *
  721. * The cursor after editing is inserted between the end of the line.
  722. */
  723. makeHeaderHandler() {
  724. const cm = this.getCodeMirror();
  725. const lineNum = cm.getCursor('from').line;
  726. const line = cm.getDoc().getLine(lineNum);
  727. let prefix = '#';
  728. if (!line.startsWith('#')) {
  729. prefix += ' ';
  730. }
  731. cm.getDoc().replaceRange(prefix, { line: lineNum, ch: 0 }, { line: lineNum, ch: 0 });
  732. cm.focus();
  733. }
  734. showGridEditorHandler() {
  735. this.gridEditModal.current.show(geu.getGridHtml(this.getCodeMirror()));
  736. }
  737. showLinkEditHandler() {
  738. this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
  739. }
  740. showHandsonTableHandler() {
  741. // this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
  742. }
  743. // fold draw.io section (::: drawio ~ :::)
  744. foldDrawioSection() {
  745. const editor = this.getCodeMirror();
  746. const lineNumbers = mdu.findAllDrawioSection(editor);
  747. lineNumbers.forEach((lineNumber) => {
  748. editor.foldCode({ line: lineNumber, ch: 0 }, { scanUp: false }, 'fold');
  749. });
  750. }
  751. onSaveForDrawio(drawioData) {
  752. const range = mdu.replaceFocusedDrawioWithEditor(this.getCodeMirror(), drawioData);
  753. // Fold the section after the drawio section (:::drawio) has been updated.
  754. this.foldDrawioSection();
  755. return range;
  756. }
  757. getNavbarItems() {
  758. return [
  759. <Button
  760. key="nav-item-bold"
  761. color={null}
  762. size="sm"
  763. title="Bold"
  764. onClick={this.createReplaceSelectionHandler('**', '**')}
  765. >
  766. <EditorIcon icon="Bold" />
  767. </Button>,
  768. <Button
  769. key="nav-item-italic"
  770. color={null}
  771. size="sm"
  772. title="Italic"
  773. onClick={this.createReplaceSelectionHandler('*', '*')}
  774. >
  775. <EditorIcon icon="Italic" />
  776. </Button>,
  777. <Button
  778. key="nav-item-strikethrough"
  779. color={null}
  780. size="sm"
  781. title="Strikethrough"
  782. onClick={this.createReplaceSelectionHandler('~~', '~~')}
  783. >
  784. <EditorIcon icon="Strikethrough" />
  785. </Button>,
  786. <Button
  787. key="nav-item-header"
  788. color={null}
  789. size="sm"
  790. title="Heading"
  791. onClick={this.makeHeaderHandler}
  792. >
  793. <EditorIcon icon="Heading" />
  794. </Button>,
  795. <Button
  796. key="nav-item-code"
  797. color={null}
  798. size="sm"
  799. title="Inline Code"
  800. onClick={this.createReplaceSelectionHandler('`', '`')}
  801. >
  802. <EditorIcon icon="InlineCode" />
  803. </Button>,
  804. <Button
  805. key="nav-item-quote"
  806. color={null}
  807. size="sm"
  808. title="Quote"
  809. onClick={this.createAddPrefixToEachLinesHandler('> ')}
  810. >
  811. <EditorIcon icon="Quote" />
  812. </Button>,
  813. <Button
  814. key="nav-item-ul"
  815. color={null}
  816. size="sm"
  817. title="List"
  818. onClick={this.createAddPrefixToEachLinesHandler('- ')}
  819. >
  820. <EditorIcon icon="List" />
  821. </Button>,
  822. <Button
  823. key="nav-item-ol"
  824. color={null}
  825. size="sm"
  826. title="Numbered List"
  827. onClick={this.createAddPrefixToEachLinesHandler('1. ')}
  828. >
  829. <EditorIcon icon="NumberedList" />
  830. </Button>,
  831. <Button
  832. key="nav-item-checkbox"
  833. color={null}
  834. size="sm"
  835. title="Check List"
  836. onClick={this.createAddPrefixToEachLinesHandler('- [ ] ')}
  837. >
  838. <EditorIcon icon="CheckList" />
  839. </Button>,
  840. <Button
  841. key="nav-item-attachment"
  842. color={null}
  843. size="sm"
  844. title="Attachment"
  845. onClick={this.props.onAddAttachmentButtonClicked}
  846. >
  847. <EditorIcon icon="Attachment" />
  848. </Button>,
  849. <Button
  850. key="nav-item-link"
  851. color={null}
  852. size="sm"
  853. title="Link"
  854. onClick={this.showLinkEditHandler}
  855. >
  856. <EditorIcon icon="Link" />
  857. </Button>,
  858. <Button
  859. key="nav-item-image"
  860. color={null}
  861. size="sm"
  862. title="Image"
  863. onClick={this.createReplaceSelectionHandler('![', ']()')}
  864. >
  865. <EditorIcon icon="Image" />
  866. </Button>,
  867. <Button
  868. key="nav-item-grid"
  869. color={null}
  870. size="sm"
  871. title="Grid"
  872. onClick={this.showGridEditorHandler}
  873. >
  874. <EditorIcon icon="Grid" />
  875. </Button>,
  876. <Button
  877. key="nav-item-table"
  878. color={null}
  879. size="sm"
  880. title="Table"
  881. onClick={this.showHandsonTableHandler}
  882. >
  883. <EditorIcon icon="Table" />
  884. </Button>,
  885. <Button
  886. key="nav-item-drawio"
  887. color={null}
  888. bssize="small"
  889. title="draw.io"
  890. onClick={() => this.props.onClickDrawioBtn(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()))}
  891. >
  892. <EditorIcon icon="Drawio" />
  893. </Button>,
  894. <Button
  895. key="nav-item-emoji"
  896. color={null}
  897. bssize="small"
  898. title="Emoji"
  899. onClick={() => this.showEmojiPicker()}
  900. >
  901. <EditorIcon icon="Emoji" />
  902. </Button>,
  903. ];
  904. }
  905. render() {
  906. const { isTextlintEnabled } = this.props;
  907. const lint = isTextlintEnabled ? this.codemirrorLintConfig : false;
  908. const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
  909. const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plain Text..';
  910. const gutters = [];
  911. if (this.props.lineNumbers != null) {
  912. gutters.push('CodeMirror-linenumbers', 'CodeMirror-foldgutter');
  913. }
  914. if (isTextlintEnabled) {
  915. gutters.push('CodeMirror-lint-markers');
  916. }
  917. return (
  918. <div className={`grw-codemirror-editor ${styles['grw-codemirror-editor']}`}>
  919. <UncontrolledCodeMirror
  920. ref={this.cm}
  921. className={additionalClasses}
  922. placeholder="search"
  923. // == temporary deactivate editorDidMount to use https://github.com/scniro/react-codemirror2/issues/284#issuecomment-1155928554
  924. // editorDidMount={(editor) => {
  925. // // add event handlers
  926. // editor.on('paste', this.pasteHandler);
  927. // editor.on('scrollCursorIntoView', this.scrollCursorIntoViewHandler);
  928. // }}
  929. value={this.props.value}
  930. options={{
  931. indentUnit: this.props.indentSize,
  932. theme: this.props.editorSettings.theme ?? 'elegant',
  933. styleActiveLine: this.props.editorSettings.styleActiveLine,
  934. lineWrapping: true,
  935. scrollPastEnd: true,
  936. autoRefresh: { force: true }, // force option is enabled by autorefresh.ext.js -- Yuki Takei
  937. autoCloseTags: true,
  938. placeholder,
  939. matchBrackets: true,
  940. emoji: true,
  941. matchTags: { bothTags: true },
  942. // folding
  943. foldGutter: this.props.lineNumbers,
  944. gutters,
  945. // match-highlighter, matchesonscrollbar, annotatescrollbar options
  946. highlightSelectionMatches: { annotateScrollbar: true },
  947. // continuelist, indentlist
  948. extraKeys: {
  949. Enter: this.handleEnterKey,
  950. 'Ctrl-Enter': this.handleCtrlEnterKey,
  951. 'Cmd-Enter': this.handleCtrlEnterKey,
  952. Tab: 'indentMore',
  953. 'Shift-Tab': 'indentLess',
  954. 'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
  955. },
  956. lint,
  957. }}
  958. onCursor={this.cursorHandler}
  959. onScroll={(editor, data) => {
  960. if (this.props.onScroll != null) {
  961. // add line data
  962. const line = editor.lineAtHeight(data.top, 'local');
  963. data.line = line;
  964. this.props.onScroll(data);
  965. }
  966. }}
  967. onChange={this.changeHandler}
  968. onDragEnter={(editor, event) => {
  969. if (this.props.onDragEnter != null) {
  970. this.props.onDragEnter(event);
  971. }
  972. }}
  973. onKeyPress={this.keyPressHandler}
  974. onKeyDown={this.keyDownHandler}
  975. />
  976. { this.renderLoadingKeymapOverlay() }
  977. { this.renderCheatsheetOverlay() }
  978. { this.renderEmojiPicker() }
  979. <GridEditModal
  980. ref={this.gridEditModal}
  981. onSave={(grid) => { return geu.replaceGridWithHtmlWithEditor(this.getCodeMirror(), grid) }}
  982. />
  983. <LinkEditModal
  984. ref={this.linkEditModal}
  985. onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
  986. />
  987. {/* <HandsontableModal
  988. ref={this.handsontableModal}
  989. onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
  990. autoFormatMarkdownTable={this.props.editorSettings.autoFormatMarkdownTable}
  991. /> */}
  992. </div>
  993. );
  994. }
  995. }
  996. CodeMirrorEditor.propTypes = Object.assign({
  997. isTextlintEnabled: PropTypes.bool,
  998. lineNumbers: PropTypes.bool,
  999. editorSettings: PropTypes.object.isRequired,
  1000. onMarkdownHelpButtonClicked: PropTypes.func,
  1001. onAddAttachmentButtonClicked: PropTypes.func,
  1002. }, AbstractEditor.propTypes);
  1003. CodeMirrorEditor.defaultProps = {
  1004. lineNumbers: true,
  1005. };
  1006. const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
  1007. const { open: openDrawioModal } = useDrawioModal();
  1008. const openDrawioModalHandler = useCallback((drawioMxFile) => {
  1009. openDrawioModal(drawioMxFile);
  1010. }, [openDrawioModal]);
  1011. return <CodeMirrorEditor ref={ref} onClickDrawioBtn={openDrawioModalHandler} {...props} />;
  1012. });
  1013. CodeMirrorEditorFc.displayName = 'CodeMirrorEditorFc';
  1014. export default CodeMirrorEditorFc;