page-editor.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import $ from 'jquery';
  2. import { cleanForSlug } from '../../utils/cleanForSlug';
  3. window.halloPlugins = {};
  4. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  5. function registerHalloPlugin(name, opts) { // lgtm[js/unused-local-variable]
  6. /* Obsolete - used on Wagtail <1.12 to register plugins for the hallo.js editor.
  7. Defined here so that third-party plugins can continue to call it to provide Wagtail <1.12
  8. compatibility, without throwing an error on later versions. */
  9. }
  10. window.registerHalloPlugin = registerHalloPlugin;
  11. function InlinePanel(opts) { // lgtm[js/unused-local-variable]
  12. const self = {};
  13. // eslint-disable-next-line func-names
  14. self.setHasContent = function () {
  15. if ($('> li', self.formsUl).not('.deleted').length) {
  16. self.formsUl.parent().removeClass('empty');
  17. } else {
  18. self.formsUl.parent().addClass('empty');
  19. }
  20. };
  21. // eslint-disable-next-line func-names
  22. self.initChildControls = function (prefix) {
  23. const childId = 'inline_child_' + prefix;
  24. const deleteInputId = 'id_' + prefix + '-DELETE';
  25. // mark container as having children to identify fields in use from those not
  26. self.setHasContent();
  27. $('#' + deleteInputId + '-button').on('click', () => {
  28. /* set 'deleted' form field to true */
  29. $('#' + deleteInputId).val('1');
  30. $('#' + childId).addClass('deleted').slideUp(() => {
  31. self.updateMoveButtonDisabledStates();
  32. self.updateAddButtonState();
  33. self.setHasContent();
  34. });
  35. });
  36. if (opts.canOrder) {
  37. $('#' + prefix + '-move-up').on('click', () => {
  38. const currentChild = $('#' + childId);
  39. const currentChildOrderElem = currentChild.children('input[name$="-ORDER"]');
  40. const currentChildOrder = currentChildOrderElem.val();
  41. /* find the previous visible 'inline_child' li before this one */
  42. const prevChild = currentChild.prevAll(':not(.deleted)').first();
  43. if (!prevChild.length) return;
  44. const prevChildOrderElem = prevChild.children('input[name$="-ORDER"]');
  45. const prevChildOrder = prevChildOrderElem.val();
  46. // async swap animation must run before the insertBefore line below, but doesn't need to finish first
  47. self.animateSwap(currentChild, prevChild);
  48. currentChild.insertBefore(prevChild);
  49. currentChildOrderElem.val(prevChildOrder);
  50. prevChildOrderElem.val(currentChildOrder);
  51. self.updateMoveButtonDisabledStates();
  52. });
  53. $('#' + prefix + '-move-down').on('click', () => {
  54. const currentChild = $('#' + childId);
  55. const currentChildOrderElem = currentChild.children('input[name$="-ORDER"]');
  56. const currentChildOrder = currentChildOrderElem.val();
  57. /* find the next visible 'inline_child' li after this one */
  58. const nextChild = currentChild.nextAll(':not(.deleted)').first();
  59. if (!nextChild.length) return;
  60. const nextChildOrderElem = nextChild.children('input[name$="-ORDER"]');
  61. const nextChildOrder = nextChildOrderElem.val();
  62. // async swap animation must run before the insertAfter line below, but doesn't need to finish first
  63. self.animateSwap(currentChild, nextChild);
  64. currentChild.insertAfter(nextChild);
  65. currentChildOrderElem.val(nextChildOrder);
  66. nextChildOrderElem.val(currentChildOrder);
  67. self.updateMoveButtonDisabledStates();
  68. });
  69. }
  70. /* Hide container on page load if it is marked as deleted. Remove the error
  71. message so that it doesn't count towards the number of errors on the tab at the
  72. top of the page. */
  73. if ($('#' + deleteInputId).val() === '1') {
  74. $('#' + childId).addClass('deleted').hide(0, () => {
  75. self.updateMoveButtonDisabledStates();
  76. self.updateAddButtonState();
  77. self.setHasContent();
  78. });
  79. $('#' + childId).find('.error-message').remove();
  80. }
  81. };
  82. self.formsUl = $('#' + opts.formsetPrefix + '-FORMS');
  83. // eslint-disable-next-line func-names
  84. self.updateMoveButtonDisabledStates = function () {
  85. if (opts.canOrder) {
  86. const forms = self.formsUl.children('li:not(.deleted)');
  87. // eslint-disable-next-line func-names
  88. forms.each(function (i) {
  89. $('ul.controls .inline-child-move-up', this)
  90. .toggleClass('disabled', i === 0)
  91. .toggleClass('enabled', i !== 0);
  92. $('ul.controls .inline-child-move-down', this)
  93. .toggleClass('disabled', i === forms.length - 1)
  94. .toggleClass('enabled', i !== forms.length - 1);
  95. });
  96. }
  97. };
  98. // eslint-disable-next-line func-names
  99. self.updateAddButtonState = function () {
  100. if (opts.maxForms) {
  101. const forms = $('> [data-inline-panel-child]', self.formsUl).not('.deleted');
  102. const addButton = $('#' + opts.formsetPrefix + '-ADD');
  103. if (forms.length >= opts.maxForms) {
  104. addButton.addClass('disabled');
  105. } else {
  106. addButton.removeClass('disabled');
  107. }
  108. }
  109. };
  110. // eslint-disable-next-line func-names
  111. self.animateSwap = function (item1, item2) {
  112. const parent = self.formsUl;
  113. const children = parent.children('li:not(.deleted)');
  114. // Apply moving class to container (ul.multiple) so it can assist absolute positioning of it's children
  115. // Also set it's relatively calculated height to be an absolute one,
  116. // to prevent the containercollapsing while its children go absolute
  117. parent.addClass('moving').css('height', parent.height());
  118. // eslint-disable-next-line func-names
  119. children.each(function () {
  120. $(this).css('top', $(this).position().top);
  121. }).addClass('moving');
  122. // animate swapping around
  123. item1.animate({
  124. top: item2.position().top
  125. }, 200, () => {
  126. parent.removeClass('moving').removeAttr('style');
  127. children.removeClass('moving').removeAttr('style');
  128. });
  129. item2.animate({
  130. top: item1.position().top
  131. }, 200, () => {
  132. parent.removeClass('moving').removeAttr('style');
  133. children.removeClass('moving').removeAttr('style');
  134. });
  135. };
  136. // eslint-disable-next-line no-undef
  137. buildExpandingFormset(opts.formsetPrefix, {
  138. onAdd(formCount) {
  139. const newChildPrefix = opts.emptyChildFormPrefix.replace(/__prefix__/g, formCount);
  140. self.initChildControls(newChildPrefix);
  141. if (opts.canOrder) {
  142. /* NB form hidden inputs use 0-based index and only increment formCount *after* this function is run.
  143. Therefore formcount and order are currently equal and order must be incremented
  144. to ensure it's *greater* than previous item */
  145. $('#id_' + newChildPrefix + '-ORDER').val(formCount + 1);
  146. }
  147. self.updateMoveButtonDisabledStates();
  148. self.updateAddButtonState();
  149. if (opts.onAdd) opts.onAdd();
  150. }
  151. });
  152. return self;
  153. }
  154. window.InlinePanel = InlinePanel;
  155. window.cleanForSlug = cleanForSlug;
  156. function initSlugAutoPopulate() {
  157. let slugFollowsTitle = false;
  158. // eslint-disable-next-line func-names
  159. $('#id_title').on('focus', function () {
  160. /* slug should only follow the title field if its value matched the title's value at the time of focus */
  161. const currentSlug = $('#id_slug').val();
  162. const slugifiedTitle = cleanForSlug(this.value, true);
  163. slugFollowsTitle = (currentSlug === slugifiedTitle);
  164. });
  165. // eslint-disable-next-line func-names
  166. $('#id_title').on('keyup keydown keypress blur', function () {
  167. if (slugFollowsTitle) {
  168. const slugifiedTitle = cleanForSlug(this.value, true);
  169. $('#id_slug').val(slugifiedTitle);
  170. }
  171. });
  172. }
  173. window.initSlugAutoPopulate = initSlugAutoPopulate;
  174. function initSlugCleaning() {
  175. // eslint-disable-next-line func-names
  176. $('#id_slug').on('blur', function () {
  177. // if a user has just set the slug themselves, don't remove stop words etc, just illegal characters
  178. $(this).val(cleanForSlug($(this).val(), false));
  179. });
  180. }
  181. window.initSlugCleaning = initSlugCleaning;
  182. function initErrorDetection() {
  183. const errorSections = {};
  184. // first count up all the errors
  185. // eslint-disable-next-line func-names
  186. $('.error-message,.help-critical').each(function () {
  187. const parentSection = $(this).closest('section');
  188. if (!errorSections[parentSection.attr('id')]) {
  189. errorSections[parentSection.attr('id')] = 0;
  190. }
  191. errorSections[parentSection.attr('id')] = errorSections[parentSection.attr('id')] + 1;
  192. });
  193. // now identify them on each tab
  194. // eslint-disable-next-line no-restricted-syntax, guard-for-in
  195. for (const index in errorSections) {
  196. $('[data-tab-nav] a[href="#' + index + '"]').addClass('errors').attr('data-count', errorSections[index]);
  197. }
  198. }
  199. window.initErrorDetection = initErrorDetection;
  200. function initKeyboardShortcuts() {
  201. // eslint-disable-next-line no-undef
  202. Mousetrap.bind(['mod+p'], () => {
  203. $('.action-preview').trigger('click');
  204. return false;
  205. });
  206. // eslint-disable-next-line no-undef
  207. Mousetrap.bind(['mod+s'], () => {
  208. $('.action-save').trigger('click');
  209. return false;
  210. });
  211. }
  212. window.initKeyboardShortcuts = initKeyboardShortcuts;
  213. $(() => {
  214. /* Only non-live pages should auto-populate the slug from the title */
  215. if (!$('body').hasClass('page-is-live')) {
  216. initSlugAutoPopulate();
  217. }
  218. initSlugCleaning();
  219. initErrorDetection();
  220. initKeyboardShortcuts();
  221. //
  222. // Preview
  223. //
  224. // In order to make the preview truly reliable, the preview page needs
  225. // to be perfectly independent from the edit page,
  226. // from the browser perspective. To pass data from the edit page
  227. // to the preview page, we send the form after each change
  228. // and save it inside the user session.
  229. const $previewButton = $('.action-preview');
  230. const $form = $('#page-edit-form');
  231. const previewUrl = $previewButton.data('action');
  232. let autoUpdatePreviewDataTimeout = -1;
  233. function setPreviewData() {
  234. return $.ajax({
  235. url: previewUrl,
  236. method: 'POST',
  237. data: new FormData($form[0]),
  238. processData: false,
  239. contentType: false
  240. });
  241. }
  242. $previewButton.one('click', () => {
  243. if ($previewButton.data('auto-update')) {
  244. // Form data is changed when field values are changed
  245. // (change event), when HTML elements are added, modified, moved,
  246. // and deleted (DOMSubtreeModified event), and we need to delay
  247. // setPreviewData when typing to avoid useless extra AJAX requests
  248. // (so we postpone setPreviewData when keyup occurs).
  249. // eslint-disable-next-line no-warning-comments
  250. // TODO: Replace DOMSubtreeModified with a MutationObserver.
  251. $form.on('change keyup DOMSubtreeModified', () => {
  252. clearTimeout(autoUpdatePreviewDataTimeout);
  253. autoUpdatePreviewDataTimeout = setTimeout(setPreviewData, 1000);
  254. }).trigger('change');
  255. }
  256. });
  257. // eslint-disable-next-line func-names
  258. $previewButton.on('click', function (e) {
  259. e.preventDefault();
  260. const $this = $(this);
  261. const $icon = $this.filter('.icon');
  262. const thisPreviewUrl = $this.data('action');
  263. $icon.addClass('icon-spinner').removeClass('icon-view');
  264. const previewWindow = window.open('', thisPreviewUrl);
  265. previewWindow.focus();
  266. setPreviewData().done((data) => {
  267. if (data.is_valid) {
  268. previewWindow.document.location = thisPreviewUrl;
  269. } else {
  270. window.focus();
  271. previewWindow.close();
  272. // eslint-disable-next-line no-warning-comments
  273. // TODO: Stop sending the form, as it removes file data.
  274. $form.trigger('submit');
  275. }
  276. }).fail(() => {
  277. // eslint-disable-next-line no-alert
  278. alert('Error while sending preview data.');
  279. window.focus();
  280. previewWindow.close();
  281. })
  282. .always(() => {
  283. $icon.addClass('icon-view').removeClass('icon-spinner');
  284. });
  285. });
  286. });
  287. let updateFooterTextTimeout = -1;
  288. window.updateFooterSaveWarning = (formDirty, commentsDirty) => {
  289. const warningContainer = $('[data-unsaved-warning]');
  290. const warnings = warningContainer.find('[data-unsaved-type]');
  291. const anyDirty = formDirty || commentsDirty;
  292. const typeVisibility = {
  293. all: formDirty && commentsDirty,
  294. any: anyDirty,
  295. comments: commentsDirty && !formDirty,
  296. edits: formDirty && !commentsDirty
  297. };
  298. let hiding = false;
  299. if (anyDirty) {
  300. warningContainer.removeClass('footer__container--hidden');
  301. } else {
  302. if (!warningContainer.hasClass('footer__container--hidden')) {
  303. hiding = true;
  304. }
  305. warningContainer.addClass('footer__container--hidden');
  306. }
  307. clearTimeout(updateFooterTextTimeout);
  308. const updateWarnings = () => {
  309. for (const warning of warnings) {
  310. const visible = typeVisibility[warning.dataset.unsavedType];
  311. warning.hidden = !visible;
  312. }
  313. };
  314. if (hiding) {
  315. // If hiding, we want to keep the text as-is before it disappears
  316. updateFooterTextTimeout = setTimeout(updateWarnings, 1050);
  317. } else {
  318. updateWarnings();
  319. }
  320. };