Browse Source

Feature/redux comments (#6856)

Adds inline commenting to the Draftail editor (FE only), and refactors field level comments to subscribe to updates from the store directly



* Refactor field level comments to subscribe to updates directly from the commenting store, rather than calling methods on supplied widgets and annotations directly from the commenting app

* Update Draftail and React-Redux packages in preparation for Draftail inline commenting

* Add CommentableEditor version of Draftail as controlled component, in preparation for manipulating state from the comments system

* Only initialize CommentableEditor if comments are on and the contentpath is valid. Add a comment-adding control to CommentableEditor

* Update eslint

* Remove comment adding control from Draftail if comments are disabled

* fixup! Only initialize CommentableEditor if comments are on and the contentpath is valid. Add a comment-adding control to CommentableEditor

* Add decorator to comments, allowing them to be focused

* Add inline styling to comments

* Make Draftail instance accessible via the DOM node on CommentableEditor as well

* Force rerender for styles and decorators when necessary, and filter out deleted comments

* Remove comment styles when saving Draftail content

* Fix formatting error

* Remove unnecessary comment

* Don't use addition for string concatenation

* Newline

* Add explanatory comment about save logic

* fixup! Don't use addition for string concatenation

* Use more idiomatic undefined check

* Fix aria-label for comment button

* Use span to decorate link

Co-authored-by: Thibaud Colas <thibaudcolas@gmail.com>

* Update getFullSelectionState comment

Co-authored-by: Thibaud Colas <thibaudcolas@gmail.com>

* Reorder selection state generation

Co-authored-by: Thibaud Colas <thibaudcolas@gmail.com>

* Remove unused argument

Co-authored-by: Thibaud Colas <thibaudcolas@gmail.com>

* Make draftail position comments by median annotation, and pin by clicked comment

* Remove inline return

* Make setPinnedComment an option on setFocusedComment

* Add JSDoc comments and remove unused attribute

* use decoratorRef instead of Ref for clarity in annotation

* fixup! Update eslint

* Update Draftail snapshot

* Move entrypoint

* Prettier reformat and eslint fix

* Use Typescript for CommentableEditor

* Install types for react-redux and draft-js

* Remove unused popPage from interface

* Add draftjs-filters as an explicit dependency

* fixup! Use Typescript for CommentableEditor

* Add explicit type for timeout

Co-authored-by: Thibaud Colas <thibaudcolas@gmail.com>
Jacob Topp-Mugglestone 4 năm trước cách đây
mục cha
commit
7f528e7c93

+ 137 - 40
client/src/components/CommentApp/comments.js

@@ -1,16 +1,8 @@
-import { initCommentsApp } from 'wagtail-comment-frontend';
+import { initCommentApp } from 'wagtail-comment-frontend';
 import { STRINGS } from '../../config/wagtailConfig';
 
 function initComments() {
-  // in case any widgets try to initialise themselves before the comment app,
-  // store their initialisations as callbacks to be executed when the comment app
-  // itself is finished initialising.
-  const callbacks = [];
-  window.commentApp = {
-    registerWidget: (widget) => {
-      callbacks.push(() => { window.commentApp.registerWidget(widget); });
-    }
-  };
+  window.commentApp = initCommentApp();
   document.addEventListener('DOMContentLoaded', () => {
     const commentsElement = document.getElementById('comments');
     const commentsOutputElement = document.getElementById('comments-output');
@@ -19,10 +11,9 @@ function initComments() {
       throw new Error('Comments app failed to initialise. Missing HTML element');
     }
     const data = JSON.parse(dataElement.textContent);
-    window.commentApp = initCommentsApp(
+    window.commentApp.renderApp(
       commentsElement, commentsOutputElement, data.user, data.comments, new Map(Object.entries(data.authors)), STRINGS
     );
-    callbacks.forEach((callback) => { callback(); });
   });
 }
 
@@ -41,14 +32,75 @@ function getContentPath(fieldNode) {
   return contentpaths.join('.');
 }
 
+/**
+ * Controls the positioning of a field level comment, and the display of the button
+ * used to focus and pin the attached comment
+ * `getDesiredPosition` is called by the comments app to determine the height
+ * at which to float the comment.
+ */
 class BasicFieldLevelAnnotation {
-  constructor(fieldNode, node) {
+  /**
+  * Create a field-level annotation
+  * @param {Element} fieldNode - an element to provide the comment position
+  * @param {Element} node - the button to focus/pin the comment
+  * @param commentApp - the commentApp the annotation is integrating with
+  */
+  constructor(fieldNode, node, commentApp) {
     this.node = node;
     this.fieldNode = fieldNode;
-    this.position = '';
+    this.unsubscribe = null;
+    this.commentApp = commentApp;
+  }
+  /**
+  * Subscribes the annotation to update when the state of a particular comment changes,
+  * and to focus that comment when clicked
+  * @param {number} localId - the localId of the comment to subscribe to
+  */
+  subscribeToUpdates(localId) {
+    const { selectFocused, selectEnabled } = this.commentApp.selectors;
+    const selectComment = this.commentApp.utils.selectCommentFactory(localId);
+    const store = this.commentApp.store;
+    const initialState = store.getState();
+    let focused = selectFocused(initialState) === localId;
+    let shown = selectEnabled(initialState);
+    if (focused) {
+      this.onFocus();
+    }
+    if (shown) {
+      this.show();
+    }
+    this.unsubscribe = store.subscribe(() => {
+      const state = store.getState();
+      const comment = selectComment(state);
+      if (!comment) {
+        this.onDelete();
+      }
+      const nowFocused = (selectFocused(state) === localId);
+      if (nowFocused !== focused) {
+        if (focused) {
+          this.onUnfocus();
+        } else {
+          this.onFocus();
+        }
+        focused = nowFocused;
+      }
+      if (shown !== selectEnabled(state)) {
+        if (shown) {
+          this.hide();
+        } else {
+          this.show();
+        }
+        shown = selectEnabled(state);
+      }
+    }
+    );
+    this.setOnClickHandler(localId);
   }
   onDelete() {
     this.node.remove();
+    if (this.unsubscribe) {
+      this.unsubscribe();
+    }
   }
   onFocus() {
     this.node.classList.remove('button-secondary');
@@ -56,7 +108,7 @@ class BasicFieldLevelAnnotation {
   }
   onUnfocus() {
     this.node.classList.add('button-secondary');
-    this.node.ariaLabel = STRINGS.UNFOCUS_COMMENT;
+    this.node.ariaLabel = STRINGS.FOCUS_COMMENT;
     // TODO: ensure comment is focused accessibly when this is clicked,
     // and that screenreader users can return to the annotation point when desired
   }
@@ -66,8 +118,12 @@ class BasicFieldLevelAnnotation {
   hide() {
     this.node.classList.add('u-hidden');
   }
-  setOnClickHandler(handler) {
-    this.node.addEventListener('click', handler);
+  setOnClickHandler(localId) {
+    this.node.addEventListener('click', () => {
+      this.commentApp.store.dispatch(
+        this.commentApp.actions.setFocusedComment(localId, { updatePinnedComment: true })
+      );
+    });
   }
   getDesiredPosition() {
     return (
@@ -81,34 +137,73 @@ class FieldLevelCommentWidget {
   constructor({
     fieldNode,
     commentAdditionNode,
-    annotationTemplateNode
+    annotationTemplateNode,
+    commentApp
   }) {
     this.fieldNode = fieldNode;
     this.contentpath = getContentPath(fieldNode);
     this.commentAdditionNode = commentAdditionNode;
     this.annotationTemplateNode = annotationTemplateNode;
-    this.commentNumber = 0;
-    this.commentsEnabled = false;
-  }
-  onRegister(makeComment) {
+    this.shown = false;
+    this.commentApp = commentApp;
+  }
+  register() {
+    const { selectEnabled } = this.commentApp.selectors;
+    const initialState = this.commentApp.store.getState();
+    let currentlyEnabled = selectEnabled(initialState);
+    const selectCommentsForContentPath = this.commentApp.utils.selectCommentsForContentPathFactory(
+      this.contentpath
+    );
+    let currentComments = selectCommentsForContentPath(initialState);
+    this.updateVisibility(currentComments.length === 0 && currentlyEnabled);
+    const unsubscribeWidget = this.commentApp.store.subscribe(() => {
+      const state = this.commentApp.store.getState();
+      const newComments = selectCommentsForContentPath(state);
+      const newEnabled = selectEnabled(state);
+      const commentsChanged = (currentComments !== newComments);
+      const enabledChanged = (currentlyEnabled !== newEnabled);
+      if (commentsChanged) {
+        // Add annotations for any new comments
+        currentComments = newComments;
+        currentComments.filter((comment) => comment.annotation === null).forEach((comment) => {
+          const annotation = this.getAnnotationForComment(comment);
+          this.commentApp.updateAnnotation(
+            annotation,
+            comment.localId
+          );
+          annotation.subscribeToUpdates(comment.localId);
+        });
+      }
+      if (enabledChanged || commentsChanged) {
+        // If comments have been enabled or disabled, or the comments have changed
+        // check whether to show the widget (if comments are enabled and there are no existing comments)
+        currentlyEnabled = newEnabled;
+        this.updateVisibility(currentComments.length === 0 && currentlyEnabled);
+      }
+    });
+    initialState.comments.comments.forEach((comment) => {
+      // Add annotations for any comments already in the store
+      if (comment.contentpath === this.contentpath) {
+        const annotation = this.getAnnotationForComment(comment);
+        this.commentApp.updateAnnotation(annotation, comment.localId);
+        annotation.subscribeToUpdates(comment.localId);
+      }
+    });
     this.commentAdditionNode.addEventListener('click', () => {
-      makeComment(this.getAnnotationForComment(), this.contentpath);
+      // Make the widget button clickable to add a comment
+      const annotation = this.getAnnotationForComment();
+      const localId = this.commentApp.makeComment(annotation, this.contentpath);
+      annotation.subscribeToUpdates(localId);
     });
+    return unsubscribeWidget; // TODO: listen for widget deletion and use this
   }
-  setEnabled(enabled) {
-    // Update whether comments are enabled for the page
-    this.commentsEnabled = enabled;
-    this.updateVisibility();
-  }
-  onChangeComments(comments) {
-    // Receives a list of comments for the widget's contentpath
-    this.commentNumber = comments.length;
-    this.updateVisibility();
-  }
-  updateVisibility() {
-    // if comments are disabled, or the widget already has at least one associated comment,
-    // don't show the comment addition button
-    if (!this.commentsEnabled || this.commentNumber > 0) {
+  updateVisibility(newShown) {
+    if (newShown === this.shown) {
+      return;
+    }
+    this.shown = newShown;
+
+    if (!this.shown) {
       this.commentAdditionNode.classList.add('u-hidden');
     } else {
       this.commentAdditionNode.classList.remove('u-hidden');
@@ -119,7 +214,7 @@ class FieldLevelCommentWidget {
     annotationNode.id = '';
     annotationNode.classList.remove('u-hidden');
     this.commentAdditionNode.insertAdjacentElement('afterend', annotationNode);
-    return new BasicFieldLevelAnnotation(this.fieldNode, annotationNode);
+    return new BasicFieldLevelAnnotation(this.fieldNode, annotationNode, this.commentApp);
   }
 }
 
@@ -127,14 +222,16 @@ function initFieldLevelCommentWidget(fieldElement) {
   const widget = new FieldLevelCommentWidget({
     fieldNode: fieldElement,
     commentAdditionNode: fieldElement.querySelector('[data-comment-add]'),
-    annotationTemplateNode: document.querySelector('#comment-icon')
+    annotationTemplateNode: document.querySelector('#comment-icon'),
+    commentApp: window.commentApp
   });
   if (widget.contentpath) {
-    window.commentApp.registerWidget(widget);
+    widget.register();
   }
 }
 
 export default {
+  getContentPath,
   initComments,
   FieldLevelCommentWidget,
   initFieldLevelCommentWidget

+ 406 - 0
client/src/components/Draftail/CommentableEditor/CommentableEditor.tsx

@@ -0,0 +1,406 @@
+import type { CommentApp } from 'wagtail-comment-frontend';
+import type { Annotation } from 'wagtail-comment-frontend/src/utils/annotation';
+import {
+  DraftailEditor,
+  ToolbarButton,
+  createEditorStateFromRaw,
+  serialiseEditorStateToRaw,
+} from 'draftail';
+import { ContentBlock, ContentState, EditorState, Modifier, RawDraftContentState, RichUtils, SelectionState } from 'draft-js';
+import type { DraftEditorLeaf } from 'draft-js/lib/DraftEditorLeaf.react';
+import { filterInlineStyles } from 'draftjs-filters';
+import React, { MutableRefObject, ReactText, useEffect, useMemo, useRef, useState } from 'react';
+import { useSelector, shallowEqual } from 'react-redux';
+
+import { STRINGS } from '../../../config/wagtailConfig';
+import Icon from '../../Icon/Icon';
+
+const COMMENT_STYLE_IDENTIFIER = 'COMMENT-';
+
+function usePrevious<Type>(value: Type) {
+  const ref = useRef(value);
+  useEffect(() => {
+    ref.current = value;
+  }, [value]);
+  return ref.current;
+}
+
+type DecoratorRef = MutableRefObject<HTMLSpanElement | null>;
+type BlockKey = string;
+
+/**
+ * Controls the positioning of a comment that has been added to Draftail.
+ * `getDesiredPosition` is called by the comments app to determine the height
+ * at which to float the comment.
+ */
+class DraftailInlineAnnotation implements Annotation {
+  /**
+   * Create an inline annotation
+   * @param {Element} field - an element to provide the fallback position for comments without any inline decorators
+   */
+  field: Element
+  decoratorRefs: Map<DecoratorRef, BlockKey>
+  focusedBlockKey: BlockKey
+  cachedMedianRef: DecoratorRef | null
+
+  constructor(field: Element) {
+    this.field = field;
+    this.decoratorRefs = new Map();
+    this.focusedBlockKey = '';
+    this.cachedMedianRef = null;
+  }
+  addDecoratorRef(ref: DecoratorRef, blockKey: BlockKey) {
+    this.decoratorRefs.set(ref, blockKey);
+
+    // We're adding a ref, so remove the cached median refs - this needs to be recalculated
+    this.cachedMedianRef = null;
+  }
+  removeDecoratorRef(ref: DecoratorRef) {
+    this.decoratorRefs.delete(ref);
+
+    // We're deleting a ref, so remove the cached median refs - this needs to be recalculated
+    this.cachedMedianRef = null;
+  }
+  setFocusedBlockKey(blockKey: BlockKey) {
+    this.focusedBlockKey = blockKey;
+  }
+  static getHeightForRef(ref: DecoratorRef) {
+    if (ref.current) {
+      return ref.current.getBoundingClientRect().top;
+    }
+    return 0;
+  }
+  static getMedianRef(refArray: Array<DecoratorRef>) {
+    const refs = refArray.sort(
+      (a, b) => this.getHeightForRef(a) - this.getHeightForRef(b)
+    );
+    const length = refs.length;
+    if (length > 0) {
+      return refs[Math.ceil(length / 2 - 1)];
+    }
+    return null;
+  }
+  getDesiredPosition(focused = false) {
+    // The comment should always aim to float by an annotation, rather than between them, so calculate which annotation is the median one by height and float the comment by that
+    let medianRef: null | DecoratorRef = null;
+    if (focused) {
+      // If the comment is focused, calculate the median of refs only within the focused block, to ensure the comment is visisble
+      // if the highlight has somehow been split up
+      medianRef = DraftailInlineAnnotation.getMedianRef(
+        Array.from(this.decoratorRefs.keys()).filter(
+          (ref) => this.decoratorRefs.get(ref) === this.focusedBlockKey
+        )
+      );
+    } else if (!this.cachedMedianRef) {
+      // Our cache is empty - try to update it
+      medianRef = DraftailInlineAnnotation.getMedianRef(
+        Array.from(this.decoratorRefs.keys())
+      );
+      this.cachedMedianRef = medianRef;
+    } else {
+      // Use the cached median refs
+      medianRef = this.cachedMedianRef;
+    }
+
+    if (medianRef) {
+      // We have a median ref - calculate its height
+      return (
+        DraftailInlineAnnotation.getHeightForRef(medianRef) +
+        document.documentElement.scrollTop
+      );
+    }
+
+    const fieldNode = this.field;
+    if (fieldNode) {
+      // Fallback to the field node, if the comment has no decorator refs
+      return (
+        fieldNode.getBoundingClientRect().top +
+        document.documentElement.scrollTop
+      );
+    }
+    return 0;
+  }
+}
+
+/**
+ * Get a selection state corresponding to the full contentState.
+ */
+function getFullSelectionState(contentState: ContentState) {
+  const lastBlock = contentState.getLastBlock();
+  return new SelectionState({
+    anchorKey: contentState.getFirstBlock().getKey(),
+    anchorOffset: 0,
+    focusKey: lastBlock.getKey(),
+    focusOffset: lastBlock.getLength()
+  });
+}
+
+interface ControlProps {
+  getEditorState: () => EditorState,
+  onChange: (editorState: EditorState) => void
+}
+
+function getCommentControl(commentApp: CommentApp, contentPath: string, fieldNode: Element) {
+  return ({ getEditorState, onChange }: ControlProps) => (
+    <ToolbarButton
+      name="comment"
+      active={false}
+      title={STRINGS.ADD_A_COMMENT}
+      icon={<Icon name="comment" />}
+      onClick={() => {
+        const annotation = new DraftailInlineAnnotation(fieldNode);
+        const commentId = commentApp.makeComment(annotation, contentPath);
+        onChange(
+          RichUtils.toggleInlineStyle(
+            getEditorState(),
+            `${COMMENT_STYLE_IDENTIFIER}${commentId}`
+          )
+        );
+      }}
+    />
+  );
+}
+
+function findCommentStyleRanges(contentBlock: ContentBlock, callback: (start: number, end: number) => void) {
+  contentBlock.findStyleRanges(
+    (metadata) =>
+      metadata
+        .getStyle()
+        .some((style) => style !== undefined && style.startsWith(COMMENT_STYLE_IDENTIFIER)),
+    (start, end) => {
+      callback(start, end);
+    }
+  );
+}
+
+interface DecoratorProps {
+  contentState: ContentState,
+  children?: Array<DraftEditorLeaf>
+}
+
+function getCommentDecorator(commentApp: CommentApp) {
+  const CommentDecorator = ({ contentState, children }: DecoratorProps) => {
+    // The comment decorator makes a comment clickable, allowing it to be focused.
+    // It does not provide styling, as draft-js imposes a 1 decorator/string limit, which would prevent comment highlights
+    // going over links/other entities
+    if (!children) {
+      return null;
+    }
+    const blockKey: BlockKey = children[0].props.block.getKey();
+    const start: number = children[0].props.start;
+
+    const commentId = useMemo(
+      () => parseInt(
+        contentState
+          .getBlockForKey(blockKey)
+          .getInlineStyleAt(start)
+          .find((style) => style !== undefined && style.startsWith(COMMENT_STYLE_IDENTIFIER))
+          .slice(8),
+        10),
+      [blockKey, start]
+    );
+    const annotationNode = useRef(null);
+    useEffect(() => {
+      // Add a ref to the annotation, allowing the comment to float alongside the attached text.
+      // This adds rather than sets the ref, so that a comment may be attached across paragraphs or around entities
+      const annotation = commentApp.layout.commentAnnotations.get(commentId);
+      if (annotation && annotation instanceof DraftailInlineAnnotation) {
+        annotation.addDecoratorRef(annotationNode, blockKey);
+        return () => annotation.removeDecoratorRef(annotationNode);
+      }
+      return undefined; // eslint demands an explicit return here
+    }, [commentId, annotationNode, blockKey]);
+    const onClick = () => {
+      // Ensure the comment will appear alongside the current block
+      const annotation = commentApp.layout.commentAnnotations.get(commentId);
+      if (annotation && annotation instanceof DraftailInlineAnnotation  && annotationNode) {
+        annotation.setFocusedBlockKey(blockKey);
+      }
+
+      // Pin and focus the clicked comment
+      commentApp.store.dispatch(
+        commentApp.actions.setFocusedComment(commentId, {
+          updatePinnedComment: true,
+        })
+      );
+    };
+    // TODO: determine the correct way to make this accessible, allowing both editing and focus jumps
+    return (
+      <span
+        role="button"
+        ref={annotationNode}
+        onClick={onClick}
+        data-annotation
+      >
+        {children}
+      </span>
+    );
+  };
+  return CommentDecorator;
+}
+
+function forceResetEditorState(editorState: EditorState, replacementContent: ContentState) {
+  const content = replacementContent || editorState.getCurrentContent();
+  const state = EditorState.set(
+    EditorState.createWithContent(content, editorState.getDecorator()),
+    {
+      selection: editorState.getSelection(),
+      undoStack: editorState.getUndoStack(),
+      redoStack: editorState.getRedoStack(),
+    }
+  );
+  return EditorState.acceptSelection(state, state.getSelection());
+}
+
+interface InlineStyle {
+  label?: string,
+  description?: string,
+  icon?: string | string[] | Node,
+  type: string,
+  style?: Record<string, string | number | ReactText | undefined >
+}
+
+interface CommentableEditorProps {
+  commentApp: CommentApp,
+  fieldNode: Element,
+  contentPath: string,
+  rawContentState: RawDraftContentState,
+  onSave: (rawContent: RawDraftContentState) => void,
+  inlineStyles: Array<InlineStyle>,
+  editorRef: MutableRefObject<HTMLInputElement>
+}
+
+function CommentableEditor({
+  commentApp,
+  fieldNode,
+  contentPath,
+  rawContentState,
+  onSave,
+  inlineStyles,
+  editorRef,
+  ...options
+}: CommentableEditorProps) {
+  const [editorState, setEditorState] = useState(() =>
+    createEditorStateFromRaw(rawContentState)
+  );
+  const CommentControl = useMemo(
+    () => getCommentControl(commentApp, contentPath, fieldNode),
+    [commentApp, contentPath, fieldNode]
+  );
+  const commentsSelector = useMemo(
+    () => commentApp.utils.selectCommentsForContentPathFactory(contentPath),
+    [contentPath, commentApp]
+  );
+  const CommentDecorator = useMemo(() => getCommentDecorator(commentApp), [
+    commentApp,
+  ]);
+  const comments = useSelector(commentsSelector, shallowEqual);
+  const enabled = useSelector(commentApp.selectors.selectEnabled);
+  const focusedId = useSelector(commentApp.selectors.selectFocused);
+
+  const ids = useMemo(() => comments.map((comment) => comment.localId), [
+    comments,
+  ]);
+
+  const commentStyles: Array<InlineStyle> = useMemo(
+    () =>
+      ids.map((id) => ({
+        type: `${COMMENT_STYLE_IDENTIFIER}${id}`,
+        style: enabled
+          ? {
+            'background-color': focusedId !== id ? '#01afb0' : '#007d7e',
+          }
+          : {},
+      })),
+    [ids, enabled, focusedId]
+  );
+
+  const [uniqueStyleId, setUniqueStyleId] = useState(0);
+
+  const previousFocused = usePrevious(focusedId);
+  const previousIds = usePrevious(ids);
+  const previousEnabled = usePrevious(enabled);
+  useEffect(() => {
+    // Only trigger a focus-related rerender if the current focused comment is inside the field, or the previous one was
+    const validFocusChange =
+      previousFocused !== focusedId &&
+      ((previousIds && previousIds.includes(previousFocused)) ||
+        ids.includes(focusedId));
+
+    if (
+      !validFocusChange &&
+      previousIds === ids &&
+      previousEnabled === enabled
+    ) {
+      return;
+    }
+
+    // Filter out any invalid styles - deleted comments, or now unneeded STYLE_RERENDER forcing styles
+    const filteredContent: ContentState = filterInlineStyles(
+      inlineStyles
+        .map((style) => style.type)
+        .concat(ids.map((id) => `${COMMENT_STYLE_IDENTIFIER}${id}`)),
+      editorState.getCurrentContent()
+    );
+    // Force reset the editor state to ensure redecoration, and apply a new (blank) inline style to force inline style rerender
+    // This must be entirely new for the rerender to trigger, hence the unique style id, as with the undo stack we cannot guarantee
+    // that a previous style won't persist without filtering everywhere, which seems a bit too heavyweight
+    // This hack can be removed when draft-js triggers inline style rerender on props change
+    setEditorState((state) =>
+      forceResetEditorState(
+        state,
+        Modifier.applyInlineStyle(
+          filteredContent,
+          getFullSelectionState(filteredContent),
+          `STYLE_RERENDER_${uniqueStyleId}`
+        )
+      )
+    );
+    setUniqueStyleId((id) => (id + 1) % 200);
+  }, [focusedId, enabled, inlineStyles, ids, editorState]);
+
+  const timeoutRef = useRef<number | undefined>();
+  useEffect(() => {
+    // This replicates the onSave logic in Draftail, but only saves the state with all
+    // comment styles filtered out
+    window.clearTimeout(timeoutRef.current);
+    const filteredEditorState = EditorState.push(
+      editorState,
+      filterInlineStyles(
+        inlineStyles.map((style) => style.type),
+        editorState.getCurrentContent()
+      ),
+      'change-inline-style'
+    );
+    timeoutRef.current = window.setTimeout(
+      () => onSave(serialiseEditorStateToRaw(filteredEditorState)),
+      250
+    );
+    return () => {
+      window.clearTimeout(timeoutRef.current);
+    };
+  }, [editorState, inlineStyles]);
+
+  return (
+    <DraftailEditor
+      ref={editorRef}
+      onChange={setEditorState}
+      editorState={editorState}
+      controls={enabled ? [CommentControl] : []}
+      decorators={
+        enabled
+          ? [
+            {
+              strategy: findCommentStyleRanges,
+              component: CommentDecorator,
+            },
+          ]
+          : []
+      }
+      inlineStyles={inlineStyles.concat(commentStyles)}
+      {...options}
+    />
+  );
+}
+
+export default CommentableEditor;

+ 3 - 0
client/src/components/Draftail/__snapshots__/index.test.js.snap

@@ -10,6 +10,7 @@ Object {
   "bottomToolbar": null,
   "controls": Array [],
   "decorators": Array [],
+  "editorState": null,
   "enableHorizontalRule": Object {
     "description": "Horizontal line",
   },
@@ -27,11 +28,13 @@ Object {
   "inlineStyles": Array [],
   "maxListNesting": 4,
   "onBlur": null,
+  "onChange": null,
   "onFocus": null,
   "onSave": [Function],
   "placeholder": "Write here…",
   "plugins": Array [],
   "rawContentState": null,
+  "readOnly": false,
   "showRedoControl": Object {
     "description": "Redo",
   },

+ 43 - 25
client/src/components/Draftail/index.js

@@ -1,6 +1,7 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
 import { DraftailEditor } from 'draftail';
+import { Provider } from 'react-redux';
 
 import { IS_IE11, STRINGS } from '../../config/wagtailConfig';
 
@@ -15,6 +16,9 @@ import ModalWorkflowSource from './sources/ModalWorkflowSource';
 import Tooltip from './Tooltip/Tooltip';
 import TooltipEntity from './decorators/TooltipEntity';
 import EditorFallback from './EditorFallback/EditorFallback';
+import CommentableEditor from './CommentableEditor/CommentableEditor';
+
+import comments from '../CommentApp/comments';
 
 // 1024x1024 SVG path rendering of the "↵" character, that renders badly in MS Edge.
 const BR_ICON = 'M.436 633.471l296.897-296.898v241.823h616.586V94.117h109.517v593.796H297.333v242.456z';
@@ -91,33 +95,47 @@ const initEditor = (selector, options, currentScript) => {
     field.draftailEditor = ref;
   };
 
-  const editor = (
-    <EditorFallback field={field}>
-      <DraftailEditor
-        ref={editorRef}
-        rawContentState={rawContentState}
-        onSave={serialiseInputValue}
-        placeholder={STRINGS.WRITE_HERE}
-        spellCheck={true}
-        enableLineBreak={{
-          description: STRINGS.LINE_BREAK,
-          icon: BR_ICON,
-        }}
-        showUndoControl={{ description: STRINGS.UNDO }}
-        showRedoControl={{ description: STRINGS.REDO }}
-        maxListNesting={4}
-        // Draft.js + IE 11 presents some issues with pasting rich text. Disable rich paste there.
-        stripPastedStyles={IS_IE11}
-        {...options}
-        blockTypes={blockTypes.map(wrapWagtailIcon)}
-        inlineStyles={inlineStyles.map(wrapWagtailIcon)}
-        entityTypes={entityTypes}
-        enableHorizontalRule={enableHorizontalRule}
+  const sharedProps = {
+    rawContentState: rawContentState,
+    onSave: serialiseInputValue,
+    placeholder: STRINGS.WRITE_HERE,
+    spellCheck: true,
+    enableLineBreak: {
+      description: STRINGS.LINE_BREAK,
+      icon: BR_ICON,
+    },
+    showUndoControl: { description: STRINGS.UNDO },
+    showRedoControl: { description: STRINGS.REDO },
+    maxListNesting: 4,
+    // Draft.js + IE 11 presents some issues with pasting rich text. Disable rich paste there.
+    stripPastedStyles: IS_IE11,
+    ...options,
+    blockTypes: blockTypes.map(wrapWagtailIcon),
+    inlineStyles: inlineStyles.map(wrapWagtailIcon),
+    entityTypes,
+    enableHorizontalRule
+  };
+
+  const contentPath = comments.getContentPath(field);
+
+  // If the field has a valid contentpath - ie is not an InlinePanel or under a ListBlock - and the comments system is initialized
+  // then use CommentableEditor, otherwise plain DraftailEditor
+  const editor = (window.commentApp && contentPath !== '') ?
+    <Provider store={window.commentApp.store}>
+      <CommentableEditor
+        editorRef={editorRef}
+        commentApp={window.commentApp}
+        fieldNode={field.parentNode}
+        contentPath={contentPath}
+        {...sharedProps}
       />
-    </EditorFallback>
-  );
+    </Provider>
+    : <DraftailEditor
+      ref={editorRef}
+      {...sharedProps}
+    />;
 
-  ReactDOM.render(editor, editorWrapper);
+  ReactDOM.render(<EditorFallback field={field}>{editor}</EditorFallback>, editorWrapper);
 };
 
 export default {

+ 0 - 1
client/src/components/Explorer/Explorer.tsx

@@ -15,7 +15,6 @@ interface ExplorerProps {
   currentPageId: number | null,
   nodes: NodeState,
   onClose(): void;
-  popPage(): void;
   gotoPage(id: number, transition: number): void;
 }
 

+ 2 - 0
client/src/entrypoints/admin/comments.js

@@ -5,3 +5,5 @@ import comments from '../../components/CommentApp/comments';
  */
 // Expose module as a global.
 window.comments = comments;
+
+comments.initComments();

+ 1 - 0
client/tests/stubs.js

@@ -51,6 +51,7 @@ global.wagtailConfig = {
     SAVING: 'Saving...',
     CANCEL: 'Cancel',
     DELETING: 'Deleting...',
+    ADD_A_COMMENT: 'Add a comment',
     SHOW_COMMENTS: 'Show comments',
     REPLY: 'Reply',
     RESOLVE: 'Resolve',

+ 237 - 112
package-lock.json

@@ -450,9 +450,9 @@
       }
     },
     "@eslint/eslintrc": {
-      "version": "0.1.3",
-      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.1.3.tgz",
-      "integrity": "sha512-4YVwPkANLeNtRjMekzux1ci8hIaH5eGKktGqR0d3LWsKNn5B2X/1Z6Trxy7jQXl9EBGE6Yj02O+t09FMeRllaA==",
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.0.tgz",
+      "integrity": "sha512-2ZPCc+uNbjV5ERJr+aKSPRwZgKd2z11x0EgLvb1PURmUrn9QNRXFqje0Ldq454PfAVyaJYyrDvvIKSFP4NnBog==",
       "dev": true,
       "requires": {
         "ajv": "^6.12.4",
@@ -462,15 +462,14 @@
         "ignore": "^4.0.6",
         "import-fresh": "^3.2.1",
         "js-yaml": "^3.13.1",
-        "lodash": "^4.17.19",
         "minimatch": "^3.0.4",
         "strip-json-comments": "^3.1.1"
       },
       "dependencies": {
         "debug": {
-          "version": "4.2.0",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz",
-          "integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==",
+          "version": "4.3.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+          "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
           "dev": true,
           "requires": {
             "ms": "2.1.2"
@@ -483,14 +482,6 @@
           "dev": true,
           "requires": {
             "type-fest": "^0.8.1"
-          },
-          "dependencies": {
-            "type-fest": {
-              "version": "0.8.1",
-              "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
-              "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
-              "dev": true
-            }
           }
         },
         "ignore": {
@@ -1119,6 +1110,16 @@
         "@types/node": "*"
       }
     },
+    "@types/draft-js": {
+      "version": "0.10.45",
+      "resolved": "https://registry.npmjs.org/@types/draft-js/-/draft-js-0.10.45.tgz",
+      "integrity": "sha512-ozmVQEI088kGQfY2XPB0b+YRNbkv1mxdxcLieBLCc2F4IkVWMs6mnIaEbBRExmM3H/Amm9CvhlmIYmDL4CjBvA==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*",
+        "immutable": "~3.7.4"
+      }
+    },
     "@types/eslint": {
       "version": "7.2.6",
       "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.2.6.tgz",
@@ -1153,6 +1154,16 @@
         "@types/node": "*"
       }
     },
+    "@types/hoist-non-react-statics": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
+      "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*",
+        "hoist-non-react-statics": "^3.3.0"
+      }
+    },
     "@types/istanbul-lib-coverage": {
       "version": "2.0.3",
       "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz",
@@ -1239,6 +1250,18 @@
         "csstype": "^3.0.2"
       }
     },
+    "@types/react-redux": {
+      "version": "7.1.16",
+      "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.16.tgz",
+      "integrity": "sha512-f/FKzIrZwZk7YEO9E1yoxIuDNRiDducxkFlkw/GNMGEnK9n4K8wJzlJBghpSuOVDgEUHoDkDF7Gi9lHNQR4siw==",
+      "dev": true,
+      "requires": {
+        "@types/hoist-non-react-statics": "^3.3.0",
+        "@types/react": "*",
+        "hoist-non-react-statics": "^3.3.0",
+        "redux": "^4.0.0"
+      }
+    },
     "@types/stack-utils": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.0.tgz",
@@ -2077,9 +2100,9 @@
       "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c="
     },
     "astral-regex": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz",
-      "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==",
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+      "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
       "dev": true
     },
     "async-done": {
@@ -4051,25 +4074,25 @@
       }
     },
     "draftail": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/draftail/-/draftail-1.2.1.tgz",
-      "integrity": "sha512-YL0QjfUxneOzwGaO1t66H47cFN82zGjrvw+0ERHpFGtUPjfrD7uCKlfXhPg1mOJgFFxuc44p7M5fUlrS1asBYg==",
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/draftail/-/draftail-1.3.0.tgz",
+      "integrity": "sha512-LOG4YgrJX4BMYJX5XrKGBegH9f/M21I8nFt+7MrPAB0ABXpURZhUVzVfWVa6hEdw6yOl2Z1T4H2G4OFTouK6Bg==",
       "requires": {
         "decorate-component-with-props": "^1.0.2",
         "draft-js-plugins-editor": "^2.1.1",
-        "draftjs-conductor": "^0.4.1",
+        "draftjs-conductor": "^1.0.0",
         "draftjs-filters": "^2.2.3"
       }
     },
     "draftjs-conductor": {
-      "version": "0.4.5",
-      "resolved": "https://registry.npmjs.org/draftjs-conductor/-/draftjs-conductor-0.4.5.tgz",
-      "integrity": "sha512-+sTQxDknS86aBIkii8EXHuyVoOi6L3EVXIf3LvXgVWfEs/v7KO4E2jyNg5t2lWDpmNzzurY9uIF7t1xkDzAjzA=="
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/draftjs-conductor/-/draftjs-conductor-1.2.0.tgz",
+      "integrity": "sha512-cQ2f/XSvFFmvWfYhEzzhAKyULVG4zmlH+NbL4mOVFkwujTW1AjI0AEG8m4zBnR2U59UnwGLRbiUVIKR51ArlVg=="
     },
     "draftjs-filters": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/draftjs-filters/-/draftjs-filters-2.3.0.tgz",
-      "integrity": "sha512-Yi4G3zbbJwrTxFCtCooXLuIeThrY4YFvRrrL3Ck+zYi1V1/3z+j+QXHE/tNa182eb7Tq7t0AxNKE+mOFlqG8tw=="
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/draftjs-filters/-/draftjs-filters-2.5.0.tgz",
+      "integrity": "sha512-ISJXDj+wQnEQ6krfGjW3B1mr4cd6GkRkDdc/IyCN8mGM/TwaSeb4CkCgnl+MTZsTY6UavhulXeHqRe2eNP1Wyg=="
     },
     "duplexer": {
       "version": "0.1.2",
@@ -4207,9 +4230,9 @@
       "integrity": "sha512-A8OG5SR/ij3SsJdWDJdkkSYUjQdCUx6APQXem0SaEePBSRg4eymGYwBkKo1Y6DU+af/Jn2dBQqDBvjnr9Vi8nQ=="
     },
     "emoji-regex": {
-      "version": "7.0.3",
-      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
-      "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
       "dev": true
     },
     "emojis-list": {
@@ -4594,13 +4617,13 @@
       }
     },
     "eslint": {
-      "version": "7.11.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.11.0.tgz",
-      "integrity": "sha512-G9+qtYVCHaDi1ZuWzBsOWo2wSwd70TXnU6UHA3cTYHp7gCTXZcpggWFoUVAMRarg68qtPoNfFbzPh+VdOgmwmw==",
+      "version": "7.22.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.22.0.tgz",
+      "integrity": "sha512-3VawOtjSJUQiiqac8MQc+w457iGLfuNGLFn8JmF051tTKbh5/x/0vlcEj8OgDCaw7Ysa2Jn8paGshV7x2abKXg==",
       "dev": true,
       "requires": {
-        "@babel/code-frame": "^7.0.0",
-        "@eslint/eslintrc": "^0.1.3",
+        "@babel/code-frame": "7.12.11",
+        "@eslint/eslintrc": "^0.4.0",
         "ajv": "^6.10.0",
         "chalk": "^4.0.0",
         "cross-spawn": "^7.0.2",
@@ -4610,13 +4633,13 @@
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^2.1.0",
         "eslint-visitor-keys": "^2.0.0",
-        "espree": "^7.3.0",
-        "esquery": "^1.2.0",
+        "espree": "^7.3.1",
+        "esquery": "^1.4.0",
         "esutils": "^2.0.2",
-        "file-entry-cache": "^5.0.1",
+        "file-entry-cache": "^6.0.1",
         "functional-red-black-tree": "^1.0.1",
         "glob-parent": "^5.0.0",
-        "globals": "^12.1.0",
+        "globals": "^13.6.0",
         "ignore": "^4.0.6",
         "import-fresh": "^3.0.0",
         "imurmurhash": "^0.1.4",
@@ -4624,7 +4647,7 @@
         "js-yaml": "^3.13.1",
         "json-stable-stringify-without-jsonify": "^1.0.1",
         "levn": "^0.4.1",
-        "lodash": "^4.17.19",
+        "lodash": "^4.17.21",
         "minimatch": "^3.0.4",
         "natural-compare": "^1.4.0",
         "optionator": "^0.9.1",
@@ -4633,11 +4656,20 @@
         "semver": "^7.2.1",
         "strip-ansi": "^6.0.0",
         "strip-json-comments": "^3.1.0",
-        "table": "^5.2.3",
+        "table": "^6.0.4",
         "text-table": "^0.2.0",
         "v8-compile-cache": "^2.0.3"
       },
       "dependencies": {
+        "@babel/code-frame": {
+          "version": "7.12.11",
+          "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
+          "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==",
+          "dev": true,
+          "requires": {
+            "@babel/highlight": "^7.10.4"
+          }
+        },
         "ansi-styles": {
           "version": "4.3.0",
           "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -4684,21 +4716,30 @@
           }
         },
         "debug": {
-          "version": "4.2.0",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz",
-          "integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==",
+          "version": "4.3.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+          "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
           "dev": true,
           "requires": {
             "ms": "2.1.2"
           }
         },
+        "file-entry-cache": {
+          "version": "6.0.1",
+          "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+          "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+          "dev": true,
+          "requires": {
+            "flat-cache": "^3.0.4"
+          }
+        },
         "globals": {
-          "version": "12.4.0",
-          "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz",
-          "integrity": "sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==",
+          "version": "13.7.0",
+          "resolved": "https://registry.npmjs.org/globals/-/globals-13.7.0.tgz",
+          "integrity": "sha512-Aipsz6ZKRxa/xQkZhNg0qIWXT6x6rD46f6x/PCnBomlttdIyAPak4YD9jTmKpZ72uROSMU87qJtcgpgHaVchiA==",
           "dev": true,
           "requires": {
-            "type-fest": "^0.8.1"
+            "type-fest": "^0.20.2"
           }
         },
         "has-flag": {
@@ -4713,6 +4754,12 @@
           "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
           "dev": true
         },
+        "lodash": {
+          "version": "4.17.21",
+          "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+          "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+          "dev": true
+        },
         "ms": {
           "version": "2.1.2",
           "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -4726,10 +4773,13 @@
           "dev": true
         },
         "semver": {
-          "version": "7.3.2",
-          "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz",
-          "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==",
-          "dev": true
+          "version": "7.3.4",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz",
+          "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==",
+          "dev": true,
+          "requires": {
+            "lru-cache": "^6.0.0"
+          }
         },
         "shebang-command": {
           "version": "2.0.0",
@@ -4755,6 +4805,12 @@
             "has-flag": "^4.0.0"
           }
         },
+        "type-fest": {
+          "version": "0.20.2",
+          "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+          "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+          "dev": true
+        },
         "which": {
           "version": "2.0.2",
           "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -5042,22 +5098,16 @@
       "dev": true
     },
     "espree": {
-      "version": "7.3.0",
-      "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.0.tgz",
-      "integrity": "sha512-dksIWsvKCixn1yrEXO8UosNSxaDoSYpq9reEjZSbHLpT5hpaCAKTLBwq0RHtLrIr+c0ByiYzWT8KTMRzoRCNlw==",
+      "version": "7.3.1",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz",
+      "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==",
       "dev": true,
       "requires": {
         "acorn": "^7.4.0",
-        "acorn-jsx": "^5.2.0",
+        "acorn-jsx": "^5.3.1",
         "eslint-visitor-keys": "^1.3.0"
       },
       "dependencies": {
-        "acorn": {
-          "version": "7.4.1",
-          "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
-          "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
-          "dev": true
-        },
         "eslint-visitor-keys": {
           "version": "1.3.0",
           "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
@@ -5072,9 +5122,9 @@
       "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
     },
     "esquery": {
-      "version": "1.3.1",
-      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.3.1.tgz",
-      "integrity": "sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ==",
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
+      "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
       "dev": true,
       "requires": {
         "estraverse": "^5.1.0"
@@ -5407,6 +5457,12 @@
         "time-stamp": "^1.0.0"
       }
     },
+    "fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true
+    },
     "fast-glob": {
       "version": "3.2.4",
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.4.tgz",
@@ -5798,6 +5854,33 @@
       "integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==",
       "dev": true
     },
+    "flat-cache": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+      "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+      "dev": true,
+      "requires": {
+        "flatted": "^3.1.0",
+        "rimraf": "^3.0.2"
+      },
+      "dependencies": {
+        "flatted": {
+          "version": "3.1.1",
+          "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.1.1.tgz",
+          "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==",
+          "dev": true
+        },
+        "rimraf": {
+          "version": "3.0.2",
+          "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+          "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+          "dev": true,
+          "requires": {
+            "glob": "^7.1.3"
+          }
+        }
+      }
+    },
     "flatted": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz",
@@ -7522,6 +7605,12 @@
       "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
       "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI="
     },
+    "is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true
+    },
     "is-generator-fn": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz",
@@ -9924,6 +10013,15 @@
         "js-tokens": "^3.0.0 || ^4.0.0"
       }
     },
+    "lru-cache": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+      "dev": true,
+      "requires": {
+        "yallist": "^4.0.0"
+      }
+    },
     "lru-queue": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz",
@@ -13114,6 +13212,12 @@
       "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
       "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I="
     },
