crowi-form.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  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. // When the user presses CTRL + TAB, it is a case to control the tab of the browser
  230. // (for Firefox 54 on Windows)
  231. if (event.ctrlKey === true) {
  232. return;
  233. }
  234. if (currentLine) {
  235. $target.selection('setPos', {start: currentLine.start, end: (currentLine.end - 1)});
  236. }
  237. if (event.shiftKey === true) {
  238. if (currentLine && currentLine.text.charAt(0) === '|') {
  239. // prev cell in table
  240. var newPos = text.lastIndexOf('|', pos.start - 1);
  241. if (newPos > 0) {
  242. $target.selection('setPos', {start: newPos - 1, end: newPos - 1});
  243. }
  244. } else {
  245. // re indent
  246. var reindentedText = $target.selection().replace(/^ {1,4}/gm, '');
  247. var reindentedCount = $target.selection().length - reindentedText.length;
  248. $target.selection('replace', {text: reindentedText, mode: 'before'});
  249. if (currentLine) {
  250. $target.selection('setPos', {start: pos.start - reindentedCount, end: pos.start - reindentedCount});
  251. }
  252. }
  253. } else {
  254. if (currentLine && currentLine.text.charAt(0) === '|') {
  255. // next cell in table
  256. var newPos = text.indexOf('|', pos.start + 1);
  257. if (newPos < 0 || newPos === text.lastIndexOf('|', currentLine.end - 1)) {
  258. $target.selection('setPos', {start: currentLine.end, end: currentLine.end});
  259. } else {
  260. $target.selection('setPos', {start: newPos + 2, end: newPos + 2});
  261. }
  262. } else {
  263. // indent
  264. $target.selection('replace', {
  265. text: ' ' + $target.selection().split("\n").join("\n "),
  266. mode: 'before'
  267. });
  268. if (currentLine) {
  269. $target.selection('setPos', {start: pos.start + 4, end: pos.start + 4});
  270. }
  271. }
  272. }
  273. $target.trigger('input');
  274. };
  275. var handleEnterKey = function(event) {
  276. if (event.metaKey || event.ctrlKey || event.shiftKey) {
  277. return;
  278. }
  279. var currentLine = getCurrentLine(event);
  280. if (!currentLine || currentLine.start === currentLine.caret) {
  281. return;
  282. }
  283. var $target = $(event.target);
  284. var match = currentLine.text.match(/^(\s*(?:-|\+|\*|\d+\.) (?:\[(?:x| )\] )?)\s*\S/);
  285. if (match) {
  286. // smart indent with list
  287. if (currentLine.text.match(/^(\s*(?:-|\+|\*|\d+\.) (?:\[(?:x| )\] ))\s*$/)) {
  288. // empty task list
  289. $target.selection('setPos', {start: currentLine.start, end: (currentLine.end - 1)});
  290. return;
  291. }
  292. event.preventDefault();
  293. var listMark = match[1].replace(/\[x\]/, '[ ]');
  294. var listMarkMatch = listMark.match(/^(\s*)(\d+)\./);
  295. if (listMarkMatch) {
  296. var indent = listMarkMatch[1];
  297. var num = parseInt(listMarkMatch[2]);
  298. if (num !== 1) {
  299. listMark = listMark.replace(/\s*\d+/, indent + (num +1));
  300. }
  301. }
  302. //$target.selection('insert', {text: "\n" + listMark, mode: 'before'});
  303. var pos = $target.selection('getPos');
  304. insertText(pos.start, pos.start, "\n" + listMark, 'replace');
  305. var newPosition = pos.start + ("\n" + listMark).length;
  306. $target.selection('setPos', {start: newPosition, end: newPosition});
  307. } else if (currentLine.text.match(/^(\s*(?:-|\+|\*|\d+\.) )/)) {
  308. // remove list
  309. $target.selection('setPos', {start: currentLine.start, end: currentLine.end});
  310. } else if (currentLine.text.match(/^.*\|\s*$/)) {
  311. // new row for table
  312. if (currentLine.text.match(/^[\|\s]+$/)) {
  313. $target.selection('setPos', {start: currentLine.start, end: currentLine.end});
  314. return;
  315. }
  316. if (!currentLine.endOfLine) {
  317. return;
  318. }
  319. event.preventDefault();
  320. var row = [];
  321. var cellbarMatch = currentLine.text.match(/\|/g);
  322. for (var i = 0; i < cellbarMatch.length; i++) {
  323. row.push('|');
  324. }
  325. var prevLine = getPrevLine(event);
  326. if (!prevLine || (!currentLine.text.match(/---/) && !prevLine.text.match(/\|/g))) {
  327. //$target.selection('insert', {text: "\n" + row.join(' --- ') + "\n" + row.join(' '), mode: 'before'});
  328. var pos = $target.selection('getPos');
  329. insertText(pos.start, pos.start, "\n" + row.join(' --- ') + "\n" + row.join(' '), 'after');
  330. $target.selection('setPos', {start: currentLine.caret + 6 * row.length - 1, end: currentLine.caret + 6 * row.length - 1});
  331. } else {
  332. //$target.selection('insert', {text: "\n" + row.join(' '), mode: 'before'});
  333. var pos = $target.selection('getPos');
  334. insertText(pos.start, pos.end, "\n" + row.join(' '), 'after');
  335. $target.selection('setPos', {start: currentLine.caret + 3, end: currentLine.caret + 3});
  336. }
  337. }
  338. $target.trigger('input');
  339. };
  340. var handleEscapeKey = function(event) {
  341. event.preventDefault();
  342. var $target = $(event.target);
  343. $target.blur();
  344. };
  345. var handleSpaceKey = function(event) {
  346. // keybind: alt + shift + space
  347. if (!(event.shiftKey && event.altKey)) {
  348. return;
  349. }
  350. var currentLine = getCurrentLine(event);
  351. if (!currentLine) {
  352. return;
  353. }
  354. var $target = $(event.target);
  355. var match = currentLine.text.match(/^(\s*)(-|\+|\*|\d+\.) (?:\[(x| )\] )(.*)/);
  356. if (match) {
  357. event.preventDefault();
  358. var checkMark = (match[3] == ' ') ? 'x' : ' ';
  359. var replaceTo = match[1] + match[2] + ' [' + checkMark + '] ' + match[4];
  360. $target.selection('setPos', {start: currentLine.start, end: currentLine.end});
  361. //$target.selection('replace', {text: replaceTo, mode: 'keep'});
  362. insertText(currentLine.start, currentLine.end, replaceTo, 'replace');
  363. $target.selection('setPos', {start: currentLine.caret, end: currentLine.caret});
  364. $target.trigger('input');
  365. }
  366. };
  367. /**
  368. * event handler when 'Ctrl-S' pressed
  369. */
  370. var handleSKey = function(event) {
  371. if (!event.ctrlKey && !event.metaKey) {
  372. return;
  373. }
  374. event.preventDefault();
  375. const revisionInput = $('#page-form [name="pageForm[currentRevision]"]');
  376. // generate data to post
  377. const body = $('#form-body').val();
  378. let endpoint;
  379. let data;
  380. // update
  381. if (pageId) {
  382. endpoint = '/pages.update';
  383. data = {
  384. page_id: pageId,
  385. revision_id: revisionInput.val(),
  386. body: body,
  387. };
  388. }
  389. // create
  390. else {
  391. endpoint = '/pages.create';
  392. data = {
  393. path: pagePath,
  394. body: body,
  395. };
  396. }
  397. crowi.apiPost(endpoint, data)
  398. .then((res) => {
  399. let page = res.page;
  400. pageId = page._id
  401. toastr.success(undefined, 'Saved successful', {
  402. closeButton: true,
  403. progressBar: true,
  404. newestOnTop: false,
  405. showDuration: "100",
  406. hideDuration: "100",
  407. timeOut: "1200",
  408. extendedTimeOut: "150",
  409. });
  410. // update currentRevision input
  411. revisionInput.val(page.revision._id);
  412. // TODO update $('#revision-body-content')
  413. })
  414. .catch((error) => {
  415. console.error(error);
  416. toastr.error(error.message, 'Error occured on saveing', {
  417. closeButton: true,
  418. progressBar: true,
  419. newestOnTop: false,
  420. showDuration: "100",
  421. hideDuration: "100",
  422. timeOut: "3000",
  423. });
  424. });
  425. }
  426. // markdown helper inspired by 'esarea'.
  427. // see: https://github.com/fukayatsu/esarea
  428. $('textarea#form-body').on('keydown', function(event) {
  429. switch (event.which || event.keyCode) {
  430. case 9:
  431. handleTabKey(event);
  432. break;
  433. case 13:
  434. handleEnterKey(event);
  435. break;
  436. case 27:
  437. handleEscapeKey(event);
  438. break;
  439. case 32:
  440. handleSpaceKey(event);
  441. break;
  442. case 83:
  443. handleSKey(event);
  444. break;
  445. default:
  446. }
  447. });
  448. var handlePasteEvent = function(event) {
  449. var currentLine = getCurrentLine(event);
  450. if (!currentLine) {
  451. return false;
  452. }
  453. var $target = $(event.target);
  454. var pasteText = event.clipboardData.getData('text');
  455. var match = currentLine.text.match(/^(\s*(?:>|\-|\+|\*|\d+\.) (?:\[(?:x| )\] )?)/);
  456. if (match) {
  457. if (pasteText.match(/(?:\r\n|\r|\n)/)) {
  458. pasteText = pasteText.replace(/(\r\n|\r|\n)/g, "$1" + match[1]);
  459. }
  460. }
  461. //$target.selection('insert', {text: pasteText, mode: 'after'});
  462. insertText(currentLine.caret, currentLine.caret, pasteText, 'replace');
  463. var newPos = currentLine.caret + pasteText.length;
  464. $target.selection('setPos', {start: newPos, end: newPos});
  465. return true;
  466. };
  467. document.getElementById('form-body').addEventListener('paste', function(event) {
  468. if (handlePasteEvent(event)) {
  469. event.preventDefault();
  470. }
  471. });
  472. var unbindInlineAttachment = function($form) {
  473. $form.unbind('.inlineattach');
  474. };
  475. var bindInlineAttachment = function($form, attachmentOption) {
  476. var $this = $form;
  477. var editor = createEditorInstance($form);
  478. var inlineattach = new inlineAttachment(attachmentOption, editor);
  479. $form.bind({
  480. 'paste.inlineattach': function(e) {
  481. inlineattach.onPaste(e.originalEvent);
  482. },
  483. 'drop.inlineattach': function(e) {
  484. e.stopPropagation();
  485. e.preventDefault();
  486. inlineattach.onDrop(e.originalEvent);
  487. },
  488. 'dragenter.inlineattach dragover.inlineattach': function(e) {
  489. e.stopPropagation();
  490. e.preventDefault();
  491. }
  492. });
  493. };
  494. var createEditorInstance = function($form) {
  495. var $this = $form;
  496. return {
  497. getValue: function() {
  498. return $this.val();
  499. },
  500. insertValue: function(val) {
  501. inlineAttachment.util.insertTextAtCursor($this[0], val);
  502. },
  503. setValue: function(val) {
  504. $this.val(val);
  505. }
  506. };
  507. };
  508. var $inputForm = $('form.uploadable textarea#form-body');
  509. if ($inputForm.length > 0) {
  510. var csrfToken = $('form.uploadable input#edit-form-csrf').val();
  511. var pageId = $('#content-main').data('page-id') || 0;
  512. var attachmentOption = {
  513. uploadUrl: '/_api/attachments.add',
  514. extraParams: {
  515. path: location.pathname,
  516. page_id: pageId,
  517. _csrf: csrfToken
  518. },
  519. progressText: '(Uploading file...)',
  520. jsonFieldName: 'url',
  521. };
  522. // if files upload is set
  523. var config = crowi.getConfig();
  524. if (config.upload.file) {
  525. attachmentOption.allowedTypes = '*';
  526. }
  527. attachmentOption.remoteFilename = function(file) {
  528. return file.name;
  529. };
  530. attachmentOption.onFileReceived = function(file) {
  531. // if not image
  532. if (!file.type.match(/^image\/.+$/)) {
  533. // modify urlText with 'a' tag
  534. this.settings.urlText = `<a href="{filename}">${file.name}</a>\n`;
  535. this.settings.urlText = `[${file.name}]({filename})\n`;
  536. } else {
  537. this.settings.urlText = `![${file.name}]({filename})\n`;
  538. }
  539. }
  540. attachmentOption.onFileUploadResponse = function(res) {
  541. var result = JSON.parse(res.response);
  542. if (result.ok && result.pageCreated) {
  543. var page = result.page,
  544. pageId = page._id;
  545. $('#content-main').data('page-id', page._id);
  546. $('#page-form [name="pageForm[currentRevision]"]').val(page.revision._id)
  547. unbindInlineAttachment($inputForm);
  548. attachmentOption.extraParams.page_id = pageId;
  549. bindInlineAttachment($inputForm, attachmentOption);
  550. }
  551. return true;
  552. };
  553. bindInlineAttachment($inputForm, attachmentOption);
  554. $('textarea#form-body').on('dragenter dragover', function() {
  555. $(this).addClass('dragover');
  556. });
  557. $('textarea#form-body').on('drop dragleave dragend', function() {
  558. $(this).removeClass('dragover');
  559. });
  560. }
  561. var enableScrollSync = function() {
  562. var getMaxScrollTop = function(dom) {
  563. var rect = dom.getBoundingClientRect();
  564. return dom.scrollHeight - rect.height;
  565. };
  566. var getScrollRate = function(dom) {
  567. var maxScrollTop = getMaxScrollTop(dom);
  568. var rate = dom.scrollTop / maxScrollTop;
  569. return rate;
  570. };
  571. var getScrollTop = function(dom, rate) {
  572. var maxScrollTop = getMaxScrollTop(dom);
  573. var top = maxScrollTop * rate;
  574. return top;
  575. };
  576. var editor = document.querySelector('#form-body');
  577. var preview = document.querySelector('#preview-body');
  578. editor.addEventListener('scroll', function(event) {
  579. var rate = getScrollRate(this);
  580. var top = getScrollTop(preview, rate);
  581. preview.scrollTop = top;
  582. });
  583. };
  584. enableScrollSync();
  585. });