comments.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  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 } = 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. if (focused) {
  59. this.onFocus();
  60. }
  61. this.show();
  62. this.unsubscribe = store.subscribe(() => {
  63. const state = store.getState();
  64. const comment = selectComment(state);
  65. if (!comment) {
  66. this.onDelete();
  67. }
  68. const nowFocused = selectFocused(state) === localId;
  69. if (nowFocused !== focused) {
  70. if (focused) {
  71. this.onUnfocus();
  72. } else {
  73. this.onFocus();
  74. }
  75. focused = nowFocused;
  76. }
  77. });
  78. this.setOnClickHandler(localId);
  79. }
  80. onDelete() {
  81. this.node.remove();
  82. if (this.unsubscribe) {
  83. this.unsubscribe();
  84. }
  85. }
  86. onFocus() {
  87. this.node.classList.add('w-field__comment-button--focused');
  88. this.node.ariaLabel = gettext('Unfocus comment');
  89. }
  90. onUnfocus() {
  91. this.node.classList.remove('w-field__comment-button--focused');
  92. this.node.ariaLabel = gettext('Focus comment');
  93. // TODO: ensure comment is focused accessibly when this is clicked,
  94. // and that screenreader users can return to the annotation point when desired
  95. }
  96. show() {
  97. this.node.classList.remove('u-hidden');
  98. }
  99. hide() {
  100. this.node.classList.add('u-hidden');
  101. }
  102. setOnClickHandler(localId) {
  103. this.node.addEventListener('click', () => {
  104. // Open the comments side panel
  105. commentApp.activate();
  106. commentApp.store.dispatch(
  107. commentApp.actions.setFocusedComment(localId, {
  108. updatePinnedComment: true,
  109. forceFocus: true,
  110. }),
  111. );
  112. });
  113. }
  114. getTab() {
  115. return this.fieldNode.closest('[role="tabpanel"]')?.getAttribute('id');
  116. }
  117. getAnchorNode() {
  118. return this.fieldNode;
  119. }
  120. }
  121. class MissingElementError extends Error {
  122. constructor(element, ...params) {
  123. super(...params);
  124. this.name = 'MissingElementError';
  125. this.element = element;
  126. }
  127. }
  128. class FieldLevelCommentWidget {
  129. constructor({ fieldNode, commentAdditionNode, annotationTemplateNode }) {
  130. this.fieldNode = fieldNode;
  131. this.contentpath = getContentPath(fieldNode);
  132. if (!commentAdditionNode) {
  133. throw new MissingElementError(commentAdditionNode);
  134. }
  135. this.commentAdditionNode = commentAdditionNode;
  136. if (!annotationTemplateNode) {
  137. throw new MissingElementError(annotationTemplateNode);
  138. }
  139. this.annotationTemplateNode = annotationTemplateNode;
  140. }
  141. register() {
  142. if (!this.contentpath) {
  143. // The widget has no valid contentpath, skip subscriptions
  144. return undefined;
  145. }
  146. const initialState = commentApp.store.getState();
  147. const selectCommentsForContentPath =
  148. commentApp.utils.selectCommentsForContentPathFactory(this.contentpath);
  149. let currentComments = selectCommentsForContentPath(initialState);
  150. const unsubscribeWidget = commentApp.store.subscribe(() => {
  151. const state = commentApp.store.getState();
  152. const newComments = selectCommentsForContentPath(state);
  153. const commentsChanged = currentComments !== newComments;
  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. });
  166. initialState.comments.comments.forEach((comment) => {
  167. // Add annotations for any comments already in the store
  168. if (comment.contentpath === this.contentpath) {
  169. const annotation = this.getAnnotationForComment(comment);
  170. commentApp.updateAnnotation(annotation, comment.localId);
  171. annotation.subscribeToUpdates(comment.localId);
  172. }
  173. });
  174. const addComment = () => {
  175. const annotation = this.getAnnotationForComment();
  176. const localId = commentApp.makeComment(annotation, this.contentpath);
  177. annotation.subscribeToUpdates(localId);
  178. };
  179. this.commentAdditionNode.addEventListener('click', () => {
  180. // Open the comments side panel
  181. commentApp.activate();
  182. // Make the widget button clickable to add a comment
  183. addComment();
  184. });
  185. this.fieldNode.addEventListener('keyup', (e) => {
  186. if (isCommentShortcut(e)) {
  187. if (currentComments.length === 0) {
  188. addComment();
  189. } else {
  190. commentApp.store.dispatch(
  191. commentApp.actions.setFocusedComment(currentComments[0].localId, {
  192. updatePinnedComment: true,
  193. forceFocus: true,
  194. }),
  195. );
  196. }
  197. }
  198. });
  199. return unsubscribeWidget; // TODO: listen for widget deletion and use this
  200. }
  201. getAnnotationForComment() {
  202. const annotationNode = this.annotationTemplateNode.cloneNode(true);
  203. annotationNode.id = '';
  204. annotationNode.setAttribute(
  205. 'aria-label',
  206. this.commentAdditionNode.getAttribute('aria-label'),
  207. );
  208. annotationNode.setAttribute(
  209. 'aria-describedby',
  210. this.commentAdditionNode.getAttribute('aria-describedby'),
  211. );
  212. annotationNode.classList.remove('u-hidden');
  213. this.commentAdditionNode.insertAdjacentElement(
  214. 'beforebegin',
  215. annotationNode,
  216. );
  217. return new BasicFieldLevelAnnotation(
  218. this.fieldNode,
  219. annotationNode,
  220. commentApp,
  221. );
  222. }
  223. }
  224. function initAddCommentButton(buttonElement) {
  225. const widget = new FieldLevelCommentWidget({
  226. fieldNode: buttonElement.closest('[data-contentpath]'),
  227. commentAdditionNode: buttonElement,
  228. annotationTemplateNode: document.querySelector('#comment-icon'),
  229. });
  230. widget.register();
  231. }
  232. function initCommentsInterface(formElement) {
  233. const commentsElement = document.getElementById('comments');
  234. const commentsOutputElement = document.getElementById('comments-output');
  235. const dataElement = document.getElementById('comments-data');
  236. if (!commentsElement || !commentsOutputElement || !dataElement) {
  237. throw new Error(
  238. 'Comments app failed to initialise. Missing HTML element',
  239. );
  240. }
  241. const data = JSON.parse(dataElement.textContent);
  242. commentApp.renderApp(
  243. commentsElement,
  244. commentsOutputElement,
  245. data.user,
  246. data.comments,
  247. new Map(Object.entries(data.authors)),
  248. );
  249. formElement
  250. .querySelectorAll('[data-component="add-comment-button"]')
  251. .forEach(initAddCommentButton);
  252. // Attach the commenting app to the tab navigation, if it exists
  253. const tabNavElement = formElement.querySelector(
  254. '[data-tabs] [role="tablist"]',
  255. );
  256. if (tabNavElement) {
  257. commentApp.setCurrentTab(
  258. tabNavElement
  259. .querySelector('[role="tab"][aria-selected="true"]')
  260. .getAttribute('href')
  261. .replace('#', ''),
  262. );
  263. tabNavElement.addEventListener('switch', (e) => {
  264. commentApp.setCurrentTab(e.detail.tab);
  265. });
  266. }
  267. // Show comments app
  268. const commentNotifications = formElement.querySelector(
  269. '[data-comment-notifications]',
  270. );
  271. commentNotifications.hidden = false;
  272. const tabContentElement = formElement.querySelector('.tab-content');
  273. tabContentElement.classList.add('tab-content--comments-enabled');
  274. // Open the comments panel whenever the comment app is activated by a user clicking on an "Add comment" widget on the form.
  275. const commentSidePanel = document.querySelector(
  276. '[data-side-panel="comments"]',
  277. );
  278. commentApp.onActivate(() => {
  279. commentSidePanel.dispatchEvent(new Event('open'));
  280. });
  281. // Keep number of comments up to date with comment app
  282. const commentToggle = document.querySelector(
  283. '[data-side-panel-toggle="comments"]',
  284. );
  285. const commentCounter = document.createElement('div');
  286. commentCounter.className =
  287. '-w-mr-3 w-py-0.5 w-px-[0.325rem] w-translate-y-[-8px] rtl:w-translate-x-[4px] w-translate-x-[-4px] w-text-[0.5625rem] w-font-bold w-bg-surface-button-default w-text-text-button w-border w-border-surface-page w-rounded-[1rem]';
  288. commentToggle.className =
  289. 'w-h-slim-header w-bg-transparent w-box-border w-py-3 w-px-3 w-flex w-justify-center w-items-center w-outline-offset-inside w-text-text-meta w-transition hover:w-transform hover:w-scale-110 hover:w-text-text-label focus:w-text-text-label expanded:w-text-text-label';
  290. commentToggle.appendChild(commentCounter);
  291. const updateCommentCount = () => {
  292. const commentCount = commentApp.selectors.selectCommentCount(
  293. commentApp.store.getState(),
  294. );
  295. // If comment counter element doesn't exist don't try to update innerText
  296. if (!commentCounter) {
  297. return;
  298. }
  299. if (commentCount > 0) {
  300. commentCounter.innerText = commentCount.toString();
  301. } else {
  302. // Note: Hide the circle when its content is empty
  303. commentCounter.hidden = true;
  304. }
  305. };
  306. commentApp.store.subscribe(updateCommentCount);
  307. updateCommentCount();
  308. }
  309. return {
  310. commentApp,
  311. getContentPath,
  312. isCommentShortcut,
  313. initAddCommentButton,
  314. initCommentsInterface,
  315. };
  316. })();