crowi-form.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. var pageId = $('#content-main').data('page-id');
  2. var pagePath= $('#content-main').data('path');
  3. var isEnabledLineBreaks = $('#content-main').data('linebreaks-enabled');
  4. // generate options obj
  5. var rendererOptions = {
  6. // see: https://www.npmjs.com/package/marked
  7. marked: {
  8. breaks: isEnabledLineBreaks
  9. }
  10. };
  11. require('bootstrap-sass');
  12. require('inline-attachment/src/inline-attachment');
  13. require('./thirdparty-js/jquery.selection');
  14. const toastr = require('toastr');
  15. // show/hide
  16. function FetchPagesUpdatePostAndInsert(path) {
  17. $.get('/_api/pages.updatePost', {path: path}, function(res) {
  18. if (res.ok) {
  19. var $slackChannels = $('#page-form-slack-channel');
  20. $slackChannels.val(res.updatePost.join(','));
  21. }
  22. });
  23. }
  24. var slackConfigured = $('#page-form-setting').data('slack-configured');
  25. // for new page
  26. if (!pageId) {
  27. if (!pageId && pagePath.match(/(20\d{4}|20\d{6}|20\d{2}_\d{1,2}|20\d{2}_\d{1,2}_\d{1,2})/)) {
  28. $('#page-warning-modal').modal('show');
  29. }
  30. if (slackConfigured) {
  31. FetchPagesUpdatePostAndInsert(pagePath);
  32. }
  33. }
  34. $('a[data-toggle="tab"][href="#edit-form"]').on('show.bs.tab', function() {
  35. $('.content-main').addClass('on-edit');
  36. if (slackConfigured) {
  37. var $slackChannels = $('#page-form-slack-channel');
  38. var slackChannels = $slackChannels.val();
  39. // if slackChannels is empty, then fetch default (admin setting)
  40. // if not empty, it means someone specified this setting for the page.
  41. if (slackChannels === '') {
  42. FetchPagesUpdatePostAndInsert(pagePath);
  43. }
  44. }
  45. });
  46. $('a[data-toggle="tab"][href="#edit-form"]').on('hide.bs.tab', function() {
  47. $('.content-main').removeClass('on-edit');
  48. });
  49. /**
  50. * DOM ready
  51. */
  52. $(function() {
  53. // preview watch
  54. var originalContent = $('#form-body').val();
  55. // restore draft
  56. // とりあえず、originalContent がない場合のみ復元する。(それ以外の場合は後で考える)
  57. var draft = crowi.findDraft(pagePath);
  58. var originalRevision = $('#page-form [name="pageForm[currentRevision]"]').val();
  59. if (!originalRevision && draft) {
  60. // TODO
  61. $('#form-body').val(draft)
  62. }
  63. var prevContent = originalContent;
  64. function renderPreview() {
  65. var markdown = $('#form-body').val();
  66. // create context object
  67. var context = {
  68. markdown,
  69. currentPagePath: decodeURIComponent(location.pathname)
  70. };
  71. crowi.interceptorManager.process('preRenderPreview', context)
  72. .then(() => crowi.interceptorManager.process('prePreProcess', context))
  73. .then(() => {
  74. context.markdown = crowiRenderer.preProcess(context.markdown);
  75. })
  76. .then(() => crowi.interceptorManager.process('postPreProcess', context))
  77. .then(() => {
  78. var parsedHTML = crowiRenderer.render(context.markdown, rendererOptions);
  79. context['parsedHTML'] = parsedHTML;
  80. })
  81. .then(() => crowi.interceptorManager.process('postRenderPreview', context))
  82. .then(() => crowi.interceptorManager.process('preRenderPreviewHtml', context))
  83. // render HTML with jQuery
  84. .then(() => {
  85. $('#preview-body').html(context.parsedHTML);
  86. Promise.resolve($('#preview-body'));
  87. })
  88. // process interceptors for post rendering
  89. .then((bodyElement) => {
  90. context = Object.assign(context, {bodyElement})
  91. return crowi.interceptorManager.process('postRenderPreviewHtml', context);
  92. });
  93. }
  94. // for initialize preview
  95. renderPreview();
  96. var watchTimer = setInterval(function() {
  97. var content = $('#form-body').val();
  98. if (prevContent != content) {
  99. renderPreview();
  100. prevContent = content;
  101. }
  102. }, 500);
  103. // edit detection
  104. var isFormChanged = false;
  105. $(window).on('beforeunload', function(e) {
  106. if (isFormChanged) {
  107. // TODO i18n
  108. return 'You haven\'t finished your comment yet. Do you want to leave without finishing?';
  109. }
  110. });
  111. $('#form-body').on('keyup change', function(e) {
  112. var content = $('#form-body').val();
  113. if (originalContent != content) {
  114. isFormChanged = true;
  115. crowi.saveDraft(pagePath, content);
  116. } else {
  117. isFormChanged = false;
  118. crowi.clearDraft(pagePath);
  119. }
  120. });
  121. $('#page-form').on('submit', function(e) {
  122. // avoid message
  123. isFormChanged = false;
  124. crowi.clearDraft(pagePath);
  125. });
  126. // This is a temporary implementation until porting to React.
  127. var insertText = function(start, end, newText, mode) {
  128. var editor = document.querySelector('#form-body');
  129. mode = mode || 'after';
  130. switch (mode) {
  131. case 'before':
  132. editor.setSelectionRange(start, start);
  133. break;
  134. case 'replace':
  135. editor.setSelectionRange(start, end);
  136. break;
  137. case 'after':
  138. default:
  139. editor.setSelectionRange(end, end);
  140. }
  141. editor.focus();
  142. var inserted = false;
  143. try {
  144. // Chrome, Safari
  145. inserted = document.execCommand('insertText', false, newText);
  146. } catch (e) {
  147. inserted = false;
  148. }
  149. if (!inserted) {
  150. // Firefox
  151. editor.value = editor.value.substr(0, start) + newText + editor.value.substr(end);
  152. }
  153. };
  154. var getCurrentLine = function(event) {
  155. var $target = $(event.target);
  156. var text = $target.val();
  157. var pos = $target.selection('getPos');
  158. if (text === null || pos.start !== pos.end) {
  159. return null;
  160. }
  161. var startPos = text.lastIndexOf("\n", pos.start - 1) + 1;
  162. var endPos = text.indexOf("\n", pos.start);
  163. if (endPos === -1) {
  164. endPos = text.length;
  165. }
  166. return {
  167. text: text.slice(startPos, endPos),
  168. start: startPos,
  169. end: endPos,
  170. caret: pos.start,
  171. endOfLine: !$.trim(text.slice(pos.start, endPos))
  172. };
  173. };
  174. var getPrevLine = function(event) {
  175. var $target = $(event.target);
  176. var currentLine = getCurrentLine(event);
  177. var text = $target.val().slice(0, currentLine.start);
  178. var startPos = text.lastIndexOf("\n", currentLine.start - 2) + 1;
  179. var endPos = currentLine.start;
  180. return {
  181. text: text.slice(startPos, endPos),
  182. start: startPos,
  183. end: endPos
  184. };
  185. };
  186. var handleTabKey = function(event) {
  187. event.preventDefault();
  188. var $target = $(event.target);
  189. var currentLine = getCurrentLine(event);
  190. var text = $target.val();
  191. var pos = $target.selection('getPos');
  192. if (currentLine) {
  193. $target.selection('setPos', {start: currentLine.start, end: (currentLine.end - 1)});
  194. }
  195. if (event.shiftKey === true) {
  196. if (currentLine && currentLine.text.charAt(0) === '|') {
  197. // prev cell in table
  198. var newPos = text.lastIndexOf('|', pos.start - 1);
  199. if (newPos > 0) {
  200. $target.selection('setPos', {start: newPos - 1, end: newPos - 1});
  201. }
  202. } else {
  203. // re indent
  204. var reindentedText = $target.selection().replace(/^ {1,4}/gm, '');
  205. var reindentedCount = $target.selection().length - reindentedText.length;
  206. $target.selection('replace', {text: reindentedText, mode: 'before'});
  207. if (currentLine) {
  208. $target.selection('setPos', {start: pos.start - reindentedCount, end: pos.start - reindentedCount});
  209. }
  210. }
  211. } else {
  212. if (currentLine && currentLine.text.charAt(0) === '|') {
  213. // next cell in table
  214. var newPos = text.indexOf('|', pos.start + 1);
  215. if (newPos < 0 || newPos === text.lastIndexOf('|', currentLine.end - 1)) {
  216. $target.selection('setPos', {start: currentLine.end, end: currentLine.end});
  217. } else {
  218. $target.selection('setPos', {start: newPos + 2, end: newPos + 2});
  219. }
  220. } else {
  221. // indent
  222. $target.selection('replace', {
  223. text: ' ' + $target.selection().split("\n").join("\n "),
  224. mode: 'before'
  225. });
  226. if (currentLine) {
  227. $target.selection('setPos', {start: pos.start + 4, end: pos.start + 4});
  228. }
  229. }
  230. }
  231. $target.trigger('input');
  232. };
  233. var handleEnterKey = function(event) {
  234. if (event.metaKey || event.ctrlKey || event.shiftKey) {
  235. return;
  236. }
  237. var currentLine = getCurrentLine(event);
  238. if (!currentLine || currentLine.start === currentLine.caret) {
  239. return;
  240. }
  241. var $target = $(event.target);
  242. var match = currentLine.text.match(/^(\s*(?:-|\+|\*|\d+\.) (?:\[(?:x| )\] )?)\s*\S/);
  243. if (match) {
  244. // smart indent with list
  245. if (currentLine.text.match(/^(\s*(?:-|\+|\*|\d+\.) (?:\[(?:x| )\] ))\s*$/)) {
  246. // empty task list
  247. $target.selection('setPos', {start: currentLine.start, end: (currentLine.end - 1)});
  248. return;
  249. }
  250. event.preventDefault();
  251. var listMark = match[1].replace(/\[x\]/, '[ ]');
  252. var listMarkMatch = listMark.match(/^(\s*)(\d+)\./);
  253. if (listMarkMatch) {
  254. var indent = listMarkMatch[1];
  255. var num = parseInt(listMarkMatch[2]);
  256. if (num !== 1) {
  257. listMark = listMark.replace(/\s*\d+/, indent + (num +1));
  258. }
  259. }
  260. //$target.selection('insert', {text: "\n" + listMark, mode: 'before'});
  261. var pos = $target.selection('getPos');
  262. insertText(pos.start, pos.start, "\n" + listMark, 'replace');
  263. var newPosition = pos.start + ("\n" + listMark).length;
  264. $target.selection('setPos', {start: newPosition, end: newPosition});
  265. } else if (currentLine.text.match(/^(\s*(?:-|\+|\*|\d+\.) )/)) {
  266. // remove list
  267. $target.selection('setPos', {start: currentLine.start, end: currentLine.end});
  268. } else if (currentLine.text.match(/^.*\|\s*$/)) {
  269. // new row for table
  270. if (currentLine.text.match(/^[\|\s]+$/)) {
  271. $target.selection('setPos', {start: currentLine.start, end: currentLine.end});
  272. return;
  273. }
  274. if (!currentLine.endOfLine) {
  275. return;
  276. }
  277. event.preventDefault();
  278. var row = [];
  279. var cellbarMatch = currentLine.text.match(/\|/g);
  280. for (var i = 0; i < cellbarMatch.length; i++) {
  281. row.push('|');
  282. }
  283. var prevLine = getPrevLine(event);
  284. if (!prevLine || (!currentLine.text.match(/---/) && !prevLine.text.match(/\|/g))) {
  285. //$target.selection('insert', {text: "\n" + row.join(' --- ') + "\n" + row.join(' '), mode: 'before'});
  286. var pos = $target.selection('getPos');
  287. insertText(pos.start, pos.start, "\n" + row.join(' --- ') + "\n" + row.join(' '), 'after');
  288. $target.selection('setPos', {start: currentLine.caret + 6 * row.length - 1, end: currentLine.caret + 6 * row.length - 1});
  289. } else {
  290. //$target.selection('insert', {text: "\n" + row.join(' '), mode: 'before'});
  291. var pos = $target.selection('getPos');
  292. insertText(pos.start, pos.end, "\n" + row.join(' '), 'after');
  293. $target.selection('setPos', {start: currentLine.caret + 3, end: currentLine.caret + 3});
  294. }
  295. }
  296. $target.trigger('input');
  297. };
  298. var handleEscapeKey = function(event) {
  299. event.preventDefault();
  300. var $target = $(event.target);
  301. $target.blur();
  302. };
  303. var handleSpaceKey = function(event) {
  304. // keybind: alt + shift + space
  305. if (!(event.shiftKey && event.altKey)) {
  306. return;
  307. }
  308. var currentLine = getCurrentLine(event);
  309. if (!currentLine) {
  310. return;
  311. }
  312. var $target = $(event.target);
  313. var match = currentLine.text.match(/^(\s*)(-|\+|\*|\d+\.) (?:\[(x| )\] )(.*)/);
  314. if (match) {
  315. event.preventDefault();
  316. var checkMark = (match[3] == ' ') ? 'x' : ' ';
  317. var replaceTo = match[1] + match[2] + ' [' + checkMark + '] ' + match[4];
  318. $target.selection('setPos', {start: currentLine.start, end: currentLine.end});
  319. //$target.selection('replace', {text: replaceTo, mode: 'keep'});
  320. insertText(currentLine.start, currentLine.end, replaceTo, 'replace');
  321. $target.selection('setPos', {start: currentLine.caret, end: currentLine.caret});
  322. $target.trigger('input');
  323. }
  324. };
  325. /**
  326. * event handler when 'Ctrl-S' pressed
  327. */
  328. var handleSKey = function(event) {
  329. if (!event.ctrlKey) {
  330. return;
  331. }
  332. event.preventDefault();
  333. const revisionInput = $('#page-form [name="pageForm[currentRevision]"]');
  334. // generate data to post
  335. let data = {
  336. page_id: pageId,
  337. revision_id: revisionInput.val(),
  338. body: $('#form-body').val(),
  339. }
  340. crowi.apiPost('/pages.update', data)
  341. .then((res) => {
  342. let page = res.page;
  343. toastr.success(undefined, 'Saved successful', {
  344. closeButton: true,
  345. progressBar: true,
  346. newestOnTop: false,
  347. showDuration: "100",
  348. hideDuration: "100",
  349. timeOut: "1200",
  350. extendedTimeOut: "150",
  351. });
  352. // update currentRevision input
  353. revisionInput.val(page.revision._id);
  354. // TODO update $('#revision-body-content')
  355. })
  356. .catch((error) => {
  357. console.error(error);
  358. toastr.error(error.message, 'Error occured on saveing', {
  359. closeButton: true,
  360. progressBar: true,
  361. newestOnTop: false,
  362. showDuration: "100",
  363. hideDuration: "100",
  364. timeOut: "3000",
  365. });
  366. });
  367. }
  368. // markdown helper inspired by 'esarea'.
  369. // see: https://github.com/fukayatsu/esarea
  370. $('textarea#form-body').on('keydown', function(event) {
  371. switch (event.which || event.keyCode) {
  372. case 9:
  373. handleTabKey(event);
  374. break;
  375. case 13:
  376. handleEnterKey(event);
  377. break;
  378. case 27:
  379. handleEscapeKey(event);
  380. break;
  381. case 32:
  382. handleSpaceKey(event);
  383. break;
  384. case 83:
  385. handleSKey(event);
  386. break;
  387. default:
  388. }
  389. });
  390. var handlePasteEvent = function(event) {
  391. var currentLine = getCurrentLine(event);
  392. if (!currentLine) {
  393. return false;
  394. }
  395. var $target = $(event.target);
  396. var pasteText = event.clipboardData.getData('text');
  397. var match = currentLine.text.match(/^(\s*(?:>|\-|\+|\*|\d+\.) (?:\[(?:x| )\] )?)/);
  398. if (match) {
  399. if (pasteText.match(/(?:\r\n|\r|\n)/)) {
  400. pasteText = pasteText.replace(/(\r\n|\r|\n)/g, "$1" + match[1]);
  401. }
  402. }
  403. //$target.selection('insert', {text: pasteText, mode: 'after'});
  404. insertText(currentLine.caret, currentLine.caret, pasteText, 'replace');
  405. var newPos = currentLine.caret + pasteText.length;
  406. $target.selection('setPos', {start: newPos, end: newPos});
  407. return true;
  408. };
  409. document.getElementById('form-body').addEventListener('paste', function(event) {
  410. if (handlePasteEvent(event)) {
  411. event.preventDefault();
  412. }
  413. });
  414. var unbindInlineAttachment = function($form) {
  415. $form.unbind('.inlineattach');
  416. };
  417. var bindInlineAttachment = function($form, attachmentOption) {
  418. var $this = $form;
  419. var editor = createEditorInstance($form);
  420. var inlineattach = new inlineAttachment(attachmentOption, editor);
  421. $form.bind({
  422. 'paste.inlineattach': function(e) {
  423. inlineattach.onPaste(e.originalEvent);
  424. },
  425. 'drop.inlineattach': function(e) {
  426. e.stopPropagation();
  427. e.preventDefault();
  428. inlineattach.onDrop(e.originalEvent);
  429. },
  430. 'dragenter.inlineattach dragover.inlineattach': function(e) {
  431. e.stopPropagation();
  432. e.preventDefault();
  433. }
  434. });
  435. };
  436. var createEditorInstance = function($form) {
  437. var $this = $form;
  438. return {
  439. getValue: function() {
  440. return $this.val();
  441. },
  442. insertValue: function(val) {
  443. inlineAttachment.util.insertTextAtCursor($this[0], val);
  444. },
  445. setValue: function(val) {
  446. $this.val(val);
  447. }
  448. };
  449. };
  450. var $inputForm = $('form.uploadable textarea#form-body');
  451. if ($inputForm.length > 0) {
  452. var csrfToken = $('form.uploadable input#edit-form-csrf').val();
  453. var pageId = $('#content-main').data('page-id') || 0;
  454. var attachmentOption = {
  455. uploadUrl: '/_api/attachments.add',
  456. extraParams: {
  457. path: location.pathname,
  458. page_id: pageId,
  459. _csrf: csrfToken
  460. },
  461. progressText: '(Uploading file...)',
  462. jsonFieldName: 'url',
  463. };
  464. // if files upload is set
  465. var config = crowi.getConfig();
  466. if (config.upload.file) {
  467. attachmentOption.allowedTypes = '*';
  468. }
  469. attachmentOption.remoteFilename = function(file) {
  470. return file.name;
  471. };
  472. attachmentOption.onFileReceived = function(file) {
  473. // if not image
  474. if (!file.type.match(/^image\/.+$/)) {
  475. // modify urlText with 'a' tag
  476. this.settings.urlText = `<a href="{filename}">${file.name}</a>\n`;
  477. this.settings.urlText = `[${file.name}]({filename})\n`;
  478. } else {
  479. this.settings.urlText = `![${file.name}]({filename})\n`;
  480. }
  481. }
  482. attachmentOption.onFileUploadResponse = function(res) {
  483. var result = JSON.parse(res.response);
  484. if (result.ok && result.pageCreated) {
  485. var page = result.page,
  486. pageId = page._id;
  487. $('#content-main').data('page-id', page._id);
  488. $('#page-form [name="pageForm[currentRevision]"]').val(page.revision._id)
  489. unbindInlineAttachment($inputForm);
  490. attachmentOption.extraParams.page_id = pageId;
  491. bindInlineAttachment($inputForm, attachmentOption);
  492. }
  493. return true;
  494. };
  495. bindInlineAttachment($inputForm, attachmentOption);
  496. $('textarea#form-body').on('dragenter dragover', function() {
  497. $(this).addClass('dragover');
  498. });
  499. $('textarea#form-body').on('drop dragleave dragend', function() {
  500. $(this).removeClass('dragover');
  501. });
  502. }
  503. var enableScrollSync = function() {
  504. var getMaxScrollTop = function(dom) {
  505. var rect = dom.getBoundingClientRect();
  506. return dom.scrollHeight - rect.height;
  507. };
  508. var getScrollRate = function(dom) {
  509. var maxScrollTop = getMaxScrollTop(dom);
  510. var rate = dom.scrollTop / maxScrollTop;
  511. return rate;
  512. };
  513. var getScrollTop = function(dom, rate) {
  514. var maxScrollTop = getMaxScrollTop(dom);
  515. var top = maxScrollTop * rate;
  516. return top;
  517. };
  518. var editor = document.querySelector('#form-body');
  519. var preview = document.querySelector('#preview-body');
  520. editor.addEventListener('scroll', function(event) {
  521. var rate = getScrollRate(this);
  522. var top = getScrollTop(preview, rate);
  523. preview.scrollTop = top;
  524. });
  525. };
  526. enableScrollSync();
  527. });