+    "require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true
+    },
     "require-main-filename": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz",
@@ -13603,20 +13707,38 @@
       "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="
     },
     "slice-ansi": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz",
-      "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==",
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+      "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
       "dev": true,
       "requires": {
-        "ansi-styles": "^3.2.0",
-        "astral-regex": "^1.0.0",
-        "is-fullwidth-code-point": "^2.0.0"
+        "ansi-styles": "^4.0.0",
+        "astral-regex": "^2.0.0",
+        "is-fullwidth-code-point": "^3.0.0"
       },
       "dependencies": {
-        "is-fullwidth-code-point": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
-          "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
+        "ansi-styles": {
+          "version": "4.3.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
           "dev": true
         }
       }
@@ -14043,37 +14165,14 @@
       }
     },
     "string-width": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
-      "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz",
+      "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==",
       "dev": true,
       "requires": {
-        "emoji-regex": "^7.0.1",
-        "is-fullwidth-code-point": "^2.0.0",
-        "strip-ansi": "^5.1.0"
-      },
-      "dependencies": {
-        "ansi-regex": {
-          "version": "4.1.0",
-          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
-          "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
-          "dev": true
-        },
-        "is-fullwidth-code-point": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
-          "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
-          "dev": true
-        },
-        "strip-ansi": {
-          "version": "5.2.0",
-          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
-          "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
-          "dev": true,
-          "requires": {
-            "ansi-regex": "^4.1.0"
-          }
-        }
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.0"
       }
     },
     "string.prototype.padend": {
@@ -14704,15 +14803,35 @@
       "integrity": "sha512-nOWwx35/JuDI4ONuF0ZTo6lYvI0fY0tZCH1ErzY2EXfu4az50ZyiUX8X073FLiZtmWUVlkRnuXsehjJgCw9tYg=="
     },
     "table": {
-      "version": "5.4.6",
-      "resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz",
-      "integrity": "sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==",
+      "version": "6.0.7",
+      "resolved": "https://registry.npmjs.org/table/-/table-6.0.7.tgz",
+      "integrity": "sha512-rxZevLGTUzWna/qBLObOe16kB2RTnnbhciwgPbMMlazz1yZGVEgnZK762xyVdVznhqxrfCeBMmMkgOOaPwjH7g==",
       "dev": true,
       "requires": {
-        "ajv": "^6.10.2",
-        "lodash": "^4.17.14",
-        "slice-ansi": "^2.1.0",
-        "string-width": "^3.0.0"
+        "ajv": "^7.0.2",
+        "lodash": "^4.17.20",
+        "slice-ansi": "^4.0.0",
+        "string-width": "^4.2.0"
+      },
+      "dependencies": {
+        "ajv": {
+          "version": "7.2.1",
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-7.2.1.tgz",
+          "integrity": "sha512-+nu0HDv7kNSOua9apAVc979qd932rrZeb3WOvoiD31A/p1mIE5/9bN2027pE2rOPYEdS3UHzsvof4hY+lM9/WQ==",
+          "dev": true,
+          "requires": {
+            "fast-deep-equal": "^3.1.1",
+            "json-schema-traverse": "^1.0.0",
+            "require-from-string": "^2.0.2",
+            "uri-js": "^4.2.2"
+          }
+        },
+        "json-schema-traverse": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+          "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+          "dev": true
+        }
       }
     },
     "tapable": {
@@ -16716,6 +16835,12 @@
       "integrity": "sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==",
       "dev": true
     },
