topics.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. /*
  2. * Gallery editor: putting images into topics.
  3. *
  4. * Two ways in, one code path: the ⋯ menu on each thumbnail, and dragging a
  5. * thumbnail onto another section. Both call admin/topics-api.php and then move
  6. * the <figure> in place, because reloading the page after every one of a few
  7. * hundred assignments is not an editing experience.
  8. *
  9. * The move is optimistic and reverts on failure, so the page never shows an
  10. * arrangement the server did not accept.
  11. *
  12. * Deletion is deliberately not here: it stays a plain form post inside the menu,
  13. * with the same confirmation and the same server path it has always had.
  14. */
  15. (function () {
  16. 'use strict';
  17. var root = document.getElementById('topic-blocks');
  18. if (!root) return;
  19. var api = root.dataset.api;
  20. var slug = root.dataset.slug;
  21. var csrf = root.dataset.csrf;
  22. /* A private drag type. The uploader's dropzone reacts to "Files"; this makes
  23. the two kinds of drag tell themselves apart, so dragging a thumbnail never
  24. looks like dropping a photo from the desktop, or the other way round. */
  25. var DRAG_TYPE = 'application/x-gallery-image';
  26. var status = document.createElement('p');
  27. status.className = 'help topic-status';
  28. status.hidden = true;
  29. root.parentNode.insertBefore(status, root);
  30. function fail(message) {
  31. status.textContent = message;
  32. status.hidden = false;
  33. }
  34. function clearStatus() {
  35. status.hidden = true;
  36. }
  37. /* ---- Server call ---------------------------------------------------- */
  38. function assign(key, topic) {
  39. var body = new FormData();
  40. body.append('slug', slug);
  41. body.append('action', 'assign');
  42. body.append('key', key);
  43. body.append('topic', topic);
  44. return fetch(api, {
  45. method: 'POST',
  46. headers: { 'X-CSRF-Token': csrf },
  47. body: body
  48. }).then(function (response) {
  49. return response.json().catch(function () {
  50. return { error: 'Server returned a non-JSON response (HTTP ' + response.status + ')' };
  51. }).then(function (data) {
  52. if (!response.ok || !data.ok) {
  53. throw new Error(data.error || ('Assign failed (HTTP ' + response.status + ')'));
  54. }
  55. });
  56. });
  57. }
  58. /* ---- Moving the thumbnail ------------------------------------------- */
  59. function blockFor(topic) {
  60. /* Topic ids are [A-Za-z0-9_-] only, so this cannot need escaping. */
  61. return root.querySelector('.topic-block[data-topic="' + topic + '"]');
  62. }
  63. function recount() {
  64. Array.prototype.forEach.call(root.querySelectorAll('.topic-block'), function (block) {
  65. var badge = block.querySelector('.topic-count');
  66. if (badge) badge.textContent = String(block.querySelectorAll('figure').length);
  67. });
  68. }
  69. /* Insert where the server will render it after the next reload: sections
  70. keep the images in their upload order, which is what data-order carries. */
  71. function insert(figure, row) {
  72. var order = parseInt(figure.dataset.order, 10) || 0;
  73. var after = Array.prototype.filter.call(row.children, function (el) {
  74. return el !== figure && (parseInt(el.dataset.order, 10) || 0) > order;
  75. })[0];
  76. row.insertBefore(figure, after || null);
  77. }
  78. function markCurrent(figure, topic) {
  79. Array.prototype.forEach.call(figure.querySelectorAll('.topic-pick'), function (pick) {
  80. pick.classList.toggle('is-current', pick.dataset.topic === topic);
  81. });
  82. }
  83. /* Move the thumbnail first, put it back if the server disagrees. */
  84. function move(figure, topic) {
  85. var block = blockFor(topic);
  86. if (!block) return;
  87. var row = block.querySelector('.thumb-row');
  88. var from = figure.parentNode;
  89. var before = figure.nextElementSibling;
  90. if (row === from) {
  91. closeMenus();
  92. return;
  93. }
  94. clearStatus();
  95. insert(figure, row);
  96. markCurrent(figure, topic);
  97. recount();
  98. assign(figure.dataset.key, topic).catch(function (err) {
  99. from.insertBefore(figure, before);
  100. markCurrent(figure, from.parentNode.dataset.topic);
  101. recount();
  102. fail(err.message);
  103. });
  104. closeMenus();
  105. }
  106. /* ---- The ⋯ menu ------------------------------------------------------ */
  107. function closeMenus(except) {
  108. Array.prototype.forEach.call(root.querySelectorAll('.thumb-menu'), function (menu) {
  109. if (menu === except) return;
  110. menu.hidden = true;
  111. menu.parentNode.querySelector('.thumb-menu-btn').setAttribute('aria-expanded', 'false');
  112. });
  113. }
  114. root.addEventListener('click', function (e) {
  115. var button = e.target.closest('.thumb-menu-btn');
  116. if (button) {
  117. var menu = button.parentNode.querySelector('.thumb-menu');
  118. var open = menu.hidden;
  119. closeMenus(menu);
  120. menu.hidden = !open;
  121. button.setAttribute('aria-expanded', open ? 'true' : 'false');
  122. return;
  123. }
  124. var pick = e.target.closest('.topic-pick');
  125. if (pick) {
  126. move(pick.closest('figure'), pick.dataset.topic);
  127. return;
  128. }
  129. /* A click anywhere else inside the editor, including on another
  130. thumbnail, dismisses an open menu. */
  131. if (!e.target.closest('.thumb-menu')) closeMenus();
  132. });
  133. document.addEventListener('click', function (e) {
  134. if (!e.target.closest || !e.target.closest('#topic-blocks')) closeMenus();
  135. });
  136. document.addEventListener('keydown', function (e) {
  137. if (e.key === 'Escape') closeMenus();
  138. });
  139. /* ---- Drag and drop --------------------------------------------------- */
  140. var dragged = null;
  141. root.addEventListener('dragstart', function (e) {
  142. var figure = e.target.closest('figure');
  143. if (!figure) return;
  144. dragged = figure;
  145. figure.classList.add('is-dragging');
  146. e.dataTransfer.effectAllowed = 'move';
  147. e.dataTransfer.setData(DRAG_TYPE, figure.dataset.key);
  148. /* Some browsers refuse a drag that sets no text/plain. */
  149. e.dataTransfer.setData('text/plain', figure.dataset.key);
  150. closeMenus();
  151. });
  152. root.addEventListener('dragend', function () {
  153. if (dragged) dragged.classList.remove('is-dragging');
  154. dragged = null;
  155. Array.prototype.forEach.call(root.querySelectorAll('.drag-over'), function (block) {
  156. block.classList.remove('drag-over');
  157. });
  158. });
  159. function ours(e) {
  160. var types = e.dataTransfer ? e.dataTransfer.types : null;
  161. return !!types && Array.prototype.indexOf.call(types, DRAG_TYPE) !== -1;
  162. }
  163. root.addEventListener('dragover', function (e) {
  164. if (!ours(e)) return; // a file drag belongs to the uploader, not here
  165. var block = e.target.closest('.topic-block');
  166. if (!block) return;
  167. e.preventDefault();
  168. e.dataTransfer.dropEffect = 'move';
  169. block.classList.add('drag-over');
  170. });
  171. root.addEventListener('dragleave', function (e) {
  172. var block = e.target.closest('.topic-block');
  173. /* Ignore the leave events fired while crossing the section's own
  174. children, or the highlight would flicker across every thumbnail. */
  175. if (block && !block.contains(e.relatedTarget)) block.classList.remove('drag-over');
  176. });
  177. root.addEventListener('drop', function (e) {
  178. if (!ours(e)) return;
  179. var block = e.target.closest('.topic-block');
  180. if (!block) return;
  181. e.preventDefault();
  182. block.classList.remove('drag-over');
  183. if (dragged) move(dragged, block.dataset.topic);
  184. });
  185. })();