comments.js 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import { initCommentApp } from './main';
  2. import { STRINGS } from '../../config/wagtailConfig';
  3. function initComments() {
  4. window.commentApp = initCommentApp();
  5. document.addEventListener('DOMContentLoaded', () => {
  6. const commentsElement = document.getElementById('comments');
  7. const commentsOutputElement = document.getElementById('comments-output');
  8. const dataElement = document.getElementById('comments-data');
  9. if (!commentsElement || !commentsOutputElement || !dataElement) {
  10. throw new Error('Comments app failed to initialise. Missing HTML element');
  11. }
  12. const data = JSON.parse(dataElement.textContent);
  13. window.commentApp.renderApp(
  14. commentsElement, commentsOutputElement, data.user, data.comments, new Map(Object.entries(data.authors)), STRINGS
  15. );
  16. });
  17. }
  18. function getContentPath(fieldNode) {
  19. // Return the total contentpath for an element as a string, in the form field.streamfield_uid.block...
  20. if (fieldNode.closest('data-contentpath-disabled')) {
  21. return '';
  22. }
  23. let element = fieldNode.closest('[data-contentpath]');
  24. const contentpaths = [];
  25. while (element !== null) {
  26. contentpaths.push(element.dataset.contentpath);
  27. element = element.parentElement.closest('[data-contentpath]');
  28. }
  29. contentpaths.reverse();
  30. return contentpaths.join('.');
  31. }
  32. /**
  33. * Controls the positioning of a field level comment, and the display of the button
  34. * used to focus and pin the attached comment
  35. * `getDesiredPosition` is called by the comments app to determine the height
  36. * at which to float the comment.
  37. */
  38. class BasicFieldLevelAnnotation {
  39. /**
  40. * Create a field-level annotation
  41. * @param {Element} fieldNode - an element to provide the comment position
  42. * @param {Element} node - the button to focus/pin the comment
  43. * @param commentApp - the commentApp the annotation is integrating with
  44. */
  45. constructor(fieldNode, node, commentApp) {
  46. this.node = node;
  47. this.fieldNode = fieldNode;
  48. this.unsubscribe = null;
  49. this.commentApp = commentApp;
  50. }
  51. /**
  52. * Subscribes the annotation to update when the state of a particular comment changes,
  53. * and to focus that comment when clicked
  54. * @param {number} localId - the localId of the comment to subscribe to
  55. */
  56. subscribeToUpdates(localId) {
  57. const { selectFocused, selectEnabled } = this.commentApp.selectors;
  58. const selectComment = this.commentApp.utils.selectCommentFactory(localId);
  59. const store = this.commentApp.store;
  60. const initialState = store.getState();
  61. let focused = selectFocused(initialState) === localId;
  62. let shown = selectEnabled(initialState);
  63. if (focused) {
  64. this.onFocus();
  65. }
  66. if (shown) {
  67. this.show();
  68. }
  69. this.unsubscribe = store.subscribe(() => {
  70. const state = store.getState();
  71. const comment = selectComment(state);
  72. if (!comment) {
  73. this.onDelete();
  74. }
  75. const nowFocused = (selectFocused(state) === localId);
  76. if (nowFocused !== focused) {
  77. if (focused) {
  78. this.onUnfocus();
  79. } else {
  80. this.onFocus();
  81. }
  82. focused = nowFocused;
  83. }
  84. if (shown !== selectEnabled(state)) {
  85. if (shown) {
  86. this.hide();
  87. } else {
  88. this.show();
  89. }
  90. shown = selectEnabled(state);
  91. }
  92. }
  93. );
  94. this.setOnClickHandler(localId);
  95. }
  96. onDelete() {
  97. this.node.remove();
  98. if (this.unsubscribe) {
  99. this.unsubscribe();
  100. }
  101. }
  102. onFocus() {
  103. this.node.classList.remove('button-secondary');
  104. this.node.ariaLabel = STRINGS.UNFOCUS_COMMENT;
  105. }
  106. onUnfocus() {
  107. this.node.classList.add('button-secondary');
  108. this.node.ariaLabel = STRINGS.FOCUS_COMMENT;
  109. // TODO: ensure comment is focused accessibly when this is clicked,
  110. // and that screenreader users can return to the annotation point when desired
  111. }
  112. show() {
  113. this.node.classList.remove('u-hidden');
  114. }
  115. hide() {
  116. this.node.classList.add('u-hidden');
  117. }
  118. setOnClickHandler(localId) {
  119. this.node.addEventListener('click', () => {
  120. this.commentApp.store.dispatch(
  121. this.commentApp.actions.setFocusedComment(localId, { updatePinnedComment: true })
  122. );
  123. });
  124. }
  125. getDesiredPosition() {
  126. return (
  127. this.fieldNode.getBoundingClientRect().top +
  128. document.documentElement.scrollTop
  129. );
  130. }
  131. }
  132. class FieldLevelCommentWidget {
  133. constructor({
  134. fieldNode,
  135. commentAdditionNode,
  136. annotationTemplateNode,
  137. commentApp
  138. }) {
  139. this.fieldNode = fieldNode;
  140. this.contentpath = getContentPath(fieldNode);
  141. this.commentAdditionNode = commentAdditionNode;
  142. this.annotationTemplateNode = annotationTemplateNode;
  143. this.shown = false;
  144. this.commentApp = commentApp;
  145. }
  146. register() {
  147. const { selectEnabled } = this.commentApp.selectors;
  148. const initialState = this.commentApp.store.getState();
  149. let currentlyEnabled = selectEnabled(initialState);
  150. const selectCommentsForContentPath = this.commentApp.utils.selectCommentsForContentPathFactory(
  151. this.contentpath
  152. );
  153. let currentComments = selectCommentsForContentPath(initialState);
  154. this.updateVisibility(currentComments.length === 0 && currentlyEnabled);
  155. const unsubscribeWidget = this.commentApp.store.subscribe(() => {
  156. const state = this.commentApp.store.getState();
  157. const newComments = selectCommentsForContentPath(state);
  158. const newEnabled = selectEnabled(state);
  159. const commentsChanged = (currentComments !== newComments);
  160. const enabledChanged = (currentlyEnabled !== newEnabled);
  161. if (commentsChanged) {
  162. // Add annotations for any new comments
  163. currentComments = newComments;
  164. currentComments.filter((comment) => comment.annotation === null).forEach((comment) => {
  165. const annotation = this.getAnnotationForComment(comment);
  166. this.commentApp.updateAnnotation(
  167. annotation,
  168. comment.localId
  169. );
  170. annotation.subscribeToUpdates(comment.localId);
  171. });
  172. }
  173. if (enabledChanged || commentsChanged) {
  174. // If comments have been enabled or disabled, or the comments have changed
  175. // check whether to show the widget (if comments are enabled and there are no existing comments)
  176. currentlyEnabled = newEnabled;
  177. this.updateVisibility(currentComments.length === 0 && currentlyEnabled);
  178. }
  179. });
  180. initialState.comments.comments.forEach((comment) => {
  181. // Add annotations for any comments already in the store
  182. if (comment.contentpath === this.contentpath) {
  183. const annotation = this.getAnnotationForComment(comment);
  184. this.commentApp.updateAnnotation(annotation, comment.localId);
  185. annotation.subscribeToUpdates(comment.localId);
  186. }
  187. });
  188. this.commentAdditionNode.addEventListener('click', () => {
  189. // Make the widget button clickable to add a comment
  190. const annotation = this.getAnnotationForComment();
  191. const localId = this.commentApp.makeComment(annotation, this.contentpath);
  192. annotation.subscribeToUpdates(localId);
  193. });
  194. return unsubscribeWidget; // TODO: listen for widget deletion and use this
  195. }
  196. updateVisibility(newShown) {
  197. if (newShown === this.shown) {
  198. return;
  199. }
  200. this.shown = newShown;
  201. if (!this.shown) {
  202. this.commentAdditionNode.classList.add('u-hidden');
  203. } else {
  204. this.commentAdditionNode.classList.remove('u-hidden');
  205. }
  206. }
  207. getAnnotationForComment() {
  208. const annotationNode = this.annotationTemplateNode.cloneNode(true);
  209. annotationNode.id = '';
  210. annotationNode.classList.remove('u-hidden');
  211. this.commentAdditionNode.insertAdjacentElement('afterend', annotationNode);
  212. return new BasicFieldLevelAnnotation(this.fieldNode, annotationNode, this.commentApp);
  213. }
  214. }
  215. function initFieldLevelCommentWidget(fieldElement) {
  216. const widget = new FieldLevelCommentWidget({
  217. fieldNode: fieldElement,
  218. commentAdditionNode: fieldElement.querySelector('[data-comment-add]'),
  219. annotationTemplateNode: document.querySelector('#comment-icon'),
  220. commentApp: window.commentApp
  221. });
  222. if (widget.contentpath) {
  223. widget.register();
  224. }
  225. }
  226. export default {
  227. getContentPath,
  228. initComments,
  229. FieldLevelCommentWidget,
  230. initFieldLevelCommentWidget
  231. };