crowi-form.js 20 KB

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