CommentableEditor.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. import type { CommentApp } from '../../CommentApp/main';
  2. import type { Annotation } from '../../CommentApp/utils/annotation';
  3. import type { Comment } from '../../CommentApp/state/comments';
  4. import {
  5. DraftailEditor,
  6. ToolbarButton,
  7. createEditorStateFromRaw,
  8. serialiseEditorStateToRaw,
  9. } from 'draftail';
  10. import {
  11. CharacterMetadata,
  12. ContentBlock,
  13. ContentState,
  14. DraftInlineStyle,
  15. EditorState,
  16. Modifier,
  17. RawDraftContentState,
  18. RichUtils,
  19. SelectionState
  20. } from 'draft-js';
  21. import type { DraftEditorLeaf } from 'draft-js/lib/DraftEditorLeaf.react';
  22. import { filterInlineStyles } from 'draftjs-filters';
  23. import React, { MutableRefObject, ReactText, useEffect, useMemo, useRef, useState } from 'react';
  24. import { useSelector, shallowEqual } from 'react-redux';
  25. import { STRINGS } from '../../../config/wagtailConfig';
  26. import Icon from '../../Icon/Icon';
  27. const COMMENT_STYLE_IDENTIFIER = 'COMMENT-';
  28. function usePrevious<Type>(value: Type) {
  29. const ref = useRef(value);
  30. useEffect(() => {
  31. ref.current = value;
  32. }, [value]);
  33. return ref.current;
  34. }
  35. type DecoratorRef = MutableRefObject<HTMLSpanElement | null>;
  36. type BlockKey = string;
  37. /**
  38. * Controls the positioning of a comment that has been added to Draftail.
  39. * `getDesiredPosition` is called by the comments app to determine the height
  40. * at which to float the comment.
  41. */
  42. class DraftailInlineAnnotation implements Annotation {
  43. /**
  44. * Create an inline annotation
  45. * @param {Element} field - an element to provide the fallback position for comments without any inline decorators
  46. */
  47. field: Element
  48. decoratorRefs: Map<DecoratorRef, BlockKey>
  49. focusedBlockKey: BlockKey
  50. cachedMedianRef: DecoratorRef | null
  51. constructor(field: Element) {
  52. this.field = field;
  53. this.decoratorRefs = new Map();
  54. this.focusedBlockKey = '';
  55. this.cachedMedianRef = null;
  56. }
  57. addDecoratorRef(ref: DecoratorRef, blockKey: BlockKey) {
  58. this.decoratorRefs.set(ref, blockKey);
  59. // We're adding a ref, so remove the cached median refs - this needs to be recalculated
  60. this.cachedMedianRef = null;
  61. }
  62. removeDecoratorRef(ref: DecoratorRef) {
  63. this.decoratorRefs.delete(ref);
  64. // We're deleting a ref, so remove the cached median refs - this needs to be recalculated
  65. this.cachedMedianRef = null;
  66. }
  67. setFocusedBlockKey(blockKey: BlockKey) {
  68. this.focusedBlockKey = blockKey;
  69. }
  70. static getHeightForRef(ref: DecoratorRef) {
  71. if (ref.current) {
  72. return ref.current.getBoundingClientRect().top;
  73. }
  74. return 0;
  75. }
  76. static getMedianRef(refArray: Array<DecoratorRef>) {
  77. const refs = refArray.sort(
  78. (firstRef, secondRef) => this.getHeightForRef(firstRef) - this.getHeightForRef(secondRef)
  79. );
  80. const length = refs.length;
  81. if (length > 0) {
  82. return refs[Math.ceil(length / 2 - 1)];
  83. }
  84. return null;
  85. }
  86. getDesiredPosition(focused = false) {
  87. // The comment should always aim to float by an annotation, rather than between them
  88. // so calculate which annotation is the median one by height and float the comment by that
  89. let medianRef: null | DecoratorRef = null;
  90. if (focused) {
  91. // If the comment is focused, calculate the median of refs only
  92. // within the focused block, to ensure the comment is visisble
  93. // if the highlight has somehow been split up
  94. medianRef = DraftailInlineAnnotation.getMedianRef(
  95. Array.from(this.decoratorRefs.keys()).filter(
  96. (ref) => this.decoratorRefs.get(ref) === this.focusedBlockKey
  97. )
  98. );
  99. } else if (!this.cachedMedianRef) {
  100. // Our cache is empty - try to update it
  101. medianRef = DraftailInlineAnnotation.getMedianRef(
  102. Array.from(this.decoratorRefs.keys())
  103. );
  104. this.cachedMedianRef = medianRef;
  105. } else {
  106. // Use the cached median refs
  107. medianRef = this.cachedMedianRef;
  108. }
  109. if (medianRef) {
  110. // We have a median ref - calculate its height
  111. return (
  112. DraftailInlineAnnotation.getHeightForRef(medianRef) +
  113. document.documentElement.scrollTop
  114. );
  115. }
  116. const fieldNode = this.field;
  117. if (fieldNode) {
  118. // Fallback to the field node, if the comment has no decorator refs
  119. return (
  120. fieldNode.getBoundingClientRect().top +
  121. document.documentElement.scrollTop
  122. );
  123. }
  124. return 0;
  125. }
  126. }
  127. function applyInlineStyleToRange({ contentState, style, blockKey, start, end }:
  128. {contentState: ContentState,
  129. style: string,
  130. blockKey: BlockKey,
  131. start: number,
  132. end: number}
  133. ) {
  134. return Modifier.applyInlineStyle(contentState,
  135. new SelectionState({
  136. anchorKey: blockKey,
  137. anchorOffset: start,
  138. focusKey: blockKey,
  139. focusOffset: end
  140. }),
  141. style
  142. );
  143. }
  144. /**
  145. * Get a selection state corresponding to the full contentState.
  146. */
  147. function getFullSelectionState(contentState: ContentState) {
  148. const lastBlock = contentState.getLastBlock();
  149. return new SelectionState({
  150. anchorKey: contentState.getFirstBlock().getKey(),
  151. anchorOffset: 0,
  152. focusKey: lastBlock.getKey(),
  153. focusOffset: lastBlock.getLength()
  154. });
  155. }
  156. interface ControlProps {
  157. getEditorState: () => EditorState,
  158. onChange: (editorState: EditorState) => void
  159. }
  160. function getCommentControl(commentApp: CommentApp, contentPath: string, fieldNode: Element) {
  161. return ({ getEditorState, onChange }: ControlProps) => (
  162. <ToolbarButton
  163. name="comment"
  164. active={false}
  165. title={STRINGS.ADD_A_COMMENT}
  166. icon={<Icon name="comment" />}
  167. onClick={() => {
  168. const annotation = new DraftailInlineAnnotation(fieldNode);
  169. const commentId = commentApp.makeComment(annotation, contentPath, '[]');
  170. onChange(
  171. RichUtils.toggleInlineStyle(
  172. getEditorState(),
  173. `${COMMENT_STYLE_IDENTIFIER}${commentId}`
  174. )
  175. );
  176. }}
  177. />
  178. );
  179. }
  180. function styleIsComment(style: string | undefined): style is string {
  181. return style !== undefined && style.startsWith(COMMENT_STYLE_IDENTIFIER);
  182. }
  183. function getIdForCommentStyle(style: string) {
  184. return parseInt(style.slice(COMMENT_STYLE_IDENTIFIER.length), 10);
  185. }
  186. function findCommentStyleRanges(
  187. contentBlock: ContentBlock,
  188. callback: (start: number, end: number) => void,
  189. filterFn?: (metadata: CharacterMetadata) => boolean) {
  190. // Find comment style ranges that do not overlap an existing entity
  191. const filterFunction = filterFn || ((metadata: CharacterMetadata) => metadata.getStyle().some(styleIsComment));
  192. const entityRanges: Array<[number, number]> = [];
  193. contentBlock.findEntityRanges(
  194. character => character.getEntity() !== null,
  195. (start, end) => entityRanges.push([start, end])
  196. );
  197. contentBlock.findStyleRanges(
  198. filterFunction,
  199. (start, end) => {
  200. const interferingEntityRanges = entityRanges.filter(value => value[1] > start).filter(value => value[0] < end);
  201. let currentPosition = start;
  202. interferingEntityRanges.forEach((value) => {
  203. const [entityStart, entityEnd] = value;
  204. if (entityStart > currentPosition) {
  205. callback(currentPosition, entityStart);
  206. }
  207. currentPosition = entityEnd;
  208. });
  209. if (currentPosition < end) {
  210. callback(start, end);
  211. }
  212. }
  213. );
  214. }
  215. function updateCommentPositions({ editorState, comments, commentApp }:
  216. {
  217. editorState: EditorState,
  218. comments: Array<Comment>,
  219. commentApp: CommentApp
  220. }) {
  221. // Construct a map of comment id -> array of style ranges
  222. const commentPositions = new Map();
  223. editorState.getCurrentContent().getBlocksAsArray().forEach(
  224. (block) => {
  225. const key = block.getKey();
  226. block.findStyleRanges((metadata) => metadata.getStyle().some(styleIsComment),
  227. (start, end) => {
  228. block.getInlineStyleAt(start).filter(styleIsComment).forEach(
  229. (style) => {
  230. // We have already filtered out any undefined styles, so cast here
  231. const id = getIdForCommentStyle(style as string);
  232. let existingPosition = commentPositions.get(id);
  233. if (!existingPosition) {
  234. existingPosition = [];
  235. }
  236. existingPosition.push({
  237. key: key,
  238. start: start,
  239. end: end
  240. });
  241. commentPositions.set(id, existingPosition);
  242. }
  243. );
  244. });
  245. }
  246. );
  247. comments.filter(comment => comment.annotation).forEach((comment) => {
  248. // if a comment has an annotation - ie the field has it inserted - update its position
  249. const newPosition = commentPositions.get(comment.localId);
  250. const serializedNewPosition = newPosition ? JSON.stringify(newPosition) : '[]';
  251. if (comment.position !== serializedNewPosition) {
  252. commentApp.store.dispatch(
  253. commentApp.actions.updateComment(
  254. comment.localId,
  255. { position: serializedNewPosition }
  256. )
  257. );
  258. }
  259. });
  260. }
  261. interface DecoratorProps {
  262. contentState: ContentState,
  263. children?: Array<DraftEditorLeaf>
  264. }
  265. function getCommentDecorator(commentApp: CommentApp) {
  266. const CommentDecorator = ({ contentState, children }: DecoratorProps) => {
  267. // The comment decorator makes a comment clickable, allowing it to be focused.
  268. // It does not provide styling, as draft-js imposes a 1 decorator/string limit,
  269. // which would prevent comment highlights going over links/other entities
  270. if (!children) {
  271. return null;
  272. }
  273. const blockKey: BlockKey = children[0].props.block.getKey();
  274. const start: number = children[0].props.start;
  275. const commentId = useMemo(
  276. () => {
  277. const block = contentState.getBlockForKey(blockKey);
  278. const styles = block.getInlineStyleAt(start).filter(styleIsComment) as Immutable.OrderedSet<string>;
  279. let styleToUse: string;
  280. if (styles.count() > 1) {
  281. // We're dealing with overlapping comments.
  282. // Find the least frequently occurring style and use that - this isn't foolproof, but in
  283. // most cases should ensure that all comments have at least one clickable section. This
  284. // logic is a bit heavier than ideal for a decorator given how often we are forced to
  285. // redecorate, but will only be used on overlapping comments
  286. // Use of casting in this function is due to issue #1563 in immutable-js, which causes operations like
  287. // map and filter to lose type information on the results. It should be fixed in v4: when we upgrade,
  288. // this casting should be removed
  289. let styleFreq = styles.map((style) => {
  290. let counter = 0;
  291. findCommentStyleRanges(block,
  292. () => { counter = counter + 1; },
  293. (metadata) => metadata.getStyle().some(rangeStyle => rangeStyle === style)
  294. );
  295. return [style, counter];
  296. }) as unknown as Immutable.OrderedSet<[string, number]>;
  297. styleFreq = styleFreq.sort(
  298. (firstStyleCount, secondStyleCount) => firstStyleCount[1] - secondStyleCount[1]
  299. ) as Immutable.OrderedSet<[string, number]>;
  300. styleToUse = styleFreq.first()[0];
  301. } else {
  302. styleToUse = styles.first();
  303. }
  304. return getIdForCommentStyle(styleToUse);
  305. }, [blockKey, start]);
  306. const annotationNode = useRef(null);
  307. useEffect(() => {
  308. // Add a ref to the annotation, allowing the comment to float alongside the attached text.
  309. // This adds rather than sets the ref, so that a comment may be attached across paragraphs or around entities
  310. const annotation = commentApp.layout.commentAnnotations.get(commentId);
  311. if (annotation && annotation instanceof DraftailInlineAnnotation) {
  312. annotation.addDecoratorRef(annotationNode, blockKey);
  313. return () => annotation.removeDecoratorRef(annotationNode);
  314. }
  315. return undefined; // eslint demands an explicit return here
  316. }, [commentId, annotationNode, blockKey]);
  317. const onClick = () => {
  318. // Ensure the comment will appear alongside the current block
  319. const annotation = commentApp.layout.commentAnnotations.get(commentId);
  320. if (annotation && annotation instanceof DraftailInlineAnnotation && annotationNode) {
  321. annotation.setFocusedBlockKey(blockKey);
  322. }
  323. // Pin and focus the clicked comment
  324. commentApp.store.dispatch(
  325. commentApp.actions.setFocusedComment(commentId, {
  326. updatePinnedComment: true,
  327. })
  328. );
  329. };
  330. // TODO: determine the correct way to make this accessible, allowing both editing and focus jumps
  331. return (
  332. <span
  333. role="button"
  334. ref={annotationNode}
  335. onClick={onClick}
  336. data-annotation
  337. >
  338. {children}
  339. </span>
  340. );
  341. };
  342. return CommentDecorator;
  343. }
  344. function forceResetEditorState(editorState: EditorState, replacementContent: ContentState) {
  345. const content = replacementContent || editorState.getCurrentContent();
  346. const state = EditorState.set(
  347. EditorState.createWithContent(content, editorState.getDecorator()),
  348. {
  349. selection: editorState.getSelection(),
  350. undoStack: editorState.getUndoStack(),
  351. redoStack: editorState.getRedoStack(),
  352. }
  353. );
  354. return EditorState.acceptSelection(state, state.getSelection());
  355. }
  356. interface InlineStyle {
  357. label?: string,
  358. description?: string,
  359. icon?: string | string[] | Node,
  360. type: string,
  361. style?: Record<string, string | number | ReactText | undefined >
  362. }
  363. interface ColorConfigProp {
  364. standardHighlight: string,
  365. overlappingHighlight: string,
  366. focusedHighlight: string
  367. }
  368. interface CommentableEditorProps {
  369. commentApp: CommentApp,
  370. fieldNode: Element,
  371. contentPath: string,
  372. rawContentState: RawDraftContentState,
  373. onSave: (rawContent: RawDraftContentState) => void,
  374. inlineStyles: Array<InlineStyle>,
  375. editorRef: MutableRefObject<HTMLInputElement>
  376. colorConfig: ColorConfigProp
  377. }
  378. function CommentableEditor({
  379. commentApp,
  380. fieldNode,
  381. contentPath,
  382. rawContentState,
  383. onSave,
  384. inlineStyles,
  385. editorRef,
  386. colorConfig: { standardHighlight, overlappingHighlight, focusedHighlight },
  387. ...options
  388. }: CommentableEditorProps) {
  389. const [editorState, setEditorState] = useState(() =>
  390. createEditorStateFromRaw(rawContentState)
  391. );
  392. const CommentControl = useMemo(
  393. () => getCommentControl(commentApp, contentPath, fieldNode),
  394. [commentApp, contentPath, fieldNode]
  395. );
  396. const commentsSelector = useMemo(
  397. () => commentApp.utils.selectCommentsForContentPathFactory(contentPath),
  398. [contentPath, commentApp]
  399. );
  400. const CommentDecorator = useMemo(() => getCommentDecorator(commentApp), [
  401. commentApp,
  402. ]);
  403. const comments = useSelector(commentsSelector, shallowEqual);
  404. const enabled = useSelector(commentApp.selectors.selectEnabled);
  405. const focusedId = useSelector(commentApp.selectors.selectFocused);
  406. const ids = useMemo(() => comments.map((comment) => comment.localId), [
  407. comments,
  408. ]);
  409. const commentStyles: Array<InlineStyle> = useMemo(
  410. () =>
  411. ids.map((id) => ({
  412. type: `${COMMENT_STYLE_IDENTIFIER}${id}`
  413. })),
  414. [ids]
  415. );
  416. const [uniqueStyleId, setUniqueStyleId] = useState(0);
  417. const previousFocused = usePrevious(focusedId);
  418. const previousIds = usePrevious(ids);
  419. const previousEnabled = usePrevious(enabled);
  420. useEffect(() => {
  421. // Only trigger a focus-related rerender if the current focused comment is inside the field, or the previous one was
  422. const validFocusChange =
  423. previousFocused !== focusedId &&
  424. ((previousFocused && previousIds && previousIds.includes(previousFocused)) ||
  425. focusedId && ids.includes(focusedId));
  426. if (
  427. !validFocusChange &&
  428. previousIds === ids &&
  429. previousEnabled === enabled
  430. ) {
  431. return;
  432. }
  433. // Filter out any invalid styles - deleted comments, or now unneeded STYLE_RERENDER forcing styles
  434. const filteredContent: ContentState = filterInlineStyles(
  435. inlineStyles
  436. .map((style) => style.type)
  437. .concat(ids.map((id) => `${COMMENT_STYLE_IDENTIFIER}${id}`)),
  438. editorState.getCurrentContent()
  439. );
  440. // Force reset the editor state to ensure redecoration, and apply a new (blank) inline style to force
  441. // inline style rerender. This must be entirely new for the rerender to trigger, hence the unique
  442. // style id, as with the undo stack we cannot guarantee that a previous style won't persist without
  443. // filtering everywhere, which seems a bit too heavyweight.
  444. // This hack can be removed when draft-js triggers inline style rerender on props change
  445. setEditorState((state) =>
  446. forceResetEditorState(
  447. state,
  448. Modifier.applyInlineStyle(
  449. filteredContent,
  450. getFullSelectionState(filteredContent),
  451. `STYLE_RERENDER_${uniqueStyleId}`
  452. )
  453. )
  454. );
  455. setUniqueStyleId((id) => (id + 1) % 200);
  456. }, [focusedId, enabled, inlineStyles, ids, editorState]);
  457. useEffect(() => {
  458. // if there are any comments without annotations, we need to add them to the EditorState
  459. let contentState = editorState.getCurrentContent();
  460. let hasUpdated = false;
  461. comments.filter(comment => !comment.annotation).forEach((comment) => {
  462. commentApp.updateAnnotation(new DraftailInlineAnnotation(fieldNode), comment.localId);
  463. const style = `${COMMENT_STYLE_IDENTIFIER}${comment.localId}`;
  464. try {
  465. const positions = JSON.parse(comment.position);
  466. positions.forEach((position) => {
  467. contentState = applyInlineStyleToRange({
  468. contentState,
  469. blockKey: position.key,
  470. start: position.start,
  471. end: position.end,
  472. style
  473. });
  474. hasUpdated = true;
  475. });
  476. } catch (err) {
  477. console.error(`Error loading comment position for comment ${comment.localId}`);
  478. console.error(err);
  479. }
  480. });
  481. if (hasUpdated) {
  482. setEditorState(forceResetEditorState(editorState, contentState));
  483. }
  484. }, [comments]);
  485. const timeoutRef = useRef<number | undefined>();
  486. useEffect(() => {
  487. // This replicates the onSave logic in Draftail, but only saves the state with all
  488. // comment styles filtered out
  489. window.clearTimeout(timeoutRef.current);
  490. const filteredEditorState = EditorState.push(
  491. editorState,
  492. filterInlineStyles(
  493. inlineStyles.map((style) => style.type),
  494. editorState.getCurrentContent()
  495. ),
  496. 'change-inline-style'
  497. );
  498. timeoutRef.current = window.setTimeout(
  499. () => {
  500. onSave(serialiseEditorStateToRaw(filteredEditorState));
  501. // Next, update comment positions in the redux store
  502. updateCommentPositions({ editorState, comments, commentApp });
  503. },
  504. 250
  505. );
  506. return () => {
  507. window.clearTimeout(timeoutRef.current);
  508. };
  509. }, [editorState, inlineStyles]);
  510. return (
  511. <DraftailEditor
  512. ref={editorRef}
  513. onChange={(state: EditorState) => {
  514. let newEditorState = state;
  515. if (['undo', 'redo'].includes(state.getLastChangeType())) {
  516. const filteredContent = filterInlineStyles(
  517. inlineStyles
  518. .map(style => style.type)
  519. .concat(ids.map(id => `${COMMENT_STYLE_IDENTIFIER}${id}`)),
  520. state.getCurrentContent()
  521. );
  522. newEditorState = forceResetEditorState(state, filteredContent);
  523. }
  524. setEditorState(newEditorState);
  525. }}
  526. editorState={editorState}
  527. controls={enabled ? [CommentControl] : []}
  528. decorators={
  529. enabled
  530. ? [
  531. {
  532. strategy: (
  533. block: ContentBlock, callback: (start: number, end: number) => void
  534. ) => findCommentStyleRanges(block, callback),
  535. component: CommentDecorator,
  536. },
  537. ]
  538. : []
  539. }
  540. inlineStyles={inlineStyles.concat(commentStyles)}
  541. plugins={enabled ? [{
  542. customStyleFn: (styleSet: DraftInlineStyle) => {
  543. // Use of casting in this function is due to issue #1563 in immutable-js, which causes operations like
  544. // map and filter to lose type information on the results. It should be fixed in v4: when we upgrade,
  545. // this casting should be removed
  546. const localCommentStyles = styleSet.filter(styleIsComment) as Immutable.OrderedSet<string>;
  547. const numStyles = localCommentStyles.count();
  548. if (numStyles > 0) {
  549. // There is at least one comment in the range
  550. const commentIds = localCommentStyles.map(
  551. style => getIdForCommentStyle(style as string)
  552. ) as unknown as Immutable.OrderedSet<number>;
  553. let background = standardHighlight;
  554. if (focusedId && commentIds.has(focusedId)) {
  555. // Use the focused colour if one of the comments is focused
  556. background = focusedHighlight;
  557. } else if (numStyles > 1) {
  558. // Otherwise if we're in a region with overlapping comments, use a slightly darker colour than usual
  559. // to indicate that
  560. background = overlappingHighlight;
  561. }
  562. return {
  563. 'background-color': background
  564. };
  565. }
  566. return undefined;
  567. }
  568. }] : []
  569. }
  570. {...options}
  571. />
  572. );
  573. }
  574. export default CommentableEditor;