+    "yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+      "dev": true
+    },
     "yaml": {
       "version": "1.10.0",
       "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz",

+ 5 - 2
package.json

@@ -48,7 +48,9 @@
     ]
   },
   "devDependencies": {
+    "@types/draft-js": "^0.10.45",
     "@types/react": "^16.9.53",
+    "@types/react-redux": "^7.1.16",
     "@typescript-eslint/eslint-plugin": "^4.5.0",
     "@typescript-eslint/parser": "^4.5.0",
     "@wagtail/stylelint-config-wagtail": "^0.1.1",
@@ -58,7 +60,7 @@
     "enzyme": "^3.9.0",
     "enzyme-adapter-react-16": "^1.9.1",
     "enzyme-to-json": "^3.3.0",
-    "eslint": "^7.11.0",
+    "eslint": "^7.20.0",
     "eslint-config-wagtail": "^0.1.1",
     "eslint-import-resolver-webpack": "^0.8.1",
     "eslint-plugin-import": "^1.8.1",
@@ -88,7 +90,8 @@
   "dependencies": {
     "core-js": "^2.5.3",
     "draft-js": "0.10.5",
-    "draftail": "^1.2.1",
+    "draftail": "^1.3.0",
+    "draftjs-filters": "^2.5.0",
     "element-closest": "^2.0.2",
     "focus-trap-react": "^3.1.0",
     "postcss-calc": "^7.0.5",

+ 2 - 7
wagtail/admin/edit_handlers.py

@@ -826,7 +826,6 @@ class CommentPanel(EditHandler):
         }
 
     template = "wagtailadmin/edit_handlers/comments/comment_panel.html"
-    js_template = "wagtailadmin/edit_handlers/comments/comment_panel.js"
     declarations_template = "wagtailadmin/edit_handlers/comments/comment_declarations.html"
 
     def html_declarations(self):
@@ -867,15 +866,11 @@ class CommentPanel(EditHandler):
 
     def render(self):
         panel = render_to_string(self.template, self.get_context())
-        js = "window.comments.initComments()"
-        return widget_with_script(panel, js)
-
-    def render_js_init(self):
-        return mark_safe(render_to_string(self.js_template, {}))
-
+        return panel
 
 # Now that we've defined EditHandlers, we can set up wagtailcore.Page to have some.
 
+
 Page.content_panels = [
     CommentPanel(),
     FieldPanel('title', classname="full title"),

+ 1 - 0
wagtail/admin/localization.py

@@ -83,6 +83,7 @@ def get_js_translation_strings():
         'SAVING': _('Saving...'),
         'CANCEL': _('Cancel'),
         'DELETING': _('Deleting...'),
+        'ADD_A_COMMENT': _('Add a comment'),
         'SHOW_COMMENTS': _('Show comments'),
         'REPLY': _('Reply'),
         'RESOLVE': _('Resolve'),