crowi-form.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. $(function() {
  2. var pageId = $('#content-main').data('page-id');
  3. var pagePath= $('#content-main').data('path');
  4. var isEnabledLineBreaks = $('#content-main').data('linebreaks-enabled');
  5. // generate options obj
  6. var rendererOptions = {
  7. // see: https://www.npmjs.com/package/marked
  8. marked: {
  9. breaks: isEnabledLineBreaks
  10. }
  11. };
  12. require('bootstrap-sass');
  13. require('inline-attachment/src/inline-attachment');
  14. require('./thirdparty-js/jquery.selection');
  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. // preview watch
  50. var originalContent = $('#form-body').val();
  51. // restore draft
  52. // とりあえず、originalContent がない場合のみ復元する。(それ以外の場合は後で考える)
  53. var draft = crowi.findDraft(pagePath);
  54. var originalRevision = $('#page-form [name="pageForm[currentRevision]"]').val();
  55. if (!originalRevision && draft) {
  56. // TODO
  57. $('#form-body').val(draft)
  58. }
  59. var prevContent = originalContent;
  60. function renderPreview() {
  61. var markdown = $('#form-body').val();
  62. var parsedHTML = crowiRenderer.render(markdown, rendererOptions);
  63. // create context object
  64. var context = {markdown, parsedHTML, currentPagePath: location.pathname};
  65. // process interceptors for pre rendering
  66. crowi.interceptorManager.process('preRenderPreview', context) // process with the context
  67. // render HTML with jQuery
  68. .then(() => {
  69. $('#preview-body').html(context.parsedHTML);
  70. Promise.resolve($('#preview-body'));
  71. })
  72. // process interceptors for post rendering
  73. .then((bodyElement) => {
  74. context = Object.assign(context, {bodyElement})
  75. return crowi.interceptorManager.process('postRenderPreview', context);
  76. });
  77. }
  78. // for initialize preview
  79. renderPreview();
  80. var watchTimer = setInterval(function() {
  81. var content = $('#form-body').val();
  82. if (prevContent != content) {
  83. renderPreview();
  84. prevContent = content;
  85. }
  86. }, 500);
  87. // edit detection
  88. var isFormChanged = false;
  89. $(window).on('beforeunload', function(e) {
  90. if (isFormChanged) {
  91. return '編集中の内容があります。内容を破棄してページを移動しますか?';
  92. }
  93. });
  94. $('#form-body').on('keyup change', function(e) {
  95. var content = $('#form-body').val();
  96. if (originalContent != content) {
  97. isFormChanged = true;
  98. crowi.saveDraft(pagePath, content);
  99. } else {
  100. isFormChanged = false;
  101. crowi.clearDraft(pagePath);
  102. }
  103. });
  104. $('#page-form').on('submit', function(e) {
  105. // avoid message
  106. isFormChanged = false;
  107. crowi.clearDraft(pagePath);
  108. });
  109. // This is a temporary implementation until porting to React.
  110. var insertText = function(start, end, newText, mode) {
  111. var editor = document.querySelector('#form-body');
  112. mode = mode || 'after';
  113. switch (mode) {
  114. case 'before':
  115. editor.setSelectionRange(start, start);
  116. break;
  117. case 'replace':
  118. editor.setSelectionRange(start, end);
  119. break;
  120. case 'after':
  121. default:
  122. editor.setSelectionRange(end, end);
  123. }
  124. editor.focus();
  125. var inserted = false;
  126. try {
  127. // Chrome, Safari
  128. inserted = document.execCommand('insertText', false, newText);
  129. } catch (e) {
  130. inserted = false;
  131. }
  132. if (!inserted) {
  133. // Firefox
  134. editor.value = editor.value.substr(0, start) + newText + editor.value.substr(end);
  135. }
  136. };
  137. var getCurrentLine = function(event) {
  138. var $target = $(event.target);
  139. var text = $target.val();
  140. var pos = $target.selection('getPos');
  141. if (text === null || pos.start !== pos.end) {
  142. return null;
  143. }
  144. var startPos = text.lastIndexOf("\n", pos.start - 1) + 1;
  145. var endPos = text.indexOf("\n", pos.start);
  146. if (endPos === -1) {
  147. endPos = text.length;
  148. }
  149. return {
  150. text: text.slice(startPos, endPos),
  151. start: startPos,
  152. end: endPos,
  153. caret: pos.start,
  154. endOfLine: !$.trim(text.slice(pos.start, endPos))
  155. };
  156. };
  157. var getPrevLine = function(event) {
  158. var $target = $(event.target);
  159. var currentLine = getCurrentLine(event);
  160. var text = $target.val().slice(0, currentLine.start);
  161. var startPos = text.lastIndexOf("\n", currentLine.start - 2) + 1;
  162. var endPos = currentLine.start;
  163. return {
  164. text: text.slice(startPos, endPos),
  165. start: startPos,
  166. end: endPos
  167. };
  168. };
  169. var handleTabKey = function(event) {
  170. event.preventDefault();
  171. var $target = $(event.target);
  172. var currentLine = getCurrentLine(event);
  173. var text = $target.val();
  174. var pos = $target.selection('getPos');
  175. if (currentLine) {
  176. $target.selection('setPos', {start: currentLine.start, end: (currentLine.end - 1)});
  177. }
  178. if (event.shiftKey === true) {
  179. if (currentLine && currentLine.text.charAt(0) === '|') {
  180. // prev cell in table
  181. var newPos = text.lastIndexOf('|', pos.start - 1);
  182. if (newPos > 0) {
  183. $target.selection('setPos', {start: newPos - 1, end: newPos - 1});
  184. }
  185. } else {
  186. // re indent
  187. var reindentedText = $target.selection().replace(/^ {1,4}/gm, '');
  188. var reindentedCount = $target.selection().length - reindentedText.length;
  189. $target.selection('replace', {text: reindentedText, mode: 'before'});
  190. if (currentLine) {
  191. $target.selection('setPos', {start: pos.start - reindentedCount, end: pos.start - reindentedCount});
  192. }
  193. }
  194. } else {
  195. if (currentLine && currentLine.text.charAt(0) === '|') {
  196. // next cell in table
  197. var newPos = text.indexOf('|', pos.start + 1);
  198. if (newPos < 0 || newPos === text.lastIndexOf('|', currentLine.end - 1)) {
  199. $target.selection('setPos', {start: currentLine.end, end: currentLine.end});
  200. } else {
  201. $target.selection('setPos', {start: newPos + 2, end: newPos + 2});
  202. }
  203. } else {
  204. // indent
  205. $target.selection('replace', {
  206. text: ' ' + $target.selection().split("\n").join("\n "),
  207. mode: 'before'
  208. });
  209. if (currentLine) {
  210. $target.selection('setPos', {start: pos.start + 4, end: pos.start + 4});
  211. }
  212. }
  213. }
  214. $target.trigger('input');
  215. };
  216. var handleEnterKey = function(event) {
  217. if (event.metaKey || event.ctrlKey || event.shiftKey) {
  218. return;
  219. }
  220. var currentLine = getCurrentLine(event);
  221. if (!currentLine || currentLine.start === currentLine.caret) {
  222. return;
  223. }
  224. var $target = $(event.target);
  225. var match = currentLine.text.match(/^(\s*(?:-|\+|\*|\d+\.) (?:\[(?:x| )\] )?)\s*\S/);
  226. if (match) {
  227. // smart indent with list
  228. if (currentLine.text.match(/^(\s*(?:-|\+|\*|\d+\.) (?:\[(?:x| )\] ))\s*$/)) {
  229. // empty task list
  230. $target.selection('setPos', {start: currentLine.start, end: (currentLine.end - 1)});
  231. return;
  232. }
  233. event.preventDefault();
  234. var listMark = match[1].replace(/\[x\]/, '[ ]');
  235. var listMarkMatch = listMark.match(/^(\s*)(\d+)\./);
  236. if (listMarkMatch) {
  237. var indent = listMarkMatch[1];
  238. var num = parseInt(listMarkMatch[2]);
  239. if (num !== 1) {
  240. listMark = listMark.replace(/\s*\d+/, indent + (num +1));
  241. }
  242. }
  243. //$target.selection('insert', {text: "\n" + listMark, mode: 'before'});
  244. var pos = $target.selection('getPos');
  245. insertText(pos.start, pos.start, "\n" + listMark, 'replace');
  246. var newPosition = pos.start + ("\n" + listMark).length;
  247. $target.selection('setPos', {start: newPosition, end: newPosition});
  248. } else if (currentLine.text.match(/^(\s*(?:-|\+|\*|\d+\.) )/)) {
  249. // remove list
  250. $target.selection('setPos', {start: currentLine.start, end: currentLine.end});
  251. } else if (currentLine.text.match(/^.*\|\s*$/)) {
  252. // new row for table
  253. if (currentLine.text.match(/^[\|\s]+$/)) {
  254. $target.selection('setPos', {start: currentLine.start, end: currentLine.end});
  255. return;
  256. }
  257. if (!currentLine.endOfLine) {
  258. return;
  259. }
  260. event.preventDefault();
  261. var row = [];
  262. var cellbarMatch = currentLine.text.match(/\|/g);
  263. for (var i = 0; i < cellbarMatch.length; i++) {
  264. row.push('|');
  265. }
  266. var prevLine = getPrevLine(event);
  267. if (!prevLine || (!currentLine.text.match(/---/) && !prevLine.text.match(/\|/g))) {
  268. //$target.selection('insert', {text: "\n" + row.join(' --- ') + "\n" + row.join(' '), mode: 'before'});
  269. var pos = $target.selection('getPos');
  270. insertText(pos.start, pos.start, "\n" + row.join(' --- ') + "\n" + row.join(' '), 'after');
  271. $target.selection('setPos', {start: currentLine.caret + 6 * row.length - 1, end: currentLine.caret + 6 * row.length - 1});
  272. } else {
  273. //$target.selection('insert', {text: "\n" + row.join(' '), mode: 'before'});
  274. var pos = $target.selection('getPos');
  275. insertText(pos.start, pos.end, "\n" + row.join(' '), 'after');
  276. $target.selection('setPos', {start: currentLine.caret + 3, end: currentLine.caret + 3});
  277. }
  278. }
  279. $target.trigger('input');
  280. };
  281. var handleEscapeKey = function(event) {
  282. event.preventDefault();
  283. var $target = $(event.target);
  284. $target.blur();
  285. };
  286. var handleSpaceKey = function(event) {
  287. // keybind: alt + shift + space
  288. if (!(event.shiftKey && event.altKey)) {
  289. return;
  290. }
  291. var currentLine = getCurrentLine(event);
  292. if (!currentLine) {
  293. return;
  294. }
  295. var $target = $(event.target);
  296. var match = currentLine.text.match(/^(\s*)(-|\+|\*|\d+\.) (?:\[(x| )\] )(.*)/);
  297. if (match) {
  298. event.preventDefault();
  299. var checkMark = (match[3] == ' ') ? 'x' : ' ';
  300. var replaceTo = match[1] + match[2] + ' [' + checkMark + '] ' + match[4];
  301. $target.selection('setPos', {start: currentLine.start, end: currentLine.end});
  302. //$target.selection('replace', {text: replaceTo, mode: 'keep'});
  303. insertText(currentLine.start, currentLine.end, replaceTo, 'replace');
  304. $target.selection('setPos', {start: currentLine.caret, end: currentLine.caret});
  305. $target.trigger('input');
  306. }
  307. };
  308. // markdown helper inspired by 'esarea'.
  309. // see: https://github.com/fukayatsu/esarea
  310. $('textarea#form-body').on('keydown', function(event) {
  311. switch (event.which || event.keyCode) {
  312. case 9:
  313. handleTabKey(event);
  314. break;
  315. case 13:
  316. handleEnterKey(event);
  317. break;
  318. case 27:
  319. handleEscapeKey(event);
  320. break;
  321. case 32:
  322. handleSpaceKey(event);
  323. break;
  324. default:
  325. }
  326. });
  327. var handlePasteEvent = function(event) {
  328. var currentLine = getCurrentLine(event);
  329. if (!currentLine) {
  330. return false;
  331. }
  332. var $target = $(event.target);
  333. var pasteText = event.clipboardData.getData('text');
  334. var match = currentLine.text.match(/^(\s*(?:>|\-|\+|\*|\d+\.) (?:\[(?:x| )\] )?)/);
  335. if (match) {
  336. if (pasteText.match(/(?:\r\n|\r|\n)/)) {
  337. pasteText = pasteText.replace(/(\r\n|\r|\n)/g, "$1" + match[1]);
  338. }
  339. }
  340. //$target.selection('insert', {text: pasteText, mode: 'after'});
  341. insertText(currentLine.caret, currentLine.caret, pasteText, 'replace');
  342. var newPos = currentLine.caret + pasteText.length;
  343. $target.selection('setPos', {start: newPos, end: newPos});
  344. return true;
  345. };
  346. document.getElementById('form-body').addEventListener('paste', function(event) {
  347. if (handlePasteEvent(event)) {
  348. event.preventDefault();
  349. }
  350. });
  351. var unbindInlineAttachment = function($form) {
  352. $form.unbind('.inlineattach');
  353. };
  354. var bindInlineAttachment = function($form, attachmentOption) {
  355. var $this = $form;
  356. var editor = createEditorInstance($form);
  357. var inlineattach = new inlineAttachment(attachmentOption, editor);
  358. $form.bind({
  359. 'paste.inlineattach': function(e) {
  360. inlineattach.onPaste(e.originalEvent);
  361. },
  362. 'drop.inlineattach': function(e) {
  363. e.stopPropagation();
  364. e.preventDefault();
  365. inlineattach.onDrop(e.originalEvent);
  366. },
  367. 'dragenter.inlineattach dragover.inlineattach': function(e) {
  368. e.stopPropagation();
  369. e.preventDefault();
  370. }
  371. });
  372. };
  373. var createEditorInstance = function($form) {
  374. var $this = $form;
  375. return {
  376. getValue: function() {
  377. return $this.val();
  378. },
  379. insertValue: function(val) {
  380. inlineAttachment.util.insertTextAtCursor($this[0], val);
  381. },
  382. setValue: function(val) {
  383. $this.val(val);
  384. }
  385. };
  386. };
  387. var $inputForm = $('form.uploadable textarea#form-body');
  388. if ($inputForm.length > 0) {
  389. var csrfToken = $('form.uploadable input#edit-form-csrf').val();
  390. var pageId = $('#content-main').data('page-id') || 0;
  391. var attachmentOption = {
  392. uploadUrl: '/_api/attachments.add',
  393. extraParams: {
  394. path: location.pathname,
  395. page_id: pageId,
  396. _csrf: csrfToken
  397. },
  398. progressText: '(Uploading file...)',
  399. urlText: "\n![file]({filename})\n"
  400. };
  401. attachmentOption.onFileUploadResponse = function(res) {
  402. var result = JSON.parse(res.response);
  403. console.log(result);
  404. if (result.ok && result.pageCreated) {
  405. var page = result.page,
  406. pageId = page._id;
  407. $('#content-main').data('page-id', page._id);
  408. $('#page-form [name="pageForm[currentRevision]"]').val(page.revision._id)
  409. unbindInlineAttachment($inputForm);
  410. attachmentOption.extraParams.page_id = pageId;
  411. bindInlineAttachment($inputForm, attachmentOption);
  412. }
  413. return true;
  414. };
  415. bindInlineAttachment($inputForm, attachmentOption);
  416. $('textarea#form-body').on('dragenter dragover', function() {
  417. $(this).addClass('dragover');
  418. });
  419. $('textarea#form-body').on('drop dragleave dragend', function() {
  420. $(this).removeClass('dragover');
  421. });
  422. }
  423. var enableScrollSync = function() {
  424. var getMaxScrollTop = function(dom) {
  425. var rect = dom.getBoundingClientRect();
  426. return dom.scrollHeight - rect.height;
  427. };
  428. var getScrollRate = function(dom) {
  429. var maxScrollTop = getMaxScrollTop(dom);
  430. var rate = dom.scrollTop / maxScrollTop;
  431. return rate;
  432. };
  433. var getScrollTop = function(dom, rate) {
  434. var maxScrollTop = getMaxScrollTop(dom);
  435. var top = maxScrollTop * rate;
  436. return top;
  437. };
  438. var editor = document.querySelector('#form-body');
  439. var preview = document.querySelector('#preview-body');
  440. editor.addEventListener('scroll', function(event) {
  441. var rate = getScrollRate(this);
  442. var top = getScrollTop(preview, rate);
  443. preview.scrollTop = top;
  444. });
  445. };
  446. enableScrollSync();
  447. });