comments.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. import { gettext } from '../../utils/gettext';
  2. import { initCommentApp } from '../../components/CommentApp/main';
  3. const KEYCODE_M = 77;
  4. /**
  5. * Entry point loaded when the comments system is in use.
  6. */
  7. window.comments = (() => {
  8. const commentApp = initCommentApp();
  9. /**
  10. * Returns true if the provided keyboard event is using the 'add/focus comment' keyboard
  11. * shortcut
  12. */
  13. function isCommentShortcut(e) {
  14. return (e.ctrlKey || e.metaKey) && e.altKey && e.keyCode === KEYCODE_M;
  15. }
  16. function getContentPath(fieldNode) {
  17. // Return the total contentpath for an element as a string, in the form field.streamfield_uid.block...
  18. if (fieldNode.closest('data-contentpath-disabled')) {
  19. return '';
  20. }
  21. let element = fieldNode.closest('[data-contentpath]');
  22. const contentpaths = [];
  23. while (element !== null) {
  24. contentpaths.push(element.dataset.contentpath);
  25. element = element.parentElement.closest('[data-contentpath]');
  26. }
  27. contentpaths.reverse();
  28. return contentpaths.join('.');
  29. }
  30. /**
  31. * Controls the positioning of a field level comment, and the display of the button
  32. * used to focus and pin the attached comment
  33. * `getAnchorNode` is called by the comments app to determine which node to
  34. * float the comment alongside
  35. */
  36. class BasicFieldLevelAnnotation {
  37. /**
  38. * Create a field-level annotation
  39. * @param {Element} fieldNode - an element to provide the comment position
  40. * @param {Element} node - the button to focus/pin the comment
  41. */
  42. constructor(fieldNode, node) {
  43. this.node = node;
  44. this.fieldNode = fieldNode;
  45. this.unsubscribe = null;
  46. }
  47. /**
  48. * Subscribes the annotation to update when the state of a particular comment changes,
  49. * and to focus that comment when clicked
  50. * @param {number} localId - the localId of the comment to subscribe to
  51. */
  52. subscribeToUpdates(localId) {
  53. const { selectFocused, selectEnabled } = commentApp.selectors;
  54. const selectComment = commentApp.utils.selectCommentFactory(localId);
  55. const store = commentApp.store;
  56. const initialState = store.getState();
  57. let focused = selectFocused(initialState) === localId;
  58. let shown = selectEnabled(initialState);
  59. if (focused) {
  60. this.onFocus();
  61. }
  62. if (shown) {
  63. this.show();
  64. }
  65. this.unsubscribe = store.subscribe(() => {
  66. const state = store.getState();
  67. const comment = selectComment(state);
  68. if (!comment) {
  69. this.onDelete();
  70. }
  71. const nowFocused = selectFocused(state) === localId;
  72. if (nowFocused !== focused) {
  73. if (focused) {
  74. this.onUnfocus();
  75. } else {
  76. this.onFocus();
  77. }
  78. focused = nowFocused;
  79. }
  80. if (shown !== selectEnabled(state)) {
  81. if (shown) {
  82. this.hide();
  83. } else {
  84. this.show();
  85. }
  86. shown = selectEnabled(state);
  87. }
  88. });
  89. this.setOnClickHandler(localId);
  90. }
  91. onDelete() {
  92. this.node.remove();
  93. if (this.unsubscribe) {
  94. this.unsubscribe();
  95. }
  96. }
  97. onFocus() {
  98. this.node.classList.remove('button-secondary');
  99. this.node.ariaLabel = gettext('Unfocus comment');
  100. }
  101. onUnfocus() {
  102. this.node.classList.add('button-secondary');
  103. this.node.ariaLabel = gettext('Focus comment');
  104. // TODO: ensure comment is focused accessibly when this is clicked,
  105. // and that screenreader users can return to the annotation point when desired
  106. }
  107. show() {
  108. this.node.classList.remove('u-hidden');
  109. }
  110. hide() {
  111. this.node.classList.add('u-hidden');
  112. }
  113. setOnClickHandler(localId) {
  114. this.node.addEventListener('click', () => {
  115. commentApp.store.dispatch(
  116. commentApp.actions.setFocusedComment(localId, {
  117. updatePinnedComment: true,
  118. forceFocus: true,
  119. }),
  120. );
  121. });
  122. }
  123. getTab() {
  124. return this.fieldNode
  125. .closest('section[data-tab]')
  126. ?.getAttribute('data-tab');
  127. }
  128. getAnchorNode() {
  129. return this.fieldNode;
  130. }
  131. }
  132. class FieldLevelCommentWidget {
  133. constructor({ fieldNode, commentAdditionNode, annotationTemplateNode }) {
  134. this.fieldNode = fieldNode;
  135. this.contentpath = getContentPath(fieldNode);
  136. this.commentAdditionNode = commentAdditionNode;
  137. this.annotationTemplateNode = annotationTemplateNode;
  138. this.shown = false;
  139. }
  140. register() {
  141. const { selectEnabled } = commentApp.selectors;
  142. const initialState = commentApp.store.getState();
  143. let currentlyEnabled = selectEnabled(initialState);
  144. const selectCommentsForContentPath =
  145. commentApp.utils.selectCommentsForContentPathFactory(this.contentpath);
  146. let currentComments = selectCommentsForContentPath(initialState);
  147. this.updateVisibility(currentComments.length === 0 && currentlyEnabled);
  148. const unsubscribeWidget = commentApp.store.subscribe(() => {
  149. const state = commentApp.store.getState();
  150. const newComments = selectCommentsForContentPath(state);
  151. const newEnabled = selectEnabled(state);
  152. const commentsChanged = currentComments !== newComments;
  153. const enabledChanged = currentlyEnabled !== newEnabled;
  154. if (commentsChanged) {
  155. // Add annotations for any new comments
  156. currentComments = newComments;
  157. currentComments
  158. .filter((comment) => comment.annotation === null)
  159. .forEach((comment) => {
  160. const annotation = this.getAnnotationForComment(comment);
  161. commentApp.updateAnnotation(annotation, comment.localId);
  162. annotation.subscribeToUpdates(comment.localId);
  163. });
  164. }
  165. if (enabledChanged || commentsChanged) {
  166. // If comments have been enabled or disabled, or the comments have changed
  167. // check whether to show the widget (if comments are enabled and there are no existing comments)
  168. currentlyEnabled = newEnabled;
  169. this.updateVisibility(
  170. currentComments.length === 0 && currentlyEnabled,
  171. );
  172. }
  173. });
  174. initialState.comments.comments.forEach((comment) => {
  175. // Add annotations for any comments already in the store
  176. if (comment.contentpath === this.contentpath) {
  177. const annotation = this.getAnnotationForComment(comment);
  178. commentApp.updateAnnotation(annotation, comment.localId);
  179. annotation.subscribeToUpdates(comment.localId);
  180. }
  181. });
  182. const addComment = () => {
  183. const annotation = this.getAnnotationForComment();
  184. const localId = commentApp.makeComment(annotation, this.contentpath);
  185. annotation.subscribeToUpdates(localId);
  186. };
  187. this.commentAdditionNode.addEventListener('click', () => {
  188. // Make the widget button clickable to add a comment
  189. addComment();
  190. });
  191. this.fieldNode.addEventListener('keyup', (e) => {
  192. if (currentlyEnabled && isCommentShortcut(e)) {
  193. if (currentComments.length === 0) {
  194. addComment();
  195. } else {
  196. commentApp.store.dispatch(
  197. commentApp.actions.setFocusedComment(currentComments[0].localId, {
  198. updatePinnedComment: true,
  199. forceFocus: true,
  200. }),
  201. );
  202. }
  203. }
  204. });
  205. return unsubscribeWidget; // TODO: listen for widget deletion and use this
  206. }
  207. updateVisibility(newShown) {
  208. if (newShown === this.shown) {
  209. return;
  210. }
  211. this.shown = newShown;
  212. if (!this.shown) {
  213. this.commentAdditionNode.classList.add('u-hidden');
  214. } else {
  215. this.commentAdditionNode.classList.remove('u-hidden');
  216. }
  217. }
  218. getAnnotationForComment() {
  219. const annotationNode = this.annotationTemplateNode.cloneNode(true);
  220. annotationNode.id = '';
  221. annotationNode.classList.remove('u-hidden');
  222. this.commentAdditionNode.insertAdjacentElement(
  223. 'afterend',
  224. annotationNode,
  225. );
  226. return new BasicFieldLevelAnnotation(
  227. this.fieldNode,
  228. annotationNode,
  229. commentApp,
  230. );
  231. }
  232. }
  233. function initAddCommentButton(buttonElement) {
  234. const widget = new FieldLevelCommentWidget({
  235. fieldNode: buttonElement.closest('[data-contentpath]'),
  236. commentAdditionNode: buttonElement,
  237. annotationTemplateNode: document.querySelector('#comment-icon'),
  238. });
  239. if (widget.contentpath) {
  240. widget.register();
  241. }
  242. }
  243. function initCommentsInterface(formElement) {
  244. const commentsElement = document.getElementById('comments');
  245. const commentsOutputElement = document.getElementById('comments-output');
  246. const dataElement = document.getElementById('comments-data');
  247. if (!commentsElement || !commentsOutputElement || !dataElement) {
  248. throw new Error(
  249. 'Comments app failed to initialise. Missing HTML element',
  250. );
  251. }
  252. const data = JSON.parse(dataElement.textContent);
  253. commentApp.renderApp(
  254. commentsElement,
  255. commentsOutputElement,
  256. data.user,
  257. data.comments,
  258. new Map(Object.entries(data.authors)),
  259. );
  260. // Local state to hold active state of comments
  261. let commentsActive = false;
  262. formElement
  263. .querySelectorAll('[data-component="add-comment-button"]')
  264. .forEach(initAddCommentButton);
  265. // Attach the commenting app to the tab navigation, if it exists
  266. const tabNavElement = formElement.querySelector(
  267. '[data-tabs] [role="tablist"]',
  268. );
  269. if (tabNavElement) {
  270. commentApp.setCurrentTab(tabNavElement.dataset.currentTab);
  271. tabNavElement.addEventListener('switch', (e) => {
  272. commentApp.setCurrentTab(e.detail.tab);
  273. });
  274. }
  275. // Comments toggle
  276. const commentToggle = document.querySelector('[data-comments-toggle]');
  277. const commentNotifications = formElement.querySelector(
  278. '[data-comment-notifications]',
  279. );
  280. const tabContentElement = formElement.querySelector('.tab-content');
  281. const updateCommentVisibility = (visible) => {
  282. // Show/hide comments
  283. commentApp.setVisible(visible);
  284. // Add/Remove tab-nav--comments-enabled class. This changes the size of streamfields
  285. if (visible) {
  286. commentToggle.classList.add('w-text-primary');
  287. tabContentElement.classList.add('tab-content--comments-enabled');
  288. commentNotifications.hidden = false;
  289. } else {
  290. commentToggle.classList.remove('w-text-primary');
  291. tabContentElement.classList.remove('tab-content--comments-enabled');
  292. commentNotifications.hidden = true;
  293. }
  294. };
  295. if (commentToggle) {
  296. commentToggle.addEventListener('click', () => {
  297. commentsActive = !commentsActive;
  298. updateCommentVisibility(commentsActive);
  299. });
  300. }
  301. // Keep number of comments up to date with comment app
  302. const commentCounter = document.querySelector(
  303. '[data-comments-toggle-count]',
  304. );
  305. const updateCommentCount = () => {
  306. const commentCount = commentApp.selectors.selectCommentCount(
  307. commentApp.store.getState(),
  308. );
  309. // If comment counter element doesn't exist don't try to update innerText
  310. if (!commentCounter) {
  311. return;
  312. }
  313. if (commentCount > 0) {
  314. commentCounter.innerText = commentCount.toString();
  315. } else {
  316. // Note: CSS will hide the circle when its content is empty
  317. commentCounter.innerText = '';
  318. }
  319. };
  320. commentApp.store.subscribe(updateCommentCount);
  321. updateCommentCount();
  322. }
  323. return {
  324. commentApp,
  325. getContentPath,
  326. isCommentShortcut,
  327. initAddCommentButton,
  328. initCommentsInterface,
  329. };
  330. })();